193 lines
5.9 KiB
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")
|
|
}
|
|
}
|