diff --git a/.gitignore b/.gitignore index 666c3d4b..624e0664 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /target node_modules +# Fixture node_modules that stand in for real npm installs in snapshot tests. +# `**` covers nested monorepo layouts (e.g. packages/foo/node_modules/.bin). +!crates/vite_task_plan/tests/plan_snapshots/fixtures/*/**/node_modules dist .claude/settings.local.json *.tsbuildinfo diff --git a/Cargo.lock b/Cargo.lock index bf11ea1b..42e7fe4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2355,6 +2355,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "peg" version = "0.8.5" @@ -4033,6 +4039,7 @@ dependencies = [ "cow-utils", "futures-util", "libtest-mimic", + "pathdiff", "petgraph", "rustc-hash", "serde", diff --git a/Cargo.toml b/Cargo.toml index 85c0f40f..2b3bf6e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ owo-colors = { version = "4.1.0", features = ["supports-colors"] } passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false } notify = "8.0.0" path-clean = "1.0.1" +pathdiff = "0.2.3" petgraph = "0.8.2" phf = { version = "0.11.3", features = ["macros"] } portable-pty = "0.9.0" diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index 5b2e6ef3..17abd81a 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -14,7 +14,9 @@ workspace = true anyhow = { workspace = true } async-trait = { workspace = true } wincode = { workspace = true, features = ["derive"] } +cow-utils = { workspace = true } futures-util = { workspace = true } +pathdiff = { workspace = true } petgraph = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -35,7 +37,6 @@ which = { workspace = true } clap = { workspace = true, features = ["derive"] } copy_dir = { workspace = true } libtest-mimic = { workspace = true } -cow-utils = { workspace = true } snapshot_test = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index c749ef29..9fb00e8a 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -7,6 +7,7 @@ mod in_process; mod path_env; mod plan; pub mod plan_request; +mod ps1_shim; use std::{collections::BTreeMap, ffi::OsStr, fmt::Debug, sync::Arc}; diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 8e657d84..1a91d916 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -294,6 +294,12 @@ async fn plan_task_as_execution_node( &script_command.envs, &script_command.cwd, )?; + let (program_path, spawn_args) = crate::ps1_shim::rewrite_cmd_shim_with_args( + program_path, + script_command.args, + &task_node.resolved_config.resolved_options.cwd, + context.workspace_path(), + ); let resolved_options = ResolvedTaskOptions { cwd: Arc::clone(&task_node.resolved_config.resolved_options.cwd), cache_config: effective_cache_config( @@ -309,7 +315,7 @@ async fn plan_task_as_execution_node( &resolved_options, &script_command.envs, program_path, - script_command.args, + spawn_args, )?; ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) } @@ -507,6 +513,8 @@ pub fn plan_synthetic_request( let SyntheticPlanRequest { program, args, cache_config, envs } = synthetic_plan_request; let program_path = which(&program, &envs, cwd)?; + let (program_path, args) = + crate::ps1_shim::rewrite_cmd_shim_with_args(program_path, args, cwd, workspace_path); let resolved_cache_config = resolve_synthetic_cache_config( parent_cache_config, cache_config, diff --git a/crates/vite_task_plan/src/ps1_shim.rs b/crates/vite_task_plan/src/ps1_shim.rs new file mode 100644 index 00000000..60e380d8 --- /dev/null +++ b/crates/vite_task_plan/src/ps1_shim.rs @@ -0,0 +1,303 @@ +//! Windows-specific: rewrite `.cmd` shims in `node_modules/.bin` so the plan +//! records a `powershell.exe -File ` invocation in place of the +//! `.cmd` hop. +//! +//! Running a `.cmd` shim from any shell causes `cmd.exe` to prompt "Terminate +//! batch job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Rewriting +//! to the `.ps1` sibling, invoked via `powershell.exe -File`, sidesteps that +//! prompt. Doing the rewrite at plan time (rather than at spawn time) means +//! the command shown in the task graph and cache fingerprint is the command +//! that actually runs. +//! +//! The `.ps1` path recorded in args is **relative to the task's cwd**, with +//! `/` as the separator. That keeps `SpawnFingerprint.args` portable across +//! machines (no absolute paths leak into cache keys) and PowerShell resolves +//! `-File ` against its own working directory, which is the task's +//! cwd, so the spawn lands on the correct file. +//! +//! The rewrite is limited to `node_modules/.bin/` triplets **inside the +//! workspace** and produced by npm/pnpm/yarn (via cmd-shim, which only emits +//! `.cmd` — not `.bat`). Any `.cmd` file outside the workspace — e.g. a +//! globally installed tool's shim somewhere on the user's system PATH — is +//! left alone even if it happens to live under some other `node_modules/.bin`. +//! +//! See . + +use std::sync::Arc; + +#[cfg(any(windows, test))] +use cow_utils::CowUtils as _; +use vite_path::AbsolutePath; +#[cfg(any(windows, test))] +use vite_path::AbsolutePathBuf; +use vite_str::Str; + +/// Fixed arguments prepended before the `.ps1` path. `-NoProfile`/`-NoLogo` +/// skip user profile loading; `-ExecutionPolicy Bypass` allows running the +/// unsigned shims that npm/pnpm install into `node_modules/.bin`. +#[cfg(any(windows, test))] +const POWERSHELL_PREFIX: &[&str] = + &["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"]; + +/// Rewrite a `node_modules/.bin/*.cmd` invocation to go through PowerShell. +/// See the module docstring for the full contract; the short form: returns +/// `(powershell.exe, [-NoProfile, …, -File, , ...args])` +/// when the rewrite applies, otherwise `(resolved, args)` unchanged. +#[cfg(windows)] +#[must_use] +pub fn rewrite_cmd_shim_with_args( + resolved: Arc, + args: Arc<[Str]>, + cwd: &AbsolutePath, + workspace_root: &AbsolutePath, +) -> (Arc, Arc<[Str]>) { + if let Some(host) = powershell_host() + && let Some(rewritten) = rewrite_with_host(&resolved, &args, cwd, workspace_root, host) + { + return rewritten; + } + (resolved, args) +} + +#[cfg(not(windows))] +#[must_use] +pub const fn rewrite_cmd_shim_with_args( + resolved: Arc, + args: Arc<[Str]>, + _cwd: &AbsolutePath, + _workspace_root: &AbsolutePath, +) -> (Arc, Arc<[Str]>) { + (resolved, args) +} + +/// Cached location of the PowerShell host used to run `.ps1` shims. Prefers +/// cross-platform `pwsh.exe` when present, falling back to the Windows +/// built-in `powershell.exe`. `None` means no host was found in PATH (or we +/// aren't on Windows). +#[cfg(windows)] +fn powershell_host() -> Option<&'static Arc> { + use std::sync::LazyLock; + + static POWERSHELL_HOST: LazyLock>> = LazyLock::new(|| { + let resolved = which::which("pwsh.exe").or_else(|_| which::which("powershell.exe")).ok()?; + AbsolutePathBuf::new(resolved).map(Arc::::from) + }); + POWERSHELL_HOST.as_ref() +} + +/// Pure rewrite logic, factored out so tests can exercise it on any platform +/// without depending on a real `powershell.exe` being on PATH. +#[cfg(any(windows, test))] +fn rewrite_with_host( + resolved: &Arc, + args: &Arc<[Str]>, + cwd: &AbsolutePath, + workspace_root: &AbsolutePath, + host: &Arc, +) -> Option<(Arc, Arc<[Str]>)> { + let ps1 = find_ps1_sibling(resolved, workspace_root)?; + let ps1_rel = pathdiff::diff_paths(ps1.as_path(), cwd.as_path())?; + let ps1_rel_str = ps1_rel.to_str()?.cow_replace('\\', "/"); + + tracing::debug!( + "rewriting .cmd shim to powershell: {} -> {} -File {}", + resolved.as_path().display(), + host.as_path().display(), + ps1_rel_str, + ); + + let new_args: Arc<[Str]> = POWERSHELL_PREFIX + .iter() + .copied() + .map(Str::from) + .chain(std::iter::once(Str::from(ps1_rel_str.as_ref()))) + .chain(args.iter().cloned()) + .collect(); + + Some((Arc::clone(host), new_args)) +} + +#[cfg(any(windows, test))] +fn find_ps1_sibling( + resolved: &AbsolutePath, + workspace_root: &AbsolutePath, +) -> Option { + let path = resolved.as_path(); + let ext = path.extension().and_then(|e| e.to_str())?; + if !ext.eq_ignore_ascii_case("cmd") { + return None; + } + + // Must live inside the workspace so we don't retarget system-wide / + // globally installed shims (e.g. a user's `%AppData%\npm\node_modules\.bin`). + if !path.starts_with(workspace_root.as_path()) { + return None; + } + + let mut parents = path.components().rev(); + parents.next()?; // shim filename + if !parents.next()?.as_os_str().eq_ignore_ascii_case(".bin") { + return None; + } + if !parents.next()?.as_os_str().eq_ignore_ascii_case("node_modules") { + return None; + } + + let ps1 = path.with_extension("ps1"); + if !ps1.is_file() { + return None; + } + + AbsolutePathBuf::new(ps1) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{AbsolutePath, AbsolutePathBuf, Arc, Str, rewrite_with_host}; + + #[expect(clippy::disallowed_types, reason = "tempdir bridges std PathBuf into AbsolutePath")] + fn abs(buf: std::path::PathBuf) -> Arc { + Arc::::from(AbsolutePathBuf::new(buf).unwrap()) + } + + #[expect(clippy::disallowed_types, reason = "tempdir hands out std Path for the test root")] + fn bin_dir(root: &std::path::Path) -> std::path::PathBuf { + let bin = root.join("node_modules").join(".bin"); + fs::create_dir_all(&bin).unwrap(); + bin + } + + #[test] + fn rewrites_cmd_to_cwd_relative_ps1_at_workspace_root() { + let dir = tempdir().unwrap(); + let workspace = abs(dir.path().canonicalize().unwrap()); + let bin = bin_dir(workspace.as_path()); + fs::write(bin.join("vite.CMD"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let host = abs(workspace.as_path().join("powershell.exe")); + let resolved = abs(bin.join("vite.CMD")); + let args: Arc<[Str]> = Arc::from(vec![Str::from("--port"), Str::from("3000")]); + + let (program, rewritten_args) = + rewrite_with_host(&resolved, &args, &workspace, &workspace, &host) + .expect("should rewrite"); + + assert_eq!(program.as_path(), host.as_path()); + let as_strs: Vec<&str> = rewritten_args.iter().map(Str::as_str).collect(); + assert_eq!( + as_strs, + vec![ + "-NoProfile", + "-NoLogo", + "-ExecutionPolicy", + "Bypass", + "-File", + "node_modules/.bin/vite.ps1", + "--port", + "3000", + ] + ); + } + + #[test] + fn rewrites_cmd_to_cwd_relative_ps1_in_hoisted_monorepo_subpackage() { + // Task cwd is `/packages/foo`; shim lives at hoisted + // `/node_modules/.bin/vite.ps1`. The recorded argument should + // traverse up to the workspace and back down into node_modules/.bin. + let dir = tempdir().unwrap(); + let workspace = abs(dir.path().canonicalize().unwrap()); + let bin = bin_dir(workspace.as_path()); + fs::write(bin.join("vite.cmd"), "").unwrap(); + fs::write(bin.join("vite.ps1"), "").unwrap(); + + let sub_pkg_path = workspace.as_path().join("packages").join("foo"); + fs::create_dir_all(&sub_pkg_path).unwrap(); + let sub_pkg = abs(sub_pkg_path); + + let host = abs(workspace.as_path().join("powershell.exe")); + let resolved = abs(bin.join("vite.cmd")); + let args: Arc<[Str]> = Arc::from(vec![]); + + let (_program, rewritten_args) = + rewrite_with_host(&resolved, &args, &sub_pkg, &workspace, &host) + .expect("should rewrite"); + + assert_eq!( + rewritten_args.get(5).map(Str::as_str), + Some("../../node_modules/.bin/vite.ps1") + ); + } + + #[test] + fn returns_none_when_no_ps1_sibling() { + let dir = tempdir().unwrap(); + let workspace = abs(dir.path().canonicalize().unwrap()); + let bin = bin_dir(workspace.as_path()); + fs::write(bin.join("vite.cmd"), "").unwrap(); + + let host = abs(workspace.as_path().join("powershell.exe")); + let resolved = abs(bin.join("vite.cmd")); + let args: Arc<[Str]> = Arc::from(vec![Str::from("build")]); + + assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host).is_none()); + } + + #[test] + fn returns_none_for_cmd_outside_node_modules_bin() { + let dir = tempdir().unwrap(); + let workspace = abs(dir.path().canonicalize().unwrap()); + fs::write(workspace.as_path().join("where.cmd"), "").unwrap(); + fs::write(workspace.as_path().join("where.ps1"), "").unwrap(); + + let host = abs(workspace.as_path().join("powershell.exe")); + let resolved = abs(workspace.as_path().join("where.cmd")); + let args: Arc<[Str]> = Arc::from(vec![]); + + assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host).is_none()); + } + + #[test] + fn returns_none_for_non_shim_extensions() { + let dir = tempdir().unwrap(); + let workspace = abs(dir.path().canonicalize().unwrap()); + let bin = bin_dir(workspace.as_path()); + fs::write(bin.join("node.exe"), "").unwrap(); + fs::write(bin.join("node.ps1"), "").unwrap(); + + let host = abs(workspace.as_path().join("powershell.exe")); + let resolved = abs(bin.join("node.exe")); + let args: Arc<[Str]> = Arc::from(vec![Str::from("--version")]); + + assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host).is_none()); + } + + #[test] + fn returns_none_for_cmd_outside_workspace() { + // Globally installed shim (e.g. `%AppData%\npm\node_modules\.bin\foo.cmd`) + // that matches every structural check — `.cmd` extension, under + // `node_modules/.bin`, sibling `.ps1` present — but lives outside the + // project. The rewrite must stay hands-off so unrelated user tooling + // isn't silently retargeted. + let dir = tempdir().unwrap(); + let root = abs(dir.path().canonicalize().unwrap()); + let workspace_path = root.as_path().join("workspace"); + fs::create_dir_all(&workspace_path).unwrap(); + let workspace = abs(workspace_path); + + let global_bin = root.as_path().join("global").join("node_modules").join(".bin"); + fs::create_dir_all(&global_bin).unwrap(); + fs::write(global_bin.join("vite.cmd"), "").unwrap(); + fs::write(global_bin.join("vite.ps1"), "").unwrap(); + + let host = abs(root.as_path().join("powershell.exe")); + let resolved = abs(global_bin.join("vite.cmd")); + let args: Arc<[Str]> = Arc::from(vec![]); + + assert!(rewrite_with_host(&resolved, &args, &workspace, &workspace, &host).is_none()); + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/package.json @@ -0,0 +1 @@ +{} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/packages/foo/node_modules/.bin/vite.cmd b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/packages/foo/node_modules/.bin/vite.cmd new file mode 100644 index 00000000..e69de29b diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/packages/foo/node_modules/.bin/vite.ps1 b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/packages/foo/node_modules/.bin/vite.ps1 new file mode 100644 index 00000000..e69de29b diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/packages/foo/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/packages/foo/package.json new file mode 100644 index 00000000..2392b6f1 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/packages/foo/package.json @@ -0,0 +1,6 @@ +{ + "name": "foo", + "scripts": { + "dev": "vite --port 3000" + } +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/pnpm-workspace.yaml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml new file mode 100644 index 00000000..104500af --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots.toml @@ -0,0 +1,17 @@ +# Windows-only: verifies the `.cmd` → PowerShell rewrite at plan time, and +# that the resulting cache key stays portable regardless of how the user +# navigated to the sub-package task. Both plan cases below target the same +# task; their snapshots should be byte-identical apart from the plan query +# itself, proving the cache key doesn't depend on invocation style. +platform = "windows" + +# Direct invocation: user `cd`'d into the sub-package before running. +[[plan]] +name = "dev_in_subpackage" +cwd = "packages/foo" +args = ["run", "dev"] + +# Same sub-package task reached via `--filter` from the workspace root. +[[plan]] +name = "dev_filter_from_root" +args = ["run", "--filter", "./packages/foo", "dev"] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc new file mode 100644 index 00000000..e235a1f0 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc @@ -0,0 +1,97 @@ +// run --filter ./packages/foo dev +{ + "graph": [ + { + "key": [ + "/packages/foo", + "dev" + ], + "node": { + "task_display": { + "package_name": "foo", + "task_name": "dev", + "package_path": "/packages/foo" + }, + "items": [ + { + "execution_item_display": { + "task_display": { + "package_name": "foo", + "task_name": "dev", + "package_path": "/packages/foo" + }, + "command": "vite --port 3000", + "and_item_index": null, + "cwd": "/packages/foo" + }, + "kind": { + "Leaf": { + "Spawn": { + "cache_metadata": { + "spawn_fingerprint": { + "cwd": "packages/foo", + "program_fingerprint": { + "OutsideWorkspace": { + "program_name": "" + } + }, + "args": [ + "-NoProfile", + "-NoLogo", + "-ExecutionPolicy", + "Bypass", + "-File", + "node_modules/.bin/vite.ps1", + "--port", + "3000" + ], + "env_fingerprints": { + "fingerprinted_envs": {}, + "untracked_env_config": [ + "" + ] + } + }, + "execution_cache_key": { + "UserTask": { + "task_name": "dev", + "and_item_index": 0, + "extra_args": [], + "package_path": "packages/foo" + } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + }, + "spawn_command": { + "program_path": "", + "args": [ + "-NoProfile", + "-NoLogo", + "-ExecutionPolicy", + "Bypass", + "-File", + "node_modules/.bin/vite.ps1", + "--port", + "3000" + ], + "all_envs": { + "NO_COLOR": "1", + "PATH": "/packages/foo/node_modules/.bin:/node_modules/.bin:" + }, + "cwd": "/packages/foo" + } + } + } + } + } + ] + }, + "neighbors": [] + } + ], + "concurrency_limit": 4 +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc new file mode 100644 index 00000000..a7e87dd0 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc @@ -0,0 +1,97 @@ +// run dev +{ + "graph": [ + { + "key": [ + "/packages/foo", + "dev" + ], + "node": { + "task_display": { + "package_name": "foo", + "task_name": "dev", + "package_path": "/packages/foo" + }, + "items": [ + { + "execution_item_display": { + "task_display": { + "package_name": "foo", + "task_name": "dev", + "package_path": "/packages/foo" + }, + "command": "vite --port 3000", + "and_item_index": null, + "cwd": "/packages/foo" + }, + "kind": { + "Leaf": { + "Spawn": { + "cache_metadata": { + "spawn_fingerprint": { + "cwd": "packages/foo", + "program_fingerprint": { + "OutsideWorkspace": { + "program_name": "" + } + }, + "args": [ + "-NoProfile", + "-NoLogo", + "-ExecutionPolicy", + "Bypass", + "-File", + "node_modules/.bin/vite.ps1", + "--port", + "3000" + ], + "env_fingerprints": { + "fingerprinted_envs": {}, + "untracked_env_config": [ + "" + ] + } + }, + "execution_cache_key": { + "UserTask": { + "task_name": "dev", + "and_item_index": 0, + "extra_args": [], + "package_path": "packages/foo" + } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + }, + "spawn_command": { + "program_path": "", + "args": [ + "-NoProfile", + "-NoLogo", + "-ExecutionPolicy", + "Bypass", + "-File", + "node_modules/.bin/vite.ps1", + "--port", + "3000" + ], + "all_envs": { + "NO_COLOR": "1", + "PATH": "/packages/foo/node_modules/.bin:/node_modules/.bin:" + }, + "cwd": "/packages/foo" + } + } + } + } + } + ] + }, + "neighbors": [] + } + ], + "concurrency_limit": 4 +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/task_graph.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/task_graph.jsonc new file mode 100644 index 00000000..9eb63892 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/task_graph.jsonc @@ -0,0 +1,37 @@ +// task graph +[ + { + "key": [ + "/packages/foo", + "dev" + ], + "node": { + "task_display": { + "package_name": "foo", + "task_name": "dev", + "package_path": "/packages/foo" + }, + "resolved_config": { + "command": "vite --port 3000", + "resolved_options": { + "cwd": "/packages/foo", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/vite-task.json new file mode 100644 index 00000000..d548edfa --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/vite-task.json @@ -0,0 +1,3 @@ +{ + "cache": true +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/main.rs b/crates/vite_task_plan/tests/plan_snapshots/main.rs index 6ebf7504..72641b40 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/main.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/main.rs @@ -42,10 +42,30 @@ struct Plan { #[derive(serde::Deserialize, Default)] struct SnapshotsFile { + /// Optional platform filter: `"unix"` or `"windows"`. If set, the whole + /// fixture only runs on that platform. Fixtures whose filter doesn't + /// match the current build are dropped during trial enumeration so they + /// don't show up as "passed" when they never ran. Mirrors the per-case + /// filter used by the e2e harness. + #[serde(default)] + pub platform: Option, #[serde(rename = "plan", default)] // toml usually uses singular for arrays pub plan_cases: Vec, } +/// Returns whether the current build should run the fixture. Panics on an +/// unknown platform string so typos surface loudly. +fn should_run_on_this_platform(platform: Option<&Str>) -> bool { + match platform.map(Str::as_str) { + None => true, + Some("unix") => cfg!(unix), + Some("windows") => cfg!(windows), + Some(other) => { + panic!("Unknown platform filter '{other}' — expected \"unix\" or \"windows\"") + } + } +} + /// Compact plan: maps `"relative_path#task_name"` to either just neighbors (simple) /// or `{ items, neighbors }` when the node has nested `Expanded` execution items. #[derive(Serialize)] @@ -121,16 +141,32 @@ fn assert_identifier_like(kind: &str, value: &str) { ); } +#[expect( + clippy::disallowed_types, + reason = "Path required for fixture handling; String required by std::fs::read and toml::from_slice" +)] +fn load_snapshots_file(fixture_path: &std::path::Path, fixture_name: &str) -> SnapshotsFile { + let cases_toml_path = fixture_path.join("snapshots.toml"); + match std::fs::read(&cases_toml_path) { + Ok(content) => toml::from_slice(&content).unwrap_or_else(|err| { + panic!("Failed to parse snapshots.toml for fixture {fixture_name}: {err}") + }), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => SnapshotsFile::default(), + Err(err) => panic!("Failed to read snapshots.toml for fixture {fixture_name}: {err}"), + } +} + #[expect(clippy::disallowed_types, reason = "Path required for fixture path handling")] fn run_case( runtime: &Runtime, tmpdir: &AbsolutePath, fixture_path: &std::path::Path, + cases_file: SnapshotsFile, ) -> Result<(), String> { let fixture_name = fixture_path.file_name().unwrap().to_str().unwrap(); assert_identifier_like("fixture folder", fixture_name); let snapshots = snapshot_test::Snapshots::new(fixture_path.join("snapshots")); - run_case_inner(runtime, tmpdir, fixture_path, fixture_name, &snapshots) + run_case_inner(runtime, tmpdir, fixture_path, fixture_name, &snapshots, cases_file) } #[expect( @@ -144,6 +180,7 @@ fn run_case_inner( fixture_path: &std::path::Path, fixture_name: &str, snapshots: &snapshot_test::Snapshots, + cases_file: SnapshotsFile, ) -> Result<(), String> { // Copy the case directory to a temporary directory to avoid discovering workspace outside of the test case. let stage_path = tmpdir.join(fixture_name); @@ -156,13 +193,6 @@ fn run_case_inner( "folder '{fixture_name}' should be a workspace root" ); - let cases_toml_path = fixture_path.join("snapshots.toml"); - let cases_file: SnapshotsFile = match std::fs::read(&cases_toml_path) { - Ok(content) => toml::from_slice(&content).unwrap(), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => SnapshotsFile::default(), - Err(err) => panic!("Failed to read cases.toml for fixture {fixture_name}: {err}"), - }; - let fake_bin_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()) .join("tests/plan_snapshots/fake-bin"); let combined_path = @@ -306,13 +336,22 @@ fn main() { let tests: Vec = fixture_paths .into_iter() - .map(|fixture_path| { - let name = fixture_path.file_name().unwrap().to_str().unwrap().to_owned(); + .filter_map(|fixture_path| { + // Parse `snapshots.toml` once. Fixtures whose platform filter + // doesn't match the current build are dropped entirely — if we + // early-returned from the test body instead they'd report as + // "passed" without having run. + let fixture_name = fixture_path.file_name().unwrap().to_str().unwrap().to_owned(); + let cases_file = load_snapshots_file(&fixture_path, &fixture_name); + if !should_run_on_this_platform(cases_file.platform.as_ref()) { + return None; + } + let tmp_dir_path = tmp_dir_path.clone(); let runtime = Arc::clone(&tokio_runtime); - libtest_mimic::Trial::test(name, move || { - run_case(&runtime, &tmp_dir_path, &fixture_path).map_err(Into::into) - }) + Some(libtest_mimic::Trial::test(fixture_name, move || { + run_case(&runtime, &tmp_dir_path, &fixture_path, cases_file).map_err(Into::into) + })) }) .collect(); diff --git a/crates/vite_task_plan/tests/plan_snapshots/redact.rs b/crates/vite_task_plan/tests/plan_snapshots/redact.rs index 01e8ca3d..e34702ab 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/redact.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/redact.rs @@ -29,6 +29,40 @@ fn redact_string_in_json(value: &mut serde_json::Value, redactions: &[(&str, &st }); } +/// Replace the bare name `pwsh` / `powershell` (post-extension-strip) with a +/// stable placeholder. The two hosts are interchangeable for our purposes, +/// so the snapshot shouldn't care which one Windows CI happens to find. +#[expect( + clippy::disallowed_types, + reason = "String mutation required by serde_json::Value::String which stores a String" +)] +fn redact_powershell_program_name(s: &mut String) { + if s == "pwsh" || s == "powershell" { + *s = "".to_string(); + } +} + +/// Replace an absolute PowerShell-host path (post-extension-strip) with a +/// stable placeholder so the snapshot doesn't pin a particular runner image's +/// system layout (e.g. `C:\Windows\System32\WindowsPowerShell\v1.0\powershell` +/// vs `C:\Program Files\PowerShell\7\pwsh`). +#[expect( + clippy::disallowed_types, + reason = "String mutation required by serde_json::Value::String which stores a String" +)] +fn redact_powershell_program_path(s: &mut String) { + // Short-circuit by scanning for a substring would risk missing Windows + // paths with unexpected casing (e.g. `WINDOWSPOWERSHELL`), so just + // normalize. `cow_replace` and `cow_to_lowercase` both return + // `Cow::Borrowed` when there's nothing to change — typical lowercase + // POSIX paths cost one chained copy via `into_owned`, not a full scan. + let replaced = s.as_str().cow_replace('\\', "/"); + let normalized = replaced.cow_to_lowercase(); + if normalized.ends_with("/powershell") || normalized.ends_with("/pwsh") { + *s = "".to_string(); + } +} + /// Strip Windows executable extensions (case-insensitive) for cross-platform consistency #[expect( clippy::disallowed_types, @@ -60,6 +94,7 @@ fn redact_string(s: &mut String, redactions: &[(&str, &str)]) { } } +#[expect(clippy::too_many_lines, reason = "linear sequence of redaction passes")] pub fn redact_snapshot(value: &impl Serialize, workspace_root: &str) -> serde_json::Value { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); #[expect(clippy::disallowed_types, reason = "PathBuf needed to build fake-bin path from env")] @@ -109,10 +144,12 @@ pub fn redact_snapshot(value: &impl Serialize, workspace_root: &str) -> serde_js // Normalize program_name field if let Some(serde_json::Value::String(program_name)) = map.get_mut("program_name") { strip_windows_executable_extension(program_name); + redact_powershell_program_name(program_name); } // Normalize program_path field if let Some(serde_json::Value::String(program_path)) = map.get_mut("program_path") { strip_windows_executable_extension(program_path); + redact_powershell_program_path(program_path); } });