Bug 1252215 - [webext] Add EmbeddedWebExtensionsUtils helper. r?kmag,aswan draft
authorLuca Greco <lgreco@mozilla.com>
Wed, 25 May 2016 20:30:28 +0200
changeset 371016 9a1688234cc2eae8a90442044cb69b58bcf60277
parent 371015 dcc55af962e6e96cbf57c52e2aeb44d41036818e
child 371396 1bba502a2fcf26389e78d47dc1d3b97956924f69
push id19202
push userluca.greco@alcacoop.it
push dateWed, 25 May 2016 20:04:11 +0000
reviewerskmag, aswan
bugs1252215
milestone49.0a1
Bug 1252215 - [webext] Add EmbeddedWebExtensionsUtils helper. r?kmag,aswan This patch introduces a new exported helper (EmbeddedWebExtensionsUtils) and its related testcase. EmbeddedWebExtensionsUtils is going to be integrated in the XPIProvider to provide the Embedded WebExtension to the Classic Extensions which have enabled it in their install.rdf MozReview-Commit-ID: 7M1DRkXjGat
toolkit/components/extensions/ClassicExtensionsUtils.jsm
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/test_chrome_ext_classic_extension_embedding.html
toolkit/components/extensions/test/xpcshell/test_classic_extension_utils.js
--- a/toolkit/components/extensions/ClassicExtensionsUtils.jsm
+++ b/toolkit/components/extensions/ClassicExtensionsUtils.jsm
@@ -89,23 +89,85 @@ class ClassicExtensionContext extends Ba
     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: Promise.resolve(),
     };
   }
 
   /**
+   * Setup the startup promise exposed as `waitForStartup` in the api object which
+   * can be used from the caller to customize its behavior, and handle the set/reset
+   * of the `startupError` accordingly to the resolution/rejection of the current
+   * pending promise.
+   *
+   * This method raises an exception if it has been called with a promise already
+   * pending.
+   */
+  setupStartupPromise() {
+    if (this._pendingStartupPromise) {
+      throw Error("setupStartupPromise has been called with a pending startup promise");
+    }
+
+    this.api.startupError = null;
+    this.api.waitForStartup = new Promise((resolve, reject) => {
+      this._pendingStartupPromise = true;
+
+      // Save the resolve/reject methods related to the pendingStartupPromise.
+      this._resolveStartupPromise = resolve;
+      this._rejectStartupPromise = reject;
+    }).then(() => {
+      this._pendingStartupPromise = false;
+      // Reset the startupError when the startup promise has been resolved.
+      this.api.startupError = null;
+    }, (err) => {
+      this._pendingStartupPromise = false;
+      // Set the startupError when the startup promise has been rejected.
+      this.api.startupError = err;
+      // Let the promise to reject.
+      throw err;
+    });
+  }
+
+  /**
+   * Resolve the pending promise, if any.
+   */
+  resolveStartupPromise() {
+    if (this._pendingStartupPromise) {
+      this._resolveStartupPromise();
+    }
+  }
+
+  /**
+   * Reject the pending promise, if any.
+   *
+   * @param {any} err
+   *   The error object to pass to the rejection handlers.
+   *
+   */
+  rejectStartupPromise(err) {
+    if (this._pendingStartupPromise) {
+      this._rejectStartupPromise(err);
+    }
+  }
+
+  /**
    * Signal that the context is shutting down and call the unload method.
    * Called when the extension shuts down.
    */
   shutdown() {
     this.unload();
   }
 
   /**
@@ -138,11 +200,198 @@ class ClassicExtensionContext extends Ba
    * The principal associated to the context (which is a system principal as the other
    * code running in a classic 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 ClassicExtensionContext
+ * instance used to exchange messages with it.
+ */
+class EmbeddedWebExtension {
+  /**
+   * Create a new EmbeddedWebExtension given the addon id and the base resource URI of the
+   * container addon (the webextension resources will be loaded from the "webextension/"
+   * subdir of the base resource URI for the classic extension addon).
+   *
+   * @param {Object} containerAddonParams
+   *   An object with the following properties:
+   * @param {String} containerAddonParams.id
+   *   The Addon id of the Classic Extension which will contain the embedded webextension.
+   * @param {nsIURI} containerAddonParams.resourceURI
+   *   The nsIURI of the Classic Extension container addon.
+   */
+  constructor({id, resourceURI}) {
+    this.addonId = id;
+
+    let webextensionURI = Services.io.newURI(resourceURI.resolve("webextension/"), null, null);
+
+    this.webextension = new Extension({
+      id,
+      resourceURI: webextensionURI,
+    });
+
+    this.classicExtensionContext = new ClassicExtensionContext(this.webextension, {
+      senderURL: resourceURI.resolve("/"),
+    });
+
+    // Setup the startup promise.
+    this.classicExtensionContext.setupStartupPromise();
+
+    // destroy the ClassicExtensionContext cloneScope when
+    // the embedded webextensions is unloaded.
+    this.webextension.callOnClose({
+      close: () => {
+        this.classicExtensionContext.unload();
+      },
+    });
+  }
+
+  /**
+   * The messaging API object exposed to the classic extension code to be able to
+   * exchange messages with the associated webextension contexts.
+   */
+  get api() {
+    return this.classicExtensionContext.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.webextension.startup()
+      .then(() => {
+        // Resolve the waitForStartup promise and reset the startupError.
+        this.pendingStartup = false;
+        this.started = true;
+        this.classicExtensionContext.resolveStartupPromise();
+
+        if (this.pendingShutdown) {
+          this.shutdown();
+        }
+      })
+      .catch((err) => {
+        this.pendingStartup = false;
+        this.started = 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 = err.errors ? JSON.stringify(err.errors, null, 2) : err.message;
+
+        let startupError = `Embedded WebExtension startup failed for addonId ${id}: ${msg}`;
+        Cu.reportError(startupError);
+
+        this.classicExtensionContext.rejectStartupPromise(err);
+      });
+  }
+
+  /**
+   * Shuts down the embedded webextension.
+   */
+  shutdown() {
+    if (!this.started && this.pendingStartup) {
+      this.pendingShutdown = true;
+      return;
+    }
+
+    // Run shutdown now if the embedded webextension has been started and
+    // there is not pending startup.
+    this.pendingShutdown = false;
+    if (this.started && !this.webextension.hasShutdown) {
+      this.webextension.shutdown();
+
+      // Create a new waitForStartup promise and reset the startupError
+      this.classicExtensionContext.setupStartupPromise();
+    }
+  }
+}
+
+// Map of the existent embeddedWebExtensions by add-on id,
+// used to retrieve the EmbeddedWebExtension class instances
+// between calls to callBootstrapMethod in the XPIProvider.
+const embeddedWebExtensionsMap = new Map();
+
+/**
+ * This exported helper is used in the XPIProvider to automatically
+ * provide the ClassicExtensionContext instance to both bootstrap.js or
+ * SDK based add-ons that request it through their install.rdf metadata.
+ */
+var EmbeddedWebExtensionsUtils = {
+  /**
+   * Retrieve an existent EmbeddedWebExtension instance (or lazily created
+   * one if it doesn't exist yet) and return its associated API object.
+   *
+   * @param {Object} addonParam
+   *   An object with the following properties
+   * @param {String} addonParam.id
+   *   The Addon id of the Classic Extension which will contain the embedded webextension.
+   * @param {nsIURI} resourceURI
+   *   The nsIURI of the Classic Extension container addon.
+   */
+  getAPIFor({id, resourceURI}) {
+    let embeddedWebExtension;
+
+    // Create the embeddedWebExtension helper instance if it doesn't
+    // exist yet.
+    if (!embeddedWebExtensionsMap.has(id)) {
+      embeddedWebExtension = new EmbeddedWebExtension({id, resourceURI});
+      embeddedWebExtensionsMap.set(id, embeddedWebExtension);
+    } else {
+      embeddedWebExtension = embeddedWebExtensionsMap.get(id);
+    }
+
+    return embeddedWebExtension.api;
+  },
+
+  /**
+   * Start the embedded webextension instance if any.
+   *
+   * @param {Object} addonParam
+   *   An object with the following properties
+   * @param {String} addonParam.id
+   *   The Addon id of the Classic Extension which will contain the embedded webextension.
+   */
+  startupFor({id}) {
+    let embeddedWebExtension = embeddedWebExtensionsMap.get(id);
+    if (embeddedWebExtension) {
+      embeddedWebExtension.startup();
+    } else {
+      Cu.reportError(`No embedded WebExtension found for addonId ${id}`);
+    }
+  },
+
+  /**
+   * Stop the embedded webextension instance if any.
+   *
+   * @param {Object} addonParam
+   *   An object with the following properties
+   * @param {String} addonParam.id
+   *   The Addon id of the Classic Extension which will contain the embedded webextension.
+   */
+  shutdownFor({id}) {
+    let embeddedWebExtension = embeddedWebExtensionsMap.get(id);
+    if (embeddedWebExtension) {
+      embeddedWebExtension.shutdown();
+      embeddedWebExtensionsMap.delete(id);
+    } else {
+      Cu.reportError(`No embedded WebExtension found for addonId ${id}`);
+    }
+  },
+};
+
 this.ClassicExtensionsUtils = {
   ClassicExtensionContext,
+  EmbeddedWebExtensionsUtils,
 };
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -3,16 +3,17 @@ support-files =
   file_download.html
   file_download.txt
   interruptible.sjs
   file_sample.html
 
 [test_chrome_ext_classic_extension_context.html]
 [test_chrome_ext_classic_extension_context_contentscript.html]
 skip-if = (os == 'android') # User browser.tabs and TabManager. Bug 1258975 on android.
+[test_chrome_ext_classic_extension_embedding.html]
 [test_chrome_ext_background_debug_global.html]
 skip-if = (os == 'android') # android doesn't have devtools
 [test_chrome_ext_background_page.html]
 skip-if = true # bug 1267328; was (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]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_classic_extension_embedding.html
@@ -0,0 +1,227 @@
+<!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 {
+  ClassicExtensionsUtils: {
+    EmbeddedWebExtensionsUtils,
+  },
+} = SpecialPowers.Cu.import("resource://gre/modules/ClassicExtensionsUtils.jsm", {});
+
+const {
+  Extension,
+} = SpecialPowers.Cu.import("resource://gre/modules/Extension.jsm", {});
+
+/**
+ * This test case ensures that the EmbeddedWebExtensionsUtils:
+ *  - load the embedded webextension resources from a "/webextension/" dir
+ *    inside the XPI
+ *  - EmbeddedEebExtensionsUtils.getAPIFor 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)
+ *  - EmbeddedWebExtensionsUtils.startup/shutdown methods manage the embedded
+ *    webextension lifecycle as expected
+ *  - The port object receive a disconnect event when the embedded webextension is
+ *    shutting down
+ */
+add_task(function* test_embedded_webextension_utils() {
+  function backgroundScript() {
+    let port = chrome.runtime.connect();
+
+    port.onMessage.addListener((msg) => {
+      if (msg == "classic_extension -> webextension") {
+        port.postMessage("webextension -> classic_extension");
+        port.disconnect();
+      }
+    });
+  }
+
+  const id = "@test.embedded.web.extension";
+  let xpi = Extension.generateXPI(id, {
+    files: {
+      "webextension/manifest.json": `{
+       "applications": {"gecko": {"id": "${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(() => {
+    SpecialPowers.Services.obs.notifyObservers(xpi, "flush-cache-entry", null);
+    xpi.remove(false);
+  });
+
+  let fileURI = SpecialPowers.Services.io.newFileURI(xpi);
+  let resourceURI = SpecialPowers.Services.io.newURI(`jar:${fileURI.spec}!/`, null, null);
+
+  let embeddedWebExtensionAPI = EmbeddedWebExtensionsUtils.getAPIFor({
+    id, resourceURI,
+  }, resourceURI.resolve("/"));
+
+  ok(embeddedWebExtensionAPI, "Got the embeddedExtensionAPI object");
+
+  let waitConnectPort = new Promise(resolve => {
+    embeddedWebExtensionAPI.onConnect.addListener(port => {
+      resolve(port);
+    });
+  });
+
+  EmbeddedWebExtensionsUtils.startupFor({id});
+
+  info("waiting embeddedWebExtensionAPI.waitForStartup is resolved");
+  yield embeddedWebExtensionAPI.waitForStartup;
+  info("embeddedWebExtensionAPI.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("classic_extension -> webextension");
+
+  let msg = yield waitPortMessage;
+
+  is(msg, "webextension -> classic_extension",
+     "ClassicExtensionContext 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");
+
+  EmbeddedWebExtensionsUtils.shutdownFor({id});
+});
+
+function* createManifestErrorTestCase(id, xpi, expected) {
+  // Remove the generated xpi file and flush the its jar cache
+  // on cleanup.
+  SimpleTest.registerCleanupFunction(() => {
+    SpecialPowers.Services.obs.notifyObservers(xpi, "flush-cache-entry", null);
+    xpi.remove(false);
+  });
+
+  let fileURI = SpecialPowers.Services.io.newFileURI(xpi);
+  let resourceURI = SpecialPowers.Services.io.newURI(`jar:${fileURI.spec}!/`, null, null);
+
+  let embeddedWebExtensionAPI = EmbeddedWebExtensionsUtils.getAPIFor({
+    id, resourceURI,
+  }, resourceURI.resolve("/"));
+
+  ok(embeddedWebExtensionAPI, "Got the embeddedExtensionAPI object");
+
+  EmbeddedWebExtensionsUtils.startupFor({id});
+
+  let startupError;
+
+  try {
+    yield embeddedWebExtensionAPI.waitForStartup;
+  } catch (e) {
+    startupError = e;
+  }
+
+  info(`Got the startupError: ${startupError}`);
+
+  ok(startupError, "waitForStartup has been rejected and we got a startupError");
+  ok(embeddedWebExtensionAPI.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(embeddedWebExtensionAPI.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(embeddedWebExtensionAPI.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.
+  EmbeddedWebExtensionsUtils.shutdownFor({id});
+}
+
+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: "background.scripts: Expected array instead of {}",
+      },
+    },
+  ];
+
+  for (let {id, files, expected} of testCases) {
+    let xpi = Extension.generateXPI(id, {files});
+    yield createManifestErrorTestCase(id, xpi, expected);
+  }
+});
+
+</script>
+
+</body>
+</html>
--- a/toolkit/components/extensions/test/xpcshell/test_classic_extension_utils.js
+++ b/toolkit/components/extensions/test/xpcshell/test_classic_extension_utils.js
@@ -27,8 +27,47 @@ add_task(function* test_classic_extensio
     () => {
       let targetExtension = {id: "fake@target-extension"};
       new ClassicExtensionContext(targetExtension);
     },
     /targetExtension is not an Extension class instance/,
     "Got the expected exception on invalid target extension parameter"
   );
 });
+
+/**
+ * This test ensures that the ClassicExtensionContext raises the
+ * expected exception when setupStartupPromise is called when an
+ * existent promise is set.
+ */
+add_task(function* test_classic_extension_context_pending_startup_promise_exception() {
+  let targetExtension = new Extension({id: "@fake-addon-id"});
+  let ctx = new ClassicExtensionContext(targetExtension);
+  ctx.setupStartupPromise();
+
+  // Pending startup promise exception.
+  Assert.throws(() => {
+    ctx.setupStartupPromise();
+  }, "called with a pending startup promise");
+
+  // Resolve should clear the pending promise state.
+  ctx.resolveStartupPromise();
+  yield ctx.api.waitForStartup;
+
+  ctx.setupStartupPromise();
+
+  // Resolve should clear the pending promise state.
+  ctx.rejectStartupPromise("fake-error");
+  try {
+    yield ctx.api.waitForStartup;
+  } catch (e) {
+    do_check_eq(e, "fake-error");
+  }
+
+  // Can setup a new promise after a reject.
+  try {
+    ctx.setupStartupPromise();
+    ctx.resolveStartupPromise();
+    yield ctx.api.waitForStartup;
+  } catch (e) {
+    do_check_eq(e, null, `Got unexpected exception on waitForStartup: ${e} ${e.stack}`);
+  }
+});