Merge branch 'master' into testing

This commit is contained in:
Storm Dragon committed 2026-10-27 12:31:15 -04:00
commit 4d29f2bb23
7 files changed
+240 -11

No files matched your search

@@ -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"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestNativeBrowserDisabledFieldsRemainReadable(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()
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)
}
}
windows, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output()
if err != nil || len(strings.Fields(string(windows))) == 0 {
t.Fatalf("fixture window: %s %v", windows, err)
}
command("windowfocus", strings.Fields(string(windows))[0])
var focus atspi.ObjectID
for attempt := 0; attempt < 12 && !focus.Valid(); attempt++ {
command("windowfocus", strings.Fields(string(windows))[0])
command("key", "--clearmodifiers", "Tab")
timer := time.NewTimer(300 * time.Millisecond)
waitFocus:
for {
select {
case event := <-adapter.Events():
if (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 == 1) && event.Source.Name == "Read only answer" {
focus = event.Source.ID()
break waitFocus
}
case <-timer.C:
break waitFocus
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
timer.Stop()
}
if !focus.Valid() {
t.Fatal("fixture focus missing")
}
// Capture before the transition to exercise invalidation of a retained tree.
if _, err := adapter.CaptureDocument(ctx, focus); err != nil {
t.Fatal(err)
}
command("windowfocus", strings.Fields(string(windows))[0])
command("key", "F9")
for {
select {
case event := <-adapter.Events():
if event.Type != "object:state-changed:sensitive" || event.Source.Name != "Answer" || event.Detail1 != 0 {
continue
}
case <-ctx.Done():
t.Fatal("disabled state update missing: ", ctx.Err())
}
break
}
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(focus)
for _, step := range []struct {
command review.DocumentCaretCommand
want string
}{
{review.CaretDocumentStart, "Before fields"},
{review.CaretNextLine, ""}, // Keep the named row, then enter its disabled fields.
{review.CaretNextLine, "Solved"},
{review.CaretNextCharacter, "o"},
{review.CaretNextLine, "Confirmed"},
{review.CaretNextLine, "After fields"},
{review.CaretPreviousLine, "Confirmed"},
{review.CaretPreviousLine, "Solved"},
} {
if err := worker.MoveDocumentCaret(step.command); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
if result.Err != nil || !result.Moved || result.DocumentCaretText != step.want {
t.Fatalf("%s: text=%q moved=%t err=%v; want %q", step.command,
result.DocumentCaretText, result.Moved, result.Err, step.want)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
+1
View File
@@ -924,6 +924,7 @@ static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_sn
g_strcmp0(role, "row header") != 0 && g_strcmp0(role, "row header") != 0 &&
g_strcmp0(role, "table cell") != 0 && g_strcmp0(role, "table cell") != 0 &&
g_strcmp0(role, "table row") != 0 && g_strcmp0(role, "table row") != 0 &&
g_strcmp0(role, "entry") != 0 &&
g_strcmp0(role, "text") != 0 && g_strcmp0(role, "text") != 0 &&
g_strcmp0(role, "section") != 0 && g_strcmp0(role, "section") != 0 &&
g_strcmp0(role, "article") != 0 && g_strcmp0(role, "article") != 0 &&
@@ -0,0 +1,71 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestDocumentCaretReadsFieldsInsideNamedClickableContainer(t *testing.T) {
reader := caretTextReader{webID("/before"): "Before", webID("/letter"): "A", webID("/after"): "After"}
node := func(path, role, name string, count int, children ...string) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, name, role, "", "")
snapshot.BusName, snapshot.CharacterCount = webID(path).BusName, count
result := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
result.Children = append(result.Children, webID(child))
}
return result
}
container := node("/row", "section", "Completed row", 2, "/dragger", "/cells")
container.Snapshot.SupportsClick, container.Snapshot.ExplicitName, container.Snapshot.Focusable = true, true, true
container.Snapshot.TextChildren = []atspi.TextChild{{Offset: 0, Child: webID("/dragger")}, {Offset: 1, Child: webID("/cells")}}
entry := node("/letter", "entry", "Letter", 1)
entry.Snapshot.Editable, entry.Snapshot.SensitiveKnown = true, true // Disabled fields can retain editable state.
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
node("/doc", "document web", "", 0, "/before", "/row", "/after"),
node("/before", "static", "", 6), container,
node("/dragger", "button", "Reorder row", 0, "/icon"),
node("/icon", "image", "Reorder icon", 0),
node("/cells", "section", "", 0, "/letter", "/password"), entry,
node("/password", "password text", "Secret", 0, "/secret"),
node("/secret", "static", "Private fixture", 15),
node("/after", "static", "", 5),
})
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := cursor.SyncFocus(ctx, webID("/before")); err != nil {
t.Fatal(err)
}
for _, step := range []struct {
command DocumentCaretCommand
path, text string
}{
{CaretNextLine, "/row", ""},
{CaretNextCharacter, "/dragger", ""},
{CaretNextCharacter, "/letter", "A"},
{CaretReadLine, "/letter", "A"},
{CaretNextLine, "/after", "After"},
{CaretPreviousLine, "/letter", "A"},
{CaretPreviousCharacter, "/dragger", ""},
{CaretPreviousLine, "/row", ""},
{CaretPreviousLine, "/before", "Before"},
} {
move, err := cursor.MoveCaret(ctx, reader, step.command)
if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) || move.Text != step.text {
t.Fatalf("%s: object=%s text=%q moved=%t err=%v; want %s %q", step.command,
move.Position.Object.ID().Path, move.Text, move.Moved, err, step.path, step.text)
}
}
}
+34 -9
View File
@@ -14,18 +14,22 @@ import (
) )
func wholeCaretNode(snapshot atspi.Snapshot) bool { func wholeCaretNode(snapshot atspi.Snapshot) bool {
if wholeCaretControl(snapshot) {
return true
}
// An explicitly named custom clickable is an atomic stop just like a
// named link. Ordinary clickable text remains readable by character/word.
return snapshot.SupportsClick && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" &&
!snapshot.Editable && !atspi.IsWebDocumentRole(snapshot.Role)
}
func wholeCaretControl(snapshot atspi.Snapshot) bool {
if snapshot.Role == "link" { if snapshot.Role == "link" {
return snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" return snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != ""
} }
if snapshot.Role == "image" { if snapshot.Role == "image" {
return snapshot.Name != "" || snapshot.Description != "" return snapshot.Name != "" || snapshot.Description != ""
} }
// An explicitly named custom clickable is an atomic stop just like a
// named link. Ordinary clickable text remains readable by character/word.
if snapshot.SupportsClick && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" &&
!snapshot.Editable && !atspi.IsWebDocumentRole(snapshot.Role) {
return true
}
switch snapshot.Role { switch snapshot.Role {
case "button", "check box", "check menu item", "list box", "menu item", "page tab", case "button", "check box", "check menu item", "list box", "menu item", "page tab",
"radio menu item", "radio button", "push button", "toggle button": "radio menu item", "radio button", "push button", "toggle button":
@@ -44,7 +48,7 @@ func passive_caret_tooltip(snapshot atspi.Snapshot) bool {
// caret_node_indices preserves provider order and treats whole controls as one // caret_node_indices preserves provider order and treats whole controls as one
// stop. MoveCaret holds the document cursor lock while using these indices. // stop. MoveCaret holds the document cursor lock while using these indices.
func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference) ([]int, int) { func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference) ([]int, int, map[atspi.ObjectID]bool) {
// Container text normally embeds its child text. Whole controls are one // Container text normally embeds its child text. Whole controls are one
// caret stop even when their label is a child or they have no text interface. // caret stop even when their label is a child or they have no text interface.
// Python's web _treatObjectAsWhole makes the same distinction. // Python's web _treatObjectAsWhole makes the same distinction.
@@ -52,6 +56,25 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference
current := -1 current := -1
skipped := make(map[atspi.ObjectID]bool) skipped := make(map[atspi.ObjectID]bool)
protected := make(map[atspi.ObjectID]bool, len(nodes)) protected := make(map[atspi.ObjectID]bool, len(nodes))
// Named custom clickables can contain independent controls, including
// disabled entries. Keep the container's label as a stop without pruning
// those controls. Ordinary labels and semantic controls remain atomic.
controls := make(map[atspi.ObjectID]bool)
for index := len(nodes) - 1; index >= 0; index-- {
node := nodes[index]
snapshot := node.Snapshot
field := documentFormFieldRole(snapshot) &&
(snapshot.Role != "text" || snapshot.Editable || snapshot.Focusable || snapshot.ReadOnly)
if !node.Hidden && (controls[node.Reference.ID()] || field || snapshot.Editable || snapshot.Role == "link") {
controls[node.Parent] = true
}
}
expanded := make(map[atspi.ObjectID]bool)
for _, node := range nodes {
if controls[node.Reference.ID()] && wholeCaretNode(node.Snapshot) && !wholeCaretControl(node.Snapshot) {
expanded[node.Reference.ID()] = true
}
}
var skipChildren func([]atspi.ObjectID) var skipChildren func([]atspi.ObjectID)
children := make(map[atspi.ObjectID][]atspi.ObjectID, len(nodes)) children := make(map[atspi.ObjectID][]atspi.ObjectID, len(nodes))
for _, node := range nodes { for _, node := range nodes {
@@ -90,7 +113,9 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference
} }
if wholeCaretNode(node.Snapshot) { if wholeCaretNode(node.Snapshot) {
indices = append(indices, index) indices = append(indices, index)
skipChildren(node.Children) if !expanded[node.Reference.ID()] {
skipChildren(node.Children)
}
} else if node.Snapshot.Editable && } else if node.Snapshot.Editable &&
(node.Snapshot.Role == "entry" || node.Snapshot.Role == "text") && (node.Snapshot.Role == "entry" || node.Snapshot.Role == "text") &&
(node.Snapshot.CharacterCount == 0 || len(node.Children) == 0) { (node.Snapshot.CharacterCount == 0 || len(node.Children) == 0) {
@@ -102,7 +127,7 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference
indices = append(indices, index) indices = append(indices, index)
} }
} }
return indices, current return indices, current, expanded
} }
// Python's web hasValidName/isUselessEmptyElement rejects standalone unnamed // Python's web hasValidName/isUselessEmptyElement rejects standalone unnamed
+2 -2
View File
@@ -18,7 +18,7 @@ import (
type caretRange struct{ index, start, end int } type caretRange struct{ index, start, end int }
func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange { func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange {
indices, _ := caret_node_indices(nodes, atspi.ObjectReference{}) indices, _, expanded := caret_node_indices(nodes, atspi.ObjectReference{})
eligible := make(map[int]bool, len(indices)) eligible := make(map[int]bool, len(indices))
byID := make(map[atspi.ObjectID]int, len(nodes)) byID := make(map[atspi.ObjectID]int, len(nodes))
for _, index := range indices { for _, index := range indices {
@@ -74,7 +74,7 @@ func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange {
} }
if eligible[index] { if eligible[index] {
result = append(result, caretRange{index, 0, caret_text_count(node.Snapshot)}) result = append(result, caretRange{index, 0, caret_text_count(node.Snapshot)})
if wholeCaretNode(node.Snapshot) { if wholeCaretNode(node.Snapshot) && !expanded[node.Reference.ID()] {
return return
} }
} }
+14
View File
@@ -0,0 +1,14 @@
<!doctype html><title>Cthulhu Browser Probe</title>
<p>Before fields</p>
<div tabindex="0" aria-label="Completed row" onclick="">
<div><input autofocus id="answer" aria-label="Answer" value="Solved"></div>
<div><input id="readonly" aria-label="Read only answer" value="Confirmed" readonly></div>
</div>
<input type="password" aria-label="Secret" value="Private fixture">
<p>After fields</p>
<script>document.addEventListener('keydown', e => {
if (e.key === 'F9') {
document.getElementById('answer').disabled = true;
document.getElementById('readonly').disabled = true;
}
});</script>
+1
View File
@@ -12,6 +12,7 @@ from native_test_session import run_private_session
BROWSER_PROOFS = { BROWSER_PROOFS = {
"disabled-fields": ("./internal/app", "^TestNativeBrowserDisabledFieldsRemainReadable$", 40, 70, "browser-disabled-fields.html"),
"live-atomic": ("./internal/atspi", "^TestNativeBrowserAtomicLiveRegion$", 30, 35, "browser-live.html"), "live-atomic": ("./internal/atspi", "^TestNativeBrowserAtomicLiveRegion$", 30, 35, "browser-live.html"),
"replaced-feed": ("./internal/atspi", "^TestNativeBrowserMissingFeedRefreshesWholeBranch$", 30, 35, "browser-replaced-feed.html"), "replaced-feed": ("./internal/atspi", "^TestNativeBrowserMissingFeedRefreshesWholeBranch$", 30, 35, "browser-replaced-feed.html"),
"nested-document": ("./internal/app", "^TestNativeBrowserNestedDocumentHeadings$", 40, 45), "nested-document": ("./internal/app", "^TestNativeBrowserNestedDocumentHeadings$", 40, 45),