mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
ec0f3a344f
## Problem
`worktrunk::styling`'s `eprint!` is anstream's and strips ANSI when
stderr isn't a terminal; std's prelude macro of the same name keeps it.
A file that imports one but not the other — or neither — gets a mix, and
adjacent lines of the same message block disagree about whether a
redirected stderr carries escapes.
`wt list 2>&1 >/dev/null | cat -v` in a repo with a deprecated `[ci]`
block, on `main`:
```
^[[33mM-bM-^VM-2^[[39m ^[[33mProject config: ^[[1m[ci]^[[22m is deprecated in favor of ^[[1m[forge]^[[22m^[[39m
M-bM-^FM-3 To see details, run wt config show; to apply updates, run wt config update
```
The warning is `eprint!("{warnings}")` at `src/config/deprecation.rs`,
which resolved to std's macro; the hint directly beneath it is the
`eprintln!` imported from `styling` four lines later. Anyone redirecting
`wt` narration to a file gets escapes on one line and not the next.
This is the stderr counterpart of what #3746 and #3766 fixed on stdout,
and it went unnoticed for the reason named in `verbatim.rs`'s own
docstring: the suite sets `CLICOLOR_FORCE=1`, which forces color on
*both* printers, so no snapshot could disagree no matter which macro was
in scope. `output_system_guard` doesn't cover it either — it scans for
`print!`/`println!` tokens under `src/commands/`, not for which
`eprint!` a file imported.
## Solution
The rule is now structural rather than per-site.
`check_stderr_macros_come_from_styling` in `output_system_guard.rs`
walks every `.rs` file under `src/` and flags a bare
`eprint!`/`eprintln!` whose file lacks the matching `worktrunk::styling`
import. A call satisfies it either way — importing the macro, or
qualifying the call as `styling::eprintln!(…)`, which several files
(`git/repository/mod.rs`, `config/user/mod.rs`,
`commands/config/alias.rs`) already do. Two files are allowlisted with a
reason: `testing/mock_stub.rs` relays a stub's captured stderr verbatim,
so its bytes are fixture data; `remove_dir.rs`'s one call is a
`#[cfg(test)]` skip diagnostic, not narration a user redirects.
Reverting the source fixes below makes it name exactly those five lines
and nothing else.
The sites it fixes:
- `src/config/deprecation.rs` — the deprecation warning block above.
- `src/commands/config/update.rs` — the `format_update_preview` block
shown before `wt config update`'s prompt, reachable with a tty stdin and
a redirected stderr.
- `src/output/prompt.rs` — the `[y/N/?]` prompt; its blank-line
`eprintln!` was already explicitly qualified as
`worktrunk::styling::eprintln!`, so the two disagreed within four lines.
Both now come from one import.
- `src/output/global.rs` — the file the first scan couldn't see, because
that scan looked for "imported `eprintln` but not `eprint`" and this
file imports neither. Its four styled `eprintln!` calls
(`print_outdated_shell_wrapper_hint_once`, `warn_retired_exec_once`,
`warn_exec_scrubbed_once`) all resolve to std's, so a user mid-upgrade
running `wt … 2>log` gets `ESC[…m` around the shell-wrapper repair hint.
The module's own docstring already claimed the contract the code didn't
have — *"Regular output still uses `eprintln!`/`println!` directly (from
`worktrunk::styling` for color support)"*. One added import makes it
true; under the suite's `CLICOLOR_FORCE=1` no snapshot moves.
- `src/commands/for_each.rs` — the pre-spawn ANSI reset.
`output/handlers.rs` runs the identical three lines
(`stderr().flush()?`, `eprint!("{}", anstyle::Reset)`,
`stderr().flush().ok()`) immediately before building its `Cmd`, but
through anstream's `eprint` *and* anstream's `stderr`; `for_each` used
std's for both, so the same operation wrote a literal `ESC[0m` into a
redirected stderr where `handlers` dropped it. Both halves move together
— the flushes have to name the stream the reset was written to, so
switching `eprint!` alone would flush std's handle while anstream's
buffer held the write. The `std::io::stderr()` handed to `Stdio::from`
four lines down is a different thing and stays.
## Tests
`test_stderr_narration_strips_ansi_when_piped` in
`output_system_guard.rs`, alongside the closed-consumer test #3766
added. It clears `CLICOLOR_FORCE` and sets `NO_COLOR` (which only
anstream honors), triggers the `[ci]` deprecation, and asserts stderr
carries the warning and no `\x1b`. Confirmed to fail on the pre-fix
source with exactly the escapes quoted above, and to pass with it.
That test proves what the property buys at one site;
`check_stderr_macros_come_from_styling` is what holds it at all of them.
No runtime test can: the suite's `CLICOLOR_FORCE=1` forces color on both
printers, so a snapshot agrees whichever macro is in scope, and the
property is about every stderr write in the binary rather than any one
path.
The module docstring is updated for both — the `Allowed:` list no longer
reads flatly as "`eprintln!` / `eprint!` (stderr is safe)", which was
the sentence someone skims before making this exact mistake.
## Verification
`cargo clippy --all-targets`, `cargo fmt --check`, `cargo test --lib
--bins` (2,454 passed), and the integration suite (1,961 passed) all run
locally. One integration test fails in this sandbox and is unrelated:
`test_copy_ignored_preserves_file_executable_permissions` expects `0644`
and sees `0664`, because the sandbox's umask is `002` rather than the
runner's `022` (confirmed by `umask` → `0002`). It touches none of these
files; CI will confirm.
---------
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
567 lines
23 KiB
Rust
567 lines
23 KiB
Rust
//! Guard test to prevent stdout leaks anywhere in the crate
|
|
//!
|
|
//! stdout carries the command's answer — content the user may want to pipe,
|
|
//! redirect, or capture: data (JSON, tables), rendered views (`wt config show`,
|
|
//! `wt hook show`), `--dry-run` previews (the whole answer when nothing
|
|
//! mutates), and shell integration output. Interactive status, progress, and
|
|
//! errors go to stderr. When shell integration is active (directive env vars
|
|
//! set), directives are written to files, not stdout.
|
|
//!
|
|
//! This test enforces: **No accidental stdout writes anywhere under `src/`**
|
|
//!
|
|
//! Allowed:
|
|
//! - `eprintln!` / `eprint!` — stderr is not the answer stream, so writing to
|
|
//! it is safe; *which* macro of that name is in scope is a separate rule,
|
|
//! enforced by [`check_stderr_macros_come_from_styling`]
|
|
//! - `println!` / `print!` in files listed in `STDOUT_ALLOWED_PATHS`
|
|
//!
|
|
//! When adding stdout output:
|
|
//! - Use `worktrunk::styling::println`. Where the consumer renders the escapes
|
|
//! and is never a tty (the statusline, `--help-page`), declare that once at
|
|
//! the top of the command with `ColorChoice::Always.write_global()` — the
|
|
//! global anstream consults before tty detection — and print normally.
|
|
//! - Add the file path to `STDOUT_ALLOWED_PATHS` with a comment explaining why
|
|
//!
|
|
//! anstream's macros don't panic when the consumer closes the pipe, which
|
|
//! [`test_stdout_surfaces_survive_a_closed_consumer`] checks from the outside.
|
|
//!
|
|
//! The stderr counterpart is two tests: [`check_stderr_macros_come_from_styling`]
|
|
//! requires every bare `eprint!`/`eprintln!` under `src/` to resolve to
|
|
//! anstream's, statically and for every site; and
|
|
//! [`test_stderr_narration_strips_ansi_when_piped`] proves from the outside
|
|
//! what that buys — narration redirected to a file carries no escapes.
|
|
|
|
use std::collections::HashSet;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::process::Stdio;
|
|
|
|
use path_slash::PathExt as _;
|
|
use rstest::rstest;
|
|
|
|
use crate::common::{TestRepo, repo};
|
|
|
|
/// Paths (relative to src/) that are allowed to use println!/print! for stdout.
|
|
/// These intentionally output data to stdout for scripting/piping.
|
|
const STDOUT_ALLOWED_PATHS: &[&str] = &[
|
|
// Shell integration code for: eval "$(wt config shell init bash)"
|
|
"commands/init.rs",
|
|
// Table and summary output for wt list
|
|
"commands/list/collect/mod.rs",
|
|
// State data output (branch names, previous worktree, etc.)
|
|
"commands/config/state.rs",
|
|
// Hint list output
|
|
"commands/config/hints.rs",
|
|
// Alias introspection output (show / dry-run), intended to be pipeable
|
|
"commands/config/alias.rs",
|
|
// Template evaluation output for scripting
|
|
"commands/eval.rs",
|
|
// LLM prompt output for wt step commit --show-prompt and squash --show-prompt
|
|
"commands/step/commit.rs",
|
|
"commands/step/squash.rs",
|
|
// wt step copy-ignored dry-run plan (human preview + --format=json)
|
|
"commands/step/copy_ignored.rs",
|
|
// wt step prune dry-run plan (human preview + --format=json)
|
|
"commands/step/prune.rs",
|
|
// wt step relocate dry-run human preview (show_dry_run_preview)
|
|
"commands/relocate.rs",
|
|
// wt config shell install/uninstall --dry-run preview (the interactive
|
|
// `?` re-preview still goes to stderr)
|
|
"commands/configure_shell.rs",
|
|
// JSON output for wt switch --format=json
|
|
"commands/worktree/switch.rs",
|
|
// Migrated TOML output for wt config update --print (pipeable)
|
|
"commands/config/update.rs",
|
|
// Hook listing for wt hook show (paged), and the wt hook --dry-run preview
|
|
"commands/hook_commands.rs",
|
|
// The statusline itself — a single line a shell prompt or Claude Code captures
|
|
"commands/statusline.rs",
|
|
// The --format=json answer for every command that has one
|
|
"output/json.rs",
|
|
// The doc entry points (--help-page, --help-md, --help-description) and
|
|
// --version: help text is the command's answer, on stdout by POSIX
|
|
// convention, and a generator or `version=$(wt --version)` reads it
|
|
"help.rs",
|
|
// wt --help / -h itself, whenever no pager takes the text (short help, no
|
|
// pager configured, stdout not a tty, or the pager failed to spawn)
|
|
"help_pager.rs",
|
|
// The mock commands the suite puts on PATH under names like gh and glab:
|
|
// this stdout is the mocked tool's output, read by the wt under test
|
|
"testing/mock_stub.rs",
|
|
];
|
|
|
|
/// Substrings that mark a line where the token is text rather than a call
|
|
/// (inside a string literal, a comment, or a reference to a test file)
|
|
const ALLOWED_LINE_PATTERNS: &[&str] = &[
|
|
// The `println!` inside llm.rs's SYNTHETIC_DIFF string literal is a line of
|
|
// the fake diff a commit-message prompt is built from, not a call
|
|
r#"println!("Hello, world!");"#,
|
|
];
|
|
|
|
/// No file under `src/` writes to stdout unless it is listed as a surface that
|
|
/// deliberately does. The scan covers the whole crate rather than just
|
|
/// `src/commands/`: `src/help.rs` carries the answer for `--help-page` and
|
|
/// `--version`, and while it was outside the scanned tree its std `println!`s
|
|
/// panicked on a closed consumer unnoticed.
|
|
#[test]
|
|
fn check_no_unexpected_stdout_writes() {
|
|
let project_root = env!("CARGO_MANIFEST_DIR");
|
|
let src_dir = Path::new(project_root).join("src");
|
|
|
|
// Forbidden tokens that write to stdout
|
|
let stdout_tokens = ["print!", "println!"];
|
|
|
|
let mut violations = Vec::new();
|
|
|
|
// Recursively scan all .rs files under src/
|
|
scan_directory(&src_dir, &stdout_tokens, &mut violations, &src_dir);
|
|
|
|
if !violations.is_empty() {
|
|
panic!(
|
|
"Unexpected stdout writes:\n\n{}\n\n\
|
|
stdout is reserved for data output (JSON, tables).\n\
|
|
Use worktrunk::styling::println for stdout, and its eprintln — not color_print's ceprintln! — for stderr.\n\
|
|
Add file path to STDOUT_ALLOWED_PATHS if stdout is intentional.",
|
|
violations.join("\n")
|
|
);
|
|
}
|
|
}
|
|
|
|
fn scan_directory(dir: &Path, tokens: &[&str], violations: &mut Vec<String>, scan_root: &Path) {
|
|
let entries = match fs::read_dir(dir) {
|
|
Ok(e) => e,
|
|
Err(_) => return,
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
|
|
if path.is_dir() {
|
|
scan_directory(&path, tokens, violations, scan_root);
|
|
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
|
|
check_file(&path, tokens, violations, scan_root);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check_file(path: &Path, tokens: &[&str], violations: &mut Vec<String>, scan_root: &Path) {
|
|
// Get path relative to src/ for matching against STDOUT_ALLOWED_PATHS
|
|
let relative_path = path
|
|
.strip_prefix(scan_root)
|
|
.map(|p| p.to_slash_lossy())
|
|
.unwrap_or_default();
|
|
|
|
// Skip files that are allowed to use stdout
|
|
if STDOUT_ALLOWED_PATHS.contains(&relative_path.as_ref()) {
|
|
return;
|
|
}
|
|
|
|
let contents = match fs::read_to_string(path) {
|
|
Ok(c) => c,
|
|
Err(_) => return,
|
|
};
|
|
|
|
let relative_path = path
|
|
.strip_prefix(env!("CARGO_MANIFEST_DIR"))
|
|
.unwrap_or(path)
|
|
.display();
|
|
|
|
for (line_num, line) in contents.lines().enumerate() {
|
|
// Skip lines with allowed patterns
|
|
if ALLOWED_LINE_PATTERNS
|
|
.iter()
|
|
.any(|pattern| line.contains(pattern))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for token in tokens {
|
|
if let Some(pos) = line.find(token) {
|
|
// The token also sits inside the stderr macros eprint!/eprintln!,
|
|
// which are safe. Walk back over the identifier the token ends
|
|
// and let that prefix through. color_print's ceprint!/ceprintln!
|
|
// write through std's stderr directly, bypassing anstream's
|
|
// resolved color choice, so they stay violations — as does a
|
|
// longer identifier like some_eprintln!.
|
|
let ident_start = line[..pos]
|
|
.char_indices()
|
|
.rev()
|
|
.find(|(_, c)| !c.is_alphanumeric() && *c != '_')
|
|
.map_or(0, |(i, c)| i + c.len_utf8());
|
|
if &line[ident_start..pos] == "e" {
|
|
continue;
|
|
}
|
|
|
|
// Skip if the token is in a comment
|
|
if let Some(comment_pos) = line.find("//")
|
|
&& comment_pos < pos
|
|
{
|
|
continue;
|
|
}
|
|
|
|
violations.push(format!(
|
|
"{}:{}: {}",
|
|
relative_path,
|
|
line_num + 1,
|
|
line.trim()
|
|
));
|
|
break; // Only report once per line
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Paths (relative to `src/`) allowed to reach std's `eprint!`/`eprintln!`.
|
|
///
|
|
/// Every other file that writes to stderr must have the macro of that name in
|
|
/// scope from `worktrunk::styling`, or qualify the call — see
|
|
/// [`check_stderr_macros_come_from_styling`].
|
|
///
|
|
/// An entry exempts the **whole file**, not the call its comment names, so an
|
|
/// entry added for one narrow site also covers whatever that file grows later.
|
|
/// Keep the list to files where std's macro is right throughout.
|
|
const STD_STDERR_ALLOWED_PATHS: &[&str] = &[
|
|
// Relays a stub's captured stderr verbatim; the bytes are the fixture's
|
|
// data, so stripping their escapes would change what the test replays.
|
|
"testing/mock_stub.rs",
|
|
// A `#[cfg(test)]` skip diagnostic, not narration a user ever redirects.
|
|
"remove_dir.rs",
|
|
];
|
|
|
|
/// Narration on stderr goes out through anstream, at every site.
|
|
///
|
|
/// `worktrunk::styling`'s `eprint!`/`eprintln!` are anstream's and strip ANSI
|
|
/// when stderr isn't a terminal; std's prelude macros of the same name keep it.
|
|
/// The two are one import apart and indistinguishable at the call site, so a
|
|
/// file that imported `eprintln` but not `eprint` printed a warning and the
|
|
/// hint beneath it through different printers — one line of `wt list 2>log`
|
|
/// carrying escapes and the next not.
|
|
///
|
|
/// No runtime test can cover this: it is a property of every stderr write in
|
|
/// the binary, and the suite's own `CLICOLOR_FORCE=1` forces color on both
|
|
/// printers, so a snapshot agrees no matter which macro is in scope. Hence a
|
|
/// static scan. A call may satisfy it either way — importing the macro from
|
|
/// `styling`, or qualifying the call as `styling::eprintln!(…)`.
|
|
#[test]
|
|
fn check_stderr_macros_come_from_styling() {
|
|
let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
|
let mut violations = Vec::new();
|
|
scan_stderr_macros(&src_dir, &src_dir, &mut violations);
|
|
violations.sort();
|
|
|
|
assert!(
|
|
violations.is_empty(),
|
|
"stderr writes that resolve to std's macro instead of anstream's:\n\n{}\n\n\
|
|
std's `eprint!`/`eprintln!` keep ANSI escapes when stderr is redirected, so a\n\
|
|
file mixing them with anstream's emits color on some lines of a log and not others.\n\
|
|
Import the macro from `worktrunk::styling`, qualify the call as\n\
|
|
`worktrunk::styling::eprintln!(…)`, or add the file to STD_STDERR_ALLOWED_PATHS\n\
|
|
with a comment explaining why std's macro is the right one there.",
|
|
violations.join("\n")
|
|
);
|
|
}
|
|
|
|
fn scan_stderr_macros(dir: &Path, src_dir: &Path, violations: &mut Vec<String>) {
|
|
let entries = match fs::read_dir(dir) {
|
|
Ok(e) => e,
|
|
Err(_) => return,
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
scan_stderr_macros(&path, src_dir, violations);
|
|
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
|
|
check_stderr_macros_in_file(&path, src_dir, violations);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check_stderr_macros_in_file(path: &Path, src_dir: &Path, violations: &mut Vec<String>) {
|
|
let relative_path = path
|
|
.strip_prefix(src_dir)
|
|
.map(|p| p.to_slash_lossy())
|
|
.unwrap_or_default();
|
|
if STD_STDERR_ALLOWED_PATHS.contains(&relative_path.as_ref()) {
|
|
return;
|
|
}
|
|
|
|
let contents = match fs::read_to_string(path) {
|
|
Ok(c) => c,
|
|
Err(_) => return,
|
|
};
|
|
let imported = styling_imports(&contents);
|
|
|
|
for (line_num, line) in contents.lines().enumerate() {
|
|
// A doc comment quoting `eprintln!` is prose, not a write.
|
|
let code = match line.find("//") {
|
|
Some(pos) => &line[..pos],
|
|
None => line,
|
|
};
|
|
|
|
for macro_name in ["eprint", "eprintln"] {
|
|
if imported.contains(macro_name) {
|
|
continue;
|
|
}
|
|
let token = format!("{macro_name}!");
|
|
for (pos, _) in code.match_indices(&token) {
|
|
// `eprintln!` never matches the `eprint!` token — the `!` is
|
|
// part of it — so only a leading identifier char can be a
|
|
// false positive here.
|
|
let preceded_by_ident = pos > 0
|
|
&& (code.as_bytes()[pos - 1].is_ascii_alphanumeric()
|
|
|| code.as_bytes()[pos - 1] == b'_');
|
|
// A qualified `styling::eprintln!(…)` picks anstream's
|
|
// regardless of what the file imported.
|
|
if preceded_by_ident || code[..pos].ends_with("styling::") {
|
|
continue;
|
|
}
|
|
violations.push(format!(
|
|
"src/{relative_path}:{}: {}",
|
|
line_num + 1,
|
|
line.trim()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Names a file imports from `worktrunk::styling` / `crate::styling`.
|
|
///
|
|
/// Only `use` statements count. A qualified call site (`styling::eprintln!(…)`)
|
|
/// carries the same `styling::` prefix but binds nothing, so counting it would
|
|
/// let a file's *bare* calls silently fall through to std's macro.
|
|
///
|
|
/// **Only *top-level* `use` statements count** — ones at column 0. The caller
|
|
/// tests coverage per file while Rust resolves imports per scope, so a
|
|
/// function-local `use worktrunk::styling::eprintln;` would otherwise mark
|
|
/// every other function in the file as covered when none of them is. Every
|
|
/// `eprint`/`eprintln` import in `src/` is top-level, so the narrower rule
|
|
/// costs nothing; a future function-local one fails this scan and gets
|
|
/// hoisted. (Function-local styling imports of *other* names do exist —
|
|
/// `commands/remove.rs`, `commands/configure_shell.rs` — so a caller that
|
|
/// checked a third macro could not assume the same.)
|
|
fn styling_imports(contents: &str) -> HashSet<String> {
|
|
let mut imports = HashSet::new();
|
|
let mut statement = String::new();
|
|
|
|
for line in contents.lines() {
|
|
if statement.is_empty() && !(line.starts_with("use ") || line.starts_with("pub use ")) {
|
|
continue;
|
|
}
|
|
let trimmed = line.trim();
|
|
statement.push_str(trimmed);
|
|
if !trimmed.ends_with(';') {
|
|
continue;
|
|
}
|
|
|
|
// `use worktrunk::styling::{a, b as c};` or `use crate::styling::a;`
|
|
if let Some(pos) = statement.find("styling::") {
|
|
let tail = &statement[pos + "styling::".len()..];
|
|
let names = match tail.strip_prefix('{') {
|
|
Some(braced) => braced.split_once('}').map(|(inner, _)| inner).unwrap_or(""),
|
|
// The single-import form ends at the statement's `;`.
|
|
None => tail.trim_end_matches(';'),
|
|
};
|
|
for name in names.split(',') {
|
|
// `foo as bar` binds `bar`, which is not the macro this checks.
|
|
let name = name.trim().split(" as ").next().unwrap_or("").trim();
|
|
if !name.is_empty() {
|
|
imports.insert(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
statement.clear();
|
|
}
|
|
|
|
imports
|
|
}
|
|
|
|
/// Every stdout surface exits cleanly when its consumer stops reading.
|
|
///
|
|
/// std's `print!`/`println!` panic on a `BrokenPipe`, so a command whose
|
|
/// answer went out through them died with exit 101 and a Rust panic message
|
|
/// the moment the reader left — `wt list | head -3`, and `wt list statusline`
|
|
/// on the surface a shell prompt runs on every redraw. anstream's macros drop
|
|
/// that error instead, and they now carry every answer this binary writes to
|
|
/// stdout.
|
|
///
|
|
/// How a surface resolves color — the process-global `ColorChoice` or tty
|
|
/// detection — is a separate axis, so the cases below span both.
|
|
///
|
|
/// The child's stdout pipe is closed before it is waited on, so its first
|
|
/// write has no reader. `--version` writes a few dozen bytes and still trips
|
|
/// it: `EPIPE` is about whether a reader is attached, not about filling the
|
|
/// pipe buffer.
|
|
#[rstest]
|
|
fn test_stdout_surfaces_survive_a_closed_consumer(repo: TestRepo) {
|
|
for args in [
|
|
&["--version"][..],
|
|
&["merge", "--help-page"][..],
|
|
&["merge", "--help-description"][..],
|
|
&["merge", "--help-md"][..],
|
|
&["list"][..],
|
|
&["list", "--full"][..],
|
|
&["list", "statusline"][..],
|
|
&["list", "--format=json"][..],
|
|
&["config", "update", "--print"][..],
|
|
] {
|
|
let mut command = repo.wt_command();
|
|
let mut child = command
|
|
.args(args)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap_or_else(|e| panic!("failed to spawn wt {args:?}: {e}"));
|
|
|
|
drop(child.stdout.take().expect("stdout was piped"));
|
|
|
|
let output = child
|
|
.wait_with_output()
|
|
.unwrap_or_else(|e| panic!("failed to wait for wt {args:?}: {e}"));
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"wt {args:?} exited {:?} with its consumer gone; stderr: {stderr}",
|
|
output.status.code()
|
|
);
|
|
assert!(
|
|
!stderr.contains("panicked"),
|
|
"wt {args:?} panicked with its consumer gone; stderr: {stderr}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Narration on stderr strips its color when stderr isn't a terminal.
|
|
///
|
|
/// The stdout rule above has a stderr counterpart, and it is the same mistake:
|
|
/// `worktrunk::styling`'s `eprint!` is anstream's and strips ANSI off a
|
|
/// non-tty, std's prelude macro of the same name keeps it. A file that
|
|
/// imported `eprintln` from `styling` but not `eprint` got one of each, so a
|
|
/// deprecation warning and the hint printed directly beneath it disagreed
|
|
/// about whether `wt list 2>log` should carry escapes.
|
|
///
|
|
/// The suite's own `CLICOLOR_FORCE=1` is what hides this — it forces color on
|
|
/// both printers, so every snapshot agrees no matter which macro is in scope.
|
|
/// This test clears it and sets `NO_COLOR`, which only anstream honors.
|
|
///
|
|
/// The trigger is a deprecated `[ci]` block, whose warning is emitted by the
|
|
/// pre-deserialization deprecation pass on every command that loads config.
|
|
#[rstest]
|
|
fn test_stderr_narration_strips_ansi_when_piped(repo: TestRepo) {
|
|
repo.write_project_config("[ci]\nplatform = \"github\"\n");
|
|
|
|
let output = repo
|
|
.wt_command()
|
|
.arg("list")
|
|
.env_remove("CLICOLOR_FORCE")
|
|
.env("NO_COLOR", "1")
|
|
.output()
|
|
.expect("failed to run wt list");
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("deprecated"),
|
|
"expected the [ci] deprecation warning on stderr; got: {stderr:?}"
|
|
);
|
|
assert!(
|
|
!stderr.contains('\x1b'),
|
|
"wt narration redirected to a file must not contain ANSI escapes; got: {stderr:?}"
|
|
);
|
|
}
|
|
|
|
/// Whether stdout carries ANSI escapes is the consumer's call, except where a
|
|
/// command declares otherwise.
|
|
///
|
|
/// Color resolves in one place — anstream's `AutoStream::choice`: the
|
|
/// process-global `ColorChoice` a command may write first, then tty detection
|
|
/// and the `NO_COLOR` / `CLICOLOR_FORCE` conventions. The rest of the suite
|
|
/// runs with `CLICOLOR_FORCE=1` (`STATIC_TEST_ENV_VARS`), which settles that
|
|
/// question before anstream reaches the interesting part, so no other test can
|
|
/// see which way it decides — the statusline losing its escapes to a strip was
|
|
/// invisible to a green suite. These cases clear both variables and read a
|
|
/// piped stdout, the environment a real consumer provides, and are the one
|
|
/// place the unforced contract is pinned.
|
|
#[rstest]
|
|
fn test_color_follows_the_consumer(repo: TestRepo) {
|
|
// (args, extra env, stdout carries ANSI escapes, what the case pins)
|
|
for (args, env, expect_escapes, pins) in [
|
|
(
|
|
&["list", "statusline"][..],
|
|
None,
|
|
true,
|
|
"the statusline declares Always because its consumer renders the escapes",
|
|
),
|
|
(
|
|
&["list", "statusline"][..],
|
|
Some(("NO_COLOR", "1")),
|
|
true,
|
|
"a declared Always outranks NO_COLOR, as --color=always conventionally does",
|
|
),
|
|
(
|
|
&["merge", "--help-page"][..],
|
|
None,
|
|
true,
|
|
"the web reference block's ANSI is data the docs pipeline turns into HTML spans",
|
|
),
|
|
(
|
|
&["merge", "--help-page", "--plain"][..],
|
|
None,
|
|
false,
|
|
"plain pages are the portable markdown skill reference files read",
|
|
),
|
|
(
|
|
&["list"][..],
|
|
None,
|
|
false,
|
|
"human output strips when it is piped rather than displayed",
|
|
),
|
|
(
|
|
&["list"][..],
|
|
Some(("CLICOLOR_FORCE", "1")),
|
|
true,
|
|
"force-on reaches a piped consumer that wants the color anyway",
|
|
),
|
|
] {
|
|
let mut command = repo.wt_command();
|
|
command
|
|
.args(args)
|
|
// The fixture forces color on for every other test; removing both
|
|
// conventions is what leaves the decision to anstream.
|
|
.env_remove("CLICOLOR_FORCE")
|
|
.env_remove("NO_COLOR");
|
|
if let Some((key, value)) = env {
|
|
command.env(key, value);
|
|
}
|
|
|
|
// `output()` pipes stdout, so the child's consumer is not a terminal.
|
|
let output = command
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("failed to run wt {args:?}: {e}"));
|
|
|
|
let case = match env {
|
|
Some((key, value)) => format!("wt {} with {key}={value}", args.join(" ")),
|
|
None => format!("wt {}", args.join(" ")),
|
|
};
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"{case} exited {:?}; stderr: {stderr}",
|
|
output.status.code()
|
|
);
|
|
// Without this an empty answer would satisfy every strip case.
|
|
assert!(
|
|
!stdout.is_empty(),
|
|
"{case} wrote nothing to stdout; stderr: {stderr}"
|
|
);
|
|
assert_eq!(
|
|
stdout.contains("\u{1b}["),
|
|
expect_escapes,
|
|
"{case}: expected stdout to carry ANSI escapes: {expect_escapes} — {pins}"
|
|
);
|
|
}
|
|
}
|