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