Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/scripts/check-emit-steps.sh
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion crates/termlens/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
89 changes: 89 additions & 0 deletions crates/termlens/tests/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>:<line>` 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(())
}
78 changes: 32 additions & 46 deletions fixtures/emit/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<u8>),
Sleep(Duration),
Wait,
WaitFor(String),
Panic(String),
EchoLine,
Echo,
Seq(u64),
Expand All @@ -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)
}

Expand Down Expand Up @@ -169,6 +143,13 @@ fn parse(args: impl Iterator<Item = String>) -> (Vec<Step>, Option<usize>) {
.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()),
Expand Down Expand Up @@ -202,6 +183,7 @@ fn parse(args: impl Iterator<Item = String>) -> (Vec<Step>, Option<usize>) {
.parse()
.unwrap_or_else(|_| usage("--exit needs an exit code")),
),
"--panic" => Step::Panic(next("--panic")),
"--loop" => {
loop_from = Some(steps.len());
continue;
Expand Down Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions fixtures/emit/src/steps.txt
Original file line number Diff line number Diff line change
@@ -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