diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /test/snapshot.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-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.zig | 348 |
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 |
