summaryrefslogtreecommitdiffstats
path: root/testing/modules
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /testing/modules
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/modules')
-rw-r--r--testing/modules/AppData.sys.mjs88
-rw-r--r--testing/modules/AppInfo.sys.mjs139
-rw-r--r--testing/modules/Assert.sys.mjs695
-rw-r--r--testing/modules/CoverageUtils.sys.mjs210
-rw-r--r--testing/modules/FileTestUtils.sys.mjs127
-rw-r--r--testing/modules/MockRegistrar.sys.mjs127
-rw-r--r--testing/modules/MockRegistry.sys.mjs145
-rw-r--r--testing/modules/Sinon.sys.mjs40
-rw-r--r--testing/modules/StructuredLog.sys.mjs304
-rw-r--r--testing/modules/TestUtils.sys.mjs381
-rw-r--r--testing/modules/XPCShellContentUtils.sys.mjs490
-rw-r--r--testing/modules/moz.build39
-rw-r--r--testing/modules/tests/browser/browser.ini1
-rw-r--r--testing/modules/tests/browser/browser_test_assert.js21
-rw-r--r--testing/modules/tests/xpcshell/test_assert.js470
-rw-r--r--testing/modules/tests/xpcshell/test_mockRegistrar.js65
-rw-r--r--testing/modules/tests/xpcshell/test_structuredlog.js147
-rw-r--r--testing/modules/tests/xpcshell/xpcshell.ini6
18 files changed, 3495 insertions, 0 deletions
diff --git a/testing/modules/AppData.sys.mjs b/testing/modules/AppData.sys.mjs
new file mode 100644
index 0000000000..0eaa3ba722
--- /dev/null
+++ b/testing/modules/AppData.sys.mjs
@@ -0,0 +1,88 @@
+/* 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/. */
+
+// Reference needed in order for fake app dir provider to be active.
+var gFakeAppDirectoryProvider;
+
+/**
+ * Installs a fake UAppData directory.
+ *
+ * This is needed by tests because a UAppData directory typically isn't
+ * present in the test environment.
+ *
+ * We create the new UAppData directory under the profile's directory
+ * because the profile directory is automatically cleaned as part of
+ * test shutdown.
+ *
+ * This returns a promise that will be resolved once the new directory
+ * is created and installed.
+ */
+export var makeFakeAppDir = function () {
+ let dirMode = 0o700;
+ let baseFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ let appD = baseFile.clone();
+ appD.append("UAppData");
+
+ if (gFakeAppDirectoryProvider) {
+ return Promise.resolve(appD.path);
+ }
+
+ function makeDir(f) {
+ if (f.exists()) {
+ return;
+ }
+
+ dump("Creating directory: " + f.path + "\n");
+ f.create(Ci.nsIFile.DIRECTORY_TYPE, dirMode);
+ }
+
+ makeDir(appD);
+
+ let reportsD = appD.clone();
+ reportsD.append("Crash Reports");
+
+ let pendingD = reportsD.clone();
+ pendingD.append("pending");
+ let submittedD = reportsD.clone();
+ submittedD.append("submitted");
+
+ makeDir(reportsD);
+ makeDir(pendingD);
+ makeDir(submittedD);
+
+ let provider = {
+ getFile(prop, persistent) {
+ persistent.value = true;
+ if (prop == "UAppData") {
+ return appD.clone();
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ },
+
+ QueryInterace(iid) {
+ if (
+ iid.equals(Ci.nsIDirectoryServiceProvider) ||
+ iid.equals(Ci.nsISupports)
+ ) {
+ return this;
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ };
+
+ // Register the new provider.
+ Services.dirsvc.registerProvider(provider);
+
+ // And undefine the old one.
+ try {
+ Services.dirsvc.undefine("UAppData");
+ } catch (ex) {}
+
+ gFakeAppDirectoryProvider = provider;
+
+ dump("Successfully installed fake UAppDir\n");
+ return Promise.resolve(appD.path);
+};
diff --git a/testing/modules/AppInfo.sys.mjs b/testing/modules/AppInfo.sys.mjs
new file mode 100644
index 0000000000..13e20851b2
--- /dev/null
+++ b/testing/modules/AppInfo.sys.mjs
@@ -0,0 +1,139 @@
+/* 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/. */
+
+let origPlatformInfo = Cc["@mozilla.org/xre/app-info;1"].getService(
+ Ci.nsIPlatformInfo
+);
+
+// eslint-disable-next-line mozilla/use-services
+let origRuntime = Cc["@mozilla.org/xre/app-info;1"].getService(
+ Ci.nsIXULRuntime
+);
+
+/**
+ * Create new XULAppInfo instance with specified options.
+ *
+ * options is a object with following keys:
+ * ID: nsIXULAppInfo.ID
+ * name: nsIXULAppInfo.name
+ * version: nsIXULAppInfo.version
+ * platformVersion: nsIXULAppInfo.platformVersion
+ * OS: nsIXULRuntime.OS
+ * appBuildID: nsIXULRuntime.appBuildID
+ * lastAppBuildID: nsIXULRuntime.lastAppBuildID
+ * lastAppVersion: nsIXULRuntime.lastAppVersion
+ *
+ * crashReporter: nsICrashReporter interface is implemented if true
+ */
+export var newAppInfo = function (options = {}) {
+ let appInfo = {
+ // nsIXULAppInfo
+ vendor: "Mozilla",
+ name: options.name ?? "xpcshell",
+ ID: options.ID ?? "xpcshell@tests.mozilla.org",
+ version: options.version ?? "1",
+ appBuildID: options.appBuildID ?? "20160315",
+
+ // nsIPlatformInfo
+ platformVersion: options.platformVersion ?? "p-ver",
+ platformBuildID: origPlatformInfo.platformBuildID,
+
+ // nsIXULRuntime
+ ...Ci.nsIXULRuntime,
+ inSafeMode: false,
+ logConsoleErrors: true,
+ OS: options.OS ?? "XPCShell",
+ XPCOMABI: "noarch-spidermonkey",
+ invalidateCachesOnRestart() {},
+ shouldBlockIncompatJaws: false,
+ processType: origRuntime.processType,
+ uniqueProcessID: origRuntime.uniqueProcessID,
+
+ fissionAutostart: origRuntime.fissionAutostart,
+ sessionHistoryInParent: origRuntime.sessionHistoryInParent,
+ browserTabsRemoteAutostart: origRuntime.browserTabsRemoteAutostart,
+ get maxWebProcessCount() {
+ return origRuntime.maxWebProcessCount;
+ },
+ get launcherProcessState() {
+ return origRuntime.launcherProcessState;
+ },
+
+ // nsIWinAppHelper
+ get userCanElevate() {
+ return false;
+ },
+ };
+
+ appInfo.lastAppBuildID = options.lastAppBuildID ?? appInfo.appBuildID;
+ appInfo.lastAppVersion = options.lastAppVersion ?? appInfo.version;
+
+ let interfaces = [
+ Ci.nsIXULAppInfo,
+ Ci.nsIPlatformInfo,
+ Ci.nsIXULRuntime,
+ Ci.nsICrashReporter,
+ ];
+ if ("nsIWinAppHelper" in Ci) {
+ interfaces.push(Ci.nsIWinAppHelper);
+ }
+
+ // nsICrashReporter
+ appInfo.annotations = {};
+ appInfo.annotateCrashReport = function (key, data) {
+ if (options.crashReporter) {
+ this.annotations[key] = data;
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+ };
+
+ appInfo.QueryInterface = ChromeUtils.generateQI(interfaces);
+
+ return appInfo;
+};
+
+var currentAppInfo = newAppInfo();
+
+/**
+ * Obtain a reference to the current object used to define XULAppInfo.
+ */
+export var getAppInfo = function () {
+ return currentAppInfo;
+};
+
+/**
+ * Update the current application info.
+ *
+ * See newAppInfo for options.
+ *
+ * To change the current XULAppInfo, simply call this function. If there was
+ * a previously registered app info object, it will be unloaded and replaced.
+ */
+export var updateAppInfo = function (options) {
+ currentAppInfo = newAppInfo(options);
+
+ let id = Components.ID("{fbfae60b-64a4-44ef-a911-08ceb70b9f31}");
+ let contractid = "@mozilla.org/xre/app-info;1";
+ let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+
+ // Unregister an existing factory if one exists.
+ try {
+ let existing = Components.manager.getClassObjectByContractID(
+ contractid,
+ Ci.nsIFactory
+ );
+ registrar.unregisterFactory(id, existing);
+ } catch (ex) {}
+
+ let factory = {
+ createInstance(iid) {
+ return currentAppInfo.QueryInterface(iid);
+ },
+ };
+
+ Services.appinfo = currentAppInfo;
+
+ registrar.registerFactory(id, "XULAppInfo", contractid, factory);
+};
diff --git a/testing/modules/Assert.sys.mjs b/testing/modules/Assert.sys.mjs
new file mode 100644
index 0000000000..bd1393e674
--- /dev/null
+++ b/testing/modules/Assert.sys.mjs
@@ -0,0 +1,695 @@
+/* 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/. */
+
+// Originally from narwhal.js (http://narwhaljs.org)
+// Copyright (c) 2009 Thomas Robinson <280north.com>
+// MIT license: http://opensource.org/licenses/MIT
+
+const { ObjectUtils } = ChromeUtils.import(
+ "resource://gre/modules/ObjectUtils.jsm"
+);
+
+/**
+ * This module is based on the
+ * `CommonJS spec <https://wiki.commonjs.org/wiki/Unit_Testing/1.0>`_
+ *
+ * When you see a jsdoc comment that contains a number, it's a reference to a
+ * specific section of the CommonJS spec.
+ *
+ * 1. The assert module provides functions that throw AssertionError's when
+ * particular conditions are not met.
+ *
+ * To use the module you may instantiate it first.
+ *
+ * @param {reporterFunc} reporterFunc
+ * Allows consumers to override reporting for this instance.
+ * @param {boolean} isDefault
+ * Used by test suites to set ``reporterFunc`` as the default
+ * used by the global instance, which is called for example
+ * by other test-only modules. This is false when the
+ * reporter is set by content scripts, because they may still
+ * run in the parent process.
+ *
+ * @class
+ */
+export function Assert(reporterFunc, isDefault) {
+ if (reporterFunc) {
+ this.setReporter(reporterFunc);
+ }
+ if (isDefault) {
+ Assert.setReporter(reporterFunc);
+ }
+}
+
+// This allows using the Assert object as an additional global instance.
+Object.setPrototypeOf(Assert, Assert.prototype);
+
+function instanceOf(object, type) {
+ return Object.prototype.toString.call(object) == "[object " + type + "]";
+}
+
+function replacer(key, value) {
+ if (value === undefined) {
+ return "" + value;
+ }
+ if (typeof value === "number" && (isNaN(value) || !isFinite(value))) {
+ return value.toString();
+ }
+ if (typeof value === "function" || instanceOf(value, "RegExp")) {
+ return value.toString();
+ }
+ return value;
+}
+
+const kTruncateLength = 128;
+
+function truncate(text, newLength = kTruncateLength) {
+ if (typeof text == "string") {
+ return text.length < newLength ? text : text.slice(0, newLength);
+ }
+ return text;
+}
+
+function getMessage(error, prefix = "") {
+ let actual, expected;
+ // Wrap calls to JSON.stringify in try...catch blocks, as they may throw. If
+ // so, fall back to toString().
+ try {
+ actual = JSON.stringify(error.actual, replacer);
+ } catch (ex) {
+ actual = Object.prototype.toString.call(error.actual);
+ }
+ try {
+ expected = JSON.stringify(error.expected, replacer);
+ } catch (ex) {
+ expected = Object.prototype.toString.call(error.expected);
+ }
+ let message = prefix;
+ if (error.operator) {
+ let truncateLength = error.truncate ? kTruncateLength : Infinity;
+ message +=
+ (prefix ? " - " : "") +
+ truncate(actual, truncateLength) +
+ " " +
+ error.operator +
+ " " +
+ truncate(expected, truncateLength);
+ }
+ return message;
+}
+
+/**
+ * 2. The AssertionError is defined in assert.
+ *
+ * At present only the four keys mentioned below are used and
+ * understood by the spec. Implementations or sub modules can pass
+ * other keys to the AssertionError's constructor - they will be
+ * ignored.
+ *
+ * @example
+ *
+ * new assert.AssertionError({
+ * message: message,
+ * actual: actual,
+ * expected: expected,
+ * operator: operator,
+ * truncate: truncate
+ * });
+ *
+ */
+Assert.AssertionError = function (options) {
+ this.name = "AssertionError";
+ this.actual = options.actual;
+ this.expected = options.expected;
+ this.operator = options.operator;
+ this.message = getMessage(this, options.message, options.truncate);
+ // The part of the stack that comes from this module is not interesting.
+ let stack = Components.stack;
+ do {
+ stack = stack.asyncCaller || stack.caller;
+ } while (
+ stack &&
+ stack.filename &&
+ stack.filename.includes("Assert.sys.mjs")
+ );
+ this.stack = stack;
+};
+
+// assert.AssertionError instanceof Error
+Assert.AssertionError.prototype = Object.create(Error.prototype, {
+ constructor: {
+ value: Assert.AssertionError,
+ enumerable: false,
+ writable: true,
+ configurable: true,
+ },
+});
+
+Assert.prototype._reporter = null;
+
+/**
+ * This callback type is used for custom assertion report handling.
+ *
+ * @callback reporterFunc
+ * @param {AssertionError|null} err
+ * An error object when the assertion failed, or null when it passed.
+ * @param {String} message
+ * Message describing the assertion.
+ * @param {Stack} stack
+ * Stack trace of the assertion function.
+ */
+
+/**
+ * Set a custom assertion report handler function.
+ *
+ * @example
+ *
+ * Assert.setReporter(function customReporter(err, message, stack) {
+ * if (err) {
+ * do_report_result(false, err.message, err.stack);
+ * } else {
+ * do_report_result(true, message, stack);
+ * }
+ * });
+ *
+ * @param {reporterFunc} reporterFunc
+ * Report handler function.
+ */
+Assert.prototype.setReporter = function (reporterFunc) {
+ this._reporter = reporterFunc;
+};
+
+/**
+ * 3. All of the following functions must throw an AssertionError when a
+ * corresponding condition is not met, with a message that may be undefined if
+ * not provided. All assertion methods provide both the actual and expected
+ * values to the assertion error for display purposes.
+ *
+ * This report method only throws errors on assertion failures, as per spec,
+ * but consumers of this module (think: xpcshell-test, mochitest) may want to
+ * override this default implementation.
+ *
+ * @example
+ *
+ * // The following will report an assertion failure.
+ * this.report(1 != 2, 1, 2, "testing JS number math!", "==");
+ *
+ * @param {boolean} failed
+ * Indicates if the assertion failed or not.
+ * @param {*} actual
+ * The result of evaluating the assertion.
+ * @param {*} [expected]
+ * Expected result from the test author.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ * @param {String} [operator]
+ * Operation qualifier used by the assertion method (ex: '==').
+ * @param {boolean} [truncate=true]
+ * Whether or not ``actual`` and ``expected`` should be truncated when printing.
+ */
+Assert.prototype.report = function (
+ failed,
+ actual,
+ expected,
+ message,
+ operator,
+ truncate = true
+) {
+ // Although not ideal, we allow a "null" message due to the way some of the extension tests
+ // work.
+ if (message !== undefined && message !== null && typeof message != "string") {
+ this.ok(
+ false,
+ `Expected a string or undefined for the error message to Assert.*, got ${typeof message}`
+ );
+ }
+ let err = new Assert.AssertionError({
+ message,
+ actual,
+ expected,
+ operator,
+ truncate,
+ });
+ if (!this._reporter) {
+ // If no custom reporter is set, throw the error.
+ if (failed) {
+ throw err;
+ }
+ } else {
+ this._reporter(failed ? err : null, err.message, err.stack);
+ }
+};
+
+/**
+ * 4. Pure assertion tests whether a value is truthy, as determined by !!guard.
+ * ``assert.ok(guard, message_opt);``
+ * This statement is equivalent to ``assert.equal(true, !!guard, message_opt);``.
+ * To test strictly for the value true, use ``assert.strictEqual(true, guard,
+ * message_opt);``.
+ *
+ * @param {*} value
+ * Test subject to be evaluated as truthy.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.ok = function (value, message) {
+ if (arguments.length > 2) {
+ this.report(
+ true,
+ false,
+ true,
+ "Too many arguments passed to `Assert.ok()`",
+ "=="
+ );
+ } else {
+ this.report(!value, value, true, message, "==");
+ }
+};
+
+/**
+ * 5. The equality assertion tests shallow, coercive equality with ==.
+ * ``assert.equal(actual, expected, message_opt);``
+ *
+ * @param {*} actual
+ * Test subject to be evaluated as equivalent to ``expected``.
+ * @param {*} expected
+ * Test reference to evaluate against ``actual``.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.equal = function equal(actual, expected, message) {
+ this.report(actual != expected, actual, expected, message, "==");
+};
+
+/**
+ * 6. The non-equality assertion tests for whether two objects are not equal
+ * with ``!=``
+ *
+ * @example
+ * assert.notEqual(actual, expected, message_opt);
+ *
+ * @param {*} actual
+ * Test subject to be evaluated as NOT equivalent to ``expected``.
+ * @param {*} expected
+ * Test reference to evaluate against ``actual``.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.notEqual = function notEqual(actual, expected, message) {
+ this.report(actual == expected, actual, expected, message, "!=");
+};
+
+/**
+ * 7. The equivalence assertion tests a deep equality relation.
+ * assert.deepEqual(actual, expected, message_opt);
+ *
+ * We check using the most exact approximation of equality between two objects
+ * to keep the chance of false positives to a minimum.
+ * `JSON.stringify` is not designed to be used for this purpose; objects may
+ * have ambiguous `toJSON()` implementations that would influence the test.
+ *
+ * @param {*} actual
+ * Test subject to be evaluated as equivalent to ``expected``, including nested properties.
+ * @param {*} expected
+ * Test reference to evaluate against ``actual``.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.deepEqual = function deepEqual(actual, expected, message) {
+ this.report(
+ !ObjectUtils.deepEqual(actual, expected),
+ actual,
+ expected,
+ message,
+ "deepEqual",
+ false
+ );
+};
+
+/**
+ * 8. The non-equivalence assertion tests for any deep inequality.
+ * assert.notDeepEqual(actual, expected, message_opt);
+ *
+ * @param {*} actual
+ * Test subject to be evaluated as NOT equivalent to ``expected``, including nested
+ * properties.
+ * @param {*} expected
+ * Test reference to evaluate against ``actual``.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.notDeepEqual = function notDeepEqual(
+ actual,
+ expected,
+ message
+) {
+ this.report(
+ ObjectUtils.deepEqual(actual, expected),
+ actual,
+ expected,
+ message,
+ "notDeepEqual",
+ false
+ );
+};
+
+/**
+ * 9. The strict equality assertion tests strict equality, as determined by ===.
+ * ``assert.strictEqual(actual, expected, message_opt);``
+ *
+ * @param {*} actual
+ * Test subject to be evaluated as strictly equivalent to ``expected``.
+ * @param {*} expected
+ * Test reference to evaluate against ``actual``.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.strictEqual = function strictEqual(actual, expected, message) {
+ this.report(actual !== expected, actual, expected, message, "===");
+};
+
+/**
+ * 10. The strict non-equality assertion tests for strict inequality, as
+ * determined by !==. ``assert.notStrictEqual(actual, expected, message_opt);``
+ *
+ * @param {*} actual
+ * Test subject to be evaluated as NOT strictly equivalent to ``expected``.
+ * @param {*} expected
+ * Test reference to evaluate against ``actual``.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.notStrictEqual = function notStrictEqual(
+ actual,
+ expected,
+ message
+) {
+ this.report(actual === expected, actual, expected, message, "!==");
+};
+
+function checkExpectedArgument(instance, funcName, expected) {
+ if (!expected) {
+ instance.ok(
+ false,
+ `Error: The 'expected' argument was not supplied to Assert.${funcName}()`
+ );
+ }
+
+ if (
+ !instanceOf(expected, "RegExp") &&
+ typeof expected !== "function" &&
+ typeof expected !== "object"
+ ) {
+ instance.ok(
+ false,
+ `Error: The 'expected' argument to Assert.${funcName}() must be a RegExp, function or an object`
+ );
+ }
+}
+
+function expectedException(actual, expected) {
+ if (!actual || !expected) {
+ return false;
+ }
+
+ if (instanceOf(expected, "RegExp")) {
+ return expected.test(actual);
+ // We need to guard against the right hand parameter of "instanceof" lacking
+ // the "prototype" property, which is true of arrow functions in particular.
+ } else if (
+ !(typeof expected === "function" && !expected.prototype) &&
+ actual instanceof expected
+ ) {
+ return true;
+ } else if (expected.call({}, actual) === true) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * 11. Expected to throw an error:
+ * assert.throws(block, Error_opt, message_opt);
+ *
+ * Example:
+ * ```js
+ * // The following will verify that an error of type TypeError was thrown:
+ * Assert.throws(() => testBody(), TypeError);
+ * // The following will verify that an error was thrown with an error message matching "hello":
+ * Assert.throws(() => testBody(), /hello/);
+ * ```
+ *
+ * @param {Function} block
+ * Function to evaluate and catch eventual thrown errors.
+ * @param {RegExp|Function} expected
+ * This parameter can be either a RegExp or a function. The function is
+ * either the error type's constructor, or it's a method that returns
+ * a boolean that describes the test outcome.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.throws = function (block, expected, message) {
+ checkExpectedArgument(this, "throws", expected);
+
+ // `true` if we realize that we have added an
+ // error to `ChromeUtils.recentJSDevError` and
+ // that we probably need to clean it up.
+ let cleanupRecentJSDevError = false;
+ if ("recentJSDevError" in ChromeUtils) {
+ // Check that we're in a build of Firefox that supports
+ // the `recentJSDevError` mechanism (i.e. Nightly build).
+ if (ChromeUtils.recentJSDevError === undefined) {
+ // There was no previous error, so if we throw
+ // an error here, we may need to clean it up.
+ cleanupRecentJSDevError = true;
+ }
+ }
+
+ let actual;
+
+ try {
+ block();
+ } catch (e) {
+ actual = e;
+ }
+
+ message =
+ (expected.name ? " (" + expected.name + ")." : ".") +
+ (message ? " " + message : ".");
+
+ if (!actual) {
+ this.report(true, actual, expected, "Missing expected exception" + message);
+ }
+
+ if (actual && !expectedException(actual, expected)) {
+ throw actual;
+ }
+
+ this.report(false, expected, expected, message);
+
+ // Make sure that we don't cause failures for JS Dev Errors that
+ // were expected, typically for tests that attempt to check
+ // that we react properly to TypeError, ReferenceError, SyntaxError.
+ if (cleanupRecentJSDevError) {
+ let recentJSDevError = ChromeUtils.recentJSDevError;
+ if (recentJSDevError) {
+ if (expectedException(recentJSDevError)) {
+ ChromeUtils.clearRecentJSDevError();
+ }
+ }
+ }
+};
+
+/**
+ * A promise that is expected to reject:
+ * assert.rejects(promise, expected, message);
+ *
+ * @param {Promise} promise
+ * A promise that is expected to reject.
+ * @param {?} [expected]
+ * Test reference to evaluate against the rejection result.
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.rejects = function (promise, expected, message) {
+ checkExpectedArgument(this, "rejects", expected);
+ return new Promise((resolve, reject) => {
+ return promise
+ .then(
+ () =>
+ this.report(
+ true,
+ null,
+ expected,
+ "Missing expected exception " + message
+ ),
+ err => {
+ if (!expectedException(err, expected)) {
+ reject(err);
+ return;
+ }
+ this.report(false, err, expected, message);
+ resolve();
+ }
+ )
+ .catch(reject);
+ });
+};
+
+function compareNumbers(expression, lhs, rhs, message, operator) {
+ let lhsIsNumber = typeof lhs == "number" && !Number.isNaN(lhs);
+ let rhsIsNumber = typeof rhs == "number" && !Number.isNaN(rhs);
+
+ if (lhsIsNumber && rhsIsNumber) {
+ this.report(expression, lhs, rhs, message, operator);
+ return;
+ }
+
+ let errorMessage;
+ if (!lhsIsNumber && !rhsIsNumber) {
+ errorMessage = "Neither '" + lhs + "' nor '" + rhs + "' are numbers";
+ } else {
+ errorMessage = "'" + (lhsIsNumber ? rhs : lhs) + "' is not a number";
+ }
+ this.report(true, lhs, rhs, errorMessage);
+}
+
+/**
+ * The lhs must be greater than the rhs.
+ * assert.greater(lhs, rhs, message_opt);
+ *
+ * @param {Number} lhs
+ * The left-hand side value.
+ * @param {Number} rhs
+ * The right-hand side value.
+ * @param {String} [message]
+ * Short explanation of the comparison result.
+ */
+Assert.prototype.greater = function greater(lhs, rhs, message) {
+ compareNumbers.call(this, lhs <= rhs, lhs, rhs, message, ">");
+};
+
+/**
+ * The lhs must be greater than or equal to the rhs.
+ * assert.greaterOrEqual(lhs, rhs, message_opt);
+ *
+ * @param {Number} [lhs]
+ * The left-hand side value.
+ * @param {Number} [rhs]
+ * The right-hand side value.
+ * @param {String} [message]
+ * Short explanation of the comparison result.
+ */
+Assert.prototype.greaterOrEqual = function greaterOrEqual(lhs, rhs, message) {
+ compareNumbers.call(this, lhs < rhs, lhs, rhs, message, ">=");
+};
+
+/**
+ * The lhs must be less than the rhs.
+ * assert.less(lhs, rhs, message_opt);
+ *
+ * @param {Number} [lhs]
+ * The left-hand side value.
+ * @param {Number} [rhs]
+ * The right-hand side value.
+ * @param {String} [message]
+ * Short explanation of the comparison result.
+ */
+Assert.prototype.less = function less(lhs, rhs, message) {
+ compareNumbers.call(this, lhs >= rhs, lhs, rhs, message, "<");
+};
+
+/**
+ * The lhs must be less than or equal to the rhs.
+ * assert.lessOrEqual(lhs, rhs, message_opt);
+ *
+ * @param {Number} [lhs]
+ * The left-hand side value.
+ * @param {Number} [rhs]
+ * The right-hand side value.
+ * @param {String} [message]
+ * Short explanation of the comparison result.
+ */
+Assert.prototype.lessOrEqual = function lessOrEqual(lhs, rhs, message) {
+ compareNumbers.call(this, lhs > rhs, lhs, rhs, message, "<=");
+};
+
+/**
+ * The lhs must be a string that matches the rhs regular expression.
+ * rhs can be specified either as a string or a RegExp object. If specified as a
+ * string it will be interpreted as a regular expression so take care to escape
+ * special characters such as "?" or "(" if you need the actual characters.
+ *
+ * @param {String} lhs
+ * The string to be tested.
+ * @param {String|RegExp} rhs
+ * The regular expression that the string will be tested with.
+ * Note that if passed as a string, this will be interpreted.
+ * as a regular expression.
+ * @param {String} [message]
+ * Short explanation of the comparison result.
+ */
+Assert.prototype.stringMatches = function stringMatches(lhs, rhs, message) {
+ if (typeof rhs != "string" && !instanceOf(rhs, "RegExp")) {
+ this.report(
+ true,
+ lhs,
+ String(rhs),
+ `Expected a string or a RegExp for rhs, but "${rhs}" isn't a string or a RegExp object.`
+ );
+ return;
+ }
+
+ if (typeof lhs != "string") {
+ this.report(
+ true,
+ lhs,
+ String(rhs),
+ `Expected a string for lhs, but "${lhs}" isn't a string.`
+ );
+ return;
+ }
+
+ if (typeof rhs == "string") {
+ try {
+ rhs = new RegExp(rhs);
+ } catch {
+ this.report(
+ true,
+ lhs,
+ rhs,
+ `Expected a valid regular expression for rhs, but "${rhs}" isn't one.`
+ );
+ return;
+ }
+ }
+
+ const isCorrect = rhs.test(lhs);
+ this.report(!isCorrect, lhs, rhs.toString(), message, "matches");
+};
+
+/**
+ * The lhs must be a string that contains the rhs string.
+ *
+ * @param {String} lhs
+ * The string to be tested (haystack).
+ * @param {String} rhs
+ * The string to be found (needle).
+ * @param {String} [message]
+ * Short explanation of the expected result.
+ */
+Assert.prototype.stringContains = function stringContains(lhs, rhs, message) {
+ if (typeof lhs != "string" || typeof rhs != "string") {
+ this.report(
+ true,
+ lhs,
+ rhs,
+ `Expected a string for both lhs and rhs, but either "${lhs}" or "${rhs}" is not a string.`
+ );
+ }
+
+ const isCorrect = lhs.includes(rhs);
+ this.report(!isCorrect, lhs, rhs, message, "includes");
+};
diff --git a/testing/modules/CoverageUtils.sys.mjs b/testing/modules/CoverageUtils.sys.mjs
new file mode 100644
index 0000000000..990d82ad7a
--- /dev/null
+++ b/testing/modules/CoverageUtils.sys.mjs
@@ -0,0 +1,210 @@
+/* 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/. */
+
+import { addDebuggerToGlobal } from "resource://gre/modules/jsdebugger.sys.mjs";
+
+// eslint-disable-next-line mozilla/reject-globalThis-modification
+addDebuggerToGlobal(globalThis);
+
+/**
+ * Records coverage for each test by way of the js debugger.
+ */
+export var CoverageCollector = function (prefix) {
+ this._prefix = prefix;
+ this._dbg = new Debugger();
+ this._dbg.collectCoverageInfo = true;
+ this._dbg.addAllGlobalsAsDebuggees();
+ this._scripts = this._dbg.findScripts();
+
+ this._dbg.onNewScript = script => {
+ this._scripts.push(script);
+ };
+
+ // Source -> coverage data;
+ this._allCoverage = {};
+ this._encoder = new TextEncoder();
+
+ this._testIndex = 0;
+};
+
+CoverageCollector.prototype._getLinesCovered = function () {
+ let coveredLines = {};
+ let currentCoverage = {};
+ this._scripts.forEach(s => {
+ let scriptName = s.url;
+ let cov = s.getOffsetsCoverage();
+ if (!cov) {
+ return;
+ }
+
+ cov.forEach(covered => {
+ let { lineNumber, columnNumber, offset, count } = covered;
+ if (!count) {
+ return;
+ }
+
+ if (!currentCoverage[scriptName]) {
+ currentCoverage[scriptName] = {};
+ }
+ if (!this._allCoverage[scriptName]) {
+ this._allCoverage[scriptName] = {};
+ }
+
+ let key = [lineNumber, columnNumber, offset].join("#");
+ if (!currentCoverage[scriptName][key]) {
+ currentCoverage[scriptName][key] = count;
+ } else {
+ currentCoverage[scriptName][key] += count;
+ }
+ });
+ });
+
+ // Covered lines are determined by comparing every offset mentioned as of the
+ // the completion of a test to the last time we measured coverage. If an
+ // offset in a line is novel as of this test, or a count has increased for
+ // any offset on a particular line, that line must have been covered.
+ for (let scriptName in currentCoverage) {
+ for (let key in currentCoverage[scriptName]) {
+ if (
+ !this._allCoverage[scriptName] ||
+ !this._allCoverage[scriptName][key] ||
+ this._allCoverage[scriptName][key] < currentCoverage[scriptName][key]
+ ) {
+ // eslint-disable-next-line no-unused-vars
+ let [lineNumber, colNumber, offset] = key.split("#");
+ if (!coveredLines[scriptName]) {
+ coveredLines[scriptName] = new Set();
+ }
+ coveredLines[scriptName].add(parseInt(lineNumber, 10));
+ this._allCoverage[scriptName][key] = currentCoverage[scriptName][key];
+ }
+ }
+ }
+
+ return coveredLines;
+};
+
+CoverageCollector.prototype._getUncoveredLines = function () {
+ let uncoveredLines = {};
+ this._scripts.forEach(s => {
+ let scriptName = s.url;
+ let scriptOffsets = s.getAllOffsets();
+
+ if (!uncoveredLines[scriptName]) {
+ uncoveredLines[scriptName] = new Set();
+ }
+
+ // Get all lines in the script
+ scriptOffsets.forEach(function (element, index) {
+ if (!element) {
+ return;
+ }
+ uncoveredLines[scriptName].add(index);
+ });
+ });
+
+ // For all covered lines, delete their entry
+ for (let scriptName in this._allCoverage) {
+ for (let key in this._allCoverage[scriptName]) {
+ // eslint-disable-next-line no-unused-vars
+ let [lineNumber, columnNumber, offset] = key.split("#");
+ uncoveredLines[scriptName].delete(parseInt(lineNumber, 10));
+ }
+ }
+
+ return uncoveredLines;
+};
+
+CoverageCollector.prototype._getMethodNames = function () {
+ let methodNames = {};
+ this._scripts.forEach(s => {
+ let method = s.displayName;
+ // If the method name is undefined, we return early
+ if (!method) {
+ return;
+ }
+
+ let scriptName = s.url;
+ let tempMethodCov = [];
+ let scriptOffsets = s.getAllOffsets();
+
+ if (!methodNames[scriptName]) {
+ methodNames[scriptName] = {};
+ }
+
+ /**
+ * Get all lines contained within the method and
+ * push a record of the form:
+ * <method name> : <lines covered>
+ */
+ scriptOffsets.forEach(function (element, index) {
+ if (!element) {
+ return;
+ }
+ tempMethodCov.push(index);
+ });
+ methodNames[scriptName][method] = tempMethodCov;
+ });
+
+ return methodNames;
+};
+
+/**
+ * Records lines covered since the last time coverage was recorded,
+ * associating them with the given test name. The result is written
+ * to a json file in a specified directory.
+ */
+CoverageCollector.prototype.recordTestCoverage = function (testName) {
+ dump("Collecting coverage for: " + testName + "\n");
+ let rawLines = this._getLinesCovered(testName);
+ let methods = this._getMethodNames();
+ let uncoveredLines = this._getUncoveredLines();
+ let result = [];
+ let versionControlBlock = { version: 1.0 };
+ result.push(versionControlBlock);
+
+ for (let scriptName in rawLines) {
+ let rec = {
+ testUrl: testName,
+ sourceFile: scriptName,
+ methods: {},
+ covered: [],
+ uncovered: [],
+ };
+
+ if (
+ typeof methods[scriptName] != "undefined" &&
+ methods[scriptName] != null
+ ) {
+ for (let [methodName, methodLines] of Object.entries(
+ methods[scriptName]
+ )) {
+ rec.methods[methodName] = methodLines;
+ }
+ }
+
+ for (let line of rawLines[scriptName]) {
+ rec.covered.push(line);
+ }
+
+ for (let line of uncoveredLines[scriptName]) {
+ rec.uncovered.push(line);
+ }
+
+ result.push(rec);
+ }
+ let path = this._prefix + "/jscov_" + Date.now() + ".json";
+ dump("Writing coverage to: " + path + "\n");
+ return IOUtils.writeUTF8(path, JSON.stringify(result, undefined, 2), {
+ tmpPath: `${path}.tmp`,
+ });
+};
+
+/**
+ * Tear down the debugger after all tests are complete.
+ */
+CoverageCollector.prototype.finalize = function () {
+ this._dbg.removeAllDebuggees();
+ this._dbg.enabled = false;
+};
diff --git a/testing/modules/FileTestUtils.sys.mjs b/testing/modules/FileTestUtils.sys.mjs
new file mode 100644
index 0000000000..5e5c3f67f0
--- /dev/null
+++ b/testing/modules/FileTestUtils.sys.mjs
@@ -0,0 +1,127 @@
+/* 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/. */
+
+/**
+ * Provides testing functions dealing with local files and their contents.
+ */
+
+import { DownloadPaths } from "resource://gre/modules/DownloadPaths.sys.mjs";
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { Assert } from "resource://testing-common/Assert.sys.mjs";
+
+let gFileCounter = 1;
+let gPathsToRemove = [];
+
+export var FileTestUtils = {
+ /**
+ * Returns a reference to a temporary file that is guaranteed not to exist and
+ * to have never been created before. If a file or a directory with this name
+ * is created by the test, it will be deleted when all tests terminate.
+ *
+ * @param suggestedName [optional]
+ * Any extension on this template file name will be preserved. If this
+ * is unspecified, the returned file name will have the generic ".dat"
+ * extension, which may indicate either a binary or a text data file.
+ *
+ * @return nsIFile pointing to a non-existent file in a temporary directory.
+ *
+ * @note It is not enough to delete the file if it exists, or to delete the
+ * file after calling nsIFile.createUnique, because on Windows the
+ * delete operation in the file system may still be pending, preventing
+ * a new file with the same name to be created.
+ */
+ getTempFile(suggestedName = "test.dat") {
+ // Prepend a serial number to the extension in the suggested leaf name.
+ let [base, ext] = DownloadPaths.splitBaseNameAndExtension(suggestedName);
+ let leafName = base + "-" + gFileCounter + ext;
+ gFileCounter++;
+
+ // Get a file reference under the temporary directory for this test file.
+ let file = this._globalTemporaryDirectory.clone();
+ file.append(leafName);
+ Assert.ok(!file.exists(), "Sanity check the temporary file doesn't exist.");
+
+ // Since directory iteration on Windows may not see files that have just
+ // been created, keep track of the known file names to be removed.
+ gPathsToRemove.push(file.path);
+ return file;
+ },
+
+ /**
+ * Attemps to remove the given file or directory recursively, in a way that
+ * works even on Windows, where race conditions may occur in the file system
+ * when creating and removing files at the pace of the test suites.
+ *
+ * The function may fail silently if access is denied. This means that it
+ * should only be used to clean up temporary files, rather than for cases
+ * where the removal is part of a test and must be guaranteed.
+ *
+ * @param path
+ * String representing the path to remove.
+ */
+ async tolerantRemove(path) {
+ try {
+ await IOUtils.remove(path, { recursive: true });
+ } catch (ex) {
+ // On Windows, we may get an access denied error instead of a no such file
+ // error if the file existed before, and was recently deleted. There is no
+ // way to distinguish this from an access list issue because checking for
+ // the file existence would also result in the same error.
+ if (
+ !DOMException.isInstance(ex) ||
+ ex.name !== "NotFoundError" ||
+ ex.name !== "NotAllowedError"
+ ) {
+ throw ex;
+ }
+ }
+ },
+};
+
+/**
+ * Returns a reference to a global temporary directory that will be deleted
+ * when all tests terminate.
+ */
+XPCOMUtils.defineLazyGetter(
+ FileTestUtils,
+ "_globalTemporaryDirectory",
+ function () {
+ // While previous test runs should have deleted their temporary directories,
+ // on Windows they might still be pending deletion on the physical file
+ // system. This makes a simple nsIFile.createUnique call unreliable, and we
+ // have to use a random number to make a collision unlikely.
+ let randomNumber = Math.floor(Math.random() * 1000000);
+ let dir = new FileUtils.File(
+ PathUtils.join(PathUtils.tempDir, `testdir-${randomNumber}`)
+ );
+ dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ // We need to run this *after* the profile-before-change phase because
+ // otherwise we can race other shutdown blockers who have created files in
+ // our temporary directory. This can cause our shutdown blocker to fail due
+ // to, e.g., JSONFile attempting to flush its contents to disk while we are
+ // trying to delete the file.
+
+ IOUtils.sendTelemetry.addBlocker("Removing test files", async () => {
+ // Remove the files we know about first.
+ for (let path of gPathsToRemove) {
+ await FileTestUtils.tolerantRemove(path);
+ }
+
+ if (!(await IOUtils.exists(dir.path))) {
+ return;
+ }
+
+ // Detect any extra files, like the ".part" files of downloads.
+ for (const child of await IOUtils.getChildren(dir.path)) {
+ await FileTestUtils.tolerantRemove(child);
+ }
+ // This will fail if any test leaves inaccessible files behind.
+ await IOUtils.remove(dir.path, { recursive: false });
+ });
+ return dir;
+ }
+);
diff --git a/testing/modules/MockRegistrar.sys.mjs b/testing/modules/MockRegistrar.sys.mjs
new file mode 100644
index 0000000000..2241c129ca
--- /dev/null
+++ b/testing/modules/MockRegistrar.sys.mjs
@@ -0,0 +1,127 @@
+/* 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/. */
+
+const Cm = Components.manager;
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+var logger = Log.repository.getLogger("MockRegistrar");
+
+export var MockRegistrar = Object.freeze({
+ _registeredComponents: new Map(),
+ _originalCIDs: new Map(),
+ get registrar() {
+ return Cm.QueryInterface(Ci.nsIComponentRegistrar);
+ },
+
+ /**
+ * Register a mock to override target interfaces.
+ * The target interface may be accessed through _genuine property of the mock.
+ * If you register multiple mocks to the same contract ID, you have to call
+ * unregister in reverse order. Otherwise the previous factory will not be
+ * restored.
+ *
+ * @param contractID The contract ID of the interface which is overridden by
+ the mock.
+ * e.g. "@mozilla.org/file/directory_service;1"
+ * @param mock An object which implements interfaces for the contract ID.
+ * @param args An array which is passed in the constructor of mock.
+ *
+ * @return The CID of the mock.
+ */
+ register(contractID, mock, args) {
+ let originalCID;
+ let originalFactory;
+ try {
+ originalCID = this._originalCIDs.get(contractID);
+ if (!originalCID) {
+ originalCID = this.registrar.contractIDToCID(contractID);
+ this._originalCIDs.set(contractID, originalCID);
+ }
+
+ originalFactory = Cm.getClassObject(originalCID, Ci.nsIFactory);
+ } catch (e) {
+ // There's no original factory. Ignore and just register the new
+ // one.
+ }
+
+ let cid = Services.uuid.generateUUID();
+
+ let factory = {
+ createInstance(iid) {
+ let wrappedMock;
+ if (mock.prototype && mock.prototype.constructor) {
+ wrappedMock = Object.create(mock.prototype);
+ mock.apply(wrappedMock, args);
+ } else if (typeof mock == "function") {
+ wrappedMock = mock();
+ } else {
+ wrappedMock = mock;
+ }
+
+ if (originalFactory) {
+ try {
+ let genuine = originalFactory.createInstance(iid);
+ wrappedMock._genuine = genuine;
+ } catch (ex) {
+ logger.info("Creating original instance failed", ex);
+ }
+ }
+
+ return wrappedMock.QueryInterface(iid);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIFactory"]),
+ };
+
+ this.registrar.registerFactory(
+ cid,
+ "A Mock for " + contractID,
+ contractID,
+ factory
+ );
+
+ this._registeredComponents.set(cid, {
+ contractID,
+ factory,
+ originalCID,
+ });
+
+ return cid;
+ },
+
+ /**
+ * Unregister the mock.
+ *
+ * @param cid The CID of the mock.
+ */
+ unregister(cid) {
+ let component = this._registeredComponents.get(cid);
+ if (!component) {
+ return;
+ }
+
+ this.registrar.unregisterFactory(cid, component.factory);
+ if (component.originalCID) {
+ // Passing `null` for the factory re-maps the contract ID to the
+ // entry for its original CID.
+ this.registrar.registerFactory(
+ component.originalCID,
+ "",
+ component.contractID,
+ null
+ );
+ }
+
+ this._registeredComponents.delete(cid);
+ },
+
+ /**
+ * Unregister all registered mocks.
+ */
+ unregisterAll() {
+ for (let cid of this._registeredComponents.keys()) {
+ this.unregister(cid);
+ }
+ },
+});
diff --git a/testing/modules/MockRegistry.sys.mjs b/testing/modules/MockRegistry.sys.mjs
new file mode 100644
index 0000000000..e9351e576d
--- /dev/null
+++ b/testing/modules/MockRegistry.sys.mjs
@@ -0,0 +1,145 @@
+/* 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/. */
+
+import { MockRegistrar } from "resource://testing-common/MockRegistrar.sys.mjs";
+
+export class MockRegistry {
+ constructor() {
+ // Three level structure of Maps pointing to Maps pointing to Maps
+ // this.roots is the top of the structure and has ROOT_KEY_* values
+ // as keys. Maps at the second level are the values of the first
+ // level Map, they have registry keys (also called paths) as keys.
+ // Third level maps are the values in second level maps, they have
+ // map registry names to corresponding values (which in this implementation
+ // are always strings).
+ this.roots = new Map([
+ [Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, new Map()],
+ [Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, new Map()],
+ [Ci.nsIWindowsRegKey.ROOT_KEY_CLASSES_ROOT, new Map()],
+ ]);
+
+ let registry = this;
+
+ /**
+ * This is a mock nsIWindowsRegistry implementation. It only implements a
+ * subset of the interface used in tests. In particular, only values
+ * of type string are supported.
+ */
+ function MockWindowsRegKey() {}
+ MockWindowsRegKey.prototype = {
+ values: null,
+
+ // --- Overridden nsISupports interface functions ---
+ QueryInterface: ChromeUtils.generateQI(["nsIWindowsRegKey"]),
+
+ // --- Overridden nsIWindowsRegKey interface functions ---
+ open(root, path, mode) {
+ let rootKey = registry.getRoot(root);
+ if (!rootKey.has(path)) {
+ rootKey.set(path, new Map());
+ }
+ this.values = rootKey.get(path);
+ },
+
+ close() {
+ this.values = null;
+ },
+
+ get valueCount() {
+ if (!this.values) {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+ return this.values.size;
+ },
+
+ hasValue(name) {
+ if (!this.values) {
+ return false;
+ }
+ return this.values.has(name);
+ },
+
+ getValueType(name) {
+ return Ci.nsIWindowsRegKey.TYPE_STRING;
+ },
+
+ getValueName(index) {
+ if (!this.values || index >= this.values.size) {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+ let names = Array.from(this.values.keys());
+ return names[index];
+ },
+
+ readStringValue(name) {
+ if (!this.values) {
+ throw new Error("invalid registry path");
+ }
+ return this.values.get(name);
+ },
+ };
+
+ // See bug 1688838 - nsNotifyAddrListener::CheckAdaptersAddresses might
+ // attempt to use the registry off the main thread, so we disable that
+ // feature while the mock registry is active.
+ this.oldSuffixListPref = Services.prefs.getBoolPref(
+ "network.notify.dnsSuffixList"
+ );
+ Services.prefs.setBoolPref("network.notify.dnsSuffixList", false);
+
+ this.oldCheckForProxiesPref = Services.prefs.getBoolPref(
+ "network.notify.checkForProxies"
+ );
+ Services.prefs.setBoolPref("network.notify.checkForProxies", false);
+
+ this.oldCheckForNRPTPref = Services.prefs.getBoolPref(
+ "network.notify.checkForNRPT"
+ );
+ Services.prefs.setBoolPref("network.notify.checkForNRPT", false);
+
+ this.cid = MockRegistrar.register(
+ "@mozilla.org/windows-registry-key;1",
+ MockWindowsRegKey
+ );
+ }
+
+ shutdown() {
+ MockRegistrar.unregister(this.cid);
+ Services.prefs.setBoolPref(
+ "network.notify.dnsSuffixList",
+ this.oldSuffixListPref
+ );
+ Services.prefs.setBoolPref(
+ "network.notify.checkForProxies",
+ this.oldCheckForProxiesPref
+ );
+ Services.prefs.setBoolPref(
+ "network.notify.checkForNRPT",
+ this.oldCheckForNRPTPref
+ );
+ this.cid = null;
+ }
+
+ getRoot(root) {
+ if (!this.roots.has(root)) {
+ throw new Error(`No such root ${root}`);
+ }
+ return this.roots.get(root);
+ }
+
+ setValue(root, path, name, value) {
+ let rootKey = this.getRoot(root);
+
+ if (!rootKey.has(path)) {
+ rootKey.set(path, new Map());
+ }
+
+ let pathmap = rootKey.get(path);
+ if (value == null) {
+ pathmap.delete(name);
+ } else {
+ pathmap.set(name, value);
+ }
+ }
+}
diff --git a/testing/modules/Sinon.sys.mjs b/testing/modules/Sinon.sys.mjs
new file mode 100644
index 0000000000..d4edb4bba9
--- /dev/null
+++ b/testing/modules/Sinon.sys.mjs
@@ -0,0 +1,40 @@
+/* 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/. */
+
+// ================================================
+// Load mocking/stubbing library sinon
+// docs: http://sinonjs.org/releases/v7.2.7/
+import {
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setIntervalWithTarget,
+ setTimeout,
+ setTimeoutWithTarget,
+} from "resource://gre/modules/Timer.sys.mjs";
+
+const obj = {
+ global: {
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setIntervalWithTarget,
+ setTimeout,
+ setTimeoutWithTarget,
+ Date,
+ },
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setIntervalWithTarget,
+ setTimeout,
+ setTimeoutWithTarget,
+};
+Services.scriptloader.loadSubScript(
+ "resource://testing-common/sinon-7.2.7.js",
+ obj
+);
+
+export const sinon = obj.global.sinon;
+// ================================================
diff --git a/testing/modules/StructuredLog.sys.mjs b/testing/modules/StructuredLog.sys.mjs
new file mode 100644
index 0000000000..fa569e3409
--- /dev/null
+++ b/testing/modules/StructuredLog.sys.mjs
@@ -0,0 +1,304 @@
+/* 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/. */
+
+/**
+ * TestLogger: Logger class generating messages compliant with the
+ * structured logging protocol for tests exposed by mozlog
+ *
+ * @param {string} name
+ * The name of the logger to instantiate.
+ * @param {function} [dumpFun]
+ * An underlying function to be used to log raw messages. This function
+ * will receive the complete serialized json string to log.
+ * @param {object} [scope]
+ * The scope that the dumpFun is loaded in, so that messages are cloned
+ * into that scope before passing them.
+ */
+export class StructuredLogger {
+ name = null;
+ #dumpFun = null;
+ #dumpScope = null;
+
+ constructor(name, dumpFun = dump, scope = null) {
+ this.name = name;
+ this.#dumpFun = dumpFun;
+ this.#dumpScope = scope;
+ }
+
+ testStart(test) {
+ var data = { test: this.#testId(test) };
+ this.logData("test_start", data);
+ }
+
+ testStatus(
+ test,
+ subtest,
+ status,
+ expected = "PASS",
+ message = null,
+ stack = null,
+ extra = null
+ ) {
+ if (subtest === null || subtest === undefined) {
+ // Fix for assertions that don't pass in a name
+ subtest = "undefined assertion name";
+ }
+
+ var data = {
+ test: this.#testId(test),
+ subtest,
+ status,
+ };
+
+ // handle case: known fail
+ if (expected === status && status != "SKIP") {
+ data.status = "PASS";
+ }
+ if (expected != status && status != "SKIP") {
+ data.expected = expected;
+ }
+ if (message !== null) {
+ data.message = String(message);
+ }
+ if (stack !== null) {
+ data.stack = stack;
+ }
+ if (extra !== null) {
+ data.extra = extra;
+ }
+
+ this.logData("test_status", data);
+ }
+
+ testEnd(
+ test,
+ status,
+ expected = "OK",
+ message = null,
+ stack = null,
+ extra = null
+ ) {
+ var data = { test: this.#testId(test), status };
+
+ // handle case: known fail
+ if (expected === status && status != "SKIP") {
+ data.status = "OK";
+ }
+ if (expected != status && status != "SKIP") {
+ data.expected = expected;
+ }
+ if (message !== null) {
+ data.message = String(message);
+ }
+ if (stack !== null) {
+ data.stack = stack;
+ }
+ if (extra !== null) {
+ data.extra = extra;
+ }
+
+ this.logData("test_end", data);
+ }
+
+ assertionCount(test, count, minExpected = 0, maxExpected = 0) {
+ var data = {
+ test: this.#testId(test),
+ min_expected: minExpected,
+ max_expected: maxExpected,
+ count,
+ };
+
+ this.logData("assertion_count", data);
+ }
+
+ suiteStart(
+ ids,
+ name = null,
+ runinfo = null,
+ versioninfo = null,
+ deviceinfo = null,
+ extra = null
+ ) {
+ Object.keys(ids).map(function (manifest) {
+ ids[manifest] = ids[manifest].map(x => this.#testId(x));
+ }, this);
+ var data = { tests: ids };
+
+ if (name !== null) {
+ data.name = name;
+ }
+
+ if (runinfo !== null) {
+ data.runinfo = runinfo;
+ }
+
+ if (versioninfo !== null) {
+ data.versioninfo = versioninfo;
+ }
+
+ if (deviceinfo !== null) {
+ data.deviceinfo = deviceinfo;
+ }
+
+ if (extra !== null) {
+ data.extra = extra;
+ }
+
+ this.logData("suite_start", data);
+ }
+
+ suiteEnd(extra = null) {
+ var data = {};
+
+ if (extra !== null) {
+ data.extra = extra;
+ }
+
+ this.logData("suite_end", data);
+ }
+
+ /**
+ * Unstructured logging functions. The "extra" parameter can always by used to
+ * log suite specific data. If a "stack" field is provided it is logged at the
+ * top level of the data object for the benefit of mozlog's formatters.
+ */
+ log(level, message, extra = null) {
+ var data = {
+ level,
+ message: String(message),
+ };
+
+ if (extra !== null) {
+ data.extra = extra;
+ if ("stack" in extra) {
+ data.stack = extra.stack;
+ }
+ }
+
+ this.logData("log", data);
+ }
+
+ debug(message, extra = null) {
+ this.log("DEBUG", message, extra);
+ }
+
+ info(message, extra = null) {
+ this.log("INFO", message, extra);
+ }
+
+ warning(message, extra = null) {
+ this.log("WARNING", message, extra);
+ }
+
+ error(message, extra = null) {
+ this.log("ERROR", message, extra);
+ }
+
+ critical(message, extra = null) {
+ this.log("CRITICAL", message, extra);
+ }
+
+ processOutput(thread, message) {
+ this.logData("process_output", {
+ message,
+ thread,
+ });
+ }
+
+ logData(action, data = {}) {
+ var allData = {
+ action,
+ time: Date.now(),
+ thread: null,
+ pid: null,
+ source: this.name,
+ };
+
+ for (var field in data) {
+ allData[field] = data[field];
+ }
+
+ if (this.#dumpScope) {
+ this.#dumpFun(Cu.cloneInto(allData, this.#dumpScope));
+ } else {
+ this.#dumpFun(allData);
+ }
+ }
+
+ #testId(test) {
+ if (Array.isArray(test)) {
+ return test.join(" ");
+ }
+ return test;
+ }
+}
+
+/**
+ * StructuredFormatter: Formatter class turning structured messages
+ * into human-readable messages.
+ */
+export class StructuredFormatter {
+ // The time at which the whole suite of tests started.
+ #suiteStartTime = null;
+
+ #testStartTimes = new Map();
+
+ log(message) {
+ return message.message;
+ }
+
+ suite_start(message) {
+ this.#suiteStartTime = message.time;
+ return "SUITE-START | Running " + message.tests.length + " tests";
+ }
+
+ test_start(message) {
+ this.#testStartTimes.set(message.test, new Date().getTime());
+ return "TEST-START | " + message.test;
+ }
+
+ test_status(message) {
+ var statusInfo =
+ message.test +
+ " | " +
+ message.subtest +
+ (message.message ? " | " + message.message : "");
+ if (message.expected) {
+ return (
+ "TEST-UNEXPECTED-" +
+ message.status +
+ " | " +
+ statusInfo +
+ " - expected: " +
+ message.expected
+ );
+ }
+ return "TEST-" + message.status + " | " + statusInfo;
+ }
+
+ test_end(message) {
+ var startTime = this.#testStartTimes.get(message.test);
+ this.#testStartTimes.delete(message.test);
+ var statusInfo =
+ message.test + (message.message ? " | " + String(message.message) : "");
+ var result;
+ if (message.expected) {
+ result =
+ "TEST-UNEXPECTED-" +
+ message.status +
+ " | " +
+ statusInfo +
+ " - expected: " +
+ message.expected;
+ } else {
+ return "TEST-" + message.status + " | " + statusInfo;
+ }
+ result = result + " | took " + message.time - startTime + "ms";
+ return result;
+ }
+
+ suite_end(message) {
+ return "SUITE-END | took " + message.time - this.#suiteStartTime + "ms";
+ }
+}
diff --git a/testing/modules/TestUtils.sys.mjs b/testing/modules/TestUtils.sys.mjs
new file mode 100644
index 0000000000..51030f5fbc
--- /dev/null
+++ b/testing/modules/TestUtils.sys.mjs
@@ -0,0 +1,381 @@
+/* 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/. */
+
+/**
+ * Contains a limited number of testing functions that are commonly used in a
+ * wide variety of situations, for example waiting for an event loop tick or an
+ * observer notification.
+ *
+ * More complex functions are likely to belong to a separate test-only module.
+ * Examples include Assert.sys.mjs for generic assertions, FileTestUtils.sys.mjs
+ * to work with local files and their contents, and BrowserTestUtils.sys.mjs to
+ * work with browser windows and tabs.
+ *
+ * Individual components also offer testing functions to other components, for
+ * example LoginTestUtils.jsm.
+ */
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+const ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"].getService(
+ Ci.nsIConsoleAPIStorage
+);
+
+/**
+ * TestUtils provides generally useful test utilities.
+ * It can be used from mochitests, browser mochitests and xpcshell tests alike.
+ *
+ * @class
+ */
+export var TestUtils = {
+ executeSoon(callbackFn) {
+ Services.tm.dispatchToMainThread(callbackFn);
+ },
+
+ waitForTick() {
+ return new Promise(resolve => this.executeSoon(resolve));
+ },
+
+ /**
+ * Waits for a console message matching the specified check function to be
+ * observed.
+ *
+ * @param {function} checkFn [optional]
+ * Called with the message as its argument, should return true if the
+ * notification is the expected one, or false if it should be ignored
+ * and listening should continue.
+ *
+ * @note Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next notification, since this is probably a bug in the test.
+ *
+ * @return {Promise}
+ * @resolves The message from the observed notification.
+ */
+ consoleMessageObserved(checkFn) {
+ return new Promise((resolve, reject) => {
+ let removed = false;
+ function observe(message) {
+ try {
+ if (checkFn && !checkFn(message)) {
+ return;
+ }
+ ConsoleAPIStorage.removeLogEventListener(observe);
+ // checkFn could reference objects that need to be destroyed before
+ // the end of the test, so avoid keeping a reference to it after the
+ // promise resolves.
+ checkFn = null;
+ removed = true;
+
+ resolve(message);
+ } catch (ex) {
+ ConsoleAPIStorage.removeLogEventListener(observe);
+ checkFn = null;
+ removed = true;
+ reject(ex);
+ }
+ }
+
+ ConsoleAPIStorage.addLogEventListener(
+ observe,
+ Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
+ );
+
+ TestUtils.promiseTestFinished?.then(() => {
+ if (removed) {
+ return;
+ }
+
+ ConsoleAPIStorage.removeLogEventListener(observe);
+ let text =
+ "Console message observer not removed before the end of test";
+ reject(text);
+ });
+ });
+ },
+
+ /**
+ * Listens for any console messages (logged via console.*) and returns them
+ * when the returned function is called.
+ *
+ * @returns {function}
+ * Returns an async function that when called will wait for a tick, then stop
+ * listening to any more console messages and finally will return the
+ * messages that have been received.
+ */
+ listenForConsoleMessages() {
+ let messages = [];
+ function observe(message) {
+ messages.push(message);
+ }
+
+ ConsoleAPIStorage.addLogEventListener(
+ observe,
+ Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
+ );
+
+ return async () => {
+ await TestUtils.waitForTick();
+ ConsoleAPIStorage.removeLogEventListener(observe);
+ return messages;
+ };
+ },
+
+ /**
+ * Waits for the specified topic to be observed.
+ *
+ * @param {string} topic
+ * The topic to observe.
+ * @param {function} checkFn [optional]
+ * Called with (subject, data) as arguments, should return true if the
+ * notification is the expected one, or false if it should be ignored
+ * and listening should continue. If not specified, the first
+ * notification for the specified topic resolves the returned promise.
+ *
+ * @note Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next notification, since this is probably a bug in the test.
+ *
+ * @return {Promise}
+ * @resolves The array [subject, data] from the observed notification.
+ */
+ topicObserved(topic, checkFn) {
+ let startTime = Cu.now();
+ return new Promise((resolve, reject) => {
+ let removed = false;
+ function observer(subject, topic, data) {
+ try {
+ if (checkFn && !checkFn(subject, data)) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ // checkFn could reference objects that need to be destroyed before
+ // the end of the test, so avoid keeping a reference to it after the
+ // promise resolves.
+ checkFn = null;
+ removed = true;
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ "topicObserved: " + topic
+ );
+ resolve([subject, data]);
+ } catch (ex) {
+ Services.obs.removeObserver(observer, topic);
+ checkFn = null;
+ removed = true;
+ reject(ex);
+ }
+ }
+ Services.obs.addObserver(observer, topic);
+
+ TestUtils.promiseTestFinished?.then(() => {
+ if (removed) {
+ return;
+ }
+
+ Services.obs.removeObserver(observer, topic);
+ let text = topic + " observer not removed before the end of test";
+ reject(text);
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ "topicObserved: " + text
+ );
+ });
+ });
+ },
+
+ /**
+ * Waits for the specified preference to be change.
+ *
+ * @param {string} prefName
+ * The pref to observe.
+ * @param {function} checkFn [optional]
+ * Called with the new preference value as argument, should return true if the
+ * notification is the expected one, or false if it should be ignored
+ * and listening should continue. If not specified, the first
+ * notification for the specified topic resolves the returned promise.
+ *
+ * @note Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next notification, since this is probably a bug in the test.
+ *
+ * @return {Promise}
+ * @resolves The value of the preference.
+ */
+ waitForPrefChange(prefName, checkFn) {
+ return new Promise((resolve, reject) => {
+ Services.prefs.addObserver(
+ prefName,
+ function observer(subject, topic, data) {
+ try {
+ let prefValue = null;
+ switch (Services.prefs.getPrefType(prefName)) {
+ case Services.prefs.PREF_STRING:
+ prefValue = Services.prefs.getStringPref(prefName);
+ break;
+ case Services.prefs.PREF_INT:
+ prefValue = Services.prefs.getIntPref(prefName);
+ break;
+ case Services.prefs.PREF_BOOL:
+ prefValue = Services.prefs.getBoolPref(prefName);
+ break;
+ }
+ if (checkFn && !checkFn(prefValue)) {
+ return;
+ }
+ Services.prefs.removeObserver(prefName, observer);
+ resolve(prefValue);
+ } catch (ex) {
+ Services.prefs.removeObserver(prefName, observer);
+ reject(ex);
+ }
+ }
+ );
+ });
+ },
+
+ /**
+ * Takes a screenshot of an area and returns it as a data URL.
+ *
+ * @param eltOrRect {Element|Rect}
+ * The DOM node or rect ({left, top, width, height}) to screenshot.
+ * @param win {Window}
+ * The current window.
+ */
+ screenshotArea(eltOrRect, win) {
+ if (Element.isInstance(eltOrRect)) {
+ eltOrRect = eltOrRect.getBoundingClientRect();
+ }
+ let { left, top, width, height } = eltOrRect;
+ let canvas = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ let ctx = canvas.getContext("2d");
+ let ratio = win.devicePixelRatio;
+ canvas.width = width * ratio;
+ canvas.height = height * ratio;
+ ctx.scale(ratio, ratio);
+ ctx.drawWindow(win, left, top, width, height, "#fff");
+ return canvas.toDataURL();
+ },
+
+ /**
+ * Will poll a condition function until it returns true.
+ *
+ * @param condition
+ * A condition function that must return true or false. If the
+ * condition ever throws, this function fails and rejects the
+ * returned promise. The function can be an async function.
+ * @param msg
+ * A message used to describe the condition being waited for.
+ * This message will be used to reject the promise should the
+ * wait fail. It is also used to add a profiler marker.
+ * @param interval
+ * The time interval to poll the condition function. Defaults
+ * to 100ms.
+ * @param maxTries
+ * The number of times to poll before giving up and rejecting
+ * if the condition has not yet returned true. Defaults to 50
+ * (~5 seconds for 100ms intervals)
+ * @return Promise
+ * Resolves with the return value of the condition function.
+ * Rejects if timeout is exceeded or condition ever throws.
+ *
+ * NOTE: This is intentionally not using setInterval, using setTimeout
+ * instead. setInterval is not promise-safe.
+ */
+ waitForCondition(condition, msg, interval = 100, maxTries = 50) {
+ let startTime = Cu.now();
+ return new Promise((resolve, reject) => {
+ let tries = 0;
+ let timeoutId = 0;
+ async function tryOnce() {
+ timeoutId = 0;
+ if (tries >= maxTries) {
+ msg += ` - timed out after ${maxTries} tries.`;
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ `waitForCondition - ${msg}`
+ );
+ condition = null;
+ reject(msg);
+ return;
+ }
+
+ let conditionPassed = false;
+ try {
+ conditionPassed = await condition();
+ } catch (e) {
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ `waitForCondition - ${msg}`
+ );
+ msg += ` - threw exception: ${e}`;
+ condition = null;
+ reject(msg);
+ return;
+ }
+
+ if (conditionPassed) {
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ `waitForCondition succeeded after ${tries} retries - ${msg}`
+ );
+ // Avoid keeping a reference to the condition function after the
+ // promise resolves, as this function could itself reference objects
+ // that should be GC'ed before the end of the test.
+ condition = null;
+ resolve(conditionPassed);
+ return;
+ }
+ tries++;
+ timeoutId = setTimeout(tryOnce, interval);
+ }
+
+ TestUtils.promiseTestFinished?.then(() => {
+ if (!timeoutId) {
+ return;
+ }
+
+ clearTimeout(timeoutId);
+ msg += " - still pending at the end of the test";
+ ChromeUtils.addProfilerMarker(
+ "TestUtils",
+ { startTime, category: "Test" },
+ `waitForCondition - ${msg}`
+ );
+ reject("waitForCondition timer - " + msg);
+ });
+
+ TestUtils.executeSoon(tryOnce);
+ });
+ },
+
+ shuffle(array) {
+ let results = [];
+ for (let i = 0; i < array.length; ++i) {
+ let randomIndex = Math.floor(Math.random() * (i + 1));
+ results[i] = results[randomIndex];
+ results[randomIndex] = array[i];
+ }
+ return results;
+ },
+
+ assertPackagedBuild() {
+ const omniJa = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
+ omniJa.append("omni.ja");
+ if (!omniJa.exists()) {
+ throw new Error(
+ "This test requires a packaged build, " +
+ "run 'mach package' and then use --appname=dist"
+ );
+ }
+ },
+};
diff --git a/testing/modules/XPCShellContentUtils.sys.mjs b/testing/modules/XPCShellContentUtils.sys.mjs
new file mode 100644
index 0000000000..ccd6a912e7
--- /dev/null
+++ b/testing/modules/XPCShellContentUtils.sys.mjs
@@ -0,0 +1,490 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+// Windowless browsers can create documents that rely on XUL Custom Elements:
+ChromeUtils.importESModule(
+ "resource://gre/modules/CustomElementsListener.sys.mjs"
+);
+
+// Need to import ActorManagerParent.sys.mjs so that the actors are initialized
+// before running extension XPCShell tests.
+ChromeUtils.importESModule("resource://gre/modules/ActorManagerParent.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContentTask: "resource://testing-common/ContentTask.sys.mjs",
+ SpecialPowersParent: "resource://testing-common/SpecialPowersParent.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ HttpServer: "resource://testing-common/httpd.js",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ proxyService: [
+ "@mozilla.org/network/protocol-proxy-service;1",
+ "nsIProtocolProxyService",
+ ],
+});
+
+const { promiseDocumentLoaded, promiseEvent, promiseObserved } = ExtensionUtils;
+
+var gRemoteContentScripts = Services.appinfo.browserTabsRemoteAutostart;
+const REMOTE_CONTENT_SUBFRAMES = Services.appinfo.fissionAutostart;
+
+function frameScript() {
+ // We need to make sure that the ExtensionPolicy service has been initialized
+ // as it sets up the observers that inject extension content scripts.
+ Cc["@mozilla.org/addons/policy-service;1"].getService();
+
+ Services.obs.notifyObservers(this, "tab-content-frameloader-created");
+
+ // eslint-disable-next-line mozilla/balanced-listeners, no-undef
+ addEventListener(
+ "MozHeapMinimize",
+ () => {
+ Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize");
+ },
+ true,
+ true
+ );
+}
+
+let kungFuDeathGrip = new Set();
+function promiseBrowserLoaded(browser, url, redirectUrl) {
+ url = url && Services.io.newURI(url);
+ redirectUrl = redirectUrl && Services.io.newURI(redirectUrl);
+
+ return new Promise(resolve => {
+ const listener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsISupportsWeakReference",
+ "nsIWebProgressListener",
+ ]),
+
+ onStateChange(webProgress, request, stateFlags, statusCode) {
+ request.QueryInterface(Ci.nsIChannel);
+
+ let requestURI =
+ request.originalURI ||
+ webProgress.DOMWindow.document.documentURIObject;
+ if (
+ webProgress.isTopLevel &&
+ (url?.equals(requestURI) || redirectUrl?.equals(requestURI)) &&
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP
+ ) {
+ resolve();
+ kungFuDeathGrip.delete(listener);
+ browser.removeProgressListener(listener);
+ }
+ },
+ };
+
+ // addProgressListener only supports weak references, so we need to
+ // use one. But we also need to make sure it stays alive until we're
+ // done with it, so thunk away a strong reference to keep it alive.
+ kungFuDeathGrip.add(listener);
+ browser.addProgressListener(
+ listener,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW
+ );
+ });
+}
+
+class ContentPage {
+ constructor(
+ remote = gRemoteContentScripts,
+ remoteSubframes = REMOTE_CONTENT_SUBFRAMES,
+ extension = null,
+ privateBrowsing = false,
+ userContextId = undefined
+ ) {
+ this.remote = remote;
+
+ // If an extension has been passed, overwrite remote
+ // with extension.remote to be sure that the ContentPage
+ // will have the same remoteness of the extension.
+ if (extension) {
+ this.remote = extension.remote;
+ }
+
+ this.remoteSubframes = this.remote && remoteSubframes;
+ this.extension = extension;
+ this.privateBrowsing = privateBrowsing;
+ this.userContextId = userContextId;
+
+ this.browserReady = this._initBrowser();
+ }
+
+ async _initBrowser() {
+ let chromeFlags = 0;
+ if (this.remote) {
+ chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_REMOTE_WINDOW;
+ }
+ if (this.remoteSubframes) {
+ chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_FISSION_WINDOW;
+ }
+ if (this.privateBrowsing) {
+ chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW;
+ }
+ this.windowlessBrowser = Services.appShell.createWindowlessBrowser(
+ true,
+ chromeFlags
+ );
+
+ let system = Services.scriptSecurityManager.getSystemPrincipal();
+
+ let chromeShell = this.windowlessBrowser.docShell.QueryInterface(
+ Ci.nsIWebNavigation
+ );
+
+ chromeShell.createAboutBlankContentViewer(system, system);
+ this.windowlessBrowser.browsingContext.useGlobalHistory = false;
+ let loadURIOptions = {
+ triggeringPrincipal: system,
+ };
+ chromeShell.loadURI(
+ Services.io.newURI("chrome://extensions/content/dummy.xhtml"),
+ loadURIOptions
+ );
+
+ await promiseObserved(
+ "chrome-document-global-created",
+ win => win.document == chromeShell.document
+ );
+
+ let chromeDoc = await promiseDocumentLoaded(chromeShell.document);
+
+ let { SpecialPowers } = chromeDoc.ownerGlobal;
+ SpecialPowers.xpcshellScope = XPCShellContentUtils.currentScope;
+ SpecialPowers.setAsDefaultAssertHandler();
+
+ let browser = chromeDoc.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ browser.setAttribute("nodefaultsrc", "true");
+ if (this.userContextId) {
+ browser.setAttribute("usercontextid", this.userContextId);
+ }
+
+ if (this.extension?.remote) {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", "extension");
+ }
+
+ // Ensure that the extension is loaded into the correct
+ // BrowsingContextGroupID by default.
+ if (this.extension) {
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ this.extension.browsingContextGroupId
+ );
+ }
+
+ let awaitFrameLoader = Promise.resolve();
+ if (this.remote) {
+ awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
+ browser.setAttribute("remote", "true");
+
+ browser.setAttribute("maychangeremoteness", "true");
+ browser.addEventListener(
+ "DidChangeBrowserRemoteness",
+ this.didChangeBrowserRemoteness.bind(this)
+ );
+ }
+
+ chromeDoc.documentElement.appendChild(browser);
+
+ // Forcibly flush layout so that we get a pres shell soon enough, see
+ // bug 1274775.
+ browser.getBoundingClientRect();
+
+ await awaitFrameLoader;
+
+ this.browser = browser;
+
+ this.loadFrameScript(frameScript);
+
+ return browser;
+ }
+
+ get browsingContext() {
+ return this.browser.browsingContext;
+ }
+
+ get SpecialPowers() {
+ return this.browser.ownerGlobal.SpecialPowers;
+ }
+
+ loadFrameScript(func) {
+ let frameScript = `data:text/javascript,(${encodeURI(func)}).call(this)`;
+ this.browser.messageManager.loadFrameScript(frameScript, true, true);
+ }
+
+ addFrameScriptHelper(func) {
+ let frameScript = `data:text/javascript,${encodeURI(func)}`;
+ this.browser.messageManager.loadFrameScript(frameScript, false, true);
+ }
+
+ didChangeBrowserRemoteness(event) {
+ // XXX: Tests can load their own additional frame scripts, so we may need to
+ // track all scripts that have been loaded, and reload them here?
+ this.loadFrameScript(frameScript);
+ }
+
+ async loadURL(url, redirectUrl = undefined) {
+ await this.browserReady;
+
+ this.browser.fixupAndLoadURIString(url, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ return promiseBrowserLoaded(this.browser, url, redirectUrl);
+ }
+
+ async fetch(...args) {
+ return this.spawn(args, async (url, options) => {
+ let resp = await this.content.fetch(url, options);
+ return resp.text();
+ });
+ }
+
+ spawn(params, task) {
+ return this.SpecialPowers.spawn(this.browser, params, task);
+ }
+
+ // Like spawn(), but uses the legacy ContentTask infrastructure rather than
+ // SpecialPowers. Exists only because the author of the SpecialPowers
+ // migration did not have the time to fix all of the legacy users who relied
+ // on the old semantics.
+ //
+ // DO NOT USE IN NEW CODE
+ legacySpawn(params, task) {
+ lazy.ContentTask.setTestScope(XPCShellContentUtils.currentScope);
+
+ return lazy.ContentTask.spawn(this.browser, params, task);
+ }
+
+ async close() {
+ await this.browserReady;
+
+ let { messageManager } = this.browser;
+
+ this.browser.removeEventListener(
+ "DidChangeBrowserRemoteness",
+ this.didChangeBrowserRemoteness.bind(this)
+ );
+ this.browser = null;
+
+ this.windowlessBrowser.close();
+ this.windowlessBrowser = null;
+
+ await lazy.TestUtils.topicObserved(
+ "message-manager-disconnect",
+ subject => subject === messageManager
+ );
+ }
+}
+
+export var XPCShellContentUtils = {
+ currentScope: null,
+ fetchScopes: new Map(),
+
+ initCommon(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,//", true, true);
+
+ scope.registerCleanupFunction(() => {
+ this.currentScope = null;
+
+ return Promise.all(
+ Array.from(this.fetchScopes.values(), promise =>
+ promise.then(scope => scope.close())
+ )
+ );
+ });
+ },
+
+ init(scope) {
+ // QuotaManager crashes if it doesn't have a profile.
+ scope.do_get_profile();
+
+ this.initCommon(scope);
+
+ lazy.SpecialPowersParent.registerActor();
+ },
+
+ initMochitest(scope) {
+ this.initCommon(scope);
+ },
+
+ ensureInitialized(scope) {
+ if (!this.currentScope) {
+ if (scope.do_get_profile) {
+ this.init(scope);
+ } else {
+ this.initMochitest(scope);
+ }
+ }
+ },
+
+ /**
+ * Creates a new HttpServer for testing, and begins listening on the
+ * specified port. Automatically shuts down the server when the test
+ * unit ends.
+ *
+ * @param {object} [options = {}]
+ * The options object.
+ * @param {integer} [options.port = -1]
+ * The port to listen on. If omitted, listen on a random
+ * port. The latter is the preferred behavior.
+ * @param {sequence<string>?} [options.hosts = null]
+ * A set of hosts to accept connections to. Support for this is
+ * implemented using a proxy filter.
+ *
+ * @returns {HttpServer}
+ * The HTTP server instance.
+ */
+ createHttpServer({ port = -1, hosts } = {}) {
+ let server = new lazy.HttpServer();
+ server.start(port);
+
+ if (hosts) {
+ const hostsSet = new Set();
+ const serverHost = "localhost";
+ const serverPort = server.identity.primaryPort;
+
+ for (let host of hosts) {
+ if (host.startsWith("[") && host.endsWith("]")) {
+ // HttpServer expects IPv6 addresses in bracket notation, but the
+ // proxy filter uses nsIURI.host, which does not have brackets.
+ hostsSet.add(host.slice(1, -1));
+ } else {
+ hostsSet.add(host);
+ }
+ server.identity.add("http", host, 80);
+ }
+
+ const proxyFilter = {
+ proxyInfo: lazy.proxyService.newProxyInfo(
+ "http",
+ serverHost,
+ serverPort,
+ "",
+ "",
+ 0,
+ 4096,
+ null
+ ),
+
+ applyFilter(channel, defaultProxyInfo, callback) {
+ if (hostsSet.has(channel.URI.host)) {
+ callback.onProxyFilterResult(this.proxyInfo);
+ } else {
+ callback.onProxyFilterResult(defaultProxyInfo);
+ }
+ },
+ };
+
+ lazy.proxyService.registerChannelFilter(proxyFilter, 0);
+ this.currentScope.registerCleanupFunction(() => {
+ lazy.proxyService.unregisterChannelFilter(proxyFilter);
+ });
+ }
+
+ this.currentScope.registerCleanupFunction(() => {
+ return new Promise(resolve => {
+ server.stop(resolve);
+ });
+ });
+
+ return server;
+ },
+
+ registerJSON(server, path, obj) {
+ server.registerPathHandler(path, (request, response) => {
+ response.setHeader("content-type", "application/json", true);
+ response.write(JSON.stringify(obj));
+ });
+ },
+
+ get remoteContentScripts() {
+ return gRemoteContentScripts;
+ },
+
+ set remoteContentScripts(val) {
+ gRemoteContentScripts = !!val;
+ },
+
+ async fetch(origin, url, options) {
+ let fetchScopePromise = this.fetchScopes.get(origin);
+ if (!fetchScopePromise) {
+ fetchScopePromise = this.loadContentPage(origin);
+ this.fetchScopes.set(origin, fetchScopePromise);
+ }
+
+ let fetchScope = await fetchScopePromise;
+ return fetchScope.fetch(url, options);
+ },
+
+ /**
+ * Loads a content page into a hidden docShell.
+ *
+ * @param {string} url
+ * The URL to load.
+ * @param {object} [options = {}]
+ * @param {ExtensionWrapper} [options.extension]
+ * If passed, load the URL as an extension page for the given
+ * extension.
+ * @param {boolean} [options.remote]
+ * If true, load the URL in a content process. If false, load
+ * it in the parent process.
+ * @param {boolean} [options.remoteSubframes]
+ * If true, load cross-origin frames in separate content processes.
+ * This is ignored if |options.remote| is false.
+ * @param {string} [options.redirectUrl]
+ * An optional URL that the initial page is expected to
+ * redirect to.
+ *
+ * @returns {ContentPage}
+ */
+ loadContentPage(
+ url,
+ {
+ extension = undefined,
+ remote = undefined,
+ remoteSubframes = undefined,
+ redirectUrl = undefined,
+ privateBrowsing = false,
+ userContextId = undefined,
+ } = {}
+ ) {
+ let contentPage = new ContentPage(
+ remote,
+ remoteSubframes,
+ extension && extension.extension,
+ privateBrowsing,
+ userContextId
+ );
+
+ return contentPage.loadURL(url, redirectUrl).then(() => {
+ return contentPage;
+ });
+ },
+};
diff --git a/testing/modules/moz.build b/testing/modules/moz.build
new file mode 100644
index 0000000000..0261c5a5ce
--- /dev/null
+++ b/testing/modules/moz.build
@@ -0,0 +1,39 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.ini"]
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
+
+TESTING_JS_MODULES += [
+ "AppData.sys.mjs",
+ "AppInfo.sys.mjs",
+ "Assert.sys.mjs",
+ "CoverageUtils.sys.mjs",
+ "FileTestUtils.sys.mjs",
+ "MockRegistrar.sys.mjs",
+ "sinon-7.2.7.js",
+ "Sinon.sys.mjs",
+ "StructuredLog.sys.mjs",
+ "TestUtils.sys.mjs",
+ "XPCShellContentUtils.sys.mjs",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ TESTING_JS_MODULES += [
+ "MockRegistry.sys.mjs",
+ ]
+
+
+TEST_HARNESS_FILES.testing.mochitest.tests.SimpleTest += ["StructuredLog.sys.mjs"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Testing", "General")
+
+with Files("tests/xpcshell/**"):
+ BUG_COMPONENT = ("Testing", "XPCShell Harness")
+
+with Files("tests/browser/**"):
+ BUG_COMPONENT = ("Testing", "General")
diff --git a/testing/modules/tests/browser/browser.ini b/testing/modules/tests/browser/browser.ini
new file mode 100644
index 0000000000..4dcdddffc3
--- /dev/null
+++ b/testing/modules/tests/browser/browser.ini
@@ -0,0 +1 @@
+[browser_test_assert.js]
diff --git a/testing/modules/tests/browser/browser_test_assert.js b/testing/modules/tests/browser/browser_test_assert.js
new file mode 100644
index 0000000000..ea0823e60d
--- /dev/null
+++ b/testing/modules/tests/browser/browser_test_assert.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function test() {
+ export_assertions();
+
+ ok(true, "pass ok");
+ is(true, true, "pass is");
+ isnot(false, true, "pass isnot");
+ todo(false, "pass todo");
+ todo_is(false, true, "pass todo_is");
+ todo_isnot(true, true, "pass todo_isnot");
+ info("info message");
+
+ var func = is;
+ func(1, 1, "pass indirect is");
+
+ stringMatches("hello world", /llo\s/);
+ stringMatches("hello world", "llo\\s");
+ stringContains("hello world", "llo");
+}
diff --git a/testing/modules/tests/xpcshell/test_assert.js b/testing/modules/tests/xpcshell/test_assert.js
new file mode 100644
index 0000000000..0168bedd7a
--- /dev/null
+++ b/testing/modules/tests/xpcshell/test_assert.js
@@ -0,0 +1,470 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test cases borrowed and adapted from:
+// https://github.com/joyent/node/blob/6101eb184db77d0b11eb96e48744e57ecce4b73d/test/simple/test-assert.js
+// MIT license: http://opensource.org/licenses/MIT
+
+var { Assert } = ChromeUtils.importESModule(
+ "resource://testing-common/Assert.sys.mjs"
+);
+
+function run_test() {
+ let assert = new Assert();
+
+ function makeBlock(f, ...args) {
+ return function () {
+ return f.apply(assert, args);
+ };
+ }
+
+ function protoCtrChain(o) {
+ let result = [];
+ while ((o = o.__proto__)) {
+ result.push(o.constructor);
+ }
+ return result.join();
+ }
+
+ function indirectInstanceOf(obj, cls) {
+ if (obj instanceof cls) {
+ return true;
+ }
+ let clsChain = protoCtrChain(cls.prototype);
+ let objChain = protoCtrChain(obj);
+ return objChain.slice(-clsChain.length) === clsChain;
+ }
+
+ assert.ok(
+ indirectInstanceOf(Assert.AssertionError.prototype, Error),
+ "Assert.AssertionError instanceof Error"
+ );
+
+ assert.throws(
+ makeBlock(assert.ok, false),
+ Assert.AssertionError,
+ "ok(false)"
+ );
+
+ assert.ok(true, "ok(true)");
+
+ assert.ok("test", "ok('test')");
+
+ assert.throws(
+ makeBlock(assert.equal, true, false),
+ Assert.AssertionError,
+ "equal"
+ );
+
+ assert.equal(null, null, "equal");
+
+ assert.equal(undefined, undefined, "equal");
+
+ assert.equal(null, undefined, "equal");
+
+ assert.equal(true, true, "equal");
+
+ assert.notEqual(true, false, "notEqual");
+
+ assert.throws(
+ makeBlock(assert.notEqual, true, true),
+ Assert.AssertionError,
+ "notEqual"
+ );
+
+ assert.throws(
+ makeBlock(assert.strictEqual, 2, "2"),
+ Assert.AssertionError,
+ "strictEqual"
+ );
+
+ assert.throws(
+ makeBlock(assert.strictEqual, null, undefined),
+ Assert.AssertionError,
+ "strictEqual"
+ );
+
+ assert.notStrictEqual(2, "2", "notStrictEqual");
+
+ // deepEquals joy!
+ // 7.2
+ assert.deepEqual(
+ new Date(2000, 3, 14),
+ new Date(2000, 3, 14),
+ "deepEqual date"
+ );
+ assert.deepEqual(new Date(NaN), new Date(NaN), "deepEqual invalid dates");
+
+ assert.throws(
+ makeBlock(assert.deepEqual, new Date(), new Date(2000, 3, 14)),
+ Assert.AssertionError,
+ "deepEqual date"
+ );
+
+ // 7.3
+ assert.deepEqual(/a/, /a/);
+ assert.deepEqual(/a/g, /a/g);
+ assert.deepEqual(/a/i, /a/i);
+ assert.deepEqual(/a/m, /a/m);
+ assert.deepEqual(/a/gim, /a/gim);
+ assert.throws(makeBlock(assert.deepEqual, /ab/, /a/), Assert.AssertionError);
+ assert.throws(makeBlock(assert.deepEqual, /a/g, /a/), Assert.AssertionError);
+ assert.throws(makeBlock(assert.deepEqual, /a/i, /a/), Assert.AssertionError);
+ assert.throws(makeBlock(assert.deepEqual, /a/m, /a/), Assert.AssertionError);
+ assert.throws(
+ makeBlock(assert.deepEqual, /a/gim, /a/im),
+ Assert.AssertionError
+ );
+
+ let re1 = /a/;
+ re1.lastIndex = 3;
+ assert.throws(makeBlock(assert.deepEqual, re1, /a/), Assert.AssertionError);
+
+ // 7.4
+ assert.deepEqual(4, "4", "deepEqual == check");
+ assert.deepEqual(true, 1, "deepEqual == check");
+ assert.throws(
+ makeBlock(assert.deepEqual, 4, "5"),
+ Assert.AssertionError,
+ "deepEqual == check"
+ );
+
+ // 7.5
+ // having the same number of owned properties && the same set of keys
+ assert.deepEqual({ a: 4 }, { a: 4 });
+ assert.deepEqual({ a: 4, b: "2" }, { a: 4, b: "2" });
+ assert.deepEqual([4], ["4"]);
+ assert.throws(
+ makeBlock(assert.deepEqual, { a: 4 }, { a: 4, b: true }),
+ Assert.AssertionError
+ );
+ assert.deepEqual(["a"], { 0: "a" });
+
+ let a1 = [1, 2, 3];
+ let a2 = [1, 2, 3];
+ a1.a = "test";
+ a1.b = true;
+ a2.b = true;
+ a2.a = "test";
+ assert.throws(
+ makeBlock(assert.deepEqual, Object.keys(a1), Object.keys(a2)),
+ Assert.AssertionError
+ );
+ assert.deepEqual(a1, a2);
+
+ let nbRoot = {
+ toString() {
+ return this.first + " " + this.last;
+ },
+ };
+
+ function nameBuilder(first, last) {
+ this.first = first;
+ this.last = last;
+ return this;
+ }
+ nameBuilder.prototype = nbRoot;
+
+ function nameBuilder2(first, last) {
+ this.first = first;
+ this.last = last;
+ return this;
+ }
+ nameBuilder2.prototype = nbRoot;
+
+ let nb1 = new nameBuilder("Ryan", "Dahl");
+ let nb2 = new nameBuilder2("Ryan", "Dahl");
+
+ assert.deepEqual(nb1, nb2);
+
+ nameBuilder2.prototype = Object;
+ nb2 = new nameBuilder2("Ryan", "Dahl");
+ assert.throws(makeBlock(assert.deepEqual, nb1, nb2), Assert.AssertionError);
+
+ // String literal + object
+ assert.throws(makeBlock(assert.deepEqual, "a", {}), Assert.AssertionError);
+
+ // Testing the throwing
+ function thrower(errorConstructor) {
+ throw new errorConstructor("test");
+ }
+ makeBlock(thrower, Assert.AssertionError);
+ makeBlock(thrower, Assert.AssertionError);
+
+ // the basic calls work
+ assert.throws(
+ makeBlock(thrower, Assert.AssertionError),
+ Assert.AssertionError,
+ "message"
+ );
+ assert.throws(
+ makeBlock(thrower, Assert.AssertionError),
+ Assert.AssertionError
+ );
+ assert.throws(
+ makeBlock(thrower, Assert.AssertionError),
+ Assert.AssertionError
+ );
+
+ // if not passing an error, catch all.
+ assert.throws(makeBlock(thrower, TypeError), TypeError);
+
+ // when passing a type, only catch errors of the appropriate type
+ let threw = false;
+ try {
+ assert.throws(makeBlock(thrower, TypeError), Assert.AssertionError);
+ } catch (e) {
+ threw = true;
+ assert.ok(e instanceof TypeError, "type");
+ }
+ assert.equal(
+ true,
+ threw,
+ "Assert.throws with an explicit error is eating extra errors",
+ Assert.AssertionError
+ );
+ threw = false;
+
+ function ifError(err) {
+ if (err) {
+ throw err;
+ }
+ }
+ assert.throws(function () {
+ ifError(new Error("test error"));
+ }, /test error/);
+
+ // make sure that validating using constructor really works
+ threw = false;
+ try {
+ assert.throws(function () {
+ throw new Error({});
+ }, Array);
+ } catch (e) {
+ threw = true;
+ }
+ assert.ok(threw, "wrong constructor validation");
+
+ // use a RegExp to validate error message
+ assert.throws(makeBlock(thrower, TypeError), /test/);
+
+ // use a fn to validate error object
+ assert.throws(makeBlock(thrower, TypeError), function (err) {
+ if (err instanceof TypeError && /test/.test(err)) {
+ return true;
+ }
+ return false;
+ });
+ // do the same with an arrow function
+ assert.throws(makeBlock(thrower, TypeError), err => {
+ if (err instanceof TypeError && /test/.test(err)) {
+ return true;
+ }
+ return false;
+ });
+
+ function testAssertionMessage(actual, expected) {
+ try {
+ assert.equal(actual, "");
+ } catch (e) {
+ assert.equal(
+ e.toString(),
+ ["AssertionError:", expected, "==", '""'].join(" ")
+ );
+ }
+ }
+ testAssertionMessage(undefined, '"undefined"');
+ testAssertionMessage(null, "null");
+ testAssertionMessage(true, "true");
+ testAssertionMessage(false, "false");
+ testAssertionMessage(0, "0");
+ testAssertionMessage(100, "100");
+ testAssertionMessage(NaN, '"NaN"');
+ testAssertionMessage(Infinity, '"Infinity"');
+ testAssertionMessage(-Infinity, '"-Infinity"');
+ testAssertionMessage("", '""');
+ testAssertionMessage("foo", '"foo"');
+ testAssertionMessage([], "[]");
+ testAssertionMessage([1, 2, 3], "[1,2,3]");
+ testAssertionMessage(/a/, '"/a/"');
+ testAssertionMessage(/abc/gim, '"/abc/gim"');
+ testAssertionMessage(function f() {}, '"function f() {}"');
+ testAssertionMessage({}, "{}");
+ testAssertionMessage({ a: undefined, b: null }, '{"a":"undefined","b":null}');
+ testAssertionMessage(
+ { a: NaN, b: Infinity, c: -Infinity },
+ '{"a":"NaN","b":"Infinity","c":"-Infinity"}'
+ );
+
+ // https://github.com/joyent/node/issues/2893
+ try {
+ assert.throws(function () {
+ ifError(null);
+ });
+ } catch (e) {
+ threw = true;
+ assert.equal(
+ e.message,
+ "Error: The 'expected' argument was not supplied to Assert.throws() - false == true"
+ );
+ }
+ assert.ok(threw);
+
+ // https://github.com/joyent/node/issues/5292
+ try {
+ assert.equal(1, 2);
+ } catch (e) {
+ assert.equal(e.toString().split("\n")[0], "AssertionError: 1 == 2");
+ }
+
+ try {
+ assert.equal(1, 2, "oh no");
+ } catch (e) {
+ assert.equal(e.toString().split("\n")[0], "AssertionError: oh no - 1 == 2");
+ }
+
+ // Need to JSON.stringify so that their length is > 128 characters.
+ let longArray0 = Array.from(Array(50), (v, i) => i);
+ let longArray1 = longArray0.concat([51]);
+ try {
+ assert.deepEqual(longArray0, longArray1);
+ } catch (e) {
+ let message = e.toString();
+ // Just check that they're both entirely present in the message
+ assert.ok(message.includes(JSON.stringify(longArray0)));
+ assert.ok(message.includes(JSON.stringify(longArray1)));
+ }
+
+ // Test XPCShell-test integration:
+ ok(true, "OK, this went well");
+ deepEqual(/a/g, /a/g, "deep equal should work on RegExp");
+ deepEqual(/a/gim, /a/gim, "deep equal should work on RegExp");
+ deepEqual(
+ { a: 4, b: "1" },
+ { b: "1", a: 4 },
+ "deep equal should work on regular Object"
+ );
+ deepEqual(a1, a2, "deep equal should work on Array with Object properties");
+
+ // Test robustness of reporting:
+ equal(
+ new Assert.AssertionError({
+ actual: {
+ toJSON() {
+ throw new Error("bam!");
+ },
+ },
+ expected: "foo",
+ operator: "=",
+ }).message,
+ '[object Object] = "foo"'
+ );
+
+ let message;
+ assert.greater(3, 2);
+ try {
+ assert.greater(2, 2);
+ } catch (e) {
+ message = e.toString().split("\n")[0];
+ }
+ assert.equal(message, "AssertionError: 2 > 2");
+
+ assert.greaterOrEqual(2, 2);
+ try {
+ assert.greaterOrEqual(1, 2);
+ } catch (e) {
+ message = e.toString().split("\n")[0];
+ }
+ assert.equal(message, "AssertionError: 1 >= 2");
+
+ assert.less(1, 2);
+ try {
+ assert.less(2, 2);
+ } catch (e) {
+ message = e.toString().split("\n")[0];
+ }
+ assert.equal(message, "AssertionError: 2 < 2");
+
+ assert.lessOrEqual(2, 2);
+ try {
+ assert.lessOrEqual(2, 1);
+ } catch (e) {
+ message = e.toString().split("\n")[0];
+ }
+ assert.equal(message, "AssertionError: 2 <= 1");
+
+ try {
+ assert.greater(NaN, 0);
+ } catch (e) {
+ message = e.toString().split("\n")[0];
+ }
+ assert.equal(message, "AssertionError: 'NaN' is not a number");
+
+ try {
+ assert.greater(0, NaN);
+ } catch (e) {
+ message = e.toString().split("\n")[0];
+ }
+ assert.equal(message, "AssertionError: 'NaN' is not a number");
+
+ /* ---- stringMatches ---- */
+ assert.stringMatches("hello world", /llo\s/);
+ assert.stringMatches("hello world", "llo\\s");
+ assert.throws(
+ () => assert.stringMatches("hello world", /foo/),
+ /^AssertionError: "hello world" matches "\/foo\/"/
+ );
+ assert.throws(
+ () => assert.stringMatches(5, /foo/),
+ /^AssertionError: Expected a string for lhs, but "5" isn't a string./
+ );
+ assert.throws(
+ () => assert.stringMatches("foo bar", "+"),
+ /^AssertionError: Expected a valid regular expression for rhs, but "\+" isn't one./
+ );
+
+ /* ---- stringContains ---- */
+ assert.stringContains("hello world", "llo");
+ assert.throws(
+ () => assert.stringContains(5, "foo"),
+ /^AssertionError: Expected a string for both lhs and rhs, but either "5" or "foo" is not a string./
+ );
+
+ run_next_test();
+}
+
+add_task(async function test_rejects() {
+ let assert = new Assert();
+
+ // A helper function to test failures.
+ async function checkRejectsFails(err, expected) {
+ try {
+ await assert.rejects(Promise.reject(err), expected);
+ ok(false, "should have thrown");
+ } catch (ex) {
+ deepEqual(ex, err, "Assert.rejects threw the original unexpected error");
+ }
+ }
+
+ // A "throwable" error that's not an actual Error().
+ let SomeErrorLikeThing = function () {};
+
+ // The actual tests...
+
+ // An explicit error object:
+ // An instance to check against.
+ await assert.rejects(Promise.reject(new Error("oh no")), Error, "rejected");
+ // A regex to match against the message.
+ await assert.rejects(Promise.reject(new Error("oh no")), /oh no/, "rejected");
+
+ // Failure cases:
+ // An instance to check against that doesn't match.
+ await checkRejectsFails(new Error("something else"), SomeErrorLikeThing);
+ // A regex that doesn't match.
+ await checkRejectsFails(new Error("something else"), /oh no/);
+
+ // Check simple string messages.
+ await assert.rejects(Promise.reject("oh no"), /oh no/, "rejected");
+ // Wrong message.
+ await checkRejectsFails("something else", /oh no/);
+});
diff --git a/testing/modules/tests/xpcshell/test_mockRegistrar.js b/testing/modules/tests/xpcshell/test_mockRegistrar.js
new file mode 100644
index 0000000000..de4224a092
--- /dev/null
+++ b/testing/modules/tests/xpcshell/test_mockRegistrar.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+function platformInfo(injectedValue) {
+ this.platformVersion = injectedValue;
+}
+
+platformInfo.prototype = {
+ platformVersion: "some version",
+ platformBuildID: "some id",
+ QueryInterface: ChromeUtils.generateQI(["nsIPlatformInfo"]),
+};
+
+add_test(function test_register() {
+ let localPlatformInfo = {
+ platformVersion: "local version",
+ platformBuildID: "local id",
+ QueryInterface: ChromeUtils.generateQI(["nsIPlatformInfo"]),
+ };
+
+ MockRegistrar.register("@mozilla.org/xre/app-info;1", localPlatformInfo);
+ Assert.equal(
+ Cc["@mozilla.org/xre/app-info;1"].createInstance(Ci.nsIPlatformInfo)
+ .platformVersion,
+ "local version"
+ );
+ run_next_test();
+});
+
+add_test(function test_register_with_arguments() {
+ MockRegistrar.register("@mozilla.org/xre/app-info;1", platformInfo, [
+ "override",
+ ]);
+ Assert.equal(
+ Cc["@mozilla.org/xre/app-info;1"].createInstance(Ci.nsIPlatformInfo)
+ .platformVersion,
+ "override"
+ );
+ run_next_test();
+});
+
+add_test(function test_register_twice() {
+ MockRegistrar.register("@mozilla.org/xre/app-info;1", platformInfo, [
+ "override",
+ ]);
+ Assert.equal(
+ Cc["@mozilla.org/xre/app-info;1"].createInstance(Ci.nsIPlatformInfo)
+ .platformVersion,
+ "override"
+ );
+
+ MockRegistrar.register("@mozilla.org/xre/app-info;1", platformInfo, [
+ "override again",
+ ]);
+ Assert.equal(
+ Cc["@mozilla.org/xre/app-info;1"].createInstance(Ci.nsIPlatformInfo)
+ .platformVersion,
+ "override again"
+ );
+ run_next_test();
+});
diff --git a/testing/modules/tests/xpcshell/test_structuredlog.js b/testing/modules/tests/xpcshell/test_structuredlog.js
new file mode 100644
index 0000000000..5288bbcbb3
--- /dev/null
+++ b/testing/modules/tests/xpcshell/test_structuredlog.js
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+ const { StructuredLogger } = ChromeUtils.importESModule(
+ "resource://testing-common/StructuredLog.sys.mjs"
+ );
+
+ let testBuffer = [];
+
+ let appendBuffer = function (msg) {
+ testBuffer.push(JSON.stringify(msg));
+ };
+
+ let assertLastMsg = function (refData) {
+ // Check all fields in refData agree with those in the
+ // last message logged, and pop that message.
+ let lastMsg = JSON.parse(testBuffer.pop());
+ for (let field in refData) {
+ deepEqual(lastMsg[field], refData[field]);
+ }
+ // The logger should always set the source to the logger name.
+ equal(lastMsg.source, "test_log");
+ };
+
+ let logger = new StructuredLogger("test_log", appendBuffer);
+
+ // Test unstructured logging
+ logger.info("Test message");
+ assertLastMsg({
+ action: "log",
+ message: "Test message",
+ level: "INFO",
+ });
+
+ logger.info("Test message", { foo: "bar" });
+ assertLastMsg({
+ action: "log",
+ message: "Test message",
+ level: "INFO",
+ extra: { foo: "bar" },
+ });
+
+ // Test end / start actions
+ logger.testStart("aTest");
+ assertLastMsg({
+ test: "aTest",
+ action: "test_start",
+ });
+
+ logger.testEnd("aTest", "OK");
+ assertLastMsg({
+ test: "aTest",
+ action: "test_end",
+ status: "OK",
+ });
+
+ // A failed test populates the "expected" field.
+ logger.testStart("aTest");
+ logger.testEnd("aTest", "FAIL", "PASS");
+ assertLastMsg({
+ action: "test_end",
+ test: "aTest",
+ status: "FAIL",
+ expected: "PASS",
+ });
+
+ // A failed test populates the "expected" field.
+ logger.testStart("aTest");
+ logger.testEnd("aTest", "FAIL", "PASS", null, "Many\nlines\nof\nstack\n");
+ assertLastMsg({
+ action: "test_end",
+ test: "aTest",
+ status: "FAIL",
+ expected: "PASS",
+ stack: "Many\nlines\nof\nstack\n",
+ });
+
+ // Skipped tests don't log failures
+ logger.testStart("aTest");
+ logger.testEnd("aTest", "SKIP", "PASS");
+ ok(!JSON.parse(testBuffer[testBuffer.length - 1]).hasOwnProperty("expected"));
+ assertLastMsg({
+ action: "test_end",
+ test: "aTest",
+ status: "SKIP",
+ });
+
+ logger.testStatus("aTest", "foo", "PASS", "PASS", "Passed test");
+ ok(!JSON.parse(testBuffer[testBuffer.length - 1]).hasOwnProperty("expected"));
+ assertLastMsg({
+ action: "test_status",
+ test: "aTest",
+ subtest: "foo",
+ status: "PASS",
+ message: "Passed test",
+ });
+
+ logger.testStatus("aTest", "bar", "FAIL");
+ assertLastMsg({
+ action: "test_status",
+ test: "aTest",
+ subtest: "bar",
+ status: "FAIL",
+ expected: "PASS",
+ });
+
+ logger.testStatus(
+ "aTest",
+ "bar",
+ "FAIL",
+ "PASS",
+ null,
+ "Many\nlines\nof\nstack\n"
+ );
+ assertLastMsg({
+ action: "test_status",
+ test: "aTest",
+ subtest: "bar",
+ status: "FAIL",
+ expected: "PASS",
+ stack: "Many\nlines\nof\nstack\n",
+ });
+
+ // Skipped tests don't log failures
+ logger.testStatus("aTest", "baz", "SKIP");
+ ok(!JSON.parse(testBuffer[testBuffer.length - 1]).hasOwnProperty("expected"));
+ assertLastMsg({
+ action: "test_status",
+ test: "aTest",
+ subtest: "baz",
+ status: "SKIP",
+ });
+
+ // Suite start and end messages.
+ var ids = { aManifest: ["aTest"] };
+ logger.suiteStart(ids);
+ assertLastMsg({
+ action: "suite_start",
+ tests: { aManifest: ["aTest"] },
+ });
+
+ logger.suiteEnd();
+ assertLastMsg({
+ action: "suite_end",
+ });
+}
diff --git a/testing/modules/tests/xpcshell/xpcshell.ini b/testing/modules/tests/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..9401dc3abf
--- /dev/null
+++ b/testing/modules/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head =
+
+[test_assert.js]
+[test_mockRegistrar.js]
+[test_structuredlog.js]