From 7b2f64cfa34d087d383d915c08268c0da3f54754 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 31 May 2024 11:19:38 +0200 Subject: [PATCH] feat: implement module mocking in browser mode (#5765) --- docs/api/vi.md | 4 - docs/config/index.md | 13 +- docs/guide/cli-table.md | 1 - packages/browser/context.d.ts | 4 + packages/browser/package.json | 2 + packages/browser/src/client/client.ts | 107 +- packages/browser/src/client/logger.ts | 7 +- packages/browser/src/client/main.ts | 13 +- packages/browser/src/client/mocker.ts | 283 ++++- .../src/client/public/esm-client-injector.js | 40 +- packages/browser/src/client/rpc.ts | 8 +- packages/browser/src/client/runner.ts | 6 +- packages/browser/src/client/snapshot.ts | 4 +- packages/browser/src/client/tester.ts | 21 +- packages/browser/src/client/utils.ts | 6 +- packages/browser/src/node/automocker.ts | 148 +++ packages/browser/src/node/esmInjector.ts | 219 +--- packages/browser/src/node/index.ts | 23 +- .../plugins/{context.ts => pluginContext.ts} | 1 + .../src/node/plugins/pluginDynamicImport.ts | 17 + .../browser/src/node/plugins/pluginMocker.ts | 59 + .../ui/client/composables/client/static.ts | 1 + packages/utils/src/base.ts | 2 +- packages/utils/src/source-map.ts | 8 +- packages/vite-node/src/server.ts | 19 +- packages/vite-node/src/types.ts | 6 + packages/vitest/src/api/browser.ts | 169 +++ packages/vitest/src/api/setup.ts | 104 +- packages/vitest/src/api/types.ts | 50 +- packages/vitest/src/browser.ts | 14 +- packages/vitest/src/constants.ts | 1 + .../vitest/src/integrations/browser/mocker.ts | 101 ++ .../vitest/src/integrations/browser/server.ts | 13 +- packages/vitest/src/integrations/vi.ts | 24 +- packages/vitest/src/node/cli/cli-api.ts | 2 +- packages/vitest/src/node/cli/cli-config.ts | 3 - packages/vitest/src/node/config.ts | 1 - packages/vitest/src/node/plugins/mocks.ts | 3 + packages/vitest/src/node/pools/browser.ts | 5 + packages/vitest/src/node/workspace.ts | 12 + packages/vitest/src/runtime/mocker.ts | 4 +- packages/vitest/src/types/browser.ts | 9 - packages/ws-client/src/index.ts | 15 +- pnpm-lock.yaml | 6 + .../fixtures/mocking/automocked.test.ts | 9 + .../mocking/import-actual-in-mock.test.ts | 15 + .../mocking/import-actual-query.test.ts | 19 + .../fixtures/mocking/import-mock.test.ts | 59 + .../fixtures/mocking/mocked-__mocks__.test.ts | 8 + .../mocking/mocked-do-mock-factory.test.ts | 14 + .../mocking/mocked-factory-hoisted.test.ts | 15 + .../fixtures/mocking/mocked-factory.test.ts | 14 + .../fixtures/mocking/mocked-nested.test.ts | 23 + .../mocking/not-mocked-nested.test.ts | 6 + .../fixtures/mocking/not-mocked.test.ts | 6 + .../mocking/src/__mocks__/mocks_calculator.ts | 3 + test/browser/fixtures/mocking/src/actions.ts | 3 + .../fixtures/mocking/src/calculator.ts | 8 + test/browser/fixtures/mocking/src/example.ts | 28 + .../fixtures/mocking/src/mocks_calculator.ts | 8 + .../fixtures/mocking/src/mocks_factory.ts | 5 + .../fixtures/mocking/src/nested_child.ts | 3 + .../fixtures/mocking/src/nested_parent.ts | 5 + .../browser/fixtures/mocking/vitest.config.ts | 17 + test/browser/package.json | 1 + test/browser/specs/benchmark.test.ts | 1 + test/browser/specs/mocking.test.ts | 22 + test/browser/specs/runner.test.ts | 4 +- test/browser/src/__mocks__/_calculator.ts | 3 + test/browser/test/cjs-lib.test.ts | 15 +- test/browser/test/mocked.test.ts | 29 - test/browser/vitest.config.mts | 1 - test/core/test/browserAutomocker.test.ts | 340 ++++++ test/core/test/injector-esm.test.ts | 1007 +---------------- test/test-utils/cli.ts | 3 +- 75 files changed, 1743 insertions(+), 1509 deletions(-) create mode 100644 packages/browser/src/node/automocker.ts rename packages/browser/src/node/plugins/{context.ts => pluginContext.ts} (96%) create mode 100644 packages/browser/src/node/plugins/pluginDynamicImport.ts create mode 100644 packages/browser/src/node/plugins/pluginMocker.ts create mode 100644 packages/vitest/src/api/browser.ts create mode 100644 packages/vitest/src/integrations/browser/mocker.ts create mode 100644 test/browser/fixtures/mocking/automocked.test.ts create mode 100644 test/browser/fixtures/mocking/import-actual-in-mock.test.ts create mode 100644 test/browser/fixtures/mocking/import-actual-query.test.ts create mode 100644 test/browser/fixtures/mocking/import-mock.test.ts create mode 100644 test/browser/fixtures/mocking/mocked-__mocks__.test.ts create mode 100644 test/browser/fixtures/mocking/mocked-do-mock-factory.test.ts create mode 100644 test/browser/fixtures/mocking/mocked-factory-hoisted.test.ts create mode 100644 test/browser/fixtures/mocking/mocked-factory.test.ts create mode 100644 test/browser/fixtures/mocking/mocked-nested.test.ts create mode 100644 test/browser/fixtures/mocking/not-mocked-nested.test.ts create mode 100644 test/browser/fixtures/mocking/not-mocked.test.ts create mode 100644 test/browser/fixtures/mocking/src/__mocks__/mocks_calculator.ts create mode 100644 test/browser/fixtures/mocking/src/actions.ts create mode 100644 test/browser/fixtures/mocking/src/calculator.ts create mode 100644 test/browser/fixtures/mocking/src/example.ts create mode 100644 test/browser/fixtures/mocking/src/mocks_calculator.ts create mode 100644 test/browser/fixtures/mocking/src/mocks_factory.ts create mode 100644 test/browser/fixtures/mocking/src/nested_child.ts create mode 100644 test/browser/fixtures/mocking/src/nested_parent.ts create mode 100644 test/browser/fixtures/mocking/vitest.config.ts create mode 100644 test/browser/specs/mocking.test.ts create mode 100644 test/browser/src/__mocks__/_calculator.ts delete mode 100644 test/browser/test/mocked.test.ts create mode 100644 test/core/test/browserAutomocker.test.ts diff --git a/docs/api/vi.md b/docs/api/vi.md index 72579744472c..7d9f5f55d24b 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -29,10 +29,6 @@ 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. ::: -::: warning -The [browser mode](/guide/browser) does not presently support mocking modules. You can track this feature in the GitHub [issue](https://github.com/vitest-dev/vitest/issues/3046). -::: - 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. 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. diff --git a/docs/config/index.md b/docs/config/index.md index a17eca5be863..044547f49821 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -1487,7 +1487,7 @@ Listen to port and serve API. When set to true, the default port is 51204 ### browser {#browser} -- **Type:** `{ enabled?, name?, provider?, headless?, api?, slowHijackESM? }` +- **Type:** `{ enabled?, name?, provider?, headless?, api? }` - **Default:** `{ enabled: false, headless: process.env.CI, api: 63315 }` - **CLI:** `--browser`, `--browser=`, `--browser.name=chrome --browser.headless` @@ -1601,17 +1601,6 @@ To have a better type safety when using built-in providers, you can add one of t ``` ::: -#### browser.slowHijackESM {#browser-slowhijackesm} - -- **Type:** `boolean` -- **Default:** `false` - -When running tests in Node.js Vitest can use its own module resolution to easily mock modules with `vi.mock` syntax. However it's not so easy to replicate ES module resolution in browser, so we need to transform your source files before browser can consume it. - -This option has no effect on tests running inside Node.js. - -If you rely on spying on ES modules with `vi.spyOn`, you can enable this experimental feature to allow spying on module exports. - #### browser.ui {#browser-ui} - **Type:** `boolean` diff --git a/docs/guide/cli-table.md b/docs/guide/cli-table.md index 20bb135225ee..e3cb122406dd 100644 --- a/docs/guide/cli-table.md +++ b/docs/guide/cli-table.md @@ -55,7 +55,6 @@ | `--browser.api.strictPort` | Set to true to exit if port is already in use, instead of automatically trying the next available port | | `--browser.provider ` | Provider used to run browser tests. Some browsers are only available for specific providers. Can be "webdriverio", "playwright", or the path to a custom provider. Visit [`browser.provider`](https://vitest.dev/config/#browser-provider) for more information (default: `"webdriverio"`) | | `--browser.providerOptions ` | Options that are passed down to a browser provider. Visit [`browser.providerOptions`](https://vitest.dev/config/#browser-provideroptions) for more information | -| `--browser.slowHijackESM` | Let Vitest use its own module resolution on the browser to enable APIs such as vi.mock and vi.spyOn. Visit [`browser.slowHijackESM`](https://vitest.dev/config/#browser-slowhijackesm) for more information (default: `false`) | | `--browser.isolate` | Run every browser test file in isolation. To disable isolation, use `--browser.isolate=false` (default: `true`) | | `--pool ` | Specify pool, if not running in the browser (default: `threads`) | | `--poolOptions.threads.isolate` | Isolate tests in threads pool (default: `true`) | diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index b9ba9fbc9ce9..259b194943da 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -61,6 +61,10 @@ export const server: { * Name of the browser provider. */ provider: string + /** + * Name of the current browser. + */ + browser: string /** * Available commands for the browser. * @see {@link https://vitest.dev/guide/browser#commands} diff --git a/packages/browser/package.json b/packages/browser/package.json index da7f7f01163d..343a7b274a1c 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -79,6 +79,8 @@ "@vitest/ui": "workspace:*", "@vitest/ws-client": "workspace:*", "@wdio/protocols": "^8.32.0", + "birpc": "0.2.17", + "flatted": "^3.3.1", "periscopic": "^4.0.2", "playwright": "^1.44.0", "playwright-core": "^1.44.0", diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index 80806d587f8e..ae248a8b9baa 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -1,21 +1,116 @@ import type { CancelReason } from '@vitest/runner' -import { createClient } from '@vitest/ws-client' +import { type BirpcReturn, createBirpc } from 'birpc' +import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from 'vitest' +import { parse, stringify } from 'flatted' +import type { VitestBrowserClientMocker } from './mocker' +import { getBrowserState } from './utils' export const PORT = import.meta.hot ? '51204' : location.port export const HOST = [location.hostname, PORT].filter(Boolean).join(':') +export const SESSION_ID = crypto.randomUUID() export const ENTRY_URL = `${ location.protocol === 'https:' ? 'wss:' : 'ws:' -}//${HOST}/__vitest_api__` +}//${HOST}/__vitest_browser_api__?type=${getBrowserState().type}&sessionId=${SESSION_ID}` let setCancel = (_: CancelReason) => {} export const onCancel = new Promise((resolve) => { setCancel = resolve }) -export const client = createClient(ENTRY_URL, { - handlers: { +export interface VitestBrowserClient { + rpc: BrowserRPC + ws: WebSocket + waitForConnection: () => Promise +} + +type BrowserRPC = BirpcReturn + +function createClient() { + const autoReconnect = true + const reconnectInterval = 2000 + const reconnectTries = 10 + const connectTimeout = 60000 + + let tries = reconnectTries + + const ctx: VitestBrowserClient = { + ws: new WebSocket(ENTRY_URL), + waitForConnection, + } as VitestBrowserClient + + let onMessage: Function + + ctx.rpc = createBirpc({ onCancel: setCancel, - }, -}) + async startMocking(id: string) { + // @ts-expect-error not typed global + if (typeof __vitest_mocker__ === 'undefined') + throw new Error(`Cannot mock modules in the orchestrator process`) + // @ts-expect-error not typed global + const mocker = __vitest_mocker__ as VitestBrowserClientMocker + const exports = await mocker.resolve(id) + return Object.keys(exports) + }, + }, { + post: msg => ctx.ws.send(msg), + on: fn => (onMessage = fn), + serialize: e => stringify(e, (_, v) => { + if (v instanceof Error) { + return { + name: v.name, + message: v.message, + stack: v.stack, + } + } + return v + }), + deserialize: parse, + onTimeoutError(functionName) { + throw new Error(`[vitest-browser]: Timeout calling "${functionName}"`) + }, + }) + + let openPromise: Promise + + function reconnect(reset = false) { + if (reset) + tries = reconnectTries + ctx.ws = new WebSocket(ENTRY_URL) + registerWS() + } + + function registerWS() { + openPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Cannot connect to the server in ${connectTimeout / 1000} seconds`)) + }, connectTimeout)?.unref?.() + if (ctx.ws.OPEN === ctx.ws.readyState) + resolve() + // still have a listener even if it's already open to update tries + ctx.ws.addEventListener('open', () => { + tries = reconnectTries + resolve() + clearTimeout(timeout) + }) + }) + ctx.ws.addEventListener('message', (v) => { + onMessage(v.data) + }) + ctx.ws.addEventListener('close', () => { + tries -= 1 + if (autoReconnect && tries > 0) + setTimeout(reconnect, reconnectInterval) + }) + } + + registerWS() + + function waitForConnection() { + return openPromise + } + + return ctx +} +export const client = createClient() export const channel = new BroadcastChannel('vitest') diff --git a/packages/browser/src/client/logger.ts b/packages/browser/src/client/logger.ts index c65eaa3f3290..5f761aef1e0a 100644 --- a/packages/browser/src/client/logger.ts +++ b/packages/browser/src/client/logger.ts @@ -53,8 +53,11 @@ export async function setupConsoleLogSpy() { console.trace = (...args: unknown[]) => { const content = processLog(args) - const error = new Error('Trace') - const stack = (error.stack || '').split('\n').slice(2).join('\n') + const error = new Error('$$Trace') + const stack = (error.stack || '') + .split('\n') + .slice(error.stack?.includes('$$Trace') ? 2 : 1) + .join('\n') sendLog('stdout', `${content}\n${stack}`) return trace(...args) } diff --git a/packages/browser/src/client/main.ts b/packages/browser/src/client/main.ts index 8816400dc086..5c72502e6b89 100644 --- a/packages/browser/src/client/main.ts +++ b/packages/browser/src/client/main.ts @@ -69,12 +69,15 @@ client.ws.addEventListener('open', async () => { const filenames = e.data.filenames filenames.forEach(filename => runningFiles.delete(filename)) - const iframeId = filenames.length > 1 ? ID_ALL : filenames[0] - iframes.get(iframeId)?.remove() - iframes.delete(iframeId) - - if (!runningFiles.size) + if (!runningFiles.size) { await done() + } + else { + // keep the last iframe + const iframeId = filenames.length > 1 ? ID_ALL : filenames[0] + iframes.get(iframeId)?.remove() + iframes.delete(iframeId) + } break } // error happened at the top level, this should never happen in user code, but it can trigger during development diff --git a/packages/browser/src/client/mocker.ts b/packages/browser/src/client/mocker.ts index f076413d52e9..10780d3c733c 100644 --- a/packages/browser/src/client/mocker.ts +++ b/packages/browser/src/client/mocker.ts @@ -1,25 +1,282 @@ -function throwNotImplemented(name: string) { - throw new Error(`[vitest] ${name} is not implemented in browser environment yet.`) -} +import { getType } from '@vitest/utils' +import { extname } from 'pathe' +import { rpc } from './rpc' +import { getBrowserState } from './utils' + +const now = Date.now export class VitestBrowserClientMocker { - public importActual() { - throwNotImplemented('importActual') + private queue = new Set>() + private mocks: Record = {} + private factories: Record any> = {} + + private spyModule!: typeof import('vitest') + + public setSpyModule(mod: typeof import('vitest')) { + 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 base = getBrowserState().config.base || '/' + const url = new URL(`/@id${base}${resolved.id}`, location.href) + const query = `_vitest_original&ext.${ext}` + const actualUrl = `${url.pathname}${ + url.search ? `${url.search}&${query}` : `?${query}` + }${url.hash}` + return getBrowserState().wrapModule(() => import(actualUrl)) + } + + public async importMock(rawId: string, importer: string) { + await this.prepare() + const { resolvedId, type, mockPath } = await rpc().resolveMock(rawId, importer) + + const factoryReturn = this.get(resolvedId) + if (factoryReturn) + return factoryReturn + + if (this.factories[resolvedId]) + return await this.resolve(resolvedId) + + const base = getBrowserState().config.base || '/' + if (type === 'redirect') { + const url = new URL(`/@id${base}${mockPath}`, location.href) + return import(url.toString()) + } + const url = new URL(`/@id${base}${resolvedId}`, location.href) + const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}` + const moduleObject = await import(`${url.pathname}${query}${url.hash}`) + return this.mockObject(moduleObject) + } + + public getMockContext() { + return { callstack: null } } - public importMock() { - throwNotImplemented('importMock') + public get(id: string) { + return this.mocks[id] } - public queueMock() { - throwNotImplemented('queueMock') + public async resolve(id: string) { + const factory = this.factories[id] + if (!factory) + throw new Error(`Cannot resolve ${id} mock: no factory provided`) + try { + this.mocks[id] = await factory() + return this.mocks[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 queueUnmock() { - throwNotImplemented('queueUnmock') + public queueMock(id: string, importer: string, factory?: () => any) { + const promise = rpc().queueMock(id, importer, !!factory) + .then((id) => { + this.factories[id] = factory! + }).finally(() => { + this.queue.delete(promise) + }) + this.queue.add(promise) } - public prepare() { - // TODO: prepare + public queueUnmock(id: string, importer: string) { + const promise = rpc().queueUnmock(id, importer) + .then((id) => { + delete this.factories[id] + }).finally(() => { + this.queue.delete(promise) + }) + this.queue.add(promise) } + + public async prepare() { + if (!this.queue.size) + return + await Promise.all([...this.queue.values()]) + } + + // 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 (error) { + // 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.vi.spyOn(this, key as string).mockImplementation(original) + mock.mockRestore = () => { + mock.mockReset() + mock.mockImplementation(original) + return mock + } + } + } + } + } + const mock = spyModule.vi.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 + +export function getAllMockableProperties(obj: any, isModule: boolean) { + const allProps = new Map() + 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/browser/src/client/public/esm-client-injector.js b/packages/browser/src/client/public/esm-client-injector.js index 07b628c7f88e..d8020791ce01 100644 --- a/packages/browser/src/client/public/esm-client-injector.js +++ b/packages/browser/src/client/public/esm-client-injector.js @@ -1,42 +1,26 @@ const moduleCache = new Map() function wrapModule(module) { - if (module instanceof Promise) { - moduleCache.set(module, { promise: module, evaluated: false }) - return module - .then(m => '__vi_inject__' in m ? m.__vi_inject__ : m) - .finally(() => moduleCache.delete(module)) - } - return '__vi_inject__' in module ? module.__vi_inject__ : module -} - -function exportAll(exports, sourceModule) { - if (exports === sourceModule) - return - - if (Object(sourceModule) !== sourceModule || Array.isArray(sourceModule)) - return - - for (const key in sourceModule) { - if (key !== 'default') { - try { - Object.defineProperty(exports, key, { - enumerable: true, - configurable: true, - get: () => sourceModule[key], - }) - } - catch (_err) { } - } + if (typeof module === 'function') { + const promise = new Promise((resolve, reject) => { + if (typeof __vitest_mocker__ === 'undefined') + return module().then(resolve, reject) + __vitest_mocker__.prepare().finally(() => { + module().then(resolve, reject) + }) + }) + moduleCache.set(promise, { promise, evaluated: false }) + return promise.finally(() => moduleCache.delete(promise)) } + return module } window.__vitest_browser_runner__ = { - exportAll, wrapModule, moduleCache, config: { __VITEST_CONFIG__ }, files: { __VITEST_FILES__ }, + type: { __VITEST_TYPE__ }, } const config = __vitest_browser_runner__.config diff --git a/packages/browser/src/client/rpc.ts b/packages/browser/src/client/rpc.ts index 883fdc0cd59e..7611cecbf4e0 100644 --- a/packages/browser/src/client/rpc.ts +++ b/packages/browser/src/client/rpc.ts @@ -1,8 +1,8 @@ import type { getSafeTimers, } from '@vitest/utils' -import type { VitestClient } from '@vitest/ws-client' import { importId } from './utils' +import type { VitestBrowserClient } from './client' const { get } = Reflect @@ -40,7 +40,7 @@ export async function rpcDone() { return Promise.all(awaitable) } -export function createSafeRpc(client: VitestClient, getTimers: () => any): VitestClient['rpc'] { +export function createSafeRpc(client: VitestBrowserClient, getTimers: () => any): VitestBrowserClient['rpc'] { return new Proxy(client.rpc, { get(target, p, handler) { if (p === 'then') @@ -62,13 +62,13 @@ export function createSafeRpc(client: VitestClient, getTimers: () => any): Vites }) } -export async function loadSafeRpc(client: VitestClient) { +export async function loadSafeRpc(client: VitestBrowserClient) { // if importing /@id/ failed, we reload the page waiting until Vite prebundles it const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils') return createSafeRpc(client, getSafeTimers) } -export function rpc(): VitestClient['rpc'] { +export function rpc(): VitestBrowserClient['rpc'] { // @ts-expect-error not typed global return globalThis.__vitest_worker__.rpc } diff --git a/packages/browser/src/client/runner.ts b/packages/browser/src/client/runner.ts index e25921ad7193..2e154543ffea 100644 --- a/packages/browser/src/client/runner.ts +++ b/packages/browser/src/client/runner.ts @@ -2,7 +2,7 @@ import type { File, Task, TaskResultPack, VitestRunner } from '@vitest/runner' import type { ResolvedConfig } from 'vitest' import type { VitestExecutor } from 'vitest/execute' import { rpc } from './rpc' -import { getConfig, importId } from './utils' +import { importId } from './utils' import { VitestBrowserSnapshotEnvironment } from './snapshot' interface BrowserRunnerOptions { @@ -43,6 +43,7 @@ export function createBrowserRunner( } onAfterRunFiles = async (files: File[]) => { + await rpc().invalidateMocks() await super.onAfterRunFiles?.(files) const coverage = await coverageModule?.takeCoverage?.() @@ -88,10 +89,9 @@ export function createBrowserRunner( let cachedRunner: VitestRunner | null = null -export async function initiateRunner() { +export async function initiateRunner(config: ResolvedConfig) { if (cachedRunner) return cachedRunner - const config = getConfig() const [ { VitestTestRunner, NodeBenchmarkRunner }, { takeCoverageInsideWorker, loadDiffConfig, loadSnapshotSerializers }, diff --git a/packages/browser/src/client/snapshot.ts b/packages/browser/src/client/snapshot.ts index a441423b81a7..3b6cd50f3ddc 100644 --- a/packages/browser/src/client/snapshot.ts +++ b/packages/browser/src/client/snapshot.ts @@ -1,5 +1,5 @@ -import type { VitestClient } from '@vitest/ws-client' import type { SnapshotEnvironment } from 'vitest/snapshot' +import type { VitestBrowserClient } from './client' export class VitestBrowserSnapshotEnvironment implements SnapshotEnvironment { getVersion(): string { @@ -31,7 +31,7 @@ export class VitestBrowserSnapshotEnvironment implements SnapshotEnvironment { } } -function rpc(): VitestClient['rpc'] { +function rpc(): VitestBrowserClient['rpc'] { // @ts-expect-error not typed global return globalThis.__vitest_worker__.rpc } diff --git a/packages/browser/src/client/tester.ts b/packages/browser/src/client/tester.ts index 58ff49ce5857..e2793b71ee51 100644 --- a/packages/browser/src/client/tester.ts +++ b/packages/browser/src/client/tester.ts @@ -27,7 +27,12 @@ async function tryCall(fn: () => Promise): Promise const now = Date.now() // try for 30 seconds const canTry = !reloadStart || (now - Number(reloadStart) < 30_000) - debug('failed to resolve runner', err?.message, 'trying again:', canTry, 'time is', now, 'reloadStart is', reloadStart) + const errorStack = (() => { + if (!err) + return null + return err.stack?.includes(err.message) ? err.stack : `${err.message}\n${err.stack}` + })() + debug('failed to resolve runner', 'trying again:', canTry, 'time is', now, 'reloadStart is', reloadStart, ':\n', errorStack) if (!canTry) { const error = serializeError(new Error('Vitest failed to load its runner after 30 seconds.')) error.cause = serializeError(err) @@ -100,22 +105,26 @@ async function prepareTestEnvironment(files: string[]) { globalThis.__vitest_browser__ = true // @ts-expect-error mocking vitest apis globalThis.__vitest_worker__ = state + const mocker = new VitestBrowserClientMocker() // @ts-expect-error mocking vitest apis - globalThis.__vitest_mocker__ = new VitestBrowserClientMocker() + globalThis.__vitest_mocker__ = mocker await setupConsoleLogSpy() setupDialogsSpy() - const { startTests, setupCommonEnv } = await importId('vitest/browser') as typeof import('vitest/browser') - - const version = url.searchParams.get('browserv') || '0' + const version = url.searchParams.get('browserv') || '' files.forEach((filename) => { const currentVersion = browserHashMap.get(filename) if (!currentVersion || currentVersion[1] !== version) browserHashMap.set(filename, [true, version]) }) - const runner = await initiateRunner() + const [runner, { startTests, setupCommonEnv, Vitest }] = await Promise.all([ + initiateRunner(config), + importId('vitest/browser') as Promise, + ]) + + mocker.setSpyModule(Vitest) onCancel.then((reason) => { runner.onCancel?.(reason) diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index 4880c2f2a100..8c13e93dc6e7 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -2,7 +2,7 @@ import type { ResolvedConfig, WorkerGlobalState } from 'vitest' export async function importId(id: string) { const name = `${getConfig().base || '/'}@id/${id}` - return getBrowserState().wrapModule(import(name)) + return getBrowserState().wrapModule(() => import(name)) } export function getConfig(): ResolvedConfig { @@ -14,8 +14,8 @@ interface BrowserRunnerState { runningFiles: string[] moduleCache: WorkerGlobalState['moduleCache'] config: ResolvedConfig - exportAll: () => void - wrapModule: (module: any) => any + type: 'tester' | 'orchestrator' + wrapModule: (module: () => T) => T runTests: (tests: string[]) => Promise } diff --git a/packages/browser/src/node/automocker.ts b/packages/browser/src/node/automocker.ts new file mode 100644 index 000000000000..fc6bb550d4b1 --- /dev/null +++ b/packages/browser/src/node/automocker.ts @@ -0,0 +1,148 @@ +import type { Declaration, ExportDefaultDeclaration, ExportNamedDeclaration, Expression, Identifier, Literal, Pattern, Positioned, Program } from '@vitest/utils/ast' +import MagicString from 'magic-string' + +// TODO: better source map replacement +export function automockModule(code: string, parse: (code: string) => Program) { + const ast = parse(code) + + const m = new MagicString(code) + + const allSpecifiers: { name: string; alias?: string }[] = [] + let importIndex = 0 + for (const _node of ast.body) { + if (_node.type === 'ExportAllDeclaration') { + throw new Error( + `automocking files with \`export *\` is not supported in browser mode because it cannot be statically analysed`, + ) + } + + if (_node.type === 'ExportNamedDeclaration') { + const node = _node as Positioned + const declaration = node.declaration // export const name + + function traversePattern(expression: Pattern) { + // export const test = '1' + if (expression.type === 'Identifier') { + allSpecifiers.push({ name: expression.name }) + } + // export const [test, ...rest] = [1, 2, 3] + else if (expression.type === 'ArrayPattern') { + expression.elements.forEach((element) => { + if (!element) + return + traversePattern(element) + }) + } + else if (expression.type === 'ObjectPattern') { + expression.properties.forEach((property) => { + // export const { ...rest } = {} + if (property.type === 'RestElement') + traversePattern(property) + // export const { test, test2: alias } = {} + else if (property.type === 'Property') + traversePattern(property.value) + else + property satisfies never + }) + } + else if (expression.type === 'RestElement') { + traversePattern(expression.argument) + } + // const [name[1], name[2]] = [] + // cannot be used in export + else if (expression.type === 'AssignmentPattern') { + throw new Error(`AssignmentPattern is not supported. Please open a new bug report.`) + } + // const test = thing.func() + // cannot be used in export + else if (expression.type === 'MemberExpression') { + throw new Error(`MemberExpression is not supported. Please open a new bug report.`) + } + else { + expression satisfies never + } + } + + if (declaration) { + if (declaration.type === 'FunctionDeclaration') { + allSpecifiers.push({ name: declaration.id.name }) + } + else if (declaration.type === 'VariableDeclaration') { + declaration.declarations.forEach((declaration) => { + traversePattern(declaration.id) + }) + } + else if (declaration.type === 'ClassDeclaration') { + allSpecifiers.push({ name: declaration.id.name }) + } + else { + declaration satisfies never + } + m.remove(node.start, (declaration as Positioned).start) + } + + const specifiers = node.specifiers || [] + const source = node.source + + if (!source && specifiers.length) { + specifiers.forEach((specifier) => { + const exported = specifier.exported as Literal | Identifier + + allSpecifiers.push({ + alias: exported.type === 'Literal' + ? exported.raw! + : exported.name, + name: specifier.local.name, + }) + }) + m.remove(node.start, node.end) + } + else if (source && specifiers.length) { + const importNames: [string, string][] = [] + + specifiers.forEach((specifier) => { + const importedName = `__vitest_imported_${importIndex++}__` + const exported = specifier.exported as Literal | Identifier + importNames.push([specifier.local.name, importedName]) + allSpecifiers.push({ + name: importedName, + alias: exported.type === 'Literal' + ? exported.raw! + : exported.name, + }) + }) + + const importString = `import { ${importNames.map(([name, alias]) => `${name} as ${alias}`).join(', ')} } from '${source.value}'` + + m.overwrite(node.start, node.end, importString) + } + } + if (_node.type === 'ExportDefaultDeclaration') { + const node = _node as Positioned + const declaration = node.declaration as Positioned + allSpecifiers.push({ name: '__vitest_default', alias: 'default' }) + m.overwrite(node.start, declaration.start, `const __vitest_default = `) + } + } + const moduleObject = ` +const __vitest_es_current_module__ = { + __esModule: true, + ${allSpecifiers.map(({ name }) => `["${name}"]: ${name},`).join('\n ')} +} +const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) +` + const assigning = allSpecifiers.map(({ name }, index) => { + return `const __vitest_mocked_${index}__ = __vitest_mocked_module__["${name}"]` + }).join('\n') + + const redeclarations = allSpecifiers.map(({ name, alias }, index) => { + return ` __vitest_mocked_${index}__ as ${alias || name},` + }).join('\n') + const specifiersExports = ` +export { +${redeclarations} +} +` + m.append(moduleObject + assigning + specifiersExports) + return m +} diff --git a/packages/browser/src/node/esmInjector.ts b/packages/browser/src/node/esmInjector.ts index eea6f2461905..08d3e370eb17 100644 --- a/packages/browser/src/node/esmInjector.ts +++ b/packages/browser/src/node/esmInjector.ts @@ -1,26 +1,9 @@ import MagicString from 'magic-string' -import { extract_names as extractNames } from 'periscopic' import type { PluginContext } from 'rollup' import { esmWalker } from '@vitest/utils/ast' -import type { Expression, ImportDeclaration, Node, Positioned } from '@vitest/utils/ast' - -const viInjectedKey = '__vi_inject__' -// const viImportMetaKey = '__vi_import_meta__' // to allow overwrite -const viExportAllHelper = '__vitest_browser_runner__.exportAll' - -const skipHijack = [ - '/@vite/client', - '/@vite/env', - /vite\/dist\/client/, -] - -// this is basically copypaste from Vite SSR -// this method transforms all import and export statements into `__vi_injected__` variable -// to allow spying on them. this can be disabled by setting `slowHijackESM` to `false` -export function injectVitestModule(code: string, id: string, parse: PluginContext['parse']) { - if (skipHijack.some(skip => id.match(skip))) - return +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 @@ -32,215 +15,19 @@ export function injectVitestModule(code: string, id: string, parse: PluginContex return } - let uid = 0 - const idToImportMap = new Map() - const declaredConst = new Set() - - const hoistIndex = 0 - - // this will transform import statements into dynamic ones, if there are imports - // it will keep the import as is, if we don't need to mock anything - // in browser environment it will wrap the module value with "vitest_wrap_module" function - // that returns a proxy to the module so that named exports can be mocked - const transformImportDeclaration = (node: ImportDeclaration) => { - const source = node.source.value as string - - if (skipHijack.some(skip => source.match(skip))) - return null - - const importId = `__vi_esm_${uid++}__` - const hasSpecifiers = node.specifiers.length > 0 - const code = hasSpecifiers - ? `import { ${viInjectedKey} as ${importId} } from '${source}'\n` - : `import '${source}'\n` - return { - code, - id: importId, - } - } - - function defineImport(node: ImportDeclaration) { - const declaration = transformImportDeclaration(node) - if (!declaration) - return null - s.appendLeft(hoistIndex, declaration.code) - return declaration.id - } - - function defineImportAll(source: string) { - const importId = `__vi_esm_${uid++}__` - s.appendLeft(hoistIndex, `const { ${viInjectedKey}: ${importId} } = await import(${JSON.stringify(source)});\n`) - return importId - } - - function defineExport(position: number, name: string, local = name) { - s.appendLeft( - position, - `\nObject.defineProperty(${viInjectedKey}, "${name}", ` - + `{ enumerable: true, configurable: true, get(){ return ${local} }});`, - ) - } - - // 1. check all import statements and record id -> importName map - for (const node of ast.body as Node[]) { - // import foo from 'foo' --> foo -> __import_foo__.default - // import { baz } from 'foo' --> baz -> __import_foo__.baz - // import * as ok from 'foo' --> ok -> __import_foo__ - if (node.type === 'ImportDeclaration') { - const importId = defineImport(node) - if (!importId) - continue - s.remove(node.start, node.end) - for (const spec of node.specifiers) { - if (spec.type === 'ImportSpecifier') { - idToImportMap.set( - spec.local.name, - `${importId}.${spec.imported.name}`, - ) - } - else if (spec.type === 'ImportDefaultSpecifier') { - idToImportMap.set(spec.local.name, `${importId}.default`) - } - else { - // namespace specifier - idToImportMap.set(spec.local.name, importId) - } - } - } - } - - // 2. check all export statements and define exports - for (const node of ast.body as Node[]) { - // named exports - if (node.type === 'ExportNamedDeclaration') { - if (node.declaration) { - if ( - node.declaration.type === 'FunctionDeclaration' - || node.declaration.type === 'ClassDeclaration' - ) { - // export function foo() {} - defineExport(node.end, node.declaration.id!.name) - } - else { - // export const foo = 1, bar = 2 - for (const declaration of node.declaration.declarations) { - const names = extractNames(declaration.id as any) - for (const name of names) - defineExport(node.end, name) - } - } - s.remove(node.start, (node.declaration as Node).start) - } - else { - s.remove(node.start, node.end) - if (node.source) { - // export { foo, bar } from './foo' - const importId = defineImportAll(node.source.value as string) - // hoist re-exports near the defined import so they are immediately exported - for (const spec of node.specifiers) { - defineExport( - hoistIndex, - spec.exported.name, - `${importId}.${spec.local.name}`, - ) - } - } - else { - // export { foo, bar } - for (const spec of node.specifiers) { - const local = spec.local.name - const binding = idToImportMap.get(local) - defineExport(node.end, spec.exported.name, binding || local) - } - } - } - } - - // default export - if (node.type === 'ExportDefaultDeclaration') { - const expressionTypes = ['FunctionExpression', 'ClassExpression'] - if ( - 'id' in node.declaration - && node.declaration.id - && !expressionTypes.includes(node.declaration.type) - ) { - // named hoistable/class exports - // export default function foo() {} - // export default class A {} - const { name } = node.declaration.id - s.remove(node.start, node.start + 15 /* 'export default '.length */) - s.append( - `\nObject.defineProperty(${viInjectedKey}, "default", ` - + `{ enumerable: true, configurable: true, value: ${name} });`, - ) - } - else { - // anonymous default exports - s.update( - node.start, - node.start + 14 /* 'export default'.length */, - `${viInjectedKey}.default =`, - ) - // keep export default for optimized dependencies - s.append(`\nexport default { ${viInjectedKey}: ${viInjectedKey}.default };\n`) - } - } - - // export * from './foo' - if (node.type === 'ExportAllDeclaration') { - s.remove(node.start, node.end) - const importId = defineImportAll(node.source.value as string) - // hoist re-exports near the defined import so they are immediately exported - if (node.exported) - defineExport(hoistIndex, node.exported.name, `${importId}`) - else - s.appendLeft(hoistIndex, `${viExportAllHelper}(${viInjectedKey}, ${importId});\n`) - } - } - // 3. convert references to import bindings & import.meta references esmWalker(ast, { - onIdentifier(id, info, parentStack) { - const binding = idToImportMap.get(id.name) - if (!binding) - return - - if (info.hasBindingShortcut) { - s.appendLeft(id.end, `: ${binding}`) - } - else if ( - info.classDeclaration - ) { - if (!declaredConst.has(id.name)) { - declaredConst.add(id.name) - // locate the top-most node containing the class declaration - const topNode = parentStack[parentStack.length - 2] - s.prependRight(topNode.start, `const ${id.name} = ${binding};\n`) - } - } - else if ( - // don't transform class name identifier - !info.classExpression - ) { - s.update(id.start, id.end, binding) - } - }, // TODO: make env updatable onImportMeta() { // s.update(node.start, node.end, viImportMetaKey) }, onDynamicImport(node) { - const replace = '__vitest_browser_runner__.wrapModule(import(' + const replace = '__vitest_browser_runner__.wrapModule(() => import(' s.overwrite(node.start, (node.source as Positioned).start, replace) s.overwrite(node.end - 1, node.end, '))') }, }) - // make sure "__vi_injected__" is declared as soon as possible - // prepend even if file doesn't export anything - s.prepend(`const ${viInjectedKey} = { [Symbol.toStringTag]: "Module" };\n`) - s.append(`\nexport { ${viInjectedKey} }`) - return { ast, code: s.toString(), diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index f0ca681339f8..90979298f3d4 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -7,8 +7,9 @@ import type { ResolvedConfig } from 'vitest' import type { BrowserScript, WorkspaceProject } from 'vitest/node' import { type Plugin, coverageConfigDefaults } from 'vitest/config' import { slash } from '@vitest/utils' -import { injectVitestModule } from './esmInjector' -import BrowserContext from './plugins/context' +import BrowserContext from './plugins/pluginContext' +import BrowserMocker from './plugins/pluginMocker' +import DynamicImport from './plugins/pluginDynamicImport' export type { BrowserCommand } from 'vitest/node' @@ -17,6 +18,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { const distRoot = resolve(pkgRoot, 'dist') return [ + ...BrowserMocker(project), { enforce: 'pre', name: 'vitest:browser', @@ -62,6 +64,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { const injector = replacer(await injectorJs, { __VITEST_CONFIG__: JSON.stringify(config), __VITEST_FILES__: JSON.stringify(files), + __VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"', }) if (url.pathname === base) { @@ -155,6 +158,9 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { 'vitest/browser', 'vitest/runners', '@vitest/utils', + 'std-env', + 'tinybench', + 'tinyspy', // loupe is manually transformed 'loupe', @@ -194,16 +200,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { }, }, BrowserContext(project), - { - name: 'vitest:browser:esm-injector', - enforce: 'post', - transform(source, id) { - const hijackESM = project.config.browser.slowHijackESM ?? false - if (!hijackESM) - return - return injectVitestModule(source, id, this.parse) - }, - }, + DynamicImport(), ] } @@ -223,7 +220,7 @@ function resolveCoverageFolder(project: WorkspaceProject) { // reportsDirectory not resolved yet const root = resolve( - options.root || options.root || process.cwd(), + options.root || process.cwd(), options.coverage.reportsDirectory || coverageConfigDefaults.reportsDirectory, ) diff --git a/packages/browser/src/node/plugins/context.ts b/packages/browser/src/node/plugins/pluginContext.ts similarity index 96% rename from packages/browser/src/node/plugins/context.ts rename to packages/browser/src/node/plugins/pluginContext.ts index 2eee16e33fa3..3b55bcb36708 100644 --- a/packages/browser/src/node/plugins/context.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -45,6 +45,7 @@ export const server = { platform: ${JSON.stringify(process.platform)}, version: ${JSON.stringify(process.version)}, provider: ${JSON.stringify(project.browserProvider!.name)}, + browser: ${JSON.stringify(project.config.browser.name)}, commands: { ${commandsCode} } diff --git a/packages/browser/src/node/plugins/pluginDynamicImport.ts b/packages/browser/src/node/plugins/pluginDynamicImport.ts new file mode 100644 index 000000000000..fe5ac3c74124 --- /dev/null +++ b/packages/browser/src/node/plugins/pluginDynamicImport.ts @@ -0,0 +1,17 @@ +import type { Plugin } from 'vite' +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/plugins/pluginMocker.ts b/packages/browser/src/node/plugins/pluginMocker.ts new file mode 100644 index 000000000000..1b2d269a6b3a --- /dev/null +++ b/packages/browser/src/node/plugins/pluginMocker.ts @@ -0,0 +1,59 @@ +import { readFile } from 'node:fs/promises' +import type { Plugin } from 'vitest/config' +import type { WorkspaceProject } from 'vitest/node' +import { automockModule } from '../automocker' + +export default (project: WorkspaceProject): Plugin[] => { + return [ + { + name: 'vitest:browser:mocker', + enforce: 'pre', + async load(id) { + const data = project.browserMocker.mocks.get(id) + if (!data) + return + const { mock, sessionId } = data + // undefined mock means there is a factory in the browser + if (mock === undefined) { + const rpc = project.browserRpc.testers.get(sessionId) + + if (!rpc) + throw new Error(`WebSocket rpc was destroyed for session ${sessionId}`) + + const exports = await rpc.startMocking(id) + const module = `const module = __vitest_mocker__.get('${id}');` + 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}` + } + + // should import the same module and automock all exports + if (mock === null) + return + + // file is inside __mocks__ + return readFile(mock, 'utf-8') + }, + }, + { + name: 'vitest:browser:automocker', + enforce: 'post', + transform(code, id) { + const data = project.browserMocker.mocks.get(id) + if (!data) + return + if (data.mock === null) { + const m = automockModule(code, this.parse) + + return { + code: m.toString(), + map: m.generateMap({ hires: 'boundary', source: id }), + } + } + }, + }, + ] +} diff --git a/packages/ui/client/composables/client/static.ts b/packages/ui/client/composables/client/static.ts index 333226b50213..24d72f56db53 100644 --- a/packages/ui/client/composables/client/static.ts +++ b/packages/ui/client/composables/client/static.ts @@ -72,6 +72,7 @@ export function createStaticClient(): VitestClient { onUnhandledError: noop, saveTestFile: asyncNoop, getProvidedContext: () => ({}), + getTestFiles: asyncNoop, } as WebSocketHandlers ctx.rpc = rpc as any as BirpcReturn diff --git a/packages/utils/src/base.ts b/packages/utils/src/base.ts index 27ab8298929d..4b213fe66f67 100644 --- a/packages/utils/src/base.ts +++ b/packages/utils/src/base.ts @@ -8,7 +8,7 @@ interface ErrorOptions { * - Rewrite prepareStackTrace to bypass "support-stack-trace" (usually takes ~250ms). */ export function createSimpleStackTrace(options?: ErrorOptions) { - const { message = 'error', stackTraceLimit = 1 } = options || {} + const { message = '$$stack trace error', stackTraceLimit = 1 } = options || {} const limit = Error.stackTraceLimit const prepareStackTrace = Error.prepareStackTrace Error.stackTraceLimit = stackTraceLimit diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index 7efa05646e7a..e6e667fb253e 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -27,7 +27,12 @@ const stackIgnorePatterns = [ '/node_modules/chai/', '/node_modules/tinypool/', '/node_modules/tinyspy/', + // browser related deps '/deps/chai.js', + '/deps/vitest___chai.js', + '/deps/p-limit.js', + /node:\w+/, + /__vitest_test__/, /__vitest_browser__/, ] @@ -46,8 +51,9 @@ function extractLocation(urlLike: string) { url = urlObj.pathname } if (url.startsWith('/@fs/')) { + const isWindows = /^\/@fs\/[a-zA-Z]:\//.test(url) url - = url.slice(typeof process !== 'undefined' && process.platform === 'win32' ? 5 : 4) + = url.slice(isWindows ? 5 : 4) } return [url, parts[2] || undefined, parts[3] || undefined] } diff --git a/packages/vite-node/src/server.ts b/packages/vite-node/src/server.ts index 076eadbd9c75..57d033ad76a1 100644 --- a/packages/vite-node/src/server.ts +++ b/packages/vite-node/src/server.ts @@ -1,12 +1,12 @@ import { performance } from 'node:perf_hooks' import { existsSync } from 'node:fs' import assert from 'node:assert' -import { join, normalize, relative, resolve } from 'pathe' +import { isAbsolute, join, normalize, relative, resolve } from 'pathe' import type { TransformResult, ViteDevServer } from 'vite' import createDebug from 'debug' import type { DebuggerOptions, EncodedSourceMap, FetchResult, ViteNodeResolveId, ViteNodeServerOptions } from './types' import { shouldExternalize } from './externalize' -import { normalizeModuleId, toArray, toFilePath, withTrailingSlash } from './utils' +import { cleanUrl, normalizeModuleId, toArray, toFilePath, withTrailingSlash } from './utils' import { Debugger } from './debug' import { withInlineSourcemap } from './source-map' @@ -136,6 +136,21 @@ export class ViteNodeServer { return this.server.pluginContainer.resolveId(id, importer, { ssr: mode === 'ssr' }) } + async resolveModule(rawId: string, resolved: ViteNodeResolveId | null) { + const id = resolved?.id || rawId + const external = (!isAbsolute(id) || this.isModuleDirectory(id)) ? rawId : null + return { + id, + fsPath: cleanUrl(id), + external, + } + } + + private isModuleDirectory(path: string) { + const moduleDirectories = this.options.deps?.moduleDirectories || ['/node_modules/'] + return moduleDirectories.some((dir: string) => path.includes(dir)) + } + getSourceMap(source: string) { const fetchResult = this.fetchCache.get(source)?.result if (fetchResult?.map) diff --git a/packages/vite-node/src/types.ts b/packages/vite-node/src/types.ts index ed0e9b056947..828c963966b2 100644 --- a/packages/vite-node/src/types.ts +++ b/packages/vite-node/src/types.ts @@ -89,6 +89,12 @@ export interface ViteNodeResolveId { syntheticNamedExports?: boolean | string | null } +export interface ViteNodeResolveModule { + external: string | null + id: string + fsPath: string +} + export interface ViteNodeServerOptions { /** * Inject inline sourcemap to modules diff --git a/packages/vitest/src/api/browser.ts b/packages/vitest/src/api/browser.ts new file mode 100644 index 000000000000..6967c7349238 --- /dev/null +++ b/packages/vitest/src/api/browser.ts @@ -0,0 +1,169 @@ +import { existsSync, promises as fs } from 'node:fs' + +import { dirname } from 'pathe' +import { createBirpc } from 'birpc' +import { parse, stringify } from 'flatted' +import type { WebSocket } from 'ws' +import { WebSocketServer } from 'ws' +import { isFileServingAllowed } from 'vite' +import type { ViteDevServer } from 'vite' +import { BROWSER_API_PATH } from '../constants' +import { stringifyReplace } from '../utils' +import type { WorkspaceProject } from '../node/workspace' +import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from './types' + +export function setupBrowserRpc(project: WorkspaceProject, server: ViteDevServer) { + const ctx = project.ctx + + const wss = new WebSocketServer({ noServer: true }) + + server.httpServer?.on('upgrade', (request, socket, head) => { + if (!request.url) + return + + const { pathname, searchParams } = new URL(request.url, 'http://localhost') + if (pathname !== BROWSER_API_PATH) + return + + const type = searchParams.get('type') ?? 'tester' + const sessionId = searchParams.get('sessionId') ?? '0' + + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request) + + const rpc = setupClient(sessionId, ws) + const rpcs = project.browserRpc + const clients = type === 'tester' ? rpcs.testers : rpcs.orchestrators + clients.set(sessionId, rpc) + + ws.on('close', () => { + clients.delete(sessionId) + }) + }) + }) + + function checkFileAccess(path: string) { + if (!isFileServingAllowed(path, server)) + throw new Error(`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`) + } + + function setupClient(sessionId: string, ws: WebSocket) { + const rpc = createBirpc( + { + async onUnhandledError(error, type) { + ctx.state.catchError(error, type) + }, + async onCollected(files) { + ctx.state.collectFiles(files) + await ctx.report('onCollected', files) + }, + async onTaskUpdate(packs) { + ctx.state.updateTasks(packs) + await ctx.report('onTaskUpdate', packs) + }, + onAfterSuiteRun(meta) { + ctx.coverageProvider?.onAfterSuiteRun(meta) + }, + sendLog(log) { + return ctx.report('onUserConsoleLog', log) + }, + resolveSnapshotPath(testPath) { + return ctx.snapshot.resolvePath(testPath) + }, + resolveSnapshotRawPath(testPath, rawPath) { + return ctx.snapshot.resolveRawPath(testPath, rawPath) + }, + snapshotSaved(snapshot) { + ctx.snapshot.add(snapshot) + }, + async readSnapshotFile(snapshotPath) { + checkFileAccess(snapshotPath) + if (!existsSync(snapshotPath)) + return null + return fs.readFile(snapshotPath, 'utf-8') + }, + async saveSnapshotFile(id, content) { + checkFileAccess(id) + await fs.mkdir(dirname(id), { recursive: true }) + return fs.writeFile(id, content, 'utf-8') + }, + async removeSnapshotFile(id) { + checkFileAccess(id) + if (!existsSync(id)) + throw new Error(`Snapshot file "${id}" does not exist.`) + return fs.unlink(id) + }, + async getBrowserFileSourceMap(id) { + const mod = project.browser?.moduleGraph.getModuleById(id) + return mod?.transformResult?.map + }, + onCancel(reason) { + ctx.cancelCurrentRun(reason) + }, + async resolveId(id, importer) { + const result = await project.server.pluginContainer.resolveId(id, importer, { + ssr: false, + }) + return result + }, + debug(...args) { + ctx.logger.console.debug(...args) + }, + getCountOfFailedTests() { + return ctx.state.getCountOfFailedTests() + }, + triggerCommand(command: string, testPath: string | undefined, payload: unknown[]) { + if (!project.browserProvider) + throw new Error('Commands are only available for browser tests.') + const commands = project.config.browser?.commands + if (!commands || !commands[command]) + throw new Error(`Unknown command "${command}".`) + return commands[command]({ + testPath, + project, + provider: project.browserProvider, + }, ...payload) + }, + getBrowserFiles() { + return project.browserState?.files ?? [] + }, + finishBrowserTests() { + return project.browserState?.resolve() + }, + getProvidedContext() { + return 'ctx' in project ? project.getProvidedContext() : ({} as any) + }, + async queueMock(id: string, importer: string, hasFactory: boolean) { + return project.browserMocker.mock(sessionId, id, importer, hasFactory) + }, + async queueUnmock(id: string, importer: string) { + return project.browserMocker.unmock(id, importer) + }, + resolveMock(rawId: string, importer: string) { + return project.browserMocker.resolveMock(rawId, importer, false) + }, + invalidateMocks() { + const mocker = project.browserMocker + mocker.mocks.forEach((_, id) => { + mocker.invalidateModuleById(id) + }) + mocker.mocks.clear() + }, + }, + { + post: msg => ws.send(msg), + on: fn => ws.on('message', fn), + eventNames: ['onCancel'], + serialize: (data: any) => stringify(data, stringifyReplace), + deserialize: parse, + onTimeoutError(functionName) { + throw new Error(`[vitest-api]: Timeout calling "${functionName}"`) + }, + }, + ) + + ctx.onCancel(reason => rpc.onCancel(reason)) + + return rpc + } +} diff --git a/packages/vitest/src/api/setup.ts b/packages/vitest/src/api/setup.ts index 484ce3ebb173..02c9359a7675 100644 --- a/packages/vitest/src/api/setup.ts +++ b/packages/vitest/src/api/setup.ts @@ -1,28 +1,22 @@ import { existsSync, promises as fs } from 'node:fs' -import { dirname } from 'pathe' -import type { BirpcReturn } from 'birpc' import { createBirpc } from 'birpc' import { parse, stringify } from 'flatted' import type { WebSocket } from 'ws' import { WebSocketServer } from 'ws' -import { isFileServingAllowed } from 'vite' -import type { ViteDevServer } from 'vite' import type { StackTraceParserOptions } from '@vitest/utils/source-map' +import type { ViteDevServer } from 'vite' import { API_PATH } from '../constants' import type { Vitest } from '../node' import type { Awaitable, File, ModuleGraphData, Reporter, SerializableSpec, TaskResultPack, UserConsoleLog } from '../types' import { getModuleGraph, isPrimitive, noop, stringifyReplace } from '../utils' -import type { WorkspaceProject } from '../node/workspace' import { parseErrorStacktrace } from '../utils/source-map' -import type { TransformResultWithSource, WebSocketEvents, WebSocketHandlers } from './types' - -export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: ViteDevServer) { - const ctx = 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.ctx : vitestOrWorkspace +import type { TransformResultWithSource, WebSocketEvents, WebSocketHandlers, WebSocketRPC } from './types' +export function setup(ctx: Vitest, _server?: ViteDevServer) { const wss = new WebSocketServer({ noServer: true }) - const clients = new Map>() + const clients = new Map() const server = _server || ctx.server @@ -40,17 +34,9 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi }) }) - function checkFileAccess(path: string) { - if (!isFileServingAllowed(path, server)) - throw new Error(`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`) - } - function setupClient(ws: WebSocket) { const rpc = createBirpc( { - async onUnhandledError(error, type) { - ctx.state.catchError(error, type) - }, async onCollected(files) { ctx.state.collectFiles(files) await ctx.report('onCollected', files) @@ -59,30 +45,12 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi ctx.state.updateTasks(packs) await ctx.report('onTaskUpdate', packs) }, - onAfterSuiteRun(meta) { - ctx.coverageProvider?.onAfterSuiteRun(meta) - }, getFiles() { return ctx.state.getFiles() }, getPaths() { return ctx.state.getPaths() }, - sendLog(log) { - return ctx.report('onUserConsoleLog', log) - }, - resolveSnapshotPath(testPath) { - return ctx.snapshot.resolvePath(testPath) - }, - resolveSnapshotRawPath(testPath, rawPath) { - return ctx.snapshot.resolveRawPath(testPath, rawPath) - }, - async readSnapshotFile(snapshotPath) { - checkFileAccess(snapshotPath) - if (!existsSync(snapshotPath)) - return null - return fs.readFile(snapshotPath, 'utf-8') - }, async readTestFile(id) { if (!ctx.state.filesMap.has(id) || !existsSync(id)) return null @@ -93,31 +61,11 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi throw new Error(`Test file "${id}" was not registered, so it cannot be updated using the API.`) return fs.writeFile(id, content, 'utf-8') }, - async saveSnapshotFile(id, content) { - checkFileAccess(id) - await fs.mkdir(dirname(id), { recursive: true }) - return fs.writeFile(id, content, 'utf-8') - }, - async removeSnapshotFile(id) { - checkFileAccess(id) - if (!existsSync(id)) - throw new Error(`Snapshot file "${id}" does not exist.`) - return fs.unlink(id) - }, - snapshotSaved(snapshot) { - ctx.snapshot.add(snapshot) - }, async rerun(files) { await ctx.rerunFiles(files) }, getConfig() { - return vitestOrWorkspace.config - }, - async getBrowserFileSourceMap(id) { - if (!('ctx' in vitestOrWorkspace)) - return undefined - const mod = vitestOrWorkspace.browser?.moduleGraph.getModuleById(id) - return mod?.transformResult?.map + return ctx.config }, async getTransformResult(id) { const result: TransformResultWithSource | null | undefined = await ctx.vitenode.transformRequest(id) @@ -137,45 +85,9 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi return ctx.updateSnapshot() return ctx.updateSnapshot([file.filepath]) }, - onCancel(reason) { - ctx.cancelCurrentRun(reason) - }, - debug(...args) { - ctx.logger.console.debug(...args) - }, - getCountOfFailedTests() { - return ctx.state.getCountOfFailedTests() - }, getUnhandledErrors() { return ctx.state.getUnhandledErrors() }, - - // TODO: have a separate websocket conection for private browser API - triggerCommand(command: string, testPath: string | undefined, payload: unknown[]) { - if (!('ctx' in vitestOrWorkspace) || !vitestOrWorkspace.browserProvider) - throw new Error('Commands are only available for browser tests.') - const commands = vitestOrWorkspace.config.browser?.commands - if (!commands || !commands[command]) - throw new Error(`Unknown command "${command}".`) - return commands[command]({ - testPath, - project: vitestOrWorkspace, - provider: vitestOrWorkspace.browserProvider, - }, ...payload) - }, - getBrowserFiles() { - if (!('ctx' in vitestOrWorkspace)) - throw new Error('`getBrowserTestFiles` is only available in the browser API') - return vitestOrWorkspace.browserState?.files ?? [] - }, - finishBrowserTests() { - if (!('ctx' in vitestOrWorkspace)) - throw new Error('`finishBrowserTests` is only available in the browser API') - return vitestOrWorkspace.browserState?.resolve() - }, - getProvidedContext() { - return 'ctx' in vitestOrWorkspace ? vitestOrWorkspace.getProvidedContext() : ({} as any) - }, async getTestFiles() { const spec = await ctx.globTestFiles() return spec.map(([project, file]) => [{ @@ -187,7 +99,7 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi { post: msg => ws.send(msg), on: fn => ws.on('message', fn), - eventNames: ['onUserConsoleLog', 'onFinished', 'onFinishedReportCoverage', 'onCollected', 'onCancel', 'onTaskUpdate'], + eventNames: ['onUserConsoleLog', 'onFinished', 'onFinishedReportCoverage', 'onCollected', 'onTaskUpdate'], serialize: (data: any) => stringify(data, stringifyReplace), deserialize: parse, onTimeoutError(functionName) { @@ -196,8 +108,6 @@ export function setup(vitestOrWorkspace: Vitest | WorkspaceProject, _server?: Vi }, ) - ctx.onCancel(reason => rpc.onCancel(reason)) - clients.set(ws, rpc) ws.on('close', () => { @@ -212,7 +122,7 @@ export class WebSocketReporter implements Reporter { constructor( public ctx: Vitest, public wss: WebSocketServer, - public clients: Map>, + public clients: Map, ) {} onCollected(files?: File[]) { diff --git a/packages/vitest/src/api/types.ts b/packages/vitest/src/api/types.ts index 273e5337f00b..309def07e464 100644 --- a/packages/vitest/src/api/types.ts +++ b/packages/vitest/src/api/types.ts @@ -1,5 +1,7 @@ import type { TransformResult } from 'vite' import type { CancelReason } from '@vitest/runner' +import type { BirpcReturn } from 'birpc' +import type { ViteNodeResolveId } from 'vite-node' import type { AfterSuiteRunMeta, File, ModuleGraphData, ProvidedContext, Reporter, ResolvedConfig, SnapshotResult, TaskResultPack, UserConsoleLog } from '../types' export interface TransformResultWithSource extends TransformResult { @@ -7,40 +9,60 @@ export interface TransformResultWithSource extends TransformResult { } export interface WebSocketHandlers { - onUnhandledError: (error: unknown, type: string) => Promise onCollected: (files?: File[]) => Promise onTaskUpdate: (packs: TaskResultPack[]) => void - onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void - onCancel: (reason: CancelReason) => void - getCountOfFailedTests: () => number - sendLog: (log: UserConsoleLog) => void getFiles: () => File[] getTestFiles: () => Promise<[{ name: string; root: string }, file: string][]> getPaths: () => string[] getConfig: () => ResolvedConfig - resolveSnapshotPath: (testPath: string) => string - resolveSnapshotRawPath: (testPath: string, rawPath: string) => string getModuleGraph: (id: string) => Promise - getBrowserFileSourceMap: (id: string) => Promise getTransformResult: (id: string) => Promise - readSnapshotFile: (id: string) => Promise readTestFile: (id: string) => Promise saveTestFile: (id: string, content: string) => Promise - saveSnapshotFile: (id: string, content: string) => Promise - removeSnapshotFile: (id: string) => Promise - snapshotSaved: (snapshot: SnapshotResult) => void rerun: (files: string[]) => Promise updateSnapshot: (file?: File) => Promise - getProvidedContext: () => ProvidedContext getUnhandledErrors: () => unknown[] +} +export interface WebSocketBrowserHandlers { + resolveSnapshotPath: (testPath: string) => string + resolveSnapshotRawPath: (testPath: string, rawPath: string) => string + onUnhandledError: (error: unknown, type: string) => Promise + onCollected: (files?: File[]) => Promise + onTaskUpdate: (packs: TaskResultPack[]) => void + onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void + onCancel: (reason: CancelReason) => void + getCountOfFailedTests: () => number + readSnapshotFile: (id: string) => Promise + saveSnapshotFile: (id: string, content: string) => Promise + removeSnapshotFile: (id: string) => Promise + sendLog: (log: UserConsoleLog) => void finishBrowserTests: () => void + snapshotSaved: (snapshot: SnapshotResult) => void getBrowserFiles: () => string[] debug: (...args: string[]) => void + resolveId: (id: string, importer?: string) => Promise triggerCommand: (command: string, testPath: string | undefined, payload: unknown[]) => Promise + queueMock: (id: string, importer: string, hasFactory: boolean) => Promise + queueUnmock: (id: string, importer: string) => Promise + resolveMock: (id: string, importer: string) => Promise<{ + type: 'factory' | 'redirect' | 'automock' + mockPath?: string | null + resolvedId: string + }> + invalidateMocks: () => void + getBrowserFileSourceMap: (id: string) => Promise + getProvidedContext: () => ProvidedContext } export interface WebSocketEvents extends Pick { - onCancel: (reason: CancelReason) => void onFinishedReportCoverage: () => void } + +export interface WebSocketBrowserEvents { + onCancel: (reason: CancelReason) => void + startMocking: (id: string) => Promise +} + +export type WebSocketRPC = BirpcReturn +export type WebSocketBrowserRPC = BirpcReturn diff --git a/packages/vitest/src/browser.ts b/packages/vitest/src/browser.ts index 3887e79d2d89..c167bc899854 100644 --- a/packages/vitest/src/browser.ts +++ b/packages/vitest/src/browser.ts @@ -1,3 +1,13 @@ export { startTests, processError } from '@vitest/runner' -export { setupCommonEnv, loadDiffConfig, loadSnapshotSerializers } from './runtime/setup-common' -export { takeCoverageInsideWorker, stopCoverageInsideWorker, getCoverageProvider, startCoverageInsideWorker } from './integrations/coverage' +export { + setupCommonEnv, + loadDiffConfig, + loadSnapshotSerializers, +} from './runtime/setup-common' +export { + takeCoverageInsideWorker, + stopCoverageInsideWorker, + getCoverageProvider, + startCoverageInsideWorker, +} from './integrations/coverage' +export * as Vitest from './index' diff --git a/packages/vitest/src/constants.ts b/packages/vitest/src/constants.ts index b71873451ea7..2b6c91243c4e 100644 --- a/packages/vitest/src/constants.ts +++ b/packages/vitest/src/constants.ts @@ -6,6 +6,7 @@ export const defaultInspectPort = 9229 export const EXIT_CODE_RESTART = 43 export const API_PATH = '/__vitest_api__' +export const BROWSER_API_PATH = '/__vitest_browser_api__' export const extraInlineDeps = [ /^(?!.*node_modules).*\.mjs$/, diff --git a/packages/vitest/src/integrations/browser/mocker.ts b/packages/vitest/src/integrations/browser/mocker.ts new file mode 100644 index 000000000000..23d2da272b4c --- /dev/null +++ b/packages/vitest/src/integrations/browser/mocker.ts @@ -0,0 +1,101 @@ +import { existsSync, readdirSync } from 'node:fs' +import { basename, dirname, extname, join, resolve } from 'pathe' +import { isNodeBuiltin } from 'vite-node/utils' +import type { WorkspaceProject } from '../../node/workspace' + +export class VitestBrowserServerMocker { + // string means it will read from __mocks__ folder + // undefined means there is a factory mock that will be called on the server + // null means it should be auto mocked + public mocks = new Map() + + // private because the typecheck fails on build if it's exposed + // due to a self reference + #project: WorkspaceProject + + constructor(project: WorkspaceProject) { + this.#project = project + } + + async mock(sessionId: string, rawId: string, importer: string, hasFactory: boolean) { + const { type, mockPath, resolvedId } = await this.resolveMock(rawId, importer, hasFactory) + + this.invalidateModuleById(resolvedId) + + if (type === 'factory') { + this.mocks.set(resolvedId, { sessionId, mock: undefined }) + return resolvedId + } + + this.mocks.set(resolvedId, { sessionId, mock: mockPath }) + + return resolvedId + } + + async unmock(rawId: string, importer: string) { + const { id } = await this.resolveId(rawId, importer) + + this.invalidateModuleById(id) + this.mocks.delete(id) + return id + } + + public async resolveMock(rawId: string, importer: string, hasFactory: boolean) { + const { id, fsPath, external } = await this.resolveId(rawId, importer) + + if (hasFactory) + return { type: 'factory' as const, resolvedId: id } + + const mockPath = this.resolveMockPath(fsPath, external) + + return { + type: mockPath === null ? 'automock' as const : 'redirect' as const, + mockPath, + resolvedId: id, + } + } + + public invalidateModuleById(id: string) { + const moduleGraph = this.#project.browser!.moduleGraph + const module = moduleGraph.getModuleById(id) + if (module) + moduleGraph.invalidateModule(module, new Set(), Date.now(), true) + } + + private async resolveId(rawId: string, importer: string) { + const resolved = await this.#project.browser!.pluginContainer.resolveId(rawId, importer, { + ssr: false, + }) + return this.#project.vitenode.resolveModule(rawId, resolved) + } + + 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.#project.config.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 + } +} diff --git a/packages/vitest/src/integrations/browser/server.ts b/packages/vitest/src/integrations/browser/server.ts index 3c6d8dba33da..c53889ca75bf 100644 --- a/packages/vitest/src/integrations/browser/server.ts +++ b/packages/vitest/src/integrations/browser/server.ts @@ -5,6 +5,8 @@ import { CoverageTransform } from '../../node/plugins/coverageTransform' import type { WorkspaceProject } from '../../node/workspace' import { MocksPlugin } from '../../node/plugins/mocks' import { resolveFsAllow } from '../../node/plugins/utils' +import { setupBrowserRpc } from '../../api/browser' +import { setup as setupUiRpc } from '../../api/setup' export async function createBrowserServer(project: WorkspaceProject, configFile: string | undefined) { const root = project.config.root @@ -21,12 +23,11 @@ export async function createBrowserServer(project: WorkspaceProject, configFile: // watch is handled by Vitest server: { hmr: false, - watch: { - ignored: ['**/**'], - }, + watch: null, }, plugins: [ ...project.options?.plugins || [], + MocksPlugin(), (await import('@vitest/browser')).default(project, '/'), CoverageTransform(project.ctx), { @@ -59,17 +60,19 @@ export async function createBrowserServer(project: WorkspaceProject, configFile: }, server: { watch: null, + preTransformRequests: false, }, } }, }, - MocksPlugin(), ], }) await server.listen() - ;(await import('../../api/setup')).setup(project, server) + setupBrowserRpc(project, server) + if (project.config.browser.ui) + setupUiRpc(project.ctx, server) return server } diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 64d5d2581304..1c19b313ba31 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -372,10 +372,14 @@ function createVitest(): VitestUtils { const _envBooleans = ['PROD', 'DEV', 'SSR'] - const getImporter = () => { - const stackTrace = createSimpleStackTrace({ stackTraceLimit: 4 }) - const importerStack = stackTrace.split('\n')[4] - const stack = parseSingleStack(importerStack) + const getImporter = (name: string) => { + const stackTrace = 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 = parseSingleStack(stackArray[importerStackIndex + 1]) return stack?.file || '' } @@ -491,7 +495,7 @@ function createVitest(): VitestUtils { mock(path: string | Promise, factory?: MockFactoryWithHelper) { if (typeof path !== 'string') throw new Error(`vi.mock() expects a string path, but received a ${typeof path}`) - const importer = getImporter() + const importer = getImporter('mock') _mocker.queueMock( path, importer, @@ -503,13 +507,13 @@ function createVitest(): VitestUtils { unmock(path: string | Promise) { if (typeof path !== 'string') throw new Error(`vi.unmock() expects a string path, but received a ${typeof path}`) - _mocker.queueUnmock(path, getImporter()) + _mocker.queueUnmock(path, getImporter('unmock')) }, doMock(path: string | Promise, factory?: MockFactoryWithHelper) { if (typeof path !== 'string') throw new Error(`vi.doMock() expects a string path, but received a ${typeof path}`) - const importer = getImporter() + const importer = getImporter('doMock') _mocker.queueMock( path, importer, @@ -521,19 +525,19 @@ function createVitest(): VitestUtils { doUnmock(path: string | Promise) { if (typeof path !== 'string') throw new Error(`vi.doUnmock() expects a string path, but received a ${typeof path}`) - _mocker.queueUnmock(path, getImporter()) + _mocker.queueUnmock(path, getImporter('doUnmock')) }, async importActual(path: string): Promise { return _mocker.importActual( path, - getImporter(), + getImporter('importActual'), _mocker.getMockContext().callstack, ) }, async importMock(path: string): Promise> { - return _mocker.importMock(path, getImporter()) + return _mocker.importMock(path, getImporter('importMock')) }, // this is typed in the interface so it's not necessary to type it here diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts index eb3803c25497..ed46e85a81b3 100644 --- a/packages/vitest/src/node/cli/cli-api.ts +++ b/packages/vitest/src/node/cli/cli-api.ts @@ -100,7 +100,7 @@ export async function startVitest( } catch (e) { process.exitCode = 1 - await ctx.logger.printError(e, { fullStack: true, type: 'Unhandled Error' }) + ctx.logger.printError(e, { fullStack: true, type: 'Unhandled Error' }) ctx.logger.error('\n\n') return ctx } diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 06e36399ecc8..25f4ccda83d0 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -341,9 +341,6 @@ export const cliOptionsConfig: VitestCLIOptions = { argument: '', subcommands: null, // don't support custom objects }, - slowHijackESM: { - description: 'Let Vitest use its own module resolution on the browser to enable APIs such as vi.mock and vi.spyOn. Visit [`browser.slowHijackESM`](https://vitest.dev/config/#browser-slowhijackesm) for more information (default: `false`)', - }, isolate: { description: 'Run every browser test file in isolation. To disable isolation, use `--browser.isolate=false` (default: `true`)', }, diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index e7937de8c323..ee5704bd4280 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -532,7 +532,6 @@ export function resolveConfig( resolved.browser ??= {} as any resolved.browser.enabled ??= false resolved.browser.headless ??= isCI - resolved.browser.slowHijackESM ??= false resolved.browser.isolate ??= true resolved.browser.ui ??= !isCI diff --git a/packages/vitest/src/node/plugins/mocks.ts b/packages/vitest/src/node/plugins/mocks.ts index 318beb864409..2f42d233e0c6 100644 --- a/packages/vitest/src/node/plugins/mocks.ts +++ b/packages/vitest/src/node/plugins/mocks.ts @@ -1,11 +1,14 @@ import type { Plugin } from 'vite' import { hoistMocks } from '../hoistMocks' +import { distDir } from '../../paths' export function MocksPlugin(): Plugin { return { name: 'vitest:mocks', enforce: 'post', transform(code, id) { + if (id.includes(distDir)) + return return hoistMocks(code, id, this.parse) }, } diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 178678ff7ef6..6f0d460d767d 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -23,6 +23,11 @@ export function createBrowserPool(ctx: Vitest): ProcessPool { const runTests = async (project: WorkspaceProject, files: string[]) => { ctx.state.clearFiles(project, files) + const mocker = project.browserMocker + mocker.mocks.forEach((_, id) => { + mocker.invalidateModuleById(id) + }) + mocker.mocks.clear() // TODO // let isCancelled = false diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 697c8b5474b6..6d76890c052d 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -14,6 +14,8 @@ import type { Typechecker } from '../typecheck/typechecker' import type { BrowserProvider } from '../types/browser' import { getBrowserProvider } from '../integrations/browser' import { deepMerge, nanoid } from '../utils/base' +import { VitestBrowserServerMocker } from '../integrations/browser/mocker' +import type { WebSocketBrowserRPC } from '../api/types' import { isBrowserEnabled, resolveConfig } from './config' import { WorkspaceVitestPlugin } from './plugins/workspace' import { createViteServer } from './vite' @@ -70,7 +72,15 @@ export class WorkspaceProject { typechecker?: Typechecker closingPromise: Promise | undefined + + // TODO: abstract browser related things and move to @vitest/browser browserProvider: BrowserProvider | undefined + browserMocker = new VitestBrowserServerMocker(this) + // TODO: I mean, we really need to abstract it + browserRpc = { + orchestrators: new Map(), + testers: new Map(), + } browserState: { files: string[] @@ -400,6 +410,8 @@ export class WorkspaceProject { }, browser: { ...this.ctx.config.browser, + indexScripts: [], + testerScripts: [], commands: {}, }, }, this.ctx.configOverride || {} as any) as ResolvedConfig diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/mocker.ts index 87b8a7f98c96..44bcb6a44c7b 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -105,7 +105,7 @@ export class VitestMocker { this.moduleCache.delete(mockId) } - private isAModuleDirectory(path: string) { + private isModuleDirectory(path: string) { return this.moduleDirectories.some(dir => path.includes(dir)) } @@ -150,7 +150,7 @@ export class VitestMocker { } // external is node_module or unresolved module // for example, some people mock "vscode" and don't have it installed - const external = (!isAbsolute(fsPath) || this.isAModuleDirectory(fsPath)) ? rawId : null + const external = (!isAbsolute(fsPath) || this.isModuleDirectory(fsPath)) ? rawId : null return { id, diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index edf1f2a5abaa..ae610c7fe387 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -71,15 +71,6 @@ export interface BrowserConfigOptions { */ api?: ApiConfig | number - /** - * Update ESM imports so they can be spied/stubbed with vi.spyOn. - * Enabled by default when running in browser. - * - * @default false - * @experimental - */ - slowHijackESM?: boolean - /** * Isolate test environment after each test * diff --git a/packages/ws-client/src/index.ts b/packages/ws-client/src/index.ts index 3c137fc3a051..acd641a7195e 100644 --- a/packages/ws-client/src/index.ts +++ b/packages/ws-client/src/index.ts @@ -4,7 +4,6 @@ import { parse, stringify } from 'flatted' // eslint-disable-next-line no-restricted-imports import type { WebSocketEvents, WebSocketHandlers } from 'vitest' -import type { CancelReason } from '@vitest/runner' import { StateManager } from '../../vitest/src/node/state' export * from '../../vitest/src/utils/tasks' @@ -80,15 +79,21 @@ export function createClient(url: string, options: VitestClientOptions = {}) { onFinishedReportCoverage() { handlers.onFinishedReportCoverage?.() }, - onCancel(reason: CancelReason) { - handlers.onCancel?.(reason) - }, } const birpcHandlers: BirpcOptions = { post: msg => ctx.ws.send(msg), on: fn => (onMessage = fn), - serialize: stringify, + serialize: e => stringify(e, (_, v) => { + if (v instanceof Error) { + return { + name: v.name, + message: v.message, + stack: v.stack, + } + } + return v + }), deserialize: parse, onTimeoutError(functionName) { throw new Error(`[vitest-ws-client]: Timeout calling "${functionName}"`) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e77440466e4..86bf5a7e6c7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,6 +478,12 @@ importers: '@wdio/protocols': specifier: ^8.32.0 version: 8.32.0 + birpc: + specifier: 0.2.17 + version: 0.2.17 + flatted: + specifier: ^3.3.1 + version: 3.3.1 periscopic: specifier: ^4.0.2 version: 4.0.2 diff --git a/test/browser/fixtures/mocking/automocked.test.ts b/test/browser/fixtures/mocking/automocked.test.ts new file mode 100644 index 000000000000..1ce8f24c6219 --- /dev/null +++ b/test/browser/fixtures/mocking/automocked.test.ts @@ -0,0 +1,9 @@ +import { expect, test, vi } from 'vitest' +import { calculator } from './src/calculator' + +vi.mock('./src/calculator') + +test('adds', () => { + vi.mocked(calculator).mockReturnValue(4) + expect(calculator('plus', 1, 2)).toBe(4) +}) diff --git a/test/browser/fixtures/mocking/import-actual-in-mock.test.ts b/test/browser/fixtures/mocking/import-actual-in-mock.test.ts new file mode 100644 index 000000000000..ce74f65daa9f --- /dev/null +++ b/test/browser/fixtures/mocking/import-actual-in-mock.test.ts @@ -0,0 +1,15 @@ +import { expect, test, vi } from 'vitest' +import { calculator, mocked } from './src/mocks_factory' + +vi.mock(import('./src/mocks_factory'), async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + mocked: 'mocked!', + } +}) + +test('actual is overriding import', () => { + expect(mocked).toBe('mocked!') + expect(calculator('plus', 1, 2)).toBe(3) +}) diff --git a/test/browser/fixtures/mocking/import-actual-query.test.ts b/test/browser/fixtures/mocking/import-actual-query.test.ts new file mode 100644 index 000000000000..dce6dfe29aaa --- /dev/null +++ b/test/browser/fixtures/mocking/import-actual-query.test.ts @@ -0,0 +1,19 @@ +import { expect, test, vi } from 'vitest' +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!"'), + } +}) + +test('factory with a query', () => { + expect(rawFactory).toBe(` +export function calculator(_action: string, _a: number, _b: number) { + return _a + _b +} + +export const mocked = "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 new file mode 100644 index 000000000000..a6a326334bee --- /dev/null +++ b/test/browser/fixtures/mocking/import-mock.test.ts @@ -0,0 +1,59 @@ +import { test,vi, expect } from 'vitest' + +vi.mock(import('./src/mocks_factory'), () => { + return { + calculator: () => 55, + mocked: true, + } +}) + +test('all mocked are valid', async () => { + const example = await vi.importMock('./src/example') + + // creates a new mocked function with no formal arguments. + expect(example.square.name).toEqual('square') + expect(example.square.length).toEqual(0) + + // async functions get the same treatment as standard synchronous functions. + expect(example.asyncSquare.name).toEqual('asyncSquare') + expect(example.asyncSquare.length).toEqual(0) + + // creates a new class with the same interface, member functions and properties are mocked. + expect(example.someClasses.constructor.name).toEqual('Bar') + expect(example.someClasses.foo.name).toEqual('foo') + expect(vi.isMockFunction(example.someClasses.foo)).toBe(true) + expect(example.someClasses.array.length).toEqual(0) + + // creates a deeply cloned version of the original object. + expect(example.object).toEqual({ + baz: 'foo', + bar: { + fiz: 1, + buzz: [], + }, + }) + + // creates a new empty array, ignoring the original array. + expect(example.array.length).toEqual(0) + + // creates a new property with the same primitive value as the original property. + expect(example.number).toEqual(123) + expect(example.string).toEqual('baz') + expect(example.boolean).toEqual(true) + expect(example.symbol).toEqual(Symbol.for('a.b.c')) +}) + +test('import from a factory if defined', async () => { + const { calculator, mocked } = await vi.importMock( + './src/mocks_factory' + ) + expect(calculator('add', 1, 2)).toBe(55) + expect(mocked).toBe(true) +}) + +test('imports from __mocks__', async () => { + const { calculator } = await vi.importMock( + './src/mocks_calculator' + ) + expect(calculator('plus', 1, 2)).toBe(42) +}) diff --git a/test/browser/fixtures/mocking/mocked-__mocks__.test.ts b/test/browser/fixtures/mocking/mocked-__mocks__.test.ts new file mode 100644 index 000000000000..03dab89bf490 --- /dev/null +++ b/test/browser/fixtures/mocking/mocked-__mocks__.test.ts @@ -0,0 +1,8 @@ +import { expect, test, vi } from 'vitest' +import { calculator } from './src/mocks_calculator' + +vi.mock(import('./src/mocks_calculator')) + +test('adds', () => { + expect(calculator('plus', 1, 2)).toBe(42) +}) diff --git a/test/browser/fixtures/mocking/mocked-do-mock-factory.test.ts b/test/browser/fixtures/mocking/mocked-do-mock-factory.test.ts new file mode 100644 index 000000000000..f05b42af6582 --- /dev/null +++ b/test/browser/fixtures/mocking/mocked-do-mock-factory.test.ts @@ -0,0 +1,14 @@ +import { expect, test, vi } from 'vitest' + +test('adds', async () => { + vi.doMock(import('./src/mocks_factory'), () => { + return { + calculator: () => 1166, + mocked: true, + } + }) + + const { mocked, calculator } = await import('./src/mocks_factory') + expect(mocked).toBe(true) + expect(calculator('plus', 1, 2)).toBe(1166) +}) diff --git a/test/browser/fixtures/mocking/mocked-factory-hoisted.test.ts b/test/browser/fixtures/mocking/mocked-factory-hoisted.test.ts new file mode 100644 index 000000000000..35920f8d4a40 --- /dev/null +++ b/test/browser/fixtures/mocking/mocked-factory-hoisted.test.ts @@ -0,0 +1,15 @@ +import { expect, test, vi } from 'vitest' +import { calculator } from './src/mocks_factory' + +const fn = vi.hoisted(() => vi.fn()) + +vi.mock(import('./src/mocks_factory'), () => { + return { + calculator: fn, + } +}) + +test('adds', () => { + fn.mockReturnValue(448) + expect(calculator('plus', 1, 2)).toBe(448) +}) diff --git a/test/browser/fixtures/mocking/mocked-factory.test.ts b/test/browser/fixtures/mocking/mocked-factory.test.ts new file mode 100644 index 000000000000..d168ba6df2e4 --- /dev/null +++ b/test/browser/fixtures/mocking/mocked-factory.test.ts @@ -0,0 +1,14 @@ +import { expect, test, vi } from 'vitest' +import { calculator, mocked } from './src/mocks_factory' + +vi.mock(import('./src/mocks_factory'), () => { + return { + calculator: () => 1166, + mocked: true, + } +}) + +test('adds', () => { + expect(mocked).toBe(true) + expect(calculator('plus', 1, 2)).toBe(1166) +}) diff --git a/test/browser/fixtures/mocking/mocked-nested.test.ts b/test/browser/fixtures/mocking/mocked-nested.test.ts new file mode 100644 index 000000000000..2c4ab404d775 --- /dev/null +++ b/test/browser/fixtures/mocking/mocked-nested.test.ts @@ -0,0 +1,23 @@ +import { expect, test, vi } from 'vitest' +import { parent } from './src/nested_parent' + +const child = vi.hoisted(() => vi.fn()) + +vi.mock(import('./src/nested_child'), () => { + return { + child, + } +}) + +test('adds', () => { + child.mockReturnValue(42) + expect(parent()).toBe(42) +}) + +test('actual', async () => { + const { child } = await vi.importActual< + typeof import('./src/nested_child') + >('./src/nested_child') + + expect(child()).toBe(true) +}) diff --git a/test/browser/fixtures/mocking/not-mocked-nested.test.ts b/test/browser/fixtures/mocking/not-mocked-nested.test.ts new file mode 100644 index 000000000000..17907f1949e0 --- /dev/null +++ b/test/browser/fixtures/mocking/not-mocked-nested.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { parent } from './src/nested_parent' + +test('adds', () => { + expect(parent()).toBe(true) +}) diff --git a/test/browser/fixtures/mocking/not-mocked.test.ts b/test/browser/fixtures/mocking/not-mocked.test.ts new file mode 100644 index 000000000000..d1648369c68d --- /dev/null +++ b/test/browser/fixtures/mocking/not-mocked.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { calculator } from './src/calculator' + +test('adds', () => { + expect(calculator('plus', 1, 2)).toBe(3) +}) diff --git a/test/browser/fixtures/mocking/src/__mocks__/mocks_calculator.ts b/test/browser/fixtures/mocking/src/__mocks__/mocks_calculator.ts new file mode 100644 index 000000000000..f6c8755a6297 --- /dev/null +++ b/test/browser/fixtures/mocking/src/__mocks__/mocks_calculator.ts @@ -0,0 +1,3 @@ +export function calculator() { + return 42 +} diff --git a/test/browser/fixtures/mocking/src/actions.ts b/test/browser/fixtures/mocking/src/actions.ts new file mode 100644 index 000000000000..55ae85133596 --- /dev/null +++ b/test/browser/fixtures/mocking/src/actions.ts @@ -0,0 +1,3 @@ +export function plus(a: number, b: number) { + return a + b +} diff --git a/test/browser/fixtures/mocking/src/calculator.ts b/test/browser/fixtures/mocking/src/calculator.ts new file mode 100644 index 000000000000..9d9d67cfe878 --- /dev/null +++ b/test/browser/fixtures/mocking/src/calculator.ts @@ -0,0 +1,8 @@ +import { plus } from './actions' + +export function calculator(operation: 'plus', a: number, b: number) { + if (operation === 'plus') + return plus(a, b) + + throw new Error('unknown operation') +} diff --git a/test/browser/fixtures/mocking/src/example.ts b/test/browser/fixtures/mocking/src/example.ts new file mode 100644 index 000000000000..eef15ace37c4 --- /dev/null +++ b/test/browser/fixtures/mocking/src/example.ts @@ -0,0 +1,28 @@ +export function square(a: number, b: number) { + return a * b +} +export async function asyncSquare(a: number, b: number) { + const result = (await a) * b + return result +} +export const someClasses = new (class Bar { + public array: number[] + constructor() { + this.array = [1, 2, 3] + } + + foo() {} +})() +export const object = { + baz: 'foo', + bar: { + fiz: 1, + buzz: [1, 2, 3], + }, +} +export const array = [1, 2, 3] +export const number = 123 +export const string = 'baz' +export const boolean = true +export const symbol = Symbol.for('a.b.c') +export default 'a default' diff --git a/test/browser/fixtures/mocking/src/mocks_calculator.ts b/test/browser/fixtures/mocking/src/mocks_calculator.ts new file mode 100644 index 000000000000..9d9d67cfe878 --- /dev/null +++ b/test/browser/fixtures/mocking/src/mocks_calculator.ts @@ -0,0 +1,8 @@ +import { plus } from './actions' + +export function calculator(operation: 'plus', a: number, b: number) { + if (operation === 'plus') + return plus(a, b) + + throw new Error('unknown operation') +} diff --git a/test/browser/fixtures/mocking/src/mocks_factory.ts b/test/browser/fixtures/mocking/src/mocks_factory.ts new file mode 100644 index 000000000000..4282c2f5641d --- /dev/null +++ b/test/browser/fixtures/mocking/src/mocks_factory.ts @@ -0,0 +1,5 @@ +export function calculator(_action: string, _a: number, _b: number) { + return _a + _b +} + +export const mocked = false diff --git a/test/browser/fixtures/mocking/src/nested_child.ts b/test/browser/fixtures/mocking/src/nested_child.ts new file mode 100644 index 000000000000..486f89075b35 --- /dev/null +++ b/test/browser/fixtures/mocking/src/nested_child.ts @@ -0,0 +1,3 @@ +export function child() { + return true +} \ No newline at end of file diff --git a/test/browser/fixtures/mocking/src/nested_parent.ts b/test/browser/fixtures/mocking/src/nested_parent.ts new file mode 100644 index 000000000000..91b86355740d --- /dev/null +++ b/test/browser/fixtures/mocking/src/nested_parent.ts @@ -0,0 +1,5 @@ +import { child } from './nested_child' + +export function parent() { + return child() +} diff --git a/test/browser/fixtures/mocking/vitest.config.ts b/test/browser/fixtures/mocking/vitest.config.ts new file mode 100644 index 000000000000..43d698a3716e --- /dev/null +++ b/test/browser/fixtures/mocking/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' + +const provider = process.env.PROVIDER || 'playwright' +const name = + process.env.BROWSER || (provider === 'playwright' ? 'chromium' : 'chrome') + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider, + name, + headless: true, + fileParallelism: false, + }, + }, +}) diff --git a/test/browser/package.json b/test/browser/package.json index 60d89d261455..f86105b99beb 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -9,6 +9,7 @@ "test:playwright": "PROVIDER=playwright pnpm run test:unit", "test:safaridriver": "PROVIDER=webdriverio BROWSER=safari pnpm run test:unit", "test-fixtures": "vitest", + "test-mocking": "vitest --root ./fixtures/mocking", "coverage": "vitest --coverage.enabled --coverage.provider=istanbul --browser.headless=yes" }, "devDependencies": { diff --git a/test/browser/specs/benchmark.test.ts b/test/browser/specs/benchmark.test.ts index 9f3659e35030..ac5cec0f7959 100644 --- a/test/browser/specs/benchmark.test.ts +++ b/test/browser/specs/benchmark.test.ts @@ -3,6 +3,7 @@ import { runVitest } from '../../test-utils' test('benchmark', async () => { const result = await runVitest({ root: 'fixtures/benchmark' }, [], 'benchmark') + expect(result.stderr).toBe('') expect(result.stdout).toContain('✓ basic.bench.ts > suite-a') expect(result.exitCode).toBe(0) }) diff --git a/test/browser/specs/mocking.test.ts b/test/browser/specs/mocking.test.ts new file mode 100644 index 000000000000..650a3c23ab96 --- /dev/null +++ b/test/browser/specs/mocking.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest' +import { runVitest } from '../../test-utils' + +test.each([true, false])('mocking works correctly - isolated %s', async (isolate) => { + const result = await runVitest({ + root: 'fixtures/mocking', + isolate, + }) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('automocked.test.ts') + expect(result.stdout).toContain('mocked-__mocks__.test.ts') + expect(result.stdout).toContain('mocked-factory.test.ts') + expect(result.stdout).toContain('mocked-factory-hoisted.test.ts') + expect(result.stdout).toContain('not-mocked.test.ts') + expect(result.stdout).toContain('mocked-nested.test.ts') + expect(result.stdout).toContain('not-mocked-nested.test.ts') + expect(result.stdout).toContain('import-actual-in-mock.test.ts') + expect(result.stdout).toContain('import-actual-query.test.ts') + expect(result.stdout).toContain('import-mock.test.ts') + expect(result.stdout).toContain('mocked-do-mock-factory.test.ts') + expect(result.exitCode).toBe(0) +}) diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 4f2278040d10..4db0b1111454 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -23,8 +23,8 @@ describe('running browser tests', async () => { console.error(stderr) }) - expect(browserResultJson.testResults).toHaveLength(16) - expect(passedTests).toHaveLength(14) + expect(browserResultJson.testResults).toHaveLength(15) + expect(passedTests).toHaveLength(13) expect(failedTests).toHaveLength(2) expect(stderr).not.toContain('has been externalized for browser compatibility') diff --git a/test/browser/src/__mocks__/_calculator.ts b/test/browser/src/__mocks__/_calculator.ts new file mode 100644 index 000000000000..94cde342d7e2 --- /dev/null +++ b/test/browser/src/__mocks__/_calculator.ts @@ -0,0 +1,3 @@ +export function calculator() { + return 4 +} diff --git a/test/browser/test/cjs-lib.test.ts b/test/browser/test/cjs-lib.test.ts index a28b593f7e4f..b2e09cd64b13 100644 --- a/test/browser/test/cjs-lib.test.ts +++ b/test/browser/test/cjs-lib.test.ts @@ -9,6 +9,13 @@ test('cjs namespace import', () => { object: { h: 'h', }, + default: { + a: 'a', + b: 'b', + object: { + h: 'h', + }, + }, }) }) @@ -17,5 +24,11 @@ test('cjs named import', () => { }) test('cjs default import not supported when slowHijackESM', () => { - expect(cjsDefault).toBeUndefined() + expect(cjsDefault).toEqual({ + a: 'a', + b: 'b', + object: { + h: 'h', + }, + }) }) diff --git a/test/browser/test/mocked.test.ts b/test/browser/test/mocked.test.ts deleted file mode 100644 index 34bbdc977993..000000000000 --- a/test/browser/test/mocked.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test, vi } from 'vitest' -import * as actions from '../src/actions' -import { calculator } from '../src/calculator' -import * as calculatorModule from '../src/calculator' - -test('spyOn works on ESM', () => { - vi.spyOn(actions, 'plus').mockReturnValue(30) - expect(calculator('plus', 1, 2)).toBe(30) - vi.mocked(actions.plus).mockRestore() - expect(calculator('plus', 1, 2)).toBe(3) -}) - -test('has module name', () => { - expect(String(actions)).toBe('[object Module]') - expect(actions[Symbol.toStringTag]).toBe('Module') -}) - -test('exports are correct', () => { - expect(Object.keys(actions)).toEqual(['plus']) - expect(Object.keys(calculatorModule)).toEqual(['calculator']) - expect(calculatorModule.calculator).toBe(calculator) -}) - -test('imports are still the same', async () => { - // @ts-expect-error typescript resolution - await expect(import('../src/calculator')).resolves.toBe(calculatorModule) - // @ts-expect-error typescript resolution - await expect(import(`../src/calculator`)).resolves.toBe(calculatorModule) -}) diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index 2308d6c4cee0..950f229e07d6 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -33,7 +33,6 @@ export default defineConfig({ headless: false, provider, isolate: false, - slowHijackESM: true, testerScripts: [ { content: 'globalThis.__injected = []', diff --git a/test/core/test/browserAutomocker.test.ts b/test/core/test/browserAutomocker.test.ts new file mode 100644 index 000000000000..b48728f39fd2 --- /dev/null +++ b/test/core/test/browserAutomocker.test.ts @@ -0,0 +1,340 @@ +import { automockModule } from '@vitest/browser/src/node/automocker.js' +import { parseAst } from 'vite' +import { expect, it } from 'vitest' + +function automock(code: string) { + return automockModule(code, parseAst).toString() +} + +it('correctly parses function declaration', () => { + expect(automock(` +export function test() {} + `)).toMatchInlineSnapshot(` + " + function test() {} + + const __vitest_es_current_module__ = { + __esModule: true, + ["test"]: test, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["test"] + export { + __vitest_mocked_0__ as test, + } + " + `) +}) + +it('correctly parses class declaration', () => { + expect(automock(` +export class Test {} + `)).toMatchInlineSnapshot(` + " + class Test {} + + const __vitest_es_current_module__ = { + __esModule: true, + ["Test"]: Test, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["Test"] + export { + __vitest_mocked_0__ as Test, + } + " + `) +}) + +it('correctly parses default export', () => { + expect(automock(` +export default class Test {} + `)).toMatchInlineSnapshot(` + " + const __vitest_default = class Test {} + + const __vitest_es_current_module__ = { + __esModule: true, + ["__vitest_default"]: __vitest_default, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] + export { + __vitest_mocked_0__ as default, + } + " + `) + + expect(automock(` +export default function test() {} + `)).toMatchInlineSnapshot(` + " + const __vitest_default = function test() {} + + const __vitest_es_current_module__ = { + __esModule: true, + ["__vitest_default"]: __vitest_default, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] + export { + __vitest_mocked_0__ as default, + } + " + `) + + expect(automock(` +export default someVariable + `)).toMatchInlineSnapshot(` + " + const __vitest_default = someVariable + + const __vitest_es_current_module__ = { + __esModule: true, + ["__vitest_default"]: __vitest_default, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] + export { + __vitest_mocked_0__ as default, + } + " + `) + + expect(automock(` +export default 'test' + `)).toMatchInlineSnapshot(` + " + const __vitest_default = 'test' + + const __vitest_es_current_module__ = { + __esModule: true, + ["__vitest_default"]: __vitest_default, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] + export { + __vitest_mocked_0__ as default, + } + " + `) + + expect(automock(` +export default null + `)).toMatchInlineSnapshot(` + " + const __vitest_default = null + + const __vitest_es_current_module__ = { + __esModule: true, + ["__vitest_default"]: __vitest_default, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] + export { + __vitest_mocked_0__ as default, + } + " + `) + + expect(automock(` +const test = '123' +export default test + `)).toMatchInlineSnapshot(` + " + const test = '123' + const __vitest_default = test + + const __vitest_es_current_module__ = { + __esModule: true, + ["__vitest_default"]: __vitest_default, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["__vitest_default"] + export { + __vitest_mocked_0__ as default, + } + " + `) +}) + +it('correctly parses const export', () => { + expect(automock(` +export const test = 'test' +export const test2 = () => {} +export const test3 = function test4() {} + `)).toMatchInlineSnapshot(` + " + const test = 'test' + const test2 = () => {} + const test3 = function test4() {} + + const __vitest_es_current_module__ = { + __esModule: true, + ["test"]: test, + ["test2"]: test2, + ["test3"]: test3, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["test"] + const __vitest_mocked_1__ = __vitest_mocked_module__["test2"] + const __vitest_mocked_2__ = __vitest_mocked_module__["test3"] + export { + __vitest_mocked_0__ as test, + __vitest_mocked_1__ as test2, + __vitest_mocked_2__ as test3, + } + " + `) +}) + +it('correctly parses const array pattern', () => { + expect(automock(` +export const [test, ...rest] = [] +export const [...rest2] = [] + `)).toMatchInlineSnapshot(` + " + const [test, ...rest] = [] + const [...rest2] = [] + + const __vitest_es_current_module__ = { + __esModule: true, + ["test"]: test, + ["rest"]: rest, + ["rest2"]: rest2, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["test"] + const __vitest_mocked_1__ = __vitest_mocked_module__["rest"] + const __vitest_mocked_2__ = __vitest_mocked_module__["rest2"] + export { + __vitest_mocked_0__ as test, + __vitest_mocked_1__ as rest, + __vitest_mocked_2__ as rest2, + } + " + `) +}) + +it('correctly parses several declarations', () => { + expect(automock(` +export const test = 2, test2 = 3, test4 = () => {}, test5 = function() {}; + `)).toMatchInlineSnapshot(` + " + const test = 2, test2 = 3, test4 = () => {}, test5 = function() {}; + + const __vitest_es_current_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_0__ = __vitest_mocked_module__["test"] + const __vitest_mocked_1__ = __vitest_mocked_module__["test2"] + const __vitest_mocked_2__ = __vitest_mocked_module__["test4"] + const __vitest_mocked_3__ = __vitest_mocked_module__["test5"] + export { + __vitest_mocked_0__ as test, + __vitest_mocked_1__ as test2, + __vitest_mocked_2__ as test4, + __vitest_mocked_3__ as test5, + } + " + `) +}) + +it('correctly parses object pattern', () => { + expect(automock(` +export const { test, ...rest } = {} +export const { test: alias } = {} +export const { ...rest2 } = {} + `)).toMatchInlineSnapshot(` + " + const { test, ...rest } = {} + const { test: alias } = {} + const { ...rest2 } = {} + + const __vitest_es_current_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_0__ = __vitest_mocked_module__["test"] + const __vitest_mocked_1__ = __vitest_mocked_module__["rest"] + const __vitest_mocked_2__ = __vitest_mocked_module__["alias"] + const __vitest_mocked_3__ = __vitest_mocked_module__["rest2"] + export { + __vitest_mocked_0__ as test, + __vitest_mocked_1__ as rest, + __vitest_mocked_2__ as alias, + __vitest_mocked_3__ as rest2, + } + " + `) +}) + +it('correctly parses export specifiers', () => { + expect(automock(` + export const test = '1' + export { test as "test3", test as test4 } + `)).toMatchInlineSnapshot(` + " + const test = '1' + + + const __vitest_es_current_module__ = { + __esModule: true, + ["test"]: test, + ["test"]: test, + ["test"]: test, + } + const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__) + const __vitest_mocked_0__ = __vitest_mocked_module__["test"] + const __vitest_mocked_1__ = __vitest_mocked_module__["test"] + const __vitest_mocked_2__ = __vitest_mocked_module__["test"] + export { + __vitest_mocked_0__ as test, + __vitest_mocked_1__ as "test3", + __vitest_mocked_2__ as test4, + } + " + `) +}) + +it('correctly parses exports from sources', () => { + expect(automock(` +export { test, test as test3, name as "name3" } from './module' +import { testing as name4 } from './another-module' +export { testing as name4 } from './another-module' + `)).toMatchInlineSnapshot(` + " + import { test as __vitest_imported_0__, test as __vitest_imported_1__, name as __vitest_imported_2__ } from './module' + import { testing as name4 } from './another-module' + import { testing as __vitest_imported_3__ } from './another-module' + + const __vitest_es_current_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_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__"] + const __vitest_mocked_3__ = __vitest_mocked_module__["__vitest_imported_3__"] + export { + __vitest_mocked_0__ as test, + __vitest_mocked_1__ as test3, + __vitest_mocked_2__ as "name3", + __vitest_mocked_3__ as name4, + } + " + `) +}) diff --git a/test/core/test/injector-esm.test.ts b/test/core/test/injector-esm.test.ts index c59897fcfe37..6bf1c6217ab1 100644 --- a/test/core/test/injector-esm.test.ts +++ b/test/core/test/injector-esm.test.ts @@ -1,1019 +1,18 @@ import { parseAst } from 'rollup/parseAst' import { expect, test } from 'vitest' -import { transformWithEsbuild } from 'vite' -import { injectVitestModule } from '../../../packages/browser/src/node/esmInjector' +import { injectDynamicImport } from '../../../packages/browser/src/node/esmInjector' function parse(code: string, options: any) { return parseAst(code, options) } function injectSimpleCode(code: string) { - return injectVitestModule(code, '/test.js', parse)?.code + return injectDynamicImport(code, '/test.js', parse)?.code } -test('default import', async () => { - expect( - injectSimpleCode('import foo from \'vue\';console.log(foo.bar)'), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - console.log(__vi_esm_0__.default.bar) - export { __vi_inject__ }" - `) -}) - -test('named import', async () => { - expect( - injectSimpleCode( - 'import { ref } from \'vue\';function foo() { return ref(0) }', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - function foo() { return __vi_esm_0__.ref(0) } - export { __vi_inject__ }" - `) -}) - -test('namespace import', async () => { - expect( - injectSimpleCode( - 'import * as vue from \'vue\';function foo() { return vue.ref(0) }', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - function foo() { return __vi_esm_0__.ref(0) } - export { __vi_inject__ }" - `) -}) - -test('export function declaration', async () => { - expect(injectSimpleCode('export function foo() {}')) - .toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - function foo() {} - Object.defineProperty(__vi_inject__, "foo", { enumerable: true, configurable: true, get(){ return foo }}); - export { __vi_inject__ }" - `) -}) - -test('export class declaration', async () => { - expect(await injectSimpleCode('export class foo {}')) - .toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - class foo {} - Object.defineProperty(__vi_inject__, "foo", { enumerable: true, configurable: true, get(){ return foo }}); - export { __vi_inject__ }" - `) -}) - -test('export var declaration', async () => { - expect(await injectSimpleCode('export const a = 1, b = 2')) - .toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - const a = 1, b = 2 - Object.defineProperty(__vi_inject__, "a", { enumerable: true, configurable: true, get(){ return a }}); - Object.defineProperty(__vi_inject__, "b", { enumerable: true, configurable: true, get(){ return b }}); - export { __vi_inject__ }" - `) -}) - -test('export named', async () => { - expect( - injectSimpleCode('const a = 1, b = 2; export { a, b as c }'), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - const a = 1, b = 2; - Object.defineProperty(__vi_inject__, "a", { enumerable: true, configurable: true, get(){ return a }}); - Object.defineProperty(__vi_inject__, "c", { enumerable: true, configurable: true, get(){ return b }}); - export { __vi_inject__ }" - `) -}) - -test('export named from', async () => { - expect( - injectSimpleCode('export { ref, computed as c } from \'vue\''), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - const { __vi_inject__: __vi_esm_0__ } = await import("vue"); - - Object.defineProperty(__vi_inject__, "ref", { enumerable: true, configurable: true, get(){ return __vi_esm_0__.ref }}); - Object.defineProperty(__vi_inject__, "c", { enumerable: true, configurable: true, get(){ return __vi_esm_0__.computed }}); - export { __vi_inject__ }" - `) -}) - -test('named exports of imported binding', async () => { - expect( - injectSimpleCode( - 'import {createApp} from \'vue\';export {createApp}', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - - Object.defineProperty(__vi_inject__, "createApp", { enumerable: true, configurable: true, get(){ return __vi_esm_0__.createApp }}); - export { __vi_inject__ }" - `) -}) - -test('export * from', async () => { - expect( - injectSimpleCode( - 'export * from \'vue\'\n' + 'export * from \'react\'', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - const { __vi_inject__: __vi_esm_0__ } = await import("vue"); - __vitest_browser_runner__.exportAll(__vi_inject__, __vi_esm_0__); - const { __vi_inject__: __vi_esm_1__ } = await import("react"); - __vitest_browser_runner__.exportAll(__vi_inject__, __vi_esm_1__); - - - export { __vi_inject__ }" - `) -}) - -test('export * as from', async () => { - expect(injectSimpleCode('export * as foo from \'vue\'')) - .toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - const { __vi_inject__: __vi_esm_0__ } = await import("vue"); - - Object.defineProperty(__vi_inject__, "foo", { enumerable: true, configurable: true, get(){ return __vi_esm_0__ }}); - export { __vi_inject__ }" - `) -}) - -test('export default', async () => { - expect( - injectSimpleCode('export default {}'), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - __vi_inject__.default = {} - export default { __vi_inject__: __vi_inject__.default }; - - export { __vi_inject__ }" - `) -}) - -test('export then import minified', async () => { - expect( - injectSimpleCode( - 'export * from \'vue\';import {createApp} from \'vue\';', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - const { __vi_inject__: __vi_esm_1__ } = await import("vue"); - __vitest_browser_runner__.exportAll(__vi_inject__, __vi_esm_1__); - - export { __vi_inject__ }" - `) -}) - -test('hoist import to top', async () => { - expect( - injectSimpleCode( - 'path.resolve(\'server.js\');import path from \'node:path\';', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'node:path' - __vi_esm_0__.default.resolve('server.js'); - export { __vi_inject__ }" - `) -}) - -// test('import.meta', async () => { -// expect( -// injectSimpleCode('console.log(import.meta.url)'), -// ).toMatchInlineSnapshot('"console.log(__vite_ssr_import_meta__.url)"') -// }) - test('dynamic import', async () => { const result = injectSimpleCode( 'export const i = () => import(\'./foo\')', ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - const i = () => __vitest_browser_runner__.wrapModule(import('./foo')) - export { __vi_inject__ }" - `) -}) - -test('do not rewrite method definition', async () => { - const result = injectSimpleCode( - 'import { fn } from \'vue\';class A { fn() { fn() } }', - ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - class A { fn() { __vi_esm_0__.fn() } } - export { __vi_inject__ }" - `) -}) - -test('do not rewrite when variable is in scope', async () => { - const result = injectSimpleCode( - 'import { fn } from \'vue\';function A(){ const fn = () => {}; return { fn }; }', - ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - function A(){ const fn = () => {}; return { fn }; } - export { __vi_inject__ }" - `) -}) - -// #5472 -test('do not rewrite when variable is in scope with object destructuring', async () => { - const result = injectSimpleCode( - 'import { fn } from \'vue\';function A(){ let {fn, test} = {fn: \'foo\', test: \'bar\'}; return { fn }; }', - ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - function A(){ let {fn, test} = {fn: 'foo', test: 'bar'}; return { fn }; } - export { __vi_inject__ }" - `) -}) - -// #5472 -test('do not rewrite when variable is in scope with array destructuring', async () => { - const result = injectSimpleCode( - 'import { fn } from \'vue\';function A(){ let [fn, test] = [\'foo\', \'bar\']; return { fn }; }', - ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - function A(){ let [fn, test] = ['foo', 'bar']; return { fn }; } - export { __vi_inject__ }" - `) -}) - -// #5727 -test('rewrite variable in string interpolation in function nested arguments', async () => { - const result = injectSimpleCode( - // eslint-disable-next-line no-template-curly-in-string - 'import { fn } from \'vue\';function A({foo = `test${fn}`} = {}){ return {}; }', - ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - function A({foo = \`test\${__vi_esm_0__.fn}\`} = {}){ return {}; } - export { __vi_inject__ }" - `) -}) - -// #6520 -test('rewrite variables in default value of destructuring params', async () => { - const result = injectSimpleCode( - 'import { fn } from \'vue\';function A({foo = fn}){ return {}; }', - ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - function A({foo = __vi_esm_0__.fn}){ return {}; } - export { __vi_inject__ }" - `) -}) - -test('do not rewrite when function declaration is in scope', async () => { - const result = injectSimpleCode( - 'import { fn } from \'vue\';function A(){ function fn() {}; return { fn }; }', - ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - function A(){ function fn() {}; return { fn }; } - export { __vi_inject__ }" - `) -}) - -test('do not rewrite catch clause', async () => { - const result = injectSimpleCode( - 'import {error} from \'./dependency\';try {} catch(error) {}', - ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from './dependency' - try {} catch(error) {} - export { __vi_inject__ }" - `) -}) - -// #2221 -test('should declare variable for imported super class', async () => { - expect( - injectSimpleCode( - 'import { Foo } from \'./dependency\';' + 'class A extends Foo {}', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from './dependency' - const Foo = __vi_esm_0__.Foo; - class A extends Foo {} - export { __vi_inject__ }" - `) - - // exported classes: should prepend the declaration at root level, before the - // first class that uses the binding - expect( - injectSimpleCode( - 'import { Foo } from \'./dependency\';' - + 'export default class A extends Foo {}\n' - + 'export class B extends Foo {}', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from './dependency' - const Foo = __vi_esm_0__.Foo; - class A extends Foo {} - class B extends Foo {} - Object.defineProperty(__vi_inject__, "B", { enumerable: true, configurable: true, get(){ return B }}); - Object.defineProperty(__vi_inject__, "default", { enumerable: true, configurable: true, value: A }); - export { __vi_inject__ }" - `) -}) - -// #4049 -test('should handle default export variants', async () => { - // default anonymous functions - expect(injectSimpleCode('export default function() {}\n')) - .toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - __vi_inject__.default = function() {} - - export default { __vi_inject__: __vi_inject__.default }; - - export { __vi_inject__ }" - `) - // default anonymous class - expect(injectSimpleCode('export default class {}\n')) - .toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - __vi_inject__.default = class {} - - export default { __vi_inject__: __vi_inject__.default }; - - export { __vi_inject__ }" - `) - // default named functions - expect( - injectSimpleCode( - 'export default function foo() {}\n' - + 'foo.prototype = Object.prototype;', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - function foo() {} - foo.prototype = Object.prototype; - Object.defineProperty(__vi_inject__, "default", { enumerable: true, configurable: true, value: foo }); - export { __vi_inject__ }" - `) - // default named classes - expect( - injectSimpleCode( - 'export default class A {}\n' + 'export class B extends A {}', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - class A {} - class B extends A {} - Object.defineProperty(__vi_inject__, "B", { enumerable: true, configurable: true, get(){ return B }}); - Object.defineProperty(__vi_inject__, "default", { enumerable: true, configurable: true, value: A }); - export { __vi_inject__ }" - `) -}) - -test('overwrite bindings', async () => { - expect( - injectSimpleCode( - 'import { inject } from \'vue\';' - + 'const a = { inject }\n' - + 'const b = { test: inject }\n' - + 'function c() { const { test: inject } = { test: true }; console.log(inject) }\n' - + 'const d = inject\n' - + 'function f() { console.log(inject) }\n' - + 'function e() { const { inject } = { inject: true } }\n' - + 'function g() { const f = () => { const inject = true }; console.log(inject) }\n', - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - const a = { inject: __vi_esm_0__.inject } - const b = { test: __vi_esm_0__.inject } - function c() { const { test: inject } = { test: true }; console.log(inject) } - const d = __vi_esm_0__.inject - function f() { console.log(__vi_esm_0__.inject) } - function e() { const { inject } = { inject: true } } - function g() { const f = () => { const inject = true }; console.log(__vi_esm_0__.inject) } - - export { __vi_inject__ }" - `) -}) - -test('Empty array pattern', async () => { - expect( - injectSimpleCode('const [, LHS, RHS] = inMatch;'), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - const [, LHS, RHS] = inMatch; - export { __vi_inject__ }" - `) -}) - -test('function argument destructure', async () => { - expect( - injectSimpleCode( - ` -import { foo, bar } from 'foo' -const a = ({ _ = foo() }) => {} -function b({ _ = bar() }) {} -function c({ _ = bar() + foo() }) {} -`, - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'foo' - - - const a = ({ _ = __vi_esm_0__.foo() }) => {} - function b({ _ = __vi_esm_0__.bar() }) {} - function c({ _ = __vi_esm_0__.bar() + __vi_esm_0__.foo() }) {} - - export { __vi_inject__ }" - `) -}) - -test('object destructure alias', async () => { - expect( - injectSimpleCode( - ` -import { n } from 'foo' -const a = () => { - const { type: n = 'bar' } = {} - console.log(n) -} -`, - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'foo' - - - const a = () => { - const { type: n = 'bar' } = {} - console.log(n) - } - - export { __vi_inject__ }" - `) - - // #9585 - expect( - injectSimpleCode( - ` -import { n, m } from 'foo' -const foo = {} - -{ - const { [n]: m } = foo -} -`, - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'foo' - - - const foo = {} - - { - const { [__vi_esm_0__.n]: m } = foo - } - - export { __vi_inject__ }" - `) -}) - -test('nested object destructure alias', async () => { - expect( - injectSimpleCode( - ` -import { remove, add, get, set, rest, objRest } from 'vue' - -function a() { - const { - o: { remove }, - a: { b: { c: [ add ] }}, - d: [{ get }, set, ...rest], - ...objRest - } = foo - - remove() - add() - get() - set() - rest() - objRest() -} - -remove() -add() -get() -set() -rest() -objRest() -`, - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - - - - function a() { - const { - o: { remove }, - a: { b: { c: [ add ] }}, - d: [{ get }, set, ...rest], - ...objRest - } = foo - - remove() - add() - get() - set() - rest() - objRest() - } - - __vi_esm_0__.remove() - __vi_esm_0__.add() - __vi_esm_0__.get() - __vi_esm_0__.set() - __vi_esm_0__.rest() - __vi_esm_0__.objRest() - - export { __vi_inject__ }" - `) -}) - -test('object props and methods', async () => { - expect( - injectSimpleCode( - ` -import foo from 'foo' - -const bar = 'bar' - -const obj = { - foo() {}, - [foo]() {}, - [bar]() {}, - foo: () => {}, - [foo]: () => {}, - [bar]: () => {}, - bar(foo) {} -} -`, - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'foo' - - - - const bar = 'bar' - - const obj = { - foo() {}, - [__vi_esm_0__.default]() {}, - [bar]() {}, - foo: () => {}, - [__vi_esm_0__.default]: () => {}, - [bar]: () => {}, - bar(foo) {} - } - - export { __vi_inject__ }" - `) -}) - -test('class props', async () => { - expect( - injectSimpleCode( - ` -import { remove, add } from 'vue' - -class A { - remove = 1 - add = null -} -`, - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - - - - const add = __vi_esm_0__.add; - const remove = __vi_esm_0__.remove; - class A { - remove = 1 - add = null - } - - export { __vi_inject__ }" - `) -}) - -test('class methods', async () => { - expect( - injectSimpleCode( - ` -import foo from 'foo' - -const bar = 'bar' - -class A { - foo() {} - [foo]() {} - [bar]() {} - #foo() {} - bar(foo) {} -} -`, - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'foo' - - - - const bar = 'bar' - - class A { - foo() {} - [__vi_esm_0__.default]() {} - [bar]() {} - #foo() {} - bar(foo) {} - } - - export { __vi_inject__ }" - `) -}) - -test('declare scope', async () => { - expect( - injectSimpleCode( - ` -import { aaa, bbb, ccc, ddd } from 'vue' - -function foobar() { - ddd() - - const aaa = () => { - bbb(ccc) - ddd() - } - const bbb = () => { - console.log('hi') - } - const ccc = 1 - function ddd() {} - - aaa() - bbb() - ccc() -} - -aaa() -bbb() -`, - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'vue' - - - - function foobar() { - ddd() - - const aaa = () => { - bbb(ccc) - ddd() - } - const bbb = () => { - console.log('hi') - } - const ccc = 1 - function ddd() {} - - aaa() - bbb() - ccc() - } - - __vi_esm_0__.aaa() - __vi_esm_0__.bbb() - - export { __vi_inject__ }" - `) -}) - -test('jsx', async () => { - const code = ` - import React from 'react' - import { Foo, Slot } from 'foo' - - function Bar({ Slot = }) { - return ( - <> - - - ) - } - ` - const id = '/foo.jsx' - const result = await transformWithEsbuild(code, id) - expect(injectSimpleCode(result.code)) - .toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'react' - import { __vi_inject__ as __vi_esm_1__ } from 'foo' - - - function Bar({ Slot: Slot2 = /* @__PURE__ */ __vi_esm_0__.default.createElement(__vi_esm_1__.Foo, null) }) { - return /* @__PURE__ */ __vi_esm_0__.default.createElement(__vi_esm_0__.default.Fragment, null, /* @__PURE__ */ __vi_esm_0__.default.createElement(Slot2, null)); - } - - export { __vi_inject__ }" - `) -}) - -test('continuous exports', async () => { - expect( - injectSimpleCode( - ` -export function fn1() { -}export function fn2() { -} - `, - ), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - - function fn1() { - } - Object.defineProperty(__vi_inject__, "fn1", { enumerable: true, configurable: true, get(){ return fn1 }});function fn2() { - } - Object.defineProperty(__vi_inject__, "fn2", { enumerable: true, configurable: true, get(){ return fn2 }}); - - export { __vi_inject__ }" - `) -}) - -// https://github.com/vitest-dev/vitest/issues/1141 -test('export default expression', async () => { - // esbuild transform result of following TS code - // export default function getRandom() { - // return Math.random() - // } - const code = ` -export default (function getRandom() { - return Math.random(); -}); -`.trim() - - expect(injectSimpleCode(code)).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - __vi_inject__.default = (function getRandom() { - return Math.random(); - }); - export default { __vi_inject__: __vi_inject__.default }; - - export { __vi_inject__ }" - `) - - expect( - injectSimpleCode('export default (class A {});'), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - __vi_inject__.default = (class A {}); - export default { __vi_inject__: __vi_inject__.default }; - - export { __vi_inject__ }" - `) -}) - -test('track scope in for loops', async () => { - expect( - injectSimpleCode(` -import { test } from './test.js' -for (const test of tests) { - console.log(test) -} -for (let test = 0; test < 10; test++) { - console.log(test) -} -for (const test in tests) { - console.log(test) -}`), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from './test.js' - - - for (const test of tests) { - console.log(test) - } - for (let test = 0; test < 10; test++) { - console.log(test) - } - for (const test in tests) { - console.log(test) - } - export { __vi_inject__ }" - `) -}) - -// #8002 -// test('with hashbang', async () => { -// expect( -// injectSimpleCode( -// `#!/usr/bin/env node -// console.log("it can parse the hashbang")`, -// ), -// ).toMatchInlineSnapshot(` -// "#!/usr/bin/env node -// console.log(\\"it can parse the hashbang\\")" -// `) -// }) - -// test('import hoisted after hashbang', async () => { -// expect( -// await injectSimpleCode( -// `#!/usr/bin/env node -// import "foo"`, -// ), -// ).toMatchInlineSnapshot(` -// "#!/usr/bin/env node -// const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foo\\"); -// " -// `) -// }) - -// #10289 -test('track scope by class, function, condition blocks', async () => { - const code = ` -import { foo, bar } from 'foobar' -if (false) { - const foo = 'foo' - console.log(foo) -} else if (false) { - const [bar] = ['bar'] - console.log(bar) -} else { - console.log(foo) - console.log(bar) -} -export class Test { - constructor() { - if (false) { - const foo = 'foo' - console.log(foo) - } else if (false) { - const [bar] = ['bar'] - console.log(bar) - } else { - console.log(foo) - console.log(bar) - } - } -};`.trim() - - expect(injectSimpleCode(code)).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'foobar' - - if (false) { - const foo = 'foo' - console.log(foo) - } else if (false) { - const [bar] = ['bar'] - console.log(bar) - } else { - console.log(__vi_esm_0__.foo) - console.log(__vi_esm_0__.bar) - } - class Test { - constructor() { - if (false) { - const foo = 'foo' - console.log(foo) - } else if (false) { - const [bar] = ['bar'] - console.log(bar) - } else { - console.log(__vi_esm_0__.foo) - console.log(__vi_esm_0__.bar) - } - } - } - Object.defineProperty(__vi_inject__, "Test", { enumerable: true, configurable: true, get(){ return Test }});; - export { __vi_inject__ }" - `) -}) - -// #10386 -test('track var scope by function', async () => { - expect( - injectSimpleCode(` -import { foo, bar } from 'foobar' -function test() { - if (true) { - var foo = () => { var why = 'would' }, bar = 'someone' - } - return [foo, bar] -}`), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'foobar' - - - function test() { - if (true) { - var foo = () => { var why = 'would' }, bar = 'someone' - } - return [foo, bar] - } - export { __vi_inject__ }" - `) -}) - -// #11806 -test('track scope by blocks', async () => { - expect( - injectSimpleCode(` -import { foo, bar, baz } from 'foobar' -function test() { - [foo]; - { - let foo = 10; - let bar = 10; - } - try {} catch (baz){ baz }; - return bar; -}`), - ).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from 'foobar' - - - function test() { - [__vi_esm_0__.foo]; - { - let foo = 10; - let bar = 10; - } - try {} catch (baz){ baz }; - return __vi_esm_0__.bar; - } - export { __vi_inject__ }" - `) -}) - -test('avoid binding ClassExpression', () => { - const result = injectSimpleCode( - ` - import Foo, { Bar } from './foo'; - console.log(Foo, Bar); - const obj = { - foo: class Foo {}, - bar: class Bar {} - } - const Baz = class extends Foo {} - `, - ) - expect(result).toMatchInlineSnapshot(` - "const __vi_inject__ = { [Symbol.toStringTag]: "Module" }; - import { __vi_inject__ as __vi_esm_0__ } from './foo' - - - console.log(__vi_esm_0__.default, __vi_esm_0__.Bar); - const obj = { - foo: class Foo {}, - bar: class Bar {} - } - const Baz = class extends __vi_esm_0__.default {} - - export { __vi_inject__ }" - `) + expect(result).toMatchInlineSnapshot(`"export const i = () => __vitest_browser_runner__.wrapModule(() => import('./foo'))"`) }) diff --git a/test/test-utils/cli.ts b/test/test-utils/cli.ts index 5abee6ee8730..909ad1f84bfe 100644 --- a/test/test-utils/cli.ts +++ b/test/test-utils/cli.ts @@ -37,7 +37,8 @@ export class Cli { } private capture(source: Source, data: any) { - this[source] += stripAnsi(data.toString()) + const msg = stripAnsi(data.toString()) + this[source] += msg this[`${source}Listeners`].forEach(fn => fn()) }