Files
max-sixty__worktrunk/tests/integration_tests/init.rs
Maximilian Roos a3daf0ded6 fix: strip only the executable suffix when naming wt from argv[0] (#3719)
`mock_stub::command_name` and `invocation::binary_name` both read
`argv[0]` to name this process, and disagreed on how. The mock dispatch
stripped `EXE_SUFFIX`; `binary_name` took `file_stem`. So `wt.old`
resolved to `wt.old` in one and `wt` in the other, while
`command_name`'s doc comment asked that the two be kept aligned.
Follow-up from #3712.

The comment was right about which way to align. `binary_name`'s own doc
already claimed the narrower behavior ("On Windows, strips `.exe`"), and
`file_stem` doesn't do that — it cuts at the last dot wherever the dot
is, on every platform. `validate_shell_command_name` accepts `.`, so
`wt.old` is a name a user can install shell integration for, and `wt
config shell init bash` under it emitted a wrapper for `wt`, a command
they may not have. The `EXE_SUFFIX` strip on the mock side is
load-bearing (it replaced `file_stem` so a dotted mock name resolves
alike on Unix `python3.11` and the Windows link `python3.11.exe`), and
it is the right answer for shell integration too.

There is now one derivation, `path::executable_name`, in the lib where
both crates reach it: `argv[0]`'s file name with `EXE_SUFFIX` stripped,
matched case-insensitively, since Windows resolves `WT.EXE` and `wt.exe`
to one file and `compute_shell_warning_reason`'s Windows branch needs
`wraps` to be the suffix-free spelling to tell the user to drop it.
`binary_name` is that plus its `"wt"` fallback; `mock_stub` calls it
directly, and `command_name` is gone along with the comment asking for
alignment. The shell-integration warning still derives its own display
name from `argv[0]` and deliberately keeps the `.exe`; the doc says so,
so it doesn't read as a third caller someone should fold in.

Two behavior changes beyond the dotted name. A non-UTF8 `argv[0]`
converts lossily rather than falling back to `"wt"`, so `wt config shell
init` rejects it with `Invalid shell integration command name` instead
of quietly generating integration for a command other than the one that
ran — the rule the existing `wt;touch` symlink test already enforces. A
missing `argv[0]` still yields `"wt"`.

The suffix is a parameter of the private `strip_suffix_ignoring_case`
rather than read from `env::consts` in place: `EXE_SUFFIX` is empty on
Unix and the merge gate runs one platform, so the unit test drives the
Windows spellings (`WT.EXE`, `wt.Exe`, `wt.exe.old`, a name whose
trailing bytes fall mid-character) everywhere. `str::get` rather than a
byte slice for the same reason `shell::extract_filename_from_path`
should use one — see below.

Tests: the derivation table and the suffix cases in `src/path.rs`, plus
two integration tests beside the existing `argv[0]` ones — a `wt.old`
symlink whose `config shell init bash` must define `wt.old()` and not
`wt()`, and a non-UTF8 `argv[0]` that must be rejected. The three
existing `argv[0]` tests are untouched and pass. Pre-merge gate green,
4548/4548.

Not done here: `shell::extract_filename_from_path` is a third
`.exe`-stripping name derivation, over `$SHELL` and process-tree names.
It stays separate because it strips `.exe` on every platform, which is
correct for a Git Bash `$SHELL` carrying a Unix-form path with a Windows
suffix on it. It does have a latent panic — `filename[filename.len() -
4..]` slices without a char-boundary check, so a `$SHELL` of `/bin/€ab`
panics — worth a small separate fix.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 06:57:38 -07:00

250 lines
7.9 KiB
Rust
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Snapshot tests for `wt config shell init` command output.
//!
//! Skipped on Windows: These tests verify shell init scripts for bash/zsh/fish.
//! Windows line endings (CRLF) cause snapshot mismatches, and these Unix shells
//! are not the primary shell integration path on Windows (PowerShell is).
#![cfg(not(windows))]
use crate::common::{TestRepo, add_standard_env_redactions, repo, wt_bin, wt_command};
use insta::Settings;
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::process::Command;
/// Helper to create snapshot for config shell init command
fn snapshot_init(test_name: &str, repo: &TestRepo, shell: &str, extra_args: &[&str]) {
let mut settings = Settings::clone_current();
settings.set_snapshot_path("../snapshots");
add_standard_env_redactions(&mut settings);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.arg("config").arg("shell").arg("init").arg(shell);
for arg in extra_args {
cmd.arg(arg);
}
cmd.current_dir(repo.root_path());
assert_cmd_snapshot!(test_name, cmd);
});
}
#[rstest]
// Test supported shells
#[case("bash")]
#[case("fish")]
#[case("zsh")]
fn test_init(#[case] shell: &str, repo: TestRepo) {
snapshot_init(&format!("init_{}", shell), &repo, shell, &[]);
}
#[rstest]
fn test_init_invalid_shell(repo: TestRepo) {
// Same custom settings as snapshot_init
let mut settings = Settings::clone_current();
settings.set_snapshot_path("../snapshots");
add_standard_env_redactions(&mut settings);
settings.bind(|| {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.arg("config")
.arg("shell")
.arg("init")
.arg("invalid-shell")
.current_dir(repo.root_path());
assert_cmd_snapshot!(cmd, @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: invalid value 'invalid-shell' for '<bash|fish|nu|zsh|powershell>'
[possible values: bash, fish, nu, zsh, powershell]
For more information, try '--help'.
");
});
}
#[rstest]
#[case("bash")]
#[case("fish")]
#[case("nu")]
#[case("powershell")]
#[case("zsh")]
fn test_init_rejects_unsafe_cmd(#[case] shell: &str, repo: TestRepo) {
let mut cmd = wt_command();
repo.configure_wt_cmd(&mut cmd);
cmd.arg("config")
.arg("shell")
.arg("init")
.arg(shell)
.arg("--cmd")
.arg("wt; touch /tmp/pwn")
.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert!(
output.stdout.is_empty(),
"unsafe command name must not emit shell code:\n{}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
String::from_utf8_lossy(&output.stderr).contains("Invalid shell integration command name"),
"expected validation error, got:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
/// A degenerate argv\[0\] — empty here, so it has no file name — never selects
/// mock playback: `path::executable_name` yields `None` and the process runs as
/// wt, rather than panicking inside `main()` or probing the config dir.
#[test]
fn test_mock_dispatch_ignores_degenerate_argv0() {
use std::os::unix::process::CommandExt;
let config_dir = tempfile::tempdir().unwrap();
let output = Command::new(wt_bin())
.arg0("")
.arg("--version")
.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", config_dir.path())
.output()
.unwrap();
assert!(output.status.success(), "wt --version should succeed");
assert!(
String::from_utf8_lossy(&output.stdout).starts_with("wt "),
"expected wt's own version output, got:\n{}",
String::from_utf8_lossy(&output.stdout)
);
}
/// A non-UTF8 argv\[0\] must not panic the dispatch (`env::args()` panics on
/// non-Unicode arguments; the dispatch reads `args_os`): it lossily converts,
/// matches no config, and wt runs as itself.
#[test]
fn test_mock_dispatch_survives_non_utf8_argv0() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::process::CommandExt;
let config_dir = tempfile::tempdir().unwrap();
let output = Command::new(wt_bin())
.arg0(OsStr::from_bytes(b"\xff\xfe"))
.arg("--version")
.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", config_dir.path())
.output()
.unwrap();
assert!(output.status.success(), "wt --version should succeed");
assert!(
String::from_utf8_lossy(&output.stdout).starts_with("wt "),
"expected wt's own version output, got:\n{}",
String::from_utf8_lossy(&output.stdout)
);
}
/// A dot in argv\[0\] is part of the command name, not an extension to drop:
/// `validate_shell_command_name` accepts `.`, so a `wt.old` invocation must
/// wrap `wt.old` — wrapping the truncated `wt` would name a command the user
/// may not have installed at all.
#[rstest]
fn test_init_keeps_dotted_argv0_command_name(repo: TestRepo) {
let temp_dir = tempfile::tempdir().unwrap();
let dotted_bin = temp_dir.path().join("wt.old");
std::os::unix::fs::symlink(wt_bin(), &dotted_bin).unwrap();
let mut cmd = Command::new(&dotted_bin);
repo.configure_wt_cmd(&mut cmd);
cmd.arg("config")
.arg("shell")
.arg("init")
.arg("bash")
.current_dir(repo.root_path());
let output = cmd.output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"shell init failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
stdout.contains("wt.old() {"),
"expected a wrapper for the full invocation name:\n{stdout}"
);
assert!(
!stdout.contains("wt() {"),
"wrapper must not fall back to the truncated name:\n{stdout}"
);
}
/// A non-UTF8 argv\[0\] names a command that cannot be spelled in shell syntax,
/// so it reaches the same rejection as the `wt;touch` case below. Answering
/// with wt's own name instead would generate integration for a command other
/// than the one that ran.
#[test]
fn test_init_rejects_non_utf8_argv0_command_name() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::process::CommandExt;
let mut cmd = wt_command();
cmd.arg0(OsStr::from_bytes(b"\xff\xfe"))
.arg("config")
.arg("shell")
.arg("init")
.arg("bash");
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert!(
output.stdout.is_empty(),
"unspellable command name must not emit shell code:\n{}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
String::from_utf8_lossy(&output.stderr).contains("Invalid shell integration command name"),
"expected validation error, got:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[rstest]
fn test_init_rejects_unsafe_argv0_command_name(repo: TestRepo) {
let temp_dir = tempfile::tempdir().unwrap();
let bad_bin = temp_dir.path().join("wt;touch");
std::os::unix::fs::symlink(wt_bin(), &bad_bin).unwrap();
let mut cmd = Command::new(&bad_bin);
repo.configure_wt_cmd(&mut cmd);
cmd.arg("config")
.arg("shell")
.arg("init")
.arg("bash")
.current_dir(repo.root_path());
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert!(
output.stdout.is_empty(),
"unsafe argv[0] command name must not emit shell code:\n{}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
String::from_utf8_lossy(&output.stderr).contains("Invalid shell integration command name"),
"expected validation error, got:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}