Merge remote-tracking branch 'origin/testing' into testing

This commit is contained in:
ak47 committed 2026-11-07 14:46:59 -05:00
commit a29910d002
7 files changed
+304 -2

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
# Clickable disclosures
A successful accessible action can reveal options without a usable
children-changed event. The Actions list must invalidate the same document
ancestry as Enter activation. Both paths also invalidate the target snapshot:
caret navigation deliberately tolerates stale child order on ticking pages,
so child-order invalidation alone can leave the newly opened options skipped.
The next move uses the existing bounded recapture, retaining live object
identities and the saved browse position. Action ownership checks remain strict.
Text caret placement also reveals its native range. Explicitly scrolling the
whole accessible object first can scroll the outer document while reading an
inner popup and cause the page to dismiss that popup. Text targets now rely on
native caret placement, as Python does; non-text stops retain component scrolling.
## Automated checks
```sh
CTHULHU_BROWSER=/usr/bin/chromium CTHULHU_NATIVE_BROWSER_TESTS=actions,disclosure,reveal,positions,ticking,document python3 tests/native_browser.py
```
The disclosure proof opens a generic scrollable popup through the label's
ancestor action and through Enter. It reads characters and both options in
both directions before closing it. The popup dismisses on blur or outer-document
scrolling. Selection-change markers wait for two browser animation frames so
queued scrolling and dismissal run before checking that the option remains
available. Restoring unconditional component scrolling fails this check.
The completion marker is separate from the reader's focus anchor so it cannot
accidentally force recapture and hide the earlier activation regression.
Viewport intersection acknowledgements also verify that distant text and
non-text images are revealed. The other proofs cover iframe actions, revealed
headings, list-position metadata, continuously replaced text, and browser
document commands. All use private Xorg and D-Bus.
The original public page was also checked in a private Chromium profile, with
browser animation-frame acknowledgements between moves. Before the scroll fix,
the next character after the first tag returned to the closed filter label.
With the fix, character reading and moving to the next tag preserved the popup
and its focused scroll container.
The separate `link-sounds` proof failed its initial native Tab-position check
with pans `[0 0 0]` both with the original unconditional scrolling and with the
fix. That baseline failure remains unresolved; it is not a passing check here.
## Live acceptance
With a rebuilt reader on the originally reported page, open each filter using
Enter and the Actions list. Read its options with Down and Up, select an option,
then reopen it and verify that it remains usable. Also check ordinary links,
buttons, Tab/Shift+Tab, and reading through a continuously updating page.
The user's installed Brave session, physical input, audible speech, and physical
Braille remain live acceptance checks. Human diff review and live testing
precede submission.
+191
View File
@@ -6,6 +6,7 @@ package app
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
@@ -250,3 +251,193 @@ func TestNativeBrowserActionOwnership(t *testing.T) {
previousContent = mode + " mode content"
}
}
func TestNativeBrowserDisclosureFocus(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser disclosure fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
var focus atspi.ObjectID
for attempt := 0; attempt < 25 && !focus.Valid(); attempt++ {
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil {
t.Fatalf("fixture focus: %v %s", err, out)
}
timer := time.NewTimer(150 * time.Millisecond)
drain:
for {
select {
case event := <-adapter.Events():
if isFocusGainEvent(event) && event.Source.Name == "Action fixture start" {
focus = event.Source.ID()
break drain
}
case <-timer.C:
break drain
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
timer.Stop()
}
if !focus.Valid() {
t.Fatal("fixture heading focus missing")
}
worker, err := review.NewWorker(adapter, 2*time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
worker.SyncFocus(focus)
testBrowserDisclosureActions(t, ctx, adapter, worker)
}
func testBrowserDisclosureActions(t *testing.T, ctx context.Context, adapter *atspi.Adapter, worker *review.Worker) {
t.Helper()
move := func(command review.DocumentCaretCommand) review.Result {
t.Helper()
if err := worker.MoveDocumentCaret(command); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
if result.Err != nil || result.CaretErr != nil {
t.Fatalf("disclosure navigation failed: %+v", result)
}
return result
case <-ctx.Done():
t.Fatal(ctx.Err())
return review.Result{}
}
}
waitToggle := func(want string) {
t.Helper()
for {
select {
case event := <-adapter.Events():
if event.Type == atspi.AccessibleNameEvent && event.Source.Name == want {
return
}
case <-ctx.Done():
t.Fatalf("disclosure did not report %q: %v", want, ctx.Err())
}
}
}
for _, method := range []string{"actions", "enter"} {
var label atspi.ObjectReference
for step := 0; step < 60; step++ {
command := review.CaretNextLine
if step == 0 {
command = review.CaretDocumentStart
}
result := move(command)
if strings.TrimSpace(result.DocumentCaretText) == "Choose category" {
label = result.DocumentPosition.Object
break
}
}
if !label.Valid() {
t.Fatal("disclosure label missing")
}
waitToggle("Caret settled:Choose category:0:closed")
perform := func() {
t.Helper()
actions, err := adapter.AccessibleActions(ctx, label.ID())
if err != nil {
t.Fatalf("%s list actions: %v", method, err)
}
if err := adapter.PerformAccessibleAction(ctx, actions.Target, "clickAncestor"); err != nil {
current, currentErr := adapter.AccessibleActions(ctx, label.ID())
t.Fatalf("%s perform action: %v; before=%+v after=%+v (%v)", method, err, actions.Target, current.Target, currentErr)
}
}
if method == "actions" {
perform()
} else {
if err := worker.ActivateDocumentCaret(); err != nil {
t.Fatal(err)
}
select {
case result := <-worker.Results():
if result.Err != nil {
t.Fatalf("%s Enter activation: %v", method, result.Err)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
waitToggle("Categories opened")
// A real dropdown's scroll container can acquire browser focus when
// its first option receives the native caret. Reading within it must
// not blur that container and dismiss the rest of the options.
optionAvailable := func(result review.Result) {
t.Helper()
prefix := fmt.Sprintf("Caret settled:%s:%d:", result.DocumentPosition.Snapshot.Name, result.DocumentPosition.Offset)
for {
select {
case event := <-adapter.Events():
if event.Type == atspi.AccessibleNameEvent && strings.HasPrefix(event.Source.Name, prefix) {
if event.Source.Name != prefix+"open" {
t.Fatalf("%s popup closed after %s", method, result.DocumentCaretCommand)
}
goto settled
}
case <-ctx.Done():
t.Fatalf("missing %s acknowledgement: %v", prefix, ctx.Err())
}
}
settled:
if _, err := adapter.AccessibleActions(ctx, result.DocumentPosition.Object.ID()); err != nil {
t.Fatalf("%s popup option disappeared after %s: %v", method, result.DocumentCaretCommand, err)
}
}
first := move(review.CaretNextLine)
if strings.TrimSpace(first.DocumentCaretText) != "First category" {
t.Fatalf("%s first disclosure option: %q", method, first.DocumentCaretText)
}
optionAvailable(first)
character := move(review.CaretNextCharacter)
if character.DocumentCaretText != "i" {
t.Fatalf("%s disclosure character: got %q, want i", method, character.DocumentCaretText)
}
optionAvailable(character)
optionAvailable(move(review.CaretLineStart))
for _, want := range []string{"Second category"} {
result := move(review.CaretNextLine)
if strings.TrimSpace(result.DocumentCaretText) != want {
t.Fatalf("%s disclosure navigation: got %q, want %q", method, result.DocumentCaretText, want)
}
optionAvailable(result)
}
// Both options must remain available when browsing backwards too.
for _, want := range []string{"First category"} {
result := move(review.CaretPreviousLine)
if strings.TrimSpace(result.DocumentCaretText) != want {
t.Fatalf("%s reverse disclosure navigation: got %q, want %q", method, result.DocumentCaretText, want)
}
optionAvailable(result)
}
perform()
waitToggle("Categories closed")
}
// Text caret placement must still reveal offscreen text, and the
// explicit component scroll remains necessary for a non-text image.
end := move(review.CaretDocumentEnd)
if end.DocumentPosition.Snapshot.Name != "Distant image" {
t.Fatalf("document end = %q, want distant image", end.DocumentPosition.Snapshot.Name)
}
waitToggle("Viewport:distant-image")
previous := move(review.CaretPreviousLine)
if strings.TrimSpace(previous.DocumentCaretText) != "Distant text" {
t.Fatalf("offscreen previous line = %q", previous.DocumentCaretText)
}
waitToggle("Viewport:distant-text")
}
+10
View File
@@ -139,6 +139,16 @@ func (adapter *Adapter) native_actions(ctx context.Context, object ObjectID, exp
C.g_error_free(nativeErr)
return ActionSet{}, errors.New("accessible action failed")
}
// Actions can reveal document branches without a children-changed
// event. Refresh the same ancestry as browse-caret activation so
// the next navigation discovers the newly opened options.
adapter.treeMutex.Lock()
tree := adapter.documentTree
adapter.treeMutex.Unlock()
if tree != nil {
adapter.treeInvalidations.Add(uint64(tree.markAncestorChildrenChanged(object)))
adapter.treeInvalidations.Add(uint64(tree.InvalidateSnapshot(object)))
}
return result, nil
}
labelRaw := C.atspi_action_get_localized_name(action, C.gint(index), nil)
+12 -2
View File
@@ -269,6 +269,10 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
}
if activated != 0 {
adapter.treeInvalidations.Add(uint64(tree.markAncestorChildrenChanged(position.ID())))
// Caret navigation tolerates stale child order on ticking pages.
// A completed action must instead refresh before leaving its
// target, or it can skip a newly revealed popup entirely.
adapter.treeInvalidations.Add(uint64(tree.InvalidateSnapshot(position.ID())))
return nil
}
}
@@ -551,15 +555,21 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position
for _, id := range echoIDs {
adapter.navigationEcho[id] = 15
}
text := C.atspi_accessible_get_text_iface(source)
component := C.atspi_accessible_get_component_iface(source)
if component != nil {
if documentTargetGrabsFocus(tree, position, snapshot) {
C.atspi_component_grab_focus(component, nil)
}
C.atspi_component_scroll_to(component, C.ATSPI_SCROLL_ANYWHERE, nil)
// Setting a text caret reveals its range. An additional whole-object
// scroll can move the outer document while reading a popup's inner
// scroller, causing the page to dismiss the popup. Python likewise
// relies on caret placement for text; non-text stops still need scroll.
if text == nil {
C.atspi_component_scroll_to(component, C.ATSPI_SCROLL_ANYWHERE, nil)
}
C.g_object_unref(C.gpointer(component))
}
text := C.atspi_accessible_get_text_iface(source)
if text == nil {
clear(adapter.navigationEcho)
// Images and other whole-object stops have a valid virtual position
+1
View File
@@ -74,3 +74,4 @@ speech-interruption-preferences,src/cthulhu/input_event.py,input,"Choose all key
pidgin-message-history,src/cthulhu/chat.py;src/cthulhu/scripts/apps/pidgin/chat.py;src/cthulhu/scripts/apps/pidgin/script.py,messaging,Announce verified Pidgin transcript insertions and review or copy nine recent messages,Storm Dragon,internal/messaging internal/atspi internal/app internal/input,implemented,go test ./...; go test -race ./internal/messaging ./internal/input ./internal/atspi ./internal/app; see docs/testing/messaging.md for private native checks,Private GTK transcript and X11 command checks pass; actual Pidgin and physical shortcuts pending.,,Recorded presentation tested; actual Pidgin listening pending.,Single flash recorded; physical Braille pending.,"Legacy defaults only: combined per-provider history, all conversations, no room prefix or typing announcements. Legacy chat preference controls remain unported. Cthulhu+H is the sole default learn-mode chord; F1-F9 review and Shift+F1-F9 copy. Existing explicit assignments and modal OCR F5 refresh take precedence. Steam notification history remains separate. See docs/testing/messaging.md."
atspi-announcements,Orca src/orca/script.py;src/orca/scripts/default.py;src/orca/event_manager.py,focus,"Present object:announcement payloads from any provider, politely or assertively, without requiring focus or foreground ownership",,internal/atspi internal/app,implemented,"go test ./internal/atspi ./internal/app -run Announcement -count=1; native GTK4 fixture delivers polite detail1=1 and assertive detail1=2 payloads through the adapter; live Niri 26.04 event capture recorded the workspace-switch announcement shape",Native fixture runs on private Xvfb; Xorg reader behavior otherwise unchanged,"Live Niri 26.04 capture on 2026-10-31: workspace switch emits accessible-name then object:announcement with the workspace label about 10ms before window activation. Maintainer confirms workspace switching, AltTab announcements and the exit dialog read out with the built reader on Niri on 2026-10-31.","Maintainer live listening on Niri passed for workspace switching, AltTab and the exit dialog on 2026-10-31.",Braille flash follows the system-role output queue; physical display acceptance pending.,"Announcements bypass background-event filtering and foreground epochs because the provider explicitly requests speech and may never own focus (a compositor's own accessible tree under Niri via AccessKit). Screen-lock and secure-input gates still apply. Trimmed repeats from one provider inside 300ms are deduped; Niri alternates live-node identities and may append a space to force re-emission. There is no Orca workspace-specific behavior to mirror: Orca only presents the generic announcement. See docs/testing/announcements.md."
web-message-article-focus,src/cthulhu/scripts/web/speech_generator.py;src/cthulhu/formatting.py,web,Announce named message articles when focus enters their controls,,internal/atspi/speech_metadata_native.go;internal/app/reading_context.go;internal/presentation/reading.go,in-progress,Speech replay covers browse and focus modes plus pending browser discovery; private Chromium article capture regression. See docs/testing/messaging.md.,Private Chromium fixture uses isolated X11 and D-Bus.,,Recorded speech only; live Discord listening pending.,Physical display acceptance pending.,Shared named article handling; no Discord identifiers or child scans. Preserve ordinary container context policy; announce article labels on entry without repeating them on exit.
web-disclosure-refresh,src/cthulhu/action_presenter.py; src/cthulhu/scripts/web/script_utilities.py,web,Discover and keep disclosure options open during Enter or Actions-list activation and caret navigation,,internal/atspi internal/app,implemented,"CTHULHU_BROWSER=/usr/bin/chromium CTHULHU_NATIVE_BROWSER_TESTS=actions,disclosure,reveal,positions,ticking,document python3 tests/native_browser.py",Private Chromium/Xorg fixture covers both activation paths; character and line reading; blur and document-scroll dismissal; offscreen text and images. Original public page also checked in a private Chromium profile.,,Original-page audible speech acceptance pending.,Physical Braille acceptance pending.,Preserve target generations and strict ownership; refresh action snapshots and ancestor order. Text caret placement reveals its range without an extra whole-object scroll that dismisses popups; non-text scrolling retained. User installed Brave acceptance pending. Link-sounds native Tab pan baseline fails before and after this fix. See docs/testing/browser-clickables.md.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
74 pidgin-message-history src/cthulhu/chat.py;src/cthulhu/scripts/apps/pidgin/chat.py;src/cthulhu/scripts/apps/pidgin/script.py messaging Announce verified Pidgin transcript insertions and review or copy nine recent messages Storm Dragon internal/messaging internal/atspi internal/app internal/input implemented go test ./...; go test -race ./internal/messaging ./internal/input ./internal/atspi ./internal/app; see docs/testing/messaging.md for private native checks Private GTK transcript and X11 command checks pass; actual Pidgin and physical shortcuts pending. Recorded presentation tested; actual Pidgin listening pending. Single flash recorded; physical Braille pending. Legacy defaults only: combined per-provider history, all conversations, no room prefix or typing announcements. Legacy chat preference controls remain unported. Cthulhu+H is the sole default learn-mode chord; F1-F9 review and Shift+F1-F9 copy. Existing explicit assignments and modal OCR F5 refresh take precedence. Steam notification history remains separate. See docs/testing/messaging.md.
75 atspi-announcements Orca src/orca/script.py;src/orca/scripts/default.py;src/orca/event_manager.py focus Present object:announcement payloads from any provider, politely or assertively, without requiring focus or foreground ownership internal/atspi internal/app implemented go test ./internal/atspi ./internal/app -run Announcement -count=1; native GTK4 fixture delivers polite detail1=1 and assertive detail1=2 payloads through the adapter; live Niri 26.04 event capture recorded the workspace-switch announcement shape Native fixture runs on private Xvfb; Xorg reader behavior otherwise unchanged Live Niri 26.04 capture on 2026-10-31: workspace switch emits accessible-name then object:announcement with the workspace label about 10ms before window activation. Maintainer confirms workspace switching, AltTab announcements and the exit dialog read out with the built reader on Niri on 2026-10-31. Maintainer live listening on Niri passed for workspace switching, AltTab and the exit dialog on 2026-10-31. Braille flash follows the system-role output queue; physical display acceptance pending. Announcements bypass background-event filtering and foreground epochs because the provider explicitly requests speech and may never own focus (a compositor's own accessible tree under Niri via AccessKit). Screen-lock and secure-input gates still apply. Trimmed repeats from one provider inside 300ms are deduped; Niri alternates live-node identities and may append a space to force re-emission. There is no Orca workspace-specific behavior to mirror: Orca only presents the generic announcement. See docs/testing/announcements.md.
76 web-message-article-focus src/cthulhu/scripts/web/speech_generator.py;src/cthulhu/formatting.py web Announce named message articles when focus enters their controls internal/atspi/speech_metadata_native.go;internal/app/reading_context.go;internal/presentation/reading.go in-progress Speech replay covers browse and focus modes plus pending browser discovery; private Chromium article capture regression. See docs/testing/messaging.md. Private Chromium fixture uses isolated X11 and D-Bus. Recorded speech only; live Discord listening pending. Physical display acceptance pending. Shared named article handling; no Discord identifiers or child scans. Preserve ordinary container context policy; announce article labels on entry without repeating them on exit.
77 web-disclosure-refresh src/cthulhu/action_presenter.py; src/cthulhu/scripts/web/script_utilities.py web Discover and keep disclosure options open during Enter or Actions-list activation and caret navigation internal/atspi internal/app implemented CTHULHU_BROWSER=/usr/bin/chromium CTHULHU_NATIVE_BROWSER_TESTS=actions,disclosure,reveal,positions,ticking,document python3 tests/native_browser.py Private Chromium/Xorg fixture covers both activation paths; character and line reading; blur and document-scroll dismissal; offscreen text and images. Original public page also checked in a private Chromium profile. Original-page audible speech acceptance pending. Physical Braille acceptance pending. Preserve target generations and strict ownership; refresh action snapshots and ancestor order. Text caret placement reveals its range without an extra whole-object scroll that dismisses popups; non-text scrolling retained. User installed Brave acceptance pending. Link-sounds native Tab pan baseline fails before and after this fix. See docs/testing/browser-clickables.md.
+34
View File
@@ -9,7 +9,41 @@
<a id="hidden" hidden onclick="chooseMode('Hidden')">Hidden action</a>
<a id="ordinary" href="#destination"><span>Ordinary link</span></a>
<p id="destination">Destination</p>
<div id="disclosure" onclick="document.getElementById('options').hidden = !document.getElementById('options').hidden; document.getElementById('activation-status').setAttribute('aria-label', document.getElementById('options').hidden ? 'Categories closed' : 'Categories opened')"><span>Choose category</span></div>
<div id="options" hidden style="height:30px; overflow:auto" tabindex="-1" onblur="if(this.hidden) return; this.hidden=true; document.getElementById('activation-status').setAttribute('aria-label', 'Categories closed by blur')"><ul style="margin:0; padding:0; list-style:none"><li onclick="this.dataset.selected='yes'"><span>First category</span></li><li onclick="this.dataset.selected='yes'"><span>Second category</span></li></ul></div>
<p>After categories</p>
<div id="activation-status" aria-label="Categories idle"></div>
<div style="height:1500px"></div>
<p id="distant-text">Distant text</p>
<div style="height:1500px"></div>
<img id="distant-image" alt="Distant image" width="32" height="32" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Crect width='32' height='32' fill='blue'/%3E%3C/svg%3E">
<script>
const viewportObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.intersectionRatio === 1) {
document.getElementById('activation-status').setAttribute('aria-label', `Viewport:${entry.target.id}`);
}
}
}, {threshold: 1});
viewportObserver.observe(document.getElementById('distant-text'));
viewportObserver.observe(document.getElementById('distant-image'));
document.addEventListener('scroll', event => {
const options = document.getElementById('options');
if (event.target === document && !options.hidden) {
options.hidden = true;
document.getElementById('activation-status').setAttribute('aria-label', 'Categories closed by scroll');
}
}, true);
document.addEventListener('selectionchange', () => {
const selection = document.getSelection();
const text = selection.anchorNode?.textContent;
if (text !== 'First category' && text !== 'Second category' && text !== 'Choose category') return;
const offset = selection.anchorOffset;
requestAnimationFrame(() => requestAnimationFrame(() => {
document.getElementById('activation-status').setAttribute('aria-label',
`Caret settled:${text}:${offset}:${document.getElementById('options').hidden ? 'closed' : 'open'}`);
}));
});
function chooseMode(mode) {
document.getElementById('content').srcdoc = '<!doctype html><p>' + mode + ' mode content</p>';
document.querySelector('h1').setAttribute('aria-label', mode + ' activated');
+1
View File
@@ -13,6 +13,7 @@ from native_test_session import run_private_session
BROWSER_PROOFS = {
"find": ("./internal/app", "^TestNativeBrowserFind$", 35, 40, "browser-find.html"),
"disclosure": ("./internal/app", "^TestNativeBrowserDisclosureFocus$", 35, 40, "browser-actions.html"),
"actions": ("./internal/app", "^TestNativeBrowserActionOwnership$", 35, 40, "browser-actions.html"),
"link-sounds": ("./internal/app", "^TestNativeBrowserCaretLinkSounds$", 40, 45, "browser-link-sounds.html"),
"article": ("./internal/atspi", "^TestNativeBrowserArticleContext$", 30, 35, "browser-article.html"),