From 156918e2d673c1f81bf09b148ad730c627f3e156 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 28 Jan 2025 16:32:23 +0100 Subject: [PATCH 01/22] feat(test runner): server side mocking fix linter revert unneeded change --- docs/src/mock.md | 176 +++++++++++ docs/src/test-api/class-testoptions.md | 16 + .../src/client/browserContext.ts | 31 +- .../playwright-core/src/client/connection.ts | 4 + packages/playwright-core/src/client/events.ts | 4 + .../src/client/mockingProxy.ts | 76 +++++ .../playwright-core/src/client/network.ts | 9 +- packages/playwright-core/src/client/page.ts | 9 +- .../playwright-core/src/protocol/debug.ts | 1 + .../playwright-core/src/protocol/validator.ts | 81 +++-- .../src/server/browserContext.ts | 2 +- .../dispatchers/localUtilsDispatcher.ts | 22 +- .../dispatchers/mockingProxyDispatcher.ts | 69 ++++ .../src/server/mockingProxy.ts | 264 ++++++++++++++++ .../playwright-core/src/server/network.ts | 14 +- .../playwright-core/src/utils/httpServer.ts | 11 +- packages/playwright/src/index.ts | 21 +- packages/playwright/types/test.d.ts | 16 + packages/protocol/src/channels.d.ts | 122 +++++-- packages/protocol/src/protocol.yml | 97 ++++-- .../playwright.mockingproxy.spec.ts | 298 ++++++++++++++++++ utils/generate_types/overrides-test.d.ts | 1 + 22 files changed, 1249 insertions(+), 95 deletions(-) create mode 100644 packages/playwright-core/src/client/mockingProxy.ts create mode 100644 packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts create mode 100644 packages/playwright-core/src/server/mockingProxy.ts create mode 100644 tests/playwright-test/playwright.mockingproxy.spec.ts diff --git a/docs/src/mock.md b/docs/src/mock.md index 50bc3915ce..873f25db7f 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -554,3 +554,179 @@ 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 **experimental** mocking proxy. Note this feature is **experimental** and subject to change. + +The mocking proxy is a 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`, reusing your existing browser routes. + +To get started, enable the `mockingProxy` option in your Playwright config: + +```ts +export default defineConfig({ + use: { mockingProxy: true } +}); +``` + +Playwright will now inject the proxy URL into all browser requests under the `x-playwright-proxy` header. +On your server, read the URL in this header and prepend it to all outgoing traffic you want to intercept: + +```js +const headers = getCurrentRequestHeaders(); // this looks different for each application +const proxyURL = decodeURIComponent(headers.get('x-playwright-proxy') ?? ''); +await fetch(proxyURL + 'https://api.example.com/users'); +``` + +Prepending the URL will direct the request through the proxy. You can now intercept it with `context.route` and `page.route`, just like browser requests: + +```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" + `); +}); +``` + +Now, prepending the proxy URL manually 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.baseURL = proxyURL + (config.baseURL ?? '/'); + return config; +}); +``` + +```js +import { setGlobalDispatcher, getGlobalDispatcher } from 'undici'; + +const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { + opts.path = opts.origin + opts.path; + opts.origin = proxyURL; + 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); + }, + ]), + ) + ] +}; + +/* ... */ +``` diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index c285430616..a7fa677a5d 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -676,3 +676,19 @@ export default defineConfig({ }, }); ``` + +## property: TestOptions.mockingProxy +* since: v1.51 +- type: <[boolean]> Enables the mocking proxy. Playwright will 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: true + }, +}); +``` \ 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 5ff432ec60..2dac5c809c 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -29,7 +29,8 @@ import { Events } from './events'; import { TimeoutSettings } from '../common/timeoutSettings'; import { Waiter } from './waiter'; import type { Headers, WaitForEventOptions, BrowserContextOptions, StorageState, LaunchOptions } from './types'; -import { type URLMatch, headersObjectToArray, isRegExp, isString, urlMatchesEqual, mkdirIfNeeded } from '../utils'; +import type { RegisteredListener } from '../utils'; +import { type URLMatch, headersObjectToArray, isRegExp, isString, urlMatchesEqual, mkdirIfNeeded, eventsHelper } from '../utils'; import type * as api from '../../types/types'; import type * as structs from '../../types/structs'; import { CDPSession } from './cdpSession'; @@ -44,6 +45,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 +70,8 @@ export class BrowserContext extends ChannelOwner _closeWasCalled = false; private _closeReason: string | undefined; private _harRouters: HarRouter[] = []; + private _registeredListeners: RegisteredListener[] = []; + _mockingProxy?: MockingProxy; static from(context: channels.BrowserContextChannel): BrowserContext { return (context as any)._object; @@ -90,7 +94,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); @@ -157,9 +165,10 @@ export class BrowserContext extends ChannelOwner this.tracing._tracesDir = browserOptions.tracesDir; } - private _onPage(page: Page): void { + private async _onPage(page: Page): Promise{ this._pages.add(page); this.emit(Events.BrowserContext.Page, page); + await this._mockingProxy?.instrumentPage(page); if (page._opener && !page._opener.isClosed()) page._opener.emit(Events.Page.Popup, page); } @@ -198,7 +207,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) { @@ -238,6 +246,19 @@ 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._registeredListeners.push( + eventsHelper.addEventListener(this._mockingProxy, Events.MockingProxy.Route, (route: network.Route) => { + const page = route.request()._safePage()!; + page._onRoute(route); + }), + // TODO: should we also emit `request`, `response`, `requestFinished`, `requestFailed` events? + ); + } + 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); + eventsHelper.removeEventListeners(this._registeredListeners); } async [Symbol.asyncDispose]() { diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index 375a84265f..46feba1879 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 a074b26f3d..3bf4040ad3 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 0000000000..91a7105f35 --- /dev/null +++ b/packages/playwright-core/src/client/mockingProxy.ts @@ -0,0 +1,76 @@ +/** + * 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'; +import type { Page } from './page'; + +export class MockingProxy extends ChannelOwner { + private _pages = 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 page = this._pages.get(params.correlation); + assert(page); + const request = network.Request.from(params.request); + request._page = page; + }); + + 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 instrumentPage(page: Page) { + const correlation = page._guid.split('@')[1]; + this._pages.set(correlation, page); + const proxyUrl = `http://localhost:${this._initializer.port}/pw_meta:${correlation}/`; + await page.setExtraHTTPHeaders({ + 'x-playwright-proxy': encodeURIComponent(proxyUrl) + }); + } + +} diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index a6b40307b3..302edb8a91 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 = {}; + _page: Page | null = null; static from(request: channels.RequestChannel): Request { return (request as any)._object; @@ -216,7 +217,7 @@ export class Request extends ChannelOwner implements ap } _safePage(): Page | null { - return Frame.fromNullable(this._initializer.frame)?._page || null; + return this._page ?? Frame.fromNullable(this._initializer.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 f1d90fece2..4f00e80664 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 c58c3e4aaf..64a442450b 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 cdcef14996..337288a7e4 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,43 @@ scheme.LocalUtilsTraceDiscardedParams = tObject({ stacksId: tString, }); scheme.LocalUtilsTraceDiscardedResult = tOptional(tObject({})); +scheme.LocalUtilsNewMockingProxyParams = tOptional(tObject({})); +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: 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 +840,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 ce10daf013..fb91219d5b 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 b6f8fe80ac..ff26a57a2e 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, + {} // TODO: this should probably respect _combinedContextOptions from test runner + ); super(scope, localUtils, 'LocalUtils', { deviceDescriptors, }); + this._requestContext = requestContext; this._type_LocalUtils = true; } @@ -273,6 +284,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(); + return { mockingProxy: MockingProxyDispatcher.from(this.parentScope(), mockingProxy) }; + } } const redirectStatus = [301, 302, 303, 307, 308]; @@ -295,7 +312,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 0000000000..315e8f83ef --- /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 0000000000..8c250572f1 --- /dev/null +++ b/packages/playwright-core/src/server/mockingProxy.ts @@ -0,0 +1,264 @@ +/** + * 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) => { + // TODO: improve error message + socket.end('HTTP/1.1 405 Method Not Allowed\r\n\r\n'); + }); + } + + async start(): Promise { + await this._httpServer.start(); + } + + 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); + + if (!req.url?.startsWith('pw_meta:')) { + res.statusCode = 400; + res.end('Playwright mocking proxy received invalid URL, must start with "pw_meta:"'); + return; + } + + const 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 (!correlation) + return await route.continue({ isFallback: false }); + + + 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 006f2f4cbf..a6738ee0e7 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 1d78df4659..f31222ce10 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 83913c18dc..3b75a3649e 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,14 @@ const playwrightFixtures: Fixtures = ({ }, true); }, { scope: 'worker', timeout: 0 }], + _mockingProxy: [async ({ mockingProxy: mockingProxyOption, playwright }, use) => { + if (!mockingProxyOption) + return await use(undefined); + const localUtils: LocalUtils = (playwright as any)._connection.localUtils(); + const { mockingProxy } = await localUtils._channel.newMockingProxy({}); + 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 }], @@ -247,7 +260,7 @@ const playwrightFixtures: Fixtures = ({ } }, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any], - _setupArtifacts: [async ({ playwright, screenshot }, use, testInfo) => { + _setupArtifacts: [async ({ playwright, screenshot, _mockingProxy }, use, testInfo) => { // This fixture has a separate zero-timeout slot to ensure that artifact collection // happens even after some fixtures or hooks time out. // Now that default test timeout is known, we can replace zero with an actual value. @@ -300,7 +313,9 @@ const playwrightFixtures: Fixtures = ({ if (!keepTestTimeout) currentTestInfo()?._setDebugMode(); }, - runAfterCreateBrowserContext: async (context: BrowserContext) => { + runAfterCreateBrowserContext: async (context: BrowserContextImpl) => { + if (_mockingProxy) + await context._subscribeToMockingProxy(_mockingProxy); await artifactsRecorder?.didCreateBrowserContext(context); const testInfo = currentTestInfo(); if (testInfo) @@ -348,7 +363,7 @@ 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; 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 61097c310e..67df822010 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -6157,6 +6157,22 @@ 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: true + * }, + * }); + * ``` + * + */ + mockingProxy: boolean | 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 30f8c03088..3acc370d66 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,72 @@ export type LocalUtilsTraceDiscardedOptions = { }; export type LocalUtilsTraceDiscardedResult = void; +export type LocalUtilsNewMockingProxyParams = {}; +export type LocalUtilsNewMockingProxyOptions = {}; +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 +1551,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 77501efa9b..9e4d18e5af 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,58 @@ LocalUtils: parameters: stacksId: string + newMockingProxy: + 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 +1104,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 0000000000..37f96984f4 --- /dev/null +++ b/tests/playwright-test/playwright.mockingproxy.spec.ts @@ -0,0 +1,298 @@ +/** + * 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'; + +const config = { + 'playwright.config.ts': ` + module.exports = { + use: { + mockingProxy: true, + ignoreHTTPSErrors: true, + } + }; + `, +}; + +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({ + ...config, + '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('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({ + ...config, + '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({ + ...config, + '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({ + ...config, + '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({ + ...config, + '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({ + ...config, + '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({ + ...config, + '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({ + ...config, + '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 1bc980b42d..b9c144a132 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -236,6 +236,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: boolean | undefined; } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure'; From 805d3072924afca3968b01e2c955944099596e66 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 28 Jan 2025 17:19:24 +0100 Subject: [PATCH 02/22] update frame assertion --- tests/playwright-test/playwright.mockingproxy.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/playwright-test/playwright.mockingproxy.spec.ts b/tests/playwright-test/playwright.mockingproxy.spec.ts index 37f96984f4..9389218281 100644 --- a/tests/playwright-test/playwright.mockingproxy.spec.ts +++ b/tests/playwright-test/playwright.mockingproxy.spec.ts @@ -110,7 +110,7 @@ test('all properties are populated', async ({ runInlineTest, server, request }) expect(await response.finished()).toBe(null); expect(request.serviceWorker()).toBe(null); - expect(request.frame()).not.toBe(null); + expect(request.frame()).toBe(null); // we know the page, but not the frame expect(request.failure()).toBe(null); expect(request.isNavigationRequest()).toBe(false); From 2d4b8a8c7a83db5bef0060f4c306d7b253128036 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 28 Jan 2025 17:29:41 +0100 Subject: [PATCH 03/22] fix tests --- packages/playwright-core/src/client/page.ts | 2 ++ tests/playwright-test/playwright.mockingproxy.spec.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 4f00e80664..88c392f3c9 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -568,6 +568,8 @@ export class Page extends ChannelOwner implements api.Page private async _updateInterceptionPatterns() { const patterns = RouteHandler.prepareInterceptionPatterns(this._routes); await this._channel.setNetworkInterceptionPatterns({ patterns }); + // TODO: merge this with browserContext patterns + await this._browserContext._mockingProxy?.setInterceptionPatterns({ patterns }); } private async _updateWebSocketInterceptionPatterns() { diff --git a/tests/playwright-test/playwright.mockingproxy.spec.ts b/tests/playwright-test/playwright.mockingproxy.spec.ts index 9389218281..d7598eb922 100644 --- a/tests/playwright-test/playwright.mockingproxy.spec.ts +++ b/tests/playwright-test/playwright.mockingproxy.spec.ts @@ -110,7 +110,7 @@ test('all properties are populated', async ({ runInlineTest, server, request }) expect(await response.finished()).toBe(null); expect(request.serviceWorker()).toBe(null); - expect(request.frame()).toBe(null); // we know the page, but not the frame + expect(() => request.frame()).toThrowError("Assertion error"); // we know the page, but not the frame. should probably improve the error message expect(request.failure()).toBe(null); expect(request.isNavigationRequest()).toBe(false); From e09f3bca06b71d1e222beb1eff687bbb447637cd Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 28 Jan 2025 18:19:26 +0100 Subject: [PATCH 04/22] don't assert on specific number --- tests/playwright-test/playwright.mockingproxy.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/playwright-test/playwright.mockingproxy.spec.ts b/tests/playwright-test/playwright.mockingproxy.spec.ts index d7598eb922..b5293ef26a 100644 --- a/tests/playwright-test/playwright.mockingproxy.spec.ts +++ b/tests/playwright-test/playwright.mockingproxy.spec.ts @@ -121,7 +121,7 @@ test('all properties are populated', async ({ runInlineTest, server, request }) expect(await request.sizes()).toEqual({ requestBodySize: 0, - requestHeadersSize: 176, + requestHeadersSize: expect.any(Number), responseBodySize: 8, responseHeadersSize: 137, }); From 660298440135c89dc4998fde980df1c1fa99c9fd Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 29 Jan 2025 09:03:58 +0100 Subject: [PATCH 05/22] merge with context routes --- packages/playwright-core/src/client/browserContext.ts | 10 +++++++++- packages/playwright-core/src/client/page.ts | 3 +-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 2dac5c809c..20d91bf305 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -421,7 +421,15 @@ 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 }); + await this._updateMockingProxyInterceptionPatterns(); + } + + async _updateMockingProxyInterceptionPatterns() { + if (!this._mockingProxy) + return; + const pageRoutes = this.pages().flatMap(page => page._routes); + const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes.concat(pageRoutes)); + await this._mockingProxy.setInterceptionPatterns({ patterns }); } private async _updateWebSocketInterceptionPatterns() { diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 88c392f3c9..2d7ecb61fd 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -568,8 +568,7 @@ export class Page extends ChannelOwner implements api.Page private async _updateInterceptionPatterns() { const patterns = RouteHandler.prepareInterceptionPatterns(this._routes); await this._channel.setNetworkInterceptionPatterns({ patterns }); - // TODO: merge this with browserContext patterns - await this._browserContext._mockingProxy?.setInterceptionPatterns({ patterns }); + await this._browserContext._updateMockingProxyInterceptionPatterns(); } private async _updateWebSocketInterceptionPatterns() { From 3ea46de5492197bbea58743d24d510117f7a7513 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 31 Jan 2025 16:27:10 +0100 Subject: [PATCH 06/22] add astro --- docs/src/mock.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/src/mock.md b/docs/src/mock.md index 873f25db7f..26b2f0927a 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -730,3 +730,47 @@ const serverConfig = { /* ... */ ``` + +#### Astro +* langs: js + +Set up a server-side fetch override in an Astro integration: + +```ts +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import type { AstroIntegration } from "astro" +import { AsyncLocalStorage } from "async_hooks"; + +const playwrightMockingProxy: AstroIntegration = { + name: 'playwrightMockingProxy', + hooks: { + 'astro:server:setup': async astro => { + if (process.env.NODE_ENV !== 'test') + return; + + const proxyStorage = new AsyncLocalStorage(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const proxy = proxyStorage.getStore(); + if (!proxy) + return originalFetch(input, init); + const request = new Request(input, init); + return originalFetch(proxy + request.url, request); + }; + astro.server.middlewares.use((req, res, next) => { + const header = req.headers['x-playwright-proxy'] as string; + if (typeof header !== 'string') + return next(); + proxyStorage.run(decodeURIComponent(header), next); + }); + }, + } +}; + +export default defineConfig({ + integrations: [ + playwrightMockingProxy + ] +}); +``` \ No newline at end of file From c08ee8f441f31ce35db23c5238da3f12c7a694b7 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 31 Jan 2025 17:08:01 +0100 Subject: [PATCH 07/22] add nuxt --- docs/src/mock.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/src/mock.md b/docs/src/mock.md index 26b2f0927a..177655adc6 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -773,4 +773,42 @@ export default defineConfig({ playwrightMockingProxy ] }); -``` \ No newline at end of file +``` + +#### Nuxt + +```ts +// server/plugins/playwright-mocking-proxy.ts + +import { getGlobalDispatcher, setGlobalDispatcher } from "undici" +import { useEvent, getRequestHeader } from '#imports' + +export default defineNitroPlugin(() => { + if (process.env.NODE_ENV !== 'test') + return; + + const proxiedDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { + const isInternal = opts.path.startsWith("/__nuxt") + const proxy = getRequestHeader(useEvent(), 'x-playwright-proxy') + if (!proxy || isInternal) + return dispatch(opts, handler) + + const newURL = new URL(decodeURIComponent(proxy) + opts.origin + opts.path); + opts.origin = newURL.origin; + opts.path = newURL.pathname; + return dispatch(opts, handler) + }) + setGlobalDispatcher(proxiedDispatcher) +}); +``` + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + nitro: { + experimental: { + asyncContext: true, + } + } +}) +``` From 08afdc600ce8557346044156e6cd12ce4fa8ccbd Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Feb 2025 09:21:40 +0100 Subject: [PATCH 08/22] update nuxt --- docs/src/mock.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/src/mock.md b/docs/src/mock.md index 177655adc6..9ef77526b9 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -790,12 +790,11 @@ export default defineNitroPlugin(() => { const proxiedDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { const isInternal = opts.path.startsWith("/__nuxt") const proxy = getRequestHeader(useEvent(), 'x-playwright-proxy') - if (!proxy || isInternal) - return dispatch(opts, handler) - - const newURL = new URL(decodeURIComponent(proxy) + opts.origin + opts.path); - opts.origin = newURL.origin; - opts.path = newURL.pathname; + if (proxy && !isInternal) { + const newURL = new URL(decodeURIComponent(proxy) + opts.origin + opts.path); + opts.origin = newURL.origin; + opts.path = newURL.pathname; + } return dispatch(opts, handler) }) setGlobalDispatcher(proxiedDispatcher) From d80f3297aa4a8a1b90cc35a89641306deb04a966 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Feb 2025 14:19:17 +0100 Subject: [PATCH 09/22] feedback --- .../src/client/browserContext.ts | 34 ++++--------------- packages/playwright-core/src/client/events.ts | 4 --- .../src/client/mockingProxy.ts | 23 ++++++------- .../playwright-core/src/client/network.ts | 4 +-- packages/playwright-core/src/client/page.ts | 1 - .../playwright-core/src/client/playwright.ts | 5 +++ .../playwright-core/src/protocol/validator.ts | 9 ----- .../dispatchers/localUtilsDispatcher.ts | 3 +- .../dispatchers/mockingProxyDispatcher.ts | 10 ------ .../src/server/mockingProxy.ts | 16 ++------- .../playwright-core/src/utils/httpServer.ts | 4 +-- packages/playwright/src/index.ts | 7 ++-- packages/protocol/src/channels.d.ts | 13 ------- packages/protocol/src/protocol.yml | 13 ------- 14 files changed, 33 insertions(+), 113 deletions(-) diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 20d91bf305..150e9581d6 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -144,7 +144,7 @@ export class BrowserContext extends ChannelOwner }); this._channel.on('request', ({ request, page }) => this._onRequest(network.Request.from(request), Page.fromNullable(page))); this._channel.on('requestFailed', ({ request, failureText, responseEndTiming, page }) => this._onRequestFailed(network.Request.from(request), responseEndTiming, failureText, Page.fromNullable(page))); - this._channel.on('requestFinished', params => this._onRequestFinished(params)); + this._channel.on('requestFinished', ({ request, response, page, responseEndTiming }) => this._onRequestFinished(network.Request.from(request), network.Response.fromNullable(response), Page.fromNullable(page), responseEndTiming)); this._channel.on('response', ({ response, page }) => this._onResponse(network.Response.from(response), Page.fromNullable(page))); this._closedPromise = new Promise(f => this.once(Events.BrowserContext.Close, f)); @@ -165,27 +165,27 @@ export class BrowserContext extends ChannelOwner this.tracing._tracesDir = browserOptions.tracesDir; } - private async _onPage(page: Page): Promise{ + private _onPage(page: Page): void { this._pages.add(page); this.emit(Events.BrowserContext.Page, page); - await this._mockingProxy?.instrumentPage(page); if (page._opener && !page._opener.isClosed()) page._opener.emit(Events.Page.Popup, page); + this._mockingProxy?.instrumentPage(page); } - private _onRequest(request: network.Request, page: Page | null) { + _onRequest(request: network.Request, page: Page | null) { this.emit(Events.BrowserContext.Request, request); if (page) page.emit(Events.Page.Request, request); } - private _onResponse(response: network.Response, page: Page | null) { + _onResponse(response: network.Response, page: Page | null) { this.emit(Events.BrowserContext.Response, response); if (page) page.emit(Events.Page.Response, response); } - private _onRequestFailed(request: network.Request, responseEndTiming: number, failureText: string | undefined, page: Page | null) { + _onRequestFailed(request: network.Request, responseEndTiming: number, failureText: string | undefined, page: Page | null) { request._failureText = failureText || null; request._setResponseEndTiming(responseEndTiming); this.emit(Events.BrowserContext.RequestFailed, request); @@ -193,11 +193,7 @@ export class BrowserContext extends ChannelOwner page.emit(Events.Page.RequestFailed, request); } - private _onRequestFinished(params: channels.BrowserContextRequestFinishedEvent) { - const { responseEndTiming } = params; - const request = network.Request.from(params.request); - const response = network.Response.fromNullable(params.response); - const page = Page.fromNullable(params.page); + _onRequestFinished(request: network.Request, response: network.Response | null, page: Page | null, responseEndTiming: number) { request._setResponseEndTiming(responseEndTiming); this.emit(Events.BrowserContext.RequestFinished, request); if (page) @@ -250,13 +246,6 @@ export class BrowserContext extends ChannelOwner if (this._mockingProxy) throw new Error('Multiple mocking proxies are not supported'); this._mockingProxy = mockingProxy; - this._registeredListeners.push( - eventsHelper.addEventListener(this._mockingProxy, Events.MockingProxy.Route, (route: network.Route) => { - const page = route.request()._safePage()!; - page._onRoute(route); - }), - // TODO: should we also emit `request`, `response`, `requestFinished`, `requestFailed` events? - ); } setDefaultNavigationTimeout(timeout: number | undefined) { @@ -421,15 +410,6 @@ export class BrowserContext extends ChannelOwner private async _updateInterceptionPatterns() { const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes); await this._channel.setNetworkInterceptionPatterns({ patterns }); - await this._updateMockingProxyInterceptionPatterns(); - } - - async _updateMockingProxyInterceptionPatterns() { - if (!this._mockingProxy) - return; - const pageRoutes = this.pages().flatMap(page => page._routes); - const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes.concat(pageRoutes)); - await this._mockingProxy.setInterceptionPatterns({ patterns }); } private async _updateWebSocketInterceptionPatterns() { diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index 3bf4040ad3..a074b26f3d 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -94,8 +94,4 @@ 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 index 91a7105f35..5bbc92d812 100644 --- a/packages/playwright-core/src/client/mockingProxy.ts +++ b/packages/playwright-core/src/client/mockingProxy.ts @@ -17,7 +17,6 @@ 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'; import type { Page } from './page'; @@ -31,39 +30,39 @@ export class MockingProxy extends ChannelOwner { this._channel.on('route', async (params: channels.MockingProxyRouteEvent) => { const route = network.Route.from(params.route); route._context = requestContext; - this.emit(Events.MockingProxy.Route, route); + const page = route.request()._safePage()!; + await page._onRoute(route); }); this._channel.on('request', async (params: channels.MockingProxyRequestEvent) => { const page = this._pages.get(params.correlation); assert(page); const request = network.Request.from(params.request); - request._page = page; + request._pageForMockingProxy = page; + page.context()._onRequest(request, page); }); this._channel.on('requestFailed', async (params: channels.MockingProxyRequestFailedEvent) => { const request = network.Request.from(params.request); - request._failureText = params.failureText ?? null; - request._setResponseEndTiming(params.responseEndTiming); + const page = request._safePage()!; + page.context()._onRequestFailed(request, params.responseEndTiming, params.failureText, page); }); 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); + const page = request._safePage()!; + page.context()._onRequestFinished(request, response, page, responseEndTiming); }); this._channel.on('response', async (params: channels.MockingProxyResponseEvent) => { - // no-op + const response = network.Response.from(params.response); + const page = response.request()._safePage()!; + page.context()._onResponse(response, page); }); } - async setInterceptionPatterns(params: channels.MockingProxySetInterceptionPatternsParams) { - await this._channel.setInterceptionPatterns(params); - } - async instrumentPage(page: Page) { const correlation = page._guid.split('@')[1]; this._pages.set(correlation, page); diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 302edb8a91..beff573146 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -86,7 +86,7 @@ export class Request extends ChannelOwner implements ap private _actualHeadersPromise: Promise | undefined; _timing: ResourceTiming; private _fallbackOverrides: SerializedFallbackOverrides = {}; - _page: Page | null = null; + _pageForMockingProxy: Page | null = null; static from(request: channels.RequestChannel): Request { return (request as any)._object; @@ -217,7 +217,7 @@ export class Request extends ChannelOwner implements ap } _safePage(): Page | null { - return this._page ?? Frame.fromNullable(this._initializer.frame)?._page ?? null; + return this._pageForMockingProxy ?? Frame.fromNullable(this._initializer.frame)?._page ?? null; } serviceWorker(): Worker | null { diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 2d7ecb61fd..4f00e80664 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -568,7 +568,6 @@ export class Page extends ChannelOwner implements api.Page private async _updateInterceptionPatterns() { const patterns = RouteHandler.prepareInterceptionPatterns(this._routes); await this._channel.setNetworkInterceptionPatterns({ patterns }); - await this._browserContext._updateMockingProxyInterceptionPatterns(); } private async _updateWebSocketInterceptionPatterns() { diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index 9933ce15de..0687ee4cff 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -73,4 +73,9 @@ export class Playwright extends ChannelOwner { static from(channel: channels.PlaywrightChannel): Playwright { return (channel as any)._object; } + + async _startMockingProxy() { + const { mockingProxy } = await this._connection.localUtils()._channel.newMockingProxy({}); + return (mockingProxy as any)._object; + } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 337288a7e4..36542dfea1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -363,16 +363,7 @@ scheme.MockingProxyRequestFinishedEvent = tObject({ }); 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']), diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index ff26a57a2e..62323e9a64 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -312,8 +312,7 @@ 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 index 315e8f83ef..f40d06c689 100644 --- a/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts @@ -13,14 +13,12 @@ * 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; @@ -58,12 +56,4 @@ export class MockingProxyDispatcher extends Dispatcher { - 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 index 8c250572f1..9dfe62e403 100644 --- a/packages/playwright-core/src/server/mockingProxy.ts +++ b/packages/playwright-core/src/server/mockingProxy.ts @@ -38,7 +38,6 @@ export class MockingProxy extends SdkObject implements RequestContext { }; fetchRequest: APIRequestContext; - private _matches?: (url: string) => boolean; private _httpServer = new WorkerHttpServer(); constructor(parent: SdkObject, requestContext: APIRequestContext) { @@ -63,10 +62,6 @@ export class MockingProxy extends SdkObject implements RequestContext { 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); @@ -212,14 +207,7 @@ export class MockingProxy extends SdkObject implements RequestContext { }, }); - if (!correlation) - return await route.continue({ isFallback: false }); - - - if (this._matches?.(req.url!)) - this.emit(MockingProxy.Events.Route, route); - else - await route.continue({ isFallback: false }); + this.emit(MockingProxy.Events.Route, route); } addRouteInFlight(route: Route): void { @@ -258,7 +246,7 @@ async function collectBody(req: http.IncomingMessage) { } export class WorkerHttpServer extends HttpServer { - override _handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean { + override handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean { return false; } } diff --git a/packages/playwright-core/src/utils/httpServer.ts b/packages/playwright-core/src/utils/httpServer.ts index f31222ce10..256bc24a57 100644 --- a/packages/playwright-core/src/utils/httpServer.ts +++ b/packages/playwright-core/src/utils/httpServer.ts @@ -213,7 +213,7 @@ export class HttpServer { readable.pipe(response); } - _handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean { + handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean { if (request.method === 'OPTIONS') { response.writeHead(200); response.end(); @@ -224,7 +224,7 @@ export class HttpServer { } private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { - if (this._handleCORS(request, response)) + if (this.handleCORS(request, response)) return; request.on('error', () => response.end()); diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 3b75a3649e..5665eb8fea 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -27,7 +27,7 @@ import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener 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'; +import type { Playwright as PlaywrightImpl } from 'playwright-core/lib/client/playwright'; export { expect } from './matchers/expect'; export const _baseTest: TestType<{}, {}> = rootTestType.test; @@ -127,9 +127,8 @@ const playwrightFixtures: Fixtures = ({ _mockingProxy: [async ({ mockingProxy: mockingProxyOption, playwright }, use) => { if (!mockingProxyOption) return await use(undefined); - const localUtils: LocalUtils = (playwright as any)._connection.localUtils(); - const { mockingProxy } = await localUtils._channel.newMockingProxy({}); - await use((mockingProxy as any)._object); + const mockingProxy = await (playwright as PlaywrightImpl)._startMockingProxy(); + await use(mockingProxy); }, { scope: 'worker', box: true }], acceptDownloads: [({ contextOptions }, use) => use(contextOptions.acceptDownloads ?? true), { option: true }], diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 3acc370d66..580a803ba8 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -589,7 +589,6 @@ export interface MockingProxyEventTarget { } export interface MockingProxyChannel extends MockingProxyEventTarget, EventTargetChannel { _type_MockingProxy: boolean; - setInterceptionPatterns(params: MockingProxySetInterceptionPatternsParams, metadata?: CallMetadata): Promise; } export type MockingProxyRouteEvent = { route: RouteChannel, @@ -610,19 +609,7 @@ export type MockingProxyRequestFinishedEvent = { }; 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; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 9e4d18e5af..2aa0a34881 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -682,18 +682,6 @@ MockingProxy: port: number requestContext: APIRequestContext - commands: - setInterceptionPatterns: - parameters: - patterns: - type: array - items: - type: object - properties: - glob: string? - regexSource: string? - regexFlags: string? - events: route: parameters: @@ -719,7 +707,6 @@ MockingProxy: response: parameters: response: Response - page: Page? Root: type: interface From c2826f0aeb7e1e4db39be7e11504450e11b5aaf5 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Feb 2025 14:32:11 +0100 Subject: [PATCH 10/22] remove "as any" --- .../dispatchers/mockingProxyDispatcher.ts | 10 +++---- .../server/dispatchers/networkDispatchers.ts | 27 ++++++++++--------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts index f40d06c689..4338f95095 100644 --- a/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts @@ -35,23 +35,23 @@ export class MockingProxyDispatcher extends Dispatcher { - const requestDispatcher = RequestDispatcher.from(this as any, route.request()); + const requestDispatcher = RequestDispatcher.from(this, 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._dispatchEvent('request', { request: RequestDispatcher.from(this, request), correlation }); }); this.addObjectListener(MockingProxy.Events.RequestFailed, (request: Request) => { this._dispatchEvent('requestFailed', { - request: RequestDispatcher.from(this as any, request), + request: RequestDispatcher.from(this, 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()), + request: RequestDispatcher.from(this, request), + response: ResponseDispatcher.fromNullable(this, request._existingResponse()), responseEndTiming: request._responseEndTiming, }); }); diff --git a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts index ba600f697e..f36f5939ef 100644 --- a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts +++ b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts @@ -26,30 +26,33 @@ import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { PageDispatcher } from './pageDispatcher'; import { FrameDispatcher } from './frameDispatcher'; import { WorkerDispatcher } from './pageDispatcher'; +import type { MockingProxyDispatcher } from './mockingProxyDispatcher'; -export class RequestDispatcher extends Dispatcher implements channels.RequestChannel { +type NetworkScope = BrowserContextDispatcher | MockingProxyDispatcher; + +export class RequestDispatcher extends Dispatcher implements channels.RequestChannel { _type_Request: boolean; - private _browserContextDispatcher: BrowserContextDispatcher; + private _networkScope: NetworkScope; - static from(scope: BrowserContextDispatcher, request: Request): RequestDispatcher { + static from(scope: NetworkScope, request: Request): RequestDispatcher { const result = existingDispatcher(request); return result || new RequestDispatcher(scope, request); } - static fromNullable(scope: BrowserContextDispatcher, request: Request | null): RequestDispatcher | undefined { + static fromNullable(scope: NetworkScope, request: Request | null): RequestDispatcher | undefined { return request ? RequestDispatcher.from(scope, request) : undefined; } - private constructor(scope: BrowserContextDispatcher, request: Request) { + private constructor(scope: NetworkScope, request: Request) { const postData = request.postDataBuffer(); // Always try to attach request to the page, if not, frame. const frame = request.frame(); const page = request.frame()?._page; const pageDispatcher = page ? existingDispatcher(page) : null; - const frameDispatcher = frame ? FrameDispatcher.from(scope, frame) : null; + const frameDispatcher = frame ? FrameDispatcher.from(scope as BrowserContextDispatcher, frame) : null; super(pageDispatcher || frameDispatcher || scope, request, 'Request', { - frame: FrameDispatcher.fromNullable(scope, request.frame()), - serviceWorker: WorkerDispatcher.fromNullable(scope, request.serviceWorker()), + frame: FrameDispatcher.fromNullable(scope as BrowserContextDispatcher, request.frame()), + serviceWorker: WorkerDispatcher.fromNullable(scope as BrowserContextDispatcher, request.serviceWorker()), url: request.url(), resourceType: request.resourceType(), method: request.method(), @@ -59,7 +62,7 @@ export class RequestDispatcher extends Dispatcher { @@ -67,20 +70,20 @@ export class RequestDispatcher extends Dispatcher { - return { response: ResponseDispatcher.fromNullable(this._browserContextDispatcher, await this._object.response()) }; + return { response: ResponseDispatcher.fromNullable(this._networkScope, await this._object.response()) }; } } export class ResponseDispatcher extends Dispatcher implements channels.ResponseChannel { _type_Response = true; - static from(scope: BrowserContextDispatcher, response: Response): ResponseDispatcher { + static from(scope: NetworkScope, response: Response): ResponseDispatcher { const result = existingDispatcher(response); const requestDispatcher = RequestDispatcher.from(scope, response.request()); return result || new ResponseDispatcher(requestDispatcher, response); } - static fromNullable(scope: BrowserContextDispatcher, response: Response | null): ResponseDispatcher | undefined { + static fromNullable(scope: NetworkScope, response: Response | null): ResponseDispatcher | undefined { return response ? ResponseDispatcher.from(scope, response) : undefined; } From 36855598bedad06cbd9d3cbb68b0e1b10c1ae372 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Feb 2025 14:43:17 +0100 Subject: [PATCH 11/22] update assertion --- packages/playwright-core/src/client/network.ts | 2 ++ tests/playwright-test/playwright.mockingproxy.spec.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index beff573146..cac330e879 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -201,6 +201,8 @@ export class Request extends ChannelOwner implements ap } frame(): Frame { + if (this._pageForMockingProxy) + throw new Error('Frame for this request is not available, because the request was issued on the server.'); if (!this._initializer.frame) { assert(this.serviceWorker()); throw new Error('Service Worker requests do not have an associated frame.'); diff --git a/tests/playwright-test/playwright.mockingproxy.spec.ts b/tests/playwright-test/playwright.mockingproxy.spec.ts index b5293ef26a..51a3636402 100644 --- a/tests/playwright-test/playwright.mockingproxy.spec.ts +++ b/tests/playwright-test/playwright.mockingproxy.spec.ts @@ -110,7 +110,7 @@ test('all properties are populated', async ({ runInlineTest, server, request }) expect(await response.finished()).toBe(null); expect(request.serviceWorker()).toBe(null); - expect(() => request.frame()).toThrowError("Assertion error"); // we know the page, but not the frame. should probably improve the error message + expect(() => request.frame()).toThrowError("Frame for this request is not available, because the request was issued on the server."); expect(request.failure()).toBe(null); expect(request.isNavigationRequest()).toBe(false); From b0cce735423e593e3f10aaaecfd2176fdc720868 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Feb 2025 15:07:27 +0100 Subject: [PATCH 12/22] respect requestcontext options --- packages/playwright-core/src/client/fetch.ts | 8 +- .../playwright-core/src/client/playwright.ts | 7 +- .../playwright-core/src/protocol/validator.ts | 38 ++++++- .../dispatchers/localUtilsDispatcher.ts | 21 ++-- packages/protocol/src/channels.d.ts | 76 ++++++++++++- packages/protocol/src/protocol.yml | 105 ++++++++++-------- 6 files changed, 190 insertions(+), 65 deletions(-) diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 58928532ac..31e0b0e8cb 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -45,7 +45,7 @@ export type FetchOptions = { maxRetries?: number, }; -type NewContextOptions = Omit & { +export type NewContextOptions = Omit & { extraHTTPHeaders?: Headers, storageState?: string | StorageState, clientCertificates?: ClientCertificate[]; @@ -65,13 +65,17 @@ export class APIRequest implements api.APIRequest { } async newContext(options: NewContextOptions = {}): Promise { + return this._newContext(options, this._playwright._channel); + } + + async _newContext(options: NewContextOptions = {}, channel: channels.PlaywrightChannel | channels.LocalUtilsChannel): Promise { options = { ...this._defaultContextOptions, ...options }; const storageState = typeof options.storageState === 'string' ? JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')) : options.storageState; // We do not expose tracesDir in the API, so do not allow options to accidentally override it. const tracesDir = this._defaultContextOptions?.tracesDir; - const context = APIRequestContext.from((await this._playwright._channel.newRequest({ + const context = APIRequestContext.from((await channel.newRequest({ ...options, extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, storageState, diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index 0687ee4cff..f60df580ed 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -20,7 +20,7 @@ import { Android } from './android'; import { BrowserType } from './browserType'; import { ChannelOwner } from './channelOwner'; import { Electron } from './electron'; -import { APIRequest } from './fetch'; +import { APIRequest, type NewContextOptions } from './fetch'; import { Selectors, SelectorsOwner } from './selectors'; export class Playwright extends ChannelOwner { @@ -74,8 +74,9 @@ export class Playwright extends ChannelOwner { return (channel as any)._object; } - async _startMockingProxy() { - const { mockingProxy } = await this._connection.localUtils()._channel.newMockingProxy({}); + async _startMockingProxy(requestContextOptions: NewContextOptions) { + const requestContext = await this.request._newContext(requestContextOptions, this._connection.localUtils()._channel); + const { mockingProxy } = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel }); return (mockingProxy as any)._object; } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 36542dfea1..bd332d7d89 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -336,7 +336,43 @@ scheme.LocalUtilsTraceDiscardedParams = tObject({ stacksId: tString, }); scheme.LocalUtilsTraceDiscardedResult = tOptional(tObject({})); -scheme.LocalUtilsNewMockingProxyParams = tOptional(tObject({})); +scheme.LocalUtilsNewRequestParams = tObject({ + baseURL: tOptional(tString), + userAgent: tOptional(tString), + ignoreHTTPSErrors: tOptional(tBoolean), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + clientCertificates: tOptional(tArray(tObject({ + origin: tString, + cert: tOptional(tBinary), + key: tOptional(tBinary), + passphrase: tOptional(tString), + pfx: tOptional(tBinary), + }))), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + send: tOptional(tEnum(['always', 'unauthorized'])), + })), + proxy: tOptional(tObject({ + server: tString, + bypass: tOptional(tString), + username: tOptional(tString), + password: tOptional(tString), + })), + timeout: tOptional(tNumber), + storageState: tOptional(tObject({ + cookies: tOptional(tArray(tType('NetworkCookie'))), + origins: tOptional(tArray(tType('OriginStorage'))), + })), + tracesDir: tOptional(tString), +}); +scheme.LocalUtilsNewRequestResult = tObject({ + request: tChannel(['APIRequestContext']), +}); +scheme.LocalUtilsNewMockingProxyParams = tObject({ + requestContext: tChannel(['APIRequestContext']), +}); scheme.LocalUtilsNewMockingProxyResult = tObject({ mockingProxy: tChannel(['MockingProxy']), }); diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 62323e9a64..e83dc9ab77 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -41,10 +41,10 @@ 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'; +import { APIRequestContextDispatcher } from './networkDispatchers'; +import { GlobalAPIRequestContext } from '../fetch'; export class LocalUtilsDispatcher extends Dispatcher implements channels.LocalUtilsChannel { _type_LocalUtils: boolean; @@ -55,21 +55,16 @@ export class LocalUtilsDispatcher extends Dispatcher(); - private _requestContext: APIRequestContext; + private _playwright: Playwright; 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, - {} // TODO: this should probably respect _combinedContextOptions from test runner - ); super(scope, localUtils, 'LocalUtils', { deviceDescriptors, }); - this._requestContext = requestContext; + this._playwright = playwright; this._type_LocalUtils = true; } @@ -285,8 +280,14 @@ export class LocalUtilsDispatcher extends Dispatcher { + const requestContext = new GlobalAPIRequestContext(this._playwright, params); + return { request: APIRequestContextDispatcher.from(this.parentScope(), requestContext) }; + } + async newMockingProxy(params: channels.LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise { - const mockingProxy = new MockingProxy(this._object, this._requestContext); + const requestContext = (params.requestContext as APIRequestContextDispatcher)._object; + const mockingProxy = new MockingProxy(this._object, requestContext); await mockingProxy.start(); return { mockingProxy: MockingProxyDispatcher.from(this.parentScope(), mockingProxy) }; } diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 580a803ba8..bc6cab3736 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -466,7 +466,8 @@ 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; + newRequest(params: LocalUtilsNewRequestParams, metadata?: CallMetadata): Promise; + newMockingProxy(params: LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise; } export type LocalUtilsZipParams = { zipFile: string, @@ -566,8 +567,77 @@ export type LocalUtilsTraceDiscardedOptions = { }; export type LocalUtilsTraceDiscardedResult = void; -export type LocalUtilsNewMockingProxyParams = {}; -export type LocalUtilsNewMockingProxyOptions = {}; +export type LocalUtilsNewRequestParams = { + baseURL?: string, + userAgent?: string, + ignoreHTTPSErrors?: boolean, + extraHTTPHeaders?: NameValue[], + clientCertificates?: { + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, + }[], + httpCredentials?: { + username: string, + password: string, + origin?: string, + send?: 'always' | 'unauthorized', + }, + proxy?: { + server: string, + bypass?: string, + username?: string, + password?: string, + }, + timeout?: number, + storageState?: { + cookies?: NetworkCookie[], + origins?: OriginStorage[], + }, + tracesDir?: string, +}; +export type LocalUtilsNewRequestOptions = { + baseURL?: string, + userAgent?: string, + ignoreHTTPSErrors?: boolean, + extraHTTPHeaders?: NameValue[], + clientCertificates?: { + origin: string, + cert?: Binary, + key?: Binary, + passphrase?: string, + pfx?: Binary, + }[], + httpCredentials?: { + username: string, + password: string, + origin?: string, + send?: 'always' | 'unauthorized', + }, + proxy?: { + server: string, + bypass?: string, + username?: string, + password?: string, + }, + timeout?: number, + storageState?: { + cookies?: NetworkCookie[], + origins?: OriginStorage[], + }, + tracesDir?: string, +}; +export type LocalUtilsNewRequestResult = { + request: APIRequestContextChannel, +}; +export type LocalUtilsNewMockingProxyParams = { + requestContext: APIRequestContextChannel, +}; +export type LocalUtilsNewMockingProxyOptions = { + +}; export type LocalUtilsNewMockingProxyResult = { mockingProxy: MockingProxyChannel, }; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 2aa0a34881..6737d27ea5 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -526,6 +526,56 @@ ContextOptions: - allow - block +NewRequestParameters: + type: mixin + properties: + baseURL: string? + userAgent: string? + ignoreHTTPSErrors: boolean? + extraHTTPHeaders: + type: array? + items: NameValue + clientCertificates: + type: array? + items: + type: object + properties: + origin: string + cert: binary? + key: binary? + passphrase: string? + pfx: binary? + httpCredentials: + type: object? + properties: + username: string + password: string + origin: string? + send: + type: enum? + literals: + - always + - unauthorized + proxy: + type: object? + properties: + server: string + bypass: string? + username: string? + password: string? + timeout: number? + storageState: + type: object? + properties: + cookies: + type: array? + items: NetworkCookie + origins: + type: array? + items: OriginStorage + tracesDir: string? + + EventTarget: type: interface @@ -669,7 +719,15 @@ LocalUtils: parameters: stacksId: string + newRequest: + parameters: + $mixin: NewRequestParameters + returns: + request: APIRequestContext + newMockingProxy: + parameters: + requestContext: APIRequestContext returns: mockingProxy: MockingProxy @@ -748,52 +806,7 @@ Playwright: commands: newRequest: parameters: - baseURL: string? - userAgent: string? - ignoreHTTPSErrors: boolean? - extraHTTPHeaders: - type: array? - items: NameValue - clientCertificates: - type: array? - items: - type: object - properties: - origin: string - cert: binary? - key: binary? - passphrase: string? - pfx: binary? - httpCredentials: - type: object? - properties: - username: string - password: string - origin: string? - send: - type: enum? - literals: - - always - - unauthorized - proxy: - type: object? - properties: - server: string - bypass: string? - username: string? - password: string? - timeout: number? - storageState: - type: object? - properties: - cookies: - type: array? - items: NetworkCookie - origins: - type: array? - items: OriginStorage - tracesDir: string? - + $mixin: NewRequestParameters returns: request: APIRequestContext From fe608159ac29201c21450b2f8fd077349b59665a Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Feb 2025 16:13:44 +0100 Subject: [PATCH 13/22] make it an enum --- docs/src/test-api/class-testoptions.md | 4 ++-- packages/playwright-core/src/client/playwright.ts | 4 ++-- packages/playwright/src/index.ts | 2 +- packages/playwright/types/test.d.ts | 5 +++-- utils/generate_types/overrides-test.d.ts | 3 ++- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index a7fa677a5d..df41058f36 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -679,7 +679,7 @@ export default defineConfig({ ## property: TestOptions.mockingProxy * since: v1.51 -- type: <[boolean]> Enables the mocking proxy. Playwright will inject the proxy URL into all outgoing requests under the `x-playwright-proxy` header. +- type: <[MockingProxyMode]<"off"|"inject-via-header">> Enables the mocking proxy. Playwright will inject the proxy URL into all outgoing requests under the `x-playwright-proxy` header. **Usage** @@ -688,7 +688,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { - mockingProxy: true + mockingProxy: 'inject-via-header' }, }); ``` \ No newline at end of file diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index f60df580ed..f3124fb385 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -74,8 +74,8 @@ export class Playwright extends ChannelOwner { return (channel as any)._object; } - async _startMockingProxy(requestContextOptions: NewContextOptions) { - const requestContext = await this.request._newContext(requestContextOptions, this._connection.localUtils()._channel); + async _startMockingProxy() { + const requestContext = await this.request._newContext(undefined, this._connection.localUtils()._channel); const { mockingProxy } = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel }); return (mockingProxy as any)._object; } diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 5665eb8fea..cd3d249ad0 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -125,7 +125,7 @@ const playwrightFixtures: Fixtures = ({ }, { scope: 'worker', timeout: 0 }], _mockingProxy: [async ({ mockingProxy: mockingProxyOption, playwright }, use) => { - if (!mockingProxyOption) + if (mockingProxyOption !== 'inject-via-header') return await use(undefined); const mockingProxy = await (playwright as PlaywrightImpl)._startMockingProxy(); await use(mockingProxy); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 67df822010..391d9f56ab 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -6166,18 +6166,19 @@ export interface PlaywrightWorkerOptions { * * export default defineConfig({ * use: { - * mockingProxy: true + * mockingProxy: 'inject-via-header' * }, * }); * ``` * */ - mockingProxy: boolean | undefined; + mockingProxy: MockingProxyMode | undefined; } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure'; export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure'; export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry'; +export type MockingProxyMode = 'off' | 'inject-via-header'; /** * Playwright Test provides many options to configure test environment, diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index b9c144a132..10475b5649 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -236,12 +236,13 @@ 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: boolean | undefined; + mockingProxy: MockingProxyMode | undefined; } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure'; export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure'; export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry'; +export type MockingProxyMode = 'off' | 'inject-via-header'; export interface PlaywrightTestOptions { acceptDownloads: boolean; From bb5672583a23ac5f4f0bca90c62c1c999dfd33c4 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Feb 2025 16:19:59 +0100 Subject: [PATCH 14/22] update docs --- docs/src/mock.md | 44 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/docs/src/mock.md b/docs/src/mock.md index 9ef77526b9..f08749c37c 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -566,7 +566,7 @@ If you send it a request, it will apply the network routes configured via `page. To get started, enable the `mockingProxy` option in your Playwright config: -```ts +```js export default defineConfig({ use: { mockingProxy: true } }); @@ -581,9 +581,8 @@ const proxyURL = decodeURIComponent(headers.get('x-playwright-proxy') ?? ''); await fetch(proxyURL + 'https://api.example.com/users'); ``` -Prepending the URL will direct the request through the proxy. You can now intercept it with `context.route` and `page.route`, just like browser requests: - -```ts +Prepending the URL will direct the request through the proxy. You can now intercept it with [`method: BrowserContext.route`] and [`method: Page.route`], just like browser requests: +```js // shopping-cart.spec.ts import { test, expect } from '@playwright/test'; @@ -605,23 +604,15 @@ test('checkout applies customer loyalty bonus points', async ({ page }) => { }); ``` -Now, prepending the proxy URL manually 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: +Now, prepending the proxy URL manually can be cumbersome. If your HTTP client supports it, consider setting up a global interceptor: ```js import { axios } from 'axios'; axios.interceptors.request.use(async config => { - config.baseURL = proxyURL + (config.baseURL ?? '/'); + const headers = getCurrentRequestHeaders(); // this line looks different for each application + const proxy = decodeURIComponent(headers.get('x-playwright-proxy') ?? ''); + config.url = new URL(proxy + config.url, config.baseURL).toString(); return config; }); ``` @@ -630,15 +621,18 @@ axios.interceptors.request.use(async config => { import { setGlobalDispatcher, getGlobalDispatcher } from 'undici'; const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { - opts.path = opts.origin + opts.path; - opts.origin = proxyURL; + const headers = getCurrentRequestHeaders(); // this line looks different for each application + const proxy = decodeURIComponent(headers.get('x-playwright-proxy') ?? ''); + const newURL = new URL(proxy + opts.origin + opts.path); + opts.origin = newURL.origin; + opts.path = newURL.pathname; 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. +Note that this style of proxying, where the proxy URL is prepended 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! ::: @@ -651,7 +645,7 @@ This is because for HTTPS requests, a `CONNECT` proxy does not have access to th Monkey-patch `globalThis.fetch` in your `instrumentation.ts` file: -```ts +```js // instrumentation.ts import { headers } from 'next/headers'; @@ -676,7 +670,7 @@ export function register() { Monkey-patch `globalThis.fetch` in your `entry.server.ts` file, and use `AsyncLocalStorage` to make current request headers available: -```ts +```js import { setGlobalDispatcher, getGlobalDispatcher } from 'undici'; import { AsyncLocalStorage } from 'node:async_hooks'; @@ -705,7 +699,7 @@ export default function handleRequest(request: Request, /* ... */) { Configure your `HttpClient` with an [interceptor](https://angular.dev/guide/http/setup#withinterceptors): -```ts +```js // app.config.server.ts import { inject, REQUEST } from '@angular/core'; @@ -736,7 +730,7 @@ const serverConfig = { Set up a server-side fetch override in an Astro integration: -```ts +```js // astro.config.mjs import { defineConfig } from 'astro/config'; import type { AstroIntegration } from "astro" @@ -777,7 +771,7 @@ export default defineConfig({ #### Nuxt -```ts +```js // server/plugins/playwright-mocking-proxy.ts import { getGlobalDispatcher, setGlobalDispatcher } from "undici" @@ -801,7 +795,7 @@ export default defineNitroPlugin(() => { }); ``` -```ts +```js // nuxt.config.ts export default defineNuxtConfig({ nitro: { From ab903419d8834e312803a7277567f185fddfc40d Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Feb 2025 16:24:36 +0100 Subject: [PATCH 15/22] add limitations --- docs/src/mock.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/src/mock.md b/docs/src/mock.md index f08749c37c..bf6f57a183 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -636,6 +636,14 @@ Note that this style of proxying, where the proxy URL is prepended to the reques 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! ::: +:::note +Known Limitations: + +1. The mocking proxy is experimental and subject to change. +2. The injected `x-playwright-proxy` header affects CORS and might turn [simple requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests) into requests that require a preflight. +3. Requests on the server that were not made in response to a browser request, like those triggered by CRON job, won't be routed because they don't have access to the `x-playwright-proxy` header. +::: + ### Recipes * langs: js From 32e40e6b4fc85784670076c2805073246666ba5d Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Feb 2025 16:34:52 +0100 Subject: [PATCH 16/22] fix tests --- packages/playwright-core/src/client/fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 31e0b0e8cb..02ec1a7736 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -65,7 +65,7 @@ export class APIRequest implements api.APIRequest { } async newContext(options: NewContextOptions = {}): Promise { - return this._newContext(options, this._playwright._channel); + return await this._newContext(options, this._playwright._channel); } async _newContext(options: NewContextOptions = {}, channel: channels.PlaywrightChannel | channels.LocalUtilsChannel): Promise { From 8712024f3a036022dd91806553b5b6961785c154 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 4 Feb 2025 08:37:47 +0100 Subject: [PATCH 17/22] enum --- docs/src/mock.md | 2 +- tests/playwright-test/playwright.mockingproxy.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/mock.md b/docs/src/mock.md index bf6f57a183..bbb38564af 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -568,7 +568,7 @@ To get started, enable the `mockingProxy` option in your Playwright config: ```js export default defineConfig({ - use: { mockingProxy: true } + use: { mockingProxy: "inject-via-header" } }); ``` diff --git a/tests/playwright-test/playwright.mockingproxy.spec.ts b/tests/playwright-test/playwright.mockingproxy.spec.ts index 51a3636402..aecfc26a49 100644 --- a/tests/playwright-test/playwright.mockingproxy.spec.ts +++ b/tests/playwright-test/playwright.mockingproxy.spec.ts @@ -21,7 +21,7 @@ const config = { 'playwright.config.ts': ` module.exports = { use: { - mockingProxy: true, + mockingProxy: "inject-via-header", ignoreHTTPSErrors: true, } }; From aa2aef146d0e19efcb05fe75e7f270c1cf63765c Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 4 Feb 2025 11:27:16 +0100 Subject: [PATCH 18/22] implement first pass --- .../src/client/browserContext.ts | 4 +--- .../playwright-core/src/client/mockingProxy.ts | 18 +++++++++++++----- packages/playwright-core/src/client/network.ts | 6 +++--- packages/playwright-core/src/client/page.ts | 2 +- .../playwright-core/src/client/playwright.ts | 5 +++-- .../dispatchers/mockingProxyDispatcher.ts | 6 +++++- .../playwright-core/src/server/mockingProxy.ts | 8 ++++++-- 7 files changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 150e9581d6..71a5aca8ec 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -70,7 +70,6 @@ export class BrowserContext extends ChannelOwner _closeWasCalled = false; private _closeReason: string | undefined; private _harRouters: HarRouter[] = []; - private _registeredListeners: RegisteredListener[] = []; _mockingProxy?: MockingProxy; static from(context: channels.BrowserContextChannel): BrowserContext { @@ -96,7 +95,7 @@ export class BrowserContext extends ChannelOwner this._channel.on('page', ({ page }) => this._onPage(Page.from(page))); this._channel.on('route', params => { const route = network.Route.from(params.route); - route._context = this.request; + route._apiRequestContext = this.request; this._onRoute(route); }); this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute))); @@ -467,7 +466,6 @@ export class BrowserContext extends ChannelOwner this._disposeHarRouters(); this.tracing._resetStackCounter(); this.emit(Events.BrowserContext.Close, this); - eventsHelper.removeEventListeners(this._registeredListeners); } async [Symbol.asyncDispose]() { diff --git a/packages/playwright-core/src/client/mockingProxy.ts b/packages/playwright-core/src/client/mockingProxy.ts index 5bbc92d812..898c620112 100644 --- a/packages/playwright-core/src/client/mockingProxy.ts +++ b/packages/playwright-core/src/client/mockingProxy.ts @@ -19,6 +19,7 @@ import { ChannelOwner } from './channelOwner'; import { APIRequestContext } from './fetch'; import { assert } from '../utils'; import type { Page } from './page'; +import { Events } from './events'; export class MockingProxy extends ChannelOwner { private _pages = new Map(); @@ -29,8 +30,8 @@ export class MockingProxy extends ChannelOwner { const requestContext = APIRequestContext.from(initializer.requestContext); this._channel.on('route', async (params: channels.MockingProxyRouteEvent) => { const route = network.Route.from(params.route); - route._context = requestContext; - const page = route.request()._safePage()!; + route._apiRequestContext = requestContext; + const page = route.request()._pageForMockingProxy!; await page._onRoute(route); }); @@ -44,7 +45,7 @@ export class MockingProxy extends ChannelOwner { this._channel.on('requestFailed', async (params: channels.MockingProxyRequestFailedEvent) => { const request = network.Request.from(params.request); - const page = request._safePage()!; + const page = request._pageForMockingProxy!; page.context()._onRequestFailed(request, params.responseEndTiming, params.failureText, page); }); @@ -52,20 +53,27 @@ export class MockingProxy extends ChannelOwner { const { responseEndTiming } = params; const request = network.Request.from(params.request); const response = network.Response.fromNullable(params.response); - const page = request._safePage()!; + const page = request._pageForMockingProxy!; page.context()._onRequestFinished(request, response, page, responseEndTiming); }); this._channel.on('response', async (params: channels.MockingProxyResponseEvent) => { const response = network.Response.from(params.response); - const page = response.request()._safePage()!; + const page = response.request()._pageForMockingProxy!; page.context()._onResponse(response, page); }); } + static from(channel: channels.MockingProxyChannel): MockingProxy { + return (channel as any)._object; + } + async instrumentPage(page: Page) { const correlation = page._guid.split('@')[1]; this._pages.set(correlation, page); + page.on(Events.Page.Close, () => { + this._pages.delete(correlation); + }); const proxyUrl = `http://localhost:${this._initializer.port}/pw_meta:${correlation}/`; await page.setExtraHTTPHeaders({ 'x-playwright-proxy': encodeURIComponent(proxyUrl) diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index cac330e879..c34ad5e564 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -219,7 +219,7 @@ export class Request extends ChannelOwner implements ap } _safePage(): Page | null { - return this._pageForMockingProxy ?? Frame.fromNullable(this._initializer.frame)?._page ?? null; + return Frame.fromNullable(this._initializer.frame)?._page ?? null; } serviceWorker(): Worker | null { @@ -294,7 +294,7 @@ export class Request extends ChannelOwner implements ap export class Route extends ChannelOwner implements api.Route { private _handlingPromise: ManualPromise | null = null; - _context!: APIRequestContext; + _apiRequestContext!: APIRequestContext; _didThrow: boolean = false; static from(route: channels.RouteChannel): Route { @@ -342,7 +342,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._innerFetch({ request: this.request(), data: options.postData, ...options }); + return await this._apiRequestContext._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 4f00e80664..466abcf595 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -140,7 +140,7 @@ export class Page extends ChannelOwner implements api.Page this._channel.on('locatorHandlerTriggered', ({ uid }) => this._onLocatorHandlerTriggered(uid)); this._channel.on('route', params => { const route = Route.from(params.route); - route._context = this.context().request; + route._apiRequestContext = this.context().request; this._onRoute(route); }); this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(WebSocketRoute.from(webSocketRoute))); diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index f3124fb385..c3eb5bfa27 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -20,8 +20,9 @@ import { Android } from './android'; import { BrowserType } from './browserType'; import { ChannelOwner } from './channelOwner'; import { Electron } from './electron'; -import { APIRequest, type NewContextOptions } from './fetch'; +import { APIRequest } from './fetch'; import { Selectors, SelectorsOwner } from './selectors'; +import { MockingProxy } from './mockingProxy'; export class Playwright extends ChannelOwner { readonly _android: Android; @@ -77,6 +78,6 @@ export class Playwright extends ChannelOwner { async _startMockingProxy() { const requestContext = await this.request._newContext(undefined, this._connection.localUtils()._channel); const { mockingProxy } = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel }); - return (mockingProxy as any)._object; + return MockingProxy.from(mockingProxy); } } diff --git a/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts index 4338f95095..eece7884de 100644 --- a/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts @@ -30,7 +30,7 @@ export class MockingProxyDispatcher extends Dispatcher Date: Tue, 4 Feb 2025 12:23:54 +0100 Subject: [PATCH 19/22] more --- docs/src/mock.md | 2 + .../playwright-core/src/client/browserType.ts | 9 +++++ .../src/client/mockingProxy.ts | 39 +++++++++++-------- .../playwright-core/src/client/playwright.ts | 8 +++- .../playwright-core/src/protocol/validator.ts | 3 +- .../dispatchers/localUtilsDispatcher.ts | 2 +- .../dispatchers/mockingProxyDispatcher.ts | 23 +++++------ .../src/server/mockingProxy.ts | 8 +++- .../playwright-core/src/server/network.ts | 2 +- packages/protocol/src/channels.d.ts | 3 +- packages/protocol/src/protocol.yml | 3 +- 11 files changed, 60 insertions(+), 42 deletions(-) diff --git a/docs/src/mock.md b/docs/src/mock.md index bbb38564af..3e1c3e3c32 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -642,6 +642,8 @@ Known Limitations: 1. The mocking proxy is experimental and subject to change. 2. The injected `x-playwright-proxy` header affects CORS and might turn [simple requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests) into requests that require a preflight. 3. Requests on the server that were not made in response to a browser request, like those triggered by CRON job, won't be routed because they don't have access to the `x-playwright-proxy` header. +4. On Firefox, the first requests after page open might not be intercepted by the mocking proxy. +5. `defaultContextOptions` aren't applied when using `route.fetch`. ::: diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 06672cc64e..78074b9612 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -27,6 +27,7 @@ import { assert, headersObjectToArray, monotonicTime } from '../utils'; import type * as api from '../../types/types'; import { raceAgainstDeadline } from '../utils/timeoutRunner'; import type { Playwright } from './playwright'; +import type { Page } from './page'; export interface BrowserServerLauncher { launchServer(options?: LaunchServerOptions): Promise; @@ -241,6 +242,14 @@ export class BrowserType extends ChannelOwner imple context.setDefaultTimeout(this._defaultContextTimeout); if (this._defaultContextNavigationTimeout !== undefined) context.setDefaultNavigationTimeout(this._defaultContextNavigationTimeout); + + if (this._playwright._mockingProxy) { + context.on(Events.BrowserContext.Page, (page: Page) => { + // TODO: funnel through protocol, so these headers are known to the server browsercontext and can be applied earlier + page.setExtraHTTPHeaders(this._playwright._mockingProxy!.instrumentationHeaders(page)); + }); + } + await this._instrumentation.runAfterCreateBrowserContext(context); } diff --git a/packages/playwright-core/src/client/mockingProxy.ts b/packages/playwright-core/src/client/mockingProxy.ts index 898c620112..f49b09ba22 100644 --- a/packages/playwright-core/src/client/mockingProxy.ts +++ b/packages/playwright-core/src/client/mockingProxy.ts @@ -16,27 +16,27 @@ import * as network from './network'; import type * as channels from '@protocol/channels'; import { ChannelOwner } from './channelOwner'; -import { APIRequestContext } from './fetch'; +import type { APIRequestContext } from './fetch'; import { assert } from '../utils'; import type { Page } from './page'; -import { Events } from './events'; +import type { Playwright } from './playwright'; export class MockingProxy extends ChannelOwner { - private _pages = new Map(); + _requestContext!: APIRequestContext; + _playwright!: Playwright; 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._apiRequestContext = requestContext; + route._apiRequestContext = this._requestContext; const page = route.request()._pageForMockingProxy!; await page._onRoute(route); }); this._channel.on('request', async (params: channels.MockingProxyRequestEvent) => { - const page = this._pages.get(params.correlation); + const page = this.findPage(params.correlation); assert(page); const request = network.Request.from(params.request); request._pageForMockingProxy = page; @@ -68,16 +68,23 @@ export class MockingProxy extends ChannelOwner { return (channel as any)._object; } - async instrumentPage(page: Page) { - const correlation = page._guid.split('@')[1]; - this._pages.set(correlation, page); - page.on(Events.Page.Close, () => { - this._pages.delete(correlation); - }); - const proxyUrl = `http://localhost:${this._initializer.port}/pw_meta:${correlation}/`; - await page.setExtraHTTPHeaders({ - 'x-playwright-proxy': encodeURIComponent(proxyUrl) - }); + findPage(correlation: string): Page | undefined { + const guid = `Page@${correlation}`; + // TODO: move this as list onto Playwright directly + for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) { + for (const context of browserType._contexts) { + for (const page of context._pages) { + if (page._guid === guid) + return page; + } + } + } } + instrumentationHeaders(page: Page) { + const correlation = page._guid.substring('Page@'.length); + return { + 'x-playwright-proxy': `${this._initializer.baseURL}/pw_meta:${correlation}/`, + }; + } } diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index c3eb5bfa27..aa47db853d 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -36,6 +36,7 @@ export class Playwright extends ChannelOwner { selectors: Selectors; readonly request: APIRequest; readonly errors: { TimeoutError: typeof TimeoutError }; + _mockingProxy?: MockingProxy; constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) { super(parent, type, guid, initializer); @@ -77,7 +78,10 @@ export class Playwright extends ChannelOwner { async _startMockingProxy() { const requestContext = await this.request._newContext(undefined, this._connection.localUtils()._channel); - const { mockingProxy } = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel }); - return MockingProxy.from(mockingProxy); + const result = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel }); + this._mockingProxy = MockingProxy.from(result.mockingProxy); + this._mockingProxy._requestContext = requestContext; + this._mockingProxy._playwright = this; + return this._mockingProxy; } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index bd332d7d89..a14c442c64 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -377,8 +377,7 @@ scheme.LocalUtilsNewMockingProxyResult = tObject({ mockingProxy: tChannel(['MockingProxy']), }); scheme.MockingProxyInitializer = tObject({ - port: tNumber, - requestContext: tChannel(['APIRequestContext']), + baseURL: tString, }); scheme.MockingProxyRouteEvent = tObject({ route: tChannel(['Route']), diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index e83dc9ab77..ecfc43bd3c 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -289,7 +289,7 @@ export class LocalUtilsDispatcher extends Dispatcher implements channels.MockingProxyChannel { +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) { + constructor(scope: LocalUtilsDispatcher, mockingProxy: MockingProxy) { super(scope, mockingProxy, 'MockingProxy', { - port: mockingProxy.port(), - requestContext: APIRequestContextDispatcher.from(scope, mockingProxy.fetchRequest), + baseURL: mockingProxy.baseURL(), }); - this.addObjectListener(MockingProxy.Events.Route, (route: Route) => { + mockingProxy.onRoute = async route => { const requestDispatcher = RequestDispatcher.from(this, 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, request), correlation }); }); diff --git a/packages/playwright-core/src/server/mockingProxy.ts b/packages/playwright-core/src/server/mockingProxy.ts index fcd0c9cb2c..edd48e1b8a 100644 --- a/packages/playwright-core/src/server/mockingProxy.ts +++ b/packages/playwright-core/src/server/mockingProxy.ts @@ -32,13 +32,13 @@ export class MockingProxy extends SdkObject implements RequestContext { static Events = { Request: 'request', Response: 'response', - Route: 'route', RequestFailed: 'requestfailed', RequestFinished: 'requestfinished', }; fetchRequest: APIRequestContext; private _httpServer = new WorkerHttpServer(); + onRoute = (route: Route) => route.continue({ isFallback: true }); constructor(parent: SdkObject, requestContext: APIRequestContext) { super(parent, 'MockingProxy'); @@ -66,6 +66,10 @@ export class MockingProxy extends SdkObject implements RequestContext { return this._httpServer.port(); } + baseURL() { + return `http://localhost:${this.port()}/`; + } + private async _proxy(req: http.IncomingMessage, res: http.ServerResponse) { if (req.url?.startsWith('/')) req.url = req.url.substring(1); @@ -211,7 +215,7 @@ export class MockingProxy extends SdkObject implements RequestContext { }, }); - this.emit(MockingProxy.Events.Route, route); + await this.onRoute(route); } addRouteInFlight(route: Route): void { diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index a6738ee0e7..5ee6c48438 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -88,7 +88,7 @@ export function stripFragmentFromUrl(url: string): string { } export interface RequestContext extends SdkObject { - fetchRequest: APIRequestContext; + readonly fetchRequest: APIRequestContext; addRouteInFlight(route: Route): void; removeRouteInFlight(route: Route): void; diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index bc6cab3736..750709543d 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -647,8 +647,7 @@ export interface LocalUtilsEvents { // ----------- MockingProxy ----------- export type MockingProxyInitializer = { - port: number, - requestContext: APIRequestContextChannel, + baseURL: string, }; export interface MockingProxyEventTarget { on(event: 'route', callback: (params: MockingProxyRouteEvent) => void): this; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 6737d27ea5..2768d4cba5 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -737,8 +737,7 @@ MockingProxy: extends: EventTarget initializer: - port: number - requestContext: APIRequestContext + baseURL: string events: route: From 5e49e08ba2d4d117a24841eeed4a2461d31530d4 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 4 Feb 2025 12:47:35 +0100 Subject: [PATCH 20/22] move to serverside --- packages/playwright-core/src/client/browser.ts | 2 +- .../src/client/browserContext.ts | 15 +++------------ .../playwright-core/src/client/browserType.ts | 10 +--------- .../playwright-core/src/client/mockingProxy.ts | 9 +++------ .../playwright-core/src/protocol/validator.ts | 1 + .../src/server/firefox/ffPage.ts | 2 +- packages/playwright-core/src/server/frames.ts | 2 +- packages/playwright-core/src/server/page.ts | 16 ++++++++++++++-- packages/playwright/src/index.ts | 17 +++++++---------- packages/protocol/src/channels.d.ts | 2 ++ packages/protocol/src/protocol.yml | 1 + 11 files changed, 35 insertions(+), 42 deletions(-) diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index be47ddeb51..fd811dfa18 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -81,7 +81,7 @@ export class Browser extends ChannelOwner implements ap async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise { options = { ...this._browserType._defaultContextOptions, ...options }; - const contextOptions = await prepareBrowserContextParams(options); + const contextOptions = await prepareBrowserContextParams(options, this._browserType); const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions); const context = BrowserContext.from(response.context); await this._browserType._didCreateContext(context, contextOptions, this._options, options.logger || this._logger); diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 71a5aca8ec..ee94a313bf 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -29,8 +29,7 @@ import { Events } from './events'; import { TimeoutSettings } from '../common/timeoutSettings'; import { Waiter } from './waiter'; import type { Headers, WaitForEventOptions, BrowserContextOptions, StorageState, LaunchOptions } from './types'; -import type { RegisteredListener } from '../utils'; -import { type URLMatch, headersObjectToArray, isRegExp, isString, urlMatchesEqual, mkdirIfNeeded, eventsHelper } from '../utils'; +import { type URLMatch, headersObjectToArray, isRegExp, isString, urlMatchesEqual, mkdirIfNeeded } from '../utils'; import type * as api from '../../types/types'; import type * as structs from '../../types/structs'; import { CDPSession } from './cdpSession'; @@ -45,7 +44,6 @@ 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(); @@ -70,7 +68,6 @@ 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; @@ -169,7 +166,6 @@ export class BrowserContext extends ChannelOwner this.emit(Events.BrowserContext.Page, page); if (page._opener && !page._opener.isClosed()) page._opener.emit(Events.Page.Popup, page); - this._mockingProxy?.instrumentPage(page); } _onRequest(request: network.Request, page: Page | null) { @@ -241,12 +237,6 @@ 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; - } - setDefaultNavigationTimeout(timeout: number | undefined) { this._timeoutSettings.setDefaultNavigationTimeout(timeout); this._wrapApiCall(async () => { @@ -530,7 +520,7 @@ function prepareRecordHarOptions(options: BrowserContextOptions['recordHar']): c }; } -export async function prepareBrowserContextParams(options: BrowserContextOptions): Promise { +export async function prepareBrowserContextParams(options: BrowserContextOptions, type?: BrowserType): Promise { if (options.videoSize && !options.videosPath) throw new Error(`"videoSize" option requires "videosPath" to be specified`); if (options.extraHTTPHeaders) @@ -548,6 +538,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions forcedColors: options.forcedColors === null ? 'no-override' : options.forcedColors, acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads), clientCertificates: await toClientCertificatesProtocol(options.clientCertificates), + mockingProxyBaseURL: type?._playwright._mockingProxy?.baseURL(), }; if (!contextParams.recordVideo && options.videosPath) { contextParams.recordVideo = { diff --git a/packages/playwright-core/src/client/browserType.ts b/packages/playwright-core/src/client/browserType.ts index 78074b9612..5417dd529d 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -27,7 +27,6 @@ import { assert, headersObjectToArray, monotonicTime } from '../utils'; import type * as api from '../../types/types'; import { raceAgainstDeadline } from '../utils/timeoutRunner'; import type { Playwright } from './playwright'; -import type { Page } from './page'; export interface BrowserServerLauncher { launchServer(options?: LaunchServerOptions): Promise; @@ -96,7 +95,7 @@ export class BrowserType extends ChannelOwner imple const logger = options.logger || this._defaultLaunchOptions?.logger; assert(!(options as any).port, 'Cannot specify a port without launching as a server.'); options = { ...this._defaultLaunchOptions, ...this._defaultContextOptions, ...options }; - const contextParams = await prepareBrowserContextParams(options); + const contextParams = await prepareBrowserContextParams(options, this); const persistentParams: channels.BrowserTypeLaunchPersistentContextParams = { ...contextParams, ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined, @@ -243,13 +242,6 @@ export class BrowserType extends ChannelOwner imple if (this._defaultContextNavigationTimeout !== undefined) context.setDefaultNavigationTimeout(this._defaultContextNavigationTimeout); - if (this._playwright._mockingProxy) { - context.on(Events.BrowserContext.Page, (page: Page) => { - // TODO: funnel through protocol, so these headers are known to the server browsercontext and can be applied earlier - page.setExtraHTTPHeaders(this._playwright._mockingProxy!.instrumentationHeaders(page)); - }); - } - await this._instrumentation.runAfterCreateBrowserContext(context); } diff --git a/packages/playwright-core/src/client/mockingProxy.ts b/packages/playwright-core/src/client/mockingProxy.ts index f49b09ba22..7886243ea1 100644 --- a/packages/playwright-core/src/client/mockingProxy.ts +++ b/packages/playwright-core/src/client/mockingProxy.ts @@ -69,7 +69,7 @@ export class MockingProxy extends ChannelOwner { } findPage(correlation: string): Page | undefined { - const guid = `Page@${correlation}`; + const guid = `page@${correlation}`; // TODO: move this as list onto Playwright directly for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) { for (const context of browserType._contexts) { @@ -81,10 +81,7 @@ export class MockingProxy extends ChannelOwner { } } - instrumentationHeaders(page: Page) { - const correlation = page._guid.substring('Page@'.length); - return { - 'x-playwright-proxy': `${this._initializer.baseURL}/pw_meta:${correlation}/`, - }; + baseURL() { + return this._initializer.baseURL; } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index a14c442c64..2508e34171 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -775,6 +775,7 @@ scheme.BrowserNewContextParams = tObject({ cookies: tOptional(tArray(tType('SetNetworkCookie'))), origins: tOptional(tArray(tType('OriginStorage'))), })), + mockingProxyBaseURL: tOptional(tString), }); scheme.BrowserNewContextResult = tObject({ context: tChannel(['BrowserContext']), diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 68559d7c7e..4e891a34bf 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -330,7 +330,7 @@ export class FFPage implements PageDelegate { } async updateExtraHTTPHeaders(): Promise { - await this._session.send('Network.setExtraHTTPHeaders', { headers: this._page.extraHTTPHeaders() || [] }); + await this._session.send('Network.setExtraHTTPHeaders', { headers: this._page.extraHTTPHeaders() }); } async updateEmulatedViewportSize(): Promise { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 1570df5770..463bd898db 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -654,7 +654,7 @@ export class Frame extends SdkObject { private async _gotoAction(progress: Progress, url: string, options: types.GotoOptions): Promise { const waitUntil = verifyLifecycle('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); progress.log(`navigating to "${url}", waiting until "${waitUntil}"`); - const headers = this._page.extraHTTPHeaders() || []; + const headers = this._page.extraHTTPHeaders(); const refererHeader = headers.find(h => h.name.toLowerCase() === 'referer'); let referer = refererHeader ? refererHeader.value : undefined; if (options.referer !== undefined) { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 9b85837b65..b4e8fbb504 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -365,8 +365,20 @@ export class Page extends SdkObject { return this._delegate.updateExtraHTTPHeaders(); } - extraHTTPHeaders(): types.HeadersArray | undefined { - return this._extraHTTPHeaders; + extraHTTPHeaders(): types.HeadersArray { + return this.instrumentationHeaders().concat(this._extraHTTPHeaders ?? []); + } + + instrumentationHeaders() { + const headers: channels.NameValue[] = []; + + const mockingProxyBaseURL = this.context()._options.mockingProxyBaseURL; + if (mockingProxyBaseURL) { + const correlation = this.guid.substring('Page@'.length); + headers.push({ name: 'x-playwright-proxy', value: encodeURIComponent(mockingProxyBaseURL + `pw_meta:${correlation}/`) }); + } + + return headers; } async _onBindingCalled(payload: string, context: dom.FrameExecutionContext) { diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index cd3d249ad0..a261cec928 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -57,7 +57,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _optionContextReuseMode: ContextReuseMode, _optionConnectOptions: PlaywrightWorkerOptions['connectOptions'], _reuseContext: boolean, - _mockingProxy?: MockingProxy, + _mockingProxy?: void, }; const playwrightFixtures: Fixtures = ({ @@ -124,12 +124,11 @@ const playwrightFixtures: Fixtures = ({ }, true); }, { scope: 'worker', timeout: 0 }], - _mockingProxy: [async ({ mockingProxy: mockingProxyOption, playwright }, use) => { - if (mockingProxyOption !== 'inject-via-header') - return await use(undefined); - const mockingProxy = await (playwright as PlaywrightImpl)._startMockingProxy(); - await use(mockingProxy); - }, { scope: 'worker', box: true }], + _mockingProxy: [async ({ mockingProxy, playwright }, use) => { + if (mockingProxy === 'inject-via-header') + await (playwright as PlaywrightImpl)._startMockingProxy(); + await use(); + }, { scope: 'worker', box: true, auto: true }], acceptDownloads: [({ contextOptions }, use) => use(contextOptions.acceptDownloads ?? true), { option: true }], bypassCSP: [({ contextOptions }, use) => use(contextOptions.bypassCSP ?? false), { option: true }], @@ -259,7 +258,7 @@ const playwrightFixtures: Fixtures = ({ } }, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any], - _setupArtifacts: [async ({ playwright, screenshot, _mockingProxy }, use, testInfo) => { + _setupArtifacts: [async ({ playwright, screenshot }, use, testInfo) => { // This fixture has a separate zero-timeout slot to ensure that artifact collection // happens even after some fixtures or hooks time out. // Now that default test timeout is known, we can replace zero with an actual value. @@ -313,8 +312,6 @@ const playwrightFixtures: Fixtures = ({ currentTestInfo()?._setDebugMode(); }, runAfterCreateBrowserContext: async (context: BrowserContextImpl) => { - if (_mockingProxy) - await context._subscribeToMockingProxy(_mockingProxy); await artifactsRecorder?.didCreateBrowserContext(context); const testInfo = currentTestInfo(); if (testInfo) diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 750709543d..f197aefe12 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1369,6 +1369,7 @@ export type BrowserNewContextParams = { cookies?: SetNetworkCookie[], origins?: OriginStorage[], }, + mockingProxyBaseURL?: string, }; export type BrowserNewContextOptions = { noDefaultViewport?: boolean, @@ -1435,6 +1436,7 @@ export type BrowserNewContextOptions = { cookies?: SetNetworkCookie[], origins?: OriginStorage[], }, + mockingProxyBaseURL?: string, }; export type BrowserNewContextResult = { context: BrowserContextChannel, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 2768d4cba5..c50d69492a 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1038,6 +1038,7 @@ Browser: origins: type: array? items: OriginStorage + mockingProxyBaseURL: string? returns: context: BrowserContext From 974e12366e06e2cd784395f20cc2baf502b4adb7 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 7 Feb 2025 15:48:15 +0100 Subject: [PATCH 21/22] use _allPages() --- packages/playwright-core/src/client/mockingProxy.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/playwright-core/src/client/mockingProxy.ts b/packages/playwright-core/src/client/mockingProxy.ts index 7886243ea1..2b2fbbd100 100644 --- a/packages/playwright-core/src/client/mockingProxy.ts +++ b/packages/playwright-core/src/client/mockingProxy.ts @@ -70,15 +70,7 @@ export class MockingProxy extends ChannelOwner { findPage(correlation: string): Page | undefined { const guid = `page@${correlation}`; - // TODO: move this as list onto Playwright directly - for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) { - for (const context of browserType._contexts) { - for (const page of context._pages) { - if (page._guid === guid) - return page; - } - } - } + return this._playwright._allPages().find(page => page._guid === guid); } baseURL() { From c322af9ed9fc517431cc04109b42a19bc3557fc6 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 7 Feb 2025 16:08:47 +0100 Subject: [PATCH 22/22] snippet lint --- docs/src/mock.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/src/mock.md b/docs/src/mock.md index 3e1c3e3c32..e7a88a17a0 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -568,7 +568,7 @@ To get started, enable the `mockingProxy` option in your Playwright config: ```js export default defineConfig({ - use: { mockingProxy: "inject-via-header" } + use: { mockingProxy: 'inject-via-header' } }); ``` @@ -724,7 +724,7 @@ const serverConfig = { (req, next) => { const proxy = inject(REQUEST)?.headers.get('x-playwright-proxy'); if (proxy) - req = req.clone({ url: decodeURIComponent(proxy) + req.url }) + req = req.clone({ url: decodeURIComponent(proxy) + req.url }); return next(req); }, ]), @@ -743,8 +743,8 @@ Set up a server-side fetch override in an Astro integration: ```js // astro.config.mjs import { defineConfig } from 'astro/config'; -import type { AstroIntegration } from "astro" -import { AsyncLocalStorage } from "async_hooks"; +import type { AstroIntegration } from 'astro'; +import { AsyncLocalStorage } from 'async_hooks'; const playwrightMockingProxy: AstroIntegration = { name: 'playwrightMockingProxy', @@ -784,24 +784,24 @@ export default defineConfig({ ```js // server/plugins/playwright-mocking-proxy.ts -import { getGlobalDispatcher, setGlobalDispatcher } from "undici" -import { useEvent, getRequestHeader } from '#imports' +import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'; +import { useEvent, getRequestHeader } from '#imports'; export default defineNitroPlugin(() => { if (process.env.NODE_ENV !== 'test') return; const proxiedDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { - const isInternal = opts.path.startsWith("/__nuxt") - const proxy = getRequestHeader(useEvent(), 'x-playwright-proxy') + const isInternal = opts.path.startsWith('/__nuxt'); + const proxy = getRequestHeader(useEvent(), 'x-playwright-proxy'); if (proxy && !isInternal) { const newURL = new URL(decodeURIComponent(proxy) + opts.origin + opts.path); opts.origin = newURL.origin; opts.path = newURL.pathname; } - return dispatch(opts, handler) - }) - setGlobalDispatcher(proxiedDispatcher) + return dispatch(opts, handler); + }); + setGlobalDispatcher(proxiedDispatcher); }); ``` @@ -813,5 +813,5 @@ export default defineNuxtConfig({ asyncContext: true, } } -}) +}); ```