Bug 1561150: Support test assertions in SpecialPowers.spawn sandboxes. r=nika
☠☠ backed out by f9bf5e4b0b4f ☠ ☠
authorKris Maglione <maglione.k@gmail.com>
Mon, 24 Jun 2019 19:25:33 -0700
changeset 540551 0b3e2164f1283b639782b41b7fd640986d3feca5
parent 540550 43211ebfe7384909dd95cb44196eea4359156d2a
child 540552 19e0edc9207746c7987b98f3c7787b7c363d651d
push id11529
push userarchaeopteryx@coole-files.de
push dateThu, 04 Jul 2019 15:22:33 +0000
treeherdermozilla-beta@ebb510a784b8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnika
bugs1561150
milestone69.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 1561150: Support test assertions in SpecialPowers.spawn sandboxes. r=nika Differential Revision: https://phabricator.services.mozilla.com/D35747
testing/mochitest/tests/Harness_sanity/mochitest.ini
testing/mochitest/tests/Harness_sanity/test_SpecialPowersSandbox.html
testing/specialpowers/content/SpecialPowersAPI.jsm
testing/specialpowers/content/SpecialPowersAPIParent.jsm
testing/specialpowers/content/SpecialPowersChild.jsm
testing/specialpowers/content/SpecialPowersSandbox.jsm
testing/specialpowers/moz.build
toolkit/components/passwordmgr/test/mochitest/test_xhr.html
--- a/testing/mochitest/tests/Harness_sanity/mochitest.ini
+++ b/testing/mochitest/tests/Harness_sanity/mochitest.ini
@@ -17,16 +17,17 @@ support-files = empty.js
 [test_sanityWindowSnapshot.html]
 [test_SpecialPowersExtension.html]
 [test_SpecialPowersExtension2.html]
 support-files = file_SpecialPowersFrame1.html
 [test_SpecialPowersPushPermissions.html]
 support-files =
     specialPowers_framescript.js
 [test_SpecialPowersPushPrefEnv.html]
+[test_SpecialPowersSandbox.html]
 [test_SpecialPowersSpawn.html]
 support-files = file_spawn.html
 [test_SimpletestGetTestFileURL.html]
 [test_SpecialPowersLoadChromeScript.html]
 support-files = SpecialPowersLoadChromeScript.js
 [test_SpecialPowersLoadChromeScript_function.html]
 [test_SpecialPowersLoadPrivilegedScript.html]
 [test_bug649012.html]
new file mode 100644
--- /dev/null
+++ b/testing/mochitest/tests/Harness_sanity/test_SpecialPowersSandbox.html
@@ -0,0 +1,96 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Test for SpecialPowers sandboxes</title>
+  <script src="/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<iframe id="iframe"></iframe>
+
+<script>
+/**
+ * Tests that the shared sandbox functionality for cross-process script
+ * execution works as expected. In particular, ensures that Assert methods
+ * report the correct diagnostics in the caller scope.
+ */
+
+/* eslint-disable prettier/prettier */
+/* globals SpecialPowers, Assert */
+
+async function interceptDiagnostics(func) {
+  let originalRecord = SimpleTest.record;
+  try {
+    let diags = [];
+
+    SimpleTest.record = (condition, name, diag, stack) => {
+      diags.push({condition, name, diag, stack});
+    };
+
+    await func();
+
+    return diags;
+  } finally {
+    SimpleTest.record = originalRecord;
+  }
+}
+
+add_task(async function() {
+  let frame = document.getElementById("iframe");
+  frame.src = "https://example.com/tests/testing/mochitest/tests/Harness_sanity/file_spawn.html";
+
+  await new Promise(resolve => {
+    frame.addEventListener("load", resolve, {once: true});
+  });
+
+  let expected = [
+    [false, "Thing - 1 == 2", "got 1, expected 2 (operator ==)"],
+    [true, "Hmm - 1 == 1", undefined],
+    [true, "Yay. - true == true", undefined],
+    [false, "Boo!. - false == true", "got false, expected true (operator ==)"],
+  ];
+
+  // Test that a representative variety of assertions work as expected, and
+  // trigger the expected calls to the harness's reporting function.
+  //
+  // Note: Assert.jsm has its own tests, and defers all of its reporting to a
+  // single reporting function, so we don't need to test it comprehensively. We
+  // just need to make sure that the general functionality works as expected.
+  let tests = {
+    "SpecialPowers.spawn": () => {
+      return SpecialPowers.spawn(frame, [], () => {
+        Assert.equal(1, 2, "Thing");
+        Assert.equal(1, 1, "Hmm");
+        Assert.ok(true, "Yay.");
+        Assert.ok(false, "Boo!.");
+      });
+    },
+    "SpecialPowers.loadChromeScript": async () => {
+      let script = SpecialPowers.loadChromeScript(() => {
+        this.addMessageListener("ping", () => "pong");
+
+        Assert.equal(1, 2, "Thing");
+        Assert.equal(1, 1, "Hmm");
+        Assert.ok(true, "Yay.");
+        Assert.ok(false, "Boo!.");
+      });
+
+      await script.sendQuery("ping");
+      script.destroy();
+    },
+  };
+
+  for (let [name, func] of Object.entries(tests)) {
+    info(`Starting task: ${name}`);
+
+    let diags = await interceptDiagnostics(func);
+
+    let results = diags.map(diag => [diag.condition, diag.name, diag.diag]);
+
+    isDeeply(results, expected, "Got expected assertions");
+  }
+});
+</script>
+</body>
+</html>
--- a/testing/specialpowers/content/SpecialPowersAPI.jsm
+++ b/testing/specialpowers/content/SpecialPowersAPI.jsm
@@ -12,16 +12,18 @@ var EXPORTED_SYMBOLS = ["SpecialPowersAP
 var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 ChromeUtils.defineModuleGetter(this, "MockFilePicker",
                                "resource://specialpowers/MockFilePicker.jsm");
 ChromeUtils.defineModuleGetter(this, "MockColorPicker",
                                "resource://specialpowers/MockColorPicker.jsm");
 ChromeUtils.defineModuleGetter(this, "MockPermissionPrompt",
                                "resource://specialpowers/MockPermissionPrompt.jsm");
+ChromeUtils.defineModuleGetter(this, "SpecialPowersSandbox",
+                               "resource://specialpowers/SpecialPowersSandbox.jsm");
 ChromeUtils.defineModuleGetter(this, "WrapPrivileged",
                                "resource://specialpowers/WrapPrivileged.jsm");
 ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
                                "resource://gre/modules/PrivateBrowsingUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "NetUtil",
                                "resource://gre/modules/NetUtil.jsm");
 ChromeUtils.defineModuleGetter(this, "AppConstants",
                                "resource://gre/modules/AppConstants.jsm");
@@ -125,16 +127,38 @@ class SpecialPowersAPI extends JSWindowA
     this._os = null;
     this._pu = null;
 
 
     this._nextExtensionID = 0;
     this._extensionListeners = null;
   }
 
+  receiveMessage(message) {
+    switch (message.name) {
+      case "Assert": {
+        // An assertion has been done in a mochitest chrome script
+        let {name, passed, stack, diag} = message.data;
+
+        let SimpleTest = (
+            this.contentWindow &&
+            this.contentWindow.wrappedJSObject.SimpleTest);
+
+        if (SimpleTest) {
+          SimpleTest.record(passed, name, diag, stack && stack.formattedStack);
+        } else {
+          // Well, this is unexpected.
+          dump(name + "\n");
+        }
+      }
+      break;
+    }
+    return undefined;
+  }
+
   /*
    * Privileged object wrapping API
    *
    * Usage:
    *   var wrapper = SpecialPowers.wrap(obj);
    *   wrapper.privilegedMethod(); wrapper.privilegedProperty;
    *   obj === SpecialPowers.unwrap(wrapper);
    *
@@ -332,17 +356,16 @@ class SpecialPowersAPI extends JSWindowA
       sendQuery: (name, message) => {
         return this.sendQuery("SPChromeScriptMessage",
                               { id, name, message });
       },
 
       destroy: () => {
         listeners = [];
         this._removeMessageListener("SPChromeScriptMessage", chromeScript);
-        this._removeMessageListener("SPChromeScriptAssert", chromeScript);
       },
 
       receiveMessage: (aMessage) => {
         let messageId = aMessage.json.id;
         let name = aMessage.json.name;
         let message = aMessage.json.message;
         if (this.contentWindow) {
           message = new StructuredCloneHolder(message).deserialize(this.contentWindow);
@@ -351,66 +374,21 @@ class SpecialPowersAPI extends JSWindowA
         if (messageId != id)
           return null;
 
         let result;
         if (aMessage.name == "SPChromeScriptMessage") {
           for (let listener of listeners.filter(o => o.name == name)) {
             result = listener.listener(message);
           }
-        } else if (aMessage.name == "SPChromeScriptAssert") {
-          assert(aMessage.json);
         }
         return result;
       },
     };
     this._addMessageListener("SPChromeScriptMessage", chromeScript);
-    this._addMessageListener("SPChromeScriptAssert", chromeScript);
-
-    let assert = json => {
-      // An assertion has been done in a mochitest chrome script
-      let {name, err, message, stack} = json;
-
-      // Try to fetch a test runner from the mochitest
-      // in order to properly log these assertions and notify
-      // all usefull log observers
-      let window = this.contentWindow;
-      let parentRunner, repr = o => o;
-      if (window) {
-        window = window.wrappedJSObject;
-        parentRunner = window.TestRunner;
-        if (window.repr) {
-          repr = window.repr;
-        }
-      }
-
-      // Craft a mochitest-like report string
-      var resultString = err ? "TEST-UNEXPECTED-FAIL" : "TEST-PASS";
-      var diagnostic =
-        message ? message :
-                  ("assertion @ " + stack.filename + ":" + stack.lineNumber);
-      if (err) {
-        diagnostic +=
-          " - got " + repr(err.actual) +
-          ", expected " + repr(err.expected) +
-          " (operator " + err.operator + ")";
-      }
-      var msg = [resultString, name, diagnostic].join(" | ");
-      if (parentRunner) {
-        if (err) {
-          parentRunner.addFailedTest(name);
-          parentRunner.error(msg);
-        } else {
-          parentRunner.log(msg);
-        }
-      } else {
-        // When we are running only a single mochitest, there is no test runner
-        dump(msg + "\n");
-      }
-    };
 
     return this.wrap(chromeScript);
   }
 
   async importInMainProcess(importString) {
     var message = await this.sendQuery("SPImportInMainProcess", importString);
     if (message.hadError) {
       throw new Error("SpecialPowers.importInMainProcess failed with error " + message.errorMessage);
@@ -1231,16 +1209,20 @@ class SpecialPowersAPI extends JSWindowA
    * promise which resolves to the return value of that task.
    *
    * The given frame may be in-process or out-of-process. Either way,
    * the task will run asynchronously, in a sandbox with access to the
    * frame's content window via its `content` global. Any arguments
    * passed will be copied via structured clone, as will its return
    * value.
    *
+   * The sandbox also has access to an Assert object, as provided by
+   * Assert.jsm. Any assertion methods called before the task resolves
+   * will be relayed back to the test environment of the caller.
+   *
    * @param {BrowsingContext or FrameLoaderOwner or WindowProxy} target
    *        The target in which to run the task. This may be any element
    *        which implements the FrameLoaderOwner interface (including
    *        HTML <iframe> elements and XUL <browser> elements) or a
    *        WindowProxy (either in-process or remote).
    * @param {Array<any>} args
    *        An array of arguments to pass to the task. All arguments
    *        must be structured clone compatible, and will be cloned
@@ -1264,42 +1246,36 @@ class SpecialPowersAPI extends JSWindowA
     if (BrowsingContext.isInstance(target)) {
       browsingContext = target;
     } else if (Element.isInstance(target)) {
       browsingContext = target.browsingContext;
     } else {
       browsingContext = BrowsingContext.getFromWindow(target);
     }
 
-    let {caller} = Components.stack;
     return this.sendQuery("Spawn", {
       browsingContext,
       args,
       task: String(task),
-      caller: {
-        filename: caller.filename,
-        lineNumber: caller.lineNumber,
-      },
+      caller: SpecialPowersSandbox.getCallerInfo(Components.stack.caller),
     });
   }
 
-  _spawnTask(task, args, caller) {
-    let sb = Cu.Sandbox(Cu.getGlobalForObject({}),
-                        {wantGlobalProperties: ["ChromeUtils"]});
+  _spawnTask(task, args, caller, taskId) {
+    let sb = new SpecialPowersSandbox(null, data => {
+      this.sendAsyncMessage("ProxiedAssert", {taskId, data});
+    });
 
-    sb.SpecialPowers = this;
-    Object.defineProperty(sb, "content", {
+    sb.sandbox.SpecialPowers = this;
+    Object.defineProperty(sb.sandbox, "content", {
       get: () => { return this.contentWindow; },
       enumerable: true,
     });
 
-    let func = Cu.evalInSandbox(`(${task})`, sb, undefined,
-                                caller.filename, caller.lineNumber);
-
-    return func(...args);
+    return sb.execute(task, args, caller);
   }
 
   getFocusedElementForWindow(targetWindow, aDeep) {
     var outParam = {};
     Services.focus.getFocusedElementForWindow(targetWindow, aDeep, outParam);
     return outParam.value;
   }
 
--- a/testing/specialpowers/content/SpecialPowersAPIParent.jsm
+++ b/testing/specialpowers/content/SpecialPowersAPIParent.jsm
@@ -9,16 +9,17 @@ var EXPORTED_SYMBOLS = ["SpecialPowersAP
 var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   ExtensionData: "resource://gre/modules/Extension.jsm",
   ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.jsm",
   PerTestCoverageUtils: "resource://testing-common/PerTestCoverageUtils.jsm",
   ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.jsm",
+  SpecialPowersSandbox: "resource://specialpowers/SpecialPowersSandbox.jsm",
 });
 
 class SpecialPowersError extends Error {
   get name() {
     return "SpecialPowersError";
   }
 }
 
@@ -94,23 +95,28 @@ function doPrefEnvOp(fn) {
   inPrefEnvOp = true;
   try {
     return fn();
   } finally {
     inPrefEnvOp = false;
   }
 }
 
+// Supplies the unique IDs for tasks created by SpecialPowers.spawn(),
+// used to bounce assertion messages back down to the correct child.
+let nextTaskID = 1;
+
 class SpecialPowersAPIParent extends JSWindowActorParent {
   constructor() {
     super();
     this._crashDumpDir = null;
     this._processCrashObserversRegistered = false;
     this._chromeScriptListeners = [];
     this._extensions = new Map();
+    this._taskActors = new Map();
   }
 
   _observe(aSubject, aTopic, aData) {
     function addDumpIDToMessage(propertyName) {
       try {
         var id = aSubject.getPropertyAsAString(propertyName);
       } catch (ex) {
         id = null;
@@ -559,60 +565,45 @@ class SpecialPowersAPIParent extends JSW
             || "<loadChromeScript anonymous function>";
         } else {
           throw new SpecialPowersError("SPLoadChromeScript: Invalid script");
         }
 
         // Setup a chrome sandbox that has access to sendAsyncMessage
         // and {add,remove}MessageListener in order to communicate with
         // the mochitest.
-        let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
-        let sandboxOptions = Object.assign({wantGlobalProperties: ["ChromeUtils"]},
-                                           aMessage.json.sandboxOptions);
-        let sb = Cu.Sandbox(systemPrincipal, sandboxOptions);
-        sb.sendAsyncMessage = (name, message) => {
-          this.sendAsyncMessage("SPChromeScriptMessage",
-                                { id, name, message });
-        };
-        sb.addMessageListener = (name, listener) => {
-          this._chromeScriptListeners.push({ id, name, listener });
-        };
-        sb.removeMessageListener = (name, listener) => {
-          let index = this._chromeScriptListeners.findIndex(function(obj) {
-            return obj.id == id && obj.name == name && obj.listener == listener;
-          });
-          if (index >= 0) {
-            this._chromeScriptListeners.splice(index, 1);
-          }
-        };
-        sb.actorParent = this.manager;
+        let sb = new SpecialPowersSandbox(
+          scriptName,
+          data => {
+            this.sendAsyncMessage("Assert", data);
+          },
+          aMessage.data);
 
-        // Also expose assertion functions
-        let reporter = (err, message, stack) => {
-          // Pipe assertions back to parent process
-          this.sendAsyncMessage("SPChromeScriptAssert",
-                                { id, name: scriptName, err, message,
-                                  stack });
-        };
-        Object.defineProperty(sb, "assert", {
-          get() {
-            let scope = Cu.createObjectIn(sb);
-            Services.scriptloader.loadSubScript("resource://specialpowers/Assert.jsm",
-                                                scope);
-
-            let assert = new scope.Assert(reporter);
-            delete sb.assert;
-            return sb.assert = assert;
+        Object.assign(sb.sandbox, {
+          sendAsyncMessage: (name, message) => {
+            this.sendAsyncMessage("SPChromeScriptMessage",
+                                  { id, name, message });
+          },
+          addMessageListener: (name, listener) => {
+            this._chromeScriptListeners.push({ id, name, listener });
           },
-          configurable: true,
+          removeMessageListener: (name, listener) => {
+            let index = this._chromeScriptListeners.findIndex(function(obj) {
+              return obj.id == id && obj.name == name && obj.listener == listener;
+            });
+            if (index >= 0) {
+              this._chromeScriptListeners.splice(index, 1);
+            }
+          },
+          actorParent: this.manager,
         });
 
         // Evaluate the chrome script
         try {
-          Cu.evalInSandbox(jsScript, sb, "1.8", scriptName, 1);
+          Cu.evalInSandbox(jsScript, sb.sandbox, "1.8", scriptName, 1);
         } catch (e) {
           throw new SpecialPowersError(
             "Error while executing chrome script '" + scriptName + "':\n" +
             e + "\n" +
             e.fileName + ":" + e.lineNumber);
         }
         return undefined; // See comment at the beginning of this function.
       }
@@ -761,17 +752,31 @@ class SpecialPowersAPIParent extends JSW
           return extension._uninstallPromise;
         });
       }
 
       case "Spawn": {
         let {browsingContext, task, args, caller} = aMessage.data;
 
         let spParent = browsingContext.currentWindowGlobal.getActor("SpecialPowers");
-        return spParent.sendQuery("Spawn", {task, args, caller});
+
+        let taskId = nextTaskID++;
+        spParent._taskActors.set(taskId, this);
+
+        return spParent.sendQuery("Spawn", {task, args, caller, taskId}).finally(() => {
+          spParent._taskActors.delete(taskId);
+        });
+      }
+
+      case "ProxiedAssert": {
+        let {taskId, data} = aMessage.data;
+        let actor = this._taskActors.get(taskId);
+
+        actor.sendAsyncMessage("Assert", data);
+        return undefined;
       }
 
       case "SPRemoveAllServiceWorkers": {
         return ServiceWorkerCleanUp.removeAll();
       }
 
       case "SPRemoveServiceWorkerDataForExampleDomain": {
         return ServiceWorkerCleanUp.removeFromHost("example.com");
--- a/testing/specialpowers/content/SpecialPowersChild.jsm
+++ b/testing/specialpowers/content/SpecialPowersChild.jsm
@@ -117,18 +117,21 @@ class SpecialPowersChild extends Special
         this._createFilesOnSuccess = null;
         this._createFilesOnError = null;
         if (errorHandler) {
           errorHandler(aMessage.data);
         }
         break;
 
       case "Spawn":
-        let {task, args, caller} = aMessage.data;
-        return this._spawnTask(task, args, caller);
+        let {task, args, caller, taskId} = aMessage.data;
+        return this._spawnTask(task, args, caller, taskId);
+
+      default:
+        return super.receiveMessage(aMessage);
     }
 
     return true;
   }
 
   quit() {
     this.sendAsyncMessage("SpecialPowers.Quit", {});
   }
new file mode 100644
--- /dev/null
+++ b/testing/specialpowers/content/SpecialPowersSandbox.jsm
@@ -0,0 +1,71 @@
+/* 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/. */
+
+/**
+ * This modules handles creating and provisioning Sandboxes for
+ * executing cross-process code from SpecialPowers. This allows all such
+ * sandboxes to have a similar environment, and in particular allows
+ * them to run test assertions in the target process and propagate
+ * results back to the caller.
+ */
+
+var EXPORTED_SYMBOLS = ["SpecialPowersSandbox"];
+
+ChromeUtils.defineModuleGetter(this, "Assert",
+                               "resource://testing-common/Assert.jsm");
+
+class SpecialPowersSandbox {
+  constructor(name, reportCallback, opts = {}) {
+    this.name = name;
+    this.reportCallback = reportCallback;
+
+    this._Assert = null;
+
+    this.sandbox = Cu.Sandbox(Cu.getGlobalForObject({}),
+                              Object.assign({wantGlobalProperties: ["ChromeUtils"]},
+                                            opts.sandboxOptions));
+
+    for (let prop of ["assert", "Assert"]) {
+      Object.defineProperty(this.sandbox, prop, {
+        get: () => {
+          return this.Assert;
+        },
+        enumerable: true,
+        configurable: true,
+      });
+    }
+  }
+
+  static getCallerInfo(frame) {
+    return {
+      filename: frame.filename,
+      lineNumber: frame.lineNumber,
+    };
+  }
+
+  get Assert() {
+    if (!this._Assert) {
+      this._Assert = new Assert((err, message, stack) => {
+        this.report(err, message, stack);
+      });
+    }
+    return this._Assert;
+  }
+
+  report(err, name, stack) {
+    let diag;
+    if (err) {
+      diag = `got ${uneval(err.actual)}, expected ${uneval(err.expected)} ` +
+              `(operator ${err.operator})`;
+    }
+
+    this.reportCallback({name, diag, passed: !err, stack});
+  }
+
+  execute(task, args, caller) {
+    let func = Cu.evalInSandbox(`(${task})`, this.sandbox, undefined,
+                                caller.filename, caller.lineNumber);
+    return func(...args);
+  }
+}
--- a/testing/specialpowers/moz.build
+++ b/testing/specialpowers/moz.build
@@ -18,13 +18,14 @@ FINAL_TARGET_FILES.content += [
     '../modules/Assert.jsm',
     'content/MockColorPicker.jsm',
     'content/MockFilePicker.jsm',
     'content/MockPermissionPrompt.jsm',
     'content/SpecialPowersAPI.jsm',
     'content/SpecialPowersAPIParent.jsm',
     'content/SpecialPowersChild.jsm',
     'content/SpecialPowersParent.jsm',
+    'content/SpecialPowersSandbox.jsm',
     'content/WrapPrivileged.jsm',
 ]
 
 with Files("**"):
     BUG_COMPONENT = ("Testing", "Mochitest")
--- a/toolkit/components/passwordmgr/test/mochitest/test_xhr.html
+++ b/toolkit/components/passwordmgr/test/mochitest/test_xhr.html
@@ -158,14 +158,18 @@ add_task(async function test2() {
               `Tab with remote URI (rather than about:blank)
                should be focused (${spec})`);
   });
 
   is(result.authok, "PASS", "Checking for successful authentication");
   is(result.username, "xhruser2", "Checking for username");
   is(result.password, "xhrpass2", "Checking for password");
 
+  // Wait for the assert from the parent script to run and send back its reply,
+  // so it's processed before the test ends.
+  await SpecialPowers.executeAfterFlushingMessageQueue();
+
   newWin.close();
 });
 </script>
 </pre>
 </body>
 </html>