// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/speech" ) func TestNativePreferencesDropdownEscape(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_PREFERENCES") != "1" { t.Skip("requires private Xvfb/AT-SPI session and preferences binary") } largeCatalog := os.Getenv("CTHULHU_PREFS_LARGE_CATALOG") == "1" if largeCatalog { t.Setenv("LC_ALL", "en_US.UTF-8") } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() wmConfig := filepath.Join(t.TempDir(), "i3.config") if err := os.WriteFile(wmConfig, []byte("font pango:monospace 10\nipc-socket "+filepath.Join(filepath.Dir(wmConfig), "i3.sock")+"\n"), 0600); err != nil { t.Fatal(err) } wm := exec.CommandContext(ctx, "i3", "-c", wmConfig) wm.Env = append(os.Environ(), "I3SOCK="+filepath.Join(filepath.Dir(wmConfig), "i3.sock")) wm.Stderr = os.Stderr wm.Stdout = os.Stderr if err := wm.Start(); err != nil { t.Fatal(err) } defer func() { _ = wm.Process.Kill() _ = wm.Wait() }() for { output, _ := exec.CommandContext(ctx, "xprop", "-root", "_NET_SUPPORTING_WM_CHECK").Output() if strings.Contains(string(output), "0x") { break } select { case <-ctx.Done(): t.Fatal("private window manager did not start") case <-time.After(20 * time.Millisecond): } } adapter := New() adapter.SetCaptureSoundGeometry(false) if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) defer stop() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() command := exec.CommandContext(ctx, os.Getenv("CTHULHU_BINARY"), "--preferences-ui") in, err := command.StdinPipe() if err != nil { t.Fatal(err) } command.Stderr = os.Stderr if err := command.Start(); err != nil { t.Fatal(err) } helperDone := make(chan error, 1) go func() { helperDone <- command.Wait() }() helperJoined := false defer func() { in.Close() _ = command.Process.Kill() if !helperJoined { <-helperDone } }() settings := config.DefaultSettings() settings.DefaultVoice.Family = &config.VoiceFamily{Name: "Test voice", Lang: "en"} if largeCatalog { settings.DefaultVoice.Family = nil } initial := preferences.Response{Values: preferences.FromSettings(settings), CanSave: true, Accepted: true, Message: "Preferences test ready.", Catalog: speech.Catalog{"": {{Name: "Test voice", Language: "en"}}, "test-module": nil}} if largeCatalog { // The helper inherits the test's locale; only 100 of the 6,000 // eSpeak voices should be built into its Person menu. for i := 0; i < 6000; i++ { language := "de" if i < 100 { language = "en" } initial.Catalog["espeak-ng"] = append(initial.Catalog["espeak-ng"], speech.Voice{Name: fmt.Sprintf("Test voice %d", i), Language: language}) } } if err := json.NewEncoder(in).Encode(initial); err != nil { t.Fatal(err) } wait_event := func(timeout time.Duration, match func(Event) bool) Event { t.Helper() timer := time.NewTimer(timeout) defer timer.Stop() for { select { case event := <-adapter.Events(): t.Logf("%s %s %q capture=%s", event.Type, event.Source.Role, event.Source.Name, event.SnapshotDuration) if event.Type == FocusedStateEvent && event.Detail1 == 1 { // Match the running reader's browser-mode classification. // Without these fresh ancestry queries, stale cached focus // hides the no-focus interval when the popup closes. _, _ = adapter.CaptureDocumentContext(ctx, event.Source.ID()) } if match(event) { return event } case <-timer.C: t.Fatal("expected preferences event missing") case <-ctx.Done(): t.Fatal(ctx.Err()) } } } focused := func(name string) func(Event) bool { return func(event Event) bool { return event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.Name == name } } wait_event(5*time.Second, func(event Event) bool { return event.Type == WindowActivatedEvent }) xdotool := func(args ...string) string { t.Helper() output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput() if err != nil { t.Fatalf("xdotool %v: %v: %s", args, err, output) } return strings.TrimSpace(string(output)) } window := xdotool("search", "--onlyvisible", "--name", "^Cthulhu Preferences$") xdotool("windowfocus", window) xdotool("key", "--clearmodifiers", "ctrl+Page_Down", "ctrl+Page_Down") wait_event(5*time.Second, focused("Speech")) xdotool("key", "Tab", "Tab") wait_event(5*time.Second, focused("Speech system")) xdotool("key", "alt+Down") wait_event(5*time.Second, focused("Speech Dispatcher")) started := time.Now() xdotool("key", "Escape") wait_event(1500*time.Millisecond, focused("Speech system")) t.Logf("dropdown Escape restored focus in %s", time.Since(started)) xdotool("key", "Tab") wait_event(1500*time.Millisecond, focused("Speech synthesizer")) xdotool("key", "alt+Down") wait_event(5*time.Second, focused("Default")) xdotool("key", "Escape") wait_event(1500*time.Millisecond, focused("Speech synthesizer")) if largeCatalog { xdotool("key", "Down") } xdotool("key", "Tab") wait_event(1500*time.Millisecond, focused("Language")) if largeCatalog { xdotool("key", "Tab") wait_event(1500*time.Millisecond, focused("Person")) xdotool("key", "alt+Down") wait_event(1500*time.Millisecond, focused("Default")) xdotool("key", "Escape") wait_event(1500*time.Millisecond, focused("Person")) } xdotool("key", "Escape") select { case err := <-helperDone: helperJoined = true if err != nil { t.Fatal(err) } case <-time.After(1500 * time.Millisecond): t.Fatal("Escape did not close preferences promptly") } }