//! Native-image backend harness. //! //! zig build image-harness fake a Kitty terminal and check //! real transmit + placement APCs //! zig build image-harness -Dplatform=gui run the real SDL GPU renderer, //! capture a PPM, inspect pixels //! //! Unlike the text snapshot suite, both arms observe the thing the native //! backend actually emits. The TTY arm completes vaxis's asynchronous //! capability handshake over a PTY; the GUI arm consumes PARDES_TEST's GPU //! readback, so a PETSCII fallback cannot accidentally satisfy either one. const std = @import("std"); const libc = std.c; const eh = @import("e2e_harness.zig"); 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 unsetenv(name: [*:0]const u8) c_int; const gpa = std.heap.page_allocator; const fixture_rgb = [3]u8{ 13, 77, 231 }; pub fn main(init: std.process.Init) !void { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const args = try init.minimal.args.toSlice(arena); if (args.len != 3) return error.BadArgs; const exe = try arena.dupeZ(u8, args[1]); const backend = args[2]; const base = try std.fmt.allocPrintSentinel(arena, "/tmp/pardes-image-harness-{d}", .{libc.getpid()}, 0); if (libc.mkdir(base, 0o755) != 0 and libc.errno(-1) != .EXIST) return error.MkdirFailed; defer cleanupBase(arena, base); _ = setenv("HOME", base, 1); _ = setenv("HISTFILE", "/dev/null", 1); _ = setenv("TERM", "xterm-256color", 1); _ = setenv("LC_ALL", "C", 1); if (std.mem.eql(u8, backend, "tty")) { try runKitty(arena, exe, base); } else if (std.mem.eql(u8, backend, "gui")) { try runGui(arena, exe, base); } else return error.BadArgs; } fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void { const names = [_][]const u8{ "native.ppm", "latest.ppm", "latest.ppm.tmp", ".bash_history" }; for (names) |name| { const path = std.fmt.allocPrintSentinel(arena, "{s}/{s}", .{ base, name }, 0) catch continue; _ = libc.unlink(path); } _ = libc.rmdir(base); } fn writePpm(arena: std.mem.Allocator, path: [:0]const u8, width: usize, height: usize) !void { var out: std.ArrayList(u8) = .empty; try out.appendSlice(arena, try std.fmt.allocPrint(arena, "P6\n{d} {d}\n255\n", .{ width, height })); for (0..width * height) |_| try out.appendSlice(arena, &fixture_rgb); try eh.writeFile(path, out.items); } fn runKitty(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { _ = unsetenv("PARDES_TEST"); _ = unsetenv("PARDES_TEST_CAPTURE_DIR"); const image_path = try std.fmt.allocPrintSentinel(arena, "{s}/native.ppm", .{base}, 0); try writePpm(arena, image_path, 2, 2); var h = try eh.Harness.initArgs(gpa, exe, 12, 30, image_path); defer h.deinit(); try h.pump(150); try h.expectRawContains("\x1b_Gi=1,a=q\x1b\\", "app did not issue the Kitty graphics capability query"); // APC says graphics is present; DA1 is the last capability response and // closes the asynchronous handshake. Arrow-up is an ordinary queued event // that wakes the main loop even on a terminal which supplied no in-band // resize report, matching the documented idle-handshake edge case. try h.send("\x1b_Gi=1;OK\x1b\\\x1b[?1;2c\x1b[A"); const deadline = eh.nowMs() + 3000; while (eh.nowMs() < deadline) { _ = try h.pumpOnce(50); const sent = std.mem.indexOf(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") != null; const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null; if (sent and placed) break; } try h.expectRawContains("\x1b_Gf=32,s=2,v=2,i=", "decoded RGBA was not transmitted to the Kitty terminal"); try h.expectRawContains("\x1b_Ga=p,i=", "transmitted Kitty image was not placed in the pane body"); // SPC t p toggles the attachment off and back on. The first transition // must release terminal-side storage; the second must transmit again // instead of resurrecting a stale pane-slot handle. try h.send(" tp"); const delete_deadline = eh.nowMs() + 2000; while (eh.nowMs() < delete_deadline and std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") == null) _ = try h.pumpOnce(50); try h.expectRawContains("\x1b_Ga=d,d=I,i=", "PETSCII toggle did not release the cached Kitty image"); try h.send(" tp"); const retransmit_deadline = eh.nowMs() + 2000; while (eh.nowMs() < retransmit_deadline and std.mem.count(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") < 2) _ = try h.pumpOnce(50); if (std.mem.count(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") < 2) return error.KittyRetransmitMissing; std.debug.print("native image harness ok: kitty transmit + placement\n", .{}); } fn runGui(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { const image_path = try std.fmt.allocPrintSentinel(arena, "{s}/native.ppm", .{base}, 0); const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0); try writePpm(arena, image_path, 32, 16); _ = libc.unlink(capture_path); _ = setenv("PARDES_TEST", "1", 1); _ = setenv("PARDES_TEST_CAPTURE_DIR", base, 1); _ = setenv("PARDES_TEST_COLS", "20", 1); _ = setenv("PARDES_TEST_ROWS", "10", 1); var h = try eh.Harness.initArgs(gpa, exe, 10, 20, image_path); defer h.deinit(); try h.pump(1200); const capture = readFile(gpa, capture_path) catch |err| { std.debug.print("SDL image harness produced no capture ({t}); raw child output: {s}\n", .{ err, h.raw.items }); return err; }; defer gpa.free(capture); const pixels = ppmPixels(capture) orelse return error.BadCapture; var exact: usize = 0; var i: usize = 0; while (i + 2 < pixels.len) : (i += 3) { if (std.mem.eql(u8, pixels[i .. i + 3], &fixture_rgb)) exact += 1; } // The source contributes 512 aligned pixels. Leave ample room for driver // filtering while still excluding glyph-art fallback and incidental UI // colors. if (exact < 200) { std.debug.print("SDL image harness found only {d} native-color pixels in the GPU capture\n", .{exact}); return error.NativePixelsMissing; } std.debug.print("native image harness ok: SDL GPU capture ({d} source pixels)\n", .{exact}); } fn readFile(allocator: std.mem.Allocator, path: [:0]const u8) ![]u8 { const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(allocator); var buf: [8192]u8 = undefined; while (true) { const n = libc.read(fd, &buf, buf.len); if (n < 0) return error.ReadFailed; if (n == 0) break; try out.appendSlice(allocator, buf[0..@intCast(n)]); } return out.toOwnedSlice(allocator); } fn ppmPixels(bytes: []const u8) ?[]const u8 { if (!std.mem.startsWith(u8, bytes, "P6\n")) return null; var newlines: usize = 0; for (bytes, 0..) |byte, i| if (byte == '\n') { newlines += 1; if (newlines == 3) return bytes[i + 1 ..]; }; return null; }