From 9db3dd2dc5dd15274c6abe526a38908495981094 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 28 May 2026 15:48:35 +0800 Subject: [PATCH] feat(cache): integrate runner-aware IPC reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous PR's placeholder with the real `vite_task_server::serve(...)` call: per-task IPC server, napi addon embedded into the runner binary and materialized to disk on first use, `VP_RUN_NODE_CLIENT_PATH` injected into the child so the JS wrapper can `require()` it. Cache integration: `Reports` collected from the IPC drive `PostRunFingerprint` and the cache-update path — - `ignore_input` reads → excluded from input fingerprint - `ignore_output` writes → excluded from output archive - `tracked_envs` (single name) + `tracked_env_globs` → folded into the post-run fingerprint so a value change misses the cache - `disable_cache` → skips the cache-update path entirely (`ToolRequested`) - IPC server bind/runtime failure → `IpcServerError` cache disable End-to-end coverage via the `ipc_client_test` fixture set: one fixture per API method, each exercising the real Rust ↔ JS path and asserting the right cache behaviour. Adds `vtt` (test-only) helpers `grep_file` and `stat_file` that the fixtures need. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 4 + crates/fspy/build.rs | 1 - crates/vite_task/Cargo.toml | 10 + crates/vite_task/build.rs | 20 +- crates/vite_task/src/lib.rs | 1 + crates/vite_task/src/napi_client.rs | 32 +++ crates/vite_task/src/session/event.rs | 1 - crates/vite_task/src/session/execute/mod.rs | 54 ++-- crates/vite_task_bin/src/vtt/grep_file.rs | 16 ++ crates/vite_task_bin/src/vtt/main.rs | 12 +- crates/vite_task_bin/src/vtt/stat_file.rs | 9 + .../fixtures/ipc_client_test/package.json | 5 + .../ipc_client_test/scripts/disable_cache.mjs | 8 + .../ipc_client_test/scripts/fetch_env.mjs | 10 + .../ipc_client_test/scripts/fetch_envs.mjs | 14 ++ .../ipc_client_test/scripts/ignore_input.mjs | 15 ++ .../ipc_client_test/scripts/ignore_output.mjs | 14 ++ .../fixtures/ipc_client_test/snapshots.toml | 232 ++++++++++++++++++ .../disable_cache_forces_reexecution.md | 20 ++ ...fetch_env_tracked_invalidates_on_change.md | 32 +++ .../fetch_envs_tracks_glob_match_set.md | 78 ++++++ .../ignore_input_keeps_cache_valid.md | 31 +++ ...ignore_output_allows_read_write_overlap.md | 39 +++ .../fixtures/ipc_client_test/vite-task.json | 24 ++ .../vite_task_bin/tests/e2e_snapshots/main.rs | 76 +++++- 25 files changed, 733 insertions(+), 25 deletions(-) create mode 100644 crates/vite_task/src/napi_client.rs create mode 100644 crates/vite_task_bin/src/vtt/grep_file.rs create mode 100644 crates/vite_task_bin/src/vtt/stat_file.rs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_tracks_glob_match_set.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_input_keeps_cache_valid.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_output_allows_read_write_overlap.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json diff --git a/Cargo.lock b/Cargo.lock index 6ce63502..f6fb8c5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4232,6 +4232,8 @@ dependencies = [ "derive_more", "fspy", "futures-util", + "materialized_artifact", + "materialized_artifact_build", "nix 0.31.2", "once_cell", "owo-colors", @@ -4255,7 +4257,9 @@ dependencies = [ "vite_path", "vite_select", "vite_str", + "vite_task_client_napi", "vite_task_graph", + "vite_task_ipc_shared", "vite_task_plan", "vite_task_server", "vite_workspace", diff --git a/crates/fspy/build.rs b/crates/fspy/build.rs index b6659f15..6c35b8a9 100644 --- a/crates/fspy/build.rs +++ b/crates/fspy/build.rs @@ -158,7 +158,6 @@ fn register_preload_cdylib() -> anyhow::Result<()> { } fn main() -> anyhow::Result<()> { - println!("cargo:rerun-if-changed=build.rs"); let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap()); fetch_macos_binaries(&out_dir).context("Failed to fetch macOS binaries")?; register_preload_cdylib().context("Failed to register preload cdylib")?; diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index deeb74db..77a9771e 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -43,18 +43,28 @@ tokio = { workspace = true, features = [ tokio-util = { workspace = true } tracing = { workspace = true } twox-hash = { workspace = true } +materialized_artifact = { workspace = true } uuid = { workspace = true, features = ["v4"] } vite_glob = { workspace = true } vite_path = { workspace = true } vite_select = { workspace = true } vite_str = { workspace = true } vite_task_graph = { workspace = true } +vite_task_ipc_shared = { workspace = true } vite_task_plan = { workspace = true } vite_task_server = { workspace = true } vite_workspace = { workspace = true } wax = { workspace = true } zstd = { workspace = true } +# Artifact build-deps must be unconditional: cargo's resolver panics when +# `artifact = "cdylib"` deps live under a `[target.cfg.build-dependencies]` +# block on cross-compile. +[build-dependencies] +anyhow = { workspace = true } +materialized_artifact_build = { workspace = true } +vite_task_client_napi = { workspace = true } + [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/vite_task/build.rs b/crates/vite_task/build.rs index 6acc864e..4f1fbe19 100644 --- a/crates/vite_task/build.rs +++ b/crates/vite_task/build.rs @@ -1,3 +1,13 @@ +#![expect( + clippy::disallowed_types, + clippy::disallowed_macros, + reason = "build.rs interfaces with std::path and cargo's env-var API" +)] + +use std::{env, path::Path}; + +use anyhow::Context; + // Why `cfg(fspy)` instead of matching on `target_os` directly at each use site: // "fspy is available" is a single semantic predicate, but the underlying reason // (the `fspy` crate builds on windows/macos/linux) is a three-OS list that @@ -7,12 +17,18 @@ // over OSes. The OS allowlist lives in two spots that must stay in sync: this // file (for the rustc cfg) and the target-scoped dep block in Cargo.toml // (which Cargo resolves before build.rs runs, so it can't reuse this cfg). -fn main() { +fn main() -> anyhow::Result<()> { println!("cargo::rustc-check-cfg=cfg(fspy)"); println!("cargo::rerun-if-changed=build.rs"); - let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); if matches!(target_os.as_str(), "windows" | "macos" | "linux") { println!("cargo::rustc-cfg=fspy"); } + + let env_name = "CARGO_CDYLIB_FILE_VITE_TASK_CLIENT_NAPI"; + println!("cargo:rerun-if-env-changed={env_name}"); + let dylib_path = env::var_os(env_name).with_context(|| format!("{env_name} not set"))?; + materialized_artifact_build::register("vite_task_client_napi", Path::new(&dylib_path)); + Ok(()) } diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index 8a8f0386..3c330796 100644 --- a/crates/vite_task/src/lib.rs +++ b/crates/vite_task/src/lib.rs @@ -1,5 +1,6 @@ mod cli; mod collections; +mod napi_client; pub mod session; // Public exports for vite_task_bin diff --git a/crates/vite_task/src/napi_client.rs b/crates/vite_task/src/napi_client.rs new file mode 100644 index 00000000..dce8328d --- /dev/null +++ b/crates/vite_task/src/napi_client.rs @@ -0,0 +1,32 @@ +//! The `vite_task_client_napi` cdylib is embedded into the `vp` binary and +//! materialized to disk on first use so tools can `require()` it at runtime. + +use std::{env, fs, sync::LazyLock}; + +use materialized_artifact::artifact; +use vite_path::{AbsolutePath, AbsolutePathBuf}; + +/// Path to the materialized `vite_task_client_napi` `.node` addon. +/// +/// The file is written to a process-wide temp directory on first call and +/// reused on every subsequent call (content-addressed filename; no re-writes). +/// +/// # Panics +/// +/// Panics if the materialization fails on first call — this mirrors fspy's +/// `SPY_IMPL` and the same reasoning applies: if we can't write into the +/// system temp dir, the runner can't run tasks anyway. +#[must_use] +pub fn napi_client_path() -> &'static AbsolutePath { + static PATH: LazyLock = LazyLock::new(|| { + let dir = env::temp_dir().join("vite_task_client_napi"); + let _ = fs::create_dir(&dir); + let path = artifact!("vite_task_client_napi") + .materialize() + .suffix(".node") + .at(&dir) + .expect("materialize vite_task_client_napi"); + AbsolutePathBuf::new(path).expect("system temp dir yields an absolute path") + }); + PATH.as_absolute_path() +} diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index 23f10448..798eb655 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -48,7 +48,6 @@ pub enum ExecutionError { /// The runner-aware IPC server failed to bind for this task. Reported /// instead of silently degrading so that `{ auto: true }` inputs stay /// observable end-to-end. - #[expect(dead_code, reason = "placeholder; constructed once the real `serve()` lands")] #[error("Failed to start runner IPC server")] IpcServerBind(#[source] std::io::Error), } diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index f53ef37d..2fa07a77 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -24,11 +24,12 @@ use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; +use vite_task_ipc_shared::NODE_CLIENT_PATH_ENV_NAME; use vite_task_plan::{ ExecutionGraph, ExecutionItemDisplay, ExecutionItemKind, LeafExecutionKind, SpawnExecution, cache_metadata::CacheMetadata, execution_graph::ExecutionNodeIndex, }; -use vite_task_server::{Recorder, Reports, StopAccepting}; +use vite_task_server::{Recorder, Reports, ServerHandle, StopAccepting, serve}; use wax::Program as _; #[cfg(fspy)] @@ -499,25 +500,35 @@ pub async fn execute_spawn( return SpawnOutcome::Failed; } }; - // PLACEHOLDER: the IPC server isn't wired up yet on this - // branch. We construct an empty `Recorder` whose `Reports` - // never get any IPC traffic, and a `StopAccepting::noop` - // since no real server was bound. A follow-up will swap - // these out for `vite_task_server::serve(...)`. + // fspy + IPC are bundled. If binding the IPC server fails + // we abort the execution — tools that rely on IPC would + // otherwise silently diverge from the cache. + // + // The IPC `getEnv` endpoint serves values from the runner's + // own parent env (not the task's filtered `all_envs`), so a + // tool can ask for vars the user never declared and have + // them fingerprinted via the tool's `tracked: true` flag. let env_map: FxHashMap, Arc> = std::env::vars_os() .map(|(k, v)| { (Arc::::from(k.as_os_str()), Arc::::from(v.as_os_str())) }) .collect(); - let recorder = Recorder::new(env_map); - let driver: LocalBoxFuture<'static, Result> = - Box::pin(std::future::ready(Ok(recorder))); - Some(Tracking { - input_negative_globs: negatives, - ipc_envs: Vec::new(), - ipc_server_fut: driver, - stop_accepting: StopAccepting::noop(), - }) + match serve(Recorder::new(env_map)) { + Ok((envs, ServerHandle { driver, stop_accepting })) => Some(Tracking { + input_negative_globs: negatives, + ipc_envs: envs.collect(), + ipc_server_fut: driver, + stop_accepting, + }), + Err(err) => { + leaf_reporter.finish( + None, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), + Some(ExecutionError::IpcServerBind(err)), + ); + return SpawnOutcome::Failed; + } + } } else { None }; @@ -539,12 +550,17 @@ pub async fn execute_spawn( ExecutionMode::Uncached { pipe_writers: None } => (SpawnStdio::Inherited, false), }; - // Build the extra envs to inject: IPC connection info + (eventually) - // napi addon path. Empty when tracking is off. The napi path is added - // in a follow-up that wires up the real server. + // Build the extra envs to inject: IPC connection info + napi addon path. + // Empty when tracking is off. let extra_envs: Vec<(&OsStr, &OsStr)> = match &mode { ExecutionMode::Cached { state: CacheState { tracking: Some(t), .. }, .. } => { - t.ipc_envs.iter().map(|(k, v)| (*k as &OsStr, v.as_os_str())).collect() + let mut envs: Vec<(&OsStr, &OsStr)> = + t.ipc_envs.iter().map(|(k, v)| (*k as &OsStr, v.as_os_str())).collect(); + envs.push(( + OsStr::new(NODE_CLIENT_PATH_ENV_NAME), + crate::napi_client::napi_client_path().as_path().as_os_str(), + )); + envs } _ => Vec::new(), }; diff --git a/crates/vite_task_bin/src/vtt/grep_file.rs b/crates/vite_task_bin/src/vtt/grep_file.rs new file mode 100644 index 00000000..50b3281b --- /dev/null +++ b/crates/vite_task_bin/src/vtt/grep_file.rs @@ -0,0 +1,16 @@ +pub fn run(args: &[String]) { + let [path, pattern] = args else { + eprintln!("Usage: vtt grep-file "); + std::process::exit(2); + }; + match std::fs::read_to_string(path) { + Ok(content) => { + if content.contains(pattern.as_str()) { + println!("{path}: found {pattern:?}"); + } else { + println!("{path}: missing {pattern:?}"); + } + } + Err(_) => println!("{path}: not found"), + } +} diff --git a/crates/vite_task_bin/src/vtt/main.rs b/crates/vite_task_bin/src/vtt/main.rs index d6dcb1af..c2d3e215 100644 --- a/crates/vite_task_bin/src/vtt/main.rs +++ b/crates/vite_task_bin/src/vtt/main.rs @@ -11,6 +11,7 @@ mod check_tty; mod cp; mod exit; mod exit_on_ctrlc; +mod grep_file; mod list_dir; mod mkdir; mod pipe_stdin; @@ -22,6 +23,7 @@ mod print_file; mod read_stdin; mod replace_file_content; mod rm; +mod stat_file; mod touch_file; mod write_file; @@ -30,7 +32,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, stat-file, touch-file, write-file" ); std::process::exit(1); } @@ -44,6 +46,10 @@ fn main() { "cp" => cp::run(&args[2..]), "exit" => exit::run(&args[2..]), "exit-on-ctrlc" => exit_on_ctrlc::run(), + "grep-file" => { + grep_file::run(&args[2..]); + Ok(()) + } "list-dir" => list_dir::run(&args[2..]), "mkdir" => mkdir::run(&args[2..]), "pipe-stdin" => pipe_stdin::run(&args[2..]), @@ -58,6 +64,10 @@ fn main() { "read-stdin" => read_stdin::run(), "replace-file-content" => replace_file_content::run(&args[2..]), "rm" => rm::run(&args[2..]), + "stat-file" => { + stat_file::run(&args[2..]); + Ok(()) + } "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), other => { diff --git a/crates/vite_task_bin/src/vtt/stat_file.rs b/crates/vite_task_bin/src/vtt/stat_file.rs new file mode 100644 index 00000000..75fbebf4 --- /dev/null +++ b/crates/vite_task_bin/src/vtt/stat_file.rs @@ -0,0 +1,9 @@ +pub fn run(args: &[String]) { + for file in args { + if std::fs::metadata(file).is_ok() { + println!("{file}: exists"); + } else { + println!("{file}: missing"); + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json new file mode 100644 index 00000000..be1e0be8 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json @@ -0,0 +1,5 @@ +{ + "name": "ipc-client-test", + "private": true, + "type": "module" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs new file mode 100644 index 00000000..f868cef5 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs @@ -0,0 +1,8 @@ +import { disableCache } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// Produce an output, then ask the runner not to cache this execution — the +// next `vt run` should re-execute the task. +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'ok\n'); +disableCache(); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs new file mode 100644 index 00000000..b9b4204f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs @@ -0,0 +1,10 @@ +import { getEnv } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// getEnv returns the env value from the runner and — with tracked: true — +// adds the env to the post-run fingerprint, so a change between runs +// invalidates the cache. +const value = getEnv('PROBE_ENV', { tracked: true }) ?? '(unset)'; + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'PROBE_ENV=' + value + '\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs new file mode 100644 index 00000000..b89f2cc0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs @@ -0,0 +1,14 @@ +import { getEnvs } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// getEnvs asks the runner for every env matching the glob. The glob (plus +// its match-set) becomes part of the post-run fingerprint, so adding, +// removing, or changing any matching env invalidates the cache on the next +// run. The non-matching UNRELATED envs set by some test steps must not +// contribute. +const matches = getEnvs('PROBE_*', { tracked: true }); + +mkdirSync('dist', { recursive: true }); +const sorted = Object.entries(matches).sort(([a], [b]) => a.localeCompare(b)); +const body = sorted.map(([k, v]) => `${k}=${v}`).join('\n'); +writeFileSync('dist/out.txt', body + '\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs new file mode 100644 index 00000000..e368bb9c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs @@ -0,0 +1,15 @@ +import { ignoreInput } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, readFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; + +// The task reads from `cache_like/` (which we want the runner to IGNORE as +// an input), and writes to `dist/`. Without the ignore, the auto-input +// fingerprint would fluctuate with cache_like/ contents even though they're +// not semantic inputs. +mkdirSync('cache_like', { recursive: true }); +writeFileSync('cache_like/stale.txt', 'stale-' + Date.now() + '\n'); +ignoreInput('cache_like'); +readFileSync('cache_like/stale.txt', 'utf8'); + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'ok\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs new file mode 100644 index 00000000..efb1aa16 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs @@ -0,0 +1,14 @@ +import { ignoreOutput } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'; + +// The task both reads and writes `sidecar/tmp.txt`. If the runner didn't +// treat `sidecar/` as an ignored output, the read-write overlap check would +// refuse to cache the task. `dist/out.txt` is the real output. +mkdirSync('sidecar', { recursive: true }); +writeFileSync('sidecar/tmp.txt', 'initial\n'); +readFileSync('sidecar/tmp.txt', 'utf8'); +writeFileSync('sidecar/tmp.txt', 'final\n'); +ignoreOutput('sidecar'); + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'ok\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml new file mode 100644 index 00000000..3337af0e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml @@ -0,0 +1,232 @@ +[[e2e]] +name = "ignore_input_keeps_cache_valid" +comment = """ +Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`. +The runner treats `cache_like/` as non-input, so mutations to it between +runs do not invalidate the cache. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "ignore-input", + ], comment = "populate the cache" }, + { argv = [ + "vtt", + "write-file", + "cache_like/other.txt", + "after", + ], comment = "mutate the ignored directory — would invalidate if tracked" }, + { argv = [ + "vt", + "run", + "ignore-input", + ], comment = "cache hit: cache_like/ was ignored via ignoreInput" }, +] + +[[e2e]] +name = "ignore_output_allows_read_write_overlap" +comment = """ +Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`; +without the ignore the runner's read-write overlap check would refuse to +cache the run ("read and wrote 'sidecar/tmp.txt'"). +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "ignore-output", + ], comment = "first run populates the cache" }, + { argv = [ + "vtt", + "rm", + "dist/out.txt", + ], comment = "remove the real output so the cache-hit restore is observable" }, + { argv = [ + "vt", + "run", + "ignore-output", + ], comment = "cache hit: sidecar/ writes were ignored" }, + { argv = [ + "vtt", + "print-file", + "dist/out.txt", + ], comment = "restored from the cache archive" }, +] + +[[e2e]] +name = "disable_cache_forces_reexecution" +comment = """ +Exercises `disableCache`. The tool asks the runner not to cache this run, +so the next invocation re-executes instead of hitting a prior entry. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "disable-cache", + ], comment = "first run — tool calls disableCache" }, + { argv = [ + "vt", + "run", + "disable-cache", + ], comment = "cache miss (NotFound) because nothing was cached" }, +] + +[[e2e]] +name = "fetch_envs_tracks_glob_match_set" +comment = """ +Exercises `getEnvs(pattern, { tracked: true })`. The glob `PROBE_*` and +its match-set snapshot enter the post-run fingerprint: later runs diff the +current match-set against what was stored and miss on add / remove / change, +but hit when only non-matching envs differ. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_B", + "b", + ], + ], comment = "populate: first run captures {PROBE_A, PROBE_B} under the glob" }, + { argv = [ + "vtt", + "print-file", + "dist/out.txt", + ], comment = "the tool observed both matching envs" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_B", + "b", + ], + ], comment = "unchanged: same match-set → cache hit" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_A", + "changed", + ], + [ + "PROBE_B", + "b", + ], + ], comment = "change: PROBE_A value differs → cache miss (TrackedEnvGlobChanged / changed)" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_A", + "changed", + ], + [ + "PROBE_B", + "b", + ], + [ + "PROBE_C", + "c", + ], + ], comment = "add: PROBE_C is new under the glob → cache miss (added)" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_B", + "b", + ], + [ + "PROBE_C", + "c", + ], + ], comment = "remove: PROBE_A dropped from the match-set → cache miss (removed)" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_B", + "b", + ], + [ + "PROBE_C", + "c", + ], + [ + "UNRELATED", + "noise", + ], + ], comment = "non-matching noise: UNRELATED doesn't match PROBE_* → cache hit" }, + { argv = [ + "vtt", + "print-file", + "dist/out.txt", + ], comment = "match-set unchanged from the previous successful run" }, +] + +[[e2e]] +name = "fetch_env_tracked_invalidates_on_change" +comment = """ +Exercises `getEnv(name, { tracked: true })`. The env value becomes part +of the post-run fingerprint: the same value still hits, a different value +misses with `tracked env 'PROBE_ENV' changed`. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-env", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "first run captures PROBE_ENV=first in the fingerprint" }, + { argv = [ + "vt", + "run", + "fetch-env", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "cache hit: PROBE_ENV unchanged" }, + { argv = [ + "vt", + "run", + "fetch-env", + ], envs = [ + [ + "PROBE_ENV", + "second", + ], + ], comment = "cache miss: tracked env changed" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md new file mode 100644 index 00000000..15015854 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md @@ -0,0 +1,20 @@ +# disable_cache_forces_reexecution + +Exercises `disableCache`. The tool asks the runner not to cache this run, +so the next invocation re-executes instead of hitting a prior entry. + +## `vt run disable-cache` + +first run — tool calls disableCache + +``` +$ node scripts/disable_cache.mjs +``` + +## `vt run disable-cache` + +cache miss (NotFound) because nothing was cached + +``` +$ node scripts/disable_cache.mjs +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md new file mode 100644 index 00000000..4f808d85 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md @@ -0,0 +1,32 @@ +# fetch_env_tracked_invalidates_on_change + +Exercises `getEnv(name, { tracked: true })`. The env value becomes part +of the post-run fingerprint: the same value still hits, a different value +misses with `tracked env 'PROBE_ENV' changed`. + +## `PROBE_ENV=first vt run fetch-env` + +first run captures PROBE_ENV=first in the fingerprint + +``` +$ node scripts/fetch_env.mjs +``` + +## `PROBE_ENV=first vt run fetch-env` + +cache hit: PROBE_ENV unchanged + +``` +$ node scripts/fetch_env.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `PROBE_ENV=second vt run fetch-env` + +cache miss: tracked env changed + +``` +$ node scripts/fetch_env.mjs ○ cache miss: tracked env 'PROBE_ENV' changed, executing +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_tracks_glob_match_set.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_tracks_glob_match_set.md new file mode 100644 index 00000000..b611d515 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_tracks_glob_match_set.md @@ -0,0 +1,78 @@ +# fetch_envs_tracks_glob_match_set + +Exercises `getEnvs(pattern, { tracked: true })`. The glob `PROBE_*` and +its match-set snapshot enter the post-run fingerprint: later runs diff the +current match-set against what was stored and miss on add / remove / change, +but hit when only non-matching envs differ. + +## `PROBE_A=a PROBE_B=b vt run fetch-envs` + +populate: first run captures {PROBE_A, PROBE_B} under the glob + +``` +$ node scripts/fetch_envs.mjs +``` + +## `vtt print-file dist/out.txt` + +the tool observed both matching envs + +``` +PROBE_A=a +PROBE_B=b +``` + +## `PROBE_A=a PROBE_B=b vt run fetch-envs` + +unchanged: same match-set → cache hit + +``` +$ node scripts/fetch_envs.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `PROBE_A=changed PROBE_B=b vt run fetch-envs` + +change: PROBE_A value differs → cache miss (TrackedEnvGlobChanged / changed) + +``` +$ node scripts/fetch_envs.mjs ○ cache miss: tracked env glob 'PROBE_*' changed, executing +``` + +## `PROBE_A=changed PROBE_B=b PROBE_C=c vt run fetch-envs` + +add: PROBE_C is new under the glob → cache miss (added) + +``` +$ node scripts/fetch_envs.mjs ○ cache miss: tracked env glob 'PROBE_*' changed, executing +``` + +## `PROBE_B=b PROBE_C=c vt run fetch-envs` + +remove: PROBE_A dropped from the match-set → cache miss (removed) + +``` +$ node scripts/fetch_envs.mjs ○ cache miss: tracked env glob 'PROBE_*' changed, executing +``` + +## `PROBE_B=b PROBE_C=c UNRELATED=noise vt run fetch-envs` + +non-matching noise: UNRELATED doesn't match PROBE_* → cache hit + +``` +$ node scripts/fetch_envs.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `vtt print-file dist/out.txt` + +match-set unchanged from the previous successful run + +``` +PROBE_B=b +PROBE_C=c +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_input_keeps_cache_valid.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_input_keeps_cache_valid.md new file mode 100644 index 00000000..d624c0c4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_input_keeps_cache_valid.md @@ -0,0 +1,31 @@ +# ignore_input_keeps_cache_valid + +Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`. +The runner treats `cache_like/` as non-input, so mutations to it between +runs do not invalidate the cache. + +## `vt run ignore-input` + +populate the cache + +``` +$ node scripts/ignore_input.mjs +``` + +## `vtt write-file cache_like/other.txt after` + +mutate the ignored directory — would invalidate if tracked + +``` +``` + +## `vt run ignore-input` + +cache hit: cache_like/ was ignored via ignoreInput + +``` +$ node scripts/ignore_input.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_output_allows_read_write_overlap.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_output_allows_read_write_overlap.md new file mode 100644 index 00000000..70ee4002 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_output_allows_read_write_overlap.md @@ -0,0 +1,39 @@ +# ignore_output_allows_read_write_overlap + +Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`; +without the ignore the runner's read-write overlap check would refuse to +cache the run ("read and wrote 'sidecar/tmp.txt'"). + +## `vt run ignore-output` + +first run populates the cache + +``` +$ node scripts/ignore_output.mjs +``` + +## `vtt rm dist/out.txt` + +remove the real output so the cache-hit restore is observable + +``` +``` + +## `vt run ignore-output` + +cache hit: sidecar/ writes were ignored + +``` +$ node scripts/ignore_output.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `vtt print-file dist/out.txt` + +restored from the cache archive + +``` +ok +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json new file mode 100644 index 00000000..7b7ddc90 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json @@ -0,0 +1,24 @@ +{ + "tasks": { + "ignore-input": { + "command": "node scripts/ignore_input.mjs", + "cache": true + }, + "ignore-output": { + "command": "node scripts/ignore_output.mjs", + "cache": true + }, + "disable-cache": { + "command": "node scripts/disable_cache.mjs", + "cache": true + }, + "fetch-env": { + "command": "node scripts/fetch_env.mjs", + "cache": true + }, + "fetch-envs": { + "command": "node scripts/fetch_envs.mjs", + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 2de54930..f0f6782c 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -283,6 +283,56 @@ fn render_formatted_screen(bytes: &[u8]) -> String { out } +/// Copy the `@voidzero-dev/vite-task-client` JS wrapper into the fixture's +/// staging `node_modules` so Node scripts can resolve it by name. Idempotent — +/// silently skipped if the source package is not found. +#[expect(clippy::disallowed_types, reason = "std::path::Path required for filesystem operations")] +fn populate_vite_task_client_package(stage_path: &AbsolutePath) { + let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let src = repo_root.join("packages/vite-task-client"); + if !src.is_dir() { + return; + } + let dst = stage_path.as_path().join("node_modules/@voidzero-dev/vite-task-client"); + std::fs::create_dir_all(dst.parent().unwrap()).unwrap(); + CopyOptions::new().copy_tree(&src, &dst).unwrap(); +} + +/// Symlink installed Node packages from the repo's `packages/tools/node_modules` +/// into the fixture's staging `node_modules` so fixtures can resolve them by +/// name without a per-fixture pnpm install. Only packages whose staging-side +/// symlink targets exist are created; missing targets are silently skipped. +#[expect(clippy::disallowed_types, reason = "std::path::Path required for filesystem operations")] +fn link_tools_packages(stage_path: &AbsolutePath, names: &[&str]) { + let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let stage_node_modules = stage_path.as_path().join("node_modules"); + std::fs::create_dir_all(&stage_node_modules).unwrap(); + + for name in names { + let src = repo_root.join("packages/tools/node_modules").join(name); + // Follow the symlink so the absolute target (pnpm's .pnpm store) is + // what we pin into the staging tree. Relative symlinks into pnpm + // internals would break outside the repo. + let Ok(canonical) = std::fs::canonicalize(&src) else { + continue; + }; + let link = stage_node_modules.join(name); + let _ = std::fs::remove_file(&link); + #[cfg(unix)] + std::os::unix::fs::symlink(&canonical, &link).unwrap(); + #[cfg(windows)] + { + if canonical.is_dir() { + std::os::windows::fs::symlink_dir(&canonical, &link).unwrap(); + } else { + std::os::windows::fs::symlink_file(&canonical, &link).unwrap(); + } + } + } +} + /// Append a fenced markdown block containing `body`. The opening and closing /// fences sit on their own lines, and trailing whitespace inside `body` is /// trimmed so the close fence isn't preceded by blank lines. @@ -319,6 +369,19 @@ fn run_case( let e2e_stage_path = tmpdir.join(vite_str::format!("{fixture_name}_case_{case_index}")); CopyOptions::new().copy_tree(fixture_path, e2e_stage_path.as_path()).unwrap(); + // Make `@voidzero-dev/vite-task-client` importable from any fixture's Node + // scripts by copying the wrapper package into the staging dir's + // `node_modules`. This mirrors the user-facing flow (`import { ... } from + // "@voidzero-dev/vite-task-client"`) without requiring pnpm install. + populate_vite_task_client_package(&e2e_stage_path); + + // Fixtures that exercise real Node toolchains (e.g. `vite build`) link + // those packages from the repo's `packages/tools/node_modules` so the + // tool and its transitive deps (resolved via pnpm) stay reachable. + if matches!(fixture_name, "vite_build_cache" | "vite_dev_disable_cache") { + link_tools_packages(&e2e_stage_path, &["vite"]); + } + let (workspace_root, _cwd) = find_workspace_root(&e2e_stage_path).unwrap(); assert_eq!( &e2e_stage_path, &*workspace_root.path, @@ -331,8 +394,19 @@ fn run_case( let bin = AbsolutePathBuf::new(std::path::PathBuf::from(bin_path)).unwrap(); Arc::::from(bin.parent().unwrap().as_path().as_os_str()) }); + + // Also expose tool bins installed under packages/tools/node_modules/.bin + // (e.g. `vite`) so ignored e2e fixtures can exercise real toolchains. + #[expect(clippy::disallowed_types, reason = "PathBuf needed for workspace path arithmetic")] + let tools_bin_dir: Option> = { + let manifest_dir = std::path::PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let tools_bin = repo_root.join("packages/tools/node_modules/.bin"); + tools_bin.is_dir().then(|| Arc::::from(tools_bin.into_os_string())) + }; + let e2e_env_path = join_paths( - bin_dirs.iter().cloned().chain( + bin_dirs.iter().cloned().chain(tools_bin_dir.iter().cloned()).chain( // the existing PATH split_paths(&env::var_os("PATH").unwrap()) .map(|path| Arc::::from(path.into_os_string())),