const std = @import("std"); const posix = std.posix; const libc = std.c; const ghostty_vt = @import("ghostty-vt"); // TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); 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 unsetenv(name: [*:0]const u8) c_int; /// Monotonic milliseconds; the harness's only clock (std.time lost /// milliTimestamp in 0.16). pub fn nowMs() i64 { var ts: libc.timespec = undefined; _ = libc.clock_gettime(.MONOTONIC, &ts); return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); } const raw_capture_max = 64 * 1024 * 1024; pub const RawCapture = struct { bytes: [raw_capture_max]u8 = undefined, items: []u8 = &.{}, full: bool = false, fn appendSlice(self: *RawCapture, chunk: []const u8) error{CaptureFull}!void { if (self.full) return error.CaptureFull; if (chunk.len > self.bytes.len - self.items.len) { self.full = true; return error.CaptureFull; } const end = self.items.len + chunk.len; @memcpy(self.bytes[self.items.len..end], chunk); self.items = self.bytes[0..end]; } }; /// E2E test harness: forks the app in a pty and parses its output with a /// ghostty terminal emulator. Tests read the 2D screen GRID (via plainString), /// not raw byte streams — so they're decoupled from how the app renders /// (vaxis diff-rendering, repaints, etc). The grid is always current after /// `pump`/`waitFor`; no setWinsize-to-force-repaint hacks needed. /// /// Input is sent as raw bytes to the pty (the app's vaxis parses them). The /// harness terminal only reads output. pub const Harness = struct { gpa: std.mem.Allocator, master: c_int, pid: posix.pid_t, /// heap-allocated so its address is stable: vtStream()'s handler holds a /// pointer back into the Terminal, so it must not move (same reason main.zig /// heap-allocates Term). Freed in deinit. term: *ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, rows: u16, cols: u16, /// when true, print the captured screen state after each pump/waitFor and on /// every assertion, so live test runs can be inspected (zig build test -Dtrace). trace: bool = false, /// every raw byte the app has emitted (accumulated in pump). Lets tests assert /// on control sequences the emulator consumes and never renders (e.g. OSC 52 /// clipboard writes). Capture is bounded explicitly so a runaway child cannot /// consume unbounded test-runner memory. raw: *RawCapture, pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness { return initArgs(gpa, exe, rows, cols, null); } /// Like init, but passes one extra CLI arg to the app (e.g. "--tty"). pub fn initArgs(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16, arg: ?[*:0]const u8) !Harness { var argv: [2]?[*:0]const u8 = .{ null, null }; if (arg) |a| argv[0] = a; return initArgv(gpa, exe, rows, cols, argv[0..(if (arg != null) @as(usize, 1) else 0)]); } /// Like init, but passes extra CLI args (e.g. "-l", "dump.zon"). pub fn initArgv(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16, args: []const ?[*:0]const u8) !Harness { const argv = try gpa.allocSentinel(?[*:0]const u8, args.len + 1, null); defer gpa.free(argv); argv[0] = exe; for (args, 0..) |a, i| argv[i + 1] = a; 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) { for ([_][*:0]const u8{ "PARDES_9P", "PARDES_PANE", "PARDES_FORWARD_LOOK" }) |name| if (unsetenv(name) != 0) _exit(126); _ = execvp(exe, argv.ptr); _exit(127); } const term = try gpa.create(ghostty_vt.Terminal); errdefer gpa.destroy(term); term.* = try ghostty_vt.Terminal.init(std.Io.Threaded.global_single_threaded.io(), gpa, .{ .cols = cols, .rows = rows, .max_scrollback = 1024 * 1024, }); errdefer term.deinit(gpa); // vtStream() captures a pointer to `term`; term is heap-allocated so the // pointer stays valid for the life of the harness. const stream = term.vtStream(); const raw = try gpa.create(RawCapture); errdefer gpa.destroy(raw); raw.* = .{}; return .{ .gpa = gpa, .master = master, .pid = pid, .term = term, .stream = stream, .rows = rows, .cols = cols, .raw = raw, }; } pub fn deinit(self: *Harness) void { posix.kill(self.pid, posix.SIG.KILL) catch {}; _ = libc.close(self.master); self.stream.deinit(); self.gpa.destroy(self.raw); self.term.deinit(self.gpa); self.gpa.destroy(self.term); } /// Write raw bytes to the pty (input to the app: keystrokes, mouse events). pub fn send(self: *Harness, bytes: []const u8) !void { var off: usize = 0; while (off < bytes.len) { const n = libc.write(self.master, bytes[off..].ptr, bytes.len - off); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.WriteFailed; } off += @intCast(n); } } /// Read pty output and feed it to our ghostty terminal for `ms` ms. After /// this, the grid reflects everything the app rendered so far. pub fn pump(self: *Harness, 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.recordRaw(buf[0..n]); self.stream.nextSlice(buf[0..n]); } } if (self.trace) self.traceScreen("pump"); } /// Wait up to `ms` for the app to say something, feed whatever arrived to /// the emulator, and report whether anything did. `ms` = 0 polls. This is /// the event-driven half of `pump`: a caller that only cares about the /// SETTLED grid sleeps in poll until the app actually writes, instead of /// burning a fixed window per check (see snapshot.zig's waitStable). pub fn pumpOnce(self: *Harness, ms: i64) !bool { var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }}; const ready = posix.poll(&fds, @intCast(@max(0, ms))) catch 0; if (ready == 0 or (fds[0].revents & posix.POLL.IN) == 0) return false; var buf: [4096]u8 = undefined; const n = posix.read(self.master, &buf) catch return false; if (n == 0) return false; self.recordRaw(buf[0..n]); self.stream.nextSlice(buf[0..n]); return true; } /// Bytes sitting in the pty that nobody has picked up — keystrokes the app /// has not read yet, or output we have not read yet (the master counts both /// directions). Zero is the proof that the app has actually CONSUMED the /// input just sent: without it, an app still asleep in its event loop looks /// exactly like an app that has finished. See snapshot.zig's waitStable. pub fn pending(self: *Harness) usize { // Darwin files FIONREAD under the socket ioctls rather than the // termios group std.posix.T exposes, and encodes it differently // besides — _IOR('f', 127, int) against linux's flat constant. // Neither is derivable from the other, so both are named. const FIONREAD: c_int = switch (@import("builtin").os.tag) { .linux => posix.T.FIONREAD, else => 0x4004667f, }; var n: c_int = 0; if (posix.system.ioctl(self.master, FIONREAD, @intFromPtr(&n)) != 0) return 0; return if (n > 0) @intCast(n) else 0; } /// Resize both the pty (SIGWINCH to the app) and our terminal (grid reflows /// to the new size). Harness terminal first so it's ready for the app's /// re-render at the new size. pub fn resize(self: *Harness, rows: u16, cols: u16) !void { self.rows = rows; self.cols = cols; self.term.resize(self.gpa, .{ .cols = cols, .rows = rows }) catch {}; const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; _ = posix.system.ioctl(self.master, TIOCSWINSZ, @intFromPtr(&ws)); } /// The full viewport text (plainString), newline-joined rows. Caller frees. pub fn screenText(self: *Harness) ![]const u8 { return self.term.plainString(self.gpa); } /// Is `needle` present anywhere on the current screen? pub fn contains(self: *Harness, needle: []const u8) bool { const text = self.screenText() catch return false; defer self.gpa.free(text); return std.mem.indexOf(u8, text, needle) != null; } /// Wait up to `ms` for `needle` to appear on screen, pumping bytes the /// whole time. Replaces pollFor + setWinsize-repaint: the grid is checked /// directly each iteration. pub fn waitFor(self: *Harness, needle: []const u8, ms: i64) !bool { { const text = try self.screenText(); defer self.gpa.free(text); if (std.mem.indexOf(u8, text, needle) != null) { if (self.trace) self.traceScreen("waitFor hit"); return true; } } 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.recordRaw(buf[0..n]); self.stream.nextSlice(buf[0..n]); } const text = try self.screenText(); defer self.gpa.free(text); if (std.mem.indexOf(u8, text, needle) != null) { if (self.trace) self.traceScreen("waitFor hit"); return true; } } if (self.trace) self.traceScreen("waitFor miss"); return false; } /// Wait up to `ms` for `needle` to appear in the app's RAW output, pumping /// bytes the whole time. Same shape as `waitFor`, against `raw` rather than /// the grid: for control sequences the emulator swallows and never renders. pub fn waitForRaw(self: *Harness, needle: []const u8, ms: i64) bool { if (std.mem.indexOf(u8, self.raw.items, needle) != null) return true; 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) continue; const n = posix.read(self.master, &buf) catch break; if (n == 0) break; self.recordRaw(buf[0..n]); self.stream.nextSlice(buf[0..n]); if (std.mem.indexOf(u8, self.raw.items, needle) != null) return true; } return false; } fn recordRaw(self: *Harness, bytes: []const u8) void { self.raw.appendSlice(bytes) catch |err| switch (err) { error.CaptureFull => {}, }; } /// The visual cursor position (viewport coords): { x, y }. pub fn cursor(self: *Harness) struct { x: u16, y: u16 } { return .{ .x = self.term.screens.active.cursor.x, .y = self.term.screens.active.cursor.y, }; } /// True if the shell is at a prompt (OSC 133 semantic prompt). Requires /// shell integration; false if the shell is outputting or has no integration. pub fn atPrompt(self: *Harness) bool { return self.term.cursorIsAtPrompt(); } /// Print the screen grid (clean text, not raw bytes) for debugging on /// failure. Also shows the cursor position and screen size. pub fn dump(self: *Harness, msg: []const u8) void { const text = self.screenText() catch { std.debug.print("E2E FAIL: {s}\n(could not read screen)\n", .{msg}); return; }; defer self.gpa.free(text); const c = self.cursor(); std.debug.print("E2E FAIL: {s}\nscreen {d}x{d} cursor=({d},{d}):\n{s}\n", .{ msg, self.cols, self.rows, c.x, c.y, text, }); } /// trace-mode screen snapshot: print the current grid + cursor + size to /// stderr (always, not just on failure). Bounded by a header so the live /// stream of snapshots is easy to scan. pub fn traceScreen(self: *Harness, tag: []const u8) void { const text = self.screenText() catch return; defer self.gpa.free(text); const c = self.cursor(); std.debug.print("\n[trace {s}] screen {d}x{d} cursor=({d},{d}):\n{s}\n[/trace]\n", .{ tag, self.cols, self.rows, c.x, c.y, text, }); } // ---- assertions (dump on failure, return error.ExpectFailed) ---- pub fn expectWaitFor(self: *Harness, needle: []const u8, ms: i64, msg: []const u8) !void { if (!try self.waitFor(needle, ms)) { self.dump(msg); return error.ExpectFailed; } } pub fn expectContains(self: *Harness, needle: []const u8, msg: []const u8) !void { if (!self.contains(needle)) { self.dump(msg); return error.ExpectFailed; } } pub fn expectNotContains(self: *Harness, needle: []const u8, msg: []const u8) !void { if (self.contains(needle)) { self.dump(msg); return error.ExpectFailed; } } /// Assert a byte sequence appears anywhere in the app's raw output so far. /// For control sequences the emulator swallows and never renders (OSC 52 etc.). pub fn expectRawContains(self: *Harness, needle: []const u8, msg: []const u8) !void { if (std.mem.indexOf(u8, self.raw.items, needle) == null) { std.debug.print("E2E FAIL: {s}\n(raw output has no {any})\n", .{ msg, needle }); return error.ExpectFailed; } } /// Assert a byte sequence appears in the app's raw output, waiting for it. /// A fixed `pump` before `expectRawContains` budgets a STARTUP instead of /// waiting for an answer, and a cold 70 MB Debug binary on a loaded machine /// overruns any such budget. pub fn expectRawWaitFor(self: *Harness, needle: []const u8, ms: i64, msg: []const u8) !void { if (!self.waitForRaw(needle, ms)) { std.debug.print("E2E FAIL: {s}\n(raw output has no {any})\n", .{ msg, needle }); return error.ExpectFailed; } } }; /// Write a file (helper for test setup, raw libc). pub fn writeFile(path: [*:0]const u8, data: []const u8) !void { const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); var off: usize = 0; while (off < data.len) { const w = libc.write(fd, data[off..].ptr, data.len - off); if (w <= 0) break; off += @intCast(w); } }