// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "encoding/json" "errors" "log/slog" "os" "reflect" "strings" "sync" "testing" "time" "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/tentacle" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type fakeVirtualBraille struct { mu sync.Mutex requests []braille.VirtualRequest responses chan braille.VirtualResponse closed chan struct{} once sync.Once } func (fake *fakeVirtualBraille) Send(request braille.VirtualRequest) error { fake.mu.Lock() defer fake.mu.Unlock() fake.requests = append(fake.requests, request) return nil } func (fake *fakeVirtualBraille) Responses() <-chan braille.VirtualResponse { return fake.responses } func (fake *fakeVirtualBraille) Close() { fake.once.Do(func() { close(fake.closed) }) } func (fake *fakeVirtualBraille) seen() []braille.VirtualRequest { fake.mu.Lock() defer fake.mu.Unlock() return append([]braille.VirtualRequest(nil), fake.requests...) } type peerRouteRecorder struct{ routes chan atspi.BrailleRoute } func (router peerRouteRecorder) RouteBraille(_ context.Context, route atspi.BrailleRoute) error { router.routes <- route return nil } func braille_fixture(t *testing.T, grants []string) (*tentacle.Session, *tentacle.RemotePolicy, *brailleWorker, *fakeVirtualBraille, peerRouteRecorder) { t.Helper() policy := tentacle.NewRemotePolicy() session := keyboard_owner_session(t, policy, grants) router := peerRouteRecorder{routes: make(chan atspi.BrailleRoute, 4)} worker := &brailleWorker{logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {}, connected: true, cells: 20, router: router, frame: braille.Frame{Cursor: -1}, baseFrame: braille.Frame{Cursor: -1}} fake := &fakeVirtualBraille{responses: make(chan braille.VirtualResponse, 4), closed: make(chan struct{})} runtime := &tentacleRuntime{logger: slog.New(slog.DiscardHandler), remotePolicy: policy, announcements: make(chan string, 4), output: &tentacleOutput{braille: worker, gate: &focusGate{}}, virtualBraille: func() (virtualBraille, error) { return fake, nil }} ctx, cancel := context.WithCancel(t.Context()) done := runtime.start_remote_braille(ctx, session) t.Cleanup(func() { cancel() select { case <-done: case <-time.After(5 * time.Second): t.Error("Braille owner did not join") } }) return session, policy, worker, fake, router } func braille_open(t *testing.T, session *tentacle.Session, role string, grants []string) tentaclewire.RemoteLease { t.Helper() approval, err := session.BeginRemoteApproval(role, grants) if err != nil { t.Fatal(err) } if _, err := session.AuthorizeRemoteWithApproval(approval, role, grants); err != nil { t.Fatal(err) } var lease tentaclewire.RemoteLease if json.Unmarshal(keyboard_owner_invoke(t, session, "open", nil), &lease) != nil || lease.Epoch == 0 { t.Fatal("lease did not open") } return lease } func braille_call(t *testing.T, session *tentacle.Session, lease tentaclewire.RemoteLease, sequence uint64, value tentaclewire.RemoteBraille) { t.Helper() request := tentaclewire.RemoteBrailleRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Braille: value} keyboard_owner_invoke(t, session, "rpc", map[string]any{"Method": "remote.braille", "Params": request}) } func wait_until(t *testing.T, what string, predicate func() bool) { t.Helper() deadline := time.Now().Add(5 * time.Second) for !predicate() { if time.Now().After(deadline) { t.Fatal("timed out waiting for", what) } time.Sleep(2 * time.Millisecond) } } func TestRemoteBrailleExportsApplicationFramesOnly(t *testing.T) { session, policy, worker, fake, router := braille_fixture(t, []string{"braille-stream"}) route := braille.TextRoute{Start: 0, End: 5, SourceStart: 0, TextLength: 5, BusName: ":1.2", Path: "/text", SourceText: "hello"} worker.EnqueueFrame(braille.Frame{Text: "hello", Cursor: -1, Routes: []braille.TextRoute{route}}) lease := braille_open(t, session, "controlled", []string{"braille-stream"}) braille_call(t, session, lease, 1, tentaclewire.RemoteBraille{Op: "size", Width: 20}) wait_until(t, "width and initial frame", func() bool { return len(fake.seen()) >= 2 }) seen := fake.seen() if seen[0].Width == nil || *seen[0].Width != 20 || seen[1].Frame == nil || seen[1].Frame.Text != "hello" || seen[1].Frame.ID == 0 || seen[1].Frame.Routes != nil { t.Fatalf("initial export = %+v", seen) } exportedID := seen[1].Frame.ID fake.responses <- braille.VirtualResponse{Cells: []int{1, 3, 9}} var event tentaclewire.SessionEvent if json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event) != nil || event.Kind != "remote.braille" || !reflect.DeepEqual(event.Braille.Cells, []int{1, 3, 9}) { t.Fatal("cells not published", event) } braille_call(t, session, lease, 2, tentaclewire.RemoteBraille{Op: "gesture", Gesture: "route", Index: 3}) braille_call(t, session, lease, 3, tentaclewire.RemoteBraille{Op: "gesture", Gesture: "pan-right"}) wait_until(t, "gestures", func() bool { return len(fake.seen()) >= 4 }) if seen = fake.seen(); seen[2].Route == nil || *seen[2].Route != 3 || seen[3].Pan != "right" { t.Fatalf("gestures = %+v", seen[2:]) } fake.responses <- braille.VirtualResponse{Route: &braille.RoutingEvent{ID: exportedID, Offset: 3}} select { case routed := <-router.routes: if routed.Offset != 3 || routed.Line != "hello" { t.Fatal("route changed", routed) } case <-time.After(5 * time.Second): t.Fatal("peer route did not reach the application") } // Reader UI is never exported; application frames under it are, unroutable. worker.SetInternalUIFrame(&braille.Frame{Text: "private form", Cursor: -1}) worker.EnqueueFrame(braille.Frame{Text: "world", Cursor: -1}) wait_until(t, "frame under reader UI", func() bool { return len(fake.seen()) >= 5 }) for _, request := range fake.seen() { if request.Frame != nil && request.Frame.Text == "private form" { t.Fatal("reader UI frame exported") } } if last := fake.seen()[4]; last.Frame == nil || last.Frame.Text != "world" || last.Frame.ID != 0 { t.Fatalf("frame under UI = %+v", last) } policy.SetAvailable(false) select { case <-fake.closed: case <-time.After(5 * time.Second): t.Fatal("revocation did not stop export") } policy.SetAvailable(true) worker.mu.Lock() exporter := worker.remoteExport worker.mu.Unlock() if exporter != nil { t.Fatal("exporter left installed") } } func TestRemoteBrailleControllerShowsPeerCellsAndReportsKeys(t *testing.T) { session, policy, worker, _, _ := braille_fixture(t, []string{"braille-stream", "remote-present"}) worker.EnqueueFrame(braille.Frame{Text: "local", Cursor: -1}) worker.SetContraction(true, "en-ueb-g2.ctb") lease := braille_open(t, session, "controller", []string{"braille-stream"}) braille_call(t, session, lease, 1, tentaclewire.RemoteBraille{Op: "geometry"}) var event tentaclewire.SessionEvent if json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event) != nil || event.Braille == nil || event.Braille.Op != "geometry" || event.Braille.Width != 20 { t.Fatal("geometry not reported", event) } braille_call(t, session, lease, 2, tentaclewire.RemoteBraille{Op: "cells", Cells: []int{1, 2, 255}}) wait_until(t, "peer cells", func() bool { return worker.current().Raw }) frame := worker.current() if frame.Text != "⠁⠂⣿" || frame.Contract || frame.Table != "" || frame.WordWrap { t.Fatalf("peer frame = %+v", frame) } // Local application frames continue updating underneath. worker.EnqueueFrame(braille.Frame{Text: "newer", Cursor: -1}) if !worker.current().Raw { t.Fatal("application frame replaced peer cells") } frame = worker.current() worker.route(t.Context(), braille.RoutingEvent{ID: frame.ID, Offset: 1}) worker.route(t.Context(), braille.RoutingEvent{ID: frame.ID, Offset: -1, Pan: "right"}) worker.route(t.Context(), braille.RoutingEvent{ID: frame.ID + 99, Offset: 0}) // Stale. for _, want := range []tentaclewire.RemoteBraille{{Op: "gesture", Gesture: "route", Index: 1}, {Op: "gesture", Gesture: "pan-right"}} { if json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event) != nil || event.Braille == nil || !reflect.DeepEqual(*event.Braille, want) { t.Fatal("gesture changed", event) } } policy.SetAvailable(false) wait_until(t, "local frame restored", func() bool { return !worker.current().Raw }) if text := worker.current().Text; text != "newer" { t.Fatal("restored frame", text) } policy.SetAvailable(true) } func TestRemoteBrailleInitialExportDuringFlashIsUnroutable(t *testing.T) { worker := &brailleWorker{cancel: func() {}, connected: true, wake: make(chan struct{}, 1)} worker.EnqueueFrame(braille.Frame{Text: "application", Cursor: -1}) baseID := worker.current().ID var exported braille.Frame worker.SetRemoteExport(func(frame braille.Frame) { exported = frame }) if exported.ID != baseID { t.Fatal("initial application lost its routing identity") } worker.FlashConfirmation("message", config.BrailleOptions{FlashMessages: true, FlashPersistent: true}) worker.SetRemoteExport(func(frame braille.Frame) { exported = frame }) if exported.Text != "application" || exported.ID != 0 { t.Fatalf("flash export = %+v", exported) } worker.DismissConfirmation() worker.SetRemoteExport(func(frame braille.Frame) { exported = frame }) if exported.ID == 0 || exported.ID != worker.current().ID { t.Fatal("restored application lost its routing identity") } } func TestRemoteBrailleInitialExportSerializedWithUpdates(t *testing.T) { worker := &brailleWorker{cancel: func() {}, wake: make(chan struct{}, 1)} worker.EnqueueFrame(braille.Frame{Text: "initial", Cursor: -1}) entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) var frames []string go func() { worker.SetRemoteExport(func(frame braille.Frame) { if frame.Text == "initial" { close(entered) <-release } frames = append(frames, frame.Text) }) close(finished) }() select { case <-entered: case <-time.After(5 * time.Second): close(release) t.Fatal("initial export was not offered") } if worker.mu.TryLock() { worker.mu.Unlock() close(release) <-finished t.Fatal("initial export was offered outside the frame lock") } updated := make(chan struct{}) go func() { worker.EnqueueFrame(braille.Frame{Text: "newer", Cursor: -1}); close(updated) }() close(release) <-finished <-updated if !reflect.DeepEqual(frames, []string{"initial", "newer"}) { t.Fatal(frames) } } func TestRemoteBrailleCancelledRouteHasNoEffect(t *testing.T) { router := peerRouteRecorder{routes: make(chan atspi.BrailleRoute, 1)} worker := &brailleWorker{cancel: func() {}, wake: make(chan struct{}, 1), router: router} worker.EnqueueFrame(braille.Frame{Text: "test", Cursor: -1, Routes: []braille.TextRoute{{Start: 0, End: 4, TextLength: 4}}}) ctx, cancel := context.WithCancel(t.Context()) cancel() worker.RouteExported(ctx, braille.RoutingEvent{ID: worker.current().ID, Offset: 1}) select { case <-router.routes: t.Fatal("cancelled route reached application") default: } } func TestRemoteBrailleWedgedHelperWriteIsBounded(t *testing.T) { reader, writer, err := os.Pipe() if err != nil { t.Fatal(err) } defer reader.Close() defer writer.Close() helper := &processVirtualBraille{stdin: writer, encoder: json.NewEncoder(writer)} done := make(chan error, 1) go func() { done <- helper.Send(braille.VirtualRequest{Frame: &braille.Frame{Text: strings.Repeat("x", 1024*1024)}}) }() select { case err := <-done: if !errors.Is(err, os.ErrDeadlineExceeded) { t.Fatalf("blocked write = %v", err) } case <-time.After(5 * time.Second): t.Fatal("wedged helper blocked its owner") } }