From 2444ff225fa82f07b185ae6e5fef7b10227ec876 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 28 Oct 2024 13:19:27 +0100 Subject: [PATCH] fix(browser): initiate MSW in the same frame as tests (#6772) --- packages/browser/src/client/channel.ts | 51 +--------------- packages/browser/src/client/orchestrator.ts | 15 ----- packages/browser/src/client/tester/mocker.ts | 32 ---------- packages/browser/src/client/tester/msw.ts | 61 ++----------------- packages/browser/src/client/tester/tester.ts | 30 ++------- packages/browser/src/node/constants.ts | 5 ++ packages/browser/src/node/index.ts | 2 + packages/browser/src/node/plugin.ts | 30 ++++++++- .../mocker/src/browser/interceptor-msw.ts | 37 +++-------- packages/mocker/src/browser/mocker.ts | 3 +- .../mocker/src/node/dynamicImportPlugin.ts | 4 ++ packages/vitest/src/node/plugins/mocks.ts | 9 ++- packages/vitest/src/node/workspace.ts | 13 +++- 13 files changed, 76 insertions(+), 216 deletions(-) create mode 100644 packages/browser/src/node/constants.ts diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts index c9b3fd67bff9..b1aaa1964475 100644 --- a/packages/browser/src/client/channel.ts +++ b/packages/browser/src/client/channel.ts @@ -1,4 +1,3 @@ -import type { MockedModuleSerialized } from '@vitest/mocker' import type { CancelReason } from '@vitest/runner' import { getBrowserState } from './utils' @@ -23,46 +22,6 @@ export interface IframeViewportEvent { id: string } -export interface IframeMockEvent { - type: 'mock' - module: MockedModuleSerialized -} - -export interface IframeUnmockEvent { - type: 'unmock' - url: string -} - -export interface IframeMockingDoneEvent { - type: 'mock:done' | 'unmock:done' -} - -export interface IframeMockFactoryRequestEvent { - type: 'mock-factory:request' - eventId: string - id: string -} - -export interface IframeMockFactoryResponseEvent { - type: 'mock-factory:response' - eventId: string - exports: string[] -} - -export interface IframeMockFactoryErrorEvent { - type: 'mock-factory:error' - eventId: string - error: any -} - -export interface IframeViewportChannelEvent { - type: 'viewport:done' | 'viewport:fail' -} - -export interface IframeMockInvalidateEvent { - type: 'mock:invalidate' -} - export interface GlobalChannelTestRunCanceledEvent { type: 'cancel' reason: CancelReason @@ -74,16 +33,8 @@ export type IframeChannelIncomingEvent = | IframeViewportEvent | IframeErrorEvent | IframeDoneEvent - | IframeMockEvent - | IframeUnmockEvent - | IframeMockFactoryResponseEvent - | IframeMockFactoryErrorEvent - | IframeMockInvalidateEvent -export type IframeChannelOutgoingEvent = - | IframeMockFactoryRequestEvent - | IframeViewportChannelEvent - | IframeMockingDoneEvent +export type IframeChannelOutgoingEvent = never export type IframeChannelEvent = | IframeChannelIncomingEvent diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index f29e3e5044b7..fc576692bd3c 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -3,7 +3,6 @@ import { channel, client } from '@vitest/browser/client' import { globalChannel, type GlobalChannelIncomingEvent, type IframeChannelEvent, type IframeChannelIncomingEvent } from '@vitest/browser/client' import { generateHash } from '@vitest/runner/utils' import { relative } from 'pathe' -import { createModuleMockerInterceptor } from './tester/msw' import { getUiAPI } from './ui' import { getBrowserState, getConfig } from './utils' @@ -13,7 +12,6 @@ const ID_ALL = '__vitest_all__' class IframeOrchestrator { private cancelled = false private runningFiles = new Set() - private interceptor = createModuleMockerInterceptor() private iframes = new Map() public async init() { @@ -186,19 +184,6 @@ class IframeOrchestrator { } break } - case 'mock:invalidate': - this.interceptor.invalidate() - break - case 'unmock': - await this.interceptor.delete(e.data.url) - break - case 'mock': - await this.interceptor.register(e.data.module) - break - case 'mock-factory:error': - case 'mock-factory:response': - // handled manually - break default: { e.data satisfies never diff --git a/packages/browser/src/client/tester/mocker.ts b/packages/browser/src/client/tester/mocker.ts index 6e15f69f3390..eb922bf6ef2e 100644 --- a/packages/browser/src/client/tester/mocker.ts +++ b/packages/browser/src/client/tester/mocker.ts @@ -1,39 +1,7 @@ -import type { IframeChannelOutgoingEvent, IframeMockFactoryErrorEvent, IframeMockFactoryResponseEvent } from '@vitest/browser/client' -import { channel } from '@vitest/browser/client' import { ModuleMocker } from '@vitest/mocker/browser' import { getBrowserState } from '../utils' export class VitestBrowserClientMocker extends ModuleMocker { - setupWorker() { - channel.addEventListener( - 'message', - async (e: MessageEvent) => { - if (e.data.type === 'mock-factory:request') { - try { - const module = await this.resolveFactoryModule(e.data.id) - const exports = Object.keys(module) - channel.postMessage({ - type: 'mock-factory:response', - eventId: e.data.eventId, - exports, - } satisfies IframeMockFactoryResponseEvent) - } - catch (err: any) { - channel.postMessage({ - type: 'mock-factory:error', - eventId: e.data.eventId, - error: { - name: err.name, - message: err.message, - stack: err.stack, - }, - } satisfies IframeMockFactoryErrorEvent) - } - } - }, - ) - } - // default "vi" utility tries to access mock context to avoid circular dependencies public getMockContext() { return { callstack: null } diff --git a/packages/browser/src/client/tester/msw.ts b/packages/browser/src/client/tester/msw.ts index 489440a971e0..5c40d46b411c 100644 --- a/packages/browser/src/client/tester/msw.ts +++ b/packages/browser/src/client/tester/msw.ts @@ -1,38 +1,9 @@ -import type { - IframeChannelEvent, - IframeMockFactoryRequestEvent, - IframeMockingDoneEvent, -} from '@vitest/browser/client' -import type { MockedModuleSerialized } from '@vitest/mocker' -import { channel } from '@vitest/browser/client' -import { ManualMockedModule } from '@vitest/mocker' import { ModuleMockerMSWInterceptor } from '@vitest/mocker/browser' -import { nanoid } from '@vitest/utils' - -export class VitestBrowserModuleMockerInterceptor extends ModuleMockerMSWInterceptor { - override async register(event: MockedModuleSerialized): Promise { - if (event.type === 'manual') { - const module = ManualMockedModule.fromJSON(event, async () => { - const keys = await getFactoryExports(event.url) - return Object.fromEntries(keys.map(key => [key, null])) - }) - await super.register(module) - } - else { - await this.init() - this.mocks.register(event) - } - channel.postMessage({ type: 'mock:done' }) - } - - override async delete(url: string): Promise { - await super.delete(url) - channel.postMessage({ type: 'unmock:done' }) - } -} +import { getConfig } from '../utils' export function createModuleMockerInterceptor() { - return new VitestBrowserModuleMockerInterceptor({ + const debug = getConfig().env.VITEST_BROWSER_DEBUG + return new ModuleMockerMSWInterceptor({ globalThisAccessor: '"__vitest_mocker__"', mswOptions: { serviceWorker: { @@ -42,31 +13,7 @@ export function createModuleMockerInterceptor() { }, }, onUnhandledRequest: 'bypass', - quiet: true, + quiet: !(debug && debug !== 'false'), }, }) } - -function getFactoryExports(id: string) { - const eventId = nanoid() - channel.postMessage({ - type: 'mock-factory:request', - eventId, - id, - } satisfies IframeMockFactoryRequestEvent) - return new Promise((resolve, reject) => { - channel.addEventListener( - 'message', - function onMessage(e: MessageEvent) { - if (e.data.type === 'mock-factory:response' && e.data.eventId === eventId) { - resolve(e.data.exports) - channel.removeEventListener('message', onMessage) - } - if (e.data.type === 'mock-factory:error' && e.data.eventId === eventId) { - reject(e.data.error) - channel.removeEventListener('message', onMessage) - } - }, - ) - }) -} diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index d4d46e2dbd40..aa86e0f31cb9 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -1,5 +1,4 @@ -import type { IframeMockEvent, IframeMockInvalidateEvent, IframeUnmockEvent } from '@vitest/browser/client' -import { channel, client, onCancel, waitForChannel } from '@vitest/browser/client' +import { channel, client, onCancel } from '@vitest/browser/client' import { page, userEvent } from '@vitest/browser/context' import { collectTests, setupCommonEnv, SpyModule, startCoverageInsideWorker, startTests, stopCoverageInsideWorker } from 'vitest/browser' import { executor, getBrowserState, getConfig, getWorkerState } from '../utils' @@ -7,6 +6,7 @@ import { setupDialogsSpy } from './dialog' import { setupExpectDom } from './expect-element' import { setupConsoleLogSpy } from './logger' import { VitestBrowserClientMocker } from './mocker' +import { createModuleMockerInterceptor } from './msw' import { createSafeRpc } from './rpc' import { browserHashMap, initiateRunner } from './runner' @@ -34,28 +34,10 @@ async function prepareTestEnvironment(files: string[]) { state.onCancel = onCancel state.rpc = rpc as any + // TODO: expose `worker` + const interceptor = createModuleMockerInterceptor() const mocker = new VitestBrowserClientMocker( - { - async delete(url: string) { - channel.postMessage({ - type: 'unmock', - url, - } satisfies IframeUnmockEvent) - await waitForChannel('unmock:done') - }, - async register(module) { - channel.postMessage({ - type: 'mock', - module: module.toJSON(), - } satisfies IframeMockEvent) - await waitForChannel('mock:done') - }, - invalidate() { - channel.postMessage({ - type: 'mock:invalidate', - } satisfies IframeMockInvalidateEvent) - }, - }, + interceptor, rpc, SpyModule.spyOn, { @@ -79,8 +61,6 @@ async function prepareTestEnvironment(files: string[]) { } }) - mocker.setupWorker() - onCancel.then((reason) => { runner.onCancel?.(reason) }) diff --git a/packages/browser/src/node/constants.ts b/packages/browser/src/node/constants.ts new file mode 100644 index 000000000000..8cdda154b67a --- /dev/null +++ b/packages/browser/src/node/constants.ts @@ -0,0 +1,5 @@ +import { fileURLToPath } from 'node:url' +import { resolve } from 'pathe' + +const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') +export const distRoot = resolve(pkgRoot, 'dist') diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index 1c194e9468b2..7d91208dee5b 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -7,7 +7,9 @@ import BrowserPlugin from './plugin' import { setupBrowserRpc } from './rpc' import { BrowserServer } from './server' +export { distRoot } from './constants' export { createBrowserPool } from './pool' + export type { BrowserServer } from './server' export async function createBrowserServer( diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 2140c4bcc803..737a9cc9d744 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -4,7 +4,6 @@ import type { WorkspaceProject } from 'vitest/node' import type { BrowserServer } from './server' import { lstatSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' -import { fileURLToPath } from 'node:url' import { dynamicImportPlugin } from '@vitest/mocker/node' import { toArray } from '@vitest/utils' import MagicString from 'magic-string' @@ -12,6 +11,7 @@ import { basename, dirname, extname, resolve } from 'pathe' import sirv from 'sirv' import { coverageConfigDefaults, type Plugin } from 'vitest/config' import { getFilePoolName, resolveApiServerConfig, resolveFsAllow, distDir as vitestDist } from 'vitest/node' +import { distRoot } from './constants' import BrowserContext from './plugins/pluginContext' import { resolveOrchestrator } from './serverOrchestrator' import { resolveTester } from './serverTester' @@ -19,9 +19,9 @@ import { resolveTester } from './serverTester' export { defineBrowserCommand } from './commands/utils' export type { BrowserCommand } from 'vitest/node' +const versionRegexp = /(?:\?|&)v=\w{8}/ + export default (browserServer: BrowserServer, base = '/'): Plugin[] => { - const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') - const distRoot = resolve(pkgRoot, 'dist') const project = browserServer.project function isPackageExists(pkg: string, root: string) { @@ -160,6 +160,24 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { res.end(buffer) }) } + server.middlewares.use((req, res, next) => { + // 9000 mega head move + // Vite always caches optimized dependencies, but users might mock + // them in _some_ tests, while keeping original modules in others + // there is no way to configure that in Vite, so we patch it here + // to always ignore the cache-control set by Vite in the next middleware + if (req.url && versionRegexp.test(req.url) && !req.url.includes('chunk-')) { + res.setHeader('Cache-Control', 'no-cache') + const setHeader = res.setHeader.bind(res) + res.setHeader = function (name, value) { + if (name === 'Cache-Control') { + return res + } + return setHeader(name, value) + } + } + next() + }) }, }, { @@ -325,6 +343,12 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => { BrowserContext(browserServer), dynamicImportPlugin({ globalThisAccessor: '"__vitest_browser_runner__"', + filter(id) { + if (id.includes(distRoot)) { + return false + } + return true + }, }), { name: 'vitest:browser:config', diff --git a/packages/mocker/src/browser/interceptor-msw.ts b/packages/mocker/src/browser/interceptor-msw.ts index 5d112a51b176..53f50899ddad 100644 --- a/packages/mocker/src/browser/interceptor-msw.ts +++ b/packages/mocker/src/browser/interceptor-msw.ts @@ -34,8 +34,8 @@ export interface ModuleMockerMSWInterceptorOptions { export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor { protected readonly mocks: MockerRegistry = new MockerRegistry() - private started = false - private startPromise: undefined | Promise + private startPromise: undefined | Promise + private worker: undefined | SetupWorker constructor( private readonly options: ModuleMockerMSWInterceptorOptions = {}, @@ -78,9 +78,9 @@ export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor { }) } - protected async init(): Promise { - if (this.started) { - return + protected async init(): Promise { + if (this.worker) { + return this.worker } if (this.startPromise) { return this.startPromise @@ -101,13 +101,6 @@ export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor { http.get(/.+/, async ({ request }) => { const path = cleanQuery(request.url.slice(location.origin.length)) if (!this.mocks.has(path)) { - // do not cache deps like Vite does for performance - // because we want to be able to update mocks without restarting the server - // TODO: check if it's still neded - we invalidate modules after each test - if (path.includes('/deps/')) { - return fetch(bypass(request)) - } - return passthrough() } @@ -126,12 +119,12 @@ export class ModuleMockerMSWInterceptor implements ModuleMockerInterceptor { } }), ) - return worker.start(this.options.mswOptions) + return worker.start(this.options.mswOptions).then(() => worker) }).finally(() => { - this.started = true + this.worker = worker this.startPromise = undefined }) - await this.startPromise + return await this.startPromise } } @@ -151,20 +144,6 @@ function passthrough() { }) } -function bypass(request: Request) { - const clonedRequest = request.clone() - clonedRequest.headers.set('x-msw-intention', 'bypass') - const cacheControl = clonedRequest.headers.get('cache-control') - if (cacheControl) { - clonedRequest.headers.set( - 'cache-control', - // allow reinvalidation of the cache so mocks can be updated - cacheControl.replace(', immutable', ''), - ) - } - return clonedRequest -} - const replacePercentageRE = /%/g function injectQuery(url: string, queryToInject: string): string { // encode percents for consistent behavior with pathToFileURL diff --git a/packages/mocker/src/browser/mocker.ts b/packages/mocker/src/browser/mocker.ts index 4e7e572153bc..cc9472d598b7 100644 --- a/packages/mocker/src/browser/mocker.ts +++ b/packages/mocker/src/browser/mocker.ts @@ -7,7 +7,6 @@ import { AutomockedModule, MockerRegistry, RedirectedModule } from '../registry' const { now } = Date -// TODO: define an interface thath both node.js and browser mocker can implement export class ModuleMocker { protected registry: MockerRegistry = new MockerRegistry() @@ -62,7 +61,7 @@ export class ModuleMocker { const resolved = await this.rpc.resolveId(id, importer) if (resolved == null) { throw new Error( - `[vitest] Cannot resolve ${id} imported from ${importer}`, + `[vitest] Cannot resolve "${id}" imported from "${importer}"`, ) } const ext = extname(resolved.id) diff --git a/packages/mocker/src/node/dynamicImportPlugin.ts b/packages/mocker/src/node/dynamicImportPlugin.ts index 22f880b217d9..62b5435ca127 100644 --- a/packages/mocker/src/node/dynamicImportPlugin.ts +++ b/packages/mocker/src/node/dynamicImportPlugin.ts @@ -11,6 +11,7 @@ export interface DynamicImportPluginOptions { * @default `"__vitest_mocker__"` */ globalThisAccessor?: string + filter?: (id: string) => boolean } export function dynamicImportPlugin(options: DynamicImportPluginOptions = {}): Plugin { @@ -22,6 +23,9 @@ export function dynamicImportPlugin(options: DynamicImportPluginOptions = {}): P if (!regexDynamicImport.test(source)) { return } + if (options.filter && !options.filter(id)) { + return + } return injectDynamicImport(source, id, this.parse, options) }, } diff --git a/packages/vitest/src/node/plugins/mocks.ts b/packages/vitest/src/node/plugins/mocks.ts index e9def6730850..d95f092f939a 100644 --- a/packages/vitest/src/node/plugins/mocks.ts +++ b/packages/vitest/src/node/plugins/mocks.ts @@ -4,7 +4,11 @@ import { normalize } from 'pathe' import { distDir } from '../../paths' import { generateCodeFrame } from '../error' -export function MocksPlugins(): Plugin[] { +export interface MocksPluginOptions { + filter?: (id: string) => boolean +} + +export function MocksPlugins(options: MocksPluginOptions = {}): Plugin[] { const normalizedDistDir = normalize(distDir) return [ hoistMocksPlugin({ @@ -12,6 +16,9 @@ export function MocksPlugins(): Plugin[] { if (id.includes(normalizedDistDir)) { return false } + if (options.filter) { + return options.filter(id) + } return true }, codeFrameGenerator(node, id, code) { diff --git a/packages/vitest/src/node/workspace.ts b/packages/vitest/src/node/workspace.ts index 487fa5dc3709..38135d1ff6c9 100644 --- a/packages/vitest/src/node/workspace.ts +++ b/packages/vitest/src/node/workspace.ts @@ -363,12 +363,21 @@ export class WorkspaceProject { return } await this.ctx.packageInstaller.ensureInstalled('@vitest/browser', this.config.root, this.ctx.version) - const { createBrowserServer } = await import('@vitest/browser') + const { createBrowserServer, distRoot } = await import('@vitest/browser') await this.browser?.close() const browser = await createBrowserServer( this, configFile, - [...MocksPlugins()], + [ + ...MocksPlugins({ + filter(id) { + if (id.includes(distRoot)) { + return false + } + return true + }, + }), + ], [CoverageTransform(this.ctx)], ) this.browser = browser