mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
Adds contract-focused regression coverage for the Electron HTTP server, remote-control events, settings events, and managed download paths, and makes Tier A coverage fail closed when instrumentation fails or a runtime-owning production file disappears from a project or from the merged Istanbul report. The old `coverage:ci` exited 0 despite a `Failed to collect coverage` diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged map. All 30 Tier A reports are now required, the merged map covers 710 files, and effects.ts is reported as 0/159 instead of silently disappearing. Also fixes remote static-file path containment for encoded, malformed, NUL, POSIX and Win32-style traversal inputs, with behavior-preserving testability seams. Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%, remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
370 lines
11 KiB
JavaScript
370 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import {
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
statSync,
|
|
} from 'node:fs';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
|
|
import {
|
|
evaluateCoverageRatchets,
|
|
validateMergedCoverage,
|
|
validateProjectCoverage,
|
|
validateRequiredProjectReports,
|
|
} from './coverage-integrity.mjs';
|
|
|
|
const COVERAGE_METRICS = [
|
|
'statements',
|
|
'branches',
|
|
'functions',
|
|
'lines',
|
|
];
|
|
const COVERAGE_SUMMARY_FIELDS = ['covered', 'total', 'pct'];
|
|
|
|
const workspaceRoot = process.cwd();
|
|
const args = new Set(process.argv.slice(2));
|
|
const requireReport = args.has('--require-report');
|
|
const policy = JSON.parse(
|
|
readFileSync(path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'), 'utf8')
|
|
);
|
|
|
|
const warnings = [];
|
|
const errors = [];
|
|
|
|
function warn(message) {
|
|
warnings.push(message);
|
|
if (process.env.GITHUB_ACTIONS) {
|
|
console.log(`::warning::${message}`);
|
|
} else {
|
|
console.warn(`Warning: ${message}`);
|
|
}
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
function projectJsonPath(project) {
|
|
return path.join(workspaceRoot, project.root, 'project.json');
|
|
}
|
|
|
|
function projectCoveragePath(project) {
|
|
return path.join(
|
|
workspaceRoot,
|
|
'coverage',
|
|
project.root,
|
|
'coverage-final.json'
|
|
);
|
|
}
|
|
|
|
function verifyTierAProjects() {
|
|
for (const project of policy.unitCoverage.tierA) {
|
|
const filePath = projectJsonPath(project);
|
|
if (!existsSync(filePath)) {
|
|
errors.push(`Tier A project ${project.name} is missing ${project.root}/project.json.`);
|
|
continue;
|
|
}
|
|
|
|
const projectJson = readJson(filePath);
|
|
if (!projectJson.targets?.test) {
|
|
errors.push(`Tier A project ${project.name} must have a test target.`);
|
|
}
|
|
if (!project.sourceRoot || !existsSync(path.join(workspaceRoot, project.sourceRoot))) {
|
|
errors.push(`Tier A project ${project.name} has an invalid sourceRoot: ${project.sourceRoot}`);
|
|
}
|
|
if (project.sourceRoot && !hasSpecUnder(project.sourceRoot)) {
|
|
errors.push(`Tier A project ${project.name} has no unit specs under ${project.sourceRoot}. Move it to Tier B/C or add focused tests.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function verifyProjectCoverageReports() {
|
|
if (requireReport) {
|
|
const validation = validateRequiredProjectReports({
|
|
projects: policy.unitCoverage.tierA,
|
|
workspaceRoot,
|
|
});
|
|
errors.push(...validation.errors);
|
|
return;
|
|
}
|
|
|
|
for (const project of policy.unitCoverage.tierA) {
|
|
if (!existsSync(projectCoveragePath(project))) {
|
|
continue;
|
|
}
|
|
|
|
const validation = validateProjectCoverage({
|
|
project,
|
|
workspaceRoot,
|
|
});
|
|
errors.push(...validation.errors);
|
|
}
|
|
}
|
|
|
|
function reportCoverageSummaryMismatches(reportedSummary, computedSummary) {
|
|
for (const metric of COVERAGE_METRICS) {
|
|
const reported = reportedSummary[metric];
|
|
const computed = computedSummary[metric];
|
|
if (
|
|
COVERAGE_SUMMARY_FIELDS.every(
|
|
(field) => reported?.[field] === computed?.[field]
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
errors.push(
|
|
`Merged coverage summary mismatch for ${metric}: coverage-summary.json reports covered ${reported?.covered}, total ${reported?.total}, pct ${reported?.pct}; coverage-final.json computes covered ${computed?.covered}, total ${computed?.total}, pct ${computed?.pct}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function verifyCoverageReport() {
|
|
const summaryPath = path.join(
|
|
workspaceRoot,
|
|
policy.reporting.mergedCoverageDir,
|
|
'coverage-summary.json'
|
|
);
|
|
const ratchet = policy.reporting.coverageRatchet;
|
|
const coveragePath = path.join(
|
|
workspaceRoot,
|
|
policy.reporting.mergedCoverageDir,
|
|
'coverage-final.json'
|
|
);
|
|
const summaryExists = existsSync(summaryPath);
|
|
const coverageExists = existsSync(coveragePath);
|
|
|
|
if (!summaryExists && !coverageExists) {
|
|
const message = `Merged coverage summary not found at ${path.relative(workspaceRoot, summaryPath)}.`;
|
|
if (requireReport) {
|
|
errors.push(message);
|
|
} else {
|
|
warn(message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (summaryExists !== coverageExists) {
|
|
const existingPath = summaryExists ? summaryPath : coveragePath;
|
|
const missingPath = summaryExists ? coveragePath : summaryPath;
|
|
errors.push(
|
|
`Merged coverage artifacts are incomplete: found ${path.relative(workspaceRoot, existingPath)} but ${path.relative(workspaceRoot, missingPath)} is missing.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
let summaryDocument;
|
|
try {
|
|
summaryDocument = readJson(summaryPath);
|
|
} catch (error) {
|
|
errors.push(
|
|
`Merged coverage summary ${path.relative(workspaceRoot, summaryPath)} contains invalid JSON: ${error.message}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const summary = summaryDocument?.total;
|
|
if (
|
|
!summary ||
|
|
COVERAGE_METRICS.some(
|
|
(metric) =>
|
|
!summary[metric] ||
|
|
!Number.isFinite(summary[metric].pct)
|
|
)
|
|
) {
|
|
errors.push(
|
|
`Merged coverage summary ${path.relative(workspaceRoot, summaryPath)} does not contain usable total coverage metrics.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
let coverageData;
|
|
try {
|
|
coverageData = readJson(coveragePath);
|
|
} catch (error) {
|
|
errors.push(
|
|
`Merged coverage report ${path.relative(workspaceRoot, coveragePath)} contains invalid JSON: ${error.message}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const mergedValidation = validateMergedCoverage({
|
|
coverageData,
|
|
projects: policy.unitCoverage.tierA,
|
|
reportPath: coveragePath,
|
|
workspaceRoot,
|
|
});
|
|
errors.push(...mergedValidation.errors);
|
|
if (!mergedValidation.coverageMap) {
|
|
return;
|
|
}
|
|
const computedSummary = mergedValidation.coverageMap
|
|
.getCoverageSummary()
|
|
.toJSON();
|
|
|
|
console.log(
|
|
`Merged coverage: statements ${computedSummary.statements.pct}%, branches ${computedSummary.branches.pct}%, functions ${computedSummary.functions.pct}%, lines ${computedSummary.lines.pct}%.`
|
|
);
|
|
reportCoverageSummaryMismatches(summary, computedSummary);
|
|
|
|
if (mergedValidation.errors.length > 0 || ratchet === undefined) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
errors.push(
|
|
...evaluateCoverageRatchets({
|
|
coverageData,
|
|
mergedSummary: computedSummary,
|
|
ratchet,
|
|
workspaceRoot,
|
|
})
|
|
);
|
|
} catch (error) {
|
|
errors.push(
|
|
`Merged coverage report ${path.relative(workspaceRoot, coveragePath)} is not valid Istanbul coverage: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
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 hasSpecUnder(sourceRoot) {
|
|
return listFiles(path.join(workspaceRoot, sourceRoot), (file) =>
|
|
/\.(spec|test)\.ts$/.test(file)
|
|
).length > 0;
|
|
}
|
|
|
|
function scanE2ETags() {
|
|
const e2eFiles = [
|
|
...listFiles(path.join(workspaceRoot, 'apps/web-e2e/src'), (file) => file.endsWith('.e2e.ts')),
|
|
...listFiles(path.join(workspaceRoot, 'apps/electron-backend-e2e/src'), (file) =>
|
|
file.endsWith('.e2e.ts')
|
|
),
|
|
];
|
|
const tags = new Set();
|
|
|
|
for (const file of e2eFiles) {
|
|
const contents = readFileSync(file, 'utf8');
|
|
for (const match of contents.matchAll(/@[a-z0-9-]+/gi)) {
|
|
tags.add(match[0]);
|
|
}
|
|
}
|
|
|
|
for (const tag of policy.e2eSemanticCoverage.trackedTags) {
|
|
if (!tags.has(tag)) {
|
|
warn(`Tracked E2E tag ${tag} is not present in current Playwright specs yet.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function changedFiles() {
|
|
const changedFileSet = new Set();
|
|
const commands = [];
|
|
if (process.env.GITHUB_BASE_REF) {
|
|
commands.push(['git', ['diff', '--name-only', `origin/${process.env.GITHUB_BASE_REF}...HEAD`]]);
|
|
} else {
|
|
commands.push(['git', ['diff', '--name-only', 'HEAD']]);
|
|
commands.push(['git', ['ls-files', '--others', '--exclude-standard']]);
|
|
commands.push(['git', ['diff', '--name-only', 'HEAD~1...HEAD']]);
|
|
}
|
|
|
|
for (const [command, commandArgs] of commands) {
|
|
try {
|
|
const output = execFileSync(command, commandArgs, {
|
|
cwd: workspaceRoot,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
const outputFiles = output
|
|
.split('\n')
|
|
.map((file) => file.trim())
|
|
.filter(Boolean);
|
|
for (const file of outputFiles) {
|
|
changedFileSet.add(file);
|
|
}
|
|
} catch {
|
|
// Try the next diff source.
|
|
}
|
|
}
|
|
|
|
return Array.from(changedFileSet);
|
|
}
|
|
|
|
function sourceOwner(file) {
|
|
return policy.unitCoverage.tierA.find((project) =>
|
|
file.startsWith(`${project.sourceRoot}/`)
|
|
);
|
|
}
|
|
|
|
function hasNearbySpec(file) {
|
|
const parsed = path.parse(file);
|
|
const candidateNames = [
|
|
path.join(parsed.dir, `${parsed.name}.spec.ts`),
|
|
path.join(parsed.dir, `${parsed.name}.test.ts`),
|
|
];
|
|
|
|
return candidateNames.some((candidate) => existsSync(path.join(workspaceRoot, candidate)));
|
|
}
|
|
|
|
function reportChangedCriticalFiles() {
|
|
const files = changedFiles().filter(
|
|
(file) =>
|
|
/\.(ts|html)$/.test(file) &&
|
|
!/\.(spec|test)\.ts$/.test(file) &&
|
|
!file.endsWith('test-setup.ts')
|
|
);
|
|
|
|
for (const file of files) {
|
|
const owner = sourceOwner(file);
|
|
if (!owner) {
|
|
continue;
|
|
}
|
|
|
|
if (!hasNearbySpec(file)) {
|
|
warn(
|
|
`${file} is Tier A source without a same-name unit spec. Preferred validation: ${owner.validationCommand}; related E2E tags: ${owner.e2eTags.join(', ')}.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
verifyTierAProjects();
|
|
verifyProjectCoverageReports();
|
|
verifyCoverageReport();
|
|
scanE2ETags();
|
|
reportChangedCriticalFiles();
|
|
|
|
if (errors.length > 0) {
|
|
for (const error of errors) {
|
|
console.error(`Error: ${error}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
warnings.length === 0
|
|
? 'Coverage health checks passed.'
|
|
: `Coverage health completed with ${warnings.length} warning(s).`
|
|
);
|