summaryrefslogtreecommitdiff
path: root/test/e2e_harness.zig
blob: f7f380df5d0fe145a68cb5620834c252323eb884 (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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
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);
    }
}