feat(Page): introduce workers (#2560)

This adds `page.workers()`, and two events `workercreated` and `workerdestroyed`. It also forwards logs from a worker into the page `console` event.

Only dedicated workers are supported for now, ServiceWorkers will probably work differently because they aren't necessarily associated with a single page.

Fixes #2350.
This commit is contained in:
Joel Einbinder 2018-05-21 14:31:11 -07:00 committed by Andrey Lushnikov
parent b474f2ce87
commit 93fe2b57d6
9 changed files with 228 additions and 7 deletions

View File

@ -67,6 +67,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)
* [page.$$eval(selector, pageFunction[, ...args])](#pageevalselector-pagefunction-args)
@ -128,6 +130,10 @@
* [page.waitForNavigation(options)](#pagewaitfornavigationoptions)
* [page.waitForSelector(selector[, options])](#pagewaitforselectorselector-options)
* [page.waitForXPath(xpath[, options])](#pagewaitforxpathxpath-options)
* [page.workers()](#pageworkers)
- [class: Worker](#class-worker)
* [worker.executionContext()](#workerexecutioncontext)
* [worker.url()](#workerurl)
- [class: Keyboard](#class-keyboard)
* [keyboard.down(key[, options])](#keyboarddownkey-options)
* [keyboard.press(key[, options])](#keyboardpresskey-options)
@ -595,7 +601,7 @@ will be closed.
#### browserContext.isIncognito()
- returns: <[boolean]>
Returns whether BrowserContext is incognito.
Returns whether BrowserContext is incognito.
The default browser context is the only non-incognito browser context.
> **NOTE** the default browser context cannot be closed.
@ -736,6 +742,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]<?[ElementHandle]>>
@ -1596,6 +1612,32 @@ puppeteer.launch().then(async browser => {
```
Shortcut for [page.mainFrame().waitForXPath(xpath[, options])](#framewaitforxpathxpath-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: Worker
The Worker class represents a [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API).
The events `workercreated` and `workerdestroyed` are emitted on the page object to signal the worker lifecycle.
```js
page.on('workercreated', worker => console.log('Worker created: ' + worker.url()));
page.on('workerdestroyed', worker => console.log('Worker destroyed: ' + worker.url()));
console.log('Current workers:');
for (const worker of page.workers())
console.log(' ' + worker.url());
```
#### worker.executionContext()
- returns: <[Promise]<[ExecutionContext]>>
#### worker.url()
- returns: <[string]>
### class: Keyboard
Keyboard provides an api for managing a virtual keyboard. The high level api is [`keyboard.type`](#keyboardtypetext-options), which takes raw characters and generates proper keydown, keypress/input, and keyup events on your page.
@ -2884,3 +2926,4 @@ reported.
[xpath]: https://developer.mozilla.org/en-US/docs/Web/XPath "xpath"
[UnixTime]: https://en.wikipedia.org/wiki/Unix_time "Unix Time"
[SecurityDetails]: #class-securitydetails "SecurityDetails"
[Worker]: #class-worker "Worker"

View File

@ -163,7 +163,7 @@ class Connection extends EventEmitter {
class CDPSession extends EventEmitter {
/**
* @param {!Connection} connection
* @param {!Connection|!CDPSession} connection
* @param {string} targetId
* @param {string} sessionId
*/
@ -175,6 +175,8 @@ class CDPSession extends EventEmitter {
this._connection = connection;
this._targetId = targetId;
this._sessionId = sessionId;
/** @type {!Map<string, !CDPSession>}*/
this._sessions = new Map();
}
/**
@ -215,6 +217,17 @@ class CDPSession extends EventEmitter {
else
callback.resolve(object.result);
} else {
if (object.method === 'Target.receivedMessageFromTarget') {
const session = this._sessions.get(object.params.sessionId);
if (session)
session._onMessage(object.params.message);
} else if (object.method === 'Target.detachedFromTarget') {
const session = this._sessions.get(object.params.sessionId);
if (session) {
session._onClosed();
this._sessions.delete(object.params.sessionId);
}
}
console.assert(!object.id);
this.emit(object.method, object.params);
}
@ -230,6 +243,16 @@ class CDPSession extends EventEmitter {
this._callbacks.clear();
this._connection = null;
}
/**
* @param {string} targetId
* @param {string} sessionId
*/
_createSession(targetId, sessionId) {
const session = new CDPSession(this, targetId, sessionId);
this._sessions.set(sessionId, session);
return session;
}
}
helper.tracePublicAPI(CDPSession);

View File

@ -20,7 +20,7 @@ class ExecutionContext {
/**
* @param {!Puppeteer.CDPSession} client
* @param {!Protocol.Runtime.ExecutionContextDescription} contextPayload
* @param {function(*):!JSHandle} objectHandleFactory
* @param {function(!Protocol.Runtime.RemoteObject):!JSHandle} objectHandleFactory
* @param {?Puppeteer.Frame} frame
*/
constructor(client, contextPayload, objectHandleFactory, frame) {

View File

@ -215,7 +215,7 @@ class FrameManager extends EventEmitter {
/**
* @param {number} contextId
* @param {*} remoteObject
* @param {!Protocol.Runtime.RemoteObject} remoteObject
* @return {!JSHandle}
*/
createJSHandle(contextId, remoteObject) {

View File

@ -26,6 +26,7 @@ const {Keyboard, Mouse, Touchscreen} = require('./Input');
const Tracing = require('./Tracing');
const {helper, debugError} = require('./helper');
const {Coverage} = require('./Coverage');
const Worker = require('./Worker');
const writeFileAsync = helper.promisify(fs.writeFile);
@ -45,6 +46,7 @@ class Page extends EventEmitter {
const page = new Page(client, target, frameTree, ignoreHTTPSErrors, screenshotTaskQueue);
await Promise.all([
client.send('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: false}),
client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
client.send('Network.enable', {}),
client.send('Runtime.enable', {}),
@ -87,6 +89,30 @@ class Page extends EventEmitter {
this._screenshotTaskQueue = screenshotTaskQueue;
/** @type {!Map<string, Worker>} */
this._workers = new Map();
client.on('Target.attachedToTarget', event => {
if (event.targetInfo.type !== 'worker') {
// If we don't detach from service workers, they will never die.
client.send('Target.detachFromTarget', {
sessionId: event.sessionId
});
return;
}
const session = client._createSession(event.targetInfo.targetId, event.sessionId);
const worker = new Worker(session, event.targetInfo.url, this._onLogEntryAdded.bind(this, session));
this._workers.set(event.sessionId, worker);
this.emit(Page.Events.WorkerCreated, worker);
});
client.on('Target.detachedFromTarget', event => {
const worker = this._workers.get(event.sessionId);
if (!worker)
return;
this.emit(Page.Events.WorkerDestroyed, worker);
this._workers.delete(event.sessionId);
});
this._frameManager.on(FrameManager.Events.FrameAttached, event => this.emit(Page.Events.FrameAttached, event));
this._frameManager.on(FrameManager.Events.FrameDetached, event => this.emit(Page.Events.FrameDetached, event));
this._frameManager.on(FrameManager.Events.FrameNavigated, event => this.emit(Page.Events.FrameNavigated, event));
@ -104,7 +130,7 @@ class Page extends EventEmitter {
client.on('Security.certificateError', event => this._onCertificateError(event));
client.on('Inspector.targetCrashed', event => this._onTargetCrashed());
client.on('Performance.metrics', event => this._emitMetrics(event));
client.on('Log.entryAdded', event => this._onLogEntryAdded(event));
client.on('Log.entryAdded', event => this._onLogEntryAdded(this._client, event));
this._target._isClosedPromise.then(() => this.emit(Page.Events.Close));
}
@ -126,10 +152,14 @@ class Page extends EventEmitter {
this.emit('error', new Error('Page crashed!'));
}
_onLogEntryAdded(event) {
/**
* @param {!Puppeteer.CDPSession} session
* @param {!Protocol.Log.entryAddedPayload} event
*/
_onLogEntryAdded(session, event) {
const {level, text, args} = event.entry;
if (args)
args.map(arg => helper.releaseObject(this._client, arg));
args.map(arg => helper.releaseObject(session, arg));
this.emit(Page.Events.Console, new ConsoleMessage(level, text));
}
@ -176,6 +206,13 @@ class Page extends EventEmitter {
return this._frameManager.frames();
}
/**
* @return {!Array<!Worker>}
*/
workers() {
return Array.from(this._workers.values());
}
/**
* @param {boolean} value
*/
@ -1019,6 +1056,8 @@ Page.Events = {
FrameNavigated: 'framenavigated',
Load: 'load',
Metrics: 'metrics',
WorkerCreated: 'workercreated',
WorkerDestroyed: 'workerdestroyed',
};
/**

59
lib/Worker.js Normal file
View File

@ -0,0 +1,59 @@
/**
* Copyright 2018 Google Inc. 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.
*/
const EventEmitter = require('events');
const {helper, debugError} = require('./helper');
const {ExecutionContext, JSHandle} = require('./ExecutionContext');
class Worker extends EventEmitter {
/**
* @param {Puppeteer.CDPSession} client
* @param {string} url
* @param {function(!Protocol.Log.entryAddedPayload)} logEntryAdded
*/
constructor(client, url, logEntryAdded) {
super();
this._client = client;
this._url = url;
this._executionContextPromise = new Promise(x => this._executionContextCallback = x);
this._client.on('Runtime.executionContextCreated', event => {
const jsHandleFactory = remoteObject => new JSHandle(executionContext, client, remoteObject);
const executionContext = new ExecutionContext(client, event.context, jsHandleFactory, null);
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('Log.entryAdded', logEntryAdded);
this._client.send('Log.enable', {}).catch(debugError);
}
/**
* @return {string}
*/
url() {
return this._url;
}
/**
* @return {!Promise<ExecutionContext>}
*/
async executionContext() {
return this._executionContextPromise;
}
}
module.exports = Worker;
helper.tracePublicAPI(Worker);

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Worker test</title>
</head>
<body>
<script>
var worker = new Worker('worker.js');
worker.onmessage = function(message) {
console.log(message.data);
};
</script>
</body>
</html>

View File

@ -0,0 +1,16 @@
console.log('hello from the worker');
function workerFunction() {
return 'worker function result';
}
self.addEventListener('message', event => {
console.log('got this data: ' + event.data);
});
(async function() {
while (true) {
self.postMessage(workerFunction.toString());
await new Promise(x => setTimeout(x, 100));
}
})();

View File

@ -1611,6 +1611,33 @@ module.exports.addTests = function({testRunner, expect, puppeteer, DeviceDescrip
await closedPromise;
});
});
describe('Workers', function() {
it('Page.workers', async function({page, server}) {
await page.goto(server.PREFIX + '/worker/worker.html');
await page.waitForFunction(() => !!worker);
const worker = page.workers()[0];
expect(worker.url()).toContain('worker.js');
const executionContext = await worker.executionContext();
expect(await executionContext.evaluate(() => self.workerFunction())).toBe('worker function result');
await page.goto(server.EMPTY_PAGE);
expect(page.workers()).toEqual([]);
});
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 worker = await workerCreatedPromise;
const workerDestroyedPromise = new Promise(x => page.once('workerdestroyed', x));
await page.evaluate(workerObj => workerObj.terminate(), workerObj);
expect(await workerDestroyedPromise).toBe(worker);
});
it('should report 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)`));
const log = await logPromise;
expect(log.text()).toBe('1');
});
});
describe('Page.browser', function() {
it('should return the correct browser instance', async function({ page, browser }) {