Bug 1252215 - [webext] LegacyExtensionsUtils JSM module and LegacyExtensionContext helper. r?kmag,aswan draft
authorLuca Greco <lgreco@mozilla.com>
Wed, 24 Aug 2016 18:31:36 +0200
changeset 409625 5ca9e920dc49265650628eb7b1d6fcfeaad9882b
parent 409624 5e1165d6f7ae3e37fed200666f7a4bfe5fa86ccc
child 409626 f18f93e3b514ddcd8fea675ba5c6d7a78ae9b18f
push id28493
push userluca.greco@alcacoop.it
push dateSat, 03 Sep 2016 18:07:33 +0000
reviewerskmag, aswan
bugs1252215
milestone51.0a1
Bug 1252215 - [webext] LegacyExtensionsUtils JSM module and LegacyExtensionContext helper. r?kmag,aswan - this new module contains helpers to be able to receive connections originated from a webextension context from a legacy extension context (implemented by the `LegacyExtensionContext` class exported from this new jsm module) - two new test files (an xpcshell-test and a mochitest-browser) ensures that the LegacyExtensionContext can receive a Port object and exchange messages with a background page and a content script (the content script test is in a different test file because it doesn't currently work on android, because it needs the browser.tabs API and the TabManager internal helper) MozReview-Commit-ID: DS1NTXk0fB6
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_legacy_extension_context_contentscript.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/LegacyExtensionsUtils.jsm
toolkit/components/extensions/moz.build
toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_context.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -35,16 +35,17 @@ tags = webextensions
 [browser_ext_contextMenus_icons.js]
 [browser_ext_contextMenus_radioGroups.js]
 [browser_ext_contextMenus_uninstall.js]
 [browser_ext_contextMenus_urlPatterns.js]
 [browser_ext_currentWindow.js]
 [browser_ext_getViews.js]
 [browser_ext_incognito_popup.js]
 [browser_ext_lastError.js]
+[browser_ext_legacy_extension_context_contentscript.js]
 [browser_ext_optionsPage_privileges.js]
 [browser_ext_pageAction_context.js]
 [browser_ext_pageAction_popup.js]
 [browser_ext_pageAction_popup_resize.js]
 [browser_ext_pageAction_simple.js]
 [browser_ext_pageAction_title.js]
 [browser_ext_popup_api_injection.js]
 [browser_ext_popup_background.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_legacy_extension_context_contentscript.js
@@ -0,0 +1,171 @@
+"use strict";
+
+const {
+  LegacyExtensionContext,
+} = Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm", {});
+
+function promiseAddonStartup(extension) {
+  const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
+
+  return new Promise((resolve) => {
+    let listener = (evt, extensionInstance) => {
+      Management.off("startup", listener);
+      resolve(extensionInstance);
+    };
+    Management.on("startup", listener);
+  });
+}
+
+/**
+ * This test case ensures that the LegacyExtensionContext can receive a connection
+ * from a content script and that the received port contains the expected sender
+ * tab info.
+ */
+add_task(function* test_legacy_extension_context_contentscript_connection() {
+  function backgroundScript() {
+    // Extract the assigned uuid from the background page url and send it
+    // in a test message.
+    let uuid = window.location.hostname;
+
+    browser.test.onMessage.addListener(msg => {
+      if (msg == "open-test-tab") {
+        browser.tabs.create({url: "http://example.com/"})
+          .then(tab => browser.test.sendMessage("get-expected-sender-info", {
+            uuid, tab,
+          }));
+      } else if (msg == "close-current-tab") {
+        browser.tabs.query({active: true})
+          .then(tabs => browser.tabs.remove(tabs[0].id))
+          .then(() => browser.test.sendMessage("current-tab-closed", true))
+          .catch(() => browser.test.sendMessage("current-tab-closed", false));
+      }
+    });
+
+    browser.test.sendMessage("ready");
+  }
+
+  function contentScript() {
+    browser.runtime.sendMessage("webextension -> legacy_extension message", (reply) => {
+      browser.test.assertEq("legacy_extension -> webextension reply", reply,
+                           "Got the expected reply from the LegacyExtensionContext");
+      browser.test.sendMessage("got-reply-message");
+    });
+
+    let port = browser.runtime.connect();
+
+    port.onMessage.addListener(msg => {
+      browser.test.assertEq("legacy_extension -> webextension port message", msg,
+                            "Got the expected message from the LegacyExtensionContext");
+      port.postMessage("webextension -> legacy_extension port message");
+    });
+  }
+
+  let extensionData = {
+    background: `new ${backgroundScript}`,
+    manifest: {
+      content_scripts: [
+        {
+          matches: ["http://example.com/*"],
+          js: ["content-script.js"],
+        },
+      ],
+    },
+    files: {
+      "content-script.js": `new ${contentScript}`,
+    },
+  };
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+  let waitForExtensionReady = extension.awaitMessage("ready");
+
+  let waitForExtensionInstance = promiseAddonStartup(extension);
+
+  extension.startup();
+
+  let extensionInstance = yield waitForExtensionInstance;
+
+  // Connect to the target extension.id as an external context
+  // using the given custom sender info.
+  let legacyContext = new LegacyExtensionContext(extensionInstance);
+
+  let waitConnectPort = new Promise(resolve => {
+    let {browser} = legacyContext.api;
+    browser.runtime.onConnect.addListener(port => {
+      resolve(port);
+    });
+  });
+
+  let waitMessage = new Promise(resolve => {
+    let {browser} = legacyContext.api;
+    browser.runtime.onMessage.addListener((singleMsg, msgSender, sendReply) => {
+      sendReply("legacy_extension -> webextension reply");
+      resolve({singleMsg, msgSender});
+    });
+  });
+
+  is(legacyContext.type, "legacy_extension",
+     "LegacyExtensionContext instance has the expected type");
+
+  ok(legacyContext.api, "Got the API object");
+
+  yield waitForExtensionReady;
+
+  extension.sendMessage("open-test-tab");
+
+  let {uuid, tab} = yield extension.awaitMessage("get-expected-sender-info");
+
+  let {singleMsg, msgSender} = yield waitMessage;
+  is(singleMsg, "webextension -> legacy_extension message",
+     "Got the expected message");
+  ok(msgSender, "Got a message sender object");
+
+  is(msgSender.id, uuid, "The sender has the expected id property");
+  is(msgSender.url, "http://example.com/", "The sender has the expected url property");
+  ok(msgSender.tab, "The sender has a tab property");
+  is(msgSender.tab.id, tab.id, "The port sender has the expected tab.id");
+
+  // Wait confirmation that the reply has been received.
+  yield extension.awaitMessage("got-reply-message");
+
+  let port = yield waitConnectPort;
+
+  ok(port, "Got the Port API object");
+  ok(port.sender, "The port has a sender property");
+
+  is(port.sender.id, uuid, "The port sender has an id property");
+  is(port.sender.url, "http://example.com/", "The port sender has the expected url property");
+  ok(port.sender.tab, "The port sender has a tab property");
+  is(port.sender.tab.id, tab.id, "The port sender has the expected tab.id");
+
+  let waitPortMessage = new Promise(resolve => {
+    port.onMessage.addListener((msg) => {
+      resolve(msg);
+    });
+  });
+
+  port.postMessage("legacy_extension -> webextension port message");
+
+  let msg = yield waitPortMessage;
+
+  is(msg, "webextension -> legacy_extension port message",
+     "LegacyExtensionContext received the expected message from the webextension");
+
+  let waitForDisconnect = new Promise(resolve => {
+    port.onDisconnect.addListener(resolve);
+  });
+
+  let waitForTestDone = extension.awaitMessage("current-tab-closed");
+
+  extension.sendMessage("close-current-tab");
+
+  yield waitForDisconnect;
+
+  info("Got the disconnect event on tab closed");
+
+  let success = yield waitForTestDone;
+
+  ok(success, "Test completed successfully");
+
+  yield extension.unload();
+});
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1,15 +1,15 @@
 /* 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";
 
-this.EXPORTED_SYMBOLS = ["Extension", "ExtensionData"];
+this.EXPORTED_SYMBOLS = ["Extension", "ExtensionData", "ExtensionContext"];
 
 /* globals Extension ExtensionData */
 
 /*
  * This file is the main entry point for extensions. When an extension
  * loads, its bootstrap.js file creates a Extension instance
  * and calls .startup() on it. It calls .shutdown() when the extension
  * unloads. Extension manages any extension-specific state in
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/LegacyExtensionsUtils.jsm
@@ -0,0 +1,104 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["LegacyExtensionsUtils"];
+
+/* exported LegacyExtensionsUtils, LegacyExtensionContext */
+
+/**
+ * This file exports helpers for Legacy Extensions that want to embed a webextensions
+ * and exchange messages with the embedded WebExtension.
+ */
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+// Lazy imports.
+XPCOMUtils.defineLazyModuleGetter(this, "Extension",
+                                  "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionContext",
+                                  "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+                                  "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+
+/**
+ * Instances created from this class provide to a legacy extension
+ * a simple API to exchange messages with a webextension.
+ */
+var LegacyExtensionContext = class extends ExtensionContext {
+  /**
+   * Create a new LegacyExtensionContext given a target Extension instance and an optional
+   * url (which can be used to recognize the messages of container context).
+   *
+   * @param {Extension} targetExtension
+   *   The webextension instance associated with this context. This will be the
+   *   instance of the newly created embedded webextension when this class is
+   *   used through the EmbeddedWebExtensionsUtils.
+   * @param {Object} [optionalParams]
+   *   An object with the following properties:
+   * @param {string}  [optionalParams.url]
+   *   An URL to mark the messages sent from this context
+   *   (e.g. EmbeddedWebExtension sets it to the base url of the container addon).
+   */
+  constructor(targetExtension, optionalParams = {}) {
+    let {url} = optionalParams;
+
+    super(targetExtension, {
+      contentWindow: null,
+      uri: NetUtil.newURI(url || "about:blank"),
+      type: "legacy_extension",
+    });
+
+    // Legacy Extensions (xul overlays, bootstrap restartless and Addon SDK)
+    // runs with a systemPrincipal.
+    let addonPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+    Object.defineProperty(
+      this, "principal",
+      {value: addonPrincipal, enumerable: true, configurable: true}
+    );
+
+    let cloneScope = Cu.Sandbox(this.principal, {});
+    Cu.setSandboxMetadata(cloneScope, {addonId: targetExtension.id});
+    Object.defineProperty(
+      this, "cloneScope",
+      {value: cloneScope, enumerable: true, configurable: true, writable: true}
+    );
+
+    this.api = {
+      browser: {
+        runtime: {
+          onConnect: this.messenger.onConnect("runtime.onConnect"),
+          onMessage: this.messenger.onMessage("runtime.onMessage"),
+        },
+      },
+    };
+  }
+
+  /**
+   * This method is called when the extension shuts down or is unloaded,
+   * and it nukes the cloneScope sandbox, if any.
+   */
+  unload() {
+    if (this.unloaded) {
+      throw new Error("Error trying to unload LegacyExtensionContext twice.");
+    }
+    super.unload();
+    Cu.nukeSandbox(this.cloneScope);
+    this.cloneScope = null;
+  }
+
+  /**
+   * The LegacyExtensionContext is not a visible context.
+   */
+  get externallyVisible() {
+    return false;
+  }
+};
+
+this.LegacyExtensionsUtils = {};
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -6,16 +6,17 @@
 
 EXTRA_JS_MODULES += [
     'Extension.jsm',
     'ExtensionAPI.jsm',
     'ExtensionContent.jsm',
     'ExtensionManagement.jsm',
     'ExtensionStorage.jsm',
     'ExtensionUtils.jsm',
+    'LegacyExtensionsUtils.jsm',
     'MessageChannel.jsm',
     'NativeMessaging.jsm',
     'Schemas.jsm',
 ]
 
 EXTRA_COMPONENTS += [
     'extensions-toolkit.manifest',
 ]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_context.js
@@ -0,0 +1,156 @@
+"use strict";
+
+/* globals browser */
+
+Cu.import("resource://gre/modules/Extension.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const {LegacyExtensionContext} = Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm");
+
+/**
+ * This test case ensures that LegacyExtensionContext instances:
+ *  - expose the expected API object and can join the messaging
+ *    of a webextension given its addon id
+ *  - the exposed API object can receive a port related to a `runtime.connect`
+ *    Port created in the webextension's background page
+ *  - the received Port instance can exchange messages with the background page
+ *  - the received Port receive a disconnect event when the webextension is
+ *    shutting down
+ */
+add_task(function* test_legacy_extension_context() {
+  function backgroundScript() {
+    let bgURL = window.location.href;
+
+    let extensionInfo = {
+      bgURL,
+      // Extract the assigned uuid from the background page url.
+      uuid: window.location.hostname,
+    };
+
+    browser.test.sendMessage("webextension-ready", extensionInfo);
+
+    browser.test.onMessage.addListener(msg => {
+      if (msg == "do-send-message") {
+        browser.runtime.sendMessage("webextension -> legacy_extension message").then(reply => {
+          browser.test.assertEq("legacy_extension -> webextension reply", reply,
+                                "Got the expected message from the LegacyExtensionContext");
+          browser.test.sendMessage("got-reply-message");
+        });
+      } else if (msg == "do-connect") {
+        let port = browser.runtime.connect();
+
+        port.onMessage.addListener(msg => {
+          browser.test.assertEq("legacy_extension -> webextension port message", msg,
+                                "Got the expected message from the LegacyExtensionContext");
+          port.postMessage("webextension -> legacy_extension port message");
+        });
+      }
+    });
+  }
+
+  let extensionData = {
+    background: "new " + backgroundScript,
+  };
+
+  let extension = Extension.generate(extensionData);
+
+  let waitForExtensionInfo = new Promise((resolve, reject) => {
+    extension.on("test-message", function testMessageListener(kind, msg, ...args) {
+      if (msg != "webextension-ready") {
+        reject(new Error(`Got an unexpected test-message: ${msg}`));
+      } else {
+        extension.off("test-message", testMessageListener);
+        resolve(args[0]);
+      }
+    });
+  });
+
+  yield extension.startup();
+
+  let extensionInfo = yield waitForExtensionInfo;
+
+  // Connect to the target extension.id as an external context
+  // using the given custom sender info.
+  let legacyContext = new LegacyExtensionContext(extension);
+
+  equal(legacyContext.type, "legacy_extension",
+     "LegacyExtensionContext instance has the expected type");
+
+  ok(legacyContext.api, "Got the expected API object");
+  ok(legacyContext.api.browser, "Got the expected browser property");
+
+  let waitMessage = new Promise(resolve => {
+    const {browser} = legacyContext.api;
+    browser.runtime.onMessage.addListener((singleMsg, msgSender) => {
+      resolve({singleMsg, msgSender});
+
+      // Send a reply to the sender.
+      return Promise.resolve("legacy_extension -> webextension reply");
+    });
+  });
+
+  extension.testMessage("do-send-message");
+
+  let {singleMsg, msgSender} = yield waitMessage;
+  equal(singleMsg, "webextension -> legacy_extension message",
+     "Got the expected message");
+  ok(msgSender, "Got a message sender object");
+
+  equal(msgSender.id, extensionInfo.uuid, "The sender has the expected id property");
+  equal(msgSender.url, extensionInfo.bgURL, "The sender has the expected url property");
+
+  // Wait confirmation that the reply has been received.
+  yield new Promise((resolve, reject) => {
+    extension.on("test-message", function testMessageListener(kind, msg, ...args) {
+      if (msg != "got-reply-message") {
+        reject(new Error(`Got an unexpected test-message: ${msg}`));
+      } else {
+        extension.off("test-message", testMessageListener);
+        resolve();
+      }
+    });
+  });
+
+  let waitConnectPort = new Promise(resolve => {
+    let {browser} = legacyContext.api;
+    browser.runtime.onConnect.addListener(port => {
+      resolve(port);
+    });
+  });
+
+  extension.testMessage("do-connect");
+
+  let port = yield waitConnectPort;
+
+  ok(port, "Got the Port API object");
+  ok(port.sender, "The port has a sender property");
+  equal(port.sender.id, extensionInfo.uuid,
+     "The port sender has the expected id property");
+  equal(port.sender.url, extensionInfo.bgURL,
+     "The port sender has the expected url property");
+
+  let waitPortMessage = new Promise(resolve => {
+    port.onMessage.addListener((msg) => {
+      resolve(msg);
+    });
+  });
+
+  port.postMessage("legacy_extension -> webextension port message");
+
+  let msg = yield waitPortMessage;
+
+  equal(msg, "webextension -> legacy_extension port message",
+     "LegacyExtensionContext received the expected message from the webextension");
+
+  let waitForDisconnect = new Promise(resolve => {
+    port.onDisconnect.addListener(resolve);
+  });
+
+  extension.shutdown();
+
+  yield waitForDisconnect;
+
+  do_print("Got the disconnect event on unload");
+
+  legacyContext.shutdown();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -49,12 +49,13 @@ skip-if = release_build
 [test_ext_runtime_sendMessage_no_receiver.js]
 [test_ext_runtime_sendMessage_self.js]
 [test_ext_schemas.js]
 [test_ext_schemas_api_injection.js]
 [test_ext_schemas_restrictions.js]
 [test_ext_simple.js]
 [test_ext_storage.js]
 [test_getAPILevelForWindow.js]
+[test_ext_legacy_extension_context.js]
 [test_locale_converter.js]
 [test_locale_data.js]
 [test_native_messaging.js]
 skip-if = os == "android"