Files
cthulhu/internal/app/tentacles_test.go

468 lines
16 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/tentacle"
sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle"
)
// Subprocess fixture: no reader backend, network, or desktop access.
func TestTentacleReaderPeer(t *testing.T) {
if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "reader-peer" {
return
}
marker := os.Args[len(os.Args)-1]
presentation := strings.HasPrefix(filepath.Base(marker), "present")
if filepath.Base(marker) == "stall" {
time.Sleep(time.Minute)
os.Exit(0)
}
err := sdk.Serve(os.Stdin, os.Stdout, []sdk.Command{{ID: "probe", Title: "Probe command", Description: "Run the private reader probe.",
Presentation: presentation,
Bindings: []sdk.Binding{{Layout: "desktop", Scope: "global", Key: "F9"}},
Handle: func(ctx context.Context, _ json.RawMessage) (any, error) {
if filepath.Base(marker) == "crash" {
os.Exit(3)
}
group, _ := os.ReadFile("/proc/self/cgroup")
if err := os.WriteFile(marker, group, 0600); err != nil {
return nil, err
}
if filepath.Base(marker) == "present-delay" {
for {
if _, err := os.Stat(marker + ".release"); err == nil {
break
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Millisecond):
}
}
}
if filepath.Base(marker) == "present-invalid" {
return map[string]any{"speech": "Private text", "unknown": true}, nil
}
if presentation {
return sdk.Presentation{Speech: "Hello, é猫😀é", Braille: "Display é猫😀é"}, nil
}
return nil, nil
},
}})
if err != nil {
os.Exit(1)
}
os.Exit(0)
}
type tentacleBindingRecorder struct {
silentCommands
mutex sync.Mutex
bindings []input.BindingOverride
reject bool
removeError error
}
func (recorder *tentacleBindingRecorder) SetTentacleBindings(_ context.Context, bindings []input.BindingOverride) error {
recorder.mutex.Lock()
defer recorder.mutex.Unlock()
if len(bindings) == 0 && recorder.removeError != nil {
return recorder.removeError
}
if recorder.reject && len(bindings) > 0 {
return errors.New("fixture grab collision")
}
recorder.bindings = input.CloneBindingOverrides(bindings)
return nil
}
func (recorder *tentacleBindingRecorder) TentacleBindings() []input.BindingOverride {
recorder.mutex.Lock()
defer recorder.mutex.Unlock()
return input.CloneBindingOverrides(recorder.bindings)
}
func readerTentacleFixture(t *testing.T, mode string, contained bool) (*tentacleRuntime, *tentacleBindingRecorder, string) {
t.Helper()
directory := t.TempDir()
marker := filepath.Join(directory, mode)
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
specs := []tentacle.Spec{{ID: "probe", Presentation: strings.HasPrefix(mode, "present"), Program: []string{executable, "-test.run=^TestTentacleReaderPeer$", "--", "reader-peer", marker}}}
data, _ := json.Marshal(specs)
path := filepath.Join(directory, "launch.json")
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
bindings := &tentacleBindingRecorder{reject: mode == "reject"}
runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), bindings, path, "auto")
if !contained {
runtime.newManager = func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) }
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
defer cancel()
if err := runtime.Stop(ctx); err != nil && !errors.Is(err, bindings.removeError) {
t.Error(err)
}
})
return runtime, bindings, marker
}
func waitTentacle(t *testing.T, check func() bool) {
t.Helper()
deadline := time.Now().Add(6 * time.Second)
for !check() {
if time.Now().After(deadline) {
t.Fatal("tentacle condition timed out")
}
time.Sleep(time.Millisecond)
}
}
func TestReaderTentacleDispatchLearnDisable(t *testing.T) {
runtime, bindings, marker := readerTentacleFixture(t, "called", false)
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
f9, _ := input.ParseCommandChord("F9")
waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" })
settings := &liveSettings{}
settings.Store(config.DefaultSettings())
speech := &learnSpeechRecorder{}
braille := &learnBrailleRecorder{}
presenter := &learnModePresenter{tentacles: runtime, live: settings, speech: speech, braille: braille, active: true}
presenter.handle(input.CommandEvent{Command: input.LearnKey, Chord: f9, KeyName: "F9"})
if speech.texts[len(speech.texts)-1] != "Run the private reader probe." || braille.texts[len(braille.texts)-1] != "Run the private reader probe." {
t.Fatal("learn description missing")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatal("learn mode executed command")
}
rows := runtime.ShortcutRows(input.DesktopKeyboardLayout)
if len(rows) != 1 || rows[0].Keys != "F9" {
t.Fatalf("shortcut help: %+v", rows)
}
command := bindings.TentacleBindings()[0].Command
if !runtime.Invoke(context.Background(), command) {
t.Fatal("command not handled")
}
waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil })
runtime.Disable()
if runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" {
t.Fatal("disabled description remains")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := runtime.Stop(ctx); err != nil {
t.Fatal(err)
}
if len(bindings.TentacleBindings()) != 0 {
t.Fatal("disabled native bindings remain")
}
if len(runtime.resultSlots) != 0 {
t.Fatal("result workers remain")
}
if err := os.Remove(marker); err != nil {
t.Fatal(err)
}
runtime.Invoke(context.Background(), command)
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatal("stale command executed")
}
}
func TestReaderTentacleStartupDoesNotWaitForPeer(t *testing.T) {
runtime, bindings, _ := readerTentacleFixture(t, "stall", false)
started := make(chan error, 1)
go func() { started <- runtime.Start(context.Background()) }()
select {
case err := <-started:
if err != nil {
t.Fatal(err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("reader startup waited for handshake")
}
waitTentacle(t, func() bool { runtime.mutex.Lock(); defer runtime.mutex.Unlock(); return len(runtime.sessions) == 1 })
runtime.Disable()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := runtime.Stop(ctx); err != nil {
t.Fatal(err)
}
if len(bindings.TentacleBindings()) != 0 {
t.Fatal("stalled peer published grabs")
}
}
func TestReaderTentaclePresentation(t *testing.T) {
for _, mode := range []string{"present", "present-invalid", "present-delay"} {
t.Run(mode, func(t *testing.T) {
runtime, bindings, marker := readerTentacleFixture(t, mode, false)
owner := tentacleOutputFixture(t)
runtime.output = owner
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
f9, _ := input.ParseCommandChord("F9")
waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" })
command := bindings.TentacleBindings()[0].Command
runtime.Invoke(context.Background(), command)
waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil })
if mode == "present-delay" {
// Same focused control can change while the child computes. A
// consumed accessibility event retires the command's revision.
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- processEventsWithInput(ctx, slog.New(slog.DiscardHandler), discardSpeech{}, adapter, nil, nil, 0, owner.live, nil, owner.gate, nil)
}()
adapter.events <- atspi.NewEvent("object:text-changed:insert", atspi.Snapshot{})
waitTentacle(t, func() bool { owner.mutex.Lock(); defer owner.mutex.Unlock(); return owner.current == nil })
cancel()
if err := <-done; err != nil {
t.Fatal(err)
}
if err := os.WriteFile(marker+".release", nil, 0600); err != nil {
t.Fatal(err)
}
}
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
owner.speech.mutex.Lock()
pending := append([]speechRequest(nil), owner.speech.pending...)
owner.speech.mutex.Unlock()
if mode == "present" {
if len(pending) != 1 || pending[0].Text != "Hello, é猫😀é" || owner.braille.current().Text != "Display é猫😀é" {
t.Fatal("reader did not present the command result")
}
runtime.Disable()
if pending[0].context.Err() == nil || owner.braille.current().Text != "Core focus" {
t.Fatal("disable left accepted output live")
}
} else {
if len(pending) != 0 || owner.braille.current().Text != "Core focus" {
t.Fatal("invalid/stale reply reached output")
}
if mode == "present-invalid" {
waitTentacle(t, func() bool { return len(bindings.TentacleBindings()) == 0 })
select {
case <-runtime.announcements:
default:
t.Fatal("invalid presentation was not reported")
}
}
}
})
}
}
func TestReaderTentacleCommandLoopHandoffAndEmergency(t *testing.T) {
runtime, bindings, marker := readerTentacleFixture(t, "called", false)
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
f9, _ := input.ParseCommandChord("F9")
waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" })
command := bindings.TentacleBindings()[0].Command
commands := &fakeCommands{commands: make(chan input.CommandEvent, 4)}
gate := newFocusGate(commands)
gate.SetSuspended(true)
barrier := make(chan struct{}, 1)
speech := &learnSpeechRecorder{}
braille := &learnBrailleRecorder{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
worker := commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands, focusGate: gate,
tentacles: runtime, speechOutput: discardSpeech{}, stopRun: cancel,
learn: &learnModePresenter{speech: speech, braille: braille}, onPreferences: func() { barrier <- struct{}{} }}
done := make(chan error, 1)
go func() { done <- worker.run(ctx) }()
commands.commands <- input.CommandEvent{Command: command}
commands.commands <- input.CommandEvent{Command: input.Preferences}
select {
case <-barrier:
case <-time.After(time.Second):
t.Fatal("command loop stalled")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatal("handoff invoked tentacle")
}
gate.SetSuspended(false)
commands.commands <- input.CommandEvent{Command: command}
waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil })
commands.commands <- input.CommandEvent{Command: input.DisableTentacles}
commands.commands <- input.CommandEvent{Command: input.Quit}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("emergency disable blocked Quit")
}
if len(speech.texts) != 1 || speech.texts[0] != "Tentacles disabled." || len(braille.texts) != 1 {
t.Fatalf("disable feedback: %v %v", speech.texts, braille.texts)
}
if runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" {
t.Fatal("emergency disable retained command")
}
}
func TestReaderTentacleInvalidDelegationDoesNotLaunch(t *testing.T) {
runtime, bindings, marker := readerTentacleFixture(t, "called", true)
runtime.parent = t.TempDir()
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
select {
case <-runtime.done:
case <-time.After(time.Second):
t.Fatal("invalid delegation blocked startup")
}
if len(runtime.sessions) != 0 || len(bindings.TentacleBindings()) != 0 {
t.Fatal("invalid delegation admitted peer")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatal("uncontained fallback ran")
}
select {
case message := <-runtime.announcements:
if message != "Tentacles could not start." {
t.Fatal(message)
}
default:
t.Fatal("startup failure has no accessible status")
}
}
func TestReaderTentacleCleanupFailureReachesReader(t *testing.T) {
runtime, bindings, _ := readerTentacleFixture(t, "called", false)
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
f9, _ := input.ParseCommandChord("F9")
waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" })
want := errors.New("fixture native removal failure")
bindings.mutex.Lock()
bindings.removeError = want
bindings.mutex.Unlock()
runtime.Disable()
select {
case err := <-runtime.failures:
if !errors.Is(err, want) {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("cleanup failure did not reach command loop")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := runtime.Stop(ctx); !errors.Is(err, want) {
t.Fatalf("cleanup falsely succeeded: %v", err)
}
}
func TestReaderTentacleFailedGrabAndCrashRevoke(t *testing.T) {
for _, mode := range []string{"reject", "crash"} {
t.Run(mode, func(t *testing.T) {
runtime, bindings, _ := readerTentacleFixture(t, mode, false)
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
f9, _ := input.ParseCommandChord("F9")
if mode == "crash" {
waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" })
runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
}
waitTentacle(t, func() bool {
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
if len(runtime.sessions) != 1 {
return false
}
select {
case <-runtime.sessions[0].Revoked():
return true
default:
return false
}
})
waitTentacle(t, func() bool { return len(bindings.TentacleBindings()) == 0 })
if runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" {
t.Fatal("revoked command still described")
}
})
}
}
func TestNativeDelegatedReaderTentacle(t *testing.T) {
if os.Getenv("CTHULHU_TENTACLE_DELEGATED") != "1" {
t.Skip("requires the delegated reader launcher")
}
runtime, bindings, marker := readerTentacleFixture(t, "called", true)
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
f9, _ := input.ParseCommandChord("F9")
waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" })
runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil })
group, _ := os.ReadFile(marker)
parent, err := tentacle.DelegatedParent()
if err != nil {
t.Fatal(err)
}
expected := strings.TrimPrefix(parent, "/sys/fs/cgroup") + "/cthulhu-tentacles-"
if !strings.Contains(string(group), expected) || strings.Contains(string(group), "/reader/") {
t.Fatalf("child outside limited sibling subtree: %s", group)
}
t.Logf("contained command membership: %s", group)
}
func TestNativeDelegatedReaderTentaclePresentation(t *testing.T) {
if os.Getenv("CTHULHU_TENTACLE_DELEGATED") != "1" {
t.Skip("requires the delegated reader launcher")
}
runtime, bindings, _ := readerTentacleFixture(t, "present", true)
owner := tentacleOutputFixture(t)
runtime.output = owner
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
f9, _ := input.ParseCommandChord("F9")
waitTentacle(t, func() bool { return runtime.Describe(f9, input.DesktopKeyboardLayout, false) != "" })
runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
waitTentacle(t, func() bool {
owner.speech.mutex.Lock()
defer owner.speech.mutex.Unlock()
return len(owner.speech.pending) == 1 && owner.braille.current().Text == "Display é猫😀é"
})
if owner.braille.current().Text != "Display é猫😀é" {
t.Fatal("contained command did not present braille")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := runtime.Stop(ctx); err != nil {
t.Fatal(err)
}
if owner.braille.current().Text != "Core focus" {
t.Fatal("shutdown left tentacle output")
}
}