Skip to content

Commit 3fb0a8d

Browse files
wan9chiclaude
andcommitted
test(e2e): cache vite build via patched vite plugin
Applies a small pnpm patch to vite 8.0.8 that auto-injects a runner-aware plugin at plugin-resolution time. When `VP_RUN_NODE_CLIENT_PATH` is set (i.e. the child runs under `vp run`), the plugin: - `ignoreInput(outDir)` — suppress fspy reads of the output dir (emptyDir scans dist/ before writing) - `ignoreInput/Output(<root>/node_modules)` — machine state (pnpm store + vite's `.vite`/`.vite-temp` caches) is not user input/output - `getEnv("NODE_ENV", true)` — tracked; drives DCE and define replacements New e2e fixture `vite_build_cache` proves `vt run --cache build` produces a cache hit on the second run and restores `dist/assets/main.js` after deletion, all with zero manual input/output configuration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1d36bfb commit 3fb0a8d

12 files changed

Lines changed: 692 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head><title>vp-run-vite-cache</title></head>
4+
<body>
5+
<script type="module" src="/src/main.js"></script>
6+
</body>
7+
</html>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "vite-build-cache-fixture",
3+
"private": true,
4+
"type": "module"
5+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Vite build end-to-end: `vt run --cache build` must produce a cache hit on
2+
# the second run without any manual input/output configuration. The patched
3+
# vite (see patches/vite.patch) reports ignoreInputs/ignoreOutputs on its
4+
# cacheDir/outDir so fspy-detected reads of `dist/` and writes to
5+
# `node_modules/.vite/` don't poison the cache.
6+
7+
[[e2e]]
8+
name = "vite_build_caches_and_restores_outputs"
9+
ignore = true
10+
steps = [
11+
["vt", "run", "--cache", "build"],
12+
# Verify the build emitted output.
13+
["vtt", "print-file", "dist/assets/main.js"],
14+
# Delete the output artefact and re-run — the cache hit should restore it.
15+
["vtt", "rm", "dist/assets/main.js"],
16+
{ argv = ["vt", "run", "--cache", "build"], comment = "cache hit: outputs restored without manual config" },
17+
["vtt", "print-file", "dist/assets/main.js"],
18+
]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# vite_build_caches_and_restores_outputs
2+
3+
## `vt run --cache build`
4+
5+
```
6+
$ vite build
7+
```
8+
9+
## `vtt print-file dist/assets/main.js`
10+
11+
```
12+
(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})(),document.body.append((e=>`hello, ${e}`)(`vite`));
13+
```
14+
15+
## `vtt rm dist/assets/main.js`
16+
17+
```
18+
```
19+
20+
## `vt run --cache build`
21+
22+
cache hit: outputs restored without manual config
23+
24+
```
25+
$ vite build ◉ cache hit, replaying
26+
27+
---
28+
vt run: cache hit.
29+
```
30+
31+
## `vtt print-file dist/assets/main.js`
32+
33+
```
34+
(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})(),document.body.append((e=>`hello, ${e}`)(`vite`));
35+
```
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const greet = (name) => `hello, ${name}`;
2+
3+
document.body.append(greet("vite"));
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"tasks": {
3+
"build": {
4+
"command": "vite build",
5+
"cache": true
6+
}
7+
}
8+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { defineConfig } from "vite";
2+
3+
export default defineConfig({
4+
logLevel: "silent",
5+
build: {
6+
rollupOptions: {
7+
output: {
8+
// Stable filenames make cache behaviour deterministic across runs.
9+
entryFileNames: "assets/main.js",
10+
chunkFileNames: "assets/chunk.js",
11+
assetFileNames: "assets/[name][extname]",
12+
},
13+
},
14+
},
15+
});

crates/vite_task_bin/tests/e2e_snapshots/main.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,43 @@ fn populate_vite_task_client_package(stage_path: &AbsolutePath) {
253253
CopyOptions::new().copy_tree(&src, &dst).unwrap();
254254
}
255255

256+
/// Symlink installed Node packages from the repo's `packages/tools/node_modules`
257+
/// into the fixture's staging `node_modules` so fixtures can resolve them by
258+
/// name without a per-fixture pnpm install. Only packages whose staging-side
259+
/// symlink targets exist are created; missing targets are silently skipped.
260+
#[expect(
261+
clippy::disallowed_types,
262+
reason = "std::path::Path required for filesystem operations"
263+
)]
264+
fn link_tools_packages(stage_path: &AbsolutePath, names: &[&str]) {
265+
let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap());
266+
let repo_root = manifest_dir.parent().unwrap().parent().unwrap();
267+
let stage_node_modules = stage_path.as_path().join("node_modules");
268+
std::fs::create_dir_all(&stage_node_modules).unwrap();
269+
270+
for name in names {
271+
let src = repo_root.join("packages/tools/node_modules").join(name);
272+
// Follow the symlink so the absolute target (pnpm's .pnpm store) is
273+
// what we pin into the staging tree. Relative symlinks into pnpm
274+
// internals would break outside the repo.
275+
let Ok(canonical) = std::fs::canonicalize(&src) else {
276+
continue;
277+
};
278+
let link = stage_node_modules.join(name);
279+
let _ = std::fs::remove_file(&link);
280+
#[cfg(unix)]
281+
std::os::unix::fs::symlink(&canonical, &link).unwrap();
282+
#[cfg(windows)]
283+
{
284+
if canonical.is_dir() {
285+
std::os::windows::fs::symlink_dir(&canonical, &link).unwrap();
286+
} else {
287+
std::os::windows::fs::symlink_file(&canonical, &link).unwrap();
288+
}
289+
}
290+
}
291+
}
292+
256293
/// Append a fenced markdown block containing `body`. The opening and closing
257294
/// fences sit on their own lines, and trailing whitespace inside `body` is
258295
/// trimmed so the close fence isn't preceded by blank lines.
@@ -296,6 +333,13 @@ fn run_case(
296333
// "@voidzero-dev/vite-task-client"`) without requiring pnpm install.
297334
populate_vite_task_client_package(&e2e_stage_path);
298335

336+
// Fixtures that exercise real Node toolchains (e.g. `vite build`) link
337+
// those packages from the repo's `packages/tools/node_modules` so the
338+
// tool and its transitive deps (resolved via pnpm) stay reachable.
339+
if fixture_name == "vite_build_cache" {
340+
link_tools_packages(&e2e_stage_path, &["vite"]);
341+
}
342+
299343
let (workspace_root, _cwd) = find_workspace_root(&e2e_stage_path).unwrap();
300344
assert_eq!(
301345
&e2e_stage_path, &*workspace_root.path,
@@ -308,8 +352,19 @@ fn run_case(
308352
let bin = AbsolutePathBuf::new(std::path::PathBuf::from(bin_path)).unwrap();
309353
Arc::<OsStr>::from(bin.parent().unwrap().as_path().as_os_str())
310354
});
355+
356+
// Also expose tool bins installed under packages/tools/node_modules/.bin
357+
// (e.g. `vite`) so ignored e2e fixtures can exercise real toolchains.
358+
#[expect(clippy::disallowed_types, reason = "PathBuf needed for workspace path arithmetic")]
359+
let tools_bin_dir: Option<Arc<OsStr>> = {
360+
let manifest_dir = std::path::PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
361+
let repo_root = manifest_dir.parent().unwrap().parent().unwrap();
362+
let tools_bin = repo_root.join("packages/tools/node_modules/.bin");
363+
tools_bin.is_dir().then(|| Arc::<OsStr>::from(tools_bin.into_os_string()))
364+
};
365+
311366
let e2e_env_path = join_paths(
312-
bin_dirs.iter().cloned().chain(
367+
bin_dirs.iter().cloned().chain(tools_bin_dir.iter().cloned()).chain(
313368
// the existing PATH
314369
split_paths(&env::var_os("PATH").unwrap())
315370
.map(|path| Arc::<OsStr>::from(path.into_os_string())),

packages/tools/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"cross-env": "^10.1.0",
77
"oxfmt": "0.42.0",
88
"oxlint": "catalog:",
9-
"oxlint-tsgolint": "catalog:"
9+
"oxlint-tsgolint": "catalog:",
10+
"vite": "catalog:"
1011
}
1112
}

patches/vite.patch

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
diff --git a/dist/node/chunks/node.js b/dist/node/chunks/node.js
2+
index 5be94a01d8aecf2502e76c05087b207980f2b06d..f436a7d9aa32d126fc1888d3bf37a6af0aa10e3e 100644
3+
--- a/dist/node/chunks/node.js
4+
+++ b/dist/node/chunks/node.js
5+
@@ -29706,6 +29706,43 @@ function esbuildBannerFooterCompatPlugin(config) {
6+
};
7+
}
8+
//#endregion
9+
+//#region vp:runner-aware-tools (patch)
10+
+// Injected by @voidzero-dev/vite-task-client integration: when Vite runs under
11+
+// `vp run`, report input/output directories and tracked envs to the runner so
12+
+// cache correctness works without manual input/output config.
13+
+let __vpRunnerAddon;
14+
+function __vpRunnerLoadAddon() {
15+
+ if (__vpRunnerAddon !== undefined) return __vpRunnerAddon;
16+
+ const addonPath = process.env.VP_RUN_NODE_CLIENT_PATH;
17+
+ if (!addonPath) return __vpRunnerAddon = null;
18+
+ try {
19+
+ __vpRunnerAddon = createRequire(import.meta.url)(addonPath);
20+
+ } catch {
21+
+ __vpRunnerAddon = null;
22+
+ }
23+
+ return __vpRunnerAddon;
24+
+}
25+
+function vpRunnerAwarePlugin(config) {
26+
+ return {
27+
+ name: "vp:runner-aware",
28+
+ buildStart() {
29+
+ const addon = __vpRunnerLoadAddon();
30+
+ if (!addon) return;
31+
+ const outDir = config.build && config.build.outDir;
32+
+ if (outDir) addon.ignoreInput(outDir);
33+
+ // node_modules holds installed deps plus Vite's own caches
34+
+ // (`.vite`, `.vite-temp`). Everything under it is machine state
35+
+ // rather than user-authored input/output; the task's real fingerprint
36+
+ // comes from sources + the lockfile, tracked separately.
37+
+ const nodeModules = (config.root || process.cwd()) + "/node_modules";
38+
+ addon.ignoreInput(nodeModules);
39+
+ addon.ignoreOutput(nodeModules);
40+
+ // NODE_ENV drives dead-code elimination and `import.meta.env.MODE`.
41+
+ addon.getEnv("NODE_ENV", true);
42+
+ }
43+
+ };
44+
+}
45+
+//#endregion
46+
//#region src/node/plugins/index.ts
47+
async function resolvePlugins(config, prePlugins, normalPlugins, postPlugins) {
48+
const isBuild = config.command === "build";
49+
@@ -29717,6 +29754,7 @@ async function resolvePlugins(config, prePlugins, normalPlugins, postPlugins) {
50+
};
51+
const { modulePreload } = config.build;
52+
return [
53+
+ vpRunnerAwarePlugin(config),
54+
!isBundled ? optimizedDepsPlugin() : null,
55+
!isWorker ? watchPackageDataPlugin(config.packageCache) : null,
56+
!isBundled ? preAliasPlugin(config) : null,

0 commit comments

Comments
 (0)