Add a PTY harness for terminal rendering tests
- Status: Done
- Kind: Chore
- Authors: jp
- Date: 2026-08-27
Printer::memory records the bytes JP emits and models nothing about what a terminal does with them. Scrolling, deferred wrap at the right margin, and resize only exist on a real terminal, so any rendering that depends on cursor position across more than one row cannot be tested at all today.
This blocks the multi-row status region (RFD 091 phase 4). Its draw and erase sequence has to be settled by watching a human run it, and the resulting byte sequence can only be pinned as a Printer::memory snapshot — a test that asserts the bytes we chose, not that a terminal renders them correctly. The same gap covers the interactive prompts that motivated issue 392: keystroke handling and widget redraw have no automated coverage either.
A harness that drives a PTY and models the screen closes both. Instead of asserting on emitted bytes, a test asserts on the resulting screen: which rows hold what, where the cursor is, and what survived an erase.
Acceptance criteria:
- Evaluate
portable-ptyagainstexpectrland pick one; pair it with a screen model (vt100ortermwiz) that answers "what does row N contain" and "where is the cursor". - Provide a harness that runs a closure against a PTY of a declared size, so a test can drive
jp_printerin-process rather than spawningjp. - Support spawning
jpin the PTY too, for end-to-end prompt tests. - Support resizing mid-test, so shrinking below a drawn row count is exercisable.
- Provide keystroke injection and a wait-for-screen-state helper; no fixed sleeps.
- Port the status region's multi-row draw and erase to screen-level assertions, replacing whichever byte snapshots phase 4 lands with.
- Cover the four cases phase 4's spike settles: claiming while the cursor sits on the last row, a persistent write landing while the region is drawn, the terminal shrinking below the drawn row count, and Windows.
- Skip cleanly rather than fail where a platform has no PTY.
Until this lands, phase 4's spike runs by hand: cargo run -p jp_printer --example region_spike prints candidate sequences for a human to observe. Delete the example when the harness replaces it.
Comments
- From: jp
- Date: 2026-09-05T19:40:11Z
Landed as crates/jp_pty, with jp_printer's screen-level region cases ported onto it and examples/region_spike.rs deleted.
Library choice
portable-pty over expectrl. expectrl is built around spawning a child and matching its output stream; it has no way to hand a writable tty to code running in this process, which is what "drive jp_printer in-process" needs. portable-pty hands back both ends and covers Windows through ConPTY, which expectrl reaches only via a separate crate.
vt100 over termwiz. Already in the tree, already vetted, and it answers exactly the three questions the ticket asks for: what row N holds, where the cursor is, and whether a row wrapped. termwiz is a terminal toolkit; the screen model is a small part of it.
Shape
Terminal has two backends behind one screen API, so a test body is written once:
Terminal::pty(size)— a real pty. Output crosses the kernel's line discipline, the writer is a tty, a child can be spawned into it and resized under it.Terminal::modelled(size)— the screen model fed directly, imitatingONLCR.Terminal::open(size)— the pty where the parent can write into one, the model otherwise.
The second backend is not a convenience. Windows' ConPTY is reachable only by a child process, so a pty-only harness would have made every ported region case unix-only — a loss of Windows coverage against what phase 4 already had. a_pty_renders_what_the_model_renders pins the two against each other on unix, and fails if the model stops imitating a tty (checked by disabling the translation). open_takes_the_pty_where_there_is_one keeps the unix fallback from happening silently.
wait_for blocks on a condvar woken by arriving bytes and returns the screen that satisfied the predicate, so the assertions after it are made against that snapshot rather than a later one. Nothing sleeps for a fixed interval. A timeout renders the screen with the cursor marked, and Error's Debug defers to Display, so an unwrap in a test prints it.
Coverage against the four cases
| Case | Where |
|---|---|
| Claiming with the cursor on the last row | a_block_claimed_at_the_bottom_ends_on_the_last_row, claiming_at_the_bottom_scrolls_content_up_rather_than_over_it |
| A persistent write while the region is drawn | a_persistent_write_lands_above_the_block |
| The terminal shrinking below the drawn row count | an_erase_after_a_shrink_stops_at_the_top_of_the_viewport |
| Windows | jp_printer/tests/region_pty.rs, spawning region_probe into a ConPTY; the in-process set on the model backend |
Three byte snapshots of multi-row draw and erase are gone, replaced by screen assertions: a_window_paints_its_lines_above_the_status_row, a_shrinking_window_clears_the_rows_it_gives_back (now a_window_that_shrinks_clears_the_rows_it_gives_back), and an_erase_walks_no_further_than_the_viewport_has_rows.
The Windows row needs the spawn path rather than the model backend. Running the in-process set there is regression coverage — it catches a change to JP's own sequence — but it is the same platform-independent Rust that runs on Linux, so it cannot answer what a console does with that sequence. That was the question phase 4's spike left open, and only a child drawing a region into a real ConPTY closes it. region_probe is that child: it prints past the bottom of the screen, claims a region with a two-row window, and either holds the block or releases it and writes over where it was.
Three things worth knowing
A pty echoes what is typed into it, and that moves the screen. The probe first took its step as a command on stdin. An echoed line lands at the cursor, which sits inside the block, and scrolls the screen by a row the region never accounted for — stranding the block's top row and putting every later erase one row low. It reproduced identically on every run and looked exactly like a multi-row erase bug; the in-process case for the same sequence passed, which is what placed it in the measurement rather than the code. The step is an argument now and nothing is typed at the probe. tests/spawn.rs matches on content rather than rows for a related reason, and says so.
The shrink case was asserting less than its name claimed. It was assert!(term.cursor().0 < 4), which holds whether or not the erase is capped — a cursor-up clamps at row 0 either way. Strengthening it surfaced why: a resize is modelled as a truncation, and a terminal reflows, so which rows survive a shrink is not something the harness can answer. The case is renamed to what it does pin — the erase clears every reachable row and stops at the top of the viewport — and the comment says the rest is unmodelled. It now fails if the erase is removed.
Nothing spawns jp itself yet. Two probe binaries go through the spawn path, but the interactive-prompt tests from issue 392 are still to be written, and examples/mcp_window_fixture.toml is still a by-hand run for RFD 091 phase 5: the harness can spawn jp into a terminal, but nothing drives two MCP servers through it.
RFD 091 phase 4 still reads "JP has no PTY harness"; left alone rather than editing an accepted design record on a chore.
New third-party crates, all reachable only through dev-dependencies and exempted at safe-to-run: portable-pty, filedescriptor, nix, downcast-rs, shell-words, serial2, shared_library, winreg, bitflags 1.x, cfg_aliases. cargo vet prune will say if any are redundant against the imported audits.
- From: jp
- Date: 2026-09-08T12:55:46Z
The Windows row of the coverage table above is wrong on GitHub's runners, and the correction is not something JP can code around.
A ConPTY child's output never reaches the output pipe on a GitHub-hosted Windows runner: actions/runner#3168 has been open since February 2024, with the same tests passing on a Windows machine of one's own. Every case in region_pty.rs and spawn.rs waited out its timeout there on a blank screen, while the unix runners passed. The two probes report nothing at all rather than leaking to the job log, because portable-pty spawns with STARTF_USESTDHANDLES and invalid handles so a child cannot fall back to the parent's stdio. Spawning by hand without that flag is what the conpty crate in the upstream report does, and its output misses the pipe too, so there is no version of the spawn path that works there.
It is the environment rather than any one library. expectrl fails the same way on both GitHub Actions and AppVeyor (expectrl#52, open since 2022, no diagnosis), through a third implementation again. portable-pty's own CI is no help: wezterm runs cargo nextest run --all on windows-2025, but no test in that repository opens a pty, so the path has never run there.
The one lever nobody has pulled is the sideloaded console host. load_conpty prefers a conpty.dll next to the binary over kernel32's, and wezterm ships conpty.dll and OpenConsole.exe for the app itself, copied beside the executable by a build script. Whether a bundled host fares better on a hosted runner is a guess, and it costs a Microsoft DLL and executable vendored into the repository or fetched during the job. Worth trying only against a runner that can confirm it.
jp_pty::spawn_is_observable reports it, and each spawning case does nothing and says so when the answer is no. The Windows row therefore reads: covered on a Windows host, not on GitHub's runners, where the in-process set on the model backend is all that runs.
From review of the PR:
Terminal::resizetakes the screen lock before the kernel hears the new size. A child that repaints onSIGWINCHcan emit a new-width frame the moment it is signalled, and the reader thread would have rendered it into a model still set to the old width.- Enter is sent as
\r. A Windows console in cooked mode completes a line on CR and ignores a bare LF; a unix pty'sICRNLaccepts CR too. Child::wait_withinreplaceswaitandfinished. An unbounded wait on a child that never sees its input hangs the runner rather than failing the case.- Waits carry what the assertions after them need. A predicate satisfied by part of a frame freezes a snapshot the rest of the frame has not reached, and
Screencompares its cursor, soreleasing_a_block_puts_the_screen_backhad to wait for the cursor as well as the rows.contentwrites each line in one call for the same reason:writeln!emits each piece of a format string separately, putting the newline in a write of its own.