Skip to content

Commit f4e6e99

Browse files
authored
feat: allow importing CSS and assets inside external dependencies (#3880)
1 parent 47f5c3a commit f4e6e99

File tree

24 files changed

+444
-202
lines changed

24 files changed

+444
-202
lines changed

docs/config/index.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,69 @@ When Vitest encounters the external library listed in `include`, it will be bund
196196
- Your `alias` configuration is now respected inside bundled packages
197197
- Code in your tests is running closer to how it's running in the browser
198198

199-
Be aware that only packages in `deps.optimizer?.[mode].include` option are bundled (some plugins populate this automatically, like Svelte). You can read more about available options in [Vite](https://vitejs.dev/config/dep-optimization-options.html) docs. By default, Vitest uses `optimizer.web` for `jsdom` and `happy-dom` environments, and `optimizer.ssr` for `node` and `edge` environments, but it is configurable by [`transformMode`](#transformmode).
199+
Be aware that only packages in `deps.optimizer?.[mode].include` option are bundled (some plugins populate this automatically, like Svelte). You can read more about available options in [Vite](https://vitejs.dev/config/dep-optimization-options.html) docs (Vitest doesn't support `disable` and `noDiscovery` options). By default, Vitest uses `optimizer.web` for `jsdom` and `happy-dom` environments, and `optimizer.ssr` for `node` and `edge` environments, but it is configurable by [`transformMode`](#transformmode).
200200

201201
This options also inherits your `optimizeDeps` configuration (for web Vitest will extend `optimizeDeps`, for ssr - `ssr.optimizeDeps`). If you redefine `include`/`exclude` option in `deps.optimizer` it will extend your `optimizeDeps` when running tests. Vitest automatically removes the same options from `include`, if they are listed in `exclude`.
202202

203203
::: tip
204204
You will not be able to edit your `node_modules` code for debugging, since the code is actually located in your `cacheDir` or `test.cache.dir` directory. If you want to debug with `console.log` statements, edit it directly or force rebundling with `deps.optimizer?.[mode].force` option.
205205
:::
206206

207+
#### deps.optimizer.{mode}.enabled
208+
209+
- **Type:** `boolean`
210+
- **Default:** `true`
211+
212+
Enable dependency optimization.
213+
214+
#### deps.web
215+
216+
- **Type:** `{ transformAssets?, ... }`
217+
- **Version:** Since Vite 0.34.2
218+
219+
Options that are applied to external files when transform mode is set to `web`. By default, `jsdom` and `happy-dom` use `web` mode, while `node` and `edge` environments use `ssr` transform mode, so these options will have no affect on files inside those environments.
220+
221+
Usually, files inside `node_modules` are externalized, but these options also affect files in [`server.deps.external`](#server-deps-external).
222+
223+
#### deps.web.transformAssets
224+
225+
- **Type:** `boolean`
226+
- **Default:** `true`
227+
228+
Should Vitest process assets (.png, .svg, .jpg, etc) files and resolve them like Vite does in the browser.
229+
230+
hese module will have a default export equal to the path to the asset, if no query is specified.
231+
232+
::: warning
233+
At the moment, this option only works with [`experimentalVmThreads`](#experimentalvmthreads) pool.
234+
:::
235+
236+
#### deps.web.transformCss
237+
238+
- **Type:** `boolean`
239+
- **Default:** `true`
240+
241+
Should Vitest process CSS (.css, .scss, .sass, etc) files and resolve them like Vite does in the browser.
242+
243+
If CSS files are disabled with [`css`](#css) options, this option will just silence `UNKNOWN_EXTENSION` errors.
244+
245+
::: warning
246+
At the moment, this option only works with [`experimentalVmThreads`](#experimentalvmthreads) pool.
247+
:::
248+
249+
#### deps.web.transformGlobPattern
250+
251+
- **Type:** `RegExp | RegExp[]`
252+
- **Default:** `[]`
253+
254+
Regexp pattern to match external files that should be transformed.
255+
256+
By default, files inside `node_modules` are externalized and not transformed, unless it's CSS or an asset, and corresponding option is not disabled.
257+
258+
::: warning
259+
At the moment, this option only works with [`experimentalVmThreads`](#experimentalvmthreads) pool.
260+
:::
261+
207262
#### deps.registerNodeLoader<NonProjectOption />
208263

209264
- **Type:** `boolean`

packages/vite-node/src/server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,19 @@ export class ViteNodeServer {
150150
return this.transformPromiseMap.get(id)!
151151
}
152152

153+
async transformModule(id: string, transformMode?: 'web' | 'ssr') {
154+
if (transformMode !== 'web')
155+
throw new Error('`transformModule` only supports `transformMode: "web"`.')
156+
157+
const normalizedId = normalizeModuleId(id)
158+
const mod = this.server.moduleGraph.getModuleById(normalizedId)
159+
const result = mod?.transformResult || await this.server.transformRequest(normalizedId)
160+
161+
return {
162+
code: result?.code,
163+
}
164+
}
165+
153166
getTransformMode(id: string) {
154167
const withoutQuery = id.split('?')[0]
155168

packages/vitest/src/node/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ export function resolveConfig(
137137
if (!resolved.deps.moduleDirectories.includes('/node_modules/'))
138138
resolved.deps.moduleDirectories.push('/node_modules/')
139139

140+
resolved.deps.web ??= {}
141+
resolved.deps.web.transformAssets ??= true
142+
resolved.deps.web.transformCss ??= true
143+
resolved.deps.web.transformGlobPattern ??= []
144+
140145
resolved.server ??= {}
141146
resolved.server.deps ??= {}
142147

packages/vitest/src/node/pools/rpc.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export function createMethodsRPC(project: WorkspaceProject): RuntimeRPC {
3030
resolveId(id, importer, transformMode) {
3131
return project.vitenode.resolveId(id, importer, transformMode)
3232
},
33+
transform(id, environment) {
34+
return project.vitenode.transformModule(id, environment)
35+
},
3336
onPathsCollected(paths) {
3437
ctx.state.collectPaths(paths)
3538
project.report('onPathsCollected', paths)

packages/vitest/src/node/pools/vm-threads.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { WorkspaceProject } from '../workspace'
1414
import { createMethodsRPC } from './rpc'
1515

1616
const workerPath = pathToFileURL(resolve(distDir, './vm.js')).href
17-
const suppressLoaderWarningsPath = resolve(rootDir, './suppress-warnings.cjs')
17+
const suppressWarningsPath = resolve(rootDir, './suppress-warnings.cjs')
1818

1919
function createWorkerChannel(project: WorkspaceProject) {
2020
const channel = new MessageChannel()
@@ -61,7 +61,7 @@ export function createVmThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessO
6161
'--experimental-import-meta-resolve',
6262
'--experimental-vm-modules',
6363
'--require',
64-
suppressLoaderWarningsPath,
64+
suppressWarningsPath,
6565
...execArgv,
6666
],
6767

packages/vitest/src/node/workspace.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,10 @@ export class WorkspaceProject {
293293
...this.config.deps,
294294
optimizer: {
295295
web: {
296-
enabled: this.config.deps?.optimizer?.web?.enabled ?? false,
296+
enabled: this.config.deps?.optimizer?.web?.enabled ?? true,
297297
},
298298
ssr: {
299-
enabled: this.config.deps?.optimizer?.ssr?.enabled ?? false,
299+
enabled: this.config.deps?.optimizer?.ssr?.enabled ?? true,
300300
},
301301
},
302302
},

packages/vitest/src/runtime/execute.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { ViteNodeRunnerOptions } from 'vite-node'
66
import { normalize, relative, resolve } from 'pathe'
77
import { processError } from '@vitest/utils/error'
88
import type { MockMap } from '../types/mocker'
9-
import type { ResolvedConfig, ResolvedTestEnvironment, WorkerGlobalState } from '../types'
9+
import type { ResolvedConfig, ResolvedTestEnvironment, RuntimeRPC, WorkerGlobalState } from '../types'
1010
import { distDir } from '../paths'
1111
import { getWorkerState } from '../utils/global'
1212
import { VitestMocker } from './mocker'
@@ -21,6 +21,7 @@ export interface ExecuteOptions extends ViteNodeRunnerOptions {
2121
moduleDirectories?: string[]
2222
context?: vm.Context
2323
state: WorkerGlobalState
24+
transform: RuntimeRPC['transform']
2425
}
2526

2627
export async function createVitestExecutor(options: ExecuteOptions) {
@@ -100,6 +101,9 @@ export async function startVitestExecutor(options: ContextExecutorOptions) {
100101
resolveId(id, importer) {
101102
return rpc().resolveId(id, importer, getTransformMode())
102103
},
104+
transform(id) {
105+
return rpc().transform(id, 'web')
106+
},
103107
packageCache,
104108
moduleCache,
105109
mockMap,
@@ -174,12 +178,6 @@ export class VitestExecutor extends ViteNodeRunner {
174178
}
175179
}
176180
else {
177-
this.externalModules = new ExternalModulesExecutor({
178-
...options,
179-
fileMap,
180-
context: options.context,
181-
packageCache: options.packageCache,
182-
})
183181
const clientStub = vm.runInContext(
184182
`(defaultClient) => ({ ...defaultClient, updateStyle: ${updateStyle.toString()}, removeStyle: ${removeStyle.toString()} })`,
185183
options.context,
@@ -189,6 +187,12 @@ export class VitestExecutor extends ViteNodeRunner {
189187
'@vite/client': clientStub,
190188
}
191189
this.primitives = vm.runInContext('({ Object, Reflect, Symbol })', options.context)
190+
this.externalModules = new ExternalModulesExecutor({
191+
...options,
192+
fileMap,
193+
context: options.context,
194+
packageCache: options.packageCache,
195+
})
192196
}
193197
}
194198

packages/vitest/src/runtime/external-executor.ts

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CommonjsExecutor } from './vm/commonjs-executor'
1212
import type { FileMap } from './vm/file-map'
1313
import { EsmExecutor } from './vm/esm-executor'
1414
import { interopCommonJsModule } from './vm/utils'
15+
import { ViteExecutor } from './vm/vite-executor'
1516

1617
const SyntheticModule: typeof VMSyntheticModule = (vm as any).SyntheticModule
1718

@@ -23,12 +24,20 @@ export interface ExternalModulesExecutorOptions extends ExecuteOptions {
2324
packageCache: Map<string, any>
2425
}
2526

27+
interface ModuleInformation {
28+
type: 'data' | 'builtin' | 'vite' | 'module' | 'commonjs'
29+
url: string
30+
path: string
31+
}
32+
2633
// TODO: improve Node.js strict mode support in #2854
2734
export class ExternalModulesExecutor {
2835
private cjs: CommonjsExecutor
2936
private esm: EsmExecutor
37+
private vite: ViteExecutor
3038
private context: vm.Context
3139
private fs: FileMap
40+
private resolvers: ((id: string, parent: string) => string | undefined)[] = []
3241

3342
constructor(private options: ExternalModulesExecutorOptions) {
3443
this.context = options.context
@@ -42,6 +51,13 @@ export class ExternalModulesExecutor {
4251
importModuleDynamically: this.importModuleDynamically,
4352
fileMap: options.fileMap,
4453
})
54+
this.vite = new ViteExecutor({
55+
esmExecutor: this.esm,
56+
context: this.context,
57+
transform: options.transform,
58+
viteClientModule: options.requestStubs!['/@vite/client'],
59+
})
60+
this.resolvers = [this.vite.resolve]
4561
}
4662

4763
// dynamic import can be used in both ESM and CJS, so we have it in the executor
@@ -56,6 +72,11 @@ export class ExternalModulesExecutor {
5672
}
5773

5874
public async resolve(specifier: string, parent: string) {
75+
for (const resolver of this.resolvers) {
76+
const id = resolver(specifier, parent)
77+
if (id)
78+
return id
79+
}
5980
return nativeResolve(specifier, parent)
6081
}
6182

@@ -124,44 +145,59 @@ export class ExternalModulesExecutor {
124145
return m
125146
}
126147

127-
private async createModule(identifier: string): Promise<VMModule> {
148+
private getModuleInformation(identifier: string): ModuleInformation {
128149
if (identifier.startsWith('data:'))
129-
return this.esm.createDataModule(identifier)
150+
return { type: 'data', url: identifier, path: identifier }
130151

131152
const extension = extname(identifier)
132-
133-
if (extension === '.node' || isNodeBuiltin(identifier)) {
134-
const exports = this.require(identifier)
135-
return this.wrapCoreSynteticModule(identifier, exports)
136-
}
153+
if (extension === '.node' || isNodeBuiltin(identifier))
154+
return { type: 'builtin', url: identifier, path: identifier }
137155

138156
const isFileUrl = identifier.startsWith('file://')
139-
const fileUrl = isFileUrl ? identifier : pathToFileURL(identifier).toString()
140157
const pathUrl = isFileUrl ? fileURLToPath(identifier.split('?')[0]) : identifier
158+
const fileUrl = isFileUrl ? identifier : pathToFileURL(pathUrl).toString()
141159

142-
// TODO: support wasm in the future
143-
// if (extension === '.wasm') {
144-
// const source = this.readBuffer(pathUrl)
145-
// const wasm = this.loadWebAssemblyModule(source, fileUrl)
146-
// this.moduleCache.set(fileUrl, wasm)
147-
// return wasm
148-
// }
149-
150-
if (extension === '.cjs') {
151-
const exports = this.require(pathUrl)
152-
return this.wrapCommonJsSynteticModule(fileUrl, exports)
160+
let type: 'module' | 'commonjs' | 'vite'
161+
if (this.vite.canResolve(fileUrl)) {
162+
type = 'vite'
163+
}
164+
else if (extension === '.mjs') {
165+
type = 'module'
166+
}
167+
else if (extension === '.cjs') {
168+
type = 'commonjs'
169+
}
170+
else {
171+
const pkgData = this.findNearestPackageData(normalize(pathUrl))
172+
type = pkgData.type === 'module' ? 'module' : 'commonjs'
153173
}
154174

155-
if (extension === '.mjs')
156-
return await this.esm.createEsmModule(fileUrl, this.fs.readFile(pathUrl))
157-
158-
const pkgData = this.findNearestPackageData(normalize(pathUrl))
159-
160-
if (pkgData.type === 'module')
161-
return await this.esm.createEsmModule(fileUrl, this.fs.readFile(pathUrl))
175+
return { type, path: pathUrl, url: fileUrl }
176+
}
162177

163-
const exports = this.cjs.require(pathUrl)
164-
return this.wrapCommonJsSynteticModule(fileUrl, exports)
178+
private async createModule(identifier: string): Promise<VMModule> {
179+
const { type, url, path } = this.getModuleInformation(identifier)
180+
181+
switch (type) {
182+
case 'data':
183+
return this.esm.createDataModule(identifier)
184+
case 'builtin': {
185+
const exports = this.require(identifier)
186+
return this.wrapCoreSynteticModule(identifier, exports)
187+
}
188+
case 'vite':
189+
return await this.vite.createViteModule(url)
190+
case 'module':
191+
return await this.esm.createEsModule(url, this.fs.readFile(path))
192+
case 'commonjs': {
193+
const exports = this.require(path)
194+
return this.wrapCommonJsSynteticModule(identifier, exports)
195+
}
196+
default: {
197+
const _deadend: never = type
198+
return _deadend
199+
}
200+
}
165201
}
166202

167203
async import(identifier: string) {

packages/vitest/src/runtime/vm/commonjs-executor.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ interface PrivateNodeModule extends NodeModule {
2121

2222
export class CommonjsExecutor {
2323
private context: vm.Context
24-
private requireCache: Record<string, NodeModule> = Object.create(null)
24+
private requireCache = new Map<string, NodeModule>()
25+
private publicRequireCache = this.createProxyCache()
2526

2627
private moduleCache = new Map<string, VMModule | Promise<VMModule>>()
2728
private builtinCache: Record<string, NodeModule> = Object.create(null)
@@ -75,7 +76,7 @@ export class CommonjsExecutor {
7576
script.identifier = filename
7677
const fn = script.runInContext(executor.context)
7778
const __dirname = dirname(filename)
78-
executor.requireCache[filename] = this
79+
executor.requireCache.set(filename, this)
7980
try {
8081
fn(this.exports, this.require, this, filename, __dirname)
8182
return this.exports
@@ -165,14 +166,31 @@ export class CommonjsExecutor {
165166
set: () => {},
166167
configurable: true,
167168
})
168-
require.main = _require.main
169-
require.cache = this.requireCache
169+
require.main = undefined // there is no main, since we are running tests using ESM
170+
require.cache = this.publicRequireCache
170171
return require
171172
}
172173

174+
private createProxyCache() {
175+
return new Proxy(Object.create(null), {
176+
defineProperty: () => true,
177+
deleteProperty: () => true,
178+
set: () => true,
179+
get: (_, key: string) => this.requireCache.get(key),
180+
has: (_, key: string) => this.requireCache.has(key),
181+
ownKeys: () => Array.from(this.requireCache.keys()),
182+
getOwnPropertyDescriptor() {
183+
return {
184+
configurable: true,
185+
enumerable: true,
186+
}
187+
},
188+
})
189+
}
190+
173191
// very naive implementation for Node.js require
174192
private loadCommonJSModule(module: NodeModule, filename: string): Record<string, unknown> {
175-
const cached = this.requireCache[filename]
193+
const cached = this.requireCache.get(filename)
176194
if (cached)
177195
return cached.exports
178196

0 commit comments

Comments
 (0)