Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: server side mocking #34403

Closed
wants to merge 53 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
6f3504d
feat: MockingProxy
Skn0tt Jan 20, 2025
64000f8
Update docs/src/api/class-mockingproxyfactory.md
Skn0tt Jan 20, 2025
b2b9fe8
remove fixme
Skn0tt Jan 20, 2025
d3a78eb
remove map to prevent leak
Skn0tt Jan 20, 2025
ad1b86b
Merge remote-tracking branch 'refs/remotes/origin/mockingproxy' into …
Skn0tt Jan 20, 2025
048f6d9
more
Skn0tt Jan 23, 2025
10646b1
move under own dispatcher
Skn0tt Jan 23, 2025
7467885
some more
Skn0tt Jan 23, 2025
d615fd3
more
Skn0tt Jan 23, 2025
51b6696
add events
Skn0tt Jan 23, 2025
7f9c091
remove .off
Skn0tt Jan 23, 2025
1112663
more
Skn0tt Jan 23, 2025
b236072
fix all tests
Skn0tt Jan 23, 2025
a6bf1f4
one more test
Skn0tt Jan 23, 2025
d6bc3cf
rename _request
Skn0tt Jan 23, 2025
a4d4b5a
revert
Skn0tt Jan 23, 2025
3d89657
remove get-free-port
Skn0tt Jan 23, 2025
bb3defd
add draft for mock-js
Skn0tt Jan 24, 2025
e0e8f2e
move over
Skn0tt Jan 24, 2025
e580a4c
move stuff over to page
Skn0tt Jan 24, 2025
fdad749
uninstall
Skn0tt Jan 24, 2025
e0de516
only emit one event
Skn0tt Jan 24, 2025
7c598a7
move route._context
Skn0tt Jan 24, 2025
4d6a453
revert unneeded
Skn0tt Jan 24, 2025
0ae6af5
Merge branch 'main' into mockingproxy
Skn0tt Jan 27, 2025
7444a76
fix link
Skn0tt Jan 27, 2025
f0e54a4
more lint
Skn0tt Jan 27, 2025
c5aad39
lint
Skn0tt Jan 27, 2025
27f8be2
revert subscription logic
Skn0tt Jan 27, 2025
b2722c6
singular until we have a usecase
Skn0tt Jan 27, 2025
2652338
fix tests, don't emit on page
Skn0tt Jan 27, 2025
2921418
add property tests
Skn0tt Jan 27, 2025
fdfd8be
test securitydetails
Skn0tt Jan 27, 2025
f6685fc
test aborting
Skn0tt Jan 27, 2025
b1021f4
test fetch
Skn0tt Jan 27, 2025
303531a
delete unneeded file
Skn0tt Jan 27, 2025
a0ce23d
update header name
Skn0tt Jan 27, 2025
a1618d2
more lint
Skn0tt Jan 27, 2025
f2cba29
box the fixture to hide it
Skn0tt Jan 27, 2025
f3e0c20
inject page info
Skn0tt Jan 27, 2025
e4e743c
rename a little
Skn0tt Jan 27, 2025
694931a
test page.route
Skn0tt Jan 27, 2025
4887ca6
emit on page, even if we don't know what's the correct one
Skn0tt Jan 27, 2025
edff9fc
call it correlation
Skn0tt Jan 27, 2025
d199c51
use fallback
Skn0tt Jan 27, 2025
662d3b2
more fallback
Skn0tt Jan 27, 2025
4d4ca68
refactor
Skn0tt Jan 27, 2025
e7e5124
refactor
Skn0tt Jan 27, 2025
4a4d930
remove instance prop
Skn0tt Jan 27, 2025
8db16b2
set frame from outside
Skn0tt Jan 27, 2025
75a8ace
add more events
Skn0tt Jan 27, 2025
bcf1240
more events
Skn0tt Jan 27, 2025
e2fe511
test failure
Skn0tt Jan 27, 2025
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
453 changes: 453 additions & 0 deletions docs/src/api/class-mockingproxy.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions docs/src/api/class-mockingproxyfactory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# class: MockingProxyFactory
* since: v1.51

This class is used for creating [MockingProxy] instances which in turn can be used to intercept network traffic from your application server. An instance
of this class can be obtained via [`property: Playwright.mockingProxy`]. For more information
see [MockingProxy].

## async method: MockingProxyFactory.newProxy
* since: v1.16
Skn0tt marked this conversation as resolved.
Show resolved Hide resolved
- returns: <[MockingProxy]>

Creates a new instance of [MockingProxy].

### param: MockingProxyFactory.newProxy.port
* since: v1.51
- `port` <[int]>

Port to listen on.
4 changes: 4 additions & 0 deletions docs/src/api/class-playwright.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ Selectors can be used to install custom selector engines. See

This object can be used to launch or connect to WebKit, returning instances of [Browser].

## property: Playwright.mockingProxy
* since: v1.51
- type: <[MockingProxyFactory]>

## method: Playwright.close
* since: v1.9
* langs: java
Expand Down
166 changes: 166 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

js linting error

Error: Strings must use singlequote. 1 | test('calls the cms to fetch frontpage posts', async ({ page, server }) => { > 2 | await server.route("https://headless-cms.example.com/frontpage", (route, request) => { | ^ 3 | await route.fulfill({ 4 | json: [ 5 | { id: 1, title: 'Hello, World!' }, Unable to lint: test('calls the cms to fetch frontpage posts', async ({ page, server }) => { await server.route("https://headless-cms.example.com/frontpage", (route, request) => { await route.fulfill({ json: [ { id: 1, title: 'Hello, World!' }, { id: 2, title: 'Second post' }, { id: 2, title: 'Third post' } ] }); }) await page.goto('http://localhost:3000/'); await expect(page.getByRole('list')).toMatchAriaSnapshot(` - list: - listitem: Hello, World! - listitem: Second post - listitem: Third post `); });

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

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: 'proxyURL' is never reassigned. Use 'const' instead. > 1 | let proxyURL = isUnderTest ? 'http://localhost:8123/' : ''; | ^ 2 | await axios.get(proxyURL + 'https://headless-cms.example.com/items'); 3 | // or 4 | await fetch(proxyURL + 'https://headless-cms.example.com/frontpage'); Unable to lint: let proxyURL = isUnderTest ? 'http://localhost:8123/' : ''; await axios.get(proxyURL + 'https://headless-cms.example.com/items'); // or await fetch(proxyURL + 'https://headless-cms.example.com/frontpage');

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

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: 'proxyPort' is never reassigned. Use 'const' instead. > 1 | let proxyPort = await headers().get("x-playwright-proxy-port"); | ^ 2 | let proxyURL = proxyPort ? `http://localhost:${proxyPort}/` : ''; 3 | await axios.get(proxyURL + 'https://headless-cms.example.com/items'); 4 | // or Unable to lint: let proxyPort = await headers().get("x-playwright-proxy-port"); let proxyURL = proxyPort ? `http://localhost:${proxyPort}/` : ''; await axios.get(proxyURL + 'https://headless-cms.example.com/items'); // or await fetch(proxyURL + 'https://headless-cms.example.com/frontpage');

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

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: Strings must use singlequote. 1 | const api = axios.create({ > 2 | baseURL: "https://jsonplaceholder.typicode.com", | ^ 3 | }); 4 | 5 | if (isUnderTest) { Unable to lint: const api = axios.create({ baseURL: "https://jsonplaceholder.typicode.com", }); if (isUnderTest) { api.interceptors.request.use(async config => { config.proxy = { protocol: "http", host: "localhost", port: 8123 }; return config; }); }

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

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: Strings must use singlequote. > 1 | import { setGlobalDispatcher, getGlobalDispatcher } from "undici"; | ^ 2 | 3 | if (isUnderTest) { 4 | const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { Unable to lint: import { setGlobalDispatcher, getGlobalDispatcher } from "undici"; if (isUnderTest) { const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { opts.path = opts.origin + opts.path; opts.origin = `http://localhost:8123`; return dispatch(opts, handler); }) setGlobalDispatcher(proxyingDispatcher); }

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

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Parsing error: Invalid character. > 1 | # playwright.config.ts 2 | export default defineConfig({ 3 | workers: 1, // disable parallelism because we can't share the proxy server across multiple workers 4 | ... Unable to lint: # playwright.config.ts export default defineConfig({ workers: 1, // disable parallelism because we can't share the proxy server across multiple workers ... use: { ... mockingProxy: { port: 8123 // example port } }, });

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

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Parsing error: Invalid character. > 1 | # playwright.config.ts 2 | export default defineConfig({ 3 | use: { 4 | ... Unable to lint: # playwright.config.ts export default defineConfig({ use: { ... mockingProxy: { port: 'inject' } }, });
id: mock
title: "Mock APIs"
---
Expand Down Expand Up @@ -554,3 +554,169 @@
```

For more details, see [WebSocketRoute].

## Mock Application Server

If you want to intercept network traffic originating from the server, you can use [MockingProxy] to intercept and mock network traffic going through a proxy server.

```js
test('calls the cms to fetch frontpage posts', async ({ page, server }) => {
await server.route("https://headless-cms.example.com/frontpage", (route, request) => {
await route.fulfill({
json: [
{ id: 1, title: 'Hello, World!' },
{ id: 2, title: 'Second post' },
{ id: 2, title: 'Third post' }
]
});
})

await page.goto('http://localhost:3000/');

await expect(page.getByRole('list')).toMatchAriaSnapshot(`
- list:
- listitem: Hello, World!
- listitem: Second post
- listitem: Third post
`);
});
```

You can configure the port of the proxy server in the `playwright.config.ts` file.

```ts
# playwright.config.ts
export default defineConfig({
workers: 1, // disable parallelism because we can't share the proxy server across multiple workers
...
use: {
...
mockingProxy: {
port: 8123 // example port
}
},
});
```

We need to disable parallelism because we can't share the proxy server across multiple workers.

Now, configure your application server to route HTTP traffic through `http://localhost:8123/`.

#### `.env` file

If you're using a `.env` file to configure API endpoints, prepend the proxy server URL:

```env
# .env.test
CMS_BASE_URL=http://localhost:8123/https://headless-cms.example.com
STOREFRONT_BASE_URL=http://localhost:8123/https://api.myexample.com
```

#### `HTTP_PROXY` environment variable

If all your requests are going to localhost, you can use the `HTTP_PROXY` environment variable to route all requests through the proxy server.

```bash
HTTP_PROXY=http://localhost:8888
```

This environment variable is interpreted by many HTTP clients, including Node.js `axios` and Python `requests`.

Pay attention though: it's important that you use `HTTP_PROXY` and not `HTTPS_PROXY` because the latter will use [`CONNECT`-style proxying](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT) where the proxy cannot intercept the traffic.

#### Manual

In your server code, prepend the proxy server URL to all outgoing requests:

```js
let proxyURL = isUnderTest ? 'http://localhost:8123/' : '';
await axios.get(proxyURL + 'https://headless-cms.example.com/items');
// or
await fetch(proxyURL + 'https://headless-cms.example.com/frontpage');
```

```python
proxy_url = "http://localhost:8123/" if is_under_test else ""
requests.get(proxy_url + "https://headless-cms.example.com/frontpage")
```

```csharp
var proxyURL = isUnderTest ? "http://localhost:8123/" : "";
await client.GetAsync(proxyURL + "https://headless-cms.example.com/frontpage");
```

#### Injecting the proxy port

The previous examples all use a single proxy server with a hard-coded port. This has the downside that you can't run tests in parallel.
If your application allows accessing current request headers conveniently, you can use `inject` mode to dynamically create one proxy server per worker, and inject the port into the request headers.

To do this, set `mockingProxy.port` to `'inject'` in your `playwright.config.ts`:

```ts
# playwright.config.ts
export default defineConfig({
use: {
...
mockingProxy: {
port: 'inject'
}
},
});
```

Now, you can access the proxy port from the request headers:

```js
let proxyPort = await headers().get("x-playwright-proxy-port");
let proxyURL = proxyPort ? `http://localhost:${proxyPort}/` : '';
await axios.get(proxyURL + 'https://headless-cms.example.com/items');
// or
await fetch(proxyURL + 'https://headless-cms.example.com/frontpage');
```

```python
proxy_port = request.headers.get("x-playwright-proxy-port")
proxy_url = f"http://localhost:{proxy_port}/" if proxy_port else ""
requests.get(proxy_url + "https://headless-cms.example.com/frontpage")
```

```csharp
var proxyPort = httpContextAccessor.HttpContext?.Request.Headers["x-playwright-proxy-port"];
var proxyURL = proxyPort.HasValue ? $"http://localhost:{proxyPort}/" : "";
await client.GetAsync(proxyURL + "https://headless-cms.example.com/frontpage");
```

#### Interceptors

If your HTTP client or runtime supports HTTP interceptors, you can use them to prepend the proxy URL to all outgoing requests
with minimal changes to your existing code:

##### Node.js Axios

```js
const api = axios.create({
baseURL: "https://jsonplaceholder.typicode.com",
});

if (isUnderTest) {
api.interceptors.request.use(async config => {
config.proxy = { protocol: "http", host: "localhost", port: 8123 };
return config;
});
}
```

##### Node.js fetch / undici

```js
import { setGlobalDispatcher, getGlobalDispatcher } from "undici";

if (isUnderTest) {
const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => {
opts.path = opts.origin + opts.path;
opts.origin = `http://localhost:8123`;
return dispatch(opts, handler);
})
setGlobalDispatcher(proxyingDispatcher);
}
```
6 changes: 6 additions & 0 deletions docs/src/test-api/class-fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,9 @@ test('basic test', async ({ request }) => {
// ...
});
```

## property: Fixtures.server
* since: v1.51
- type: <[MockingProxy]>

Instance of [MockingProxy] that can be used to intercept network requests from your application server.
19 changes: 19 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,22 @@ export default defineConfig({
},
});
```

## property: TestOptions.mockingProxy
* since: v1.51
- type: <[Object]>
- `port` <[int]|"inject"> What port to start the mocking proxy on. If set to `"inject"`, Playwright will use a free port and inject it into all outgoing requests under the `x-playwright-proxy-port` parameter.

**Usage**

```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';

export default defineConfig({
use: {
mockingProxy: {
port: 9956,
},
},
});
```
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
}

async _onRoute(route: network.Route) {
route._context = this;
route._request = this.request;
const page = route.request()._safePage();
const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) {
Expand Down
3 changes: 3 additions & 0 deletions packages/playwright-core/src/client/localUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import type * as channels from '@protocol/channels';
import { ChannelOwner } from './channelOwner';
import type { Size } from './types';
import { APIRequestContext } from './fetch';

type DeviceDescriptor = {
userAgent: string,
Expand All @@ -30,12 +31,14 @@ type Devices = { [name: string]: DeviceDescriptor };

export class LocalUtils extends ChannelOwner<channels.LocalUtilsChannel> {
readonly devices: Devices;
readonly requestContext: APIRequestContext;

constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.LocalUtilsInitializer) {
super(parent, type, guid, initializer);
this.markAsInternalType();
this.devices = {};
for (const { name, descriptor } of initializer.deviceDescriptors)
this.devices[name] = descriptor;
this.requestContext = APIRequestContext.from(initializer.requestContext);
}
}
Loading
Loading