diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 897275001d..a20dc402ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1102,6 +1102,18 @@ jobs: with: tool: cargo-nextest + # Keep fixtures outside runner.temp: setup-node writes an .npmrc there, + # which Yarn Classic discovers when walking parent directories. + - name: Create NTFS temp directory for case-sensitive shim snapshots + if: matrix.shard == 1 + id: snapshot-temp + shell: pwsh + run: | + $volume = [System.IO.Path]::GetPathRoot($env:RUNNER_TEMP) + $snapshotTemp = Join-Path $volume ('vp-snapshots-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $snapshotTemp | Out-Null + "directory=$snapshotTemp" >> $env:GITHUB_OUTPUT + # `cargo-nextest` is invoked directly so the job never depends on the # runner's Rust toolchain. --workspace-remap makes nextest rewrite # CARGO_MANIFEST_DIR and CARGO_BIN_EXE_vpt to this checkout, which is @@ -1121,7 +1133,20 @@ jobs: export VP_SNAP_PWSH_BIN="$(cygpath -w "$(command -v pwsh.exe)")" # --no-fail-fast: on a snapshot suite every diff is diagnostic # signal; cancelling on the first failure hides the rest. - cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast --partition hash:${{ matrix.shard }}/3 + test_exit=0 + # This fixture requires case-sensitive directory support, which the + # default temp directory on these runners does not provide. + cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast --partition hash:${{ matrix.shard }}/3 \ + -E 'not test(windows_case_sensitive_shims)' || test_exit=$? + + if [[ '${{ matrix.shard }}' == '1' ]]; then + # Run both flavors on NTFS once. Rust's GetTempPath2 ignores + # TEMP/TMP under the SYSTEM account, so override SystemTemp. + SystemTemp='${{ steps.snapshot-temp.outputs.directory }}' \ + cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast \ + -E 'test(windows_case_sensitive_shims)' || test_exit=$? + fi + exit "$test_exit" env: RUST_BACKTRACE: '1' # Keep Windows env parity with the `test` recipe in justfile. diff --git a/Cargo.lock b/Cargo.lock index e7e53025df..b7155d0f89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8923,6 +8923,7 @@ name = "vp_shared" version = "0.0.0" dependencies = [ "console", + "cow-utils", "directories", "nix 0.30.1", "reqwest", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/later-shim.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/later-shim.cjs new file mode 100644 index 0000000000..c1c6e6807f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/later-shim.cjs @@ -0,0 +1,4 @@ +const fs = require('node:fs'); + +fs.mkdirSync('node_modules/.bin', { recursive: true }); +fs.writeFileSync('node_modules/.bin/astro.CMD', '@echo wrong workspace shim %*\r\n'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/local-cli.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/local-cli.cjs new file mode 100644 index 0000000000..6a616b505b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/local-cli.cjs @@ -0,0 +1,13 @@ +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +const vp = path.join(path.dirname(require.resolve('vite-plus/package.json')), 'bin', 'vp'); +// The runner's node shim can expand PATHEXT before this script starts. +// Reset it in a new process that launches real Node directly, so only the +// NAPI initializer can supply the lowercase extension for this CLI lookup. +execFileSync(process.execPath, [vp, 'exec', 'astro', '--version'], { + cwd: path.resolve('packages/app'), + env: { ...process.env, PATHEXT: '.COM;.EXE;.BAT;.CMD' }, + stdio: 'inherit', + timeout: 30000, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/package.json new file mode 100644 index 0000000000..349202b9ec --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/package.json @@ -0,0 +1,8 @@ +{ + "name": "case-sensitive-shims", + "private": true, + "packageManager": "pnpm@12.4.1", + "scripts": { + "probe": "vp run --filter @fixture/app probe" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json new file mode 100644 index 0000000000..5b3bcd0b65 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/package.json @@ -0,0 +1,8 @@ +{ + "name": "@fixture/app", + "private": true, + "scripts": { + "probe": "astro --version", + "priority": "node-priority -e \"process.exit(0)\"" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/print.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/print.cjs new file mode 100644 index 0000000000..2397d28d99 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/print.cjs @@ -0,0 +1 @@ +console.log('local shim', ...process.argv.slice(2)); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/vite.config.ts new file mode 100644 index 0000000000..d6d4b1aeea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/packages/app/vite.config.ts @@ -0,0 +1,9 @@ +export default { + run: { + tasks: { + cached: { + command: "astro cached", + }, + }, + }, +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs new file mode 100644 index 0000000000..f9f8a36665 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/setup.cjs @@ -0,0 +1,16 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const bin = path.resolve('packages/app/node_modules/.bin'); +fs.mkdirSync(bin, { recursive: true }); +// The directory must be empty when case sensitivity is enabled. Do not depend +// on the checkout's NTFS flags or the casing chosen by a package manager. +execFileSync('fsutil.exe', ['file', 'setCaseSensitiveInfo', bin, 'enable'], { stdio: 'inherit' }); +fs.writeFileSync(path.join(bin, 'astro.cmd'), '@echo off\r\nnode "%~dp0/../../print.cjs" %*\r\n'); +// A lowercase executable must win over an uppercase command shim in the same directory. +fs.copyFileSync(process.execPath, path.join(bin, 'node-priority.exe')); +fs.writeFileSync(path.join(bin, 'node-priority.CMD'), '@echo wrong cmd shim\r\n@exit /b 1\r\n'); +assert.equal(fs.existsSync(path.join(bin, 'astro.CMD')), false); +assert.ok(process.env.PATHEXT.split(';').includes('.CMD')); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml new file mode 100644 index 0000000000..5ff306114c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots.toml @@ -0,0 +1,27 @@ +[[case]] +name = "windows_case_sensitive_shims" +vp = ["local", "global"] +skip-platforms = ["linux", "macos"] +env = { PATHEXT = ".COM;.EXE;.BAT;.CMD" } +steps = [ + { argv = ["node", "setup.cjs"], snapshot = false }, + { argv = ["vp", "run", "priority"], cwd = "packages/app", tty = false, snapshot = false }, + { argv = ["vp", "exec", "node-priority", "-e", "process.exit(0)"], cwd = "packages/app", tty = false, snapshot = false }, + { argv = ["vp", "run", "probe"], tty = false, comment = "Nested task planning resolves the lowercase shim with uppercase PATHEXT." }, + { argv = ["vp", "exec", "astro", "--version"], cwd = "packages/app", tty = false }, + { argv = ["node", "later-shim.cjs"], snapshot = false }, + { argv = ["vp", "run", "probe"], tty = false, comment = "The earlier package-local lowercase shim wins over a later uppercase shim." }, + { argv = ["vp", "exec", "astro", "--version"], cwd = "packages/app", tty = false }, + { argv = ["vp", "run", "cached"], cwd = "packages/app", tty = false }, + { argv = ["vp", "run", "cached"], cwd = "packages/app", tty = false, comment = "Resolving the program keeps the task's cache behavior." }, +] + +[[case]] +name = "windows_case_sensitive_shims_external_node" +vp = "local" +skip-platforms = ["linux", "macos"] +env = { PATHEXT = ".COM;.EXE;.BAT;.CMD" } +steps = [ + { argv = ["node", "setup.cjs"], snapshot = false }, + { argv = ["node", "local-cli.cjs"], tty = false, comment = "The local CLI resolves the lowercase shim when real Node starts with uppercase PATHEXT." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md new file mode 100644 index 0000000000..6a63dd9f6a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.global.md @@ -0,0 +1,62 @@ +# windows_case_sensitive_shims + +## `node setup.cjs` + + +## `cd packages/app && vp run priority` + + +## `cd packages/app && vp exec node-priority -e process.exit(0)` + + +## `vp run probe` + +Nested task planning resolves the lowercase shim with uppercase PATHEXT. + +``` +~/packages/app$ astro --version ⊘ cache disabled +local shim --version +``` + +## `cd packages/app && vp exec astro --version` + +``` +local shim --version +``` + +## `node later-shim.cjs` + + +## `vp run probe` + +The earlier package-local lowercase shim wins over a later uppercase shim. + +``` +~/packages/app$ astro --version ⊘ cache disabled +local shim --version +``` + +## `cd packages/app && vp exec astro --version` + +``` +local shim --version +``` + +## `cd packages/app && vp run cached` + +``` +~/packages/app$ astro cached +local shim cached +``` + +## `cd packages/app && vp run cached` + +Resolving the program keeps the task's cache behavior. + +``` +~/packages/app$ astro cached ◉ cache hit, replaying +local shim cached + +--- +vp run: cache hit, saved. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md new file mode 100644 index 0000000000..6a63dd9f6a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims.local.md @@ -0,0 +1,62 @@ +# windows_case_sensitive_shims + +## `node setup.cjs` + + +## `cd packages/app && vp run priority` + + +## `cd packages/app && vp exec node-priority -e process.exit(0)` + + +## `vp run probe` + +Nested task planning resolves the lowercase shim with uppercase PATHEXT. + +``` +~/packages/app$ astro --version ⊘ cache disabled +local shim --version +``` + +## `cd packages/app && vp exec astro --version` + +``` +local shim --version +``` + +## `node later-shim.cjs` + + +## `vp run probe` + +The earlier package-local lowercase shim wins over a later uppercase shim. + +``` +~/packages/app$ astro --version ⊘ cache disabled +local shim --version +``` + +## `cd packages/app && vp exec astro --version` + +``` +local shim --version +``` + +## `cd packages/app && vp run cached` + +``` +~/packages/app$ astro cached +local shim cached +``` + +## `cd packages/app && vp run cached` + +Resolving the program keeps the task's cache behavior. + +``` +~/packages/app$ astro cached ◉ cache hit, replaying +local shim cached + +--- +vp run: cache hit, saved. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims_external_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims_external_node.md new file mode 100644 index 0000000000..1699845cd4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/windows_case_sensitive_shims/snapshots/windows_case_sensitive_shims_external_node.md @@ -0,0 +1,12 @@ +# windows_case_sensitive_shims_external_node + +## `node setup.cjs` + + +## `node local-cli.cjs` + +The local CLI resolves the lowercase shim when real Node starts with uppercase PATHEXT. + +``` +local shim --version +``` diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index 292791805b..4bcae58dea 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -381,6 +381,7 @@ async fn main() -> ExitCode { } vp_shared::ensure_blocking_stdio(); + vp_shared::ensure_windows_pathext(); // Initialize tracing vp_shared::init_tracing(); diff --git a/crates/vp_shared/Cargo.toml b/crates/vp_shared/Cargo.toml index ecc58afb7d..62d4158f57 100644 --- a/crates/vp_shared/Cargo.toml +++ b/crates/vp_shared/Cargo.toml @@ -18,6 +18,7 @@ test-utils = ["dep:temp-env", "dep:tempfile"] directories = { workspace = true } nix = { workspace = true, features = ["fs", "poll", "term"] } console = { workspace = true } +cow-utils = { workspace = true } serde = { workspace = true } # use `preserve_order` feature to preserve the order of the fields in `package.json` serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index ead1abf014..1ec2a26c13 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -39,7 +39,9 @@ pub use json_edit::{JsonStyle, edit_json_object, insert_after}; pub use package_json::{ DevEngineDependency, DevEngineField, DevEngines, Engines, OnFail, PackageJson, dev_engine_entry, }; -pub use path_env::{PrependOptions, ToolPathEnv, prepend_tools_to_path_env}; +pub use path_env::{ + PrependOptions, ToolPathEnv, ensure_windows_pathext, prepend_tools_to_path_env, +}; pub use process::exit_code_from_status; pub use stdio::ensure_blocking_stdio; pub use tls::ensure_tls_provider; diff --git a/crates/vp_shared/src/path_env.rs b/crates/vp_shared/src/path_env.rs index 33ddba5cf8..b8c03c0580 100644 --- a/crates/vp_shared/src/path_env.rs +++ b/crates/vp_shared/src/path_env.rs @@ -1,4 +1,4 @@ -//! PATH environment variable manipulation utilities. +//! PATH and PATHEXT environment variable manipulation utilities. //! //! This module provides functions for prepending directories to the PATH //! environment variable with various deduplication strategies. @@ -9,6 +9,44 @@ use vt_path::AbsolutePath; use crate::env_vars; +/// Add uppercase and lowercase variants of each configured Windows extension. +/// Call during CLI initialization, before `which` caches PATHEXT. This also +/// supplies the same extension order to Vite Task, fspy, and child processes. +pub fn ensure_windows_pathext() { + #[cfg(windows)] + if let Ok(pathext) = env::var("PATHEXT") { + let expanded = expand_pathext_case_variants(&pathext); + if expanded != pathext { + // SAFETY: Environment mutation is thread-safe on Windows. This is + // also called when the NAPI binding loads in a Node.js process. + unsafe { env::set_var("PATHEXT", expanded) }; + } + } +} + +#[cfg(any(windows, test))] +fn expand_pathext_case_variants(pathext: &str) -> String { + use std::borrow::Cow; + + use cow_utils::CowUtils; + + let mut extensions = Vec::new(); + for extension in pathext.split(';') { + // Keep each variant next to its original extension so .EXE and .exe + // both precede .CMD and .cmd in the standard Windows search order. + for variant in [ + Cow::Borrowed(extension), + extension.cow_to_ascii_uppercase(), + extension.cow_to_ascii_lowercase(), + ] { + if !extensions.contains(&variant) { + extensions.push(variant); + } + } + } + extensions.join(";") +} + /// PATH and the tools whose real binary directories Vite+ has injected into it. /// Keep both values together when preparing a child process environment. #[derive(Debug, Clone)] @@ -99,6 +137,22 @@ mod tests { use super::*; + #[test] + fn pathext_keeps_exe_variants_before_cmd_variants() { + assert_eq!( + expand_pathext_case_variants(".COM;.EXE;.BAT;.CMD"), + ".COM;.com;.EXE;.exe;.BAT;.bat;.CMD;.cmd" + ); + } + + #[test] + fn pathext_preserves_custom_order_and_is_idempotent() { + let expanded = expand_pathext_case_variants(".cmd;.exe;.CMD;.PY"); + assert_eq!(expanded, ".cmd;.CMD;.exe;.EXE;.PY;.py"); + assert_eq!(expand_pathext_case_variants(&expanded), expanded); + assert_eq!(expand_pathext_case_variants(""), ""); + } + #[test] fn injected_tools_accumulate_without_changing_other_environments() { let original = ToolPathEnv::new(OsString::new(), "node,npm,npx,node"); diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index e195bfb55b..cb5b1368bb 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -45,6 +45,7 @@ use crate::cli::{ #[allow(clippy::disallowed_macros)] pub fn init() { vp_shared::ensure_blocking_stdio(); + vp_shared::ensure_windows_pathext(); crate::cli::init_tracing(); // Install a Vite+ panic hook so panics are correctly attributed to Vite+.