mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
dd453304ed
`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>
300 lines
10 KiB
Rust
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)
|
|
);
|
|
}
|