diff options
Diffstat (limited to 'testing/modules')
-rw-r--r-- | testing/modules/AppData.sys.mjs | 88 | ||||
-rw-r--r-- | testing/modules/AppInfo.sys.mjs | 139 | ||||
-rw-r--r-- | testing/modules/Assert.sys.mjs | 695 | ||||
-rw-r--r-- | testing/modules/CoverageUtils.sys.mjs | 210 | ||||
-rw-r--r-- | testing/modules/FileTestUtils.sys.mjs | 127 | ||||
-rw-r--r-- | testing/modules/MockRegistrar.sys.mjs | 127 | ||||
-rw-r--r-- | testing/modules/MockRegistry.sys.mjs | 145 | ||||
-rw-r--r-- | testing/modules/Sinon.sys.mjs | 40 | ||||
-rw-r--r-- | testing/modules/StructuredLog.sys.mjs | 304 | ||||
-rw-r--r-- | testing/modules/TestUtils.sys.mjs | 381 | ||||
-rw-r--r-- | testing/modules/XPCShellContentUtils.sys.mjs | 490 | ||||
-rw-r--r-- | testing/modules/moz.build | 39 | ||||
-rw-r--r-- | testing/modules/tests/browser/browser.ini | 1 | ||||
-rw-r--r-- | testing/modules/tests/browser/browser_test_assert.js | 21 | ||||
-rw-r--r-- | testing/modules/tests/xpcshell/test_assert.js | 470 | ||||
-rw-r--r-- | testing/modules/tests/xpcshell/test_mockRegistrar.js | 65 | ||||
-rw-r--r-- | testing/modules/tests/xpcshell/test_structuredlog.js | 147 | ||||
-rw-r--r-- | testing/modules/tests/xpcshell/xpcshell.ini | 6 |
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] |