From fd100a868d4f4ff203d49843aff4991503861d9a Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Mon, 16 Mar 2026 23:09:33 +0800 Subject: [PATCH] chore: update coverage summary check in web tests workflow (#33533) Co-authored-by: CodingOnStar Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .github/workflows/web-tests.yml | 324 +---------------- .../check-components-diff-coverage.test.ts | 31 +- .../components-coverage-common.test.ts | 72 ++++ .../check-components-diff-coverage-lib.mjs | 62 +++- .../check-components-diff-coverage.mjs | 327 ++---------------- web/scripts/components-coverage-common.mjs | 195 +++++++++++ .../report-components-coverage-baseline.mjs | 165 +++++++++ web/scripts/report-components-test-touch.mjs | 129 +++++++ 8 files changed, 697 insertions(+), 608 deletions(-) create mode 100644 web/__tests__/components-coverage-common.test.ts create mode 100644 web/scripts/components-coverage-common.mjs create mode 100644 web/scripts/report-components-coverage-baseline.mjs create mode 100644 web/scripts/report-components-test-touch.mjs diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index ec9d1c98c8..be2595a599 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -89,14 +89,24 @@ jobs: - name: Merge reports run: vp test --merge-reports --reporter=json --reporter=agent --coverage - - name: Check app/components diff coverage + - name: Report app/components baseline coverage + run: node ./scripts/report-components-coverage-baseline.mjs + + - name: Report app/components test touch + env: + BASE_SHA: ${{ inputs.base_sha }} + DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }} + HEAD_SHA: ${{ inputs.head_sha }} + run: node ./scripts/report-components-test-touch.mjs + + - name: Check app/components pure diff coverage env: BASE_SHA: ${{ inputs.base_sha }} DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }} HEAD_SHA: ${{ inputs.head_sha }} run: node ./scripts/check-components-diff-coverage.mjs - - name: Coverage Summary + - name: Check Coverage Summary if: always() id: coverage-summary run: | @@ -105,313 +115,15 @@ jobs: 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" + if [ -f "$COVERAGE_FILE" ] || [ -f "$COVERAGE_SUMMARY_FILE" ]; then + echo "has_coverage=true" >> "$GITHUB_OUTPUT" 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 + echo "has_coverage=false" >> "$GITHUB_OUTPUT" + echo "### 🚨 app/components Diff Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Coverage artifacts not found. Ensure Vitest merge reports ran with coverage enabled." >> "$GITHUB_STEP_SUMMARY" - name: Upload Coverage Artifact if: steps.coverage-summary.outputs.has_coverage == 'true' diff --git a/web/__tests__/check-components-diff-coverage.test.ts b/web/__tests__/check-components-diff-coverage.test.ts index 79f6c8fd26..62e5ff5ed5 100644 --- a/web/__tests__/check-components-diff-coverage.test.ts +++ b/web/__tests__/check-components-diff-coverage.test.ts @@ -163,9 +163,38 @@ describe('check-components-diff-coverage helpers', () => { expect(coverage).toEqual({ covered: 0, - total: 2, + total: 1, uncoveredBranches: [ { armIndex: 0, line: 33 }, + ], + }) + }) + + it('should require all branch arms when the branch condition changes', () => { + const entry = { + b: { + 0: [0, 0], + }, + branchMap: { + 0: { + line: 30, + loc: { start: { line: 30 }, end: { line: 35 } }, + locations: [ + { start: { line: 31 }, end: { line: 34 } }, + { start: { line: 35 }, end: { line: 38 } }, + ], + type: 'if', + }, + }, + } + + const coverage = getChangedBranchCoverage(entry, new Set([30])) + + expect(coverage).toEqual({ + covered: 0, + total: 2, + uncoveredBranches: [ + { armIndex: 0, line: 31 }, { armIndex: 1, line: 35 }, ], }) diff --git a/web/__tests__/components-coverage-common.test.ts b/web/__tests__/components-coverage-common.test.ts new file mode 100644 index 0000000000..ab189ed854 --- /dev/null +++ b/web/__tests__/components-coverage-common.test.ts @@ -0,0 +1,72 @@ +import { + getCoverageStats, + isRelevantTestFile, + isTrackedComponentSourceFile, + loadTrackedCoverageEntries, +} from '../scripts/components-coverage-common.mjs' + +describe('components coverage common helpers', () => { + it('should identify tracked component source files and relevant tests', () => { + const excludedComponentCoverageFiles = new Set([ + 'web/app/components/share/types.ts', + ]) + + expect(isTrackedComponentSourceFile('web/app/components/share/index.tsx', excludedComponentCoverageFiles)).toBe(true) + expect(isTrackedComponentSourceFile('web/app/components/share/types.ts', excludedComponentCoverageFiles)).toBe(false) + expect(isTrackedComponentSourceFile('web/app/components/provider/index.tsx', excludedComponentCoverageFiles)).toBe(false) + + expect(isRelevantTestFile('web/__tests__/share/text-generation-run-once-flow.test.tsx')).toBe(true) + expect(isRelevantTestFile('web/app/components/share/__tests__/index.spec.tsx')).toBe(true) + expect(isRelevantTestFile('web/utils/format.spec.ts')).toBe(false) + }) + + it('should load only tracked coverage entries from mixed coverage paths', () => { + const context = { + excludedComponentCoverageFiles: new Set([ + 'web/app/components/share/types.ts', + ]), + repoRoot: '/repo', + webRoot: '/repo/web', + } + const coverage = { + '/repo/web/app/components/provider/index.tsx': { + path: '/repo/web/app/components/provider/index.tsx', + statementMap: { 0: { start: { line: 1 }, end: { line: 1 } } }, + s: { 0: 1 }, + }, + 'app/components/share/index.tsx': { + path: 'app/components/share/index.tsx', + statementMap: { 0: { start: { line: 2 }, end: { line: 2 } } }, + s: { 0: 1 }, + }, + 'app/components/share/types.ts': { + path: 'app/components/share/types.ts', + statementMap: { 0: { start: { line: 3 }, end: { line: 3 } } }, + s: { 0: 1 }, + }, + } + + expect([...loadTrackedCoverageEntries(coverage, context).keys()]).toEqual([ + 'web/app/components/share/index.tsx', + ]) + }) + + it('should calculate coverage stats using statement-derived line hits', () => { + const entry = { + b: { 0: [1, 0] }, + f: { 0: 1, 1: 0 }, + s: { 0: 1, 1: 0 }, + statementMap: { + 0: { start: { line: 10 }, end: { line: 10 } }, + 1: { start: { line: 12 }, end: { line: 13 } }, + }, + } + + expect(getCoverageStats(entry)).toEqual({ + branches: { covered: 1, total: 2 }, + functions: { covered: 1, total: 2 }, + lines: { covered: 1, total: 2 }, + statements: { covered: 1, total: 2 }, + }) + }) +}) diff --git a/web/scripts/check-components-diff-coverage-lib.mjs b/web/scripts/check-components-diff-coverage-lib.mjs index 48abc14613..354424d878 100644 --- a/web/scripts/check-components-diff-coverage-lib.mjs +++ b/web/scripts/check-components-diff-coverage-lib.mjs @@ -131,14 +131,15 @@ export function getChangedBranchCoverage(entry, changedLines) { let total = 0 for (const [branchId, branch] of Object.entries(entry.branchMap ?? {})) { - if (!branchIntersectsChangedLines(branch, changedLines)) - continue - const hits = Array.isArray(entry.b?.[branchId]) ? entry.b[branchId] : [] const locations = getBranchLocations(branch) const armCount = Math.max(locations.length, hits.length) + const impactedArmIndexes = getImpactedBranchArmIndexes(branch, changedLines, armCount) - for (let armIndex = 0; armIndex < armCount; armIndex += 1) { + if (impactedArmIndexes.length === 0) + continue + + for (const armIndex of impactedArmIndexes) { total += 1 if ((hits[armIndex] ?? 0) > 0) { covered += 1 @@ -219,22 +220,50 @@ function emptyIgnoreResult(changedLines = []) { } } -function branchIntersectsChangedLines(branch, changedLines) { +function getBranchLocations(branch) { + return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : [] +} + +function getImpactedBranchArmIndexes(branch, changedLines, armCount) { + if (!changedLines || changedLines.size === 0 || armCount === 0) + return [] + + const locations = getBranchLocations(branch) + if (isWholeBranchTouched(branch, changedLines, locations, armCount)) + return Array.from({ length: armCount }, (_, armIndex) => armIndex) + + const impactedArmIndexes = [] + for (let armIndex = 0; armIndex < armCount; armIndex += 1) { + const location = locations[armIndex] + if (rangeIntersectsChangedLines(location, changedLines)) + impactedArmIndexes.push(armIndex) + } + + return impactedArmIndexes +} + +function isWholeBranchTouched(branch, changedLines, locations, armCount) { if (!changedLines || changedLines.size === 0) return false - if (rangeIntersectsChangedLines(branch.loc, changedLines)) + if (branch.line && changedLines.has(branch.line)) return true - const locations = getBranchLocations(branch) - if (locations.some(location => rangeIntersectsChangedLines(location, changedLines))) + const branchRange = branch.loc ?? branch + if (!rangeIntersectsChangedLines(branchRange, changedLines)) + return false + + if (locations.length === 0 || locations.length < armCount) return true - return branch.line ? changedLines.has(branch.line) : false -} + for (const lineNumber of changedLines) { + if (!lineTouchesLocation(lineNumber, branchRange)) + continue + if (!locations.some(location => lineTouchesLocation(lineNumber, location))) + return true + } -function getBranchLocations(branch) { - return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : [] + return false } function rangeIntersectsChangedLines(location, changedLines) { @@ -268,6 +297,15 @@ function getFirstChangedLineInRange(location, changedLines, fallbackLine = 1) { return startLine ?? fallbackLine } +function lineTouchesLocation(lineNumber, location) { + const startLine = getLocationStartLine(location) + const endLine = getLocationEndLine(location) ?? startLine + if (!startLine || !endLine) + return false + + return lineNumber >= startLine && lineNumber <= endLine +} + function getLocationStartLine(location) { return location?.start?.line ?? location?.line ?? null } diff --git a/web/scripts/check-components-diff-coverage.mjs b/web/scripts/check-components-diff-coverage.mjs index 8cf3c96223..e8822b84d4 100644 --- a/web/scripts/check-components-diff-coverage.mjs +++ b/web/scripts/check-components-diff-coverage.mjs @@ -6,41 +6,34 @@ import { getChangedBranchCoverage, getChangedStatementCoverage, getIgnoredChangedLinesFromFile, - getLineHits, - normalizeToRepoRelative, parseChangedLineMap, } from './check-components-diff-coverage-lib.mjs' +import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs' import { - collectComponentCoverageExcludedFiles, - COMPONENT_COVERAGE_EXCLUDE_LABEL, -} from './component-coverage-filters.mjs' -import { - COMPONENTS_GLOBAL_THRESHOLDS, - EXCLUDED_COMPONENT_MODULES, - getComponentModuleThreshold, -} from './components-coverage-thresholds.mjs' + APP_COMPONENTS_PREFIX, + createComponentCoverageContext, + getModuleName, + isAnyComponentSourceFile, + isExcludedComponentSourceFile, + isTrackedComponentSourceFile, + loadTrackedCoverageEntries, +} from './components-coverage-common.mjs' +import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs' -const APP_COMPONENTS_PREFIX = 'web/app/components/' -const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/' -const SHARED_TEST_PREFIX = 'web/__tests__/' -const STRICT_TEST_FILE_TOUCH = process.env.STRICT_COMPONENT_TEST_TOUCH === 'true' -const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ') const DIFF_RANGE_MODE = process.env.DIFF_RANGE_MODE === 'exact' ? 'exact' : 'merge-base' +const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ') const repoRoot = repoRootFromCwd() -const webRoot = path.join(repoRoot, 'web') -const excludedComponentCoverageFiles = new Set( - collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: 'web/app/components' }), -) +const context = createComponentCoverageContext(repoRoot) const baseSha = process.env.BASE_SHA?.trim() const headSha = process.env.HEAD_SHA?.trim() || 'HEAD' -const coverageFinalPath = path.join(webRoot, 'coverage', 'coverage-final.json') +const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json') if (!baseSha || /^0+$/.test(baseSha)) { appendSummary([ - '### app/components Diff Coverage', + '### app/components Pure Diff Coverage', '', - 'Skipped diff coverage check because `BASE_SHA` was not available.', + 'Skipped pure diff coverage check because `BASE_SHA` was not available.', ]) process.exit(0) } @@ -53,52 +46,27 @@ if (!fs.existsSync(coverageFinalPath)) { const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8')) const changedFiles = getChangedFiles(baseSha, headSha) const changedComponentSourceFiles = changedFiles.filter(isAnyComponentSourceFile) -const changedSourceFiles = changedComponentSourceFiles.filter(isTrackedComponentSourceFile) -const changedExcludedSourceFiles = changedComponentSourceFiles.filter(isExcludedComponentSourceFile) -const changedTestFiles = changedFiles.filter(isRelevantTestFile) +const changedSourceFiles = changedComponentSourceFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) +const changedExcludedSourceFiles = changedComponentSourceFiles.filter(filePath => isExcludedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) if (changedSourceFiles.length === 0) { appendSummary(buildSkipSummary(changedExcludedSourceFiles)) process.exit(0) } -const coverageEntries = new Map() -for (const [file, entry] of Object.entries(coverage)) { - const repoRelativePath = normalizeToRepoRelative(entry.path ?? file, { - appComponentsCoveragePrefix: APP_COMPONENTS_COVERAGE_PREFIX, - appComponentsPrefix: APP_COMPONENTS_PREFIX, - repoRoot, - sharedTestPrefix: SHARED_TEST_PREFIX, - webRoot, - }) - if (!isTrackedComponentSourceFile(repoRelativePath)) - continue - - coverageEntries.set(repoRelativePath, entry) -} - -const fileCoverageRows = [] -const moduleCoverageMap = new Map() - -for (const [file, entry] of coverageEntries.entries()) { - const stats = getCoverageStats(entry) - const moduleName = getModuleName(file) - fileCoverageRows.push({ file, moduleName, ...stats }) - mergeCoverageStats(moduleCoverageMap, moduleName, stats) -} - -const overallCoverage = sumCoverageStats(fileCoverageRows) +const coverageEntries = loadTrackedCoverageEntries(coverage, context) const diffChanges = getChangedLineMap(baseSha, headSha) const diffRows = [] const ignoredDiffLines = [] const invalidIgnorePragmas = [] for (const [file, changedLines] of diffChanges.entries()) { - if (!isTrackedComponentSourceFile(file)) + if (!isTrackedComponentSourceFile(file, context.excludedComponentCoverageFiles)) continue const entry = coverageEntries.get(file) const ignoreInfo = getIgnoredChangedLinesFromFile(path.join(repoRoot, file), changedLines) + for (const [line, reason] of ignoreInfo.ignoredLines.entries()) { ignoredDiffLines.push({ file, @@ -106,6 +74,7 @@ for (const [file, changedLines] of diffChanges.entries()) { reason, }) } + for (const invalidPragma of ignoreInfo.invalidPragmas) { invalidIgnorePragmas.push({ file, @@ -137,40 +106,15 @@ const diffTotals = diffRows.reduce((acc, row) => { const diffStatementFailures = diffRows.filter(row => row.statements.uncoveredLines.length > 0) const diffBranchFailures = diffRows.filter(row => row.branches.uncoveredBranches.length > 0) -const overallThresholdFailures = getThresholdFailures(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS) -const moduleCoverageRows = [...moduleCoverageMap.entries()] - .map(([moduleName, stats]) => ({ - moduleName, - stats, - thresholds: getComponentModuleThreshold(moduleName), - })) - .map(row => ({ - ...row, - failures: row.thresholds ? getThresholdFailures(row.stats, row.thresholds) : [], - })) -const moduleThresholdFailures = moduleCoverageRows - .filter(row => row.failures.length > 0) - .flatMap(row => row.failures.map(failure => ({ - moduleName: row.moduleName, - ...failure, - }))) -const hasRelevantTestChanges = changedTestFiles.length > 0 -const missingTestTouch = !hasRelevantTestChanges appendSummary(buildSummary({ - overallCoverage, - overallThresholdFailures, - moduleCoverageRows, - moduleThresholdFailures, + changedSourceFiles, diffBranchFailures, diffRows, diffStatementFailures, diffTotals, - changedSourceFiles, - changedTestFiles, ignoredDiffLines, invalidIgnorePragmas, - missingTestTouch, })) if (process.env.CI) { @@ -178,44 +122,37 @@ if (process.env.CI) { const firstLine = failure.statements.uncoveredLines[0] ?? 1 console.log(`::error file=${failure.file},line=${firstLine}::Uncovered changed statements: ${formatLineRanges(failure.statements.uncoveredLines)}`) } + for (const failure of diffBranchFailures.slice(0, 20)) { const firstBranch = failure.branches.uncoveredBranches[0] const line = firstBranch?.line ?? 1 console.log(`::error file=${failure.file},line=${line}::Uncovered changed branches: ${formatBranchRefs(failure.branches.uncoveredBranches)}`) } + for (const invalidPragma of invalidIgnorePragmas.slice(0, 20)) { console.log(`::error file=${invalidPragma.file},line=${invalidPragma.line}::Invalid diff coverage ignore pragma: ${invalidPragma.reason}`) } } if ( - overallThresholdFailures.length > 0 - || moduleThresholdFailures.length > 0 - || diffStatementFailures.length > 0 + diffStatementFailures.length > 0 || diffBranchFailures.length > 0 || invalidIgnorePragmas.length > 0 - || (STRICT_TEST_FILE_TOUCH && missingTestTouch) ) { process.exit(1) } function buildSummary({ - overallCoverage, - overallThresholdFailures, - moduleCoverageRows, - moduleThresholdFailures, + changedSourceFiles, diffBranchFailures, diffRows, diffStatementFailures, diffTotals, - changedSourceFiles, - changedTestFiles, ignoredDiffLines, invalidIgnorePragmas, - missingTestTouch, }) { const lines = [ - '### app/components Diff Coverage', + '### app/components Pure Diff Coverage', '', `Compared \`${baseSha.slice(0, 12)}\` -> \`${headSha.slice(0, 12)}\``, `Diff range mode: \`${DIFF_RANGE_MODE}\``, @@ -225,60 +162,11 @@ function buildSummary({ '', '| Check | Result | Details |', '|---|---:|---|', - `| Overall tracked lines | ${formatPercent(overallCoverage.lines)} | ${overallCoverage.lines.covered}/${overallCoverage.lines.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% |`, - `| Overall tracked statements | ${formatPercent(overallCoverage.statements)} | ${overallCoverage.statements.covered}/${overallCoverage.statements.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% |`, - `| Overall tracked functions | ${formatPercent(overallCoverage.functions)} | ${overallCoverage.functions.covered}/${overallCoverage.functions.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% |`, - `| Overall tracked branches | ${formatPercent(overallCoverage.branches)} | ${overallCoverage.branches.covered}/${overallCoverage.branches.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% |`, `| Changed statements | ${formatDiffPercent(diffTotals.statements)} | ${diffTotals.statements.covered}/${diffTotals.statements.total} |`, `| Changed branches | ${formatDiffPercent(diffTotals.branches)} | ${diffTotals.branches.covered}/${diffTotals.branches.total} |`, '', ] - if (overallThresholdFailures.length > 0) { - lines.push('Overall thresholds failed:') - for (const failure of overallThresholdFailures) - lines.push(`- ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`) - lines.push('') - } - - if (moduleThresholdFailures.length > 0) { - lines.push('Module thresholds failed:') - for (const failure of moduleThresholdFailures) - lines.push(`- ${failure.moduleName} ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`) - lines.push('') - } - - const moduleRows = moduleCoverageRows - .map(({ moduleName, stats, thresholds, failures }) => ({ - moduleName, - lines: percentage(stats.lines.covered, stats.lines.total), - statements: percentage(stats.statements.covered, stats.statements.total), - functions: percentage(stats.functions.covered, stats.functions.total), - branches: percentage(stats.branches.covered, stats.branches.total), - thresholds, - failures, - })) - .sort((a, b) => { - if (a.failures.length !== b.failures.length) - return b.failures.length - a.failures.length - - return a.lines - b.lines || a.moduleName.localeCompare(b.moduleName) - }) - - lines.push('
Module coverage') - lines.push('') - lines.push('| Module | Lines | Statements | Functions | Branches | Thresholds | Status |') - lines.push('|---|---:|---:|---:|---:|---|---|') - for (const row of moduleRows) { - const thresholdLabel = row.thresholds - ? `L${row.thresholds.lines}/S${row.thresholds.statements}/F${row.thresholds.functions}/B${row.thresholds.branches}` - : 'n/a' - const status = row.thresholds ? (row.failures.length > 0 ? 'fail' : 'pass') : 'info' - lines.push(`| ${row.moduleName} | ${row.lines.toFixed(2)}% | ${row.statements.toFixed(2)}% | ${row.functions.toFixed(2)}% | ${row.branches.toFixed(2)}% | ${thresholdLabel} | ${status} |`) - } - lines.push('
') - lines.push('') - const changedRows = diffRows .filter(row => row.statements.total > 0 || row.branches.total > 0) .sort((a, b) => { @@ -297,59 +185,43 @@ function buildSummary({ lines.push('') lines.push('') - if (missingTestTouch) { - lines.push(`Warning: tracked source files changed under \`web/app/components/\`, but no test files changed under \`web/app/components/**\` or \`web/__tests__/\`.`) - if (STRICT_TEST_FILE_TOUCH) - lines.push('`STRICT_COMPONENT_TEST_TOUCH=true` is enabled, so this warning fails the check.') - lines.push('') - } - else { - lines.push(`Relevant test files changed: ${changedTestFiles.length}`) - lines.push('') - } - if (diffStatementFailures.length > 0) { lines.push('Uncovered changed statements:') - for (const row of diffStatementFailures) { + for (const row of diffStatementFailures) lines.push(`- ${row.file.replace('web/', '')}: ${formatLineRanges(row.statements.uncoveredLines)}`) - } lines.push('') } if (diffBranchFailures.length > 0) { lines.push('Uncovered changed branches:') - for (const row of diffBranchFailures) { + for (const row of diffBranchFailures) lines.push(`- ${row.file.replace('web/', '')}: ${formatBranchRefs(row.branches.uncoveredBranches)}`) - } lines.push('') } if (ignoredDiffLines.length > 0) { lines.push('Ignored changed lines via pragma:') - for (const ignoredLine of ignoredDiffLines) { + for (const ignoredLine of ignoredDiffLines) lines.push(`- ${ignoredLine.file.replace('web/', '')}:${ignoredLine.line} - ${ignoredLine.reason}`) - } lines.push('') } if (invalidIgnorePragmas.length > 0) { lines.push('Invalid diff coverage ignore pragmas:') - for (const invalidPragma of invalidIgnorePragmas) { + for (const invalidPragma of invalidIgnorePragmas) lines.push(`- ${invalidPragma.file.replace('web/', '')}:${invalidPragma.line} - ${invalidPragma.reason}`) - } lines.push('') } lines.push(`Changed source files checked: ${changedSourceFiles.length}`) - lines.push(`Changed statement coverage: ${formatDiffPercent(diffTotals.statements)}`) - lines.push(`Changed branch coverage: ${formatDiffPercent(diffTotals.branches)}`) + lines.push('Blocking rules: uncovered changed statements, uncovered changed branches, invalid ignore pragmas.') return lines } function buildSkipSummary(changedExcludedSourceFiles) { const lines = [ - '### app/components Diff Coverage', + '### app/components Pure Diff Coverage', '', `Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``, `Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``, @@ -357,18 +229,18 @@ function buildSkipSummary(changedExcludedSourceFiles) { ] if (changedExcludedSourceFiles.length > 0) { - lines.push('Only excluded component modules or type-only files changed, so diff coverage check was skipped.') + lines.push('Only excluded component modules or type-only files changed, so pure diff coverage was skipped.') lines.push(`Skipped files: ${changedExcludedSourceFiles.length}`) } else { - lines.push('No source changes under tracked `web/app/components/`. Diff coverage check skipped.') + lines.push('No tracked source changes under `web/app/components/`. Pure diff coverage skipped.') } return lines } function getChangedFiles(base, head) { - const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', 'web/app/components', 'web/__tests__']) + const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', APP_COMPONENTS_PREFIX]) return output .split('\n') .map(line => line.trim()) @@ -376,127 +248,8 @@ function getChangedFiles(base, head) { } function getChangedLineMap(base, head) { - const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', 'web/app/components']) - return parseChangedLineMap(diff, isTrackedComponentSourceFile) -} - -function isAnyComponentSourceFile(filePath) { - return filePath.startsWith(APP_COMPONENTS_PREFIX) - && /\.(?:ts|tsx)$/.test(filePath) - && !isTestLikePath(filePath) -} - -function isTrackedComponentSourceFile(filePath) { - return isAnyComponentSourceFile(filePath) - && !isExcludedComponentSourceFile(filePath) -} - -function isExcludedComponentSourceFile(filePath) { - return isAnyComponentSourceFile(filePath) - && ( - EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath)) - || excludedComponentCoverageFiles.has(filePath) - ) -} - -function isRelevantTestFile(filePath) { - return filePath.startsWith(SHARED_TEST_PREFIX) - || (filePath.startsWith(APP_COMPONENTS_PREFIX) && isTestLikePath(filePath) && !isExcludedComponentTestFile(filePath)) -} - -function isExcludedComponentTestFile(filePath) { - if (!filePath.startsWith(APP_COMPONENTS_PREFIX)) - return false - - return EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath)) -} - -function isTestLikePath(filePath) { - return /(?:^|\/)__tests__\//.test(filePath) - || /(?:^|\/)__mocks__\//.test(filePath) - || /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath) - || /\.stories\.(?:ts|tsx)$/.test(filePath) - || /\.d\.ts$/.test(filePath) -} - -function getCoverageStats(entry) { - const lineHits = getLineHits(entry) - const statementHits = Object.values(entry.s ?? {}) - const functionHits = Object.values(entry.f ?? {}) - const branchHits = Object.values(entry.b ?? {}).flat() - - return { - lines: { - covered: Object.values(lineHits).filter(count => count > 0).length, - total: Object.keys(lineHits).length, - }, - statements: { - covered: statementHits.filter(count => count > 0).length, - total: statementHits.length, - }, - functions: { - covered: functionHits.filter(count => count > 0).length, - total: functionHits.length, - }, - branches: { - covered: branchHits.filter(count => count > 0).length, - total: branchHits.length, - }, - } -} - -function sumCoverageStats(rows) { - const total = createEmptyCoverageStats() - for (const row of rows) - addCoverageStats(total, row) - return total -} - -function mergeCoverageStats(map, moduleName, stats) { - const existing = map.get(moduleName) ?? createEmptyCoverageStats() - addCoverageStats(existing, stats) - map.set(moduleName, existing) -} - -function addCoverageStats(target, source) { - for (const metric of ['lines', 'statements', 'functions', 'branches']) { - target[metric].covered += source[metric].covered - target[metric].total += source[metric].total - } -} - -function createEmptyCoverageStats() { - return { - lines: { covered: 0, total: 0 }, - statements: { covered: 0, total: 0 }, - functions: { covered: 0, total: 0 }, - branches: { covered: 0, total: 0 }, - } -} - -function getThresholdFailures(stats, thresholds) { - const failures = [] - for (const metric of ['lines', 'statements', 'functions', 'branches']) { - const actual = percentage(stats[metric].covered, stats[metric].total) - const expected = thresholds[metric] - if (actual < expected) { - failures.push({ - metric, - actual, - expected, - }) - } - } - return failures -} - -function getModuleName(filePath) { - const relativePath = filePath.slice(APP_COMPONENTS_PREFIX.length) - if (!relativePath) - return '(root)' - - const segments = relativePath.split('/') - return segments.length === 1 ? '(root)' : segments[0] + const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', APP_COMPONENTS_PREFIX]) + return parseChangedLineMap(diff, filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) } function formatLineRanges(lines) { @@ -536,10 +289,6 @@ function percentage(covered, total) { return (covered / total) * 100 } -function formatPercent(metric) { - return `${percentage(metric.covered, metric.total).toFixed(2)}%` -} - function formatDiffPercent(metric) { if (metric.total === 0) return 'n/a' diff --git a/web/scripts/components-coverage-common.mjs b/web/scripts/components-coverage-common.mjs new file mode 100644 index 0000000000..e50da1d178 --- /dev/null +++ b/web/scripts/components-coverage-common.mjs @@ -0,0 +1,195 @@ +import fs from 'node:fs' +import path from 'node:path' +import { getLineHits, normalizeToRepoRelative } from './check-components-diff-coverage-lib.mjs' +import { collectComponentCoverageExcludedFiles } from './component-coverage-filters.mjs' +import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs' + +export const APP_COMPONENTS_ROOT = 'web/app/components' +export const APP_COMPONENTS_PREFIX = `${APP_COMPONENTS_ROOT}/` +export const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/' +export const SHARED_TEST_PREFIX = 'web/__tests__/' + +export function createComponentCoverageContext(repoRoot) { + const webRoot = path.join(repoRoot, 'web') + const excludedComponentCoverageFiles = new Set( + collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: APP_COMPONENTS_ROOT }), + ) + + return { + excludedComponentCoverageFiles, + repoRoot, + webRoot, + } +} + +export function loadTrackedCoverageEntries(coverage, context) { + const coverageEntries = new Map() + + for (const [file, entry] of Object.entries(coverage)) { + const repoRelativePath = normalizeToRepoRelative(entry.path ?? file, { + appComponentsCoveragePrefix: APP_COMPONENTS_COVERAGE_PREFIX, + appComponentsPrefix: APP_COMPONENTS_PREFIX, + repoRoot: context.repoRoot, + sharedTestPrefix: SHARED_TEST_PREFIX, + webRoot: context.webRoot, + }) + + if (!isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles)) + continue + + coverageEntries.set(repoRelativePath, entry) + } + + return coverageEntries +} + +export function collectTrackedComponentSourceFiles(context) { + const trackedFiles = [] + + walkComponentSourceFiles(path.join(context.webRoot, 'app/components'), (absolutePath) => { + const repoRelativePath = path.relative(context.repoRoot, absolutePath).split(path.sep).join('/') + if (isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles)) + trackedFiles.push(repoRelativePath) + }) + + trackedFiles.sort((a, b) => a.localeCompare(b)) + return trackedFiles +} + +export function isTestLikePath(filePath) { + return /(?:^|\/)__tests__\//.test(filePath) + || /(?:^|\/)__mocks__\//.test(filePath) + || /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath) + || /\.stories\.(?:ts|tsx)$/.test(filePath) + || /\.d\.ts$/.test(filePath) +} + +export function getModuleName(filePath) { + const relativePath = filePath.slice(APP_COMPONENTS_PREFIX.length) + if (!relativePath) + return '(root)' + + const segments = relativePath.split('/') + return segments.length === 1 ? '(root)' : segments[0] +} + +export function isAnyComponentSourceFile(filePath) { + return filePath.startsWith(APP_COMPONENTS_PREFIX) + && /\.(?:ts|tsx)$/.test(filePath) + && !isTestLikePath(filePath) +} + +export function isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles) { + return isAnyComponentSourceFile(filePath) + && ( + EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath)) + || excludedComponentCoverageFiles.has(filePath) + ) +} + +export function isTrackedComponentSourceFile(filePath, excludedComponentCoverageFiles) { + return isAnyComponentSourceFile(filePath) + && !isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles) +} + +export function isTrackedComponentTestFile(filePath) { + return filePath.startsWith(APP_COMPONENTS_PREFIX) + && isTestLikePath(filePath) + && !EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath)) +} + +export function isRelevantTestFile(filePath) { + return filePath.startsWith(SHARED_TEST_PREFIX) + || isTrackedComponentTestFile(filePath) +} + +export function isAnyWebTestFile(filePath) { + return filePath.startsWith('web/') + && isTestLikePath(filePath) +} + +export function getCoverageStats(entry) { + const lineHits = getLineHits(entry) + const statementHits = Object.values(entry.s ?? {}) + const functionHits = Object.values(entry.f ?? {}) + const branchHits = Object.values(entry.b ?? {}).flat() + + return { + lines: { + covered: Object.values(lineHits).filter(count => count > 0).length, + total: Object.keys(lineHits).length, + }, + statements: { + covered: statementHits.filter(count => count > 0).length, + total: statementHits.length, + }, + functions: { + covered: functionHits.filter(count => count > 0).length, + total: functionHits.length, + }, + branches: { + covered: branchHits.filter(count => count > 0).length, + total: branchHits.length, + }, + } +} + +export function sumCoverageStats(rows) { + const total = createEmptyCoverageStats() + for (const row of rows) + addCoverageStats(total, row) + return total +} + +export function mergeCoverageStats(map, moduleName, stats) { + const existing = map.get(moduleName) ?? createEmptyCoverageStats() + addCoverageStats(existing, stats) + map.set(moduleName, existing) +} + +export function percentage(covered, total) { + if (total === 0) + return 100 + return (covered / total) * 100 +} + +export function formatPercent(metric) { + return `${percentage(metric.covered, metric.total).toFixed(2)}%` +} + +function createEmptyCoverageStats() { + return { + lines: { covered: 0, total: 0 }, + statements: { covered: 0, total: 0 }, + functions: { covered: 0, total: 0 }, + branches: { covered: 0, total: 0 }, + } +} + +function addCoverageStats(target, source) { + for (const metric of ['lines', 'statements', 'functions', 'branches']) { + target[metric].covered += source[metric].covered + target[metric].total += source[metric].total + } +} + +function walkComponentSourceFiles(currentDir, onFile) { + if (!fs.existsSync(currentDir)) + return + + const entries = fs.readdirSync(currentDir, { withFileTypes: true }) + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name) + if (entry.isDirectory()) { + if (entry.name === '__tests__' || entry.name === '__mocks__') + continue + walkComponentSourceFiles(entryPath, onFile) + continue + } + + if (!/\.(?:ts|tsx)$/.test(entry.name) || isTestLikePath(entry.name)) + continue + + onFile(entryPath) + } +} diff --git a/web/scripts/report-components-coverage-baseline.mjs b/web/scripts/report-components-coverage-baseline.mjs new file mode 100644 index 0000000000..16445b4689 --- /dev/null +++ b/web/scripts/report-components-coverage-baseline.mjs @@ -0,0 +1,165 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs' +import { + collectTrackedComponentSourceFiles, + createComponentCoverageContext, + formatPercent, + getCoverageStats, + getModuleName, + loadTrackedCoverageEntries, + mergeCoverageStats, + percentage, + sumCoverageStats, +} from './components-coverage-common.mjs' +import { + COMPONENTS_GLOBAL_THRESHOLDS, + EXCLUDED_COMPONENT_MODULES, + getComponentModuleThreshold, +} from './components-coverage-thresholds.mjs' + +const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ') + +const repoRoot = repoRootFromCwd() +const context = createComponentCoverageContext(repoRoot) +const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json') + +if (!fs.existsSync(coverageFinalPath)) { + console.error(`Coverage report not found at ${coverageFinalPath}`) + process.exit(1) +} + +const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8')) +const trackedSourceFiles = collectTrackedComponentSourceFiles(context) +const coverageEntries = loadTrackedCoverageEntries(coverage, context) +const fileCoverageRows = [] +const moduleCoverageMap = new Map() + +for (const [file, entry] of coverageEntries.entries()) { + const stats = getCoverageStats(entry) + const moduleName = getModuleName(file) + fileCoverageRows.push({ file, moduleName, ...stats }) + mergeCoverageStats(moduleCoverageMap, moduleName, stats) +} + +const overallCoverage = sumCoverageStats(fileCoverageRows) +const overallTargetGaps = getTargetGaps(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS) +const moduleCoverageRows = [...moduleCoverageMap.entries()] + .map(([moduleName, stats]) => ({ + moduleName, + stats, + targets: getComponentModuleThreshold(moduleName), + })) + .map(row => ({ + ...row, + targetGaps: row.targets ? getTargetGaps(row.stats, row.targets) : [], + })) + .sort((a, b) => { + const aWorst = Math.min(...a.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY) + const bWorst = Math.min(...b.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY) + return aWorst - bWorst || a.moduleName.localeCompare(b.moduleName) + }) + +appendSummary(buildSummary({ + coverageEntriesCount: coverageEntries.size, + moduleCoverageRows, + overallCoverage, + overallTargetGaps, + trackedSourceFilesCount: trackedSourceFiles.length, +})) + +function buildSummary({ + coverageEntriesCount, + moduleCoverageRows, + overallCoverage, + overallTargetGaps, + trackedSourceFilesCount, +}) { + const lines = [ + '### app/components Baseline Coverage', + '', + `Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``, + `Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``, + '', + `Coverage entries: ${coverageEntriesCount}/${trackedSourceFilesCount} tracked source files`, + '', + '| Metric | Current | Target | Delta |', + '|---|---:|---:|---:|', + `| Lines | ${formatPercent(overallCoverage.lines)} | ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% | ${formatDelta(overallCoverage.lines, COMPONENTS_GLOBAL_THRESHOLDS.lines)} |`, + `| Statements | ${formatPercent(overallCoverage.statements)} | ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% | ${formatDelta(overallCoverage.statements, COMPONENTS_GLOBAL_THRESHOLDS.statements)} |`, + `| Functions | ${formatPercent(overallCoverage.functions)} | ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% | ${formatDelta(overallCoverage.functions, COMPONENTS_GLOBAL_THRESHOLDS.functions)} |`, + `| Branches | ${formatPercent(overallCoverage.branches)} | ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% | ${formatDelta(overallCoverage.branches, COMPONENTS_GLOBAL_THRESHOLDS.branches)} |`, + '', + ] + + if (coverageEntriesCount !== trackedSourceFilesCount) { + lines.push('Warning: coverage report did not include every tracked component source file. CI should set `VITEST_COVERAGE_SCOPE=app-components` before collecting coverage.') + lines.push('') + } + + if (overallTargetGaps.length > 0) { + lines.push('Below baseline targets:') + for (const gap of overallTargetGaps) + lines.push(`- overall ${gap.metric}: ${gap.actual.toFixed(2)}% < ${gap.target}%`) + lines.push('') + } + + lines.push('
Module baseline coverage') + lines.push('') + lines.push('| Module | Lines | Statements | Functions | Branches | Targets | Status |') + lines.push('|---|---:|---:|---:|---:|---|---|') + for (const row of moduleCoverageRows) { + const targetsLabel = row.targets + ? `L${row.targets.lines}/S${row.targets.statements}/F${row.targets.functions}/B${row.targets.branches}` + : 'n/a' + const status = row.targets + ? (row.targetGaps.length > 0 ? 'below-target' : 'at-target') + : 'unconfigured' + lines.push(`| ${row.moduleName} | ${percentage(row.stats.lines.covered, row.stats.lines.total).toFixed(2)}% | ${percentage(row.stats.statements.covered, row.stats.statements.total).toFixed(2)}% | ${percentage(row.stats.functions.covered, row.stats.functions.total).toFixed(2)}% | ${percentage(row.stats.branches.covered, row.stats.branches.total).toFixed(2)}% | ${targetsLabel} | ${status} |`) + } + lines.push('
') + lines.push('') + lines.push('Report only: baseline targets no longer gate CI. The blocking rule is the pure diff coverage step.') + + return lines +} + +function getTargetGaps(stats, targets) { + const gaps = [] + for (const metric of ['lines', 'statements', 'functions', 'branches']) { + const actual = percentage(stats[metric].covered, stats[metric].total) + const target = targets[metric] + const delta = actual - target + if (delta < 0) { + gaps.push({ + actual, + delta, + metric, + target, + }) + } + } + return gaps +} + +function formatDelta(metric, target) { + const actual = percentage(metric.covered, metric.total) + const delta = actual - target + const sign = delta >= 0 ? '+' : '' + return `${sign}${delta.toFixed(2)}%` +} + +function appendSummary(lines) { + const content = `${lines.join('\n')}\n` + if (process.env.GITHUB_STEP_SUMMARY) + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content) + console.log(content) +} + +function repoRootFromCwd() { + return execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: process.cwd(), + encoding: 'utf8', + }).trim() +} diff --git a/web/scripts/report-components-test-touch.mjs b/web/scripts/report-components-test-touch.mjs new file mode 100644 index 0000000000..0d384c2f4a --- /dev/null +++ b/web/scripts/report-components-test-touch.mjs @@ -0,0 +1,129 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import { + buildGitDiffRevisionArgs, +} from './check-components-diff-coverage-lib.mjs' +import { + createComponentCoverageContext, + isAnyWebTestFile, + isRelevantTestFile, + isTrackedComponentSourceFile, +} from './components-coverage-common.mjs' + +const DIFF_RANGE_MODE = process.env.DIFF_RANGE_MODE === 'exact' ? 'exact' : 'merge-base' + +const repoRoot = repoRootFromCwd() +const context = createComponentCoverageContext(repoRoot) +const baseSha = process.env.BASE_SHA?.trim() +const headSha = process.env.HEAD_SHA?.trim() || 'HEAD' + +if (!baseSha || /^0+$/.test(baseSha)) { + appendSummary([ + '### app/components Test Touch', + '', + 'Skipped test-touch report because `BASE_SHA` was not available.', + ]) + process.exit(0) +} + +const changedFiles = getChangedFiles(baseSha, headSha) +const changedSourceFiles = changedFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) + +if (changedSourceFiles.length === 0) { + appendSummary([ + '### app/components Test Touch', + '', + 'No tracked source changes under `web/app/components/`. Test-touch report skipped.', + ]) + process.exit(0) +} + +const changedRelevantTestFiles = changedFiles.filter(isRelevantTestFile) +const changedOtherWebTestFiles = changedFiles.filter(filePath => isAnyWebTestFile(filePath) && !isRelevantTestFile(filePath)) +const totalChangedWebTests = [...new Set([...changedRelevantTestFiles, ...changedOtherWebTestFiles])] + +appendSummary(buildSummary({ + changedOtherWebTestFiles, + changedRelevantTestFiles, + changedSourceFiles, + totalChangedWebTests, +})) + +function buildSummary({ + changedOtherWebTestFiles, + changedRelevantTestFiles, + changedSourceFiles, + totalChangedWebTests, +}) { + const lines = [ + '### app/components Test Touch', + '', + `Compared \`${baseSha.slice(0, 12)}\` -> \`${headSha.slice(0, 12)}\``, + `Diff range mode: \`${DIFF_RANGE_MODE}\``, + '', + `Tracked source files changed: ${changedSourceFiles.length}`, + `Component-local or shared integration tests changed: ${changedRelevantTestFiles.length}`, + `Other web tests changed: ${changedOtherWebTestFiles.length}`, + `Total changed web tests: ${totalChangedWebTests.length}`, + '', + ] + + if (totalChangedWebTests.length === 0) { + lines.push('Warning: no frontend test files changed alongside tracked component source changes.') + lines.push('') + } + + if (changedRelevantTestFiles.length > 0) { + lines.push('
Changed component-local or shared tests') + lines.push('') + for (const filePath of changedRelevantTestFiles.slice(0, 40)) + lines.push(`- ${filePath.replace('web/', '')}`) + if (changedRelevantTestFiles.length > 40) + lines.push(`- ... ${changedRelevantTestFiles.length - 40} more`) + lines.push('
') + lines.push('') + } + + if (changedOtherWebTestFiles.length > 0) { + lines.push('
Changed other web tests') + lines.push('') + for (const filePath of changedOtherWebTestFiles.slice(0, 40)) + lines.push(`- ${filePath.replace('web/', '')}`) + if (changedOtherWebTestFiles.length > 40) + lines.push(`- ... ${changedOtherWebTestFiles.length - 40} more`) + lines.push('
') + lines.push('') + } + + lines.push('Report only: test-touch is now advisory and no longer blocks the diff coverage gate.') + return lines +} + +function getChangedFiles(base, head) { + const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(base, head, DIFF_RANGE_MODE), '--', 'web']) + return output + .split('\n') + .map(line => line.trim()) + .filter(Boolean) +} + +function appendSummary(lines) { + const content = `${lines.join('\n')}\n` + if (process.env.GITHUB_STEP_SUMMARY) + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content) + console.log(content) +} + +function execGit(args) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + }) +} + +function repoRootFromCwd() { + return execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: process.cwd(), + encoding: 'utf8', + }).trim() +}