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