057cbff8
test: ptyclient pastes like a terminal, and nvim proves it
a73x 2026-08-15 17:20
Commit message
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -774,6 +774,9 @@ cleanup() { | |||
| 774 | # the session runs. Spelled from $OUT rather than from a variable of its | 774 | # the session runs. Spelled from $OUT rather than from a variable of its |
| 775 | # own, for the reason the M12 controls above are. | 775 | # own, for the reason the M12 controls above are. |
| 776 | rm -f "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh" | 776 | rm -f "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh" |
| 777 | # ...and its other half: the paste capture and the file nvim wrote, which | ||
| 778 | # IS that scenario's assertion rather than a log beside it. | ||
| 779 | rm -f "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt" | ||
| 777 | # M14 handoff. The shim, both runtime dirs (each holding its daemon's | 780 | # M14 handoff. The shim, both runtime dirs (each holding its daemon's |
| 778 | # socket), the second key, the unusable config home, and the five | 781 | # socket), the second key, the unusable config home, and the five |
| 779 | # captures. | 782 | # captures. |
| @@ -2732,6 +2735,76 @@ grep -qaF "$CLIPESC" "$OUT.clip" || { | |||
| 2732 | rm_swept "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh" | 2735 | rm_swept "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh" |
| 2733 | ok "the session's OSC 52 reaches the host terminal" | 2736 | ok "the session's OSC 52 reaches the host terminal" |
| 2734 | 2737 | ||
| 2738 | # --- side channel: a paste into a real editor keeps its indentation ------ | ||
| 2739 | # | ||
| 2740 | # The byte-level pin (?2004h in a host capture) proves the FRAME arrived; | ||
| 2741 | # this proves pasting WORKS. Measured on 2026-08-15 before the fix: the | ||
| 2742 | # third line of this exact block came out with 8 spaces instead of 4 — the | ||
| 2743 | # classic autoindent staircase — because the host terminal was never told | ||
| 2744 | # the application had asked for bracketed paste, and the host terminal is | ||
| 2745 | # the only thing that can actually bracket one. | ||
| 2746 | # | ||
| 2747 | # ptyclient's `paste` verb brackets only when it has SEEN ?2004h go past on | ||
| 2748 | # the capture, which is what a real terminal does and is why this scenario | ||
| 2749 | # cannot pass on a mirror that stopped working: with the mirror neutered the | ||
| 2750 | # same bytes arrive unbracketed, nvim autoindents them, and the file below | ||
| 2751 | # holds the staircase. The assertion is that file, not an escape — a grep | ||
| 2752 | # for the escape would go on passing while paste itself broke. | ||
| 2753 | # | ||
| 2754 | # nvim is REQUIRED, not guarded with `command -v`. A guard would let a box | ||
| 2755 | # without nvim skip this quietly, and a silently-skipped scenario is exactly | ||
| 2756 | # what the OK_COUNT pin at the bottom exists to catch — so the dependency | ||
| 2757 | # fails loudly here instead of disappearing. | ||
| 2758 | command -v nvim > /dev/null 2>&1 || { | ||
| 2759 | echo "e2e FAIL: this suite needs nvim (the paste scenario asserts on a file" | ||
| 2760 | echo " a real editor wrote); install it, or lose the only check that" | ||
| 2761 | echo " proves bracketed paste works rather than merely arrives" | ||
| 2762 | exit 1; } | ||
| 2763 | # Unquoted heredoc, like the clipboard leg above: the send verb carries | ||
| 2764 | # $OUT's per-run path. `\x1b`, `\r`, `\n` and `\x1c` are the fixture's | ||
| 2765 | # escapes and the shell leaves every one of them alone. | ||
| 2766 | # | ||
| 2767 | # -i NONE keeps nvim off the operator's shada file; -u NONE keeps it off | ||
| 2768 | # their config, so 'autoindent' is set here and nowhere else — the staircase | ||
| 2769 | # this measures has to come from the option this line spells. | ||
| 2770 | # | ||
| 2771 | # ESC and `:wq` are two sends with a settle between them: delivered in one | ||
| 2772 | # read, nvim's input parser is entitled to read `\x1b:` as Alt-: rather than | ||
| 2773 | # as leaving insert mode, and the file would never be written. | ||
| 2774 | set +e | ||
| 2775 | timeout 60 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.paste" --err "$OUT.paste.err" \ | ||
| 2776 | -- "$MUX" --sock "$SOCK" > "$OUT.paste.log" 2>&1 <<EOF | ||
| 2777 | expect \x1b[?1049h 15000 | ||
| 2778 | settle 400 15000 | ||
| 2779 | send nvim -u NONE -i NONE -c "set autoindent" -c startinsert $OUT.pasted.txt\n | ||
| 2780 | expect INSERT 20000 | ||
| 2781 | settle 500 20000 | ||
| 2782 | paste if x:\r a = 1\r b = 2\r | ||
| 2783 | settle 500 20000 | ||
| 2784 | send \x1b | ||
| 2785 | settle 300 15000 | ||
| 2786 | send :wq\r | ||
| 2787 | settle 800 20000 | ||
| 2788 | send \x1c | ||
| 2789 | waitexit 10000 | ||
| 2790 | EOF | ||
| 2791 | RC=$? | ||
| 2792 | set -e | ||
| 2793 | [ "$RC" -eq 0 ] || { | ||
| 2794 | echo "e2e FAIL: paste scenario did not run: ptyclient exited $RC" | ||
| 2795 | cat "$OUT.paste.log"; cat -v "$OUT.paste.err" 2>/dev/null; exit 1; } | ||
| 2796 | # Separate from the indentation check on purpose: "nvim never wrote" and | ||
| 2797 | # "nvim wrote the wrong thing" are different failures, and one message for | ||
| 2798 | # both would send the reader looking at the wrong half. | ||
| 2799 | [ -s "$OUT.pasted.txt" ] || { | ||
| 2800 | echo "e2e FAIL: nvim wrote no file — the paste never reached the editor" | ||
| 2801 | cat "$OUT.paste.log"; exit 1; } | ||
| 2802 | grep -q '^ b = 2$' "$OUT.pasted.txt" || { | ||
| 2803 | echo "e2e FAIL: pasted block lost its indentation (bracketed paste not mirrored)" | ||
| 2804 | cat -A "$OUT.pasted.txt"; exit 1; } | ||
| 2805 | rm_swept "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt" | ||
| 2806 | ok "a paste into nvim keeps its indentation" | ||
| 2807 | |||
| 2735 | # --- M14: the ssh→QUIC handoff ----------------------------------------- | 2808 | # --- M14: the ssh→QUIC handoff ----------------------------------------- |
| 2736 | # | 2809 | # |
| 2737 | # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and | 2810 | # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and |
| @@ -3737,7 +3810,7 @@ DPID="" | |||
| 3737 | 3810 | ||
| 3738 | # The pins. Literals, not variables set from counting something else — | 3811 | # The pins. Literals, not variables set from counting something else — |
| 3739 | # "assert the literal, never the constant the code under test reads" | 3812 | # "assert the literal, never the constant the code under test reads" |
| 3740 | # (decisions.md, M10). 27 scenario checkpoints; 35 convergence points. | 3813 | # (decisions.md, M10). 28 scenario checkpoints; 35 convergence points. |
| 3741 | # Anyone adding a scenario updates these by hand, on purpose. | 3814 | # Anyone adding a scenario updates these by hand, on purpose. |
| 3742 | # | 3815 | # |
| 3743 | # M18 added three checkpoints and no convergence points: its wall block | 3816 | # M18 added three checkpoints and no convergence points: its wall block |
| @@ -3745,9 +3818,11 @@ DPID="" | |||
| 3745 | # CONV_COUNT (see its comment) — that pin counts assert_converged call | 3818 | # CONV_COUNT (see its comment) — that pin counts assert_converged call |
| 3746 | # sites, and folding the two together would make either number stop | 3819 | # sites, and folding the two together would make either number stop |
| 3747 | # meaning anything. The side-channel leg added the 27th and no convergence | 3820 | # meaning anything. The side-channel leg added the 27th and no convergence |
| 3748 | # point: its capture is asserted on for a byte the grid does not carry. | 3821 | # point: its capture is asserted on for a byte the grid does not carry. The |
| 3749 | [ "$OK_COUNT" = "27" ] || { | 3822 | # paste leg added the 28th and no convergence point either: what it asserts |
| 3750 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 27 —" | 3823 | # on is a file an editor wrote, not a grid at all. |
| 3824 | [ "$OK_COUNT" = "28" ] || { | ||
| 3825 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 28 —" | ||
| 3751 | echo " a scenario was added (update the pin) or silently lost" | 3826 | echo " a scenario was added (update the pin) or silently lost" |
| 3752 | exit 1 | 3827 | exit 1 |
| 3753 | } | 3828 | } |
| @@ -3755,4 +3830,4 @@ DPID="" | |||
| 3755 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" | 3830 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" |
| 3756 | exit 1 | 3831 | exit 1 |
| 3757 | } | 3832 | } |
| 3758 | echo "e2e OK (27 scenarios, 35 convergence points)" | 3833 | echo "e2e OK (28 scenarios, 35 convergence points)" |
test/ptyclient.zig
| Old | New | ||
|---|---|---|---|
| @@ -39,8 +39,58 @@ const Expecter = struct { | |||
| 39 | } | 39 | } |
| 40 | }; | 40 | }; |
| 41 | 41 | ||
| 42 | /// Whether the client under test has put this "terminal" into bracketed | ||
| 43 | /// paste. Read off the same bytes the Expecter accumulates, because that | ||
| 44 | /// buffer is exactly what a real terminal would have received. | ||
| 45 | var bracketed_paste = false; | ||
| 46 | /// How far into that buffer noteModes has already looked. | ||
| 47 | var modes_scanned: usize = 0; | ||
| 48 | |||
| 49 | const BP_SET = "\x1b[?2004h"; | ||
| 50 | const BP_RESET = "\x1b[?2004l"; | ||
| 51 | |||
| 52 | /// Track the mode over the WHOLE capture rather than one chunk at a time, | ||
| 53 | /// for two reasons a per-chunk scan gets wrong: | ||
| 54 | /// | ||
| 55 | /// * both escapes can land in one read — nvim sets the mode and clears it | ||
| 56 | /// again either side of a shell escape, and 4096 bytes swallow both. The | ||
| 57 | /// mode the terminal is left in is whichever came LAST, not whichever | ||
| 58 | /// `indexOf` the author happened to write second. | ||
| 59 | /// * a read can split the 8-byte escape down the middle, and neither half | ||
| 60 | /// matches anything. Backing the scan up by 7 covers every such split, | ||
| 61 | /// and rescanning bytes is harmless: last-occurrence is idempotent. | ||
| 62 | fn noteModes(buf: []const u8) void { | ||
| 63 | const from = modes_scanned -| (BP_SET.len - 1); | ||
| 64 | const win = buf[from..]; | ||
| 65 | const set = std.mem.lastIndexOf(u8, win, BP_SET); | ||
| 66 | const reset = std.mem.lastIndexOf(u8, win, BP_RESET); | ||
| 67 | if (set) |s| { | ||
| 68 | bracketed_paste = if (reset) |r| s > r else true; | ||
| 69 | } else if (reset != null) { | ||
| 70 | bracketed_paste = false; | ||
| 71 | } | ||
| 72 | modes_scanned = buf.len; | ||
| 73 | } | ||
| 74 | |||
| 75 | /// ONE write, asserted: the client's scroll-key parser exact-matches a whole | ||
| 76 | /// read, so a short write here would silently turn one keystroke into two. | ||
| 77 | /// A bracketed paste goes the same way — its markers and its text are one | ||
| 78 | /// paste to the application, not three reads for the parser to race over. | ||
| 79 | fn writeWhole(master: std.posix.fd_t, bytes: []const u8, verb_no: usize) void { | ||
| 80 | const n = std.posix.write(master, bytes) catch |e| | ||
| 81 | fatal(EXIT_CHILD_DIED, "verb {d}: write to the client's pty failed: {s}", .{ verb_no, @errorName(e) }); | ||
| 82 | if (n != bytes.len) | ||
| 83 | fatal(EXIT_USAGE, "verb {d}: short write ({d} of {d}) — send payloads must fit one write", .{ verb_no, n, bytes.len }); | ||
| 84 | } | ||
| 85 | |||
| 42 | const Verb = union(enum) { | 86 | const Verb = union(enum) { |
| 43 | send: []u8, | 87 | send: []u8, |
| 88 | /// Like `send`, but wrapped in bracketed-paste markers IF the client has | ||
| 89 | /// asked this "terminal" for them. That condition is the whole point: the | ||
| 90 | /// fixture behaves the way a real terminal behaves rather than asserting | ||
| 91 | /// what the test wishes were true, so a scenario using `paste` fails when | ||
| 92 | /// the mode mirror regresses. | ||
| 93 | paste: []u8, | ||
| 44 | expect: struct { needle: []u8, deadline_ms: u64 }, | 94 | expect: struct { needle: []u8, deadline_ms: u64 }, |
| 45 | resize: struct { cols: u16, rows: u16 }, | 95 | resize: struct { cols: u16, rows: u16 }, |
| 46 | settle: struct { quiet_ms: u64, deadline_ms: u64 }, | 96 | settle: struct { quiet_ms: u64, deadline_ms: u64 }, |
| @@ -51,7 +101,7 @@ const Verb = union(enum) { | |||
| 51 | /// correct without anyone remembering to revisit it. | 101 | /// correct without anyone remembering to revisit it. |
| 52 | fn deinit(self: Verb, alloc: std.mem.Allocator) void { | 102 | fn deinit(self: Verb, alloc: std.mem.Allocator) void { |
| 53 | switch (self) { | 103 | switch (self) { |
| 54 | .send => |s| alloc.free(s), | 104 | .send, .paste => |s| alloc.free(s), |
| 55 | .expect => |e| alloc.free(e.needle), | 105 | .expect => |e| alloc.free(e.needle), |
| 56 | .resize, .settle, .waitexit => {}, | 106 | .resize, .settle, .waitexit => {}, |
| 57 | } | 107 | } |
| @@ -71,6 +121,8 @@ fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb { | |||
| 71 | const rest = line[sp + 1 ..]; | 121 | const rest = line[sp + 1 ..]; |
| 72 | if (std.mem.eql(u8, verb, "send")) { | 122 | if (std.mem.eql(u8, verb, "send")) { |
| 73 | return .{ .send = try decodeEscapes(alloc, rest) }; | 123 | return .{ .send = try decodeEscapes(alloc, rest) }; |
| 124 | } else if (std.mem.eql(u8, verb, "paste")) { | ||
| 125 | return .{ .paste = try decodeEscapes(alloc, rest) }; | ||
| 74 | } else if (std.mem.eql(u8, verb, "expect")) { | 126 | } else if (std.mem.eql(u8, verb, "expect")) { |
| 75 | const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return error.BadVerb; | 127 | const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return error.BadVerb; |
| 76 | // An empty needle matches instantly at any cursor — a check that | 128 | // An empty needle matches instantly at any cursor — a check that |
| @@ -139,6 +191,7 @@ fn drain(alloc: std.mem.Allocator, pty: *Pty, out: std.fs.File, exp: *Expecter) | |||
| 139 | if (n == 0) return false; | 191 | if (n == 0) return false; |
| 140 | try out.writeAll(buf[0..n]); | 192 | try out.writeAll(buf[0..n]); |
| 141 | try exp.feed(alloc, buf[0..n]); | 193 | try exp.feed(alloc, buf[0..n]); |
| 194 | noteModes(exp.buf.items); | ||
| 142 | } | 195 | } |
| 143 | } | 196 | } |
| 144 | 197 | ||
| @@ -236,14 +289,18 @@ pub fn main() !void { | |||
| 236 | defer verb.deinit(alloc); | 289 | defer verb.deinit(alloc); |
| 237 | verb_no += 1; | 290 | verb_no += 1; |
| 238 | switch (verb) { | 291 | switch (verb) { |
| 239 | .send => |bytes| { | 292 | .send => |bytes| writeWhole(pty.master, bytes, verb_no), |
| 240 | // ONE write, asserted: the client's scroll-key parser | 293 | .paste => |text| { |
| 241 | // exact-matches a whole read, so a short write here would | 294 | // Bracket ONLY when this "terminal" has been told to. A |
| 242 | // silently turn one keystroke into two. | 295 | // fixture that always bracketed would pass whether or not |
| 243 | const n = std.posix.write(pty.master, bytes) catch |e| | 296 | // the client ever mirrored the mode, which is the one thing |
| 244 | fatal(EXIT_CHILD_DIED, "verb {d}: write to the client's pty failed: {s}", .{ verb_no, @errorName(e) }); | 297 | // a paste scenario exists to measure. |
| 245 | if (n != bytes.len) | 298 | const framed = if (bracketed_paste) |
| 246 | fatal(EXIT_USAGE, "verb {d}: short write ({d} of {d}) — send payloads must fit one write", .{ verb_no, n, bytes.len }); | 299 | try std.mem.concat(alloc, u8, &.{ "\x1b[200~", text, "\x1b[201~" }) |
| 300 | else | ||
| 301 | try alloc.dupe(u8, text); | ||
| 302 | defer alloc.free(framed); | ||
| 303 | writeWhole(pty.master, framed, verb_no); | ||
| 247 | }, | 304 | }, |
| 248 | .expect => |x| { | 305 | .expect => |x| { |
| 249 | const start = std.time.milliTimestamp(); | 306 | const start = std.time.milliTimestamp(); |
| @@ -436,6 +493,43 @@ test "parseLine: verbs, spaces in payloads, comments" { | |||
| 436 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect two words later")); | 493 | try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect two words later")); |
| 437 | } | 494 | } |
| 438 | 495 | ||
| 496 | test "parseLine: paste is a verb and its payload keeps its spaces" { | ||
| 497 | const alloc = std.testing.allocator; | ||
| 498 | const v = (try parseLine(alloc, "paste a = 1")).?; | ||
| 499 | defer v.deinit(alloc); | ||
| 500 | try std.testing.expectEqualStrings("a = 1", v.paste); | ||
| 501 | } | ||
| 502 | |||
| 503 | test "noteModes: the LAST escape in the window wins, split reads included" { | ||
| 504 | bracketed_paste = false; | ||
| 505 | modes_scanned = 0; | ||
| 506 | defer { | ||
| 507 | bracketed_paste = false; | ||
| 508 | modes_scanned = 0; | ||
| 509 | } | ||
| 510 | |||
| 511 | noteModes("nvim starting \x1b[?2004h"); | ||
| 512 | try std.testing.expect(bracketed_paste); | ||
| 513 | |||
| 514 | // Both in one read, set last: a per-chunk scan whose reset check ran | ||
| 515 | // second would report the opposite. | ||
| 516 | noteModes("nvim starting \x1b[?2004h ...shell out \x1b[?2004l back \x1b[?2004h"); | ||
| 517 | try std.testing.expect(bracketed_paste); | ||
| 518 | |||
| 519 | noteModes("nvim starting \x1b[?2004h ...shell out \x1b[?2004l back \x1b[?2004h quit \x1b[?2004l"); | ||
| 520 | try std.testing.expect(!bracketed_paste); | ||
| 521 | |||
| 522 | // Split down the middle of the escape: neither half matches on its own, | ||
| 523 | // and only the backed-up rescan sees the whole of it. | ||
| 524 | bracketed_paste = false; | ||
| 525 | modes_scanned = 0; | ||
| 526 | const whole = "abc\x1b[?2004habc"; | ||
| 527 | noteModes(whole[0 .. whole.len - 5]); // cuts inside the escape | ||
| 528 | try std.testing.expect(!bracketed_paste); | ||
| 529 | noteModes(whole); | ||
| 530 | try std.testing.expect(bracketed_paste); | ||
| 531 | } | ||
| 532 | |||
| 439 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 533 | // Forces semantic analysis of every pub decl under `zig build test`, so an |
| 440 | // unreferenced decl must at least compile (the silent-module-loss hazard, | 534 | // unreferenced decl must at least compile (the silent-module-loss hazard, |
| 441 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | 535 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. |