summaryrefslogtreecommitdiff
path: root/tests.zig
blob: 504dad4d9d67af462b403e4d6fdfd7e5ff4c0bd0 (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
const std = @import("std");
const h = @import("e2e_harness.zig");
const Harness = h.Harness;
const config = @import("config");

// E2E tests for pardes. The harness forks the app in a pty and parses its
// output with a ghostty terminal emulator, so all assertions read the 2D screen
// GRID (plainString) — not raw byte streams. This is decoupled from the app's
// rendering (vaxis diff-rendering, repaints, escape sequences): the grid is
// always current after pump/waitFor.
//
// Input is sent as raw bytes (keystrokes, SGR mouse sequences) that the app's
// vaxis parses. The harness terminal only reads output.
//
// Mouse events use SGR pixel mode: \x1b[<button;col;row M (press) / m (release).
// Cols/rows are 1-based screen coords. Button 0=left, 1=middle, 2=right.
// A drag is press at start + release at end (with different coords).

// Row index (0-based) of the first screen line containing `needle`, or null.
fn rowOf(text: []const u8, needle: []const u8) ?usize {
    var it = std.mem.splitScalar(u8, text, '\n');
    var i: usize = 0;
    while (it.next()) |line| : (i += 1) {
        if (std.mem.indexOf(u8, line, needle) != null) return i;
    }
    return null;
}

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];

    var hs = try Harness.init(gpa, exe.ptr, 24, 80);
    hs.trace = config.trace;
    defer hs.deinit();

    // 1. The app posts an initial winsize on startup. Its winsize handler bumps
    //    the resize counter to 1 and repaints, so "resizes=1" shows in the debug
    //    overlay. Confirms the app is up and handled its first size.
    try hs.expectWaitFor("resizes=1", 6000, "app never handled its initial winsize (no 'resizes=1')");

    // 2. Regression: grow the pty -> SIGWINCH -> app re-resizes. Counter bumps
    //    to 2 only if the resize handler is installed.
    try hs.resize(30, 100);
    try hs.expectWaitFor("resizes=2", 8000, "SIGWINCH was not turned into a resize (counter stuck at 1)");

    // 3. Default is NORMAL mode: the tag shows "nm" (helix-style block cursor).
    try hs.expectContains("nm /", "default tag is not 'nm' (normal mode not default)");

    // 3b. ls-on-open: a fresh shell greets with its directory listing. The e2e app
    //     boots in the project dir, so a known file shows up as command output
    //     (visible even in normal mode — only prompt rows are hidden).
    try hs.expectWaitFor("build.zig", 8000, "new terminals did not auto-ls their directory on open");

    // 3c. Normal-mode TAB = acme middle-click "execute" the word under the cursor.
    //     Drop a one-line executable, echo its path onto body row 0 (clear homes the
    //     cursor first), left-click the path to enter normal + pin the cursor on it,
    //     then TAB. It runs the word in place (no new pane) -> the script's marker.
    try hs.send("\x02"); // normal -> tty (Ctrl-b)
    try hs.pump(600);
    try hs.send("printf '#!/bin/sh\\necho ZZTABRAN\\n' > /tmp/zztabcmd; chmod +x /tmp/zztabcmd; clear; echo /tmp/zztabcmd\r");
    try hs.pump(1200);
    try hs.send("\x1b[<0;8;2M\x1b[<0;8;2m"); // left-click the path on body row 0 -> normal + pin
    try hs.pump(600);
    try hs.send("\t"); // TAB: execute "/tmp/zztabcmd"
    try hs.expectWaitFor("ZZTABRAN", 8000, "normal-mode Tab did not execute the word under the cursor");
    try hs.pump(400);

    // 3d. OUTPUT PADDING: a command composed (typed as an overlay) far below the
    //     prompt runs BELOW where it was typed, not back up at the prompt. The app
    //     pads the shell down with newlines to max(last-edit row, prompt row); the
    //     padding prompts are hidden in text mode, so the output flows under the
    //     composed line. Clear the pane (prompt -> row 0), type a command on a low
    //     row whose OUTPUT marker (PAD13Z) differs from its source (echo PAD...),
    //     run it, and assert the output row is BELOW the typed row. (Screen is 30x100
    //     from step 2; terms[0] is the ~15-row top-left pane.)
    try hs.send("\x02"); // normal -> tty
    try hs.pump(400);
    try hs.send("clear\r"); // prompt back to row 0
    try hs.pump(900);
    try hs.send("\x02"); // tty -> normal
    try hs.pump(400);
    try hs.send("\x1b[<0;5;10M\x1b[<0;5;10m"); // left-click a low blank row (screen row 9) -> normal + pin
    try hs.pump(400);
    try hs.send("i"); // insert
    try hs.pump(400);
    try hs.send("echo PAD$((6+7))Z"); // overlay; output is "PAD13Z"
    try hs.pump(600);
    try hs.send("\x1b"); // -> normal
    try hs.pump(400);
    try hs.send("\x1b[<1;5;10M\x1b[<33;21;10M\x1b[<1;21;10m"); // middle-drag-select the line -> run (padded)
    try hs.expectWaitFor("PAD13Z", 8000, "padded command did not run");
    try hs.pump(600);
    {
        const text = try hs.screenText();
        defer gpa.free(text);
        const typed = rowOf(text, "echo PAD") orelse {
            hs.dump("composed line vanished before the padding check");
            return error.ExpectFailed;
        };
        const outp = rowOf(text, "PAD13Z") orelse {
            hs.dump("padded output not found on screen");
            return error.ExpectFailed;
        };
        if (outp <= typed) {
            hs.dump("output did not land below the composed command (padding failed)");
            return error.ExpectFailed;
        }
        // and it should land RIGHT under the composed line (tight alignment), not
        // somewhere far below — the hidden padding prompts fill the gap above it.
        if (outp - typed > 2) {
            hs.dump("padded output landed too far below the composed line (misaligned)");
            return error.ExpectFailed;
        }
    }
    // tidy up: drop the lingering overlay so it doesn't ride the scrollback into
    // later steps (cursor is on the typed row; x selects it, d clears the run).
    try hs.send("x");
    try hs.pump(300);
    try hs.send("d");
    try hs.pump(300);

    // 4. Ctrl-b toggles raw TTY mode from normal and forwards keystrokes to the
    //    shell. tty is a hidden toggle (Ctrl-b), off the normal editing workflow.
    const marker = "PARDES_E2E";
    try hs.send("\x02"); // normal -> tty (Ctrl-b)
    try hs.pump(600);
    try hs.send("echo " ++ marker ++ "\r");
    try hs.expectWaitFor(marker, 8000, "tty mode did not forward the marker to the shell");
    try hs.expectContains("sy /", "no 'sy' indicator after Ctrl-b (tty mode)");

    // 4b. Ctrl-b toggles back: tty -> normal. Send Ctrl-b again and confirm the
    //    tag flips off "sy" (raw tty) back to "nm" (normal).
    try hs.send("\x02"); // tty -> normal (Ctrl-b)
    try hs.pump(600);
    try hs.expectContains("nm /", "Ctrl-b did not toggle tty back to normal");
    try hs.send("\x02"); // normal -> tty again for the prompt-hiding step
    try hs.pump(600);

    // 5. Prompt hiding in TEXT mode. tty shows the real terminal (command line +
    //    output). BOTH normal and insert hide the prompt/command rows (the acme
    //    compose surface) while keeping output. Run a command in tty, left-click
    //    to NORMAL, and check: output present, the command's prompt row hidden.
    try hs.send("echo CMDHIDEZZ\r");
    try hs.expectWaitFor("CMDHIDEZZ", 8000, "tty mode did not run the command");
    try hs.pump(800); // let tty-mode frames settle
    try hs.send("\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> normal (prompts now hidden)
    try hs.pump(600);
    try hs.expectContains("CMDHIDEZZ", "command output not shown in normal mode");
    // The prompt row (with the typed command) is hidden in normal mode now too. We
    // check "echo CMDHIDEZZ" (this pane's prompt row) rather than "goblin@" since a
    // raw-tty pane elsewhere could show its own prompt.
    try hs.expectNotContains("echo CMDHIDEZZ", "command line still visible in normal mode (prompt row not hidden)");

    // 6. Insert-mode editing + middle-click execute. Click a blank row to pin the
    //    cursor at text col 0, type a computed echo, then middle-select those 15
    //    cells and release. "Q7Z" appears only if the SELECTED typed text ran —
    //    proving selection reads the rendered screen.
    try hs.send("\x1b[<0;3;14M\x1b[<0;3;14m"); // click blank row 13, col 0 -> normal + pin
    try hs.pump(400);
    try hs.send("i"); // normal -> insert so typing composes the command
    try hs.pump(400);
    try hs.send("echo Q$((3+4))Z");
    try hs.pump(600); // let the run build
    try hs.send("\x1b[<1;3;14M\x1b[<33;17;14M\x1b[<1;17;14m"); // middle-select it
    try hs.expectWaitFor("Q7Z", 8000, "middle-click did not execute the selected typed text");
    // leave insert -> normal so the next step can reach tty via Ctrl-b
    try hs.send("\x1b");
    try hs.pump(400);

    // 6b. FOLLOW OUTPUT: new tty output scrolls the pane to the bottom, so a result
    //     shows even after you've scrolled up. Fill the pane, wheel UP off the
    //     bottom (LN40 disappears), then run a command — the pane snaps back and the
    //     new marker is visible. Done in tty mode so the active pane follows too.
    try hs.send("\x02"); // normal -> tty
    try hs.pump(400);
    try hs.send("clear; for i in $(seq 1 40); do echo LN$i; done\r");
    try hs.expectWaitFor("LN40", 8000, "filler output never appeared");
    try hs.pump(500);
    {
        var w: usize = 0;
        while (w < 30) : (w += 1) try hs.send("\x1b[<64;5;5M"); // wheel UP over the pane
    }
    try hs.pump(500);
    try hs.expectNotContains("LN40", "wheel-up did not scroll the pane off the bottom");
    try hs.send("echo ZZFOLLOW\r");
    try hs.expectWaitFor("ZZFOLLOW", 8000, "new output did not scroll the pane back to the bottom");
    try hs.send("\x02"); // tty -> normal (step 7 re-enters via Ctrl-b)
    try hs.pump(400);

    // 7. Right-click a directory opens (acme "look") a terminal there and ls's
    //    it. Make a dir with a unique entry, then `clear` and echo its path so the
    //    path lands as contiguous output on body row 0. Right-click (no drag) on
    //    it: the new terminal lists the dir, so the unique entry appears only if
    //    the directory was actually opened.
    try hs.send("\x02"); // normal -> tty (Ctrl-b)
    try hs.pump(700);
    try hs.send("rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r");
    try hs.pump(2000); // let it run + settle
    try hs.send("\x1b[<2;5;2M\x1b[<2;5;2m"); // right-click the path on screen row 1
    try hs.expectWaitFor("ZZUNIQDIR", 12000, "right-click on a directory did not open+ls it");

    // 8. Right-click a "path:line" opens a line-numbered FILE pane SCROLLED to
    //    that line. Build a tall file whose deep marker (line 150) is only
    //    reachable by scrolling, type the path:line on a blank row of the right
    //    column, then right-click it. The file pane opens, scrolls to line 150 ->
    //    the marker shows.
    {
        var fbuf: std.ArrayList(u8) = .empty;
        defer fbuf.deinit(gpa);
        var ln: usize = 1;
        while (ln <= 200) : (ln += 1)
            try fbuf.appendSlice(gpa, if (ln == 150) "ZZDEEPMARK\n" else "filler\n");
        try h.writeFile("/tmp/pardesE2Efile.txt", fbuf.items);
    }
    try hs.resize(30, 100); // clean geometry (right column starts ~col 51)
    try hs.pump(700); // settle the reflow
    try hs.send("\x1b[<0;60;11M\x1b[<0;60;11m"); // left-click blank row, right column -> normal + pin
    try hs.pump(400);
    try hs.send("i"); // normal -> insert so typing composes the path:line
    try hs.pump(400);
    try hs.send("/tmp/pardesE2Efile.txt:150");
    try hs.pump(800); // build the run
    try hs.send("\x1b[<2;70;11M\x1b[<2;70;11m"); // right-click on the typed path
    try hs.expectWaitFor("ZZDEEPMARK", 12000, "right-click 'path:150' did not open the file scrolled to line 150");

    // 9. Modal modes on the active FILE pane (left open & focused by step 8):
    //    `i` -> INSERT (tag shows "in"), Esc -> NORMAL ("nm"). Validates the
    //    3-mode model end to end via the tag indicator. No content changes here.
    try hs.send("i"); // normal -> insert
    try hs.pump(600);
    try hs.expectContains("in /", "no 'in' indicator after i (insert mode)");
    try hs.send("\x1b"); // insert -> normal
    try hs.pump(600);
    try hs.expectContains("nm /", "no 'nm' indicator after Esc (back to normal)");

    // 10. Helix-style file editing on that same file pane (still focused, normal,
    //     cursor on the ZZDEEPMARK line). `o` opens a line below and enters insert;
    //     type a unique marker, Esc to normal, then `x` (select line) + `d` (delete
    //     selection) removes it. Assert the marker appears then is gone, while the
    //     surrounding file content survives — proving real content mutation.
    try hs.send("o"); // open line below -> insert
    try hs.pump(600);
    try hs.send("ZZMODALNEW");
    try hs.pump(600);
    try hs.send("\x1b"); // back to normal
    try hs.pump(600);
    try hs.expectContains("ZZMODALNEW", "'o' did not open a line / typed text not shown");
    // cursor sits on the ZZMODALNEW line: `x` selects it, `d` deletes it.
    try hs.send("x");
    try hs.pump(400);
    try hs.send("d");
    try hs.pump(800);
    try hs.expectNotContains("ZZMODALNEW", "ZZMODALNEW still present after x+d (line delete failed)");
    // sanity: the deep marker (line 150, above the opened/deleted line) survives.
    try hs.expectContains("ZZDEEPMARK", "ZZDEEPMARK vanished — file pane content was clobbered");

    // 11. The Tutor builtin: middle-clicking "Tutor" in any tag spawns the tutor
    //     text as a new file pane. The tutor is embedded in the binary
    //     (@embedFile), so its content (the PARDES TUTOR header + the acme mouse
    //     intro) appears on screen. Middle-click is button 1 (SGR: <1;...M).
    //     Tag row 0 is the first row of any pane; "Tutor" sits at the end of the
    //     tag. Click somewhere in the tag's right half to hit it.
    try hs.send("\x1b[<1;48;1M\x1b[<1;48;1m"); // middle-click "Tutor" in the tag row (file pane's "nm " prefix shifts it +3)
    try hs.expectWaitFor("PARDES TUTOR", 10000, "middle-click 'Tutor' did not spawn the tutor pane");
    try hs.expectContains("tmux + vi + acme", "tutor pane content is wrong (no tagline)");

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