summaryrefslogtreecommitdiff
path: root/test/snapshot.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 11:05:21 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:28:58 -0300
commite5f9e172330bc4500995ddd5954ed94f94e07af6 (patch)
treeaa7c5079b4c2951cef5b5c15595d0b20bc3ae245 /test/snapshot.zig
parent6f48508aa08396bcf9dd4da2cab1d221bcc53f78 (diff)
downloadpardes-e5f9e172330bc4500995ddd5954ed94f94e07af6.tar.gz
pardes-e5f9e172330bc4500995ddd5954ed94f94e07af6.zip
tests: a capture is a delta and a click names its word, so a tagline edit stops rewriting the suite
Diffstat (limited to 'test/snapshot.zig')
-rw-r--r--test/snapshot.zig283
1 files changed, 223 insertions, 60 deletions
diff --git a/test/snapshot.zig b/test/snapshot.zig
index f2d7984c..7f8c4f21 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -48,6 +48,16 @@
// resize <rows> <cols>
// snap <label> capture grid text + cursor
// snapstyle <label> capture per-cell style runs (fg,bg,attrs)
+//
+// Any <col> above may be `@Word`, `@Word#2` or `@Word+4` instead of a number:
+// the column of a word on that row of the live grid (see resolveCol). Chrome
+// words move, and a frozen column silently clicks whatever moved into it.
+//
+// A capture is a DELTA against the previous capture of the same kind in the
+// same script: the first one (and any after a resize) is the whole screen as
+// `|<row>`, the rest are only the rows that changed, as `|<n>: <row>`. Before
+// this, every frame restated every row, so 82% of golden lines were a copy of
+// the line above and one added builtin word rewrote 78 goldens (see Prev).
const std = @import("std");
const libc = std.c;
const ghostty_vt = @import("ghostty-vt");
@@ -105,13 +115,14 @@ pub fn main(init: std.process.Init) !void {
script_count += 1;
}
}
- // Updating is the one run whose output becomes evidence instead of being
- // checked against it. The ordinary parallel pass can reject a transient
- // capture and retry it alone; update mode has no expected bytes to compare,
- // so it cannot know which capture needs that retry. Run it serially even if
- // --jobs was supplied: a slower intentional baseline refresh is cheaper
- // than blessing scheduler or shared-process startup noise as behavior.
- if (update) jobs = 1;
+ // Updating used to be serial, because update mode has no expected bytes and
+ // so cannot tell a transient capture from behaviour. Serial was a proxy for
+ // that, and an expensive one (77s against 6s). What it was reaching for is
+ // available directly: capture at the WIDEST probe settings the harness has
+ // (beCareful, as if every script had already failed once), then run the
+ // ordinary verify pass over what was just written. A golden that does not
+ // reproduce is reported instead of blessed, and the parallel pass keeps its
+ // retry machinery, which serial update never had.
var scripts = try arena.alloc([]const u8, script_count);
var script_i: usize = 0;
var saw_exe = false;
@@ -144,7 +155,13 @@ pub fn main(init: std.process.Init) !void {
_ = lockOrWait(SNAP_BASE ++ "/.run.lock", "another pardes-snap run");
const codes = try arena.alloc(u8, scripts.len);
- try runBatch(arena, exe_z, toBatch(arena, scripts), update, jobs, codes);
+ if (update) {
+ careful = true;
+ try runBatch(arena, exe_z, toBatch(arena, scripts), true, jobs, codes);
+ careful = false;
+ std.debug.print("verifying {d} regenerated golden(s)\n", .{scripts.len});
+ }
+ try runBatch(arena, exe_z, toBatch(arena, scripts), false, jobs, codes);
// A failure in the parallel pass is more often contention than regression:
// the app can lose a race with a kworker while 20 siblings fight for the
@@ -179,7 +196,7 @@ pub fn main(init: std.process.Init) !void {
std.debug.print("\n", .{});
careful = round > 0;
const recodes = try arena.alloc(u8, paths.len);
- try runBatch(arena, exe_z, paths, update, 1, recodes);
+ try runBatch(arena, exe_z, paths, false, 1, recodes);
for (retry, recodes) |i, c| codes[i] = c;
}
}
@@ -195,7 +212,11 @@ pub fn main(init: std.process.Init) !void {
}
}
if (failed > 0) {
- std.debug.print("{d}/{d} snapshot scripts FAILED\n", .{ failed, scripts.len });
+ // In update mode the golden was written and then failed to reproduce:
+ // the capture is not stable, so it is not a baseline.
+ std.debug.print("{d}/{d} snapshot scripts {s}\n", .{
+ failed, scripts.len, if (update) "UNSTABLE (regenerated, does not reproduce)" else "FAILED",
+ });
std.process.exit(1);
}
std.debug.print("all {d} snapshot scripts ok\n", .{scripts.len});
@@ -382,6 +403,8 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
var chunk_len: usize = 0;
var h: ?eh.Harness = null;
defer if (h) |*hp| hp.deinit();
+ // captures are deltas against the previous one of the same kind (see Prev)
+ var prev: Prev = .{};
var lines_it = std.mem.splitScalar(u8, src, '\n');
var lineno: usize = 0;
@@ -444,6 +467,9 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
extra[nextra] = (try arena.dupeZ(u8, a)).ptr;
}
if (h) |*hp| hp.deinit(); // scripts may restart (e.g. dump then -l)
+ // a new process is a new screen: capture it whole, or its first
+ // frame would be a delta against a dead instance's rows
+ prev = .{};
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;
@@ -469,29 +495,32 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
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 col_spec = tok.next() orelse return error.BadScript;
const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const col = try resolveCol(live(&h), col_spec, row);
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 col_spec = tok.next() orelse return error.BadScript;
const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const col = try resolveCol(live(&h), col_spec, row);
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 col_spec = tok.next() orelse return error.BadScript;
const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const col = try resolveCol(live(&h), col_spec, row);
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 appendChunk(chunks, &chunk_len, try snapText(arena, live(&h), tok.rest()));
+ try appendChunk(chunks, &chunk_len, try snapText(arena, live(&h), tok.rest(), &prev));
} else if (std.mem.eql(u8, cmd, "snapstyle")) {
- try appendChunk(chunks, &chunk_len, try snapStyle(arena, live(&h), tok.rest()));
+ try appendChunk(chunks, &chunk_len, try snapStyle(arena, live(&h), tok.rest(), &prev));
} else {
std.debug.print("unknown command {s}\n", .{cmd});
return error.BadScript;
@@ -679,9 +708,20 @@ fn waitStable(h: *eh.Harness, quiet: i64, timeout: i64) !void {
// mints a fresh pty). So the hold is a bet on kworker latency, and a loaded
// machine loses it. The parallel pass bets small; the serial re-run that decides
// the verdict bets big.
-var probe_after_ms: i64 = 10;
+//
+// Only the SUM is the safety margin: a return cannot happen before
+// `probe_after_ms + probe_hold_ms` after the last change either way. The split
+// is pure cost, and the two halves cost differently - the arm window is one
+// `poll()` sleep, while every `probe_every_ms` of hold is a full `treeAsleep()`
+// walk of the app's process tree. Measured across the suite: 796 armed probes,
+// 54 of them saw a later change, none later than 11ms at 20 jobs. That is the
+// scheduler-tick case. The number the sum is really sized for is bigger and
+// found by adversarial measurement: a theme transition sleeps
+// `animation.frame_ms` (16ms) between frames with the whole tree asleep, and
+// under `taskset -c 0,1` those gaps stretch to 57ms. 60ms total stays.
+var probe_after_ms: i64 = 50;
const probe_every_ms: i64 = 5;
-var probe_hold_ms: i64 = 50;
+var probe_hold_ms: i64 = 10;
/// Widen the probes for the re-run pass: nothing else is competing then, so the
/// extra wait is cheap, and its verdict is the one that gets reported.
@@ -771,51 +811,179 @@ fn stateKey(h: *eh.Harness) ![]u8 {
return std.fmt.allocPrint(h.gpa, "{d},{d},{x}\n{s}", .{ c.x, c.y, styles.final(), text });
}
-fn snapText(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8) ![]u8 {
+/// A mouse column: either a screen column, or `@Word` / `@Word#2` - the column
+/// of a WORD found on that row of the LIVE grid.
+///
+/// Chrome words move. When `Newtty` was added to every tail and `Joincol` and
+/// `Changelog` to the topbar, six scripts kept their frozen columns and started
+/// clicking a different word: `tutor.snap` clicked `Grep` instead of `Tutor`,
+/// `exec.snap`/`respawn.snap`/`tagalign.snap` clicked `Newtty` instead of `Del`,
+/// `find.snap` clicked `Joincol` instead of `Find`, `windowops.snap` clicked
+/// `Tutor` instead of `Debug` - and `--update` blessed all of it, so 256 golden
+/// lines were green while asserting the opposite of their script's first line.
+/// Naming the word instead of its column makes that class of drift impossible:
+/// the word is either there to be clicked or the script fails loudly.
+///
+/// The match is boundary-anchored, which is not a detail: `New` is a prefix of
+/// `Newcol` and of `Newtty`, and `@New` resolving to `Newcol` is exactly the bug
+/// this is here to prevent. `#n` picks the nth occurrence for a row that holds
+/// two panes' tags, and `+n`/`-n` addresses a column NEAR a word - which is how
+/// a click that means "in the gap the right-aligned builtins leave after the
+/// path" is spelled without depending on how wide those builtins are.
+fn resolveCol(h: *eh.Harness, spec: []const u8, row: u16) !u16 {
+ if (spec.len == 0 or spec[0] != '@') return std.fmt.parseInt(u16, spec, 10);
+ var word = spec[1..];
+ var want: usize = 1;
+ var offset: i32 = 0;
+ // parsed off the end, so a word may hold a `-` or a `#` itself
+ if (std.mem.lastIndexOfAny(u8, word, "+-")) |sign| {
+ if (sign > 0) {
+ if (std.fmt.parseInt(i32, word[sign + 1 ..], 10)) |n| {
+ offset = if (word[sign] == '-') -n else n;
+ word = word[0..sign];
+ } else |_| {}
+ }
+ }
+ if (std.mem.lastIndexOfScalar(u8, word, '#')) |hash| {
+ if (std.fmt.parseInt(usize, word[hash + 1 ..], 10)) |n| {
+ want = n;
+ word = word[0..hash];
+ } else |_| {}
+ }
+ if (word.len == 0 or want == 0) return error.BadScript;
+ if (row == 0 or row > h.rows) return error.WordRowMissing;
+ // One byte per CELL, not per byte of UTF-8: a mouse column is a cell, and
+ // a tag row can hold a wide glyph or the U+254E column divider left of the
+ // word. Non-ASCII cells become 0xff, which is not a word byte, so they
+ // read as boundaries - every chrome word is ASCII.
+ var cells: [1024]u8 = undefined;
+ const line = rowCells(h, row - 1, &cells);
+ var seen: usize = 0;
+ var from: usize = 0;
+ while (std.mem.indexOfPos(u8, line, from, word)) |at| : (from = at + 1) {
+ if (at > 0 and wordByte(line[at - 1])) continue;
+ const after = at + word.len;
+ if (after < line.len and wordByte(line[after])) continue;
+ seen += 1;
+ if (seen != want) continue;
+ // SGR columns are 1-based
+ const col = @as(i32, @intCast(at + 1)) + offset;
+ if (col < 1 or col > @as(i32, h.cols)) return error.BadScript;
+ return @intCast(col);
+ }
+ std.debug.print(" no \"{s}\" #{d} on row {d}: {s}\n", .{ word, want, row, std.mem.trimEnd(u8, line, " ") });
+ return error.WordNotOnRow;
+}
+
+fn rowCells(h: *eh.Harness, y: u16, buf: *[1024]u8) []const u8 {
+ const n = @min(h.cols, buf.len);
+ var x: u16 = 0;
+ while (x < n) : (x += 1) {
+ const ci = h.term.screens.active.pages.getCell(.{ .viewport = .{ .x = x, .y = y } });
+ buf[x] = blk: {
+ const cell = (ci orelse break :blk ' ').cell;
+ if (cell.content_tag != .codepoint and cell.content_tag != .codepoint_grapheme) break :blk ' ';
+ const cp = cell.content.codepoint.data;
+ break :blk if (cp == 0) ' ' else if (cp < 128) @intCast(cp) else 0xff;
+ };
+ }
+ return buf[0..n];
+}
+
+fn wordByte(c: u8) bool {
+ return std.ascii.isAlphanumeric(c) or c == '_';
+}
+
+/// A capture, kept so the NEXT capture of the same kind can be emitted as the
+/// rows that changed instead of the whole screen again. 81.7% of re-captured
+/// rows used to be byte-identical to the previous capture in the same script,
+/// which is why one word added to a tagline rewrote 78 goldens: every frame
+/// re-stated every row of chrome. The delta keeps the assertion (a row that
+/// moves still shows up, and a capture whose only change is the cursor is
+/// exactly the empty delta its script means) and drops the restatement.
+const Prev = struct {
+ snap: []const []const u8 = &.{},
+ style: []const []const u8 = &.{},
+ // per kind: one shared width let a `snapstyle` between a `resize` and the
+ // next `snap` re-arm the guard, and the post-resize frame then came out as
+ // a delta against rows of the old width - dropping every row whose text is
+ // width-invariant, which is every left-aligned chrome row there is.
+ snap_cols: u16 = 0,
+ style_cols: u16 = 0,
+};
+
+/// `== snap <label> grid=CxR cursor=X,Y` then either the whole screen as
+/// `|<row text>` (first capture of a script, or after a resize) or just the
+/// rows that changed as `|<n>: <row text>`. An indexed row is a patch against
+/// the previous capture; an unindexed one is a frame.
+fn snapText(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8, prev: *Prev) ![]u8 {
const text = try h.screenText();
defer h.gpa.free(text);
const c = h.cursor();
- const header = 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 total = header.len;
+ const rows = try arena.alloc([]const u8, h.rows);
var it = std.mem.splitScalar(u8, text, '\n');
- var row: usize = 0;
- while (row < h.rows) : (row += 1) {
- const line = it.next() orelse "";
- total += 1 + std.mem.trimEnd(u8, line, " ").len + 1;
- }
- const out = try arena.alloc(u8, total);
- var pos: usize = 0;
- copyBytes(out, &pos, header);
- it = std.mem.splitScalar(u8, text, '\n');
- row = 0;
- while (row < h.rows) : (row += 1) {
- const line = it.next() orelse "";
- copyBytes(out, &pos, "|");
- copyBytes(out, &pos, std.mem.trimEnd(u8, line, " "));
- copyBytes(out, &pos, "\n");
+ for (rows) |*r| r.* = try arena.dupe(u8, std.mem.trimEnd(u8, it.next() orelse "", " "));
+
+ var out: std.ArrayList(u8) = .empty;
+ 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,
+ }));
+ if (prev.snap.len == rows.len and prev.snap_cols == h.cols) {
+ for (rows, prev.snap, 0..) |now, was, i| {
+ if (std.mem.eql(u8, now, was)) continue;
+ // no trailing space when a row goes blank: a whitespace-trimming
+ // editor or `git apply --whitespace=fix` would rewrite it and the
+ // golden would then fail like a regression
+ try out.appendSlice(arena, if (now.len == 0)
+ try std.fmt.allocPrint(arena, "|{d}:\n", .{i})
+ else
+ try std.fmt.allocPrint(arena, "|{d}: {s}\n", .{ i, now }));
+ }
+ } else {
+ // A full frame stops at the last row with anything on it: the blank
+ // tail of the grid is the same information as the `grid=CxR` header.
+ var end = rows.len;
+ while (end > 0 and rows[end - 1].len == 0) end -= 1;
+ for (rows[0..end]) |r| {
+ try out.appendSlice(arena, "|");
+ try out.appendSlice(arena, r);
+ try out.appendSlice(arena, "\n");
+ }
}
- return out;
+ prev.snap = rows;
+ prev.snap_cols = h.cols;
+ return out.items;
}
/// 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, h: *eh.Harness, label: []const u8) ![]u8 {
- const header = try std.fmt.allocPrint(arena, "== style {s}\n", .{label});
- const rows_len = styleRows(h, null);
- const out = try arena.alloc(u8, header.len + rows_len);
- var pos: usize = 0;
- copyBytes(out, &pos, header);
- _ = styleRows(h, out[pos..]);
- return out;
+/// for underline single/double/curly/dotted/dashed. Deltas the same way
+/// `snapText` does - the row already carries its own index, so a patch and a
+/// frame are spelled identically here. The header carries the grid because a
+/// style capture has no cursor: without it an unchanged style would be a label
+/// line asserting nothing at all.
+fn snapStyle(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8, prev: *Prev) ![]u8 {
+ const rows = try styleRows(arena, h);
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== style {s} grid={d}x{d}\n", .{ label, h.cols, h.rows }));
+ const delta = prev.style.len == rows.len and prev.style_cols == h.cols;
+ for (rows, 0..) |now, i| {
+ if (delta and std.mem.eql(u8, now, prev.style[i])) continue;
+ try out.appendSlice(arena, "|");
+ try out.appendSlice(arena, now);
+ try out.appendSlice(arena, "\n");
+ }
+ prev.style = rows;
+ prev.style_cols = h.cols;
+ return out.items;
}
-fn styleRows(h: *eh.Harness, dst: ?[]u8) usize {
- var pos: usize = 0;
- var y: u16 = 0;
- while (y < h.rows) : (y += 1) {
- var fmt_buf: [64]u8 = undefined;
- emitBytes(dst, &pos, std.fmt.bufPrint(&fmt_buf, "|{d}:", .{y}) catch unreachable);
+fn styleRows(arena: std.mem.Allocator, h: *eh.Harness) ![]const []const u8 {
+ const out = try arena.alloc([]const u8, h.rows);
+ for (out, 0..) |*dst, y_usize| {
+ const y: u16 = @intCast(y_usize);
+ var row: std.ArrayList(u8) = .empty;
+ try row.appendSlice(arena, try std.fmt.allocPrint(arena, "{d}:", .{y}));
var key_buf: [64]u8 = undefined;
var run_key: [64]u8 = undefined;
var run_len: usize = 0;
@@ -829,20 +997,15 @@ fn styleRows(h: *eh.Harness, dst: ?[]u8) usize {
continue;
}
if (std.mem.eql(u8, key, run_key[0..run_len])) continue;
- emitBytes(dst, &pos, std.fmt.bufPrint(&fmt_buf, " {d}-{d} ", .{ run_start, x - 1 }) catch unreachable);
- emitBytes(dst, &pos, run_key[0..run_len]);
+ try row.appendSlice(arena, try std.fmt.allocPrint(arena, " {d}-{d} ", .{ run_start, x - 1 }));
+ try row.appendSlice(arena, run_key[0..run_len]);
@memcpy(run_key[0..key.len], key);
run_len = key.len;
run_start = x;
}
- emitBytes(dst, &pos, "\n");
+ dst.* = row.items;
}
- return pos;
-}
-
-fn emitBytes(dst: ?[]u8, pos: *usize, bytes: []const u8) void {
- if (dst) |out| @memcpy(out[pos.*..][0..bytes.len], bytes);
- pos.* += bytes.len;
+ return out;
}
fn cellKey(h: *eh.Harness, x: u16, y: u16, buf: *[64]u8) []const u8 {