Bug 1315251 - Create a DevTools Remote Debugger Actor as a backend for the WebExtension DevTools API. r=ochameau
authorLuca Greco <lgreco@mozilla.com>
Thu, 17 Nov 2016 19:31:06 +0100
changeset 371433 5d0207006a7e28d5a68c6ae982fff122bd20623b
parent 371432 2e7f1ee0d65b9cff4d43c989ecbd98040513b3cb
child 371434 631f977c50ee68ac64aecc40e7fcfeda9b14ca04
push id1419
push userjlund@mozilla.com
push dateMon, 10 Apr 2017 20:44:07 +0000
treeherdermozilla-release@5e6801b73ef6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersochameau
bugs1315251
milestone53.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1315251 - Create a DevTools Remote Debugger Actor as a backend for the WebExtension DevTools API. r=ochameau 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
@@ -113,18 +113,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-cookies-same-name.html
   storage-dynamic-windows.html
   storage-listings.html
   storage-unsecured-iframe.html
   storage-updates.html
   storage-secured-iframe.html
@@ -92,8 +93,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;