Skip to content

Commit

Permalink
feat(test runner): server side mocking
Browse files Browse the repository at this point in the history
fix linter

revert unneeded change
  • Loading branch information
Skn0tt committed Jan 28, 2025
1 parent 63f96ef commit 156918e
Show file tree
Hide file tree
Showing 22 changed files with 1,249 additions and 95 deletions.
176 changes: 176 additions & 0 deletions docs/src/mock.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,179 @@ await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
```

For more details, see [WebSocketRoute].

## Mock Server
* langs: js

By default, Playwright only has access to the network traffic made by the browser.
To mock and intercept traffic made by the application server, use Playwright's **experimental** mocking proxy. Note this feature is **experimental** and subject to change.

The mocking proxy is a HTTP proxy server that's connected to the currently running test.
If you send it a request, it will apply the network routes configured via `page.route` and `context.route`, reusing your existing browser routes.

To get started, enable the `mockingProxy` option in your Playwright config:

```ts
export default defineConfig({
use: { mockingProxy: true }
});
```

Playwright will now inject the proxy URL into all browser requests under the `x-playwright-proxy` header.
On your server, read the URL in this header and prepend it to all outgoing traffic you want to intercept:

```js
const headers = getCurrentRequestHeaders(); // this looks different for each application
const proxyURL = decodeURIComponent(headers.get('x-playwright-proxy') ?? '');
await fetch(proxyURL + 'https://api.example.com/users');
```
Prepending the URL will direct the request through the proxy. You can now intercept it with `context.route` and `page.route`, just like browser requests:
```ts
// shopping-cart.spec.ts
import { test, expect } from '@playwright/test';

test('checkout applies customer loyalty bonus points', async ({ page }) => {
await page.route('https://users.internal.example.com/loyalty/balance*', (route, request) => {
await route.fulfill({ json: { userId: '[email protected]', 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 { axiosfrom '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);
},
]),
)
]
};

/* ... */
```
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
},
});
```
31 changes: 27 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,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
private async _updateInterceptionPatterns() {
const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes);
await this._channel.setNetworkInterceptionPatterns({ patterns });
await this._mockingProxy?.setInterceptionPatterns({ patterns });
}

private async _updateWebSocketInterceptionPatterns() {
Expand Down Expand Up @@ -457,6 +479,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
4 changes: 4 additions & 0 deletions packages/playwright-core/src/client/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<channels.RootChannel> {
constructor(connection: Connection) {
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/client/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,8 @@ export const Events = {
Console: 'console',
Window: 'window',
},

MockingProxy: {
Route: 'route',
},
};
Loading

0 comments on commit 156918e

Please sign in to comment.