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';