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:
@@ -60,20 +60,18 @@ Headings render distinct-by-level via chalk, tapering from bold at level 1 towar
|
|||||||
Per the cli-view spec, the specific colour and weight choices are visual decisions verified by inspection, not asserted in tests — the renderer tests assert ANSI-stripped text, spacing, and the presence/absence of styling, not particular colours.
|
Per the cli-view spec, the specific colour and weight choices are visual decisions verified by inspection, not asserted in tests — the renderer tests assert ANSI-stripped text, spacing, and the presence/absence of styling, not particular colours.
|
||||||
The frontmatter separator is a dimmed 40-character box-drawing rule.
|
The frontmatter separator is a dimmed 40-character box-drawing rule.
|
||||||
|
|
||||||
### Tests that are green on arrival
|
### Test suite structured on ADR 0009's tiers
|
||||||
|
|
||||||
A few required-coverage tests document behaviour that the minimal implementation already satisfies and so pass without a preceding red (frontmatter empty/absent, flat document order, and the level-distinctness/suppression renderer test).
|
The suite is organised by the three tiers ADR 0009 defines, each seam tested at exactly one tier.
|
||||||
They assert real observable behaviour against independent literals rather than restating the implementation.
|
Because task 0003's code was already built, these are characterization tests — green on arrival — asserting observable behaviour against independent literals rather than restating the implementation.
|
||||||
|
The CLI entry shell (`packages/bin/src/index.ts`) guards its `program.parse()` behind `import.meta.main`, so importing it never runs the CLI, and the boundary work it does is reachable only through the e2e tier.
|
||||||
|
|
||||||
### Review follow-up applied
|
Unit tests cover the two pure seams.
|
||||||
|
`packages/core/src/parse_test.ts` asserts `parse` through the package barrel: frontmatter passthrough for arbitrary, empty, and absent blocks, headings at every level 1–6, paragraphs with `TextNode` content, flat document order, and empty diagnostics.
|
||||||
|
`packages/bin/src/render_test.ts` asserts `render` on ANSI-stripped output: headings at every level, paragraph blank-line spacing, the frontmatter YAML with its separator, and their absence when frontmatter is empty.
|
||||||
|
|
||||||
The `runCli` and `stripAnsi` test helpers were extracted into `packages/bin/src/test-support.ts` to remove duplication the review flagged across the bin test files.
|
Integration tests (`packages/bin/src/view_test.ts`) assert the `view` command function's returned `Result` in-process: `ok` with rendered output for a readable file, frontmatter passthrough, a `read-failed` error for a missing path, and `formatViewError`'s message.
|
||||||
The scaffold's remaining `test.todo` placeholders (future block/inline types, annotations) are left intact for their owning tasks.
|
|
||||||
|
|
||||||
### End-to-end coverage pulled forward
|
End-to-end tests (`packages/bin/src/index_test.ts`) drive the `kitchen` binary as a subprocess, asserting exit codes, stream routing, document order, ANSI suppression on a pipe, the missing-file and missing-argument errors, and `--help`.
|
||||||
|
|
||||||
A reduced end-to-end smoke was pulled forward so the vertical slice is validated the way a user runs it.
|
|
||||||
`fixtures/prose.md` is a committed recipe using only the constructs this slice models — frontmatter, headings at levels 1–3, and plain-text paragraphs — so it renders losslessly today.
|
`fixtures/prose.md` is a committed recipe using only the constructs this slice models — frontmatter, headings at levels 1–3, and plain-text paragraphs — so it renders losslessly today.
|
||||||
`integration_test.ts` runs `kitchen view fixtures/prose.md` as a subprocess and asserts the ANSI-stripped output: exit 0, frontmatter YAML before the body, the separator, every heading and paragraph, and flat document order.
|
The full `fixtures/basic.md` end-to-end remains task 0007's, once 0004's richer nodes make that fixture render losslessly.
|
||||||
`smoke_test.ts` covers `kitchen --help` exiting 0 with the `view` command listed.
|
|
||||||
This is a subset of task 0007's smoke, which still owns the full `fixtures/basic.md` end-to-end once 0004's richer nodes make that fixture render losslessly.
|
|
||||||
|
|||||||
@@ -4,5 +4,3 @@
|
|||||||
coverageReporter = ["text", "lcov"]
|
coverageReporter = ["text", "lcov"]
|
||||||
coverageDir = "coverage"
|
coverageDir = "coverage"
|
||||||
coverageSkipTestFiles = true
|
coverageSkipTestFiles = true
|
||||||
# test-support.ts is test scaffolding, not product code; keep it out of the numbers.
|
|
||||||
coveragePathIgnorePatterns = ["**/test-support.ts"]
|
|
||||||
|
|||||||
@@ -20,4 +20,6 @@ program
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
program.parse();
|
program.parse();
|
||||||
|
}
|
||||||
|
|||||||
65
packages/bin/src/index_test.ts
Normal file
65
packages/bin/src/index_test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
99
packages/bin/src/render_test.ts
Normal file
99
packages/bin/src/render_test.ts
Normal 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 1–6", () => {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
86
packages/bin/src/view_test.ts
Normal file
86
packages/bin/src/view_test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
102
packages/core/src/parse_test.ts
Normal file
102
packages/core/src/parse_test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { parse } from "@kitchen-md/core";
|
||||||
|
|
||||||
|
describe("parse", () => {
|
||||||
|
test("frontmatter passthrough — arbitrary fields become a plain object", () => {
|
||||||
|
const input = `---
|
||||||
|
title: Buttered Toast
|
||||||
|
servings: 2
|
||||||
|
tags: [breakfast, simple]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Buttered Toast
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = parse(input);
|
||||||
|
|
||||||
|
expect(result.frontmatter).toEqual({
|
||||||
|
title: "Buttered Toast",
|
||||||
|
servings: 2,
|
||||||
|
tags: ["breakfast", "simple"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("frontmatter is an empty object when absent", () => {
|
||||||
|
const result = parse("# Just a heading");
|
||||||
|
|
||||||
|
expect(result.frontmatter).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("frontmatter is an empty object when the block is empty", () => {
|
||||||
|
const input = `---
|
||||||
|
---
|
||||||
|
|
||||||
|
# Heading
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = parse(input);
|
||||||
|
|
||||||
|
expect(result.frontmatter).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("headings are modelled at every level 1–6 with TextNode children", () => {
|
||||||
|
const cases: [string, 1 | 2 | 3 | 4 | 5 | 6, string][] = [
|
||||||
|
["# Level One", 1, "Level One"],
|
||||||
|
["## Level Two", 2, "Level Two"],
|
||||||
|
["### Level Three", 3, "Level Three"],
|
||||||
|
["#### Level Four", 4, "Level Four"],
|
||||||
|
["##### Level Five", 5, "Level Five"],
|
||||||
|
["###### Level Six", 6, "Level Six"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [markdown, level, text] of cases) {
|
||||||
|
const result = parse(markdown);
|
||||||
|
|
||||||
|
expect(result.blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
level,
|
||||||
|
children: [{ type: "text", value: text }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a paragraph is a ParagraphBlock with TextNode content", () => {
|
||||||
|
const result = parse("Just some plain prose.");
|
||||||
|
|
||||||
|
expect(result.blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
children: [{ type: "text", value: "Just some plain prose." }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocks are flat and in document order — a heading is a sibling of the following paragraph", () => {
|
||||||
|
const input = `# Title
|
||||||
|
|
||||||
|
A paragraph under it.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = parse(input);
|
||||||
|
|
||||||
|
expect(result.blocks).toEqual([
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
level: 1,
|
||||||
|
children: [{ type: "text", value: "Title" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
children: [{ type: "text", value: "A paragraph under it." }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagnostics are empty in the normal case", () => {
|
||||||
|
const result = parse("# Ok");
|
||||||
|
|
||||||
|
expect(result.diagnostics).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user