From 5c361e9dc36f3973282b5a36ea1f14d0d6cb6a3f Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:59:20 +0700 Subject: [PATCH 1/2] feat(emit): explain the step language, and add a --panic step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `emit` is the fixture almost every integration test drives, and the only description of its 27 steps was the `//!` header of its main.rs: running it with a typo printed "see the crate doc in fixtures/emit/src/main.rs". The step language moves to `fixtures/emit/src/steps.txt`, which the module header includes between its `text` fences and `--help` prints after a short banner. One copy, so the two cannot say different things. An unknown step still exits 2 naming the step. `--panic MESSAGE` is the 28th step, and it exists to test a claim: the README's "What TestBackend cannot see" table and SKILL.md §1 both promise a panicking child's message is assertable with `s.contains("panicked")`, and nothing in fixtures/ had ever panicked. Measured, three ways, in process.rs: the message reaches the grid from a plain child, from one that dies inside the alternate screen (where it joins the frame it died on), and from one that tore the alternate screen down first (where it lands on the restored primary screen). The status is exit code 101, not a signal. The README was right; it now has a test. Closes #304 Closes #311 Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- crates/termlens/tests/common/mod.rs | 4 +- crates/termlens/tests/process.rs | 89 +++++++++++++++++++++++++++++ fixtures/emit/src/main.rs | 78 +++++++++++-------------- fixtures/emit/src/steps.txt | 45 +++++++++++++++ 4 files changed, 169 insertions(+), 47 deletions(-) create mode 100644 fixtures/emit/src/steps.txt diff --git a/crates/termlens/tests/common/mod.rs b/crates/termlens/tests/common/mod.rs index 5f95170..31a9acd 100644 --- a/crates/termlens/tests/common/mod.rs +++ b/crates/termlens/tests/common/mod.rs @@ -9,7 +9,9 @@ use termlens::{Terminal, TerminalBuilder}; /// Spawn the `emit` fixture with `steps` from a builder the caller has /// already sized and timed: what `sh -c 'printf …; read _'` used to be, /// with no shell deciding how `printf` reads an escape (#249). The steps -/// are documented in `fixtures/emit/src/main.rs`. +/// are documented in `fixtures/emit/src/steps.txt`, which is both the +/// fixture's module header and its `--help`; `check-emit-steps.sh` holds +/// that list against the steps the fixture implements. /// /// This module is compiled into every test binary that declares it, and /// not every one of them uses every helper. diff --git a/crates/termlens/tests/process.rs b/crates/termlens/tests/process.rs index a08bf8d..37d7b84 100644 --- a/crates/termlens/tests/process.rs +++ b/crates/termlens/tests/process.rs @@ -147,3 +147,92 @@ fn a_normally_exited_child_still_reports_its_code() -> termlens::Result<()> { assert_eq!(status.to_string(), "exit code 7"); Ok(()) } + +/// The README's "What `TestBackend` cannot see" table promises a panic is +/// assertable with `s.contains("panicked")`, and `skills/termlens/SKILL.md` +/// §1 says the same; until #311 nothing in `fixtures/` ever panicked, so +/// neither claim had a test. This is the case users reach for when their +/// TUI dies in CI, and the alternate screen is the part that could have +/// eaten the message. +/// +/// **Measured, not assumed**: the message survives both ways. A panic +/// raised inside the alternate screen lands in that buffer, next to what +/// the application had drawn; a panic raised after the application tore the +/// alternate screen down lands on the restored primary screen. The exit +/// status is an exit *code* of 101 — the value the Rust runtime uses — and +/// not a signal. +#[test] +fn a_panicking_child_puts_its_message_on_the_screen() -> termlens::Result<()> { + // Wide enough that the message is one row: the runtime's own + // `panicked at :` line wraps on a narrow grid, and a + // wrapped needle is a test about the width, not about the panic. + let wide = || { + Terminal::builder() + .size(100, 10) + .timeout(Duration::from_secs(10)) + }; + + // 1. A plain child, which is what the README's table is about. + let mut t = common::spawn_emit(wide(), &["drew this ", "--panic", "plain panic here"])?; + t.wait_until(|s| s.contains("panicked"))?; + let s = t.screen(); + assert!( + s.contains("plain panic here"), + "the message reaches the grid: {s}" + ); + assert!(s.contains("drew this"), "and what was drawn before it: {s}"); + let status = t.wait_exit()?; + assert_eq!( + status.code(), + Some(101), + "the Rust runtime's code: {status}" + ); + assert!(!status.success(), "{status}"); + + // 2. Dying inside the alternate screen, with no panic hook to leave it + // — the shape a TUI that panics mid-draw actually has. + let mut t = common::spawn_emit( + wide(), + &[ + "--csi", + "?1049h", + "TUI drawing here", + "--panic", + "boom in the alt screen", + ], + )?; + t.wait_until(|s| s.contains("panicked"))?; + let s = t.screen(); + assert!(s.alternate_screen(), "nothing tore it down: {s}"); + assert!(s.contains("boom in the alt screen"), "{s}"); + assert!( + s.contains("TUI drawing here"), + "the message joins the frame it died on: {s}" + ); + assert_eq!(t.wait_exit()?.code(), Some(101)); + + // 3. And after the teardown a panic hook would do, where the message + // lands on the restored primary screen instead. + let mut t = common::spawn_emit( + wide(), + &[ + "--csi", + "?1049h", + "TUI drawing here", + "--csi", + "?1049l", + "--panic", + "boom after teardown", + ], + )?; + t.wait_until(|s| s.contains("panicked"))?; + let s = t.screen(); + assert!(!s.alternate_screen(), "the child left it: {s}"); + assert!(s.contains("boom after teardown"), "{s}"); + assert!( + !s.contains("TUI drawing here"), + "what the alternate screen held went with it: {s}" + ); + assert_eq!(t.wait_exit()?.code(), Some(101)); + Ok(()) +} diff --git a/fixtures/emit/src/main.rs b/fixtures/emit/src/main.rs index e055fd6..3d75494 100644 --- a/fixtures/emit/src/main.rs +++ b/fixtures/emit/src/main.rs @@ -9,51 +9,7 @@ //! decode. Steps apply left to right: //! //! ```text -//! TEXT literal text — any argument that is not a step below -//! NL CR a newline / a carriage return -//! --text WORD literal text that happens to spell a step name -//! --esc BYTES ESC followed by BYTES `--esc '(0'` is ESC ( 0 -//! --csi BYTES ESC [ followed by BYTES `--csi '?2026h'` -//! --raw SPEC bytes with escapes: \e \n \r \t \a \\ and \xNN -//! --sleep DUR pause for DUR: `250ms`, `1.5s`, `2s` -//! --wait read one line from stdin and discard it — "hold the -//! terminal open until the test sends Enter" -//! --wait-for WORD read lines until one is exactly WORD -//! --echo-line read one line from stdin and write it back, without -//! its newline -//! --echo copy stdin to stdout, line by line, until EOF -//! --seq N the integers 1..=N, one per line -//! --cwd the current directory, as the process sees it -//! --pid this process's id, in decimal -//! --env NAME the value of environment variable NAME, or `unset` -//! --environ every environment variable as NAME=VALUE, one per -//! line, sorted -//! --exit CODE exit now with CODE -//! --loop run the steps before it once, then the steps after it -//! forever -//! ``` -//! -//! And the steps that read what the terminal *typed back* — a query's reply, -//! a mouse report, a paste — which need the line discipline out of the way: -//! -//! ```text -//! --raw-mode ICANON and ECHO off: bytes arrive as sent, unechoed -//! (what `stty -icanon -echo` did); ICRNL is left on, so -//! --wait still ends at Enter -//! --no-icrnl ICRNL off too, so a CR arrives as CR (what raw mode -//! does in an application) — --wait then needs a LF -//! --read N read exactly N bytes and write them, ESC as `E` and -//! BEL as `G` so a reply is legible on the grid -//! --skip N read exactly N bytes and write nothing -//! --read-hex N read exactly N bytes and write them as lowercase hex -//! --read-quiet N read up to N bytes, stopping after 2s without one, -//! and write them as --read does -//! --read-count N C read exactly N bytes and write how many were C -//! --winsize the tty's size as the kernel reports it: -//! `COLSxROWS px WIDTHxHEIGHT` -//! --kill-self raise SIGTERM against this process -//! --on-term TEXT CODE on SIGTERM, write TEXT and exit CODE … -//! --idle … and sit here until that happens +#![doc = include_str!("steps.txt")] //! ``` //! //! Every emitting step is one `write_all` and a flush, so a test that wants @@ -71,12 +27,30 @@ use std::io::{self, BufRead, Write}; use std::process; use std::time::Duration; +/// The step language, and the only copy of it: the module header above +/// includes this same file, so `emit --help` and the doc a test author +/// reads cannot say different things (#304). `check-emit-steps.sh` holds +/// it against the match arms below. +const STEPS: &str = include_str!("steps.txt"); + +/// What `-h`/`--help` prints. Only the banner is written here; every step +/// comes from [`STEPS`]. +fn help() -> String { + format!( + "usage: emit [STEP]...\n\n\ + A termlens fixture: writes exactly the bytes its steps describe, in\n\ + order, then waits, sleeps, reads or exits as told. Steps apply left\n\ + to right.\n\n{STEPS}" + ) +} + #[derive(Debug, Clone)] enum Step { Write(Vec), Sleep(Duration), Wait, WaitFor(String), + Panic(String), EchoLine, Echo, Seq(u64), @@ -100,7 +74,7 @@ enum Step { fn usage(reason: &str) -> ! { eprintln!("emit: {reason}"); - eprintln!("see the crate doc in fixtures/emit/src/main.rs for the steps"); + eprintln!("emit --help lists every step"); process::exit(2) } @@ -169,6 +143,13 @@ fn parse(args: impl Iterator) -> (Vec, Option) { .unwrap_or_else(|| usage(&format!("{flag} needs a value"))) }; let step = match arg.as_str() { + // Before anything is parsed, so `emit --help` explains itself + // whatever follows it. Not a Step: it never reaches `run`. + "-h" | "--help" => { + print!("{}", help()); + let _ = io::stdout().flush(); + process::exit(0); + } "NL" => Step::Write(b"\n".to_vec()), "CR" => Step::Write(b"\r".to_vec()), "--text" => Step::Write(next("--text").into_bytes()), @@ -202,6 +183,7 @@ fn parse(args: impl Iterator) -> (Vec, Option) { .parse() .unwrap_or_else(|_| usage("--exit needs an exit code")), ), + "--panic" => Step::Panic(next("--panic")), "--loop" => { loop_from = Some(steps.len()); continue; @@ -293,6 +275,10 @@ fn run( process::exit(0); } } + // Every step before this one ended in a flush, so what was + // drawn is already on the terminal; the panic message follows + // it on stderr, which in a PTY is the same screen (#311). + Step::Panic(message) => panic!("{message}"), Step::WaitFor(word) => loop { match line(stdin)? { Some(l) if l == *word => break, diff --git a/fixtures/emit/src/steps.txt b/fixtures/emit/src/steps.txt new file mode 100644 index 0000000..79f51b8 --- /dev/null +++ b/fixtures/emit/src/steps.txt @@ -0,0 +1,45 @@ +TEXT literal text — any argument that is not a step below +NL CR a newline / a carriage return +--text WORD literal text that happens to spell a step name +--esc BYTES ESC followed by BYTES `--esc '(0'` is ESC ( 0 +--csi BYTES ESC [ followed by BYTES `--csi '?2026h'` +--raw SPEC bytes with escapes: \e \n \r \t \a \\ and \xNN +--sleep DUR pause for DUR: `250ms`, `1.5s`, `2s` +--wait read one line from stdin and discard it — "hold the + terminal open until the test sends Enter" +--wait-for WORD read lines until one is exactly WORD +--echo-line read one line from stdin and write it back, without + its newline +--echo copy stdin to stdout, line by line, until EOF +--seq N the integers 1..=N, one per line +--cwd the current directory, as the process sees it +--pid this process's id, in decimal +--env NAME the value of environment variable NAME, or `unset` +--environ every environment variable as NAME=VALUE, one per + line, sorted +--exit CODE exit now with CODE +--panic MESSAGE panic with MESSAGE, so the harness has a child that + dies the way a Rust program dies +--loop run the steps before it once, then the steps after it + forever + +and the steps that read what the terminal typed back — a query's reply, a +mouse report, a paste — which need the line discipline out of the way: + +--raw-mode ICANON and ECHO off: bytes arrive as sent, unechoed + (what `stty -icanon -echo` did); ICRNL is left on, so + --wait still ends at Enter +--no-icrnl ICRNL off too, so a CR arrives as CR (what raw mode + does in an application) — --wait then needs a LF +--read N read exactly N bytes and write them, ESC as `E` and + BEL as `G` so a reply is legible on the grid +--skip N read exactly N bytes and write nothing +--read-hex N read exactly N bytes and write them as lowercase hex +--read-quiet N read up to N bytes, stopping after 2s without one, + and write them as --read does +--read-count N C read exactly N bytes and write how many were C +--winsize the tty's size as the kernel reports it: + `COLSxROWS px WIDTHxHEIGHT` +--kill-self raise SIGTERM against this process +--on-term TEXT CODE on SIGTERM, write TEXT and exit CODE … +--idle … and sit here until that happens From 54b9464e671fcfbb5a70c260e3c90dad7e598411 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:59:20 +0700 Subject: [PATCH 2/2] ci: gate the emit fixture's steps against the ones it implements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit steps.txt and `emit --help` cannot disagree — they are one file. What they could still disagree with is the match that parses the steps, and a step added to one side only is invisible until it costs a test author an afternoon. check-emit-steps.sh compares the two sets and names the difference in either direction. It runs in the gates-listed job because it needs no toolchain, and is listed in CONTRIBUTING §1 with the other scripts. Proven to fail three ways before being trusted to pass: a documented step with no arm, an arm with no documentation, and a rename on one side (which reports both halves). It also refuses to pass when either pattern matches nothing, so a future edit that breaks the extraction cannot make the gate silently vacuous. Closes #318 Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- .github/scripts/check-emit-steps.sh | 70 +++++++++++++++++++++++++++++ .github/workflows/ci.yml | 6 +++ CONTRIBUTING.md | 1 + 3 files changed, 77 insertions(+) create mode 100755 .github/scripts/check-emit-steps.sh diff --git a/.github/scripts/check-emit-steps.sh b/.github/scripts/check-emit-steps.sh new file mode 100755 index 0000000..150130d --- /dev/null +++ b/.github/scripts/check-emit-steps.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# check-emit-steps.sh — the emit fixture's documented steps against the ones +# it implements. +# +# `fixtures/emit` is the fixture almost every integration test drives, and +# its step language is the reference every test author reads. Since #304 the +# language lives in exactly one file, `fixtures/emit/src/steps.txt`: the +# module header includes it and `emit --help` prints it, so those two cannot +# disagree. What they still could disagree with is the `match` that parses +# the steps — a step added to one side only, or renamed on one, is invisible +# until someone wastes an afternoon on it (#318). +# +# This repository solves that class of drift twice already, with +# check-ci-gates-listed.sh and check-skill-snippets.sh; this is the third. +# +# A step is documented by a line in steps.txt beginning `--name` in column 1, +# and implemented by a match arm `"--name" =>` in main.rs. `-h`/`--help` is +# neither: it is handled before any step is parsed and never becomes a Step, +# and its arm (`"-h" | "--help" =>`) does not have the shape below. +# +# Portability: macOS ships bash 3.2 and BSD sed. No `declare -A`, no +# associative arrays, no GNU-only flags, no process substitution. +# +# Usage: check-emit-steps.sh [steps.txt] [main.rs] +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +steps="${1:-$root/fixtures/emit/src/steps.txt}" +main="${2:-$root/fixtures/emit/src/main.rs}" +for file in "$steps" "$main"; do + [ -f "$file" ] || { echo "::error::$file does not exist"; exit 1; } +done + +work="$(mktemp -d 2>/dev/null || mktemp -d -t termlens-emit-steps)" +trap 'rm -rf "$work"' EXIT + +grep -oE '^--[a-z][a-z-]*' "$steps" | sort -u > "$work/documented" +grep -oE '^[[:space:]]+"--[a-z][a-z-]*" =>' "$main" \ + | grep -oE -- '--[a-z][a-z-]*' | sort -u > "$work/implemented" + +documented="$(grep -c . "$work/documented" || true)" +implemented="$(grep -c . "$work/implemented" || true)" +if [ "$documented" -eq 0 ] || [ "$implemented" -eq 0 ]; then + echo "::error::found $documented documented and $implemented implemented steps;" \ + "one of the two patterns has stopped matching, which would make this gate pass on anything" + exit 1 +fi + +status=0 +# -23: documented only. -13: implemented only. Named separately because the +# two are different mistakes with different fixes. Through files rather than +# a process substitution, so the loop runs in this shell and its `status=1` +# is the one that gets read. +comm -23 "$work/documented" "$work/implemented" > "$work/undone" +comm -13 "$work/documented" "$work/implemented" > "$work/unwritten" +while IFS= read -r step; do + [ -z "$step" ] && continue + echo "::error::\`$step\` is documented in ${steps#"$root"/} but no \`\"$step\" =>\` arm implements it" + status=1 +done < "$work/undone" +while IFS= read -r step; do + [ -z "$step" ] && continue + echo "::error::\`$step\` is implemented in ${main#"$root"/} but not documented in ${steps#"$root"/}" + status=1 +done < "$work/unwritten" + +if [ "$status" -eq 0 ]; then + echo "emit steps: $documented documented, $implemented implemented, the same set" +fi +exit "$status" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fcbbb7..38b38bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,6 +186,12 @@ jobs: # misspelled org repository and a deleted in-repo target are both # asserted to go red, so today's green run means something. - run: tools/link-gate-selftest/run.sh + # The emit fixture's documented steps against the ones it implements + # (#318). It lives in this job because it needs no toolchain — it is + # two greps and a `comm` — and the fixture is what almost every + # integration test drives, so a step that is documented and not + # implemented (or the reverse) costs a test author an afternoon. + - run: .github/scripts/check-emit-steps.sh msrv: name: msrv diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5664caf..5752137 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,7 @@ cargo test --workspace # default features cargo build -p termlens-cli # then the CLI's documented exit codes: .github/scripts/check-cli-contract.sh target/debug/termlens .github/scripts/check-readme-links.sh README.md CONTRIBUTING.md # every in-repo link target exists; README.md alone may not use relative ones +.github/scripts/check-emit-steps.sh # the emit fixture documents exactly the steps it implements RUSTDOCFLAGS='-D warnings' cargo doc --no-deps RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --all-features .github/scripts/check-candidate-statement.sh # README, CHANGELOG and STABILITY state the candidate in the same words