Some browser fixes.

This commit is contained in:
lilmike committed 2026-10-24 12:07:02 -07:00
1 parent 8a560a25a5
commit 7c032a5f6e
22 files changed
+631 -31

No files matched your search

+1
View File
@@ -137,6 +137,7 @@ const (
terminalMutationDebounce = 50 * time.Millisecond
objectReviewCaptureTimeout = 2 * time.Second
flatReviewCaptureTimeout = 10 * time.Second
documentCaptureTimeout = 15 * time.Second
)
var startupMessages = []string{
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestNativeBrowserNestedDocumentHeadings(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()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<!doctype html><title>Nested document headings</title><main><h1>First heading</h1>`)
for i := 0; i < 200; i++ {
fmt.Fprint(w, `<article>`)
for depth := 0; depth < 15; depth++ {
fmt.Fprint(w, `<section>`)
}
fmt.Fprintf(w, `<h2>Post %d</h2><p>Post body %d</p><button>Reply</button>`, i, i)
for depth := 0; depth < 15; depth++ {
fmt.Fprint(w, `</section>`)
}
fmt.Fprint(w, `</article>`)
}
fmt.Fprint(w, `<h1>Last heading</h1></main>`)
}))
defer server.Close()
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)
}
}
window, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output()
if err != nil || len(strings.Fields(string(window))) == 0 {
t.Fatalf("browser window: %s: %v", window, err)
}
command("windowfocus", "--sync", strings.Fields(string(window))[0])
command("key", "--clearmodifiers", "ctrl+l")
// The X window can appear before browser startup finishes. Wait for
// native address-bar focus so startup cannot swallow the URL keystrokes.
addressFocused := false
retryFocus := time.NewTicker(time.Second)
defer retryFocus.Stop()
for !addressFocused {
select {
case event := <-adapter.Events():
addressFocused = event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.Editable
case <-retryFocus.C:
command("key", "--clearmodifiers", "ctrl+l")
case <-ctx.Done():
t.Fatal("browser address-bar focus: ", ctx.Err())
}
}
command("key", "--clearmodifiers", "ctrl+a")
command("type", "--clearmodifiers", "--delay", "1", server.URL)
command("key", "Return")
var document atspi.ObjectID
for !document.Valid() {
select {
case event := <-adapter.Events():
if event.Type == atspi.DocumentLoadCompleteEvent && event.Source.Name == "Nested document headings" {
document = event.Source.ID()
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
worker, err := review.NewWorker(adapter, 2*time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.SetDocumentCaptureTimeout(documentCaptureTimeout); err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
worker.SyncFocus(document)
result := func(want string) {
t.Helper()
select {
case result := <-worker.Results():
if result.CaptureDuration > 0 {
t.Logf("whole-document capture: %v", result.CaptureDuration)
}
if result.Err != nil || result.DocumentPosition.Snapshot.Name != want {
t.Fatalf("navigation = %q, want %q: err=%v capture=%v diagnostics=%+v", result.DocumentPosition.Snapshot.Name, want, result.Err, result.CaptureReason, result.DocumentDiagnostics)
}
if result.DocumentSoundErr != nil || !result.DocumentPosition.Snapshot.SoundGeometry.ObjectKnown || !result.DocumentPosition.Snapshot.SoundGeometry.ReferenceKnown {
t.Fatalf("heading sound geometry unavailable: %+v, %v", result.DocumentPosition.Snapshot.SoundGeometry, result.DocumentSoundErr)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
for _, step := range []struct {
backwards bool
want string
}{
{false, "First heading"}, {true, "Last heading"}, {true, "Post 199"}, {false, "Last heading"}, {false, "First heading"},
} {
if err := worker.MoveDocument(review.DocumentHeading, 0, step.backwards); err != nil {
t.Fatal(err)
}
result(step.want)
}
}
+155
View File
@@ -0,0 +1,155 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestNativeBrowserRevealedHeadings(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()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<!doctype html><title>Revealed headings regression</title>
<style>.hidden { display: none }</style>
<section><h2>Across</h2><div data-clues>`)
for i := 1; i <= 12; i++ {
class := ""
if i > 5 {
class = "hidden"
}
fmt.Fprintf(w, `<div class="%s"><div><span>%d</span><h3><a href="/clue/%d">Clue %d</a></h3><div><button>A</button><button>B</button><button>C</button></div></div><div><button>Answer</button><button>Hint</button></div></div>`, class, i, i, i)
}
fmt.Fprint(w, `</div><div><button onclick="document.querySelectorAll('[data-clues] > div').forEach((e,i) => { if (i > 4) e.classList.toggle('hidden') }); this.firstChild.firstChild.data = this.textContent === 'More clues' ? 'Fewer clues' : 'More clues' "><span>More clues</span></button></div></section>
<h2>Following section</h2>`)
}))
defer server.Close()
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)
}
}
window, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output()
if err != nil || len(strings.Fields(string(window))) == 0 {
t.Fatalf("browser window: %s: %v", window, err)
}
command("windowfocus", strings.Fields(string(window))[0])
command("key", "--clearmodifiers", "ctrl+l")
command("type", "--clearmodifiers", "--delay", "1", server.URL)
command("key", "Return")
var document atspi.ObjectID
for !document.Valid() {
select {
case event := <-adapter.Events():
if event.Type == atspi.DocumentLoadCompleteEvent && event.Source.Name == "Revealed headings regression" {
document = event.Source.ID()
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
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(document)
result := func(want string) {
t.Helper()
select {
case result := <-worker.Results():
if result.Err != nil || result.DocumentPosition.Snapshot.Name != want {
t.Fatalf("navigation = %q, want %q: err=%v capture=%v diagnostics=%+v", result.DocumentPosition.Snapshot.Name, want, result.Err, result.CaptureReason, result.DocumentDiagnostics)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
if err := worker.MoveDocument(review.DocumentHeading, 0, false); err != nil {
t.Fatal(err)
}
result("Across")
if err := worker.MoveDocument(review.DocumentHeading, 0, true); err != nil {
t.Fatal(err)
}
result("Following section")
if err := worker.MoveDocumentCaret(review.CaretPreviousLine); err != nil {
t.Fatal(err)
}
result("More clues")
activate := func(want string) {
t.Helper()
if err := worker.ActivateDocumentCaret(); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
if result.Err != nil {
t.Fatal(result.Err)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
for {
select {
case event := <-adapter.Events():
if event.Type == atspi.AccessibleNameEvent && event.Source.Name == want && event.Source.Role == "button" {
return
}
case <-ctx.Done():
t.Fatal("clue toggle was not observed: ", ctx.Err())
}
}
}
activate("Fewer clues")
// The added headings precede the button. Browse in both directions
// without ever tabbing into the new content.
for _, step := range []struct {
backwards bool
want string
}{
{true, "Clue 12"},
{true, "Clue 11"},
{false, "Clue 12"},
{false, "Following section"},
} {
if err := worker.MoveDocument(review.DocumentHeading, 0, step.backwards); err != nil {
t.Fatal(err)
}
result(step.want)
}
if err := worker.MoveDocumentCaret(review.CaretPreviousLine); err != nil {
t.Fatal(err)
}
result("Fewer clues")
activate("More clues")
if err := worker.MoveDocument(review.DocumentHeading, 0, true); err != nil {
t.Fatal(err)
}
result("Clue 5")
}
+3
View File
@@ -40,6 +40,9 @@ func newRuntimeReviewServices(logger *slog.Logger, commands commandListener, ada
if err := objectReview.SetFlatCaptureTimeout(flatReviewCaptureTimeout); err != nil {
return nil, nil, nil, err
}
if err := objectReview.SetDocumentCaptureTimeout(documentCaptureTimeout); err != nil {
return nil, nil, nil, err
}
services := []lifecycle.Service{{Name: "object review", Start: objectReview.Start, Stop: objectReview.Stop}}
var browser *browserModeController
if keys, ok := commands.(browseKeyOwner); ok {
+4 -1
View File
@@ -355,8 +355,11 @@ func (adapter *Adapter) capture(ctx context.Context, reviewFocus ObjectID, captu
ctx, cancel := adapter.secureRequestContext(ctx)
defer cancel()
var trace *captureDiagnostics
if !document && reviewFocus.Valid() {
if reviewFocus.Valid() {
ctx, trace = new_capture_diagnostics(ctx, adapter.captureLogger.Load(), reviewFocus)
if trace != nil && document {
trace.kind = "document"
}
defer func() { trace.finish(err) }()
}
if err := ctx.Err(); err != nil {
@@ -241,6 +241,7 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
return err
}
if activated != 0 {
adapter.treeInvalidations.Add(uint64(tree.markAncestorChildrenChanged(position.ID())))
return nil
}
}
+6 -2
View File
@@ -18,6 +18,8 @@ import (
func (adapter *Adapter) captureDesktop(ctx context.Context, reviewFocus ObjectID, document, documentText bool) (*AccessibleTree, error) {
if document {
trace := capture_diagnostics(ctx)
trace.begin_stage("root-selection", reviewFocus, "")
root, err := adapter.nativeDocumentRoot(ctx, reviewFocus)
adapter.treeMutex.Lock()
previousTree := adapter.documentTree
@@ -63,6 +65,8 @@ func (adapter *Adapter) captureDesktop(ctx context.Context, reviewFocus ObjectID
if err != nil {
return nil, err
}
trace.set_root(nativeObjectID(root), "document")
trace.begin_stage("cache-preparation", nativeObjectID(root), "")
cache := nativeTreeCache{}
if !documentText && previousTree != nil {
cache.nodes = previousTree.nativeRefresh(nativeObjectID(root))
@@ -70,7 +74,7 @@ func (adapter *Adapter) captureDesktop(ctx context.Context, reviewFocus ObjectID
}
// Retain this bounded capture for narrow OCR geometry queries too.
retained := make(map[ObjectID]*C.AtspiAccessible)
rootID, nodes, err := captureNativeTree(ctx, root, "", true, documentText, adapter.captureSound.Load(), retained, cache)
rootID, nodes, err := captureNativeTree(ctx, root, "", true, documentText, retained, cache)
var tree *AccessibleTree
if err == nil {
tree, err = adapter.installCapturedTree(rootID, nodes, true, documentText)
@@ -122,7 +126,7 @@ func (adapter *Adapter) captureDesktop(ctx context.Context, reviewFocus ObjectID
}
}
}
root, nodes, err := captureNativeTree(ctx, rootSource, reviewFocus.BusName, document, documentText, adapter.captureSound.Load(), retained, cache)
root, nodes, err := captureNativeTree(ctx, rootSource, reviewFocus.BusName, document, documentText, retained, cache)
if err != nil {
for _, source := range retained {
C.g_object_unref(C.gpointer(source))
+6 -3
View File
@@ -87,7 +87,7 @@ type nativeTreeCache struct {
// captureNativeTree copies the desktop or, when reviewBusName is set, just the
// focused application's subtree. The latter keeps flat review independent of
// unrelated AT-SPI providers that can be slow or transiently unavailable.
func captureNativeTree(ctx context.Context, root *C.AtspiAccessible, reviewBusName string, document, documentText, captureSound bool, retained map[ObjectID]*C.AtspiAccessible, cache nativeTreeCache) (ObjectID, []TreeNode, error) {
func captureNativeTree(ctx context.Context, root *C.AtspiAccessible, reviewBusName string, document, documentText bool, retained map[ObjectID]*C.AtspiAccessible, cache nativeTreeCache) (ObjectID, []TreeNode, error) {
trace := capture_diagnostics(ctx)
trace.begin_stage("tree-setup", ObjectID{}, "")
reviewOnly := !document && reviewBusName != ""
@@ -148,12 +148,15 @@ func captureNativeTree(ctx context.Context, root *C.AtspiAccessible, reviewBusNa
// cached children and interfaces for slow native providers.
source.cached_properties &^= C.ATSPI_CACHE_STATES
}
// Navigation refreshes sound geometry only for the announced target,
// after scrolling. Capturing it for every descendant repeats native
// ancestor walks and can exhaust the document capture deadline.
applicationName, knownApplication := applicationNames[nativeObjectID(source).BusName]
if knownApplication {
snapshot = snapshotFromNativeWithOptions(source, !document, false, document, document && captureSound, false)
snapshot = snapshotFromNativeWithOptions(source, !document, false, document, false, false)
snapshot.ApplicationName = applicationName
} else {
snapshot = snapshotFromNativeWithCaret(source, !document, false, document, document && captureSound)
snapshot = snapshotFromNativeWithCaret(source, !document, false, document, false)
}
}
trace.progress(snapshot.Role, len(nodes), len(pending)+1, textBytes)
+31
View File
@@ -39,6 +39,37 @@ func TestAdmitNativeChildKeepsFirstTreeEdge(t *testing.T) {
}
}
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} {
+6 -1
View File
@@ -28,6 +28,7 @@ type captureStageTiming struct {
type captureDiagnostics struct {
mutex sync.Mutex
logger *slog.Logger
kind string
started time.Time
stageStarted time.Time
stage string
@@ -138,7 +139,11 @@ func (trace *captureDiagnostics) finish(err error) {
now := time.Now()
trace.record_stage(now)
trace.finished = true
trace.logger.Debug("flat review capture timing", "duration", now.Sub(trace.started), "error", err,
message := "flat review capture timing"
if trace.kind != "" {
message = trace.kind + " capture timing"
}
trace.logger.Debug(message, "duration", now.Sub(trace.started), "error", err,
"last_stage", trace.stage, "stage_duration", now.Sub(trace.stageStarted),
"bus", trace.object.BusName, "object_path", trace.object.Path, "role", trace.role,
"root_bus", trace.root.BusName, "root_path", trace.root.Path, "root_scope", trace.rootScope,
+3 -3
View File
@@ -62,9 +62,9 @@ func TestNativeBrowserCatalogCapture(t *testing.T) {
if err != nil || strings.TrimSpace(line.Text) != "Movie 599" {
t.Fatalf("last catalog heading text = %+v, %v", line, err)
}
focused, err := tree.Resolve(findNamedReference(t, tree, "Choose language"))
if err != nil || !focused.SoundGeometry.ObjectKnown || !focused.SoundGeometry.ReferenceKnown {
t.Fatalf("catalog control sound geometry = %+v, %v", focused.SoundGeometry, err)
geometry, err := adapter.CaptureDocumentSoundGeometry(ctx, findNamedReference(t, tree, "Choose language"), ObjectReference{})
if err != nil || !geometry[0].ObjectKnown || !geometry[0].ReferenceKnown {
t.Fatalf("catalog control sound geometry = %+v, %v", geometry[0], err)
}
if elapsed >= 2*time.Second {
t.Fatalf("cold catalog capture exceeded runtime budget: %v", elapsed)
+4 -3
View File
@@ -50,9 +50,10 @@ func (adapter *Adapter) document_object_error(object ObjectID, err error) error
tree.mu.RUnlock()
adapter.treeInvalidations.Add(uint64(tree.Invalidate(object)))
if entry.hasParent {
// The parent's cached child list may also predate the removal.
// Refresh it to discover any replacement while retaining siblings.
tree.MarkChildrenChanged(entry.parent)
// Virtualized pages can replace the enclosing post or feed, not
// just this target. Refresh every ancestor's child list so capture
// reaches the live replacement rather than reusing stale branches.
adapter.treeInvalidations.Add(uint64(tree.markAncestorChildrenChanged(entry.parent)))
}
}
clear(adapter.documentLineCache)
+35
View File
@@ -83,3 +83,38 @@ func TestDocumentEmptyTextRefreshesMetadataAndParentWithoutRetiringTargets(t *te
t.Fatalf("parent's children did not require refresh: %v", err)
}
}
func TestDocumentMissingDescendantRefreshesAncestorChildOrder(t *testing.T) {
adapter := New()
adapter.tree, adapter.documentTree, adapter.documentTextTree = testTree(t), testTree(t), testTree(t)
object := testID("/grandchild")
failure := fmt.Errorf("get document caret: could not be processed as object %s does not exist", object.Path)
if err := adapter.document_object_error(object, failure); !errors.Is(err, ErrDeadObject) {
t.Fatal(err)
}
for _, tree := range []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} {
for _, id := range []ObjectID{testID("/second"), testID("/root")} {
ref, err := tree.Reference(id)
if err != nil {
t.Fatal(err)
}
if _, _, err := tree.FirstChild(ref); !errors.Is(err, ErrIncompleteTree) {
t.Fatalf("ancestor %s retained obsolete child order: %v", id.Path, err)
}
}
// Refresh ancestry without declaring unrelated siblings dead or dirty.
sibling, err := tree.Reference(testID("/first"))
if err != nil {
t.Fatal(err)
}
if _, err := tree.Resolve(sibling); err != nil {
t.Fatal(err)
}
if _, _, err := tree.FirstChild(sibling); err != nil {
t.Fatal(err)
}
if _, err := tree.Reference(object); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("missing object survived: %v", err)
}
}
}
+137
View File
@@ -0,0 +1,137 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"testing"
"time"
)
func TestNativeBrowserMissingFeedRefreshesWholeBranch(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 := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
keys := func(args ...string) {
t.Helper()
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("xdotool: %v: %s", err, output)
}
}
desktop, err := adapter.CaptureDesktop(ctx)
if err != nil {
t.Fatal(err)
}
start := findNamedReference(t, desktop, "Feed start")
bounds, err := desktop.Geometry(start)
if err != nil || !bounds.BoundsKnown {
t.Fatalf("feed start bounds unavailable: %+v, %v", bounds, err)
}
keys("key", "--clearmodifiers", "ctrl+l")
keys("mousemove", "--sync", fmt.Sprint(bounds.Bounds.X+bounds.Bounds.Width/2), fmt.Sprint(bounds.Bounds.Y+bounds.Bounds.Height/2), "click", "1")
focused := false
for !focused {
select {
case event := <-adapter.Events():
focused = event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == start.ID()
case <-ctx.Done():
t.Fatal("feed start focus: ", ctx.Err())
}
}
tree, err := adapter.CaptureDocument(ctx, start.ID())
if err != nil {
t.Fatal(err)
}
root, _ := tree.Root()
doc, err := tree.Document(ctx, root)
if err != nil {
t.Fatal(err)
}
// Keep the captured semantics from before replacement. This explicitly
// models a provider omitting the structural events for an enclosing feed.
oldNodes := make([]TreeNode, 0, len(doc.Nodes))
for _, node := range doc.Nodes {
oldNodes = append(oldNodes, TreeNode{Snapshot: node.Snapshot, Children: node.Children, Geometry: node.Geometry, Text: node.Text})
}
missing := findNamedReference(t, tree, "Later post")
otherOld := findNamedReference(t, tree, "Earlier post")
keys("key", "--clearmodifiers", "F9")
replaced := false
for !replaced {
select {
case event := <-adapter.Events():
replaced = event.Type == AccessibleNameEvent && event.Source.Name == "Feed replacement complete"
case <-ctx.Done():
t.Fatal("feed replacement: ", ctx.Err())
}
}
if err := tree.Replace(root.ID(), oldNodes); err != nil {
t.Fatal(err)
}
// Use the current generation after restoring the deliberately stale cache.
missing, err = tree.Reference(missing.ID())
if err != nil {
t.Fatal(err)
}
_, err = adapter.ReadDocumentCaret(ctx, missing, 0, DocumentCaretBrailleLine)
if !errors.Is(err, ErrDeadObject) {
t.Fatalf("missing feed target = %v, want confirmed removal", err)
}
// libatspi can dispatch late defunct events during the failed native
// read. Restore the omitted-event scenario after that call, then replay
// its genuine missing-object response against the stale owned snapshot.
if restoreErr := tree.Replace(root.ID(), oldNodes); restoreErr != nil {
t.Fatal(restoreErr)
}
if replayErr := adapter.document_object_error(missing.ID(), err); !errors.Is(replayErr, ErrDeadObject) {
t.Fatal(replayErr)
}
tree, err = adapter.CaptureDocument(ctx, start.ID())
if err != nil {
t.Fatal(err)
}
if _, err := tree.Reference(otherOld.ID()); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("recapture retained another dead heading: %v", err)
}
root, err = tree.Root()
if err != nil {
t.Fatal(err)
}
fresh, err := tree.Document(ctx, root)
if err != nil {
t.Fatal(err)
}
expected := []string{"Later refreshed post", "Middle refreshed post", "Earlier refreshed post"}
headingIndex := 0
for index := len(fresh.Nodes) - 1; index >= 0; index-- {
node := fresh.Nodes[index]
if node.Snapshot.Role != "heading" {
continue
}
if headingIndex >= len(expected) || node.Snapshot.Name != expected[headingIndex] {
t.Fatalf("unexpected backward heading %q at index %d", node.Snapshot.Name, headingIndex)
}
text, err := adapter.ReadDocumentCaret(ctx, node.Reference, 0, DocumentCaretBrailleLine)
if err != nil || text.Text != expected[headingIndex] {
t.Fatalf("backward feed reading %q = %+v, %v", expected[headingIndex], text, err)
}
headingIndex++
}
if headingIndex != len(expected) {
t.Fatalf("backward headings = %d, want %d", headingIndex, len(expected))
}
}
+25
View File
@@ -428,6 +428,31 @@ func (tree *AccessibleTree) MarkChildrenChanged(parentID ObjectID) int {
return 1
}
// markAncestorChildrenChanged refreshes child order up to the root. Web
// providers can reveal or replace whole branches without children-changed
// events. Keep surviving identities and unrelated snapshots so recapture
// discovers the current branch from the nearest live ancestor.
func (tree *AccessibleTree) markAncestorChildrenChanged(id ObjectID) int {
tree.mu.Lock()
defer tree.mu.Unlock()
invalidated := 0
for {
entry, ok := tree.nodes[id]
if !ok {
return invalidated
}
if !entry.childrenInvalid {
entry.childrenInvalid = true
tree.nodes[id] = entry
invalidated++
}
if !entry.hasParent {
return invalidated
}
id = entry.parent
}
}
func (tree *AccessibleTree) resolveLocked(reference ObjectReference) (treeEntry, error) {
entry, ok := tree.nodes[reference.id]
if !ok {
+1 -1
View File
@@ -57,7 +57,7 @@ func (worker *Worker) readDocumentLine(ctx context.Context, tree *atspi.Accessib
}
}
}
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
captureStarted := time.Now()
tree, err := worker.capturer.CaptureDocumentText(captureContext, focus)
result.CaptureDuration = time.Since(captureStarted)
+1 -1
View File
@@ -100,7 +100,7 @@ func (worker *Worker) listDocument(ctx context.Context, cursor *DocumentCursor,
if !list.focus.Valid() {
return list, ErrNoCurrent
}
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
defer cancel()
stop := context.AfterFunc(query.ctx, cancel)
defer stop()
+7 -7
View File
@@ -71,7 +71,7 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
// allowing a constantly mutating page to loop indefinitely.
result.CaptureReason = result.Err
result.DocumentDiagnostics.CaptureAttempts++
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
captureStarted := time.Now()
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
result.CaptureDuration = time.Since(captureStarted)
@@ -98,7 +98,7 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
// A second replacement can race the first capture. Retry once while
// the navigation has not moved, so the heading is not skipped.
result.DocumentDiagnostics.CaptureAttempts++
retryContext, retryCancel := context.WithTimeout(ctx, worker.captureTimeout)
retryContext, retryCancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
retryStarted := time.Now()
tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus)
result.CaptureDuration += time.Since(retryStarted)
@@ -120,7 +120,7 @@ func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *Document
result.Err = ErrNoCurrent
return result, cursor
}
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
captureStarted := time.Now()
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
result.CaptureDuration = time.Since(captureStarted)
@@ -171,7 +171,7 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
// capture; repeating the navigation command could skip to another target.
oldPosition := result.DocumentPosition
result.DocumentDiagnostics.CaptureAttempts++
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
captureStarted := time.Now()
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
result.CaptureDuration += time.Since(captureStarted)
@@ -271,7 +271,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
}
result.CaptureReason = result.Err
result.DocumentDiagnostics.CaptureAttempts++
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
captureStarted := time.Now()
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
result.CaptureDuration = time.Since(captureStarted)
@@ -300,7 +300,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
// A dynamic page can replace the captured target between recapture and
// SyncFocus. Retry once only if the first attempt did not move the caret.
result.DocumentDiagnostics.CaptureAttempts++
retryContext, retryCancel := context.WithTimeout(ctx, worker.captureTimeout)
retryContext, retryCancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
retryStarted := time.Now()
tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus)
result.CaptureDuration += time.Since(retryStarted)
@@ -436,7 +436,7 @@ func (worker *Worker) refresh_document_cursor(ctx context.Context, cursor *Docum
if cursor != nil && len(cursor.places) > 0 {
saved = cursor.places[len(cursor.places)-1]
}
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
defer cancel()
tree, err := worker.capturer.CaptureDocument(captureContext, focus)
if err != nil {
+34
View File
@@ -1034,3 +1034,37 @@ func TestDocumentCaretKeepsOffsetAfterDocumentRefresh(t *testing.T) {
})
}
}
func TestDocumentWorkerUsesSeparateCaptureDeadline(t *testing.T) {
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
worker, err := NewWorker(capturer, time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.SetDocumentCaptureTimeout(15 * time.Second); err != nil {
t.Fatal(err)
}
if err := worker.Start(context.Background()); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
if err := worker.SetDocumentCaptureTimeout(time.Second); err == nil {
t.Fatal("changed document capture deadline after start")
}
worker.SyncFocus(webID("/doc"))
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
t.Fatal(err)
}
call := <-capturer.calls
deadline, ok := call.ctx.Deadline()
if !ok || time.Until(deadline) < 13*time.Second {
t.Fatalf("document capture deadline = %v, present = %t", deadline, ok)
}
// A shutdown must still cancel a capture with the larger budget.
if err := worker.Stop(context.Background()); err != nil {
t.Fatal(err)
}
if !errors.Is(call.ctx.Err(), context.Canceled) {
t.Fatalf("capture was not canceled: %v", call.ctx.Err())
}
}
+26 -9
View File
@@ -40,9 +40,10 @@ var ErrRequestQueueFull = errors.New("object review request queue is full")
// requests remain ordered. After a document timeout, queued document commands
// are retired so an unavailable provider cannot build a backlog of failures.
type Worker struct {
capturer TreeCapturer
captureTimeout time.Duration
flatCaptureTimeout time.Duration
capturer TreeCapturer
captureTimeout time.Duration
flatCaptureTimeout time.Duration
documentCaptureTimeout time.Duration
stateMutex sync.Mutex
latestFocus atspi.ObjectID
@@ -95,12 +96,13 @@ func NewWorker(capturer TreeCapturer, captureTimeout time.Duration) (*Worker, er
return nil, errors.New("create object review worker: capture timeout must be positive")
}
return &Worker{
capturer: capturer,
captureTimeout: captureTimeout,
flatCaptureTimeout: captureTimeout,
wakeFocus: make(chan struct{}, 1),
requests: make(chan request, requestCapacity),
results: make(chan Result, resultCapacity),
capturer: capturer,
captureTimeout: captureTimeout,
flatCaptureTimeout: captureTimeout,
documentCaptureTimeout: captureTimeout,
wakeFocus: make(chan struct{}, 1),
requests: make(chan request, requestCapacity),
results: make(chan Result, resultCapacity),
}, nil
}
@@ -119,6 +121,21 @@ func (worker *Worker) SetFlatCaptureTimeout(timeout time.Duration) error {
return nil
}
// SetDocumentCaptureTimeout gives whole-document capture its own budget.
// Call before Start; native text, placement, and activation keep their short deadline.
func (worker *Worker) SetDocumentCaptureTimeout(timeout time.Duration) error {
if timeout <= 0 {
return errors.New("document capture timeout must be positive")
}
worker.stateMutex.Lock()
defer worker.stateMutex.Unlock()
if worker.started {
return errors.New("cannot change document capture timeout after start")
}
worker.documentCaptureTimeout = timeout
return nil
}
func (worker *Worker) Results() <-chan Result {
return worker.results
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html><meta charset="utf-8"><title>Cthulhu Browser Probe</title>
<button autofocus>Feed start</button>
<main><section id="feed"><article><section><h2>Earlier post</h2></section></article><article><section><h2>Middle post</h2></section></article><article><section><h2>Later post</h2></section></article></section></main>
<script>
document.addEventListener('keydown', event => {
if (event.key === 'F9') {
document.getElementById('feed').outerHTML = '<section id="feed"><article><section><h2>Earlier refreshed post</h2></section></article><article><section><h2>Middle refreshed post</h2></section></article><article><section><h2>Later refreshed post</h2></section></article></section>';
document.querySelector('button').textContent = 'Feed replacement complete';
event.preventDefault();
}
});
</script>
+3
View File
@@ -12,6 +12,9 @@ from native_test_session import run_private_session
BROWSER_PROOFS = {
"replaced-feed": ("./internal/atspi", "^TestNativeBrowserMissingFeedRefreshesWholeBranch$", 30, 35, "browser-replaced-feed.html"),
"nested-document": ("./internal/app", "^TestNativeBrowserNestedDocumentHeadings$", 40, 45),
"reveal": ("./internal/app", "^TestNativeBrowserRevealedHeadings$", 60, 65),
"flat-chrome": ("./internal/review", "^TestNativeBrowserChromeFlatReview$", 35, 40),
"generatedlinks": ("./internal/app", "^TestNativeBrowserGeneratedLinkNavigation$", 30, 35),
"replacement": ("./internal/app", "^TestNativeBrowserInlineReplacementNavigation$", 30, 35),