diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-30 08:57:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-30 11:43:29 -0300 |
| commit | 8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch) | |
| tree | 788ae4b5e9145cbb601d5b8a30b16bfcfacf5277 /e2e_harness.zig | |
| parent | b5b526f465924324e2cd4424f5bacc43e50b7673 (diff) | |
| download | pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip | |
chinese vibes modal editing
Diffstat (limited to 'e2e_harness.zig')
| -rw-r--r-- | e2e_harness.zig | 248 |
1 files changed, 248 insertions, 0 deletions
diff --git a/e2e_harness.zig b/e2e_harness.zig new file mode 100644 index 00000000..05b9c6e5 --- /dev/null +++ b/e2e_harness.zig @@ -0,0 +1,248 @@ +const std = @import("std"); +const posix = std.posix; +const linux = std.os.linux; +const ghostty_vt = @import("ghostty-vt"); + +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; + +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); +} + +/// 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, + + pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness { + 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) { + const argv = [_:null]?[*:0]const u8{ exe, null }; + _ = execvp(exe, &argv); + _exit(127); + } + const term = try gpa.create(ghostty_vt.Terminal); + errdefer gpa.destroy(term); + term.* = try ghostty_vt.Terminal.init(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(); + return .{ + .gpa = gpa, + .master = master, + .pid = pid, + .term = term, + .stream = stream, + .rows = rows, + .cols = cols, + }; + } + + pub fn deinit(self: *Harness) void { + posix.kill(self.pid, posix.SIG.KILL) catch {}; + _ = linux.close(self.master); + self.stream.deinit(); + 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 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); + } + } + + /// 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.stream.nextSlice(buf[0..n]); + } + } + if (self.trace) self.traceScreen("pump"); + } + + /// 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, rows) catch {}; + const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(self.master, posix.T.IOCSWINSZ, @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.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; + } + + /// 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; + } + } +}; + +/// Write a file (helper for test setup, raw linux syscalls). +pub fn writeFile(path: [*:0]const u8, data: []const u8) !void { + const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const sfd: isize = @bitCast(rc); + if (sfd < 0) return error.OpenFailed; + const fd: c_int = @intCast(sfd); + defer _ = linux.close(fd); + var off: usize = 0; + while (off < data.len) { + const w = linux.write(fd, data[off..].ptr, data.len - off); + const sw: isize = @bitCast(w); + if (sw <= 0) break; + off += @intCast(sw); + } +} |
