import { afterAll, describe, expect, test } from "bun:test"; import { spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { safeParseTale, type Tale } from "@taleseal/core"; /** * End-to-end for `taleseal tale`'s incremental (read + edit) subcommands. Same idiom as * cli.test.ts: the CLI is spawned as a subprocess (so process.exit codes are real) against a * throwaway HOME and a fake `node:http` server that answers the v1 tale routes with canned * shapes. TALESEAL_URL points at that server; TALESEAL_API_KEY is supplied via `extra`. The * fake server records every request so a test can assert the CLI's round trips (e.g. that a * base-less edit reads the outline first). */ const cliPath = fileURLToPath(new URL("./cli.ts", import.meta.url)); const root = mkdtempSync(join(tmpdir(), "taleseal-tale-cli-")); afterAll(() => rmSync(root, { recursive: true, force: true })); let caseId = 0; function freshHome(): string { caseId += 1; const home = join(root, `home-${caseId}`); mkdirSync(home, { recursive: true }); return home; } interface CliResult { status: number | null; stdout: string; stderr: string; } function runCli(args: string[], home: string, extra: Record = {}): Promise { const env: NodeJS.ProcessEnv = { ...process.env, HOME: home }; delete env.TALESEAL_API_KEY; delete env.TALESEAL_URL; delete env.XDG_CONFIG_HOME; Object.assign(env, extra); return new Promise((resolve, reject) => { const child = spawn(process.execPath, [cliPath, ...args], { env, stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, }); let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { stdout += chunk; }); child.stderr.on("data", (chunk: string) => { stderr += chunk; }); child.on("error", reject); child.on("close", (status) => resolve({ status, stdout, stderr })); }); } // ---------- the fake tale API ---------- const TALE_ID = "ltr1234abcd"; /** A full, schema-valid draft body — what GET /v1/tales/:id (pull) hands back. */ const DRAFT_BODY: Tale = { version: 1, title: "Quarterly research summary", standfirst: "What we found, and what to do next.", sender: { name: "Acme Research" }, stationery: "letter", blocks: [ { kind: "lead", text: "The migration cut p99 latency by a third." }, { kind: "prose", markdown: "Full detail follows in the sections below." }, ], }; /** The outline string the server echoes; the CLI prints it verbatim, so it is opaque here. */ const OUTLINE_TEXT = 'draft · 2 blocks · 0.2 KiB · "Quarterly research summary"\n' + " b001 lead the migration cut p99…\n" + " b002 prose full detail follows"; interface Canned { status: number; body: unknown; } interface Seen { method: string; url: string; body: string; /** the Authorization header as received — undefined when the request carried none */ auth: string | undefined; } interface TaleRoutes { outline?: Canned; blocks?: Canned; pull?: Canned; ops?: Canned; anon?: Canned; } const defaults = { outline: (): Canned => ({ status: 200, body: { id: TALE_ID, draftSeq: 3, revision: 0, outline: OUTLINE_TEXT, lint: [] }, }), blocks: (): Canned => ({ status: 200, body: { id: TALE_ID, draftSeq: 3, blocks: [{ kind: "prose", markdown: "Full detail follows in the sections below.", id: "b001" }], missing: [], }, }), pull: (): Canned => ({ status: 200, body: { id: TALE_ID, body: DRAFT_BODY, draftSeq: 5, revision: 1 } }), /** What POST /v1/tales/anonymous answers: published at create, claim fields, keep deadline. */ anon: (): Canned => ({ status: 200, body: { id: TALE_ID, url: `https://taleseal.example/t/${TALE_ID}`, status: "published", claimUrl: "https://taleseal.example/claim/tc_9hXk2mQ7RfLpZw4TnB8eVs6yUdCa01Gt", claimExpiresAt: "2026-07-21T12:00:00.000Z", next: "Unclaimed pages are hard-deleted after 24 hours — open the claim link to keep this one.", }, }), applied: (): Canned => ({ status: 200, body: { id: TALE_ID, draftSeq: 4, revision: 0, touched: [], removed: ["b002"], actions: {}, outline: OUTLINE_TEXT, lint: [], }, }), }; /** * A fake server that routes the v1 tale reads and the ops PATCH. Reads answer their canned * shapes; the ops route answers whatever `routes.ops` says (default: 200 applied), so a test * drives applied / conflict / invalid by swapping that one response. */ async function startTaleServer(routes: TaleRoutes = {}): Promise<{ url: string; seen: Seen[]; close: () => void }> { const outline = routes.outline ?? defaults.outline(); const blocks = routes.blocks ?? defaults.blocks(); const pull = routes.pull ?? defaults.pull(); const ops = routes.ops ?? defaults.applied(); const anon = routes.anon ?? defaults.anon(); const seen: Seen[] = []; const server = createServer((req, res) => { let raw = ""; req.setEncoding("utf8"); req.on("data", (chunk: string) => { raw += chunk; }); req.on("end", () => { const method = req.method ?? ""; const url = req.url ?? ""; seen.push({ method, url, body: raw, auth: req.headers.authorization }); const send = (canned: Canned): void => { res.statusCode = canned.status; res.setHeader("content-type", "application/json"); res.end(JSON.stringify(canned.body)); }; if (method === "GET" && url.endsWith("/outline")) return send(outline); if (method === "GET" && url.includes("/blocks")) return send(blocks); if (method === "PATCH" && url.endsWith("/ops")) return send(ops); if (method === "POST" && url.endsWith("/v1/tales/anonymous")) return send(anon); if (method === "GET") return send(pull); // GET /v1/tales/:id res.statusCode = 500; res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ error: `unrouted ${method} ${url}` })); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; return { url, seen, close: () => server.close() }; } /** The env every networked tale call needs: the fake server's URL and any non-empty key. */ const withServer = (url: string): Record => ({ TALESEAL_URL: url, TALESEAL_API_KEY: "tk_test" }); const sawGet = (seen: Seen[], suffix: string): boolean => seen.some((s) => s.method === "GET" && s.url.endsWith(suffix)); /** A schema-valid tale file to draft — DRAFT_BODY doubles as the input shape. */ function writeTale(home: string): string { const path = join(home, "tale.json"); writeFileSync(path, JSON.stringify(DRAFT_BODY)); return path; } // ---------- reads ---------- describe("tale reads", () => { test("outline prints the server's outline text to stdout, exit 0", async () => { const server = await startTaleServer(); try { const res = await runCli(["outline", TALE_ID], freshHome(), withServer(server.url)); expect(res.status).toBe(0); expect(res.stdout).toContain(OUTLINE_TEXT); expect(sawGet(server.seen, `/v1/tales/${TALE_ID}/outline`)).toBe(true); } finally { server.close(); } }); test("get b001 prints the blocks JSON, exit 0", async () => { const server = await startTaleServer(); try { const res = await runCli(["get", TALE_ID, "b001"], freshHome(), withServer(server.url)); expect(res.status).toBe(0); // the CLI prints JSON.stringify(result, null, 2) verbatim — it must parse back to the shape served expect(JSON.parse(res.stdout)).toEqual(defaults.blocks().body); expect(server.seen.some((s) => s.method === "GET" && s.url.includes("/blocks?ids=b001"))).toBe(true); } finally { server.close(); } }); test("pull writes the body JSON to the file and prints a Pulled draft line", async () => { const server = await startTaleServer(); const home = freshHome(); const out = join(home, "tale.json"); try { const res = await runCli(["pull", TALE_ID, out], home, withServer(server.url)); expect(res.status).toBe(0); expect(res.stdout).toContain("Pulled draft (draftSeq 5)"); expect(res.stdout).toContain(out); const written: unknown = JSON.parse(readFileSync(out, "utf8")); expect(written).toEqual(DRAFT_BODY); const parsed = safeParseTale(written); expect(parsed.success).toBe(true); } finally { server.close(); } }); test("pull with no file prints the body JSON to stdout, exit 0", async () => { const server = await startTaleServer(); try { const res = await runCli(["pull", TALE_ID], freshHome(), withServer(server.url)); expect(res.status).toBe(0); expect(JSON.parse(res.stdout)).toEqual(DRAFT_BODY); expect(safeParseTale(JSON.parse(res.stdout)).success).toBe(true); } finally { server.close(); } }); }); // ---------- ops (the atomic edit batch) ---------- describe("tale ops", () => { function writeOps(home: string): string { const path = join(home, "ops.json"); writeFileSync(path, JSON.stringify([{ op: "remove", ids: ["b002"] }])); return path; } test("200 applied → stdout contains `Applied at draftSeq`, exit 0", async () => { const server = await startTaleServer(); const home = freshHome(); const ops = writeOps(home); try { const res = await runCli(["ops", TALE_ID, ops, "--base", "0"], home, withServer(server.url)); expect(res.status).toBe(0); expect(res.stdout).toContain("Applied at draftSeq"); // --base 0 was given, so the CLI must NOT read the outline for a base first expect(sawGet(server.seen, "/outline")).toBe(false); expect(server.seen.some((s) => s.method === "PATCH" && s.url.endsWith("/ops"))).toBe(true); } finally { server.close(); } }); test("409 → stderr contains CONFLICT, exit 1", async () => { const server = await startTaleServer({ ops: { status: 409, body: { error: "revision conflict", draftSeq: 7, outline: OUTLINE_TEXT, lint: [] } }, }); const home = freshHome(); const ops = writeOps(home); try { const res = await runCli(["ops", TALE_ID, ops, "--base", "0"], home, withServer(server.url)); expect(res.status).toBe(1); expect(res.stderr).toContain("CONFLICT"); } finally { server.close(); } }); test("422 invalid → stderr contains NOT APPLIED, exit 1", async () => { const server = await startTaleServer({ ops: { status: 422, body: { error: "invalid ops", issues: [ { opIndex: 0, code: "unknown_block_id", blockId: "b002", message: "no block b002 in the tale", repair: "Use an id from the current outline, or drop this op.", }, ], }, }, }); const home = freshHome(); const ops = writeOps(home); try { const res = await runCli(["ops", TALE_ID, ops, "--base", "0"], home, withServer(server.url)); expect(res.status).toBe(1); expect(res.stderr).toContain("NOT APPLIED"); } finally { server.close(); } }); }); // ---------- edit sugar (insert / replace) ---------- describe("tale insert / replace", () => { test("insert blocks.json with no position flag → exit 1 with `say where` (server untouched)", async () => { const home = freshHome(); const blocks = join(home, "blocks.json"); writeFileSync(blocks, JSON.stringify({ kind: "prose", markdown: "hi" })); // no fake server on purpose: buildWhere refuses before any transport is resolved const res = await runCli(["insert", TALE_ID, blocks], home); expect(res.status).toBe(1); expect(res.stderr).toContain("say where"); }); test("replace b001 block.json with no --base → reads the outline, notes it on stderr, then PATCHes, exit 0", async () => { const server = await startTaleServer(); const home = freshHome(); const block = join(home, "block.json"); writeFileSync(block, JSON.stringify({ kind: "prose", markdown: "hi" })); try { const res = await runCli(["replace", TALE_ID, "b001", block], home, withServer(server.url)); expect(res.status).toBe(0); // auto-base: the CLI GETs the outline for the current draftSeq before applying expect(sawGet(server.seen, `/v1/tales/${TALE_ID}/outline`)).toBe(true); expect(res.stderr).toContain("Using the current draftSeq"); expect(server.seen.some((s) => s.method === "PATCH" && s.url.endsWith("/ops"))).toBe(true); expect(res.stdout).toContain("Applied at draftSeq"); } finally { server.close(); } }); }); // ---------- argument parsing ---------- describe("tale --base validation", () => { test("a non-integer --base is refused before anything is sent, exit 1", async () => { // no server: the guard lives in cli.ts arg parsing, before taleCommand runs const res = await runCli(["--base", "notanint", "outline", TALE_ID], freshHome()); expect(res.status).toBe(1); expect(res.stderr).toContain("--base must be a non-negative integer"); }); }); // ---------- anonymous drafting (no key anywhere, or --anon) ---------- describe("anonymous draft", () => { test("no key anywhere → POSTs /v1/tales/anonymous with NO Authorization header; prints the URL, deadline and claim link", async () => { const server = await startTaleServer(); const home = freshHome(); const talePath = writeTale(home); try { const res = await runCli(["draft", talePath, "--yes"], home, { TALESEAL_URL: server.url }); expect(res.status).toBe(0); const post = server.seen.find((s) => s.method === "POST"); expect(post?.url).toBe("/v1/tales/anonymous"); expect(post?.auth).toBeUndefined(); expect(res.stdout).toContain("Published live (anonymous):"); expect(res.stdout).toContain("24 hours"); expect(res.stdout).toContain("2026-07-21T12:00:00.000Z"); expect(res.stdout).toContain("/claim/tc_"); expect(res.stdout).toContain("KEEP"); } finally { server.close(); } }); test("--anon forces the anonymous route even when a key is available — and the key is never sent", async () => { const server = await startTaleServer(); const home = freshHome(); const talePath = writeTale(home); try { const res = await runCli(["draft", talePath, "--anon", "--yes"], home, withServer(server.url)); expect(res.status).toBe(0); const post = server.seen.find((s) => s.method === "POST"); expect(post?.url).toBe("/v1/tales/anonymous"); expect(post?.auth).toBeUndefined(); // tk_test is in the env, and must NOT travel } finally { server.close(); } }); test("draft --json (anonymous) passes the server result through whole: claimUrl, claimExpiresAt, next", async () => { const server = await startTaleServer(); const home = freshHome(); const talePath = writeTale(home); try { const res = await runCli(["draft", talePath, "--yes", "--json"], home, { TALESEAL_URL: server.url }); expect(res.status).toBe(0); const parsed = JSON.parse(res.stdout) as Record; expect(parsed.status).toBe("published"); expect(String(parsed.claimUrl)).toContain("/claim/tc_"); expect(parsed.claimExpiresAt).toBe("2026-07-21T12:00:00.000Z"); expect(typeof parsed.next).toBe("string"); } finally { server.close(); } }); test("an EMPTY TALESEAL_API_KEY refuses loudly and contacts no server — a misloaded secret is not consent to go anonymous", async () => { const server = await startTaleServer(); const home = freshHome(); const talePath = writeTale(home); try { const res = await runCli(["draft", talePath, "--yes"], home, { TALESEAL_URL: server.url, TALESEAL_API_KEY: "", }); expect(res.status).toBe(1); expect(res.stderr).toContain("set but empty"); expect(server.seen).toEqual([]); } finally { server.close(); } }); test("without --yes on a non-TTY the anonymous path still refuses at the gate — automation must opt in", async () => { const server = await startTaleServer(); const home = freshHome(); const talePath = writeTale(home); try { const res = await runCli(["draft", talePath], home, { TALESEAL_URL: server.url }); expect(res.status).toBe(1); expect(res.stderr).toContain("--yes"); expect(server.seen).toEqual([]); } finally { server.close(); } }); }); // ---------- the local no-key refusals (the first regression net for these) ---------- // // The fail() lives inline at each call site in tale.ts, not inside tOpts(): moving it // there once silently stripped all of these. Anonymous applies to draft/create ONLY. describe("no-key refusals survive the anonymous path", () => { test("publish refuses locally with no key, before any network", async () => { const res = await runCli(["publish", TALE_ID, "--yes"], freshHome()); expect(res.status).toBe(1); expect(res.stderr).toContain("no API key"); }); test("revise refuses locally with no key", async () => { const home = freshHome(); const talePath = writeTale(home); const res = await runCli(["revise", TALE_ID, talePath, "--yes"], home); expect(res.status).toBe(1); expect(res.stderr).toContain("no API key"); }); test("retract refuses locally with no key", async () => { const res = await runCli(["retract", TALE_ID], freshHome()); expect(res.status).toBe(1); expect(res.stderr).toContain("no API key"); }); test("an empty env key refuses publish exactly like an absent one", async () => { const res = await runCli(["publish", TALE_ID, "--yes"], freshHome(), { TALESEAL_API_KEY: " " }); expect(res.status).toBe(1); expect(res.stderr).toContain("no API key"); }); });