From 732b8f4760b6bcec19d4b17206f6fbc70ea9d668 Mon Sep 17 00:00:00 2001 From: Ross Wollman Date: Thu, 14 Jul 2022 17:40:22 -0700 Subject: [PATCH] chore: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS and doc clarifications (#15688) --- docs/src/network.md | 10 + ...ce-workers-experimental-network-events.md} | 21 +- .../src/server/chromium/crNetworkManager.ts | 5 +- .../src/server/chromium/crServiceWorker.ts | 13 +- tests/library/chromium/chromium.spec.ts | 1159 +++++++++-------- 5 files changed, 651 insertions(+), 557 deletions(-) rename docs/src/{service-workers.md => service-workers-experimental-network-events.md} (93%) diff --git a/docs/src/network.md b/docs/src/network.md index a4b2866d38..d4bde2508a 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -871,3 +871,13 @@ page.WebSocket += (_, ws) => - [`event: WebSocket.close`]
+ +## 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). + +
diff --git a/docs/src/service-workers.md b/docs/src/service-workers-experimental-network-events.md similarity index 93% rename from docs/src/service-workers.md rename to docs/src/service-workers-experimental-network-events.md index 262e948d0c..1c0362eb24 100644 --- a/docs/src/service-workers.md +++ b/docs/src/service-workers-experimental-network-events.md @@ -1,8 +1,11 @@ --- -id: service-workers -title: "Service Workers" +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,20 +13,16 @@ 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): diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index 72c3576e21..00bd6c8a6d 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -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) { diff --git a/packages/playwright-core/src/server/chromium/crServiceWorker.ts b/packages/playwright-core/src/server/chromium/crServiceWorker.ts index 0c79459e71..6c3c060018 100644 --- a/packages/playwright-core/src/server/chromium/crServiceWorker.ts +++ b/packages/playwright-core/src/server/chromium/crServiceWorker.ts @@ -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; - this._networkManager = new CRNetworkManager(session, null, this, null); + 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 { @@ -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 { @@ -81,7 +82,7 @@ export class CRServiceWorker extends Worker { } updateRequestInterception(): Promise { - if (!this._isNetworkInspectionEnabled()) + if (!this._networkManager || !this._isNetworkInspectionEnabled()) return Promise.resolve(); return this._networkManager.setRequestInterception(this.needsRequestInterception()).catch(e => { }); diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 929addf78d..dca6186140 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -39,366 +39,6 @@ test('should create a worker from service worker with noop routing', async ({ co expect(await worker.evaluate(() => self.toString())).toBe('[object ServiceWorkerGlobalScope]'); }); -test('serviceWorker(), and fromServiceWorker() work', async ({ context, page, server }) => { - const [worker, html, main, inWorker] = await Promise.all([ - context.waitForEvent('serviceworker'), - context.waitForEvent('request', r => r.url().endsWith('/sw.html')), - context.waitForEvent('request', r => r.url().endsWith('/sw.js')), - context.waitForEvent('request', r => r.url().endsWith('/request-from-within-worker.txt')), - page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html') - ]); - - expect(html.frame()).toBeTruthy(); - expect(html.serviceWorker()).toBe(null); - expect((await html.response()).fromServiceWorker()).toBe(false); - - expect(main.frame).toThrow(); - expect(main.serviceWorker()).toBe(worker); - expect((await main.response()).fromServiceWorker()).toBe(false); - - expect(inWorker.frame).toThrow(); - expect(inWorker.serviceWorker()).toBe(worker); - expect((await inWorker.response()).fromServiceWorker()).toBe(false); - - await page.evaluate(() => window['activationPromise']); - const [innerSW, innerPage] = await Promise.all([ - context.waitForEvent('request', r => r.url().endsWith('/inner.txt') && !!r.serviceWorker()), - context.waitForEvent('request', r => r.url().endsWith('/inner.txt') && !r.serviceWorker()), - page.evaluate(() => fetch('/inner.txt')), - ]); - expect(innerPage.serviceWorker()).toBe(null); - expect((await innerPage.response()).fromServiceWorker()).toBe(true); - - expect(innerSW.serviceWorker()).toBe(worker); - expect((await innerSW.response()).fromServiceWorker()).toBe(false); -}); - -test('should intercept service worker requests (main and within)', async ({ context, page, server }) => { - await context.route('**/request-from-within-worker', route => - route.fulfill({ - contentType: 'application/json', - status: 200, - body: '"intercepted!"', - }) - ); - - await context.route('**/sw.js', route => - route.fulfill({ - contentType: 'text/javascript', - status: 200, - body: ` - self.contentPromise = new Promise(res => fetch('/request-from-within-worker').then(r => r.json()).then(res)); - `, - }) - ); - - const [ sw ] = await Promise.all([ - context.waitForEvent('serviceworker'), - context.waitForEvent('response', r => r.url().endsWith('/request-from-within-worker')), - context.waitForEvent('request', r => r.url().endsWith('sw.js') && !!r.serviceWorker()), - context.waitForEvent('response', r => r.url().endsWith('sw.js') && !r.fromServiceWorker()), - page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'), - ]); - - await expect(sw.evaluate(() => self['contentPromise'])).resolves.toBe('intercepted!'); -}); - -test('should report failure (due to content-type) of main service worker request', async ({ server, page, context, browserMajorVersion }) => { - test.skip(browserMajorVersion < 104, 'Requires http://crrev.com/1012503 or later.'); - server.setRoute('/serviceworkers/fetch/sw.js', (req, res) => { - res.writeHead(200, 'OK', { 'Content-Type': 'text/html' }); - res.write(`console.log('hi from sw');`); - res.end(); - }); - const [, main] = await Promise.all([ - server.waitForRequest('/serviceworkers/fetch/sw.js'), - context.waitForEvent('request', r => r.url().endsWith('sw.js')), - page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html'), - ]); - // This will timeout today - await main.response(); -}); - -test('should report failure (due to redirect) of main service worker request', async ({ server, page, context, browserMajorVersion }) => { - test.skip(browserMajorVersion < 104, 'Requires http://crrev.com/1012503 or later.'); - server.setRedirect('/serviceworkers/empty/sw.js', '/dev/null'); - const [, main] = await Promise.all([ - server.waitForRequest('/serviceworkers/empty/sw.js'), - context.waitForEvent('request', r => r.url().endsWith('sw.js')), - page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'), - ]); - // This will timeout today - const resp = await main.response(); - expect(resp.status()).toBe(302); -}); - -test('should intercept service worker importScripts', async ({ context, page, server }) => { - await context.route('**/import.js', route => - route.fulfill({ - contentType: 'text/javascript', - status: 200, - body: 'self.exportedValue = 47;', - }) - ); - - await context.route('**/sw.js', route => - route.fulfill({ - contentType: 'text/javascript', - status: 200, - body: ` - importScripts('/import.js'); - self.importedValue = self.exportedValue; - `, - }) - ); - - const [ sw ] = await Promise.all([ - context.waitForEvent('serviceworker'), - context.waitForEvent('response', r => r.url().endsWith('/import.js')), - page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'), - ]); - - await expect(sw.evaluate(() => self['importedValue'])).resolves.toBe(47); -}); - -test('should report intercepted service worker requests in HAR', async ({ pageWithHar, server }) => { - const { context, page, getLog } = await pageWithHar(); - await context.route('**/request-from-within-worker', route => - route.fulfill({ - contentType: 'application/json', - headers: { - 'x-pw-test': 'request-within-worker', - }, - status: 200, - body: '"intercepted!"', - }) - ); - - await context.route('**/sw.js', route => - route.fulfill({ - contentType: 'text/javascript', - headers: { - 'x-pw-test': 'intercepted-main', - }, - status: 200, - body: ` - self.contentPromise = new Promise(res => fetch('/request-from-within-worker').then(r => r.json()).then(res)); - `, - }) - ); - - const [ sw ] = await Promise.all([ - context.waitForEvent('serviceworker'), - context.waitForEvent('response', r => r.url().endsWith('/request-from-within-worker')), - page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'), - ]); - - await expect(sw.evaluate(() => self['contentPromise'])).resolves.toBe('intercepted!'); - - const log = await getLog(); - { - const sw = log.entries.filter(e => e.request.url.endsWith('sw.js')); - expect.soft(sw).toHaveLength(1); - expect.soft(sw[0].response.headers.filter(v => v.name === 'x-pw-test')).toEqual([{ name: 'x-pw-test', value: 'intercepted-main' }]); - } - { - const req = log.entries.filter(e => e.request.url.endsWith('request-from-within-worker')); - expect.soft(req).toHaveLength(1); - expect.soft(req[0].response.headers.filter(v => v.name === 'x-pw-test')).toEqual([{ name: 'x-pw-test', value: 'request-within-worker' }]); - expect.soft(req[0].response.content.text).toBe('"intercepted!"'); - } -}); - -test('should intercept only serviceworker request, not page', async ({ context, page, server }) => { - await context.route('**/data.json', async route => { - if (route.request().serviceWorker()) { - return route.fulfill({ - contentType: 'text/plain', - status: 200, - body: 'from sw', - }); - } else { - return route.continue(); - } - }); - - const [ sw ] = await Promise.all([ - context.waitForEvent('serviceworker'), - page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html'), - ]); - await page.evaluate(() => window['activationPromise']); - const response = await page.evaluate(() => fetch('/data.json').then(r => r.text())); - const [ url ] = await sw.evaluate(() => self['intercepted']); - expect(url).toMatch(/\/data\.json$/); - expect(response).toBe('from sw'); -}); - -test('should produce network events, routing, and annotations for Service Worker', async ({ page, context, server }) => { - server.setRoute('/index.html', (req, res) => { - res.write(` - - `); - res.end(); - }); - server.setRoute('/transparent-service-worker.js', (req, res) => { - res.writeHead(200, 'OK', { 'Content-Type': 'text/javascript' }); - res.write(` - self.addEventListener("fetch", (event) => { - // actually make the request - const responsePromise = fetch(event.request); - // send it back to the page - event.respondWith(responsePromise); - }); - - self.addEventListener("activate", (event) => { - event.waitUntil(clients.claim()); - }); - `); - res.end(); - }); - - const routed = []; - const formatRequest = async ([scope, r]: ['page' | 'context', any]) => `| ${(scope === 'page' ? '[`event: Page.request`]' : '[`event: BrowserContext.request`]').padEnd('[`event: BrowserContext.request`]'.length, ' ')} | ${r.serviceWorker() ? 'Service [Worker]' : '[Frame]'.padEnd('Service [Worker]'.length, ' ')} | ${r.url().split('/').pop().padEnd(30, ' ')} | ${(routed.includes(r) ? 'Yes' : '').padEnd('Routed'.length, ' ')} | ${((await r.response()).fromServiceWorker() ? 'Yes' : '').padEnd('[`method: Response.fromServiceWorker`]'.length, ' ')} |`; - await context.route('**', async route => { - routed.push(route.request()); - await route.continue(); - }); - await page.route('**', async route => { - routed.push(route.request()); - await route.continue(); - }); - const requests = []; - page.on('request', r => requests.push(['page', r])); - context.on('request', r => requests.push(['context', r])); - - const [ sw ] = await Promise.all([ - context.waitForEvent('serviceworker'), - page.goto(server.PREFIX + '/index.html'), - ]); - - await expect.poll(() => sw.evaluate(() => (self as any).registration.active?.state)).toBe('activated'); - - await page.evaluate(() => fetch('/data.json')); - - expect([ - '| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |', - ...await Promise.all(requests.map(formatRequest))]) - .toEqual([ - '| 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 | |', - '| [`event: BrowserContext.request`] | Service [Worker] | data.json | Yes | |', - '| [`event: BrowserContext.request`] | [Frame] | data.json | | Yes |', - '| [`event: Page.request`] | [Frame] | data.json | | Yes |', - ]); -}); - -test('should produce network events, routing, and annotations for Service Worker (advanced)', async ({ page, context, server }) => { - server.setRoute('/index.html', (req, res) => { - res.write(` - - `); - res.end(); - }); - server.setRoute('/complex-service-worker.js', (req, res) => { - res.writeHead(200, 'OK', { 'Content-Type': 'text/javascript' }); - res.write(` - self.addEventListener("install", function (event) { - event.waitUntil( - caches.open("v1").then(function (cache) { - // 1. Pre-fetches and caches /addressbook.json - return cache.add("/addressbook.json"); - }) - ); - }); - - // Opt to handle FetchEvent's from the page - self.addEventListener("fetch", (event) => { - event.respondWith( - (async () => { - // 1. Try to first serve directly from caches - let response = await caches.match(event.request); - if (response) return response; - - // 2. Re-write request for /foo to /bar - if (event.request.url.endsWith("foo")) return fetch("./bar"); - - // 3. Prevent tracker.js from being retrieved, and returns a placeholder response - if (event.request.url.endsWith("tracker.js")) - return new Response('conosole.log("no trackers!")', { - status: 200, - headers: { "Content-Type": "text/javascript" }, - }); - - // 4. Otherwise, fallthrough, perform the fetch and respond - return fetch(event.request); - })() - ); - }); - - self.addEventListener("activate", (event) => { - event.waitUntil(clients.claim()); - }); - `); - res.end(); - }); - server.setRoute('/addressbook.json', (req, res) => { - res.write('{}'); - res.end(); - }); - - const routed = []; - const formatRequest = async ([scope, r]: ['page' | 'context', any]) => `| ${(scope === 'page' ? '[`event: Page.request`]' : '[`event: BrowserContext.request`]').padEnd('[`event: BrowserContext.request`]'.length, ' ')} | ${r.serviceWorker() ? 'Service [Worker]' : '[Frame]'.padEnd('Service [Worker]'.length, ' ')} | ${r.url().split('/').pop().padEnd(30, ' ')} | ${(routed.includes(r) ? 'Yes' : '').padEnd('Routed'.length, ' ')} | ${((await r.response()).fromServiceWorker() ? 'Yes' : '').padEnd('[`method: Response.fromServiceWorker`]'.length, ' ')} |`; - await context.route('**', async route => { - routed.push(route.request()); - await route.continue(); - }); - await page.route('**', async route => { - routed.push(route.request()); - await route.continue(); - }); - const requests = []; - page.on('request', r => requests.push(['page', r])); - context.on('request', r => requests.push(['context', r])); - - const [ sw ] = await Promise.all([ - context.waitForEvent('serviceworker'), - page.goto(server.PREFIX + '/index.html'), - ]); - - await expect.poll(() => sw.evaluate(() => (self as any).registration.active?.state)).toBe('activated'); - - await page.evaluate(() => fetch('/addressbook.json')); - await page.evaluate(() => fetch('/foo')); - await page.evaluate(() => fetch('/tracker.js')); - await page.evaluate(() => fetch('/fallthrough.txt')); - - expect([ - '| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |', - ...await Promise.all(requests.map(formatRequest))]) - .toEqual([ - '| 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 | |', - '| [`event: BrowserContext.request`] | Service [Worker] | addressbook.json | Yes | |', - '| [`event: BrowserContext.request`] | [Frame] | addressbook.json | | Yes |', - '| [`event: Page.request`] | [Frame] | addressbook.json | | Yes |', - '| [`event: BrowserContext.request`] | Service [Worker] | bar | Yes | |', - '| [`event: BrowserContext.request`] | [Frame] | foo | | Yes |', - '| [`event: Page.request`] | [Frame] | foo | | Yes |', - '| [`event: BrowserContext.request`] | [Frame] | tracker.js | | Yes |', - '| [`event: Page.request`] | [Frame] | tracker.js | | Yes |', - '| [`event: BrowserContext.request`] | Service [Worker] | fallthrough.txt | Yes | |', - '| [`event: BrowserContext.request`] | [Frame] | fallthrough.txt | | Yes |', - '| [`event: Page.request`] | [Frame] | fallthrough.txt | | Yes |' ]); -}); - test('should emit new service worker on update', async ({ context, page, server }) => { let version = 0; server.setRoute('/worker.js', (req, res) => { @@ -447,185 +87,6 @@ test('should emit new service worker on update', async ({ context, page, server await expect.poll(() => updatedSW.evaluate(() => self['PW_VERSION'])).toBe(1); }); -test('should intercept service worker update requests', async ({ context, page, server }) => { - test.fixme(); - test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/14711' }); - - let version = 0; - server.setRoute('/worker.js', (req, res) => { - res.writeHead(200, 'OK', { 'Content-Type': 'text/javascript' }); - res.write(`self.PW_VERSION = ${version++};`); - res.end(); - }); - - server.setRoute('/home', (req, res) => { - res.write(` - - - - Service Worker Update Demo - - - - - - - `); - res.end(); - }); - - const [ sw ] = await Promise.all([ - context.waitForEvent('serviceworker'), - page.goto(server.PREFIX + '/home'), - ]); - - await expect.poll(() => sw.evaluate(() => self['PW_VERSION'])).toBe(0); - - // Before triggering, let's intercept the update request - await context.route('**/worker.js', async route => { - await route.fulfill({ - status: 200, - body: `self.PW_VERSION = "intercepted";`, - contentType: 'text/javascript', - }); - }); - - const [ updatedSW ] = await Promise.all([ - context.waitForEvent('serviceworker'), - // currently times out here - context.waitForEvent('request', r => r.url().endsWith('worker.js')), - page.click('#update'), - ]); - - await expect.poll(() => updatedSW.evaluate(() => self['PW_VERSION'])).toBe('intercepted'); -}); - -test('setOffline', async ({ context, page, server }) => { - const [worker] = await Promise.all([ - context.waitForEvent('serviceworker'), - page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html') - ]); - - await page.evaluate(() => window['activationPromise']); - await context.setOffline(true); - const [,error] = await Promise.all([ - context.waitForEvent('request', r => r.url().endsWith('/inner.txt') && !!r.serviceWorker()), - worker.evaluate(() => fetch('/inner.txt').catch(e => `REJECTED: ${e}`)), - ]); - expect(error).toMatch(/REJECTED.*Failed to fetch/); -}); - -test.describe('should emit page-level network events with service worker fetch handler', () => { - test.describe('when not using routing', () => { - test('successful request', async ({ page, server }) => { - await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); - await page.evaluate(() => window['activationPromise']); - - const [pageReq, pageResp, /* pageFinished */, swResponse] = await Promise.all([ - page.waitForEvent('request'), - page.waitForEvent('response'), - page.waitForEvent('requestfinished'), - page.evaluate(() => window['fetchDummy']('foo')), - ]); - expect(swResponse).toBe('responseFromServiceWorker:foo'); - expect(pageReq.url()).toMatch(/fetchdummy\/foo$/); - expect(pageReq.serviceWorker()).toBe(null); - expect(pageResp.fromServiceWorker()).toBe(true); - expect(pageResp).toBe(await pageReq.response()); - expect((await pageReq.response()).fromServiceWorker()).toBe(true); - }); - - test('failed request', async ({ page, server }) => { - await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); - await page.evaluate(() => window['activationPromise']); - - const [pageReq] = await Promise.all([ - page.waitForEvent('request'), - page.waitForEvent('requestfailed'), - page.evaluate(() => window['fetchDummy']('error')).catch(e => e), - ]); - expect(pageReq.url()).toMatch(/fetchdummy\/error$/); - expect(pageReq.failure().errorText).toMatch(/net::ERR_FAILED/); - expect(pageReq.serviceWorker()).toBe(null); - expect(await pageReq.response()).toBe(null); - }); - }); - - test.describe('when routing', () => { - test('successful request', async ({ page, server, context }) => { - await context.route('**', route => route.continue()); - let markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = false; - await page.route('**', route => { - if (route.request().url().endsWith('foo')) - markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = true; - route.continue(); - }); - await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); - await page.evaluate(() => window['activationPromise']); - - const [pageReq, pageResp, /* pageFinished */, swResponse] = await Promise.all([ - page.waitForEvent('request'), - page.waitForEvent('response'), - page.waitForEvent('requestfinished'), - page.evaluate(() => window['fetchDummy']('foo')), - ]); - expect(swResponse).toBe('responseFromServiceWorker:foo'); - expect(pageReq.url()).toMatch(/fetchdummy\/foo$/); - expect(pageReq.serviceWorker()).toBe(null); - expect(pageResp.fromServiceWorker()).toBe(true); - expect(pageResp).toBe(await pageReq.response()); - expect((await pageReq.response()).fromServiceWorker()).toBe(true); - expect(markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker).toBe(false); - }); - - test('failed request', async ({ page, server, context }) => { - await context.route('**', route => route.continue()); - let markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = false; - await page.route('**', route => { - if (route.request().url().endsWith('foo')) - markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = true; - route.continue(); - }); - await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); - await page.evaluate(() => window['activationPromise']); - - const [pageReq] = await Promise.all([ - page.waitForEvent('request'), - page.waitForEvent('requestfailed'), - page.evaluate(() => window['fetchDummy']('error')).catch(e => e), - ]); - expect(pageReq.url()).toMatch(/fetchdummy\/error$/); - expect(pageReq.failure().errorText).toMatch(/net::ERR_FAILED/); - expect(pageReq.serviceWorker()).toBe(null); - expect(await pageReq.response()).toBe(null); - expect(markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker).toBe(false); - }); - }); -}); - -test('setExtraHTTPHeaders', async ({ context, page, server }) => { - const [worker] = await Promise.all([ - context.waitForEvent('serviceworker'), - page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html') - ]); - - await page.evaluate(() => window['activationPromise']); - await context.setExtraHTTPHeaders({ 'x-custom-header': 'custom!' }); - const requestPromise = server.waitForRequest('/inner.txt'); - await worker.evaluate(() => fetch('/inner.txt')); - const req = await requestPromise; - expect(req.headers['x-custom-header']).toBe('custom!'); -}); - test.describe('http credentials', () => { test.use({ httpCredentials: { username: 'user', password: 'pass' } }); @@ -1100,3 +561,623 @@ playwrightTest('should pass args with spaces', async ({ browserType, createUserD await browser.close(); expect(userAgent).toBe('I am Foo'); }); + +test.describe('should emit page-level network events with service worker fetch handler', () => { + test.describe('when not using routing', () => { + test('successful request', async ({ page, server }) => { + await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); + await page.evaluate(() => window['activationPromise']); + + const [pageReq, pageResp, /* pageFinished */, swResponse] = await Promise.all([ + page.waitForEvent('request'), + page.waitForEvent('response'), + page.waitForEvent('requestfinished'), + page.evaluate(() => window['fetchDummy']('foo')), + ]); + expect(swResponse).toBe('responseFromServiceWorker:foo'); + expect(pageReq.url()).toMatch(/fetchdummy\/foo$/); + expect(pageReq.serviceWorker()).toBe(null); + expect(pageResp.fromServiceWorker()).toBe(true); + expect(pageResp).toBe(await pageReq.response()); + expect((await pageReq.response()).fromServiceWorker()).toBe(true); + }); + + test('failed request', async ({ page, server }) => { + await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); + await page.evaluate(() => window['activationPromise']); + + const [pageReq] = await Promise.all([ + page.waitForEvent('request'), + page.waitForEvent('requestfailed'), + page.evaluate(() => window['fetchDummy']('error')).catch(e => e), + ]); + expect(pageReq.url()).toMatch(/fetchdummy\/error$/); + expect(pageReq.failure().errorText).toMatch(/net::ERR_FAILED/); + expect(pageReq.serviceWorker()).toBe(null); + expect(await pageReq.response()).toBe(null); + }); + }); + + test.describe('when routing', () => { + test('successful request', async ({ page, server, context }) => { + await context.route('**', route => route.continue()); + await page.route('**', route => route.continue()); + await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); + await page.evaluate(() => window['activationPromise']); + + const [result, pageResp] = await Promise.all([ + page.waitForEvent('request', { timeout: 750 }).catch(e => 'timeout'), + page.evaluate(() => window['fetchDummy']('foo')), + ]); + expect(result).toBe('timeout'); + expect(pageResp).toBeTruthy(); + }); + + test('failed request', async ({ page, server, context }) => { + await context.route('**', route => route.continue()); + let markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = false; + await page.route('**', route => { + if (route.request().url().endsWith('foo')) + markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = true; + route.continue(); + }); + await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); + await page.evaluate(() => window['activationPromise']); + + const [pageReq] = await Promise.all([ + page.waitForEvent('request'), + page.waitForEvent('requestfailed'), + page.evaluate(() => window['fetchDummy']('error')).catch(e => e), + ]); + expect(pageReq.url()).toMatch(/fetchdummy\/error$/); + expect(pageReq.failure().errorText).toMatch(/net::ERR_FAILED/); + expect(pageReq.serviceWorker()).toBe(null); + expect(await pageReq.response()).toBe(null); + expect(markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker).toBe(false); + }); + }); +}); + +test.describe('PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1', () => { + test.beforeAll(() => process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1'); + test.afterAll(() => delete process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS); + + test('serviceWorker(), and fromServiceWorker() work', async ({ context, page, server }) => { + const [worker, html, main, inWorker] = await Promise.all([ + context.waitForEvent('serviceworker'), + context.waitForEvent('request', r => r.url().endsWith('/sw.html')), + context.waitForEvent('request', r => r.url().endsWith('/sw.js')), + context.waitForEvent('request', r => r.url().endsWith('/request-from-within-worker.txt')), + page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html') + ]); + + expect(html.frame()).toBeTruthy(); + expect(html.serviceWorker()).toBe(null); + expect((await html.response()).fromServiceWorker()).toBe(false); + + expect(main.frame).toThrow(); + expect(main.serviceWorker()).toBe(worker); + expect((await main.response()).fromServiceWorker()).toBe(false); + + expect(inWorker.frame).toThrow(); + expect(inWorker.serviceWorker()).toBe(worker); + expect((await inWorker.response()).fromServiceWorker()).toBe(false); + + await page.evaluate(() => window['activationPromise']); + const [innerSW, innerPage] = await Promise.all([ + context.waitForEvent('request', r => r.url().endsWith('/inner.txt') && !!r.serviceWorker()), + context.waitForEvent('request', r => r.url().endsWith('/inner.txt') && !r.serviceWorker()), + page.evaluate(() => fetch('/inner.txt')), + ]); + expect(innerPage.serviceWorker()).toBe(null); + expect((await innerPage.response()).fromServiceWorker()).toBe(true); + + expect(innerSW.serviceWorker()).toBe(worker); + expect((await innerSW.response()).fromServiceWorker()).toBe(false); + }); + + test('should intercept service worker requests (main and within)', async ({ context, page, server }) => { + await context.route('**/request-from-within-worker', route => + route.fulfill({ + contentType: 'application/json', + status: 200, + body: '"intercepted!"', + }) + ); + + await context.route('**/sw.js', route => + route.fulfill({ + contentType: 'text/javascript', + status: 200, + body: ` + self.contentPromise = new Promise(res => fetch('/request-from-within-worker').then(r => r.json()).then(res)); + `, + }) + ); + + const [ sw ] = await Promise.all([ + context.waitForEvent('serviceworker'), + context.waitForEvent('response', r => r.url().endsWith('/request-from-within-worker')), + context.waitForEvent('request', r => r.url().endsWith('sw.js') && !!r.serviceWorker()), + context.waitForEvent('response', r => r.url().endsWith('sw.js') && !r.fromServiceWorker()), + page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'), + ]); + + await expect(sw.evaluate(() => self['contentPromise'])).resolves.toBe('intercepted!'); + }); + + test('should report failure (due to content-type) of main service worker request', async ({ server, page, context, browserMajorVersion }) => { + test.skip(browserMajorVersion < 104, 'Requires http://crrev.com/1012503 or later.'); + server.setRoute('/serviceworkers/fetch/sw.js', (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/html' }); + res.write(`console.log('hi from sw');`); + res.end(); + }); + const [, main] = await Promise.all([ + server.waitForRequest('/serviceworkers/fetch/sw.js'), + context.waitForEvent('request', r => r.url().endsWith('sw.js')), + page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html'), + ]); + // This will timeout today + await main.response(); + }); + + test('should report failure (due to redirect) of main service worker request', async ({ server, page, context, browserMajorVersion }) => { + test.skip(browserMajorVersion < 104, 'Requires http://crrev.com/1012503 or later.'); + server.setRedirect('/serviceworkers/empty/sw.js', '/dev/null'); + const [, main] = await Promise.all([ + server.waitForRequest('/serviceworkers/empty/sw.js'), + context.waitForEvent('request', r => r.url().endsWith('sw.js')), + page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'), + ]); + // This will timeout today + const resp = await main.response(); + expect(resp.status()).toBe(302); + }); + + test('should intercept service worker importScripts', async ({ context, page, server }) => { + await context.route('**/import.js', route => + route.fulfill({ + contentType: 'text/javascript', + status: 200, + body: 'self.exportedValue = 47;', + }) + ); + + await context.route('**/sw.js', route => + route.fulfill({ + contentType: 'text/javascript', + status: 200, + body: ` + importScripts('/import.js'); + self.importedValue = self.exportedValue; + `, + }) + ); + + const [ sw ] = await Promise.all([ + context.waitForEvent('serviceworker'), + context.waitForEvent('response', r => r.url().endsWith('/import.js')), + page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'), + ]); + + await expect(sw.evaluate(() => self['importedValue'])).resolves.toBe(47); + }); + + test('should report intercepted service worker requests in HAR', async ({ pageWithHar, server }) => { + const { context, page, getLog } = await pageWithHar(); + await context.route('**/request-from-within-worker', route => + route.fulfill({ + contentType: 'application/json', + headers: { + 'x-pw-test': 'request-within-worker', + }, + status: 200, + body: '"intercepted!"', + }) + ); + + await context.route('**/sw.js', route => + route.fulfill({ + contentType: 'text/javascript', + headers: { + 'x-pw-test': 'intercepted-main', + }, + status: 200, + body: ` + self.contentPromise = new Promise(res => fetch('/request-from-within-worker').then(r => r.json()).then(res)); + `, + }) + ); + + const [ sw ] = await Promise.all([ + context.waitForEvent('serviceworker'), + context.waitForEvent('response', r => r.url().endsWith('/request-from-within-worker')), + page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'), + ]); + + await expect(sw.evaluate(() => self['contentPromise'])).resolves.toBe('intercepted!'); + + const log = await getLog(); + { + const sw = log.entries.filter(e => e.request.url.endsWith('sw.js')); + expect.soft(sw).toHaveLength(1); + expect.soft(sw[0].response.headers.filter(v => v.name === 'x-pw-test')).toEqual([{ name: 'x-pw-test', value: 'intercepted-main' }]); + } + { + const req = log.entries.filter(e => e.request.url.endsWith('request-from-within-worker')); + expect.soft(req).toHaveLength(1); + expect.soft(req[0].response.headers.filter(v => v.name === 'x-pw-test')).toEqual([{ name: 'x-pw-test', value: 'request-within-worker' }]); + expect.soft(req[0].response.content.text).toBe('"intercepted!"'); + } + }); + + test('should intercept only serviceworker request, not page', async ({ context, page, server }) => { + await context.route('**/data.json', async route => { + if (route.request().serviceWorker()) { + return route.fulfill({ + contentType: 'text/plain', + status: 200, + body: 'from sw', + }); + } else { + return route.continue(); + } + }); + + const [ sw ] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html'), + ]); + await page.evaluate(() => window['activationPromise']); + const response = await page.evaluate(() => fetch('/data.json').then(r => r.text())); + const [ url ] = await sw.evaluate(() => self['intercepted']); + expect(url).toMatch(/\/data\.json$/); + expect(response).toBe('from sw'); + }); + + test('should produce network events, routing, and annotations for Service Worker', async ({ page, context, server }) => { + server.setRoute('/index.html', (req, res) => { + res.write(` + + `); + res.end(); + }); + server.setRoute('/transparent-service-worker.js', (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/javascript' }); + res.write(` + self.addEventListener("fetch", (event) => { + // actually make the request + const responsePromise = fetch(event.request); + // send it back to the page + event.respondWith(responsePromise); + }); + + self.addEventListener("activate", (event) => { + event.waitUntil(clients.claim()); + }); + `); + res.end(); + }); + + const routed = []; + const formatRequest = async ([scope, r]: ['page' | 'context', any]) => `| ${(scope === 'page' ? '[`event: Page.request`]' : '[`event: BrowserContext.request`]').padEnd('[`event: BrowserContext.request`]'.length, ' ')} | ${r.serviceWorker() ? 'Service [Worker]' : '[Frame]'.padEnd('Service [Worker]'.length, ' ')} | ${r.url().split('/').pop().padEnd(30, ' ')} | ${(routed.includes(r) ? 'Yes' : '').padEnd('Routed'.length, ' ')} | ${((await r.response()).fromServiceWorker() ? 'Yes' : '').padEnd('[`method: Response.fromServiceWorker`]'.length, ' ')} |`; + await context.route('**', async route => { + routed.push(route.request()); + await route.continue(); + }); + await page.route('**', async route => { + routed.push(route.request()); + await route.continue(); + }); + const requests = []; + page.on('request', r => requests.push(['page', r])); + context.on('request', r => requests.push(['context', r])); + + const [ sw ] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/index.html'), + ]); + + await expect.poll(() => sw.evaluate(() => (self as any).registration.active?.state)).toBe('activated'); + + await page.evaluate(() => fetch('/data.json')); + + expect([ + '| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |', + ...await Promise.all(requests.map(formatRequest))]) + .toEqual([ + '| 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 | |', + '| [`event: BrowserContext.request`] | Service [Worker] | data.json | Yes | |', + '| [`event: BrowserContext.request`] | [Frame] | data.json | | Yes |', + '| [`event: Page.request`] | [Frame] | data.json | | Yes |', + ]); + }); + + test('should produce network events, routing, and annotations for Service Worker (advanced)', async ({ page, context, server }) => { + server.setRoute('/index.html', (req, res) => { + res.write(` + + `); + res.end(); + }); + server.setRoute('/complex-service-worker.js', (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/javascript' }); + res.write(` + self.addEventListener("install", function (event) { + event.waitUntil( + caches.open("v1").then(function (cache) { + // 1. Pre-fetches and caches /addressbook.json + return cache.add("/addressbook.json"); + }) + ); + }); + + // Opt to handle FetchEvent's from the page + self.addEventListener("fetch", (event) => { + event.respondWith( + (async () => { + // 1. Try to first serve directly from caches + let response = await caches.match(event.request); + if (response) return response; + + // 2. Re-write request for /foo to /bar + if (event.request.url.endsWith("foo")) return fetch("./bar"); + + // 3. Prevent tracker.js from being retrieved, and returns a placeholder response + if (event.request.url.endsWith("tracker.js")) + return new Response('conosole.log("no trackers!")', { + status: 200, + headers: { "Content-Type": "text/javascript" }, + }); + + // 4. Otherwise, fallthrough, perform the fetch and respond + return fetch(event.request); + })() + ); + }); + + self.addEventListener("activate", (event) => { + event.waitUntil(clients.claim()); + }); + `); + res.end(); + }); + server.setRoute('/addressbook.json', (req, res) => { + res.write('{}'); + res.end(); + }); + + const routed = []; + const formatRequest = async ([scope, r]: ['page' | 'context', any]) => `| ${(scope === 'page' ? '[`event: Page.request`]' : '[`event: BrowserContext.request`]').padEnd('[`event: BrowserContext.request`]'.length, ' ')} | ${r.serviceWorker() ? 'Service [Worker]' : '[Frame]'.padEnd('Service [Worker]'.length, ' ')} | ${r.url().split('/').pop().padEnd(30, ' ')} | ${(routed.includes(r) ? 'Yes' : '').padEnd('Routed'.length, ' ')} | ${((await r.response()).fromServiceWorker() ? 'Yes' : '').padEnd('[`method: Response.fromServiceWorker`]'.length, ' ')} |`; + await context.route('**', async route => { + routed.push(route.request()); + await route.continue(); + }); + await page.route('**', async route => { + routed.push(route.request()); + await route.continue(); + }); + const requests = []; + page.on('request', r => requests.push(['page', r])); + context.on('request', r => requests.push(['context', r])); + + const [ sw ] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/index.html'), + ]); + + await expect.poll(() => sw.evaluate(() => (self as any).registration.active?.state)).toBe('activated'); + + await page.evaluate(() => fetch('/addressbook.json')); + await page.evaluate(() => fetch('/foo')); + await page.evaluate(() => fetch('/tracker.js')); + await page.evaluate(() => fetch('/fallthrough.txt')); + + expect([ + '| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |', + ...await Promise.all(requests.map(formatRequest))]) + .toEqual([ + '| 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 | |', + '| [`event: BrowserContext.request`] | Service [Worker] | addressbook.json | Yes | |', + '| [`event: BrowserContext.request`] | [Frame] | addressbook.json | | Yes |', + '| [`event: Page.request`] | [Frame] | addressbook.json | | Yes |', + '| [`event: BrowserContext.request`] | Service [Worker] | bar | Yes | |', + '| [`event: BrowserContext.request`] | [Frame] | foo | | Yes |', + '| [`event: Page.request`] | [Frame] | foo | | Yes |', + '| [`event: BrowserContext.request`] | [Frame] | tracker.js | | Yes |', + '| [`event: Page.request`] | [Frame] | tracker.js | | Yes |', + '| [`event: BrowserContext.request`] | Service [Worker] | fallthrough.txt | Yes | |', + '| [`event: BrowserContext.request`] | [Frame] | fallthrough.txt | | Yes |', + '| [`event: Page.request`] | [Frame] | fallthrough.txt | | Yes |' ]); + }); + + test('should intercept service worker update requests', async ({ context, page, server }) => { + test.fixme(); + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/14711' }); + + let version = 0; + server.setRoute('/worker.js', (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/javascript' }); + res.write(`self.PW_VERSION = ${version++};`); + res.end(); + }); + + server.setRoute('/home', (req, res) => { + res.write(` + + + + Service Worker Update Demo + + + + + + + `); + res.end(); + }); + + const [ sw ] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/home'), + ]); + + await expect.poll(() => sw.evaluate(() => self['PW_VERSION'])).toBe(0); + + // Before triggering, let's intercept the update request + await context.route('**/worker.js', async route => { + await route.fulfill({ + status: 200, + body: `self.PW_VERSION = "intercepted";`, + contentType: 'text/javascript', + }); + }); + + const [ updatedSW ] = await Promise.all([ + context.waitForEvent('serviceworker'), + // currently times out here + context.waitForEvent('request', r => r.url().endsWith('worker.js')), + page.click('#update'), + ]); + + await expect.poll(() => updatedSW.evaluate(() => self['PW_VERSION'])).toBe('intercepted'); + }); + + test('setOffline', async ({ context, page, server }) => { + const [worker] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html') + ]); + + await page.evaluate(() => window['activationPromise']); + await context.setOffline(true); + const [,error] = await Promise.all([ + context.waitForEvent('request', r => r.url().endsWith('/inner.txt') && !!r.serviceWorker()), + worker.evaluate(() => fetch('/inner.txt').catch(e => `REJECTED: ${e}`)), + ]); + expect(error).toMatch(/REJECTED.*Failed to fetch/); + }); + + test.describe('should emit page-level network events with service worker fetch handler', () => { + test.describe('when not using routing', () => { + test('successful request', async ({ page, server }) => { + await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); + await page.evaluate(() => window['activationPromise']); + + const [pageReq, pageResp, /* pageFinished */, swResponse] = await Promise.all([ + page.waitForEvent('request'), + page.waitForEvent('response'), + page.waitForEvent('requestfinished'), + page.evaluate(() => window['fetchDummy']('foo')), + ]); + expect(swResponse).toBe('responseFromServiceWorker:foo'); + expect(pageReq.url()).toMatch(/fetchdummy\/foo$/); + expect(pageReq.serviceWorker()).toBe(null); + expect(pageResp.fromServiceWorker()).toBe(true); + expect(pageResp).toBe(await pageReq.response()); + expect((await pageReq.response()).fromServiceWorker()).toBe(true); + }); + + test('failed request', async ({ page, server }) => { + await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); + await page.evaluate(() => window['activationPromise']); + + const [pageReq] = await Promise.all([ + page.waitForEvent('request'), + page.waitForEvent('requestfailed'), + page.evaluate(() => window['fetchDummy']('error')).catch(e => e), + ]); + expect(pageReq.url()).toMatch(/fetchdummy\/error$/); + expect(pageReq.failure().errorText).toMatch(/net::ERR_FAILED/); + expect(pageReq.serviceWorker()).toBe(null); + expect(await pageReq.response()).toBe(null); + }); + }); + + test.describe('when routing', () => { + test('successful request', async ({ page, server, context }) => { + await context.route('**', route => route.continue()); + let markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = false; + await page.route('**', route => { + if (route.request().url().endsWith('foo')) + markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = true; + route.continue(); + }); + await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); + await page.evaluate(() => window['activationPromise']); + + const [pageReq, pageResp, /* pageFinished */, swResponse] = await Promise.all([ + page.waitForEvent('request'), + page.waitForEvent('response'), + page.waitForEvent('requestfinished'), + page.evaluate(() => window['fetchDummy']('foo')), + ]); + expect(swResponse).toBe('responseFromServiceWorker:foo'); + expect(pageReq.url()).toMatch(/fetchdummy\/foo$/); + expect(pageReq.serviceWorker()).toBe(null); + expect(pageResp.fromServiceWorker()).toBe(true); + expect(pageResp).toBe(await pageReq.response()); + expect((await pageReq.response()).fromServiceWorker()).toBe(true); + expect(markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker).toBe(false); + }); + + test('failed request', async ({ page, server, context }) => { + await context.route('**', route => route.continue()); + let markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = false; + await page.route('**', route => { + if (route.request().url().endsWith('foo')) + markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker = true; + route.continue(); + }); + await page.goto(server.PREFIX + '/serviceworkers/fetchdummy/sw.html'); + await page.evaluate(() => window['activationPromise']); + + const [pageReq] = await Promise.all([ + page.waitForEvent('request'), + page.waitForEvent('requestfailed'), + page.evaluate(() => window['fetchDummy']('error')).catch(e => e), + ]); + expect(pageReq.url()).toMatch(/fetchdummy\/error$/); + expect(pageReq.failure().errorText).toMatch(/net::ERR_FAILED/); + expect(pageReq.serviceWorker()).toBe(null); + expect(await pageReq.response()).toBe(null); + expect(markFailureIfPageRoutesARequestAlreadyHandledByServiceWorker).toBe(false); + }); + }); + }); + + test('setExtraHTTPHeaders', async ({ context, page, server }) => { + const [worker] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html') + ]); + + await page.evaluate(() => window['activationPromise']); + await context.setExtraHTTPHeaders({ 'x-custom-header': 'custom!' }); + const requestPromise = server.waitForRequest('/inner.txt'); + await worker.evaluate(() => fetch('/inner.txt')); + const req = await requestPromise; + expect(req.headers['x-custom-header']).toBe('custom!'); + }); +}); \ No newline at end of file