summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 17:03:15 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit3267d276ab2553adb412183eb92390ed579b494f (patch)
tree060a631c88777cea9d022bd0713629b4e033fb78 /test
parent8509453c2fa78357187f1c4a7e99bfddf29027d8 (diff)
downloadpardes-3267d276ab2553adb412183eb92390ed579b494f.tar.gz
pardes-3267d276ab2553adb412183eb92390ed579b494f.zip
zig build monkey: random snapshot scripts hunting panics
test/monkey.zig (Zig, as the project's tooling is): one random .snap per seed, over bad-UTF-8, wide-glyph and long files: keys, leader bursts, typing (an alphabet with no path characters), workspace words run by a middle click (Newcol for narrow panes, Find and Grep for prompts, long prompt text), presses, drags, wheel, hover, and resizes down to a cell. pardes-snap runs it with --update; a panic is a record in the app's crash file (in the script's fake home: seen even when the crash cut the script short) or a `.zig:N:N: 0x` frame of the app's (not the harness's) in its report or captured grid, and a hit keeps the script, captures and log under --out (default /tmp/pardes-monkey). A seed always makes the same script (tested), so --from=N replays one. Not a gate. README documents it next to snap. No Shader line: the tty has no post chain; the core fuzz in src/draw.zig covers the compile-error notice at a gui's metrics. 80 seeds x 70 steps: no panic.
Diffstat (limited to 'test')
-rw-r--r--test/monkey.zig244
1 files changed, 244 insertions, 0 deletions
diff --git a/test/monkey.zig b/test/monkey.zig
new file mode 100644
index 00000000..83869ce4
--- /dev/null
+++ b/test/monkey.zig
@@ -0,0 +1,244 @@
+// pardes-monkey: random snapshot scripts against the tty, hunting panics.
+//
+// zig build monkey -Dplatform=tty -- <seeds> [steps] [--from=N] [--out=DIR] [--keep]
+//
+// Each seed makes one script (a fixed seed makes the same script, so a hit
+// replays): keys and leader bursts, typing, mouse presses, drags and wheel,
+// tiny resizes, prompts and long prompt text, over files of bad UTF-8 and
+// wide glyphs. pardes-snap runs it with --update into DIR (default
+// /tmp/pardes-monkey), and a panic is a record in the app's crash file
+// (src/crash.zig, in the script's fake home: found even when the crash cut
+// the script short) or a `.zig:N:N: 0x` stack frame in the report or the
+// grid it captured (a trace renders INTO the screen). A hit
+// keeps DIR/seed-N.snap, its captures and its report; the rest are deleted.
+// A panic's trace can scroll off a small grid: replay the kept script, or
+// run the app in a larger pty. Every hit gets a fix and a pinned regression
+// script in test/snapshots/.
+//
+// Not a test gate: it is slow, random over seeds and aimed at finding, not
+// holding. Shell text typed into a pane only ever runs in the throwaway
+// script directory, and its alphabet has no path characters.
+const std = @import("std");
+const libc = std.c;
+
+const Script = struct {
+ out: std.ArrayListUnmanaged(u8) = .empty,
+ gpa: std.mem.Allocator,
+ rows: u16 = 24,
+ cols: u16 = 80,
+
+ fn line(s: *Script, comptime fmt: []const u8, args: anytype) !void {
+ try s.out.print(s.gpa, fmt ++ "\n", args);
+ }
+};
+
+// Workspace-tag words: clicked with the middle button they run, and the
+// harness finds their column on row 1 while the window is wide enough.
+const commands = [_][]const u8{ "Newcol", "Find", "Grep", "Joincol", "Help", "Dump", "NextColor", "Debug" };
+const leader_keys = "abcdefghijklmnopqrstuvwxyz0123456789$^";
+const typed = "abcdefghijklmnopqrstuvwxyz0123456789 -_=+,:;()";
+
+fn generate(gpa: std.mem.Allocator, seed: u64, steps: usize) ![]u8 {
+ var prng = std.Random.DefaultPrng.init(seed);
+ const random = prng.random();
+ var s: Script = .{ .gpa = gpa };
+ try s.line("# pardes-monkey seed {d}, {d} steps", .{ seed, steps });
+ try s.line("file bad.txt hello \\xff\\xfe world\\nlatin-1 caf\\xe9 tail\\ntruncated \\xe6\\x97 here\\nend\\n", .{});
+ try s.line("file wide.txt {s}tail\\nsecond line\\n", .{"\\xe6\\x97\\xa5\\xe6\\x9c\\xac\\xe8\\xaa\\x9e " ** 8});
+ try s.line("lines long.txt 400 some words after the number, long enough to wrap in a narrow pane", .{});
+ try s.line("start 24 80 bad.txt", .{});
+ try s.line("wait 8000 Newcol Joincol", .{});
+ try s.line("stable 300 5000", .{});
+ for (0..steps) |step| {
+ const wide = s.cols >= 70 and s.rows >= 6;
+ switch (random.uintLessThan(u8, 12)) {
+ // A workspace word, run with a middle click: new columns make
+ // narrow panes, Find and Grep open prompts.
+ 0, 1 => if (wide) {
+ const word = commands[random.uintLessThan(usize, commands.len)];
+ try s.line("press middle @{s} 1", .{word});
+ try s.line("release middle @{s} 1", .{word});
+ },
+ // Typing: into a prompt when one is up, far past its band.
+ 2, 3 => {
+ var text: [200]u8 = undefined;
+ const n = random.intRangeAtMost(usize, 1, if (random.uintLessThan(u8, 4) == 0) text.len else 12);
+ for (text[0..n]) |*c| c.* = typed[random.uintLessThan(usize, typed.len)];
+ try s.line("text {s}", .{text[0..n]});
+ },
+ 4 => try s.line("key {s}", .{switch (random.uintLessThan(u8, 10)) {
+ 0 => "enter",
+ 1 => "tab",
+ 2 => "bs",
+ 3 => "up",
+ 4 => "down",
+ 5 => "left",
+ 6 => "right",
+ 7 => "pgdn",
+ 8 => "home",
+ else => "end",
+ }}),
+ 5 => {
+ try s.line("key esc", .{});
+ try s.line("settle 120", .{});
+ },
+ // A leader burst: space and a chord's keys.
+ 6 => {
+ var chord: [3][2]u8 = undefined;
+ const n = random.intRangeAtMost(usize, 1, 3);
+ for (chord[0..n]) |*c| c.* = .{ leader_keys[random.uintLessThan(usize, leader_keys.len)], 0 };
+ try s.out.appendSlice(gpa, "key space");
+ for (chord[0..n]) |c| try s.out.print(gpa, " {c}", .{c[0]});
+ try s.out.append(gpa, '\n');
+ },
+ 7, 8 => {
+ const buttons = [_][]const u8{ "left", "middle", "right" };
+ const button = buttons[random.uintLessThan(usize, buttons.len)];
+ const col = random.intRangeAtMost(u16, 1, s.cols);
+ const row = random.intRangeAtMost(u16, 1, s.rows);
+ try s.line("press {s} {d} {d}", .{ button, col, row });
+ if (random.boolean()) try s.line("drag {s} {d} {d}", .{ button, random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) });
+ try s.line("release {s} {d} {d}", .{ button, random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) });
+ },
+ 9 => try s.line("wheel {s} {d} {d}", .{ if (random.boolean()) "up" else "down", random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) }),
+ 10 => try s.line("motion {d} {d}", .{ random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) }),
+ // Tiny windows as often as ordinary ones.
+ else => {
+ s.rows = if (random.boolean()) random.intRangeAtMost(u16, 1, 6) else random.intRangeAtMost(u16, 7, 40);
+ s.cols = if (random.boolean()) random.intRangeAtMost(u16, 1, 20) else random.intRangeAtMost(u16, 21, 140);
+ try s.line("resize {d} {d}", .{ s.rows, s.cols });
+ },
+ }
+ try s.line("settle 30", .{});
+ if (step % 10 == 9) try s.line("snap s{d}", .{step});
+ }
+ try s.line("stable 300 5000", .{});
+ try s.line("snap end", .{});
+ return s.out.toOwnedSlice(gpa);
+}
+
+/// A stack frame of a Zig panic trace, `.zig:12:34: 0x`, of the app's: the
+/// harness's own error trace (a script it could not finish) is not a hit.
+fn hasTrace(bytes: []const u8) bool {
+ var lines = std.mem.splitScalar(u8, bytes, '\n');
+ while (lines.next()) |line| {
+ if (std.mem.indexOf(u8, line, "test/snapshot.zig") != null or std.mem.indexOf(u8, line, "test/e2e_harness.zig") != null) continue;
+ if (frameIn(line)) return true;
+ }
+ return false;
+}
+
+fn frameIn(bytes: []const u8) bool {
+ var from: usize = 0;
+ while (std.mem.indexOfPos(u8, bytes, from, ".zig:")) |at| : (from = at + 1) {
+ var i = at + ".zig:".len;
+ var digits: usize = 0;
+ while (i < bytes.len and std.ascii.isDigit(bytes[i])) : (i += 1) digits += 1;
+ if (digits == 0 or i >= bytes.len or bytes[i] != ':') continue;
+ i += 1;
+ digits = 0;
+ while (i < bytes.len and std.ascii.isDigit(bytes[i])) : (i += 1) digits += 1;
+ if (digits > 0 and std.mem.startsWith(u8, bytes[i..], ": 0x")) return true;
+ }
+ return false;
+}
+
+test "a panic trace is found, and ordinary colons are not" {
+ try std.testing.expect(hasTrace(" /src/draw.zig:814:31: 0x4212821 in joinNotice"));
+ try std.testing.expect(!hasTrace("draw.zig:814 is where it was; 12:30: 0x"));
+ try std.testing.expect(!hasTrace("/x/test/snapshot.zig:197:28: 0x1279761 in main (snapshot.zig)"));
+}
+
+test "a seed makes the same script every time" {
+ const a = try generate(std.testing.allocator, 42, 50);
+ defer std.testing.allocator.free(a);
+ const b = try generate(std.testing.allocator, 42, 50);
+ defer std.testing.allocator.free(b);
+ try std.testing.expectEqualStrings(a, b);
+}
+
+fn readAll(gpa: std.mem.Allocator, io: std.Io, path: []const u8) []u8 {
+ return std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 << 20)) catch &.{};
+}
+
+pub fn main(init: std.process.Init) !void {
+ const gpa = std.heap.page_allocator;
+ const io = init.io;
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ if (args.len < 4) {
+ std.debug.print("usage: pardes-monkey <pardes-snap> <pardes> <seeds> [steps] [--from=N] [--out=DIR]\n", .{});
+ std.process.exit(2);
+ }
+ const snap = args[1];
+ const app = args[2];
+ var seeds: u64 = 20;
+ var steps: usize = 70;
+ var from: u64 = 1;
+ var out: []const u8 = "/tmp/pardes-monkey";
+ // Keep every script and capture, not only a hit's: to see what it did.
+ var keep = false;
+ var positional: usize = 0;
+ for (args[3..]) |a| {
+ if (std.mem.startsWith(u8, a, "--from=")) {
+ from = try std.fmt.parseInt(u64, a["--from=".len..], 10);
+ } else if (std.mem.eql(u8, a, "--keep")) {
+ keep = true;
+ } else if (std.mem.startsWith(u8, a, "--out=")) {
+ out = a["--out=".len..];
+ } else {
+ switch (positional) {
+ 0 => seeds = try std.fmt.parseInt(u64, a, 10),
+ 1 => steps = try std.fmt.parseInt(usize, a, 10),
+ else => {},
+ }
+ positional += 1;
+ }
+ }
+ std.Io.Dir.cwd().createDirPath(io, out) catch {};
+ var hits: usize = 0;
+ var seed = from;
+ while (seed < from + seeds) : (seed += 1) {
+ const script = try generate(gpa, seed, steps);
+ defer gpa.free(script);
+ var path_buf: [4096]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, "{s}/seed-{d}.snap", .{ out, seed });
+ try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = script });
+ const run = std.process.run(gpa, io, .{
+ .argv = &.{ snap, app, "--update", "--no-retry", "--jobs=1", path },
+ .stdout_limit = .limited(64 << 20),
+ .stderr_limit = .limited(64 << 20),
+ }) catch |err| {
+ std.debug.print("seed {d}: pardes-snap did not run ({t})\n", .{ seed, err });
+ continue;
+ };
+ defer gpa.free(run.stdout);
+ defer gpa.free(run.stderr);
+ var golden_buf: [4096]u8 = undefined;
+ const golden_path = try std.fmt.bufPrint(&golden_buf, "{s}/seed-{d}.golden", .{ out, seed });
+ const golden = readAll(gpa, io, golden_path);
+ defer gpa.free(golden);
+ // The app's own crash file (src/crash.zig) in the script's fake home:
+ // found even when the crash cut the script short of a capture.
+ var crash_buf: [4096]u8 = undefined;
+ const crash_path = try std.fmt.bufPrint(&crash_buf, "/tmp/pardes-snap/seed-{d}/home/.config/pardes/crashes", .{seed});
+ const crashes = readAll(gpa, io, crash_path);
+ defer gpa.free(crashes);
+ if (crashes.len != 0 or hasTrace(run.stdout) or hasTrace(run.stderr) or hasTrace(golden)) {
+ hits += 1;
+ var log_buf: [4096]u8 = undefined;
+ const log_path = try std.fmt.bufPrint(&log_buf, "{s}/seed-{d}.log", .{ out, seed });
+ const both = try std.mem.concat(gpa, u8, &.{ crashes, run.stdout, run.stderr });
+ defer gpa.free(both);
+ try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = log_path, .data = both });
+ std.debug.print("PANIC seed {d}: {s}\n", .{ seed, path });
+ } else if (!keep) {
+ std.Io.Dir.cwd().deleteFile(io, path) catch {};
+ std.Io.Dir.cwd().deleteFile(io, golden_path) catch {};
+ std.debug.print("ok seed {d}\n", .{seed});
+ } else {
+ std.debug.print("ok seed {d} (kept)\n", .{seed});
+ }
+ }
+ std.debug.print("{d} of {d} seeds panicked; kept in {s}\n", .{ hits, seeds, out });
+ if (hits != 0) std.process.exit(1);
+}