diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts index 90146d5..508ebf7 100644 --- a/packages/cli-json-cas/src/cli.test.ts +++ b/packages/cli-json-cas/src/cli.test.ts @@ -23,6 +23,25 @@ async function runCli( return { stdout, stderr, exitCode }; } +async function runCliWithStdin( + args: string[], + storePath: string, + stdin: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const finalArgs = ["bun", entrypoint, "--store", storePath, ...args]; + const proc = Bun.spawn(finalArgs, { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }); + proc.stdin.write(stdin); + proc.stdin.end(); + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + return { stdout, stderr, exitCode }; +} + describe("ucas command alias", () => { test("T1: ucas bin entry exists in package.json", async () => { const pkg = await Bun.file(pkgPath).json(); @@ -287,13 +306,14 @@ describe("ucas render command", () => { const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); try { await runCli(["init"], tmpStore); - const { exitCode, stdout } = await runCli( + const { exitCode, stderr } = await runCli( ["render", "ZZZZZZZZZZZZZ"], tmpStore, ); - // Missing hash renders as cas: reference - expect(exitCode).toBe(0); - expect(stdout).toContain("cas:ZZZZZZZZZZZZZ"); + // Missing hash should exit with error + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Node not found"); + expect(stderr).toContain("ZZZZZZZZZZZZZ"); } finally { rmSync(tmpStore, { recursive: true, force: true }); } @@ -596,4 +616,105 @@ describe("Suite 6: CLI Integration with Templates", () => { rmSync(tmpStore, { recursive: true, force: true }); } }); + + test("R8: render with non-existent hash exits with error", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + const { exitCode, stderr, stdout } = await runCli( + ["render", "AAAAAAAAAAAAA"], + tmpStore, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Node not found"); + expect(stderr).toContain("AAAAAAAAAAAAA"); + expect(stdout).toBe(""); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("R9: render with valid hash exits successfully", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Get @string type hash + const { stdout: typesJson } = await runCli(["types"], tmpStore); + const types = JSON.parse(typesJson); + const stringType = types["@string"]; + + // Create and store a simple string node + const nodeFile = join(tmpStore, "test.json"); + writeFileSync(nodeFile, JSON.stringify("hello world")); + const { stdout: nodeHash } = await runCli( + ["put", stringType, nodeFile], + tmpStore, + ); + + // Render the valid hash + const { exitCode, stdout, stderr } = await runCli( + ["render", nodeHash.trim()], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("hello world"); + expect(stderr).toBe(""); + expect(stdout).not.toContain("Error"); + expect(stdout).not.toContain("Node not found"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("R10: render --pipe with valid envelope succeeds", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Get @string type hash + const { stdout: typesJson } = await runCli(["types"], tmpStore); + const types = JSON.parse(typesJson); + const stringType = types["@string"]; + + // Create envelope and pipe to render + const envelope = JSON.stringify({ type: stringType, value: "test" }); + const { exitCode, stdout, stderr } = await runCliWithStdin( + ["render", "--pipe"], + tmpStore, + envelope, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("test"); + expect(stderr).toBe(""); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("R11: render --pipe with invalid type hash still renders", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Use invalid type hash in envelope + const envelope = JSON.stringify({ + type: "ZZZZZZZZZZZZZ", + value: "test", + }); + const { exitCode, stdout, stderr } = await runCliWithStdin( + ["render", "--pipe"], + tmpStore, + envelope, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("test"); + expect(stderr).toBe(""); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 51bdf4d..2f0bd20 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -486,6 +486,9 @@ async function cmdRender(args: string[]): Promise { process.stdout.write(output); } } catch (error) { + if (error instanceof CasNodeNotFoundError) { + die(`Error: Node not found: ${error.hash}`); + } if (error instanceof Error) { die(error.message); } diff --git a/packages/json-cas/src/render.test.ts b/packages/json-cas/src/render.test.ts index eaf629b..bc06d85 100644 --- a/packages/json-cas/src/render.test.ts +++ b/packages/json-cas/src/render.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test"; import { bootstrap } from "./bootstrap.js"; -import { render, renderDirect } from "./render.js"; +import { render, renderAsync, renderDirect } from "./render.js"; import { putSchema } from "./schema.js"; import { createMemoryStore } from "./store.js"; import type { Hash } from "./types.js"; +import { CasNodeNotFoundError } from "./variable-store.js"; describe("Suite 1: Basic Rendering (No Nesting)", () => { test("1.1 Render Simple Primitives", async () => { @@ -65,13 +66,14 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => { expect(output.trim()).toBe(`cas:${hash}`); }); - test("1.5 Render Non-existent Hash", () => { + test("1.5 Render Non-existent Hash Throws Error", () => { const store = createMemoryStore(); const fakeHash = "ZZZZZZZZZZZZZ" as Hash; - // Non-existent node renders as cas: reference - const output = render(store, fakeHash); - expect(output.trim()).toBe(`cas:${fakeHash}`); + // Non-existent root node should throw + expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError); + expect(() => render(store, fakeHash)).toThrow("Node not found"); + expect(() => render(store, fakeHash)).toThrow(fakeHash); }); }); @@ -1055,3 +1057,93 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => { expect(output).toContain("key: val"); }); }); + +describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => { + test("10.1 renderAsync() throws CasNodeNotFoundError for missing root hash", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const fakeHash = "AAAAAAAAAAAAA" as Hash; + + await expect(renderAsync(store, fakeHash)).rejects.toThrow( + CasNodeNotFoundError, + ); + await expect(renderAsync(store, fakeHash)).rejects.toThrow("Node not found"); + await expect(renderAsync(store, fakeHash)).rejects.toThrow(fakeHash); + }); + + test("10.2 render() throws CasNodeNotFoundError for missing root hash", () => { + const store = createMemoryStore(); + const fakeHash = "ZZZZZZZZZZZZZ" as Hash; + + expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError); + expect(() => render(store, fakeHash)).toThrow("Node not found"); + expect(() => render(store, fakeHash)).toThrow(fakeHash); + }); + + test("10.3 renderDirect() does NOT throw for non-existent type hash", () => { + const store = createMemoryStore(); + const fakeTypeHash = "0000000000000" as Hash; + const output = renderDirect(fakeTypeHash, { key: "value" }, store, null); + + expect(output).toContain("key: value"); + }); + + test("10.4 Missing nested node renders as cas: reference (no error)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + title: { type: "string" }, + child: { type: "string", format: "cas_ref" }, + }, + }); + + const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash; + const parentHash = await store.put(parentSchema, { + title: "root", + child: fakeChildHash, + }); + + const output = render(store, parentHash); + + expect(output).toContain("title: root"); + expect(output).toContain(`cas:${fakeChildHash}`); + }); + + test("10.5 Resolution below epsilon renders as cas: reference (no error)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + level: { type: "number" }, + next: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 3-level chain + let currentHash: Hash | null = null; + for (let i = 2; i >= 0; i--) { + currentHash = await store.put(nodeSchema, { + level: i, + next: currentHash, + }); + } + + const output = render(store, currentHash as Hash, { + resolution: 1.0, + decay: 0.1, + epsilon: 0.5, + }); + + // Level 0 should be expanded (resolution = 1.0 > 0.5) + expect(output).toContain("level: 0"); + // Level 1+ should be cas: references (0.1, 0.01 < 0.5) + expect(output).toContain("cas:"); + }); +}); diff --git a/packages/json-cas/src/render.ts b/packages/json-cas/src/render.ts index 85d163c..d935e74 100644 --- a/packages/json-cas/src/render.ts +++ b/packages/json-cas/src/render.ts @@ -1,6 +1,7 @@ import { renderWithTemplate } from "./liquid-render.js"; import { collectRefs, getSchema, putSchema, refs } from "./schema.js"; import type { Hash, Store } from "./types.js"; +import { CasNodeNotFoundError } from "./variable-store.js"; import type { VariableStore } from "./variable-store.js"; export type RenderOptions = { @@ -55,6 +56,11 @@ export function render( ): string { const { resolution, decay, epsilon } = validateAndExtractOptions(options); + // Check if root node exists + if (store.get(hash) === null) { + throw new CasNodeNotFoundError(hash); + } + const visited = new Set(); return renderNode(store, hash, resolution, decay, epsilon, visited); } @@ -70,6 +76,12 @@ export async function renderAsync( options?: RenderOptions, ): Promise { const { resolution, decay, epsilon } = validateAndExtractOptions(options); + + // Check if root node exists + if (store.get(hash) === null) { + throw new CasNodeNotFoundError(hash); + } + const varStore = options?.varStore; // If varStore provided, try template rendering first diff --git a/packages/json-cas/src/variable-store.ts b/packages/json-cas/src/variable-store.ts index 2f1808b..cd421e3 100644 --- a/packages/json-cas/src/variable-store.ts +++ b/packages/json-cas/src/variable-store.ts @@ -36,8 +36,11 @@ export class SchemaMismatchError extends Error { } export class CasNodeNotFoundError extends Error { - constructor(hash: string) { - super(`CAS node not found: ${hash}`); + constructor( + public readonly hash: string, + message?: string, + ) { + super(message ?? `Node not found: ${hash}`); this.name = "CasNodeNotFoundError"; } }