summaryrefslogtreecommitdiff
path: root/test/image_harness.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 13:13:53 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit487434a0b55fa7cf479f39481b51da0a7eda79e1 (patch)
treece410e0c52eb030e1de76079834e6697975c9c17 /test/image_harness.zig
parentd59397d4146ffa23adbbe6dc99c64ebb22a93b2d (diff)
downloadpardes-487434a0b55fa7cf479f39481b51da0a7eda79e1.tar.gz
pardes-487434a0b55fa7cf479f39481b51da0a7eda79e1.zip
gui: draw image surfaces as GPU quads via glsl shaders
Diffstat (limited to 'test/image_harness.zig')
-rw-r--r--test/image_harness.zig166
1 files changed, 166 insertions, 0 deletions
diff --git a/test/image_harness.zig b/test/image_harness.zig
new file mode 100644
index 00000000..5ec192c2
--- /dev/null
+++ b/test/image_harness.zig
@@ -0,0 +1,166 @@
+//! 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;
+}