diff --git a/packages/browser-playwright/src/playwright.ts b/packages/browser-playwright/src/playwright.ts index 48b0e77b1b70..2bb4c00da43c 100644 --- a/packages/browser-playwright/src/playwright.ts +++ b/packages/browser-playwright/src/playwright.ts @@ -12,6 +12,7 @@ import type { LaunchOptions, Page, CDPSession as PlaywrightCDPSession, + Route as PlaywrightRoute, } from 'playwright' import type { SourceMap } from 'rollup' import type { ResolvedConfig } from 'vite' @@ -41,10 +42,44 @@ const debug = createDebugger('vitest:browser:playwright') const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const type PlaywrightBrowser = (typeof playwrightBrowsers)[number] +interface PlaywrightRouteEntry { + matcher: string | RegExp + handler: Parameters[1] +} + // Enable intercepting of requests made by service workers - experimental API is only available in Chromium based browsers // Requests from service workers are only available on context.route() https://playwright.dev/docs/service-workers-experimental process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS ??= '1' +type SerializedRouteMatcher + = | { type: 'string'; value: string } + | { type: 'regexp'; value: string; flags: string } + +interface RouteRegisterPayload { + id: string + matcher: SerializedRouteMatcher +} + +interface RouteContinueOverrides { + url?: string + method?: string + headers?: Record + postData?: string +} + +interface RouteEvaluationRequest { + url: string + method: string + headers: Record + postData?: string | null + resourceType?: string +} + +type RouteEvaluationResult + = | { type: 'continue'; overrides?: RouteContinueOverrides } + | { type: 'fulfill'; status?: number; headers?: Record; body?: string; contentType?: string } + | { type: 'abort'; errorCode?: string } + export interface PlaywrightProviderOptions { /** * The options passed down to [`playwright.connect`](https://playwright.dev/docs/api/class-browsertype#browser-type-launch) method. @@ -97,6 +132,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider { public contexts: Map = new Map() public pages: Map = new Map() + private routes: Map> = new Map() public mocker: BrowserModuleMocker public browserName: PlaywrightBrowser @@ -416,6 +452,168 @@ export class PlaywrightBrowserProvider implements BrowserProvider { } } + private getSessionRouteMap(sessionId: string): Map { + let routes = this.routes.get(sessionId) + if (!routes) { + routes = new Map() + this.routes.set(sessionId, routes) + } + return routes + } + + private deserializeMatcher(matcher: SerializedRouteMatcher): string | RegExp { + if (matcher.type === 'string') { + return matcher.value + } + if (matcher.type === 'regexp') { + return new RegExp(matcher.value, matcher.flags) + } + throw new Error(`Unsupported route matcher type "${(matcher as any)?.type}".`) + } + + private createRouteHandler(sessionId: string, routeId: string): Parameters[1] { + return async (route) => { + const request = route.request() + const requestInfo: RouteEvaluationRequest = { + url: request.url(), + method: request.method(), + headers: { ...request.headers() }, + postData: request.postData(), + resourceType: typeof request.resourceType === 'function' ? request.resourceType() : undefined, + } + const result = await this.evaluateRouteHandler(sessionId, routeId, requestInfo) + await this.applyRouteResult(route, result) + } + } + + private async evaluateRouteHandler( + sessionId: string, + routeId: string, + request: RouteEvaluationRequest, + ): Promise { + const page = this.getPage(sessionId) + try { + const result = await page.evaluate(([id, payload]) => { + const handler = (window as any).__vitest_handleRoute + if (!handler) { + return { type: 'continue' } + } + return handler(id, payload) + }, [routeId, request] as const) + + if (!result || typeof result !== 'object') { + return { type: 'continue' } + } + return result as RouteEvaluationResult + } + catch (error) { + debug?.('[%s] route handler execution failed: %O', sessionId, error) + return { type: 'continue' } + } + } + + private async applyRouteResult(route: PlaywrightRoute, result: RouteEvaluationResult): Promise { + if (result.type === 'fulfill') { + let headers = result.headers ? { ...result.headers } : undefined + if (result.contentType) { + headers ??= {} + headers['content-type'] = result.contentType + } + await route.fulfill({ + status: result.status, + headers, + body: result.body, + }) + return + } + + if (result.type === 'abort') { + await route.abort(result.errorCode) + return + } + + const overrides = result.overrides + if (overrides) { + await route.continue({ + url: overrides.url, + method: overrides.method, + headers: overrides.headers, + postData: overrides.postData, + }) + return + } + + await route.continue() + } + + private async reapplyRoutes(sessionId: string, page: Page): Promise { + const routes = this.routes.get(sessionId) + if (!routes?.size) { + return + } + for (const [routeId, entry] of routes) { + const handler = this.createRouteHandler(sessionId, routeId) + await page.route(entry.matcher, handler) + entry.handler = handler + } + } + + public async registerRoute(sessionId: string, payload: RouteRegisterPayload): Promise { + const routes = this.getSessionRouteMap(sessionId) + if (routes.has(payload.id)) { + await this.unregisterRoute(sessionId, payload.id) + } + const matcher = this.deserializeMatcher(payload.matcher) + const handler = this.createRouteHandler(sessionId, payload.id) + const page = this.getPage(sessionId) + await page.route(matcher, handler) + routes.set(payload.id, { matcher, handler }) + } + + public async unregisterRoute(sessionId: string, routeId: string): Promise { + const routes = this.routes.get(sessionId) + if (!routes) { + return + } + const entry = routes.get(routeId) + if (!entry) { + return + } + routes.delete(routeId) + const page = this.pages.get(sessionId) + if (page) { + try { + await page.unroute(entry.matcher, entry.handler) + } + catch (error) { + debug?.('[%s] failed to unroute handler: %O', sessionId, error) + } + } + if (!routes.size) { + this.routes.delete(sessionId) + } + } + + public async resetRoutes(sessionId: string): Promise { + const routes = this.routes.get(sessionId) + if (!routes?.size) { + return + } + const page = this.pages.get(sessionId) + if (page) { + for (const entry of routes.values()) { + try { + await page.unroute(entry.matcher, entry.handler) + } + catch (error) { + debug?.('[%s] failed to unroute handler: %O', sessionId, error) + } + } + } + routes.clear() + this.routes.delete(sessionId) + } + private async openBrowserPage(sessionId: string) { await this._throwIfClosing() @@ -431,6 +629,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider { debug?.('[%s][%s] the page is ready', sessionId, this.browserName) await this._throwIfClosing(page) this.pages.set(sessionId, page) + await this.reapplyRoutes(sessionId, page) if (process.env.VITEST_PW_DEBUG) { page.on('requestfailed', (request) => { @@ -499,6 +698,7 @@ export class PlaywrightBrowserProvider implements BrowserProvider { this.browser = null await Promise.all([...this.pages.values()].map(p => p.close())) this.pages.clear() + this.routes.clear() await Promise.all([...this.contexts.values()].map(c => c.close())) this.contexts.clear() await browser?.close() diff --git a/packages/browser-webdriverio/src/webdriverio.ts b/packages/browser-webdriverio/src/webdriverio.ts index 7996fb265ace..f25efbbfb13e 100644 --- a/packages/browser-webdriverio/src/webdriverio.ts +++ b/packages/browser-webdriverio/src/webdriverio.ts @@ -12,8 +12,9 @@ import type { TestProject, } from 'vitest/node' import type { ClickOptions, DragAndDropOptions, MoveToOptions, remote } from 'webdriverio' -import { defineBrowserProvider } from '@vitest/browser' +import { Buffer } from 'node:buffer' +import { defineBrowserProvider } from '@vitest/browser' import { resolve } from 'pathe' import { createDebugger } from 'vitest/node' import commands from './commands' @@ -24,6 +25,40 @@ const debug = createDebugger('vitest:browser:wdio') const webdriverBrowsers = ['firefox', 'chrome', 'edge', 'safari'] as const type WebdriverBrowser = (typeof webdriverBrowsers)[number] +type SerializedRouteMatcher + = | { type: 'string'; value: string } + | { type: 'regexp'; value: string; flags: string } + +interface RouteRegisterPayload { + id: string + matcher: SerializedRouteMatcher +} + +interface RouteContinueOverrides { + url?: string + method?: string + headers?: Record + postData?: string +} + +interface RouteEvaluationRequest { + url: string + method: string + headers: Record + postData?: string | null + resourceType?: string +} + +type RouteEvaluationResult + = | { type: 'continue'; overrides?: RouteContinueOverrides } + | { type: 'fulfill'; status?: number; headers?: Record; body?: string; contentType?: string } + | { type: 'abort'; errorCode?: string } + +interface WebdriverRouteEntry { + matcher: string | RegExp + mock: WebdriverIO.Mock +} + export interface WebdriverProviderOptions extends Partial< Parameters[0] > {} @@ -44,6 +79,7 @@ export class WebdriverBrowserProvider implements BrowserProvider { public supportsParallelism: boolean = false public browser: WebdriverIO.Browser | null = null + private routes: Map> = new Map() private browserName!: WebdriverBrowser private project!: TestProject @@ -135,6 +171,240 @@ export class WebdriverBrowserProvider implements BrowserProvider { } } + private getSessionRouteMap(sessionId: string): Map { + let routes = this.routes.get(sessionId) + if (!routes) { + routes = new Map() + this.routes.set(sessionId, routes) + } + return routes + } + + private deserializeMatcher(matcher: SerializedRouteMatcher): string | RegExp { + if (matcher.type === 'string') { + return matcher.value + } + if (matcher.type === 'regexp') { + return new RegExp(matcher.value, matcher.flags) + } + throw new Error(`Unsupported route matcher type "${(matcher as any)?.type}".`) + } + + private normalizeHeaders(headers: any): Record { + const normalized: Record = {} + if (!headers) { + return normalized + } + if (Array.isArray(headers)) { + for (const header of headers) { + if (!header) { + continue + } + const name = header.name ?? header.key + if (!name) { + continue + } + const value = Array.isArray(header.value) + ? header.value.join(', ') + : header.value ?? header.values + if (value == null) { + continue + } + normalized[name] = String(value) + } + return normalized + } + if (typeof headers === 'object') { + for (const [key, value] of Object.entries(headers)) { + if (value == null) { + continue + } + normalized[key] = Array.isArray(value) ? value.join(', ') : String(value) + } + } + return normalized + } + + private extractBody(body: any): string | null { + if (!body) { + return null + } + if (typeof body === 'string') { + return body + } + if (typeof body.text === 'string') { + return body.text + } + if (typeof body.value === 'string') { + return body.value + } + if (typeof body.data === 'string') { + return body.data + } + if (typeof body.bytes === 'string') { + try { + return Buffer.from(body.bytes, 'base64').toString() + } + catch { + return null + } + } + return null + } + + private toRouteRequest(event: any): RouteEvaluationRequest { + const request = (event && event.request) || {} + const url = typeof request.url === 'string' ? request.url : '' + const method = typeof request.method === 'string' ? request.method : 'GET' + const headers = this.normalizeHeaders(request.headers) + const postData = this.extractBody(request.body) + const resourceType = typeof request.initiator?.type === 'string' + ? request.initiator.type + : (typeof request.resourceType === 'string' ? request.resourceType : undefined) + return { + url, + method, + headers, + postData, + resourceType, + } + } + + private async evaluateRouteHandler( + sessionId: string, + routeId: string, + request: RouteEvaluationRequest, + ): Promise { + const browser = await this.openBrowser() + try { + const result = await browser.execute( + (id: string, payload: RouteEvaluationRequest) => { + const handler = (window as any).__vitest_handleRoute + if (!handler) { + return { type: 'continue' } + } + return handler(id, payload) + }, + routeId, + request, + ) + if (!result || typeof result !== 'object') { + return { type: 'continue' } + } + return result as RouteEvaluationResult + } + catch (error) { + debug?.('[%s] route handler execution failed: %O', sessionId, error) + return { type: 'continue' } + } + } + + private applyMockResult(mock: WebdriverIO.Mock, result: RouteEvaluationResult) { + if (result.type === 'fulfill') { + const options: Record = {} + if (result.status != null) { + options.statusCode = result.status + } + if (result.headers) { + options.headers = result.headers + } + if (result.contentType) { + options.headers ??= {} + options.headers['content-type'] = result.contentType + } + mock.respondOnce(result.body ?? '', options) + return + } + + if (result.type === 'abort') { + mock.abortOnce() + return + } + + const overrides = result.overrides + if (overrides) { + mock.requestOnce({ + url: overrides.url, + method: overrides.method as any, + headers: overrides.headers, + body: overrides.postData, + }) + } + } + + private async handleMockRequest( + sessionId: string, + routeId: string, + mock: WebdriverIO.Mock, + event: any, + ) { + try { + const request = this.toRouteRequest(event) + const result = await this.evaluateRouteHandler(sessionId, routeId, request) + this.applyMockResult(mock, result) + } + catch (error) { + debug?.('[%s] failed to process network mock: %O', sessionId, error) + } + } + + public async registerRoute(sessionId: string, payload: RouteRegisterPayload): Promise { + const routes = this.getSessionRouteMap(sessionId) + if (routes.has(payload.id)) { + await this.unregisterRoute(sessionId, payload.id) + } + const matcher = this.deserializeMatcher(payload.matcher) + const browser = await this.openBrowser() + if (typeof browser.mock !== 'function') { + throw new TypeError('This WebDriverIO setup does not support network interception. Ensure WebDriver BiDi is available.') + } + const mock = await browser.mock(matcher as any) + mock.on('request', (event) => { + void this.handleMockRequest(sessionId, payload.id, mock, event) + }) + routes.set(payload.id, { matcher, mock }) + } + + public async unregisterRoute(sessionId: string, routeId: string): Promise { + const routes = this.routes.get(sessionId) + if (!routes) { + return + } + const entry = routes.get(routeId) + if (!entry) { + return + } + routes.delete(routeId) + try { + await entry.mock.restore() + } + catch (error) { + debug?.('[%s] failed to restore mock: %O', sessionId, error) + } + if (!routes.size) { + this.routes.delete(sessionId) + } + } + + public async resetRoutes(sessionId: string): Promise { + const routes = this.routes.get(sessionId) + if (!routes?.size) { + return + } + await Promise.all( + [...routes.values()].map(async (entry) => { + try { + await entry.mock.restore() + } + catch (error) { + debug?.('[%s] failed to restore mock: %O', sessionId, error) + } + }), + ) + routes.clear() + this.routes.delete(sessionId) + } + async openBrowser(): Promise { await this._throwIfClosing('opening the browser') @@ -250,9 +520,15 @@ export class WebdriverBrowserProvider implements BrowserProvider { const browser = this.browser const sessionId = browser?.sessionId if (!browser || !sessionId) { + this.routes.clear() return } + await Promise.all([...this.routes.keys()].map(id => this.resetRoutes(id))).catch((error) => { + debug?.('[%s] failed to reset routes during teardown: %O', this.browserName, error) + }) + this.routes.clear() + // https://github.com/webdriverio/webdriverio/blob/ab1a2e82b13a9c7d0e275ae87e7357e1b047d8d3/packages/wdio-runner/src/index.ts#L486 await browser.deleteSession() browser.sessionId = undefined as unknown as string diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 136ff10187da..26e40d73c435 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -668,11 +668,68 @@ export const userEvent: UserEvent */ export const commands: BrowserCommands +export type BrowserRouteMatch = string | RegExp + +export interface BrowserRouteRequest { + url(): string + method(): string + headers(): Record + postData(): string | null + resourceType(): string | undefined +} + +export interface BrowserRouteFulfillOptions { + status?: number + headers?: Record + body?: string + /** + * Convenience option that sets the `content-type` header. + */ + contentType?: string +} + +export interface BrowserRouteAbortOptions { + errorCode?: string +} + +export interface BrowserRouteContinueOverrides { + url?: string + method?: string + headers?: Record + postData?: string +} + +export interface BrowserRoute { + request(): BrowserRouteRequest + fulfill(options: BrowserRouteFulfillOptions): Promise | void + abort(error?: string | BrowserRouteAbortOptions): void + continue(overrides?: BrowserRouteContinueOverrides): void +} + +export type BrowserRouteHandler = ( + route: BrowserRoute, + request: BrowserRouteRequest +) => unknown | Promise + export interface BrowserPage extends LocatorSelectors { /** * Change the size of iframe's viewport. */ viewport(width: number, height: number): Promise + /** + * Intercept network requests that match the provided pattern. + * Only available for the `playwright` and `webdriverio` providers. + */ + route(match: BrowserRouteMatch, handler: BrowserRouteHandler): Promise + /** + * Remove previously registered route handlers. + * If `handler` is omitted, every handler for the pattern is removed. + */ + unroute(match: BrowserRouteMatch, handler?: BrowserRouteHandler): Promise + /** + * Remove every route handler registered in the current session. + */ + unrouteAll(): Promise /** * Make a screenshot of the test iframe or a specific element. * @returns Path to the screenshot file or path and base64. diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index a3b562d7ffec..40044ff3f6b3 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -6,6 +6,13 @@ import type { RunnerTask } from 'vitest' import type { BrowserLocators, BrowserPage, + BrowserRoute, + BrowserRouteAbortOptions, + BrowserRouteContinueOverrides, + BrowserRouteFulfillOptions, + BrowserRouteHandler, + BrowserRouteMatch, + BrowserRouteRequest, Locator, LocatorSelectors, UserEvent, @@ -29,6 +36,256 @@ function triggerCommand(command: string, args: any[], error?: Error) { return getBrowserState().commands.triggerCommand(command, args, error) } +type SerializedRouteMatcher + = | { type: 'string'; value: string } + | { type: 'regexp'; value: string; flags: string } + +interface RouteEvaluationRequest { + url: string + method: string + headers: Record + postData?: string | null + resourceType?: string +} + +type RouteEvaluationResult + = | { type: 'continue'; overrides?: BrowserRouteContinueOverrides } + | { type: 'fulfill'; status?: number; headers?: Record; body?: string; contentType?: string } + | { type: 'abort'; errorCode?: string } + +interface RegisteredRoute { + id: string + match: BrowserRouteMatch + serialized: SerializedRouteMatcher + handler: BrowserRouteHandler +} + +const registeredRoutes = new Map() +const handlerToRouteIds = new Map>() +let routeIdCounter = 0 + +function assertRouteSupported() { + if (provider !== 'playwright' && provider !== 'webdriverio') { + throw new Error('page.route is only supported when using the Playwright or WebdriverIO providers.') + } +} + +function createRouteId(): string { + routeIdCounter += 1 + return `vitest-route-${routeIdCounter}` +} + +function serializeRouteMatcher(match: BrowserRouteMatch): SerializedRouteMatcher { + if (typeof match === 'string') { + return { type: 'string', value: match } + } + if (match instanceof RegExp) { + return { type: 'regexp', value: match.source, flags: match.flags } + } + throw new TypeError('Only string or RegExp matchers are supported for page.route.') +} + +function sameMatcher(a: BrowserRouteMatch, b: BrowserRouteMatch): boolean { + if (typeof a === 'string' && typeof b === 'string') { + return a === b + } + if (a instanceof RegExp && b instanceof RegExp) { + return a.source === b.source && a.flags === b.flags + } + return false +} + +function registerRouteLocally(match: BrowserRouteMatch, handler: BrowserRouteHandler): RegisteredRoute { + const id = createRouteId() + const serialized = serializeRouteMatcher(match) + const entry: RegisteredRoute = { id, match, serialized, handler } + registeredRoutes.set(id, entry) + const ids = handlerToRouteIds.get(handler) ?? new Set() + ids.add(id) + handlerToRouteIds.set(handler, ids) + getBrowserState().cleanups.push(() => { + if (!registeredRoutes.has(id)) { + return + } + removeRouteLocally(id) + return triggerCommand('__vitest_route_unregister', [id]).catch(() => {}) + }) + return entry +} + +function removeRouteLocally(id: string) { + const entry = registeredRoutes.get(id) + if (!entry) { + return + } + registeredRoutes.delete(id) + const ids = handlerToRouteIds.get(entry.handler) + if (ids) { + ids.delete(id) + if (!ids.size) { + handlerToRouteIds.delete(entry.handler) + } + } +} + +function clearAllRoutesLocally() { + registeredRoutes.clear() + handlerToRouteIds.clear() +} + +function findMatchingRoutes(match: BrowserRouteMatch, handler?: BrowserRouteHandler): RegisteredRoute[] { + const routes = Array.from(registeredRoutes.values()).filter(entry => sameMatcher(entry.match, match)) + if (!handler) { + return routes + } + return routes.filter(entry => entry.handler === handler) +} + +class BrowserRouteRequestImpl implements BrowserRouteRequest { + constructor(private readonly payload: RouteEvaluationRequest) {} + + url(): string { + return this.payload.url + } + + method(): string { + return this.payload.method + } + + headers(): Record { + return { ...this.payload.headers } + } + + postData(): string | null { + return this.payload.postData ?? null + } + + resourceType(): string | undefined { + return this.payload.resourceType + } +} + +class BrowserRouteImpl implements BrowserRoute { + private handled = false + private result: RouteEvaluationResult = { type: 'continue' } + + constructor(private readonly requestInfo: BrowserRouteRequestImpl) {} + + request(): BrowserRouteRequest { + return this.requestInfo + } + + fulfill(options: BrowserRouteFulfillOptions) { + this.ensureNotHandled('fulfill') + if (options.body != null && typeof options.body !== 'string') { + throw new TypeError('route.fulfill only supports string bodies.') + } + let headers = normalizeHeaders(options.headers) + if (options.contentType) { + headers ??= {} + headers['content-type'] = options.contentType + } + this.result = { + type: 'fulfill', + status: options.status, + headers, + body: options.body, + contentType: options.contentType, + } + this.handled = true + } + + abort(error?: string | BrowserRouteAbortOptions) { + this.ensureNotHandled('abort') + const errorCode = typeof error === 'string' ? error : error?.errorCode + this.result = { + type: 'abort', + errorCode, + } + this.handled = true + } + + continue(overrides?: BrowserRouteContinueOverrides) { + this.ensureNotHandled('continue') + this.result = { + type: 'continue', + overrides: normalizeContinueOverrides(overrides), + } + this.handled = true + } + + getResult(): RouteEvaluationResult { + return this.result + } + + private ensureNotHandled(action: string) { + if (this.handled) { + throw new Error(`route.${action} was already called for this request.`) + } + } +} + +function normalizeHeaders(headers?: Record): Record | undefined { + if (!headers) { + return undefined + } + const normalized: Record = {} + for (const [key, value] of Object.entries(headers)) { + if (value == null) { + continue + } + normalized[key] = String(value) + } + return normalized +} + +function normalizeContinueOverrides( + overrides?: BrowserRouteContinueOverrides, +): BrowserRouteContinueOverrides | undefined { + if (!overrides) { + return undefined + } + const normalized: BrowserRouteContinueOverrides = {} + if (overrides.url != null) { + normalized.url = overrides.url + } + if (overrides.method != null) { + normalized.method = overrides.method + } + if (overrides.headers) { + normalized.headers = normalizeHeaders(overrides.headers) + } + if (overrides.postData != null) { + normalized.postData = overrides.postData + } + return normalized +} + +async function handleRoute(routeId: string, payload: RouteEvaluationRequest): Promise { + const entry = registeredRoutes.get(routeId) + if (!entry) { + return { type: 'continue' } + } + const request = new BrowserRouteRequestImpl(payload) + const route = new BrowserRouteImpl(request) + try { + await entry.handler(route, request) + } + catch (error) { + console.error('[vitest] Failed to execute route handler', error) + return { type: 'continue' } + } + return route.getResult() +} + +declare global { + interface Window { + __vitest_handleRoute?: (routeId: string, payload: RouteEvaluationRequest) => Promise | RouteEvaluationResult + } +} + +window.__vitest_handleRoute = handleRoute + export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent, options?: TestingLibraryOptions): UserEvent { if (__tl_user_event_base__) { return createPreviewUserEvent(__tl_user_event_base__, options ?? {}) @@ -270,6 +527,48 @@ export const page: BrowserPage = { }) }) }, + route(match, handler) { + assertRouteSupported() + if (typeof handler !== 'function') { + throw new TypeError('page.route requires a handler function.') + } + const entry = registerRouteLocally(match, handler) + return ensureAwaited(async (error) => { + try { + await triggerCommand('__vitest_route_register', [{ + id: entry.id, + matcher: entry.serialized, + }], error) + } + catch (err) { + removeRouteLocally(entry.id) + throw err + } + }) + }, + unroute(match, handler) { + assertRouteSupported() + const targets = findMatchingRoutes(match, handler) + if (!targets.length) { + return Promise.resolve() + } + return ensureAwaited(async (error) => { + for (const entry of targets) { + await triggerCommand('__vitest_route_unregister', [entry.id], error) + removeRouteLocally(entry.id) + } + }) + }, + unrouteAll() { + assertRouteSupported() + if (!registeredRoutes.size) { + return Promise.resolve() + } + return ensureAwaited(async (error) => { + await triggerCommand('__vitest_route_reset', [], error) + clearAllRoutesLocally() + }) + }, async screenshot(options = {}) { const currentTest = getWorkerState().current if (!currentTest) { diff --git a/packages/browser/src/node/commands/index.ts b/packages/browser/src/node/commands/index.ts index 64e115b731a6..d7c131f99d60 100644 --- a/packages/browser/src/node/commands/index.ts +++ b/packages/browser/src/node/commands/index.ts @@ -4,6 +4,11 @@ import { removeFile, writeFile, } from './fs' +import { + register as registerRoute, + reset as resetRoutes, + unregister as unregisterRoute, +} from './route' import { screenshot } from './screenshot' import { screenshotMatcher } from './screenshotMatcher' @@ -15,4 +20,7 @@ export default { __vitest_fileInfo: _fileInfo as typeof _fileInfo, __vitest_screenshot: screenshot as typeof screenshot, __vitest_screenshotMatcher: screenshotMatcher as typeof screenshotMatcher, + __vitest_route_register: registerRoute as typeof registerRoute, + __vitest_route_unregister: unregisterRoute as typeof unregisterRoute, + __vitest_route_reset: resetRoutes as typeof resetRoutes, } diff --git a/packages/browser/src/node/commands/route.ts b/packages/browser/src/node/commands/route.ts new file mode 100644 index 000000000000..d3c42f722d8b --- /dev/null +++ b/packages/browser/src/node/commands/route.ts @@ -0,0 +1,40 @@ +import type { BrowserCommand } from 'vitest/node' + +export type SerializedRouteMatcher + = | { type: 'string'; value: string } + | { type: 'regexp'; value: string; flags: string } + +export interface RouteRegisterPayload { + id: string + matcher: SerializedRouteMatcher +} + +interface RouteCapableProvider { + registerRoute?: (sessionId: string, payload: RouteRegisterPayload) => Promise + unregisterRoute?: (sessionId: string, id: string) => Promise + resetRoutes?: (sessionId: string) => Promise +} + +export const register: BrowserCommand<[RouteRegisterPayload]> = async (context, payload) => { + const provider = context.provider as RouteCapableProvider + if (!provider.registerRoute) { + throw new Error('The current browser provider does not support route interception.') + } + await provider.registerRoute(context.sessionId, payload) +} + +export const unregister: BrowserCommand<[string]> = async (context, id) => { + const provider = context.provider as RouteCapableProvider + if (!provider.unregisterRoute) { + throw new Error('The current browser provider does not support route interception.') + } + await provider.unregisterRoute(context.sessionId, id) +} + +export const reset: BrowserCommand<[]> = async (context) => { + const provider = context.provider as RouteCapableProvider + if (!provider.resetRoutes) { + throw new Error('The current browser provider does not support route interception.') + } + await provider.resetRoutes(context.sessionId) +} diff --git a/test/browser/test/route.test.ts b/test/browser/test/route.test.ts new file mode 100644 index 000000000000..acddcee7433f --- /dev/null +++ b/test/browser/test/route.test.ts @@ -0,0 +1,29 @@ +import { afterEach, expect, it } from 'vitest' +import { page } from 'vitest/browser' + +afterEach(async () => { + await page.unrouteAll() +}) + +it('fulfills intercepted requests', async () => { + await page.route(/\/api\/route-fulfill$/, (route) => { + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ message: 'stubbed' }), + }) + }) + + const response = await fetch('/api/route-fulfill') + expect(response.status).toBe(201) + const data = await response.json() + expect(data).toEqual({ message: 'stubbed' }) +}) + +it('aborts intercepted requests', async () => { + await page.route(/\/api\/route-abort$/, (route) => { + route.abort() + }) + + await expect(fetch('/api/route-abort')).rejects.toBeInstanceOf(Error) +})