//! 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 }; const pdf_red = [3]u8{ 255, 0, 0 }; const pdf_green = [3]u8{ 0, 255, 0 }; 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); _ = 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 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); } fn appendFmt(arena: std.mem.Allocator, out: *std.ArrayList(u8), comptime fmt: []const u8, args: anytype) !void { try out.appendSlice(arena, try std.fmt.allocPrint(arena, fmt, args)); } /// 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) !void { const first_stream = \\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 ; 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: std.ArrayList(u8) = .empty; var offsets: [12]usize = @splat(0); try out.appendSlice(arena, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); offsets[1] = out.items.len; try out.appendSlice(arena, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); offsets[2] = out.items.len; try out.appendSlice(arena, "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.items.len; try out.appendSlice(arena, "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.items.len; try appendFmt(arena, &out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream }); offsets[5] = out.items.len; try out.appendSlice(arena, "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.items.len; try appendFmt(arena, &out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream }); offsets[7] = out.items.len; try out.appendSlice(arena, "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.items.len; try appendFmt(arena, &out, "8 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ third_stream.len, third_stream }); offsets[9] = out.items.len; try out.appendSlice(arena, "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.items.len; try appendFmt(arena, &out, "10 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ fourth_stream.len, fourth_stream }); offsets[11] = out.items.len; try out.appendSlice(arena, "11 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); const xref = out.items.len; try out.appendSlice(arena, "xref\n0 12\n0000000000 65535 f \n"); for (offsets[1..]) |offset| try appendFmt(arena, &out, "{d:0>10} 00000 n \n", .{offset}); try appendFmt(arena, &out, "trailer\n<< /Size 12 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref}); try eh.writeFile(path, out.items); } 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 runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { _ = unsetenv("PARDES_TEST"); _ = unsetenv("PARDES_TEST_CAPTURE_DIR"); const pdf_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.pdf", .{base}, 0); try writePdf(arena, pdf_path); var h = try eh.Harness.initArgs(gpa, exe, 16, 80, pdf_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"); try h.send("\x1b_Gi=1;OK\x1b\\\x1b[?1;2c\x1b[A"); const transmit = "\x1b_Gf=32,s=144,v=96,i="; const first_deadline = eh.nowMs() + 5000; while (eh.nowMs() < first_deadline) { _ = try h.pumpOnce(50); const sent = std.mem.indexOf(u8, h.raw.items, transmit) != null; const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null; if (sent and placed and h.contains("pdf 1/4 width PdfFit")) break; } try h.expectRawContains(transmit, "first PDF page was not transmitted as 144x96 RGBA"); try h.expectRawContains("\x1b_Ga=p,i=", "first PDF page was not placed in the pane body"); try h.expectContains("pdf 1/4 width PdfFit", "PDF pane did not expose its initial width fit"); // Fit-width is the initial PDF policy. It must use Kitty's explicit column // scaling rather than Vaxis contain. The PDF shares this test session with // the initial shell pane, making its body slightly narrower than the page: // the full source is retained and vertically letterboxed by a pixel offset. const width_apc = kittyPlacementApc(h.raw.items, 0) orelse return error.KittyPdfWidthPlacementMissing; if (kittyParam(width_apc, 'x') != 0 or kittyParam(width_apc, 'y') != 0 or kittyParam(width_apc, 'w') != 144) { std.debug.print("unexpected Kitty fit-width placement: {s}\n", .{width_apc}); return error.KittyPdfWidthCropWrong; } const source_h = kittyParam(width_apc, 'h') orelse return error.KittyPdfWidthCropMissing; const pixel_y = kittyParam(width_apc, 'Y') orelse return error.KittyPdfWidthLetterboxMissing; if (source_h != 96 or pixel_y == 0 or kittyParam(width_apc, 'c') == null) { std.debug.print("unexpected Kitty fit-width letterbox: {s}\n", .{width_apc}); return error.KittyPdfWidthLetterboxWrong; } // Placement-only state reuses the terminal image. Middle-click the // visible PdfFit word in the PDF tag, wait for a fit-height placement // with explicit rows and a full source image, and prove that no second // transmit happened. const transmit_before_fit = std.mem.count(u8, h.raw.items, transmit); const fit_raw_start = h.raw.items.len; const tag = try h.screenText(); defer gpa.free(tag); const fit_at = std.mem.indexOf(u8, tag, "PdfFit") orelse return error.PdfFitMissing; const line_at = std.mem.lastIndexOfScalar(u8, tag[0..fit_at], '\n'); const mouse_col = fit_at - if (line_at) |at| at + 1 else 0; const mouse_row = std.mem.count(u8, tag[0..fit_at], "\n"); const click = try std.fmt.allocPrint( arena, "\x1b[<1;{d};{d}M\x1b[<1;{d};{d}m", .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1 }, ); try h.send(click); const fit_deadline = eh.nowMs() + 3000; while (eh.nowMs() < fit_deadline) { _ = try h.pumpOnce(50); if (kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') != null) break; } try h.expectContains("pdf 1/4 height PdfFit", "PdfFit did not expose the active height fit"); 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 (kittyParam(height_apc, 'x') != 0 or kittyParam(height_apc, 'y') != 0 or height_crop_w == 0 or height_crop_w >= 144 or kittyParam(height_apc, 'h') != 96 or kittyParam(height_apc, 'r') == null) return error.KittyPdfHeightPlacementWrong; if (std.mem.count(u8, h.raw.items, transmit) != transmit_before_fit) return error.KittyPdfFitRetransmitted; // Same pane, different pixels: page navigation must delete the cached // Kitty image and transmit the new revision, not keep showing page one. try h.send("j"); const second_deadline = eh.nowMs() + 5000; while (eh.nowMs() < second_deadline) { _ = try h.pumpOnce(50); const deleted = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") != null; if (deleted and std.mem.count(u8, h.raw.items, transmit) >= 2 and h.contains("pdf 2/4 height PdfFit")) break; } try h.expectRawContains("\x1b_Ga=d,d=I,i=", "page navigation did not release the old Kitty PDF image"); if (std.mem.count(u8, h.raw.items, transmit) < 2) return error.KittyPdfRetransmitMissing; try h.expectContains("pdf 2/4 height PdfFit", "j did not retain height fit on PDF page two"); // Exercise the semantic seam too: return to page one, search an exact // occurrence across the document, then let n Look the page-two result. // That Look must change the page and therefore produce another pixel // generation. try h.send("k"); try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "k did not return to PDF page one at height fit"); try h.send("/SECOND\r"); try h.expectWaitFor("pages.pdf:2:1 SECOND", 5000, "PDF search did not preserve the exact page-two query occurrence"); const before_look = std.mem.count(u8, h.raw.items, transmit); try h.send("n"); const look_deadline = eh.nowMs() + 5000; while (eh.nowMs() < look_deadline) { _ = try h.pumpOnce(50); if (std.mem.count(u8, h.raw.items, transmit) > before_look and h.contains("pdf 2/4 height PdfFit")) break; } try h.expectContains("pdf 2/4 height PdfFit", "n did not Look the PDF search result at page two"); if (std.mem.count(u8, h.raw.items, transmit) <= before_look) return error.KittyPdfLookRetransmitMissing; // Kitty turns a larger wheel offset into adjacent SGR wheel presses. The // TTY shell may drain those presses in one input batch, but every native // PDF page transition must be rastered before the next press consumes the // new page's geometry. Distinct page heights make that ordering observable // in the actual Kitty transmit headers: page 2 is 144x96, page 3 144x100, // and page 4 144x104. This is also a direct one-detent check before the // burst and a direction-reversal check after it. const wheel_up = try std.fmt.allocPrint(arena, "\x1b[<64;{d};{d}M", .{ mouse_col + 1, mouse_row + 1 }); const wheel_down = try std.fmt.allocPrint(arena, "\x1b[<65;{d};{d}M", .{ mouse_col + 1, mouse_row + 1 }); const page_three_transmit = "\x1b_Gf=32,s=144,v=100,i="; const page_four_transmit = "\x1b_Gf=32,s=144,v=104,i="; try h.send(wheel_up); try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "wheel-up did not return to PDF page one"); const single_start = h.raw.items.len; try h.send(wheel_down); try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "one wheel detent did not advance exactly one PDF page"); try h.pump(100); if (std.mem.indexOf(u8, h.raw.items[single_start..], page_three_transmit) != null) return error.KittyPdfSingleWheelSkippedPage; try h.send(wheel_up); try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "wheel-up did not reset the burst fixture"); const burst_start = h.raw.items.len; const forward_burst = try std.fmt.allocPrint( arena, "\x1b[<65;{d};{d}M\x1b[<65;{d};{d}M\x1b[<65;{d};{d}M", .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1 }, ); try h.send(forward_burst); try h.expectWaitFor("pdf 4/4 height PdfFit", 8000, "forward Kitty wheel burst did not reach PDF page four"); const forward_raw = h.raw.items[burst_start..]; const page_two_at = std.mem.indexOf(u8, forward_raw, transmit) orelse return error.KittyPdfBurstSkippedPageTwoRender; const page_three_at = std.mem.indexOf(u8, forward_raw, page_three_transmit) orelse return error.KittyPdfBurstSkippedPageThreeRender; const page_four_at = std.mem.indexOf(u8, forward_raw, page_four_transmit) orelse return error.KittyPdfBurstSkippedPageFourRender; if (!(page_two_at < page_three_at and page_three_at < page_four_at)) return error.KittyPdfBurstRenderOrderWrong; const reverse_start = h.raw.items.len; const reverse_burst = try std.fmt.allocPrint( arena, "\x1b[<64;{d};{d}M\x1b[<64;{d};{d}M\x1b[<65;{d};{d}M\x1b[<64;{d};{d}M", .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1, }, ); try h.send(reverse_burst); try h.expectWaitFor("pdf 2/4 height PdfFit", 8000, "mixed-direction Kitty wheel burst did not return to PDF page two"); const reverse_deadline = eh.nowMs() + 5000; while (eh.nowMs() < reverse_deadline) { const emitted = h.raw.items[reverse_start..]; if (std.mem.count(u8, emitted, page_three_transmit) >= 2 and std.mem.count(u8, emitted, transmit) >= 2) break; _ = try h.pumpOnce(50); } const reverse_raw = h.raw.items[reverse_start..]; const reverse_three_1 = std.mem.indexOf(u8, reverse_raw, page_three_transmit) orelse return error.KittyPdfReverseBurstSkippedPageThreeRender; const reverse_two_1 = std.mem.indexOfPos(u8, reverse_raw, reverse_three_1 + page_three_transmit.len, transmit) orelse return error.KittyPdfReverseBurstSkippedPageTwoRender; const reverse_three_2 = std.mem.indexOfPos(u8, reverse_raw, reverse_two_1 + transmit.len, page_three_transmit) orelse return error.KittyPdfDirectionChangeSkippedPageThreeRender; const reverse_two_2 = std.mem.indexOfPos(u8, reverse_raw, reverse_three_2 + page_three_transmit.len, transmit) orelse return error.KittyPdfDirectionChangeSkippedPageTwoRender; if (!(reverse_three_1 < reverse_two_1 and reverse_two_1 < reverse_three_2 and reverse_three_2 < reverse_two_2)) return error.KittyPdfReverseBurstRenderOrderWrong; std.debug.print("PDF harness ok: Kitty fit/cache + page/search + metered wheel transitions\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(); 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 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); _ = 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, pdf_path); defer h.deinit(); const fit_width = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); // The narrow PDF pane makes fit-width vertically letterboxed. Fit-height // keeps roughly the same colored width but 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_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(); if (fit_height.height() < fit_width.height() * 2 or width_delta > 8 or fit_height.min_y >= fit_width.min_y) { std.debug.print("SDL PDF fit extents did not change as expected: width={any}, height={any}\n", .{ fit_width, fit_height }); return error.SdlPdfFitGeometryWrong; } // Remove the old capture before the key so merely rereading page one's // file cannot satisfy page two. The app replaces this path atomically. _ = libc.unlink(capture_path); try h.send("j"); const green = try waitForCaptureBounds(&h, capture_path, pdf_green, 500, 5000); std.debug.print("PDF harness ok: SDL fit geometry + red->green ({d}->{d} pixels)\n", .{ fit_width.count, green.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 PDF harness found only {d} pixels of color {any}\n", .{ best, color }); return error.PdfPageColorMissing; } 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); 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 { 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] }; }