Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d08c1bf4d | |||
| ca4fe8c3ac | |||
| 58f57b0dd8 |
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
});
|
||||
});
|
||||
+198
-240
@@ -1,245 +1,5 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
|
||||
{
|
||||
"type": "CRK52TZT2ZND7",
|
||||
"value": {
|
||||
"payload": {
|
||||
"age": 30,
|
||||
"name": "Alice",
|
||||
},
|
||||
"type": "5J4TDW3H4J427",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
|
||||
{
|
||||
"type": "0RJTSZR6K608G",
|
||||
"value": "ok",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "DGB04ECT2M49Q",
|
||||
"value": []
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
|
||||
"{
|
||||
"type": "EQ015GFTBKQJA",
|
||||
"value": [
|
||||
"078KT0M8N554S"
|
||||
]
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
|
||||
{
|
||||
"type": "E88C2CKY90ERT",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {},
|
||||
"value": "078KT0M8N554S",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
{
|
||||
"type": "0GVNZDR5AC35Y",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {},
|
||||
"value": "078KT0M8N554S",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
{
|
||||
"type": "3XAAW976C1KT8",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {},
|
||||
"value": "078KT0M8N554S",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
{
|
||||
"type": "3XAAW976C1KT8",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {},
|
||||
"value": "078KT0M8N554S",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
|
||||
{
|
||||
"type": "E88C2CKY90ERT",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {},
|
||||
"value": "0S3E6H68VK328",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
{
|
||||
"type": "92GB8QS47S7HS",
|
||||
"value": {
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "078KT0M8N554S",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||
{
|
||||
"type": "3XAAW976C1KT8",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "078KT0M8N554S",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
|
||||
{
|
||||
"type": "3XAAW976C1KT8",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "078KT0M8N554S",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
{
|
||||
"type": "92GB8QS47S7HS",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "078KT0M8N554S",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
{
|
||||
"type": "EV9W9HDVK7WX2",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "5J4TDW3H4J427",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "078KT0M8N554S",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=5J4TDW3H4J427"`;
|
||||
|
||||
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
|
||||
{
|
||||
"type": "FTZ3B2BN1H3SA",
|
||||
"value": {
|
||||
"contentHash": "0MNS832R7RZSV",
|
||||
"schemaHash": "5J4TDW3H4J427",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
|
||||
{
|
||||
"type": "EZ034E7H0JWFV",
|
||||
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
|
||||
{
|
||||
"type": "DCVZJFMSKBGJV",
|
||||
"value": [
|
||||
{
|
||||
"contentHash": "0MNS832R7RZSV",
|
||||
"schemaHash": "5J4TDW3H4J427",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
|
||||
{
|
||||
"type": "3RKA4H2KVF0TF",
|
||||
"value": {
|
||||
"deleted": true,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 5J4TDW3H4J427"`;
|
||||
|
||||
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>...]"`;
|
||||
@@ -287,3 +47,201 @@ Flags:
|
||||
--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`] = `
|
||||
{
|
||||
"type": "5KVHSESSX7N96",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
|
||||
{
|
||||
"type": "7D4R2MJ1EYF08",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
|
||||
{
|
||||
"type": "E8158M25YNR9M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
|
||||
{
|
||||
"type": "E8158M25YNR9M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
|
||||
{
|
||||
"type": "5KVHSESSX7N96",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {},
|
||||
"value": "AE80669JYG4K2",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
|
||||
{
|
||||
"type": "2WX4XRYSACRWR",
|
||||
"value": {
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
|
||||
{
|
||||
"type": "E8158M25YNR9M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
|
||||
{
|
||||
"type": "E8158M25YNR9M",
|
||||
"value": [
|
||||
{
|
||||
"labels": [
|
||||
"important",
|
||||
],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
|
||||
{
|
||||
"type": "2WX4XRYSACRWR",
|
||||
"value": {
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
|
||||
{
|
||||
"type": "F89626QEK09YY",
|
||||
"value": [
|
||||
{
|
||||
"labels": [],
|
||||
"name": "myapp/config",
|
||||
"schema": "8WAZV39SD724T",
|
||||
"tags": {
|
||||
"env": "prod",
|
||||
},
|
||||
"value": "6KZ930XYK2MHB",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=8WAZV39SD724T"`;
|
||||
|
||||
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
|
||||
{
|
||||
"type": "A8RGY9B1JSCDJ",
|
||||
"value": {
|
||||
"contentHash": "0359SHMP7VBFD",
|
||||
"schemaHash": "8WAZV39SD724T",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
|
||||
{
|
||||
"type": "64FP3TWKK69YH",
|
||||
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
|
||||
{
|
||||
"type": "BE20H482GBNM3",
|
||||
"value": [
|
||||
{
|
||||
"contentHash": "0359SHMP7VBFD",
|
||||
"schemaHash": "8WAZV39SD724T",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
|
||||
{
|
||||
"type": "0FXB2GS8Y9R1R",
|
||||
"value": {
|
||||
"deleted": true,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 8WAZV39SD724T"`;
|
||||
@@ -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"
|
||||
]
|
||||
}"
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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 });
|
||||
+1
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,16 @@ const BOOTSTRAP_PAYLOAD = {
|
||||
minProperties: { type: "number" },
|
||||
maxProperties: { type: "number" },
|
||||
default: {},
|
||||
// P3 combinators
|
||||
not: { type: "object", additionalProperties: false },
|
||||
contains: { type: "object", additionalProperties: false },
|
||||
propertyNames: { type: "object", additionalProperties: false },
|
||||
// P3 metadata
|
||||
examples: { type: "array" },
|
||||
readOnly: { type: "boolean" },
|
||||
writeOnly: { type: "boolean" },
|
||||
deprecated: { type: "boolean" },
|
||||
$comment: { type: "string" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -663,4 +663,91 @@ describe("bootstrap meta-schema self-reference", () => {
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
|
||||
// ── P3 combinators, propertyNames, and metadata ──────────────────────────
|
||||
|
||||
test("accepts schema with not", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
not: { type: "string" },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, 42);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, "hello");
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with contains", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "array",
|
||||
contains: { type: "number", minimum: 10 },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, [1, 2, 15]);
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, [1, 2, 3]);
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with propertyNames", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "object",
|
||||
propertyNames: { pattern: "^[a-z]+$" },
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
|
||||
const good = await store.put(hash, { foo: 1, bar: 2 });
|
||||
expect(validate(store, store.get(good) as CasNode)).toBe(true);
|
||||
|
||||
const bad = await store.put(hash, { Foo: 1 });
|
||||
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts schema with metadata keywords", async () => {
|
||||
const store = createMemoryStore();
|
||||
const hash = await putSchema(store, {
|
||||
type: "string",
|
||||
examples: ["hello", "world"],
|
||||
readOnly: true,
|
||||
deprecated: false,
|
||||
$comment: "This is a test schema",
|
||||
});
|
||||
expect(hash).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("rejects invalid P3 keyword types", async () => {
|
||||
const store = createMemoryStore();
|
||||
await expect(
|
||||
putSchema(store, { not: "not-object" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { examples: "not-array" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
putSchema(store, { readOnly: "yes" } as never),
|
||||
).rejects.toThrow();
|
||||
await expect(putSchema(store, { $comment: 42 } as never)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("collectRefs traverses contains", async () => {
|
||||
const store = createMemoryStore();
|
||||
const innerSchema = await putSchema(store, { type: "string" });
|
||||
const schema = await putSchema(store, {
|
||||
type: "array",
|
||||
contains: { type: "string", format: "cas_ref" },
|
||||
});
|
||||
|
||||
const targetHash = await store.put(innerSchema, "hello");
|
||||
const nodeHash = await store.put(schema, [targetHash, "not-a-ref"]);
|
||||
const node = store.get(nodeHash) as CasNode;
|
||||
const refList = refs(store, node);
|
||||
expect(refList).toContain(targetHash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,16 @@ const ALLOWED_SCHEMA_KEYS = new Set([
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
"default",
|
||||
// P3 combinators (need collectRefs)
|
||||
"not",
|
||||
"contains",
|
||||
"propertyNames",
|
||||
// P3 metadata (no collectRefs impact)
|
||||
"examples",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"deprecated",
|
||||
"$comment",
|
||||
]);
|
||||
|
||||
const JSON_SCHEMA_TYPES = new Set([
|
||||
@@ -210,6 +220,22 @@ function isValidSchema(value: unknown): boolean {
|
||||
return false;
|
||||
// "default" accepts any value — no type check needed
|
||||
|
||||
// P3 combinators — recursive sub-schema checks
|
||||
if ("not" in schema && !isValidSchema(schema.not)) return false;
|
||||
if ("contains" in schema && !isValidSchema(schema.contains)) return false;
|
||||
if ("propertyNames" in schema && !isValidSchema(schema.propertyNames))
|
||||
return false;
|
||||
|
||||
// P3 metadata — type checks only
|
||||
if ("examples" in schema && !Array.isArray(schema.examples)) return false;
|
||||
if ("readOnly" in schema && typeof schema.readOnly !== "boolean")
|
||||
return false;
|
||||
if ("writeOnly" in schema && typeof schema.writeOnly !== "boolean")
|
||||
return false;
|
||||
if ("deprecated" in schema && typeof schema.deprecated !== "boolean")
|
||||
return false;
|
||||
if ("$comment" in schema && typeof schema.$comment !== "string") return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -268,7 +294,7 @@ export function validate(store: Store, node: CasNode): boolean {
|
||||
/**
|
||||
* Recursively collect values of all properties whose schema has format: 'cas_ref'.
|
||||
* Handles: direct format, anyOf/allOf (combinators), oneOf, if/then/else (conditionals),
|
||||
* items + prefixItems (arrays), properties (nested objects),
|
||||
* not, contains, items + prefixItems (arrays), properties (nested objects),
|
||||
* additionalProperties (record refs), and patternProperties (regex-keyed refs).
|
||||
*/
|
||||
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
@@ -328,6 +354,15 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
result.push(...collectRefs(itemSchema, arr[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// P3: contains — sub-schema for array items
|
||||
if (schema.contains && typeof schema.contains === "object") {
|
||||
const containsSchema = schema.contains as JSONSchema;
|
||||
for (const item of value as unknown[]) {
|
||||
result.push(...collectRefs(containsSchema, item));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -369,6 +404,11 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
|
||||
}
|
||||
}
|
||||
|
||||
// P3: not — sub-schema applies to the same value
|
||||
if (schema.not && typeof schema.not === "object") {
|
||||
result.push(...collectRefs(schema.not as JSONSchema, value));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
expect(properties).not.toHaveProperty("$ref");
|
||||
expect(properties).not.toHaveProperty("$id");
|
||||
expect(properties).not.toHaveProperty("$defs");
|
||||
expect(properties).not.toHaveProperty("not");
|
||||
|
||||
// P2 keywords should be present
|
||||
expect(properties).toHaveProperty("allOf");
|
||||
@@ -85,6 +84,16 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
|
||||
expect(properties).toHaveProperty("minProperties");
|
||||
expect(properties).toHaveProperty("maxProperties");
|
||||
expect(properties).toHaveProperty("default");
|
||||
|
||||
// P3 keywords should be present
|
||||
expect(properties).toHaveProperty("not");
|
||||
expect(properties).toHaveProperty("contains");
|
||||
expect(properties).toHaveProperty("propertyNames");
|
||||
expect(properties).toHaveProperty("examples");
|
||||
expect(properties).toHaveProperty("readOnly");
|
||||
expect(properties).toHaveProperty("writeOnly");
|
||||
expect(properties).toHaveProperty("deprecated");
|
||||
expect(properties).toHaveProperty("$comment");
|
||||
});
|
||||
|
||||
test("1.5: Meta-schema node type equals its own hash", async () => {
|
||||
|
||||
Reference in New Issue
Block a user