Bug 1561150: Support test assertions in SpecialPowers.spawn sandboxes. r=nika

Differential Revision: https://phabricator.services.mozilla.com/D35747

--HG--
extra : rebase_source : 62aa469b15c2ebfb0de299c163b26ed68361ee1e
extra : source : 0b3e2164f1283b639782b41b7fd640986d3feca5
This commit is contained in:
Kris Maglione 2019-06-24 19:25:33 -07:00
parent b459f53a11
commit 3c304b4239
8 changed files with 259 additions and 102 deletions

View File

@ -22,6 +22,7 @@ support-files = file_SpecialPowersFrame1.html
support-files = support-files =
specialPowers_framescript.js specialPowers_framescript.js
[test_SpecialPowersPushPrefEnv.html] [test_SpecialPowersPushPrefEnv.html]
[test_SpecialPowersSandbox.html]
[test_SpecialPowersSpawn.html] [test_SpecialPowersSpawn.html]
support-files = file_spawn.html support-files = file_spawn.html
[test_SimpletestGetTestFileURL.html] [test_SimpletestGetTestFileURL.html]

View File

@ -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>

View File

@ -17,6 +17,8 @@ ChromeUtils.defineModuleGetter(this, "MockColorPicker",
"resource://specialpowers/MockColorPicker.jsm"); "resource://specialpowers/MockColorPicker.jsm");
ChromeUtils.defineModuleGetter(this, "MockPermissionPrompt", ChromeUtils.defineModuleGetter(this, "MockPermissionPrompt",
"resource://specialpowers/MockPermissionPrompt.jsm"); "resource://specialpowers/MockPermissionPrompt.jsm");
ChromeUtils.defineModuleGetter(this, "SpecialPowersSandbox",
"resource://specialpowers/SpecialPowersSandbox.jsm");
ChromeUtils.defineModuleGetter(this, "WrapPrivileged", ChromeUtils.defineModuleGetter(this, "WrapPrivileged",
"resource://specialpowers/WrapPrivileged.jsm"); "resource://specialpowers/WrapPrivileged.jsm");
ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils", ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
@ -130,6 +132,28 @@ class SpecialPowersAPI extends JSWindowActorChild {
this._extensionListeners = null; 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 * Privileged object wrapping API
* *
@ -337,7 +361,6 @@ class SpecialPowersAPI extends JSWindowActorChild {
destroy: () => { destroy: () => {
listeners = []; listeners = [];
this._removeMessageListener("SPChromeScriptMessage", chromeScript); this._removeMessageListener("SPChromeScriptMessage", chromeScript);
this._removeMessageListener("SPChromeScriptAssert", chromeScript);
}, },
receiveMessage: (aMessage) => { receiveMessage: (aMessage) => {
@ -356,56 +379,11 @@ class SpecialPowersAPI extends JSWindowActorChild {
for (let listener of listeners.filter(o => o.name == name)) { for (let listener of listeners.filter(o => o.name == name)) {
result = listener.listener(message); result = listener.listener(message);
} }
} else if (aMessage.name == "SPChromeScriptAssert") {
assert(aMessage.json);
} }
return result; return result;
}, },
}; };
this._addMessageListener("SPChromeScriptMessage", chromeScript); 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); return this.wrap(chromeScript);
} }
@ -1236,6 +1214,10 @@ class SpecialPowersAPI extends JSWindowActorChild {
* passed will be copied via structured clone, as will its return * passed will be copied via structured clone, as will its return
* value. * 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 * @param {BrowsingContext or FrameLoaderOwner or WindowProxy} target
* The target in which to run the task. This may be any element * The target in which to run the task. This may be any element
* which implements the FrameLoaderOwner interface (including * which implements the FrameLoaderOwner interface (including
@ -1269,32 +1251,26 @@ class SpecialPowersAPI extends JSWindowActorChild {
browsingContext = BrowsingContext.getFromWindow(target); browsingContext = BrowsingContext.getFromWindow(target);
} }
let {caller} = Components.stack;
return this.sendQuery("Spawn", { return this.sendQuery("Spawn", {
browsingContext, browsingContext,
args, args,
task: String(task), task: String(task),
caller: { caller: SpecialPowersSandbox.getCallerInfo(Components.stack.caller),
filename: caller.filename,
lineNumber: caller.lineNumber,
},
}); });
} }
_spawnTask(task, args, caller) { _spawnTask(task, args, caller, taskId) {
let sb = Cu.Sandbox(Cu.getGlobalForObject({}), let sb = new SpecialPowersSandbox(null, data => {
{wantGlobalProperties: ["ChromeUtils"]}); this.sendAsyncMessage("ProxiedAssert", {taskId, data});
});
sb.SpecialPowers = this; sb.sandbox.SpecialPowers = this;
Object.defineProperty(sb, "content", { Object.defineProperty(sb.sandbox, "content", {
get: () => { return this.contentWindow; }, get: () => { return this.contentWindow; },
enumerable: true, enumerable: true,
}); });
let func = Cu.evalInSandbox(`(${task})`, sb, undefined, return sb.execute(task, args, caller);
caller.filename, caller.lineNumber);
return func(...args);
} }
getFocusedElementForWindow(targetWindow, aDeep) { getFocusedElementForWindow(targetWindow, aDeep) {

View File

@ -14,6 +14,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.jsm", ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.jsm",
PerTestCoverageUtils: "resource://testing-common/PerTestCoverageUtils.jsm", PerTestCoverageUtils: "resource://testing-common/PerTestCoverageUtils.jsm",
ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.jsm", ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.jsm",
SpecialPowersSandbox: "resource://specialpowers/SpecialPowersSandbox.jsm",
}); });
class SpecialPowersError extends Error { class SpecialPowersError extends Error {
@ -99,6 +100,10 @@ function doPrefEnvOp(fn) {
} }
} }
// 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 { class SpecialPowersAPIParent extends JSWindowActorParent {
constructor() { constructor() {
super(); super();
@ -106,6 +111,7 @@ class SpecialPowersAPIParent extends JSWindowActorParent {
this._processCrashObserversRegistered = false; this._processCrashObserversRegistered = false;
this._chromeScriptListeners = []; this._chromeScriptListeners = [];
this._extensions = new Map(); this._extensions = new Map();
this._taskActors = new Map();
} }
_observe(aSubject, aTopic, aData) { _observe(aSubject, aTopic, aData) {
@ -564,50 +570,35 @@ class SpecialPowersAPIParent extends JSWindowActorParent {
// Setup a chrome sandbox that has access to sendAsyncMessage // Setup a chrome sandbox that has access to sendAsyncMessage
// and {add,remove}MessageListener in order to communicate with // and {add,remove}MessageListener in order to communicate with
// the mochitest. // the mochitest.
let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); let sb = new SpecialPowersSandbox(
let sandboxOptions = Object.assign({wantGlobalProperties: ["ChromeUtils"]}, scriptName,
aMessage.json.sandboxOptions); data => {
let sb = Cu.Sandbox(systemPrincipal, sandboxOptions); this.sendAsyncMessage("Assert", data);
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;
// 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;
}, },
configurable: true, aMessage.data);
Object.assign(sb.sandbox, {
sendAsyncMessage: (name, message) => {
this.sendAsyncMessage("SPChromeScriptMessage",
{ id, name, message });
},
addMessageListener: (name, listener) => {
this._chromeScriptListeners.push({ id, name, listener });
},
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 // Evaluate the chrome script
try { try {
Cu.evalInSandbox(jsScript, sb, "1.8", scriptName, 1); Cu.evalInSandbox(jsScript, sb.sandbox, "1.8", scriptName, 1);
} catch (e) { } catch (e) {
throw new SpecialPowersError( throw new SpecialPowersError(
"Error while executing chrome script '" + scriptName + "':\n" + "Error while executing chrome script '" + scriptName + "':\n" +
@ -772,7 +763,21 @@ class SpecialPowersAPIParent extends JSWindowActorParent {
let {browsingContext, task, args, caller} = aMessage.data; let {browsingContext, task, args, caller} = aMessage.data;
let spParent = browsingContext.currentWindowGlobal.getActor("SpecialPowers"); 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": { case "SPRemoveAllServiceWorkers": {

View File

@ -122,8 +122,11 @@ class SpecialPowersChild extends SpecialPowersAPI {
break; break;
case "Spawn": case "Spawn":
let {task, args, caller} = aMessage.data; let {task, args, caller, taskId} = aMessage.data;
return this._spawnTask(task, args, caller); return this._spawnTask(task, args, caller, taskId);
default:
return super.receiveMessage(aMessage);
} }
return true; return true;

View File

@ -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);
}
}

View File

@ -23,6 +23,7 @@ FINAL_TARGET_FILES.content += [
'content/SpecialPowersAPIParent.jsm', 'content/SpecialPowersAPIParent.jsm',
'content/SpecialPowersChild.jsm', 'content/SpecialPowersChild.jsm',
'content/SpecialPowersParent.jsm', 'content/SpecialPowersParent.jsm',
'content/SpecialPowersSandbox.jsm',
'content/WrapPrivileged.jsm', 'content/WrapPrivileged.jsm',
] ]

View File

@ -163,6 +163,10 @@ add_task(async function test2() {
is(result.username, "xhruser2", "Checking for username"); is(result.username, "xhruser2", "Checking for username");
is(result.password, "xhrpass2", "Checking for password"); 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(); newWin.close();
}); });
</script> </script>