summaryrefslogtreecommitdiff
path: root/test/snapshot.zig
diff options
context:
space:
mode:
Diffstat (limited to 'test/snapshot.zig')
-rw-r--r--test/snapshot.zig553
1 files changed, 553 insertions, 0 deletions
diff --git a/test/snapshot.zig b/test/snapshot.zig
new file mode 100644
index 00000000..c1bf03c0
--- /dev/null
+++ b/test/snapshot.zig
@@ -0,0 +1,553 @@
+// pardes-snap: drive a pardes binary with a scripted input trace in a pty and
+// capture the rendered grid at named sync points. The same script run against
+// the prototype and the rewrite must produce byte-identical captures — this is
+// the old-vs-new parity oracle for the rewrite.
+//
+// zig build snap run all snapshots/*.snap, diff goldens
+// zig build snap -- --update regenerate goldens from this binary
+// zig build snap -- snapshots/boot.snap one script
+//
+// Script commands (line-oriented, # comments):
+// file <name> <content> create file in the script's cwd (before start)
+// lines <name> <n> create file with n numbered lines
+// dirmk <name> create a subdirectory
+// start <rows> <cols> [arg] fork the app in a pty (optional extra CLI arg)
+// wait <ms> <needle...> pump until needle appears on the grid (fails hard)
+// settle <ms> pump for a fixed duration
+// stable <quiet_ms> <timeout_ms> pump until the grid stops changing
+// text <literal> send literal bytes
+// send <escaped> send with \e \r \n \t \\ \xNN escapes
+// key <names...> enter esc tab bs space up down left right home end
+// pgup pgdn c-<ch> a-<ch> (after esc: settle >=600ms,
+// or vaxis parses the next byte as alt-<ch>)
+// press|release <left|middle|right> <col> <row> SGR mouse, 1-based
+// drag <btn> <col> <row> motion with button held
+// motion <col> <row> button-less motion (hover)
+// wheel <up|down> <col> <row>
+// resize <rows> <cols>
+// snap <label> capture grid text + cursor
+// snapstyle <label> capture per-cell style runs (fg,bg,attrs)
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const ghostty_vt = @import("ghostty-vt");
+const eh = @import("e2e_harness.zig");
+
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+
+const SNAP_BASE = "/tmp/pardes-snap";
+const gpa = std.heap.page_allocator;
+
+pub fn main(init: std.process.Init) !void {
+ var arena_state = std.heap.ArenaAllocator.init(gpa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+
+ // argv: exe-under-test, then flags/scripts. All paths made absolute up
+ // front because runScript chdirs into a per-script workdir.
+ var cwd_buf: [4096]u8 = undefined;
+ const cwd_rc: isize = @bitCast(linux.getcwd(&cwd_buf, cwd_buf.len));
+ if (cwd_rc <= 0) fatal("getcwd failed", .{});
+ const orig_cwd = cwd_buf[0..@intCast(cwd_rc - 1)];
+
+ var exe: ?[]const u8 = null;
+ var update = false;
+ var scripts: std.ArrayList([]const u8) = .empty;
+ for (args[1..]) |a| {
+ if (std.mem.eql(u8, a, "--update")) {
+ update = true;
+ } else if (exe == null) {
+ exe = try absPath(arena, orig_cwd, a);
+ } else {
+ try scripts.append(arena, try absPath(arena, orig_cwd, a));
+ }
+ }
+ if (exe == null) fatal("usage: pardes-snap <exe> [scripts...] [--update]", .{});
+ const exe_z = try arena.dupeZ(u8, exe.?);
+
+ if (scripts.items.len == 0) {
+ try listSnaps(arena, try absPath(arena, orig_cwd, "test/snapshots"), &scripts);
+ std.mem.sort([]const u8, scripts.items, {}, struct {
+ fn lt(_: void, x: []const u8, y: []const u8) bool {
+ return std.mem.lessThan(u8, x, y);
+ }
+ }.lt);
+ }
+ if (scripts.items.len == 0) fatal("no .snap scripts found", .{});
+
+ var failed: usize = 0;
+ for (scripts.items) |script_path| {
+ const stem = std.fs.path.stem(script_path);
+ const base_path = script_path[0 .. script_path.len - ".snap".len];
+ const golden_path = try std.fmt.allocPrint(arena, "{s}.golden", .{base_path});
+ const out = runScript(arena, exe_z, script_path, stem) catch |e| {
+ std.debug.print("FAIL {s}: script error {s}\n", .{ stem, @errorName(e) });
+ failed += 1;
+ continue;
+ };
+ if (update) {
+ try eh.writeFile(try arena.dupeZ(u8, golden_path), out);
+ std.debug.print("UPDATED {s} ({d} bytes)\n", .{ stem, out.len });
+ } else {
+ const golden = readFileAlloc(arena, golden_path) catch {
+ std.debug.print("FAIL {s}: no golden (run with -- --update)\n", .{stem});
+ failed += 1;
+ continue;
+ };
+ if (std.mem.eql(u8, golden, out)) {
+ std.debug.print("PASS {s}\n", .{stem});
+ } else {
+ const actual_path = try std.fmt.allocPrint(arena, "{s}.actual", .{base_path});
+ try eh.writeFile(try arena.dupeZ(u8, actual_path), out);
+ std.debug.print("FAIL {s}: differs from golden (actual written to {s})\n", .{ stem, actual_path });
+ printFirstDiff(golden, out);
+ failed += 1;
+ }
+ }
+ }
+ if (failed > 0) {
+ std.debug.print("{d}/{d} snapshot scripts FAILED\n", .{ failed, scripts.items.len });
+ std.process.exit(1);
+ }
+ std.debug.print("all {d} snapshot scripts ok\n", .{scripts.items.len});
+}
+
+fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8, stem: []const u8) ![]u8 {
+ const src = try readFileAlloc(arena, script_path);
+
+ // Fixed, recreated-per-run workdir: its PATH shows up in pane tags, so it
+ // must be identical across runs and across old/new binaries.
+ const base = try std.fmt.allocPrint(arena, "{s}/{s}", .{ SNAP_BASE, stem });
+ rmrf(base);
+ try mkdir(arena, SNAP_BASE, true);
+ try mkdir(arena, base, false);
+ const home = try std.fmt.allocPrint(arena, "{s}/home", .{base});
+ const work = try std.fmt.allocPrint(arena, "{s}/cwd", .{base});
+ try mkdir(arena, home, false);
+ try mkdir(arena, work, false);
+ // deterministic shell: fixed prompt, no history, no user rc leakage
+ try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/.bashrc", .{home}, 0), "PS1='$ '\nHISTFILE=\n");
+ _ = setenv("HOME", try arena.dupeZ(u8, home), 1);
+ _ = setenv("TERM", "xterm-256color", 1);
+ _ = setenv("LC_ALL", "C", 1);
+ if (chdirRc(try arena.dupeZ(u8, work)) < 0) return error.ChdirFailed;
+
+ var out: std.ArrayList(u8) = .empty;
+ var h: ?eh.Harness = null;
+ defer if (h) |*hp| hp.deinit();
+
+ var lines_it = std.mem.splitScalar(u8, src, '\n');
+ var lineno: usize = 0;
+ while (lines_it.next()) |raw_line| {
+ lineno += 1;
+ const line = std.mem.trim(u8, raw_line, " \t\r");
+ if (line.len == 0 or line[0] == '#') continue;
+ var tok = std.mem.tokenizeScalar(u8, line, ' ');
+ const cmd = tok.next().?;
+ errdefer std.debug.print(" at {s}:{d}: {s}\n", .{ script_path, lineno, line });
+
+ if (std.mem.eql(u8, cmd, "file")) {
+ const name = tok.next() orelse return error.BadScript;
+ try eh.writeFile(try arena.dupeZ(u8, name), try unescape(arena, tok.rest()));
+ } else if (std.mem.eql(u8, cmd, "lines")) {
+ const name = tok.next() orelse return error.BadScript;
+ const n = try std.fmt.parseInt(usize, tok.next() orelse return error.BadScript, 10);
+ var buf: std.ArrayList(u8) = .empty;
+ for (1..n + 1) |i| try buf.appendSlice(arena, try std.fmt.allocPrint(arena, "line {d}\n", .{i}));
+ try eh.writeFile(try arena.dupeZ(u8, name), buf.items);
+ } else if (std.mem.eql(u8, cmd, "ppmdiag")) {
+ // the 64x64 diagonal-split test PPM (white/blue) from the old e2e
+ const name = tok.next() orelse return error.BadScript;
+ var ppm: std.ArrayList(u8) = .empty;
+ try ppm.appendSlice(arena, "P6\n64 64\n255\n");
+ var py: usize = 0;
+ while (py < 64) : (py += 1) {
+ var px: usize = 0;
+ while (px < 64) : (px += 1)
+ try ppm.appendSlice(arena, if (px + py < 64) &[_]u8{ 255, 255, 255 } else &[_]u8{ 0, 0, 255 });
+ }
+ try eh.writeFile(try arena.dupeZ(u8, name), ppm.items);
+ } else if (std.mem.eql(u8, cmd, "dirmk")) {
+ try mkdir(arena, tok.next() orelse return error.BadScript, false);
+ } else if (std.mem.eql(u8, cmd, "start")) {
+ const rows = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const cols = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ var extra: [6]?[*:0]const u8 = @splat(null);
+ var nextra: usize = 0;
+ while (tok.next()) |a| : (nextra += 1) {
+ if (nextra >= extra.len) return error.BadScript;
+ extra[nextra] = (try arena.dupeZ(u8, a)).ptr;
+ }
+ if (h) |*hp| hp.deinit(); // scripts may restart (e.g. dump then -l)
+ h = try eh.Harness.initArgv(gpa, exe_z.ptr, rows, cols, extra[0..nextra]);
+ } else if (std.mem.eql(u8, cmd, "snapfile")) {
+ const label = tok.next() orelse return error.BadScript;
+ const contents = try readFileAlloc(arena, tok.rest());
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== file {s}\n", .{label}));
+ // elide base64 payloads: they encode raw pty byte HISTORY, whose
+ // micro-timing (prompt redraw cycles) legitimately differs between
+ // implementations; the cleaned text fields are the contract.
+ var flines = std.mem.splitScalar(u8, contents, '\n');
+ while (flines.next()) |fl| {
+ if (std.mem.indexOf(u8, fl, "_b64 = \"") != null) {
+ const eq = std.mem.indexOf(u8, fl, "=").?;
+ try out.appendSlice(arena, fl[0 .. eq + 1]);
+ try out.appendSlice(arena, " <elided>\n");
+ } else {
+ try out.appendSlice(arena, fl);
+ try out.append(arena, '\n');
+ }
+ }
+ try out.appendSlice(arena, "== end file\n");
+ } else if (std.mem.eql(u8, cmd, "wait")) {
+ const ms = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
+ try live(&h).expectWaitFor(tok.rest(), ms, "snapshot wait");
+ } else if (std.mem.eql(u8, cmd, "settle")) {
+ try live(&h).pump(try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10));
+ } else if (std.mem.eql(u8, cmd, "stable")) {
+ const quiet = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
+ const timeout = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
+ try waitStable(live(&h), quiet, timeout);
+ } else if (std.mem.eql(u8, cmd, "text")) {
+ try live(&h).send(tok.rest());
+ } else if (std.mem.eql(u8, cmd, "send")) {
+ try live(&h).send(try unescape(arena, tok.rest()));
+ } else if (std.mem.eql(u8, cmd, "key")) {
+ while (tok.next()) |name| try live(&h).send(keyBytes(name) orelse return error.BadKey);
+ } else if (std.mem.eql(u8, cmd, "press") or std.mem.eql(u8, cmd, "release") or std.mem.eql(u8, cmd, "drag")) {
+ const btn = buttonCode(tok.next() orelse return error.BadScript) orelse return error.BadScript;
+ const col = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const b: u16 = if (std.mem.eql(u8, cmd, "drag")) btn + 32 else btn;
+ const fin: u8 = if (std.mem.eql(u8, cmd, "release")) 'm' else 'M';
+ try live(&h).send(try std.fmt.allocPrint(arena, "\x1b[<{d};{d};{d}{c}", .{ b, col, row, fin }));
+ } else if (std.mem.eql(u8, cmd, "motion")) {
+ const col = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ try live(&h).send(try std.fmt.allocPrint(arena, "\x1b[<35;{d};{d}M", .{ col, row }));
+ } else if (std.mem.eql(u8, cmd, "wheel")) {
+ const dir = tok.next() orelse return error.BadScript;
+ const b: u16 = if (std.mem.eql(u8, dir, "up")) 64 else 65;
+ const col = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ try live(&h).send(try std.fmt.allocPrint(arena, "\x1b[<{d};{d};{d}M", .{ b, col, row }));
+ } else if (std.mem.eql(u8, cmd, "resize")) {
+ const rows = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const cols = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ try live(&h).resize(rows, cols);
+ } else if (std.mem.eql(u8, cmd, "snap")) {
+ try snapText(arena, &out, live(&h), tok.rest());
+ } else if (std.mem.eql(u8, cmd, "snapstyle")) {
+ try snapStyle(arena, &out, live(&h), tok.rest());
+ } else {
+ std.debug.print("unknown command {s}\n", .{cmd});
+ return error.BadScript;
+ }
+ }
+ return out.items;
+}
+
+fn live(h: *?eh.Harness) *eh.Harness {
+ if (h.*) |*v| return v;
+ fatal("script used the app before `start`", .{});
+}
+
+/// Pump until the grid is unchanged for `quiet` ms (bounded by `timeout`).
+/// The one sync primitive that needs no needle — used before every snap so
+/// old and new binaries are compared at rest, not mid-repaint.
+fn waitStable(h: *eh.Harness, quiet: i64, timeout: i64) !void {
+ var total: i64 = 0;
+ var prev = try h.screenText();
+ defer h.gpa.free(prev);
+ while (total < timeout) {
+ try h.pump(quiet);
+ total += quiet;
+ const cur = try h.screenText();
+ if (std.mem.eql(u8, prev, cur)) {
+ h.gpa.free(cur);
+ return;
+ }
+ h.gpa.free(prev);
+ prev = cur;
+ }
+ return error.NeverStable;
+}
+
+fn snapText(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {
+ const text = try h.screenText();
+ defer h.gpa.free(text);
+ const c = h.cursor();
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== snap {s} grid={d}x{d} cursor={d},{d}\n", .{ label, h.cols, h.rows, c.x, c.y }));
+ var it = std.mem.splitScalar(u8, text, '\n');
+ var row: usize = 0;
+ while (row < h.rows) : (row += 1) {
+ const line = it.next() orelse "";
+ try out.append(arena, '|');
+ try out.appendSlice(arena, std.mem.trimEnd(u8, line, " "));
+ try out.append(arena, '\n');
+ }
+}
+
+/// Per-row style runs from the harness emulator: `|y: x0-x1 fg,bg,attrs ...`.
+/// fg/bg: d (default) | pN (palette) | #rrggbb. attrs: b f i k r v x + u U ~ . -
+/// for underline single/double/curly/dotted/dashed.
+fn snapStyle(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== style {s}\n", .{label}));
+ var y: u16 = 0;
+ while (y < h.rows) : (y += 1) {
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "|{d}:", .{y}));
+ var run_buf: [64]u8 = undefined;
+ var key_buf: [64]u8 = undefined;
+ var run_key: []const u8 = "";
+ var run_start: u16 = 0;
+ var x: u16 = 0;
+ while (x <= h.cols) : (x += 1) {
+ const key: []const u8 = if (x == h.cols) "\x00end" else cellKey(h, x, y, &key_buf);
+ if (x == 0) {
+ run_key = try arena.dupe(u8, key);
+ continue;
+ }
+ if (std.mem.eql(u8, key, run_key)) continue;
+ try out.appendSlice(arena, try std.fmt.bufPrint(&run_buf, " {d}-{d} ", .{ run_start, x - 1 }));
+ try out.appendSlice(arena, run_key);
+ run_key = try arena.dupe(u8, key);
+ run_start = x;
+ }
+ try out.append(arena, '\n');
+ }
+}
+
+fn cellKey(h: *eh.Harness, x: u16, y: u16, buf: *[64]u8) []const u8 {
+ const ci = h.term.screens.active.pages.getCell(.{ .viewport = .{ .x = x, .y = y } }) orelse return "d,d,";
+ if (ci.cell.wide == .spacer_tail) return "d,d,";
+ const style = ci.style();
+ var n: usize = 0;
+ var tmp: [16]u8 = undefined;
+ const fg = colorStr(style.fg_color, &tmp);
+ @memcpy(buf[n..][0..fg.len], fg);
+ n += fg.len;
+ buf[n] = ',';
+ n += 1;
+ var tmp2: [16]u8 = undefined;
+ const bg: []const u8 = switch (ci.cell.content_tag) {
+ .bg_color_palette => std.fmt.bufPrint(&tmp2, "p{d}", .{ci.cell.content.color_palette.data}) catch "?",
+ .bg_color_rgb => blk: {
+ const rgb = ci.cell.content.color_rgb;
+ break :blk std.fmt.bufPrint(&tmp2, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }) catch "?";
+ },
+ else => colorStr(style.bg_color, &tmp2),
+ };
+ @memcpy(buf[n..][0..bg.len], bg);
+ n += bg.len;
+ buf[n] = ',';
+ n += 1;
+ if (style.flags.bold) {
+ buf[n] = 'b';
+ n += 1;
+ }
+ if (style.flags.faint) {
+ buf[n] = 'f';
+ n += 1;
+ }
+ if (style.flags.italic) {
+ buf[n] = 'i';
+ n += 1;
+ }
+ if (style.flags.blink) {
+ buf[n] = 'k';
+ n += 1;
+ }
+ if (style.flags.inverse) {
+ buf[n] = 'r';
+ n += 1;
+ }
+ if (style.flags.invisible) {
+ buf[n] = 'v';
+ n += 1;
+ }
+ if (style.flags.strikethrough) {
+ buf[n] = 'x';
+ n += 1;
+ }
+ const ul: []const u8 = switch (style.flags.underline) {
+ .none => "",
+ .single => "u",
+ .double => "U",
+ .curly => "~",
+ .dotted => ".",
+ .dashed => "-",
+ };
+ @memcpy(buf[n..][0..ul.len], ul);
+ n += ul.len;
+ return buf[0..n];
+}
+
+fn colorStr(color: ghostty_vt.Style.Color, buf: *[16]u8) []const u8 {
+ return switch (color) {
+ .none => "d",
+ .palette => |idx| std.fmt.bufPrint(buf, "p{d}", .{idx}) catch "?",
+ .rgb => |rgb| std.fmt.bufPrint(buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }) catch "?",
+ };
+}
+
+fn keyBytes(name: []const u8) ?[]const u8 {
+ const map = .{
+ .{ "enter", "\r" }, .{ "esc", "\x1b" }, .{ "tab", "\t" },
+ .{ "bs", "\x7f" }, .{ "space", " " }, .{ "up", "\x1b[A" },
+ .{ "down", "\x1b[B" }, .{ "right", "\x1b[C" }, .{ "left", "\x1b[D" },
+ .{ "home", "\x1b[H" }, .{ "end", "\x1b[F" }, .{ "pgup", "\x1b[5~" },
+ .{ "pgdn", "\x1b[6~" },
+ };
+ inline for (map) |e| if (std.mem.eql(u8, name, e[0])) return e[1];
+ const S = struct {
+ var buf: [2]u8 = undefined;
+ };
+ if (name.len == 1) {
+ S.buf[0] = name[0];
+ return S.buf[0..1];
+ }
+ if (name.len == 3 and name[1] == '-') {
+ if (name[0] == 'c' and name[2] >= 'a' and name[2] <= 'z') {
+ S.buf[0] = name[2] - 'a' + 1;
+ return S.buf[0..1];
+ }
+ if (name[0] == 'a') {
+ S.buf = .{ 0x1b, name[2] };
+ return S.buf[0..2];
+ }
+ }
+ return null;
+}
+
+fn buttonCode(name: []const u8) ?u16 {
+ if (std.mem.eql(u8, name, "left")) return 0;
+ if (std.mem.eql(u8, name, "middle")) return 1;
+ if (std.mem.eql(u8, name, "right")) return 2;
+ return null;
+}
+
+fn unescape(arena: std.mem.Allocator, s: []const u8) ![]const u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ var i: usize = 0;
+ while (i < s.len) : (i += 1) {
+ if (s[i] != '\\' or i + 1 >= s.len) {
+ try buf.append(arena, s[i]);
+ continue;
+ }
+ i += 1;
+ switch (s[i]) {
+ 'e' => try buf.append(arena, 0x1b),
+ 'r' => try buf.append(arena, '\r'),
+ 'n' => try buf.append(arena, '\n'),
+ 't' => try buf.append(arena, '\t'),
+ '\\' => try buf.append(arena, '\\'),
+ 'x' => {
+ if (i + 2 >= s.len) return error.BadEscape;
+ try buf.append(arena, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16));
+ i += 2;
+ },
+ else => return error.BadEscape,
+ }
+ }
+ return buf.items;
+}
+
+fn absPath(arena: std.mem.Allocator, cwd: []const u8, p: []const u8) ![]const u8 {
+ if (p.len > 0 and p[0] == '/') return try arena.dupe(u8, p);
+ return try std.fmt.allocPrint(arena, "{s}/{s}", .{ cwd, p });
+}
+
+fn listSnaps(arena: std.mem.Allocator, dir_path: []const u8, scripts: *std.ArrayList([]const u8)) !void {
+ const dir_z = try arena.dupeZ(u8, dir_path);
+ const rc = linux.open(dir_z, .{ .ACCMODE = .RDONLY, .DIRECTORY = true }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) fatal("no snapshots/ dir at {s}", .{dir_path});
+ const fd: i32 = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var buf: [8192]u8 align(@alignOf(linux.dirent64)) = undefined;
+ while (true) {
+ const r = linux.getdents64(fd, &buf, buf.len);
+ const n: isize = @bitCast(r);
+ if (n <= 0) break;
+ var off: usize = 0;
+ while (off < @as(usize, @intCast(n))) {
+ const d: *align(1) linux.dirent64 = @ptrCast(&buf[off]);
+ const name = std.mem.span(@as([*:0]const u8, @ptrCast(&d.name)));
+ if (std.mem.endsWith(u8, name, ".snap"))
+ try scripts.append(arena, try std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, name }));
+ off += d.reclen;
+ }
+ }
+}
+
+fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 {
+ const path_z = try arena.dupeZ(u8, path);
+ const rc = linux.open(path_z, .{ .ACCMODE = .RDONLY }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return error.OpenFailed;
+ const fd: i32 = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ var chunk: [16384]u8 = undefined;
+ while (true) {
+ const r = linux.read(fd, &chunk, chunk.len);
+ const n: isize = @bitCast(r);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(arena, chunk[0..@intCast(n)]);
+ }
+ return buf.items;
+}
+
+fn mkdir(arena: std.mem.Allocator, path: []const u8, exist_ok: bool) !void {
+ const path_z = try arena.dupeZ(u8, path);
+ const n: isize = @bitCast(linux.mkdir(path_z, 0o755));
+ if (n < 0) {
+ if (exist_ok and n == -@as(isize, @intFromEnum(linux.E.EXIST))) return;
+ return error.MkdirFailed;
+ }
+}
+
+fn chdirRc(path_z: [:0]const u8) isize {
+ return @bitCast(linux.chdir(path_z.ptr));
+}
+
+fn rmrf(path: []const u8) void {
+ var path_buf: [4096:0]u8 = undefined;
+ if (path.len >= path_buf.len) return;
+ @memcpy(path_buf[0..path.len], path);
+ path_buf[path.len] = 0;
+ const pid: isize = @bitCast(linux.fork());
+ if (pid == 0) {
+ const argv: [4:null]?[*:0]const u8 = .{ "rm", "-rf", @as([*:0]const u8, @ptrCast(&path_buf)), null };
+ _ = execvp("rm", &argv);
+ linux.exit_group(127);
+ }
+ if (pid < 0) return;
+ var status: u32 = 0;
+ _ = linux.wait4(@intCast(pid), &status, 0, null);
+}
+
+fn printFirstDiff(golden: []const u8, actual: []const u8) void {
+ var gi = std.mem.splitScalar(u8, golden, '\n');
+ var ai = std.mem.splitScalar(u8, actual, '\n');
+ var n: usize = 1;
+ while (true) : (n += 1) {
+ const g = gi.next();
+ const a = ai.next();
+ if (g == null and a == null) return;
+ if (g != null and a != null and std.mem.eql(u8, g.?, a.?)) continue;
+ std.debug.print(" first diff at golden line {d}:\n -{s}\n +{s}\n", .{ n, g orelse "<eof>", a orelse "<eof>" });
+ return;
+ }
+}
+
+fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
+ std.debug.print(fmt ++ "\n", args);
+ std.process.exit(1);
+}