summaryrefslogtreecommitdiff
path: root/test/e2e_harness.zig
blob: 6354aa4bc7cf5eb55f635bb68f6218dc4a68db23 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
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);
    }
}