205 lines
7.8 KiB
Go
205 lines
7.8 KiB
Go
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")
|
|
}
|
|
}
|