mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
346 lines
12 KiB
JavaScript
346 lines
12 KiB
JavaScript
/*
|
|
* Copyright © 2025-26 l5yth & contributors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
/**
|
|
* Coverage floor of the frontend suite (SPEC CV3).
|
|
*
|
|
* `npm test` runs Node's test runner with coverage of the app's own sources,
|
|
* `public/assets/js` without its `__tests__` directories, and writes that
|
|
* coverage as an lcov report. This script reads the report and exits 1 when
|
|
* line or branch coverage falls below the floor given on the command line.
|
|
* V8 reports only files a test imported, so every source no test loaded
|
|
* counts here with all of its lines uncovered. The floors sit 0.1 point
|
|
* below the lowest measured figures and only rise, until coverage reaches
|
|
* 100% (SPEC CV1).
|
|
*
|
|
* Usage, from `web/`:
|
|
* `node scripts/check-coverage.js <lcov> --lines <pct> --branches <pct> [--source-root <dir>]`
|
|
*
|
|
* @module scripts/check-coverage
|
|
*/
|
|
|
|
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
/**
|
|
* Directory, relative to `web/`, whose JavaScript the floor measures: the
|
|
* app's shipped sources. `web/scripts`, CI tooling such as this check and the
|
|
* istanbul exporter, is left out (SPEC CV7).
|
|
*/
|
|
export const DEFAULT_SOURCE_ROOT = 'public/assets/js';
|
|
|
|
/** The metrics the floor applies to, in report order. */
|
|
const METRICS = ['lines', 'branches'];
|
|
|
|
/**
|
|
* Line and branch hit counts of one source file.
|
|
*
|
|
* @typedef {Object} CoverageRecord
|
|
* @property {Map<number, number>} lines Hits per line number.
|
|
* @property {Map<string, number>} branches Hits per `line,block,branch` key.
|
|
*/
|
|
|
|
/**
|
|
* Covered and total counts of one metric.
|
|
*
|
|
* @typedef {Object} Count
|
|
* @property {number} covered Items hit at least once.
|
|
* @property {number} total All items.
|
|
*/
|
|
|
|
/**
|
|
* Parse an lcov report into per-file line and branch hit counts.
|
|
*
|
|
* Only `SF`, `DA`, `BRDA` and `end_of_record` matter; summary records are
|
|
* recomputed. Records repeated for one file merge by the higher count, and a
|
|
* `-` branch (its block never ran) counts as not taken.
|
|
*
|
|
* @param {string} text The lcov report.
|
|
* @returns {Map<string, CoverageRecord>} Records keyed by the `SF` path.
|
|
*/
|
|
export function parseLcov(text) {
|
|
const records = new Map();
|
|
let current = null;
|
|
for (const raw of text.split('\n')) {
|
|
const line = raw.trim();
|
|
if (line.startsWith('SF:')) {
|
|
const file = line.slice(3);
|
|
current = records.get(file) ?? { lines: new Map(), branches: new Map() };
|
|
records.set(file, current);
|
|
continue;
|
|
}
|
|
if (line === 'end_of_record') {
|
|
current = null;
|
|
continue;
|
|
}
|
|
if (!current) continue;
|
|
if (line.startsWith('DA:')) {
|
|
const [lineNumber, hits] = line.slice(3).split(',');
|
|
mergeMax(current.lines, Number(lineNumber), Number(hits));
|
|
} else if (line.startsWith('BRDA:')) {
|
|
const [lineNumber, block, branch, taken] = line.slice(5).split(',');
|
|
mergeMax(current.branches, `${lineNumber},${block},${branch}`, taken === '-' ? 0 : Number(taken));
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
/**
|
|
* Store `value` under `key` unless a higher count is already there.
|
|
*
|
|
* @param {Map<*, number>} map Counts to update.
|
|
* @param {*} key Line number or branch key.
|
|
* @param {number} value Hit count.
|
|
* @returns {void}
|
|
*/
|
|
function mergeMax(map, key, value) {
|
|
map.set(key, Math.max(map.get(key) ?? 0, value));
|
|
}
|
|
|
|
/**
|
|
* Number of lines Node's coverage reports for a file: every line ending in
|
|
* a newline, plus a last line without one.
|
|
*
|
|
* @param {string} source File content.
|
|
* @returns {number} The line count.
|
|
*/
|
|
export function countLines(source) {
|
|
if (source.length === 0) return 0;
|
|
const breaks = source.split('\n').length - 1;
|
|
return source.endsWith('\n') ? breaks : breaks + 1;
|
|
}
|
|
|
|
/**
|
|
* List the JavaScript files under `root`, skipping `__tests__` directories.
|
|
*
|
|
* @param {string} root Directory to walk.
|
|
* @returns {Promise<string[]>} Paths joined onto `root`, sorted.
|
|
*/
|
|
export async function listSources(root) {
|
|
const found = [];
|
|
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
|
const full = path.join(root, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name !== '__tests__') found.push(...(await listSources(full)));
|
|
} else if (entry.name.endsWith('.js')) {
|
|
found.push(full);
|
|
}
|
|
}
|
|
return found.sort();
|
|
}
|
|
|
|
/**
|
|
* Line and branch totals over the measured records, with the lines of every
|
|
* source no test loaded added as uncovered.
|
|
*
|
|
* @param {Map<string, CoverageRecord>} records Records of loaded sources.
|
|
* @param {Array<{file: string, lines: number}>} unloaded Sources no test loaded.
|
|
* @returns {{lines: Count, branches: Count}} The totals.
|
|
*/
|
|
export function summarise(records, unloaded) {
|
|
const totals = { lines: { covered: 0, total: 0 }, branches: { covered: 0, total: 0 } };
|
|
for (const { lines, branches } of records.values()) {
|
|
addCounts(totals.lines, lines);
|
|
addCounts(totals.branches, branches);
|
|
}
|
|
for (const { lines } of unloaded) totals.lines.total += lines;
|
|
return totals;
|
|
}
|
|
|
|
/**
|
|
* Add each hit count to a running total.
|
|
*
|
|
* @param {Count} target Totals to update.
|
|
* @param {Map<*, number>} hits Hit counts.
|
|
* @returns {void}
|
|
*/
|
|
function addCounts(target, hits) {
|
|
for (const count of hits.values()) {
|
|
target.total += 1;
|
|
if (count > 0) target.covered += 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Percentage covered; an empty total counts as fully covered.
|
|
*
|
|
* @param {Count} count Covered and total.
|
|
* @returns {number} Percentage from 0 to 100.
|
|
*/
|
|
export function percent({ covered, total }) {
|
|
return total === 0 ? 100 : (covered / total) * 100;
|
|
}
|
|
|
|
/**
|
|
* Compare the totals with the floors, unrounded.
|
|
*
|
|
* @param {{lines: Count, branches: Count}} totals Measured totals.
|
|
* @param {{lines: number, branches: number}} floors Minimum percentages.
|
|
* @returns {string[]} One message per metric below its floor.
|
|
*/
|
|
export function belowFloors(totals, floors) {
|
|
const failures = [];
|
|
for (const metric of METRICS) {
|
|
const actual = percent(totals[metric]);
|
|
if (actual < floors[metric]) failures.push(`${metric} ${actual.toFixed(3)}% < ${floors[metric]}%`);
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
/**
|
|
* Parse the command line.
|
|
*
|
|
* @param {string[]} argv Arguments after the script path.
|
|
* @returns {{lcovPath: string, sourceRoot: string, floors: {lines: number, branches: number}}}
|
|
* The report path, the measured directory and both floors.
|
|
* @throws {Error} When an argument is missing, unknown or malformed.
|
|
*/
|
|
export function parseArgs(argv) {
|
|
const options = { sourceRoot: DEFAULT_SOURCE_ROOT, floors: {} };
|
|
const rest = [...argv];
|
|
while (rest.length > 0) {
|
|
const arg = rest.shift();
|
|
if (arg === '--lines' || arg === '--branches') {
|
|
options.floors[arg.slice(2)] = parseFloor(arg, rest.shift());
|
|
} else if (arg === '--source-root') {
|
|
options.sourceRoot = requireValue(arg, rest.shift());
|
|
} else if (arg.startsWith('--')) {
|
|
throw new Error(`unknown option ${arg}`);
|
|
} else if (options.lcovPath) {
|
|
throw new Error(`unexpected argument ${arg}`);
|
|
} else {
|
|
options.lcovPath = arg;
|
|
}
|
|
}
|
|
if (!options.lcovPath) throw new Error('missing lcov report path');
|
|
for (const metric of METRICS) {
|
|
if (options.floors[metric] === undefined) throw new Error(`missing --${metric} floor`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* Read a floor percentage.
|
|
*
|
|
* @param {string} flag Option name, for messages.
|
|
* @param {string | undefined} value The option's value.
|
|
* @returns {number} The floor.
|
|
* @throws {Error} When the value is missing or not a percentage.
|
|
*/
|
|
function parseFloor(flag, value) {
|
|
const floor = Number(requireValue(flag, value));
|
|
if (!Number.isFinite(floor) || floor < 0 || floor > 100) {
|
|
throw new Error(`${flag} needs a percentage from 0 to 100`);
|
|
}
|
|
return floor;
|
|
}
|
|
|
|
/**
|
|
* Require an option value that is not the next option.
|
|
*
|
|
* @param {string} flag Option name, for messages.
|
|
* @param {string | undefined} value The option's value.
|
|
* @returns {string} The value.
|
|
* @throws {Error} When the value is missing.
|
|
*/
|
|
function requireValue(flag, value) {
|
|
if (value === undefined || value === '' || value.startsWith('--')) {
|
|
throw new Error(`${flag} needs a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Turn an lcov `SF` path into a path relative to `cwd`.
|
|
*
|
|
* @param {string} file The path as the report names it.
|
|
* @param {string} cwd Directory the report's relative paths start from.
|
|
* @returns {string} The relative path.
|
|
*/
|
|
function relativeTo(file, cwd) {
|
|
return path.isAbsolute(file) ? path.relative(cwd, file) : file;
|
|
}
|
|
|
|
/**
|
|
* Check the coverage report against the floors.
|
|
*
|
|
* @param {string[]} argv Arguments after the script path.
|
|
* @param {{cwd?: string, stdout?: {write: function(string): *}, stderr?: {write: function(string): *}}} [io]
|
|
* Working directory and output streams; the process's own by default.
|
|
* @returns {Promise<number>} 0 at or above both floors, 1 below one, 2 on
|
|
* bad arguments or an unreadable report or source root.
|
|
*/
|
|
export async function main(argv, { cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
let options;
|
|
let text;
|
|
let sources;
|
|
try {
|
|
options = parseArgs(argv);
|
|
} catch (error) {
|
|
stderr.write(`check-coverage: ${error.message}\n`);
|
|
return 2;
|
|
}
|
|
try {
|
|
text = await fs.readFile(path.resolve(cwd, options.lcovPath), 'utf8');
|
|
} catch (error) {
|
|
stderr.write(`check-coverage: cannot read ${options.lcovPath}: ${error.message}\n`);
|
|
return 2;
|
|
}
|
|
try {
|
|
sources = (await listSources(path.resolve(cwd, options.sourceRoot))).map(file => path.relative(cwd, file));
|
|
} catch (error) {
|
|
stderr.write(`check-coverage: cannot list ${options.sourceRoot}: ${error.message}\n`);
|
|
return 2;
|
|
}
|
|
|
|
// Only the sources count: a report entry for a test file, a helper or a
|
|
// deleted file is left out, whatever the runner's coverage filters were.
|
|
const reported = new Map();
|
|
for (const [file, record] of parseLcov(text)) reported.set(relativeTo(file, cwd), record);
|
|
const measured = new Map();
|
|
const unloaded = [];
|
|
for (const file of sources) {
|
|
if (reported.has(file)) {
|
|
measured.set(file, reported.get(file));
|
|
} else {
|
|
unloaded.push({ file, lines: countLines(await fs.readFile(path.resolve(cwd, file), 'utf8')) });
|
|
}
|
|
}
|
|
|
|
const totals = summarise(measured, unloaded);
|
|
const parts = METRICS.map(metric => {
|
|
const { covered, total } = totals[metric];
|
|
return `${metric} ${percent(totals[metric]).toFixed(3)}% (${covered}/${total}, floor ${options.floors[metric]}%)`;
|
|
});
|
|
stdout.write(`Coverage of ${options.sourceRoot}: ${parts.join(', ')}\n`);
|
|
if (unloaded.length > 0) {
|
|
stdout.write(`Never loaded by a test, counted as uncovered lines: ${unloaded.map(entry => entry.file).join(', ')}\n`);
|
|
}
|
|
const failures = belowFloors(totals, options.floors);
|
|
if (failures.length > 0) {
|
|
stderr.write(`check-coverage: below the floor: ${failures.join('; ')}\n`);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Run when executed as a script, not when a test imports the module.
|
|
if (import.meta.url === pathToFileURL(path.resolve(String(process.argv[1]))).href) {
|
|
process.exitCode = await main(process.argv.slice(2));
|
|
}
|