diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts deleted file mode 100644 index 9497564..0000000 --- a/packages/cli-json-cas/src/cli.test.ts +++ /dev/null @@ -1,1236 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import type { JSONSchema } from "@uncaged/json-cas"; -import { bootstrap, putSchema } from "@uncaged/json-cas"; -import { createFsStore, openStore as openFsStore } from "@uncaged/json-cas-fs"; - -const pkgPath = resolve(import.meta.dir, "../package.json"); -const entrypoint = resolve(import.meta.dir, "index.ts"); - -/** Extract the `value` field from a { type, value } envelope JSON string. */ -function envValue(json: string): unknown { - return (JSON.parse(json.trim()) as { value: unknown }).value; -} - -/** - * Register a schema directly via the library (CLI schema put was removed). - * Returns the type hash. - */ -async function putSchemaFile( - storePath: string, - schemaFilePath: string, -): Promise { - const store = await openFsStore(storePath); - const schema = JSON.parse( - readFileSync(schemaFilePath, "utf-8"), - ) as JSONSchema; - const hash = await putSchema(store, schema); - return hash; -} - -async function runCli( - args: string[], - storePath?: string, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const finalArgs = storePath - ? ["bun", entrypoint, "--store", storePath, ...args] - : ["bun", entrypoint, ...args]; - const proc = Bun.spawn(finalArgs, { - stdout: "pipe", - stderr: "pipe", - }); - 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 }; -} - -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(); - expect(pkg.bin.ucas).toBe("./src/index.ts"); - }); - - test("T2: json-cas bin entry is preserved in package.json", async () => { - const pkg = await Bun.file(pkgPath).json(); - expect(pkg.bin["json-cas"]).toBe("./src/index.ts"); - }); - - test("T3: ucas command is executable and shows help", async () => { - const proc = Bun.spawn(["bun", entrypoint, "--help"], { - stdout: "pipe", - stderr: "pipe", - }); - const exitCode = await proc.exited; - const stdout = await new Response(proc.stdout).text(); - expect(exitCode).toBe(0); - expect(stdout.length).toBeGreaterThan(0); - }); - - test("T4: both commands point to the same entrypoint", async () => { - const pkg = await Bun.file(pkgPath).json(); - expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]); - }); -}); - -// ---- @ Alias Resolution Tests ---- - -let testDir: string; -let storePath: string; -let cliPath: string; - -beforeEach(() => { - // Create unique temp directory for each test - testDir = join( - tmpdir(), - `json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - storePath = join(testDir, "store"); - cliPath = join(import.meta.dir, "index.ts"); - - mkdirSync(testDir, { recursive: true }); - mkdirSync(storePath, { recursive: true }); -}); - -afterEach(() => { - // Clean up test directory - try { - rmSync(testDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } -}); - -/** - * Run CLI command and return stdout, stderr, and exit code - */ -async function runCliAlias(...args: string[]): Promise<{ - stdout: string; - stderr: string; - exitCode: number; -}> { - const proc = Bun.spawn( - ["bun", "run", cliPath, "--store", storePath, ...args], - { - stdout: "pipe", - stderr: "pipe", - }, - ); - - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - - await proc.exited; - - return { - stdout: stdout.trim(), - stderr: stderr.trim(), - exitCode: proc.exitCode ?? 0, - }; -} - -describe("@ Alias Resolution - put", () => { - test("ucas put @string should resolve alias", async () => { - await runCliAlias("init"); - - const payloadFile = join(testDir, "payload.json"); - writeFileSync(payloadFile, JSON.stringify("hello world")); - - const { stdout, stderr, exitCode } = await runCliAlias( - "put", - "@string", - payloadFile, - ); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - // Should output an envelope whose value is a valid hash (13 chars) - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - }); - - test("ucas put @number should resolve alias", async () => { - await runCliAlias("init"); - - const payloadFile = join(testDir, "payload.json"); - writeFileSync(payloadFile, "42"); - - const { stdout, exitCode } = await runCliAlias( - "put", - "@number", - payloadFile, - ); - - expect(exitCode).toBe(0); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - }); - - test("ucas put @object should resolve alias", async () => { - await runCliAlias("init"); - - const payloadFile = join(testDir, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({ foo: "bar" })); - - const { stdout, exitCode } = await runCliAlias( - "put", - "@object", - payloadFile, - ); - - expect(exitCode).toBe(0); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - }); - - test("ucas put @invalid should fail", async () => { - await runCliAlias("init"); - - const payloadFile = join(testDir, "payload.json"); - writeFileSync(payloadFile, "{}"); - - const { stderr, exitCode } = await runCliAlias( - "put", - "@invalid", - payloadFile, - ); - - expect(exitCode).not.toBe(0); - expect(stderr.length).toBeGreaterThan(0); - }); - - test("ucas put @schema with nested type constraints should succeed", async () => { - await runCliAlias("init"); - - const schemaFile = join(testDir, "constrained-schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { - name: { type: "string", minLength: 1, maxLength: 50 }, - age: { type: "number", minimum: 0, maximum: 150 }, - tags: { - type: "array", - items: { type: "string" }, - minItems: 1, - uniqueItems: true, - }, - }, - required: ["name"], - }), - ); - - const { stdout, stderr, exitCode } = await runCliAlias( - "put", - "@schema", - schemaFile, - ); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - }); -}); - -describe("@ Alias Resolution - hash", () => { - test("ucas hash @string should compute hash without storing", async () => { - await runCliAlias("init"); - - const payloadFile = join(testDir, "payload.json"); - writeFileSync(payloadFile, JSON.stringify("test")); - - const { stdout, stderr, exitCode } = await runCliAlias( - "hash", - "@string", - payloadFile, - ); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - }); -}); - -describe("ucas render command", () => { - test("R1: render requires hash argument", async () => { - const { exitCode, stderr } = await runCli(["render"]); - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Usage"); - }); - - test("R2: render with missing hash shows error", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - const { exitCode, stderr } = await runCli( - ["render", "ZZZZZZZZZZZZZ"], - tmpStore, - ); - // 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 }); - } - }); - - test("R3: render with invalid numeric flag fails", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - const { exitCode, stderr } = await runCli( - ["render", "AAAAAAAAAAAAA", "--resolution", "invalid"], - tmpStore, - ); - expect(exitCode).not.toBe(0); - expect(stderr).toContain("valid number"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); -}); - -// ---- Issue #50: Schema Validation in put Command ---- - -describe("Issue #50: Schema Validation in put", () => { - describe("Test Group 1: Valid Data (Regression Tests)", () => { - test("T1.1: Valid data matching schema should be accepted", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Create schema with required name property - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - additionalProperties: false, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Create valid payload - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({ name: "test" })); - - const { stdout, stderr, exitCode } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - - // Verify node was stored - const hash = envValue(stdout) as string; - const { exitCode: hasExitCode } = await runCli(["has", hash], tmpStore); - expect(hasExitCode).toBe(0); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T1.2: Valid data with optional properties should be accepted", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Schema with optional property - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - additionalProperties: false, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Payload with only required properties - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({ name: "test" })); - - const { exitCode, stdout } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T1.3: Valid data with nested objects should be accepted", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Schema with nested structure - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { - name: { type: "string" }, - address: { - type: "object", - properties: { - street: { type: "string" }, - city: { type: "string" }, - }, - }, - }, - additionalProperties: false, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Payload with nested structure - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync( - payloadFile, - JSON.stringify({ - name: "test", - address: { street: "123 Main", city: "NYC" }, - }), - ); - - const { exitCode, stdout } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T1.4: Valid data using @ alias for type-hash should be accepted", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify("hello world")); - - const { exitCode, stdout } = await runCli( - ["put", "@string", payloadFile], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - }); - - describe("Test Group 2: Type Mismatches (New Validation)", () => { - test("T2.1: Wrong property type should be rejected", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Schema with name as string - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - additionalProperties: false, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Payload with name as number - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({ name: 123 })); - - const { exitCode, stdout, stderr } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(exitCode).toBe(1); - expect(stdout).toBe(""); - expect(stderr).toContain("Validation failed"); - expect(stderr).toContain(schemaHash.trim()); - expect(stderr).toContain(payloadFile); - - // Verify no node was stored - const { stdout: hasOutput } = await runCli( - ["has", "0000000000000"], - tmpStore, - ); - expect(envValue(hasOutput)).toBe(false); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T2.2: Missing required property should be rejected", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Schema with required name - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - additionalProperties: false, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Empty payload - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({})); - - const { exitCode, stderr } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Validation failed"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T2.3: Additional properties when disallowed should be rejected", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Schema with additionalProperties: false - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - additionalProperties: false, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Payload with extra property - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync( - payloadFile, - JSON.stringify({ name: "test", extra: "not allowed" }), - ); - - const { exitCode, stderr } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Validation failed"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T2.4: Wrong root type should be rejected", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Schema expecting array - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "array", - items: { type: "string" }, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Payload is an object - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({})); - - const { exitCode, stderr } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Validation failed"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T2.5: Nested type mismatch should be rejected", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Schema with nested object - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { - user: { - type: "object", - properties: { - age: { type: "number" }, - }, - }, - }, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Payload with wrong nested type - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync( - payloadFile, - JSON.stringify({ user: { age: "not a number" } }), - ); - - const { exitCode, stderr } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Validation failed"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - }); - - describe("Test Group 3: Schema Errors (Edge Cases)", () => { - test("T3.1: Non-existent type-hash should fail gracefully", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({ name: "test" })); - - const { exitCode, stderr } = await runCli( - ["put", "ZZZZZZZZZZZZZ", payloadFile], - tmpStore, - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Schema not found"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T3.3: Invalid @ alias should fail before validation", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({})); - - const { exitCode, stderr } = await runCli( - ["put", "@nonexistent", payloadFile], - tmpStore, - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Schema not found"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - }); - - describe("Test Group 4: Integration with Existing Features", () => { - test("T4.1: Hash command should not validate (dry-run consistency)", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Create schema - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - additionalProperties: false, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Invalid payload - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({ name: 123 })); - - // Hash command should succeed even with invalid data - const { exitCode, stdout } = await runCli( - ["hash", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T4.2: Validation respects cas_ref format in schemas", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Schema with cas_ref format - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { - ref: { type: "string", format: "cas_ref" }, - }, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Valid cas_ref - const validFile = join(tmpStore, "valid.json"); - writeFileSync(validFile, JSON.stringify({ ref: "0000000000000" })); - - const { exitCode: validExitCode } = await runCli( - ["put", schemaHash.trim(), validFile], - tmpStore, - ); - expect(validExitCode).toBe(0); - - // Invalid cas_ref (wrong length) - const invalidFile = join(tmpStore, "invalid.json"); - writeFileSync(invalidFile, JSON.stringify({ ref: "short" })); - - const { exitCode: invalidExitCode, stderr } = await runCli( - ["put", schemaHash.trim(), invalidFile], - tmpStore, - ); - expect(invalidExitCode).not.toBe(0); - expect(stderr).toContain("Validation failed"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T4.3: Schema self-validation still works", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Valid schema - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - }), - ); - - const hash = await putSchemaFile(tmpStore, schemaFile); - expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - }); - - describe("Test Group 5: Error Message Quality", () => { - test("T5.1: Error message should be helpful", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({ name: 123 })); - - const { stderr } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(stderr).toContain("Validation failed"); - expect(stderr).toContain(schemaHash.trim()); - expect(stderr).toContain(payloadFile); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("T5.2: Error should go to stderr, not stdout", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - const payloadFile = join(tmpStore, "payload.json"); - writeFileSync(payloadFile, JSON.stringify({ name: 123 })); - - const { stdout, stderr } = await runCli( - ["put", schemaHash.trim(), payloadFile], - tmpStore, - ); - - expect(stdout).toBe(""); - expect(stderr.length).toBeGreaterThan(0); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - }); -}); - -describe("Suite 6: CLI Integration with Templates", () => { - test("6.1 CLI with Template (Default Parameters)", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - // Initialize store - await runCli(["init"], tmpStore); - - // Create schema - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Create node - const nodeFile = join(tmpStore, "node.json"); - writeFileSync(nodeFile, JSON.stringify({ name: "Alice" })); - const { stdout: nodeOut } = await runCli( - ["put", schemaHash.trim(), nodeFile], - tmpStore, - ); - const nodeHash = envValue(nodeOut) as string; - - // Create template file (JSON-encoded string) - const templateFile = join(tmpStore, "template.json"); - writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!")); - const { stdout: tmplOut } = await runCli( - ["put", "@string", templateFile], - tmpStore, - ); - const tmplHash = envValue(tmplOut) as string; - - // Register template - await runCli( - ["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash], - tmpStore, - ); - - // Render with template - const { stdout: output, exitCode } = await runCli( - ["render", nodeHash], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(output).toBe("Hello Alice!"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("6.2 CLI with Template + Custom Decay", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - // Create schema with child ref - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { - value: { type: "string" }, - child: { - anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], - }, - }, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - // Create child node - const childFile = join(tmpStore, "child.json"); - writeFileSync(childFile, JSON.stringify({ value: "child", child: null })); - const { stdout: childOut } = await runCli( - ["put", schemaHash.trim(), childFile], - tmpStore, - ); - const childHash = envValue(childOut) as string; - - // Create parent node - const parentFile = join(tmpStore, "parent.json"); - writeFileSync( - parentFile, - JSON.stringify({ value: "parent", child: childHash }), - ); - const { stdout: parentOut } = await runCli( - ["put", schemaHash.trim(), parentFile], - tmpStore, - ); - const parentHash = envValue(parentOut) as string; - - // Create template showing resolution (JSON-encoded string) - const templateFile = join(tmpStore, "template.json"); - writeFileSync( - templateFile, - JSON.stringify("{{ payload.value }}(res={{ resolution }})"), - ); - const { stdout: tmplOut } = await runCli( - ["put", "@string", templateFile], - tmpStore, - ); - const tmplHash = envValue(tmplOut) as string; - - // Register template - await runCli( - ["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash], - tmpStore, - ); - - // Render with custom decay - const { stdout: output, exitCode } = await runCli( - ["render", parentHash, "--decay", "0.7"], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(output).toContain("parent(res=1)"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("6.3 CLI with Template + All Parameters", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - const nodeFile = join(tmpStore, "node.json"); - writeFileSync(nodeFile, JSON.stringify({ name: "Bob" })); - const { stdout: nodeOut } = await runCli( - ["put", schemaHash.trim(), nodeFile], - tmpStore, - ); - const nodeHash = envValue(nodeOut) as string; - - // Create template (JSON-encoded string) - const templateFile = join(tmpStore, "template.json"); - writeFileSync( - templateFile, - JSON.stringify("Greetings {{ payload.name }}!"), - ); - const { stdout: tmplOut } = await runCli( - ["put", "@string", templateFile], - tmpStore, - ); - const tmplHash = envValue(tmplOut) as string; - - await runCli( - ["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash], - tmpStore, - ); - - const { stdout: output, exitCode } = await runCli( - [ - "render", - nodeHash, - "--resolution", - "0.8", - "--decay", - "0.6", - "--epsilon", - "0.005", - ], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(output).toBe("Greetings Bob!"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("6.4 CLI with Non-templated Node (YAML Fallback)", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - const nodeFile = join(tmpStore, "node.json"); - writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" })); - const { stdout: nodeOut } = await runCli( - ["put", schemaHash.trim(), nodeFile], - tmpStore, - ); - const nodeHash = envValue(nodeOut) as string; - - // No template registered - should fall back to YAML - const { stdout: output, exitCode } = await runCli( - ["render", nodeHash], - tmpStore, - ); - - expect(exitCode).toBe(0); - expect(output).toContain("name:"); - expect(output).toContain("Charlie"); - } finally { - rmSync(tmpStore, { recursive: true, force: true }); - } - }); - - test("6.5 CLI Error: Invalid Decay Value", async () => { - const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); - try { - await runCli(["init"], tmpStore); - - const schemaFile = join(tmpStore, "schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { name: { type: "string" } }, - }), - ); - const schemaHash = await putSchemaFile(tmpStore, schemaFile); - - const nodeFile = join(tmpStore, "node.json"); - writeFileSync(nodeFile, JSON.stringify({ name: "Test" })); - const { stdout: nodeOut } = await runCli( - ["put", schemaHash.trim(), nodeFile], - tmpStore, - ); - const nodeHash = envValue(nodeOut) as string; - - const { exitCode, stderr } = await runCli( - ["render", nodeHash, "--decay", "1.5"], - tmpStore, - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("decay"); - } finally { - 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 via bootstrap - const store = createFsStore(tmpStore); - const types = await bootstrap(store); - 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: nodeOut } = await runCli( - ["put", stringType, nodeFile], - tmpStore, - ); - const nodeHash = envValue(nodeOut) as string; - - // Render the valid hash - const { exitCode, stdout, stderr } = await runCli( - ["render", nodeHash], - 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 via bootstrap - const store = createFsStore(tmpStore); - const types = await bootstrap(store); - 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 }); - } - }); -}); - -// Store validation tests removed - with auto-bootstrap in Phase 1a, -// stores are automatically created and bootstrapped when opened. -// Issue #55 validation is no longer applicable. diff --git a/packages/cli-json-cas/src/e2e.test.ts b/packages/cli-json-cas/src/e2e.test.ts deleted file mode 100644 index e4dbf81..0000000 --- a/packages/cli-json-cas/src/e2e.test.ts +++ /dev/null @@ -1,695 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -const entrypoint = resolve(import.meta.dir, "index.ts"); - -let tmpStore: string; -let varDbPath: string; - -// Shared hashes across phases -let typeHash: string; -let nodeHash: string; - -beforeAll(() => { - tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); - varDbPath = join(tmpStore, "variables.db"); -}); - -afterAll(() => { - rmSync(tmpStore, { recursive: true, force: true }); -}); - -async function runCli( - args: string[], -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const proc = Bun.spawn( - ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], - { stdout: "pipe", stderr: "pipe" }, - ); - const exitCode = await proc.exited; - const stdout = (await new Response(proc.stdout).text()).trim(); - const stderr = (await new Response(proc.stderr).text()).trim(); - return { stdout, stderr, exitCode }; -} - -/** - * Parse JSON and strip volatile fields (timestamp, created, updated) - * so snapshots are stable across runs. - */ -function stripVolatile(json: string): unknown { - const strip = (v: unknown): unknown => { - if (Array.isArray(v)) return v.map(strip); - if (v !== null && typeof v === "object") { - const out: Record = {}; - for (const [k, val] of Object.entries(v as Record)) { - if (k === "timestamp" || k === "created" || k === "updated") continue; - out[k] = strip(val); - } - return out; - } - return v; - }; - return strip(JSON.parse(json)); -} - -/** Extract the `value` field from a { type, value } envelope JSON string. */ -function envValue(json: string): unknown { - return (JSON.parse(json) as { value: unknown }).value; -} - -/** Run a CLI command feeding `stdin` to its standard input. */ -async function runCliWithStdin( - args: string[], - stdin: string, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const proc = Bun.spawn( - ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], - { stdin: "pipe", stdout: "pipe", stderr: "pipe" }, - ); - proc.stdin.write(stdin); - proc.stdin.end(); - const exitCode = await proc.exited; - const stdout = (await new Response(proc.stdout).text()).trim(); - const stderr = (await new Response(proc.stderr).text()).trim(); - return { stdout, stderr, exitCode }; -} - -// ---- Phase 1: CAS Core ---- - -describe("Phase 1: CAS Core", () => { - test("1.1 init + put with @object bootstraps store", async () => { - const schemaFile = join(tmpStore, "test-schema.json"); - writeFileSync( - schemaFile, - JSON.stringify({ - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - additionalProperties: false, - }), - ); - // Use putSchema via the library to register schema, since CLI schema put is removed - const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); - const { putSchema } = await import("@uncaged/json-cas"); - const store = await openFsStore(tmpStore); - const hash = await putSchema( - store, - JSON.parse(readFileSync(schemaFile, "utf-8")), - ); - typeHash = hash; - expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - }); - - test("1.5 put returns node hash", async () => { - const nodeFile = join(tmpStore, "test-node.json"); - writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 })); - const { stdout, exitCode } = await runCli(["put", typeHash, nodeFile]); - expect(exitCode).toBe(0); - expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - nodeHash = envValue(stdout) as string; - }); - - test("1.6 get returns node JSON (snapshot)", async () => { - const { stdout, exitCode } = await runCli(["get", nodeHash]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("1.7 has returns true for existing node", async () => { - const { stdout, exitCode } = await runCli(["has", nodeHash]); - expect(exitCode).toBe(0); - expect(envValue(stdout)).toBe(true); - }); - - test("1.8 has returns false for non-existing hash", async () => { - const { stdout, exitCode } = await runCli(["has", "AAAAAAAAAAAAA"]); - expect(exitCode).toBe(0); - expect(envValue(stdout)).toBe(false); - }); - - test("1.9 verify returns ok for valid node", async () => { - const { stdout, exitCode } = await runCli(["verify", nodeHash]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("1.10 refs lists direct references (snapshot)", async () => { - const { stdout, exitCode } = await runCli(["refs", nodeHash]); - expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); - }); - - test("1.11 walk shows traversal tree (snapshot)", async () => { - const { stdout, exitCode } = await runCli(["walk", nodeHash]); - expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); - }); - - test("1.12 hash dry-run returns same hash as put", async () => { - const nodeFile = join(tmpStore, "test-node.json"); - const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]); - expect(exitCode).toBe(0); - expect(envValue(stdout)).toBe(nodeHash); - }); - - test("1.13 list --type returns nodes of that type", async () => { - const { stdout, exitCode } = await runCli(["list", "--type", typeHash]); - expect(exitCode).toBe(0); - expect(envValue(stdout)).toContain(nodeHash); - }); -}); - -// ---- Phase 2: Schema Validation ---- - -describe("Phase 2: Schema Validation", () => { - test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => { - const badFile = join(tmpStore, "bad-node.json"); - writeFileSync(badFile, JSON.stringify({ name: 123 })); - const { stdout, stderr, exitCode } = await runCli([ - "put", - typeHash, - badFile, - ]); - expect(exitCode).not.toBe(0); - expect(stdout).toBe(""); - expect(stderr).toContain("Validation failed"); - expect(stderr).toContain(typeHash); - // Do NOT snapshot stderr — it embeds a machine-specific tmp path - }); - - test("2.2 verify on valid node returns ok (hash + schema)", async () => { - const { stdout, exitCode } = await runCli(["verify", nodeHash]); - expect(exitCode).toBe(0); - expect(envValue(stdout)).toBe("ok"); - }); - - test("2.3 put against non-existent schema hash fails", async () => { - const nodeFile = join(tmpStore, "test-node.json"); - const { stderr, exitCode } = await runCli([ - "put", - "AAAAAAAAAAAAA", - nodeFile, - ]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatchSnapshot(); - }); -}); - -// ---- Phase 3: Variable System ---- - -describe("Phase 3: Variable System", () => { - test("3.1 var set creates variable", async () => { - const { exitCode, stdout } = await runCli([ - "var", - "set", - "myapp/config", - nodeHash, - ]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("3.2 var get returns variable", async () => { - const { stdout, exitCode } = await runCli([ - "var", - "get", - "myapp/config", - "--schema", - typeHash, - ]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - expect(stdout).toContain(nodeHash); - }); - - test("3.3 var list shows all variables", async () => { - const { stdout, exitCode } = await runCli(["var", "list"]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - expect(stdout).toContain("myapp/config"); - }); - - test("3.4 var list prefix filters by prefix", async () => { - const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - expect(stdout).toContain("myapp/config"); - }); - - test("3.5 var set upsert updates existing variable", async () => { - const node2File = join(tmpStore, "node2.json"); - writeFileSync(node2File, JSON.stringify({ name: "Bob", age: 25 })); - const { stdout: node2Out } = await runCli(["put", typeHash, node2File]); - const node2Hash = envValue(node2Out) as string; - const { exitCode, stdout } = await runCli([ - "var", - "set", - "myapp/config", - node2Hash, - ]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - // Restore original value - await runCli(["var", "set", "myapp/config", nodeHash]); - }); - - test("3.6 var tag adds kv tag and label", async () => { - const { exitCode, stdout } = await runCli([ - "var", - "tag", - "myapp/config", - "--schema", - typeHash, - "env:prod", - "important", - ]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("3.7 var list --tag env:prod filters by kv tag", async () => { - const { stdout, exitCode } = await runCli([ - "var", - "list", - "--tag", - "env:prod", - ]); - expect(exitCode).toBe(0); - expect(stdout).toContain("myapp/config"); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("3.8 var list --tag important filters by label", async () => { - const { stdout, exitCode } = await runCli([ - "var", - "list", - "--tag", - "important", - ]); - expect(exitCode).toBe(0); - expect(stdout).toContain("myapp/config"); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("3.9 var tag remove deletes label", async () => { - const { exitCode, stdout } = await runCli([ - "var", - "tag", - "myapp/config", - "--schema", - typeHash, - ":important", - ]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - // Verify label is gone - const { stdout: listOut } = await runCli([ - "var", - "list", - "--tag", - "important", - ]); - expect(listOut).not.toContain("myapp/config"); - }); - - test("3.10 var delete removes variable", async () => { - const { exitCode, stdout } = await runCli([ - "var", - "delete", - "myapp/config", - ]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("3.11 var get deleted variable returns not found", async () => { - const { stderr, exitCode } = await runCli([ - "var", - "get", - "myapp/config", - "--schema", - typeHash, - ]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatchSnapshot(); - }); -}); - -// ---- Phase 4: Template System ---- - -describe("Phase 4: Template System", () => { - test("4.1 template set registers template", async () => { - const tmplFile = join(tmpStore, "test.liquid"); - writeFileSync(tmplFile, "Name: {{ payload.name }}, Age: {{ payload.age }}"); - const { exitCode, stdout } = await runCli([ - "template", - "set", - typeHash, - tmplFile, - ]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("4.2 template get returns template text", async () => { - const { stdout, exitCode } = await runCli(["template", "get", typeHash]); - expect(exitCode).toBe(0); - expect(envValue(stdout)).toBe( - "Name: {{ payload.name }}, Age: {{ payload.age }}", - ); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("4.3 template list shows registered templates", async () => { - const { stdout, exitCode } = await runCli(["template", "list"]); - expect(exitCode).toBe(0); - expect(stdout).toContain(typeHash); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("4.4 template delete removes template", async () => { - const { exitCode, stdout } = await runCli(["template", "delete", typeHash]); - expect(exitCode).toBe(0); - expect(stripVolatile(stdout)).toMatchSnapshot(); - }); - - test("4.5 template get deleted template returns not found", async () => { - const { stderr, exitCode } = await runCli(["template", "get", typeHash]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatchSnapshot(); - }); -}); - -// ---- Phase 5: Render ---- - -describe("Phase 5: Render", () => { - beforeAll(async () => { - const tmplFile = join(tmpStore, "render-template.liquid"); - writeFileSync(tmplFile, "Hello {{ payload.name }}!"); - await runCli(["template", "set", typeHash, tmplFile]); - }); - - test("5.1 render fills payload variables", async () => { - const { stdout, exitCode } = await runCli(["render", nodeHash]); - expect(exitCode).toBe(0); - expect(stdout).toBe("Hello Alice!"); - expect(stdout).toMatchSnapshot(); - }); - - test("5.2 render --resolution with different value", async () => { - const { stdout, exitCode } = await runCli([ - "render", - nodeHash, - "--resolution", - "0.5", - ]); - expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); - }); - - test("5.3 render non-existent hash fails with error", async () => { - const { stderr, exitCode } = await runCli(["render", "ZZZZZZZZZZZZZ"]); - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Node not found"); - expect(stderr).toContain("ZZZZZZZZZZZZZ"); - }); -}); - -// ---- Phase 6: GC ---- - -describe("Phase 6: GC", () => { - let gcNodeHash: string; - - beforeAll(async () => { - // Create a fresh node for GC tests (independent of shared nodeHash) - const gcNodeFile = join(tmpStore, "gc-node.json"); - writeFileSync(gcNodeFile, JSON.stringify({ name: "GcAlice", age: 30 })); - const { stdout } = await runCli(["put", typeHash, gcNodeFile]); - gcNodeHash = envValue(stdout) as string; - // Set a var referencing this node so it survives GC during Phase 6 - await runCli(["var", "set", "gc-test/ref", gcNodeHash]); - }); - - test("6.1 gc runs without error", async () => { - const { exitCode, stdout } = await runCli(["gc"]); - expect(exitCode).toBe(0); - // Assert structural shape only — exact counts depend on phase history - const result = envValue(stdout) as Record; - expect(typeof result.total).toBe("number"); - expect(typeof result.reachable).toBe("number"); - expect(typeof result.collected).toBe("number"); - expect(typeof result.scanned).toBe("number"); - expect(result.total as number).toBeGreaterThanOrEqual( - result.reachable as number, - ); - }); - - test("6.2 gc preserves node referenced by a var", async () => { - const { exitCode } = await runCli(["gc"]); - expect(exitCode).toBe(0); - const { stdout } = await runCli(["has", gcNodeHash]); - expect(envValue(stdout)).toBe(true); - }); - - test("6.3 gc reclaims orphan node", async () => { - const orphanFile = join(tmpStore, "orphan.json"); - writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 })); - const { stdout: orphanOut } = await runCli(["put", typeHash, orphanFile]); - const orphanHash = envValue(orphanOut) as string; - - const { stdout: beforeGc } = await runCli(["has", orphanHash]); - expect(envValue(beforeGc)).toBe(true); - - await runCli(["gc"]); - const { stdout: afterGc } = await runCli(["has", orphanHash]); - expect(envValue(afterGc)).toBe(false); - }); -}); - -// ---- Phase 7: Edge Cases ---- - -describe("Phase 7: Edge Cases", () => { - test("7.1 get non-existent hash errors gracefully", async () => { - const { stderr, exitCode } = await runCli(["get", "AAAAAAAAAAAAA"]); - expect(exitCode).not.toBe(0); - expect(stderr).toMatchSnapshot(); - }); - - test("7.2 put with non-existent file errors with ENOENT", async () => { - const { stderr, exitCode } = await runCli([ - "put", - typeHash, - "/nonexistent/file.json", - ]); - expect(exitCode).not.toBe(0); - expect(stderr).toContain("ENOENT"); - }); - - test("7.3 var set empty name errors", async () => { - const { stderr, exitCode } = await runCli(["var", "set", "", nodeHash]); - expect(exitCode).not.toBe(0); - expect(stderr.length).toBeGreaterThan(0); - expect(stderr).toMatchSnapshot(); - }); - - test("7.4 var set name with invalid chars errors", async () => { - const { stderr, exitCode } = await runCli([ - "var", - "set", - "invalid name!", - nodeHash, - ]); - expect(exitCode).not.toBe(0); - expect(stderr.length).toBeGreaterThan(0); - expect(stderr).toMatchSnapshot(); - }); - - test("7.5 no subcommand shows help text", async () => { - const { stdout, stderr, exitCode: _exitCode } = await runCli([]); - const combined = stdout + stderr; - expect(combined.length).toBeGreaterThan(0); - expect(combined).toMatchSnapshot(); - expect(combined.toLowerCase()).toContain("usage"); - }); - - test("7.6 --store path is a file errors", async () => { - const fileAsStore = join(tmpStore, "not-a-directory"); - writeFileSync(fileAsStore, "test"); - const proc = Bun.spawn( - [ - "bun", - entrypoint, - "--store", - fileAsStore, - "--var-db", - varDbPath, - "get", - "AAAAAAAAAAAAA", - ], - { stdout: "pipe", stderr: "pipe" }, - ); - const exitCode = await proc.exited; - const stderr = (await new Response(proc.stderr).text()).trim(); - expect(exitCode).not.toBe(0); - expect(stderr).toContain("not a directory"); - }); -}); - -// ---- Phase 8: Pipe Composition ---- -// -// Every JSON command emits a { type, value } envelope, so its stdout can be -// fed straight into `render --pipe` (which renders the envelope value) or into -// any downstream JSON consumer. These tests verify the envelopes compose -// end-to-end. - -describe("Phase 8: Pipe Composition", () => { - test("8.1 put | render -p expands the stored hash to its content", async () => { - const nodeFile = join(tmpStore, "pipe-node.json"); - writeFileSync(nodeFile, JSON.stringify({ name: "Bob", age: 42 })); - - const { stdout: putOut, exitCode: putExit } = await runCli([ - "put", - typeHash, - nodeFile, - ]); - expect(putExit).toBe(0); - - // The put envelope value is a cas_ref hash; render -p dereferences it and - // renders the stored node's payload. - const { stdout, exitCode } = await runCliWithStdin( - ["render", "--pipe"], - putOut, - ); - expect(exitCode).toBe(0); - expect(stdout).toContain("Bob"); - }); - - test("8.2 gc | render -p renders the gc stats", async () => { - const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]); - expect(gcExit).toBe(0); - - const { stdout, exitCode } = await runCliWithStdin( - ["render", "--pipe"], - gcOut, - ); - expect(exitCode).toBe(0); - // gc value is an object { total, reachable, collected, scanned } - expect(stdout).toContain("total:"); - }); - - test("8.3 list --type @schema emits a parseable envelope of hashes", async () => { - const { stdout, exitCode } = await runCli(["list", "--type", "@schema"]); - expect(exitCode).toBe(0); - - // Downstream consumers (jq, etc.) read the `value` array of hashes. - const value = envValue(stdout) as string[]; - expect(Array.isArray(value)).toBe(true); - for (const hash of value) { - expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - } - }); - - test("8.4 list --type @schema | render -p expands the schema list", async () => { - const { stdout: listOut } = await runCli(["list", "--type", "@schema"]); - // list result items are cas_ref hashes; render -p dereferences each one - // and renders the schema contents. - const { stdout, exitCode } = await runCliWithStdin( - ["render", "--pipe"], - listOut, - ); - expect(exitCode).toBe(0); - expect(stdout.length).toBeGreaterThan(0); - }); - - test("8.5 render uses a registered template", async () => { - // Register a template for the schema, then render a fresh node by hash. - const tmplFile = join(tmpStore, "pipe-render.liquid"); - writeFileSync(tmplFile, "Person: {{ payload.name }} ({{ payload.age }})"); - const { exitCode: setExit } = await runCli([ - "template", - "set", - typeHash, - tmplFile, - ]); - expect(setExit).toBe(0); - - const nodeFile = join(tmpStore, "pipe-render-node.json"); - writeFileSync(nodeFile, JSON.stringify({ name: "Carol", age: 25 })); - const { stdout: putOut } = await runCli(["put", typeHash, nodeFile]); - const freshHash = envValue(putOut) as string; - - const { stdout, exitCode } = await runCli(["render", freshHash]); - expect(exitCode).toBe(0); - expect(stdout).toBe("Person: Carol (25)"); - }); -}); - -// ---- Phase 9: Put/Hash Pipe Input ---- - -describe("Phase 9: Put/Hash Pipe Input", () => { - test("9.1 put -p reads JSON from stdin and stores node", async () => { - const payload = JSON.stringify({ name: "PipeAlice", age: 99 }); - const { stdout, exitCode } = await runCliWithStdin( - ["put", typeHash, "-p"], - payload, - ); - expect(exitCode).toBe(0); - const hash = envValue(stdout); - expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - - // Verify stored correctly - const { stdout: getOut } = await runCli(["get", hash as string]); - expect(getOut).toContain("PipeAlice"); - }); - - test("9.2 hash -p reads JSON from stdin and computes hash without storing", async () => { - const payload = JSON.stringify({ name: "PipeBob", age: 55 }); - const { stdout, exitCode } = await runCliWithStdin( - ["hash", typeHash, "-p"], - payload, - ); - expect(exitCode).toBe(0); - const hash = envValue(stdout); - expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); - - // Should NOT be stored - const { exitCode: hasExit, stdout: hasOut } = await runCli([ - "has", - hash as string, - ]); - expect(hasExit).toBe(0); - expect(envValue(hasOut)).toBe(false); - }); - - test("9.3 put -p with file arg errors", async () => { - const { stderr, exitCode } = await runCliWithStdin( - ["put", typeHash, "some-file.json", "-p"], - "{}", - ); - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Cannot use --pipe/-p with a file argument"); - }); - - test("9.4 put -p with empty stdin errors", async () => { - const { stderr, exitCode } = await runCliWithStdin( - ["put", typeHash, "-p"], - "", - ); - expect(exitCode).not.toBe(0); - expect(stderr).toContain("No input on stdin"); - }); - - test("9.5 put -p with invalid JSON errors", async () => { - const { stderr, exitCode } = await runCliWithStdin( - ["put", typeHash, "-p"], - "not json", - ); - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Invalid JSON on stdin"); - }); -}); diff --git a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap b/packages/cli-json-cas/tests/__snapshots__/edge-cases.test.ts.snap similarity index 89% rename from packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap rename to packages/cli-json-cas/tests/__snapshots__/edge-cases.test.ts.snap index 64b4b6f..e9c909f 100644 --- a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap +++ b/packages/cli-json-cas/tests/__snapshots__/edge-cases.test.ts.snap @@ -1,42 +1,52 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = ` -{ - "type": "CE40D09H75NFZ", - "value": { - "payload": { - "age": 30, - "name": "Alice", - }, - "type": "8WAZV39SD724T", - }, -} -`; +exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`; -exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = ` -{ - "type": "BZ31JDDWX2AWH", - "value": "ok", -} -`; +exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-cas var set [--tag ...]"`; -exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = ` -"{ - "type": "DG8SAB75PV9P7", - "value": [] -}" -`; +exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`; -exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = ` -"{ - "type": "EVHG7Q7FK83H0", - "value": [ - "6KZ930XYK2MHB" - ] -}" -`; +exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = ` +"Usage: json-cas [--store ] [--json] [args] -exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`; +All JSON commands emit a { type, value } envelope. The type is the hash of the +command's @output/* schema (shown in parentheses); pipe any envelope into +\`render -p\` to render its value (cas_ref hashes are expanded). + +Commands: + put Store node, print envelope (value=hash) (@output/put) + get Print node as envelope (@output/get) + has Print envelope (value=boolean) (@output/has) + verify Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify) + refs List direct cas_ref edges (@output/refs) + walk [--format tree] Recursive traversal (@output/walk) + hash Compute hash without storing (@output/hash) + render [options] Render node as text with resolution decay (raw output) + render --pipe/-p [options] Render { type, value } from stdin (raw output) + list --type List hashes for a type (value=string[]) (@output/list) + var set [--tag ...] Create/update a variable (@output/var-set) + var get --schema Get a variable by name + schema (@output/var-get) + var delete [--schema ] Delete variable(s) (@output/var-delete) + var list [prefix] [--schema ] [--tag ...] List variables (@output/var-list) + var tag --schema Modify tags/labels (@output/var-tag) + template set | --inline Set template for schema (@output/template-set) + template get Get template content (value=string) (@output/template-get) + template list List all templates (@output/template-list) + template delete Delete template for schema (@output/template-delete) + gc Run garbage collection (@output/gc) + +Flags: + --store Store directory (default: ~/.uncaged/json-cas) + --var-db Variable database path (default: /variables.db) + --json Compact JSON output + --schema Schema hash filter for var get/delete/tag/list + --tag Tag/label (can be repeated): key:value (tag), name (label), :name (delete) + --inline Inline text content for template set + --resolution Initial resolution for render (default: 1.0) + --decay Decay factor for render (default: 0.5) + --epsilon Cutoff threshold for render (default: 0.01) + --pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)" +`; exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = ` { @@ -235,55 +245,3 @@ exports[`Phase 4: Template System 4.4 template delete removes template 1`] = ` `; exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 8WAZV39SD724T"`; - -exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`; - -exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`; - -exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`; - -exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-cas var set [--tag ...]"`; - -exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`; - -exports[`Phase 7: Edge Cases 7.5 no subcommand shows help text 1`] = ` -"Usage: json-cas [--store ] [--json] [args] - -All JSON commands emit a { type, value } envelope. The type is the hash of the -command's @output/* schema (shown in parentheses); pipe any envelope into -\`render -p\` to render its value (cas_ref hashes are expanded). - -Commands: - put Store node, print envelope (value=hash) (@output/put) - get Print node as envelope (@output/get) - has Print envelope (value=boolean) (@output/has) - verify Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify) - refs List direct cas_ref edges (@output/refs) - walk [--format tree] Recursive traversal (@output/walk) - hash Compute hash without storing (@output/hash) - render [options] Render node as text with resolution decay (raw output) - render --pipe/-p [options] Render { type, value } from stdin (raw output) - list --type List hashes for a type (value=string[]) (@output/list) - var set [--tag ...] Create/update a variable (@output/var-set) - var get --schema Get a variable by name + schema (@output/var-get) - var delete [--schema ] Delete variable(s) (@output/var-delete) - var list [prefix] [--schema ] [--tag ...] List variables (@output/var-list) - var tag --schema Modify tags/labels (@output/var-tag) - template set | --inline Set template for schema (@output/template-set) - template get Get template content (value=string) (@output/template-get) - template list List all templates (@output/template-list) - template delete Delete template for schema (@output/template-delete) - gc Run garbage collection (@output/gc) - -Flags: - --store Store directory (default: ~/.uncaged/json-cas) - --var-db Variable database path (default: /variables.db) - --json Compact JSON output - --schema Schema hash filter for var get/delete/tag/list - --tag Tag/label (can be repeated): key:value (tag), name (label), :name (delete) - --inline Inline text content for template set - --resolution Initial resolution for render (default: 1.0) - --decay Decay factor for render (default: 0.5) - --epsilon Cutoff threshold for render (default: 0.01) - --pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)" -`; diff --git a/packages/cli-json-cas/tests/__snapshots__/put-get-has.test.ts.snap b/packages/cli-json-cas/tests/__snapshots__/put-get-has.test.ts.snap new file mode 100644 index 0000000..d8731c2 --- /dev/null +++ b/packages/cli-json-cas/tests/__snapshots__/put-get-has.test.ts.snap @@ -0,0 +1,14 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = ` +{ + "type": "CE40D09H75NFZ", + "value": { + "payload": { + "age": 30, + "name": "Alice", + }, + "type": "8WAZV39SD724T", + }, +} +`; diff --git a/packages/cli-json-cas/tests/__snapshots__/render.test.ts.snap b/packages/cli-json-cas/tests/__snapshots__/render.test.ts.snap new file mode 100644 index 0000000..4c7e970 --- /dev/null +++ b/packages/cli-json-cas/tests/__snapshots__/render.test.ts.snap @@ -0,0 +1,5 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`; + +exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`; diff --git a/packages/cli-json-cas/tests/__snapshots__/schema-validation.test.ts.snap b/packages/cli-json-cas/tests/__snapshots__/schema-validation.test.ts.snap new file mode 100644 index 0000000..3a357d5 --- /dev/null +++ b/packages/cli-json-cas/tests/__snapshots__/schema-validation.test.ts.snap @@ -0,0 +1,3 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`; diff --git a/packages/cli-json-cas/tests/__snapshots__/verify-refs-walk.test.ts.snap b/packages/cli-json-cas/tests/__snapshots__/verify-refs-walk.test.ts.snap new file mode 100644 index 0000000..b8435b7 --- /dev/null +++ b/packages/cli-json-cas/tests/__snapshots__/verify-refs-walk.test.ts.snap @@ -0,0 +1,24 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = ` +{ + "type": "BZ31JDDWX2AWH", + "value": "ok", +} +`; + +exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = ` +"{ + "type": "DG8SAB75PV9P7", + "value": [] +}" +`; + +exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = ` +"{ + "type": "EVHG7Q7FK83H0", + "value": [ + "6KZ930XYK2MHB" + ] +}" +`; diff --git a/packages/cli-json-cas/tests/alias.test.ts b/packages/cli-json-cas/tests/alias.test.ts new file mode 100644 index 0000000..9d9b41a --- /dev/null +++ b/packages/cli-json-cas/tests/alias.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// ---- @ Alias Resolution Tests ---- + +let testDir: string; +let storePath: string; +let cliPath: string; + +beforeEach(() => { + // Create unique temp directory for each test + testDir = join( + tmpdir(), + `json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + storePath = join(testDir, "store"); + cliPath = join(import.meta.dir, "../src/index.ts"); + + mkdirSync(testDir, { recursive: true }); + mkdirSync(storePath, { recursive: true }); +}); + +afterEach(() => { + // Clean up test directory + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +/** + * Run CLI command and return stdout, stderr, and exit code + */ +async function runCliAlias(...args: string[]): Promise<{ + stdout: string; + stderr: string; + exitCode: number; +}> { + const proc = Bun.spawn( + ["bun", "run", cliPath, "--store", storePath, ...args], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + await proc.exited; + + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: proc.exitCode ?? 0, + }; +} + +/** Extract the `value` field from a { type, value } envelope JSON string. */ +function envValue(json: string): unknown { + return (JSON.parse(json.trim()) as { value: unknown }).value; +} + +describe("@ Alias Resolution - put", () => { + test("ucas put @string should resolve alias", async () => { + await runCliAlias("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, JSON.stringify("hello world")); + + const { stdout, stderr, exitCode } = await runCliAlias( + "put", + "@string", + payloadFile, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + // Should output an envelope whose value is a valid hash (13 chars) + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("ucas put @number should resolve alias", async () => { + await runCliAlias("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, "42"); + + const { stdout, exitCode } = await runCliAlias( + "put", + "@number", + payloadFile, + ); + + expect(exitCode).toBe(0); + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("ucas put @object should resolve alias", async () => { + await runCliAlias("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ foo: "bar" })); + + const { stdout, exitCode } = await runCliAlias( + "put", + "@object", + payloadFile, + ); + + expect(exitCode).toBe(0); + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("ucas put @invalid should fail", async () => { + await runCliAlias("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, "{}"); + + const { stderr, exitCode } = await runCliAlias( + "put", + "@invalid", + payloadFile, + ); + + expect(exitCode).not.toBe(0); + expect(stderr.length).toBeGreaterThan(0); + }); + + test("ucas put @schema with nested type constraints should succeed", async () => { + await runCliAlias("init"); + + const schemaFile = join(testDir, "constrained-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 50 }, + age: { type: "number", minimum: 0, maximum: 150 }, + tags: { + type: "array", + items: { type: "string" }, + minItems: 1, + uniqueItems: true, + }, + }, + required: ["name"], + }), + ); + + const { stdout, stderr, exitCode } = await runCliAlias( + "put", + "@schema", + schemaFile, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); +}); + +describe("@ Alias Resolution - hash", () => { + test("ucas hash @string should compute hash without storing", async () => { + await runCliAlias("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, JSON.stringify("test")); + + const { stdout, stderr, exitCode } = await runCliAlias( + "hash", + "@string", + payloadFile, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); +}); diff --git a/packages/cli-json-cas/tests/edge-cases.test.ts b/packages/cli-json-cas/tests/edge-cases.test.ts new file mode 100644 index 0000000..3d0a7dc --- /dev/null +++ b/packages/cli-json-cas/tests/edge-cases.test.ts @@ -0,0 +1,427 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { envValue, stripVolatile } from "./helpers"; + +const entrypoint = resolve(import.meta.dir, "../src/index.ts"); +const pkgPath = resolve(import.meta.dir, "../package.json"); + +// --- ucas command alias tests (from cli.test.ts) --- + +describe("ucas command alias", () => { + test("T1: ucas bin entry exists in package.json", async () => { + const pkg = await Bun.file(pkgPath).json(); + expect(pkg.bin.ucas).toBe("./src/index.ts"); + }); + + test("T2: json-cas bin entry is preserved in package.json", async () => { + const pkg = await Bun.file(pkgPath).json(); + expect(pkg.bin["json-cas"]).toBe("./src/index.ts"); + }); + + test("T3: ucas command is executable and shows help", async () => { + const proc = Bun.spawn(["bun", entrypoint, "--help"], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + expect(exitCode).toBe(0); + expect(stdout.length).toBeGreaterThan(0); + }); + + test("T4: both commands point to the same entrypoint", async () => { + const pkg = await Bun.file(pkgPath).json(); + expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]); + }); +}); + +// --- e2e Phase 7: Edge Cases --- + +describe("Phase 7: Edge Cases", () => { + let tmpStore: string; + let varDbPath: string; + let typeHash: string; + let nodeHash: string; + + async function runCli( + args: string[], + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; + } + + beforeAll(async () => { + tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); + varDbPath = join(tmpStore, "variables.db"); + + const schemaFile = join(tmpStore, "test-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); + const { putSchema } = await import("@uncaged/json-cas"); + const store = await openFsStore(tmpStore); + typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8"))); + + const nodeFile = join(tmpStore, "test-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 })); + const { stdout } = await runCli(["put", typeHash, nodeFile]); + nodeHash = envValue(stdout) as string; + }); + + afterAll(() => { + rmSync(tmpStore, { recursive: true, force: true }); + }); + + test("7.1 get non-existent hash errors gracefully", async () => { + const { stderr, exitCode } = await runCli(["get", "AAAAAAAAAAAAA"]); + expect(exitCode).not.toBe(0); + expect(stderr).toMatchSnapshot(); + }); + + test("7.2 put with non-existent file errors with ENOENT", async () => { + const { stderr, exitCode } = await runCli([ + "put", + typeHash, + "/nonexistent/file.json", + ]); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("ENOENT"); + }); + + test("7.3 var set empty name errors", async () => { + const { stderr, exitCode } = await runCli(["var", "set", "", nodeHash]); + expect(exitCode).not.toBe(0); + expect(stderr.length).toBeGreaterThan(0); + expect(stderr).toMatchSnapshot(); + }); + + test("7.4 var set name with invalid chars errors", async () => { + const { stderr, exitCode } = await runCli([ + "var", + "set", + "invalid name!", + nodeHash, + ]); + expect(exitCode).not.toBe(0); + expect(stderr.length).toBeGreaterThan(0); + expect(stderr).toMatchSnapshot(); + }); + + test("7.5 no subcommand shows help text", async () => { + const { stdout, stderr, exitCode: _exitCode } = await runCli([]); + const combined = stdout + stderr; + expect(combined.length).toBeGreaterThan(0); + expect(combined).toMatchSnapshot(); + expect(combined.toLowerCase()).toContain("usage"); + }); + + test("7.6 --store path is a file errors", async () => { + const fileAsStore = join(tmpStore, "not-a-directory"); + writeFileSync(fileAsStore, "test"); + const proc = Bun.spawn( + [ + "bun", + entrypoint, + "--store", + fileAsStore, + "--var-db", + varDbPath, + "get", + "AAAAAAAAAAAAA", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stderr = (await new Response(proc.stderr).text()).trim(); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("not a directory"); + }); +}); + +// --- e2e Phase 3: Variable System (edge cases from e2e.test.ts) --- + +describe("Phase 3: Variable System", () => { + let tmpStore: string; + let varDbPath: string; + let typeHash: string; + let nodeHash: string; + + async function runCli( + args: string[], + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; + } + + beforeAll(async () => { + tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); + varDbPath = join(tmpStore, "variables.db"); + + const schemaFile = join(tmpStore, "test-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); + const { putSchema } = await import("@uncaged/json-cas"); + const store = await openFsStore(tmpStore); + typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8"))); + + const nodeFile = join(tmpStore, "test-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 })); + const { stdout } = await runCli(["put", typeHash, nodeFile]); + nodeHash = envValue(stdout) as string; + }); + + afterAll(() => { + rmSync(tmpStore, { recursive: true, force: true }); + }); + + test("3.1 var set creates variable", async () => { + const { exitCode, stdout } = await runCli([ + "var", + "set", + "myapp/config", + nodeHash, + ]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("3.2 var get returns variable", async () => { + const { stdout, exitCode } = await runCli([ + "var", + "get", + "myapp/config", + "--schema", + typeHash, + ]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + expect(stdout).toContain(nodeHash); + }); + + test("3.3 var list shows all variables", async () => { + const { stdout, exitCode } = await runCli(["var", "list"]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + expect(stdout).toContain("myapp/config"); + }); + + test("3.4 var list prefix filters by prefix", async () => { + const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + expect(stdout).toContain("myapp/config"); + }); + + test("3.5 var set upsert updates existing variable", async () => { + const node2File = join(tmpStore, "node2.json"); + writeFileSync(node2File, JSON.stringify({ name: "Bob", age: 25 })); + const { stdout: node2Out } = await runCli(["put", typeHash, node2File]); + const node2Hash = envValue(node2Out) as string; + const { exitCode, stdout } = await runCli([ + "var", + "set", + "myapp/config", + node2Hash, + ]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + // Restore original value + await runCli(["var", "set", "myapp/config", nodeHash]); + }); + + test("3.6 var tag adds kv tag and label", async () => { + const { exitCode, stdout } = await runCli([ + "var", + "tag", + "myapp/config", + "--schema", + typeHash, + "env:prod", + "important", + ]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("3.7 var list --tag env:prod filters by kv tag", async () => { + const { stdout, exitCode } = await runCli([ + "var", + "list", + "--tag", + "env:prod", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("myapp/config"); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("3.8 var list --tag important filters by label", async () => { + const { stdout, exitCode } = await runCli([ + "var", + "list", + "--tag", + "important", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("myapp/config"); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("3.9 var tag remove deletes label", async () => { + const { exitCode, stdout } = await runCli([ + "var", + "tag", + "myapp/config", + "--schema", + typeHash, + ":important", + ]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + // Verify label is gone + const { stdout: listOut } = await runCli([ + "var", + "list", + "--tag", + "important", + ]); + expect(listOut).not.toContain("myapp/config"); + }); + + test("3.10 var delete removes variable", async () => { + const { exitCode, stdout } = await runCli([ + "var", + "delete", + "myapp/config", + ]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("3.11 var get deleted variable returns not found", async () => { + const { stderr, exitCode } = await runCli([ + "var", + "get", + "myapp/config", + "--schema", + typeHash, + ]); + expect(exitCode).not.toBe(0); + expect(stderr).toMatchSnapshot(); + }); +}); + +// --- e2e Phase 4: Template System --- + +describe("Phase 4: Template System", () => { + let tmpStore: string; + let varDbPath: string; + let typeHash: string; + + async function runCli( + args: string[], + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; + } + + beforeAll(async () => { + tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); + varDbPath = join(tmpStore, "variables.db"); + + const schemaFile = join(tmpStore, "test-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); + const { putSchema } = await import("@uncaged/json-cas"); + const store = await openFsStore(tmpStore); + typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8"))); + }); + + afterAll(() => { + rmSync(tmpStore, { recursive: true, force: true }); + }); + + test("4.1 template set registers template", async () => { + const tmplFile = join(tmpStore, "test.liquid"); + writeFileSync(tmplFile, "Name: {{ payload.name }}, Age: {{ payload.age }}"); + const { exitCode, stdout } = await runCli([ + "template", + "set", + typeHash, + tmplFile, + ]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("4.2 template get returns template text", async () => { + const { stdout, exitCode } = await runCli(["template", "get", typeHash]); + expect(exitCode).toBe(0); + expect(envValue(stdout)).toBe( + "Name: {{ payload.name }}, Age: {{ payload.age }}", + ); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("4.3 template list shows registered templates", async () => { + const { stdout, exitCode } = await runCli(["template", "list"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(typeHash); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("4.4 template delete removes template", async () => { + const { exitCode, stdout } = await runCli(["template", "delete", typeHash]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("4.5 template get deleted template returns not found", async () => { + const { stderr, exitCode } = await runCli(["template", "get", typeHash]); + expect(exitCode).not.toBe(0); + expect(stderr).toMatchSnapshot(); + }); +}); diff --git a/packages/cli-json-cas/tests/gc.test.ts b/packages/cli-json-cas/tests/gc.test.ts new file mode 100644 index 0000000..18c1965 --- /dev/null +++ b/packages/cli-json-cas/tests/gc.test.ts @@ -0,0 +1,113 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { envValue } from "./helpers"; + +const entrypoint = resolve(import.meta.dir, "../src/index.ts"); + +let tmpStore: string; +let varDbPath: string; +let typeHash: string; +let nodeHash: string; + +beforeAll(async () => { + tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); + varDbPath = join(tmpStore, "variables.db"); + + const schemaFile = join(tmpStore, "test-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); + const { putSchema } = await import("@uncaged/json-cas"); + const store = await openFsStore(tmpStore); + typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8"))); + + const nodeFile = join(tmpStore, "test-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 })); + const { stdout } = await runCli(["put", typeHash, nodeFile]); + nodeHash = envValue(stdout) as string; + + // Set a var referencing the node so it survives GC + await runCli(["var", "set", "gc-test/ref", nodeHash]); +}); + +afterAll(() => { + rmSync(tmpStore, { recursive: true, force: true }); +}); + +async function runCli( + args: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; +} + +// ---- Phase 6: GC ---- + +describe("Phase 6: GC", () => { + test("6.1 gc runs without error", async () => { + const { exitCode, stdout } = await runCli(["gc"]); + expect(exitCode).toBe(0); + // Assert structural shape only — exact counts depend on phase history + const result = envValue(stdout) as Record; + expect(typeof result.total).toBe("number"); + expect(typeof result.reachable).toBe("number"); + expect(typeof result.collected).toBe("number"); + expect(typeof result.scanned).toBe("number"); + expect(result.total as number).toBeGreaterThanOrEqual( + result.reachable as number, + ); + }); + + test("6.2 gc | render -p renders the gc stats", async () => { + const { stdout: gcOut, exitCode: gcExit } = await runCli(["gc"]); + expect(gcExit).toBe(0); + + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, "render", "--pipe"], + { stdin: "pipe", stdout: "pipe", stderr: "pipe" }, + ); + proc.stdin.write(gcOut); + proc.stdin.end(); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + expect(exitCode).toBe(0); + // gc value is an object { total, reachable, collected, scanned } + expect(stdout).toContain("total:"); + }); + + test("6.3 gc preserves node referenced by a var", async () => { + const { exitCode } = await runCli(["gc"]); + expect(exitCode).toBe(0); + const { stdout } = await runCli(["has", nodeHash]); + expect(envValue(stdout)).toBe(true); + }); + + test("6.4 gc reclaims orphan node", async () => { + const orphanFile = join(tmpStore, "orphan.json"); + writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 })); + const { stdout: orphanOut } = await runCli(["put", typeHash, orphanFile]); + const orphanHash = envValue(orphanOut) as string; + + const { stdout: beforeGc } = await runCli(["has", orphanHash]); + expect(envValue(beforeGc)).toBe(true); + + await runCli(["gc"]); + const { stdout: afterGc } = await runCli(["has", orphanHash]); + expect(envValue(afterGc)).toBe(false); + }); +}); diff --git a/packages/cli-json-cas/tests/helpers.ts b/packages/cli-json-cas/tests/helpers.ts new file mode 100644 index 0000000..f707a5b --- /dev/null +++ b/packages/cli-json-cas/tests/helpers.ts @@ -0,0 +1,94 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { JSONSchema } from "@uncaged/json-cas"; +import { putSchema } from "@uncaged/json-cas"; +import { openStore as openFsStore } from "@uncaged/json-cas-fs"; + +export { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync }; +export { tmpdir }; +export { join, resolve }; + +export const entrypoint = resolve(import.meta.dir, "../src/index.ts"); +export const pkgPath = resolve(import.meta.dir, "../package.json"); + +/** Extract the `value` field from a { type, value } envelope JSON string. */ +export function envValue(json: string): unknown { + return (JSON.parse(json.trim()) as { value: unknown }).value; +} + +/** + * Register a schema directly via the library (CLI schema put was removed). + * Returns the type hash. + */ +export async function putSchemaFile( + storePath: string, + schemaFilePath: string, +): Promise { + const store = await openFsStore(storePath); + const schema = JSON.parse( + readFileSync(schemaFilePath, "utf-8"), + ) as JSONSchema; + const hash = await putSchema(store, schema); + return hash; +} + +/** + * Run CLI command. Accepts either a string[] or ...string[] (rest args). + * If first arg is an array, uses that as args. Otherwise treats all args as the command. + */ +export async function runCli( + args: string[], + storePath?: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const finalArgs = storePath + ? ["bun", entrypoint, "--store", storePath, ...args] + : ["bun", entrypoint, ...args]; + const proc = Bun.spawn(finalArgs, { + stdout: "pipe", + stderr: "pipe", + }); + 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 }; +} + +export 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 }; +} + +/** + * Parse JSON and strip volatile fields (timestamp, created, updated) + * so snapshots are stable across runs. + */ +export function stripVolatile(json: string): unknown { + const strip = (v: unknown): unknown => { + if (Array.isArray(v)) return v.map(strip); + if (v !== null && typeof v === "object") { + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + if (k === "timestamp" || k === "created" || k === "updated") continue; + out[k] = strip(val); + } + return out; + } + return v; + }; + return strip(JSON.parse(json)); +} diff --git a/packages/cli-json-cas/tests/pipe.test.ts b/packages/cli-json-cas/tests/pipe.test.ts new file mode 100644 index 0000000..d3e8909 --- /dev/null +++ b/packages/cli-json-cas/tests/pipe.test.ts @@ -0,0 +1,212 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { envValue, stripVolatile } from "./helpers"; + +const entrypoint = resolve(import.meta.dir, "../src/index.ts"); + +let tmpStore: string; +let varDbPath: string; +let typeHash: string; +let nodeHash: string; + +beforeAll(async () => { + tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); + varDbPath = join(tmpStore, "variables.db"); + + const schemaFile = join(tmpStore, "test-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); + const { putSchema } = await import("@uncaged/json-cas"); + const store = await openFsStore(tmpStore); + typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8"))); + + const nodeFile = join(tmpStore, "test-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 })); + const { stdout } = await runCli(["put", typeHash, nodeFile]); + nodeHash = envValue(stdout) as string; + + // Set up template for render tests + const tmplFile = join(tmpStore, "render-template.liquid"); + writeFileSync(tmplFile, "Hello {{ payload.name }}!"); + await runCli(["template", "set", typeHash, tmplFile]); +}); + +afterAll(() => { + rmSync(tmpStore, { recursive: true, force: true }); +}); + +async function runCli( + args: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; +} + +async function runCliWithStdin( + args: string[], + stdin: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdin: "pipe", stdout: "pipe", stderr: "pipe" }, + ); + proc.stdin.write(stdin); + proc.stdin.end(); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; +} + +// ---- Phase 8: Pipe Composition ---- + +describe("Phase 8: Pipe Composition", () => { + test("8.1 put | render -p expands the stored hash to its content", async () => { + const nodeFile = join(tmpStore, "pipe-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Bob", age: 42 })); + + const { stdout: putOut, exitCode: putExit } = await runCli([ + "put", + typeHash, + nodeFile, + ]); + expect(putExit).toBe(0); + + // The put envelope value is a cas_ref hash; render -p dereferences it and + // renders the stored node's payload. + const { stdout, exitCode } = await runCliWithStdin( + ["render", "--pipe"], + putOut, + ); + expect(exitCode).toBe(0); + expect(stdout).toContain("Bob"); + }); + + test("8.3 list --type @schema emits a parseable envelope of hashes", async () => { + const { stdout, exitCode } = await runCli(["list", "--type", "@schema"]); + expect(exitCode).toBe(0); + + // Downstream consumers (jq, etc.) read the `value` array of hashes. + const value = envValue(stdout) as string[]; + expect(Array.isArray(value)).toBe(true); + for (const hash of value) { + expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } + }); + + test("8.4 list --type @schema | render -p expands the schema list", async () => { + const { stdout: listOut } = await runCli(["list", "--type", "@schema"]); + // list result items are cas_ref hashes; render -p dereferences each one + // and renders the schema contents. + const { stdout, exitCode } = await runCliWithStdin( + ["render", "--pipe"], + listOut, + ); + expect(exitCode).toBe(0); + expect(stdout.length).toBeGreaterThan(0); + }); + + test("8.5 render uses a registered template", async () => { + // Register a template for the schema, then render a fresh node by hash. + const tmplFile = join(tmpStore, "pipe-render.liquid"); + writeFileSync(tmplFile, "Person: {{ payload.name }} ({{ payload.age }})"); + const { exitCode: setExit } = await runCli([ + "template", + "set", + typeHash, + tmplFile, + ]); + expect(setExit).toBe(0); + + const nodeFile = join(tmpStore, "pipe-render-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Carol", age: 25 })); + const { stdout: putOut } = await runCli(["put", typeHash, nodeFile]); + const freshHash = envValue(putOut) as string; + + const { stdout, exitCode } = await runCli(["render", freshHash]); + expect(exitCode).toBe(0); + expect(stdout).toBe("Person: Carol (25)"); + }); + +}); + +// ---- Phase 9: Put/Hash Pipe Input ---- + +describe("Phase 9: Put/Hash Pipe Input", () => { + test("9.1 put -p reads JSON from stdin and stores node", async () => { + const payload = JSON.stringify({ name: "PipeAlice", age: 99 }); + const { stdout, exitCode } = await runCliWithStdin( + ["put", typeHash, "-p"], + payload, + ); + expect(exitCode).toBe(0); + const hash = envValue(stdout); + expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + + // Verify stored correctly + const { stdout: getOut } = await runCli(["get", hash as string]); + expect(getOut).toContain("PipeAlice"); + }); + + test("9.2 hash -p reads JSON from stdin and computes hash without storing", async () => { + const payload = JSON.stringify({ name: "PipeBob", age: 55 }); + const { stdout, exitCode } = await runCliWithStdin( + ["hash", typeHash, "-p"], + payload, + ); + expect(exitCode).toBe(0); + const hash = envValue(stdout); + expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + + // Should NOT be stored + const { exitCode: hasExit, stdout: hasOut } = await runCli([ + "has", + hash as string, + ]); + expect(hasExit).toBe(0); + expect(envValue(hasOut)).toBe(false); + }); + + test("9.3 put -p with file arg errors", async () => { + const { stderr, exitCode } = await runCliWithStdin( + ["put", typeHash, "some-file.json", "-p"], + "{}", + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Cannot use --pipe/-p with a file argument"); + }); + + test("9.4 put -p with empty stdin errors", async () => { + const { stderr, exitCode } = await runCliWithStdin( + ["put", typeHash, "-p"], + "", + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("No input on stdin"); + }); + + test("9.5 put -p with invalid JSON errors", async () => { + const { stderr, exitCode } = await runCliWithStdin( + ["put", typeHash, "-p"], + "not json", + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Invalid JSON on stdin"); + }); +}); diff --git a/packages/cli-json-cas/tests/put-get-has.test.ts b/packages/cli-json-cas/tests/put-get-has.test.ts new file mode 100644 index 0000000..c2826ef --- /dev/null +++ b/packages/cli-json-cas/tests/put-get-has.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { envValue, stripVolatile } from "./helpers"; + +const entrypoint = resolve(import.meta.dir, "../src/index.ts"); + +let tmpStore: string; +let varDbPath: string; +let typeHash: string; +let nodeHash: string; + +beforeAll(async () => { + tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); + varDbPath = join(tmpStore, "variables.db"); + + const schemaFile = join(tmpStore, "test-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + additionalProperties: false, + }), + ); + const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); + const { putSchema } = await import("@uncaged/json-cas"); + const store = await openFsStore(tmpStore); + typeHash = await putSchema( + store, + JSON.parse(readFileSync(schemaFile, "utf-8")), + ); + + const nodeFile = join(tmpStore, "test-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 })); + const { stdout } = await runCli(["put", typeHash, nodeFile]); + nodeHash = envValue(stdout) as string; +}); + +afterAll(() => { + rmSync(tmpStore, { recursive: true, force: true }); +}); + +async function runCli( + args: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; +} + +describe("Phase 1: CAS Core", () => { + test("1.1 init + put with @object bootstraps store", async () => { + expect(typeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("1.5 put returns node hash", async () => { + expect(nodeHash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("1.6 get returns node JSON (snapshot)", async () => { + const { stdout, exitCode } = await runCli(["get", nodeHash]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("1.7 has returns true for existing node", async () => { + const { stdout, exitCode } = await runCli(["has", nodeHash]); + expect(exitCode).toBe(0); + expect(envValue(stdout)).toBe(true); + }); + + test("1.8 has returns false for non-existing hash", async () => { + const { stdout, exitCode } = await runCli(["has", "AAAAAAAAAAAAA"]); + expect(exitCode).toBe(0); + expect(envValue(stdout)).toBe(false); + }); + + test("1.12 hash dry-run returns same hash as put", async () => { + const nodeFile = join(tmpStore, "test-node.json"); + const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]); + expect(exitCode).toBe(0); + expect(envValue(stdout)).toBe(nodeHash); + }); + + test("1.13 list --type returns nodes of that type", async () => { + const { stdout, exitCode } = await runCli(["list", "--type", typeHash]); + expect(exitCode).toBe(0); + expect(envValue(stdout)).toContain(nodeHash); + }); +}); diff --git a/packages/cli-json-cas/tests/render.test.ts b/packages/cli-json-cas/tests/render.test.ts new file mode 100644 index 0000000..c0d4135 --- /dev/null +++ b/packages/cli-json-cas/tests/render.test.ts @@ -0,0 +1,518 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { bootstrap } from "@uncaged/json-cas"; +import { createFsStore } from "@uncaged/json-cas-fs"; +import { envValue, putSchemaFile, runCli, runCliWithStdin } from "./helpers"; + +const entrypoint = resolve(import.meta.dir, "../src/index.ts"); + +// --- Standalone render tests from cli.test.ts --- + +describe("ucas render command", () => { + test("R1: render requires hash argument", async () => { + const { exitCode, stderr } = await runCli(["render"]); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Usage"); + }); + + test("R2: render with missing hash shows error", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + const { exitCode, stderr } = await runCli( + ["render", "ZZZZZZZZZZZZZ"], + tmpStore, + ); + // 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 }); + } + }); + + test("R3: render with invalid numeric flag fails", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + const { exitCode, stderr } = await runCli( + ["render", "AAAAAAAAAAAAA", "--resolution", "invalid"], + tmpStore, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("valid number"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); +}); + +// --- e2e Phase 5: Render --- + +describe("Phase 5: Render", () => { + let tmpStore: string; + let varDbPath: string; + let typeHash: string; + let nodeHash: string; + + async function runCliE2e( + args: string[], + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; + } + + async function runCliE2eWithStdin( + args: string[], + stdin: string, + ): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdin: "pipe", stdout: "pipe", stderr: "pipe" }, + ); + proc.stdin.write(stdin); + proc.stdin.end(); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; + } + + beforeAll(async () => { + tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); + varDbPath = join(tmpStore, "variables.db"); + + const schemaFile = join(tmpStore, "test-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); + const { putSchema } = await import("@uncaged/json-cas"); + const store = await openFsStore(tmpStore); + typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8"))); + + const nodeFile = join(tmpStore, "test-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 })); + const { stdout } = await runCliE2e(["put", typeHash, nodeFile]); + nodeHash = envValue(stdout) as string; + + // Register template for render tests + const tmplFile = join(tmpStore, "render-template.liquid"); + writeFileSync(tmplFile, "Hello {{ payload.name }}!"); + await runCliE2e(["template", "set", typeHash, tmplFile]); + }); + + afterAll(() => { + rmSync(tmpStore, { recursive: true, force: true }); + }); + + test("5.1 render fills payload variables", async () => { + const { stdout, exitCode } = await runCliE2e(["render", nodeHash]); + expect(exitCode).toBe(0); + expect(stdout).toBe("Hello Alice!"); + expect(stdout).toMatchSnapshot(); + }); + + test("5.2 render --resolution with different value", async () => { + const { stdout, exitCode } = await runCliE2e([ + "render", + nodeHash, + "--resolution", + "0.5", + ]); + expect(exitCode).toBe(0); + expect(stdout).toMatchSnapshot(); + }); + + test("5.3 render non-existent hash fails with error", async () => { + const { stderr, exitCode } = await runCliE2e(["render", "ZZZZZZZZZZZZZ"]); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Node not found"); + expect(stderr).toContain("ZZZZZZZZZZZZZ"); + }); +}); + +// --- Suite 6: CLI Integration with Templates (from cli.test.ts) --- + +describe("Suite 6: CLI Integration with Templates", () => { + test("6.1 CLI with Template (Default Parameters)", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + // Initialize store + await runCli(["init"], tmpStore); + + // Create schema + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Create node + const nodeFile = join(tmpStore, "node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Alice" })); + const { stdout: nodeOut } = await runCli( + ["put", schemaHash.trim(), nodeFile], + tmpStore, + ); + const nodeHash = envValue(nodeOut) as string; + + // Create template file (JSON-encoded string) + const templateFile = join(tmpStore, "template.json"); + writeFileSync(templateFile, JSON.stringify("Hello {{ payload.name }}!")); + const { stdout: tmplOut } = await runCli( + ["put", "@string", templateFile], + tmpStore, + ); + const tmplHash = envValue(tmplOut) as string; + + // Register template + await runCli( + ["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash], + tmpStore, + ); + + // Render with template + const { stdout: output, exitCode } = await runCli( + ["render", nodeHash], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(output).toBe("Hello Alice!"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("6.2 CLI with Template + Custom Decay", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Create schema with child ref + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + value: { type: "string" }, + child: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Create child node + const childFile = join(tmpStore, "child.json"); + writeFileSync(childFile, JSON.stringify({ value: "child", child: null })); + const { stdout: childOut } = await runCli( + ["put", schemaHash.trim(), childFile], + tmpStore, + ); + const childHash = envValue(childOut) as string; + + // Create parent node + const parentFile = join(tmpStore, "parent.json"); + writeFileSync( + parentFile, + JSON.stringify({ value: "parent", child: childHash }), + ); + const { stdout: parentOut } = await runCli( + ["put", schemaHash.trim(), parentFile], + tmpStore, + ); + const parentHash = envValue(parentOut) as string; + + // Create template showing resolution (JSON-encoded string) + const templateFile = join(tmpStore, "template.json"); + writeFileSync( + templateFile, + JSON.stringify("{{ payload.value }}(res={{ resolution }})"), + ); + const { stdout: tmplOut } = await runCli( + ["put", "@string", templateFile], + tmpStore, + ); + const tmplHash = envValue(tmplOut) as string; + + // Register template + await runCli( + ["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash], + tmpStore, + ); + + // Render with custom decay + const { stdout: output, exitCode } = await runCli( + ["render", parentHash, "--decay", "0.7"], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(output).toContain("parent(res=1)"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("6.3 CLI with Template + All Parameters", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + const nodeFile = join(tmpStore, "node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Bob" })); + const { stdout: nodeOut } = await runCli( + ["put", schemaHash.trim(), nodeFile], + tmpStore, + ); + const nodeHash = envValue(nodeOut) as string; + + // Create template (JSON-encoded string) + const templateFile = join(tmpStore, "template.json"); + writeFileSync( + templateFile, + JSON.stringify("Greetings {{ payload.name }}!"), + ); + const { stdout: tmplOut } = await runCli( + ["put", "@string", templateFile], + tmpStore, + ); + const tmplHash = envValue(tmplOut) as string; + + await runCli( + ["var", "set", `@ucas/template/text/${schemaHash.trim()}`, tmplHash], + tmpStore, + ); + + const { stdout: output, exitCode } = await runCli( + [ + "render", + nodeHash, + "--resolution", + "0.8", + "--decay", + "0.6", + "--epsilon", + "0.005", + ], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(output).toBe("Greetings Bob!"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("6.4 CLI with Non-templated Node (YAML Fallback)", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + const nodeFile = join(tmpStore, "node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Charlie" })); + const { stdout: nodeOut } = await runCli( + ["put", schemaHash.trim(), nodeFile], + tmpStore, + ); + const nodeHash = envValue(nodeOut) as string; + + // No template registered - should fall back to YAML + const { stdout: output, exitCode } = await runCli( + ["render", nodeHash], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(output).toContain("name:"); + expect(output).toContain("Charlie"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("6.5 CLI Error: Invalid Decay Value", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + const nodeFile = join(tmpStore, "node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Test" })); + const { stdout: nodeOut } = await runCli( + ["put", schemaHash.trim(), nodeFile], + tmpStore, + ); + const nodeHash = envValue(nodeOut) as string; + + const { exitCode, stderr } = await runCli( + ["render", nodeHash, "--decay", "1.5"], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("decay"); + } finally { + 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 via bootstrap + const store = createFsStore(tmpStore); + const types = await bootstrap(store); + 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: nodeOut } = await runCli( + ["put", stringType, nodeFile], + tmpStore, + ); + const nodeHash = envValue(nodeOut) as string; + + // Render the valid hash + const { exitCode, stdout, stderr } = await runCli( + ["render", nodeHash], + 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 via bootstrap + const store = createFsStore(tmpStore); + const types = await bootstrap(store); + 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 }); + } + }); +}); + +// Store validation tests removed - with auto-bootstrap in Phase 1a, +// stores are automatically created and bootstrapped when opened. +// Issue #55 validation is no longer applicable. diff --git a/packages/cli-json-cas/tests/schema-validation.test.ts b/packages/cli-json-cas/tests/schema-validation.test.ts new file mode 100644 index 0000000..acf087c --- /dev/null +++ b/packages/cli-json-cas/tests/schema-validation.test.ts @@ -0,0 +1,626 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { envValue, putSchemaFile, runCli } from "./helpers"; + +// ---- Issue #50: Schema Validation in put Command ---- + +describe("Issue #50: Schema Validation in put", () => { + describe("Test Group 1: Valid Data (Regression Tests)", () => { + test("T1.1: Valid data matching schema should be accepted", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Create schema with required name property + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Create valid payload + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: "test" })); + + const { stdout, stderr, exitCode } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + + // Verify node was stored + const hash = envValue(stdout) as string; + const { exitCode: hasExitCode } = await runCli(["has", hash], tmpStore); + expect(hasExitCode).toBe(0); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T1.2: Valid data with optional properties should be accepted", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with optional property + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + additionalProperties: false, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Payload with only required properties + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: "test" })); + + const { exitCode, stdout } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T1.3: Valid data with nested objects should be accepted", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with nested structure + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + name: { type: "string" }, + address: { + type: "object", + properties: { + street: { type: "string" }, + city: { type: "string" }, + }, + }, + }, + additionalProperties: false, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Payload with nested structure + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync( + payloadFile, + JSON.stringify({ + name: "test", + address: { street: "123 Main", city: "NYC" }, + }), + ); + + const { exitCode, stdout } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T1.4: Valid data using @ alias for type-hash should be accepted", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify("hello world")); + + const { exitCode, stdout } = await runCli( + ["put", "@string", payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); + + describe("Test Group 2: Type Mismatches (New Validation)", () => { + test("T2.1: Wrong property type should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with name as string + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Payload with name as number + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: 123 })); + + const { exitCode, stdout, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("Validation failed"); + expect(stderr).toContain(schemaHash.trim()); + expect(stderr).toContain(payloadFile); + + // Verify no node was stored + const { stdout: hasOutput } = await runCli( + ["has", "0000000000000"], + tmpStore, + ); + expect(envValue(hasOutput)).toBe(false); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T2.2: Missing required property should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with required name + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Empty payload + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({})); + + const { exitCode, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T2.3: Additional properties when disallowed should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with additionalProperties: false + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + additionalProperties: false, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Payload with extra property + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync( + payloadFile, + JSON.stringify({ name: "test", extra: "not allowed" }), + ); + + const { exitCode, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T2.4: Wrong root type should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema expecting array + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "array", + items: { type: "string" }, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Payload is an object + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({})); + + const { exitCode, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T2.5: Nested type mismatch should be rejected", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with nested object + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + user: { + type: "object", + properties: { + age: { type: "number" }, + }, + }, + }, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Payload with wrong nested type + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync( + payloadFile, + JSON.stringify({ user: { age: "not a number" } }), + ); + + const { exitCode, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); + + describe("Test Group 3: Schema Errors (Edge Cases)", () => { + test("T3.1: Non-existent type-hash should fail gracefully", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: "test" })); + + const { exitCode, stderr } = await runCli( + ["put", "ZZZZZZZZZZZZZ", payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Schema not found"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T3.3: Invalid @ alias should fail before validation", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({})); + + const { exitCode, stderr } = await runCli( + ["put", "@nonexistent", payloadFile], + tmpStore, + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Schema not found"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); + + describe("Test Group 4: Integration with Existing Features", () => { + test("T4.1: Hash command should not validate (dry-run consistency)", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Create schema + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Invalid payload + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: 123 })); + + // Hash command should succeed even with invalid data + const { exitCode, stdout } = await runCli( + ["hash", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T4.2: Validation respects cas_ref format in schemas", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Schema with cas_ref format + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { + ref: { type: "string", format: "cas_ref" }, + }, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + // Valid cas_ref + const validFile = join(tmpStore, "valid.json"); + writeFileSync(validFile, JSON.stringify({ ref: "0000000000000" })); + + const { exitCode: validExitCode } = await runCli( + ["put", schemaHash.trim(), validFile], + tmpStore, + ); + expect(validExitCode).toBe(0); + + // Invalid cas_ref (wrong length) + const invalidFile = join(tmpStore, "invalid.json"); + writeFileSync(invalidFile, JSON.stringify({ ref: "short" })); + + const { exitCode: invalidExitCode, stderr } = await runCli( + ["put", schemaHash.trim(), invalidFile], + tmpStore, + ); + expect(invalidExitCode).not.toBe(0); + expect(stderr).toContain("Validation failed"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T4.3: Schema self-validation still works", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Valid schema + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + }), + ); + + const hash = await putSchemaFile(tmpStore, schemaFile); + expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); + + describe("Test Group 5: Error Message Quality", () => { + test("T5.1: Error message should be helpful", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: 123 })); + + const { stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(stderr).toContain("Validation failed"); + expect(stderr).toContain(schemaHash.trim()); + expect(stderr).toContain(payloadFile); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("T5.2: Error should go to stderr, not stdout", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + const schemaFile = join(tmpStore, "schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" } }, + }), + ); + const schemaHash = await putSchemaFile(tmpStore, schemaFile); + + const payloadFile = join(tmpStore, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ name: 123 })); + + const { stdout, stderr } = await runCli( + ["put", schemaHash.trim(), payloadFile], + tmpStore, + ); + + expect(stdout).toBe(""); + expect(stderr.length).toBeGreaterThan(0); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + }); +}); + +// e2e Phase 2 tests +describe("Phase 2: Schema Validation", () => { + let tmpStore: string; + let varDbPath: string; + let typeHash: string; + let nodeHash: string; + + const entrypoint = resolve(import.meta.dir, "../src/index.ts"); + + beforeAll(async () => { + tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); + varDbPath = join(tmpStore, "variables.db"); + + const schemaFile = join(tmpStore, "test-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); + const { putSchema } = await import("@uncaged/json-cas"); + const store = await openFsStore(tmpStore); + typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8"))); + + const nodeFile = join(tmpStore, "test-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 })); + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, "put", typeHash, nodeFile], + { stdout: "pipe", stderr: "pipe" }, + ); + await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + nodeHash = envValue(stdout) as string; + }); + + afterAll(() => { + rmSync(tmpStore, { recursive: true, force: true }); + }); + + test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => { + const badFile = join(tmpStore, "bad-node.json"); + writeFileSync(badFile, JSON.stringify({ name: 123 })); + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, "put", typeHash, badFile], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + expect(exitCode).not.toBe(0); + expect(stdout).toBe(""); + expect(stderr).toContain("Validation failed"); + expect(stderr).toContain(typeHash); + }); + + test("2.3 put against non-existent schema hash fails", async () => { + const nodeFile = join(tmpStore, "test-node.json"); + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, "put", "AAAAAAAAAAAAA", nodeFile], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stderr = (await new Response(proc.stderr).text()).trim(); + expect(exitCode).not.toBe(0); + expect(stderr).toMatchSnapshot(); + }); +}); diff --git a/packages/cli-json-cas/src/template.test.ts b/packages/cli-json-cas/tests/template.test.ts similarity index 99% rename from packages/cli-json-cas/src/template.test.ts rename to packages/cli-json-cas/tests/template.test.ts index 7f1db68..ae8d7b7 100644 --- a/packages/cli-json-cas/src/template.test.ts +++ b/packages/cli-json-cas/tests/template.test.ts @@ -21,7 +21,7 @@ beforeEach(() => { ); storePath = join(testDir, "store"); varDbPath = join(testDir, "variables.db"); - cliPath = join(import.meta.dir, "index.ts"); + cliPath = join(import.meta.dir, "../src/index.ts"); mkdirSync(testDir, { recursive: true }); mkdirSync(storePath, { recursive: true }); diff --git a/packages/cli-json-cas/src/var.test.ts b/packages/cli-json-cas/tests/variable.test.ts similarity index 99% rename from packages/cli-json-cas/src/var.test.ts rename to packages/cli-json-cas/tests/variable.test.ts index 175d1d9..098c6a3 100644 --- a/packages/cli-json-cas/src/var.test.ts +++ b/packages/cli-json-cas/tests/variable.test.ts @@ -21,7 +21,7 @@ beforeEach(() => { ); storePath = join(testDir, "store"); varDbPath = join(testDir, "variables.db"); - cliPath = join(import.meta.dir, "index.ts"); + cliPath = join(import.meta.dir, "../src/index.ts"); mkdirSync(testDir, { recursive: true }); mkdirSync(storePath, { recursive: true }); diff --git a/packages/cli-json-cas/tests/verify-refs-walk.test.ts b/packages/cli-json-cas/tests/verify-refs-walk.test.ts new file mode 100644 index 0000000..d2e89e0 --- /dev/null +++ b/packages/cli-json-cas/tests/verify-refs-walk.test.ts @@ -0,0 +1,82 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { envValue, stripVolatile } from "./helpers"; + +const entrypoint = resolve(import.meta.dir, "../src/index.ts"); + +let tmpStore: string; +let varDbPath: string; +let typeHash: string; +let nodeHash: string; + +beforeAll(async () => { + tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-")); + varDbPath = join(tmpStore, "variables.db"); + + const schemaFile = join(tmpStore, "test-schema.json"); + writeFileSync( + schemaFile, + JSON.stringify({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name"], + additionalProperties: false, + }), + ); + const { openStore: openFsStore } = await import("@uncaged/json-cas-fs"); + const { putSchema } = await import("@uncaged/json-cas"); + const store = await openFsStore(tmpStore); + typeHash = await putSchema(store, JSON.parse(readFileSync(schemaFile, "utf-8"))); + + const nodeFile = join(tmpStore, "test-node.json"); + writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 })); + const { stdout } = await runCli(["put", typeHash, nodeFile]); + nodeHash = envValue(stdout) as string; +}); + +afterAll(() => { + rmSync(tmpStore, { recursive: true, force: true }); +}); + +async function runCli( + args: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn( + ["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args], + { stdout: "pipe", stderr: "pipe" }, + ); + const exitCode = await proc.exited; + const stdout = (await new Response(proc.stdout).text()).trim(); + const stderr = (await new Response(proc.stderr).text()).trim(); + return { stdout, stderr, exitCode }; +} + +describe("Phase 1: CAS Core", () => { + test("1.9 verify returns ok for valid node", async () => { + const { stdout, exitCode } = await runCli(["verify", nodeHash]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("1.10 refs lists direct references (snapshot)", async () => { + const { stdout, exitCode } = await runCli(["refs", nodeHash]); + expect(exitCode).toBe(0); + expect(stdout).toMatchSnapshot(); + }); + + test("1.11 walk shows traversal tree (snapshot)", async () => { + const { stdout, exitCode } = await runCli(["walk", nodeHash]); + expect(exitCode).toBe(0); + expect(stdout).toMatchSnapshot(); + }); +}); + +describe("Phase 2: Schema Validation", () => { + test("2.2 verify on valid node returns ok (hash + schema)", async () => { + const { stdout, exitCode } = await runCli(["verify", nodeHash]); + expect(exitCode).toBe(0); + expect(envValue(stdout)).toBe("ok"); + }); +});