// 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 . package atspi import ( "context" "errors" "fmt" "testing" "time" ) func TestAdmitNativeChildKeepsFirstTreeEdge(t *testing.T) { child := NewObjectID(":1.42", "/child") scheduled := make(map[ObjectID]struct{}) if !admitNativeChild(scheduled, child) { t.Fatal("first child edge was rejected") } if admitNativeChild(scheduled, child) { t.Fatal("duplicate child edge was accepted") } if len(scheduled) != 1 { t.Fatalf("scheduled children = %d, want 1", len(scheduled)) } } func TestDocumentCompletionRetainsNativeFocusWithoutChromeFocus(t *testing.T) { chrome := NewObjectID(":1.42", "/address") for _, eventType := range []string{DocumentLoadCompleteEvent, DocumentLoadStoppedEvent, BusyStateEvent} { t.Run(eventType, func(t *testing.T) { if !shouldRetainNativeFocus(eventType, 0, ObjectID{}) { t.Fatal("completion cannot resolve its document without a retained native source") } if shouldRetainNativeFocus(eventType, 0, chrome) { t.Fatal("background completion displaced retained chrome focus") } }) } for _, eventType := range []string{DocumentReloadEvent, BusyStateEvent} { if shouldRetainNativeFocus(eventType, 1, ObjectID{}) { t.Fatalf("loading start %q acquired document focus", eventType) } } } func TestDetachedDocumentWindowRequiresSelectedOwnedTab(t *testing.T) { document := NewObjectID(":1.42", "/old_document") window := NewObjectID(":1.42", "/browser_window") other := NewObjectID(":1.42", "/other_window") for _, tc := range []struct { name string active, selected, known ObjectID eventType string detail int want bool }{ {"returned selected tab", ObjectID{}, window, window, FocusedStateEvent, 1, true}, {"other window selected", ObjectID{}, other, window, FocusedStateEvent, 1, false}, {"background focus loss", ObjectID{}, window, window, FocusedStateEvent, 0, false}, {"active focus loss", document, ObjectID{}, window, FocusedStateEvent, 0, false}, {"background caret", ObjectID{}, window, window, CaretEvent, 1, false}, {"established document caret", document, ObjectID{}, window, CaretEvent, 1, true}, } { t.Run(tc.name, func(t *testing.T) { if got := restoredDocumentWindowAllowed(document, tc.active, tc.selected, tc.known, tc.eventType, tc.detail); got != tc.want { t.Fatalf("window restoration = %t, want %t", got, tc.want) } }) } } func TestCaptureDesktopRejectsUnavailableAdapterAndCanceledContext(t *testing.T) { adapter := New() if _, err := adapter.CaptureDesktop(context.Background()); err == nil { t.Fatal("capture succeeded before adapter start") } canceled, cancel := context.WithCancel(context.Background()) cancel() if _, err := adapter.CaptureDesktop(canceled); !errors.Is(err, context.Canceled) { t.Fatalf("canceled capture error = %v", err) } if _, err := adapter.CaptureDesktop(nil); err == nil { t.Fatal("capture accepted a nil context") } } func TestDocumentSoundGeometryHonorsCaptureGateAndCancellation(t *testing.T) { adapter := New() tree := testTree(t) object, _ := tree.Reference(testID("/first")) adapter.SetCaptureSoundGeometry(false) geometry, err := adapter.CaptureDocumentSoundGeometry(context.Background(), object, ObjectReference{}) if err != nil || geometry != [2]SoundGeometry{} { t.Fatalf("disabled sound geometry accessed the stopped adapter: %+v, %v", geometry, err) } adapter.SetCaptureSoundGeometry(true) canceled, cancel := context.WithCancel(context.Background()) cancel() if _, err := adapter.CaptureDocumentSoundGeometry(canceled, object, ObjectReference{}); !errors.Is(err, context.Canceled) { t.Fatalf("canceled geometry capture error = %v", err) } adapter.documentTree = tree tree.Invalidate(object.ID()) if _, err := adapter.captureNativeDocumentSoundGeometry(context.Background(), [2]ObjectReference{object}); !errors.Is(err, ErrStaleObject) { t.Fatalf("stale geometry reference accepted: %v", err) } } func TestDocumentCaptureTreeIsIndependentAndInvalidated(t *testing.T) { for _, eventType := range []string{ChildrenAddedEvent, DefunctStateEvent, DocumentReloadEvent, "shutdown"} { t.Run(eventType, func(t *testing.T) { adapter := New() adapter.tree = testTree(t) adapter.documentTree = testTree(t) root, _ := adapter.tree.Root() reviewRef, _ := adapter.tree.Reference(testID("/first")) documentRef, _ := adapter.documentTree.Reference(testID("/first")) if eventType == "shutdown" { adapter.invalidateTree() } else { adapter.invalidateTreeEvent(eventType, 1, root.ID(), ObjectID{}) } if _, err := adapter.tree.Resolve(reviewRef); !errors.Is(err, ErrStaleObject) { t.Fatalf("review reference survived %s: %v", eventType, err) } if eventType == ChildrenAddedEvent { if _, err := adapter.documentTree.Resolve(documentRef); err != nil { t.Fatalf("insertion retired surviving document child: %v", err) } documentRoot, _ := adapter.documentTree.Root() if _, _, err := adapter.documentTree.FirstChild(documentRoot); !errors.Is(err, ErrIncompleteTree) { t.Fatalf("insertion left old child order navigable: %v", err) } } else if _, err := adapter.documentTree.Resolve(documentRef); !errors.Is(err, ErrStaleObject) { t.Fatalf("document reference survived %s: %v", eventType, err) } }) } adapter := New() adapter.tree = testTree(t) root, _ := adapter.tree.Root() reviewRef, _ := adapter.tree.Reference(testID("/first")) snapshot := NewSnapshot(root.ID().Path, "Document", "document web", "", "") snapshot.BusName = root.ID().BusName document, err := adapter.installCapturedTree(root.ID(), []TreeNode{{Snapshot: snapshot}}, true, false) if err != nil { t.Fatal(err) } if document == adapter.tree { t.Fatal("document capture replaced review tree") } if _, err := adapter.tree.Resolve(reviewRef); err != nil { t.Fatalf("document capture invalidated independent review position: %v", err) } } func TestReplacementDocumentRetiresOnlyActiveApplicationTree(t *testing.T) { adapter := New() adapter.documentTree = testTree(t) old, err := adapter.documentTree.Reference(testID("/first")) if err != nil { t.Fatal(err) } replacement := testID("/replacement") adapter.invalidateTreeEvent(DocumentLoadCompleteEvent, 0, replacement, ObjectID{}) if _, err := adapter.documentTree.Resolve(old); err != nil { t.Fatalf("unknown load source unexpectedly retired tree: %v", err) } adapter.invalidateReplacedDocument(replacement, testID("/background")) if _, err := adapter.documentTree.Resolve(old); err != nil { t.Fatalf("inactive document retired tree: %v", err) } adapter.invalidateReplacedDocument(replacement, replacement) if _, err := adapter.documentTree.Resolve(old); !errors.Is(err, ErrStaleObject) { t.Fatalf("active replacement left old document available: %v", err) } } func TestDocumentTextTreeTracksLayoutInvalidationIndependently(t *testing.T) { adapter := New() adapter.documentTree = testTree(t) adapter.documentTextTree = testTree(t) semantic, _ := adapter.documentTree.Reference(testID("/first")) visual, _ := adapter.documentTextTree.Reference(testID("/first")) adapter.invalidateTreeEvent(BoundsChangedEvent, 0, testID("/first"), ObjectID{}) if _, err := adapter.documentTree.Geometry(semantic); err != nil { t.Fatalf("semantic document geometry was invalidated: %v", err) } if _, err := adapter.documentTextTree.Geometry(visual); !errors.Is(err, ErrStaleGeometry) { t.Fatalf("visual document geometry error = %v, want stale geometry", err) } adapter.documentTextTree = testTree(t) visual, _ = adapter.documentTextTree.Reference(testID("/first")) adapter.invalidateTreeEvent(CaretEvent, 1, testID("/first"), ObjectID{}) if _, err := adapter.documentTextTree.VisibleText(visual); err != nil { t.Fatalf("caret event invalidated reusable visual text: %v", err) } } func TestAdapterTreeInvalidationPolicy(t *testing.T) { t.Run("unrelated semantic events preserve document", func(t *testing.T) { for _, eventType := range []string{AccessibleNameEvent, AccessibleValueEvent, ValueChangedEvent, "object:state-changed:selected", "object:state-changed:checked", "object:state-changed:sensitive"} { t.Run(eventType, func(t *testing.T) { adapter := New() adapter.documentTree = testTree(t) cached, _ := adapter.documentTree.Reference(testID("/first")) for _, source := range []ObjectID{testID("/browser-chrome"), {BusName: ":1.999", Path: cached.ID().Path}, {}} { adapter.invalidateTreeEvent(eventType, 1, source, ObjectID{}) if _, err := adapter.documentTree.Resolve(cached); err != nil { t.Fatalf("outside event from %v discarded document: %v", source, err) } } if got := adapter.TreeInvalidations(); got != 0 { t.Fatalf("unrelated events invalidated %d objects", got) } adapter.invalidateTreeEvent(eventType, 1, cached.ID(), ObjectID{}) if _, err := adapter.documentTree.Resolve(cached); !errors.Is(err, ErrStaleSnapshot) { t.Fatalf("in-document change did not retire snapshot: %v", err) } }) } }) t.Run("semantic change preserves identities for refresh", func(t *testing.T) { tree := testTree(t) adapter := New() adapter.documentTree = tree cached, _ := tree.Reference(testID("/first")) adapter.invalidateTreeEvent(AccessibleNameEvent, 0, cached.ID(), ObjectID{}) if _, err := tree.Resolve(cached); !errors.Is(err, ErrStaleSnapshot) { t.Fatalf("semantic snapshot error = %v, want stale semantics", err) } root, err := tree.Root() if err != nil { t.Fatal(err) } current, err := tree.Reference(cached.ID()) if err != nil || current != cached { t.Fatalf("name change retired the current identity: %v", err) } cachedNodes := tree.nativeRefresh(root.ID()) if len(cachedNodes) == 0 || !cachedNodes[cached.ID()].snapshotInvalid { t.Fatal("semantic refresh omitted the dirty object") } nodes := make([]TreeNode, 0, len(cachedNodes)) for id, entry := range cachedNodes { if id == cached.ID() { entry.snapshot.Name = "Updated name" } nodes = append(nodes, TreeNode{Snapshot: entry.snapshot, Children: entry.children, Geometry: entry.geometry, Text: entry.text}) } if err := tree.Replace(root.ID(), nodes); err != nil { t.Fatal(err) } snapshot, err := tree.Resolve(cached) if err != nil || snapshot.Name != "Updated name" { t.Fatalf("refreshed object = %+v, %v", snapshot, err) } tree.InvalidateSnapshot(cached.ID()) tree.MarkChildrenChanged(root.ID()) if plan := tree.nativeRefresh(root.ID()); !plan[root.ID()].childrenInvalid || !plan[cached.ID()].snapshotInvalid { t.Fatal("incremental refresh lost a dirty child list or snapshot") } tree.Invalidate(root.ID()) if plan := tree.nativeRefresh(root.ID()); plan != nil { t.Fatal("incremental refresh retained a replaced document") } }) t.Run("known removal", func(t *testing.T) { tree := testTree(t) adapter := New() adapter.tree = tree removed, _ := tree.Reference(testID("/second")) kept, _ := tree.Reference(testID("/first")) adapter.invalidateTreeEvent(ChildrenRemovedEvent, 0, testID("/root"), removed.ID()) if _, err := tree.Resolve(removed); !errors.Is(err, ErrStaleObject) { t.Fatalf("removed reference error = %v", err) } if _, err := tree.Resolve(kept); err != nil { t.Fatalf("unrelated sibling became stale: %v", err) } if got := adapter.TreeInvalidations(); got != 2 { t.Fatalf("invalidation count = %d, want 2", got) } }) t.Run("addition invalidates cached children", func(t *testing.T) { tree := testTree(t) adapter := New() adapter.tree = tree root, _ := tree.Root() child, _, _ := tree.FirstChild(root) adapter.invalidateTreeEvent(ChildrenAddedEvent+":system", 1, root.ID(), ObjectID{}) if _, err := tree.Resolve(root); err != nil { t.Fatalf("parent became stale: %v", err) } if _, err := tree.Resolve(child); !errors.Is(err, ErrStaleObject) { t.Fatalf("cached child error = %v, want stale object", err) } if got := adapter.TreeInvalidations(); got != 3 { t.Fatalf("invalidation count = %d, want 3", got) } }) t.Run("defunct false is ignored", func(t *testing.T) { tree := testTree(t) adapter := New() adapter.tree = tree root, _ := tree.Root() adapter.invalidateTreeEvent(DefunctStateEvent, 0, root.ID(), ObjectID{}) if _, err := tree.Resolve(root); err != nil { t.Fatalf("false defunct state invalidated root: %v", err) } adapter.invalidateTreeEvent(DefunctStateEvent, 1, root.ID(), ObjectID{}) if _, err := tree.Resolve(root); !errors.Is(err, ErrStaleObject) { t.Fatalf("defunct root error = %v, want stale object", err) } }) t.Run("navigation defunct preserves semantic document", func(t *testing.T) { owned := testTree(t) document := testTree(t) visual := testTree(t) adapter := New() adapter.tree = owned adapter.documentTree = document adapter.documentTextTree = visual target := testID("/first") adapter.invalidateTreeEventWithPolicy(DefunctStateEvent, 1, target, ObjectID{}, true) for name, tree := range map[string]*AccessibleTree{"owned": owned, "visual": visual} { if _, err := tree.Reference(target); !errors.Is(err, ErrObjectNotFound) { t.Fatalf("%s target error = %v, want missing", name, err) } } if _, err := document.Reference(target); err != nil { t.Fatalf("semantic document target was invalidated: %v", err) } }) } func TestAdapterInvalidatesGeometryWithoutRemovingObjects(t *testing.T) { for _, eventType := range []string{BoundsChangedEvent, VisibleStateEvent, ShowingStateEvent} { for _, detail := range []int{0, 1} { t.Run(eventType+fmt.Sprint(detail), func(t *testing.T) { tree := testTree(t) adapter := New() adapter.tree = tree second, _ := tree.Reference(testID("/second")) child, _, _ := tree.FirstChild(second) adapter.invalidateTreeEvent(eventType, detail, second.ID(), ObjectID{}) for _, ref := range []ObjectReference{second, child} { if _, err := tree.Geometry(ref); !errors.Is(err, ErrStaleGeometry) { t.Fatalf("geometry error = %v", err) } if _, err := tree.Resolve(ref); err != nil { t.Fatalf("object invalidated: %v", err) } } }) } } } func TestQueuedDocumentPositionRejectsDepartedFocus(t *testing.T) { tree, err := NewAccessibleTree(testID("/root"), documentTestNodes()) if err != nil { t.Fatal(err) } position, err := tree.Reference(testID("/heading")) if err != nil { t.Fatal(err) } for _, tc := range []struct { path string allowed bool }{ {"/heading", true}, {"/link", true}, {"/chrome", false}, {"/other", false}, {"/gone", false}, } { if err := documentPositionMatchesFocus(tree, position, testID(tc.path)); (err == nil) != tc.allowed { t.Fatalf("focus %s: %v", tc.path, err) } } tree.Invalidate(testID("/heading")) if err := documentPositionMatchesFocus(tree, position, testID("/link")); err == nil { t.Fatal("stale target accepted") } } func TestSecureInputCancelsCapturesAcrossLockTransitions(t *testing.T) { adapter := New() before, cancelBefore := adapter.secureRequestContext(context.Background()) defer cancelBefore() adapter.SetSecureInputActive(true) select { case <-before.Done(): case <-time.After(time.Second): t.Fatal("lock did not cancel in-flight capture") } during, cancelDuring := adapter.secureRequestContext(context.Background()) defer cancelDuring() if during.Err() == nil { t.Fatal("capture allowed while locked") } adapter.SetSecureInputActive(false) after, cancelAfter := adapter.secureRequestContext(context.Background()) defer cancelAfter() if after.Err() != nil { t.Fatal("unlock left captures canceled") } if before.Err() == nil || during.Err() == nil { t.Fatal("unlock revived old capture") } }