From 231a2be9710f4f918b3dd7f388bf5976d80d826f Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Thu, 9 Aug 2018 14:57:08 -0700 Subject: [PATCH] feat: expose frame's execution contexts (#3048) This patch exposes frame's execution contexts, making it possible to debug extension's content scripts. This is a resurrected #2812. --- docs/api.md | 51 ++++++++++++++-- lib/ExecutionContext.js | 16 +++++ lib/FrameManager.js | 59 ++++++++++++++----- lib/Page.js | 4 ++ .../assets/simple-extension/content-script.js | 3 + test/assets/simple-extension/manifest.json | 14 +++-- test/headful.spec.js | 22 +++++++ test/page.spec.js | 5 ++ test/utils.js | 11 +++- 9 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 test/assets/simple-extension/content-script.js diff --git a/docs/api.md b/docs/api.md index 11389887..a391df72 100644 --- a/docs/api.md +++ b/docs/api.md @@ -58,6 +58,8 @@ Next Release: **Aug 9, 2018** * [event: 'dialog'](#event-dialog) * [event: 'domcontentloaded'](#event-domcontentloaded) * [event: 'error'](#event-error) + * [event: 'executioncontextcreated'](#event-executioncontextcreated) + * [event: 'executioncontextdestroyed'](#event-executioncontextdestroyed) * [event: 'frameattached'](#event-frameattached) * [event: 'framedetached'](#event-framedetached) * [event: 'framenavigated'](#event-framenavigated) @@ -180,6 +182,7 @@ Next Release: **Aug 9, 2018** * [frame.evaluate(pageFunction, ...args)](#frameevaluatepagefunction-args) * [frame.evaluateHandle(pageFunction, ...args)](#frameevaluatehandlepagefunction-args) * [frame.executionContext()](#frameexecutioncontext) + * [frame.executionContexts()](#frameexecutioncontexts) * [frame.focus(selector)](#framefocusselector) * [frame.hover(selector)](#framehoverselector) * [frame.isDetached()](#frameisdetached) @@ -199,6 +202,8 @@ Next Release: **Aug 9, 2018** * [executionContext.evaluate(pageFunction, ...args)](#executioncontextevaluatepagefunction-args) * [executionContext.evaluateHandle(pageFunction, ...args)](#executioncontextevaluatehandlepagefunction-args) * [executionContext.frame()](#executioncontextframe) + * [executionContext.isDefault()](#executioncontextisdefault) + * [executionContext.name()](#executioncontextname) * [executionContext.queryObjects(prototypeHandle)](#executioncontextqueryobjectsprototypehandle) - [class: JSHandle](#class-jshandle) * [jsHandle.asElement()](#jshandleaselement) @@ -770,6 +775,16 @@ Emitted when the page crashes. > **NOTE** `error` event has a special meaning in Node, see [error events](https://nodejs.org/api/events.html#events_error_events) for details. +#### event: 'executioncontextcreated' +- <[ExecutionContext]> + +Emitted whenever an execution context is created in one of the page's frames. + +#### event: 'executioncontextdestroyed' +- <[ExecutionContext]> + +Emitted whenever an execution context is removed from one of the page's frames. + #### event: 'frameattached' - <[Frame]> @@ -2200,7 +2215,14 @@ await resultHandle.dispose(); #### frame.executionContext() -- returns: <[Promise]<[ExecutionContext]>> Execution context associated with this frame. +- returns: <[Promise]<[ExecutionContext]>> + +Returns promise that resolves to the frame's default execution context. + +#### frame.executionContexts() +- returns: <[Array]<[ExecutionContext]>> + +Returns all execution contexts associated with this frame. #### frame.focus(selector) - `selector` <[string]> A [selector] of an element to focus. If there are multiple elements satisfying the selector, the first will be focused. @@ -2398,9 +2420,16 @@ puppeteer.launch().then(async browser => { ### class: ExecutionContext -The class represents a context for JavaScript execution. Examples of JavaScript contexts are: -- each [frame](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) has a separate execution context -- all kind of [workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) have their own contexts +The class represents a context for JavaScript execution. Page might have many execution contexts: +- each [frame](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) has "default" execution context that is + always created after frame is attached to DOM. This context could be awaited with the [`frame.executionContext()`](#frameexecutioncontext) method. +- [extensions](https://developer.chrome.com/extensions)'s content scripts create additional execution contexts. These execution + contexts can be pulled with [`frame.executionContexts()`](#frameexecutioncontexts) method. + +Execution context lifecycle could be observed using Page's ['executioncontextcreated'](#event-executioncontextcreated) and +['executioncontextdestroyed'](#event-executioncontextdestroyed) events. + +Besides pages, execution contexts can be found in all kind of [workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API). #### executionContext.evaluate(pageFunction, ...args) - `pageFunction` <[function]|[string]> Function to be evaluated in `executionContext` @@ -2466,6 +2495,20 @@ await resultHandle.dispose(); > **NOTE** Not every execution context is associated with a frame. For example, workers and extensions have execution contexts that are not associated with frames. + +#### executionContext.isDefault() +- returns: <[boolean]> + +Returns `true` if the execution context is a frame's main execution context; returns `false` otherwise. + + +#### executionContext.name() +- returns: <[string]> + +Returns execution context name, if any. If this execution context is associated with Chrome Extension's content +script, this will return extension's name. + + #### executionContext.queryObjects(prototypeHandle) - `prototypeHandle` <[JSHandle]> A handle to the object prototype. - returns: <[JSHandle]> A handle to an array of objects with this prototype diff --git a/lib/ExecutionContext.js b/lib/ExecutionContext.js index e605cc36..de9e6aea 100644 --- a/lib/ExecutionContext.js +++ b/lib/ExecutionContext.js @@ -30,9 +30,25 @@ class ExecutionContext { this._client = client; this._frame = frame; this._contextId = contextPayload.id; + this._isDefault = contextPayload.auxData ? !!contextPayload.auxData['isDefault'] : false; + this._name = contextPayload.name; this._objectHandleFactory = objectHandleFactory; } + /** + * @return {string} + */ + name() { + return this._name; + } + + /** + * @return {boolean} + */ + isDefault() { + return this._isDefault; + } + /** * @return {?Puppeteer.Frame} */ diff --git a/lib/FrameManager.js b/lib/FrameManager.js index 9b43ad37..8e1f2381 100644 --- a/lib/FrameManager.js +++ b/lib/FrameManager.js @@ -180,21 +180,14 @@ class FrameManager extends EventEmitter { } _onExecutionContextCreated(contextPayload) { - const frameId = contextPayload.auxData && contextPayload.auxData.isDefault ? contextPayload.auxData.frameId : null; - const frame = frameId ? this._frames.get(frameId) : null; + const frameId = contextPayload.auxData ? contextPayload.auxData.frameId : null; + const frame = this._frames.get(frameId) || null; /** @type {!ExecutionContext} */ const context = new ExecutionContext(this._client, contextPayload, obj => this.createJSHandle(context, obj), frame); this._contextIdToContext.set(contextPayload.id, context); if (frame) - frame._setDefaultContext(context); - } - - /** - * @param {!ExecutionContext} context - */ - _removeContext(context) { - if (context.frame()) - context.frame()._setDefaultContext(null); + frame._addExecutionContext(context); + this.emit(FrameManager.Events.ExecutionContextCreated, context); } /** @@ -205,13 +198,20 @@ class FrameManager extends EventEmitter { if (!context) return; this._contextIdToContext.delete(executionContextId); - this._removeContext(context); + if (context.frame()) + context.frame()._removeExecutionContext(context); + this.emit(FrameManager.Events.ExecutionContextDestroyed, context); } _onExecutionContextsCleared() { - for (const context of this._contextIdToContext.values()) - this._removeContext(context); + const contexts = Array.from(this._contextIdToContext.values()); this._contextIdToContext.clear(); + for (const context of contexts) { + if (context.frame()) + context.frame()._removeExecutionContext(context); + } + for (const context of contexts) + this.emit(FrameManager.Events.ExecutionContextDestroyed, context); } /** @@ -253,7 +253,9 @@ FrameManager.Events = { FrameNavigated: 'framenavigated', FrameDetached: 'framedetached', LifecycleEvent: 'lifecycleevent', - FrameNavigatedWithinDocument: 'framenavigatedwithindocument' + FrameNavigatedWithinDocument: 'framenavigatedwithindocument', + ExecutionContextCreated: 'executioncontextcreated', + ExecutionContextDestroyed: 'executioncontextdestroyed', }; /** @@ -278,6 +280,8 @@ class Frame { this._contextResolveCallback = null; this._setDefaultContext(null); + this._executionContexts = new Set(); + /** @type {!Set} */ this._waitTasks = new Set(); this._loaderId = ''; @@ -290,6 +294,24 @@ class Frame { this._parentFrame._childFrames.add(this); } + /** + * @param {!ExecutionContext} context + */ + _addExecutionContext(context) { + this._executionContexts.add(context); + if (context.isDefault()) + this._setDefaultContext(context); + } + + /** + * @param {!ExecutionContext} context + */ + _removeExecutionContext(context) { + this._executionContexts.delete(context); + if (context.isDefault()) + this._setDefaultContext(null); + } + /** * @param {?ExecutionContext} context */ @@ -314,6 +336,13 @@ class Frame { return this._contextPromise; } + /** + * @return {!Array} + */ + executionContexts() { + return Array.from(this._executionContexts); + } + /** * @param {function()|string} pageFunction * @param {!Array<*>} args diff --git a/lib/Page.js b/lib/Page.js index 0e2c2fb3..eb1cdf1a 100644 --- a/lib/Page.js +++ b/lib/Page.js @@ -121,6 +121,8 @@ class Page extends EventEmitter { 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)); + this._frameManager.on(FrameManager.Events.ExecutionContextCreated, event => this.emit(Page.Events.ExecutionContextCreated, event)); + this._frameManager.on(FrameManager.Events.ExecutionContextDestroyed, event => this.emit(Page.Events.ExecutionContextDestroyed, event)); this._networkManager.on(NetworkManager.Events.Request, event => this.emit(Page.Events.Request, event)); this._networkManager.on(NetworkManager.Events.Response, event => this.emit(Page.Events.Response, event)); @@ -1128,6 +1130,8 @@ Page.Events = { Metrics: 'metrics', WorkerCreated: 'workercreated', WorkerDestroyed: 'workerdestroyed', + ExecutionContextCreated: 'executioncontextcreated', + ExecutionContextDestroyed: 'executioncontextdestroyed', }; diff --git a/test/assets/simple-extension/content-script.js b/test/assets/simple-extension/content-script.js new file mode 100644 index 00000000..965f99fd --- /dev/null +++ b/test/assets/simple-extension/content-script.js @@ -0,0 +1,3 @@ +console.log('hey from the content-script'); +self.thisIsTheContentScript = true; + diff --git a/test/assets/simple-extension/manifest.json b/test/assets/simple-extension/manifest.json index 649bce40..da2cd082 100644 --- a/test/assets/simple-extension/manifest.json +++ b/test/assets/simple-extension/manifest.json @@ -1,12 +1,14 @@ { "name": "Simple extension", "version": "0.1", - "app": { - "background": { - "scripts": ["index.js"] - } + "background": { + "scripts": ["index.js"] }, - "permissions": ["background"], - + "content_scripts": [{ + "matches": [""], + "css": [], + "js": ["content-script.js"] + }], + "permissions": ["background", "activeTab"], "manifest_version": 2 } diff --git a/test/headful.spec.js b/test/headful.spec.js index 798d5ea1..bd3ad8f8 100644 --- a/test/headful.spec.js +++ b/test/headful.spec.js @@ -16,6 +16,7 @@ const path = require('path'); const os = require('os'); +const {waitEvent} = require('./utils.js'); const TMP_FOLDER = path.join(os.tmpdir(), 'pptr_tmp_folder-'); @@ -115,6 +116,27 @@ module.exports.addTests = function({testRunner, expect, PROJECT_ROOT, defaultBro ]); await browser.close(); }); + it('should report content script execution contexts', async({server}) => { + const browserWithExtension = await puppeteer.launch(extensionOptions); + const page = await browserWithExtension.newPage(); + const [extensionContext, msg] = await Promise.all([ + waitEvent(page, 'executioncontextcreated', context => !context.isDefault()), + waitEvent(page, 'console'), + page.goto(server.EMPTY_PAGE) + ]); + expect(msg.text()).toBe('hey from the content-script'); + expect(page.mainFrame().executionContexts().length).toBe(2); + expect(extensionContext.frame()).toBe(page.mainFrame()); + expect(extensionContext.name()).toBe('Simple extension'); + expect(await extensionContext.evaluate('thisIsTheContentScript')).toBe(true); + + const [destroyedContext] = await Promise.all([ + waitEvent(page, 'executioncontextdestroyed', context => !context.isDefault()), + page.reload() + ]); + expect(destroyedContext).toBe(extensionContext); + await browserWithExtension.close(); + }); }); }; diff --git a/test/page.spec.js b/test/page.spec.js index 485e1ed9..ee80272a 100644 --- a/test/page.spec.js +++ b/test/page.spec.js @@ -77,6 +77,11 @@ module.exports.addTests = function({testRunner, expect, puppeteer, DeviceDescrip const result = await page.evaluate(() => 7 * 3); expect(result).toBe(21); }); + it('should have nice default execution context', async({page, server}) => { + const executionContext = await page.mainFrame().executionContext(); + expect(executionContext.name()).toBe(''); + expect(executionContext.isDefault()).toBe(true); + }); it('should throw when evaluation triggers reload', async({page, server}) => { let error = null; await page.evaluate(() => { diff --git a/test/utils.js b/test/utils.js index eaadca62..3aae5f8d 100644 --- a/test/utils.js +++ b/test/utils.js @@ -78,7 +78,14 @@ const utils = module.exports = { * @param {string} eventName * @return {!Promise} */ - waitEvent: function(emitter, eventName) { - return new Promise(fulfill => emitter.once(eventName, fulfill)); + waitEvent: function(emitter, eventName, predicate = () => true) { + return new Promise(fulfill => { + emitter.on(eventName, function listener(event) { + if (!predicate(event)) + return; + emitter.removeListener(eventName, listener); + fulfill(event); + }); + }); }, };