From cf4e5c33e883c960cab240b3fedf1d193002a2f8 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 19 Mar 2026 15:38:02 +0000 Subject: [PATCH] feat(tstest): add support for package.json before scripts during test execution --- changelog.md | 7 +++ ts/00_commitinfo_data.ts | 2 +- ts/tstest.classes.beforescripts.ts | 86 ++++++++++++++++++++++++++++++ ts/tstest.classes.tstest.ts | 70 +++++++++++++++++++++++- ts/tstest.logging.ts | 34 ++++++++++++ 5 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 ts/tstest.classes.beforescripts.ts diff --git a/changelog.md b/changelog.md index 9c87195..5587d62 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-19 - 3.5.0 - feat(tstest) +add support for package.json before scripts during test execution + +- load test:before or test:before:once once per test run from package.json scripts +- run test:before:testfile before each test file execution and pass TSTEST_FILE and TSTEST_RUNTIME environment variables +- log before-script lifecycle events and abort or skip execution when setup scripts fail + ## 2026-03-18 - 3.4.0 - feat(tapbundle,deno) replace smarts3 test tooling with smartstorage and pre-resolve Deno test dependencies diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4374aef..7443d51 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tstest', - version: '3.4.0', + version: '3.5.0', description: 'A powerful, modern test runner for TypeScript with multi-runtime support (Node.js, Deno, Bun, Chromium) and a batteries-included test framework.' } diff --git a/ts/tstest.classes.beforescripts.ts b/ts/tstest.classes.beforescripts.ts new file mode 100644 index 0000000..fbfcda4 --- /dev/null +++ b/ts/tstest.classes.beforescripts.ts @@ -0,0 +1,86 @@ +import * as plugins from './tstest.plugins.js'; +import type { TsTestLogger } from './tstest.logging.js'; +import type { Smartshell } from '@push.rocks/smartshell'; + +export interface IBeforeScripts { + /** The "test:before" or "test:before:once" script command, or null if not defined */ + beforeOnce: string | null; + /** The "test:before:testfile" script command, or null if not defined */ + beforeTestfile: string | null; +} + +/** + * Load before-script commands from the project's package.json scripts section. + */ +export function loadBeforeScripts(cwd: string): IBeforeScripts { + const result: IBeforeScripts = { beforeOnce: null, beforeTestfile: null }; + + try { + const packageJsonPath = plugins.path.join(cwd, 'package.json'); + const packageJson = JSON.parse(plugins.fs.readFileSync(packageJsonPath, 'utf8')); + const scripts = packageJson?.scripts; + if (!scripts) return result; + + // test:before takes precedence over test:before:once (they are aliases) + if (scripts['test:before']) { + result.beforeOnce = scripts['test:before']; + if (scripts['test:before:once']) { + console.warn('Warning: Both "test:before" and "test:before:once" are defined. Using "test:before".'); + } + } else if (scripts['test:before:once']) { + result.beforeOnce = scripts['test:before:once']; + } + + if (scripts['test:before:testfile']) { + result.beforeTestfile = scripts['test:before:testfile']; + } + } catch { + // No package.json or parse error — return defaults + } + + return result; +} + +/** + * Execute a before-script command and return whether it succeeded. + */ +export async function runBeforeScript( + smartshellInstance: Smartshell, + command: string, + label: string, + logger: TsTestLogger, + env?: { TSTEST_FILE?: string; TSTEST_RUNTIME?: string }, +): Promise { + logger.beforeScriptStart(label, command); + const startTime = Date.now(); + + // Set environment variables if provided + const envKeysSet: string[] = []; + if (env) { + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + process.env[key] = value; + envKeysSet.push(key); + } + } + } + + try { + const execResult = await smartshellInstance.execStreaming(command); + const result = await execResult.finalPromise; + const durationMs = Date.now() - startTime; + const success = result.exitCode === 0; + + logger.beforeScriptEnd(label, success, durationMs); + return success; + } catch { + const durationMs = Date.now() - startTime; + logger.beforeScriptEnd(label, false, durationMs); + return false; + } finally { + // Clean up environment variables + for (const key of envKeysSet) { + delete process.env[key]; + } + } +} diff --git a/ts/tstest.classes.tstest.ts b/ts/tstest.classes.tstest.ts index 4824d40..8aec65b 100644 --- a/ts/tstest.classes.tstest.ts +++ b/ts/tstest.classes.tstest.ts @@ -27,6 +27,9 @@ import { hasDirectives, } from './tstest.classes.testfile.directives.js'; +// Before-script support +import { loadBeforeScripts, runBeforeScript, type IBeforeScripts } from './tstest.classes.beforescripts.js'; + export class TsTest { public testDir: TestDirectory; public executionMode: TestExecutionMode; @@ -48,6 +51,8 @@ export class TsTest { public runtimeRegistry = new RuntimeAdapterRegistry(); public dockerAdapter: DockerRuntimeAdapter | null = null; + private beforeScripts: IBeforeScripts | null = null; + constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) { this.executionMode = executionModeArg; this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg); @@ -97,7 +102,22 @@ export class TsTest { if (this.logger.options.logFile) { await this.movePreviousLogFiles(); } - + + // Load and execute test:before script (runs once per test run) + this.beforeScripts = loadBeforeScripts(this.testDir.cwd); + if (this.beforeScripts.beforeOnce) { + const success = await runBeforeScript( + this.smartshellInstance, + this.beforeScripts.beforeOnce, + 'test:before', + this.logger, + ); + if (!success) { + this.logger.error('test:before script failed. Aborting test run.'); + process.exit(1); + } + } + const testGroups = await this.testDir.getTestFileGroups(); const allFiles = [...testGroups.serial, ...Object.values(testGroups.parallelGroups).flat()]; @@ -280,6 +300,22 @@ export class TsTest { if (adapters.length === 1) { // Single runtime - no sections needed const adapter = adapters[0]; + + // Run test:before:testfile if defined + if (this.beforeScripts?.beforeTestfile) { + const success = await runBeforeScript( + this.smartshellInstance, + this.beforeScripts.beforeTestfile, + `test:before:testfile (${fileName})`, + this.logger, + { TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: adapter.id }, + ); + if (!success) { + this.logger.error(`test:before:testfile failed for ${fileName}. Skipping test file.`); + return; + } + } + const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined; const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options); tapCombinator.addTapParser(tapParser); @@ -288,6 +324,23 @@ export class TsTest { for (let i = 0; i < adapters.length; i++) { const adapter = adapters[i]; this.logger.sectionStart(`Part ${i + 1}: ${adapter.displayName}`); + + // Run test:before:testfile if defined (runs before each runtime) + if (this.beforeScripts?.beforeTestfile) { + const success = await runBeforeScript( + this.smartshellInstance, + this.beforeScripts.beforeTestfile, + `test:before:testfile (${fileName} on ${adapter.displayName})`, + this.logger, + { TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: adapter.id }, + ); + if (!success) { + this.logger.error(`test:before:testfile failed for ${fileName} on ${adapter.displayName}. Skipping.`); + this.logger.sectionEnd(); + continue; + } + } + const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined; const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options); tapCombinator.addTapParser(tapParser); @@ -310,6 +363,21 @@ export class TsTest { return; } + // Run test:before:testfile if defined + if (this.beforeScripts?.beforeTestfile) { + const success = await runBeforeScript( + this.smartshellInstance, + this.beforeScripts.beforeTestfile, + `test:before:testfile (${fileNameArg} on Docker)`, + this.logger, + { TSTEST_FILE: fileNameArg, TSTEST_RUNTIME: 'docker' }, + ); + if (!success) { + this.logger.error(`test:before:testfile failed for ${fileNameArg}. Skipping.`); + return; + } + } + try { const tapParser = await this.dockerAdapter.run(fileNameArg, fileIndex, totalFiles); tapCombinator.addTapParser(tapParser); diff --git a/ts/tstest.logging.ts b/ts/tstest.logging.ts index 875a843..cc9ee22 100644 --- a/ts/tstest.logging.ts +++ b/ts/tstest.logging.ts @@ -122,6 +122,40 @@ export class TsTestLogger { this.log(this.format(`[${'ā–ˆ'.repeat(filled)}${'ā–‘'.repeat(empty)}] ${message}`, 'dim')); } + // Before-script lifecycle hooks + beforeScriptStart(label: string, command: string) { + if (this.options.json) { + this.logJson({ event: 'beforeScript', label, command }); + return; + } + + if (this.options.quiet) { + this.log(`Running ${label}...`); + } else { + this.log(this.format(`\nšŸ”§ Running ${label}...`, 'cyan')); + this.log(this.format(` Command: ${command}`, 'dim')); + } + } + + beforeScriptEnd(label: string, success: boolean, durationMs: number) { + const durationStr = durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`; + + if (this.options.json) { + this.logJson({ event: 'beforeScriptEnd', label, success, durationMs }); + return; + } + + if (this.options.quiet) { + this.log(success ? `${label} done (${durationStr})` : `${label} FAILED`); + } else { + if (success) { + this.log(this.format(` āœ“ ${label} completed (${durationStr})`, 'green')); + } else { + this.log(this.format(` āœ— ${label} failed (${durationStr})`, 'red')); + } + } + } + // Test discovery testDiscovery(count: number, pattern: string, executionMode: string) { if (this.options.json) {