diff --git a/package.json b/package.json index ec6bc6a..800626e 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,36 @@ "license": "MIT", "keywords": [ "test", - "test framework", "testing", + "automated testing", + "test framework", + "ui testing", + "e2e", + "e2e testing", + "0 dependencies", + "zero dependencies", + "no dependencies", + "universal testing", + "universal test framework", + "universal test", + "universal", + "cross platform", + "cross platform testing", + "cross platform test framework", + "cross platform test", + "unit testing", + "unit test", + "unit", + "integration testing", + "integration test", + "integration", + "qa", + "quality assurance", + "tdd", + "bdd", + "assert", + "frontend", + "backend", "qunit", "node", "deno", diff --git a/shims/deno/assert.js b/shims/deno/assert.js deleted file mode 100644 index aec686a..0000000 --- a/shims/deno/assert.js +++ /dev/null @@ -1,333 +0,0 @@ -import { AssertionError as DenoAssertionError, assertRejects, assertThrows } from "https://deno.land/std@0.192.0/testing/asserts.ts"; -import '../../vendor/qunit.js'; -import { objectValues, objectValuesSubset, validateExpectedExceptionArgs, validateException } from '../shared/index.js'; -import util from 'node:util'; - -export class AssertionError extends DenoAssertionError { - constructor(object) { - super(object.message); - } -} - -export default class Assert { - AssertionError = AssertionError - - #asyncOps = []; - - constructor(module, test) { - this.test = test || module; - } - _incrementAssertionCount() { - this.test.totalExecutedAssertions++; - } - timeout(number) { - if (!Number.isInteger(number) || number < 0) { - throw new Error('assert.timeout() expects a positive integer.'); - } - - this.test.timeout = number; - } - step(message) { - let assertionMessage = message; - let result = !!message; - - this.test.steps.push(message); - - if (typeof message === 'undefined' || message === '') { - assertionMessage = 'You must provide a message to assert.step'; - } else if (typeof message !== 'string') { - assertionMessage = 'You must provide a string value to assert.step'; - result = false; - } - - this.pushResult({ - result, - message: assertionMessage - }); - } - verifySteps(steps, message = 'Verify steps failed!') { - this.deepEqual(this.test.steps, steps, message); - this.test.steps.length = 0; - } - expect(number) { - if (!Number.isInteger(number) || number < 0) { - throw new Error('assert.expect() expects a positive integer.'); - } - - this.test.expectedAssertionCount = number; - } - async() { - let resolveFn; - let done = new Promise(resolve => { resolveFn = resolve; }); - - this.#asyncOps.push(done); - - return () => { resolveFn(); }; - } - async waitForAsyncOps() { - return Promise.all(this.#asyncOps); - } - pushResult(resultInfo = {}) { - this._incrementAssertionCount(); - if (!resultInfo.result) { - throw new AssertionError({ - actual: resultInfo.actual, - expected: resultInfo.expected, - message: resultInfo.message || 'Custom assertion failed!', - stackStartFn: this.pushResult, - }); - } - - return this; - } - ok(state, message) { - this._incrementAssertionCount(); - if (!state) { - throw new AssertionError({ - actual: state, - expected: true, - message: message || `Expected argument to be truthy, it was: ${inspect(state)}`, - stackStartFn: this.ok, - }); - } - } - notOk(state, message) { - this._incrementAssertionCount(); - if (state) { - throw new AssertionError({ - actual: state, - expected: false, - message: message || `Expected argument to be falsy, it was: ${inspect(state)}`, - stackStartFn: this.notOk, - }); - } - } - true(state, message) { - this._incrementAssertionCount(); - if (state !== true) { - throw new AssertionError({ - actual: state, - expected: true, - message: message || `Expected argument to be true, it was: ${inspect(state)}`, - stackStartFn: this.true, - }); - } - } - false(state, message) { - this._incrementAssertionCount(); - if (state !== false) { - throw new AssertionError({ - actual: state, - expected: true, - message: message || `Expected argument to be false, it was: ${inspect(state)}`, - stackStartFn: this.false, - }); - } - } - equal(actual, expected, message) { - this._incrementAssertionCount(); - if (actual != expected) { - throw new AssertionError({ - actual, - expected, - message: message || `Expected: ${defaultMessage(actual, 'should equal to:', expected)}`, - operator: '==', - stackStartFn: this.equal, - }); - } - } - notEqual(actual, expected, message) { - this._incrementAssertionCount(); - if (actual == expected) { - throw new AssertionError({ - actual, - expected, - operator: '!=', - message: message || `Expected: ${defaultMessage(actual, 'should notEqual to:', expected)}`, - stackStartFn: this.notEqual, - }); - } - } - propEqual(actual, expected, message) { - this._incrementAssertionCount(); - let targetActual = objectValues(actual); - let targetExpected = objectValues(expected); - if (!QUnit.equiv(targetActual, targetExpected)) { - throw new AssertionError({ - actual: targetActual, - expected: targetExpected, - message: message || `Expected properties to be propEqual: ${defaultMessage(targetActual, 'should propEqual to:', targetExpected)}`, - stackStartFn: this.propEqual, - }); - } - } - notPropEqual(actual, expected, message) { - this._incrementAssertionCount(); - let targetActual = objectValues(actual); - let targetExpected = objectValues(expected); - if (QUnit.equiv(targetActual, targetExpected)) { - throw new AssertionError({ - actual: targetActual, - expected: targetExpected, - message: message || `Expected properties to NOT be propEqual: ${defaultMessage(targetActual, 'should notPropEqual to:', targetExpected)}`, - stackStartFn: this.notPropEqual, - }); - } - } - propContains(actual, expected, message) { - this._incrementAssertionCount(); - let targetActual = objectValuesSubset(actual, expected); - let targetExpected = objectValues(expected, false); - if (!QUnit.equiv(targetActual, targetExpected)) { - throw new AssertionError({ - actual: targetActual, - expected: targetExpected, - message: message || `propContains assertion fail on: ${defaultMessage(targetActual, 'should propContains to:', targetExpected)}`, - stackStartFn: this.propContains, - }); - } - } - notPropContains(actual, expected, message) { - this._incrementAssertionCount(); - let targetActual = objectValuesSubset(actual, expected); - let targetExpected = objectValues(expected); - if (QUnit.equiv(targetActual, targetExpected)) { - throw new AssertionError({ - actual: targetActual, - expected: targetExpected, - message: message || `notPropContains assertion fail on: ${defaultMessage(targetActual, 'should notPropContains of:', targetExpected)}`, - stackStartFn: this.notPropContains, - }); - } - } - deepEqual(actual, expected, message) { - this._incrementAssertionCount(); - if (!QUnit.equiv(actual, expected)) { - throw new AssertionError({ - actual, - expected, - message: message || `Expected values to be deepEqual: ${defaultMessage(actual, 'should deepEqual to:', expected)}`, - operator: 'deepEqual', - stackStartFn: this.deepEqual, - }); - } - } - notDeepEqual(actual, expected, message) { - this._incrementAssertionCount(); - if (QUnit.equiv(actual, expected)) { - throw new AssertionError({ - actual, - expected, - message: message || `Expected values to be NOT deepEqual: ${defaultMessage(actual, 'should notDeepEqual to:', expected)}`, - operator: 'notDeepEqual', - stackStartFn: this.notDeepEqual, - }); - } - } - strictEqual(actual, expected, message) { - this._incrementAssertionCount(); - if (actual !== expected) { - throw new AssertionError({ - actual, - expected, - message: message || `Expected: ${defaultMessage(actual, 'is strictEqual to:', expected)}`, - operator: 'strictEqual', - stackStartFn: this.strictEqual, - }); - } - } - notStrictEqual(actual, expected, message) { - this._incrementAssertionCount(); - if (actual === expected) { - throw new AssertionError({ - actual, - expected, - message: message || `Expected: ${defaultMessage(actual, 'is notStrictEqual to:', expected)}`, - operator: 'notStrictEqual', - stackStartFn: this.notStrictEqual, - }); - } - } - throws(blockFn, expectedInput, assertionMessage) { - this?._incrementAssertionCount(); - let [expected, message] = validateExpectedExceptionArgs(expectedInput, assertionMessage, 'rejects'); - if (typeof blockFn !== 'function') { - throw new AssertionError({ - actual: blockFn, - expected: Function, - message: 'The value provided to `assert.throws` was not a function.', - stackStartFn: this.throws, - }); - } - - try { - blockFn(); - } catch (error) { - let validation = validateException(error, expected, message); - if (validation.result === false) { - throw new AssertionError({ - actual: validation.result, - expected: validation.expected, - message: validation.message, - stackStartFn: this.throws, - }); - } - - return; - } - - throw new AssertionError({ - actual: blockFn, - expected: expected, - message: 'Function passed to `assert.throws` did not throw an exception!', - stackStartFn: this.throws, - }); - } - async rejects(promise, expectedInput, assertionMessage) { - this._incrementAssertionCount(); - let [expected, message] = validateExpectedExceptionArgs(expectedInput, assertionMessage, 'rejects'); - let then = promise && promise.then; - if (typeof then !== 'function') { - throw new AssertionError({ - actual: promise, - expected: expected, - message: 'The value provided to `assert.rejects` was not a promise!', - stackStartFn: this.rejects, - }); - } - - try { - await promise; - throw new AssertionError({ - actual: promise, - expected: expected, - message: 'The promise returned by the `assert.rejects` callback did not reject!', - stackStartFn: this.rejects, - }); - } catch (error) { - let validation = validateException(error, expected, message); - if (validation.result === false) { - throw new AssertionError({ - actual: validation.result, - expected: validation.expected, - message: validation.message, - stackStartFn: this.rejects, - }); - } - } - } -}; - -function defaultMessage(actual, description, expected) { - return ` - -${inspect(actual)} - -${description} - -${inspect(expected)}` -} - -function inspect(value) { - return util.inspect(value, { depth: 10, colors: true, compact: false }); -} diff --git a/shims/deno/index.js b/shims/deno/index.js index bfc4f91..0e344ea 100644 --- a/shims/deno/index.js +++ b/shims/deno/index.js @@ -1,238 +1,27 @@ -import { - beforeAll, - afterAll, - describe, - it, -} from "https://deno.land/std@0.192.0/testing/bdd.ts"; -import Assert from './assert.js'; - -class TestContext { - name; - - #module; - get module() { - return this.#module; - } - set module(value) { - this.#module = value; - } - - #assert; - get assert() { - return this.#assert; - } - set assert(value) { - this.#assert = value; - } - - #timeout; - get timeout() { - return this.#timeout; - } - set timeout(value) { - this.#timeout = value; - } - - #steps = []; - get steps() { - return this.#steps; - } - set steps(value) { - this.#steps = value; - } - - #expectedAssertionCount; - get expectedAssertionCount() { - return this.#expectedAssertionCount; - } - set expectedAssertionCount(value) { - this.#expectedAssertionCount = value; - } - - #totalExecutedAssertions = 0; - get totalExecutedAssertions() { - return this.#totalExecutedAssertions; - } - set totalExecutedAssertions(value) { - this.#totalExecutedAssertions = value; - } - - constructor(name, moduleContext) { - if (moduleContext) { - this.name = `${moduleContext.name} | ${name}`; - this.module = moduleContext; - this.module.tests.push(this); - this.assert = new Assert(moduleContext, this); - } - } - - finish() { - if (this.totalExecutedAssertions === 0) { - this.assert.pushResult({ - result: false, - actual: this.totalExecutedAssertions, - expected: '> 0', - message: `Expected at least one assertion to be run for test: ${this.name}`, - }); - } else if (this.steps.length > 0) { - this.assert.pushResult({ - result: false, - actual: this.steps, - expected: [], - message: `Expected assert.verifySteps() to be called before end of test after using assert.step(). Unverified steps: ${this.steps.join(', ')}`, - }); - } else if (this.expectedAssertionCount && this.expectedAssertionCount !== this.totalExecutedAssertions) { - this.assert.pushResult({ - result: false, - actual: this.totalExecutedAssertions, - expected: this.expectedAssertionCount, - message: `Expected ${this.expectedAssertionCount} assertions, but ${this.totalExecutedAssertions} were run for test: ${this.name}`, - }); - } - } -} - -class ModuleContext extends TestContext { - static currentModuleChain = []; - - static get lastModule() { - return this.currentModuleChain[this.currentModuleChain.length - 1]; - } - - #tests = []; - get tests() { - return this.#tests; - } - - #beforeEachHooks = []; - get beforeEachHooks() { - return this.#beforeEachHooks; - } - - #afterEachHooks = []; - get afterEachHooks() { - return this.#afterEachHooks; - } - - #moduleChain = []; - get moduleChain() { - return this.#moduleChain; - } - set moduleChain(value) { - this.#moduleChain = value; - } - - constructor(name) { - super(name); - - let parentModule = ModuleContext.currentModuleChain[ModuleContext.currentModuleChain.length - 1]; - - ModuleContext.currentModuleChain.push(this); - - this.moduleChain = ModuleContext.currentModuleChain.slice(0); - this.name = parentModule ? `${parentModule.name} > ${name}` : name; - this.assert = new Assert(this); +import { AssertionError as DenoAssertionError } from "https://deno.land/std@0.192.0/testing/asserts.ts"; +import '../../vendor/qunit.js'; +import Assert from '../shared/assert.js'; +import ModuleContext from '../shared/module-context.js'; +import TestContext from '../shared/test-context.js'; +import Module from './module.js'; +import Test from './test.js'; + +export class AssertionError extends DenoAssertionError { + constructor(object) { + super(object.message); } } -export const module = (moduleName, runtimeOptions, moduleContent) => { - let targetRuntimeOptions = moduleContent ? runtimeOptions : {}; - let targetModuleContent = moduleContent ? moduleContent : runtimeOptions; - let moduleContext = new ModuleContext(moduleName); - - return describe(moduleName, { concurrency: true, ...targetRuntimeOptions }, async function () { - let beforeHooks = []; - let afterHooks = []; - - beforeAll(async function () { - Object.assign(moduleContext, moduleContext.moduleChain.reduce((result, module) => { - const { name, ...moduleWithoutName } = module; - - return Object.assign(result, moduleWithoutName, { - steps: result.steps.concat(module.steps), - expectedAssertionCount: module.expectedAssertionCount - ? module.expectedAssertionCount - : result.expectedAssertionCount - }); - }, { steps: [], expectedAssertionCount: undefined })); - - for (let hook of beforeHooks) { - await hook.call(moduleContext, moduleContext.assert); - } +Assert.QUnit = QUnit; +Assert.AssertionError = AssertionError; +ModuleContext.Assert = Assert; +TestContext.Assert = Assert; - moduleContext.tests.forEach((testContext) => { - const { name, ...moduleContextWithoutName } = moduleContext; +Object.freeze(Assert); +Object.freeze(ModuleContext); +Object.freeze(TestContext); - Object.assign(testContext, moduleContextWithoutName, { - steps: moduleContext.steps, - totalExecutedAssertions: moduleContext.totalExecutedAssertions, - expectedAssertionCount: moduleContext.expectedAssertionCount, - }); - }); - }); - afterAll(async () => { - for (const assert of moduleContext.tests.map(testContext => testContext.assert)) { - await assert.waitForAsyncOps(); - } - - let targetContext = moduleContext.tests[moduleContext.tests.length - 1]; - for (let j = afterHooks.length - 1; j >= 0; j--) { - await afterHooks[j].call(targetContext, targetContext.assert); - } - - moduleContext.tests.forEach(testContext => testContext.finish()); - }); - - targetModuleContent.call(moduleContext, { - before(beforeFn) { - return beforeHooks.push(beforeFn); - }, - beforeEach(beforeEachFn) { - return moduleContext.beforeEachHooks.push(beforeEachFn); - }, - afterEach(afterEachFn) { - return moduleContext.afterEachHooks.push(afterEachFn); - }, - after(afterFn) { - return afterHooks.push(afterFn); - } - }, { moduleName, options: runtimeOptions }); - - ModuleContext.currentModuleChain.pop(); - }); -} - -export const test = (testName, runtimeOptions, testContent) => { - let moduleContext = ModuleContext.lastModule; - if (!moduleContext) { - throw new Error(`Test '${testName}' called outside of module context.`); - } - - let targetRuntimeOptions = testContent ? runtimeOptions : {}; - let targetTestContent = testContent ? testContent : runtimeOptions; - let context = new TestContext(testName, moduleContext); - - return it(testName, { concurrency: true, ...targetRuntimeOptions }, async function () { - let result; - for (let module of context.module.moduleChain) { - for (let hook of module.beforeEachHooks) { - await hook.call(context, context.assert); - } - } - - result = await targetTestContent.call(context, context.assert, { testName, options: runtimeOptions }); - - await context.assert.waitForAsyncOps(); - - for (let i = context.module.moduleChain.length - 1; i >= 0; i--) { - let module = context.module.moduleChain[i]; - for (let j = module.afterEachHooks.length - 1; j >= 0; j--) { - await module.afterEachHooks[j].call(context, context.assert); - } - } - - return result; - }); -} +export const module = Module; +export const test = Test; -export default { module, test, config: {} }; +export default { AssertionError: Assert.AssertionError, module, test, config: {} }; diff --git a/shims/deno/module.js b/shims/deno/module.js new file mode 100644 index 0000000..45b934f --- /dev/null +++ b/shims/deno/module.js @@ -0,0 +1,71 @@ +import { describe, beforeAll, afterAll } from "https://deno.land/std@0.192.0/testing/bdd.ts"; +import ModuleContext from '../shared/module-context.js'; + +// NOTE: node.js beforeEach & afterEach is buggy because the TestContext it has is NOT correct reference when called, it gets the last context +// NOTE: QUnit expect() logic is buggy in nested modules +// NOTE: after gets the last direct children test of the module, not last defined context of a module(last defined context is a module) + +export default function module(moduleName, runtimeOptions, moduleContent) { + let targetRuntimeOptions = moduleContent ? runtimeOptions : {}; + let targetModuleContent = moduleContent ? moduleContent : runtimeOptions; + let moduleContext = new ModuleContext(moduleName); + + return describe(moduleName, { concurrency: true, ...targetRuntimeOptions }, async function () { + let beforeHooks = []; + let afterHooks = []; + + beforeAll(async function () { + Object.assign(moduleContext.context, moduleContext.moduleChain.reduce((result, module) => { + return Object.assign(result, module.context, { + steps: result.steps.concat(module.context.steps), + expectedAssertionCount: module.context.expectedAssertionCount + ? module.context.expectedAssertionCount + : result.expectedAssertionCount + }); + }, { steps: [], expectedAssertionCount: undefined })); + + for (let hook of beforeHooks) { + await hook.call(moduleContext.context, moduleContext.assert); + } + + for (let i = 0, len = moduleContext.tests.length; i < len; i++) { + Object.assign(moduleContext.tests[i], moduleContext.context, { + steps: moduleContext.context.steps, + totalExecutedAssertions: moduleContext.context.totalExecutedAssertions, + expectedAssertionCount: moduleContext.context.expectedAssertionCount, + }); + } + }); + afterAll(async () => { + for (const assert of moduleContext.tests.map(testContext => testContext.assert)) { + await assert.waitForAsyncOps(); + } + + let targetContext = moduleContext.tests[moduleContext.tests.length - 1]; + for (let j = afterHooks.length - 1; j >= 0; j--) { + await afterHooks[j].call(targetContext, targetContext.assert); + } + + for (let i = 0, len = moduleContext.tests.length; i < len; i++) { + moduleContext.tests[i].finish(); + } + }); + + targetModuleContent.call(moduleContext.context, { + before(beforeFn) { + beforeHooks[beforeHooks.length] = beforeFn; + }, + beforeEach(beforeEachFn) { + moduleContext.beforeEachHooks[moduleContext.beforeEachHooks.length] = beforeEachFn; + }, + afterEach(afterEachFn) { + moduleContext.afterEachHooks[moduleContext.afterEachHooks.length] = afterEachFn; + }, + after(afterFn) { + afterHooks[afterHooks.length] = afterFn; + } + }, { moduleName, options: runtimeOptions }); + + ModuleContext.currentModuleChain.pop(); + }); +} diff --git a/shims/deno/test.js b/shims/deno/test.js new file mode 100644 index 0000000..29c5939 --- /dev/null +++ b/shims/deno/test.js @@ -0,0 +1,37 @@ +import { it } from "https://deno.land/std@0.192.0/testing/bdd.ts"; +import TestContext from '../shared/test-context.js'; +import ModuleContext from '../shared/module-context.js'; + +export default function test(testName, runtimeOptions, testContent) { + let moduleContext = ModuleContext.lastModule; + if (!moduleContext) { + throw new Error(`Test '${testName}' called outside of module context.`); + } + + let targetRuntimeOptions = testContent ? runtimeOptions : {}; + let targetTestContent = testContent ? testContent : runtimeOptions; + let context = new TestContext(testName, moduleContext); + + return it(testName, { concurrency: true, ...targetRuntimeOptions }, async function () { + let result; + for (let module of context.module.moduleChain) { + for (let hook of module.beforeEachHooks) { + await hook.call(context, context.assert); + } + } + + result = await targetTestContent.call(context, context.assert, { testName, options: runtimeOptions }); + + await context.assert.waitForAsyncOps(); + + for (let i = context.module.moduleChain.length - 1; i >= 0; i--) { + let module = context.module.moduleChain[i]; + for (let j = module.afterEachHooks.length - 1; j >= 0; j--) { + await module.afterEachHooks[j].call(context, context.assert); + } + } + + return result; + }); +} + diff --git a/shims/node/index.js b/shims/node/index.js index d8db9a1..f233120 100644 --- a/shims/node/index.js +++ b/shims/node/index.js @@ -1,237 +1,22 @@ -import { describe, it, before as beforeAll, after as afterAll } from 'node:test'; -import Assert from './assert.js'; +import { AssertionError } from 'node:assert'; +import QUnit from '../../vendor/qunit.js'; +import Assert from '../shared/assert.js'; +import ModuleContext from '../shared/module-context.js'; +import TestContext from '../shared/test-context.js'; +import Module from './module.js'; +import Test from './test.js'; -// NOTE: node.js beforeEach & afterEach is buggy because the TestContext it has is NOT correct reference when called, it gets the last context -// NOTE: QUnit expect() logic is buggy in nested modules -// NOTE: after gets the last direct children test of the module, not last defined context of a module(last defined context is a module) +Assert.QUnit = QUnit; +Assert.AssertionError = AssertionError; -class TestContext { - name; +ModuleContext.Assert = Assert; +TestContext.Assert = Assert; - #module; - get module() { - return this.#module; - } - set module(value) { - this.#module = value; - } +Object.freeze(Assert); +Object.freeze(ModuleContext); +Object.freeze(TestContext); - #assert; - get assert() { - return this.#assert; - } - set assert(value) { - this.#assert = value; - } +export const module = Module; +export const test = Test; - #timeout; - get timeout() { - return this.#timeout; - } - set timeout(value) { - this.#timeout = value; - } - - #steps = []; - get steps() { - return this.#steps; - } - set steps(value) { - this.#steps = value; - } - - #expectedAssertionCount; - get expectedAssertionCount() { - return this.#expectedAssertionCount; - } - set expectedAssertionCount(value) { - this.#expectedAssertionCount = value; - } - - #totalExecutedAssertions = 0; - get totalExecutedAssertions() { - return this.#totalExecutedAssertions; - } - set totalExecutedAssertions(value) { - this.#totalExecutedAssertions = value; - } - - constructor(name, moduleContext) { - if (moduleContext) { - this.name = `${moduleContext.name} | ${name}`; - this.module = moduleContext; - this.module.tests.push(this); - this.assert = new Assert(moduleContext, this); - } - } - - finish() { - if (this.totalExecutedAssertions === 0) { - this.assert.pushResult({ - result: false, - actual: this.totalExecutedAssertions, - expected: '> 0', - message: `Expected at least one assertion to be run for test: ${this.name}`, - }); - } else if (this.steps.length > 0) { - this.assert.pushResult({ - result: false, - actual: this.steps, - expected: [], - message: `Expected assert.verifySteps() to be called before end of test after using assert.step(). Unverified steps: ${this.steps.join(', ')}`, - }); - } else if (this.expectedAssertionCount && this.expectedAssertionCount !== this.totalExecutedAssertions) { - this.assert.pushResult({ - result: false, - actual: this.totalExecutedAssertions, - expected: this.expectedAssertionCount, - message: `Expected ${this.expectedAssertionCount} assertions, but ${this.totalExecutedAssertions} were run for test: ${this.name}`, - }); - } - } -} - -class ModuleContext extends TestContext { - static currentModuleChain = []; - - static get lastModule() { - return this.currentModuleChain[this.currentModuleChain.length - 1]; - } - - #tests = []; - get tests() { - return this.#tests; - } - - #beforeEachHooks = []; - get beforeEachHooks() { - return this.#beforeEachHooks; - } - - #afterEachHooks = []; - get afterEachHooks() { - return this.#afterEachHooks; - } - - #moduleChain = []; - get moduleChain() { - return this.#moduleChain; - } - set moduleChain(value) { - this.#moduleChain = value; - } - - constructor(name) { - super(name); - - let parentModule = ModuleContext.currentModuleChain[ModuleContext.currentModuleChain.length - 1]; - - ModuleContext.currentModuleChain.push(this); - - this.moduleChain = ModuleContext.currentModuleChain.slice(0); - this.name = parentModule ? `${parentModule.name} > ${name}` : name; - this.assert = new Assert(this); - } -} - -export const module = (moduleName, runtimeOptions, moduleContent) => { - let targetRuntimeOptions = moduleContent ? runtimeOptions : {}; - let targetModuleContent = moduleContent ? moduleContent : runtimeOptions; - let moduleContext = new ModuleContext(moduleName); - - return describe(moduleName, { concurrency: true, ...targetRuntimeOptions }, async function () { - let beforeHooks = []; - let afterHooks = []; - - beforeAll(async function () { - Object.assign(moduleContext, moduleContext.moduleChain.reduce((result, module) => { - const { name, ...moduleWithoutName } = module; - - return Object.assign(result, moduleWithoutName, { - steps: result.steps.concat(module.steps), - expectedAssertionCount: module.expectedAssertionCount - ? module.expectedAssertionCount - : result.expectedAssertionCount - }); - }, { steps: [], expectedAssertionCount: undefined })); - - for (let hook of beforeHooks) { - await hook.call(moduleContext, moduleContext.assert); - } - - moduleContext.tests.forEach((testContext) => { - const { name, ...moduleContextWithoutName } = moduleContext; - - Object.assign(testContext, moduleContextWithoutName, { - steps: moduleContext.steps, - totalExecutedAssertions: moduleContext.totalExecutedAssertions, - expectedAssertionCount: moduleContext.expectedAssertionCount, - }); - }); - }); - afterAll(async () => { - for (const assert of moduleContext.tests.map(testContext => testContext.assert)) { - await assert.waitForAsyncOps(); - } - - let targetContext = moduleContext.tests[moduleContext.tests.length - 1]; - for (let j = afterHooks.length - 1; j >= 0; j--) { - await afterHooks[j].call(targetContext, targetContext.assert); - } - - moduleContext.tests.forEach(testContext => testContext.finish()); - }); - - targetModuleContent.call(moduleContext, { - before(beforeFn) { - return beforeHooks.push(beforeFn); - }, - beforeEach(beforeEachFn) { - return moduleContext.beforeEachHooks.push(beforeEachFn); - }, - afterEach(afterEachFn) { - return moduleContext.afterEachHooks.push(afterEachFn); - }, - after(afterFn) { - return afterHooks.push(afterFn); - } - }, { moduleName, options: runtimeOptions }); - - ModuleContext.currentModuleChain.pop(); - }); -} - -export const test = (testName, runtimeOptions, testContent) => { - let moduleContext = ModuleContext.lastModule; - if (!moduleContext) { - throw new Error(`Test '${testName}' called outside of module context.`); - } - - let targetRuntimeOptions = testContent ? runtimeOptions : {}; - let targetTestContent = testContent ? testContent : runtimeOptions; - let context = new TestContext(testName, moduleContext); - - return it(testName, { concurrency: true, ...targetRuntimeOptions }, async function () { - let result; - for (let module of context.module.moduleChain) { - for (let hook of module.beforeEachHooks) { - await hook.call(context, context.assert); - } - } - - result = await targetTestContent.call(context, context.assert, { testName, options: runtimeOptions }); - - await context.assert.waitForAsyncOps(); - - for (let i = context.module.moduleChain.length - 1; i >= 0; i--) { - let module = context.module.moduleChain[i]; - for (let j = module.afterEachHooks.length - 1; j >= 0; j--) { - await module.afterEachHooks[j].call(context, context.assert); - } - } - - return result; - }); -} - -export default { module, test, config: {} }; +export default { AssertionError: Assert.AssertionError, module, test, config: {} }; diff --git a/shims/node/module.js b/shims/node/module.js new file mode 100644 index 0000000..c4fe0a2 --- /dev/null +++ b/shims/node/module.js @@ -0,0 +1,71 @@ +import { describe, before as beforeAll, after as afterAll } from 'node:test'; +import ModuleContext from '../shared/module-context.js'; + +// NOTE: node.js beforeEach & afterEach is buggy because the TestContext it has is NOT correct reference when called, it gets the last context +// NOTE: QUnit expect() logic is buggy in nested modules +// NOTE: after gets the last direct children test of the module, not last defined context of a module(last defined context is a module) + +export default function module(moduleName, runtimeOptions, moduleContent) { + let targetRuntimeOptions = moduleContent ? runtimeOptions : {}; + let targetModuleContent = moduleContent ? moduleContent : runtimeOptions; + let moduleContext = new ModuleContext(moduleName); + + return describe(moduleName, { concurrency: true, ...targetRuntimeOptions }, async function () { + let beforeHooks = []; + let afterHooks = []; + + beforeAll(async function () { + Object.assign(moduleContext.context, moduleContext.moduleChain.reduce((result, module) => { + return Object.assign(result, module.context, { + steps: result.steps.concat(module.context.steps), + expectedAssertionCount: module.context.expectedAssertionCount + ? module.context.expectedAssertionCount + : result.expectedAssertionCount + }); + }, { steps: [], expectedAssertionCount: undefined })); + + for (let hook of beforeHooks) { + await hook.call(moduleContext.context, moduleContext.assert); + } + + for (let i = 0, len = moduleContext.tests.length; i < len; i++) { + Object.assign(moduleContext.tests[i], moduleContext.context, { + steps: moduleContext.context.steps, + totalExecutedAssertions: moduleContext.context.totalExecutedAssertions, + expectedAssertionCount: moduleContext.context.expectedAssertionCount, + }); + } + }); + afterAll(async () => { + for (const assert of moduleContext.tests.map(testContext => testContext.assert)) { + await assert.waitForAsyncOps(); + } + + let targetContext = moduleContext.tests[moduleContext.tests.length - 1]; + for (let j = afterHooks.length - 1; j >= 0; j--) { + await afterHooks[j].call(targetContext, targetContext.assert); + } + + for (let i = 0, len = moduleContext.tests.length; i < len; i++) { + moduleContext.tests[i].finish(); + } + }); + + targetModuleContent.call(moduleContext.context, { + before(beforeFn) { + beforeHooks[beforeHooks.length] = beforeFn; + }, + beforeEach(beforeEachFn) { + moduleContext.beforeEachHooks[moduleContext.beforeEachHooks.length] = beforeEachFn; + }, + afterEach(afterEachFn) { + moduleContext.afterEachHooks[moduleContext.afterEachHooks.length] = afterEachFn; + }, + after(afterFn) { + afterHooks[afterHooks.length] = afterFn; + } + }, { moduleName, options: runtimeOptions }); + + ModuleContext.currentModuleChain.pop(); + }); +} diff --git a/shims/node/test.js b/shims/node/test.js new file mode 100644 index 0000000..d5b0b53 --- /dev/null +++ b/shims/node/test.js @@ -0,0 +1,37 @@ +import { it } from 'node:test'; +import TestContext from '../shared/test-context.js'; +import ModuleContext from '../shared/module-context.js'; + +export default function test(testName, runtimeOptions, testContent) { + let moduleContext = ModuleContext.lastModule; + if (!moduleContext) { + throw new Error(`Test '${testName}' called outside of module context.`); + } + + let targetRuntimeOptions = testContent ? runtimeOptions : {}; + let targetTestContent = testContent ? testContent : runtimeOptions; + let context = new TestContext(testName, moduleContext); + + return it(testName, { concurrency: true, ...targetRuntimeOptions }, async function () { + let result; + for (let module of context.module.moduleChain) { + for (let hook of module.beforeEachHooks) { + await hook.call(context, context.assert); + } + } + + result = await targetTestContent.call(context, context.assert, { testName, options: runtimeOptions }); + + await context.assert.waitForAsyncOps(); + + for (let i = context.module.moduleChain.length - 1; i >= 0; i--) { + let module = context.module.moduleChain[i]; + for (let j = module.afterEachHooks.length - 1; j >= 0; j--) { + await module.afterEachHooks[j].call(context, context.assert); + } + } + + return result; + }); +} + diff --git a/shims/node/assert.js b/shims/shared/assert.js similarity index 86% rename from shims/node/assert.js rename to shims/shared/assert.js index e2c0d85..0ad11f1 100644 --- a/shims/node/assert.js +++ b/shims/shared/assert.js @@ -1,23 +1,19 @@ -import QUnit from '../../vendor/qunit.js'; +import '../../vendor/qunit.js'; import { objectValues, objectValuesSubset, validateExpectedExceptionArgs, validateException } from '../shared/index.js'; -import assert, { AssertionError as _AssertionError } from 'node:assert'; import util from 'node:util'; -export const AssertionError = _AssertionError; - // More: contexts needed for timeout -// NOTE: QUnit API provides assert on hooks, which makes it hard to make it concurrent // NOTE: Another approach for a global report Make this._assertions.set(this.currentTest, (this._assertions.get(this.currentTest) || 0) + 1); for pushResult // NOTE: This should *always* be a singleton(?), passed around as an argument for hooks. Seems difficult with concurrency. Singleton needs to be a concurrent data structure. export default class Assert { - AssertionError = _AssertionError; - - #asyncOps = []; + static QUnit; + static AssertionError; constructor(module, test) { - this.test = test || module; + this.test = test || module.context; } + _incrementAssertionCount() { this.test.totalExecutedAssertions++; } @@ -61,17 +57,17 @@ export default class Assert { let resolveFn; let done = new Promise(resolve => { resolveFn = resolve; }); - this.#asyncOps.push(done); + this.test.asyncOps.push(done); return () => { resolveFn(); }; } async waitForAsyncOps() { - return Promise.all(this.#asyncOps); + return Promise.all(this.test.asyncOps); } pushResult(resultInfo = {}) { this._incrementAssertionCount(); if (!resultInfo.result) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual: resultInfo.actual, expected: resultInfo.expected, message: resultInfo.message || 'Custom assertion failed!', @@ -84,7 +80,7 @@ export default class Assert { ok(state, message) { this._incrementAssertionCount(); if (!state) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual: state, expected: true, message: message || `Expected argument to be truthy, it was: ${inspect(state)}`, @@ -95,7 +91,7 @@ export default class Assert { notOk(state, message) { this._incrementAssertionCount(); if (state) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual: state, expected: false, message: message || `Expected argument to be falsy, it was: ${inspect(state)}`, @@ -106,7 +102,7 @@ export default class Assert { true(state, message) { this._incrementAssertionCount(); if (state !== true) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual: state, expected: true, message: message || `Expected argument to be true, it was: ${inspect(state)}`, @@ -117,7 +113,7 @@ export default class Assert { false(state, message) { this._incrementAssertionCount(); if (state !== false) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual: state, expected: true, message: message || `Expected argument to be false, it was: ${inspect(state)}`, @@ -128,7 +124,7 @@ export default class Assert { equal(actual, expected, message) { this._incrementAssertionCount(); if (actual != expected) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual, expected, message: message || `Expected: ${defaultMessage(actual, 'should equal to:', expected)}`, @@ -140,7 +136,7 @@ export default class Assert { notEqual(actual, expected, message) { this._incrementAssertionCount(); if (actual == expected) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual, expected, operator: '!=', @@ -153,8 +149,8 @@ export default class Assert { this._incrementAssertionCount(); let targetActual = objectValues(actual); let targetExpected = objectValues(expected); - if (!QUnit.equiv(targetActual, targetExpected)) { - throw new AssertionError({ + if (!Assert.QUnit.equiv(targetActual, targetExpected)) { + throw new Assert.AssertionError({ actual: targetActual, expected: targetExpected, message: message || `Expected properties to be propEqual: ${defaultMessage(targetActual, 'should propEqual to:', targetExpected)}`, @@ -166,8 +162,8 @@ export default class Assert { this._incrementAssertionCount(); let targetActual = objectValues(actual); let targetExpected = objectValues(expected); - if (QUnit.equiv(targetActual, targetExpected)) { - throw new AssertionError({ + if (Assert.QUnit.equiv(targetActual, targetExpected)) { + throw new Assert.AssertionError({ actual: targetActual, expected: targetExpected, message: message || `Expected properties to NOT be propEqual: ${defaultMessage(targetActual, 'should notPropEqual to:', targetExpected)}`, @@ -179,8 +175,8 @@ export default class Assert { this._incrementAssertionCount(); let targetActual = objectValuesSubset(actual, expected); let targetExpected = objectValues(expected, false); - if (!QUnit.equiv(targetActual, targetExpected)) { - throw new AssertionError({ + if (!Assert.QUnit.equiv(targetActual, targetExpected)) { + throw new Assert.AssertionError({ actual: targetActual, expected: targetExpected, message: message || `propContains assertion fail on: ${defaultMessage(targetActual, 'should propContains to:', targetExpected)}`, @@ -192,8 +188,8 @@ export default class Assert { this._incrementAssertionCount(); let targetActual = objectValuesSubset(actual, expected); let targetExpected = objectValues(expected); - if (QUnit.equiv(targetActual, targetExpected)) { - throw new AssertionError({ + if (Assert.QUnit.equiv(targetActual, targetExpected)) { + throw new Assert.AssertionError({ actual: targetActual, expected: targetExpected, message: message || `notPropContains assertion fail on: ${defaultMessage(targetActual, 'should notPropContains of:', targetExpected)}`, @@ -203,8 +199,8 @@ export default class Assert { } deepEqual(actual, expected, message) { this._incrementAssertionCount(); - if (!QUnit.equiv(actual, expected)) { - throw new AssertionError({ + if (!Assert.QUnit.equiv(actual, expected)) { + throw new Assert.AssertionError({ actual, expected, message: message || `Expected values to be deepEqual: ${defaultMessage(actual, 'should deepEqual to:', expected)}`, @@ -215,8 +211,8 @@ export default class Assert { } notDeepEqual(actual, expected, message) { this._incrementAssertionCount(); - if (QUnit.equiv(actual, expected)) { - throw new AssertionError({ + if (Assert.QUnit.equiv(actual, expected)) { + throw new Assert.AssertionError({ actual, expected, message: message || `Expected values to be NOT deepEqual: ${defaultMessage(actual, 'should notDeepEqual to:', expected)}`, @@ -228,7 +224,7 @@ export default class Assert { strictEqual(actual, expected, message) { this._incrementAssertionCount(); if (actual !== expected) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual, expected, message: message || `Expected: ${defaultMessage(actual, 'is strictEqual to:', expected)}`, @@ -240,7 +236,7 @@ export default class Assert { notStrictEqual(actual, expected, message) { this._incrementAssertionCount(); if (actual === expected) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual, expected, message: message || `Expected: ${defaultMessage(actual, 'is notStrictEqual to:', expected)}`, @@ -253,7 +249,7 @@ export default class Assert { this?._incrementAssertionCount(); let [expected, message] = validateExpectedExceptionArgs(expectedInput, assertionMessage, 'rejects'); if (typeof blockFn !== 'function') { - throw new AssertionError({ + throw new Assert.AssertionError({ actual: blockFn, expected: Function, message: 'The value provided to `assert.throws` was not a function.', @@ -266,7 +262,7 @@ export default class Assert { } catch (error) { let validation = validateException(error, expected, message); if (validation.result === false) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual: validation.result, expected: validation.expected, message: validation.message, @@ -277,7 +273,7 @@ export default class Assert { return; } - throw new AssertionError({ + throw new Assert.AssertionError({ actual: blockFn, expected: expected, message: 'Function passed to `assert.throws` did not throw an exception!', @@ -289,7 +285,7 @@ export default class Assert { let [expected, message] = validateExpectedExceptionArgs(expectedInput, assertionMessage, 'rejects'); let then = promise && promise.then; if (typeof then !== 'function') { - throw new AssertionError({ + throw new Assert.AssertionError({ actual: promise, expected: expected, message: 'The value provided to `assert.rejects` was not a promise!', @@ -299,7 +295,7 @@ export default class Assert { try { await promise; - throw new AssertionError({ + throw new Assert.AssertionError({ actual: promise, expected: expected, message: 'The promise returned by the `assert.rejects` callback did not reject!', @@ -308,7 +304,7 @@ export default class Assert { } catch (error) { let validation = validateException(error, expected, message); if (validation.result === false) { - throw new AssertionError({ + throw new Assert.AssertionError({ actual: validation.result, expected: validation.expected, message: validation.message, diff --git a/shims/shared/module-context.js b/shims/shared/module-context.js new file mode 100644 index 0000000..ade4c00 --- /dev/null +++ b/shims/shared/module-context.js @@ -0,0 +1,47 @@ +import TestContext from './test-context.js'; + +export default class ModuleContext { + static Assert; + static currentModuleChain = []; + + static get lastModule() { + return this.currentModuleChain[this.currentModuleChain.length - 1]; + } + + context = new TestContext(); + + #tests = []; + get tests() { + return this.#tests; + } + + #beforeEachHooks = []; + get beforeEachHooks() { + return this.#beforeEachHooks; + } + + #afterEachHooks = []; + get afterEachHooks() { + return this.#afterEachHooks; + } + + #moduleChain = []; + get moduleChain() { + return this.#moduleChain; + } + set moduleChain(value) { + this.#moduleChain = value; + } + + constructor(name) { + let parentModule = ModuleContext.currentModuleChain[ModuleContext.currentModuleChain.length - 1]; + + ModuleContext.currentModuleChain.push(this); + + this.moduleChain = ModuleContext.currentModuleChain.slice(0); + this.name = parentModule ? `${parentModule.name} > ${name}` : name; + this.assert = new ModuleContext.Assert(this); + + return Object.freeze(this); + } +} diff --git a/shims/shared/test-context.js b/shims/shared/test-context.js new file mode 100644 index 0000000..fe16b81 --- /dev/null +++ b/shims/shared/test-context.js @@ -0,0 +1,102 @@ +export default class TestContext { + static Assert; + + #name; + get name() { + return this.#name; + } + set name(value) { + this.#name = value; + } + + #module; + get module() { + return this.#module; + } + set module(value) { + this.#module = value; + } + + #asyncOps = []; + get asyncOps() { + return this.#asyncOps; + } + set asyncOps(value) { + this.#asyncOps = value; + } + + #assert; + get assert() { + return this.#assert; + } + set assert(value) { + this.#assert = value; + } + + #timeout; + get timeout() { + return this.#timeout; + } + set timeout(value) { + this.#timeout = value; + } + + #steps = []; + get steps() { + return this.#steps; + } + set steps(value) { + this.#steps = value; + } + + #expectedAssertionCount; + get expectedAssertionCount() { + return this.#expectedAssertionCount; + } + set expectedAssertionCount(value) { + this.#expectedAssertionCount = value; + } + + #totalExecutedAssertions = 0; + get totalExecutedAssertions() { + return this.#totalExecutedAssertions; + } + set totalExecutedAssertions(value) { + this.#totalExecutedAssertions = value; + } + + constructor(name, moduleContext) { + if (moduleContext) { + this.name = `${moduleContext.name} | ${name}`; + this.module = moduleContext; + this.module.tests.push(this); + this.assert = new TestContext.Assert(moduleContext, this); + } + } + + finish() { + if (this.totalExecutedAssertions === 0) { + this.assert.pushResult({ + result: false, + actual: this.totalExecutedAssertions, + expected: '> 0', + message: `Expected at least one assertion to be run for test: ${this.name}`, + }); + } else if (this.steps.length > 0) { + this.assert.pushResult({ + result: false, + actual: this.steps, + expected: [], + message: `Expected assert.verifySteps() to be called before end of test after using assert.step(). Unverified steps: ${this.steps.join(', ')}`, + }); + } else if (this.expectedAssertionCount && this.expectedAssertionCount !== this.totalExecutedAssertions) { + this.assert.pushResult({ + result: false, + actual: this.totalExecutedAssertions, + expected: this.expectedAssertionCount, + message: `Expected ${this.expectedAssertionCount} assertions, but ${this.totalExecutedAssertions} were run for test: ${this.name}`, + }); + } + } +} + diff --git a/test/hooks-test.js b/test/hooks-test.js index 140b438..4a9359f 100644 --- a/test/hooks-test.js +++ b/test/hooks-test.js @@ -67,14 +67,14 @@ module('module', function () { module('Test context object', function (hooks) { hooks.beforeEach(function (assert) { - this.name = 'Test context object'; + this.helper = 'Test context object'; var key; var keys = []; for (key in this) { keys.push(key); } - assert.deepEqual(keys, ['name']); + assert.deepEqual(keys, ['helper']); }); test('keys', function (assert) {