Keep failed cursor trials and native recapture anchored to the original document, and restore the requested cached root before LRU eviction. Base braille retry resets on established connection time rather than stalled startup. Poll private AT-SPI daemon ownership without triggering service activation and clean up partial startup failures. Add focused regressions and update browser testing notes.
202 lines
7.0 KiB
Go
202 lines
7.0 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
"git.stormux.org/storm/cthulhu/internal/braille"
|
|
"git.stormux.org/storm/cthulhu/internal/logging"
|
|
)
|
|
|
|
// The test executable doubles as a real pipe-protocol peer. Only the explicit
|
|
// child invocation takes this path; the parent runs the ordinary test suite.
|
|
func TestMain(m *testing.M) {
|
|
if os.Getenv("CTHULHU_BRAILLE_TEST_FAIL") == "1" && len(os.Args) == 2 && os.Args[1] == "--client" {
|
|
fmt.Fprintln(os.Stderr, "braille test startup failure")
|
|
os.Exit(1)
|
|
}
|
|
if os.Getenv("CTHULHU_BRAILLE_TEST_HANG") == "1" && len(os.Args) == 2 && os.Args[1] == "--client" {
|
|
// Simulate BRLTTY stalling the connection attempt (e.g. Bluetooth
|
|
// reconnect with the display switched off): never become ready.
|
|
select {}
|
|
}
|
|
if os.Getenv("CTHULHU_BRAILLE_TEST_CLIENT") == "1" && len(os.Args) == 2 && os.Args[1] == "--client" {
|
|
fmt.Println("ready")
|
|
decoder, encoder := json.NewDecoder(os.Stdin), json.NewEncoder(os.Stdout)
|
|
for {
|
|
var frame braille.Frame
|
|
if err := decoder.Decode(&frame); err != nil {
|
|
os.Exit(0)
|
|
}
|
|
if frame.Text != "" {
|
|
if err := encoder.Encode(braille.RoutingEvent{ID: frame.ID, Offset: 1}); err != nil {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func TestBrailleWorkerReportsHelperStartupFailure(t *testing.T) {
|
|
t.Setenv("CTHULHU_BRAILLE_TEST_FAIL", "1")
|
|
path, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker := &brailleWorker{path: path, logger: slog.New(slog.DiscardHandler)}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if connectedFor, err := worker.session(ctx); connectedFor != 0 || err == nil || !strings.Contains(err.Error(), "braille test startup failure") {
|
|
t.Fatalf("helper startup duration = %v, error = %v", connectedFor, err)
|
|
}
|
|
}
|
|
|
|
// A display that is switched off (or BRLTTY that never answers) makes the
|
|
// helper fail every connection attempt. Retries must back off and the
|
|
// failure is announced once, not re-warned on every cycle forever.
|
|
func TestBrailleWorkerAbsentDisplayBacksOffAndWarnsOnce(t *testing.T) {
|
|
if got := nextBrailleBackoff(0); got != time.Second {
|
|
t.Fatalf("initial backoff = %v, want 1s", got)
|
|
}
|
|
if got := nextBrailleBackoff(time.Second); got != 2*time.Second {
|
|
t.Fatalf("second backoff = %v, want 2s", got)
|
|
}
|
|
if got := nextBrailleBackoff(20 * time.Second); got != 30*time.Second {
|
|
t.Fatalf("capped backoff = %v, want 30s", got)
|
|
}
|
|
t.Setenv("CTHULHU_BRAILLE_TEST_FAIL", "1")
|
|
path, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var output bytes.Buffer
|
|
worker := &brailleWorker{path: path, logger: logging.New(&output, logging.Options{Level: slog.LevelDebug}), wake: make(chan struct{}, 1)}
|
|
if err := worker.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(3500 * time.Millisecond)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := worker.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if warnings := strings.Count(output.String(), `"message":"braille helper disconnected"`); warnings != 1 {
|
|
t.Fatalf("disconnect warnings = %d, want exactly 1\n%s", warnings, output.String())
|
|
}
|
|
if worker.Running() {
|
|
t.Fatal("worker reports running without a display")
|
|
}
|
|
}
|
|
|
|
// When BRLTTY stalls the connection attempt (display off, Bluetooth
|
|
// reconnect in progress), the helper blocks inside Open until BRLTTY
|
|
// answers. The session must wait with it instead of killing and respawning;
|
|
// cancellation still ends it promptly.
|
|
func TestBrailleWorkerSessionWaitsForLateDisplay(t *testing.T) {
|
|
t.Setenv("CTHULHU_BRAILLE_TEST_HANG", "1")
|
|
path, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker := &brailleWorker{path: path, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1)}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
connectedFor, err := worker.session(ctx)
|
|
if connectedFor != 0 {
|
|
err = fmt.Errorf("unready helper counted as connected for %v", connectedFor)
|
|
}
|
|
done <- err
|
|
}()
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("session gave up on a stalled connection attempt: %v", err)
|
|
case <-time.After(6 * time.Second):
|
|
}
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("cancelled session error = %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("cancelled session did not end")
|
|
}
|
|
if worker.Running() {
|
|
t.Fatal("worker reports running while waiting for a display")
|
|
}
|
|
}
|
|
|
|
// A permanently wedged helper (never ready, never exits) is recycled at
|
|
// the give-up bound so braille can recover if BRLTTY restarts.
|
|
func TestBrailleWorkerSessionRecyclesWedgedHelper(t *testing.T) {
|
|
t.Setenv("CTHULHU_BRAILLE_TEST_HANG", "1")
|
|
path, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker := &brailleWorker{path: path, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), readyGiveUp: 300 * time.Millisecond}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if connectedFor, err := worker.session(ctx); connectedFor != 0 || err == nil || !strings.Contains(err.Error(), "connection timed out") {
|
|
t.Fatalf("wedged helper duration = %v, error = %v", connectedFor, err)
|
|
}
|
|
}
|
|
|
|
func TestBrailleWorkerHelperProtocolAndRestart(t *testing.T) {
|
|
t.Setenv("CTHULHU_BRAILLE_TEST_CLIENT", "1")
|
|
path, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router := &recordingBrailleRouter{calls: make(chan atspi.BrailleRoute, 8)}
|
|
var output bytes.Buffer
|
|
worker := &brailleWorker{path: path, router: router, logger: logging.New(&output, logging.Options{Level: slog.LevelDebug}), wake: make(chan struct{}, 1)}
|
|
stop := func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := worker.Stop(ctx); err != nil {
|
|
t.Errorf("stop helper: %v", err)
|
|
}
|
|
}
|
|
defer stop()
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
if err := worker.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worker.EnqueueFrame(braille.Frame{Text: "é猫", Cursor: 0, Routes: []braille.TextRoute{{Start: 0, End: 2, TextLength: 2, SourceStart: 10, BusName: ":1.42", Path: "/entry", SourceText: "é猫"}}})
|
|
select {
|
|
case route := <-router.calls:
|
|
if route.Offset != 11 || route.Focus.Path != "/entry" {
|
|
t.Fatalf("route %+v", route)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("helper route was not delivered")
|
|
}
|
|
stop()
|
|
// Stop joins the writer before inspecting its debug output.
|
|
for _, expected := range []string{`"message":"sent braille frame"`, `"braille_text":"é猫"`, `"cursor_offset":0`} {
|
|
if !strings.Contains(output.String(), expected) {
|
|
t.Fatalf("braille diagnostics missing %s: %s", expected, output.String())
|
|
}
|
|
}
|
|
// A duplicate send can be coalesced by either side; discard already handled
|
|
// keys before checking that the restarted child returns a new event.
|
|
for len(router.calls) > 0 {
|
|
<-router.calls
|
|
}
|
|
if worker.current().Text != "" {
|
|
t.Fatal("stopped worker retained old content")
|
|
}
|
|
}
|
|
}
|