Bug 1288885: Support testing WebExtensions from xpcshell tests. r=aswan
authorKris Maglione <maglione.k@gmail.com>
Sun, 24 Jul 2016 16:09:26 -0700
changeset 347194 7cf25e2c60bebe17506582b213aa382c1cdeaa37
parent 347193 0a1c5b06f3da3ef1e72ea5e82d6724521868fedb
child 347195 0d9110f6340ac19845f5aeffc48703085f312915
push id6389
push userraliiev@mozilla.com
push dateMon, 19 Sep 2016 13:38:22 +0000
treeherdermozilla-beta@01d67bfe6c81 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan
bugs1288885
milestone50.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1288885: Support testing WebExtensions from xpcshell tests. r=aswan Most of the test helper code is derived from the SpecialPowers/ExtensionTestUtils code that does the same. Eventually, the two implementations should probably be unified, but I don't think it's worth the trouble for now. MozReview-Commit-ID: 7Yy9jWkGsMM
browser/components/extensions/test/xpcshell/.eslintrc
browser/components/extensions/test/xpcshell/head.js
browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionXPCShellUtils.jsm
toolkit/components/extensions/moz.build
toolkit/components/extensions/test/xpcshell/.eslintrc
toolkit/components/extensions/test/xpcshell/head.js
toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js
--- a/browser/components/extensions/test/xpcshell/.eslintrc
+++ b/browser/components/extensions/test/xpcshell/.eslintrc
@@ -1,3 +1,7 @@
 {
   "extends": "../../../../../testing/xpcshell/xpcshell.eslintrc",
+
+  "globals": {
+    "browser": false,
+  },
 }
--- a/browser/components/extensions/test/xpcshell/head.js
+++ b/browser/components/extensions/test/xpcshell/head.js
@@ -1,50 +1,55 @@
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
+/* exported createHttpServer */
+
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+                                  "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Extension",
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionData",
                                   "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
+                                  "resource://gre/modules/ExtensionManagement.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionTestUtils",
+                                  "resource://testing-common/ExtensionXPCShellUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "HttpServer",
+                                  "resource://testing-common/httpd.js");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
-/* exported normalizeManifest */
-
-let BASE_MANIFEST = {
-  "applications": {"gecko": {"id": "test@web.ext"}},
-
-  "manifest_version": 2,
+ExtensionTestUtils.init(this);
 
-  "name": "name",
-  "version": "0",
-};
-
-function* normalizeManifest(manifest, baseManifest = BASE_MANIFEST) {
-  const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
-  yield Management.lazyInit();
 
-  let errors = [];
-  let context = {
-    url: null,
-
-    logError: error => {
-      errors.push(error);
-    },
+/**
+ * Creates a new HttpServer for testing, and begins listening on the
+ * specified port. Automatically shuts down the server when the test
+ * unit ends.
+ *
+ * @param {integer} [port]
+ *        The port to listen on. If omitted, listen on a random
+ *        port. The latter is the preferred behavior.
+ *
+ * @returns {HttpServer}
+ */
+function createHttpServer(port = -1) {
+  let server = new HttpServer();
+  server.start(port);
 
-    preprocessors: {},
-  };
-
-  manifest = Object.assign({}, baseManifest, manifest);
+  do_register_cleanup(() => {
+    return new Promise(resolve => {
+      server.stop(resolve);
+    });
+  });
 
-  let normalized = Schemas.normalize(manifest, "manifest.WebExtensionManifest", context);
-  normalized.errors = errors;
-
-  return normalized;
+  return server;
 }
--- a/browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js
+++ b/browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js
@@ -1,15 +1,15 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 
 add_task(function* test_manifest_commands() {
-  let normalized = yield normalizeManifest({
+  let normalized = yield ExtensionTestUtils.normalizeManifest({
     "commands": {
       "toggle-feature": {
         "suggested_key": {"default": "Shifty+Y"},
         "description": "Send a 'toggle-feature' event to the extension",
       },
     },
   });
 
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -51,16 +51,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/Task.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
                                   "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 
+Cu.import("resource://gre/modules/ExtensionContent.jsm");
 Cu.import("resource://gre/modules/ExtensionManagement.jsm");
 
 const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
 const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas";
 const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts";
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
@@ -321,17 +322,17 @@ class ProxyContext extends ExtensionCont
 
   get externallyVisible() {
     return false;
   }
 }
 
 function findPathInObject(obj, path) {
   for (let elt of path) {
-    obj = obj[elt];
+    obj = obj[elt] || undefined;
   }
   return obj;
 }
 
 let ParentAPIManager = {
   proxyContexts: new Map(),
 
   init() {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
@@ -0,0 +1,285 @@
+/* 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 = ["ExtensionTestUtils"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Components.utils.import("resource://gre/modules/Task.jsm");
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Extension",
+                                  "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
+                                  "resource://gre/modules/Schemas.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "uuidGenerator",
+                                   "@mozilla.org/uuid-generator;1", "nsIUUIDGenerator");
+
+/* exported ExtensionTestUtils */
+
+let BASE_MANIFEST = Object.freeze({
+  "applications": Object.freeze({
+    "gecko": Object.freeze({
+      "id": "test@web.ext",
+    }),
+  }),
+
+  "manifest_version": 2,
+
+  "name": "name",
+  "version": "0",
+});
+
+class ExtensionWrapper {
+  constructor(extension, testScope) {
+    this.extension = extension;
+    this.testScope = testScope;
+
+    this.state = "uninitialized";
+
+    this.testResolve = null;
+    this.testDone = new Promise(resolve => { this.testResolve = resolve; });
+
+    this.messageHandler = new Map();
+    this.messageAwaiter = new Map();
+
+    this.messageQueue = new Set();
+
+    this.testScope.do_register_cleanup(() => {
+      if (this.messageQueue.size) {
+        let names = Array.from(this.messageQueue, ([msg]) => msg);
+        this.testScope.equal(JSON.stringify(names), "[]", "message queue is empty");
+      }
+      if (this.messageAwaiter.size) {
+        let names = Array.from(this.messageAwaiter.keys());
+        this.testScope.equal(JSON.stringify(names), "[]", "no tasks awaiting on messages");
+      }
+    });
+
+    /* eslint-disable mozilla/balanced-listeners */
+    extension.on("test-eq", (kind, pass, msg, expected, actual) => {
+      this.testScope.ok(pass, `${msg} - Expected: ${expected}, Actual: ${actual}`);
+    });
+    extension.on("test-log", (kind, pass, msg) => {
+      this.testScope.do_print(msg);
+    });
+    extension.on("test-result", (kind, pass, msg) => {
+      this.testScope.ok(pass, msg);
+    });
+    extension.on("test-done", (kind, pass, msg, expected, actual) => {
+      this.testScope.ok(pass, msg);
+      this.testResolve(msg);
+    });
+
+    extension.on("test-message", (kind, msg, ...args) => {
+      let handler = this.messageHandler.get(msg);
+      if (handler) {
+        handler(...args);
+      } else {
+        this.messageQueue.add([msg, ...args]);
+        this.checkMessages();
+      }
+    });
+    /* eslint-enable mozilla/balanced-listeners */
+
+    this.testScope.do_register_cleanup(() => {
+      if (this.state == "pending" || this.state == "running") {
+        this.testScope.equal(this.state, "unloaded", "Extension left running at test shutdown");
+        return this.unload();
+      } else if (extension.state == "unloading") {
+        this.testScope.equal(this.state, "unloaded", "Extension not fully unloaded at test shutdown");
+      }
+    });
+
+    this.testScope.do_print(`Extension loaded`);
+  }
+
+  startup() {
+    if (this.state != "uninitialized") {
+      throw new Error("Extension already started");
+    }
+    this.state = "pending";
+
+    return this.extension.startup().then(
+      result => {
+        this.state = "running";
+
+        return result;
+      },
+      error => {
+        this.state = "failed";
+
+        return Promise.reject(error);
+      });
+  }
+
+  unload() {
+    if (this.state != "running") {
+      throw new Error("Extension not running");
+    }
+    this.state = "unloading";
+
+    this.extension.shutdown();
+    this.state = "unloaded";
+
+    return Promise.resolve();
+  }
+
+  sendMessage(...args) {
+    this.extension.testMessage(...args);
+  }
+
+  awaitFinish(msg) {
+    return this.testDone.then(actual => {
+      if (msg) {
+        this.testScope.equal(actual, msg, "test result correct");
+      }
+      return actual;
+    });
+  }
+
+  checkMessages() {
+    for (let message of this.messageQueue) {
+      let [msg, ...args] = message;
+
+      let listener = this.messageAwaiter.get(msg);
+      if (listener) {
+        this.messageQueue.delete(message);
+        this.messageAwaiter.delete(msg);
+
+        listener.resolve(...args);
+        return;
+      }
+    }
+  }
+
+  checkDuplicateListeners(msg) {
+    if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) {
+      throw new Error("only one message handler allowed");
+    }
+  }
+
+  awaitMessage(msg) {
+    return new Promise(resolve => {
+      this.checkDuplicateListeners(msg);
+
+      this.messageAwaiter.set(msg, {resolve});
+      this.checkMessages();
+    });
+  }
+
+  onMessage(msg, callback) {
+    this.checkDuplicateListeners(msg);
+    this.messageHandler.set(msg, callback);
+  }
+}
+
+var ExtensionTestUtils = {
+  BASE_MANIFEST,
+
+  normalizeManifest: Task.async(function* (manifest, baseManifest = BASE_MANIFEST) {
+    const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
+
+    yield Management.lazyInit();
+
+    let errors = [];
+    let context = {
+      url: null,
+
+      logError: error => {
+        errors.push(error);
+      },
+
+      preprocessors: {},
+    };
+
+    manifest = Object.assign({}, baseManifest, manifest);
+
+    let normalized = Schemas.normalize(manifest, "manifest.WebExtensionManifest", context);
+    normalized.errors = errors;
+
+    return normalized;
+  }),
+
+  currentScope: null,
+
+  profileDir: null,
+
+  init(scope) {
+    this.currentScope = scope;
+
+    this.profileDir = scope.do_get_profile();
+
+    // We need to load at least one frame script into every message
+    // manager to ensure that the scriptable wrapper for its global gets
+    // created before we try to access it externally. If we don't, we
+    // fail sanity checks on debug builds the first time we try to
+    // create a wrapper, because we should never have a global without a
+    // cached wrapper.
+    Services.mm.loadFrameScript("data:text/javascript,//", true);
+
+
+    let tmpD = this.profileDir.clone();
+    tmpD.append("tmp");
+    tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+    let dirProvider = {
+      getFile(prop, persistent) {
+        persistent.value = false;
+        if (prop == "TmpD") {
+          return tmpD.clone();
+        }
+        return null;
+      },
+
+      QueryInterface: XPCOMUtils.generateQI([Ci.nsIDirectoryServiceProvider]),
+    };
+    Services.dirsvc.registerProvider(dirProvider);
+
+
+    scope.do_register_cleanup(() => {
+      tmpD.remove(true);
+      Services.dirsvc.unregisterProvider(dirProvider);
+
+      this.currentScope = null;
+    });
+  },
+
+  addonManagerStarted: false,
+
+  startAddonManager() {
+    if (this.addonManagerStarted) {
+      return;
+    }
+    this.addonManagerStarted = true;
+
+    let appInfo = {};
+    Cu.import("resource://testing-common/AppInfo.jsm", appInfo);
+
+    appInfo.updateAppInfo({
+      ID: "xpcshell@tests.mozilla.org",
+      name: "XPCShell",
+      version: "48",
+      platformVersion: "48",
+    });
+
+
+    let manager = Cc["@mozilla.org/addons/integration;1"].getService(Ci.nsIObserver)
+                                                         .QueryInterface(Ci.nsITimerCallback);
+    manager.observe(null, "addons-startup", null);
+  },
+
+  loadExtension(data, id = uuidGenerator.generateUUID().number) {
+    let extension = Extension.generate(id, data);
+
+    return new ExtensionWrapper(extension, this.currentScope);
+  },
+};
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -14,15 +14,19 @@ EXTRA_JS_MODULES += [
     'NativeMessaging.jsm',
     'Schemas.jsm',
 ]
 
 EXTRA_COMPONENTS += [
     'extensions-toolkit.manifest',
 ]
 
+TESTING_JS_MODULES += [
+    'ExtensionXPCShellUtils.jsm',
+]
+
 DIRS += ['schemas']
 
 JAR_MANIFESTS += ['jar.mn']
 
 MOCHITEST_MANIFESTS += ['test/mochitest/mochitest.ini']
 MOCHITEST_CHROME_MANIFESTS += ['test/mochitest/chrome.ini']
 XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
--- a/toolkit/components/extensions/test/xpcshell/.eslintrc
+++ b/toolkit/components/extensions/test/xpcshell/.eslintrc
@@ -1,3 +1,7 @@
 {
   "extends": "../../../../../testing/xpcshell/xpcshell.eslintrc",
+
+  "globals": {
+    "browser": false,
+  },
 }
--- a/toolkit/components/extensions/test/xpcshell/head.js
+++ b/toolkit/components/extensions/test/xpcshell/head.js
@@ -3,49 +3,18 @@
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Extension",
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionData",
                                   "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionTestUtils",
+                                  "resource://testing-common/ExtensionXPCShellUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
-/* exported normalizeManifest */
-
-let BASE_MANIFEST = {
-  "applications": {"gecko": {"id": "test@web.ext"}},
-
-  "manifest_version": 2,
-
-  "name": "name",
-  "version": "0",
-};
-
-function* normalizeManifest(manifest, baseManifest = BASE_MANIFEST) {
-  const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
-
-  yield Management.lazyInit();
-
-  let errors = [];
-  let context = {
-    url: null,
-
-    logError: error => {
-      errors.push(error);
-    },
-
-    preprocessors: {},
-  };
-
-  manifest = Object.assign({}, baseManifest, manifest);
-
-  let normalized = Schemas.normalize(manifest, "manifest.WebExtensionManifest", context);
-  normalized.errors = errors;
-
-  return normalized;
-}
+ExtensionTestUtils.init(this);
--- a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
@@ -1,26 +1,26 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 
 add_task(function* test_manifest_csp() {
-  let normalized = yield normalizeManifest({
+  let normalized = yield ExtensionTestUtils.normalizeManifest({
     "content_security_policy": "script-src 'self'; object-src 'none'",
   });
 
   equal(normalized.error, undefined, "Should not have an error");
   equal(normalized.errors.length, 0, "Should not have warnings");
   equal(normalized.value.content_security_policy,
         "script-src 'self'; object-src 'none'",
         "Should have the expected poilcy string");
 
 
-  normalized = yield normalizeManifest({
+  normalized = yield ExtensionTestUtils.normalizeManifest({
     "content_security_policy": "object-src 'none'",
   });
 
   equal(normalized.error, undefined, "Should not have an error");
 
   Assert.deepEqual(normalized.errors,
                    ["Error processing content_security_policy: SyntaxError: Policy is missing a required \u2018script-src\u2019 directive"],
                    "Should have the expected warning");
--- a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js
@@ -1,25 +1,25 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 
 add_task(function* test_manifest_incognito() {
-  let normalized = yield normalizeManifest({
+  let normalized = yield ExtensionTestUtils.normalizeManifest({
     "incognito": "spanning",
   });
 
   equal(normalized.error, undefined, "Should not have an error");
   equal(normalized.errors.length, 0, "Should not have warnings");
   equal(normalized.value.incognito,
         "spanning",
         "Should have the expected incognito string");
 
-  normalized = yield normalizeManifest({
+  normalized = yield ExtensionTestUtils.normalizeManifest({
     "incognito": "split",
   });
 
   equal(normalized.error, undefined, "Should not have an error");
   Assert.deepEqual(normalized.errors,
                    ['Error processing incognito: Invalid enumeration value "split"'],
                    "Should have the expected warning");
   equal(normalized.value.incognito, null,