summaryrefslogtreecommitdiff
path: root/test/monkey.zig
blob: 83869ce4c9291be83d03e54b8ae4c4cbb2cace2d (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
// pardes-monkey: random snapshot scripts against the tty, hunting panics.
//
//   zig build monkey -Dplatform=tty -- <seeds> [steps] [--from=N] [--out=DIR] [--keep]
//
// Each seed makes one script (a fixed seed makes the same script, so a hit
// replays): keys and leader bursts, typing, mouse presses, drags and wheel,
// tiny resizes, prompts and long prompt text, over files of bad UTF-8 and
// wide glyphs. pardes-snap runs it with --update into DIR (default
// /tmp/pardes-monkey), and a panic is a record in the app's crash file
// (src/crash.zig, in the script's fake home: found even when the crash cut
// the script short) or a `.zig:N:N: 0x` stack frame in the report or the
// grid it captured (a trace renders INTO the screen). A hit
// keeps DIR/seed-N.snap, its captures and its report; the rest are deleted.
// A panic's trace can scroll off a small grid: replay the kept script, or
// run the app in a larger pty. Every hit gets a fix and a pinned regression
// script in test/snapshots/.
//
// Not a test gate: it is slow, random over seeds and aimed at finding, not
// holding. Shell text typed into a pane only ever runs in the throwaway
// script directory, and its alphabet has no path characters.
const std = @import("std");
const libc = std.c;

const Script = struct {
    out: std.ArrayListUnmanaged(u8) = .empty,
    gpa: std.mem.Allocator,
    rows: u16 = 24,
    cols: u16 = 80,

    fn line(s: *Script, comptime fmt: []const u8, args: anytype) !void {
        try s.out.print(s.gpa, fmt ++ "\n", args);
    }
};

// Workspace-tag words: clicked with the middle button they run, and the
// harness finds their column on row 1 while the window is wide enough.
const commands = [_][]const u8{ "Newcol", "Find", "Grep", "Joincol", "Help", "Dump", "NextColor", "Debug" };
const leader_keys = "abcdefghijklmnopqrstuvwxyz0123456789$^";
const typed = "abcdefghijklmnopqrstuvwxyz0123456789 -_=+,:;()";

fn generate(gpa: std.mem.Allocator, seed: u64, steps: usize) ![]u8 {
    var prng = std.Random.DefaultPrng.init(seed);
    const random = prng.random();
    var s: Script = .{ .gpa = gpa };
    try s.line("# pardes-monkey seed {d}, {d} steps", .{ seed, steps });
    try s.line("file bad.txt hello \\xff\\xfe world\\nlatin-1 caf\\xe9 tail\\ntruncated \\xe6\\x97 here\\nend\\n", .{});
    try s.line("file wide.txt {s}tail\\nsecond line\\n", .{"\\xe6\\x97\\xa5\\xe6\\x9c\\xac\\xe8\\xaa\\x9e " ** 8});
    try s.line("lines long.txt 400 some words after the number, long enough to wrap in a narrow pane", .{});
    try s.line("start 24 80 bad.txt", .{});
    try s.line("wait 8000 Newcol Joincol", .{});
    try s.line("stable 300 5000", .{});
    for (0..steps) |step| {
        const wide = s.cols >= 70 and s.rows >= 6;
        switch (random.uintLessThan(u8, 12)) {
            // A workspace word, run with a middle click: new columns make
            // narrow panes, Find and Grep open prompts.
            0, 1 => if (wide) {
                const word = commands[random.uintLessThan(usize, commands.len)];
                try s.line("press middle @{s} 1", .{word});
                try s.line("release middle @{s} 1", .{word});
            },
            // Typing: into a prompt when one is up, far past its band.
            2, 3 => {
                var text: [200]u8 = undefined;
                const n = random.intRangeAtMost(usize, 1, if (random.uintLessThan(u8, 4) == 0) text.len else 12);
                for (text[0..n]) |*c| c.* = typed[random.uintLessThan(usize, typed.len)];
                try s.line("text {s}", .{text[0..n]});
            },
            4 => try s.line("key {s}", .{switch (random.uintLessThan(u8, 10)) {
                0 => "enter",
                1 => "tab",
                2 => "bs",
                3 => "up",
                4 => "down",
                5 => "left",
                6 => "right",
                7 => "pgdn",
                8 => "home",
                else => "end",
            }}),
            5 => {
                try s.line("key esc", .{});
                try s.line("settle 120", .{});
            },
            // A leader burst: space and a chord's keys.
            6 => {
                var chord: [3][2]u8 = undefined;
                const n = random.intRangeAtMost(usize, 1, 3);
                for (chord[0..n]) |*c| c.* = .{ leader_keys[random.uintLessThan(usize, leader_keys.len)], 0 };
                try s.out.appendSlice(gpa, "key space");
                for (chord[0..n]) |c| try s.out.print(gpa, " {c}", .{c[0]});
                try s.out.append(gpa, '\n');
            },
            7, 8 => {
                const buttons = [_][]const u8{ "left", "middle", "right" };
                const button = buttons[random.uintLessThan(usize, buttons.len)];
                const col = random.intRangeAtMost(u16, 1, s.cols);
                const row = random.intRangeAtMost(u16, 1, s.rows);
                try s.line("press {s} {d} {d}", .{ button, col, row });
                if (random.boolean()) try s.line("drag {s} {d} {d}", .{ button, random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) });
                try s.line("release {s} {d} {d}", .{ button, random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) });
            },
            9 => try s.line("wheel {s} {d} {d}", .{ if (random.boolean()) "up" else "down", random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) }),
            10 => try s.line("motion {d} {d}", .{ random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) }),
            // Tiny windows as often as ordinary ones.
            else => {
                s.rows = if (random.boolean()) random.intRangeAtMost(u16, 1, 6) else random.intRangeAtMost(u16, 7, 40);
                s.cols = if (random.boolean()) random.intRangeAtMost(u16, 1, 20) else random.intRangeAtMost(u16, 21, 140);
                try s.line("resize {d} {d}", .{ s.rows, s.cols });
            },
        }
        try s.line("settle 30", .{});
        if (step % 10 == 9) try s.line("snap s{d}", .{step});
    }
    try s.line("stable 300 5000", .{});
    try s.line("snap end", .{});
    return s.out.toOwnedSlice(gpa);
}

/// A stack frame of a Zig panic trace, `.zig:12:34: 0x`, of the app's: the
/// harness's own error trace (a script it could not finish) is not a hit.
fn hasTrace(bytes: []const u8) bool {
    var lines = std.mem.splitScalar(u8, bytes, '\n');
    while (lines.next()) |line| {
        if (std.mem.indexOf(u8, line, "test/snapshot.zig") != null or std.mem.indexOf(u8, line, "test/e2e_harness.zig") != null) continue;
        if (frameIn(line)) return true;
    }
    return false;
}

fn frameIn(bytes: []const u8) bool {
    var from: usize = 0;
    while (std.mem.indexOfPos(u8, bytes, from, ".zig:")) |at| : (from = at + 1) {
        var i = at + ".zig:".len;
        var digits: usize = 0;
        while (i < bytes.len and std.ascii.isDigit(bytes[i])) : (i += 1) digits += 1;
        if (digits == 0 or i >= bytes.len or bytes[i] != ':') continue;
        i += 1;
        digits = 0;
        while (i < bytes.len and std.ascii.isDigit(bytes[i])) : (i += 1) digits += 1;
        if (digits > 0 and std.mem.startsWith(u8, bytes[i..], ": 0x")) return true;
    }
    return false;
}

test "a panic trace is found, and ordinary colons are not" {
    try std.testing.expect(hasTrace("  /src/draw.zig:814:31: 0x4212821 in joinNotice"));
    try std.testing.expect(!hasTrace("draw.zig:814 is where it was; 12:30: 0x"));
    try std.testing.expect(!hasTrace("/x/test/snapshot.zig:197:28: 0x1279761 in main (snapshot.zig)"));
}

test "a seed makes the same script every time" {
    const a = try generate(std.testing.allocator, 42, 50);
    defer std.testing.allocator.free(a);
    const b = try generate(std.testing.allocator, 42, 50);
    defer std.testing.allocator.free(b);
    try std.testing.expectEqualStrings(a, b);
}

fn readAll(gpa: std.mem.Allocator, io: std.Io, path: []const u8) []u8 {
    return std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 << 20)) catch &.{};
}

pub fn main(init: std.process.Init) !void {
    const gpa = std.heap.page_allocator;
    const io = init.io;
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    if (args.len < 4) {
        std.debug.print("usage: pardes-monkey <pardes-snap> <pardes> <seeds> [steps] [--from=N] [--out=DIR]\n", .{});
        std.process.exit(2);
    }
    const snap = args[1];
    const app = args[2];
    var seeds: u64 = 20;
    var steps: usize = 70;
    var from: u64 = 1;
    var out: []const u8 = "/tmp/pardes-monkey";
    // Keep every script and capture, not only a hit's: to see what it did.
    var keep = false;
    var positional: usize = 0;
    for (args[3..]) |a| {
        if (std.mem.startsWith(u8, a, "--from=")) {
            from = try std.fmt.parseInt(u64, a["--from=".len..], 10);
        } else if (std.mem.eql(u8, a, "--keep")) {
            keep = true;
        } else if (std.mem.startsWith(u8, a, "--out=")) {
            out = a["--out=".len..];
        } else {
            switch (positional) {
                0 => seeds = try std.fmt.parseInt(u64, a, 10),
                1 => steps = try std.fmt.parseInt(usize, a, 10),
                else => {},
            }
            positional += 1;
        }
    }
    std.Io.Dir.cwd().createDirPath(io, out) catch {};
    var hits: usize = 0;
    var seed = from;
    while (seed < from + seeds) : (seed += 1) {
        const script = try generate(gpa, seed, steps);
        defer gpa.free(script);
        var path_buf: [4096]u8 = undefined;
        const path = try std.fmt.bufPrint(&path_buf, "{s}/seed-{d}.snap", .{ out, seed });
        try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = script });
        const run = std.process.run(gpa, io, .{
            .argv = &.{ snap, app, "--update", "--no-retry", "--jobs=1", path },
            .stdout_limit = .limited(64 << 20),
            .stderr_limit = .limited(64 << 20),
        }) catch |err| {
            std.debug.print("seed {d}: pardes-snap did not run ({t})\n", .{ seed, err });
            continue;
        };
        defer gpa.free(run.stdout);
        defer gpa.free(run.stderr);
        var golden_buf: [4096]u8 = undefined;
        const golden_path = try std.fmt.bufPrint(&golden_buf, "{s}/seed-{d}.golden", .{ out, seed });
        const golden = readAll(gpa, io, golden_path);
        defer gpa.free(golden);
        // The app's own crash file (src/crash.zig) in the script's fake home:
        // found even when the crash cut the script short of a capture.
        var crash_buf: [4096]u8 = undefined;
        const crash_path = try std.fmt.bufPrint(&crash_buf, "/tmp/pardes-snap/seed-{d}/home/.config/pardes/crashes", .{seed});
        const crashes = readAll(gpa, io, crash_path);
        defer gpa.free(crashes);
        if (crashes.len != 0 or hasTrace(run.stdout) or hasTrace(run.stderr) or hasTrace(golden)) {
            hits += 1;
            var log_buf: [4096]u8 = undefined;
            const log_path = try std.fmt.bufPrint(&log_buf, "{s}/seed-{d}.log", .{ out, seed });
            const both = try std.mem.concat(gpa, u8, &.{ crashes, run.stdout, run.stderr });
            defer gpa.free(both);
            try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = log_path, .data = both });
            std.debug.print("PANIC seed {d}: {s}\n", .{ seed, path });
        } else if (!keep) {
            std.Io.Dir.cwd().deleteFile(io, path) catch {};
            std.Io.Dir.cwd().deleteFile(io, golden_path) catch {};
            std.debug.print("ok seed {d}\n", .{seed});
        } else {
            std.debug.print("ok seed {d} (kept)\n", .{seed});
        }
    }
    std.debug.print("{d} of {d} seeds panicked; kept in {s}\n", .{ hits, seeds, out });
    if (hits != 0) std.process.exit(1);
}