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'; export class TapParser { testStore: TapTestResult[] = []; expectedTestsRegex = /([0-9]*)\.\.([0-9]*)$/; expectedTests: number; receivedTests: number; testStatusRegex = /(ok|not\sok)\s([0-9]+)\s-\s(.*?)(\s#\s(.*))?$/; activeTapTestResult: TapTestResult; collectingErrorDetails: boolean = false; currentTestError: string[] = []; pretaskRegex = /^::__PRETASK:(.*)$/; private logger: TsTestLogger; /** * the constructor for TapParser */ constructor(public fileName: string, logger?: TsTestLogger) { this.logger = logger; } 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(); } // lets parse the log information for (const logLine of logLineArray) { let logLineIsTapProtocol = false; if (!this.expectedTests && this.expectedTestsRegex.test(logLine)) { logLineIsTapProtocol = true; const regexResult = this.expectedTestsRegex.exec(logLine); this.expectedTests = parseInt(regexResult[2]); if (this.logger) { this.logger.tapOutput(`Expecting ${this.expectedTests} tests!`); } // initiating first TapResult this._getNewTapTestResult(); } else if (this.pretaskRegex.test(logLine)) { logLineIsTapProtocol = true; const pretaskContentMatch = this.pretaskRegex.exec(logLine); if (pretaskContentMatch && pretaskContentMatch[1]) { if (this.logger) { this.logger.tapOutput(`Pretask -> ${pretaskContentMatch[1]}: Success.`); } } } else if (this.testStatusRegex.test(logLine)) { logLineIsTapProtocol = true; const regexResult = this.testStatusRegex.exec(logLine); const testId = parseInt(regexResult[2]); const testOk = (() => { if (regexResult[1] === 'ok') { return true; } return false; })(); const testSubject = regexResult[3].trim(); const testMetadata = regexResult[5]; // This will be either "time=XXXms" or "SKIP reason" or "TODO reason" let testDuration = 0; let isSkipped = false; let isTodo = false; if (testMetadata) { const timeMatch = testMetadata.match(/time=(\d+)ms/); const skipMatch = testMetadata.match(/SKIP\s*(.*)/); const todoMatch = testMetadata.match(/TODO\s*(.*)/); if (timeMatch) { testDuration = parseInt(timeMatch[1]); } else if (skipMatch) { isSkipped = true; } else if (todoMatch) { isTodo = true; } } // test for protocol error - disabled as it's not critical // The test ID mismatch can occur when tests are filtered, skipped, or use todo // if (testId !== this.activeTapTestResult.id) { // if (this.logger) { // this.logger.error('Something is strange! Test Ids are not equal!'); // } // } this.activeTapTestResult.setTestResult(testOk); if (testOk) { if (this.logger) { this.logger.testResult(testSubject, true, testDuration); } } else { // Start collecting error details for failed test this.collectingErrorDetails = true; this.currentTestError = []; if (this.logger) { this.logger.testResult(testSubject, false, testDuration); } } } if (!logLineIsTapProtocol) { if (this.activeTapTestResult) { this.activeTapTestResult.addLogLine(logLine); } // Check for snapshot communication 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) { if (this.logger) { this.logger.testConsoleOutput(`Error parsing snapshot data: ${error.message}`); } } } else { // Check if we're collecting error details if (this.collectingErrorDetails) { // Check if this line is an error detail (starts with Error: or has stack trace characteristics) if (logLine.trim().startsWith('Error:') || logLine.trim().match(/^\s*at\s/)) { this.currentTestError.push(logLine); } else if (this.currentTestError.length > 0) { // End of error details, show the error const errorMessage = this.currentTestError.join('\n'); if (this.logger) { this.logger.testErrorDetails(errorMessage); } this.collectingErrorDetails = false; this.currentTestError = []; } } // Don't output TAP error details as console output when we're collecting them if (!this.collectingErrorDetails || (!logLine.trim().startsWith('Error:') && !logLine.trim().match(/^\s*at\s/))) { if (this.logger) { // This is console output from the test file, not TAP protocol this.logger.testConsoleOutput(logLine); } } } } if (this.activeTapTestResult && this.activeTapTestResult.testSettled) { // Ensure any pending error is shown before settling the test if (this.collectingErrorDetails && this.currentTestError.length > 0) { const errorMessage = this.currentTestError.join('\n'); if (this.logger) { this.logger.testErrorDetails(errorMessage); } this.collectingErrorDetails = false; this.currentTestError = []; } this.testStore.push(this.activeTapTestResult); this._getNewTapTestResult(); } } } /** * 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) { if (this.logger) { this.logger.error('No tests were defined. Therefore the testfile failed!'); } } 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'); } } 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); } } } }