From 36715c91390721d8c5191af36956715c1941134d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 10 Oct 2025 16:35:22 +0000 Subject: [PATCH] feat(runtime): Add runtime adapters, filename runtime parser and migration tool; integrate runtime selection into TsTest and add tests --- changelog.md | 10 + test/test.migration.node.ts | 111 +++++++++ test/test.runtime.parser.node.ts | 167 ++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/tstest.classes.migration.ts | 316 ++++++++++++++++++++++++++ ts/tstest.classes.runtime.adapter.ts | 245 ++++++++++++++++++++ ts/tstest.classes.runtime.bun.ts | 219 ++++++++++++++++++ ts/tstest.classes.runtime.chromium.ts | 293 ++++++++++++++++++++++++ ts/tstest.classes.runtime.deno.ts | 244 ++++++++++++++++++++ ts/tstest.classes.runtime.node.ts | 222 ++++++++++++++++++ ts/tstest.classes.runtime.parser.ts | 211 +++++++++++++++++ ts/tstest.classes.tstest.ts | 89 ++++++-- 12 files changed, 2106 insertions(+), 23 deletions(-) create mode 100644 test/test.migration.node.ts create mode 100644 test/test.runtime.parser.node.ts create mode 100644 ts/tstest.classes.migration.ts create mode 100644 ts/tstest.classes.runtime.adapter.ts create mode 100644 ts/tstest.classes.runtime.bun.ts create mode 100644 ts/tstest.classes.runtime.chromium.ts create mode 100644 ts/tstest.classes.runtime.deno.ts create mode 100644 ts/tstest.classes.runtime.node.ts create mode 100644 ts/tstest.classes.runtime.parser.ts diff --git a/changelog.md b/changelog.md index 7da08b2..9d9a8a8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-10-10 - 2.4.0 - feat(runtime) +Add runtime adapters, filename runtime parser and migration tool; integrate runtime selection into TsTest and add tests + +- Introduce RuntimeAdapter abstraction and RuntimeAdapterRegistry to manage multiple runtimes +- Add runtime adapters: NodeRuntimeAdapter, ChromiumRuntimeAdapter, DenoRuntimeAdapter and BunRuntimeAdapter +- Add filename runtime parser utilities: parseTestFilename, isLegacyFilename and getLegacyMigrationTarget +- Add Migration class to detect and (dry-run) migrate legacy test filenames to the new naming convention +- Integrate runtime registry into TsTest and choose execution adapters based on parsed runtimes; show deprecation warnings for legacy naming +- Add tests covering runtime parsing and migration: test/test.runtime.parser.node.ts and test/test.migration.node.ts + ## 2025-09-12 - 2.3.8 - fix(tstest) Improve free port selection for Chrome runner and bump smartnetwork dependency diff --git a/test/test.migration.node.ts b/test/test.migration.node.ts new file mode 100644 index 0000000..811e00a --- /dev/null +++ b/test/test.migration.node.ts @@ -0,0 +1,111 @@ +import { expect, tap } from '../ts_tapbundle/index.js'; +import { Migration } from '../ts/tstest.classes.migration.js'; +import * as plugins from '../ts/tstest.plugins.js'; +import * as paths from '../ts/tstest.paths.js'; + +tap.test('Migration - can initialize', async () => { + const migration = new Migration({ + baseDir: process.cwd(), + dryRun: true, + }); + + expect(migration).toBeInstanceOf(Migration); +}); + +tap.test('Migration - findLegacyFiles returns empty for no legacy files', async () => { + const migration = new Migration({ + baseDir: process.cwd(), + pattern: 'test/test.migration.node.ts', // This file itself, not legacy + dryRun: true, + }); + + const legacyFiles = await migration.findLegacyFiles(); + expect(legacyFiles).toEqual([]); +}); + +tap.test('Migration - generateReport works', async () => { + const migration = new Migration({ + baseDir: process.cwd(), + dryRun: true, + }); + + const report = await migration.generateReport(); + expect(report).toBeTypeOf('string'); + expect(report).toContain('Test File Migration Report'); +}); + +tap.test('Migration - detects legacy files when they exist', async () => { + // Create a temporary legacy test file + const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration'); + await plugins.smartfile.fs.ensureEmptyDir(tempDir); + + const legacyFile = plugins.path.join(tempDir, 'test.browser.ts'); + await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile); + + const migration = new Migration({ + baseDir: tempDir, + pattern: '**/*.ts', + dryRun: true, + }); + + const legacyFiles = await migration.findLegacyFiles(); + expect(legacyFiles.length).toEqual(1); + expect(legacyFiles[0]).toContain('test.browser.ts'); + + // Clean up + await plugins.smartfile.fs.removeSync(tempDir); +}); + +tap.test('Migration - detects both legacy pattern', async () => { + // Create temporary legacy files + const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration_both'); + await plugins.smartfile.fs.ensureEmptyDir(tempDir); + + const browserFile = plugins.path.join(tempDir, 'test.browser.ts'); + const bothFile = plugins.path.join(tempDir, 'test.both.ts'); + await plugins.smartfile.memory.toFs('// Browser test\nexport default Promise.resolve();', browserFile); + await plugins.smartfile.memory.toFs('// Both test\nexport default Promise.resolve();', bothFile); + + const migration = new Migration({ + baseDir: tempDir, + pattern: '**/*.ts', + dryRun: true, + }); + + const legacyFiles = await migration.findLegacyFiles(); + expect(legacyFiles.length).toEqual(2); + + // Clean up + await plugins.smartfile.fs.removeSync(tempDir); +}); + +tap.test('Migration - dry run does not modify files', async () => { + // Create a temporary legacy test file + const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration_dryrun'); + await plugins.smartfile.fs.ensureEmptyDir(tempDir); + + const legacyFile = plugins.path.join(tempDir, 'test.browser.ts'); + await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile); + + const migration = new Migration({ + baseDir: tempDir, + pattern: '**/*.ts', + dryRun: true, + verbose: false, + }); + + const summary = await migration.run(); + + expect(summary.dryRun).toEqual(true); + expect(summary.totalLegacyFiles).toEqual(1); + expect(summary.migratedCount).toEqual(1); // Dry run still counts as "would migrate" + + // Verify original file still exists + const fileExists = await plugins.smartfile.fs.fileExists(legacyFile); + expect(fileExists).toEqual(true); + + // Clean up + await plugins.smartfile.fs.removeSync(tempDir); +}); + +export default tap.start(); diff --git a/test/test.runtime.parser.node.ts b/test/test.runtime.parser.node.ts new file mode 100644 index 0000000..b8c715f --- /dev/null +++ b/test/test.runtime.parser.node.ts @@ -0,0 +1,167 @@ +import { expect, tap } from '../ts_tapbundle/index.js'; +import { parseTestFilename, isLegacyFilename, getLegacyMigrationTarget } from '../ts/tstest.classes.runtime.parser.js'; + +tap.test('parseTestFilename - single runtime', async () => { + const parsed = parseTestFilename('test.node.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['node']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('parseTestFilename - chromium runtime', async () => { + const parsed = parseTestFilename('test.chromium.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['chromium']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('parseTestFilename - multiple runtimes', async () => { + const parsed = parseTestFilename('test.node+chromium.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['node', 'chromium']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('parseTestFilename - deno+bun runtime', async () => { + const parsed = parseTestFilename('test.deno+bun.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['deno', 'bun']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('parseTestFilename - with nonci modifier', async () => { + const parsed = parseTestFilename('test.chromium.nonci.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['chromium']); + expect(parsed.modifiers).toEqual(['nonci']); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('parseTestFilename - multi-runtime with nonci', async () => { + const parsed = parseTestFilename('test.node+chromium.nonci.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['node', 'chromium']); + expect(parsed.modifiers).toEqual(['nonci']); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('parseTestFilename - legacy browser', async () => { + const parsed = parseTestFilename('test.browser.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['chromium']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(true); +}); + +tap.test('parseTestFilename - legacy both', async () => { + const parsed = parseTestFilename('test.both.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['node', 'chromium']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(true); +}); + +tap.test('parseTestFilename - legacy browser with nonci', async () => { + const parsed = parseTestFilename('test.browser.nonci.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['chromium']); + expect(parsed.modifiers).toEqual(['nonci']); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(true); +}); + +tap.test('parseTestFilename - complex basename', async () => { + const parsed = parseTestFilename('test.some.feature.node.ts'); + expect(parsed.baseName).toEqual('test.some.feature'); + expect(parsed.runtimes).toEqual(['node']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('parseTestFilename - default to node when no runtime', async () => { + const parsed = parseTestFilename('test.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['node']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('parseTestFilename - tsx extension', async () => { + const parsed = parseTestFilename('test.chromium.tsx'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['chromium']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('tsx'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('parseTestFilename - deduplicates runtime tokens', async () => { + const parsed = parseTestFilename('test.node+node.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['node']); + expect(parsed.modifiers).toEqual([]); + expect(parsed.extension).toEqual('ts'); + expect(parsed.isLegacy).toEqual(false); +}); + +tap.test('isLegacyFilename - detects browser', async () => { + expect(isLegacyFilename('test.browser.ts')).toEqual(true); +}); + +tap.test('isLegacyFilename - detects both', async () => { + expect(isLegacyFilename('test.both.ts')).toEqual(true); +}); + +tap.test('isLegacyFilename - rejects new naming', async () => { + expect(isLegacyFilename('test.node.ts')).toEqual(false); + expect(isLegacyFilename('test.chromium.ts')).toEqual(false); + expect(isLegacyFilename('test.node+chromium.ts')).toEqual(false); +}); + +tap.test('getLegacyMigrationTarget - browser to chromium', async () => { + const target = getLegacyMigrationTarget('test.browser.ts'); + expect(target).toEqual('test.chromium.ts'); +}); + +tap.test('getLegacyMigrationTarget - both to node+chromium', async () => { + const target = getLegacyMigrationTarget('test.both.ts'); + expect(target).toEqual('test.node+chromium.ts'); +}); + +tap.test('getLegacyMigrationTarget - browser with nonci', async () => { + const target = getLegacyMigrationTarget('test.browser.nonci.ts'); + expect(target).toEqual('test.chromium.nonci.ts'); +}); + +tap.test('getLegacyMigrationTarget - both with nonci', async () => { + const target = getLegacyMigrationTarget('test.both.nonci.ts'); + expect(target).toEqual('test.node+chromium.nonci.ts'); +}); + +tap.test('getLegacyMigrationTarget - returns null for non-legacy', async () => { + const target = getLegacyMigrationTarget('test.node.ts'); + expect(target).toEqual(null); +}); + +tap.test('parseTestFilename - handles full paths', async () => { + const parsed = parseTestFilename('/path/to/test.node+chromium.ts'); + expect(parsed.baseName).toEqual('test'); + expect(parsed.runtimes).toEqual(['node', 'chromium']); + expect(parsed.original).toEqual('test.node+chromium.ts'); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c5d92c3..eec6bc4 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: '2.3.8', + version: '2.4.0', description: 'a test utility to run tests that match test/**/*.ts' } diff --git a/ts/tstest.classes.migration.ts b/ts/tstest.classes.migration.ts new file mode 100644 index 0000000..d69d2d5 --- /dev/null +++ b/ts/tstest.classes.migration.ts @@ -0,0 +1,316 @@ +import * as plugins from './tstest.plugins.js'; +import { coloredString as cs } from '@push.rocks/consolecolor'; +import { parseTestFilename, getLegacyMigrationTarget, isLegacyFilename } from './tstest.classes.runtime.parser.js'; + +/** + * Migration result for a single file + */ +export interface MigrationResult { + /** + * Original file path + */ + oldPath: string; + + /** + * New file path after migration + */ + newPath: string; + + /** + * Whether the migration was performed + */ + migrated: boolean; + + /** + * Error message if migration failed + */ + error?: string; +} + +/** + * Migration summary + */ +export interface MigrationSummary { + /** + * Total number of legacy files found + */ + totalLegacyFiles: number; + + /** + * Number of files successfully migrated + */ + migratedCount: number; + + /** + * Number of files that failed to migrate + */ + errorCount: number; + + /** + * Individual migration results + */ + results: MigrationResult[]; + + /** + * Whether this was a dry run + */ + dryRun: boolean; +} + +/** + * Migration options + */ +export interface MigrationOptions { + /** + * Base directory to search for test files + * Default: process.cwd() + */ + baseDir?: string; + + /** + * Glob pattern for finding test files + * Default: '** /*test*.ts' (without space) + */ + pattern?: string; + + /** + * Dry run mode - don't actually rename files + * Default: true + */ + dryRun?: boolean; + + /** + * Verbose output + * Default: false + */ + verbose?: boolean; +} + +/** + * Migration class for renaming legacy test files to new naming convention + * + * Migrations: + * - .browser.ts → .chromium.ts + * - .both.ts → .node+chromium.ts + * - .both.nonci.ts → .node+chromium.nonci.ts + * - .browser.nonci.ts → .chromium.nonci.ts + */ +export class Migration { + private options: Required; + + constructor(options: MigrationOptions = {}) { + this.options = { + baseDir: options.baseDir || process.cwd(), + pattern: options.pattern || '**/test*.ts', + dryRun: options.dryRun !== undefined ? options.dryRun : true, + verbose: options.verbose || false, + }; + } + + /** + * Find all legacy test files in the base directory + */ + async findLegacyFiles(): Promise { + const files = await plugins.smartfile.fs.listFileTree( + this.options.baseDir, + this.options.pattern + ); + + const legacyFiles: string[] = []; + + for (const file of files) { + const fileName = plugins.path.basename(file); + if (isLegacyFilename(fileName)) { + const absolutePath = plugins.path.isAbsolute(file) + ? file + : plugins.path.join(this.options.baseDir, file); + legacyFiles.push(absolutePath); + } + } + + return legacyFiles; + } + + /** + * Migrate a single file + */ + private async migrateFile(filePath: string): Promise { + const fileName = plugins.path.basename(filePath); + const dirName = plugins.path.dirname(filePath); + + try { + // Get the new filename + const newFileName = getLegacyMigrationTarget(fileName); + + if (!newFileName) { + return { + oldPath: filePath, + newPath: filePath, + migrated: false, + error: 'File is not a legacy file', + }; + } + + const newPath = plugins.path.join(dirName, newFileName); + + // Check if target file already exists + if (await plugins.smartfile.fs.fileExists(newPath)) { + return { + oldPath: filePath, + newPath, + migrated: false, + error: `Target file already exists: ${newPath}`, + }; + } + + if (!this.options.dryRun) { + // Check if we're in a git repository + const isGitRepo = await this.isGitRepository(this.options.baseDir); + + if (isGitRepo) { + // Use git mv to preserve history + const smartshell = new plugins.smartshell.Smartshell({ + executor: 'bash', + pathDirectories: [], + }); + const gitCommand = `cd "${this.options.baseDir}" && git mv "${filePath}" "${newPath}"`; + const result = await smartshell.exec(gitCommand); + + if (result.exitCode !== 0) { + throw new Error(`git mv failed: ${result.stderr}`); + } + } else { + // Not a git repository - cannot migrate without git + throw new Error('Migration requires a git repository. We have git!'); + } + } + + return { + oldPath: filePath, + newPath, + migrated: true, + }; + } catch (error) { + return { + oldPath: filePath, + newPath: filePath, + migrated: false, + error: error.message, + }; + } + } + + /** + * Check if a directory is a git repository + */ + private async isGitRepository(dir: string): Promise { + try { + const gitDir = plugins.path.join(dir, '.git'); + return await plugins.smartfile.fs.isDirectory(gitDir); + } catch { + return false; + } + } + + /** + * Run the migration + */ + async run(): Promise { + const legacyFiles = await this.findLegacyFiles(); + + console.log(''); + console.log(cs('='.repeat(60), 'blue')); + console.log(cs('Test File Migration Tool', 'blue')); + console.log(cs('='.repeat(60), 'blue')); + console.log(''); + + if (this.options.dryRun) { + console.log(cs('🔍 DRY RUN MODE - No files will be modified', 'orange')); + console.log(''); + } + + console.log(`Found ${legacyFiles.length} legacy test file(s)`); + console.log(''); + + const results: MigrationResult[] = []; + let migratedCount = 0; + let errorCount = 0; + + for (const file of legacyFiles) { + const result = await this.migrateFile(file); + results.push(result); + + if (result.migrated) { + migratedCount++; + const oldName = plugins.path.basename(result.oldPath); + const newName = plugins.path.basename(result.newPath); + + if (this.options.dryRun) { + console.log(cs(` Would migrate:`, 'cyan')); + } else { + console.log(cs(` ✓ Migrated:`, 'green')); + } + console.log(` ${oldName}`); + console.log(cs(` → ${newName}`, 'green')); + console.log(''); + } else if (result.error) { + errorCount++; + console.log(cs(` ✗ Failed: ${plugins.path.basename(result.oldPath)}`, 'red')); + console.log(cs(` ${result.error}`, 'red')); + console.log(''); + } + } + + console.log(cs('='.repeat(60), 'blue')); + console.log(`Summary:`); + console.log(` Total legacy files: ${legacyFiles.length}`); + console.log(` Successfully migrated: ${migratedCount}`); + console.log(` Errors: ${errorCount}`); + console.log(cs('='.repeat(60), 'blue')); + + if (this.options.dryRun && legacyFiles.length > 0) { + console.log(''); + console.log(cs('To apply these changes, run:', 'orange')); + console.log(cs(' tstest migrate --write', 'orange')); + } + + console.log(''); + + return { + totalLegacyFiles: legacyFiles.length, + migratedCount, + errorCount, + results, + dryRun: this.options.dryRun, + }; + } + + /** + * Create a migration report without performing the migration + */ + async generateReport(): Promise { + const legacyFiles = await this.findLegacyFiles(); + + let report = ''; + report += 'Test File Migration Report\n'; + report += '='.repeat(60) + '\n'; + report += '\n'; + report += `Found ${legacyFiles.length} legacy test file(s)\n`; + report += '\n'; + + for (const file of legacyFiles) { + const fileName = plugins.path.basename(file); + const newFileName = getLegacyMigrationTarget(fileName); + + if (newFileName) { + report += `${fileName}\n`; + report += ` → ${newFileName}\n`; + report += '\n'; + } + } + + report += '='.repeat(60) + '\n'; + + return report; + } +} diff --git a/ts/tstest.classes.runtime.adapter.ts b/ts/tstest.classes.runtime.adapter.ts new file mode 100644 index 0000000..40cee9d --- /dev/null +++ b/ts/tstest.classes.runtime.adapter.ts @@ -0,0 +1,245 @@ +import * as plugins from './tstest.plugins.js'; +import type { Runtime } from './tstest.classes.runtime.parser.js'; +import { TapParser } from './tstest.classes.tap.parser.js'; + +/** + * Runtime-specific configuration options + */ +export interface RuntimeOptions { + /** + * Environment variables to pass to the runtime + */ + env?: Record; + + /** + * Additional command-line arguments + */ + extraArgs?: string[]; + + /** + * Working directory for test execution + */ + cwd?: string; + + /** + * Timeout in milliseconds (0 = no timeout) + */ + timeout?: number; +} + +/** + * Deno-specific configuration options + */ +export interface DenoOptions extends RuntimeOptions { + /** + * Permissions to grant to Deno + * Default: ['--allow-read', '--allow-env'] + */ + permissions?: string[]; + + /** + * Path to deno.json config file + */ + configPath?: string; + + /** + * Path to import map file + */ + importMap?: string; +} + +/** + * Chromium-specific configuration options + */ +export interface ChromiumOptions extends RuntimeOptions { + /** + * Chromium launch arguments + */ + launchArgs?: string[]; + + /** + * Headless mode (default: true) + */ + headless?: boolean; + + /** + * Port range for HTTP server + */ + portRange?: { min: number; max: number }; +} + +/** + * Command configuration returned by createCommand() + */ +export interface RuntimeCommand { + /** + * The main command executable (e.g., 'node', 'deno', 'bun') + */ + command: string; + + /** + * Command-line arguments + */ + args: string[]; + + /** + * Environment variables + */ + env?: Record; + + /** + * Working directory + */ + cwd?: string; +} + +/** + * Runtime availability check result + */ +export interface RuntimeAvailability { + /** + * Whether the runtime is available + */ + available: boolean; + + /** + * Version string if available + */ + version?: string; + + /** + * Error message if not available + */ + error?: string; +} + +/** + * Abstract base class for runtime adapters + * Each runtime (Node, Chromium, Deno, Bun) implements this interface + */ +export abstract class RuntimeAdapter { + /** + * Runtime identifier + */ + abstract readonly id: Runtime; + + /** + * Human-readable display name + */ + abstract readonly displayName: string; + + /** + * Check if this runtime is available on the system + * @returns Availability information including version + */ + abstract checkAvailable(): Promise; + + /** + * Create the command configuration for executing a test + * @param testFile - Absolute path to the test file + * @param options - Runtime-specific options + * @returns Command configuration + */ + abstract createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand; + + /** + * Execute a test file and return a TAP parser + * @param testFile - Absolute path to the test file + * @param index - Test index (for display) + * @param total - Total number of tests (for display) + * @param options - Runtime-specific options + * @returns TAP parser with test results + */ + abstract run( + testFile: string, + index: number, + total: number, + options?: RuntimeOptions + ): Promise; + + /** + * Get the default options for this runtime + * Can be overridden by subclasses + */ + protected getDefaultOptions(): RuntimeOptions { + return { + timeout: 0, + extraArgs: [], + env: {}, + }; + } + + /** + * Merge user options with defaults + */ + protected mergeOptions(userOptions?: T): T { + const defaults = this.getDefaultOptions(); + return { + ...defaults, + ...userOptions, + env: { ...defaults.env, ...userOptions?.env }, + extraArgs: [...(defaults.extraArgs || []), ...(userOptions?.extraArgs || [])], + } as T; + } +} + +/** + * Registry for runtime adapters + * Manages all available runtime implementations + */ +export class RuntimeAdapterRegistry { + private adapters: Map = new Map(); + + /** + * Register a runtime adapter + */ + register(adapter: RuntimeAdapter): void { + this.adapters.set(adapter.id, adapter); + } + + /** + * Get an adapter by runtime ID + */ + get(runtime: Runtime): RuntimeAdapter | undefined { + return this.adapters.get(runtime); + } + + /** + * Get all registered adapters + */ + getAll(): RuntimeAdapter[] { + return Array.from(this.adapters.values()); + } + + /** + * Check which runtimes are available on the system + */ + async checkAvailability(): Promise> { + const results = new Map(); + + for (const [runtime, adapter] of this.adapters) { + const availability = await adapter.checkAvailable(); + results.set(runtime, availability); + } + + return results; + } + + /** + * Get adapters for a list of runtimes, in order + * @param runtimes - Ordered list of runtimes + * @returns Adapters in the same order, skipping any that aren't registered + */ + getAdaptersForRuntimes(runtimes: Runtime[]): RuntimeAdapter[] { + const adapters: RuntimeAdapter[] = []; + + for (const runtime of runtimes) { + const adapter = this.get(runtime); + if (adapter) { + adapters.push(adapter); + } + } + + return adapters; + } +} diff --git a/ts/tstest.classes.runtime.bun.ts b/ts/tstest.classes.runtime.bun.ts new file mode 100644 index 0000000..f322ca4 --- /dev/null +++ b/ts/tstest.classes.runtime.bun.ts @@ -0,0 +1,219 @@ +import * as plugins from './tstest.plugins.js'; +import { coloredString as cs } from '@push.rocks/consolecolor'; +import { + RuntimeAdapter, + type RuntimeOptions, + type RuntimeCommand, + type RuntimeAvailability, +} from './tstest.classes.runtime.adapter.js'; +import { TapParser } from './tstest.classes.tap.parser.js'; +import { TsTestLogger } from './tstest.logging.js'; +import type { Runtime } from './tstest.classes.runtime.parser.js'; + +/** + * Bun runtime adapter + * Executes tests using the Bun runtime with native TypeScript support + */ +export class BunRuntimeAdapter extends RuntimeAdapter { + readonly id: Runtime = 'bun'; + readonly displayName: string = 'Bun'; + + constructor( + private logger: TsTestLogger, + private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell + private timeoutSeconds: number | null, + private filterTags: string[] + ) { + super(); + } + + /** + * Check if Bun is available + */ + async checkAvailable(): Promise { + try { + const result = await this.smartshellInstance.exec('bun --version', { + cwd: process.cwd(), + onError: () => { + // Ignore error + } + }); + + if (result.exitCode !== 0) { + return { + available: false, + error: 'Bun not found. Install from: https://bun.sh/', + }; + } + + // Bun version is just the version number + const version = result.stdout.trim(); + + return { + available: true, + version: `Bun ${version}`, + }; + } catch (error) { + return { + available: false, + error: error.message, + }; + } + } + + /** + * Create command configuration for Bun test execution + */ + createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand { + const mergedOptions = this.mergeOptions(options); + + const args: string[] = ['run']; + + // Add extra args + if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) { + args.push(...mergedOptions.extraArgs); + } + + // Add test file + args.push(testFile); + + // Set environment variables + const env = { ...mergedOptions.env }; + + if (this.filterTags.length > 0) { + env.TSTEST_FILTER_TAGS = this.filterTags.join(','); + } + + return { + command: 'bun', + args, + env, + cwd: mergedOptions.cwd, + }; + } + + /** + * Execute a test file in Bun + */ + async run( + testFile: string, + index: number, + total: number, + options?: RuntimeOptions + ): Promise { + this.logger.testFileStart(testFile, this.displayName, index, total); + const tapParser = new TapParser(testFile + ':bun', this.logger); + + const mergedOptions = this.mergeOptions(options); + + // Build Bun command + const command = this.createCommand(testFile, mergedOptions); + const fullCommand = `${command.command} ${command.args.join(' ')}`; + + // Set filter tags as environment variable + if (this.filterTags.length > 0) { + process.env.TSTEST_FILTER_TAGS = this.filterTags.join(','); + } + + // Check for 00init.ts file in test directory + const testDir = plugins.path.dirname(testFile); + const initFile = plugins.path.join(testDir, '00init.ts'); + const initFileExists = await plugins.smartfile.fs.fileExists(initFile); + + let runCommand = fullCommand; + let loaderPath: string | null = null; + + // If 00init.ts exists, create a loader file + if (initFileExists) { + const absoluteInitFile = plugins.path.resolve(initFile); + const absoluteTestFile = plugins.path.resolve(testFile); + const loaderContent = ` +import '${absoluteInitFile.replace(/\\/g, '/')}'; +import '${absoluteTestFile.replace(/\\/g, '/')}'; +`; + loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`); + await plugins.smartfile.memory.toFs(loaderContent, loaderPath); + + // Rebuild command with loader file + const loaderCommand = this.createCommand(loaderPath, mergedOptions); + runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`; + } + + const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand); + + // If we created a loader file, clean it up after test execution + if (loaderPath) { + const cleanup = () => { + try { + if (plugins.smartfile.fs.fileExistsSync(loaderPath)) { + plugins.smartfile.fs.removeSync(loaderPath); + } + } catch (e) { + // Ignore cleanup errors + } + }; + + execResultStreaming.childProcess.on('exit', cleanup); + execResultStreaming.childProcess.on('error', cleanup); + } + + // Start warning timer if no timeout was specified + let warningTimer: NodeJS.Timeout | null = null; + if (this.timeoutSeconds === null) { + warningTimer = setTimeout(() => { + console.error(''); + console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange')); + console.error(cs(` File: ${testFile}`, 'orange')); + console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange')); + console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange')); + console.error(''); + }, 60000); // 1 minute + } + + // Handle timeout if specified + if (this.timeoutSeconds !== null) { + const timeoutMs = this.timeoutSeconds * 1000; + let timeoutId: NodeJS.Timeout; + + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(async () => { + // Use smartshell's terminate() to kill entire process tree + await execResultStreaming.terminate(); + reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`)); + }, timeoutMs); + }); + + try { + await Promise.race([ + tapParser.handleTapProcess(execResultStreaming.childProcess), + timeoutPromise + ]); + // Clear timeout if test completed successfully + clearTimeout(timeoutId); + } catch (error) { + // Clear warning timer if it was set + if (warningTimer) { + clearTimeout(warningTimer); + } + // Handle timeout error + tapParser.handleTimeout(this.timeoutSeconds); + // Ensure entire process tree is killed if still running + try { + await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL + } catch (killError) { + // Process tree might already be dead + } + await tapParser.evaluateFinalResult(); + } + } else { + await tapParser.handleTapProcess(execResultStreaming.childProcess); + } + + // Clear warning timer if it was set + if (warningTimer) { + clearTimeout(warningTimer); + } + + return tapParser; + } +} diff --git a/ts/tstest.classes.runtime.chromium.ts b/ts/tstest.classes.runtime.chromium.ts new file mode 100644 index 0000000..c43dd15 --- /dev/null +++ b/ts/tstest.classes.runtime.chromium.ts @@ -0,0 +1,293 @@ +import * as plugins from './tstest.plugins.js'; +import * as paths from './tstest.paths.js'; +import { coloredString as cs } from '@push.rocks/consolecolor'; +import { + RuntimeAdapter, + type ChromiumOptions, + type RuntimeCommand, + type RuntimeAvailability, +} from './tstest.classes.runtime.adapter.js'; +import { TapParser } from './tstest.classes.tap.parser.js'; +import { TsTestLogger } from './tstest.logging.js'; +import type { Runtime } from './tstest.classes.runtime.parser.js'; + +/** + * Chromium runtime adapter + * Executes tests in a headless Chromium browser + */ +export class ChromiumRuntimeAdapter extends RuntimeAdapter { + readonly id: Runtime = 'chromium'; + readonly displayName: string = 'Chromium'; + + constructor( + private logger: TsTestLogger, + private tsbundleInstance: any, // TsBundle instance from @push.rocks/tsbundle + private smartbrowserInstance: any, // SmartBrowser instance from @push.rocks/smartbrowser + private timeoutSeconds: number | null + ) { + super(); + } + + /** + * Check if Chromium is available + */ + async checkAvailable(): Promise { + try { + // Check if smartbrowser is available and can start + // The browser binary is usually handled by @push.rocks/smartbrowser + return { + available: true, + version: 'Chromium (via smartbrowser)', + }; + } catch (error) { + return { + available: false, + error: error.message || 'Chromium not available', + }; + } + } + + /** + * Create command configuration for Chromium test execution + * Note: Chromium tests don't use a traditional command, but this satisfies the interface + */ + createCommand(testFile: string, options?: ChromiumOptions): RuntimeCommand { + const mergedOptions = this.mergeOptions(options); + + return { + command: 'chromium', + args: [], + env: mergedOptions.env, + cwd: mergedOptions.cwd, + }; + } + + /** + * Find free ports for HTTP server and WebSocket + */ + private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> { + const smartnetwork = new plugins.smartnetwork.SmartNetwork(); + + // Find random free HTTP port in range 30000-40000 to minimize collision chance + const httpPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true }); + if (!httpPort) { + throw new Error('Could not find a free HTTP port in range 30000-40000'); + } + + // Find random free WebSocket port, excluding the HTTP port to ensure they're different + const wsPort = await smartnetwork.findFreePort(30000, 40000, { + randomize: true, + exclude: [httpPort] + }); + if (!wsPort) { + throw new Error('Could not find a free WebSocket port in range 30000-40000'); + } + + // Log selected ports for debugging + if (!this.logger.options.quiet) { + console.log(`Selected ports - HTTP: ${httpPort}, WebSocket: ${wsPort}`); + } + return { httpPort, wsPort }; + } + + /** + * Execute a test file in Chromium browser + */ + async run( + testFile: string, + index: number, + total: number, + options?: ChromiumOptions + ): Promise { + this.logger.testFileStart(testFile, this.displayName, index, total); + + // lets get all our paths sorted + const tsbundleCacheDirPath = plugins.path.join(paths.cwd, './.nogit/tstest_cache'); + const bundleFileName = testFile.replace('/', '__') + '.js'; + const bundleFilePath = plugins.path.join(tsbundleCacheDirPath, bundleFileName); + + // lets bundle the test + await plugins.smartfile.fs.ensureEmptyDir(tsbundleCacheDirPath); + await this.tsbundleInstance.build(process.cwd(), testFile, bundleFilePath, { + bundler: 'esbuild', + }); + + // Find free ports for HTTP and WebSocket + const { httpPort, wsPort } = await this.findFreePorts(); + + // lets create a server + const server = new plugins.typedserver.servertools.Server({ + cors: true, + port: httpPort, + }); + server.addRoute( + '/test', + new plugins.typedserver.servertools.Handler('GET', async (_req, res) => { + res.type('.html'); + res.write(` + + + + + + + `); + res.end(); + }) + ); + server.addRoute('/*splat', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath)); + await server.start(); + + // lets handle realtime comms + const tapParser = new TapParser(testFile + ':chrome', this.logger); + const wss = new plugins.ws.WebSocketServer({ port: wsPort }); + wss.on('connection', (ws) => { + ws.on('message', (message) => { + const messageStr = message.toString(); + if (messageStr.startsWith('console:')) { + const [, level, ...messageParts] = messageStr.split(':'); + this.logger.browserConsole(messageParts.join(':'), level); + } else { + tapParser.handleTapLog(messageStr); + } + }); + }); + + // lets do the browser bit with timeout handling + await this.smartbrowserInstance.start(); + + const evaluatePromise = this.smartbrowserInstance.evaluateOnPage( + `http://localhost:${httpPort}/test?bundleName=${bundleFileName}`, + async () => { + // lets enable real time comms + const ws = new WebSocket(`ws://localhost:${globalThis.wsPort}`); + await new Promise((resolve) => (ws.onopen = resolve)); + + // Ensure this function is declared with 'async' + const logStore = []; + const originalLog = console.log; + const originalError = console.error; + + // Override console methods to capture the logs + console.log = (...args: any[]) => { + logStore.push(args.join(' ')); + ws.send(args.join(' ')); + originalLog(...args); + }; + console.error = (...args: any[]) => { + logStore.push(args.join(' ')); + ws.send(args.join(' ')); + originalError(...args); + }; + + const bundleName = new URLSearchParams(window.location.search).get('bundleName'); + originalLog(`::TSTEST IN CHROMIUM:: Relevant Script name is: ${bundleName}`); + + try { + // Dynamically import the test module + const testModule = await import(`/${bundleName}`); + if (testModule && testModule.default && testModule.default instanceof Promise) { + // Execute the exported test function + await testModule.default; + } else if (testModule && testModule.default && typeof testModule.default.then === 'function') { + console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + console.log('Test module default export is just promiselike: Something might be messing with your Promise implementation.'); + console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + await testModule.default; + } else if (globalThis.tapPromise && typeof globalThis.tapPromise.then === 'function') { + console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + console.log('Using globalThis.tapPromise'); + console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + await testModule.default; + } else { + console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + console.error('Test module does not export a default promise.'); + console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + console.log(`We got: ${JSON.stringify(testModule)}`); + } + } catch (err) { + console.error(err); + } + + return logStore.join('\n'); + } + ); + + // Start warning timer if no timeout was specified + let warningTimer: NodeJS.Timeout | null = null; + if (this.timeoutSeconds === null) { + warningTimer = setTimeout(() => { + console.error(''); + console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange')); + console.error(cs(` File: ${testFile}`, 'orange')); + console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange')); + console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange')); + console.error(''); + }, 60000); // 1 minute + } + + // Handle timeout if specified + if (this.timeoutSeconds !== null) { + const timeoutMs = this.timeoutSeconds * 1000; + let timeoutId: NodeJS.Timeout; + + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`)); + }, timeoutMs); + }); + + try { + await Promise.race([ + evaluatePromise, + timeoutPromise + ]); + // Clear timeout if test completed successfully + clearTimeout(timeoutId); + } catch (error) { + // Clear warning timer if it was set + if (warningTimer) { + clearTimeout(warningTimer); + } + // Handle timeout error + tapParser.handleTimeout(this.timeoutSeconds); + } + } else { + await evaluatePromise; + } + + // Clear warning timer if it was set + if (warningTimer) { + clearTimeout(warningTimer); + } + + // Always clean up resources, even on timeout + try { + await this.smartbrowserInstance.stop(); + } catch (error) { + // Browser might already be stopped + } + + try { + await server.stop(); + } catch (error) { + // Server might already be stopped + } + + try { + wss.close(); + } catch (error) { + // WebSocket server might already be closed + } + + console.log( + `${cs('=> ', 'blue')} Stopped ${cs(testFile, 'orange')} chromium instance and server.` + ); + // Always evaluate final result (handleTimeout just sets up the test state) + await tapParser.evaluateFinalResult(); + return tapParser; + } +} diff --git a/ts/tstest.classes.runtime.deno.ts b/ts/tstest.classes.runtime.deno.ts new file mode 100644 index 0000000..f1d8543 --- /dev/null +++ b/ts/tstest.classes.runtime.deno.ts @@ -0,0 +1,244 @@ +import * as plugins from './tstest.plugins.js'; +import { coloredString as cs } from '@push.rocks/consolecolor'; +import { + RuntimeAdapter, + type DenoOptions, + type RuntimeCommand, + type RuntimeAvailability, +} from './tstest.classes.runtime.adapter.js'; +import { TapParser } from './tstest.classes.tap.parser.js'; +import { TsTestLogger } from './tstest.logging.js'; +import type { Runtime } from './tstest.classes.runtime.parser.js'; + +/** + * Deno runtime adapter + * Executes tests using the Deno runtime + */ +export class DenoRuntimeAdapter extends RuntimeAdapter { + readonly id: Runtime = 'deno'; + readonly displayName: string = 'Deno'; + + constructor( + private logger: TsTestLogger, + private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell + private timeoutSeconds: number | null, + private filterTags: string[] + ) { + super(); + } + + /** + * Get default Deno options + */ + protected getDefaultOptions(): DenoOptions { + return { + ...super.getDefaultOptions(), + permissions: ['--allow-read', '--allow-env'], + }; + } + + /** + * Check if Deno is available + */ + async checkAvailable(): Promise { + try { + const result = await this.smartshellInstance.exec('deno --version', { + cwd: process.cwd(), + onError: () => { + // Ignore error + } + }); + + if (result.exitCode !== 0) { + return { + available: false, + error: 'Deno not found. Install from: https://deno.land/', + }; + } + + // Parse Deno version from output (first line is "deno X.Y.Z") + const versionMatch = result.stdout.match(/deno (\d+\.\d+\.\d+)/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + + return { + available: true, + version: `Deno ${version}`, + }; + } catch (error) { + return { + available: false, + error: error.message, + }; + } + } + + /** + * Create command configuration for Deno test execution + */ + createCommand(testFile: string, options?: DenoOptions): RuntimeCommand { + const mergedOptions = this.mergeOptions(options) as DenoOptions; + + const args: string[] = ['run']; + + // Add permissions + const permissions = mergedOptions.permissions || ['--allow-read', '--allow-env']; + args.push(...permissions); + + // Add config file if specified + if (mergedOptions.configPath) { + args.push('--config', mergedOptions.configPath); + } + + // Add import map if specified + if (mergedOptions.importMap) { + args.push('--import-map', mergedOptions.importMap); + } + + // Add extra args + if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) { + args.push(...mergedOptions.extraArgs); + } + + // Add test file + args.push(testFile); + + // Set environment variables + const env = { ...mergedOptions.env }; + + if (this.filterTags.length > 0) { + env.TSTEST_FILTER_TAGS = this.filterTags.join(','); + } + + return { + command: 'deno', + args, + env, + cwd: mergedOptions.cwd, + }; + } + + /** + * Execute a test file in Deno + */ + async run( + testFile: string, + index: number, + total: number, + options?: DenoOptions + ): Promise { + this.logger.testFileStart(testFile, this.displayName, index, total); + const tapParser = new TapParser(testFile + ':deno', this.logger); + + const mergedOptions = this.mergeOptions(options) as DenoOptions; + + // Build Deno command + const command = this.createCommand(testFile, mergedOptions); + const fullCommand = `${command.command} ${command.args.join(' ')}`; + + // Set filter tags as environment variable + if (this.filterTags.length > 0) { + process.env.TSTEST_FILTER_TAGS = this.filterTags.join(','); + } + + // Check for 00init.ts file in test directory + const testDir = plugins.path.dirname(testFile); + const initFile = plugins.path.join(testDir, '00init.ts'); + const initFileExists = await plugins.smartfile.fs.fileExists(initFile); + + let runCommand = fullCommand; + let loaderPath: string | null = null; + + // If 00init.ts exists, create a loader file + if (initFileExists) { + const absoluteInitFile = plugins.path.resolve(initFile); + const absoluteTestFile = plugins.path.resolve(testFile); + const loaderContent = ` +import '${absoluteInitFile.replace(/\\/g, '/')}'; +import '${absoluteTestFile.replace(/\\/g, '/')}'; +`; + loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`); + await plugins.smartfile.memory.toFs(loaderContent, loaderPath); + + // Rebuild command with loader file + const loaderCommand = this.createCommand(loaderPath, mergedOptions); + runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`; + } + + const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand); + + // If we created a loader file, clean it up after test execution + if (loaderPath) { + const cleanup = () => { + try { + if (plugins.smartfile.fs.fileExistsSync(loaderPath)) { + plugins.smartfile.fs.removeSync(loaderPath); + } + } catch (e) { + // Ignore cleanup errors + } + }; + + execResultStreaming.childProcess.on('exit', cleanup); + execResultStreaming.childProcess.on('error', cleanup); + } + + // Start warning timer if no timeout was specified + let warningTimer: NodeJS.Timeout | null = null; + if (this.timeoutSeconds === null) { + warningTimer = setTimeout(() => { + console.error(''); + console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange')); + console.error(cs(` File: ${testFile}`, 'orange')); + console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange')); + console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange')); + console.error(''); + }, 60000); // 1 minute + } + + // Handle timeout if specified + if (this.timeoutSeconds !== null) { + const timeoutMs = this.timeoutSeconds * 1000; + let timeoutId: NodeJS.Timeout; + + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(async () => { + // Use smartshell's terminate() to kill entire process tree + await execResultStreaming.terminate(); + reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`)); + }, timeoutMs); + }); + + try { + await Promise.race([ + tapParser.handleTapProcess(execResultStreaming.childProcess), + timeoutPromise + ]); + // Clear timeout if test completed successfully + clearTimeout(timeoutId); + } catch (error) { + // Clear warning timer if it was set + if (warningTimer) { + clearTimeout(warningTimer); + } + // Handle timeout error + tapParser.handleTimeout(this.timeoutSeconds); + // Ensure entire process tree is killed if still running + try { + await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL + } catch (killError) { + // Process tree might already be dead + } + await tapParser.evaluateFinalResult(); + } + } else { + await tapParser.handleTapProcess(execResultStreaming.childProcess); + } + + // Clear warning timer if it was set + if (warningTimer) { + clearTimeout(warningTimer); + } + + return tapParser; + } +} diff --git a/ts/tstest.classes.runtime.node.ts b/ts/tstest.classes.runtime.node.ts new file mode 100644 index 0000000..6443333 --- /dev/null +++ b/ts/tstest.classes.runtime.node.ts @@ -0,0 +1,222 @@ +import * as plugins from './tstest.plugins.js'; +import { coloredString as cs } from '@push.rocks/consolecolor'; +import { + RuntimeAdapter, + type RuntimeOptions, + type RuntimeCommand, + type RuntimeAvailability, +} from './tstest.classes.runtime.adapter.js'; +import { TapParser } from './tstest.classes.tap.parser.js'; +import { TsTestLogger } from './tstest.logging.js'; +import type { Runtime } from './tstest.classes.runtime.parser.js'; + +/** + * Node.js runtime adapter + * Executes tests using tsrun (TypeScript runner for Node.js) + */ +export class NodeRuntimeAdapter extends RuntimeAdapter { + readonly id: Runtime = 'node'; + readonly displayName: string = 'Node.js'; + + constructor( + private logger: TsTestLogger, + private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell + private timeoutSeconds: number | null, + private filterTags: string[] + ) { + super(); + } + + /** + * Check if Node.js and tsrun are available + */ + async checkAvailable(): Promise { + try { + // Check Node.js version + const nodeVersion = process.version; + + // Check if tsrun is available + const result = await this.smartshellInstance.exec('tsrun --version', { + cwd: process.cwd(), + onError: () => { + // Ignore error + } + }); + + if (result.exitCode !== 0) { + return { + available: false, + error: 'tsrun not found. Install with: pnpm install --save-dev @git.zone/tsrun', + }; + } + + return { + available: true, + version: nodeVersion, + }; + } catch (error) { + return { + available: false, + error: error.message, + }; + } + } + + /** + * Create command configuration for Node.js test execution + */ + createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand { + const mergedOptions = this.mergeOptions(options); + + // Build tsrun options + const args: string[] = []; + + if (process.argv.includes('--web')) { + args.push('--web'); + } + + // Add any extra args + if (mergedOptions.extraArgs) { + args.push(...mergedOptions.extraArgs); + } + + // Set environment variables + const env = { ...mergedOptions.env }; + + if (this.filterTags.length > 0) { + env.TSTEST_FILTER_TAGS = this.filterTags.join(','); + } + + return { + command: 'tsrun', + args: [testFile, ...args], + env, + cwd: mergedOptions.cwd, + }; + } + + /** + * Execute a test file in Node.js + */ + async run( + testFile: string, + index: number, + total: number, + options?: RuntimeOptions + ): Promise { + this.logger.testFileStart(testFile, this.displayName, index, total); + const tapParser = new TapParser(testFile + ':node', this.logger); + + const mergedOptions = this.mergeOptions(options); + + // Build tsrun command + let tsrunOptions = ''; + if (process.argv.includes('--web')) { + tsrunOptions += ' --web'; + } + + // Set filter tags as environment variable + if (this.filterTags.length > 0) { + process.env.TSTEST_FILTER_TAGS = this.filterTags.join(','); + } + + // Check for 00init.ts file in test directory + const testDir = plugins.path.dirname(testFile); + const initFile = plugins.path.join(testDir, '00init.ts'); + let runCommand = `tsrun ${testFile}${tsrunOptions}`; + + const initFileExists = await plugins.smartfile.fs.fileExists(initFile); + + // If 00init.ts exists, run it first + let loaderPath: string | null = null; + if (initFileExists) { + // Create a temporary loader file that imports both 00init.ts and the test file + const absoluteInitFile = plugins.path.resolve(initFile); + const absoluteTestFile = plugins.path.resolve(testFile); + const loaderContent = ` +import '${absoluteInitFile.replace(/\\/g, '/')}'; +import '${absoluteTestFile.replace(/\\/g, '/')}'; +`; + loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`); + await plugins.smartfile.memory.toFs(loaderContent, loaderPath); + runCommand = `tsrun ${loaderPath}${tsrunOptions}`; + } + + const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand); + + // If we created a loader file, clean it up after test execution + if (loaderPath) { + const cleanup = () => { + try { + if (plugins.smartfile.fs.fileExistsSync(loaderPath)) { + plugins.smartfile.fs.removeSync(loaderPath); + } + } catch (e) { + // Ignore cleanup errors + } + }; + + execResultStreaming.childProcess.on('exit', cleanup); + execResultStreaming.childProcess.on('error', cleanup); + } + + // Start warning timer if no timeout was specified + let warningTimer: NodeJS.Timeout | null = null; + if (this.timeoutSeconds === null) { + warningTimer = setTimeout(() => { + console.error(''); + console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange')); + console.error(cs(` File: ${testFile}`, 'orange')); + console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange')); + console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange')); + console.error(''); + }, 60000); // 1 minute + } + + // Handle timeout if specified + if (this.timeoutSeconds !== null) { + const timeoutMs = this.timeoutSeconds * 1000; + let timeoutId: NodeJS.Timeout; + + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(async () => { + // Use smartshell's terminate() to kill entire process tree + await execResultStreaming.terminate(); + reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`)); + }, timeoutMs); + }); + + try { + await Promise.race([ + tapParser.handleTapProcess(execResultStreaming.childProcess), + timeoutPromise + ]); + // Clear timeout if test completed successfully + clearTimeout(timeoutId); + } catch (error) { + // Clear warning timer if it was set + if (warningTimer) { + clearTimeout(warningTimer); + } + // Handle timeout error + tapParser.handleTimeout(this.timeoutSeconds); + // Ensure entire process tree is killed if still running + try { + await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL + } catch (killError) { + // Process tree might already be dead + } + await tapParser.evaluateFinalResult(); + } + } else { + await tapParser.handleTapProcess(execResultStreaming.childProcess); + } + + // Clear warning timer if it was set + if (warningTimer) { + clearTimeout(warningTimer); + } + + return tapParser; + } +} diff --git a/ts/tstest.classes.runtime.parser.ts b/ts/tstest.classes.runtime.parser.ts new file mode 100644 index 0000000..6ca24a0 --- /dev/null +++ b/ts/tstest.classes.runtime.parser.ts @@ -0,0 +1,211 @@ +/** + * Runtime parser for test file naming convention + * Supports: test.runtime1+runtime2.modifier.ts + * Examples: + * - test.node.ts + * - test.chromium.ts + * - test.node+chromium.ts + * - test.deno+bun.ts + * - test.chromium.nonci.ts + */ + +export type Runtime = 'node' | 'chromium' | 'deno' | 'bun'; +export type Modifier = 'nonci'; + +export interface ParsedFilename { + baseName: string; + runtimes: Runtime[]; + modifiers: Modifier[]; + extension: string; + isLegacy: boolean; + original: string; +} + +export interface ParserConfig { + strictUnknownRuntime?: boolean; // default: true + defaultRuntimes?: Runtime[]; // default: ['node'] +} + +const KNOWN_RUNTIMES: Set = new Set(['node', 'chromium', 'deno', 'bun']); +const KNOWN_MODIFIERS: Set = new Set(['nonci']); +const VALID_EXTENSIONS: Set = new Set(['ts', 'tsx', 'mts', 'cts']); + +// Legacy mappings for backwards compatibility +const LEGACY_RUNTIME_MAP: Record = { + browser: ['chromium'], + both: ['node', 'chromium'], +}; + +/** + * Parse a test filename to extract runtimes, modifiers, and detect legacy patterns + * Algorithm: Right-to-left token analysis from the extension + */ +export function parseTestFilename( + filePath: string, + config: ParserConfig = {} +): ParsedFilename { + const strictUnknownRuntime = config.strictUnknownRuntime ?? true; + const defaultRuntimes = config.defaultRuntimes ?? ['node']; + + // Extract just the filename from the path + const fileName = filePath.split('/').pop() || filePath; + const original = fileName; + + // Step 1: Extract and validate extension + const lastDot = fileName.lastIndexOf('.'); + if (lastDot === -1) { + throw new Error(`Invalid test file: no extension found in "${fileName}"`); + } + + const extension = fileName.substring(lastDot + 1); + if (!VALID_EXTENSIONS.has(extension)) { + throw new Error( + `Invalid test file extension ".${extension}" in "${fileName}". ` + + `Valid extensions: ${Array.from(VALID_EXTENSIONS).join(', ')}` + ); + } + + // Step 2: Split remaining basename by dots + const withoutExtension = fileName.substring(0, lastDot); + const tokens = withoutExtension.split('.'); + + if (tokens.length === 0) { + throw new Error(`Invalid test file: empty basename in "${fileName}"`); + } + + // Step 3: Parse from right to left + let isLegacy = false; + const modifiers: Modifier[] = []; + let runtimes: Runtime[] = []; + let runtimeTokenIndex = -1; + + // Scan from right to left + for (let i = tokens.length - 1; i >= 0; i--) { + const token = tokens[i]; + + // Check if this is a known modifier + if (KNOWN_MODIFIERS.has(token)) { + modifiers.unshift(token as Modifier); + continue; + } + + // Check if this is a legacy runtime token + if (LEGACY_RUNTIME_MAP[token]) { + isLegacy = true; + runtimes = LEGACY_RUNTIME_MAP[token]; + runtimeTokenIndex = i; + break; + } + + // Check if this is a runtime chain (may contain + separators) + if (token.includes('+')) { + const runtimeCandidates = token.split('+').map(r => r.trim()).filter(Boolean); + const validRuntimes: Runtime[] = []; + const invalidRuntimes: string[] = []; + + for (const candidate of runtimeCandidates) { + if (KNOWN_RUNTIMES.has(candidate)) { + // Dedupe: only add if not already in list + if (!validRuntimes.includes(candidate as Runtime)) { + validRuntimes.push(candidate as Runtime); + } + } else { + invalidRuntimes.push(candidate); + } + } + + if (invalidRuntimes.length > 0) { + if (strictUnknownRuntime) { + throw new Error( + `Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` + + `Valid runtimes: ${Array.from(KNOWN_RUNTIMES).join(', ')}` + ); + } else { + console.warn( + `⚠️ Warning: Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` + + `Defaulting to: ${defaultRuntimes.join('+')}` + ); + runtimes = [...defaultRuntimes]; + runtimeTokenIndex = i; + break; + } + } + + if (validRuntimes.length > 0) { + runtimes = validRuntimes; + runtimeTokenIndex = i; + break; + } + } + + // Check if this is a single runtime token + if (KNOWN_RUNTIMES.has(token)) { + runtimes = [token as Runtime]; + runtimeTokenIndex = i; + break; + } + + // If we've scanned past modifiers and haven't found a runtime, stop looking + if (modifiers.length > 0) { + break; + } + } + + // Step 4: Determine base name + // Everything before the runtime token (if found) is the base name + const baseNameTokens = runtimeTokenIndex >= 0 ? tokens.slice(0, runtimeTokenIndex) : tokens; + const baseName = baseNameTokens.join('.'); + + // Step 5: Apply defaults if no runtime was detected + if (runtimes.length === 0) { + runtimes = [...defaultRuntimes]; + } + + return { + baseName: baseName || 'test', + runtimes, + modifiers, + extension, + isLegacy, + original, + }; +} + +/** + * Check if a filename uses legacy naming convention + */ +export function isLegacyFilename(fileName: string): boolean { + const tokens = fileName.split('.'); + for (const token of tokens) { + if (LEGACY_RUNTIME_MAP[token]) { + return true; + } + } + return false; +} + +/** + * Get the suggested new filename for a legacy filename + */ +export function getLegacyMigrationTarget(fileName: string): string | null { + const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false }); + + if (!parsed.isLegacy) { + return null; + } + + // Reconstruct filename with new naming + const parts = [parsed.baseName]; + + if (parsed.runtimes.length > 0) { + parts.push(parsed.runtimes.join('+')); + } + + if (parsed.modifiers.length > 0) { + parts.push(...parsed.modifiers); + } + + parts.push(parsed.extension); + + return parts.join('.'); +} diff --git a/ts/tstest.classes.tstest.ts b/ts/tstest.classes.tstest.ts index 14f25ec..ec0b2be 100644 --- a/ts/tstest.classes.tstest.ts +++ b/ts/tstest.classes.tstest.ts @@ -10,6 +10,14 @@ import { TestExecutionMode } from './index.js'; import { TsTestLogger } from './tstest.logging.js'; import type { LogOptions } from './tstest.logging.js'; +// Runtime adapters +import { parseTestFilename } from './tstest.classes.runtime.parser.js'; +import { RuntimeAdapterRegistry } from './tstest.classes.runtime.adapter.js'; +import { NodeRuntimeAdapter } from './tstest.classes.runtime.node.js'; +import { ChromiumRuntimeAdapter } from './tstest.classes.runtime.chromium.js'; +import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js'; +import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js'; + export class TsTest { public testDir: TestDirectory; public executionMode: TestExecutionMode; @@ -28,6 +36,8 @@ export class TsTest { public tsbundleInstance = new plugins.tsbundle.TsBundle(); + public runtimeRegistry = new RuntimeAdapterRegistry(); + 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); @@ -36,6 +46,20 @@ export class TsTest { this.startFromFile = startFromFile; this.stopAtFile = stopAtFile; this.timeoutSeconds = timeoutSeconds; + + // Register runtime adapters + this.runtimeRegistry.register( + new NodeRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags) + ); + this.runtimeRegistry.register( + new ChromiumRuntimeAdapter(this.logger, this.tsbundleInstance, this.smartbrowserInstance, this.timeoutSeconds) + ); + this.runtimeRegistry.register( + new DenoRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags) + ); + this.runtimeRegistry.register( + new BunRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags) + ); } async run() { @@ -175,29 +199,50 @@ export class TsTest { } private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) { - switch (true) { - case process.env.CI && fileNameArg.includes('.nonci.'): - this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`); - break; - case fileNameArg.endsWith('.browser.ts') || fileNameArg.endsWith('.browser.nonci.ts'): - const tapParserBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles); - tapCombinator.addTapParser(tapParserBrowser); - break; - case fileNameArg.endsWith('.both.ts') || fileNameArg.endsWith('.both.nonci.ts'): - this.logger.sectionStart('Part 1: Chrome'); - const tapParserBothBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles); - tapCombinator.addTapParser(tapParserBothBrowser); + // Parse the filename to determine runtimes and modifiers + const fileName = plugins.path.basename(fileNameArg); + const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false }); + + // Check for nonci modifier in CI environment + if (process.env.CI && parsed.modifiers.includes('nonci')) { + this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`); + return; + } + + // Show deprecation warning for legacy naming + if (parsed.isLegacy) { + console.warn(''); + console.warn(cs('⚠️ DEPRECATION WARNING', 'orange')); + console.warn(cs(` File: ${fileName}`, 'orange')); + console.warn(cs(` Legacy naming detected. Please migrate to new naming convention.`, 'orange')); + console.warn(cs(` Suggested: ${fileName.replace('.browser.', '.chromium.').replace('.both.', '.node+chromium.')}`, 'green')); + console.warn(cs(` Run: tstest migrate --dry-run`, 'cyan')); + console.warn(''); + } + + // Get adapters for the specified runtimes + const adapters = this.runtimeRegistry.getAdaptersForRuntimes(parsed.runtimes); + + if (adapters.length === 0) { + this.logger.tapOutput(`Skipping ${fileNameArg} - no runtime adapters available`); + return; + } + + // Execute tests for each runtime + if (adapters.length === 1) { + // Single runtime - no sections needed + const adapter = adapters[0]; + const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles); + tapCombinator.addTapParser(tapParser); + } else { + // Multiple runtimes - use sections + for (let i = 0; i < adapters.length; i++) { + const adapter = adapters[i]; + this.logger.sectionStart(`Part ${i + 1}: ${adapter.displayName}`); + const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles); + tapCombinator.addTapParser(tapParser); this.logger.sectionEnd(); - - this.logger.sectionStart('Part 2: Node'); - const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, totalFiles); - tapCombinator.addTapParser(tapParserBothNode); - this.logger.sectionEnd(); - break; - default: - const tapParserNode = await this.runInNode(fileNameArg, fileIndex, totalFiles); - tapCombinator.addTapParser(tapParserNode); - break; + } } }