Lab 8: The Headless Emulator (Milestone 6)

Background

You now have a PTY layer and a terminal core. This lab joins them into mini-term — a command-line tool that runs a program through a PTY, feeds every byte to a Terminal, and prints a deterministic screen snapshot.

This is the most useful artifact in the curriculum. It is your test harness, your bug-report format, your regression suite, and — critically — proof that the terminal core works with no display at all, which is the property Sections 4 and 5 depend on entirely.

Why This Lab Matters

  • Golden tests, PTY integration tests, and differential tests are all mini-term invocations.
  • A terminal you can run headlessly is a terminal you can test in CI.
  • Milestone 13's criterion "the same core serves four consumers" starts here with consumer #1.

Prerequisites


Predict First

  1. mini-term run -- printf 'hello\n' twice. Byte-identical output?
  2. mini-term run -- vim twice. Byte-identical? Name two reasons not.
  3. --replay on a recording of vim. Identical to the live run? Why or why not?

Step 1: The CLI

cargo new --bin crates/terminal-cli --name terminal-cli
mini-term run    [--rows N] [--cols N] [--format text|debug|json] [--timeout MS]
                 [--record FILE] [--env K=V]... -- <command> [args...]

mini-term replay [--rows N] [--cols N] [--format ...] [--bytewise] FILE
mini-term feed   [--rows N] [--cols N] [--format ...]          # read bytes from stdin
mini-term diff   FILE_A FILE_B                                  # compare two snapshots
#![allow(unused)]
fn main() {
fn cmd_run(args: RunArgs) -> anyhow::Result<()> {
    let size = PtySize::new(args.rows, args.cols);
    let mut pty = Pty::spawn(&PtyConfig {
        program: CString::new(args.command[0].clone())?,
        args: args.command.iter().map(|s| CString::new(s.clone())).collect::<Result<_,_>>()?,
        // A FIXED environment. Inheriting the caller's is the #1 source of
        // nondeterminism: TERM, LANG, COLUMNS, PS1, and locale all change output.
        env: fixed_env(&args.env),
        size,
    })?;

    let mut term = Terminal::new(args.rows as usize, args.cols as usize);
    let mut recorder = args.record.as_ref().map(|p| Recorder::new(File::create(p)?, ...)).transpose()?;
    let deadline = Instant::now() + Duration::from_millis(args.timeout_ms);
    let mut buf = [0u8; 65536];

    loop {
        // Poll with a timeout so a hung child cannot hang CI.
        match pty.poll_read(&mut buf, deadline)? {
            ReadOutcome::Data(n) => {
                if let Some(r) = recorder.as_mut() { r.event(Direction::Output, &buf[..n])?; }
                term.advance(&buf[..n]);
                // Terminal replies (CSI 6n, CSI c) must go BACK to the program.
                // terminal-core does no I/O; the caller is responsible. Programs
                // that query and wait will hang without this.
                let replies = term.take_replies();
                if !replies.is_empty() { pty.write_all(&replies)?; }
            }
            ReadOutcome::Eof => break,
            ReadOutcome::Timeout => {
                eprintln!("mini-term: timeout after {}ms; snapshotting anyway", args.timeout_ms);
                break;
            }
        }
    }
    pty.wait()?;

    print!("{}", match args.format {
        Format::Text => term.snapshot_text(),
        Format::Debug => term.snapshot_debug(),
        Format::Json => term.snapshot_json(),
    });
    Ok(())
}

/// A FIXED environment. Everything that could vary between machines is pinned.
fn fixed_env(extra: &[String]) -> Vec<CString> {
    let mut env = vec![
        "TERM=xterm-256color",
        "LANG=C.UTF-8",
        "LC_ALL=C.UTF-8",
        "PATH=/usr/local/bin:/usr/bin:/bin",
        "PS1=$ ",            // a fixed prompt, so shell snapshots are stable
        "HOME=/tmp/mini-term-home",
    ];
    // LINES and COLUMNS are deliberately ABSENT: programs must use TIOCGWINSZ,
    // and a stale env value would override it.
    env.extend(extra.iter().map(String::as_str));
    env.into_iter().map(|s| CString::new(s).unwrap()).collect()
}
}

Warning: The two lines that make this tool trustworthy are fixed_env and the timeout. Without a fixed environment, a snapshot that passes on your machine fails in CI because $LANG differs. Without a timeout, one hung child blocks the whole suite forever.


Step 2: Deterministic Snapshots

#![allow(unused)]
fn main() {
/// Snapshot rules, all of them chosen so `diff` output is readable:
///   1. Trailing blanks on each row are trimmed.
///   2. Trailing blank rows are kept (row count is part of the state) but shown
///      as empty lines.
///   3. Wide-character spacers are skipped (the character appears once).
///   4. The cursor is NOT drawn in `text` format (it is state, not content);
///      `debug` reports it separately.
///   5. No trailing whitespace anywhere.
pub fn snapshot_text(&self) -> String { /* ... */ }
}

Snapshot formats and what each is for:

FormatContainsUse
textJust the charactersGolden tests, readable diffs, bug reports
debugCursor, modes, scroll region, title, per-cell styles that differ from default, damageDebugging a specific failure
jsonStructured rows of styled runsDifferential tests, the mux protocol, tooling
$ mini-term run --rows 4 --cols 20 --format debug -- printf '\033[31mred\033[0m\n'
=== TERMINAL 4x20 ===
cursor: row=1 col=0 pending_wrap=false visible=true
modes: AUTO_WRAP | CURSOR_VISIBLE
scroll_region: 0..3
title: ""
scrollback: 0 lines
--- screen ---
 0 | red
 1 |
 2 |
 3 |
--- styles (non-default cells) ---
 (0,0) fg=Indexed(1)
 (0,1) fg=Indexed(1)
 (0,2) fg=Indexed(1)
--- damage ---
rows: 0, 1

Step 3: The Required Integration Tests

These four commands come from the curriculum brief. Each tests something different.

#![allow(unused)]
fn main() {
// crates/terminal-cli/tests/pty_integration.rs

#[test]
fn plain_text_lands_on_the_screen() {
    let out = mini_term(&["--rows", "5", "--cols", "20"], &["printf", "hello\\n"]);
    assert_eq!(out.trim_end(), "hello");
}

#[test]
fn sgr_color_is_applied_to_the_right_cells() {
    // Exercises: parser CSI path, SGR handling, per-cell style storage.
    let out = mini_term_debug(&["--rows", "5", "--cols", "20"],
                              &["printf", "\\033[31mred\\033[0m\\n"]);
    assert!(out.contains("(0,0) fg=Indexed(1)"));
    assert!(out.contains("(0,2) fg=Indexed(1)"));
    assert!(!out.contains("(0,3) fg=Indexed(1)"), "the reset must take effect");
}

#[test]
fn long_output_wraps_at_the_terminal_width() {
    // Exercises: wrapping, pending wrap, and the wrapped-line flag.
    let out = mini_term(&["--rows", "5", "--cols", "80"],
                        &["python3", "-c", "print('x' * 100)"]);
    let lines: Vec<&str> = out.lines().collect();
    assert_eq!(lines[0].len(), 80);
    assert_eq!(lines[1].len(), 20);
}

#[test]
fn interactive_read_round_trips_through_the_line_discipline() {
    // Exercises the WHOLE stack: PTY, line discipline, echo, canonical mode,
    // and the terminal core. This is the test that proves the layers connect.
    let out = mini_term_with_input(
        &["--rows", "5", "--cols", "40"],
        &["bash", "-c", "read -p 'Name: ' name; echo \"Hello $name\""],
        b"World\n",
    );
    assert!(out.contains("Name: World"), "the prompt AND the echoed input");
    assert!(out.contains("Hello World"));
}
}

That last test is the important one. It only passes if: the PTY was allocated correctly, the child got a controlling terminal, the line discipline echoed the input, canonical mode released the line on Enter, and the terminal core rendered both the prompt and the echo. One assertion, six layers.


Step 4: Golden Tests

tests/golden/
├── cases/
│   ├── ls-color.cast          + ls-color.snapshot
│   ├── vim-startup.cast       + vim-startup.snapshot
│   ├── top-one-frame.cast     + top-one-frame.snapshot
│   ├── less-scroll.cast       + less-scroll.snapshot
│   ├── git-log-graph.cast     + git-log-graph.snapshot
│   ├── cjk-text.cast          + cjk-text.snapshot
│   ├── emoji-zwj.cast         + emoji-zwj.snapshot
│   ├── python-repl.cast       + python-repl.snapshot
│   ├── htop-mouse.cast        + htop-mouse.snapshot
│   └── urandom-4k.cast        + urandom-4k.snapshot
└── golden_test.rs
#![allow(unused)]
fn main() {
#[test]
fn golden_snapshots_match() {
    for case in golden_cases() {
        let rec = Recording::parse(&fs::read_to_string(&case.cast).unwrap()).unwrap();
        let mut term = Terminal::new(rec.height as usize, rec.width as usize);
        rec.replay_output_fast(&mut |bytes| term.advance(bytes));
        let got = term.snapshot_text();
        let expected = fs::read_to_string(&case.snapshot).unwrap();
        if got != expected {
            // UPDATE_GOLDEN=1 rewrites, so accepting an intentional change is one
            // command — but the diff still gets reviewed in the commit.
            if std::env::var("UPDATE_GOLDEN").is_ok() {
                fs::write(&case.snapshot, &got).unwrap();
            } else {
                panic!("golden mismatch in {}:\n{}", case.name, diff(&expected, &got));
            }
        }
    }
}

#[test]
fn bytewise_replay_produces_identical_screens() {
    // THE parser-correctness test, applied at the screen level. If feeding one
    // byte at a time changes the screen, the parser is not a real state machine.
    for case in golden_cases() {
        let rec = Recording::parse(&fs::read_to_string(&case.cast).unwrap()).unwrap();

        let mut fast = Terminal::new(rec.height as usize, rec.width as usize);
        rec.replay_output_fast(&mut |b| fast.advance(b));

        let mut slow = Terminal::new(rec.height as usize, rec.width as usize);
        rec.replay_output_bytewise(&mut |b| slow.advance(b));

        assert_eq!(fast.snapshot_debug(), slow.snapshot_debug(),
                   "chunking changed the result in {}", case.name);
    }
}
}

Step 5: Determinism, and What Breaks It

SourceFix
$TERM, $LANG, localefixed_env
Shell prompt ($PS1)Fixed in fixed_env; or use bash --norc --noprofile
Timestamps (top, date, ls -l)Use deterministic programs, or mask time fields before comparing
Terminal queries the program waits forAnswer them — take_replies() → pty.write_all()
Program startup timing / partial framesUse --timeout plus a settle delay, or record once and replay
Random dataSeed it, or snapshot only structure
Terminal size from the environmentNever inherit LINES/COLUMNS
Locale-dependent sorting in lsLC_ALL=C.UTF-8
# Prove determinism, ten times:
for i in $(seq 10); do
  mini-term run --rows 24 --cols 80 -- ls --color=always /usr/bin | md5sum
done | sort -u | wc -l
# Must print 1.

Expected Output

$ mini-term run --rows 5 --cols 20 -- printf 'hello\n'
hello

$ mini-term run --rows 5 --cols 20 --format debug -- printf '\033[31mred\033[0m\n'
=== TERMINAL 5x20 ===
cursor: row=1 col=0 pending_wrap=false visible=true
...
--- styles (non-default cells) ---
 (0,0) fg=Indexed(1)
 (0,1) fg=Indexed(1)
 (0,2) fg=Indexed(1)

$ mini-term run --rows 5 --cols 80 -- python3 -c 'print("x"*100)'
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxx

$ echo 'World' | mini-term run --rows 5 --cols 40 -- \
    bash -c 'read -p "Name: " n; echo "Hello $n"'
Name: World
Hello World

$ mini-term replay tests/golden/cases/vim-startup.cast
~
~
~
"[No Name]"                                        0,0-1         All

Debugging Steps

Output differs between runs

Run the determinism loop above and bisect: does it differ with printf? With bash? With ls? The first program that differs tells you which nondeterminism source you missed.

bash -c 'read -p ...' produces no prompt

The child is not on a PTY (isatty false), or your input never reached the master. Check with --record and look for the "i" events.

vim snapshots are empty

vim is on the alternate screen and your snapshot renders the primary. Snapshot the active buffer.

A snapshot has trailing whitespace and diffs are unreadable

Trim trailing blanks per row. Add a test that asserts no line ends with a space.

A program hangs forever

It sent a query (CSI 6n, CSI c, OSC 11;?) and is waiting for a reply you never sent. Wire take_replies() to pty.write_all().

Replay differs from the live run

Expected for query-using programs, unless you replay the recorded input too. See Lab 5.


Experiment

CLAIM. Programs behave differently when stdout is not a terminal, and mini-term makes the difference measurable.

METHOD.

# 1. The same command, three ways.
ls --color=auto | cat | cat -v | head -3          # piped: no color
mini-term run --rows 24 --cols 80 -- ls --color=auto | head -3   # PTY: color

# 2. vim, redirected vs. on a PTY.
vim -c ':q' < /dev/null > /tmp/out 2>&1; cat /tmp/out
#    "Vim: Warning: Output is not to a terminal"
mini-term run --rows 24 --cols 80 --timeout 2000 -- vim -c ':q'
#    a real screen

# 3. What does the program actually check?
strace -f -e trace=ioctl ls --color=auto 2>&1 >/dev/null | head -3   # Linux
#    You will see it calling TCGETS (i.e. isatty) on fd 1.

PREDICTION. Before #3: which syscall does ls use to decide about colors? Which fd does it check — 0, 1, or 2?


Challenge Extensions

  1. mini-term diff, producing a cell-level diff of two snapshots with +/- markers. You will use it constantly.
  2. --watch mode, re-running and re-snapshotting on file change. A TUI development loop.
  3. --assert mode: mini-term run --assert 'row 0 contains "hello"' -- ... so shell scripts can test terminals.
  4. Run the golden suite in CI on both Linux and macOS. Any platform difference in the core is a bug (it must be pure); differences in run are expected and belong in terminal-pty.
  5. A snapshot-based bisect helper: given a recording and a failing assertion, binary-search the byte stream for the first byte at which the snapshot goes wrong. This is a genuinely powerful debugging tool and it is about 40 lines.

Deliverables

  • mini-term with run, replay, feed, and diff.
  • All three snapshot formats.
  • The four required PTY integration tests passing.
  • ≥10 golden cases with checked-in snapshots.
  • The bytewise-equivalence test passing over the whole corpus.
  • Determinism proven: ten identical runs of a real command.
  • Terminal replies wired back to the PTY.
  • A timeout so no test can hang CI.

Validation / Self-check

  1. Name five sources of nondeterminism and the fix for each.
  2. Why does mini-term pin the environment rather than inheriting it?
  3. Why are LINES and COLUMNS deliberately absent from the fixed environment?
  4. Why must take_replies() be wired to the PTY? What hangs without it?
  5. What does the read -p integration test prove that the printf test does not?
  6. What does the bytewise-equivalence test catch, and at which layer is the bug when it fails?
  7. Why does a vim snapshot need the active buffer rather than the primary?
  8. Which syscall does ls use to decide about color, and on which fd?
  9. Why is a timeout mandatory in a test harness?
  10. You have proven terminal-core works with no display. Which two later milestones depend on that property?

Next: Lab 9 — UTF-8 and Wide Characters.