diff --git a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap new file mode 100644 index 0000000..c3aa2b8 --- /dev/null +++ b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap @@ -0,0 +1,305 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`Phase 1: CAS Core 1.3 schema get returns schema JSON (snapshot) 1`] = ` +"{ + "type": "object", + "required": [ + "name" + ], + "properties": { + "age": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false +}" +`; + +exports[`Phase 1: CAS Core 1.4 schema list shows registered schemas 1`] = ` +"8J5E9WCD54ZJZ (unnamed) +89ZQWMV9PTC7B (unnamed) +86QB0Q5VA7797 (unnamed) +AKEX4KYV98MGT (unnamed) +AGSJVKM01WNKZ (unnamed) +7XX5H51CVD9H0 (unnamed)" +`; + +exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = ` +{ + "payload": { + "age": 30, + "name": "Alice", + }, + "type": "7XX5H51CVD9H0", +} +`; + +exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `"ok"`; + +exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `""`; + +exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `"ERARPP19YJT05"`; + +exports[`Phase 1: CAS Core 1.13 cat returns full node (snapshot) 1`] = ` +{ + "payload": { + "age": 30, + "name": "Alice", + }, + "type": "7XX5H51CVD9H0", +} +`; + +exports[`Phase 1: CAS Core 1.14 cat --payload returns only payload (snapshot) 1`] = ` +"{ + "age": 30, + "name": "Alice" +}" +`; + +exports[`Phase 2: Schema Validation 2.2 schema validate on valid node returns valid 1`] = `"valid"`; + +exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`; + +exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": { + "labels": [], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": {}, + "value": "ERARPP19YJT05", + }, +} +`; + +exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": { + "labels": [], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": {}, + "value": "ERARPP19YJT05", + }, +} +`; + +exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "labels": [], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": {}, + "value": "ERARPP19YJT05", + }, + ], +} +`; + +exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "labels": [], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": {}, + "value": "ERARPP19YJT05", + }, + ], +} +`; + +exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": { + "labels": [], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": {}, + "value": "F68P1BZ46YDXM", + }, +} +`; + +exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": { + "labels": [ + "important", + ], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": { + "env": "prod", + }, + "value": "ERARPP19YJT05", + }, +} +`; + +exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "labels": [ + "important", + ], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": { + "env": "prod", + }, + "value": "ERARPP19YJT05", + }, + ], +} +`; + +exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "labels": [ + "important", + ], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": { + "env": "prod", + }, + "value": "ERARPP19YJT05", + }, + ], +} +`; + +exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": { + "labels": [], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": { + "env": "prod", + }, + "value": "ERARPP19YJT05", + }, +} +`; + +exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = ` +{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "labels": [], + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "tags": { + "env": "prod", + }, + "value": "ERARPP19YJT05", + }, + ], +} +`; + +exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=7XX5H51CVD9H0"`; + +exports[`Phase 4: Template System 4.1 template set registers template 1`] = ` +"{ + "schemaHash": "7XX5H51CVD9H0", + "contentHash": "FC8WACA792B6F" +}" +`; + +exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `"Name: {{ payload.name }}, Age: {{ payload.age }}"`; + +exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = ` +"[ + { + "schemaHash": "7XX5H51CVD9H0", + "preview": "Name: {{ payload.name }}, Age: {{ payload.age }}" + } +]" +`; + +exports[`Phase 4: Template System 4.4 template delete removes template 1`] = ` +"{ + "deleted": true +}" +`; + +exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 7XX5H51CVD9H0"`; + +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 schema put invalid schema errors 1`] = `"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema"`; + +exports[`Phase 7: Edge Cases 7.6 no subcommand shows help text 1`] = ` +"Usage: json-cas [--store ] [--json] [args] + +Commands: + init Create store dir and write bootstrap seed + bootstrap Write meta-schema seed, print hash + schema put Register schema, print type hash + schema get Print schema JSON + schema list List all schemas (name + hash) + schema validate Validate node against its schema + put Store node, print hash + get Print node as JSON + has Print true/false + verify Verify integrity, print ok/corrupted + refs List direct cas_ref edges + walk [--format tree] Recursive traversal + hash Compute hash without storing (dry run) + render [options] Render node as YAML with resolution decay + render --pipe/-p [options] Render { type, value } from stdin + cat [--payload] Output node (--payload for payload only) + var set [--tag ...] Create/update a variable + var get --schema Get a variable by name + schema + var delete [--schema ] Delete variable(s) + var list [prefix] [--schema ] [--tag ...] List variables + var tag --schema Modify tags/labels + template set | --inline Set template for schema + template get Get template content as raw text + template list List all templates + template delete Delete template for schema + gc Run garbage collection + +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 { type, value } JSON from stdin for render" +`; diff --git a/packages/cli-json-cas/src/e2e.test.ts b/packages/cli-json-cas/src/e2e.test.ts new file mode 100644 index 0000000..1e86b09 --- /dev/null +++ b/packages/cli-json-cas/src/e2e.test.ts @@ -0,0 +1,555 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, 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)); +} + +// ---- Phase 1: CAS Core ---- + +describe("Phase 1: CAS Core", () => { + test("1.1 bootstrap returns 13-char Base32 hash", async () => { + const { stdout, exitCode } = await runCli(["init"]); + expect(exitCode).toBe(0); + expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("1.2 schema put returns type hash", 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, + }), + ); + const { stdout, exitCode } = await runCli(["schema", "put", schemaFile]); + expect(exitCode).toBe(0); + expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + typeHash = stdout; + }); + + test("1.3 schema get returns schema JSON (snapshot)", async () => { + const { stdout, exitCode } = await runCli(["schema", "get", typeHash]); + expect(exitCode).toBe(0); + expect(stdout).toMatchSnapshot(); + }); + + test("1.4 schema list shows registered schemas", async () => { + const { stdout, exitCode } = await runCli(["schema", "list"]); + expect(exitCode).toBe(0); + expect(stdout).toMatchSnapshot(); + expect(stdout).toContain(typeHash); + }); + + 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(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + nodeHash = stdout; + }); + + 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(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(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(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(stdout).toBe(nodeHash); + }); + + test("1.13 cat returns full node (snapshot)", async () => { + const { stdout, exitCode } = await runCli(["cat", nodeHash]); + expect(exitCode).toBe(0); + expect(stripVolatile(stdout)).toMatchSnapshot(); + }); + + test("1.14 cat --payload returns only payload (snapshot)", async () => { + const { stdout, exitCode } = await runCli(["cat", nodeHash, "--payload"]); + expect(exitCode).toBe(0); + expect(stdout).toMatchSnapshot(); + const parsed = JSON.parse(stdout) as Record; + expect(parsed).not.toHaveProperty("type"); + expect(parsed).toHaveProperty("name", "Alice"); + }); +}); + +// ---- 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 schema validate on valid node returns valid", async () => { + const { stdout, exitCode } = await runCli(["schema", "validate", nodeHash]); + expect(exitCode).toBe(0); + expect(stdout).toMatchSnapshot(); + }); + + 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: node2Hash } = await runCli(["put", typeHash, node2File]); + const { exitCode, stdout } = await runCli([ + "var", + "set", + "myapp/config", + node2Hash.trim(), + ]); + 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(stdout).toMatchSnapshot(); + }); + + test("4.2 template get returns template text", async () => { + const { stdout, exitCode } = await runCli(["template", "get", typeHash]); + expect(exitCode).toBe(0); + expect(stdout).toBe("Name: {{ payload.name }}, Age: {{ payload.age }}"); + expect(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(stdout).toMatchSnapshot(); + }); + + test("4.4 template delete removes template", async () => { + const { exitCode, stdout } = await runCli(["template", "delete", typeHash]); + expect(exitCode).toBe(0); + expect(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 = stdout.trim(); + // 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 = JSON.parse(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(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: orphanHashRaw } = await runCli([ + "put", + typeHash, + orphanFile, + ]); + const orphanHash = orphanHashRaw.trim(); + + const { stdout: beforeGc } = await runCli(["has", orphanHash]); + expect(beforeGc).toBe("true"); + + await runCli(["gc"]); + const { stdout: afterGc } = await runCli(["has", orphanHash]); + expect(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 schema put invalid schema errors", async () => { + const badSchemaFile = join(tmpStore, "bad-schema.json"); + writeFileSync(badSchemaFile, JSON.stringify({ type: "invalid" })); + const { stdout, stderr, exitCode } = await runCli([ + "schema", + "put", + badSchemaFile, + ]); + expect(exitCode).not.toBe(0); + expect(stdout).toBe(""); + expect(stderr).toMatchSnapshot(); + }); + + test("7.6 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.7 --store non-existent path errors with Store not found", async () => { + const fakeStore = "/nonexistent/store/path"; + const proc = Bun.spawn( + [ + "bun", + entrypoint, + "--store", + fakeStore, + "--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("Store not found"); + expect(stderr).not.toContain("Node not found"); + }); +});