grafana/scripts/compare-coverage-by-codeowner.js
Jesse David Peterson 4623a00050
Tests: Check frontend test coverage CI workflow (#116633)
* chore(git): ignore coverage summary from CI workflow

* feat(script): check if files changed for a codeowner

* feat(ci): clean up PR comments when break glass label applied

* feat(ci): add change detection to coverage workflow

* feat(ci): conditionally run coverage checks only for teams changed files

* feat(ci): compare code coverage reports by codeowner

* feat(ci): improve PR comment message

* feat(ci): add skip warning with opted-in teams list

* fix(scripts): avoid rounding errors in test coverage summaries

* fix(script): fix GHA linting errors

* fix(script): multiple space delimited filenames in change detection

* fix(scripts): round to two decimal points of precision

* feat(script): collector job to fan-in coverage comparison matrix

* fix(script): display correct git SHA

* fix(script): serial execution in each test suite for deterministism

* fix(script): use base branch SHA, not ref for main

* fix(script): ignore CI scripts written in Node.js and Jest config file

* fix(script): post failure message when coverage drops

* fix(script): use correct SHAs in PR comment message

* fix(script): fail when any one of the coverage comparisons fail

* fix(script): use the same PR comment bot for all messages

* fix(script): use the same token so comments are cleared when re-run etc.

* feat(script): make PR message more concise
2026-01-22 09:46:04 -04:00

184 lines
5.6 KiB
JavaScript
Executable file

#!/usr/bin/env node
const fs = require('fs');
const COVERAGE_MAIN_PATH = './coverage-main/coverage-summary.json';
const COVERAGE_PR_PATH = './coverage-pr/coverage-summary.json';
const COMPARISON_OUTPUT_PATH = './coverage-comparison.md';
/**
* Reads and parses a coverage summary JSON file
* @param {string} filePath - Path to coverage summary file
* @returns {Object} Parsed coverage data
*/
function readCoverageFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (err) {
console.error(`Error reading coverage file ${filePath}: ${err.message}`);
process.exit(1);
}
}
/**
* Formats a number as a percentage string
* @param {number} value - Percentage value
* @returns {string} Formatted percentage (e.g., "85.34%")
*/
function formatPercentage(value) {
return `${value.toFixed(2)}%`;
}
/**
* Returns status icon based on coverage comparison
* @param {number} mainValue - Main branch coverage percentage
* @param {number} prValue - PR branch coverage percentage
* @returns {string} Status icon and text
*/
function getStatusIcon(mainValue, prValue) {
// Round to 2 decimal places for comparison to match display precision
const prPct = Math.round(prValue * 100) / 100;
const mainPct = Math.round(mainValue * 100) / 100;
if (prPct >= mainPct) {
return '✅ Pass';
}
return '❌ Fail';
}
/**
* Determines overall pass/fail status for all coverage metrics
* @param {Object} mainSummary - Main branch coverage summary
* @param {Object} prSummary - PR branch coverage summary
* @returns {boolean} True if all metrics maintained or improved
*/
function getOverallStatus(mainSummary, prSummary) {
const metrics = ['lines', 'statements', 'functions', 'branches'];
const allPass = metrics.every((metric) => {
// Round to 2 decimal places for comparison to match display precision
const prPct = Math.round(prSummary[metric].pct * 100) / 100;
const mainPct = Math.round(mainSummary[metric].pct * 100) / 100;
return prPct >= mainPct;
});
return allPass;
}
/**
* Calculates the difference between PR and main coverage
* @param {number} prValue - PR coverage percentage
* @param {number} mainValue - Main coverage percentage
* @returns {string} Formatted delta (e.g., "+1.2%" or "-0.5%")
*/
function formatDelta(prValue, mainValue) {
const delta = prValue - mainValue;
if (delta > 0) {
return `+${delta.toFixed(2)}%`;
} else if (delta < 0) {
return `${delta.toFixed(2)}%`;
}
return '—';
}
/**
* Generates markdown report comparing main and PR coverage
* @param {Object} mainCoverage - Main branch coverage data
* @param {Object} prCoverage - PR branch coverage data
* @returns {string} Markdown formatted report
*/
function generateMarkdown(mainCoverage, prCoverage) {
const teamName = prCoverage.team;
const mainSummary = mainCoverage.summary;
const prSummary = prCoverage.summary;
const overallPass = getOverallStatus(mainSummary, prSummary);
const rows = [
{
metric: 'Lines',
main: mainSummary.lines.pct,
pr: prSummary.lines.pct,
},
{
metric: 'Statements',
main: mainSummary.statements.pct,
pr: prSummary.statements.pct,
},
{
metric: 'Functions',
main: mainSummary.functions.pct,
pr: prSummary.functions.pct,
},
{
metric: 'Branches',
main: mainSummary.branches.pct,
pr: prSummary.branches.pct,
},
];
const tableRows = rows
.map((row) => {
const status = getStatusIcon(row.main, row.pr);
const delta = formatDelta(row.pr, row.main);
return `| ${row.metric} | ${formatPercentage(row.main)} | ${formatPercentage(row.pr)} | ${delta} | ${status} |`;
})
.join('\n');
const overallStatus = overallPass ? '✅ Passed' : '❌ Failed';
return `## Test Coverage Checks ${overallStatus} for ${teamName}
| Metric | Main | PR | Change | Status |
|--------|------|----|----|--------|
${tableRows}
**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.
`;
}
/**
* Compares coverage between main and PR branches and generates a markdown report
* @param {string} mainPath - Path to main branch coverage summary JSON
* @param {string} prPath - Path to PR branch coverage summary JSON
* @param {string} outputPath - Path to write comparison markdown
* @returns {boolean} True if coverage check passed
*/
function compareCoverageByCodeowner(
mainPath = COVERAGE_MAIN_PATH,
prPath = COVERAGE_PR_PATH,
outputPath = COMPARISON_OUTPUT_PATH
) {
const mainCoverage = readCoverageFile(mainPath);
const prCoverage = readCoverageFile(prPath);
if (!mainCoverage.summary || !prCoverage.summary) {
console.error('Error: Coverage summary data is missing or invalid');
process.exit(1);
}
const markdown = generateMarkdown(mainCoverage, prCoverage);
const overallPass = getOverallStatus(mainCoverage.summary, prCoverage.summary);
try {
fs.writeFileSync(outputPath, markdown, 'utf8');
console.log(`✅ Coverage comparison written to ${outputPath}`);
} catch (err) {
console.error(`Error writing output file: ${err.message}`);
process.exit(1);
}
return overallPass;
}
if (require.main === module) {
const passed = compareCoverageByCodeowner();
if (!passed) {
console.error('❌ Coverage check failed: One or more metrics decreased');
process.exit(1);
}
console.log('✅ Coverage check passed: All metrics maintained or improved');
}
module.exports = { compareCoverageByCodeowner, generateMarkdown, getOverallStatus };