a73x

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());
}