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; } }