mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
test(coverage): add integrity primitives
This commit is contained in:
1 parent
710227aed7
commit
76b17dcef2
2 files changed
+1225
No files matched your search
@@ -0,0 +1,486 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createCoverageMap } = require('istanbul-lib-coverage');
|
||||
|
||||
export const COVERAGE_COLLECTION_FAILURE = 'Failed to collect coverage';
|
||||
const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
||||
const COVERAGE_METRICS = [
|
||||
'statements',
|
||||
'branches',
|
||||
'functions',
|
||||
'lines',
|
||||
];
|
||||
|
||||
function toPosix(filePath) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function hasDeclareModifier(statement) {
|
||||
const modifiers = ts.canHaveModifiers(statement)
|
||||
? (ts.getModifiers(statement) ?? [])
|
||||
: [];
|
||||
return modifiers.some(
|
||||
(modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRuntimeOwnedStatement(sourceText, fileName = 'source.ts') {
|
||||
const source = ts.createSourceFile(
|
||||
fileName,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
);
|
||||
|
||||
return source.statements.some((statement) => {
|
||||
if (hasDeclareModifier(statement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(
|
||||
ts.isImportDeclaration(statement) ||
|
||||
ts.isImportEqualsDeclaration(statement) ||
|
||||
ts.isInterfaceDeclaration(statement) ||
|
||||
ts.isTypeAliasDeclaration(statement) ||
|
||||
ts.isExportDeclaration(statement) ||
|
||||
ts.isEmptyStatement(statement)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createCoverageOutputScanner(maxCharacters = 4096) {
|
||||
let tail = '';
|
||||
let collectionFailed = false;
|
||||
let ansiState = 'text';
|
||||
|
||||
function stripAnsi(chunk) {
|
||||
const input = String(chunk).replace(ANSI_ESCAPE, '');
|
||||
let output = '';
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const character = input[index];
|
||||
const code = character.charCodeAt(0);
|
||||
|
||||
if (ansiState === 'text') {
|
||||
if (code === 0x1b) {
|
||||
ansiState = 'escape';
|
||||
} else {
|
||||
output += character;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ansiState === 'escape') {
|
||||
if (character === '[') {
|
||||
ansiState = 'parameters';
|
||||
} else {
|
||||
output += '\u001b';
|
||||
ansiState = 'text';
|
||||
index -= 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ansiState === 'parameters') {
|
||||
if (code >= 0x30 && code <= 0x3f) {
|
||||
continue;
|
||||
}
|
||||
if (code >= 0x20 && code <= 0x2f) {
|
||||
ansiState = 'intermediates';
|
||||
continue;
|
||||
}
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
ansiState = 'text';
|
||||
continue;
|
||||
}
|
||||
|
||||
ansiState = 'text';
|
||||
index -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code >= 0x20 && code <= 0x2f) {
|
||||
continue;
|
||||
}
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
ansiState = 'text';
|
||||
continue;
|
||||
}
|
||||
|
||||
ansiState = 'text';
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
return {
|
||||
get collectionFailed() {
|
||||
return collectionFailed;
|
||||
},
|
||||
get tail() {
|
||||
return tail;
|
||||
},
|
||||
push(chunk) {
|
||||
const output = `${tail}${stripAnsi(chunk)}`;
|
||||
if (output.includes(COVERAGE_COLLECTION_FAILURE)) {
|
||||
collectionFailed = true;
|
||||
}
|
||||
tail = output.slice(-maxCharacters);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isExcludedSource(filePath) {
|
||||
const file = toPosix(filePath);
|
||||
return (
|
||||
/\.(spec|test)\.ts$/.test(file) ||
|
||||
file.endsWith('.d.ts') ||
|
||||
file.endsWith('/test-setup.ts') ||
|
||||
file.includes('/test-stubs/') ||
|
||||
/\.generated\./.test(file) ||
|
||||
file.includes('/environments/') ||
|
||||
file.endsWith('/index.ts')
|
||||
);
|
||||
}
|
||||
|
||||
function runtimeOwningSourceFiles(workspaceRoot, sourceRoot) {
|
||||
const absoluteSourceRoot = path.resolve(workspaceRoot, sourceRoot);
|
||||
const runtimeFiles = [];
|
||||
const directories = [absoluteSourceRoot];
|
||||
|
||||
while (directories.length > 0) {
|
||||
const directory = directories.pop();
|
||||
for (const name of readdirSync(directory)) {
|
||||
const filePath = path.join(directory, name);
|
||||
const stats = statSync(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
directories.push(filePath);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!stats.isFile() ||
|
||||
!filePath.endsWith('.ts') ||
|
||||
isExcludedSource(filePath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
hasRuntimeOwnedStatement(
|
||||
readFileSync(filePath, 'utf8'),
|
||||
filePath
|
||||
)
|
||||
) {
|
||||
runtimeFiles.push(path.resolve(filePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return runtimeFiles.sort();
|
||||
}
|
||||
|
||||
function isObjectRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot) {
|
||||
const filesByAbsolutePath = new Map();
|
||||
const duplicateAbsolutePaths = new Set();
|
||||
|
||||
for (const reportedPath of coverageMap.files().sort()) {
|
||||
const absolutePath = path.resolve(workspaceRoot, reportedPath);
|
||||
if (filesByAbsolutePath.has(absolutePath)) {
|
||||
duplicateAbsolutePaths.add(absolutePath);
|
||||
continue;
|
||||
}
|
||||
filesByAbsolutePath.set(absolutePath, reportedPath);
|
||||
}
|
||||
|
||||
return {
|
||||
duplicateAbsolutePaths: [...duplicateAbsolutePaths].sort(),
|
||||
filesByAbsolutePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateProjectCoverage({ workspaceRoot, project }) {
|
||||
const reportPath = path.resolve(
|
||||
workspaceRoot,
|
||||
'coverage',
|
||||
project.root,
|
||||
'coverage-final.json'
|
||||
);
|
||||
const relativeReportPath = toPosix(
|
||||
path.relative(workspaceRoot, reportPath)
|
||||
);
|
||||
|
||||
if (!existsSync(reportPath)) {
|
||||
return {
|
||||
errors: [
|
||||
`${project.name} did not produce ${relativeReportPath}.`,
|
||||
],
|
||||
report: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(reportPath, 'utf8'));
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [
|
||||
`${project.name} coverage report ${relativeReportPath} contains invalid JSON: ${error.message}`,
|
||||
],
|
||||
report: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!isObjectRecord(data) ||
|
||||
Object.values(data).some((entry) => !isObjectRecord(entry))
|
||||
) {
|
||||
return {
|
||||
errors: [
|
||||
`${project.name} coverage report ${relativeReportPath} must contain a JSON object mapping source paths to JSON objects.`,
|
||||
],
|
||||
report: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let coverageMap;
|
||||
try {
|
||||
coverageMap = createCoverageMap(data);
|
||||
for (const filePath of coverageMap.files()) {
|
||||
coverageMap.fileCoverageFor(filePath).toSummary();
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [
|
||||
`${project.name} coverage report ${relativeReportPath} is not valid Istanbul coverage: ${error.message}`,
|
||||
],
|
||||
report: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
duplicateAbsolutePaths,
|
||||
filesByAbsolutePath: reportedFiles,
|
||||
} = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot);
|
||||
if (duplicateAbsolutePaths.length > 0) {
|
||||
return {
|
||||
errors: duplicateAbsolutePaths.map(
|
||||
(filePath) =>
|
||||
`${project.name} coverage report ${relativeReportPath} contains a duplicate entry for ${toPosix(
|
||||
path.relative(workspaceRoot, filePath)
|
||||
)} after path normalization.`
|
||||
),
|
||||
report: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
for (const filePath of runtimeOwningSourceFiles(
|
||||
workspaceRoot,
|
||||
project.sourceRoot
|
||||
)) {
|
||||
const reportedPath = reportedFiles.get(filePath);
|
||||
const relativeSourcePath = toPosix(
|
||||
path.relative(workspaceRoot, filePath)
|
||||
);
|
||||
if (reportedPath === undefined) {
|
||||
errors.push(
|
||||
`${project.name} coverage report is missing runtime-owning source ${relativeSourcePath}.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const summary = coverageMap
|
||||
.fileCoverageFor(reportedPath)
|
||||
.toSummary()
|
||||
.toJSON();
|
||||
const hasUsableInstrumentation = [
|
||||
summary.statements,
|
||||
summary.functions,
|
||||
summary.branches,
|
||||
].some((metric) => metric.total > 0);
|
||||
if (!hasUsableInstrumentation) {
|
||||
errors.push(
|
||||
`${project.name} coverage report ${relativeReportPath} has no usable instrumentation for runtime-owning source ${relativeSourcePath}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
report: {
|
||||
data,
|
||||
path: reportPath,
|
||||
project,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRequiredProjectReports({ projects, workspaceRoot }) {
|
||||
const errors = [];
|
||||
const reports = [];
|
||||
|
||||
for (const project of projects) {
|
||||
const result = validateProjectCoverage({ project, workspaceRoot });
|
||||
errors.push(...result.errors);
|
||||
if (result.report) {
|
||||
reports.push(result.report);
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, reports };
|
||||
}
|
||||
|
||||
function formatConfigurationValue(value) {
|
||||
return typeof value === 'string' ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
function validateCoverageRatchetConfiguration(ratchet) {
|
||||
const errors = [];
|
||||
|
||||
for (const metric of COVERAGE_METRICS) {
|
||||
const value = ratchet?.merged?.[metric];
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isFinite(value) ||
|
||||
value < 0 ||
|
||||
value > 100
|
||||
) {
|
||||
errors.push(
|
||||
`Invalid coverage ratchet merged.${metric}: expected a finite number between 0 and 100, received ${formatConfigurationValue(value)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const criticalFiles = ratchet?.criticalFiles;
|
||||
if (!Array.isArray(criticalFiles)) {
|
||||
errors.push(
|
||||
`Invalid coverage ratchet criticalFiles: expected an array, received ${formatConfigurationValue(criticalFiles)}.`
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
|
||||
for (const [index, criticalFile] of criticalFiles.entries()) {
|
||||
const criticalPath = criticalFile?.path;
|
||||
const hasValidPath =
|
||||
typeof criticalPath === 'string' &&
|
||||
criticalPath.trim().length > 0;
|
||||
if (!hasValidPath) {
|
||||
errors.push(
|
||||
`Invalid coverage ratchet criticalFiles[${index}].path: expected a non-empty string, received ${formatConfigurationValue(criticalPath)}.`
|
||||
);
|
||||
}
|
||||
const filePath = hasValidPath
|
||||
? toPosix(criticalPath)
|
||||
: `criticalFiles[${index}]`;
|
||||
const minimumCovered =
|
||||
criticalFile?.statements?.minimumCovered;
|
||||
if (
|
||||
typeof minimumCovered !== 'number' ||
|
||||
!Number.isFinite(minimumCovered) ||
|
||||
!Number.isInteger(minimumCovered) ||
|
||||
minimumCovered < 0
|
||||
) {
|
||||
errors.push(
|
||||
`Invalid coverage ratchet ${filePath} statements.minimumCovered: expected a non-negative integer, received ${formatConfigurationValue(minimumCovered)}.`
|
||||
);
|
||||
}
|
||||
|
||||
const minimumPercent =
|
||||
criticalFile?.statements?.minimumPercent;
|
||||
if (
|
||||
typeof minimumPercent !== 'number' ||
|
||||
!Number.isFinite(minimumPercent) ||
|
||||
minimumPercent < 0 ||
|
||||
minimumPercent > 100
|
||||
) {
|
||||
errors.push(
|
||||
`Invalid coverage ratchet ${filePath} statements.minimumPercent: expected a finite number between 0 and 100, received ${formatConfigurationValue(minimumPercent)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function evaluateCoverageRatchets({
|
||||
coverageData,
|
||||
mergedSummary,
|
||||
ratchet,
|
||||
workspaceRoot,
|
||||
}) {
|
||||
const configurationErrors =
|
||||
validateCoverageRatchetConfiguration(ratchet);
|
||||
if (configurationErrors.length > 0) {
|
||||
return configurationErrors;
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const metric of COVERAGE_METRICS) {
|
||||
const required = ratchet.merged[metric];
|
||||
const observed = mergedSummary[metric]?.pct;
|
||||
if (!Number.isFinite(observed) || observed < required) {
|
||||
errors.push(
|
||||
`Merged ${metric} coverage regressed: observed ${observed}%, required at least ${required}%.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const coverageMap = createCoverageMap(coverageData);
|
||||
const {
|
||||
duplicateAbsolutePaths,
|
||||
filesByAbsolutePath: reportedFiles,
|
||||
} = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot);
|
||||
if (duplicateAbsolutePaths.length > 0) {
|
||||
return [
|
||||
...errors,
|
||||
...duplicateAbsolutePaths.map(
|
||||
(filePath) =>
|
||||
`Merged coverage contains a duplicate entry for ${toPosix(
|
||||
path.relative(workspaceRoot, filePath)
|
||||
)} after path normalization.`
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
for (const criticalFile of ratchet.criticalFiles) {
|
||||
const filePath = path.resolve(workspaceRoot, criticalFile.path);
|
||||
const relativePath = toPosix(path.relative(workspaceRoot, filePath));
|
||||
const reportedPath = reportedFiles.get(filePath);
|
||||
if (reportedPath === undefined) {
|
||||
errors.push(
|
||||
`Critical coverage ${relativePath} is missing from merged coverage.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const statements = coverageMap
|
||||
.fileCoverageFor(reportedPath)
|
||||
.toSummary()
|
||||
.toJSON().statements;
|
||||
const minimumCovered = criticalFile.statements.minimumCovered;
|
||||
if (statements.covered < minimumCovered) {
|
||||
errors.push(
|
||||
`Critical coverage ${relativePath} statements covered regressed: observed ${statements.covered}, required at least ${minimumCovered}.`
|
||||
);
|
||||
}
|
||||
|
||||
const minimumPercent = criticalFile.statements.minimumPercent;
|
||||
if (statements.pct < minimumPercent) {
|
||||
errors.push(
|
||||
`Critical coverage ${relativePath} statements percent regressed: observed ${statements.pct}%, required at least ${minimumPercent}%.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
createCoverageOutputScanner,
|
||||
evaluateCoverageRatchets,
|
||||
hasRuntimeOwnedStatement,
|
||||
validateProjectCoverage,
|
||||
validateRequiredProjectReports,
|
||||
} from './coverage-integrity.mjs';
|
||||
|
||||
const temporaryRoots = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeProjectFixture({
|
||||
name = 'example',
|
||||
projectRoot = `libs/${name}`,
|
||||
workspaceRoot = undefined,
|
||||
} = {}) {
|
||||
const ownsWorkspace = workspaceRoot === undefined;
|
||||
const fixtureRoot =
|
||||
workspaceRoot ??
|
||||
mkdtempSync(path.join(tmpdir(), 'iptvnator-coverage-integrity-'));
|
||||
if (ownsWorkspace) {
|
||||
temporaryRoots.push(fixtureRoot);
|
||||
}
|
||||
|
||||
const sourceRoot = `${projectRoot}/src`;
|
||||
const sourcePath = path.join(fixtureRoot, sourceRoot, 'runtime.ts');
|
||||
const reportPath = path.join(
|
||||
fixtureRoot,
|
||||
'coverage',
|
||||
projectRoot,
|
||||
'coverage-final.json'
|
||||
);
|
||||
|
||||
mkdirSync(path.dirname(sourcePath), { recursive: true });
|
||||
mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
writeFileSync(sourcePath, 'export const runtime = true;\n');
|
||||
|
||||
const project = {
|
||||
name,
|
||||
root: projectRoot,
|
||||
sourceRoot,
|
||||
};
|
||||
|
||||
return {
|
||||
project,
|
||||
reportPath,
|
||||
sourcePath,
|
||||
workspaceRoot: fixtureRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function coverageEntry(filePath, hits = [0]) {
|
||||
const statementMap = Object.fromEntries(
|
||||
hits.map((_, index) => [
|
||||
index,
|
||||
{
|
||||
start: { line: index + 1, column: 0 },
|
||||
end: { line: index + 1, column: 28 },
|
||||
},
|
||||
])
|
||||
);
|
||||
const statementHits = Object.fromEntries(
|
||||
hits.map((hit, index) => [index, hit])
|
||||
);
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
statementMap,
|
||||
fnMap: {},
|
||||
branchMap: {},
|
||||
s: statementHits,
|
||||
f: {},
|
||||
b: {},
|
||||
};
|
||||
}
|
||||
|
||||
function functionOnlyCoverageEntry(filePath) {
|
||||
return {
|
||||
path: filePath,
|
||||
statementMap: {},
|
||||
fnMap: {
|
||||
0: {
|
||||
name: 'runtime',
|
||||
decl: {
|
||||
start: { line: 1, column: 7 },
|
||||
end: { line: 1, column: 15 },
|
||||
},
|
||||
loc: {
|
||||
start: { line: 1, column: 7 },
|
||||
end: { line: 1, column: 28 },
|
||||
},
|
||||
line: 1,
|
||||
},
|
||||
},
|
||||
branchMap: {},
|
||||
s: {},
|
||||
f: { 0: 1 },
|
||||
b: {},
|
||||
};
|
||||
}
|
||||
|
||||
function writeCompleteReport(fixture, reportKey = fixture.sourcePath) {
|
||||
writeFileSync(
|
||||
fixture.reportPath,
|
||||
JSON.stringify({
|
||||
[reportKey]: coverageEntry(fixture.sourcePath),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function fullyCoveredSummary() {
|
||||
return {
|
||||
statements: { covered: 1, total: 1, pct: 100 },
|
||||
branches: { covered: 0, total: 0, pct: 100 },
|
||||
functions: { covered: 0, total: 0, pct: 100 },
|
||||
lines: { covered: 1, total: 1, pct: 100 },
|
||||
};
|
||||
}
|
||||
|
||||
function ratchetWithCriticalFiles(criticalFiles) {
|
||||
return {
|
||||
merged: {
|
||||
statements: 0,
|
||||
branches: 0,
|
||||
functions: 0,
|
||||
lines: 0,
|
||||
},
|
||||
criticalFiles,
|
||||
};
|
||||
}
|
||||
|
||||
describe('hasRuntimeOwnedStatement', () => {
|
||||
it('excludes type-only, import-only, and pure re-export source', () => {
|
||||
assert.equal(
|
||||
hasRuntimeOwnedStatement(
|
||||
'import type { Settings } from "./settings";'
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
hasRuntimeOwnedStatement(
|
||||
'import settings = require("./settings");'
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
hasRuntimeOwnedStatement('interface Settings { enabled: boolean }'),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
hasRuntimeOwnedStatement('export type Mode = "live" | "vod";'),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
hasRuntimeOwnedStatement('export { value } from "./value";'),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
hasRuntimeOwnedStatement('declare const injected: string;'),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('includes emitted declarations and executable statements', () => {
|
||||
assert.equal(
|
||||
hasRuntimeOwnedStatement('export const enabled = true;'),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
hasRuntimeOwnedStatement(
|
||||
'export class RuntimeBoundary { start() {} }'
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(hasRuntimeOwnedStatement('console.log("runtime");'), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCoverageOutputScanner', () => {
|
||||
it('detects an ANSI-colored marker split across chunks', () => {
|
||||
const scanner = createCoverageOutputScanner(128);
|
||||
|
||||
scanner.push('\u001b[31mFailed to collect');
|
||||
scanner.push(' coverage from /workspace/effects.ts\u001b[39m');
|
||||
|
||||
assert.equal(scanner.collectionFailed, true);
|
||||
});
|
||||
|
||||
it('detects a marker across a split ANSI escape sequence', () => {
|
||||
const scanner = createCoverageOutputScanner(128);
|
||||
|
||||
scanner.push('Failed to \u001b[3');
|
||||
scanner.push('1mcollect coverage from /workspace/effects.ts');
|
||||
|
||||
assert.equal(scanner.collectionFailed, true);
|
||||
assert.equal(scanner.tail.includes('\u001b'), false);
|
||||
});
|
||||
|
||||
it('keeps only a bounded rolling tail', () => {
|
||||
const scanner = createCoverageOutputScanner(32);
|
||||
|
||||
scanner.push('x'.repeat(256));
|
||||
|
||||
assert.equal(scanner.tail.length, 32);
|
||||
assert.equal(scanner.collectionFailed, false);
|
||||
});
|
||||
|
||||
it('detects the marker before trimming a long chunk', () => {
|
||||
const scanner = createCoverageOutputScanner(32);
|
||||
|
||||
scanner.push(`Failed to collect coverage${'x'.repeat(256)}`);
|
||||
|
||||
assert.equal(scanner.tail.length, 32);
|
||||
assert.equal(scanner.collectionFailed, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProjectCoverage', () => {
|
||||
it('accepts a report containing every runtime-owning file', () => {
|
||||
const fixture = makeProjectFixture();
|
||||
const relativeReportKey = path.relative(
|
||||
fixture.workspaceRoot,
|
||||
fixture.sourcePath
|
||||
);
|
||||
writeCompleteReport(fixture, relativeReportKey);
|
||||
|
||||
const result = validateProjectCoverage(fixture);
|
||||
|
||||
assert.deepEqual(result.errors, []);
|
||||
assert.equal(result.report?.project.name, 'example');
|
||||
assert.equal(result.report?.path, fixture.reportPath);
|
||||
});
|
||||
|
||||
it('reports a runtime-owning source omitted from a valid report', () => {
|
||||
const fixture = makeProjectFixture();
|
||||
writeFileSync(fixture.reportPath, '{}');
|
||||
|
||||
const result = validateProjectCoverage(fixture);
|
||||
|
||||
assert.equal(result.errors.length, 1);
|
||||
assert.match(result.errors[0], /example/);
|
||||
assert.match(result.errors[0], /libs\/example\/src\/runtime\.ts/);
|
||||
});
|
||||
|
||||
it('rejects a malformed Istanbul coverage entry', () => {
|
||||
const fixture = makeProjectFixture();
|
||||
writeFileSync(
|
||||
fixture.reportPath,
|
||||
JSON.stringify({ [fixture.sourcePath]: {} })
|
||||
);
|
||||
|
||||
const result = validateProjectCoverage(fixture);
|
||||
|
||||
assert.equal(result.report, undefined);
|
||||
assert.match(
|
||||
result.errors[0],
|
||||
/example.*coverage\/libs\/example\/coverage-final\.json.*valid Istanbul/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects duplicate report paths after normalization', () => {
|
||||
const fixture = makeProjectFixture();
|
||||
const relativePath = path.relative(
|
||||
fixture.workspaceRoot,
|
||||
fixture.sourcePath
|
||||
);
|
||||
writeFileSync(
|
||||
fixture.reportPath,
|
||||
JSON.stringify({
|
||||
[relativePath]: coverageEntry(relativePath),
|
||||
[fixture.sourcePath]: coverageEntry(fixture.sourcePath),
|
||||
})
|
||||
);
|
||||
|
||||
const result = validateProjectCoverage(fixture);
|
||||
|
||||
assert.equal(result.report, undefined);
|
||||
assert.equal(result.errors.length, 1);
|
||||
assert.match(
|
||||
result.errors[0],
|
||||
/example.*coverage\/libs\/example\/coverage-final\.json.*duplicate.*libs\/example\/src\/runtime\.ts.*normalization/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a runtime source without usable instrumentation', () => {
|
||||
const fixture = makeProjectFixture();
|
||||
writeFileSync(
|
||||
fixture.reportPath,
|
||||
JSON.stringify({
|
||||
[fixture.sourcePath]: coverageEntry(fixture.sourcePath, []),
|
||||
})
|
||||
);
|
||||
|
||||
const result = validateProjectCoverage(fixture);
|
||||
|
||||
assert.equal(result.errors.length, 1);
|
||||
assert.match(result.errors[0], /example/);
|
||||
assert.match(result.errors[0], /libs\/example\/src\/runtime\.ts/);
|
||||
assert.match(result.errors[0], /usable instrumentation/);
|
||||
});
|
||||
|
||||
it('accepts function-only instrumentation for a runtime source', () => {
|
||||
const fixture = makeProjectFixture();
|
||||
writeFileSync(
|
||||
fixture.sourcePath,
|
||||
'export function runtime() {}\n'
|
||||
);
|
||||
writeFileSync(
|
||||
fixture.reportPath,
|
||||
JSON.stringify({
|
||||
[fixture.sourcePath]: functionOnlyCoverageEntry(
|
||||
fixture.sourcePath
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
const result = validateProjectCoverage(fixture);
|
||||
|
||||
assert.deepEqual(result.errors, []);
|
||||
assert.equal(result.report?.project.name, 'example');
|
||||
});
|
||||
|
||||
it('does not require excluded or type-only TypeScript sources', () => {
|
||||
const fixture = makeProjectFixture();
|
||||
const excludedSources = [
|
||||
'feature.spec.ts',
|
||||
'feature.test.ts',
|
||||
'ambient.d.ts',
|
||||
'test-setup.ts',
|
||||
'test-stubs/runtime.ts',
|
||||
'feature.generated.ts',
|
||||
'environments/environment.ts',
|
||||
'index.ts',
|
||||
];
|
||||
for (const relativePath of excludedSources) {
|
||||
const filePath = path.join(
|
||||
fixture.workspaceRoot,
|
||||
fixture.project.sourceRoot,
|
||||
relativePath
|
||||
);
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, 'export const omitted = true;\n');
|
||||
}
|
||||
writeFileSync(
|
||||
path.join(
|
||||
fixture.workspaceRoot,
|
||||
fixture.project.sourceRoot,
|
||||
'contract.ts'
|
||||
),
|
||||
'export interface Contract { enabled: boolean }\n'
|
||||
);
|
||||
writeCompleteReport(fixture);
|
||||
|
||||
assert.deepEqual(validateProjectCoverage(fixture).errors, []);
|
||||
});
|
||||
|
||||
it('reports missing, malformed, and non-object project reports', () => {
|
||||
const missing = makeProjectFixture();
|
||||
const malformed = makeProjectFixture();
|
||||
const array = makeProjectFixture();
|
||||
const nullValue = makeProjectFixture();
|
||||
writeFileSync(malformed.reportPath, '{ invalid json');
|
||||
writeFileSync(array.reportPath, '[]');
|
||||
writeFileSync(nullValue.reportPath, 'null');
|
||||
|
||||
assert.match(
|
||||
validateProjectCoverage(missing).errors[0],
|
||||
/example.*did not produce.*coverage\/libs\/example\/coverage-final\.json/
|
||||
);
|
||||
assert.match(
|
||||
validateProjectCoverage(malformed).errors[0],
|
||||
/example.*invalid JSON/
|
||||
);
|
||||
assert.match(
|
||||
validateProjectCoverage(array).errors[0],
|
||||
/example.*JSON object/
|
||||
);
|
||||
assert.match(
|
||||
validateProjectCoverage(nullValue).errors[0],
|
||||
/example.*JSON object/
|
||||
);
|
||||
});
|
||||
|
||||
it('requires every configured report and preserves policy order', () => {
|
||||
const first = makeProjectFixture({
|
||||
name: 'first',
|
||||
projectRoot: 'libs/first',
|
||||
});
|
||||
const missing = makeProjectFixture({
|
||||
name: 'missing',
|
||||
projectRoot: 'libs/missing',
|
||||
workspaceRoot: first.workspaceRoot,
|
||||
});
|
||||
const last = makeProjectFixture({
|
||||
name: 'last',
|
||||
projectRoot: 'libs/last',
|
||||
workspaceRoot: first.workspaceRoot,
|
||||
});
|
||||
writeCompleteReport(first);
|
||||
writeCompleteReport(last);
|
||||
|
||||
const result = validateRequiredProjectReports({
|
||||
projects: [last.project, missing.project, first.project],
|
||||
workspaceRoot: first.workspaceRoot,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result.reports.map((report) => report.project.name),
|
||||
['last', 'first']
|
||||
);
|
||||
assert.equal(result.errors.length, 1);
|
||||
assert.match(result.errors[0], /missing/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateCoverageRatchets', () => {
|
||||
it('reports all aggregate percentage regressions', () => {
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {},
|
||||
mergedSummary: {
|
||||
statements: { covered: 68, total: 100, pct: 68 },
|
||||
branches: { covered: 57, total: 100, pct: 57 },
|
||||
functions: { covered: 67, total: 100, pct: 67 },
|
||||
lines: { covered: 69, total: 100, pct: 69 },
|
||||
},
|
||||
ratchet: {
|
||||
merged: {
|
||||
statements: 68.86,
|
||||
branches: 58.66,
|
||||
functions: 67.19,
|
||||
lines: 69.2,
|
||||
},
|
||||
criticalFiles: [],
|
||||
},
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, [
|
||||
'Merged statements coverage regressed: observed 68%, required at least 68.86%.',
|
||||
'Merged branches coverage regressed: observed 57%, required at least 58.66%.',
|
||||
'Merged functions coverage regressed: observed 67%, required at least 67.19%.',
|
||||
'Merged lines coverage regressed: observed 69%, required at least 69.2%.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects missing, non-numeric, and out-of-range aggregate minima', () => {
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet: {
|
||||
merged: {
|
||||
branches: '58.66',
|
||||
functions: -1,
|
||||
lines: 101,
|
||||
},
|
||||
criticalFiles: [],
|
||||
},
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
|
||||
assert.equal(errors.length, 4);
|
||||
assert.match(errors[0], /merged\.statements.*undefined/);
|
||||
assert.match(errors[1], /merged\.branches.*"58\.66"/);
|
||||
assert.match(errors[2], /merged\.functions.*-1/);
|
||||
assert.match(errors[3], /merged\.lines.*101/);
|
||||
});
|
||||
|
||||
it('accepts an explicitly empty criticalFiles array', () => {
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet: ratchetWithCriticalFiles([]),
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
it('requires an explicit criticalFiles array', () => {
|
||||
const merged = ratchetWithCriticalFiles([]).merged;
|
||||
const ratchets = [
|
||||
{ merged },
|
||||
{ merged, criticalFiles: null },
|
||||
];
|
||||
|
||||
for (const ratchet of ratchets) {
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet,
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /criticalFiles.*array/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a non-array criticalFiles container without throwing', () => {
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet: ratchetWithCriticalFiles({}),
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /criticalFiles.*array.*object/);
|
||||
});
|
||||
|
||||
it('requires a non-empty string path for every critical file', () => {
|
||||
for (const invalidPath of [undefined, null, 42, '', ' ']) {
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet: ratchetWithCriticalFiles([
|
||||
{
|
||||
path: invalidPath,
|
||||
statements: {
|
||||
minimumCovered: 0,
|
||||
minimumPercent: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /criticalFiles\[0\]\.path/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid critical-file statement minima', () => {
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet: {
|
||||
merged: {
|
||||
statements: 0,
|
||||
branches: 0,
|
||||
functions: 0,
|
||||
lines: 0,
|
||||
},
|
||||
criticalFiles: [
|
||||
{
|
||||
path: 'apps/missing-values.ts',
|
||||
statements: {},
|
||||
},
|
||||
{
|
||||
path: 'apps/string-values.ts',
|
||||
statements: {
|
||||
minimumCovered: '2',
|
||||
minimumPercent: '75',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'apps/out-of-range.ts',
|
||||
statements: {
|
||||
minimumCovered: -1,
|
||||
minimumPercent: 101,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'apps/fractional.ts',
|
||||
statements: {
|
||||
minimumCovered: 1.5,
|
||||
minimumPercent: 75,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
|
||||
assert.equal(errors.length, 7);
|
||||
assert.match(
|
||||
errors[0],
|
||||
/apps\/missing-values\.ts.*minimumCovered.*undefined/
|
||||
);
|
||||
assert.match(
|
||||
errors[1],
|
||||
/apps\/missing-values\.ts.*minimumPercent.*undefined/
|
||||
);
|
||||
assert.match(
|
||||
errors[2],
|
||||
/apps\/string-values\.ts.*minimumCovered.*"2"/
|
||||
);
|
||||
assert.match(
|
||||
errors[3],
|
||||
/apps\/string-values\.ts.*minimumPercent.*"75"/
|
||||
);
|
||||
assert.match(
|
||||
errors[4],
|
||||
/apps\/out-of-range\.ts.*minimumCovered.*-1/
|
||||
);
|
||||
assert.match(
|
||||
errors[5],
|
||||
/apps\/out-of-range\.ts.*minimumPercent.*101/
|
||||
);
|
||||
assert.match(
|
||||
errors[6],
|
||||
/apps\/fractional\.ts.*minimumCovered.*1\.5/
|
||||
);
|
||||
});
|
||||
|
||||
it('requires both covered statements and percentage for a critical file', () => {
|
||||
const workspaceRoot = '/workspace';
|
||||
const filePath = path.join(workspaceRoot, 'apps/runtime.ts');
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {
|
||||
[filePath]: coverageEntry(filePath, [1, 0]),
|
||||
},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet: {
|
||||
merged: {
|
||||
statements: 0,
|
||||
branches: 0,
|
||||
functions: 0,
|
||||
lines: 0,
|
||||
},
|
||||
criticalFiles: [
|
||||
{
|
||||
path: 'apps/runtime.ts',
|
||||
statements: {
|
||||
minimumCovered: 2,
|
||||
minimumPercent: 75,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
workspaceRoot,
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, [
|
||||
'Critical coverage apps/runtime.ts statements covered regressed: observed 1, required at least 2.',
|
||||
'Critical coverage apps/runtime.ts statements percent regressed: observed 50%, required at least 75%.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('looks up a relative Istanbul key by its normalized critical path', () => {
|
||||
const workspaceRoot = process.cwd();
|
||||
const relativePath = 'apps/runtime.ts';
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {
|
||||
[relativePath]: coverageEntry(relativePath, [1, 1]),
|
||||
},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet: {
|
||||
merged: {
|
||||
statements: 0,
|
||||
branches: 0,
|
||||
functions: 0,
|
||||
lines: 0,
|
||||
},
|
||||
criticalFiles: [
|
||||
{
|
||||
path: relativePath,
|
||||
statements: {
|
||||
minimumCovered: 2,
|
||||
minimumPercent: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
workspaceRoot,
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
it('rejects duplicate Istanbul keys after path normalization', () => {
|
||||
const workspaceRoot = process.cwd();
|
||||
const relativePath = 'apps/runtime.ts';
|
||||
const absolutePath = path.join(workspaceRoot, relativePath);
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {
|
||||
[relativePath]: coverageEntry(relativePath, [1]),
|
||||
[absolutePath]: coverageEntry(absolutePath, [1]),
|
||||
},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet: {
|
||||
merged: {
|
||||
statements: 0,
|
||||
branches: 0,
|
||||
functions: 0,
|
||||
lines: 0,
|
||||
},
|
||||
criticalFiles: [],
|
||||
},
|
||||
workspaceRoot,
|
||||
});
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(
|
||||
errors[0],
|
||||
/duplicate.*apps\/runtime\.ts.*normalization/
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a critical file missing from merged coverage', () => {
|
||||
const errors = evaluateCoverageRatchets({
|
||||
coverageData: {},
|
||||
mergedSummary: fullyCoveredSummary(),
|
||||
ratchet: {
|
||||
merged: {
|
||||
statements: 0,
|
||||
branches: 0,
|
||||
functions: 0,
|
||||
lines: 0,
|
||||
},
|
||||
criticalFiles: [
|
||||
{
|
||||
path: 'apps/missing.ts',
|
||||
statements: {
|
||||
minimumCovered: 1,
|
||||
minimumPercent: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, [
|
||||
'Critical coverage apps/missing.ts is missing from merged coverage.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user