feat(chromium): Service Worker Network Instrumentation and Inspection (#14716)

Adds Chromium support for Service Worker Networking (interception/routing, Request/Response events, and HAR).

Resolves #1090.
Depends on #14714 and #14714.
Supercedes #14321.
Follow up #14711.

Landed upstream patches:
- https://chromium-review.googlesource.com/c/chromium/src/+/3510917
- https://chromium-review.googlesource.com/c/chromium/src/+/3526571
- https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3566669
- https://chromium-review.googlesource.com/c/chromium/src/+/3544685
- https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3610924
- https://chromium-review.googlesource.com/c/chromium/src/+/3689949
This commit is contained in:
Ross Wollman 2022-07-01 12:49:43 -07:00 committed by GitHub
parent 295ea7a3cb
commit 6cb3236acd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 575 additions and 119 deletions

View file

@ -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.

View file

@ -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<channels.RequestChannel> 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<channels.RouteChannel> implements api.Ro
return Request.from(this._initializer.request);
}
private _raceWithPageClose(promise: Promise<any>): Promise<void> {
const page = this.request().frame()._page;
private _raceWithTargetClose(promise: Promise<any>): Promise<void> {
// 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<channels.RouteChannel> 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<channels.RouteChannel> 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<channels.RouteChannel> 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,

View file

@ -26,6 +26,7 @@ import type * as structs from '../../types/structs';
export class Worker extends ChannelOwner<channels.WorkerChannel> implements api.Worker {
_page: Page | undefined; // Set for web workers.
_context: BrowserContext | undefined; // Set for service workers.
_closedPromise: Promise<void>;
static from(worker: channels.WorkerChannel): Worker {
return (worker as any)._object;
@ -40,6 +41,7 @@ export class Worker extends ChannelOwner<channels.WorkerChannel> 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 {

View file

@ -3145,7 +3145,8 @@ export interface ElementHandleEvents {
// ----------- Request -----------
export type RequestInitializer = {
frame: FrameChannel,
frame?: FrameChannel,
serviceWorker?: WorkerChannel,
url: string,
resourceType: string,
method: string,

View file

@ -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:

View file

@ -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,

View file

@ -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<void> {
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<void> {
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<void> {
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() {

View file

@ -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<string, InterceptableRequest>();
private _requestIdToRequestWillBeSentEvent = new Map<string, Protocol.Network.requestWillBeSentPayload>();
@ -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 <link prefetch we are going to receive empty body with non-emtpy content-length expectation. Reach out for the actual content.
const resource = await this._client.send('Network.loadNetworkResource', { url: request.request.url(), frameId: request.request.frame()._id, options: { disableCache: false, includeCredentials: true } });
const resource = await this._client.send('Network.loadNetworkResource', { url: request.request.url(), frameId: this._serviceWorker ? undefined : request.request.frame()!._id, options: { disableCache: false, includeCredentials: true } });
const chunks: Buffer[] = [];
while (resource.resource.stream) {
const chunk = await this._client.send('IO.read', { handle: resource.resource.stream });
@ -341,8 +354,8 @@ export class CRNetworkManager {
this._requestIdToRequest.delete(request._requestId);
if (request._interceptionId)
this._attemptedAuthentications.delete(request._interceptionId);
this._page._frameManager.requestReceivedResponse(response);
this._page._frameManager.reportRequestFinished(request.request, response);
(this._page?._frameManager || this._serviceWorker)!.requestReceivedResponse(response);
(this._page?._frameManager || this._serviceWorker)!.reportRequestFinished(request.request, response);
}
_onResponseReceivedExtraInfo(event: Protocol.Network.responseReceivedExtraInfoPayload) {
@ -355,7 +368,7 @@ export class CRNetworkManager {
if (!request)
return;
const response = this._createResponse(request, event.response, event.hasExtraInfo);
this._page._frameManager.requestReceivedResponse(response);
(this._page?._frameManager || this._serviceWorker)!.requestReceivedResponse(response);
}
_onLoadingFinished(event: Protocol.Network.loadingFinishedPayload) {
@ -380,7 +393,7 @@ export class CRNetworkManager {
this._requestIdToRequest.delete(request._requestId);
if (request._interceptionId)
this._attemptedAuthentications.delete(request._interceptionId);
this._page._frameManager.reportRequestFinished(request.request, response);
(this._page?._frameManager || this._serviceWorker)!.reportRequestFinished(request.request, response);
}
_onLoadingFailed(workerFrame: frames.Frame | undefined, event: Protocol.Network.loadingFailedPayload) {
@ -416,7 +429,7 @@ export class CRNetworkManager {
if (request._interceptionId)
this._attemptedAuthentications.delete(request._interceptionId);
request.request._setFailureText(event.errorText);
this._page._frameManager.requestFailed(request.request, !!event.canceled);
(this._page?._frameManager || this._serviceWorker)!.requestFailed(request.request, !!event.canceled);
}
private _maybeAdoptMainRequest(requestId: Protocol.Network.RequestId): InterceptableRequest | undefined {
@ -448,14 +461,16 @@ class InterceptableRequest {
private _redirectedFrom: InterceptableRequest | null;
constructor(options: {
frame: frames.Frame;
context: contexts.BrowserContext;
frame: frames.Frame | null;
serviceWorker: CRServiceWorker | null;
documentId?: string;
route: RouteImpl | null;
requestWillBeSentEvent: Protocol.Network.requestWillBeSentPayload;
requestPausedEvent: Protocol.Fetch.requestPausedPayload | null;
redirectedFrom: InterceptableRequest | null;
}) {
const { frame, documentId, route, requestWillBeSentEvent, requestPausedEvent, redirectedFrom } = options;
const { context, frame, documentId, route, requestWillBeSentEvent, requestPausedEvent, redirectedFrom, serviceWorker } = options;
this._timestamp = requestWillBeSentEvent.timestamp;
this._wallTime = requestWillBeSentEvent.wallTime;
this._requestId = requestWillBeSentEvent.requestId;
@ -475,7 +490,7 @@ class InterceptableRequest {
if (postDataEntries && postDataEntries.length && postDataEntries[0].bytes)
postDataBuffer = Buffer.from(postDataEntries[0].bytes, 'base64');
this.request = new network.Request(frame, redirectedFrom?.request || null, documentId, url, type, method, postDataBuffer, headersObjectToArray(headers));
this.request = new network.Request(context, frame, serviceWorker, redirectedFrom?.request || null, documentId, url, type, method, postDataBuffer, headersObjectToArray(headers));
}
_routeForRedirectChain(): RouteImpl | null {

View file

@ -406,7 +406,7 @@ class FrameSession {
this._crPage = crPage;
this._page = crPage._page;
this._targetId = targetId;
this._networkManager = new CRNetworkManager(client, this._page, parentSession ? parentSession._networkManager : null);
this._networkManager = new CRNetworkManager(client, this._page, null, parentSession ? parentSession._networkManager : null);
this._parentSession = parentSession;
if (parentSession)
parentSession._childSessions.add(this);
@ -1077,7 +1077,7 @@ class FrameSession {
}
async _updateRequestInterception(): Promise<void> {
await this._networkManager.setRequestInterception(this._page._needsRequestInterception());
await this._networkManager.setRequestInterception(this._page.needsRequestInterception());
}
async _updateFileChooserInterception(initial: boolean) {

View file

@ -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<void> {
if (!this._isNetworkInspectionEnabled())
return;
const offline = !!this._browserContext._options.offline;
if (!initial || offline)
await this._networkManager.setOffline(offline);
}
async updateHttpCredentials(initial: boolean): Promise<void> {
if (!this._isNetworkInspectionEnabled())
return;
const credentials = this._browserContext._options.httpCredentials || null;
if (!initial || credentials)
await this._networkManager.authenticate(credentials);
}
async updateExtraHTTPHeaders(initial: boolean): Promise<void> {
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<void> {
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';
}
}

View file

@ -80,24 +80,24 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
context.on(BrowserContext.Events.Request, (request: Request) => {
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()),
}));
}

View file

@ -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<Request, channels.RequestChannel> implements channels.RequestChannel {
@ -39,7 +40,8 @@ export class RequestDispatcher extends Dispatcher<Request, channels.RequestChann
private constructor(scope: DispatcherScope, request: Request) {
const postData = request.postDataBuffer();
super(scope, request, 'Request', {
frame: FrameDispatcher.from(scope, request.frame()),
frame: FrameDispatcher.fromNullable(scope, request.frame()),
serviceWorker: WorkerDispatcher.fromNullable(scope, request.serviceWorker()),
url: request.url(),
resourceType: request.resourceType(),
method: request.method(),

View file

@ -296,6 +296,13 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel> imple
export class WorkerDispatcher extends Dispatcher<Worker, channels.WorkerChannel> implements channels.WorkerChannel {
static fromNullable(scope: DispatcherScope, worker: Worker | null): WorkerDispatcher | undefined {
if (!worker)
return undefined;
const result = existingDispatcher<WorkerDispatcher>(worker);
return result || new WorkerDispatcher(scope, worker);
}
_type_Worker = true;
constructor(scope: DispatcherScope, worker: Worker) {
super(scope, worker, 'Worker', {

View file

@ -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);

View file

@ -383,7 +383,7 @@ export class FFPage implements PageDelegate {
}
async updateRequestInterception(): Promise<void> {
await this._networkManager.setRequestInterception(this._page._needsRequestInterception());
await this._networkManager.setRequestInterception(this._page.needsRequestInterception());
}
async updateFileChooserInterception(enabled: boolean) {

View file

@ -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);

View file

@ -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<void>) {
private _addBarrier(target: Page | Worker | null, promise: Promise<void>) {
if (!target)
return null;
if (!this._options.waitForContentOnStop)
return;
const race = Promise.race([
new Promise<void>(f => page.on('close', () => {
new Promise<void>(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));

View file

@ -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<string, string>();
readonly _frame: frames.Frame | null = null;
readonly _serviceWorker: pages.Worker | null = null;
readonly _context: contexts.BrowserContext;
private _rawRequestHeadersPromise = new ManualPromise<types.HeadersArray>();
private _frame: frames.Frame;
private _waitForResponsePromise = new ManualPromise<Response | null>();
_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<number | null>();
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();
}

View file

@ -161,8 +161,8 @@ export class Page extends SdkObject {
private _workers = new Map<string, Worker>();
readonly pdf: ((options: channels.PagePdfParams) => Promise<Buffer>) | 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) => {

View file

@ -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));
}

View file

@ -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<void> {
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);

View file

@ -14999,6 +14999,13 @@ export interface Request {
*/
response(): Promise<null|Response>;
/**
* > 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.
*/

View file

@ -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')

View file

@ -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<BrowserType['launchPersistentContext']>[1]) => Promise<{ context: BrowserContext, page: Page }>;
startRemoteServer: (options?: RemoteServerOptions) => Promise<RemoteServer>;
contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>;
pageWithHar(options?: { outputPath?: string, content?: 'embed' | 'attach' | 'omit', omitContent?: boolean }): Promise<{ context: BrowserContext, page: Page, getLog: () => Promise<Log>, getZip: () => Promise<Map<string, Buffer>> }>
};
const test = baseTest.extend<BrowserTestTestFixtures, BrowserTestWorkerFixtures>({
@ -110,6 +113,26 @@ const test = baseTest.extend<BrowserTestTestFixtures, BrowserTestWorkerFixtures>
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;

View file

@ -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([