diff --git a/docs/src/mock.md b/docs/src/mock.md index 50bc3915ceef8..9081c4aa2cd40 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -554,3 +554,226 @@ await page.RouteWebSocketAsync("wss://example.com/ws", ws => { ``` For more details, see [WebSocketRoute]. + +## Mock Server +* langs: js + +By default, Playwright only has access to the network traffic made by the browser. +To mock and intercept traffic made by the application server, use Playwright's mocking proxy. +How to do this differs for each application. This section explains the moving parts that you can use to embed it in any application. Skip forward to find recipes for Next.js, Remix and Angular. + +Playwright's mocking proxy is an HTTP proxy server that's connected to the currently running test. If you send it a request, it will apply the network routes configured via `page.route` and `context.route`, allowing you to reuse your existing browser routes. + +For browser network mocking, Playwright always knows what browser context and page a request is coming from. But because there's only a single application server shared by multiple concurrent test runs, it cannot know this for server requests! To resolve this, pick one of these two strategies: + +1. [Disable parallelism](./test-parallel.md#disable-parallelism), so that there's only a single test at a time. +2. On the server, read the `x-playwright-proxy` header of incoming requests. When the mocking proxy is configured, Playwright adds this header to all browser requests. + +The second strategy can be hard to integrate for some applications, because it requires access to the current request from where you're making your API requests. +If this is possible in your application, this is the recommended approach. +If it isn't, then go with disabling parallelism. It will slow down your test execution, but will make the proxy configuration easier because there will be only a single proxy running, on a port that is hardcoded. + +Putting this together, figuring out what proxy to funnel a request should look something like this in your application: + +```js +const proxyUrl = `http://localhost:8123/`; // 1: Disable Parallelism + hardcode port OR +const proxyUrl = decodeURIComponent(currentHeaders.get('x-playwright-proxy') ?? ''); // 2: Inject proxy port +``` + +And this is the Playwright config to go with it: + +```ts +// playwright.config.ts +// 1: Disable Parallelism + hardcode port +export default defineConfig({ + workers: 1, + use: { mockingProxy: { port: 8123 } } +}); + +// 2: Inject proxy port +export default defineConfig({ + use: { mockingProxy: { port: 'inject' } } +}); +``` + +After figuring out what proxy to send traffic to, you need to direct traffic through it. To do so, prepend the proxy URL to all outgoing HTTP requests: + +```js +await fetch(proxyUrl + 'https://api.example.com/users'); +``` + +That's it! Your `context.route` and `page.route` methods can now intercept network traffic from your server: + +```ts +// shopping-cart.spec.ts +import { test, expect } from '@playwright/test' + +test('checkout applies customer loyalty bonus points', async ({ page }) => { + await page.route('https://users.internal.example.com/loyalty/balance*', (route, request) => { + await route.fulfill({ json: { userId: 'jane@doe.com', balance: 100 } }); + }) + + await page.goto('http://localhost:3000/checkout'); + + await expect(page.getByRole('list')).toMatchAriaSnapshot(` + - list "Cart": + - listitem: Super Duper Hammer + - listitem: Nails + - listitem: 16mm Birch Plywood + - text: "Price after applying 10$ loyalty discount: 79.99$" + - button "Buy now" + `); +}); +``` + +Prepending the proxy URL manually to all outgoing requests can be cumbersome. If your HTTP client supports it, consider updating your client baseURL ... + +```js +import { axios } from 'axios'; + +const api = axios.create({ + baseURL: proxyUrl + 'https://jsonplaceholder.typicode.com', +}); +``` + +... or setting up a global interceptor: + +```js +import { axios } from 'axios'; + +axios.interceptors.request.use(async config => { + config.proxy = { protocol: 'http', host: 'localhost', port: 8123 }; + return config; +}); +``` + +```js +import { setGlobalDispatcher, getGlobalDispatcher } from 'undici'; + +const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { + opts.path = opts.origin + opts.path; + opts.origin = `http://localhost:8123`; + return dispatch(opts, handler); +}) +setGlobalDispatcher(proxyingDispatcher); // this will also apply to global fetch +``` + +:::note +Note that this style of proxying, where the proxy URL is prended to the request URL, does *not* use [`CONNECT`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT), which is the common way of establishing a proxy connection. +This is because for HTTPS requests, a `CONNECT` proxy does not have access to the proxied traffic. That's great behaviour for a production proxy, but counteracts network interception! +::: + + +### Recipes +* langs: js + +#### Next.js +* langs: js + +Monkey-patch `globalThis.fetch` in your `instrumentation.ts` file: + +```ts +// instrumentation.ts + +import { headers } from 'next/headers' + +export function register() { + if (process.env.NODE_ENV === 'test') { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const proxy = (await headers()).get('x-playwright-proxy'); + if (!proxy) + return originalFetch(input, init); + const request = new Request(input, init); + return originalFetch(decodeURIComponent(proxy) + request.url, request); + }; + } +} +``` + +#### Remix +* langs: js + + +Monkey-patch `globalThis.fetch` in your `entry.server.ts` file, and use `AsyncLocalStorage` to make current request headers available: + +```ts +import { setGlobalDispatcher, getGlobalDispatcher } from 'undici'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +const headersStore = new AsyncLocalStorage(); +if (process.env.NODE_ENV === 'test') { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const proxy = headersStore.getStore()?.get('x-playwright-proxy'); + if (!proxy) + return originalFetch(input, init); + const request = new Request(input, init); + return originalFetch(decodeURIComponent(proxy) + request.url, request); + }; +} + +export default function handleRequest(request: Request, /* ... */) { + return headersStore.run(request.headers, () => { + // ... + return handleBrowserRequest(request, /* ... */); + }) +} +``` + +#### Angular +* langs: js + +Configure your `HttpClient` with an [interceptor](https://angular.dev/guide/http/setup#withinterceptors): + +```ts +// app.config.server.ts + +import { inject, REQUEST } from '@angular/core'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; + +const serverConfig = { + providers: [ + /* ... */ + provideHttpClient( + /* ... */, + withInterceptors([ + (req, next) => { + const proxy = inject(REQUEST)?.headers.get('x-playwright-proxy'); + if (proxy) + req = req.clone({ url: decodeURIComponent(proxy) + req.url }) + return next(req); + }, + ]) + ) + ] +}; + +/* ... */ +``` + +```ts +// playwright.config.ts +export default defineConfig({ + use: { mockingProxy: { port: 'inject' } } +}); +``` + +#### `.env` file +* langs: js + +If your application uses `.env` files to configure API endpoints, you can configure the proxy by prepending them with the proxy URL: + +```bash +# .env.test +CMS_BASE_URL=http://localhost:8123/https://cms.example.com/api/ +USERS_SERVICE_BASE_URL=http://localhost:8123/https://users.internal.api.example.com/ +``` + +```ts +// playwright.config.ts +export default defineConfig({ + workers: 1, + use: { mockingProxy: { port: 8123 } } +}); +``` diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index c2854306164ad..91fc733adc352 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -676,3 +676,22 @@ export default defineConfig({ }, }); ``` + +## property: TestOptions.mockingProxy +* since: v1.51 +- type: <[Object]> + - `port` <[int]|"inject"> What port to start the mocking proxy on. If set to `"inject"`, Playwright will use a free port and inject the proxy URL into all outgoing requests under the `x-playwright-proxy` header. + +**Usage** + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + use: { + mockingProxy: { + port: 9956, + }, + }, +}); +``` \ No newline at end of file diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 5ff432ec60a1d..73743ea21dedb 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -44,6 +44,7 @@ import { Dialog } from './dialog'; import { WebError } from './webError'; import { TargetClosedError, parseError } from './errors'; import { Clock } from './clock'; +import type { MockingProxy } from './mockingProxy'; export class BrowserContext extends ChannelOwner implements api.BrowserContext { _pages = new Set(); @@ -68,6 +69,7 @@ export class BrowserContext extends ChannelOwner _closeWasCalled = false; private _closeReason: string | undefined; private _harRouters: HarRouter[] = []; + _mockingProxy?: MockingProxy; static from(context: channels.BrowserContextChannel): BrowserContext { return (context as any)._object; @@ -90,7 +92,11 @@ export class BrowserContext extends ChannelOwner this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding))); this._channel.on('close', () => this._onClose()); this._channel.on('page', ({ page }) => this._onPage(Page.from(page))); - this._channel.on('route', ({ route }) => this._onRoute(network.Route.from(route))); + this._channel.on('route', params => { + const route = network.Route.from(params.route); + route._context = this.request; + this._onRoute(route); + }); this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute))); this._channel.on('backgroundPage', ({ page }) => { const backgroundPage = Page.from(page); @@ -198,7 +204,6 @@ export class BrowserContext extends ChannelOwner } async _onRoute(route: network.Route) { - route._context = this; const page = route.request()._safePage(); const routeHandlers = this._routes.slice(); for (const routeHandler of routeHandlers) { @@ -223,6 +228,14 @@ export class BrowserContext extends ChannelOwner await route._innerContinue(true /* isFallback */).catch(() => {}); } + private _onRouteListener = (route: network.Route) => { + const subject = + route.request()._safePage() + ?? this.pages()[0] // Fallback to the first page if no page is associated with the request. This should be the `page` fixture. + ?? this; + subject._onRoute(route); + }; + async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) { const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url())); if (routeHandler) @@ -238,6 +251,14 @@ export class BrowserContext extends ChannelOwner await bindingCall.call(func); } + async _subscribeToMockingProxy(mockingProxy: MockingProxy) { + if (this._mockingProxy) + throw new Error('Multiple mocking proxies are not supported'); + this._mockingProxy = mockingProxy; + this._mockingProxy.on(Events.MockingProxy.Route, this._onRouteListener); + await this.route('**', (route: network.Route) => this._mockingProxy!.instrumentBrowserRequest(route)); + } + setDefaultNavigationTimeout(timeout: number | undefined) { this._timeoutSettings.setDefaultNavigationTimeout(timeout); this._wrapApiCall(async () => { @@ -400,6 +421,7 @@ export class BrowserContext extends ChannelOwner private async _updateInterceptionPatterns() { const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes); await this._channel.setNetworkInterceptionPatterns({ patterns }); + await this._mockingProxy?.setInterceptionPatterns({ patterns }); } private async _updateWebSocketInterceptionPatterns() { @@ -457,6 +479,7 @@ export class BrowserContext extends ChannelOwner this._disposeHarRouters(); this.tracing._resetStackCounter(); this.emit(Events.BrowserContext.Close, this); + this._mockingProxy?.off(Events.MockingProxy.Route, this._onRouteListener); } async [Symbol.asyncDispose]() { diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index 375a84265f33e..46feba1879003 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -45,6 +45,7 @@ import { findValidator, ValidationError, type ValidatorContext } from '../protoc import { createInstrumentation } from './clientInstrumentation'; import type { ClientInstrumentation } from './clientInstrumentation'; import { formatCallLog, rewriteErrorMessage, zones } from '../utils'; +import { MockingProxy } from './mockingProxy'; class Root extends ChannelOwner { constructor(connection: Connection) { @@ -279,6 +280,9 @@ export class Connection extends EventEmitter { if (!this._localUtils) this._localUtils = result as LocalUtils; break; + case 'MockingProxy': + result = new MockingProxy(parent, type, guid, initializer); + break; case 'Page': result = new Page(parent, type, guid, initializer); break; diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index a074b26f3d581..3bf4040ad343b 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -94,4 +94,8 @@ export const Events = { Console: 'console', Window: 'window', }, + + MockingProxy: { + Route: 'route', + }, }; diff --git a/packages/playwright-core/src/client/mockingProxy.ts b/packages/playwright-core/src/client/mockingProxy.ts new file mode 100644 index 0000000000000..5d21be65ee9ce --- /dev/null +++ b/packages/playwright-core/src/client/mockingProxy.ts @@ -0,0 +1,92 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as network from './network'; +import type * as channels from '@protocol/channels'; +import { ChannelOwner } from './channelOwner'; +import { APIRequestContext } from './fetch'; +import { Events } from './events'; +import { assert } from '../utils'; + +export class MockingProxy extends ChannelOwner { + private _browserRequests = new Map(); + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.MockingProxyInitializer) { + super(parent, type, guid, initializer); + + const requestContext = APIRequestContext.from(initializer.requestContext); + this._channel.on('route', async (params: channels.MockingProxyRouteEvent) => { + const route = network.Route.from(params.route); + route._context = requestContext; + this.emit(Events.MockingProxy.Route, route); + }); + + this._channel.on('request', async (params: channels.MockingProxyRequestEvent) => { + const request = network.Request.from(params.request); + if (params.correlation) { + const browserRequest = this._browserRequests.get(params.correlation); + this._browserRequests.delete(params.correlation); + assert(browserRequest); + request._frame = browserRequest._frame; + } + }); + + this._channel.on('requestFailed', async (params: channels.MockingProxyRequestFailedEvent) => { + const request = network.Request.from(params.request); + request._failureText = params.failureText ?? null; + request._setResponseEndTiming(params.responseEndTiming); + }); + + this._channel.on('requestFinished', async (params: channels.MockingProxyRequestFinishedEvent) => { + const { responseEndTiming } = params; + const request = network.Request.from(params.request); + const response = network.Response.fromNullable(params.response); + request._setResponseEndTiming(responseEndTiming); + response?._finishedPromise.resolve(null); + }); + + this._channel.on('response', async (params: channels.MockingProxyResponseEvent) => { + // no-op + }); + } + + async setInterceptionPatterns(params: channels.MockingProxySetInterceptionPatternsParams) { + await this._channel.setInterceptionPatterns(params); + } + + async instrumentBrowserRequest(route: network.Route) { + const isSimpleCORS = false; // TODO: implement simple CORS + if (isSimpleCORS) + return await route.fallback(); + + const request = route.request(); + const correlation = request._guid.split('@')[1]; + this._browserRequests.set(correlation, request); + + void request.response() + .then(response => response?.finished()) + .catch(() => {}) + .finally(() => this._browserRequests.delete(correlation)); + + const proxyUrl = `http://localhost:${this.port()}/pw_meta:${correlation}/`; + + await route.fallback({ headers: { 'x-playwright-proxy': encodeURIComponent(proxyUrl) } }); + } + + port(): number { + return this._initializer.port; + } + +} diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index a6b40307b3fd2..831dcadc608d1 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -30,9 +30,9 @@ import type { Page } from './page'; import { Waiter } from './waiter'; import type * as api from '../../types/types'; import type { HeadersArray } from '../common/types'; +import type { APIRequestContext } from './fetch'; import { APIResponse } from './fetch'; import type { Serializable } from '../../types/structs'; -import type { BrowserContext } from './browserContext'; import { isTargetClosedError } from './errors'; export type NetworkCookie = { @@ -86,6 +86,7 @@ export class Request extends ChannelOwner implements ap private _actualHeadersPromise: Promise | undefined; _timing: ResourceTiming; private _fallbackOverrides: SerializedFallbackOverrides = {}; + _frame: Frame | null = null; static from(request: channels.RequestChannel): Request { return (request as any)._object; @@ -102,6 +103,7 @@ export class Request extends ChannelOwner implements ap if (this._redirectedFrom) this._redirectedFrom._redirectedTo = this; this._provisionalHeaders = new RawHeaders(initializer.headers); + this._frame = Frame.fromNullable(initializer.frame); this._timing = { startTime: 0, domainLookupStart: -1, @@ -200,23 +202,22 @@ export class Request extends ChannelOwner implements ap } frame(): Frame { - if (!this._initializer.frame) { + if (!this._frame) { assert(this.serviceWorker()); throw new Error('Service Worker requests do not have an associated frame.'); } - const frame = Frame.from(this._initializer.frame); - if (!frame._page) { + if (!this._frame._page) { throw new Error([ 'Frame for this navigation request is not available, because the request', 'was issued before the frame is created. You can check whether the request', 'is a navigation request by calling isNavigationRequest() method.', ].join('\n')); } - return frame; + return this._frame; } _safePage(): Page | null { - return Frame.fromNullable(this._initializer.frame)?._page || null; + return this._frame?._page || null; } serviceWorker(): Worker | null { @@ -291,7 +292,7 @@ export class Request extends ChannelOwner implements ap export class Route extends ChannelOwner implements api.Route { private _handlingPromise: ManualPromise | null = null; - _context!: BrowserContext; + _context!: APIRequestContext; _didThrow: boolean = false; static from(route: channels.RouteChannel): Route { @@ -339,7 +340,7 @@ export class Route extends ChannelOwner implements api.Ro async fetch(options: FallbackOverrides & { maxRedirects?: number, maxRetries?: number, timeout?: number } = {}): Promise { return await this._wrapApiCall(async () => { - return await this._context.request._innerFetch({ request: this.request(), data: options.postData, ...options }); + return await this._context._innerFetch({ request: this.request(), data: options.postData, ...options }); }); } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index f1d90fece24ca..4f00e806649c2 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -138,7 +138,11 @@ export class Page extends ChannelOwner implements api.Page this._channel.on('frameAttached', ({ frame }) => this._onFrameAttached(Frame.from(frame))); this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(Frame.from(frame))); this._channel.on('locatorHandlerTriggered', ({ uid }) => this._onLocatorHandlerTriggered(uid)); - this._channel.on('route', ({ route }) => this._onRoute(Route.from(route))); + this._channel.on('route', params => { + const route = Route.from(params.route); + route._context = this.context().request; + this._onRoute(route); + }); this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(WebSocketRoute.from(webSocketRoute))); this._channel.on('video', ({ artifact }) => { const artifactObject = Artifact.from(artifact); @@ -179,8 +183,7 @@ export class Page extends ChannelOwner implements api.Page this.emit(Events.Page.FrameDetached, frame); } - private async _onRoute(route: Route) { - route._context = this.context(); + async _onRoute(route: Route) { const routeHandlers = this._routes.slice(); for (const routeHandler of routeHandlers) { // If the page was closed we stall all requests right away. diff --git a/packages/playwright-core/src/protocol/debug.ts b/packages/playwright-core/src/protocol/debug.ts index c58c3e4aafd7c..64a442450b5e1 100644 --- a/packages/playwright-core/src/protocol/debug.ts +++ b/packages/playwright-core/src/protocol/debug.ts @@ -66,6 +66,7 @@ export const slowMoActions = new Set([ export const commandsWithTracingSnapshots = new Set([ 'EventTarget.waitForEventInfo', + 'MockingProxy.waitForEventInfo', 'BrowserContext.waitForEventInfo', 'Page.waitForEventInfo', 'WebSocket.waitForEventInfo', diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 50e8b4f02ae63..604b8ea50f927 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -226,6 +226,29 @@ scheme.APIResponse = tObject({ headers: tArray(tType('NameValue')), }); scheme.LifecycleEvent = tEnum(['load', 'domcontentloaded', 'networkidle', 'commit']); +scheme.EventTargetInitializer = tOptional(tObject({})); +scheme.EventTargetWaitForEventInfoParams = tObject({ + info: tObject({ + waitId: tString, + phase: tEnum(['before', 'after', 'log']), + event: tOptional(tString), + message: tOptional(tString), + error: tOptional(tString), + }), +}); +scheme.MockingProxyWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.BrowserContextWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.PageWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.WebSocketWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.ElectronApplicationWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.AndroidDeviceWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.EventTargetWaitForEventInfoResult = tOptional(tObject({})); +scheme.MockingProxyWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.BrowserContextWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.PageWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.WebSocketWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.ElectronApplicationWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.AndroidDeviceWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.LocalUtilsInitializer = tObject({ deviceDescriptors: tArray(tObject({ name: tString, @@ -313,6 +336,45 @@ scheme.LocalUtilsTraceDiscardedParams = tObject({ stacksId: tString, }); scheme.LocalUtilsTraceDiscardedResult = tOptional(tObject({})); +scheme.LocalUtilsNewMockingProxyParams = tObject({ + port: tOptional(tNumber), +}); +scheme.LocalUtilsNewMockingProxyResult = tObject({ + mockingProxy: tChannel(['MockingProxy']), +}); +scheme.MockingProxyInitializer = tObject({ + port: tNumber, + requestContext: tChannel(['APIRequestContext']), +}); +scheme.MockingProxyRouteEvent = tObject({ + route: tChannel(['Route']), +}); +scheme.MockingProxyRequestEvent = tObject({ + request: tChannel(['Request']), + correlation: tOptional(tString), +}); +scheme.MockingProxyRequestFailedEvent = tObject({ + request: tChannel(['Request']), + failureText: tOptional(tString), + responseEndTiming: tNumber, +}); +scheme.MockingProxyRequestFinishedEvent = tObject({ + request: tChannel(['Request']), + response: tOptional(tChannel(['Response'])), + responseEndTiming: tNumber, +}); +scheme.MockingProxyResponseEvent = tObject({ + response: tChannel(['Response']), + page: tOptional(tChannel(['Page'])), +}); +scheme.MockingProxySetInterceptionPatternsParams = tObject({ + patterns: tArray(tObject({ + glob: tOptional(tString), + regexSource: tOptional(tString), + regexFlags: tOptional(tString), + })), +}); +scheme.MockingProxySetInterceptionPatternsResult = tOptional(tObject({})); scheme.RootInitializer = tOptional(tObject({})); scheme.RootInitializeParams = tObject({ sdkLanguage: tEnum(['javascript', 'python', 'java', 'csharp']), @@ -780,27 +842,6 @@ scheme.BrowserStopTracingParams = tOptional(tObject({})); scheme.BrowserStopTracingResult = tObject({ artifact: tChannel(['Artifact']), }); -scheme.EventTargetInitializer = tOptional(tObject({})); -scheme.EventTargetWaitForEventInfoParams = tObject({ - info: tObject({ - waitId: tString, - phase: tEnum(['before', 'after', 'log']), - event: tOptional(tString), - message: tOptional(tString), - error: tOptional(tString), - }), -}); -scheme.BrowserContextWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); -scheme.PageWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); -scheme.WebSocketWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); -scheme.ElectronApplicationWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); -scheme.AndroidDeviceWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); -scheme.EventTargetWaitForEventInfoResult = tOptional(tObject({})); -scheme.BrowserContextWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); -scheme.PageWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); -scheme.WebSocketWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); -scheme.ElectronApplicationWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); -scheme.AndroidDeviceWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.BrowserContextInitializer = tObject({ isChromium: tBoolean, requestContext: tChannel(['APIRequestContext']), diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index ce10daf0139bb..fb91219d5b673 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -44,7 +44,7 @@ import { Clock } from './clock'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { RecorderApp } from './recorder/recorderApp'; -export abstract class BrowserContext extends SdkObject { +export abstract class BrowserContext extends SdkObject implements network.RequestContext { static Events = { Console: 'console', Close: 'close', diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index b6f8fe80ac793..1dcc7911550ac 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -41,8 +41,12 @@ import type { Playwright } from '../playwright'; import { SdkObject } from '../../server/instrumentation'; import { serializeClientSideCallMetadata } from '../../utils'; import { deviceDescriptors as descriptors } from '../deviceDescriptors'; +import type { APIRequestContext } from '../fetch'; +import { GlobalAPIRequestContext } from '../fetch'; +import { MockingProxy } from '../mockingProxy'; +import { MockingProxyDispatcher } from './mockingProxyDispatcher'; -export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel { +export class LocalUtilsDispatcher extends Dispatcher implements channels.LocalUtilsChannel { _type_LocalUtils: boolean; private _harBackends = new Map(); private _stackSessions = new Map(); + private _requestContext: APIRequestContext; constructor(scope: RootDispatcher, playwright: Playwright) { const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils'); const deviceDescriptors = Object.entries(descriptors) .map(([name, descriptor]) => ({ name, descriptor })); + + const requestContext = new GlobalAPIRequestContext(playwright, {}); super(scope, localUtils, 'LocalUtils', { deviceDescriptors, }); + this._requestContext = requestContext; this._type_LocalUtils = true; } @@ -273,6 +281,12 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. await removeFolders([session.tmpDir]); this._stackSessions.delete(stacksId!); } + + async newMockingProxy(params: channels.LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise { + const mockingProxy = new MockingProxy(this._object, this._requestContext); + await mockingProxy.start(params.port); + return { mockingProxy: MockingProxyDispatcher.from(this.parentScope(), mockingProxy) }; + } } const redirectStatus = [301, 302, 303, 307, 308]; @@ -295,7 +309,8 @@ class HarBackend { redirectURL?: string, status?: number, headers?: HeadersArray, - body?: Buffer }> { + body?: Buffer + }> { let entry; try { entry = await this._harFindResponse(url, method, headers, postData); diff --git a/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts new file mode 100644 index 0000000000000..3cd6a199182b8 --- /dev/null +++ b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { CallMetadata } from '@protocol/callMetadata'; +import { MockingProxy } from '../mockingProxy'; +import type { RootDispatcher } from './dispatcher'; +import { Dispatcher, existingDispatcher } from './dispatcher'; +import type * as channels from '@protocol/channels'; +import { APIRequestContextDispatcher, RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers'; +import type { Request, Route } from '../network'; +import { urlMatches } from '../../utils/isomorphic/urlMatch'; + +export class MockingProxyDispatcher extends Dispatcher implements channels.MockingProxyChannel { + _type_MockingProxy = true; + _type_EventTarget = true; + + static from(scope: RootDispatcher, mockingProxy: MockingProxy): MockingProxyDispatcher { + return existingDispatcher(mockingProxy) || new MockingProxyDispatcher(scope, mockingProxy); + } + + private constructor(scope: RootDispatcher, mockingProxy: MockingProxy) { + super(scope, mockingProxy, 'MockingProxy', { + port: mockingProxy.port, + requestContext: APIRequestContextDispatcher.from(scope, mockingProxy.fetchRequest), + }); + + this.addObjectListener(MockingProxy.Events.Route, (route: Route) => { + const requestDispatcher = RequestDispatcher.from(this as any, route.request()); + this._dispatchEvent('route', { route: RouteDispatcher.from(requestDispatcher, route) }); + }); + this.addObjectListener(MockingProxy.Events.Request, ({ request, correlation }: { request: Request, correlation?: string }) => { + this._dispatchEvent('request', { request: RequestDispatcher.from(this as any, request), correlation }); + }); + this.addObjectListener(MockingProxy.Events.RequestFailed, (request: Request) => { + this._dispatchEvent('requestFailed', { + request: RequestDispatcher.from(this as any, request), + failureText: request._failureText ?? undefined, + responseEndTiming: request._responseEndTiming, + }); + }); + this.addObjectListener(MockingProxy.Events.RequestFinished, (request: Request) => { + this._dispatchEvent('requestFinished', { + request: RequestDispatcher.from(this as any, request), + response: ResponseDispatcher.fromNullable(this as any, request._existingResponse()), + responseEndTiming: request._responseEndTiming, + }); + }); + } + + async setInterceptionPatterns(params: channels.MockingProxySetInterceptionPatternsParams, metadata?: CallMetadata): Promise { + if (params.patterns.length === 0) + return this._object.setInterceptionPatterns(undefined); + + const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!); + this._object.setInterceptionPatterns(url => urlMatchers.some(urlMatch => urlMatches(undefined, url, urlMatch))); + } +} diff --git a/packages/playwright-core/src/server/mockingProxy.ts b/packages/playwright-core/src/server/mockingProxy.ts new file mode 100644 index 0000000000000..dfcd9dc538f73 --- /dev/null +++ b/packages/playwright-core/src/server/mockingProxy.ts @@ -0,0 +1,256 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import http from 'http'; +import https from 'https'; +import url from 'url'; +import type { APIRequestContext } from './fetch'; +import { SdkObject } from './instrumentation'; +import type { RequestContext, ResourceTiming, SecurityDetails } from './network'; +import { Request, Response, Route } from './network'; +import type { HeadersArray, } from './types'; +import { HttpServer, ManualPromise, monotonicTime } from '../utils'; +import { TLSSocket } from 'tls'; +import type { AddressInfo } from 'net'; +import { pipeline } from 'stream/promises'; +import { Transform } from 'stream'; + +export class MockingProxy extends SdkObject implements RequestContext { + static Events = { + Request: 'request', + Response: 'response', + Route: 'route', + RequestFailed: 'requestfailed', + RequestFinished: 'requestfinished', + }; + + fetchRequest: APIRequestContext; + private _matches?: (url: string) => boolean; + private _httpServer = new WorkerHttpServer(); + + constructor(parent: SdkObject, requestContext: APIRequestContext) { + super(parent, 'MockingProxy'); + this.fetchRequest = requestContext; + + this._httpServer.routePrefix('/', (req, res) => { + this._proxy(req, res); + return true; + }); + this._httpServer.server().on('connect', (req, socket, head) => { + socket.end('HTTP/1.1 405 Method Not Allowed\r\n\r\n'); + }); + } + + async start(port?: number): Promise { + await this._httpServer.start({ port }); + } + + get port() { + return this._httpServer.port(); + } + + setInterceptionPatterns(matches?: (url: string) => boolean) { + this._matches = matches; + } + + private async _proxy(req: http.IncomingMessage, res: http.ServerResponse) { + if (req.url?.startsWith('/')) + req.url = req.url.substring(1); + + let correlation: string | undefined; + if (req.url?.startsWith('pw_meta:')) { + correlation = req.url.substring('pw_meta:'.length, req.url.indexOf('/')); + req.url = req.url.substring(req.url.indexOf('/') + 1); + } + + // Java URL likes removing double slashes from the pathname. + if (req.url?.startsWith('http:/') && !req.url?.startsWith('http://')) + req.url = req.url.replace('http:/', 'http://'); + if (req.url?.startsWith('https:/') && !req.url?.startsWith('https://')) + req.url = req.url.replace('https:/', 'https://'); + + delete req.headersDistinct.host; + const headers = headersArray(req); + const body = await collectBody(req); + const request = new Request(this, null, null, null, undefined, req.url!, '', req.method!, body, headers); + request.setRawRequestHeaders(headers); + this.emit(MockingProxy.Events.Request, { request, correlation }); + + const route = new Route(request, { + abort: async errorCode => { + req.destroy(errorCode ? new Error(errorCode) : undefined); + }, + continue: async overrides => { + const proxyUrl = url.parse(overrides?.url ?? req.url!); + const httpLib = proxyUrl.protocol === 'https:' ? https : http; + const proxyHeaders = overrides?.headers ?? headers; + const proxyMethod = overrides?.method ?? req.method; + const proxyBody = overrides?.postData ?? body; + + const startAt = monotonicTime(); + let connectEnd: number | undefined; + let connectStart: number | undefined; + let dnsLookupAt: number | undefined; + let tlsHandshakeAt: number | undefined; + let socketBytesReadStart = 0; + + return new Promise(resolve => { + const proxyReq = httpLib.request({ + ...proxyUrl, + headers: headersArrayToOutgoingHeaders(proxyHeaders), + method: proxyMethod, + }, async proxyRes => { + const responseStart = monotonicTime(); + const timings: ResourceTiming = { + startTime: startAt / 1000, + connectStart: connectStart ? (connectStart - startAt) : -1, + connectEnd: connectEnd ? (connectEnd - startAt) : -1, + domainLookupStart: -1, + domainLookupEnd: dnsLookupAt ? (dnsLookupAt - startAt) : -1, + requestStart: -1, + responseStart: (responseStart - startAt), + secureConnectionStart: tlsHandshakeAt ? (tlsHandshakeAt - startAt) : -1, + }; + + const socket = proxyRes.socket; + + let securityDetails: SecurityDetails | undefined; + if (socket instanceof TLSSocket) { + const peerCertificate = socket.getPeerCertificate(); + securityDetails = { + protocol: socket.getProtocol() ?? undefined, + subjectName: peerCertificate.subject.CN, + validFrom: new Date(peerCertificate.valid_from).getTime() / 1000, + validTo: new Date(peerCertificate.valid_to).getTime() / 1000, + issuer: peerCertificate.issuer.CN + }; + } + + const address = socket.address() as AddressInfo; + const responseBodyPromise = new ManualPromise(); + const response = new Response(request, proxyRes.statusCode!, proxyRes.statusMessage!, headersArray(proxyRes), timings, () => responseBodyPromise, false, proxyRes.httpVersion); + response.setRawResponseHeaders(headersArray(proxyRes)); + response._securityDetailsFinished(securityDetails); + response._serverAddrFinished({ ipAddress: address.family === 'IPv6' ? `[${address.address}]` : address.address, port: address.port }); + this.emit(MockingProxy.Events.Response, response); + + try { + res.writeHead(proxyRes.statusCode!, proxyRes.headers); + + const chunks: Buffer[] = []; + await pipeline( + proxyRes, + new Transform({ + transform(chunk, encoding, callback) { + chunks.push(chunk); + callback(undefined, chunk); + }, + }), + res + ); + const body = Buffer.concat(chunks); + responseBodyPromise.resolve(body); + + const transferSize = socket.bytesRead - socketBytesReadStart; + const encodedBodySize = body.byteLength; + response._requestFinished(monotonicTime() - startAt); + response.setTransferSize(transferSize); + response.setEncodedBodySize(encodedBodySize); + response.setResponseHeadersSize(transferSize - encodedBodySize); + this.emit(MockingProxy.Events.RequestFinished, request); + resolve(); + } catch (error) { + request._setFailureText('' + error); + this.emit(MockingProxy.Events.RequestFailed, request); + resolve(); + } + }); + + proxyReq.on('error', error => { + request._setFailureText('' + error); + this.emit(MockingProxy.Events.RequestFailed, request); + res.statusCode = 502; + res.end(resolve); + }); + proxyReq.once('socket', socket => { + if (proxyReq.reusedSocket) + return; + + socketBytesReadStart = socket.bytesRead; + + socket.once('lookup', () => { dnsLookupAt = monotonicTime(); }); + socket.once('connectionAttempt', () => { connectStart = monotonicTime(); }); + socket.once('connect', () => { connectEnd = monotonicTime(); }); + socket.once('secureConnect', () => { tlsHandshakeAt = monotonicTime(); }); + }); + proxyReq.end(proxyBody); + }); + }, + fulfill: async ({ status, headers, body, isBase64 }) => { + res.statusCode = status; + for (const { name, value } of headers) + res.appendHeader(name, value); + res.sendDate = false; + res.end(Buffer.from(body, isBase64 ? 'base64' : 'utf-8')); + }, + }); + + if (this._matches?.(req.url!)) + this.emit(MockingProxy.Events.Route, route); + else + await route.continue({ isFallback: false }); + } + + addRouteInFlight(route: Route): void { + // no-op, might be useful for warnings + } + + removeRouteInFlight(route: Route): void { + // no-op, might be useful for warnings + } +} + +function headersArray(req: Pick): HeadersArray { + return Object.entries(req.headersDistinct).flatMap(([name, values = []]) => values.map(value => ({ name, value }))); +} + +function headersArrayToOutgoingHeaders(headers: HeadersArray) { + const result: http.OutgoingHttpHeaders = {}; + for (const { name, value } of headers) { + if (result[name] === undefined) + result[name] = value; + else if (Array.isArray(result[name])) + result[name].push(value); + else + result[name] = [result[name] as string, value]; + } + return result; +} + +async function collectBody(req: http.IncomingMessage) { + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +export class WorkerHttpServer extends HttpServer { + override _handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean { + return false; + } +} diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 006f2f4cbff39..a6738ee0e7518 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import type * as contexts from './browserContext'; import type * as pages from './page'; import type * as frames from './frames'; import type * as types from './types'; @@ -88,6 +87,13 @@ export function stripFragmentFromUrl(url: string): string { return url.substring(0, url.indexOf('#')); } +export interface RequestContext extends SdkObject { + fetchRequest: APIRequestContext; + + addRouteInFlight(route: Route): void; + removeRouteInFlight(route: Route): void; +} + export class Request extends SdkObject { private _response: Response | null = null; private _redirectedFrom: Request | null; @@ -103,14 +109,14 @@ export class Request extends SdkObject { private _headersMap = new Map(); readonly _frame: frames.Frame | null = null; readonly _serviceWorker: pages.Worker | null = null; - readonly _context: contexts.BrowserContext; + readonly _context: RequestContext; private _rawRequestHeadersPromise = new ManualPromise(); private _waitForResponsePromise = new ManualPromise(); _responseEndTiming = -1; private _overrides: NormalizedContinueOverrides | undefined; private _bodySize: number | undefined; - constructor(context: contexts.BrowserContext, frame: frames.Frame | null, serviceWorker: pages.Worker | null, redirectedFrom: Request | null, documentId: string | undefined, + constructor(context: RequestContext, frame: frames.Frame | null, serviceWorker: pages.Worker | null, redirectedFrom: Request | null, documentId: string | undefined, url: string, resourceType: string, method: string, postData: Buffer | null, headers: HeadersArray) { super(frame || context, 'request'); assert(!url.startsWith('data:'), 'Data urls should not fire requests'); @@ -346,7 +352,7 @@ export class Route extends SdkObject { export type RouteHandler = (route: Route, request: Request) => boolean; -type GetResponseBodyCallback = () => Promise; +export type GetResponseBodyCallback = () => Promise; export type ResourceTiming = { startTime: number; diff --git a/packages/playwright-core/src/utils/httpServer.ts b/packages/playwright-core/src/utils/httpServer.ts index 1d78df465945d..f31222ce10dc5 100644 --- a/packages/playwright-core/src/utils/httpServer.ts +++ b/packages/playwright-core/src/utils/httpServer.ts @@ -213,13 +213,20 @@ export class HttpServer { readable.pipe(response); } - private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { + _handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean { if (request.method === 'OPTIONS') { response.writeHead(200); response.end(); - return; + return true; } + return false; + } + + private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { + if (this._handleCORS(request, response)) + return; + request.on('error', () => response.end()); try { if (!request.url) { diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 83913c18dc9c1..eec7ccfb93c70 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -24,7 +24,10 @@ import type { TestInfoImpl, TestStepInternal } from './worker/testInfo'; import { rootTestType } from './common/testType'; import type { ContextReuseMode } from './common/config'; import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation'; +import type { MockingProxy } from '../../playwright-core/src/client/mockingProxy'; +import type { BrowserContext as BrowserContextImpl } from '../../playwright-core/src/client/browserContext'; import { currentTestInfo } from './common/globals'; +import type { LocalUtils } from 'playwright-core/lib/client/localUtils'; export { expect } from './matchers/expect'; export const _baseTest: TestType<{}, {}> = rootTestType.test; @@ -54,6 +57,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _optionContextReuseMode: ContextReuseMode, _optionConnectOptions: PlaywrightWorkerOptions['connectOptions'], _reuseContext: boolean, + _mockingProxy?: MockingProxy, }; const playwrightFixtures: Fixtures = ({ @@ -71,6 +75,7 @@ const playwrightFixtures: Fixtures = ({ screenshot: ['off', { scope: 'worker', option: true }], video: ['off', { scope: 'worker', option: true }], trace: ['off', { scope: 'worker', option: true }], + mockingProxy: [undefined, { scope: 'worker', option: true }], _browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => { const options: LaunchOptions = { @@ -119,6 +124,20 @@ const playwrightFixtures: Fixtures = ({ }, true); }, { scope: 'worker', timeout: 0 }], + _mockingProxy: [async ({ mockingProxy: mockingProxyOption, playwright }, use) => { + if (!mockingProxyOption) + return await use(undefined); + + const testInfoImpl = test.info() as TestInfoImpl; + if (typeof mockingProxyOption.port === 'number' && testInfoImpl.config.workers > 1) + throw new Error(`Cannot share mocking proxy between multiple workers. Either disable parallel mode or set mockingProxy.port to 'inject'`); + + const port = mockingProxyOption.port === 'inject' ? undefined : mockingProxyOption.port; + const localUtils: LocalUtils = (playwright as any)._connection.localUtils(); + const { mockingProxy } = await localUtils._channel.newMockingProxy({ port }); + await use((mockingProxy as any)._object); + }, { scope: 'worker', box: true }], + acceptDownloads: [({ contextOptions }, use) => use(contextOptions.acceptDownloads ?? true), { option: true }], bypassCSP: [({ contextOptions }, use) => use(contextOptions.bypassCSP ?? false), { option: true }], colorScheme: [({ contextOptions }, use) => use(contextOptions.colorScheme === undefined ? 'light' : contextOptions.colorScheme), { option: true }], @@ -172,6 +191,7 @@ const playwrightFixtures: Fixtures = ({ baseURL, contextOptions, serviceWorkers, + _mockingProxy, }, use) => { const options: BrowserContextOptions = {}; if (acceptDownloads !== undefined) @@ -327,7 +347,7 @@ const playwrightFixtures: Fixtures = ({ }, { auto: 'all-hooks-included', title: 'trace recording', box: true, timeout: 0 } as any], - _contextFactory: [async ({ browser, video, _reuseContext, _combinedContextOptions /** mitigate dep-via-auto lack of traceability */ }, use, testInfo) => { + _contextFactory: [async ({ browser, video, _reuseContext, _mockingProxy, _combinedContextOptions /** mitigate dep-via-auto lack of traceability */ }, use, testInfo) => { const testInfoImpl = testInfo as TestInfoImpl; const videoMode = normalizeVideoMode(video); const captureVideo = shouldCaptureVideo(videoMode, testInfo) && !_reuseContext; @@ -348,7 +368,9 @@ const playwrightFixtures: Fixtures = ({ size: typeof video === 'string' ? undefined : video.size, } } : {}; - const context = await browser.newContext({ ...videoOptions, ...options }); + const context = await browser.newContext({ ...videoOptions, ...options }) as BrowserContextImpl; + if (_mockingProxy) + await context._subscribeToMockingProxy(_mockingProxy); const contextData: { pagesWithVideo: Page[] } = { pagesWithVideo: [] }; contexts.set(context, contextData); if (captureVideo) diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index fd42612df8946..2283c58bdeed6 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5892,6 +5892,12 @@ type ConnectOptions = { */ timeout?: number; }; +type MockingProxyOptions = { + /** + * What port to start the mocking proxy on. If set to `"inject"`, Playwright will use a free port and inject the proxy URL it into all outgoing requests under the `x-playwright-proxy` header. + */ + port: number | "inject"; +} /** * Playwright Test provides many options to configure test environment, @@ -6139,6 +6145,24 @@ export interface PlaywrightWorkerOptions { * Learn more about [recording video](https://playwright.dev/docs/test-use-options#recording-options). */ video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize }; + /** + * **Usage** + * + * ```js + * // playwright.config.ts + * import { defineConfig } from '@playwright/test'; + * + * export default defineConfig({ + * use: { + * mockingProxy: { + * port: 9956, + * }, + * }, + * }); + * ``` + * + */ + mockingProxy: MockingProxyOptions | undefined; } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure'; diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 526cc599ab89d..a6ae5d8cd503a 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -49,7 +49,6 @@ export type InitializerTraits = T extends FrameChannel ? FrameInitializer : T extends PageChannel ? PageInitializer : T extends BrowserContextChannel ? BrowserContextInitializer : - T extends EventTargetChannel ? EventTargetInitializer : T extends BrowserChannel ? BrowserInitializer : T extends BrowserTypeChannel ? BrowserTypeInitializer : T extends SelectorsChannel ? SelectorsInitializer : @@ -57,7 +56,9 @@ export type InitializerTraits = T extends DebugControllerChannel ? DebugControllerInitializer : T extends PlaywrightChannel ? PlaywrightInitializer : T extends RootChannel ? RootInitializer : + T extends MockingProxyChannel ? MockingProxyInitializer : T extends LocalUtilsChannel ? LocalUtilsInitializer : + T extends EventTargetChannel ? EventTargetInitializer : T extends APIRequestContextChannel ? APIRequestContextInitializer : object; @@ -87,7 +88,6 @@ export type EventsTraits = T extends FrameChannel ? FrameEvents : T extends PageChannel ? PageEvents : T extends BrowserContextChannel ? BrowserContextEvents : - T extends EventTargetChannel ? EventTargetEvents : T extends BrowserChannel ? BrowserEvents : T extends BrowserTypeChannel ? BrowserTypeEvents : T extends SelectorsChannel ? SelectorsEvents : @@ -95,7 +95,9 @@ export type EventsTraits = T extends DebugControllerChannel ? DebugControllerEvents : T extends PlaywrightChannel ? PlaywrightEvents : T extends RootChannel ? RootEvents : + T extends MockingProxyChannel ? MockingProxyEvents : T extends LocalUtilsChannel ? LocalUtilsEvents : + T extends EventTargetChannel ? EventTargetEvents : T extends APIRequestContextChannel ? APIRequestContextEvents : undefined; @@ -125,7 +127,6 @@ export type EventTargetTraits = T extends FrameChannel ? FrameEventTarget : T extends PageChannel ? PageEventTarget : T extends BrowserContextChannel ? BrowserContextEventTarget : - T extends EventTargetChannel ? EventTargetEventTarget : T extends BrowserChannel ? BrowserEventTarget : T extends BrowserTypeChannel ? BrowserTypeEventTarget : T extends SelectorsChannel ? SelectorsEventTarget : @@ -133,7 +134,9 @@ export type EventTargetTraits = T extends DebugControllerChannel ? DebugControllerEventTarget : T extends PlaywrightChannel ? PlaywrightEventTarget : T extends RootChannel ? RootEventTarget : + T extends MockingProxyChannel ? MockingProxyEventTarget : T extends LocalUtilsChannel ? LocalUtilsEventTarget : + T extends EventTargetChannel ? EventTargetEventTarget : T extends APIRequestContextChannel ? APIRequestContextEventTarget : undefined; @@ -404,6 +407,31 @@ export type APIResponse = { }; export type LifecycleEvent = 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; +// ----------- EventTarget ----------- +export type EventTargetInitializer = {}; +export interface EventTargetEventTarget { +} +export interface EventTargetChannel extends EventTargetEventTarget, Channel { + _type_EventTarget: boolean; + waitForEventInfo(params: EventTargetWaitForEventInfoParams, metadata?: CallMetadata): Promise; +} +export type EventTargetWaitForEventInfoParams = { + info: { + waitId: string, + phase: 'before' | 'after' | 'log', + event?: string, + message?: string, + error?: string, + }, +}; +export type EventTargetWaitForEventInfoOptions = { + +}; +export type EventTargetWaitForEventInfoResult = void; + +export interface EventTargetEvents { +} + // ----------- LocalUtils ----------- export type LocalUtilsInitializer = { deviceDescriptors: { @@ -438,6 +466,7 @@ export interface LocalUtilsChannel extends LocalUtilsEventTarget, Channel { tracingStarted(params: LocalUtilsTracingStartedParams, metadata?: CallMetadata): Promise; addStackToTracingNoReply(params: LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata): Promise; traceDiscarded(params: LocalUtilsTraceDiscardedParams, metadata?: CallMetadata): Promise; + newMockingProxy(params: LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise; } export type LocalUtilsZipParams = { zipFile: string, @@ -537,10 +566,76 @@ export type LocalUtilsTraceDiscardedOptions = { }; export type LocalUtilsTraceDiscardedResult = void; +export type LocalUtilsNewMockingProxyParams = { + port?: number, +}; +export type LocalUtilsNewMockingProxyOptions = { + port?: number, +}; +export type LocalUtilsNewMockingProxyResult = { + mockingProxy: MockingProxyChannel, +}; export interface LocalUtilsEvents { } +// ----------- MockingProxy ----------- +export type MockingProxyInitializer = { + port: number, + requestContext: APIRequestContextChannel, +}; +export interface MockingProxyEventTarget { + on(event: 'route', callback: (params: MockingProxyRouteEvent) => void): this; + on(event: 'request', callback: (params: MockingProxyRequestEvent) => void): this; + on(event: 'requestFailed', callback: (params: MockingProxyRequestFailedEvent) => void): this; + on(event: 'requestFinished', callback: (params: MockingProxyRequestFinishedEvent) => void): this; + on(event: 'response', callback: (params: MockingProxyResponseEvent) => void): this; +} +export interface MockingProxyChannel extends MockingProxyEventTarget, EventTargetChannel { + _type_MockingProxy: boolean; + setInterceptionPatterns(params: MockingProxySetInterceptionPatternsParams, metadata?: CallMetadata): Promise; +} +export type MockingProxyRouteEvent = { + route: RouteChannel, +}; +export type MockingProxyRequestEvent = { + request: RequestChannel, + correlation?: string, +}; +export type MockingProxyRequestFailedEvent = { + request: RequestChannel, + failureText?: string, + responseEndTiming: number, +}; +export type MockingProxyRequestFinishedEvent = { + request: RequestChannel, + response?: ResponseChannel, + responseEndTiming: number, +}; +export type MockingProxyResponseEvent = { + response: ResponseChannel, + page?: PageChannel, +}; +export type MockingProxySetInterceptionPatternsParams = { + patterns: { + glob?: string, + regexSource?: string, + regexFlags?: string, + }[], +}; +export type MockingProxySetInterceptionPatternsOptions = { + +}; +export type MockingProxySetInterceptionPatternsResult = void; + +export interface MockingProxyEvents { + 'route': MockingProxyRouteEvent; + 'request': MockingProxyRequestEvent; + 'requestFailed': MockingProxyRequestFailedEvent; + 'requestFinished': MockingProxyRequestFinishedEvent; + 'response': MockingProxyResponseEvent; +} + // ----------- Root ----------- export type RootInitializer = {}; export interface RootEventTarget { @@ -1460,31 +1555,6 @@ export interface BrowserEvents { 'close': BrowserCloseEvent; } -// ----------- EventTarget ----------- -export type EventTargetInitializer = {}; -export interface EventTargetEventTarget { -} -export interface EventTargetChannel extends EventTargetEventTarget, Channel { - _type_EventTarget: boolean; - waitForEventInfo(params: EventTargetWaitForEventInfoParams, metadata?: CallMetadata): Promise; -} -export type EventTargetWaitForEventInfoParams = { - info: { - waitId: string, - phase: 'before' | 'after' | 'log', - event?: string, - message?: string, - error?: string, - }, -}; -export type EventTargetWaitForEventInfoOptions = { - -}; -export type EventTargetWaitForEventInfoResult = void; - -export interface EventTargetEvents { -} - // ----------- BrowserContext ----------- export type BrowserContextInitializer = { isChromium: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index df54dcbe1ca0f..f908389428637 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -526,6 +526,28 @@ ContextOptions: - allow - block +EventTarget: + type: interface + + commands: + waitForEventInfo: + parameters: + info: + type: object + properties: + waitId: string + phase: + type: enum + literals: + - before + - after + - log + event: string? + message: string? + error: string? + flags: + snapshot: true + LocalUtils: type: interface @@ -647,6 +669,60 @@ LocalUtils: parameters: stacksId: string + newMockingProxy: + parameters: + port: number? + returns: + mockingProxy: MockingProxy + +MockingProxy: + type: interface + + extends: EventTarget + + initializer: + port: number + requestContext: APIRequestContext + + commands: + setInterceptionPatterns: + parameters: + patterns: + type: array + items: + type: object + properties: + glob: string? + regexSource: string? + regexFlags: string? + + events: + route: + parameters: + route: Route + + request: + parameters: + request: Request + correlation: string? + + requestFailed: + parameters: + request: Request + failureText: string? + responseEndTiming: number + + requestFinished: + parameters: + request: Request + response: Response? + responseEndTiming: number + + response: + parameters: + response: Response + page: Page? + Root: type: interface @@ -1030,29 +1106,6 @@ ConsoleMessage: lineNumber: number columnNumber: number - -EventTarget: - type: interface - - commands: - waitForEventInfo: - parameters: - info: - type: object - properties: - waitId: string - phase: - type: enum - literals: - - before - - after - - log - event: string? - message: string? - error: string? - flags: - snapshot: true - BrowserContext: type: interface diff --git a/tests/playwright-test/playwright.mockingproxy.spec.ts b/tests/playwright-test/playwright.mockingproxy.spec.ts new file mode 100644 index 0000000000000..441e6560ccd8f --- /dev/null +++ b/tests/playwright-test/playwright.mockingproxy.spec.ts @@ -0,0 +1,355 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './playwright-test-fixtures'; +import http from 'http'; + +test('inject mode', async ({ runInlineTest, server }) => { + server.setRoute('/page', (req, res) => { + res.end(req.headers['x-playwright-proxy'] ? 'proxy url injected' : 'proxy url missing'); + }); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: { port: 'inject' } + } + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('foo', async ({ page }) => { + await page.goto('${server.PREFIX}/page'); + expect(await page.textContent('body')).toEqual('proxy url injected'); + }); + ` + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('throws on fixed mocking proxy port and parallel workers', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: { port: 1234 } + } + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('foo', async ({}) => {}); + ` + }, { workers: 2 }); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Cannot share mocking proxy between multiple workers.'); +}); + +test('routes are reset between tests', async ({ runInlineTest, server, request }) => { + server.setRoute('/fallback', async (req, res) => { + res.end('fallback'); + }); + server.setRoute('/page', async (req, res) => { + const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? ''); + const response = await request.get(proxyURL + server.PREFIX + '/fallback'); + res.end(await response.body()); + }); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: { port: 'inject' } + } + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('first', async ({ page, request, context }) => { + await context.route('${server.PREFIX}/fallback', route => route.fulfill({ body: 'first' })); + await page.goto('${server.PREFIX}/page'); + expect(await page.textContent('body')).toEqual('first'); + }); + test('second', async ({ page, request, context }) => { + await context.route('${server.PREFIX}/fallback', route => route.fallback()); + await page.goto('${server.PREFIX}/page'); + expect(await page.textContent('body')).toEqual('fallback'); + }); + ` + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); +}); + +test('all properties are populated', async ({ runInlineTest, server, request }) => { + server.setRoute('/fallback', async (req, res) => { + res.statusCode = 201; + res.setHeader('foo', 'bar'); + res.end('fallback'); + }); + server.setRoute('/page', async (req, res) => { + const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? ''); + const response = await request.get(proxyURL + server.PREFIX + '/fallback'); + res.end(await response.body()); + }); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: { port: 'inject' } + } + }; + `, + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page, context }) => { + let request; + await context.route('${server.PREFIX}/fallback', route => { + request = route.request(); + route.continue(); + }); + await page.goto('${server.PREFIX}/page'); + expect(await page.textContent('body')).toEqual('fallback'); + + const response = await request.response(); + expect(request.url()).toBe('${server.PREFIX}/fallback'); + expect(response.url()).toBe('${server.PREFIX}/fallback'); + expect(response.status()).toBe(201); + expect(await response.headersArray()).toContainEqual({ name: 'foo', value: 'bar' }); + expect(await response.body()).toEqual(Buffer.from('fallback')); + + expect(await response.finished()).toBe(null); + expect(request.serviceWorker()).toBe(null); + expect(request.frame()).not.toBe(null); + + expect(request.failure()).toBe(null); + expect(request.isNavigationRequest()).toBe(false); + expect(request.redirectedFrom()).toBe(null); + expect(request.redirectedTo()).toBe(null); + expect(request.resourceType()).toBe(''); // TODO: should this be different? + expect(request.method()).toBe('GET'); + + expect(await request.sizes()).toEqual({ + requestBodySize: 0, + requestHeadersSize: 176, + responseBodySize: 8, + responseHeadersSize: 137, + }); + + expect(request.timing()).toEqual({ + 'connectEnd': expect.any(Number), + 'connectStart': expect.any(Number), + 'domainLookupEnd': expect.any(Number), + 'domainLookupStart': -1, + 'requestStart': expect.any(Number), + 'responseEnd': expect.any(Number), + 'responseStart': expect.any(Number), + 'secureConnectionStart': -1, + 'startTime': expect.any(Number), + }); + + expect(await response.securityDetails()).toBe(null); + expect(await response.serverAddr()).toEqual({ + ipAddress: expect.any(String), + port: expect.any(Number), + }); + }); + ` + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('securityDetails', async ({ httpsServer, request, runInlineTest }) => { + httpsServer.setRoute('/fallback', async (req, res) => { + res.statusCode = 201; + res.setHeader('foo', 'bar'); + res.end('fallback'); + }); + httpsServer.setRoute('/page', async (req, res) => { + const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? ''); + const response = await request.get(proxyURL + httpsServer.PREFIX + '/fallback', { ignoreHTTPSErrors: true }); + res.end(await response.body()); + }); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: { port: 'inject' }, + ignoreHTTPSErrors: true, + } + }; + `, + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page, context }) => { + let request; + await context.route('${httpsServer.PREFIX}/fallback', route => { + request = route.request(); + route.continue(); + }); + await page.goto('${httpsServer.PREFIX}/page'); + expect(await page.textContent('body')).toEqual('fallback'); + const response = await request.response(); + expect(await response.securityDetails()).toEqual({ + "issuer": "playwright-test", + "protocol": expect.any(String), + "subjectName": "playwright-test", + "validFrom": expect.any(Number), + "validTo": expect.any(Number) + }); + }); + ` + }, { workers: 1 }, { NODE_TLS_REJECT_UNAUTHORIZED: '0' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('aborting', async ({ runInlineTest, server }) => { + server.setRoute('/page', async (req, res) => { + const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? ''); + const request = http.get(proxyURL + server.PREFIX + '/fallback'); + request.on('error', () => res.end('aborted')); + request.pipe(res); + }); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: { port: 'inject' } + } + }; + `, + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page, context, request }) => { + await context.route('${server.PREFIX}/fallback', route => route.abort()); + const response = await request.get('${server.PREFIX}/page') + expect(await response.text()).toEqual('aborted'); + }); + ` + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('fetch', async ({ runInlineTest, server, request }) => { + server.setRoute('/fallback', async (req, res) => { + res.statusCode = 201; + res.setHeader('foo', 'bar'); + res.end('fallback'); + }); + server.setRoute('/page', async (req, res) => { + const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? ''); + const response = await request.get(proxyURL + server.PREFIX + '/fallback'); + res.end(await response.body()); + }); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: { port: 'inject' } + } + }; + `, + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page, context }) => { + let request; + await context.route('${server.PREFIX}/fallback', async route => { + route.fulfill({ response: await route.fetch() }); + }); + await page.goto('${server.PREFIX}/page'); + expect(await page.textContent('body')).toEqual('fallback'); + }); + ` + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('inject mode knows originating page', async ({ runInlineTest, server, request }) => { + server.setRoute('/fallback', async (req, res) => { + res.end('fallback'); + }); + server.setRoute('/page', async (req, res) => { + const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? ''); + const response = await request.get(proxyURL + server.PREFIX + '/fallback'); + res.end(await response.body()); + }); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: { port: 'inject' } + } + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('first', async ({ page, context }) => { + await page.route('${server.PREFIX}/fallback', route => route.fulfill({ body: 'first' })); + await page.goto('${server.PREFIX}/page'); + expect(await page.textContent('body')).toEqual('first'); + }); + ` + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('failure', async ({ runInlineTest, server, request }) => { + server.setRoute('/fallback', async (req, res) => { + res.socket.destroy(); + }); + server.setRoute('/page', async (req, res) => { + const proxyURL = decodeURIComponent((req.headers['x-playwright-proxy'] as string) ?? ''); + const response = await request.get(proxyURL + server.PREFIX + '/fallback'); + res.end(await response.body()); + }); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: { port: 'inject' } + } + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('first', async ({ page, context }) => { + let request; + await page.route('${server.PREFIX}/fallback', route => { + request = route.request(); + route.continue(); + }); + await page.goto('${server.PREFIX}/page'); + + expect(request.failure()).toEqual({ errorText: expect.any(String) }); + }); + ` + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 1bc980b42d605..cbefc4b5b1f11 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -225,6 +225,12 @@ type ConnectOptions = { */ timeout?: number; }; +type MockingProxyOptions = { + /** + * What port to start the mocking proxy on. If set to `"inject"`, Playwright will use a free port and inject the proxy URL it into all outgoing requests under the `x-playwright-proxy` header. + */ + port: number | "inject"; +} export interface PlaywrightWorkerOptions { browserName: BrowserName; @@ -236,6 +242,7 @@ export interface PlaywrightWorkerOptions { screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean, screenshots?: boolean, sources?: boolean, attachments?: boolean }; video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize }; + mockingProxy: MockingProxyOptions | undefined; } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';