ttysnap — “Playwright for the terminal”

A small framework for driving a real terminal program under test: send input, read the rendered screen (not just a byte stream), assert against goldens, record the session, and inject faults — all composed Suckless-style from a tuple of modules in config.zig and baked into a single binary.

Browsers have Playwright/Cypress. TUIs and CLIs have expect, pexpect, and a scattering of language-specific helpers — but nothing that combines a PTY + a real VT emulator for screen assertions + recording + fault injection behind one clean, composable API. This is that, reusing atty’s own VT grid.

zig build ttysnap          # build the configured binary → zig-out/bin/ttysnap
zig build run-ttysnap      # run the example (drives bash, asserts on screen)
zig build test-ttysnap     # run the ttysnap unit tests

Why it exists

Terminal programs are notoriously hard to test: output is an ANSI byte stream, timing is load-dependent, and “what’s on screen” only exists after an emulator interprets cursor moves, scroll regions, and SGR. The ttysnap answers all three:

  • Real PTY — the program runs under a pseudo-terminal exactly as it would in a user’s emulator (controlling tty, SIGWINCH, job control, raw input).
  • Real screen — output is fed to a VT grid (vt.Grid), so you assert on the rendered screen, surviving overwrites/cursor moves/scrolls.
  • Determinism — auto-waiting primitives (waitFor, waitStable) replace sleeps, and fault injection is a module: fragment_injector splits reads so load-dependent fragmentation races reproduce on every run (this is how atty’s #525 ghost flake was pinned down and fixed).

Architecture — the atty way

The ttysnap mirrors atty’s composition idiom (Dispatcher(modules) for the proxy, PanelHost(panels) for attop):

config.zig  ──>  Harness(modules)  ──drives──>  child under a PTY
  (your tuple)     (comptime walk)               │
                                                  └── vt.Grid (rendered screen)
modules ── observe + inject around the lifecycle (resolved by @hasDecl)
  • Harness(modules) (ttysnap.zig) — the composed driver: spawns the child, renders output into a grid, drives input, and fans the lifecycle into each module. Harness(.{}) (empty tuple) is the bare engine. Every hook is @hasDecl-gated at comptime, so unused hooks cost nothing and deleting a module from the tuple removes it from the binary.
  • module.zig — the contract (the hook set; see below).
  • pty.zig — open a master/slave pair, fork, controlled-env execvpe.
  • config.def.zigconfig.zig — the dwm-style template/override split (build.zig seeds config.zig, gitignored). Edit the modules tuple, recompile — menuconfig, not runtime config.

The module lifecycle

attach            once, right after spawn
beforeRead        before each read — may shrink it (fault injection; ttysnap
                  takes the smallest cap any module asks for)
onOutput          after a chunk is read + fed to the grid (raw bytes)
onFrame           after that chunk — the new screen state
onInput           when the driver sends bytes to the child
onSnapshot        at a named checkpoint (assert / capture); may error to fail
onExit / detach   at teardown

Hook shape (all optional except attach, each @hasDecl-gated):

pub const Runtime = struct {  }                     // per-module state
pub fn attach(allocator, info: SessionInfo) !Runtime // REQUIRED
pub fn detach(rt: *Runtime) void
pub fn beforeRead(rt: *Runtime, want: usize) usize   // cap ≤ want
pub fn onOutput(rt: *Runtime, bytes: []const u8) void
pub fn onFrame(rt: *Runtime, grid: *const Grid) void
pub fn onInput(rt: *Runtime, bytes: []const u8) void
pub fn onSnapshot(rt: *Runtime, name: []const u8, grid: *const Grid) !void
pub fn onExit(rt: *Runtime, status: u32) void

Writing a module

A parameter-free module is a plain pub const; a parameterised one is a comptime factory returning a type (so you call it in the tuple with its config):

// modules/byte_counter.zig
const std = @import("std");
const mod = @import("../module.zig");

pub const byte_counter = struct {
    pub const Runtime = struct { total: usize = 0 };
    pub fn attach(_: std.mem.Allocator, _: mod.SessionInfo) !Runtime {
        return .{};
    }
    pub fn onOutput(rt: *Runtime, bytes: []const u8) void {
        rt.total += bytes.len;
    }
};

Drop it into the tuple in config.zig and recompile:

pub const modules = .{
    cast_recorder(.{ .path = "run.cast" }),
    @import("modules/byte_counter.zig").byte_counter,
};

Built-in modules

Module Hook Does
snapshotter(.{ .dir, .update }) onSnapshot render the grid → compare to <dir>/<name>.txt; on mismatch write <name>.actual.txt + fail. update (re)writes goldens.
cast_recorder(.{ .path }) onOutput/onInput record an asciinema v2 cast (asciinema play, or agg → GIF).
fragment_injector(.{ .bytes }) beforeRead cap each read so escapes split across reads — deterministic fragmentation, no load needed.
latency_injector(.{ .read_ms }) beforeRead sleep before each read — spread output in time to surface timing races.
resize_injector(.{ .every, .sizes }) onOutput SIGWINCH storm: resize the child on a cadence (stresses resize handling; leaves the observer grid put — for a grid-tracking resize call Harness.resize from the scenario).
gif_recorder(.{ .path, .frame_ms }) onFrame capture distinct frames → a play-once animated SVG (browser/Inkscape; for a GitHub GIF convert the cast with agg).

Programmatic API

const H = ttysnap.Harness(config.modules);
var h = try H.spawn(alloc, .{ .argv = &.{ "vim", "file" }, .cols = 80, .rows = 24, .env = env });
defer h.deinit();

try h.send("ihello\x1b");          // send input
_ = try h.waitFor("hello", 2000);  // auto-wait for it on screen
_ = try h.waitStable(150, 2000);   // settle (no output for 150ms)
if (h.gridContains("ERROR")) {  } // query the rendered screen
try h.snapshot("after_insert");    // fan onSnapshot (golden compare/capture)
try h.resize(100, 30);             // SIGWINCH the child + resize the grid
const status = try h.waitExit(2000);

env is the complete environment (nothing is inherited) so a run is reproducible across machines.

Status + roadmap

This is the foundation slice: the engine + the module framework + three modules

  • a working example driving bash, all reusing atty’s vt.Grid. Deliberately deferred:

  • VT emulator breadthvt.Grid is “just enough for atty’s tests”; a general tool wants fuller scroll-region/alt-screen/wide-char/SGR/mouse coverage. This is the main lift to stand alone.
  • More modules — a GIF recorder (onFrame), latency + resize injectors.
  • Folding atty’s own e2e harness (src/test/e2e) onto this, removing the near-duplicate PTY spawn.
  • ConPTY for Windows (currently Linux PTY).