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-terminvocations. - 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
mini-term run -- printf 'hello\n'twice. Byte-identical output?mini-term run -- vimtwice. Byte-identical? Name two reasons not.--replayon a recording ofvim. 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_envand the timeout. Without a fixed environment, a snapshot that passes on your machine fails in CI because$LANGdiffers. 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:
| Format | Contains | Use |
|---|---|---|
text | Just the characters | Golden tests, readable diffs, bug reports |
debug | Cursor, modes, scroll region, title, per-cell styles that differ from default, damage | Debugging a specific failure |
json | Structured rows of styled runs | Differential 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
| Source | Fix |
|---|---|
$TERM, $LANG, locale | fixed_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 for | Answer them — take_replies() → pty.write_all() |
| Program startup timing / partial frames | Use --timeout plus a settle delay, or record once and replay |
| Random data | Seed it, or snapshot only structure |
| Terminal size from the environment | Never inherit LINES/COLUMNS |
Locale-dependent sorting in ls | LC_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
mini-term diff, producing a cell-level diff of two snapshots with+/-markers. You will use it constantly.--watchmode, re-running and re-snapshotting on file change. A TUI development loop.--assertmode:mini-term run --assert 'row 0 contains "hello"' -- ...so shell scripts can test terminals.- 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
runare expected and belong interminal-pty. - 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-termwithrun,replay,feed, anddiff. - 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
- Name five sources of nondeterminism and the fix for each.
- Why does
mini-termpin the environment rather than inheriting it? - Why are
LINESandCOLUMNSdeliberately absent from the fixed environment? - Why must
take_replies()be wired to the PTY? What hangs without it? - What does the
read -pintegration test prove that theprintftest does not? - What does the bytewise-equivalence test catch, and at which layer is the bug when it fails?
- Why does a
vimsnapshot need the active buffer rather than the primary? - Which syscall does
lsuse to decide about color, and on which fd? - Why is a timeout mandatory in a test harness?
- You have proven
terminal-coreworks with no display. Which two later milestones depend on that property?