summaryrefslogtreecommitdiff
path: root/e2e_harness_sdl.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-02 20:06:25 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitbead9f0b78380d1428f40fdab339c1c8f88fa626 (patch)
tree960255de5a2cef661ec5d0ad3220a4b2b1738842 /e2e_harness_sdl.zig
parent62ef1882ab7878210f05f06504796849e412c318 (diff)
downloadpardes-bead9f0b78380d1428f40fdab339c1c8f88fa626.tar.gz
pardes-bead9f0b78380d1428f40fdab339c1c8f88fa626.zip
sdl ui backend
Diffstat (limited to 'e2e_harness_sdl.zig')
-rw-r--r--e2e_harness_sdl.zig332
1 files changed, 332 insertions, 0 deletions
diff --git a/e2e_harness_sdl.zig b/e2e_harness_sdl.zig
new file mode 100644
index 00000000..cac25473
--- /dev/null
+++ b/e2e_harness_sdl.zig
@@ -0,0 +1,332 @@
+// E2E harness for the SDL3 GPU backend's rendered-image test mode.
+//
+// The child app runs the real SDL/GPU renderer. Input is still delivered through
+// a pty so the same terminal escape sequences exercise the same app input path,
+// but rendered frames are captured by the app as PPM image files in a temp
+// directory. Assertions inspect pixels, not the retained cell grid.
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+
+extern "c" fn forkpty(
+ amaster: *c_int,
+ name: ?[*:0]u8,
+ termp: ?*const anyopaque,
+ winp: ?*const posix.winsize,
+) c_int;
+extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+
+fn nowMs() i64 {
+ var ts: linux.timespec = undefined;
+ _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
+ return ts.sec * 1000 + @divFloor(@as(i64, ts.nsec), 1_000_000);
+}
+
+pub const Image = struct {
+ width: u32,
+ height: u32,
+ pixels: []u8, // RGB, row-major
+
+ pub fn deinit(self: *Image, gpa: std.mem.Allocator) void {
+ gpa.free(self.pixels);
+ self.* = .{ .width = 0, .height = 0, .pixels = &.{} };
+ }
+
+ pub fn hash(self: Image) u64 {
+ return std.hash.Wyhash.hash(0, self.pixels);
+ }
+
+ pub fn brightPixels(self: Image, y0_in: u32, y1_in: u32, threshold: u8) usize {
+ const y0 = @min(y0_in, self.height);
+ const y1 = @min(y1_in, self.height);
+ var n: usize = 0;
+ var y = y0;
+ while (y < y1) : (y += 1) {
+ var x: u32 = 0;
+ while (x < self.width) : (x += 1) {
+ const i = (@as(usize, y) * @as(usize, self.width) + @as(usize, x)) * 3;
+ const lum = (@as(u16, self.pixels[i]) + @as(u16, self.pixels[i + 1]) + @as(u16, self.pixels[i + 2])) / 3;
+ if (lum >= threshold) n += 1;
+ }
+ }
+ return n;
+ }
+
+ pub fn changedPixels(self: Image, other: Image, threshold: u8) usize {
+ if (self.width != other.width or self.height != other.height) return std.math.maxInt(usize);
+ var n: usize = 0;
+ var i: usize = 0;
+ while (i < self.pixels.len) : (i += 3) {
+ const dr = absDiff(self.pixels[i], other.pixels[i]);
+ const dg = absDiff(self.pixels[i + 1], other.pixels[i + 1]);
+ const db = absDiff(self.pixels[i + 2], other.pixels[i + 2]);
+ if (dr + dg + db >= threshold) n += 1;
+ }
+ return n;
+ }
+};
+
+pub const SdlHarness = struct {
+ gpa: std.mem.Allocator,
+ io: std.Io,
+ master: c_int,
+ pid: posix.pid_t,
+ rows: u16,
+ cols: u16,
+ trace: bool = false,
+ capture_dir: [:0]u8,
+ latest_path: []u8,
+ image: ?Image = null,
+ rbuf: std.ArrayList(u8) = .empty,
+
+ pub fn init(gpa: std.mem.Allocator, io: std.Io, exe: [*:0]const u8, rows: u16, cols: u16) !SdlHarness {
+ return initArgs(gpa, io, exe, rows, cols, null);
+ }
+
+ pub fn initArgs(gpa: std.mem.Allocator, io: std.Io, exe: [*:0]const u8, rows: u16, cols: u16, arg: ?[*:0]const u8) !SdlHarness {
+ const base = ".zig-cache/pardes-sdl-e2e";
+ try std.Io.Dir.cwd().createDirPath(io, base);
+ const capture_dir_plain = try std.fmt.allocPrint(gpa, "{s}/{d}-{d}", .{ base, linux.getpid(), nowMs() });
+ defer gpa.free(capture_dir_plain);
+ const capture_dir = try gpa.dupeZ(u8, capture_dir_plain);
+ errdefer gpa.free(capture_dir);
+ try std.Io.Dir.cwd().createDirPath(io, capture_dir);
+ const latest_path = try std.fmt.allocPrint(gpa, "{s}/latest.ppm", .{capture_dir});
+ errdefer gpa.free(latest_path);
+
+ var master: c_int = undefined;
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master, null, null, &ws);
+ if (pid == 0) {
+ _ = setenv("PARDES_TEST", "1", 1);
+ _ = setenv("PARDES_TEST_CAPTURE_DIR", capture_dir.ptr, 1);
+ var rows_buf: [16]u8 = undefined;
+ var cols_buf: [16]u8 = undefined;
+ const rows_z = std.fmt.bufPrintZ(&rows_buf, "{d}", .{rows}) catch unreachable;
+ const cols_z = std.fmt.bufPrintZ(&cols_buf, "{d}", .{cols}) catch unreachable;
+ _ = setenv("PARDES_TEST_ROWS", rows_z.ptr, 1);
+ _ = setenv("PARDES_TEST_COLS", cols_z.ptr, 1);
+ const argv: [3:null]?[*:0]const u8 = .{ exe, arg, null };
+ _ = execvp(exe, &argv);
+ _ = _exit(127);
+ }
+ return .{
+ .gpa = gpa,
+ .io = io,
+ .master = master,
+ .pid = pid,
+ .rows = rows,
+ .cols = cols,
+ .capture_dir = capture_dir,
+ .latest_path = latest_path,
+ };
+ }
+
+ pub fn deinit(self: *SdlHarness) void {
+ posix.kill(self.pid, posix.SIG.KILL) catch {};
+ _ = linux.close(self.master);
+ if (self.image) |*img| img.deinit(self.gpa);
+ self.rbuf.deinit(self.gpa);
+ self.gpa.free(self.latest_path);
+ self.gpa.free(self.capture_dir);
+ }
+
+ pub fn send(self: *SdlHarness, bytes: []const u8) !void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const rc = linux.write(self.master, bytes[off..].ptr, bytes.len - off);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return error.WriteFailed;
+ }
+ off += @intCast(n);
+ }
+ }
+
+ pub fn pump(self: *SdlHarness, ms: i64) !void {
+ const deadline = nowMs() + ms;
+ var buf: [4096]u8 = undefined;
+ while (nowMs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 50) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(self.master, &buf) catch break;
+ if (n == 0) break;
+ self.rbuf.appendSlice(self.gpa, buf[0..n]) catch {};
+ }
+ self.loadLatest() catch {};
+ }
+ self.loadLatest() catch {};
+ if (self.trace) self.traceImage("pump");
+ }
+
+ pub fn resize(self: *SdlHarness, rows: u16, cols: u16) !void {
+ self.rows = rows;
+ self.cols = cols;
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(self.master, posix.T.IOCSWINSZ, @intFromPtr(&ws));
+ }
+
+ pub fn waitForImage(self: *SdlHarness, ms: i64) !bool {
+ if (self.image != null) return true;
+ const deadline = nowMs() + ms;
+ while (nowMs() < deadline) {
+ try self.pump(100);
+ if (self.image != null) return true;
+ }
+ return false;
+ }
+
+ pub fn waitForChange(self: *SdlHarness, old_hash: u64, ms: i64) !bool {
+ const deadline = nowMs() + ms;
+ while (nowMs() < deadline) {
+ try self.pump(100);
+ if (self.image) |img| if (img.hash() != old_hash) return true;
+ }
+ return false;
+ }
+
+ pub fn imageHash(self: *SdlHarness) u64 {
+ return if (self.image) |img| img.hash() else 0;
+ }
+
+ pub fn imageCopy(self: *SdlHarness) !Image {
+ const img = self.image orelse return error.NoImage;
+ return .{
+ .width = img.width,
+ .height = img.height,
+ .pixels = try self.gpa.dupe(u8, img.pixels),
+ };
+ }
+
+ pub fn cellWidth(self: *SdlHarness) u32 {
+ const img = self.image orelse return 0;
+ return img.width / @as(u32, self.cols);
+ }
+
+ pub fn cellHeight(self: *SdlHarness) u32 {
+ const img = self.image orelse return 0;
+ return img.height / @as(u32, self.rows);
+ }
+
+ pub fn expectImage(self: *SdlHarness, msg: []const u8) !void {
+ if (self.image == null) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ pub fn expectWaitForImage(self: *SdlHarness, ms: i64, msg: []const u8) !void {
+ if (!try self.waitForImage(ms)) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ pub fn expectWaitForChange(self: *SdlHarness, old_hash: u64, ms: i64, msg: []const u8) !void {
+ if (!try self.waitForChange(old_hash, ms)) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ pub fn expectGridPixels(self: *SdlHarness, msg: []const u8) !void {
+ const img = self.image orelse {
+ self.dump(msg);
+ return error.ExpectFailed;
+ };
+ if (self.cols == 0 or self.rows == 0 or img.width % self.cols != 0 or img.height % self.rows != 0) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ if (img.width / self.cols < 6 or img.height / self.rows < 10) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ pub fn expectBrightRows(self: *SdlHarness, row0: u16, row1: u16, min_bright: usize, msg: []const u8) !void {
+ const img = self.image orelse {
+ self.dump(msg);
+ return error.ExpectFailed;
+ };
+ const ch = self.cellHeight();
+ const y0 = @as(u32, row0) * ch;
+ const y1 = @as(u32, row1) * ch;
+ if (img.brightPixels(y0, y1, 60) < min_bright) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ fn loadLatest(self: *SdlHarness) !void {
+ const data = std.Io.Dir.cwd().readFileAlloc(self.io, self.latest_path, self.gpa, .limited(128 * 1024 * 1024)) catch |err| switch (err) {
+ error.FileNotFound => return,
+ else => return err,
+ };
+ defer self.gpa.free(data);
+ const img = try parsePpm(self.gpa, data);
+ if (self.image) |*old| old.deinit(self.gpa);
+ self.image = img;
+ }
+
+ pub fn dump(self: *SdlHarness, msg: []const u8) void {
+ if (self.image) |img| {
+ std.debug.print("E2E FAIL: {s}\nimage {d}x{d} hash={x} path={s}\nraw tail:\n{s}\n", .{
+ msg, img.width, img.height, img.hash(), self.latest_path, rawTail(self.rbuf.items),
+ });
+ } else {
+ std.debug.print("E2E FAIL: {s}\n(no captured image yet) path={s}\nraw tail:\n{s}\n", .{
+ msg, self.latest_path, rawTail(self.rbuf.items),
+ });
+ }
+ }
+
+ pub fn traceImage(self: *SdlHarness, tag: []const u8) void {
+ if (self.image) |img| {
+ std.debug.print("[trace {s}] image {d}x{d} hash={x} path={s}\n", .{
+ tag, img.width, img.height, img.hash(), self.latest_path,
+ });
+ } else {
+ std.debug.print("[trace {s}] no image yet path={s}\n", .{ tag, self.latest_path });
+ }
+ }
+};
+
+fn parsePpm(gpa: std.mem.Allocator, data: []const u8) !Image {
+ if (!std.mem.startsWith(u8, data, "P6")) return error.BadPpm;
+ var idx: usize = 2;
+ const w = try parsePpmInt(data, &idx);
+ const h = try parsePpmInt(data, &idx);
+ const max = try parsePpmInt(data, &idx);
+ if (max != 255) return error.BadPpm;
+ if (idx >= data.len or !isWs(data[idx])) return error.BadPpm;
+ idx += 1;
+ const len = @as(usize, w) * @as(usize, h) * 3;
+ if (data.len < idx + len) return error.BadPpm;
+ return .{ .width = w, .height = h, .pixels = try gpa.dupe(u8, data[idx .. idx + len]) };
+}
+
+fn parsePpmInt(data: []const u8, idx: *usize) !u32 {
+ while (idx.* < data.len and isWs(data[idx.*])) idx.* += 1;
+ const start = idx.*;
+ while (idx.* < data.len and data[idx.*] >= '0' and data[idx.*] <= '9') idx.* += 1;
+ if (idx.* == start) return error.BadPpm;
+ return std.fmt.parseInt(u32, data[start..idx.*], 10);
+}
+
+fn isWs(b: u8) bool {
+ return b == ' ' or b == '\n' or b == '\r' or b == '\t';
+}
+
+fn absDiff(a: u8, b: u8) u16 {
+ return if (a >= b) @as(u16, a - b) else @as(u16, b - a);
+}
+
+fn rawTail(raw: []const u8) []const u8 {
+ const max = 4096;
+ return if (raw.len > max) raw[raw.len - max ..] else raw;
+}