summaryrefslogtreecommitdiff
path: root/test/snapshot.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /test/snapshot.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'test/snapshot.zig')
-rw-r--r--test/snapshot.zig348
1 files changed, 329 insertions, 19 deletions
diff --git a/test/snapshot.zig b/test/snapshot.zig
index 5dc2e6e9..a7a1ba48 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -48,6 +48,7 @@
// resize <rows> <cols>
// snap <label> capture grid text + cursor
// snapstyle <label> capture per-cell style runs (fg,bg,attrs)
+// snap9p <label> read core cells/styles over the child's 9P socket
//
// 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
@@ -62,6 +63,7 @@ const std = @import("std");
const libc = std.c;
const ghostty_vt = @import("ghostty-vt");
const eh = @import("e2e_harness.zig");
+const Client = @import("9p_io").Client;
// This harness parses the app's output with its OWN ghostty-vt, which narrates
// every sequence it does not implement — vaxis's startup capability probes
@@ -74,6 +76,7 @@ pub const std_options: std.Options = .{ .log_level = .err };
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
extern "c" fn system(cmd: [*:0]const u8) c_int;
+extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8;
const SNAP_BASE = "/tmp/pardes-snap";
var trace_stable = false;
@@ -82,6 +85,7 @@ var careful = false;
/// abs path of the deterministic mock language server the build handed over
/// (--lspmock=); null leaves the protocol client fully disabled for the run
var lspmock_bin: ?[:0]const u8 = null;
+var record_dir: ?[]const u8 = null;
const gpa = std.heap.page_allocator;
pub fn main(init: std.process.Init) !void {
@@ -89,6 +93,7 @@ pub fn main(init: std.process.Init) !void {
defer arena_state.deinit();
const arena = arena_state.allocator();
const args = try init.minimal.args.toSlice(init.arena.allocator());
+ if (args.len == 2 and std.mem.eql(u8, args[1], "--self-test")) return testCli(init);
// argv: exe-under-test, then flags/scripts. All paths made absolute up
// front because runScript chdirs into a per-script workdir.
@@ -98,6 +103,7 @@ pub fn main(init: std.process.Init) !void {
var exe: ?[]const u8 = null;
var update = false;
+ var retry_failures = true;
// 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
@@ -109,10 +115,14 @@ pub fn main(init: std.process.Init) !void {
for (args[1..]) |a| {
if (std.mem.eql(u8, a, "--update")) {
update = true;
+ } else if (std.mem.eql(u8, a, "--no-retry")) {
+ retry_failures = false;
} else if (std.mem.eql(u8, a, "--trace-stable")) {
trace_stable = true;
} else if (std.mem.startsWith(u8, a, "--lspmock=")) {
lspmock = try absPath(arena, orig_cwd, a["--lspmock=".len..]);
+ } else if (std.mem.startsWith(u8, a, "--record=")) {
+ record_dir = try absPath(arena, orig_cwd, a["--record=".len..]);
} else if (std.mem.startsWith(u8, a, "--jobs=")) {
jobs = @max(1, try std.fmt.parseInt(usize, a["--jobs=".len..], 10));
} else if (exe == null) {
@@ -134,8 +144,10 @@ pub fn main(init: std.process.Init) !void {
var saw_exe = false;
for (args[1..]) |a| {
if (std.mem.eql(u8, a, "--update") or
+ std.mem.eql(u8, a, "--no-retry") or
std.mem.eql(u8, a, "--trace-stable") or
std.mem.startsWith(u8, a, "--lspmock=") or
+ std.mem.startsWith(u8, a, "--record=") or
std.mem.startsWith(u8, a, "--jobs=")) continue;
if (!saw_exe) {
saw_exe = true;
@@ -157,6 +169,16 @@ pub fn main(init: std.process.Init) !void {
}.lt);
}
if (scripts.len == 0) fatal("no .snap scripts found", .{});
+ for (scripts, 0..) |script, i| for (scripts[0..i]) |previous| {
+ if (std.mem.eql(u8, std.fs.path.stem(script), std.fs.path.stem(previous))) {
+ std.debug.print("snapshot scripts share a work directory: {s} and {s}\n", .{ previous, script });
+ return error.DuplicateScriptName;
+ }
+ };
+ if (record_dir) |dir| {
+ if (update) fatal("--record and --update are separate modes", .{});
+ try mkdir(arena, dir, true);
+ }
try mkdir(arena, SNAP_BASE, true);
// one run at a time (this is the flock that used to be typed by hand)
@@ -165,20 +187,23 @@ pub fn main(init: std.process.Init) !void {
const codes = try arena.alloc(u8, scripts.len);
if (update) {
careful = true;
- try runBatch(arena, exe_z, toBatch(arena, scripts), true, jobs, codes);
+ try runBatch(arena, exe_z, try toBatch(arena, scripts), true, jobs, codes);
+ var update_failed = false;
+ for (scripts, codes) |script, code| if (code != 0) {
+ update_failed = true;
+ const report = readFileAlloc(arena, try reportPath(arena, script)) catch "child died without a report\n";
+ std.debug.print("{s}", .{report});
+ };
+ if (update_failed) return error.SnapshotRegenerationFailed;
careful = false;
std.debug.print("verifying {d} regenerated golden(s)\n", .{scripts.len});
}
- try runBatch(arena, exe_z, toBatch(arena, scripts), false, jobs, codes);
+ try runBatch(arena, exe_z, try 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
- // 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) {
+ const attempts = try arena.alloc(u8, scripts.len);
+ @memset(attempts, 0);
+ var retry_dir: ?[]const u8 = null;
+ if (jobs > 1 and retry_failures) {
var round: usize = 0;
while (round < 2) : (round += 1) {
var retry_count: usize = 0;
@@ -186,10 +211,18 @@ pub fn main(init: std.process.Init) !void {
if (c != 0) retry_count += 1;
}
if (retry_count == 0 or retry_count > 10) break;
+ if (retry_dir == null) {
+ var template = "/tmp/pardes-snap-retry-XXXXXX".* ++ [_:0]u8{0};
+ const path = mkdtemp(&template) orelse return error.TempDirectoryFailed;
+ retry_dir = try arena.dupe(u8, std.mem.span(path));
+ std.debug.print("retry captures: {s}\n", .{retry_dir.?});
+ }
const retry = try arena.alloc(usize, retry_count);
var retry_i: usize = 0;
for (codes, 0..) |c, i| {
if (c != 0) {
+ try archiveAttempt(init.io, arena, retry_dir.?, scripts[i], attempts[i]);
+ attempts[i] += 1;
retry[retry_i] = i;
retry_i += 1;
}
@@ -210,7 +243,11 @@ pub fn main(init: std.process.Init) !void {
}
var failed: usize = 0;
- for (scripts, codes) |script_path, code| {
+ for (scripts, codes, attempts) |script_path, code, attempt| {
+ if (attempt != 0) {
+ try archiveAttempt(init.io, arena, retry_dir.?, script_path, attempt);
+ std.debug.print("{s} required {d} retr{s}\n", .{ std.fs.path.stem(script_path), attempt, if (attempt == 1) "y" else "ies" });
+ }
const report = readFileAlloc(arena, reportPath(arena, script_path) catch "") catch "";
std.debug.print("{s}", .{report});
if (code != 0) {
@@ -230,6 +267,120 @@ pub fn main(init: std.process.Init) !void {
std.debug.print("all {d} snapshot scripts ok\n", .{scripts.len});
}
+fn testCli(init: std.process.Init) !void {
+ const io = init.io;
+ const arena = init.arena.allocator();
+ const exe = try std.process.executablePathAlloc(io, arena);
+ var template = "/tmp/pardes-snap-selftest-XXXXXX".* ++ [_:0]u8{0};
+ const root = std.mem.span(mkdtemp(&template) orelse return error.TempDirectoryFailed);
+ const prefix = std.fs.path.basename(root);
+ var dir = try std.Io.Dir.cwd().openDir(io, root, .{});
+ defer dir.close(io);
+ var archives: std.ArrayList([]const u8) = .empty;
+ var passed = false;
+ defer {
+ if (passed) {
+ for (archives.items) |path| std.Io.Dir.cwd().deleteTree(io, path) catch {};
+ for ([_][]const u8{ "bad", "good", "error", "unwritable" }) |name| {
+ const work = std.fmt.allocPrint(arena, "{s}/{s}-{s}", .{ SNAP_BASE, prefix, name }) catch continue;
+ std.Io.Dir.cwd().deleteTree(io, work) catch {};
+ const report = std.fmt.allocPrint(arena, "{s}.report", .{work}) catch continue;
+ std.Io.Dir.cwd().deleteFile(io, report) catch {};
+ }
+ std.Io.Dir.cwd().deleteTree(io, root) catch {};
+ } else std.debug.print("snapshot driver test evidence: {s}\n", .{root});
+ }
+ var scripts: [4][]const u8 = undefined;
+ for ([_][]const u8{ "bad", "good", "error", "unwritable" }, 0..) |name, i| {
+ const path = try std.fmt.allocPrint(arena, "{s}-{s}.snap", .{ prefix, name });
+ try dir.writeFile(io, .{ .sub_path = path, .data = if (i == 2) "run false\n" else "file marker content\n" });
+ scripts[i] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root, path });
+ const golden = try std.fmt.allocPrint(arena, "{s}-{s}.golden", .{ prefix, name });
+ try dir.writeFile(io, .{ .sub_path = golden, .data = if (i == 0 or i == 3) "expected capture\n" else "" });
+ }
+ try dir.createDir(io, "other", .default_dir);
+ const duplicate = try std.fmt.allocPrint(arena, "{s}/other/{s}", .{ root, std.fs.path.basename(scripts[1]) });
+ try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = duplicate, .data = "file marker content\n" });
+ const stale_actual = try std.fmt.allocPrint(arena, "{s}/{s}-error.actual", .{ root, prefix });
+ try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = stale_actual, .data = "older verification evidence\n" });
+ const unwritable = try std.fmt.allocPrint(arena, "{s}-unwritable.actual", .{prefix});
+ try dir.createDir(io, unwritable, .default_dir);
+ const record_arg = try std.fmt.allocPrint(arena, "--record={s}/recorded", .{root});
+ const fixtures = [_]struct {
+ name: []const u8,
+ args: []const []const u8,
+ status: u8 = 1,
+ message: []const u8,
+ archived: ?usize = null,
+ actual: bool = false,
+ }{
+ .{ .name = "retry", .args = &.{ scripts[0], scripts[1], "--jobs=2" }, .message = "required 2 retries", .archived = 0, .actual = true },
+ .{ .name = "no-retry", .args = &.{ scripts[0], scripts[1], "--jobs=2", "--no-retry" }, .message = "1/2 snapshot scripts FAILED" },
+ .{ .name = "duplicate", .args = &.{ scripts[1], duplicate, "--jobs=2" }, .message = "DuplicateScriptName" },
+ .{ .name = "record", .args = &.{ scripts[2], scripts[1], "--jobs=2", record_arg }, .message = "1/2 snapshot scripts FAILED", .archived = 2 },
+ .{ .name = "update", .args = &.{ scripts[2], "--jobs=2", "--update", "--no-retry" }, .message = "SnapshotRegenerationFailed" },
+ .{ .name = "actual-write", .args = &.{ scripts[3], "--no-retry" }, .message = "cannot write" },
+ .{ .name = "pass", .args = &.{ scripts[1], "--jobs=2" }, .status = 0, .message = "all 1 snapshot scripts ok" },
+ };
+ for (fixtures) |fixture| {
+ const argv = try arena.alloc([]const u8, fixture.args.len + 2);
+ argv[0] = exe;
+ argv[1] = exe;
+ @memcpy(argv[2..], fixture.args);
+ const result = try std.process.run(arena, io, .{
+ .argv = argv,
+ .stdout_limit = .limited(1024 * 1024),
+ .stderr_limit = .limited(1024 * 1024),
+ .timeout = .{ .duration = .{ .clock = .awake, .raw = .fromSeconds(30) } },
+ });
+ try dir.writeFile(io, .{ .sub_path = try std.fmt.allocPrint(arena, "{s}.log", .{fixture.name}), .data = result.stderr });
+ if (result.term != .exited or result.term.exited != fixture.status or std.mem.indexOf(u8, result.stderr, fixture.message) == null) {
+ std.debug.print("snapshot CLI fixture {s}:\n{s}\n", .{ fixture.name, result.stderr });
+ return error.SnapshotDriverTestFailed;
+ }
+ const archive_marker = "retry captures: ";
+ const archive_start = std.mem.indexOf(u8, result.stderr, archive_marker);
+ if (fixture.archived) |script| {
+ const rest = result.stderr[(archive_start orelse return error.MissingRetryArchive) + archive_marker.len ..];
+ const archive = rest[0 .. std.mem.indexOfScalar(u8, rest, '\n') orelse return error.MissingRetryArchive];
+ const archive_prefix = "/tmp/pardes-snap-retry-";
+ if (!std.mem.startsWith(u8, archive, archive_prefix) or archive.len == archive_prefix.len or
+ std.mem.indexOfScalar(u8, archive[archive_prefix.len..], '/') != null)
+ return error.IncorrectRetryArchive;
+ try archives.append(arena, archive);
+ if (std.mem.count(u8, result.stderr, "re-running ") != 2) return error.IncorrectRetryCount;
+ for (0..3) |attempt| {
+ const report = try std.fmt.allocPrint(arena, "{s}/{s}.{d}.report", .{ archive, std.fs.path.stem(scripts[script]), attempt });
+ const report_bytes = try std.Io.Dir.cwd().readFileAlloc(io, report, arena, .limited(1024 * 1024));
+ const failure = try std.fmt.allocPrint(arena, "FAIL {s}:", .{std.fs.path.stem(scripts[script])});
+ if (std.mem.indexOf(u8, report_bytes, failure) == null) return error.MissingAttemptReport;
+ const actual = try std.fmt.allocPrint(arena, "{s}/{s}.{d}.actual", .{ archive, std.fs.path.stem(scripts[script]), attempt });
+ const actual_bytes = std.Io.Dir.cwd().readFileAlloc(io, actual, arena, .limited(1024 * 1024)) catch |err| {
+ if (err == error.FileNotFound and !fixture.actual) continue;
+ return err;
+ };
+ if (!fixture.actual or actual_bytes.len != 0) return error.IncorrectAttemptActual;
+ }
+ } else if (archive_start != null or std.mem.indexOf(u8, result.stderr, "re-running ") != null) return error.UnexpectedRetry;
+ if (std.mem.eql(u8, fixture.name, "update") and std.mem.indexOf(u8, result.stderr, "verifying ") != null)
+ return error.VerifiedFailedRegeneration;
+ if (std.mem.eql(u8, fixture.name, "actual-write") and std.mem.indexOf(u8, result.stderr, "actual written") != null)
+ return error.FalseActualReport;
+ if (std.mem.eql(u8, fixture.name, "retry") or std.mem.eql(u8, fixture.name, "no-retry")) {
+ const good = try std.fmt.allocPrint(arena, "PASS {s}", .{std.fs.path.stem(scripts[1])});
+ if (std.mem.indexOf(u8, result.stderr, good) == null) return error.OmittedPassingScript;
+ }
+ if (std.mem.eql(u8, fixture.name, "record")) {
+ const good = try std.fmt.allocPrint(arena, "RECORDED {s}", .{std.fs.path.stem(scripts[1])});
+ if (std.mem.indexOf(u8, result.stderr, good) == null) return error.OmittedPassingScript;
+ const stale = try std.Io.Dir.cwd().readFileAlloc(io, stale_actual, arena, .limited(1024));
+ if (!std.mem.eql(u8, stale, "older verification evidence\n")) return error.ChangedStaleActual;
+ }
+ }
+ passed = true;
+ std.debug.print("snapshot driver: {d} CLI regression fixtures passed\n", .{fixtures.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
@@ -243,8 +394,8 @@ pub fn main(init: std.process.Init) !void {
// 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 &.{};
+fn toBatch(arena: std.mem.Allocator, scripts: []const []const u8) ![]const Idx {
+ const out = try arena.alloc(Idx, scripts.len);
for (scripts, out, 0..) |path, *o, i| o.* = .{ .path = path, .code_idx = i };
return out;
}
@@ -304,14 +455,20 @@ fn runOne(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8
// before it starts; a new mismatch below writes it back.
const actual_path = std.fmt.allocPrint(arena, "{s}.actual", .{base_path}) catch return 1;
const actual_path_z = arena.dupeZ(u8, actual_path) catch return 1;
- _ = libc.unlink(actual_path_z);
- const golden: ?[]u8 = readFileAlloc(arena, golden_path) catch null;
+ if (record_dir == null) _ = libc.unlink(actual_path_z);
+ const golden: ?[]u8 = if (record_dir == null) readFileAlloc(arena, golden_path) catch null else 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) });
+ std.debug.print("FAIL {s}: script error {s} ({d}ms)\n", .{ stem, @errorName(e), eh.nowMs() - started });
return 1;
};
const took = eh.nowMs() - started;
+ if (record_dir) |dir| {
+ const path = std.fmt.allocPrintSentinel(arena, "{s}/{s}.snapshot", .{ dir, stem }, 0) catch return 1;
+ eh.writeFile(path, out) catch return 1;
+ std.debug.print("RECORDED {s}\n", .{stem});
+ return 0;
+ }
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 });
@@ -325,8 +482,12 @@ fn runOne(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8
std.debug.print("PASS {s} ({d}ms)\n", .{ stem, took });
return 0;
}
- 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 });
+ eh.writeFile(arena.dupeZ(u8, actual_path) catch return 1, out) catch |err| {
+ std.debug.print("FAIL {s}: differs from golden; cannot write {s}: {s} ({d}ms)\n", .{ stem, actual_path, @errorName(err), took });
+ printFirstDiff(golden.?, out);
+ return 1;
+ };
+ std.debug.print("FAIL {s}: differs from golden (actual written to {s}, {d}ms)\n", .{ stem, actual_path, took });
printFirstDiff(golden.?, out);
return 1;
}
@@ -335,6 +496,25 @@ 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) });
}
+fn archiveAttempt(io: std.Io, arena: std.mem.Allocator, dir: []const u8, script_path: []const u8, attempt: u8) !void {
+ const stem = std.fs.path.stem(script_path);
+ for ([_][]const u8{ "report", "actual" }) |suffix| {
+ if (record_dir != null and std.mem.eql(u8, suffix, "actual")) continue;
+ const source = if (std.mem.eql(u8, suffix, "report"))
+ try reportPath(arena, script_path)
+ else
+ try std.fmt.allocPrint(arena, "{s}.actual", .{script_path[0 .. script_path.len - ".snap".len]});
+ const bytes = std.Io.Dir.cwd().readFileAlloc(io, source, arena, .limited(64 * 1024 * 1024)) catch |err| {
+ if (err == error.FileNotFound) continue;
+ return err;
+ };
+ const path = try std.fmt.allocPrint(arena, "{s}/{s}.{d}.{s}", .{ dir, stem, attempt, suffix });
+ const file = try std.Io.Dir.cwd().createFile(io, path, .{ .exclusive = true });
+ defer file.close(io);
+ try file.writeStreamingAll(io, bytes);
+ }
+}
+
/// Exclusive flock on `path`, announcing the wait if someone else holds it.
/// The fd is deliberately leaked: the lock lives until the process exits.
///
@@ -383,7 +563,7 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
// a hostname and whose greeting carries a version, so a golden taken
// against it would be a golden for this machine. Every script here runs
// bash — the .bashrc above pins its prompt to `$ ` — and the fish path is
- // covered by shell_bin.zig's unit tests instead. This is an ordinary
+ // covered by host_io.Shell's unit tests instead. This is an ordinary
// config init going through the ordinary startup-config path, so it also
// proves `Shell <name>` is dispatchable from one.
try mkdir(arena, config_home, false);
@@ -538,6 +718,8 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
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(), &prev));
+ } else if (std.mem.eql(u8, cmd, "snap9p")) {
+ try appendChunk(chunks, &chunk_len, try snap9p(arena, live(&h), tok.rest(), &prev));
} else {
std.debug.print("unknown command {s}\n", .{cmd});
return error.BadScript;
@@ -556,6 +738,7 @@ fn countCaptures(src: []const u8) usize {
const cmd = tok.next().?;
if (std.mem.eql(u8, cmd, "snap") or
std.mem.eql(u8, cmd, "snapstyle") or
+ std.mem.eql(u8, cmd, "snap9p") or
std.mem.eql(u8, cmd, "snapfile")) count += 1;
}
return count;
@@ -921,14 +1104,141 @@ fn wordByte(c: u8) bool {
const Prev = struct {
snap: []const []const u8 = &.{},
style: []const []const u8 = &.{},
+ ninep: []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,
+ ninep_cols: u16 = 0,
+};
+
+const Screen9p = struct {
+ cols: u16,
+ rows: u16,
+ cursor: ?struct { x: u16, y: u16, bar: bool },
+ cells: []const struct { []const u8, usize },
+ styles: []const struct {
+ fg: Color,
+ bg: Color,
+ bold: bool,
+ dim: bool,
+ italic: bool,
+ blink: bool,
+ reverse: bool,
+ invisible: bool,
+ strikethrough: bool,
+ ul: enum { off, single, double, curly, dotted, dashed },
+ font_role: enum { body, tagline },
+ },
+
+ const Color = union(enum) {
+ default,
+ index: u8,
+ rgb: [3]u8,
+
+ fn text(color: Color, buf: *[16]u8) []const u8 {
+ return switch (color) {
+ .default => "d",
+ .index => |index| std.fmt.bufPrint(buf, "p{d}", .{index}) catch unreachable,
+ .rgb => |rgb| std.fmt.bufPrint(buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb[0], rgb[1], rgb[2] }) catch unreachable,
+ };
+ }
+ };
};
+fn snap9p(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8, prev: *Prev) ![]u8 {
+ var dial_buf: [24]u8 = undefined;
+ const dial = try std.fmt.bufPrint(&dial_buf, "{d}", .{h.pid});
+ const bytes = try Client.read(gpa, dial, "/self/screen", "/self/screen");
+ defer gpa.free(bytes);
+ const parsed = try std.json.parseFromSlice(Screen9p, gpa, bytes, .{});
+ defer parsed.deinit();
+ const screen = parsed.value;
+ if (screen.cols != h.cols or screen.rows != h.rows or screen.cols == 0 or screen.rows == 0 or
+ screen.cells.len != @as(usize, screen.cols) * screen.rows or screen.styles.len == 0)
+ return error.Screen9pDimensions;
+ if (screen.cursor) |cursor| {
+ if (cursor.x >= screen.cols or cursor.y >= screen.rows) return error.Screen9pCursor;
+ }
+ for (screen.cells) |cell| {
+ if (cell[1] >= screen.styles.len or !std.unicode.utf8ValidateSlice(cell[0])) return error.Screen9pCell;
+ }
+
+ const keys = try arena.alloc([]const u8, screen.styles.len);
+ for (screen.styles, keys) |style, *key| {
+ var flags: [8]u8 = undefined;
+ var len: usize = 0;
+ inline for (.{ "bold", "dim", "italic", "blink", "reverse", "invisible", "strikethrough" }, "bfikrvx") |field, flag| {
+ if (@field(style, field)) {
+ flags[len] = flag;
+ len += 1;
+ }
+ }
+ if (style.ul != .off) {
+ flags[len] = switch (style.ul) {
+ .off => unreachable,
+ .single => 'u',
+ .double => 'U',
+ .curly => '~',
+ .dotted => '.',
+ .dashed => '-',
+ };
+ len += 1;
+ }
+ var fg_buf: [16]u8 = undefined;
+ var bg_buf: [16]u8 = undefined;
+ key.* = try std.fmt.allocPrint(arena, "{s},{s},{s},{s}", .{
+ style.fg.text(&fg_buf), style.bg.text(&bg_buf), flags[0..len], @tagName(style.font_role),
+ });
+ }
+
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== 9p {s} grid={d}x{d} cursor=", .{ label, screen.cols, screen.rows }));
+ try out.appendSlice(arena, if (screen.cursor) |c|
+ try std.fmt.allocPrint(arena, "{d},{d},{s}\n", .{ c.x, c.y, if (c.bar) "bar" else "block" })
+ else
+ "none\n");
+
+ const rows = try arena.alloc([]const u8, screen.rows);
+ const delta = prev.ninep.len == rows.len and prev.ninep_cols == screen.cols;
+ for (rows, 0..) |*row, y| {
+ var text_row: std.ArrayList(u8) = .empty;
+ const cells = screen.cells[y * screen.cols ..][0..screen.cols];
+ for (cells) |cell| try text_row.appendSlice(arena, cell[0]);
+ var capture: std.ArrayList(u8) = .empty;
+ const trimmed = std.mem.trimEnd(u8, text_row.items, " ");
+ try capture.appendSlice(arena, if (trimmed.len == 0)
+ try std.fmt.allocPrint(arena, "|{d}:\n", .{y})
+ else
+ try std.fmt.allocPrint(arena, "|{d}: {s}\n", .{ y, trimmed }));
+ var annotated = false;
+ for (cells, 0..) |cell, x| {
+ if (cell[0].len == 1) continue;
+ if (!annotated) try capture.appendSlice(arena, try std.fmt.allocPrint(arena, "+{d}:", .{y}));
+ const glyph = try std.json.Stringify.valueAlloc(arena, cell[0], .{});
+ try capture.appendSlice(arena, try std.fmt.allocPrint(arena, " {d}={s}", .{ x, glyph }));
+ annotated = true;
+ }
+ if (annotated) try capture.append(arena, '\n');
+ try capture.appendSlice(arena, try std.fmt.allocPrint(arena, "@{d}:", .{y}));
+ var start: usize = 0;
+ for (1..cells.len + 1) |x| {
+ const key = keys[cells[start][1]];
+ if (x != cells.len and std.mem.eql(u8, key, keys[cells[x][1]])) continue;
+ try capture.appendSlice(arena, try std.fmt.allocPrint(arena, " {d}-{d} {s}", .{ start, x - 1, key }));
+ start = x;
+ }
+ try capture.append(arena, '\n');
+ row.* = capture.items;
+ if (!delta or !std.mem.eql(u8, row.*, prev.ninep[y])) try out.appendSlice(arena, row.*);
+ }
+ prev.ninep = rows;
+ prev.ninep_cols = screen.cols;
+ return out.items;
+}
+
/// `== 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