| 1 | package persistentshell |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "os/exec" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func TestExtractOutputIgnoresEchoedScript(t *testing.T) { |
| 11 | start := "REASONIX_START_abc" |
| 12 | end := "REASONIX_END_abc:" |
| 13 | raw := posixCommandScript("pwd", start, end) + |
| 14 | start + "\n" + |
| 15 | "/tmp/work\n" + |
| 16 | end + "0\n" |
| 17 | body, code, ok := extractOutput(raw, start, end) |
| 18 | if !ok { |
| 19 | t.Fatal("expected completed extraction") |
| 20 | } |
| 21 | if code != 0 { |
| 22 | t.Fatalf("code=%d", code) |
| 23 | } |
| 24 | if body != "/tmp/work" { |
| 25 | t.Fatalf("body=%q", body) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func TestAnsiCQuoteASCIIByteRoundTrip(t *testing.T) { |
| 30 | skipNonPOSIX(t) |
| 31 | // NUL cannot occur in a shell argument. Include every other byte followed |
| 32 | // by octal digits to catch escapes that accidentally consume the suffix. |
| 33 | var input []byte |
| 34 | for c := 1; c <= 255; c++ { |
| 35 | input = append(input, byte(c), '7', '0') |
| 36 | } |
| 37 | quoted := ansiCQuote(string(input)) |
| 38 | for _, c := range []byte(quoted) { |
| 39 | if c < 0x20 || c >= 0x7f { |
| 40 | t.Fatalf("unsafe terminal input byte: %02x", c) |
| 41 | } |
| 42 | } |
| 43 | for _, name := range []string{"bash", "zsh"} { |
| 44 | t.Run(name, func(t *testing.T) { |
| 45 | path, err := exec.LookPath(name) |
| 46 | if err != nil { |
| 47 | t.Skipf("%s not installed", name) |
| 48 | } |
| 49 | cmd := exec.Command(path, "-c", "printf '%s' "+quoted) |
| 50 | cmd.Env = []string{"LC_ALL=C"} |
| 51 | got, err := cmd.Output() |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if !bytes.Equal(got, input) { |
| 56 | t.Fatalf("shell decoded %x, want %x", got, input) |
| 57 | } |
| 58 | }) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | // The echoed wrapper source contains both markers. Completion must require the |
| 63 | // status digits that only the executed printf can produce. |
| 64 | func TestEchoedScriptAloneIsNotCompletion(t *testing.T) { |
| 65 | start := "REASONIX_START_abc" |
| 66 | end := "REASONIX_END_abc:" |
| 67 | if _, _, ok := extractOutput(posixCommandScript("pwd", start, end), start, end); ok { |
| 68 | t.Fatal("echoed wrapper source must not complete a command") |
| 69 | } |
| 70 | if readyLine(posixSetupScript()) { |
| 71 | t.Fatal("echoed setup source must not report readiness") |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestExtractOutputCRLF(t *testing.T) { |
| 76 | start := "REASONIX_START_x" |
| 77 | end := "REASONIX_END_x:" |
| 78 | raw := start + "\r\nhello\r\n" + end + "7\r\n" |
| 79 | body, code, ok := extractOutput(raw, start, end) |
| 80 | if !ok || code != 7 || body != "hello" { |
| 81 | t.Fatalf("body=%q code=%d ok=%v", body, code, ok) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // A command whose output has no trailing newline leaves the status marker |
| 86 | // mid-line. Requiring a line start there hung every such command until its |
| 87 | // deadline (printf without \n, echo -n, cat of a file with no final newline). |
| 88 | func TestExtractOutputWithoutTrailingNewline(t *testing.T) { |
| 89 | start := "REASONIX_START_y" |
| 90 | end := "REASONIX_END_y:" |
| 91 | raw := start + "\nhi" + end + "0\n" |
| 92 | body, code, ok := extractOutput(raw, start, end) |
| 93 | if !ok || code != 0 || body != "hi" { |
| 94 | t.Fatalf("body=%q code=%d ok=%v", body, code, ok) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestReadyLine(t *testing.T) { |
| 99 | if !readyLine("noise\n" + readyToken + "\nmore") { |
| 100 | t.Fatal("ready token not detected") |
| 101 | } |
| 102 | if readyLine("printf '%s\\n' '" + readyToken + "'\n") { |
| 103 | t.Fatal("quoted token in echoed source must not count as ready") |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestPosixQuote(t *testing.T) { |
| 108 | if got := posixQuote("it's"); got != `'it'\''s'` { |
| 109 | t.Fatalf("got %q", got) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // A multi-line command has to reach the shell as one physical input line, or an |
| 114 | // interactive shell prints PS2 and the wrapper's own source leaks into output. |
| 115 | func TestAnsiCQuoteKeepsOnePhysicalLine(t *testing.T) { |
| 116 | script := posixCommandScript("cat <<'EOF'\nline\nEOF", "S", "E:") |
| 117 | if strings.Count(script, "\n") != 1 || !strings.HasSuffix(script, "\n") { |
| 118 | t.Fatalf("wrapper must be one line, got %q", script) |
| 119 | } |
| 120 | if got := ansiCQuote("a'b\nc\\d\te"); got != `$'a\'b\nc\\d\te'` { |
| 121 | t.Fatalf("quote=%q", got) |
| 122 | } |
| 123 | if got := ansiCQuote("\x01"); got != `$'\001'` { |
| 124 | t.Fatalf("control quote=%q", got) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // The command's stdin is /dev/null, matching one-shot execution: a command that |
| 129 | // prompts fails immediately instead of blocking the session shell. |
| 130 | func TestCommandScriptClosesStdin(t *testing.T) { |
| 131 | if !strings.Contains(posixCommandScript("read x", "S", "E:"), "</dev/null") { |
| 132 | t.Fatal("wrapper must detach stdin") |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestLongCommandScriptBoundsPhysicalLinesAndPreservesState(t *testing.T) { |
| 137 | skipNonPOSIX(t) |
| 138 | text := strings.Repeat("中文😀'\\\n", 2000) |
| 139 | stages := commandStages("value="+posixQuote(text)+"; printf '%s' \"$value\"; false", "S", "E:") |
| 140 | var script, acknowledgements string |
| 141 | for _, stage := range stages { |
| 142 | script += stage.script |
| 143 | if stage.ack != "" { |
| 144 | acknowledgements += stage.ack + "\n" |
| 145 | } |
| 146 | } |
| 147 | for line := range strings.SplitSeq(script, "\n") { |
| 148 | if len(line) > 768 { |
| 149 | t.Fatalf("physical input line has %d bytes; canonical PTYs can discard excess bytes", len(line)) |
| 150 | } |
| 151 | } |
| 152 | if _, _, ok := extractOutput(script, "S", "E:"); ok { |
| 153 | t.Fatal("echoed multi-line source fabricated completion") |
| 154 | } |
| 155 | for _, name := range []string{"bash", "zsh"} { |
| 156 | t.Run(name, func(t *testing.T) { |
| 157 | path, err := exec.LookPath(name) |
| 158 | if err != nil { |
| 159 | t.Skipf("%s not installed", name) |
| 160 | } |
| 161 | cmd := exec.CommandContext(t.Context(), path, "-c", script+"printf '%s' \"$value\"") |
| 162 | cmd.Env = []string{"LC_ALL=C"} |
| 163 | got, err := cmd.Output() |
| 164 | want := acknowledgements + "S\n" + text + "E:1\n" + text |
| 165 | if err != nil || string(got) != want { |
| 166 | t.Fatalf("wrapper changed output, state or status: err=%v bytes=%d want=%d", err, len(got), len(want)) |
| 167 | } |
| 168 | }) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // The line discipline can emit \r\r\n under output pressure. Mapping every \r |
| 173 | // to \n injected blank lines into model-visible output. |
| 174 | func TestNormalizePTYCollapsesCarriageReturnRuns(t *testing.T) { |
| 175 | cases := map[string]string{ |
| 176 | "a\r\nb": "a\nb", |
| 177 | "a\r\r\nb": "a\nb", |
| 178 | "a\r\r\r\nb": "a\nb", |
| 179 | "a\rb": "a\nb", |
| 180 | "plain": "plain", |
| 181 | } |
| 182 | for in, want := range cases { |
| 183 | if got := normalizePTY(in); got != want { |
| 184 | t.Fatalf("normalizePTY(%q)=%q want %q", in, got, want) |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 |