37ec2628
test: render helper replays a client capture into a dump-format grid
a73x 2026-08-09 17:12
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -211,6 +211,17 @@ pub fn build(b: *std.Build) void { | |||
| 211 | .optimize = optimize, | 211 | .optimize = optimize, |
| 212 | }); | 212 | }); |
| 213 | 213 | ||
| 214 | // Replays a captured client stdout stream and prints the final grid in | ||
| 215 | // `muxd dump`'s formats — the client half of the M11 render-vs-dump | ||
| 216 | // convergence check. Imports the engine module so both sides of the | ||
| 217 | // diff go through the same ghostty-vt and the same formatter. | ||
| 218 | const render_mod = b.createModule(.{ | ||
| 219 | .root_source_file = b.path("test/render.zig"), | ||
| 220 | .target = target, | ||
| 221 | .optimize = optimize, | ||
| 222 | }); | ||
| 223 | render_mod.addImport("engine", engine_mod); | ||
| 224 | |||
| 214 | const exe_mod = b.createModule(.{ | 225 | const exe_mod = b.createModule(.{ |
| 215 | .root_source_file = b.path("src/main.zig"), | 226 | .root_source_file = b.path("src/main.zig"), |
| 216 | .target = target, | 227 | .target = target, |
| @@ -257,13 +268,18 @@ pub fn build(b: *std.Build) void { | |||
| 257 | delaypipe_exe.use_lld = true; | 268 | delaypipe_exe.use_lld = true; |
| 258 | b.installArtifact(delaypipe_exe); | 269 | b.installArtifact(delaypipe_exe); |
| 259 | 270 | ||
| 271 | const render_exe = b.addExecutable(.{ .name = "render", .root_module = render_mod }); | ||
| 272 | render_exe.use_llvm = true; | ||
| 273 | render_exe.use_lld = true; | ||
| 274 | b.installArtifact(render_exe); | ||
| 275 | |||
| 260 | const test_step = b.step("test", "Run unit tests"); | 276 | const test_step = b.step("test", "Run unit tests"); |
| 261 | // mux_mod and exe_mod are executable roots, but they carry the argument | 277 | // mux_mod and exe_mod are executable roots, but they carry the argument |
| 262 | // parsers, and a test that is never built is not a test. exe_mod's | 278 | // parsers, and a test that is never built is not a test. exe_mod's |
| 263 | // absence here was a live hazard recorded in decisions.md — muxd's | 279 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 264 | // entrypoint could grow tests that silently never ran, exactly as | 280 | // entrypoint could grow tests that silently never ran, exactly as |
| 265 | // mux_main.zig's five did before it was added. | 281 | // mux_main.zig's five did before it was added. |
| 266 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod }) |mod| { | 282 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, render_mod }) |mod| { |
| 267 | const t = b.addTest(.{ .root_module = mod }); | 283 | const t = b.addTest(.{ .root_module = mod }); |
| 268 | t.use_llvm = true; | 284 | t.use_llvm = true; |
| 269 | t.use_lld = true; | 285 | t.use_lld = true; |
| @@ -286,6 +302,7 @@ pub fn build(b: *std.Build) void { | |||
| 286 | // just produced rather than whatever is installed. | 302 | // just produced rather than whatever is installed. |
| 287 | e2e.addArtifactArg(rawmode_exe); | 303 | e2e.addArtifactArg(rawmode_exe); |
| 288 | e2e.addArtifactArg(delaypipe_exe); | 304 | e2e.addArtifactArg(delaypipe_exe); |
| 305 | e2e.addArtifactArg(render_exe); | ||
| 289 | const e2e_step = b.step("e2e", "Run end-to-end test"); | 306 | const e2e_step = b.step("e2e", "Run end-to-end test"); |
| 290 | e2e_step.dependOn(&e2e.step); | 307 | e2e_step.dependOn(&e2e.step); |
| 291 | 308 | ||
test/render.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,143 @@ | |||
| 1 | //! e2e render helper: replays a captured mux-client stdout stream through | ||
| 2 | //! its own ghostty-vt engine and prints the final grid in `muxd dump`'s | ||
| 3 | //! text format, so the suite can diff what the client painted against | ||
| 4 | //! what the daemon holds. Third helper beside rawmode/delaypipe. | ||
| 5 | const std = @import("std"); | ||
| 6 | const Engine = @import("engine").Engine; | ||
| 7 | |||
| 8 | const alt_exit = "\x1b[?1049l"; | ||
| 9 | |||
| 10 | /// The restore-boundary rule: a tty client's exit path leaves the | ||
| 11 | /// alternate screen, discarding the very grid under test. Feed everything | ||
| 12 | /// up to the LAST alt-exit and stop there — the grid at that moment is | ||
| 13 | /// what a human saw last (multiple enter/exit cycles: last exit wins). A | ||
| 14 | /// stream with no alt-exit (the non-tty client, or a client that died | ||
| 15 | /// before its first frame) is fed whole and renders as-is. | ||
| 16 | /// | ||
| 17 | /// Scanning for the literal bytes is sound for streams the mux client | ||
| 18 | /// produced: session content never reaches stdout raw — it is repainted | ||
| 19 | /// from the replica as content-only rows — so the only 1049 sequences in | ||
| 20 | /// a capture are the client's own enter and exit. | ||
| 21 | fn feedForFinalGrid(eng: *Engine, stream: []const u8) void { | ||
| 22 | if (std.mem.lastIndexOf(u8, stream, alt_exit)) |i| { | ||
| 23 | eng.feed(stream[0..i]); | ||
| 24 | } else { | ||
| 25 | eng.feed(stream); | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void { | ||
| 30 | var off: usize = 0; | ||
| 31 | while (off < bytes.len) off += try std.posix.write(fd, bytes[off..]); | ||
| 32 | } | ||
| 33 | |||
| 34 | fn usage() u8 { | ||
| 35 | writeAll( | ||
| 36 | std.posix.STDERR_FILENO, | ||
| 37 | "usage: render [--cols N] [--rows M] [--vt] < client-stdout-capture\n", | ||
| 38 | ) catch {}; | ||
| 39 | return 2; | ||
| 40 | } | ||
| 41 | |||
| 42 | pub fn main() !u8 { | ||
| 43 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 44 | defer _ = gpa.deinit(); | ||
| 45 | const alloc = gpa.allocator(); | ||
| 46 | |||
| 47 | var cols: u16 = 80; | ||
| 48 | var rows: u16 = 24; | ||
| 49 | var vt_mode = false; | ||
| 50 | |||
| 51 | const args = try std.process.argsAlloc(alloc); | ||
| 52 | defer std.process.argsFree(alloc, args); | ||
| 53 | var i: usize = 1; | ||
| 54 | while (i < args.len) : (i += 1) { | ||
| 55 | const a = args[i]; | ||
| 56 | if (std.mem.eql(u8, a, "--vt")) { | ||
| 57 | vt_mode = true; | ||
| 58 | } else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) { | ||
| 59 | i += 1; | ||
| 60 | cols = std.fmt.parseInt(u16, args[i], 10) catch return usage(); | ||
| 61 | } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) { | ||
| 62 | i += 1; | ||
| 63 | rows = std.fmt.parseInt(u16, args[i], 10) catch return usage(); | ||
| 64 | } else { | ||
| 65 | return usage(); | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | var stream: std.ArrayList(u8) = .empty; | ||
| 70 | defer stream.deinit(alloc); | ||
| 71 | var buf: [16 * 1024]u8 = undefined; | ||
| 72 | while (true) { | ||
| 73 | const n = try std.posix.read(std.posix.STDIN_FILENO, &buf); | ||
| 74 | if (n == 0) break; | ||
| 75 | try stream.appendSlice(alloc, buf[0..n]); | ||
| 76 | } | ||
| 77 | |||
| 78 | const eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows }); | ||
| 79 | defer eng.deinit(); | ||
| 80 | feedForFinalGrid(eng, stream.items); | ||
| 81 | |||
| 82 | const out: []const u8 = if (vt_mode) | ||
| 83 | try eng.dumpVt(alloc) | ||
| 84 | else | ||
| 85 | try eng.dumpPlain(alloc); | ||
| 86 | defer alloc.free(out); | ||
| 87 | try writeAll(std.posix.STDOUT_FILENO, out); | ||
| 88 | // muxd dump appends one newline after the payload (main.zig dump()); | ||
| 89 | // matching it exactly is what makes the two outputs diffable. | ||
| 90 | try writeAll(std.posix.STDOUT_FILENO, "\n"); | ||
| 91 | return 0; | ||
| 92 | } | ||
| 93 | |||
| 94 | test "render: a stream that never touches the alternate screen renders as-is" { | ||
| 95 | const alloc = std.testing.allocator; | ||
| 96 | const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 97 | defer e.deinit(); | ||
| 98 | feedForFinalGrid(e, "plain text"); | ||
| 99 | const s = try e.dumpPlain(alloc); | ||
| 100 | defer alloc.free(s); | ||
| 101 | try std.testing.expectEqualStrings("plain text", s); | ||
| 102 | } | ||
| 103 | |||
| 104 | test "render: the grid is snapshotted at alt-screen exit, not after it" { | ||
| 105 | const alloc = std.testing.allocator; | ||
| 106 | const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 107 | defer e.deinit(); | ||
| 108 | feedForFinalGrid(e, "\x1b[?1049hgrid under test\x1b[?1049lprimary junk"); | ||
| 109 | const s = try e.dumpPlain(alloc); | ||
| 110 | defer alloc.free(s); | ||
| 111 | // The alt grid at the moment of exit — not the primary screen the | ||
| 112 | // exit would have revealed, and not the bytes painted after it. | ||
| 113 | try std.testing.expectEqualStrings("grid under test", s); | ||
| 114 | } | ||
| 115 | |||
| 116 | test "render: multiple alt cycles — the last exit wins" { | ||
| 117 | const alloc = std.testing.allocator; | ||
| 118 | const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 119 | defer e.deinit(); | ||
| 120 | feedForFinalGrid( | ||
| 121 | e, | ||
| 122 | "\x1b[?1049hfirst\x1b[?1049l\x1b[?1049hsecond\x1b[?1049ltrailing", | ||
| 123 | ); | ||
| 124 | const s = try e.dumpPlain(alloc); | ||
| 125 | defer alloc.free(s); | ||
| 126 | try std.testing.expectEqualStrings("second", s); | ||
| 127 | } | ||
| 128 | |||
| 129 | test "render: styled state survives replay identically to a direct feed" { | ||
| 130 | const alloc = std.testing.allocator; | ||
| 131 | const a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 132 | defer a.deinit(); | ||
| 133 | const b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 134 | defer b.deinit(); | ||
| 135 | const styled = "\x1b[1;31mbold red\x1b[0m plain"; | ||
| 136 | feedForFinalGrid(a, styled); | ||
| 137 | b.feed(styled); | ||
| 138 | const va = try a.dumpVt(alloc); | ||
| 139 | defer alloc.free(va); | ||
| 140 | const vb = try b.dumpVt(alloc); | ||
| 141 | defer alloc.free(vb); | ||
| 142 | try std.testing.expectEqualStrings(vb, va); | ||
| 143 | } | ||