diff --git a/docs/src/api/class-request.md b/docs/src/api/class-request.md index 5afca6492b..7a17689b8d 100644 --- a/docs/src/api/class-request.md +++ b/docs/src/api/class-request.md @@ -209,6 +209,15 @@ following: `document`, `stylesheet`, `image`, `media`, `font`, `script`, `texttr Returns the matching [Response] object, or `null` if the response was not received due to error. +## method: Request.serviceWorker +- returns: <[null]|[Worker]> + +:::note +This field is Chromium only. It's safe to call when using other browsers, but it will always be `null`. +::: + +The Service [Worker] that is performing the request. + ## async method: Request.sizes - returns: <[Object]> - `requestBodySize` <[int]> Size of the request body (POST data payload) in bytes. Set to 0 if there was no body. diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 5c97d47cea..54f0eab730 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -18,10 +18,11 @@ import { URLSearchParams } from 'url'; import type * as channels from '../protocol/channels'; import { ChannelOwner } from './channelOwner'; import { Frame } from './frame'; +import { Worker } from './worker'; import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from './types'; import fs from 'fs'; import { mime } from '../utilsBundle'; -import { isString, headersObjectToArray } from '../utils'; +import { assert, isString, headersObjectToArray } from '../utils'; import { ManualPromise } from '../utils/manualPromise'; import { Events } from './events'; import type { Page } from './page'; @@ -197,9 +198,17 @@ export class Request extends ChannelOwner implements ap } frame(): Frame { + if (!this._initializer.frame) { + assert(this.serviceWorker()); + throw new Error('Service Worker requests do not have an associated frame.'); + } return Frame.from(this._initializer.frame); } + serviceWorker(): Worker | null { + return this._initializer.serviceWorker ? Worker.from(this._initializer.serviceWorker) : null; + } + isNavigationRequest(): boolean { return this._initializer.isNavigationRequest; } @@ -259,14 +268,13 @@ export class Route extends ChannelOwner implements api.Ro return Request.from(this._initializer.request); } - private _raceWithPageClose(promise: Promise): Promise { - const page = this.request().frame()._page; + private _raceWithTargetClose(promise: Promise): Promise { // When page closes or crashes, we catch any potential rejects from this Route. // Note that page could be missing when routing popup's initial request that // does not have a Page initialized just yet. return Promise.race([ promise, - page ? page._closedOrCrashedPromise : Promise.resolve(), + this.request().serviceWorker()?._closedPromise || this.request().frame()._page?._closedOrCrashedPromise || Promise.resolve(), ]); } @@ -283,13 +291,13 @@ export class Route extends ChannelOwner implements api.Ro async abort(errorCode?: string) { this._checkNotHandled(); - await this._raceWithPageClose(this._channel.abort({ errorCode })); + await this._raceWithTargetClose(this._channel.abort({ errorCode })); this._reportHandled(true); } async _redirectNavigationRequest(url: string) { this._checkNotHandled(); - await this._raceWithPageClose(this._channel.redirectNavigationRequest({ url })); + await this._raceWithTargetClose(this._channel.redirectNavigationRequest({ url })); this._reportHandled(true); } @@ -342,7 +350,7 @@ export class Route extends ChannelOwner implements api.Ro if (length && !('content-length' in headers)) headers['content-length'] = String(length); - await this._raceWithPageClose(this._channel.fulfill({ + await this._raceWithTargetClose(this._channel.fulfill({ status: statusOption || 200, headers: headersObjectToArray(headers), body, @@ -373,7 +381,7 @@ export class Route extends ChannelOwner implements api.Ro const options = this.request()._fallbackOverridesForContinue(); return await this._wrapApiCall(async () => { const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData; - await this._raceWithPageClose(this._channel.continue({ + await this._raceWithTargetClose(this._channel.continue({ url: options.url, method: options.method, headers: options.headers ? headersObjectToArray(options.headers) : undefined, diff --git a/packages/playwright-core/src/client/worker.ts b/packages/playwright-core/src/client/worker.ts index ed6c16e881..d6d1211ca8 100644 --- a/packages/playwright-core/src/client/worker.ts +++ b/packages/playwright-core/src/client/worker.ts @@ -26,6 +26,7 @@ import type * as structs from '../../types/structs'; export class Worker extends ChannelOwner implements api.Worker { _page: Page | undefined; // Set for web workers. _context: BrowserContext | undefined; // Set for service workers. + _closedPromise: Promise; static from(worker: channels.WorkerChannel): Worker { return (worker as any)._object; @@ -40,6 +41,7 @@ export class Worker extends ChannelOwner implements api. this._context._serviceWorkers.delete(this); this.emit(Events.Worker.Close, this); }); + this._closedPromise = new Promise(f => this.once(Events.Worker.Close, f)); } url(): string { diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index 14ac2e710e..ba439e1309 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -3145,7 +3145,8 @@ export interface ElementHandleEvents { // ----------- Request ----------- export type RequestInitializer = { - frame: FrameChannel, + frame?: FrameChannel, + serviceWorker?: WorkerChannel, url: string, resourceType: string, method: string, diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index 77cc6bb87a..ea9ac91ff4 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -2478,7 +2478,8 @@ Request: type: interface initializer: - frame: Frame + frame: Frame? + serviceWorker: Worker? url: string resourceType: string method: string @@ -2538,7 +2539,6 @@ Route: isBase64: boolean? fetchResponseUid: string? - ResourceTiming: type: object properties: diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 4ae6a8e805..780b3ce812 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1766,7 +1766,8 @@ scheme.ElementHandleWaitForSelectorResult = tObject({ element: tOptional(tChannel(['ElementHandle'])), }); scheme.RequestInitializer = tObject({ - frame: tChannel(['Frame']), + frame: tOptional(tChannel(['Frame'])), + serviceWorker: tOptional(tChannel(['Worker'])), url: tString, resourceType: tString, method: tString, diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 0dba9f9cbf..0ab2aca0ec 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -20,8 +20,8 @@ import { Browser } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, verifyGeolocation } from '../browserContext'; import { assert } from '../../utils'; import * as network from '../network'; -import type { PageBinding, PageDelegate } from '../page'; -import { Page, Worker } from '../page'; +import type { PageBinding, PageDelegate , Worker } from '../page'; +import { Page } from '../page'; import { Frame } from '../frames'; import type { Dialog } from '../dialog'; import type { ConnectionTransport } from '../transport'; @@ -32,8 +32,8 @@ import { ConnectionEvents, CRConnection } from './crConnection'; import { CRPage } from './crPage'; import { readProtocolStream } from './crProtocolHelper'; import type { Protocol } from './protocol'; -import { CRExecutionContext } from './crExecutionContext'; import type { CRDevTools } from './crDevTools'; +import { CRServiceWorker } from './crServiceWorker'; export class CRBrowser extends Browser { readonly _connection: CRConnection; @@ -307,21 +307,6 @@ export class CRBrowser extends Browser { } } -class CRServiceWorker extends Worker { - readonly _browserContext: CRBrowserContext; - - constructor(browserContext: CRBrowserContext, session: CRSession, url: string) { - super(browserContext, url); - this._browserContext = browserContext; - session.once('Runtime.executionContextCreated', event => { - this._createExecutionContext(new CRExecutionContext(session, event.context)); - }); - // This might fail if the target is closed before we receive all execution contexts. - session.send('Runtime.enable', {}).catch(e => {}); - session.send('Runtime.runIfWaitingForDebugger').catch(e => {}); - } -} - export class CRBrowserContext extends BrowserContext { static CREvents = { BackgroundPage: 'backgroundpage', @@ -451,18 +436,24 @@ export class CRBrowserContext extends BrowserContext { this._options.extraHTTPHeaders = headers; for (const page of this.pages()) await (page._delegate as CRPage).updateExtraHTTPHeaders(); + for (const sw of this.serviceWorkers()) + await (sw as CRServiceWorker).updateExtraHTTPHeaders(false); } async setOffline(offline: boolean): Promise { this._options.offline = offline; for (const page of this.pages()) await (page._delegate as CRPage).updateOffline(); + for (const sw of this.serviceWorkers()) + await (sw as CRServiceWorker).updateOffline(false); } async doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise { this._options.httpCredentials = httpCredentials; for (const page of this.pages()) await (page._delegate as CRPage).updateHttpCredentials(); + for (const sw of this.serviceWorkers()) + await (sw as CRServiceWorker).updateHttpCredentials(false); } async doAddInitScript(source: string) { @@ -488,6 +479,8 @@ export class CRBrowserContext extends BrowserContext { async doUpdateRequestInterception(): Promise { for (const page of this.pages()) await (page._delegate as CRPage).updateRequestInterception(); + for (const sw of this.serviceWorkers()) + await (sw as CRServiceWorker).updateRequestInterception(); } async doClose() { diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index 540d6e649f..ab46ec66f5 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -22,14 +22,17 @@ import type { RegisteredListener } from '../../utils/eventsHelper'; import { eventsHelper } from '../../utils/eventsHelper'; import type { Protocol } from './protocol'; import * as network from '../network'; +import type * as contexts from '../browserContext'; import type * as frames from '../frames'; import type * as types from '../types'; import type { CRPage } from './crPage'; import { assert, headersObjectToArray } from '../../utils'; +import type { CRServiceWorker } from './crServiceWorker'; export class CRNetworkManager { private _client: CRSession; - private _page: Page; + private _page: Page | null; + private _serviceWorker: CRServiceWorker | null; private _parentManager: CRNetworkManager | null; private _requestIdToRequest = new Map(); private _requestIdToRequestWillBeSentEvent = new Map(); @@ -41,15 +44,16 @@ export class CRNetworkManager { private _eventListeners: RegisteredListener[]; private _responseExtraInfoTracker = new ResponseExtraInfoTracker(); - constructor(client: CRSession, page: Page, parentManager: CRNetworkManager | null) { + constructor(client: CRSession, page: Page | null, serviceWorker: CRServiceWorker | null, parentManager: CRNetworkManager | null) { this._client = client; this._page = page; + this._serviceWorker = serviceWorker; this._parentManager = parentManager; this._eventListeners = this.instrumentNetworkEvents(client); } instrumentNetworkEvents(session: CRSession, workerFrame?: frames.Frame): RegisteredListener[] { - return [ + const listeners = [ eventsHelper.addEventListener(session, 'Fetch.requestPaused', this._onRequestPaused.bind(this, workerFrame)), eventsHelper.addEventListener(session, 'Fetch.authRequired', this._onAuthRequired.bind(this)), eventsHelper.addEventListener(session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this, workerFrame)), @@ -59,14 +63,19 @@ export class CRNetworkManager { eventsHelper.addEventListener(session, 'Network.responseReceivedExtraInfo', this._onResponseReceivedExtraInfo.bind(this)), eventsHelper.addEventListener(session, 'Network.loadingFinished', this._onLoadingFinished.bind(this)), eventsHelper.addEventListener(session, 'Network.loadingFailed', this._onLoadingFailed.bind(this, workerFrame)), - eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page._frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page._frameManager.onWebSocketRequest(e.requestId)), - eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page._frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page._frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page._frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page._frameManager.webSocketClosed(e.requestId)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page._frameManager.webSocketError(e.requestId, e.errorMessage)), ]; + if (this._page) { + listeners.push(...[ + eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page!._frameManager.onWebSocketCreated(e.requestId, e.url)), + eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!._frameManager.onWebSocketRequest(e.requestId)), + eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!._frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!._frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!._frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page!._frameManager.webSocketClosed(e.requestId)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page!._frameManager.webSocketError(e.requestId, e.errorMessage)), + ]); + } + return listeners; } async initialize() { @@ -193,15 +202,15 @@ export class CRNetworkManager { redirectedFrom = request; } } - let frame = requestWillBeSentEvent.frameId ? this._page._frameManager.frame(requestWillBeSentEvent.frameId) : workerFrame; + let frame = requestWillBeSentEvent.frameId ? this._page?._frameManager.frame(requestWillBeSentEvent.frameId) : workerFrame; // Requests from workers lack frameId, because we receive Network.requestWillBeSent // on the worker target. However, we receive Fetch.requestPaused on the page target, // and lack workerFrame there. Luckily, Fetch.requestPaused provides a frameId. - if (!frame && requestPausedEvent && requestPausedEvent.frameId) + if (!frame && this._page && requestPausedEvent && requestPausedEvent.frameId) frame = this._page._frameManager.frame(requestPausedEvent.frameId); // Check if it's main resource request interception (targetId === main frame id). - if (!frame && requestWillBeSentEvent.frameId === (this._page._delegate as CRPage)._targetId) { + if (!frame && this._page && requestWillBeSentEvent.frameId === (this._page?._delegate as CRPage)._targetId) { // Main resource request for the page is being intercepted so the Frame is not created // yet. Precreate it here for the purposes of request interception. It will be updated // later as soon as the request continues and we receive frame tree from the page. @@ -213,7 +222,7 @@ export class CRNetworkManager { // // Note: it would be better to match the URL against interception patterns, but // that information is only available to the client. Perhaps we can just route to the client? - if (requestPausedEvent && requestPausedEvent.request.method === 'OPTIONS' && this._page._needsRequestInterception()) { + if (requestPausedEvent && requestPausedEvent.request.method === 'OPTIONS' && (this._page || this._serviceWorker)!.needsRequestInterception()) { const requestHeaders = requestPausedEvent.request.headers; const responseHeaders: Protocol.Fetch.HeaderEntry[] = [ { name: 'Access-Control-Allow-Origin', value: requestHeaders['Origin'] || '*' }, @@ -232,7 +241,8 @@ export class CRNetworkManager { return; } - if (!frame) { + // Non-service-worker requests MUST have a frame—if they don't, we pretend there was no request + if (!frame && !this._serviceWorker) { if (requestPausedEvent) this._client._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId }); return; @@ -249,7 +259,9 @@ export class CRNetworkManager { const isNavigationRequest = requestWillBeSentEvent.requestId === requestWillBeSentEvent.loaderId && requestWillBeSentEvent.type === 'Document'; const documentId = isNavigationRequest ? requestWillBeSentEvent.loaderId : undefined; const request = new InterceptableRequest({ - frame, + context: (this._page || this._serviceWorker)!._browserContext, + frame: frame || null, + serviceWorker: this._serviceWorker || null, documentId, route, requestWillBeSentEvent, @@ -257,13 +269,14 @@ export class CRNetworkManager { redirectedFrom }); this._requestIdToRequest.set(requestWillBeSentEvent.requestId, request); + if (requestPausedEvent && !requestPausedEvent.responseStatusCode && !requestPausedEvent.responseErrorReason) { // We will not receive extra info when intercepting the request. // Use the headers from the Fetch.requestPausedPayload and release the allHeaders() // right away, so that client can call it from the route handler. request.request.setRawRequestHeaders(headersObjectToArray(requestPausedEvent.request.headers, '\n')); } - this._page._frameManager.requestStarted(request.request, route || undefined); + (this._page?._frameManager || this._serviceWorker)!.requestStarted(request.request, route || undefined); } _createResponse(request: InterceptableRequest, responsePayload: Protocol.Network.Response, hasExtraInfo: boolean): network.Response { @@ -276,7 +289,7 @@ export class CRNetworkManager { return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); // For { - await this._networkManager.setRequestInterception(this._page._needsRequestInterception()); + await this._networkManager.setRequestInterception(this._page.needsRequestInterception()); } async _updateFileChooserInterception(initial: boolean) { diff --git a/packages/playwright-core/src/server/chromium/crServiceWorker.ts b/packages/playwright-core/src/server/chromium/crServiceWorker.ts new file mode 100644 index 0000000000..0c79459e71 --- /dev/null +++ b/packages/playwright-core/src/server/chromium/crServiceWorker.ts @@ -0,0 +1,121 @@ +/** + * 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 { Worker } from '../page'; +import type { CRBrowserContext } from './crBrowser'; +import type { CRSession } from './crConnection'; +import type * as types from '../types'; +import { CRExecutionContext } from './crExecutionContext'; +import { CRNetworkManager } from './crNetworkManager'; +import * as network from '../network'; +import { BrowserContext } from '../browserContext'; +import { headersArrayToObject } from '../../utils'; + +export class CRServiceWorker extends Worker { + readonly _browserContext: CRBrowserContext; + readonly _networkManager: CRNetworkManager; + private _session: CRSession; + private _extraHTTPHeaders: types.HeadersArray | null = null; + + constructor(browserContext: CRBrowserContext, session: CRSession, url: string) { + super(browserContext, url); + this._session = session; + this._browserContext = browserContext; + this._networkManager = new CRNetworkManager(session, null, this, null); + session.once('Runtime.executionContextCreated', event => { + this._createExecutionContext(new CRExecutionContext(session, event.context)); + }); + + if (this._isNetworkInspectionEnabled()) { + this._networkManager.initialize().catch(() => {}); + this.updateRequestInterception(); + this.updateExtraHTTPHeaders(true); + this.updateHttpCredentials(true); + this.updateOffline(true); + } + + session.send('Runtime.enable', {}).catch(e => { }); + session.send('Runtime.runIfWaitingForDebugger').catch(e => { }); + } + + async updateOffline(initial: boolean): Promise { + if (!this._isNetworkInspectionEnabled()) + return; + + const offline = !!this._browserContext._options.offline; + if (!initial || offline) + await this._networkManager.setOffline(offline); + } + + async updateHttpCredentials(initial: boolean): Promise { + if (!this._isNetworkInspectionEnabled()) + return; + + const credentials = this._browserContext._options.httpCredentials || null; + if (!initial || credentials) + await this._networkManager.authenticate(credentials); + } + + async updateExtraHTTPHeaders(initial: boolean): Promise { + if (!this._isNetworkInspectionEnabled()) + return; + + const headers = network.mergeHeaders([ + this._browserContext._options.extraHTTPHeaders, + this._extraHTTPHeaders, + ]); + if (!initial || headers.length) + await this._session.send('Network.setExtraHTTPHeaders', { headers: headersArrayToObject(headers, false /* lowerCase */) }); + } + + updateRequestInterception(): Promise { + if (!this._isNetworkInspectionEnabled()) + return Promise.resolve(); + + return this._networkManager.setRequestInterception(this.needsRequestInterception()).catch(e => { }); + } + + needsRequestInterception(): boolean { + return this._isNetworkInspectionEnabled() && !!this._browserContext._requestInterceptor; + } + + reportRequestFinished(request: network.Request, response: network.Response | null) { + this._browserContext.emit(BrowserContext.Events.RequestFinished, { request, response }); + } + + requestFailed(request: network.Request, _canceled: boolean) { + this._browserContext.emit(BrowserContext.Events.RequestFailed, request); + } + + requestReceivedResponse(response: network.Response) { + this._browserContext.emit(BrowserContext.Events.Response, response); + } + + requestStarted(request: network.Request, route?: network.RouteDelegate) { + this._browserContext.emit(BrowserContext.Events.Request, request); + if (route) { + const r = new network.Route(request, route); + if (this._browserContext._requestInterceptor) { + this._browserContext._requestInterceptor(r, request); + return; + } + r.continue(); + } + } + + private _isNetworkInspectionEnabled(): boolean { + return this._browserContext._options.serviceWorkers === 'allow'; + } +} diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 6a50fb67ac..501c4e7947 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -80,24 +80,24 @@ export class BrowserContextDispatcher extends Dispatcher { return this._dispatchEvent('request', { request: RequestDispatcher.from(this._scope, request), - page: PageDispatcher.fromNullable(this._scope, request.frame()._page.initializedOrUndefined()) + page: PageDispatcher.fromNullable(this._scope, request.frame()?._page.initializedOrUndefined()) }); }); context.on(BrowserContext.Events.Response, (response: Response) => this._dispatchEvent('response', { response: ResponseDispatcher.from(this._scope, response), - page: PageDispatcher.fromNullable(this._scope, response.frame()._page.initializedOrUndefined()) + page: PageDispatcher.fromNullable(this._scope, response.frame()?._page.initializedOrUndefined()) })); context.on(BrowserContext.Events.RequestFailed, (request: Request) => this._dispatchEvent('requestFailed', { request: RequestDispatcher.from(this._scope, request), failureText: request._failureText || undefined, responseEndTiming: request._responseEndTiming, - page: PageDispatcher.fromNullable(this._scope, request.frame()._page.initializedOrUndefined()) + page: PageDispatcher.fromNullable(this._scope, request.frame()?._page.initializedOrUndefined()) })); context.on(BrowserContext.Events.RequestFinished, ({ request, response }: { request: Request, response: Response | null }) => this._dispatchEvent('requestFinished', { request: RequestDispatcher.from(scope, request), response: ResponseDispatcher.fromNullable(scope, response), responseEndTiming: request._responseEndTiming, - page: PageDispatcher.fromNullable(this._scope, request.frame()._page.initializedOrUndefined()), + page: PageDispatcher.fromNullable(this._scope, request.frame()?._page.initializedOrUndefined()), })); } diff --git a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts index 715a7bfe68..aafba3e0e9 100644 --- a/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts +++ b/packages/playwright-core/src/server/dispatchers/networkDispatchers.ts @@ -22,6 +22,7 @@ import { WebSocket } from '../network'; import type { DispatcherScope } from './dispatcher'; import { Dispatcher, existingDispatcher, lookupNullableDispatcher } from './dispatcher'; import { FrameDispatcher } from './frameDispatcher'; +import { WorkerDispatcher } from './pageDispatcher'; import { TracingDispatcher } from './tracingDispatcher'; export class RequestDispatcher extends Dispatcher implements channels.RequestChannel { @@ -39,7 +40,8 @@ export class RequestDispatcher extends Dispatcher imple export class WorkerDispatcher extends Dispatcher implements channels.WorkerChannel { + static fromNullable(scope: DispatcherScope, worker: Worker | null): WorkerDispatcher | undefined { + if (!worker) + return undefined; + const result = existingDispatcher(worker); + return result || new WorkerDispatcher(scope, worker); + } + _type_Worker = true; constructor(scope: DispatcherScope, worker: Worker) { super(scope, worker, 'Worker', { diff --git a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts index 2f4bf7be6b..2fdc8850b5 100644 --- a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts +++ b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts @@ -200,7 +200,7 @@ class InterceptableRequest { let postDataBuffer = null; if (payload.postData) postDataBuffer = Buffer.from(payload.postData, 'base64'); - this.request = new network.Request(frame, redirectedFrom ? redirectedFrom.request : null, payload.navigationId, + this.request = new network.Request(frame._page._browserContext, frame, null, redirectedFrom ? redirectedFrom.request : null, payload.navigationId, payload.url, internalCauseToResourceType[payload.internalCause] || causeToResourceType[payload.cause] || 'other', payload.method, postDataBuffer, payload.headers); // "raw" headers are the same as "provisional" headers in Firefox. this.request.setRawRequestHeaders(null); diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index d09ea17f4f..5c2a4f6ffa 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -383,7 +383,7 @@ export class FFPage implements PageDelegate { } async updateRequestInterception(): Promise { - await this._networkManager.setRequestInterception(this._page._needsRequestInterception()); + await this._networkManager.setRequestInterception(this._page.needsRequestInterception()); } async updateFileChooserInterception(enabled: boolean) { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index e321053d0e..a160cf973f 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -293,7 +293,7 @@ export class FrameManager { } requestStarted(request: network.Request, route?: network.RouteDelegate) { - const frame = request.frame(); + const frame = request.frame()!; this._inflightRequestStarted(request); if (request._documentId) frame.setPendingDocument({ documentId: request._documentId, request }); @@ -303,8 +303,22 @@ export class FrameManager { return; } this._page.emitOnContext(BrowserContext.Events.Request, request); - if (route) - this._page._requestStarted(request, route); + if (route) { + const r = new network.Route(request, route); + if (this._page._serverRequestInterceptor) { + this._page._serverRequestInterceptor(r, request); + return; + } + if (this._page._clientRequestInterceptor) { + this._page._clientRequestInterceptor(r, request); + return; + } + if (this._page._browserContext._requestInterceptor) { + this._page._browserContext._requestInterceptor(r, request); + return; + } + r.continue(); + } } requestReceivedResponse(response: network.Response) { @@ -321,7 +335,7 @@ export class FrameManager { } requestFailed(request: network.Request, canceled: boolean) { - const frame = request.frame(); + const frame = request.frame()!; this._inflightRequestFinished(request); if (frame.pendingDocument() && frame.pendingDocument()!.request === request) { let errorText = request.failure()!.errorText; @@ -359,7 +373,7 @@ export class FrameManager { } private _inflightRequestFinished(request: network.Request) { - const frame = request.frame(); + const frame = request.frame()!; if (request._isFavicon) return; if (!frame._inflightRequests.has(request)) @@ -370,7 +384,7 @@ export class FrameManager { } private _inflightRequestStarted(request: network.Request) { - const frame = request.frame(); + const frame = request.frame()!; if (request._isFavicon) return; frame._inflightRequests.add(request); diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 5b06804546..ab196e1197 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -19,6 +19,7 @@ import type { APIRequestEvent, APIRequestFinishedEvent } from '../fetch'; import { APIRequestContext } from '../fetch'; import { helper } from '../helper'; import * as network from '../network'; +import type { Worker } from '../page'; import type { Page } from '../page'; import type * as har from './har'; import { assert, calculateSha1, monotonicTime } from '../../utils'; @@ -110,7 +111,9 @@ export class HarTracer { return (request as any)[this._entrySymbol]; } - private _createPageEntryIfNeeded(page: Page): har.Page | undefined { + private _createPageEntryIfNeeded(page?: Page): har.Page | undefined { + if (!page) + return; if (this._options.omitPages) return; if (this._page && page !== this._page) @@ -167,11 +170,13 @@ export class HarTracer { this._addBarrier(page, promise); } - private _addBarrier(page: Page, promise: Promise) { + private _addBarrier(target: Page | Worker | null, promise: Promise) { + if (!target) + return null; if (!this._options.waitForContentOnStop) return; const race = Promise.race([ - new Promise(f => page.on('close', () => { + new Promise(f => target.on('close', () => { this._barrierPromises.delete(race); f(); })), @@ -231,7 +236,7 @@ export class HarTracer { private _onRequest(request: network.Request) { if (!this._shouldIncludeEntryWithUrl(request.url())) return; - const page = request.frame()._page; + const page = request.frame()?._page; if (this._page && page !== this._page) return; const url = network.parsedURL(request.url()); @@ -239,7 +244,7 @@ export class HarTracer { return; const pageEntry = this._createPageEntryIfNeeded(page); - const harEntry = createHarEntry(request.method(), url, request.frame().guid, this._options); + const harEntry = createHarEntry(request.method(), url, request.frame()?.guid, this._options); if (pageEntry) harEntry.pageref = pageEntry.id; harEntry.request.postData = this._postDataForRequest(request, this._options.content); @@ -261,7 +266,7 @@ export class HarTracer { const harEntry = this._entryForRequest(request); if (!harEntry) return; - const page = request.frame()._page; + const page = request.frame()?._page; const httpVersion = response.httpVersion(); harEntry.request.httpVersion = httpVersion; @@ -287,7 +292,7 @@ export class HarTracer { } }; if (compressionCalculationBarrier) - this._addBarrier(page, compressionCalculationBarrier.barrier); + this._addBarrier(page || request.serviceWorker(), compressionCalculationBarrier.barrier); const promise = response.body().then(buffer => { if (this._options.skipScripts && request.resourceType() === 'script') { @@ -304,10 +309,10 @@ export class HarTracer { if (this._started) this._delegate.onEntryFinished(harEntry); }); - this._addBarrier(page, promise); + this._addBarrier(page || request.serviceWorker(), promise); if (!this._options.omitSizes) { - this._addBarrier(page, response.sizes().then(sizes => { + this._addBarrier(page || request.serviceWorker(), response.sizes().then(sizes => { harEntry.response.bodySize = sizes.responseBodySize; harEntry.response.headersSize = sizes.responseHeadersSize; harEntry.response._transferSize = sizes.transferSize; @@ -361,7 +366,7 @@ export class HarTracer { const harEntry = this._entryForRequest(response.request()); if (!harEntry) return; - const page = response.frame()._page; + const page = response.frame()?._page; const pageEntry = this._createPageEntryIfNeeded(page); const request = response.request(); @@ -404,7 +409,7 @@ export class HarTracer { } if (!this._options.omitServerIP) { - this._addBarrier(page, response.serverAddr().then(server => { + this._addBarrier(page || request.serviceWorker(), response.serverAddr().then(server => { if (server?.ipAddress) harEntry.serverIPAddress = server.ipAddress; if (server?.port) @@ -412,19 +417,19 @@ export class HarTracer { })); } if (!this._options.omitSecurityDetails) { - this._addBarrier(page, response.securityDetails().then(details => { + this._addBarrier(page || request.serviceWorker(), response.securityDetails().then(details => { if (details) harEntry._securityDetails = details; })); } - this._addBarrier(page, request.rawRequestHeaders().then(headers => { + this._addBarrier(page || request.serviceWorker(), request.rawRequestHeaders().then(headers => { if (!this._options.omitCookies) { for (const header of headers.filter(header => header.name.toLowerCase() === 'cookie')) harEntry.request.cookies.push(...header.value.split(';').map(parseCookie)); } harEntry.request.headers = headers; })); - this._addBarrier(page, response.rawResponseHeaders().then(headers => { + this._addBarrier(page || request.serviceWorker(), response.rawResponseHeaders().then(headers => { if (!this._options.omitCookies) { for (const header of headers.filter(header => header.name.toLowerCase() === 'set-cookie')) harEntry.response.cookies.push(parseCookie(header.value)); diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 708933eb10..aeb487d56c 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -14,6 +14,8 @@ * 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'; import type * as channels from '../protocol/channels'; @@ -97,16 +99,20 @@ export class Request extends SdkObject { private _postData: Buffer | null; readonly _headers: types.HeadersArray; private _headersMap = new Map(); + readonly _frame: frames.Frame | null = null; + readonly _serviceWorker: pages.Worker | null = null; + readonly _context: contexts.BrowserContext; private _rawRequestHeadersPromise = new ManualPromise(); - private _frame: frames.Frame; private _waitForResponsePromise = new ManualPromise(); _responseEndTiming = -1; - constructor(frame: frames.Frame, redirectedFrom: Request | null, documentId: string | undefined, + constructor(context: contexts.BrowserContext, frame: frames.Frame | null, serviceWorker: pages.Worker | null, redirectedFrom: Request | null, documentId: string | undefined, url: string, resourceType: string, method: string, postData: Buffer | null, headers: types.HeadersArray) { - super(frame, 'request'); + super(frame || context, 'request'); assert(!url.startsWith('data:'), 'Data urls should not fire requests'); + this._context = context; this._frame = frame; + this._serviceWorker = serviceWorker; this._redirectedFrom = redirectedFrom; if (redirectedFrom) redirectedFrom._redirectedTo = this; @@ -177,10 +183,14 @@ export class Request extends SdkObject { return this._redirectedTo ? this._redirectedTo._finalRequest() : this; } - frame(): frames.Frame { + frame(): frames.Frame | null { return this._frame; } + serviceWorker(): pages.Worker | null { + return this._serviceWorker; + } + isNavigationRequest(): boolean { return !!this._documentId; } @@ -219,7 +229,7 @@ export class Route extends SdkObject { private _handled = false; constructor(request: Request, delegate: RouteDelegate) { - super(request.frame(), 'route'); + super(request._frame || request._context , 'route'); this._request = request; this._delegate = delegate; } @@ -236,7 +246,7 @@ export class Route extends SdkObject { async redirectNavigationRequest(url: string) { this._startHandling(); assert(this._request.isNavigationRequest()); - this._request.frame().redirectNavigation(url, this._request._documentId!, this._request.headerValue('referer')); + this._request.frame()!.redirectNavigation(url, this._request._documentId!, this._request.headerValue('referer')); } async fulfill(overrides: channels.RouteFulfillParams) { @@ -245,8 +255,7 @@ export class Route extends SdkObject { let isBase64 = overrides.isBase64 || false; if (body === undefined) { if (overrides.fetchResponseUid) { - const context = this._request.frame()._page._browserContext; - const buffer = context.fetchRequest.fetchResponses.get(overrides.fetchResponseUid) || APIRequestContext.findResponseBody(overrides.fetchResponseUid); + const buffer = this._request._context.fetchRequest.fetchResponses.get(overrides.fetchResponseUid) || APIRequestContext.findResponseBody(overrides.fetchResponseUid); assert(buffer, 'Fetch response has been disposed'); body = buffer.toString('base64'); isBase64 = true; @@ -357,7 +366,7 @@ export class Response extends SdkObject { private _responseHeadersSizePromise = new ManualPromise(); constructor(request: Request, status: number, statusText: string, headers: types.HeadersArray, timing: ResourceTiming, getResponseBodyCallback: GetResponseBodyCallback, fromServiceWorker: boolean, httpVersion?: string) { - super(request.frame(), 'response'); + super(request.frame() || request._context, 'response'); this._request = request; this._timing = timing; this._status = status; @@ -458,7 +467,7 @@ export class Response extends SdkObject { return this._request; } - frame(): frames.Frame { + frame(): frames.Frame | null { return this._request.frame(); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index a9e15b61af..1124c8b28e 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -161,8 +161,8 @@ export class Page extends SdkObject { private _workers = new Map(); readonly pdf: ((options: channels.PagePdfParams) => Promise) | undefined; readonly coverage: any; - private _clientRequestInterceptor: network.RouteHandler | undefined; - private _serverRequestInterceptor: network.RouteHandler | undefined; + _clientRequestInterceptor: network.RouteHandler | undefined; + _serverRequestInterceptor: network.RouteHandler | undefined; _ownedContext: BrowserContext | undefined; readonly selectors: Selectors; _pageIsError: Error | undefined; @@ -439,7 +439,7 @@ export class Page extends SdkObject { await this._delegate.removeInitScripts(); } - _needsRequestInterception(): boolean { + needsRequestInterception(): boolean { return !!this._clientRequestInterceptor || !!this._serverRequestInterceptor || !!this._browserContext._requestInterceptor; } @@ -453,23 +453,6 @@ export class Page extends SdkObject { await this._delegate.updateRequestInterception(); } - _requestStarted(request: network.Request, routeDelegate: network.RouteDelegate) { - const route = new network.Route(request, routeDelegate); - if (this._serverRequestInterceptor) { - this._serverRequestInterceptor(route, request); - return; - } - if (this._clientRequestInterceptor) { - this._clientRequestInterceptor(route, request); - return; - } - if (this._browserContext._requestInterceptor) { - this._browserContext._requestInterceptor(route, request); - return; - } - route.continue(); - } - async expectScreenshot(metadata: CallMetadata, options: ExpectScreenshotOptions = {}): Promise<{ actual?: Buffer, previous?: Buffer, diff?: Buffer, errorMessage?: string, log?: string[] }> { const locator = options.locator; const rafrafScreenshot = locator ? async (progress: Progress, timeout: number) => { diff --git a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts index c987b461b7..390d33423d 100644 --- a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts +++ b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts @@ -60,7 +60,7 @@ export class WKInterceptableRequest { this._wallTime = event.walltime * 1000; if (event.request.postData) postDataBuffer = Buffer.from(event.request.postData, 'base64'); - this.request = new network.Request(frame, redirectedFrom?.request || null, documentId, event.request.url, + this.request = new network.Request(frame._page._browserContext, frame, null, redirectedFrom?.request || null, documentId, event.request.url, resourceType, event.request.method, postDataBuffer, headersObjectToArray(event.request.headers)); } diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 2d8bc872a8..b36578bdd6 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -182,7 +182,7 @@ export class WKPage implements PageDelegate { session.send('Network.enable'), this._workers.initializeSession(session) ]; - if (this._page._needsRequestInterception()) { + if (this._page.needsRequestInterception()) { promises.push(session.send('Network.setInterceptionEnabled', { enabled: true })); promises.push(session.send('Network.addInterception', { url: '.*', stage: 'request', isRegex: true })); } @@ -708,7 +708,7 @@ export class WKPage implements PageDelegate { } async updateRequestInterception(): Promise { - const enabled = this._page._needsRequestInterception(); + const enabled = this._page.needsRequestInterception(); await Promise.all([ this._updateState('Network.setInterceptionEnabled', { enabled }), this._updateState('Network.addInterception', { url: '.*', stage: 'request', isRegex: true }), @@ -1011,7 +1011,7 @@ export class WKPage implements PageDelegate { const documentId = isNavigationRequest ? event.loaderId : undefined; let route = null; // We do not support intercepting redirects. - if (this._page._needsRequestInterception() && !redirectedFrom) + if (this._page.needsRequestInterception() && !redirectedFrom) route = new WKRouteImpl(session, event.requestId); const request = new WKInterceptableRequest(session, route, frame, event, redirectedFrom, documentId); this._requestIdToRequest.set(event.requestId, request); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 711a56d16a..2ce572bc7e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -14999,6 +14999,13 @@ export interface Request { */ response(): Promise; + /** + * > NOTE: This field is Chromium only. It's safe to call when using other browsers, but it will always be `null`. + * + * The Service [Worker] that is performing the request. + */ + serviceWorker(): null|Worker; + /** * Returns resource size information for given request. */ diff --git a/tests/assets/serviceworkers/fetch/sw.js b/tests/assets/serviceworkers/fetch/sw.js index d44c7eab94..34557dfed9 100644 --- a/tests/assets/serviceworkers/fetch/sw.js +++ b/tests/assets/serviceworkers/fetch/sw.js @@ -1,7 +1,12 @@ +self.intercepted = []; + self.addEventListener('fetch', event => { + self.intercepted.push(event.request.url) event.respondWith(fetch(event.request)); }); self.addEventListener('activate', event => { event.waitUntil(clients.claim()); }); + +fetch('/request-from-within-worker.txt') diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 9b5fb9731c..d57a33c28e 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -23,6 +23,8 @@ import { removeFolders } from '../../packages/playwright-core/lib/utils/fileUtil import { baseTest } from './baseTest'; import type { RemoteServerOptions } from './remoteServer'; import { RemoteServer } from './remoteServer'; +import type { Log } from '../../packages/playwright-core/src/server/har/har'; +import { parseHar } from '../config/utils'; export type BrowserTestWorkerFixtures = PageWorkerFixtures & { browserVersion: string; @@ -38,6 +40,7 @@ type BrowserTestTestFixtures = PageTestFixtures & { launchPersistent: (options?: Parameters[1]) => Promise<{ context: BrowserContext, page: Page }>; startRemoteServer: (options?: RemoteServerOptions) => Promise; contextFactory: (options?: BrowserContextOptions) => Promise; + pageWithHar(options?: { outputPath?: string, content?: 'embed' | 'attach' | 'omit', omitContent?: boolean }): Promise<{ context: BrowserContext, page: Page, getLog: () => Promise, getZip: () => Promise> }> }; const test = baseTest.extend({ @@ -110,6 +113,26 @@ const test = baseTest.extend await new Promise(f => setTimeout(f, 1000)); } }, + pageWithHar: async ({ contextFactory }, use, testInfo) => { + const pageWithHar = async (options: { outputPath?: string, content?: 'embed' | 'attach' | 'omit', omitContent?: boolean } = {}) => { + const harPath = testInfo.outputPath(options.outputPath || 'test.har'); + const context = await contextFactory({ recordHar: { path: harPath, content: options.content, omitContent: options.omitContent }, ignoreHTTPSErrors: true }); + const page = await context.newPage(); + return { + page, + context, + getLog: async () => { + await context.close(); + return JSON.parse(fs.readFileSync(harPath).toString())['log'] as Log; + }, + getZip: async () => { + await context.close(); + return parseHar(harPath); + }, + }; + }; + await use(pageWithHar); + } }); export const playwrightTest = test; diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index fd0900c130..c884eadbda 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -30,6 +30,257 @@ test('should create a worker from a service worker', async ({ page, server }) => expect(await worker.evaluate(() => self.toString())).toBe('[object ServiceWorkerGlobalScope]'); }); +test('should create a worker from service worker with noop routing', async ({ context, page, server }) => { + await context.route('**', route => route.continue()); + const [worker] = await Promise.all([ + page.context().waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/serviceworkers/empty/sw.html') + ]); + 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') + ]); + const [inner] = await Promise.all([ + context.waitForEvent('request', r => r.url().endsWith('/inner.txt')), + page.evaluate(() => fetch('/inner.txt')), + ]); + 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(inner.frame()).toBeTruthy(); + expect(inner.serviceWorker()).toBe(null); + expect((await inner.response()).fromServiceWorker()).toBe(true); + + 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'), + 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'), + 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'), + 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('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('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' } }); + + test('httpCredentials', async ({ context, page, server }) => { + server.setAuth('/serviceworkers/fetch/sw.html', 'user', 'pass'); + server.setAuth('/empty.html', 'user', 'pass'); + const [worker] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html') + ]); + + await page.evaluate(() => window['activationPromise']); + expect(await worker.evaluate(() => fetch('/empty.html').then(r => r.status))).toBe(200); + }); +}); + test('serviceWorkers() should return current workers', async ({ page, server }) => { const context = page.context(); const [worker1] = await Promise.all([