From b0d5b054575d8c9aadd367b32f142d2b77384d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Sun, 31 May 2026 19:24:17 +0800 Subject: [PATCH 1/3] feat(cli): add e2e snapshot fixture tests for all CLI scenarios Convert 46 e2e-check workflow scenarios to fast bun test snapshot tests. 7 describe phases share a single mkdtempSync store; hashes are deterministic so cross-phase data dependencies work without re-creating data. Closes #66 Co-Authored-By: Claude Sonnet 4 --- .../src/__snapshots__/e2e.test.ts.snap | 338 +++++++++++ packages/cli-json-cas/src/e2e.test.ts | 526 ++++++++++++++++++ 2 files changed, 864 insertions(+) create mode 100644 packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap create mode 100644 packages/cli-json-cas/src/e2e.test.ts 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..ef245e9 --- /dev/null +++ b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap @@ -0,0 +1,338 @@ +// 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`] = ` +"{ + "type": "7XX5H51CVD9H0", + "payload": { + "age": 30, + "name": "Alice" + }, + "timestamp": 1780226624080 +}" +`; + +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`] = ` +"{ + "type": "7XX5H51CVD9H0", + "payload": { + "age": 30, + "name": "Alice" + }, + "timestamp": 1780226624080 +}" +`; + +exports[`Phase 1: CAS Core 1.14 cat --payload returns only payload (snapshot) 1`] = ` +"{ + "age": 30, + "name": "Alice" +}" +`; + +exports[`Phase 2: Schema Validation 2.1 put {name:123} against string-schema fails with non-zero exit 1`] = `"Validation failed: payload in /var/folders/_x/kgw53jp56ngdfz2x1ly96wyc0000gn/T/json-cas-e2e-WXS50Y/bad-node.json does not match schema 7XX5H51CVD9H0"`; + +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": { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "ERARPP19YJT05", + "created": 1780226624515, + "updated": 1780226624515, + "tags": {}, + "labels": [] + } +}" +`; + +exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = ` +"{ + "type": "E1D32N3GT69Q8", + "value": { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "ERARPP19YJT05", + "created": 1780226624515, + "updated": 1780226624515, + "tags": {}, + "labels": [] + } +}" +`; + +exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` +"{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "ERARPP19YJT05", + "created": 1780226624515, + "updated": 1780226624515, + "tags": {}, + "labels": [] + } + ] +}" +`; + +exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = ` +"{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "ERARPP19YJT05", + "created": 1780226624515, + "updated": 1780226624515, + "tags": {}, + "labels": [] + } + ] +}" +`; + +exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = ` +"{ + "type": "E1D32N3GT69Q8", + "value": { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "F68P1BZ46YDXM", + "created": 1780226624515, + "updated": 1780226624700, + "tags": {}, + "labels": [] + } +}" +`; + +exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = ` +"{ + "type": "E1D32N3GT69Q8", + "value": { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "ERARPP19YJT05", + "created": 1780226624515, + "updated": 1780226624771, + "tags": { + "env": "prod" + }, + "labels": [ + "important" + ] + } +}" +`; + +exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = ` +"{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "ERARPP19YJT05", + "created": 1780226624515, + "updated": 1780226624771, + "tags": { + "env": "prod" + }, + "labels": [ + "important" + ] + } + ] +}" +`; + +exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = ` +"{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "ERARPP19YJT05", + "created": 1780226624515, + "updated": 1780226624771, + "tags": { + "env": "prod" + }, + "labels": [ + "important" + ] + } + ] +}" +`; + +exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = ` +"{ + "type": "E1D32N3GT69Q8", + "value": { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "ERARPP19YJT05", + "created": 1780226624515, + "updated": 1780226624878, + "tags": { + "env": "prod" + }, + "labels": [] + } +}" +`; + +exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = ` +"{ + "type": "E1D32N3GT69Q8", + "value": [ + { + "name": "myapp/config", + "schema": "7XX5H51CVD9H0", + "value": "ERARPP19YJT05", + "created": 1780226624515, + "updated": 1780226624878, + "tags": { + "env": "prod" + }, + "labels": [] + } + ] +}" +`; + +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 6: GC 6.1 gc runs without error 1`] = ` +"{ + "total": 13, + "reachable": 5, + "collected": 8, + "scanned": 2 +}" +`; + +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..b86307d --- /dev/null +++ b/packages/cli-json-cas/src/e2e.test.ts @@ -0,0 +1,526 @@ +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 }; +} + +// ---- 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(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(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).toMatchSnapshot(); + }); + + 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(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(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(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(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(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(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(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(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(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(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); + expect(stdout).toMatchSnapshot(); + }); + + 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"); + }); +}); From 2ed097e207e69127bc2133b943ec52fca583ff24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Sun, 31 May 2026 19:29:25 +0800 Subject: [PATCH 2/3] fix(cli): make e2e snapshots stable across machines and runs - Strip volatile fields (timestamp, created, updated) from JSON before snapshotting using a stripVolatile helper - Remove toMatchSnapshot() from test 2.1 to avoid embedding machine- specific tmp paths in the snapshot; use toContain assertions instead - Replace GC count snapshot with structural shape assertions so counts don't need to match exact phase-history state Co-Authored-By: Claude Sonnet 4 --- .../src/__snapshots__/e2e.test.ts.snap | 181 +++++++----------- packages/cli-json-cas/src/e2e.test.ts | 57 ++++-- 2 files changed, 117 insertions(+), 121 deletions(-) diff --git a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap index ef245e9..c3aa2b8 100644 --- a/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap +++ b/packages/cli-json-cas/src/__snapshots__/e2e.test.ts.snap @@ -28,14 +28,13 @@ AGSJVKM01WNKZ (unnamed) `; exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = ` -"{ - "type": "7XX5H51CVD9H0", +{ "payload": { "age": 30, - "name": "Alice" + "name": "Alice", }, - "timestamp": 1780226624080 -}" + "type": "7XX5H51CVD9H0", +} `; exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `"ok"`; @@ -45,14 +44,13 @@ 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`] = ` -"{ - "type": "7XX5H51CVD9H0", +{ "payload": { "age": 30, - "name": "Alice" + "name": "Alice", }, - "timestamp": 1780226624080 -}" + "type": "7XX5H51CVD9H0", +} `; exports[`Phase 1: CAS Core 1.14 cat --payload returns only payload (snapshot) 1`] = ` @@ -62,186 +60,164 @@ exports[`Phase 1: CAS Core 1.14 cat --payload returns only payload (snapshot) 1` }" `; -exports[`Phase 2: Schema Validation 2.1 put {name:123} against string-schema fails with non-zero exit 1`] = `"Validation failed: payload in /var/folders/_x/kgw53jp56ngdfz2x1ly96wyc0000gn/T/json-cas-e2e-WXS50Y/bad-node.json does not match schema 7XX5H51CVD9H0"`; - 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", - "value": "ERARPP19YJT05", - "created": 1780226624515, - "updated": 1780226624515, "tags": {}, - "labels": [] - } -}" + "value": "ERARPP19YJT05", + }, +} `; exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = ` -"{ +{ "type": "E1D32N3GT69Q8", "value": { + "labels": [], "name": "myapp/config", "schema": "7XX5H51CVD9H0", - "value": "ERARPP19YJT05", - "created": 1780226624515, - "updated": 1780226624515, "tags": {}, - "labels": [] - } -}" + "value": "ERARPP19YJT05", + }, +} `; exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = ` -"{ +{ "type": "E1D32N3GT69Q8", "value": [ { + "labels": [], "name": "myapp/config", "schema": "7XX5H51CVD9H0", - "value": "ERARPP19YJT05", - "created": 1780226624515, - "updated": 1780226624515, "tags": {}, - "labels": [] - } - ] -}" + "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", - "value": "ERARPP19YJT05", - "created": 1780226624515, - "updated": 1780226624515, "tags": {}, - "labels": [] - } - ] -}" + "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", - "value": "F68P1BZ46YDXM", - "created": 1780226624515, - "updated": 1780226624700, "tags": {}, - "labels": [] - } -}" + "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", - "value": "ERARPP19YJT05", - "created": 1780226624515, - "updated": 1780226624771, "tags": { - "env": "prod" + "env": "prod", }, - "labels": [ - "important" - ] - } -}" + "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", - "value": "ERARPP19YJT05", - "created": 1780226624515, - "updated": 1780226624771, "tags": { - "env": "prod" + "env": "prod", }, - "labels": [ - "important" - ] - } - ] -}" + "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", - "value": "ERARPP19YJT05", - "created": 1780226624515, - "updated": 1780226624771, "tags": { - "env": "prod" + "env": "prod", }, - "labels": [ - "important" - ] - } - ] -}" + "value": "ERARPP19YJT05", + }, + ], +} `; exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = ` -"{ +{ "type": "E1D32N3GT69Q8", "value": { + "labels": [], "name": "myapp/config", "schema": "7XX5H51CVD9H0", - "value": "ERARPP19YJT05", - "created": 1780226624515, - "updated": 1780226624878, "tags": { - "env": "prod" + "env": "prod", }, - "labels": [] - } -}" + "value": "ERARPP19YJT05", + }, +} `; exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = ` -"{ +{ "type": "E1D32N3GT69Q8", "value": [ { + "labels": [], "name": "myapp/config", "schema": "7XX5H51CVD9H0", - "value": "ERARPP19YJT05", - "created": 1780226624515, - "updated": 1780226624878, "tags": { - "env": "prod" + "env": "prod", }, - "labels": [] - } - ] -}" + "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"`; @@ -276,15 +252,6 @@ 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 6: GC 6.1 gc runs without error 1`] = ` -"{ - "total": 13, - "reachable": 5, - "collected": 8, - "scanned": 2 -}" -`; - 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 ...]"`; diff --git a/packages/cli-json-cas/src/e2e.test.ts b/packages/cli-json-cas/src/e2e.test.ts index b86307d..058dec2 100644 --- a/packages/cli-json-cas/src/e2e.test.ts +++ b/packages/cli-json-cas/src/e2e.test.ts @@ -34,6 +34,26 @@ async function runCli( 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", () => { @@ -88,7 +108,7 @@ describe("Phase 1: CAS Core", () => { test("1.6 get returns node JSON (snapshot)", async () => { const { stdout, exitCode } = await runCli(["get", nodeHash]); expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); }); test("1.7 has returns true for existing node", async () => { @@ -131,7 +151,7 @@ describe("Phase 1: CAS Core", () => { test("1.13 cat returns full node (snapshot)", async () => { const { stdout, exitCode } = await runCli(["cat", nodeHash]); expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); }); test("1.14 cat --payload returns only payload (snapshot)", async () => { @@ -158,7 +178,8 @@ describe("Phase 2: Schema Validation", () => { expect(exitCode).not.toBe(0); expect(stdout).toBe(""); expect(stderr).toContain("Validation failed"); - expect(stderr).toMatchSnapshot(); + 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 () => { @@ -190,7 +211,7 @@ describe("Phase 3: Variable System", () => { nodeHash, ]); expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); }); test("3.2 var get returns variable", async () => { @@ -202,21 +223,21 @@ describe("Phase 3: Variable System", () => { typeHash, ]); expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); + 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(stdout).toMatchSnapshot(); + 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(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); expect(stdout).toContain("myapp/config"); }); @@ -231,7 +252,7 @@ describe("Phase 3: Variable System", () => { node2Hash.trim(), ]); expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); // Restore original value await runCli(["var", "set", "myapp/config", nodeHash]); }); @@ -247,7 +268,7 @@ describe("Phase 3: Variable System", () => { "important", ]); expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); }); test("3.7 var list --tag env:prod filters by kv tag", async () => { @@ -259,7 +280,7 @@ describe("Phase 3: Variable System", () => { ]); expect(exitCode).toBe(0); expect(stdout).toContain("myapp/config"); - expect(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); }); test("3.8 var list --tag important filters by label", async () => { @@ -271,7 +292,7 @@ describe("Phase 3: Variable System", () => { ]); expect(exitCode).toBe(0); expect(stdout).toContain("myapp/config"); - expect(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); }); test("3.9 var tag remove deletes label", async () => { @@ -284,7 +305,7 @@ describe("Phase 3: Variable System", () => { ":important", ]); expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); // Verify label is gone const { stdout: listOut } = await runCli([ "var", @@ -302,7 +323,7 @@ describe("Phase 3: Variable System", () => { "myapp/config", ]); expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); + expect(stripVolatile(stdout)).toMatchSnapshot(); }); test("3.11 var get deleted variable returns not found", async () => { @@ -414,7 +435,15 @@ describe("Phase 6: GC", () => { test("6.1 gc runs without error", async () => { const { exitCode, stdout } = await runCli(["gc"]); expect(exitCode).toBe(0); - expect(stdout).toMatchSnapshot(); + // 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 () => { From 9912013b0ae2744d7b1fa2a87122f4a182cb5774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Sun, 31 May 2026 19:30:31 +0800 Subject: [PATCH 3/3] fix(cli): use dot notation for GC result assertions (Biome lint) Co-Authored-By: Claude Sonnet 4 --- packages/cli-json-cas/src/e2e.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli-json-cas/src/e2e.test.ts b/packages/cli-json-cas/src/e2e.test.ts index 058dec2..1e86b09 100644 --- a/packages/cli-json-cas/src/e2e.test.ts +++ b/packages/cli-json-cas/src/e2e.test.ts @@ -437,12 +437,12 @@ describe("Phase 6: 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, + 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, ); });