diff --git a/packages/vitest/src/node/plugins/runnerTransform.ts b/packages/vitest/src/node/plugins/runnerTransform.ts index b9595cd8d6b7..4a4b7084b446 100644 --- a/packages/vitest/src/node/plugins/runnerTransform.ts +++ b/packages/vitest/src/node/plugins/runnerTransform.ts @@ -1,18 +1,22 @@ -import type { ResolvedConfig, UserConfig, Plugin as VitePlugin } from 'vite' +import type { ResolveOptions, UserConfig, Plugin as VitePlugin } from 'vite' import { builtinModules } from 'node:module' import { normalize } from 'pathe' -import { mergeConfig } from 'vite' import { escapeRegExp } from '../../utils/base' import { resolveOptimizerConfig } from './utils' export function ModuleRunnerTransform(): VitePlugin { + let testConfig: NonNullable + const noExternal: (string | RegExp)[] = [] + const external: (string | RegExp)[] = [] + let noExternalAll = false + // make sure Vite always applies the module runner transform return { name: 'vitest:environments-module-runner', config: { order: 'post', handler(config) { - const testConfig = config.test || {} + testConfig = config.test || {} config.environments ??= {} @@ -53,11 +57,6 @@ export function ModuleRunnerTransform(): VitePlugin { testConfig.deps ??= {} testConfig.deps.moduleDirectories = moduleDirectories - const external: (string | RegExp)[] = [] - const noExternal: (string | RegExp)[] = [] - - let noExternalAll: true | undefined - for (const name of names) { config.environments[name] ??= {} @@ -73,117 +72,52 @@ export function ModuleRunnerTransform(): VitePlugin { } environment.dev.preTransformRequests = false environment.keepProcessEnv = true + } + }, + }, + configEnvironment: { + order: 'post', + handler(name, config) { + if (name === '__vitest_vm__' || name === '__vitest__') { + return + } - const resolveExternal = name === 'client' - ? config.resolve?.external - : [] - const resolveNoExternal = name === 'client' - ? config.resolve?.noExternal - : [] - - const topLevelResolveOptions: UserConfig['resolve'] = {} - if (resolveExternal != null) { - topLevelResolveOptions.external = resolveExternal - } - if (resolveNoExternal != null) { - topLevelResolveOptions.noExternal = resolveNoExternal - } - - const currentResolveOptions = mergeConfig( - topLevelResolveOptions, - environment.resolve || {}, - ) as ResolvedConfig['resolve'] - - const envNoExternal = resolveViteResolveOptions('noExternal', currentResolveOptions, moduleDirectories) - if (envNoExternal === true) { - noExternalAll = true - } - else if (envNoExternal.length) { - noExternal.push(...envNoExternal) - } - else if (name === 'client' || name === 'ssr') { - const deprecatedNoExternal = resolveDeprecatedOptions( - name === 'client' - ? config.resolve?.noExternal - : config.ssr?.noExternal, - moduleDirectories, - ) - if (deprecatedNoExternal === true) { - noExternalAll = true - } - else { - noExternal.push(...deprecatedNoExternal) - } - } - - const envExternal = resolveViteResolveOptions('external', currentResolveOptions, moduleDirectories) - if (envExternal !== true && envExternal.length) { - external.push(...envExternal) - } - else if (name === 'client' || name === 'ssr') { - const deprecatedExternal = resolveDeprecatedOptions( - name === 'client' - ? config.resolve?.external - : config.ssr?.external, - moduleDirectories, - ) - if (deprecatedExternal !== true) { - external.push(...deprecatedExternal) - } - } - - // remove Vite's externalization logic because we have our own (unfortunetly) - environment.resolve ??= {} - - environment.resolve.external = [ - ...builtinModules, - ...builtinModules.map(m => `node:${m}`), - ] - // by setting `noExternal` to `true`, we make sure that - // Vite will never use its own externalization mechanism - // to externalize modules and always resolve static imports - // in both SSR and Client environments - environment.resolve.noExternal = true - - // Workaround `noExternal` merging bug on Vite 6 - // https://github.com/vitejs/vite/pull/20502 - if (name === 'ssr') { - delete config.ssr?.noExternal - delete config.ssr?.external - } - - if (name === '__vitest_vm__' || name === '__vitest__') { - continue - } - - const currentOptimizeDeps = environment.optimizeDeps || ( - name === 'client' - ? config.optimizeDeps - : name === 'ssr' - ? config.ssr?.optimizeDeps - : undefined - ) - - const optimizeDeps = resolveOptimizerConfig( - testConfig.deps?.optimizer?.[name], - currentOptimizeDeps, - ) + config.resolve ??= {} + const envNoExternal = resolveViteResolveOptions('noExternal', config.resolve, testConfig.deps?.moduleDirectories) + if (envNoExternal === true) { + noExternalAll = true + } + else if (envNoExternal.length) { + noExternal.push(...envNoExternal) + } - // Vite respects the root level optimize deps, so we override it instead - if (name === 'client') { - config.optimizeDeps = optimizeDeps - environment.optimizeDeps = undefined - } - else if (name === 'ssr') { - config.ssr ??= {} - config.ssr.optimizeDeps = optimizeDeps - environment.optimizeDeps = undefined - } - else { - environment.optimizeDeps = optimizeDeps - } + const envExternal = resolveViteResolveOptions('external', config.resolve, testConfig.deps?.moduleDirectories) + if (envExternal !== true && envExternal.length) { + external.push(...envExternal) } + // remove Vite's externalization logic because we have our own (unfortunately) + config.resolve.external = [ + ...builtinModules, + ...builtinModules.map(m => `node:${m}`), + ] + + // by setting `noExternal` to `true`, we make sure that + // Vite will never use its own externalization mechanism + // to externalize modules and always resolve static imports + // in both SSR and Client environments + config.resolve.noExternal = true + + config.optimizeDeps = resolveOptimizerConfig( + testConfig?.deps?.optimizer?.[name], + config.optimizeDeps, + ) + }, + }, + configResolved: { + order: 'pre', + handler(config) { + const testConfig = config.test! testConfig.server ??= {} testConfig.server.deps ??= {} @@ -207,7 +141,7 @@ export function ModuleRunnerTransform(): VitePlugin { function resolveViteResolveOptions( key: 'noExternal' | 'external', - options: ResolvedConfig['resolve'], + options: Pick, moduleDirectories: string[] | undefined, ): true | (string | RegExp)[] { if (Array.isArray(options[key])) { @@ -229,22 +163,6 @@ function resolveViteResolveOptions( return [] } -function resolveDeprecatedOptions( - options: string | RegExp | (string | RegExp)[] | true | undefined, - moduleDirectories: string[] | undefined, -): true | (string | RegExp)[] { - if (options === true) { - return true - } - else if (Array.isArray(options)) { - return options.map(dep => processWildcard(dep, moduleDirectories)) - } - else if (options != null) { - return [processWildcard(options, moduleDirectories)] - } - return [] -} - function processWildcard(dep: string | RegExp, moduleDirectories: string[] | undefined) { if (typeof dep !== 'string') { return dep diff --git a/test/config/test/vite-ssr-resolve.test.ts b/test/config/test/vite-ssr-resolve.test.ts index e7e47a8f7f6f..2476711953a8 100644 --- a/test/config/test/vite-ssr-resolve.test.ts +++ b/test/config/test/vite-ssr-resolve.test.ts @@ -1,3 +1,4 @@ +import type { Plugin } from 'vite' import type { CliOptions } from 'vitest/node' import { join } from 'pathe' import { describe, expect, onTestFinished, test } from 'vitest' @@ -269,16 +270,66 @@ describe.each(['deprecated', 'environment'] as const)('VitestResolver with Vite expect(await resolver.shouldExternalize('/usr/a/project/node_modules/lib/style.css?inline&lang=scss')).toBe(false) expect(await resolver.shouldExternalize('/usr/a/project/node_modules/lib/Component.vue?vue&type=template&lang=pug')).toBeUndefined() }) + + // Test that plugins can set noExternal/external in configEnvironment hook + // This simulates frameworks like Astro that add their packages via configEnvironment + test('collects noExternal/external from plugin configEnvironment', async () => { + const plugin: Plugin = { + name: 'test-plugin', + configEnvironment(name) { + if (name === 'ssr') { + return { + resolve: { + noExternal: ['plugin-inline-dep', '@framework/*'], + external: ['plugin-external-dep'], + }, + } + } + }, + } + + // Also test merging with user config + const resolver = await getResolver(style, {}, { + noExternal: ['user-inline-dep'], + external: ['user-external-dep'], + }, [plugin]) + + // user config noExternal: should be inlined + expect(await resolver.shouldExternalize('/usr/a/project/node_modules/user-inline-dep/index.js')).toBe(false) + + // plugin noExternal: should be inlined + expect(await resolver.shouldExternalize('/usr/a/project/node_modules/plugin-inline-dep/index.js')).toBe(false) + + // plugin noExternal with wildcard: should be inlined + expect(await resolver.shouldExternalize('/usr/a/project/node_modules/@framework/core/index.js')).toBe(false) + expect(await resolver.shouldExternalize('/usr/a/project/node_modules/@framework/utils/index.js')).toBe(false) + + // user config external: should be externalized + expect(await resolver.shouldExternalize('/usr/a/project/node_modules/user-external-dep/index.js')).toBeTruthy() + + // plugin external: should be externalized + expect(await resolver.shouldExternalize('/usr/a/project/node_modules/plugin-external-dep/index.js')).toBeTruthy() + + // other deps: default behavior + expect(await resolver.shouldExternalize('/usr/a/project/node_modules/other-dep/index.cjs.js')).toBeTruthy() + expect(await resolver.shouldExternalize('/usr/a/project/node_modules/@other/lib/index.cjs.js')).toBeTruthy() + }) }) -async function getResolver(style: 'environment' | 'deprecated', options: CliOptions, externalOptions: { - external?: true | string[] - noExternal?: true | string | RegExp | (string | RegExp)[] -}) { +async function getResolver( + style: 'environment' | 'deprecated', + options: CliOptions, + externalOptions: { + external?: true | string[] + noExternal?: true | string | RegExp | (string | RegExp)[] + }, + plugins: Plugin[] = [], +) { const ctx = await createVitest('test', { watch: false, }, style === 'environment' ? { + plugins, environments: { ssr: { resolve: externalOptions, @@ -287,6 +338,7 @@ async function getResolver(style: 'environment' | 'deprecated', options: CliOpti test: options, } : { + plugins, ssr: externalOptions, test: options, })