summaryrefslogtreecommitdiff
path: root/tests.zig
blob: c389b8cf856cac030f15599f76184fd9511bddfe (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
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).

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)");

    // 4. Enter switches to TTY mode from normal and forwards keystrokes to the
    //    shell. (Esc no longer reaches tty; Enter is the tty-enter key.)
    const marker = "PARDES_E2E";
    try hs.send("\r"); // normal -> tty (Enter)
    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 Enter (tty mode)");

    // 4b. Enter-to-tty POSITIONING (the start-vs-end distinction). Still in tty:
    //    clear to a clean screen, type input WITHOUT running it, go to normal,
    //    navigate the modal cursor to the last char of the typed text, then
    //    Enter. enterTty uses ghostty's promptClickMove to move the shell's real
    //    cursor there, so the next typed char inserts BEFORE that last char.
    //    Running it yields "ABCDEXF" — proving the shell cursor was repositioned
    //    mid-input. A no-op Enter (cursor stays at end) would append -> "ABCDEFX".
    try hs.send("clear\r");
    try hs.pump(800);
    try hs.send("echo ABCDEF"); // typed input, NOT run (no \r)
    try hs.pump(800);
    try hs.send("\x1b[<0;3;2M\x1b[<0;3;2m"); // left-click body row 0, col 0 (col 3 = past gutter) -> normal
    try hs.pump(600);
    try hs.send("$"); // modal: end of row 0 (lands on 'F')
    try hs.pump(600);
    try hs.send("\r"); // Enter: normal -> tty, shell cursor moved onto 'F'
    try hs.pump(600);
    try hs.send("X\r"); // insert 'X' before 'F', then run -> "ABCDEXF"
    try hs.expectWaitFor("ABCDEXF", 10000, "Enter did not reposition the shell cursor mid-input (no ABCDEXF)");
    try hs.pump(600);
    try hs.expectNotContains("ABCDEFX", "Enter left the shell cursor at the end (appended X -> ABCDEFX)");

    // 5. Prompt hiding. In tty mode a command line + its output are both visible.
    //    Normal mode shows the real terminal (prompts visible); INSERT mode is the
    //    acme compose surface, hiding prompt/command rows but keeping the output.
    //    Run a command, left-click to normal, press `i` to enter insert, then
    //    check: output present, prompt + command absent.
    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
    try hs.pump(600);
    try hs.send("i"); // normal -> insert (prompts now hidden)
    try hs.pump(600);
    try hs.expectContains("CMDHIDEZZ", "command output not shown in insert mode");
    // The prompt row (with the typed command) is hidden in insert mode. We check
    // "echo CMDHIDEZZ" (specific to this pane's prompt row) rather than
    // "goblin@" because other panes in normal mode show their own prompts.
    try hs.expectNotContains("echo CMDHIDEZZ", "command line still visible in insert 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 Enter
    try hs.send("\x1b");
    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("\r"); // normal -> tty (Enter)
    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;45;1M\x1b[<1;45;1m"); // middle-click the tag row (pane 0)
    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");
}