refactor: merge view into index and rename test tiers (task 0003)
Fold the view command module (viewFile, the error type, the read adapter) into index.ts so the command functions sit beside the guarded CLI, and generalise formatViewError into formatError matching over a CliError union that new fallible commands extend. Align the test files with ADR 0009's tiers by name: - index_test.ts now holds the in-process integration tests of the command functions (formerly view_test.ts) - end_to_end_test.ts holds the black-box subprocess e2e (formerly index_test.ts)
This commit is contained in:
@@ -64,14 +64,14 @@ The frontmatter separator is a dimmed 40-character box-drawing rule.
|
|||||||
|
|
||||||
The suite is organised by the three tiers ADR 0009 defines, each seam tested at exactly one tier.
|
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.
|
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.
|
|
||||||
|
|
||||||
Unit tests cover the two pure seams.
|
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/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.
|
`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.
|
||||||
|
|
||||||
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 `view` command function and the CLI share `packages/bin/src/index.ts`: `viewFile`, `formatError`, and the `CliError` union are value-returning and tested in-process, while the thin `program.parse()` dispatch is guarded behind `import.meta.main` so importing the module for a test never runs the CLI.
|
||||||
|
Integration tests (`packages/bin/src/index_test.ts`) assert that 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 `formatError`'s message.
|
||||||
|
|
||||||
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`.
|
End-to-end tests (`packages/bin/src/end_to_end_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.
|
`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.
|
||||||
The full `fixtures/basic.md` end-to-end remains task 0007's, 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.
|
||||||
|
|||||||
65
packages/bin/src/end_to_end_test.ts
Normal file
65
packages/bin/src/end_to_end_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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,35 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { parse } from "@kitchen-md/core";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { formatViewError, viewFile } from "./view.ts";
|
import { err, ok, type Result } from "neverthrow";
|
||||||
|
import { render } from "./render.ts";
|
||||||
|
|
||||||
|
export type ViewError = { tag: "read-failed"; path: string; cause: string };
|
||||||
|
|
||||||
|
// The union of every error a command can surface to the boundary; new fallible commands add their variants here.
|
||||||
|
export type CliError = ViewError;
|
||||||
|
|
||||||
|
export function viewFile(path: string): Result<string, ViewError> {
|
||||||
|
return readFile(path).map((content) => render(parse(content)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatError(error: CliError): string {
|
||||||
|
switch (error.tag) {
|
||||||
|
case "read-failed":
|
||||||
|
return `cannot read '${error.path}': ${error.cause}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one place a throwing API is turned into a Result; nothing above this leaks exceptions.
|
||||||
|
function readFile(path: string): Result<string, ViewError> {
|
||||||
|
try {
|
||||||
|
return ok(readFileSync(path, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
const cause = error instanceof Error ? error.message : String(error);
|
||||||
|
return err({ tag: "read-failed", path, cause });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
@@ -14,7 +43,7 @@ program
|
|||||||
viewFile(file).match(
|
viewFile(file).match(
|
||||||
(output) => process.stdout.write(output),
|
(output) => process.stdout.write(output),
|
||||||
(error) => {
|
(error) => {
|
||||||
process.stderr.write(`kitchen: ${formatViewError(error)}\n`);
|
process.stderr.write(`kitchen: ${formatError(error)}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,65 +1,86 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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 { join } from "node:path";
|
||||||
|
import { formatError, type ViewError, viewFile } from "./index.ts";
|
||||||
|
|
||||||
const repoRoot = join(import.meta.dir, "..", "..", "..");
|
describe("viewFile", () => {
|
||||||
const entry = join(repoRoot, "packages", "bin", "src", "index.ts");
|
let dir: string | undefined;
|
||||||
|
|
||||||
async function runKitchen(args: string[]) {
|
afterEach(() => {
|
||||||
const proc = Bun.spawn(["bun", entry, ...args], {
|
if (dir) {
|
||||||
cwd: repoRoot,
|
rmSync(dir, { recursive: true, force: true });
|
||||||
stdout: "pipe",
|
dir = undefined;
|
||||||
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 writeRecipe = (name: string, content: string): string => {
|
||||||
const { stdout } = await runKitchen(["view", "fixtures/prose.md"]);
|
dir = mkdtempSync(join(tmpdir(), "kitchen-view-"));
|
||||||
const out = Bun.stripANSI(stdout);
|
const path = join(dir, name);
|
||||||
expect(out.indexOf("Method")).toBeLessThan(out.indexOf("Serving"));
|
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("ANSI styling is suppressed when stdout is not a TTY (piped)", async () => {
|
test("viewFile returns ok and passes frontmatter through to the rendered output", () => {
|
||||||
const { stdout } = await runKitchen(["view", "fixtures/prose.md"]);
|
const path = writeRecipe(
|
||||||
expect(stdout).not.toContain("\x1b");
|
"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("a nonexistent file prints a human-readable error to stderr and exits 1", async () => {
|
test("viewFile returns a read-failed error for a nonexistent path", () => {
|
||||||
const { exitCode, stdout, stderr } = await runKitchen(["view", "does/not/exist.md"]);
|
dir = mkdtempSync(join(tmpdir(), "kitchen-view-"));
|
||||||
expect(exitCode).toBe(1);
|
const missing = join(dir, "does-not-exist.md");
|
||||||
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 result = viewFile(missing);
|
||||||
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 () => {
|
expect(result.isErr()).toBe(true);
|
||||||
const { exitCode, stdout } = await runKitchen(["--help"]);
|
const e = result._unsafeUnwrapErr();
|
||||||
expect(exitCode).toBe(0);
|
expect(e.tag).toBe("read-failed");
|
||||||
expect(stdout).toContain("view");
|
expect(e.path).toBe(missing);
|
||||||
|
expect(typeof e.cause).toBe("string");
|
||||||
|
expect(e.cause.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatError", () => {
|
||||||
|
test("formatError 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(formatError(error)).toBe("cannot read '/nope/recipe.md': no such file");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { parse } from "@kitchen-md/core";
|
|
||||||
import { err, ok, type Result } from "neverthrow";
|
|
||||||
import { render } from "./render.ts";
|
|
||||||
|
|
||||||
export type ViewError = { tag: "read-failed"; path: string; cause: string };
|
|
||||||
|
|
||||||
export function viewFile(path: string): Result<string, ViewError> {
|
|
||||||
return readFile(path).map((content) => render(parse(content)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatViewError(error: ViewError): string {
|
|
||||||
switch (error.tag) {
|
|
||||||
case "read-failed":
|
|
||||||
return `cannot read '${error.path}': ${error.cause}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The one place a throwing API is turned into a Result; nothing above this leaks exceptions.
|
|
||||||
function readFile(path: string): Result<string, ViewError> {
|
|
||||||
try {
|
|
||||||
return ok(readFileSync(path, "utf8"));
|
|
||||||
} catch (error) {
|
|
||||||
const cause = error instanceof Error ? error.message : String(error);
|
|
||||||
return err({ tag: "read-failed", path, cause });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user