diff --git a/CHANGELOG.md b/CHANGELOG.md index 456aa55..342584d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,27 @@ reads that marker. ## [Unreleased] +### Fixed + +- A relative `TerminalBuilder::current_dir`, and so `termlens inspect --cwd + ./dir`, runs a relative program path such as `./myapp` inside that + directory. The directory was applied twice — the program was looked up + as `dir/./myapp` from inside `dir` — and the child died of a Rust runtime + abort it could not report (`fatal runtime error: assertion failed: + output.write(&bytes).is_ok()`), which inspect showed as `exited: killed + by signal: Aborted`. A relative directory is now taken from the test + process's own, as `std::process::Command` does. An absolute directory, + or a bare program name found on `PATH`, was never affected. +- `termlens inspect --help` and `examples/inspect.rs --help` describe the + wait and the environment as they are: the silence window starts only + once the program has drawn something; the child also gets + `TERM=xterm-256color` and `SHELL=/bin/sh`; and `--inherit-env` still + replaces the caller's `TERM` unless `--env TERM=…` sets it. `--cwd`'s + argument is called `DIR` in the help and in its diagnostic, beside a + paragraph about the `PATH` variable, and the help says a relative program + path is found inside it. The CLI README's `--cwd` example ran a program + from the wrong directory and is corrected. + ## [0.11.4] - 2026-09-27 ### Changed diff --git a/crates/termlens-cli/README.md b/crates/termlens-cli/README.md index 0294a6e..c98a84d 100644 --- a/crates/termlens-cli/README.md +++ b/crates/termlens-cli/README.md @@ -8,7 +8,7 @@ cargo install termlens-cli --locked termlens inspect --size 120x40 htop # run a program, print its screen termlens inspect --ansi ./target/debug/myapp # …in colour -termlens inspect --cwd ./examples ./myapp # …somewhere else +termlens inspect --cwd ./examples ./demo # …in ./examples, where ./demo is found termlens diff old.snap new.snap.new # what changed, cell by cell; exit 1 if anything termlens render --svg --out failing.svg failing.snap.new ``` @@ -33,8 +33,10 @@ when the render fails. A shell redirect cannot promise that — `> file.svg` truncates the file before termlens runs, so a failed render leaves an empty image attached to the bug report. -`inspect --cwd PATH` runs the program in `PATH`, which must already exist: -the working directory is part of how a program is normally run, and +`inspect --cwd DIR` runs the program in `DIR`, which must already exist. A +relative `DIR` is taken from where termlens runs, and a relative program +path such as `./demo` is found inside `DIR`, where the program runs. The +working directory is part of how a program is normally run, and `TerminalBuilder::current_dir` had no way through to the command line. Exit codes: `0` ran; `diff` exits `1` when the screens differ; `2` means the diff --git a/crates/termlens-cli/src/main.rs b/crates/termlens-cli/src/main.rs index 8e97585..3d87717 100644 --- a/crates/termlens-cli/src/main.rs +++ b/crates/termlens-cli/src/main.rs @@ -47,17 +47,21 @@ run — bad arguments, an unreadable file, a program that could not be spawned. const INSPECT_USAGE: &str = "\ usage: termlens inspect [--size COLSxROWS] [--timeout SECONDS] [--idle MILLIS] - [--cwd PATH] [--inherit-env] [--ansi] + [--cwd DIR] [--inherit-env] [--ansi] [--env KEY=VALUE]... [--] [args…] Runs in an 80x24 pseudo-terminal (or --size) and prints the rendered screen. The wait ends on whichever comes first: the program -exits, or its output has been silent for --idle milliseconds (default -300), bounded by --timeout (default 5 seconds); a program still running -when it ends is killed. The child environment is cleared by default -except for PATH; --inherit-env keeps the caller's environment, and -repeatable --env sets selected values. ---cwd runs the program in PATH, which must be an existing directory. +exits, or, once it has drawn something, its output has been silent for +--idle milliseconds (default 300). --timeout (default 5 seconds) bounds +both, and a program still running when the wait ends is killed. +The child environment is cleared except for PATH, and gets +TERM=xterm-256color and SHELL=/bin/sh; --inherit-env keeps the caller's +environment instead, but for TERM, and repeatable --env sets selected +values, TERM included. +--cwd runs the program in DIR, which must be an existing directory. A +relative DIR is taken from where inspect runs, and a relative program +path such as ./myapp is then found inside DIR. --ansi paints the screen in colour on a terminal. -- ends the options; the program name follows. Options already stop at the first argument that does not begin with `-`, so this matters only for @@ -672,7 +676,7 @@ fn inspect(args: Vec) -> ExitCode { // names the flag the user typed; the builder refuses it too, with // a message about `current_dir` the caller never wrote (#312). "--cwd" => { - take(&mut args, "--cwd", "PATH", "/tmp", |s| Some(s.to_owned())).and_then(|dir| { + take(&mut args, "--cwd", "DIR", "/tmp", |s| Some(s.to_owned())).and_then(|dir| { if Path::new(&dir).is_dir() { cwd = Some(dir); Ok(()) diff --git a/crates/termlens-cli/tests/cli.rs b/crates/termlens-cli/tests/cli.rs index eb07e91..5e810ff 100644 --- a/crates/termlens-cli/tests/cli.rs +++ b/crates/termlens-cli/tests/cli.rs @@ -1185,6 +1185,20 @@ fn render_empty_out_value_names_the_flag() -> termlens::Result<()> { Ok(()) } +/// `inspect --help` calls `--cwd`'s argument DIR, beside a paragraph about the +/// `PATH` variable, so the diagnostic for a missing one says DIR as well. +#[test] +fn inspect_cwd_without_a_value_names_its_argument_dir() -> termlens::Result<()> { + use std::process::Command; + let out = Command::new(env!("CARGO_BIN_EXE_termlens")) + .args(["inspect", "--cwd"]) + .output()?; + assert_eq!(out.status.code(), Some(2), "{out:?}"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(stderr, "termlens: --cwd needs a DIR argument\n"); + Ok(()) +} + /// `render --out` exists because every caller was writing `> file.svg`, and /// a redirect truncates the file before termlens runs — so a failing render /// leaves an empty file where a bug report expected an image (#313). diff --git a/crates/termlens/examples/inspect.rs b/crates/termlens/examples/inspect.rs index bcf808b..5c9e4d0 100644 --- a/crates/termlens/examples/inspect.rs +++ b/crates/termlens/examples/inspect.rs @@ -8,9 +8,10 @@ //! cargo run --example inspect -- --env NO_COLOR=1 my-app //! ``` //! -//! The wait ends on whichever comes first: the program exits, or its -//! output has been silent for a window (`--idle`, 300ms by default) — -//! bounded by the deadline (`--timeout`, five seconds by default). Five +//! The wait ends on whichever comes first: the program exits, or, once it +//! has drawn something, its output has been silent for a window (`--idle`, +//! 300ms by default) — bounded by the deadline (`--timeout`, five seconds +//! by default). Five //! seconds is what a test suite wants, where a deadline exists to turn a //! hang into a readable failure; a person at a terminal pointing this at an //! application that loads a large file or compiles before it draws is @@ -49,17 +50,21 @@ const REAP_GRACE: Duration = if cfg!(windows) { /// 0, a missing program prints it to stderr and exits 2 (#229). const USAGE: &str = "\ usage: inspect [--size COLSxROWS] [--timeout SECONDS] [--idle MILLIS] - [--cwd PATH] [--inherit-env] [--ansi] + [--cwd DIR] [--inherit-env] [--ansi] [--env KEY=VALUE]... [args…] Runs in an 80x24 pseudo-terminal (or --size) and prints the rendered screen. The wait ends on whichever comes first: the program -exits, or its output has been silent for --idle milliseconds (default -300), bounded by --timeout (default 5 seconds); a program still running -when it ends is killed. -The child environment is cleared by default except for PATH; --inherit-env -keeps the caller's environment, and repeatable --env sets selected values. ---cwd runs the program in PATH, which must be an existing directory. +exits, or, once it has drawn something, its output has been silent for +--idle milliseconds (default 300). --timeout (default 5 seconds) bounds +both, and a program still running when the wait ends is killed. +The child environment is cleared except for PATH, and gets +TERM=xterm-256color and SHELL=/bin/sh; --inherit-env keeps the caller's +environment instead, but for TERM, and repeatable --env sets selected +values, TERM included. +--cwd runs the program in DIR, which must be an existing directory. A +relative DIR is taken from where inspect runs, and a relative program +path such as ./myapp is then found inside DIR. --ansi paints the screen in colour on a terminal. Every option that takes a value also accepts it attached with `=`, as in --size=100x30 or --env=KEY=VALUE. @@ -187,7 +192,7 @@ fn main() -> ExitCode { // names the flag the user typed; the builder refuses it too, with // a message about `current_dir` the caller never wrote (#312). "--cwd" => { - take(&mut args, "--cwd", "PATH", "/tmp", |s| Some(s.to_owned())).and_then(|dir| { + take(&mut args, "--cwd", "DIR", "/tmp", |s| Some(s.to_owned())).and_then(|dir| { if Path::new(&dir).is_dir() { cwd = Some(dir); Ok(()) diff --git a/crates/termlens/src/terminal.rs b/crates/termlens/src/terminal.rs index f228568..266247a 100644 --- a/crates/termlens/src/terminal.rs +++ b/crates/termlens/src/terminal.rs @@ -1606,6 +1606,10 @@ impl TerminalBuilder { /// shell is needed. (Before 0.9 the default was the PTY layer's /// fallback, `$HOME`, while this text claimed otherwise: #215.) /// + /// A relative `dir` is relative to the test process's working directory, + /// as with `std::process::Command`, and a relative program path such as + /// `./myapp` is then found inside `dir`, where the child runs. + /// /// [`spawn`](Self::spawn) fails with [`Error::Spawn`] if `dir` is not /// an existing directory — running somewhere else instead would make /// a directory-sensitive test pass against the wrong tree. @@ -1896,7 +1900,16 @@ impl TerminalBuilder { // rustdoc promised the opposite (#215). The test process's own // directory is the default `std::process::Command` gives. match &self.cwd { - Some(dir) => cmd.cwd(dir), + // A relative directory is the test process's, as with + // `std::process::Command`, so it is made absolute here. Handed + // over relative, the PTY layer joins it onto a relative program + // path and the child then enters it before exec: `dir/./prog` + // looked up from inside `dir`, which does not exist. The exec + // failure could not even be reported, because the PTY layer + // closes the pipe std reports it through, so the child died of + // a runtime abort instead. `absolute` fails only on an empty + // path or an unreadable cwd; the given path stands then. + Some(dir) => cmd.cwd(std::path::absolute(dir).unwrap_or_else(|_| dir.clone())), None => { if let Ok(here) = std::env::current_dir() { cmd.cwd(here); diff --git a/crates/termlens/tests/inspect.rs b/crates/termlens/tests/inspect.rs index bb8a7b3..649f828 100644 --- a/crates/termlens/tests/inspect.rs +++ b/crates/termlens/tests/inspect.rs @@ -296,6 +296,8 @@ fn inspect_takes_its_deadline_and_silence_window_from_flags() { ), (&["--timeout"][..], "--timeout needs a SECONDS argument"), (&["--idle"][..], "--idle needs a MILLIS argument"), + // The help names `--cwd`'s argument DIR, so the diagnostic does too. + (&["--cwd"][..], "--cwd needs a DIR argument"), ] { let out = run_inspect(bin, args); assert_eq!(out.status.code(), Some(2), "{args:?}"); @@ -425,6 +427,42 @@ fn inspect_resolves_a_relative_program_path_from_its_working_directory() { !stdout.contains("---"), "the trailer belongs on stderr:\n{stdout}" ); + + // A *relative* `--cwd` is relative to where inspect was started, and the + // relative program resolves inside it, in the example and the command + // alike. It used to be applied twice, so `--cwd inspect-relative ./echo` + // looked for `inspect-relative/./echo` inside `inspect-relative`, and + // the child died of a runtime abort it could not report. + let parent = scratch + .parent() + .expect("the scratch directory has a parent"); + for (who, program, lead) in [ + ("example", bin.as_path(), None), + ("command", command_bin().as_path(), Some("inspect")), + ] { + let out = Command::new(program) + .current_dir(parent) + .args(lead) + .args([ + "--cwd", + "inspect-relative", + "./echo", + "relative cwd resolved", + ]) + .output() + .expect("failed to run inspect"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success() && stdout.contains("relative cwd resolved"), + "{who}:\n{}", + both_streams(&out) + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("--- exited: exit code 0 ---"), + "{who}: the program ran and exited:\n{}", + both_streams(&out) + ); + } } /// A program whose exit status lands a second after its last byte is diff --git a/crates/termlens/tests/process.rs b/crates/termlens/tests/process.rs index 486eb19..65dc3ef 100644 --- a/crates/termlens/tests/process.rs +++ b/crates/termlens/tests/process.rs @@ -14,6 +14,83 @@ fn emit(steps: &[&str]) -> termlens::Result { common::spawn_emit(Terminal::builder().timeout(Duration::from_secs(10)), steps) } +/// `to` relative to `from`, both absolute: `..` for each component of +/// `from` past the common prefix, then the rest of `to`. +#[cfg(unix)] +fn relative_path(from: &std::path::Path, to: &std::path::Path) -> std::path::PathBuf { + let from: Vec<_> = from.components().collect(); + let to: Vec<_> = to.components().collect(); + let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count(); + let mut out = std::path::PathBuf::new(); + for _ in common..from.len() { + out.push(".."); + } + for part in &to[common..] { + out.push(part); + } + out +} + +/// A relative `current_dir` is the test process's, and a relative program +/// path then resolves inside it. Handed to the PTY layer relative, the +/// directory was applied twice — joined onto `./emit`, then entered by the +/// child before exec — so the exec looked for `dir/./emit` from inside +/// `dir`, and the child died of a Rust runtime abort it could not report. +/// Seen through `termlens inspect --cwd ./examples ./myapp`. +/// +/// The scratch directory sits deeper than the test's own working directory +/// so that the doubled path cannot land back on it: a directory as deep as +/// this one, such as `target/debug` beside `crates/termlens`, maps onto +/// itself and hid the bug. The test checks that before it trusts a pass. +/// Unix-only, as the CLI's relative-program test is: the fixture is +/// linked in, and the program path is spelled the Unix way. +#[cfg(unix)] +#[test] +fn a_relative_current_dir_is_the_test_process_s_and_a_relative_program_resolves_in_it( +) -> termlens::Result<()> { + let emit = common::fixture_bin("emit").canonicalize()?; + let scratch = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")) + .join(format!("relative-cwd-{}", std::process::id())) + .join("a") + .join("b"); + std::fs::create_dir_all(&scratch)?; + let scratch = scratch.canonicalize()?; + let link = scratch.join("emit"); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(&emit, &link)?; + + let here = std::env::current_dir()?.canonicalize()?; + let relative = relative_path(&here, &scratch); + assert!( + relative.is_relative() && relative.join("emit").is_file(), + "{} from {}", + relative.display(), + here.display() + ); + assert!( + !scratch.join(&relative).join("emit").exists(), + "applied twice, {} still finds the program, so this test could not fail", + relative.display() + ); + + let mut t = Terminal::builder() + .timeout(Duration::from_secs(10)) + .current_dir(&relative) + .args(["--cwd", "--wait"]) + .spawn("./emit")?; + // `--cwd` prints the directory as the kernel put the process there. + t.wait_until(|s| s.contains(scratch.to_str().expect("utf-8 scratch directory")))?; + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + let _ = std::fs::remove_dir_all( + scratch + .parent() + .and_then(std::path::Path::parent) + .expect("the scratch root"), + ); + Ok(()) +} + #[test] fn current_dir_runs_the_child_where_asked() -> termlens::Result<()> { // Canonicalize: /tmp is a symlink on macOS and `--cwd` reports the real diff --git a/skills/termlens/SKILL.md b/skills/termlens/SKILL.md index 443d0f2..196c3bf 100644 --- a/skills/termlens/SKILL.md +++ b/skills/termlens/SKILL.md @@ -561,7 +561,8 @@ directory (see §9b). trailer goes to stderr), an insta `.snap`, the grid a wait error leaves in a log — or the JSON the `serde` feature writes. `diff` and `render` read `-` as stdin (one operand of `diff` at most), and - `inspect --cwd DIR` runs the program somewhere other than here. + `inspect --cwd DIR` runs the program somewhere other than here; a + relative program path such as `./demo` is then found inside `DIR`. - In CI, set `TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens` on the test step and add `uses: vyncint/termlens/.github/actions/report@v0.11.4` with `if: failure()` after it: every screen a failing wait embedded, and