refactor: reorganize CLI E2E tests into tests/ by scenario

Move 4 monolithic test files (cli.test.ts, e2e.test.ts, template.test.ts,
var.test.ts) from src/ into tests/ directory, split by scenario:

  tests/helpers.ts           - shared utilities (envValue, stripVolatile)
  tests/put-get-has.test.ts  - basic CAS storage operations
  tests/verify-refs-walk.test.ts - graph traversal
  tests/schema-validation.test.ts - Issue #50 schema validation
  tests/alias.test.ts        - @ alias resolution
  tests/variable.test.ts     - var set/get/delete/list/tag
  tests/template.test.ts     - template set/get/list/delete
  tests/render.test.ts       - render + render -p
  tests/pipe.test.ts         - pipe composition + stdin input
  tests/gc.test.ts           - garbage collection
  tests/edge-cases.test.ts   - help, --store, error paths

All 516 tests pass (159 CLI + 357 core/fs).
This commit is contained in:
2026-06-01 03:38:35 +00:00
parent ca4fe8c3ac
commit 1d08c1bf4d
18 changed files with 2455 additions and 2018 deletions
File diff suppressed because it is too large Load Diff
-695
View File
@@ -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<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
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<string, unknown>;
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 <hash> 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");
});
});
@@ -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 <name> <hash> [--tag <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 <path>] [--json] <command> [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 <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
template get <schema-hash> Get template content (value=string) (@output/template-get)
template list List all templates (@output/template-list)
template delete <schema-hash> Delete template for schema (@output/template-delete)
gc Run garbage collection (@output/gc)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> 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 <name> <hash> [--tag <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 <path>] [--json] <command> [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 <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
var set <name> <hash> [--tag <tag>...] Create/update a variable (@output/var-set)
var get <name> --schema <hash> Get a variable by name + schema (@output/var-get)
var delete <name> [--schema <hash>] Delete variable(s) (@output/var-delete)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables (@output/var-list)
var tag <name> --schema <hash> <operations...> Modify tags/labels (@output/var-tag)
template set <schema-hash> <file> | --inline <text> Set template for schema (@output/template-set)
template get <schema-hash> Get template content (value=string) (@output/template-get)
template list List all templates (@output/template-list)
template delete <schema-hash> Delete template for schema (@output/template-delete)
gc Run garbage collection (@output/gc)
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)"
`;
@@ -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",
},
}
`;
@@ -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!"`;
@@ -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"`;
@@ -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"
]
}"
`;
+191
View File
@@ -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 <file> 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 <file> 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 <file> 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 <file> 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 <file> 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}$/);
});
});
@@ -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();
});
});
+113
View File
@@ -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<string, unknown>;
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);
});
});
+94
View File
@@ -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<string> {
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<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (k === "timestamp" || k === "created" || k === "updated") continue;
out[k] = strip(val);
}
return out;
}
return v;
};
return strip(JSON.parse(json));
}
+212
View File
@@ -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 <hash> 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");
});
});
@@ -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);
});
});
+518
View File
@@ -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.
@@ -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();
});
});
@@ -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 });
@@ -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 });
@@ -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");
});
});