Bug 1287007 - Allow local implementations to call remote implementations r=billm
authorRob Wu <rob@robwu.nl>
Sun, 04 Sep 2016 02:29:48 -0700
changeset 428700 3ca8f3e64eebe2b6cdcf5a3ddab89572db4d9e18
parent 428699 7292a34a169eec162d65b515a2c306cdb7bd0872
child 428701 32d29dd44c656b3faf85c21ab097c96a0623c3bd
push id33405
push userbcampen@mozilla.com
push dateMon, 24 Oct 2016 15:32:53 +0000
reviewersbillm
bugs1287007
milestone52.0a1
Bug 1287007 - Allow local implementations to call remote implementations r=billm - Add callParentFunctionNoReturn / callParentAsyncFunction to ChildAPIManager to implement remote calls. - Add in-process browser.test implementation that uses this. - Add tests to verify that the browser.test.assert* methods with the `allowAmbiguousOptionalArguments` schema attribute are working with objects that cannot be passed as-is over IPC. (except test.sendMessage, because stringifying the arguments has an observable impact on test behavior) MozReview-Commit-ID: 6cFVgmFfU93
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/ext-c-test.js
toolkit/components/extensions/ext-test.js
toolkit/components/extensions/extensions-toolkit.manifest
toolkit/components/extensions/jar.mn
toolkit/components/extensions/test/mochitest/mochitest.ini
toolkit/components/extensions/test/mochitest/test_ext_test.html
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -1694,36 +1694,21 @@ class ProxyAPIImplementation extends Sch
    */
   constructor(namespace, name, childApiManager) {
     super();
     this.path = `${namespace}.${name}`;
     this.childApiManager = childApiManager;
   }
 
   callFunctionNoReturn(args) {
-    this.childApiManager.messageManager.sendAsyncMessage("API:Call", {
-      childId: this.childApiManager.id,
-      path: this.path,
-      args,
-    });
+    this.childApiManager.callParentFunctionNoReturn(this.path, args);
   }
 
   callAsyncFunction(args, callback) {
-    let callId = nextId++;
-    let deferred = PromiseUtils.defer();
-    this.childApiManager.callPromises.set(callId, deferred);
-
-    this.childApiManager.messageManager.sendAsyncMessage("API:Call", {
-      childId: this.childApiManager.id,
-      callId,
-      path: this.path,
-      args,
-    });
-
-    return this.childApiManager.context.wrapPromise(deferred.promise, callback);
+    return this.childApiManager.callParentAsyncFunction(this.path, args, callback);
   }
 
   addListener(listener, args) {
     let set = this.childApiManager.listeners.get(this.path);
     if (!set) {
       set = new Set();
       this.childApiManager.listeners.set(this.path, set);
     }
@@ -1815,16 +1800,56 @@ class ChildAPIManager {
         } else {
           deferred.resolve(new SpreadArgs(data.args));
         }
         this.callPromises.delete(data.callId);
         break;
     }
   }
 
+  /**
+   * Call a function in the parent process and ignores its return value.
+   *
+   * @param {string} path The full name of the method, e.g. "tabs.create".
+   * @param {Array} args The parameters for the function.
+   */
+  callParentFunctionNoReturn(path, args) {
+    this.messageManager.sendAsyncMessage("API:Call", {
+      childId: this.id,
+      path,
+      args,
+    });
+  }
+
+  /**
+   * Calls a function in the parent process and returns its result
+   * asynchronously.
+   *
+   * @param {string} path The full name of the method, e.g. "tabs.create".
+   * @param {Array} args The parameters for the function.
+   * @param {function(*)} [callback] The callback to be called when the function
+   *     completes.
+   * @returns {Promise|undefined} Must be void if `callback` is set, and a
+   *     promise otherwise. The promise is resolved when the function completes.
+   */
+  callParentAsyncFunction(path, args, callback) {
+    let callId = nextId++;
+    let deferred = PromiseUtils.defer();
+    this.callPromises.set(callId, deferred);
+
+    this.messageManager.sendAsyncMessage("API:Call", {
+      childId: this.id,
+      callId,
+      path,
+      args,
+    });
+
+    return this.context.wrapPromise(deferred.promise, callback);
+  }
+
   close() {
     this.messageManager.sendAsyncMessage("API:CloseProxyContext", {childId: this.id});
   }
 
   get cloneScope() {
     return this.context.cloneScope;
   }
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ext-c-test.js
@@ -0,0 +1,48 @@
+"use strict";
+
+function testApiFactory(context) {
+  return {
+    test: {
+      // These functions accept arbitrary values. Convert the parameters to
+      // make sure that the values can be cloned structurally for IPC.
+
+      sendMessage(...args) {
+        args = Cu.cloneInto(args, context.cloneScope);
+        context.childManager.callParentFunctionNoReturn("test.sendMessage", args);
+      },
+
+      assertTrue(value, msg) {
+        context.childManager.callParentFunctionNoReturn("test.assertTrue", [
+          Boolean(value),
+          String(msg),
+        ]);
+      },
+
+      assertFalse(value, msg) {
+        context.childManager.callParentFunctionNoReturn("test.assertFalse", [
+          Boolean(value),
+          String(msg),
+        ]);
+      },
+
+      assertEq(expected, actual, msg) {
+        let equal = expected === actual;
+        expected += "";
+        actual += "";
+        if (!equal && expected === actual) {
+          // Add an extra tag so that "expected === actual" in the parent is
+          // also false, despite the fact that the serialization is equal.
+          actual += " (different)";
+        }
+        context.childManager.callParentFunctionNoReturn("test.assertEq", [
+          expected,
+          actual,
+          String(msg),
+        ]);
+      },
+    },
+  };
+}
+extensions.registerSchemaAPI("test", "addon_child", testApiFactory);
+extensions.registerSchemaAPI("test", "content_child", testApiFactory);
+
--- a/toolkit/components/extensions/ext-test.js
+++ b/toolkit/components/extensions/ext-test.js
@@ -49,25 +49,31 @@ function testApiFactory(context) {
         extension.emit("test-result", false, msg);
       },
 
       succeed: function(msg) {
         extension.emit("test-result", true, msg);
       },
 
       assertTrue: function(value, msg) {
-        extension.emit("test-result", Boolean(value), msg);
+        extension.emit("test-result", Boolean(value), String(msg));
       },
 
       assertFalse: function(value, msg) {
-        extension.emit("test-result", !value, msg);
+        extension.emit("test-result", !value, String(msg));
       },
 
       assertEq: function(expected, actual, msg) {
-        extension.emit("test-eq", expected === actual, msg, String(expected), String(actual));
+        let equal = expected === actual;
+        expected += "";
+        actual += "";
+        if (!equal && expected === actual) {
+          actual += " (different)";
+        }
+        extension.emit("test-eq", equal, String(msg), expected, actual);
       },
 
       onMessage: new EventManager(context, "test.onMessage", fire => {
         let handlers = messageHandlers.get(extension);
         handlers.add(fire);
 
         return () => {
           handlers.delete(fire);
--- a/toolkit/components/extensions/extensions-toolkit.manifest
+++ b/toolkit/components/extensions/extensions-toolkit.manifest
@@ -14,21 +14,25 @@ category webextension-scripts extension 
 category webextension-scripts storage chrome://extensions/content/ext-storage.js
 category webextension-scripts test chrome://extensions/content/ext-test.js
 category webextension-scripts topSites chrome://extensions/content/ext-topSites.js
 
 # scripts specific for content process.
 category webextension-scripts-content extension chrome://extensions/content/ext-c-extension.js
 category webextension-scripts-content i18n chrome://extensions/content/ext-i18n.js
 category webextension-scripts-content runtime chrome://extensions/content/ext-c-runtime.js
+category webextension-scripts-content test chrome://extensions/content/ext-c-test.js
 
 # scripts that must run in the same process as addon code.
 category webextension-scripts-addon extension chrome://extensions/content/ext-c-extension.js
 category webextension-scripts-addon i18n chrome://extensions/content/ext-i18n.js
 category webextension-scripts-addon runtime chrome://extensions/content/ext-c-runtime.js
+# Uncomment when the child code and parent code are isolated from each other.
+# Currently they are aliases of each other (see "addon_parent" and "addon_child" in Extension.jsm).
+# category webextension-scripts-addon test chrome://extensions/content/ext-c-test.js
 
 # schemas
 category webextension-schemas alarms chrome://extensions/content/schemas/alarms.json
 category webextension-schemas cookies chrome://extensions/content/schemas/cookies.json
 category webextension-schemas downloads chrome://extensions/content/schemas/downloads.json
 category webextension-schemas events chrome://extensions/content/schemas/events.json
 category webextension-schemas extension chrome://extensions/content/schemas/extension.json
 category webextension-schemas extension_types chrome://extensions/content/schemas/extension_types.json
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -17,8 +17,9 @@ toolkit.jar:
     content/extensions/ext-webNavigation.js
     content/extensions/ext-runtime.js
     content/extensions/ext-extension.js
     content/extensions/ext-storage.js
     content/extensions/ext-test.js
     content/extensions/ext-topSites.js
     content/extensions/ext-c-extension.js
     content/extensions/ext-c-runtime.js
+    content/extensions/ext-c-test.js
--- a/toolkit/components/extensions/test/mochitest/mochitest.ini
+++ b/toolkit/components/extensions/test/mochitest/mochitest.ini
@@ -71,16 +71,17 @@ skip-if = os == 'android' # port.sender.
 [test_ext_sendmessage_reply2.html]
 skip-if = os == 'android' # port.sender.tab is undefined on Android (bug 1258975).
 [test_ext_sendmessage_doublereply.html]
 skip-if = os == 'android' # port.sender.tab is undefined on Android (bug 1258975).
 [test_ext_sendmessage_no_receiver.html]
 [test_ext_storage_content.html]
 [test_ext_storage_tab.html]
 skip-if = os == 'android' # Android does not currently support tabs.
+[test_ext_test.html]
 [test_ext_cookies.html]
 skip-if = (os == 'android' || buildapp == 'b2g') # needs TabManager which is not yet implemented. Bug 1258975 on android.
 [test_ext_background_api_injection.html]
 [test_ext_background_generated_url.html]
 [test_ext_background_teardown.html]
 [test_ext_tab_teardown.html]
 skip-if = (os == 'android') # Android does not support tabs API. Bug 1260250
 [test_ext_unload_frame.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_test.html
@@ -0,0 +1,190 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Testing test</title>
+  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+function loadExtensionAndInterceptTest(extensionData) {
+  let results = [];
+  let testResolve;
+  let testDone = new Promise(resolve => { testResolve = resolve; });
+  let handler = {
+    testResult(...result) {
+      results.push(result);
+      SimpleTest.info(`Received test result: ${JSON.stringify(result)}`);
+    },
+
+    testMessage(msg, ...args) {
+      results.push(["test-message", msg, ...args]);
+      SimpleTest.info(`Received message: ${msg} ${JSON.stringify(args)}`);
+      if (msg === "This is the last browser.test call") {
+        testResolve();
+      }
+    },
+  };
+  let extension = SpecialPowers.loadExtension(extensionData, handler);
+  SimpleTest.registerCleanupFunction(() => {
+    if (extension.state == "pending" || extension.state == "running") {
+      SimpleTest.ok(false, "Extension left running at test shutdown");
+      return extension.unload();
+    } else if (extension.state == "unloading") {
+      SimpleTest.ok(false, "Extension not fully unloaded at test shutdown");
+    }
+  });
+  extension.awaitResults = () => testDone.then(() => results);
+  return extension;
+}
+
+function testScript() {
+  // Note: The result of these browser.test calls are intercepted by the test.
+  // See verifyTestResults for the expectations of each browser.test call.
+  browser.test.notifyPass("dot notifyPass");
+  browser.test.notifyFail("dot notifyFail");
+  browser.test.log("dot log");
+  browser.test.fail("dot fail");
+  browser.test.succeed("dot succeed");
+  browser.test.assertTrue(true);
+  browser.test.assertFalse(false);
+  browser.test.assertEq("", "");
+
+  let obj = {};
+  let arr = [];
+  let dom = document.createElement("body");
+  browser.test.assertTrue(obj, "Object truthy");
+  browser.test.assertTrue(arr, "Array truthy");
+  browser.test.assertTrue(dom, "Element truthy");
+  browser.test.assertTrue(true, "True truthy");
+  browser.test.assertTrue(false, "False truthy");
+  browser.test.assertTrue(null, "Null truthy");
+  browser.test.assertTrue(undefined, "Void truthy");
+  browser.test.assertTrue(false, document.createElement("html"));
+
+  browser.test.assertFalse(obj, "Object falsey");
+  browser.test.assertFalse(arr, "Array falsey");
+  browser.test.assertFalse(dom, "Element falsey");
+  browser.test.assertFalse(true, "True falsey");
+  browser.test.assertFalse(false, "False falsey");
+  browser.test.assertFalse(null, "Null falsey");
+  browser.test.assertFalse(undefined, "Void falsey");
+  browser.test.assertFalse(true, document.createElement("head"));
+
+  browser.test.assertEq(obj, obj, "Object equality");
+  browser.test.assertEq(arr, arr, "Array equality");
+  browser.test.assertEq(dom, dom, "Element equality");
+  browser.test.assertEq(null, null, "Null equality");
+  browser.test.assertEq(undefined, undefined, "Void equality");
+
+  browser.test.assertEq({}, {}, "Object reference ineqality");
+  browser.test.assertEq([], [], "Array reference ineqality");
+  browser.test.assertEq(dom, document.createElement("body"), "Element ineqality");
+  browser.test.assertEq(null, undefined, "Null and void ineqality");
+  browser.test.assertEq(true, false, document.createElement("div"));
+
+  obj = {
+    toString() {
+      return "Dynamic toString forbidden";
+    },
+  };
+  browser.test.assertEq(obj, obj, "obj with dynamic toString()");
+  browser.test.sendMessage("Ran test at", location.protocol);
+  browser.test.sendMessage("This is the last browser.test call");
+}
+
+function verifyTestResults(results, shortName, expectedProtocol) {
+  let expectations = [
+    ["test-done", true, "dot notifyPass"],
+    ["test-done", false, "dot notifyFail"],
+    ["test-log", true, "dot log"],
+    ["test-result", false, "dot fail"],
+    ["test-result", true, "dot succeed"],
+    ["test-result", true, "undefined"],
+    ["test-result", true, "undefined"],
+    ["test-eq", true, "undefined", "", ""],
+
+    ["test-result", true, "Object truthy"],
+    ["test-result", true, "Array truthy"],
+    ["test-result", true, "Element truthy"],
+    ["test-result", true, "True truthy"],
+    ["test-result", false, "False truthy"],
+    ["test-result", false, "Null truthy"],
+    ["test-result", false, "Void truthy"],
+    ["test-result", false, "[object HTMLHtmlElement]"],
+
+    ["test-result", false, "Object falsey"],
+    ["test-result", false, "Array falsey"],
+    ["test-result", false, "Element falsey"],
+    ["test-result", false, "True falsey"],
+    ["test-result", true, "False falsey"],
+    ["test-result", true, "Null falsey"],
+    ["test-result", true, "Void falsey"],
+    ["test-result", false, "[object HTMLHeadElement]"],
+
+    ["test-eq", true, "Object equality", "[object Object]", "[object Object]"],
+    ["test-eq", true, "Array equality", "", ""],
+    ["test-eq", true, "Element equality", "[object HTMLBodyElement]", "[object HTMLBodyElement]"],
+    ["test-eq", true, "Null equality", "null", "null"],
+    ["test-eq", true, "Void equality", "undefined", "undefined"],
+
+    ["test-eq", false, "Object reference ineqality", "[object Object]", "[object Object] (different)"],
+    ["test-eq", false, "Array reference ineqality", "", " (different)"],
+    ["test-eq", false, "Element ineqality", "[object HTMLBodyElement]", "[object HTMLBodyElement] (different)"],
+    ["test-eq", false, "Null and void ineqality", "null", "undefined"],
+    ["test-eq", false, "[object HTMLDivElement]", "true", "false"],
+
+    ["test-eq", true, "obj with dynamic toString()", "[object Object]", "[object Object]"],
+
+    ["test-message", "Ran test at", expectedProtocol],
+    ["test-message", "This is the last browser.test call"],
+  ];
+
+  expectations.forEach((expectation, i) => {
+    let msg = expectation.slice(2).join(" - ");
+    isDeeply(results[i], expectation, `${shortName} (${msg})`);
+  });
+  is(results[expectations.length], undefined, "No more results");
+}
+
+add_task(function* test_test_in_background() {
+  let extensionData = {
+    background: `(${testScript})()`,
+  };
+
+  let extension = loadExtensionAndInterceptTest(extensionData);
+  yield extension.startup();
+  let results = yield extension.awaitResults();
+  verifyTestResults(results, "background page", "moz-extension:");
+  yield extension.unload();
+});
+
+add_task(function* test_test_in_content_script() {
+  let extensionData = {
+    manifest: {
+      content_scripts: [{
+        matches: ["http://mochi.test/*/file_sample.html"],
+        js: ["contentscript.js"],
+      }],
+    },
+    files: {
+      "contentscript.js": `(${testScript})()`,
+    },
+  };
+
+  let extension = loadExtensionAndInterceptTest(extensionData);
+  yield extension.startup();
+  let win = window.open("file_sample.html");
+  let results = yield extension.awaitResults();
+  win.close();
+  verifyTestResults(results, "content script", "http:");
+  yield extension.unload();
+});
+</script>
+</body>
+</html>