520 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			520 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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;
 | |
|   private startTime: number;
 | |
| 
 | |
|   /**
 | |
|    * the constructor for TapParser
 | |
|    */
 | |
|   constructor(public fileName: string, logger?: TsTestLogger) {
 | |
|     this.logger = logger;
 | |
|     this.protocolParser = new ProtocolParser();
 | |
|     this.startTime = Date.now();
 | |
|   }
 | |
|   
 | |
|   /**
 | |
|    * 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;
 | |
|     const duration = Date.now() - this.startTime;
 | |
| 
 | |
|     // 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, duration); // 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, duration);
 | |
|       }
 | |
|     } else if (this.getErrorTests().length === 0) {
 | |
|       if (this.logger) {
 | |
|         this.logger.tapOutput('All tests are successfull!!!');
 | |
|         this.logger.testFileEnd(this.receivedTests, 0, duration);
 | |
|       }
 | |
|     } 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, duration);
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| } |