feat(core): Implement Protocol V2 with enhanced settings and lifecycle hooks
This commit is contained in:
		| @@ -8,28 +8,27 @@ 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[] = []; | ||||
|  | ||||
|   expectedTestsRegex = /([0-9]*)\.\.([0-9]*)$/; | ||||
|   expectedTests: number; | ||||
|   receivedTests: number; | ||||
|   expectedTests: number = 0; | ||||
|   receivedTests: number = 0; | ||||
|  | ||||
|   testStatusRegex = /(ok|not\sok)\s([0-9]+)\s-\s(.*?)(\s#\s(.*))?$/; | ||||
|   activeTapTestResult: TapTestResult; | ||||
|   collectingErrorDetails: boolean = false; | ||||
|   currentTestError: string[] = []; | ||||
|  | ||||
|   pretaskRegex = /^::__PRETASK:(.*)$/; | ||||
|    | ||||
|   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(); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
| @@ -75,137 +74,299 @@ export class TapParser { | ||||
|       logLineArray.pop(); | ||||
|     } | ||||
|  | ||||
|     // lets parse the log information | ||||
|     // Process each line through the protocol parser | ||||
|     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!`); | ||||
|       const messages = this.protocolParser.parseLine(logLine); | ||||
|        | ||||
|       if (messages.length > 0) { | ||||
|         // Handle protocol messages | ||||
|         for (const message of messages) { | ||||
|           this._handleProtocolMessage(message, logLine); | ||||
|         } | ||||
|  | ||||
|         // 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]); // Currently unused | ||||
|         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; | ||||
|          | ||||
|         if (testMetadata) { | ||||
|           const timeMatch = testMetadata.match(/time=(\d+)ms/); | ||||
|           // const skipMatch = testMetadata.match(/SKIP\s*(.*)/); // Currently unused | ||||
|           // const todoMatch = testMetadata.match(/TODO\s*(.*)/); // Currently unused | ||||
|            | ||||
|           if (timeMatch) { | ||||
|             testDuration = parseInt(timeMatch[1]); | ||||
|           } | ||||
|           // Skip/todo handling could be added here in the future | ||||
|         } | ||||
|  | ||||
|         // 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) { | ||||
|       } else { | ||||
|         // Not a protocol message, handle as console output | ||||
|         if (this.activeTapTestResult) { | ||||
|           this.activeTapTestResult.addLogLine(logLine); | ||||
|         } | ||||
|          | ||||
|         // Check for snapshot communication | ||||
|         // 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) { | ||||
|           } catch (error: any) { | ||||
|             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); | ||||
|             } | ||||
|           } | ||||
|         } else if (this.logger) { | ||||
|           // This is console output from the test file | ||||
|           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'); | ||||
|   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.testErrorDetails(errorMessage); | ||||
|             this.logger.tapOutput(`Skipping all tests: ${plan.skipAll}`); | ||||
|           } | ||||
|           this.collectingErrorDetails = false; | ||||
|           this.currentTestError = []; | ||||
|         } 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 | ||||
| @@ -353,4 +514,4 @@ export class TapParser { | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user