diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/build.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/build.mjs new file mode 100644 index 00000000..42982d61 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/build.mjs @@ -0,0 +1,18 @@ +// Simulates Vite's behavior during build: +// 1. Reads the source file (tracked as input by fspy) +// 2. Writes a temp config to node_modules/.vite-temp/ (also tracked by fspy) +// Without the negative input glob, step 2 causes a read-write overlap +// that prevents caching. + +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; + +// Read source (tracked as input) +const source = readFileSync('src/index.ts', 'utf-8'); + +// Simulate Vite writing temp config (read-write to .vite-temp) +const tempDir = 'node_modules/.vite-temp'; +mkdirSync(tempDir, { recursive: true }); +const tempFile = `${tempDir}/vite.config.ts.timestamp-${Date.now()}.mjs`; +writeFileSync(tempFile, `// compiled config\nexport default {};`); + +console.log(`built: ${source.trim()}`); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/package.json new file mode 100644 index 00000000..74a60e6e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/package.json @@ -0,0 +1,4 @@ +{ + "name": "cache-negative-input-glob", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/snapshots.toml new file mode 100644 index 00000000..40a9c95e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/snapshots.toml @@ -0,0 +1,18 @@ +# Tests that negative input globs exclude paths from fspy tracking, +# so tasks writing to excluded paths (like node_modules/.vite-temp) are still cached. +# See: https://github.com/voidzero-dev/vite-plus/issues/1095 + +[[e2e]] +name = "negative input glob excludes vite-temp from tracking" +steps = [ + "vt run build # first run: cache miss", + "vt run build # second run: should hit cache despite .vite-temp write", +] + +[[e2e]] +name = "source change still causes cache miss with negative input glob" +steps = [ + "vt run build # first run: cache miss", + "replace-file-content src/index.ts world universe # modify real source", + "vt run build # cache miss: source changed", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/snapshots/negative input glob excludes vite-temp from tracking.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/snapshots/negative input glob excludes vite-temp from tracking.snap new file mode 100644 index 00000000..7b8ce5c4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/snapshots/negative input glob excludes vite-temp from tracking.snap @@ -0,0 +1,13 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run build # first run: cache miss +$ node build.mjs +built: export const hello = 'world'; +> vt run build # second run: should hit cache despite .vite-temp write +$ node build.mjs ◉ cache hit, replaying +built: export const hello = 'world'; + +--- +vt run: cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/snapshots/source change still causes cache miss with negative input glob.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/snapshots/source change still causes cache miss with negative input glob.snap new file mode 100644 index 00000000..00dc2fd2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/snapshots/source change still causes cache miss with negative input glob.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run build # first run: cache miss +$ node build.mjs +built: export const hello = 'world'; +> replace-file-content src/index.ts world universe # modify real source + +> vt run build # cache miss: source changed +$ node build.mjs ○ cache miss: 'src/index.ts' modified, executing +built: export const hello = 'universe'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/src/index.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/src/index.ts new file mode 100644 index 00000000..35f468bf --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/src/index.ts @@ -0,0 +1 @@ +export const hello = 'world'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/vite-task.json new file mode 100644 index 00000000..a6bd2951 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-negative-input-glob/vite-task.json @@ -0,0 +1,10 @@ +{ + "cache": true, + "tasks": { + "build": { + "command": "node build.mjs", + "cache": true, + "input": [{ "auto": true }, "!node_modules/.vite-temp/**"] + } + } +} diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index ebaaec25..4ae66e43 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -20,7 +20,7 @@ use vite_str::Str; use vite_task_graph::{ TaskNodeIndex, TaskSource, config::{ - CacheConfig, ResolvedGlobalCacheConfig, ResolvedTaskOptions, + CacheConfig, ResolvedGlobalCacheConfig, ResolvedInputConfig, ResolvedTaskOptions, user::{UserCacheConfig, UserTaskOptions}, }, query::TaskQuery, @@ -464,6 +464,24 @@ fn resolve_synthetic_cache_config( if let Some(extra_pts) = enabled_cache_config.untracked_env { parent_config.env_config.untracked_env.extend(extra_pts); } + // Merge synthetic's input config (resolve relative to workspace root, + // since synthetic commands operate at CLI level, not package level) + if let Some(ref input) = enabled_cache_config.input { + let synthetic_input = ResolvedInputConfig::from_user_config( + Some(input), + workspace_path, + workspace_path, + ) + .map_err(Error::ResolveTaskConfig)?; + parent_config + .input_config + .negative_globs + .extend(synthetic_input.negative_globs); + parent_config + .input_config + .positive_globs + .extend(synthetic_input.positive_globs); + } Some(parent_config) } }) @@ -749,3 +767,212 @@ pub async fn plan_query_request( Error::CycleDependencyDetected(displays) }) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use rustc_hash::FxHashSet; + use vite_path::AbsolutePathBuf; + use vite_str::Str; + use vite_task_graph::config::{ + CacheConfig, EnvConfig, ResolvedInputConfig, + user::{EnabledCacheConfig, UserCacheConfig, UserInputEntry}, + }; + + use super::{ParentCacheConfig, resolve_synthetic_cache_config}; + + fn make_workspace_path() -> AbsolutePathBuf { + #[cfg(unix)] + { + AbsolutePathBuf::new("/workspace".into()).unwrap() + } + #[cfg(windows)] + { + AbsolutePathBuf::new("C:\\workspace".into()).unwrap() + } + } + + fn make_parent_config() -> CacheConfig { + CacheConfig { + env_config: EnvConfig { + fingerprinted_envs: FxHashSet::default(), + untracked_env: FxHashSet::default(), + }, + input_config: ResolvedInputConfig::default_auto(), + } + } + + #[test] + fn inherited_disabled_synthetic_returns_none() { + let workspace = make_workspace_path(); + let cwd = workspace.clone().into(); + let result = resolve_synthetic_cache_config( + ParentCacheConfig::Inherited(make_parent_config()), + UserCacheConfig::disabled(), + &cwd, + &workspace, + ) + .unwrap(); + assert!(result.is_none()); + } + + #[test] + fn disabled_parent_returns_none() { + let workspace = make_workspace_path(); + let cwd = workspace.clone().into(); + let result = resolve_synthetic_cache_config( + ParentCacheConfig::Disabled, + UserCacheConfig::with_config(EnabledCacheConfig { + env: None, + untracked_env: None, + input: None, + }), + &cwd, + &workspace, + ) + .unwrap(); + assert!(result.is_none()); + } + + #[test] + fn inherited_merges_env() { + let workspace = make_workspace_path(); + let cwd = workspace.clone().into(); + let result = resolve_synthetic_cache_config( + ParentCacheConfig::Inherited(make_parent_config()), + UserCacheConfig::with_config(EnabledCacheConfig { + env: Some(Box::new([Str::from("VITE_*")])), + untracked_env: Some(vec![Str::from("HOME")]), + input: None, + }), + &cwd, + &workspace, + ) + .unwrap() + .unwrap(); + assert!(result.env_config.fingerprinted_envs.contains("VITE_*")); + assert!(result.env_config.untracked_env.contains("HOME")); + } + + #[test] + fn inherited_merges_negative_input_globs() { + let workspace = make_workspace_path(); + let cwd = workspace.clone().into(); + let result = resolve_synthetic_cache_config( + ParentCacheConfig::Inherited(make_parent_config()), + UserCacheConfig::with_config(EnabledCacheConfig { + env: None, + untracked_env: None, + input: Some(vec![ + UserInputEntry::Auto { auto: true }, + UserInputEntry::Glob(Str::from("!node_modules/.vite-temp/**")), + ]), + }), + &cwd, + &workspace, + ) + .unwrap() + .unwrap(); + assert!(result.input_config.includes_auto); + assert!( + result.input_config.negative_globs.contains(&Str::from("node_modules/.vite-temp/**")), + "negative globs should contain the vite-temp exclusion, got: {:?}", + result.input_config.negative_globs, + ); + } + + #[test] + fn inherited_merges_positive_input_globs() { + let workspace = make_workspace_path(); + let cwd = workspace.clone().into(); + let result = resolve_synthetic_cache_config( + ParentCacheConfig::Inherited(make_parent_config()), + UserCacheConfig::with_config(EnabledCacheConfig { + env: None, + untracked_env: None, + input: Some(vec![UserInputEntry::Glob(Str::from("src/**"))]), + }), + &cwd, + &workspace, + ) + .unwrap() + .unwrap(); + assert!( + result.input_config.positive_globs.contains(&Str::from("src/**")), + "positive globs should contain src/**, got: {:?}", + result.input_config.positive_globs, + ); + } + + #[test] + fn inherited_input_none_preserves_parent_config() { + let workspace = make_workspace_path(); + let cwd = workspace.clone().into(); + let mut parent = make_parent_config(); + parent.input_config.negative_globs.insert(Str::from("existing/**")); + let result = resolve_synthetic_cache_config( + ParentCacheConfig::Inherited(parent), + UserCacheConfig::with_config(EnabledCacheConfig { + env: None, + untracked_env: None, + input: None, + }), + &cwd, + &workspace, + ) + .unwrap() + .unwrap(); + assert_eq!(result.input_config.negative_globs, BTreeSet::from([Str::from("existing/**")]),); + } + + #[test] + fn inherited_merges_input_with_existing_parent_globs() { + let workspace = make_workspace_path(); + let cwd = workspace.clone().into(); + let mut parent = make_parent_config(); + parent.input_config.negative_globs.insert(Str::from("dist/**")); + let result = resolve_synthetic_cache_config( + ParentCacheConfig::Inherited(parent), + UserCacheConfig::with_config(EnabledCacheConfig { + env: None, + untracked_env: None, + input: Some(vec![UserInputEntry::Glob(Str::from("!node_modules/.vite-temp/**"))]), + }), + &cwd, + &workspace, + ) + .unwrap() + .unwrap(); + assert_eq!( + result.input_config.negative_globs, + BTreeSet::from([Str::from("dist/**"), Str::from("node_modules/.vite-temp/**"),]), + ); + } + + #[test] + fn inherited_input_globs_resolved_relative_to_workspace_root() { + let workspace = make_workspace_path(); + // cwd is a subdirectory, but globs should resolve relative to workspace root + let cwd = workspace.join("packages/hello").into(); + let result = resolve_synthetic_cache_config( + ParentCacheConfig::Inherited(make_parent_config()), + UserCacheConfig::with_config(EnabledCacheConfig { + env: None, + untracked_env: None, + input: Some(vec![UserInputEntry::Glob(Str::from("!node_modules/.vite-temp/**"))]), + }), + &cwd, + &workspace, + ) + .unwrap() + .unwrap(); + // The glob should be workspace-root-relative, NOT cwd-relative + // (i.e., "node_modules/.vite-temp/**", NOT "packages/hello/node_modules/.vite-temp/**") + assert!( + result.input_config.negative_globs.contains(&Str::from("node_modules/.vite-temp/**")), + "glob should be workspace-root-relative, got: {:?}", + result.input_config.negative_globs, + ); + } +}