mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
0b27755726
Three developer entry points — `--help-page`, `--help-description`, and
`--print-schema` — each carried its own copy of a scan that picked the
subcommand out of argv by rejecting entries that looked like the binary:
```rust
.filter(|a| *a != "--help-page" && !a.starts_with('-') && !a.ends_with("/wt"))
.find(|a| !a.contains("target/") && *a != "wt")
```
Neither `ends_with("/wt")` nor `contains("target/")` matches `wt.exe`
under a backslash path, so on every Windows install all three read the
binary's own path as the command. `--print-schema` exited 2; the other
two exited 0 with empty stdout and `Unknown command: C:\...\wt.exe` on
stderr. The scan also took the value of a value-carrying global as the
command, so `wt -C <path> list --print-schema` answered `No JSON schema
for '<path>'`.
clap had already parsed the same argv one call earlier.
`parse_early_globals` runs the real `Cli` definition with
`ignore_errors(true)` and reads `matches.subcommand()` to pick the
alias-splice context, and the comment above it already said that exists
"so the splice path in `augment_help` has no separate arg scanner". The
doc entry points just never asked it. It now carries the name in an
`EarlyGlobals` struct, and the three handlers take `Option<&str>`
instead of `&[String]`.
Error messages are unchanged: `src/cli/mod.rs` has
`#[command(external_subcommand)]` for user aliases, so a name the `Cli`
definition doesn't declare still arrives intact and can be named back in
`Unknown command: X`.
`--print-schema` now prints through `crate::output::print_json` rather
than `serde_json::to_string_pretty` plus std's `println!`. std's panics
with exit 101 when a consumer closes the pipe; anstream's drops the
`BrokenPipe`. This was the last JSON-to-stdout surface still panicking
after #3746 — the schema landed via #3747 while that PR was in flight,
and it lives outside `src/commands/`, where #3746's stdout guard doesn't
scan.
<details>
<summary>Why CI never caught this</summary>
`tests/integration_tests/help.rs` and
`tests/integration_tests/readme_sync.rs` are both
`#![cfg(not(windows))]`, and on Unix the test binary is always named
`wt` under `target/`, which is exactly the shape the scan was written
against. The new test drives all four cases through a symlink named
`wt.exe` — a symlink rather than a copy because the debug binary is
67MB. Verified to fail against the pre-change code.
</details>
`wt merge --help-page` and `wt merge --help-description` still panic on
a closed pipe. Those use std's `println!`/`print!` deliberately, to
preserve the ANSI codes anstream's would strip, so they need a different
mechanism than this one. Left for a follow-up.
> _This was written by Claude Code on behalf of max-sixty_
432 lines
16 KiB
Rust
432 lines
16 KiB
Rust
//! Snapshot tests for `-h` (short) and `--help` (long) output.
|
|
//!
|
|
//! These ensure our help formatting stays stable across releases and
|
|
//! catches accidental regressions in wording or wrapping.
|
|
//!
|
|
//! - Short help (`-h`): Compact format, single-line options
|
|
//! - Long help (`--help`): Verbose format with `after_long_help` content
|
|
//!
|
|
//! Skipped on Windows: clap renders markdown differently on Windows (tables, links,
|
|
//! emphasis) resulting in formatting-only differences. The help content is identical;
|
|
//! only the presentation varies.
|
|
#![cfg(not(windows))]
|
|
|
|
use crate::common::{add_standard_env_redactions, configure_cli_command, wt_bin, wt_command};
|
|
use ansi_str::AnsiStr as _;
|
|
use insta::Settings;
|
|
use insta_cmd::assert_cmd_snapshot;
|
|
use rstest::rstest;
|
|
use std::process::Command;
|
|
|
|
/// Insta settings shared by every help / version / usage snapshot: the
|
|
/// standard env redactions plus the snapshot path. Routing all of them through
|
|
/// one builder is deliberate — a test that constructs its own `Settings` and
|
|
/// forgets `add_standard_env_redactions` leaks host-specific env (e.g.
|
|
/// `LLVM_PROFILE_FILE`'s temp path) into the recorded `env:` block, which then
|
|
/// diffs whenever the snapshot is regenerated on another machine. Callers layer
|
|
/// extra filters (e.g. the version-string filter) on the returned value.
|
|
fn help_settings() -> Settings {
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("../snapshots");
|
|
add_standard_env_redactions(&mut settings);
|
|
settings
|
|
}
|
|
|
|
fn snapshot_help(test_name: &str, args: &[&str]) {
|
|
let settings = help_settings();
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
cmd.args(args);
|
|
|
|
// Check for double blank lines before snapshotting.
|
|
// Double blanks indicate formatting issues (e.g., HTML comments like
|
|
// `<!-- demo: file.gif -->` with blank lines on both sides).
|
|
let output = cmd.output().expect("failed to run command");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
!stdout.contains("\n\n\n"),
|
|
"Double blank line in help output for `wt {}`",
|
|
args.join(" ")
|
|
);
|
|
|
|
// Re-run for snapshot (assert_cmd_snapshot needs the Command)
|
|
let mut cmd = wt_command();
|
|
cmd.args(args);
|
|
assert_cmd_snapshot!(test_name, cmd);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_help_describes_exact_shape_no_rebase() {
|
|
let output = wt_command()
|
|
.args(["merge", "--help"])
|
|
.output()
|
|
.expect("failed to run wt merge --help");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stdout = stdout.ansi_strip();
|
|
assert!(
|
|
stdout.contains("Skip rebase; require the target to fast-forward to the resulting tip"),
|
|
"missing graph-preservation contract:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("wt merge --no-commit --no-rebase"),
|
|
"missing exact-shape example:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("explicit --no-rebase preserves the graph produced by earlier steps"),
|
|
"missing no-ff qualification:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
// Root command (wt)
|
|
#[rstest]
|
|
#[case("help_root_short", "-h")]
|
|
#[case("help_root_long", "--help")]
|
|
#[case("help_no_args", "")]
|
|
// Major commands - short and long variants
|
|
#[case("help_config_short", "config -h")]
|
|
#[case("help_config_long", "config --help")]
|
|
#[case("help_list_short", "list -h")]
|
|
#[case("help_list_long", "list --help")]
|
|
#[case("help_switch_short", "switch -h")]
|
|
#[case("help_switch_long", "switch --help")]
|
|
#[case("help_remove_short", "remove -h")]
|
|
#[case("help_remove_long", "remove --help")]
|
|
#[case("help_merge_short", "merge -h")]
|
|
#[case("help_merge_long", "merge --help")]
|
|
#[case("help_step_short", "step -h")]
|
|
#[case("help_step_long", "step --help")]
|
|
#[case("help_step_promote", "step promote --help")]
|
|
#[case("help_step_copy_ignored", "step copy-ignored --help")]
|
|
// Config subcommands (long help only - these are less frequently accessed)
|
|
#[case("help_config_shell", "config shell --help")]
|
|
#[case("help_config_create", "config create --help")]
|
|
#[case("help_config_show", "config show --help")]
|
|
#[case("help_config_plugins", "config plugins --help")]
|
|
#[case("help_config_plugins_codex", "config plugins codex --help")]
|
|
#[case(
|
|
"help_config_plugins_codex_install",
|
|
"config plugins codex install --help"
|
|
)]
|
|
#[case("help_config_state", "config state --help")]
|
|
#[case("help_config_state_cache", "config state cache --help")]
|
|
#[case(
|
|
"help_config_state_default_branch",
|
|
"config state default-branch --help"
|
|
)]
|
|
#[case(
|
|
"help_config_state_previous_branch",
|
|
"config state previous-branch --help"
|
|
)]
|
|
#[case("help_config_state_ci_status", "config state ci-status --help")]
|
|
#[case("help_config_state_marker", "config state marker --help")]
|
|
#[case("help_config_state_logs", "config state logs --help")]
|
|
#[case("help_config_state_logs_profile", "config state logs profile --help")]
|
|
#[case("help_config_state_get", "config state get --help")]
|
|
#[case("help_config_state_clear", "config state clear --help")]
|
|
#[case("help_config_approvals", "config approvals --help")]
|
|
#[case("help_config_approvals_add", "config approvals add --help")]
|
|
#[case("help_config_approvals_clear", "config approvals clear --help")]
|
|
fn test_help(#[case] test_name: &str, #[case] args_str: &str) {
|
|
let args: Vec<&str> = if args_str.is_empty() {
|
|
vec![]
|
|
} else {
|
|
args_str.split_whitespace().collect()
|
|
};
|
|
snapshot_help(test_name, &args);
|
|
}
|
|
|
|
#[test]
|
|
fn test_version() {
|
|
let mut settings = help_settings();
|
|
// Filter out version number for stable snapshots
|
|
// Formats:
|
|
// - wt v0.4.0-25-gc9bcf6c0 (version with git commit info)
|
|
// - wt 7df940e (just git short hash in CI)
|
|
// - wt v0.4.0-dirty or wt 7df940e-dirty (uncommitted changes)
|
|
settings.add_filter(
|
|
r"wt (v\d+\.\d+\.\d+(-[\w.-]+)?|[a-f0-9]{7,40}(?:-dirty)?)",
|
|
"wt [VERSION]",
|
|
);
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
cmd.arg("--version");
|
|
assert_cmd_snapshot!("version", cmd);
|
|
});
|
|
}
|
|
|
|
/// `--help` must write to stdout, not stderr. POSIX convention — matches
|
|
/// `cargo`, `curl`, `python`, and `git <cmd> -h`. Lets users do
|
|
/// `wt --help | less` or `wt --help > help.txt` without redirection.
|
|
#[test]
|
|
fn test_help_goes_to_stdout() {
|
|
for args in [&["--help"][..], &["-h"][..], &["merge", "--help"][..]] {
|
|
let output = wt_command()
|
|
.args(args)
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("failed to run wt {args:?}: {e}"));
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
assert!(
|
|
stdout.contains("Usage:"),
|
|
"wt {args:?} should write help to stdout, but stdout was: {stdout:?} (stderr: {stderr:?})"
|
|
);
|
|
assert!(
|
|
stderr.trim().is_empty(),
|
|
"wt {args:?} should not write to stderr, but stderr was: {stderr:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// When stdout is piped, help must be plain text — no ANSI escapes leaking into
|
|
/// `wt --help > file.txt` or `wt --help | less`. Built from `wt_command()` for
|
|
/// HOME/config isolation, then clears the `CLICOLOR_FORCE` it sets so color
|
|
/// detection sees a non-tty (piped) stdout.
|
|
#[test]
|
|
fn test_help_strips_ansi_when_piped() {
|
|
let output = wt_command()
|
|
.arg("--help")
|
|
.env_remove("CLICOLOR_FORCE")
|
|
.env("NO_COLOR", "1")
|
|
.output()
|
|
.expect("failed to run wt --help");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
!stdout.contains('\x1b'),
|
|
"wt --help piped to a file must not contain ANSI escapes; got: {stdout:?}"
|
|
);
|
|
}
|
|
|
|
/// `--version` must write to stdout, not stderr. This is the POSIX convention
|
|
/// and what scripts expect — e.g., `version=$(wt --version)` or test harnesses
|
|
/// that grep for a version string from stdout. See #2072.
|
|
#[test]
|
|
fn test_version_goes_to_stdout() {
|
|
let output = wt_command()
|
|
.arg("--version")
|
|
.output()
|
|
.expect("failed to run wt --version");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
assert!(
|
|
stdout.contains("wt "),
|
|
"wt --version should write to stdout, but stdout was: {stdout:?} (stderr: {stderr:?})"
|
|
);
|
|
assert!(
|
|
stderr.trim().is_empty(),
|
|
"wt --version should not write to stderr, but stderr was: {stderr:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_help_md() {
|
|
help_settings().bind(|| {
|
|
let mut cmd = wt_command();
|
|
cmd.args(["--help-md"]);
|
|
assert_cmd_snapshot!("help_md_root", cmd);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_help_md_subcommand() {
|
|
help_settings().bind(|| {
|
|
let mut cmd = wt_command();
|
|
cmd.args(["merge", "--help-md"]);
|
|
assert_cmd_snapshot!("help_md_merge", cmd);
|
|
});
|
|
}
|
|
|
|
/// Verifies width handling when help is narrower than its content:
|
|
/// - Markdown tables stay intact (no mid-row breaks), extending past 80 columns.
|
|
/// - Captured `wt list` example tables (`<!-- wt list … -->` blocks) are chopped
|
|
/// to width with an ellipsis — like real `wt list` — instead of word-wrapping,
|
|
/// while hand-authored command sessions (the `jq` examples) still wrap.
|
|
#[test]
|
|
fn test_help_list_narrow_terminal() {
|
|
help_settings().bind(|| {
|
|
let mut cmd = wt_command();
|
|
cmd.env("COLUMNS", "80");
|
|
cmd.args(["list", "--help"]);
|
|
assert_cmd_snapshot!("help_list_narrow_80", cmd);
|
|
});
|
|
}
|
|
|
|
/// With no detectable width (piped output, no COLUMNS), `terminal_width()`
|
|
/// returns `None` and the markdown renderer falls back to its own defaults.
|
|
/// Back when "no width" was a `usize::MAX` sentinel, `wt list --help` panicked
|
|
/// with a capacity overflow trying to render a `---` rule that wide.
|
|
#[test]
|
|
fn test_help_without_detectable_width() {
|
|
let mut cmd = wt_command();
|
|
cmd.env_remove("COLUMNS");
|
|
cmd.args(["list", "--help"]);
|
|
let output = cmd.output().expect("failed to run command");
|
|
assert!(
|
|
output.status.success(),
|
|
"wt list --help without COLUMNS failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(!output.stdout.is_empty(), "help output should not be empty");
|
|
}
|
|
|
|
/// Tests --help-description outputs the meta description for docs frontmatter.
|
|
#[rstest]
|
|
#[case("switch", "Switch to a worktree; create if needed.")]
|
|
#[case(
|
|
"merge",
|
|
"Merge current branch into the target branch. Squash & rebase"
|
|
)]
|
|
#[case("hook", "Run configured hooks.")]
|
|
fn test_help_description(#[case] cmd: &str, #[case] expected_prefix: &str) {
|
|
let output = wt_command()
|
|
.args([cmd, "--help-description"])
|
|
.output()
|
|
.expect("failed to run wt --help-description");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.starts_with(expected_prefix),
|
|
"Expected description for '{cmd}' to start with '{expected_prefix}', got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_help_description_no_subcommand() {
|
|
let output = wt_command()
|
|
.args(["--help-description"])
|
|
.output()
|
|
.expect("failed to run wt --help-description");
|
|
|
|
// Exits 0 (eprintln + return, not process::exit(1))
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("Usage:"),
|
|
"Expected usage hint, got: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_help_description_unknown_command() {
|
|
let output = wt_command()
|
|
.args(["nonexistent", "--help-description"])
|
|
.output()
|
|
.expect("failed to run wt --help-description");
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("Unknown command"),
|
|
"Expected unknown command error, got: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// Tests that using a nested subcommand at the top level suggests the correct command.
|
|
///
|
|
/// When users type `wt squash` instead of `wt step squash`, or `wt pre-merge` instead
|
|
/// of `wt hook pre-merge`, they should get a helpful suggestion.
|
|
#[rstest]
|
|
#[case("nested_subcommand_step_squash", "squash", "wt step squash")]
|
|
#[case("nested_subcommand_step_commit", "commit", "wt step commit")]
|
|
#[case("nested_subcommand_hook_pre_merge", "pre-merge", "wt hook pre-merge")]
|
|
#[case("nested_subcommand_hook_pre_start", "pre-start", "wt hook pre-start")]
|
|
fn test_nested_subcommand_suggestion(
|
|
#[case] test_name: &str,
|
|
#[case] subcommand: &str,
|
|
#[case] expected_suggestion: &str,
|
|
) {
|
|
help_settings().bind(|| {
|
|
let mut cmd = wt_command();
|
|
cmd.arg(subcommand);
|
|
let output = cmd.output().expect("failed to run wt");
|
|
|
|
// Should fail (exit code 2)
|
|
assert_eq!(output.status.code(), Some(2));
|
|
|
|
// Should contain the suggestion
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains(expected_suggestion),
|
|
"Expected stderr to contain '{expected_suggestion}', got:\n{stderr}"
|
|
);
|
|
|
|
// Snapshot the full error output
|
|
assert_cmd_snapshot!(test_name, cmd);
|
|
});
|
|
}
|
|
|
|
/// `--print-schema` names one command's `--format=json` payload, so an
|
|
/// unrecognized or missing command is a usage error rather than a silent
|
|
/// empty document. `test_docs_are_in_sync` treats a non-zero exit as a sync
|
|
/// failure, which is what keeps a renamed command from quietly publishing
|
|
/// nothing.
|
|
#[test]
|
|
fn test_print_schema_rejects_unknown_command() {
|
|
for args in [&["--print-schema"][..], &["merge", "--print-schema"][..]] {
|
|
let output = wt_command()
|
|
.args(args)
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("failed to run wt {args:?}: {e}"));
|
|
|
|
assert_eq!(
|
|
output.status.code(),
|
|
Some(2),
|
|
"wt {args:?} should exit 2; stderr: {:?}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(
|
|
output.stdout.is_empty(),
|
|
"wt {args:?} should print no schema, but stdout was: {:?}",
|
|
String::from_utf8_lossy(&output.stdout)
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The doc-generation entry points take their command from the early clap
|
|
/// parse, so neither the binary's name nor a global option carrying a value
|
|
/// reaches the answer.
|
|
///
|
|
/// A hand-rolled argv scan recognized the binary by the shape of its path,
|
|
/// which made `wt.exe list --print-schema` — the name every Windows install
|
|
/// ships — a request for a command named after the binary, and read the
|
|
/// `<path>` in `wt -C <path> list --print-schema` as the command. The name is
|
|
/// exercised through a symlink because this module is Unix-only.
|
|
#[test]
|
|
fn test_dev_entry_points_take_command_from_clap() {
|
|
let dir = tempfile::tempdir().expect("create tempdir");
|
|
let renamed = dir.path().join("wt.exe");
|
|
std::os::unix::fs::symlink(wt_bin(), &renamed).expect("symlink wt binary");
|
|
|
|
for args in [
|
|
&["list", "--print-schema"][..],
|
|
&["merge", "--help-page"][..],
|
|
&["merge", "--help-description"][..],
|
|
&["-C", ".", "list", "--print-schema"][..],
|
|
] {
|
|
let mut cmd = Command::new(&renamed);
|
|
configure_cli_command(&mut cmd);
|
|
cmd.current_dir(dir.path());
|
|
let output = cmd
|
|
.args(args)
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("failed to run wt.exe {args:?}: {e}"));
|
|
|
|
assert_eq!(
|
|
output.status.code(),
|
|
Some(0),
|
|
"wt.exe {args:?} should succeed; stderr: {:?}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(
|
|
!output.stdout.is_empty(),
|
|
"wt.exe {args:?} printed nothing; stderr: {:?}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
}
|