diff --git a/testing/mochitest/browser-test.js b/testing/mochitest/browser-test.js index ef29179988bb..bc43710df78e 100644 --- a/testing/mochitest/browser-test.js +++ b/testing/mochitest/browser-test.js @@ -1936,3 +1936,11 @@ testScope.prototype = { } }, }; + +/* import-globals-from ../modules/Mochia.js */ +Services.scriptloader.loadSubScript( + "resource://testing-common/Mochia.js", + this +); + +Mochia(testScope); diff --git a/testing/mochitest/tests/SimpleTest/SimpleTest.js b/testing/mochitest/tests/SimpleTest/SimpleTest.js index 94760a888e0a..e02aff12a1a4 100644 --- a/testing/mochitest/tests/SimpleTest/SimpleTest.js +++ b/testing/mochitest/tests/SimpleTest/SimpleTest.js @@ -2262,3 +2262,11 @@ addEventListener("message", async event => { SimpleTest.finish(); } }); + +/* import-globals-from ../../../modules/Mochia.js */ +SpecialPowers.Services.scriptloader.loadSubScript( + "resource://testing-common/Mochia.js", + this +); + +Mochia(this); diff --git a/testing/modules/Mochia.js b/testing/modules/Mochia.js new file mode 100644 index 000000000000..2c3e06f5f3bf --- /dev/null +++ b/testing/modules/Mochia.js @@ -0,0 +1,259 @@ +/* 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/. */ + +/** + * Define Mochia's helpers on the given scope. + * + * @param {object} scope + * The `globalThis` of the running test, where `add_task` is defined. + */ +const Mochia = (function () { + /** + * The context of each test suite. + */ + class Context { + static #stack = []; + + static current() { + return Context.#stack.at(-1); + } + + static push(ctx) { + Context.#stack.push(ctx); + } + + static pop() { + Context.#stack.pop(); + } + + constructor() { + this.description = []; + this.beforeEach = []; + this.afterEach = []; + } + + clone() { + const newCtx = new Context(); + newCtx.description.push(...this.description); + newCtx.beforeEach.push(...this.beforeEach); + newCtx.afterEach.push(...this.afterEach); + return newCtx; + } + } + + Context.push(new Context()); + + let _testScope = null; + + /** + * @typedef {void|Promise} MaybePromise + * + * Either undefined or a Promise that resolves to undefined. + */ + + const MochiaImpl = { + /** + * Describe a new test suite, which is a scoped environment for running setup + * and teardown. + * + * @param {string} desc + * A description of the test suite. + * + * @param {function(): MaybePromise} suite + * The test suite + */ + async describe(desc, suite) { + const ctx = Context.current().clone(); + ctx.description.push(desc); + + Context.push(ctx); + + const p = suite(); + if (p?.then) { + await p; + } + + Context.pop(); + }, + + /** + * Register a setup funciton to run before each test. + * + * Multiple functions can be registered with `beforeEach` and they will be run + * in order before each test in the suite and the suites nested inside of it. + * + * @param {function(): MaybePromise} setupFn + * The setup function. If this function returns a `Promise` it will be + * awaited. + */ + beforeEach(setupFn) { + Context.current().beforeEach.push(setupFn); + }, + + /** + * Register a tear down function to run at the end of each test. + * + * Multiple functions can be registered with `afterEach` and they will run in + * reverse order after each test in the suite and the suites nested inside of it. + * + * @param {function(): MaybePromise} tearDownFn + * The tear down function. If this function returns a `Promise` it will + * be awaited. + */ + afterEach(tearDownFn) { + Context.current().afterEach.push(tearDownFn); + }, + + /** + * Register a test function. + * + * The test will be registered via `add_task`. Each setup function registered + * before this function call will be called in order before the actual test + * and each teardown function before this function will be called in reverse + * order after the actual test. + * + * @param {string} desc + * A description of the test. This is logged at the start of the test. + * + * @param {function(): MaybePromise} testFn + * The test function. If this function returns a `Promise` it will be + * awaited. + * + * @returns {any} + * The result of calling `add_task` with the wrapped function. + */ + it(desc, testFn) { + return _testScope.add_task(MochiaImpl.wrap(desc, testFn)); + }, + + /** + * Register a test that will be the only test run. + * + * @param {string} desc + * A description of the test. This is logged at the start of the test. + * + * @param {function(): MaybePromise} testFn + * The test function. If this function returns a `Promise` it will be + * awaited. + */ + only(desc, testFn) { + MochiaImpl.it(desc, testFn).only(); + }, + + /** + * Register a test that will be skipped. + * + * @param {string} desc + * A description of the test. This is logged at the start of the test. + * + * @param {function(): MaybePromise} testFn + * The test function. If this function returns a `Promise` it will be + * awaited. + */ + skip(desc, testFn) { + MochiaImpl.it(desc, testFn).skip(); + }, + + /** + * Register a test that will be skipped if the provided predicate evaluates to + * a truthy value. + * + * @param {string} desc + * A description of the test. This is logged at the start of the test. + * + * @param {function(): boolean} skipFn + * A predicate that will be called by the test harness to determine + * whether or not the test should be skipped. + * + * @param {function(): MaybePromise} testFn + * The test function. If this function returns a `Promise` it will be + * awaited. + * + * @returns {any} + * The result of calling `add_task` with the wrapped function. + */ + skipIf(desc, skipFn, testFn) { + return _testScope.add_task( + { skip_if: skipFn }, + MochiaImpl.wrap(desc, testFn) + ); + }, + + /** + * Wrap `fn` so that all the setup functions declared with `beforeEach` are + * called before it and all the teardown functions declared with `afterEach` + * are called after. + * + * @param {string} desc + * A description of the test. + * + * @param {function(): MaybePromise} fn + * The function to wrap. + * + * @returns {function(): Promise} + * The wrapped function. + */ + wrap(desc, fn) { + const ctx = Context.current().clone(); + const name = [...ctx.description, desc].join(" / "); + + // This is a hack to give the function an implicit name. + const wrapper = { + [name]: async () => { + _testScope.info(name); + + for (const before of ctx.beforeEach) { + const p = before(); + if (p?.then) { + await p; + } + } + + { + const p = fn(); + if (p?.then) { + await p; + } + } + + for (let i = ctx.afterEach.length - 1; i >= 0; i--) { + const after = ctx.afterEach[i]; + const p = after(); + if (p?.then) { + await p; + } + } + }, + }; + + return wrapper[name]; + }, + }; + + Object.defineProperties(MochiaImpl.it, { + only: { + configurable: false, + value: MochiaImpl.only, + }, + skip: { + configurable: false, + value: MochiaImpl.skip, + }, + skipIf: { + configurable: false, + get: MochiaImpl.skipIf, + }, + }); + + return function (scope) { + _testScope = scope; + + Object.assign(_testScope, { + describe: MochiaImpl.describe, + beforeEach: MochiaImpl.beforeEach, + afterEach: MochiaImpl.afterEach, + it: MochiaImpl.it, + }); + }; +})(); diff --git a/testing/modules/moz.build b/testing/modules/moz.build index f078152ce35b..214d29664dac 100644 --- a/testing/modules/moz.build +++ b/testing/modules/moz.build @@ -13,6 +13,7 @@ TESTING_JS_MODULES += [ "Assert.sys.mjs", "CoverageUtils.sys.mjs", "FileTestUtils.sys.mjs", + "Mochia.js", "MockRegistrar.sys.mjs", "sinon-7.2.7.js", "Sinon.sys.mjs", diff --git a/tools/lint/eslint/eslint-plugin-mozilla/lib/configs/browser-test.js b/tools/lint/eslint/eslint-plugin-mozilla/lib/configs/browser-test.js index ed97134505a2..c8e48231793e 100644 --- a/tools/lint/eslint/eslint-plugin-mozilla/lib/configs/browser-test.js +++ b/tools/lint/eslint/eslint-plugin-mozilla/lib/configs/browser-test.js @@ -24,7 +24,10 @@ module.exports = { addLoadEvent: false, add_setup: false, add_task: false, + afterEach: false, + beforeEach: false, content: false, + describe: false, executeSoon: false, expectUncaughtException: false, export_assertions: false, @@ -40,6 +43,7 @@ module.exports = { info: false, is: false, isnot: false, + it: false, ok: false, record: false, registerCleanupFunction: false, diff --git a/tools/lint/eslint/eslint-plugin-mozilla/lib/configs/chrome-test.js b/tools/lint/eslint/eslint-plugin-mozilla/lib/configs/chrome-test.js index e8fbbb3c0a7d..9d615e4aef60 100644 --- a/tools/lint/eslint/eslint-plugin-mozilla/lib/configs/chrome-test.js +++ b/tools/lint/eslint/eslint-plugin-mozilla/lib/configs/chrome-test.js @@ -11,11 +11,15 @@ module.exports = { globals: { // SpecialPowers is injected into the window object via SimpleTest.js SpecialPowers: false, + afterEach: false, + beforeEach: false, + describe: false, extractJarToTmp: false, getChromeDir: false, getJar: false, getResolvedURI: false, getRootDirectory: false, + it: false, }, name: "mozilla/chrome-test",