diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/app/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/app/index.js new file mode 100644 index 0000000000..36b8c6d193 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/app/index.js @@ -0,0 +1 @@ +export const app = 'fixture'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/input.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/input.txt new file mode 100644 index 0000000000..e79c5e8f96 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/input.txt @@ -0,0 +1 @@ +initial diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/package.json new file mode 100644 index 0000000000..2da2125db0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/package.json @@ -0,0 +1,5 @@ +{ + "name": "config-resolution-hooks", + "private": true, + "type": "module" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs new file mode 100644 index 0000000000..7fd44a1ea8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/settings.mjs @@ -0,0 +1,9 @@ +export const staged = { + '*.txt': 'vpt stat-file', +}; + +export const pack = { + entry: ['app/index.js'], + dts: false, + format: 'esm', +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml new file mode 100644 index 0000000000..e0a0b18510 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots.toml @@ -0,0 +1,21 @@ +[[case]] +name = "staged_preserves_config_hooks" +vp = "local" +unset-env = ["NODE_ENV"] +steps = [ + { argv = ["git", "init", "--quiet"], snapshot = false }, + { argv = ["git", "add", "input.txt"], snapshot = false }, + { argv = ["git", "commit", "--quiet", "-m", "initial"], snapshot = false }, + { argv = ["vpt", "write-file", "input.txt", "changed\n"], snapshot = false }, + { argv = ["git", "add", "input.txt"], snapshot = false }, + { argv = ["vp", "staged", "--verbose", "--no-stash"], comment = "Run staged configuration supplied by a Vite plugin and print the matched files." }, +] + +[[case]] +name = "pack_preserves_config_hooks" +vp = "local" +env = { NODE_ENV = "development" } +steps = [ + { argv = ["vp", "pack", "--logLevel", "silent"], comment = "Build with plugin-supplied pack options and configuration metadata available in configResolved." }, + ["vpt", "print-file", "dist/index.mjs"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md new file mode 100644 index 0000000000..8dad7ecdc1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/pack_preserves_config_hooks.md @@ -0,0 +1,25 @@ +# pack_preserves_config_hooks + +## `vp pack --logLevel silent` + +Build with plugin-supplied pack options and configuration metadata available in configResolved. + +``` +config NODE_ENV: development +apply root: +config root: +configResolved root: +configResolved configFile: /vite.config.mjs +configResolved dependencies: + /settings.mjs + /vite.config.mjs +ℹ Build start +``` + +## `vpt print-file dist/index.mjs` + +``` +// app/index.js +const app = "fixture"; +export { app }; +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md new file mode 100644 index 0000000000..48448c2332 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/snapshots/staged_preserves_config_hooks.md @@ -0,0 +1,38 @@ +# staged_preserves_config_hooks + +## `git init --quiet` + + +## `git add input.txt` + + +## `git commit --quiet -m initial` + + +## `vpt write-file input.txt 'changed +'` + + +## `git add input.txt` + + +## `vp staged --verbose --no-stash` + +Run staged configuration supplied by a Vite plugin and print the matched files. + +``` +config NODE_ENV: development +apply root: +config root: +configResolved root: +configResolved configFile: /vite.config.mjs +configResolved dependencies: + /settings.mjs + /vite.config.mjs +⚠ Skipping backup because `--no-stash` was used. This might result in data loss. + +✔ vpt stat-file + +→ vpt stat-file: +/input.txt: file +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs new file mode 100644 index 0000000000..967b15f3b2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_resolution_hooks/vite.config.mjs @@ -0,0 +1,37 @@ +import { defineConfig, lazyPlugins, normalizePath } from 'vite-plus'; + +import { pack, staged } from './settings.mjs'; + +console.log(`config NODE_ENV: ${process.env.NODE_ENV}`); + +export default defineConfig({ + // CLI metadata reads retain the command's root override, even when the file sets root. + root: 'app', + plugins: [ + lazyPlugins(() => { + throw new Error('metadata resolution must skip lazy plugin factories'); + }), + { + name: 'provide-tool-config', + apply(config) { + console.log(`apply root: ${normalizePath(String(config.root))}`); + return true; + }, + config: { + order: 'pre', + handler(config) { + console.log(`config root: ${normalizePath(String(config.root))}`); + return { staged, pack }; + }, + }, + async configResolved(config) { + console.log(`configResolved root: ${normalizePath(config.root)}`); + console.log(`configResolved configFile: ${config.configFile}`); + console.log('configResolved dependencies:'); + for (const dependency of config.configFileDependencies.map(normalizePath).sort()) { + console.log(` ${dependency}`); + } + }, + }, + ], +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/configured-root/src/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/configured-root/src/index.js new file mode 100644 index 0000000000..b6e9253f80 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/configured-root/src/index.js @@ -0,0 +1 @@ +export const source = 'configured Vite root source must not be packed'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/package.json new file mode 100644 index 0000000000..590f4ca302 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/package.json @@ -0,0 +1,6 @@ +{ + "name": "pack-shared-config-cwd", + "private": true, + "type": "module", + "workspaces": ["packages/*"] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/package.json new file mode 100644 index 0000000000..3d4d6d5acf --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/package.json @@ -0,0 +1,5 @@ +{ + "name": "shared-config-library", + "private": true, + "type": "module" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/src/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/src/index.js new file mode 100644 index 0000000000..89a643707d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/packages/lib/src/index.js @@ -0,0 +1 @@ +export const source = 'package source'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml new file mode 100644 index 0000000000..cb47bf3fe2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "pack_shared_config_preserves_package_cwd" +vp = "local" +cwd = "packages/lib" +steps = [ + { argv = ["vp", "pack", "--logLevel", "silent"], comment = "Load the parent config through traverseUp while plugin hooks and pack paths stay at the package cwd, overriding the file's root." }, + ["vpt", "print-file", "dist/index.mjs"], + ["vpt", "stat-file", "../../dist", "../../configured-root/dist"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md new file mode 100644 index 0000000000..fc61220783 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/snapshots/pack_shared_config_preserves_package_cwd.md @@ -0,0 +1,31 @@ +# pack_shared_config_preserves_package_cwd + +## `vp pack --logLevel silent` + +Load the parent config through traverseUp while plugin hooks and pack paths stay at the package cwd, overriding the file's root. + +``` +apply root: /packages/lib +apply cwd: /packages/lib +config root: /packages/lib +config cwd: /packages/lib +configResolved root: /packages/lib +configResolved cwd: /packages/lib +configResolved configFile: /vite.config.mjs +ℹ Build start +``` + +## `vpt print-file dist/index.mjs` + +``` +// src/index.js +const source = "package source"; +export { source }; +``` + +## `vpt stat-file ../../dist ../../configured-root/dist` + +``` +../../dist: missing +../../configured-root/dist: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/src/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/src/index.js new file mode 100644 index 0000000000..dd1e3f1cff --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/src/index.js @@ -0,0 +1 @@ +export const source = 'workspace source must not be packed'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs new file mode 100644 index 0000000000..e9c7a0370b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/pack_shared_config_cwd/vite.config.mjs @@ -0,0 +1,36 @@ +import { defineConfig, normalizePath } from 'vite-plus'; + +function printRoot(hook, config) { + console.log(`${hook} root: ${normalizePath(String(config.root))}`); + console.log(`${hook} cwd: ${normalizePath(process.cwd())}`); +} + +export default defineConfig({ + // The existing CLI contract overrides this with the package cwd when reading pack settings. + root: 'configured-root', + plugins: [ + { + name: 'print-shared-config-package-root', + apply(config) { + printRoot('apply', config); + return true; + }, + config: { + order: 'pre', + handler(config) { + printRoot('config', config); + }, + }, + configResolved(config) { + printRoot('configResolved', config); + console.log(`configResolved configFile: ${config.configFile}`); + }, + }, + ], + pack: { + entry: ['src/index.js'], + outDir: 'dist', + dts: false, + format: 'esm', + }, +}); diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 503c2f401e..c1839d8028 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -200,7 +200,7 @@ Object-valued nested npm and Bun overrides are preserved: they are user-defined `vite` and `vite/*` imports are rewritten to `vite-plus` **only in config entry files**: `vite.config.*`, `vitest.config.*`, and any config file the migration resolved. Every other file keeps its `vite` imports, for two reasons: -- `vite-plus` is not a guaranteed superset of Vite's exposed surface. It owns only `defineConfig`, `defineProject`, and `lazyPlugins`, so rewriting a pass-through symbol such as `createBuilder` or `loadConfigFromFile` (including in `typeof import('vite')` type positions) can break. +- `vite-plus` is not a guaranteed superset of Vite's exposed surface. It owns only `defineConfig`, `defineProject`, `lazyPlugins`, and `resolveConfigFromFile`, so rewriting a pass-through symbol such as `createBuilder` or `loadConfigFromFile` (including in `typeof import('vite')` type positions) can break. - An unrewritten `vite` import still resolves through the `@voidzero-dev/vite-plus-core` alias in a Vite+ project. Plugin packages (an unscoped name starting with `vite-plugin-` or `unplugin-`, or `vite` in `peerDependencies`/`dependencies`) skip the rewrite even in config files. Only the `vite` specifier is in scope for this rule. diff --git a/packages/cli/src/__tests__/resolve-config-from-file.spec.ts b/packages/cli/src/__tests__/resolve-config-from-file.spec.ts new file mode 100644 index 0000000000..3c0afc0860 --- /dev/null +++ b/packages/cli/src/__tests__/resolve-config-from-file.spec.ts @@ -0,0 +1,447 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import { mkdtempSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { normalizePath } from 'vite'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { resolveConfigFromFile } from '../index.js'; + +let tempDir: string; +const exec = promisify(execFile); + +beforeEach(() => { + // Resolve symlinks (macOS /var -> /private/var) to match path.resolve behavior + tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-from-file-test-'))); +}); + +afterEach(async () => { + // Vite removes its bundled config asynchronously. Allow transient Windows + // file locks to clear; persistent cleanup errors still fail the test. + await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +// Plugins read `root` in their `config` hook, as TanStack Start does to find +// its entries, so the fixture plugin records the value it sees there. +function writeConfig(dir: string, root?: string): string { + const configFile = path.join(dir, 'vite.config.mjs'); + const rootField = root === undefined ? '' : `root: ${JSON.stringify(root)},`; + fs.writeFileSync( + configFile, + `export default { + ${rootField} + plugins: [{ name: 'record-root', config: (config) => ({ seenRoot: config.root }) }], +}; +`, + ); + return configFile; +} + +// Vite stores the real path, which may differ from `tempDir` in casing or +// short names on Windows. +function realPath(file: string): string { + return fs.realpathSync.native(file); +} + +describe('resolveConfigFromFile', () => { + it('rejects an empty path instead of searching cwd', async () => { + await expect(resolveConfigFromFile('')).rejects.toThrow('Config file path must not be empty.'); + }); + + it.each(['unset', 'empty', 'relative', 'parent-relative', 'absolute'] as const)( + 'resolves a %s root from the config directory when rootBase is omitted', + async (kind) => { + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const root = { + unset: undefined, + empty: '', + relative: 'app', + 'parent-relative': '../app', + absolute: tempDir, + }[kind]; + const expectedRoot = path.resolve(configDir, root ?? ''); + fs.mkdirSync(expectedRoot, { recursive: true }); + const configFile = writeConfig(configDir, root); + const cwd = process.cwd(); + + const config = await resolveConfigFromFile(configFile); + + expect(realPath(config.root)).toBe(realPath(expectedRoot)); + expect(config).toHaveProperty('seenRoot', expectedRoot); + expect(config.inlineConfig.root).toBeUndefined(); + expect(process.cwd()).toBe(cwd); + }, + ); + + it.each([ + { name: 'undefined options', options: undefined }, + { name: 'empty options', options: {} }, + { name: 'undefined rootBase', options: { rootBase: undefined } }, + ])('uses the config directory with $name', async ({ options }) => { + const configFile = writeConfig(tempDir); + + const config = await resolveConfigFromFile(configFile, options); + + expect(realPath(config.root)).toBe(realPath(tempDir)); + expect(config).toHaveProperty('seenRoot', tempDir); + }); + + it('uses an explicit workspace base when the config is in a subdirectory', async () => { + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const configFile = writeConfig(configDir); + + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); + + expect(config).toHaveProperty('seenRoot', tempDir); + expect(realPath(config.root)).toBe(realPath(tempDir)); + }); + + it('resolves a relative root from the workspace base instead of the config directory', async () => { + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const configFile = writeConfig(configDir, 'app'); + fs.mkdirSync(path.join(tempDir, 'app')); + + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); + + expect(config).toHaveProperty('seenRoot', path.join(tempDir, 'app')); + expect(realPath(config.root)).toBe(realPath(path.join(tempDir, 'app'))); + }); + + it('preserves an absolute config root when a workspace base is supplied', async () => { + const root = path.join(tempDir, 'app'); + const rootBase = path.join(tempDir, 'workspace'); + fs.mkdirSync(root); + fs.mkdirSync(rootBase); + const configFile = writeConfig(tempDir, root); + + const config = await resolveConfigFromFile(configFile, { rootBase }); + + expect(config).toHaveProperty('seenRoot', root); + expect(realPath(config.root)).toBe(realPath(root)); + }); + + it('keeps the config file and its dependencies', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync(path.join(tempDir, 'lint.mjs'), 'export const lint = { rules: {} };\n'); + fs.writeFileSync(configFile, "import { lint } from './lint.mjs';\nexport default { lint };\n"); + + const config = await resolveConfigFromFile(configFile); + + expect(config.lint).toEqual({ rules: {} }); + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config.configFileDependencies.map(realPath)).toContain( + realPath(path.join(tempDir, 'lint.mjs')), + ); + }); + + it.each(['default', 'explicit'])( + 'provides the %s base root before plugin filtering and early config hooks', + async (base) => { + const configDir = path.join(tempDir, 'config'); + fs.mkdirSync(configDir); + const configFile = path.join(configDir, 'vite.config.mjs'); + const root = path.join(base === 'default' ? configDir : tempDir, 'app'); + fs.mkdirSync(root); + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; +export default { + root: 'app', + plugins: [{ + name: 'check-early-root', + apply(config) { + assert.equal(config.root, ${JSON.stringify(root)}); + return true; + }, + config: { + order: 'pre', + handler(config) { + assert.equal(config.root, ${JSON.stringify(root)}); + return { lint: { rules: { 'no-console': 'error' } } }; + }, + }, + }], +};\n`, + ); + const cwd = process.cwd(); + + const config = await resolveConfigFromFile( + configFile, + base === 'default' ? undefined : { rootBase: tempDir }, + ); + + expect(config.lint).toEqual({ rules: { 'no-console': 'error' } }); + expect(process.cwd()).toBe(cwd); + }, + ); + + it('provides file metadata to sync and async hooks and retains added dependencies', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + const dependency = path.join(tempDir, 'lint.mjs'); + const pluginDependency = path.join(tempDir, 'plugin-settings.json'); + fs.writeFileSync(dependency, 'export const lint = { rules: {} };\n'); + fs.writeFileSync(pluginDependency, '{}'); + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; +import { lint } from './lint.mjs'; +function checkMetadata(config) { + assert.equal(config.configFile, ${JSON.stringify(normalizePath(configFile))}); + assert.ok(config.configFileDependencies.map((file) => realpathSync.native(file)).includes( + realpathSync.native(${JSON.stringify(dependency)}), + )); + assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, '1'); +} +export default { + lint, + plugins: [{ + name: 'check-sync-metadata', + configResolved: { + order: 'pre', + handler(config) { + checkMetadata(config); + config.configFileDependencies.push(${JSON.stringify(normalizePath(pluginDependency))}); + }, + }, + }, { + name: 'check-async-metadata', + async configResolved(config) { + await Promise.resolve(); + checkMetadata(config); + }, + }], +};\n`, + ); + + const config = await resolveConfigFromFile(configFile); + + expect(config.configFileDependencies.map(realPath)).toContain(realPath(pluginDependency)); + }); + + it('preserves Vite config environments and keeps the file config out of inlineConfig', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + `export default (env) => ({ + mode: 'custom', + build: { ssr: true }, + functionEnv: { ...env }, + lint: { rules: {} }, + plugins: [{ + name: 'record-environment', + config(_config, hookEnv) { return { hookEnv: { ...hookEnv } }; }, + }], +});\n`, + ); + + const config = await resolveConfigFromFile(configFile, { rootBase: tempDir }); + + expect(config).toHaveProperty('functionEnv', { + command: 'build', + mode: 'development', + isSsrBuild: false, + isPreview: false, + }); + expect(config).toHaveProperty('hookEnv', { + command: 'build', + mode: 'custom', + isSsrBuild: false, + isPreview: false, + }); + expect(config.mode).toBe('custom'); + expect(config.inlineConfig.configFile).toBe(configFile); + expect(config.inlineConfig.root).toBeUndefined(); + expect(config.inlineConfig.lint).toBeUndefined(); + expect(config.inlineConfig.build?.ssr).toBeUndefined(); + }); + + it.each(['object', 'function', 'async function'])( + 'evaluates a %s config only once', + async (kind) => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + const evaluationsFile = path.join(tempDir, 'evaluations.log'); + let exportValue = '{ lint: { rules: {} } }'; + if (kind !== 'object') { + const asyncKeyword = kind === 'async function' ? 'async ' : ''; + exportValue = `${asyncKeyword}() => { + appendFileSync(${JSON.stringify(evaluationsFile)}, 'function\\n'); + return { lint: { rules: {} } }; +}`; + } + fs.writeFileSync( + configFile, + `import { appendFileSync } from 'node:fs'; +appendFileSync(${JSON.stringify(evaluationsFile)}, 'module\\n'); +export default ${exportValue};\n`, + ); + + await resolveConfigFromFile(configFile); + + expect(fs.readFileSync(evaluationsFile, 'utf8')).toBe( + kind === 'object' ? 'module\n' : 'module\nfunction\n', + ); + }, + ); + + it.each([ + { name: 'unset', nodeEnv: undefined, taskNodeEnv: undefined, expected: 'development' }, + { name: 'explicit', nodeEnv: 'production', taskNodeEnv: undefined, expected: 'production' }, + { + name: 'provided by Vite Task', + nodeEnv: undefined, + taskNodeEnv: 'production', + expected: 'production', + }, + ])( + 'initializes NODE_ENV before evaluation when $name', + async ({ nodeEnv, taskNodeEnv, expected }) => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync(configFile, 'export default () => ({ nodeEnv: process.env.NODE_ENV });\n'); + const env = { ...process.env }; + delete env.NODE_ENV; + delete env.VP_RUN_NODE_CLIENT_PATH; + if (nodeEnv !== undefined) { + env.NODE_ENV = nodeEnv; + } + if (taskNodeEnv !== undefined) { + const clientFile = path.join(tempDir, 'task-client.cjs'); + fs.writeFileSync( + clientFile, + `exports.load = () => ({ + getEnv: (name) => name === 'NODE_ENV' ? ${JSON.stringify(taskNodeEnv)} : undefined, + getEnvs: () => ({}), + ignoreInput() {}, + ignoreOutput() {}, +});\n`, + ); + env.VP_RUN_NODE_CLIENT_PATH = clientFile; + } + + // Start outside Vitest's NODE_ENV=test and cached Vite Task client state. + await exec( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; +import { resolveConfigFromFile } from ${JSON.stringify(new URL('../define-config.ts', import.meta.url).href)}; +const first = await resolveConfigFromFile(${JSON.stringify(configFile)}); +const second = await resolveConfigFromFile(${JSON.stringify(configFile)}); +assert.equal(first.nodeEnv, ${JSON.stringify(expected)}); +assert.equal(second.nodeEnv, ${JSON.stringify(expected)});`, + ], + { env, cwd: tempDir }, + ); + }, + ); + + it('evaluates the config as a config-metadata resolution', async () => { + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + 'export default () => ({ metadata: process.env.VP_RESOLVING_CONFIG_METADATA });\n', + ); + + const config = await resolveConfigFromFile(configFile); + + expect(config).toHaveProperty('metadata', '1'); + }); + + it('restores the metadata marker when a plugin rejects', async () => { + const originalMetadata = process.env.VP_RESOLVING_CONFIG_METADATA; + const configFile = path.join(tempDir, 'vite.config.mjs'); + fs.writeFileSync( + configFile, + `export default { + plugins: [{ name: 'reject-config', configResolved() { throw new Error('plugin rejected'); } }], +};\n`, + ); + + await expect(resolveConfigFromFile(configFile)).rejects.toThrow('plugin rejected'); + + expect(process.env.VP_RESOLVING_CONFIG_METADATA).toBe(originalMetadata); + }); + + it.each(['import', 'require'])('resolves through the built %s entry', async (entryType) => { + const launcher = path.join(tempDir, 'launcher'); + const workspace = path.join(tempDir, 'workspace'); + const configDir = path.join(workspace, 'config'); + const configFile = path.join(configDir, 'vite.config.mjs'); + const configRoot = path.join(configDir, 'app'); + const workspaceRoot = path.join(workspace, 'app'); + const launcherRoot = path.join(launcher, 'app'); + fs.mkdirSync(configRoot, { recursive: true }); + fs.mkdirSync(launcherRoot, { recursive: true }); + fs.mkdirSync(workspaceRoot); + const esmEntry = new URL('../../dist/index.js', import.meta.url); + const entry = new URL( + entryType === 'import' ? '../../dist/index.js' : '../../dist/index.cjs', + import.meta.url, + ); + const loadApi = + entryType === 'import' + ? `await import(${JSON.stringify(entry.href)})` + : `createRequire(import.meta.url)(${JSON.stringify(fileURLToPath(entry))})`; + fs.writeFileSync( + configFile, + `import assert from 'node:assert/strict'; +import { defineConfig, lazyPlugins } from ${JSON.stringify(normalizePath(fileURLToPath(esmEntry)))}; +export default defineConfig({ + root: 'app', + lint: { rules: {} }, + plugins: [ + lazyPlugins(() => { throw new Error('lazy plugin factory must not run'); }), + { + name: 'check-published-helper', + config(config) { return { seenRoot: config.root }; }, + configResolved(config) { + assert.equal(config.configFile, ${JSON.stringify(normalizePath(configFile))}); + assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, '1'); + }, + }, + ], +});\n`, + ); + + await exec( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +const api = ${loadApi}; +const marker = process.env.VP_RESOLVING_CONFIG_METADATA; +const cwd = process.cwd(); +const configFile = ${JSON.stringify(path.relative(launcher, configFile))}; +const defaults = await api.resolveConfigFromFile(configFile); +assert.equal(realpathSync.native(defaults.root), realpathSync.native(${JSON.stringify(configRoot)})); +assert.equal(defaults.seenRoot, ${JSON.stringify(configRoot)}); +assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker); +const config = await api.resolveConfigFromFile(configFile, { rootBase: ${JSON.stringify(path.relative(launcher, workspace))} }); +assert.equal(realpathSync.native(config.root), realpathSync.native(${JSON.stringify(workspaceRoot)})); +assert.equal(config.seenRoot, ${JSON.stringify(workspaceRoot)}); +assert.deepEqual(config.lint, { rules: {} }); +assert.equal(config.inlineConfig.root, undefined); +for (const rootBase of [cwd, '']) { + const fromCwd = await api.resolveConfigFromFile(configFile, { rootBase }); + assert.equal(realpathSync.native(fromCwd.root), realpathSync.native(${JSON.stringify(launcherRoot)})); + assert.equal(fromCwd.seenRoot, ${JSON.stringify(launcherRoot)}); +} +assert.equal(process.cwd(), cwd); +assert.equal(process.env.VP_RESOLVING_CONFIG_METADATA, marker);`, + ], + { cwd: launcher }, + ); + }); +}); diff --git a/packages/cli/src/__tests__/resolve-vite-config.spec.ts b/packages/cli/src/__tests__/resolve-vite-config.spec.ts index 9dc4b89427..11ed35ee35 100644 --- a/packages/cli/src/__tests__/resolve-vite-config.spec.ts +++ b/packages/cli/src/__tests__/resolve-vite-config.spec.ts @@ -1,24 +1,28 @@ import fs from 'node:fs'; import { mkdtempSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { normalizePath } from 'vite'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { findViteConfigUp } from '../resolve-vite-config.js'; +import { findViteConfigUp, resolveViteConfig } from '../resolve-vite-config.js'; -describe('findViteConfigUp', () => { - let tempDir: string; +let tempDir: string; - beforeEach(() => { - // Resolve symlinks (macOS /var -> /private/var) to match path.resolve behavior - tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-test-'))); - }); +beforeEach(() => { + // Resolve symlinks (macOS /var -> /private/var) to match path.resolve behavior + tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-test-'))); +}); - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); +afterEach(async () => { + // Vite removes its bundled config asynchronously. Allow transient Windows + // file locks to clear; persistent cleanup errors still fail the test. + await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); +describe('findViteConfigUp', () => { it('should find config in the start directory', () => { fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), ''); const result = findViteConfigUp(tempDir, tempDir); @@ -91,3 +95,79 @@ describe('findViteConfigUp', () => { expect(result).toBe(path.join(tempDir, `vite.config.${extension}`)); }); }); + +// Plugins read `root` in their `config` hook, as TanStack Start does to find +// its entries, so the fixture plugin records the value it sees there. +function writeConfig(dir: string, root?: string): string { + const configFile = path.join(dir, 'vite.config.mjs'); + const rootField = root === undefined ? '' : `root: ${JSON.stringify(root)},`; + fs.writeFileSync( + configFile, + `export default { + ${rootField} + plugins: [{ name: 'record-root', config: (config) => ({ seenRoot: config.root }) }], +}; +`, + ); + return configFile; +} + +// Vite stores the real path, which may differ from `tempDir` in casing or +// short names on Windows. +function realPath(file: string): string { + return fs.realpathSync.native(file); +} + +describe('resolveViteConfig', () => { + it.each(['unset', 'relative', 'absolute'] as const)( + 'uses the caller root when the local config root is %s', + async (kind) => { + const root = { + unset: undefined, + relative: 'app', + absolute: path.join(tempDir, 'app'), + }[kind]; + const configFile = writeConfig(tempDir, root); + + const config = await resolveViteConfig(tempDir); + + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config).toHaveProperty('seenRoot', tempDir); + expect(realPath(config.root)).toBe(realPath(tempDir)); + expect(config.inlineConfig.root).toBe(tempDir); + }, + ); + + it.each(['unset', 'relative', 'absolute'] as const)( + 'uses the package root when traverseUp finds a config with root %s', + async (kind) => { + fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); + fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"fixture"}'); + const root = { + unset: undefined, + relative: 'app', + absolute: path.join(tempDir, 'app'), + }[kind]; + const configFile = writeConfig(tempDir, root); + const packageDir = path.join(tempDir, 'packages', 'lib'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, 'package.json'), '{"name":"lib"}'); + + const config = await resolveViteConfig(packageDir, { traverseUp: true }); + + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config).toHaveProperty('seenRoot', packageDir); + expect(realPath(config.root)).toBe(realPath(packageDir)); + expect(config.inlineConfig.root).toBe(packageDir); + }, + ); + + it('resolves without a config file', async () => { + const config = await resolveViteConfig(tempDir); + + expect(config.configFile).toBeUndefined(); + expect(config.lint).toBeUndefined(); + expect(realPath(config.root)).toBe(realPath(tempDir)); + expect(config.inlineConfig.root).toBe(tempDir); + }); +}); diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 930c2ca9e4..8ecf080232 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1,13 +1,20 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ignoreInput, ignoreOutput } from '@voidzero-dev/vite-task-client'; import type { OxfmtConfig } from 'oxfmt'; import type { OxlintConfig } from 'oxlint'; -import { searchForWorkspaceRoot } from 'vite'; -import type { Alias, Plugin, PluginOption, UserConfig } from 'vite'; +import { resolveConfig, searchForWorkspaceRoot } from 'vite'; +import type { + Alias, + InlineConfig as ViteInlineConfig, + Plugin, + PluginOption, + ResolvedConfig, + UserConfig, +} from 'vite'; import { defineConfig as viteDefineConfig, defineProject as viteDefineProject, @@ -1049,6 +1056,71 @@ export async function withConfigMetadataResolution(fn: () => Promise): Pro } } +// The bundled core's internal parameters are not part of its public type +// contract. Keep this bridge private to the helper. +type ResolveConfigWithInternalOptions = ( + inlineConfig: ViteInlineConfig, + command: 'build' | 'serve', + defaultMode?: string, + defaultNodeEnv?: string, + isPreview?: boolean, + patchConfig?: (config: ResolvedConfig) => void, + patchPlugins?: (plugins: Plugin[]) => void, + internalOptions?: { rootBase?: string }, +) => Promise; + +const resolveConfigWithInternalOptions = resolveConfig as ResolveConfigWithInternalOptions; + +export interface ResolveConfigFromFileOptions { + /** + * Base directory for an unset or relative `root` in the config file. + * Defaults to the config file's parent directory. + * Relative paths resolve from `process.cwd()`. Does not change the process cwd. + */ + rootBase?: string; +} + +/** + * Resolve a Vite config file to read a non-plugin block (`lint`, `fmt`, `pack`, …) from it, + * for tools such as oxlint, oxfmt, and Vite+ itself. + * + * With an absolute `configFile`, default resolution of the initial `root` from the + * config file is independent of `process.cwd()`: an unset or relative `root` + * resolves from the config file's parent directory. + * Callers can supply `rootBase` to use a different project directory, + * such as when the config is in a subdirectory. Absolute roots are preserved. + * Plugin hooks can later change `root` using Vite's normal semantics. For example, + * a relative `root` returned by a `config` hook resolves from `process.cwd()`. + * + * Relative `configFile` and explicit `rootBase` paths still resolve from `process.cwd()`. + * The helper does not change the process cwd; config code and plugins can still read it. + * The resolver loads the config once, as a config-metadata resolution, + * so `lazyPlugins` skips its factory. + * Plugins outside `lazyPlugins` still run their hooks and side effects, + * e.g. TanStack Start's router generator scans `src/routes`. + */ +export async function resolveConfigFromFile( + configFile: string, + options: ResolveConfigFromFileOptions = {}, +): Promise { + if (configFile.length === 0) { + throw new Error('Config file path must not be empty.'); + } + const rootBase = resolve(options.rootBase ?? dirname(resolve(configFile))); + return withConfigMetadataResolution(() => + resolveConfigWithInternalOptions( + { configFile }, + 'build', + undefined, + undefined, + undefined, + undefined, + undefined, + { rootBase }, + ), + ); +} + export function lazyPlugins(cb: () => PluginOption[]): PluginOption[] | undefined; export function lazyPlugins(cb: () => Promise): PluginOption[] | undefined; export function lazyPlugins( diff --git a/packages/cli/src/index.cts b/packages/cli/src/index.cts index 49a1821ca8..bb154a1da0 100644 --- a/packages/cli/src/index.cts +++ b/packages/cli/src/index.cts @@ -8,7 +8,12 @@ const { defaultInclude, } = require('vitest/config'); -const { defineConfig, defineProject, lazyPlugins } = require('./define-config'); +const { + defineConfig, + defineProject, + lazyPlugins, + resolveConfigFromFile, +} = require('./define-config'); module.exports = { ...vite, @@ -20,4 +25,5 @@ module.exports = { defineProject, defineConfig, lazyPlugins, + resolveConfigFromFile, }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2c4b0237f7..d8f7b7fff8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,10 @@ -import { defineConfig, defineProject, lazyPlugins } from './define-config.ts'; +export { + defineConfig, + defineProject, + lazyPlugins, + resolveConfigFromFile, +} from './define-config.ts'; +export type { ResolveConfigFromFileOptions } from './define-config.ts'; export * from 'vite'; @@ -25,5 +31,3 @@ export type { ViteUserConfigFnPromise, WatcherTriggerPattern, } from 'vitest/config'; - -export { defineConfig, defineProject, lazyPlugins }; diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md index 6a024b268b..e946fb9f3a 100644 --- a/packages/core/BUNDLING.md +++ b/packages/core/BUNDLING.md @@ -19,6 +19,20 @@ This approach enables users to access Vite, Rolldown, and related tools through ## Build Steps +### Vite Source Preparation + +Before core bundling, [`brand-vite.ts`](../tools/src/brand-vite.ts) applies branding and the [`patch-vite-config.ts`](../tools/src/patch-vite-config.ts) resolver patch to the vendored Vite source. `sync-remote` runs this preparation. [CI](../../.github/actions/build-upstream/action.yml) also applies it after checking out upstream sources. + +Regular `pnpm build` runs use the prepared sources. If the patch code changes after synchronization, run `pnpm exec tool brand-vite` once, then `pnpm build` to regenerate Vite types and rebuild the packages. + +The resolver patch adds an optional internal eighth argument to `resolveConfig`. The `resolveConfigFromFile(configFile, options?)` helper exported by `vite-plus` passes a base directory through a private type bridge. By default, this base is the config file's parent directory. With an absolute `configFile`, default resolution of the initial `root` from the config file is independent of `process.cwd()`. Callers can pass `{ rootBase: projectRoot }` to use a different project directory, including when the config is in a subdirectory. Relative `configFile` and explicit `rootBase` paths resolve from `process.cwd()`; the helper does not change the process cwd. + +After a config file loads, an unset or relative `root` resolves from the selected base, before plugin `apply` and `config` hooks. Absolute roots are preserved, and an inline `root` retains priority. Plugin hooks can later change `root` using Vite's normal semantics. For example, a relative `root` returned by a `config` hook resolves from `process.cwd()`. Vite still handles config loading, environment initialization, configuration metadata, and plugin hooks. + +The helper's default does not change ordinary `resolveConfig` calls, which retain Vite's cwd-based root resolution. Existing CLI callers keep their inline `root: cwd`, including when `vp pack` finds a parent config. Adopting the new helper is a separate integration choice for callers such as Oxlint and Oxfmt; it is not restricted to LSP. + +[`build.ts`](build.ts) calls `assertViteConfigPatched()` before any bundling starts. It rejects missing, changed, duplicate, or partially applied patch anchors. When the Vite pin changes, review the patch against the new source. The guard validates `src/node/config.ts`; it does not check whether generated type declarations are current. + The build process executes 5 steps in sequence: ### Step 1: Bundle Rolldown Pluginutils (`bundleRolldownPluginutils`) @@ -299,8 +313,8 @@ dist/ ### Updating Vite -1. Update the `vite` git submodule to the new version -2. Run `pnpm -C packages/core build` +1. Update the Vite pin in [`packages/tools/.upstream-versions.json`](../tools/.upstream-versions.json) and review the resolver patch against the new source +2. Follow the [upstream sync and build instructions](../../CONTRIBUTING.md#pull-upstream-dependencies) to prepare the source, regenerate Vite types, and rebuild the packages 3. Verify `bundledVersions.vite` in `package.json` is updated 4. Test with `pnpm test` @@ -324,6 +338,8 @@ dist/ ## Build Commands +These direct core builds require [prepared Vite sources](#vite-source-preparation) and current Vite types. The [standard repository build](../../CONTRIBUTING.md#build-vite-and-upstream-dependencies) regenerates Vite types before building core. For a direct core build after source changes, run `pnpm --filter vite build-types` first. + ```bash # Build the core package pnpm -C packages/core build @@ -339,6 +355,7 @@ RELEASE_BUILD=1 pnpm -C packages/core build ### Build Flow ``` + assertViteConfigPatched() Validate prepared Vite source before bundling 1. bundleRolldownPluginutils() Copy pre-built dist 2. bundleRolldown() Copy + rewrite module specifiers 3. buildVite() Full Rolldown build with transforms diff --git a/packages/core/__tests__/resolve-config-root.spec.ts b/packages/core/__tests__/resolve-config-root.spec.ts new file mode 100644 index 0000000000..9d18dcab41 --- /dev/null +++ b/packages/core/__tests__/resolve-config-root.spec.ts @@ -0,0 +1,173 @@ +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; + +// Import the built core directly: a source-only test cannot detect a missing +// branding patch or a CLI running against an older core artifact. +import { normalizePath, resolveConfig } from '../dist/vite/node/index.js'; +import type { InlineConfig, ResolvedConfig } from '../dist/vite/node/index.js'; + +const resolveWithOptions = resolveConfig as ( + config: InlineConfig, + command: 'build' | 'serve', + defaultMode: undefined, + defaultNodeEnv: undefined, + isPreview: undefined, + patchConfig: undefined, + patchPlugins: undefined, + internalOptions?: { rootBase?: string }, +) => Promise; + +function resolve(config: InlineConfig, rootBase?: string): Promise { + return resolveWithOptions( + config, + 'build', + undefined, + undefined, + undefined, + undefined, + undefined, + rootBase === undefined ? undefined : { rootBase }, + ); +} + +let tempDir: string; +let configDir: string; + +beforeEach(() => { + tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), 'vite-core-config-root-'))); + configDir = path.join(tempDir, 'project'); + mkdirSync(path.join(configDir, 'app'), { recursive: true }); +}); + +afterEach(async () => { + // Config bundles can remain open briefly on Windows. + await rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function expectedRoot(root: string): string { + try { + return normalizePath(realpathSync.native(root)); + } catch { + // Vite also permits a root directory that does not exist yet. + return normalizePath(root); + } +} + +function writeConfig(config: Record, dir = configDir): string { + const file = path.join(dir, 'vite.config.mjs'); + writeFileSync(file, `export default ${JSON.stringify(config)};\n`); + return file; +} + +describe('built core config root resolution', () => { + it('preserves normal Vite root resolution when rootBase is omitted', async () => { + const cwd = process.cwd(); + for (const root of [undefined, '', 'app']) { + const configFile = writeConfig({ root }); + const normal = await resolveConfig({ configFile }, 'build'); + const config = await resolve({ configFile }); + expect(config.root).toBe(normal.root); + expect(config.root).toBe(expectedRoot(path.resolve(cwd, root ?? ''))); + } + expect(process.cwd()).toBe(cwd); + }); + + it.each([undefined, '', '.', '..', 'app'])( + 'resolves file root %s from the supplied config directory base', + async (root) => { + const cwd = process.cwd(); + const configFile = writeConfig({ root }); + const config = await resolve({ configFile }, configDir); + + expect(config.root).toBe(expectedRoot(path.resolve(configDir, root ?? ''))); + expect(config.configFile).toBe(normalizePath(configFile)); + expect(process.cwd()).toBe(cwd); + }, + ); + + it.each([undefined, 'app'])( + 'uses an independent workspace base for root %s in a nested config file', + async (root) => { + const nestedDir = path.join(configDir, 'configs', 'nested'); + const workspaceBase = path.join(tempDir, 'workspace'); + mkdirSync(nestedDir, { recursive: true }); + mkdirSync(path.join(workspaceBase, 'app'), { recursive: true }); + const configFile = writeConfig({ root }, nestedDir); + const config = await resolve({ configFile }, workspaceBase); + + expect(config.root).toBe(expectedRoot(path.resolve(workspaceBase, root ?? ''))); + expect(config.configFile).toBe(normalizePath(configFile)); + }, + ); + + it('preserves an absolute root from the config file', async () => { + const root = path.join(tempDir, 'absolute'); + mkdirSync(root); + const config = await resolve({ configFile: writeConfig({ root }) }, configDir); + + expect(config.root).toBe(expectedRoot(root)); + }); + + it.each(['', 'inline-app', 'absolute'])( + 'keeps explicit inline root precedence and cwd semantics for %s', + async (value) => { + const root = value === 'absolute' ? tempDir : value; + const configFile = writeConfig({ root: 'app' }); + const config = await resolve({ configFile, root }, configDir); + + expect(config.root).toBe(expectedRoot(path.resolve(root))); + }, + ); + + it('leaves no-file resolution unchanged', async () => { + const config = await resolve({ root: tempDir }, configDir); + + expect(config.configFile).toBeUndefined(); + expect(config.root).toBe(expectedRoot(tempDir)); + }); + + it('does not load a file when configFile is false', async () => { + writeFileSync(path.join(configDir, 'vite.config.mjs'), "throw new Error('must not load');\n"); + for (const root of [undefined, configDir]) { + const config = await resolve({ configFile: false, root }, configDir); + + expect(config.configFile).toBeUndefined(); + expect(config.root).toBe(expectedRoot(root ?? process.cwd())); + } + }); + + it('honors a root supplied by a config hook after file root resolution', async () => { + const configFile = writeConfig({ root: 'app' }); + const config = await resolve( + { + configFile, + plugins: [{ name: 'change-root', config: () => ({ root: 'plugin-app' }) }], + }, + configDir, + ); + + expect(config.root).toBe(expectedRoot(path.resolve('plugin-app'))); + }); + + it.each([false, true])( + 'respects preserveSymlinks=%s for a config loaded through a directory link', + async (preserveSymlinks) => { + writeConfig({ root: 'app', resolve: { preserveSymlinks } }); + const link = path.join(tempDir, 'linked-project'); + symlinkSync(configDir, link, process.platform === 'win32' ? 'junction' : 'dir'); + const configFile = path.join(link, 'vite.config.mjs'); + const config = await resolve({ configFile }, link); + + expect(config.configFile).toBe(normalizePath(configFile)); + expect(config.root).toBe( + preserveSymlinks + ? normalizePath(path.join(link, 'app')) + : expectedRoot(path.join(configDir, 'app')), + ); + }, + ); +}); diff --git a/packages/core/build.ts b/packages/core/build.ts index 203addbd8d..c3bf11b33a 100644 --- a/packages/core/build.ts +++ b/packages/core/build.ts @@ -19,6 +19,7 @@ import { glob } from 'tinyglobby'; import { generateLicenseFile } from '../../scripts/generate-license.js'; import viteRolldownConfig from '../../vite/packages/vite/rolldown.config.js'; import cliPkgJson from '../cli/package.json' with { type: 'json' }; +import { assertViteConfigPatched } from '../tools/src/patch-vite-config.ts'; import { buildCjsDeps } from './build-support/build-cjs-deps.js'; import { replaceThirdPartyCjsRequires } from './build-support/find-create-require.js'; import { RewriteImportsPlugin } from './build-support/rewrite-imports.js'; @@ -52,6 +53,17 @@ const rolldownViteSourceDir = resolve(projectDir, '..', '..', 'vite', 'packages' const tsdownSourceDir = resolve(projectDir, 'node_modules/tsdown'); // Main build orchestration +try { + assertViteConfigPatched( + await readFile(join(rolldownViteSourceDir, 'src/node/config.ts'), 'utf-8'), + ); +} catch (cause) { + throw new Error( + 'Prepare the Vite source before building core. From the repository root, run ' + + '`pnpm exec tool brand-vite` and `pnpm --filter vite build-types`.', + { cause }, + ); +} await bundleRolldownPluginutils(); await bundleRolldown(); await buildVite(); diff --git a/packages/tools/src/__tests__/brand-vite.spec.ts b/packages/tools/src/__tests__/brand-vite.spec.ts index 1364658cad..d6833c7af0 100644 --- a/packages/tools/src/__tests__/brand-vite.spec.ts +++ b/packages/tools/src/__tests__/brand-vite.spec.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import { expect, test } from 'vitest'; import { brandVite } from '../brand-vite.ts'; +import { assertViteConfigPatched } from '../patch-vite-config.ts'; import { alignVendoredVitestDependencies } from '../vendored-vitest.ts'; const upstreamSources = { @@ -37,6 +38,23 @@ const upstreamSources = { '', ].join('\n'), 'config.ts': [ + 'export async function resolveConfig(', + ' inlineConfig: InlineConfig,', + " command: 'build' | 'serve',", + " defaultMode = 'development',", + " defaultNodeEnv = 'development',", + ' isPreview = false,', + ' /** @internal */', + ' patchConfig?: (config: ResolvedConfig) => void,', + ' /** @internal */', + ' patchPlugins?: (resolvedPlugins: Plugin[]) => void,', + '): Promise {', + ' if (loadResult) {', + ' config = mergeConfig(loadResult.config, config)', + ' configFile = loadResult.path', + ' configFileDependencies = loadResult.dependencies', + ' }', + '}', ' !process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING &&', ' createNativeConfigCompatPlugin(nativeIncompatibilities),', '', @@ -48,37 +66,48 @@ function write(file: string, source: string) { writeFileSync(file, source); } -test('branding preserves aligned dependencies and unrelated changes across repeated builds', () => { +function createUpstreamFixture(configSource = upstreamSources['config.ts']): { + root: string; + nodeDir: string; + manifest: string; + lockfile: string; + unrelatedSource: string; +} { const root = mkdtempSync(join(tmpdir(), 'vp-brand-vite-')); const viteDir = join(root, 'vite'); const nodeDir = join(viteDir, 'packages/vite/src/node'); const git = (...args: string[]) => execFileSync('git', args, { cwd: viteDir, stdio: 'pipe' }); - try { - for (const [file, source] of Object.entries(upstreamSources)) { - write(join(nodeDir, file), source); - } - const manifest = join(viteDir, 'packages/vite/package.json'); - const lockfile = join(viteDir, 'pnpm-lock.yaml'); - const unrelatedSource = join(nodeDir, 'server.ts'); - write(manifest, '{"devDependencies":{"@vitest/utils":"4.1.10"}}\n'); - write(lockfile, '# upstream lockfile\n'); - write(unrelatedSource, '// upstream source\n'); - git('init'); - git('config', 'core.autocrlf', 'false'); - git('config', 'core.hooksPath', join(root, 'no-hooks')); - git('add', '.'); - git( - '-c', - 'user.name=Test', - '-c', - 'user.email=test@example.com', - '-c', - 'commit.gpgsign=false', - 'commit', - '-m', - 'upstream fixture', - ); + for (const [file, source] of Object.entries(upstreamSources)) { + write(join(nodeDir, file), file === 'config.ts' ? configSource : source); + } + const manifest = join(viteDir, 'packages/vite/package.json'); + const lockfile = join(viteDir, 'pnpm-lock.yaml'); + const unrelatedSource = join(nodeDir, 'server.ts'); + write(manifest, '{"devDependencies":{"@vitest/utils":"4.1.10"}}\n'); + write(lockfile, '# upstream lockfile\n'); + write(unrelatedSource, '// upstream source\n'); + git('init'); + git('config', 'core.autocrlf', 'false'); + git('config', 'core.hooksPath', join(root, 'no-hooks')); + git('add', '.'); + git( + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + '-c', + 'commit.gpgsign=false', + 'commit', + '-m', + 'upstream fixture', + ); + return { root, nodeDir, manifest, lockfile, unrelatedSource }; +} + +test('branding preserves aligned dependencies and unrelated changes across repeated builds', () => { + const { root, nodeDir, manifest, lockfile, unrelatedSource } = createUpstreamFixture(); + try { alignVendoredVitestDependencies(root, '5.0.0'); const alignedManifest = readFileSync(manifest, 'utf8'); expect(JSON.parse(alignedManifest).devDependencies['@vitest/utils']).toBe('5.0.0'); @@ -94,6 +123,10 @@ test('branding preserves aligned dependencies and unrelated changes across repea expect(readFileSync(unrelatedSource, 'utf8')).toBe('// unrelated local source change\n'); expect(readFileSync(join(nodeDir, 'cli.ts'), 'utf8')).toContain("cac('vp')"); expect(readFileSync(join(nodeDir, 'build.ts'), 'utf8')).not.toContain('logger.info('); + const configSource = readFileSync(join(nodeDir, 'config.ts'), 'utf8'); + expect(() => assertViteConfigPatched(configSource)).not.toThrow(); + expect(configSource).toContain('false &&\n createNativeConfigCompatPlugin'); + expect(configSource).not.toContain('!process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING'); const sources = Object.entries(upstreamSources).map(([file, upstream]) => { const source = readFileSync(join(nodeDir, file), 'utf8'); expect(source).not.toBe(upstream); @@ -108,3 +141,14 @@ test('branding preserves aligned dependencies and unrelated changes across repea rmSync(root, { recursive: true, force: true }); } }); + +test('branding validates both config patches before writing config.ts', () => { + const source = upstreamSources['config.ts'].replace('defaultNodeEnv', 'changedUpstreamParameter'); + const { root, nodeDir } = createUpstreamFixture(source); + try { + expect(() => brandVite(root)).toThrow(/changed, duplicate, or partial patch anchors/); + expect(readFileSync(join(nodeDir, 'config.ts'), 'utf8')).toBe(source); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/tools/src/brand-vite.ts b/packages/tools/src/brand-vite.ts index 228dbf6069..c06229c141 100644 --- a/packages/tools/src/brand-vite.ts +++ b/packages/tools/src/brand-vite.ts @@ -1,5 +1,5 @@ /** - * Apply Vite+ branding patches to vite source after sync. + * Apply Vite+ branding and config-resolution patches to vite source after sync. * * This script modifies user-visible branding strings in the vite * source to show "VITE+" instead of "VITE". It is called automatically @@ -11,12 +11,15 @@ * 3. build.ts: Remove startup build banner and change error prefix * 4. logger.ts: Change default prefix from '[vite]' to '[vite+]' * 5. plugins/reporter.ts: Suppress redundant "vite v" native reporter line + * 6. config.ts: Suppress the native-loader notice and support an explicit root base */ import { execFileSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { patchViteConfig } from './patch-vite-config.ts'; + const VITE_DIR = 'vite'; const VITE_NODE_DIR = join(VITE_DIR, 'packages', 'vite', 'src', 'node'); @@ -36,15 +39,27 @@ function replaceInFile( replacement: string, ): 'patched' | 'already' { const content = readFileSync(filePath, 'utf-8'); + const newContent = replaceInSource(filePath, content, search, replacement); + if (content === newContent) { + return 'already'; + } + writeFileSync(filePath, newContent, 'utf-8'); + return 'patched'; +} + +function replaceInSource( + filePath: string, + content: string, + search: string, + replacement: string, +): string { // Check replacement first: the search string may be a substring of the replacement // (e.g. constants.ts where the replacement appends lines after the search string). if (content.includes(replacement)) { - return 'already'; + return content; } if (content.includes(search)) { - const newContent = content.replace(search, replacement); - writeFileSync(filePath, newContent, 'utf-8'); - return 'patched'; + return content.replace(search, replacement); } throw new Error( `[brand-vite] Patch failed in ${filePath}:\n` + @@ -86,7 +101,7 @@ function logPatch(file: string, desc: string, result: 'patched' | 'already') { } } -export function brandVite(rootDir: string = process.cwd()) { +export function brandVite(rootDir: string = process.cwd()): void { log('Applying Vite+ branding patches...'); const nodeDir = join(rootDir, VITE_NODE_DIR); @@ -199,14 +214,22 @@ export function brandVite(rootDir: string = process.cwd()) { // `nativeIncompatibilities` list empty and no warning is emitted. The `false` // guard keeps the import referenced so the type-check stays clean. const configFile = join(nodeDir, 'config.ts'); + const configSource = readFileSync(configFile, 'utf-8'); + // Validate both config patches in memory before writing either edit. + const configWithoutNotice = replaceInSource( + configFile, + configSource, + ' !process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),', + " // Vite+ manages config loading, so Vite's native-config-loader\n // migration notice is noise. Never register the compat-check plugin.\n false &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),", + ); + const patchedConfig = patchViteConfig(configWithoutNotice); + if (patchedConfig !== configSource) { + writeFileSync(configFile, patchedConfig, 'utf-8'); + } logPatch( 'config.ts', - 'Suppressed native config loader migration notice', - replaceInFile( - configFile, - ' !process.env.VITE_CONFIG_NATIVE_IGNORE_WARNING &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),', - " // Vite+ manages config loading, so Vite's native-config-loader\n // migration notice is noise. Never register the compat-check plugin.\n false &&\n createNativeConfigCompatPlugin(nativeIncompatibilities),", - ), + 'Suppressed native config loader notice and enabled an explicit root base', + patchedConfig === configSource ? 'already' : 'patched', ); log('Done!'); diff --git a/packages/tools/src/patch-vite-config.ts b/packages/tools/src/patch-vite-config.ts new file mode 100644 index 0000000000..2499ebc41b --- /dev/null +++ b/packages/tools/src/patch-vite-config.ts @@ -0,0 +1,98 @@ +/** + * Extend the pinned Vite resolver without replacing its config-loading pipeline. + * Keep this transform free of dependencies: branding also runs before installation. + */ +const upstreamSignature = `export async function resolveConfig( + inlineConfig: InlineConfig, + command: 'build' | 'serve', + defaultMode = 'development', + defaultNodeEnv = 'development', + isPreview = false, + /** @internal */ + patchConfig?: (config: ResolvedConfig) => void, + /** @internal */ + patchPlugins?: (resolvedPlugins: Plugin[]) => void, +): Promise {`; + +const patchedSignature = upstreamSignature.replace( + '): Promise {', + ` /** @internal */ + internalOptions?: { + rootBase?: string + }, +): Promise {`, +); + +const upstreamLoadResult = ` if (loadResult) { + config = mergeConfig(loadResult.config, config) + configFile = loadResult.path + configFileDependencies = loadResult.dependencies + }`; + +const patchedLoadResult = upstreamLoadResult.replace( + '\n }', + ` + if (internalOptions?.rootBase !== undefined && inlineConfig.root == null) { + config.root = path.resolve(internalOptions.rootBase, config.root ?? '') + } + }`, +); + +function patchError(detail: string): Error { + return new Error( + `[patch-vite-config] ${detail} in vite/packages/vite/src/node/config.ts. ` + + 'Check the Vite pin in packages/tools/.upstream-versions.json and update patch-vite-config.ts.', + ); +} + +function count(source: string, anchor: string): number { + return source.split(anchor).length - 1; +} + +function patchState(source: string): 'upstream' | 'patched' { + const upstreamSignatures = count(source, upstreamSignature); + const patchedSignatures = count(source, patchedSignature); + const upstreamLoads = count(source, upstreamLoadResult); + const patchedLoads = count(source, patchedLoadResult); + if ( + upstreamSignatures === 1 && + upstreamLoads === 1 && + patchedSignatures === 0 && + patchedLoads === 0 && + !/\b(?:internalOptions|rootBase)\b/.test(source) + ) { + return 'upstream'; + } + if ( + patchedSignatures === 1 && + patchedLoads === 1 && + upstreamSignatures === 0 && + upstreamLoads === 0 && + source.match(/\binternalOptions\b/g)?.length === 3 && + source.match(/\brootBase\b/g)?.length === 3 + ) { + return 'patched'; + } + throw patchError( + 'Expected one complete upstream or patched resolver; found changed, duplicate, or partial patch anchors', + ); +} + +/** Validate the complete patch before a build can produce a core that ignores it. */ +export function assertViteConfigPatched(source: string): void { + if (patchState(source) !== 'patched') { + throw patchError('The root base resolution patch has not been applied'); + } +} + +/** Apply both edits together; reject upstream drift and incomplete prior edits. */ +export function patchViteConfig(source: string): string { + if (patchState(source) === 'patched') { + return source; + } + const patched = source + .replace(upstreamSignature, patchedSignature) + .replace(upstreamLoadResult, patchedLoadResult); + assertViteConfigPatched(patched); + return patched; +}