From 6f3504d931f6cee459ed97e3a7772642600e77c3 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 20 Jan 2025 16:25:46 +0100 Subject: [PATCH] feat: MockingProxy --- docs/src/api/class-mockingproxy.md | 453 ++++++++++++++++++ docs/src/api/class-mockingproxyfactory.md | 18 + docs/src/api/class-playwright.md | 4 + docs/src/mock.md | 166 +++++++ docs/src/test-api/class-fixtures.md | 6 + docs/src/test-api/class-testoptions.md | 19 + .../src/client/browserContext.ts | 2 +- .../playwright-core/src/client/localUtils.ts | 3 + .../src/client/mockingProxy.ts | 202 ++++++++ .../playwright-core/src/client/network.ts | 6 +- packages/playwright-core/src/client/page.ts | 4 +- .../playwright-core/src/client/playwright.ts | 3 + .../playwright-core/src/protocol/debug.ts | 1 + .../playwright-core/src/protocol/validator.ts | 74 ++- .../src/server/browserContext.ts | 2 +- .../dispatchers/localUtilsDispatcher.ts | 69 ++- .../src/server/dispatchers/mockingProxy.ts | 311 ++++++++++++ .../playwright-core/src/server/network.ts | 15 +- .../playwright-core/src/utils/httpServer.ts | 11 +- packages/playwright-core/types/types.d.ts | 348 ++++++++++++++ packages/playwright/src/index.ts | 28 +- packages/playwright/src/util.ts | 15 +- packages/playwright/types/test.d.ts | 31 +- packages/protocol/src/channels.d.ts | 102 ++-- packages/protocol/src/protocol.yml | 82 +++- tests/library/mockingproxy.spec.ts | 280 +++++++++++ .../playwright.mockingproxy.spec.ts | 115 +++++ utils/generate_types/overrides-test.d.ts | 10 +- 28 files changed, 2287 insertions(+), 93 deletions(-) create mode 100644 docs/src/api/class-mockingproxy.md create mode 100644 docs/src/api/class-mockingproxyfactory.md create mode 100644 packages/playwright-core/src/client/mockingProxy.ts create mode 100644 packages/playwright-core/src/server/dispatchers/mockingProxy.ts create mode 100644 tests/library/mockingproxy.spec.ts create mode 100644 tests/playwright-test/playwright.mockingproxy.spec.ts diff --git a/docs/src/api/class-mockingproxy.md b/docs/src/api/class-mockingproxy.md new file mode 100644 index 0000000000..f16883d23e --- /dev/null +++ b/docs/src/api/class-mockingproxy.md @@ -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]> + +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]> + +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]> + diff --git a/docs/src/api/class-mockingproxyfactory.md b/docs/src/api/class-mockingproxyfactory.md new file mode 100644 index 0000000000..f0298fb620 --- /dev/null +++ b/docs/src/api/class-mockingproxyfactory.md @@ -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. diff --git a/docs/src/api/class-playwright.md b/docs/src/api/class-playwright.md index e0579eabc8..d4147b7c97 100644 --- a/docs/src/api/class-playwright.md +++ b/docs/src/api/class-playwright.md @@ -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 diff --git a/docs/src/mock.md b/docs/src/mock.md index 50bc3915ce..d4d5195a01 100644 --- a/docs/src/mock.md +++ b/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); +} +``` diff --git a/docs/src/test-api/class-fixtures.md b/docs/src/test-api/class-fixtures.md index 0c08b19da3..cda74b4346 100644 --- a/docs/src/test-api/class-fixtures.md +++ b/docs/src/test-api/class-fixtures.md @@ -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. diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index c285430616..e392704e72 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -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, + }, + }, +}); +``` \ No newline at end of file diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 5ff432ec60..e6f073d5ab 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -198,7 +198,7 @@ export class BrowserContext extends ChannelOwner } 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) { diff --git a/packages/playwright-core/src/client/localUtils.ts b/packages/playwright-core/src/client/localUtils.ts index 530547b227..b50b4bc323 100644 --- a/packages/playwright-core/src/client/localUtils.ts +++ b/packages/playwright-core/src/client/localUtils.ts @@ -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 { 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 { this.devices = {}; for (const { name, descriptor } of initializer.deviceDescriptors) this.devices[name] = descriptor; + this.requestContext = APIRequestContext.from(initializer.requestContext); } } diff --git a/packages/playwright-core/src/client/mockingProxy.ts b/packages/playwright-core/src/client/mockingProxy.ts new file mode 100644 index 0000000000..228a6d7fd4 --- /dev/null +++ b/packages/playwright-core/src/client/mockingProxy.ts @@ -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 { + 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 { + this._routes.unshift(new network.RouteHandler(undefined, url, handler, options.times)); + await this._updateInterceptionPatterns(); + } + + async unrouteAll(options?: { behavior?: 'wait' | 'ignoreErrors' | 'default' }): Promise { + await this._unrouteInternal(this._routes, [], options?.behavior); + } + + async unroute(url: URLMatch, handler?: network.RouteHandlerCallback): Promise { + 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 { + 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), options: { timeout?: number } = {}): Promise { + 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), options: { timeout?: number } = {}): Promise { + 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 { + 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 { + 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; + } + +} diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index a6b40307b3..bd545a572d 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -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 implements ap export class Route extends ChannelOwner implements api.Route { private _handlingPromise: ManualPromise | null = null; - _context!: BrowserContext; + _request!: APIRequestContext; _didThrow: boolean = false; static from(route: channels.RouteChannel): Route { @@ -339,7 +339,7 @@ export class Route extends ChannelOwner implements api.Ro async fetch(options: FallbackOverrides & { maxRedirects?: number, maxRetries?: number, timeout?: number } = {}): Promise { 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 }); }); } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index f1d90fece2..e030eaae57 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -180,7 +180,7 @@ export class Page extends ChannelOwner 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 { } } -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)) diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index 9933ce15de..fe10f07219 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -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 { readonly _android: Android; @@ -34,11 +35,13 @@ export class Playwright extends ChannelOwner { 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); diff --git a/packages/playwright-core/src/protocol/debug.ts b/packages/playwright-core/src/protocol/debug.ts index c58c3e4aaf..e5b2dc2758 100644 --- a/packages/playwright-core/src/protocol/debug.ts +++ b/packages/playwright-core/src/protocol/debug.ts @@ -66,6 +66,7 @@ export const slowMoActions = new Set([ export const commandsWithTracingSnapshots = new Set([ 'EventTarget.waitForEventInfo', + 'LocalUtils.waitForEventInfo', 'BrowserContext.waitForEventInfo', 'Page.waitForEventInfo', 'WebSocket.waitForEventInfo', diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 50e8b4f02a..3d2f551d06 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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']), diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index ce10daf013..fb91219d5b 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -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', diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index b6f8fe80ac..0ef2f3c0e4 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -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 implements channels.LocalUtilsChannel { _type_LocalUtils: boolean; + _type_EventTarget: boolean; private _harBackends = new Map(); private _stackSessions = new Map(); + _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 { @@ -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 { + 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); diff --git a/packages/playwright-core/src/server/dispatchers/mockingProxy.ts b/packages/playwright-core/src/server/dispatchers/mockingProxy.ts new file mode 100644 index 0000000000..82914317e9 --- /dev/null +++ b/packages/playwright-core/src/server/dispatchers/mockingProxy.ts @@ -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(); // 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 { + 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, 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): 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((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(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(); + 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'); + } + } + } +} diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 006f2f4cbf..f3c09d9aa8 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -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(); readonly _frame: frames.Frame | null = null; readonly _serviceWorker: pages.Worker | null = null; - readonly _context: contexts.BrowserContext; + readonly _context: RequestContext; private _rawRequestHeadersPromise = new ManualPromise(); private _waitForResponsePromise = new ManualPromise(); _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; +export type GetResponseBodyCallback = () => Promise; export type ResourceTiming = { startTime: number; diff --git a/packages/playwright-core/src/utils/httpServer.ts b/packages/playwright-core/src/utils/httpServer.ts index 8da2a0e0d0..f3039d913c 100644 --- a/packages/playwright-core/src/utils/httpServer.ts +++ b/packages/playwright-core/src/utils/httpServer.ts @@ -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) { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index d35137fbe2..fb3bceae96 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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), options?: { + /** + * How often a route should be used. By default it will be used every time. + */ + times?: number; + }): Promise; + + /** + * 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)): Promise; + + /** + * 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; + + /** + * 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, timeout?: number } | ((request: Request) => boolean | Promise)): Promise; + + /** + * Emitted when a request fails, for example by timing out. + */ + waitForEvent(event: 'requestfailed', optionsOrPredicate?: { predicate?: (request: Request) => boolean | Promise, timeout?: number } | ((request: Request) => boolean | Promise)): Promise; + + /** + * 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, timeout?: number } | ((request: Request) => boolean | Promise)): Promise; + + /** + * 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, timeout?: number } | ((response: Response) => boolean | Promise)): Promise; + + + /** + * 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), 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; + + /** + * 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), options?: { + /** + * Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout. + */ + timeout?: number; + }): Promise; +} + +/** + * 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; +} + /** * 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. */ diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 83913c18dc..d8225ef077 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -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 = ({ @@ -71,6 +73,7 @@ const playwrightFixtures: Fixtures = ({ 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 = ({ }, 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 = ({ baseURL, contextOptions, serviceWorkers, + _mockingProxy, }, use) => { const options: BrowserContextOptions = {}; if (acceptDownloads !== undefined) @@ -218,6 +235,8 @@ const playwrightFixtures: Fixtures = ({ 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 = ({ 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; diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index f7f91d3198..23dea04d10 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -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(); + 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; +} diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index b67377eb6d..8d04cba78a 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -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 = { diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 526cc599ab..b28d3385b9 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -49,7 +49,6 @@ export type InitializerTraits = 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 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 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 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 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 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; +} +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; harOpen(params: LocalUtilsHarOpenParams, metadata?: CallMetadata): Promise; @@ -438,7 +469,28 @@ export interface LocalUtilsChannel extends LocalUtilsEventTarget, Channel { tracingStarted(params: LocalUtilsTracingStartedParams, metadata?: CallMetadata): Promise; addStackToTracingNoReply(params: LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata): Promise; traceDiscarded(params: LocalUtilsTraceDiscardedParams, metadata?: CallMetadata): Promise; + setServerNetworkInterceptionPatterns(params: LocalUtilsSetServerNetworkInterceptionPatternsParams, metadata?: CallMetadata): Promise; } +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; -} -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, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index df54dcbe1c..b0a0439420 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -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 diff --git a/tests/library/mockingproxy.spec.ts b/tests/library/mockingproxy.spec.ts new file mode 100644 index 0000000000..1668650972 --- /dev/null +++ b/tests/library/mockingproxy.spec.ts @@ -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'); +}); diff --git a/tests/playwright-test/playwright.mockingproxy.spec.ts b/tests/playwright-test/playwright.mockingproxy.spec.ts new file mode 100644 index 0000000000..ebb6107224 --- /dev/null +++ b/tests/playwright-test/playwright.mockingproxy.spec.ts @@ -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); +}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 1bc980b42d..ff19d6b350 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -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; 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 = {