Bug 1213993: [webext] Support frameId/allFrames/runAt in browser.tabs.executeScript and insertCSS. r=billm
authorKris Maglione <maglione.k@gmail.com>
Mon, 08 Feb 2016 17:40:02 -0800
changeset 324001 36d6bc68fe0f21d87b306c7712e939d8ae537b88
parent 324000 88df606b81dadf05046728d14428214dfc00e0af
child 324002 b5c0cd56381547fe527c724d86eb955c209e0a6e
push id1128
push userjlund@mozilla.com
push dateWed, 01 Jun 2016 01:31:59 +0000
treeherdermozilla-release@fe0d30de989d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbillm
bugs1213993
milestone47.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 1213993: [webext] Support frameId/allFrames/runAt in browser.tabs.executeScript and insertCSS. r=billm MozReview-Commit-ID: FgV9vyHVjj8
browser/components/extensions/ext-tabs.js
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js
browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js
browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js
browser/components/extensions/test/browser/file_iframe_document.html
browser/components/extensions/test/browser/file_iframe_document.sjs
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/schemas/extension_types.json
toolkit/modules/addons/WebNavigationFrames.jsm
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -638,25 +638,34 @@ extensions.registerSchemaAPI("tabs", nul
           width: browser.clientWidth,
           height: browser.clientHeight,
         };
 
         return context.sendMessage(browser.messageManager, "Extension:Capture",
                                    message, recipient);
       },
 
-      _execute: function(tabId, details, kind) {
+      _execute: function(tabId, details, kind, method) {
         let tab = tabId !== null ? TabManager.getTab(tabId) : TabManager.activeTab;
         let mm = tab.linkedBrowser.messageManager;
 
         let options = {
           js: [],
           css: [],
         };
 
+        // We require a `code` or a `file` property, but we can't accept both.
+        if ((details.code === null) == (details.file === null)) {
+          return Promise.reject({message: `${method} requires either a 'code' or a 'file' property, but not both`});
+        }
+
+        if (details.frameId !== null && details.allFrames) {
+          return Promise.reject({message: `'frameId' and 'allFrames' are mutually exclusive`});
+        }
+
         let recipient = {
           innerWindowID: tab.linkedBrowser.innerWindowID,
         };
 
         if (TabManager.for(extension).hasActiveTabPermission(tab)) {
           // If we have the "activeTab" permission for this tab, ignore
           // the host whitelist.
           options.matchesHost = ["<all_urls>"];
@@ -672,32 +681,37 @@ extensions.registerSchemaAPI("tabs", nul
           if (!extension.isExtensionURL(url)) {
             return Promise.reject({message: "Files to be injected must be within the extension"});
           }
           options[kind].push(url);
         }
         if (details.allFrames) {
           options.all_frames = details.allFrames;
         }
+        if (details.frameId !== null) {
+          options.frame_id = details.frameId;
+        }
         if (details.matchAboutBlank) {
           options.match_about_blank = details.matchAboutBlank;
         }
         if (details.runAt !== null) {
           options.run_at = details.runAt;
+        } else {
+          options.run_at = "document_idle";
         }
 
         return context.sendMessage(mm, "Extension:Execute", {options}, recipient);
       },
 
       executeScript: function(tabId, details) {
-        return self.tabs._execute(tabId, details, "js");
+        return self.tabs._execute(tabId, details, "js", "executeScript");
       },
 
       insertCSS: function(tabId, details) {
-        return self.tabs._execute(tabId, details, "css");
+        return self.tabs._execute(tabId, details, "css", "insertCSS");
       },
 
       connect: function(tabId, connectInfo) {
         let tab = TabManager.getTab(tabId);
         let mm = tab.linkedBrowser.messageManager;
 
         let name = "";
         if (connectInfo && connectInfo.name !== null) {
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -2,16 +2,18 @@
 support-files =
   head.js
   context.html
   ctxmenu-image.png
   context_tabs_onUpdated_page.html
   context_tabs_onUpdated_iframe.html
   file_popup_api_injection_a.html
   file_popup_api_injection_b.html
+  file_iframe_document.html
+  file_iframe_document.sjs
 
 [browser_ext_simple.js]
 [browser_ext_commands.js]
 [browser_ext_currentWindow.js]
 [browser_ext_browserAction_simple.js]
 [browser_ext_browserAction_pageAction_icon.js]
 [browser_ext_browserAction_context.js]
 [browser_ext_browserAction_disabled.js]
@@ -24,25 +26,26 @@ support-files =
 [browser_ext_lastError.js]
 [browser_ext_runtime_setUninstallURL.js]
 [browser_ext_tabs_audio.js]
 [browser_ext_tabs_captureVisibleTab.js]
 [browser_ext_tabs_events.js]
 [browser_ext_tabs_executeScript.js]
 [browser_ext_tabs_executeScript_good.js]
 [browser_ext_tabs_executeScript_bad.js]
+[browser_ext_tabs_executeScript_runAt.js]
 [browser_ext_tabs_insertCSS.js]
 [browser_ext_tabs_query.js]
 [browser_ext_tabs_getCurrent.js]
 [browser_ext_tabs_create.js]
 [browser_ext_tabs_create_invalid_url.js]
 [browser_ext_tabs_duplicate.js]
 [browser_ext_tabs_update.js]
 [browser_ext_tabs_update_url.js]
 [browser_ext_tabs_onUpdated.js]
 [browser_ext_tabs_sendMessage.js]
 [browser_ext_tabs_move.js]
 [browser_ext_tabs_move_window.js]
 [browser_ext_windows_create_tabId.js]
 [browser_ext_windows_update.js]
 [browser_ext_contentscript_connect.js]
 [browser_ext_tab_runtimeConnect.js]
-[browser_ext_webNavigation_getFrames.js]
\ No newline at end of file
+[browser_ext_webNavigation_getFrames.js]
--- a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js
@@ -3,61 +3,160 @@
 "use strict";
 
 add_task(function* testExecuteScript() {
   let {MessageChannel} = Cu.import("resource://gre/modules/MessageChannel.jsm", {});
 
   let messageManagersSize = MessageChannel.messageManagers.size;
   let responseManagersSize = MessageChannel.responseManagers.size;
 
-  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/", true);
+  const BASE = "http://mochi.test:8888/browser/browser/components/extensions/test/browser/";
+  const URL = BASE + "file_iframe_document.html";
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, URL, true);
 
   function background() {
-    browser.tabs.executeScript({
-      file: "script.js",
-      code: "42",
-    }, result => {
-      browser.test.assertEq(42, result, "Expected callback result");
-      browser.test.sendMessage("got result", result);
-    });
+    browser.tabs.query({active: true, currentWindow: true}).then(tabs => {
+      return browser.webNavigation.getAllFrames({tabId: tabs[0].id});
+    }).then(frames => {
+      browser.test.log(`FRAMES: ${frames[1].frameId} ${JSON.stringify(frames)}\n`);
+      return Promise.all([
+        browser.tabs.executeScript({
+          code: "42",
+        }).then(result => {
+          browser.test.assertEq(42, result, "Expected callback result");
+        }),
+
+        browser.tabs.executeScript({
+          file: "script.js",
+          code: "42",
+        }).then(result => {
+          browser.test.fail("Expected not to be able to execute a script with both file and code");
+        }, error => {
+          browser.test.assertTrue(/a 'code' or a 'file' property, but not both/.test(error.message),
+                                  "Got expected error");
+        }),
+
+        browser.tabs.executeScript({
+          file: "script.js",
+        }).then(result => {
+          browser.test.assertEq(undefined, result, "Expected callback result");
+        }),
+
+        browser.tabs.executeScript({
+          file: "script2.js",
+        }).then(result => {
+          browser.test.assertEq(27, result, "Expected callback result");
+        }),
+
+        browser.tabs.executeScript({
+          code: "location.href;",
+          allFrames: true,
+        }).then(result => {
+          browser.test.assertTrue(Array.isArray(result), "Result is an array");
+
+          browser.test.assertEq(2, result.length, "Result has correct length");
+
+          browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct");
+          browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct");
+        }),
+
+        browser.tabs.executeScript({
+          code: "location.href;",
+          runAt: "document_end",
+        }).then(result => {
+          browser.test.assertTrue(typeof(result) == "string", "Result is a string");
+
+          browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result), "Result is correct");
+        }),
 
-    browser.tabs.executeScript({
-      file: "script2.js",
-    }, result => {
-      browser.test.assertEq(27, result, "Expected callback result");
-      browser.test.sendMessage("got callback", result);
-    });
+        browser.tabs.executeScript({
+          code: "window",
+        }).then(result => {
+          browser.test.fail("Expected error when returning non-structured-clonable object");
+        }, error => {
+          browser.test.assertEq("Script returned non-structured-clonable data",
+                                error.message, "Got expected error");
+        }),
+
+        browser.tabs.executeScript({
+          code: "Promise.resolve(window)",
+        }).then(result => {
+          browser.test.fail("Expected error when returning non-structured-clonable object");
+        }, error => {
+          browser.test.assertEq("Script returned non-structured-clonable data",
+                                error.message, "Got expected error");
+        }),
+
+        browser.tabs.executeScript({
+          code: "Promise.resolve(42)",
+        }).then(result => {
+          browser.test.assertEq(42, result, "Got expected promise resolution value as result");
+        }),
+
+        browser.tabs.executeScript({
+          code: "location.href;",
+          runAt: "document_end",
+          allFrames: true,
+        }).then(result => {
+          browser.test.assertTrue(Array.isArray(result), "Result is an array");
 
-    browser.runtime.onMessage.addListener(message => {
-      browser.test.assertEq("script ran", message, "Expected runtime message");
-      browser.test.sendMessage("got message", message);
+          browser.test.assertEq(2, result.length, "Result has correct length");
+
+          browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct");
+          browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct");
+        }),
+
+        browser.tabs.executeScript({
+          code: "location.href;",
+          frameId: frames[0].frameId,
+        }).then(result => {
+          browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result), `Result for frameId[0] is correct: ${result}`);
+        }),
+
+        browser.tabs.executeScript({
+          code: "location.href;",
+          frameId: frames[1].frameId,
+        }).then(result => {
+          browser.test.assertEq("http://mochi.test:8888/", result, "Result for frameId[1] is correct");
+        }),
+
+        new Promise(resolve => {
+          browser.runtime.onMessage.addListener(message => {
+            browser.test.assertEq("script ran", message, "Expected runtime message");
+            resolve();
+          });
+        }),
+      ]);
+    }).then(() => {
+      browser.test.notifyPass("executeScript");
+    }).catch(e => {
+      browser.test.fail(`Error: ${e} :: ${e.stack}`);
+      browser.test.notifyFail("executeScript");
     });
   }
 
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
-      "permissions": ["http://mochi.test/"],
+      "permissions": ["http://mochi.test/", "webNavigation"],
     },
 
     background,
 
     files: {
       "script.js": function() {
         browser.runtime.sendMessage("script ran");
       },
 
       "script2.js": "27",
     },
   });
 
   yield extension.startup();
 
-  yield extension.awaitMessage("got result");
-  yield extension.awaitMessage("got callback");
-  yield extension.awaitMessage("got message");
+  yield extension.awaitFinish("executeScript");
 
   yield extension.unload();
 
   yield BrowserTestUtils.removeTab(tab);
 
   // Make sure that we're not holding on to references to closed message
   // managers.
   is(MessageChannel.messageManagers.size, messageManagersSize, "Message manager count");
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js
@@ -0,0 +1,110 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/**
+ * These tests ensure that the runAt argument to tabs.executeScript delays
+ * script execution until the document has reached the correct state.
+ *
+ * Since tests of this nature are especially race-prone, it relies on a
+ * server-JS script to delay the completion of our test page's load cycle long
+ * enough for us to attempt to load our scripts in the earlies phase we support.
+ *
+ * And since we can't actually rely on that timing, it retries any attempts that
+ * fail to load as early as expected, but don't load at any illegal time.
+ */
+
+add_task(function* testExecuteScript() {
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank", true);
+
+  function background() {
+    let tab;
+
+    const BASE = "http://mochi.test:8888/browser/browser/components/extensions/test/browser/";
+    const URL = BASE + "file_iframe_document.sjs";
+
+    const MAX_TRIES = 10;
+    let tries = 0;
+
+    function again() {
+      if (tries++ == MAX_TRIES) {
+        return Promise.reject(new Error("Max tries exceeded"));
+      }
+
+      let loadingPromise = new Promise(resolve => {
+        browser.tabs.onUpdated.addListener(function listener(tabId, changed, tab_) {
+          if (tabId == tab.id && changed.status == "loading" && tab_.url == URL) {
+            browser.tabs.onUpdated.removeListener(listener);
+            resolve();
+          }
+        });
+      });
+
+      // TODO: Test allFrames and frameId.
+
+      return browser.tabs.update({url: URL}).then(() => {
+        return loadingPromise;
+      }).then(() => {
+        return Promise.all([
+          // Send the executeScript requests in the reverse order that we expect
+          // them to execute in, to avoid them passing only because of timing
+          // races.
+          browser.tabs.executeScript({
+            code: "document.readyState",
+            runAt: "document_idle",
+          }),
+          browser.tabs.executeScript({
+            code: "document.readyState",
+            runAt: "document_end",
+          }),
+          browser.tabs.executeScript({
+            code: "document.readyState",
+            runAt: "document_start",
+          }),
+        ].reverse());
+      }).then(states => {
+        browser.test.log(`Got states: ${states}`);
+
+        // Make sure that none of our scripts executed earlier than expected,
+        // regardless of retries.
+        browser.test.assertTrue(states[1] == "interactive" || states[1] == "complete",
+                                `document_end state is valid: ${states[1]}`);
+        browser.test.assertTrue(states[2] == "complete",
+                                `document_idle state is valid: ${states[2]}`);
+
+        // If we have the earliest valid states for each script, we're done.
+        // Otherwise, try again.
+        if (states[0] != "loading" || states[1] != "interactive" || states[2] != "complete") {
+          return again();
+        }
+      });
+    }
+
+    browser.tabs.query({active: true, currentWindow: true}).then(tabs => {
+      tab = tabs[0];
+
+      return again();
+    }).then(() => {
+      browser.test.notifyPass("executeScript-runAt");
+    }).catch(e => {
+      browser.test.fail(`Error: ${e} :: ${e.stack}`);
+      browser.test.notifyFail("executeScript-runAt");
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["http://mochi.test/", "tabs"],
+    },
+
+    background,
+  });
+
+  yield extension.startup();
+
+  yield extension.awaitFinish("executeScript-runAt");
+
+  yield extension.unload();
+
+  yield BrowserTestUtils.removeTab(tab);
+});
--- a/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js
@@ -8,89 +8,75 @@ add_task(function* testExecuteScript() {
   let messageManagersSize = MessageChannel.messageManagers.size;
   let responseManagersSize = MessageChannel.responseManagers.size;
 
   let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/", true);
 
   function background() {
     let promises = [
       {
-        background: "rgb(0, 0, 0)",
-        foreground: "rgb(255, 192, 203)",
-        promise: resolve => {
-          browser.tabs.insertCSS({
-            file: "file1.css",
-            code: "* { background: black }",
-          }, result => {
-            browser.test.assertEq(undefined, result, "Expected callback result");
-            resolve();
-          });
-        },
-      },
-      {
-        background: "rgb(0, 0, 0)",
+        background: "transparent",
         foreground: "rgb(0, 113, 4)",
-        promise: resolve => {
-          browser.tabs.insertCSS({
+        promise: () => {
+          return browser.tabs.insertCSS({
             file: "file2.css",
-          }, result => {
-            browser.test.assertEq(undefined, result, "Expected callback result");
-            resolve();
           });
         },
       },
       {
         background: "rgb(42, 42, 42)",
         foreground: "rgb(0, 113, 4)",
-        promise: resolve => {
-          browser.tabs.insertCSS({
+        promise: () => {
+          return browser.tabs.insertCSS({
             code: "* { background: rgb(42, 42, 42) }",
-          }, result => {
-            browser.test.assertEq(undefined, result, "Expected callback result");
-            resolve();
           });
         },
       },
     ];
 
     function checkCSS() {
       let computedStyle = window.getComputedStyle(document.body);
       return [computedStyle.backgroundColor, computedStyle.color];
     }
 
     function next() {
       if (!promises.length) {
-        browser.test.notifyPass("insertCSS");
         return;
       }
 
       let {promise, background, foreground} = promises.shift();
-      new Promise(promise).then(() => {
-        browser.tabs.executeScript({
+      return promise().then(result => {
+        browser.test.assertEq(undefined, result, "Expected callback result");
+
+        return browser.tabs.executeScript({
           code: `(${checkCSS})()`,
-        }, result => {
-          browser.test.assertEq(background, result[0], "Expected background color");
-          browser.test.assertEq(foreground, result[1], "Expected foreground color");
-          next();
         });
+      }).then(result => {
+        browser.test.assertEq(background, result[0], "Expected background color");
+        browser.test.assertEq(foreground, result[1], "Expected foreground color");
+        return next();
       });
     }
 
-    next();
+    next().then(() => {
+      browser.test.notifyPass("insertCSS");
+    }).catch(e => {
+      browser.test.fail(`Error: ${e} :: ${e.stack}`);
+      browser.test.notifyFailure("insertCSS");
+    });
   }
 
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       "permissions": ["http://mochi.test/"],
     },
 
     background,
 
     files: {
-      "file1.css": "* { color: pink }",
       "file2.css": "* { color: rgb(0, 113, 4) }",
     },
   });
 
   yield extension.startup();
 
   yield extension.awaitFinish("insertCSS");
 
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/file_iframe_document.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title></title>
+</head>
+<body>
+  <iframe src="/"></iframe>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/file_iframe_document.sjs
@@ -0,0 +1,40 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */
+"use strict";
+
+// This script slows the load of an HTML document so that we can reliably test
+// all phases of the load cycle supported by the extension API.
+
+/* eslint-disable no-unused-vars */
+
+const DELAY = 1 * 1000; // Delay one second before completing the request.
+
+const Ci = Components.interfaces;
+
+let nsTimer = Components.Constructor("@mozilla.org/timer;1", "nsITimer", "initWithCallback");
+
+let timer;
+
+function handleRequest(request, response) {
+  response.processAsync();
+
+  response.setHeader("Content-Type", "text/html", false);
+  response.write(`<!DOCTYPE html>
+    <html lang="en">
+    <head>
+      <meta charset="UTF-8">
+      <title></title>
+    </head>
+    <body>
+  `);
+
+  // Note: We need to store a reference to the timer to prevent it from being
+  // canceled when it's GCed.
+  timer = new nsTimer(() => {
+    response.write(`
+        <iframe src="/"></iframe>
+      </body>
+      </html>`);
+    response.finish();
+  }, DELAY, Ci.nsITimer.TYPE_ONE_SHOT);
+}
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -161,43 +161,49 @@ Script.prototype = {
     if (!(this.matches_.matches(uri) || this.matches_host_.matchesIgnoringPath(uri))) {
       return false;
     }
 
     if (this.exclude_matches_.matches(uri)) {
       return false;
     }
 
-    if (!this.options.all_frames && window.top != window) {
+    if (this.options.frame_id != null) {
+      if (WebNavigationFrames.getFrameId(window) != this.options.frame_id) {
+        return false;
+      }
+    } else if (!this.options.all_frames && window.top != window) {
       return false;
     }
 
     // TODO: match_about_blank.
 
     return true;
   },
 
   tryInject(extension, window, sandbox, shouldRun) {
     if (!this.matches(window)) {
-      this.deferred.reject();
+      this.deferred.reject({message: "No matching window"});
       return;
     }
 
     if (shouldRun("document_start")) {
       let winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor)
                            .getInterface(Ci.nsIDOMWindowUtils);
 
       for (let url of this.css) {
         url = extension.baseURI.resolve(url);
         runSafeSyncWithoutClone(winUtils.loadSheetUsingURIString, url, winUtils.AUTHOR_SHEET);
+        this.deferred.resolve();
       }
 
       if (this.options.cssCode) {
         let url = "data:text/css;charset=utf-8," + encodeURIComponent(this.options.cssCode);
         runSafeSyncWithoutClone(winUtils.loadSheetUsingURIString, url, winUtils.AUTHOR_SHEET);
+        this.deferred.resolve();
       }
     }
 
     let result;
     let scheduled = this.run_at || "document_idle";
     if (shouldRun(scheduled)) {
       for (let url of this.js) {
         // On gonk we need to load the resources asynchronously because the
@@ -213,69 +219,68 @@ Script.prototype = {
           target: sandbox,
           charset: "UTF-8",
           async: AppConstants.platform == "gonk",
         };
         try {
           result = Services.scriptloader.loadSubScriptWithOptions(url, options);
         } catch (e) {
           Cu.reportError(e);
-          this.deferred.reject(e.message);
+          this.deferred.reject(e);
         }
       }
 
       if (this.options.jsCode) {
         try {
           result = Cu.evalInSandbox(this.options.jsCode, sandbox, "latest");
         } catch (e) {
           Cu.reportError(e);
-          this.deferred.reject(e.message);
+          this.deferred.reject(e);
         }
       }
+
+      this.deferred.resolve(result);
     }
-
-    // TODO: Handle this correctly when we support runAt and allFrames.
-    this.deferred.resolve(result);
   },
 };
 
 function getWindowMessageManager(contentWindow) {
   let ir = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
                         .getInterface(Ci.nsIDocShell)
                         .QueryInterface(Ci.nsIInterfaceRequestor);
   try {
     return ir.getInterface(Ci.nsIContentFrameMessageManager);
   } catch (e) {
     // Some windows don't support this interface (hidden window).
     return null;
   }
 }
 
+var DocumentManager;
 var ExtensionManager;
 
 // Scope in which extension content script code can run. It uses
 // Cu.Sandbox to run the code. There is a separate scope for each
 // frame.
 class ExtensionContext extends BaseContext {
   constructor(extensionId, contentWindow, contextOptions = {}) {
     super();
 
     let {isExtensionPage} = contextOptions;
 
     this.isExtensionPage = isExtensionPage;
     this.extension = ExtensionManager.get(extensionId);
     this.extensionId = extensionId;
     this.contentWindow = contentWindow;
 
-    let utils = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
-                             .getInterface(Ci.nsIDOMWindowUtils);
-    let outerWindowId = utils.outerWindowID;
-    let frameId = contentWindow == contentWindow.top ? 0 : outerWindowId;
+    let frameId = WebNavigationFrames.getFrameId(contentWindow);
     this.frameId = frameId;
 
+    this.scripts = [];
+
     let mm = getWindowMessageManager(contentWindow);
     this.messageManager = mm;
 
     let prin;
     let contentPrincipal = contentWindow.document.nodePrincipal;
     let ssm = Services.scriptSecurityManager;
 
     let extensionPrincipal = ssm.createCodebasePrincipal(this.extension.baseURI, {addonId: extensionId});
@@ -344,16 +349,37 @@ class ExtensionContext extends BaseConte
   get cloneScope() {
     return this.sandbox;
   }
 
   execute(script, shouldRun) {
     script.tryInject(this.extension, this.contentWindow, this.sandbox, shouldRun);
   }
 
+  addScript(script) {
+    let state = DocumentManager.getWindowState(this.contentWindow);
+    this.execute(script, scheduled => isWhenBeforeOrSame(scheduled, state));
+
+    // Save the script in case it has pending operations in later load
+    // states, but only if we're before document_idle.
+    if (state != "document_idle") {
+      this.scripts.push(script);
+    }
+  }
+
+  triggerScripts(documentState) {
+    for (let script of this.scripts) {
+      this.execute(script, scheduled => scheduled == documentState);
+    }
+    if (documentState == "document_idle") {
+      // Don't bother saving scripts after document_idle.
+      this.scripts.length = 0;
+    }
+  }
+
   close() {
     super.unload();
 
     // Overwrite the content script APIs with an empty object if the APIs objects are still
     // defined in the content window (See Bug 1214658 for rationale).
     if (this.isExtensionPage && !Cu.isDeadWrapper(this.contentWindow) &&
         Cu.waiveXrays(this.contentWindow).browser === this.chromeObj) {
       Cu.createObjectIn(this.contentWindow, {defineAs: "browser"});
@@ -367,17 +393,17 @@ class ExtensionContext extends BaseConte
 function windowId(window) {
   return window.QueryInterface(Ci.nsIInterfaceRequestor)
                .getInterface(Ci.nsIDOMWindowUtils)
                .currentInnerWindowID;
 }
 
 // Responsible for creating ExtensionContexts and injecting content
 // scripts into them when new documents are created.
-var DocumentManager = {
+DocumentManager = {
   extensionCount: 0,
 
   // Map[windowId -> Map[extensionId -> ExtensionContext]]
   contentScriptWindows: new Map(),
 
   // Map[windowId -> ExtensionContext]
   extensionPageWindows: new Map(),
 
@@ -388,22 +414,22 @@ var DocumentManager = {
 
   uninit() {
     Services.obs.removeObserver(this, "document-element-inserted");
     Services.obs.removeObserver(this, "inner-window-destroyed");
   },
 
   getWindowState(contentWindow) {
     let readyState = contentWindow.document.readyState;
-    if (readyState == "loading") {
-      return "document_start";
+    if (readyState == "complete") {
+      return "document_idle";
     } else if (readyState == "interactive") {
       return "document_end";
     } else {
-      return "document_idle";
+      return "document_start";
     }
   },
 
   observe: function(subject, topic, data) {
     if (topic == "document-element-inserted") {
       let document = subject;
       let window = document && document.defaultView;
       if (!document || !document.location || !window) {
@@ -470,35 +496,48 @@ var DocumentManager = {
 
     if (event.type == "DOMContentLoaded") {
       this.trigger("document_end", window);
     } else if (event.type == "load") {
       this.trigger("document_idle", window);
     }
   },
 
-  executeScript(global, extensionId, script) {
-    let window = global.content;
-    let context = this.getContentScriptContext(extensionId, window);
-    if (!context) {
-      throw new Error("Unexpected add-on ID");
-    }
+  executeScript(global, extensionId, options) {
+    let executeInWin = (window) => {
+      let deferred = PromiseUtils.defer();
+      let script = new Script(options, deferred);
+
+      if (script.matches(window)) {
+        let context = this.getContentScriptContext(extensionId, window);
+        context.addScript(script);
+        return deferred.promise;
+      }
+      return null;
+    };
 
-    // TODO: Somehow make sure we have the right permissions for this origin!
+    let promises = Array.from(this.enumerateWindows(global.docShell), executeInWin)
+                        .filter(promise => promise);
 
-    // FIXME: Script should be executed only if current state has
-    // already reached its run_at state, or we have to keep it around
-    // somewhere to execute later.
-    context.execute(script, scheduled => true);
+    if (!promises.length) {
+      return Promise.reject({message: `No matching window`});
+    }
+    if (options.all_frames) {
+      return Promise.all(promises);
+    }
+    if (promises.length > 1) {
+      return Promise.reject({message: `Internal error: Script matched multiple windows`});
+    }
+    return promises[0];
   },
 
   enumerateWindows: function*(docShell) {
     let window = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIDOMWindow);
-    yield [window, this.getWindowState(window)];
+    yield window;
 
     for (let i = 0; i < docShell.childCount; i++) {
       let child = docShell.getChildAt(i).QueryInterface(Ci.nsIDocShell);
       yield* this.enumerateWindows(child);
     }
   },
 
   getContentScriptContext(extensionId, window) {
@@ -534,21 +573,21 @@ var DocumentManager = {
     }
     this.extensionCount++;
 
     let extension = ExtensionManager.get(extensionId);
     for (let global of ExtensionContent.globals.keys()) {
       // Note that we miss windows in the bfcache here. In theory we
       // could execute content scripts on a pageshow event for that
       // window, but that seems extreme.
-      for (let [window, state] of this.enumerateWindows(global.docShell)) {
+      for (let window of this.enumerateWindows(global.docShell)) {
         for (let script of extension.scripts) {
           if (script.matches(window)) {
             let context = this.getContentScriptContext(extensionId, window);
-            context.execute(script, scheduled => isWhenBeforeOrSame(scheduled, state));
+            context.addScript(script);
           }
         }
       }
     }
   },
 
   shutdownExtension(extensionId) {
     // Clean up content-script contexts on extension shutdown.
@@ -573,23 +612,31 @@ var DocumentManager = {
     this.extensionCount--;
     if (this.extensionCount == 0) {
       this.uninit();
     }
   },
 
   trigger(when, window) {
     let state = this.getWindowState(window);
-    for (let [extensionId, extension] of ExtensionManager.extensions) {
-      for (let script of extension.scripts) {
-        if (script.matches(window)) {
-          let context = this.getContentScriptContext(extensionId, window);
-          context.execute(script, scheduled => scheduled == state);
+
+    if (state == "document_start") {
+      for (let [extensionId, extension] of ExtensionManager.extensions) {
+        for (let script of extension.scripts) {
+          if (script.matches(window)) {
+            let context = this.getContentScriptContext(extensionId, window);
+            context.addScript(script);
+          }
         }
       }
+    } else {
+      let contexts = this.contentScriptWindows.get(windowId(window)) || new Map();
+      for (let context of contexts.values()) {
+        context.triggerScripts(state);
+      }
     }
   },
 };
 
 // Represents a browser extension in the content process.
 function BrowserExtensionContent(data) {
   this.id = data.id;
   this.uuid = data.uuid;
@@ -700,29 +747,26 @@ class ExtensionGlobal {
   }
 
   uninit() {
     this.global.sendAsyncMessage("Extension:RemoveTopWindowID", {windowId: this.windowId});
   }
 
   get messageFilter() {
     return {
-      innerWindowID: this.global.content
-                         .QueryInterface(Ci.nsIInterfaceRequestor)
-                         .getInterface(Ci.nsIDOMWindowUtils)
-                         .currentInnerWindowID,
+      innerWindowID: windowId(this.global.content),
     };
   }
 
   receiveMessage({target, messageName, recipient, data}) {
     switch (messageName) {
       case "Extension:Capture":
         return this.handleExtensionCapture(data.width, data.height, data.options);
       case "Extension:Execute":
-        return this.handleExtensionExecute(target, recipient, data.options);
+        return this.handleExtensionExecute(target, recipient.extensionId, data.options);
       case "WebNavigation:GetFrame":
         return this.handleWebNavigationGetFrame(data.options);
       case "WebNavigation:GetAllFrames":
         return this.handleWebNavigationGetAllFrames();
     }
   }
 
   handleExtensionCapture(width, height, options) {
@@ -741,22 +785,27 @@ class ExtensionGlobal {
     // settings like full zoom come into play.
     ctx.scale(canvas.width / win.innerWidth, canvas.height / win.innerHeight);
 
     ctx.drawWindow(win, win.scrollX, win.scrollY, win.innerWidth, win.innerHeight, "#fff");
 
     return canvas.toDataURL(`image/${options.format}`, options.quality / 100);
   }
 
-  handleExtensionExecute(target, recipient, options) {
-    let deferred = PromiseUtils.defer();
-    let script = new Script(options, deferred);
-    let {extensionId} = recipient;
-    DocumentManager.executeScript(target, extensionId, script);
-    return deferred.promise;
+  handleExtensionExecute(target, extensionId, options) {
+    return DocumentManager.executeScript(target, extensionId, options).then(result => {
+      try {
+        // Make sure we can structured-clone the result value before
+        // we try to send it back over the message manager.
+        Cu.cloneInto(result, target);
+      } catch (e) {
+        return Promise.reject({message: "Script returned non-structured-clonable data"});
+      }
+      return result;
+    });
   }
 
   handleWebNavigationGetFrame({frameId}) {
     return WebNavigationFrames.getFrame(this.global.docShell, frameId);
   }
 
   handleWebNavigationGetAllFrames() {
     return WebNavigationFrames.getAllFrames(this.global.docShell);
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -660,17 +660,17 @@ class IntegerType extends Type {
 
   normalize(value, context) {
     let r = this.normalizeBase("integer", value, context);
     if (r.error) {
       return r;
     }
 
     // Ensure it's between -2**31 and 2**31-1
-    if ((value | 0) !== value) {
+    if (!Number.isSafeInteger(value)) {
       return context.error("Integer is out of range");
     }
 
     if (value < this.minimum) {
       return context.error(`Integer ${value} is too small (must be at least ${this.minimum})`);
     }
     if (value > this.maximum) {
       return context.error(`Integer ${value} is too big (must be at most ${this.maximum})`);
--- a/toolkit/components/extensions/schemas/extension_types.json
+++ b/toolkit/components/extensions/schemas/extension_types.json
@@ -42,16 +42,22 @@
         "id": "InjectDetails",
         "type": "object",
         "description": "Details of the script or CSS to inject. Either the code or the file property must be set, but both may not be set at the same time.",
         "properties": {
           "code": {"type": "string", "optional": true, "description": "JavaScript or CSS code to inject.<br><br><b>Warning:</b><br>Be careful using the <code>code</code> parameter. Incorrect use of it may open your extension to <a href=\"https://en.wikipedia.org/wiki/Cross-site_scripting\">cross site scripting</a> attacks."},
           "file": {"type": "string", "optional": true, "description": "JavaScript or CSS file to inject."},
           "allFrames": {"type": "boolean", "optional": true, "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame."},
           "matchAboutBlank": {"type": "boolean", "optional": true, "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>."},
+          "frameId": {
+            "type": "integer",
+            "minimum": 0,
+            "optional": true,
+            "description": "The ID of the frame to inject the script into. This may not be used in combination with <code>allFrames</code>."
+          },
           "runAt": {
             "$ref": "RunAt",
             "optional": true,
             "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"."
           }
         }
       }
     ]
--- a/toolkit/modules/addons/WebNavigationFrames.jsm
+++ b/toolkit/modules/addons/WebNavigationFrames.jsm
@@ -74,43 +74,69 @@ function* iterateDocShellTree(docShell) 
   while (docShellsEnum.hasMoreElements()) {
     yield docShellsEnum.getNext();
   }
 
   return null;
 }
 
 /**
+ * Returns the frame ID of the given window. If the window is the
+ * top-level content window, its frame ID is 0. Otherwise, its frame ID
+ * is its outer window ID.
+ *
+ * @param {Window} window - The window to retrieve the frame ID for.
+ * @returns {number}
+ */
+function getFrameId(window) {
+  let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor)
+                       .getInterface(Ci.nsIDocShell);
+
+  if (!docShell.sameTypeParent) {
+    return 0;
+  }
+
+  let utils = window.getInterface(Ci.nsIDOMWindowUtils);
+  return utils.outerWindowID;
+}
+
+/**
  * Search for a frame starting from the passed root docShell and
  * convert it to its related frame detail representation.
  *
- * @param  {number}      windowId - the windowId of the frame to retrieve
+ * @param  {number}      frameId - the frame ID of the frame to retrieve, as
+ *                                 described in getFrameId.
  * @param  {nsIDocShell} docShell - the root docShell object
- * @return {FrameDetail} the FrameDetail JSON object which represents the docShell.
+ * @return {nsIDocShell?} the docShell with the given frameId, or null
+ *                        if no match.
  */
-function findFrame(windowId, rootDocShell) {
+function findDocShell(frameId, rootDocShell) {
   for (let docShell of iterateDocShellTree(rootDocShell)) {
-    if (windowId == getWindowId(docShellToWindow(docShell))) {
-      return convertDocShellToFrameDetail(docShell);
+    if (frameId == getFrameId(docShellToWindow(docShell))) {
+      return docShell;
     }
   }
 
   return null;
 }
 
 var WebNavigationFrames = {
   iterateDocShellTree,
 
+  findDocShell,
+
   getFrame(docShell, frameId) {
-    if (frameId == 0) {
-      return convertDocShellToFrameDetail(docShell);
+    let result = findDocShell(frameId, docShell);
+    if (result) {
+      return convertDocShellToFrameDetail(result);
     }
+    return null;
+  },
 
-    return findFrame(frameId, docShell);
-  },
+  getFrameId,
 
   getAllFrames(docShell) {
     return Array.from(iterateDocShellTree(docShell), convertDocShellToFrameDetail);
   },
 
   getWindowId,
   getParentWindowId,
 };