468 lines
16 KiB
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")
|
|
}
|
|
}
|