Bug 1213993: [webext] Support frameId/allFrames/runAt in browser.tabs.executeScript and insertCSS. r?billm draft
authorKris Maglione <maglione.k@gmail.com>
Mon, 08 Feb 2016 17:40:02 -0800
changeset 332669 7fc8714a19b692779d27984942cb4afbc451441a
parent 330476 307c1bd67eb60eb2e4213c5aed1b9460c1a5ed06
child 514583 5a00c0d10f6326ec133a84be1f7096f4fc11fc59
push id11204
push usermaglione.k@gmail.com
push dateSat, 20 Feb 2016 01:23:22 +0000
reviewersbillm
bugs1213993
milestone47.0a1
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/extension_types.json
toolkit/modules/addons/WebNavigationFrames.jsm
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -492,25 +492,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>"];
@@ -526,32 +535,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_currentWindow.js]
 [browser_ext_browserAction_simple.js]
 [browser_ext_browserAction_pageAction_icon.js]
 [browser_ext_browserAction_context.js]
 [browser_ext_browserAction_disabled.js]
 [browser_ext_pageAction_context.js]
@@ -22,21 +24,22 @@ support-files =
 [browser_ext_getViews.js]
 [browser_ext_lastError.js]
 [browser_ext_runtime_setUninstallURL.js]
 [browser_ext_tabs_audio.js]
 [browser_ext_tabs_captureVisibleTab.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_update.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_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,156 @@
 "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 => {
+      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.runtime.onMessage.addListener(message => {
-      browser.test.assertEq("script ran", message, "Expected runtime message");
-      browser.test.sendMessage("got message", message);
+        browser.tabs.executeScript({
+          code: "location.href;",
+          runAt: "document_end",
+          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;",
+          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");
+        }),
+
+        browser.runtime.onMessage.addListener(message => {
+          browser.test.assertEq("script ran", message, "Expected runtime message");
+        }),
+      ]);
+    }).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
@@ -146,43 +146,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
@@ -198,69 +204,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});
@@ -329,16 +334,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" });
@@ -352,17 +378,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(),
 
@@ -373,22 +399,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) {
@@ -455,35 +481,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) {
@@ -519,21 +558,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.
@@ -558,23 +597,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;
@@ -685,29 +732,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) {
@@ -726,22 +770,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/extension_types.json
+++ b/toolkit/components/extensions/schemas/extension_types.json
@@ -42,16 +42,21 @@
         "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",
+            "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,41 +74,66 @@ 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.
  */
-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 = {
+  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,
 };