Some browser fixes.
This commit is contained in:
1 parent
8a560a25a5
commit
7c032a5f6e
22 files changed
+631
-31
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user