// 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; }