diff --git a/.claude/tasks/0003-view-skeleton.md b/.claude/tasks/0003-view-skeleton.md index 3883114..b65f51a 100644 --- a/.claude/tasks/0003-view-skeleton.md +++ b/.claude/tasks/0003-view-skeleton.md @@ -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. 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). -They assert real observable behaviour against independent literals rather than restating the implementation. +The suite is organised by the three tiers ADR 0009 defines, each seam tested at exactly one tier. +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. -The scaffold's remaining `test.todo` placeholders (future block/inline types, annotations) are left intact for their owning tasks. +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. -### End-to-end coverage pulled forward - -A reduced end-to-end smoke was pulled forward so the vertical slice is validated the way a user runs it. +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`. `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. -`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. +The full `fixtures/basic.md` end-to-end remains task 0007's, once 0004's richer nodes make that fixture render losslessly. diff --git a/bunfig.toml b/bunfig.toml index 4c20355..dc9a260 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -4,5 +4,3 @@ coverageReporter = ["text", "lcov"] coverageDir = "coverage" coverageSkipTestFiles = true -# test-support.ts is test scaffolding, not product code; keep it out of the numbers. -coveragePathIgnorePatterns = ["**/test-support.ts"] diff --git a/packages/bin/src/index.ts b/packages/bin/src/index.ts index 4f29454..e53468e 100644 --- a/packages/bin/src/index.ts +++ b/packages/bin/src/index.ts @@ -20,4 +20,6 @@ program ); }); -program.parse(); +if (import.meta.main) { + program.parse(); +} diff --git a/packages/bin/src/index_test.ts b/packages/bin/src/index_test.ts new file mode 100644 index 0000000..a3709d7 --- /dev/null +++ b/packages/bin/src/index_test.ts @@ -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"); + }); +}); diff --git a/packages/bin/src/render_test.ts b/packages/bin/src/render_test.ts new file mode 100644 index 0000000..cf1823c --- /dev/null +++ b/packages/bin/src/render_test.ts @@ -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`); + } + }); +}); diff --git a/packages/bin/src/view_test.ts b/packages/bin/src/view_test.ts new file mode 100644 index 0000000..1c6ba25 --- /dev/null +++ b/packages/bin/src/view_test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/parse_test.ts b/packages/core/src/parse_test.ts new file mode 100644 index 0000000..767c196 --- /dev/null +++ b/packages/core/src/parse_test.ts @@ -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([]); + }); +});