32220d929b
ConfirmInput's first poll can run before the TUI renders the pasted text. It then finds an empty editor, falls through to the idle branch, and reports confirmation=editor_cleared. The launch text sits unsent for the whole lease while the coordinator believes the agent is working. Run 5 died on exactly that: first_submit_at 21:13:19.570, confirmed_at 21:13:19.577. Seven milliseconds. The two launches that worked took ~500ms and a second Enter, so the difference was scheduling luck. An empty editor is only proof once it has held the text, or once it has stayed empty past a settle window. Text seen and then gone still confirms at once, and so do busy, blocked and queued. Waiting only happens in the never-observed case, which is the one that cannot be told apart from a slow render. With this, the failing case reaches the existing resubmit path instead: the text appears, is recognised as unsubmitted, and Enter is resent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu
494 lines
17 KiB
Go
494 lines
17 KiB
Go
package herdr
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestTmuxBackendStartsCapturesPromptsAndKillsClaude(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("requires tmux")
|
||
}
|
||
dir := t.TempDir()
|
||
harness := filepath.Join(dir, "fake-claude")
|
||
script := "#!/bin/sh\nprintf '❯ ready\\n'\nwhile IFS= read -r line; do printf 'GOT:%s\\n' \"$line\"; done\n"
|
||
if err := os.WriteFile(harness, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b := NewTmuxBackend(filepath.Join(t.TempDir(), "tmux.sock"), harness)
|
||
if err := b.Check(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s, err := b.StartAgent(context.Background(), dir, dir, "", "claude", "tmux-backend-test")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { _ = b.Kill(context.Background(), s) })
|
||
if s.Worktree != dir || s.Harness != "claude" || !strings.Contains(s.PaneID, ":") || !strings.Contains(s.PaneID, ".") {
|
||
t.Fatalf("unexpected session: %+v", s)
|
||
}
|
||
if err := b.Prompt(context.Background(), s.PaneID, "hello from Orchestra", 0); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
for {
|
||
capture, err := b.PaneCapture(context.Background(), s, "recent")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(capture, "GOT:hello from Orchestra") {
|
||
break
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Fatalf("prompt was not captured: %q", capture)
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
for _, line := range []string{"/clear", "@HANDOFF.md"} {
|
||
if err := b.SendText(context.Background(), s, line); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := b.SendKeys(context.Background(), s, []string{"ENTER"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
deadline = time.Now().Add(2 * time.Second)
|
||
for {
|
||
capture, err := b.PaneCapture(context.Background(), s, "recent")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(capture, "GOT:/clear") && strings.Contains(capture, "GOT:@HANDOFF.md") {
|
||
break
|
||
}
|
||
if time.Now().After(deadline) {
|
||
t.Fatalf("Claude rollover lines were not captured: %q", capture)
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
if status, err := b.AgentStatus(context.Background(), s); err != nil || status != "idle" {
|
||
t.Fatalf("status=%q err=%v", status, err)
|
||
}
|
||
if err := b.ReleaseAgent(context.Background(), s, "claude"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := b.Kill(context.Background(), s); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := b.PaneCapture(context.Background(), s, "recent"); err == nil {
|
||
t.Fatal("killed tmux session remained readable")
|
||
}
|
||
}
|
||
|
||
func TestTmuxBackendRefusesUnverifiedHarnesses(t *testing.T) {
|
||
b := NewTmuxBackend("test", "true")
|
||
if _, err := b.StartAgent(context.Background(), "", t.TempDir(), "", "codex", "task"); err == nil {
|
||
t.Fatal("tmux backend accepted Codex before its terminal behavior was implemented")
|
||
}
|
||
}
|
||
|
||
func TestTmuxSessionNameKeepsCollisionResistantSuffix(t *testing.T) {
|
||
a := tmuxSessionName(strings.Repeat("same-prefix", 10) + "-one")
|
||
b := tmuxSessionName(strings.Repeat("same-prefix", 10) + "-two")
|
||
if a == b || len(a) > 64 || len(b) > 64 {
|
||
t.Fatalf("unsafe tmux session names %q %q", a, b)
|
||
}
|
||
}
|
||
|
||
// The launch dump is Orchestra's scratch space, not the session's work. A
|
||
// worktree that starts dirty pollutes the gate, the review diff, and the
|
||
// agent's own reading of `git status`.
|
||
func TestWriteLaunchContextLeavesTheWorktreeClean(t *testing.T) {
|
||
dir := t.TempDir()
|
||
for _, args := range [][]string{{"init"}, {"config", "user.email", "t@t"}, {"config", "user.name", "t"}, {"commit", "--allow-empty", "-m", "init"}} {
|
||
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
|
||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||
}
|
||
}
|
||
if err := WriteLaunchContext(dir, "the instruction"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b, err := os.ReadFile(filepath.Join(dir, LaunchContextFile))
|
||
if err != nil || string(b) != "the instruction" {
|
||
t.Fatalf("launch context = %q, err %v", b, err)
|
||
}
|
||
out, err := exec.Command("git", "-C", dir, "status", "--short").CombinedOutput()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.TrimSpace(string(out)) != "" {
|
||
t.Fatalf("worktree is dirty after a launch dump:\n%s", out)
|
||
}
|
||
}
|
||
|
||
func TestLaunchTransportIsHarnessSpecific(t *testing.T) {
|
||
b := &TmuxBackend{}
|
||
if got := b.LaunchTransport("claude"); got != LaunchFileRef {
|
||
t.Fatalf("claude transport=%q, want %q", got, LaunchFileRef)
|
||
}
|
||
for _, harness := range []string{"opencode", "codex", ""} {
|
||
if got := b.LaunchTransport(harness); got != LaunchInline {
|
||
t.Fatalf("%s transport=%q, want %q", harness, got, LaunchInline)
|
||
}
|
||
}
|
||
}
|
||
|
||
// F17, found live during burn-in run 3: a queued or already-accepted prompt
|
||
// renders with the same "❯" prefix as an unsubmitted one, so only cursor
|
||
// ownership can tell them apart.
|
||
func TestInputStateReadsTheEditorOwningTheCursor(t *testing.T) {
|
||
wrapped := "\u2500\u2500\u2500\u2500\n\u276f @.orchestra/launch.md is your complete Orchestra launch instruction. Read\n .orchestra/launch.md now and follow it.\n\u2500\u2500\u2500\u2500\n"
|
||
b := paneTmux(t, wrapped, 2)
|
||
state, err := b.inputState(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !state.Active || !sameInput(state.Text, LaunchReference) {
|
||
t.Fatalf("state=%+v, want the wrapped launch reference in the active editor", state)
|
||
}
|
||
|
||
// The same text, but the cursor sits in the empty editor below it: the
|
||
// harness took the prompt and queued it.
|
||
queued := " \u276f @.orchestra/launch.md is your complete Orchestra launch instruction. Read\n .orchestra/launch.md now and follow it.\n\u2500\u2500\u2500\u2500\n\u276f Press up to edit queued messages\n"
|
||
b = paneTmux(t, queued, 3)
|
||
state, err = b.inputState(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !state.Active || sameInput(state.Text, LaunchReference) {
|
||
t.Fatalf("state=%+v, want the queued marker rather than the launch text", state)
|
||
}
|
||
if !queuedInput(state.Text) {
|
||
t.Fatalf("state=%+v, want queued input recognized", state)
|
||
}
|
||
}
|
||
|
||
// Run 3 proved one Enter is not enough. A launch still owning the cursor must
|
||
// be resubmitted, and the evidence must say how many submits it took.
|
||
func TestConfirmInputResubmitsUntilTheEditorClears(t *testing.T) {
|
||
b, keys := submitTmux(t, 2)
|
||
evidence, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||
if err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
if !strings.Contains(evidence, "submit_attempts=2") {
|
||
t.Fatalf("evidence=%q, want two submits recorded", evidence)
|
||
}
|
||
if got := enterCount(t, keys); got != 1 {
|
||
t.Fatalf("resent Enter %d times, want 1", got)
|
||
}
|
||
}
|
||
|
||
// The intervention is bounded independently of the observation deadline: a TUI
|
||
// that already accepted the prompt must not receive a fortieth Enter.
|
||
func TestConfirmInputBoundsResubmission(t *testing.T) {
|
||
b, keys := submitTmux(t, 99)
|
||
// Generous deadline on purpose: the cap must be what stops the resends.
|
||
b.LaunchConfirmTimeout = 3 * time.Second
|
||
if _, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference); !errors.Is(err, ErrPromptNotSubmitted) {
|
||
t.Fatalf("err=%v, want ErrPromptNotSubmitted", err)
|
||
}
|
||
if got := enterCount(t, keys); got != launchResubmitLimit {
|
||
t.Fatalf("resent Enter %d times, want the %d-resubmit cap", got, launchResubmitLimit)
|
||
}
|
||
}
|
||
|
||
// Queued is submitted. Treating it as a stalled editor would kill a pane whose
|
||
// harness had already accepted the instruction.
|
||
func TestConfirmInputAcceptsQueuedInput(t *testing.T) {
|
||
pane := "\u2500\u2500\u2500\u2500\n\u276f Press up to edit queued messages\n"
|
||
b := paneTmux(t, pane, 1)
|
||
evidence, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||
if err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
if !strings.Contains(evidence, "confirmation=queued") {
|
||
t.Fatalf("evidence=%q, want queued input confirmed", evidence)
|
||
}
|
||
}
|
||
|
||
// paneTmux serves one fixed pane with the cursor on the given row.
|
||
func paneTmux(t *testing.T, pane string, cursor int) *TmuxBackend {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
paneFile := filepath.Join(dir, "pane")
|
||
if err := os.WriteFile(paneFile, []byte(pane), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
bin := filepath.Join(dir, "tmux")
|
||
script := `#!/bin/sh
|
||
cmd=""
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
capture-pane|has-session|display-message|send-keys) cmd=$a; break;;
|
||
esac
|
||
done
|
||
case "$cmd" in
|
||
has-session) exit 0 ;;
|
||
capture-pane) cat ` + paneFile + ` ; exit 0 ;;
|
||
display-message)
|
||
case "$*" in
|
||
*cursor_y*) printf '` + fmt.Sprint(cursor) + `\n' ;;
|
||
*) printf '0\tclaude\n' ;;
|
||
esac
|
||
exit 0 ;;
|
||
esac
|
||
exit 0
|
||
`
|
||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: time.Second, LaunchConfirmPoll: 5 * time.Millisecond}
|
||
}
|
||
|
||
// submitTmux holds the launch reference in the active editor until the given
|
||
// number of submits has been received, then clears it. The first submit is the
|
||
// one Prompt already sent, so submitOn=2 means one resend is required.
|
||
func submitTmux(t *testing.T, submitOn int) (*TmuxBackend, string) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
keys := filepath.Join(dir, "keys")
|
||
bin := filepath.Join(dir, "tmux")
|
||
script := `#!/bin/sh
|
||
dir=` + dir + `
|
||
cmd=""
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
capture-pane|has-session|display-message|send-keys) cmd=$a; break;;
|
||
esac
|
||
done
|
||
submits=$(cat $dir/submits 2>/dev/null || echo 1)
|
||
case "$cmd" in
|
||
has-session) exit 0 ;;
|
||
send-keys)
|
||
case "$*" in
|
||
*Enter*)
|
||
echo Enter >> ` + keys + `
|
||
echo $((submits+1)) > $dir/submits ;;
|
||
esac
|
||
exit 0 ;;
|
||
capture-pane)
|
||
if [ "$submits" -ge "` + fmt.Sprint(submitOn) + `" ]; then
|
||
printf 'esc to interrupt\n\xe2\x9d\xaf \n'
|
||
else
|
||
printf '\xe2\x94\x80\x0a\xe2\x9d\xaf ` + LaunchReference + `\n'
|
||
fi
|
||
exit 0 ;;
|
||
display-message)
|
||
case "$*" in
|
||
*cursor_y*)
|
||
if [ "$submits" -ge "` + fmt.Sprint(submitOn) + `" ]; then printf '1\n'; else printf '1\n'; fi ;;
|
||
*) printf '0\tclaude\n' ;;
|
||
esac
|
||
exit 0 ;;
|
||
esac
|
||
exit 0
|
||
`
|
||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: time.Second, LaunchConfirmPoll: 5 * time.Millisecond}, keys
|
||
}
|
||
|
||
func enterCount(t *testing.T, path string) int {
|
||
t.Helper()
|
||
b, err := os.ReadFile(path)
|
||
if os.IsNotExist(err) {
|
||
return 0
|
||
}
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return len(strings.Fields(string(b)))
|
||
}
|
||
|
||
// fakeTmux scripts capture-pane so confirmation can be driven without waiting
|
||
// on a real harness. clearAfter is how many captures still show the stalled
|
||
// editor before it clears.
|
||
func fakeTmux(t *testing.T, clearAfter int, failCapture bool) *TmuxBackend {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
bin := filepath.Join(dir, "tmux")
|
||
fail := "0"
|
||
if failCapture {
|
||
fail = "1"
|
||
}
|
||
script := `#!/bin/sh
|
||
state=` + filepath.Join(dir, "count") + `
|
||
cmd=""
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
capture-pane|has-session|display-message) cmd=$a; break;;
|
||
esac
|
||
done
|
||
case "$cmd" in
|
||
has-session) exit 0 ;;
|
||
display-message)
|
||
case "$*" in
|
||
*cursor_y*) printf '0\n' ;;
|
||
*) printf '0\tclaude\n' ;;
|
||
esac
|
||
exit 0 ;;
|
||
capture-pane)
|
||
if [ "` + fail + `" = "1" ]; then echo "no server running" >&2; exit 1; fi
|
||
n=$(cat $state 2>/dev/null || echo 0); n=$((n+1)); echo $n > $state
|
||
if [ "$n" -le "` + fmt.Sprint(clearAfter) + `" ]; then
|
||
printf '\xe2\x9d\xaf [Pasted text #1 +66 lines]\n'
|
||
else
|
||
printf 'esc to interrupt\n\xe2\x9d\xaf \n'
|
||
fi
|
||
exit 0 ;;
|
||
esac
|
||
exit 0
|
||
`
|
||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: 2 * time.Second, LaunchConfirmPoll: 5 * time.Millisecond}
|
||
}
|
||
|
||
func TestConfirmInputAcceptsObservedActivity(t *testing.T) {
|
||
b := fakeTmux(t, 1, false)
|
||
evidence, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||
if err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
if !strings.Contains(evidence, "busy") {
|
||
t.Fatalf("evidence=%q, want the observed activity named", evidence)
|
||
}
|
||
}
|
||
|
||
// Prompt returning nil is not proof. An editor that still holds the text at
|
||
// the deadline is a launch failure, and it must be the one class that releases
|
||
// the lease instead of holding it as uncertain.
|
||
func TestConfirmInputFailsWhileTheEditorStillHoldsThePrompt(t *testing.T) {
|
||
b := fakeTmux(t, 1000, false)
|
||
b.LaunchConfirmTimeout = 60 * time.Millisecond
|
||
_, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||
if !errors.Is(err, ErrPromptNotSubmitted) {
|
||
t.Fatalf("err=%v, want ErrPromptNotSubmitted", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "Pasted text") {
|
||
t.Fatalf("err=%v, want the stalled editor content quoted", err)
|
||
}
|
||
}
|
||
|
||
// A confirmation that cannot read the pane must not become an acknowledgement.
|
||
func TestConfirmInputSurfacesTransportErrors(t *testing.T) {
|
||
b := fakeTmux(t, 0, true)
|
||
if _, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference); err == nil {
|
||
t.Fatal("a failed pane capture must not confirm a launch")
|
||
}
|
||
}
|
||
|
||
// F16 and F20 are separate concerns and this is the line between them:
|
||
// Orchestra confirms delivery of what it originates, but nothing typed at a
|
||
// pane counts as the agent doing work. A capture whose only difference is the
|
||
// input line must produce the same progress digest.
|
||
func TestPaneProgressIgnoresInputLines(t *testing.T) {
|
||
body := "────\n ran the checks, nothing to change\n────\n"
|
||
idle := paneTmux(t, body+"❯ \n", 3)
|
||
typed := paneTmux(t, body+"❯ go ahead and implement it\n", 3)
|
||
a, err := idle.PaneProgress(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b, err := typed.PaneProgress(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if a != b {
|
||
t.Fatalf("typing changed the progress digest:\n%q\n%q", a, b)
|
||
}
|
||
if !strings.Contains(a, "ran the checks") {
|
||
t.Fatalf("progress digest dropped harness output: %q", a)
|
||
}
|
||
|
||
// Harness output still moves it.
|
||
worked := paneTmux(t, "────\n edited scripts/orchestra_e2e_healthcheck.sh\n────\n❯ \n", 3)
|
||
c, err := worked.PaneProgress(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if c == a {
|
||
t.Fatal("real harness output left the progress digest unchanged")
|
||
}
|
||
}
|
||
|
||
// TestMissingSessionIsRecognisedOnAnEmptyRunningServer guards F32. A running
|
||
// but empty tmux server answers "no current target", not "no server running".
|
||
// The runtime only stays alive past its last pane since it became its own unit
|
||
// running `tmux -D`, so this reply had never been seen before. Treating it as
|
||
// a real error made Kill fail for a pane that was already gone, which left the
|
||
// quarantine set and pinned the worker's only session slot.
|
||
func TestMissingSessionIsRecognisedOnAnEmptyRunningServer(t *testing.T) {
|
||
for _, message := range []string{"no current target", "can't find session: x", "no server running"} {
|
||
bin := filepath.Join(t.TempDir(), "tmux")
|
||
script := "#!/bin/sh\necho \"" + message + "\" >&2\nexit 1\n"
|
||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b := &TmuxBackend{Binary: bin}
|
||
if err := b.Kill(context.Background(), Session{PaneID: "s:1.0"}); err != nil {
|
||
t.Fatalf("%q: killing an already-gone session failed: %v", message, err)
|
||
}
|
||
status, err := b.AgentStatus(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil || status != "exited" {
|
||
t.Fatalf("%q: status=%q err=%v, want exited", message, status, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestEmptyEditorIsNotProofBeforeItEverHeldTheText guards F33. The submit
|
||
// races the TUI's render: a poll 7ms after Enter found an empty editor, called
|
||
// it confirmation=editor_cleared, and the launch text then sat unsent for the
|
||
// whole lease. Run 5 died on exactly that. An editor that never held the text
|
||
// has to keep being observed, and once the text appears the resubmit path can
|
||
// do its job.
|
||
func TestEmptyEditorIsNotProofBeforeItEverHeldTheText(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := filepath.Join(dir, "tmux")
|
||
calls := filepath.Join(dir, "calls")
|
||
// capture-pane reports an empty editor on the first two polls, exactly as a
|
||
// TUI that has not rendered the paste yet, then shows the submitted text.
|
||
script := `#!/bin/sh
|
||
cmd=""
|
||
for a in "$@"; do
|
||
case "$a" in capture-pane|has-session|display-message|send-keys) cmd=$a; break;; esac
|
||
done
|
||
case "$cmd" in
|
||
has-session) exit 0 ;;
|
||
send-keys) exit 0 ;;
|
||
display-message)
|
||
case "$*" in
|
||
*cursor_y*) printf '1\n' ;;
|
||
*) printf '0\tclaude\n' ;;
|
||
esac
|
||
exit 0 ;;
|
||
capture-pane)
|
||
n=$(cat ` + calls + ` 2>/dev/null || echo 0); n=$((n+1)); echo $n > ` + calls + `
|
||
if [ "$n" -le 2 ]; then printf '\xe2\x9d\xaf \n\n'; else printf '\xe2\x9d\xaf hello world\n\n'; fi
|
||
exit 0 ;;
|
||
esac
|
||
exit 0
|
||
`
|
||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b := &TmuxBackend{Binary: bin, LaunchConfirmTimeout: 300 * time.Millisecond, LaunchConfirmPoll: 5 * time.Millisecond}
|
||
_, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, "hello world")
|
||
if err == nil {
|
||
t.Fatal("an editor still holding the submitted text was reported as confirmed")
|
||
}
|
||
if !errors.Is(err, ErrPromptNotSubmitted) {
|
||
t.Fatalf("err=%v, want ErrPromptNotSubmitted", err)
|
||
}
|
||
}
|