summaryrefslogtreecommitdiff
path: root/test/e2e_harness.zig
diff options
context:
space:
mode:
Diffstat (limited to 'test/e2e_harness.zig')
-rw-r--r--test/e2e_harness.zig278
1 files changed, 278 insertions, 0 deletions
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig
new file mode 100644
index 00000000..6354aa4b
--- /dev/null
+++ b/test/e2e_harness.zig
@@ -0,0 +1,278 @@
+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,
+ /// 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). gpa-owned; freed in deinit.
+ raw: std.ArrayList(u8) = .empty,
+
+ 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 {
+ 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) {
+ var argv: [8:null]?[*:0]const u8 = @splat(null);
+ argv[0] = exe;
+ for (args, 0..) |a, i| argv[i + 1] = a;
+ _ = 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.raw.deinit(self.gpa);
+ 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.raw.appendSlice(self.gpa, buf[0..n]) catch {};
+ 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.raw.appendSlice(self.gpa, buf[0..n]) catch {};
+ 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;
+ }
+ }
+
+ /// 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;
+ }
+ }
+};
+
+/// 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);
+ }
+}