summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-28 13:21:42 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit0b5ad7a30ba9e7c6eedcb426be294bd94219310f (patch)
tree1a991acfb2a14f25ece19e13e65915dbbb48c7fc
parent95202b9b22751c86f100aae9ff1c64e535dcbd70 (diff)
downloadpardes-0b5ad7a30ba9e7c6eedcb426be294bd94219310f.tar.gz
pardes-0b5ad7a30ba9e7c6eedcb426be294bd94219310f.zip
build.zig runners and testing improvements
-rw-r--r--build.zig65
-rw-r--r--build/snap.zig70
-rw-r--r--src/pardes.zig1
-rw-r--r--src/syntax.zig11
-rw-r--r--test/e2e_harness.zig32
-rw-r--r--test/snapshot.zig399
-rw-r--r--test/snapshots/chordcut.snap4
-rw-r--r--test/snapshots/cursorline.snap2
-rw-r--r--test/snapshots/dump.snap9
-rw-r--r--test/snapshots/edit.snap4
-rw-r--r--test/snapshots/exec-bigdrag.snap6
-rw-r--r--test/snapshots/exec.snap2
-rw-r--r--test/snapshots/fileedit.snap6
-rw-r--r--test/snapshots/find.snap2
-rw-r--r--test/snapshots/fsearch.snap4
-rw-r--r--test/snapshots/hscroll.snap2
-rw-r--r--test/snapshots/images.snap2
-rw-r--r--test/snapshots/layout-close.snap4
-rw-r--r--test/snapshots/layout-open.snap4
-rw-r--r--test/snapshots/leader.snap4
-rw-r--r--test/snapshots/load.snap4
-rw-r--r--test/snapshots/look-center.snap2
-rw-r--r--test/snapshots/look-dir.snap2
-rw-r--r--test/snapshots/look-file-1col.snap2
-rw-r--r--test/snapshots/look-file.snap2
-rw-r--r--test/snapshots/lookloaded.snap2
-rw-r--r--test/snapshots/lookpanes.snap2
-rw-r--r--test/snapshots/modal.snap4
-rw-r--r--test/snapshots/reflow.snap2
-rw-r--r--test/snapshots/restore.golden2
-rw-r--r--test/snapshots/rsearch.snap2
-rw-r--r--test/snapshots/shrinktag.snap2
-rw-r--r--test/snapshots/syntax.snap2
-rw-r--r--test/snapshots/tag.snap6
-rw-r--r--test/snapshots/tagcmd.snap6
-rw-r--r--test/snapshots/tagnav.golden4
-rw-r--r--test/snapshots/tagnav.snap4
-rw-r--r--test/snapshots/tagpath.snap2
-rw-r--r--test/snapshots/tinywin.snap10
-rw-r--r--test/snapshots/ttylook.snap2
-rw-r--r--test/snapshots/yankpaste.snap2
41 files changed, 538 insertions, 161 deletions
diff --git a/build.zig b/build.zig
index a4d530f1..6073bbcc 100644
--- a/build.zig
+++ b/build.zig
@@ -1,4 +1,5 @@
const std = @import("std");
+const snap_build = @import("build/snap.zig");
pub const Platform = enum { tty, gui, web };
pub const TreeSitterGrammars = enum { disabled, zig, minimal, full };
@@ -24,6 +25,13 @@ pub fn build(b: *std.Build) void {
const is_emscripten = target.result.os.tag == .emscripten;
// wasm: size is the budget
const optimize = if (is_emscripten) .ReleaseSmall else requested_optimize;
+ // The vendored C is never what we are debugging, and at -O0 it dominates
+ // the app: 90% of a Debug startup is tree-sitter's query analyser
+ // (perf: ts_query__perform_analysis + ts_lookahead_iterator__next), and
+ // stb_image decodes at a crawl. Building the C optimized whatever the Zig
+ // mode takes a Debug boot from ~710ms to ~210ms — the same treatment
+ // ghostty's simdutf/highway already get here. Zig code keeps its mode.
+ const c_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseFast else optimize;
const default_grammars: TreeSitterGrammars = if (is_emscripten) .zig else .full;
const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full") orelse default_grammars;
const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout");
@@ -52,33 +60,7 @@ pub fn build(b: *std.Build) void {
const inspect_sources = b.addInstallFileWithDir(embedded_sources, .{ .custom = "web" }, "embedded_sources.zig");
b.step("embed-web-sources", "generate zig-out/web/embedded_sources.zig from tracked .zig files").dependOn(&inspect_sources.step);
- // Browser regression suite: make an isolated web build from the real TTY
- // dump captured after running `git ls-files '*.zig'`, with the Zig-only
- // tree-sitter tier. The dependency-free CDP harness then drives
- // its .snap script through real Chromium touch events. This recursive build
- // selects `web`, not `web-snap`, so it bottoms out after one level.
- const build_web_snap = b.addSystemCommand(&.{
- b.graph.zig_exe,
- "build",
- "web",
- "-Dplatform=web",
- "-Dtarget=wasm32-emscripten",
- "-Dtree-sitter=zig",
- "-Ddump=test/web-snapshots/source-list.dump.zon",
- "-p",
- "zig-out/web-snap-test",
- });
- build_web_snap.setCwd(b.path("."));
- const run_web_snap = b.addSystemCommand(&.{
- "node",
- "test/web_snapshot.mjs",
- "zig-out/web-snap-test/web",
- "test/web-snapshots/source-list.snap",
- });
- run_web_snap.setCwd(b.path("."));
- run_web_snap.step.dependOn(&build_web_snap.step);
- if (b.args) |args| run_web_snap.addArgs(args);
- b.step("web-snap", "run browser touch/LOOK snapshots (-- --update regenerates goldens)").dependOn(&run_web_snap.step);
+ snap_build.addWeb(b);
const emscripten_sysroot = if (is_emscripten) detectEmscriptenSysroot(b) else null;
if (platform == .web and !is_emscripten) return failBuild(b, web_step, "-Dplatform=web requires -Dtarget=wasm32-emscripten");
@@ -173,7 +155,7 @@ pub fn build(b: *std.Build) void {
// and each grammar's highlights.scm slurped at build time into the
// ts_queries options module (codegen consumed by comptime in syntax.zig).
if (tree_sitter_grammars != .disabled) {
- const tree_sitter_mod = b.dependency("tree_sitter", .{ .target = target, .optimize = optimize }).module("tree_sitter");
+ const tree_sitter_mod = b.dependency("tree_sitter", .{ .target = target, .optimize = c_optimize }).module("tree_sitter");
// the C runtime rides the module as a static lib; give it the wasm
// libc and hand its archive to the emcc link
if (is_emscripten) {
@@ -237,7 +219,7 @@ pub fn build(b: *std.Build) void {
const lib = b.addLibrary(.{
.name = "tree-sitter-" ++ g.name,
- .root_module = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }),
+ .root_module = b.createModule(.{ .target = target, .optimize = c_optimize, .link_libc = true }),
.linkage = .static,
});
if (libc_file) |f| lib.setLibCFile(f);
@@ -263,7 +245,7 @@ pub fn build(b: *std.Build) void {
}
// zstbi (C stb_image): image pane decode. zigimg crashes the 0.16 compiler.
- const zstbi_dep = b.dependency("zstbi", .{ .target = target, .optimize = optimize });
+ const zstbi_dep = b.dependency("zstbi", .{ .target = target, .optimize = c_optimize });
root_mod.addImport("zstbi", zstbi_dep.module("root"));
hx_core_mod.addImport("zstbi", zstbi_dep.module("root"));
@@ -484,24 +466,13 @@ pub fn build(b: *std.Build) void {
// snapshot parity suite: scripted input traces in a pty, captured grids
// diffed against the goldens in snapshots/ — the frozen record of parity
- // with the prototype this program replaced. `-- --update` regenerates.
- const snap = b.addExecutable(.{
- .name = "pardes-snap",
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("test/snapshot.zig"),
- .link_libc = true,
- }),
+ // with the prototype this program replaced. See build/snap.zig.
+ snap_build.addTty(b, .{
+ .exe = exe,
+ .target = target,
+ .optimize = optimize,
+ .ghostty_vt = ghostty_vt_for_snap,
});
- if (ghostty_vt_for_snap) |vt| snap.root_module.addImport("ghostty-vt", vt);
- if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd)
- snap.root_module.linkSystemLibrary("util", .{});
- b.installArtifact(snap);
- const run_snap = b.addRunArtifact(snap);
- run_snap.addArtifactArg(exe);
- if (b.args) |args| run_snap.addArgs(args);
- b.step("snap", "run the snapshot parity suite (-- --update regenerates goldens)").dependOn(&run_snap.step);
// helix differential harness: drive the sans-IO core headlessly over
// JSON-Lines cases, one contract result line per case on stdout —
diff --git a/build/snap.zig b/build/snap.zig
new file mode 100644
index 00000000..a721f483
--- /dev/null
+++ b/build/snap.zig
@@ -0,0 +1,70 @@
+//! Build-time wiring for the two snapshot suites — the `snap` step (scripted
+//! pty traces diffed against test/snapshots/*.golden) and the `web-snap` step
+//! (the same idea driven through headless Chromium). Kept out of build.zig so
+//! the test harness's plumbing is not tangled with the shell/platform build.
+//!
+//! Both steps forward `-- <args>` to their runner: `--update` regenerates
+//! goldens, `--jobs=N` / `--idle=MS` tune the tty runner's parallelism.
+
+const std = @import("std");
+
+/// TTY parity suite: pardes-snap forks the real binary in a pty per script.
+/// The runner handles its own locking and fan-out, so this is just a run step.
+pub fn addTty(b: *std.Build, opts: struct {
+ exe: *std.Build.Step.Compile,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ ghostty_vt: ?*std.Build.Module,
+}) void {
+ const snap = b.addExecutable(.{
+ .name = "pardes-snap",
+ .root_module = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = b.path("test/snapshot.zig"),
+ .link_libc = true,
+ }),
+ });
+ if (opts.ghostty_vt) |vt| snap.root_module.addImport("ghostty-vt", vt);
+ switch (opts.target.result.os.tag) {
+ .freebsd, .netbsd, .openbsd => snap.root_module.linkSystemLibrary("util", .{}),
+ else => {},
+ }
+ b.installArtifact(snap);
+ const run_snap = b.addRunArtifact(snap);
+ run_snap.addArtifactArg(opts.exe);
+ if (b.args) |args| run_snap.addArgs(args);
+ // scripts fork children and drive real ptys: never cached, never "up to date"
+ run_snap.has_side_effects = true;
+ b.step("snap", "run the snapshot parity suite (-- --update regenerates goldens)").dependOn(&run_snap.step);
+}
+
+/// Browser regression suite: make an isolated web build from the real TTY dump
+/// captured after running `git ls-files '*.zig'`, with the Zig-only tree-sitter
+/// tier. The dependency-free CDP harness then drives its .snap script through
+/// real Chromium touch events. This recursive build selects `web`, not
+/// `web-snap`, so it bottoms out after one level.
+pub fn addWeb(b: *std.Build) void {
+ const build_web_snap = b.addSystemCommand(&.{
+ b.graph.zig_exe,
+ "build",
+ "web",
+ "-Dplatform=web",
+ "-Dtarget=wasm32-emscripten",
+ "-Dtree-sitter=zig",
+ "-Ddump=test/web-snapshots/source-list.dump.zon",
+ "-p",
+ "zig-out/web-snap-test",
+ });
+ build_web_snap.setCwd(b.path("."));
+ const run_web_snap = b.addSystemCommand(&.{
+ "node",
+ "test/web_snapshot.mjs",
+ "zig-out/web-snap-test/web",
+ "test/web-snapshots/source-list.snap",
+ });
+ run_web_snap.setCwd(b.path("."));
+ run_web_snap.step.dependOn(&build_web_snap.step);
+ if (b.args) |args| run_web_snap.addArgs(args);
+ b.step("web-snap", "run browser touch/LOOK snapshots (-- --update regenerates goldens)").dependOn(&run_web_snap.step);
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index 8e644470..731dfe0a 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -970,7 +970,6 @@ pub const Pardes = struct {
scratch: std.heap.ArenaAllocator,
pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
- syntax.compileStartupQueries();
const p = try gpa.create(Pardes);
p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) };
if (opts.file) |path| {
diff --git a/src/syntax.zig b/src/syntax.zig
index bbab17fa..43e370d5 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -120,12 +120,11 @@ fn forExt(ext: []const u8) !?Selected {
return null;
}
-/// Compile the compact-tier queries eagerly so the first source open is fast.
-pub fn compileStartupQueries() void {
- if (!enabled) return;
- for (&zig_specs) |*spec| _ = ensure(spec) catch {};
- for (&minimal_specs) |*spec| _ = ensure(spec) catch {};
-}
+// Queries compile on first use (`ensure`), never at startup. Pre-compiling the
+// compact tier in Pardes.init cost EVERY boot ~120ms of ts_query__perform_analysis
+// (55% of a Debug startup) to save ~40ms on the first .zig/.c/.cpp open — a pane
+// of prose or a shell paid for a language it never opened. Grammar availability
+// is unchanged; only the timing moved.
fn synFor(name: []const u8) Syn {
for ([_]struct { []const u8, Syn }{
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig
index 9099f13e..eb4c7a2f 100644
--- a/test/e2e_harness.zig
+++ b/test/e2e_harness.zig
@@ -15,7 +15,9 @@ extern "c" fn forkpty(
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
extern "c" fn _exit(status: c_int) noreturn;
-fn nowMs() i64 {
+/// Monotonic milliseconds; the harness's only clock (std.time lost
+/// milliTimestamp in 0.16).
+pub fn nowMs() i64 {
var ts: libc.timespec = undefined;
_ = libc.clock_gettime(.MONOTONIC, &ts);
return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000);
@@ -133,6 +135,34 @@ pub const Harness = struct {
if (self.trace) self.traceScreen("pump");
}
+ /// Wait up to `ms` for the app to say something, feed whatever arrived to
+ /// the emulator, and report whether anything did. `ms` = 0 polls. This is
+ /// the event-driven half of `pump`: a caller that only cares about the
+ /// SETTLED grid sleeps in poll until the app actually writes, instead of
+ /// burning a fixed window per check (see snapshot.zig's waitStable).
+ pub fn pumpOnce(self: *Harness, ms: i64) !bool {
+ var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }};
+ const ready = posix.poll(&fds, @intCast(@max(0, ms))) catch 0;
+ if (ready == 0 or (fds[0].revents & posix.POLL.IN) == 0) return false;
+ var buf: [4096]u8 = undefined;
+ const n = posix.read(self.master, &buf) catch return false;
+ if (n == 0) return false;
+ self.raw.appendSlice(self.gpa, buf[0..n]) catch {};
+ self.stream.nextSlice(buf[0..n]);
+ return true;
+ }
+
+ /// Bytes sitting in the pty that nobody has picked up — keystrokes the app
+ /// has not read yet, or output we have not read yet (the master counts both
+ /// directions). Zero is the proof that the app has actually CONSUMED the
+ /// input just sent: without it, an app still asleep in its event loop looks
+ /// exactly like an app that has finished. See snapshot.zig's waitStable.
+ pub fn pending(self: *Harness) usize {
+ var n: c_int = 0;
+ if (posix.system.ioctl(self.master, posix.T.FIONREAD, @intFromPtr(&n)) != 0) return 0;
+ return if (n > 0) @intCast(n) else 0;
+ }
+
/// Resize both the pty (SIGWINCH to the app) and our terminal (grid reflows
/// to the new size). Harness terminal first so it's ready for the app's
/// re-render at the new size.
diff --git a/test/snapshot.zig b/test/snapshot.zig
index f1ebd941..31f2c8ea 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -6,6 +6,23 @@
// 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
+// zig build snap -- --jobs=1 serial (default: 1.25 per cpu)
+// zig build snap -- --trace-stable show what every `stable` waited on
+//
+// Scripts run one per forked child, in parallel; each child's report is held
+// in a temp file and printed in script order at the end. The runner takes its
+// own lockfile, so `zig build snap` never needs an `flock` wrapper: a second
+// run waits for the first instead of trampling the fixed /tmp paths that the
+// goldens record verbatim. A script that fails in the parallel pass is re-run
+// alone before it counts as a failure.
+//
+// `stable` no longer burns its wall-clock window: it returns as soon as the
+// app is provably done — screen unchanged, pty empty, and every process it
+// forked asleep (see waitStable). That, the fan-out, and building the vendored
+// C optimized (build.zig's c_optimize: 90% of a Debug startup used to be
+// tree-sitter's query analyser at -O0) took the suite from 7m54s to ~4s. The
+// app now reaches first paint in ~25ms, so what is left is mostly real work:
+// bash spawns, their output, and the 40 `settle`s after `key esc`.
//
// Script commands (line-oriented, # comments):
// file <name> <content> create file in the script's cwd (before start)
@@ -18,8 +35,11 @@
// 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>)
+// pgup pgdn c-<ch> a-<ch> (after esc: settle, or
+// vaxis reads the next byte in the same batch and
+// parses it as alt-<ch>. The scripts said 700ms and
+// it cost the suite 28s; there is no timer in vaxis
+// to outlast — 20ms passes, they use 100ms)
// 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)
@@ -44,6 +64,9 @@ extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
const SNAP_BASE = "/tmp/pardes-snap";
+var trace_stable = false;
+/// set for the serial re-run pass; children widen their probes (see beCareful)
+var careful = false;
const gpa = std.heap.page_allocator;
pub fn main(init: std.process.Init) !void {
@@ -60,17 +83,27 @@ pub fn main(init: std.process.Init) !void {
var exe: ?[]const u8 = null;
var update = false;
+ // Slight oversubscription: a script spends most of its life waiting on a
+ // shell, not computing. Measured on 16 cores: 16 jobs 4.4s, 20 jobs 3.9s,
+ // 24 jobs 3.7s but losing the idle-probe bet often enough that the re-run
+ // pass costs more than the extra parallelism saves.
+ const cpus = std.Thread.getCpuCount() catch 4;
+ var jobs: usize = cpus + cpus / 4;
var scripts: std.ArrayList([]const u8) = .empty;
for (args[1..]) |a| {
if (std.mem.eql(u8, a, "--update")) {
update = true;
+ } else if (std.mem.eql(u8, a, "--trace-stable")) {
+ trace_stable = true;
+ } else if (std.mem.startsWith(u8, a, "--jobs=")) {
+ jobs = @max(1, try std.fmt.parseInt(usize, a["--jobs=".len..], 10));
} 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]", .{});
+ if (exe == null) fatal("usage: pardes-snap <exe> [scripts...] [--update] [--jobs=N]", .{});
const exe_z = try arena.dupeZ(u8, exe.?);
if (scripts.items.len == 0) {
@@ -83,34 +116,49 @@ pub fn main(init: std.process.Init) !void {
}
if (scripts.items.len == 0) fatal("no .snap scripts found", .{});
+ try mkdir(arena, SNAP_BASE, true);
+ // one run at a time (this is the flock that used to be typed by hand)
+ _ = lockOrWait(SNAP_BASE ++ "/.run.lock", "another pardes-snap run");
+
+ const codes = try arena.alloc(u8, scripts.items.len);
+ try runBatch(arena, exe_z, toBatch(arena, scripts.items), update, 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
+ // cpu. So a failure is re-run alone before it counts — first at the normal
+ // probe settings, which is usually all an idle machine needs, and only if
+ // it fails AGAIN with the probes widened (beCareful), which is slow but
+ // decides. A wholesale failure is not flakiness, so past a handful there is
+ // nothing to re-check.
+ if (jobs > 1) {
+ var round: usize = 0;
+ while (round < 2) : (round += 1) {
+ var retry: std.ArrayList(usize) = .empty;
+ for (codes, 0..) |c, i| if (c != 0) try retry.append(arena, i);
+ if (retry.items.len == 0 or retry.items.len > 10) break;
+ const paths = try arena.alloc(Idx, retry.items.len);
+ for (retry.items, paths, 0..) |i, *p, n| p.* = .{ .path = scripts.items[i], .code_idx = n };
+ std.debug.print("re-running {d} failed script(s) serially{s}:", .{
+ paths.len,
+ if (round == 0) "" else " with wider settle probes",
+ });
+ for (paths) |p| std.debug.print(" {s}", .{std.fs.path.stem(p.path)});
+ std.debug.print("\n", .{});
+ careful = round > 0;
+ const recodes = try arena.alloc(u8, paths.len);
+ try runBatch(arena, exe_z, paths, update, 1, recodes);
+ for (retry.items, recodes) |i, c| codes[i] = c;
+ }
+ }
+
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) });
+ for (scripts.items, codes) |script_path, code| {
+ const report = readFileAlloc(arena, reportPath(arena, script_path) catch "") catch "";
+ std.debug.print("{s}", .{report});
+ if (code != 0) {
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 (report.len == 0)
+ std.debug.print("FAIL {s}: child died with no report\n", .{std.fs.path.stem(script_path)});
}
}
if (failed > 0) {
@@ -120,6 +168,127 @@ pub fn main(init: std.process.Init) !void {
std.debug.print("all {d} snapshot scripts ok\n", .{scripts.items.len});
}
+/// Run `scripts` `jobs` at a time, one forked child each, filling `codes` with
+/// their exit codes. Forking rather than threading because runScript owns
+/// process-global state — chdir into its workdir, HOME/TERM/PARDES_DUMP in the
+/// environment — which siblings must not see. Each child's report goes to its
+/// own file and is replayed in script order by the caller, so parallelism
+/// never interleaves a diff.
+// Scripts run in the order given — alphabetical. Sorting longest-first (from
+// the durations the previous run left in its reports) is the textbook makespan
+// fix and it measured WORSE here: 3.0s when clean, but bunching the heavy
+// scripts at t=0 makes them contend, the idle probes lose their bet, and the
+// re-run pass costs more than the tail ever did (13-20s runs). Left alone.
+const Idx = struct { path: []const u8, code_idx: usize };
+
+fn toBatch(arena: std.mem.Allocator, scripts: []const []const u8) []const Idx {
+ const out = arena.alloc(Idx, scripts.len) catch return &.{};
+ for (scripts, out, 0..) |path, *o, i| o.* = .{ .path = path, .code_idx = i };
+ return out;
+}
+
+fn runBatch(
+ arena: std.mem.Allocator,
+ exe_z: [:0]const u8,
+ scripts: []const Idx,
+ update: bool,
+ jobs: usize,
+ codes: []u8,
+) !void {
+ const Job = struct { pid: libc.pid_t, idx: usize };
+ var running: std.ArrayList(Job) = .empty;
+ defer running.deinit(arena);
+ var next: usize = 0;
+ while (next < scripts.len or running.items.len > 0) {
+ while (next < scripts.len and running.items.len < jobs) : (next += 1) {
+ const idx = next;
+ const pid = libc.fork();
+ if (pid == 0) std.process.exit(runOne(arena, exe_z, scripts[idx].path, update));
+ if (pid < 0) fatal("fork failed", .{});
+ try running.append(arena, .{ .pid = pid, .idx = scripts[idx].code_idx });
+ }
+ var status: c_int = 0;
+ const pid = libc.waitpid(-1, &status, 0);
+ for (running.items, 0..) |j, i| {
+ if (j.pid != pid) continue;
+ // a killed child (crash in the harness itself) counts as a failure
+ codes[j.idx] = if (status == 0) 0 else 1;
+ _ = running.swapRemove(i);
+ break;
+ }
+ }
+}
+
+/// One script, in its own process: run it, then diff or update its golden.
+/// Everything it prints lands in its report file (dup2'd over stdout/stderr).
+/// Returns the child's exit code.
+fn runOne(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8, update: bool) u8 {
+ if (careful) beCareful();
+ const stem = std.fs.path.stem(script_path);
+ const report_z = arena.dupeZ(u8, reportPath(arena, script_path) catch return 1) catch return 1;
+ const fd = libc.open(report_z, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd >= 0) {
+ _ = libc.dup2(fd, 1);
+ _ = libc.dup2(fd, 2);
+ _ = libc.close(fd);
+ }
+ const base_path = script_path[0 .. script_path.len - ".snap".len];
+ const golden_path = std.fmt.allocPrint(arena, "{s}.golden", .{base_path}) catch return 1;
+ const golden: ?[]u8 = readFileAlloc(arena, golden_path) catch null;
+ const started = eh.nowMs();
+ const out = runScript(arena, exe_z, script_path, stem) catch |e| {
+ std.debug.print("FAIL {s}: script error {s}\n", .{ stem, @errorName(e) });
+ return 1;
+ };
+ const took = eh.nowMs() - started;
+ if (update) {
+ eh.writeFile(arena.dupeZ(u8, golden_path) catch return 1, out) catch return 1;
+ std.debug.print("UPDATED {s} ({d} bytes)\n", .{ stem, out.len });
+ return 0;
+ }
+ if (golden == null) {
+ std.debug.print("FAIL {s}: no golden (run with -- --update)\n", .{stem});
+ return 1;
+ }
+ if (std.mem.eql(u8, golden.?, out)) {
+ std.debug.print("PASS {s} ({d}ms)\n", .{ stem, took });
+ return 0;
+ }
+ const actual_path = std.fmt.allocPrint(arena, "{s}.actual", .{base_path}) catch return 1;
+ eh.writeFile(arena.dupeZ(u8, actual_path) catch return 1, out) catch {};
+ std.debug.print("FAIL {s}: differs from golden (actual written to {s})\n", .{ stem, actual_path });
+ printFirstDiff(golden.?, out);
+ return 1;
+}
+
+fn reportPath(arena: std.mem.Allocator, script_path: []const u8) ![]const u8 {
+ return std.fmt.allocPrint(arena, "{s}/{s}.report", .{ SNAP_BASE, std.fs.path.stem(script_path) });
+}
+
+/// Exclusive flock on `path`, announcing the wait if someone else holds it.
+/// The fd is deliberately leaked: the lock lives until the process exits.
+///
+/// CLOEXEC is load-bearing. flock lives on the open file description, so an
+/// inherited fd keeps the lock — and this process forks a pty full of app and
+/// shell processes that can outlive it. Without CLOEXEC an orphaned bash holds
+/// the lock forever and every later script blocks in flock().
+fn lockOrWait(path: [*:0]const u8, what: []const u8) libc.fd_t {
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .CLOEXEC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) fatal("cannot open lockfile {s}", .{path});
+ if (libc.flock(fd, std.posix.LOCK.EX | std.posix.LOCK.NB) == 0) return fd;
+ // polled rather than a blocking flock so a stuck holder is reported instead
+ // of hanging the run until someone notices. The cap clears a full serial
+ // run (--jobs=1 is ~6 minutes) with room to spare.
+ std.debug.print("waiting for {s}...\n", .{what});
+ var waited_ms: i64 = 0;
+ while (waited_ms < 600_000) : (waited_ms += 100) {
+ var none: [0]std.posix.pollfd = .{};
+ _ = std.posix.poll(&none, 100) catch {};
+ if (libc.flock(fd, std.posix.LOCK.EX | std.posix.LOCK.NB) == 0) return fd;
+ }
+ fatal("gave up after 10 minutes waiting for {s} ({s})", .{ what, path });
+}
+
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);
@@ -138,10 +307,14 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
_ = setenv("HOME", try arena.dupeZ(u8, home), 1);
_ = setenv("TERM", "xterm-256color", 1);
_ = setenv("LC_ALL", "C", 1);
- // pin the dump path: live runs use a timestamped file under the user data
+ // Pin the dump path: live runs use a timestamped file under the user data
// dir (dump.outPath), which would make the dump/load/restore goldens
- // nondeterministic
- _ = setenv("PARDES_DUMP", "/tmp/pardes-dump.zon", 1);
+ // nondeterministic. Per-SCRIPT, not one shared /tmp/pardes-dump.zon: four
+ // scripts write it and read it back, and sharing it meant they could only
+ // take turns — which cost more wall clock than the other 51 scripts put
+ // together. Scripts name it `$DUMP`.
+ const dump_path = try std.fmt.allocPrint(arena, "{s}/dump.zon", .{base});
+ _ = setenv("PARDES_DUMP", try arena.dupeZ(u8, dump_path), 1);
if (chdirRc(try arena.dupeZ(u8, work)) < 0) return error.ChdirFailed;
var out: std.ArrayList(u8) = .empty;
@@ -152,8 +325,14 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
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;
+ const trimmed = std.mem.trim(u8, raw_line, " \t\r");
+ if (trimmed.len == 0 or trimmed[0] == '#') continue;
+ // `$DUMP` is the script's own PARDES_DUMP file (see above)
+ const line = if (std.mem.indexOf(u8, trimmed, "$DUMP") == null) trimmed else blk: {
+ const buf = try arena.alloc(u8, std.mem.replacementSize(u8, trimmed, "$DUMP", dump_path));
+ _ = std.mem.replace(u8, trimmed, "$DUMP", dump_path, buf);
+ break :blk buf;
+ };
var tok = std.mem.tokenizeScalar(u8, line, ' ');
const cmd = tok.next().?;
errdefer std.debug.print(" at {s}:{d}: {s}\n", .{ script_path, lineno, line });
@@ -275,25 +454,153 @@ fn live(h: *?eh.Harness) *eh.Harness {
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.
+/// Pump until the captured state — grid text AND cursor — has not changed 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.
+///
+/// Same oracle as before, timed continuously instead of in fixed windows. The
+/// old loop pumped a whole `quiet` window, compared, and pumped another — so
+/// even an app that fell silent immediately cost 2 × 700ms, and 446 `stable`s
+/// cost the suite ten minutes. This one sleeps in poll until the app writes,
+/// and returns `quiet` ms after the LAST change. Waiting on the change rather
+/// than on silence is what keeps it honest: pardes finishes a repaint with a
+/// late cursor move, hundreds of ms after its last burst of text.
fn waitStable(h: *eh.Harness, quiet: i64, timeout: i64) !void {
- var total: i64 = 0;
- var prev = try h.screenText();
+ const t0 = eh.nowMs();
+ const deadline = t0 + timeout;
+ var prev = try stateKey(h);
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);
+ var last_change = t0;
+ var asleep_since: i64 = 0;
+ while (true) {
+ const now = eh.nowMs();
+ const since = now - last_change;
+ if (since >= quiet) {
+ if (trace_stable) std.debug.print("[stable q={d}] window: last change +{d}ms, cost {d}ms\n", .{ quiet, last_change - t0, now - t0 });
return;
}
+ // Fast path: the screen has held still, the pty is empty in both
+ // directions (so the app has CONSUMED everything the script just sent
+ // — otherwise an app still asleep in its event loop looks finished),
+ // and nothing in its process tree is runnable. Held across two probes,
+ // because a pane's shell can write and sleep a scheduler tick before
+ // the app wakes to read it.
+ if (since >= probe_after_ms and h.pending() == 0 and treeAsleep(h.pid)) {
+ if (asleep_since == 0) {
+ asleep_since = now;
+ } else if (now - asleep_since >= probe_hold_ms) {
+ if (trace_stable) std.debug.print("[stable q={d}] asleep: last change +{d}ms, cost {d}ms\n", .{ quiet, last_change - t0, now - t0 });
+ return;
+ }
+ } else if (since >= probe_after_ms) {
+ asleep_since = 0;
+ }
+ if (now >= deadline) return error.NeverStable;
+ const nap = if (since >= probe_after_ms) probe_every_ms else probe_after_ms - since;
+ if (!try h.pumpOnce(@min(nap, deadline - now))) continue;
+ while (try h.pumpOnce(0)) {} // drain the burst before comparing
+ const cur = try stateKey(h);
+ if (std.mem.eql(u8, cur, prev)) {
+ h.gpa.free(cur);
+ continue;
+ }
h.gpa.free(prev);
prev = cur;
+ if (trace_stable) std.debug.print("[stable q={d}] change at +{d}ms (gap {d}ms)\n", .{ quiet, eh.nowMs() - t0, eh.nowMs() - last_change });
+ last_change = eh.nowMs();
+ asleep_since = 0;
+ }
+}
+
+// How long the screen must hold still before the sleep probe is trusted at all,
+// how often it is taken, and how long it must stay true.
+//
+// The hold is the one number that cannot be reasoned down to zero. When the app
+// writes to a pane's pty the kernel parks the bytes in a flip buffer and leaves
+// the hand-off to a kworker: until that runs, the app is asleep, the shell is
+// asleep, nothing is runnable and no /proc or ioctl query can see the round trip
+// in flight (a pty master cannot even be reopened through /proc/<pid>/fd — it
+// 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;
+const probe_every_ms: i64 = 5;
+var probe_hold_ms: i64 = 50;
+
+/// 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.
+fn beCareful() void {
+ probe_after_ms = 40;
+ probe_hold_ms = 300;
+}
+
+/// Is every process in the app's tree asleep? Walks /proc from the app's pid
+/// through `children`, checking every thread's run state: `R` (runnable) or
+/// `D` (uninterruptible) anywhere means work is still in flight — a repaint,
+/// a fork, a shell mid-command — so a quiet pty proves nothing. Anything else
+/// (`S`, or a zombie waiting to be reaped) is idle.
+///
+/// This is what replaces the wall-clock window: the app finishes a keystroke
+/// in ~10ms, and the scripts' 400-700ms `stable` windows were insurance
+/// against not being able to tell "finished" from "paused mid-fork".
+fn treeAsleep(root: libc.pid_t) bool {
+ var stack: [64]libc.pid_t = undefined;
+ stack[0] = root;
+ var n: usize = 1;
+ var path_buf: [64:0]u8 = undefined;
+ var buf: [4096]u8 = undefined;
+ while (n > 0) {
+ n -= 1;
+ const pid = stack[n];
+ const dir_path = std.fmt.bufPrintZ(&path_buf, "/proc/{d}/task", .{pid}) catch continue;
+ const dir = libc.opendir(dir_path) orelse continue; // exited between steps
+ defer _ = libc.closedir(dir);
+ while (libc.readdir(dir)) |d| {
+ const tid = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(&d.name)), 0);
+ if (tid[0] == '.') continue;
+ const stat = readProc(&buf, std.fmt.bufPrintZ(&path_buf, "/proc/{d}/task/{s}/stat", .{ pid, tid }) catch continue) orelse continue;
+ // "pid (comm) S ..." — comm can hold spaces and parens, so the
+ // state is the char two past the LAST ')'
+ const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse continue;
+ if (close + 2 >= stat.len) continue;
+ switch (stat[close + 2]) {
+ // R/D: still working. Z: just exited, so its parent is about to
+ // be woken to reap it and carry on — a pane's shell sitting in
+ // wait4 over a zombie `ls` looks asleep, but the prompt it is
+ // about to print is what hides the echoed command line in the
+ // goldens.
+ 'R', 'D', 'Z' => return false,
+ else => {},
+ }
+ const kids = readProc(&buf, std.fmt.bufPrintZ(&path_buf, "/proc/{d}/task/{s}/children", .{ pid, tid }) catch continue) orelse continue;
+ var it = std.mem.tokenizeAny(u8, kids, " \n");
+ while (it.next()) |k| {
+ if (n >= stack.len) return false; // deeper than we can walk: assume busy
+ stack[n] = std.fmt.parseInt(libc.pid_t, k, 10) catch continue;
+ n += 1;
+ }
+ }
}
- return error.NeverStable;
+ return true;
+}
+
+/// Read a small /proc file into `buf` (procfs needs one read, no stat).
+fn readProc(buf: []u8, path: [*:0]const u8) ?[]const u8 {
+ const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true });
+ if (fd < 0) return null;
+ defer _ = libc.close(fd);
+ const got = libc.read(fd, buf.ptr, buf.len);
+ if (got <= 0) return null;
+ return buf[0..@intCast(got)];
+}
+
+/// What a `snap` capture is made of: the grid text plus the cursor cell.
+fn stateKey(h: *eh.Harness) ![]u8 {
+ const text = try h.screenText();
+ defer h.gpa.free(text);
+ const c = h.cursor();
+ return std.fmt.allocPrint(h.gpa, "{d},{d}\n{s}", .{ c.x, c.y, text });
}
fn snapText(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {
diff --git a/test/snapshots/chordcut.snap b/test/snapshots/chordcut.snap
index fc9ccb40..a412b57e 100644
--- a/test/snapshots/chordcut.snap
+++ b/test/snapshots/chordcut.snap
@@ -17,7 +17,7 @@ stable 400 5000
key i
text f.txt
key esc
-settle 700
+settle 100
stable 400 5000
press right 6 8
release right 6 8
@@ -65,7 +65,7 @@ stable 400 5000
key i
text echo B''OO g.txt
key esc
-settle 700
+settle 100
stable 400 5000
# 2-3 cancel (acme textselect2): tap right during the middle execute-drag
# over `echo B''OO` — the command never runs (no BOO anywhere)
diff --git a/test/snapshots/cursorline.snap b/test/snapshots/cursorline.snap
index a7e38423..5519287e 100644
--- a/test/snapshots/cursorline.snap
+++ b/test/snapshots/cursorline.snap
@@ -10,7 +10,7 @@ stable 400 5000
key i
text cur.txt
key esc
-settle 700
+settle 100
stable 400 5000
press right 6 8
release right 6 8
diff --git a/test/snapshots/dump.snap b/test/snapshots/dump.snap
index 2cd2ace0..9ec0b457 100644
--- a/test/snapshots/dump.snap
+++ b/test/snapshots/dump.snap
@@ -1,4 +1,5 @@
-# the Dump builtin serializes the session to /tmp/pardes-dump.zon; the ZON must
+# the Dump builtin serializes the session to $DUMP (this script's own dump
+# file under /tmp/pardes-snap/dump/); the ZON must
# be byte-identical between old and new (golden generated from the OLD binary)
file d.txt x
start 30 100 -n 3
@@ -10,9 +11,9 @@ stable 400 5000
key i
text typed-marker
key esc
-settle 700
+settle 100
stable 400 5000
press middle 35 1
release middle 35 1
-settle 800
-snapfile dump /tmp/pardes-dump.zon
+stable 600 8000
+snapfile dump $DUMP
diff --git a/test/snapshots/edit.snap b/test/snapshots/edit.snap
index 52e287d8..5a0b610e 100644
--- a/test/snapshots/edit.snap
+++ b/test/snapshots/edit.snap
@@ -5,11 +5,11 @@ wait 8000 e.txt
stable 700 20000
press left 5 10
release left 5 10
-settle 400
+stable 400 8000
key i
text hello-insert
key esc
-settle 700
+settle 100
stable 500 8000
snap inserted
snapstyle inserted
diff --git a/test/snapshots/exec-bigdrag.snap b/test/snapshots/exec-bigdrag.snap
index 9158f08d..91ff1727 100644
--- a/test/snapshots/exec-bigdrag.snap
+++ b/test/snapshots/exec-bigdrag.snap
@@ -12,7 +12,7 @@ stable 300 5000
key i
text tr -d '\n' < big.txt
key esc
-settle 400
+settle 100
press middle 105 8
drag middle 126 8
release middle 126 8
@@ -22,7 +22,7 @@ press middle 105 4
drag middle 150 30
drag middle 198 58
release middle 198 58
-settle 500
+stable 500 8000
stable 800 30000
# still alive: type and execute a marker command
press left 105 40
@@ -31,7 +31,7 @@ stable 300 5000
key i
text echo alive-o''k
key esc
-settle 300
+settle 100
press middle 105 40
drag middle 119 40
release middle 119 40
diff --git a/test/snapshots/exec.snap b/test/snapshots/exec.snap
index a66285d5..fcbc0746 100644
--- a/test/snapshots/exec.snap
+++ b/test/snapshots/exec.snap
@@ -11,7 +11,7 @@ stable 400 5000
key i
text echo MID''OK
key esc
-settle 700
+settle 100
stable 400 5000
press middle 5 8
drag middle 16 8
diff --git a/test/snapshots/fileedit.snap b/test/snapshots/fileedit.snap
index 8bbe4eac..8a29b66b 100644
--- a/test/snapshots/fileedit.snap
+++ b/test/snapshots/fileedit.snap
@@ -9,7 +9,7 @@ stable 400 5000
key i
text f.txt
key esc
-settle 700
+settle 100
press right 6 8
release right 6 8
wait 10000 line 3
@@ -21,7 +21,7 @@ stable 400 5000
key i
text EDIT''ME
key esc
-settle 700
+settle 100
stable 400 5000
snap edited
# x-select line 2 and delete it
@@ -36,5 +36,5 @@ snap undone
# Save via the tag: middle-click the Save word (after "NOR <path> ")
press middle 43 2
release middle 43 2
-settle 800
+stable 600 8000
snapfile saved /tmp/pardes-snap/fileedit/cwd/f.txt
diff --git a/test/snapshots/find.snap b/test/snapshots/find.snap
index bad36bb6..8be0e511 100644
--- a/test/snapshots/find.snap
+++ b/test/snapshots/find.snap
@@ -63,7 +63,7 @@ release middle 8 3
stable 700 10000
snap word
key esc
-settle 700
+settle 100
stable 400 5000
snap cancel
# a no-match walk empties the buffer; n then has nowhere to go. The list is
diff --git a/test/snapshots/fsearch.snap b/test/snapshots/fsearch.snap
index 7953b408..c4633cda 100644
--- a/test/snapshots/fsearch.snap
+++ b/test/snapshots/fsearch.snap
@@ -15,7 +15,7 @@ stable 400 5000
key i
text g.txt
key esc
-settle 700
+settle 100
stable 400 5000
press right 6 8
release right 6 8
@@ -51,7 +51,7 @@ snap back
key /
text zz
key esc
-settle 700
+settle 100
stable 400 5000
snap cancel
# a no-match search: n has nowhere to go (nothing changes, no crash)
diff --git a/test/snapshots/hscroll.snap b/test/snapshots/hscroll.snap
index 97e8c99d..3f371147 100644
--- a/test/snapshots/hscroll.snap
+++ b/test/snapshots/hscroll.snap
@@ -14,7 +14,7 @@ stable 400 5000
key i
text wide.txt
key esc
-settle 700
+settle 100
stable 400 5000
press right 7 8
release right 7 8
diff --git a/test/snapshots/images.snap b/test/snapshots/images.snap
index e31d57ec..696aaec1 100644
--- a/test/snapshots/images.snap
+++ b/test/snapshots/images.snap
@@ -12,7 +12,7 @@ stable 400 5000
key i
text pic.ppm
key esc
-settle 700
+settle 100
press right 6 8
release right 6 8
wait 10000 img
diff --git a/test/snapshots/layout-close.snap b/test/snapshots/layout-close.snap
index d4160221..f353f39f 100644
--- a/test/snapshots/layout-close.snap
+++ b/test/snapshots/layout-close.snap
@@ -36,7 +36,7 @@ stable 400 5000
key i
text exit
key esc
-settle 700
+settle 100
key tab
stable 700 10000
snap exit
@@ -48,7 +48,7 @@ stable 400 5000
key i
text exit
key esc
-settle 700
+settle 100
key tab
stable 700 10000
snap colclose
diff --git a/test/snapshots/layout-open.snap b/test/snapshots/layout-open.snap
index ee909f2e..36d8bb1b 100644
--- a/test/snapshots/layout-open.snap
+++ b/test/snapshots/layout-open.snap
@@ -16,7 +16,7 @@ key i
text echo one; echo two
key enter
key esc
-settle 700
+settle 100
stable 400 5000
snap before
# Alt-n: new shell below pane 0 — the bottom pane's boundary must not move
@@ -38,7 +38,7 @@ stable 400 5000
key i
text sub/f.txt
key esc
-settle 700
+settle 100
stable 400 5000
press right 7 27
release right 7 27
diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap
index 70b8a4d4..c8e13b8e 100644
--- a/test/snapshots/leader.snap
+++ b/test/snapshots/leader.snap
@@ -21,7 +21,7 @@ stable 400 5000
snap spc-h
# Esc abandons the sequence: indicator gone, nothing ran, cursor untouched
key esc
-settle 700
+settle 100
stable 400 5000
snap abandoned
# an UNMAPPED key abandons too, rather than waiting: a typo must not leave the
@@ -85,6 +85,6 @@ snap help-window
# nothing worth keeping, so the key that elsewhere only drops a selection
# dismisses the whole pane and hands its height back to the pane above.
key esc
-settle 700
+settle 100
stable 700 15000
snap esc-del
diff --git a/test/snapshots/load.snap b/test/snapshots/load.snap
index 4b6372c4..561d30ee 100644
--- a/test/snapshots/load.snap
+++ b/test/snapshots/load.snap
@@ -19,8 +19,8 @@ wheel up 10 8
stable 500 8000
press middle 35 1
release middle 35 1
-settle 800
-start 30 100 -l /tmp/pardes-dump.zon
+stable 600 8000
+start 30 100 -l $DUMP
wait 8000 LOADMARK
stable 700 20000
snap loaded
diff --git a/test/snapshots/look-center.snap b/test/snapshots/look-center.snap
index 8bd3ea72..1b11bfd8 100644
--- a/test/snapshots/look-center.snap
+++ b/test/snapshots/look-center.snap
@@ -12,7 +12,7 @@ stable 400 5000
key i
text big.txt:100 big.txt:150 big.txt:2
key esc
-settle 700
+settle 100
stable 400 5000
press right 7 8
release right 7 8
diff --git a/test/snapshots/look-dir.snap b/test/snapshots/look-dir.snap
index 38023fbd..918a5de5 100644
--- a/test/snapshots/look-dir.snap
+++ b/test/snapshots/look-dir.snap
@@ -11,7 +11,7 @@ stable 400 5000
key i
text sub
key esc
-settle 700
+settle 100
stable 400 5000
press right 6 8
release right 6 8
diff --git a/test/snapshots/look-file-1col.snap b/test/snapshots/look-file-1col.snap
index 3ef7e579..ce0ec78d 100644
--- a/test/snapshots/look-file-1col.snap
+++ b/test/snapshots/look-file-1col.snap
@@ -11,7 +11,7 @@ stable 400 5000
key i
text big.txt:40
key esc
-settle 700
+settle 100
stable 400 5000
press right 7 8
release right 7 8
diff --git a/test/snapshots/look-file.snap b/test/snapshots/look-file.snap
index 81cf14cb..4b8b39b7 100644
--- a/test/snapshots/look-file.snap
+++ b/test/snapshots/look-file.snap
@@ -10,7 +10,7 @@ stable 400 5000
key i
text big.txt:40
key esc
-settle 700
+settle 100
stable 400 5000
press right 7 8
release right 7 8
diff --git a/test/snapshots/lookloaded.snap b/test/snapshots/lookloaded.snap
index 89106d25..68674ba2 100644
--- a/test/snapshots/lookloaded.snap
+++ b/test/snapshots/lookloaded.snap
@@ -12,7 +12,7 @@ stable 400 5000
key i
text big.txt:40 big.txt:10
key esc
-settle 700
+settle 100
stable 400 5000
press right 7 8
release right 7 8
diff --git a/test/snapshots/lookpanes.snap b/test/snapshots/lookpanes.snap
index 98648226..64bb4a98 100644
--- a/test/snapshots/lookpanes.snap
+++ b/test/snapshots/lookpanes.snap
@@ -34,7 +34,7 @@ stable 400 5000
key i
text zqonly.txt pick.txt twin.txt
key esc
-settle 700
+settle 100
stable 400 5000
snap shells
# make the beta pane the most recently focused, so the walk has to pass it
diff --git a/test/snapshots/modal.snap b/test/snapshots/modal.snap
index 68809e18..b6769fdd 100644
--- a/test/snapshots/modal.snap
+++ b/test/snapshots/modal.snap
@@ -35,11 +35,11 @@ stable 400 5000
snap vsel
snapstyle vsel
key esc
-settle 700
+settle 100
key i
text TYPED
key esc
-settle 700
+settle 100
stable 400 5000
snap typed
key u
diff --git a/test/snapshots/reflow.snap b/test/snapshots/reflow.snap
index 0fa4a30d..931fe9d4 100644
--- a/test/snapshots/reflow.snap
+++ b/test/snapshots/reflow.snap
@@ -8,7 +8,7 @@ start 24 40
wait 8000 $
text printf 'y%.0s' $(seq 1 3000); echo do''ne
key enter
-settle 2500
+stable 600 15000
stable 700 15000
wheel up 10 10
wheel up 10 10
diff --git a/test/snapshots/restore.golden b/test/snapshots/restore.golden
index 611b9fc6..4ca6cedf 100644
--- a/test/snapshots/restore.golden
+++ b/test/snapshots/restore.golden
@@ -1,5 +1,5 @@
== snap dumped grid=100x30 cursor=4,5
-|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-dump.zon
+|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-snap/restore/dump.zon
| TTY /tmp/pardes-snap/restore/cwd Del
| $ ls
| $ echo KEEP''ME
diff --git a/test/snapshots/rsearch.snap b/test/snapshots/rsearch.snap
index 16f40137..669483e9 100644
--- a/test/snapshots/rsearch.snap
+++ b/test/snapshots/rsearch.snap
@@ -17,7 +17,7 @@ stable 400 5000
key i
text code.txt
key esc
-settle 700
+settle 100
stable 400 5000
press right 6 8
release right 6 8
diff --git a/test/snapshots/shrinktag.snap b/test/snapshots/shrinktag.snap
index d82f8192..94d36282 100644
--- a/test/snapshots/shrinktag.snap
+++ b/test/snapshots/shrinktag.snap
@@ -13,7 +13,7 @@ key i
text echo one; echo two
key enter
key esc
-settle 700
+settle 100
stable 400 5000
snap before
# drag pane 0's bottom handle (its last row) up to its tag row: tag-only pane
diff --git a/test/snapshots/syntax.snap b/test/snapshots/syntax.snap
index 0aa85b71..d660fdd9 100644
--- a/test/snapshots/syntax.snap
+++ b/test/snapshots/syntax.snap
@@ -10,7 +10,7 @@ stable 400 5000
key i
text demo.zig
key esc
-settle 700
+settle 100
press right 6 8
release right 6 8
wait 10000 @import
diff --git a/test/snapshots/tag.snap b/test/snapshots/tag.snap
index 4094e841..079244e1 100644
--- a/test/snapshots/tag.snap
+++ b/test/snapshots/tag.snap
@@ -12,7 +12,7 @@ release left 40 2
stable 400 5000
snap tag-focus
send \x20t.txt
-settle 700
+stable 600 8000
stable 400 5000
snap tag-typed
key bs
@@ -22,11 +22,11 @@ key bs
stable 400 5000
snap tag-del
send .txt
-settle 700
+stable 600 8000
stable 400 5000
# Esc: out of the tag, body back in normal mode, typed tail stays as text
key esc
-settle 700
+settle 100
stable 400 5000
snap tag-exit
# re-enter the tag and look the typed word: Enter opens t.txt as a file pane
diff --git a/test/snapshots/tagcmd.snap b/test/snapshots/tagcmd.snap
index d7d002ff..d5317b8d 100644
--- a/test/snapshots/tagcmd.snap
+++ b/test/snapshots/tagcmd.snap
@@ -14,7 +14,7 @@ stable 700 20000
key i
text ZZEDIT
key esc
-settle 700
+settle 100
stable 400 5000
snap edited
# `:` -> the tag, in normal mode, cursor on the tail's first cell
@@ -27,7 +27,7 @@ stable 400 5000
snap word
# Tab executes it: c.txt is written and focus is back in the body
key tab
-settle 800
+stable 600 8000
stable 400 5000
snap saved
snapfile written /tmp/pardes-snap/tagcmd/cwd/c.txt
@@ -35,6 +35,6 @@ snapfile written /tmp/pardes-snap/tagcmd/cwd/c.txt
key :
stable 400 5000
key esc
-settle 700
+settle 100
stable 400 5000
snap escaped
diff --git a/test/snapshots/tagnav.golden b/test/snapshots/tagnav.golden
index 77d5a846..6ea682d2 100644
--- a/test/snapshots/tagnav.golden
+++ b/test/snapshots/tagnav.golden
@@ -495,7 +495,7 @@
|
|
== snap topbar-exec grid=100x30 cursor=7,2
-|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-dump.zon
+|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-snap/tagnav/dump.zon
| NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del TTY /tmp/pardes-snap/tagnav/cwd Del
| 1 alpha $ ls
| 2 beta w.txt
@@ -526,7 +526,7 @@
|
|
== snap topbar-restore-word grid=100x30 cursor=39,0
-|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-dump.zon
+|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-snap/tagnav/dump.zon
| NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del TTY /tmp/pardes-snap/tagnav/cwd Del
| 1 alpha $ ls
| 2 beta w.txt
diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap
index fbb9c917..3da71cd9 100644
--- a/test/snapshots/tagnav.snap
+++ b/test/snapshots/tagnav.snap
@@ -69,7 +69,7 @@ key right right
stable 400 5000
snap arrows-still-motion
key esc
-settle 700
+settle 100
stable 400 5000
snap back-to-body
# A TERMINAL MUST NOT LOSE ITS TTY MODE. Put the launch shell in raw tty mode
@@ -87,7 +87,7 @@ key l
stable 400 5000
snap tty-tag
key esc
-settle 700
+settle 100
stable 400 5000
snap tty-restored
# Enter/Tab on the BAR runs the word under the cursor through the very dispatch
diff --git a/test/snapshots/tagpath.snap b/test/snapshots/tagpath.snap
index 61181dd5..e4517ddc 100644
--- a/test/snapshots/tagpath.snap
+++ b/test/snapshots/tagpath.snap
@@ -37,7 +37,7 @@ snapstyle sel-path
# the file, which is the only way a snapshot can see what was yanked.
key y
key esc
-settle 700
+settle 100
stable 400 5000
key p
stable 400 5000
diff --git a/test/snapshots/tinywin.snap b/test/snapshots/tinywin.snap
index fab7f950..95050301 100644
--- a/test/snapshots/tinywin.snap
+++ b/test/snapshots/tinywin.snap
@@ -5,11 +5,11 @@
start 30 100
wait 8000 $
key a-n
-settle 400
+stable 400 8000
key a-n
-settle 400
+stable 400 8000
key a-n
-settle 400
+stable 400 8000
key a-n
stable 700 15000
snap stacked
@@ -20,9 +20,9 @@ snap shrunk
resize 30 100
stable 700 10000
key space c n
-settle 600
+stable 600 8000
key space c n
-settle 600
+stable 600 8000
key space c n
stable 700 15000
snap columns
diff --git a/test/snapshots/ttylook.snap b/test/snapshots/ttylook.snap
index f22cd0d1..cc746970 100644
--- a/test/snapshots/ttylook.snap
+++ b/test/snapshots/ttylook.snap
@@ -29,7 +29,7 @@ snap tokback
key i
text nn
key esc
-settle 700
+settle 100
stable 400 5000
snap insert
key u
diff --git a/test/snapshots/yankpaste.snap b/test/snapshots/yankpaste.snap
index 96082ad6..3445fe98 100644
--- a/test/snapshots/yankpaste.snap
+++ b/test/snapshots/yankpaste.snap
@@ -31,7 +31,7 @@ snap charwise-inline
# y then p. It pastes as one empty line.
key o
key esc
-settle 700
+settle 100
key y p
stable 400 5000
snap blankline