diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-30 08:57:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-30 11:43:29 -0300 |
| commit | 8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch) | |
| tree | 788ae4b5e9145cbb601d5b8a30b16bfcfacf5277 /tests.zig | |
| parent | b5b526f465924324e2cd4424f5bacc43e50b7673 (diff) | |
| download | pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip | |
chinese vibes modal editing
Diffstat (limited to 'tests.zig')
| -rw-r--r-- | tests.zig | 375 |
1 files changed, 153 insertions, 222 deletions
@@ -1,103 +1,20 @@ const std = @import("std"); -const posix = std.posix; -const linux = std.os.linux; +const h = @import("e2e_harness.zig"); +const Harness = h.Harness; +const config = @import("config"); -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; - }, - } - } -} - -fn writeFileE2E(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); - try writeAll(fd, data); -} - -// 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 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); -} - -// Drain the pty for a fixed WALL-CLOCK duration (a real settle). pollFor's -// iter-count "settle" is unreliable for big bursts: reading caps at 4096/iter so -// all iters can be spent reading with no idle wait, making it finish too soon. -fn drainFor(gpa: std.mem.Allocator, master: c_int, output: *std.ArrayList(u8), ms: i64) !void { - const deadline = nowMs() + ms; - var buf: [4096]u8 = undefined; - while (nowMs() < deadline) { - var fds = [_]posix.pollfd{.{ .fd = master, .events = posix.POLL.IN, .revents = 0 }}; - _ = posix.poll(&fds, 50) catch {}; - if ((fds[0].revents & posix.POLL.IN) != 0) { - const n = posix.read(master, &buf) catch break; - if (n == 0) break; - try output.appendSlice(gpa, buf[0..n]); - } - } -} - -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)], - }); -} +// 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; @@ -106,153 +23,167 @@ pub fn main(init: std.process.Init) !void { 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); + 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 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; - } + // 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 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; - } + // 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)"); - // NOTE on polls: vaxis diff-renders, and char-mode text is built one keystroke - // per frame, so inserted chars are emitted across frames and never appear as a - // contiguous byte run. We `settle` (drain so input is processed, also avoiding - // a resize race), force a full repaint with setWinsize (re-emits whole rows - // contiguously), then poll. Command *output* lands in one frame already. - - // 3. Default is TEXT mode: the tag shows "ch" and shell prompts are hidden. - if (!try pollFor(gpa, master, &output, "ch /", 60)) { - dump(output.items, "default tag is not 'ch' (text mode)"); - return error.TextModeNotDefault; - } + // 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. Esc switches to TTY mode and forwards keystrokes to the shell. The settle - // lets vaxis emit a *standalone* Escape (lone ESC + text would be Alt+char). + // 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 writeAll(master, "\x1b"); - _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle - try writeAll(master, "echo " ++ marker ++ "\r"); - if (!try pollFor(gpa, master, &output, marker, 80)) { - dump(output.items, "tty mode did not forward the marker to the shell"); - return error.TtyModeNoForward; - } - try setWinsize(master, 50, 201); // repaint so the whole tag re-emits contiguously - if (!try pollFor(gpa, master, &output, "sy /", 40)) { - dump(output.items, "no 'sy' indicator after Esc (tty mode)"); - return error.TtyModeIndicatorMissing; - } + 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. - // Switching back to text mode hides the prompt/command rows but keeps the - // output. We clear the capture, switch with a left-click, force a repaint, - // then check the fresh frame: output present, prompt + command absent. - try writeAll(master, "echo CMDHIDEZZ\r"); - if (!try pollFor(gpa, master, &output, "CMDHIDEZZ", 80)) { - dump(output.items, "tty mode did not run the command"); - return error.TtyCommandFailed; - } - _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 8); // drain all tty-mode frames - output.clearRetainingCapacity(); // so the next poll only sees the text-mode repaint - try writeAll(master, "\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> text mode - _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle - try setWinsize(master, 50, 202); // force a full repaint (text mode, prompts hidden) - if (!try pollFor(gpa, master, &output, "CMDHIDEZZ", 80)) { - dump(output.items, "command output not shown in text mode"); - return error.OutputHiddenInTextMode; - } - if (std.mem.indexOf(u8, output.items, "goblin@") != null) { - dump(output.items, "shell prompt still visible in text mode"); - return error.PromptNotHidden; - } - if (std.mem.indexOf(u8, output.items, "echo CMDHIDEZZ") != null) { - dump(output.items, "command line still visible in text mode"); - return error.CommandNotHidden; - } + // 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. Text-mode editing + middle-click execute. Click a blank row to pin the - // cursor at text col 0, compose a computed echo, then middle-select those - // 15 cells (screen cols 2..16) and release. "Q7Z" appears only if the - // SELECTED typed text ran — proving selection reads the rendered screen. - try writeAll(master, "\x1b[<0;3;14M\x1b[<0;3;14m"); // click blank row 13, col 0 - 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; - } + // 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 (screen row 1). 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 writeAll(master, "\x1b"); - try drainFor(gpa, master, &output, 700); // settle (standalone Esc -> tty) - try writeAll(master, "rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r"); - try drainFor(gpa, master, &output, 2000); // let it run + the screen fully settle - try writeAll(master, "\x1b[<2;5;2M\x1b[<2;5;2m"); // right-click the path on screen row 1 - if (!try pollFor(gpa, master, &output, "ZZUNIQDIR", 120)) { - dump(output.items, "right-click on a directory did not open+ls it"); - return error.RightClickDirFailed; - } + // 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. Typed text renders starting at the click - // column, so the right-click lands on the path without knowing any pane - // geometry. The file pane opens, scrolls to line 150 -> the marker shows. - var fbuf: std.ArrayList(u8) = .empty; - defer fbuf.deinit(gpa); + // 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 writeFileE2E("/tmp/pardesE2Efile.txt", fbuf.items); - try setWinsize(master, 50, 200); // clean, known geometry (col 149 is in the right column) - try drainFor(gpa, master, &output, 700); // settle the reflow - output.clearRetainingCapacity(); - try writeAll(master, "\x1b[<0;150;11M\x1b[<0;150;11m"); // left-click blank row, right column -> text mode + pin - try writeAll(master, "/tmp/pardesE2Efile.txt:150"); // type the path:line (char mode) - _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 8); // settle (build the run) - try writeAll(master, "\x1b[<2;156;11M\x1b[<2;156;11m"); // right-click on the typed path - if (!try pollFor(gpa, master, &output, "ZZDEEPMARK", 120)) { - dump(output.items, "right-click 'path:150' did not open the file scrolled to line 150"); - return error.RightClickFileFailed; - } + 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 writeAll(master, "\x03"); + try hs.send("\x03"); } |
