@moyal/js-test
Version:
A lightweight, dependency-free JavaScript testing utility. This project is framework-agnostic and usable in both browser and Node.js environments.
1,620 lines (1,440 loc) • 71.9 kB
JavaScript
let SimpleLogger$1;
let BrowserLogger$1;
let NodeLogger$1;
/**
* Base class for logger.
*
* @abstract
*/
class LoggerBase {
/** @type {LoggerBase} */
static #_defaultLogger = null;
/**
*
* @param {LoggerBase} simpleLogger
* @param {LoggerBase} browserLogger
* @param {LoggerBase} nodeLogger
* @ignore
*/
static __setup(simpleLogger, browserLogger, nodeLogger) {
SimpleLogger$1 = simpleLogger;
BrowserLogger$1 = browserLogger;
NodeLogger$1 = nodeLogger;
}
/**
* Detects the current environment and returns the appropriate console printer adapter.
* @returns {LoggerBase}
*/
static getDefaultLogger() {
if (this.#_defaultLogger === null) {
try {
if ((typeof window !== "undefined" && typeof window.document !== "undefined"/* Browser */)
||
(typeof importScripts === "function" && typeof self !== "undefined" /* Web worker */)) {
this.#_defaultLogger = new BrowserLogger$1();
}
else if (typeof process !== "undefined" && process.versions?.node) {
this.#_defaultLogger = new NodeLogger$1();
}
else {
this.#_defaultLogger = new SimpleLogger$1();
}
} catch {
this.#_defaultLogger = new SimpleLogger$1();
}
}
return this.#_defaultLogger;
}
/**
* Checks if obj is an instance of a subclass of LoggerBase.
* @param {any} logger - Object to test.
* @returns {boolean} True if obj is derived from LoggerBase.
*/
static isLogger(logger) {
return logger instanceof LoggerBase && Object.getPrototypeOf(logger.constructor) === LoggerBase;
}
/**
* Support only colors that supported on all systems.
*/
#_supportedColors = new Set([
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray",
"lightred", "lightgreen", "lightyellow", "lightblue", "lightmagenta", "lightcyan", "lightgray"
]);
/**
* Returns true if the specified color is supported by the logger.
* @param {string} color - The color.
* @returns {boolean} - true if the specified color is supported by the logger; otherwise, flase.
*/
isSupportedColor(color) {
return this.#_supportedColors.has(color);
}
/**
* Normalizes the specified color name, or return an empty string if it is not supported.
*
* @param {string} color - The color.
* @returns {string} - The normalized color.
*/
normalizeColor(color) {
return typeof color === "string" ? color.toLowerCase() : "";
}
/**
* Logs a message.
* @param {string} message - The message.
* @param {string} [color] - The color to be used.
* @param {...any} args - Additional arguments.
* @returns {this} - The current instance for chaining.
* @abstract
*/
log(message, color, ...args){ /* eslint-disable-line no-unused-vars */ }
/**
* Logs information message.
* @param {string} message - The message.
* @param {string} [color] - The color to be used.
* @param {...any} args - Additional arguments.
* @returns {this} The current instance for chaining.
* @abstract
*/
info(message, color, ...args){ /* eslint-disable-line no-unused-vars */ }
/**
* Logs warning message.
* @param {string} message - The message.
* @param {string} [color] - The color to be used.
* @param {...any} args - Additional arguments.
* @returns {this} The current instance for chaining.
* @abstract
*/
warn(message, color, ...args){ /* eslint-disable-line no-unused-vars */ }
/**
* Logs error message.
* @param {string} message - The message.
* @param {string} [color] - The color to be used.
* @param {...any} args - Additional arguments.
* @returns {this} The current instance for chaining.
* @abstract
*/
error(message, color, ...args){ /* eslint-disable-line no-unused-vars */ }
/**
* Starts grouped output.
* @param {string} label - The group label.
* @param {string} [color] - The color to be used.
* @returns {this} The current instance for chaining.
* @abstract
*/
group(label, color){ /* eslint-disable-line no-unused-vars */ }
/**
* Starts grouped output (collapsed by default).
* @param {string} label - The group label.
* @param {string} [color] - The color to be used.
* @returns {this} The current instance for chaining.
* @abstract
*/
groupCollapsed(label, color){ /* eslint-disable-line no-unused-vars */ }
/**
* Ends group output.
* @returns {this} The current instance for chaining.
* @abstract
*/
groupEnd(){}
}
/*!
* File: src/utilClasses/_InternalUtils.js
*/
/**
* A set of internal utility functions for type checking.
* @ignore
*/
class InternalUtils {
static #_now;
static {
// Safe timer that always exists
this.#_now = (typeof performance !== "undefined" && typeof performance.now === "function")
? () => performance.now() : () => Date.now();
}
/**
*
* @returns {Number}
* @ignore
*/
static now() {
return this.#_now();
}
/**
* Checks if an object is a string.
* @param {*} obj - The object to test.
* @returns {boolean} True if the object is a string.
* @ignore
*/
static isString(obj) { return typeof obj === "string" || Object.prototype.toString.call(obj) === "[object String]"; }
/**
* Checks if an object is iterable (i.e., supports Symbol.iterator).
* @param {*} obj - The object to check.
* @returns {boolean} True if it's iterable.
* @ignore
*/
static isIterable(obj) { return InternalUtils.isFunctionOrGeneratorFunction(obj?.[Symbol.iterator]); }
/**
* Checks if an object is a function.
* @param {*} obj - The object to check.
* @returns {boolean} True if the object is a function.
* @ignore
*/
static isFunction(obj) {
let too = typeof obj;
return (too === "object" || too === "function") && Object.prototype.toString.call(obj) === "[object Function]";
}
/**
* Checks if an object is either a normal function or a generator function.
* @param {*} obj - The object to check.
* @returns {boolean} True if the object is a function or generator function.
* @ignore
*/
static isFunctionOrGeneratorFunction(obj) {
const typeOfObj = typeof obj;
if (typeOfObj !== "function" && typeOfObj !== "object") return false;
const tag = Object.prototype.toString.call(obj);
return tag === "[object Function]" || tag === "[object GeneratorFunction]";
}
}
/**
* File: src/utilClasses/SequentialText.js
*/
/**
* @class SequentialText
*
* A utility class that generates a sequence of formatted strings like `"1"`, `"2"`, etc., using a
* text template such as `"{0}"` or `"Step {0}"`.
*
* Supports resetting and iteration with `for...of`.
*
* Example:
* ```js
* const st = new SequentialText("Item {0}", 1);
* st.next(); // "Item 1"
* st.next(); // "Item 2"
* ```
*/
class SequentialText {
/**
* Generator that produces an infinite sequence of formatted strings using a number.
* Example: "{0}" → "1", "2", "3", ...
* @param {string} textFormat - A string template, e.g., "{0}" or "Step {0}".
* @param {number} startValue - The initial numeric value.
* @yields {string} Formatted strings.
*/
static *#_sequentialTextGen(textFormat, startValue) {
while (true) {
yield textFormat.replace("{0}", startValue++);
} }
#_textFormat = null;
#_startValue = null;
#_gen = null;
/**
* Constructs a sequential text generator instance.
* @param {string} textFormat - The format string, default is "{0}".
* @param {number} startValue - The starting number, default is 1.
*/
constructor(textFormat, startValue) {
if (startValue != null && (!Number.isInteger(startValue) || startValue < 1)) {
throw new Error("startValue must be a positive integer");
}
this.#_textFormat = textFormat ?? "{0}";
this.#_startValue = startValue ?? 1;
}
/**
* Resets the generator state so iteration starts over from startValue.
*/
reset() {
this.#_gen = SequentialText.#_sequentialTextGen(this.#_textFormat, this.#_startValue);
}
/**
* Returns the next generated formatted string.
*
* @returns {string}
*/
next() {
if (this.#_gen == null)
this.reset();
return this.#_gen.next().value;
}
/**
* Closes the generator and cleans up internal state.
*/
close() {
if (this.#_gen?.return)
this.#_gen.return();
this.#_gen = null;
}
/**
* Enables iteration using for...of syntax on the class.
* Each call to the iterator returns a fresh generator.
*/
*[Symbol.iterator]() {
let gen = SequentialText.#_sequentialTextGen(this.#_textFormat, this.#_startValue);
for (let item of gen) {
yield item;
}
}
}
/**
* File: src/utilClasses/AutoNumbering.js
*/
/**
* @class AutoNumbering
*
* Extends {@link SequentialText} to support formatted auto-numbered items like `"1. Step A"`.
*
* Useful for numbering tests, documentation sections, or steps in a procedure.
*
* Example:
* ```js
* const an = new AutoNumbering();
* an.next("Initialize DB"); // "1. Initialize DB"
* an.next("Check Schema"); // "2. Check Schema"
* ```
*/
class AutoNumbering extends SequentialText {
/**
* Constructs an auto-numbering generator that prefixes a number to each item.
*
* This is a convenience wrapper around `SequentialText` for cases where you want
* numbered outputs like "1. Item A", "2. Item B", etc.
*
* @param {number} [startValue=1] - Starting number for the sequence.
* @param {string} [numberingTextFormat="{0}. "] - Format for the numeric prefix.
* The string must contain "{0}" as a placeholder.
*/
constructor(startValue = 1, numberingTextFormat = "{0}. ") {
numberingTextFormat ??= "{0}. ";
if(!InternalUtils.isString(numberingTextFormat) || numberingTextFormat.indexOf("{0}") < 0)
throw new Error("Automatic numbering format must include {0}");
super(numberingTextFormat, startValue ?? 1);
}
/**
* Generates the next string in the sequence by prefixing a number to the given text.
*
* This method calls the base `next()` to get the current number
* and appends the optional string after it.
*
* @param {string} [text=""] - Optional content to append after the number.
* @returns {string} Numbered string like "1. Hello"
*/
next(text) { return super.next() + (text ?? ""); }
}
/**
* File: src/utilClasses/MultiLevelAutoNumbering.js
*/
/**
* @class MultiLevelAutoNumbering
*
* A hierarchical auto-numbering utility supporting nested sequences like:
* ```
* 1.
* 1.1.
* 1.2.
* 2.
* 2.1.1.
* ```
*
* Internally uses a stack of {@link AutoNumbering} instances, one for each level.
* Supports `nest()` to go deeper and `unnest()` to go back.
*
* Example:
* ```js
* const ml = new MultiLevelAutoNumbering();
* ml.next("Root A"); // "1. Root A"
* ml.nest().next("Child A"); // "1.1. Child A"
* ml.next("Child B"); // "1.2. Child B"
* ml.unnest().next("Root B"); // "2. Root B"
* ml.next("Root C"); // "3. Root B"
* ```
*/
class MultiLevelAutoNumbering {
/**
* Stores the most recent result to calculate the nested prefix
*
* @type {string}
* */
#_current = "";
/**
* Stack of AutoNumbering generators, one per nesting level
*
* @type {AutoNumbering[]}
* */
#_an = [];
/**
* The start value of the auto numbering
*
* @type {number}
*/
#_startValue = 1;
/**
* Creates a new multi-level auto-numbering generator.
*
* Only the default format `"{0}. "` is supported — other formats are not allowed.
*
* @param {number} [startValue=1] - The starting number for the top-level counter.
*
* @throws {Error} If a custom numbering format is provided.
*/
constructor(startValue) {
this.#_startValue = startValue ?? 1;
this.reset();
}
/**
* Resets this instance of {@link MultiLevelAutoNumbering}.
*/
reset(){
this.#_current = "";
this.#_an.length = 0;
this.#_an.push(new AutoNumbering(this.#_startValue, null));
}
/**
* Gets the current nesting level (1 = root).
*
* @returns {number} Current nesting level (1 = root)
* */
get level() { return this.#_an.length; }
/**
* Returns the next string in the current nesting level.
*
* @param {string} [text] - Optional content to append after the number (e.g., a title).
* @returns {string} Formatted numbered string like `1. Title` or `2.3. Another`.
*/
next(text) {
this.#_current = this.#_an[this.#_an.length - 1].next();
return this.#_current + (text ?? "");
}
/**
* Increases the nesting level (e.g., goes from `2.` to `2.1.`, or from `1.2.` to `1.2.1.`).
*
* The new level resets its own counter, while prefixing the last generated parent string.
*
* @param {number} [startValue=1] - Starting number for the new level.
* @returns {MultiLevelAutoNumbering} The current instance (for chaining).
*/
nest(startValue) {
let nxtFormat = this.#_current.trim();
nxtFormat += "{0}. ";
this.#_an.push(new AutoNumbering(startValue ?? 1, nxtFormat));
return this;
}
/**
* Decreases the nesting level (e.g., goes from `1.1.1.` to `1.1.`).
*
* Does nothing if already at the top-level (level 1).
*
* @returns {MultiLevelAutoNumbering} The current instance (for chaining).
*/
unnest() {
if(this.#_an.length > 1)
this.#_an.pop();
return this;
}
}
// eslint-disable-next-line no-unused-vars
/**
* Internal class used by assertions to carry both the result of a test evaluation
* and any associated metadata (such as expected/actual values) for logging.
*
* @private
* @class
*/
class TestInternalResult{
/**
* Indicates if the test passed or failed.
*
* @type {boolean}
*/
_result = null;
/**
* Additional context (e.g., expected/actual values) to display with the result.
*
* @type {any}
*/
_additionalData = null;
/**
* Constructs a new result object for use in lazy assertions.
*
* @param {boolean} result - The outcome of the test (true/false).
* @param {any} additionalData - Extra metadata to assist in diagnostics (optional).
*/
constructor(result, additionalData) {
this._result = result;
this._additionalData = additionalData;
}
}
/**
* @class BaseTest
*
* Abstract base class for all test types.
*
* Provides a unified interface for managing test name, success/failure status, timing, and output.
* Subclasses must override the `runImpl()` method to implement test logic.
* @abstract
*/
class TestBase {
/** @type {string} */
#_testName = null;
/** @type {boolean} */
#_succeeded = true;
/** @type {string} */
#_successMessage = null;
/** @type {string} */
#_failureMessage = null;
/** @type {any} */
#_additionalData = null;
/** @type {Array<Error>} */
#_errors = [];
/** @type {number} */
#_elapsed = 0;
/** @type {{logger: LoggerBase}} */
static #_test = null;
/**
* @param {{{logger: LoggerBase}}} test
* @ignore
*/
static __setup(test) {
this.#_test = test;
}
/**
* Base class for all test types.
*
* This class defines the common interface for test name, result summary,
* success/failure messages, optional data, and a way to log results.
*
* @param {string} testName - The name of the test (must be string).
* @param {string} [successMessage="success"] - Message when the test passes.
* @param {string} [failureMessage="failure"] - Message when the test fails.
* @param {any} [additionalData] - Arbitrary data to show with test output.
*/
constructor(testName, successMessage, failureMessage, additionalData) {
if (!InternalUtils.isString(testName)) { throw new Error("testName must be string"); }
if (successMessage != null && !InternalUtils.isString(successMessage)) { throw new Error("successMessage must be string, null or undefined"); }
if (failureMessage != null && !InternalUtils.isString(failureMessage)) { throw new Error("failureMessage must be string, null or undefined"); }
this.#_testName = testName;
this.#_successMessage = successMessage ?? "success";
this.#_failureMessage = failureMessage ?? "failure";
this.#_additionalData = additionalData;
}
/**
* Returns the name of the test.
*
* @returns {string} - The name of the test.
*/
get name() { return this.#_testName; }
/**
* Returns the message to display on test success.
*
* @returns {string} - The message to display on test success.
*/
get successMessage() { return this.#_successMessage; }
/**
* Returns the message to display on test failure.
*
* @returns {string} - The message to display on test failure.
*/
get failureMessage() { return this.#_failureMessage; }
/**
* Gets the duration in milliseconds.
*
* @returns {number} The duration in milliseconds. */
get elapsed() { return this.#_elapsed; } /* milliseconds*/
/**
* Sets the time elapsed.
*
* @param {number} value - Duration in milliseconds
*/
set elapsed(value) { this.#_elapsed = value; } /* milliseconds*/
/**
* Returns whether the test passed — overridden in derived classes.
*
* @returns {boolean} - Whether the test passed — overridden in derived classes.
*/
get succeeded() { return this.#_succeeded;}
/**
* Sets a value indicating whether the test passed.
*
* @param {boolean} value - A value indicating whether the test passed.
*/
set succeeded(value) { this.#_succeeded = (value === true);}
/**
* Returns whether the test failed (inverse of succeeded).
*
* @returns {boolean} - Whether the test failed (inverse of succeeded).
*/
get failed() { return this.succeeded !== true; }
/**
* Returns the list of errors associated with the test.
*
* @returns {Array<Error>} - The list of errors associated with the test.
*/
get errors() { return this.#_errors; }
/**
* Returns the count of errors (possibly from child tests)
*
* @returns {number} - The count of errors (possibly from child tests).
*/
get errorCount() { return this.errors.length; } /* might be the count of inner tests' errors, so in derived class might be positive even though the errors collection is null! */
/**
* Gets extra information to log with the test.
*
* @returns {any} Extra information to log with the test
*/
get additionalData() { return this.#_additionalData; }
/**
* Sets extra information to log with the test.
*
* @param {any} value The additional data.
*/
set additionalData(value) { this.#_additionalData = value; }
/**
* Returns the logger used to log test results.
*
* @returns {LoggerBase} - The logger used to log test results.
*/
get logger() { return TestBase.#_test.logger; }
/**
* Runs the test and optionally writes its result.
*
* @param {boolean} write - If true, writes the result to the console;
* If false doesn't write the result to the console;
* Otherwise writes only failures to the console.
* @param {MultiLevelAutoNumbering} [mlAutoNumber] - Optional multi-level automatic numbering to automatically prefix messages with numbers.
* @returns {boolean} Whether the test passed.
*/
run(write, mlAutoNumber) {
this.runImpl();
if (write === true || (write !== false && !this.succeeded)) {
this.write(mlAutoNumber);
}
return this.succeeded;
}
/**
* Runs the test without printing, just settings succeeded to the test result.
*
* @abstract
*/
runImpl() {
throw new Error("Method 'runImpl()' must be implemented by subclass");
}
/**
* Pushes the specified error to the error list.
*
* @param {Error} e - The error.
* @ignore
*/
_push_error(e){
this.#_errors.push(e);
}
/**
* Logs the result of the test to the console.
*
* If the test passes with no errors, it uses a flat `console.log`.
* If there are errors or additional data, it uses a collapsed group for clarity.
* @param {MultiLevelAutoNumbering} [mlAutoNumber] - Optional multi-level automatic numbering to automatically prefix messages with numbers.
*/
write(mlAutoNumber) {
if(mlAutoNumber == null || !(mlAutoNumber instanceof MultiLevelAutoNumbering))
mlAutoNumber = null;
const labelName = this.name?.trim() || "(unnamed test)";
let label = `${(mlAutoNumber?.next() ?? "")}${labelName}: ${(this.succeeded ? this.successMessage : this.failureMessage)} (${this.elapsed} ms`;
let color = this.succeeded ? "green" : "red";
if (this.errorCount === 0) {
label += ")";
}
else {
let errorStr = (this.succeeded ? "" : "un") + "expected " + (this.errorCount > 1 ? "errors" : "error");
label += `, ${this.errorCount} ${errorStr})`;
}
if (this.errorCount == 0 && this.additionalData == null) {
// Simple success case
this.logger.log(label, color);
return;
}
// Grouped output with errors or extra info
this.logger.groupCollapsed(label, color);
// Show errors if available
if (this.errorCount > 0) {
if (this.additionalData != null) {
this.logger.groupCollapsed("errors");
}
/*
* Available colors:
* "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "gray", "lightred",
* "lightgreen", "lightyellow", "lightblue", "lightmagenta", "lightcyan", "lightgray"
*/
for (let err of this.errors) {
if(this.succeeded)
this.logger.error(err, "black");
else
this.logger.error(err);
}
if (this.additionalData != null) {
this.logger.groupEnd();
}
}
// Show additional data if available
if (this.additionalData != null) {
if (this.errorCount > 0) {
this.logger.groupCollapsed("additional data");
}
this.logger.log(JSON.stringify(this.additionalData));
if (this.errorCount > 0) {
this.logger.groupEnd();
}
}
this.logger.groupEnd();
}
}
/**
* @class Assert
*
* A generic assertion test class that evaluates either a boolean or a function returning boolean.
*
* Inherits from {@link TestBase}.
* Typically used for boolean tests or custom logic.
*/
class Assert extends TestBase {
// Holds the test logic, result, context, error and timing info
/** @type {(function|boolean)} */
#_test = null;
/** @type {any} */
#_thisArg = null;
/**
* A test that evaluates a function or boolean and tracks its result.
*
* If the test value is a function, it's called and timed.
* If the function throws, it fails and captures the error.
*
* @param {string} testName - Name of the test.
* @param {(function|boolean)} test - Test logic (function or static boolean).
* @param {string} [successMessage] - Custom message on success.
* @param {string} [failureMessage] - Custom message on failure.
* @param {any} [additionalData] - Extra data to log.
* @param {any} [thisArg=globalThis] - `this` context to bind when calling the function.
*/
constructor(testName, test, successMessage, failureMessage, additionalData, thisArg) {
super(testName, successMessage, failureMessage, additionalData);
this.#_test = test;
this.#_thisArg = thisArg ?? globalThis;
}
/**
* Runs the test without printing, just set `succeeded` property to the test result.
*
* @override
* @ignore
*/
runImpl() {
if (this.#_test === true) {
this.succeeded = true;
}
else if (!InternalUtils.isFunction(this.#_test)) {
this.succeeded = false; // Test is neither true nor a function
}
else {
let res;
const t0 = InternalUtils.now();
try {
let tmp = this.#_test.call(this.#_thisArg);
if(tmp instanceof TestInternalResult){
this.additionalData = tmp._additionalData;
res = tmp._result === true;
}
else {
res = tmp;
}
}
catch (e) {
this._push_error(e);
res = false;
}
const t1 = InternalUtils.now();
this.elapsed = t1 - t0;
this.succeeded = res;
}
return this.succeeded;
}
}
/**
* @class ThrowsBase
*
* Base class for tests that evaluate whether a function throws or not.
*
* Supports optional error validation via predicate functions.
*
* Inherits from {@link Assert}.
* Not used directly — use {@link Throws} or {@link NoThrows} instead.
*/
class ThrowsBase extends Assert {
/** @type {function} */
#_checkErrorFn = null;
/** @type {any} */
#_thisArg = null;
/** @type {boolean} */
#_expected = true;
/**
* Base class to test whether a function throws (or not), and optionally validate the error thrown.
*
* @param {string} testName - Name of the test.
* @param {boolean} expectingError - Whether an error is expected (`true` = should throw).
* @param {function} fn - Function to test.
* @param {function(any):boolean} [checkErrorFn] - Optional error predicate to validate the thrown error.
* @param {any} [thisArg] - Optional `this` context for invoking the test/check function.
*/
constructor(testName, expectingError, fn, checkErrorFn, thisArg) {
expectingError = (expectingError === true);
const errWasThrownAsExpected = "An error was thrown (as expected)!";
const errWasNotThrownAsExpected = "An error was NOT thrown (as expected).";
const errExpectedFail = "Expected an error, but none was thrown or it did not satisfy the predicate.";
const errWasThrownAsUnexpectedly = "An error was not thrown (unexpectedly).";
super(testName, fn,
expectingError ? errWasThrownAsExpected : errWasNotThrownAsExpected,
expectingError ? errExpectedFail : errWasThrownAsUnexpectedly,
thisArg);
if (!InternalUtils.isFunction(fn)) { throw new Error("fn parameter must be a function"); }
if (expectingError && checkErrorFn != null && !InternalUtils.isFunction(checkErrorFn)) { throw new Error("checkErrorFn parameter must be a function, null or undefined"); }
this.#_expected = expectingError;
this.#_thisArg = thisArg;
this.#_checkErrorFn = checkErrorFn;
}
/**
* Executes the test, checking if an error was thrown and optionally applying a predicate on the error.
*
* @override
*/
runImpl() {
super.runImpl();
const basePassed = super.succeeded;
if (!basePassed && this.errors.length === 1) {
this.succeeded = this.#_expected && (this.#_checkErrorFn == null || this.#_checkErrorFn.call(this.#_thisArg, this.errors[0]) === true);
}
else {
this.succeeded = !this.#_expected;
}
}
}
/**
* File: src/testClasses/AreEqual.js
*/
/**
* @class AreEqual
*
* A test that compares two values using strict equality (`===`) or a custom comparer function.
*
* Inherits from {@link Assert}.
*/
class AreEqual extends Assert {
/**
* Compares two values using `===` or a custom comparer function.
* Values can be passed directly or as functions for deferred evaluation.
*
* @param {string} testName - Name of the test.
* @param {any|Function} expected - Expected value or function returning it.
* @param {any|Function} actual - Actual value or function returning it.
* @param {function(any, any):boolean} [comparer] - Optional custom comparison function ((expected, actual) => boolean).
* @param {any} [thisArg] - Optional context for invoking deferred or comparison functions.
*/
constructor(testName, expected, actual, comparer, thisArg) {
// Use the comparer if provided, otherwise compare using strict equality
const needsDelayedExecution = InternalUtils.isFunction(expected) || InternalUtils.isFunction(actual) || InternalUtils.isFunction(comparer);
let test;
let ad = null;
if(needsDelayedExecution){
test = () => {
let expectedVal = InternalUtils.isFunction(expected) ? expected.call(thisArg ?? globalThis) : expected;
let actualVal = InternalUtils.isFunction(actual) ? actual.call(thisArg ?? globalThis) : actual;
return new TestInternalResult(
InternalUtils.isFunction(comparer) ? comparer.call(thisArg ?? globalThis, expectedVal, actualVal) : expectedVal === actualVal,
{ "expected": expectedVal, "actual": actualVal }
);
};
}
else {
test = expected === actual;
ad = { "expected": expected, "actual": actual };
}
super(testName,
test,
"Expected and actual values matched",
"Expected and actual values did not match",
ad,
thisArg);
}
}
/**
* File: src/testClasses/AreNotNull.js
*/
/**
* @class AreNotEqual
*
* A test that verifies two values are **not equal** using strict inequality (`!==`)
* or a custom comparer function that is expected to return `false`.
*
* This test passes when `actual !== not_expected`, or when the `comparer` returns `false`.
*
* Inherits from {@link Assert}.
*
* Example:
* ```js
* new AreNotEqual("Should be different", 42, value);
* new AreNotEqual("Custom inequality", a, b, (a, b) => deepCompare(a, b));
* ```
*/
class AreNotEqual extends Assert {
/**
* Constructs a new inequality assertion.
*
* @param {string} testName - Name of the test.
* @param {any|Function} not_expected - Value the actual result must NOT match.
* @param {any|Function} actual - Actual value or function returning it.
* @param {function(any, any):boolean} [comparer] - Optional custom comparison function ((not_expected, actual) => boolean).
* @param {any} [thisArg] - Optional context for invoking deferred or comparison functions.
*/
constructor(testName, not_expected, actual, comparer, thisArg) {
// Use the comparer if provided, otherwise compare using strict equality
const needsDelayedExecution = InternalUtils.isFunction(not_expected) || InternalUtils.isFunction(actual) || InternalUtils.isFunction(comparer);
let test;
let ad = null;
if(needsDelayedExecution){
test = () => {
let not_expectedVal = InternalUtils.isFunction(not_expected) ? not_expected.call(thisArg ?? globalThis) : not_expected;
let actualVal = InternalUtils.isFunction(actual) ? actual.call(thisArg ?? globalThis) : actual;
return new TestInternalResult(
InternalUtils.isFunction(comparer) ? !comparer.call(thisArg ?? globalThis, not_expectedVal, actualVal) : not_expectedVal !== actualVal,
{ "not_expected": not_expectedVal, "actual": actualVal }
);
};
}
else {
test = not_expected !== actual;
ad = { "not_expected": not_expected, "actual": actual };
}
super(testName,
test,
"Actual value did not match the disallowed value (as expected!)",
"Actual value matched the disallowed value (not as expected!)",
ad,
thisArg);
}
}
/**
* File: src/testClasses/IsDefined.js
*/
/**
* @class IsDefined
*
* A test that asserts the actual value is **not** `undefined`.
*
* Inherits from {@link Assert}.
*
* Example:
* ```js
* isDefined("Value should be defined", myValue);
* ```
*/
class IsDefined extends AreNotEqual {
/**
* Constructs a defined-check assertion.
*
* @param {string} testName - Descriptive name of the test.
* @param {any|Function} actual - Value to evaluate or function to call.
* @param {any} [thisArg] - Optional context for evaluation.
*/
constructor(testName, actual, thisArg) {
super(testName, undefined, actual, null, thisArg);
}
}
/**
* File: src/testClasses/IsFalse.js
*/
/**
* @class IsFalse
*
* A test that asserts the actual value is strictly `false`.
*
* Can accept a boolean value or a function returning boolean.
*
* Inherits from {@link Assert}.
*/
class IsFalse extends AreEqual {
/**
* Asserts that a value is strictly `false`.
*
* @param {string} testName - The name of the test.
* @param {any|Function} actual - The value or function to evaluate.
* @param {any} [thisArg] - Optional context for evaluation.
*/
constructor(testName, actual, thisArg) {
super(testName, false, actual, null, thisArg);
}
}
/**
* File: src/testClasses/IsNotNull.js
*/
/**
* @class IsNotNull
*
* A test that asserts the actual value is **not** `null`.
*
* Inherits from {@link Assert}.
*
* Example:
* ```js
* isNotNull("Should not be null", myValue);
* ```
*/
class IsNotNull extends AreNotEqual {
/**
* Constructs a not-null assertion.
*
* @param {string} testName - Descriptive name of the test.
* @param {any|Function} actual - Value to check.
* @param {any} [thisArg] - Optional context for invocation.
*/
constructor(testName, actual, thisArg) {
super(testName, null, actual, null, thisArg);
}
}
/**
* File: src/testClasses/IsNull.js
*/
/**
* @class IsNull
*
* A test that asserts the actual value is strictly `null`.
*
* Inherits from {@link Assert}.
*
* Example:
* ```js
* isNull("Should be null", myValue);
* ```
*/
class IsNull extends AreEqual {
/**
* Constructs a null-check assertion.
*
* @param {string} testName - Descriptive name of the test.
* @param {any|Function} actual - Value to test or function that returns it.
* @param {any} [thisArg] - Optional context for function calls.
*/
constructor(testName, actual, thisArg) {
super(testName, null, actual, null, thisArg);
}
}
/**
* File: src/testClasses/IsTrue.js
*/
/**
* @class IsTrue
*
* A test that asserts the actual value is strictly `true`.
*
* Can accept a boolean value or a function returning boolean.
*
* Inherits from {@link Assert}.
*/
class IsTrue extends AreEqual {
/**
* Asserts that a value is strictly `true`.
*
* @param {string} testName - The name of the test.
* @param {any|Function} actual - The value or function to evaluate.
* @param {any} [thisArg] - Optional context in which to invoke deferred evaluation.
*/
constructor(testName, actual, thisArg) {
super(testName, true, actual, null, thisArg);
}
}
/**
* File: src/testClasses/IsUndefined.js
*/
/**
* @class IsUndefined
*
* A test that asserts the actual value is strictly `undefined`.
*
* Inherits from {@link Assert}.
*
* Example:
* ```js
* isUndefined("Should be undefined", maybeMissing);
* ```
*/
class IsUndefined extends AreEqual {
/**
* Constructs an undefined-check assertion.
*
* @param {string} testName - Descriptive name of the test.
* @param {any|Function} actual - Value to test or a function that returns it.
* @param {any} [thisArg] - Optional context for deferred invocation.
*/
constructor(testName, actual, thisArg) {
super(testName, undefined, actual, null, thisArg);
}
}
/**
* File: src/testClasses/NoThrows.js
*/
/**
* @class NoThrows
*
* A test that verifies a function does not throw any error.
*
* Inherits from {@link ThrowsBase}.
*/
class NoThrows extends ThrowsBase {
/**
* Tests that a function does NOT throw.
*
* @param {string} testName - Name of the test.
* @param {?function} fn - The function to test.
* @param {any} [thisArg] - Optional `this` context.
*/
constructor(testName, fn, thisArg) {
super(testName, false, fn, null, thisArg);
}
}
/**
* File: src/testClasses/SequenceAreEqual.js
*/
/**
* @class SequencesAreEqual
*
* A test that compares two iterable sequences element-by-element for equality.
*
* You can supply a custom item comparison function. Results include index mismatches.
*
* Inherits from {@link TestBase}.
*/
class SequencesAreEqual extends TestBase {
/** @type {Iterable<any>} */
#_expected = null;
/** @type {Iterable<any>} */
#_actual = null;
/** @type {function} */
#_itemComparer = null;
/** @type {boolean} */
#_validIterables = true;
/** @type {any} */
#_thisArg = null;
/**
* Compares two iterable sequences element by element.
*
* @param {string} testName - Name of the test.
* @param {Iterable<any>} expected - Expected sequence.
* @param {Iterable<any>} actual - Actual sequence.
* @param {function(any, any):boolean} [itemComparer] - Optional custom comparison function to compare individual items ((expected, actual) => boolean).
* @param {any} [thisArg] - Optional `this` binding for the itemComparer.
*/
constructor(testName, expected, actual, itemComparer, thisArg) {
// Call Assert constructor with all info
super(testName, `Actual sequence equals to the expected sequence`, `Actual sequence does not equal to the expected sequence)`, null, thisArg);
this.additionalData = {};
this.#_expected = expected;
this.#_actual = actual;
this.#_itemComparer = itemComparer;
this.#_thisArg = thisArg;
// Validate that expected is iterable
if (!InternalUtils.isIterable(expected)) {
this.additionalData["expected"] = "ERROR: 'expected' argument is not iterable!";
this.#_validIterables = false;
}
// Validate that actual is iterable
if (!InternalUtils.isIterable(actual)) {
this.additionalData["actual"] = "ERROR: 'actual' argument is not iterable!";
this.#_validIterables = false;
}
}
/**
* Runs the test without printing.
*
* @returns {boolean} Whether the test passed.
* @override
*/
runImpl() {
if(!this.#_validIterables) {
this.succeeded = false;
}
else {
const t0 = InternalUtils.now();
let expectedArr = this.additionalData["expected"] = Array.from(this.#_expected);
let actualArr = this.additionalData["actual"] = Array.from(this.#_actual);
let res;
// Check lengths
if (expectedArr.length === actualArr.length) {
// Check individual items
let indicesDifferent = [];
for (var i = 0; i < expectedArr.length; i++) {
let res = this.#_itemComparer != null ? this.#_itemComparer.call(this.#_thisArg ?? undefined, expectedArr[i], actualArr[i]) : expectedArr[i] === actualArr[i];
if (res !== true) {
indicesDifferent.push(i);
}
}
if (indicesDifferent.length > 0)
this.additionalData["Mismatch at indices"] = "Different element indices are: {" + indicesDifferent.join(", ") + "}";
res = indicesDifferent.length === 0;
}
else {
this.additionalData["Mismatch at indices"] = "expected.length !== actual.length";
res = false;
}
const t1 = InternalUtils.now();
this.elapsed = t1 - t0;
this.succeeded = res;
}
}
}
/**
* File: src/testClasses/Throws.js
*/
/**
* @class Throws
*
* A test that expects a function to throw an exception.
*
* You may optionally provide a predicate to verify the thrown error.
*
* Inherits from {@link ThrowsBase}.
*/
class Throws extends ThrowsBase {
/**
* Tests that a function throws, and optionally that the thrown error satisfies a condition.
*
* @param {string} testName - Name of the test.
* @param {function} fn - The function that should throw.
* @param {function(any):boolean} [checkErrorFn] - Optional error predicate.
* @param {any} [thisArg] - Optional `this` context.
*/
constructor(testName, fn, checkErrorFn, thisArg) {
super(testName, true, fn, checkErrorFn, thisArg);
}
}
/**
* File: src/testClasses/TestGroup.js
*/
/**
* @class TestGroup
*
* A container for managing and executing multiple tests (or nested groups of tests).
*
* Automatically aggregates success/failure counts and outputs structured logs.
*
* Supports fluent-style chaining:
* ```js
* group.isTrue("A", true)
* .areEqual("Compare", 1, 1)
* .throws("Expect error", () => { throw new Error(); });
* .groupStart("another group")
* .areEqual("Compare", 3, 3)
* .throws("Expect error", () => { throw new Error(); });
* .groupClose()
* .run();
* ```
*
* Inherits from {@link TestBase}.
*/
class TestGroup extends TestBase {
/** @type {Array<TestBase>} */
#_tests = [];
/** @type {number} */
#_directFailureCount = 0;
/** @type {number} */
#_totalFailureCount = 0;
/** @type {number} */
#_totalErrorCount = 0;
/** @type {number} */
#_unexpectedErrorCount = 0;
/** @type {TestGroup|null} */
#_parentGroup = null;
/** @type {boolean|null} */
#_write = null; // Controls output policy for child test writes (true, false, or conditional)
/**
* Creates a new test group to encapsulate multiple tests or nested groups.
*
* @param {string} testName - The name/title of this group.
* @param {...TestBase} tests - Optional tests or nested groups to immediately add.
*/
constructor(testName, ...tests) {
super(testName);
this.add(...tests);
}
/**
* Clears all tests in this group.
*/
clear() {
this.#_tests.length = 0;
this.#_directFailureCount = 0;
this.#_totalFailureCount = 0;
this.#_totalErrorCount = 0;
this.#_unexpectedErrorCount = 0;
}
/**
* The total number of errors found (including in nested groups).
*
* @returns {number} Total number of errors found (including in nested groups).
* */
get errorCount() { return this.#_totalErrorCount; }
/**
* Runs the test and optionally writes its result.
*
* @param {boolean} write - If true, writes the result to the console;
* If false doesn't write the result to the console;
* Otherwise writes only failures to the console.
* @param {MultiLevelAutoNumbering} [mlAutoNumber] - Optional multi-level automatic numbering to automatically prefix messages with numbers.
* @returns {boolean} Whether the test passed.
* @override
*/
run(write, mlAutoNumber) {
this.#_write = write;
return super.run(write, mlAutoNumber);
}
/**
* Executes all tests/groups in this group without printing.
* Aggregates error and timing info, but delays output if `write` is false.*
* @returns {boolean} True if all direct tests succeeded.
* @override
*/
runImpl() {
this.#_directFailureCount = 0;
this.#_totalErrorCount = 0;
this.#_unexpectedErrorCount = 0;
const t0 = InternalUtils.now();
for (let t of this.#_tests) {
t.runImpl();
if (t instanceof TestGroup) {
// Accumulate from nested groups
this.#_totalFailureCount += t.totalFailureCount;
this.#_unexpectedErrorCount += t.unexpectedErrorCount;
}
else {
// Leaf tests
this.#_directFailureCount += t.succeeded ? 0 : 1;
this.#_unexpectedErrorCount += t.succeeded ? 0 : t.errorCount;
this.#_totalFailureCount += t.succeeded ? 0 : 1;
}
this.#_totalErrorCount += t.errorCount;
}
const t1 = InternalUtils.now();
this.elapsed = t1 - t0;
return this.#_directFailureCount === 0;
}
/**
* Outputs a summary line and recursively logs all child test results.
* Uses collapsed group for passed tests and expanded group for failed ones.
*
* @param {MultiLevelAutoNumbering} [mlAutoNumber] - Optional multi-level automatic numbering to automatically prefix messages with numbers.
* @override
*/
write(mlAutoNumber, parentWriteMode) {
if(mlAutoNumber == null || !(mlAutoNumber instanceof MultiLevelAutoNumbering))
mlAutoNumber = null;
const writeMode = parentWriteMode == null ? this.#_write : parentWriteMode;
let label = `${(mlAutoNumber?.next() ?? "")}${this.name}: (${this.elapsed}ms, `;
let color;
if (this.succeeded) {
color = "green";
label += "all passed";
}
else {
color = "red";
label += `${(this.#_directFailureCount == 0 ? "no" : this.#_directFailureCount.toString())} direct failure${(this.#_directFailureCount === 1 ? "" : "s")}, ${this.#_totalFailureCount} total failure${(this.#_totalFailureCount === 1 ? "" : "s")}`;
}
if (this.#_unexpectedErrorCount> 0) {
label += `, ${this.#_unexpectedErrorCount} unexpected error${(this.#_unexpectedErrorCount > 1 ? "s" : "")}`;
}
label += ")";
if (this.succeeded) {
this.logger.groupCollapsed(label, color);
}
else {
this.logger.group(label, color);
}
if(this.#_tests?.length > 0) {
mlAutoNumber?.nest();
for (let t of this.#_tests) {
if(writeMode === true || (writeMode == null && !t.succeeded)) {
t.write(mlAutoNumber, this.#_write);
}
}
mlAutoNumber?.unnest();
}
this.logger.groupEnd();
}
/**
* Returns the number of unexpected errors that were thrown.
*
* @returns {number} - The number of unexpected errors that were thrown.
*/
get unexpectedErrorCount() {
return this.#_unexpectedErrorCount;
}
/**
* Returns the number of direct failures of tests within the group. Note that a failure of the group itself is not counted.
*
* @returns {number} - The number of direct failures of tests within the group. Note that a failure of the group itself is not counted.
*/
get directFailureCount() {
return this.#_directFailureCount;
}
/**
* Returns the number of total failure count, including in inner groups. Note that a failure of the group itself is not counted.
*
* @returns {number} - The number of total failure count, including in inner groups. Note that a failure of the group itself is not counted.
*/
get totalFailureCount() {
return this.#_totalFailureCount;
}
/**
* Returns `true` if the test scucceeded (that is the value of `totalFailureCount` equals 0); otherwise, `false`.
*
* @returns {boolean} - `true` if the test scucceeded (that is the value of `totalFailureCount` equals 0); otherwise, `false`.
*/
get succeeded() {
return this.#_totalFailureCount === 0;
}
/**
* Adds tests or groups to this group.
*
* @param {...TestBase} tests - One or more test/group instances.
*/
add(...tests) {
this.#_tests.push(...tests);
for (let t of tests) {
if (t instanceof TestGroup) {
t.#_parentGroup = this;
}
}
}
/**
* Begins a new nested group and automatically adds it to this group.
*
* @param {string} testName - The name of the nested group.
* @returns {TestGroup} The new nested group.
*/
groupStart(testName) {
let grp = new TestGroup(testName);
this.add(grp);
return grp;
}
/**
* Ends the current group and returns its parent, if any.
* Enables fluid chaining of group nesting.
*
* @returns {TestGroup} - The parent group or `this` if already root.
*/
groupClose() {
return this.#_parentGroup ?? this;
}
/**
* Adds an equality assertion to the group.
* Checks if `actual === expected`, or uses a custom comparer if provided.
*
* @param {string} testName - Descriptive test title.
* @param {*} expected - Expected value.
* @param {*} actual - Actual value to compare.
* @param {function(any, any):boolean} [comparer] - Optional custom comparison function ((expected, actual) => boolean).
* @param {any} [thisArg] - Optional context for evaluation.
* @returns {TestGroup} The current test group (for chaining).
*/
areEqual(testNam