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") } } }