name: Web Tests on: workflow_call: concurrency: group: web-tests-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: test: name: Web Tests runs-on: ubuntu-latest defaults: run: shell: bash working-directory: ./web steps: - name: Checkout code uses: actions/checkout@v4 with: persist-credentials: false - name: Install pnpm uses: pnpm/action-setup@v4 with: package_json_file: web/package.json run_install: false - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm cache-dependency-path: ./web/pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run tests run: pnpm test:coverage - name: Coverage Summary if: always() id: coverage-summary run: | set -eo pipefail COVERAGE_FILE="coverage/coverage-final.json" COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json" if [ ! -f "$COVERAGE_FILE" ] && [ ! -f "$COVERAGE_SUMMARY_FILE" ]; then echo "has_coverage=false" >> "$GITHUB_OUTPUT" echo "### 🚨 Test Coverage Report :test_tube:" >> "$GITHUB_STEP_SUMMARY" echo "Coverage data not found. Ensure Vitest runs with coverage enabled." >> "$GITHUB_STEP_SUMMARY" exit 0 fi echo "has_coverage=true" >> "$GITHUB_OUTPUT" node <<'NODE' >> "$GITHUB_STEP_SUMMARY" const fs = require('fs'); const path = require('path'); let libCoverage = null; try { libCoverage = require('istanbul-lib-coverage'); } catch (error) { libCoverage = null; } const summaryPath = path.join('coverage', 'coverage-summary.json'); const finalPath = path.join('coverage', 'coverage-final.json'); const hasSummary = fs.existsSync(summaryPath); const hasFinal = fs.existsSync(finalPath); if (!hasSummary && !hasFinal) { console.log('### Test Coverage Summary :test_tube:'); console.log(''); console.log('No coverage data found.'); process.exit(0); } const summary = hasSummary ? JSON.parse(fs.readFileSync(summaryPath, 'utf8')) : null; const coverage = hasFinal ? JSON.parse(fs.readFileSync(finalPath, 'utf8')) : null; const getLineCoverageFromStatements = (statementMap, statementHits) => { const lineHits = {}; if (!statementMap || !statementHits) { return lineHits; } Object.entries(statementMap).forEach(([key, statement]) => { const line = statement?.start?.line; if (!line) { return; } const hits = statementHits[key] ?? 0; const previous = lineHits[line]; lineHits[line] = previous === undefined ? hits : Math.max(previous, hits); }); return lineHits; }; const getFileCoverage = (entry) => ( libCoverage ? libCoverage.createFileCoverage(entry) : null ); const getLineHits = (entry, fileCoverage) => { const lineHits = entry.l ?? {}; if (Object.keys(lineHits).length > 0) { return lineHits; } if (fileCoverage) { return fileCoverage.getLineCoverage(); } return getLineCoverageFromStatements(entry.statementMap ?? {}, entry.s ?? {}); }; const getUncoveredLines = (entry, fileCoverage, lineHits) => { if (lineHits && Object.keys(lineHits).length > 0) { return Object.entries(lineHits) .filter(([, count]) => count === 0) .map(([line]) => Number(line)) .sort((a, b) => a - b); } if (fileCoverage) { return fileCoverage.getUncoveredLines(); } return []; }; const totals = { lines: { covered: 0, total: 0 }, statements: { covered: 0, total: 0 }, branches: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 }, }; const fileSummaries = []; if (summary) { const totalEntry = summary.total ?? {}; ['lines', 'statements', 'branches', 'functions'].forEach((key) => { if (totalEntry[key]) { totals[key].covered = totalEntry[key].covered ?? 0; totals[key].total = totalEntry[key].total ?? 0; } }); Object.entries(summary) .filter(([file]) => file !== 'total') .forEach(([file, data]) => { fileSummaries.push({ file, pct: data.lines?.pct ?? data.statements?.pct ?? 0, lines: { covered: data.lines?.covered ?? 0, total: data.lines?.total ?? 0, }, }); }); } else if (coverage) { Object.entries(coverage).forEach(([file, entry]) => { const fileCoverage = getFileCoverage(entry); const lineHits = getLineHits(entry, fileCoverage); const statementHits = entry.s ?? {}; const branchHits = entry.b ?? {}; const functionHits = entry.f ?? {}; const lineTotal = Object.keys(lineHits).length; const lineCovered = Object.values(lineHits).filter((n) => n > 0).length; const statementTotal = Object.keys(statementHits).length; const statementCovered = Object.values(statementHits).filter((n) => n > 0).length; const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0); const branchCovered = Object.values(branchHits).reduce( (acc, branches) => acc + branches.filter((n) => n > 0).length, 0, ); const functionTotal = Object.keys(functionHits).length; const functionCovered = Object.values(functionHits).filter((n) => n > 0).length; totals.lines.total += lineTotal; totals.lines.covered += lineCovered; totals.statements.total += statementTotal; totals.statements.covered += statementCovered; totals.branches.total += branchTotal; totals.branches.covered += branchCovered; totals.functions.total += functionTotal; totals.functions.covered += functionCovered; const pct = (covered, tot) => (tot > 0 ? (covered / tot) * 100 : 0); fileSummaries.push({ file, pct: pct(lineCovered || statementCovered, lineTotal || statementTotal), lines: { covered: lineCovered || statementCovered, total: lineTotal || statementTotal, }, }); }); } const pct = (covered, tot) => (tot > 0 ? ((covered / tot) * 100).toFixed(2) : '0.00'); console.log('### Test Coverage Summary :test_tube:'); console.log(''); console.log('| Metric | Coverage | Covered / Total |'); console.log('|--------|----------|-----------------|'); console.log(`| Lines | ${pct(totals.lines.covered, totals.lines.total)}% | ${totals.lines.covered} / ${totals.lines.total} |`); console.log(`| Statements | ${pct(totals.statements.covered, totals.statements.total)}% | ${totals.statements.covered} / ${totals.statements.total} |`); console.log(`| Branches | ${pct(totals.branches.covered, totals.branches.total)}% | ${totals.branches.covered} / ${totals.branches.total} |`); console.log(`| Functions | ${pct(totals.functions.covered, totals.functions.total)}% | ${totals.functions.covered} / ${totals.functions.total} |`); console.log(''); console.log('
File coverage (lowest lines first)'); console.log(''); console.log('```'); fileSummaries .sort((a, b) => (a.pct - b.pct) || (b.lines.total - a.lines.total)) .slice(0, 25) .forEach(({ file, pct, lines }) => { console.log(`${pct.toFixed(2)}%\t${lines.covered}/${lines.total}\t${file}`); }); console.log('```'); console.log('
'); if (coverage) { const pctValue = (covered, tot) => { if (tot === 0) { return '0'; } return ((covered / tot) * 100) .toFixed(2) .replace(/\.?0+$/, ''); }; const formatLineRanges = (lines) => { if (lines.length === 0) { return ''; } const ranges = []; let start = lines[0]; let end = lines[0]; for (let i = 1; i < lines.length; i += 1) { const current = lines[i]; if (current === end + 1) { end = current; continue; } ranges.push(start === end ? `${start}` : `${start}-${end}`); start = current; end = current; } ranges.push(start === end ? `${start}` : `${start}-${end}`); return ranges.join(','); }; const tableTotals = { statements: { covered: 0, total: 0 }, branches: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 }, lines: { covered: 0, total: 0 }, }; const tableRows = Object.entries(coverage) .map(([file, entry]) => { const fileCoverage = getFileCoverage(entry); const lineHits = getLineHits(entry, fileCoverage); const statementHits = entry.s ?? {}; const branchHits = entry.b ?? {}; const functionHits = entry.f ?? {}; const lineTotal = Object.keys(lineHits).length; const lineCovered = Object.values(lineHits).filter((n) => n > 0).length; const statementTotal = Object.keys(statementHits).length; const statementCovered = Object.values(statementHits).filter((n) => n > 0).length; const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0); const branchCovered = Object.values(branchHits).reduce( (acc, branches) => acc + branches.filter((n) => n > 0).length, 0, ); const functionTotal = Object.keys(functionHits).length; const functionCovered = Object.values(functionHits).filter((n) => n > 0).length; tableTotals.lines.total += lineTotal; tableTotals.lines.covered += lineCovered; tableTotals.statements.total += statementTotal; tableTotals.statements.covered += statementCovered; tableTotals.branches.total += branchTotal; tableTotals.branches.covered += branchCovered; tableTotals.functions.total += functionTotal; tableTotals.functions.covered += functionCovered; const uncoveredLines = getUncoveredLines(entry, fileCoverage, lineHits); const filePath = entry.path ?? file; const relativePath = path.isAbsolute(filePath) ? path.relative(process.cwd(), filePath) : filePath; return { file: relativePath || file, statements: pctValue(statementCovered, statementTotal), branches: pctValue(branchCovered, branchTotal), functions: pctValue(functionCovered, functionTotal), lines: pctValue(lineCovered, lineTotal), uncovered: formatLineRanges(uncoveredLines), }; }) .sort((a, b) => a.file.localeCompare(b.file)); const columns = [ { key: 'file', header: 'File', align: 'left' }, { key: 'statements', header: '% Stmts', align: 'right' }, { key: 'branches', header: '% Branch', align: 'right' }, { key: 'functions', header: '% Funcs', align: 'right' }, { key: 'lines', header: '% Lines', align: 'right' }, { key: 'uncovered', header: 'Uncovered Line #s', align: 'left' }, ]; const allFilesRow = { file: 'All files', statements: pctValue(tableTotals.statements.covered, tableTotals.statements.total), branches: pctValue(tableTotals.branches.covered, tableTotals.branches.total), functions: pctValue(tableTotals.functions.covered, tableTotals.functions.total), lines: pctValue(tableTotals.lines.covered, tableTotals.lines.total), uncovered: '', }; const rowsForOutput = [allFilesRow, ...tableRows]; const formatRow = (row) => `| ${columns .map(({ key }) => String(row[key] ?? '')) .join(' | ')} |`; const headerRow = `| ${columns.map(({ header }) => header).join(' | ')} |`; const dividerRow = `| ${columns .map(({ align }) => (align === 'right' ? '---:' : ':---')) .join(' | ')} |`; console.log(''); console.log('
Vitest coverage table'); console.log(''); console.log(headerRow); console.log(dividerRow); rowsForOutput.forEach((row) => console.log(formatRow(row))); console.log('
'); } NODE - name: Upload Coverage Artifact if: steps.coverage-summary.outputs.has_coverage == 'true' uses: actions/upload-artifact@v4 with: name: web-coverage-report path: web/coverage retention-days: 30 if-no-files-found: error