Skip to content
16 changes: 16 additions & 0 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils'
import { SnapshotManager } from '@vitest/snapshot/manager'
import { deepClone, deepMerge, nanoid, noop, toArray } from '@vitest/utils/helpers'
import { join, normalize, relative } from 'pathe'
import { isRunnableDevEnvironment } from 'vite'
import { version } from '../../package.json' with { type: 'json' }
import { WebSocketReporter } from '../api/setup'
import { distDir } from '../paths'
Expand Down Expand Up @@ -247,6 +248,21 @@ export class Vitest {
this._fetcher,
resolved,
)
// patch default ssr runnable environment so third-party usage of `runner.import`
// still works with Vite's external/noExternal configuration.
const ssrEnvironment = server.environments.ssr
if (isRunnableDevEnvironment(ssrEnvironment)) {
const ssrRunner = new ServerModuleRunner(
ssrEnvironment,
this._fetcher,
resolved,
)
Object.defineProperty(ssrEnvironment, 'runner', {
value: ssrRunner,
writable: true,
configurable: true,
})
}

if (this.config.watch) {
// hijack server restart
Expand Down
2 changes: 0 additions & 2 deletions packages/vitest/src/node/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,6 @@ export async function VitestPlugin(
}
},
configureServer: {
// runs after vite:import-analysis as it relies on `server` instance on Vite 5
order: 'post',
async handler(server) {
if (options.watch && process.env.VITE_TEST_WATCHER_DEBUG) {
server.watcher.on('ready', () => {
Expand Down
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
1 change: 0 additions & 1 deletion packages/vitest/src/node/plugins/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,6 @@ export function WorkspaceVitestPlugin(
},
{
name: 'vitest:project:server',
enforce: 'post',
async configureServer(server) {
const options = deepMerge({}, configDefaults, server.config.test || {})
await project._configureServer(options, server)
Expand Down
15 changes: 15 additions & 0 deletions packages/vitest/src/node/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { deepMerge, nanoid, slash } from '@vitest/utils/helpers'
import { isAbsolute, join, relative } from 'pathe'
import pm from 'picomatch'
import { glob } from 'tinyglobby'
import { isRunnableDevEnvironment } from 'vite'
import { setup } from '../api/setup'
import { createDefinesScript } from '../utils/config-helpers'
import { NativeModuleRunner } from '../utils/nativeModuleRunner'
Expand Down Expand Up @@ -579,6 +580,20 @@ export class TestProject {
this._fetcher,
this._config,
)

const ssrEnvironment = server.environments.ssr
if (isRunnableDevEnvironment(ssrEnvironment)) {
const ssrRunner = new ServerModuleRunner(
ssrEnvironment,
this._fetcher,
this._config,
)
Object.defineProperty(ssrEnvironment, 'runner', {
value: ssrRunner,
writable: true,
configurable: true,
})
}
}

/** @internal */
Expand Down
5 changes: 5 additions & 0 deletions test/cli/fixtures/ssr-runner/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { test, expect } from 'vitest'

test('basic', () => {
expect(1 + 1).toBe(2)
})
2 changes: 2 additions & 0 deletions test/cli/fixtures/ssr-runner/test-runner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import * as vite from 'vite'
export { vite }
20 changes: 20 additions & 0 deletions test/cli/fixtures/ssr-runner/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import assert from 'node:assert'
import { isRunnableDevEnvironment, createServer } from 'vite'
import { defineConfig } from 'vitest/config'

export default defineConfig({
plugins: [
{
name: 'test-ssr-runner',
// test ssr runner.import() with correct external semantics in configureServer hook
// vite should be externalized and reference-equal to the directly imported one
async configureServer(server) {
const ssr = server.environments.ssr
assert(isRunnableDevEnvironment(ssr))
const mod = await ssr.runner.import<{ vite: typeof import("vite") }>('./test-runner.js')
assert(mod.vite.createServer === createServer)
;(globalThis as any).__testSsrRunner = mod.vite.version
},
},
],
})
9 changes: 9 additions & 0 deletions test/cli/test/ssr-runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { version } from 'vite'
import { expect, it } from 'vitest'
import { runVitest } from '../../test-utils'

// https://github.com/vitest-dev/vitest/issues/9324
it('ssr runner.import() works in configureServer', async () => {
await runVitest({ root: './fixtures/ssr-runner' })
expect((globalThis as any).__testSsrRunner).toBe(version)
})
Loading
Loading