diff --git a/docs/api.md b/docs/api.md index 6c328dbf3c..459ecc4639 100644 --- a/docs/api.md +++ b/docs/api.md @@ -138,6 +138,8 @@ * [event: 'requestfailed'](#event-requestfailed) * [event: 'requestfinished'](#event-requestfinished) * [event: 'response'](#event-response) + * [event: 'workercreated'](#event-workercreated) + * [event: 'workerdestroyed'](#event-workerdestroyed) * [page.$(selector)](#pageselector) * [page.$$(selector)](#pageselector-1) * [page.$$eval(selector, pageFunction[, ...args])](#pageevalselector-pagefunction-args) @@ -192,6 +194,7 @@ * [page.waitForRequest(urlOrPredicate[, options])](#pagewaitforrequesturlorpredicate-options) * [page.waitForResponse(urlOrPredicate[, options])](#pagewaitforresponseurlorpredicate-options) * [page.waitForSelector(selector[, options])](#pagewaitforselectorselector-options) + * [page.workers()](#pageworkers) - [class: Request](#class-request) * [request.abort([errorCode])](#requestaborterrorcode) * [request.continue([overrides])](#requestcontinueoverrides) @@ -248,13 +251,10 @@ * [chromiumPlaywright.launch([options])](#chromiumplaywrightlaunchoptions) * [chromiumPlaywright.launchServer([options])](#chromiumplaywrightlaunchserveroptions) - [class: ChromiumPage](#class-chromiumpage) - * [event: 'workercreated'](#event-workercreated) - * [event: 'workerdestroyed'](#event-workerdestroyed) * [chromiumPage.accessibility](#chromiumpageaccessibility) * [chromiumPage.coverage](#chromiumpagecoverage) * [chromiumPage.interception](#chromiumpageinterception) * [chromiumPage.pdf([options])](#chromiumpagepdfoptions) - * [chromiumPage.workers()](#chromiumpageworkers) - [class: ChromiumSession](#class-chromiumsession) * [chromiumSession.detach()](#chromiumsessiondetach) * [chromiumSession.send(method[, params])](#chromiumsessionsendmethod-params) @@ -1842,6 +1842,16 @@ Emitted when a request finishes successfully. Emitted when a [response] is received. +#### event: 'workercreated' +- <[Worker]> + +Emitted when a dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is spawned by the page. + +#### event: 'workerdestroyed' +- <[Worker]> + +Emitted when a dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is terminated. + #### page.$(selector) - `selector` <[string]> A selector to query page for - returns: <[Promise]> @@ -2738,6 +2748,12 @@ const playwright = require('playwright'); ``` Shortcut for [page.mainFrame().waitForSelector(selector[, options])](#framewaitforselectorselector-options). +#### page.workers() +- returns: <[Array]<[Worker]>> +This method returns all of the dedicated [WebWorkers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) associated with the page. + +> **NOTE** This does not contain ServiceWorkers + ### class: Request Whenever the page sends a request, such as for a network resource, the following events are emitted by playwright's page: @@ -3336,16 +3352,6 @@ const browser = await playwright.launch({ The ChromiumPage class represents a Chromium-specific extension of the page. -#### event: 'workercreated' -- <[Worker]> - -Emitted when a dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is spawned by the page. - -#### event: 'workerdestroyed' -- <[Worker]> - -Emitted when a dedicated [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) is terminated. - #### chromiumPage.accessibility - returns: <[Accessibility]> @@ -3424,12 +3430,6 @@ The `format` options are: > 1. Script tags inside templates are not evaluated. > 2. Page styles are not visible inside templates. -#### chromiumPage.workers() -- returns: <[Array]<[Worker]>> -This method returns all of the dedicated [WebWorkers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) associated with the page. - -> **NOTE** This does not contain ServiceWorkers - ### class: ChromiumSession * extends: [EventEmitter](https://nodejs.org/api/events.html#events_class_eventemitter) diff --git a/src/chromium/crApi.ts b/src/chromium/crApi.ts index a0c3246448..9f6c79048b 100644 --- a/src/chromium/crApi.ts +++ b/src/chromium/crApi.ts @@ -19,5 +19,4 @@ export { CRSession as ChromiumSession } from './crConnection'; export { ChromiumPage } from './crPage'; export { CRPlaywright as ChromiumPlaywright } from './crPlaywright'; export { CRTarget as ChromiumTarget } from './crTarget'; -export { CRCoverage as ChromiumCoverage } from './features/crCoverage'; -export { CRWorker as ChromiumWorker } from './features/crWorkers'; +export { CRCoverage as ChromiumCoverage } from './crCoverage'; diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts index 30dac55ca4..ad44d2b2b4 100644 --- a/src/chromium/crBrowser.ts +++ b/src/chromium/crBrowser.ts @@ -20,7 +20,7 @@ import { Events as CommonEvents } from '../events'; import { assert, helper } from '../helper'; import { BrowserContext, BrowserContextOptions } from '../browserContext'; import { CRConnection, ConnectionEvents, CRSession } from './crConnection'; -import { Page } from '../page'; +import { Page, Worker } from '../page'; import { CRTarget } from './crTarget'; import { Protocol } from './protocol'; import { CRPage } from './crPage'; @@ -28,7 +28,6 @@ import * as browser from '../browser'; import * as network from '../network'; import * as types from '../types'; import * as platform from '../platform'; -import { CRWorker } from './features/crWorkers'; import { ConnectionTransport } from '../transport'; import { readProtocolStream } from './crProtocolHelper'; @@ -238,7 +237,7 @@ export class CRBrowser extends browser.Browser { return [...this._targets.values()].find(t => t.type() === 'browser'); } - serviceWorker(target: CRTarget): Promise { + serviceWorker(target: CRTarget): Promise { return target._worker(); } diff --git a/src/chromium/features/crCoverage.ts b/src/chromium/crCoverage.ts similarity index 98% rename from src/chromium/features/crCoverage.ts rename to src/chromium/crCoverage.ts index 2e85d49cd3..411d58a98e 100644 --- a/src/chromium/features/crCoverage.ts +++ b/src/chromium/crCoverage.ts @@ -15,11 +15,11 @@ * limitations under the License. */ -import { CRSession } from '../crConnection'; -import { assert, debugError, helper, RegisteredListener } from '../../helper'; -import { Protocol } from '../protocol'; +import { CRSession } from './crConnection'; +import { assert, debugError, helper, RegisteredListener } from '../helper'; +import { Protocol } from './protocol'; -import { EVALUATION_SCRIPT_URL } from '../crExecutionContext'; +import { EVALUATION_SCRIPT_URL } from './crExecutionContext'; type CoverageEntry = { url: string, diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index 1f75a5c7da..0ad37f3967 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -30,9 +30,9 @@ import * as dialog from '../dialog'; import { PageDelegate } from '../page'; import { RawMouseImpl, RawKeyboardImpl } from './crInput'; import { getAccessibilityTree } from './crAccessibility'; -import { CRCoverage } from './features/crCoverage'; -import { CRPDF, PDFOptions } from './features/crPdf'; -import { CRWorkers, CRWorker } from './features/crWorkers'; +import { CRCoverage } from './crCoverage'; +import { CRPDF, PDFOptions } from './crPdf'; +import { CRWorkers } from './crWorkers'; import { CRBrowser } from './crBrowser'; import { BrowserContext } from '../browserContext'; import * as types from '../types'; @@ -46,6 +46,7 @@ export class CRPage implements PageDelegate { _client: CRSession; private readonly _page: ChromiumPage; readonly _networkManager: CRNetworkManager; + private _workers: CRWorkers; private _contextIdToContext = new Map(); private _isolatedWorlds = new Set(); private _eventListeners: RegisteredListener[]; @@ -59,7 +60,8 @@ export class CRPage implements PageDelegate { this.rawKeyboard = new RawKeyboardImpl(client); this.rawMouse = new RawMouseImpl(client); this._page = new ChromiumPage(client, this, browserContext); - this._networkManager = this._page._networkManager; + this._networkManager = new CRNetworkManager(client, this._page); + this._workers = new CRWorkers(client, this._page); this._eventListeners = [ helper.addEventListener(client, 'Inspector.targetCrashed', event => this._onTargetCrashed()), @@ -484,24 +486,16 @@ export class CRPage implements PageDelegate { export class ChromiumPage extends Page { readonly coverage: CRCoverage; private _pdf: CRPDF; - private _workers: CRWorkers; - _networkManager: CRNetworkManager; constructor(client: CRSession, delegate: CRPage, browserContext: BrowserContext) { super(delegate, browserContext); this.coverage = new CRCoverage(client); this._pdf = new CRPDF(client); - this._workers = new CRWorkers(client, this, this._addConsoleMessage.bind(this), error => this.emit(Events.Page.PageError, error)); - this._networkManager = new CRNetworkManager(client, this); } async pdf(options?: PDFOptions): Promise { return this._pdf.generate(options); } - - workers(): CRWorker[] { - return this._workers.list(); - } } function toRemoteObject(handle: dom.ElementHandle): Protocol.Runtime.RemoteObject { diff --git a/src/chromium/features/crPdf.ts b/src/chromium/crPdf.ts similarity index 95% rename from src/chromium/features/crPdf.ts rename to src/chromium/crPdf.ts index 008b57b297..11ab2a4e47 100644 --- a/src/chromium/features/crPdf.ts +++ b/src/chromium/crPdf.ts @@ -15,10 +15,10 @@ * limitations under the License. */ -import { assert, helper } from '../../helper'; -import { CRSession } from '../crConnection'; -import { readProtocolStream } from '../crProtocolHelper'; -import * as platform from '../../platform'; +import { assert, helper } from '../helper'; +import * as platform from '../platform'; +import { CRSession } from './crConnection'; +import { readProtocolStream } from './crProtocolHelper'; export type PDFOptions = { scale?: number, diff --git a/src/chromium/crTarget.ts b/src/chromium/crTarget.ts index 959ae06c7c..a6304b2b0b 100644 --- a/src/chromium/crTarget.ts +++ b/src/chromium/crTarget.ts @@ -19,11 +19,11 @@ import { CRBrowser } from './crBrowser'; import { BrowserContext } from '../browserContext'; import { CRSession, CRSessionEvents } from './crConnection'; import { Events } from '../events'; -import { CRWorker } from './features/crWorkers'; -import { Page } from '../page'; +import { Page, Worker } from '../page'; import { Protocol } from './protocol'; import { debugError } from '../helper'; import { CRPage } from './crPage'; +import { CRExecutionContext } from './crExecutionContext'; const targetSymbol = Symbol('target'); @@ -35,7 +35,7 @@ export class CRTarget { private _sessionFactory: () => Promise; private _pagePromise: Promise | null = null; _crPage: CRPage | null = null; - private _workerPromise: Promise | null = null; + private _workerPromise: Promise | null = null; readonly _initializedPromise: Promise; _initializedCallback: (value?: unknown) => void; _isInitialized: boolean; @@ -98,13 +98,20 @@ export class CRTarget { return this._pagePromise; } - async _worker(): Promise { + async _worker(): Promise { if (this._targetInfo.type !== 'service_worker' && this._targetInfo.type !== 'shared_worker') return null; if (!this._workerPromise) { // TODO(einbinder): Make workers send their console logs. - this._workerPromise = this._sessionFactory() - .then(client => new CRWorker(client, this._targetInfo.url, () => { } /* consoleAPICalled */, () => { } /* exceptionThrown */)); + this._workerPromise = this._sessionFactory().then(session => { + const worker = new Worker(this._targetInfo.url); + session.once('Runtime.executionContextCreated', async event => { + worker._createExecutionContext(new CRExecutionContext(session, event.context)); + }); + // This might fail if the target is closed before we recieve all execution contexts. + session.send('Runtime.enable', {}).catch(debugError); + return worker; + }); } return this._workerPromise; } diff --git a/src/chromium/crWorkers.ts b/src/chromium/crWorkers.ts new file mode 100644 index 0000000000..e5c63c79d6 --- /dev/null +++ b/src/chromium/crWorkers.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications 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 { Events } from '../events'; +import { debugError } from '../helper'; +import { Worker } from '../page'; +import { CRConnection, CRSession } from './crConnection'; +import { CRExecutionContext } from './crExecutionContext'; +import { ChromiumPage } from './crPage'; +import { exceptionToError, toConsoleMessageLocation } from './crProtocolHelper'; + +export class CRWorkers { + constructor(client: CRSession, page: ChromiumPage) { + client.on('Target.attachedToTarget', event => { + if (event.targetInfo.type !== 'worker') + return; + const url = event.targetInfo.url; + const session = CRConnection.fromSession(client).session(event.sessionId); + const worker = new Worker(url); + page._addWorker(event.sessionId, worker); + session.once('Runtime.executionContextCreated', async event => { + worker._createExecutionContext(new CRExecutionContext(session, event.context)); + }); + // This might fail if the target is closed before we recieve all execution contexts. + session.send('Runtime.enable', {}).catch(debugError); + session.on('Runtime.consoleAPICalled', event => page._addConsoleMessage(event.type, event.args.map(o => worker._existingExecutionContext._createHandle(o)), toConsoleMessageLocation(event.stackTrace))); + session.on('Runtime.exceptionThrown', exception => page.emit(Events.Page.PageError, exceptionToError(exception.exceptionDetails))); + }); + client.on('Target.detachedFromTarget', event => page._removeWorker(event.sessionId)); + } +} diff --git a/src/chromium/events.ts b/src/chromium/events.ts index f3cf01b51a..1b85dfaa6f 100644 --- a/src/chromium/events.ts +++ b/src/chromium/events.ts @@ -20,10 +20,5 @@ export const Events = { TargetCreated: 'targetcreated', TargetDestroyed: 'targetdestroyed', TargetChanged: 'targetchanged', - }, - - CRPage: { - WorkerCreated: 'workercreated', - WorkerDestroyed: 'workerdestroyed', } }; diff --git a/src/chromium/features/crWorkers.ts b/src/chromium/features/crWorkers.ts deleted file mode 100644 index 66ad311bae..0000000000 --- a/src/chromium/features/crWorkers.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Copyright 2018 Google Inc. All rights reserved. - * Modifications 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 { EventEmitter } from '../../platform'; -import { CRSession, CRConnection } from '../crConnection'; -import { debugError } from '../../helper'; -import { Protocol } from '../protocol'; -import { Events } from '../events'; -import * as types from '../../types'; -import * as js from '../../javascript'; -import * as console from '../../console'; -import { CRExecutionContext } from '../crExecutionContext'; -import { toConsoleMessageLocation, exceptionToError } from '../crProtocolHelper'; -import { ChromiumPage } from '../crPage'; - -type AddToConsoleCallback = (type: string, args: js.JSHandle[], location: console.ConsoleMessageLocation) => void; -type HandleExceptionCallback = (error: Error) => void; - -export class CRWorkers { - private _workers = new Map(); - - constructor(client: CRSession, page: ChromiumPage, addToConsole: AddToConsoleCallback, handleException: HandleExceptionCallback) { - client.on('Target.attachedToTarget', event => { - if (event.targetInfo.type !== 'worker') - return; - const session = CRConnection.fromSession(client).session(event.sessionId); - const worker = new CRWorker(session, event.targetInfo.url, addToConsole, handleException); - this._workers.set(event.sessionId, worker); - page.emit(Events.CRPage.WorkerCreated, worker); - }); - client.on('Target.detachedFromTarget', event => { - const worker = this._workers.get(event.sessionId); - if (!worker) - return; - page.emit(Events.CRPage.WorkerDestroyed, worker); - this._workers.delete(event.sessionId); - }); - } - - list(): CRWorker[] { - return Array.from(this._workers.values()); - } -} - -export class CRWorker extends EventEmitter { - private _client: CRSession; - private _url: string; - private _executionContextPromise: Promise; - private _executionContextCallback: (value?: js.ExecutionContext) => void; - - constructor(client: CRSession, url: string, addToConsole: AddToConsoleCallback, handleException: HandleExceptionCallback) { - super(); - this._client = client; - this._url = url; - this._executionContextPromise = new Promise(x => this._executionContextCallback = x); - let jsHandleFactory: (o: Protocol.Runtime.RemoteObject) => js.JSHandle; - this._client.once('Runtime.executionContextCreated', async event => { - jsHandleFactory = remoteObject => executionContext._createHandle(remoteObject); - const executionContext = new js.ExecutionContext(new CRExecutionContext(client, event.context)); - this._executionContextCallback(executionContext); - }); - // This might fail if the target is closed before we recieve all execution contexts. - this._client.send('Runtime.enable', {}).catch(debugError); - - this._client.on('Runtime.consoleAPICalled', event => addToConsole(event.type, event.args.map(jsHandleFactory), toConsoleMessageLocation(event.stackTrace))); - this._client.on('Runtime.exceptionThrown', exception => handleException(exceptionToError(exception.exceptionDetails))); - } - - url(): string { - return this._url; - } - - evaluate: types.Evaluate = async (pageFunction, ...args) => { - return (await this._executionContextPromise).evaluate(pageFunction, ...args as any); - } - - evaluateHandle: types.EvaluateHandle = async (pageFunction, ...args) => { - return (await this._executionContextPromise).evaluateHandle(pageFunction, ...args as any); - } -} diff --git a/src/events.ts b/src/events.ts index 2e4980677b..9cd13b33d2 100644 --- a/src/events.ts +++ b/src/events.ts @@ -38,5 +38,7 @@ export const Events = { FrameNavigated: 'framenavigated', Load: 'load', Popup: 'popup', - }, + WorkerCreated: 'workercreated', + WorkerDestroyed: 'workerdestroyed', + } }; diff --git a/src/page.ts b/src/page.ts index 5ba4184090..99dd48be1d 100644 --- a/src/page.ts +++ b/src/page.ts @@ -104,6 +104,7 @@ export class Page extends platform.EventEmitter { readonly _screenshotter: Screenshotter; readonly _frameManager: frames.FrameManager; readonly accessibility: accessibility.Accessibility; + private _workers = new Map(); constructor(delegate: PageDelegate, browserContext: BrowserContext) { super(); @@ -495,4 +496,53 @@ export class Page extends platform.EventEmitter { async $wait(selector: string, pageFunction: Function | string, options?: types.WaitForFunctionOptions, ...args: any[]): Promise { return this.mainFrame().$wait(selector, pageFunction, options, ...args); } + + workers(): Worker[] { + return [...this._workers.values()]; + } + + _addWorker(workerId: string, worker: Worker) { + this._workers.set(workerId, worker); + this.emit(Events.Page.WorkerCreated, worker); + } + + _removeWorker(workerId: string) { + const worker = this._workers.get(workerId); + if (!worker) + return; + this.emit(Events.Page.WorkerDestroyed, worker); + this._workers.delete(workerId); + } + + _clearWorkers() { + this._workers.clear(); + } +} + +export class Worker { + private _url: string; + private _executionContextPromise: Promise; + private _executionContextCallback: (value?: js.ExecutionContext) => void; + _existingExecutionContext: js.ExecutionContext | null; + + constructor(url: string) { + this._url = url; + this._executionContextPromise = new Promise(x => this._executionContextCallback = x); + } + _createExecutionContext(delegate: js.ExecutionContextDelegate) { + this._existingExecutionContext = new js.ExecutionContext(delegate); + this._executionContextCallback(this._existingExecutionContext); + } + + url(): string { + return this._url; + } + + evaluate: types.Evaluate = async (pageFunction, ...args) => { + return (await this._executionContextPromise).evaluate(pageFunction, ...args as any); + } + + evaluateHandle: types.EvaluateHandle = async (pageFunction, ...args) => { + return (await this._executionContextPromise).evaluateHandle(pageFunction, ...args as any); + } } diff --git a/src/webkit/wkConnection.ts b/src/webkit/wkConnection.ts index 35a6d3b020..1ddfe2e888 100644 --- a/src/webkit/wkConnection.ts +++ b/src/webkit/wkConnection.ts @@ -214,19 +214,45 @@ export class WKPageProxySession extends platform.EventEmitter { } } -export class WKTargetSession extends platform.EventEmitter { - _pageProxySession: WKPageProxySession; - private readonly _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); - private readonly _targetType: string; - readonly _sessionId: string; - _swappedOut = false; - private _provisionalMessages?: string[]; +export class WKSession extends platform.EventEmitter { + readonly _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); on: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; addListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; off: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; removeListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; once: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; + send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise { + throw new Error('Not implemented'); + } + + protected _dispatchMessage(message: string) { + const object = JSON.parse(message); + debugWrappedMessage('◀ RECV ' + JSON.stringify(object, null, 2)); + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id); + this._callbacks.delete(object.id); + if (object.error) + callback.reject(createProtocolError(callback.error, callback.method, object)); + else + callback.resolve(object.result); + } else { + assert(!object.id); + Promise.resolve().then(() => this.emit(object.method, object.params)); + } + } +} + +export class WKTargetSession extends WKSession { + _pageProxySession: WKPageProxySession; + private readonly _targetType: string; + readonly _sessionId: string; + _swappedOut = false; + private _provisionalMessages?: string[]; + constructor(pageProxySession: WKPageProxySession, targetInfo: Protocol.Target.TargetInfo) { super(); const {targetId, type, isProvisional} = targetInfo; @@ -287,19 +313,7 @@ export class WKTargetSession extends platform.EventEmitter { _dispatchMessageFromTarget(message: string) { console.assert(!this.isProvisional()); - const object = JSON.parse(message); - debugWrappedMessage('◀ RECV ' + JSON.stringify(object, null, 2)); - if (object.id && this._callbacks.has(object.id)) { - const callback = this._callbacks.get(object.id); - this._callbacks.delete(object.id); - if (object.error) - callback.reject(createProtocolError(callback.error, callback.method, object)); - else - callback.resolve(object.result); - } else { - assert(!object.id); - Promise.resolve().then(() => this.emit(object.method, object.params)); - } + this._dispatchMessage(message); } _onClosed() { @@ -316,14 +330,14 @@ export class WKTargetSession extends platform.EventEmitter { } } -function createProtocolError(error: Error, method: string, object: { error: { message: string; data: any; }; }): Error { +export function createProtocolError(error: Error, method: string, object: { error: { message: string; data: any; }; }): Error { let message = `Protocol error (${method}): ${object.error.message}`; if ('data' in object.error) message += ` ${object.error.data}`; return rewriteError(error, message); } -function rewriteError(error: Error, message: string): Error { +export function rewriteError(error: Error, message: string): Error { error.message = message; return error; } diff --git a/src/webkit/wkExecutionContext.ts b/src/webkit/wkExecutionContext.ts index b07aa20ea0..b6fae930e5 100644 --- a/src/webkit/wkExecutionContext.ts +++ b/src/webkit/wkExecutionContext.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { WKTargetSession, isSwappedOutError } from './wkConnection'; +import { WKSession, isSwappedOutError } from './wkConnection'; import { helper } from '../helper'; import { valueFromRemoteObject, releaseObject } from './wkProtocolHelper'; import { Protocol } from './protocol'; @@ -26,15 +26,15 @@ const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; export class WKExecutionContext implements js.ExecutionContextDelegate { private _globalObjectId?: Promise; - _session: WKTargetSession; - _contextId: number; + _session: WKSession; + _contextId: number | undefined; private _contextDestroyedCallback: () => void; private _executionContextDestroyedPromise: Promise; _jsonStringifyObjectId: Protocol.Runtime.RemoteObjectId | undefined; - constructor(client: WKTargetSession, contextPayload: Protocol.Runtime.ExecutionContextDescription) { + constructor(client: WKSession, contextId: number | undefined) { this._session = client; - this._contextId = contextPayload.id; + this._contextId = contextId; this._contextDestroyedCallback = null; this._executionContextDestroyedPromise = new Promise((resolve, reject) => { this._contextDestroyedCallback = resolve; diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index 2f174e875d..cee6f88306 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -23,6 +23,7 @@ import { WKTargetSession, WKTargetSessionEvents } from './wkConnection'; import { Events } from '../events'; import { WKExecutionContext, EVALUATION_SCRIPT_URL } from './wkExecutionContext'; import { WKNetworkManager } from './wkNetworkManager'; +import { WKWorkers } from './wkWorkers'; import { Page, PageDelegate } from '../page'; import { Protocol } from './protocol'; import * as dialog from '../dialog'; @@ -43,11 +44,12 @@ export class WKPage implements PageDelegate { _session: WKTargetSession; readonly _page: Page; private _browser: WKBrowser; - private readonly _networkManager: WKNetworkManager; - private readonly _contextIdToContext: Map; + private _networkManager: WKNetworkManager; + private _workers: WKWorkers; + private _contextIdToContext: Map; private _isolatedWorlds: Set; private _sessionListeners: RegisteredListener[] = []; - private readonly _bootstrapScripts: string[] = []; + private _bootstrapScripts: string[] = []; constructor(browser: WKBrowser, browserContext: BrowserContext) { this._browser = browser; @@ -57,6 +59,7 @@ export class WKPage implements PageDelegate { this._isolatedWorlds = new Set(); this._page = new Page(this, browserContext); this._networkManager = new WKNetworkManager(this._page); + this._workers = new WKWorkers(this._page); } setSession(session: WKTargetSession) { @@ -67,6 +70,8 @@ export class WKPage implements PageDelegate { this.rawMouse.setSession(session); this._addSessionListeners(); this._networkManager.setSession(session); + this._workers.setSession(session); + this._page._clearWorkers(); this._isolatedWorlds = new Set(); // New bootstrap scripts may have been added during provisional load, push them // again to be on the safe side. @@ -86,6 +91,7 @@ export class WKPage implements PageDelegate { session.send('Console.enable'), session.send('Page.setInterceptFileChooserDialog', { enabled: true }), this._networkManager.initializeSession(session, this._page._state.interceptNetwork, this._page._state.offlineMode, this._page._state.credentials), + this._workers.initializeSession(session) ]; if (!session.isProvisional()) { // FIXME: move dialog agent to web process. @@ -195,7 +201,7 @@ export class WKPage implements PageDelegate { const frame = this._page._frameManager.frame(contextPayload.frameId); if (!frame) return; - const delegate = new WKExecutionContext(this._session, contextPayload); + const delegate = new WKExecutionContext(this._session, contextPayload.id); const context = new dom.FrameExecutionContext(delegate, frame); if (contextPayload.isPageContext) frame._contextCreated('main', context); diff --git a/src/webkit/wkProtocolHelper.ts b/src/webkit/wkProtocolHelper.ts index 2bc6995163..8d8bcee838 100644 --- a/src/webkit/wkProtocolHelper.ts +++ b/src/webkit/wkProtocolHelper.ts @@ -16,7 +16,7 @@ */ import { assert, debugError } from '../helper'; -import { WKTargetSession } from './wkConnection'; +import { WKSession } from './wkConnection'; import { Protocol } from './protocol'; export function valueFromRemoteObject(remoteObject: Protocol.Runtime.RemoteObject): any { @@ -43,7 +43,7 @@ export function valueFromRemoteObject(remoteObject: Protocol.Runtime.RemoteObjec return remoteObject.value; } -export async function releaseObject(client: WKTargetSession, remoteObject: Protocol.Runtime.RemoteObject) { +export async function releaseObject(client: WKSession, remoteObject: Protocol.Runtime.RemoteObject) { if (!remoteObject.objectId) return; await client.send('Runtime.releaseObject', {objectId: remoteObject.objectId}).catch(error => { diff --git a/src/webkit/wkWorkers.ts b/src/webkit/wkWorkers.ts new file mode 100644 index 0000000000..a9a4d69595 --- /dev/null +++ b/src/webkit/wkWorkers.ts @@ -0,0 +1,131 @@ +/** + * Copyright 2019 Microsoft Corporation All rights reserved. + * + * 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 { assert, helper, RegisteredListener } from '../helper'; +import { Page, Worker } from '../page'; +import { Protocol } from './protocol'; +import { rewriteError, WKSession, WKTargetSession } from './wkConnection'; +import { WKExecutionContext } from './wkExecutionContext'; + +export class WKWorkers { + private _sessionListeners: RegisteredListener[] = []; + private _page: Page; + + constructor(page: Page) { + this._page = page; + } + + setSession(session: WKTargetSession) { + helper.removeEventListeners(this._sessionListeners); + this._sessionListeners = [ + helper.addEventListener(session, 'Worker.workerCreated', async (event: Protocol.Worker.workerCreatedPayload) => { + const worker = new Worker(event.url); + const workerSession = new WKWorkerSession(session, event.workerId); + worker._createExecutionContext(new WKExecutionContext(workerSession, undefined)); + this._page._addWorker(event.workerId, worker); + workerSession.on('Console.messageAdded', event => this._onConsoleMessage(worker, event)); + try { + Promise.all([ + workerSession.send('Runtime.enable'), + workerSession.send('Console.enable'), + session.send('Worker.initialized', { workerId: event.workerId }).catch(e => { + this._page._removeWorker(event.workerId); + }) + ]); + } catch (e) { + // Worker can go as we are initializing it. + } + }), + helper.addEventListener(session, 'Worker.workerTerminated', (event: Protocol.Worker.workerTerminatedPayload) => { + this._page._removeWorker(event.workerId); + }) + ]; + } + + async initializeSession(session: WKTargetSession) { + await session.send('Worker.enable'); + } + + async _onConsoleMessage(worker: Worker, event: Protocol.Console.messageAddedPayload) { + const { type, level, text, parameters, url, line: lineNumber, column: columnNumber } = event.message; + let derivedType: string = type; + if (type === 'log') + derivedType = level; + else if (type === 'timing') + derivedType = 'timeEnd'; + + const handles = (parameters || []).map(p => { + return worker._existingExecutionContext._createHandle(p); + }); + this._page._addConsoleMessage(derivedType, handles, { url, lineNumber: lineNumber - 1, columnNumber: columnNumber - 1 }, handles.length ? undefined : text); + } +} + +export class WKWorkerSession extends WKSession { + private _targetSession: WKTargetSession | null; + private _workerId: string; + private _lastId = 1001; + + constructor(targetSession: WKTargetSession, workerId: string) { + super(); + this._targetSession = targetSession; + this._workerId = workerId; + this._targetSession.on('Worker.dispatchMessageFromWorker', event => { + if (event.workerId === workerId) + this._dispatchMessage(event.message); + }); + this._targetSession.on('Worker.workerTerminated', event => { + if (event.workerId === workerId) + this._workerTerminated(); + }); + } + + send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise { + if (!this._targetSession) + return Promise.reject(new Error(`Protocol error (${method}): Most likely the worker has been closed.`)); + const innerId = ++this._lastId; + const messageObj = { + id: innerId, + method, + params + }; + const message = JSON.stringify(messageObj); + const result = new Promise((resolve, reject) => { + this._callbacks.set(innerId, {resolve, reject, error: new Error(), method}); + }); + this._targetSession.send('Worker.sendMessageToWorker', { + workerId: this._workerId, + message: message + }).catch(e => { + // There is a possible race of the connection closure. We may have received + // targetDestroyed notification before response for the command, in that + // case it's safe to swallow the exception. + const callback = this._callbacks.get(innerId); + assert(!callback, 'Callback was not rejected when worker was terminated.'); + }); + return result; + } + + _workerTerminated() { + for (const callback of this._callbacks.values()) + callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Worker terminated.`)); + this._callbacks.clear(); + this._targetSession = null; + } +} diff --git a/test/playwright.spec.js b/test/playwright.spec.js index 34470ab7b9..f9227c0da4 100644 --- a/test/playwright.spec.js +++ b/test/playwright.spec.js @@ -168,13 +168,13 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => { testRunner.loadTests(require('./waittask.spec.js'), testOptions); testRunner.loadTests(require('./interception.spec.js'), testOptions); testRunner.loadTests(require('./geolocation.spec.js'), testOptions); + testRunner.loadTests(require('./workers.spec.js'), testOptions); if (CHROME) { testRunner.loadTests(require('./chromium/chromium.spec.js'), testOptions); testRunner.loadTests(require('./chromium/coverage.spec.js'), testOptions); testRunner.loadTests(require('./chromium/pdf.spec.js'), testOptions); testRunner.loadTests(require('./chromium/session.spec.js'), testOptions); - testRunner.loadTests(require('./chromium/workers.spec.js'), testOptions); } if (CHROME || FFOX) { diff --git a/test/chromium/workers.spec.js b/test/workers.spec.js similarity index 68% rename from test/chromium/workers.spec.js rename to test/workers.spec.js index dff567923c..54e5c5510f 100644 --- a/test/chromium/workers.spec.js +++ b/test/workers.spec.js @@ -15,7 +15,7 @@ * limitations under the License. */ -const utils = require('../utils'); +const utils = require('./utils'); const { waitEvent } = utils; module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { @@ -23,10 +23,10 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { const {it, fit, xit, dit} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; - describe('Workers', function() { + describe.skip(FFOX)('Workers', function() { it('Page.workers', async function({page, server}) { await Promise.all([ - new Promise(x => page.once('workercreated', x)), + page.waitForEvent('workercreated'), page.goto(server.PREFIX + '/worker/worker.html')]); const worker = page.workers()[0]; expect(worker.url()).toContain('worker.js'); @@ -37,8 +37,8 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { expect(page.workers().length).toBe(0); }); it('should emit created and destroyed events', async function({page}) { - const workerCreatedPromise = new Promise(x => page.once('workercreated', x)); - const workerObj = await page.evaluateHandle(() => new Worker('data:text/javascript,1')); + const workerCreatedPromise = page.waitForEvent('workercreated'); + const workerObj = await page.evaluateHandle(() => new Worker(URL.createObjectURL(new Blob(['1'], {type: 'application/javascript'})))); const worker = await workerCreatedPromise; const workerThisObj = await worker.evaluateHandle(() => this); const workerDestroyedPromise = new Promise(x => page.once('workerdestroyed', x)); @@ -50,34 +50,38 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { it('should report console logs', async function({page}) { const [message] = await Promise.all([ waitEvent(page, 'console'), - page.evaluate(() => new Worker(`data:text/javascript,console.log(1)`)), + page.evaluate(() => new Worker(URL.createObjectURL(new Blob(['console.log(1)'], {type: 'application/javascript'})))), ]); expect(message.text()).toBe('1'); - expect(message.location()).toEqual({ - url: 'data:text/javascript,console.log(1)', - lineNumber: 0, - columnNumber: 8, - }); }); it('should have JSHandles for console logs', async function({page}) { const logPromise = new Promise(x => page.on('console', x)); - await page.evaluate(() => new Worker(`data:text/javascript,console.log(1,2,3,this)`)); + await page.evaluate(() => new Worker(URL.createObjectURL(new Blob(['console.log(1,2,3,this)'], {type: 'application/javascript'})))); const log = await logPromise; expect(log.text()).toBe('1 2 3 JSHandle@object'); expect(log.args().length).toBe(4); expect(await (await log.args()[3].getProperty('origin')).jsonValue()).toBe('null'); }); it('should evaluate', async function({page}) { - const workerCreatedPromise = new Promise(x => page.once('workercreated', x)); - await page.evaluate(() => new Worker(`data:text/javascript,console.log(1)`)); + const workerCreatedPromise = page.waitForEvent('workercreated'); + page.evaluate(() => new Worker(URL.createObjectURL(new Blob(['console.log(1)'], {type: 'application/javascript'})))); const worker = await workerCreatedPromise; expect(await worker.evaluate('1+1')).toBe(2); }); it('should report errors', async function({page}) { const errorPromise = new Promise(x => page.on('pageerror', x)); - await page.evaluate(() => new Worker(`data:text/javascript, throw new Error('this is my error');`)); + page.evaluate(() => new Worker(URL.createObjectURL(new Blob([`setTimeout(() => { throw new Error('this is my error'); })`], {type: 'application/javascript'})))); const errorLog = await errorPromise; expect(errorLog.message).toContain('this is my error'); }); + it('should clear upon navigation', async function({server, page}) { + await page.goto(server.EMPTY_PAGE); + const workerCreatedPromise = page.waitForEvent('workercreated'); + page.evaluate(() => new Worker(URL.createObjectURL(new Blob(['console.log(1)'], {type: 'application/javascript'})))); + await workerCreatedPromise; + expect(page.workers().length).toBe(1); + await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + expect(page.workers().length).toBe(0); + }); }); };