UNPKG

@projectwallace/css-code-coverage

Version:

Generate useful CSS Code Coverage report from browser-reported coverage

341 lines (324 loc) 12.7 kB
#!/usr/bin/env node 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 { };