summaryrefslogtreecommitdiff
path: root/tests.zig
blob: 1d5e8c4c6c34e646aec132aa7a9b76e0a69d1f41 (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
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;

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 writeAll(fd: c_int, bytes: []const u8) !void {
    var off: usize = 0;
    while (off < bytes.len) {
        const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off);
        switch (posix.errno(rc)) {
            .SUCCESS => off += @intCast(rc),
            .INTR => {},
            else => |err| {
                std.debug.print("write failed: {}\n", .{err});
                return error.WriteFailed;
            },
        }
    }
}

// TIOCSWINSZ on the pty master: changes the size and makes the kernel raise
// SIGWINCH in the slave's foreground process group (i.e. the app under test).
fn setWinsize(fd: c_int, rows: u16, cols: u16) !void {
    const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
    const rc = linux.ioctl(fd, linux.T.IOCSWINSZ, @intFromPtr(&ws));
    if (posix.errno(rc) != .SUCCESS) return error.IoctlFailed;
}

// Poll the pty for up to `iters` * 100ms, appending everything read into
// `output`. Returns true as soon as `needle` is present in the accumulated
// output (the app renders to the alt screen, so we just scan the byte stream).
fn pollFor(
    gpa: std.mem.Allocator,
    master: c_int,
    output: *std.ArrayList(u8),
    needle: []const u8,
    iters: usize,
) !bool {
    if (std.mem.indexOf(u8, output.items, needle) != null) return true;
    var buf: [4096]u8 = undefined;
    for (0..iters) |_| {
        var fds = [_]posix.pollfd{.{ .fd = master, .events = posix.POLL.IN, .revents = 0 }};
        _ = posix.poll(&fds, 100) catch {};
        if ((fds[0].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR)) != 0) {
            const n = posix.read(master, &buf) catch break;
            if (n == 0) break;
            try output.appendSlice(gpa, buf[0..n]);
            if (std.mem.indexOf(u8, output.items, needle) != null) return true;
        }
    }
    return std.mem.indexOf(u8, output.items, needle) != null;
}

fn dump(items: []const u8, msg: []const u8) void {
    std.debug.print("E2E FAIL: {s}\ncaptured {d} bytes:\n{s}\n", .{
        msg,
        items.len,
        items[0..@min(items.len, 8192)],
    });
}

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    if (args.len != 2) {
        std.debug.print("usage: {s} <pardes-exe>\n", .{args[0]});
        return error.BadArgs;
    }

    const exe = args[1];

    // Boot the app in a small pty.
    var master: c_int = undefined;
    const ws = posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
    const pid = forkpty(&master, null, null, &ws);
    if (pid == 0) {
        const child_argv = [_:null]?[*:0]const u8{ exe.ptr, null };
        _ = execvp(exe.ptr, &child_argv);
        _exit(127);
    }
    defer _ = linux.close(master);
    defer posix.kill(pid, posix.SIG.KILL) catch {};

    var output: std.ArrayList(u8) = .empty;
    defer output.deinit(gpa);

    // 1. The app posts an initial winsize on startup. Its winsize handler bumps
    //    the resize counter to 1 and calls vx.resize, which forces a full
    //    repaint, so "resizes 1" shows up in the rendered stream. This just
    //    confirms the app is up and handled its first size.
    if (!try pollFor(gpa, master, &output, "resizes=1", 60)) {
        dump(output.items, "app never handled its initial winsize (no 'resizes=1')");
        return error.InitialResizeMissing;
    }

    // 2. Regression check for "resizes aren't happening": grow the outer pty.
    //    This delivers SIGWINCH to the app. Only if the app installed its
    //    resize handler will it post a new winsize event, bump the counter to
    //    2, and repaint -> "resizes 2". If the handler is missing the counter
    //    stays at 1 forever and this times out.
    try setWinsize(master, 50, 200);
    if (!try pollFor(gpa, master, &output, "resizes=2", 80)) {
        dump(output.items, "SIGWINCH was not turned into a resize (counter stuck at 1)");
        return error.ResizeNotDelivered;
    }

    // 3. Sym mode (default): keystrokes are forwarded to the embedded shell.
    //    The tag shows the "sy" mode indicator and the marker echoes back.
    const marker = "PARDES_E2E";
    try writeAll(master, "echo " ++ marker ++ "\r");
    if (!try pollFor(gpa, master, &output, marker, 80)) {
        dump(output.items, "app did not render echoed marker");
        return error.MarkerNotRendered;
    }
    if (!try pollFor(gpa, master, &output, "sy ", 20)) {
        dump(output.items, "no 'sy' mode indicator on the tag in sym mode");
        return error.ModeIndicatorMissing;
    }

    // NOTE on the polls below: vaxis diff-renders, and char-mode text is built
    // one keystroke per frame, so the inserted chars are emitted across frames
    // and never appear as a contiguous byte run. We therefore `settle` (drain so
    // the input is fully processed, which also avoids racing the resize), force a
    // full repaint with setWinsize (which re-emits whole rows contiguously), and
    // only then poll. Command *output* (e.g. R42Q) lands in one frame already, so
    // it needs no repaint.

    // 4. A left-click enters char mode and typed text is inserted as an overlay.
    //    SGR mouse: press+release of button 0 at a body cell (col 6, row 3).
    try writeAll(master, "\x1b[<0;6;3M\x1b[<0;6;3m");
    try writeAll(master, "EDITZZ1");
    _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle
    try setWinsize(master, 50, 201); // force a full repaint
    if (!try pollFor(gpa, master, &output, "EDITZZ1", 80)) {
        dump(output.items, "char-mode insertion did not render");
        return error.EditNotRendered;
    }

    // 5. The same repaint confirms the mode flipped sy -> ch on the tag. If char
    //    mode never engaged, the marker went to the shell and the tag reads "sy".
    if (!try pollFor(gpa, master, &output, "ch /", 80)) {
        dump(output.items, "left-click did not switch the tag to 'ch' (char mode)");
        return error.CharModeNotEntered;
    }

    // 6. A second click anchors a second independent insertion run.
    try writeAll(master, "\x1b[<0;6;5M\x1b[<0;6;5m");
    try writeAll(master, "EDITZZ2");
    _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle
    try setWinsize(master, 50, 202); // force a full repaint
    if (!try pollFor(gpa, master, &output, "EDITZZ2", 80)) {
        dump(output.items, "second anchored insertion did not render");
        return error.SecondEditNotRendered;
    }

    // 6b. Middle-click executes the typed text read off the rendered screen.
    //     Char-click a blank body row, type a computed echo as an insertion, then
    //     middle-select exactly those 15 cells (text col 0 -> screen cols 2..16)
    //     and release. The result "Q7Z" appears only if the SELECTED inserted
    //     text ran — proving selection sees typed text, not the tty beneath it.
    try writeAll(master, "\x1b[<0;3;14M\x1b[<0;3;14m"); // char-click blank row 13
    try writeAll(master, "echo Q$((3+4))Z");
    _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle (build the run)
    try writeAll(master, "\x1b[<1;3;14M\x1b[<33;17;14M\x1b[<1;17;14m"); // middle-select it
    if (!try pollFor(gpa, master, &output, "Q7Z", 80)) {
        dump(output.items, "middle-click did not execute the selected typed text");
        return error.MiddleExecFailed;
    }

    // 7. Esc leaves char mode (tag back to sy) and input resumes going to the
    //    pty: a computed echo's result only appears if the shell executed it.
    //    (In char mode the literal "$((20+22))" would render but never "R42Q".)
    //    The settle after Esc lets vaxis emit a *standalone* Escape — a lone ESC
    //    immediately followed by text is otherwise parsed as Alt+<char>.
    try writeAll(master, "\x1b");
    _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle
    try writeAll(master, "echo R$((20+22))Q\r");
    if (!try pollFor(gpa, master, &output, "R42Q", 80)) {
        dump(output.items, "Esc did not return to sym mode (computed echo never ran)");
        return error.SymModeNotRestored;
    }

    // Tell the app to quit (ctrl-c is the app's own exit key).
    try writeAll(master, "\x03");
}