Cut the first vertical slice through both layers: a runnable `kitchen view <file>` that reads a Recipe File, parses it, and renders it styled to the terminal. @kitchen-md/core exposes a pure, total `parse(input): DocumentAST` built on a minimal remark pipeline (parse + frontmatter) with a translation layer to core's own AST types — frontmatter passthrough, HeadingBlock, ParagraphBlock, and TextNode. remark types never leak into the public API. @kitchen-md/bin's `view` subcommand (commander) reads the file and hands the DocumentAST to a pure `render(ast): string` (chalk, ANSI auto-suppressed off a TTY). Fallible file I/O is modelled as a neverthrow Result over a tagged-union CliError, matched at the boundary: stdout on success, stderr and exit 1 on failure. The command functions sit beside the import.meta.main-guarded CLI entry so they are testable in-process. Tests follow ADR 0009's tiers — unit (parse, render), integration (the view command's Result), and e2e (the binary as a subprocess) — with coverage, a path-scoped test-report generator, and a prose fixture rounding out the tooling. ADR 0008 records errors-as-values at the CLI boundary; ADR 0009 records the testing tiers.
279 lines
8.3 KiB
TypeScript
279 lines
8.3 KiB
TypeScript
// Runs the test suite and renders reports/test-report.md: a summary, a
|
|
// describe-grouped inventory, and a coverage table.
|
|
// Any positional arguments are forwarded to `bun test` as path filters; with
|
|
// none, the whole repo runs.
|
|
// The report is written even when tests fail, and the process exits with the
|
|
// test run's own status so CI can gate on it.
|
|
// Split into run -> parse -> render so a future CI job can reuse parse+render
|
|
// on artifacts it already produced.
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { XMLParser } from "fast-xml-parser";
|
|
|
|
const REPORTS_DIR = "reports";
|
|
const JUNIT_PATH = `${REPORTS_DIR}/junit.xml`;
|
|
const LCOV_PATH = "coverage/lcov.info";
|
|
const REPORT_PATH = `${REPORTS_DIR}/test-report.md`;
|
|
|
|
type TestStatus = "passed" | "failed" | "todo";
|
|
|
|
interface TestCase {
|
|
name: string;
|
|
status: TestStatus;
|
|
time: number;
|
|
message?: string;
|
|
}
|
|
|
|
interface Suite {
|
|
name: string;
|
|
suites: Suite[];
|
|
tests: TestCase[];
|
|
}
|
|
|
|
interface Junit {
|
|
totals: { tests: number; failures: number; skipped: number; time: number };
|
|
files: Suite[];
|
|
}
|
|
|
|
interface FileCoverage {
|
|
file: string;
|
|
linesFound: number;
|
|
linesHit: number;
|
|
funcsFound: number;
|
|
funcsHit: number;
|
|
uncovered: number[];
|
|
}
|
|
|
|
// --- run ---
|
|
|
|
function runTests(paths: string[]): number {
|
|
mkdirSync(REPORTS_DIR, { recursive: true });
|
|
const proc = Bun.spawnSync(
|
|
[
|
|
"bun",
|
|
"test",
|
|
...paths,
|
|
"--coverage",
|
|
"--coverage-reporter=lcov",
|
|
"--reporter=junit",
|
|
`--reporter-outfile=${JUNIT_PATH}`,
|
|
],
|
|
{ stdout: "inherit", stderr: "inherit" },
|
|
);
|
|
return proc.exitCode ?? 1;
|
|
}
|
|
|
|
// --- parse ---
|
|
|
|
const xml = new XMLParser({
|
|
ignoreAttributes: false,
|
|
attributeNamePrefix: "@_",
|
|
isArray: (name) => name === "testsuite" || name === "testcase",
|
|
});
|
|
|
|
function parseJunit(path: string): Junit {
|
|
const root = xml.parse(readFileSync(path, "utf8")).testsuites;
|
|
return {
|
|
totals: {
|
|
tests: Number(root["@_tests"] ?? 0),
|
|
failures: Number(root["@_failures"] ?? 0),
|
|
skipped: Number(root["@_skipped"] ?? 0),
|
|
time: Number(root["@_time"] ?? 0),
|
|
},
|
|
files: (root.testsuite ?? []).map(parseSuite),
|
|
};
|
|
}
|
|
|
|
// biome-ignore lint/suspicious/noExplicitAny: raw fast-xml-parser nodes are untyped
|
|
function parseSuite(node: any): Suite {
|
|
return {
|
|
name: node["@_name"] ?? "(unnamed)",
|
|
suites: (node.testsuite ?? []).map(parseSuite),
|
|
tests: (node.testcase ?? []).map(parseCase),
|
|
};
|
|
}
|
|
|
|
// biome-ignore lint/suspicious/noExplicitAny: raw fast-xml-parser nodes are untyped
|
|
function parseCase(node: any): TestCase {
|
|
const name = node["@_name"] ?? "(unnamed)";
|
|
const time = Number(node["@_time"] ?? 0);
|
|
if (node.failure !== undefined) {
|
|
const message = node.failure["@_message"] ?? node.failure["#text"] ?? "";
|
|
return { name, status: "failed", time, message: String(message) };
|
|
}
|
|
if (node.skipped !== undefined) {
|
|
return { name, status: "todo", time };
|
|
}
|
|
return { name, status: "passed", time };
|
|
}
|
|
|
|
function parseLcov(path: string): FileCoverage[] {
|
|
if (!existsSync(path)) {
|
|
return [];
|
|
}
|
|
const records: FileCoverage[] = [];
|
|
let current: FileCoverage | null = null;
|
|
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
if (line.startsWith("SF:")) {
|
|
current = {
|
|
file: line.slice(3).trim(),
|
|
linesFound: 0,
|
|
linesHit: 0,
|
|
funcsFound: 0,
|
|
funcsHit: 0,
|
|
uncovered: [],
|
|
};
|
|
} else if (!current) {
|
|
// Skip anything before the first source record.
|
|
} else if (line.startsWith("FNF:")) {
|
|
current.funcsFound = Number(line.slice(4));
|
|
} else if (line.startsWith("FNH:")) {
|
|
current.funcsHit = Number(line.slice(4));
|
|
} else if (line.startsWith("DA:")) {
|
|
const [lineNo, hits] = line.slice(3).split(",");
|
|
current.linesFound++;
|
|
if (Number(hits) > 0) {
|
|
current.linesHit++;
|
|
} else {
|
|
current.uncovered.push(Number(lineNo));
|
|
}
|
|
} else if (line.startsWith("end_of_record")) {
|
|
records.push(current);
|
|
current = null;
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
// --- render ---
|
|
|
|
const ICON: Record<TestStatus, string> = {
|
|
passed: "✅",
|
|
failed: "❌",
|
|
todo: "⏭️",
|
|
};
|
|
|
|
function pct(hit: number, found: number): string {
|
|
if (found === 0) {
|
|
return "—";
|
|
}
|
|
return `${((hit / found) * 100).toFixed(1)}% (${hit}/${found})`;
|
|
}
|
|
|
|
// Collapse consecutive line numbers into ranges, e.g. 33,34,...,40 -> "33-40".
|
|
function ranges(lines: number[]): string {
|
|
if (lines.length === 0) {
|
|
return "—";
|
|
}
|
|
const sorted = [...lines].sort((a, b) => a - b);
|
|
const out: string[] = [];
|
|
let start = sorted[0];
|
|
let prev = sorted[0];
|
|
for (const n of sorted.slice(1)) {
|
|
if (n === prev + 1) {
|
|
prev = n;
|
|
continue;
|
|
}
|
|
out.push(start === prev ? `${start}` : `${start}-${prev}`);
|
|
start = n;
|
|
prev = n;
|
|
}
|
|
out.push(start === prev ? `${start}` : `${start}-${prev}`);
|
|
return out.join(", ");
|
|
}
|
|
|
|
function renderSuite(suite: Suite, depth: number, lines: string[]): void {
|
|
const indent = " ".repeat(depth);
|
|
for (const child of suite.suites) {
|
|
lines.push(`${indent}- **${child.name}**`);
|
|
renderSuite(child, depth + 1, lines);
|
|
}
|
|
for (const test of suite.tests) {
|
|
const timing =
|
|
test.status === "passed" && test.time > 0 ? ` _(${(test.time * 1000).toFixed(0)}ms)_` : "";
|
|
lines.push(`${indent}- ${ICON[test.status]} ${test.name}${timing}`);
|
|
if (test.status === "failed" && test.message) {
|
|
lines.push(`${indent} - \`${test.message.replace(/`/g, "'").replace(/\n/g, " ")}\``);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderReport(junit: Junit, coverage: FileCoverage[], meta: string): string {
|
|
const { tests, failures, skipped, time } = junit.totals;
|
|
const passed = tests - failures - skipped;
|
|
const result = failures === 0 ? "✅ All passing" : `❌ ${failures} failing`;
|
|
|
|
const lines: string[] = [];
|
|
lines.push("# Test Report", "", `_${meta}_`, "");
|
|
|
|
lines.push("## Summary", "");
|
|
lines.push("| Metric | Value |", "| --- | --- |");
|
|
lines.push(`| Result | ${result} |`);
|
|
lines.push(`| Passed | ${passed} |`);
|
|
lines.push(`| Failed | ${failures} |`);
|
|
lines.push(`| Todo | ${skipped} |`);
|
|
lines.push(`| Total | ${tests} |`);
|
|
lines.push(`| Duration | ${time.toFixed(2)}s |`, "");
|
|
|
|
lines.push("## Test inventory", "");
|
|
for (const file of junit.files) {
|
|
lines.push(`### \`${file.name}\``, "");
|
|
renderSuite(file, 0, lines);
|
|
lines.push("");
|
|
}
|
|
|
|
lines.push("## Coverage", "");
|
|
if (coverage.length === 0) {
|
|
lines.push("_No coverage data found._", "");
|
|
} else {
|
|
lines.push("| File | Lines | Functions | Uncovered |", "| --- | --- | --- | --- |");
|
|
const total = { linesFound: 0, linesHit: 0, funcsFound: 0, funcsHit: 0 };
|
|
for (const file of coverage) {
|
|
total.linesFound += file.linesFound;
|
|
total.linesHit += file.linesHit;
|
|
total.funcsFound += file.funcsFound;
|
|
total.funcsHit += file.funcsHit;
|
|
lines.push(
|
|
`| \`${file.file}\` | ${pct(file.linesHit, file.linesFound)} | ${pct(file.funcsHit, file.funcsFound)} | ${ranges(file.uncovered)} |`,
|
|
);
|
|
}
|
|
lines.push(
|
|
`| **Total** | **${pct(total.linesHit, total.linesFound)}** | **${pct(total.funcsHit, total.funcsFound)}** | |`,
|
|
"",
|
|
);
|
|
lines.push(
|
|
"> Coverage instruments in-process code only; code exercised solely through a subprocess (such as the CLI entry point) is not counted.",
|
|
"",
|
|
);
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// --- main ---
|
|
|
|
function gitSha(): string {
|
|
const proc = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"]);
|
|
return proc.exitCode === 0 ? proc.stdout.toString().trim() : "unknown";
|
|
}
|
|
|
|
const paths = process.argv.slice(2);
|
|
const exitCode = runTests(paths);
|
|
mkdirSync(REPORTS_DIR, { recursive: true });
|
|
|
|
const scope = paths.length > 0 ? paths.join(", ") : "whole repo";
|
|
const meta = `Generated ${new Date().toISOString()} · commit ${gitSha()} · ${scope}`;
|
|
|
|
if (!existsSync(JUNIT_PATH)) {
|
|
writeFileSync(
|
|
REPORT_PATH,
|
|
`# Test Report\n\n_${meta}_\n\nThe test run produced no JUnit output; it likely failed to start.\n`,
|
|
);
|
|
console.error(`Test run produced no JUnit output. Wrote a stub to ${REPORT_PATH}.`);
|
|
process.exit(exitCode || 1);
|
|
}
|
|
|
|
writeFileSync(REPORT_PATH, renderReport(parseJunit(JUNIT_PATH), parseLcov(LCOV_PATH), meta));
|
|
console.log(`Wrote ${REPORT_PATH}`);
|
|
process.exit(exitCode);
|