diff --git a/.eslintignore b/.eslintignore index 192f0a673894..b2213566789f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -118,8 +118,10 @@ devtools/server/actors/** !devtools/server/actors/styles.js !devtools/server/actors/webbrowser.js !devtools/server/actors/webextension.js +!devtools/server/actors/webextension-inspected-window.js devtools/server/performance/** devtools/server/tests/browser/** +!devtools/server/tests/browser/browser_webextension_inspected_window.js devtools/server/tests/mochitest/** devtools/server/tests/unit/** devtools/shared/*.js diff --git a/devtools/server/actors/moz.build b/devtools/server/actors/moz.build index 5980876e21c6..92ca1bd2a52c 100644 --- a/devtools/server/actors/moz.build +++ b/devtools/server/actors/moz.build @@ -63,6 +63,7 @@ DevToolsModules( 'webaudio.js', 'webbrowser.js', 'webconsole.js', + 'webextension-inspected-window.js', 'webextension.js', 'webgl.js', 'worker.js', diff --git a/devtools/server/actors/webextension-inspected-window.js b/devtools/server/actors/webextension-inspected-window.js new file mode 100644 index 000000000000..bf55cf7856cd --- /dev/null +++ b/devtools/server/actors/webextension-inspected-window.js @@ -0,0 +1,469 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const protocol = require("devtools/shared/protocol"); + +const {Ci, Cu, Cr} = require("chrome"); + +const Services = require("Services"); + +const { + XPCOMUtils, +} = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}); + +const { + webExtensionInspectedWindowSpec, +} = require("devtools/shared/specs/webextension-inspected-window"); + +function CustomizedReload(params) { + this.docShell = params.tabActor.window + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + this.docShell.QueryInterface(Ci.nsIWebProgress); + + this.inspectedWindowEval = params.inspectedWindowEval; + this.callerInfo = params.callerInfo; + + this.ignoreCache = params.ignoreCache; + this.injectedScript = params.injectedScript; + this.userAgent = params.userAgent; + + this.customizedReloadWindows = new WeakSet(); +} + +CustomizedReload.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + Ci.nsISupports]), + get window() { + return this.docShell.DOMWindow; + }, + + get webNavigation() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation); + }, + + start() { + if (!this.waitForReloadCompleted) { + this.waitForReloadCompleted = new Promise((resolve, reject) => { + this.resolveReloadCompleted = resolve; + this.rejectReloadCompleted = reject; + + if (this.userAgent) { + this.docShell.customUserAgent = this.userAgent; + } + + let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + + if (this.ignoreCache) { + reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + } + + try { + if (this.injectedScript) { + // Listen to the newly created document elements only if there is an + // injectedScript to evaluate. + Services.obs.addObserver(this, "document-element-inserted", false); + } + + // Watch the loading progress and clear the current CustomizedReload once the + // page has been reloaded (or if its reloading has been interrupted). + this.docShell.addProgressListener(this, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT); + + this.webNavigation.reload(reloadFlags); + } catch (err) { + // Cancel the injected script listener if the reload fails + // (which will also report the error by rejecting the promise). + this.stop(err); + } + }); + } + + return this.waitForReloadCompleted; + }, + + observe(subject, topic, data) { + if (topic !== "document-element-inserted") { + return; + } + + const document = subject; + const window = document && document.defaultView; + + // Filter out non interesting documents. + if (!document || !document.location || !window) { + return; + } + + let subjectDocShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + + // Keep track of the set of window objects where we are going to inject + // the injectedScript: the top level window and all its descendant + // that are still of type content (filtering out loaded XUL pages, if any). + if (window == this.window) { + this.customizedReloadWindows.add(window); + } else if (subjectDocShell.sameTypeParent) { + let parentWindow = subjectDocShell.sameTypeParent + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + if (parentWindow && this.customizedReloadWindows.has(parentWindow)) { + this.customizedReloadWindows.add(window); + } + } + + if (this.customizedReloadWindows.has(window)) { + const { + apiErrorResult + } = this.inspectedWindowEval(this.callerInfo, this.injectedScript, {}, window); + + // Log only apiErrorResult, because no one is waiting for the + // injectedScript result, and any exception is going to be logged + // in the inspectedWindow webconsole. + if (apiErrorResult) { + console.error( + "Unexpected Error in injectedScript during inspectedWindow.reload for", + `${this.callerInfo.url}:${this.callerInfo.lineNumber}`, + apiErrorResult + ); + } + } + }, + + onStateChange(webProgress, request, state, status) { + if (webProgress.DOMWindow !== this.window) { + return; + } + + if (state & Ci.nsIWebProgressListener.STATE_STOP) { + if (status == Cr.NS_BINDING_ABORTED) { + // The customized reload has been interrupted and we can clear + // the CustomizedReload and reject the promise. + const url = this.window.location.href; + this.stop(new Error( + `devtools.inspectedWindow.reload on ${url} has been interrupted` + )); + } else { + // Once the top level frame has been loaded, we can clear the customized reload + // and resolve the promise. + this.stop(); + } + } + }, + + stop(error) { + if (this.stopped) { + return; + } + + this.docShell.removeProgressListener(this); + + if (this.injectedScript) { + Services.obs.removeObserver(this, "document-element-inserted", false); + } + + // Reset the customized user agent. + if (this.userAgent && this.docShell.customUserAgent == this.userAgent) { + this.docShell.customUserAgent = null; + } + + if (error) { + this.rejectReloadCompleted(error); + } else { + this.resolveReloadCompleted(); + } + + this.stopped = true; + } +}; + +var WebExtensionInspectedWindowActor = protocol.ActorClassWithSpec( + webExtensionInspectedWindowSpec, + { + /** + * Created the WebExtension InspectedWindow actor + */ + initialize(conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + }, + + destroy(conn) { + protocol.Actor.prototype.destroy.call(this, conn); + if (this.customizedReload) { + this.customizedReload.stop( + new Error("WebExtensionInspectedWindowActor destroyed") + ); + delete this.customizedReload; + } + + if (this._dbg) { + this._dbg.enabled = false; + delete this._dbg; + } + }, + + isSystemPrincipal(window) { + const principal = window.document.nodePrincipal; + return Services.scriptSecurityManager.isSystemPrincipal(principal); + }, + + get dbg() { + if (this._dbg) { + return this._dbg; + } + + this._dbg = this.tabActor.makeDebugger(); + return this._dbg; + }, + + get window() { + return this.tabActor.window; + }, + + get webNavigation() { + return this.tabActor.webNavigation; + }, + + /** + * Reload the target tab, optionally bypass cache, customize the userAgent and/or + * inject a script in targeted document or any of its sub-frame. + * + * @param {webExtensionCallerInfo} callerInfo + * the addonId and the url (the addon base url or the url of the actual caller + * filename and lineNumber) used to log useful debugging information in the + * produced error logs and eval stack trace. + * + * @param {webExtensionReloadOptions} options + * used to optionally enable the reload customizations. + * @param {boolean|undefined} options.ignoreCache + * enable/disable the cache bypass headers. + * @param {string|undefined} options.userAgent + * customize the userAgent during the page reload. + * @param {string|undefined} options.injectedScript + * evaluate the provided javascript code in the top level and every sub-frame + * created during the page reload, before any other script in the page has been + * executed. + */ + reload(callerInfo, {ignoreCache, userAgent, injectedScript}) { + if (this.isSystemPrincipal(this.window)) { + console.error("Ignored inspectedWindow.reload on system principal target for " + + `${callerInfo.url}:${callerInfo.lineNumber}`); + return {}; + } + + const delayedReload = () => { + // This won't work while the browser is shutting down and we don't really + // care. + if (Services.startup.shuttingDown) { + return; + } + + if (injectedScript || userAgent) { + if (this.customizedReload) { + // TODO(rpl): check what chrome does, and evaluate if queue the new reload + // after the current one has been completed. + console.error( + "Reload already in progress. Ignored inspectedWindow.reload for " + + `${callerInfo.url}:${callerInfo.lineNumber}` + ); + return; + } + + try { + this.customizedReload = new CustomizedReload({ + tabActor: this.tabActor, + inspectedWindowEval: this.eval.bind(this), + callerInfo, injectedScript, userAgent, ignoreCache, + }); + + this.customizedReload.start() + .then(() => { + delete this.customizedReload; + }) + .catch(err => { + delete this.customizedReload; + throw err; + }); + } catch (err) { + // Cancel the customized reload (if any) on exception during the + // reload setup. + if (this.customizedReload) { + this.customizedReload.stop(err); + } + + throw err; + } + } else { + // If there is no custom user agent and/or injected script, then + // we can reload the target without subscribing any observer/listener. + let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + if (ignoreCache) { + reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + } + this.webNavigation.reload(reloadFlags); + } + }; + + // Execute the reload in a dispatched runnable, so that we can + // return the reply to the caller before the reload is actually + // started. + Services.tm.currentThread.dispatch(delayedReload, 0); + + return {}; + }, + + /** + * Evaluate the provided javascript code in a target window (that is always the + * tabActor window when called through RDP protocol, or the passed customTargetWindow + * when called directly from the CustomizedReload instances). + * + * @param {webExtensionCallerInfo} callerInfo + * the addonId and the url (the addon base url or the url of the actual caller + * filename and lineNumber) used to log useful debugging information in the + * produced error logs and eval stack trace. + * + * @param {string} expression + * the javascript code to be evaluated in the target window + * + * @param {webExtensionEvalOptions} evalOptions + * used to optionally enable the eval customizations. + * NOTE: none of the eval options is currently implemented, they will be already + * reported as unsupported by the WebExtensions schema validation wrappers, but + * an additional level of error reporting is going to be applied here, so that + * if the server and the client have different ideas of which option is supported + * the eval call result will contain detailed informations (in the format usually + * expected for errors not raised in the evaluated javascript code). + * + * @param {DOMWindow|undefined} customTargetWindow + * Used in the CustomizedReload instances to evaluate the `injectedScript` + * javascript code in every sub-frame of the target window during the tab reload. + * NOTE: this parameter is not part of the RDP protocol exposed by this actor, when + * it is called over the remote debugging protocol the target window is always + * `tabActor.window`. + */ + eval(callerInfo, expression, options, customTargetWindow) { + const window = customTargetWindow || this.window; + + if (Object.keys(options).length > 0) { + return { + exceptionInfo: { + isError: true, + code: "E_PROTOCOLERROR", + description: "Inspector protocol error: %s", + details: [ + "The inspectedWindow.eval options are currently not supported", + ], + }, + }; + } + + if (!window) { + return { + exceptionInfo: { + isError: true, + code: "E_PROTOCOLERROR", + description: "Inspector protocol error: %s", + details: [ + "The target window is not defined. inspectedWindow.eval not executed.", + ], + }, + }; + } + + if (this.isSystemPrincipal(window)) { + // On denied JS evaluation, report it using the same data format + // used in the corresponding chrome API method to report issues that are + // not exceptions raised in the evaluated javascript code. + return { + exceptionInfo: { + isError: true, + code: "E_PROTOCOLERROR", + description: "Inspector protocol error: %s", + details: [ + "This target has a system principal. inspectedWindow.eval denied.", + ], + }, + }; + } + + const dbgWindow = this.dbg.makeGlobalObjectReference(window); + + let evalCalledFrom = callerInfo.url; + if (callerInfo.lineNumber) { + evalCalledFrom += `:${callerInfo.lineNumber}`; + } + // TODO(rpl): add $0 and inspect(...) bindings (Bug 1300590) + const result = dbgWindow.executeInGlobalWithBindings(expression, {}, { + url: `debugger eval called from ${evalCalledFrom} - eval code`, + }); + + let evalResult; + + if (result) { + if ("return" in result) { + evalResult = result.return; + } else if ("yield" in result) { + evalResult = result.yield; + } else if ("throw" in result) { + const throwErr = result.throw; + + // XXXworkers: Calling unsafeDereference() returns an object with no + // toString method in workers. See Bug 1215120. + const unsafeDereference = throwErr && (typeof throwErr === "object") && + throwErr.unsafeDereference(); + const message = unsafeDereference && unsafeDereference.toString ? + unsafeDereference.toString() : String(throwErr); + const stack = unsafeDereference && unsafeDereference.stack ? + unsafeDereference.stack : null; + + return { + exceptionInfo: { + isException: true, + value: `${message}\n\t${stack}`, + }, + }; + } + } else { + // TODO(rpl): can the result of executeInGlobalWithBinding be null or + // undefined? (which means that it is not a return, a yield or a throw). + console.error("Unexpected empty inspectedWindow.eval result for", + `${callerInfo.url}:${callerInfo.lineNumber}`); + } + + if (evalResult) { + try { + if (evalResult && typeof evalResult === "object") { + evalResult = evalResult.unsafeDereference(); + } + evalResult = JSON.parse(JSON.stringify(evalResult)); + } catch (err) { + // The evaluation result cannot be sent over the RDP Protocol, + // report it as with the same data format used in the corresponding + // chrome API method. + return { + exceptionInfo: { + isError: true, + code: "E_PROTOCOLERROR", + description: "Inspector protocol error: %s", + details: [ + String(err), + ], + }, + }; + } + } + + return {value: evalResult}; + } + } +); + +exports.WebExtensionInspectedWindowActor = WebExtensionInspectedWindowActor; diff --git a/devtools/server/main.js b/devtools/server/main.js index 45cb7fde66b4..d7a6e4f1c77f 100644 --- a/devtools/server/main.js +++ b/devtools/server/main.js @@ -569,6 +569,11 @@ var DebuggerServer = { constructor: "EmulationActor", type: { tab: true } }); + this.registerModule("devtools/server/actors/webextension-inspected-window", { + prefix: "webExtensionInspectedWindow", + constructor: "WebExtensionInspectedWindowActor", + type: { tab: true } + }); }, /** diff --git a/devtools/server/tests/browser/browser.ini b/devtools/server/tests/browser/browser.ini index b7929e2b0bae..3ca89fe16b6e 100644 --- a/devtools/server/tests/browser/browser.ini +++ b/devtools/server/tests/browser/browser.ini @@ -9,6 +9,7 @@ support-files = doc_force_gc.html doc_innerHTML.html doc_perf.html + inspectedwindow-reload-target.sjs navigate-first.html navigate-second.html storage-cookies-same-name.html @@ -97,3 +98,4 @@ skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still di [browser_directorscript_actors.js] skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S [browser_register_actor.js] +[browser_webextension_inspected_window.js] \ No newline at end of file diff --git a/devtools/server/tests/browser/browser_webextension_inspected_window.js b/devtools/server/tests/browser/browser_webextension_inspected_window.js new file mode 100644 index 000000000000..1a21e08fb4fb --- /dev/null +++ b/devtools/server/tests/browser/browser_webextension_inspected_window.js @@ -0,0 +1,364 @@ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + WebExtensionInspectedWindowFront +} = require("devtools/shared/fronts/webextension-inspected-window"); + +const TEST_RELOAD_URL = `${MAIN_DOMAIN}/inspectedwindow-reload-target.sjs`; + +const FAKE_CALLER_INFO = { + url: "moz-extension://fake-webextension-uuid/fake-caller-script.js", + lineNumber: 1, + addonId: "fake-webextension-uuid", +}; + +function* setup(pageUrl) { + yield addTab(pageUrl); + initDebuggerServer(); + + const client = new DebuggerClient(DebuggerServer.connectPipe()); + const form = yield connectDebuggerClient(client); + + const [, tabClient] = yield client.attachTab(form.actor); + + const [, consoleClient] = yield client.attachConsole(form.consoleActor, []); + + const inspectedWindowFront = new WebExtensionInspectedWindowFront(client, form); + + return { + client, form, + tabClient, consoleClient, + inspectedWindowFront, + }; +} + +function* teardown({client}) { + yield client.close(); + DebuggerServer.destroy(); + gBrowser.removeCurrentTab(); +} + +function waitForNextTabNavigated(client) { + return new Promise(resolve => { + client.addListener("tabNavigated", function tabNavigatedListener(evt, pkt) { + if (pkt.state == "stop" && pkt.isFrameSwitching == false) { + client.removeListener("tabNavigated", tabNavigatedListener); + resolve(); + } + }); + }); +} + +function consoleEvalJS(consoleClient, jsCode) { + return new Promise(resolve => { + consoleClient.evaluateJS(jsCode, resolve); + }); +} + +// Script used as the injectedScript option in the inspectedWindow.reload tests. +function injectedScript() { + if (!window.pageScriptExecutedFirst) { + window.addEventListener("DOMContentLoaded", function listener() { + window.removeEventListener("DOMContentLoaded", listener); + if (document.querySelector("pre")) { + document.querySelector("pre").textContent = "injected script executed first"; + } + }); + } +} + +// Script evaluated in the target tab, to collect the results of injectedScript +// evaluation in the inspectedWindow.reload tests. +function collectEvalResults() { + let results = []; + let iframeDoc = document; + + while (iframeDoc) { + if (iframeDoc.querySelector("pre")) { + results.push(iframeDoc.querySelector("pre").textContent); + } + const iframe = iframeDoc.querySelector("iframe"); + iframeDoc = iframe ? iframe.contentDocument : null; + } + return JSON.stringify(results); +} + +add_task(function* test_successfull_inspectedWindowEval_result() { + const {client, inspectedWindowFront} = yield setup(MAIN_DOMAIN); + const result = yield inspectedWindowFront.eval(FAKE_CALLER_INFO, "window.location", {}); + + ok(result.value, "Got a result from inspectedWindow eval"); + is(result.value.href, MAIN_DOMAIN, + "Got the expected window.location.href property value"); + is(result.value.protocol, "http:", + "Got the expected window.location.protocol property value"); + + yield teardown({client}); +}); + +add_task(function* test_error_inspectedWindowEval_result() { + const {client, inspectedWindowFront} = yield setup(MAIN_DOMAIN); + const result = yield inspectedWindowFront.eval(FAKE_CALLER_INFO, "window", {}); + + ok(!result.value, "Got a null result from inspectedWindow eval"); + ok(result.exceptionInfo.isError, "Got an API Error result from inspectedWindow eval"); + ok(!result.exceptionInfo.isException, "An error isException is false as expected"); + is(result.exceptionInfo.code, "E_PROTOCOLERROR", + "Got the expected 'code' property in the error result"); + is(result.exceptionInfo.description, "Inspector protocol error: %s", + "Got the expected 'description' property in the error result"); + is(result.exceptionInfo.details.length, 1, + "The 'details' array property should contains 1 element"); + ok(result.exceptionInfo.details[0].includes("cyclic object value"), + "Got the expected content in the error results's details"); + + yield teardown({client}); +}); + +add_task(function* test_system_principal_denied_error_inspectedWindowEval_result() { + const {client, inspectedWindowFront} = yield setup("about:addons"); + const result = yield inspectedWindowFront.eval(FAKE_CALLER_INFO, "window", {}); + + ok(!result.value, "Got a null result from inspectedWindow eval"); + ok(result.exceptionInfo.isError, + "Got an API Error result from inspectedWindow eval on a system principal page"); + is(result.exceptionInfo.code, "E_PROTOCOLERROR", + "Got the expected 'code' property in the error result"); + is(result.exceptionInfo.description, "Inspector protocol error: %s", + "Got the expected 'description' property in the error result"); + is(result.exceptionInfo.details.length, 1, + "The 'details' array property should contains 1 element"); + is(result.exceptionInfo.details[0], + "This target has a system principal. inspectedWindow.eval denied.", + "Got the expected content in the error results's details"); + + yield teardown({client}); +}); + +add_task(function* test_exception_inspectedWindowEval_result() { + const {client, inspectedWindowFront} = yield setup(MAIN_DOMAIN); + const result = yield inspectedWindowFront.eval( + FAKE_CALLER_INFO, "throw Error('fake eval error');", {}); + + ok(result.exceptionInfo.isException, "Got an exception as expected"); + ok(!result.value, "Got an undefined eval value"); + ok(!result.exceptionInfo.isError, "An exception should not be isError=true"); + ok(result.exceptionInfo.value.includes("Error: fake eval error"), + "Got the expected exception message"); + + const expectedCallerInfo = + `called from ${FAKE_CALLER_INFO.url}:${FAKE_CALLER_INFO.lineNumber}`; + ok(result.exceptionInfo.value.includes(expectedCallerInfo), + "Got the expected caller info in the exception message"); + + const expectedStack = `eval code:1:7`; + ok(result.exceptionInfo.value.includes(expectedStack), + "Got the expected stack trace in the exception message"); + + yield teardown({client}); +}); + +add_task(function* test_exception_inspectedWindowReload() { + const { + client, consoleClient, inspectedWindowFront, + } = yield setup(`${TEST_RELOAD_URL}?test=cache`); + + // Test reload with bypassCache=false. + + const waitForNoBypassCacheReload = waitForNextTabNavigated(client); + const reloadResult = yield inspectedWindowFront.reload(FAKE_CALLER_INFO, + {ignoreCache: false}); + + ok(!reloadResult, "Got the expected undefined result from inspectedWindow reload"); + + yield waitForNoBypassCacheReload; + + const noBypassCacheEval = yield consoleEvalJS(consoleClient, + "document.body.textContent"); + + is(noBypassCacheEval.result, "empty cache headers", + "Got the expected result with reload forceBypassCache=false"); + + // Test reload with bypassCache=true. + + const waitForForceBypassCacheReload = waitForNextTabNavigated(client); + yield inspectedWindowFront.reload(FAKE_CALLER_INFO, {ignoreCache: true}); + + yield waitForForceBypassCacheReload; + + const forceBypassCacheEval = yield consoleEvalJS(consoleClient, + "document.body.textContent"); + + is(forceBypassCacheEval.result, "no-cache:no-cache", + "Got the expected result with reload forceBypassCache=true"); + + yield teardown({client}); +}); + +add_task(function* test_exception_inspectedWindowReload_customUserAgent() { + const { + client, consoleClient, inspectedWindowFront, + } = yield setup(`${TEST_RELOAD_URL}?test=user-agent`); + + // Test reload with custom userAgent. + + const waitForCustomUserAgentReload = waitForNextTabNavigated(client); + yield inspectedWindowFront.reload(FAKE_CALLER_INFO, + {userAgent: "Customized User Agent"}); + + yield waitForCustomUserAgentReload; + + const customUserAgentEval = yield consoleEvalJS(consoleClient, + "document.body.textContent"); + + is(customUserAgentEval.result, "Customized User Agent", + "Got the expected result on reload with a customized userAgent"); + + // Test reload with no custom userAgent. + + const waitForNoCustomUserAgentReload = waitForNextTabNavigated(client); + yield inspectedWindowFront.reload(FAKE_CALLER_INFO, {}); + + yield waitForNoCustomUserAgentReload; + + const noCustomUserAgentEval = yield consoleEvalJS(consoleClient, + "document.body.textContent"); + + is(noCustomUserAgentEval.result, window.navigator.userAgent, + "Got the expected result with reload without a customized userAgent"); + + yield teardown({client}); +}); + +add_task(function* test_exception_inspectedWindowReload_injectedScript() { + const { + client, consoleClient, inspectedWindowFront, + } = yield setup(`${TEST_RELOAD_URL}?test=injected-script&frames=3`); + + // Test reload with an injectedScript. + + const waitForInjectedScriptReload = waitForNextTabNavigated(client); + yield inspectedWindowFront.reload(FAKE_CALLER_INFO, + {injectedScript: `new ${injectedScript}`}); + yield waitForInjectedScriptReload; + + const injectedScriptEval = yield consoleEvalJS(consoleClient, + `(${collectEvalResults})()`); + + const expectedResult = (new Array(4)).fill("injected script executed first"); + + SimpleTest.isDeeply(JSON.parse(injectedScriptEval.result), expectedResult, + "Got the expected result on reload with an injected script"); + + // Test reload without an injectedScript. + + const waitForNoInjectedScriptReload = waitForNextTabNavigated(client); + yield inspectedWindowFront.reload(FAKE_CALLER_INFO, {}); + yield waitForNoInjectedScriptReload; + + const noInjectedScriptEval = yield consoleEvalJS(consoleClient, + `(${collectEvalResults})()`); + + const newExpectedResult = (new Array(4)).fill("injected script NOT executed"); + + SimpleTest.isDeeply(JSON.parse(noInjectedScriptEval.result), newExpectedResult, + "Got the expected result on reload with no injected script"); + + yield teardown({client}); +}); + +add_task(function* test_exception_inspectedWindowReload_multiple_calls() { + const { + client, consoleClient, inspectedWindowFront, + } = yield setup(`${TEST_RELOAD_URL}?test=user-agent`); + + // Test reload with custom userAgent three times (and then + // check that only the first one has affected the page reload. + + const waitForCustomUserAgentReload = waitForNextTabNavigated(client); + + inspectedWindowFront.reload(FAKE_CALLER_INFO, {userAgent: "Customized User Agent 1"}); + inspectedWindowFront.reload(FAKE_CALLER_INFO, {userAgent: "Customized User Agent 2"}); + + yield waitForCustomUserAgentReload; + + const customUserAgentEval = yield consoleEvalJS(consoleClient, + "document.body.textContent"); + + is(customUserAgentEval.result, "Customized User Agent 1", + "Got the expected result on reload with a customized userAgent"); + + // Test reload with no custom userAgent. + + const waitForNoCustomUserAgentReload = waitForNextTabNavigated(client); + yield inspectedWindowFront.reload(FAKE_CALLER_INFO, {}); + + yield waitForNoCustomUserAgentReload; + + const noCustomUserAgentEval = yield consoleEvalJS(consoleClient, + "document.body.textContent"); + + is(noCustomUserAgentEval.result, window.navigator.userAgent, + "Got the expected result with reload without a customized userAgent"); + + yield teardown({client}); +}); + +add_task(function* test_exception_inspectedWindowReload_stopped() { + const { + client, consoleClient, inspectedWindowFront, + } = yield setup(`${TEST_RELOAD_URL}?test=injected-script&frames=3`); + + // Test reload on a page that calls window.stop() immediately during the page loading + + const waitForPageLoad = waitForNextTabNavigated(client); + yield inspectedWindowFront.eval(FAKE_CALLER_INFO, + "window.location += '&stop=windowStop'"); + + info("Load a webpage that calls 'window.stop()' while is still loading"); + yield waitForPageLoad; + + info("Starting a reload with an injectedScript"); + const waitForInjectedScriptReload = waitForNextTabNavigated(client); + yield inspectedWindowFront.reload(FAKE_CALLER_INFO, + {injectedScript: `new ${injectedScript}`}); + yield waitForInjectedScriptReload; + + const injectedScriptEval = yield consoleEvalJS(consoleClient, + `(${collectEvalResults})()`); + + // The page should have stopped during the reload and only one injected script + // is expected. + const expectedResult = (new Array(1)).fill("injected script executed first"); + + SimpleTest.isDeeply(JSON.parse(injectedScriptEval.result), expectedResult, + "The injected script has been executed on the 'stopped' page reload"); + + // Reload again with no options. + + info("Reload the tab again without any reload options"); + const waitForNoInjectedScriptReload = waitForNextTabNavigated(client); + yield inspectedWindowFront.reload(FAKE_CALLER_INFO, {}); + yield waitForNoInjectedScriptReload; + + const noInjectedScriptEval = yield consoleEvalJS(consoleClient, + `(${collectEvalResults})()`); + + // The page should have stopped during the reload and no injected script should + // have been executed during this second reload (or it would mean that the previous + // customized reload was still pending and has wrongly affected the second reload) + const newExpectedResult = (new Array(1)).fill("injected script NOT executed"); + + SimpleTest.isDeeply( + JSON.parse(noInjectedScriptEval.result), newExpectedResult, + "No injectedScript should have been evaluated during the second reload" + ); + + yield teardown({client}); +}); + +// TODO: check eval with $0 binding once implemented (Bug 1300590) diff --git a/devtools/server/tests/browser/inspectedwindow-reload-target.sjs b/devtools/server/tests/browser/inspectedwindow-reload-target.sjs new file mode 100644 index 000000000000..9af6c4ce7304 --- /dev/null +++ b/devtools/server/tests/browser/inspectedwindow-reload-target.sjs @@ -0,0 +1,75 @@ +Components.utils.importGlobalProperties(["URLSearchParams"]); + +function handleRequest(request, response) { + let params = new URLSearchParams(request.queryString); + + switch(params.get("test")) { + case "cache": + handleCacheTestRequest(request, response); + break; + + case "user-agent": + handleUserAgentTestRequest(request, response); + break; + + case "injected-script": + handleInjectedScriptTestRequest(request, response, params); + break; + } +} + +function handleCacheTestRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write(`${request.getHeader("pragma")}:${request.getHeader("cache-control")}`); + } else { + response.write("empty cache headers"); + } +} + +function handleUserAgentTestRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("user-agent")) { + response.write(request.getHeader("user-agent")); + } else { + response.write("no user agent header"); + } +} + +function handleInjectedScriptTestRequest(request, response, params) { + response.setHeader("Content-Type", "text/html; charset=UTF-8", false); + + const frames = parseInt(params.get("frames")); + let content = ""; + + if (frames > 0) { + // Output an iframe in seamless mode, so that there is an higher chance that in case + // of test failures we get a screenshot where the nested iframes are all visible. + content = ``; + } + + if (params.get("stop") == "windowStop") { + content = "" + content; + } + + response.write(` + + + + + + +

IFRAME ${frames}

+
injected script NOT executed
+ + ${content} + + + `); +} \ No newline at end of file diff --git a/devtools/shared/fronts/moz.build b/devtools/shared/fronts/moz.build index 8a38d6b5d3aa..9f6a826552e8 100644 --- a/devtools/shared/fronts/moz.build +++ b/devtools/shared/fronts/moz.build @@ -37,5 +37,6 @@ DevToolsModules( 'stylesheets.js', 'timeline.js', 'webaudio.js', + 'webextension-inspected-window.js', 'webgl.js' ) diff --git a/devtools/shared/fronts/webextension-inspected-window.js b/devtools/shared/fronts/webextension-inspected-window.js new file mode 100644 index 000000000000..1bd418a24b22 --- /dev/null +++ b/devtools/shared/fronts/webextension-inspected-window.js @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + webExtensionInspectedWindowSpec, +} = require("devtools/shared/specs/webextension-inspected-window"); + +const protocol = require("devtools/shared/protocol"); + +/** + * The corresponding Front object for the WebExtensionInspectedWindowActor. + */ +const WebExtensionInspectedWindowFront = protocol.FrontClassWithSpec( + webExtensionInspectedWindowSpec, + { + initialize: function (client, { webExtensionInspectedWindowActor }) { + protocol.Front.prototype.initialize.call(this, client, { + actor: webExtensionInspectedWindowActor + }); + this.manage(this); + } + } +); + +exports.WebExtensionInspectedWindowFront = WebExtensionInspectedWindowFront; diff --git a/devtools/shared/specs/moz.build b/devtools/shared/specs/moz.build index 52a8106373ee..f313690d6ac1 100644 --- a/devtools/shared/specs/moz.build +++ b/devtools/shared/specs/moz.build @@ -45,6 +45,7 @@ DevToolsModules( 'stylesheets.js', 'timeline.js', 'webaudio.js', + 'webextension-inspected-window.js', 'webgl.js', 'worker.js' ) diff --git a/devtools/shared/specs/webextension-inspected-window.js b/devtools/shared/specs/webextension-inspected-window.js new file mode 100644 index 000000000000..b43821374a85 --- /dev/null +++ b/devtools/shared/specs/webextension-inspected-window.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Arg, + RetVal, + generateActorSpec, + types, +} = require("devtools/shared/protocol"); + +/** + * Sent with the eval and reload requests, used to inform the + * webExtensionInspectedWindowActor about the caller information + * to be able to evaluate code as being executed from the caller + * WebExtension sources, or log errors with information that can + * help the addon developer to more easily identify the affected + * lines in his own addon code. + */ +types.addDictType("webExtensionCallerInfo", { + // Information related to the line of code that has originated + // the request. + url: "string", + lineNumber: "nullable:number", + + // The called addonId. + addonId: "string", +}); + +/** + * RDP type related to the inspectedWindow.eval method request. + */ +types.addDictType("webExtensionEvalOptions", { + frameURL: "nullable:string", + contextSecurityOrigin: "nullable:string", + useContentScriptContext: "nullable:boolean", +}); + +/** + * RDP type related to the inspectedWindow.eval method result errors. + * + * This type has been modelled on the same data format + * used in the corresponding chrome API method. + */ +types.addDictType("webExtensionEvalExceptionInfo", { + // The following properties are set if the error has not occurred + // in the evaluated JS code. + isError: "nullable:boolean", + code: "nullable:string", + description: "nullable:string", + details: "nullable:array:json", + + // The following properties are set if the error has occurred + // in the evaluated JS code. + isException: "nullable:string", + value: "nullable:string", +}); + +/** + * RDP type related to the inspectedWindow.eval method result. + */ +types.addDictType("webExtensionEvalResult", { + // The following properties are set if the evaluation has been + // completed successfully. + value: "nullable:json", + // The following properties are set if the evalutation has been + // completed with errors. + exceptionInfo: "nullable:webExtensionEvalExceptionInfo", +}); + +/** + * RDP type related to the inspectedWindow.reload method request. + */ +types.addDictType("webExtensionReloadOptions", { + ignoreCache: "nullable:boolean", + userAgent: "nullable:string", + injectedScript: "nullable:string", +}); + +const webExtensionInspectedWindowSpec = generateActorSpec({ + typeName: "webExtensionInspectedWindow", + + methods: { + reload: { + request: { + webExtensionCallerInfo: Arg(0, "webExtensionCallerInfo"), + options: Arg(1, "webExtensionReloadOptions"), + }, + }, + eval: { + request: { + webExtensionCallerInfo: Arg(0, "webExtensionCallerInfo"), + expression: Arg(1, "string"), + options: Arg(2, "webExtensionEvalOptions"), + }, + + response: { + evalResult: RetVal("webExtensionEvalResult"), + }, + }, + }, +}); + +exports.webExtensionInspectedWindowSpec = webExtensionInspectedWindowSpec;