From d717b93849ff5d4662c744214dfa3dd3b8751cf6 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 18 Oct 2026 19:18:57 -0400 Subject: [PATCH] Fixed terminal bug. Fixed tests which could leave the system without proper accessibility setup which could cause cthulhu to fail to start. --- CONTRIBUTING.md | 18 +++ docs/testing/terminal-scrollback.md | 38 +++++ internal/app/event_accessibility.go | 4 + internal/app/terminal_events.go | 19 +++ internal/app/terminal_events_test.go | 108 ++++++++++++++ internal/atspi/model.go | 8 ++ internal/atspi/model_test.go | 10 ++ tests/check_speech.py | 4 +- tests/native_alert.py | 3 +- tests/native_atspi.py | 4 +- tests/native_browser.py | 4 +- tests/native_browser_navigation.py | 3 +- tests/native_clipboard_preferences.py | 3 +- tests/native_firefox.py | 4 +- tests/native_internal_ui.py | 4 +- tests/native_libreoffice.py | 4 +- tests/native_mozilla.py | 3 +- tests/native_notifications.py | 3 +- tests/native_ownership.py | 3 +- tests/native_preferences.py | 3 +- tests/native_progress.py | 4 +- tests/native_qt_controls.py | 3 +- tests/native_speech_history.py | 3 +- tests/native_terminal.py | 199 ++++++++++++++++++++++++++ tests/native_test_session.py | 40 ++++++ tests/native_thunderbird.py | 3 +- tests/native_webscripts.py | 4 +- tests/native_x11.py | 4 +- tests/test_native_test_session.py | 117 +++++++++++++++ tests/window_titles.md | 6 +- 30 files changed, 611 insertions(+), 22 deletions(-) create mode 100644 docs/testing/terminal-scrollback.md create mode 100644 internal/app/terminal_events.go create mode 100644 tests/native_terminal.py create mode 100644 tests/native_test_session.py create mode 100644 tests/test_native_test_session.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23dda2c..daf5923 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,6 +90,24 @@ Preserve versioned TOML settings. The default file is `$XDG_DATA_HOME/cthulhu/us Native `*_native_test.go` checks require fixtures and environment flags. Use the relevant isolated harness in `tests/`, such as `python3 tests/native_atspi.py` or `python3 tests/native_preferences.py`. Shared browser changes also need the relevant browser harness and real-browser regression checks. +Native harnesses must launch their private D-Bus session through +`tests/native_test_session.py:run_private_session`. It gives activated services +a disposable mode-0700 `XDG_RUNTIME_DIR` and removes inherited desktop bus +addresses. Start Xvfb before D-Bus: service activation inherits the bus daemon's +display, even when the test client later changes `DISPLAY`. Sharing the desktop +runtime directory can let another AT-SPI launcher replace its live socket. + +For a one-off native test or diagnostic command, use the same launcher: + +```sh +python3 tests/native_test_session.py env CTHULHU_NATIVE_WINDOW_TITLES=1 go test ./internal/app -run '^TestNativeAutomaticTitleDispatchLatency$' -count=1 -v +python3 tests/test_native_test_session.py -v +``` + +The second command checks environment and cleanup boundaries and activates a +real AT-SPI bus against disposable canary sockets, without contacting the +desktop display or accessibility bus. + Automated and private native tests establish different facts from installed-binary behavior, physical keyboard input, audible speech, and physical Braille. Report which checks actually ran and which remain pending. Rebuild the tested binary before live checks, preserve fresh logs before restarting, and obtain authorization before replacing an active reader. ## Diagnostic Logging diff --git a/docs/testing/terminal-scrollback.md b/docs/testing/terminal-scrollback.md new file mode 100644 index 0000000..0e7b18b --- /dev/null +++ b/docs/testing/terminal-scrollback.md @@ -0,0 +1,38 @@ +# Terminal scrollback deletion feedback + +VTE reports old lines leaving its accessible text as deletions at offset zero. +Speaking those deletions replays previous prompts and command output while new +output arrives. This was reproduced with a real shell and reader on private X11; +the same event pattern appeared in the reported Wayland log. + +The shared event policy suppresses only terminal deletions at offset zero when +the native caret is known, the original removed text ends with a newline, and +its positive code-point length does not extend past the current line start. +Other deletions retain the existing redraw coalescing and editing feedback. +Speech normalization stays unchanged; numeric length and line-ending metadata +are retained separately because normalized payloads lose that information. + +Build and run the focused native regression: + +```sh +go build -o /tmp/cthulhu-terminal-check ./cmd/cthulhu +CTHULHU_BINARY=/tmp/cthulhu-terminal-check /usr/bin/python3 tests/native_terminal.py +``` + +It requires GTK3, VTE 2.91, Python GI/pyatspi, Xvfb, i3 and xdotool. It uses a +private display, D-Bus session, configuration, shell home and recorded SSIP +server; it does not replace the running reader. A small terminal with zero +scrollback reaches the scrolling condition quickly. The test checks that 16 +command outputs reach SSIP without old prompt deletions, then exercises +Backspace, Delete, Ctrl+W, character navigation, history and current-line flat +review. It prints the evidence directory containing reader logs and speech +records. Run private X11 display tests sequentially to avoid xvfb-run display +allocation collisions. + +Human acceptance still requires listening in LXTerminal on both X11 and +Wayland. Fill the terminal until output scrolls, run short and multiline +commands, and confirm previous prompts and output are not replayed. Edit at +the beginning, middle and end of a command with Backspace, Delete and Ctrl+W; +use arrows, history and flat review. Check fresh-terminal and cleared-screen +behavior as well. Physical keyboard, audible speech and Braille acceptance +are separate from recorded SSIP and synthetic input. diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index f64a6a0..63e37b3 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -278,6 +278,10 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { return } if event.Type == atspi.TextDeletedEvent && event.Source.Role == "terminal" { + if should_ignore_terminal_scrollback(event) { + logger.Debug("skipped terminal scrollback deletion", "role", event.Source.Role) + return + } state.pending.terminalDeletion.schedule(event, input.NoIntent, terminalMutationDebounce) return } diff --git a/internal/app/terminal_events.go b/internal/app/terminal_events.go new file mode 100644 index 0000000..8987d9b --- /dev/null +++ b/internal/app/terminal_events.go @@ -0,0 +1,19 @@ +// Cthulhu screen reader. +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import "git.stormux.org/storm/cthulhu/internal/atspi" + +func should_ignore_terminal_scrollback(event atspi.Event) bool { + // VTE can remove old complete lines at the start of its accessible text + // while appending output at a different offset. These are not edits to + // the prompt. This event pattern occurs on both X11 and Wayland. + return event.Type == atspi.TextDeletedEvent && + event.Source.Role == "terminal" && event.Detail1 == 0 && + event.Source.TextCaretKnown && event.Source.BrailleLineStart > 0 && + event.TextChangeEndsLine && event.TextChangeLength > 0 && + event.TextChangeLength <= event.Source.BrailleLineStart +} diff --git a/internal/app/terminal_events_test.go b/internal/app/terminal_events_test.go index d9b1ef8..20df10e 100644 --- a/internal/app/terminal_events_test.go +++ b/internal/app/terminal_events_test.go @@ -20,12 +20,120 @@ package app import ( "context" "log/slog" + "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" ) +func TestProcessEventsTerminalScrollbackDeletionBothSessions(t *testing.T) { + for _, sessionType := range []string{"wayland", "x11"} { + t.Run(sessionType, func(t *testing.T) { + t.Setenv("XDG_SESSION_TYPE", sessionType) + // Both sessions must apply the same event policy, including + // an X11 launcher which also supplies WAYLAND_DISPLAY. + t.Setenv("WAYLAND_DISPLAY", "wayland-test") + speech := &fakeSpeechQueue{spoken: make(chan string, 3)} + adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} + ctx, cancel := context.WithCancel(context.Background()) + completed := make(chan struct{}) + go func() { + defer close(completed) + processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0) + }() + t.Cleanup(func() { + cancel() + select { + case <-completed: + case <-time.After(time.Second): + t.Error("event loop did not stop") + } + }) + + terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Username@Host> ") + terminal.TextCaretKnown = true + terminal.BrailleLineStart = 300 + terminal.BrailleCaret = 315 + oldPrompt := "Username@Host> old command\n" + deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, oldPrompt) + output := atspi.NewEventWithPayload(atspi.TextInsertedEvent, terminal, "Current command output") + output.Detail1 = 200 + // A caret event can arrive between the scroll and its insertion. + caret := atspi.NewEvent(atspi.CaretEvent, terminal) + adapter.events <- deletion + adapter.events <- caret + adapter.events <- output + + for _, want := range []string{"Current command output"} { + select { + case got := <-speech.spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for %q", want) + } + } + for _, deleted := range []string{"x", "command"} { + edit := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, deleted) + edit.Detail1 = terminal.BrailleLineStart + adapter.events <- edit + select { + case got := <-speech.spoken: + if got != deleted { + t.Fatalf("editing speech = %q, want %q", got, deleted) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for deletion %q", deleted) + } + } + }) + } +} + +func TestTerminalScrollbackDeletionPreservesEditing(t *testing.T) { + t.Setenv("XDG_SESSION_TYPE", "wayland") + terminal := atspi.NewSnapshot("/accessible/terminal", "", "terminal", "", "Prompt") + terminal.TextCaretKnown = true + terminal.BrailleLineStart = 100 + terminal.BrailleCaret = 110 + base := atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old prompt\n") + for _, test := range []struct { + name string + modify func(*atspi.Event) + ignore bool + }{ + {"scroll", func(event *atspi.Event) {}, true}, + {"unicode offsets", func(event *atspi.Event) { + *event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, strings.Repeat("猫", 40)+"\n") + }, true}, + {"backspace", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 109 }, false}, + {"delete", func(event *atspi.Event) { event.Payload = "x"; event.Detail1 = 110 }, false}, + {"delete word", func(event *atspi.Event) { event.Payload = "command"; event.Detail1 = 100 }, false}, + {"first character", func(event *atspi.Event) { + *event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "x") + }, false}, + {"newline", func(event *atspi.Event) { event.Payload = "\n"; event.Detail1 = 100 }, false}, + {"current first line", func(event *atspi.Event) { event.Source.BrailleLineStart = 0 }, false}, + {"range reaches current line", func(event *atspi.Event) { event.Source.BrailleLineStart = 5 }, false}, + {"unknown caret", func(event *atspi.Event) { event.Source.TextCaretKnown = false }, false}, + {"partial line", func(event *atspi.Event) { + *event = atspi.NewEventWithPayload(atspi.TextDeletedEvent, terminal, "Old prompt") + }, false}, + {"editor", func(event *atspi.Event) { event.Source.Role = "text" }, false}, + {"insertion", func(event *atspi.Event) { event.Type = atspi.TextInsertedEvent }, false}, + } { + t.Run(test.name, func(t *testing.T) { + event := base + test.modify(&event) + if got := should_ignore_terminal_scrollback(event); got != test.ignore { + t.Fatalf("ignore = %v, want %v", got, test.ignore) + } + }) + } +} + func TestProcessEventsCoalescesTerminalDeletionWithReplacementInsertion(t *testing.T) { speech := &fakeSpeechQueue{spoken: make(chan string, 3)} adapter := &fakeAdapter{events: make(chan atspi.Event, 3)} diff --git a/internal/atspi/model.go b/internal/atspi/model.go index 1f2c09d..3f3953b 100644 --- a/internal/atspi/model.go +++ b/internal/atspi/model.go @@ -295,6 +295,10 @@ type Event struct { OriginPath string Payload string Detail1 int + // TextChangeLength and TextChangeEndsLine describe the original payload, + // before speech normalization collapses whitespace and limits its length. + TextChangeLength int + TextChangeEndsLine bool } type ForegroundCheck struct { @@ -375,6 +379,10 @@ func NewManagedDescendantEvent(eventType, originPath string, source Snapshot) Ev func NewEventWithPayload(eventType string, source Snapshot, payload string) Event { event := NewEvent(eventType, source) event.Payload = sanitizeTextPayload(payload) + if event.Type == TextInsertedEvent || event.Type == TextDeletedEvent { + event.TextChangeLength = utf8.RuneCountInString(payload) + event.TextChangeEndsLine = strings.HasSuffix(payload, "\n") + } return event } diff --git a/internal/atspi/model_test.go b/internal/atspi/model_test.go index 2b82daf..e1cbdd3 100644 --- a/internal/atspi/model_test.go +++ b/internal/atspi/model_test.go @@ -181,6 +181,16 @@ func TestNewEventWithPayloadSanitizesPayload(t *testing.T) { } } +func TestTextChangeRetainsOriginalRangeMetadata(t *testing.T) { + source := NewSnapshot("/accessible/terminal", "", "terminal", "", "") + for _, eventType := range []string{TextInsertedEvent, TextDeletedEvent, TextDeletedEvent + ":system"} { + event := NewEventWithPayload(eventType, source, "猫 command\r\n") + if event.Payload != "猫 command" || event.TextChangeLength != 12 || !event.TextChangeEndsLine { + t.Fatalf("text change = %#v", event) + } + } +} + func TestNewEventWithPayloadPreservesInsertedSpace(t *testing.T) { event := NewEventWithPayload(TextInsertedEvent, NewSnapshot("/accessible/editor", "", "text", "", ""), " ") if event.Payload != " " { diff --git a/tests/check_speech.py b/tests/check_speech.py index 0db5bcf..36ab73f 100755 --- a/tests/check_speech.py +++ b/tests/check_speech.py @@ -8,6 +8,8 @@ import subprocess import sys import tempfile +from native_test_session import run_private_session + requiredTests = { "TestPrivateWineReviewUpdatesPreserveQueuedTitleSpeech", @@ -34,7 +36,7 @@ def run_checks(sourceRoot: Path) -> None: "./internal/app", "./internal/input", "./internal/speech", "./internal/wine", ] print("Checking speech queues, Wine metadata and key interruption...", flush=True) - result = subprocess.run(command, cwd=sourceRoot, env=testEnv, text=True, + result = run_private_session(command, cwd=sourceRoot, env=testEnv, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) passedTests = set() diagnostics = [] diff --git a/tests/native_alert.py b/tests/native_alert.py index 135a9d9..103ad0f 100644 --- a/tests/native_alert.py +++ b/tests/native_alert.py @@ -8,6 +8,7 @@ import tempfile import time sys.dont_write_bytecode = True +from native_test_session import run_private_session def inside(): @@ -69,7 +70,7 @@ def main(): environment.pop("DBUS_SESSION_BUS_ADDRESS", None) environment.pop("AT_SPI_BUS_ADDRESS", None) environment.pop("WAYLAND_DISPLAY", None) - subprocess.run( + run_private_session( ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, timeout=50, ) diff --git a/tests/native_atspi.py b/tests/native_atspi.py index 6fa0d1c..7089fe4 100644 --- a/tests/native_atspi.py +++ b/tests/native_atspi.py @@ -9,6 +9,8 @@ import sys import tempfile import time +from native_test_session import run_private_session + def wait_for(check, message, timeout=15): deadline = time.monotonic() + timeout @@ -265,7 +267,7 @@ def main() -> None: environment.pop("DBUS_SESSION_BUS_ADDRESS", None) environment.pop("AT_SPI_BUS_ADDRESS", None) environment.pop("WAYLAND_DISPLAY", None) - subprocess.run( + run_private_session( ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside-screen-lock" if len(sys.argv) == 2 and sys.argv[1] == "--screen-lock" else "--inside-table" if len(sys.argv) == 2 and sys.argv[1] == "--table-only" else "--inside"], diff --git a/tests/native_browser.py b/tests/native_browser.py index d543bf7..380b1f7 100644 --- a/tests/native_browser.py +++ b/tests/native_browser.py @@ -8,6 +8,8 @@ import sys import tempfile import time +from native_test_session import run_private_session + BROWSER_PROOFS = { "menu": ("./internal/app", "^TestNativeBrowserMenuControls$", 30, 35), @@ -122,7 +124,7 @@ def main(): environment.pop("DBUS_SESSION_BUS_ADDRESS", None) environment.pop("AT_SPI_BUS_ADDRESS", None) environment.pop("WAYLAND_DISPLAY", None) - subprocess.run( + run_private_session( ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, diff --git a/tests/native_browser_navigation.py b/tests/native_browser_navigation.py index ad7dacd..5d12ac6 100644 --- a/tests/native_browser_navigation.py +++ b/tests/native_browser_navigation.py @@ -12,6 +12,7 @@ import time sys.dont_write_bytecode = True from native_echo_preview import PreviewSpeechServer from native_x11 import wait_for +from native_test_session import run_private_session def run_inside(): @@ -175,7 +176,7 @@ def main(): environment = dict(os.environ, DISPLAY=":" + number, XDG_SESSION_TYPE="x11") for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): environment.pop(name, None) - subprocess.run(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], + run_private_session(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, timeout=120) finally: display.terminate() diff --git a/tests/native_clipboard_preferences.py b/tests/native_clipboard_preferences.py index b94f517..8804a03 100644 --- a/tests/native_clipboard_preferences.py +++ b/tests/native_clipboard_preferences.py @@ -10,6 +10,7 @@ import threading sys.dont_write_bytecode = True from native_x11 import wait_for +from native_test_session import run_private_session def owner(): @@ -211,7 +212,7 @@ def main(): env = dict(os.environ, DISPLAY=":" + display, GDK_BACKEND="x11", GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local") for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): env.pop(name, None) - subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--inside"], env=env, check=True, timeout=75) + run_private_session(["dbus-run-session", "--", sys.executable, __file__, "--inside"], env=env, check=True, timeout=75) finally: server.terminate() server.wait(timeout=5) diff --git a/tests/native_firefox.py b/tests/native_firefox.py index 2937a46..122e31b 100644 --- a/tests/native_firefox.py +++ b/tests/native_firefox.py @@ -9,6 +9,8 @@ import sys import tempfile import time +from native_test_session import run_private_session + ROOT = Path(__file__).resolve().parents[1] PAGE = Path(__file__).with_name("firefox-baseline.html") @@ -97,7 +99,7 @@ def main(): environment.pop("DBUS_SESSION_BUS_ADDRESS", None) environment.pop("AT_SPI_BUS_ADDRESS", None) environment.pop("WAYLAND_DISPLAY", None) - subprocess.run( + run_private_session( ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, timeout=90, ) diff --git a/tests/native_internal_ui.py b/tests/native_internal_ui.py index 40a87bd..f480393 100644 --- a/tests/native_internal_ui.py +++ b/tests/native_internal_ui.py @@ -8,6 +8,8 @@ import sys import tempfile import time +from native_test_session import run_private_session + def run_inside(): root = Path(__file__).resolve().parents[1] @@ -112,6 +114,6 @@ if __name__ == "__main__": elif "--inside" in sys.argv: run_inside() else: - subprocess.run(["xvfb-run", "-a", "dbus-run-session", "--", sys.executable, + run_private_session(["xvfb-run", "-a", "dbus-run-session", "--", sys.executable, __file__, "--inside"], check=True, env=dict(os.environ, GTK_MODULES="gail:atk-bridge", NO_AT_BRIDGE="0")) diff --git a/tests/native_libreoffice.py b/tests/native_libreoffice.py index c75e96a..5a2c0d3 100644 --- a/tests/native_libreoffice.py +++ b/tests/native_libreoffice.py @@ -11,6 +11,8 @@ import sys import tempfile import time +from native_test_session import run_private_session + def wait_for(check, message, timeout=15): deadline = time.monotonic() + timeout @@ -166,7 +168,7 @@ def main(): XDG_DATA_HOME=temporary) for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY", "I3SOCK"): environment.pop(name, None) - subprocess.run( + run_private_session( ["xvfb-run", "-a", "dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, timeout=120, diff --git a/tests/native_mozilla.py b/tests/native_mozilla.py index ebc538c..a0b89d1 100644 --- a/tests/native_mozilla.py +++ b/tests/native_mozilla.py @@ -12,6 +12,7 @@ import tempfile sys.dont_write_bytecode = True from native_echo_preview import PreviewSpeechServer from native_x11 import wait_for +from native_test_session import run_private_session TITLE = "Cthulhu Mozilla Reading Probe" @@ -197,7 +198,7 @@ def main(): for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): environment.pop(name, None) with (directory / "session.log").open("w") as log: - subprocess.run(["xvfb-run", "-a", "-s", "-screen 0 1280x900x24", "dbus-run-session", "--", + run_private_session(["xvfb-run", "-a", "-s", "-screen 0 1280x900x24", "dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), application, "--inside"], env=environment, stderr=log, check=True, timeout=120) diff --git a/tests/native_notifications.py b/tests/native_notifications.py index fd5fae9..a83905d 100755 --- a/tests/native_notifications.py +++ b/tests/native_notifications.py @@ -9,6 +9,7 @@ import sys sys.dont_write_bytecode = True from native_x11 import wait_for +from native_test_session import run_private_session def inside(): @@ -161,7 +162,7 @@ def main(): ) for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): environment.pop(key, None) - subprocess.run( + run_private_session( ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, timeout=60, ) diff --git a/tests/native_ownership.py b/tests/native_ownership.py index ca7eb5a..55866ef 100644 --- a/tests/native_ownership.py +++ b/tests/native_ownership.py @@ -12,6 +12,7 @@ import tempfile sys.dont_write_bytecode = True from native_x11 import wait_for +from native_test_session import run_private_session def fixture(toolkit): @@ -347,7 +348,7 @@ def main(): environment = dict(os.environ, DISPLAY=":"+display, XDG_SESSION_TYPE="x11", QT_ACCESSIBILITY="1", QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1") for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY", "I3SOCK"): environment.pop(name, None) - subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--inside", directory], env=environment, check=True, timeout=100) + run_private_session(["dbus-run-session", "--", sys.executable, __file__, "--inside", directory], env=environment, check=True, timeout=100) finally: server.terminate(); server.wait(timeout=3) diff --git a/tests/native_preferences.py b/tests/native_preferences.py index 5c37b3c..b380b3e 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -11,6 +11,7 @@ import time sys.dont_write_bytecode = True from native_x11 import wait_for +from native_test_session import run_private_session class ResponseReader: @@ -1411,7 +1412,7 @@ def main(): env["LC_ALL"] = "en_US.UTF-8" for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): env.pop(key, None) - subprocess.run(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=env, check=True, timeout=90) + run_private_session(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=env, check=True, timeout=90) finally: server.terminate() server.wait(timeout=5) diff --git a/tests/native_progress.py b/tests/native_progress.py index e029569..0bf098e 100644 --- a/tests/native_progress.py +++ b/tests/native_progress.py @@ -5,6 +5,8 @@ from pathlib import Path import subprocess import sys +from native_test_session import run_private_session + def main(): if len(sys.argv) == 2 and sys.argv[1] == "--inside": @@ -29,7 +31,7 @@ def main(): GDK_BACKEND="x11", GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local") for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): environment.pop(name, None) - subprocess.run(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), + run_private_session(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, timeout=45) finally: server.terminate() diff --git a/tests/native_qt_controls.py b/tests/native_qt_controls.py index 25a26d8..32d0919 100644 --- a/tests/native_qt_controls.py +++ b/tests/native_qt_controls.py @@ -11,6 +11,7 @@ import tempfile sys.dont_write_bytecode = True from native_x11 import wait_for +from native_test_session import run_private_session def fixture(version): @@ -107,5 +108,5 @@ if __name__ == '__main__': QT_QPA_PLATFORM='xcb', QT_LINUX_ACCESSIBILITY_ALWAYS_ON='1', NO_AT_BRIDGE='0') environment.pop('AT_SPI_BUS_ADDRESS', None) - subprocess.run(['xvfb-run', '-a', 'dbus-run-session', '--', sys.executable, + run_private_session(['xvfb-run', '-a', 'dbus-run-session', '--', sys.executable, __file__, '--inside'], env=environment, check=True) diff --git a/tests/native_speech_history.py b/tests/native_speech_history.py index 8c38521..9701df6 100644 --- a/tests/native_speech_history.py +++ b/tests/native_speech_history.py @@ -9,6 +9,7 @@ import sys sys.dont_write_bytecode = True from native_x11 import wait_for +from native_test_session import run_private_session def inside(): @@ -133,7 +134,7 @@ def main(): GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local") for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): environment.pop(key, None) - subprocess.run( + run_private_session( ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, timeout=60, ) diff --git a/tests/native_terminal.py b/tests/native_terminal.py new file mode 100644 index 0000000..0af789e --- /dev/null +++ b/tests/native_terminal.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Real shell/VTE/reader regression with private X11, AT-SPI and recorded SSIP.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +sys.dont_write_bytecode = True +from native_echo_preview import PreviewSpeechServer +from native_x11 import wait_for +from native_test_session import run_private_session + +TITLE = "Terminal scrolling probe" +PROMPT = "Username@Host> " + + +def run_fixture(): + import gi + gi.require_version("Gtk", "3.0") + gi.require_version("Vte", "2.91") + from gi.repository import GLib, Gtk, Vte + + window = Gtk.Window(title=TITLE) + window.set_default_size(600, 220) + terminal = Vte.Terminal() + terminal.set_scrollback_lines(0) + window.add(terminal) + window.connect("destroy", Gtk.main_quit) + window.show_all() + terminal.grab_focus() + childEnv = [f"HOME={os.environ['HOME']}", "PATH=/usr/bin:/bin", "TERM=xterm", + "LC_ALL=C.UTF-8", "HISTFILE=/dev/null", "PS1=" + PROMPT, "PS2=Continue> "] + terminal.spawn_async(Vte.PtyFlags.DEFAULT, os.environ["HOME"], + ["/bin/bash", "--noprofile", "--norc"], childEnv, + GLib.SpawnFlags.DEFAULT, None, None, -1, None, None, None) + Gtk.main() + + +def run_inside(): + import pyatspi + directory = Path(tempfile.mkdtemp(prefix="cthulhu-terminal-runtime-")) + print(f"Evidence: {directory}", flush=True) + server = PreviewSpeechServer(directory / "speech.sock") + config = directory / "settings.toml" + config.write_text('''schema-version = 2 +active-profile = "default" +[profiles.default.speech] +output-module = "main" +[profiles.default.braille] +enabled = false +[profiles.default.sound] +enabled = false +[profiles.default.key-echo] +enabled = false +''') + wmConfig = directory / "i3.config" + wmConfig.write_text(f"font pango:monospace 10\nipc-socket {directory / 'i3.sock'}\n" + f'for_window [title="^{TITLE}$"] floating enable\n') + logPath = directory / "reader.log" + processes = [] + report = {} + + def key(chord): + subprocess.run(["xdotool", "key", "--clearmodifiers", chord], check=True) + + def type_text(text): + offset = len(logs()) + subprocess.run(["xdotool", "type", "--clearmodifiers", "--delay", "40", "--", text], check=True) + # Wait for the reader to receive VTE's final character event before + # sending another edit; otherwise VTE can merge both redraws. + wait_for(lambda: any(row.get("message") == "received accessibility event" + and row.get("type") == "object:text-caret-moved" + and row.get("native_caret_offset") == terminal.queryText().caretOffset + for row in logs()[offset:]), "typed text did not reach the reader") + + def logs(): + result = [] + for line in logPath.read_text().splitlines(): + try: + result.append(json.loads(line)) + except json.JSONDecodeError: + pass + return result + + def settle(): + wait_for(lambda: server.snapshot() and server.snapshot()[-1]["finished"], + "last speech request did not complete") + + try: + processes.append(subprocess.Popen(["i3", "-c", str(wmConfig)], + stdout=subprocess.DEVNULL, stderr=(directory / "wm.log").open("w"))) + wait_for(lambda: "0x" in subprocess.check_output( + ["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "private WM missing") + processes.append(subprocess.Popen([ + os.environ["CTHULHU_BINARY"], "--config", str(config), "--no-tentacles", + "--speech-socket", str(directory / "speech.sock"), "--debug-file", str(logPath)], + stdout=subprocess.DEVNULL, stderr=(directory / "reader-stderr.log").open("w"))) + wait_for(lambda: logPath.exists() and "listening for focus events" in logPath.read_text(), + "private reader did not start") + processes.append(subprocess.Popen([sys.executable, __file__, "--fixture"], + stdout=subprocess.DEVNULL, stderr=(directory / "fixture.log").open("w"))) + + def find_terminal(): + for application in pyatspi.Registry.getDesktop(0): + for window in application: + if window and window.name == TITLE: + return next((child for child in window if child and child.getRoleName() == "terminal"), None) + + terminal = wait_for(find_terminal, "terminal fixture missing") + text = terminal.queryText() + wait_for(lambda: PROMPT in text.getText(0, -1), "shell prompt missing") + windowId = subprocess.check_output( + ["xdotool", "search", "--onlyvisible", "--name", "^" + TITLE + "$"], text=True).splitlines()[0] + subprocess.run(["xdotool", "windowactivate", "--sync", windowId], check=True) + settle() + logOffset = len(logs()) + speechOffset = len(server.snapshot()) + for row in range(16): + output = f"Output{row:02d}" + type_text("echo " + output) + key("Return") + wait_for(lambda: any(output in item["text"] for item in server.snapshot()[speechOffset:]), + f"command output {output} was not spoken") + wait_for(lambda: text.getText(0, -1).rstrip().endswith(PROMPT.rstrip()), "prompt did not return") + settle() + scrollLogs = logs()[logOffset:] + replay = [row["utterance"] for row in scrollLogs + if row.get("message") == "delivering accessible presentation" + and row.get("cause") == "object:text-changed:delete" + and "Username@Host>" in row.get("utterance", "")] + recorded = [row["text"] for row in server.snapshot()[speechOffset:]] + ignored = sum(row.get("message") == "skipped terminal scrollback deletion" for row in scrollLogs) + report.update(replayed_prompts=replay, ignored_scroll_deletions=ignored) + if "--expect-replay" in sys.argv: + assert replay and any(value in recorded for value in replay), (replay, recorded) + print(f"CONFIRMED: X11 sent {len(replay)} old prompt deletions to SSIP", flush=True) + else: + assert not replay, replay + assert ignored > 0, "fixture did not exercise scroll deletions" + print(f"PASS: {ignored} scroll deletions suppressed; all 16 command outputs reached SSIP", flush=True) + + def check_edit(chord, expected): + offset = len(server.snapshot()) + key(chord) + wait_for(lambda: any(row["text"] == expected for row in server.snapshot()[offset:]), + f"{chord} did not speak {expected!r}") + settle() + report[chord] = [row["text"] for row in server.snapshot()[offset:]] + + type_text("abcd") + check_edit("BackSpace", "d") + check_edit("ctrl+w", "abc") + type_text("abcd") + check_edit("Left", "d") + check_edit("Delete", "d") + wait_for(lambda: text.getText(0, -1).rstrip().endswith(PROMPT + "abc"), "Delete did not remove d") + offset = len(server.snapshot()) + type_text("Z") + key("Return") + wait_for(lambda: any("abcZ" in row["text"] for row in server.snapshot()[offset:]), + "editing command did not reach shell") + settle() + report["editing_command"] = [row["text"] for row in server.snapshot()[offset:]] + # History and flat review must still announce terminal content. + check_edit("Up", "abcZ") + key("ctrl+u") + check_edit("KP_Up", "Username@Host>") + print("PASS: Backspace, Ctrl+W, Left, Delete, history and flat review", flush=True) + finally: + (directory / "speech.json").write_text(json.dumps(server.snapshot(), indent=2)) + (directory / "report.json").write_text(json.dumps(report, indent=2)) + for process in reversed(processes): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + server.close() + + +if __name__ == "__main__": + if "--fixture" in sys.argv: + run_fixture() + elif "--inside" in sys.argv: + run_inside() + else: + with tempfile.TemporaryDirectory(prefix="cthulhu-terminal-home-") as temporary: + environment = dict(os.environ, HOME=temporary, XDG_CONFIG_HOME=temporary, + XDG_DATA_HOME=temporary, XDG_CACHE_HOME=temporary, + XDG_SESSION_TYPE="x11", GDK_BACKEND="x11", + GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local") + for name in ("DISPLAY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS"): + environment.pop(name, None) + run_private_session(["xvfb-run", "-a", "dbus-run-session", "--", sys.executable, + __file__, "--inside", *sys.argv[1:]], env=environment, check=True, timeout=100) diff --git a/tests/native_test_session.py b/tests/native_test_session.py new file mode 100644 index 0000000..9999044 --- /dev/null +++ b/tests/native_test_session.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Launch native tests with disposable runtime sockets and private D-Bus.""" +import os +import subprocess +import sys +import tempfile + + +def run_private_session(command, *, env, **kwargs): + """Keep activated services away from the desktop's runtime sockets.""" + if not command or command[0] not in ("dbus-run-session", "xvfb-run"): + raise ValueError("Start the private session with xvfb-run or dbus-run-session") + if "dbus-run-session" not in command: + raise ValueError("A private session requires dbus-run-session") + if "xvfb-run" in command and command.index("xvfb-run") > command.index("dbus-run-session"): + raise ValueError("Start Xvfb before D-Bus so activated services inherit the private display") + + # Keep this path short: AT-SPI falls back from named sockets if its + # generated socket path is 100 bytes or longer. + with tempfile.TemporaryDirectory(prefix="cth-run-", dir="/tmp") as runtimeDir: + sessionEnv = dict(env, XDG_RUNTIME_DIR=runtimeDir) + for name in ("DBUS_SESSION_BUS_ADDRESS", "DBUS_STARTER_ADDRESS", "DBUS_STARTER_BUS_TYPE", + "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + sessionEnv.pop(name, None) + return subprocess.run(command, env=sessionEnv, **kwargs) + + +def main(): + if len(sys.argv) < 2: + sys.exit("Usage: native_test_session.py COMMAND [ARG ...]") + result = run_private_session( + ["xvfb-run", "-a", "dbus-run-session", "--", *sys.argv[1:]], env=os.environ) + return result.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/native_thunderbird.py b/tests/native_thunderbird.py index 86cf883..786c5e8 100644 --- a/tests/native_thunderbird.py +++ b/tests/native_thunderbird.py @@ -15,6 +15,7 @@ sys.dont_write_bytecode = True from native_echo_preview import PreviewSpeechServer from native_mozilla import walk, write_profile from native_x11 import wait_for +from native_test_session import run_private_session SUBJECTS = ["First test message", "Second test message"] @@ -372,7 +373,7 @@ def main(): for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): environment.pop(name, None) with (directory / "session.log").open("w") as log: - subprocess.run(["xvfb-run", "-a", "-s", "-screen 0 1280x900x24", "dbus-run-session", "--", + run_private_session(["xvfb-run", "-a", "-s", "-screen 0 1280x900x24", "dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--format", composeFormat, "--inside"], env=environment, stderr=log, check=True, timeout=120) diff --git a/tests/native_webscripts.py b/tests/native_webscripts.py index 811bb89..75ff024 100644 --- a/tests/native_webscripts.py +++ b/tests/native_webscripts.py @@ -5,6 +5,8 @@ from pathlib import Path import subprocess import sys +from native_test_session import run_private_session + def main() -> None: if sys.argv[1:] == ["--inside"]: @@ -30,7 +32,7 @@ def main() -> None: environment = dict(os.environ, DISPLAY=":" + display, XDG_SESSION_TYPE="x11") for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): environment.pop(key, None) - subprocess.run( + run_private_session( ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, timeout=45, ) diff --git a/tests/native_x11.py b/tests/native_x11.py index 07fef38..fcdb4ad 100644 --- a/tests/native_x11.py +++ b/tests/native_x11.py @@ -9,6 +9,8 @@ import sys import tempfile import time +from native_test_session import run_private_session + def wait_for(check, message, timeout=15): deadline = time.monotonic() + timeout @@ -717,7 +719,7 @@ def main(): environment.pop('AT_SPI_BUS_ADDRESS', None) environment.pop('WAYLAND_DISPLAY', None) environment['XDG_SESSION_TYPE'] = 'x11' - subprocess.run(['dbus-run-session', '--', sys.executable, str(Path(__file__).resolve()), '--inside', directory], env=environment, check=True, timeout=60) + run_private_session(['dbus-run-session', '--', sys.executable, str(Path(__file__).resolve()), '--inside', directory], env=environment, check=True, timeout=60) finally: server.terminate() server.wait(timeout=3) diff --git a/tests/test_native_test_session.py b/tests/test_native_test_session.py new file mode 100644 index 0000000..931a786 --- /dev/null +++ b/tests/test_native_test_session.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Runtime isolation and real AT-SPI socket collision regressions.""" +import ast +import json +import os +from pathlib import Path +import socket +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from native_test_session import run_private_session + + +class PrivateSessionTests(unittest.TestCase): + def test_child_runtime_and_bus_are_private(self): + environment = dict(os.environ, XDG_RUNTIME_DIR="/invalid/desktop-runtime", + DBUS_SESSION_BUS_ADDRESS="unix:path=/invalid/desktop-bus", + AT_SPI_BUS_ADDRESS="unix:path=/invalid/accessibility-bus", + DBUS_STARTER_ADDRESS="unix:path=/invalid/starter-bus", + DBUS_STARTER_BUS_TYPE="session", WAYLAND_DISPLAY="desktop-wayland") + originalEnv = environment.copy() + script = """import json, os, socket, stat +from pathlib import Path +runtime = Path(os.environ['XDG_RUNTIME_DIR']) +listener = socket.socket(socket.AF_UNIX) +listener.bind(str(runtime / 'probe.sock')) +print(json.dumps(dict(runtime=str(runtime), mode=stat.S_IMODE(runtime.stat().st_mode), + bus=os.environ['DBUS_SESSION_BUS_ADDRESS'], + inherited=[name for name in ('AT_SPI_BUS_ADDRESS', 'DBUS_STARTER_ADDRESS', + 'DBUS_STARTER_BUS_TYPE', 'WAYLAND_DISPLAY') if name in os.environ]))) +""" + result = run_private_session( + ["dbus-run-session", "--", sys.executable, "-c", script], env=environment, + capture_output=True, text=True, check=True, timeout=15) + state = json.loads(result.stdout) + self.assertEqual(state["mode"], 0o700) + self.assertEqual(state["inherited"], []) + self.assertNotEqual(state["bus"], originalEnv["DBUS_SESSION_BUS_ADDRESS"]) + self.assertFalse(Path(state["runtime"]).exists(), "child runtime was not cleaned up") + self.assertEqual(environment, originalEnv) + + def test_cleanup_after_child_failure(self): + script = "import os, sys; print(os.environ['XDG_RUNTIME_DIR'], flush=True); sys.exit(7)" + with self.assertRaises(subprocess.CalledProcessError) as caught: + run_private_session( + ["dbus-run-session", "--", sys.executable, "-c", script], env=os.environ, + capture_output=True, text=True, check=True, timeout=15) + self.assertEqual(caught.exception.returncode, 7) + self.assertFalse(Path(caught.exception.stdout.strip()).exists()) + + def test_rejects_dbus_before_xvfb_without_launching(self): + with patch("native_test_session.subprocess.run") as launch: + with self.assertRaisesRegex(ValueError, "Start Xvfb before D-Bus"): + run_private_session( + ["dbus-run-session", "--", "xvfb-run", "-a", "true"], env=os.environ) + launch.assert_not_called() + + def test_cli_activates_atspi_on_virtual_display(self): + script = """import ast, json, os, subprocess +result = subprocess.run(['gdbus', 'call', '--session', '--dest', 'org.a11y.Bus', + '--object-path', '/org/a11y/bus', '--method', 'org.a11y.Bus.GetAddress'], + capture_output=True, text=True, check=True, timeout=10) +print(json.dumps(dict(display=os.environ['DISPLAY'], runtime=os.environ['XDG_RUNTIME_DIR'], + address=ast.literal_eval(result.stdout)[0]))) +""" + result = subprocess.run( + [sys.executable, "-B", str(Path(__file__).with_name("native_test_session.py")), + sys.executable, "-c", script], + env=dict(os.environ, DISPLAY=":7777"), capture_output=True, text=True, + check=True, timeout=20) + state = json.loads(result.stdout) + self.assertNotEqual(state["display"], ":7777") + expectedPath = Path(state["runtime"]) / "at-spi" / ("bus" + state["display"].replace(":", "_")) + self.assertEqual(state["address"].split(",", 1)[0], "unix:path=" + str(expectedPath)) + self.assertFalse(Path(state["runtime"]).exists()) + + def test_atspi_activation_preserves_other_session_socket(self): + # No real display is contacted. Reproduce D-Bus inheriting one display + # while its client uses another, with a live canary in the parent runtime. + with tempfile.TemporaryDirectory(prefix="cth-parent-", dir="/tmp") as parentRuntime: + socketDir = Path(parentRuntime) / "at-spi" + socketDir.mkdir(mode=0o700) + socketPath = socketDir / "bus_7777" + with socket.socket(socket.AF_UNIX) as listener: + listener.bind(str(socketPath)) + listener.listen(1) + originalInode = socketPath.stat().st_ino + environment = dict(os.environ, XDG_RUNTIME_DIR=parentRuntime, DISPLAY=":7777", + AT_SPI_BUS_ADDRESS="unix:path=/invalid/desktop-accessibility") + result = run_private_session( + ["dbus-run-session", "--", "env", "DISPLAY=:7778", "gdbus", "call", + "--session", "--dest", "org.a11y.Bus", "--object-path", "/org/a11y/bus", + "--method", "org.a11y.Bus.GetAddress"], env=environment, + capture_output=True, text=True, check=True, timeout=15) + address = ast.literal_eval(result.stdout)[0] + busPath = Path(address.split(",", 1)[0].removeprefix("unix:path=")) + self.assertEqual(busPath.name, "bus_7777", address) + self.assertNotEqual(busPath.parent.parent, Path(parentRuntime)) + self.assertFalse(busPath.parent.parent.exists(), "private runtime survived cleanup") + self.assertEqual(socketPath.stat().st_ino, originalInode) + self.assertTrue(stat.S_ISSOCK(socketPath.stat().st_mode)) + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(2) + client.connect(str(socketPath)) + connection, _ = listener.accept() + connection.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/window_titles.md b/tests/window_titles.md index 426d1d1..5fa9240 100644 --- a/tests/window_titles.md +++ b/tests/window_titles.md @@ -17,8 +17,8 @@ Run the focused checks from the repository root: ```sh go test -race ./internal/app -run 'Title|WineReview|XTerm|Foreground' -count=1 CTHULHU_NATIVE_BROWSER_TESTS=editing GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off python3 tests/native_browser.py -xvfb-run -a dbus-run-session -- env CTHULHU_NATIVE_WINDOW_TITLES=1 GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test ./internal/app -run '^TestNativeAutomaticTitleDispatchLatency$' -count=1 -v -dbus-run-session -- env CTHULHU_WINE_TITLE_DBUS=1 go test -race ./internal/app -run '^TestPrivateWineTitleSurvivesReviewFocusBurst$' -count=1 -v +python3 tests/native_test_session.py env CTHULHU_NATIVE_WINDOW_TITLES=1 GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test ./internal/app -run '^TestNativeAutomaticTitleDispatchLatency$' -count=1 -v +python3 tests/native_test_session.py env CTHULHU_WINE_TITLE_DBUS=1 go test -race ./internal/app -run '^TestPrivateWineTitleSurvivesReviewFocusBurst$' -count=1 -v ``` The private D-Bus test runs the event loop against copied foreground metadata @@ -40,7 +40,7 @@ is not implemented. ```sh go test ./internal/input ./internal/app -run 'WindowTitle|WhereAmI|CommandRuntimeRoutesFocusPresentationRequests' -count=1 -xvfb-run -a env CTHULHU_NATIVE_X11=1 go test ./internal/platform/xorg -run '^TestNativeDispatchBeforeRelease$' -count=1 -v +python3 tests/native_test_session.py env CTHULHU_NATIVE_X11=1 go test ./internal/platform/xorg -run '^TestNativeDispatchBeforeRelease$' -count=1 -v ``` The native automatic-title fixture above also checks the desktop title chord