Files
ak47 49be8309fe Enable isolated Remote speech and protect reading exports
Attach incoming software speech to reader sessions with lease checks, local output priority and isolated cancellation. Preserve every document fragment source for privacy checks and export reviewed browse current-line reading, spelling and phonetic output.

Verified focused reading and Remote race tests, complete review and external Remote race suites, private relay integration, speech regressions, affected-package vet, reader build and Tentacles make check. Audible speech, physical input/Braille and live interoperability remain unverified.
2026-11-05 15:41:08 -05:00

383 lines
13 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package atspi
import (
"context"
"errors"
"reflect"
"sync"
"testing"
"unicode/utf8"
)
func documentTestNodes() []TreeNode {
node := func(path, role string, children ...ObjectID) TreeNode {
n := testNode(path, path, children...)
n.Snapshot.Role = role
return n
}
return []TreeNode{
node("/root", "frame", testID("/chrome"), testID("/doc"), testID("/other")),
node("/chrome", "entry"),
node("/doc", "document web", testID("/heading"), testID("/inner")),
node("/heading", "heading"),
node("/inner", "document frame", testID("/link")),
node("/link", "link"),
node("/other", "document web"),
}
}
func TestDocumentTargetFocusPolicyForHeadings(t *testing.T) {
nodes := []TreeNode{
testNode("/doc", "/doc", testID("/ordinary"), testID("/linked"), testID("/button"), testID("/entry"), testID("/image"), testID("/imageLink")),
testNode("/ordinary", "/ordinary", testID("/text")),
testNode("/text", "/text"),
testNode("/linked", "/linked", testID("/link")),
testNode("/link", "/link"),
testNode("/button", "/button"),
testNode("/entry", "/entry"),
testNode("/image", "/image"),
testNode("/imageLink", "/imageLink", testID("/linkedImage")),
testNode("/linkedImage", "/linkedImage"),
}
nodes[0].Snapshot.Role = "document web"
for i, role := range map[int]string{1: "heading", 2: "static", 3: "heading", 4: "link", 5: "button", 6: "entry", 7: "image", 8: "link", 9: "image"} {
nodes[i].Snapshot.Role = role
nodes[i].Snapshot.Focusable = true
}
tree, err := NewAccessibleTree(testID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
path string
want bool
}{
{"/ordinary", false},
{"/linked", true},
{"/button", true},
{"/entry", false},
{"/image", false},
{"/linkedImage", true},
} {
ref, err := tree.Reference(testID(tc.path))
if err != nil {
t.Fatal(err)
}
snapshot, err := tree.Resolve(ref)
if err != nil {
t.Fatal(err)
}
if got := documentTargetGrabsFocus(tree, ref, snapshot); got != tc.want {
t.Errorf("focus policy for %s = %v, want %v", tc.path, got, tc.want)
}
}
}
func TestDocumentOwnershipAndOrder(t *testing.T) {
tree, err := NewAccessibleTree(testID("/root"), documentTestNodes())
if err != nil {
t.Fatal(err)
}
ref, _ := tree.Reference(testID("/link"))
doc, err := tree.Document(context.Background(), ref)
if err != nil {
t.Fatal(err)
}
if doc.Context.Document.ID() != testID("/inner") || doc.Context.TopLevel.ID() != testID("/doc") {
t.Fatalf("wrong document ownership: %+v", doc.Context)
}
var paths []string
for _, node := range doc.Nodes {
paths = append(paths, node.Snapshot.Path)
}
if !reflect.DeepEqual(paths, []string{"/doc", "/heading", "/inner", "/link"}) {
t.Fatalf("document order: %v", paths)
}
var ancestors []string
for _, node := range doc.Context.Ancestors {
ancestors = append(ancestors, node.Path)
}
if !reflect.DeepEqual(ancestors, []string{"/link", "/inner", "/doc", "/root"}) {
t.Fatalf("ancestry: %v", ancestors)
}
doc.Nodes[0].Snapshot.Name = "mutated"
doc.Context.Ancestors[0].Name = "mutated"
again, err := tree.Document(context.Background(), ref)
if err != nil || again.Nodes[0].Snapshot.Name != "/doc" || again.Context.Ancestors[0].Name != "/link" {
t.Fatalf("snapshot aliasing: %+v %v", again, err)
}
chrome, _ := tree.Reference(testID("/chrome"))
if _, err := tree.DocumentContext(chrome); !errors.Is(err, ErrNotInDocument) {
t.Fatalf("browser chrome ownership: %v", err)
}
}
func TestDocumentRejectsStaleCapture(t *testing.T) {
for _, tc := range []struct {
name string
invalidate func(*AccessibleTree)
want error
}{
{"children", func(tree *AccessibleTree) { tree.InvalidateChildren(testID("/inner")) }, ErrIncompleteTree},
{"text", func(tree *AccessibleTree) { tree.InvalidateText(testID("/link")) }, ErrStaleText},
{"visibility", func(tree *AccessibleTree) { tree.InvalidateGeometry(testID("/inner")) }, ErrStaleGeometry},
{"document", func(tree *AccessibleTree) { tree.Invalidate(testID("/doc")) }, ErrStaleObject},
} {
t.Run(tc.name, func(t *testing.T) {
tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes())
ref, _ := tree.Reference(testID("/heading"))
tc.invalidate(tree)
doc, err := tree.Document(context.Background(), ref)
if !errors.Is(err, tc.want) || len(doc.Nodes) != 0 {
t.Fatalf("partial/stale document: %+v %v", doc, err)
}
})
}
}
func TestDocumentVisibilityAndCancellation(t *testing.T) {
nodes := documentTestNodes()
nodes[3].Geometry = Geometry{VisibilityKnown: true, Visible: true} // Offscreen, still navigable.
nodes[4].Geometry = Geometry{VisibilityKnown: true} // Hidden ancestor.
tree, _ := NewAccessibleTree(testID("/root"), nodes)
ref, _ := tree.Reference(testID("/heading"))
doc, err := tree.Document(context.Background(), ref)
if err != nil || doc.Nodes[1].Hidden || !doc.Nodes[2].Hidden || !doc.Nodes[3].Hidden {
t.Fatalf("visibility: %+v %v", doc, err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := tree.Document(ctx, ref); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
}
func TestDocumentLineAssemblesSameVisualRow(t *testing.T) {
nodes := documentTestNodes()
lineNode := func(index int, path, role, content string, bounds Rectangle, caret bool) {
snapshot := NewSnapshot(path, path, role, "", content)
snapshot.BusName = ":1.42"
nodes[index].Snapshot = snapshot
nodes[index].Geometry = Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
text := VisibleText{Known: true, Ranges: []TextRange{{
Start: 0, End: utf8.RuneCountInString(content), Text: content, Bounds: bounds,
}}}
if caret {
text.CaretKnown, text.CaretOffset = true, 1
}
nodes[index].Text = text
}
lineNode(3, "/heading", "heading", "Before", Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, true)
lineNode(5, "/link", "link", "link text", Rectangle{X: 100, Y: 100, Width: 80, Height: 20}, true)
lineNode(6, "/other", "paragraph", "Other row", Rectangle{X: 0, Y: 200, Width: 80, Height: 20}, false)
tree, err := NewAccessibleTree(testID("/root"), nodes)
if err != nil {
t.Fatal(err)
}
ref, err := tree.Reference(testID("/link"))
if err != nil {
t.Fatal(err)
}
line, err := tree.DocumentLineAtOffset(context.Background(), ref, -1)
if err != nil {
t.Fatal(err)
}
var contents []string
for _, content := range line.Contents {
contents = append(contents, content.Text)
}
if !reflect.DeepEqual(contents, []string{"Before", "link text"}) {
t.Fatalf("line contents = %q", contents)
}
if line.Document.ID() != testID("/doc") {
t.Fatalf("line document = %#v", line.Document)
}
if len(line.SourceContexts) != len(line.Contents) {
t.Fatal("assembled line lost contributing sources")
}
for index, sources := range line.SourceContexts {
if len(sources) == 0 || sources[0].ID() != line.Contents[index].Snapshot.ID() {
t.Fatal("line source context belongs to another fragment")
}
}
// The first fragment is outside the caret object's ancestry. Keep its
// original ancestry even though line.Context belongs to the caret.
if line.SourceContexts[0][1].ID() != testID("/doc") || line.SourceContexts[1][1].ID() != testID("/inner") {
t.Fatal("neighbor ancestry was replaced by caret ancestry")
}
}
func TestDocumentLineExcludesHiddenAndOtherRows(t *testing.T) {
nodes := documentTestNodes()
content := func(index int, text string, bounds Rectangle, caret bool) {
nodes[index].Snapshot.Role = "static"
nodes[index].Geometry = Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
nodes[index].Text = VisibleText{Known: true, Ranges: []TextRange{{
Start: 0, End: utf8.RuneCountInString(text), Text: text, Bounds: bounds,
}}}
if caret {
nodes[index].Text.CaretKnown, nodes[index].Text.CaretOffset = true, 3
}
}
content(3, "Visible", Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, true)
content(5, "Hidden", Rectangle{X: 100, Y: 100, Width: 50, Height: 20}, false)
content(6, "Other row", Rectangle{X: 0, Y: 200, Width: 80, Height: 20}, false)
nodes[4].Geometry = Geometry{VisibilityKnown: true, Visible: false}
tree, err := NewAccessibleTree(testID("/root"), nodes)
if err != nil {
t.Fatal(err)
}
ref, err := tree.Reference(testID("/heading"))
if err != nil {
t.Fatal(err)
}
line, err := tree.DocumentLineAtOffset(context.Background(), ref, -1)
if err != nil {
t.Fatal(err)
}
if len(line.Contents) != 1 || line.Contents[0].Text != "Visible" {
t.Fatalf("line contents = %#v", line.Contents)
}
emptyNodes := documentTestNodes()
withoutCaretTree, err := NewAccessibleTree(testID("/root"), emptyNodes)
if err != nil {
t.Fatal(err)
}
withoutCaret, err := withoutCaretTree.Reference(testID("/heading"))
if err != nil {
t.Fatal(err)
}
if _, err := withoutCaretTree.DocumentLineAtOffset(context.Background(), withoutCaret, -1); !errors.Is(err, ErrNoDocumentCaret) {
t.Fatalf("missing caret error = %v", err)
}
}
func TestDocumentLineAcceptsEndOfTextCaretOffset(t *testing.T) {
nodes := documentTestNodes()
content := "Visible"
nodes[3].Snapshot.Role = "static"
nodes[3].Geometry = Geometry{
Bounds: Rectangle{X: 0, Y: 100, Width: 50, Height: 20}, BoundsKnown: true,
VisibilityKnown: true, Visible: true, Showing: true,
}
nodes[3].Text = VisibleText{Known: true, Ranges: []TextRange{{
Start: 0, End: utf8.RuneCountInString(content), Text: content,
Bounds: nodes[3].Geometry.Bounds,
}}}
tree, err := NewAccessibleTree(testID("/root"), nodes)
if err != nil {
t.Fatal(err)
}
reference, err := tree.Reference(testID("/heading"))
if err != nil {
t.Fatal(err)
}
line, err := tree.DocumentLineAtOffset(context.Background(), reference, utf8.RuneCountInString(content))
if err != nil {
t.Fatal(err)
}
if len(line.Contents) != 1 || line.Contents[0].Text != content {
t.Fatalf("line contents = %#v", line.Contents)
}
}
func TestDocumentLifecycleRetiresReusedIdentity(t *testing.T) {
for _, kind := range []string{DocumentReloadEvent, DocumentLoadCompleteEvent, DocumentLoadStoppedEvent} {
t.Run(kind, func(t *testing.T) {
nodes := documentTestNodes()
tree, _ := NewAccessibleTree(testID("/root"), nodes)
adapter := New()
adapter.tree = tree
doc, _ := tree.Reference(testID("/doc"))
child, _ := tree.Reference(testID("/link"))
chrome, _ := tree.Reference(testID("/chrome"))
adapter.invalidateTreeEvent(kind, 0, doc.ID(), ObjectID{})
if err := tree.Replace(testID("/root"), nodes); err != nil {
t.Fatal(err)
}
for _, ref := range []ObjectReference{doc, child} {
if _, err := tree.Resolve(ref); !errors.Is(err, ErrStaleObject) {
t.Fatalf("reused reference survived: %v", err)
}
}
if _, err := tree.Resolve(chrome); err != nil {
t.Fatalf("unrelated chrome: %v", err)
}
if adapter.TreeInvalidations() != 4 {
t.Fatalf("invalidation count: %d", adapter.TreeInvalidations())
}
})
}
}
func TestDocumentCaptureIsAtomicAcrossReplacement(t *testing.T) {
tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes())
ref, _ := tree.Reference(testID("/link"))
var workers sync.WaitGroup
workers.Add(1)
go func() {
defer workers.Done()
for i := 0; i < 100; i++ {
nodes := documentTestNodes()
for j := range nodes {
nodes[j].Snapshot.Name = "replacement"
}
if err := tree.Replace(testID("/root"), nodes); err != nil {
t.Error(err)
return
}
}
}()
for i := 0; i < 100; i++ {
doc, err := tree.Document(context.Background(), ref)
if err != nil {
t.Fatal(err)
}
replaced := doc.Context.Ancestors[0].Name == "replacement"
for _, node := range doc.Nodes {
if (node.Snapshot.Name == "replacement") != replaced {
t.Fatal("mixed document captures")
}
}
}
workers.Wait()
}
func TestDocumentIgnoresCaretAndViewportInvalidation(t *testing.T) {
for _, event := range []string{CaretEvent, BoundsChangedEvent, TextBoundsChangedEvent, ShowingStateEvent, TextInsertedEvent, VisibleStateEvent} {
t.Run(event, func(t *testing.T) {
adapter := New()
adapter.documentTree, _ = NewAccessibleTree(testID("/root"), documentTestNodes())
adapter.tree, _ = NewAccessibleTree(testID("/root"), documentTestNodes())
ref, _ := adapter.documentTree.Reference(testID("/heading"))
adapter.invalidateTreeEvent(event, 1, ref.ID(), ObjectID{})
_, err := adapter.documentTree.Document(context.Background(), ref)
mustRefresh := event == TextInsertedEvent || event == VisibleStateEvent
if (err != nil) != mustRefresh {
t.Fatalf("document error = %v, refresh = %v", err, mustRefresh)
}
flatRef, _ := adapter.tree.Reference(testID("/heading"))
if _, err := adapter.tree.Document(context.Background(), flatRef); err == nil {
t.Fatal("flat review failed to invalidate")
}
})
}
}