From 9462ebfc82f63d348d5a87132797dd5c864ba552 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 24 Oct 2026 18:33:04 -0400 Subject: [PATCH] More browser work. --- docs/testing/browser-audit-fixes.md | 140 ++++++++++++++++++ internal/atspi/adapter.go | 11 +- internal/atspi/adapter_document_native.go | 21 ++- .../atspi/adapter_event_callback_native.go | 50 +++++-- internal/atspi/adapter_events.c | 60 ++++++++ internal/atspi/adapter_lifecycle_native.go | 1 + internal/atspi/adapter_native.h | 8 + internal/atspi/adapter_secure_native.go | 1 + internal/atspi/browser_live_native_test.go | 118 +++++++++++++++ internal/atspi/document_caret.go | 5 +- internal/atspi/document_view.go | 7 + internal/atspi/live_regions.go | 46 ++++++ internal/atspi/live_regions_native.go | 66 +++++++++ internal/atspi/live_regions_test.go | 39 +++++ internal/review/document.go | 1 + internal/review/document_caret.go | 17 ++- internal/review/document_caret_line.go | 4 +- internal/review/document_caret_ranges.go | 54 +++++-- internal/review/document_caret_text.go | 32 +++- internal/review/document_hypertext_test.go | 74 +++++++++ internal/review/document_navigation_reader.go | 18 +++ internal/review/document_table.go | 134 +++++++++++++---- internal/review/document_table_test.go | 51 +++++++ internal/review/document_visual_line.go | 22 +-- internal/review/document_word_spaces_test.go | 78 ++++++++++ testdata/parity-ledger.csv | 4 +- tests/browser-live.html | 31 ++++ tests/native_browser.py | 1 + 28 files changed, 1000 insertions(+), 94 deletions(-) create mode 100644 docs/testing/browser-audit-fixes.md create mode 100644 internal/atspi/browser_live_native_test.go create mode 100644 internal/atspi/live_regions.go create mode 100644 internal/atspi/live_regions_native.go create mode 100644 internal/atspi/live_regions_test.go create mode 100644 internal/review/document_word_spaces_test.go create mode 100644 tests/browser-live.html diff --git a/docs/testing/browser-audit-fixes.md b/docs/testing/browser-audit-fixes.md new file mode 100644 index 0000000..a697a74 --- /dev/null +++ b/docs/testing/browser-audit-fixes.md @@ -0,0 +1,140 @@ +# Browser audit fixes + +This work addresses the browser review of `bf4b9d7` against local Orca. The first +implementation batch fixes the four reproduced defects and two navigation costs. +It also adds busy live-region deferral. It does not establish complete Orca parity. + +## Implemented behavior + +- Crossing objects validates dirty hypertext ancestors, because parent-owned text + can change embedded offsets without changing accessible child identities. + A failed move preserves the position; the refreshed retry reads the insertion. + A local character move within an unchanged child still avoids recapture. +- Atomic updates read the enclosing atomic region for both text changes and child + additions. The source's effective `container-atomic` metadata gates this path. + Chromium exposes `atomic=false` on ordinary descendants, so those default values + do not terminate the ancestor walk. +- Table movement crosses missing cells, including neighbors in lower rows of a + spanning cell. It uses captured cells rather than iterating huge sparse row or + column dimensions. Real blank cells remain stops. Table validation and lookup + operate on the table subtree and its ancestors, preserving refreshes for changed + coordinates, headers and cell data while allowing unrelated page updates. +- Word movement skips whitespace using blocks of at most 128 native Unicode + characters. Only observed whitespace triggers this path, so ordinary word and + character movement do not gain an extra block request. Native word boundaries, + spelling metadata and embedded-object limits retain their existing handling. +- The cursor indexes object-to-range positions once per immutable document view. + A hypertext object can have multiple ranges, and lookup preserves their actual + traversal order. Retired views rebuild both the ranges and their index. +- Busy live regions defer updates until their busy flag clears. The GLib owner + keeps at most 32 region/document identities, without retained native references + or intermediate text. Completion rereads the active region and enters the live + insertion path, without acquiring focus or producing document-loading speech. + Secure input, tab selection, object destruction, document lifecycle and shutdown + retire the relevant deferred state. Live attributes are fetched together once + per candidate event, after foreground filtering. + +The browser must expose accurate live attributes. A diagnostic Chromium fixture +which set `aria-busy=true` and replaced descendant text in the same JavaScript +turn reported `container-busy=false` on the initial descendant event. Cthulhu +cannot reliably defer an update which the provider reports as not busy. The +retained regression establishes busy state before the initial accessibility +capture and proves that clearing the flag alone releases the deferred content. +Nested explicit atomic=false boundaries likewise depend on correct effective +`container-atomic` metadata from the provider. + +## Automated evidence + +The AT-SPI, review and app packages passed with the race detector. The full race +run failed in `internal/sound` at +`TestNewPresentationInterruptsOldCueAndDiscardsPendingCues` with “stale link cues +remained queued.” Ten isolated repetitions also reproduced that failure in an +untouched snapshot of `bf4b9d7`; the sound files were not changed. This remains an +existing gate, so the full suite is not described as passing. + +Thirteen selected Chromium fixtures passed in a private X11/D-Bus session: +`generatedlinks,replacement,fragments,namedlinks,linkedheadings,tables,selection,modes,document,editing,tabs,live,live-atomic`. +The final `live-atomic` run also verifies that busy completion requires no new text +insertion. It covers atomic text updates, atomic child additions, non-atomic +updates, suppression while busy, and release after busy becomes false. + +Firefox baseline reading passed. Its tab regression timed out on the second +“New Tab” announcement in both the modified checkout and an untouched `bf4b9d7` +snapshot. The recorded events include a selected-state event for the previous +unselected tab. This is an unresolved existing/provider case, not a verified +regression introduced by these changes. + +The affected `go vet` checks, normal reader build, BrlAPI helper build, Python +syntax check, parity-ledger validation and whitespace check passed. No installed +reader was replaced. The native fixtures record protocol/snapshot behavior; +they do not prove audible output or physical keyboard/Braille acceptance. + +A counting reader crossed 1,000 whitespace characters with three word commands +using 39 reads, including 16 character-block reads. The old implementation used +1,003 reads for a single forward word command. This is an API-call count, not a +measurement of audible latency. + +Three 100 ms synthetic benchmark repetitions still show character round trips +near 12 microseconds at 10,000 content nodes. Allocation traffic remains about +16 KB per character round trip and 52 KB per worker heading round trip. These +runs overlapped other checks and are informational, not a controlled timing +comparison. They exclude native IPC, initial capture, speech and physical input. + +Run the relevant checks: + +```sh +python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache go test -race ./internal/atspi ./internal/review ./internal/app -count=1 +CTHULHU_NATIVE_BROWSER_TESTS=live-atomic,generatedlinks,replacement,fragments,namedlinks,linkedheadings,tables,selection,modes,document,editing,tabs,live python3 tests/native_browser.py +python3 tests/native_firefox.py +go vet ./internal/atspi ./internal/review ./internal/app +go run ./cmd/parityledger validate testdata/parity-ledger.csv +``` + +## Human acceptance before submission + +Review the generated diff under the project's AI-assisted contribution policy. +With an explicitly rebuilt reader, test these workflows in Chromium and Firefox: + +1. Read forward and backward through links and changing text between them. Read + All must include inserted text once and continue through generated links. +2. Check atomic status updates retain their surrounding label, non-atomic updates + announce the change, and busy updates wait for the completed region. +3. Navigate ragged and merged-cell tables in all directions. Verify headers, + blank cells, logical row/column retention, nested tables and boundary messages. + Repeat with an unrelated page ticker and then a changed cell/header. +4. Move by word through long indentation, tabs, newlines and Unicode whitespace. + Check reverse movement, emoji, named links, and character-by-character spacing. +5. Type/select in an editor; return from the address bar and another tab; check + Steam browsing/search; verify stale targets never activate replacement controls. + Compare speech and physical Braille where available. + +Do not submit or replace the active reader automatically. Preserve logs if a +workflow fails and distinguish provider metadata from reader-owned state. + +## Remaining review work + +The user authorized addressing the whole review, in priority order. On October +24, the maintainer prioritized stabilizing the current release before adding +new functionality. MathCAT was left to implementation judgment and is deferred +with the other feature additions below. Release work should concentrate on the +existing fixes, the recorded test failures, and live acceptance. These items +remain open and must not be reported as implemented: + +- Automatic language switching, including language runs within a line, native + focus, structural navigation, character reading and Read All. The current + speech queue and client selection do not carry per-content language metadata. +- Structured math speech, Braille and exploration. MathCAT's maintained C + interface provides a shared engine, but adds a Rust-built library and its rule + data to packaging. No MathCAT dependency has been added in this batch. +- Constrained label inference for controls without an accessible name or explicit + label relationship. Keep explicit labels authoritative and reject ambiguous + neighboring text. This is a deliberate presentation-policy addition. +- Demand-driven capture for pages exceeding the 30,000-object safety bound. + The bound and whole-document initial capture remain unchanged. A partial tree + must represent unloaded branches explicitly so it cannot silently report false + end-of-document or incomplete element lists as complete results. + +MathCAT integration should keep Go as the policy owner, use the upstream C ABI, +serialize engine state on a dedicated worker, and preserve browser responsiveness +when the engine or rules are unavailable. Revisit dependency and packaging choices +after the current release is stable; no MathCAT dependency is being added now. diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index d388272..90bbdef 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -112,11 +112,12 @@ var eventTypes = []string{ type Adapter struct { captureLogger atomic.Pointer[slog.Logger] - libreOfficeHeaders libreOfficeHeaderStore // copied choices shared with the event loop - applicationProcesses map[string]string // GLib owner; bounded provider identity cache - webNotificationRoots map[ObjectID]bool // copied identities only; no native references - foregroundReader *xorg.ForegroundReader // GLib thread only - foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client + libreOfficeHeaders libreOfficeHeaderStore // copied choices shared with the event loop + applicationProcesses map[string]string // GLib owner; bounded provider identity cache + webNotificationRoots map[ObjectID]bool // copied identities only; no native references + deferredLiveRegions deferredLiveRegionStore // GLib owner; copied identities only + foregroundReader *xorg.ForegroundReader // GLib thread only + foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client foreground atomic.Pointer[ForegroundState] echoFocus atomic.Pointer[echoFocus] eventListener *C.AtspiEventListener // GLib owner; released with subscriptions. diff --git a/internal/atspi/adapter_document_native.go b/internal/atspi/adapter_document_native.go index c37e084..c26bc9c 100644 --- a/internal/atspi/adapter_document_native.go +++ b/internal/atspi/adapter_document_native.go @@ -259,6 +259,12 @@ func (adapter *Adapter) ReadDocumentCaret(ctx context.Context, position ObjectRe return result.caret, err } +// ReadDocumentCharacters shares the normal GLib request/lifetime checks. The +// consumer can skip spaces in one block without one provider request per rune. +func (adapter *Adapter) ReadDocumentCharacters(ctx context.Context, position ObjectReference, offset int) (DocumentCaretText, error) { + return adapter.ReadDocumentCaret(ctx, position, offset, DocumentCaretCharacters) +} + func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position ObjectReference, offset int, unit DocumentCaretUnit) (DocumentCaretText, error) { if err := ctx.Err(); err != nil { return DocumentCaretText{}, err @@ -273,7 +279,7 @@ func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position Ob if err != nil { return DocumentCaretText{}, err } - if unit == DocumentCaretBrailleLine && snapshot.Role == "password text" { + if (unit == DocumentCaretBrailleLine || unit == DocumentCaretCharacters) && snapshot.Role == "password text" { return DocumentCaretText{}, ErrNoDocumentCaret } source := adapter.nativeDocument[position.ID()] @@ -303,6 +309,19 @@ func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position Ob // A caret after the final character still belongs to the final line. offset = count - 1 } + if unit == DocumentCaretCharacters { + start := max(0, offset/128*128) + end := min(count, start+128) + content := C.atspi_text_get_text(text, C.gint(start), C.gint(end), &nativeErr) + if nativeErr != nil { + err := adapter.document_object_error(position.ID(), nativeError("read document characters", nativeErr)) + C.g_error_free(nativeErr) + C.g_free(C.gpointer(content)) + return DocumentCaretText{}, err + } + defer C.g_free(C.gpointer(content)) + return DocumentCaretText{Start: start, End: end, RawText: C.GoString(content), CharacterCount: count}, nil + } if unit != DocumentCaretCharacter && unit != DocumentCaretWord { line, err := adapter.readNativeDocumentLine(ctx, position.ID(), snapshot, text, offset, count) if err != nil { diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 5a114c7..5eafb72 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -105,7 +105,7 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc // captureNativeEventSnapshot enriches the target without changing event ownership. // It runs on the GLib owner and retains no native references. -func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID) (Snapshot, ObjectID) { +func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID, live nativeLiveProperties) (Snapshot, ObjectID) { // Document focus/load presentation needs only the title. Real caret // events still capture text, including carets hosted on document roots. captureCaret := !isDocumentLifecycleEvent(eventType) && eventType != BusyStateEvent @@ -131,20 +131,12 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target * isFirefoxChromeAutocomplete(snapshot, C.cthulhu_in_active_document(target) != 0) } if eventType == TextInsertedEvent || eventType == ChildrenAddedEvent { - key := C.CString("container-live") - live := C.cthulhu_live_attribute(event.source, key) - C.free(unsafe.Pointer(key)) - snapshot.LivePoliteness = sanitizeSnapshotField(C.GoString(live)) - snapshot.LiveRegion = snapshot.LivePoliteness != "" && C.cthulhu_in_active_document(event.source) != 0 - C.g_free(C.gpointer(live)) + snapshot.LivePoliteness = live.politeness + snapshot.LiveRegion = live.politeness != "" && C.cthulhu_in_active_document(event.source) != 0 if snapshot.LiveRegion { - key = C.CString("container-atomic") - atomic := C.cthulhu_live_attribute(event.source, key) - C.free(unsafe.Pointer(key)) - snapshot.LiveAtomic = C.GoString(atomic) == "true" - C.g_free(C.gpointer(atomic)) + snapshot.LiveAtomic = live.atomic if eventType == TextInsertedEvent && snapshot.LiveAtomic { - text := C.cthulhu_live_content(event.source) + text := C.cthulhu_atomic_live_content(event.source) snapshot.Text = sanitizeSnapshotField(C.GoString(text)) C.g_free(C.gpointer(text)) } @@ -153,7 +145,7 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target * text := C.cthulhu_live_content(child) if snapshot.LiveAtomic { C.g_free(C.gpointer(text)) - text = C.cthulhu_live_content(event.source) + text = C.cthulhu_atomic_live_content(event.source) } snapshot.Text = sanitizeSnapshotField(C.GoString(text)) C.g_free(C.gpointer(text)) @@ -336,6 +328,7 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A eventType == SelectionChangedEvent && snapshot.Selected && strings.EqualFold(snapshot.ToolkitName, "Gecko")) { adapter.selectedTabWindow = focusEvent.Window adapter.activeDocument = ObjectID{} + adapter.deferredLiveRegions = nil } if IsWebDocumentRole(snapshot.Role) && (eventType == WindowActivatedEvent || eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == DocumentLoadCompleteEvent) { @@ -430,6 +423,10 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { adapter.applySecureReset() adapter.refreshForeground() + if isDocumentLifecycleEvent(eventType) || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 { + adapter.deferredLiveRegions.take(originID) + adapter.deferredLiveRegions.forget_document(originID) + } if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 { adapter.forgetDocumentWindow(originID) } else if eventType == WindowDestroyedEvent { @@ -449,7 +446,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { // notification script needs their semantics before that early return, // including when the provider is outside foreground ownership. webNotification := adapter.webNotification(event) - if invalidated && webNotification == nil && (eventType != DefunctStateEvent || !adapter.nativeFocusIs(originID)) { + if invalidated && webNotification == nil && eventType != ChildrenAddedEvent && (eventType != DefunctStateEvent || !adapter.nativeFocusIs(originID)) { return } if !shouldSpeakEvent(eventType, int(C.cthulhu_event_detail1(event))) { @@ -459,6 +456,20 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { adapter.backgroundEventsFiltered.Add(1) return } + var live nativeLiveProperties + if eventType == TextInsertedEvent || eventType == ChildrenAddedEvent || eventType == BusyStateEvent { + live = capture_live_properties(event.source) + } + if invalidated && webNotification == nil && eventType == ChildrenAddedEvent && (live.politeness == "" || live.politeness == "off") { + return + } + deferLive, completeLive := adapter.prepare_live_region(event, eventType, originID, live) + if deferLive { + return + } + if completeLive { + eventType = TextInsertedEvent + } if (isDocumentLifecycleEvent(eventType) || eventType == BusyStateEvent) && C.cthulhu_active_document(event.source) == 0 { return } @@ -489,7 +500,14 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { C.atspi_accessible_clear_cache_single(event.source) } payload := C.cthulhu_event_payload(event) - snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID) + snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID, live) + if completeLive { + text := C.cthulhu_live_content(event.source) + snapshot.Text = sanitizeSnapshotField(C.GoString(text)) + snapshot.LiveAtomic = true + C.g_free(C.gpointer(payload)) + payload = text + } // Shared Qt actions can report focus through their original, hidden menu // even while another menu emits focus-gained for that very accessible. // The explicit event establishes focus; the queried state may disagree. diff --git a/internal/atspi/adapter_events.c b/internal/atspi/adapter_events.c index a4fa5d0..8303be8 100644 --- a/internal/atspi/adapter_events.c +++ b/internal/atspi/adapter_events.c @@ -58,6 +58,66 @@ gchar *cthulhu_live_attribute(AtspiAccessible *source, const gchar *key) { return value; } +cthulhu_live_properties cthulhu_capture_live_properties(AtspiAccessible *source) { + cthulhu_live_properties properties = {0}; + GError *error = NULL; + GHashTable *attributes = atspi_accessible_get_attributes(source, &error); + if (error != NULL) g_clear_error(&error); + if (attributes == NULL) return properties; + properties.politeness = g_strdup(g_hash_table_lookup(attributes, "container-live")); + properties.atomic = g_strcmp0(g_hash_table_lookup(attributes, "container-atomic"), "true") == 0; + properties.busy = g_strcmp0(g_hash_table_lookup(attributes, "container-busy"), "true") == 0; + g_hash_table_unref(attributes); + return properties; +} + +// container-atomic is inherited. Chromium also publishes atomic=false on +// ordinary descendants, so only atomic=true identifies an enclosing region. +// The caller first checks the source's effective container-atomic attribute. +gchar *cthulhu_atomic_live_content(AtspiAccessible *source) { + AtspiAccessible *current = g_object_ref(source); + for (guint depth = 0; current != NULL && depth < 64; depth++) { + gchar *atomic = cthulhu_live_attribute(current, "atomic"); + gboolean boundary = g_strcmp0(atomic, "true") == 0; + g_free(atomic); + AtspiRole role = atspi_accessible_get_role(current, NULL); + if (boundary) { + gchar *content = cthulhu_live_content(current); + g_object_unref(current); + return content; + } + if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) { + g_object_unref(current); + return cthulhu_live_content(source); + } + AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL); + g_object_unref(current); + current = parent; + } + if (current != NULL) g_object_unref(current); + return NULL; +} + +AtspiAccessible *cthulhu_busy_live_container(AtspiAccessible *source) { + AtspiAccessible *current = g_object_ref(source); + for (guint depth = 0; current != NULL && depth < 64; depth++) { + AtspiStateSet *states = atspi_accessible_get_state_set(current); + gboolean busy = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_BUSY); + if (states != NULL) g_object_unref(states); + if (busy) return current; + AtspiRole role = atspi_accessible_get_role(current, NULL); + if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) { + g_object_unref(current); + return NULL; + } + AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL); + g_object_unref(current); + current = parent; + } + if (current != NULL) g_object_unref(current); + return NULL; +} + AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event) { if (!G_IS_VALUE(&event->any_data) || !G_VALUE_HOLDS_OBJECT(&event->any_data)) { return NULL; diff --git a/internal/atspi/adapter_lifecycle_native.go b/internal/atspi/adapter_lifecycle_native.go index 3b4ae1f..596ecf6 100644 --- a/internal/atspi/adapter_lifecycle_native.go +++ b/internal/atspi/adapter_lifecycle_native.go @@ -86,6 +86,7 @@ func (adapter *Adapter) Start(ctx context.Context) error { adapter.documentWindows = nil adapter.documentWindowIDs = nil adapter.clearNativeDocument() + adapter.deferredLiveRegions = nil adapter.clearNativeReview() adapter.clearMouseApplication() }() diff --git a/internal/atspi/adapter_native.h b/internal/atspi/adapter_native.h index 3f7952a..43f6ce9 100644 --- a/internal/atspi/adapter_native.h +++ b/internal/atspi/adapter_native.h @@ -102,6 +102,8 @@ gchar *cthulhu_radio_group_name(AtspiAccessible *source); void cthulhu_capture_set_position(AtspiAccessible *source, GHashTable *attributes, cthulhu_snapshot *snapshot); gboolean cthulhu_has_set_position(const gchar *role); gchar *cthulhu_live_content(AtspiAccessible *source); +gchar *cthulhu_atomic_live_content(AtspiAccessible *source); +AtspiAccessible *cthulhu_busy_live_container(AtspiAccessible *source); gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity unit); AtspiTextRange *cthulhu_text_get_string_at_offset(AtspiText *text, gint offset, AtspiTextGranularity unit, GError **error); @@ -111,6 +113,12 @@ gint cthulhu_event_detail1(AtspiEvent *event); gchar *cthulhu_document_uri(AtspiAccessible *source); gchar *cthulhu_event_payload(AtspiEvent *event); gchar *cthulhu_live_attribute(AtspiAccessible *source, const gchar *key); +typedef struct { + gchar *politeness; + gboolean atomic; + gboolean busy; +} cthulhu_live_properties; +cthulhu_live_properties cthulhu_capture_live_properties(AtspiAccessible *source); AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event); AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source); AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source); diff --git a/internal/atspi/adapter_secure_native.go b/internal/atspi/adapter_secure_native.go index 2cdb332..08289c6 100644 --- a/internal/atspi/adapter_secure_native.go +++ b/internal/atspi/adapter_secure_native.go @@ -43,6 +43,7 @@ func (adapter *Adapter) applySecureReset() { if !adapter.secureReset.Swap(false) { return } + adapter.deferredLiveRegions = nil adapter.invalidateTree() adapter.treeMutex.Lock() adapter.tree, adapter.documentTree, adapter.documentTextTree = nil, nil, nil diff --git a/internal/atspi/browser_live_native_test.go b/internal/atspi/browser_live_native_test.go new file mode 100644 index 0000000..42364cd --- /dev/null +++ b/internal/atspi/browser_live_native_test.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativeBrowserAtomicLiveRegion(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 func() { + stop, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := adapter.Stop(stop); err != nil { + t.Error(err) + } + }() + 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) + } + if out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Fields(string(window))[0]).CombinedOutput(); err != nil { + t.Fatalf("focus: %s: %v", out, err) + } + for _, test := range []struct { + key, marker, want string + atomic bool + }{ + {"F9", "Completed", "Download status: Completed", true}, + {"F10", "Verified", "Account status: Verified", true}, + {"F11", "Inner update", "Inner update", false}, + } { + t.Run(test.key, func(t *testing.T) { + if out, err := exec.CommandContext(ctx, "xdotool", "key", test.key).CombinedOutput(); err != nil { + t.Fatalf("trigger: %s: %v", out, err) + } + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + for { + select { + case event := <-adapter.Events(): + if !event.Source.LiveRegion || (!strings.Contains(event.Source.Text, test.marker) && !strings.Contains(event.Payload, test.marker)) { + continue + } + if event.Source.LiveAtomic != test.atomic || event.Source.LiveAtomic && event.Source.Text != test.want { + t.Fatalf("atomic=%v text=%q payload=%q; want atomic=%v text=%q", event.Source.LiveAtomic, event.Source.Text, event.Payload, test.atomic, test.want) + } + if !test.atomic && strings.Contains(event.Source.Text, "Outer context:") { + t.Fatalf("crossed atomic=false boundary: %q", event.Source.Text) + } + return + case <-deadline.C: + t.Fatal("live update missing") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + }) + } + // A busy region must remain silent while its intermediate contents change. + if out, err := exec.CommandContext(ctx, "xdotool", "key", "F12").CombinedOutput(); err != nil { + t.Fatalf("busy start: %s %v", out, err) + } + quiet := time.NewTimer(200 * time.Millisecond) +quietLoop: + for { + select { + case event := <-adapter.Events(): + if event.Source.LiveRegion && (strings.Contains(event.Source.Text, "Intermediate update") || strings.Contains(event.Payload, "Intermediate update")) { + t.Fatalf("busy region announced incomplete text: %+v", event) + } + case <-quiet.C: + break quietLoop + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + quiet.Stop() + if out, err := exec.CommandContext(ctx, "xdotool", "key", "F7").CombinedOutput(); err != nil { + t.Fatalf("busy complete: %s %v", out, err) + } + completion := time.NewTimer(5 * time.Second) + defer completion.Stop() + for { + select { + case event := <-adapter.Events(): + if event.Source.LiveRegion && strings.Contains(event.Source.Text, "Intermediate update") { + if event.Type != TextInsertedEvent && event.Type != ChildrenAddedEvent { + t.Fatalf("live completion changed focus/lifecycle: %+v", event) + } + if event.Source.Text != "Busy status: Intermediate update" { + t.Fatalf("completion text: %q", event.Source.Text) + } + return + } + case <-completion.C: + t.Fatal("busy completion missing") + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + +} diff --git a/internal/atspi/document_caret.go b/internal/atspi/document_caret.go index bda26b5..ab8b285 100644 --- a/internal/atspi/document_caret.go +++ b/internal/atspi/document_caret.go @@ -12,10 +12,13 @@ const ( DocumentCaretLineRemainder // DocumentCaretBrailleLine preserves native spacing for display offsets. DocumentCaretBrailleLine + // DocumentCaretCharacters returns a bounded block of raw characters for + // whitespace traversal. It does not normalize spaces or query line geometry. + DocumentCaretCharacters ) func (unit DocumentCaretUnit) Valid() bool { - return unit >= DocumentCaretCharacter && unit <= DocumentCaretBrailleLine + return unit >= DocumentCaretCharacter && unit <= DocumentCaretCharacters } type DocumentCaretText struct { diff --git a/internal/atspi/document_view.go b/internal/atspi/document_view.go index 04601dc..4b705b0 100644 --- a/internal/atspi/document_view.go +++ b/internal/atspi/document_view.go @@ -143,6 +143,13 @@ func (tree *AccessibleTree) validate_document_path_locked(ctx context.Context, d if requireOrder && entry.childrenInvalid { return ErrIncompleteTree } + // Hypertext ranges interleave parent-owned text with embedded + // children. An insertion can change those ranges without changing + // the accessible child list. Refresh before crossing objects, while + // allowing movement within an unchanged child to remain local. + if requireOrder && entry.textInvalid && len(entry.snapshot.TextChildren) != 0 { + return ErrStaleText + } if err := tree.validate_document_row_locked(document, entry.snapshot.ID()); err != nil { return err } diff --git a/internal/atspi/live_regions.go b/internal/atspi/live_regions.go new file mode 100644 index 0000000..fc584fd --- /dev/null +++ b/internal/atspi/live_regions.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +type deferredLiveRegion struct{ object, document ObjectID } + +// No text or native handles survive the event. Completion re-reads the owned +// active region. Secure input, shutdown and document replacement retire it. +type deferredLiveRegionStore []deferredLiveRegion + +func (store *deferredLiveRegionStore) remember(object, document ObjectID) { + if !object.Valid() || !document.Valid() { + return + } + for _, entry := range *store { + if entry.object == object { + return + } + } + if len(*store) == 32 { + *store = (*store)[1:] + } + *store = append(*store, deferredLiveRegion{object, document}) +} + +func (store *deferredLiveRegionStore) take(object ObjectID) (ObjectID, bool) { + for index, entry := range *store { + if entry.object == object { + *store = append((*store)[:index], (*store)[index+1:]...) + return entry.document, true + } + } + return ObjectID{}, false +} + +func (store *deferredLiveRegionStore) forget_document(document ObjectID) { + kept := (*store)[:0] + for _, entry := range *store { + if entry.document != document { + kept = append(kept, entry) + } + } + *store = kept +} diff --git a/internal/atspi/live_regions_native.go b/internal/atspi/live_regions_native.go new file mode 100644 index 0000000..b18c8d5 --- /dev/null +++ b/internal/atspi/live_regions_native.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +type nativeLiveProperties struct { + politeness string + atomic, busy bool +} + +func capture_live_properties(source *C.AtspiAccessible) nativeLiveProperties { + properties := C.cthulhu_capture_live_properties(source) + defer C.g_free(C.gpointer(properties.politeness)) + return nativeLiveProperties{sanitizeSnapshotField(C.GoString(properties.politeness)), properties.atomic != 0, properties.busy != 0} +} + +// Called after foreground filtering. A completion is transformed into a live +// insertion so it cannot acquire focus or enter document-loading policy. +func (adapter *Adapter) prepare_live_region(event *C.AtspiEvent, eventType string, origin ObjectID, properties nativeLiveProperties) (deferEvent, complete bool) { + if eventType != TextInsertedEvent && eventType != ChildrenAddedEvent && eventType != BusyStateEvent { + return false, false + } + var savedDocument ObjectID + if eventType == BusyStateEvent { + if C.cthulhu_event_detail1(event) != 0 { + return false, false + } + var found bool + savedDocument, found = adapter.deferredLiveRegions.take(origin) + if !found { + return false, false + } + } + if properties.politeness == "" || properties.politeness == "off" { + return eventType == BusyStateEvent, false + } + if eventType != BusyStateEvent && !properties.busy { + return false, false + } + if C.cthulhu_in_active_document(event.source) == 0 { + return eventType == BusyStateEvent, false + } + document := C.cthulhu_document_ancestor(event.source) + if document == nil { + return eventType == BusyStateEvent, false + } + documentID := nativeObjectID(document) + C.g_object_unref(C.gpointer(document)) + if savedDocument.Valid() && savedDocument != documentID { + return true, false + } + if properties.busy { + if owner := C.cthulhu_busy_live_container(event.source); owner != nil { + adapter.deferredLiveRegions.remember(nativeObjectID(owner), documentID) + C.g_object_unref(C.gpointer(owner)) + return true, false + } + } + return false, eventType == BusyStateEvent +} diff --git a/internal/atspi/live_regions_test.go b/internal/atspi/live_regions_test.go new file mode 100644 index 0000000..6e69184 --- /dev/null +++ b/internal/atspi/live_regions_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "fmt" + "testing" +) + +func TestDeferredLiveRegionLifetime(t *testing.T) { + var store deferredLiveRegionStore + for index := range 40 { + store.remember(testID(fmt.Sprintf("/region/%d", index)), testID("/document")) + } + if len(store) != 32 { + t.Fatalf("unbounded deferred state: %d", len(store)) + } + if _, found := store.take(testID("/region/0")); found { + t.Fatal("oldest entry not evicted") + } + store.remember(testID("/region/39"), testID("/document")) + if len(store) != 32 { + t.Fatal("duplicate added a second entry") + } + document, found := store.take(testID("/region/39")) + if !found || document != testID("/document") { + t.Fatal("lost deferred owner") + } + if _, found = store.take(testID("/region/39")); found { + t.Fatal("completed update remained deferred") + } + store.remember(testID("/other_region"), testID("/other_document")) + store.forget_document(testID("/document")) + if len(store) != 1 || store[0].document != testID("/other_document") { + t.Fatal("document retirement retained old entries or removed another document") + } +} diff --git a/internal/review/document.go b/internal/review/document.go index 8daa629..cba3d7e 100644 --- a/internal/review/document.go +++ b/internal/review/document.go @@ -85,6 +85,7 @@ type DocumentCursor struct { active atspi.ObjectReference caretDocument atspi.DocumentSnapshot caretRanges []caretRange + caretLookup *caretRangeLookup lineOwners map[int]atspi.ObjectID } diff --git a/internal/review/document_caret.go b/internal/review/document_caret.go index d1cc6f5..23e5fd2 100644 --- a/internal/review/document_caret.go +++ b/internal/review/document_caret.go @@ -53,6 +53,10 @@ type DocumentCaretReader interface { ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) } +type DocumentCharacterReader interface { + ReadDocumentCharacters(context.Context, atspi.ObjectReference, int) (atspi.DocumentCaretText, error) +} + type DocumentCaretMove struct { Position DocumentPosition Text string @@ -101,11 +105,6 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare return DocumentCaretMove{}, err } if command >= TableLeft { - // Table coordinates and row/header presentation consume the complete - // table, unlike ordinary caret movement's local text path. - if err := cursor.tree.ValidateDocumentRange(ctx, doc, 0, len(doc.Nodes)-1); err != nil { - return DocumentCaretMove{}, err - } movement, err := cursor.moveTable(ctx, doc, place, command) if err == nil && movement.Moved { *saved = next @@ -116,6 +115,7 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare if !doc.SameContent(cursor.caretDocument) { cursor.caretDocument = doc cursor.caretRanges = caretNodeRanges(doc.Nodes) + cursor.caretLookup = new_caret_range_lookup(cursor.caretRanges, len(doc.Nodes)) cursor.lineOwners = visual_line_owners(doc.Nodes) } indices := cursor.caretRanges @@ -128,13 +128,16 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare var movement DocumentCaretMove navigationReader := &navigationTextReader{DocumentCaretReader: reader, tree: cursor.tree, document: doc, anchor: current} reader = navigationReader + if characters, ok := navigationReader.DocumentCaretReader.(DocumentCharacterReader); ok { + reader = &navigationCharacterReader{navigationTextReader: navigationReader, source: characters} + } switch command { case CaretNextWord, CaretPreviousWord, CaretNextCharacter, CaretPreviousCharacter: - movement, err = moveCaretText(ctx, reader, doc, place, indices, current, + movement, err = moveCaretText(ctx, reader, doc, place, indices, cursor.caretLookup, current, command == CaretPreviousWord || command == CaretPreviousCharacter, command == CaretNextWord || command == CaretPreviousWord) default: - movement, err = move_caret_line(ctx, reader, cursor.tree, doc, place, indices, current, command, cursor.lineOwners) + movement, err = move_caret_line(ctx, reader, cursor.tree, doc, place, indices, cursor.caretLookup, current, command, cursor.lineOwners) } if err == nil && movement.Moved { target, found := doc.Index(place.object.ID()) diff --git a/internal/review/document_caret_line.go b/internal/review/document_caret_line.go index f8dbf73..b2a0f82 100644 --- a/internal/review/document_caret_line.go +++ b/internal/review/document_caret_line.go @@ -15,12 +15,12 @@ import ( // move_caret_line handles line and document boundaries under the cursor lock // held by MoveCaret. Text-unit movement has its separate traversal helper. -func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, command DocumentCaretCommand) (DocumentCaretMove, error) { +func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, lookup *caretRangeLookup, current int, command DocumentCaretCommand) (DocumentCaretMove, error) { unit := atspi.DocumentCaretLine if command == CaretReadLine { unit = atspi.DocumentCaretLineRemainder } - position, same := caretRangePosition(indices, current, place.offset) + position, same := lookup.position(indices, current, place.offset) backwards := command == CaretPreviousLine || command == CaretDocumentEnd if command == CaretDocumentStart { position = 0 diff --git a/internal/review/document_caret_ranges.go b/internal/review/document_caret_ranges.go index e707c4c..739be43 100644 --- a/internal/review/document_caret_ranges.go +++ b/internal/review/document_caret_ranges.go @@ -90,24 +90,50 @@ func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange { return result } -func caretRangePosition(ranges []caretRange, current, offset int) (int, bool) { +// Object-local range positions also handle a hypertext parent occurring on +// both sides of an embedded child. after preserves provider traversal order, +// which is not monotonically increasing in document node index. +type caretRangeLookup struct { + objects [][]int + after []int +} + +func new_caret_range_lookup(ranges []caretRange, nodeCount int) *caretRangeLookup { + lookup := &caretRangeLookup{objects: make([][]int, nodeCount), after: make([]int, nodeCount)} for position, item := range ranges { - if item.index == current && offset >= item.start && offset <= max(item.start, item.end-1) { + lookup.objects[item.index] = append(lookup.objects[item.index], position) + } + following := len(ranges) + for index := nodeCount - 1; index >= 0; index-- { + lookup.after[index] = following + if positions := lookup.objects[index]; len(positions) > 0 { + following = min(following, positions[0]) + } + } + return lookup +} + +func (lookup *caretRangeLookup) containing(ranges []caretRange, current, offset int) int { + for _, position := range lookup.objects[current] { + item := ranges[position] + if offset >= item.start && offset < max(item.start+1, item.end) { + return position + } + } + return -1 +} + +func (lookup *caretRangeLookup) position(ranges []caretRange, current, offset int) (int, bool) { + if position := lookup.containing(ranges, current, offset); position >= 0 { + return position, true + } + // Word and Read All cursors may be just after the final character. + for _, position := range lookup.objects[current] { + if offset == ranges[position].end { return position, true } } - // Word and read-all cursors may be just after the final character. - for position, item := range ranges { - if item.index == current && offset == item.end { - return position, true - } - } - for position, item := range ranges { - if item.index > current { - return position, false - } - } - return len(ranges), false + return lookup.after[current], false } func readCaretRange(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, item caretRange, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { diff --git a/internal/review/document_caret_text.go b/internal/review/document_caret_text.go index 75a91aa..b3750eb 100644 --- a/internal/review/document_caret_text.go +++ b/internal/review/document_caret_text.go @@ -17,12 +17,12 @@ import ( // Python caret_navigation starts a word move at the adjacent non-space // character, rather than the next/previous word boundary. In particular, // reversing at the end of a word must read that word, not skip over it. -func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, backwards, byWord bool) (DocumentCaretMove, error) { +func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, lookup *caretRangeLookup, current int, backwards, byWord bool) (DocumentCaretMove, error) { step := 1 if backwards { step = -1 } - position, same := caretRangePosition(indices, current, place.offset) + position, same := lookup.position(indices, current, place.offset) offset := 0 if same { offset = place.offset + step @@ -62,6 +62,16 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do if err == nil && character.End > character.Start { value, _ := utf8.DecodeRuneInString(character.Text) if character.Text == "" || byWord && unicode.IsSpace(value) || value == '\ufffc' || value == '\ufeff' { + if blocks, ok := reader.(DocumentCharacterReader); byWord && unicode.IsSpace(value) && ok && named_link_label(node.Snapshot) == "" { + nextOffset, err := skip_word_spaces(ctx, blocks, node.Reference, indices[position], offset, step) + if err != nil { + return DocumentCaretMove{}, err + } + if nextOffset != offset { + offset = nextOffset + continue + } + } offset += step continue } @@ -109,3 +119,21 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do } return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil } + +// Keep native Unicode offsets and stop at embedded-object range boundaries. +// A broken optional block cannot advance the cursor; ordinary unit reads then +// retain their existing provider-error handling. +func skip_word_spaces(ctx context.Context, reader DocumentCharacterReader, object atspi.ObjectReference, item caretRange, offset, step int) (int, error) { + block, err := reader.ReadDocumentCharacters(ctx, object, offset) + if err != nil { + return offset, err + } + runes := []rune(block.RawText) + if block.Start < 0 || block.Start > offset || block.End <= offset || block.End > block.CharacterCount || len(runes) != block.End-block.Start { + return offset, nil + } + for offset >= item.start && offset < item.end && offset >= block.Start && offset < block.End && unicode.IsSpace(runes[offset-block.Start]) { + offset += step + } + return offset, nil +} diff --git a/internal/review/document_hypertext_test.go b/internal/review/document_hypertext_test.go index fcc2dd0..2e6c07e 100644 --- a/internal/review/document_hypertext_test.go +++ b/internal/review/document_hypertext_test.go @@ -6,6 +6,7 @@ package review import ( "context" + "errors" "testing" "unicode/utf8" @@ -204,3 +205,76 @@ func TestDocumentCaretHypertextCannotEscapePasswordSubtree(t *testing.T) { t.Fatalf("password subtree exposed caret ranges: %+v", ranges) } } + +// Plain-text changes need not change accessible children or their identities. +func TestDocumentCaretRefreshesChangedHypertextAncestor(t *testing.T) { + for _, command := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter, CaretNextWord, CaretPreviousWord, CaretNextLine, CaretPreviousLine, CaretReadLine} { + t.Run(command.String(), func(t *testing.T) { + reader := caretTextReader{webID("/body"): "\ufffc\ufffc", webID("/one"): "A", webID("/two"): "B"} + makeNode := func(path, role string, children ...string) atspi.TreeNode { + s := atspi.NewSnapshot(path, "", role, "", "") + s.BusName = webID(path).BusName + s.CharacterCount = utf8.RuneCountInString(reader[webID(path)]) + n := atspi.TreeNode{Snapshot: s} + for _, child := range children { + n.Children = append(n.Children, webID(child)) + } + return n + } + body := makeNode("/body", "paragraph", "/one", "/two") + body.Snapshot.TextChildren = []atspi.TextChild{{Offset: 0, Child: webID("/one")}, {Offset: 1, Child: webID("/two")}} + nodes := []atspi.TreeNode{makeNode("/doc", "document web", "/body"), body, makeNode("/one", "link"), makeNode("/two", "link")} + tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, _ := NewDocumentCursor(tree) + path := "/one" + if command == CaretPreviousCharacter || command == CaretPreviousWord || command == CaretPreviousLine { + path = "/two" + } + if _, err = cursor.SyncFocus(context.Background(), webID(path)); err != nil { + t.Fatal(err) + } + reader[webID("/body")] = "\ufffc inserted \ufffc" + tree.InvalidateText(webID("/body")) + if command == CaretReadLine { + cursor.places[0].offset = 1 + } + before := cursor.places[0] + move, err := cursor.MoveCaret(context.Background(), reader, command) + if !errors.Is(err, atspi.ErrStaleText) { + t.Fatalf("skipped parent insertion: move=%+v error=%v", move, err) + } + if after := cursor.places[0]; after.object != before.object || after.offset != before.offset { + t.Fatal("failed move changed position") + } + // A successful refresh must expose the insertion on the retry, + // retaining both embedded link identities and the original position. + nodes[1].Snapshot.CharacterCount = utf8.RuneCountInString(reader[webID("/body")]) + nodes[1].Snapshot.TextChildren = []atspi.TextChild{{Offset: 0, Child: webID("/one")}, {Offset: nodes[1].Snapshot.CharacterCount - 1, Child: webID("/two")}} + if err := tree.Replace(webID("/doc"), nodes); err != nil { + t.Fatal(err) + } + move, err = cursor.MoveCaret(context.Background(), reader, command) + if err != nil || !move.Moved || move.Position.Object.ID() != webID("/body") { + t.Fatalf("refreshed move skipped insertion: %+v error=%v", move, err) + } + }) + } +} + +func TestDocumentCaretLocalMoveSurvivesHypertextInsertion(t *testing.T) { + cursor, reader := hypertextCursor(t) + if _, err := cursor.SyncFocus(context.Background(), webID("/link")); err != nil { + t.Fatal(err) + } + cursor.tree.InvalidateText(webID("/body")) + move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter) + if err != nil || move.Text != "e" { + t.Fatalf("local character: %+v %v", move, err) + } + if _, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine); !errors.Is(err, atspi.ErrStaleText) { + t.Fatalf("crossing changed hypertext: %v", err) + } +} diff --git a/internal/review/document_navigation_reader.go b/internal/review/document_navigation_reader.go index 47fc453..fe54aaf 100644 --- a/internal/review/document_navigation_reader.go +++ b/internal/review/document_navigation_reader.go @@ -44,3 +44,21 @@ func (reader *navigationTextReader) did_read(object atspi.ObjectReference) bool } return false } + +// Character blocks belong to one word command and never survive an event or +// recapture. Native spelling and actual word boundaries still use normal reads. +type navigationCharacterReader struct { + *navigationTextReader + source DocumentCharacterReader +} + +func (reader *navigationCharacterReader) ReadDocumentCharacters(ctx context.Context, object atspi.ObjectReference, offset int) (atspi.DocumentCaretText, error) { + index, found := reader.document.Index(object.ID()) + if !found || reader.document.Nodes[index].Reference != object { + return atspi.DocumentCaretText{}, atspi.ErrStaleObject + } + if err := reader.tree.ValidateDocumentRange(ctx, reader.document, reader.anchor, index); err != nil { + return atspi.DocumentCaretText{}, err + } + return reader.source.ReadDocumentCharacters(ctx, object, offset) +} diff --git a/internal/review/document_table.go b/internal/review/document_table.go index 4378751..a5df0ee 100644 --- a/internal/review/document_table.go +++ b/internal/review/document_table.go @@ -16,42 +16,35 @@ import ( // The virtual coordinate survives a spanning cell so reversing direction keeps // the user's column/row. Nested tables have independent coordinate spaces. func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentSnapshot, place *documentPlace, command DocumentCaretCommand) (DocumentCaretMove, error) { - nodes := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes)) - parents := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes)) - for _, node := range doc.Nodes { - nodes[node.Reference.ID()] = node - for _, child := range node.Children { - parents[child] = node.Reference.ID() - } - } - nearestTable := func(id atspi.ObjectID) atspi.ObjectID { - for depth := 0; id.Valid() && depth < 128; depth++ { - if nodes[id].Snapshot.Role == "table" || nodes[id].Snapshot.Role == "tree table" { - return id - } - id = parents[id] - } - return atspi.ObjectID{} - } - table := nearestTable(place.object.ID()) + tableIndex, found := document_table_index(doc, place.object.ID()) message := func(text string) (DocumentCaretMove, error) { return DocumentCaretMove{Position: documentPosition(doc, *place), Text: text}, nil } - if !table.Valid() { + if !found { return message("Not in a table.") } + table := doc.Nodes[tableIndex] + tableEnd := doc.SubtreeEnd(tableIndex) + if err := cursor.tree.ValidateDocumentRange(ctx, doc, tableIndex, tableEnd); err != nil { + return DocumentCaretMove{}, err + } var current atspi.DocumentNode - for id, depth := place.object.ID(), 0; id.Valid() && id != table && depth < 128; id, depth = parents[id], depth+1 { - role := nodes[id].Snapshot.Role - if (role == "table cell" || role == "row header" || role == "column header") && !nodes[id].Snapshot.TablePositionKnown { - return message("Table coordinates unavailable.") - } - if nodes[id].Snapshot.TablePositionKnown { - current = nodes[id] + for id, depth := place.object.ID(), 0; id.Valid() && id != table.Reference.ID() && depth < 128; depth++ { + node, ok := doc.Node(id) + if !ok { break } + role := node.Snapshot.Role + if (role == "table cell" || role == "row header" || role == "column header") && !node.Snapshot.TablePositionKnown { + return message("Table coordinates unavailable.") + } + if node.Snapshot.TablePositionKnown { + current = node + break + } + id = node.Parent } - rows, columns := nodes[table].Snapshot.TableRowCount, nodes[table].Snapshot.TableColumnCount + rows, columns := table.Snapshot.TableRowCount, table.Snapshot.TableColumnCount if rows <= 0 || columns <= 0 { return message("Table coordinates unavailable.") } @@ -82,8 +75,13 @@ func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentS row, col = 0, 0 } cells := make([]atspi.DocumentNode, 0) - for _, node := range doc.Nodes { - if node.Snapshot.TablePositionKnown && !node.Hidden && nearestTable(node.Reference.ID()) == table { + for index := tableIndex + 1; index <= tableEnd; index++ { + node := doc.Nodes[index] + if node.Snapshot.Role == "table" || node.Snapshot.Role == "tree table" { + index = doc.SubtreeEnd(index) + continue + } + if node.Snapshot.TablePositionKnown && !node.Hidden { cells = append(cells, node) } } @@ -111,6 +109,9 @@ func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentS break } } + if !target.Reference.Valid() && (dr != 0 || dc != 0) { + target, row, col = next_available_table_cell(cells, current, row, col, dr, dc) + } if !target.Reference.Valid() { return message("Table cell unavailable.") } @@ -165,3 +166,78 @@ func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentS } return message("Table cell unavailable.") } + +// Use the immutable document index and avoid rebuilding whole-page parent maps. +func document_table_index(doc atspi.DocumentSnapshot, id atspi.ObjectID) (int, bool) { + for depth := 0; id.Valid() && depth < 128; depth++ { + index, ok := doc.Index(id) + if !ok { + break + } + node := doc.Nodes[index] + if node.Snapshot.Role == "table" || node.Snapshot.Role == "tree table" { + return index, true + } + id = node.Parent + } + return 0, false +} + +// Search captured cells rather than iterating potentially enormous sparse +// dimensions. Horizontal neighbors can occupy a lower row of a spanning cell. +func next_available_table_cell(cells []atspi.DocumentNode, current atspi.DocumentNode, row, col, dr, dc int) (atspi.DocumentNode, int, int) { + var target atspi.DocumentNode + bestRow, bestCol := row, col + for _, node := range cells { + if node.Reference == current.Reference { + continue + } + s := node.Snapshot + rowEnd, colEnd := s.TableRowIndex+max(1, s.TableRowSpan)-1, s.TableColumnIndex+max(1, s.TableColumnSpan)-1 + candidateRow, candidateCol := row, col + if dr != 0 { + if col < s.TableColumnIndex || col > colEnd { + continue + } + if dr > 0 { + candidateRow = max(row, s.TableRowIndex) + if candidateRow > rowEnd { + continue + } + } else { + candidateRow = min(row, rowEnd) + if candidateRow < s.TableRowIndex { + continue + } + } + if target.Reference.Valid() && (dr > 0 && candidateRow >= bestRow || dr < 0 && candidateRow <= bestRow) { + continue + } + } else { + lastRow := row + if current.Reference.Valid() { + lastRow = max(row, current.Snapshot.TableRowIndex+max(1, current.Snapshot.TableRowSpan)-1) + } + candidateRow = max(row, s.TableRowIndex) + if candidateRow > rowEnd || candidateRow > lastRow { + continue + } + if dc > 0 { + candidateCol = max(col, s.TableColumnIndex) + if candidateCol > colEnd { + continue + } + } else { + candidateCol = min(col, colEnd) + if candidateCol < s.TableColumnIndex { + continue + } + } + if target.Reference.Valid() && (candidateRow > bestRow || candidateRow == bestRow && (dc > 0 && candidateCol >= bestCol || dc < 0 && candidateCol <= bestCol)) { + continue + } + } + target, bestRow, bestCol = node, candidateRow, candidateCol + } + return target, bestRow, bestCol +} diff --git a/internal/review/document_table_test.go b/internal/review/document_table_test.go index f848cc5..d0f9f28 100644 --- a/internal/review/document_table_test.go +++ b/internal/review/document_table_test.go @@ -6,6 +6,7 @@ package review import ( "context" + "errors" "strings" "testing" @@ -126,3 +127,53 @@ func TestTableNavigationRowSpanAndNestedTable(t *testing.T) { t.Fatalf("nested table escaped: %+v %v", got, err) } } + +func TestTableNavigationSkipsHolesAndKeepsLocalDependencies(t *testing.T) { + makeNode := func(path, role string, children ...string) atspi.TreeNode { + s := atspi.NewSnapshot(path, path, role, "", "") + s.BusName = webID(path).BusName + n := atspi.TreeNode{Snapshot: s} + for _, child := range children { + n.Children = append(n.Children, webID(child)) + } + return n + } + nodes := []atspi.TreeNode{makeNode("/doc", "document web", "/table", "/ticker"), makeNode("/table", "table", "/a", "/b", "/c"), makeNode("/ticker", "static")} + nodes[1].Snapshot.TableRowCount, nodes[1].Snapshot.TableColumnCount = 1000000, 3 + for _, cell := range []struct { + path string + row, col, span int + }{{"/a", 0, 0, 1000000}, {"/b", 1, 1, 1}, {"/c", 999999, 1, 1}} { + n := makeNode(cell.path, "table cell") + n.Snapshot.TablePositionKnown = true + n.Snapshot.TableRowIndex, n.Snapshot.TableColumnIndex, n.Snapshot.TableRowSpan = cell.row, cell.col, cell.span + nodes = append(nodes, n) + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, _ := NewDocumentCursor(tree) + if _, err = cursor.SyncFocus(context.Background(), webID("/a")); err != nil { + t.Fatal(err) + } + tree.InvalidateText(webID("/ticker")) + for _, step := range []struct { + command DocumentCaretCommand + path string + }{{TableRight, "/b"}, {TableDown, "/c"}, {TableUp, "/b"}, {TableLeft, "/a"}, {TableRight, "/b"}} { + move, err := cursor.MoveCaret(context.Background(), caretTextReader{}, step.command) + if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) { + t.Fatalf("%s: %+v error=%v", step.command, move, err) + } + } + // Changed cells inside the table must still refresh the coordinate/header view. + tree.InvalidateSnapshot(webID("/c")) + before := cursor.places[0] + if _, err = cursor.MoveCaret(context.Background(), caretTextReader{}, TableDown); !errors.Is(err, atspi.ErrStaleSnapshot) { + t.Fatalf("stale cell ignored: %v", err) + } + if after := cursor.places[0]; after.object != before.object || after.tableRow != before.tableRow { + t.Fatal("failed move changed table position") + } +} diff --git a/internal/review/document_visual_line.go b/internal/review/document_visual_line.go index b94f0ba..d47249c 100644 --- a/internal/review/document_visual_line.go +++ b/internal/review/document_visual_line.go @@ -48,19 +48,16 @@ func (reader *visualLineReader) ReadDocumentCaret(ctx context.Context, object at return text, err } -func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, command DocumentCaretCommand, owners map[int]atspi.ObjectID) (DocumentCaretMove, error) { +func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, lookup *caretRangeLookup, current int, command DocumentCaretCommand, owners map[int]atspi.ObjectID) (DocumentCaretMove, error) { reader := &visualLineReader{DocumentCaretReader: source, cache: make(map[visualLineKey]atspi.DocumentCaretText)} next := *place // Move beyond the entire current line, even when character/word movement // left the cursor in a middle fragment or structural navigation chose its // heading container. The object-local mover still handles empty controls. if command == CaretNextLine || command == CaretPreviousLine { - position := -1 - for i, index := range indices { - if index.index == current && place.offset >= index.start && place.offset < max(index.start+1, index.end) || place.origin == StructuralNavigation && index.index > current { - position = i - break - } + position := lookup.containing(indices, current, place.offset) + if place.origin == StructuralNavigation && lookup.after[current] < len(indices) && (position < 0 || lookup.after[current] < position) { + position = lookup.after[current] } if position >= 0 { parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, place.offset, true) @@ -77,20 +74,15 @@ func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atsp } } } - movement, err := move_caret_object_line(ctx, reader, doc, &next, indices, current, command) + movement, err := move_caret_object_line(ctx, reader, doc, &next, indices, lookup, current, command) if err != nil || !movement.Moved { if err == nil { movement.Position = documentPosition(doc, *place) } return movement, err } - position := -1 - for i, index := range indices { - if doc.Nodes[index.index].Reference == next.object && next.offset >= index.start && next.offset < max(index.start+1, index.end) { - position = i - break - } - } + nextIndex, _ := doc.Index(next.object.ID()) + position := lookup.containing(indices, nextIndex, next.offset) if position >= 0 { parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, next.offset, command != CaretReadLine) if err != nil { diff --git a/internal/review/document_word_spaces_test.go b/internal/review/document_word_spaces_test.go new file mode 100644 index 0000000..6ec9439 --- /dev/null +++ b/internal/review/document_word_spaces_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +type blockCaretReader struct { + caretTextReader + calls, blocks int + malformed bool +} + +func (reader *blockCaretReader) ReadDocumentCaret(ctx context.Context, object atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { + reader.calls++ + return reader.caretTextReader.ReadDocumentCaret(ctx, object, offset, unit) +} + +func (reader *blockCaretReader) ReadDocumentCharacters(_ context.Context, object atspi.ObjectReference, offset int) (atspi.DocumentCaretText, error) { + reader.calls++ + reader.blocks++ + runes := []rune(reader.caretTextReader[object.ID()]) + start := offset / 128 * 128 + end := min(len(runes), start+128) + raw := string(runes[start:end]) + if reader.malformed { + raw = "" + } + return atspi.DocumentCaretText{Start: start, End: end, RawText: raw, CharacterCount: len(runes)}, nil +} + +func TestDocumentWordSpacesUseBoundedBlocks(t *testing.T) { + values := caretTextReader{webID("/text"): "A" + strings.Repeat(" \t\n\u2003", 250) + "猫"} + cursor := newCaretTextCursor(t, values) + reader := &blockCaretReader{caretTextReader: values} + for _, step := range []struct { + command DocumentCaretCommand + text string + }{{CaretNextWord, "猫"}, {CaretPreviousWord, "猫"}, {CaretPreviousWord, "A \t\n\u2003"}} { + move, err := cursor.MoveCaret(context.Background(), reader, step.command) + // Native word providers include varying trailing whitespace. Retain + // their text and verify the actual requested word and Unicode offset. + if err != nil || !strings.HasPrefix(move.Text, strings.TrimSpace(step.text)) { + t.Fatalf("%s: %+v error=%v", step.command, move, err) + } + } + t.Logf("three word commands across 1000 whitespace characters: %d reads, %d blocks", reader.calls, reader.blocks) + if reader.blocks == 0 || reader.calls > 50 { + t.Fatalf("word traversal used %d reads (%d blocks)", reader.calls, reader.blocks) + } +} + +func TestDocumentCharacterMovementDoesNotReadBlocks(t *testing.T) { + values := caretTextReader{webID("/text"): "A B"} + cursor := newCaretTextCursor(t, values) + reader := &blockCaretReader{caretTextReader: values} + move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter) + if err != nil || move.Text != " " || reader.blocks != 0 || reader.calls != 1 { + t.Fatalf("move=%+v error=%v calls=%d blocks=%d", move, err, reader.calls, reader.blocks) + } +} + +func TestDocumentWordMalformedBlockCannotSkipText(t *testing.T) { + values := caretTextReader{webID("/text"): "A B"} + cursor := newCaretTextCursor(t, values) + reader := &blockCaretReader{caretTextReader: values, malformed: true} + move, err := cursor.MoveCaret(context.Background(), reader, CaretNextWord) + if err != nil || move.Text != "B" || move.Position.Offset != 4 { + t.Fatalf("move=%+v error=%v", move, err) + } +} diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index e402fec..588f3dd 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -3,7 +3,7 @@ gtk-alert-message,src/cthulhu/speech_generator.py;src/cthulhu/script_utilities.p yad-simple-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD frame question before its first focused button,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD/Xvfb/AT-SPI fixture captured the exact question on frame activation or first button focus. The live 2026-09-27 log showed a third path where activation targets the focused button; an event-loop regression covers its speech order.,,Maintainer confirmed the YAD question was spoken on the updated build on 2026-09-27.,,YAD exposes the prompt as a frame with layout nodes and a label plus buttons. The generic Python frame format appears to omit the label; this Go addition follows the user's explicit example. A strict simple prompt shape limits frame text capture and bounded first-button refresh handles late labels. radio-group-focus,src/cthulhu/generator.py;src/cthulhu/speech_generator.py,speech,Speak the radio group question with a focused option,,internal/atspi internal/presentation internal/preferences,implemented,go test ./internal/presentation ./internal/atspi ./internal/preferences -count=1; python3 tests/native_alert.py,Private GTK radio group focus captured Punctuation Level.,,Physical preferences speech pending.,,A named panel or labelled-by relation supplies the group question; each focused radio presents that name before its option. The Braille verbosity radios now have a named frame. yad-list-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD list question when its window opens,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD power list captured Power options after ordinary or synthesized window activation.,,Physical I38 power menu listening pending.,,The I38 list has a labelled question above its choices; foreground recovery and direct activation both capture the same bounded prompt shape. -web-live-region-announcements,src/cthulhu/scripts/web/script.py; src/cthulhu/liveregions.py,web,Automatically speak active-tab polite and assertive live region updates,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; CTHULHU_NATIVE_BROWSER_TESTS=live python3 tests/native_browser.py,Private Brave/Xvfb fixture captures the Skald-style status update and real child text; physical Skald Xorg test pending.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Go captures inherited live metadata and child text for text insertions and child additions. A Chromium placeholder update is suppressed. Off regions and inactive documents are filtered. Complete Python live-region review commands and politeness overrides remain pending. +web-live-region-announcements,src/cthulhu/scripts/web/script.py; src/cthulhu/liveregions.py,web,Automatically speak active-tab polite and assertive live region updates,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; CTHULHU_NATIVE_BROWSER_TESTS=live python3 tests/native_browser.py; CTHULHU_NATIVE_BROWSER_TESTS=live-atomic python3 tests/native_browser.py,Private Brave/Xvfb fixture captures the Skald-style status update and real child text; physical Skald Xorg test pending.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Go captures inherited live metadata and child text for text insertions and child additions. A Chromium placeholder update is suppressed. Off regions and inactive documents are filtered. Complete Python live-region review commands and politeness overrides remain pending. October 24 audit fixes resolve atomic ancestors and defer established busy live regions until completion. Private Chromium atomic text/child updates non-atomic updates and busy release without a new insertion pass. Provider metadata limits and pending physical checks are documented in docs/testing/browser-audit-fixes.md. core-startup,src/cthulhu/cthulhu.py,core,Orchestrate runtime service startup and graceful shutdown,Storm Dragon,internal/app,in-progress,go test ./internal/app ./internal/atspi ./internal/lifecycle -count=1; CTHULHU_XRECORD_TEST=/tmp/cthulhu-xrecord.test python3 tests/native_x11.py,Maintainer accepted the Stage 1 physical Xorg shutdown checklist on 2026-09-11; post-fix cthulhu.sh returned to the prompt with complete shutdown records on Xorg using its LXTerminal test application. The private Xvfb/D-Bus native proof with XRecord also passed.,,,,"Lifecycle and service orchestration only; see lifecycle-welcome-message, lifecycle-farewell-message, lifecycle-start-sound, and lifecycle-stop-sound for the four user-visible deliveries. General startup or shutdown parity is not claimed." lifecycle-welcome-message,src/cthulhu/plugins/HelloCthulhu/plugin.py,speech,Speak a randomly selected startup welcome message,Storm Dragon,internal/app,in-progress,go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1,Maintainer accepted welcome speech in the Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,,,Maintainer heard the startup welcome message during the accepted Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,Python presents Cthulhu welcomes you. once through HelloCthulhu after start-application-completed and only while an active script exists. Go creates its speech worker only when enableSpeech is true and randomly selects from a small startup list after service startup without an active-script or once-only gate; delivery is not waited for and has no per-message deadline. The extra variants are an intentional Go behavior extension. General startup parity is not claimed. lifecycle-farewell-message,src/cthulhu/plugins/ByeCthulhu/plugin.py,speech,Speak a randomly selected graceful-shutdown farewell message,Storm Dragon,internal/app,in-progress,go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1,Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg using its LXTerminal test application on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12; the launcher returned to the prompt and completed final shutdown records.,,,Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,"Python presents Cthulhu lurks beneath the waves. through ByeCthulhu only while an active script exists. Go randomly selects from a small shutdown list only when enableSpeech is true, interrupts owned speech, and waits up to 2 seconds for the backend result before native cleanup; timeout cancels the active request. The extra variants are an intentional Go behavior extension. audit-required: active-script gating, speech-disabled presentation semantics, and Python's overall shutdown watchdog versus Go's per-message deadline are not directly aligned by the named evidence." @@ -19,7 +19,7 @@ managed-descendant-presentation,src/cthulhu/scripts/default.py,focus,Resolve foc ssip-speak-cancel,src/cthulhu/speechdispatcherfactory.py,speech,Send speech and cancellation through SSIP,Storm Dragon,internal/speech,in-progress,go test ./internal/speech -count=1,Maintainer accepted audible non-consuming Control/Shift interruption in the Stage 1 physical Xorg checklist on 2026-09-11.,,Maintainer heard lifecycle and terminal speech plus Control/Shift interruption during the accepted Stage 1 physical Xorg checklist on 2026-09-11.,,Protocol replies 230 and 225 were confirmed against the local service; broader backend and X11 coverage remains pending. web-structural-navigation,src/cthulhu/scripts/web/script.py,web,Move by headings and other document structure,Storm Dragon,internal/input internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Structural navigation follows native caret movement. Echo suppression ends on subsequent input. Failed same-object context capture retries. Delayed document carets cannot override editable focus and stale results cannot overwrite unresolved unrelated focus. All 27 browse symbols pass through after routing is disabled. Complete Python structural settings and dialogs remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. web-document-recapture,src/cthulhu/ax_object.py,web,Recover heading navigation after browser document replacement,Storm Dragon,internal/atspi internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb passes immediate H after loading a new page and after activating its return link on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Native caches refresh on recapture. Load completion retains the new root and resets browser context and pending reading. Worker page epochs retire queued and in-flight results across reused paths. Native reload and mode/chrome tests remain covered. Installed Go preview and Brave Origin are not verified. On 2026-09-21 semantic invalidation was scoped to captured objects so browser chrome and other applications cannot discard the document. Focused race tests and the full private Brave suite pass; heading/link jumps assert capture reuse. Large-page latency and logged stale-object failures still require physical retesting. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 23 ownership correction rejects loads outside the current application or known window and after owner loss; accepted loads update the focus owner for correct defunct recovery. Five event-loop regressions and app race tests pass; physical acceptance remains pending. September 24 same-geometry window recovery now requires fresh active/showing/non-iconified state before foreground correlation; private recovery regression fails on the earlier preview and passes on the rebuilt one. Address changes outside the owned field are suppressed; popup-associated typing remains supported. September 25 review: recovery now checks the fresh tree when text/geometry recapture discovers a removed focus; deterministic regression and focused race checks pass. -web-caret-line-reading,src/cthulhu/scripts/web/script.py,web,Read displayed document content at the browser caret,Storm Dragon,internal/atspi internal/review internal/app internal/presentation,implemented,"go test -race ./... -count=1; python3 tests/native_browser.py; go test -race ./internal/atspi ./internal/review ./internal/app -count=1; CTHULHU_NATIVE_BROWSER_TESTS=generatedlinks,replacement,tooltips,fragments,namedlinks,linkedheadings python3 tests/native_browser.py",Private Brave/Xvfb H/Down/H and expanded page transition sequences pass on 2026-09-20. On 2026-10-22 the maintainer confirmed reading the entire original Techrights article in Brave without looping after the generated-link recovery fix. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Original Techrights full-article reading accepted after the generated-link fix on 2026-10-22; see docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Captured and assembled document lines use role/state sound categories and speech fallback. Unit tests cover visited links and per-object cue deduplication and sound-only versus speech-only policy. Character and word intents remain separate. Ordinary caret movement updates structural position. Initial read-all is implemented; braille and complete line-presentation parity remain pending. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 24 prerequisite review: pending caret/terminal timers extracted under the same event-loop owner; adjacent-character word navigation and whitespace and embedded-marker traversal corrected; whole caret movement deadline added. Full race and isolated Brave/X11/ownership proofs pass; physical and installed-preview gates remain pending. See docs/testing/chromium-browser.md for dependency order. September 24 log correction: browse word and line speech includes link and heading ancestors with role sound policy; character output retains character granularity. Native Brave assertions cover link words and document-start heading level. See docs/testing/chromium-browser.md. September 25 review: trailing non-text objects no longer cause false stale-location errors for line/document-edge movement; focused regression and private Brave suite pass. Physical listening remains open. Explicitly named links retain whole-label line speech while character and word review use accessible-label offsets independently of native text. Generic unit and private Brave named-link regressions cover empty native text and badges plus both directions across following punctuation. Live speech and physical Braille acceptance remain pending. October 22 generated-link regression verifies exact prefix/body text with forward/reverse lines and Read All in private Brave. The maintainer accepted full-article reading on the original Techrights page after the fix; other live gates remain scoped to their named workflows. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22. +web-caret-line-reading,src/cthulhu/scripts/web/script.py,web,Read displayed document content at the browser caret,Storm Dragon,internal/atspi internal/review internal/app internal/presentation,implemented,"go test -race ./... -count=1; python3 tests/native_browser.py; go test -race ./internal/atspi ./internal/review ./internal/app -count=1; CTHULHU_NATIVE_BROWSER_TESTS=generatedlinks,replacement,tooltips,fragments,namedlinks,linkedheadings python3 tests/native_browser.py",Private Brave/Xvfb H/Down/H and expanded page transition sequences pass on 2026-09-20. On 2026-10-22 the maintainer confirmed reading the entire original Techrights article in Brave without looping after the generated-link recovery fix. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Original Techrights full-article reading accepted after the generated-link fix on 2026-10-22; see docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Captured and assembled document lines use role/state sound categories and speech fallback. Unit tests cover visited links and per-object cue deduplication and sound-only versus speech-only policy. Character and word intents remain separate. Ordinary caret movement updates structural position. Initial read-all is implemented; braille and complete line-presentation parity remain pending. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 24 prerequisite review: pending caret/terminal timers extracted under the same event-loop owner; adjacent-character word navigation and whitespace and embedded-marker traversal corrected; whole caret movement deadline added. Full race and isolated Brave/X11/ownership proofs pass; physical and installed-preview gates remain pending. See docs/testing/chromium-browser.md for dependency order. September 24 log correction: browse word and line speech includes link and heading ancestors with role sound policy; character output retains character granularity. Native Brave assertions cover link words and document-start heading level. See docs/testing/chromium-browser.md. September 25 review: trailing non-text objects no longer cause false stale-location errors for line/document-edge movement; focused regression and private Brave suite pass. Physical listening remains open. Explicitly named links retain whole-label line speech while character and word review use accessible-label offsets independently of native text. Generic unit and private Brave named-link regressions cover empty native text and badges plus both directions across following punctuation. Live speech and physical Braille acceptance remain pending. October 22 generated-link regression verifies exact prefix/body text with forward/reverse lines and Read All in private Brave. The maintainer accepted full-article reading on the original Techrights page after the fix; other live gates remain scoped to their named workflows. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22. October 24 audit fixes validate dirty hypertext ancestors before crossing objects; refreshed retries include inserted parent text. Word whitespace uses bounded native character blocks and caret ranges have indexed lookup. Existing native navigation regressions pass; see docs/testing/browser-audit-fixes.md for limitations and remaining work. config-read-preview,src/cthulhu/backends/toml_backend.py,settings,Read existing Cthulhu TOML and merge the startup profile,Storm Dragon,internal/config,implemented,go test ./internal/config ./cmd/cthulhu -count=1,,,,,Read-only preview bridge available only through explicit --config selection. The Go-native schema and default path are intentionally separate; the one-shot importer remains Phase 8 work. config-speech-preview,src/cthulhu/speechdispatcherfactory.py,speech,Apply supported active-profile speech settings,Storm Dragon,internal/config internal/speech internal/app,implemented,go test ./internal/config ./internal/speech ./internal/presentation ./internal/app -count=1,,,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,,Applies enablement description punctuation capitalization and default voice settings. typing-printable-key-echo,src/cthulhu/typing_echo_presenter.py,input,Apply printable typing-echo category settings to terminal and editable text insertions,Storm Dragon,internal/config internal/atspi internal/presentation internal/app,implemented,go test ./internal/config ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer reported the configured printable echo settings worked in minimal physical Xorg tests on 2026-09-12; broader application coverage remains pending.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,Single-character terminal and editable AT-SPI insertions honor enableKeyEcho enableEchoByCharacter and alphabetic numeric punctuation space and diacritical categories. Multi-character insertions remain presentation events. Preferences offer disabled enabled and edit-fields-only radio choices; edit-fields-only uses native editable metadata and Wine editor focus while preserving configured echo voices. Automated focus and persistence checks pass; live speech and physical Braille acceptance for the new mode remain pending. diff --git a/tests/browser-live.html b/tests/browser-live.html new file mode 100644 index 0000000..40ebeb5 --- /dev/null +++ b/tests/browser-live.html @@ -0,0 +1,31 @@ + +Cthulhu Browser Probe + + +
Download status: Waiting
+
Account status:
+
Outer context: Waiting
+
Busy status: Waiting
+ + diff --git a/tests/native_browser.py b/tests/native_browser.py index 9b9f758..beb935f 100644 --- a/tests/native_browser.py +++ b/tests/native_browser.py @@ -12,6 +12,7 @@ from native_test_session import run_private_session BROWSER_PROOFS = { + "live-atomic": ("./internal/atspi", "^TestNativeBrowserAtomicLiveRegion$", 30, 35, "browser-live.html"), "replaced-feed": ("./internal/atspi", "^TestNativeBrowserMissingFeedRefreshesWholeBranch$", 30, 35, "browser-replaced-feed.html"), "nested-document": ("./internal/app", "^TestNativeBrowserNestedDocumentHeadings$", 40, 45), "reveal": ("./internal/app", "^TestNativeBrowserRevealedHeadings$", 60, 65),