// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { getAssertionState } from "@std/internal/assertion-state"; import { AssertionError } from "@std/assert/assertion-error"; const assertionState = getAssertionState(); /** The options for creating a test suite with the describe function. */ export interface DescribeDefinition extends Omit { /** The body of the test suite */ fn?: () => void | undefined; /** * The `describe` function returns a `TestSuite` representing the group of tests. * If `describe` is called within another `describe` calls `fn`, the suite will default to that parent `describe` calls returned `TestSuite`. * If `describe` is not called within another `describe` calls `fn`, the suite will default to the `TestSuite` representing the global group of tests. */ suite?: TestSuite; /** Run some shared setup before all of the tests in the suite. */ beforeAll?: | ((this: T) => void | Promise) | ((this: T) => void | Promise)[]; /** Run some shared teardown after all of the tests in the suite. */ afterAll?: | ((this: T) => void | Promise) | ((this: T) => void | Promise)[]; /** Run some shared setup before each test in the suite. */ beforeEach?: | ((this: T) => void | Promise) | ((this: T) => void | Promise)[]; /** Run some shared teardown after each test in the suite. */ afterEach?: | ((this: T) => void | Promise) | ((this: T) => void | Promise)[]; } /** The options for creating an individual test case with the it function. */ export interface ItDefinition extends Omit { /** The body of the test case */ fn: (this: T, t: Deno.TestContext) => void | Promise; /** * The `describe` function returns a `TestSuite` representing the group of tests. * If `it` is called within a `describe` calls `fn`, the suite will default to that parent `describe` calls returned `TestSuite`. * If `it` is not called within a `describe` calls `fn`, the suite will default to the `TestSuite` representing the global group of tests. */ suite?: TestSuite; } /** The names of all the different types of hooks. */ export type HookNames = "beforeAll" | "afterAll" | "beforeEach" | "afterEach"; /** * A group of tests. */ export interface TestSuite { /** The symbol to use for grouping the test suite */ symbol: symbol; } /** * An internal representation of a group of tests. */ export class TestSuiteInternal implements TestSuite { symbol: symbol; protected describe: DescribeDefinition; protected steps: (TestSuiteInternal | ItDefinition)[]; protected hasOnlyStep: boolean; constructor(describe: DescribeDefinition) { this.describe = describe; this.steps = []; this.hasOnlyStep = false; const { suite } = describe; if (suite && !TestSuiteInternal.suites.has(suite.symbol)) { throw new Error( "Cannot construct Test Suite: suite does not represent a registered test suite", ); } const testSuite = suite ? TestSuiteInternal.suites.get(suite.symbol) : TestSuiteInternal.current; this.symbol = Symbol(); TestSuiteInternal.suites.set(this.symbol, this); const { fn } = describe; if (fn) { const temp = TestSuiteInternal.current; TestSuiteInternal.current = this; try { // deno-lint-ignore no-explicit-any const value = fn() as any; if (value instanceof Promise) { throw new Error( 'Returning a Promise from "describe" is not supported: tests must be defined synchronously', ); } } finally { TestSuiteInternal.current = temp; } } if (testSuite) { TestSuiteInternal.addStep(testSuite, this); } else { const { name, ignore, permissions, sanitizeExit, sanitizeOps, sanitizeResources, } = describe; let { only } = describe; if (!ignore && this.hasOnlyStep) { only = true; } const options: Deno.TestDefinition = { name, fn: async (t) => { TestSuiteInternal.runningCount++; try { const context = {} as T; const { beforeAll } = this.describe; if (typeof beforeAll === "function") { await beforeAll.call(context); } else if (beforeAll) { for (const hook of beforeAll) { await hook.call(context); } } try { TestSuiteInternal.active.push(this.symbol); await TestSuiteInternal.run(this, context, t); } finally { TestSuiteInternal.active.pop(); const { afterAll } = this.describe; if (typeof afterAll === "function") { await afterAll.call(context); } else if (afterAll) { for (const hook of afterAll) { await hook.call(context); } } } } finally { TestSuiteInternal.runningCount--; } }, }; if (ignore !== undefined) { options.ignore = ignore; } if (only !== undefined) { options.only = only; } if (permissions !== undefined) { options.permissions = permissions; } if (sanitizeExit !== undefined) { options.sanitizeExit = sanitizeExit; } if (sanitizeOps !== undefined) { options.sanitizeOps = sanitizeOps; } if (sanitizeResources !== undefined) { options.sanitizeResources = sanitizeResources; } TestSuiteInternal.registerTest(options); } } /** Stores how many test suites are executing. */ static runningCount = 0; /** If a test has been registered yet. Block adding global hooks if a test has been registered. */ static started = false; /** A map of all test suites by symbol. */ // deno-lint-ignore no-explicit-any static suites = new Map>(); /** The current test suite being registered. */ // deno-lint-ignore no-explicit-any static current: TestSuiteInternal | null = null; /** The stack of tests that are actively running. */ static active: symbol[] = []; /** This is used internally for testing this module. */ static reset() { TestSuiteInternal.runningCount = 0; TestSuiteInternal.started = false; TestSuiteInternal.current = null; TestSuiteInternal.active = []; } /** This is used internally to register tests. */ static registerTest(options: Deno.TestDefinition) { options = { ...options }; if (options.only === undefined) { delete options.only; } if (options.permissions === undefined) { delete options.permissions; } if (options.ignore === undefined) { delete options.ignore; } if (options.sanitizeExit === undefined) { delete options.sanitizeExit; } if (options.sanitizeOps === undefined) { delete options.sanitizeOps; } if (options.sanitizeResources === undefined) { delete options.sanitizeResources; } Deno.test(options); } /** Updates all steps within top level suite to have ignore set to true if only is not set to true on step. */ static addingOnlyStep(suite: TestSuiteInternal) { if (!suite.hasOnlyStep) { for (let i = 0; i < suite.steps.length; i++) { const step = suite.steps[i]!; if (!(step instanceof TestSuiteInternal) && !step.only) { suite.steps.splice(i--, 1); } } suite.hasOnlyStep = true; } const parentSuite = suite.describe.suite; const parentTestSuite = parentSuite && TestSuiteInternal.suites.get(parentSuite.symbol); if (parentTestSuite) { TestSuiteInternal.addingOnlyStep(parentTestSuite); } } /** This is used internally to add steps to a test suite. */ static addStep( suite: TestSuiteInternal, step: TestSuiteInternal | ItDefinition, ) { if (!suite.hasOnlyStep) { if (step instanceof TestSuiteInternal) { if (step.hasOnlyStep || step.describe.only) { TestSuiteInternal.addingOnlyStep(suite); } } else { if (step.only) TestSuiteInternal.addingOnlyStep(suite); } } if ( !(suite.hasOnlyStep && !(step instanceof TestSuiteInternal) && !step.only) ) { suite.steps.push(step); } } /** This is used internally to add hooks to a test suite. */ static setHook( suite: TestSuiteInternal, name: HookNames, fn: (this: T) => void | Promise, ) { if (suite.describe[name]) { if (typeof suite.describe[name] === "function") { suite.describe[name] = [ suite.describe[name] as ((this: T) => void | Promise), ]; } (suite.describe[name] as ((this: T) => void | Promise)[]).push(fn); } else { suite.describe[name] = fn; } } /** This is used internally to run all steps for a test suite. */ static async run( suite: TestSuiteInternal, context: T, t: Deno.TestContext, ) { const hasOnly = suite.hasOnlyStep || suite.describe.only || false; for (const step of suite.steps) { if ( hasOnly && step instanceof TestSuiteInternal && !(step.hasOnlyStep || step.describe.only || false) ) { continue; } const { name, fn, ignore, permissions, sanitizeExit, sanitizeOps, sanitizeResources, } = step instanceof TestSuiteInternal ? step.describe : step; const options: Deno.TestStepDefinition = { name, fn: async (t) => { if (permissions) { throw new Error( "permissions option not available for nested tests", ); } context = { ...context }; if (step instanceof TestSuiteInternal) { const { beforeAll } = step.describe; if (typeof beforeAll === "function") { await beforeAll.call(context); } else if (beforeAll) { for (const hook of beforeAll) { await hook.call(context); } } try { TestSuiteInternal.active.push(step.symbol); await TestSuiteInternal.run(step, context, t); } finally { TestSuiteInternal.active.pop(); const { afterAll } = step.describe; if (typeof afterAll === "function") { await afterAll.call(context); } else if (afterAll) { for (const hook of afterAll) { await hook.call(context); } } } } else { await TestSuiteInternal.runTest(t, fn!, context); } }, }; if (ignore !== undefined) { options.ignore = ignore; } if (sanitizeExit !== undefined) { options.sanitizeExit = sanitizeExit; } if (sanitizeOps !== undefined) { options.sanitizeOps = sanitizeOps; } if (sanitizeResources !== undefined) { options.sanitizeResources = sanitizeResources; } await t.step(options); } } static async runTest( t: Deno.TestContext, fn: (this: T, t: Deno.TestContext) => void | Promise, context: T, activeIndex = 0, ) { const suite = TestSuiteInternal.active[activeIndex]; const testSuite = suite && TestSuiteInternal.suites.get(suite); if (testSuite) { if (activeIndex === 0) context = { ...context }; const { beforeEach } = testSuite.describe; if (typeof beforeEach === "function") { await beforeEach.call(context); } else if (beforeEach) { for (const hook of beforeEach) { await hook.call(context); } } try { await TestSuiteInternal.runTest(t, fn, context, activeIndex + 1); } finally { const { afterEach } = testSuite.describe; if (typeof afterEach === "function") { await afterEach.call(context); } else if (afterEach) { for (const hook of afterEach) { await hook.call(context); } } } } else { await fn.call(context, t); } if (assertionState.checkAssertionErrorState()) { throw new AssertionError( "Expected at least one assertion to be called but received none", ); } if (assertionState.checkAssertionCountSatisfied()) { throw new AssertionError( `Expected at least ${assertionState.assertionCount} assertion to be called, ` + `but received ${assertionState.assertionTriggeredCount}`, ); } assertionState.resetAssertionState(); } }