package app import ( "context" "encoding/json" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/tentacle" sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle" "log/slog" "os" "path/filepath" "strings" "testing" "time" ) func TestTentacleInteractionPeer(t *testing.T) { if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "interaction-peer" { return } marker := os.Args[len(os.Args)-1] initial := func(context.Context, json.RawMessage) (any, error) { if filepath.Base(marker) == "copy-failed" { code := "7-alpha-beta" return sdk.Interaction{Presentation: sdk.Presentation{Speech: "Wormhole code: " + code, Braille: "Wormhole code: " + code}, Clipboard: &code, Done: true}, nil } next := "submit" if filepath.Base(marker) == "invalid" { next = "unregistered" } return sdk.Interaction{Form: &sdk.Form{Title: "Wormhole menu", Elements: []sdk.Element{{ID: "code", Label: "Wormhole code", Kind: sdk.Entry, Required: true, MaxLength: 256}}}, Next: next}, nil } submit := func(_ context.Context, raw json.RawMessage) (any, error) { os.WriteFile(marker, raw, 0600) return sdk.Interaction{Presentation: sdk.Presentation{Speech: "Submitted", Braille: "Submitted"}}, nil } commands := []sdk.Command{ {ID: "probe", Title: "Interactive probe", Presentation: true, Interaction: true, Bindings: []sdk.Binding{{Layout: "desktop", Scope: "global", Key: "F9"}}, Handle: initial}, {ID: "submit", Title: "Submit", Presentation: true, Interaction: true, Handle: submit}, } if strings.HasPrefix(filepath.Base(marker), "settings") { commands = append(commands, sdk.Command{ID: "settings", Title: "Probe settings", Settings: true, Presentation: true, Interaction: true, Handle: initial}) } err := sdk.Serve(os.Stdin, os.Stdout, commands) if err != nil { os.Exit(1) } os.Exit(0) } func interactiveFixture(t *testing.T, mode string) (*tentacleRuntime, *internalUI, *uiTestKeys, string) { t.Helper() directory := t.TempDir() marker := filepath.Join(directory, mode) executable, _ := os.Executable() specs := []tentacle.Spec{{ID: "probe", Presentation: true, Interaction: true, Program: []string{executable, "-test.run=^TestTentacleInteractionPeer$", "--", "interaction-peer", marker}}} data, _ := json.Marshal(specs) path := filepath.Join(directory, "launch.json") os.WriteFile(path, data, 0600) bindings := &tentacleBindingRecorder{} runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), bindings, path, "") runtime.output = tentacleOutputFixture(t) runtime.newManager = func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) } keys := &uiTestKeys{} output := &ocrTestOutput{speech: make(chan speechRequest, 32)} ui := &internalUI{keys: keys, speech: output, gate: runtime.output.gate, events: make(chan input.CommandEvent, 16), requests: make(chan internalUIRequest, 1), notices: make(chan internalUINotice, 4), done: make(chan struct{})} runtime.forms = ui ctx, cancel := context.WithCancel(context.Background()) go ui.run(ctx) t.Cleanup(func() { cancel() <-ui.done stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) defer stopCancel() if err := runtime.Stop(stopCtx); err != nil { t.Error(err) } }) if err := runtime.Start(ctx); err != nil { t.Fatal(err) } waitTentacle(t, func() bool { published := bindings.TentacleBindings() if len(published) == 0 { return false } // Native bindings are installed before activation publishes dispatch. runtime.mutex.Lock() defer runtime.mutex.Unlock() _, ready := runtime.commands[published[0].Command] return ready }) runtime.Invoke(ctx, bindings.TentacleBindings()[0].Command) return runtime, ui, keys, marker } func TestReaderTentacleHumanInputOutlivesRPCDeadline(t *testing.T) { runtime, ui, keys, marker := interactiveFixture(t, "submitted") waitTentacle(t, func() bool { return keys.InternalUISession() != 0 }) // RequestTimeout is two seconds; a user can take longer to type a code. time.Sleep(2100 * time.Millisecond) if keys.InternalUISession() == 0 { t.Fatal("RPC deadline cancelled human input") } runtime.output.Retire() // The native command consumer retires output on each key. ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Text: "7-alpha-beta"}) ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Chord: input.CommandChord{Symbol: 0xff0d}}) waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil }) data, _ := os.ReadFile(marker) var values sdk.Submission if json.Unmarshal(data, &values) != nil || values.Values["code"].Text != "7-alpha-beta" || values.Cancelled { t.Fatalf("submission %s", data) } waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) if keys.InternalUISession() != 0 { t.Fatal("input capture retained after submit") } } func TestReaderTentacleFocusLossAndRevocationCloseForms(t *testing.T) { for _, mode := range []string{"focus-loss", "disable"} { t.Run(mode, func(t *testing.T) { runtime, _, keys, marker := interactiveFixture(t, mode) waitTentacle(t, func() bool { return keys.InternalUISession() != 0 }) if mode == "disable" { runtime.Disable() } else { keys.session.Store(0) } waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) if keys.InternalUISession() != 0 { t.Fatal("capture retained") } if mode == "focus-loss" { data, err := os.ReadFile(marker) var submitted sdk.Submission if err != nil || json.Unmarshal(data, &submitted) != nil || !submitted.Cancelled { t.Fatalf("cleanup was not delivered: %s %v", data, err) } } }) } } func TestReaderTentacleRejectsUnregisteredContinuation(t *testing.T) { runtime, _, keys, _ := interactiveFixture(t, "invalid") waitTentacle(t, func() bool { runtime.mutex.Lock() defer runtime.mutex.Unlock() return len(runtime.sessions) == 1 && len(runtime.sessions[0].Commands()) == 0 }) if keys.InternalUISession() != 0 { t.Fatal("invalid form was opened") } } func TestInternalUINoticeCannotReachAnotherForm(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() keys := &uiTestKeys{} output := &ocrTestOutput{speech: make(chan speechRequest, 16)} ui := &internalUI{keys: keys, speech: output, gate: &focusGate{}, events: make(chan input.CommandEvent, 16), requests: make(chan internalUIRequest, 1), notices: make(chan internalUINotice, 4), done: make(chan struct{})} go ui.run(ctx) defer func() { cancel(); <-ui.done }() form := interaction.Form{Title: "Receive", Elements: []interaction.Element{{ID: "code", Label: "Code", Kind: interaction.Entry}}} formCtx, closeForm := context.WithCancel(ctx) first, err := ui.Open(formCtx, form) if err != nil { t.Fatal(err) } <-output.speech closeForm() <-first secondCtx, closeSecond := context.WithCancel(ctx) defer closeSecond() if _, err := ui.Open(secondCtx, form); err != nil { t.Fatal(err) } <-output.speech ui.Notice(formCtx, sdk.Presentation{Speech: "Stale code"}) ui.Notice(secondCtx, sdk.Presentation{Speech: "New code"}) select { case got := <-output.speech: if got.Text != "New code" { t.Fatal(got) } case <-time.After(time.Second): t.Fatal("notice not delivered") } } func TestReaderTentacleCopyFailureStillReadsCode(t *testing.T) { t.Setenv("PATH", t.TempDir()) // The private peer is an absolute test executable. runtime, _, keys, _ := interactiveFixture(t, "copy-failed") waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) runtime.output.speech.mutex.Lock() defer runtime.output.speech.mutex.Unlock() found := false for _, request := range runtime.output.speech.pending { if request.Text == "Wormhole code: 7-alpha-beta. Clipboard text could not be saved" { found = true } } if !found || keys.InternalUISession() != 0 { t.Fatal("clipboard failure lost the code or retained input") } }