mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
715af4cd72
Two test-suite flakes fixed at the root, both dependencies on machine
load and concurrent builds.
**Concurrent-cargo spawn `NotFound`.** Cargo uplifts `target/debug/wt`
by removing the path and recreating it, so a second `cargo` against the
same target directory leaves the binary every test spawns absent for a
fraction of a millisecond per rebuild — the one-off `NotFound` spawn
failures that pass on re-run. `wt_bin()` now returns a hardlink pinned
under `target/debug/wt-test-bin/<mtime>-<len>/`: the uplift unlinks only
the uplifted name, so the pin keeps serving the observed binary through
any number of concurrent rebuilds, at no disk cost beyond the `deps/`
artifact whose inode it shares. `test_wt_spawns_are_pinned` keeps every
spawn routed through it. Reproduced by re-creating the uplift every 200
ms alongside a full `cargo nextest run`: 302 of 4583 tests failed before
this change (147 as direct `NotFound` spawn panics, five of them
byte-identical to the original shell-wrapper report), 4585 of 4585 after
— with an unrelated external cargo also rebuilding `wt` mid-validation,
absorbed the same way.
**`--reap` probe races.** `test_remove_reap_kills_process` predicted the
reap guard's verdict with its own `lsof`/`ps` snapshot, and under load
either probe's spawn can stall past the 5 s bound, whose fail-safe empty
result flips the outcome — a prediction `wt` then contradicts, or `wt`
reporting "No processes to reap" for a live child. The prediction now
reads the session's controlling terminal directly (`/dev/tty` opens iff
the session has one — the property the child inherits at spawn), the
probe timeout is env-pinnable (`WORKTRUNK_TEST_PROBE_TIMEOUT_MS`, set to
60 s in the static test baseline; production keeps its 5 s bound), and
the discovery poll uses the suite's 60 s presence-poll convention.
Looped 15/15 green at load average ~60, where the previous shape failed
2/10.
Not covered here, noted as follow-ups: benches still spawn
`env!("CARGO_BIN_EXE_wt")` directly (same hazard, separate runner,
outside the guard's scan), and the reap "spared" branch has no
deterministic end-to-end test (needs a PTY-held child).
> _This was written by Claude Code on behalf of max-sixty_
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
183 lines
6.6 KiB
Rust
183 lines
6.6 KiB
Rust
//! PowerShell shell integration tests.
|
|
//!
|
|
//! These tests verify that PowerShell shell integration works correctly.
|
|
//! Requires pwsh (PowerShell Core), which is pre-installed on GitHub Actions runners.
|
|
|
|
#![cfg(feature = "shell-integration-tests")]
|
|
|
|
use std::process::Command;
|
|
|
|
use worktrunk::shell::{Shell, ShellInit};
|
|
|
|
/// Test that the PowerShell config_line() actually works when evaluated.
|
|
///
|
|
/// This is a regression test for issue #885 where `Invoke-Expression` failed
|
|
/// because command output is an array of strings, not a single string.
|
|
/// The fix was adding `| Out-String` to the config_line.
|
|
#[test]
|
|
fn test_powershell_config_line_evaluates_correctly() {
|
|
let wt_bin = crate::common::wt_bin();
|
|
let bin_dir = wt_bin.parent().expect("Failed to get binary directory");
|
|
|
|
// Build a script that:
|
|
// 1. Adds the binary directory to PATH so Get-Command wt works
|
|
// 2. Sets WORKTRUNK_BIN so the init script can find the binary
|
|
// 3. Runs the config_line (which uses Invoke-Expression)
|
|
// 4. Checks if the function is defined
|
|
let config_line = Shell::PowerShell.config_line("wt");
|
|
let script = format!(
|
|
r#"
|
|
$env:PATH = '{}' + [IO.Path]::PathSeparator + $env:PATH
|
|
$env:WORKTRUNK_BIN = '{}'
|
|
{}
|
|
$cmd = Get-Command wt -ErrorAction SilentlyContinue
|
|
if ($cmd -and $cmd.CommandType -eq 'Function') {{
|
|
Write-Output 'FUNCTION_DEFINED'
|
|
}} else {{
|
|
Write-Output "FUNCTION_NOT_DEFINED: CommandType=$($cmd.CommandType)"
|
|
}}
|
|
"#,
|
|
bin_dir.display().to_string().replace('\'', "''"),
|
|
wt_bin.display().to_string().replace('\'', "''"),
|
|
config_line
|
|
);
|
|
|
|
let output = Command::new("pwsh")
|
|
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
|
|
.output()
|
|
.expect("Failed to run pwsh");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"pwsh command failed.\nstdout: {}\nstderr: {}",
|
|
stdout,
|
|
stderr
|
|
);
|
|
|
|
assert!(
|
|
stdout.contains("FUNCTION_DEFINED"),
|
|
"PowerShell config_line failed to define function.\n\
|
|
Config line: {}\n\
|
|
stdout: {}\n\
|
|
stderr: {}",
|
|
config_line,
|
|
stdout,
|
|
stderr
|
|
);
|
|
}
|
|
|
|
/// Regression test: PowerShell wrapper must not consume short flags like -D.
|
|
///
|
|
/// When the wrapper function uses `[Parameter(ValueFromRemainingArguments)]`, PowerShell
|
|
/// promotes it to an "advanced function" which adds common parameters (-Debug, -Verbose,
|
|
/// etc.). The `-D` flag is then consumed as `-Debug` instead of being passed to the binary.
|
|
/// The fix uses `$args` (simple function automatic variable) for transparent passthrough.
|
|
#[test]
|
|
fn test_powershell_wrapper_passes_short_flags_through() {
|
|
// Create a .ps1 mock that prints each argument on its own line.
|
|
// Using .ps1 (not a shell script) so this works on Windows too.
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let mock_bin = temp_dir.path().join("mock-wt.ps1");
|
|
std::fs::write(&mock_bin, "foreach ($a in $args) { Write-Output $a }\n").unwrap();
|
|
|
|
let init = ShellInit::with_prefix(Shell::PowerShell, "wt".to_string());
|
|
let wrapper = init.generate().unwrap();
|
|
|
|
let mock_bin_escaped = mock_bin.display().to_string().replace('\'', "''");
|
|
let script = format!(
|
|
r#"
|
|
$env:WORKTRUNK_BIN = '{mock_bin_escaped}'
|
|
{wrapper}
|
|
wt remove -D test --force
|
|
"#
|
|
);
|
|
|
|
let output = Command::new("pwsh")
|
|
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
|
|
.output()
|
|
.expect("Failed to run pwsh");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"pwsh command failed.\nstdout: {stdout}\nstderr: {stderr}",
|
|
);
|
|
|
|
// Each argument should appear as a separate line in the mock's output.
|
|
// If -D were consumed as -Debug (advanced function), it would be missing.
|
|
let lines: Vec<&str> = stdout.lines().map(|l| l.trim()).collect();
|
|
for expected in ["remove", "-D", "test", "--force"] {
|
|
assert!(
|
|
lines.contains(&expected),
|
|
"Expected argument {expected:?} to be passed through to binary.\n\
|
|
Got lines: {lines:?}\nstdout: {stdout}\nstderr: {stderr}",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Regression test: the wrapper must not emit a stray exit-code line to stdout.
|
|
///
|
|
/// The wrapper used to end with `return $exitCode`. In a PowerShell function,
|
|
/// `return <value>` writes the value to the output (success) stream, so after
|
|
/// the real `wt` output the function appended a bare exit-code line (e.g. `0`).
|
|
/// That corrupts any capture of a command's output, e.g. `$out = wt list
|
|
/// --format json`. Exit-code propagation is handled by `$global:LASTEXITCODE`
|
|
/// (the `Write-Error` only surfaces a visible error record — it does not set
|
|
/// the caller's `$?` from a simple function), so the `return` was pure stdout
|
|
/// pollution.
|
|
///
|
|
/// The mock must exit with a real code so `$LASTEXITCODE` is set — a `.ps1`
|
|
/// that only calls `Write-Output` leaves `$LASTEXITCODE` unset, and the old
|
|
/// `return $null` emitted nothing, hiding the bug.
|
|
#[test]
|
|
fn test_powershell_wrapper_no_stray_exit_code_on_stdout() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let mock_bin = temp_dir.path().join("mock-wt.ps1");
|
|
// Emit a single distinctive line, then exit 0 like a real native binary.
|
|
std::fs::write(&mock_bin, "Write-Output 'MOCK_OUTPUT_LINE'\nexit 0\n").unwrap();
|
|
|
|
let init = ShellInit::with_prefix(Shell::PowerShell, "wt".to_string());
|
|
let wrapper = init.generate().unwrap();
|
|
|
|
let mock_bin_escaped = mock_bin.display().to_string().replace('\'', "''");
|
|
let script = format!(
|
|
r#"
|
|
$env:WORKTRUNK_BIN = '{mock_bin_escaped}'
|
|
{wrapper}
|
|
wt list
|
|
"#
|
|
);
|
|
|
|
let output = Command::new("pwsh")
|
|
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
|
|
.output()
|
|
.expect("Failed to run pwsh");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"pwsh command failed.\nstdout: {stdout}\nstderr: {stderr}",
|
|
);
|
|
|
|
// The mock's line is the only thing that should reach stdout. Before the
|
|
// fix, a stray `0` (the exit code) followed it.
|
|
let lines: Vec<&str> = stdout
|
|
.lines()
|
|
.map(|l| l.trim())
|
|
.filter(|l| !l.is_empty())
|
|
.collect();
|
|
assert_eq!(
|
|
lines,
|
|
vec!["MOCK_OUTPUT_LINE"],
|
|
"wrapper leaked extra stdout (likely a stray exit-code line from `return`).\n\
|
|
stdout: {stdout}\nstderr: {stderr}",
|
|
);
|
|
}
|