Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions docs/src/mock.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
---

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Missing semicolon. 13 | const proxy = inject(REQUEST)?.headers.get('x-playwright-proxy'); 14 | if (proxy) > 15 | req = req.clone({ url: decodeURIComponent(proxy) + req.url }) | ^ 16 | return next(req); 17 | }, 18 | ]), Unable to lint: // 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); }, ]), ) ] }; /* ... */

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Strings must use singlequote. 1 | // astro.config.mjs 2 | import { defineConfig } from 'astro/config'; > 3 | import type { AstroIntegration } from "astro" | ^ 4 | import { AsyncLocalStorage } from "async_hooks"; 5 | 6 | const playwrightMockingProxy: AstroIntegration = { Unable to lint: // 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<string>(); 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 ] });

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Strings must use singlequote. 1 | // server/plugins/playwright-mocking-proxy.ts 2 | > 3 | import { getGlobalDispatcher, setGlobalDispatcher } from "undici" | ^ 4 | import { useEvent, getRequestHeader } from '#imports' 5 | 6 | export default defineNitroPlugin(() => { Unable to lint: // 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) });

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Missing semicolon. 6 | } 7 | } > 8 | }) | ^ Unable to lint: // nuxt.config.ts export default defineNuxtConfig({ nitro: { experimental: { asyncContext: true, } } })
id: mock
title: "Mock APIs"
---
Expand Down Expand Up @@ -554,3 +554,260 @@
```

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<Headers>();
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);
},
]),
)
]
};

/* ... */
```

#### Astro
* langs: js

Set up a server-side fetch override in an Astro integration:

```ts
// astro.config.mjs
import { defineConfig } from 'astro/config';
import type { AstroIntegration } from "astro"
import { AsyncLocalStorage } from "async_hooks";

const playwrightMockingProxy: AstroIntegration = {
name: 'playwrightMockingProxy',
hooks: {
'astro:server:setup': async astro => {
if (process.env.NODE_ENV !== 'test')
return;

const proxyStorage = new AsyncLocalStorage<string>();
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

```ts
// server/plugins/playwright-mocking-proxy.ts

import { getGlobalDispatcher, setGlobalDispatcher } from "undici"
import { useEvent, getRequestHeader } from '#imports'

export default defineNitroPlugin(() => {
if (process.env.NODE_ENV !== 'test')
return;

const proxiedDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => {
const isInternal = opts.path.startsWith("/__nuxt")
const proxy = getRequestHeader(useEvent(), 'x-playwright-proxy')
if (proxy && !isInternal) {
const newURL = new URL(decodeURIComponent(proxy) + opts.origin + opts.path);
opts.origin = newURL.origin;
opts.path = newURL.pathname;
}
return dispatch(opts, handler)
})
setGlobalDispatcher(proxiedDispatcher)
});
```

```ts
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: {
asyncContext: true,
}
}
})
```
16 changes: 16 additions & 0 deletions docs/src/test-api/class-testoptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
});
```
39 changes: 35 additions & 4 deletions packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<channels.BrowserContextChannel> implements api.BrowserContext {
_pages = new Set<Page>();
Expand All @@ -68,6 +70,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
_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;
Expand All @@ -90,7 +94,11 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
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);
Expand Down Expand Up @@ -157,9 +165,10 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this.tracing._tracesDir = browserOptions.tracesDir;
}

private _onPage(page: Page): void {
private async _onPage(page: Page): Promise<void>{
this._pages.add(page);
this.emit(Events.BrowserContext.Page, page);
await this._mockingProxy?.instrumentPage(page);
if (page._opener && !page._opener.isClosed())
page._opener.emit(Events.Page.Popup, page);
}
Expand Down Expand Up @@ -198,7 +207,6 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
}

async _onRoute(route: network.Route) {
route._context = this;
const page = route.request()._safePage();
const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) {
Expand Down Expand Up @@ -238,6 +246,19 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
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 () => {
Expand Down Expand Up @@ -400,6 +421,15 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
private async _updateInterceptionPatterns() {
const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes);
await this._channel.setNetworkInterceptionPatterns({ patterns });
await this._updateMockingProxyInterceptionPatterns();
}

async _updateMockingProxyInterceptionPatterns() {
if (!this._mockingProxy)
return;
const pageRoutes = this.pages().flatMap(page => page._routes);
const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes.concat(pageRoutes));
await this._mockingProxy.setInterceptionPatterns({ patterns });
}

private async _updateWebSocketInterceptionPatterns() {
Expand Down Expand Up @@ -457,6 +487,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this._disposeHarRouters();
this.tracing._resetStackCounter();
this.emit(Events.BrowserContext.Close, this);
eventsHelper.removeEventListeners(this._registeredListeners);
}

async [Symbol.asyncDispose]() {
Expand Down
Loading
Loading