From ab6bc896fd1d54d59337ef83a9466b3614128c9b Mon Sep 17 00:00:00 2001 From: Maja Frydrychowicz Date: Wed, 11 Dec 2019 20:49:46 +0000 Subject: [PATCH] Bug 1597879 - Implement Page.addScriptToEvaluateOnNewDocument; r=remote-protocol-reviewers,whimboo,ato Differential Revision: https://phabricator.services.mozilla.com/D55334 --HG-- extra : moz-landing-system : lando --- remote/domains/ContextObserver.jsm | 5 + remote/domains/content/Page.jsm | 62 ++++++++- remote/test/browser/page/browser.ini | 1 + .../browser_scriptToEvaluateOnNewDocument.js | 121 ++++++++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 remote/test/browser/page/browser_scriptToEvaluateOnNewDocument.js diff --git a/remote/domains/ContextObserver.jsm b/remote/domains/ContextObserver.jsm index ada43a5ed673..3190d11ac760 100644 --- a/remote/domains/ContextObserver.jsm +++ b/remote/domains/ContextObserver.jsm @@ -80,6 +80,10 @@ class ContextObserver { this.emit("context-destroyed", { frameId }); this.emit("frame-navigated", { frameId, window }); this.emit("context-created", { windowId: id, window }); + // Delay script-loaded to allow context cleanup to happen first + Services.tm.dispatchToMainThread(() => { + this.emit("script-loaded"); + }); break; case "pageshow": // `persisted` is true when this is about a page being resurected from BF Cache @@ -89,6 +93,7 @@ class ContextObserver { // XXX(ochameau) we might have to emit FrameNavigate here to properly handle BF Cache // scenario in Page domain events this.emit("context-created", { windowId: id, window }); + this.emit("script-loaded"); break; case "pagehide": diff --git a/remote/domains/content/Page.jsm b/remote/domains/content/Page.jsm index 44e916b73893..d11fcaa55e6f 100644 --- a/remote/domains/content/Page.jsm +++ b/remote/domains/content/Page.jsm @@ -6,14 +6,25 @@ var EXPORTED_SYMBOLS = ["Page"]; +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + const { ContentProcessDomain } = ChromeUtils.import( "chrome://remote/content/domains/ContentProcessDomain.jsm" ); -const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { UnsupportedError } = ChromeUtils.import( "chrome://remote/content/Error.jsm" ); +XPCOMUtils.defineLazyServiceGetter( + this, + "uuidGen", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator" +); + const { LOAD_FLAGS_BYPASS_CACHE, LOAD_FLAGS_BYPASS_PROXY, @@ -26,12 +37,19 @@ class Page extends ContentProcessDomain { this.enabled = false; this.lifecycleEnabled = false; + // script id => { source, worldName } + this.scriptsToEvaluateOnLoad = new Map(); + this.worldsToEvaluateOnLoad = new Set(); this._onFrameNavigated = this._onFrameNavigated.bind(this); + this._onScriptLoaded = this._onScriptLoaded.bind(this); + + this.contextObserver.on("script-loaded", this._onScriptLoaded); } destructor() { this.setLifecycleEventsEnabled({ enabled: false }); + this.contextObserver.off("script-loaded", this._onScriptLoaded); this.disable(); super.destructor(); @@ -124,7 +142,33 @@ class Page extends ContentProcessDomain { }; } - addScriptToEvaluateOnNewDocument() {} + /** + * Enqueues given script to be evaluated in every frame upon creation + * + * If `worldName` is specified, creates an execution context with the given name + * and evaluates given script in it. + * + * At this time, queued scripts do not get evaluated, hence `source` is marked as + * "unsupported". + * + * @param {Object} options + * @param {string} options.source (not supported) + * @param {string=} options.worldName + * @return {string} Page.ScriptIdentifier + */ + addScriptToEvaluateOnNewDocument(options = {}) { + const { source, worldName } = options; + if (worldName) { + this.worldsToEvaluateOnLoad.add(worldName); + } + const identifier = uuidGen + .generateUUID() + .toString() + .slice(1, -1); + this.scriptsToEvaluateOnLoad.set(identifier, { worldName, source }); + + return { identifier }; + } /** * Creates an isolated world for the given frame. @@ -186,6 +230,20 @@ class Page extends ContentProcessDomain { }); } + _onScriptLoaded(name) { + const Runtime = this.session.domains.get("Runtime"); + for (const world of this.worldsToEvaluateOnLoad) { + Runtime._onContextCreated("context-created", { + windowId: this.content.windowUtils.currentInnerWindowID, + window: this.content, + isDefault: false, + contextName: world, + contextType: "isolated", + }); + } + // TODO evaluate each onNewDoc script in the appropriate world + } + emitLifecycleEvent(frameId, loaderId, name, timestamp) { if (this.lifecycleEnabled) { this.emit("Page.lifecycleEvent", { frameId, loaderId, name, timestamp }); diff --git a/remote/test/browser/page/browser.ini b/remote/test/browser/page/browser.ini index 50c23e98318b..1ca8865236fd 100644 --- a/remote/test/browser/page/browser.ini +++ b/remote/test/browser/page/browser.ini @@ -16,5 +16,6 @@ support-files = [browser_javascriptDialog_otherTarget.js] [browser_javascriptDialog_prompt.js] [browser_lifecycleEvent.js] +[browser_scriptToEvaluateOnNewDocument.js] [browser_reload.js] [browser_runtimeEvents.js] diff --git a/remote/test/browser/page/browser_scriptToEvaluateOnNewDocument.js b/remote/test/browser/page/browser_scriptToEvaluateOnNewDocument.js new file mode 100644 index 000000000000..4119deeda645 --- /dev/null +++ b/remote/test/browser/page/browser_scriptToEvaluateOnNewDocument.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test Page.addScriptToEvaluateOnNewDocument and Page.removeScriptToEvaluateOnNewDocument + +const DOC = toDataURL("default-test-page"); +const WORLD = "testWorld"; + +// TODO Bug 1601695 - Add support for `source` parameter +add_task(async function addScript({ Page, Runtime }) { + await loadURL(DOC); + const { identifier: id1 } = await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + }); + is(typeof id1, "string", "Script id should be a string"); + ok(id1.length > 0, "Script id is non-empty"); + const { identifier: id2 } = await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + }); + ok(id2.length > 0, "Script id is non-empty"); + isnot(id1, id2, "Two scripts should have different ids"); + await Runtime.enable(); + // flush event for DOC default context + await Runtime.executionContextCreated(); + await checkIsolatedContextAfterLoad(Runtime, toDataURL("

Hello"), []); +}); + +add_task(async function addScriptAfterNavigation({ Page }) { + await loadURL(DOC); + const { identifier: id1 } = await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + }); + is(typeof id1, "string", "Script id should be a string"); + ok(id1.length > 0, "Script id is non-empty"); + await loadURL(toDataURL("

Hello")); + const { identifier: id2 } = await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 2;", + }); + ok(id2.length > 0, "Script id is non-empty"); + isnot(id1, id2, "Two scripts should have different ids"); +}); + +add_task(async function addWithIsolatedWorldAndNavigate({ Page, Runtime }) { + await Runtime.enable(); + const { frameId } = await Page.navigate({ url: DOC }); + await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + worldName: WORLD, + }); + const isolatedId = await Page.createIsolatedWorld({ + frameId, + worldName: WORLD, + grantUniversalAccess: true, + }); + const contexts = await checkIsolatedContextAfterLoad( + Runtime, + toDataURL("

Next") + ); + isnot(contexts[1].id, isolatedId, "The context has a new id"); +}); + +add_task(async function addWithIsolatedWorldNavigateTwice({ Page, Runtime }) { + await Runtime.enable(); + await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + worldName: WORLD, + }); + await checkIsolatedContextAfterLoad(Runtime, DOC); + await checkIsolatedContextAfterLoad(Runtime, toDataURL("

Hello")); +}); + +add_task(async function addTwoScriptsWithIsolatedWorld({ Page, Runtime }) { + await Runtime.enable(); + const names = [WORLD, "A_whole_new_world"]; + await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 1;", + worldName: names[0], + }); + await Page.addScriptToEvaluateOnNewDocument({ + source: "1 + 8;", + worldName: names[1], + }); + await checkIsolatedContextAfterLoad(Runtime, DOC, names); +}); + +async function checkIsolatedContextAfterLoad(Runtime, url, names = [WORLD]) { + // At least the default context will get created + const expected = names.length + 1; + const contextsCreated = new Promise(resolve => { + const ctx = []; + const unsubscribe = Runtime.executionContextCreated(payload => { + ctx.push(payload.context); + info( + `Runtime.executionContextCreated: ${payload.context.auxData.type}` + + `\n\turl ${payload.context.origin}` + ); + if (ctx.length > expected) { + unsubscribe(); + resolve(ctx); + } + }); + timeoutPromise(1000).then(() => { + unsubscribe(); + resolve(ctx); + }); + }); + await loadURL(url); + const contexts = await contextsCreated; + is(contexts.length, expected, "Expected number of contexts got created"); + is(contexts[0].auxData.isDefault, true, "Got default context"); + is(contexts[0].auxData.type, "default", "Got default context"); + is(contexts[0].name, "", "Get context with empty name"); + names.forEach((name, index) => { + is(contexts[index + 1].name, name, "Get context with expected name"); + is(contexts[index + 1].auxData.isDefault, false, "Got isolated context"); + is(contexts[index + 1].auxData.type, "isolated", "Got isolated context"); + }); + return contexts; +}