a73x

37ec2628

test: render helper replays a client capture into a dump-format grid

a73x   2026-08-09 17:12

Commit message
test: render helper replays a client capture into a dump-format grid

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 }