Refresh browser tests and recover Firefox tab announcements

Bring the fragmented-line browser fixture into agreement with current browse
navigation. Rich editors are read through their text descendants, so Down from
the preceding heading must read "Review the code." directly. Remove the stale
empty editor-control stop while retaining forward and reverse line navigation,
character and word movement, Read All, and inline Braille-routing assertions.

Fix Firefox's missing announcement when creating a second tab. Firefox can emit
selection events before the new tab enters its accessible child list, causing
the native selected-child lookup to return nothing. Use the subsequent
child-added event only when its target is a live, freshly selected browser-chrome
tab with the emitting tab list as its direct parent. Keep document-authored tabs
and unselected background additions excluded, retain foreground filtering and
tab-identity deduplication, and release native references on the GLib owner.

Normalize child-added origin suffixes such as :system before event dispatch,
preserving the original native type in diagnostics. Rename the shared mutation
normalizer to reflect its expanded scope and update its callers, including the
Pidgin insertion check. Extend the normalization regression to cover child
additions and reject unrelated event names. Firefox keeps its normal animations
and all existing tab-announcement and address-editing assertions.

Make private-session teardown tolerate the document portal's asynchronous FUSE
unmount after D-Bus exits. Retry only ENOTCONN and EBUSY cleanup failures for at
most five seconds. Keep unrelated errors and persistent mount failures visible.
Add regressions for transient teardown, preservation of a failing child's exit
status, unrelated cleanup errors, and the cleanup deadline. Runtime directories
and accessibility buses remain private, with no init-system dependency.

Update browser verification notes and the parity ledger with the fixes, evidence,
regression risks, and concrete live acceptance procedures.

Validation:
- All 33 Chromium browser proofs passed in the final combined run.
- Firefox baseline and tab checks passed repeatedly with normal animations.
- All eight private-session isolation and cleanup regressions passed.
- AT-SPI, app, and review package race suites passed during development; focused
  race regressions passed again after the final event-normalization change.
- Go vet passed for internal/atspi, internal/app, and internal/review.
- A separate reader binary built successfully.
- Parity-ledger validation passed for 75 rows; git diff --check passed.
This commit is contained in:
Storm Dragon committed 2026-11-03 02:38:59 -05:00
1 parent 7ec6e4ccaf
commit 89686fa029
11 files changed
+199 -24

No files matched your search

@@ -420,7 +420,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
}
return
}
eventType := canonicalTextChangeEventType(nativeEventType)
eventType := canonicalObjectChangeEventType(nativeEventType)
originID := nativeObjectID(event.source)
if eventType == AccessibleNameEvent || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
C.cthulhu_forget_menu_checkability(event.source)
@@ -449,9 +449,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
adapter.forgetWindowDocuments(originID)
}
invalidated := adapter.applyTreeInvalidation(eventType, event)
returnedMenuItem := adapter.returned_menu_item(event, eventType, originID)
if returnedMenuItem != nil {
defer C.g_object_unref(C.gpointer(returnedMenuItem))
returnedTarget := adapter.returned_menu_item(event, eventType, originID)
if returnedTarget != nil {
defer C.g_object_unref(C.gpointer(returnedTarget))
// Keep the original showing event and popup origin in diagnostics.
// Only fresh focus on its parent supplies this normalized focus event.
eventType, invalidated = FocusEvent, false
@@ -505,6 +505,11 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
adapter.backgroundEventsFiltered.Add(1)
return
}
if tab := addedSelectedBrowserTab(event, eventType); tab != nil {
defer C.g_object_unref(C.gpointer(tab))
returnedTarget = tab
eventType, invalidated = SelectionChangedEvent, false
}
var live nativeLiveProperties
if eventType == TextInsertedEvent || eventType == ChildrenAddedEvent || eventType == BusyStateEvent {
live = capture_live_properties(event.source)
@@ -523,7 +528,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
return
}
snapshotStarted := time.Now()
target, ownedTarget := adapter.resolveNativeEventTarget(event, eventType, returnedMenuItem)
target, ownedTarget := adapter.resolveNativeEventTarget(event, eventType, returnedTarget)
if target == nil {
return
}
+50 -3
View File
@@ -9,11 +9,58 @@ package atspi
*/
import "C"
// resolveNativeEventTarget runs on the GLib owner. The returned menu item is
// addedSelectedBrowserTab handles providers which emit selection before the new
// tab enters their accessible child list. The later child-added event supplies
// the actual target; only a freshly selected browser chrome tab qualifies.
// The caller releases the retained target on the GLib owner.
func addedSelectedBrowserTab(event *C.AtspiEvent, eventType string) *C.AtspiAccessible {
if eventType != ChildrenAddedEvent || C.atspi_accessible_get_role(event.source, nil) != C.ATSPI_ROLE_PAGE_TAB_LIST {
return nil
}
child := C.cthulhu_event_active_descendant(event)
if child == nil || C.atspi_accessible_get_role(child, nil) != C.ATSPI_ROLE_PAGE_TAB {
return nil
}
parent := C.atspi_accessible_get_parent(child, nil)
if parent == nil {
return nil
}
matchesParent := nativeObjectID(parent) == nativeObjectID(event.source)
C.g_object_unref(C.gpointer(parent))
if !matchesParent {
return nil
}
C.atspi_accessible_clear_cache_single(child)
states := C.atspi_accessible_get_state_set(child)
if states == nil {
return nil
}
selected := C.atspi_state_set_contains(states, C.ATSPI_STATE_SELECTED) != 0 &&
C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0
C.g_object_unref(C.gpointer(states))
if !selected {
return nil
}
toolkit := C.atspi_accessible_get_toolkit_name(child, nil)
snapshot := Snapshot{Role: "page tab", ToolkitName: C.GoString(toolkit)}
C.g_free(C.gpointer(toolkit))
document := C.cthulhu_document_ancestor(child)
if document != nil {
C.g_object_unref(C.gpointer(document))
return nil
}
if !isBrowserChromeTab(snapshot, false) {
return nil
}
C.g_object_ref(C.gpointer(child))
return child
}
// resolveNativeEventTarget runs on the GLib owner. The override target is
// borrowed from the callback; LibreOffice and selected descendants can return
// owned references. The caller releases an owned target after event capture.
func (adapter *Adapter) resolveNativeEventTarget(event *C.AtspiEvent, eventType string, returnedMenuItem *C.AtspiAccessible) (*C.AtspiAccessible, bool) {
target := returnedMenuItem
func (adapter *Adapter) resolveNativeEventTarget(event *C.AtspiEvent, eventType string, overrideTarget *C.AtspiAccessible) (*C.AtspiAccessible, bool) {
target := overrideTarget
ownedTarget := false
if target == nil {
target = adapter.libreOfficeFocusTarget(event, eventType)
+4 -4
View File
@@ -349,18 +349,18 @@ func NewEvent(eventType string, source Snapshot) Event {
if eventType == "object:state-changed:checked" && source.Role == "menu item" {
source.Checkable = true
}
event := Event{Type: canonicalTextChangeEventType(eventType), Source: source, OriginPath: source.Path}
event := Event{Type: canonicalObjectChangeEventType(eventType), Source: source, OriginPath: source.Path}
if event.Type != eventType {
event.NativeType = eventType
}
return event
}
// AT-SPI appends an origin to some text mutations (for example, :system).
// AT-SPI appends an origin to some object mutations (for example, :system).
// The reader handles those mutations by kind; leaving the suffix on Type
// makes them fall through as ordinary object presentation events.
func canonicalTextChangeEventType(eventType string) string {
for _, kind := range []string{TextInsertedEvent, TextDeletedEvent} {
func canonicalObjectChangeEventType(eventType string) string {
for _, kind := range []string{TextInsertedEvent, TextDeletedEvent, ChildrenAddedEvent} {
if eventType == kind || strings.HasPrefix(eventType, kind+":") {
return kind
}
+7 -3
View File
@@ -45,17 +45,21 @@ func TestShouldSpeakEvent(t *testing.T) {
}
}
func TestTextChangeOriginIsCanonicalized(t *testing.T) {
func TestObjectChangeOriginIsCanonicalized(t *testing.T) {
for _, test := range []struct{ incoming, want string }{
{TextInsertedEvent + ":system", TextInsertedEvent},
{TextDeletedEvent + ":system", TextDeletedEvent},
{TextInsertedEvent, TextInsertedEvent},
{TextDeletedEvent, TextDeletedEvent},
{ChildrenAddedEvent + ":system", ChildrenAddedEvent},
{ChildrenAddedEvent, ChildrenAddedEvent},
{ChildrenRemovedEvent + ":system", ChildrenRemovedEvent + ":system"},
{"object:children-changed:additional", "object:children-changed:additional"},
{"object:text-caret-moved", "object:text-caret-moved"},
{"object:text-changed:insertion", "object:text-changed:insertion"},
} {
if got := canonicalTextChangeEventType(test.incoming); got != test.want {
t.Errorf("canonicalTextChangeEventType(%q) = %q, want %q", test.incoming, got, test.want)
if got := canonicalObjectChangeEventType(test.incoming); got != test.want {
t.Errorf("canonicalObjectChangeEventType(%q) = %q, want %q", test.incoming, got, test.want)
}
if got := NewEvent(test.incoming, Snapshot{}).Type; got != test.want {
t.Errorf("NewEvent(%q).Type = %q, want %q", test.incoming, got, test.want)
+1 -1
View File
@@ -35,7 +35,7 @@ import (
// title or application-supplied name. Only verified Pidgin transcript insertions
// bypass background filtering. Entry typing and buddy-list changes do not.
func (adapter *Adapter) pidginMessage(event *C.AtspiEvent) *messaging.Message {
if canonicalTextChangeEventType(C.GoString(event._type)) != TextInsertedEvent || adapter.applicationProcess(event.source) != "pidgin" ||
if canonicalObjectChangeEventType(C.GoString(event._type)) != TextInsertedEvent || adapter.applicationProcess(event.source) != "pidgin" ||
C.atspi_accessible_get_role(event.source, nil) != C.ATSPI_ROLE_TEXT {
return nil
}
@@ -196,7 +196,7 @@ func TestNativeBrowserFragmentLines(t *testing.T) {
command DocumentCaretCommand
want string
}{
{CaretNextLine, ""}, // Stop on the editor control before entering its text.
// Read rich-editor descendants directly, without an empty control stop.
{CaretNextLine, "Review the code."},
{CaretNextLine, "Report actionable issues."},
{CaretNextLine, "After the suggestion."},
+1 -1
View File
@@ -22,7 +22,7 @@ import "strings"
const Name = "Cthulhu"
const Version = "2026.11.03"
const Channel = "hunter"
const Channel = "testing"
var Build = Version