diff --git a/docs/config/index.md b/docs/config/index.md index cb62351448a5..0e5abfaf1d84 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -91,6 +91,28 @@ Files to exclude from the test run, using glob pattern. Handling for dependencies resolution. +#### deps.experimentalOptimizer + +- **Type:** `DepOptimizationConfig & { enabled: boolean }` +- **Version:** Vitets 0.29.0 +- **See also:** [Dep Optimization Options](https://vitejs.dev/config/dep-optimization-options.html) + +Enable dependency optimization. If you have a lot of tests, this might improve their performance. + +For `jsdom` and `happy-dom` environments, when Vitest will encounter the external library, it will be bundled into a single file using esbuild and imported as a whole module. This is good for several reasons: + +- Importing packages with a lot of imports is expensive. By bundling them into one file we can save a lot of time +- Importing UI libraries is expensive because they are not meant to run inside Node.js +- Your `alias` configuration is now respected inside bundled packages + +You can opt-out of this behavior for certain packages with `exclude` option. You can read more about available options in [Vite](https://vitejs.dev/config/dep-optimization-options.html) docs. + +This options also inherits your `optimizeDeps` configuration. If you redefine `include`/`exclude`/`entries` option in `deps.experimentalOptimizer` it will overwrite your `optimizeDeps` when running tests. + +:::note +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.experimentalOptimizer.force` option. +::: + #### deps.external - **Type:** `(string | RegExp)[]` diff --git a/packages/vite-node/src/client.ts b/packages/vite-node/src/client.ts index f647c72901ed..c85127937178 100644 --- a/packages/vite-node/src/client.ts +++ b/packages/vite-node/src/client.ts @@ -204,18 +204,18 @@ export class ViteNodeRunner { return !isInternalRequest(id) && !isNodeBuiltin(id) } - private async _resolveUrl(id: string, importee?: string): Promise<[url: string, fsPath: string]> { + private async _resolveUrl(id: string, importer?: string): Promise<[url: string, fsPath: string]> { // we don't pass down importee here, because otherwise Vite doesn't resolve it correctly // should be checked before normalization, because it removes this prefix - if (importee && id.startsWith(VALID_ID_PREFIX)) - importee = undefined + if (importer && id.startsWith(VALID_ID_PREFIX)) + importer = undefined id = normalizeRequestId(id, this.options.base) if (!this.shouldResolveId(id)) return [id, id] const { path, exists } = toFilePath(id, this.root) if (!this.options.resolveId || exists) return [id, path] - const resolved = await this.options.resolveId(id, importee) + const resolved = await this.options.resolveId(id, importer) const resolvedId = resolved ? normalizeRequestId(resolved.id, this.options.base) : id diff --git a/packages/vite-node/src/externalize.ts b/packages/vite-node/src/externalize.ts index b388593a5719..a75682ef4eae 100644 --- a/packages/vite-node/src/externalize.ts +++ b/packages/vite-node/src/externalize.ts @@ -105,6 +105,10 @@ async function _shouldExternalize( id = patchWindowsImportPath(id) + // always externalize Vite deps, they are too big to inline + if (options?.cacheDir && id.includes(options.cacheDir)) + return id + if (matchExternalizePattern(id, options?.inline)) return false if (matchExternalizePattern(id, options?.external)) diff --git a/packages/vite-node/src/server.ts b/packages/vite-node/src/server.ts index b73b03d39067..d9de1be8537e 100644 --- a/packages/vite-node/src/server.ts +++ b/packages/vite-node/src/server.ts @@ -1,5 +1,6 @@ import { performance } from 'node:perf_hooks' -import { resolve } from 'pathe' +import { existsSync } from 'node:fs' +import { join, relative, resolve } from 'pathe' import type { TransformResult, ViteDevServer } from 'vite' import createDebug from 'debug' import type { EncodedSourceMap } from '@jridgewell/trace-mapping' @@ -17,6 +18,8 @@ export class ViteNodeServer { private fetchPromiseMap = new Map>() private transformPromiseMap = new Map>() + private existingOptimizedDeps = new Set() + fetchCache = new Map { + private async ensureExists(id: string): Promise { + if (this.existingOptimizedDeps.has(id)) + return true + if (existsSync(id)) { + this.existingOptimizedDeps.add(id) + return true + } + return new Promise((resolve) => { + setTimeout(() => { + this.ensureExists(id).then(() => { + resolve(true) + }) + }) + }) + } + + async resolveId(id: string, importer?: string, transformMode?: 'web' | 'ssr'): Promise { if (importer && !importer.startsWith(this.server.config.root)) importer = resolve(this.server.config.root, importer) - const mode = (importer && this.getTransformMode(importer)) || 'ssr' + const mode = transformMode ?? ((importer && this.getTransformMode(importer)) || 'ssr') return this.server.pluginContainer.resolveId(id, importer, { ssr: mode === 'ssr' }) } @@ -80,12 +102,12 @@ export class ViteNodeServer { return (ssrTransformResult?.map || null) as unknown as EncodedSourceMap | null } - async fetchModule(id: string): Promise { + async fetchModule(id: string, transformMode?: 'web' | 'ssr'): Promise { id = normalizeModuleId(id) // reuse transform for concurrent requests if (!this.fetchPromiseMap.has(id)) { this.fetchPromiseMap.set(id, - this._fetchModule(id) + this._fetchModule(id, transformMode) .then((r) => { return this.options.sourcemap !== true ? { ...r, map: undefined } : r }) @@ -123,9 +145,20 @@ export class ViteNodeServer { return 'web' } - private async _fetchModule(id: string): Promise { + private async _fetchModule(id: string, transformMode?: 'web' | 'ssr'): Promise { let result: FetchResult + const cacheDir = this.options.deps?.cacheDir + + if (cacheDir && id.includes(cacheDir) && !id.includes(this.server.config.root)) { + id = join(this.server.config.root, id) + const timeout = setTimeout(() => { + throw new Error(`ViteNodeServer: ${id} not found. This is a bug, please report it.`) + }, 5000) // CI can be quite slow + await this.ensureExists(id) + clearTimeout(timeout) + } + const { path: filePath } = toFilePath(id, this.server.config.root) const module = this.server.moduleGraph.getModuleById(id) @@ -143,7 +176,7 @@ export class ViteNodeServer { } else { const start = performance.now() - const r = await this._transformRequest(id) + const r = await this._transformRequest(id, transformMode) duration = performance.now() - start result = { code: r?.code, map: r?.map as any } } @@ -157,7 +190,7 @@ export class ViteNodeServer { return result } - private async _transformRequest(id: string) { + private async _transformRequest(id: string, customTransformMode?: 'web' | 'ssr') { debugRequest(id) let result: TransformResult | null = null @@ -168,7 +201,9 @@ export class ViteNodeServer { return result } - if (this.getTransformMode(id) === 'web') { + const transformMode = customTransformMode ?? this.getTransformMode(id) + + if (transformMode === 'web') { // for components like Vue, we want to use the client side // plugins but then convert the code to be consumed by the server result = await this.server.transformRequest(id) diff --git a/packages/vite-node/src/types.ts b/packages/vite-node/src/types.ts index 98498c3e4739..57298f0ab248 100644 --- a/packages/vite-node/src/types.ts +++ b/packages/vite-node/src/types.ts @@ -8,6 +8,7 @@ export type Arrayable = T | Array export interface DepsHandlingOptions { external?: (string | RegExp)[] inline?: (string | RegExp)[] | true + cacheDir?: string /** * Try to guess the CJS version of a package when it's invalid ESM * @default false diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 2dd8e1e6f9f2..03ed78760d6c 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -155,8 +155,9 @@ export class Vitest { } async typecheck(filters: string[] = []) { + const { dir, root } = this.config const { include, exclude } = this.config.typecheck - const testsFilesList = await this.globFiles(filters, include, exclude) + const testsFilesList = this.filterFiles(await this.globFiles(include, exclude, dir || root), filters) const checker = new Typechecker(this, testsFilesList) this.typechecker = checker checker.onParseEnd(async ({ files, sourceErrors }) => { @@ -606,32 +607,26 @@ export class Vitest { ))) } - async globFiles(filters: string[], include: string[], exclude: string[]) { + async globFiles(include: string[], exclude: string[], cwd: string) { const globOptions: fg.Options = { absolute: true, dot: true, - cwd: this.config.dir || this.config.root, + cwd, ignore: exclude, } - let testFiles = await fg(include, globOptions) - - if (filters.length && process.platform === 'win32') - filters = filters.map(f => toNamespacedPath(f)) - - if (filters.length) - testFiles = testFiles.filter(i => filters.some(f => i.includes(f))) - - return testFiles + return fg(include, globOptions) } - async globTestFiles(filters: string[] = []) { - const { include, exclude, includeSource } = this.config + private _allTestsCache: string[] | null = null + + async globAllTestFiles(config: ResolvedConfig, cwd: string) { + const { include, exclude, includeSource } = config - const testFiles = await this.globFiles(filters, include, exclude) + const testFiles = await this.globFiles(include, exclude, cwd) if (includeSource) { - const files = await this.globFiles(filters, includeSource, exclude) + const files = await this.globFiles(includeSource, exclude, cwd) await Promise.all(files.map(async (file) => { try { @@ -645,9 +640,31 @@ export class Vitest { })) } + this._allTestsCache = testFiles + + return testFiles + } + + filterFiles(testFiles: string[], filters: string[] = []) { + if (filters.length && process.platform === 'win32') + filters = filters.map(f => toNamespacedPath(f)) + + if (filters.length) + return testFiles.filter(i => filters.some(f => i.includes(f))) + return testFiles } + async globTestFiles(filters: string[] = []) { + const { dir, root } = this.config + + const testFiles = this._allTestsCache ?? await this.globAllTestFiles(this.config, dir || root) + + this._allTestsCache = null + + return this.filterFiles(testFiles, filters) + } + async isTargetFile(id: string, source?: string): Promise { const relativeId = relative(this.config.dir || this.config.root, id) if (mm.isMatch(relativeId, this.config.exclude)) diff --git a/packages/vitest/src/node/create.ts b/packages/vitest/src/node/create.ts index 4d64cf1c1901..e254af4456f3 100644 --- a/packages/vitest/src/node/create.ts +++ b/packages/vitest/src/node/create.ts @@ -27,7 +27,8 @@ export async function createVitest(mode: VitestRunMode, options: UserConfig, vit const server = await createServer(mergeConfig(config, mergeConfig(viteOverrides, { root: options.root }))) - if (ctx.config.api?.port) + // optimizer needs .listen() to be called + if (ctx.config.api?.port || ctx.config.deps?.experimentalOptimizer?.enabled) await server.listen() else await server.pluginContainer.buildStart({}) diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index ff525f526fc5..15bf1f619a19 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -33,7 +33,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t options() { this.meta.watchMode = false }, - config(viteConfig: any) { + async config(viteConfig: any) { // preliminary merge of options to be able to create server options for vite // however to allow vitest plugins to modify vitest config values // this is repeated in configResolved where the config is final @@ -131,15 +131,37 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t } if (!options.browser) { - // disable deps optimization - Object.assign(config, { - cacheDir: undefined, - optimizeDeps: { + const optimizeConfig: Partial = {} + const optimizer = preOptions.deps?.experimentalOptimizer + if (!optimizer?.enabled) { + optimizeConfig.cacheDir = undefined + optimizeConfig.optimizeDeps = { // experimental in Vite >2.9.2, entries remains to help with older versions disabled: true, entries: [], - }, - }) + } + } + else { + const entries = await ctx.globAllTestFiles(preOptions as ResolvedConfig, preOptions.dir || getRoot()) + optimizeConfig.cacheDir = preOptions.cache?.dir ?? 'node_modules/.vitest' + optimizeConfig.optimizeDeps = { + ...viteConfig.optimizeDeps, + ...optimizer, + disabled: false, + entries: [...(optimizer.entries || viteConfig.optimizeDeps?.entries || []), ...entries], + exclude: ['vitest', ...(optimizer.exclude || viteConfig.optimizeDeps?.exclude || [])], + include: (optimizer.include || viteConfig.optimizeDeps?.include || []).filter((n: string) => n !== 'vitest'), + } + // Vite throws an error that it cannot rename "deps_temp", but optimization still works + // let's not show this error to users + const { error: logError } = console + console.error = (...args) => { + if (typeof args[0] === 'string' && args[0].includes('/deps_temp')) + return + return logError(...args) + } + } + Object.assign(config, optimizeConfig) } return config diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index a91bf2f684ea..fcc85794a403 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -8,7 +8,7 @@ import { createBirpc } from 'birpc' import type { RawSourceMap } from 'vite-node' import type { ResolvedConfig, WorkerContext, WorkerRPC, WorkerTestEnvironment } from '../types' import { distDir, rootDir } from '../constants' -import { AggregateError, groupBy } from '../utils' +import { AggregateError, getEnvironmentTransformMode, groupBy } from '../utils' import { envsOrder, groupFilesByEnv } from '../utils/test-helpers' import type { Vitest } from './core' @@ -195,11 +195,13 @@ function createChannel(ctx: Vitest) { const r = await ctx.vitenode.transformRequest(id) return r?.map as RawSourceMap | undefined }, - fetch(id) { - return ctx.vitenode.fetchModule(id) + fetch(id, environment) { + const transformMode = getEnvironmentTransformMode(ctx.config, environment) + return ctx.vitenode.fetchModule(id, transformMode) }, - resolveId(id, importer) { - return ctx.vitenode.resolveId(id, importer) + resolveId(id, importer, environment) { + const transformMode = getEnvironmentTransformMode(ctx.config, environment) + return ctx.vitenode.resolveId(id, importer, transformMode) }, onPathsCollected(paths) { ctx.state.collectPaths(paths) diff --git a/packages/vitest/src/runtime/execute.ts b/packages/vitest/src/runtime/execute.ts index de85684fbeee..6f2e271a998d 100644 --- a/packages/vitest/src/runtime/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -43,10 +43,10 @@ export class VitestExecutor extends ViteNodeRunner { return environment === 'node' ? !isNodeBuiltin(id) : !id.startsWith('node:') } - async resolveUrl(id: string, importee?: string) { - if (importee && importee.startsWith('mock:')) - importee = importee.slice(5) - return super.resolveUrl(id, importee) + async resolveUrl(id: string, importer?: string) { + if (importer && importer.startsWith('mock:')) + importer = importer.slice(5) + return super.resolveUrl(id, importer) } async dependencyRequest(id: string, fsPath: string, callstack: string[]): Promise { diff --git a/packages/vitest/src/runtime/loader.ts b/packages/vitest/src/runtime/loader.ts index 421b951a6a3d..12e501cb8087 100644 --- a/packages/vitest/src/runtime/loader.ts +++ b/packages/vitest/src/runtime/loader.ts @@ -50,7 +50,7 @@ export const resolve: Resolver = async (url, context, next) => { const id = normalizeModuleId(url) const importer = normalizeModuleId(parentURL) - const resolved = await resolver(id, importer) + const resolved = await resolver(id, importer, state.ctx.environment.name) let result: ResolveResult let filepath: string diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 7da111c14c80..53e2f731f41b 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -50,10 +50,10 @@ async function startViteNode(ctx: WorkerContext) { const executor = await createVitestExecutor({ fetchModule(id) { - return rpc().fetch(id) + return rpc().fetch(id, ctx.environment.name) }, resolveId(id, importer) { - return rpc().resolveId(id, importer) + return rpc().resolveId(id, importer, ctx.environment.name) }, moduleCache, mockMap, diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index d49e4e27d9b4..c838b80de54d 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -1,4 +1,4 @@ -import type { AliasOptions, CommonServerOptions } from 'vite' +import type { AliasOptions, CommonServerOptions, DepOptimizationConfig } from 'vite' import type { PrettyFormatOptions } from 'pretty-format' import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' import type { BuiltinReporters } from '../node/reporters' @@ -67,6 +67,12 @@ export interface InlineConfig { * Handling for dependencies inlining or externalizing */ deps?: { + /** + * Enable dependency optimization. This can improve the performance of your tests. + */ + experimentalOptimizer?: Omit & { + enabled: boolean + } /** * Externalize means that Vite will bypass the package to native Node. * diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index ec154c08b5bc..fd231ed43020 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -1,6 +1,6 @@ import type { MessagePort } from 'node:worker_threads' import type { File, TaskResultPack, Test } from '@vitest/runner' -import type { FetchFunction, ModuleCacheMap, RawSourceMap, ViteNodeResolveId } from 'vite-node' +import type { FetchResult, ModuleCacheMap, RawSourceMap, ViteNodeResolveId } from 'vite-node' import type { BirpcReturn } from 'birpc' import type { MockMap } from './mocker' import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from './config' @@ -28,8 +28,8 @@ export interface AfterSuiteRunMeta { } export interface WorkerRPC { - fetch: FetchFunction - resolveId: ResolveIdFunction + fetch: (id: string, environment: VitestEnvironment) => Promise + resolveId: (id: string, importer: string | undefined, environment: VitestEnvironment) => Promise getSourceMap: (id: string, force?: boolean) => Promise onFinished: (files: File[], errors?: unknown[]) => void diff --git a/packages/vitest/src/utils/base.ts b/packages/vitest/src/utils/base.ts index a8042d5b0d17..9bbe16341b9b 100644 --- a/packages/vitest/src/utils/base.ts +++ b/packages/vitest/src/utils/base.ts @@ -1,4 +1,4 @@ -import type { Arrayable, DeepMerge, Nullable } from '../types' +import type { Arrayable, DeepMerge, Nullable, ResolvedConfig, VitestEnvironment } from '../types' function isFinalObj(obj: any) { return obj === Object.prototype || obj === Function.prototype || obj === RegExp.prototype @@ -123,3 +123,9 @@ export function stdout(): NodeJS.WriteStream { // eslint-disable-next-line no-console return console._stdout || process.stdout } + +export function getEnvironmentTransformMode(config: ResolvedConfig, environment: VitestEnvironment) { + if (!config.deps?.experimentalOptimizer?.enabled) + return undefined + return environment === 'happy-dom' || environment === 'jsdom' ? 'web' : 'ssr' +} diff --git a/packages/web-worker/src/utils.ts b/packages/web-worker/src/utils.ts index 1ed47dd90b53..9104707e9651 100644 --- a/packages/web-worker/src/utils.ts +++ b/packages/web-worker/src/utils.ts @@ -62,14 +62,14 @@ export function createMessageEvent(data: any, transferOrOptions: StructuredSeria } export function getRunnerOptions() { - const { config, rpc, mockMap, moduleCache } = getWorkerState() + const { config, ctx, rpc, mockMap, moduleCache } = getWorkerState() return { fetchModule(id: string) { - return rpc.fetch(id) + return rpc.fetch(id, ctx.environment.name) }, resolveId(id: string, importer?: string) { - return rpc.resolveId(id, importer) + return rpc.resolveId(id, importer, ctx.environment.name) }, moduleCache, mockMap, diff --git a/tsconfig.json b/tsconfig.json index ec9831421c10..54fbe9d3a339 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ "vitest/node": ["./packages/vitest/src/node/index.ts"], "vitest/config": ["./packages/vitest/src/config.ts"], "vitest/browser": ["./packages/vitest/src/browser.ts"], + "vitest/runners": ["./packages/vitest/src/runners.ts"], "vite-node": ["./packages/vite-node/src/index.ts"], "vite-node/client": ["./packages/vite-node/src/client.ts"], "vite-node/server": ["./packages/vite-node/src/server.ts"], @@ -43,8 +44,8 @@ "exclude": [ "**/dist/**", "./packages/vitest/dist/**", - "./packages/vitest/*.d.ts", - "./packages/vitest/*.d.cts", + "./packages/*/*.d.ts", + "./packages/*/*.d.cts", "./packages/ui/client/**", "./examples/**/*.*", "./bench/**",