Bug 1252215 - [webext] Add Embedded Extensions helper to LegacyExtensionsUtils. r?kmag,aswan draft
authorLuca Greco <lgreco@mozilla.com>
Sat, 16 Jul 2016 16:11:44 +0200
changeset 388654 0fe5da4e555007d6ed89724e524b8b5446c6d875
parent 388653 1200af00b11d82d7f5f474b13f149a265b72b070
child 388655 e09ac693f6b1deb7f90e53d5f5ccf8b8db286b14
push id23215
push userluca.greco@alcacoop.it
push dateSat, 16 Jul 2016 20:37:12 +0000
reviewerskmag, aswan
bugs1252215
milestone50.0a1
Bug 1252215 - [webext] Add Embedded Extensions helper to LegacyExtensionsUtils. r?kmag,aswan This patch introduces helper for the embedding of a webextension (and new related tests). The new exported helpers are going to be integrated in the XPIProvider to provide the Embedded WebExtension to the Legacy Extensions which have enabled it in their install.rdf MozReview-Commit-ID: 7M1DRkXjGat
toolkit/components/extensions/LegacyExtensionsUtils.jsm
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/test_chrome_ext_legacy_extension_embedding.html
--- a/toolkit/components/extensions/LegacyExtensionsUtils.jsm
+++ b/toolkit/components/extensions/LegacyExtensionsUtils.jsm
@@ -43,19 +43,22 @@ class LegacyExtensionContext extends Bas
    *   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.senderURL]
    *   An URL to mark the messages sent from this context
    *   (e.g. EmbeddedWebExtension sets it to the base url of the container addon).
+   * @param {Promise}  [optionalParams.startupPromise]
+   *   A Promise that will be used as the `waitForStartup` Promise provided in the
+   *   exposed API.
    */
   constructor(targetExtension, optionalParams = {}) {
-    let {senderURL} = optionalParams;
+    let {senderURL, startupPromise} = optionalParams;
 
     if (targetExtension) {
       if (!(targetExtension instanceof Extension)) {
         throw new Error("targetExtension is not an Extension class instance");
       }
     } else {
       throw new Error("targetExtension parameter is mandatory");
     }
@@ -91,19 +94,25 @@ class LegacyExtensionContext extends Bas
     this.addonPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
 
     this.messenger = new Messenger(this, [Services.mm, Services.ppmm],
                                        sender, filter, delegate);
 
     this._cloneScope = Cu.Sandbox(this.addonPrincipal, {});
     Cu.setSandboxMetadata(this._cloneScope, {addonId: targetExtension.id});
 
+    this._pendingStartupPromise = false;
+
     this.api = {
       onConnect: this.messenger.onConnect("runtime.onConnect"),
       onMessage: this.messenger.onMessage("runtime.onMessage"),
+      // By default startupError is null and waitForStartup a resolved promise,
+      // this behavior can be redefined using the `setupStartupPromise` method.
+      startupError: null,
+      waitForStartup: startupPromise || Promise.resolve(),
     };
   }
 
   /**
    * Signal that the context is shutting down and call the unload method.
    * Called when the extension shuts down.
    */
   shutdown() {
@@ -140,11 +149,150 @@ class LegacyExtensionContext extends Bas
    * The principal associated to the context (which is a system principal as the other
    * code running in a legacy extension).
    */
   get principal() {
     return this.addonPrincipal;
   }
 }
 
+/**
+ * Instances of this class are used internally by the exported EmbeddedWebExtensionsUtils
+ * to manage the embedded webextension instance and the related LegacyExtensionContext
+ * instance used to exchange messages with it.
+ */
+class EmbeddedExtension {
+  /**
+   * Create a new EmbeddedExtension given the add-on id and the base resource URI of the
+   * container add-on (the webextension resources will be loaded from the "webextension/"
+   * subdir of the base resource URI for the legacy extension add-on).
+   *
+   * @param {Object} containerAddonParams
+   *   An object with the following properties:
+   * @param {string} containerAddonParams.id
+   *   The Add-on id of the Legacy Extension which will contain the embedded webextension.
+   * @param {nsIURI} containerAddonParams.resourceURI
+   *   The nsIURI of the Legacy Extension container add-on.
+   */
+  constructor({id, resourceURI}) {
+    this.addonId = id;
+
+    let embeddedExtensionURI = Services.io.newURI("webextension/", null, resourceURI);
+
+    // This is the instance of the WebExtension embedded in the hybrid add-on.
+    this.embeddedExtension = new Extension({
+      id,
+      resourceURI: embeddedExtensionURI,
+    });
+
+    // Setup the startup promise.
+    this.startupPromise = new Promise((resolve, reject) => {
+      this.startupResolve = resolve;
+      this.startupReject = reject;
+    });
+
+    // Setup status flags.
+    this.started = false;
+    this.pendingStartup = false;
+    this.pendingShutdown = false;
+
+    this.containerContext = new LegacyExtensionContext(this.embeddedExtension, {
+      senderURL: resourceURI.resolve("/"),
+      startupPromise: this.startupPromise,
+    });
+
+    // Destroy the LegacyExtensionContext cloneScope when
+    // the embedded webextensions is unloaded.
+    this.embeddedExtension.callOnClose({
+      close: () => {
+        this.containerContext.unload();
+      },
+    });
+  }
+
+  /**
+   * The messaging API object exposed to the legacy extension code to be able to
+   * exchange messages with the associated webextension contexts.
+   */
+  get api() {
+    return this.containerContext.api;
+  }
+
+  /**
+   * Start the embedded webextension (and report any loading error in the Browser console).
+   */
+  startup() {
+    if (this.started || this.pendingStartup) {
+      return;
+    }
+
+    this.pendingStartup = true;
+
+    this.embeddedExtension.startup()
+      .then(() => {
+        // Resolve the waitForStartup promise and reset the startupError.
+        this.pendingStartup = false;
+        this.started = true;
+
+        this.startupResolve();
+
+        if (this.pendingShutdown) {
+          // There is a pending shutdown to be called.
+          this.pendingShutdown = false;
+          this.shutdown();
+        }
+      })
+      .catch((err) => {
+        this.pendingStartup = false;
+        this.started = false;
+
+        // The add-on has never been started, clear the pendingShutdown if set.
+        this.pendingShutdown = false;
+
+        // Report an error if the embedded webextension fails during
+        // its startup and reject the waitForStartup promise
+        // (with the error object as parameter).
+        let id = this.addonId;
+
+        // Adjust the error message to nicely handle both the exception and
+        // the validation errors scenarios.
+        let msg;
+        if (err.errors) {
+          msg = JSON.stringify(err.errors, null, 2);
+        } else {
+          msg = err.message;
+        }
+
+        let startupError = `Embedded WebExtension startup failed for addonId ${id}: ${msg}`;
+        Cu.reportError(startupError);
+
+        this.containerContext.api.startupError = err;
+        this.startupReject(err);
+      });
+  }
+
+  /**
+   * Shuts down the embedded webextension.
+   */
+  shutdown() {
+    if (this.pendingShutdown) {
+      // There is already a pending shutdown.
+      return;
+    }
+
+    if (!this.started && this.pendingStartup) {
+      // Queue a shutdown once the startup is completed.
+      this.pendingShutdown = true;
+      return;
+    }
+
+    // Run shutdown now if the embedded webextension has been started and
+    // there is not pending startup.
+    if (this.started && !this.embeddedExtension.hasShutdown) {
+      this.embeddedExtension.shutdown();
+    }
+  }
+}
+
 this.LegacyExtensionsUtils = {
   LegacyExtensionContext,
+  EmbeddedExtension,
 };
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -12,16 +12,17 @@ skip-if = (os == 'android') # android do
 skip-if = (toolkit == 'android') # android doesn't have devtools
 [test_chrome_ext_downloads_download.html]
 [test_chrome_ext_downloads_misc.html]
 [test_chrome_ext_downloads_search.html]
 [test_chrome_ext_eventpage_warning.html]
 [test_chrome_ext_legacy_extension_context.html]
 [test_chrome_ext_legacy_extension_context_contentscript.html]
 skip-if = (os == 'android') # User browser.tabs and TabManager. Bug 1258975 on android.
+[test_chrome_ext_legacy_extension_embedding.html]
 [test_chrome_ext_native_messaging.html]
 skip-if = os == "android"  # native messaging is not supported on android
 [test_chrome_ext_contentscript_unrecognizedprop_warning.html]
 skip-if = (os == 'android') # browser.tabs is undefined. Bug 1258975 on android.
 [test_chrome_ext_webnavigation_resolved_urls.html]
 skip-if = (os == 'android') # browser.tabs is undefined. Bug 1258975 on android.
 [test_chrome_native_messaging_paths.html]
 skip-if = os != "mac" && os != "linux"
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_legacy_extension_embedding.html
@@ -0,0 +1,242 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Test for simple WebExtension</title>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {
+  utils: Cu,
+} = Components;
+
+const {
+  Services,
+} = Cu.import("resource://gre/modules/Services.jsm");
+
+const {
+  LegacyExtensionsUtils,
+} = Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm");
+
+const {
+  Extension,
+} = Cu.import("resource://gre/modules/Extension.jsm");
+
+/**
+ * This test case ensures that the LegacyExtensionsUtils.EmbeddedExtension:
+ *  - load the embedded webextension resources from a "/webextension/" dir
+ *    inside the XPI.
+ *  - EmbeddedExtension.prototype.api returns an API object which exposes
+ *    a working `runtime.onConnect` event object (e.g. the API can receive a port
+ *    when the embedded webextension is started  and it can exchange messages
+ *    with the background page).
+ *  - EmbeddedExtension.prototype.startup/shutdown methods manage the embedded
+ *    webextension lifecycle as expected.
+ */
+add_task(function* test_embedded_webextension_utils() {
+  function backgroundScript() {
+    let port = browser.runtime.connect();
+
+    port.onMessage.addListener((msg) => {
+      if (msg == "legacy_extension -> webextension") {
+        port.postMessage("webextension -> legacy_extension");
+        port.disconnect();
+      }
+    });
+  }
+
+  const id = "@test.embedded.web.extension";
+
+  // Extensions.generateXPI is used here (and in the other hybrid addons tests in this same
+  // test dir) to be able to generate an xpi with the directory layout that we expect from
+  // an hybrid legacy+webextension addon (where all the embedded webextension resources are
+  // loaded from a 'webextension/' directory).
+  let xpi = Extension.generateXPI(id, {
+    files: {
+      "webextension/manifest.json": {
+        applications: {gecko: {id}},
+        name: "embedded webextension name",
+        manifest_version: 2,
+        version: "1.0",
+        background: {
+          scripts: ["bg.js"],
+        },
+      },
+      "webextension/bg.js": `new ${backgroundScript}`,
+    },
+  });
+
+  // Remove the generated xpi file and flush the its jar cache
+  // on cleanup.
+  SimpleTest.registerCleanupFunction(() => {
+    Services.obs.notifyObservers(xpi, "flush-cache-entry", null);
+    xpi.remove(false);
+  });
+
+  let fileURI = Services.io.newFileURI(xpi);
+  let resourceURI = Services.io.newURI(`jar:${fileURI.spec}!/`, null, null);
+
+  const {EmbeddedExtension} = LegacyExtensionsUtils;
+  let embeddedExtension = new EmbeddedExtension({
+    id, resourceURI,
+  }, resourceURI.resolve("/"));
+
+  ok(embeddedExtension, "Got the embeddedExtension object");
+
+  let embeddedExtensionAPI = embeddedExtension.api;
+  ok(embeddedExtensionAPI, "Got the embeddedExtensionAPI object");
+
+  let waitConnectPort = new Promise(resolve => {
+    embeddedExtensionAPI.onConnect.addListener(port => {
+      resolve(port);
+    });
+  });
+
+  embeddedExtension.startup();
+
+  info("waiting embeddedExtensionAPI.waitForStartup is resolved");
+  yield embeddedExtensionAPI.waitForStartup;
+  info("embeddedExtensionAPI.waitForStartup resolved as expected");
+
+  let port = yield waitConnectPort;
+
+  ok(port, "Got the Port API object");
+
+  let waitPortMessage = new Promise(resolve => {
+    port.onMessage.addListener((msg) => {
+      resolve(msg);
+    });
+  });
+
+  port.postMessage("legacy_extension -> webextension");
+
+  let msg = yield waitPortMessage;
+
+  is(msg, "webextension -> legacy_extension",
+     "LegacyExtensionContext received the expected message from the webextension");
+
+  let waitForDisconnect = new Promise(resolve => {
+    port.onDisconnect.addListener(resolve);
+  });
+
+  info("Wait for the disconnect port event");
+  yield waitForDisconnect;
+  info("Got the disconnect port event");
+
+  embeddedExtension.shutdown();
+});
+
+function* createManifestErrorTestCase(id, xpi, expected) {
+  // Remove the generated xpi file and flush the its jar cache
+  // on cleanup.
+  SimpleTest.registerCleanupFunction(() => {
+    Services.obs.notifyObservers(xpi, "flush-cache-entry", null);
+    xpi.remove(false);
+  });
+
+  let fileURI = Services.io.newFileURI(xpi);
+  let resourceURI = Services.io.newURI(`jar:${fileURI.spec}!/`, null, null);
+
+  const {EmbeddedExtension} = LegacyExtensionsUtils;
+  let embeddedExtension = new EmbeddedExtension({
+    id, resourceURI,
+  }, resourceURI.resolve("/"));
+
+  let embeddedExtensionAPI = embeddedExtension.api;
+  ok(embeddedExtensionAPI, "Got the embeddedExtensionAPI object");
+
+  embeddedExtension.startup();
+
+  let startupError;
+
+  try {
+    yield embeddedExtensionAPI.waitForStartup;
+  } catch (e) {
+    startupError = e;
+  }
+
+  info(`Got the startupError: ${startupError}`);
+
+  ok(startupError, "waitForStartup has been rejected and we got a startupError");
+  ok(embeddedExtensionAPI.startupError,
+     "The startupError is available in the api object");
+
+  if (expected.isExceptionError) {
+    ok(startupError.message, "Got an exception error message");
+    ok(startupError.message.includes(expected.errorMessageIncludes),
+       `The error message includes the expected error message "${expected.errorMessageIncludes}"`);
+    is(embeddedExtensionAPI.startupError.message, startupError.message,
+       "the api.startupError is equal to the rejected error");
+    info(`Got the Exception: ${startupError} - ${startupError.stack}`);
+  }
+
+  if (expected.isValidationErrors) {
+    ok(startupError.errors, "Got validation errors as expected");
+    ok(startupError.errors.some((msg) => msg.includes(expected.validationErrorsIncludes)),
+       `The validation errors include the expected error message "${expected.validationErrorsIncludes}"`);
+
+    isDeeply(embeddedExtensionAPI.startupError, String(startupError),
+             "the api.startupError is equal to the rejected error");
+    info(`Got Validation Errors: ${JSON.stringify(startupError.errors, null, 2)}`);
+  }
+
+  // Shutdown a "never-started" addon with an embedded webextension should not
+  // raise any exception, and if it does this test will fail.
+  embeddedExtension.shutdown();
+}
+
+add_task(function* test_embedded_webextension_utils_manifest_errors() {
+  const isExceptionError = true;
+  const isValidationErrors = true;
+
+  let testCases = [
+    {
+      id: "empty-manifest@test.embedded.web.extension",
+      files: {
+        "webextension/manifest.json": ``,
+      },
+      expected: {isExceptionError, errorMessageIncludes: "(NS_BASE_STREAM_CLOSED)"},
+    },
+    {
+      id: "invalid-json-manifest@test.embedded.web.extension",
+      files: {
+        "webextension/manifest.json": `{ "name": }`,
+      },
+      expected: {isExceptionError, errorMessageIncludes: "JSON.parse:"},
+    },
+    {
+      id: "blocking-manifest-validation-error@test.embedded.web.extension",
+      files: {
+        "webextension/manifest.json": {
+          name: "embedded webextension name",
+          manifest_version: 2,
+          version: "1.0",
+          background: {
+            scripts: {},
+          },
+        },
+      },
+      expected: {
+        isValidationErrors,
+        validationErrorsIncludes: "Error processing background:",
+      },
+    },
+  ];
+
+  for (let {id, files, expected} of testCases) {
+    let xpi = Extension.generateXPI(id, {files});
+    yield createManifestErrorTestCase(id, xpi, expected);
+  }
+});
+
+</script>
+
+</body>
+</html>