// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bufio" "context" "encoding/json" "log/slog" "os" "os/exec" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) func TestNativeAutomaticTitleDispatchLatency(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_WINDOW_TITLES") != "1" { t.Skip("private Xvfb and D-Bus fixture only") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() fixture := exec.CommandContext(ctx, "python3", "-u", "../../tests/window_title_fixture.py") stdin, err := fixture.StdinPipe() if err != nil { t.Fatal(err) } stdout, err := fixture.StdoutPipe() if err != nil { t.Fatal(err) } fixture.Stderr = os.Stderr if err = fixture.Start(); err != nil { t.Fatal(err) } defer func() { stdin.Close(); fixture.Wait() }() scanner := bufio.NewScanner(stdout) if !scanner.Scan() { t.Fatal("fixture did not start") } adapter := atspi.New() if err = adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) listener := xorg.New() if err = listener.Start(ctx); err != nil { t.Fatal(err) } defer listener.Stop(context.Background()) queue := &fakeSpeechQueue{spoken: make(chan string, 8)} inputDone := make(chan struct{}) go func() { defer close(inputDone) processInputEvents(ctx, slog.New(slog.DiscardHandler), queue, listener, input.EchoSettings{}, nil, nil) }() defer func() { cancel(); <-inputDone }() completed := make(chan error, 1) go func() { completed <- processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, listener, nil, nil) }() defer func() { cancel() if err := <-completed; err != nil { t.Error(err) } }() wait := func(want string, deadline <-chan time.Time) { t.Helper() for { select { case got := <-queue.spoken: if got != want { t.Fatalf("title = %q, want %q", got, want) } return case <-deadline: t.Fatalf("title %q was not queued promptly", want) case <-ctx.Done(): t.Fatal(ctx.Err()) } } } wait("Example title", time.After(2*time.Second)) for _, title := range []string{"Status one", "Status two", "Status three"} { data, _ := json.Marshal(map[string]string{"action": "title", "window": "normal", "title": title}) began := time.Now() deadline := time.After(200 * time.Millisecond) if _, err = stdin.Write(append(data, '\n')); err != nil { t.Fatal(err) } if !scanner.Scan() || scanner.Text() != "ready" { t.Fatal("fixture command failed") } wait(title, deadline) t.Logf("X11 title update to speech queue: %s", time.Since(began)) } // The switch chord must not suppress the destination's introduction. The // fixture has no window manager, so activate each destination explicitly. for _, destination := range []struct{ window, title string }{ {"unrelated", "Unrelated title"}, {"normal", "Status three"}, } { if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "alt+Tab").CombinedOutput(); err != nil { t.Fatalf("switch keys: %v %s", err, out) } data, _ := json.Marshal(map[string]string{"action": "activate", "window": destination.window}) if _, err = stdin.Write(append(data, '\n')); err != nil { t.Fatal(err) } if !scanner.Scan() || scanner.Text() != "ready" { t.Fatal("fixture switch failed") } wait(destination.title, time.After(2*time.Second)) } select { case got := <-queue.spoken: t.Fatalf("duplicate window title: %q", got) case <-time.After(2 * windowTitleIntroductionDelay): } }