Found and fixed a bug where Cthulhu could get stuck in a loop sometimes on pages with tooltips.
This commit is contained in:
1 parent
90f1411405
commit
58e4b8aa15
6 files changed
+236
-2
No files matched your search
@@ -288,6 +288,13 @@ line. Left/Right, Ctrl+Left/Right, and Home/End review that label by character,
|
||||
word, or boundary, including links with no native text or only a numeric badge.
|
||||
The label's review position remains separate from the browser's text caret.
|
||||
|
||||
Browse arrows, word navigation and Read All skip non-focusable, non-editable
|
||||
tooltips and their contents. This avoids repeatedly entering a tooltip that
|
||||
disappears when native caret placement moves focus away from its owner.
|
||||
Focusable or editable tooltip contents remain navigable. Verify the native
|
||||
focus-triggered case with
|
||||
`CTHULHU_NATIVE_BROWSER_TESTS=tooltips python3 tests/native_browser.py`.
|
||||
|
||||
Enter activates the current link or clickable, including explicitly named custom
|
||||
controls and clickable ancestors of ordinary text. Activation itself is silent
|
||||
whether it succeeds or fails; the page's own focus changes and live regions still
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
)
|
||||
|
||||
func TestNativeBrowserTooltipNavigation(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
||||
t.Skip("requires private browser session")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `<!doctype html><title>Tooltip navigation regression</title><style>li {list-style: none}</style>
|
||||
<ul><li><a id="project" href="#after" aria-describedby="tip"
|
||||
onfocus="document.getElementById('tip').hidden=false"
|
||||
onblur="document.getElementById('tip').hidden=true">Example project</a>
|
||||
<span id="tip" role="tooltip" hidden><span>Example project</span></span>
|
||||
<p id="after">After project</p></li></ul>`)
|
||||
}))
|
||||
defer server.Close()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
command := func(args ...string) {
|
||||
t.Helper()
|
||||
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("xdotool: %v: %s", err, output)
|
||||
}
|
||||
}
|
||||
command("key", "--clearmodifiers", "ctrl+l")
|
||||
command("type", "--clearmodifiers", "--delay", "0", server.URL)
|
||||
command("key", "Return")
|
||||
var document atspi.ObjectID
|
||||
for !document.Valid() {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if event.Type == atspi.DocumentLoadCompleteEvent && event.Source.Name == "Tooltip navigation regression" {
|
||||
document = event.Source.ID()
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
worker, err := review.NewWorker(adapter, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := worker.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer worker.Stop(context.Background())
|
||||
worker.SyncFocus(document)
|
||||
move := func(command review.DocumentCaretCommand, want string) {
|
||||
t.Helper()
|
||||
if err := worker.MoveDocumentCaret(command); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case result := <-worker.Results():
|
||||
if result.Err != nil || result.CaretErr != nil || !result.Moved || result.DocumentCaretText != want {
|
||||
t.Fatalf("%s: text=%q want=%q err=%v caret=%v diagnostics=%+v", command,
|
||||
result.DocumentCaretText, want, result.Err, result.CaretErr, result.DocumentDiagnostics)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
move(review.CaretNextLine, "Example project")
|
||||
// Capture after focus has exposed the tooltip. This proves the fixture
|
||||
// supplies a real native tooltip subtree, rather than merely hidden text.
|
||||
for {
|
||||
tree, err := adapter.CaptureDocument(ctx, document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref, _ := tree.Reference(document)
|
||||
doc, err := tree.Document(ctx, ref)
|
||||
found := false
|
||||
if err == nil {
|
||||
for _, node := range doc.Nodes {
|
||||
if node.Snapshot.Role == "tool tip" && !node.Hidden && len(node.Children) > 0 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-adapter.Events():
|
||||
case <-ctx.Done():
|
||||
t.Fatal("visible native tooltip missing: ", ctx.Err())
|
||||
}
|
||||
}
|
||||
move(review.CaretNextLine, "After project")
|
||||
move(review.CaretPreviousLine, "Example project")
|
||||
move(review.CaretLineEnd, "t")
|
||||
move(review.CaretNextCharacter, "A")
|
||||
move(review.CaretPreviousCharacter, "t")
|
||||
}
|
||||
@@ -34,6 +34,14 @@ func wholeCaretNode(snapshot atspi.Snapshot) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Passive tooltips appear when their owner receives focus and disappear when
|
||||
// caret placement moves focus away. Entering their text can strand navigation
|
||||
// on a removed object and repeatedly recover to the owner's container. Match
|
||||
// Orca's web caret policy while retaining focusable/editable tooltip content.
|
||||
func passive_caret_tooltip(snapshot atspi.Snapshot) bool {
|
||||
return snapshot.Role == "tool tip" && !snapshot.Focusable && !snapshot.Editable
|
||||
}
|
||||
|
||||
// caret_node_indices preserves provider order and treats whole controls as one
|
||||
// stop. MoveCaret holds the document cursor lock while using these indices.
|
||||
func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference) ([]int, int) {
|
||||
@@ -73,7 +81,7 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference
|
||||
if node.Hidden || skipped[node.Reference.ID()] {
|
||||
continue
|
||||
}
|
||||
if snapshot.Role == "password text" {
|
||||
if snapshot.Role == "password text" || passive_caret_tooltip(snapshot) {
|
||||
skipChildren(node.Children)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange {
|
||||
var visit func(int)
|
||||
visit = func(index int) {
|
||||
node := nodes[index]
|
||||
if node.Hidden || node.Snapshot.Role == "password text" {
|
||||
if node.Hidden || node.Snapshot.Role == "password text" || passive_caret_tooltip(node.Snapshot) {
|
||||
return
|
||||
}
|
||||
children := node.Snapshot.TextChildren
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package review
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
func tooltip_cursor(t *testing.T, focusable, editable, hypertext bool) (*DocumentCursor, caretTextReader) {
|
||||
t.Helper()
|
||||
reader := caretTextReader{webID("/before"): "Project", webID("/tiptext"): "Project", webID("/after"): "After"}
|
||||
node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
|
||||
return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName, Path: path,
|
||||
Role: role, StatesKnown: true, CharacterCount: len(reader[webID(path)])}, Children: children}
|
||||
}
|
||||
tip := node("/tip", "tool tip", webID("/tiptext"))
|
||||
tip.Snapshot.Focusable, tip.Snapshot.Editable = focusable, editable
|
||||
if hypertext {
|
||||
// Text around an embedded child must not bypass subtree exclusion.
|
||||
tip.Snapshot.CharacterCount = 3
|
||||
tip.Snapshot.TextChildren = []atspi.TextChild{{Offset: 1, Child: webID("/tiptext")}}
|
||||
reader[webID("/tip")] = "x\ufffcy"
|
||||
}
|
||||
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
|
||||
node("/doc", "document web", webID("/before"), webID("/tip"), webID("/after")),
|
||||
node("/before", "static"), tip, node("/tiptext", "static"), node("/after", "static"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor, err := NewDocumentCursor(tree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cursor.SyncFocus(context.Background(), webID("/before")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cursor, reader
|
||||
}
|
||||
|
||||
func TestDocumentCaretSkipsPassiveTooltip(t *testing.T) {
|
||||
for _, hypertext := range []bool{false, true} {
|
||||
for _, command := range []DocumentCaretCommand{CaretNextLine, CaretNextCharacter, CaretNextWord} {
|
||||
t.Run(command.String(), func(t *testing.T) {
|
||||
cursor, reader := tooltip_cursor(t, false, false, hypertext)
|
||||
if command != CaretNextLine {
|
||||
cursor.places[0].offset = len("Project") - 1
|
||||
}
|
||||
move, err := cursor.MoveCaret(context.Background(), reader, command)
|
||||
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/after") {
|
||||
t.Fatalf("forward: %+v, %v", move, err)
|
||||
}
|
||||
reverse := CaretPreviousLine
|
||||
if command == CaretNextCharacter {
|
||||
reverse = CaretPreviousCharacter
|
||||
} else if command == CaretNextWord {
|
||||
reverse = CaretPreviousWord
|
||||
}
|
||||
cursor.places[0].offset = 0
|
||||
move, err = cursor.MoveCaret(context.Background(), reader, reverse)
|
||||
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/before") {
|
||||
t.Fatalf("backward: %+v, %v", move, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCaretPreservesInteractiveTooltip(t *testing.T) {
|
||||
for _, focusable := range []bool{false, true} {
|
||||
cursor, reader := tooltip_cursor(t, focusable, !focusable, false)
|
||||
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine)
|
||||
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/tiptext") {
|
||||
t.Fatalf("interactive tooltip: %+v, %v", move, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAllSkipsPassiveTooltip(t *testing.T) {
|
||||
cursor, reader := tooltip_cursor(t, false, false, true)
|
||||
worker, err := NewWorker(&readAllCapturer{tree: cursor.tree, caretTextReader: reader}, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.SyncFocus(webID("/before"))
|
||||
result := worker.readAll(context.Background(), cursor)
|
||||
if result.Err != nil || len(result.ReadAllLines) != 2 {
|
||||
t.Fatalf("Read All: %+v", result)
|
||||
}
|
||||
for index, want := range []string{"Project", "After"} {
|
||||
if got := result.ReadAllLines[index].Contents[0].Text; got != want {
|
||||
t.Fatalf("line %d = %q, want %q", index, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ from native_test_session import run_private_session
|
||||
|
||||
|
||||
BROWSER_PROOFS = {
|
||||
"tooltips": ("./internal/app", "^TestNativeBrowserTooltipNavigation$", 30, 35),
|
||||
"menu": ("./internal/app", "^TestNativeBrowserMenuControls$", 30, 35),
|
||||
"lists": ("./internal/app", "^TestNativeBrowserLists$", 35, 40, "browser-lists.html"),
|
||||
"pointer": ("./internal/atspi", "^TestNativePointerInput$", 15, 20),
|
||||
|
||||
Reference in new issue
Block a user