From 9e2ecf04950218e2384af4baa809961c5a89498e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 12 Oct 2026 14:36:17 +0800 Subject: [PATCH 1/3] test: run pnpm directly in the pnpm workspace state e2e steps The install and recheck steps ran pnpm through `vt run --no-cache` tasks. Running it directly failed on Windows: portable-pty's PATH search takes an exact name before trying `PATHEXT` extensions, so it picked pnpm's extensionless shell shim in `node_modules/.bin` over `pnpm.cmd`, which Windows can't execute. The harness now resolves programs other than `vt` and `vtt` with `which::which_in`, as `vp` resolves task programs. Co-Authored-By: Claude Opus 5.5 --- .../fixtures/pnpm_workspace_state/snapshots.toml | 16 ++++++++-------- .../snapshots/pnpm_workspace_state_ignored.md | 8 +++----- .../fixtures/pnpm_workspace_state/vite-task.json | 3 --- crates/vt_bin/tests/e2e_snapshots/main.rs | 14 ++++++++++---- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/snapshots.toml index afacfc8e5..4a8c33e08 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/snapshots.toml @@ -6,10 +6,10 @@ A task that runs `pnpm run` reads pnpm's `node_modules/.pnpm-workspace-state-v1. ignore = true steps = [ { argv = [ - "vt", - "run", - "--no-cache", - "pnpm-install", + "pnpm", + "install", + "--frozen-lockfile", + "--silent", ], comment = "install, recording pnpm's dependency check" }, { argv = [ "vtt", @@ -42,11 +42,11 @@ steps = [ "vtt print hello", ], comment = "switch back: package.json is unchanged but newer than pnpm's last check" }, { argv = [ - "vt", + "pnpm", + "--silent", "run", - "--no-cache", - "pnpm-greet", - ], comment = "an uncached run: pnpm rechecks dependencies and rewrites the state file" }, + "greet", + ], comment = "pnpm rechecks dependencies and rewrites the state file" }, { argv = [ "node", "compare-state.mjs", diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/snapshots/pnpm_workspace_state_ignored.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/snapshots/pnpm_workspace_state_ignored.md index f05bc686c..62159bd25 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/snapshots/pnpm_workspace_state_ignored.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/snapshots/pnpm_workspace_state_ignored.md @@ -2,12 +2,11 @@ A task that runs `pnpm run` reads pnpm's `node_modules/.pnpm-workspace-state-v1.json`, which pnpm rewrites with a new timestamp whenever it rechecks dependencies. The file is not part of the fingerprint, so the task keeps its cache hit after another pnpm command rechecks. -## `vt run --no-cache pnpm-install` +## `pnpm install --frozen-lockfile --silent` install, recording pnpm's dependency check ``` -$ pnpm install --frozen-lockfile --silent ⊘ cache disabled ``` ## `vtt cp node_modules/.pnpm-workspace-state-v1.json state-after-install.json` @@ -52,12 +51,11 @@ switch back: package.json is unchanged but newer than pnpm's last check ``` ``` -## `vt run --no-cache pnpm-greet` +## `pnpm --silent run greet` -an uncached run: pnpm rechecks dependencies and rewrites the state file +pnpm rechecks dependencies and rewrites the state file ``` -$ pnpm --silent run greet ⊘ cache disabled hello ``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/vite-task.json index 3f26e3c47..3d09d33f9 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_workspace_state/vite-task.json @@ -1,8 +1,5 @@ { "tasks": { - "pnpm-install": { - "command": "pnpm install --frozen-lockfile --silent" - }, "pnpm-greet": { "command": "pnpm --silent run greet", "cache": true diff --git a/crates/vt_bin/tests/e2e_snapshots/main.rs b/crates/vt_bin/tests/e2e_snapshots/main.rs index 345c3ee7b..1de5a6c0e 100644 --- a/crates/vt_bin/tests/e2e_snapshots/main.rs +++ b/crates/vt_bin/tests/e2e_snapshots/main.rs @@ -464,17 +464,24 @@ fn run_case( let argv = step.argv(); let program = argv[0].as_str(); + let step_cwd = e2e_stage_path.join(step.cwd().unwrap_or(&e2e.cwd)); // Resolve the locally built task binaries exactly. Other programs // are resolved through the prepared PATH, allowing ignored tests // to opt into external toolchains without weakening the default - // suite's self-contained execution. + // suite's self-contained execution. They're resolved like `vp` + // resolves task programs: portable-pty's own search on Windows + // prefers an exact name over `PATHEXT` extensions, so it would + // pick an extensionless shell shim such as `node_modules/.bin/pnpm` + // over `pnpm.cmd`, which Windows can't execute. let mut cmd = if matches!(program, "vt" | "vtt") { let exe_env = vt_str::format!("CARGO_BIN_EXE_{program}"); let resolved = env::var_os(exe_env.as_str()).unwrap_or_else(|| panic!("{exe_env} not set")); CommandBuilder::new(resolved) } else { - CommandBuilder::new(program) + let resolved = which::which_in(program, Some(&e2e_env_path), step_cwd.as_path()) + .unwrap_or_else(|err| panic!("failed to resolve {program}: {err}")); + CommandBuilder::new(resolved) }; for arg in &argv[1..] { cmd.arg(arg.as_str()); @@ -526,8 +533,7 @@ fn run_case( let resolved = resolve_env_placeholder(v.as_str()); cmd.env(k.as_str(), AsRef::::as_ref(&resolved)); } - let step_cwd = step.cwd().unwrap_or(&e2e.cwd); - cmd.cwd(e2e_stage_path.join(step_cwd).as_path()); + cmd.cwd(step_cwd.as_path()); let terminal = TestTerminal::spawn(SCREEN_SIZE, cmd).unwrap(); let mut killer = terminal.child_handle.clone(); From 4929686dde24fe7823c7ad140eb9304118e2cf48 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 12 Oct 2026 14:45:24 +0800 Subject: [PATCH 2/3] test: run external e2e steps outside the verbatim temp path on Windows The harness canonicalizes its temp directory, which gives verbatim (`\\?\D:\...`) paths on Windows. `cmd.exe`, which runs npm-style `.cmd` shims such as pnpm's, rejects those as a working directory, so the direct `pnpm` steps failed with "UNC paths are not supported". Programs other than `vt` and `vtt` now start in the same directory without the verbatim prefix. Co-Authored-By: Claude Opus 5.5 --- crates/vt_bin/tests/e2e_snapshots/main.rs | 24 +++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/vt_bin/tests/e2e_snapshots/main.rs b/crates/vt_bin/tests/e2e_snapshots/main.rs index 1de5a6c0e..ec82c27ad 100644 --- a/crates/vt_bin/tests/e2e_snapshots/main.rs +++ b/crates/vt_bin/tests/e2e_snapshots/main.rs @@ -473,15 +473,15 @@ fn run_case( // prefers an exact name over `PATHEXT` extensions, so it would // pick an extensionless shell shim such as `node_modules/.bin/pnpm` // over `pnpm.cmd`, which Windows can't execute. - let mut cmd = if matches!(program, "vt" | "vtt") { + let (mut cmd, spawn_cwd) = if matches!(program, "vt" | "vtt") { let exe_env = vt_str::format!("CARGO_BIN_EXE_{program}"); let resolved = env::var_os(exe_env.as_str()).unwrap_or_else(|| panic!("{exe_env} not set")); - CommandBuilder::new(resolved) + (CommandBuilder::new(resolved), step_cwd.clone()) } else { let resolved = which::which_in(program, Some(&e2e_env_path), step_cwd.as_path()) .unwrap_or_else(|err| panic!("failed to resolve {program}: {err}")); - CommandBuilder::new(resolved) + (CommandBuilder::new(resolved), without_verbatim_prefix(&step_cwd)) }; for arg in &argv[1..] { cmd.arg(arg.as_str()); @@ -533,7 +533,7 @@ fn run_case( let resolved = resolve_env_placeholder(v.as_str()); cmd.env(k.as_str(), AsRef::::as_ref(&resolved)); } - cmd.cwd(step_cwd.as_path()); + cmd.cwd(spawn_cwd.as_path()); let terminal = TestTerminal::spawn(SCREEN_SIZE, cmd).unwrap(); let mut killer = terminal.child_handle.clone(); @@ -659,6 +659,22 @@ fn run_case( Ok(()) } +/// `canonicalize` gives verbatim (`\\?\`) paths on Windows, which `cmd.exe` +/// rejects as a working directory. npm-style `.cmd` shims, such as pnpm's, +/// run through it. +fn without_verbatim_prefix(path: &AbsolutePath) -> AbsolutePathBuf { + #[cfg(windows)] + { + if let Some(stripped) = path.as_path().to_str().and_then(|path| path.strip_prefix(r"\\?\")) + && !stripped.starts_with(r"UNC\") + && let Some(stripped) = AbsolutePath::new(stripped) + { + return stripped.to_absolute_path_buf(); + } + } + path.to_absolute_path_buf() +} + #[expect(clippy::disallowed_types, reason = "Path required for CARGO_MANIFEST_DIR path traversal")] fn main() { let tmp_dir = tempfile::tempdir().unwrap(); From 587b0ec491fafa246a20241439981e58533abf3e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 12 Oct 2026 14:54:43 +0800 Subject: [PATCH 3/3] test: simplify external steps' working directory with dunce `dunce::simplified` drops the verbatim prefix only when the path means the same without it, for example not past the legacy `MAX_PATH` limit or with reserved names, and is a no-op off Windows. It replaces the hand-written prefix stripping. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 7 ++++++ Cargo.toml | 1 + crates/vt_bin/Cargo.toml | 1 + crates/vt_bin/tests/e2e_snapshots/main.rs | 27 +++++++---------------- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d18f72928..9e3103998 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1125,6 +1125,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -4986,6 +4992,7 @@ dependencies = [ "cow-utils", "cp_r", "ctrlc", + "dunce", "jsonc-parser", "libc", "libtest-mimic", diff --git a/Cargo.toml b/Cargo.toml index ebd5a3d14..4fe61fa87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ ctrlc = "3.5.2" derive_more = "2.0.1" diff-struct = "0.5.3" directories = "6.0.0" +dunce = "1.0.5" elf = { version = "0.8.0", default-features = false } flate2 = "1.0.35" fspy = { path = "crates/fspy" } diff --git a/crates/vt_bin/Cargo.toml b/crates/vt_bin/Cargo.toml index 1b4e4e0fc..0b39cb66a 100644 --- a/crates/vt_bin/Cargo.toml +++ b/crates/vt_bin/Cargo.toml @@ -38,6 +38,7 @@ nix = { workspace = true, features = ["mount", "sched", "user"] } cargo-platform = { workspace = true } cow-utils = { workspace = true } cp_r = { workspace = true } +dunce = { workspace = true } libtest-mimic = { workspace = true } vt_snapshot_test = { workspace = true } vt_pty_terminal = { workspace = true } diff --git a/crates/vt_bin/tests/e2e_snapshots/main.rs b/crates/vt_bin/tests/e2e_snapshots/main.rs index ec82c27ad..0e4d1c819 100644 --- a/crates/vt_bin/tests/e2e_snapshots/main.rs +++ b/crates/vt_bin/tests/e2e_snapshots/main.rs @@ -473,15 +473,20 @@ fn run_case( // prefers an exact name over `PATHEXT` extensions, so it would // pick an extensionless shell shim such as `node_modules/.bin/pnpm` // over `pnpm.cmd`, which Windows can't execute. + // + // `canonicalize` gives the staged workspace a verbatim (`\\?\`) + // path on Windows. `cmd.exe`, which runs npm-style `.cmd` shims + // such as pnpm's, rejects that as a working directory, so other + // programs start in its simplified form. let (mut cmd, spawn_cwd) = if matches!(program, "vt" | "vtt") { let exe_env = vt_str::format!("CARGO_BIN_EXE_{program}"); let resolved = env::var_os(exe_env.as_str()).unwrap_or_else(|| panic!("{exe_env} not set")); - (CommandBuilder::new(resolved), step_cwd.clone()) + (CommandBuilder::new(resolved), step_cwd.as_path()) } else { let resolved = which::which_in(program, Some(&e2e_env_path), step_cwd.as_path()) .unwrap_or_else(|err| panic!("failed to resolve {program}: {err}")); - (CommandBuilder::new(resolved), without_verbatim_prefix(&step_cwd)) + (CommandBuilder::new(resolved), dunce::simplified(step_cwd.as_path())) }; for arg in &argv[1..] { cmd.arg(arg.as_str()); @@ -533,7 +538,7 @@ fn run_case( let resolved = resolve_env_placeholder(v.as_str()); cmd.env(k.as_str(), AsRef::::as_ref(&resolved)); } - cmd.cwd(spawn_cwd.as_path()); + cmd.cwd(spawn_cwd); let terminal = TestTerminal::spawn(SCREEN_SIZE, cmd).unwrap(); let mut killer = terminal.child_handle.clone(); @@ -659,22 +664,6 @@ fn run_case( Ok(()) } -/// `canonicalize` gives verbatim (`\\?\`) paths on Windows, which `cmd.exe` -/// rejects as a working directory. npm-style `.cmd` shims, such as pnpm's, -/// run through it. -fn without_verbatim_prefix(path: &AbsolutePath) -> AbsolutePathBuf { - #[cfg(windows)] - { - if let Some(stripped) = path.as_path().to_str().and_then(|path| path.strip_prefix(r"\\?\")) - && !stripped.starts_with(r"UNC\") - && let Some(stripped) = AbsolutePath::new(stripped) - { - return stripped.to_absolute_path_buf(); - } - } - path.to_absolute_path_buf() -} - #[expect(clippy::disallowed_types, reason = "Path required for CARGO_MANIFEST_DIR path traversal")] fn main() { let tmp_dir = tempfile::tempdir().unwrap();