Skip to content
Merged
45 changes: 25 additions & 20 deletions crates/vite_cli_snapshots/tests/cli_snapshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ cargo test -p vite_cli_snapshots -- <filter> # if vp is already built
```

Trial names are `<fixture>::<case>` (plus `::<flavor>` 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:

Expand Down Expand Up @@ -83,8 +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
`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/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.

Expand All @@ -105,12 +110,10 @@ 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`. `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
`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`;
Expand Down Expand Up @@ -166,13 +169,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` (runner `vpt`, then
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, 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.

Fixture configs may import bare `vite-plus` and
`@voidzero-dev/vite-plus-core`: the runner links the checkout packages
Expand Down
223 changes: 57 additions & 166 deletions crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand All @@ -33,12 +27,12 @@ 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<PathBuf>,
/// 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,
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 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
Expand Down Expand Up @@ -96,16 +90,17 @@ fn global_vp_path() -> Result<PathBuf, String> {
/// Locates the local JS CLI bin directory. `VP_SNAP_LOCAL_CLI_BIN_DIR`
/// overrides the default `<repo>/packages/cli/bin` (useful when the built
/// `dist/` lives in another checkout or a CI artifact directory).
fn local_cli_bin_dir() -> Result<PathBuf, String> {
fn local_cli_package_dir() -> Result<PathBuf, String> {
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
Expand All @@ -118,7 +113,7 @@ fn local_cli_bin_dir() -> Result<PathBuf, String> {
// 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"),
Expand All @@ -140,7 +135,7 @@ fn local_cli_bin_dir() -> Result<PathBuf, String> {
}
}
}
Ok(bin_dir)
Ok(package_dir.to_path_buf())
}

fn newest_mtime(dir: &Path) -> Option<std::time::SystemTime> {
Expand Down Expand Up @@ -183,15 +178,15 @@ fn vpt_path() -> Result<PathBuf, String> {
})
}

/// 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`.
Expand All @@ -205,35 +200,40 @@ pub fn js_runtime_seed_dir() -> Option<PathBuf> {
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<PathBuf, String> {
#[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)
}
}

/// 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.
/// 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);
Expand All @@ -243,126 +243,17 @@ pub fn link_dir(target: &Path, link: &Path) {
}
}

fn compose_path_env(bin_dir: &Path, node_dir: &Path) -> OsString {
let mut entries: Vec<PathBuf> = 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<FlavorRuntime, String> {
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 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 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
}
let vpt = install_runner_tool(&runner_bin_dir, "vpt", &vpt_path()?)?;
let global_vp = global_vp_path()?;
let cli_package_dir = match flavor {
Flavor::Local => local_cli_package_dir()?,
Flavor::Global => repo_root().join("packages/cli"),
};
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 })
}

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`
/// (which leads with `$VP_HOME/bin`), so shims a case creates via
/// `vp env setup` or global installs take precedence over host tools.
pub fn resolve_program(
&self,
program: &str,
case_path: &std::ffi::OsStr,
cwd: &Path,
) -> Result<PathBuf, String> {
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)
}
// 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}"))
}
other => Err(format!(
"step program `{other}` is not allowed; use a `vpt` subcommand instead"
)),
}
}

/// Looks a tool up directly in the flavor bin dir.
fn bin_dir_tool(&self, program: &str) -> Result<PathBuf, String> {
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)
}
}
Ok(FlavorRuntime { runner_bin_dir, vpt, global_vp, cli_package_dir })
}
Loading
Loading