grafana/jest.config.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

197 lines
6.1 KiB
JavaScript

const fs = require('fs');
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 CODEOWNERS_MANIFEST_FILENAMES_BY_TEAM_PATH = 'codeowners-manifest/filenames-by-team.json';
const codeownerName = process.env.CODEOWNER_NAME;
if (!codeownerName) {
console.error('ERROR: CODEOWNER_NAME environment variable is required');
process.exit(1);
}
const outputDir = `./coverage/by-team/${createCodeownerDirectory(codeownerName)}`;
const COVERAGE_SUMMARY_OUTPUT_PATH = './coverage-summary.json';
const codeownersFilePath = path.join(__dirname, CODEOWNERS_MANIFEST_FILENAMES_BY_TEAM_PATH);
if (!fs.existsSync(codeownersFilePath)) {
console.error(`Codeowners file not found at ${codeownersFilePath} ...`);
console.error('Please run: yarn codeowners-manifest first to generate the mapping file');
process.exit(1);
}
const codeownersData = JSON.parse(fs.readFileSync(codeownersFilePath, 'utf8'));
const teamFiles = codeownersData[codeownerName] || [];
if (teamFiles.length === 0) {
console.error(`ERROR: No files found for team "${codeownerName}"`);
console.error('Available teams:', Object.keys(codeownersData).join(', '));
process.exit(1);
}
const sourceFiles = teamFiles.filter((file) => {
const ext = path.extname(file);
return (
['.ts', '.tsx', '.js', '.jsx'].includes(ext) &&
// exclude all tests and mocks
!path.matchesGlob(file, '**/test/**/*') &&
!file.includes('.test.') &&
!file.includes('.spec.') &&
!path.matchesGlob(file, '**/__mocks__/**/*') &&
// and storybook stories
!file.includes('.story.') &&
// and generated files
!file.includes('.gen.ts') &&
// and type definitions
!file.includes('.d.ts') &&
!file.endsWith('/types.ts') &&
// and anything in graveyard
!path.matchesGlob(file, '**/graveyard/**/*') &&
// and scripts directory
!file.startsWith('scripts/') &&
// and jest config files
!path.matchesGlob(file, '**/jest.config*.js')
);
});
const testFiles = teamFiles.filter((file) => {
const ext = path.extname(file);
return ['.ts', '.tsx', '.js', '.jsx'].includes(ext) && (file.includes('.test.') || file.includes('.spec.'));
});
if (testFiles.length === 0) {
console.log(`No test files found for team ${codeownerName}`);
process.exit(0);
}
console.log(
`🧪 Collecting coverage for ${sourceFiles.length} testable files and running ${testFiles.length} test files of ${teamFiles.length} files owned by ${codeownerName}.`
);
module.exports = {
...baseConfig,
collectCoverage: true,
collectCoverageFrom: sourceFiles.map((file) => `<rootDir>/${file}`),
coverageReporters: ['none'],
coverageDirectory: '/tmp/jest-coverage-ignore',
coverageProvider: 'v8',
reporters: [
'default',
[
'jest-monocart-coverage',
{
name: `Coverage Report - ${codeownerName} owned files`,
outputDir: outputDir,
reports: ['console-summary', 'v8', 'json', 'lcov'],
sourceFilter: (coveredFile) => sourceFiles.includes(coveredFile),
all: {
dir: ['./packages', './public'],
filter: (filePath) => {
const relativePath = filePath.replace(process.cwd() + '/', '');
return sourceFiles.includes(relativePath);
},
},
cleanCache: true,
onEnd: (coverageResults) => {
const reportURL = `file://${path.resolve(outputDir)}/index.html`;
console.log(`📄 Coverage report saved to ${reportURL}`);
if (process.env.SHOULD_OPEN_COVERAGE_REPORT === 'true') {
openCoverageReport(reportURL);
}
writeCoverageSummaryArtifact(coverageResults);
// TODO: Emit coverage metrics https://github.com/grafana/grafana/issues/111208
},
},
],
],
testRegex: undefined,
testMatch: testFiles.map((file) => `<rootDir>/${file}`),
};
/**
* @typedef {Object} CoverageMetric
* @property {number} pct - Percentage value
*/
/**
* @typedef {Object} CoverageSummary
* @property {CoverageMetric} lines
* @property {CoverageMetric} statements
* @property {CoverageMetric} functions
* @property {CoverageMetric} branches
*/
/**
* @typedef {Object} CoverageResults
* @property {CoverageSummary} summary
*/
/**
* Writes coverage summary artifact for CI/CD consumption
* @param {CoverageResults} coverageResults - Coverage results from jest-monocart-coverage
*/
function writeCoverageSummaryArtifact(coverageResults) {
if (!coverageResults || !coverageResults.summary) {
return;
}
const summary = {
team: codeownerName,
commit: process.env.GITHUB_SHA || 'unknown',
timestamp: new Date().toISOString(),
summary: {
lines: { pct: coverageResults.summary.lines.pct },
statements: { pct: coverageResults.summary.statements.pct },
functions: { pct: coverageResults.summary.functions.pct },
branches: { pct: coverageResults.summary.branches.pct },
},
};
try {
fs.writeFileSync(COVERAGE_SUMMARY_OUTPUT_PATH, JSON.stringify(summary, null, 2));
console.log(`📊 Coverage summary written to ${COVERAGE_SUMMARY_OUTPUT_PATH}`);
} catch (err) {
console.error(`Failed to write coverage summary: ${err}`);
}
}
/**
* 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
*/
async function openCoverageReport(reportURL) {
try {
await open(reportURL);
} catch (err) {
console.error(`Failed to open coverage report: ${err}`);
}
}