Bug 1315251 - Create a DevTools Remote Debugger Actor as a backend for the WebExtension DevTools API. draft
authorLuca Greco <lgreco@mozilla.com>
Thu, 17 Nov 2016 19:31:06 +0100
changeset 440519 93b308707d7db1d27037e0be6fc215087252e60a
parent 439919 eb26ed8de3bf51429e5587e22bb8f6c2a920d051
child 440520 191d4501b4139e3d392309d88a3ddc2f89ba85cb
push id36246
push userluca.greco@alcacoop.it
push dateThu, 17 Nov 2016 18:34:36 +0000
bugs1315251
milestone53.0a1
Bug 1315251 - Create a DevTools Remote Debugger Actor as a backend for the WebExtension DevTools API. MozReview-Commit-ID: E6eNG8BgBwF
.eslintignore
devtools/server/actors/moz.build
devtools/server/actors/webextension-inspected-window.js
devtools/server/main.js
devtools/server/tests/browser/browser.ini
devtools/server/tests/browser/browser_webextension_inspected_window.js
devtools/server/tests/browser/inspectedwindow-reload-target.sjs
devtools/shared/fronts/moz.build
devtools/shared/fronts/webextension-inspected-window.js
devtools/shared/specs/moz.build
devtools/shared/specs/webextension-inspected-window.js
--- a/.eslintignore
+++ b/.eslintignore
@@ -114,18 +114,20 @@ devtools/server/actors/**
 !devtools/server/actors/inspector.js
 !devtools/server/actors/highlighters/css-grid.js
 !devtools/server/actors/highlighters/eye-dropper.js
 !devtools/server/actors/layout.js
 !devtools/server/actors/string.js
 !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
 !devtools/shared/async-storage.js
 !devtools/shared/async-utils.js
 !devtools/shared/defer.js
 !devtools/shared/event-emitter.js
 !devtools/shared/indentation.js
--- a/devtools/server/actors/moz.build
+++ b/devtools/server/actors/moz.build
@@ -58,12 +58,13 @@ DevToolsModules(
     'string.js',
     'styleeditor.js',
     'styles.js',
     'stylesheets.js',
     'timeline.js',
     'webaudio.js',
     'webbrowser.js',
     'webconsole.js',
+    'webextension-inspected-window.js',
     'webextension.js',
     'webgl.js',
     'worker.js',
 )
new file mode 100644
--- /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;
--- a/devtools/server/main.js
+++ b/devtools/server/main.js
@@ -564,16 +564,21 @@ var DebuggerServer = {
       constructor: "PerformanceEntriesActor",
       type: { tab: true }
     });
     this.registerModule("devtools/server/actors/emulation", {
       prefix: "emulation",
       constructor: "EmulationActor",
       type: { tab: true }
     });
+    this.registerModule("devtools/server/actors/webextension-inspected-window", {
+      prefix: "webExtensionInspectedWindow",
+      constructor: "WebExtensionInspectedWindowActor",
+      type: { tab: true }
+    });
   },
 
   /**
    * Passes a set of options to the BrowserAddonActors for the given ID.
    *
    * @param id string
    *        The ID of the add-on to pass the options to
    * @param options object
--- a/devtools/server/tests/browser/browser.ini
+++ b/devtools/server/tests/browser/browser.ini
@@ -4,16 +4,17 @@ subsuite = devtools
 support-files =
   head.js
   animation.html
   doc_allocations.html
   doc_force_cc.html
   doc_force_gc.html
   doc_innerHTML.html
   doc_perf.html
+  inspectedwindow-reload-target.sjs
   navigate-first.html
   navigate-second.html
   storage-dynamic-windows.html
   storage-listings.html
   storage-unsecured-iframe.html
   storage-updates.html
   storage-secured-iframe.html
   stylesheets-nested-iframes.html
@@ -90,8 +91,9 @@ skip-if = e10s # Bug 1183605 - devtools/
 [browser_timeline_iframes.js]
 [browser_directorscript_actors_exports.js]
 skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S
 [browser_directorscript_actors_error_events.js]
 skip-if = e10s # Bug 1183605 - devtools/server/tests/browser/ tests are still disabled in E10S
 [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
new file mode 100644
--- /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)
new file mode 100644
--- /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 = `<iframe seamless src="?test=injected-script&frames=${frames - 1}"></iframe>`;
+  }
+
+  if (params.get("stop") == "windowStop") {
+    content = "<script>window.stop();</script>" + content;
+  }
+
+  response.write(`<!DOCTYPE html>
+    <html>
+      <head>
+       <meta charset="utf-8">
+       <style>
+         iframe { width: 100%; height: ${frames * 150}px; }
+       </style>
+      </head>
+      <body>
+       <h1>IFRAME ${frames}</h1>
+       <pre>injected script NOT executed</pre>
+       <script>
+         window.pageScriptExecutedFirst = true;
+       </script>
+       ${content}
+      </body>
+    </html>
+  `);
+}
\ No newline at end of file
--- a/devtools/shared/fronts/moz.build
+++ b/devtools/shared/fronts/moz.build
@@ -32,10 +32,11 @@ DevToolsModules(
     'reflow.js',
     'settings.js',
     'storage.js',
     'string.js',
     'styles.js',
     'stylesheets.js',
     'timeline.js',
     'webaudio.js',
+    'webextension-inspected-window.js',
     'webgl.js'
 )
new file mode 100644
--- /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;
--- a/devtools/shared/specs/moz.build
+++ b/devtools/shared/specs/moz.build
@@ -40,11 +40,12 @@ DevToolsModules(
     'source.js',
     'storage.js',
     'string.js',
     'styleeditor.js',
     'styles.js',
     'stylesheets.js',
     'timeline.js',
     'webaudio.js',
+    'webextension-inspected-window.js',
     'webgl.js',
     'worker.js'
 )
new file mode 100644
--- /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;