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
182 changes: 50 additions & 132 deletions packages/vitest/src/node/plugins/runnerTransform.ts
Original file line number Diff line number Diff line change
@@ -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<UserConfig['test']>
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 ??= {}

Expand Down Expand Up @@ -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] ??= {}

Expand All @@ -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 ??= {}

Expand All @@ -207,7 +141,7 @@ export function ModuleRunnerTransform(): VitePlugin {

function resolveViteResolveOptions(
key: 'noExternal' | 'external',
options: ResolvedConfig['resolve'],
options: Pick<ResolveOptions, 'noExternal' | 'external'>,
moduleDirectories: string[] | undefined,
): true | (string | RegExp)[] {
if (Array.isArray(options[key])) {
Expand All @@ -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
Expand Down
60 changes: 56 additions & 4 deletions test/config/test/vite-ssr-resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -287,6 +338,7 @@ async function getResolver(style: 'environment' | 'deprecated', options: CliOpti
test: options,
}
: {
plugins,
ssr: externalOptions,
test: options,
})
Expand Down
Loading