save-test-results.js
1 #!/usr/bin/env node 2 /** 3 * TAP-to-JSON converter for test results 4 * Parses TAP output and saves to JSON format for dashboard consumption 5 */ 6 7 import { readFileSync, writeFileSync, mkdirSync } from 'fs'; 8 import { dirname } from 'path'; 9 10 function parseTapToJson(tapContent) { 11 const lines = tapContent.split('\n'); 12 const result = { 13 total: 0, 14 pass: 0, 15 fail: 0, 16 skip: 0, 17 duration_ms: 0, 18 tests: [], 19 timestamp: new Date().toISOString(), 20 }; 21 22 let currentSuite = null; 23 const suiteStack = []; 24 25 for (let i = 0; i < lines.length; i++) { 26 const line = lines[i]; 27 28 // Parse summary lines 29 if (line.startsWith('# tests ')) { 30 result.total = parseInt(line.split(' ')[2], 10); 31 } else if (line.startsWith('# pass ')) { 32 result.pass = parseInt(line.split(' ')[2], 10); 33 } else if (line.startsWith('# fail ')) { 34 result.fail = parseInt(line.split(' ')[2], 10); 35 } else if (line.startsWith('# skip ')) { 36 result.skip = parseInt(line.split(' ')[2], 10); 37 } else if (line.startsWith('# duration_ms ')) { 38 result.duration_ms = parseFloat(line.split(' ')[2]); 39 } 40 // Track test suites 41 else if (line.includes('# Subtest:')) { 42 const indent = line.search(/\S/); 43 const suiteName = line.replace('# Subtest:', '').trim(); 44 45 // Manage suite hierarchy based on indentation 46 while (suiteStack.length > 0 && suiteStack[suiteStack.length - 1].indent >= indent) { 47 suiteStack.pop(); 48 } 49 50 suiteStack.push({ name: suiteName, indent }); 51 currentSuite = suiteStack.map(s => s.name).join(' > '); 52 } 53 // Parse individual test results 54 else if (line.trim().startsWith('ok ') || line.trim().startsWith('not ok ')) { 55 // Skip suite-level results 56 const nextLine = i + 1 < lines.length ? lines[i + 1] : ''; 57 if (nextLine.includes("type: 'suite'")) { 58 continue; 59 } 60 61 const parts = line.trim().split(' '); 62 const status = parts[0] === 'ok' ? 'pass' : 'fail'; 63 const testNum = parts[1]; 64 let testName = parts.slice(2).join(' ').replace(/^- /, ''); 65 66 // Check for SKIP directive 67 const isSkipped = testName.includes('# SKIP') || testName.includes('# TODO'); 68 if (isSkipped) { 69 testName = testName.replace(/# (SKIP|TODO).*$/, '').trim(); 70 } 71 72 // Extract duration from following lines 73 let duration_ms = null; 74 for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) { 75 if (lines[j].includes('duration_ms:')) { 76 const match = lines[j].match(/duration_ms:\s*([\d.]+)/); 77 if (match) { 78 duration_ms = parseFloat(match[1]); 79 break; 80 } 81 } 82 } 83 84 result.tests.push({ 85 suite: currentSuite || 'Root', 86 name: testName, 87 status: isSkipped ? 'skip' : status, 88 duration_ms, 89 }); 90 } 91 } 92 93 return result; 94 } 95 96 // Main execution 97 const args = process.argv.slice(2); 98 if (args.length < 2) { 99 console.error('Usage: save-test-results.js <input-tap-file> <output-json-file>'); 100 process.exit(1); 101 } 102 103 const [inputFile, outputFile] = args; 104 105 try { 106 // Read TAP input 107 const tapContent = readFileSync(inputFile, 'utf8'); 108 109 // Parse to JSON 110 const jsonResult = parseTapToJson(tapContent); 111 112 // Ensure output directory exists 113 mkdirSync(dirname(outputFile), { recursive: true }); 114 115 // Write JSON output 116 writeFileSync(outputFile, JSON.stringify(jsonResult, null, 2)); 117 118 console.log(`✅ Saved test results to ${outputFile}`); 119 console.log( 120 ` Tests: ${jsonResult.total} total, ${jsonResult.pass} passed, ${jsonResult.fail} failed, ${jsonResult.skip} skipped` 121 ); 122 console.log(` Duration: ${(jsonResult.duration_ms / 1000).toFixed(2)}s`); 123 } catch (error) { 124 console.error(`❌ Error processing test results: ${error.message}`); 125 process.exit(1); 126 }