750 lines
28 KiB
Go
750 lines
28 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"
|
|
"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 TestActivationRefreshesAncestorOrderWithoutRetiringSiblings(t *testing.T) {
|
|
tree := testTree(t)
|
|
root, _ := tree.Root()
|
|
sibling, _, _ := tree.FirstChild(root)
|
|
parent, _ := tree.Reference(testID("/second"))
|
|
target, _, _ := tree.FirstChild(parent)
|
|
if got := tree.markAncestorChildrenChanged(target.ID()); got != 3 {
|
|
t.Fatalf("invalidated = %d, want target and two ancestors", got)
|
|
}
|
|
for _, ref := range []ObjectReference{target, parent, root} {
|
|
if _, err := tree.Resolve(ref); err != nil {
|
|
t.Fatalf("activation retired a live identity: %v", err)
|
|
}
|
|
if _, _, err := tree.FirstChild(ref); !errors.Is(err, ErrIncompleteTree) {
|
|
t.Fatalf("activated ancestry retained old child order: %v", err)
|
|
}
|
|
}
|
|
if _, err := tree.Resolve(sibling); err != nil {
|
|
t.Fatalf("activation retired unrelated sibling: %v", err)
|
|
}
|
|
if _, _, err := tree.FirstChild(sibling); err != nil {
|
|
t.Fatalf("activation invalidated unrelated child order: %v", err)
|
|
}
|
|
if got := tree.markAncestorChildrenChanged(target.ID()); got != 0 {
|
|
t.Fatalf("repeated activation invalidated %d fresh entries", got)
|
|
}
|
|
if got := tree.markAncestorChildrenChanged(testID("/missing")); got != 0 {
|
|
t.Fatalf("unknown activation invalidated %d entries", got)
|
|
}
|
|
}
|
|
|
|
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 refreshes semantic children", func(t *testing.T) {
|
|
owned := testTree(t)
|
|
document := testTree(t)
|
|
visual := testTree(t)
|
|
adapter := New()
|
|
adapter.tree = owned
|
|
adapter.documentTree = document
|
|
adapter.documentTextTree = visual
|
|
root, _ := document.Root()
|
|
sibling, _ := document.Reference(testID("/second"))
|
|
target := testID("/first")
|
|
adapter.invalidateTreeEventWithPolicy(DefunctStateEvent, 1, target, ObjectID{}, true)
|
|
for name, tree := range map[string]*AccessibleTree{"owned": owned, "visual": visual, "document": document} {
|
|
if _, err := tree.Reference(target); !errors.Is(err, ErrObjectNotFound) {
|
|
t.Fatalf("%s target error = %v, want missing", name, err)
|
|
}
|
|
}
|
|
for _, reference := range []ObjectReference{root, sibling} {
|
|
if _, err := document.Resolve(reference); err != nil {
|
|
t.Fatalf("navigation defunct invalidated live context: %v", err)
|
|
}
|
|
}
|
|
if _, _, err := document.FirstChild(root); !errors.Is(err, ErrIncompleteTree) {
|
|
t.Fatalf("navigation defunct did not refresh parent children: %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 TestRemovedBrowseCaretRetainsOnlyItsDocument(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name, path, document string
|
|
staleDocument bool
|
|
allowed bool
|
|
}{
|
|
{"removed browse text", "/gone", "/doc", false, true},
|
|
{"removed native focus", "/gone", "", false, false},
|
|
{"browser chrome", "/chrome", "/doc", false, false},
|
|
{"other document", "/other", "/doc", false, false},
|
|
{"removed other-tab caret", "/gone", "/other", false, false},
|
|
{"replaced document generation", "/gone", "/doc", true, false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
tree, err := NewAccessibleTree(testID("/root"), documentTestNodes())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
focus := nativeFocusReference{id: testID(test.path)}
|
|
if test.document != "" {
|
|
focus.navigationDocument, err = tree.Reference(testID(test.document))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if test.staleDocument {
|
|
tree.Invalidate(testID("/doc"))
|
|
if err := tree.Replace(testID("/root"), documentTestNodes()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
position, err := tree.Reference(testID("/heading"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := document_position_matches_retained_focus(tree, position, focus); (err == nil) != test.allowed {
|
|
t.Fatalf("caret ownership error = %v, allowed = %t", err, test.allowed)
|
|
}
|
|
tree.Invalidate(position.ID())
|
|
if err := document_position_matches_retained_focus(tree, position, focus); err == nil {
|
|
t.Fatal("removed 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")
|
|
}
|
|
}
|
|
|
|
// Switching browser tabs must not discard the previous document's captured
|
|
// tree: returning to a tab reuses its tree so capture only re-reads nodes
|
|
// dirtied while away, instead of snapshotting the whole document cold.
|
|
func TestDocumentSlotsRetainTreesAcrossRoots(t *testing.T) {
|
|
adapter := New()
|
|
install := func(rootPath, childPath string) *AccessibleTree {
|
|
t.Helper()
|
|
root := NewObjectID(":1.42", rootPath)
|
|
adapter.rotateDocumentSlots(root)
|
|
rootSnapshot := NewSnapshot(rootPath, "Document", "document web", "", "")
|
|
rootSnapshot.BusName = root.BusName
|
|
childSnapshot := NewSnapshot(childPath, "Heading", "heading", "", "")
|
|
childSnapshot.BusName = root.BusName
|
|
tree, err := adapter.installCapturedTree(root, []TreeNode{
|
|
{Snapshot: rootSnapshot, Children: []ObjectID{NewObjectID(":1.42", childPath)}},
|
|
{Snapshot: childSnapshot},
|
|
}, true, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tree
|
|
}
|
|
treeA := install("/docA", "/a_heading")
|
|
childA, err := treeA.Reference(NewObjectID(":1.42", "/a_heading"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
treeB := install("/docB", "/b_heading")
|
|
if treeB == treeA {
|
|
t.Fatal("second document replaced first document tree object")
|
|
}
|
|
adapter.rotateDocumentSlots(NewObjectID(":1.42", "/docA"))
|
|
if adapter.documentTree != treeA {
|
|
t.Fatal("returning to first document did not restore its retained tree")
|
|
}
|
|
if _, err := adapter.documentTree.Resolve(childA); err != nil {
|
|
t.Fatalf("retained document lost its captured nodes: %v", err)
|
|
}
|
|
// Retention is bounded: cycling through more documents than the limit
|
|
// evicts the least recently used one.
|
|
for _, rootPath := range []string{"/docC", "/docD", "/docE"} {
|
|
install(rootPath, rootPath+"_heading")
|
|
}
|
|
adapter.rotateDocumentSlots(NewObjectID(":1.42", "/docB"))
|
|
if adapter.documentTree == treeB {
|
|
t.Fatal("least recently used document tree was not evicted")
|
|
}
|
|
if adapter.documentTree != nil {
|
|
if root, err := adapter.documentTree.Root(); err == nil && root.ID().Path == "/docB" {
|
|
t.Fatal("evicted document tree retained its nodes")
|
|
}
|
|
}
|
|
// Events for a stashed document must still invalidate it while inactive.
|
|
adapter.rotateDocumentSlots(NewObjectID(":1.42", "/docE"))
|
|
stashedE := adapter.documentTree
|
|
if stashedE == nil {
|
|
t.Fatal("most recent document tree missing")
|
|
}
|
|
childE, err := stashedE.Reference(NewObjectID(":1.42", "/docE_heading"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
adapter.rotateDocumentSlots(NewObjectID(":1.42", "/docD"))
|
|
adapter.invalidateTreeEvent(DefunctStateEvent, 1, NewObjectID(":1.42", "/docE_heading"), ObjectID{})
|
|
if _, err := stashedE.Resolve(childE); !errors.Is(err, ErrStaleObject) {
|
|
t.Fatalf("inactive document ignored defunct invalidation: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentSlotsRestoreOldestAtCapacity(t *testing.T) {
|
|
adapter := New()
|
|
var first *AccessibleTree
|
|
for index := range maxStashedDocuments + 1 {
|
|
root := NewObjectID(":1.42", fmt.Sprintf("/doc%d", index))
|
|
adapter.rotateDocumentSlots(root)
|
|
snapshot := NewSnapshot(root.Path, "Document", "document web", "", "")
|
|
snapshot.BusName = root.BusName
|
|
tree, err := adapter.installCapturedTree(root, []TreeNode{{Snapshot: snapshot}}, true, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if index == 0 {
|
|
first = tree
|
|
}
|
|
}
|
|
adapter.rotateDocumentSlots(NewObjectID(":1.42", "/doc0"))
|
|
if adapter.documentTree != first {
|
|
t.Fatal("returning to oldest retained document evicted it before restoration")
|
|
}
|
|
if len(adapter.stashedDocuments) != maxStashedDocuments {
|
|
t.Fatalf("stashed documents = %d, want %d", len(adapter.stashedDocuments), maxStashedDocuments)
|
|
}
|
|
}
|
|
|
|
// These fixtures describe source membership without owning native handles.
|
|
func documentSourceKeys[T any](_ map[ObjectID]T, ids ...ObjectID) map[ObjectID]T {
|
|
sources := make(map[ObjectID]T, len(ids))
|
|
var value T
|
|
for _, id := range ids {
|
|
sources[id] = value
|
|
}
|
|
return sources
|
|
}
|
|
|
|
func TestRetainedDocumentRootDoesNotCrossTabsOnSameBus(t *testing.T) {
|
|
adapter := New()
|
|
active, inactive := testID("/active"), testID("/inactive")
|
|
adapter.documentSlotRoot = active
|
|
adapter.nativeDocument = documentSourceKeys(adapter.nativeDocument, testID("/active_child"))
|
|
adapter.stashedDocumentIDs = []ObjectID{inactive}
|
|
adapter.stashedDocuments = map[ObjectID]stashedDocument{
|
|
inactive: {sources: documentSourceKeys(adapter.nativeDocument, testID("/removed_child"))},
|
|
}
|
|
for _, item := range []struct{ focus, want ObjectID }{
|
|
{testID("/active_child"), active},
|
|
{testID("/removed_child"), inactive},
|
|
{testID("/chrome"), ObjectID{}},
|
|
{NewObjectID(":1.99", "/removed_child"), ObjectID{}},
|
|
} {
|
|
if got := adapter.retainedDocumentRoot(item.focus); got != item.want {
|
|
t.Fatalf("owner of %v = %v, want %v", item.focus, got, item.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A provider whose capture just timed out is probably still stuck. The
|
|
// next document capture for that bus must fail fast with ErrProviderHung
|
|
// instead of queueing another multi-second native stall, and the mark
|
|
// must expire so a recovered provider is used again.
|
|
func TestCaptureFailsFastWhileProviderHung(t *testing.T) {
|
|
adapter := New()
|
|
now := time.Now()
|
|
adapter.markProviderHung(":1.99", now)
|
|
if !adapter.providerHung(":1.99", now.Add(time.Second)) {
|
|
t.Fatal("fresh hung mark not reported")
|
|
}
|
|
if adapter.providerHung(":1.99", now.Add(time.Hour)) {
|
|
t.Fatal("hung mark never expires")
|
|
}
|
|
if adapter.providerHung(":1.98", now) {
|
|
t.Fatal("hung mark leaked to another bus")
|
|
}
|
|
// The expiry probe above purged the mark, as a real post-cooldown
|
|
// capture attempt would. Mark again for the capture assertion.
|
|
adapter.markProviderHung(":1.99", time.Now())
|
|
if _, err := adapter.CaptureDocument(context.Background(), NewObjectID(":1.99", "/doc")); !errors.Is(err, ErrProviderHung) {
|
|
t.Fatalf("hung capture error = %v, want ErrProviderHung", err)
|
|
}
|
|
tree, err := NewAccessibleTree(NewObjectID(":1.99", "/doc"), []TreeNode{
|
|
{Snapshot: func() Snapshot {
|
|
snapshot := NewSnapshot("/doc", "Document", "document web", "", "")
|
|
snapshot.BusName = ":1.99"
|
|
return snapshot
|
|
}()},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
position, err := tree.Reference(NewObjectID(":1.99", "/doc"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := adapter.ReadDocumentCaret(context.Background(), position, 0, DocumentCaretLine); !errors.Is(err, ErrProviderHung) {
|
|
t.Fatalf("hung read error = %v, want ErrProviderHung", err)
|
|
}
|
|
if err := adapter.SetDocumentPosition(context.Background(), position, position.ID()); !errors.Is(err, ErrProviderHung) {
|
|
t.Fatalf("hung placement error = %v, want ErrProviderHung", err)
|
|
}
|
|
}
|
|
|
|
func TestDocumentSlotReleaseAllowsReentrantInvalidation(t *testing.T) {
|
|
for _, orphaned := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("orphaned=%v", orphaned), func(t *testing.T) {
|
|
adapter := New()
|
|
oldRoot := testID("/old")
|
|
newRoot := testID("/new")
|
|
oldTree, err := NewAccessibleTree(oldRoot, []TreeNode{{Snapshot: Snapshot{BusName: oldRoot.BusName, Path: oldRoot.Path}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if orphaned {
|
|
adapter.nativeDocument = documentSourceKeys(adapter.nativeDocument, oldRoot)
|
|
} else {
|
|
adapter.documentSlotRoot = testID("/active")
|
|
adapter.documentTree = oldTree
|
|
adapter.stashedDocumentIDs = []ObjectID{oldRoot, testID("/second"), testID("/third")}
|
|
adapter.stashedDocuments = map[ObjectID]stashedDocument{oldRoot: {tree: oldTree}}
|
|
}
|
|
releases := 0
|
|
adapter.rotateDocumentSlotsAndRelease(newRoot, func(retired stashedDocument) {
|
|
releases++
|
|
if !adapter.treeMutex.TryLock() {
|
|
t.Fatal("native disposal would deadlock reentrant defunct invalidation")
|
|
}
|
|
adapter.treeMutex.Unlock()
|
|
if adapter.documentSlotRoot != newRoot || adapter.documentTree != nil {
|
|
t.Fatal("disposal observed partially rotated slots")
|
|
}
|
|
if _, retained := adapter.stashedDocuments[oldRoot]; retained {
|
|
t.Fatal("disposed document still retained")
|
|
}
|
|
adapter.invalidateTreeEvent(DefunctStateEvent, 1, oldRoot, ObjectID{})
|
|
})
|
|
if releases != 1 {
|
|
t.Fatalf("released %d documents, want 1", releases)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func BenchmarkDocumentSlotEviction(b *testing.B) {
|
|
adapter := New()
|
|
roots := make([]ObjectID, maxStashedDocuments+2)
|
|
trees := make([]*AccessibleTree, len(roots))
|
|
for i := range roots {
|
|
roots[i] = testID(fmt.Sprintf("/doc%d", i))
|
|
var err error
|
|
trees[i], err = NewAccessibleTree(roots[i], []TreeNode{{Snapshot: Snapshot{BusName: roots[i].BusName, Path: roots[i].Path}}})
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
index := i % len(roots)
|
|
adapter.rotateDocumentSlots(roots[index])
|
|
if adapter.documentTree != nil {
|
|
b.Fatal("expected evicted document to need recapture")
|
|
}
|
|
adapter.documentTree = trees[index]
|
|
}
|
|
}
|