// ==UserScript==
// ==UserLibrary==
// @name NH_xunit
// @description xUnit style testing.
// @version 1
// @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html
// @homepageURL https://github.com/nexushoratio/userscripts
// @supportURL https://github.com/nexushoratio/userscripts/issues
// @match https://www.example.com/*
// ==/UserLibrary==
// ==/UserScript==
window.NexusHoratio ??= {};
window.NexusHoratio.xunit = (function base() {
'use strict';
/** @type {number} - Bumped per release. */
const version = 1;
/**
* @type {object} - For testing support (to be replaced with `TestCase`).
*/
const testing = {
enabled: false,
funcs: [],
testCases: [],
};
/**
* An xUnit style test framework.
*
* TODO(#172): WIP.
*
* Many expected methods exist, such as setUp, setUpClass, addCleanup,
* addClassCleanup, etc. No tearDown methods, however; use addCleanup.
*
* Generally, register the class with a test runner that will do them all in
* turn. One approach is to use a static initializer block at the top of
* the class.
*/
class TestCase {
/** Instantiate a TestCase. */
constructor() {
if (new.target === TestCase) {
throw new TypeError('Abstract class; do not instantiate directly.');
}
}
/** Called once before any instances are created. */
static setUpClass() {
// Empty.
}
/**
* Register a function with arguments to run after all tests in the class
* have ran.
* @param {function} func - Function to call.
* @param {...*} rest - Arbitrary arguments to func.
*/
static addClassCleanup(func, ...rest) {
this.classCleanups.push([func, rest]);
}
/** Execute all functions registered with addClassCleanup. */
static doClassCleanups() {
while (this.classCleanups.length) {
const [func, rest] = this.classCleanups.pop();
// TODO: decide how resistant these should be to exceptions
func(...rest);
}
}
static Skip = class extends Error {}
/** Called once before each test method. */
setUp() { // eslint-disable-line class-methods-use-this
// Empty.
}
/**
* Register a function with arguments to run after a test.
* @param {function} func - Function to call.
* @param {...*} rest - Arbitrary arguments to func.
*/
addCleanup(func, ...rest) {
this.#cleanups.push([func, rest]);
}
// TODO: Add assertions as needed.
static classCleanups = [];
#cleanups = [];
}
/** Test TestCase. */
class TestTestCase extends TestCase {
}
testing.testCases.push(TestTestCase);
/** Accumulated results from running a TestCase. */
class TestResult {
/**
* Record a successful execution.
* @param {string} name - Name of the TestCase.testMethod.
*/
addSuccess(name) {
this.successes.push(name);
}
/**
* Record an unexpected exception from a execution.
* @param {string} name - Name of the TestCase.testMethod.
* @param {Error} exception - Exception caught.
*/
addError(name, exception) {
this.errors.push({
name: name,
error: exception.name,
message: exception.message,
});
}
/**
* Record a test failure.
* @param {string} name - Name of the TestCase.testMethod.
* @param {string} message - Message from the test or framework.
*/
addFailure(name, message) {
this.failures.push({
name: name,
message: message,
});
}
/**
* Record a test skipped.
* @param {string} name - Name of the TestCase.testMethod.
* @param {string} message - Reason the test was skipped.
*/
addSkip(name, message) {
this.skipped.push({
name: name,
message: message,
});
}
/** Successes. */
successes = [];
/** Unexpected exceptions. */
errors = [];
/** Explicit test failures (failed asserts). */
failures = [];
/** Skipped tests. */
skipped = [];
}
/** Assembles and drives execution of {@link TestCase}s. */
class TestRunner {
/** @param {function(): TestCase} tests - TestCases to execute. */
constructor(tests) {
const badKlasses = [];
const testMethods = [];
for (const klass of tests) {
if (klass.prototype instanceof TestCase) {
testMethods.push(...this.#extractTestMethods(klass));
} else {
badKlasses.push(klass);
}
}
if (badKlasses.length) {
const msg = `Bad class count: ${badKlasses.length}`;
for (const klass of badKlasses) {
// eslint-disable-next-line no-console
console.error('Not a TestCase:', klass);
}
throw new Error(`Bad classes: ${msg}`);
}
this.#tests = testMethods;
}
/**
* Run each test method in turn.
* @returns {TestResult} - Collected results.
*/
runTests() {
const result = new TestResult();
let lastKlass = null;
for (const {klass} of this.#tests) {
if (klass !== lastKlass) {
this.#doClassCleanUps(lastKlass, result);
this.#doSetUpClass(klass, result);
}
lastKlass = klass;
}
// TODO: instantiate and run test method here
this.#doClassCleanUps(lastKlass, result);
return result;
}
#tests
/** @param {function(): TestCase} klass - TestCase to process. */
#extractTestMethods = function *extractTestMethods(klass) {
let obj = klass;
while (obj) {
if (obj.prototype instanceof TestCase) {
for (const prop of Object.getOwnPropertyNames(obj.prototype)) {
if (prop.startsWith('test')) {
yield {klass: klass, method: prop};
}
}
}
obj = Object.getPrototypeOf(obj);
}
}
/**
* @param {TestCase} klass - TestCase to process.
* @param {TestResult} result - Result to use if any errors.
*/
#doClassCleanUps = (klass, result) => {
if (klass) {
const name = `${klass.name}.doClassCleanups`;
try {
klass.doClassCleanups();
} catch (e) {
result.addError(name, e);
}
}
}
/**
* @param {TestCase} klass - TestCase to process.
* @param {TestResult} result - Result to use if any errors.
*/
#doSetUpClass = (klass, result) => {
const name = `${klass.name}.setUpClass`;
try {
klass.setUpClass();
} catch (e) {
if (e instanceof TestCase.Skip) {
result.addSkip(name, e.message);
} else {
result.addError(name, e);
}
}
}
}
/** TestRunner TestCase. */
class RunnerTestCase extends TestCase {
}
testing.testCases.push(RunnerTestCase);
/**
* Run registered TestCases.
* @returns {TestResult} - Accumulated results of these tests.
*/
function runTests() {
const runner = new TestRunner(testing.testCases);
return runner.runTests();
}
return {
version: version,
testing: testing,
TestCase: TestCase,
runTests: runTests,
};
}());