a73x

test/ptyclient.zig

Ref:   Size: 32.4 KiB   History

//! e2e fixture: runs the real mux client on the slave side of a pty it
//! owns, so `isatty()` answers yes and the tty-gated branches open. Driven
//! by a line-oriented script on stdin; everything read from the master
//! tees into --out so the convergence machinery consumes the same capture
//! files non-tty scenarios produce. Capture convention: the master is read
//! only while a verb is running, so a script MUST end with `waitexit` — end
//! it on an expect and --out holds only what had arrived by that match.
const std = @import("std");
const Pty = @import("pty").Pty;
// The script dialect this fixture and wsclient both speak: the escape
// table and the exit codes (test/script.zig).
const script = @import("script");
const decodeEscapes = script.decodeEscapes;

const BP_SET = "\x1b[?2004h";
const BP_RESET = "\x1b[?2004l";

/// Everything read off the master, which is also everything a real terminal
/// on the other end of this client would have received. Two things are read
/// out of it: needles, with expect(1) semantics — the search starts at a
/// cursor and a match advances the cursor past itself, because without that
/// a needle painted BEFORE the previous verb would satisfy this one (tp1's
/// post-scroll expect would pass on bytes from the initial snapshot) — and
/// the bracketed-paste mode, which is what makes `paste` behave the way a
/// terminal behaves rather than the way the test wishes it would.
///
/// The mode lives HERE rather than at file scope because tracking it needs
/// an index into this exact buffer as it grows. As a free function taking
/// bytes it had a foot-gun in its signature: hand it a chunk shorter than
/// the cursor and the slice is out of bounds, from a call site that looks
/// correct. Owning both makes that misuse unspellable, and gives every test
/// a fresh terminal instead of globals to reset.
const Expecter = struct {
    buf: std.ArrayList(u8) = .empty,
    cursor: usize = 0,
    /// Whether the client under test has put this "terminal" into bracketed
    /// paste — set by the escapes it wrote, never by what a scenario assumes.
    bracketed_paste: bool = false,
    /// The mouse-reporting modes, mirrored the same way: a real terminal
    /// sends a click only while the application holds reporting ON, and a
    /// fixture that sends regardless is blind to the whole bug class where
    /// mux tears the modes down and nothing re-arms them — the class that
    /// shipped (a focus move onto a dead host's tile deafened the wall's
    /// mouse; see `interact.wall_mouse_capture`).
    m1000: bool = false,
    m1002: bool = false,
    m1006: bool = false,
    /// How far into `buf` noteModes has already looked.
    modes_scanned: usize = 0,

    fn feed(self: *Expecter, alloc: std.mem.Allocator, bytes: []const u8) !void {
        try self.buf.appendSlice(alloc, bytes);
        self.noteModes();
    }

    /// What this "terminal" would report a button press under: a press
    /// mode (1000 or 1002) and the SGR encoding (1006) — the only wire
    /// format `click` speaks.
    fn mouseReporting(self: *const Expecter) bool {
        return (self.m1000 or self.m1002) and self.m1006;
    }

    /// Track the mode over the WHOLE capture rather than one chunk at a time,
    /// for two reasons a per-chunk scan gets wrong:
    ///
    ///   * both escapes can land in one read — nvim sets the mode and clears
    ///     it again either side of a shell escape, and 4096 bytes swallow
    ///     both. The mode the terminal is left in is whichever came LAST, not
    ///     whichever `indexOf` the author happened to write second.
    ///   * a read can split the 8-byte escape down the middle, and neither
    ///     half matches anything. Backing the scan up by 7 covers every such
    ///     split, and rescanning bytes is harmless: last-occurrence is
    ///     idempotent.
    ///
    /// A window holding neither escape changes nothing: the mode is sticky
    /// until the client says otherwise, which is what a real terminal does.
    fn noteModes(self: *Expecter) void {
        // Every escape tracked here is 8 bytes, so one 7-byte backup
        // window covers any split for all of them.
        const win = self.buf.items[self.modes_scanned -| (BP_SET.len - 1)..];
        trackMode(win, BP_SET, BP_RESET, &self.bracketed_paste);
        trackMode(win, "\x1b[?1000h", "\x1b[?1000l", &self.m1000);
        trackMode(win, "\x1b[?1002h", "\x1b[?1002l", &self.m1002);
        trackMode(win, "\x1b[?1006h", "\x1b[?1006l", &self.m1006);
        self.modes_scanned = self.buf.items.len;
    }

    fn trackMode(win: []const u8, set_esc: []const u8, reset_esc: []const u8, state: *bool) void {
        const set = std.mem.lastIndexOf(u8, win, set_esc);
        const reset = std.mem.lastIndexOf(u8, win, reset_esc);
        if (set) |s| {
            state.* = if (reset) |r| s > r else true;
        } else if (reset != null) {
            state.* = false;
        }
    }

    /// A paste as this "terminal" would deliver it: markers only when the
    /// client has asked for them. Caller owns the result.
    ///
    /// Extracted from the verb loop so the NEGATIVE case has somewhere to be
    /// pinned. Bracketing unconditionally would sail through the e2e paste
    /// scenario — nvim receives valid input either way — and the suite would
    /// go on reporting green while the fixture had stopped modelling a
    /// terminal at all. That is this branch's whole worry, so it gets a test
    /// rather than a comment.
    fn framePaste(self: *const Expecter, alloc: std.mem.Allocator, text: []const u8) ![]u8 {
        if (!self.bracketed_paste) return alloc.dupe(u8, text);
        return std.mem.concat(alloc, u8, &.{ "\x1b[200~", text, "\x1b[201~" });
    }

    fn match(self: *Expecter, needle: []const u8) bool {
        if (std.mem.indexOfPos(u8, self.buf.items, self.cursor, needle)) |i| {
            self.cursor = i + needle.len;
            return true;
        }
        return false;
    }

    fn deinit(self: *Expecter, alloc: std.mem.Allocator) void {
        self.buf.deinit(alloc);
    }
};

/// ONE write, asserted: the client's scroll-key parser exact-matches a whole
/// read, so a short write here would silently turn one keystroke into two.
/// A bracketed paste goes the same way — its markers and its text are one
/// paste to the application, not three reads for the parser to race over.
fn writeWhole(master: std.posix.fd_t, bytes: []const u8, verb_no: usize) void {
    const n = std.posix.write(master, bytes) catch |e|
        fatal(EXIT_CHILD_DIED, "verb {d}: write to the client's pty failed: {s}", .{ verb_no, @errorName(e) });
    // Named for the payload most likely to hit it: a paste is the one that
    // grows, and the ceiling is the pty's buffer, not anything this fixture
    // chose.
    if (n != bytes.len)
        fatal(EXIT_USAGE, "verb {d}: short write ({d} of {d}) — a payload must fit one write into the pty's buffer", .{ verb_no, n, bytes.len });
}

const Verb = union(enum) {
    send: []u8,
    /// Like `send`, but wrapped in bracketed-paste markers IF the client has
    /// asked this "terminal" for them. That condition is the whole point: the
    /// fixture behaves the way a real terminal behaves rather than asserting
    /// what the test wishes were true, so a scenario using `paste` fails when
    /// the mode mirror regresses.
    paste: []u8,
    expect: struct { needle: []u8, deadline_ms: u64 },
    resize: struct { cols: u16, rows: u16 },
    settle: struct { quiet_ms: u64, deadline_ms: u64 },
    /// A button press the way a terminal delivers one: SGR press+release at
    /// COL;ROW (1-based), sent only once the client has mouse reporting ON.
    /// The deadline is for the ARMING — a claim is a pump-side write and
    /// may trail the paint — and a deadline that passes with reporting off
    /// is this fixture saying what a real terminal says by sending nothing:
    /// the wall has gone deaf to its user's mouse.
    click: struct { col: u16, row: u16, deadline_ms: u64 },
    waitexit: u64,

    /// Exhaustive on purpose: a future arm that owns memory will not
    /// compile until it is freed here, so the loop's one `defer` stays
    /// correct without anyone remembering to revisit it.
    fn deinit(self: Verb, alloc: std.mem.Allocator) void {
        switch (self) {
            .send, .paste => |s| alloc.free(s),
            .expect => |e| alloc.free(e.needle),
            .resize, .settle, .click, .waitexit => {},
        }
    }
};

/// One script line -> one verb; blank lines and #-comments are null.
/// Payloads may contain spaces: `send` takes the whole rest of the line;
/// `expect` takes everything up to the LAST space, then the deadline.
/// Payload ends are trimmed along with the line, so a needle that must
/// end in a space needs \x20.
fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb {
    const line = std.mem.trim(u8, raw, " \t\r");
    if (line.len == 0 or line[0] == '#') return null;
    const sp = std.mem.indexOfScalar(u8, line, ' ') orelse return error.BadVerb;
    const verb = line[0..sp];
    const rest = line[sp + 1 ..];
    if (std.mem.eql(u8, verb, "send")) {
        return .{ .send = try decodeEscapes(alloc, rest) };
    } else if (std.mem.eql(u8, verb, "paste")) {
        return .{ .paste = try decodeEscapes(alloc, rest) };
    } else if (std.mem.eql(u8, verb, "expect")) {
        const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return error.BadVerb;
        // An empty needle matches instantly at any cursor — a check that
        // cannot fail is worse than no check, so refuse it here.
        if (last == 0) return error.BadVerb;
        // Deadline first: a bad one must not strand an allocated needle.
        const ms = std.fmt.parseInt(u64, rest[last + 1 ..], 10) catch return error.BadVerb;
        const needle = try decodeEscapes(alloc, rest[0..last]);
        return .{ .expect = .{ .needle = needle, .deadline_ms = ms } };
    } else if (std.mem.eql(u8, verb, "resize")) {
        var it = std.mem.tokenizeScalar(u8, rest, ' ');
        const cols = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
        const rows = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
        if (it.next() != null) return error.BadVerb;
        return .{ .resize = .{ .cols = cols, .rows = rows } };
    } else if (std.mem.eql(u8, verb, "settle")) {
        var it = std.mem.tokenizeScalar(u8, rest, ' ');
        const quiet = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
        const deadline = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
        if (it.next() != null) return error.BadVerb;
        return .{ .settle = .{ .quiet_ms = quiet, .deadline_ms = deadline } };
    } else if (std.mem.eql(u8, verb, "click")) {
        var it = std.mem.tokenizeScalar(u8, rest, ' ');
        const col = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
        const row = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
        const ms = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
        if (it.next() != null) return error.BadVerb;
        return .{ .click = .{ .col = col, .row = row, .deadline_ms = ms } };
    } else if (std.mem.eql(u8, verb, "waitexit")) {
        const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb;
        return .{ .waitexit = ms };
    }
    return error.BadVerb;
}

// Exit codes are script.zig's, shared with wsclient so a scenario reads
// the same number the same way whichever fixture produced it. Here "the
// far side died" means the client on the pty slave.
const EXIT_USAGE = script.EXIT_USAGE;
const EXIT_TIMEOUT = script.EXIT_TIMEOUT;
const EXIT_CHILD_DIED = script.EXIT_DIED;

fn fatal(code: u8, comptime fmt: []const u8, args: anytype) noreturn {
    std.debug.print("ptyclient: " ++ fmt ++ "\n", args);
    std.process.exit(code);
}

/// Print bytes with escapes visible: what DID arrive, when a needle did not.
fn dumpTail(bytes: []const u8) void {
    const tail = if (bytes.len > 200) bytes[bytes.len - 200 ..] else bytes;
    std.debug.print("ptyclient: last {d} bytes received: \"", .{tail.len});
    for (tail) |b| switch (b) {
        0x20...0x7e => std.debug.print("{c}", .{b}),
        '\n' => std.debug.print("\\n", .{}),
        '\r' => std.debug.print("\\r", .{}),
        0x1b => std.debug.print("\\x1b", .{}),
        else => std.debug.print("\\x{x:0>2}", .{b}),
    };
    std.debug.print("\"\n", .{});
}

/// Drain whatever the master has right now into the capture + expecter.
/// Returns false on EOF/EIO — the child side is gone.
fn drain(alloc: std.mem.Allocator, pty: *Pty, out: std.fs.File, exp: *Expecter) !bool {
    var buf: [4096]u8 = undefined;
    while (true) {
        var fds = [_]std.posix.pollfd{
            .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
        };
        const ready = try std.posix.poll(&fds, 0);
        if (ready == 0) return true;
        const n = std.posix.read(pty.master, &buf) catch return false;
        if (n == 0) return false;
        try out.writeAll(buf[0..n]);
        try exp.feed(alloc, buf[0..n]);
    }
}

pub fn main() !void {
    var dbg = std.heap.DebugAllocator(.{}){};
    defer _ = dbg.deinit();
    const alloc = dbg.allocator();

    // --- args: --cols C --rows R --out FILE --err FILE -- argv... ---
    var cols: u16 = 80;
    var rows: u16 = 24;
    var out_path: ?[]const u8 = null;
    var err_path: ?[]const u8 = null;
    var child_argv: std.ArrayList(?[*:0]const u8) = .empty;
    defer child_argv.deinit(alloc);

    const argv = try std.process.argsAlloc(alloc);
    defer std.process.argsFree(alloc, argv);
    var i: usize = 1;
    while (i < argv.len) : (i += 1) {
        const a = argv[i];
        if (std.mem.eql(u8, a, "--cols")) {
            i += 1;
            if (i >= argv.len) fatal(EXIT_USAGE, "--cols needs a value", .{});
            cols = std.fmt.parseInt(u16, argv[i], 10) catch
                fatal(EXIT_USAGE, "--cols: not a number: {s}", .{argv[i]});
        } else if (std.mem.eql(u8, a, "--rows")) {
            i += 1;
            if (i >= argv.len) fatal(EXIT_USAGE, "--rows needs a value", .{});
            rows = std.fmt.parseInt(u16, argv[i], 10) catch
                fatal(EXIT_USAGE, "--rows: not a number: {s}", .{argv[i]});
        } else if (std.mem.eql(u8, a, "--out")) {
            i += 1;
            if (i >= argv.len) fatal(EXIT_USAGE, "--out needs a path", .{});
            out_path = argv[i];
        } else if (std.mem.eql(u8, a, "--err")) {
            i += 1;
            if (i >= argv.len) fatal(EXIT_USAGE, "--err needs a path", .{});
            err_path = argv[i];
        } else if (std.mem.eql(u8, a, "--")) {
            for (argv[i + 1 ..]) |c| try child_argv.append(alloc, c.ptr);
            break;
        } else {
            fatal(EXIT_USAGE, "unknown flag {s} (usage: ptyclient --cols C --rows R --out F --err F -- CMD...)", .{a});
        }
    }
    if (child_argv.items.len == 0)
        fatal(EXIT_USAGE, "no client command after -- (nothing to run on the pty)", .{});
    // Sentinel-terminated by the type system, not by a trailing append the
    // reader has to trust — and it consumes the list, so no raw pointer
    // into a still-mutable buffer survives to the spawn.
    const argv_z = try child_argv.toOwnedSliceSentinel(alloc, null);
    defer alloc.free(argv_z);
    const op = out_path orelse fatal(EXIT_USAGE, "--out is required (the capture the suite asserts on)", .{});
    const ep = err_path orelse fatal(EXIT_USAGE, "--err is required (predict stats land there)", .{});

    const out = std.fs.cwd().createFile(op, .{ .truncate = true }) catch |e|
        fatal(EXIT_USAGE, "cannot create --out {s}: {s}", .{ op, @errorName(e) });
    defer out.close();
    const errf = std.fs.cwd().createFile(ep, .{ .truncate = true }) catch |e|
        fatal(EXIT_USAGE, "cannot create --err {s}: {s}", .{ ep, @errorName(e) });
    defer errf.close();

    // Whole script up front: the harness feeds it as a heredoc and the
    // fixture's own progress lines ("done N") are how the harness knows
    // where the script is — the tp1 tear keys off exactly that.
    var stdin_buf: std.ArrayList(u8) = .empty;
    defer stdin_buf.deinit(alloc);
    var rbuf: [4096]u8 = undefined;
    while (true) {
        const n = try std.posix.read(std.posix.STDIN_FILENO, &rbuf);
        if (n == 0) break;
        try stdin_buf.appendSlice(alloc, rbuf[0..n]);
    }

    var pty = Pty.spawnArgv(.{
        .cols = cols,
        .rows = rows,
        .argv = argv_z,
        .stderr_fd = errf.handle,
    }) catch |e| fatal(EXIT_USAGE, "pty spawn failed: {s}", .{@errorName(e)});
    defer pty.deinit(); // kills by tracked pid if the child is still alive

    var exp: Expecter = .{};
    defer exp.deinit(alloc);

    var lines = std.mem.splitScalar(u8, stdin_buf.items, '\n');
    var verb_no: usize = 0;
    while (lines.next()) |raw| {
        // The error name matters: a doubled space (empty needle) is
        // invisible in a heredoc, and only BadVerb-vs-BadEscape tells the
        // operator whether to look at structure or at an escape.
        const verb = (parseLine(alloc, raw) catch |e|
            fatal(EXIT_USAGE, "bad script line ({s}): {s}", .{ @errorName(e), raw })) orelse continue;
        defer verb.deinit(alloc);
        verb_no += 1;
        switch (verb) {
            .send => |bytes| writeWhole(pty.master, bytes, verb_no),
            .paste => |text| {
                const framed = try exp.framePaste(alloc, text);
                defer alloc.free(framed);
                writeWhole(pty.master, framed, verb_no);
            },
            .expect => |x| {
                const start = std.time.milliTimestamp();
                while (!exp.match(x.needle)) {
                    if (std.time.milliTimestamp() - start > x.deadline_ms) {
                        std.debug.print("ptyclient: verb {d}: expect \"{s}\" did not arrive within {d}ms\n", .{ verb_no, x.needle, x.deadline_ms });
                        dumpTail(exp.buf.items);
                        std.process.exit(EXIT_TIMEOUT);
                    }
                    var fds = [_]std.posix.pollfd{
                        .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
                    };
                    _ = try std.posix.poll(&fds, 50);
                    if (!try drain(alloc, &pty, out, &exp)) {
                        if (exp.match(x.needle)) break; // arrived with the last gasp
                        std.debug.print("ptyclient: verb {d}: client closed the pty before \"{s}\" matched\n", .{ verb_no, x.needle });
                        dumpTail(exp.buf.items);
                        std.process.exit(EXIT_CHILD_DIED);
                    }
                }
            },
            .resize => |r| {
                pty.resize(r.cols, r.rows) catch |e|
                    fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) });
            },
            .click => |c| {
                // Wait for the ARMING, not for bytes: the enables are a
                // pump's write and may trail whatever paint the script just
                // matched on.
                const start = std.time.milliTimestamp();
                while (!exp.mouseReporting()) {
                    if (std.time.milliTimestamp() - start > c.deadline_ms) {
                        std.debug.print("ptyclient: verb {d}: click {d};{d} — mouse reporting is off, " ++
                            "this terminal has nothing to send (the wall is deaf to its user's mouse)\n", .{ verb_no, c.col, c.row });
                        dumpTail(exp.buf.items);
                        std.process.exit(EXIT_TIMEOUT);
                    }
                    var fds = [_]std.posix.pollfd{
                        .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
                    };
                    _ = try std.posix.poll(&fds, 50);
                    if (!try drain(alloc, &pty, out, &exp)) {
                        std.debug.print("ptyclient: verb {d}: client closed the pty before a click could arm\n", .{verb_no});
                        std.process.exit(EXIT_CHILD_DIED);
                    }
                }
                var report: [64]u8 = undefined;
                const bytes = std.fmt.bufPrint(&report, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ c.col, c.row, c.col, c.row }) catch unreachable;
                writeWhole(pty.master, bytes, verb_no);
            },
            .settle => |s| {
                // Quiesce by CONDITION rather than by needle: succeed once
                // the master has gone quiet_ms without a byte. A needle can
                // only ever say "this row arrived", and once a scrolling
                // repaint splits across deltas the rows have no reliable
                // order — so every needle-shaped wait is a race with some
                // row it did not name, in both directions (a needle that
                // matches too early detaches mid-repaint; one whose bytes
                // were consumed by an earlier match waits forever). Silence
                // names no row at all, which is why it closes all of those
                // at once.
                //
                // What this does NOT say is that anything arrived. The quiet
                // window is measured from this verb's own start and never
                // requires a byte, so a screen that has not begun changing
                // yet satisfies it having observed nothing — which is a pass
                // built on an empty observation. Pair it with a needle that
                // proves the paint you care about ARRIVED, and use it only
                // once the session is otherwise idle: the bytes still in
                // flight are then deltas the daemon has already generated,
                // so silence means they landed.
                const start = std.time.milliTimestamp();
                var last_seen = start;
                while (true) {
                    const before = exp.buf.items.len;
                    if (!try drain(alloc, &pty, out, &exp)) {
                        // Nothing has asked the client to leave yet, so a
                        // closed pty here is a death, not a quiet screen.
                        std.debug.print("ptyclient: verb {d}: client closed the pty while settling\n", .{verb_no});
                        dumpTail(exp.buf.items);
                        std.process.exit(EXIT_CHILD_DIED);
                    }
                    const now = std.time.milliTimestamp();
                    if (exp.buf.items.len != before) last_seen = now;
                    if (now - last_seen >= s.quiet_ms) break;
                    if (now - start > s.deadline_ms) {
                        std.debug.print("ptyclient: verb {d}: settle: never saw {d}ms of silence within {d}ms\n", .{ verb_no, s.quiet_ms, s.deadline_ms });
                        dumpTail(exp.buf.items);
                        std.process.exit(EXIT_TIMEOUT);
                    }
                    var fds = [_]std.posix.pollfd{
                        .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
                    };
                    // Well under the smallest useful quiet window, so the
                    // silence is measured rather than rounded up to a poll.
                    _ = try std.posix.poll(&fds, 20);
                }
            },
            .waitexit => |deadline_ms| {
                const start = std.time.milliTimestamp();
                while (true) {
                    const alive = try drain(alloc, &pty, out, &exp);
                    if (pty.checkExited()) |status| {
                        // The child can write its last bytes and exit in the
                        // window between drain's poll and this waitpid; they
                        // are sitting in the pty buffer, and dropping them
                        // truncates a capture that assert_converged compares
                        // byte for byte — silently, with the fixture still
                        // reporting success. Read to EIO before believing the
                        // exit. Bounded, because anything OTHER than the client
                        // still holding the slave keeps the fd open forever,
                        // and a hung suite is worse than a short capture. Two
                        // ways out: a quiet poll ends the drain normally, and
                        // the verb's deadline ends it when the holder is never
                        // quiet — a process that ignores the HUP and keeps
                        // writing satisfies every poll and would otherwise spin
                        // here forever, filling --out as it went. That deadline
                        // is the OUTER one, measured from the same `start`, so
                        // the drain does not get a fresh budget: waitexit's
                        // bound covers the wait and this drain together.
                        while (try drain(alloc, &pty, out, &exp)) {
                            if (std.time.milliTimestamp() - start > deadline_ms) break;
                            var fds = [_]std.posix.pollfd{
                                .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
                            };
                            if (try std.posix.poll(&fds, 50) == 0) break;
                        }
                        if (status != 0)
                            fatal(@intCast(@min(status, 255)), "client exited {d}", .{status});
                        break;
                    }
                    if (std.time.milliTimestamp() - start > deadline_ms)
                        fatal(EXIT_TIMEOUT, "verb {d}: client still running after {d}ms", .{ verb_no, deadline_ms });
                    if (alive) {
                        var fds = [_]std.posix.pollfd{
                            .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
                        };
                        _ = try std.posix.poll(&fds, 50);
                    } else {
                        std.Thread.sleep(20 * std.time.ns_per_ms);
                    }
                }
            },
        }
        // Progress line per verb: the harness coordinates the tp1 tear by
        // watching for "done N" in the fixture's log. std.debug.print is
        // stderr and unbuffered, which is exactly what a barrier needs.
        std.debug.print("ptyclient: done {d}\n", .{verb_no});
    }
}

test "Expecter: a needle split across two feeds still matches" {
    const alloc = std.testing.allocator;
    var e: Expecter = .{};
    defer e.deinit(alloc);
    try e.feed(alloc, "scroll-mar");
    try std.testing.expect(!e.match("marker"));
    try e.feed(alloc, "ker arrived");
    try std.testing.expect(e.match("marker"));
}

test "Expecter: the cursor consumes matches — old bytes cannot satisfy a new expect" {
    const alloc = std.testing.allocator;
    var e: Expecter = .{};
    defer e.deinit(alloc);
    try e.feed(alloc, "row-60 painted live");
    try std.testing.expect(e.match("row-60"));
    // Advance is past-the-match, not to buffer end: two needles in one chunk must both land.
    try std.testing.expect(e.match("painted live"));
    // The same needle again: only NEW bytes may answer.
    try std.testing.expect(!e.match("row-60"));
    try e.feed(alloc, " ... row-60 painted by the scroll view");
    try std.testing.expect(e.match("row-60"));
}

test "parseLine: verbs, spaces in payloads, comments" {
    const alloc = std.testing.allocator;
    try std.testing.expect(try parseLine(alloc, "") == null);
    try std.testing.expect(try parseLine(alloc, "# comment") == null);

    const s = (try parseLine(alloc, "send echo tp2-claim\\n")).?;
    defer s.deinit(alloc);
    try std.testing.expectEqualSlices(u8, "echo tp2-claim\n", s.send);

    const x = (try parseLine(alloc, "expect two words 15000")).?;
    defer x.deinit(alloc);
    try std.testing.expectEqualSlices(u8, "two words", x.expect.needle);
    try std.testing.expectEqual(@as(u64, 15000), x.expect.deadline_ms);

    const r = (try parseLine(alloc, "resize 90 28")).?;
    try std.testing.expectEqual(@as(u16, 90), r.resize.cols);
    try std.testing.expectEqual(@as(u16, 28), r.resize.rows);

    const w = (try parseLine(alloc, "waitexit 10000")).?;
    try std.testing.expectEqual(@as(u64, 10000), w.waitexit);

    const st = (try parseLine(alloc, "settle 500 15000")).?;
    try std.testing.expectEqual(@as(u64, 500), st.settle.quiet_ms);
    try std.testing.expectEqual(@as(u64, 15000), st.settle.deadline_ms);
    // Both bounds are required and there are exactly two: a settle that
    // silently defaulted its deadline would hang a suite instead of
    // failing it, and a third token means the operator meant something
    // this verb does not do.
    try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle 500"));
    try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle 500 15000 extra"));
    try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle soon 15000"));
    try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle 500 later"));

    try std.testing.expectError(error.BadVerb, parseLine(alloc, "frobnicate x"));
    try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect nodeadline"));
    // Doubled space: the needle would be empty, and an empty needle is a
    // check that cannot fail.
    try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect  1000"));
    // A bad deadline must not strand the needle: parse before allocating.
    try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect two words later"));
}

test "parseLine: paste is a verb and its payload keeps its spaces" {
    const alloc = std.testing.allocator;
    const v = (try parseLine(alloc, "paste a = 1")).?;
    defer v.deinit(alloc);
    try std.testing.expectEqualStrings("a = 1", v.paste);
}

test "Expecter: the LAST bracketed-paste escape wins, and the mode is sticky" {
    const alloc = std.testing.allocator;
    var e: Expecter = .{};
    defer e.deinit(alloc);

    try std.testing.expect(!e.bracketed_paste);
    try e.feed(alloc, "nvim starting \x1b[?2004h");
    try std.testing.expect(e.bracketed_paste);

    // A read carrying neither escape leaves the mode alone — the client says
    // when it changes, and silence is not a change.
    try e.feed(alloc, "rows and rows of ordinary paint");
    try std.testing.expect(e.bracketed_paste);

    // Both escapes in ONE read, set last: a per-chunk scan whose reset check
    // happened to be written second would report the opposite.
    try e.feed(alloc, " shell out \x1b[?2004l and back \x1b[?2004h");
    try std.testing.expect(e.bracketed_paste);

    try e.feed(alloc, " out \x1b[?2004h again then quit \x1b[?2004l");
    try std.testing.expect(!e.bracketed_paste);
}

test "Expecter: a set escape split across two reads still registers" {
    const alloc = std.testing.allocator;
    var e: Expecter = .{};
    defer e.deinit(alloc);
    // The 8-byte escape cut down the middle: neither half matches anything,
    // so only the backed-up rescan of the second feed can see it.
    try e.feed(alloc, "paint\x1b[?200");
    try std.testing.expect(!e.bracketed_paste);
    try e.feed(alloc, "4hmore paint");
    try std.testing.expect(e.bracketed_paste);
}

test "Expecter: framePaste brackets only what the client asked to be bracketed" {
    const alloc = std.testing.allocator;
    var e: Expecter = .{};
    defer e.deinit(alloc);

    // The negative case, and the one that matters most: a fixture that
    // bracketed unconditionally would still pass the e2e paste scenario,
    // because nvim takes valid input either way. Only this can fail.
    const bare = try e.framePaste(alloc, "a = 1");
    defer alloc.free(bare);
    try std.testing.expectEqualStrings("a = 1", bare);

    try e.feed(alloc, BP_SET);
    const framed = try e.framePaste(alloc, "a = 1");
    defer alloc.free(framed);
    try std.testing.expectEqualStrings("\x1b[200~a = 1\x1b[201~", framed);

    try e.feed(alloc, BP_RESET);
    const bare_again = try e.framePaste(alloc, "a = 1");
    defer alloc.free(bare_again);
    try std.testing.expectEqualStrings("a = 1", bare_again);
}

// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
    std.testing.refAllDeclsRecursive(@This());
}