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: Check i18n types synchronization run: pnpm run check:i18n-types - name: Run tests run: | pnpm exec jest \ --ci \ --runInBand \ --coverage \ --passWithNoTests - 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 Jest 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'); 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 totals = { lines: { covered: 0, total: 0 }, statements: { covered: 0, total: 0 }, branches: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 }, }; const fileSummaries = []; if (hasSummary) { const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); 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 (hasFinal) { const coverage = JSON.parse(fs.readFileSync(finalPath, 'utf8')); Object.entries(coverage).forEach(([file, entry]) => { const lineHits = entry.l ?? {}; 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('
'); 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