|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Generate GitHub Actions job summary from Jest test output |
| 4 | + * This script parses Jest test output and creates a markdown summary |
| 5 | + * showing what scenarios were tested and their results. |
| 6 | + */ |
| 7 | + |
| 8 | +import * as fs from 'fs'; |
| 9 | +import * as path from 'path'; |
| 10 | + |
| 11 | +interface TestResults { |
| 12 | + passed: number; |
| 13 | + failed: number; |
| 14 | + total: number; |
| 15 | + duration: string; |
| 16 | +} |
| 17 | + |
| 18 | +interface TestScenario { |
| 19 | + name: string; |
| 20 | + passed: boolean; |
| 21 | + isGroup?: boolean; |
| 22 | +} |
| 23 | + |
| 24 | +function parseTestOutput(output: string): { results: TestResults; scenarios: TestScenario[] } { |
| 25 | + const lines = output.split('\n'); |
| 26 | + |
| 27 | + // Extract test results from "Tests:" line |
| 28 | + const testsLine = lines.find(line => line.startsWith('Tests:')); |
| 29 | + let results: TestResults = { passed: 0, failed: 0, total: 0, duration: 'unknown' }; |
| 30 | + |
| 31 | + if (testsLine) { |
| 32 | + const passedMatch = testsLine.match(/(\d+) passed/); |
| 33 | + const failedMatch = testsLine.match(/(\d+) failed/); |
| 34 | + const totalMatch = testsLine.match(/(\d+) total/); |
| 35 | + |
| 36 | + results.passed = passedMatch ? parseInt(passedMatch[1], 10) : 0; |
| 37 | + results.failed = failedMatch ? parseInt(failedMatch[1], 10) : 0; |
| 38 | + results.total = totalMatch ? parseInt(totalMatch[1], 10) : results.passed + results.failed; |
| 39 | + } |
| 40 | + |
| 41 | + // Extract duration from "Time:" line |
| 42 | + const timeLine = lines.find(line => line.match(/Time:\s+[\d.]+\s*s/)); |
| 43 | + if (timeLine) { |
| 44 | + const timeMatch = timeLine.match(/Time:\s+([\d.]+\s*s)/); |
| 45 | + if (timeMatch) { |
| 46 | + results.duration = timeMatch[1]; |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + // Extract test scenarios |
| 51 | + const scenarios: TestScenario[] = []; |
| 52 | + |
| 53 | + for (const line of lines) { |
| 54 | + // Check for describe blocks (e.g., " 1. Happy-Path Basics") |
| 55 | + // These are indented with 4 spaces and don't have ✓ or ✗ |
| 56 | + if (line.match(/^\s{4}[0-9A-Z]/) && !line.includes('✓') && !line.includes('✗')) { |
| 57 | + const groupName = line.trim(); |
| 58 | + scenarios.push({ name: groupName, passed: true, isGroup: true }); |
| 59 | + } |
| 60 | + // Check for test results (lines with ✓ or ✗) |
| 61 | + else if (line.match(/^\s+[✓✗]/)) { |
| 62 | + const isPassed = line.includes('✓'); |
| 63 | + // Remove leading whitespace, status symbol, and timing info |
| 64 | + const name = line.trim().replace(/^[✓✗]\s*/, '').replace(/\s*\(\d+\s*ms\)$/, ''); |
| 65 | + scenarios.push({ name, passed: isPassed, isGroup: false }); |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + return { results, scenarios }; |
| 70 | +} |
| 71 | + |
| 72 | +function generateSummary(testFile: string, testName: string, output: string): string { |
| 73 | + const { results, scenarios } = parseTestOutput(output); |
| 74 | + |
| 75 | + // Determine status emoji |
| 76 | + const statusEmoji = results.failed === 0 ? '✅' : '❌'; |
| 77 | + |
| 78 | + let summary = `## ${statusEmoji} ${testName}\n\n`; |
| 79 | + summary += `**Test File:** \`${testFile}\`\n\n`; |
| 80 | + summary += `**Results:** ${results.passed} passed, ${results.failed} failed (Total: ${results.total}) in ${results.duration}\n\n`; |
| 81 | + |
| 82 | + if (scenarios.length > 0) { |
| 83 | + summary += `### Test Scenarios\n\n`; |
| 84 | + |
| 85 | + for (const scenario of scenarios) { |
| 86 | + if (scenario.isGroup) { |
| 87 | + summary += `\n**${scenario.name}**\n\n`; |
| 88 | + } else { |
| 89 | + const emoji = scenario.passed ? '✅' : '❌'; |
| 90 | + summary += `- ${emoji} ${scenario.name}\n`; |
| 91 | + } |
| 92 | + } |
| 93 | + } else { |
| 94 | + summary += `_Test details not available in output_\n\n`; |
| 95 | + summary += `<details>\n<summary>Raw Test Output</summary>\n\n\`\`\`\n${output}\n\`\`\`\n\n</details>\n`; |
| 96 | + } |
| 97 | + |
| 98 | + return summary; |
| 99 | +} |
| 100 | + |
| 101 | +function main() { |
| 102 | + const args = process.argv.slice(2); |
| 103 | + |
| 104 | + if (args.length < 2) { |
| 105 | + console.error('Usage: generate-test-summary.ts <test-file> <test-name> [<output-file>]'); |
| 106 | + process.exit(1); |
| 107 | + } |
| 108 | + |
| 109 | + const testFile = args[0]; |
| 110 | + const testName = args[1]; |
| 111 | + const outputFile = args[2]; // Optional: file containing test output |
| 112 | + |
| 113 | + // Read test output from file or stdin |
| 114 | + let testOutput: string; |
| 115 | + if (outputFile && fs.existsSync(outputFile)) { |
| 116 | + testOutput = fs.readFileSync(outputFile, 'utf-8'); |
| 117 | + } else { |
| 118 | + // Read from stdin |
| 119 | + testOutput = fs.readFileSync(0, 'utf-8'); |
| 120 | + } |
| 121 | + |
| 122 | + // Generate summary |
| 123 | + const summary = generateSummary(testFile, testName, testOutput); |
| 124 | + |
| 125 | + // Write to GITHUB_STEP_SUMMARY or stdout |
| 126 | + const summaryPath = process.env.GITHUB_STEP_SUMMARY; |
| 127 | + if (summaryPath) { |
| 128 | + fs.appendFileSync(summaryPath, summary); |
| 129 | + console.log('Summary generated successfully'); |
| 130 | + } else { |
| 131 | + console.error('Warning: GITHUB_STEP_SUMMARY not set. Running outside GitHub Actions?'); |
| 132 | + console.log('\n--- Summary ---'); |
| 133 | + console.log(summary); |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +main(); |
0 commit comments