98df7072
test(ptyclient): the terminal owns the mode, and the bare case is pinned
a73x 2026-08-15 17:50
Commit message
test/ptyclient.zig
| Old | New | ||
|---|---|---|---|
| @@ -13,17 +13,76 @@ const Pty = @import("pty").Pty; | |||
| 13 | const script = @import("script"); | 13 | const script = @import("script"); |
| 14 | const decodeEscapes = script.decodeEscapes; | 14 | const decodeEscapes = script.decodeEscapes; |
| 15 | 15 | ||
| 16 | /// Accumulates everything read off the master and matches needles with | 16 | const BP_SET = "\x1b[?2004h"; |
| 17 | /// expect(1) semantics: the search starts at a cursor, and a match | 17 | const BP_RESET = "\x1b[?2004l"; |
| 18 | /// advances the cursor past itself. Without the cursor, a needle painted | 18 | |
| 19 | /// BEFORE the previous verb would satisfy this one — tp1's post-scroll | 19 | /// Everything read off the master, which is also everything a real terminal |
| 20 | /// expect would pass on bytes from the initial snapshot. | 20 | /// on the other end of this client would have received. Two things are read |
| 21 | /// out of it: needles, with expect(1) semantics — the search starts at a | ||
| 22 | /// cursor and a match advances the cursor past itself, because without that | ||
| 23 | /// a needle painted BEFORE the previous verb would satisfy this one (tp1's | ||
| 24 | /// post-scroll expect would pass on bytes from the initial snapshot) — and | ||
| 25 | /// the bracketed-paste mode, which is what makes `paste` behave the way a | ||
| 26 | /// terminal behaves rather than the way the test wishes it would. | ||
| 27 | /// | ||
| 28 | /// The mode lives HERE rather than at file scope because tracking it needs | ||
| 29 | /// an index into this exact buffer as it grows. As a free function taking | ||
| 30 | /// bytes it had a foot-gun in its signature: hand it a chunk shorter than | ||
| 31 | /// the cursor and the slice is out of bounds, from a call site that looks | ||
| 32 | /// correct. Owning both makes that misuse unspellable, and gives every test | ||
| 33 | /// a fresh terminal instead of globals to reset. | ||
| 21 | const Expecter = struct { | 34 | const Expecter = struct { |
| 22 | buf: std.ArrayList(u8) = .empty, | 35 | buf: std.ArrayList(u8) = .empty, |
| 23 | cursor: usize = 0, | 36 | cursor: usize = 0, |
| 37 | /// Whether the client under test has put this "terminal" into bracketed | ||
| 38 | /// paste — set by the escapes it wrote, never by what a scenario assumes. | ||
| 39 | bracketed_paste: bool = false, | ||
| 40 | /// How far into `buf` noteBracketedPaste has already looked. | ||
| 41 | modes_scanned: usize = 0, | ||
| 24 | 42 | ||
| 25 | fn feed(self: *Expecter, alloc: std.mem.Allocator, bytes: []const u8) !void { | 43 | fn feed(self: *Expecter, alloc: std.mem.Allocator, bytes: []const u8) !void { |
| 26 | try self.buf.appendSlice(alloc, bytes); | 44 | try self.buf.appendSlice(alloc, bytes); |
| 45 | self.noteBracketedPaste(); | ||
| 46 | } | ||
| 47 | |||
| 48 | /// Track the mode over the WHOLE capture rather than one chunk at a time, | ||
| 49 | /// for two reasons a per-chunk scan gets wrong: | ||
| 50 | /// | ||
| 51 | /// * both escapes can land in one read — nvim sets the mode and clears | ||
| 52 | /// it again either side of a shell escape, and 4096 bytes swallow | ||
| 53 | /// both. The mode the terminal is left in is whichever came LAST, not | ||
| 54 | /// whichever `indexOf` the author happened to write second. | ||
| 55 | /// * a read can split the 8-byte escape down the middle, and neither | ||
| 56 | /// half matches anything. Backing the scan up by 7 covers every such | ||
| 57 | /// split, and rescanning bytes is harmless: last-occurrence is | ||
| 58 | /// idempotent. | ||
| 59 | /// | ||
| 60 | /// A window holding neither escape changes nothing: the mode is sticky | ||
| 61 | /// until the client says otherwise, which is what a real terminal does. | ||
| 62 | fn noteBracketedPaste(self: *Expecter) void { | ||
| 63 | const win = self.buf.items[self.modes_scanned -| (BP_SET.len - 1)..]; | ||
| 64 | const set = std.mem.lastIndexOf(u8, win, BP_SET); | ||
| 65 | const reset = std.mem.lastIndexOf(u8, win, BP_RESET); | ||
| 66 | if (set) |s| { | ||
| 67 | self.bracketed_paste = if (reset) |r| s > r else true; | ||
| 68 | } else if (reset != null) { | ||
| 69 | self.bracketed_paste = false; | ||
| 70 | } | ||
| 71 | self.modes_scanned = self.buf.items.len; | ||
| 72 | } | ||
| 73 | |||
| 74 | /// A paste as this "terminal" would deliver it: markers only when the | ||
| 75 | /// client has asked for them. Caller owns the result. | ||
| 76 | /// | ||
| 77 | /// Extracted from the verb loop so the NEGATIVE case has somewhere to be | ||
| 78 | /// pinned. Bracketing unconditionally would sail through the e2e paste | ||
| 79 | /// scenario — nvim receives valid input either way — and the suite would | ||
| 80 | /// go on reporting green while the fixture had stopped modelling a | ||
| 81 | /// terminal at all. That is this branch's whole worry, so it gets a test | ||
| 82 | /// rather than a comment. | ||
| 83 | fn framePaste(self: *const Expecter, alloc: std.mem.Allocator, text: []const u8) ![]u8 { | ||
| 84 | if (!self.bracketed_paste) return alloc.dupe(u8, text); | ||
| 85 | return std.mem.concat(alloc, u8, &.{ "\x1b[200~", text, "\x1b[201~" }); | ||
| 27 | } | 86 | } |
| 28 | 87 | ||
| 29 | fn match(self: *Expecter, needle: []const u8) bool { | 88 | fn match(self: *Expecter, needle: []const u8) bool { |
| @@ -39,39 +98,6 @@ const Expecter = struct { | |||
| 39 | } | 98 | } |
| 40 | }; | 99 | }; |
| 41 | 100 | ||
| 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 | 101 | /// 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. | 102 | /// 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 | 103 | /// A bracketed paste goes the same way — its markers and its text are one |
| @@ -79,8 +105,11 @@ fn noteModes(buf: []const u8) void { | |||
| 79 | fn writeWhole(master: std.posix.fd_t, bytes: []const u8, verb_no: usize) void { | 105 | fn writeWhole(master: std.posix.fd_t, bytes: []const u8, verb_no: usize) void { |
| 80 | const n = std.posix.write(master, bytes) catch |e| | 106 | 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) }); | 107 | fatal(EXIT_CHILD_DIED, "verb {d}: write to the client's pty failed: {s}", .{ verb_no, @errorName(e) }); |
| 108 | // Named for the payload most likely to hit it: a paste is the one that | ||
| 109 | // grows, and the ceiling is the pty's buffer, not anything this fixture | ||
| 110 | // chose. | ||
| 82 | if (n != bytes.len) | 111 | 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 }); | 112 | fatal(EXIT_USAGE, "verb {d}: short write ({d} of {d}) — a payload must fit one write into the pty's buffer", .{ verb_no, n, bytes.len }); |
| 84 | } | 113 | } |
| 85 | 114 | ||
| 86 | const Verb = union(enum) { | 115 | const Verb = union(enum) { |
| @@ -191,7 +220,6 @@ fn drain(alloc: std.mem.Allocator, pty: *Pty, out: std.fs.File, exp: *Expecter) | |||
| 191 | if (n == 0) return false; | 220 | if (n == 0) return false; |
| 192 | try out.writeAll(buf[0..n]); | 221 | try out.writeAll(buf[0..n]); |
| 193 | try exp.feed(alloc, buf[0..n]); | 222 | try exp.feed(alloc, buf[0..n]); |
| 194 | noteModes(exp.buf.items); | ||
| 195 | } | 223 | } |
| 196 | } | 224 | } |
| 197 | 225 | ||
| @@ -291,14 +319,7 @@ pub fn main() !void { | |||
| 291 | switch (verb) { | 319 | switch (verb) { |
| 292 | .send => |bytes| writeWhole(pty.master, bytes, verb_no), | 320 | .send => |bytes| writeWhole(pty.master, bytes, verb_no), |
| 293 | .paste => |text| { | 321 | .paste => |text| { |
| 294 | // Bracket ONLY when this "terminal" has been told to. A | 322 | const framed = try exp.framePaste(alloc, text); |
| 295 | // fixture that always bracketed would pass whether or not | ||
| 296 | // the client ever mirrored the mode, which is the one thing | ||
| 297 | // a paste scenario exists to measure. | ||
| 298 | const framed = if (bracketed_paste) | ||
| 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); | 323 | defer alloc.free(framed); |
| 303 | writeWhole(pty.master, framed, verb_no); | 324 | writeWhole(pty.master, framed, verb_no); |
| 304 | }, | 325 | }, |
| @@ -500,34 +521,62 @@ test "parseLine: paste is a verb and its payload keeps its spaces" { | |||
| 500 | try std.testing.expectEqualStrings("a = 1", v.paste); | 521 | try std.testing.expectEqualStrings("a = 1", v.paste); |
| 501 | } | 522 | } |
| 502 | 523 | ||
| 503 | test "noteModes: the LAST escape in the window wins, split reads included" { | 524 | test "Expecter: the LAST bracketed-paste escape wins, and the mode is sticky" { |
| 504 | bracketed_paste = false; | 525 | const alloc = std.testing.allocator; |
| 505 | modes_scanned = 0; | 526 | var e: Expecter = .{}; |
| 506 | defer { | 527 | defer e.deinit(alloc); |
| 507 | bracketed_paste = false; | 528 | |
| 508 | modes_scanned = 0; | 529 | try std.testing.expect(!e.bracketed_paste); |
| 509 | } | 530 | try e.feed(alloc, "nvim starting \x1b[?2004h"); |
| 531 | try std.testing.expect(e.bracketed_paste); | ||
| 532 | |||
| 533 | // A read carrying neither escape leaves the mode alone — the client says | ||
| 534 | // when it changes, and silence is not a change. | ||
| 535 | try e.feed(alloc, "rows and rows of ordinary paint"); | ||
| 536 | try std.testing.expect(e.bracketed_paste); | ||
| 537 | |||
| 538 | // Both escapes in ONE read, set last: a per-chunk scan whose reset check | ||
| 539 | // happened to be written second would report the opposite. | ||
| 540 | try e.feed(alloc, " shell out \x1b[?2004l and back \x1b[?2004h"); | ||
| 541 | try std.testing.expect(e.bracketed_paste); | ||
| 542 | |||
| 543 | try e.feed(alloc, " out \x1b[?2004h again then quit \x1b[?2004l"); | ||
| 544 | try std.testing.expect(!e.bracketed_paste); | ||
| 545 | } | ||
| 546 | |||
| 547 | test "Expecter: a set escape split across two reads still registers" { | ||
| 548 | const alloc = std.testing.allocator; | ||
| 549 | var e: Expecter = .{}; | ||
| 550 | defer e.deinit(alloc); | ||
| 551 | // The 8-byte escape cut down the middle: neither half matches anything, | ||
| 552 | // so only the backed-up rescan of the second feed can see it. | ||
| 553 | try e.feed(alloc, "paint\x1b[?200"); | ||
| 554 | try std.testing.expect(!e.bracketed_paste); | ||
| 555 | try e.feed(alloc, "4hmore paint"); | ||
| 556 | try std.testing.expect(e.bracketed_paste); | ||
| 557 | } | ||
| 558 | |||
| 559 | test "Expecter: framePaste brackets only what the client asked to be bracketed" { | ||
| 560 | const alloc = std.testing.allocator; | ||
| 561 | var e: Expecter = .{}; | ||
| 562 | defer e.deinit(alloc); | ||
| 510 | 563 | ||
| 511 | noteModes("nvim starting \x1b[?2004h"); | 564 | // The negative case, and the one that matters most: a fixture that |
| 512 | try std.testing.expect(bracketed_paste); | 565 | // bracketed unconditionally would still pass the e2e paste scenario, |
| 513 | 566 | // because nvim takes valid input either way. Only this can fail. | |
| 514 | // Both in one read, set last: a per-chunk scan whose reset check ran | 567 | const bare = try e.framePaste(alloc, "a = 1"); |
| 515 | // second would report the opposite. | 568 | defer alloc.free(bare); |
| 516 | noteModes("nvim starting \x1b[?2004h ...shell out \x1b[?2004l back \x1b[?2004h"); | 569 | try std.testing.expectEqualStrings("a = 1", bare); |
| 517 | try std.testing.expect(bracketed_paste); | 570 | |
| 518 | 571 | try e.feed(alloc, BP_SET); | |
| 519 | noteModes("nvim starting \x1b[?2004h ...shell out \x1b[?2004l back \x1b[?2004h quit \x1b[?2004l"); | 572 | const framed = try e.framePaste(alloc, "a = 1"); |
| 520 | try std.testing.expect(!bracketed_paste); | 573 | defer alloc.free(framed); |
| 521 | 574 | try std.testing.expectEqualStrings("\x1b[200~a = 1\x1b[201~", framed); | |
| 522 | // Split down the middle of the escape: neither half matches on its own, | 575 | |
| 523 | // and only the backed-up rescan sees the whole of it. | 576 | try e.feed(alloc, BP_RESET); |
| 524 | bracketed_paste = false; | 577 | const bare_again = try e.framePaste(alloc, "a = 1"); |
| 525 | modes_scanned = 0; | 578 | defer alloc.free(bare_again); |
| 526 | const whole = "abc\x1b[?2004habc"; | 579 | try std.testing.expectEqualStrings("a = 1", bare_again); |
| 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 | } | 580 | } |
| 532 | 581 | ||
| 533 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 582 | // Forces semantic analysis of every pub decl under `zig build test`, so an |