@projectwallace/css-code-coverage
Version:
Generate useful CSS Code Coverage report from browser-reported coverage
341 lines (324 loc) • 12.7 kB
JavaScript
import { parseArgs, styleText } from "node:util";
import * as v from "valibot";
import { calculate_coverage } from "@projectwallace/css-code-coverage";
import { readFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
//#region src/cli/arguments.ts
const show_uncovered_options = {
none: "none",
all: "all",
violations: "violations"
};
const reporters = {
pretty: "pretty",
tap: "tap",
json: "json"
};
let CoverageDirSchema = v.pipe(v.string(), v.nonEmpty());
let RatioPercentageSchema = v.pipe(v.string(), v.transform(Number), v.number(), v.minValue(0), v.maxValue(1));
let ShowUncoveredSchema = v.pipe(v.string(), v.enum(show_uncovered_options));
let ReporterSchema = v.pipe(v.string(), v.enum(reporters));
let CliArgumentsSchema = v.object({
"coverage-dir": CoverageDirSchema,
"min-coverage": RatioPercentageSchema,
"min-file-coverage": v.optional(RatioPercentageSchema),
"show-uncovered": v.optional(ShowUncoveredSchema, show_uncovered_options.violations),
reporter: v.optional(ReporterSchema, reporters.pretty)
});
var InvalidArgumentsError = class extends Error {
issues;
constructor(issues) {
super();
this.issues = issues;
}
};
function validate_arguments(args) {
let parse_result = v.safeParse(CliArgumentsSchema, args);
if (!parse_result.success) throw new InvalidArgumentsError(parse_result.issues.map((issue) => ({
path: issue.path?.map((path) => path.key).join("."),
message: issue.message
})));
return parse_result.output;
}
function parse_arguments(args) {
let { values } = parseArgs({
args,
options: {
"coverage-dir": { type: "string" },
"min-coverage": { type: "string" },
"min-file-coverage": {
type: "string",
default: "0"
},
"show-uncovered": {
type: "string",
default: "violations"
},
reporter: {
type: "string",
default: "pretty"
}
}
});
return values;
}
//#endregion
//#region src/cli/program.ts
function validate_min_line_coverage(actual, expected) {
return {
ok: actual >= expected,
actual,
expected
};
}
function validate_min_file_line_coverage(actual, expected) {
if (expected === void 0) return {
ok: true,
actual,
expected
};
return {
ok: actual >= expected,
actual,
expected
};
}
function program({ min_coverage, min_file_coverage }, coverage_data) {
let coverage = calculate_coverage(coverage_data);
let min_coverage_result = validate_min_line_coverage(coverage.line_coverage_ratio, min_coverage);
let min_file_coverage_result = validate_min_file_line_coverage(Math.min(...coverage.coverage_per_stylesheet.map((sheet) => sheet.line_coverage_ratio)), min_file_coverage);
return {
context: { coverage },
report: {
ok: min_coverage_result.ok && min_file_coverage_result.ok,
min_line_coverage: min_coverage_result,
min_file_line_coverage: min_file_coverage_result
}
};
}
//#endregion
//#region src/lib/parse-coverage.ts
let RangeSchema = v.object({
start: v.number(),
end: v.number()
});
let CoverageSchema = v.object({
text: v.string(),
url: v.string(),
ranges: v.array(RangeSchema)
});
function is_valid_coverage(input) {
return v.safeParse(v.array(CoverageSchema), input).success;
}
function parse_coverage(input) {
try {
let parse_result = JSON.parse(input);
return is_valid_coverage(parse_result) ? parse_result : [];
} catch {
return [];
}
}
//#endregion
//#region src/cli/file-reader.ts
async function read(coverage_dir) {
if (!(await stat(coverage_dir)).isDirectory()) throw new TypeError("InvalidDirectory");
let file_paths = await readdir(coverage_dir);
let parsed_files = [];
for (let file_path of file_paths) {
if (!file_path.endsWith(".json")) continue;
let parsed = parse_coverage(await readFile(join(coverage_dir, file_path), "utf-8"));
parsed_files.push(...parsed);
}
return parsed_files;
}
//#endregion
//#region src/cli/reporters/pretty.ts
function indent(line) {
return (line || "").replace(/^\t+/, (tabs) => " ".repeat(tabs.length * 4));
}
let line_number = (num, covered = true) => `${num.toString().padStart(5, " ")} ${covered ? "│" : "━"} `;
function percentage(ratio, decimals = 2) {
return `${(ratio * 100).toFixed(ratio === 1 ? 0 : decimals)}%`;
}
function print_lines({ report, context }, params, { styleText: styleText$1, print_width }) {
let output = [];
if (report.min_line_coverage.ok) output.push(`${styleText$1(["bold", "green"], "Success")}: total line coverage is ${percentage(report.min_line_coverage.actual)}`);
else {
let { actual, expected } = report.min_line_coverage;
output.push(`${styleText$1(["bold", "red"], "Failed")}: line coverage is ${percentage(actual)}% which is lower than the threshold of ${expected}`);
let lines_to_cover = expected * context.coverage.total_lines - context.coverage.covered_lines;
output.push(`Tip: cover ${Math.ceil(lines_to_cover)} more ${lines_to_cover === 1 ? "line" : "lines"} to meet the threshold of ${percentage(expected)}`);
}
if (report.min_file_line_coverage.expected !== void 0) {
let { expected, actual, ok: ok$1 } = report.min_file_line_coverage;
if (ok$1) output.push(`${styleText$1(["bold", "green"], "Success")}: all files pass minimum line coverage of ${percentage(expected)}`);
else {
let num_files_failed = context.coverage.coverage_per_stylesheet.filter((sheet) => sheet.line_coverage_ratio < expected).length;
output.push(`${styleText$1(["bold", "red"], "Failed")}: ${num_files_failed} ${num_files_failed === 1 ? "file does" : "files do"} not meet the minimum line coverage of ${percentage(expected)} (minimum coverage was ${percentage(actual)})`);
if (params["show-uncovered"] === "none") output.push(` Hint: set --show-uncovered=violations to see which files didn't pass`);
}
}
if (params["show-uncovered"] !== "none") {
const NUM_LEADING_LINES = 3;
const NUM_TRAILING_LINES = NUM_LEADING_LINES;
print_width = print_width ?? 80;
let min_file_line_coverage = report.min_file_line_coverage.expected;
output.push();
for (let sheet of context.coverage.coverage_per_stylesheet.sort((a, b) => a.line_coverage_ratio - b.line_coverage_ratio)) if (sheet.line_coverage_ratio !== 1 && params["show-uncovered"] === "all" || min_file_line_coverage !== void 0 && min_file_line_coverage !== 0 && sheet.line_coverage_ratio < min_file_line_coverage && params["show-uncovered"] === "violations") {
output.push();
output.push(styleText$1("dim", "─".repeat(print_width)));
output.push(sheet.url);
output.push(`Coverage: ${percentage(sheet.line_coverage_ratio)}, ${sheet.covered_lines}/${sheet.total_lines} lines covered`);
if (min_file_line_coverage && min_file_line_coverage !== 0 && sheet.line_coverage_ratio < min_file_line_coverage) {
let lines_to_cover = min_file_line_coverage * sheet.total_lines - sheet.covered_lines;
output.push(`Tip: cover ${Math.ceil(lines_to_cover)} more ${lines_to_cover === 1 ? "line" : "lines"} to meet the file threshold of ${percentage(min_file_line_coverage)}`);
}
output.push(styleText$1("dim", "─".repeat(print_width)));
let lines = sheet.text.split("\n");
for (let chunk of sheet.chunks.filter((chunk$1) => !chunk$1.is_covered)) {
for (let x = Math.max(chunk.start_line - NUM_LEADING_LINES, 1); x < chunk.start_line; x++) output.push([styleText$1("dim", line_number(x)), styleText$1("dim", indent(lines[x - 1]))].join(""));
for (let i = chunk.start_line; i <= chunk.end_line; i++) output.push([styleText$1("red", line_number(i, false)), indent(lines[i - 1])].join(""));
for (let y = chunk.end_line + 1; y < Math.min(chunk.end_line + NUM_TRAILING_LINES, lines.length); y++) output.push([styleText$1("dim", line_number(y)), styleText$1("dim", indent(lines[y - 1]))].join(""));
output.push();
}
}
}
return output;
}
function print(report, params) {
let logger = report.report.ok ? console.log : console.error;
for (let line of print_lines(report, params, {
styleText,
print_width: process.stdout.columns
})) logger(line);
}
//#endregion
//#region src/cli/reporters/tap.ts
function version() {
console.log("TAP version 13");
}
function plan(total) {
console.log(`1..${total}`);
}
function ok(n, description) {
console.log(`ok ${n} ${description ? `- ${description}` : ""}`);
}
function not_ok(n, description) {
console.error(`not ok ${n} ${description ? `- ${description}` : ""}`);
}
function meta(data) {
console.log(" ---");
for (let key in data) console.log(` ${key}: ${data[key]}`);
console.log(" ...");
}
function print$1({ report, context }, params) {
let total_files = context.coverage.coverage_per_stylesheet.length;
let total_checks = total_files + 1;
let checks_added = 1;
if (report.min_file_line_coverage.expected !== void 0) {
total_checks++;
checks_added++;
}
version();
plan(total_checks);
if (report.min_line_coverage.ok) ok(1, "overall line coverage");
else not_ok(1, "overall line coverage");
if (report.min_file_line_coverage.expected !== void 0) {
if (report.min_file_line_coverage.ok) ok(2, "line coverage per file");
else {
not_ok(2, "line coverage per file");
meta({
expected_min_coverage: report.min_file_line_coverage.expected,
actual_min_coverage: report.min_file_line_coverage.actual
});
}
for (let i = 0; i < total_files; i++) {
let sheet = context.coverage.coverage_per_stylesheet[i];
let num = i + checks_added + 1;
if (sheet.line_coverage_ratio < report.min_file_line_coverage.expected) {
not_ok(num, sheet.url);
meta({
expected_coverage: report.min_file_line_coverage.expected,
actual_coverage: report.min_file_line_coverage.actual,
lines_covered: sheet.covered_lines,
total_lines: sheet.total_lines
});
} else ok(num, sheet.url);
}
}
}
//#endregion
//#region src/cli/reporters/json.ts
function prepare({ report, context }, params) {
context.coverage.coverage_per_stylesheet = context.coverage.coverage_per_stylesheet.filter((sheet) => {
if (params["show-uncovered"] === "violations" && report.min_file_line_coverage.expected !== void 0 && sheet.line_coverage_ratio < report.min_file_line_coverage.expected) return true;
if (params["show-uncovered"] === "all" && sheet.line_coverage_ratio < 1) return true;
return false;
});
return {
report,
context
};
}
function print$2({ report, context }, params) {
let logger = report.ok ? console.log : console.error;
let data = prepare({
context,
report
}, params);
logger(JSON.stringify(data));
}
//#endregion
//#region src/cli/help.ts
function help() {
return `
${styleText(["bold"], "USAGE")}
$ css-coverage --coverage-dir=<dir> --min-coverage=<number> [options]
${styleText("bold", "OPTIONS")}
Required:
--coverage-dir Where your Coverage JSON files are
--min-coverage Minimum overall CSS coverage [0-1]
Optional:
--min-file-coverage Minimal coverage per file [0-1]
--show-uncovered Which files to show when not meeting
the --min-file-line-coverage threshold
• violations [default] ${styleText("dim", "show under-threshold files")}
• all ${styleText("dim", "show partially covered files")}
• none ${styleText("dim", "do not show files")}
--reporter How to show the results
• pretty [default]
• tap
• json
${styleText("bold", "EXAMPLES")}
${styleText("dim", "# analyze all .json files in ./coverage; require 80% overall coverage")}
$ css-coverage --coverage-dir=./coverage --min-coverage=0.8
${styleText("dim", "# Require 50% coverage per file")}
$ css-coverage --coverage-dir=./coverage --min-coverage=0.8 --min-file-coverage=0.5
${styleText("dim", "Report JSON")}
$ css-coverage --coverage-dir=./coverage --min-coverage=0.8 --reporter=json
`.trim();
}
//#endregion
//#region src/cli/cli.ts
async function cli(cli_args) {
if (!cli_args || cli_args.length === 0 || cli_args.includes("--help") || cli_args.includes("-h")) return console.log(help());
let params = validate_arguments(parse_arguments(cli_args));
let coverage_data = await read(params["coverage-dir"]);
let report = program({
min_coverage: params["min-coverage"],
min_file_coverage: params["min-file-coverage"]
}, coverage_data);
if (report.report.ok === false) process.exitCode = 1;
if (params.reporter === "tap") return print$1(report, params);
if (params.reporter === "json") return print$2(report, params);
return print(report, params);
}
try {
await cli(process.argv.slice(2));
} catch (error) {
console.error(error);
process.exit(1);
}
//#endregion
export { };