#!/usr/bin/env tsx /** * Merge Coverage Script * * Combines Jest unit test coverage with Playwright E2E test coverage * to generate a comprehensive combined coverage report. * * Usage: * npm run coverage:merge * # or directly: * tsx scripts/merge-coverage.ts * * Prerequisites: * - Jest coverage must exist at: ./coverage/coverage-final.json * - E2E coverage must exist at: ./coverage-e2e/.nyc_output/*.json * * Output: * - Combined coverage report in: ./coverage-combined/ * - Formats: HTML, text, JSON, LCOV */ import { createCoverageMap } from 'istanbul-lib-coverage'; import { createContext } from 'istanbul-lib-report'; import reports from 'istanbul-reports'; import fs from 'fs'; import path from 'path'; interface CoverageData { [key: string]: any; } interface MergeStats { jestFiles: number; e2eFiles: number; combinedFiles: number; jestOnlyFiles: string[]; e2eOnlyFiles: string[]; sharedFiles: string[]; } async function mergeCoverage() { console.log('\nšŸ”„ Merging Coverage Data...\n'); const map = createCoverageMap(); const stats: MergeStats = { jestFiles: 0, e2eFiles: 0, combinedFiles: 0, jestOnlyFiles: [], e2eOnlyFiles: [], sharedFiles: [], }; const jestFiles = new Set(); const e2eFiles = new Set(); // Step 1: Load Jest coverage console.log('šŸ“Š Loading Jest unit test coverage...'); const jestCoveragePath = path.join(process.cwd(), 'coverage/coverage-final.json'); if (fs.existsSync(jestCoveragePath)) { const jestCoverage: CoverageData = JSON.parse(fs.readFileSync(jestCoveragePath, 'utf-8')); Object.keys(jestCoverage).forEach((file) => jestFiles.add(file)); stats.jestFiles = jestFiles.size; console.log(` āœ… Loaded ${stats.jestFiles} files from Jest coverage`); map.merge(jestCoverage); } else { console.log(' āš ļø No Jest coverage found at:', jestCoveragePath); console.log(' Run: npm run test:coverage'); } // Step 2: Load E2E coverage console.log('\nšŸŽ­ Loading Playwright E2E test coverage...'); const e2eDir = path.join(process.cwd(), 'coverage-e2e/.nyc_output'); if (fs.existsSync(e2eDir)) { const files = fs.readdirSync(e2eDir).filter((f) => f.endsWith('.json')); if (files.length === 0) { console.log(' āš ļø No E2E coverage files found in:', e2eDir); console.log(' Run: E2E_COVERAGE=true npm run test:e2e'); } else { for (const file of files) { const coverage: CoverageData = JSON.parse( fs.readFileSync(path.join(e2eDir, file), 'utf-8') ); Object.keys(coverage).forEach((f) => e2eFiles.add(f)); map.merge(coverage); console.log(` āœ… Loaded E2E coverage from: ${file}`); } stats.e2eFiles = e2eFiles.size; console.log(` šŸ“ Total unique files in E2E coverage: ${stats.e2eFiles}`); } } else { console.log(' āš ļø No E2E coverage directory found at:', e2eDir); console.log(' Run: E2E_COVERAGE=true npm run test:e2e'); } // Step 3: Calculate statistics stats.combinedFiles = map.files().length; map.files().forEach((file) => { const inJest = jestFiles.has(file); const inE2E = e2eFiles.has(file); if (inJest && inE2E) { stats.sharedFiles.push(file); } else if (inJest) { stats.jestOnlyFiles.push(file); } else if (inE2E) { stats.e2eOnlyFiles.push(file); } }); // Step 4: Generate reports console.log('\nšŸ“ Generating combined coverage reports...'); const reportDir = path.join(process.cwd(), 'coverage-combined'); fs.mkdirSync(reportDir, { recursive: true }); const context = createContext({ dir: reportDir, coverageMap: map, }); const reportTypes = ['text', 'text-summary', 'html', 'json', 'lcov']; reportTypes.forEach((reportType) => { try { const report = reports.create(reportType as any, {}); report.execute(context); console.log(` āœ… Generated ${reportType} report`); } catch (error) { console.error(` āŒ Failed to generate ${reportType} report:`, error); } }); // Step 5: Print summary const summary = map.getCoverageSummary(); console.log('\n' + '='.repeat(70)); console.log('šŸ“Š COMBINED COVERAGE SUMMARY'); console.log('='.repeat(70)); console.log( `\n Statements: ${summary.statements.pct.toFixed(2)}% (${summary.statements.covered}/${summary.statements.total})` ); console.log( ` Branches: ${summary.branches.pct.toFixed(2)}% (${summary.branches.covered}/${summary.branches.total})` ); console.log( ` Functions: ${summary.functions.pct.toFixed(2)}% (${summary.functions.covered}/${summary.functions.total})` ); console.log( ` Lines: ${summary.lines.pct.toFixed(2)}% (${summary.lines.covered}/${summary.lines.total})` ); console.log('\n' + '-'.repeat(70)); console.log('šŸ“ FILE COVERAGE BREAKDOWN'); console.log('-'.repeat(70)); console.log(`\n Total files: ${stats.combinedFiles}`); console.log(` Jest only: ${stats.jestOnlyFiles.length}`); console.log(` E2E only: ${stats.e2eOnlyFiles.length}`); console.log(` Covered by both: ${stats.sharedFiles.length}`); // Show E2E-only files (these were excluded from Jest) if (stats.e2eOnlyFiles.length > 0) { console.log('\n šŸ“‹ Files covered ONLY by E2E tests (excluded from unit tests):'); stats.e2eOnlyFiles.slice(0, 10).forEach((file) => { const fileCoverage = map.fileCoverageFor(file); const fileSummary = fileCoverage.toSummary(); console.log( ` • ${path.relative(process.cwd(), file)} (${fileSummary.statements.pct.toFixed(1)}%)` ); }); if (stats.e2eOnlyFiles.length > 10) { console.log(` ... and ${stats.e2eOnlyFiles.length - 10} more`); } } console.log('\n' + '='.repeat(70)); console.log(`\nāœ… Combined coverage report available at:\n ${reportDir}/index.html\n`); // Step 6: Check thresholds (from .nycrc.json) const thresholds = { statements: 90, branches: 85, functions: 85, lines: 90, }; let thresholdsFailed = false; console.log('šŸŽÆ Checking Coverage Thresholds:\n'); Object.entries(thresholds).forEach(([metric, threshold]) => { const actual = (summary as any)[metric].pct; const passed = actual >= threshold; const icon = passed ? 'āœ…' : 'āŒ'; console.log( ` ${icon} ${metric.padEnd(12)}: ${actual.toFixed(2)}% (threshold: ${threshold}%)` ); if (!passed) thresholdsFailed = true; }); if (thresholdsFailed) { console.log('\nāŒ Coverage thresholds not met!\n'); process.exit(1); } else { console.log('\nāœ… All coverage thresholds met!\n'); } } // Run the merge mergeCoverage().catch((error) => { console.error('\nāŒ Error merging coverage:', error); process.exit(1); });