just-test
Version:
JavaScript tests runner
232 lines (199 loc) • 5.86 kB
JavaScript
// this is the main just-test SDK harness entrypoint from consumer perspective
// this module can found itself running in 3 modes:
// - plain_run - simple test execution, no server, no interop, just debugging the tests
// - session - tests registration phase, tests are not being run
// - test - test run, only the tests required by environment will be running
import { obtainExecutionContext, EXECUTION_MODES } from './environment-config.js';
import { getTestId } from '../common/interop-utils.js';
import { EVENT, STATUS } from '../common/constants.js';
import { Test } from '../testing/model/test.js';
export {
getSuite,
MessageBus
}
const DEFAULT_SUITE_OPTIONS = {
only: false,
skip: false,
sequental: false
};
const DEFAULT_TEST_OPTIONS = {
only: false,
skip: false,
ttl: 3000
};
const DEFAULT_CONFIGS_SUBMISSION_DELAY = 92;
class AssertTimeout extends Error { }
const MessageBus = new MessageChannel();
class SuiteContext {
#name = null;
#mode = null;
#testId = null;
#port = null;
#only = false;
#skip = false;
#sequental = false;
#testConfigs = [];
#testConfigsSubmitter = null;
#executionTail;
constructor(name, execContext, options = DEFAULT_SUITE_OPTIONS) {
this.#name = name;
this.#mode = execContext.mode;
this.#testId = execContext.testId;
this.#port = execContext.childPort;
this.#only = Boolean(options?.only);
this.#skip = Boolean(options?.skip);
this.#sequental = Boolean(options?.sequental);
}
get name() { return this.#name; }
get only() { return this.#only; }
get skip() { return this.#skip; }
get test() {
return this.#mode === EXECUTION_MODES.SESSION
? this.#registerTest
: this.#runTest;
}
async run() {
console.info(`suite '${this.#name}' started...`);
const testPromises = [];
this.#executionTail = Promise.resolve();
for (const testConfig of this.#testConfigs) {
if (!testConfig.skip) {
const runResultPromise = this.#runTest(testConfig.tName, testConfig.tOptions, testConfig.tCode);
if (this.#sequental) {
this.#executionTail = this.#executionTail.finally(() => runResultPromise);
} else {
testPromises.push(runResultPromise);
}
}
}
testPromises.push(this.#executionTail);
await Promise.all(testPromises);
console.info(`... suite '${this.#name}' done`);
}
#registerTest(name, options, code) {
if (!this.#testConfigsSubmitter) {
this.#testConfigsSubmitter = globalThis.setTimeout(() => {
console.info(`reporting ${this.#testConfigs.length} test config/s...`);
this.#port.postMessage(this.#testConfigs);
console.info(`... reported`);
}, DEFAULT_CONFIGS_SUBMISSION_DELAY);
}
const testConfig = this.#verifyNormalize(name, options, code);
this.#testConfigs.push(testConfig);
}
async #runTest(name, options, code) {
const testConfig = this.#verifyNormalize(name, options, code);
if (this.#mode === EXECUTION_MODES.TEST && this.#testId !== testConfig.id) {
return;
}
if (typeof options === 'function') {
code = options;
}
if (this.#sequental && this.#executionTail) {
await this.#executionTail;
}
console.info(`'${testConfig.id}' started...`);
if (this.#mode === EXECUTION_MODES.TEST) {
const runStartMessage = {
type: EVENT.RUN_STARTED,
suiteName: this.#name,
testName: name
};
this.#port.postMessage(runStartMessage);
}
const run = {};
let runResult;
let start;
let timeout;
try {
run.timestamp = Date.now();
start = globalThis.performance.now();
const runPromise = Promise.race([
new Promise(resolve => {
timeout = setTimeout(
resolve,
testConfig.config.ttl,
new AssertTimeout(`run of '${testConfig.id}' exceeded ${testConfig.config.ttl}ms`));
}),
Promise.resolve(code())
]);
if (this.#sequental) {
this.#executionTail = runPromise;
}
runResult = await runPromise;
} catch (e) {
console.error(e);
runResult = e;
} finally {
clearTimeout(timeout);
run.time = Math.round((globalThis.performance.now() - start) * 10000) / 10000;
finalizeRun(run, runResult);
if (this.#mode === EXECUTION_MODES.TEST) {
const runEndedMessage = {
type: EVENT.RUN_ENDED,
suiteName: this.#name,
testName: name,
run
};
this.#port.postMessage(runEndedMessage);
}
console.info(`'${testConfig.id}' ${run.status.toUpperCase()} in ${run.time}ms`);
}
return run;
}
#verifyNormalize(name, options, code) {
if (!name || typeof name !== 'string') {
throw new Error(`invalid test name: '${name}'`);
}
if (typeof options === 'function' || !options) {
code = options;
options = DEFAULT_TEST_OPTIONS;
}
if (typeof code !== 'function') {
throw new Error(`invalid test code: '${code}'`);
}
const result = new Test(
getTestId(this.#name, name),
name,
this.#name,
options
);
return result;
}
}
function getSuite(suiteName, suiteOptions) {
if (!suiteName || typeof suiteName !== 'string') {
throw new Error(`invalid suite name '${suiteName}'`);
}
const execContext = obtainExecutionContext();
return new SuiteContext(
suiteName,
execContext,
suiteOptions
);
}
function finalizeRun(run, runResult) {
if (runResult && typeof runResult.name === 'string' && typeof runResult.message === 'string' && runResult.stack) {
const runError = processError(runResult);
if ((runError.type && runError.type.toLowerCase().includes('assert')) ||
(runError.name && runError.name.toLowerCase().includes('assert'))) {
run.status = STATUS.FAIL;
} else {
run.status = STATUS.ERROR;
}
run.error = runError;
} else {
run.status = STATUS.PASS;
}
}
function processError(error) {
const stacktrace = error.stack.split(/\r\n|\r|\n/)
.map(l => l.trim())
.filter(Boolean);
return {
name: error.name,
type: error.constructor.name,
message: error.message,
stacktrace
};
}