mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
* add scoped coverage reporting * fix coverage review feedback --------- Co-authored-by: 4gray <fourgray@proton.me>
246 lines
7.6 KiB
JavaScript
246 lines
7.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
|
|
const workspaceRoot = process.cwd();
|
|
const args = process.argv.slice(2);
|
|
const projectArg = valueFor('--project');
|
|
const inputArg = valueFor('--input');
|
|
const policy = JSON.parse(
|
|
readFileSync(path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'), 'utf8')
|
|
);
|
|
const outputDir = path.join(workspaceRoot, policy.reporting.e2eSummaryDir);
|
|
|
|
function valueFor(flag) {
|
|
const prefixed = args.find((arg) => arg.startsWith(`${flag}=`));
|
|
if (prefixed) {
|
|
return prefixed.slice(flag.length + 1);
|
|
}
|
|
const index = args.indexOf(flag);
|
|
return index >= 0 ? args[index + 1] : undefined;
|
|
}
|
|
|
|
function listFiles(directory, predicate) {
|
|
if (!existsSync(directory)) {
|
|
return [];
|
|
}
|
|
const files = [];
|
|
for (const entry of readdirSync(directory)) {
|
|
const fullPath = path.join(directory, entry);
|
|
const stats = statSync(fullPath);
|
|
if (stats.isDirectory()) {
|
|
files.push(...listFiles(fullPath, predicate));
|
|
} else if (predicate(fullPath)) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function normalizeTag(tag) {
|
|
const normalized = tag.startsWith('@') ? tag : `@${tag}`;
|
|
return normalized.toLowerCase();
|
|
}
|
|
|
|
function tagsFromTitle(title) {
|
|
return Array.from(
|
|
new Set((title.match(/@[a-z0-9-]+/gi) ?? []).map(normalizeTag).sort())
|
|
);
|
|
}
|
|
|
|
function collectFromPlaywrightJson(filePath, projectName) {
|
|
const report = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
const tests = [];
|
|
|
|
function walkSuite(suite, inheritedFile) {
|
|
const file = suite.file ?? inheritedFile;
|
|
for (const spec of suite.specs ?? []) {
|
|
const title = [...(spec.titlePath ?? []), spec.title].filter(Boolean).join(' ');
|
|
const tags = new Set([
|
|
...(spec.tags ?? []).map(normalizeTag),
|
|
...tagsFromTitle(title),
|
|
]);
|
|
const statuses = (spec.tests ?? []).flatMap((test) =>
|
|
(test.results ?? []).map((result) => result.status)
|
|
);
|
|
const status = statuses.includes('failed')
|
|
? 'failed'
|
|
: statuses.includes('timedOut')
|
|
? 'failed'
|
|
: statuses.includes('skipped')
|
|
? 'skipped'
|
|
: statuses.length > 1 && statuses.includes('passed')
|
|
? 'flaky'
|
|
: statuses[0] ?? 'unknown';
|
|
|
|
tests.push({
|
|
project: projectName,
|
|
file,
|
|
title,
|
|
status,
|
|
tags: Array.from(tags).sort(),
|
|
});
|
|
}
|
|
|
|
for (const child of suite.suites ?? []) {
|
|
walkSuite(child, file);
|
|
}
|
|
}
|
|
|
|
for (const suite of report.suites ?? []) {
|
|
walkSuite(suite, suite.file);
|
|
}
|
|
|
|
return tests;
|
|
}
|
|
|
|
function collectFromSource(projectName) {
|
|
const root =
|
|
projectName === 'electron-backend-e2e'
|
|
? 'apps/electron-backend-e2e/src'
|
|
: 'apps/web-e2e/src';
|
|
const files = listFiles(path.join(workspaceRoot, root), (file) => file.endsWith('.e2e.ts'));
|
|
const tests = [];
|
|
|
|
for (const file of files) {
|
|
const relativeFile = path.relative(workspaceRoot, file);
|
|
const contents = readFileSync(file, 'utf8');
|
|
const regex = /\btest(?:\.describe)?\s*\(\s*(['"`])([\s\S]*?)\1/g;
|
|
for (const match of contents.matchAll(regex)) {
|
|
const title = match[2].replace(/\s+/g, ' ').trim();
|
|
const tags = tagsFromTitle(title);
|
|
if (tags.length > 0) {
|
|
tests.push({
|
|
project: projectName,
|
|
file: relativeFile,
|
|
title,
|
|
status: 'not-run',
|
|
tags,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return tests;
|
|
}
|
|
|
|
function defaultInputFor(projectName) {
|
|
return path.join(workspaceRoot, 'dist/test-results', projectName, 'results.json');
|
|
}
|
|
|
|
function collectTests(projectName) {
|
|
const inputPath = inputArg
|
|
? path.resolve(workspaceRoot, inputArg)
|
|
: defaultInputFor(projectName);
|
|
|
|
if (existsSync(inputPath)) {
|
|
return collectFromPlaywrightJson(inputPath, projectName);
|
|
}
|
|
|
|
return collectFromSource(projectName);
|
|
}
|
|
|
|
function statusCounts(tests) {
|
|
return tests.reduce((counts, test) => {
|
|
counts[test.status] = (counts[test.status] ?? 0) + 1;
|
|
return counts;
|
|
}, {});
|
|
}
|
|
|
|
function tagCounts(tests) {
|
|
const counts = {};
|
|
for (const test of tests) {
|
|
for (const tag of test.tags) {
|
|
counts[tag] = (counts[tag] ?? 0) + 1;
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function journeyMatches(journey, tests) {
|
|
return tests.filter(
|
|
(test) =>
|
|
journey.projects.includes(test.project) &&
|
|
journey.matchAnyTags.some((tag) => test.tags.includes(tag))
|
|
);
|
|
}
|
|
|
|
function markdownFor(projectName, tests) {
|
|
const counts = statusCounts(tests);
|
|
const countsText = Object.entries(counts)
|
|
.map(([status, count]) => `${status}: ${count}`)
|
|
.join(', ');
|
|
const tagRows = Object.entries(tagCounts(tests))
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([tag, count]) => `| ${tag} | ${count} |`)
|
|
.join('\n');
|
|
const journeys = policy.e2eSemanticCoverage.criticalJourneys
|
|
.filter((journey) => !projectName || journey.projects.includes(projectName))
|
|
.map((journey) => {
|
|
const matches = journeyMatches(journey, tests);
|
|
const failed = matches.some((test) => test.status === 'failed');
|
|
const status = matches.length === 0 ? 'missing' : failed ? 'failing' : 'covered';
|
|
return `| ${journey.name} | ${journey.matchAnyTags.join(', ')} | ${matches.length} | ${status} |`;
|
|
})
|
|
.join('\n');
|
|
|
|
return `# E2E Semantic Coverage${projectName ? `: ${projectName}` : ''}
|
|
|
|
Source: ${tests.some((test) => test.status === 'not-run') ? 'spec source scan' : 'Playwright JSON report'}
|
|
|
|
Total tracked tests: ${tests.length}
|
|
|
|
Statuses: ${countsText || 'none'}
|
|
|
|
## Tags
|
|
|
|
| Tag | Tests |
|
|
| --- | ---: |
|
|
${tagRows || '| _none_ | 0 |'}
|
|
|
|
## Critical Journeys
|
|
|
|
| Journey | Matching tags | Tests | Status |
|
|
| --- | --- | ---: | --- |
|
|
${journeys || '| _none_ | _n/a_ | 0 | missing |'}
|
|
`;
|
|
}
|
|
|
|
const projects = projectArg ? [projectArg] : ['web-e2e', 'electron-backend-e2e'];
|
|
const allTests = projects.flatMap((projectName) => collectTests(projectName));
|
|
|
|
mkdirSync(outputDir, { recursive: true });
|
|
|
|
if (projectArg) {
|
|
const content = markdownFor(projectArg, allTests);
|
|
writeFileSync(path.join(outputDir, `${projectArg}-semantic-summary.md`), content);
|
|
writeFileSync(
|
|
path.join(outputDir, `${projectArg}-semantic-summary.json`),
|
|
`${JSON.stringify(allTests, null, 4)}\n`
|
|
);
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
writeFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${content}\n`, { flag: 'a' });
|
|
}
|
|
console.log(`Wrote ${policy.reporting.e2eSummaryDir}/${projectArg}-semantic-summary.md`);
|
|
} else {
|
|
const content = markdownFor(undefined, allTests);
|
|
writeFileSync(path.join(outputDir, 'semantic-summary.md'), content);
|
|
writeFileSync(
|
|
path.join(outputDir, 'semantic-summary.json'),
|
|
`${JSON.stringify(allTests, null, 4)}\n`
|
|
);
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
writeFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${content}\n`, { flag: 'a' });
|
|
}
|
|
console.log(`Wrote ${policy.reporting.e2eSummaryDir}/semantic-summary.md`);
|
|
}
|