407 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			407 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import type {
 | |
|   ITestResult,
 | |
|   ITestMetadata,
 | |
|   IPlanLine,
 | |
|   IProtocolMessage,
 | |
|   ISnapshotData,
 | |
|   IErrorBlock,
 | |
|   ITestEvent
 | |
| } from './protocol.types.js';
 | |
| 
 | |
| import {
 | |
|   PROTOCOL_MARKERS
 | |
| } from './protocol.types.js';
 | |
| 
 | |
| /**
 | |
|  * ProtocolParser parses Protocol V2 messages
 | |
|  * This class is used by tstest to parse test results from the new protocol format
 | |
|  */
 | |
| export class ProtocolParser {
 | |
|   private protocolVersion: string | null = null;
 | |
|   private inBlock = false;
 | |
|   private blockType: string | null = null;
 | |
|   private blockContent: string[] = [];
 | |
| 
 | |
|   /**
 | |
|    * Parse a single line and return protocol messages
 | |
|    */
 | |
|   public parseLine(line: string): IProtocolMessage[] {
 | |
|     const messages: IProtocolMessage[] = [];
 | |
| 
 | |
|     // Handle block content
 | |
|     if (this.inBlock) {
 | |
|       if (this.isBlockEnd(line)) {
 | |
|         messages.push(this.finalizeBlock());
 | |
|         this.inBlock = false;
 | |
|         this.blockType = null;
 | |
|         this.blockContent = [];
 | |
|       } else {
 | |
|         this.blockContent.push(line);
 | |
|       }
 | |
|       return messages;
 | |
|     }
 | |
| 
 | |
|     // Check for block start
 | |
|     if (this.isBlockStart(line)) {
 | |
|       this.inBlock = true;
 | |
|       this.blockType = this.extractBlockType(line);
 | |
|       return messages;
 | |
|     }
 | |
| 
 | |
|     // Check for protocol version
 | |
|     const protocolVersion = this.parseProtocolVersion(line);
 | |
|     if (protocolVersion) {
 | |
|       this.protocolVersion = protocolVersion;
 | |
|       messages.push({
 | |
|         type: 'protocol',
 | |
|         content: { version: protocolVersion }
 | |
|       });
 | |
|       return messages;
 | |
|     }
 | |
| 
 | |
|     // Parse TAP version
 | |
|     const tapVersion = this.parseTapVersion(line);
 | |
|     if (tapVersion !== null) {
 | |
|       messages.push({
 | |
|         type: 'version',
 | |
|         content: tapVersion
 | |
|       });
 | |
|       return messages;
 | |
|     }
 | |
| 
 | |
|     // Parse plan
 | |
|     const plan = this.parsePlan(line);
 | |
|     if (plan) {
 | |
|       messages.push({
 | |
|         type: 'plan',
 | |
|         content: plan
 | |
|       });
 | |
|       return messages;
 | |
|     }
 | |
| 
 | |
|     // Parse bailout
 | |
|     const bailout = this.parseBailout(line);
 | |
|     if (bailout) {
 | |
|       messages.push({
 | |
|         type: 'bailout',
 | |
|         content: bailout
 | |
|       });
 | |
|       return messages;
 | |
|     }
 | |
| 
 | |
|     // Parse comment
 | |
|     if (this.isComment(line)) {
 | |
|       messages.push({
 | |
|         type: 'comment',
 | |
|         content: line.substring(2) // Remove "# "
 | |
|       });
 | |
|       return messages;
 | |
|     }
 | |
| 
 | |
|     // Parse test result
 | |
|     const testResult = this.parseTestResult(line);
 | |
|     if (testResult) {
 | |
|       messages.push({
 | |
|         type: 'test',
 | |
|         content: testResult
 | |
|       });
 | |
|       return messages;
 | |
|     }
 | |
| 
 | |
|     // Parse event
 | |
|     const event = this.parseEvent(line);
 | |
|     if (event) {
 | |
|       messages.push({
 | |
|         type: 'event',
 | |
|         content: event
 | |
|       });
 | |
|       return messages;
 | |
|     }
 | |
| 
 | |
|     return messages;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Parse protocol version header
 | |
|    */
 | |
|   private parseProtocolVersion(line: string): string | null {
 | |
|     const match = this.extractProtocolData(line, PROTOCOL_MARKERS.PROTOCOL_PREFIX);
 | |
|     return match;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Parse TAP version line
 | |
|    */
 | |
|   private parseTapVersion(line: string): number | null {
 | |
|     const match = line.match(/^TAP version (\d+)$/);
 | |
|     if (match) {
 | |
|       return parseInt(match[1], 10);
 | |
|     }
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Parse plan line
 | |
|    */
 | |
|   private parsePlan(line: string): IPlanLine | null {
 | |
|     // Skip all plan
 | |
|     const skipMatch = line.match(/^1\.\.0\s*#\s*Skipped:\s*(.*)$/);
 | |
|     if (skipMatch) {
 | |
|       return {
 | |
|         start: 1,
 | |
|         end: 0,
 | |
|         skipAll: skipMatch[1]
 | |
|       };
 | |
|     }
 | |
| 
 | |
|     // Normal plan
 | |
|     const match = line.match(/^(\d+)\.\.(\d+)$/);
 | |
|     if (match) {
 | |
|       return {
 | |
|         start: parseInt(match[1], 10),
 | |
|         end: parseInt(match[2], 10)
 | |
|       };
 | |
|     }
 | |
| 
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Parse bailout
 | |
|    */
 | |
|   private parseBailout(line: string): string | null {
 | |
|     const match = line.match(/^Bail out!\s*(.*)$/);
 | |
|     return match ? match[1] : null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Parse event
 | |
|    */
 | |
|   private parseEvent(line: string): ITestEvent | null {
 | |
|     const eventData = this.extractProtocolData(line, PROTOCOL_MARKERS.EVENT_PREFIX);
 | |
|     if (eventData) {
 | |
|       try {
 | |
|         return JSON.parse(eventData) as ITestEvent;
 | |
|       } catch (e) {
 | |
|         // Invalid JSON, ignore
 | |
|         return null;
 | |
|       }
 | |
|     }
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Check if line is a comment
 | |
|    */
 | |
|   private isComment(line: string): boolean {
 | |
|     return line.startsWith('# ');
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Parse test result line
 | |
|    */
 | |
|   private parseTestResult(line: string): ITestResult | null {
 | |
|     // First extract any inline metadata
 | |
|     const metadata = this.extractInlineMetadata(line);
 | |
|     const cleanLine = this.removeInlineMetadata(line);
 | |
| 
 | |
|     // Parse the TAP part
 | |
|     const tapMatch = cleanLine.match(/^(ok|not ok)\s+(\d+)\s*-?\s*(.*)$/);
 | |
|     if (!tapMatch) {
 | |
|       return null;
 | |
|     }
 | |
| 
 | |
|     const result: ITestResult = {
 | |
|       ok: tapMatch[1] === 'ok',
 | |
|       testNumber: parseInt(tapMatch[2], 10),
 | |
|       description: tapMatch[3].trim()
 | |
|     };
 | |
| 
 | |
|     // Parse directive
 | |
|     const directiveMatch = result.description.match(/^(.*?)\s*#\s*(SKIP|TODO)\s*(.*)$/i);
 | |
|     if (directiveMatch) {
 | |
|       result.description = directiveMatch[1].trim();
 | |
|       result.directive = {
 | |
|         type: directiveMatch[2].toLowerCase() as 'skip' | 'todo',
 | |
|         reason: directiveMatch[3] || undefined
 | |
|       };
 | |
|     }
 | |
| 
 | |
|     // Add metadata if found
 | |
|     if (metadata) {
 | |
|       result.metadata = metadata;
 | |
|     }
 | |
| 
 | |
|     return result;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Extract inline metadata from line
 | |
|    */
 | |
|   private extractInlineMetadata(line: string): ITestMetadata | null {
 | |
|     const metadata: ITestMetadata = {};
 | |
|     let hasData = false;
 | |
| 
 | |
|     // Extract skip reason
 | |
|     const skipData = this.extractProtocolData(line, PROTOCOL_MARKERS.SKIP_PREFIX);
 | |
|     if (skipData) {
 | |
|       metadata.skip = skipData;
 | |
|       hasData = true;
 | |
|     }
 | |
| 
 | |
|     // Extract todo reason
 | |
|     const todoData = this.extractProtocolData(line, PROTOCOL_MARKERS.TODO_PREFIX);
 | |
|     if (todoData) {
 | |
|       metadata.todo = todoData;
 | |
|       hasData = true;
 | |
|     }
 | |
| 
 | |
|     // Extract META JSON
 | |
|     const metaData = this.extractProtocolData(line, PROTOCOL_MARKERS.META_PREFIX);
 | |
|     if (metaData) {
 | |
|       try {
 | |
|         Object.assign(metadata, JSON.parse(metaData));
 | |
|         hasData = true;
 | |
|       } catch (e) {
 | |
|         // Invalid JSON, ignore
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Extract simple key:value pairs
 | |
|     const simpleMatch = line.match(new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]+)${this.escapeRegex(PROTOCOL_MARKERS.END)}`));
 | |
|     if (simpleMatch && simpleMatch[1].includes(':') && !simpleMatch[1].includes('META:') && !simpleMatch[1].includes('SKIP:') && !simpleMatch[1].includes('TODO:') && !simpleMatch[1].includes('EVENT:')) {
 | |
|       // This is a simple key:value format (not a prefixed format)
 | |
|       const pairs = simpleMatch[1].split(',');
 | |
|       for (const pair of pairs) {
 | |
|         const [key, value] = pair.split(':');
 | |
|         if (key && value) {
 | |
|           if (key === 'time') {
 | |
|             metadata.time = parseInt(value, 10);
 | |
|             hasData = true;
 | |
|           } else if (key === 'retry') {
 | |
|             metadata.retry = parseInt(value, 10);
 | |
|             hasData = true;
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return hasData ? metadata : null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Remove inline metadata from line
 | |
|    */
 | |
|   private removeInlineMetadata(line: string): string {
 | |
|     // Remove all protocol markers
 | |
|     const regex = new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}[^${this.escapeRegex(PROTOCOL_MARKERS.END)}]*${this.escapeRegex(PROTOCOL_MARKERS.END)}`, 'g');
 | |
|     return line.replace(regex, '').trim();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Extract protocol data with specific prefix
 | |
|    */
 | |
|   private extractProtocolData(line: string, prefix: string): string | null {
 | |
|     const regex = new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}${this.escapeRegex(prefix)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]*)${this.escapeRegex(PROTOCOL_MARKERS.END)}`);
 | |
|     const match = line.match(regex);
 | |
|     return match ? match[1] : null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Check if line starts a block
 | |
|    */
 | |
|   private isBlockStart(line: string): boolean {
 | |
|     // Only match if the line is exactly the block marker (after trimming)
 | |
|     const trimmed = line.trim();
 | |
|     return trimmed === `${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_PREFIX}${PROTOCOL_MARKERS.END}` ||
 | |
|            (trimmed.startsWith(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_PREFIX}`) && 
 | |
|             trimmed.endsWith(PROTOCOL_MARKERS.END) &&
 | |
|             !trimmed.includes(' '));
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Check if line ends a block
 | |
|    */
 | |
|   private isBlockEnd(line: string): boolean {
 | |
|     return line.includes(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.ERROR_END}${PROTOCOL_MARKERS.END}`) ||
 | |
|            line.includes(`${PROTOCOL_MARKERS.START}${PROTOCOL_MARKERS.SNAPSHOT_END}${PROTOCOL_MARKERS.END}`);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Extract block type from start line
 | |
|    */
 | |
|   private extractBlockType(line: string): string | null {
 | |
|     if (line.includes(PROTOCOL_MARKERS.ERROR_PREFIX)) {
 | |
|       return 'error';
 | |
|     }
 | |
|     if (line.includes(PROTOCOL_MARKERS.SNAPSHOT_PREFIX)) {
 | |
|       const match = line.match(new RegExp(`${this.escapeRegex(PROTOCOL_MARKERS.START)}${this.escapeRegex(PROTOCOL_MARKERS.SNAPSHOT_PREFIX)}([^${this.escapeRegex(PROTOCOL_MARKERS.END)}]*)${this.escapeRegex(PROTOCOL_MARKERS.END)}`));
 | |
|       return match ? `snapshot:${match[1]}` : 'snapshot';
 | |
|     }
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Finalize current block
 | |
|    */
 | |
|   private finalizeBlock(): IProtocolMessage {
 | |
|     const content = this.blockContent.join('\n');
 | |
| 
 | |
|     if (this.blockType === 'error') {
 | |
|       try {
 | |
|         const errorData = JSON.parse(content) as IErrorBlock;
 | |
|         return {
 | |
|           type: 'error',
 | |
|           content: errorData
 | |
|         };
 | |
|       } catch (e) {
 | |
|         return {
 | |
|           type: 'error',
 | |
|           content: { error: { message: content } }
 | |
|         };
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (this.blockType?.startsWith('snapshot:')) {
 | |
|       const name = this.blockType.substring(9);
 | |
|       let parsedContent = content;
 | |
|       let format: 'json' | 'text' = 'text';
 | |
| 
 | |
|       try {
 | |
|         parsedContent = JSON.parse(content);
 | |
|         format = 'json';
 | |
|       } catch (e) {
 | |
|         // Not JSON, keep as text
 | |
|       }
 | |
| 
 | |
|       return {
 | |
|         type: 'snapshot',
 | |
|         content: {
 | |
|           name,
 | |
|           content: parsedContent,
 | |
|           format
 | |
|         } as ISnapshotData
 | |
|       };
 | |
|     }
 | |
| 
 | |
|     // Fallback
 | |
|     return {
 | |
|       type: 'comment',
 | |
|       content: content
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Escape regex special characters
 | |
|    */
 | |
|   private escapeRegex(str: string): string {
 | |
|     return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get protocol version
 | |
|    */
 | |
|   public getProtocolVersion(): string | null {
 | |
|     return this.protocolVersion;
 | |
|   }
 | |
| } |