From 56add5c8f4257d378207f10df022c9c5d43bdbc8 Mon Sep 17 00:00:00 2001 From: Chapman Pendery Date: Fri, 3 Apr 2026 21:42:41 -0700 Subject: [PATCH 1/2] feat: add lifecycle hooks Signed-off-by: Chapman Pendery --- src/config/config.ts | 3 +- src/runner/runner.ts | 105 +++++++++++++++++++++++++------------------ src/runner/worker.ts | 53 ++++++++++++++++++++-- src/test/suite.ts | 43 +++++++++++++++++- src/test/test.ts | 78 +++++++++++++++++++++++++++++++- src/test/testcase.ts | 2 + test/e2e.test.ts | 77 +++++++++++++++++++++++++++++++ 7 files changed, 310 insertions(+), 51 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index ae54d46..d44566a 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -210,7 +210,8 @@ export declare type TestConfig = { /** * The number of workers to use. Defaults to 50% of the logical cpu cores. If - * there are less tests than requested workers, there will be 1 worker used per test. + * there are less files than requested workers, there will be 1 worker used per file. 1 worker is + * used per file in order to support lifecycle hooks correctly. * * **Usage** * diff --git a/src/runner/runner.ts b/src/runner/runner.ts index c5d0a46..fbcf6b9 100644 --- a/src/runner/runner.ts +++ b/src/runner/runner.ts @@ -62,48 +62,70 @@ const runSuites = async ( reporter: BaseReporter, options: ExecutionOptions, config: Required, - pool: Pool + maxWorkers: number ) => { const { updateSnapshot } = options; const trace = options.trace ?? config.trace; - const tasks: Promise[] = []; - const suites = [...allSuites]; - while (suites.length != 0) { - const suite = suites.shift(); - if (!suite) break; - tasks.push( - ...suite.tests.map(async (test) => { - if (!filteredTestIds.has(test.id)) { - return; + + const groupTestsByFile = (suites: Suite[]): TestCase[][] => { + const fileTests = new Map(); + const visit = (suites: Suite[]) => { + for (const suite of suites) { + for (const test of suite.tests) { + if (!filteredTestIds.has(test.id)) continue; + const file = test.filePath() ?? ""; + if (!fileTests.has(file)) fileTests.set(file, []); + fileTests.get(file)!.push(test); } - for (let i = 0; i < Math.max(0, getRetries()) + 1; i++) { - const testResult = await runTestWorker( - test, - test.sourcePath()!, - { - timeout: getTimeout(), - updateSnapshot, - shellReadyTimeout: getShellReadyTimeout(), - }, - trace, - pool, - reporter, - i, - config.traceFolder - ); - test.results.push(testResult); - reporter.endTest(test, testResult); - if ( - testResult.status == "skipped" || - testResult.status == test.expectedStatus - ) - break; + visit(suite.suites); + } + }; + visit(suites); + return Array.from(fileTests.values()); + }; + + const files = groupTestsByFile(allSuites); + const runNextFile = async (): Promise => { + while (files.length != 0) { + const tests = files.shift(); + if (!tests) break; + const filePool = createWorkerPool(1); + try { + for (const test of tests) { + for (let i = 0; i < Math.max(0, getRetries()) + 1; i++) { + const testResult = await runTestWorker( + test, + test.sourcePath()!, + { + timeout: getTimeout(), + updateSnapshot, + shellReadyTimeout: getShellReadyTimeout(), + }, + trace, + filePool, + reporter, + i, + config.traceFolder + ); + test.results.push(testResult); + reporter.endTest(test, testResult); + if ( + testResult.status == "skipped" || + testResult.status == test.expectedStatus + ) + break; + } } - }) - ); - suites.push(...suite.suites); - } - return Promise.all(tasks); + await filePool.exec("afterAllWorker", []); + } finally { + try { await filePool.terminate(true); } catch { /* empty */ } + } + } + }; + + await Promise.all( + Array.from({ length: Math.min(maxWorkers, files.length) }, () => runNextFile()) + ); }; const checkRuntimeVersion = () => { @@ -202,7 +224,6 @@ export const run = async (options: ExecutionOptions) => { const config = await loadConfig(); const rootSuite = await getRootSuite(config); const reporter = new ListReporter(); - const pool = createWorkerPool(config.workers); const suites = [rootSuite]; while (suites.length != 0) { @@ -276,13 +297,9 @@ export const run = async (options: ExecutionOptions) => { reporter, options, config, - pool + config.workers ); - try { - await pool.terminate(true); - } catch { - /* empty */ - } + const staleSnapshots = await cleanSnapshots(allTests, options); const failures = reporter.end(rootSuite, { obsolete: options.updateSnapshot ? 0 : staleSnapshots, diff --git a/src/runner/worker.ts b/src/runner/worker.ts index ddaf10e..63be039 100644 --- a/src/runner/worker.ts +++ b/src/runner/worker.ts @@ -31,6 +31,8 @@ type WorkerExecutionOptions = { }; const importSet = new Set(); +const activeSuites: Suite[] = []; +const beforeAllExecuted = new Set(); const runTest = async ( testId: string, @@ -42,7 +44,7 @@ const runTest = async ( shellReadyTimeout: number ) => { process.setSourceMapsEnabled(true); - globalThis.suite = testSuite; + globalThis.suite = Suite.from(testSuite); globalThis.tests = globalThis.tests ?? {}; globalThis.__expectState = { updateSnapshot }; if (!importSet.has(importPath)) { @@ -50,6 +52,25 @@ const runTest = async ( importSet.add(importPath); } const test = globalThis.tests[testId]; + + const ancestry = test.suite.parentSuites(); + const { exit, enter } = Suite.computeTransition(activeSuites, ancestry); + + const exitHooks = exit.flatMap((s) => s.afterAllHooks); + for (const hook of exitHooks) { + await Promise.resolve(hook()); + } + activeSuites.length -= exit.length; + + const enterHooks = enter + .filter((s) => !beforeAllExecuted.has(s)) + .flatMap((s) => { beforeAllExecuted.add(s); return s.beforeAllHooks; }); + + for (const hook of enterHooks) { + await Promise.resolve(hook()); + } + activeSuites.push(...enter); + const { shell, rows, columns, env, program } = test.suite.options ?? {}; const traceEmitter = new EventEmitter(); traceEmitter.on("data", (data: string, time: number) => @@ -129,13 +150,28 @@ const runTest = async ( ]); } + const suites = test.suite.parentSuites(); try { + for (const s of suites) { + for (const hook of s.beforeEachHooks) { + await Promise.resolve(hook({ terminal })); + } + } + await Promise.resolve(test.testFunction({ terminal })); } finally { try { - terminal.kill(); - } catch { - // terminal can pre-terminate if program is provided + for (const s of suites) { + for (const hook of s.afterEachHooks) { + await Promise.resolve(hook({ terminal })); + } + } + } finally { + try { + terminal.kill(); + } catch { + // terminal can pre-terminate if program is provided + } } } }; @@ -388,6 +424,14 @@ const testWorker = async ( } }; +const afterAllWorker = async (): Promise => { + const hooks = [...activeSuites].reverse().flatMap((s) => s.afterAllHooks); + for (const hook of hooks) { + await Promise.resolve(hook()); + } + activeSuites.length = 0; +}; + if (!workerpool.isMainThread) { process.on("uncaughtException", () => { // prevent worker crashes from unhandled native errors @@ -397,5 +441,6 @@ if (!workerpool.isMainThread) { }); workerpool.worker({ testWorker: testWorker, + afterAllWorker: afterAllWorker, }); } diff --git a/src/test/suite.ts b/src/test/suite.ts index fb401ef..ad3f91b 100644 --- a/src/test/suite.ts +++ b/src/test/suite.ts @@ -5,7 +5,7 @@ import { glob } from "glob"; import { TestOptions } from "./option.js"; import { TestConfig, ProjectConfig } from "../config/config.js"; -import type { TestCase } from "./testcase.js"; +import type { TestCase, TestFunction, HookFunction } from "./testcase.js"; type SuiteType = "file" | "describe" | "project" | "root"; @@ -13,6 +13,10 @@ export class Suite { suites: Suite[] = []; tests: TestCase[] = []; source?: string; + beforeAllHooks: HookFunction[] = []; + afterAllHooks: HookFunction[] = []; + beforeEachHooks: TestFunction[] = []; + afterEachHooks: TestFunction[] = []; constructor( readonly title: string, @@ -21,6 +25,32 @@ export class Suite { public parentSuite?: Suite ) {} + static from(data: Suite): Suite { + const suite = new Suite(data.title, data.type, data.options); + if (data.parentSuite) { + suite.parentSuite = Suite.from(data.parentSuite); + } + return suite; + } + + static computeTransition( + activeSuites: Suite[], + targetSuites: Suite[] + ): { exit: Suite[]; enter: Suite[] } { + let commonPrefix = 0; + while ( + commonPrefix < activeSuites.length && + commonPrefix < targetSuites.length && + activeSuites[commonPrefix] === targetSuites[commonPrefix] + ) { + commonPrefix++; + } + return { + exit: activeSuites.slice(commonPrefix).reverse(), + enter: targetSuites.slice(commonPrefix), + }; + } + allTests(): TestCase[] { const suitesIterable = [...this.suites]; const tests = []; @@ -32,6 +62,17 @@ export class Suite { return tests; } + parentSuites(): Suite[] { + const suites: Suite[] = [this]; + let current = this.parentSuite; + while (current != null) { + suites.push(current); + current = current.parentSuite; + } + return suites.reverse(); + } + + titlePath(): string[] { const titles = []; let currentSuite = this.parentSuite; diff --git a/src/test/test.ts b/src/test/test.ts index 54faf66..7a1c1b4 100644 --- a/src/test/test.ts +++ b/src/test/test.ts @@ -10,7 +10,7 @@ import { import path from "node:path"; import { Suite, suiteFilePath } from "./suite.js"; -import { TestFunction, TestCase, Location } from "./testcase.js"; +import { TestFunction, TestCase, Location, HookFunction } from "./testcase.js"; export { Shell } from "../terminal/shell.js"; export { Key, MouseKey } from "../terminal/ansi.js"; import { TestOptions } from "./option.js"; @@ -254,6 +254,82 @@ export namespace test { } globalThis.suite.tests.push(test); }; + + /** + * Declares a `beforeAll` hook that is executed once before all tests in the current scope. + * + * **Usage** + * + * ```js + * import { test } from '@microsoft/tui-test'; + * + * test.beforeAll(async () => { + * // Set up shared resources + * }); + * ``` + * + * @param fn The hook function to run before all tests. + */ + export const beforeAll = (fn: HookFunction) => { + globalThis.suite.beforeAllHooks.push(fn); + }; + + /** + * Declares an `afterAll` hook that is executed once after all tests in the current scope. + * + * **Usage** + * + * ```js + * import { test } from '@microsoft/tui-test'; + * + * test.afterAll(async () => { + * // Clean up shared resources + * }); + * ``` + * + * @param fn The hook function to run after all tests. + */ + export const afterAll = (fn: HookFunction) => { + globalThis.suite.afterAllHooks.push(fn); + }; + + /** + * Declares a `beforeEach` hook that is executed before each test in the current scope. + * + * **Usage** + * + * ```js + * import { test } from '@microsoft/tui-test'; + * + * test.beforeEach(async ({ terminal }) => { + * terminal.write('setup command\r'); + * }); + * ``` + * + * @param fn The hook function to run before each test. Receives `{ terminal }`. + */ + export const beforeEach = (fn: TestFunction) => { + globalThis.suite.beforeEachHooks.push(fn); + }; + + /** + * Declares an `afterEach` hook that is executed after each test in the current scope. + * + * **Usage** + * + * ```js + * import { test } from '@microsoft/tui-test'; + * + * test.afterEach(async ({ terminal }) => { + * // Clean up after each test + * }); + * ``` + * + * @param fn The hook function to run after each test. Receives `{ terminal }`. + */ + export const afterEach = (fn: TestFunction) => { + globalThis.suite.afterEachHooks.push(fn); + }; } jestExpect.extend({ diff --git a/src/test/testcase.ts b/src/test/testcase.ts index 1a2f2a0..f5c4d17 100644 --- a/src/test/testcase.ts +++ b/src/test/testcase.ts @@ -14,6 +14,8 @@ export type TestFunction = (args: { terminal: Terminal; }) => void | Promise; +export type HookFunction = () => void | Promise; + export type TestStatus = | "expected" | "unexpected" diff --git a/test/e2e.test.ts b/test/e2e.test.ts index c8e5d4c..36d6c91 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -298,3 +298,80 @@ test.describe("shells", () => { }); }); }); + +let fileBeforeAllCount = 0; + +test.beforeAll(() => { + fileBeforeAllCount++; +}); + +test.describe("lifecycle hooks", () => { + let describeBeforeAllCount = 0; + let beforeEachCount = 0; + let afterEachCount = 0; + + test.beforeAll(() => { + describeBeforeAllCount++; + }); + + test.afterAll(() => {}); + + test.beforeEach(async () => { + beforeEachCount++; + }); + + test.afterEach(async () => { + afterEachCount++; + }); + + test("file-level beforeAll ran once", async () => { + expect(fileBeforeAllCount).toBe(1); + }); + + test("describe-level beforeAll ran once", async () => { + expect(describeBeforeAllCount).toBe(1); + }); + + test("beforeEach runs before each test", async () => { + expect(beforeEachCount).toBeGreaterThanOrEqual(1); + }); + + test("afterEach ran for prior test", async () => { + expect(afterEachCount).toBeGreaterThanOrEqual(1); + }); + + test.describe("nested hooks", () => { + let nestedBeforeAllCount = 0; + let nestedBeforeEachCount = 0; + + test.beforeAll(() => { + nestedBeforeAllCount++; + }); + + test.beforeEach(async () => { + nestedBeforeEachCount++; + }); + + test.afterEach(async () => {}); + + test("file-level beforeAll also visible in nested scope", async () => { + expect(fileBeforeAllCount).toBe(1); + }); + + test("outer describe beforeAll also visible in nested scope", async () => { + expect(describeBeforeAllCount).toBe(1); + }); + + test("nested beforeAll ran once", async () => { + expect(nestedBeforeAllCount).toBe(1); + }); + + test("outer beforeEach also runs for nested tests", async () => { + expect(beforeEachCount).toBeGreaterThanOrEqual(1); + }); + + test("nested beforeEach runs for nested tests", async () => { + expect(nestedBeforeEachCount).toBeGreaterThanOrEqual(1); + }); + }); +}); From 440c1ffd5e4e3b0002a913cbb59aaf1eb576244e Mon Sep 17 00:00:00 2001 From: Chapman Pendery Date: Fri, 3 Apr 2026 21:44:46 -0700 Subject: [PATCH 2/2] fix: style Signed-off-by: Chapman Pendery --- src/runner/runner.ts | 10 ++++++++-- src/runner/worker.ts | 5 ++++- src/test/suite.ts | 1 - 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/runner/runner.ts b/src/runner/runner.ts index fbcf6b9..ce3843e 100644 --- a/src/runner/runner.ts +++ b/src/runner/runner.ts @@ -118,13 +118,19 @@ const runSuites = async ( } await filePool.exec("afterAllWorker", []); } finally { - try { await filePool.terminate(true); } catch { /* empty */ } + try { + await filePool.terminate(true); + } catch { + /* empty */ + } } } }; await Promise.all( - Array.from({ length: Math.min(maxWorkers, files.length) }, () => runNextFile()) + Array.from({ length: Math.min(maxWorkers, files.length) }, () => + runNextFile() + ) ); }; diff --git a/src/runner/worker.ts b/src/runner/worker.ts index 63be039..53bcd33 100644 --- a/src/runner/worker.ts +++ b/src/runner/worker.ts @@ -64,7 +64,10 @@ const runTest = async ( const enterHooks = enter .filter((s) => !beforeAllExecuted.has(s)) - .flatMap((s) => { beforeAllExecuted.add(s); return s.beforeAllHooks; }); + .flatMap((s) => { + beforeAllExecuted.add(s); + return s.beforeAllHooks; + }); for (const hook of enterHooks) { await Promise.resolve(hook()); diff --git a/src/test/suite.ts b/src/test/suite.ts index ad3f91b..544a2e3 100644 --- a/src/test/suite.ts +++ b/src/test/suite.ts @@ -72,7 +72,6 @@ export class Suite { return suites.reverse(); } - titlePath(): string[] { const titles = []; let currentSuite = this.parentSuite;