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
|
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 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)],
});
}
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;
}
// 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;
}
// 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).
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;
}
// 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;
}
// 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;
}
// 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;
}
// Tell the app to quit (ctrl-c is the app's own exit key).
try writeAll(master, "\x03");
}
|