feat: MockingProxy

This commit is contained in:
Simon Knott 2025-01-20 16:25:46 +01:00
parent 99fb188cb4
commit 6f3504d931
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
28 changed files with 2287 additions and 93 deletions

View file

@ -0,0 +1,453 @@
# class: MockingProxy
* since: v1.51
`MockingProxy` allows you to intercept network traffic from your application server.
```js
const { webkit, mockingProxy } = require('playwright'); // Or 'chromium' or 'firefox'.
(async () => {
const browser = await webkit.launch();
const context = await browser.newContext();
const server = await mockingProxy.newProxy(8888); // point your application server to MockingProxy all requests through this port
await server.route("https://headless-cms.example.com/posts", (route, request) => {
await route.fulfill({
json: [
{ id: 1, title: 'Hello, World!' },
{ id: 2, title: 'Second post' },
{ id: 2, title: 'Third post' }
]
});
})
const page = await context.newPage();
await page.goto('https://localhost:3000/posts');
console.log(await page.getByRole('list').ariaSnapshot())
// - list:
// - listitem: Hello, World!
// - listitem: Second post
// - listitem: Third post
})();
```
## async method: MockingProxy.route
* since: v1.51
Routing provides the capability to modify network requests that are made through the MockingProxy.
Once routing is enabled, every request matching the url pattern will stall unless it's continued, fulfilled or aborted.
**Usage**
An example of a naive handler that aborts all requests to a specific domain:
```js
const page = await browser.newPage();
const server = await page.context().newMockingProxy(8888)
await server.route('https://api.example.com', route => route.abort()); // simulates this API being unreachable
await page.goto('http://localhost:3000');
```
It is possible to examine the request to decide the route action. For example, mocking all requests that contain some post data, and leaving all other requests as is:
```js
await serer.route('https://api.example.com/*', async route => {
if (route.request().postData().includes('my-string'))
await route.fulfill({ body: 'mocked-data' });
else
await route.continue();
})
```
To remove a route with its handler you can use [`method: MockingProxy.unroute`].
### param: MockingProxy.route.url
* since: v1.51
- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]>
A glob pattern, regex pattern or predicate receiving [URL] to match while routing.
When a [`option: Browser.newContext.baseURL`] via the context options was provided and the passed URL is a path,
it gets merged via the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor.
### param: MockingProxy.route.handler
* since: v1.51
* langs: js, python
- `handler` <[function]\([Route], [Request]\): [Promise<any>|any]>
handler function to route the request.
### param: MockingProxy.route.handler
* since: v1.51
* langs: csharp, java
- `handler` <[function]\([Route]\)>
handler function to route the request.
### option: MockingProxy.route.times
* since: v1.51
- `times` <[int]>
How often a route should be used. By default it will be used every time.
## async method: MockingProxy.unrouteAll
* since: v1.51
Removes all routes created with [`method: MockingProxy.route`].
### option: MockingProxy.unrouteAll.behavior = %%-unroute-all-options-behavior-%%
* since: v1.51
## async method: MockingProxy.unroute
* since: v1.51
Removes a route created with [`method: MockingProxy.route`]. When [`param: handler`] is not specified, removes all
routes for the [`param: url`].
### param: MockingProxy.unroute.url
* since: v1.51
- `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]>
A glob pattern, regex pattern or predicate receiving [URL] used to register a routing with
[`method: MockingProxy.route`].
### param: MockingProxy.unroute.handler
* since: v1.51
* langs: js, python
- `handler` ?<[function]\([Route], [Request]\): [Promise<any>|any]>
Optional handler function used to register a routing with [`method: MockingProxy.route`].
### param: MockingProxy.unroute.handler
* since: v1.51
* langs: csharp, java
- `handler` ?<[function]\([Route]\)>
Optional handler function used to register a routing with [`method: MockingProxy.route`].
## event: MockingProxy.request
* since: v1.51
- argument: <[Request]>
Emitted when a request passes through the MockingProxy. The [request] object is read-only. In order to intercept and mutate requests, see
[`method: MockingProxy.route`].
## event: MockingProxy.requestfailed
* since: v1.51
- argument: <[Request]>
Emitted when a request fails, for example by timing out.
## event: MockingProxy.requestfinished
* since: v1.51
- argument: <[Request]>
Emitted when a request finishes successfully after downloading the response body. For a successful response, the
sequence of events is `request`, `response` and `requestfinished`.
## event: MockingProxy.response
* since: v1.51
- argument: <[Response]>
Emitted when [response] status and headers are received for a request. For a successful response, the sequence of events
is `request`, `response` and `requestfinished`.
## async method: MockingProxy.waitForEvent
* since: v1.51
* langs: js, python
- alias-python: expect_event
- returns: <[any]>
Waits for event to fire and passes its value into the predicate function. Returns when the predicate returns truthy
value. Will throw an error if the page is closed before the event is fired. Returns the event data value.
**Usage**
```js
const requestPromise = MockingProxy.waitForEvent('request');
await page.getByText('Download file').click();
const download = await requestPromise;
```
```python async
async with MockingProxy.expect_event("request") as event_info:
await page.get_by_role("button")
frame = await event_info.value
```
```python sync
with MockingProxy.expect_event("request") as event_info:
page.get_by_role("button")
frame = event_info.value
```
### param: MockingProxy.waitForEvent.event = %%-wait-for-event-event-%%
* since: v1.51
### param: MockingProxy.waitForEvent.optionsOrPredicate
* since: v1.51
* langs: js
- `optionsOrPredicate` ?<[function]|[Object]>
- `predicate` <[function]> Receives the event data and resolves to truthy value when the waiting should resolve.
- `timeout` ?<[float]> Maximum time to wait for in milliseconds. Defaults to `0` - no timeout.
Either a predicate that receives an event or an options object. Optional.
### option: MockingProxy.waitForEvent.predicate = %%-wait-for-event-predicate-%%
* since: v1.51
### option: MockingProxy.waitForEvent.timeout = %%-wait-for-event-timeout-%%
* since: v1.51
## async method: MockingProxy.waitForRequest
* since: v1.51
* langs:
* alias-python: expect_request
* alias-csharp: RunAndWaitForRequest
- returns: <[Request]>
Waits for the matching request and returns it. See [waiting for event](../events.md#waiting-for-event) for more details about events.
**Usage**
```js
// Start waiting for request before clicking. Note no await.
const requestPromise = MockingProxy.waitForRequest('https://example.com/resource');
await page.getByText('trigger request').click();
const request = await requestPromise;
// Alternative way with a predicate. Note no await.
const requestPromise = MockingProxy.waitForRequest(request =>
request.url() === 'https://example.com' && request.method() === 'GET',
);
await page.getByText('trigger request').click();
const request = await requestPromise;
```
```java
// Waits for the next request with the specified url
Request request = MockingProxy.waitForRequest("https://example.com/resource", () -> {
// Triggers the request
page.getByText("trigger request").click();
});
// Waits for the next request matching some conditions
Request request = MockingProxy.waitForRequest(request -> "https://example.com".equals(request.url()) && "GET".equals(request.method()), () -> {
// Triggers the request
page.getByText("trigger request").click();
});
```
```python async
async with MockingProxy.expect_request("http://example.com/resource") as first:
await page.get_by_text("trigger request").click()
first_request = await first.value
# or with a lambda
async with MockingProxy.expect_request(lambda request: request.url == "http://example.com" and request.method == "get") as second:
await page.get_by_text("trigger request").click()
second_request = await second.value
```
```python sync
with MockingProxy.expect_request("http://example.com/resource") as first:
page.get_by_text("trigger request").click()
first_request = first.value
# or with a lambda
with MockingProxy.expect_request(lambda request: request.url == "http://example.com" and request.method == "get") as second:
page.get_by_text("trigger request").click()
second_request = second.value
```
```csharp
// Waits for the next request with the specified url.
await MockingProxy.RunAndWaitForRequestAsync(async () =>
{
await page.GetByText("trigger request").ClickAsync();
}, "http://example.com/resource");
// Alternative way with a predicate.
await MockingProxy.RunAndWaitForRequestAsync(async () =>
{
await page.GetByText("trigger request").ClickAsync();
}, request => request.Url == "https://example.com" && request.Method == "GET");
```
## async method: MockingProxy.waitForRequest
* since: v1.51
* langs: python
- returns: <[EventContextManager]<[Request]>>
### param: MockingProxy.waitForRequest.action = %%-csharp-wait-for-event-action-%%
* since: v1.51
### param: MockingProxy.waitForRequest.urlOrPredicate
* since: v1.51
- `urlOrPredicate` <[string]|[RegExp]|[function]\([Request]\):[boolean]>
Request URL string, regex or predicate receiving [Request] object.
### param: MockingProxy.waitForRequest.urlOrPredicate
* since: v1.51
* langs: js
- `urlOrPredicate` <[string]|[RegExp]|[function]\([Request]\):[boolean]|[Promise]<[boolean]>>
Request URL string, regex or predicate receiving [Request] object.
### option: MockingProxy.waitForRequest.timeout
* since: v1.51
- `timeout` <[float]>
Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout. The default value can be
changed by using the [`method: Page.setDefaultTimeout`] method.
### param: MockingProxy.waitForRequest.callback = %%-java-wait-for-event-callback-%%
* since: v1.51
## async method: MockingProxy.waitForRequestFinished
* since: v1.51
* langs: java, python, csharp
- alias-python: expect_request_finished
- alias-csharp: RunAndWaitForRequestFinished
- returns: <[Request]>
Performs action and waits for a [Request] to finish loading. If predicate is provided, it passes
[Request] value into the `predicate` function and waits for `predicate(request)` to return a truthy value.
## async method: MockingProxy.waitForRequestFinished
* since: v1.51
* langs: python
- returns: <[EventContextManager]<[Request]>>
### param: MockingProxy.waitForRequestFinished.action = %%-csharp-wait-for-event-action-%%
* since: v1.51
### option: MockingProxy.waitForRequestFinished.predicate
* since: v1.51
- `predicate` <[function]\([Request]\):[boolean]>
Receives the [Request] object and resolves to truthy value when the waiting should resolve.
### option: MockingProxy.waitForRequestFinished.timeout = %%-wait-for-event-timeout-%%
* since: v1.51
### param: MockingProxy.waitForRequestFinished.callback = %%-java-wait-for-event-callback-%%
* since: v1.51
## async method: MockingProxy.waitForResponse
* since: v1.51
* langs:
* alias-python: expect_response
* alias-csharp: RunAndWaitForResponse
- returns: <[Response]>
Returns the matched response. See [waiting for event](../events.md#waiting-for-event) for more details about events.
**Usage**
```js
// Start waiting for response before clicking. Note no await.
const responsePromise = MockingProxy.waitForResponse('https://example.com/resource');
await page.getByText('trigger response').click();
const response = await responsePromise;
// Alternative way with a predicate. Note no await.
const responsePromise = MockingProxy.waitForResponse(response =>
response.url() === 'https://example.com' && response.status() === 200
&& response.request().method() === 'GET'
);
await page.getByText('trigger response').click();
const response = await responsePromise;
```
```java
// Waits for the next response with the specified url
Response response = MockingProxy.waitForResponse("https://example.com/resource", () -> {
// Triggers the response
page.getByText("trigger response").click();
});
// Waits for the next response matching some conditions
Response response = MockingProxy.waitForResponse(response -> "https://example.com".equals(response.url()) && response.status() == 200 && "GET".equals(response.request().method()), () -> {
// Triggers the response
page.getByText("trigger response").click();
});
```
```python async
async with MockingProxy.expect_response("https://example.com/resource") as response_info:
await page.get_by_text("trigger response").click()
response = await response_info.value
return response.ok
# or with a lambda
async with MockingProxy.expect_response(lambda response: response.url == "https://example.com" and response.status == 200 and response.request.method == "get") as response_info:
await page.get_by_text("trigger response").click()
response = await response_info.value
return response.ok
```
```python sync
with MockingProxy.expect_response("https://example.com/resource") as response_info:
page.get_by_text("trigger response").click()
response = response_info.value
return response.ok
# or with a lambda
with MockingProxy.expect_response(lambda response: response.url == "https://example.com" and response.status == 200 and response.request.method == "get") as response_info:
page.get_by_text("trigger response").click()
response = response_info.value
return response.ok
```
```csharp
// Waits for the next response with the specified url.
await MockingProxy.RunAndWaitForResponseAsync(async () =>
{
await page.GetByText("trigger response").ClickAsync();
}, "http://example.com/resource");
// Alternative way with a predicate.
await MockingProxy.RunAndWaitForResponseAsync(async () =>
{
await page.GetByText("trigger response").ClickAsync();
}, response => response.Url == "https://example.com" && response.Status == 200 && response.Request.Method == "GET");
```
## async method: MockingProxy.waitForResponse
* since: v1.51
* langs: python
- returns: <[EventContextManager]<[Response]>>
### param: MockingProxy.waitForResponse.action = %%-csharp-wait-for-event-action-%%
* since: v1.51
### param: MockingProxy.waitForResponse.urlOrPredicate
* since: v1.51
- `urlOrPredicate` <[string]|[RegExp]|[function]\([Response]\):[boolean]>
Request URL string, regex or predicate receiving [Response] object.
### param: MockingProxy.waitForResponse.urlOrPredicate
* since: v1.51
* langs: js
- `urlOrPredicate` <[string]|[RegExp]|[function]\([Response]\):[boolean]|[Promise]<[boolean]>>
Request URL string, regex or predicate receiving [Response] object.
### option: MockingProxy.waitForResponse.timeout
* since: v1.51
- `timeout` <[float]>
Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout.
### param: MockingProxy.waitForResponse.callback = %%-java-wait-for-event-callback-%%
* since: v1.51
## method: MockingProxy.port
* since: v1.51
- returns: <[int]>

View file

@ -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
- returns: <[MockingProxy]>
Creates a new instance of [MockingProxy].
### param: MockingProxyFactory.newProxy.port
* since: v1.51
- `port` <[int]>
Port to listen on.

View file

@ -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

View file

@ -554,3 +554,169 @@ await page.RouteWebSocketAsync("wss://example.com/ws", ws => {
```
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);
}
```

View file

@ -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.

View file

@ -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,
},
},
});
```

View file

@ -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) {

View file

@ -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,
@ -30,6 +31,7 @@ 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);
@ -37,5 +39,6 @@ export class LocalUtils extends ChannelOwner<channels.LocalUtilsChannel> {
this.devices = {};
for (const { name, descriptor } of initializer.deviceDescriptors)
this.devices[name] = descriptor;
this.requestContext = APIRequestContext.from(initializer.requestContext);
}
}

View file

@ -0,0 +1,202 @@
/**
* 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 type * as api from '../../types/types';
import * as network from './network';
import { urlMatches, urlMatchesEqual, type URLMatch } from '../utils/isomorphic/urlMatch';
import type { LocalUtils } from './localUtils';
import type * as channels from '@protocol/channels';
import { EventEmitter } from './eventEmitter';
import type { WaitForEventOptions } from './types';
import { Waiter } from './waiter';
import { Events } from './events';
import { isString } from '../utils/isomorphic/stringUtils';
import { isRegExp } from '../utils';
import { trimUrl } from './page';
import { TimeoutSettings } from '../common/timeoutSettings';
export class MockingProxyFactory implements api.MockingProxyFactory {
constructor(private _localUtils: LocalUtils) {}
async newProxy(port: number): Promise<api.MockingProxy> {
return await MockingProxy.create(this._localUtils, port);
}
}
export class MockingProxy extends EventEmitter implements api.MockingProxy {
_routes: network.RouteHandler[] = [];
private _localUtils: LocalUtils;
private _port: number;
private _timeoutSettings = new TimeoutSettings();
private routeListener = ({ route }: channels.LocalUtilsRouteEvent) => {
this._onRoute(network.Route.from(route));
};
private failedListener = (params: channels.LocalUtilsRequestFailedEvent) => {
const request = network.Request.from(params.request);
if (params.failureText)
request._failureText = params.failureText;
request._setResponseEndTiming(params.responseEndTiming);
this.emit('requestfailed', request);
};
private finishedListener = (params: channels.LocalUtilsRequestFinishedEvent) => {
const { responseEndTiming } = params;
const request = network.Request.from(params.request);
const response = network.Response.fromNullable(params.response);
request._setResponseEndTiming(responseEndTiming);
this.emit('requestfinished', request);
response?._finishedPromise.resolve(null);
};
private responseListener = ({ response }: channels.LocalUtilsResponseEvent) => {
this.emit('response', network.Response.from(response));
};
private requestListener = ({ request }: channels.LocalUtilsRequestEvent) => {
this.emit('request', network.Request.from(request));
};
private constructor(localUtils: LocalUtils, port: number) {
super();
this._localUtils = localUtils;
this._port = port;
this._localUtils._channel.on('route', this.routeListener);
this._localUtils._channel.on('request', this.requestListener);
this._localUtils._channel.on('requestFailed', this.failedListener);
this._localUtils._channel.on('requestFinished', this.finishedListener);
this._localUtils._channel.on('response', this.responseListener);
}
private async _start() {
await this._localUtils._channel.setServerNetworkInterceptionPatterns({ patterns: [], port: this._port });
}
static async create(localUtils: LocalUtils, port: number) {
const instance = new MockingProxy(localUtils, port);
await instance._start();
return instance;
}
dispose() {
this._localUtils._channel.off('route', this.routeListener);
this._localUtils._channel.off('request', this.requestListener);
this._localUtils._channel.off('requestFailed', this.failedListener);
this._localUtils._channel.off('requestFinished', this.finishedListener);
this._localUtils._channel.off('response', this.responseListener);
}
async route(url: URLMatch, handler: network.RouteHandlerCallback, options: { times?: number } = {}): Promise<void> {
this._routes.unshift(new network.RouteHandler(undefined, url, handler, options.times));
await this._updateInterceptionPatterns();
}
async unrouteAll(options?: { behavior?: 'wait' | 'ignoreErrors' | 'default' }): Promise<void> {
await this._unrouteInternal(this._routes, [], options?.behavior);
}
async unroute(url: URLMatch, handler?: network.RouteHandlerCallback): Promise<void> {
const removed = [];
const remaining = [];
for (const route of this._routes) {
if (urlMatchesEqual(route.url, url) && (!handler || route.handler === handler))
removed.push(route);
else
remaining.push(route);
}
await this._unrouteInternal(removed, remaining, 'default');
}
private async _unrouteInternal(removed: network.RouteHandler[], remaining: network.RouteHandler[], behavior?: 'wait' | 'ignoreErrors' | 'default'): Promise<void> {
this._routes = remaining;
await this._updateInterceptionPatterns();
if (!behavior || behavior === 'default')
return;
const promises = removed.map(routeHandler => routeHandler.stop(behavior));
await Promise.all(promises);
}
async _onRoute(route: network.Route) {
route._request = this._localUtils.requestContext;
const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) {
if (!routeHandler.matches(route.request().url()))
continue;
const index = this._routes.indexOf(routeHandler);
if (index === -1)
continue;
if (routeHandler.willExpire())
this._routes.splice(index, 1);
const handled = await routeHandler.handle(route);
if (!this._routes.length)
this._updateInterceptionPatterns();
if (handled)
return;
}
// If the page is closed or unrouteAll() was called without waiting and interception disabled,
// the method will throw an error - silence it.
await route._innerContinue(true /* isFallback */).catch(() => { });
}
private async _updateInterceptionPatterns() {
const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes);
await this._localUtils._channel.setServerNetworkInterceptionPatterns({ patterns, port: this._port });
}
async waitForRequest(urlOrPredicate: string | RegExp | ((r: network.Request) => boolean | Promise<boolean>), options: { timeout?: number } = {}): Promise<network.Request> {
const predicate = async (request: network.Request) => {
if (isString(urlOrPredicate) || isRegExp(urlOrPredicate))
return urlMatches(undefined, request.url(), urlOrPredicate);
return await urlOrPredicate(request);
};
const trimmedUrl = trimUrl(urlOrPredicate);
const logLine = trimmedUrl ? `waiting for request ${trimmedUrl}` : undefined;
return await this._waitForEvent(Events.Page.Request, { predicate, timeout: options.timeout }, logLine);
}
async waitForResponse(urlOrPredicate: string | RegExp | ((r: network.Response) => boolean | Promise<boolean>), options: { timeout?: number } = {}): Promise<network.Response> {
const predicate = async (response: network.Response) => {
if (isString(urlOrPredicate) || isRegExp(urlOrPredicate))
return urlMatches(undefined, response.url(), urlOrPredicate);
return await urlOrPredicate(response);
};
const trimmedUrl = trimUrl(urlOrPredicate);
const logLine = trimmedUrl ? `waiting for response ${trimmedUrl}` : undefined;
return await this._waitForEvent(Events.Page.Response, { predicate, timeout: options.timeout }, logLine);
}
async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> {
const result = await this._waitForEvent(event, optionsOrPredicate, `waiting for event "${event}"`);
await this._updateInterceptionPatterns();
return result;
}
private async _waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions, logLine?: string): Promise<any> {
return await this._localUtils._wrapApiCall(async () => {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this._localUtils, event);
if (logLine)
waiter.log(logLine);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
const result = await waiter.waitForEvent(this, event, predicate as any);
waiter.dispose();
return result;
});
}
port(): number {
return this._port;
}
}

View file

@ -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 = {
@ -291,7 +291,7 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
export class Route extends ChannelOwner<channels.RouteChannel> implements api.Route {
private _handlingPromise: ManualPromise<boolean> | null = null;
_context!: BrowserContext;
_request!: APIRequestContext;
_didThrow: boolean = false;
static from(route: channels.RouteChannel): Route {
@ -339,7 +339,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
async fetch(options: FallbackOverrides & { maxRedirects?: number, maxRetries?: number, timeout?: number } = {}): Promise<APIResponse> {
return await this._wrapApiCall(async () => {
return await this._context.request._innerFetch({ request: this.request(), data: options.postData, ...options });
return await this._request._innerFetch({ request: this.request(), data: options.postData, ...options });
});
}

View file

@ -180,7 +180,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
}
private async _onRoute(route: Route) {
route._context = this.context();
route._request = this.context().request;
const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) {
// If the page was closed we stall all requests right away.
@ -850,7 +850,7 @@ export class BindingCall extends ChannelOwner<channels.BindingCallChannel> {
}
}
function trimUrl(param: any): string | undefined {
export function trimUrl(param: any): string | undefined {
if (isRegExp(param))
return `/${trimStringWithEllipsis(param.source, 50)}/${param.flags}`;
if (isString(param))

View file

@ -22,6 +22,7 @@ import { ChannelOwner } from './channelOwner';
import { Electron } from './electron';
import { APIRequest } from './fetch';
import { Selectors, SelectorsOwner } from './selectors';
import { MockingProxyFactory } from './mockingProxy';
export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
readonly _android: Android;
@ -34,11 +35,13 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
readonly devices: any;
selectors: Selectors;
readonly request: APIRequest;
readonly mockingProxy: MockingProxyFactory;
readonly errors: { TimeoutError: typeof TimeoutError };
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) {
super(parent, type, guid, initializer);
this.request = new APIRequest(this);
this.mockingProxy = new MockingProxyFactory(this._connection.localUtils());
this.chromium = BrowserType.from(initializer.chromium);
this.chromium._playwright = this;
this.firefox = BrowserType.from(initializer.firefox);

View file

@ -66,6 +66,7 @@ export const slowMoActions = new Set([
export const commandsWithTracingSnapshots = new Set([
'EventTarget.waitForEventInfo',
'LocalUtils.waitForEventInfo',
'BrowserContext.waitForEventInfo',
'Page.waitForEventInfo',
'WebSocket.waitForEventInfo',

View file

@ -226,6 +226,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.LocalUtilsWaitForEventInfoParams = 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.LocalUtilsWaitForEventInfoResult = 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,
@ -245,6 +268,27 @@ scheme.LocalUtilsInitializer = tObject({
defaultBrowserType: tEnum(['chromium', 'firefox', 'webkit']),
}),
})),
requestContext: tChannel(['APIRequestContext']),
});
scheme.LocalUtilsRouteEvent = tObject({
route: tChannel(['Route']),
});
scheme.LocalUtilsRequestEvent = tObject({
request: tChannel(['Request']),
});
scheme.LocalUtilsResponseEvent = tObject({
request: tChannel(['Request']),
response: tChannel(['Response']),
});
scheme.LocalUtilsRequestFailedEvent = tObject({
request: tChannel(['Request']),
failureText: tOptional(tString),
responseEndTiming: tNumber,
});
scheme.LocalUtilsRequestFinishedEvent = tObject({
request: tChannel(['Request']),
response: tOptional(tChannel(['Response'])),
responseEndTiming: tNumber,
});
scheme.LocalUtilsZipParams = tObject({
zipFile: tString,
@ -313,6 +357,15 @@ scheme.LocalUtilsTraceDiscardedParams = tObject({
stacksId: tString,
});
scheme.LocalUtilsTraceDiscardedResult = tOptional(tObject({}));
scheme.LocalUtilsSetServerNetworkInterceptionPatternsParams = tObject({
port: tNumber,
patterns: tArray(tObject({
glob: tOptional(tString),
regexSource: tOptional(tString),
regexFlags: tOptional(tString),
})),
});
scheme.LocalUtilsSetServerNetworkInterceptionPatternsResult = tOptional(tObject({}));
scheme.RootInitializer = tOptional(tObject({}));
scheme.RootInitializeParams = tObject({
sdkLanguage: tEnum(['javascript', 'python', 'java', 'csharp']),
@ -780,27 +833,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']),

View file

@ -44,7 +44,7 @@ import { Clock } from './clock';
import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
import { RecorderApp } from './recorder/recorderApp';
export abstract class BrowserContext extends SdkObject {
export abstract class BrowserContext extends SdkObject implements network.RequestContext {
static Events = {
Console: 'console',
Close: 'close',

View file

@ -20,7 +20,7 @@ import path from 'path';
import os from 'os';
import type * as channels from '@protocol/channels';
import { ManualPromise } from '../../utils/manualPromise';
import { assert, calculateSha1, createGuid, removeFolders } from '../../utils';
import { assert, calculateSha1, createGuid, HttpServer, removeFolders, urlMatches } from '../../utils';
import type { RootDispatcher } from './dispatcher';
import { Dispatcher } from './dispatcher';
import { yazl, yauzl } from '../../zipBundle';
@ -41,9 +41,14 @@ import type { Playwright } from '../playwright';
import { SdkObject } from '../../server/instrumentation';
import { serializeClientSideCallMetadata } from '../../utils';
import { deviceDescriptors as descriptors } from '../deviceDescriptors';
import { APIRequestContextDispatcher, RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers';
import type { APIRequestContext } from '../fetch';
import { GlobalAPIRequestContext } from '../fetch';
import { MockingProxy, ServerInterceptionRegistry } from './mockingProxy';
export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel {
export class LocalUtilsDispatcher extends Dispatcher<SdkObject, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel {
_type_LocalUtils: boolean;
_type_EventTarget: boolean;
private _harBackends = new Map<string, HarBackend>();
private _stackSessions = new Map<string, {
file: string,
@ -51,15 +56,52 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
tmpDir: string | undefined,
callStacks: channels.ClientSideCallMetadata[]
}>();
_requestContext: APIRequestContext;
private _interceptionRegistry;
private _server?: WorkerHttpServer;
constructor(scope: RootDispatcher, playwright: Playwright) {
const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils');
const deviceDescriptors = Object.entries(descriptors)
.map(([name, descriptor]) => ({ name, descriptor }));
const requestContext = new GlobalAPIRequestContext(playwright, {});
super(scope, localUtils, 'LocalUtils', {
deviceDescriptors,
requestContext: APIRequestContextDispatcher.from(scope, requestContext),
});
this._requestContext = requestContext;
this._type_LocalUtils = true;
this._type_EventTarget = true;
this._interceptionRegistry = new ServerInterceptionRegistry(this._object, this._requestContext, {
onRequest: request => {
this._dispatchEvent('request', { request: RequestDispatcher.from(this.parentScope() as any, request) });
},
onRequestFinished: (request, response) => {
this._dispatchEvent('requestFinished', {
request: RequestDispatcher.from(this.parentScope() as any, request),
response: ResponseDispatcher.fromNullable(this.parentScope() as any, response ?? null),
responseEndTiming: request._responseEndTiming,
});
},
onRequestFailed: request => {
this._dispatchEvent('requestFailed', {
request: RequestDispatcher.from(this.parentScope() as any, request),
responseEndTiming: request._responseEndTiming,
failureText: request._failureText ?? undefined
});
},
onResponse: (request, response) => {
this._dispatchEvent('response', {
request: RequestDispatcher.from(this.parentScope() as any, request),
response: ResponseDispatcher.from(this.parentScope() as any, response),
});
},
onRoute: (route, request) => {
this._dispatchEvent('route', { route: RouteDispatcher.from(RequestDispatcher.from(this.parentScope() as any, request), route) });
},
});
}
async zip(params: channels.LocalUtilsZipParams): Promise<void> {
@ -273,6 +315,26 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
await removeFolders([session.tmpDir]);
this._stackSessions.delete(stacksId!);
}
async setServerNetworkInterceptionPatterns(params: channels.LocalUtilsSetServerNetworkInterceptionPatternsParams, metadata?: CallMetadata): Promise<channels.LocalUtilsSetServerNetworkInterceptionPatternsResult> {
if (!this._server) {
this._server = new WorkerHttpServer();
new MockingProxy(this._interceptionRegistry).install(this._server);
await this._server.start({ port: params.port });
}
if (params.patterns.length === 0)
return this._interceptionRegistry.setRequestInterceptor(undefined);
const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!);
this._interceptionRegistry.setRequestInterceptor(url => urlMatchers.some(urlMatch => urlMatches(undefined, url, urlMatch)));
}
}
export class WorkerHttpServer extends HttpServer {
override _handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean {
return false;
}
}
const redirectStatus = [301, 302, 303, 307, 308];
@ -295,7 +357,8 @@ class HarBackend {
redirectURL?: string,
status?: number,
headers?: HeadersArray,
body?: Buffer }> {
body?: Buffer
}> {
let entry;
try {
entry = await this._harFindResponse(url, method, headers, postData);

View file

@ -0,0 +1,311 @@
/**
* 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 { RemoteAddr, RequestContext, ResourceTiming, SecurityDetails } from '../network';
import { Request, Response, Route } from '../network';
import type { HeadersArray, NormalizedContinueOverrides, NormalizedFulfillResponse } from '../types';
import { ManualPromise, monotonicTime } from 'playwright-core/lib/utils';
import type { WorkerHttpServer } from './localUtilsDispatcher';
import { TLSSocket } from 'tls';
import type { AddressInfo } from 'net';
import { pipeline } from 'stream/promises';
import { Transform } from 'stream';
type InterceptorResult =
| { result: 'continue', guid: string, overrides?: NormalizedContinueOverrides }
| { result: 'abort', guid: string, errorCode: string }
| { result: 'fulfill', guid: string, response: NormalizedFulfillResponse };
interface EventDelegate {
onRequest(request: Request): void;
onRequestFinished(request: Request, response: Response): void;
onRequestFailed(request: Request): void;
onResponse(request: Request, response: Response): void;
onRoute(route: Route, request: Request): void;
}
export class ServerInterceptionRegistry extends SdkObject implements RequestContext {
private _eventDelegate: EventDelegate;
private readonly _requests = new Map<string, Request>(); // TODO: dont memory leak requests
fetchRequest: APIRequestContext;
private _matches?: (url: string) => boolean;
constructor(parent: SdkObject, requestContext: APIRequestContext, eventDelegate: EventDelegate) {
super(parent, 'serverInterceptionRegistry');
this._eventDelegate = eventDelegate;
this.fetchRequest = requestContext;
}
setRequestInterceptor(matches?: (url: string) => boolean) {
this._matches = matches;
}
handle(url: string, method: string, body: Buffer | null, headers: HeadersArray): Promise<InterceptorResult> {
const request = new Request(this, null, null, null, undefined, url, '', method, body, headers);
request.setRawRequestHeaders(headers);
this._eventDelegate.onRequest(request);
const guid = request.guid;
this._requests.set(guid, request);
if (!this._matches?.(url))
return Promise.resolve({ result: 'continue', guid });
return new Promise(resolve => {
const route = new Route(request, {
async abort(errorCode) {
resolve({ result: 'abort', guid, errorCode });
},
async continue(overrides) {
resolve({ result: 'continue', guid, overrides });
},
async fulfill(response) {
resolve({ result: 'fulfill', guid, response });
},
});
this._eventDelegate.onRoute(route, request);
});
}
failed(guid: string, error: string) {
const request = this._requests.get(guid);
if (!request)
throw new Error('Internal error: missing request for response');
request._setFailureText(error);
this._eventDelegate.onRequestFailed(request);
}
response(guid: string, status: number, statusText: string, headers: HeadersArray, body: () => Promise<Buffer>, httpVersion: string, timing: ResourceTiming, securityDetails: SecurityDetails | undefined, serverAddr: RemoteAddr | undefined) {
const request = this._requests.get(guid);
if (!request)
throw new Error('Internal error: missing request for response');
const response = new Response(request, status, statusText, headers, timing, body, false, httpVersion);
response.setRawResponseHeaders(headers);
response._securityDetailsFinished(securityDetails);
response._serverAddrFinished(serverAddr);
this._eventDelegate.onResponse(request, response);
return {
finished: async (responseEndTiming: number, transferSize: number, encodedBodySize: number) => {
response._requestFinished(responseEndTiming);
response.setTransferSize(transferSize);
response.setEncodedBodySize(encodedBodySize);
response.setResponseHeadersSize(transferSize - encodedBodySize);
this._eventDelegate.onRequestFinished(request, response);
}
};
}
addRouteInFlight(route: Route): void {
}
removeRouteInFlight(route: Route): void {
}
}
function headersArray(req: Pick<http.IncomingMessage, 'headersDistinct'>): 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<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
export class MockingProxy {
private readonly _registry: ServerInterceptionRegistry;
constructor(registry: ServerInterceptionRegistry) {
this._registry = registry;
}
install(server: WorkerHttpServer) {
server.routePrefix('/', (req, res) => {
this._proxy(req, res);
return true;
});
server.server().on('connect', (req, socket, head) => {
socket.end('HTTP/1.1 405 Method Not Allowed\r\n\r\n');
});
}
private async _proxy(req: http.IncomingMessage, res: http.ServerResponse) {
if (req.url?.startsWith('/'))
req.url = req.url.substring(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 result = await this._registry.handle(req.url!, req.method!, body, headers);
switch (result.result) {
case 'abort': {
req.destroy(result.errorCode ? new Error(result.errorCode) : undefined);
return;
}
case 'continue': {
const { overrides } = result;
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<void>(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<Buffer>();
const response = this._registry.response(
result.guid,
proxyRes.statusCode!,
proxyRes.statusMessage!, headersArray(proxyRes),
() => responseBodyPromise,
proxyRes.httpVersion,
timings,
securityDetails,
{ ipAddress: address.family === 'IPv6' ? `[${address.address}]` : address.address, port: address.port },
);
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);
response.finished(
monotonicTime() - startAt,
socket.bytesRead - socketBytesReadStart,
body.byteLength
);
resolve();
} catch (error) {
this._registry.failed(result.guid, error.toString());
resolve();
}
});
proxyReq.on('error', error => {
this._registry.failed(result.guid, error.toString());
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);
});
}
case 'fulfill': {
const { response: { status, headers, body, isBase64 } } = result;
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'));
return;
}
default: {
throw new Error('Unexpected result');
}
}
}
}

View file

@ -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';
@ -23,6 +22,7 @@ import { assert } from '../utils';
import { ManualPromise } from '../utils/manualPromise';
import { SdkObject } from './instrumentation';
import type { HeadersArray, NameValue } from '../common/types';
import type { BrowserContextAPIRequestContext } from './fetch';
import { APIRequestContext } from './fetch';
import type { NormalizedContinueOverrides } from './types';
import { BrowserContext } from './browserContext';
@ -88,6 +88,13 @@ export function stripFragmentFromUrl(url: string): string {
return url.substring(0, url.indexOf('#'));
}
export interface RequestContext extends SdkObject {
addRouteInFlight(route: Route): void;
removeRouteInFlight(route: Route): void;
fetchRequest: APIRequestContext;
}
export class Request extends SdkObject {
private _response: Response | null = null;
private _redirectedFrom: Request | null;
@ -103,14 +110,14 @@ export class Request extends SdkObject {
private _headersMap = new Map<string, string>();
readonly _frame: frames.Frame | null = null;
readonly _serviceWorker: pages.Worker | null = null;
readonly _context: contexts.BrowserContext;
readonly _context: RequestContext;
private _rawRequestHeadersPromise = new ManualPromise<HeadersArray>();
private _waitForResponsePromise = new ManualPromise<Response | null>();
_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 +353,7 @@ export class Route extends SdkObject {
export type RouteHandler = (route: Route, request: Request) => boolean;
type GetResponseBodyCallback = () => Promise<Buffer>;
export type GetResponseBodyCallback = () => Promise<Buffer>;
export type ResourceTiming = {
startTime: number;

View file

@ -213,7 +213,7 @@ export class HttpServer {
readable.pipe(response);
}
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
_handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean {
response.setHeader('Access-Control-Allow-Origin', '*');
response.setHeader('Access-Control-Request-Method', '*');
response.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET');
@ -223,9 +223,16 @@ export class HttpServer {
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) {

View file

@ -20036,6 +20036,352 @@ export interface Logger {
}): void;
}
/**
* `MockingProxy` allows you to intercept network traffic from your application server.
*
* ```js
* const { webkit, mockingProxy } = require('playwright'); // Or 'chromium' or 'firefox'.
*
* (async () => {
* const browser = await webkit.launch();
* const context = await browser.newContext();
* const server = await mockingProxy.newProxy(8888); // point your application server to MockingProxy all requests through this port
*
* await server.route("https://headless-cms.example.com/posts", (route, request) => {
* await route.fulfill({
* json: [
* { id: 1, title: 'Hello, World!' },
* { id: 2, title: 'Second post' },
* { id: 2, title: 'Third post' }
* ]
* });
* })
*
* const page = await context.newPage();
* await page.goto('https://localhost:3000/posts');
*
* console.log(await page.getByRole('list').ariaSnapshot())
* // - list:
* // - listitem: Hello, World!
* // - listitem: Second post
* // - listitem: Third post
* })();
* ```
*
*/
export interface MockingProxy {
/**
* Emitted when a request passes through the MockingProxy. The [request] object is read-only. In order to intercept
* and mutate requests, see
* [mockingProxy.route(url, handler[, options])](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-route).
*/
on(event: 'request', listener: (request: Request) => any): this;
/**
* Emitted when a request fails, for example by timing out.
*/
on(event: 'requestfailed', listener: (request: Request) => any): this;
/**
* Emitted when a request finishes successfully after downloading the response body. For a successful response, the
* sequence of events is `request`, `response` and `requestfinished`.
*/
on(event: 'requestfinished', listener: (request: Request) => any): this;
/**
* Emitted when [response] status and headers are received for a request. For a successful response, the sequence of
* events is `request`, `response` and `requestfinished`.
*/
on(event: 'response', listener: (response: Response) => any): this;
/**
* Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event.
*/
once(event: 'request', listener: (request: Request) => any): this;
/**
* Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event.
*/
once(event: 'requestfailed', listener: (request: Request) => any): this;
/**
* Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event.
*/
once(event: 'requestfinished', listener: (request: Request) => any): this;
/**
* Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event.
*/
once(event: 'response', listener: (response: Response) => any): this;
/**
* Emitted when a request passes through the MockingProxy. The [request] object is read-only. In order to intercept
* and mutate requests, see
* [mockingProxy.route(url, handler[, options])](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-route).
*/
addListener(event: 'request', listener: (request: Request) => any): this;
/**
* Emitted when a request fails, for example by timing out.
*/
addListener(event: 'requestfailed', listener: (request: Request) => any): this;
/**
* Emitted when a request finishes successfully after downloading the response body. For a successful response, the
* sequence of events is `request`, `response` and `requestfinished`.
*/
addListener(event: 'requestfinished', listener: (request: Request) => any): this;
/**
* Emitted when [response] status and headers are received for a request. For a successful response, the sequence of
* events is `request`, `response` and `requestfinished`.
*/
addListener(event: 'response', listener: (response: Response) => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
removeListener(event: 'request', listener: (request: Request) => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
removeListener(event: 'requestfailed', listener: (request: Request) => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
removeListener(event: 'requestfinished', listener: (request: Request) => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
removeListener(event: 'response', listener: (response: Response) => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
off(event: 'request', listener: (request: Request) => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
off(event: 'requestfailed', listener: (request: Request) => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
off(event: 'requestfinished', listener: (request: Request) => any): this;
/**
* Removes an event listener added by `on` or `addListener`.
*/
off(event: 'response', listener: (response: Response) => any): this;
/**
* Emitted when a request passes through the MockingProxy. The [request] object is read-only. In order to intercept
* and mutate requests, see
* [mockingProxy.route(url, handler[, options])](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-route).
*/
prependListener(event: 'request', listener: (request: Request) => any): this;
/**
* Emitted when a request fails, for example by timing out.
*/
prependListener(event: 'requestfailed', listener: (request: Request) => any): this;
/**
* Emitted when a request finishes successfully after downloading the response body. For a successful response, the
* sequence of events is `request`, `response` and `requestfinished`.
*/
prependListener(event: 'requestfinished', listener: (request: Request) => any): this;
/**
* Emitted when [response] status and headers are received for a request. For a successful response, the sequence of
* events is `request`, `response` and `requestfinished`.
*/
prependListener(event: 'response', listener: (response: Response) => any): this;
port(): number;
/**
* Routing provides the capability to modify network requests that are made through the MockingProxy.
*
* Once routing is enabled, every request matching the url pattern will stall unless it's continued, fulfilled or
* aborted.
*
* **Usage**
*
* An example of a naive handler that aborts all requests to a specific domain:
*
* ```js
* const page = await browser.newPage();
* const server = await page.context().newMockingProxy(8888)
* await server.route('https://api.example.com', route => route.abort()); // simulates this API being unreachable
* await page.goto('http://localhost:3000');
* ```
*
* It is possible to examine the request to decide the route action. For example, mocking all requests that contain
* some post data, and leaving all other requests as is:
*
* ```js
* await serer.route('https://api.example.com/*', async route => {
* if (route.request().postData().includes('my-string'))
* await route.fulfill({ body: 'mocked-data' });
* else
* await route.continue();
* })
* ```
*
* To remove a route with its handler you can use
* [mockingProxy.unroute(url[, handler])](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-unroute).
* @param url A glob pattern, regex pattern or predicate receiving [URL] to match while routing. When a
* [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) via the context
* options was provided and the passed URL is a path, it gets merged via the
* [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor.
* @param handler handler function to route the request.
* @param options
*/
route(url: string|RegExp|((url: URL) => boolean), handler: ((route: Route, request: Request) => Promise<any>|any), options?: {
/**
* How often a route should be used. By default it will be used every time.
*/
times?: number;
}): Promise<void>;
/**
* Removes a route created with
* [mockingProxy.route(url, handler[, options])](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-route).
* When [`handler`](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-unroute-option-handler) is not
* specified, removes all routes for the
* [`url`](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-unroute-option-url).
* @param url A glob pattern, regex pattern or predicate receiving [URL] used to register a routing with
* [mockingProxy.route(url, handler[, options])](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-route).
* @param handler Optional handler function used to register a routing with
* [mockingProxy.route(url, handler[, options])](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-route).
*/
unroute(url: string|RegExp|((url: URL) => boolean), handler?: ((route: Route, request: Request) => Promise<any>|any)): Promise<void>;
/**
* Removes all routes created with
* [mockingProxy.route(url, handler[, options])](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-route).
* @param options
*/
unrouteAll(options?: {
/**
* Specifies whether to wait for already running handlers and what to do if they throw errors:
* - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may
* result in unhandled error
* - `'wait'` - wait for current handler calls (if any) to finish
* - `'ignoreErrors'` - do not wait for current handler calls (if any) to finish, all errors thrown by the handlers
* after unrouting are silently caught
*/
behavior?: "wait"|"ignoreErrors"|"default";
}): Promise<void>;
/**
* Emitted when a request passes through the MockingProxy. The [request] object is read-only. In order to intercept
* and mutate requests, see
* [mockingProxy.route(url, handler[, options])](https://playwright.dev/docs/api/class-mockingproxy#mocking-proxy-route).
*/
waitForEvent(event: 'request', optionsOrPredicate?: { predicate?: (request: Request) => boolean | Promise<boolean>, timeout?: number } | ((request: Request) => boolean | Promise<boolean>)): Promise<Request>;
/**
* Emitted when a request fails, for example by timing out.
*/
waitForEvent(event: 'requestfailed', optionsOrPredicate?: { predicate?: (request: Request) => boolean | Promise<boolean>, timeout?: number } | ((request: Request) => boolean | Promise<boolean>)): Promise<Request>;
/**
* Emitted when a request finishes successfully after downloading the response body. For a successful response, the
* sequence of events is `request`, `response` and `requestfinished`.
*/
waitForEvent(event: 'requestfinished', optionsOrPredicate?: { predicate?: (request: Request) => boolean | Promise<boolean>, timeout?: number } | ((request: Request) => boolean | Promise<boolean>)): Promise<Request>;
/**
* Emitted when [response] status and headers are received for a request. For a successful response, the sequence of
* events is `request`, `response` and `requestfinished`.
*/
waitForEvent(event: 'response', optionsOrPredicate?: { predicate?: (response: Response) => boolean | Promise<boolean>, timeout?: number } | ((response: Response) => boolean | Promise<boolean>)): Promise<Response>;
/**
* Waits for the matching request and returns it. See [waiting for event](https://playwright.dev/docs/events#waiting-for-event) for more
* details about events.
*
* **Usage**
*
* ```js
* // Start waiting for request before clicking. Note no await.
* const requestPromise = MockingProxy.waitForRequest('https://example.com/resource');
* await page.getByText('trigger request').click();
* const request = await requestPromise;
*
* // Alternative way with a predicate. Note no await.
* const requestPromise = MockingProxy.waitForRequest(request =>
* request.url() === 'https://example.com' && request.method() === 'GET',
* );
* await page.getByText('trigger request').click();
* const request = await requestPromise;
* ```
*
* @param urlOrPredicate Request URL string, regex or predicate receiving [Request](https://playwright.dev/docs/api/class-request) object.
* @param options
*/
waitForRequest(urlOrPredicate: string|RegExp|((request: Request) => boolean|Promise<boolean>), options?: {
/**
* Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout. The default value can
* be changed by using the
* [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) method.
*/
timeout?: number;
}): Promise<Request>;
/**
* Returns the matched response. See [waiting for event](https://playwright.dev/docs/events#waiting-for-event) for more details about
* events.
*
* **Usage**
*
* ```js
* // Start waiting for response before clicking. Note no await.
* const responsePromise = MockingProxy.waitForResponse('https://example.com/resource');
* await page.getByText('trigger response').click();
* const response = await responsePromise;
*
* // Alternative way with a predicate. Note no await.
* const responsePromise = MockingProxy.waitForResponse(response =>
* response.url() === 'https://example.com' && response.status() === 200
* && response.request().method() === 'GET'
* );
* await page.getByText('trigger response').click();
* const response = await responsePromise;
* ```
*
* @param urlOrPredicate Request URL string, regex or predicate receiving [Response](https://playwright.dev/docs/api/class-response) object.
* @param options
*/
waitForResponse(urlOrPredicate: string|RegExp|((response: Response) => boolean|Promise<boolean>), options?: {
/**
* Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout.
*/
timeout?: number;
}): Promise<Response>;
}
/**
* This class is used for creating [MockingProxy](https://playwright.dev/docs/api/class-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 [playwright.mockingProxy](https://playwright.dev/docs/api/class-playwright#playwright-mocking-proxy).
* For more information see [MockingProxy](https://playwright.dev/docs/api/class-mockingproxy).
*/
export interface MockingProxyFactory {
/**
* Creates a new instance of [MockingProxy](https://playwright.dev/docs/api/class-mockingproxy).
* @param port Port to listen on.
*/
newProxy(port: number): Promise<MockingProxy>;
}
/**
* The Mouse class operates in main-frame CSS pixels relative to the top-left corner of the viewport.
*
@ -20172,6 +20518,8 @@ export const chromium: BrowserType;
*/
export const firefox: BrowserType;
export const mockingProxy: MockingProxyFactory;
/**
* Exposes API that can be used for the Web API testing.
*/

View file

@ -16,7 +16,7 @@
import * as fs from 'fs';
import * as path from 'path';
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video, MockingProxy } from 'playwright-core';
import * as playwrightLibrary from 'playwright-core';
import { createGuid, debugMode, addInternalStackPrefix, isString, asLocator, jsonStringifyForceASCII, zones } from 'playwright-core/lib/utils';
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, VideoMode } from '../types/test';
@ -25,6 +25,7 @@ import { rootTestType } from './common/testType';
import type { ContextReuseMode } from './common/config';
import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
import { currentTestInfo } from './common/globals';
import { getFreePort } from './util';
export { expect } from './matchers/expect';
export const _baseTest: TestType<{}, {}> = rootTestType.test;
@ -54,6 +55,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
_optionContextReuseMode: ContextReuseMode,
_optionConnectOptions: PlaywrightWorkerOptions['connectOptions'],
_reuseContext: boolean,
_mockingProxy?: MockingProxy,
};
const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
@ -71,6 +73,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
screenshot: ['off', { scope: 'worker', option: true }],
video: ['off', { scope: 'worker', option: true }],
trace: ['off', { scope: 'worker', option: true }],
mockingProxy: [undefined, { scope: 'worker', option: true }],
_browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => {
const options: LaunchOptions = {
@ -119,6 +122,19 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
}, true);
}, { scope: 'worker', timeout: 0 }],
_mockingProxy: [async ({ mockingProxy: mockingProxyOption, playwright }, use) => {
if (!mockingProxyOption)
return await use(undefined);
const testInfoImpl = test.info() as TestInfoImpl;
if (typeof mockingProxyOption.port === 'number' && testInfoImpl.config.workers > 1)
throw new Error(`Cannot share mocking proxy between multiple workers. Either disable parallel mode or set mockingProxy.port to 'inject'`);
const port = typeof mockingProxyOption.port === 'number' ? mockingProxyOption.port : await getFreePort();
const mockingProxy = await playwright.mockingProxy.newProxy(port);
await use(mockingProxy);
}, { scope: 'worker' }],
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 }],
@ -172,6 +188,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
baseURL,
contextOptions,
serviceWorkers,
_mockingProxy,
}, use) => {
const options: BrowserContextOptions = {};
if (acceptDownloads !== undefined)
@ -218,6 +235,8 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
options.baseURL = baseURL;
if (serviceWorkers !== undefined)
options.serviceWorkers = serviceWorkers;
if (_mockingProxy)
options.extraHTTPHeaders = { ...options.extraHTTPHeaders, 'x-pw-proxy-port': String(_mockingProxy.port()) };
await use({
...contextOptions,
...options,
@ -448,6 +467,13 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
await request.dispose();
}
},
server: async ({ _mockingProxy }, use) => {
if (!_mockingProxy)
throw new Error(`The 'server' fixture is only available when 'mockingProxy' is enabled.`);
await use(_mockingProxy);
await _mockingProxy.unrouteAll();
}
});
type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined;

View file

@ -19,8 +19,9 @@ import type { StackFrame } from '@protocol/channels';
import util from 'util';
import path from 'path';
import url from 'url';
import net, { AddressInfo } from 'net';
import { debug, mime, minimatch, parseStackTraceLine } from 'playwright-core/lib/utilsBundle';
import { formatCallLog } from 'playwright-core/lib/utils';
import { formatCallLog, ManualPromise } from 'playwright-core/lib/utils';
import type { Location } from './../types/testReporter';
import { calculateSha1, isRegExp, isString, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils';
import type { RawStack } from 'playwright-core/lib/utils';
@ -397,3 +398,15 @@ export async function removeDirAndLogToConsole(dir: string) {
} catch {
}
}
export async function getFreePort() {
const promise = new ManualPromise<number>();
const server = net.createServer();
server.unref();
server.on('error', promise.reject);
server.listen(0, () => {
const { port } = server.address() as AddressInfo;
server.close(() => promise.resolve(port));
});
return promise;
}

View file

@ -15,7 +15,7 @@
* limitations under the License.
*/
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core';
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions, MockingProxy } from 'playwright-core';
export * from 'playwright-core';
export type ReporterDescription = Readonly<
@ -5892,6 +5892,12 @@ type ConnectOptions = {
*/
timeout?: number;
};
type MockingProxyOptions = {
/**
* 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.
*/
port: number | "inject";
}
/**
* Playwright Test provides many options to configure test environment,
@ -6139,6 +6145,24 @@ 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: {
* port: 9956,
* },
* },
* });
* ```
*
*/
mockingProxy: MockingProxyOptions | undefined;
}
export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';
@ -6889,6 +6913,11 @@ export interface PlaywrightTestArgs {
*
*/
request: APIRequestContext;
/**
* Instance of [MockingProxy](https://playwright.dev/docs/api/class-mockingproxy) that can be used to intercept
* network requests from your application server.
*/
server: MockingProxy;
}
type ExcludeProps<A, B> = {

View file

@ -49,7 +49,6 @@ export type InitializerTraits<T> =
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 :
@ -58,6 +57,7 @@ export type InitializerTraits<T> =
T extends PlaywrightChannel ? PlaywrightInitializer :
T extends RootChannel ? RootInitializer :
T extends LocalUtilsChannel ? LocalUtilsInitializer :
T extends EventTargetChannel ? EventTargetInitializer :
T extends APIRequestContextChannel ? APIRequestContextInitializer :
object;
@ -87,7 +87,6 @@ export type EventsTraits<T> =
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 :
@ -96,6 +95,7 @@ export type EventsTraits<T> =
T extends PlaywrightChannel ? PlaywrightEvents :
T extends RootChannel ? RootEvents :
T extends LocalUtilsChannel ? LocalUtilsEvents :
T extends EventTargetChannel ? EventTargetEvents :
T extends APIRequestContextChannel ? APIRequestContextEvents :
undefined;
@ -125,7 +125,6 @@ export type EventTargetTraits<T> =
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 :
@ -134,6 +133,7 @@ export type EventTargetTraits<T> =
T extends PlaywrightChannel ? PlaywrightEventTarget :
T extends RootChannel ? RootEventTarget :
T extends LocalUtilsChannel ? LocalUtilsEventTarget :
T extends EventTargetChannel ? EventTargetEventTarget :
T extends APIRequestContextChannel ? APIRequestContextEventTarget :
undefined;
@ -404,6 +404,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<EventTargetWaitForEventInfoResult>;
}
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: {
@ -424,10 +449,16 @@ export type LocalUtilsInitializer = {
defaultBrowserType: 'chromium' | 'firefox' | 'webkit',
},
}[],
requestContext: APIRequestContextChannel,
};
export interface LocalUtilsEventTarget {
on(event: 'route', callback: (params: LocalUtilsRouteEvent) => void): this;
on(event: 'request', callback: (params: LocalUtilsRequestEvent) => void): this;
on(event: 'response', callback: (params: LocalUtilsResponseEvent) => void): this;
on(event: 'requestFailed', callback: (params: LocalUtilsRequestFailedEvent) => void): this;
on(event: 'requestFinished', callback: (params: LocalUtilsRequestFinishedEvent) => void): this;
}
export interface LocalUtilsChannel extends LocalUtilsEventTarget, Channel {
export interface LocalUtilsChannel extends LocalUtilsEventTarget, EventTargetChannel {
_type_LocalUtils: boolean;
zip(params: LocalUtilsZipParams, metadata?: CallMetadata): Promise<LocalUtilsZipResult>;
harOpen(params: LocalUtilsHarOpenParams, metadata?: CallMetadata): Promise<LocalUtilsHarOpenResult>;
@ -438,7 +469,28 @@ export interface LocalUtilsChannel extends LocalUtilsEventTarget, Channel {
tracingStarted(params: LocalUtilsTracingStartedParams, metadata?: CallMetadata): Promise<LocalUtilsTracingStartedResult>;
addStackToTracingNoReply(params: LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata): Promise<LocalUtilsAddStackToTracingNoReplyResult>;
traceDiscarded(params: LocalUtilsTraceDiscardedParams, metadata?: CallMetadata): Promise<LocalUtilsTraceDiscardedResult>;
setServerNetworkInterceptionPatterns(params: LocalUtilsSetServerNetworkInterceptionPatternsParams, metadata?: CallMetadata): Promise<LocalUtilsSetServerNetworkInterceptionPatternsResult>;
}
export type LocalUtilsRouteEvent = {
route: RouteChannel,
};
export type LocalUtilsRequestEvent = {
request: RequestChannel,
};
export type LocalUtilsResponseEvent = {
request: RequestChannel,
response: ResponseChannel,
};
export type LocalUtilsRequestFailedEvent = {
request: RequestChannel,
failureText?: string,
responseEndTiming: number,
};
export type LocalUtilsRequestFinishedEvent = {
request: RequestChannel,
response?: ResponseChannel,
responseEndTiming: number,
};
export type LocalUtilsZipParams = {
zipFile: string,
entries: NameValue[],
@ -537,8 +589,25 @@ export type LocalUtilsTraceDiscardedOptions = {
};
export type LocalUtilsTraceDiscardedResult = void;
export type LocalUtilsSetServerNetworkInterceptionPatternsParams = {
port: number,
patterns: {
glob?: string,
regexSource?: string,
regexFlags?: string,
}[],
};
export type LocalUtilsSetServerNetworkInterceptionPatternsOptions = {
};
export type LocalUtilsSetServerNetworkInterceptionPatternsResult = void;
export interface LocalUtilsEvents {
'route': LocalUtilsRouteEvent;
'request': LocalUtilsRequestEvent;
'response': LocalUtilsResponseEvent;
'requestFailed': LocalUtilsRequestFailedEvent;
'requestFinished': LocalUtilsRequestFinishedEvent;
}
// ----------- Root -----------
@ -1460,31 +1529,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<EventTargetWaitForEventInfoResult>;
}
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,

View file

@ -526,9 +526,33 @@ ContextOptions:
- allow
- block
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
extends: EventTarget
initializer:
deviceDescriptors:
type: array
@ -559,6 +583,7 @@ LocalUtils:
- chromium
- firefox
- webkit
requestContext: APIRequestContext
commands:
@ -646,6 +671,40 @@ LocalUtils:
traceDiscarded:
parameters:
stacksId: string
setServerNetworkInterceptionPatterns:
parameters:
port: number
patterns:
type: array
items:
type: object
properties:
glob: string?
regexSource: string?
regexFlags: string?
events:
route:
parameters:
route: Route
request:
parameters:
request: Request
response:
parameters:
request: Request
response: Response
requestFailed:
parameters:
request: Request
failureText: string?
responseEndTiming: number
requestFinished:
parameters:
request: Request
response: Response?
responseEndTiming: number
Root:
type: interface
@ -1030,29 +1089,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

View file

@ -0,0 +1,280 @@
/**
* 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 type { APIRequestContext, MockingProxy, Route } from 'packages/playwright-test';
import { playwrightTest as baseTest, expect } from '../config/browserTest';
import { pipeline } from 'stream/promises';
import { suppressCertificateWarning } from 'tests/config/utils';
const test = baseTest.extend<{ proxiedRequest: APIRequestContext }, { mockingProxy: MockingProxy }>({
mockingProxy: [async ({ playwright }, use, testInfo) => {
const port = 32181 + testInfo.parallelIndex;
const proxy = await playwright.mockingProxy.newProxy(port);
await use(proxy);
}, { scope: 'worker' }],
proxiedRequest: async ({ request, mockingProxy: mockproxy }, use) => {
const originalFetch = request.fetch;
request.fetch = function(urlOrRequest, options) {
if (typeof urlOrRequest !== 'string')
throw new Error('not supported in this test');
urlOrRequest = `http://localhost:${mockproxy.port()}/${urlOrRequest}`;
return originalFetch.call(this, urlOrRequest, options);
};
await use(request);
},
});
test.beforeEach(async ({ mockingProxy: mockproxy }) => {
await mockproxy.unrouteAll();
});
test.describe('transparent', () => {
test('generates events', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
const events: string[] = [];
mockproxy.on('request', () => {
events.push('request');
});
mockproxy.on('response', () => {
events.push('response');
});
mockproxy.on('requestfinished', () => {
events.push('requestfinished');
});
const response = await proxiedRequest.get(server.EMPTY_PAGE);
await expect(response).toBeOK();
expect(events).toEqual(['request', 'response', 'requestfinished']);
});
test('event properties', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
const [
requestFinished,
request,
responseEvent,
response
] = await Promise.all([
mockproxy.waitForEvent('requestfinished'),
mockproxy.waitForRequest('**/*'),
mockproxy.waitForResponse('**/*'),
proxiedRequest.get(server.EMPTY_PAGE),
]);
await expect(response).toBeOK();
expect(request).toBe(requestFinished);
expect(responseEvent.request()).toBe(request);
expect(await request.response()).toBe(responseEvent);
expect(request.url()).toBe(server.EMPTY_PAGE);
expect(responseEvent.url()).toBe(server.EMPTY_PAGE);
expect(responseEvent.status()).toBe(response.status());
expect(await responseEvent.headersArray()).toEqual(response.headersArray());
expect(await responseEvent.body()).toEqual(await response.body());
expect(await responseEvent.finished()).toBe(null);
expect(request.serviceWorker()).toBe(null);
expect(() => request.frame()).toThrowError('Assertion error'); // TODO: improve error message
expect(() => responseEvent.frame()).toThrowError('Assertion error');
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: 164,
responseBodySize: 0,
responseHeadersSize: 197,
});
expect(request.timing()).toEqual({
'connectEnd': expect.any(Number),
'connectStart': expect.any(Number),
'domainLookupEnd': expect.any(Number),
'domainLookupStart': -1,
'requestStart': -1,
'responseEnd': expect.any(Number),
'responseStart': expect.any(Number),
'secureConnectionStart': -1,
'startTime': expect.any(Number),
});
expect(await responseEvent.securityDetails()).toBe(null);
expect(await responseEvent.serverAddr()).toEqual({
ipAddress: expect.any(String),
port: expect.any(Number),
});
});
test('securityDetails', async ({ httpsServer, proxiedRequest, mockingProxy: mockproxy }) => {
const oldValue = process.env['NODE_TLS_REJECT_UNAUTHORIZED'];
// https://stackoverflow.com/a/21961005/552185
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
suppressCertificateWarning();
try {
const [
event,
response
] = await Promise.all([
mockproxy.waitForResponse('**/*'),
proxiedRequest.get(httpsServer.EMPTY_PAGE),
]);
await expect(response).toBeOK();
expect(await event.securityDetails()).toEqual({
'issuer': 'playwright-test',
'protocol': 'TLSv1.3',
'subjectName': 'playwright-test',
'validFrom': expect.any(Number),
'validTo': expect.any(Number),
});
} finally {
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = oldValue;
}
});
test('request with body', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
server.setRoute('/echo', (req, res) => pipeline(req, res));
const [
requestEvent,
responseEvent,
response,
] = await Promise.all([
mockproxy.waitForRequest('**/*'),
mockproxy.waitForResponse('**/*'),
proxiedRequest.post(server.PREFIX + '/echo', { data: 'hello' }),
]);
expect(response.status()).toBe(200);
expect(await response.text()).toBe('hello');
expect(await responseEvent.body()).toEqual(Buffer.from('hello'));
expect(requestEvent.postData()).toBe('hello');
expect(await requestEvent.sizes()).toEqual({ // TODO: fixme
requestBodySize: 5,
requestHeadersSize: 218,
responseBodySize: 5,
responseHeadersSize: 141,
});
});
test('request failed', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
server.setRoute('/failure', (req, res) => {
res.socket.destroy();
});
const [
request,
requestFailed,
response,
] = await Promise.all([
mockproxy.waitForRequest('**/*'),
mockproxy.waitForEvent('requestfailed'),
proxiedRequest.get(server.PREFIX + '/failure'),
]);
expect(response.status()).toEqual(502); // TODO: should the proxy also close the socket instead?
expect(request).toBe(requestFailed);
expect(request.failure()).toEqual({
errorText: 'Error: socket hang up',
});
expect(await request.response()).toBe(null);
});
});
test('stalling', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
const routes: Route[] = [];
await mockproxy.route('**/abort', route => routes.push(route));
await expect(() => proxiedRequest.get(server.PREFIX + '/abort', { timeout: 100 })).rejects.toThrowError('Request timed out after 100ms');
expect(routes.length).toBe(1);
});
test('route properties', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
const routes: Route[] = [];
await mockproxy.route('**/*', (route, request) => {
expect(route.request()).toBe(request);
routes.push(route);
return route.continue();
});
await expect(await proxiedRequest.get(server.EMPTY_PAGE)).toBeOK();
expect(routes.length).toBe(1);
});
test('aborting', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
await mockproxy.route('**/abort', route => route.abort());
await expect(() => proxiedRequest.get(server.PREFIX + '/abort', { timeout: 100 })).rejects.toThrowError('Request timed out after 100ms');
});
test('fulfill', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
let apiCalls = 0;
server.setRoute('/endpoint', (req, res) => {
apiCalls++;
});
await mockproxy.route('**/endpoint', route => route.fulfill({ body: 'Hello', contentType: 'foo/bar', headers: { 'x-test': 'foo' }, status: 202 }));
const response = await proxiedRequest.get(server.PREFIX + '/endpoint');
expect(response.status()).toBe(202);
expect(await response.text()).toBe('Hello');
expect(response.headers()['content-type']).toBe('foo/bar');
expect(response.headers()['x-test']).toBe('foo');
expect(apiCalls).toBe(0);
});
test('continue', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
server.setRoute('/echo', (req, res) => {
res.setHeader('request-method', req.method);
res.writeHead(200, req.headers);
return pipeline(req, res);
});
await mockproxy.route('**/endpoint', (route, request) =>
route.continue({
headers: { 'x-override': 'bar', 'x-add': 'baz' },
method: 'PUT',
postData: 'world',
url: new URL('./echo', request.url()).toString(),
})
);
const response = await proxiedRequest.get(server.PREFIX + '/endpoint', { headers: { 'x-override': 'foo' } });
expect(response.status()).toBe(200);
expect(await response.text()).toBe('world');
expect(response.headers()['request-method']).toBe('PUT');
expect(response.headers()['x-override']).toBe('bar');
expect(response.headers()['x-add']).toBe('baz');
});
test('fallback', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
server.setRoute('/foo', (req, res) => {
res.end('ok');
});
await mockproxy.route('**/endpoint', route => route.continue());
await mockproxy.route('**/endpoint', (route, request) => route.fallback({ url: new URL('./foo', request.url()).toString() }));
const response = await proxiedRequest.get(server.PREFIX + '/endpoint');
expect(response.status()).toBe(200);
expect(await response.text()).toBe('ok');
});
test('fetch', async ({ server, proxiedRequest, mockingProxy: mockproxy }) => {
server.setRoute('/foo', (req, res) => {
res.end('ok');
});
await mockproxy.route('**/endpoint', async (route, request) => {
const response = await route.fetch({ url: new URL('./foo', request.url()).toString() });
await route.fulfill({ response });
});
const response = await proxiedRequest.get(server.PREFIX + '/endpoint');
expect(response.status()).toBe(200);
expect(await response.text()).toBe('ok');
});

View file

@ -0,0 +1,115 @@
/**
* 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';
test('inject mode', async ({ runInlineTest, server }) => {
server.setRoute('/page', (req, res) => {
res.end(req.headers['x-pw-proxy-port'] ?? 'no port given');
});
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
use: {
mockingProxy: { port: 'inject' }
}
};
`,
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('foo', async ({ server, page, request }) => {
await page.goto('${server.PREFIX}/page');
expect(await page.textContent('body')).toEqual('' + server.port());
const response = await request.get('${server.PREFIX}/page');
expect(await response.text()).toEqual('' + server.port());
});
`
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('throws on fixed mocking proxy port and parallel workers', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
use: {
mockingProxy: { port: 1234 }
}
};
`,
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('foo', async ({ server }) => {
});
`
}, { workers: 2 });
expect(result.exitCode).toBe(1);
expect(result.output).toContain('Cannot share mocking proxy between multiple workers.');
});
test('throws on missing config', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('foo', async ({ server }) => {
});
`
}, { workers: 2 });
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`The 'server' fixture is only available when 'mockingProxy' is enabled.`);
});
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 port = req.headers['x-pw-proxy-port'];
const proxyURL = `http://localhost:${port}/`;
const response = await request.get(proxyURL + server.PREFIX + '/fallback');
res.end(await response.body());
});
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
use: {
mockingProxy: { port: 'inject' }
}
};
`,
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('first', async ({ server, page, request }) => {
await server.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 ({ server, page, request }) => {
await server.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);
});

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions } from 'playwright-core';
import type { APIRequestContext, Browser, BrowserContext, BrowserContextOptions, Page, LaunchOptions, ViewportSize, Geolocation, HTTPCredentials, Locator, APIResponse, PageScreenshotOptions, MockingProxy } from 'playwright-core';
export * from 'playwright-core';
export type ReporterDescription = Readonly<
@ -225,6 +225,12 @@ type ConnectOptions = {
*/
timeout?: number;
};
type MockingProxyOptions = {
/**
* 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.
*/
port: number | "inject";
}
export interface PlaywrightWorkerOptions {
browserName: BrowserName;
@ -236,6 +242,7 @@ export interface PlaywrightWorkerOptions {
screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick<PageScreenshotOptions, 'fullPage' | 'omitBackground'>;
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: MockingProxyOptions | undefined;
}
export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';
@ -281,6 +288,7 @@ export interface PlaywrightTestArgs {
context: BrowserContext;
page: Page;
request: APIRequestContext;
server: MockingProxy;
}
type ExcludeProps<A, B> = {