diff --git a/docs/api/vi.md b/docs/api/vi.md index 3d534a785cdd..8288d7683b74 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -16,8 +16,8 @@ This section describes the API that you can use when [mocking a module](/guide/m ### vi.mock -- **Type**: `(path: string, factory?: (importOriginal: () => unknown) => unknown) => void` -- **Type**: `(path: Promise, factory?: (importOriginal: () => T) => T | Promise) => void` +- **Type**: `(path: string, factory?: MockOptions | ((importOriginal: () => unknown) => unknown)) => void` +- **Type**: `(path: Promise, factory?: MockOptions | ((importOriginal: () => T) => T | Promise)) => void` Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn't matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can define them inside [`vi.hoisted`](#vi-hoisted) and reference them inside `vi.mock`. @@ -29,11 +29,27 @@ In order to hoist `vi.mock`, Vitest statically analyzes your files. It indicates Vitest will not mock modules that were imported inside a [setup file](/config/#setupfiles) because they are cached by the time a test file is running. You can call [`vi.resetModules()`](#vi-resetmodules) inside [`vi.hoisted`](#vi-hoisted) to clear all module caches before running a test file. ::: -If `factory` is defined, all imports will return its result. Vitest calls factory only once and caches results for all subsequent imports until [`vi.unmock`](#vi-unmock) or [`vi.doUnmock`](#vi-dounmock) is called. +If the `factory` function is defined, all imports will return its result. Vitest calls factory only once and caches results for all subsequent imports until [`vi.unmock`](#vi-unmock) or [`vi.doUnmock`](#vi-dounmock) is called. Unlike in `jest`, the factory can be asynchronous. You can use [`vi.importActual`](#vi-importactual) or a helper with the factory passed in as the first argument, and get the original module inside. -Vitest also supports a module promise instead of a string in the `vi.mock` and `vi.doMock` methods for better IDE support. When the file is moved, the path will be updated, and `importOriginal` also inherits the type automatically. Using this signature will also enforce factory return type to be compatible with the original module (but every export is optional). +Since Vitest 2.1, you can also provide an object with a `spy` property instead of a factory function. If `spy` is `true`, then Vitest will automock the module as usual, but it won't override the implementation of exports. This is useful if you just want to assert that the exported method was called correctly by another method. + +```ts +import { calculator } from './src/calculator.ts' + +vi.mock('./src/calculator.ts', { spy: true }) + +// calls the original implementation, +// but allows asserting the behaviour later +const result = calculator(1, 2) + +expect(result).toBe(3) +expect(calculator).toHaveBeenCalledWith(1, 2) +expect(calculator).toHaveReturned(3) +``` + +Vitest also supports a module promise instead of a string in the `vi.mock` and `vi.doMock` methods for better IDE support. When the file is moved, the path will be updated, and `importOriginal` inherits the type automatically. Using this signature will also enforce factory return type to be compatible with the original module (keeping exports optional). ```ts twoslash // @filename: ./path/to/module.js @@ -103,7 +119,7 @@ vi.mock('./path/to/module.js', () => { ``` ::: -If there is a `__mocks__` folder alongside a file that you are mocking, and the factory is not provided, Vitest will try to find a file with the same name in the `__mocks__` subfolder and use it as an actual module. If you are mocking a dependency, Vitest will try to find a `__mocks__` folder in the [root](/config/#root) of the project (default is `process.cwd()`). You can tell Vitest where the dependencies are located through the [deps.moduleDirectories](/config/#deps-moduledirectories) config option. +If there is a `__mocks__` folder alongside a file that you are mocking, and the factory is not provided, Vitest will try to find a file with the same name in the `__mocks__` subfolder and use it as an actual module. If you are mocking a dependency, Vitest will try to find a `__mocks__` folder in the [root](/config/#root) of the project (default is `process.cwd()`). You can tell Vitest where the dependencies are located through the [`deps.moduleDirectories`](/config/#deps-moduledirectories) config option. For example, you have this file structure: @@ -118,7 +134,7 @@ For example, you have this file structure: - increment.test.js ``` -If you call `vi.mock` in a test file without a factory provided, it will find a file in the `__mocks__` folder to use as a module: +If you call `vi.mock` in a test file without a factory or options provided, it will find a file in the `__mocks__` folder to use as a module: ```ts // increment.test.js @@ -144,8 +160,8 @@ If there is no `__mocks__` folder or a factory provided, Vitest will import the ### vi.doMock -- **Type**: `(path: string, factory?: (importOriginal: () => unknown) => unknown) => void` -- **Type**: `(path: Promise, factory?: (importOriginal: () => T) => T | Promise) => void` +- **Type**: `(path: string, factory?: MockOptions | ((importOriginal: () => unknown) => unknown)) => void` +- **Type**: `(path: Promise, factory?: MockOptions | ((importOriginal: () => T) => T | Promise)) => void` The same as [`vi.mock`](#vi-mock), but it's not hoisted to the top of the file, so you can reference variables in the global file scope. The next [dynamic import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) of the module will be mocked. @@ -418,6 +434,23 @@ console.log(cart.getApples()) // still 42! ``` ::: +::: tip +It is not possible to spy on a specific exported method in [Browser Mode](/guide/browser/). Instead, you can spy on every exported method by calling `vi.mock("./file-path.js", { spy: true })`. This will mock every export but keep its implementation intact, allowing you to assert if the method was called correctly. + +```ts +import { calculator } from './src/calculator.ts' + +vi.mock('./src/calculator.ts', { spy: true }) + +calculator(1, 2) + +expect(calculator).toHaveBeenCalledWith(1, 2) +expect(calculator).toHaveReturned(3) +``` + +And while it is possible to spy on exports in `jsdom` or other Node.js environments, this might change in the future. +::: + ### vi.stubEnv {#vi-stubenv} - **Type:** `(name: string, value: string) => Vitest` diff --git a/eslint.config.js b/eslint.config.js index 9e6d32aedb35..e652806e9017 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -97,6 +97,7 @@ export default antfu( { files: [ `docs/${GLOB_SRC}`, + `**/*.md`, ], rules: { 'style/max-statements-per-line': 'off', diff --git a/packages/browser/package.json b/packages/browser/package.json index 41880766cea6..837a93ba57ba 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -95,6 +95,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.4.8", "@types/ws": "^8.5.12", + "@vitest/mocker": "workspace:*", "@vitest/runner": "workspace:*", "@vitest/ui": "workspace:*", "@vitest/ws-client": "workspace:*", diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts index 952dba0a6528..1eb9d9ca9228 100644 --- a/packages/browser/src/client/channel.ts +++ b/packages/browser/src/client/channel.ts @@ -1,4 +1,5 @@ import type { CancelReason } from '@vitest/runner' +import type { MockedModuleSerialized } from '@vitest/mocker' import { getBrowserState } from './utils' export interface IframeDoneEvent { @@ -24,13 +25,12 @@ export interface IframeViewportEvent { export interface IframeMockEvent { type: 'mock' - paths: string[] - mock: string | undefined | null + module: MockedModuleSerialized } export interface IframeUnmockEvent { type: 'unmock' - paths: string[] + url: string } export interface IframeMockingDoneEvent { diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 77feea937143..10d4c0bf4f73 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -5,7 +5,7 @@ import { relative } from 'pathe' import type { SerializedConfig } from 'vitest' import { getBrowserState, getConfig } from './utils' import { getUiAPI } from './ui' -import { createModuleMocker } from './tester/msw' +import { createModuleMockerInterceptor } from './tester/msw' const url = new URL(location.href) const ID_ALL = '__vitest_all__' @@ -13,7 +13,7 @@ const ID_ALL = '__vitest_all__' class IframeOrchestrator { private cancelled = false private runningFiles = new Set() - private mocker = createModuleMocker() + private interceptor = createModuleMockerInterceptor() private iframes = new Map() public async init() { @@ -187,13 +187,13 @@ class IframeOrchestrator { break } case 'mock:invalidate': - this.mocker.invalidate() + this.interceptor.invalidate() break case 'unmock': - await this.mocker.unmock(e.data) + await this.interceptor.delete(e.data.url) break case 'mock': - await this.mocker.mock(e.data) + await this.interceptor.register(e.data.module) break case 'mock-factory:error': case 'mock-factory:response': diff --git a/packages/browser/src/client/public/esm-client-injector.js b/packages/browser/src/client/public/esm-client-injector.js index bed5a31cdeb4..27afb1bb9b06 100644 --- a/packages/browser/src/client/public/esm-client-injector.js +++ b/packages/browser/src/client/public/esm-client-injector.js @@ -17,6 +17,7 @@ function wrapModule(module) { window.__vitest_browser_runner__ = { wrapModule, + wrapDynamicImport: wrapModule, moduleCache, config: { __VITEST_CONFIG__ }, viteConfig: { __VITEST_VITE_CONFIG__ }, diff --git a/packages/browser/src/client/tester/mocker.ts b/packages/browser/src/client/tester/mocker.ts index 63d9e706e993..cc3f63bde9f4 100644 --- a/packages/browser/src/client/tester/mocker.ts +++ b/packages/browser/src/client/tester/mocker.ts @@ -1,32 +1,16 @@ -import { getType } from '@vitest/utils' -import { extname, join } from 'pathe' import type { IframeChannelOutgoingEvent } from '@vitest/browser/client' -import { channel, waitForChannel } from '@vitest/browser/client' -import { getBrowserState, importId } from '../utils' -import { rpc } from './rpc' - -const now = Date.now - -interface SpyModule { - spyOn: typeof import('vitest').vi['spyOn'] -} - -export class VitestBrowserClientMocker { - private queue = new Set>() - private mocks: Record = {} - private mockObjects: Record = {} - private factories: Record any> = {} - private ids = new Set() - - private spyModule!: SpyModule +import { channel } from '@vitest/browser/client' +import { ModuleMocker } from '@vitest/mocker/browser' +import { getBrowserState } from '../utils' +export class VitestBrowserClientMocker extends ModuleMocker { setupWorker() { channel.addEventListener( 'message', async (e: MessageEvent) => { if (e.data.type === 'mock-factory:request') { try { - const module = await this.resolve(e.data.id) + const module = await this.resolveFactoryModule(e.data.id) const exports = Object.keys(module) channel.postMessage({ type: 'mock-factory:response', @@ -34,12 +18,13 @@ export class VitestBrowserClientMocker { }) } catch (err: any) { - const { processError } = (await importId( - 'vitest/browser', - )) as typeof import('vitest/browser') channel.postMessage({ type: 'mock-factory:error', - error: processError(err), + error: { + name: err.name, + message: err.message, + stack: err.stack, + }, }) } } @@ -47,421 +32,12 @@ export class VitestBrowserClientMocker { ) } - public setSpyModule(mod: SpyModule) { - this.spyModule = mod - } - - public async importActual(id: string, importer: string) { - const resolved = await rpc().resolveId(id, importer) - if (resolved == null) { - throw new Error( - `[vitest] Cannot resolve ${id} imported from ${importer}`, - ) - } - const ext = extname(resolved.id) - const url = new URL(resolved.url, location.href) - const query = `_vitest_original&ext${ext}` - const actualUrl = `${url.pathname}${ - url.search ? `${url.search}&${query}` : `?${query}` - }${url.hash}` - return getBrowserState().wrapModule(() => import(/* @vite-ignore */ actualUrl)).then((mod) => { - if (!resolved.optimized || typeof mod.default === 'undefined') { - return mod - } - // vite injects this helper for optimized modules, so we try to follow the same behavior - const m = mod.default - return m?.__esModule ? m : { ...((typeof m === 'object' && !Array.isArray(m)) || typeof m === 'function' ? m : {}), default: m } - }) - } - - public async importMock(rawId: string, importer: string) { - await this.prepare() - const { resolvedId, type, mockPath } = await rpc().resolveMock( - rawId, - importer, - false, - ) - - const factoryReturn = this.get(resolvedId) - if (factoryReturn) { - return factoryReturn - } - - if (this.factories[resolvedId]) { - return await this.resolve(resolvedId) - } - - if (type === 'redirect') { - const url = new URL(`/@id/${mockPath}`, location.href) - return import(/* @vite-ignore */ url.toString()) - } - const url = new URL(`/@id/${resolvedId}`, location.href) - const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}` - const moduleObject = await import(/* @vite-ignore */ `${url.pathname}${query}${url.hash}`) - return this.mockObject(moduleObject) - } - + // default "vi" utility tries to access mock context to avoid circular dependencies public getMockContext() { return { callstack: null } } - public get(id: string) { - return this.mockObjects[id] - } - - public async invalidate() { - const ids = Array.from(this.ids) - if (!ids.length) { - return - } - await rpc().invalidate(ids) - channel.postMessage({ type: 'mock:invalidate' }) - this.ids.clear() - this.mocks = {} - this.mockObjects = {} - this.factories = {} - } - - public async resolve(id: string) { - const factory = this.factories[id] - if (!factory) { - throw new Error(`Cannot resolve ${id} mock: no factory provided`) - } - try { - this.mockObjects[id] = await factory() - return this.mockObjects[id] - } - catch (err) { - const vitestError = new Error( - '[vitest] There was an error when mocking a module. ' - + 'If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. ' - + 'Read more: https://vitest.dev/api/vi.html#vi-mock', - ) - vitestError.cause = err - throw vitestError - } - } - - public queueMock(id: string, importer: string, factory?: () => any) { - const promise = rpc() - .resolveMock(id, importer, !!factory) - .then(async ({ mockPath, resolvedId, needsInterop }) => { - this.ids.add(resolvedId) - const urlPaths = resolveMockPaths(cleanVersion(resolvedId)) - const resolvedMock - = typeof mockPath === 'string' - ? new URL(resolvedMockedPath(cleanVersion(mockPath)), location.href).toString() - : mockPath - const _factory = factory && needsInterop - ? async () => { - const data = await factory() - return { default: data } - } - : factory - urlPaths.forEach((url) => { - this.mocks[url] = resolvedMock - if (_factory) { - this.factories[url] = _factory - } - }) - channel.postMessage({ - type: 'mock', - paths: urlPaths, - mock: resolvedMock, - }) - await waitForChannel('mock:done') - }) - .finally(() => { - this.queue.delete(promise) - }) - this.queue.add(promise) - } - - public queueUnmock(id: string, importer: string) { - const promise = rpc() - .resolveId(id, importer) - .then(async (resolved) => { - if (!resolved) { - return - } - this.ids.delete(resolved.id) - const urlPaths = resolveMockPaths(cleanVersion(resolved.id)) - urlPaths.forEach((url) => { - delete this.mocks[url] - delete this.factories[url] - delete this.mockObjects[url] - }) - channel.postMessage({ - type: 'unmock', - paths: urlPaths, - }) - await waitForChannel('unmock:done') - }) - .finally(() => { - this.queue.delete(promise) - }) - this.queue.add(promise) - } - - public async prepare() { - if (!this.queue.size) { - return - } - await Promise.all([...this.queue.values()]) + public override wrapDynamicImport(moduleFactory: () => Promise): Promise { + return getBrowserState().wrapModule(moduleFactory) } - - // TODO: move this logic into a util(?) - public mockObject( - object: Record, - mockExports: Record = {}, - ) { - const finalizers = new Array<() => void>() - const refs = new RefTracker() - - const define = (container: Record, key: Key, value: any) => { - try { - container[key] = value - return true - } - catch { - return false - } - } - - const mockPropertiesOf = ( - container: Record, - newContainer: Record, - ) => { - const containerType = /* #__PURE__ */ getType(container) - const isModule = containerType === 'Module' || !!container.__esModule - for (const { key: property, descriptor } of getAllMockableProperties( - container, - isModule, - )) { - // Modules define their exports as getters. We want to process those. - if (!isModule && descriptor.get) { - try { - Object.defineProperty(newContainer, property, descriptor) - } - catch { - // Ignore errors, just move on to the next prop. - } - continue - } - - // Skip special read-only props, we don't want to mess with those. - if (isSpecialProp(property, containerType)) { - continue - } - - const value = container[property] - - // Special handling of references we've seen before to prevent infinite - // recursion in circular objects. - const refId = refs.getId(value) - if (refId !== undefined) { - finalizers.push(() => - define(newContainer, property, refs.getMockedValue(refId)), - ) - continue - } - - const type = /* #__PURE__ */ getType(value) - - if (Array.isArray(value)) { - define(newContainer, property, []) - continue - } - - const isFunction - = type.includes('Function') && typeof value === 'function' - if ( - (!isFunction || value.__isMockFunction) - && type !== 'Object' - && type !== 'Module' - ) { - define(newContainer, property, value) - continue - } - - // Sometimes this assignment fails for some unknown reason. If it does, - // just move along. - if (!define(newContainer, property, isFunction ? value : {})) { - continue - } - - if (isFunction) { - const spyModule = this.spyModule - if (!spyModule) { - throw new Error( - '[vitest] `spyModule` is not defined. This is Vitest error. Please open a new issue with reproduction.', - ) - } - function mockFunction(this: any) { - // detect constructor call and mock each instance's methods - // so that mock states between prototype/instances don't affect each other - // (jest reference https://github.com/jestjs/jest/blob/2c3d2409879952157433de215ae0eee5188a4384/packages/jest-mock/src/index.ts#L678-L691) - if (this instanceof newContainer[property]) { - for (const { key, descriptor } of getAllMockableProperties( - this, - false, - )) { - // skip getter since it's not mocked on prototype as well - if (descriptor.get) { - continue - } - - const value = this[key] - const type = /* #__PURE__ */ getType(value) - const isFunction - = type.includes('Function') && typeof value === 'function' - if (isFunction) { - // mock and delegate calls to original prototype method, which should be also mocked already - const original = this[key] - const mock = spyModule - .spyOn(this, key as string) - .mockImplementation(original) - mock.mockRestore = () => { - mock.mockReset() - mock.mockImplementation(original) - return mock - } - } - } - } - } - const mock = spyModule - .spyOn(newContainer, property) - .mockImplementation(mockFunction) - mock.mockRestore = () => { - mock.mockReset() - mock.mockImplementation(mockFunction) - return mock - } - // tinyspy retains length, but jest doesn't. - Object.defineProperty(newContainer[property], 'length', { value: 0 }) - } - - refs.track(value, newContainer[property]) - mockPropertiesOf(value, newContainer[property]) - } - } - - const mockedObject: Record = mockExports - mockPropertiesOf(object, mockedObject) - - // Plug together refs - for (const finalizer of finalizers) { - finalizer() - } - - return mockedObject - } -} - -function isSpecialProp(prop: Key, parentType: string) { - return ( - parentType.includes('Function') - && typeof prop === 'string' - && ['arguments', 'callee', 'caller', 'length', 'name'].includes(prop) - ) -} - -class RefTracker { - private idMap = new Map() - private mockedValueMap = new Map() - - public getId(value: any) { - return this.idMap.get(value) - } - - public getMockedValue(id: number) { - return this.mockedValueMap.get(id) - } - - public track(originalValue: any, mockedValue: any): number { - const newId = this.idMap.size - this.idMap.set(originalValue, newId) - this.mockedValueMap.set(newId, mockedValue) - return newId - } -} - -type Key = string | symbol - -function getAllMockableProperties(obj: any, isModule: boolean) { - const allProps = new Map< - string | symbol, - { key: string | symbol; descriptor: PropertyDescriptor } - >() - let curr = obj - do { - // we don't need properties from these - if ( - curr === Object.prototype - || curr === Function.prototype - || curr === RegExp.prototype - ) { - break - } - - collectOwnProperties(curr, (key) => { - const descriptor = Object.getOwnPropertyDescriptor(curr, key) - if (descriptor) { - allProps.set(key, { key, descriptor }) - } - }) - // eslint-disable-next-line no-cond-assign - } while ((curr = Object.getPrototypeOf(curr))) - // default is not specified in ownKeys, if module is interoped - if (isModule && !allProps.has('default') && 'default' in obj) { - const descriptor = Object.getOwnPropertyDescriptor(obj, 'default') - if (descriptor) { - allProps.set('default', { key: 'default', descriptor }) - } - } - return Array.from(allProps.values()) -} - -function collectOwnProperties( - obj: any, - collector: Set | ((key: string | symbol) => void), -) { - const collect - = typeof collector === 'function' - ? collector - : (key: string | symbol) => collector.add(key) - Object.getOwnPropertyNames(obj).forEach(collect) - Object.getOwnPropertySymbols(obj).forEach(collect) -} - -function resolvedMockedPath(path: string) { - const config = getBrowserState().viteConfig - if (path.startsWith(config.root)) { - return path.slice(config.root.length) - } - return path -} - -// TODO: check _base_ path -function resolveMockPaths(path: string) { - const config = getBrowserState().viteConfig - const fsRoot = join('/@fs/', config.root) - const paths = [path, join('/@fs/', path)] - - // URL can be /file/path.js, but path is resolved to /file/path - if (path.startsWith(config.root)) { - paths.push(path.slice(config.root.length)) - } - - if (path.startsWith(fsRoot)) { - paths.push(path.slice(fsRoot.length)) - } - - return paths -} - -const versionRegexp = /(\?|&)v=\w{8}/ -function cleanVersion(url: string) { - return url.replace(versionRegexp, '') } diff --git a/packages/browser/src/client/tester/msw.ts b/packages/browser/src/client/tester/msw.ts index 0897ef998e05..997ae2ba4a7e 100644 --- a/packages/browser/src/client/tester/msw.ts +++ b/packages/browser/src/client/tester/msw.ts @@ -1,97 +1,44 @@ import { channel } from '@vitest/browser/client' import type { IframeChannelEvent, - IframeMockEvent, IframeMockingDoneEvent, - IframeUnmockEvent, } from '@vitest/browser/client' +import type { MockedModuleSerialized } from '@vitest/mocker' +import { ManualMockedModule } from '@vitest/mocker' +import { ModuleMockerMSWInterceptor } from '@vitest/mocker/browser' -export function createModuleMocker() { - const mocks: Map = new Map() - - let started = false - let startPromise: undefined | Promise - - async function init() { - if (started) { - return +export class VitestBrowserModuleMockerInterceptor extends ModuleMockerMSWInterceptor { + override async register(event: MockedModuleSerialized): Promise { + if (event.type === 'manual') { + const module = ManualMockedModule.fromJSON(event, async () => { + const keys = await getFactoryExports(event.url) + return Object.fromEntries(keys.map(key => [key, null])) + }) + await super.register(module) } - if (startPromise) { - return startPromise + else { + await this.init() + this.mocks.register(event) } - startPromise = Promise.all([ - import('msw/browser'), - import('msw/core/http'), - ]).then(([{ setupWorker }, { http }]) => { - const worker = setupWorker( - http.get(/.+/, async ({ request }) => { - const path = cleanQuery(request.url.slice(location.origin.length)) - if (!mocks.has(path)) { - if (path.includes('/deps/')) { - return fetch(bypass(request)) - } - - return passthrough() - } - - const mock = mocks.get(path) - - // using a factory - if (mock === undefined) { - const exports = await getFactoryExports(path) - const module = `const module = __vitest_mocker__.get('${path}');` - const keys = exports - .map((name) => { - if (name === 'default') { - return `export default module['default'];` - } - return `export const ${name} = module['${name}'];` - }) - .join('\n') - const text = `${module}\n${keys}` - return new Response(text, { - headers: { - 'Content-Type': 'application/javascript', - }, - }) - } - - if (typeof mock === 'string') { - return Response.redirect(mock) - } + channel.postMessage({ type: 'mock:done' }) + } - return Response.redirect(injectQuery(path, 'mock=auto')) - }), - ) - return worker.start({ - serviceWorker: { - url: '/__vitest_msw__', - }, - quiet: true, - }) - }) - .finally(() => { - started = true - startPromise = undefined - }) - await startPromise + override async delete(url: string): Promise { + await super.delete(url) + channel.postMessage({ type: 'unmock:done' }) } +} - return { - async mock(event: IframeMockEvent) { - await init() - event.paths.forEach(path => mocks.set(path, event.mock)) - channel.postMessage({ type: 'mock:done' }) - }, - async unmock(event: IframeUnmockEvent) { - await init() - event.paths.forEach(path => mocks.delete(path)) - channel.postMessage({ type: 'unmock:done' }) - }, - invalidate() { - mocks.clear() +export function createModuleMockerInterceptor() { + return new VitestBrowserModuleMockerInterceptor({ + globalThisAccessor: '"__vitest_mocker__"', + mswOptions: { + serviceWorker: { + url: '/__vitest_msw__', + }, + quiet: true, }, - } + }) } function getFactoryExports(id: string) { @@ -115,53 +62,3 @@ function getFactoryExports(id: string) { ) }) } - -const timestampRegexp = /(\?|&)t=\d{13}/ -const versionRegexp = /(\?|&)v=\w{8}/ -function cleanQuery(url: string) { - return url.replace(timestampRegexp, '').replace(versionRegexp, '') -} - -function passthrough() { - return new Response(null, { - status: 302, - statusText: 'Passthrough', - headers: { - 'x-msw-intention': 'passthrough', - }, - }) -} - -function bypass(request: Request) { - const clonedRequest = request.clone() - clonedRequest.headers.set('x-msw-intention', 'bypass') - const cacheControl = clonedRequest.headers.get('cache-control') - if (cacheControl) { - clonedRequest.headers.set( - 'cache-control', - // allow reinvalidation of the cache so mocks can be updated - cacheControl.replace(', immutable', ''), - ) - } - return clonedRequest -} - -const postfixRE = /[?#].*$/ -function cleanUrl(url: string): string { - return url.replace(postfixRE, '') -} - -const replacePercentageRE = /%/g -function injectQuery(url: string, queryToInject: string): string { - // encode percents for consistent behavior with pathToFileURL - // see #2614 for details - const resolvedUrl = new URL( - url.replace(replacePercentageRE, '%25'), - location.href, - ) - const { search, hash } = resolvedUrl - const pathname = cleanUrl(url) - return `${pathname}?${queryToInject}${search ? `&${search.slice(1)}` : ''}${ - hash ?? '' - }` -} diff --git a/packages/browser/src/client/tester/state.ts b/packages/browser/src/client/tester/state.ts index 80d32bd6b2f6..19266798a911 100644 --- a/packages/browser/src/client/tester/state.ts +++ b/packages/browser/src/client/tester/state.ts @@ -24,7 +24,6 @@ const state: WorkerGlobalState = { invalidates: [], }, onCancel: null as any, - mockMap: new Map(), config, environment: { name: 'browser', diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index abc46387ef6d..4a309db077fc 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -1,6 +1,7 @@ import { SpyModule, collectTests, setupCommonEnv, startCoverageInsideWorker, startTests, stopCoverageInsideWorker } from 'vitest/browser' import { page } from '@vitest/browser/context' -import { channel, client, onCancel } from '@vitest/browser/client' +import type { IframeMockEvent, IframeMockInvalidateEvent, IframeUnmockEvent } from '@vitest/browser/client' +import { channel, client, onCancel, waitForChannel } from '@vitest/browser/client' import { executor, getBrowserState, getConfig, getWorkerState } from '../utils' import { setupDialogsSpy } from './dialog' import { setupConsoleLogSpy } from './logger' @@ -33,7 +34,34 @@ async function prepareTestEnvironment(files: string[]) { state.onCancel = onCancel state.rpc = rpc as any - const mocker = new VitestBrowserClientMocker() + const mocker = new VitestBrowserClientMocker( + { + async delete(url: string) { + channel.postMessage({ + type: 'unmock', + url, + } satisfies IframeUnmockEvent) + await waitForChannel('unmock:done') + }, + async register(module) { + channel.postMessage({ + type: 'mock', + module: module.toJSON(), + } satisfies IframeMockEvent) + await waitForChannel('mock:done') + }, + invalidate() { + channel.postMessage({ + type: 'mock:invalidate', + } satisfies IframeMockInvalidateEvent) + }, + }, + rpc, + SpyModule.spyOn, + { + root: getBrowserState().viteConfig.root, + }, + ) // @ts-expect-error mocking vitest apis globalThis.__vitest_mocker__ = mocker @@ -51,7 +79,6 @@ async function prepareTestEnvironment(files: string[]) { } }) - mocker.setSpyModule(SpyModule) mocker.setupWorker() onCancel.then((reason) => { diff --git a/packages/browser/src/node/esmInjector.ts b/packages/browser/src/node/esmInjector.ts deleted file mode 100644 index 554b70029540..000000000000 --- a/packages/browser/src/node/esmInjector.ts +++ /dev/null @@ -1,46 +0,0 @@ -import MagicString from 'magic-string' -import type { PluginContext } from 'rollup' -import { esmWalker } from '@vitest/utils/ast' -import type { Expression, Positioned } from '@vitest/utils/ast' - -export function injectDynamicImport( - code: string, - id: string, - parse: PluginContext['parse'], -) { - const s = new MagicString(code) - - let ast: any - try { - ast = parse(code) - } - catch (err) { - console.error(`Cannot parse ${id}:\n${(err as any).message}`) - return - } - - // 3. convert references to import bindings & import.meta references - esmWalker(ast, { - // TODO: make env updatable - onImportMeta() { - // s.update(node.start, node.end, viImportMetaKey) - }, - onDynamicImport(node) { - const replaceString = '__vitest_browser_runner__.wrapModule(() => import(' - const importSubstring = code.substring(node.start, node.end) - const hasIgnore = importSubstring.includes('/* @vite-ignore */') - s.overwrite( - node.start, - (node.source as Positioned).start, - replaceString + (hasIgnore ? '/* @vite-ignore */ ' : ''), - ) - s.overwrite(node.end - 1, node.end, '))') - }, - }) - - return { - ast, - code: s.toString(), - map: s.generateMap({ hires: 'boundary', source: id }), - } -} diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index dc0b8f096403..f2516aca04fd 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -9,8 +9,8 @@ import { getFilePoolName, resolveApiServerConfig, resolveFsAllow, distDir as vit import { type Plugin, coverageConfigDefaults } from 'vitest/config' import { toArray } from '@vitest/utils' import { defaultBrowserPort } from 'vitest/config' +import { dynamicImportPlugin } from '@vitest/mocker/node' import BrowserContext from './plugins/pluginContext' -import DynamicImport from './plugins/pluginDynamicImport' import type { BrowserServer } from './server' import { resolveOrchestrator } from './serverOrchestrator' import { resolveTester } from './serverTester' @@ -306,7 +306,9 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { }, }, BrowserContext(browserServer), - DynamicImport(), + dynamicImportPlugin({ + globalThisAccessor: '"__vitest_browser_runner__"', + }), { name: 'vitest:browser:config', enforce: 'post', diff --git a/packages/browser/src/node/plugins/pluginDynamicImport.ts b/packages/browser/src/node/plugins/pluginDynamicImport.ts deleted file mode 100644 index 5a36adc05b9a..000000000000 --- a/packages/browser/src/node/plugins/pluginDynamicImport.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Plugin } from 'vitest/config' -import { injectDynamicImport } from '../esmInjector' - -const regexDynamicImport = /import\s*\(/ - -export default (): Plugin => { - return { - name: 'vitest:browser:esm-injector', - enforce: 'post', - transform(source, id) { - // TODO: test is not called for static imports - if (!regexDynamicImport.test(source)) { - return - } - return injectDynamicImport(source, id, this.parse) - }, - } -} diff --git a/packages/browser/src/node/resolveMock.ts b/packages/browser/src/node/resolveMock.ts deleted file mode 100644 index fc41c8ebf894..000000000000 --- a/packages/browser/src/node/resolveMock.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { builtinModules } from 'node:module' -import { basename, dirname, extname, isAbsolute, join, resolve } from 'pathe' -import type { PartialResolvedId } from 'rollup' -import type { ResolvedConfig, WorkspaceProject } from 'vitest/node' -import type { ResolvedConfig as ViteConfig } from 'vite' - -export async function resolveMock( - project: WorkspaceProject, - rawId: string, - importer: string, - hasFactory: boolean, -) { - const { id, fsPath, external } = await resolveId(project, rawId, importer) - - if (hasFactory) { - const needsInteropMap = viteDepsInteropMap(project.browser!.vite.config) - const needsInterop = needsInteropMap?.get(fsPath) ?? false - return { type: 'factory' as const, resolvedId: id, needsInterop } - } - - const mockPath = resolveMockPath(project.config.root, fsPath, external) - - return { - type: mockPath === null ? ('automock' as const) : ('redirect' as const), - mockPath, - resolvedId: id, - } -} - -async function resolveId(project: WorkspaceProject, rawId: string, importer: string) { - const resolved = await project.browser!.vite.pluginContainer.resolveId( - rawId, - importer, - { - ssr: false, - }, - ) - return resolveModule(project, rawId, resolved) -} - -async function resolveModule(project: WorkspaceProject, rawId: string, resolved: PartialResolvedId | null) { - const id = resolved?.id || rawId - const external - = !isAbsolute(id) || isModuleDirectory(project.config, id) ? rawId : null - return { - id, - fsPath: cleanUrl(id), - external, - } -} - -function isModuleDirectory(config: ResolvedConfig, path: string) { - const moduleDirectories = config.server.deps?.moduleDirectories || [ - '/node_modules/', - ] - return moduleDirectories.some((dir: string) => path.includes(dir)) -} - -function resolveMockPath(root: string, mockPath: string, external: string | null) { - const path = external || mockPath - - // it's a node_module alias - // all mocks should be inside /__mocks__ - if (external || isNodeBuiltin(mockPath) || !existsSync(mockPath)) { - const mockDirname = dirname(path) // for nested mocks: @vueuse/integration/useJwt - const mockFolder = join( - root, - '__mocks__', - mockDirname, - ) - - if (!existsSync(mockFolder)) { - return null - } - - const files = readdirSync(mockFolder) - const baseOriginal = basename(path) - - for (const file of files) { - const baseFile = basename(file, extname(file)) - if (baseFile === baseOriginal) { - return resolve(mockFolder, file) - } - } - - return null - } - - const dir = dirname(path) - const baseId = basename(path) - const fullPath = resolve(dir, '__mocks__', baseId) - return existsSync(fullPath) ? fullPath : null -} - -const prefixedBuiltins = new Set(['node:test']) - -const builtins = new Set([ - ...builtinModules, - 'assert/strict', - 'diagnostics_channel', - 'dns/promises', - 'fs/promises', - 'path/posix', - 'path/win32', - 'readline/promises', - 'stream/consumers', - 'stream/promises', - 'stream/web', - 'timers/promises', - 'util/types', - 'wasi', -]) - -const NODE_BUILTIN_NAMESPACE = 'node:' -export function isNodeBuiltin(id: string): boolean { - if (prefixedBuiltins.has(id)) { - return true - } - return builtins.has( - id.startsWith(NODE_BUILTIN_NAMESPACE) - ? id.slice(NODE_BUILTIN_NAMESPACE.length) - : id, - ) -} - -const postfixRE = /[?#].*$/ -export function cleanUrl(url: string): string { - return url.replace(postfixRE, '') -} - -const metadata = new WeakMap>() - -function viteDepsInteropMap(config: ViteConfig) { - if (metadata.has(config)) { - return metadata.get(config)! - } - const cacheDirPath = getDepsCacheDir(config) - const metadataPath = resolve(cacheDirPath, '_metadata.json') - if (!existsSync(metadataPath)) { - return null - } - const { optimized } = JSON.parse(readFileSync(metadataPath, 'utf-8')) - const needsInteropMap = new Map() - for (const name in optimized) { - const dep = optimized[name] - const file = resolve(cacheDirPath, dep.file) - needsInteropMap.set(file, dep.needsInterop) - } - metadata.set(config, needsInteropMap) - return needsInteropMap -} - -function getDepsCacheDir(config: ViteConfig): string { - return resolve(config.cacheDir, 'deps') -} diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 5c780857b0a2..219b9bd22f43 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -1,5 +1,5 @@ import { existsSync, promises as fs } from 'node:fs' -import { dirname, isAbsolute, join } from 'pathe' +import { dirname } from 'pathe' import { createBirpc } from 'birpc' import { parse, stringify } from 'flatted' import type { WebSocket } from 'ws' @@ -7,18 +7,15 @@ import { WebSocketServer } from 'ws' import type { BrowserCommandContext } from 'vitest/node' import { createDebugger, isFileServingAllowed } from 'vitest/node' import type { ErrorWithDiff } from 'vitest' +import { ServerMockResolver } from '@vitest/mocker/node' import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' import type { BrowserServer } from './server' -import { cleanUrl, resolveMock } from './resolveMock' const debug = createDebugger('vitest:browser:api') const BROWSER_API_PATH = '/__vitest_browser_api__' -const VALID_ID_PREFIX = '/@id/' -export function setupBrowserRpc( - server: BrowserServer, -) { +export function setupBrowserRpc(server: BrowserServer) { const project = server.project const vite = server.vite const ctx = project.ctx @@ -65,6 +62,10 @@ export function setupBrowserRpc( } function setupClient(sessionId: string, ws: WebSocket) { + const mockResolver = new ServerMockResolver(server.vite, { + moduleDirectories: project.config.server?.deps?.moduleDirectories, + }) + const rpc = createBirpc( { async onUnhandledError(error, type) { @@ -124,44 +125,7 @@ export function setupBrowserRpc( ctx.cancelCurrentRun(reason) }, async resolveId(id, importer) { - const resolved = await vite.pluginContainer.resolveId( - id, - importer, - { - ssr: false, - }, - ) - if (!resolved) { - return null - } - const isOptimized = resolved.id.startsWith(withTrailingSlash(vite.config.cacheDir)) - let url: string - // normalise the URL to be acceptible by the browser - // https://github.com/vitejs/vite/blob/e833edf026d495609558fd4fb471cf46809dc369/packages/vite/src/node/plugins/importAnalysis.ts#L335 - const root = vite.config.root - if (resolved.id.startsWith(withTrailingSlash(root))) { - url = resolved.id.slice(root.length) - } - else if ( - resolved.id !== '/@react-refresh' - && isAbsolute(resolved.id) - && existsSync(cleanUrl(resolved.id)) - ) { - url = join('/@fs/', resolved.id) - } - else { - url = resolved.id - } - if (url[0] !== '.' && url[0] !== '/') { - url = id.startsWith(VALID_ID_PREFIX) - ? id - : VALID_ID_PREFIX + id.replace('\0', '__x00__') - } - return { - id: resolved.id, - url, - optimized: isOptimized, - } + return mockResolver.resolveId(id, importer) }, debug(...args) { ctx.logger.console.debug(...args) @@ -206,17 +170,11 @@ export function setupBrowserRpc( debug?.('[%s] Finishing browser tests for context', contextId) return server.state.getContext(contextId)?.resolve() }, - resolveMock(rawId, importer, hasFactory) { - return resolveMock(project, rawId, importer, hasFactory) + resolveMock(rawId, importer, options) { + return mockResolver.resolveMock(rawId, importer, options) }, invalidate(ids) { - ids.forEach((id) => { - const moduleGraph = server.vite.moduleGraph - const module = moduleGraph.getModuleById(id) - if (module) { - moduleGraph.invalidateModule(module, new Set(), Date.now(), true) - } - }) + return mockResolver.invalidate(ids) }, // CDP @@ -278,11 +236,3 @@ export function stringifyReplace(key: string, value: any) { return value } } - -function withTrailingSlash(path: string): string { - if (path[path.length - 1] !== '/') { - return `${path}/` - } - - return path -} diff --git a/packages/browser/src/node/types.ts b/packages/browser/src/node/types.ts index 8f783cdc34fe..91ed6c8c605d 100644 --- a/packages/browser/src/node/types.ts +++ b/packages/browser/src/node/types.ts @@ -1,11 +1,12 @@ +import type { ServerIdResolution, ServerMockResolution } from '@vitest/mocker/node' import type { BirpcReturn } from 'birpc' -import type { AfterSuiteRunMeta, CancelReason, File, Reporter, SnapshotResult, TaskResultPack, UserConsoleLog } from 'vitest' +import type { AfterSuiteRunMeta, CancelReason, Reporter, RunnerTestFile, SnapshotResult, TaskResultPack, UserConsoleLog } from 'vitest' export interface WebSocketBrowserHandlers { resolveSnapshotPath: (testPath: string) => string resolveSnapshotRawPath: (testPath: string, rawPath: string) => string onUnhandledError: (error: unknown, type: string) => Promise - onCollected: (files?: File[]) => Promise + onCollected: (files?: RunnerTestFile[]) => Promise onTaskUpdate: (packs: TaskResultPack[]) => void onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void onCancel: (reason: CancelReason) => void @@ -20,7 +21,7 @@ export interface WebSocketBrowserHandlers { resolveId: ( id: string, importer?: string - ) => Promise<{ id: string; url: string; optimized: boolean } | null> + ) => Promise triggerCommand: ( contextId: string, command: string, @@ -30,13 +31,8 @@ export interface WebSocketBrowserHandlers { resolveMock: ( id: string, importer: string, - hasFactory: boolean - ) => Promise<{ - type: 'factory' | 'redirect' | 'automock' - mockPath?: string | null - resolvedId: string - needsInterop?: boolean - }> + options: { mock: 'spy' | 'factory' | 'auto' }, + ) => Promise invalidate: (ids: string[]) => void getBrowserFileSourceMap: ( id: string diff --git a/packages/mocker/EXPORTS.md b/packages/mocker/EXPORTS.md new file mode 100644 index 000000000000..93a865399056 --- /dev/null +++ b/packages/mocker/EXPORTS.md @@ -0,0 +1,289 @@ +# Using as a Vite plugin + +Make sure you have `vite` and `@vitest/spy` installed (and `msw` if you are planning to use `ModuleMockerMSWInterceptor`). + +```ts +import { mockerPlugin } from '@vitest/mocker/node' + +export default defineConfig({ + plugins: [mockerPlugin()], +}) +``` + +To use it in your code, register the runtime mocker. The naming of `vi` matters - it is used by the compiler. You can configure the name by changing the `hoistMocks.utilsObjectName` and `hoistMocks.regexpHoistable` options. + +```ts +import { + ModuleMockerMSWInterceptor, + ModuleMockerServerInterceptor, + registerModuleMocker +} from '@vitest/mocker/register' + +// you can use either a server interceptor (relies on Vite's websocket connection) +const vi = registerModuleMocker(() => new ModuleMockerServerInterceptor()) +// or you can use MSW to intercept requests directly in the browser +const vi = registerModuleMocker(globalThisAccessor => new ModuleMockerMSWInterceptor({ globalThisAccessor })) +``` + +```ts +// you can also just import "auto-register" at the top of your entry point, +// this will use the server interceptor by default +import '@vitest/mocker/auto-register' +// if you do this, you can create compiler hints with "createCompilerHints" +// utility to use in your own code +import { createCompilerHints } from '@vitest/mocker/browser' +const vi = createCompilerHints() +``` + +`registerModuleMocker` returns compiler hints that Vite plugin will look for. + +By default, Vitest looks for `vi.mock`/`vi.doMock`/`vi.unmock`/`vi.doUnmock`/`vi.hoisted`. You can configure this with the `hoistMocks` option when initiating a plugin: + +```ts +import { mockerPlugin } from '@vitest/mocker/node' + +export default defineConfig({ + plugins: [ + mockerPlugin({ + hoistMocks: { + regexpHoistable: /myObj.mock/, + // you will also need to update other options accordingly + utilsObjectName: ['myObj'], + }, + }), + ], +}) +``` + +Now you can call `vi.mock` in your code and the mocker should kick in automatially: + +```ts +import { mocked } from './some-module.js' + +vi.mock('./some-module.js', () => { + return { mocked: true } +}) + +mocked === true +``` + +# Public Exports + +## MockerRegistry + +Just a cache that holds mocked modules to be used by the actual mocker. + +```ts +import { ManualMockedModule, MockerRegistry } from '@vitest/mocker' +const registry = new MockerRegistry() + +// Vitest requites the original ID for better error messages, +// You can pass down anything related to the module there +registry.register('manual', './id.js', '/users/custom/id.js', factory) +registry.get('/users/custom/id.js') instanceof ManualMockedModule +``` + +## mockObject + +Deeply mock an object. This is the function that automocks modules in Vitest. + +```ts +import { mockObject } from '@vitest/mocker' +import { spyOn } from '@vitest/spy' + +mockObject( + { + // this is needed because it can be used in vm context + globalContructors: { + Object, + // ... + }, + // you can provide your own spyOn implementation + spyOn, + mockType: 'automock' // or 'autospy' + }, + { + myDeep: { + object() { + return { + willAlso: { + beMocked() { + return true + }, + }, + } + }, + }, + } +) +``` + +## automockPlugin + +The Vite plugin that can mock any module in the browser. + +```ts +import { automockPlugin } from '@vitest/mocker/node' +import { createServer } from 'vite' + +await createServer({ + plugins: [ + automockPlugin(), + ], +}) +``` + +Any module that has `mock=automock` or `mock=autospy` query will be mocked: + +```ts +import { calculator } from './src/calculator.js?mock=automock' + +calculator(1, 2) +calculator.mock.calls[0] === [1, 2] +``` + +Ideally, you would inject those queries somehow, not write them manually. In the future, this package will support `with { mock: 'auto' }` syntax. + +> [!WARNING] +> The plugin expects a global `__vitest_mocker__` variable with a `mockObject` method. Make sure it is injected _before_ the mocked file is imported. You can also configure the accessor by changing the `globalThisAccessor` option. + +> [!NOTE] +> This plugin is included in `mockerPlugin`. + +## automockModule + +Replace every export with a mock in the code. + +```ts +import { automockModule } from '@vitest/mocker/node' +import { parseAst } from 'vite' + +const ms = await automockModule( + `export function test() {}`, + 'automock', + parseAst, +) +console.log( + ms.toString(), + ms.generateMap({ hires: 'boundary' }) +) +``` + +Produces this: + +```ts +function test() {} + +const __vitest_es_current_module__ = { + __esModule: true, + test, +} +const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__, 'automock') +const __vitest_mocked_0__ = __vitest_mocked_module__.test +export { + __vitest_mocked_0__ as test, +} +``` + +## hoistMocksPlugin + +The plugin that hoists every compiler hint, replaces every static import with dynamic one and updates exports access to make sure live-binding is not broken. + +```ts +import { hoistMocksPlugin } from '@vitest/mocker/node' +import { createServer } from 'vite' + +await createServer({ + plugins: [ + hoistMocksPlugin({ + hoistedModules: ['virtual:my-module'], + regexpHoistable: /myObj.(mock|hoist)/, + utilsObjectName: ['myObj'], + hoistableMockMethodNames: ['mock'], + // disable support for vi.mock(import('./path')) + dynamicImportMockMethodNames: [], + hoistedMethodNames: ['hoist'], + }), + ], +}) +``` + +> [!NOTE] +> This plugin is included in `mockerPlugin`. + +## hoistMocks + +Hoist compiler hints, replace static imports with dynamic ones and update exports access to make sure live-binding is not broken. + +This is required to ensure mocks are resolved before we import the user module. + +```ts +import { parseAst } from 'vite' + +hoistMocks( + ` +import { mocked } from './some-module.js' + +vi.mock('./some-module.js', () => { + return { mocked: true } +}) + +mocked === true + `, + '/my-module.js', + parseAst +) +``` + +Produces this code: + +```js +vi.mock('./some-module.js', () => { + return { mocked: true } +}) + +const __vi_import_0__ = await import('./some-module.js') +__vi_import_0__.mocked === true +``` + +## dynamicImportPlugin + +Wrap every dynamic import with `mocker.wrapDynamicImport`. This is required to ensure mocks are resolved before we import the user module. You can configure the `globalThis` accessor with `globalThisAccessor` option. + +It doesn't make sense to use this plugin in isolation from other plugins. + +```ts +import { dynamicImportPlugin } from '@vitest/mocker/node' +import { createServer } from 'vite' + +await createServer({ + plugins: [ + dynamicImportPlugin({ + globalThisAccessor: 'Symbol.for("my-mocker")' + }), + ], +}) +``` + +```ts +await import('./my-module.js') + +// produces this: +await globalThis[`Symbol.for('my-mocker')`].wrapDynamicImport(() => import('./my-module.js')) +``` + +## findMockRedirect + +This method will try to find a file inside `__mocks__` folder that corresponds to the current file. + +```ts +import { findMockRedirect } from '@vitest/mocker/node' + +// uses sync fs APIs +const mockRedirect = findMockRedirect( + root, + 'vscode', + 'vscode', // if defined, will assume the file is a library name +) +// mockRedirect == ${root}/__mocks__/vscode.js +``` diff --git a/packages/mocker/README.md b/packages/mocker/README.md new file mode 100644 index 000000000000..2a68697719c1 --- /dev/null +++ b/packages/mocker/README.md @@ -0,0 +1,5 @@ +# @vitest/mocker + +Vitest's module mocker implementation. + +[GitHub](https://github.com/vitest-dev/vitest/packages/mocker) | [Documentation](https://github.com/vitest-dev/vitest/packages/mocker/EXPORTS.md) diff --git a/packages/mocker/package.json b/packages/mocker/package.json new file mode 100644 index 000000000000..88aef3479955 --- /dev/null +++ b/packages/mocker/package.json @@ -0,0 +1,83 @@ +{ + "name": "@vitest/mocker", + "type": "module", + "version": "2.1.0-beta.5", + "description": "Vitest module mocker implementation", + "license": "MIT", + "funding": "https://opencollective.com/vitest", + "homepage": "https://github.com/vitest-dev/vitest/tree/main/packages/mocker#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/vitest-dev/vitest.git", + "directory": "packages/mocker" + }, + "bugs": { + "url": "https://github.com/vitest-dev/vitest/issues" + }, + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "default": "./dist/browser.js" + }, + "./redirect": { + "types": "./dist/redirect.d.ts", + "default": "./dist/redirect.js" + }, + "./register": { + "types": "./dist/register.d.ts", + "default": "./dist/register.js" + }, + "./auto-register": { + "types": "./dist/register.d.ts", + "default": "./dist/register.js" + }, + "./*": "./*" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "*.d.ts", + "dist" + ], + "scripts": { + "build": "rimraf dist && rollup -c", + "dev": "rollup -c --watch" + }, + "peerDependencies": { + "@vitest/spy": "workspace:*", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + }, + "dependencies": { + "@vitest/spy": "workspace:^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "devDependencies": { + "@types/estree": "^1.0.5", + "@vitest/spy": "workspace:*", + "@vitest/utils": "workspace:*", + "acorn-walk": "^8.3.3", + "msw": "^2.3.5", + "pathe": "^1.1.2", + "vite": "^5.4.0" + } +} diff --git a/packages/mocker/rollup.config.js b/packages/mocker/rollup.config.js new file mode 100644 index 000000000000..f469dbfbab96 --- /dev/null +++ b/packages/mocker/rollup.config.js @@ -0,0 +1,70 @@ +import { builtinModules, createRequire } from 'node:module' +import { defineConfig } from 'rollup' +import esbuild from 'rollup-plugin-esbuild' +import dts from 'rollup-plugin-dts' +import resolve from '@rollup/plugin-node-resolve' +import json from '@rollup/plugin-json' +import commonjs from '@rollup/plugin-commonjs' + +const require = createRequire(import.meta.url) +const pkg = require('./package.json') + +const entries = { + 'index': 'src/index.ts', + 'node': 'src/node/index.ts', + 'redirect': 'src/node/redirect.ts', + 'browser': 'src/browser/index.ts', + 'register': 'src/browser/register.ts', + 'auto-register': 'src/browser/auto-register.ts', +} + +const external = [ + ...builtinModules, + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + /^msw/, +] + +const plugins = [ + resolve({ + preferBuiltins: true, + }), + json(), + esbuild({ + target: 'node14', + }), + commonjs(), +] + +export default defineConfig([ + { + input: entries, + output: { + dir: 'dist', + format: 'esm', + entryFileNames: '[name].js', + chunkFileNames: 'chunk-[name].js', + }, + external, + plugins, + onwarn, + }, + { + input: entries, + output: { + dir: 'dist', + entryFileNames: '[name].d.ts', + format: 'esm', + }, + external, + plugins: [dts({ respectExternal: true })], + onwarn, + }, +]) + +function onwarn(message) { + if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code)) { + return + } + console.error(message) +} diff --git a/packages/mocker/src/automocker.ts b/packages/mocker/src/automocker.ts new file mode 100644 index 000000000000..e6b193d96899 --- /dev/null +++ b/packages/mocker/src/automocker.ts @@ -0,0 +1,251 @@ +import type { MockedModuleType } from './registry' + +type Key = string | symbol + +export interface MockObjectOptions { + type: MockedModuleType + globalConstructors: GlobalConstructors + spyOn: (obj: any, prop: Key) => any +} + +export function mockObject( + options: MockObjectOptions, + object: Record, + mockExports: Record = {}, +): Record { + const finalizers = new Array<() => void>() + const refs = new RefTracker() + + const define = (container: Record, key: Key, value: any) => { + try { + container[key] = value + return true + } + catch { + return false + } + } + + const mockPropertiesOf = ( + container: Record, + newContainer: Record, + ) => { + const containerType = getType(container) + const isModule = containerType === 'Module' || !!container.__esModule + for (const { key: property, descriptor } of getAllMockableProperties( + container, + isModule, + options.globalConstructors, + )) { + // Modules define their exports as getters. We want to process those. + if (!isModule && descriptor.get) { + try { + Object.defineProperty(newContainer, property, descriptor) + } + catch { + // Ignore errors, just move on to the next prop. + } + continue + } + + // Skip special read-only props, we don't want to mess with those. + if (isSpecialProp(property, containerType)) { + continue + } + + const value = container[property] + + // Special handling of references we've seen before to prevent infinite + // recursion in circular objects. + const refId = refs.getId(value) + if (refId !== undefined) { + finalizers.push(() => + define(newContainer, property, refs.getMockedValue(refId)), + ) + continue + } + + const type = getType(value) + + if (Array.isArray(value)) { + define(newContainer, property, []) + continue + } + + const isFunction + = type.includes('Function') && typeof value === 'function' + if ( + (!isFunction || value.__isMockFunction) + && type !== 'Object' + && type !== 'Module' + ) { + define(newContainer, property, value) + continue + } + + // Sometimes this assignment fails for some unknown reason. If it does, + // just move along. + if (!define(newContainer, property, isFunction ? value : {})) { + continue + } + + if (isFunction) { + if (!options.spyOn) { + throw new Error( + '[@vitest/mocker] `spyOn` is not defined. This is a Vitest error. Please open a new issue with reproduction.', + ) + } + const spyOn = options.spyOn + function mockFunction(this: any) { + // detect constructor call and mock each instance's methods + // so that mock states between prototype/instances don't affect each other + // (jest reference https://github.com/jestjs/jest/blob/2c3d2409879952157433de215ae0eee5188a4384/packages/jest-mock/src/index.ts#L678-L691) + if (this instanceof newContainer[property]) { + for (const { key, descriptor } of getAllMockableProperties( + this, + false, + options.globalConstructors, + )) { + // skip getter since it's not mocked on prototype as well + if (descriptor.get) { + continue + } + + const value = this[key] + const type = getType(value) + const isFunction + = type.includes('Function') && typeof value === 'function' + if (isFunction) { + // mock and delegate calls to original prototype method, which should be also mocked already + const original = this[key] + const mock = spyOn(this, key as string) + .mockImplementation(original) + mock.mockRestore = () => { + mock.mockReset() + mock.mockImplementation(original) + return mock + } + } + } + } + } + const mock = spyOn(newContainer, property) + if (options.type === 'automock') { + mock.mockImplementation(mockFunction) + mock.mockRestore = () => { + mock.mockReset() + mock.mockImplementation(mockFunction) + return mock + } + } + // tinyspy retains length, but jest doesn't. + Object.defineProperty(newContainer[property], 'length', { value: 0 }) + } + + refs.track(value, newContainer[property]) + mockPropertiesOf(value, newContainer[property]) + } + } + + const mockedObject: Record = mockExports + mockPropertiesOf(object, mockedObject) + + // Plug together refs + for (const finalizer of finalizers) { + finalizer() + } + + return mockedObject +} + +class RefTracker { + private idMap = new Map() + private mockedValueMap = new Map() + + public getId(value: any) { + return this.idMap.get(value) + } + + public getMockedValue(id: number) { + return this.mockedValueMap.get(id) + } + + public track(originalValue: any, mockedValue: any): number { + const newId = this.idMap.size + this.idMap.set(originalValue, newId) + this.mockedValueMap.set(newId, mockedValue) + return newId + } +} + +function getType(value: unknown): string { + return Object.prototype.toString.apply(value).slice(8, -1) +} + +function isSpecialProp(prop: Key, parentType: string) { + return ( + parentType.includes('Function') + && typeof prop === 'string' + && ['arguments', 'callee', 'caller', 'length', 'name'].includes(prop) + ) +} + +export interface GlobalConstructors { + Object: ObjectConstructor + Function: FunctionConstructor + RegExp: RegExpConstructor + Array: ArrayConstructor + Map: MapConstructor +} + +function getAllMockableProperties( + obj: any, + isModule: boolean, + constructors: GlobalConstructors, +) { + const { Map, Object, Function, RegExp, Array } = constructors + + const allProps = new Map< + string | symbol, + { key: string | symbol; descriptor: PropertyDescriptor } + >() + let curr = obj + do { + // we don't need properties from these + if ( + curr === Object.prototype + || curr === Function.prototype + || curr === RegExp.prototype + ) { + break + } + + collectOwnProperties(curr, (key) => { + const descriptor = Object.getOwnPropertyDescriptor(curr, key) + if (descriptor) { + allProps.set(key, { key, descriptor }) + } + }) + // eslint-disable-next-line no-cond-assign + } while ((curr = Object.getPrototypeOf(curr))) + // default is not specified in ownKeys, if module is interoped + if (isModule && !allProps.has('default') && 'default' in obj) { + const descriptor = Object.getOwnPropertyDescriptor(obj, 'default') + if (descriptor) { + allProps.set('default', { key: 'default', descriptor }) + } + } + return Array.from(allProps.values()) +} + +function collectOwnProperties( + obj: any, + collector: Set | ((key: string | symbol) => void), +) { + const collect + = typeof collector === 'function' + ? collector + : (key: string | symbol) => collector.add(key) + Object.getOwnPropertyNames(obj).forEach(collect) + Object.getOwnPropertySymbols(obj).forEach(collect) +} diff --git a/packages/mocker/src/browser/auto-register.ts b/packages/mocker/src/browser/auto-register.ts new file mode 100644 index 000000000000..dcf6afbbbf1b --- /dev/null +++ b/packages/mocker/src/browser/auto-register.ts @@ -0,0 +1,6 @@ +import { ModuleMockerServerInterceptor } from './interceptor-native' +import { registerModuleMocker } from './register' + +registerModuleMocker( + () => new ModuleMockerServerInterceptor(), +) diff --git a/packages/mocker/src/browser/hints.ts b/packages/mocker/src/browser/hints.ts new file mode 100644 index 000000000000..1d86148cb3f2 --- /dev/null +++ b/packages/mocker/src/browser/hints.ts @@ -0,0 +1,146 @@ +import type { MaybeMockedDeep } from '@vitest/spy' +import { createSimpleStackTrace } from '@vitest/utils' +import { parseSingleStack } from '@vitest/utils/source-map' +import type { ModuleMockFactoryWithHelper, ModuleMockOptions } from '../types' +import type { ModuleMocker } from './mocker' + +export interface CompilerHintsOptions { + /** + * This is the key used to access the globalThis object in the worker. + * Unlike `globalThisAccessor` in other APIs, this is not injected into the script. + * ```ts + * // globalThisKey: '__my_variable__' produces: + * globalThis['__my_variable__'] + * // globalThisKey: '"__my_variable__"' produces: + * globalThis['"__my_variable__"'] // notice double quotes + * ``` + * @default '__vitest_mocker__' + */ + globalThisKey?: string +} + +export interface ModuleMockerCompilerHints { + hoisted: (factory: () => T) => T + mock: (path: string | Promise, factory?: ModuleMockOptions | ModuleMockFactoryWithHelper) => void + unmock: (path: string | Promise) => void + doMock: (path: string | Promise, factory?: ModuleMockOptions | ModuleMockFactoryWithHelper) => void + doUnmock: (path: string | Promise) => void + importActual: (path: string) => Promise + importMock: (path: string) => Promise> +} + +export function createCompilerHints(options?: CompilerHintsOptions): ModuleMockerCompilerHints { + const globalThisAccessor = options?.globalThisKey || '__vitest_mocker__' + function _mocker(): ModuleMocker { + // @ts-expect-error injected by the plugin + return typeof globalThis[globalThisAccessor] !== 'undefined' + // @ts-expect-error injected by the plugin + ? globalThis[globalThisAccessor] + : new Proxy( + {}, + { + get(_, name) { + throw new Error( + 'Vitest mocker was not initialized in this environment. ' + + `vi.${String(name)}() is forbidden.`, + ) + }, + }, + ) + } + + const getImporter = (name: string) => { + const stackTrace = /* @__PURE__ */ createSimpleStackTrace({ stackTraceLimit: 5 }) + const stackArray = stackTrace.split('\n') + // if there is no message in a stack trace, use the item - 1 + const importerStackIndex = stackArray.findIndex((stack) => { + return stack.includes(` at Object.${name}`) || stack.includes(`${name}@`) + }) + const stack = /* @__PURE__ */ parseSingleStack(stackArray[importerStackIndex + 1]) + return stack?.file || '' + } + + return { + hoisted(factory: () => T): T { + if (typeof factory !== 'function') { + throw new TypeError( + `vi.hoisted() expects a function, but received a ${typeof factory}`, + ) + } + return factory() + }, + + mock(path: string | Promise, factory?: ModuleMockOptions | ModuleMockFactoryWithHelper): void { + if (typeof path !== 'string') { + throw new TypeError( + `vi.mock() expects a string path, but received a ${typeof path}`, + ) + } + const importer = getImporter('mock') + _mocker().queueMock( + path, + importer, + typeof factory === 'function' + ? () => + factory(() => + _mocker().importActual( + path, + importer, + ), + ) + : factory, + ) + }, + + unmock(path: string | Promise): void { + if (typeof path !== 'string') { + throw new TypeError( + `vi.unmock() expects a string path, but received a ${typeof path}`, + ) + } + _mocker().queueUnmock(path, getImporter('unmock')) + }, + + doMock(path: string | Promise, factory?: ModuleMockOptions | ModuleMockFactoryWithHelper): void { + if (typeof path !== 'string') { + throw new TypeError( + `vi.doMock() expects a string path, but received a ${typeof path}`, + ) + } + const importer = getImporter('doMock') + _mocker().queueMock( + path, + importer, + typeof factory === 'function' + ? () => + factory(() => + _mocker().importActual( + path, + importer, + ), + ) + : factory, + ) + }, + + doUnmock(path: string | Promise): void { + if (typeof path !== 'string') { + throw new TypeError( + `vi.doUnmock() expects a string path, but received a ${typeof path}`, + ) + } + _mocker().queueUnmock(path, getImporter('doUnmock')) + }, + + async importActual(path: string): Promise { + return _mocker().importActual( + path, + getImporter('importActual'), + ) + }, + + async importMock(path: string): Promise> { + return _mocker().importMock(path, getImporter('importMock')) + }, + } +} diff --git a/packages/mocker/src/browser/index.ts b/packages/mocker/src/browser/index.ts new file mode 100644 index 000000000000..0d1f5ead0e3c --- /dev/null +++ b/packages/mocker/src/browser/index.ts @@ -0,0 +1,13 @@ +export type { ModuleMockerInterceptor } from './interceptor' +export { ModuleMocker } from './mocker' +export { ModuleMockerMSWInterceptor, type ModuleMockerMSWInterceptorOptions } from './interceptor-msw' +export { ModuleMockerServerInterceptor } from './interceptor-native' + +export type { + ModuleMockerRPC, + ModuleMockerConfig, + ResolveIdResult, + ResolveMockResul, +} from './mocker' +export { createCompilerHints } from './hints' +export type { CompilerHintsOptions, ModuleMockerCompilerHints } from './hints' diff --git a/packages/mocker/src/browser/interceptor-msw.ts b/packages/mocker/src/browser/interceptor-msw.ts new file mode 100644 index 000000000000..408dbee5f547 --- /dev/null +++ b/packages/mocker/src/browser/interceptor-msw.ts @@ -0,0 +1,182 @@ +import type { SetupWorker, StartOptions } from 'msw/browser' +import type { HttpHandler } from 'msw' +import type { ManualMockedModule, MockedModule } from '../registry' +import { MockerRegistry } from '../registry' +import { cleanUrl } from '../utils' +import type { ModuleMockerInterceptor } from './interceptor' + +export interface ModuleMockerMSWInterceptorOptions { + /** + * The identifier to access the globalThis object in the worker. + * This will be injected into the script as is, so make sure it's a valid JS expression. + * @example + * ```js + * // globalThisAccessor: '__my_variable__' produces: + * globalThis[__my_variable__] + * // globalThisAccessor: 'Symbol.for('secret:mocks')' produces: + * globalThis[Symbol.for('secret:mocks')] + * // globalThisAccessor: '"__vitest_mocker__"' (notice quotes) produces: + * globalThis["__vitest_mocker__"] + * ``` + * @default `"__vitest_mocker__"` + */ + globalThisAccessor?: string + /** + * Options passed down to `msw.setupWorker().start(options)` + */ + mswOptions?: StartOptions + /** + * A pre-configured `msw.setupWorker` instance. + */ + mswWorker?: SetupWorker +} + +export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor { + protected readonly mocks: MockerRegistry = new MockerRegistry() + + private started = false + private startPromise: undefined | Promise + + constructor( + private readonly options: ModuleMockerMSWInterceptorOptions = {}, + ) { + if (!options.globalThisAccessor) { + options.globalThisAccessor = '"__vitest_mocker__"' + } + } + + async register(module: MockedModule): Promise { + await this.init() + this.mocks.add(module) + } + + async delete(url: string): Promise { + await this.init() + this.mocks.delete(url) + } + + invalidate(): void { + this.mocks.clear() + } + + private async resolveManualMock(mock: ManualMockedModule) { + const exports = Object.keys(await mock.resolve()) + const module = `const module = globalThis[${this.options.globalThisAccessor!}].getFactoryModule("${mock.url}");` + const keys = exports + .map((name) => { + if (name === 'default') { + return `export default module["default"];` + } + return `export const ${name} = module["${name}"];` + }) + .join('\n') + const text = `${module}\n${keys}` + return new Response(text, { + headers: { + 'Content-Type': 'application/javascript', + }, + }) + } + + protected async init(): Promise { + if (this.started) { + return + } + if (this.startPromise) { + return this.startPromise + } + const worker = this.options.mswWorker + this.startPromise = Promise.all([ + worker + ? { + setupWorker(handler: HttpHandler) { + worker.use(handler) + return worker + }, + } + : import('msw/browser'), + import('msw/core/http'), + ]).then(([{ setupWorker }, { http }]) => { + const worker = setupWorker( + http.get(/.+/, async ({ request }) => { + const path = cleanQuery(request.url.slice(location.origin.length)) + if (!this.mocks.has(path)) { + // do not cache deps like Vite does for performance + // because we want to be able to update mocks without restarting the server + // TODO: check if it's still neded - we invalidate modules after each test + if (path.includes('/deps/')) { + return fetch(bypass(request)) + } + + return passthrough() + } + + const mock = this.mocks.get(path)! + + switch (mock.type) { + case 'manual': + return this.resolveManualMock(mock) + case 'automock': + case 'autospy': + return Response.redirect(injectQuery(path, `mock=${mock.type}`)) + case 'redirect': + return Response.redirect(mock.redirect) + default: + throw new Error(`Unknown mock type: ${(mock as any).type}`) + } + }), + ) + return worker.start(this.options.mswOptions) + }) + .finally(() => { + this.started = true + this.startPromise = undefined + }) + await this.startPromise + } +} + +const timestampRegexp = /(\?|&)t=\d{13}/ +const versionRegexp = /(\?|&)v=\w{8}/ +function cleanQuery(url: string) { + return url.replace(timestampRegexp, '').replace(versionRegexp, '') +} + +function passthrough() { + return new Response(null, { + status: 302, + statusText: 'Passthrough', + headers: { + 'x-msw-intention': 'passthrough', + }, + }) +} + +function bypass(request: Request) { + const clonedRequest = request.clone() + clonedRequest.headers.set('x-msw-intention', 'bypass') + const cacheControl = clonedRequest.headers.get('cache-control') + if (cacheControl) { + clonedRequest.headers.set( + 'cache-control', + // allow reinvalidation of the cache so mocks can be updated + cacheControl.replace(', immutable', ''), + ) + } + return clonedRequest +} + +const replacePercentageRE = /%/g +function injectQuery(url: string, queryToInject: string): string { + // encode percents for consistent behavior with pathToFileURL + // see #2614 for details + const resolvedUrl = new URL( + url.replace(replacePercentageRE, '%25'), + location.href, + ) + const { search, hash } = resolvedUrl + const pathname = cleanUrl(url) + return `${pathname}?${queryToInject}${search ? `&${search.slice(1)}` : ''}${ + hash ?? '' + }` +} diff --git a/packages/mocker/src/browser/interceptor-native.ts b/packages/mocker/src/browser/interceptor-native.ts new file mode 100644 index 000000000000..9c9c2858f49b --- /dev/null +++ b/packages/mocker/src/browser/interceptor-native.ts @@ -0,0 +1,17 @@ +import type { MockedModule } from '../registry' +import type { ModuleMockerInterceptor } from './interceptor' +import { rpc } from './utils' + +export class ModuleMockerServerInterceptor implements ModuleMockerInterceptor { + async register(module: MockedModule): Promise { + await rpc('vitest:interceptor:register', module.toJSON()) + } + + async delete(id: string): Promise { + await rpc('vitest:interceptor:delete', id) + } + + invalidate(): void { + rpc('vitest:interceptor:invalidate') + } +} diff --git a/packages/mocker/src/browser/interceptor.ts b/packages/mocker/src/browser/interceptor.ts new file mode 100644 index 000000000000..44a1bf61b5c9 --- /dev/null +++ b/packages/mocker/src/browser/interceptor.ts @@ -0,0 +1,7 @@ +import type { MockedModule } from '../registry' + +export interface ModuleMockerInterceptor { + register: (module: MockedModule) => Promise + delete: (url: string) => Promise + invalidate: () => void +} diff --git a/packages/mocker/src/browser/mocker.ts b/packages/mocker/src/browser/mocker.ts new file mode 100644 index 000000000000..b610de11b32d --- /dev/null +++ b/packages/mocker/src/browser/mocker.ts @@ -0,0 +1,261 @@ +import { extname, join } from 'pathe' +import type { MockedModule, MockedModuleType } from '../registry' +import { AutomockedModule, MockerRegistry, RedirectedModule } from '../registry' +import type { ModuleMockOptions } from '../types' +import { mockObject } from '../automocker' +import type { ModuleMockerInterceptor } from './interceptor' + +const { now } = Date + +// TODO: define an interface thath both node.js and browser mocker can implement +export class ModuleMocker { + protected registry: MockerRegistry = new MockerRegistry() + + private queue = new Set>() + private mockedIds = new Set() + + constructor( + private interceptor: ModuleMockerInterceptor, + private rpc: ModuleMockerRPC, + private spyOn: (obj: any, method: string | symbol) => any, + private config: ModuleMockerConfig, + ) {} + + public async prepare(): Promise { + if (!this.queue.size) { + return + } + await Promise.all([...this.queue.values()]) + } + + public async resolveFactoryModule(id: string): Promise> { + const mock = this.registry.get(id) + if (!mock || mock.type !== 'manual') { + throw new Error(`Mock ${id} wasn't registered. This is probably a Vitest error. Please, open a new issue with reproduction.`) + } + const result = await mock.resolve() + return result + } + + public getFactoryModule(id: string): any { + const mock = this.registry.get(id) + if (!mock || mock.type !== 'manual') { + throw new Error(`Mock ${id} wasn't registered. This is probably a Vitest error. Please, open a new issue with reproduction.`) + } + if (!mock.cache) { + throw new Error(`Mock ${id} wasn't resolved. This is probably a Vitest error. Please, open a new issue with reproduction.`) + } + return mock.cache + } + + public async invalidate(): Promise { + const ids = Array.from(this.mockedIds) + if (!ids.length) { + return + } + await this.rpc.invalidate(ids) + this.interceptor.invalidate() + this.registry.clear() + } + + public async importActual(id: string, importer: string): Promise { + const resolved = await this.rpc.resolveId(id, importer) + if (resolved == null) { + throw new Error( + `[vitest] Cannot resolve ${id} imported from ${importer}`, + ) + } + const ext = extname(resolved.id) + const url = new URL(resolved.url, location.href) + const query = `_vitest_original&ext${ext}` + const actualUrl = `${url.pathname}${ + url.search ? `${url.search}&${query}` : `?${query}` + }${url.hash}` + return this.wrapDynamicImport(() => import(/* @vite-ignore */ actualUrl)).then((mod) => { + if (!resolved.optimized || typeof mod.default === 'undefined') { + return mod + } + // vite injects this helper for optimized modules, so we try to follow the same behavior + const m = mod.default + return m?.__esModule ? m : { ...((typeof m === 'object' && !Array.isArray(m)) || typeof m === 'function' ? m : {}), default: m } + }) + } + + public async importMock(rawId: string, importer: string): Promise { + await this.prepare() + const { resolvedId, redirectUrl } = await this.rpc.resolveMock( + rawId, + importer, + { mock: 'auto' }, + ) + + const mockUrl = this.resolveMockPath(cleanVersion(resolvedId)) + let mock = this.registry.get(mockUrl) + + if (!mock) { + if (redirectUrl) { + const resolvedRedirect = new URL(this.resolveMockPath(cleanVersion(redirectUrl)), location.href).toString() + mock = new RedirectedModule(rawId, mockUrl, resolvedRedirect) + } + else { + mock = new AutomockedModule(rawId, mockUrl) + } + } + + if (mock.type === 'manual') { + return await mock.resolve() as T + } + + if (mock.type === 'automock' || mock.type === 'autospy') { + const url = new URL(`/@id/${resolvedId}`, location.href) + const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}` + const moduleObject = await import(/* @vite-ignore */ `${url.pathname}${query}&mock=${mock.type}${url.hash}`) + return this.mockObject(moduleObject, mock.type) as T + } + + return import(/* @vite-ignore */ mock.redirect) + } + + public mockObject( + object: Record, + moduleType: MockedModuleType = 'automock', + ): Record { + return mockObject({ + globalConstructors: { + Object, + Function, + Array, + Map, + RegExp, + }, + spyOn: this.spyOn, + type: moduleType, + }, object) + } + + public queueMock(rawId: string, importer: string, factoryOrOptions?: ModuleMockOptions | (() => any)): void { + const promise = this.rpc + .resolveMock(rawId, importer, { + mock: typeof factoryOrOptions === 'function' + ? 'factory' + : factoryOrOptions?.spy ? 'spy' : 'auto', + }) + .then(async ({ redirectUrl, resolvedId, needsInterop, mockType }) => { + const mockUrl = this.resolveMockPath(cleanVersion(resolvedId)) + this.mockedIds.add(resolvedId) + const factory = typeof factoryOrOptions === 'function' + ? async () => { + const data = await factoryOrOptions() + // vite wraps all external modules that have "needsInterop" in a function that + // merges all exports from default into the module object + return needsInterop ? { default: data } : data + } + : undefined + + const mockRedirect = typeof redirectUrl === 'string' + ? new URL(this.resolveMockPath(cleanVersion(redirectUrl)), location.href).toString() + : null + + let module: MockedModule + if (mockType === 'manual') { + module = this.registry.register('manual', rawId, mockUrl, factory!) + } + // autospy takes higher priority over redirect, so it needs to be checked first + else if (mockType === 'autospy') { + module = this.registry.register('autospy', rawId, mockUrl) + } + else if (mockType === 'redirect') { + module = this.registry.register('redirect', rawId, mockUrl, mockRedirect!) + } + else { + module = this.registry.register('automock', rawId, mockUrl) + } + + await this.interceptor.register(module) + }) + .finally(() => { + this.queue.delete(promise) + }) + this.queue.add(promise) + } + + public queueUnmock(id: string, importer: string): void { + const promise = this.rpc + .resolveId(id, importer) + .then(async (resolved) => { + if (!resolved) { + return + } + const mockUrl = this.resolveMockPath(cleanVersion(resolved.id)) + this.mockedIds.add(resolved.id) + this.registry.delete(mockUrl) + await this.interceptor.delete(mockUrl) + }) + .finally(() => { + this.queue.delete(promise) + }) + this.queue.add(promise) + } + + // We need to await mock registration before importing the actual module + // In case there is a mocked module in the import chain + public wrapDynamicImport(moduleFactory: () => Promise): Promise { + if (typeof moduleFactory === 'function') { + const promise = new Promise((resolve, reject) => { + this.prepare().finally(() => { + moduleFactory().then(resolve, reject) + }) + }) + return promise + } + return moduleFactory + } + + private resolveMockPath(path: string) { + const config = this.config + const fsRoot = join('/@fs/', config.root) + + // URL can be /file/path.js, but path is resolved to /file/path + if (path.startsWith(config.root)) { + return path.slice(config.root.length) + } + + if (path.startsWith(fsRoot)) { + return path.slice(fsRoot.length) + } + + return path + } +} + +const versionRegexp = /(\?|&)v=\w{8}/ +function cleanVersion(url: string) { + return url.replace(versionRegexp, '') +} + +export interface ResolveIdResult { + id: string + url: string + optimized: boolean +} + +export interface ResolveMockResul { + mockType: MockedModuleType + resolvedId: string + redirectUrl?: string | null + needsInterop?: boolean +} + +export interface ModuleMockerRPC { + invalidate: (ids: string[]) => Promise + resolveId: (id: string, importer: string) => Promise + resolveMock: ( + id: string, + importer: string, + options: { mock: 'spy' | 'factory' | 'auto' } + ) => Promise +} + +export interface ModuleMockerConfig { + root: string +} diff --git a/packages/mocker/src/browser/register.ts b/packages/mocker/src/browser/register.ts new file mode 100644 index 000000000000..f878e5044909 --- /dev/null +++ b/packages/mocker/src/browser/register.ts @@ -0,0 +1,48 @@ +import { spyOn } from '@vitest/spy' +import type { ModuleMockerCompilerHints } from './hints' +import { createCompilerHints } from './hints' +import { hot, rpc } from './utils' +import type { ModuleMockerInterceptor } from './index' +import { ModuleMocker } from './index' + +declare const __VITEST_GLOBAL_THIS_ACCESSOR__: string +declare const __VITEST_MOCKER_ROOT__: string + +export function registerModuleMocker( + interceptor: (accessor: string) => ModuleMockerInterceptor, +): ModuleMockerCompilerHints { + const mocker = new ModuleMocker( + interceptor(__VITEST_GLOBAL_THIS_ACCESSOR__), + { + resolveId(id, importer) { + return rpc('vitest:mocks:resolveId', { id, importer }) + }, + resolveMock(id, importer, options) { + return rpc('vitest:mocks:resolveMock', { id, importer, options }) + }, + async invalidate(ids) { + return rpc('vitest:mocks:invalidate', { ids }) + }, + }, + spyOn, + { + root: __VITEST_MOCKER_ROOT__, + }, + ) + + ;(globalThis as any)[__VITEST_GLOBAL_THIS_ACCESSOR__] = mocker + + registerNativeFactoryResolver(mocker) + + return createCompilerHints({ + globalThisKey: __VITEST_GLOBAL_THIS_ACCESSOR__, + }) +} + +export function registerNativeFactoryResolver(mocker: ModuleMocker): void { + hot.on('vitest:interceptor:resolve', async (url: string) => { + const exports = await mocker.resolveFactoryModule(url) + const keys = Object.keys(exports) + hot.send('vitest:interceptor:resolved', { url, keys }) + }) +} diff --git a/packages/mocker/src/browser/utils.ts b/packages/mocker/src/browser/utils.ts new file mode 100644 index 000000000000..827c5cdc854b --- /dev/null +++ b/packages/mocker/src/browser/utils.ts @@ -0,0 +1,27 @@ +import type { ViteHotContext } from 'vite/types/hot.js' + +const hot: ViteHotContext = import.meta.hot! || { + on: warn, + off: warn, + send: warn, +} + +function warn() { + console.warn('Vitest mocker cannot work if Vite didn\'t establish WS connection.') +} + +export { hot } + +export function rpc(event: string, data?: any): Promise { + hot.send(event, data) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Failed to resolve ${event} in time`)) + }, 5_000) + hot.on(`${event}:result`, function r(data) { + resolve(data) + clearTimeout(timeout) + hot.off('vitest:mocks:resolvedId:result', r) + }) + }) +} diff --git a/packages/mocker/src/index.ts b/packages/mocker/src/index.ts new file mode 100644 index 000000000000..2b140b7c6800 --- /dev/null +++ b/packages/mocker/src/index.ts @@ -0,0 +1,25 @@ +export { + MockerRegistry, + ManualMockedModule, + RedirectedModule, + AutomockedModule, + AutospiedModule, +} from './registry' +export { mockObject } from './automocker' + +export type { GlobalConstructors, MockObjectOptions } from './automocker' +export type { + MockedModule, + MockedModuleType, + MockedModuleSerialized, + AutomockedModuleSerialized, + AutospiedModuleSerialized, + RedirectedModuleSerialized, + ManualMockedModuleSerialized, +} from './registry' + +export type { + ModuleMockFactory, + ModuleMockFactoryWithHelper, + ModuleMockOptions, +} from './types' diff --git a/packages/vitest/src/node/automock.ts b/packages/mocker/src/node/automockPlugin.ts similarity index 81% rename from packages/vitest/src/node/automock.ts rename to packages/mocker/src/node/automockPlugin.ts index 551f67e96883..5251b1330502 100644 --- a/packages/vitest/src/node/automock.ts +++ b/packages/mocker/src/node/automockPlugin.ts @@ -1,3 +1,6 @@ +import MagicString from 'magic-string' +import type { Plugin } from 'vite' +import { cleanUrl } from '../utils' import type { Declaration, ExportDefaultDeclaration, @@ -8,12 +11,41 @@ import type { Pattern, Positioned, Program, -} from '@vitest/utils/ast' -import MagicString from 'magic-string' +} from './esmWalker' + +export interface AutomockPluginOptions { + /** + * @default "__vitest_mocker__" + */ + globalThisAccessor?: string +} + +export function automockPlugin(options: AutomockPluginOptions = {}): Plugin { + return { + name: 'vitest:automock', + enforce: 'post', + transform(code, id) { + if (id.includes('mock=automock') || id.includes('mock=autospy')) { + const mockType = id.includes('mock=automock') ? 'automock' : 'autospy' + const ms = automockModule(code, mockType, this.parse, options) + return { + code: ms.toString(), + map: ms.generateMap({ hires: 'boundary', source: cleanUrl(id) }), + } + } + }, + } +} // TODO: better source map replacement -export function automockModule(code: string, parse: (code: string) => Program) { - const ast = parse(code) +export function automockModule( + code: string, + mockType: 'automock' | 'autospy', + parse: (code: string) => any, + options: AutomockPluginOptions = {}, +): MagicString { + const globalThisAccessor = options.globalThisAccessor || '"__vitest_mocker__"' + const ast = parse(code) as Program const m = new MagicString(code) @@ -141,11 +173,11 @@ export function automockModule(code: string, parse: (code: string) => Program) { } } const moduleObject = ` -const __vitest_es_current_module__ = { +const __vitest_current_es_module__ = { __esModule: true, ${allSpecifiers.map(({ name }) => `["${name}"]: ${name},`).join('\n ')} } -const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) +const __vitest_mocked_module__ = globalThis[${globalThisAccessor}].mockObject(__vitest_current_es_module__, "${mockType}") ` const assigning = allSpecifiers .map(({ name }, index) => { diff --git a/packages/mocker/src/node/dynamicImportPlugin.ts b/packages/mocker/src/node/dynamicImportPlugin.ts new file mode 100644 index 000000000000..545e8529fc5f --- /dev/null +++ b/packages/mocker/src/node/dynamicImportPlugin.ts @@ -0,0 +1,78 @@ +import type { SourceMap } from 'magic-string' +import MagicString from 'magic-string' +import type { Plugin, Rollup } from 'vite' +import type { Expression, Positioned } from './esmWalker' +import { esmWalker } from './esmWalker' + +const regexDynamicImport = /import\s*\(/ + +export interface DynamicImportPluginOptions { + /** + * @default `"__vitest_mocker__"` + */ + globalThisAccessor?: string +} + +export function dynamicImportPlugin(options: DynamicImportPluginOptions = {}): Plugin { + return { + name: 'vitest:browser:esm-injector', + enforce: 'post', + transform(source, id) { + // TODO: test is not called for static imports + if (!regexDynamicImport.test(source)) { + return + } + return injectDynamicImport(source, id, this.parse, options) + }, + } +} + +export interface DynamicImportInjectorResult { + ast: Rollup.ProgramNode + code: string + map: SourceMap +} + +export function injectDynamicImport( + code: string, + id: string, + parse: Rollup.PluginContext['parse'], + options: DynamicImportPluginOptions = {}, +): DynamicImportInjectorResult | undefined { + const s = new MagicString(code) + + let ast: any + try { + ast = parse(code) + } + catch (err) { + console.error(`Cannot parse ${id}:\n${(err as any).message}`) + return + } + + // 3. convert references to import bindings & import.meta references + esmWalker(ast, { + // TODO: make env updatable + onImportMeta() { + // s.update(node.start, node.end, viImportMetaKey) + }, + onDynamicImport(node) { + const globalThisAccessor = options.globalThisAccessor || '"__vitest_mocker__"' + const replaceString = `globalThis[${globalThisAccessor}].wrapDynamicImport(() => import(` + const importSubstring = code.substring(node.start, node.end) + const hasIgnore = importSubstring.includes('/* @vite-ignore */') + s.overwrite( + node.start, + (node.source as Positioned).start, + replaceString + (hasIgnore ? '/* @vite-ignore */ ' : ''), + ) + s.overwrite(node.end - 1, node.end, '))') + }, + }) + + return { + ast, + code: s.toString(), + map: s.generateMap({ hires: 'boundary', source: id }), + } +} diff --git a/packages/utils/src/ast/esmWalker.ts b/packages/mocker/src/node/esmWalker.ts similarity index 82% rename from packages/utils/src/ast/esmWalker.ts rename to packages/mocker/src/node/esmWalker.ts index c233112a51b4..7a5e4d29d1b6 100644 --- a/packages/utils/src/ast/esmWalker.ts +++ b/packages/mocker/src/node/esmWalker.ts @@ -1,4 +1,5 @@ import type { + CallExpression, Function as FunctionNode, Identifier, ImportExpression, @@ -8,6 +9,9 @@ import type { Node as _Node, } from 'estree' import { walk as eswalk } from 'estree-walker' +import type { Rollup } from 'vite' + +export type * from 'estree' export type Positioned = T & { start: number @@ -16,14 +20,31 @@ export type Positioned = T & { export type Node = Positioned<_Node> +interface IdentifierInfo { + /** + * If the identifier is used in a property shorthand + * { foo } -> { foo: __import_x__.foo } + */ + hasBindingShortcut: boolean + /** + * The identifier is used in a class declaration + */ + classDeclaration: boolean + /** + * The identifier is a name for a class expression + */ + classExpression: boolean +} + interface Visitors { - onIdentifier: ( + onIdentifier?: ( node: Positioned, - parent: Node, + info: IdentifierInfo, parentStack: Node[] ) => void - onImportMeta: (node: Node) => void - onDynamicImport: (node: Positioned) => void + onImportMeta?: (node: Node) => void + onDynamicImport?: (node: Positioned) => void + onCallExpression?: (node: Positioned) => void } const isNodeInPatternWeakSet = new WeakSet<_Node>() @@ -39,8 +60,8 @@ export function isNodeInPattern(node: _Node): node is Property { * Except this is using acorn AST */ export function esmWalker( - root: Node, - { onIdentifier, onImportMeta, onDynamicImport }: Visitors, + root: Rollup.ProgramNode, + { onIdentifier, onImportMeta, onDynamicImport, onCallExpression }: Visitors, ): void { const parentStack: Node[] = [] const varKindStack: VariableDeclaration['kind'][] = [] @@ -95,8 +116,8 @@ export function esmWalker( } } - (eswalk as any)(root, { - enter(node: Node, parent: Node | null) { + eswalk(root as Node, { + enter(node, parent) { if (node.type === 'ImportDeclaration') { return this.skip() } @@ -107,7 +128,7 @@ export function esmWalker( parent && !(parent.type === 'IfStatement' && node === parent.alternate) ) { - parentStack.unshift(parent) + parentStack.unshift(parent as Node) } // track variable declaration kind stack used by VariableDeclarator @@ -115,11 +136,15 @@ export function esmWalker( varKindStack.unshift(node.kind) } + if (node.type === 'CallExpression') { + onCallExpression?.(node as Positioned) + } + if (node.type === 'MetaProperty' && node.meta.name === 'import') { - onImportMeta(node) + onImportMeta?.(node as Node) } else if (node.type === 'ImportExpression') { - onDynamicImport(node) + onDynamicImport?.(node as Positioned) } if (node.type === 'Identifier') { @@ -197,7 +222,7 @@ export function esmWalker( } }, - leave(node: Node, parent: Node | null) { + leave(node, parent) { // untrack parent stack from above if ( parent @@ -216,7 +241,31 @@ export function esmWalker( // can be captured correctly identifiers.forEach(([node, stack]) => { if (!isInScope(node.name, stack)) { - onIdentifier(node, stack[0], stack) + const parent = stack[0] + const grandparent = stack[1] + const hasBindingShortcut + = isStaticProperty(parent) + && parent.shorthand + && (!isNodeInPattern(parent) + || isInDestructuringAssignment(parent, parentStack)) + + const classDeclaration + = (parent.type === 'PropertyDefinition' + && grandparent?.type === 'ClassBody') + || (parent.type === 'ClassDeclaration' && node === parent.superClass) + + const classExpression + = parent.type === 'ClassExpression' && node === parent.id + + onIdentifier?.( + node, + { + hasBindingShortcut, + classDeclaration, + classExpression, + }, + stack, + ) } }) } diff --git a/packages/vitest/src/node/hoistMocks.ts b/packages/mocker/src/node/hoistMocksPlugin.ts similarity index 76% rename from packages/vitest/src/node/hoistMocks.ts rename to packages/mocker/src/node/hoistMocksPlugin.ts index 3305100c06ae..81adfb5b0102 100644 --- a/packages/vitest/src/node/hoistMocks.ts +++ b/packages/mocker/src/node/hoistMocksPlugin.ts @@ -1,3 +1,4 @@ +import type { SourceMap } from 'magic-string' import MagicString from 'magic-string' import type { AwaitExpression, @@ -9,21 +10,86 @@ import type { ImportDeclaration, ImportExpression, VariableDeclaration, - Node as _Node, } from 'estree' import { findNodeAround } from 'acorn-walk' -import type { PluginContext, ProgramNode } from 'rollup' -import { esmWalker } from '@vitest/utils/ast' -import type { Colors } from 'tinyrainbow' -import { highlightCode } from '../utils/colors' -import { generateCodeFrame } from './error' - -export type Positioned = T & { - start: number - end: number +import type { Plugin, Rollup } from 'vite' +import { createFilter } from 'vite' +import type { Node, Positioned } from './esmWalker' +import { esmWalker } from './esmWalker' + +interface HoistMocksOptions { + /** + * List of modules that should always be imported before compiler hints. + * @default ['vitest'] + */ + hoistedModules?: string[] + /** + * @default ["vi", "vitest"] + */ + utilsObjectNames?: string[] + /** + * @default ["mock", "unmock"] + */ + hoistableMockMethodNames?: string[] + /** + * @default ["mock", "unmock", "doMock", "doUnmock"] + */ + dynamicImportMockMethodNames?: string[] + /** + * @default ["hoisted"] + */ + hoistedMethodNames?: string[] + regexpHoistable?: RegExp + codeFrameGenerator?: CodeFrameGenerator } -export type Node = Positioned<_Node> +export interface HoistMocksPluginOptions extends Omit { + include?: string | RegExp | (string | RegExp)[] + exclude?: string | RegExp | (string | RegExp)[] + /** + * overrides include/exclude options + */ + filter?: (id: string) => boolean +} + +export function hoistMocksPlugin(options: HoistMocksPluginOptions = {}): Plugin { + const filter = options.filter || createFilter(options.include, options.exclude) + + const { + hoistableMockMethodNames = ['mock', 'unmock'], + dynamicImportMockMethodNames = ['mock', 'unmock', 'doMock', 'doUnmock'], + hoistedMethodNames = ['hoisted'], + utilsObjectNames = ['vi', 'vitest'], + } = options + + const methods = new Set([ + ...hoistableMockMethodNames, + ...hoistedMethodNames, + ...dynamicImportMockMethodNames, + ]) + + const regexpHoistable = new RegExp( + `\\b(?:${utilsObjectNames.join('|')})\\s*\.\\s*(?:${Array.from(methods).join('|')})\\(`, + ) + + return { + name: 'vitest:mocks', + enforce: 'post', + transform(code, id) { + if (!filter(id)) { + return + } + return hoistMocks(code, id, this.parse, { + regexpHoistable, + hoistableMockMethodNames, + hoistedMethodNames, + utilsObjectNames, + dynamicImportMockMethodNames, + ...options, + }) + }, + } +} const API_NOT_FOUND_ERROR = `There are some problems in resolving the mocks API. You may encounter this issue when importing the mocks API from another module other than 'vitest'. @@ -31,43 +97,16 @@ To fix this issue you can either: - import the mocks API directly from 'vitest' - enable the 'globals' options` -const API_NOT_FOUND_CHECK - = '\nif (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") ' - + `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n` +function API_NOT_FOUND_CHECK(names: string[]) { + return `\nif (${names.map(name => `typeof globalThis["${name}"] === "undefined"`).join(' && ')}) ` + + `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n` +} function isIdentifier(node: any): node is Positioned { return node.type === 'Identifier' } -function transformImportSpecifiers(node: ImportDeclaration) { - const dynamicImports = node.specifiers - .map((specifier) => { - if (specifier.type === 'ImportDefaultSpecifier') { - return `default: ${specifier.local.name}` - } - - if (specifier.type === 'ImportSpecifier') { - const local = specifier.local.name - const imported = specifier.imported.name - if (local === imported) { - return local - } - return `${imported}: ${local}` - } - - return null - }) - .filter(Boolean) - .join(', ') - - if (!dynamicImports.length) { - return '' - } - - return `{ ${dynamicImports} }` -} - -export function getBetterEnd(code: string, node: Node) { +function getBetterEnd(code: string, node: Node) { let end = node.end if (code[node.end] === ';') { end += 1 @@ -82,13 +121,24 @@ const regexpHoistable = /\b(?:vi|vitest)\s*\.\s*(?:mock|unmock|hoisted|doMock|doUnmock)\(/ const hashbangRE = /^#!.*\n/ +export interface HoistMocksResult { + ast: Rollup.ProgramNode + code: string + map: SourceMap +} + +interface CodeFrameGenerator { + (node: Positioned, id: string, code: string): string +} + +// this is a fork of Vite SSR trandform export function hoistMocks( code: string, id: string, - parse: PluginContext['parse'], - colors?: Colors, -) { - const needHoisting = regexpHoistable.test(code) + parse: Rollup.PluginContext['parse'], + options: HoistMocksOptions = {}, +): HoistMocksResult | undefined { + const needHoisting = (options.regexpHoistable || regexpHoistable).test(code) if (!needHoisting) { return @@ -96,18 +146,26 @@ export function hoistMocks( const s = new MagicString(code) - let ast: ProgramNode + let ast: Rollup.ProgramNode try { ast = parse(code) } catch (err) { - console.error(`Cannot parse ${id}:\n${(err as any).message}`) + console.error(`Cannot parse ${id}:\n${(err as any).message}.`) return } + const { + hoistableMockMethodNames = ['mock', 'unmock'], + dynamicImportMockMethodNames = ['mock', 'unmock', 'doMock', 'doUnmock'], + hoistedMethodNames = ['hoisted'], + utilsObjectNames = ['vi', 'vitest'], + hoistedModules = ['vitest'], + } = options + const hoistIndex = code.match(hashbangRE)?.[0].length ?? 0 - let hoistedVitestImports = '' + let hoistedModuleImported = false let uid = 0 const idToImportMap = new Map() @@ -133,12 +191,8 @@ export function hoistMocks( function defineImport(node: Positioned) { // always hoist vitest import to top of the file, so // "vi" helpers can access it - if (node.source.value === 'vitest') { - const code = `const ${transformImportSpecifiers( - node, - )} = await import('vitest')\n` - hoistedVitestImports += code - s.remove(node.start, getBetterEnd(code, node)) + if (hoistedModules.includes(node.source.value as string)) { + hoistedModuleImported = true return } @@ -187,16 +241,15 @@ export function hoistMocks( function createSyntaxError(node: Positioned, message: string) { const _error = new SyntaxError(message) Error.captureStackTrace(_error, createSyntaxError) - return { + const serializedError: any = { name: 'SyntaxError', message: _error.message, stack: _error.stack, - frame: generateCodeFrame( - highlightCode(id, code, colors), - 4, - node.start + 1, - ), } + if (options.codeFrameGenerator) { + serializedError.frame = options.codeFrameGenerator(node, id, code) + } + return serializedError } function assertNotDefaultExport( @@ -276,13 +329,12 @@ export function hoistMocks( if ( node.callee.type === 'MemberExpression' && isIdentifier(node.callee.object) - && (node.callee.object.name === 'vi' - || node.callee.object.name === 'vitest') + && utilsObjectNames.includes(node.callee.object.name) && isIdentifier(node.callee.property) ) { const methodName = node.callee.property.name - if (methodName === 'mock' || methodName === 'unmock') { + if (hoistableMockMethodNames.includes(methodName)) { const method = `${node.callee.object.name}.${methodName}` assertNotDefaultExport( node, @@ -297,10 +349,9 @@ export function hoistMocks( } hoistedNodes.push(node) } - // vi.doMock(import('./path')) -> vi.doMock('./path') // vi.doMock(await import('./path')) -> vi.doMock('./path') - if (methodName === 'doMock' || methodName === 'doUnmock') { + else if (dynamicImportMockMethodNames.includes(methodName)) { const moduleInfo = node.arguments[0] as Positioned let source: Positioned | null = null if (moduleInfo.type === 'ImportExpression') { @@ -321,7 +372,7 @@ export function hoistMocks( } } - if (methodName === 'hoisted') { + if (hoistedMethodNames.includes(methodName)) { assertNotDefaultExport( node, 'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.', @@ -445,8 +496,7 @@ export function hoistMocks( if ( node.type === 'CallExpression' && node.callee.type === 'MemberExpression' - && ((node.callee.property as Identifier).name === 'mock' - || (node.callee.property as Identifier).name === 'unmock') + && dynamicImportMockMethodNames.includes((node.callee.property as Identifier).name) ) { const moduleInfo = node.arguments[0] as Positioned // vi.mock(import('./path')) -> vi.mock('./path') @@ -479,10 +529,9 @@ export function hoistMocks( }) .join('') - if (hoistedCode || hoistedVitestImports) { + if (hoistedCode || hoistedModuleImported) { s.prepend( - hoistedVitestImports - + (!hoistedVitestImports && hoistedCode ? API_NOT_FOUND_CHECK : '') + (!hoistedModuleImported && hoistedCode ? API_NOT_FOUND_CHECK(utilsObjectNames) : '') + hoistedCode, ) } diff --git a/packages/mocker/src/node/index.ts b/packages/mocker/src/node/index.ts new file mode 100644 index 000000000000..998836bf2d04 --- /dev/null +++ b/packages/mocker/src/node/index.ts @@ -0,0 +1,16 @@ +export { automockModule, automockPlugin } from './automockPlugin' +export { findMockRedirect } from './redirect' +export { hoistMocksPlugin, hoistMocks } from './hoistMocksPlugin' +export { ServerMockResolver } from './resolver' +export { dynamicImportPlugin } from './dynamicImportPlugin' +export { interceptorPlugin } from './interceptorPlugin' +export { mockerPlugin } from './mockerPlugin' + +export type { + ServerMockResolution, + ServerIdResolution, + ServerResolverOptions, +} from './resolver' +export type { AutomockPluginOptions } from './automockPlugin' +export type { HoistMocksPluginOptions, HoistMocksResult } from './hoistMocksPlugin' +export type { InterceptorPluginOptions } from './interceptorPlugin' diff --git a/packages/mocker/src/node/interceptorPlugin.ts b/packages/mocker/src/node/interceptorPlugin.ts new file mode 100644 index 000000000000..200492bc3df0 --- /dev/null +++ b/packages/mocker/src/node/interceptorPlugin.ts @@ -0,0 +1,115 @@ +import { join } from 'node:path/posix' +import { readFile } from 'node:fs/promises' +import type { Plugin } from 'vite' +import type { MockedModuleSerialized } from '../registry' +import { ManualMockedModule, MockerRegistry } from '../registry' +import { cleanUrl } from '../utils' +import { automockModule } from './automockPlugin' + +export interface InterceptorPluginOptions { + /** + * @default "__vitest_mocker__" + */ + globalThisAccessor?: string +} + +export function interceptorPlugin(options: InterceptorPluginOptions): Plugin { + const registry = new MockerRegistry() + return { + name: 'vitest:mocks:interceptor', + enforce: 'pre', + async load(id) { + const mock = registry.get(id) + if (!mock) { + return + } + if (mock.type === 'manual') { + const exports = Object.keys(await mock.resolve()) + const accessor = options.globalThisAccessor || '"__vitest_mocker__"' + const serverUrl = (mock as any).serverUrl as string + const module = `const module = globalThis[${accessor}].getFactoryModule("${serverUrl}");` + const keys = exports + .map((name) => { + if (name === 'default') { + return `export default module["default"];` + } + return `export const ${name} = module["${name}"];` + }) + .join('\n') + return `${module}\n${keys}` + } + if (mock.type === 'redirect') { + return readFile(mock.redirect, 'utf-8') + } + }, + transform: { + order: 'post', + handler(code, id) { + const mock = registry.get(id) + if (!mock) { + return + } + if (mock.type === 'automock' || mock.type === 'autospy') { + const m = automockModule(code, mock.type, this.parse, { + globalThisAccessor: options.globalThisAccessor, + }) + + return { + code: m.toString(), + map: m.generateMap({ hires: 'boundary', source: cleanUrl(id) }), + } + } + }, + }, + configureServer(server) { + server.ws.on('vitest:interceptor:register', (event: MockedModuleSerialized) => { + const serverUrl = event.url + // the browsers stores the url relative to the root + // but on the server "id" operates on the file paths + event.url = join(server.config.root, event.url) + if (event.type === 'manual') { + const module = ManualMockedModule.fromJSON(event, async () => { + const keys = await getFactoryExports(serverUrl) + return Object.fromEntries(keys.map(key => [key, null])) + }) + Object.assign(module, { + serverUrl, + }) + registry.add(module) + } + else { + if (event.type === 'redirect') { + const redirectUrl = new URL(event.redirect) + event.redirect = join(server.config.root, redirectUrl.pathname) + } + registry.register(event) + } + server.ws.send('vitest:interceptor:register:result') + }) + server.ws.on('vitest:interceptor:delete', (id: string) => { + registry.delete(id) + server.ws.send('vitest:interceptor:register:delete') + }) + server.ws.on('vitest:interceptor:invalidate', () => { + registry.clear() + server.ws.send('vitest:interceptor:register:invalidate') + }) + + function getFactoryExports(url: string) { + server.ws.send('vitest:interceptor:resolve', url) + let timeout: NodeJS.Timeout + return new Promise((resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error(`Timeout while waiting for factory exports of ${url}`)) + }, 10_000) + server.ws.on('vitest:interceptor:resolved', ({ url: resolvedUrl, keys }: { url: string; keys: string[] }) => { + if (resolvedUrl === url) { + clearTimeout(timeout) + resolve(keys) + } + }) + }) + } + }, + } +} diff --git a/packages/mocker/src/node/mockerPlugin.ts b/packages/mocker/src/node/mockerPlugin.ts new file mode 100644 index 000000000000..983bef471c60 --- /dev/null +++ b/packages/mocker/src/node/mockerPlugin.ts @@ -0,0 +1,83 @@ +import { fileURLToPath } from 'node:url' +import { readFile } from 'node:fs/promises' +import type { Plugin, ViteDevServer } from 'vite' + +import { resolve } from 'pathe' +import { type AutomockPluginOptions, automockPlugin } from './automockPlugin' +import { type HoistMocksPluginOptions, hoistMocksPlugin } from './hoistMocksPlugin' +import { dynamicImportPlugin } from './dynamicImportPlugin' +import { ServerMockResolver } from './resolver' +import { interceptorPlugin } from './interceptorPlugin' + +interface MockerPluginOptions extends AutomockPluginOptions { + hoistMocks?: HoistMocksPluginOptions +} + +// this is an implementation for public usage +// vitest doesn't use this plugin directly + +export function mockerPlugin(options: MockerPluginOptions = {}): Plugin[] { + let server: ViteDevServer + const registerPath = resolve(fileURLToPath(new URL('./register.js', import.meta.url))) + return [ + { + name: 'vitest:mocker:ws-rpc', + config(_, { command }) { + if (command !== 'serve') { + return + } + return { + server: { + // don't pre-transform request because they might be mocked at runtime + preTransformRequests: false, + }, + optimizeDeps: { + exclude: ['@vitest/mocker/register', '@vitest/mocker/browser'], + }, + } + }, + configureServer(server_) { + server = server_ + const mockResolver = new ServerMockResolver(server) + server.ws.on('vitest:mocks:resolveId', async ({ id, importer }: { id: string; importer: string }) => { + const resolved = await mockResolver.resolveId(id, importer) + server.ws.send('vitest:mocks:resolvedId:result', resolved) + }) + server.ws.on('vitest:mocks:resolveMock', async ({ id, importer, options }: { id: string; importer: string; options: any }) => { + const resolved = await mockResolver.resolveMock(id, importer, options) + server.ws.send('vitest:mocks:resolveMock:result', resolved) + }) + server.ws.on('vitest:mocks:invalidate', async ({ ids }: { ids: string[] }) => { + mockResolver.invalidate(ids) + server.ws.send('vitest:mocks:invalidate:result') + }) + }, + async load(id) { + if (id !== registerPath) { + return + } + + if (!server) { + // mocker doesn't work during build + return 'export {}' + } + + const content = await readFile(registerPath, 'utf-8') + const result = content + .replace( + /__VITEST_GLOBAL_THIS_ACCESSOR__/g, + options.globalThisAccessor ?? '"__vitest_mocker__"', + ) + .replace( + '__VITEST_MOCKER_ROOT__', + JSON.stringify(server.config.root), + ) + return result + }, + }, + hoistMocksPlugin(options.hoistMocks), + interceptorPlugin(options), + automockPlugin(options), + dynamicImportPlugin(options), + ] +} diff --git a/packages/mocker/src/node/redirect.ts b/packages/mocker/src/node/redirect.ts new file mode 100644 index 000000000000..9abb215278cc --- /dev/null +++ b/packages/mocker/src/node/redirect.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs' +import { builtinModules } from 'node:module' +import { basename, dirname, extname, join, resolve } from 'pathe' + +const { existsSync, readdirSync, statSync } = fs + +export function findMockRedirect( + root: string, + mockPath: string, + external: string | null, +): string | null { + const path = external || mockPath + + // it's a node_module alias + // all mocks should be inside /__mocks__ + if (external || isNodeBuiltin(mockPath) || !existsSync(mockPath)) { + const mockDirname = dirname(path) // for nested mocks: @vueuse/integration/useJwt + const mockFolder = join(root, '__mocks__', mockDirname) + + if (!existsSync(mockFolder)) { + return null + } + + const baseOriginal = basename(path) + + function findFile(mockFolder: string, baseOriginal: string): string | null { + const files = readdirSync(mockFolder) + for (const file of files) { + const baseFile = basename(file, extname(file)) + if (baseFile === baseOriginal) { + const path = resolve(mockFolder, file) + // if the same name, return the file + if (statSync(path).isFile()) { + return path + } + else { + // find folder/index.{js,ts} + const indexFile = findFile(path, 'index') + if (indexFile) { + return indexFile + } + } + } + } + return null + } + + return findFile(mockFolder, baseOriginal) + } + + const dir = dirname(path) + const baseId = basename(path) + const fullPath = resolve(dir, '__mocks__', baseId) + return existsSync(fullPath) ? fullPath : null +} + +const builtins = new Set([ + ...builtinModules, + 'assert/strict', + 'diagnostics_channel', + 'dns/promises', + 'fs/promises', + 'path/posix', + 'path/win32', + 'readline/promises', + 'stream/consumers', + 'stream/promises', + 'stream/web', + 'timers/promises', + 'util/types', + 'wasi', +]) + +const prefixedBuiltins = new Set(['node:test', 'node:sqlite']) +const NODE_BUILTIN_NAMESPACE = 'node:' +function isNodeBuiltin(id: string): boolean { + if (prefixedBuiltins.has(id)) { + return true + } + return builtins.has( + id.startsWith(NODE_BUILTIN_NAMESPACE) + ? id.slice(NODE_BUILTIN_NAMESPACE.length) + : id, + ) +} diff --git a/packages/mocker/src/node/resolver.ts b/packages/mocker/src/node/resolver.ts new file mode 100644 index 000000000000..f7df32afff9d --- /dev/null +++ b/packages/mocker/src/node/resolver.ts @@ -0,0 +1,188 @@ +import { existsSync, readFileSync } from 'node:fs' +import { isAbsolute, join, resolve } from 'pathe' +import type { PartialResolvedId } from 'rollup' +import type { ResolvedConfig as ViteConfig, ViteDevServer } from 'vite' +import { cleanUrl } from '../utils' +import { findMockRedirect } from './redirect' + +export interface ServerResolverOptions { + /** + * @default ['/node_modules/'] + */ + moduleDirectories?: string[] +} + +const VALID_ID_PREFIX = '/@id/' + +export class ServerMockResolver { + constructor( + private server: ViteDevServer, + private options: ServerResolverOptions = {}, + ) {} + + async resolveMock( + rawId: string, + importer: string, + options: { mock: 'spy' | 'factory' | 'auto' }, + ): Promise { + const { id, fsPath, external } = await this.resolveMockId(rawId, importer) + + if (options.mock === 'factory') { + const manifest = getViteDepsManifest(this.server.config) + const needsInterop = manifest?.[fsPath]?.needsInterop ?? false + return { mockType: 'manual', resolvedId: id, needsInterop } + } + + if (options.mock === 'spy') { + return { mockType: 'autospy', resolvedId: id } + } + + const redirectUrl = findMockRedirect(this.server.config.root, fsPath, external) + + return { + mockType: redirectUrl === null ? 'automock' : 'redirect', + redirectUrl, + resolvedId: id, + } + } + + public invalidate(ids: string[]): void { + ids.forEach((id) => { + const moduleGraph = this.server.moduleGraph + const module = moduleGraph.getModuleById(id) + if (module) { + moduleGraph.invalidateModule(module, new Set(), Date.now(), true) + } + }) + } + + public async resolveId(id: string, importer?: string): Promise { + const resolved = await this.server.pluginContainer.resolveId( + id, + importer, + { + ssr: false, + }, + ) + if (!resolved) { + return null + } + const isOptimized = resolved.id.startsWith(withTrailingSlash(this.server.config.cacheDir)) + let url: string + // normalise the URL to be acceptible by the browser + // https://github.com/vitejs/vite/blob/e833edf026d495609558fd4fb471cf46809dc369/packages/vite/src/node/plugins/importAnalysis.ts#L335 + const root = this.server.config.root + if (resolved.id.startsWith(withTrailingSlash(root))) { + url = resolved.id.slice(root.length) + } + else if ( + resolved.id !== '/@react-refresh' + && isAbsolute(resolved.id) + && existsSync(cleanUrl(resolved.id)) + ) { + url = join('/@fs/', resolved.id) + } + else { + url = resolved.id + } + if (url[0] !== '.' && url[0] !== '/') { + url = id.startsWith(VALID_ID_PREFIX) + ? id + : VALID_ID_PREFIX + id.replace('\0', '__x00__') + } + return { + id: resolved.id, + url, + optimized: isOptimized, + } + } + + private async resolveMockId(rawId: string, importer: string) { + if (!importer.startsWith(this.server.config.root)) { + importer = join(this.server.config.root, importer) + } + const resolved = await this.server.pluginContainer.resolveId( + rawId, + importer, + { + ssr: false, + }, + ) + return this.resolveModule(rawId, resolved) + } + + private resolveModule(rawId: string, resolved: PartialResolvedId | null) { + const id = resolved?.id || rawId + const external + = !isAbsolute(id) || isModuleDirectory(this.options, id) ? rawId : null + return { + id, + fsPath: cleanUrl(id), + external, + } + } +} + +function isModuleDirectory(config: ServerResolverOptions, path: string) { + const moduleDirectories = config.moduleDirectories || [ + '/node_modules/', + ] + return moduleDirectories.some((dir: string) => path.includes(dir)) +} + +interface PartialManifest { + [name: string]: { + hash: string + needsInterop: boolean + } +} + +const metadata = new WeakMap() + +function getViteDepsManifest(config: ViteConfig) { + if (metadata.has(config)) { + return metadata.get(config)! + } + const cacheDirPath = getDepsCacheDir(config) + const metadataPath = resolve(cacheDirPath, '_metadata.json') + if (!existsSync(metadataPath)) { + return null + } + const { optimized } = JSON.parse(readFileSync(metadataPath, 'utf-8')) + const newManifest: PartialManifest = {} + for (const name in optimized) { + const dep = optimized[name] + const file = resolve(cacheDirPath, dep.file) + newManifest[file] = { + hash: dep.fileHash, + needsInterop: dep.needsInterop, + } + } + metadata.set(config, newManifest) + return newManifest +} + +function getDepsCacheDir(config: ViteConfig): string { + return resolve(config.cacheDir, 'deps') +} + +function withTrailingSlash(path: string): string { + if (path[path.length - 1] !== '/') { + return `${path}/` + } + + return path +} + +export interface ServerMockResolution { + mockType: 'manual' | 'redirect' | 'automock' | 'autospy' + resolvedId: string + needsInterop?: boolean + redirectUrl?: string | null +} + +export interface ServerIdResolution { + id: string + url: string + optimized: boolean +} diff --git a/packages/mocker/src/registry.ts b/packages/mocker/src/registry.ts new file mode 100644 index 000000000000..4412bc8fb48b --- /dev/null +++ b/packages/mocker/src/registry.ts @@ -0,0 +1,286 @@ +export class MockerRegistry { + private readonly registry: Map = new Map() + + clear(): void { + this.registry.clear() + } + + keys(): IterableIterator { + return this.registry.keys() + } + + add(mock: MockedModule): void { + this.registry.set(mock.url, mock) + } + + public register( + json: MockedModuleSerialized, + ): MockedModule + public register( + type: 'redirect', + raw: string, + url: string, + redirect: string, + ): RedirectedModule + public register( + type: 'manual', + raw: string, + url: string, + factory: () => any, + ): ManualMockedModule + public register( + type: 'automock', + raw: string, + url: string, + ): AutomockedModule + public register( + type: 'autospy', + raw: string, + url: string, + ): AutospiedModule + public register( + typeOrEvent: MockedModuleType | MockedModuleSerialized, + raw?: string, + url?: string, + factoryOrRedirect?: string | (() => any), + ): MockedModule { + const type = typeof typeOrEvent === 'object' ? typeOrEvent.type : typeOrEvent + + if (typeof typeOrEvent === 'object') { + const event = typeOrEvent + if ( + event instanceof AutomockedModule + || event instanceof AutospiedModule + || event instanceof ManualMockedModule + || event instanceof RedirectedModule + ) { + throw new TypeError( + `[vitest] Cannot register a mock that is already defined. ` + + `Expected a JSON representation from \`MockedModule.toJSON\`, instead got "${event.type}". ` + + `Use "registry.add()" to update a mock instead.`, + ) + } + if (event.type === 'automock') { + const module = AutomockedModule.fromJSON(event) + this.add(module) + return module + } + else if (event.type === 'autospy') { + const module = AutospiedModule.fromJSON(event) + this.add(module) + return module + } + else if (event.type === 'redirect') { + const module = RedirectedModule.fromJSON(event) + this.add(module) + return module + } + else if (event.type === 'manual') { + throw new Error(`Cannot set serialized manual mock. Define a factory function manually with \`ManualMockedModule.fromJSON()\`.`) + } + else { + throw new Error(`Unknown mock type: ${(event as any).type}`) + } + } + + if (typeof raw !== 'string') { + throw new TypeError('[vitest] Mocks require a raw string.') + } + + if (typeof url !== 'string') { + throw new TypeError('[vitest] Mocks require a url string.') + } + + if (type === 'manual') { + if (typeof factoryOrRedirect !== 'function') { + throw new TypeError('[vitest] Manual mocks require a factory function.') + } + const mock = new ManualMockedModule(raw, url, factoryOrRedirect) + this.add(mock) + return mock + } + else if (type === 'automock' || type === 'autospy') { + const mock = type === 'automock' + ? new AutomockedModule(raw, url) + : new AutospiedModule(raw, url) + this.add(mock) + return mock + } + else if (type === 'redirect') { + if (typeof factoryOrRedirect !== 'string') { + throw new TypeError('[vitest] Redirect mocks require a redirect string.') + } + const mock = new RedirectedModule(raw, url, factoryOrRedirect) + this.add(mock) + return mock + } + else { + throw new Error(`[vitest] Unknown mock type: ${type}`) + } + } + + public delete(id: string): void { + this.registry.delete(id) + } + + public get(id: string): MockedModule | undefined { + return this.registry.get(id) + } + + public has(id: string): boolean { + return this.registry.has(id) + } +} + +export type MockedModule = + | AutomockedModule + | AutospiedModule + | ManualMockedModule + | RedirectedModule +export type MockedModuleType = 'automock' | 'autospy' | 'manual' | 'redirect' + +export type MockedModuleSerialized = + | AutomockedModuleSerialized + | AutospiedModuleSerialized + | ManualMockedModuleSerialized + | RedirectedModuleSerialized + +export class AutomockedModule { + public readonly type = 'automock' + + constructor( + public raw: string, + public url: string, + ) {} + + static fromJSON(data: AutomockedModuleSerialized): AutospiedModule { + return new AutospiedModule(data.raw, data.url) + } + + toJSON(): AutomockedModuleSerialized { + return { + type: this.type, + url: this.url, + raw: this.raw, + } + } +} + +export interface AutomockedModuleSerialized { + type: 'automock' + url: string + raw: string +} + +export class AutospiedModule { + public readonly type = 'autospy' + + constructor( + public raw: string, + public url: string, + ) {} + + static fromJSON(data: AutospiedModuleSerialized): AutospiedModule { + return new AutospiedModule(data.raw, data.url) + } + + toJSON(): AutospiedModuleSerialized { + return { + type: this.type, + url: this.url, + raw: this.raw, + } + } +} + +export interface AutospiedModuleSerialized { + type: 'autospy' + url: string + raw: string +} + +export class RedirectedModule { + public readonly type = 'redirect' + + constructor( + public raw: string, + public url: string, + public redirect: string, + ) {} + + static fromJSON(data: RedirectedModuleSerialized): RedirectedModule { + return new RedirectedModule(data.raw, data.url, data.redirect) + } + + toJSON(): RedirectedModuleSerialized { + return { + type: this.type, + url: this.url, + raw: this.raw, + redirect: this.redirect, + } + } +} + +export interface RedirectedModuleSerialized { + type: 'redirect' + url: string + raw: string + redirect: string +} + +export class ManualMockedModule { + public cache: Record | undefined + public readonly type = 'manual' + + constructor( + public raw: string, + public url: string, + public factory: () => any, + ) {} + + async resolve(): Promise> { + if (this.cache) { + return this.cache + } + let exports: any + try { + exports = await this.factory() + } + catch (err) { + const vitestError = new Error( + '[vitest] There was an error when mocking a module. ' + + 'If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. ' + + 'Read more: https://vitest.dev/api/vi.html#vi-mock', + ) + vitestError.cause = err + throw vitestError + } + + if (exports === null || typeof exports !== 'object' || Array.isArray(exports)) { + throw new TypeError( + `[vitest] vi.mock("${this.raw}", factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?`, + ) + } + + return (this.cache = exports) + } + + static fromJSON(data: ManualMockedModuleSerialized, factory: () => any): ManualMockedModule { + return new ManualMockedModule(data.raw, data.url, factory) + } + + toJSON(): ManualMockedModuleSerialized { + return { + type: this.type, + url: this.url, + raw: this.raw, + } + } +} + +export interface ManualMockedModuleSerialized { + type: 'manual' + url: string + raw: string +} diff --git a/packages/mocker/src/types.ts b/packages/mocker/src/types.ts new file mode 100644 index 000000000000..caf9964d47e1 --- /dev/null +++ b/packages/mocker/src/types.ts @@ -0,0 +1,9 @@ +type Awaitable = T | PromiseLike + +export type ModuleMockFactoryWithHelper = ( + importOriginal: () => Promise +) => Awaitable> +export type ModuleMockFactory = () => any +export interface ModuleMockOptions { + spy?: boolean +} diff --git a/packages/mocker/src/utils.ts b/packages/mocker/src/utils.ts new file mode 100644 index 000000000000..c07da4712716 --- /dev/null +++ b/packages/mocker/src/utils.ts @@ -0,0 +1,4 @@ +const postfixRE = /[?#].*$/ +export function cleanUrl(url: string): string { + return url.replace(postfixRE, '') +} diff --git a/packages/mocker/tsconfig.json b/packages/mocker/tsconfig.json new file mode 100644 index 000000000000..5a89697f0a07 --- /dev/null +++ b/packages/mocker/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "moduleResolution": "Bundler", + "types": ["vite/client"], + "isolatedDeclarations": true + }, + "include": ["src/**/*"], + "exclude": ["**/dist/**"] +} diff --git a/packages/utils/package.json b/packages/utils/package.json index eb18be96254b..4f7f76b80359 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -65,7 +65,6 @@ }, "dependencies": { "@vitest/pretty-format": "workspace:*", - "estree-walker": "^3.0.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, diff --git a/packages/utils/rollup.config.js b/packages/utils/rollup.config.js index 8fe89ec76887..7110b6b18291 100644 --- a/packages/utils/rollup.config.js +++ b/packages/utils/rollup.config.js @@ -13,7 +13,6 @@ const entries = { 'index': 'src/index.ts', 'helpers': 'src/helpers.ts', 'diff': 'src/diff/index.ts', - 'ast': 'src/ast/index.ts', 'error': 'src/error.ts', 'source-map': 'src/source-map.ts', 'types': 'src/types.ts', diff --git a/packages/utils/src/ast/index.ts b/packages/utils/src/ast/index.ts deleted file mode 100644 index 370a401ccc51..000000000000 --- a/packages/utils/src/ast/index.ts +++ /dev/null @@ -1,376 +0,0 @@ -import type { - CallExpression, - Function as FunctionNode, - Identifier, - ImportExpression, - Pattern, - Property, - VariableDeclaration, - Node as _Node, -} from 'estree' -import { walk as eswalk } from 'estree-walker' - -export type * from 'estree' - -export type Positioned = T & { - start: number - end: number -} - -export type Node = Positioned<_Node> - -interface IdentifierInfo { - /** - * If the identifier is used in a property shorthand - * { foo } -> { foo: __import_x__.foo } - */ - hasBindingShortcut: boolean - /** - * The identifier is used in a class declaration - */ - classDeclaration: boolean - /** - * The identifier is a name for a class expression - */ - classExpression: boolean -} - -interface Visitors { - onIdentifier?: ( - node: Positioned, - info: IdentifierInfo, - parentStack: Node[] - ) => void - onImportMeta?: (node: Node) => void - onDynamicImport?: (node: Positioned) => void - onCallExpression?: (node: Positioned) => void -} - -const isNodeInPatternWeakSet = new WeakSet<_Node>() -export function setIsNodeInPattern(node: Property): WeakSet<_Node> { - return isNodeInPatternWeakSet.add(node) -} -export function isNodeInPattern(node: _Node): node is Property { - return isNodeInPatternWeakSet.has(node) -} - -/** - * Same logic from \@vue/compiler-core & \@vue/compiler-sfc - * Except this is using acorn AST - */ -export function esmWalker( - root: Node, - { onIdentifier, onImportMeta, onDynamicImport, onCallExpression }: Visitors, -): void { - const parentStack: Node[] = [] - const varKindStack: VariableDeclaration['kind'][] = [] - const scopeMap = new WeakMap<_Node, Set>() - const identifiers: [id: any, stack: Node[]][] = [] - - const setScope = (node: _Node, name: string) => { - let scopeIds = scopeMap.get(node) - if (scopeIds && scopeIds.has(name)) { - return - } - - if (!scopeIds) { - scopeIds = new Set() - scopeMap.set(node, scopeIds) - } - scopeIds.add(name) - } - - function isInScope(name: string, parents: Node[]) { - return parents.some(node => node && scopeMap.get(node)?.has(name)) - } - function handlePattern(p: Pattern, parentScope: _Node) { - if (p.type === 'Identifier') { - setScope(parentScope, p.name) - } - else if (p.type === 'RestElement') { - handlePattern(p.argument, parentScope) - } - else if (p.type === 'ObjectPattern') { - p.properties.forEach((property) => { - if (property.type === 'RestElement') { - setScope(parentScope, (property.argument as Identifier).name) - } - else { - handlePattern(property.value, parentScope) - } - }) - } - else if (p.type === 'ArrayPattern') { - p.elements.forEach((element) => { - if (element) { - handlePattern(element, parentScope) - } - }) - } - else if (p.type === 'AssignmentPattern') { - handlePattern(p.left, parentScope) - } - else { - setScope(parentScope, (p as any).name) - } - } - - eswalk(root, { - enter(node, parent) { - if (node.type === 'ImportDeclaration') { - return this.skip() - } - - // track parent stack, skip for "else-if"/"else" branches as acorn nests - // the ast within "if" nodes instead of flattening them - if ( - parent - && !(parent.type === 'IfStatement' && node === parent.alternate) - ) { - parentStack.unshift(parent as Node) - } - - // track variable declaration kind stack used by VariableDeclarator - if (node.type === 'VariableDeclaration') { - varKindStack.unshift(node.kind) - } - - if (node.type === 'CallExpression') { - onCallExpression?.(node as Positioned) - } - - if (node.type === 'MetaProperty' && node.meta.name === 'import') { - onImportMeta?.(node as Node) - } - else if (node.type === 'ImportExpression') { - onDynamicImport?.(node as Positioned) - } - - if (node.type === 'Identifier') { - if ( - !isInScope(node.name, parentStack) - && isRefIdentifier(node, parent!, parentStack) - ) { - // record the identifier, for DFS -> BFS - identifiers.push([node, parentStack.slice(0)]) - } - } - else if (isFunctionNode(node)) { - // If it is a function declaration, it could be shadowing an import - // Add its name to the scope so it won't get replaced - if (node.type === 'FunctionDeclaration') { - const parentScope = findParentScope(parentStack) - if (parentScope) { - setScope(parentScope, node.id!.name) - } - } - // walk function expressions and add its arguments to known identifiers - // so that we don't prefix them - node.params.forEach((p) => { - if (p.type === 'ObjectPattern' || p.type === 'ArrayPattern') { - handlePattern(p, node) - return - } - (eswalk as any)(p.type === 'AssignmentPattern' ? p.left : p, { - enter(child: Node, parent: Node) { - // skip params default value of destructure - if ( - parent?.type === 'AssignmentPattern' - && parent?.right === child - ) { - return this.skip() - } - - if (child.type !== 'Identifier') { - return - } - // do not record as scope variable if is a destructuring keyword - if (isStaticPropertyKey(child, parent)) { - return - } - // do not record if this is a default value - // assignment of a destructuring variable - if ( - (parent?.type === 'TemplateLiteral' - && parent?.expressions.includes(child)) - || (parent?.type === 'CallExpression' && parent?.callee === child) - ) { - return - } - - setScope(node, child.name) - }, - }) - }) - } - else if (node.type === 'Property' && parent!.type === 'ObjectPattern') { - // mark property in destructuring pattern - setIsNodeInPattern(node) - } - else if (node.type === 'VariableDeclarator') { - const parentFunction = findParentScope( - parentStack, - varKindStack[0] === 'var', - ) - if (parentFunction) { - handlePattern(node.id, parentFunction) - } - } - else if (node.type === 'CatchClause' && node.param) { - handlePattern(node.param, node) - } - }, - - leave(node, parent) { - // untrack parent stack from above - if ( - parent - && !(parent.type === 'IfStatement' && node === parent.alternate) - ) { - parentStack.shift() - } - - if (node.type === 'VariableDeclaration') { - varKindStack.shift() - } - }, - }) - - // emit the identifier events in BFS so the hoisted declarations - // can be captured correctly - identifiers.forEach(([node, stack]) => { - if (!isInScope(node.name, stack)) { - const parent = stack[0] - const grandparent = stack[1] - const hasBindingShortcut - = isStaticProperty(parent) - && parent.shorthand - && (!isNodeInPattern(parent) - || isInDestructuringAssignment(parent, parentStack)) - - const classDeclaration - = (parent.type === 'PropertyDefinition' - && grandparent?.type === 'ClassBody') - || (parent.type === 'ClassDeclaration' && node === parent.superClass) - - const classExpression - = parent.type === 'ClassExpression' && node === parent.id - - onIdentifier?.( - node, - { - hasBindingShortcut, - classDeclaration, - classExpression, - }, - stack, - ) - } - }) -} - -function isRefIdentifier(id: Identifier, parent: _Node, parentStack: _Node[]) { - // declaration id - if ( - parent.type === 'CatchClause' - || ((parent.type === 'VariableDeclarator' - || parent.type === 'ClassDeclaration') - && parent.id === id) - ) { - return false - } - - if (isFunctionNode(parent)) { - // function declaration/expression id - if ((parent as any).id === id) { - return false - } - - // params list - if (parent.params.includes(id)) { - return false - } - } - - // class method name - if (parent.type === 'MethodDefinition' && !parent.computed) { - return false - } - - // property key - if (isStaticPropertyKey(id, parent)) { - return false - } - - // object destructuring pattern - if (isNodeInPattern(parent) && parent.value === id) { - return false - } - - // non-assignment array destructuring pattern - if ( - parent.type === 'ArrayPattern' - && !isInDestructuringAssignment(parent, parentStack) - ) { - return false - } - - // member expression property - if ( - parent.type === 'MemberExpression' - && parent.property === id - && !parent.computed - ) { - return false - } - - if (parent.type === 'ExportSpecifier') { - return false - } - - // is a special keyword but parsed as identifier - if (id.name === 'arguments') { - return false - } - - return true -} - -export function isStaticProperty(node: _Node): node is Property { - return node && node.type === 'Property' && !node.computed -} - -export function isStaticPropertyKey(node: _Node, parent: _Node): boolean { - return isStaticProperty(parent) && parent.key === node -} - -const functionNodeTypeRE = /Function(?:Expression|Declaration)$|Method$/ -export function isFunctionNode(node: _Node): node is FunctionNode { - return functionNodeTypeRE.test(node.type) -} - -const blockNodeTypeRE = /^BlockStatement$|^For(?:In|Of)?Statement$/ -function isBlock(node: _Node) { - return blockNodeTypeRE.test(node.type) -} - -function findParentScope( - parentStack: _Node[], - isVar = false, -): _Node | undefined { - return parentStack.find(isVar ? isFunctionNode : isBlock) -} - -export function isInDestructuringAssignment( - parent: _Node, - parentStack: _Node[], -): boolean { - if ( - parent - && (parent.type === 'Property' || parent.type === 'ArrayPattern') - ) { - return parentStack.some(i => i.type === 'AssignmentExpression') - } - - return false -} diff --git a/packages/vitest/LICENSE.md b/packages/vitest/LICENSE.md index e012ba88698a..d54def96b44e 100644 --- a/packages/vitest/LICENSE.md +++ b/packages/vitest/LICENSE.md @@ -1557,6 +1557,35 @@ Repository: git+https://github.com/antfu/strip-literal.git --------------------------------------- +## tinyexec +License: MIT +By: James Garbutt +Repository: git+https://github.com/tinylibs/tinyexec.git + +> MIT License +> +> Copyright (c) 2024 Tinylibs +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +--------------------------------------- + ## to-regex-range License: MIT By: Jon Schlinkert, Rouven Weßling diff --git a/packages/vitest/mocker.d.ts b/packages/vitest/mocker.d.ts new file mode 100644 index 000000000000..38df3da1924a --- /dev/null +++ b/packages/vitest/mocker.d.ts @@ -0,0 +1 @@ +export * from './dist/mocker.js' diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 1c5e1b96e646..35590d2d8717 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -94,6 +94,10 @@ "./snapshot": { "types": "./dist/snapshot.d.ts", "default": "./dist/snapshot.js" + }, + "./mocker": { + "types": "./dist/mocker.d.ts", + "default": "./dist/mocker.js" } }, "main": "./dist/index.js", @@ -148,6 +152,7 @@ "dependencies": { "@ampproject/remapping": "^2.3.0", "@vitest/expect": "workspace:*", + "@vitest/mocker": "workspace:*", "@vitest/pretty-format": "workspace:^", "@vitest/runner": "workspace:*", "@vitest/snapshot": "workspace:*", diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js index 4652deaec581..6e255d93330a 100644 --- a/packages/vitest/rollup.config.js +++ b/packages/vitest/rollup.config.js @@ -24,6 +24,7 @@ const entries = { 'browser': 'src/public/browser.ts', 'runners': 'src/public/runners.ts', 'environments': 'src/public/environments.ts', + 'mocker': 'src/public/mocker.ts', 'spy': 'src/integrations/spy.ts', 'coverage': 'src/public/coverage.ts', 'utils': 'src/public/utils.ts', @@ -56,6 +57,7 @@ const dtsEntries = { utils: 'src/public/utils.ts', execute: 'src/public/execute.ts', reporters: 'src/public/reporters.ts', + mocker: 'src/public/mocker.ts', workers: 'src/public/workers.ts', snapshot: 'src/public/snapshot.ts', } @@ -76,6 +78,8 @@ const external = [ 'vite-node/server', 'vite-node/constants', 'vite-node/utils', + '@vitest/mocker', + '@vitest/mocker/node', '@vitest/utils/diff', '@vitest/utils/ast', '@vitest/utils/error', diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 611d0281fc0b..c378884f008e 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -3,7 +3,7 @@ import { assertTypes, createSimpleStackTrace } from '@vitest/utils' import { parseSingleStack } from '../utils/source-map' import type { VitestMocker } from '../runtime/mocker' import type { RuntimeOptions, SerializedConfig } from '../runtime/config' -import type { MockFactoryWithHelper } from '../types/mocker' +import type { MockFactoryWithHelper, MockOptions } from '../types/mocker' import { getWorkerState } from '../runtime/utils' import { resetModules, waitForImportsToResolve } from '../utils/modules' import { isChildProcess } from '../utils/base' @@ -199,9 +199,9 @@ export interface VitestUtils { * @param factory Mocked module factory. The result of this function will be an exports object */ // eslint-disable-next-line ts/method-signature-style - mock(path: string, factory?: MockFactoryWithHelper): void + mock(path: string, factory?: MockFactoryWithHelper | MockOptions): void // eslint-disable-next-line ts/method-signature-style - mock(module: Promise, factory?: MockFactoryWithHelper): void + mock(module: Promise, factory?: MockFactoryWithHelper | MockOptions): void /** * Removes module from mocked registry. All calls to import will return the original module even if it was mocked before. @@ -224,9 +224,9 @@ export interface VitestUtils { * @param factory Mocked module factory. The result of this function will be an exports object */ // eslint-disable-next-line ts/method-signature-style - doMock(path: string, factory?: MockFactoryWithHelper): void + doMock(path: string, factory?: MockFactoryWithHelper | MockOptions): void // eslint-disable-next-line ts/method-signature-style - doMock(module: Promise, factory?: MockFactoryWithHelper): void + doMock(module: Promise, factory?: MockFactoryWithHelper | MockOptions): void /** * Removes module from mocked registry. All subsequent calls to import will return original module. * @@ -555,7 +555,7 @@ function createVitest(): VitestUtils { return factory() }, - mock(path: string | Promise, factory?: MockFactoryWithHelper) { + mock(path: string | Promise, factory?: MockOptions | MockFactoryWithHelper) { if (typeof path !== 'string') { throw new TypeError( `vi.mock() expects a string path, but received a ${typeof path}`, @@ -565,7 +565,7 @@ function createVitest(): VitestUtils { _mocker().queueMock( path, importer, - factory + typeof factory === 'function' ? () => factory(() => _mocker().importActual( @@ -574,8 +574,7 @@ function createVitest(): VitestUtils { _mocker().getMockContext().callstack, ), ) - : undefined, - true, + : factory, ) }, @@ -588,7 +587,7 @@ function createVitest(): VitestUtils { _mocker().queueUnmock(path, getImporter('unmock')) }, - doMock(path: string | Promise, factory?: MockFactoryWithHelper) { + doMock(path: string | Promise, factory?: MockOptions | MockFactoryWithHelper) { if (typeof path !== 'string') { throw new TypeError( `vi.doMock() expects a string path, but received a ${typeof path}`, @@ -598,7 +597,7 @@ function createVitest(): VitestUtils { _mocker().queueMock( path, importer, - factory + typeof factory === 'function' ? () => factory(() => _mocker().importActual( @@ -607,8 +606,7 @@ function createVitest(): VitestUtils { _mocker().getMockContext().callstack, ), ) - : undefined, - false, + : factory, ) }, diff --git a/packages/vitest/src/node/plugins/mocks.ts b/packages/vitest/src/node/plugins/mocks.ts index 267871d9915d..865496d762f9 100644 --- a/packages/vitest/src/node/plugins/mocks.ts +++ b/packages/vitest/src/node/plugins/mocks.ts @@ -1,33 +1,25 @@ import type { Plugin } from 'vite' -import { cleanUrl } from 'vite-node/utils' -import { hoistMocks } from '../hoistMocks' +import { automockPlugin, hoistMocksPlugin } from '@vitest/mocker/node' import { distDir } from '../../paths' -import { automockModule } from '../automock' +import { generateCodeFrame } from '../error' export function MocksPlugins(): Plugin[] { return [ - { - name: 'vitest:mocks', - enforce: 'post', - transform(code, id) { + hoistMocksPlugin({ + filter(id) { if (id.includes(distDir)) { - return + return false } - return hoistMocks(code, id, this.parse) + return true }, - }, - { - name: 'vitest:automock', - enforce: 'post', - transform(code, id) { - if (id.includes('mock=auto')) { - const ms = automockModule(code, this.parse) - return { - code: ms.toString(), - map: ms.generateMap({ hires: true, source: cleanUrl(id) }), - } - } + codeFrameGenerator(node, id, code) { + return generateCodeFrame( + code, + 4, + node.start + 1, + ) }, - }, + }), + automockPlugin(), ] } diff --git a/packages/vitest/src/public/mocker.ts b/packages/vitest/src/public/mocker.ts new file mode 100644 index 000000000000..f898a39ad42c --- /dev/null +++ b/packages/vitest/src/public/mocker.ts @@ -0,0 +1 @@ +export * from '@vitest/mocker' diff --git a/packages/vitest/src/runtime/execute.ts b/packages/vitest/src/runtime/execute.ts index 441e659c42a3..4566f41ddc37 100644 --- a/packages/vitest/src/runtime/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -13,7 +13,6 @@ import type { ViteNodeRunnerOptions } from 'vite-node' import { normalize, relative } from 'pathe' import { processError } from '@vitest/utils/error' import { distDir } from '../paths' -import type { MockMap } from '../types/mocker' import type { WorkerGlobalState } from '../types/worker' import { VitestMocker } from './mocker' import type { ExternalModulesExecutor } from './external-executor' @@ -21,7 +20,6 @@ import type { ExternalModulesExecutor } from './external-executor' const { readFileSync } = fs export interface ExecuteOptions extends ViteNodeRunnerOptions { - mockMap: MockMap moduleDirectories?: string[] state: WorkerGlobalState context?: vm.Context @@ -40,7 +38,6 @@ export async function createVitestExecutor(options: ExecuteOptions) { const externalizeMap = new Map() export interface ContextExecutorOptions { - mockMap?: MockMap moduleCache?: ModuleCacheMap context?: vm.Context externalModulesExecutor?: ExternalModulesExecutor @@ -139,9 +136,6 @@ export async function startVitestExecutor(options: ContextExecutorOptions) { get moduleCache() { return state().moduleCache }, - get mockMap() { - return state().mockMap - }, get interopDefault() { return state().config.deps.interopDefault }, diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/mocker.ts index 7d3d0819a048..4a8de04be4ae 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -1,39 +1,15 @@ -import fs from 'node:fs' import vm from 'node:vm' -import { basename, dirname, extname, isAbsolute, join, resolve } from 'pathe' -import { getType, highlight } from '@vitest/utils' -import { isNodeBuiltin } from 'vite-node/utils' +import { isAbsolute, resolve } from 'pathe' +import { highlight } from '@vitest/utils' +import type { ManualMockedModule, MockedModuleType } from '@vitest/mocker' +import { AutomockedModule, MockerRegistry, RedirectedModule, mockObject } from '@vitest/mocker' +import { findMockRedirect } from '@vitest/mocker/redirect' import { distDir } from '../paths' -import { getAllMockableProperties } from '../utils/base' -import type { MockFactory, PendingSuiteMock } from '../types/mocker' +import type { MockFactory, MockOptions, PendingSuiteMock } from '../types/mocker' import type { VitestExecutor } from './execute' -const { existsSync, readdirSync } = fs - const spyModulePath = resolve(distDir, 'spy.js') -class RefTracker { - private idMap = new Map() - private mockedValueMap = new Map() - - public getId(value: any) { - return this.idMap.get(value) - } - - public getMockedValue(id: number) { - return this.mockedValueMap.get(id) - } - - public track(originalValue: any, mockedValue: any): number { - const newId = this.idMap.size - this.idMap.set(originalValue, newId) - this.mockedValueMap.set(newId, mockedValue) - return newId - } -} - -type Key = string | symbol - interface MockContext { /** * When mocking with a factory, this refers to the module that imported the mock. @@ -41,18 +17,9 @@ interface MockContext { callstack: null | string[] } -function isSpecialProp(prop: Key, parentType: string) { - return ( - parentType.includes('Function') - && typeof prop === 'string' - && ['arguments', 'callee', 'caller', 'length', 'name'].includes(prop) - ) -} - export class VitestMocker { static pendingIds: PendingSuiteMock[] = [] private spyModule?: typeof import('@vitest/spy') - private resolveCache = new Map>() private primitives: { Object: typeof Object Function: typeof Function @@ -65,6 +32,8 @@ export class VitestMocker { private filterPublicKeys: (symbol | string)[] + private registries = new Map() + private mockContext: MockContext = { callstack: null, } @@ -113,10 +82,6 @@ export class VitestMocker { return this.executor.options.root } - private get mockMap() { - return this.executor.options.mockMap - } - private get moduleCache() { return this.executor.moduleCache } @@ -129,6 +94,18 @@ export class VitestMocker { this.spyModule = await this.executor.executeId(spyModulePath) } + private getMockerRegistry() { + const suite = this.getSuiteFilepath() + if (!this.registries.has(suite)) { + this.registries.set(suite, new MockerRegistry()) + } + return this.registries.get(suite)! + } + + public reset() { + this.registries.clear() + } + private deleteCachedItem(id: string) { const mockId = this.getMockPath(id) if (this.moduleCache.has(mockId)) { @@ -151,17 +128,6 @@ export class VitestMocker { return error } - public getMocks() { - const suite = this.getSuiteFilepath() - const suiteMocks = this.mockMap.get(suite) - const globalMocks = this.mockMap.get('global') - - return { - ...globalMocks, - ...suiteMocks, - } - } - private async resolvePath(rawId: string, importer: string) { let id: string let fsPath: string @@ -203,11 +169,17 @@ export class VitestMocker { mock.id, mock.importer, ) - if (mock.type === 'unmock') { + if (mock.action === 'unmock') { this.unmockPath(fsPath) } - if (mock.type === 'mock') { - this.mockPath(mock.id, fsPath, external, mock.factory) + if (mock.action === 'mock') { + this.mockPath( + mock.id, + fsPath, + external, + mock.type, + mock.factory, + ) } }), ) @@ -215,34 +187,12 @@ export class VitestMocker { VitestMocker.pendingIds = [] } - private async callFunctionMock(dep: string, mock: MockFactory) { + private async callFunctionMock(dep: string, mock: ManualMockedModule) { const cached = this.moduleCache.get(dep)?.exports if (cached) { return cached } - let exports: any - try { - exports = await mock() - } - catch (err) { - const vitestError = this.createError( - '[vitest] There was an error when mocking a module. ' - + 'If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. ' - + 'Read more: https://vitest.dev/api/vi.html#vi-mock', - ) - vitestError.cause = err - throw vitestError - } - - const filepath = dep.slice(5) - const mockpath - = this.resolveCache.get(this.getSuiteFilepath())?.[filepath] || filepath - - if (exports === null || typeof exports !== 'object') { - throw this.createError( - `[vitest] vi.mock("${mockpath}", factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?`, - ) - } + const exports = await mock.resolve() const moduleExports = new Proxy(exports, { get: (target, prop) => { @@ -259,12 +209,10 @@ export class VitestMocker { return undefined } throw this.createError( - `[vitest] No "${String( - prop, - )}" export is defined on the "${mockpath}" mock. ` + `[vitest] No "${String(prop)}" export is defined on the "${mock.raw}" mock. ` + 'Did you forget to return it from "vi.mock"?' + '\nIf you need to partially mock a module, you can use "importOriginal" helper inside:\n', - highlight(`vi.mock("${mockpath}", async (importOriginal) => { + highlight(`vi.mock(import("${mock.raw}"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, @@ -283,16 +231,19 @@ export class VitestMocker { return moduleExports } + // public method to avoid circular dependency public getMockContext() { return this.mockContext } + // path used to store mocked dependencies public getMockPath(dep: string) { return `mock:${dep}` } public getDependencyMock(id: string) { - return this.getMocks()[id] + const registry = this.getMockerRegistry() + return registry.get(id) } public normalizePath(path: string) { @@ -300,211 +251,32 @@ export class VitestMocker { } public resolveMockPath(mockPath: string, external: string | null) { - const path = external || mockPath - - // it's a node_module alias - // all mocks should be inside /__mocks__ - if (external || isNodeBuiltin(mockPath) || !existsSync(mockPath)) { - const mockDirname = dirname(path) // for nested mocks: @vueuse/integration/useJwt - const mockFolder = join(this.root, '__mocks__', mockDirname) - - if (!existsSync(mockFolder)) { - return null - } - - const baseOriginal = basename(path) - - function findFile(mockFolder: string, baseOriginal: string): string | null { - const files = readdirSync(mockFolder) - for (const file of files) { - const baseFile = basename(file, extname(file)) - if (baseFile === baseOriginal) { - const path = resolve(mockFolder, file) - // if the same name, return the file - if (fs.statSync(path).isFile()) { - return path - } - else { - // find folder/index.{js,ts} - const indexFile = findFile(path, 'index') - if (indexFile) { - return indexFile - } - } - } - } - return null - } - - return findFile(mockFolder, baseOriginal) - } - - const dir = dirname(path) - const baseId = basename(path) - const fullPath = resolve(dir, '__mocks__', baseId) - return existsSync(fullPath) ? fullPath : null + return findMockRedirect(this.root, mockPath, external) } public mockObject( - object: Record, - mockExports: Record = {}, + object: Record, + mockExports: Record = {}, + behavior: MockedModuleType = 'automock', ) { - const finalizers = new Array<() => void>() - const refs = new RefTracker() - - const define = (container: Record, key: Key, value: any) => { - try { - container[key] = value - return true - } - catch { - return false - } - } - - const mockPropertiesOf = ( - container: Record, - newContainer: Record, - ) => { - const containerType = getType(container) - const isModule = containerType === 'Module' || !!container.__esModule - for (const { key: property, descriptor } of getAllMockableProperties( - container, - isModule, - this.primitives, - )) { - // Modules define their exports as getters. We want to process those. - if (!isModule && descriptor.get) { - try { - Object.defineProperty(newContainer, property, descriptor) - } - catch { - // Ignore errors, just move on to the next prop. - } - continue - } - - // Skip special read-only props, we don't want to mess with those. - if (isSpecialProp(property, containerType)) { - continue - } - - const value = container[property] - - // Special handling of references we've seen before to prevent infinite - // recursion in circular objects. - const refId = refs.getId(value) - if (refId !== undefined) { - finalizers.push(() => - define(newContainer, property, refs.getMockedValue(refId)), - ) - continue - } - - const type = getType(value) - - if (Array.isArray(value)) { - define(newContainer, property, []) - continue - } - - const isFunction - = type.includes('Function') && typeof value === 'function' - if ( - (!isFunction || value.__isMockFunction) - && type !== 'Object' - && type !== 'Module' - ) { - define(newContainer, property, value) - continue - } - - // Sometimes this assignment fails for some unknown reason. If it does, - // just move along. - if (!define(newContainer, property, isFunction ? value : {})) { - continue - } - - if (isFunction) { - if (!this.spyModule) { - throw this.createError( - '[vitest] `spyModule` is not defined. This is Vitest error. Please open a new issue with reproduction.', - ) - } - const spyModule = this.spyModule - const primitives = this.primitives - function mockFunction(this: any) { - // detect constructor call and mock each instance's methods - // so that mock states between prototype/instances don't affect each other - // (jest reference https://github.com/jestjs/jest/blob/2c3d2409879952157433de215ae0eee5188a4384/packages/jest-mock/src/index.ts#L678-L691) - if (this instanceof newContainer[property]) { - for (const { key, descriptor } of getAllMockableProperties( - this, - false, - primitives, - )) { - // skip getter since it's not mocked on prototype as well - if (descriptor.get) { - continue - } - - const value = this[key] - const type = getType(value) - const isFunction - = type.includes('Function') && typeof value === 'function' - if (isFunction) { - // mock and delegate calls to original prototype method, which should be also mocked already - const original = this[key] - const mock = spyModule - .spyOn(this, key as string) - .mockImplementation(original) - mock.mockRestore = () => { - mock.mockReset() - mock.mockImplementation(original) - return mock - } - } - } - } - } - const mock = spyModule - .spyOn(newContainer, property) - .mockImplementation(mockFunction) - mock.mockRestore = () => { - mock.mockReset() - mock.mockImplementation(mockFunction) - return mock - } - // tinyspy retains length, but jest doesn't. - Object.defineProperty(newContainer[property], 'length', { value: 0 }) - } - - refs.track(value, newContainer[property]) - mockPropertiesOf(value, newContainer[property]) - } - } - - const mockedObject: Record = mockExports - mockPropertiesOf(object, mockedObject) - - // Plug together refs - for (const finalizer of finalizers) { - finalizer() + const spyOn = this.spyModule?.spyOn + if (!spyOn) { + throw this.createError( + '[vitest] `spyModule` is not defined. This is a Vitest error. Please open a new issue with reproduction.', + ) } - - return mockedObject + return mockObject({ + globalConstructors: this.primitives, + spyOn, + type: behavior, + }, object, mockExports) } public unmockPath(path: string) { - const suitefile = this.getSuiteFilepath() - + const registry = this.getMockerRegistry() const id = this.normalizePath(path) - const mock = this.mockMap.get(suitefile) - if (mock && id in mock) { - delete mock[id] - } - + registry.delete(id) this.deleteCachedItem(id) } @@ -512,30 +284,29 @@ export class VitestMocker { originalId: string, path: string, external: string | null, + mockType: MockedModuleType | undefined, factory: MockFactory | undefined, ) { + const registry = this.getMockerRegistry() const id = this.normalizePath(path) - // const { config } = this.executor.state - // const isIsolatedThreads = config.pool === 'threads' && (config.poolOptions?.threads?.isolate ?? true) - // const isIsolatedForks = config.pool === 'forks' && (config.poolOptions?.forks?.isolate ?? true) - - // TODO: find a good way to throw this error even in non-isolated mode - // if (throwIfExists && (isIsolatedThreads || isIsolatedForks || config.pool === 'vmThreads')) { - // const cached = this.moduleCache.has(id) && this.moduleCache.getByModuleId(id) - // if (cached && cached.importers.size) - // throw new Error(`[vitest] Cannot mock "${originalId}" because it is already loaded by "${[...cached.importers.values()].map(i => relative(this.root, i)).join('", "')}".\n\nPlease, remove the import if you want static imports to be mocked, or clear module cache by calling "vi.resetModules()" before mocking if you are going to import the file again. See: https://vitest.dev/guide/common-errors.html#cannot-mock-mocked-file-js-because-it-is-already-loaded`) - // } - - const suitefile = this.getSuiteFilepath() - const mocks = this.mockMap.get(suitefile) || {} - const resolves = this.resolveCache.get(suitefile) || {} - - mocks[id] = factory || this.resolveMockPath(id, external) - resolves[id] = originalId + if (mockType === 'manual') { + registry.register('manual', originalId, id, factory!) + } + else if (mockType === 'autospy') { + registry.register('autospy', originalId, id) + } + else { + const redirect = this.resolveMockPath(id, external) + if (redirect) { + registry.register('redirect', originalId, id, redirect) + } + else { + registry.register('automock', originalId, id) + } + } - this.mockMap.set(suitefile, mocks) - this.resolveCache.set(suitefile, resolves) + // every time the mock is registered, we remove the previous one from the cache this.deleteCachedItem(id) } @@ -559,42 +330,51 @@ export class VitestMocker { const normalizedId = this.normalizePath(fsPath) let mock = this.getDependencyMock(normalizedId) - if (mock === undefined) { - mock = this.resolveMockPath(normalizedId, external) + if (!mock) { + const redirect = this.resolveMockPath(normalizedId, external) + if (redirect) { + mock = new RedirectedModule(rawId, normalizedId, redirect) + } + else { + mock = new AutomockedModule(rawId, normalizedId) + } } - if (mock === null) { + if (mock.type === 'automock' || mock.type === 'autospy') { const mod = await this.executor.cachedRequest(id, fsPath, [importee]) - return this.mockObject(mod) + return this.mockObject(mod, {}, mock.type) } - if (typeof mock === 'function') { + if (mock.type === 'manual') { return this.callFunctionMock(fsPath, mock) } - return this.executor.dependencyRequest(mock, mock, [importee]) + return this.executor.dependencyRequest(mock.redirect, mock.redirect, [importee]) } public async requestWithMock(url: string, callstack: string[]) { const id = this.normalizePath(url) const mock = this.getDependencyMock(id) + if (!mock) { + return + } + const mockPath = this.getMockPath(id) - if (mock === null) { + if (mock.type === 'automock' || mock.type === 'autospy') { const cache = this.moduleCache.get(mockPath) if (cache.exports) { return cache.exports } - const exports = {} // Assign the empty exports object early to allow for cycles to work. The object will be filled by mockObject() this.moduleCache.set(mockPath, { exports }) const mod = await this.executor.directRequest(url, url, callstack) - this.mockObject(mod, exports) + this.mockObject(mod, exports, mock.type) return exports } if ( - typeof mock === 'function' + mock.type === 'manual' && !callstack.includes(mockPath) && !callstack.includes(url) ) { @@ -612,32 +392,41 @@ export class VitestMocker { callstack.splice(indexMock, 1) } } - if (typeof mock === 'string' && !callstack.includes(mock)) { - return mock + else if (mock.type === 'redirect' && !callstack.includes(mock.redirect)) { + return mock.redirect } } public queueMock( id: string, importer: string, - factory?: MockFactory, - throwIfCached = false, + factoryOrOptions?: MockFactory | MockOptions, ) { + const mockType = getMockType(factoryOrOptions) VitestMocker.pendingIds.push({ - type: 'mock', + action: 'mock', id, importer, - factory, - throwIfCached, + factory: typeof factoryOrOptions === 'function' ? factoryOrOptions : undefined, + type: mockType, }) } - public queueUnmock(id: string, importer: string, throwIfCached = false) { + public queueUnmock(id: string, importer: string) { VitestMocker.pendingIds.push({ - type: 'unmock', + action: 'unmock', id, importer, - throwIfCached, }) } } + +function getMockType(factoryOrOptions?: MockFactory | MockOptions): MockedModuleType { + if (!factoryOrOptions) { + return 'automock' + } + if (typeof factoryOrOptions === 'function') { + return 'manual' + } + return factoryOrOptions.spy ? 'autospy' : 'automock' +} diff --git a/packages/vitest/src/runtime/runBaseTests.ts b/packages/vitest/src/runtime/runBaseTests.ts index 17e04c0f6a06..223090f5a25f 100644 --- a/packages/vitest/src/runtime/runBaseTests.ts +++ b/packages/vitest/src/runtime/runBaseTests.ts @@ -57,7 +57,7 @@ export async function run( && (config.poolOptions?.forks?.isolate ?? true) if (isIsolatedThreads || isIsolatedForks) { - workerState.mockMap.clear() + executor.mocker.reset() resetModules(workerState.moduleCache, true) } diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index e8bc761acc11..c35f7a6733b7 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -3,7 +3,7 @@ import { workerId as poolId } from 'tinypool' import { ModuleCacheMap } from 'vite-node/client' import { loadEnvironment } from '../integrations/env/loader' import { isChildProcess, setProcessTitle } from '../utils/base' -import type { ContextRPC } from '../types/worker' +import type { ContextRPC, WorkerGlobalState } from '../types/worker' import { setupInspect } from './inspector' import { createRuntimeRpc, rpcDone } from './rpc' import type { VitestWorker } from './workers/types' @@ -63,7 +63,6 @@ async function execute(mehtod: 'run' | 'collect', ctx: ContextRPC) { ctx, // here we create a new one, workers can reassign this if they need to keep it non-isolated moduleCache: new ModuleCacheMap(), - mockMap: new Map(), config: ctx.config, onCancel, environment, @@ -73,7 +72,7 @@ async function execute(mehtod: 'run' | 'collect', ctx: ContextRPC) { }, rpc, providedContext: ctx.providedContext, - } + } satisfies WorkerGlobalState const methodName = mehtod === 'collect' ? 'collectTests' : 'runTests' diff --git a/packages/vitest/src/runtime/workers/base.ts b/packages/vitest/src/runtime/workers/base.ts index 6d3c3a14a1d7..22d9e8916313 100644 --- a/packages/vitest/src/runtime/workers/base.ts +++ b/packages/vitest/src/runtime/workers/base.ts @@ -3,12 +3,10 @@ import type { WorkerGlobalState } from '../../types/worker' import { provideWorkerState } from '../utils' import type { ContextExecutorOptions, VitestExecutor } from '../execute' import { getDefaultRequestStubs, startVitestExecutor } from '../execute' -import type { MockMap } from '../../types/mocker' let _viteNode: VitestExecutor const moduleCache = new ModuleCacheMap() -const mockMap: MockMap = new Map() async function startViteNode(options: ContextExecutorOptions) { if (_viteNode) { @@ -23,7 +21,6 @@ export async function runBaseTests(method: 'run' | 'collect', state: WorkerGloba const { ctx } = state // state has new context, but we want to reuse existing ones state.moduleCache = moduleCache - state.mockMap = mockMap provideWorkerState(globalThis, state) diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts index ebd9e75f55aa..3bf9ff9f59e6 100644 --- a/packages/vitest/src/runtime/workers/vm.ts +++ b/packages/vitest/src/runtime/workers/vm.ts @@ -77,7 +77,6 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS const executor = await startVitestExecutor({ context, moduleCache: state.moduleCache, - mockMap: state.mockMap, state, externalModulesExecutor, requestStubs: stubs, diff --git a/packages/vitest/src/types/mocker.ts b/packages/vitest/src/types/mocker.ts index 4937955f512b..cd90f4006135 100644 --- a/packages/vitest/src/types/mocker.ts +++ b/packages/vitest/src/types/mocker.ts @@ -1,16 +1,19 @@ +import type { MockedModuleType } from '@vitest/mocker' + type Promisable = T | Promise export type MockFactoryWithHelper = ( importOriginal: () => Promise ) => Promisable> export type MockFactory = () => any - -export type MockMap = Map> +export interface MockOptions { + spy?: boolean +} export interface PendingSuiteMock { id: string importer: string - type: 'mock' | 'unmock' - throwIfCached: boolean + action: 'mock' | 'unmock' + type?: MockedModuleType factory?: MockFactory } diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 78561f48c935..5e1b8fd73cd9 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -3,7 +3,6 @@ import type { BirpcReturn } from 'birpc' import type { CancelReason, Task } from '@vitest/runner' import type { SerializedConfig } from '../runtime/config' import type { RunnerRPC, RuntimeRPC } from './rpc' -import type { MockMap } from './mocker' import type { TransformMode } from './general' import type { Environment } from './environment' @@ -43,7 +42,6 @@ export interface WorkerGlobalState { environmentTeardownRun?: boolean onCancel: Promise moduleCache: ModuleCacheMap - mockMap: MockMap providedContext: Record durations: { environment: number diff --git a/packages/web-worker/src/utils.ts b/packages/web-worker/src/utils.ts index 74150884b626..002105c83748 100644 --- a/packages/web-worker/src/utils.ts +++ b/packages/web-worker/src/utils.ts @@ -81,7 +81,7 @@ export function createMessageEvent( export function getRunnerOptions(): any { const state = getWorkerState() - const { config, rpc, mockMap, moduleCache } = state + const { config, rpc, moduleCache } = state return { async fetchModule(id: string) { @@ -96,7 +96,6 @@ export function getRunnerOptions(): any { return rpc.resolveId(id, importer, 'web') }, moduleCache, - mockMap, interopDefault: config.deps.interopDefault ?? true, moduleDirectories: config.deps.moduleDirectories, root: config.root, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ce2c053b17c..206325367caf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,7 +231,7 @@ importers: devDependencies: '@vitest/browser': specifier: latest - version: 2.0.5(playwright@1.46.0)(typescript@5.5.4)(vitest@packages+vitest)(webdriverio@8.32.2(typescript@5.5.4)) + version: 2.0.5(playwright@1.46.1)(typescript@5.5.4)(vitest@packages+vitest)(webdriverio@8.32.2(typescript@5.5.4)) '@vitest/ui': specifier: latest version: 2.0.5(vitest@packages+vitest) @@ -458,6 +458,9 @@ importers: '@types/ws': specifier: ^8.5.12 version: 8.5.12 + '@vitest/mocker': + specifier: workspace:* + version: link:../mocker '@vitest/runner': specifier: workspace:* version: link:../runner @@ -654,6 +657,37 @@ importers: specifier: ^3.5.0 version: 3.5.0 + packages/mocker: + dependencies: + '@vitest/spy': + specifier: workspace:^2.1.0-beta.1 + version: link:../spy + estree-walker: + specifier: ^3.0.3 + version: 3.0.3 + magic-string: + specifier: ^0.30.11 + version: 0.30.11 + devDependencies: + '@types/estree': + specifier: ^1.0.5 + version: 1.0.5 + '@vitest/utils': + specifier: workspace:* + version: link:../utils + acorn-walk: + specifier: ^8.3.3 + version: 8.3.3 + msw: + specifier: ^2.3.5 + version: 2.3.5(typescript@5.5.4) + pathe: + specifier: ^1.1.2 + version: 1.1.2 + vite: + specifier: ^5.4.0 + version: 5.4.0(@types/node@20.14.15)(terser@5.22.0) + packages/pretty-format: dependencies: tinyrainbow: @@ -824,9 +858,6 @@ importers: '@vitest/pretty-format': specifier: workspace:* version: link:../pretty-format - estree-walker: - specifier: ^3.0.3 - version: 3.0.3 loupe: specifier: ^3.1.1 version: 3.1.1 @@ -883,6 +914,9 @@ importers: '@vitest/expect': specifier: workspace:* version: link:../expect + '@vitest/mocker': + specifier: workspace:* + version: link:../mocker '@vitest/pretty-format': specifier: workspace:^ version: link:../pretty-format @@ -1180,6 +1214,9 @@ importers: '@vitest/expect': specifier: workspace:* version: link:../../packages/expect + '@vitest/mocker': + specifier: workspace:* + version: link:../../packages/mocker '@vitest/runner': specifier: workspace:* version: link:../../packages/runner @@ -1316,6 +1353,21 @@ importers: specifier: workspace:* version: link:../../packages/vitest + test/public-mocker: + devDependencies: + '@vitest/browser': + specifier: workspace:* + version: link:../../packages/browser + '@vitest/mocker': + specifier: workspace:* + version: link:../../packages/mocker + playwright: + specifier: ^1.46.1 + version: 1.46.1 + vitest: + specifier: workspace:* + version: link:../../packages/vitest + test/reporters: devDependencies: flatted: @@ -7224,16 +7276,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.3.4: - resolution: {integrity: sha512-sHMlwrajgmZSA2l1o7qRSe+azm/I+x9lvVVcOxAzi4vCtH8uVPJk1K5BQYDkzGl+tt0RvM9huEXXdeGrgcc79g==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - typescript: '>= 4.7.x' - peerDependenciesMeta: - typescript: - optional: true - msw@2.3.5: resolution: {integrity: sha512-+GUI4gX5YC5Bv33epBrD+BGdmDvBg2XGruiWnI3GbIbRmMMBeZ5gs3mJ51OWSGHgJKztZ8AtZeYMMNMVrje2/Q==} engines: {node: '>=18'} @@ -7612,6 +7654,11 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.46.1: + resolution: {integrity: sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==} + engines: {node: '>=18'} + hasBin: true + playwright@1.41.0: resolution: {integrity: sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==} engines: {node: '>=16'} @@ -7622,6 +7669,11 @@ packages: engines: {node: '>=18'} hasBin: true + playwright@1.46.1: + resolution: {integrity: sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -11324,7 +11376,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 '@jridgewell/resolve-uri@3.1.0': {} @@ -11352,7 +11404,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.1 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 '@jsdevtools/ez-spawn@3.0.4': dependencies: @@ -11765,7 +11817,7 @@ snapshots: devalue: 4.3.2 esm-env: 1.0.0 kleur: 4.1.5 - magic-string: 0.30.10 + magic-string: 0.30.11 mime: 3.0.0 sade: 1.8.1 set-cookie-parser: 2.6.0 @@ -12589,18 +12641,18 @@ snapshots: vite: 5.4.0(@types/node@20.14.15)(terser@5.22.0) vue: 3.4.37(typescript@5.5.4) - '@vitest/browser@2.0.5(playwright@1.46.0)(typescript@5.5.4)(vitest@packages+vitest)(webdriverio@8.32.2(typescript@5.5.4))': + '@vitest/browser@2.0.5(playwright@1.46.1)(typescript@5.5.4)(vitest@packages+vitest)(webdriverio@8.32.2(typescript@5.5.4))': dependencies: '@testing-library/dom': 10.4.0 '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) '@vitest/utils': 2.0.5 - magic-string: 0.30.10 - msw: 2.3.4(typescript@5.5.4) + magic-string: 0.30.11 + msw: 2.3.5(typescript@5.5.4) sirv: 2.0.4 vitest: link:packages/vitest ws: 8.18.0 optionalDependencies: - playwright: 1.46.0 + playwright: 1.46.1 webdriverio: 8.32.2(typescript@5.5.4) transitivePeerDependencies: - bufferutil @@ -16499,28 +16551,6 @@ snapshots: ms@2.1.3: {} - msw@2.3.4(typescript@5.5.4): - dependencies: - '@bundled-es-modules/cookie': 2.0.0 - '@bundled-es-modules/statuses': 1.0.1 - '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 3.1.9 - '@mswjs/interceptors': 0.29.1 - '@open-draft/until': 2.1.0 - '@types/cookie': 0.6.0 - '@types/statuses': 2.0.5 - chalk: 4.1.2 - graphql: 16.8.1 - headers-polyfill: 4.0.3 - is-node-process: 1.2.0 - outvariant: 1.4.2 - path-to-regexp: 6.2.2 - strict-event-emitter: 0.5.1 - type-fest: 4.20.0 - yargs: 17.7.2 - optionalDependencies: - typescript: 5.5.4 - msw@2.3.5(typescript@5.5.4): dependencies: '@bundled-es-modules/cookie': 2.0.0 @@ -16901,6 +16931,8 @@ snapshots: playwright-core@1.46.0: {} + playwright-core@1.46.1: {} + playwright@1.41.0: dependencies: playwright-core: 1.41.0 @@ -16913,6 +16945,12 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + playwright@1.46.1: + dependencies: + playwright-core: 1.46.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} postcss-selector-parser@6.0.16: diff --git a/test/browser/fixtures/mocking/autospying.test.ts b/test/browser/fixtures/mocking/autospying.test.ts new file mode 100644 index 000000000000..4dd01074c3c6 --- /dev/null +++ b/test/browser/fixtures/mocking/autospying.test.ts @@ -0,0 +1,16 @@ +import { expect, test, vi } from 'vitest' +import { calculator } from './src/calculator' +import * as mocks_calculator from './src/mocks_calculator' + +vi.mock('./src/calculator', { spy: true }) +vi.mock('./src/mocks_calculator', { spy: true }) + +test('correctly spies on a regular module', () => { + expect(calculator('plus', 1, 2)).toBe(3) + expect(calculator).toHaveBeenCalled() +}) + +test('spy options overrides __mocks__ folder', () => { + expect(mocks_calculator.calculator('plus', 1, 2)).toBe(3) + expect(mocks_calculator.calculator).toHaveBeenCalled() +}) diff --git a/test/browser/fixtures/mocking/import-actual-query.test.ts b/test/browser/fixtures/mocking/import-actual-query.test.ts index dce6dfe29aaa..ff6d5136fba7 100644 --- a/test/browser/fixtures/mocking/import-actual-query.test.ts +++ b/test/browser/fixtures/mocking/import-actual-query.test.ts @@ -4,7 +4,7 @@ import rawFactory from './src/mocks_factory?raw' vi.mock(import('./src/mocks_factory?raw'), async (importOriginal) => { const original = await importOriginal() return { - default: original.default.replace('mocked = false', 'mocked = "mocked!"'), + default: original.default.replace('mocked: boolean = false', 'mocked: boolean = "mocked!"'), } }) @@ -14,6 +14,6 @@ export function calculator(_action: string, _a: number, _b: number) { return _a + _b } -export const mocked = "mocked!" +export const mocked: boolean = "mocked!" `.trimStart()) }) \ No newline at end of file diff --git a/test/browser/fixtures/mocking/import-mock.test.ts b/test/browser/fixtures/mocking/import-mock.test.ts index a6a326334bee..71500a242919 100644 --- a/test/browser/fixtures/mocking/import-mock.test.ts +++ b/test/browser/fixtures/mocking/import-mock.test.ts @@ -1,4 +1,4 @@ -import { test,vi, expect } from 'vitest' +import { test, vi, expect } from 'vitest' vi.mock(import('./src/mocks_factory'), () => { return { diff --git a/test/browser/fixtures/mocking/src/mocks_factory.ts b/test/browser/fixtures/mocking/src/mocks_factory.ts index 4282c2f5641d..085bfc215fd1 100644 --- a/test/browser/fixtures/mocking/src/mocks_factory.ts +++ b/test/browser/fixtures/mocking/src/mocks_factory.ts @@ -2,4 +2,4 @@ export function calculator(_action: string, _a: number, _b: number) { return _a + _b } -export const mocked = false +export const mocked: boolean = false diff --git a/test/core/package.json b/test/core/package.json index b9cb2f6d4df6..59de88e33c36 100644 --- a/test/core/package.json +++ b/test/core/package.json @@ -14,6 +14,7 @@ "devDependencies": { "@types/debug": "^4.1.12", "@vitest/expect": "workspace:*", + "@vitest/mocker": "workspace:*", "@vitest/runner": "workspace:*", "@vitest/test-dep1": "file:./deps/dep1", "@vitest/test-dep2": "file:./deps/dep2", diff --git a/test/core/test/browserAutomocker.test.ts b/test/core/test/browserAutomocker.test.ts index 1848678627bc..7d295c7d930c 100644 --- a/test/core/test/browserAutomocker.test.ts +++ b/test/core/test/browserAutomocker.test.ts @@ -1,9 +1,9 @@ import { parseAst } from 'vite' import { expect, it } from 'vitest' -import { automockModule } from 'vitest/src/node/automock.js' +import { automockModule } from '@vitest/mocker/node' function automock(code: string) { - return automockModule(code, parseAst).toString() + return automockModule(code, 'automock', parseAst).toString() } it('correctly parses function declaration', () => { @@ -13,11 +13,11 @@ export function test() {} " function test() {} - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["test"]: test, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["test"] export { __vitest_mocked_0__ as test, @@ -33,11 +33,11 @@ export class Test {} " class Test {} - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["Test"]: Test, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["Test"] export { __vitest_mocked_0__ as Test, @@ -53,11 +53,11 @@ export default class Test {} " const __vitest_default = class Test {} - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["__vitest_default"]: __vitest_default, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] export { __vitest_mocked_0__ as default, @@ -71,11 +71,11 @@ export default function test() {} " const __vitest_default = function test() {} - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["__vitest_default"]: __vitest_default, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] export { __vitest_mocked_0__ as default, @@ -89,11 +89,11 @@ export default someVariable " const __vitest_default = someVariable - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["__vitest_default"]: __vitest_default, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] export { __vitest_mocked_0__ as default, @@ -107,11 +107,11 @@ export default 'test' " const __vitest_default = 'test' - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["__vitest_default"]: __vitest_default, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] export { __vitest_mocked_0__ as default, @@ -125,11 +125,11 @@ export default null " const __vitest_default = null - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["__vitest_default"]: __vitest_default, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] export { __vitest_mocked_0__ as default, @@ -145,11 +145,11 @@ export default test const test = '123' const __vitest_default = test - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["__vitest_default"]: __vitest_default, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] export { __vitest_mocked_0__ as default, @@ -169,13 +169,13 @@ export const test3 = function test4() {} const test2 = () => {} const test3 = function test4() {} - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["test"]: test, ["test2"]: test2, ["test3"]: test3, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["test"] const __vitest_mocked_1__ = __vitest_mocked_module__["test2"] const __vitest_mocked_2__ = __vitest_mocked_module__["test3"] @@ -197,13 +197,13 @@ export const [...rest2] = [] const [test, ...rest] = [] const [...rest2] = [] - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["test"]: test, ["rest"]: rest, ["rest2"]: rest2, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["test"] const __vitest_mocked_1__ = __vitest_mocked_module__["rest"] const __vitest_mocked_2__ = __vitest_mocked_module__["rest2"] @@ -223,14 +223,14 @@ export const test = 2, test2 = 3, test4 = () => {}, test5 = function() {}; " const test = 2, test2 = 3, test4 = () => {}, test5 = function() {}; - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["test"]: test, ["test2"]: test2, ["test4"]: test4, ["test5"]: test5, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["test"] const __vitest_mocked_1__ = __vitest_mocked_module__["test2"] const __vitest_mocked_2__ = __vitest_mocked_module__["test4"] @@ -256,14 +256,14 @@ export const { ...rest2 } = {} const { test: alias } = {} const { ...rest2 } = {} - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["test"]: test, ["rest"]: rest, ["alias"]: alias, ["rest2"]: rest2, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["test"] const __vitest_mocked_1__ = __vitest_mocked_module__["rest"] const __vitest_mocked_2__ = __vitest_mocked_module__["alias"] @@ -287,13 +287,13 @@ it('correctly parses export specifiers', () => { const test = '1' - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["test"]: test, ["test"]: test, ["test"]: test, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["test"] const __vitest_mocked_1__ = __vitest_mocked_module__["test"] const __vitest_mocked_2__ = __vitest_mocked_module__["test"] @@ -317,14 +317,14 @@ export { testing as name4 } from './another-module' import { testing as name4 } from './another-module' import { testing as __vitest_imported_3__ } from './another-module' - const __vitest_es_current_module__ = { + const __vitest_current_es_module__ = { __esModule: true, ["__vitest_imported_0__"]: __vitest_imported_0__, ["__vitest_imported_1__"]: __vitest_imported_1__, ["__vitest_imported_2__"]: __vitest_imported_2__, ["__vitest_imported_3__"]: __vitest_imported_3__, } - const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_module__ = globalThis["__vitest_mocker__"].mockObject(__vitest_current_es_module__, "automock") const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_imported_0__"] const __vitest_mocked_1__ = __vitest_mocked_module__["__vitest_imported_1__"] const __vitest_mocked_2__ = __vitest_mocked_module__["__vitest_imported_2__"] diff --git a/test/core/test/injector-esm.test.ts b/test/core/test/injector-esm.test.ts index 6bf1c6217ab1..7fb2e8e49e40 100644 --- a/test/core/test/injector-esm.test.ts +++ b/test/core/test/injector-esm.test.ts @@ -1,6 +1,6 @@ import { parseAst } from 'rollup/parseAst' import { expect, test } from 'vitest' -import { injectDynamicImport } from '../../../packages/browser/src/node/esmInjector' +import { injectDynamicImport } from '../../../packages/mocker/src/node/dynamicImportPlugin' function parse(code: string, options: any) { return parseAst(code, options) @@ -14,5 +14,5 @@ test('dynamic import', async () => { const result = injectSimpleCode( 'export const i = () => import(\'./foo\')', ) - expect(result).toMatchInlineSnapshot(`"export const i = () => __vitest_browser_runner__.wrapModule(() => import('./foo'))"`) + expect(result).toMatchInlineSnapshot(`"export const i = () => globalThis["__vitest_mocker__"].wrapDynamicImport(() => import('./foo'))"`) }) diff --git a/test/core/test/injector-mock.test.ts b/test/core/test/injector-mock.test.ts index 3791acce4715..9d878ca5db63 100644 --- a/test/core/test/injector-mock.test.ts +++ b/test/core/test/injector-mock.test.ts @@ -1,19 +1,30 @@ import { parseAst } from 'rollup/parseAst' import { describe, expect, it, test } from 'vitest' import stripAnsi from 'strip-ansi' -import { getDefaultColors } from 'tinyrainbow' -import { hoistMocks } from '../../../packages/vitest/src/node/hoistMocks' +import { generateCodeFrame } from 'vitest/src/node/error.js' +import type { HoistMocksPluginOptions } from '../../../packages/mocker/src/node/hoistMocksPlugin' +import { hoistMocks } from '../../../packages/mocker/src/node/hoistMocksPlugin' function parse(code: string, options: any) { return parseAst(code, options) } +const hoistMocksOptions: HoistMocksPluginOptions = { + codeFrameGenerator(node: any, id: string, code: string) { + return generateCodeFrame( + code, + 4, + node.start + 1, + ) + }, +} + async function hoistSimple(code: string, url = '') { - return hoistMocks(code, url, parse) + return hoistMocks(code, url, parse, hoistMocksOptions) } function hoistSimpleCode(code: string) { - return hoistMocks(code, '/test.js', parse)?.code.trim() + return hoistMocks(code, '/test.js', parse, hoistMocksOptions)?.code.trim() } test('hoists mock, unmock, hoisted', () => { @@ -22,7 +33,7 @@ test('hoists mock, unmock, hoisted', () => { vi.unmock('path') vi.hoisted(() => {}) `)).toMatchInlineSnapshot(` - "if (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") { throw new Error("There are some problems in resolving the mocks API.\\nYou may encounter this issue when importing the mocks API from another module other than 'vitest'.\\nTo fix this issue you can either:\\n- import the mocks API directly from 'vitest'\\n- enable the 'globals' options") } + "if (typeof globalThis["vi"] === "undefined" && typeof globalThis["vitest"] === "undefined") { throw new Error("There are some problems in resolving the mocks API.\\nYou may encounter this issue when importing the mocks API from another module other than 'vitest'.\\nTo fix this issue you can either:\\n- import the mocks API directly from 'vitest'\\n- enable the 'globals' options") } vi.mock('path', () => {}) vi.unmock('path') vi.hoisted(() => {})" @@ -37,11 +48,15 @@ test('always hoists import from vitest', () => { vi.hoisted(() => {}) import { test } from 'vitest' `)).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - const { test } = await import('vitest') - vi.mock('path', () => {}) + "vi.mock('path', () => {}) vi.unmock('path') - vi.hoisted(() => {})" + vi.hoisted(() => {}) + + import { vi } from 'vitest' + + + + import { test } from 'vitest'" `) }) @@ -55,13 +70,19 @@ test('always hoists all imports but they are under mocks', () => { vi.hoisted(() => {}) import { test } from 'vitest' `)).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - const { test } = await import('vitest') - vi.mock('path', () => {}) + "vi.mock('path', () => {}) vi.unmock('path') vi.hoisted(() => {}) const __vi_import_0__ = await import('./path.js') - const __vi_import_1__ = await import('./path2.js')" + const __vi_import_1__ = await import('./path2.js') + + import { vi } from 'vitest' + + + + + + import { test } from 'vitest'" `) }) @@ -71,9 +92,10 @@ test('correctly mocks namespaced', () => { import add, * as AddModule from '../src/add' vi.mock('../src/add', () => {}) `)).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('../src/add', () => {}) - const __vi_import_0__ = await import('../src/add')" + "vi.mock('../src/add', () => {}) + const __vi_import_0__ = await import('../src/add') + + import { vi } from 'vitest'" `) }) @@ -84,11 +106,10 @@ test('correctly access import', () => { add(); vi.mock('../src/add', () => {}) `)).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('../src/add', () => {}) + "vi.mock('../src/add', () => {}) const __vi_import_0__ = await import('../src/add') - + import { vi } from 'vitest' __vi_import_0__.default();" `) @@ -96,16 +117,15 @@ test('correctly access import', () => { describe('transform', () => { const hoistSimpleCodeWithoutMocks = (code: string) => { - return hoistMocks(`import {vi} from "vitest";\n${code}\nvi.mock('faker');`, '/test.js', parse)?.code.trim() + return hoistMocks(`import {vi} from "vitest";\n${code}\nvi.mock('faker');`, '/test.js', parse, hoistMocksOptions)?.code.trim() } test('default import', async () => { expect( hoistSimpleCodeWithoutMocks(`import foo from 'vue';console.log(foo.bar)`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; console.log(__vi_import_0__.default.bar)" `) }) @@ -122,17 +142,18 @@ vi.mock('./mock.js', () => ({ admin: admin, })) })) -`, './test.js', parse)?.code.trim(), +`, './test.js', parse, hoistMocksOptions)?.code.trim(), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('./mock.js', () => ({ + "vi.mock('./mock.js', () => ({ getSession: vi.fn().mockImplementation(() => ({ user: __vi_import_0__.default, admin: __vi_import_1__.admin, })) })) const __vi_import_0__ = await import('./user') - const __vi_import_1__ = await import('./admin')" + const __vi_import_1__ = await import('./admin') + + import { vi } from 'vitest'" `) }) @@ -151,10 +172,9 @@ vi.mock('./mock.js', () => { admin: admin, })) }) -`, './test.js', parse)?.code.trim(), +`, './test.js', parse, hoistMocksOptions)?.code.trim(), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - const { user, admin } = await vi.hoisted(async () => { + "const { user, admin } = await vi.hoisted(async () => { const { default: user } = await import('./user') const { admin } = await import('./admin') return { user, admin } @@ -164,7 +184,9 @@ vi.mock('./mock.js', () => { user, admin: admin, })) - })" + }) + + import { vi } from 'vitest'" `) }) @@ -174,10 +196,9 @@ vi.mock('./mock.js', () => { `import { ref } from 'vue';function foo() { return ref(0) }`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function foo() { return __vi_import_0__.ref(0) }" `) }) @@ -188,10 +209,9 @@ vi.mock('./mock.js', () => { `import * as vue from 'vue';function foo() { return vue.ref(0) }`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function foo() { return __vi_import_0__.ref(0) }" `) }) @@ -199,9 +219,8 @@ vi.mock('./mock.js', () => { test('export function declaration', async () => { expect(await hoistSimpleCodeWithoutMocks(`export function foo() {}`)) .toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export function foo() {}" `) }) @@ -209,9 +228,8 @@ vi.mock('./mock.js', () => { test('export class declaration', async () => { expect(await hoistSimpleCodeWithoutMocks(`export class foo {}`)) .toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export class foo {}" `) }) @@ -219,9 +237,8 @@ vi.mock('./mock.js', () => { test('export var declaration', async () => { expect(await hoistSimpleCodeWithoutMocks(`export const a = 1, b = 2`)) .toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export const a = 1, b = 2" `) }) @@ -230,9 +247,8 @@ vi.mock('./mock.js', () => { expect( await hoistSimpleCodeWithoutMocks(`const a = 1, b = 2; export { a, b as c }`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; const a = 1, b = 2; export { a, b as c }" `) }) @@ -241,9 +257,8 @@ vi.mock('./mock.js', () => { expect( await hoistSimpleCodeWithoutMocks(`export { ref, computed as c } from 'vue'`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export { ref, computed as c } from 'vue'" `) }) @@ -254,10 +269,9 @@ vi.mock('./mock.js', () => { `import {createApp} from 'vue';export {createApp}`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; export {createApp}" `) }) @@ -268,9 +282,8 @@ vi.mock('./mock.js', () => { `export * from 'vue'\n` + `export * from 'react'`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export * from 'vue' export * from 'react'" `) @@ -279,9 +292,8 @@ vi.mock('./mock.js', () => { test('export * as from', async () => { expect(await hoistSimpleCodeWithoutMocks(`export * as foo from 'vue'`)) .toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export * as foo from 'vue'" `) }) @@ -290,9 +302,8 @@ vi.mock('./mock.js', () => { expect( await hoistSimpleCodeWithoutMocks(`export default {}`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export default {}" `) }) @@ -303,10 +314,9 @@ vi.mock('./mock.js', () => { `export * from 'vue';import {createApp} from 'vue';`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; export * from 'vue';" `) }) @@ -317,10 +327,9 @@ vi.mock('./mock.js', () => { `path.resolve('server.js');import path from 'node:path';`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('node:path') - + import {vi} from "vitest"; __vi_import_0__.default.resolve('server.js');" `) }) @@ -329,9 +338,8 @@ vi.mock('./mock.js', () => { expect( await hoistSimpleCodeWithoutMocks(`console.log(import.meta.url)`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; console.log(import.meta.url)" `) }) @@ -341,9 +349,8 @@ vi.mock('./mock.js', () => { `export const i = () => import('./foo')`, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export const i = () => import('./foo')" `) }) @@ -353,10 +360,9 @@ vi.mock('./mock.js', () => { `import { fn } from 'vue';class A { fn() { fn() } }`, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; class A { fn() { __vi_import_0__.fn() } }" `) }) @@ -366,10 +372,9 @@ vi.mock('./mock.js', () => { `import { fn } from 'vue';function A(){ const fn = () => {}; return { fn }; }`, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function A(){ const fn = () => {}; return { fn }; }" `) }) @@ -380,10 +385,9 @@ vi.mock('./mock.js', () => { `import { fn } from 'vue';function A(){ let {fn, test} = {fn: 'foo', test: 'bar'}; return { fn }; }`, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function A(){ let {fn, test} = {fn: 'foo', test: 'bar'}; return { fn }; }" `) }) @@ -394,10 +398,9 @@ vi.mock('./mock.js', () => { `import { fn } from 'vue';function A(){ let [fn, test] = ['foo', 'bar']; return { fn }; }`, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function A(){ let [fn, test] = ['foo', 'bar']; return { fn }; }" `) }) @@ -408,10 +411,9 @@ vi.mock('./mock.js', () => { `import { fn } from 'vue';function A({foo = \`test\${fn}\`} = {}){ return {}; }`, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function A({foo = \`test\${__vi_import_0__.fn}\`} = {}){ return {}; }" `) }) @@ -422,10 +424,9 @@ vi.mock('./mock.js', () => { `import { fn } from 'vue';function A({foo = fn}){ return {}; }`, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function A({foo = __vi_import_0__.fn}){ return {}; }" `) }) @@ -435,10 +436,9 @@ vi.mock('./mock.js', () => { `import { fn } from 'vue';function A(){ function fn() {}; return { fn }; }`, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function A(){ function fn() {}; return { fn }; }" `) }) @@ -448,10 +448,9 @@ vi.mock('./mock.js', () => { `import {error} from './dependency';try {} catch(error) {}`, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('./dependency') - + import {vi} from "vitest"; try {} catch(error) {}" `) }) @@ -463,10 +462,9 @@ vi.mock('./mock.js', () => { `import { Foo } from './dependency';` + `class A extends Foo {}`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('./dependency') - + import {vi} from "vitest"; const Foo = __vi_import_0__.Foo; class A extends Foo {}" `) @@ -480,10 +478,9 @@ vi.mock('./mock.js', () => { + `export class B extends Foo {}`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('./dependency') - + import {vi} from "vitest"; const Foo = __vi_import_0__.Foo; export default class A extends Foo {} export class B extends Foo {}" @@ -495,17 +492,15 @@ vi.mock('./mock.js', () => { // default anonymous functions expect(await hoistSimpleCodeWithoutMocks(`export default function() {}\n`)) .toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export default function() {}" `) // default anonymous class expect(await hoistSimpleCodeWithoutMocks(`export default class {}\n`)) .toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export default class {}" `) // default named functions @@ -515,9 +510,8 @@ vi.mock('./mock.js', () => { + `foo.prototype = Object.prototype;`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export default function foo() {} foo.prototype = Object.prototype;" `) @@ -527,9 +521,8 @@ vi.mock('./mock.js', () => { `export default class A {}\n` + `export class B extends A {}`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export default class A {} export class B extends A {}" `) @@ -559,10 +552,9 @@ vi.mock('./mock.js', () => { + `function g() { const f = () => { const inject = true }; console.log(inject) }\n`, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; const a = { inject: __vi_import_0__.inject } const b = { test: __vi_import_0__.inject } function c() { const { test: inject } = { test: true }; console.log(inject) } @@ -577,9 +569,8 @@ vi.mock('./mock.js', () => { expect( await hoistSimpleCodeWithoutMocks(`const [, LHS, RHS] = inMatch;`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; const [, LHS, RHS] = inMatch;" `) }) @@ -595,10 +586,9 @@ function c({ _ = bar() + foo() }) {} `, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('foo') - + import {vi} from "vitest"; const a = ({ _ = __vi_import_0__.foo() }) => {} @@ -619,10 +609,9 @@ const a = () => { `, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('foo') - + import {vi} from "vitest"; const a = () => { @@ -644,10 +633,9 @@ const foo = {} `, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('foo') - + import {vi} from "vitest"; const foo = {} @@ -689,10 +677,9 @@ objRest() `, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function a() { @@ -740,10 +727,9 @@ const obj = { `, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('foo') - + import {vi} from "vitest"; const bar = 'bar' @@ -773,10 +759,9 @@ class A { `, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; const add = __vi_import_0__.add; @@ -806,10 +791,9 @@ class A { `, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('foo') - + import {vi} from "vitest"; const bar = 'bar' @@ -853,10 +837,9 @@ bbb() `, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('vue') - + import {vi} from "vitest"; function foobar() { @@ -892,9 +875,8 @@ export function fn1() { `, ), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export function fn1() { }export function fn2() { @@ -915,9 +897,8 @@ export default (function getRandom() { `.trim() expect(await hoistSimpleCodeWithoutMocks(code)).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export default (function getRandom() { return Math.random(); });" @@ -926,9 +907,8 @@ export default (function getRandom() { expect( await hoistSimpleCodeWithoutMocks(`export default (class A {});`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); - + "vi.mock('faker'); + import {vi} from "vitest"; export default (class A {});" `) }) @@ -983,10 +963,9 @@ export class Test { };`.trim() expect(await hoistSimpleCodeWithoutMocks(code)).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('foobar') - + import {vi} from "vitest"; if (false) { const foo = 'foo' @@ -1027,10 +1006,9 @@ function test() { return [foo, bar] }`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('foobar') - + import {vi} from "vitest"; function test() { @@ -1057,10 +1035,9 @@ function test() { return bar; }`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('foobar') - + import {vi} from "vitest"; function test() { @@ -1092,10 +1069,9 @@ for (const test in tests) { console.log(test) }`), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('./test.js') - + import {vi} from "vitest"; for (const test of tests) { @@ -1126,10 +1102,9 @@ const Baz = class extends Foo {} `, ) expect(result).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('./foo') - + import {vi} from "vitest"; console.log(__vi_import_0__.default, __vi_import_0__.Bar); @@ -1148,10 +1123,9 @@ const Baz = class extends Foo {} import('./bar.json', { with: { type: 'json' } }); `), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('./foo.json') - + import {vi} from "vitest"; import('./bar.json', { with: { type: 'json' } });" @@ -1171,10 +1145,9 @@ export * from './b' console.log(foo + 2) `), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - vi.mock('faker'); + "vi.mock('faker'); const __vi_import_0__ = await import('./foo') - + import {vi} from "vitest"; console.log(__vi_import_0__.foo + 1) export * from './a' @@ -1193,11 +1166,10 @@ await vi .hoisted(() => {}); `), ).toMatchInlineSnapshot(` - "const { vi } = await import('vitest') - await vi + "await vi .hoisted(() => {}); - + import { vi } from 'vitest'; 1234;" `) }) @@ -1238,7 +1210,7 @@ await vi vi.mock(await import(\`./path\`), () => {}); `), ).toMatchInlineSnapshot(` - "if (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") { throw new Error("There are some problems in resolving the mocks API.\\nYou may encounter this issue when importing the mocks API from another module other than 'vitest'.\\nTo fix this issue you can either:\\n- import the mocks API directly from 'vitest'\\n- enable the 'globals' options") } + "if (typeof globalThis["vi"] === "undefined" && typeof globalThis["vitest"] === "undefined") { throw new Error("There are some problems in resolving the mocks API.\\nYou may encounter this issue when importing the mocks API from another module other than 'vitest'.\\nTo fix this issue you can either:\\n- import the mocks API directly from 'vitest'\\n- enable the 'globals' options") } vi.mock('./path') vi.mock(somePath) vi.mock(\`./path\`) @@ -1304,7 +1276,7 @@ test('test', async () => { describe('throws an error when nodes are incompatible', () => { const getErrorWhileHoisting = (code: string) => { try { - hoistMocks(code, '/test.js', parse, getDefaultColors())?.code.trim() + hoistMocks(code, '/test.js', parse, hoistMocksOptions)?.code.trim() } catch (err: any) { return err diff --git a/test/core/test/mocking/autospying.test.ts b/test/core/test/mocking/autospying.test.ts new file mode 100644 index 000000000000..33740a5b51e3 --- /dev/null +++ b/test/core/test/mocking/autospying.test.ts @@ -0,0 +1,24 @@ +import { expect, test, vi } from 'vitest' +import axios from 'axios' +import { getAuthToken } from '../../src/env' + +vi.mock(import('../../src/env'), { spy: true }) + +vi.mock('axios', { spy: true }) + +test('getAuthToken is spied', async () => { + import.meta.env.AUTH_TOKEN = '123' + const token = getAuthToken() + expect(token).toBe('123') + expect(getAuthToken).toHaveBeenCalledTimes(1) + vi.mocked(getAuthToken).mockRestore() + expect(vi.isMockFunction(getAuthToken)).toBe(false) +}) + +test('package in __mocks__ has lower priority', async () => { + expect(vi.isMockFunction(axios.get)).toBe(true) + + // isAxiosError is not defined in __mocks__ + expect(axios.isAxiosError(new Error('test'))).toBe(false) + expect(axios.isAxiosError).toHaveBeenCalled() +}) diff --git a/test/public-mocker/fixtures/automock/index.html b/test/public-mocker/fixtures/automock/index.html new file mode 100644 index 000000000000..6fb842ec568c --- /dev/null +++ b/test/public-mocker/fixtures/automock/index.html @@ -0,0 +1,12 @@ + + + + + + Document + + + +
+ + \ No newline at end of file diff --git a/test/public-mocker/fixtures/automock/index.js b/test/public-mocker/fixtures/automock/index.js new file mode 100644 index 000000000000..8cffdec959b8 --- /dev/null +++ b/test/public-mocker/fixtures/automock/index.js @@ -0,0 +1,8 @@ +import { mocker } from 'virtual:mocker' +import { calculate } from './test' + +mocker.customMock(import('./test'), { spy: true }) + +calculate.mockReturnValue(42) + +document.querySelector('#mocked').textContent = calculate(1, 2) diff --git a/test/public-mocker/fixtures/automock/test.js b/test/public-mocker/fixtures/automock/test.js new file mode 100644 index 000000000000..ce8540fd595f --- /dev/null +++ b/test/public-mocker/fixtures/automock/test.js @@ -0,0 +1,3 @@ +export function calculate (a, b) { + return a + b +} diff --git a/test/public-mocker/fixtures/autospy/index.html b/test/public-mocker/fixtures/autospy/index.html new file mode 100644 index 000000000000..6fb842ec568c --- /dev/null +++ b/test/public-mocker/fixtures/autospy/index.html @@ -0,0 +1,12 @@ + + + + + + Document + + + +
+ + \ No newline at end of file diff --git a/test/public-mocker/fixtures/autospy/index.js b/test/public-mocker/fixtures/autospy/index.js new file mode 100644 index 000000000000..062ff734066a --- /dev/null +++ b/test/public-mocker/fixtures/autospy/index.js @@ -0,0 +1,10 @@ +import { mocker } from 'virtual:mocker' +import { calculate } from './test' + +mocker.customMock(import('./test')) + +document.querySelector('#mocked').textContent = calculate(1, 2) + +calculate.mockReturnValue(42) + +document.querySelector('#mocked').textContent += `, ${calculate(1, 2)}` diff --git a/test/public-mocker/fixtures/autospy/test.js b/test/public-mocker/fixtures/autospy/test.js new file mode 100644 index 000000000000..ce8540fd595f --- /dev/null +++ b/test/public-mocker/fixtures/autospy/test.js @@ -0,0 +1,3 @@ +export function calculate (a, b) { + return a + b +} diff --git a/test/public-mocker/fixtures/manual-mock/index.html b/test/public-mocker/fixtures/manual-mock/index.html new file mode 100644 index 000000000000..6fb842ec568c --- /dev/null +++ b/test/public-mocker/fixtures/manual-mock/index.html @@ -0,0 +1,12 @@ + + + + + + Document + + + +
+ + \ No newline at end of file diff --git a/test/public-mocker/fixtures/manual-mock/index.js b/test/public-mocker/fixtures/manual-mock/index.js new file mode 100644 index 000000000000..980413a0e429 --- /dev/null +++ b/test/public-mocker/fixtures/manual-mock/index.js @@ -0,0 +1,8 @@ +import { mocker } from 'virtual:mocker' +import { mocked } from './test' + +mocker.customMock(import('./test'), () => { + return { mocked: true } +}) + +document.querySelector('#mocked').textContent = mocked diff --git a/test/public-mocker/fixtures/manual-mock/test.js b/test/public-mocker/fixtures/manual-mock/test.js new file mode 100644 index 000000000000..f0b5ec62bc13 --- /dev/null +++ b/test/public-mocker/fixtures/manual-mock/test.js @@ -0,0 +1 @@ +export const mocked = false diff --git a/test/public-mocker/fixtures/redirect/__mocks__/test.js b/test/public-mocker/fixtures/redirect/__mocks__/test.js new file mode 100644 index 000000000000..f5f84326502d --- /dev/null +++ b/test/public-mocker/fixtures/redirect/__mocks__/test.js @@ -0,0 +1,3 @@ +export function calculate() { + return 42 +} \ No newline at end of file diff --git a/test/public-mocker/fixtures/redirect/index.html b/test/public-mocker/fixtures/redirect/index.html new file mode 100644 index 000000000000..6fb842ec568c --- /dev/null +++ b/test/public-mocker/fixtures/redirect/index.html @@ -0,0 +1,12 @@ + + + + + + Document + + + +
+ + \ No newline at end of file diff --git a/test/public-mocker/fixtures/redirect/index.js b/test/public-mocker/fixtures/redirect/index.js new file mode 100644 index 000000000000..ac716b04438b --- /dev/null +++ b/test/public-mocker/fixtures/redirect/index.js @@ -0,0 +1,6 @@ +import { mocker } from 'virtual:mocker' +import { calculate } from './test' + +mocker.customMock(import('./test')) + +document.querySelector('#mocked').textContent = calculate(1, 2) diff --git a/test/public-mocker/fixtures/redirect/test.js b/test/public-mocker/fixtures/redirect/test.js new file mode 100644 index 000000000000..ce8540fd595f --- /dev/null +++ b/test/public-mocker/fixtures/redirect/test.js @@ -0,0 +1,3 @@ +export function calculate (a, b) { + return a + b +} diff --git a/test/public-mocker/package.json b/test/public-mocker/package.json new file mode 100644 index 000000000000..7bee2f679a5f --- /dev/null +++ b/test/public-mocker/package.json @@ -0,0 +1,15 @@ +{ + "name": "@vitest/test-public-mocker", + "type": "module", + "private": true, + "scripts": { + "test": "vitest run", + "dev": "vite" + }, + "devDependencies": { + "@vitest/browser": "workspace:*", + "@vitest/mocker": "workspace:*", + "playwright": "^1.46.1", + "vitest": "workspace:*" + } +} diff --git a/test/public-mocker/test/mocker.test.ts b/test/public-mocker/test/mocker.test.ts new file mode 100644 index 000000000000..09a4a698b90a --- /dev/null +++ b/test/public-mocker/test/mocker.test.ts @@ -0,0 +1,105 @@ +import { mockerPlugin } from '@vitest/mocker/node' +import type { UserConfig } from 'vite' +import { createServer } from 'vite' +import { beforeAll, expect, it, onTestFinished } from 'vitest' +import type { Browser } from 'playwright' +import { chromium } from 'playwright' + +let browser: Browser +beforeAll(async () => { + browser = await chromium.launch() + return async () => { + await browser.close() + browser = null as any + } +}) + +it('default server manual mocker works correctly', async () => { + const { page } = await createTestServer({ + root: 'fixtures/manual-mock', + }) + + await expect.poll(() => page.locator('css=#mocked').textContent()).toBe('true') +}) + +it('automock works correctly', async () => { + const { page } = await createTestServer({ + root: 'fixtures/automock', + }) + + await expect.poll(() => page.locator('css=#mocked').textContent()).toBe('42') +}) + +it('autospy works correctly', async () => { + const { page } = await createTestServer({ + root: 'fixtures/autospy', + }) + + await expect.poll(() => page.locator('css=#mocked').textContent()).toBe('3, 42') +}) + +it('redirect works correctly', async () => { + const { page } = await createTestServer({ + root: 'fixtures/redirect', + }) + + await expect.poll(() => page.locator('css=#mocked').textContent()).toBe('42') +}) + +async function createTestServer(config: UserConfig) { + const server = await createServer({ + ...config, + plugins: [ + mockerPlugin({ + globalThisAccessor: 'Symbol.for("vitest.mocker")', + hoistMocks: { + utilsObjectNames: ['mocker'], + hoistedModules: ['virtual:mocker'], + hoistableMockMethodNames: ['customMock'], + dynamicImportMockMethodNames: ['customMock'], + hoistedMethodNames: ['customHoisted'], + }, + }), + { + name: 'vi:resolver', + enforce: 'pre', + resolveId(id) { + if (id === 'virtual:mocker') { + return id + } + }, + load(id) { + if (id === 'virtual:mocker') { + return ` +import { registerModuleMocker } from '@vitest/mocker/register' +import { ModuleMockerServerInterceptor } from '@vitest/mocker/browser' + +const _mocker = registerModuleMocker( + () => new ModuleMockerServerInterceptor() +) + +export const mocker = { + customMock: _mocker.mock, + customHoisted: _mocker.hoisted, +} + ` + } + }, + }, + ], + }) + await server.listen() + onTestFinished(async () => { + await server.close() + }) + const page = await browser.newPage() + onTestFinished(async () => { + await page.close() + }) + await page.goto(server.resolvedUrls!.local[0]) + + return { + server, + page, + } +} diff --git a/test/public-mocker/vite.config.ts b/test/public-mocker/vite.config.ts new file mode 100644 index 000000000000..3dc989e5e486 --- /dev/null +++ b/test/public-mocker/vite.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from 'vite' +import { mockerPlugin } from '@vitest/mocker/node' + +export default defineConfig({ + root: 'fixtures/redirect', + plugins: [ + mockerPlugin({ + globalThisAccessor: 'Symbol.for("vitest.mocker")', + hoistMocks: { + utilsObjectNames: ['mocker'], + hoistedModules: ['virtual:mocker'], + hoistableMockMethodNames: ['customMock'], + dynamicImportMockMethodNames: ['customMock'], + hoistedMethodNames: ['customHoisted'], + }, + }), + { + name: 'vi:resolver', + enforce: 'pre', + resolveId(id) { + if (id === 'virtual:mocker') { + return id + } + }, + load(id) { + if (id === 'virtual:mocker') { + return ` +import { registerModuleMocker } from '@vitest/mocker/register' +import { ModuleMockerServerInterceptor } from '@vitest/mocker/browser' + +const _mocker = registerModuleMocker( +() => new ModuleMockerServerInterceptor() +) + +export const mocker = { +customMock: _mocker.mock, +customHoisted: _mocker.hoisted, +} + ` + } + }, + }, + ], +}) diff --git a/test/public-mocker/vitest.config.ts b/test/public-mocker/vitest.config.ts new file mode 100644 index 000000000000..17e2f1e3d80f --- /dev/null +++ b/test/public-mocker/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + isolate: false, + environment: 'node', + expect: { + poll: { + timeout: 30_000, + }, + }, + }, +}) diff --git a/tsconfig.base.json b/tsconfig.base.json index 5907b7d02575..0b7c334df5be 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,6 +14,9 @@ "@vitest/snapshot": ["./packages/snapshot/src/index.ts"], "@vitest/snapshot/*": ["./packages/snapshot/src/*"], "@vitest/expect": ["./packages/expect/src/index.ts"], + "@vitest/mocker": ["./packages/mocker/src/index.ts"], + "@vitest/mocker/node": ["./packages/mocker/src/node/index.ts"], + "@vitest/mocker/browser": ["./packages/mocker/src/browser/index.ts"], "@vitest/runner": ["./packages/runner/src/index.ts"], "@vitest/runner/*": ["./packages/runner/src/*"], "@vitest/browser": ["./packages/browser/src/node/index.ts"],