test/render.zig
Ref: Size: 7.0 KiB History
//! e2e render helper: replays a captured mux-client stdout stream through
//! its own ghostty-vt engine and prints the final grid in `mux d dump`'s
//! text format, so the suite can diff what the client painted against
//! what the daemon holds. Third helper beside rawmode/delaypipe.
const std = @import("std");
const Engine = @import("engine").Engine;
const alt_exit = "\x1b[?1049l";
/// The restore-boundary rule: a tty client's exit path leaves the
/// alternate screen, discarding the very grid under test. Feed everything
/// up to the LAST alt-exit and stop there — the grid at that moment is
/// what a human saw last (multiple enter/exit cycles: last exit wins). A
/// stream with no alt-exit (the non-tty client, or a client that died
/// before its first frame) is fed whole and renders as-is.
///
/// Scanning for the literal bytes is sound for streams the mux client
/// produced: session content never reaches stdout raw — it is repainted
/// from the replica as content-only rows — so the only 1049 sequences in
/// a capture are the client's own enter and exit.
fn feedForFinalGrid(eng: *Engine, stream: []const u8) void {
if (std.mem.lastIndexOf(u8, stream, alt_exit)) |i| {
eng.feed(stream[0..i]);
} else {
eng.feed(stream);
}
}
/// The first `n` lines of a plain dump, gone — the label-bar rows a tty
/// client owns and the daemon grid never held. Returns the tail of `s`,
/// or the empty slice when fewer than `n` lines exist.
fn dropTop(s: []const u8, n: usize) []const u8 {
var rest = s;
var left = n;
while (left > 0) : (left -= 1) {
const nl = std.mem.indexOfScalar(u8, rest, '\n') orelse return rest[rest.len..];
rest = rest[nl + 1 ..];
}
return rest;
}
fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void {
var off: usize = 0;
while (off < bytes.len) off += try std.posix.write(fd, bytes[off..]);
}
fn usage() u8 {
writeAll(
std.posix.STDERR_FILENO,
"usage: render [--cols N] [--rows M] [--vt] [--drop-top N] < client-stdout-capture\n",
) catch {};
return 2;
}
pub fn main() !u8 {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const alloc = gpa.allocator();
var cols: u16 = 80;
var rows: u16 = 24;
var vt_mode = false;
var drop_top: u16 = 0;
const args = try std.process.argsAlloc(alloc);
defer std.process.argsFree(alloc, args);
var i: usize = 1;
while (i < args.len) : (i += 1) {
const a = args[i];
if (std.mem.eql(u8, a, "--vt")) {
vt_mode = true;
} else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) {
i += 1;
cols = std.fmt.parseInt(u16, args[i], 10) catch return usage();
} else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) {
i += 1;
rows = std.fmt.parseInt(u16, args[i], 10) catch return usage();
} else if (std.mem.eql(u8, a, "--drop-top") and i + 1 < args.len) {
i += 1;
drop_top = std.fmt.parseInt(u16, args[i], 10) catch return usage();
} else {
return usage();
}
}
if (drop_top >= rows) return usage();
var stream: std.ArrayList(u8) = .empty;
defer stream.deinit(alloc);
var buf: [16 * 1024]u8 = undefined;
while (true) {
const n = try std.posix.read(std.posix.STDIN_FILENO, &buf);
if (n == 0) break;
try stream.appendSlice(alloc, buf[0..n]);
}
const eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
defer eng.deinit();
feedForFinalGrid(eng, stream.items);
// --drop-top cuts the label-bar rows a tty client paints above the
// session: the rows below stay byte-diffable against the daemon's
// shorter grid (Engine.dumpVtFrom pins the vt equivalence).
const out: []const u8 = if (vt_mode)
try eng.dumpVtFrom(alloc, drop_top)
else
try eng.dumpPlain(alloc);
defer alloc.free(out);
try writeAll(std.posix.STDOUT_FILENO, if (vt_mode) out else dropTop(out, drop_top));
// mux d dump appends one newline after the payload (main.zig dump());
// matching it exactly is what makes the two outputs diffable.
try writeAll(std.posix.STDOUT_FILENO, "\n");
return 0;
}
test "render: dropTop cuts whole leading lines and nothing else" {
// The plain half of --drop-top: a tty client's label bar is the first
// grid row, and the suite diffs the rows under it against the daemon.
try std.testing.expectEqualStrings("b\nc", dropTop("bar\nb\nc", 1));
try std.testing.expectEqualStrings("c", dropTop("bar\nb\nc", 2));
// Dropping everything (or more) leaves the empty grid, not a crash.
try std.testing.expectEqualStrings("", dropTop("bar\nb\nc", 3));
try std.testing.expectEqualStrings("", dropTop("bar\nb\nc", 9));
try std.testing.expectEqualStrings("bar\nb\nc", dropTop("bar\nb\nc", 0));
}
test "render: a stream that never touches the alternate screen renders as-is" {
const alloc = std.testing.allocator;
const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
defer e.deinit();
feedForFinalGrid(e, "plain text");
const s = try e.dumpPlain(alloc);
defer alloc.free(s);
try std.testing.expectEqualStrings("plain text", s);
}
test "render: the grid is snapshotted at alt-screen exit, not after it" {
const alloc = std.testing.allocator;
const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
defer e.deinit();
feedForFinalGrid(e, "\x1b[?1049hgrid under test\x1b[?1049lprimary junk");
const s = try e.dumpPlain(alloc);
defer alloc.free(s);
// The alt grid at the moment of exit — not the primary screen the
// exit would have revealed, and not the bytes painted after it.
try std.testing.expectEqualStrings("grid under test", s);
}
test "render: multiple alt cycles — the last exit wins" {
const alloc = std.testing.allocator;
const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
defer e.deinit();
feedForFinalGrid(
e,
"\x1b[?1049hfirst\x1b[?1049l\x1b[?1049hsecond\x1b[?1049ltrailing",
);
const s = try e.dumpPlain(alloc);
defer alloc.free(s);
try std.testing.expectEqualStrings("second", s);
}
test "render: styled state survives replay identically to a direct feed" {
const alloc = std.testing.allocator;
const a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
defer a.deinit();
const b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
defer b.deinit();
const styled = "\x1b[1;31mbold red\x1b[0m plain";
feedForFinalGrid(a, styled);
b.feed(styled);
const va = try a.dumpVt(alloc);
defer alloc.free(va);
const vb = try b.dumpVt(alloc);
defer alloc.free(vb);
try std.testing.expectEqualStrings(vb, va);
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
std.testing.refAllDeclsRecursive(@This());
}