mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 01:16:40 +02:00
293 lines
9.2 KiB
Go
293 lines
9.2 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/x/ansi"
|
|
"github.com/usestrix/strix/tui/internal/protocol"
|
|
)
|
|
|
|
// lastIndex returns the index of the last command of the given type, or -1.
|
|
func lastIndex(types []string, want string) int {
|
|
last := -1
|
|
for i, value := range types {
|
|
if value == want {
|
|
last = i
|
|
}
|
|
}
|
|
return last
|
|
}
|
|
|
|
// firstIndex returns the index of the first command of the given type, or -1.
|
|
func firstIndex(types []string, want string) int {
|
|
for i, value := range types {
|
|
if value == want {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// drainCommands runs a (possibly batched) command and decodes every protocol
|
|
// frame the sends wrote to the connection, in order.
|
|
func drainCommands(t *testing.T, cmd tea.Cmd, connection *recordingConn) []protocol.Envelope {
|
|
t.Helper()
|
|
if cmd == nil {
|
|
return nil
|
|
}
|
|
var run func(tea.Cmd)
|
|
run = func(c tea.Cmd) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
msg := c()
|
|
switch typed := msg.(type) {
|
|
case tea.BatchMsg:
|
|
for _, sub := range typed {
|
|
run(sub)
|
|
}
|
|
case sentMsg:
|
|
if typed.err != nil {
|
|
t.Fatalf("command failed: %#v", typed)
|
|
}
|
|
default:
|
|
// tea.Sequence yields an unexported sequenceMsg ([]tea.Cmd); run its
|
|
// commands in order, which is the ordering the sequence guarantees.
|
|
if value := reflect.ValueOf(msg); value.Kind() == reflect.Slice {
|
|
for i := 0; i < value.Len(); i++ {
|
|
if sub, ok := value.Index(i).Interface().(tea.Cmd); ok {
|
|
run(sub)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
run(cmd)
|
|
|
|
var envelopes []protocol.Envelope
|
|
raw := connection.Bytes()
|
|
for len(raw) >= 4 {
|
|
size := int(binary.BigEndian.Uint32(raw[:4]))
|
|
if len(raw) < size+4 {
|
|
t.Fatalf("truncated command frame")
|
|
}
|
|
var envelope protocol.Envelope
|
|
if err := json.Unmarshal(raw[4:size+4], &envelope); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envelopes = append(envelopes, envelope)
|
|
raw = raw[size+4:]
|
|
}
|
|
return envelopes
|
|
}
|
|
|
|
func commandTypes(envelopes []protocol.Envelope) []string {
|
|
types := make([]string, len(envelopes))
|
|
for i, envelope := range envelopes {
|
|
types[i] = envelope.Type
|
|
}
|
|
return types
|
|
}
|
|
|
|
// startVerify returns the verify flag on the setup.start command, and whether
|
|
// a setup.start command was present at all.
|
|
func startVerify(t *testing.T, envelopes []protocol.Envelope) (verify, found bool) {
|
|
t.Helper()
|
|
for _, envelope := range envelopes {
|
|
if envelope.Type != "setup.start" {
|
|
continue
|
|
}
|
|
var payload struct {
|
|
Verify bool `json:"verify"`
|
|
}
|
|
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return payload.Verify, true
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func contains(values []string, want string) bool {
|
|
for _, value := range values {
|
|
if value == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// startPayloadFlag reports a boolean field on the setup.start command.
|
|
func startPayloadFlag(t *testing.T, envelopes []protocol.Envelope, field string) (value, found bool) {
|
|
t.Helper()
|
|
for _, envelope := range envelopes {
|
|
if envelope.Type != "setup.start" {
|
|
continue
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
flag, ok := payload[field].(bool)
|
|
return flag, ok
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// A bare prompt launches straight away, asking to mount the working directory
|
|
// rather than adding it as a target. The prompt is held in case it is declined.
|
|
func TestSetupPromptWithoutTargetLaunchesAndRequestsMount(t *testing.T) {
|
|
connection := &recordingConn{}
|
|
model := New(&Client{conn: connection})
|
|
model.snapshot = protocol.Snapshot{SetupMode: true, WorkingDir: "/Users/me/code/api"}
|
|
|
|
updated, cmd := model.submit("find auth bugs in the login flow")
|
|
model = updated.(Model)
|
|
envelopes := drainCommands(t, cmd, connection)
|
|
types := commandTypes(envelopes)
|
|
|
|
if !contains(types, "setup.set_instruction") || !contains(types, "setup.start") {
|
|
t.Fatalf("bare prompt did not launch: %v", types)
|
|
}
|
|
if contains(types, "setup.add_target") {
|
|
t.Fatalf("the working directory must not be added as a target: %v", types)
|
|
}
|
|
if mount, found := startPayloadFlag(t, envelopes, "mount_working_dir"); !found || !mount {
|
|
t.Fatalf("mount was not requested: mount_working_dir=%v found=%v", mount, found)
|
|
}
|
|
// A bare prompt launches optimistically: no model preflight.
|
|
if verify, found := startVerify(t, envelopes); !found || verify {
|
|
t.Fatalf("bare prompt should launch with verify=false, got verify=%v found=%v", verify, found)
|
|
}
|
|
// setup.start leaves setup mode, so it must be the last command sent.
|
|
if start, instr := firstIndex(types, "setup.start"), lastIndex(types, "setup.set_instruction"); start < instr {
|
|
t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types)
|
|
}
|
|
if model.pendingPrompt != "find auth bugs in the login flow" {
|
|
t.Fatalf("prompt was not held in case the mount is declined: %q", model.pendingPrompt)
|
|
}
|
|
// The confirmation is not raised locally; the backend asks for it.
|
|
if model.modal != modalNone {
|
|
t.Fatalf("submit should not open a dialog itself: modal=%v", model.modal)
|
|
}
|
|
}
|
|
|
|
// The backend asks from the live view, so the prompt follows the snapshot.
|
|
func TestPendingMountOpensAndClosesWithTheSnapshot(t *testing.T) {
|
|
model := New(nil)
|
|
model.width, model.height = 130, 40
|
|
model.ready = true
|
|
|
|
model.snapshot.PendingMount = "/Users/me/code/api"
|
|
model.syncMountPrompt()
|
|
if model.modal != modalConfirmMount {
|
|
t.Fatalf("pending mount did not raise the prompt: modal=%v", model.modal)
|
|
}
|
|
if model.modalChoice != 1 {
|
|
t.Fatalf("a consent prompt should default to declining, got %d", model.modalChoice)
|
|
}
|
|
// It names the directory the backend is waiting on, and stays compact.
|
|
view := ansi.Strip(model.mountConfirmView())
|
|
if !strings.Contains(view, "/Users/me/code/api") {
|
|
t.Fatalf("prompt does not name the directory: %s", view)
|
|
}
|
|
if rows := strings.Count(view, "\n") + 1; rows > 6 {
|
|
t.Fatalf("corner prompt should stay compact, got %d rows:\n%s", rows, view)
|
|
}
|
|
|
|
// Once the backend has the answer it clears, which closes the prompt.
|
|
model.snapshot.PendingMount = ""
|
|
model.syncMountPrompt()
|
|
if model.modal != modalNone {
|
|
t.Fatalf("prompt stayed open after the pending mount cleared: %v", model.modal)
|
|
}
|
|
}
|
|
|
|
// Answering replies to the backend; declining puts the prompt back to edit.
|
|
func TestMountConfirmationAnswers(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
key tea.KeyMsg
|
|
choice int
|
|
approved bool
|
|
}{
|
|
{"confirm", tea.KeyMsg{Type: tea.KeyEnter}, 0, true},
|
|
{"cancel", tea.KeyMsg{Type: tea.KeyEnter}, 1, false},
|
|
{"escape", tea.KeyMsg{Type: tea.KeyEsc}, 1, false},
|
|
} {
|
|
connection := &recordingConn{}
|
|
model := New(&Client{conn: connection})
|
|
model.width, model.height = 130, 40
|
|
model.snapshot = protocol.Snapshot{SetupMode: true, WorkingDir: "/Users/me/code/api"}
|
|
updated, _ := model.submit("find auth bugs in the login flow")
|
|
model = updated.(Model)
|
|
connection.Reset()
|
|
model.snapshot.PendingMount = "/Users/me/code/api"
|
|
model.syncMountPrompt()
|
|
model.modalChoice = tc.choice
|
|
|
|
updated, cmd := model.updateModal(tc.key)
|
|
model = updated.(Model)
|
|
envelopes := drainCommands(t, cmd, connection)
|
|
|
|
if len(envelopes) != 1 || envelopes[0].Type != "setup.confirm_mount" {
|
|
t.Fatalf("%s: expected one setup.confirm_mount, got %v", tc.name, commandTypes(envelopes))
|
|
}
|
|
var payload struct {
|
|
Approved bool `json:"approved"`
|
|
}
|
|
if err := json.Unmarshal(envelopes[0].Payload, &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload.Approved != tc.approved {
|
|
t.Fatalf("%s: approved=%v, want %v", tc.name, payload.Approved, tc.approved)
|
|
}
|
|
// Declining returns to the start screen, so the prompt comes back.
|
|
want := ""
|
|
if !tc.approved {
|
|
want = "find auth bugs in the login flow"
|
|
}
|
|
if got := model.input.Value(); got != want {
|
|
t.Fatalf("%s: composer = %q, want %q", tc.name, got, want)
|
|
}
|
|
if model.pendingPrompt != "" {
|
|
t.Fatalf("%s: held prompt was not cleared: %q", tc.name, model.pendingPrompt)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A prompt that names a target adds it and launches.
|
|
func TestSetupPromptWithTargetLaunches(t *testing.T) {
|
|
connection := &recordingConn{}
|
|
model := New(&Client{conn: connection})
|
|
model.snapshot = protocol.Snapshot{SetupMode: true}
|
|
|
|
_, cmd := model.submit("https://juice-shop.example.com hit the coupon endpoint")
|
|
envelopes := drainCommands(t, cmd, connection)
|
|
types := commandTypes(envelopes)
|
|
|
|
for _, want := range []string{"setup.add_target", "setup.set_instruction", "setup.start"} {
|
|
if !contains(types, want) {
|
|
t.Fatalf("missing %s in %v", want, types)
|
|
}
|
|
}
|
|
// A named target keeps the upfront model check.
|
|
if verify, found := startVerify(t, envelopes); !found || !verify {
|
|
t.Fatalf("targeted prompt should launch with verify=true, got verify=%v found=%v", verify, found)
|
|
}
|
|
// The target and instruction must reach the backend before setup.start
|
|
// closes the setup guard.
|
|
start := firstIndex(types, "setup.start")
|
|
if target := lastIndex(types, "setup.add_target"); start < target {
|
|
t.Fatalf("setup.start (%d) must come after setup.add_target (%d): %v", start, target, types)
|
|
}
|
|
if instr := lastIndex(types, "setup.set_instruction"); start < instr {
|
|
t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types)
|
|
}
|
|
}
|