| 1 | package persistentshell |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | ) |
| 7 | |
| 8 | func TestSanitizerStripsTerminalControls(t *testing.T) { |
| 9 | var s sanitizer |
| 10 | got := s.push([]byte("\x1b[31mred\x1b[0m done\x1b]0;title\x07!")) |
| 11 | if got != "red done!" { |
| 12 | t.Fatalf("got %q", got) |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | // An escape sequence split across two PTY reads must not leak its bytes. |
| 17 | func TestSanitizerCarriesSplitEscape(t *testing.T) { |
| 18 | var s sanitizer |
| 19 | first := s.push([]byte("a\x1b[3")) |
| 20 | second := s.push([]byte("1mb")) |
| 21 | if first+second != "ab" { |
| 22 | t.Fatalf("got %q+%q", first, second) |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | // A carriage return at a read boundary cannot be classified until the next |
| 27 | // byte arrives; classifying it early produced a spurious blank line. |
| 28 | func TestSanitizerCarriesTrailingCarriageReturn(t *testing.T) { |
| 29 | var s sanitizer |
| 30 | first := s.push([]byte("a\r")) |
| 31 | second := s.push([]byte("\nb")) |
| 32 | if first+second != "a\nb" { |
| 33 | t.Fatalf("got %q+%q", first, second) |
| 34 | } |
| 35 | |
| 36 | var lone sanitizer |
| 37 | if got := lone.push([]byte("a\r")) + lone.push([]byte("b")); got != "a\nb" { |
| 38 | t.Fatalf("lone CR: got %q", got) |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | func TestCaptureExtractsBodyAndStatus(t *testing.T) { |
| 43 | c := newCapture("S", "E:", nil) |
| 44 | c.push("noise\nS\n") |
| 45 | c.push("hello\n") |
| 46 | c.push("E:0\n") |
| 47 | if !c.done || c.exitCode != 0 { |
| 48 | t.Fatalf("done=%v code=%d", c.done, c.exitCode) |
| 49 | } |
| 50 | if c.body() != "hello\n" { |
| 51 | t.Fatalf("body=%q", c.body()) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // The end marker may arrive split across reads, and its status digits may |
| 56 | // arrive after the marker itself. |
| 57 | func TestCaptureHandlesSplitEndMarker(t *testing.T) { |
| 58 | c := newCapture("S", "E:", nil) |
| 59 | c.push("S\nbody") |
| 60 | c.push("E") |
| 61 | if c.done { |
| 62 | t.Fatal("marker prefix alone must not complete") |
| 63 | } |
| 64 | c.push(":") |
| 65 | if c.done { |
| 66 | t.Fatal("marker without status must not complete") |
| 67 | } |
| 68 | c.push("12") |
| 69 | if c.done { |
| 70 | t.Fatal("status without terminator must not complete") |
| 71 | } |
| 72 | c.push("\n") |
| 73 | if !c.done || c.exitCode != 12 { |
| 74 | t.Fatalf("done=%v code=%d", c.done, c.exitCode) |
| 75 | } |
| 76 | if c.body() != "body" { |
| 77 | t.Fatalf("body=%q", c.body()) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // Output with no trailing newline leaves the marker mid-line. |
| 82 | func TestCaptureBodyWithoutTrailingNewline(t *testing.T) { |
| 83 | c := newCapture("S", "E:", nil) |
| 84 | c.push("S\nhiE:0\n") |
| 85 | if !c.done || c.body() != "hi" { |
| 86 | t.Fatalf("done=%v body=%q", c.done, c.body()) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // A command that never reports a status still owes the model its output. |
| 91 | func TestCapturePartialReleasesWithheldText(t *testing.T) { |
| 92 | c := newCapture("S", "E:", nil) |
| 93 | c.push("S\npartial output") |
| 94 | if c.body() == "partial output" { |
| 95 | t.Fatal("tail must stay withheld until the command settles") |
| 96 | } |
| 97 | if got := c.partial(); got != "partial output" { |
| 98 | t.Fatalf("partial=%q", got) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // Large output must not be rescanned per read: the withheld window stays at |
| 103 | // marker width no matter how much text passed through. |
| 104 | func TestCaptureHoldStaysBounded(t *testing.T) { |
| 105 | c := newCapture("S", "E:", nil) |
| 106 | c.push("S\n") |
| 107 | for range 100 { |
| 108 | c.push(strings.Repeat("x", 4096)) |
| 109 | } |
| 110 | if len(c.hold) > markerOverlap { |
| 111 | t.Fatalf("hold grew to %d bytes", len(c.hold)) |
| 112 | } |
| 113 | if len(c.body()) < 100*4096-markerOverlap { |
| 114 | t.Fatalf("body lost data: %d bytes", len(c.body())) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // A shell tracing its own input (set -x) prints the end marker followed by |
| 119 | // wrapper source instead of digits. That trailer is output, not a completion, |
| 120 | // and must not park the reader until the deadline. |
| 121 | func TestCaptureSkipsMarkerWithNonStatusTrailer(t *testing.T) { |
| 122 | c := newCapture("S", "E:", nil) |
| 123 | c.push("S\n+ printf '%s%s\\n' E:' \"$__rx_status\"\n") |
| 124 | if c.done { |
| 125 | t.Fatal("a traced marker must not complete the command") |
| 126 | } |
| 127 | c.push("real output\nE:3\n") |
| 128 | if !c.done || c.exitCode != 3 { |
| 129 | t.Fatalf("done=%v code=%d", c.done, c.exitCode) |
| 130 | } |
| 131 | if !strings.Contains(c.body(), "real output") { |
| 132 | t.Fatalf("body=%q", c.body()) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // A CRLF host puts \r before the status terminator. |
| 137 | func TestCaptureAcceptsCarriageReturnStatus(t *testing.T) { |
| 138 | c := newCapture("S", "E:", nil) |
| 139 | c.push("S\nout\nE:5\r\n") |
| 140 | if !c.done || c.exitCode != 5 { |
| 141 | t.Fatalf("done=%v code=%d", c.done, c.exitCode) |
| 142 | } |
| 143 | } |
| 144 |