Skip to content
Merged
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
87 changes: 78 additions & 9 deletions packages/cli/src/__tests__/define-config-vitest-resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import { fileURLToPath } from 'node:url';

import { preview } from '@vitest/browser-preview';
import { resolveConfig, type Alias, type ConfigEnv, type Plugin, type UserConfig } from 'vite';
import {
build,
resolveConfig,
type Alias,
type ConfigEnv,
type Plugin,
type UserConfig,
} from 'vite';
import { describe, expect, it } from 'vitest';

import { defineConfig, defineProject, isVitestFamilySpecifier } from '../define-config.ts';
import { VITEST_RESOLVER_PACKAGES } from '../utils/vitest-ecosystem.ts';

const RESOLVER_PLUGIN_NAME = 'vite-plus:vitest-resolver';

function findPlugin(plugins: unknown, name: string): Record<string, unknown> | undefined {
function findPlugin(plugins: unknown, name: string): Plugin | undefined {
if (!Array.isArray(plugins)) {
return undefined;
}
return plugins.find(
(p): p is Record<string, unknown> =>
!!p && typeof p === 'object' && (p as { name?: unknown }).name === name,
(p): p is Plugin => !!p && typeof p === 'object' && (p as { name?: unknown }).name === name,
);
}

Expand Down Expand Up @@ -86,6 +93,66 @@ describe('isVitestFamilySpecifier', () => {
});

describe('vitePlusVitestResolverPlugin', () => {
it('lets every resolver package through the hook filter', () => {
const plugin = findPlugin(defineConfig({}).plugins, RESOLVER_PLUGIN_NAME)!;
const { filter } = plugin.resolveId as { filter: { id: RegExp } };
for (const name of [...VITEST_RESOLVER_PACKAGES, 'vite-plus/test']) {
expect(name).toMatch(filter.id);
}
for (const name of VITEST_RESOLVER_PACKAGES) {
expect(`${name}/subpath`).toMatch(filter.id);
}
});

it.each([
['react', 'resolved-by-project'],
['vitest', 'resolved-by-vitest'],
['vitest/internal/browser', 'resolved-by-vitest'],
['@vitest/mocker', 'resolved-by-vitest'],
['vite-plus/test', 'resolved-by-vitest'],
])('dispatches %s through the native hook filter', async (specifier, expected) => {
const plugin = findPlugin(defineConfig({}).plugins, RESOLVER_PLUGIN_NAME)!;
const hook = plugin.resolveId;
if (typeof hook !== 'object') {
throw new Error('The resolver plugin declares resolveId as an object hook.');
}
const resolver = {
...plugin,
resolveId: {
...hook,
// The stub's external id shows which resolver answered.
handler() {
return { id: 'resolved-by-vitest', external: true };
},
},
};
const result = await build({
configFile: false,
logLevel: 'silent',
plugins: [
resolver,
{
name: 'fixture',
// Ahead of Vite's own resolver, so an installed package can't answer first.
enforce: 'pre',
resolveId(id) {
return id === 'virtual:entry'
? '\0entry'
: { id: 'resolved-by-project', external: true };
},
load(id) {
if (id === '\0entry') {
return `export { default } from ${JSON.stringify(specifier)};`;
}
return null;
},
},
],
build: { write: false, rolldownOptions: { input: 'virtual:entry' } },
});
expect(result).toMatchObject({ output: [{ imports: [expected] }] });
});

it.each(['serve', 'build'] as const)(
'leaves application dependencies unchanged during %s with browser tests configured',
async (command) => {
Expand Down Expand Up @@ -213,7 +280,7 @@ describe('vitePlusVitestResolverPlugin', () => {
const plugin = findPlugin(plugins, RESOLVER_PLUGIN_NAME);
expect(plugin).toBeDefined();
expect(plugin?.enforce).toBe('pre');
expect(typeof plugin?.resolveId).toBe('function');
expect(plugin?.resolveId).toMatchObject({ handler: expect.any(Function) });
}
});

Expand All @@ -225,7 +292,7 @@ describe('vitePlusVitestResolverPlugin', () => {

expect(plugin).toBeDefined();
expect(plugin?.enforce).toBe('pre');
expect(typeof plugin?.resolveId).toBe('function');
expect(plugin?.resolveId).toMatchObject({ handler: expect.any(Function) });
});
});

Expand Down Expand Up @@ -255,8 +322,10 @@ interface ResolveCall {
function getResolveId(): ResolveId {
const result = defineConfig({}) as { plugins: unknown[] };
const plugin = findPlugin(result.plugins, RESOLVER_PLUGIN_NAME);
expect(plugin).toBeDefined();
return plugin?.resolveId as ResolveId;
if (!plugin) {
throw new Error(`${RESOLVER_PLUGIN_NAME} is not injected`);
}
return (plugin.resolveId as { handler: ResolveId }).handler;
}

// The resolver re-resolves through `this.resolve` rooted at vite-plus's own
Expand All @@ -282,7 +351,7 @@ function resolver(command: 'serve' | 'build', vitestServer: boolean): ResolveId
const plugin = findPlugin(defineConfig({}).plugins, RESOLVER_PLUGIN_NAME)!;
const hook = (plugin.config as { handler: (config: UserConfig, env: ConfigEnv) => void }).handler;
hook(vitestServer ? { environments: { __vitest__: {} } } : {}, { command, mode: 'test' });
return plugin.resolveId as ResolveId;
return (plugin.resolveId as { handler: ResolveId }).handler;
}

describe('workspace test API identity', () => {
Expand Down
72 changes: 38 additions & 34 deletions packages/cli/src/define-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,41 +360,45 @@ function vitePlusVitestResolverPlugin(): PluginOption {
});
plugins.splice(0, plugins.length, ...unique);
},
async resolveId(id, importer, options) {
if (isVitestServer && id === 'vite-plus/test') {
// pnpm can install separate vite-plus/Vitest peer instances for a
// workspace's root and children. Externalizing the child wrapper lets
// Node load its uninitialized runner state, bypassing Vite's hooks.
// Resolve the API before externalization through Vitest's own resolver:
// it selects the active runner (or the browser's optimized instance),
// not the Vitest copy next to this config's vite-plus installation.
return this.resolve('vitest', importer, { ...options, skipSelf: true });
}
if (!isVitestFamilySpecifier(id)) {
return null;
}
// The redirected imports are all clean bare specifiers; a queried id is
// outside the scope of this resolver — let the default resolver handle it.
if (id.includes('?')) {
return null;
}
// Re-resolve from vite-plus's own (pinned) tree via Vite's resolver so the
// runner and every imported Vitest module are a single physical instance.
// `skipSelf` avoids infinite recursion back into this plugin.
const vitestAnchorPath = getVitestAnchor();
const bundledAnchors = vitestAnchorPath
? [vitePlusModuleFile, vitestAnchorPath]
: [vitePlusModuleFile];
for (const anchor of bundledAnchors) {
const resolved = await this.resolve(id, anchor, { ...options, skipSelf: true });
if (resolved) {
return resolved;
resolveId: {
// Keep the allowlist in the handler; skip unrelated ids before the handler runs.
filter: { id: /^(?:vitest(?:\/|$)|@vitest\/|vite-plus\/test$)/ },
async handler(id, importer, options) {
if (isVitestServer && id === 'vite-plus/test') {
// pnpm can install separate vite-plus/Vitest peer instances for a
// workspace's root and children. Externalizing the child wrapper lets
// Node load its uninitialized runner state, bypassing Vite's hooks.
// Resolve the API before externalization through Vitest's own resolver:
// it selects the active runner (or the browser's optimized instance),
// not the Vitest copy next to this config's vite-plus installation.
return this.resolve('vitest', importer, { ...options, skipSelf: true });
}
}
// Last resort: default project-rooted resolution for any family id the
// bundled tree cannot resolve (e.g. coverage providers not shipped with
// vite-plus).
return this.resolve(id, importer, { ...options, skipSelf: true });
if (!isVitestFamilySpecifier(id)) {
return null;
}
// The redirected imports are all clean bare specifiers; a queried id is
// outside the scope of this resolver — let the default resolver handle it.
if (id.includes('?')) {
return null;
}
// Re-resolve from vite-plus's own (pinned) tree via Vite's resolver so the
// runner and every imported Vitest module are a single physical instance.
// `skipSelf` avoids infinite recursion back into this plugin.
const vitestAnchorPath = getVitestAnchor();
const bundledAnchors = vitestAnchorPath
? [vitePlusModuleFile, vitestAnchorPath]
: [vitePlusModuleFile];
for (const anchor of bundledAnchors) {
const resolved = await this.resolve(id, anchor, { ...options, skipSelf: true });
if (resolved) {
return resolved;
}
}
// Last resort: default project-rooted resolution for any family id the
// bundled tree cannot resolve (e.g. coverage providers not shipped with
// vite-plus).
return this.resolve(id, importer, { ...options, skipSelf: true });
},
},
};
}
Expand Down
Loading