﻿# 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-pty` against `expectrl` and pick one; pair it with a screen
  model (`vt100` or `termwiz`) 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_printer` in-process rather than spawning `jp`.
- Support spawning `jp` in 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, imitating
  `ONLCR`.
- `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::resize` takes the screen lock before the kernel hears the new size.
  A child that repaints on `SIGWINCH` can 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's `ICRNL` accepts CR too.
- `Child::wait_within` replaces `wait` and `finished`.
  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 `Screen` compares its cursor, so
  `releasing_a_block_puts_the_screen_back` had to wait for the cursor as well as
  the rows.
  `content` writes 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.

[RFD 091]: https://jp.computer/rfd/091
[actions/runner#3168]: https://github.com/actions/runner/issues/3168
[expectrl#52]: https://github.com/zhiburt/expectrl/issues/52
[issue 392]: https://github.com/dcdpr/jp/issues/392
