test: rebuild the view-skeleton suite on ADR 0009 tiers (task 0003)

Guard the CLI entry's program.parse() behind import.meta.main so the
command module can be imported without running the CLI, then rebuild the
deleted suite across the three tiers ADR 0009 defines:

- unit: parse (core) and render (bin), asserted on return values
- integration: the view command function, asserted on its Result
- e2e: the kitchen binary as a subprocess (exit codes, streams, ANSI
  suppression, errors, --help)

Drop the stale test-support.ts coverage ignore and refresh the task's
implementation notes to describe the tiered suite.
This commit is contained in:
2026-07-28 22:41:59 -04:00
parent e16f3f957d
commit 02020309eb
7 changed files with 365 additions and 15 deletions

View File

@@ -20,4 +20,6 @@ program
);
});
program.parse();
if (import.meta.main) {
program.parse();
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
const repoRoot = join(import.meta.dir, "..", "..", "..");
const entry = join(repoRoot, "packages", "bin", "src", "index.ts");
async function runKitchen(args: string[]) {
const proc = Bun.spawn(["bun", entry, ...args], {
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
return { exitCode, stdout, stderr };
}
describe("kitchen CLI (e2e)", () => {
test("view renders a recipe file to stdout and exits 0", async () => {
const { exitCode, stdout, stderr } = await runKitchen(["view", "fixtures/prose.md"]);
const out = Bun.stripANSI(stdout);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(out).toContain("Buttered Toast");
expect(out).toContain("Method");
expect(out).toContain("Serving");
expect(out).toContain("Cut into triangles and serve at once.");
});
test("view preserves document order in the output", async () => {
const { stdout } = await runKitchen(["view", "fixtures/prose.md"]);
const out = Bun.stripANSI(stdout);
expect(out.indexOf("Method")).toBeLessThan(out.indexOf("Serving"));
});
test("ANSI styling is suppressed when stdout is not a TTY (piped)", async () => {
const { stdout } = await runKitchen(["view", "fixtures/prose.md"]);
expect(stdout).not.toContain("\x1b");
});
test("a nonexistent file prints a human-readable error to stderr and exits 1", async () => {
const { exitCode, stdout, stderr } = await runKitchen(["view", "does/not/exist.md"]);
expect(exitCode).toBe(1);
expect(stdout).toBe("");
expect(stderr).toContain("cannot read");
expect(stderr).toContain("does/not/exist.md");
expect(stderr.startsWith("kitchen:")).toBe(true);
});
test("a missing file argument prints usage to stderr and exits 1", async () => {
const { exitCode, stderr } = await runKitchen(["view"]);
expect(exitCode).toBe(1);
expect(stderr).not.toBe("");
expect(stderr.toLowerCase()).toContain("missing required argument");
});
test("--help lists the view command and exits 0", async () => {
const { exitCode, stdout } = await runKitchen(["--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("view");
});
});

View File

@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test";
import type { DocumentAST, HeadingBlock } from "@kitchen-md/core";
import { render } from "./render.ts";
describe("render", () => {
test("a heading renders its text followed by a single newline", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "heading", level: 2, children: [{ type: "text", value: "Method" }] }],
diagnostics: [],
};
expect(Bun.stripANSI(render(ast))).toBe("Method\n");
});
test("a paragraph renders its text followed by a blank line", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "paragraph", children: [{ type: "text", value: "Toast the bread." }] }],
diagnostics: [],
};
expect(Bun.stripANSI(render(ast))).toBe("Toast the bread.\n\n");
});
test("frontmatter renders as YAML before the body, followed by a separator rule", () => {
const ast: DocumentAST = {
frontmatter: { title: "Buttered Toast", servings: 2 },
blocks: [
{ type: "heading", level: 1, children: [{ type: "text", value: "Buttered Toast" }] },
],
diagnostics: [],
};
const stripped = Bun.stripANSI(render(ast));
expect(stripped).toContain("title: Buttered Toast");
expect(stripped).toContain("servings: 2");
expect(stripped).toContain("──────");
const titleIndex = stripped.indexOf("title: Buttered Toast");
const separatorIndex = stripped.indexOf("──────");
const bodyIndex = stripped.lastIndexOf("Buttered Toast");
expect(titleIndex).toBeLessThan(separatorIndex);
expect(separatorIndex).toBeLessThan(bodyIndex);
});
test("no frontmatter emits neither YAML nor a separator", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "paragraph", children: [{ type: "text", value: "Body only." }] }],
diagnostics: [],
};
const stripped = Bun.stripANSI(render(ast));
expect(stripped).toBe("Body only.\n\n");
expect(stripped).not.toContain("─");
});
test("blocks render in document order", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [
{ type: "heading", level: 1, children: [{ type: "text", value: "Title" }] },
{ type: "paragraph", children: [{ type: "text", value: "First para." }] },
{ type: "heading", level: 2, children: [{ type: "text", value: "Next" }] },
],
diagnostics: [],
};
const stripped = Bun.stripANSI(render(ast));
expect(stripped.indexOf("Title")).toBeLessThan(stripped.indexOf("First para."));
expect(stripped.indexOf("First para.")).toBeLessThan(stripped.indexOf("Next"));
});
test("a heading renders its text at every level 16", () => {
const cases: [HeadingBlock["level"], string][] = [
[1, "Level One"],
[2, "Level Two"],
[3, "Level Three"],
[4, "Level Four"],
[5, "Level Five"],
[6, "Level Six"],
];
for (const [level, title] of cases) {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "heading", level, children: [{ type: "text", value: title }] }],
diagnostics: [],
};
expect(Bun.stripANSI(render(ast))).toBe(`${title}\n`);
}
});
});

View File

@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatViewError, type ViewError, viewFile } from "./view.ts";
describe("viewFile", () => {
let dir: string | undefined;
afterEach(() => {
if (dir) {
rmSync(dir, { recursive: true, force: true });
dir = undefined;
}
});
const writeRecipe = (name: string, content: string): string => {
dir = mkdtempSync(join(tmpdir(), "kitchen-view-"));
const path = join(dir, name);
writeFileSync(path, content);
return path;
};
test("viewFile returns ok with the rendered document for a readable file", () => {
const path = writeRecipe(
"toast.md",
`# Buttered Toast
Toast the bread until golden.
`,
);
const result = viewFile(path);
expect(result.isOk()).toBe(true);
const rendered = Bun.stripANSI(result._unsafeUnwrap());
expect(rendered).toContain("Buttered Toast");
expect(rendered).toContain("Toast the bread until golden.");
});
test("viewFile returns ok and passes frontmatter through to the rendered output", () => {
const path = writeRecipe(
"toast.md",
`---
title: Buttered Toast
servings: 2
---
# Buttered Toast
`,
);
const result = viewFile(path);
expect(result.isOk()).toBe(true);
const rendered = Bun.stripANSI(result._unsafeUnwrap());
expect(rendered).toContain("title: Buttered Toast");
expect(rendered).toContain("servings: 2");
});
test("viewFile returns a read-failed error for a nonexistent path", () => {
dir = mkdtempSync(join(tmpdir(), "kitchen-view-"));
const missing = join(dir, "does-not-exist.md");
const result = viewFile(missing);
expect(result.isErr()).toBe(true);
const e = result._unsafeUnwrapErr();
expect(e.tag).toBe("read-failed");
expect(e.path).toBe(missing);
expect(typeof e.cause).toBe("string");
expect(e.cause.length).toBeGreaterThan(0);
});
});
describe("formatViewError", () => {
test("formatViewError renders a read-failed error as a human-readable line", () => {
const error: ViewError = {
tag: "read-failed",
path: "/nope/recipe.md",
cause: "no such file",
};
expect(formatViewError(error)).toBe("cannot read '/nope/recipe.md': no such file");
});
});