diff --git a/docs/src/mock.md b/docs/src/mock.md index 50bc3915ce..e7a88a17a0 100644 --- a/docs/src/mock.md +++ b/docs/src/mock.md @@ -554,3 +554,264 @@ 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: + +```js +export default defineConfig({ + use: { mockingProxy: 'inject-via-header' } +}); +``` + +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 [`method: BrowserContext.route`] and [`method: Page.route`], just like browser requests: +```js +// 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 setting up a global interceptor: + +```js +import { axios } from 'axios'; + +axios.interceptors.request.use(async config => { + 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; +}); +``` + +```js +import { setGlobalDispatcher, getGlobalDispatcher } from 'undici'; + +const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { + 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 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! +::: + +:::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. +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`. +::: + + +### Recipes +* langs: js + +#### Next.js +* langs: js + +Monkey-patch `globalThis.fetch` in your `instrumentation.ts` file: + +```js +// 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: + +```js +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): + +```js +// 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); + }, + ]), + ) + ] +}; + +/* ... */ +``` + +#### Astro +* langs: js + +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'; + +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 + ] +}); +``` + +#### Nuxt + +```js +// 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) { + const newURL = new URL(decodeURIComponent(proxy) + opts.origin + opts.path); + opts.origin = newURL.origin; + opts.path = newURL.pathname; + } + return dispatch(opts, handler); + }); + setGlobalDispatcher(proxiedDispatcher); +}); +``` + +```js +// nuxt.config.ts +export default defineNuxtConfig({ + nitro: { + experimental: { + asyncContext: true, + } + } +}); +``` diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index c285430616..df41058f36 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: <[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** + +```js title="playwright.config.ts" +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + use: { + mockingProxy: 'inject-via-header' + }, +}); +``` \ No newline at end of file diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index 10cac4d0f9..e9786dce32 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._playwright._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 83655887e6..f43dc64f06 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -90,7 +90,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._apiRequestContext = 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); @@ -136,7 +140,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)); @@ -164,19 +168,19 @@ export class BrowserContext extends ChannelOwner page._opener.emit(Events.Page.Popup, 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); @@ -184,11 +188,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) @@ -198,7 +198,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) { @@ -521,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) @@ -540,6 +539,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions contrast: options.contrast === null ? 'no-override' : options.contrast, 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 1932d6e2d8..00dd20fcd7 100644 --- a/packages/playwright-core/src/client/browserType.ts +++ b/packages/playwright-core/src/client/browserType.ts @@ -89,7 +89,7 @@ export class BrowserType extends ChannelOwner imple const logger = options.logger || this._playwright._defaultLaunchOptions?.logger; assert(!(options as any).port, 'Cannot specify a port without launching as a server.'); options = { ...this._playwright._defaultLaunchOptions, ...this._playwright._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, 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/fetch.ts b/packages/playwright-core/src/client/fetch.ts index a424004474..42ec6d1c08 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 | SetStorageState, clientCertificates?: ClientCertificate[]; @@ -62,6 +62,10 @@ export class APIRequest implements api.APIRequest { } async newContext(options: NewContextOptions = {}): Promise { + return await this._newContext(options, this._playwright._channel); + } + + async _newContext(options: NewContextOptions = {}, channel: channels.PlaywrightChannel | channels.LocalUtilsChannel): Promise { options = { ...this._playwright._defaultContextOptions, timeout: this._playwright._defaultContextTimeout, @@ -70,7 +74,7 @@ export class APIRequest implements api.APIRequest { const storageState = typeof options.storageState === 'string' ? JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')) : options.storageState; - 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/mockingProxy.ts b/packages/playwright-core/src/client/mockingProxy.ts new file mode 100644 index 0000000000..2b2fbbd100 --- /dev/null +++ b/packages/playwright-core/src/client/mockingProxy.ts @@ -0,0 +1,79 @@ +/** + * 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 type { APIRequestContext } from './fetch'; +import { assert } from '../utils'; +import type { Page } from './page'; +import type { Playwright } from './playwright'; + +export class MockingProxy extends ChannelOwner { + _requestContext!: APIRequestContext; + _playwright!: Playwright; + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.MockingProxyInitializer) { + super(parent, type, guid, initializer); + + this._channel.on('route', async (params: channels.MockingProxyRouteEvent) => { + const route = network.Route.from(params.route); + route._apiRequestContext = this._requestContext; + const page = route.request()._pageForMockingProxy!; + await page._onRoute(route); + }); + + this._channel.on('request', async (params: channels.MockingProxyRequestEvent) => { + const page = this.findPage(params.correlation); + assert(page); + const request = network.Request.from(params.request); + request._pageForMockingProxy = page; + page.context()._onRequest(request, page); + }); + + this._channel.on('requestFailed', async (params: channels.MockingProxyRequestFailedEvent) => { + const request = network.Request.from(params.request); + const page = request._pageForMockingProxy!; + 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); + 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()._pageForMockingProxy!; + page.context()._onResponse(response, page); + }); + } + + static from(channel: channels.MockingProxyChannel): MockingProxy { + return (channel as any)._object; + } + + findPage(correlation: string): Page | undefined { + const guid = `page@${correlation}`; + return this._playwright._allPages().find(page => page._guid === guid); + } + + baseURL() { + return this._initializer.baseURL; + } +} diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index a6b40307b3..c34ad5e564 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 = {}; + _pageForMockingProxy: Page | null = null; static from(request: channels.RequestChannel): Request { return (request as any)._object; @@ -200,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.'); @@ -216,7 +219,7 @@ export class Request extends ChannelOwner implements ap } _safePage(): Page | null { - return Frame.fromNullable(this._initializer.frame)?._page || null; + return Frame.fromNullable(this._initializer.frame)?._page ?? null; } serviceWorker(): Worker | null { @@ -291,7 +294,7 @@ export class Request extends ChannelOwner implements ap export class Route extends ChannelOwner implements api.Route { private _handlingPromise: ManualPromise | null = null; - _context!: BrowserContext; + _apiRequestContext!: APIRequestContext; _didThrow: boolean = false; static from(route: channels.RouteChannel): Route { @@ -339,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.request._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 d0db2cbae9..953f901a13 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._apiRequestContext = 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/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index 078e31259d..25386d5893 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -22,6 +22,7 @@ import { ChannelOwner } from './channelOwner'; import { Electron } from './electron'; import { APIRequest } from './fetch'; import { Selectors, SelectorsOwner } from './selectors'; +import { MockingProxy } from './mockingProxy'; import type { BrowserContextOptions, LaunchOptions } from 'playwright-core'; export class Playwright extends ChannelOwner { @@ -36,6 +37,7 @@ export class Playwright extends ChannelOwner { selectors: Selectors; readonly request: APIRequest; readonly errors: { TimeoutError: typeof TimeoutError }; + _mockingProxy?: MockingProxy; // Instrumentation. _defaultLaunchOptions?: LaunchOptions; @@ -92,4 +94,14 @@ export class Playwright extends ChannelOwner { _allPages() { return this._allContexts().flatMap(context => context.pages()); } + + + async _startMockingProxy() { + const requestContext = await this.request._newContext(undefined, this._connection.localUtils()._channel); + 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/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 d1e7c3d63c..0383a72cc1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -257,6 +257,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, @@ -344,6 +367,69 @@ scheme.LocalUtilsTraceDiscardedParams = tObject({ stacksId: tString, }); scheme.LocalUtilsTraceDiscardedResult = 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('SetOriginStorage'))), + })), + tracesDir: tOptional(tString), +}); +scheme.LocalUtilsNewRequestResult = tObject({ + request: tChannel(['APIRequestContext']), +}); +scheme.LocalUtilsNewMockingProxyParams = tObject({ + requestContext: tChannel(['APIRequestContext']), +}); +scheme.LocalUtilsNewMockingProxyResult = tObject({ + mockingProxy: tChannel(['MockingProxy']), +}); +scheme.MockingProxyInitializer = tObject({ + baseURL: tString, +}); +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']), +}); scheme.RootInitializer = tOptional(tObject({})); scheme.RootInitializeParams = tObject({ sdkLanguage: tEnum(['javascript', 'python', 'java', 'csharp']), @@ -722,6 +808,7 @@ scheme.BrowserNewContextParams = tObject({ cookies: tOptional(tArray(tType('SetNetworkCookie'))), origins: tOptional(tArray(tType('SetOriginStorage'))), })), + mockingProxyBaseURL: tOptional(tString), }); scheme.BrowserNewContextResult = tObject({ context: tChannel(['BrowserContext']), @@ -814,27 +901,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 c4469f52b0..b5dd65636f 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -46,7 +46,7 @@ import { RecorderApp } from './recorder/recorderApp'; import * as storageScript from './storageScript'; import * as utilityScriptSerializers from './isomorphic/utilityScriptSerializers'; -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..ecfc43bd3c 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 { MockingProxy } from '../mockingProxy'; +import { MockingProxyDispatcher } from './mockingProxyDispatcher'; +import { APIRequestContextDispatcher } from './networkDispatchers'; +import { GlobalAPIRequestContext } from '../fetch'; -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 _playwright: Playwright; constructor(scope: RootDispatcher, playwright: Playwright) { const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils'); @@ -59,6 +64,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. super(scope, localUtils, 'LocalUtils', { deviceDescriptors, }); + this._playwright = playwright; this._type_LocalUtils = true; } @@ -273,6 +279,18 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels. await removeFolders([session.tmpDir]); this._stackSessions.delete(stacksId!); } + + async newRequest(params: channels.LocalUtilsNewRequestParams, metadata?: CallMetadata): Promise { + const requestContext = new GlobalAPIRequestContext(this._playwright, params); + return { request: APIRequestContextDispatcher.from(this.parentScope(), requestContext) }; + } + + async newMockingProxy(params: channels.LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise { + const requestContext = (params.requestContext as APIRequestContextDispatcher)._object; + const mockingProxy = new MockingProxy(this._object, requestContext); + await mockingProxy.start(); + return { mockingProxy: new MockingProxyDispatcher(this, mockingProxy) }; + } } const redirectStatus = [301, 302, 303, 307, 308]; 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..58d3ea9db1 --- /dev/null +++ b/packages/playwright-core/src/server/dispatchers/mockingProxyDispatcher.ts @@ -0,0 +1,58 @@ +/** + * 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 { MockingProxy } from '../mockingProxy'; +import { Dispatcher } from './dispatcher'; +import type * as channels from '@protocol/channels'; +import { RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers'; +import type { Request } from '../network'; +import type { LocalUtilsDispatcher } from './localUtilsDispatcher'; + +export class MockingProxyDispatcher extends Dispatcher implements channels.MockingProxyChannel { + _type_MockingProxy = true; + _type_EventTarget = true; + + constructor(scope: LocalUtilsDispatcher, mockingProxy: MockingProxy) { + super(scope, mockingProxy, 'MockingProxy', { + baseURL: mockingProxy.baseURL(), + }); + + 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 }); + }); + this.addObjectListener(MockingProxy.Events.RequestFailed, (request: Request) => { + this._dispatchEvent('requestFailed', { + 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, request), + response: ResponseDispatcher.fromNullable(this, request._existingResponse()), + responseEndTiming: request._responseEndTiming, + }); + }); + } + + override _onDispose(): void { + this._object.stop(); + } +} diff --git a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts index 6c2468176a..199e3d84a9 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; } diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 71199dc8d5..00f812ddc9 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 b1f4d4c1c6..3ebbf70c67 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/mockingProxy.ts b/packages/playwright-core/src/server/mockingProxy.ts new file mode 100644 index 0000000000..edd48e1b8a --- /dev/null +++ b/packages/playwright-core/src/server/mockingProxy.ts @@ -0,0 +1,260 @@ +/** + * 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', + 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'); + 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(); + } + + async stop() { + await this._httpServer.stop(); + } + + port() { + 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); + + 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')); + }, + }); + + await this.onRoute(route); + } + + 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); + }); +} + +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..5ee6c48438 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 { + readonly 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/server/page.ts b/packages/playwright-core/src/server/page.ts index b8fdae2808..8ad50d48e4 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -366,8 +366,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-core/src/utils/httpServer.ts b/packages/playwright-core/src/utils/httpServer.ts index 1d78df4659..256bc24a57 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 652e8ecab1..35d7146ee2 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -24,6 +24,7 @@ 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 { BrowserContext as BrowserContextImpl } from '../../playwright-core/src/client/browserContext'; import type { Playwright as PlaywrightImpl } from '../../playwright-core/src/client/playwright'; import { currentTestInfo } from './common/globals'; export { expect } from './matchers/expect'; @@ -56,6 +57,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _optionContextReuseMode: ContextReuseMode, _optionConnectOptions: PlaywrightWorkerOptions['connectOptions'], _reuseContext: boolean, + _mockingProxy?: void, _pageSnapshot: PageSnapshotOption, }; @@ -74,6 +76,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 }], _pageSnapshot: ['off', { scope: 'worker', option: true }], _browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => { @@ -121,6 +124,12 @@ const playwrightFixtures: Fixtures = ({ }, true); }, { scope: 'worker', timeout: 0 }], + _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 }], colorScheme: [({ contextOptions }, use) => use(contextOptions.colorScheme === undefined ? 'light' : contextOptions.colorScheme), { option: true }], @@ -295,7 +304,7 @@ const playwrightFixtures: Fixtures = ({ if (!keepTestTimeout) currentTestInfo()?._setDebugMode(); }, - runAfterCreateBrowserContext: async (context: BrowserContext) => { + runAfterCreateBrowserContext: async (context: BrowserContextImpl) => { await artifactsRecorder?.didCreateBrowserContext(context); const testInfo = currentTestInfo(); if (testInfo) @@ -343,7 +352,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 ef839e44cb..9dbd22e765 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -6243,11 +6243,28 @@ 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: 'inject-via-header' + * }, + * }); + * ``` + * + */ + 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/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 522bbb779e..c5fecbd8b0 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; @@ -439,6 +442,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: { @@ -473,6 +501,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; + newRequest(params: LocalUtilsNewRequestParams, metadata?: CallMetadata): Promise; + newMockingProxy(params: LocalUtilsNewMockingProxyParams, metadata?: CallMetadata): Promise; } export type LocalUtilsZipParams = { zipFile: string, @@ -572,10 +602,127 @@ export type LocalUtilsTraceDiscardedOptions = { }; export type LocalUtilsTraceDiscardedResult = void; +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?: SetOriginStorage[], + }, + 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?: SetOriginStorage[], + }, + tracesDir?: string, +}; +export type LocalUtilsNewRequestResult = { + request: APIRequestContextChannel, +}; +export type LocalUtilsNewMockingProxyParams = { + requestContext: APIRequestContextChannel, +}; +export type LocalUtilsNewMockingProxyOptions = { + +}; +export type LocalUtilsNewMockingProxyResult = { + mockingProxy: MockingProxyChannel, +}; export interface LocalUtilsEvents { } +// ----------- MockingProxy ----------- +export type MockingProxyInitializer = { + baseURL: string, +}; +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; +} +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, +}; + +export interface MockingProxyEvents { + 'route': MockingProxyRouteEvent; + 'request': MockingProxyRequestEvent; + 'requestFailed': MockingProxyRequestFailedEvent; + 'requestFinished': MockingProxyRequestFinishedEvent; + 'response': MockingProxyResponseEvent; +} + // ----------- Root ----------- export type RootInitializer = {}; export interface RootEventTarget { @@ -1260,6 +1407,7 @@ export type BrowserNewContextParams = { cookies?: SetNetworkCookie[], origins?: SetOriginStorage[], }, + mockingProxyBaseURL?: string, }; export type BrowserNewContextOptions = { noDefaultViewport?: boolean, @@ -1327,6 +1475,7 @@ export type BrowserNewContextOptions = { cookies?: SetNetworkCookie[], origins?: SetOriginStorage[], }, + mockingProxyBaseURL?: string, }; export type BrowserNewContextResult = { context: BrowserContextChannel, @@ -1501,31 +1650,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 316ac8c2a8..3aa18b4ff7 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -584,6 +584,78 @@ 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: SetOriginStorage + tracesDir: string? + + +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 @@ -705,6 +777,52 @@ LocalUtils: parameters: stacksId: string + newRequest: + parameters: + $mixin: NewRequestParameters + returns: + request: APIRequestContext + + newMockingProxy: + parameters: + requestContext: APIRequestContext + returns: + mockingProxy: MockingProxy + +MockingProxy: + type: interface + + extends: EventTarget + + initializer: + baseURL: 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 + Root: type: interface @@ -745,52 +863,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: SetOriginStorage - tracesDir: string? - + $mixin: NewRequestParameters returns: request: APIRequestContext @@ -1023,6 +1096,7 @@ Browser: origins: type: array? items: SetOriginStorage + mockingProxyBaseURL: string? returns: context: BrowserContext @@ -1088,29 +1162,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..aecfc26a49 --- /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: "inject-via-header", + 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()).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); + 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: expect.any(Number), + 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 04121cb281..5a2d0852e8 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -236,11 +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: 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;