import { ChildProcess } from 'child_process'; import { coloredString as cs } from '@push.rocks/consolecolor'; // ============ // combines different tap test files to an overall result // ============ import * as plugins from './tstest.plugins.js'; import { TapTestResult } from './tstest.classes.tap.testresult.js'; import * as logPrefixes from './tstest.logprefixes.js'; import { TsTestLogger } from './tstest.logging.js'; import { ProtocolParser } from '../dist_ts_tapbundle_protocol/index.js'; import type { IProtocolMessage, ITestResult, IPlanLine, IErrorBlock, ITestEvent } from '../dist_ts_tapbundle_protocol/index.js'; export class TapParser { testStore: TapTestResult[] = []; expectedTests: number = 0; receivedTests: number = 0; activeTapTestResult: TapTestResult; private logger: TsTestLogger; private protocolParser: ProtocolParser; private protocolVersion: string | null = null; /** * the constructor for TapParser */ constructor(public fileName: string, logger?: TsTestLogger) { this.logger = logger; this.protocolParser = new ProtocolParser(); } /** * Handle test file timeout */ public handleTimeout(timeoutSeconds: number) { // If no tests have been defined yet, set expected to 1 if (this.expectedTests === 0) { this.expectedTests = 1; } // Create a fake failing test result for timeout this._getNewTapTestResult(); this.activeTapTestResult.testOk = false; this.activeTapTestResult.testSettled = true; this.testStore.push(this.activeTapTestResult); // Log the timeout error if (this.logger) { // First log the test result this.logger.testResult( `Test file timeout`, false, timeoutSeconds * 1000, `Error: Test file exceeded timeout of ${timeoutSeconds} seconds` ); this.logger.testErrorDetails(`Test execution was terminated after ${timeoutSeconds} seconds`); } // Don't call evaluateFinalResult here, let the caller handle it } private _getNewTapTestResult() { this.activeTapTestResult = new TapTestResult(this.testStore.length + 1); } private _processLog(logChunk: Buffer | string) { if (Buffer.isBuffer(logChunk)) { logChunk = logChunk.toString(); } const logLineArray = logChunk.split('\n'); if (logLineArray[logLineArray.length - 1] === '') { logLineArray.pop(); } // Process each line through the protocol parser for (const logLine of logLineArray) { const messages = this.protocolParser.parseLine(logLine); if (messages.length > 0) { // Handle protocol messages for (const message of messages) { this._handleProtocolMessage(message, logLine); } } else { // Not a protocol message, handle as console output if (this.activeTapTestResult) { this.activeTapTestResult.addLogLine(logLine); } // Check for snapshot communication (legacy) const snapshotMatch = logLine.match(/###SNAPSHOT###(.+)###SNAPSHOT###/); if (snapshotMatch) { const base64Data = snapshotMatch[1]; try { const snapshotData = JSON.parse(Buffer.from(base64Data, 'base64').toString()); this.handleSnapshot(snapshotData); } catch (error: any) { if (this.logger) { this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`); } } } else if (this.logger) { // This is console output from the test file this.logger.testConsoleOutput(logLine); } } } } private _handleProtocolMessage(message: IProtocolMessage, originalLine: string) { switch (message.type) { case 'protocol': this.protocolVersion = message.content.version; if (this.logger) { this.logger.tapOutput(`Protocol version: ${this.protocolVersion}`); } break; case 'version': // TAP version, we can ignore this break; case 'plan': const plan = message.content as IPlanLine; this.expectedTests = plan.end - plan.start + 1; if (plan.skipAll) { if (this.logger) { this.logger.tapOutput(`Skipping all tests: ${plan.skipAll}`); } } else { if (this.logger) { this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`); } } // Initialize first TapResult this._getNewTapTestResult(); break; case 'test': const testResult = message.content as ITestResult; // Update active test result this.activeTapTestResult.setTestResult(testResult.ok); // Extract test duration from metadata let testDuration = 0; if (testResult.metadata?.time) { testDuration = testResult.metadata.time; } // Log test result if (this.logger) { if (testResult.ok) { this.logger.testResult(testResult.description, true, testDuration); } else { this.logger.testResult(testResult.description, false, testDuration); // If there's error metadata, show it if (testResult.metadata?.error) { const error = testResult.metadata.error; let errorDetails = error.message; if (error.stack) { errorDetails = error.stack; } this.logger.testErrorDetails(errorDetails); } } } // Handle directives (skip/todo) if (testResult.directive) { if (this.logger) { if (testResult.directive.type === 'skip') { this.logger.testConsoleOutput(`Test skipped: ${testResult.directive.reason || 'No reason given'}`); } else if (testResult.directive.type === 'todo') { this.logger.testConsoleOutput(`Test todo: ${testResult.directive.reason || 'No reason given'}`); } } } // Mark test as settled and move to next this.activeTapTestResult.testSettled = true; this.testStore.push(this.activeTapTestResult); this._getNewTapTestResult(); break; case 'comment': if (this.logger) { // Check if it's a pretask comment const pretaskMatch = message.content.match(/^Pretask -> (.+): Success\.$/); if (pretaskMatch) { this.logger.tapOutput(message.content); } else { this.logger.testConsoleOutput(message.content); } } break; case 'bailout': if (this.logger) { this.logger.error(`Bail out! ${message.content}`); } break; case 'error': const errorBlock = message.content as IErrorBlock; if (this.logger && errorBlock.error) { let errorDetails = errorBlock.error.message; if (errorBlock.error.stack) { errorDetails = errorBlock.error.stack; } this.logger.testErrorDetails(errorDetails); } break; case 'snapshot': // Handle new protocol snapshot format const snapshot = message.content; this.handleSnapshot({ path: snapshot.name, content: typeof snapshot.content === 'string' ? snapshot.content : JSON.stringify(snapshot.content), action: 'compare' // Default action }); break; case 'event': const event = message.content as ITestEvent; this._handleTestEvent(event); break; } } private _handleTestEvent(event: ITestEvent) { if (!this.logger) return; switch (event.eventType) { case 'test:queued': // We can track queued tests if needed break; case 'test:started': this.logger.testConsoleOutput(cs(`Test starting: ${event.data.description}`, 'cyan')); if (event.data.retry) { this.logger.testConsoleOutput(cs(` Retry attempt ${event.data.retry}`, 'orange')); } break; case 'test:progress': if (event.data.progress !== undefined) { this.logger.testConsoleOutput(cs(` Progress: ${event.data.progress}%`, 'cyan')); } break; case 'test:completed': // Test completion is already handled by the test result // This event provides additional timing info if needed break; case 'suite:started': this.logger.testConsoleOutput(cs(`\nSuite: ${event.data.suiteName}`, 'blue')); break; case 'suite:completed': this.logger.testConsoleOutput(cs(`Suite completed: ${event.data.suiteName}\n`, 'blue')); break; case 'hook:started': this.logger.testConsoleOutput(cs(` Hook: ${event.data.hookName}`, 'cyan')); break; case 'hook:completed': // Silent unless there's an error if (event.data.error) { this.logger.testConsoleOutput(cs(` Hook failed: ${event.data.hookName}`, 'red')); } break; case 'assertion:failed': // Enhanced assertion failure with diff if (event.data.error) { this._displayAssertionError(event.data.error); } break; } } private _displayAssertionError(error: any) { if (!this.logger) return; // Display error message if (error.message) { this.logger.testErrorDetails(error.message); } // Display visual diff if available if (error.diff) { this._displayDiff(error.diff, error.expected, error.actual); } } private _displayDiff(diff: any, expected: any, actual: any) { if (!this.logger) return; this.logger.testConsoleOutput(cs('\n Diff:', 'cyan')); switch (diff.type) { case 'string': this._displayStringDiff(diff.changes); break; case 'object': this._displayObjectDiff(diff.changes, expected, actual); break; case 'array': this._displayArrayDiff(diff.changes, expected, actual); break; case 'primitive': this._displayPrimitiveDiff(diff.changes); break; } } private _displayStringDiff(changes: any[]) { for (const change of changes) { const linePrefix = ` Line ${change.line + 1}: `; if (change.type === 'add') { this.logger.testConsoleOutput(cs(`${linePrefix}+ ${change.content}`, 'green')); } else if (change.type === 'remove') { this.logger.testConsoleOutput(cs(`${linePrefix}- ${change.content}`, 'red')); } } } private _displayObjectDiff(changes: any[], expected: any, actual: any) { this.logger.testConsoleOutput(cs(' Expected:', 'red')); this.logger.testConsoleOutput(` ${JSON.stringify(expected, null, 2)}`); this.logger.testConsoleOutput(cs(' Actual:', 'green')); this.logger.testConsoleOutput(` ${JSON.stringify(actual, null, 2)}`); this.logger.testConsoleOutput(cs('\n Changes:', 'cyan')); for (const change of changes) { const path = change.path.join('.'); if (change.type === 'add') { this.logger.testConsoleOutput(cs(` + ${path}: ${JSON.stringify(change.newValue)}`, 'green')); } else if (change.type === 'remove') { this.logger.testConsoleOutput(cs(` - ${path}: ${JSON.stringify(change.oldValue)}`, 'red')); } else if (change.type === 'modify') { this.logger.testConsoleOutput(cs(` ~ ${path}:`, 'cyan')); this.logger.testConsoleOutput(cs(` - ${JSON.stringify(change.oldValue)}`, 'red')); this.logger.testConsoleOutput(cs(` + ${JSON.stringify(change.newValue)}`, 'green')); } } } private _displayArrayDiff(changes: any[], expected: any[], actual: any[]) { this._displayObjectDiff(changes, expected, actual); } private _displayPrimitiveDiff(changes: any[]) { const change = changes[0]; if (change) { this.logger.testConsoleOutput(cs(` Expected: ${JSON.stringify(change.oldValue)}`, 'red')); this.logger.testConsoleOutput(cs(` Actual: ${JSON.stringify(change.newValue)}`, 'green')); } } /** * returns all tests that are not completed */ public getUncompletedTests() { // TODO: } /** * returns all tests that threw an error */ public getErrorTests() { return this.testStore.filter((tapTestArg) => { return !tapTestArg.testOk; }); } /** * returns a test overview as string */ getTestOverviewAsString() { let overviewString = ''; for (const test of this.testStore) { if (overviewString !== '') { overviewString += ' | '; } if (test.testOk) { overviewString += cs(`T${test.id} ${plugins.figures.tick}`, 'green'); } else { overviewString += cs(`T${test.id} ${plugins.figures.cross}`, 'red'); } } return overviewString; } /** * handles a tap process * @param childProcessArg */ public async handleTapProcess(childProcessArg: ChildProcess) { const done = plugins.smartpromise.defer(); childProcessArg.stdout.on('data', (data) => { this._processLog(data); }); childProcessArg.stderr.on('data', (data) => { this._processLog(data); }); childProcessArg.on('exit', async () => { await this.evaluateFinalResult(); done.resolve(); }); await done.promise; } public async handleTapLog(tapLog: string) { this._processLog(tapLog); } /** * Handle snapshot data from the test */ private async handleSnapshot(snapshotData: { path: string; content: string; action: string }) { try { const smartfile = await import('@push.rocks/smartfile'); if (snapshotData.action === 'compare') { // Try to read existing snapshot try { const existingSnapshot = await smartfile.fs.toStringSync(snapshotData.path); if (existingSnapshot !== snapshotData.content) { // Snapshot mismatch if (this.logger) { this.logger.testConsoleOutput(`Snapshot mismatch: ${snapshotData.path}`); this.logger.testConsoleOutput(`Expected:\n${existingSnapshot}`); this.logger.testConsoleOutput(`Received:\n${snapshotData.content}`); } // TODO: Communicate failure back to the test } else { if (this.logger) { this.logger.testConsoleOutput(`Snapshot matched: ${snapshotData.path}`); } } } catch (error: any) { if (error.code === 'ENOENT') { // Snapshot doesn't exist, create it const dirPath = snapshotData.path.substring(0, snapshotData.path.lastIndexOf('/')); await smartfile.fs.ensureDir(dirPath); await smartfile.memory.toFs(snapshotData.content, snapshotData.path); if (this.logger) { this.logger.testConsoleOutput(`Snapshot created: ${snapshotData.path}`); } } else { throw error; } } } else if (snapshotData.action === 'update') { // Update snapshot const dirPath = snapshotData.path.substring(0, snapshotData.path.lastIndexOf('/')); await smartfile.fs.ensureDir(dirPath); await smartfile.memory.toFs(snapshotData.content, snapshotData.path); if (this.logger) { this.logger.testConsoleOutput(`Snapshot updated: ${snapshotData.path}`); } } } catch (error: any) { if (this.logger) { this.logger.testConsoleOutput(`Error handling snapshot: ${error.message}`); } } } public async evaluateFinalResult() { this.receivedTests = this.testStore.length; // check wether all tests ran if (this.expectedTests === this.receivedTests) { if (this.logger) { this.logger.tapOutput(`${this.receivedTests} out of ${this.expectedTests} Tests completed!`); } } else { if (this.logger) { this.logger.error(`Only ${this.receivedTests} out of ${this.expectedTests} completed!`); } } if (!this.expectedTests && this.receivedTests === 0) { if (this.logger) { this.logger.error('No tests were defined. Therefore the testfile failed!'); this.logger.testFileEnd(0, 1, 0); // Count as 1 failure } } else if (this.expectedTests !== this.receivedTests) { if (this.logger) { this.logger.error('The amount of received tests and expectedTests is unequal! Therefore the testfile failed'); const errorCount = this.getErrorTests().length || 1; // At least 1 error this.logger.testFileEnd(this.receivedTests - errorCount, errorCount, 0); } } else if (this.getErrorTests().length === 0) { if (this.logger) { this.logger.tapOutput('All tests are successfull!!!'); this.logger.testFileEnd(this.receivedTests, 0, 0); } } else { if (this.logger) { this.logger.tapOutput(`${this.getErrorTests().length} tests threw an error!!!`, true); this.logger.testFileEnd(this.receivedTests - this.getErrorTests().length, this.getErrorTests().length, 0); } } } }