From 5368051610babf52e7df927c323d315a493fb56f Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Wed, 10 Jan 2018 19:33:22 -0800 Subject: [PATCH] feat: expose raw devtools protocol connection (#1770) feat: expose raw devtools protocol connection This patch introduces `target.createCDPSession` method that allows directly communicating with the target over the Chrome DevTools Protocol. Fixes #31. --- docs/api.md | 48 +++++++++++++++++++++ lib/Browser.js | 14 +++++-- lib/Connection.js | 24 ++++------- lib/Coverage.js | 6 +-- lib/Dialog.js | 2 +- lib/ElementHandle.js | 2 +- lib/EmulationManager.js | 2 +- lib/ExecutionContext.js | 4 +- lib/FrameManager.js | 4 +- lib/Input.js | 6 +-- lib/NetworkManager.js | 6 +-- lib/Page.js | 27 ++++++++---- lib/Tracing.js | 2 +- lib/externs.d.ts | 7 ++-- lib/helper.js | 2 +- test/test.js | 56 +++++++++++++++++++++++-- utils/doclint/check_public_api/index.js | 1 - 17 files changed, 162 insertions(+), 51 deletions(-) diff --git a/docs/api.md b/docs/api.md index 913be3f4a76..ff4f27c4a23 100644 --- a/docs/api.md +++ b/docs/api.md @@ -87,6 +87,7 @@ * [page.setUserAgent(userAgent)](#pagesetuseragentuseragent) * [page.setViewport(viewport)](#pagesetviewportviewport) * [page.tap(selector)](#pagetapselector) + * [page.target()](#pagetarget) * [page.title()](#pagetitle) * [page.touchscreen](#pagetouchscreen) * [page.tracing](#pagetracing) @@ -198,9 +199,13 @@ * [response.text()](#responsetext) * [response.url()](#responseurl) - [class: Target](#class-target) + * [target.createCDPSession()](#targetcreatecdpsession) * [target.page()](#targetpage) * [target.type()](#targettype) * [target.url()](#targeturl) +- [class: CDPSession](#class-cdpsession) + * [cdpSession.detach()](#cdpsessiondetach) + * [cdpSession.send(method[, params])](#cdpsessionsendmethod-params) - [class: Coverage](#class-coverage) * [coverage.startCSSCoverage(options)](#coveragestartcsscoverageoptions) * [coverage.startJSCoverage(options)](#coveragestartjscoverageoptions) @@ -1139,6 +1144,9 @@ In the case of multiple pages in a single browser, each page can have its own vi This method fetches an element with `selector`, scrolls it into view if needed, and then uses [page.touchscreen](#pagetouchscreen) to tap in the center of the element. If there's no element matching `selector`, the method throws an error. +#### page.target() +- returns: <[Target]> a target this page was created from. + #### page.title() - returns: <[Promise]<[string]>> Returns page's title. @@ -2166,6 +2174,11 @@ Contains the URL of the response. ### class: Target +#### target.createCDPSession() +- returns: <[Promise]<[CDPSession]>> + +Creates a Chrome Devtools Protocol session attached to the target. + #### target.page() - returns: <[Promise]> @@ -2179,6 +2192,40 @@ Identifies what kind of target this is. Can be `"page"`, `"service_worker"`, or #### target.url() - returns: <[string]> + +### class: CDPSession + +* extends: [`EventEmitter`](https://nodejs.org/api/events.html#events_class_eventemitter) + +The `CDPSession` instances are used to talk raw Chrome Devtools Protocol: +- protocol methods can be called with `session.send` method. +- protocol events can be subscribed to with `session.on` method. + +Documentation on DevTools Protocol can be found here: [DevTools Protocol Viewer](https://chromedevtools.github.io/devtools-protocol/). + +```js +const client = await page.target().createCDPSession(); +await client.send('Animation.enable'); +await client.on('Animation.animationCreated', () => console.log('Animation created!')); +const response = await client.send('Animation.getPlaybackRate'); +console.log('playback rate is ' + response.playbackRate); +await client.send('Animation.setPlaybackRate', { + playbackRate: response.playbackRate / 2 +}); +``` + +#### cdpSession.detach() +- returns: <[Promise]> + +Detaches session from target. Once detached, session won't emit any events and can't be used +to send messages. + +#### cdpSession.send(method[, params]) +- `method` <[string]> protocol method name +- `params` <[Object]> Optional method parameters +- returns: <[Promise]<[Object]>> + + ### class: Coverage Coverage gathers information about parts of JavaScript and CSS that were used by the page. @@ -2253,6 +2300,7 @@ reported. [Promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise "Promise" [string]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type "String" [stream.Readable]: https://nodejs.org/api/stream.html#stream_class_stream_readable "stream.Readable" +[CDPSession]: #class-cdpsession "CDPSession" [Error]: https://nodejs.org/api/errors.html#errors_class_error "Error" [Frame]: #class-frame "Frame" [ConsoleMessage]: #class-consolemessage "ConsoleMessage" diff --git a/lib/Browser.js b/lib/Browser.js index 06eca858fe7..2772956be69 100644 --- a/lib/Browser.js +++ b/lib/Browser.js @@ -193,6 +193,7 @@ class Target { */ constructor(browser, targetInfo) { this._browser = browser; + this._targetId = targetInfo.targetId; this._targetInfo = targetInfo; /** @type {?Promise} */ this._pagePromise = null; @@ -202,13 +203,20 @@ class Target { this._initializedCallback(true); } + /** + * @return {!Promise} + */ + createCDPSession() { + return this._browser._connection.createSession(this._targetId); + } + /** * @return {!Promise} */ async page() { if (this._targetInfo.type === 'page' && !this._pagePromise) { - this._pagePromise = this._browser._connection.createSession(this._targetInfo.targetId) - .then(client => Page.create(client, this._browser._ignoreHTTPSErrors, this._browser._appMode, this._browser._screenshotTaskQueue)); + this._pagePromise = this._browser._connection.createSession(this._targetId) + .then(client => Page.create(client, this, this._browser._ignoreHTTPSErrors, this._browser._appMode, this._browser._screenshotTaskQueue)); } return this._pagePromise; } @@ -258,4 +266,4 @@ helper.tracePublicAPI(Target); * @property {boolean} attached */ -module.exports = { Browser, TaskQueue }; +module.exports = { Browser, TaskQueue, Target }; diff --git a/lib/Connection.js b/lib/Connection.js index a093c750c75..b3439bed941 100644 --- a/lib/Connection.js +++ b/lib/Connection.js @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +const {helper} = require('./helper'); const debugProtocol = require('debug')('puppeteer:protocol'); const debugSession = require('debug')('puppeteer:session'); @@ -49,7 +50,7 @@ class Connection extends EventEmitter { this._ws = ws; this._ws.on('message', this._onMessage.bind(this)); this._ws.on('close', this._onClose.bind(this)); - /** @type {!Map}*/ + /** @type {!Map}*/ this._sessions = new Map(); } @@ -135,17 +136,17 @@ class Connection extends EventEmitter { /** * @param {string} targetId - * @return {!Promise} + * @return {!Promise} */ async createSession(targetId) { const {sessionId} = await this.send('Target.attachToTarget', {targetId}); - const session = new Session(this, targetId, sessionId); + const session = new CDPSession(this, targetId, sessionId); this._sessions.set(sessionId, session); return session; } } -class Session extends EventEmitter { +class CDPSession extends EventEmitter { /** * @param {!Connection} connection * @param {string} targetId @@ -161,13 +162,6 @@ class Session extends EventEmitter { this._sessionId = sessionId; } - /** - * @return {string} - */ - targetId() { - return this._targetId; - } - /** * @param {string} method * @param {!Object=} params @@ -211,9 +205,8 @@ class Session extends EventEmitter { } } - async dispose() { - console.assert(!!this._connection, 'Protocol error: Connection closed. Most likely the page has been closed.'); - await this._connection.send('Target.closeTarget', {targetId: this._targetId}); + async detach() { + await this._connection.send('Target.detachFromTarget', {sessionId: this._sessionId}); } _onClosed() { @@ -223,6 +216,7 @@ class Session extends EventEmitter { this._connection = null; } } +helper.tracePublicAPI(CDPSession); /** * @param {!Error} error @@ -234,4 +228,4 @@ function rewriteError(error, message) { return error; } -module.exports = {Connection, Session}; +module.exports = {Connection, CDPSession}; diff --git a/lib/Coverage.js b/lib/Coverage.js index 0a513d8bdce..00fb3e13221 100644 --- a/lib/Coverage.js +++ b/lib/Coverage.js @@ -25,7 +25,7 @@ const {helper, debugError} = require('./helper'); class Coverage { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client */ constructor(client) { this._jsCoverage = new JSCoverage(client); @@ -66,7 +66,7 @@ helper.tracePublicAPI(Coverage); class JSCoverage { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client */ constructor(client) { this._client = client; @@ -154,7 +154,7 @@ class JSCoverage { class CSSCoverage { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client */ constructor(client) { this._client = client; diff --git a/lib/Dialog.js b/lib/Dialog.js index b03673a126e..78a071eab4c 100644 --- a/lib/Dialog.js +++ b/lib/Dialog.js @@ -18,7 +18,7 @@ const {helper} = require('./helper'); class Dialog { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {string} type * @param {string} message * @param {(string|undefined)} defaultValue diff --git a/lib/ElementHandle.js b/lib/ElementHandle.js index 40cc8ab4e78..2257daf4fc0 100644 --- a/lib/ElementHandle.js +++ b/lib/ElementHandle.js @@ -20,7 +20,7 @@ const {helper, debugError} = require('./helper'); class ElementHandle extends JSHandle { /** * @param {!Puppeteer.ExecutionContext} context - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {!Object} remoteObject * @param {!Puppeteer.Page} page */ diff --git a/lib/EmulationManager.js b/lib/EmulationManager.js index a1c2c59899d..77fae4333a6 100644 --- a/lib/EmulationManager.js +++ b/lib/EmulationManager.js @@ -16,7 +16,7 @@ class EmulationManager { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client */ constructor(client) { this._client = client; diff --git a/lib/ExecutionContext.js b/lib/ExecutionContext.js index f6ba5630972..695bf82c42e 100644 --- a/lib/ExecutionContext.js +++ b/lib/ExecutionContext.js @@ -18,7 +18,7 @@ const {helper} = require('./helper'); class ExecutionContext { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {!Object} contextPayload * @param {function(*):!JSHandle} objectHandleFactory */ @@ -117,7 +117,7 @@ class ExecutionContext { class JSHandle { /** * @param {!ExecutionContext} context - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {!Object} remoteObject */ constructor(context, client, remoteObject) { diff --git a/lib/FrameManager.js b/lib/FrameManager.js index 9d6b90e656d..4e7563967a2 100644 --- a/lib/FrameManager.js +++ b/lib/FrameManager.js @@ -24,7 +24,7 @@ const readFileAsync = helper.promisify(fs.readFile); class FrameManager extends EventEmitter { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {{frame: Object, childFrames: ?Array}} frameTree * @param {!Puppeteer.Page} page */ @@ -226,7 +226,7 @@ FrameManager.Events = { */ class Frame { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {?Frame} parentFrame * @param {string} frameId */ diff --git a/lib/Input.js b/lib/Input.js index 8fc1b0ab218..2ea8823cad6 100644 --- a/lib/Input.js +++ b/lib/Input.js @@ -28,7 +28,7 @@ const keyDefinitions = require('./USKeyboardLayout'); class Keyboard { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client */ constructor(client) { this._client = client; @@ -189,7 +189,7 @@ class Keyboard { class Mouse { /** - * @param {Puppeteer.Session} client + * @param {Puppeteer.CDPSession} client * @param {!Keyboard} keyboard */ constructor(client, keyboard) { @@ -268,7 +268,7 @@ class Mouse { class Touchscreen { /** - * @param {Puppeteer.Session} client + * @param {Puppeteer.CDPSession} client * @param {Keyboard} keyboard */ constructor(client, keyboard) { diff --git a/lib/NetworkManager.js b/lib/NetworkManager.js index 64e3b129e28..d2976bbb43d 100644 --- a/lib/NetworkManager.js +++ b/lib/NetworkManager.js @@ -19,7 +19,7 @@ const Multimap = require('./Multimap'); class NetworkManager extends EventEmitter { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {!Puppeteer.FrameManager} frameManager */ constructor(client, frameManager) { @@ -281,7 +281,7 @@ class NetworkManager extends EventEmitter { class Request { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {?string} requestId * @param {string} interceptionId * @param {boolean} allowInterception @@ -479,7 +479,7 @@ helper.tracePublicAPI(Request); class Response { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {!Request} request * @param {number} status * @param {!Object} headers diff --git a/lib/Page.js b/lib/Page.js index 5ac3356e8d7..9599b120dd1 100644 --- a/lib/Page.js +++ b/lib/Page.js @@ -31,17 +31,18 @@ const writeFileAsync = helper.promisify(fs.writeFile); class Page extends EventEmitter { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client + * @param {!Puppeteer.Target} target * @param {boolean} ignoreHTTPSErrors * @param {boolean} appMode * @param {!Puppeteer.TaskQueue} screenshotTaskQueue * @return {!Promise} */ - static async create(client, ignoreHTTPSErrors, appMode, screenshotTaskQueue) { + static async create(client, target, ignoreHTTPSErrors, appMode, screenshotTaskQueue) { await client.send('Page.enable'); const {frameTree} = await client.send('Page.getFrameTree'); - const page = new Page(client, frameTree, ignoreHTTPSErrors, screenshotTaskQueue); + const page = new Page(client, target, frameTree, ignoreHTTPSErrors, screenshotTaskQueue); await Promise.all([ client.send('Page.setLifecycleEventsEnabled', { enabled: true }), @@ -60,14 +61,16 @@ class Page extends EventEmitter { } /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client + * @param {!Puppeteer.Target} target * @param {{frame: Object, childFrames: ?Array}} frameTree * @param {boolean} ignoreHTTPSErrors * @param {!Puppeteer.TaskQueue} screenshotTaskQueue */ - constructor(client, frameTree, ignoreHTTPSErrors, screenshotTaskQueue) { + constructor(client, target, frameTree, ignoreHTTPSErrors, screenshotTaskQueue) { super(); this._client = client; + this._target = target; this._keyboard = new Keyboard(client); this._mouse = new Mouse(client, this._keyboard); this._touchscreen = new Touchscreen(client, this._keyboard); @@ -101,6 +104,13 @@ class Page extends EventEmitter { client.on('Performance.metrics', event => this._emitMetrics(event)); } + /** + * @return {!Puppeteer.Target} + */ + target() { + return this._target; + } + _onTargetCrashed() { this.emit('error', new Error('Page crashed!')); } @@ -505,7 +515,7 @@ class Page extends EventEmitter { return request ? request.response() : null; /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {string} url * @param {string} referrer * @return {!Promise} @@ -691,7 +701,7 @@ class Page extends EventEmitter { * @return {!Promise} */ async _screenshotTask(format, options) { - await this._client.send('Target.activateTarget', {targetId: this._client.targetId()}); + await this._client.send('Target.activateTarget', {targetId: this._target._targetId}); let clip = options.clip ? Object.assign({}, options['clip']) : undefined; if (clip) clip.scale = 1; @@ -785,7 +795,8 @@ class Page extends EventEmitter { } async close() { - await this._client.dispose(); + console.assert(!!this._client._connection, 'Protocol error: Connection closed. Most likely the page has been closed.'); + await this._client._connection.send('Target.closeTarget', {targetId: this._target._targetId}); } /** diff --git a/lib/Tracing.js b/lib/Tracing.js index bf421818dc8..6b9a87e55a4 100644 --- a/lib/Tracing.js +++ b/lib/Tracing.js @@ -22,7 +22,7 @@ const closeAsync = helper.promisify(fs.close); class Tracing { /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client */ constructor(client) { this._client = client; diff --git a/lib/externs.d.ts b/lib/externs.d.ts index dcadfdb25a7..baa6eaaca34 100644 --- a/lib/externs.d.ts +++ b/lib/externs.d.ts @@ -1,5 +1,5 @@ -import { Connection as RealConnection, Session as RealSession } from './Connection.js'; -import {Browser as RealBrowser, TaskQueue as RealTaskQueue} from './Browser.js'; +import { Connection as RealConnection, CDPSession as RealCDPSession } from './Connection.js'; +import {Browser as RealBrowser, TaskQueue as RealTaskQueue, Target as RealTarget} from './Browser.js'; import * as RealPage from './Page.js'; import {Mouse as RealMouse, Keyboard as RealKeyboard, Touchscreen as RealTouchscreen} from './Input.js'; import {Frame as RealFrame, FrameManager as RealFrameManager} from './FrameManager.js'; @@ -10,12 +10,13 @@ import * as child_process from 'child_process'; export as namespace Puppeteer; export class Connection extends RealConnection {} -export class Session extends RealSession {} +export class CDPSession extends RealCDPSession {} export class Mouse extends RealMouse {} export class Keyboard extends RealKeyboard {} export class Touchscreen extends RealTouchscreen {} export class TaskQueue extends RealTaskQueue {} export class Browser extends RealBrowser {} +export class Target extends RealTarget {} export class Frame extends RealFrame {} export class FrameManager extends RealFrameManager {} export class NetworkManager extends RealNetworkManager {} diff --git a/lib/helper.js b/lib/helper.js index 8c9cf38ade0..cd5d038ad6a 100644 --- a/lib/helper.js +++ b/lib/helper.js @@ -83,7 +83,7 @@ class Helper { } /** - * @param {!Puppeteer.Session} client + * @param {!Puppeteer.CDPSession} client * @param {!Object} remoteObject */ static async releaseObject(client, remoteObject) { diff --git a/test/test.js b/test/test.js index e8b99712999..ea8fc86ca7d 100644 --- a/test/test.js +++ b/test/test.js @@ -3474,6 +3474,56 @@ describe('Page', function() { }); }); + describe('Target.createCDPSession', function() { + it('should work', async function({page, server}) { + const client = await page.target().createCDPSession(); + + await Promise.all([ + client.send('Runtime.enable'), + client.send('Runtime.evaluate', { expression: 'window.foo = "bar"' }) + ]); + const foo = await page.evaluate(() => window.foo); + expect(foo).toBe('bar'); + }); + it('should send events', async function({page, server}) { + const client = await page.target().createCDPSession(); + await client.send('Network.enable'); + const events = []; + client.on('Network.requestWillBeSent', event => events.push(event)); + await page.goto(server.EMPTY_PAGE); + expect(events.length).toBe(1); + }); + it('should enable and disable domains independently', async function({page, server}) { + const client = await page.target().createCDPSession(); + await client.send('Runtime.enable'); + await client.send('Debugger.enable'); + // JS coverage enables and then disables Debugger domain. + await page.coverage.startJSCoverage(); + await page.coverage.stopJSCoverage(); + // generate a script in page and wait for the event. + const [event] = await Promise.all([ + waitForEvents(client, 'Debugger.scriptParsed'), + page.evaluate('//# sourceURL=foo.js') + ]); + // expect events to be dispatched. + expect(event.url).toBe('foo.js'); + }); + it('should be able to detach session', async function({page, server}) { + const client = await page.target().createCDPSession(); + await client.send('Runtime.enable'); + const evalResponse = await client.send('Runtime.evaluate', {expression: '1 + 2', returnByValue: true}); + expect(evalResponse.result.value).toBe(3); + await client.detach(); + let error = null; + try { + await client.send('Runtime.evaluate', {expression: '3 + 1', returnByValue: true}); + } catch (e) { + error = e; + } + expect(error.message).toContain('Session closed.'); + }); + }); + describe('JSCoverage', function() { it('should work', async function({page, server}) { await page.coverage.startJSCoverage(); @@ -3656,7 +3706,7 @@ runner.run(); * @param {!EventEmitter} emitter * @param {string} eventName * @param {number=} eventCount - * @return {!Promise} + * @return {!Promise} */ function waitForEvents(emitter, eventName, eventCount = 1) { let fulfill; @@ -3664,12 +3714,12 @@ function waitForEvents(emitter, eventName, eventCount = 1) { emitter.on(eventName, onEvent); return promise; - function onEvent() { + function onEvent(event) { --eventCount; if (eventCount) return; emitter.removeListener(eventName, onEvent); - fulfill(); + fulfill(event); } } diff --git a/utils/doclint/check_public_api/index.js b/utils/doclint/check_public_api/index.js index 3c2eb577b0c..2cc6893acc7 100644 --- a/utils/doclint/check_public_api/index.js +++ b/utils/doclint/check_public_api/index.js @@ -31,7 +31,6 @@ const EXCLUDE_CLASSES = new Set([ 'Multimap', 'NavigatorWatcher', 'NetworkManager', - 'Session', 'TaskQueue', 'WaitTask', ]);