summaryrefslogtreecommitdiff
path: root/tests.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-30 08:57:43 -0300
committerGabriel Schneider <[email protected]>2026-06-30 11:43:29 -0300
commit8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch)
tree788ae4b5e9145cbb601d5b8a30b16bfcfacf5277 /tests.zig
parentb5b526f465924324e2cd4424f5bacc43e50b7673 (diff)
downloadpardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz
pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip
chinese vibes modal editing
Diffstat (limited to 'tests.zig')
-rw-r--r--tests.zig375
1 files changed, 153 insertions, 222 deletions
diff --git a/tests.zig b/tests.zig
index d9cc763b..c389b8cf 100644
--- a/tests.zig
+++ b/tests.zig
@@ -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");
}