diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 11:05:21 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 12:28:58 -0300 |
| commit | e5f9e172330bc4500995ddd5954ed94f94e07af6 (patch) | |
| tree | aa7c5079b4c2951cef5b5c15595d0b20bc3ae245 /test/snapshot.zig | |
| parent | 6f48508aa08396bcf9dd4da2cab1d221bcc53f78 (diff) | |
| download | pardes-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.zig | 283 |
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 { |
