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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions crates/termlens-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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
Expand Down
20 changes: 12 additions & 8 deletions crates/termlens-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]... [--] <program> [args…]

Runs <program> 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
Expand Down Expand Up @@ -672,7 +676,7 @@ fn inspect(args: Vec<String>) -> 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(())
Expand Down
14 changes: 14 additions & 0 deletions crates/termlens-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
27 changes: 16 additions & 11 deletions crates/termlens/examples/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]... <program> [args…]

Runs <program> 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.
Expand Down Expand Up @@ -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(())
Expand Down
15 changes: 14 additions & 1 deletion crates/termlens/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions crates/termlens/tests/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}");
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions crates/termlens/tests/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,83 @@ fn emit(steps: &[&str]) -> termlens::Result<Terminal> {
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
Expand Down
3 changes: 2 additions & 1 deletion skills/termlens/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading