// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bufio" "context" "encoding/json" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/remote" "git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/wine" "log/slog" "os" "os/exec" "strings" "testing" "time" ) type wineNativeOutput struct { requests chan speechRequest frames chan braille.Frame } func (o *wineNativeOutput) Enqueue(request speechRequest) { o.requests <- request } func (o *wineNativeOutput) Interrupt() {} func (o *wineNativeOutput) EnqueueFrame(frame braille.Frame) { o.frames <- frame } func TestNativeWineReviewPhysicalKeys(t *testing.T) { if os.Getenv("CTHULHU_WINE_REVIEW_BUILD") == "" { t.Skip("requires isolated Wine build/display/session") } ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second) defer cancel() logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) registry := wine.NewReviewRegistry(logger) if err := registry.Start(ctx); err != nil { t.Fatal(err) } defer registry.Stop(context.Background()) adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout) if err := commands.Start(ctx); err != nil { t.Fatal(err) } defer commands.Stop(context.Background()) gate := newFocusGate(commands) worker, err := review.NewWorker(&reviewCaptureRouter{native: adapter, wine: registry}, time.Second) if err != nil { t.Fatal(err) } if err = worker.Start(ctx); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) live := &liveSettings{wineReview: registry} live.Store(config.DefaultSettings()) output := &wineNativeOutput{requests: make(chan speechRequest, 256), frames: make(chan braille.Frame, 256)} queue := outputQueue{speech: output, braille: output, live: live} helperEvents := newWineEventPresenter(func(text string) bool { output.Enqueue(speechRequest{Text: text, Cause: "wine-accessible-event"}) return true }) defer helperEvents.Close() service := remote.New(logger, remoteSpeech{speech: output, wine: helperEvents, wineReview: registry}) if err = service.Start(ctx); err != nil { t.Fatal(err) } defer service.Stop(context.Background()) eventDone, commandDone := make(chan error, 1), make(chan error, 1) go func() { eventDone <- processEventsWithAttributes(ctx, logger, queue, adapter, nil, nil, worker, 0, live, nil, gate, nil, nil) }() go func() { commandDone <- (commandRuntime{logger: logger, commands: commands, focusChanges: commands.FocusChanges(), focusGate: gate, speechOutput: queue, objectReview: worker, live: live, stopRun: cancel}).run(ctx) }() defer func() { cancel() if err := <-eventDone; err != nil { t.Error(err) } if err := <-commandDone; err != nil { t.Error(err) } }() driver := exec.CommandContext(ctx, "python3", "-u", "../../tests/wine_review_x11.py") stdin, err := driver.StdinPipe() if err != nil { t.Fatal(err) } stdout, err := driver.StdoutPipe() if err != nil { t.Fatal(err) } driver.Stderr = os.Stderr if err = driver.Start(); err != nil { t.Fatal(err) } defer func() { stdin.Close() if err := driver.Wait(); err != nil && ctx.Err() == nil { t.Error(err) } }() scanner := bufio.NewScanner(stdout) ready := func() { t.Helper() if !scanner.Scan() || scanner.Text() != "{\"ready\": true}" { t.Fatalf("physical fixture not ready: %s", scanner.Text()) } } ready() send := func(command map[string]any) { t.Helper() data, _ := json.Marshal(command) if _, err := stdin.Write(append(data, '\n')); err != nil { t.Fatal(err) } ready() } waitFocus := func() { t.Helper() deadline := time.Now().Add(6 * time.Second) for { matched := false probe := &wineReviewFocusController{} for _, notice := range registry.Focuses() { if notice.ID() == worker.Focus() && probe.eligible(notice, adapter.Foreground(), adapter.ForegroundTitle()) { matched = true } } if matched { return } if time.Now().After(deadline) { t.Fatalf("Wine focus unavailable; notices=%+v foreground=%+v title=%+v", registry.Focuses(), adapter.Foreground(), adapter.ForegroundTitle()) } time.Sleep(10 * time.Millisecond) } } waitFocus() waitCaptureReady := func() { t.Helper() deadline := time.Now().Add(5 * time.Second) for { id := worker.Focus() if _, err := registry.Capture(ctx, id); err == nil && registry.Current(id) && worker.Focus() == id { return } if time.Now().After(deadline) { t.Fatal("new native provider never reached a consistent capture") } time.Sleep(10 * time.Millisecond) } } check := func(symbols []uint32, want string, spelling bool) { t.Helper() // Normal Linux focus frames can remain queued after switching back to // Wine. Inspect only frames produced for this physical command. for len(output.frames) > 0 { <-output.frames } started := time.Now() send(map[string]any{"action": "keys", "symbols": symbols}) for { select { case request := <-output.requests: if request.Cause != "flat-review" { continue } if request.Text != want || (spelling && len(request.Spelling) == 0) { t.Fatalf("keys %x: speech=%+v want=%q", symbols, request, want) } if spelling && want == "b" && request.Spelling[0] != "bravo" { t.Fatalf("phonetic letter: %+v", request.Spelling) } t.Logf("keys %x: %q queued in %s", symbols, want, time.Since(started)) if strings.Contains(request.Text, "private-review-secret") || strings.Contains(request.Text, "hidden-review-secret") { t.Fatal("excluded text reached speech") } goto frame case <-time.After(2 * time.Second): tree, captureErr := registry.Capture(ctx, worker.Focus()) t.Logf("capture=%+v error=%v focus=%+v notices=%+v foreground=%+v title=%+v", tree, captureErr, worker.Focus(), registry.Focuses(), adapter.Foreground(), adapter.ForegroundTitle()) t.Fatalf("physical keys %x did not produce %q", symbols, want) } } frame: if !wine.IsReviewID(worker.Focus()) { return } for { select { case f := <-output.frames: if f.Text == "" { continue } if len(f.Routes) != 0 || f.Document != nil || strings.Contains(f.Text, "private-review-secret") { t.Fatalf("unsafe native routing: %+v", f) } return case <-time.After(time.Second): t.Fatal("review braille output missing") } } } check([]uint32{0xff97}, "alpha beta", false) check([]uint32{0xff9a}, "second line", false) check([]uint32{0xff95}, "alpha beta", false) check([]uint32{0xff9d}, "alpha ", false) check([]uint32{0xff9d}, "alpha ", true) check([]uint32{0xff9d}, "alpha ", true) check([]uint32{0xff98}, "beta", false) check([]uint32{0xff99}, "b", false) check([]uint32{0xff99}, "b", true) check([]uint32{0xff9b}, "e", false) check([]uint32{0xff9c}, "b", false) check([]uint32{0xff63, 0xff9c}, "a", false) check([]uint32{0xff63, 0xff98}, "line", false) check([]uint32{0xff63, 0xff96}, "beta", false) check([]uint32{0xff63, 0xff95}, "Command:", false) check([]uint32{0xff63, 0xff9a}, "Open & close", false) check([]uint32{0xff63, 0xff95}, "Command:", false) check([]uint32{0xff9a}, "alpha beta", false) check([]uint32{0xff98}, "beta", false) send(map[string]any{"action": "check"}) if os.Getenv("CTHULHU_WINE_REVIEW_WM") == "i3" { send(map[string]any{"action": "keys", "symbols": []uint32{0xff53}}) send(map[string]any{"action": "check", "check": "--check-right"}) return } send(map[string]any{"action": "configure", "mode": 6}) time.Sleep(200 * time.Millisecond) check([]uint32{0xff9d}, "zeta", false) // Seek the first text line from the window's caption, then verify an update // within the same control without a native caret change. check([]uint32{0xff63, 0xff95}, "Command:", false) check([]uint32{0xff9a}, "omega zeta native & item", false) send(map[string]any{"action": "check"}) send(map[string]any{"action": "keys", "symbols": []uint32{0xff53}}) send(map[string]any{"action": "check", "check": "--check-right"}) send(map[string]any{"action": "configure", "mode": 8}) positionDeadline := time.Now().Add(5 * time.Second) for { moved := false for _, notice := range registry.Focuses() { if notice.Bounds.X < 0 { moved = true } } if moved { break } if time.Now().After(positionDeadline) { t.Fatal("native window movement was not published") } time.Sleep(10 * time.Millisecond) } waitFocus() check([]uint32{0xff97}, "alpha beta native & item", false) oldFocus := worker.Focus() send(map[string]any{"action": "restart-helper"}) deadline := time.Now().Add(5 * time.Second) for worker.Focus() == oldFocus || !wine.IsReviewID(worker.Focus()) { if time.Now().After(deadline) { t.Fatal("helper restart kept old review lifetime") } time.Sleep(10 * time.Millisecond) } waitFocus() check([]uint32{0xff97}, "alpha beta native & item", false) // Restart the actual service owner and registry connections. The helper must // register again and authorize the newly owned connection for capture. if err := service.Stop(context.Background()); err != nil { t.Fatal(err) } if err := registry.Stop(context.Background()); err != nil { t.Fatal(err) } worker.ClearFocus() if err := registry.Start(ctx); err != nil { t.Fatal(err) } if err := service.Start(ctx); err != nil { t.Fatal(err) } waitFocus() check([]uint32{0xff97}, "alpha beta native & item", false) // A native Linux foreground change retires a queued Wine capture, while its // blocked MSAA worker is still waiting in the application's window procedure. send(map[string]any{"action": "configure", "mode": 10}) waitFocus() send(map[string]any{"action": "keys", "symbols": []uint32{0xff97}}) send(map[string]any{"action": "linux"}) deadline = time.Now().Add(5 * time.Second) for !worker.Focus().Valid() || wine.IsReviewID(worker.Focus()) { if time.Now().After(deadline) { t.Fatalf("Linux focus not restored: %+v", worker.Focus()) } time.Sleep(10 * time.Millisecond) } check([]uint32{0xff97}, "Linux review ready", false) lateDeadline := time.After(2 * time.Second) late: for { select { case request := <-output.requests: if request.Cause == "flat-review" { t.Fatalf("late Wine review result: %+v", request) } case <-lateDeadline: break late } } send(map[string]any{"action": "wine"}) waitFocus() firstPrefix := worker.Focus() send(map[string]any{"action": "second-prefix"}) deadline = time.Now().Add(5 * time.Second) for worker.Focus().BusName == firstPrefix.BusName || !wine.IsReviewID(worker.Focus()) { if time.Now().After(deadline) { t.Fatal("identical titles shared a provider across prefixes") } time.Sleep(10 * time.Millisecond) } waitFocus() if worker.Focus().BusName == firstPrefix.BusName { t.Fatal("identical titles shared a provider across prefixes") } // The 32-bit hook host can publish initial lifetime events after its X11 // window appears. Handshake on a consistent snapshot before this physical // key; every key still captures again. Do not warm the stalled-call phase. waitCaptureReady() check([]uint32{0xff97}, "alpha beta native & item", false) send(map[string]any{"action": "wine"}) deadline = time.Now().Add(5 * time.Second) for worker.Focus() != firstPrefix { if time.Now().After(deadline) { t.Fatal("return to first prefix lost its native lifetime") } time.Sleep(10 * time.Millisecond) } waitFocus() if worker.Focus() != firstPrefix { t.Fatal("return to first prefix lost its native lifetime") } check([]uint32{0xff97}, "alpha beta native & item", false) oldDesktopFocus := worker.Focus() send(map[string]any{"action": "virtual-desktop"}) deadline = time.Now().Add(6 * time.Second) for !wine.IsReviewID(worker.Focus()) || worker.Focus().BusName == oldDesktopFocus.BusName { if time.Now().After(deadline) { t.Fatal("virtual desktop restart kept the old helper lifetime") } time.Sleep(10 * time.Millisecond) } waitFocus() waitCaptureReady() check([]uint32{0xff97}, "alpha beta native & item", false) }