feat: MockingProxy
This commit is contained in:
parent
99fb188cb4
commit
6f3504d931
453
docs/src/api/class-mockingproxy.md
Normal file
453
docs/src/api/class-mockingproxy.md
Normal 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]>
|
||||
|
||||
18
docs/src/api/class-mockingproxyfactory.md
Normal file
18
docs/src/api/class-mockingproxyfactory.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
166
docs/src/mock.md
166
docs/src/mock.md
|
|
@ -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);
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
202
packages/playwright-core/src/client/mockingProxy.ts
Normal file
202
packages/playwright-core/src/client/mockingProxy.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export const slowMoActions = new Set([
|
|||
|
||||
export const commandsWithTracingSnapshots = new Set([
|
||||
'EventTarget.waitForEventInfo',
|
||||
'LocalUtils.waitForEventInfo',
|
||||
'BrowserContext.waitForEventInfo',
|
||||
'Page.waitForEventInfo',
|
||||
'WebSocket.waitForEventInfo',
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
311
packages/playwright-core/src/server/dispatchers/mockingProxy.ts
Normal file
311
packages/playwright-core/src/server/dispatchers/mockingProxy.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
348
packages/playwright-core/types/types.d.ts
vendored
348
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
31
packages/playwright/types/test.d.ts
vendored
31
packages/playwright/types/test.d.ts
vendored
|
|
@ -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> = {
|
||||
|
|
|
|||
102
packages/protocol/src/channels.d.ts
vendored
102
packages/protocol/src/channels.d.ts
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
280
tests/library/mockingproxy.spec.ts
Normal file
280
tests/library/mockingproxy.spec.ts
Normal 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');
|
||||
});
|
||||
115
tests/playwright-test/playwright.mockingproxy.spec.ts
Normal file
115
tests/playwright-test/playwright.mockingproxy.spec.ts
Normal 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);
|
||||
});
|
||||
10
utils/generate_types/overrides-test.d.ts
vendored
10
utils/generate_types/overrides-test.d.ts
vendored
|
|
@ -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> = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue