/** * Internal protocol constants and utilities for improved TAP communication * between tapbundle and tstest */ export const PROTOCOL = { VERSION: '2.0', MARKERS: { START: '⟦TSTEST:', END: '⟧', BLOCK_END: '⟦/TSTEST:', }, TYPES: { META: 'META', ERROR: 'ERROR', SKIP: 'SKIP', TODO: 'TODO', SNAPSHOT: 'SNAPSHOT', PROTOCOL: 'PROTOCOL', } } as const; export interface TestMetadata { // Timing time?: number; // milliseconds startTime?: number; // Unix timestamp endTime?: number; // Unix timestamp // Status skip?: string; // skip reason todo?: string; // todo reason retry?: number; // retry attempt maxRetries?: number; // max retries allowed // Error details error?: { message: string; stack?: string; diff?: string; actual?: any; expected?: any; }; // Test context file?: string; // source file line?: number; // line number column?: number; // column number // Custom data tags?: string[]; // test tags custom?: Record; } export class ProtocolEncoder { /** * Encode metadata for inline inclusion */ static encodeInline(type: string, data: any): string { if (typeof data === 'string') { return `${PROTOCOL.MARKERS.START}${type}:${data}${PROTOCOL.MARKERS.END}`; } return `${PROTOCOL.MARKERS.START}${type}:${JSON.stringify(data)}${PROTOCOL.MARKERS.END}`; } /** * Encode block data for multi-line content */ static encodeBlock(type: string, data: any): string[] { const lines: string[] = []; lines.push(`${PROTOCOL.MARKERS.START}${type}${PROTOCOL.MARKERS.END}`); if (typeof data === 'string') { lines.push(data); } else { lines.push(JSON.stringify(data, null, 2)); } lines.push(`${PROTOCOL.MARKERS.BLOCK_END}${type}${PROTOCOL.MARKERS.END}`); return lines; } /** * Create a TAP line with metadata */ static createTestLine( status: 'ok' | 'not ok', number: number, description: string, metadata?: TestMetadata ): string { let line = `${status} ${number} - ${description}`; if (metadata) { // For skip/todo, use inline format for compatibility if (metadata.skip) { line += ` ${this.encodeInline(PROTOCOL.TYPES.SKIP, metadata.skip)}`; } else if (metadata.todo) { line += ` ${this.encodeInline(PROTOCOL.TYPES.TODO, metadata.todo)}`; } else { // For other metadata, append inline const metaCopy = { ...metadata }; delete metaCopy.error; // Error details go in separate block if (Object.keys(metaCopy).length > 0) { line += ` ${this.encodeInline(PROTOCOL.TYPES.META, metaCopy)}`; } } } return line; } } export class ProtocolDecoder { /** * Extract all protocol markers from a line */ static extractMarkers(line: string): Array<{type: string, data: any, start: number, end: number}> { const markers: Array<{type: string, data: any, start: number, end: number}> = []; let searchFrom = 0; while (true) { const start = line.indexOf(PROTOCOL.MARKERS.START, searchFrom); if (start === -1) break; const end = line.indexOf(PROTOCOL.MARKERS.END, start); if (end === -1) break; const content = line.substring(start + PROTOCOL.MARKERS.START.length, end); const colonIndex = content.indexOf(':'); if (colonIndex !== -1) { const type = content.substring(0, colonIndex); const dataStr = content.substring(colonIndex + 1); let data: any; try { // Try to parse as JSON first data = JSON.parse(dataStr); } catch { // If not JSON, treat as string data = dataStr; } markers.push({ type, data, start, end: end + PROTOCOL.MARKERS.END.length }); } searchFrom = end + 1; } return markers; } /** * Remove protocol markers from a line */ static cleanLine(line: string): string { const markers = this.extractMarkers(line); // Remove markers from end to start to preserve indices let cleanedLine = line; for (let i = markers.length - 1; i >= 0; i--) { const marker = markers[i]; cleanedLine = cleanedLine.substring(0, marker.start) + cleanedLine.substring(marker.end); } return cleanedLine.trim(); } /** * Parse a test line and extract metadata */ static parseTestLine(line: string): { cleaned: string; metadata: TestMetadata; } { const markers = this.extractMarkers(line); const metadata: TestMetadata = {}; for (const marker of markers) { switch (marker.type) { case PROTOCOL.TYPES.META: Object.assign(metadata, marker.data); break; case PROTOCOL.TYPES.SKIP: metadata.skip = marker.data; break; case PROTOCOL.TYPES.TODO: metadata.todo = marker.data; break; } } return { cleaned: this.cleanLine(line), metadata }; } /** * Check if a line starts a protocol block */ static isBlockStart(line: string): {isBlock: boolean, type?: string} { const trimmed = line.trim(); if (trimmed.startsWith(PROTOCOL.MARKERS.START) && trimmed.endsWith(PROTOCOL.MARKERS.END)) { const content = trimmed.slice(PROTOCOL.MARKERS.START.length, -PROTOCOL.MARKERS.END.length); if (!content.includes(':')) { return { isBlock: true, type: content }; } } return { isBlock: false }; } /** * Check if a line ends a protocol block */ static isBlockEnd(line: string, type: string): boolean { return line.trim() === `${PROTOCOL.MARKERS.BLOCK_END}${type}${PROTOCOL.MARKERS.END}`; } }