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 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