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:
2026-07-28 23:15:41 -04:00
parent 02020309eb
commit a425a6b312
6 changed files with 172 additions and 170 deletions

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

@@ -1,6 +1,35 @@
#!/usr/bin/env bun
import { readFileSync } from "node:fs";
import { parse } from "@kitchen-md/core";
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();
@@ -14,7 +43,7 @@ program
viewFile(file).match(
(output) => process.stdout.write(output),
(error) => {
process.stderr.write(`kitchen: ${formatViewError(error)}\n`);
process.stderr.write(`kitchen: ${formatError(error)}\n`);
process.exit(1);
},
);

View File

@@ -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 { formatError, type ViewError, viewFile } from "./index.ts";
const repoRoot = join(import.meta.dir, "..", "..", "..");
const entry = join(repoRoot, "packages", "bin", "src", "index.ts");
describe("viewFile", () => {
let dir: string | undefined;
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.");
afterEach(() => {
if (dir) {
rmSync(dir, { recursive: true, force: true });
dir = undefined;
}
});
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"));
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("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("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("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("viewFile returns a read-failed error for a nonexistent path", () => {
dir = mkdtempSync(join(tmpdir(), "kitchen-view-"));
const missing = join(dir, "does-not-exist.md");
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");
});
const result = viewFile(missing);
test("--help lists the view command and exits 0", async () => {
const { exitCode, stdout } = await runKitchen(["--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("view");
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("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");
});
});

View 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 });
}
}

View File

@@ -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");
});
});