370 lines
12 KiB
Go
370 lines
12 KiB
Go
// 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)
|
|
}
|