cherry-pick(#15688): PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS (#15692)

This commit is contained in:
Ross Wollman 2022-07-14 21:24:50 -07:00 committed by GitHub
parent b66873dfdb
commit eae7bde3ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 726 additions and 631 deletions

View file

@ -871,3 +871,13 @@ page.WebSocket += (_, ws) =>
- [`event: WebSocket.close`]
<br/>
## Missing Network Events and Service Workers
Playwright's built-in [`method: BrowserContext.route`] and [`method: Page.route`] allow your tests to natively route requests and perform mocking and interception.
1. If you're using Playwright's native [`method: BrowserContext.route`] and [`method: Page.route`], and it appears network events are missing, disable Service Workers by setting [`option: Browser.newContext.serviceWorkers`] to `'block'`.
1. It might be that you are using a mock tool such as Mock Service Worker (MSW). While this tool works out of the box for mocking responses, it adds its own Service Worker that takes over the network requests, hence making them invisible to [`method: BrowserContext.route`] and [`method: Page.route`]. If you are interested in both network testing and mocking, consider using built-in [`method: BrowserContext.route`] and [`method: Page.route`] for [response mocking](#handle-requests).
1. If you're interested in not solely using Service Workers for testing and network mocking, but in routing and listening for requests made by Service Workers themselves, please see [this experimental feature](https://github.com/microsoft/playwright/issues/15684).
<br/>

View file

@ -1,8 +1,11 @@
---
id: service-workers
title: "Service Workers Guide"
id: service-workers-experimental
title: "(Experimental) Service Worker Network Events"
---
:::warning
If you're looking to do general network mocking, routing, and interception, please see the [Network Guide](./network.md) first. Playwright provides built-in APIs for this use case that don't require the information below. However, if you're interested in requests made by Service Workers themselves, please read below.
:::
[Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) provide a browser-native method of handling requests made by a page with the native [Fetch API (`fetch`)](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) along with other network-requested assets (like scripts, css, and images).
@ -10,35 +13,31 @@ They can act as a **network proxy** between the page and the external network to
Many sites that use Service Workers simply use them as a transparent optimization technique. While users might notice a faster experience, the app's implementation is unaware of their existence. Running the app with or without Service Workers enabled appears functionally equivalent.
**If your app uses Service Workers**, here's the scenarios that Playwright supports:
## How to Enable
1. Testing the page exactly like a user would experience it. This works out of the box in all supported browsers.
1. Test your page without a Service Worker. Set [`option: Browser.newContext.serviceWorkers`] to `'block'`. You can test your page as if no Service Worker was registered.
1. Listen for and route network traffic via Playwright, whether it comes from a Service Worker or not. In Firefox and WebKit, set [`option: Browser.newContext.serviceWorkers`] to `'block'` to avoid Service Worker network traffic entirely. In Chromium, either block Service Workers or use [`method: BrowserContext.route`].
1. (Chromium-only) Test your Service Worker implementation itself. Use [`method: BrowserContext.serviceWorkers`] to get access to the Service Worker and evaluate there.
Playwright's inspection and routing of requests made by Service Workers are **experimental** and disabled by default.
Set the `PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS` environment variable to `1` (or any other value) to enable the feature. Only Chrome/Chromium are currently supported.
If you're using (or are interested in using this this feature), please comment on [this issue](https://github.com/microsoft/playwright/issues/15684) letting us know your use case.
## Service Worker Fetch
:::note
The next sections are only currently supported when using Playwright with Chrome/Chromium. In Firefox and WebKit, if a Service Worker has a FetchEvent handler, Playwright will **not** emit Network events for all network traffic.
:::
### Accessing Service Workers and Waiting for Activation
You can use [`method: BrowserContext.serviceWorkers`] to list the Service [Worker]s, or specifically watch for the Service [Worker] if you anticipate a page will trigger its [registration](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register):
```js tab=js-ts
const [ serviceworker ] = await Promise.all([
context.waitForEvent('serviceworker'),
page.goto('/example-with-a-service-worker.html'),
const [serviceworker] = await Promise.all([
context.waitForEvent("serviceworker"),
page.goto("/example-with-a-service-worker.html"),
]);
```
```js tab=js-js
const [ serviceworker ] = await Promise.all([
context.waitForEvent('serviceworker'),
page.goto('/example-with-a-service-worker.html'),
const [serviceworker] = await Promise.all([
context.waitForEvent("serviceworker"),
page.goto("/example-with-a-service-worker.html"),
]);
```
@ -66,25 +65,27 @@ Worker serviceWorker = page.waitForRequest(() -> {
});
```
[`event: BrowserContext.serviceWorker`] is fired ***before*** the Service Worker's main script has been evaluated, so ***before*** calling service[`method: Worker.evaluate`] you should wait on its activation.
[`event: BrowserContext.serviceWorker`] is fired **_before_** the Service Worker's main script has been evaluated, so **_before_** calling service[`method: Worker.evaluate`] you should wait on its activation.
There are more iodiomatic methods of waiting for a Service Worker to be activated, but the following is an implementation agnostic method:
```js tab=js-ts
await page.evaluate(async () => {
const registration = await window.navigator.serviceWorker.getRegistration();
if (registration.active?.state === 'activated')
return;
await new Promise(res => window.navigator.serviceWorker.addEventListener('controllerchange', res));
if (registration.active?.state === "activated") return;
await new Promise((res) =>
window.navigator.serviceWorker.addEventListener("controllerchange", res)
);
});
```
```js tab=js-js
await page.evaluate(async () => {
const registration = await window.navigator.serviceWorker.getRegistration();
if (registration.active?.state === 'activated')
return;
await new Promise(res => window.navigator.serviceWorker.addEventListener('controllerchange', res));
if (registration.active?.state === "activated") return;
await new Promise((res) =>
window.navigator.serviceWorker.addEventListener("controllerchange", res)
);
});
```
@ -130,18 +131,18 @@ page.evaluate(
Any network request made by the **Service Worker** will have:
* [`event: BrowserContext.request`] and its correponding events ([`event: BrowserContext.requestFinished`] and [`event: BrowserContext.response`], or [`event: BrowserContext.requestFailed`])
* [`method: BrowserContext.route`] will see the request
* [`method: Request.serviceWorker`] will be set to the Service [Worker] instance, and [`method: Request.frame`] will **throw**
* [`method: Response.fromServiceWorker`] will return `false`
- [`event: BrowserContext.request`] and its correponding events ([`event: BrowserContext.requestFinished`] and [`event: BrowserContext.response`], or [`event: BrowserContext.requestFailed`])
- [`method: BrowserContext.route`] will see the request
- [`method: Request.serviceWorker`] will be set to the Service [Worker] instance, and [`method: Request.frame`] will **throw**
- [`method: Response.fromServiceWorker`] will return `false`
Additionally, any network request made by the **Page** (including its sub-[Frame]s) will have:
* [`event: BrowserContext.request`] and its correponding events ([`event: BrowserContext.requestFinished`] and [`event: BrowserContext.response`], or [`event: BrowserContext.requestFailed`])
* [`event: Page.request`] and its correponding events ([`event: Page.requestFinished`] and [`event: Page.response`], or [`event: Page.requestFailed`])
* [`method: Page.route`] and [`method: Page.route`] will **not** see the request (if a Service Worker's fetch handler was registered)
* [`method: Request.serviceWorker`] will be set to `null`, and [`method: Request.frame`] will return the [Frame]
* [`method: Response.fromServiceWorker`] will return `true` (if a Service Worker's fetch handler was registered)
- [`event: BrowserContext.request`] and its correponding events ([`event: BrowserContext.requestFinished`] and [`event: BrowserContext.response`], or [`event: BrowserContext.requestFailed`])
- [`event: Page.request`] and its correponding events ([`event: Page.requestFinished`] and [`event: Page.response`], or [`event: Page.requestFailed`])
- [`method: Page.route`] and [`method: Page.route`] will **not** see the request (if a Service Worker's fetch handler was registered)
- [`method: Request.serviceWorker`] will be set to `null`, and [`method: Request.frame`] will return the [Frame]
- [`method: Response.fromServiceWorker`] will return `true` (if a Service Worker's fetch handler was registered)
Many Service Worker implementations simply execute the request from the page (possibly with some custom caching/offline logic omitted for simplicity):
@ -164,14 +165,16 @@ If a page registers the above Service Worker:
```html
<!-- filename: index.html -->
<script>
window.registrationPromise = navigator.serviceWorker.register('/transparent-service-worker.js');
window.registrationPromise = navigator.serviceWorker.register(
"/transparent-service-worker.js"
);
</script>
```
On the first visit to the page via [`method: Page.goto`], the following Request/Response events would be emitted (along with the corresponding network lifecycle events):
| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |
| - | - | - | - | - |
| --------------------------------- | ---------------- | ----------------------------- | ------ | -------------------------------------- |
| [`event: BrowserContext.request`] | [Frame] | index.html | Yes | |
| [`event: Page.request`] | [Frame] | index.html | Yes | |
| [`event: BrowserContext.request`] | Service [Worker] | transparent-service-worker.js | Yes | |
@ -179,25 +182,21 @@ On the first visit to the page via [`method: Page.goto`], the following Request/
| [`event: BrowserContext.request`] | [Frame] | data.json | | Yes |
| [`event: Page.request`] | [Frame] | data.json | | Yes |
Since the example Service Worker just acts a basic transparent "proxy":
* There's 2 [`event: BrowserContext.request`] events for `data.json`; one [Frame]-owned, the other Service [Worker]-owned.
* Only the Service [Worker]-owned request for the resource was routable via [`method: BrowserContext.route`]; the [Frame]-owned events for `data.json` are not routeable, as they would not have even had the possibility to hit the external network since the Service Worker has a fetch handler registered.
- There's 2 [`event: BrowserContext.request`] events for `data.json`; one [Frame]-owned, the other Service [Worker]-owned.
- Only the Service [Worker]-owned request for the resource was routable via [`method: BrowserContext.route`]; the [Frame]-owned events for `data.json` are not routeable, as they would not have even had the possibility to hit the external network since the Service Worker has a fetch handler registered.
:::caution
It's important to note: calling [`method: Request.frame`] or [`method: Response.frame`] will **throw** an exception, if called on a [Request]/[Response] that has a non-null [`method: Request.serviceWorker`].
:::
#### Advanced Example
When a Service Worker handles a page's request, the Service Worker can make 0 to n requests to the external network. The Service Worker might respond directly from a cache, generate a reponse in memory, rewrite the request, make two requests and then combine into 1, etc.
Consider the code snippets below to understand Playwright's view into the Request/Responses and how it impacts routing in some of these cases.
```js
// filename: complex-service-worker.js
self.addEventListener("install", function (event) {
@ -243,14 +242,16 @@ And a page that simply registers the Service Worker:
```html
<!-- filename: index.html -->
<script>
window.registrationPromise = navigator.serviceWorker.register('/complex-service-worker.js');
window.registrationPromise = navigator.serviceWorker.register(
"/complex-service-worker.js"
);
</script>
```
On the first visit to the page via [`method: Page.goto`], the following Request/Response events would be emitted:
| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |
| - | - | - | - | - |
| --------------------------------- | ---------------- | ------------------------- | ------ | -------------------------------------- |
| [`event: BrowserContext.request`] | [Frame] | index.html | Yes | |
| [`event: Page.request`] | [Frame] | index.html | Yes | |
| [`event: BrowserContext.request`] | Service [Worker] | complex-service-worker.js | Yes | |
@ -261,17 +262,17 @@ It's important to note that [`cache.add`](https://developer.mozilla.org/en-US/do
Once the Service Worker is activated and handling FetchEvents, if the page makes the following requests:
```js tab=js-ts
await page.evaluate(() => fetch('/addressbook.json'));
await page.evaluate(() => fetch('/foo'));
await page.evaluate(() => fetch('/tracker.js'));
await page.evaluate(() => fetch('/fallthrough.txt'));
await page.evaluate(() => fetch("/addressbook.json"));
await page.evaluate(() => fetch("/foo"));
await page.evaluate(() => fetch("/tracker.js"));
await page.evaluate(() => fetch("/fallthrough.txt"));
```
```js tab=js-js
await page.evaluate(() => fetch('/addressbook.json'));
await page.evaluate(() => fetch('/foo'));
await page.evaluate(() => fetch('/tracker.js'));
await page.evaluate(() => fetch('/fallthrough.txt'));
await page.evaluate(() => fetch("/addressbook.json"));
await page.evaluate(() => fetch("/foo"));
await page.evaluate(() => fetch("/tracker.js"));
await page.evaluate(() => fetch("/fallthrough.txt"));
```
```python async
@ -305,7 +306,7 @@ page.evaluate("fetch('/fallthrough.txt')")
The following Request/Response events would be emitted:
| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |
| - | - | - | - | - |
| --------------------------------- | ---------------- | ---------------- | ------ | -------------------------------------- |
| [`event: BrowserContext.request`] | [Frame] | addressbook.json | | Yes |
| [`event: Page.request`] | [Frame] | addressbook.json | | Yes |
| [`event: BrowserContext.request`] | Service [Worker] | bar | Yes | |
@ -319,19 +320,19 @@ The following Request/Response events would be emitted:
It's important to note:
* The page requested `/foo`, but the Service Worker requested `/bar`, so there are only [Frame]-owned events for `/foo`, but not `/bar`.
* Likewise, the Service Worker never hit the network for `tracker.js`, so ony [Frame]-owned events were emitted for that request.
- The page requested `/foo`, but the Service Worker requested `/bar`, so there are only [Frame]-owned events for `/foo`, but not `/bar`.
- Likewise, the Service Worker never hit the network for `tracker.js`, so ony [Frame]-owned events were emitted for that request.
## Routing Service Worker Requests Only
```js tab=js-ts
await context.route('**', async route => {
await context.route("**", async (route) => {
if (route.request().serviceWorker()) {
// NB: calling route.request().frame() here would THROW
return route.fulfill({
contentType: 'text/plain',
contentType: "text/plain",
status: 200,
body: 'from sw',
body: "from sw",
});
} else {
return route.continue();
@ -340,13 +341,13 @@ await context.route('**', async route => {
```
```js tab=js-js
await context.route('**', async route => {
await context.route("**", async (route) => {
if (route.request().serviceWorker()) {
// NB: calling route.request().frame() here would THROW
return route.fulfill({
contentType: 'text/plain',
contentType: "text/plain",
status: 200,
body: 'from sw',
body: "from sw",
});
} else {
return route.continue();
@ -406,4 +407,3 @@ browserContext.route("**", route -> {
## Known Limitations
Requests for updated Service Worker main script code currently cannot be routed (https://github.com/microsoft/playwright/issues/14711).

View file

@ -367,7 +367,10 @@ export class CRNetworkManager {
// For frame-level Requests that are handled by a Service Worker's fetch handler, we'll never get a requestPaused event, so we need to
// manually create the request. In an ideal world, crNetworkManager would be able to know this on Network.requestWillBeSent, but there
// is not enough metadata there.
if (!request && event.response.fromServiceWorker) {
//
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS we guard with, since this would fix an old bug where, when using routing,
// request would not be emitted to the user for requests made by a page with a SW (and fetch handler) registered
if (!!process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS && !request && event.response.fromServiceWorker) {
const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(event.requestId);
const frame = requestWillBeSentEvent?.frameId ? this._page?._frameManager.frame(requestWillBeSentEvent.frameId) : null;
if (requestWillBeSentEvent && frame) {

View file

@ -25,7 +25,7 @@ import { headersArrayToObject } from '../../utils';
export class CRServiceWorker extends Worker {
readonly _browserContext: CRBrowserContext;
readonly _networkManager: CRNetworkManager;
readonly _networkManager?: CRNetworkManager;
private _session: CRSession;
private _extraHTTPHeaders: types.HeadersArray | null = null;
@ -33,12 +33,13 @@ export class CRServiceWorker extends Worker {
super(browserContext, url);
this._session = session;
this._browserContext = browserContext;
if (!!process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS)
this._networkManager = new CRNetworkManager(session, null, this, null);
session.once('Runtime.executionContextCreated', event => {
this._createExecutionContext(new CRExecutionContext(session, event.context));
});
if (this._isNetworkInspectionEnabled()) {
if (this._networkManager && this._isNetworkInspectionEnabled()) {
this._networkManager.initialize().catch(() => {});
this.updateRequestInterception();
this.updateExtraHTTPHeaders(true);
@ -56,7 +57,7 @@ export class CRServiceWorker extends Worker {
const offline = !!this._browserContext._options.offline;
if (!initial || offline)
await this._networkManager.setOffline(offline);
await this._networkManager?.setOffline(offline);
}
async updateHttpCredentials(initial: boolean): Promise<void> {
@ -65,7 +66,7 @@ export class CRServiceWorker extends Worker {
const credentials = this._browserContext._options.httpCredentials || null;
if (!initial || credentials)
await this._networkManager.authenticate(credentials);
await this._networkManager?.authenticate(credentials);
}
async updateExtraHTTPHeaders(initial: boolean): Promise<void> {
@ -81,7 +82,7 @@ export class CRServiceWorker extends Worker {
}
updateRequestInterception(): Promise<void> {
if (!this._isNetworkInspectionEnabled())
if (!this._networkManager || !this._isNetworkInspectionEnabled())
return Promise.resolve();
return this._networkManager.setRequestInterception(this.needsRequestInterception()).catch(e => { });

File diff suppressed because it is too large Load diff