Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add an option to enable Vite optimizer #2912

Merged
merged 9 commits into from Feb 25, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/config/index.md
Expand Up @@ -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)[]`
Expand Down
8 changes: 4 additions & 4 deletions packages/vite-node/src/client.ts
Expand Up @@ -208,18 +208,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
Expand Down
4 changes: 4 additions & 0 deletions packages/vite-node/src/externalize.ts
Expand Up @@ -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))
Expand Down
57 changes: 46 additions & 11 deletions 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 { DebuggerOptions, FetchResult, RawSourceMap, ViteNodeResolveId, ViteNodeServerOptions } from './types'
Expand All @@ -16,6 +17,8 @@ export class ViteNodeServer {
private fetchPromiseMap = new Map<string, Promise<FetchResult>>()
private transformPromiseMap = new Map<string, Promise<TransformResult | null | undefined>>()

private existingOptimizedDeps = new Set<string>()

fetchCache = new Map<string, {
duration?: number
timestamp: number
Expand All @@ -33,9 +36,12 @@ export class ViteNodeServer {
// eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error
// @ts-ignore ssr is not typed in Vite 2, but defined in Vite 3, so we can't use expect-error
const ssrOptions = server.config.ssr
if (ssrOptions) {
options.deps ??= {}

options.deps ??= {}

options.deps.cacheDir = relative(server.config.root, server.config.cacheDir)

if (ssrOptions) {
// we don't externalize ssr, because it has different semantics in Vite
// if (ssrOptions.external) {
// options.deps.external ??= []
Expand Down Expand Up @@ -64,10 +70,26 @@ export class ViteNodeServer {
return shouldExternalize(id, this.options.deps, this.externalizeCache)
}

async resolveId(id: string, importer?: string): Promise<ViteNodeResolveId | null> {
private async ensureExists(id: string): Promise<boolean> {
if (this.existingOptimizedDeps.has(id))
return true
if (existsSync(id)) {
this.existingOptimizedDeps.add(id)
return true
}
return new Promise<boolean>((resolve) => {
setTimeout(() => {
this.ensureExists(id).then(() => {
resolve(true)
})
})
})
}

async resolveId(id: string, importer?: string, transformMode?: 'web' | 'ssr'): Promise<ViteNodeResolveId | null> {
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' })
}

Expand All @@ -79,12 +101,12 @@ export class ViteNodeServer {
return (ssrTransformResult?.map || null) as unknown as RawSourceMap | null
}

async fetchModule(id: string): Promise<FetchResult> {
async fetchModule(id: string, transformMode?: 'web' | 'ssr'): Promise<FetchResult> {
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
})
Expand Down Expand Up @@ -122,9 +144,20 @@ export class ViteNodeServer {
return 'web'
}

private async _fetchModule(id: string): Promise<FetchResult> {
private async _fetchModule(id: string, transformMode?: 'web' | 'ssr'): Promise<FetchResult> {
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)
Expand All @@ -142,7 +175,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 unknown as RawSourceMap }
}
Expand All @@ -156,7 +189,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
Expand All @@ -167,7 +200,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)
Expand Down
1 change: 1 addition & 0 deletions packages/vite-node/src/types.ts
Expand Up @@ -7,6 +7,7 @@ export type Arrayable<T> = T | Array<T>
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
Expand Down
49 changes: 33 additions & 16 deletions packages/vitest/src/node/core.ts
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 {
Expand All @@ -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<boolean> {
const relativeId = relative(this.config.dir || this.config.root, id)
if (mm.isMatch(relativeId, this.config.exclude))
Expand Down
3 changes: 2 additions & 1 deletion packages/vitest/src/node/create.ts
Expand Up @@ -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({})
Expand Down
36 changes: 29 additions & 7 deletions packages/vitest/src/node/plugins/index.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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<ViteConfig> = {}
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
Expand Down
12 changes: 7 additions & 5 deletions packages/vitest/src/node/pool.ts
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions packages/vitest/src/runtime/execute.ts
Expand Up @@ -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<any> {
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/runtime/loader.ts
Expand Up @@ -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
Expand Down