//! 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 //! zig build pdf-harness render a generated multi-page PDF //! zig build pdf-harness -Dplatform=gui //! //! 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 }; // The harness pins configuration, so fresh PDFs use the legacy `helix` // theme (#080808 paper, #bdbdbd ink). These are the exact fixed-point // filtered-tint results for the fixture's saturated red and green pages. const pdf_filtered_red = [3]u8{ 255, 77, 77 }; const pdf_filtered_green = [3]u8{ 0, 162, 0 }; const pdf_filtered_blue = [3]u8{ 147, 147, 255 }; const PdfFirstPage = enum { red, blue }; 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 and args.len != 4) return error.BadArgs; const exe = try arena.dupeZ(u8, args[1]); const backend = args[2]; const pdf_mode = args.len == 4 and std.mem.eql(u8, args[3], "pdf"); if (args.len == 4 and !pdf_mode) return error.BadArgs; 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); // Do not let an exported developer XDG_CONFIG_HOME style or mutate the // native process under test; this nonexistent directory is test-owned. const config_home = try std.fmt.allocPrintSentinel(arena, "{s}/.config", .{base}, 0); _ = setenv("XDG_CONFIG_HOME", config_home, 1); if (libc.mkdir(config_home, 0o755) != 0) return error.MkdirFailed; const config_dir = try std.fmt.allocPrintSentinel(arena, "{s}/pardes", .{config_home}, 0); if (libc.mkdir(config_dir, 0o755) != 0) return error.MkdirFailed; try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/init", .{config_dir}, 0), "Theme helix\n"); _ = setenv("HISTFILE", "/dev/null", 1); _ = setenv("TERM", "xterm-256color", 1); _ = setenv("LC_ALL", "C", 1); if (std.mem.eql(u8, backend, "tty")) { if (pdf_mode) try runKittyPdf(arena, exe, base) else try runKitty(arena, exe, base); } else if (std.mem.eql(u8, backend, "gui")) { if (pdf_mode) try runGuiPdf(arena, exe, base) else try runGui(arena, exe, base); } else return error.BadArgs; } fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void { const init_path = std.fmt.allocPrintSentinel(arena, "{s}/.config/pardes/init", .{base}, 0) catch return; _ = libc.unlink(init_path); const config_dir = std.fmt.allocPrintSentinel(arena, "{s}/.config/pardes", .{base}, 0) catch return; _ = libc.rmdir(config_dir); const config_home = std.fmt.allocPrintSentinel(arena, "{s}/.config", .{base}, 0) catch return; _ = libc.rmdir(config_home); const names = [_][]const u8{ "native.ppm", "pages.pdf", "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); } const pdf_fixture_cap = 4096; const BoundedPdf = struct { bytes: [pdf_fixture_cap]u8 = undefined, len: usize = 0, fn appendSlice(out: *BoundedPdf, bytes: []const u8) !void { if (bytes.len > out.bytes.len - out.len) return error.FixturePdfTooLarge; @memcpy(out.bytes[out.len..][0..bytes.len], bytes); out.len += bytes.len; } fn written(out: *const BoundedPdf) []const u8 { return out.bytes[0..out.len]; } }; fn appendFmt(out: *BoundedPdf, comptime fmt: []const u8, args: anytype) !void { const bytes = std.fmt.bufPrint(out.bytes[out.len..], fmt, args) catch return error.FixturePdfTooLarge; out.len += bytes.len; } /// A self-contained, valid PDF fixture. The first two pages retain the red / /// green and search-text contract exercised below; two differently-sized /// trailing pages make skipped native renders visible in Kitty's transmit /// headers. Building it here keeps the backend test independent of mutool, /// fonts, and checked-in binary fixtures while the xref offsets remain exact. fn writePdf(arena: std.mem.Allocator, path: [:0]const u8, first_page: PdfFirstPage) !void { _ = arena; const first_stream = switch (first_page) { .red => \\1 0 0 rg \\0 0 72 48 re \\f \\0 0 0 rg \\BT \\/F1 10 Tf \\4 20 Td \\(FIRST NEEDLE) Tj \\ET , .blue => \\0 0 1 rg \\0 0 72 48 re \\f \\0 0 0 rg \\BT \\/F1 10 Tf \\4 20 Td \\(FIRST NEEDLE) Tj \\ET , }; const second_stream = \\0 1 0 rg \\0 0 72 48 re \\f \\0 0 0 rg \\BT \\/F1 10 Tf \\4 20 Td \\(SECOND TARGET) Tj \\ET ; const third_stream = \\0 0 1 rg \\0 0 72 50 re \\f \\0 0 0 rg \\BT \\/F1 10 Tf \\4 20 Td \\(THIRD PAGE) Tj \\ET ; const fourth_stream = \\1 1 0 rg \\0 0 72 52 re \\f \\0 0 0 rg \\BT \\/F1 10 Tf \\4 20 Td \\(FOURTH PAGE) Tj \\ET ; var out: BoundedPdf = .{}; var offsets: [16]usize = @splat(0); try out.appendSlice("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); offsets[1] = out.len; try out.appendSlice("1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 12 0 R /PageMode /UseOutlines >>\nendobj\n"); offsets[2] = out.len; try out.appendSlice("2 0 obj\n<< /Type /Pages /Count 4 /Kids [3 0 R 5 0 R 7 0 R 9 0 R] >>\nendobj\n"); offsets[3] = out.len; try out.appendSlice("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 4 0 R >>\nendobj\n"); offsets[4] = out.len; try appendFmt(&out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream }); offsets[5] = out.len; try out.appendSlice("5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 6 0 R >>\nendobj\n"); offsets[6] = out.len; try appendFmt(&out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream }); offsets[7] = out.len; try out.appendSlice("7 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 50] /Resources << /Font << /F1 11 0 R >> >> /Contents 8 0 R >>\nendobj\n"); offsets[8] = out.len; try appendFmt(&out, "8 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ third_stream.len, third_stream }); offsets[9] = out.len; try out.appendSlice("9 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 52] /Resources << /Font << /F1 11 0 R >> >> /Contents 10 0 R >>\nendobj\n"); offsets[10] = out.len; try appendFmt(&out, "10 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ fourth_stream.len, fourth_stream }); offsets[11] = out.len; try out.appendSlice("11 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); offsets[12] = out.len; try out.appendSlice("12 0 obj\n<< /Type /Outlines /First 13 0 R /Last 15 0 R /Count 3 >>\nendobj\n"); offsets[13] = out.len; try out.appendSlice("13 0 obj\n<< /Title (Upper) /Parent 12 0 R /First 14 0 R /Last 14 0 R /Next 15 0 R /Count 1 /Dest [3 0 R /XYZ 4 8 null] >>\nendobj\n"); offsets[14] = out.len; try out.appendSlice("14 0 obj\n<< /Title (Lower) /Parent 13 0 R /Dest [3 0 R /XYZ 4 28 null] >>\nendobj\n"); offsets[15] = out.len; try out.appendSlice("15 0 obj\n<< /Title (Page two) /Parent 12 0 R /Prev 13 0 R /Dest [5 0 R /XYZ 4 38 null] >>\nendobj\n"); const xref = out.len; try out.appendSlice("xref\n0 16\n0000000000 65535 f \n"); for (offsets[1..]) |offset| try appendFmt(&out, "{d:0>10} 00000 n \n", .{offset}); try appendFmt(&out, "trailer\n<< /Size 16 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref}); try eh.writeFile(path, out.written()); } fn writePpm(arena: std.mem.Allocator, path: [:0]const u8, width: usize, height: usize) !void { var header_buf: [64]u8 = undefined; const header = std.fmt.bufPrint(&header_buf, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.FixturePpmHeaderTooLarge; const pixel_count = try std.math.mul(usize, width, height); const pixel_bytes = try std.math.mul(usize, pixel_count, fixture_rgb.len); const out = try arena.alloc(u8, try std.math.add(usize, header.len, pixel_bytes)); @memcpy(out[0..header.len], header); var offset = header.len; for (0..pixel_count) |_| { @memcpy(out[offset..][0..fixture_rgb.len], &fixture_rgb); offset += fixture_rgb.len; } try eh.writeFile(path, out); } 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.expectRawWaitFor( "\x1b_Gi=1,a=q\x1b\\", 5000, "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 runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { _ = unsetenv("PARDES_TEST"); _ = unsetenv("PARDES_TEST_CAPTURE_DIR"); _ = base; // Keep the generated location token short enough that the 38-column PDF // pane can visibly include each outline title after `path:page:ordinal`. const pdf_path = try std.fmt.allocPrintSentinel(arena, "/tmp/p{d}.pdf", .{libc.getpid()}, 0); try writePdf(arena, pdf_path, .red); defer _ = libc.unlink(pdf_path); // Preserve fourteen document rows below workspace, column and pane tags. var h = try eh.Harness.initArgs(gpa, exe, 17, 40, pdf_path); defer h.deinit(); try h.expectRawWaitFor( "\x1b_Gi=1,a=q\x1b\\", 5000, "app did not issue the Kitty graphics capability query", ); try h.send("\x1b_Gi=1;OK\x1b\\\x1b[?1;2c\x1b[A"); const transmit = "\x1b_Gf=32,s=96,v=64,i="; const first_deadline = eh.nowMs() + 5000; while (eh.nowMs() < first_deadline) { _ = try h.pumpOnce(50); const sent = std.mem.count(u8, h.raw.items, transmit) >= 2; const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null; if (sent and placed and h.contains("[1/4]")) break; } try h.expectRawContains(transmit, "first PDF page was not transmitted as the 96x64 Kitty raster"); try h.expectRawContains("\x1b_Ga=p,i=", "first PDF page was not placed in the pane body"); try h.expectContains( "[1/4]", "fresh PDF pane did not show its compact page indicator", ); // Fit-width is the initial PDF policy. The short fixture pages make the // first boundary visible immediately, so both page rasters must coexist. // Page one fills its own document box instead of being letterboxed inside // the whole pane viewport. const initial_transmits = std.mem.count(u8, h.raw.items, transmit); if (initial_transmits < 2) return error.KittyPdfBoundaryPageMissing; const width_apc = kittyPlacementApc(h.raw.items, 0) orelse return error.KittyPdfWidthPlacementMissing; const width_clip_h = kittyParam(width_apc, 'h') orelse return error.KittyPdfWidthCropWrong; if (kittyParam(width_apc, 'x') != 0 or kittyParam(width_apc, 'y') != 0 or kittyParam(width_apc, 'w') != 96 or width_clip_h == 0 or width_clip_h > 64 or kittyParam(width_apc, 'c') == null) { std.debug.print("unexpected Kitty fit-width placement: {s}\n", .{width_apc}); return error.KittyPdfWidthCropWrong; } const initial_aspect_h = (38 * 8 * width_clip_h + 95) / 96; if (((initial_aspect_h + 15) / 16) * 16 > 202) return error.KittyPdfWidthPlacementOverdraw; // A one-row document scroll changes placements, not pixel generations or // the active page. This is the cache-reuse seam the old page-at-a-time // harness could not exercise. const placements_before = std.mem.count(u8, h.raw.items, "\x1b_Ga=p,i="); const placement_start = h.raw.items.len; try h.send("j"); const placement_deadline = eh.nowMs() + 2000; while (eh.nowMs() < placement_deadline and std.mem.count(u8, h.raw.items, "\x1b_Ga=p,i=") <= placements_before) _ = try h.pumpOnce(50); if (std.mem.count(u8, h.raw.items, transmit) != initial_transmits) return error.KittyPdfPlacementScrollRetransmitted; try h.expectContains("[1/4]", "one-row scroll snapped to another PDF page"); // The newly exposed page-two head is 29px tall and begins three pixels // into a cell. Kitty infers an integer row count when only c is supplied; // the crop must therefore stay within one 16px row, not ceil into the gap. const tail_deadline = eh.nowMs() + 2000; while (eh.nowMs() < tail_deadline and kittyPlacementApcWithParam(h.raw.items, placement_start, 'Y') == null) _ = try h.pumpOnce(50); const tail_apc = kittyPlacementApcWithParam(h.raw.items, placement_start, 'Y') orelse return error.KittyPdfSafeTailPlacementMissing; const tail_cols = kittyParam(tail_apc, 'c') orelse return error.KittyPdfSafeTailPlacementWrong; const tail_w = kittyParam(tail_apc, 'w') orelse return error.KittyPdfSafeTailPlacementWrong; const tail_h = kittyParam(tail_apc, 'h') orelse return error.KittyPdfSafeTailPlacementWrong; if (kittyParam(tail_apc, 'Y') != 3 or tail_cols != 38 or tail_w != 96) return error.KittyPdfSafeTailPlacementWrong; const tail_aspect_h = (tail_cols * 8 * tail_h + tail_w - 1) / tail_w; const tail_declared_h = ((tail_aspect_h + 15) / 16) * 16; if (tail_declared_h > 29) return error.KittyPdfTailOverpaintedGap; // A viewport step crosses the boundary continuously: page one leaves and // is deleted, page two remains cached, and newly intersecting page three // is transmitted as the 64-row grain covering its visible head. The new // post-step transmit identifies that page without requiring an offscreen // whole-page raster during a viewport-sized fling. const page_step_start = h.raw.items.len; const all_transmits = "\x1b_Gf=32,s="; const transmits_before_step = std.mem.count(u8, h.raw.items, all_transmits); const page_three_transmit = "\x1b_Gf=32,s=96,v=64,i="; try h.send("\x06"); try h.expectWaitFor("[2/4]", 5000, "continuous page-down did not enter PDF page two"); const page_step_deadline = eh.nowMs() + 5000; while (eh.nowMs() < page_step_deadline and std.mem.indexOf(u8, h.raw.items[page_step_start..], page_three_transmit) == null) _ = try h.pumpOnce(50); if (std.mem.indexOf(u8, h.raw.items[page_step_start..], "\x1b_Ga=d,d=I,i=") == null) return error.KittyPdfOffscreenPageNotReleased; if (std.mem.indexOf(u8, h.raw.items[page_step_start..], page_three_transmit) == null) return error.KittyPdfNewVisiblePageMissing; if (std.mem.count(u8, h.raw.items, all_transmits) != transmits_before_step + 1) return error.KittyPdfRetainedNeighborRetransmitted; // Search still addresses semantic pages inside the strip. Returning one // viewport, then submitting the bare search selects and Looks its first // hit through the shared walk, creating a decorated page-two revision. try h.send("\x02"); try h.expectWaitFor("[1/4]", 5000, "continuous page-up did not return to PDF page one"); const transmits_before_search = std.mem.count(u8, h.raw.items, all_transmits); try h.send("/SECOND\r"); const search_row = try std.fmt.allocPrint(arena, "{s}:2:1 SECOND", .{std.fs.path.basename(pdf_path)}); try h.expectWaitFor(search_row, 5000, "PDF search did not preserve the exact page-two query occurrence"); const look_deadline = eh.nowMs() + 5000; while (eh.nowMs() < look_deadline) { _ = try h.pumpOnce(50); if (std.mem.count(u8, h.raw.items, all_transmits) > transmits_before_search and h.contains("[2/4]")) break; } try h.expectContains("[2/4]", "bare search did not reveal its first PDF hit at page two"); if (std.mem.count(u8, h.raw.items, all_transmits) <= transmits_before_search) return error.KittyPdfLookRetransmitMissing; // PdfFit changes only placement. The active decorated raster remains the // same terminal image while height fit supplies an explicit row extent. const before_fit = std.mem.count(u8, h.raw.items, all_transmits); const fit_raw_start = h.raw.items.len; try h.send(" tz"); try h.expectWaitFor("[2/4]", 5000, "PdfFit did not expose height fit"); const fit_deadline = eh.nowMs() + 3000; while (eh.nowMs() < fit_deadline and kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') == null) _ = try h.pumpOnce(50); const height_apc = kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') orelse return error.KittyPdfHeightPlacementMissing; const height_crop_w = kittyParam(height_apc, 'w') orelse return error.KittyPdfHeightPlacementWrong; if (height_crop_w == 0 or height_crop_w > 96 or kittyParam(height_apc, 'h') != 64) return error.KittyPdfHeightPlacementWrong; if (std.mem.count(u8, h.raw.items, all_transmits) != before_fit) return error.KittyPdfFitRetransmitted; // The generated PDF carries a real nested outline. PdfSections must show // all three DFS rows in its output pane. n/N only select rows; Enter Looks // the selected destination. The first two share page one but have distinct // page-space y coordinates; their placement APCs must differ while the // second reveal reuses exactly the rasters made visible by the first. try h.send(" ts"); try h.expectWaitFor("Upper", 5000, "PdfSections did not expose the root outline row"); try h.expectContains("Lower", "PdfSections did not expose the nested outline row"); try h.expectContains("Page two", "PdfSections did not expose the other-page outline row"); const upper_start = h.raw.items.len; try h.send("n\r"); try h.expectWaitFor("[1/4]", 5000, "first PdfSections n+Look did not reveal page one"); const upper_deadline = eh.nowMs() + 3000; while (eh.nowMs() < upper_deadline and kittyPlacementApc(h.raw.items, upper_start) == null) _ = try h.pumpOnce(50); const upper_apc = kittyPlacementApc(h.raw.items, upper_start) orelse return error.KittyPdfSectionsUpperPlacementMissing; const upper_copy = try arena.dupe(u8, upper_apc); try h.pump(150); const same_page_transmits = std.mem.count(u8, h.raw.items, "\x1b_Gf=32,"); const lower_start = h.raw.items.len; try h.send("n\r"); try h.expectWaitFor("[1/4]", 5000, "nested PdfSections n+Look left page one"); const lower_deadline = eh.nowMs() + 3000; while (eh.nowMs() < lower_deadline and kittyPlacementApc(h.raw.items, lower_start) == null) _ = try h.pumpOnce(50); const lower_apc = kittyPlacementApc(h.raw.items, lower_start) orelse return error.KittyPdfSectionsLowerPlacementMissing; if (std.mem.eql(u8, upper_copy, lower_apc)) return error.KittyPdfSectionsSamePagePlacementUnchanged; if (std.mem.count(u8, h.raw.items, "\x1b_Gf=32,") != same_page_transmits) return error.KittyPdfSectionsSamePageRetransmitted; try h.send("n\r"); try h.expectWaitFor("[2/4]", 5000, "third PdfSections n+Look did not reveal page two"); try h.send("N\r"); try h.expectWaitFor("[1/4]", 5000, "PdfSections N+Look did not return to the nested page-one row"); // Editor-style save: build a same-sized blue first page on another inode, // atomically rename it over the live pathname, and send NO input. The // inotify wake alone must reopen MuPDF and transmit a fresh raster. const replacement_path = try std.fmt.allocPrintSentinel(arena, "{s}.replacement", .{pdf_path}, 0); defer _ = libc.unlink(replacement_path); try writePdf(arena, replacement_path, .blue); try h.pump(250); const before_reload = std.mem.count(u8, h.raw.items, all_transmits); try h.pump(250); if (std.mem.count(u8, h.raw.items, all_transmits) != before_reload) return error.KittyPdfNavigationDidNotSettle; if (libc.rename(replacement_path, pdf_path) != 0) return error.PdfLiveRenameFailed; const reload_deadline = eh.nowMs() + 5000; while (eh.nowMs() < reload_deadline and std.mem.count(u8, h.raw.items, all_transmits) <= before_reload) _ = try h.pumpOnce(50); if (std.mem.count(u8, h.raw.items, all_transmits) <= before_reload) return error.KittyPdfLiveReloadMissing; try h.expectWaitFor("reloaded", 5000, "PDF inotify reload did not report a committed transaction"); std.debug.print("PDF harness ok: Kitty continuous cache + search/fit + sections + idle live reload\n", .{}); } fn kittyPlacementApc(raw: []const u8, start: usize) ?[]const u8 { if (start > raw.len) return null; const relative = std.mem.indexOf(u8, raw[start..], "\x1b_Ga=p,i=") orelse return null; const begin = start + relative; const end = std.mem.indexOf(u8, raw[begin..], "\x1b\\") orelse return null; return raw[begin .. begin + end + 2]; } fn kittyPlacementApcWithParam(raw: []const u8, start: usize, key: u8) ?[]const u8 { var at = start; while (kittyPlacementApc(raw, at)) |apc| { if (kittyParam(apc, key) != null) return apc; const next = @intFromPtr(apc.ptr) - @intFromPtr(raw.ptr) + apc.len; if (next <= at or next > raw.len) return null; at = next; } return null; } fn kittyParam(apc: []const u8, key: u8) ?u32 { var i: usize = 0; while (i + 3 <= apc.len) : (i += 1) { if (apc[i] != ',' or apc[i + 1] != key or apc[i + 2] != '=') continue; const begin = i + 3; var end = begin; while (end < apc.len and std.ascii.isDigit(apc[end])) : (end += 1) {} if (end == begin) return null; return std.fmt.parseUnsigned(u32, apc[begin..end], 10) catch null; } return null; } 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(); // The source contributes 512 aligned pixels. Leave ample room for driver // filtering while still excluding glyph-art fallback and incidental UI // colors. // // WAIT for the capture rather than budget a fixed startup and read once. // The first frame lands when the GPU device is ready, and on a host whose // only SPIR-V backend is vulkan-on-MoltenVK that is ~0.7s idle and past // 1.9s under load — a 1200ms budget made this arm fail with an unwritten // latest.ppm while the device was still coming up. Same deadline the PDF // arm below already gives its first capture. const bounds = waitForCaptureBounds(&h, capture_path, fixture_rgb, 200, 5000) catch |err| { std.debug.print( "SDL image harness produced no usable capture ({t}); raw child output: {s}\n", .{ err, h.raw.items }, ); return err; }; std.debug.print("native image harness ok: SDL GPU capture ({d} source pixels)\n", .{bounds.count}); } fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { const pdf_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.pdf", .{base}, 0); const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0); try writePdf(arena, pdf_path, .red); _ = libc.unlink(capture_path); _ = setenv("PARDES_TEST", "1", 1); _ = setenv("PARDES_TEST_CAPTURE_DIR", base, 1); _ = setenv("PARDES_TEST_COLS", "20", 1); // Keep eight document rows beneath the workspace, column and pane tags; // the fit-height assertions below compare this viewport with width-fit. _ = setenv("PARDES_TEST_ROWS", "11", 1); var h = try eh.Harness.initArgs(gpa, exe, 11, 20, pdf_path); defer h.deinit(); const fit_width = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 500, 5000); const initial_green = try waitForCaptureBounds(&h, capture_path, pdf_filtered_green, 500, 5000); try expectThemeGap(capture_path, fit_width, initial_green, pdf_filtered_red, pdf_filtered_green); // One row moves both native quads by the exact document distance. They // remain together on screen, separated by the same theme-colored gap; a // page-at-a-time implementation would replace red with green here. _ = libc.unlink(capture_path); try h.send("j"); const moved_red = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 500, 5000); const moved_green = try waitForCaptureBounds(&h, capture_path, pdf_filtered_green, 500, 5000); if (moved_red.max_y >= fit_width.max_y or moved_green.min_y >= initial_green.min_y) return error.SdlPdfContinuousStepDidNotMove; try expectThemeGap(capture_path, moved_red, moved_green, pdf_filtered_red, pdf_filtered_green); // Fit-height fills/crops the full body height. These are GPU readback // extents, so this exercises SDL's UV crop and destination rectangle // rather than merely observing the page color. _ = libc.unlink(capture_path); try h.send(" tz"); const fit_height = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 500, 5000); const width_delta = if (fit_width.width() > fit_height.width()) fit_width.width() - fit_height.width() else fit_height.width() - fit_width.width(); // A positional PDF now owns the full single column, so width fit already // occupies much of this short test window. Height fit must still extend // the colored page by at least a third while preserving its clipped width. if (fit_height.height() * 3 < fit_width.height() * 4 or width_delta > 8) { std.debug.print("SDL PDF fit extents did not change as expected: width={any}, height={any}\n", .{ fit_width, fit_height }); return error.SdlPdfFitGeometryWrong; } // In height fit one row exposes page two at the bottom but keeps page one // active. Enough additional rows then move page one wholly offscreen and // leave page two at the top, proving continuous traversal in GPU output. _ = libc.unlink(capture_path); try h.send("j"); const height_red = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 500, 5000); const height_green = try waitForCaptureBounds(&h, capture_path, pdf_filtered_green, 200, 5000); try expectThemeGap(capture_path, height_red, height_green, pdf_filtered_red, pdf_filtered_green); _ = libc.unlink(capture_path); try h.send("jjjjjjjj"); const green = try waitForCaptureBounds(&h, capture_path, pdf_filtered_green, 500, 5000); const final_capture = try readFile(gpa, capture_path); defer gpa.free(final_capture); const final_pixels = ppmPixels(final_capture) orelse return error.BadCapture; if (countColor(final_pixels, pdf_filtered_red) != 0) return error.SdlPdfOffscreenPageStillDrawn; // Exercise the actual PdfSections output + n/N selection + Look path in // SDL too. // The first two nested outline rows stay on red page one at different y // positions, the third moves to green page two, and N returns exactly to // the nested page-one destination. _ = libc.unlink(capture_path); try h.send("gg"); _ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 500, 5000); _ = libc.unlink(capture_path); try h.send(" ts"); _ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 500, 5000); _ = libc.unlink(capture_path); try h.send("n\r"); _ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 200, 5000); _ = libc.unlink(capture_path); try h.send("n\r"); _ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 100, 5000); _ = libc.unlink(capture_path); try h.send("n\r"); _ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_green, 200, 5000); _ = libc.unlink(capture_path); try h.send("N\r"); _ = try waitForCaptureBounds(&h, capture_path, pdf_filtered_red, 100, 5000); // The GUI host receives the same parent-directory rename while idle. A // newly captured blue first page proves the wake reached MuPDF, replaced // the pane raster, and reached the actual GPU output without a key event. const replacement_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.replacement.pdf", .{base}, 0); defer _ = libc.unlink(replacement_path); try writePdf(arena, replacement_path, .blue); _ = libc.unlink(capture_path); if (libc.rename(replacement_path, pdf_path) != 0) return error.PdfLiveRenameFailed; const blue = try waitForCaptureBounds(&h, capture_path, pdf_filtered_blue, 100, 5000); const reloaded_capture = try readFile(gpa, capture_path); defer gpa.free(reloaded_capture); const reloaded_pixels = ppmPixels(reloaded_capture) orelse return error.BadCapture; if (countColor(reloaded_pixels, pdf_filtered_red) != 0) return error.SdlPdfLiveReloadRetainedOldPixels; std.debug.print( "PDF harness ok: SDL continuous strip + sections + idle red->blue reload ({d}->{d}->{d} pixels)\n", .{ fit_width.count, green.count, blue.count }, ); } const ColorBounds = struct { count: usize, min_x: usize, min_y: usize, max_x: usize, max_y: usize, fn width(bounds: ColorBounds) usize { return bounds.max_x - bounds.min_x + 1; } fn height(bounds: ColorBounds) usize { return bounds.max_y - bounds.min_y + 1; } }; fn waitForCaptureBounds( h: *eh.Harness, path: [:0]const u8, color: [3]u8, minimum: usize, timeout_ms: i64, ) !ColorBounds { const deadline = eh.nowMs() + timeout_ms; var best: usize = 0; while (eh.nowMs() < deadline) { if (readFile(gpa, path)) |capture| { defer gpa.free(capture); if (ppmView(capture)) |ppm| { if (colorBounds(ppm, color)) |bounds| { best = @max(best, bounds.count); if (bounds.count >= minimum) return bounds; } } } else |_| {} _ = try h.pumpOnce(50); } std.debug.print("SDL harness found only {d} pixels of color {any}\n", .{ best, color }); return error.CaptureColorMissing; } fn expectThemeGap( path: [:0]const u8, upper: ColorBounds, lower: ColorBounds, upper_color: [3]u8, lower_color: [3]u8, ) !void { if (lower.min_y <= upper.max_y + 1) return error.SdlPdfGapMissing; const gap = lower.min_y - upper.max_y - 1; if (gap < 7 or gap > 9) return error.SdlPdfGapWrongSize; const capture = try readFile(gpa, path); defer gpa.free(capture); const ppm = ppmView(capture) orelse return error.BadCapture; const y = upper.max_y + 1 + gap / 2; const x0 = upper.min_x + 2; const x1 = @min(upper.max_x, lower.max_x) -| 2; if (x0 > x1 or y >= ppm.height) return error.SdlPdfGapOutOfBounds; const first = ppm.pixels[(y * ppm.width + x0) * 3 ..][0..3]; if (std.mem.eql(u8, first, &upper_color) or std.mem.eql(u8, first, &lower_color)) return error.SdlPdfGapCovered; var x = x0 + 1; while (x <= x1) : (x += 1) { const pixel = ppm.pixels[(y * ppm.width + x) * 3 ..][0..3]; if (!std.mem.eql(u8, pixel, first)) return error.SdlPdfGapNotThemeBackground; } } const PpmView = struct { width: usize, height: usize, pixels: []const u8 }; fn colorBounds(ppm: PpmView, color: [3]u8) ?ColorBounds { var bounds = ColorBounds{ .count = 0, .min_x = std.math.maxInt(usize), .min_y = std.math.maxInt(usize), .max_x = 0, .max_y = 0, }; var pixel: usize = 0; while (pixel < ppm.width * ppm.height) : (pixel += 1) { const i = pixel * 3; if (!std.mem.eql(u8, ppm.pixels[i .. i + 3], &color)) continue; const x = pixel % ppm.width; const y = pixel / ppm.width; bounds.count += 1; bounds.min_x = @min(bounds.min_x, x); bounds.min_y = @min(bounds.min_y, y); bounds.max_x = @max(bounds.max_x, x); bounds.max_y = @max(bounds.max_y, y); } return if (bounds.count == 0) null else bounds; } fn countColor(pixels: []const u8, color: [3]u8) usize { var count: usize = 0; var i: usize = 0; while (i + 2 < pixels.len) : (i += 3) count += @intFromBool(std.mem.eql(u8, pixels[i .. i + 3], &color)); return count; } 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); const end = libc.lseek(fd, 0, libc.SEEK.END); if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; const out = try allocator.alloc(u8, @intCast(end)); errdefer allocator.free(out); var filled: usize = 0; while (filled < out.len) { const n = libc.read(fd, out[filled..].ptr, out.len - filled); if (n < 0) return error.ReadFailed; if (n == 0) return error.UnexpectedEof; filled += @intCast(n); } return out; } fn ppmPixels(bytes: []const u8) ?[]const u8 { return (ppmView(bytes) orelse return null).pixels; } fn ppmView(bytes: []const u8) ?PpmView { if (!std.mem.startsWith(u8, bytes, "P6\n")) return null; const dimensions_end = std.mem.indexOfScalarPos(u8, bytes, 3, '\n') orelse return null; var dimensions = std.mem.splitScalar(u8, bytes[3..dimensions_end], ' '); const width = std.fmt.parseUnsigned(usize, dimensions.next() orelse return null, 10) catch return null; const height = std.fmt.parseUnsigned(usize, dimensions.next() orelse return null, 10) catch return null; if (dimensions.next() != null) return null; const max_end = std.mem.indexOfScalarPos(u8, bytes, dimensions_end + 1, '\n') orelse return null; if (!std.mem.eql(u8, bytes[dimensions_end + 1 .. max_end], "255")) return null; const expected = std.math.mul(usize, width, height) catch return null; const byte_len = std.math.mul(usize, expected, 3) catch return null; if (bytes.len -| (max_end + 1) < byte_len) return null; return .{ .width = width, .height = height, .pixels = bytes[max_end + 1 ..][0..byte_len] }; }