Frontend coverage: show regressed files and HTML report artifact on failure (#124305)

This commit is contained in:
Paul Marbach 2026-05-07 14:38:52 -04:00 committed by GitHub
parent 6a2d839cda
commit f6b3e475c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 160 additions and 21 deletions

View file

@ -296,9 +296,37 @@ jobs:
if: steps.check-affected.outputs.affected == 'true'
run: mkdir -p ./coverage-pr && cp ./pr/coverage-summary.json ./coverage-pr/coverage-summary.json
- name: Get codeowner coverage directory slug
if: steps.check-affected.outputs.affected == 'true'
id: coverage-dir
run: |
SLUG=$(node -e "
const {createCodeownerSlug} = require('./pr/scripts/codeowners-manifest/utils.js');
console.log(createCodeownerSlug('${{ matrix.codeowner }}'));
")
echo "slug=${SLUG}" >> "$GITHUB_OUTPUT"
PATH=$(node -e "
const {buildCodeownerDirectoryPath} = require('./pr/scripts/codeowners-manifest/utils.js');
console.log(buildCodeownerDirectoryPath('${{ matrix.codeowner }}'));
")
echo "path=${PATH}" >> "$GITHUB_OUTPUT"
- name: Upload PR HTML coverage report
if: steps.check-affected.outputs.affected == 'true'
id: upload-coverage
uses: actions/upload-artifact@v4
with:
name: frontend-coverage-${{ steps.coverage-dir.outputs.slug }}
path: ./pr/coverage/by-team/${{ steps.coverage-dir.outputs.path }}/html
retention-days: 7
- name: Compare coverage
if: steps.check-affected.outputs.affected == 'true'
id: compare
env:
COVERAGE_ARTIFACT_URL: ${{ steps.upload-coverage.outputs.artifact-url }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
# Run comparison from workspace root where coverage-main/ and coverage-pr/ live
if node ./pr/scripts/compare-coverage-by-codeowner.js; then

View file

@ -3,7 +3,7 @@ const open = require('open').default;
const path = require('path');
const baseConfig = require('./jest.config.js');
const { CODEOWNER_KIND, getCodeownerKind, createCodeownerSlug } = require('./scripts/codeowners-manifest/utils.js');
const { buildCodeownerDirectoryPath } = require('./scripts/codeowners-manifest/utils.js');
const CODEOWNERS_MANIFEST_FILENAMES_BY_TEAM_PATH = 'codeowners-manifest/filenames-by-team.json';
@ -13,7 +13,7 @@ if (!codeownerName) {
process.exit(1);
}
const outputDir = `./coverage/by-team/${createCodeownerDirectory(codeownerName)}`;
const outputDir = path.join('./coverage/by-team', buildCodeownerDirectoryPath(codeownerName));
const COVERAGE_SUMMARY_OUTPUT_PATH = './coverage-summary.json';
const codeownersFilePath = path.join(__dirname, CODEOWNERS_MANIFEST_FILENAMES_BY_TEAM_PATH);
@ -147,6 +147,19 @@ function writeCoverageSummaryArtifact(coverageResults) {
return;
}
const files = {};
if (coverageResults.files) {
for (const file of coverageResults.files) {
const relativePath = file.sourcePath.replace(process.cwd() + '/', '');
files[relativePath] = {
lines: { pct: file.summary.lines.pct },
statements: { pct: file.summary.statements.pct },
functions: { pct: file.summary.functions.pct },
branches: { pct: file.summary.branches.pct },
};
}
}
const summary = {
team: codeownerName,
commit: process.env.GITHUB_SHA || 'unknown',
@ -157,6 +170,7 @@ function writeCoverageSummaryArtifact(coverageResults) {
functions: { pct: coverageResults.summary.functions.pct },
branches: { pct: coverageResults.summary.branches.pct },
},
files,
};
try {
@ -167,24 +181,6 @@ function writeCoverageSummaryArtifact(coverageResults) {
}
}
/**
* Creates a directory path for coverage reports grouped by codeowner kind
* @param {string} codeowner - CODEOWNERS codeowner
* @returns {string} Directory path relative to coverage/by-team/
*/
function createCodeownerDirectory(codeowner) {
const kind = getCodeownerKind(codeowner);
if (kind === CODEOWNER_KIND.UNKNOWN) {
throw new Error(
`Invalid codeowner format: "${codeowner}". Must be a GitHub team (@org/team), user (@username), or email (email@domain.tld)`
);
}
const slug = createCodeownerSlug(codeowner);
return `${kind}s/${slug}`;
}
/**
* Opens the coverage report in the default browser
* @param {string} reportURL - File URL to the coverage report HTML

View file

@ -1,4 +1,5 @@
const { readFile } = require('node:fs/promises');
const path = require('node:path');
const { CODEOWNERS_JSON_PATH: CODEOWNERS_MANIFEST_CODEOWNERS_PATH } = require('./constants.js');
@ -61,10 +62,29 @@ function createCodeownerSlug(codeowner) {
}
}
/**
* Creates a directory path for coverage reports grouped by codeowner kind
* @param {string} codeowner - CODEOWNERS codeowner
* @returns {string} Directory path relative to coverage/by-team/
*/
function buildCodeownerDirectoryPath(codeowner) {
const kind = getCodeownerKind(codeowner);
if (kind === CODEOWNER_KIND.UNKNOWN) {
throw new Error(
`Invalid codeowner format: "${codeowner}". Must be a GitHub team (@org/team), user (@username), or email (email@domain.tld)`
);
}
const slug = createCodeownerSlug(codeowner);
return path.join(`${kind}s`, slug);
}
module.exports = {
CODEOWNER_KIND,
getCodeownerKind,
createCodeownerSlug,
buildCodeownerDirectoryPath,
/**
* Imports codeowners manifest with caching

View file

@ -80,6 +80,84 @@ function formatDelta(prValue, mainValue) {
return '—';
}
/**
* Identifies files where coverage decreased between main and PR branches
* @param {Object} mainCoverage - Main branch coverage data (with optional .files map)
* @param {Object} prCoverage - PR branch coverage data (with optional .files map)
* @returns {Array<{path: string, main: Object, pr: Object}>}
*/
function getFilesWithDecreasedCoverage(mainCoverage, prCoverage) {
const mainFiles = mainCoverage.files || {};
const prFiles = prCoverage.files || {};
const metrics = ['lines', 'statements', 'functions', 'branches'];
const decreased = [];
for (const [filePath, prFile] of Object.entries(prFiles)) {
const mainFile = mainFiles[filePath];
if (!mainFile) {
continue; // new file — not a regression
}
const anyDecreased = metrics.some((metric) => {
const prPct = Math.round(prFile[metric].pct * 100) / 100;
const mainPct = Math.round(mainFile[metric].pct * 100) / 100;
return prPct < mainPct;
});
if (anyDecreased) {
decreased.push({ path: filePath, main: mainFile, pr: prFile });
}
}
// eslint-disable-next-line @grafana/no-locale-compare
return decreased.sort((a, b) => a.path.localeCompare(b.path));
}
/**
* Generates the "files with decreased coverage" markdown section
* @param {Array} decreasedFiles - Output of getFilesWithDecreasedCoverage
* @param {string} artifactUrl - URL to the uploaded HTML coverage artifact
* @param {string} prSha - PR head commit SHA for GitHub file links
* @param {string} repo - GitHub repository in "owner/repo" format
* @returns {string} Markdown section
*/
function generateFailureDetailsSection(decreasedFiles, artifactUrl, prSha, repo) {
const lines = [];
if (decreasedFiles.length === 0) {
return lines.join('\n');
}
lines.push(`### Files with Decreased Coverage\n`);
if (artifactUrl) {
lines.push(`📊 [View full HTML coverage report](${artifactUrl})\n`);
}
const MAX_FILES = 20;
const metrics = ['lines', 'statements', 'functions', 'branches'];
const headers = ['File', 'Lines', 'Statements', 'Functions', 'Branches'];
lines.push(`| ${headers.join(' | ')} |`);
lines.push(`|------|-------|------------|-----------|----------|`);
const shown = decreasedFiles.slice(0, MAX_FILES);
for (const { path, main, pr } of shown) {
const metricCells = metrics.map((metric) => {
const prPct = Math.round(pr[metric].pct * 100) / 100;
const mainPct = Math.round(main[metric].pct * 100) / 100;
return prPct < mainPct ? `${formatPercentage(mainPct)}${formatPercentage(prPct)}` : '—';
});
lines.push(`| ${path} | ${metricCells.join(' | ')} |`);
}
if (decreasedFiles.length > MAX_FILES) {
lines.push(`\n_...and ${decreasedFiles.length - MAX_FILES} more files. See the full report for details._`);
}
return lines.join('\n');
}
/**
* Generates markdown report comparing main and PR coverage
* @param {Object} mainCoverage - Main branch coverage data
@ -126,12 +204,23 @@ function generateMarkdown(mainCoverage, prCoverage) {
const overallStatus = overallPass ? '✅ Passed' : '❌ Failed';
let failureDetails = '';
if (!overallPass) {
const artifactUrl = process.env.COVERAGE_ARTIFACT_URL || '';
const prSha = process.env.PR_SHA || '';
const repo = process.env.GITHUB_REPOSITORY || '';
const decreasedFiles = getFilesWithDecreasedCoverage(mainCoverage, prCoverage);
failureDetails = generateFailureDetailsSection(decreasedFiles, artifactUrl, prSha, repo);
}
return `## Test Coverage Checks ${overallStatus} for ${teamName}
| Metric | Main | PR | Change | Status |
|--------|------|----|----|--------|
${tableRows}
${failureDetails}
**Run locally:** 💻 \`yarn test:coverage:by-codeowner ${teamName}\`
**Break glass:** 🚨 In case of emergency, adding the \`no-check-frontend-test-coverage\` label to this PR will skip checks.
@ -181,4 +270,10 @@ if (require.main === module) {
console.log('✅ Coverage check passed: All metrics maintained or improved');
}
module.exports = { compareCoverageByCodeowner, generateMarkdown, getOverallStatus };
module.exports = {
compareCoverageByCodeowner,
generateMarkdown,
getOverallStatus,
getFilesWithDecreasedCoverage,
generateFailureDetailsSection,
};