Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 191 additions & 2 deletions packages/cli/src/__tests__/resolve-vite-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import fs from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test';

import { findViteConfigUp } from '../resolve-vite-config.js';
import { normalizePath, resolveConfig } from '../index.js';
import {
findViteConfigUp,
resolveUniversalViteConfig,
resolveViteConfig,
} from '../resolve-vite-config.js';

describe('findViteConfigUp', () => {
let tempDir: string;
Expand Down Expand Up @@ -91,3 +97,186 @@ describe('findViteConfigUp', () => {
expect(result).toBe(path.join(tempDir, `vite.config.${extension}`));
});
});

describe('resolveViteConfig metadata', () => {
let tempDir: string;
let originalNodeEnv: string | undefined;

beforeEach(() => {
tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-metadata-')));
fs.writeFileSync(path.join(tempDir, 'package.json'), '{"type":"module"}');
originalNodeEnv = process.env.NODE_ENV;
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
});

function writeConfig(source: string): string {
const configFile = path.join(tempDir, 'vite.config.ts');
fs.writeFileSync(configFile, source);
return normalizePath(configFile);
}

it('loads tool metadata without running Vite plugin hooks', async () => {
const configFile = writeConfig(`
export default {
run: { tasks: { hello: { command: 'echo hello' } } },
lint: { rules: { eqeqeq: 'error' } },
fmt: { semi: false },
check: { lint: false },
staged: { '*.ts': 'vp check' },
pack: { entry: ['src/index.ts'] },
create: { defaultTemplate: '@example' },
plugins: [{
name: 'build-only',
config() { throw new Error('config hook ran'); },
configResolved() { throw new Error('configResolved hook ran'); },
}],
};
`);

const config = await resolveViteConfig(tempDir);
expect(config).toMatchObject({
configFile,
pack: { entry: ['src/index.ts'] },
create: { defaultTemplate: '@example' },
});
expect(JSON.parse(await resolveUniversalViteConfig(null, tempDir))).toEqual({
configFile,
run: { tasks: { hello: { command: 'echo hello' } } },
lint: { rules: { eqeqeq: 'error' } },
fmt: { semi: false },
check: { lint: false },
staged: { '*.ts': 'vp check' },
});
}, 15_000);

it('returns identical metadata across equivalent pnpm installation states', async () => {
const metadata = { run: { tasks: { hello: { command: 'echo hello' } } }, fmt: {} };
const configFile = writeConfig(`export default ${JSON.stringify(metadata)};`);
fs.mkdirSync(path.join(tempDir, 'node_modules'));
const modulesFile = path.join(tempDir, 'node_modules', '.modules.yaml');
const results = [];
for (const install of ['first', 'second']) {
const storeDir = path.join(tempDir, install);
const virtualStoreDir = path.join(storeDir, '.pnpm');
fs.mkdirSync(storeDir);
// pnpm >= 10.29 writes JSON to this manifest. The lockfile and
// installed packages are unchanged; only install-local state differs.
fs.writeFileSync(
modulesFile,
JSON.stringify({
layoutVersion: 5,
packageManager: 'pnpm@12.11.2',
prunedAt: install === 'first' ? '2026-09-28T12:55:07Z' : '2026-09-28T12:58:26Z',
storeDir,
virtualStoreDir,
}),
);
results.push(await resolveViteConfig(tempDir));
// The same manifest still affects callers that need full resolution.
const resolved = await resolveConfig({ root: tempDir }, 'build');
expect(resolved.server.fs.allow).toContain(normalizePath(virtualStoreDir));
}

expect(results[0]).toEqual({ ...metadata, configFile });
expect(results[1]).toEqual(results[0]);
});

it.each([undefined, '', 'production'])(
'preserves config evaluation with NODE_ENV=%s',
async (nodeEnv) => {
if (nodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = nodeEnv;
}
writeConfig(`
export default async (env) => ({
run: { tasks: { hello: {
command: JSON.stringify([
env.command, env.mode, env.isSsrBuild, env.isPreview, process.env.NODE_ENV,
]),
} } },
});
`);

const config = await resolveViteConfig(tempDir);
expect(config.run).toEqual({
tasks: {
hello: {
command: JSON.stringify([
'build',
'development',
false,
false,
nodeEnv || 'development',
]),
},
},
});
},
);

it('leaves full resolution and lazy plugins available after reading metadata', async () => {
fs.mkdirSync(path.join(tempDir, 'node_modules'));
fs.symlinkSync(
fileURLToPath(new URL('../..', import.meta.url)),
path.join(tempDir, 'node_modules', 'vite-plus'),
'junction',
);
writeConfig(`
import { writeFileSync } from 'node:fs';
import { defineConfig, lazyPlugins } from 'vite-plus';
export default defineConfig({
fmt: { semi: false },
plugins: lazyPlugins(() => [{
name: 'build-only',
config() { return { define: { __FROM_PLUGIN__: 'true' } }; },
configResolved(config) {
writeFileSync(new URL('./resolved.txt', import.meta.url), config.command);
},
}]),
});
`);

const metadata = await resolveViteConfig(tempDir);
expect(metadata.fmt).toEqual({ semi: false });
expect(fs.existsSync(path.join(tempDir, 'resolved.txt'))).toBe(false);

const config = await resolveConfig({ root: tempDir }, 'build');
expect(config.define).toHaveProperty('__FROM_PLUGIN__', 'true');
expect(config.plugins.some((plugin) => plugin.name === 'build-only')).toBe(true);
expect(fs.readFileSync(path.join(tempDir, 'resolved.txt'), 'utf8')).toBe('build');
});

it('returns no metadata when no config exists', async () => {
expect(await resolveViteConfig(tempDir)).toEqual({ configFile: undefined });
});

it('uses the closest config when traversing within a workspace', async () => {
fs.writeFileSync(path.join(tempDir, 'pnpm-workspace.yaml'), 'packages: [packages/*]\n');
const configFile = writeConfig('export default { pack: { entry: ["root.ts"] } };');
const packageDir = path.join(tempDir, 'packages', 'app');
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(path.join(packageDir, 'package.json'), '{"name":"app","type":"module"}');
expect(await resolveViteConfig(packageDir)).toEqual({ configFile: undefined });
expect(await resolveViteConfig(packageDir, { traverseUp: true })).toEqual({
configFile,
pack: { entry: ['root.ts'] },
});

const localConfig = path.join(packageDir, 'vite.config.mts');
fs.writeFileSync(localConfig, 'export default { pack: { entry: ["local.ts"] } };');
expect(await resolveViteConfig(packageDir, { traverseUp: true })).toEqual({
configFile: normalizePath(localConfig),
pack: { entry: ['local.ts'] },
});
});
});
2 changes: 1 addition & 1 deletion packages/cli/src/define-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,7 +1015,7 @@ let configMetadataDepth = 0;
let configMetadataSavedValue: string | undefined;

/**
* Run a config-metadata resolution (a `resolveConfig` call that loads the
* Run a config-metadata resolution (a config load that evaluates the
* user's config purely to read a non-plugin block) with the metadata marker
* set, so any `lazyPlugins` evaluated during it skips the plugin factory.
*
Expand Down
37 changes: 29 additions & 8 deletions packages/cli/src/resolve-vite-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';

import { getEnv } from '@voidzero-dev/vite-task-client';
import type { UserConfig } from 'vite';

import { findMonorepoRoot } from '../binding/index.js';
import { withConfigMetadataResolution } from './define-config.ts';
import { VITE_CONFIG_FILES } from './utils/constants.ts';
Expand Down Expand Up @@ -53,25 +56,43 @@ export interface ResolveViteConfigOptions {
}

/**
* Resolve vite.config.ts and return the config object.
* Load vite.config.ts metadata without resolving Vite's plugins or server options.
*/
export async function resolveViteConfig(cwd: string, options?: ResolveViteConfigOptions) {
const { resolveConfig } = await import('./index.js');
export async function resolveViteConfig(
cwd: string,
options?: ResolveViteConfigOptions,
): Promise<UserConfig & { configFile: string | undefined }> {
const { loadConfigFromFile } = await import('./index.js');

// This loads the config purely to read a non-plugin block (lint/fmt/pack/run/
// staged/create…), so skip the user's plugin factory while it evaluates.
return withConfigMetadataResolution(async () => {
// Preserve resolveConfig's environment defaults, including Vite Task's
// tracked NODE_ENV, before evaluating function configs and their imports.
if (process.env.NODE_ENV === undefined) {
const nodeEnv = getEnv('NODE_ENV');
if (nodeEnv !== undefined) {
process.env.NODE_ENV = nodeEnv;
}
}
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = 'development';
}

let configFile: string | undefined;
if (options?.traverseUp && !hasViteConfig(cwd)) {
const workspaceRoot = findMonorepoRoot(cwd);
if (workspaceRoot) {
const configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot);
if (configFile) {
return resolveConfig({ root: cwd, configFile }, 'build');
}
configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot);
}
}

return resolveConfig({ root: cwd }, 'build');
const result = await loadConfigFromFile(
{ command: 'build', mode: 'development', isSsrBuild: false, isPreview: false },
configFile,
cwd,
);
return { ...result?.config, configFile: result?.path };
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/staged/bin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Runs staged linters on staged files using the lint-staged programmatic API.
// Bundled by rolldown — no runtime dependency needed in user projects.
//
// Reads the "staged" key from vite.config.ts via resolveConfig() and passes it
// Loads the "staged" metadata from vite.config.ts and passes it
// to lint-staged as an explicit config object. Exits with a warning if no
// staged config is found.
//
Expand Down