Bug 1269342 - [webext] Add EmbeddedWebExtensionsUtils helper. draft
authorLuca Greco <lgreco@mozilla.com>
Fri, 20 May 2016 18:07:38 +0200
changeset 369169 8660bee29d01fb166a95435dc26ef950c55f1c30
parent 369165 eacf052393967aed9b8cbf1f1c60c2365eb9e790
child 369170 7270ce1f3b2c293e0322928ba7955a4a64bbad79
child 369171 13fbfed35c6e88bbcf78856bae03c427728d30cf
push id18768
push userluca.greco@alcacoop.it
push dateFri, 20 May 2016 16:11:36 +0000
bugs1269342
milestone49.0a1
Bug 1269342 - [webext] Add EmbeddedWebExtensionsUtils helper. 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
@@ -1,17 +1,17 @@
 /* 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 = ["ClassicExtensionContext"];
+this.EXPORTED_SYMBOLS = ["ClassicExtensionContext", "EmbeddedWebExtensionsUtils"];
 
-/* exported ClassicExtensionContext */
+/* exported ClassicExtensionContext, EmbeddedWebExtensionsUtils */
 
 /**
  *  This file exports helpers for Classic Extensions that want to embed a webextensions
  *  and exchange messages with the embedded WebExtension.
  */
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
@@ -121,23 +121,95 @@ var ClassicExtensionContext = class exte
     this[ns].addonPrincipal = systemPrincipal;
 
     this[ns].messenger = new Messenger(this, [Services.mm, Services.ppmm],
                                        sender, filter, delegate);
 
     this[ns].cloneScope = Cu.Sandbox(this[ns].addonPrincipal, {});
     Cu.setSandboxMetadata(this[ns].cloneScope, {addonId: targetAddonId});
 
+    this[ns].pendingStartupPromise = false;
+
     this[ns].api = {
       onConnect: this[ns].messenger.onConnect("runtime.onConnect"),
       onMessage: this[ns].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.
+   *
+   *  @returns (Object)
+   *    An object with the following properties:
+   *    - resolve: (Function)
+   *      to be called to resolve the pending startup promise.
+   *    - reject: (Function)
+   *      to be called to reject the pending startup promise.
+   */
+  setupStartupPromise() {
+    if (this[ns].pendingStartupPromise) {
+      throw Error("setupStartupPromise has been called with a pending startup promise");
+    }
+
+    let webextStartupPromise = {};
+    this[ns].api.startupError = null;
+    this[ns].api.waitForStartup = new Promise((resolve, reject) => {
+      this[ns].pendingStartupPromise = true;
+
+      // Save the resolve/reject methods related to the pendingStartupPromise.
+      this[ns].resolveStartupPromise = resolve;
+      this[ns].rejectStartupPromise = reject;
+    }).then(() => {
+      this[ns].pendingStartupPromise = false;
+      // Reset the startupError when the startup promise has been resolved.
+      this[ns].api.startupError = null;
+    }, (err) => {
+      this[ns].pendingStartupPromise = false;
+      // Set the startupError when the startup promise has been rejected.
+      this[ns].api.startupError = err;
+      // Let the promise to reject.
+      throw err;
+    });
+
+    return webextStartupPromise;
+  }
+
+  /**
+   *  Resolve the pending promise, if any.
+   */
+  resolveStartupPromise() {
+    if (this[ns].pendingStartupPromise) {
+      this[ns].resolveStartupPromise();
+    }
+  }
+
+  /**
+   *  Reject the pending promise, if any.
+   *
+   *  @param err: (any)
+   *    The error object to pass to the rejection handlers.
+   *
+   */
+  rejectStartupPromise(err) {
+    if (this[ns].pendingStartupPromise) {
+      this[ns].rejectStartupPromise(err);
+    }
+  }
+
+  /**
    *  Signal that the context is shutting down and call the unload method.
    *  Called when the extension shuts down.
    */
   shutdown() {
     this.unload();
   }
 
   /**
@@ -180,8 +252,194 @@ var ClassicExtensionContext = class exte
   /**
    *  The messaging API object exposed to the classic extension code to be exchange to
    *  exchange messages with the associated webextension contexts.
    */
   get api() {
     return this[ns].api;
   }
 };
+
+/**
+ *  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 containerAddonParams: (Object)
+   *    An object with the following properties:
+   *    - id: (String)
+   *      The Addon id of the Classic Extension which will contain the embedded webextension.
+   *    - resourceURI (nsIURI)
+   *      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(id, {
+      targetExtension: 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 addonParam: (Object)
+   *    An object with the following properties
+   *    - id: (String)
+   *      The Addon id of the Classic Extension which will contain the embedded webextension.
+   *    - resourceURI (nsIURI)
+   *      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 addonParam: (Object)
+   *    An object with the following properties
+   *    - id: (String)
+   */
+  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 addonParam: (Object)
+   *    An object with the following properties
+   *    - id: (String)
+   */
+  shutdownFor({id}) {
+    let embeddedWebExtension = embeddedWebExtensionsMap.get(id);
+    if (embeddedWebExtension) {
+      embeddedWebExtension.shutdown();
+      embeddedWebExtensionsMap.delete(id);
+    } else {
+      Cu.reportError(`No embedded WebExtension found for addonId ${id}`);
+    }
+  },
+};
--- 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,225 @@
+<!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 {
+  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
@@ -29,8 +29,46 @@ add_task(function* classic_extension_con
   // targetAddonId mismatch
   let id = "fake@target-extension";
   let resourceURI = Services.io.newURI(`jar:file://tmp/fake-path.xpi!/`, null, null);
   Assert.throws(() => {
     let targetExtension = new Extension({id, resourceURI});
     new ClassicExtensionContext("mismatch@target-extension", {targetExtension});
   }, "targetExtension.id is not equal to the targetAddonId");
 });
+
+/**
+ *  This test ensures that the ClassicExtensionContext raises the
+ *  expected exception when setupStartupPromise is called when an
+ *  existent promise is set.
+ */
+add_task(function* classic_extension_context_pending_startup_promise_exception() {
+  let ctx = new ClassicExtensionContext("@fake-addon-id");
+  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}`);
+  }
+});