From 99530ccef73ee17b9a5eff095fc040d6c83e6747 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Thu, 9 Jul 2026 21:06:38 +0800 Subject: [PATCH 1/6] wip --- .../tests/cli_snapshots/README.md | 47 ++-- .../fixtures/global_env_setup/snapshots.toml | 21 ++ .../snapshots/global_home_shims.md | 26 ++ .../snapshots/local_home_shims.md | 29 +++ .../tests/cli_snapshots/flavor.rs | 136 ++++------- .../tests/cli_snapshots/main.rs | 224 ++++++++++++++++-- justfile | 6 +- 7 files changed, 354 insertions(+), 135 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md index d549706e82..3d3ad26cb6 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md @@ -51,11 +51,11 @@ cargo test -p vite_cli_snapshots -- # if vp is already built ``` Trial names are `::` (plus `::` for multi-flavor -cases). Prerequisites: the global flavor needs `cargo build -p -vite_global_cli` (the `just` recipe does it); the local flavor needs `node` -and a built `packages/cli/dist` (`pnpm build`); the runner fails fast when -`dist` is older than `src`, so a forgotten rebuild never silently tests -stale local-CLI code. +cases). Prerequisites: both flavors need `cargo build -p vite_global_cli` +(the `just` recipe does it); the local flavor also needs a built +`packages/cli/dist` (`pnpm build`). The runner fails fast when `dist` is +older than `src`, so a forgotten rebuild never silently tests stale +local-CLI code. Environment overrides, mainly for CI: @@ -83,10 +83,13 @@ steps = [ ... ] after = [ ... ] # cleanup steps, never snapshotted ``` -`vp` picks which CLI runs the case: `"global"` is the Rust binary, -`"local"` is the JS dispatch in `packages/cli/bin`. The list form registers -one trial and one snapshot per flavor; use it for parity cases (help output, -routing, error messages) where both surfaces must agree. +`vp` picks which CLI runs the case. Both flavors install the built Rust binary +into the case's `VP_HOME/current/bin`, install the checkout package under that +case home, and run `vp env setup` before steps. `"global"` exposes only +`VP_HOME/bin`; `"local"` also exposes the case-local +`VP_HOME/current/current/node_modules/vite-plus/bin` package bin. The list form +registers one trial and one snapshot per flavor; use it for parity cases (help +output, routing, error messages) where both surfaces must agree. A step is a bare argv array or a table: @@ -106,12 +109,10 @@ A step is a bare argv array or a table: ``` `argv[0]` may be `vp`, `vpr`, `vpx`, `vpt`, `oxfmt`, `oxlint`, `node`, -`git`, `npm`, `pnpm`, `yarn`, or `bun`. `oxfmt`/`oxlint` are JS shims from -the local CLI build, so they exist under the local flavor only; a global -case uses `vp fmt` / `vp lint` (what global-binary users run) or a shim -the case itself creates. There is no shell: no `&&`, no redirects, no -globs. File setup and assertions go through `vpt` so behavior -is identical on every platform: +`git`, `npm`, `pnpm`, `yarn`, or `bun`, but everything except `vpt` must +resolve from the flavor-owned tool directories described above. There is no +shell: no `&&`, no redirects, no globs. File setup and assertions go through +`vpt` so behavior is identical on every platform: `vpt print-file` (cat), `vpt stat-file` (prints `file`/`dir`/`missing`; `--assert ` / `--assert-not ` also fail on mismatch, so @@ -166,13 +167,15 @@ runner itself (see `fixtures/interactive_probe/`). ## What a step sees -Each case gets a cleared environment: controlled `PATH` (flavor bin dir, -node, system dirs), `TERM=xterm-256color`, `VP_CLI_TEST=1`, -`VP_EMIT_MILESTONES=1`, a fresh `HOME`, `VP_HOME`, and npm prefix. `CI` and -`NO_COLOR` are deliberately NOT set: with a PTY attached, the CLI behaves -interactively by default, which is the point. `seed-runtime = true` -(default) symlinks a provisioned managed Node runtime into the case -`VP_HOME` so commands do not download ~50MB per case. +Each case gets a cleared environment: controlled `PATH` (case-owned tool dirs, +then a system tail for child processes), `TERM=xterm-256color`, +`VP_CLI_TEST=1`, `VP_EMIT_MILESTONES=1`, a fresh `HOME`, `VP_HOME`, and npm +prefix. The runner still rejects direct step tools that resolve outside the +case-owned dirs; the system tail is only for child processes such as `git` +inside real `vp create` flows. `CI` and `NO_COLOR` are deliberately NOT set: +with a PTY attached, the CLI behaves interactively by default, which is the +point. `seed-runtime = true` (default) symlinks a provisioned managed Node +runtime into the case `VP_HOME` so commands do not download ~50MB per case. Fixture configs may import bare `vite-plus` and `@voidzero-dev/vite-plus-core`: the runner links the checkout packages diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml new file mode 100644 index 0000000000..0132fa2605 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml @@ -0,0 +1,21 @@ +[[case]] +name = "global_home_shims" +vp = "global" +skip-platforms = ["windows"] +comment = "The global flavor installs the case-local standalone layout and only exposes VP_HOME/bin tools." +steps = [ + { argv = ["vp", "--version"], comment = "vp resolves from the case home shim", snapshot = false }, + { argv = ["node", "--version"], comment = "node resolves from the case home shim", snapshot = false }, + { argv = ["vpt", "stat-file", "../home/.vite-plus/current/bin/vp", "../home/.vite-plus/current/node_modules/vite-plus/bin/vp", "../home/.vite-plus/bin/vp", "../home/.vite-plus/bin/vpr", "../home/.vite-plus/bin/vpx", "../home/.vite-plus/bin/node", "--assert", "file"], comment = "vp env setup created the shims inside the case home" }, +] + +[[case]] +name = "local_home_shims" +vp = "local" +skip-platforms = ["windows"] +comment = "The local flavor installs the case-local standalone layout, then exposes the case-local package bin." +steps = [ + { argv = ["vp", "--version"], comment = "vp resolves from the case-local package bin", snapshot = false }, + { argv = ["node", "--version"], comment = "node resolves from the case home shim", snapshot = false }, + { argv = ["vpt", "stat-file", "../home/.vite-plus/current/bin/vp", "../home/.vite-plus/current/current/node_modules/vite-plus/bin/vp", "../home/.vite-plus/current/current/node_modules/vite-plus/bin/vpr", "../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxfmt", "../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxlint", "../home/.vite-plus/bin/vp", "../home/.vite-plus/bin/vpr", "../home/.vite-plus/bin/vpx", "../home/.vite-plus/bin/node", "--assert", "file"], comment = "vp env setup created shims and the local package bin is inside the case home" }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md new file mode 100644 index 0000000000..e300aa239b --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md @@ -0,0 +1,26 @@ +# global_home_shims + +The global flavor installs the case-local standalone layout and only exposes VP_HOME/bin tools. + +## `vp --version` + +vp resolves from the case home shim + + +## `node --version` + +node resolves from the case home shim + + +## `vpt stat-file ../home/.vite-plus/current/bin/vp ../home/.vite-plus/current/node_modules/vite-plus/bin/vp ../home/.vite-plus/bin/vp ../home/.vite-plus/bin/vpr ../home/.vite-plus/bin/vpx ../home/.vite-plus/bin/node --assert file` + +vp env setup created the shims inside the case home + +``` +../home/.vite-plus/current/bin/vp: file +../home/.vite-plus/current/node_modules/vite-plus/bin/vp: file +../home/.vite-plus/bin/vp: file +../home/.vite-plus/bin/vpr: file +../home/.vite-plus/bin/vpx: file +../home/.vite-plus/bin/node: file +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md new file mode 100644 index 0000000000..aad65a8ba8 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md @@ -0,0 +1,29 @@ +# local_home_shims + +The local flavor installs the case-local standalone layout, then exposes the case-local package bin. + +## `vp --version` + +vp resolves from the case-local package bin + + +## `node --version` + +node resolves from the case home shim + + +## `vpt stat-file ../home/.vite-plus/current/bin/vp ../home/.vite-plus/current/current/node_modules/vite-plus/bin/vp ../home/.vite-plus/current/current/node_modules/vite-plus/bin/vpr ../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxfmt ../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxlint ../home/.vite-plus/bin/vp ../home/.vite-plus/bin/vpr ../home/.vite-plus/bin/vpx ../home/.vite-plus/bin/node --assert file` + +vp env setup created shims and the local package bin is inside the case home + +``` +../home/.vite-plus/current/bin/vp: file +../home/.vite-plus/current/current/node_modules/vite-plus/bin/vp: file +../home/.vite-plus/current/current/node_modules/vite-plus/bin/vpr: file +../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxfmt: file +../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxlint: file +../home/.vite-plus/bin/vp: file +../home/.vite-plus/bin/vpr: file +../home/.vite-plus/bin/vpx: file +../home/.vite-plus/bin/node: file +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs b/crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs index a892adc86a..35e69baae9 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs @@ -1,19 +1,13 @@ //! Provisioning for the two `vp` flavors a case can run under. //! -//! - `global`: the Rust binary built from `crates/vite_global_cli`, resolved -//! from the target directory next to this test executable. -//! - `local`: the JS CLI dispatch scripts in `packages/cli/bin`, which require -//! `node` on `PATH` and a built `packages/cli/dist`. +//! Both flavors install the built global `vp` binary into each case's isolated +//! `VP_HOME` and run `vp env setup`. The local flavor additionally exposes the +//! checkout package's JS bin directory from inside that same case home. //! -//! Each flavor gets one bin directory per run (created under the run temp -//! root) that fronts exactly the executables a fixture may invoke; per-case -//! state isolation happens through `VP_HOME`/`HOME`, not through the bin dir. +//! Each flavor gets one runner bin directory per run (created under the run +//! temp root) for runner-owned helpers. Only `vpt` lives there. -use std::{ - env::{join_paths, split_paths}, - ffi::OsString, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; #[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -34,11 +28,10 @@ impl Flavor { /// Everything the runner needs to spawn commands under one flavor. pub struct FlavorRuntime { pub bin_dir: PathBuf, - /// `VITE_GLOBAL_CLI_JS_SCRIPTS_DIR` value for the global flavor. - pub js_scripts_dir: Option, - /// Baseline `PATH` (bin dir, node, system tail); node and the other real - /// tools resolve through the per-case PATH derived from this. - pub path_env: OsString, + /// Source global `vp` binary to install into each case's `VP_HOME/current`. + pub global_vp: PathBuf, + /// Source JS CLI bin dir for the local flavor. + pub local_cli_bin_dir: Option, } /// The runner crate's manifest dir. The runtime env var wins: cargo sets it @@ -143,6 +136,10 @@ fn local_cli_bin_dir() -> Result { Ok(bin_dir) } +pub fn cli_package_dir() -> PathBuf { + repo_root().join("packages/cli") +} + fn newest_mtime(dir: &Path) -> Option { let mut newest = None; let entries = std::fs::read_dir(dir).ok()?; @@ -231,6 +228,16 @@ fn install_tool(bin_dir: &Path, name: &str, target: &Path) -> Result<(), String> } } +/// Installs a real executable file. Used for the standalone global layout under +/// `VP_HOME/current/bin`, where symlinking to the build output would test the +/// wrong installation shape. +pub fn install_file(dest: &Path, source: &Path, label: &str) -> Result<(), String> { + let source = std::fs::canonicalize(source).unwrap_or_else(|_| source.to_path_buf()); + std::fs::hard_link(&source, dest) + .or_else(|_| std::fs::copy(&source, dest).map(|_| ())) + .map_err(|e| format!("failed to install {label}: {e}")) +} + /// Best-effort directory link. On Windows, directory symlinks may require /// privileges, so a junction (which never does) is the fallback; only if /// both fail does resolution fall back to whatever the fixture vendors. @@ -243,70 +250,16 @@ pub fn link_dir(target: &Path, link: &Path) { } } -fn compose_path_env(bin_dir: &Path, node_dir: &Path) -> OsString { - let mut entries: Vec = vec![bin_dir.to_path_buf(), node_dir.to_path_buf()]; - if cfg!(windows) { - // Windows needs System32 and friends for anything to run; inherit the - // ambient PATH after the controlled entries. - if let Some(path) = std::env::var_os("PATH") { - entries.extend(split_paths(&path)); - } - } else { - // A fixed system tail keeps child processes deterministic: `git` and - // the usual coreutils resolve from the OS, nothing else leaks in. - for dir in ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] { - entries.push(PathBuf::from(dir)); - } - } - join_paths(entries).unwrap() -} - /// Creates the per-run bin directory for `flavor` under `run_root`. pub fn provision(flavor: Flavor, run_root: &Path) -> Result { - let node = which::which("node") - .map_err(|e| format!("`node` not found on PATH (needed by the CLI under test): {e}"))?; - let node_dir = node.parent().ok_or("node has no parent dir")?.to_path_buf(); - let bin_dir = run_root.join(format!("bin-{}", flavor.as_str())); std::fs::create_dir_all(&bin_dir).map_err(|e| format!("failed to create bin dir: {e}"))?; - let js_scripts_dir = match flavor { - Flavor::Global => { - let vp = global_vp_path()?; - // The global binary dispatches on argv0, so the aliases are links - // to the same executable. - for name in ["vp", "vpr", "vpx"] { - install_tool(&bin_dir, name, &vp)?; - } - // Windows `vp env setup` looks for the trampoline template - // (vp-shim.exe) beside vp.exe; carry it over when the source - // build has one, so shim-creating cases work. - #[cfg(windows)] - if let Some(shim) = vp.parent().map(|dir| dir.join("vp-shim.exe")) - && shim.is_file() - { - let dest = bin_dir.join("vp-shim.exe"); - if std::fs::hard_link(&shim, &dest).is_err() { - let _ = std::fs::copy(&shim, &dest); - } - } - Some(repo_root().join("packages/cli/dist")) - } - Flavor::Local => { - let local_bin = local_cli_bin_dir()?; - for name in ["vp", "vpr", "oxfmt", "oxlint"] { - let target = local_bin.join(name); - if target.exists() { - install_tool(&bin_dir, name, &target)?; - } - } - None - } - }; install_tool(&bin_dir, "vpt", &vpt_path()?)?; - let path_env = compose_path_env(&bin_dir, &node_dir); - Ok(FlavorRuntime { bin_dir, js_scripts_dir, path_env }) + let global_vp = global_vp_path()?; + let local_cli_bin_dir = if flavor == Flavor::Local { Some(local_cli_bin_dir()?) } else { None }; + Ok(FlavorRuntime { bin_dir, global_vp, local_cli_bin_dir }) } impl FlavorRuntime { @@ -314,33 +267,25 @@ impl FlavorRuntime { /// `vpt`, and an allow-list of real tools may run as steps; everything /// else belongs behind a `vpt` subcommand so fixtures stay /// platform-identical. Keep the allow-list in sync with - /// `PASSTHROUGH_PROGRAMS` in packages/tools/src/migrate-snap-tests.ts. Real tools resolve through the CASE's `PATH` - /// (which leads with `$VP_HOME/bin`), so shims a case creates via - /// `vp env setup` or global installs take precedence over host tools. + /// `PASSTHROUGH_PROGRAMS` in packages/tools/src/migrate-snap-tests.ts. + /// Real tools resolve through the CASE's `PATH`, then the resolved path is + /// checked against the flavor-owned tool dirs. pub fn resolve_program( &self, program: &str, case_path: &std::ffi::OsStr, cwd: &Path, + allowed_tool_dirs: &[PathBuf], ) -> Result { match program { "vp" | "vpr" | "vpx" | "oxfmt" | "oxlint" => { - // Case PATH first: shims a case creates in $VP_HOME/bin must - // shadow the runner-installed aliases. The flavor bin dir is - // on that PATH too, so this is a pure precedence rule; the - // direct bin-dir lookup below only remains as the fallback - // for cases that override PATH entirely. - if let Ok(found) = which::which_in(program, Some(case_path), cwd) { - return Ok(found); - } - self.bin_dir_tool(program) + self.resolve_case_tool(program, case_path, cwd, allowed_tool_dirs) } // vpt is the runner's own assertion tool: a case-created shim must // never shadow it, so it resolves only from the flavor bin dir. "vpt" => self.bin_dir_tool(program), "node" | "git" | "npm" | "pnpm" | "yarn" | "bun" => { - which::which_in(program, Some(case_path), cwd) - .map_err(|e| format!("`{program}` not found on the case PATH: {e}")) + self.resolve_case_tool(program, case_path, cwd, allowed_tool_dirs) } other => Err(format!( "step program `{other}` is not allowed; use a `vpt` subcommand instead" @@ -348,6 +293,21 @@ impl FlavorRuntime { } } + fn resolve_case_tool( + &self, + program: &str, + case_path: &std::ffi::OsStr, + cwd: &Path, + allowed_tool_dirs: &[PathBuf], + ) -> Result { + let found = which::which_in(program, Some(case_path), cwd) + .map_err(|e| format!("`{program}` not found on the case PATH: {e}"))?; + if allowed_tool_dirs.iter().any(|dir| found.starts_with(dir)) { + return Ok(found); + } + Err(format!("`{program}` resolved outside the flavor-owned tool dirs: {}", found.display())) + } + /// Looks a tool up directly in the flavor bin dir. fn bin_dir_tool(&self, program: &str) -> Result { if cfg!(windows) { diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs index 1f12177189..40f8d80522 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs @@ -39,6 +39,8 @@ const STEP_TIMEOUT: Duration = /// Screen size for the PTY terminal. Large enough to avoid line wrapping. const SCREEN_SIZE: ScreenSize = ScreenSize { rows: 500, cols: 500 }; +const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; + /// Raw serde shape for a step: bare argv array or full table. #[derive(serde::Deserialize, Debug)] #[serde(untagged)] @@ -423,6 +425,11 @@ struct CaseHome { home: PathBuf, } +struct CaseInstall { + path_env: OsString, + allowed_tool_dirs: Vec, +} + impl CaseHome { fn provision(root: &Path, seed_runtime: bool) -> Self { let this = Self { home: root.join("home") }; @@ -436,6 +443,158 @@ impl CaseHome { this } + fn provision_vite_plus( + &self, + flavor: Flavor, + runtime: &FlavorRuntime, + ) -> Result { + let current_bin = self.vp_home().join("current").join("bin"); + std::fs::create_dir_all(¤t_bin) + .map_err(|e| format!("failed to create current/bin dir: {e}"))?; + + let vp = current_bin.join(VP_BINARY_NAME); + flavor::install_file(&vp, &runtime.global_vp, "global vp")?; + + #[cfg(windows)] + { + let shim = runtime + .global_vp + .parent() + .ok_or("global vp source has no parent dir")? + .join("vp-shim.exe"); + if !shim.is_file() { + return Err(format!( + "global vp trampoline template not found at {}; run `cargo build -p vite_trampoline`", + shim.display() + )); + } + flavor::install_file(¤t_bin.join("vp-shim.exe"), &shim, "global vp-shim.exe")?; + } + + let package_dir = match flavor { + Flavor::Global => self.vp_home().join("current").join("node_modules").join("vite-plus"), + Flavor::Local => self + .vp_home() + .join("current") + .join("current") + .join("node_modules") + .join("vite-plus"), + }; + self.install_case_package(&package_dir)?; + #[cfg(windows)] + if flavor == Flavor::Local { + self.write_local_package_cmd_shims(&package_dir)?; + } + self.run_env_setup(&vp)?; + + let vp_bin_dir = self.vp_home().join("bin"); + let allowed_tool_dirs = match flavor { + Flavor::Global => vec![vp_bin_dir], + Flavor::Local => { + runtime.local_cli_bin_dir.as_deref().ok_or( + "internal error: local case reached run_case without a local CLI bin dir", + )?; + vec![package_dir.join("bin"), vp_bin_dir] + } + }; + + Ok(CaseInstall { path_env: compose_case_path_env(&allowed_tool_dirs), allowed_tool_dirs }) + } + + fn install_case_package(&self, package_dir: &Path) -> Result<(), String> { + let source = flavor::cli_package_dir(); + let parent = package_dir.parent().ok_or("case package dir has no parent")?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create case package parent: {e}"))?; + + #[cfg(windows)] + { + CopyOptions::new() + .copy_tree(&source, package_dir) + .map_err(|e| format!("failed to copy case vite-plus package: {e}"))?; + } + + #[cfg(not(windows))] + { + flavor::link_dir(&source, package_dir); + if package_dir.join("package.json").is_file() { + return Ok(()); + } + CopyOptions::new() + .copy_tree(&source, package_dir) + .map_err(|e| format!("failed to copy case vite-plus package: {e}"))?; + } + Ok(()) + } + + #[cfg(windows)] + fn write_local_package_cmd_shims(&self, package_dir: &Path) -> Result<(), String> { + let bin_dir = package_dir.join("bin"); + for name in ["vp", "vpr", "oxfmt", "oxlint"] { + let script = bin_dir.join(name); + if !script.is_file() { + continue; + } + let content = + format!("@echo off\r\nnode \"%~dp0{name}\" %*\r\nexit /b %ERRORLEVEL%\r\n"); + std::fs::write(bin_dir.join(format!("{name}.cmd")), content) + .map_err(|e| format!("failed to write local package {name}.cmd: {e}"))?; + } + Ok(()) + } + + fn run_env_setup(&self, vp: &Path) -> Result<(), String> { + let mut env = BTreeMap::new(); + env.insert("PATH".to_string(), compose_case_path_env(&[])); + env.insert("TERM".to_string(), "xterm-256color".into()); + env.insert("VP_CLI_TEST".to_string(), "1".into()); + env.insert("NODE_NO_WARNINGS".to_string(), "1".into()); + env.insert("VP_HOME".to_string(), self.vp_home().into_os_string()); + if cfg!(windows) { + env.insert("USERPROFILE".to_string(), self.home.clone().into_os_string()); + env.insert( + "PATHEXT".to_string(), + ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC".into(), + ); + for name in [ + "TMP", + "TEMP", + "APPDATA", + "LOCALAPPDATA", + "PROGRAMDATA", + "HOMEDRIVE", + "HOMEPATH", + "WINDIR", + "SYSTEMROOT", + "SYSTEMDRIVE", + "ProgramFiles", + "ProgramFiles(x86)", + ] { + if let Some(value) = std::env::var_os(name) { + env.insert(name.to_string(), value); + } + } + } else { + env.insert("HOME".to_string(), self.home.clone().into_os_string()); + } + let output = std::process::Command::new(vp) + .args(["env", "setup", "--refresh"]) + .env_clear() + .envs(&env) + .output() + .map_err(|e| format!("failed to run `vp env setup`: {e}"))?; + if output.status.success() { + return Ok(()); + } + + Err(format!( + "`vp env setup` failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) + } + fn vp_home(&self) -> PathBuf { self.home.join(flavor::VP_HOME_DIR) } @@ -445,17 +604,27 @@ impl CaseHome { } } +fn compose_case_path_env(tool_dirs: &[PathBuf]) -> OsString { + let mut entries = tool_dirs.to_vec(); + if cfg!(windows) { + if let Some(path) = std::env::var_os("PATH") { + entries.extend(std::env::split_paths(&path)); + } + } else { + for dir in ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] { + entries.push(PathBuf::from(dir)); + } + } + std::env::join_paths(entries).unwrap() +} + /// The environment every step starts from. Deliberately small and fully /// controlled: no ambient variables survive except through the composed PATH. /// Notably absent: `CI` and `NO_COLOR`; the PTY makes real interactive /// behaviour the default, and grid rendering strips styling from snapshots. -fn baseline_env(rt: &FlavorRuntime, case_home: &CaseHome) -> BTreeMap { +fn baseline_env(case_home: &CaseHome, install: &CaseInstall) -> BTreeMap { let mut env: BTreeMap = BTreeMap::new(); - // The case's VP_HOME/bin comes first so `vp env setup` shims take - // precedence over the runner-provided tools once a case creates them. - let mut path_entries = vec![case_home.vp_home().join("bin")]; - path_entries.extend(std::env::split_paths(&rt.path_env)); - env.insert("PATH".into(), std::env::join_paths(path_entries).unwrap()); + env.insert("PATH".into(), install.path_env.clone()); // xterm-256color keeps anstream from stripping the OSC 8 milestone // sequences the runner synchronizes on. env.insert("TERM".into(), "xterm-256color".into()); @@ -481,12 +650,6 @@ fn baseline_env(rt: &FlavorRuntime, case_home: &CaseHome) -> BTreeMap( stage: &Path, case_cwd: &str, runtime: &FlavorRuntime, + allowed_tool_dirs: &[PathBuf], ) -> Result<(std::borrow::Cow<'a, BTreeMap>, PathBuf, PathBuf), String> { use std::borrow::Cow; assert!(!step.argv.is_empty(), "step argv must not be empty"); @@ -624,7 +788,7 @@ fn step_context<'a>( // and custom-prefix steps run exactly the child's tool. let path = env.get("PATH").cloned().unwrap_or_else(|| case_path.clone()); let cwd = stage.join(step.cwd.as_deref().unwrap_or(case_cwd)); - let program = runtime.resolve_program(&step.argv[0], &path, &cwd)?; + let program = runtime.resolve_program(&step.argv[0], &path, &cwd, allowed_tool_dirs)?; Ok((env, cwd, program)) } @@ -831,8 +995,9 @@ fn run_case( .unwrap(); let case_home = CaseHome::provision(&case_root, case.seed_runtime); + let case_install = case_home.provision_vite_plus(flavor, runtime)?; - let mut case_env = baseline_env(runtime, &case_home); + let mut case_env = baseline_env(&case_home, &case_install); for key in &case.unset_env { case_env.remove(key); } @@ -843,7 +1008,7 @@ fn run_case( // Real tools resolve through the case's PATH (may be overridden by the // case's own env table). let case_path: OsString = - case_env.get("PATH").cloned().unwrap_or_else(|| runtime.path_env.clone()); + case_env.get("PATH").cloned().unwrap_or_else(|| case_install.path_env.clone()); // local-registry cases: stand up a per-case npm registry (serving the // packed checkout packages plus this fixture's mock-manifest/tarballs) and @@ -853,8 +1018,10 @@ fn run_case( let _registry = if case.local_registry { let pack_dir = local_registry_pack .ok_or("internal error: local-registry case reached run_case without a packed dir")?; - let node = runtime.resolve_program("node", &case_path, &stage)?; - let (registry_env, handle) = start_local_registry(&node, &stage, pack_dir, &case_env)?; + let registry_node = + runtime.resolve_program("node", &case_path, &stage, &case_install.allowed_tool_dirs)?; + let (registry_env, handle) = + start_local_registry(®istry_node, &stage, pack_dir, &case_env)?; for (key, value) in registry_env { case_env.insert(key, value); } @@ -901,8 +1068,15 @@ fn run_case( while step_index < case.steps.len() { let step = &case.steps[step_index]; let argv = &step.argv; - let (step_env, step_cwd, program) = - step_context(step, &case_env, &case_path, &stage, &case.cwd, runtime)?; + let (step_env, step_cwd, program) = step_context( + step, + &case_env, + &case_path, + &stage, + &case.cwd, + runtime, + &case_install.allowed_tool_dirs, + )?; let step_env: &BTreeMap = &step_env; let timeout = step.timeout(step_default_timeout); @@ -1114,9 +1288,15 @@ fn run_case( // here too: cleanup often depends on the same PATH/prefix overrides as // the step it tears down. for step in &case.after { - let Ok((after_env, after_cwd, program)) = - step_context(step, &case_env, &case_path, &stage, &case.cwd, runtime) - else { + let Ok((after_env, after_cwd, program)) = step_context( + step, + &case_env, + &case_path, + &stage, + &case.cwd, + runtime, + &case_install.allowed_tool_dirs, + ) else { continue; }; let mut cmd = std::process::Command::new(program); diff --git a/justfile b/justfile index ca3747db37..c0bae3adab 100644 --- a/justfile +++ b/justfile @@ -81,13 +81,13 @@ test: $packages = Get-ChildItem -Path crates -Directory | Where-Object { $_.Name -ne 'vite_cli_snapshots' } | ForEach-Object { '-p'; $_.Name }; $Env:RUST_MIN_STACK='8388608'; $Env:__COMPAT_LAYER='RunAsInvoker'; cargo test @packages -p vite-plus-cli # PTY-based CLI snapshot tests (crates/vite_cli_snapshots). Builds the global -# binary first so the runner never tests a stale build. Filter by trial name -# substring: `just snapshot-test create`. Accept snapshot changes with +# binary and shim template first so the runner never tests a stale build. +# Filter by trial name substring: `just snapshot-test create`. Accept snapshot changes with # `UPDATE_SNAPSHOTS=1 just snapshot-test`. Local-flavor cases additionally # need a built packages/cli (`pnpm build`); the runner fails fast when dist # is missing or stale. Use snapshot-test-global on checkouts without one. snapshot-test *args='': - cargo build -p vite_global_cli + cargo build -p vite_global_cli -p vite_trampoline cargo test -p vite_cli_snapshots -- {{args}} # Global flavor + vpt cases only: needs no JS build, for Rust-side work on From ae40194b4bb962f70a6581d7c9a7169389142f9b Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Thu, 9 Jul 2026 21:28:56 +0800 Subject: [PATCH 2/6] fix(snapshots): keep vpt on case PATH --- .../tests/cli_snapshots/README.md | 19 ++++++++++--------- .../tests/cli_snapshots/main.rs | 15 +++++++++++---- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md index 3d3ad26cb6..894bda5647 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md @@ -167,15 +167,16 @@ runner itself (see `fixtures/interactive_probe/`). ## What a step sees -Each case gets a cleared environment: controlled `PATH` (case-owned tool dirs, -then a system tail for child processes), `TERM=xterm-256color`, -`VP_CLI_TEST=1`, `VP_EMIT_MILESTONES=1`, a fresh `HOME`, `VP_HOME`, and npm -prefix. The runner still rejects direct step tools that resolve outside the -case-owned dirs; the system tail is only for child processes such as `git` -inside real `vp create` flows. `CI` and `NO_COLOR` are deliberately NOT set: -with a PTY attached, the CLI behaves interactively by default, which is the -point. `seed-runtime = true` (default) symlinks a provisioned managed Node -runtime into the case `VP_HOME` so commands do not download ~50MB per case. +Each case gets a cleared environment: controlled `PATH` (runner `vpt`, then +case-owned tool dirs, then a system tail for child processes), +`TERM=xterm-256color`, `VP_CLI_TEST=1`, `VP_EMIT_MILESTONES=1`, a fresh +`HOME`, `VP_HOME`, and npm prefix. The runner still rejects direct step tools +that resolve outside the case-owned dirs; `vpt` is the only runner helper on +PATH, and the system tail is only for child processes such as `git` inside real +`vp create` flows. `CI` and `NO_COLOR` are deliberately NOT set: with a PTY +attached, the CLI behaves interactively by default, which is the point. +`seed-runtime = true` (default) symlinks a provisioned managed Node runtime +into the case `VP_HOME` so commands do not download ~50MB per case. Fixture configs may import bare `vite-plus` and `@voidzero-dev/vite-plus-core`: the runner links the checkout packages diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs index 40f8d80522..0f039b64e6 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs @@ -498,7 +498,10 @@ impl CaseHome { } }; - Ok(CaseInstall { path_env: compose_case_path_env(&allowed_tool_dirs), allowed_tool_dirs }) + Ok(CaseInstall { + path_env: compose_case_path_env(&allowed_tool_dirs, Some(&runtime.bin_dir)), + allowed_tool_dirs, + }) } fn install_case_package(&self, package_dir: &Path) -> Result<(), String> { @@ -545,7 +548,7 @@ impl CaseHome { fn run_env_setup(&self, vp: &Path) -> Result<(), String> { let mut env = BTreeMap::new(); - env.insert("PATH".to_string(), compose_case_path_env(&[])); + env.insert("PATH".to_string(), compose_case_path_env(&[], None)); env.insert("TERM".to_string(), "xterm-256color".into()); env.insert("VP_CLI_TEST".to_string(), "1".into()); env.insert("NODE_NO_WARNINGS".to_string(), "1".into()); @@ -604,8 +607,12 @@ impl CaseHome { } } -fn compose_case_path_env(tool_dirs: &[PathBuf]) -> OsString { - let mut entries = tool_dirs.to_vec(); +fn compose_case_path_env(tool_dirs: &[PathBuf], runner_bin_dir: Option<&Path>) -> OsString { + let mut entries = Vec::new(); + if let Some(runner_bin_dir) = runner_bin_dir { + entries.push(runner_bin_dir.to_path_buf()); + } + entries.extend(tool_dirs.iter().cloned()); if cfg!(windows) { if let Some(path) = std::env::var_os("PATH") { entries.extend(std::env::split_paths(&path)); From 300dc4eeba7b3ee64f1f954df9e7338b60a872e4 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Thu, 9 Jul 2026 22:33:17 +0800 Subject: [PATCH 3/6] fix(snapshots): use one current package layout --- .../tests/cli_snapshots/README.md | 8 ++-- .../fixtures/global_env_setup/snapshots.toml | 2 +- .../snapshots/local_home_shims.md | 10 ++--- .../tests/cli_snapshots/main.rs | 43 +++++++++++-------- 4 files changed, 36 insertions(+), 27 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md index 894bda5647..193774f164 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md @@ -87,9 +87,11 @@ after = [ ... ] # cleanup steps, never snapshotted into the case's `VP_HOME/current/bin`, install the checkout package under that case home, and run `vp env setup` before steps. `"global"` exposes only `VP_HOME/bin`; `"local"` also exposes the case-local -`VP_HOME/current/current/node_modules/vite-plus/bin` package bin. The list form -registers one trial and one snapshot per flavor; use it for parity cases (help -output, routing, error messages) where both surfaces must agree. +`VP_HOME/current/node_modules/vite-plus/bin` package bin. On Windows, local +flavor exposes sibling `.cmd` shims under +`VP_HOME/current/node_modules/.vite-plus-bin` instead. The list form registers +one trial and one snapshot per flavor; use it for parity cases (help output, +routing, error messages) where both surfaces must agree. A step is a bare argv array or a table: diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml index 0132fa2605..9f7ce93311 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml @@ -17,5 +17,5 @@ comment = "The local flavor installs the case-local standalone layout, then expo steps = [ { argv = ["vp", "--version"], comment = "vp resolves from the case-local package bin", snapshot = false }, { argv = ["node", "--version"], comment = "node resolves from the case home shim", snapshot = false }, - { argv = ["vpt", "stat-file", "../home/.vite-plus/current/bin/vp", "../home/.vite-plus/current/current/node_modules/vite-plus/bin/vp", "../home/.vite-plus/current/current/node_modules/vite-plus/bin/vpr", "../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxfmt", "../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxlint", "../home/.vite-plus/bin/vp", "../home/.vite-plus/bin/vpr", "../home/.vite-plus/bin/vpx", "../home/.vite-plus/bin/node", "--assert", "file"], comment = "vp env setup created shims and the local package bin is inside the case home" }, + { argv = ["vpt", "stat-file", "../home/.vite-plus/current/bin/vp", "../home/.vite-plus/current/node_modules/vite-plus/bin/vp", "../home/.vite-plus/current/node_modules/vite-plus/bin/vpr", "../home/.vite-plus/current/node_modules/vite-plus/bin/oxfmt", "../home/.vite-plus/current/node_modules/vite-plus/bin/oxlint", "../home/.vite-plus/bin/vp", "../home/.vite-plus/bin/vpr", "../home/.vite-plus/bin/vpx", "../home/.vite-plus/bin/node", "--assert", "file"], comment = "vp env setup created shims and the local package bin is inside the case home" }, ] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md index aad65a8ba8..3d2a0d13fe 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md @@ -12,16 +12,16 @@ vp resolves from the case-local package bin node resolves from the case home shim -## `vpt stat-file ../home/.vite-plus/current/bin/vp ../home/.vite-plus/current/current/node_modules/vite-plus/bin/vp ../home/.vite-plus/current/current/node_modules/vite-plus/bin/vpr ../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxfmt ../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxlint ../home/.vite-plus/bin/vp ../home/.vite-plus/bin/vpr ../home/.vite-plus/bin/vpx ../home/.vite-plus/bin/node --assert file` +## `vpt stat-file ../home/.vite-plus/current/bin/vp ../home/.vite-plus/current/node_modules/vite-plus/bin/vp ../home/.vite-plus/current/node_modules/vite-plus/bin/vpr ../home/.vite-plus/current/node_modules/vite-plus/bin/oxfmt ../home/.vite-plus/current/node_modules/vite-plus/bin/oxlint ../home/.vite-plus/bin/vp ../home/.vite-plus/bin/vpr ../home/.vite-plus/bin/vpx ../home/.vite-plus/bin/node --assert file` vp env setup created shims and the local package bin is inside the case home ``` ../home/.vite-plus/current/bin/vp: file -../home/.vite-plus/current/current/node_modules/vite-plus/bin/vp: file -../home/.vite-plus/current/current/node_modules/vite-plus/bin/vpr: file -../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxfmt: file -../home/.vite-plus/current/current/node_modules/vite-plus/bin/oxlint: file +../home/.vite-plus/current/node_modules/vite-plus/bin/vp: file +../home/.vite-plus/current/node_modules/vite-plus/bin/vpr: file +../home/.vite-plus/current/node_modules/vite-plus/bin/oxfmt: file +../home/.vite-plus/current/node_modules/vite-plus/bin/oxlint: file ../home/.vite-plus/bin/vp: file ../home/.vite-plus/bin/vpr: file ../home/.vite-plus/bin/vpx: file diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs index 0f039b64e6..4d69dee484 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs @@ -471,19 +471,12 @@ impl CaseHome { flavor::install_file(¤t_bin.join("vp-shim.exe"), &shim, "global vp-shim.exe")?; } - let package_dir = match flavor { - Flavor::Global => self.vp_home().join("current").join("node_modules").join("vite-plus"), - Flavor::Local => self - .vp_home() - .join("current") - .join("current") - .join("node_modules") - .join("vite-plus"), - }; + let package_dir = self.vp_home().join("current").join("node_modules").join("vite-plus"); self.install_case_package(&package_dir)?; + let local_bin_dir = local_package_bin_dir(&package_dir); #[cfg(windows)] if flavor == Flavor::Local { - self.write_local_package_cmd_shims(&package_dir)?; + self.write_local_package_cmd_shims(&package_dir, &local_bin_dir)?; } self.run_env_setup(&vp)?; @@ -494,7 +487,7 @@ impl CaseHome { runtime.local_cli_bin_dir.as_deref().ok_or( "internal error: local case reached run_case without a local CLI bin dir", )?; - vec![package_dir.join("bin"), vp_bin_dir] + vec![local_bin_dir, vp_bin_dir] } }; @@ -512,9 +505,8 @@ impl CaseHome { #[cfg(windows)] { - CopyOptions::new() - .copy_tree(&source, package_dir) - .map_err(|e| format!("failed to copy case vite-plus package: {e}"))?; + junction::create(&source, package_dir) + .map_err(|e| format!("failed to junction case vite-plus package: {e}"))?; } #[cfg(not(windows))] @@ -531,16 +523,24 @@ impl CaseHome { } #[cfg(windows)] - fn write_local_package_cmd_shims(&self, package_dir: &Path) -> Result<(), String> { + fn write_local_package_cmd_shims( + &self, + package_dir: &Path, + shim_dir: &Path, + ) -> Result<(), String> { let bin_dir = package_dir.join("bin"); + std::fs::create_dir_all(shim_dir) + .map_err(|e| format!("failed to create local package shim dir: {e}"))?; for name in ["vp", "vpr", "oxfmt", "oxlint"] { let script = bin_dir.join(name); if !script.is_file() { continue; } - let content = - format!("@echo off\r\nnode \"%~dp0{name}\" %*\r\nexit /b %ERRORLEVEL%\r\n"); - std::fs::write(bin_dir.join(format!("{name}.cmd")), content) + let content = format!( + "@echo off\r\nnode \"{}\" %*\r\nexit /b %ERRORLEVEL%\r\n", + script.display() + ); + std::fs::write(shim_dir.join(format!("{name}.cmd")), content) .map_err(|e| format!("failed to write local package {name}.cmd: {e}"))?; } Ok(()) @@ -607,6 +607,13 @@ impl CaseHome { } } +fn local_package_bin_dir(package_dir: &Path) -> PathBuf { + if cfg!(windows) { + return package_dir.parent().unwrap().join(".vite-plus-bin"); + } + package_dir.join("bin") +} + fn compose_case_path_env(tool_dirs: &[PathBuf], runner_bin_dir: Option<&Path>) -> OsString { let mut entries = Vec::new(); if let Some(runner_bin_dir) = runner_bin_dir { From f9aabc673ee201f1e30c4742beae1dad62265c4d Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Fri, 10 Jul 2026 07:33:45 +0800 Subject: [PATCH 4/6] simplify --- .../tests/cli_snapshots/README.md | 10 +- .../fixtures/global_env_setup/snapshots.toml | 3 +- .../snapshots/global_home_shims.md | 8 +- .../tests/cli_snapshots/flavor.rs | 147 ++++---------- .../tests/cli_snapshots/main.rs | 188 ++++++++---------- packages/tools/src/migrate-snap-tests.ts | 3 +- 6 files changed, 133 insertions(+), 226 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md index 193774f164..1f60de16f1 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md @@ -110,11 +110,11 @@ A step is a bare argv array or a table: interactions = [ ... ] } ``` -`argv[0]` may be `vp`, `vpr`, `vpx`, `vpt`, `oxfmt`, `oxlint`, `node`, -`git`, `npm`, `pnpm`, `yarn`, or `bun`, but everything except `vpt` must -resolve from the flavor-owned tool directories described above. There is no -shell: no `&&`, no redirects, no globs. File setup and assertions go through -`vpt` so behavior is identical on every platform: +`argv[0]` may be `vpt` or any executable exposed by the case's Vite+ +installation, including default shims such as `vp`, `node`, and `corepack` +and globally installed package binaries. There is no shell: no `&&`, no +redirects, no globs. File setup and assertions go through `vpt` so behavior +is identical on every platform: `vpt print-file` (cat), `vpt stat-file` (prints `file`/`dir`/`missing`; `--assert ` / `--assert-not ` also fail on mismatch, so diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml index 9f7ce93311..382b618fbf 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml @@ -6,7 +6,8 @@ comment = "The global flavor installs the case-local standalone layout and only steps = [ { argv = ["vp", "--version"], comment = "vp resolves from the case home shim", snapshot = false }, { argv = ["node", "--version"], comment = "node resolves from the case home shim", snapshot = false }, - { argv = ["vpt", "stat-file", "../home/.vite-plus/current/bin/vp", "../home/.vite-plus/current/node_modules/vite-plus/bin/vp", "../home/.vite-plus/bin/vp", "../home/.vite-plus/bin/vpr", "../home/.vite-plus/bin/vpx", "../home/.vite-plus/bin/node", "--assert", "file"], comment = "vp env setup created the shims inside the case home" }, + { argv = ["corepack", "--version"], comment = "any Vite+-managed shim can run without a runner allowlist", snapshot = false }, + { argv = ["vpt", "stat-file", "../home/.vite-plus/current/bin/vp", "../home/.vite-plus/current/node_modules/vite-plus/bin/vp", "../home/.vite-plus/bin/vp", "../home/.vite-plus/bin/vpr", "../home/.vite-plus/bin/vpx", "../home/.vite-plus/bin/node", "../home/.vite-plus/bin/corepack", "--assert", "file"], comment = "vp env setup created the shims inside the case home" }, ] [[case]] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md index e300aa239b..6c7107b633 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md @@ -12,7 +12,12 @@ vp resolves from the case home shim node resolves from the case home shim -## `vpt stat-file ../home/.vite-plus/current/bin/vp ../home/.vite-plus/current/node_modules/vite-plus/bin/vp ../home/.vite-plus/bin/vp ../home/.vite-plus/bin/vpr ../home/.vite-plus/bin/vpx ../home/.vite-plus/bin/node --assert file` +## `corepack --version` + +any Vite+-managed shim can run without a runner allowlist + + +## `vpt stat-file ../home/.vite-plus/current/bin/vp ../home/.vite-plus/current/node_modules/vite-plus/bin/vp ../home/.vite-plus/bin/vp ../home/.vite-plus/bin/vpr ../home/.vite-plus/bin/vpx ../home/.vite-plus/bin/node ../home/.vite-plus/bin/corepack --assert file` vp env setup created the shims inside the case home @@ -23,4 +28,5 @@ vp env setup created the shims inside the case home ../home/.vite-plus/bin/vpr: file ../home/.vite-plus/bin/vpx: file ../home/.vite-plus/bin/node: file +../home/.vite-plus/bin/corepack: file ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs b/crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs index 35e69baae9..85b7e9fada 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs @@ -27,11 +27,12 @@ impl Flavor { /// Everything the runner needs to spawn commands under one flavor. pub struct FlavorRuntime { - pub bin_dir: PathBuf, + pub runner_bin_dir: PathBuf, + pub vpt: PathBuf, /// Source global `vp` binary to install into each case's `VP_HOME/current`. pub global_vp: PathBuf, - /// Source JS CLI bin dir for the local flavor. - pub local_cli_bin_dir: Option, + /// Source package installed into each case's `VP_HOME/current/node_modules`. + pub cli_package_dir: PathBuf, } /// The runner crate's manifest dir. The runtime env var wins: cargo sets it @@ -89,16 +90,17 @@ fn global_vp_path() -> Result { /// Locates the local JS CLI bin directory. `VP_SNAP_LOCAL_CLI_BIN_DIR` /// overrides the default `/packages/cli/bin` (useful when the built /// `dist/` lives in another checkout or a CI artifact directory). -fn local_cli_bin_dir() -> Result { +fn local_cli_package_dir() -> Result { let overridden = std::env::var_os("VP_SNAP_LOCAL_CLI_BIN_DIR"); let bin_dir = overridden.as_ref().map_or_else(|| repo_root().join("packages/cli/bin"), PathBuf::from); - let dist_entry = bin_dir.parent().map(|p| p.join("dist/bin.js")); - if !dist_entry.as_deref().is_some_and(Path::is_file) { + let package_dir = bin_dir.parent().ok_or("local CLI bin dir has no parent")?; + let dist_entry = package_dir.join("dist/bin.js"); + if !dist_entry.is_file() { return Err(format!( "local CLI is not built: expected {} (run `pnpm build`, or point \ VP_SNAP_LOCAL_CLI_BIN_DIR at a built packages/cli/bin)", - dist_entry.map_or_else(String::new, |p| p.display().to_string()), + dist_entry.display(), )); } // A stale dist silently tests old code; fail fast when sources are newer @@ -111,7 +113,7 @@ fn local_cli_bin_dir() -> Result { // has no dist of its own; it is bundled into the CLI dist. Keep this // list in sync with the packages feeding the CLI build (see // packages/cli/BUNDLING.md): a new bundled package needs an entry. - let cli_pkg = bin_dir.parent().unwrap().to_path_buf(); + let cli_pkg = package_dir.to_path_buf(); let core_pkg = repo_root().join("packages/core"); let checks = [ (cli_pkg.join("src"), cli_pkg.join("dist"), "packages/cli"), @@ -133,11 +135,7 @@ fn local_cli_bin_dir() -> Result { } } } - Ok(bin_dir) -} - -pub fn cli_package_dir() -> PathBuf { - repo_root().join("packages/cli") + Ok(package_dir.to_path_buf()) } fn newest_mtime(dir: &Path) -> Option { @@ -180,15 +178,15 @@ fn vpt_path() -> Result { }) } -/// Directory holding an already-provisioned managed JS runtime that each -/// case's `VP_HOME` is seeded with (symlinked, read-mostly). Without a seed, -/// any command that touches the managed runtime downloads ~50MB per case. -/// Override with `VP_SNAP_JS_RUNTIME_DIR` (CI restores a cached runtime /// Home-layout names, shared with `CaseHome` in main.rs so the product's /// `~/.vite-plus/js_runtime` layout is spelled once. pub const VP_HOME_DIR: &str = ".vite-plus"; pub const JS_RUNTIME_DIR: &str = "js_runtime"; +/// Directory holding an already-provisioned managed JS runtime that each +/// case's `VP_HOME` is seeded with (symlinked, read-mostly). Without a seed, +/// any command that touches the managed runtime downloads ~50MB per case. +/// Override with `VP_SNAP_JS_RUNTIME_DIR` (CI restores a cached runtime /// there); defaults to the developer's real `~/.vite-plus/js_runtime`. /// Cases that test runtime provisioning itself opt out via /// `seed-runtime = false`. @@ -202,29 +200,25 @@ pub fn js_runtime_seed_dir() -> Option { dir.is_dir().then_some(dir) } -/// Installs `name` into `bin_dir`, pointing at `target`. Symlink on Unix; on -/// Windows, native executables are copied and scripts get a `.cmd` shim that -/// invokes `node` directly. -fn install_tool(bin_dir: &Path, name: &str, target: &Path) -> Result<(), String> { +/// Installs a runner-owned native tool. A symlink is enough on Unix; Windows +/// uses a hard link or copy so the executable keeps its `.exe` suffix. +fn install_runner_tool(bin_dir: &Path, name: &str, target: &Path) -> Result { #[cfg(unix)] { - std::os::unix::fs::symlink(target, bin_dir.join(name)) - .map_err(|e| format!("failed to link {name}: {e}")) + let dest = bin_dir.join(name); + std::os::unix::fs::symlink(target, &dest) + .map_err(|e| format!("failed to link {name}: {e}"))?; + Ok(dest) } #[cfg(windows)] { - if target.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) { - // Hard links are free and CI's bin dir shares a volume with the - // source; fall back to a real copy across volumes. - let dest = bin_dir.join(format!("{name}.exe")); - std::fs::hard_link(target, &dest) - .or_else(|_| std::fs::copy(target, &dest).map(|_| ())) - .map_err(|e| format!("failed to copy {name}: {e}")) - } else { - let shim = format!("@node \"{}\" %*\r\n", target.display()); - std::fs::write(bin_dir.join(format!("{name}.cmd")), shim) - .map_err(|e| format!("failed to write {name}.cmd: {e}")) - } + // Hard links are free when source and destination share a volume; + // fall back to a real copy across volumes. + let dest = bin_dir.join(format!("{name}.exe")); + std::fs::hard_link(target, &dest) + .or_else(|_| std::fs::copy(target, &dest).map(|_| ())) + .map_err(|e| format!("failed to copy {name}: {e}"))?; + Ok(dest) } } @@ -239,8 +233,7 @@ pub fn install_file(dest: &Path, source: &Path, label: &str) -> Result<(), Strin } /// Best-effort directory link. On Windows, directory symlinks may require -/// privileges, so a junction (which never does) is the fallback; only if -/// both fail does resolution fall back to whatever the fixture vendors. +/// privileges, so a junction (which never does) is the fallback. pub fn link_dir(target: &Path, link: &Path) { #[cfg(unix)] let _ = std::os::unix::fs::symlink(target, link); @@ -252,77 +245,15 @@ pub fn link_dir(target: &Path, link: &Path) { /// Creates the per-run bin directory for `flavor` under `run_root`. pub fn provision(flavor: Flavor, run_root: &Path) -> Result { - let bin_dir = run_root.join(format!("bin-{}", flavor.as_str())); - std::fs::create_dir_all(&bin_dir).map_err(|e| format!("failed to create bin dir: {e}"))?; - - install_tool(&bin_dir, "vpt", &vpt_path()?)?; + let runner_bin_dir = run_root.join(format!("bin-{}", flavor.as_str())); + std::fs::create_dir_all(&runner_bin_dir) + .map_err(|e| format!("failed to create bin dir: {e}"))?; + let vpt = install_runner_tool(&runner_bin_dir, "vpt", &vpt_path()?)?; let global_vp = global_vp_path()?; - let local_cli_bin_dir = if flavor == Flavor::Local { Some(local_cli_bin_dir()?) } else { None }; - Ok(FlavorRuntime { bin_dir, global_vp, local_cli_bin_dir }) -} - -impl FlavorRuntime { - /// Resolves a step's `argv[0]` to an absolute path. Only the vp family, - /// `vpt`, and an allow-list of real tools may run as steps; everything - /// else belongs behind a `vpt` subcommand so fixtures stay - /// platform-identical. Keep the allow-list in sync with - /// `PASSTHROUGH_PROGRAMS` in packages/tools/src/migrate-snap-tests.ts. - /// Real tools resolve through the CASE's `PATH`, then the resolved path is - /// checked against the flavor-owned tool dirs. - pub fn resolve_program( - &self, - program: &str, - case_path: &std::ffi::OsStr, - cwd: &Path, - allowed_tool_dirs: &[PathBuf], - ) -> Result { - match program { - "vp" | "vpr" | "vpx" | "oxfmt" | "oxlint" => { - self.resolve_case_tool(program, case_path, cwd, allowed_tool_dirs) - } - // vpt is the runner's own assertion tool: a case-created shim must - // never shadow it, so it resolves only from the flavor bin dir. - "vpt" => self.bin_dir_tool(program), - "node" | "git" | "npm" | "pnpm" | "yarn" | "bun" => { - self.resolve_case_tool(program, case_path, cwd, allowed_tool_dirs) - } - other => Err(format!( - "step program `{other}` is not allowed; use a `vpt` subcommand instead" - )), - } - } - - fn resolve_case_tool( - &self, - program: &str, - case_path: &std::ffi::OsStr, - cwd: &Path, - allowed_tool_dirs: &[PathBuf], - ) -> Result { - let found = which::which_in(program, Some(case_path), cwd) - .map_err(|e| format!("`{program}` not found on the case PATH: {e}"))?; - if allowed_tool_dirs.iter().any(|dir| found.starts_with(dir)) { - return Ok(found); - } - Err(format!("`{program}` resolved outside the flavor-owned tool dirs: {}", found.display())) - } - - /// Looks a tool up directly in the flavor bin dir. - fn bin_dir_tool(&self, program: &str) -> Result { - if cfg!(windows) { - // Installed as either .exe or .cmd; try both. - ["exe", "cmd"] - .iter() - .map(|ext| self.bin_dir.join(format!("{program}.{ext}"))) - .find(|p| p.is_file()) - .ok_or_else(|| format!("`{program}` is not available in this flavor")) - } else { - let p = self.bin_dir.join(program); - if !p.is_file() && !p.is_symlink() { - return Err(format!("`{program}` is not available in this flavor")); - } - Ok(p) - } - } + let cli_package_dir = match flavor { + Flavor::Local => local_cli_package_dir()?, + Flavor::Global => repo_root().join("packages/cli"), + }; + Ok(FlavorRuntime { runner_bin_dir, vpt, global_vp, cli_package_dir }) } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs index 4d69dee484..f56d75fe29 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs @@ -427,7 +427,30 @@ struct CaseHome { struct CaseInstall { path_env: OsString, - allowed_tool_dirs: Vec, + tool_dirs: Vec, + vpt: PathBuf, +} + +impl CaseInstall { + /// Resolve a step program from the case-owned Vite+ installation. `vpt` + /// stays runner-owned so a fixture cannot shadow the assertion helper. + fn resolve_program( + &self, + program: &str, + case_path: &std::ffi::OsStr, + cwd: &Path, + ) -> Result { + if program == "vpt" { + return Ok(self.vpt.clone()); + } + + let found = which::which_in(program, Some(case_path), cwd) + .map_err(|e| format!("`{program}` not found on the case PATH: {e}"))?; + if self.tool_dirs.iter().any(|dir| found.starts_with(dir)) { + return Ok(found); + } + Err(format!("`{program}` resolved outside the case-owned tool dirs: {}", found.display())) + } } impl CaseHome { @@ -472,7 +495,7 @@ impl CaseHome { } let package_dir = self.vp_home().join("current").join("node_modules").join("vite-plus"); - self.install_case_package(&package_dir)?; + Self::install_case_package(&runtime.cli_package_dir, &package_dir)?; let local_bin_dir = local_package_bin_dir(&package_dir); #[cfg(windows)] if flavor == Flavor::Local { @@ -481,42 +504,39 @@ impl CaseHome { self.run_env_setup(&vp)?; let vp_bin_dir = self.vp_home().join("bin"); - let allowed_tool_dirs = match flavor { + let tool_dirs = match flavor { Flavor::Global => vec![vp_bin_dir], - Flavor::Local => { - runtime.local_cli_bin_dir.as_deref().ok_or( - "internal error: local case reached run_case without a local CLI bin dir", - )?; - vec![local_bin_dir, vp_bin_dir] - } + Flavor::Local => vec![local_bin_dir, vp_bin_dir], }; + let mut path_dirs = vec![runtime.runner_bin_dir.clone()]; + path_dirs.extend(tool_dirs.iter().cloned()); Ok(CaseInstall { - path_env: compose_case_path_env(&allowed_tool_dirs, Some(&runtime.bin_dir)), - allowed_tool_dirs, + path_env: compose_path_env(&path_dirs), + tool_dirs, + vpt: runtime.vpt.clone(), }) } - fn install_case_package(&self, package_dir: &Path) -> Result<(), String> { - let source = flavor::cli_package_dir(); + fn install_case_package(source: &Path, package_dir: &Path) -> Result<(), String> { let parent = package_dir.parent().ok_or("case package dir has no parent")?; std::fs::create_dir_all(parent) .map_err(|e| format!("failed to create case package parent: {e}"))?; #[cfg(windows)] { - junction::create(&source, package_dir) + junction::create(source, package_dir) .map_err(|e| format!("failed to junction case vite-plus package: {e}"))?; } #[cfg(not(windows))] { - flavor::link_dir(&source, package_dir); + flavor::link_dir(source, package_dir); if package_dir.join("package.json").is_file() { return Ok(()); } CopyOptions::new() - .copy_tree(&source, package_dir) + .copy_tree(source, package_dir) .map_err(|e| format!("failed to copy case vite-plus package: {e}"))?; } Ok(()) @@ -547,16 +567,38 @@ impl CaseHome { } fn run_env_setup(&self, vp: &Path) -> Result<(), String> { + let env = self.base_env(compose_path_env(&[])); + let output = std::process::Command::new(vp) + .args(["env", "setup", "--refresh"]) + .env_clear() + .envs(&env) + .output() + .map_err(|e| format!("failed to run `vp env setup`: {e}"))?; + if output.status.success() { + return Ok(()); + } + + Err(format!( + "`vp env setup` failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) + } + + fn base_env(&self, path_env: OsString) -> BTreeMap { let mut env = BTreeMap::new(); - env.insert("PATH".to_string(), compose_case_path_env(&[], None)); - env.insert("TERM".to_string(), "xterm-256color".into()); - env.insert("VP_CLI_TEST".to_string(), "1".into()); - env.insert("NODE_NO_WARNINGS".to_string(), "1".into()); - env.insert("VP_HOME".to_string(), self.vp_home().into_os_string()); + env.insert("PATH".into(), path_env); + // xterm-256color keeps anstream from stripping the OSC 8 milestone + // sequences used by test steps. It is harmless during env setup. + env.insert("TERM".into(), "xterm-256color".into()); + env.insert("VP_CLI_TEST".into(), "1".into()); + env.insert("NODE_NO_WARNINGS".into(), "1".into()); + env.insert("VP_HOME".into(), self.vp_home().into_os_string()); if cfg!(windows) { - env.insert("USERPROFILE".to_string(), self.home.clone().into_os_string()); + env.insert("USERPROFILE".into(), self.home.clone().into_os_string()); env.insert( - "PATHEXT".to_string(), + "PATHEXT".into(), ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC".into(), ); for name in [ @@ -574,28 +616,13 @@ impl CaseHome { "ProgramFiles(x86)", ] { if let Some(value) = std::env::var_os(name) { - env.insert(name.to_string(), value); + env.insert(name.into(), value); } } } else { - env.insert("HOME".to_string(), self.home.clone().into_os_string()); + env.insert("HOME".into(), self.home.clone().into_os_string()); } - let output = std::process::Command::new(vp) - .args(["env", "setup", "--refresh"]) - .env_clear() - .envs(&env) - .output() - .map_err(|e| format!("failed to run `vp env setup`: {e}"))?; - if output.status.success() { - return Ok(()); - } - - Err(format!( - "`vp env setup` failed with status {}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )) + env } fn vp_home(&self) -> PathBuf { @@ -614,12 +641,8 @@ fn local_package_bin_dir(package_dir: &Path) -> PathBuf { package_dir.join("bin") } -fn compose_case_path_env(tool_dirs: &[PathBuf], runner_bin_dir: Option<&Path>) -> OsString { - let mut entries = Vec::new(); - if let Some(runner_bin_dir) = runner_bin_dir { - entries.push(runner_bin_dir.to_path_buf()); - } - entries.extend(tool_dirs.iter().cloned()); +fn compose_path_env(tool_dirs: &[PathBuf]) -> OsString { + let mut entries = tool_dirs.to_vec(); if cfg!(windows) { if let Some(path) = std::env::var_os("PATH") { entries.extend(std::env::split_paths(&path)); @@ -637,25 +660,13 @@ fn compose_case_path_env(tool_dirs: &[PathBuf], runner_bin_dir: Option<&Path>) - /// Notably absent: `CI` and `NO_COLOR`; the PTY makes real interactive /// behaviour the default, and grid rendering strips styling from snapshots. fn baseline_env(case_home: &CaseHome, install: &CaseInstall) -> BTreeMap { - let mut env: BTreeMap = BTreeMap::new(); - env.insert("PATH".into(), install.path_env.clone()); - // xterm-256color keeps anstream from stripping the OSC 8 milestone - // sequences the runner synchronizes on. - env.insert("TERM".into(), "xterm-256color".into()); - env.insert("VP_CLI_TEST".into(), "1".into()); + let mut env = case_home.base_env(install.path_env.clone()); env.insert("VP_EMIT_MILESTONES".into(), "1".into()); - env.insert("NODE_NO_WARNINGS".into(), "1".into()); // Legacy-runner parity: `vp migrate` fixtures skip real dependency // installs (slow, network-bound). Cases that want real installs unset // this via `unset-env`. env.insert("VP_SKIP_INSTALL".into(), "1".into()); - env.insert("VP_HOME".into(), case_home.vp_home().into_os_string()); env.insert("NPM_CONFIG_PREFIX".into(), case_home.npm_prefix().into_os_string()); - if cfg!(windows) { - env.insert("USERPROFILE".into(), case_home.home.clone().into_os_string()); - } else { - env.insert("HOME".into(), case_home.home.clone().into_os_string()); - } for (key, value) in [ ("GIT_AUTHOR_NAME", "vite-plus-test"), ("GIT_AUTHOR_EMAIL", "test@vite-plus.invalid"), @@ -664,32 +675,6 @@ fn baseline_env(case_home: &CaseHome, install: &CaseInstall) -> BTreeMap( case_path: &OsString, stage: &Path, case_cwd: &str, - runtime: &FlavorRuntime, - allowed_tool_dirs: &[PathBuf], + install: &CaseInstall, ) -> Result<(std::borrow::Cow<'a, BTreeMap>, PathBuf, PathBuf), String> { use std::borrow::Cow; assert!(!step.argv.is_empty(), "step argv must not be empty"); @@ -802,7 +786,7 @@ fn step_context<'a>( // and custom-prefix steps run exactly the child's tool. let path = env.get("PATH").cloned().unwrap_or_else(|| case_path.clone()); let cwd = stage.join(step.cwd.as_deref().unwrap_or(case_cwd)); - let program = runtime.resolve_program(&step.argv[0], &path, &cwd, allowed_tool_dirs)?; + let program = install.resolve_program(&step.argv[0], &path, &cwd)?; Ok((env, cwd, program)) } @@ -1032,8 +1016,7 @@ fn run_case( let _registry = if case.local_registry { let pack_dir = local_registry_pack .ok_or("internal error: local-registry case reached run_case without a packed dir")?; - let registry_node = - runtime.resolve_program("node", &case_path, &stage, &case_install.allowed_tool_dirs)?; + let registry_node = case_install.resolve_program("node", &case_path, &stage)?; let (registry_env, handle) = start_local_registry(®istry_node, &stage, pack_dir, &case_env)?; for (key, value) in registry_env { @@ -1082,15 +1065,8 @@ fn run_case( while step_index < case.steps.len() { let step = &case.steps[step_index]; let argv = &step.argv; - let (step_env, step_cwd, program) = step_context( - step, - &case_env, - &case_path, - &stage, - &case.cwd, - runtime, - &case_install.allowed_tool_dirs, - )?; + let (step_env, step_cwd, program) = + step_context(step, &case_env, &case_path, &stage, &case.cwd, &case_install)?; let step_env: &BTreeMap = &step_env; let timeout = step.timeout(step_default_timeout); @@ -1302,15 +1278,9 @@ fn run_case( // here too: cleanup often depends on the same PATH/prefix overrides as // the step it tears down. for step in &case.after { - let Ok((after_env, after_cwd, program)) = step_context( - step, - &case_env, - &case_path, - &stage, - &case.cwd, - runtime, - &case_install.allowed_tool_dirs, - ) else { + let Ok((after_env, after_cwd, program)) = + step_context(step, &case_env, &case_path, &stage, &case.cwd, &case_install) + else { continue; }; let mut cmd = std::process::Command::new(program); diff --git a/packages/tools/src/migrate-snap-tests.ts b/packages/tools/src/migrate-snap-tests.ts index 68e1f28823..2e26d5da18 100644 --- a/packages/tools/src/migrate-snap-tests.ts +++ b/packages/tools/src/migrate-snap-tests.ts @@ -143,8 +143,7 @@ function extractComment(line: string): { command: string; comment?: string } { return { command: line.trim() }; } -// Keep in sync with resolve_program's allow-list in -// crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs. +// Legacy commands that can be represented directly without shell semantics. const PASSTHROUGH_PROGRAMS = new Set([ 'vp', 'vpr', From ab38d1d9e481234445e351c82c968ab17233cd84 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Fri, 10 Jul 2026 09:09:49 +0800 Subject: [PATCH 5/6] allow git --- .../tests/cli_snapshots/README.md | 7 ++-- .../fixtures/global_env_setup/snapshots.toml | 22 ------------- .../snapshots/global_home_shims.md | 32 ------------------- .../snapshots/local_home_shims.md | 29 ----------------- .../tests/cli_snapshots/main.rs | 4 ++- 5 files changed, 6 insertions(+), 88 deletions(-) delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md index 1f60de16f1..3436f4979c 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md @@ -170,12 +170,11 @@ runner itself (see `fixtures/interactive_probe/`). ## What a step sees Each case gets a cleared environment: controlled `PATH` (runner `vpt`, then -case-owned tool dirs, then a system tail for child processes), +case-owned tool dirs, then a system tail for child processes and direct `git` steps), `TERM=xterm-256color`, `VP_CLI_TEST=1`, `VP_EMIT_MILESTONES=1`, a fresh `HOME`, `VP_HOME`, and npm prefix. The runner still rejects direct step tools -that resolve outside the case-owned dirs; `vpt` is the only runner helper on -PATH, and the system tail is only for child processes such as `git` inside real -`vp create` flows. `CI` and `NO_COLOR` are deliberately NOT set: with a PTY +that resolve outside the case-owned dirs, except for `git`; `vpt` is the only +runner helper on PATH. `CI` and `NO_COLOR` are deliberately NOT set: with a PTY attached, the CLI behaves interactively by default, which is the point. `seed-runtime = true` (default) symlinks a provisioned managed Node runtime into the case `VP_HOME` so commands do not download ~50MB per case. diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml deleted file mode 100644 index 382b618fbf..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots.toml +++ /dev/null @@ -1,22 +0,0 @@ -[[case]] -name = "global_home_shims" -vp = "global" -skip-platforms = ["windows"] -comment = "The global flavor installs the case-local standalone layout and only exposes VP_HOME/bin tools." -steps = [ - { argv = ["vp", "--version"], comment = "vp resolves from the case home shim", snapshot = false }, - { argv = ["node", "--version"], comment = "node resolves from the case home shim", snapshot = false }, - { argv = ["corepack", "--version"], comment = "any Vite+-managed shim can run without a runner allowlist", snapshot = false }, - { argv = ["vpt", "stat-file", "../home/.vite-plus/current/bin/vp", "../home/.vite-plus/current/node_modules/vite-plus/bin/vp", "../home/.vite-plus/bin/vp", "../home/.vite-plus/bin/vpr", "../home/.vite-plus/bin/vpx", "../home/.vite-plus/bin/node", "../home/.vite-plus/bin/corepack", "--assert", "file"], comment = "vp env setup created the shims inside the case home" }, -] - -[[case]] -name = "local_home_shims" -vp = "local" -skip-platforms = ["windows"] -comment = "The local flavor installs the case-local standalone layout, then exposes the case-local package bin." -steps = [ - { argv = ["vp", "--version"], comment = "vp resolves from the case-local package bin", snapshot = false }, - { argv = ["node", "--version"], comment = "node resolves from the case home shim", snapshot = false }, - { argv = ["vpt", "stat-file", "../home/.vite-plus/current/bin/vp", "../home/.vite-plus/current/node_modules/vite-plus/bin/vp", "../home/.vite-plus/current/node_modules/vite-plus/bin/vpr", "../home/.vite-plus/current/node_modules/vite-plus/bin/oxfmt", "../home/.vite-plus/current/node_modules/vite-plus/bin/oxlint", "../home/.vite-plus/bin/vp", "../home/.vite-plus/bin/vpr", "../home/.vite-plus/bin/vpx", "../home/.vite-plus/bin/node", "--assert", "file"], comment = "vp env setup created shims and the local package bin is inside the case home" }, -] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md deleted file mode 100644 index 6c7107b633..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/global_home_shims.md +++ /dev/null @@ -1,32 +0,0 @@ -# global_home_shims - -The global flavor installs the case-local standalone layout and only exposes VP_HOME/bin tools. - -## `vp --version` - -vp resolves from the case home shim - - -## `node --version` - -node resolves from the case home shim - - -## `corepack --version` - -any Vite+-managed shim can run without a runner allowlist - - -## `vpt stat-file ../home/.vite-plus/current/bin/vp ../home/.vite-plus/current/node_modules/vite-plus/bin/vp ../home/.vite-plus/bin/vp ../home/.vite-plus/bin/vpr ../home/.vite-plus/bin/vpx ../home/.vite-plus/bin/node ../home/.vite-plus/bin/corepack --assert file` - -vp env setup created the shims inside the case home - -``` -../home/.vite-plus/current/bin/vp: file -../home/.vite-plus/current/node_modules/vite-plus/bin/vp: file -../home/.vite-plus/bin/vp: file -../home/.vite-plus/bin/vpr: file -../home/.vite-plus/bin/vpx: file -../home/.vite-plus/bin/node: file -../home/.vite-plus/bin/corepack: file -``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md deleted file mode 100644 index 3d2a0d13fe..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_env_setup/snapshots/local_home_shims.md +++ /dev/null @@ -1,29 +0,0 @@ -# local_home_shims - -The local flavor installs the case-local standalone layout, then exposes the case-local package bin. - -## `vp --version` - -vp resolves from the case-local package bin - - -## `node --version` - -node resolves from the case home shim - - -## `vpt stat-file ../home/.vite-plus/current/bin/vp ../home/.vite-plus/current/node_modules/vite-plus/bin/vp ../home/.vite-plus/current/node_modules/vite-plus/bin/vpr ../home/.vite-plus/current/node_modules/vite-plus/bin/oxfmt ../home/.vite-plus/current/node_modules/vite-plus/bin/oxlint ../home/.vite-plus/bin/vp ../home/.vite-plus/bin/vpr ../home/.vite-plus/bin/vpx ../home/.vite-plus/bin/node --assert file` - -vp env setup created shims and the local package bin is inside the case home - -``` -../home/.vite-plus/current/bin/vp: file -../home/.vite-plus/current/node_modules/vite-plus/bin/vp: file -../home/.vite-plus/current/node_modules/vite-plus/bin/vpr: file -../home/.vite-plus/current/node_modules/vite-plus/bin/oxfmt: file -../home/.vite-plus/current/node_modules/vite-plus/bin/oxlint: file -../home/.vite-plus/bin/vp: file -../home/.vite-plus/bin/vpr: file -../home/.vite-plus/bin/vpx: file -../home/.vite-plus/bin/node: file -``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs index f56d75fe29..693f8b8679 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/main.rs @@ -446,7 +446,9 @@ impl CaseInstall { let found = which::which_in(program, Some(case_path), cwd) .map_err(|e| format!("`{program}` not found on the case PATH: {e}"))?; - if self.tool_dirs.iter().any(|dir| found.starts_with(dir)) { + // Git is a fixture dependency in real create/migrate flows, so steps may + // invoke the system installation directly as the sole PATH exception. + if program == "git" || self.tool_dirs.iter().any(|dir| found.starts_with(dir)) { return Ok(found); } Err(format!("`{program}` resolved outside the case-owned tool dirs: {}", found.display())) From 0bd295118526ffc908cc1af4e62f178e094e6f18 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Fri, 10 Jul 2026 09:25:58 +0800 Subject: [PATCH 6/6] add corepack --- packages/tools/src/migrate-snap-tests.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tools/src/migrate-snap-tests.ts b/packages/tools/src/migrate-snap-tests.ts index 2e26d5da18..ba9a4891ad 100644 --- a/packages/tools/src/migrate-snap-tests.ts +++ b/packages/tools/src/migrate-snap-tests.ts @@ -157,6 +157,7 @@ const PASSTHROUGH_PROGRAMS = new Set([ 'pnpm', 'yarn', 'bun', + 'corepack', ]); const COREUTILS_MAP: Record = {