Merge branch 'master' into testing
This commit is contained in:
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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),
|
||||||
|
|||||||
Reference in new issue
Block a user