Bug 1288885: Support testing WebExtensions from xpcshell tests. r?aswan draft
authorKris Maglione <maglione.k@gmail.com>
Sun, 24 Jul 2016 15:59:39 -0700
changeset 392202 0ddcdcf73eb1b7e6036f048899859790402a3daa
parent 392201 c6dcc7da1823711e3223ba39d038e1ccc949f6fd
child 392203 1dd5e678e1827c254012b4581f979df696ae277e
push id23963
push usermaglione.k@gmail.com
push dateSun, 24 Jul 2016 23:16:27 +0000
reviewersaswan
bugs1288885
milestone50.0a1
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/Schemas.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,55 +1,56 @@
 "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");
 
-Cu.import("resource://gre/modules/ExtensionManagement.jsm");
-
-/* exported normalizeManifest */
-
-let BASE_MANIFEST = {
-  "applications": {"gecko": {"id": "test@web.ext"}},
-
-  "manifest_version": 2,
-
-  "name": "name",
-  "version": "0",
-};
+ExtensionTestUtils.init(this);
 
 ExtensionManagement.registerSchema("chrome://browser/content/schemas/commands.json");
 
-function* normalizeManifest(manifest, baseManifest = BASE_MANIFEST) {
-  const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
-
-  yield Management.lazyInit();
-
-  let errors = [];
-  let context = {
-    url: null,
+/**
+ * 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);
 
-    logError: error => {
-      errors.push(error);
-    },
-
-    preprocessors: {},
-  };
+  do_register_cleanup(() => {
+    return new Promise(resolve => {
+      server.stop(resolve);
+    });
+  });
 
-  manifest = Object.assign({}, baseManifest, manifest);
-
-  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
@@ -50,16 +50,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");
 
 // Register built-in parts of the API. Other parts may be registered
 // in browser/, mobile/, or b2g/.
 ExtensionManagement.registerScript("chrome://extensions/content/ext-alarms.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-backgroundPage.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-cookies.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-downloads.js");
@@ -347,17 +348,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,286 @@
+/* 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.ok(false, "Extension left running at test shutdown");
+        return this.unload();
+      } else if (extension.state == "unloading") {
+        this.testScope.ok(false, "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,
+
+  init(scope) {
+    this.currentScope = scope;
+
+    // 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,null", true);
+
+    scope.do_register_cleanup(() => {
+      this.currentScope = null;
+    });
+  },
+
+  addonManagerStarted: false,
+
+  startAddonManager() {
+    if (this.addonManagerStarted) {
+      return;
+    }
+    this.addonManagerStarted = true;
+
+    let tmpD = this.currentScope.do_get_profile().clone();
+    tmpD.append("tmp");
+    tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+
+    let dirProvider = {
+      getFile: function(prop, persistent) {
+        persistent.value = false;
+        if (prop == "TmpD") {
+          return tmpD.clone();
+        }
+        return null;
+      },
+
+      QueryInterface: XPCOMUtils.generateQI([Ci.nsIDirectoryServiceProvider]),
+    };
+    Services.dirsvc.registerProvider(dirProvider);
+
+
+    this.currentScope.do_register_cleanup(() => {
+      tmpD.remove(true);
+      Services.dirsvc.unregisterProvider(dirProvider);
+
+      this.currentScope = null;
+    });
+
+
+    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/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -1403,17 +1403,17 @@ this.Schemas = {
       return new NumberType(type);
     } else if (type.type == "integer") {
       checkTypeProperties("minimum", "maximum");
       return new IntegerType(type, type.minimum || -Infinity, type.maximum || Infinity);
     } else if (type.type == "boolean") {
       checkTypeProperties();
       return new BooleanType(type);
     } else if (type.type == "function") {
-      let isAsync = Boolean(type.async);
+      let isAsync = Boolean(type.async || false);
 
       let parameters = null;
       if ("parameters" in type) {
         parameters = [];
         for (let param of type.parameters) {
           // Callbacks default to optional for now, because of promise
           // handling.
           let isCallback = isAsync && param.name == type.async;
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -10,15 +10,19 @@ EXTRA_JS_MODULES += [
     'ExtensionManagement.jsm',
     'ExtensionStorage.jsm',
     'ExtensionUtils.jsm',
     'MessageChannel.jsm',
     'NativeMessaging.jsm',
     'Schemas.jsm',
 ]
 
+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,