Files
cthulhu/internal/atspi/preferences_native_test.go

193 lines
5.9 KiB
Go

// 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")
}
}