Sync with testing.

This commit is contained in:
hjozwiak committed 2026-11-02 23:15:59 -05:00
commit f2a35a9305
14 files changed
+502 -6

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ Expose GUI controls through accessibility APIs. Keep outer preferences combo wid
## Testing Guidelines ## Testing Guidelines
Use Go's `testing` package, `*_test.go`, and `TestBehavior` functions. Start with the affected package. Use deterministic synchronization and generic fixtures. Native checks need their relevant harness, such as `python3 tests/native_atspi.py`. No numeric coverage target is configured. Report automated, installed-binary, physical-input, audible-speech, and Braille-display checks separately. Use Go's `testing` package, `*_test.go`, and `TestBehavior` functions. Start with the affected package. Use deterministic synchronization and generic fixtures. Native checks need their relevant harness, such as `python3 tests/native_atspi.py`. Specify a 24-bit Xvfb screen for Qt fixtures: Slint's `xvfb-run` defaults to 8-bit, which can crash Qt text-cursor painting. No numeric coverage target is configured. Report automated, installed-binary, physical-input, audible-speech, and Braille-display checks separately.
For performance work, run the relevant `BenchmarkBehavior` functions before and after changes using repeated runs and allocation reporting. Add or expand representative benchmarks when investigating a new bottleneck; keep fixture construction outside timed loops and check that the intended work actually happens. Preserve announcement content, ordering, interruption, focus, and Braille behavior. Benchmark timing alone does not establish a bug or live responsiveness, and noisy timing changes must not become automatic pass/fail thresholds. See [benchmark procedures](docs/testing/benchmarks.md) for commands and measurement limits. Plain `go test` does not run benchmarks. For performance work, run the relevant `BenchmarkBehavior` functions before and after changes using repeated runs and allocation reporting. Add or expand representative benchmarks when investigating a new bottleneck; keep fixture construction outside timed loops and check that the intended work actually happens. Preserve announcement content, ordering, interruption, focus, and Braille behavior. Benchmark timing alone does not establish a bug or live responsiveness, and noisy timing changes must not become automatic pass/fail thresholds. See [benchmark procedures](docs/testing/benchmarks.md) for commands and measurement limits. Plain `go test` does not run benchmarks.
+46
View File
@@ -55,6 +55,52 @@ go test ./internal/atspi ./internal/presentation ./internal/app -count=1
CTHULHU_NATIVE_BROWSER_TESTS=menu /usr/bin/python3 tests/native_browser.py CTHULHU_NATIVE_BROWSER_TESTS=menu /usr/bin/python3 tests/native_browser.py
``` ```
## Editable combos with read-only editors
Some Qt applications make a combo editable, then make its internal line edit
read-only. Qt can expose that child with both EDITABLE and READ_ONLY states.
Closed Up/Down navigation sends child caret events followed by a name/value
event from the combo. A caret event can arrive without a new focus event.
On Slint with Qt/PySide6 6.9.3 and AT-SPI 2.58.1, querying the word at that
child's caret reproduced an 800 ms timeout, followed by stale list-item
queries and loss of further accessibility events. Cthulhu identifies these
children using READ_ONLY plus a direct combo parent, copies that parent's
identity into each caret snapshot, and omits the unused word query. It keeps
native focus on the combo and lets its name/value event present the selection.
This does not depend on the last caret/focus event or on a coalescing delay.
Writable combo editors and standalone read-only text fields retain word capture.
The app event loop suppresses a child caret only when its copied combo identity
matches the owned focus. Normal foreground checks still apply. Popup selection
followed by focus is presented once, and deselection of the previous managed
item does not replace the current item. Deselection of the current focused
item remains audible.
Run the isolated regression with:
```sh
python3 tests/native_toby_combo.py
```
This requires PySide6, Xvfb, xdotool and the native Go build dependencies. It
explicitly uses a 24-bit screen; Slint's 8-bit Xvfb default can crash Qt's text
cursor painter before accessibility checks complete. Fixture diagnostics are
saved in the printed `/tmp/cthulhu-combo-qt-*.log` path. The test
checks consecutive closed arrows, Home/End, popup Return and Escape,
Tab/Shift+Tab after each close, and word capture in writable fields, standalone
read-only fields and writable combo editors. It waits for each provider event
instead of filling the adapter queue with an unobserved key sequence. Snapshot
timings are reported for diagnosis, without a machine-speed pass threshold.
For live acceptance, repeat those keys in Toby Doom Launcher for both IWAD and
game choices. Confirm each new value is spoken once, navigation continues after
several changes, and Tab/Shift+Tab still reaches narration and buttons. In an
ordinary text field and writable combo, check typing and Ctrl+Left/Right word
reading. Switch away with a popup open and confirm background events do not
interrupt the new foreground application. Check a physical Braille display when
available; private event tests do not establish audible or physical output.
## Providers which omit checkability ## Providers which omit checkability
The tested Mumble 1.5.939 / Qt 5 installation exports Mute Self and Deafen Self The tested Mumble 1.5.939 / Qt 5 installation exports Mute Self and Deafen Self
+1
View File
@@ -100,6 +100,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
"snapshot_duration", event.SnapshotDuration, "snapshot_duration", event.SnapshotDuration,
"editable", event.Source.Editable, "editable", event.Source.Editable,
"editable_root", event.Source.EditableRoot.Path, "editable_root", event.Source.EditableRoot.Path,
"read_only_combo", event.Source.ReadOnlyCombo.Path,
"autocomplete", event.Source.Autocomplete, "autocomplete", event.Source.Autocomplete,
"autocomplete_popup", event.Source.AutocompletePopup, "autocomplete_popup", event.Source.AutocompletePopup,
"event_detail1", event.Detail1, "event_detail1", event.Detail1,
+7
View File
@@ -21,6 +21,13 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit
objectReview := handler.objectReview objectReview := handler.objectReview
debounce := handler.debounce debounce := handler.debounce
if event.Type == atspi.CaretEvent { if event.Type == atspi.CaretEvent {
if event.Source.ReadOnlyCombo.Valid() && event.Source.ReadOnlyCombo == state.focusedID &&
state.current.ID() == state.focusedID && state.current.Role == "combo box" {
// The combo's name/value event presents this selection, even when
// provider requests delay it past the normal caret coalescing time.
logger.Debug("skipped read-only combo editor caret", "combo_path", event.Source.ReadOnlyCombo.Path)
return true
}
if event.Source.ID() == state.selectionCaretID { if event.Source.ID() == state.selectionCaretID {
state.selectionCaretID = atspi.ObjectID{} state.selectionCaretID = atspi.ObjectID{}
if activity.Sequence == state.selectionCaretSequence && time.Since(state.selectionCaretAt) < 150*time.Millisecond { if activity.Sequence == state.selectionCaretSequence && time.Since(state.selectionCaretAt) < 150*time.Millisecond {
+55
View File
@@ -20,10 +20,12 @@ package app
import ( import (
"context" "context"
"log/slog" "log/slog"
"reflect"
"testing" "testing"
"time" "time"
"git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
) )
func TestProcessEventsSpeaksChangedCaretLines(t *testing.T) { func TestProcessEventsSpeaksChangedCaretLines(t *testing.T) {
@@ -101,6 +103,59 @@ func TestProcessEventsSuppressesCaretAfterEditableTextChange(t *testing.T) {
<-completed <-completed
} }
func TestProcessEventsReadOnlyComboUsesNameInsteadOfEditorCaret(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box", Name: "Classic Doom", Focused: true}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, combo)
editor := atspi.Snapshot{BusName: combo.BusName, Path: "/combo/editor", Role: "text", Text: "Funny Pack - Classic Doom",
BrailleLine: "Funny Pack - Classic Doom", TextCaretKnown: true, Editable: true, ReadOnly: true,
Focused: true, ReadOnlyCombo: combo.ID()}
for _, value := range []string{"Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"} {
editor.Text, editor.BrailleLine = value, value
adapter.events <- atspi.NewEvent(atspi.CaretEvent, editor)
combo.Name = value
adapter.events <- atspi.NewEvent(atspi.AccessibleNameEvent, combo)
}
close(adapter.events)
processEvents(context.Background(), slog.New(slog.DiscardHandler), speech, adapter, 0)
close(speech.spoken)
var got []string
for text := range speech.spoken {
got = append(got, text)
}
want := []string{"Classic Doom, combo box", "Funny Pack - Classic Doom", "Toby Doom", "Classic Doom"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("combo speech = %q, want %q", got, want)
}
}
func TestReadOnlyComboCaretRequiresExactOwnedCombo(t *testing.T) {
combo := atspi.Snapshot{BusName: ":1.7", Path: "/combo", Role: "combo box"}
for _, test := range []struct {
name string
owner atspi.ObjectID
skip bool
}{
{"owned", combo.ID(), true},
{"different combo", atspi.NewObjectID(combo.BusName, "/other-combo"), false},
{"different provider", atspi.NewObjectID(":1.8", combo.Path), false},
{"ordinary editor", atspi.ObjectID{}, false},
} {
t.Run(test.name, func(t *testing.T) {
state := &eventLoopState{}
state.current, state.focusedID = combo, combo.ID()
handler := accessibilityEventHandler{state: state, logger: slog.New(slog.DiscardHandler), debounce: time.Hour}
event := atspi.NewEvent(atspi.CaretEvent, atspi.Snapshot{BusName: combo.BusName, Path: "/editor", Role: "text", ReadOnlyCombo: test.owner})
handler.handleCaret(event, input.Activity{}, input.NoIntent)
defer state.pending.clear()
if skipped := state.pending.caret.event.Type == ""; skipped != test.skip {
t.Fatalf("caret skipped = %v, want %v", skipped, test.skip)
}
})
}
}
func TestProcessEventsSpeaksCaretCharacters(t *testing.T) { func TestProcessEventsSpeaksCaretCharacters(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 3)} speech := &fakeSpeechQueue{spoken: make(chan string, 3)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)}
+9 -2
View File
@@ -33,11 +33,13 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
// Compare with the owned focus snapshot, not the last speech request: // Compare with the owned focus snapshot, not the last speech request:
// a state/value update can intervene between two toolkit focus events. // a state/value update can intervene between two toolkit focus events.
// Still refresh review and braille below before suppressing presentation. // Still refresh review and braille below before suppressing presentation.
managedSelectionFocus := isFocusGainEvent(event) && state.managedDescendantID.Valid() &&
event.Source.ID() == state.managedDescendantID && sameSelectionItem(event.Source, state.current)
duplicateFocus := (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0) && duplicateFocus := (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0) &&
event.Source.Role != "terminal" && !presentsReusedRow(event.Source) && event.Source.Role != "terminal" && !presentsReusedRow(event.Source) &&
!(state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow) && !(state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow) &&
state.focusedID.Valid() && event.Source.ID() == state.focusedID && ((state.focusedID.Valid() && event.Source.ID() == state.focusedID &&
event.Source.ID() == state.current.ID() && event.Source.ID() == state.current.ID()) || managedSelectionFocus) &&
presentation.FocusTextWithOptions(event.Source, state.presentationOptions) == presentation.FocusTextWithOptions(state.current, state.presentationOptions) && presentation.FocusTextWithOptions(event.Source, state.presentationOptions) == presentation.FocusTextWithOptions(state.current, state.presentationOptions) &&
slices.Equal(presentation.SoundCues(event.Source, state.presentationOptions), presentation.SoundCues(state.current, state.presentationOptions)) slices.Equal(presentation.SoundCues(event.Source, state.presentationOptions), presentation.SoundCues(state.current, state.presentationOptions))
if isFocusEvent(event.Type) { if isFocusEvent(event.Type) {
@@ -93,6 +95,11 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
logger.Debug("skipped selection already presented by focus", "role", event.Source.Role) logger.Debug("skipped selection already presented by focus", "role", event.Source.Role)
return true return true
} }
} else if event.Type == atspi.SelectedStateEvent && !event.Source.Selected &&
(event.Source.Role == "tree item" || event.Source.Role == "list item") &&
state.managedDescendantID.Valid() && event.Source.ID() != state.managedDescendantID {
logger.Debug("skipped deselected previous managed descendant", "role", event.Source.Role)
return true
} else if (isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type)) && } else if (isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type)) &&
(state.focusedID.Path == "" || event.Source.ID() != state.focusedID) { (state.focusedID.Path == "" || event.Source.ID() != state.focusedID) {
logger.Debug("skipped event outside focused object", "type", event.Type, "role", event.Source.Role) logger.Debug("skipped event outside focused object", "type", event.Type, "role", event.Source.Role)
+46
View File
@@ -67,6 +67,52 @@ func TestProcessEventsSuppressesDuplicateFocusObjects(t *testing.T) {
} }
} }
func TestProcessEventsSuppressesQtPopupSelectionRepeats(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
close(done)
}()
defer func() { cancel(); <-done }()
list := atspi.Snapshot{BusName: ":1.7", Path: "/combo/list", Role: "list"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, list)
if got := <-speech.spoken; got != "list" {
t.Fatalf("list focus speech = %q", got)
}
item := func(path, name string) atspi.Snapshot {
return atspi.Snapshot{BusName: list.BusName, Path: path, Role: "list item", Name: name, Focused: true, Selected: true}
}
first := item("/combo/list/1", "Classic Doom")
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, first)
if got := <-speech.spoken; got != "Classic Doom, list item, selected" {
t.Fatalf("first selection speech = %q", got)
}
firstFocus := atspi.NewEvent(atspi.FocusedStateEvent, first)
firstFocus.Detail1 = 1
adapter.events <- firstFocus
second := item("/combo/list/2", "Funny Pack - Toby Doom")
adapter.events <- atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, second)
if got := <-speech.spoken; got != "Funny Pack - Toby Doom, list item, selected" {
t.Fatalf("second selection speech = %q", got)
}
first.Focused, first.Selected = false, false
adapter.events <- atspi.NewEvent(atspi.SelectedStateEvent, first)
secondFocus := atspi.NewEvent(atspi.FocusedStateEvent, second)
secondFocus.Detail1 = 1
adapter.events <- secondFocus
select {
case got := <-speech.spoken:
t.Fatalf("Qt popup selection repeated as %q", got)
case <-time.After(50 * time.Millisecond):
}
}
func TestProcessEventsSuppressesUnfocusedNameChangesDuringFileNavigation(t *testing.T) { func TestProcessEventsSuppressesUnfocusedNameChangesDuringFileNavigation(t *testing.T) {
speech := &fakeSpeechQueue{spoken: make(chan string, 8)} speech := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)} adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
@@ -114,6 +114,12 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
captureCaret = role != C.ATSPI_ROLE_DOCUMENT_WEB && role != C.ATSPI_ROLE_DOCUMENT_FRAME && role != C.ATSPI_ROLE_EMBEDDED captureCaret = role != C.ATSPI_ROLE_DOCUMENT_WEB && role != C.ATSPI_ROLE_DOCUMENT_FRAME && role != C.ATSPI_ROLE_EMBEDDED
} }
snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load()) snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load())
if eventType == CaretEvent {
if combo := C.cthulhu_read_only_combo(target); combo != nil {
snapshot.ReadOnlyCombo = nativeObjectID(combo)
C.g_object_unref(C.gpointer(combo))
}
}
if snapshot.Editable && (eventType == CaretEvent || eventType == TextInsertedEvent || if snapshot.Editable && (eventType == CaretEvent || eventType == TextInsertedEvent ||
eventType == TextDeletedEvent || eventType == TextSelectionChangedEvent) { eventType == TextDeletedEvent || eventType == TextSelectionChangedEvent) {
if editor := C.cthulhu_editable_root(target); editor != nil { if editor := C.cthulhu_editable_root(target); editor != nil {
@@ -212,7 +218,10 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
} }
} }
if eventType == CaretEvent && snapshot.Role != "password text" { // A read-only combo child only mirrors the chosen value. Qt 6.9 can stop
// answering accessibility requests after its word-at-caret query; no word
// feedback is needed for this non-writable selection proxy.
if eventType == CaretEvent && snapshot.Role != "password text" && !snapshot.ReadOnlyCombo.Valid() {
word := C.cthulhu_capture_caret_unit(target, C.ATSPI_TEXT_GRANULARITY_WORD) word := C.cthulhu_capture_caret_unit(target, C.ATSPI_TEXT_GRANULARITY_WORD)
snapshot.CaretWord = sanitizeSnapshotField(C.GoString(word)) snapshot.CaretWord = sanitizeSnapshotField(C.GoString(word))
C.g_free(C.gpointer(word)) C.g_free(C.gpointer(word))
@@ -559,7 +568,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
focusEvent.Window = originID focusEvent.Window = originID
} }
if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" { if !focusEvent.ForegroundMismatch && !snapshot.Defunct && snapshot.Role != "invalid" {
if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) && if shouldRetainNativeFocus(eventType, focusEvent.Detail1, chromeFocus) && !snapshot.ReadOnlyCombo.Valid() &&
!(eventType == SelectionChangedEvent && focusEvent.Source.LibreOffice != nil && focusEvent.Source.LibreOffice.InSpreadsheet) { !(eventType == SelectionChangedEvent && focusEvent.Source.LibreOffice != nil && focusEvent.Source.LibreOffice.InSpreadsheet) {
adapter.rememberNativeFocus(target, snapshot.ID()) adapter.rememberNativeFocus(target, snapshot.ID())
} }
+22
View File
@@ -746,6 +746,28 @@ AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source) {
return NULL; return NULL;
} }
// Qt exposes an editable combo's read-only line edit as TEXT with EDITABLE
// still set. Require READ_ONLY and a direct combo parent; an unrelated editor
// in the same application must keep ordinary caret and word navigation.
AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source) {
if (source == NULL) return NULL;
AtspiRole role = atspi_accessible_get_role(source, NULL);
if (role != ATSPI_ROLE_TEXT && role != ATSPI_ROLE_ENTRY) return NULL;
AtspiStateSet *states = atspi_accessible_get_state_set(source);
gboolean readOnly = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_READ_ONLY) &&
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
if (states != NULL) g_object_unref(states);
if (!readOnly) return NULL;
GError *error = NULL;
AtspiAccessible *parent = atspi_accessible_get_parent(source, &error);
if (error == NULL && parent != NULL &&
atspi_accessible_get_role(parent, NULL) == ATSPI_ROLE_COMBO_BOX) return parent;
g_clear_error(&error);
if (parent != NULL) g_object_unref(parent);
return NULL;
}
// Chromium can detach an existing document from its frame's AT-SPI parent // Chromium can detach an existing document from its frame's AT-SPI parent
// chain while returning to a tab. Its document identity remains usable. // chain while returning to a tab. Its document identity remains usable.
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source) { AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source) {
+1
View File
@@ -142,6 +142,7 @@ AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source);
gboolean cthulhu_may_be_background_notification(AtspiAccessible *source); gboolean cthulhu_may_be_background_notification(AtspiAccessible *source);
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source); AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source);
AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source); AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source);
AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source);
AtspiEventListenerCB cthulhu_event_callback_pointer(void); AtspiEventListenerCB cthulhu_event_callback_pointer(void);
gboolean cthulhu_request_event_loop_quit(void); gboolean cthulhu_request_event_loop_quit(void);
gboolean cthulhu_request_tree_capture(gpointer userData); gboolean cthulhu_request_tree_capture(gpointer userData);
+4 -1
View File
@@ -93,7 +93,10 @@ type Snapshot struct {
Editable bool Editable bool
// EditableRoot identifies the nearest focusable writable editor or editable // EditableRoot identifies the nearest focusable writable editor or editable
// web document. Paragraph events can belong to its native focus. // web document. Paragraph events can belong to its native focus.
EditableRoot ObjectID EditableRoot ObjectID
// ReadOnlyCombo identifies the direct combo parent of a read-only text
// child. Its caret changes mirror selection; the combo owns presentation.
ReadOnlyCombo ObjectID
Expandable bool Expandable bool
Expanded bool Expanded bool
Indeterminate bool Indeterminate bool
+149
View File
@@ -0,0 +1,149 @@
// Cthulhu screen reader.
// 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"
"testing"
"time"
)
func TestNativeTobyComboPopupLifetime(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TOBY_COMBO") != "1" {
t.Skip("requires isolated Toby combo fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer stopCancel()
if err := adapter.Stop(stopCtx); err != nil {
t.Error(err)
}
}()
if _, err := adapter.CaptureDesktop(ctx); err != nil {
t.Fatal(err)
}
key := func(keys ...string) {
t.Helper()
arguments := append([]string{"key", "--delay", "120"}, keys...)
if err := exec.CommandContext(ctx, "xdotool", arguments...).Run(); err != nil {
t.Fatal(err)
}
}
var combo ObjectID
var comboCarets int
var slowest time.Duration
wait := func(description string, match func(Event) bool) Event {
t.Helper()
deadline := time.NewTimer(4 * time.Second)
defer deadline.Stop()
var recent []string
for {
select {
case event := <-adapter.Events():
if event.Source.ApplicationName != "toby_combo_fixture.py" {
continue
}
recent = append(recent, event.Type+" "+event.Source.Role+" "+event.Source.Name)
if len(recent) > 12 {
recent = recent[1:]
}
slowest = max(slowest, event.SnapshotDuration)
if event.Type == CaretEvent && event.Source.ReadOnlyCombo.Valid() {
comboCarets++
if event.Source.ReadOnlyCombo != combo || event.Source.CaretWord != "" {
t.Fatalf("read-only combo caret: owner=%v word=%q", event.Source.ReadOnlyCombo, event.Source.CaretWord)
}
}
if match(event) {
return event
}
case <-deadline.C:
t.Fatalf("missing %s; recent events: %q; background filtered=%d", description, recent, adapter.backgroundEventsFiltered.Load())
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
focus := func(role, name string) Event {
t.Helper()
return wait("focus on "+role+" "+name, func(event Event) bool {
return event.Type == FocusedStateEvent && event.Detail1 == 1 &&
event.Source.Role == role && event.Source.Name == name
})
}
name := func(value string) Event {
t.Helper()
return wait("combo value "+value, func(event Event) bool {
return event.Type == AccessibleNameEvent && event.Source.Role == "combo box" && event.Source.Name == value
})
}
// Closed arrows produce consecutive caret events without another combo
// focus event between them. This was missing from the popup-only probe.
key("Tab", "shift+Tab")
combo = focus("combo box", "Classic Doom").Source.ID()
for _, step := range []struct{ key, value string }{
{"Down", "Bring Back the 90s"}, {"Down", "Funny Pack - Toby Doom"},
{"Up", "Bring Back the 90s"}, {"Up", "Classic Doom"},
{"End", "Funny Pack - Toby Doom"}, {"Home", "Classic Doom"},
} {
key(step.key)
name(step.value)
}
if comboCarets < 4 {
t.Fatalf("only %d read-only combo caret events observed", comboCarets)
}
for _, closeKey := range []string{"Return", "Escape"} {
key("alt+Down")
focus("list", "")
key("Down")
wait("popup selection", func(event Event) bool {
return event.Type == SelectionChangedEvent && event.Source.Role == "list item" && event.Source.Selected
})
key(closeKey)
wait("combo focus after "+closeKey, func(event Event) bool {
return event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == combo
})
key("Tab")
focus("button", "Launch")
key("shift+Tab")
wait("return to combo", func(event Event) bool {
return event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == combo
})
}
key("Tab")
focus("button", "Launch")
// Other text controls still need native word capture, including read-only
// fields outside combos and writable combo editors in this same provider.
for _, control := range []struct{ role, name string }{
{"text", "Plain editor"}, {"text", "Read-only text"}, {"combo box", "alpha beta"},
} {
key("Tab")
// Qt can expose entry into a line edit only through a text-selection
// event, without refreshing libatspi's focused state or emitting focus.
wait("entry into "+control.name, func(event Event) bool {
return event.Source.Role == control.role && event.Source.Name == control.name
})
key("Home", "ctrl+Right")
caret := wait("word caret in "+control.name, func(event Event) bool {
return event.Type == CaretEvent && event.Source.CaretWord == "beta"
})
if caret.Source.ReadOnlyCombo.Valid() {
t.Fatalf("ordinary text caret acquired combo selection owner: %+v", caret.Source)
}
}
if dropped := adapter.Overflow(); dropped != 0 {
t.Fatalf("native event queue dropped %d events", dropped)
}
t.Logf("closed arrows, Home/End, popup Return/Escape and text navigation completed; slowest snapshot %s", slowest)
}
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Exercise Toby's editable read-only combo under a private AT-SPI session."""
import os
from pathlib import Path
import site
import subprocess
import sys
import tempfile
import time
from native_test_session import run_private_session
def wait_for_window():
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
result = subprocess.run(
["xdotool", "search", "--onlyvisible", "--name", "^Toby combo probe$"],
capture_output=True, text=True,
)
if result.stdout.strip():
return result.stdout.strip().splitlines()[0]
time.sleep(0.05)
raise RuntimeError("Toby combo fixture window missing")
def run_inside():
root = Path(__file__).resolve().parents[1]
diagnostics = tempfile.NamedTemporaryFile(prefix="cthulhu-combo-qt-", suffix=".log", delete=False)
print("Qt fixture diagnostics:", diagnostics.name, flush=True)
fixture = subprocess.Popen(
[sys.executable, root / "tests" / "toby_combo_fixture.py"],
stderr=diagnostics,
stdout=diagnostics,
)
try:
window = wait_for_window()
subprocess.run(["xdotool", "windowfocus", "--sync", window], check=True)
from native_x11 import set_active_window
set_active_window(int(window))
subprocess.run(
["go", "test", "./internal/atspi", "-run", "^TestNativeTobyComboPopupLifetime$",
"-count=1", "-v", "-timeout=45s"],
cwd=root, check=True, timeout=60,
env=dict(os.environ, CTHULHU_NATIVE_TOBY_COMBO="1", GOCACHE="/tmp/cthulhu-cache"),
)
finally:
exited = fixture.poll() is not None
if not exited:
fixture.terminate()
fixture.wait(timeout=5)
diagnostics.close()
if exited:
raise RuntimeError(f"Qt fixture exited with status {fixture.returncode}; see {diagnostics.name}")
if __name__ == "__main__":
if "--inside" in sys.argv:
run_inside()
else:
with tempfile.TemporaryDirectory(prefix="cthulhu-toby-combo-") as temporary:
environment = dict(
os.environ,
HOME=temporary,
XDG_CONFIG_HOME=temporary,
XDG_CACHE_HOME=temporary,
XDG_DATA_HOME=temporary,
QT_QPA_PLATFORM="xcb",
QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1",
NO_AT_BRIDGE="0",
PYTHONPATH=site.getusersitepackages(),
GOMODCACHE=subprocess.check_output(
["go", "env", "GOMODCACHE"], text=True
).strip(),
)
environment.pop("AT_SPI_BUS_ADDRESS", None)
run_private_session(
["xvfb-run", "-a", "-s", "-screen 0 1024x768x24", "dbus-run-session", "--", sys.executable,
__file__, "--inside"],
env=environment, check=True,
)
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Minimal Toby Doom Launcher combo-box fixture for native diagnostics."""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QComboBox, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
class AccessibleComboBox(QComboBox):
def __init__(self, parent=None):
super().__init__(parent)
self.setEditable(True)
self.lineEdit().setReadOnly(True)
self.pageStep = 5
def keyPressEvent(self, event):
current_index = self.currentIndex()
item_count = self.count()
if event.key() == Qt.Key_PageUp:
self.setCurrentIndex(max(0, current_index - self.pageStep))
elif event.key() == Qt.Key_PageDown:
self.setCurrentIndex(min(item_count - 1, current_index + self.pageStep))
elif event.key() == Qt.Key_Home:
self.setCurrentIndex(0)
self.setFocus()
self.currentIndexChanged.emit(0)
self.activated.emit(0)
elif event.key() == Qt.Key_End:
last_index = item_count - 1
self.setCurrentIndex(last_index)
self.setFocus()
self.currentIndexChanged.emit(last_index)
self.activated.emit(last_index)
else:
super().keyPressEvent(event)
application = QApplication([])
window = QWidget()
window.setWindowTitle("Toby combo probe")
layout = QVBoxLayout(window)
label = QLabel("Game")
combo = AccessibleComboBox()
combo.setAccessibleName("Game")
combo.addItems(("Classic Doom", "Bring Back the 90s", "Funny Pack - Toby Doom"))
label.setBuddy(combo)
layout.addWidget(label)
layout.addWidget(combo)
button = QPushButton("Launch")
layout.addWidget(button)
editor = QLineEdit("alpha beta")
editor.setAccessibleName("Plain editor")
layout.addWidget(editor)
read_only = QLineEdit("alpha beta")
read_only.setReadOnly(True)
read_only.setAccessibleName("Read-only text")
layout.addWidget(read_only)
writable_combo = QComboBox()
writable_combo.setEditable(True)
writable_combo.addItems(("alpha beta", "gamma delta"))
layout.addWidget(writable_combo)
window.resize(420, 300)
window.show()
combo.setFocus()
application.exec()