Skip to content

Commit 156918e

Browse files
committed
feat(test runner): server side mocking
fix linter revert unneeded change
1 parent 63f96ef commit 156918e

22 files changed

+1249
-95
lines changed

docs/src/mock.md

+176
Original file line numberDiff line numberDiff line change
@@ -554,3 +554,179 @@ await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
554554
```
555555

556556
For more details, see [WebSocketRoute].
557+
558+
## Mock Server
559+
* langs: js
560+
561+
By default, Playwright only has access to the network traffic made by the browser.
562+
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.
563+
564+
The mocking proxy is a HTTP proxy server that's connected to the currently running test.
565+
If you send it a request, it will apply the network routes configured via `page.route` and `context.route`, reusing your existing browser routes.
566+
567+
To get started, enable the `mockingProxy` option in your Playwright config:
568+
569+
```ts
570+
export default defineConfig({
571+
use: { mockingProxy: true }
572+
});
573+
```
574+
575+
Playwright will now inject the proxy URL into all browser requests under the `x-playwright-proxy` header.
576+
On your server, read the URL in this header and prepend it to all outgoing traffic you want to intercept:
577+
578+
```js
579+
const headers = getCurrentRequestHeaders(); // this looks different for each application
580+
const proxyURL = decodeURIComponent(headers.get('x-playwright-proxy') ?? '');
581+
await fetch(proxyURL + 'https://api.example.com/users');
582+
```
583+
584+
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:
585+
586+
```ts
587+
// shopping-cart.spec.ts
588+
import { test, expect } from '@playwright/test';
589+
590+
test('checkout applies customer loyalty bonus points', async ({ page }) => {
591+
await page.route('https://users.internal.example.com/loyalty/balance*', (route, request) => {
592+
await route.fulfill({ json: { userId: '[email protected]', balance: 100 } });
593+
});
594+
595+
await page.goto('http://localhost:3000/checkout');
596+
597+
await expect(page.getByRole('list')).toMatchAriaSnapshot(`
598+
- list "Cart":
599+
- listitem: Super Duper Hammer
600+
- listitem: Nails
601+
- listitem: 16mm Birch Plywood
602+
- text: "Price after applying 10$ loyalty discount: 79.99$"
603+
- button "Buy now"
604+
`);
605+
});
606+
```
607+
608+
Now, prepending the proxy URL manually can be cumbersome. If your HTTP client supports it, consider updating your client baseURL ...
609+
610+
```js
611+
import { axios } from 'axios';
612+
613+
const api = axios.create({
614+
baseURL: proxyURL + 'https://jsonplaceholder.typicode.com',
615+
});
616+
```
617+
618+
... or setting up a global interceptor:
619+
620+
```js
621+
import { axiosfrom 'axios';
622+
623+
axios.interceptors.request.use(async config => {
624+
config.baseURL = proxyURL + (config.baseURL ?? '/');
625+
return config;
626+
});
627+
```
628+
629+
```js
630+
import { setGlobalDispatcher, getGlobalDispatcher } from 'undici';
631+
632+
const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => {
633+
opts.path = opts.origin + opts.path;
634+
opts.origin = proxyURL;
635+
return dispatch(opts, handler);
636+
});
637+
setGlobalDispatcher(proxyingDispatcher); // this will also apply to global fetch
638+
```
639+
640+
:::note
641+
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.
642+
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!
643+
:::
644+
645+
646+
### Recipes
647+
* langs: js
648+
649+
#### Next.js
650+
* langs: js
651+
652+
Monkey-patch `globalThis.fetch` in your `instrumentation.ts` file:
653+
654+
```ts
655+
// instrumentation.ts
656+
657+
import { headers } from 'next/headers';
658+
659+
export function register() {
660+
if (process.env.NODE_ENV === 'test') {
661+
const originalFetch = globalThis.fetch;
662+
globalThis.fetch = async (input, init) => {
663+
const proxy = (await headers()).get('x-playwright-proxy');
664+
if (!proxy)
665+
return originalFetch(input, init);
666+
const request = new Request(input, init);
667+
return originalFetch(decodeURIComponent(proxy) + request.url, request);
668+
};
669+
}
670+
}
671+
```
672+
673+
#### Remix
674+
* langs: js
675+
676+
677+
Monkey-patch `globalThis.fetch` in your `entry.server.ts` file, and use `AsyncLocalStorage` to make current request headers available:
678+
679+
```ts
680+
import { setGlobalDispatcher, getGlobalDispatcher } from 'undici';
681+
import { AsyncLocalStorage } from 'node:async_hooks';
682+
683+
const headersStore = new AsyncLocalStorage<Headers>();
684+
if (process.env.NODE_ENV === 'test') {
685+
const originalFetch = globalThis.fetch;
686+
globalThis.fetch = async (input, init) => {
687+
const proxy = headersStore.getStore()?.get('x-playwright-proxy');
688+
if (!proxy)
689+
return originalFetch(input, init);
690+
const request = new Request(input, init);
691+
return originalFetch(decodeURIComponent(proxy) + request.url, request);
692+
};
693+
}
694+
695+
export default function handleRequest(request: Request, /* ... */) {
696+
return headersStore.run(request.headers, () => {
697+
// ...
698+
return handleBrowserRequest(request, /* ... */);
699+
});
700+
}
701+
```
702+
703+
#### Angular
704+
* langs: js
705+
706+
Configure your `HttpClient` with an [interceptor](https://angular.dev/guide/http/setup#withinterceptors):
707+
708+
```ts
709+
// app.config.server.ts
710+
711+
import { inject, REQUEST } from '@angular/core';
712+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
713+
714+
const serverConfig = {
715+
providers: [
716+
/* ... */
717+
provideHttpClient(
718+
/* ... */
719+
withInterceptors([
720+
(req, next) => {
721+
const proxy = inject(REQUEST)?.headers.get('x-playwright-proxy');
722+
if (proxy)
723+
req = req.clone({ url: decodeURIComponent(proxy) + req.url })
724+
return next(req);
725+
},
726+
]),
727+
)
728+
]
729+
};
730+
731+
/* ... */
732+
```

docs/src/test-api/class-testoptions.md

+16
Original file line numberDiff line numberDiff line change
@@ -676,3 +676,19 @@ export default defineConfig({
676676
},
677677
});
678678
```
679+
680+
## property: TestOptions.mockingProxy
681+
* since: v1.51
682+
- type: <[boolean]> Enables the mocking proxy. Playwright will inject the proxy URL into all outgoing requests under the `x-playwright-proxy` header.
683+
684+
**Usage**
685+
686+
```js title="playwright.config.ts"
687+
import { defineConfig } from '@playwright/test';
688+
689+
export default defineConfig({
690+
use: {
691+
mockingProxy: true
692+
},
693+
});
694+
```

packages/playwright-core/src/client/browserContext.ts

+27-4
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import { Events } from './events';
2929
import { TimeoutSettings } from '../common/timeoutSettings';
3030
import { Waiter } from './waiter';
3131
import type { Headers, WaitForEventOptions, BrowserContextOptions, StorageState, LaunchOptions } from './types';
32-
import { type URLMatch, headersObjectToArray, isRegExp, isString, urlMatchesEqual, mkdirIfNeeded } from '../utils';
32+
import type { RegisteredListener } from '../utils';
33+
import { type URLMatch, headersObjectToArray, isRegExp, isString, urlMatchesEqual, mkdirIfNeeded, eventsHelper } from '../utils';
3334
import type * as api from '../../types/types';
3435
import type * as structs from '../../types/structs';
3536
import { CDPSession } from './cdpSession';
@@ -44,6 +45,7 @@ import { Dialog } from './dialog';
4445
import { WebError } from './webError';
4546
import { TargetClosedError, parseError } from './errors';
4647
import { Clock } from './clock';
48+
import type { MockingProxy } from './mockingProxy';
4749

4850
export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel> implements api.BrowserContext {
4951
_pages = new Set<Page>();
@@ -68,6 +70,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
6870
_closeWasCalled = false;
6971
private _closeReason: string | undefined;
7072
private _harRouters: HarRouter[] = [];
73+
private _registeredListeners: RegisteredListener[] = [];
74+
_mockingProxy?: MockingProxy;
7175

7276
static from(context: channels.BrowserContextChannel): BrowserContext {
7377
return (context as any)._object;
@@ -90,7 +94,11 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
9094
this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding)));
9195
this._channel.on('close', () => this._onClose());
9296
this._channel.on('page', ({ page }) => this._onPage(Page.from(page)));
93-
this._channel.on('route', ({ route }) => this._onRoute(network.Route.from(route)));
97+
this._channel.on('route', params => {
98+
const route = network.Route.from(params.route);
99+
route._context = this.request;
100+
this._onRoute(route);
101+
});
94102
this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute)));
95103
this._channel.on('backgroundPage', ({ page }) => {
96104
const backgroundPage = Page.from(page);
@@ -157,9 +165,10 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
157165
this.tracing._tracesDir = browserOptions.tracesDir;
158166
}
159167

160-
private _onPage(page: Page): void {
168+
private async _onPage(page: Page): Promise<void>{
161169
this._pages.add(page);
162170
this.emit(Events.BrowserContext.Page, page);
171+
await this._mockingProxy?.instrumentPage(page);
163172
if (page._opener && !page._opener.isClosed())
164173
page._opener.emit(Events.Page.Popup, page);
165174
}
@@ -198,7 +207,6 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
198207
}
199208

200209
async _onRoute(route: network.Route) {
201-
route._context = this;
202210
const page = route.request()._safePage();
203211
const routeHandlers = this._routes.slice();
204212
for (const routeHandler of routeHandlers) {
@@ -238,6 +246,19 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
238246
await bindingCall.call(func);
239247
}
240248

249+
async _subscribeToMockingProxy(mockingProxy: MockingProxy) {
250+
if (this._mockingProxy)
251+
throw new Error('Multiple mocking proxies are not supported');
252+
this._mockingProxy = mockingProxy;
253+
this._registeredListeners.push(
254+
eventsHelper.addEventListener(this._mockingProxy, Events.MockingProxy.Route, (route: network.Route) => {
255+
const page = route.request()._safePage()!;
256+
page._onRoute(route);
257+
}),
258+
// TODO: should we also emit `request`, `response`, `requestFinished`, `requestFailed` events?
259+
);
260+
}
261+
241262
setDefaultNavigationTimeout(timeout: number | undefined) {
242263
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
243264
this._wrapApiCall(async () => {
@@ -400,6 +421,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
400421
private async _updateInterceptionPatterns() {
401422
const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes);
402423
await this._channel.setNetworkInterceptionPatterns({ patterns });
424+
await this._mockingProxy?.setInterceptionPatterns({ patterns });
403425
}
404426

405427
private async _updateWebSocketInterceptionPatterns() {
@@ -457,6 +479,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
457479
this._disposeHarRouters();
458480
this.tracing._resetStackCounter();
459481
this.emit(Events.BrowserContext.Close, this);
482+
eventsHelper.removeEventListeners(this._registeredListeners);
460483
}
461484

462485
async [Symbol.asyncDispose]() {

packages/playwright-core/src/client/connection.ts

+4
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { findValidator, ValidationError, type ValidatorContext } from '../protoc
4545
import { createInstrumentation } from './clientInstrumentation';
4646
import type { ClientInstrumentation } from './clientInstrumentation';
4747
import { formatCallLog, rewriteErrorMessage, zones } from '../utils';
48+
import { MockingProxy } from './mockingProxy';
4849

4950
class Root extends ChannelOwner<channels.RootChannel> {
5051
constructor(connection: Connection) {
@@ -279,6 +280,9 @@ export class Connection extends EventEmitter {
279280
if (!this._localUtils)
280281
this._localUtils = result as LocalUtils;
281282
break;
283+
case 'MockingProxy':
284+
result = new MockingProxy(parent, type, guid, initializer);
285+
break;
282286
case 'Page':
283287
result = new Page(parent, type, guid, initializer);
284288
break;

packages/playwright-core/src/client/events.ts

+4
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,8 @@ export const Events = {
9494
Console: 'console',
9595
Window: 'window',
9696
},
97+
98+
MockingProxy: {
99+
Route: 'route',
100+
},
97101
};

0 commit comments

Comments
 (0)