summaryrefslogtreecommitdiff
path: root/e2e_harness.zig
diff options
context:
space:
mode:
Diffstat (limited to 'e2e_harness.zig')
-rw-r--r--e2e_harness.zig269
1 files changed, 0 insertions, 269 deletions
diff --git a/e2e_harness.zig b/e2e_harness.zig
deleted file mode 100644
index cd4ffd63..00000000
--- a/e2e_harness.zig
+++ /dev/null
@@ -1,269 +0,0 @@
-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 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: [3:null]?[*:0]const u8 = .{ exe, arg, 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.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);
- }
-}