Files
max-sixty__worktrunk/tests/integration_tests/custom.rs
Maximilian Roos dd453304ed test: fold the mock stub into the wt binary (#3712)
`cargo test --test integration` neither built nor rebuilt `mock-stub`: a
target filter deselects the dummy test that pulled it in, so a fresh
tree panicked ("mock-stub binary not found") and a warm one could run a
stale stub. The chain that compensated — the separate helper package,
its dummy `builds.rs`, the `default-members` entry, nextest's
experimental `build-bins` setup script, `workspace_bin()` — existed only
because cargo-dist ships every `[[bin]]`, and carried a TODO to collapse
once that changed. dist 0.30.2 does support per-binary exclusion now
(`[dist.binaries]`, since 0.29.0; verified with `dist plan`), but the
TODO's plan has a hole it predates: `cargo install worktrunk` installs
every feature-satisfied `[[bin]]`, and dist config doesn't govern
crates.io installs.

So the mock commands are now the `wt` binary itself. `mock_commands`
links `wt` under the mock's name (`gh`, `glab`, …), and `main()`
dispatches to the ported playback (`testing::mock_stub`) when
`WORKTRUNK_TEST_MOCK_CONFIG_DIR` is set, argv[0] is a foreign name, and
the config dir holds `<argv0>.json` for it. The existence check keeps wt
under a foreign argv[0] *without* a config being wt — the
argv0-validation security test symlinks it as `wt;touch` and must reach
wt's own rejection. The shipped binary already compiles the whole
`testing` module unconditionally, so this adds no new category of test
code to it. Windows links `wt.exe` with `hard_link` (copy fallback for
cross-drive dirs); the debug binary is ~67 MB, so per-mock copies stay
the fallback.

Every runner is now safe by construction — cargo rebuilds a package's
own binaries whenever its integration tests build, so there is no
separate artifact to go missing or stale. Deleted: the helper package,
the setup script plus `experimental = ["setup-scripts"]`, the
`default-members` trick, `workspace_bin()`, and `wt_bin()`'s dead
compile-time branch (unit-test targets get neither the runtime variable
nor the `option_env!` value, so the runtime resolution is the one
mechanism).

Validated locally: the pre-merge gate's full suite passes (4542/4542;
its doc step also caught unescaped `argv[0]` intra-doc links in the new
comments, fixed and `cargo doc -Dwarnings` re-verified). With all stub
artifacts purged from `target/`, plain `cargo test --test integration`
on mock-dependent tests builds them and passes — the command that used
to hit the trap. Two integration tests pin the dispatch's argv[0] edges
(an empty argv[0], and a non-UTF8 one), alongside the existing
`wt;touch` carve-out test.

The first commit is the investigation that preceded the fix: it verified
the `wt` binary itself was never subject to the staleness the mock-stub
was, and documented that in `tests/CLAUDE.md`; the fix then narrows that
paragraph further, since the gap it scoped no longer exists.

A two-reviewer subagent round (one prosecuting the diff against the
failure modes documented in the repo's own mock history — the #401/#407
Windows era, #547, #654, #127, #2544, #2730, #2744 — the other
adversarial) then hardened the dispatch. The reserved-name guard is
case-insensitive, matching the config probe, which goes through a
filesystem that equates `WT.json` with `wt.json` on macOS and Windows;
`command_name()` reads `args_os` — `env::args()` panics on a non-Unicode
argument, and this runs inside `main()` on every invocation (caught by
the tend review) — and returns `None` for a degenerate argv[0] instead
of panicking; the `.exe` suffix is stripped explicitly rather than via
`file_stem`, so a dotted mock name (`python3.11`) resolves identically
on every platform; and `copy_mock_binary` is now private —
`MockConfig::write` writes `<name>.json` before linking and is the only
way to create a mock, so a link cannot exist without its config, and the
dispatch's missing-config fall-through can only mean "wt under a foreign
name" (the argv0-validation tests' `wt;touch`), never a half-configured
mock that silently runs real wt with the mocked tool's arguments. The
`Option<&str>` mock helpers whose `None` arm produced exactly such
configless links lost the arm (every caller passed `Some`), and 25
redundant standalone link calls went with it.

The review also surfaced the one remaining spawn-a-stale-binary path
outside the suite: `wt-perf timeline` resolved a sibling `wt` by path,
checked only existence, and told the user to build it manually — so
`cargo run -p wt-perf -- timeline` after a `src/` edit silently measured
stale code. It now builds `wt` first and takes the artifact path from
cargo's `--message-format=json` report rather than deriving a sibling
location, so target-dir and profile overrides can't divert the build
away from where it's resolved; a release wt-perf builds and measures a
release wt. The build runs before the timeline's wall-clock measurement
starts, cargo's progress streams on stderr, and stdout keeps the
`--chrome` JSON contract.

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 22:12:21 -07:00

300 lines
10 KiB
Rust

//! Tests for git-style custom subcommand dispatch (`wt-<name>`).
use crate::common::{
mock_commands::{MockConfig, MockResponse},
wt_command,
};
use ansi_str::AnsiStr;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
/// Prepend `dir` to PATH on the given command.
fn prepend_path(cmd: &mut Command, dir: &Path) {
let (path_var, current) = std::env::vars_os()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(k, v)| (k.to_string_lossy().into_owned(), Some(v)))
.unwrap_or(("PATH".to_string(), None));
let mut paths: Vec<PathBuf> = current
.as_deref()
.map(|p| std::env::split_paths(p).collect())
.unwrap_or_default();
paths.insert(0, dir.to_path_buf());
let new_path = std::env::join_paths(&paths).unwrap();
cmd.env(path_var, new_path);
}
/// Create a mock `wt-<name>` binary in a temp dir, and return the dir.
fn mock_bin_dir(name: &str, response: MockResponse) -> TempDir {
let dir = TempDir::new().unwrap();
MockConfig::new(name)
.command("_default", response)
.write(dir.path());
dir
}
#[test]
fn custom_subcommand_runs_wt_prefixed_binary_on_path() {
// `wt wt-test-extcmd-ok` should find `wt-wt-test-extcmd-ok` on PATH.
// We use a deliberately unique name so host PATH pollution doesn't match.
let dir = mock_bin_dir("wt-wt-test-extcmd-ok", MockResponse::output("custom ran\n"));
let mut cmd = wt_command();
prepend_path(&mut cmd, dir.path());
cmd.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", dir.path());
cmd.args(["wt-test-extcmd-ok", "arg1", "arg2"]);
let output = cmd.output().expect("failed to run wt");
assert!(
output.status.success(),
"expected success, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "custom ran");
}
#[cfg(unix)]
#[test]
fn custom_subcommand_accepts_non_utf8_forwarded_arg() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let script = dir.path().join("wt-wt-test-extcmd-raw");
std::fs::write(&script, "#!/bin/sh\nprintf 'custom ran\\n'\n").unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
let mut cmd = wt_command();
prepend_path(&mut cmd, dir.path());
cmd.arg("wt-test-extcmd-raw")
.arg(OsString::from_vec(vec![0xff]));
let output = cmd.output().expect("failed to run wt");
assert!(
output.status.success(),
"expected success, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "custom ran");
}
#[cfg(unix)]
#[test]
fn custom_subcommand_scrubs_retired_directive_and_preserves_split_files() {
use std::os::unix::fs::PermissionsExt;
use worktrunk::shell_exec::{
DIRECTIVE_CD_FILE_ENV_VAR, DIRECTIVE_EXEC_FILE_ENV_VAR, RETIRED_DIRECTIVE_FILE_ENV_VAR,
};
let dir = TempDir::new().unwrap();
let retired_file = dir.path().join("retired");
let cd_file = dir.path().join("cd");
let exec_file = dir.path().join("exec");
std::fs::write(&retired_file, "").unwrap();
std::fs::write(&cd_file, "").unwrap();
std::fs::write(&exec_file, "").unwrap();
let script = dir.path().join("wt-wt-test-extcmd-directives");
std::fs::write(
&script,
r#"#!/bin/sh
if [ -n "${WORKTRUNK_DIRECTIVE_FILE+x}" ]; then
printf 'retired write\n' >> "$WORKTRUNK_DIRECTIVE_FILE"
fi
printf 'retired=%s\ncd=%s\nexec=%s\n' "${WORKTRUNK_DIRECTIVE_FILE-unset}" "${WORKTRUNK_DIRECTIVE_CD_FILE-unset}" "${WORKTRUNK_DIRECTIVE_EXEC_FILE-unset}"
"#,
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut cmd = wt_command();
prepend_path(&mut cmd, dir.path());
cmd.env(RETIRED_DIRECTIVE_FILE_ENV_VAR, &retired_file)
.env(DIRECTIVE_CD_FILE_ENV_VAR, &cd_file)
.env(DIRECTIVE_EXEC_FILE_ENV_VAR, &exec_file)
.arg("wt-test-extcmd-directives");
let output = cmd.output().expect("failed to run wt");
assert!(
output.status.success(),
"expected success, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("retired=unset"), "{stdout}");
assert!(
stdout.contains(&format!("cd={}", cd_file.display())),
"{stdout}"
);
assert!(
stdout.contains(&format!("exec={}", exec_file.display())),
"{stdout}"
);
assert_eq!(
std::fs::read_to_string(&retired_file).unwrap(),
"",
"the retired directive file must remain untouched"
);
}
#[test]
fn custom_subcommand_not_found_prints_clap_error() {
let mut cmd = wt_command();
// Clear PATH so no `wt-*` binaries can be discovered, then add a single
// empty dir so `which` has somewhere to look.
let empty = TempDir::new().unwrap();
cmd.env("PATH", empty.path());
cmd.arg("definitely-not-a-wt-subcommand");
let output = cmd.output().expect("failed to run wt");
assert!(!output.status.success(), "expected failure");
// clap's standard InvalidSubcommand exit code.
assert_eq!(output.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(
stderr.contains("unrecognized subcommand 'definitely-not-a-wt-subcommand'"),
"stderr should use clap's native error format: {stderr}"
);
assert!(
stderr.contains("Usage:") && stderr.contains("try '--help'"),
"stderr should include Usage block and --help suggestion: {stderr}"
);
}
#[test]
fn custom_subcommand_typo_suggests_closest_builtin() {
let mut cmd = wt_command();
let empty = TempDir::new().unwrap();
cmd.env("PATH", empty.path());
cmd.arg("siwtch"); // typo of `switch`
let output = cmd.output().expect("failed to run wt");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(
stderr.contains("tip:") && stderr.contains("similar subcommand"),
"stderr missing clap's similar-subcommand tip: {stderr}"
);
assert!(
stderr.contains("'switch'"),
"stderr should suggest 'switch': {stderr}"
);
}
#[test]
fn custom_subcommand_nested_suggestion_wins_over_path_lookup() {
// `wt squash` should suggest `wt step squash` even though `wt-squash` is
// not on PATH. The nested tip is layered on top of clap's standard
// unrecognized-subcommand error.
let mut cmd = wt_command();
let empty = TempDir::new().unwrap();
cmd.env("PATH", empty.path());
cmd.arg("squash");
let output = cmd.output().expect("failed to run wt");
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&output.stderr)
.ansi_strip()
.into_owned();
assert!(
stderr.contains("wt step squash"),
"stderr should suggest 'wt step squash': {stderr}"
);
}
#[test]
fn custom_subcommand_propagates_exit_code() {
let dir = mock_bin_dir(
"wt-wt-test-extcmd-fail",
MockResponse::exit(0).with_exit_code(42),
);
let mut cmd = wt_command();
prepend_path(&mut cmd, dir.path());
cmd.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", dir.path());
cmd.arg("wt-test-extcmd-fail");
let output = cmd.output().expect("failed to run wt");
assert_eq!(
output.status.code(),
Some(42),
"expected exit code 42, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// When the custom command fails, wt should NOT add its own error line —
// the child already reported whatever it needed to.
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("exited with status"),
"wt should not decorate child failures: {stderr}"
);
}
#[test]
fn custom_subcommand_respects_global_dash_c_flag() {
// `wt -C <dir> foo` should run `wt-foo` with `<dir>` as its cwd. We verify
// by reading argv, not cwd, because MockResponse doesn't reflect cwd —
// instead we run `pwd` via a shell wrapper using the `file` response. Too
// fiddly; simpler: use a unique temp dir as cwd and have the mock emit
// `$PWD` via its stderr field. Alas, MockResponse just emits literals.
//
// Instead: point `-C` at a sentinel dir, and have the mock exit 0. The
// assertion is indirect — if wt fails to chdir to the dir (because it
// doesn't exist from the parent's cwd), the child will still run because
// we pass an absolute path. So we verify by confirming the child ran and
// exited cleanly even though the parent's cwd is unrelated.
let target_dir = TempDir::new().unwrap();
let dir = mock_bin_dir("wt-wt-test-extcmd-cwd", MockResponse::output("ok\n"));
let mut cmd = wt_command();
prepend_path(&mut cmd, dir.path());
cmd.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", dir.path());
cmd.current_dir(std::env::temp_dir());
cmd.args([
"-C",
target_dir.path().to_str().unwrap(),
"wt-test-extcmd-cwd",
]);
let output = cmd.output().expect("failed to run wt");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn custom_subcommand_passes_help_flag_through() {
// `wt foo --help` should hand `--help` to `wt-foo`, not to wt itself.
// The mock playback has no built-in `--help` handler, so if `--help` reaches
// it the mock falls through to `_default` (which we set to exit 0).
let dir = mock_bin_dir(
"wt-wt-test-extcmd-help",
MockResponse::output("child got help\n"),
);
let mut cmd = wt_command();
prepend_path(&mut cmd, dir.path());
cmd.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", dir.path());
cmd.args(["wt-test-extcmd-help", "--help"]);
let output = cmd.output().expect("failed to run wt");
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"child got help",
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}