Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a9b2bf86c | |||
| de20cfde53 | |||
| 9948db77ea | |||
| c60f05f650 | |||
| 314b076c05 | |||
| 664409b94a | |||
| f3f13e6f35 | |||
| 10c5c8f98e | |||
| d28003779e | |||
| 885a8e1147 | |||
| f0ffe6b234 | |||
| fc869cfc99 | |||
| 7fd2013ef2 | |||
| 0b72c9400f | |||
| eb36c16420 | |||
| 3c8b16d7b1 | |||
| 51e81c7b99 |
@@ -0,0 +1,416 @@
|
||||
name: "e2e-check"
|
||||
description: "Docker-isolated E2E testing of json-cas CLI. Preparer builds from scratch, tester runs scenarios, reporter files bugs."
|
||||
|
||||
roles:
|
||||
preparer:
|
||||
description: "Spins up Docker container, copies repo, installs, builds, runs unit tests"
|
||||
goal: "You set up a clean Docker environment for E2E testing. Your job is to start a container, install deps, build, lint, run unit tests, and initialize a CAS store. Report any setup failures as bugs."
|
||||
capabilities:
|
||||
- docker
|
||||
procedure: |
|
||||
1. Start a detached container:
|
||||
```bash
|
||||
docker run -d --name json-cas-e2e \
|
||||
-v "<repoPath>:/src:ro" \
|
||||
-w /workspace \
|
||||
oven/bun:latest \
|
||||
sleep 3600
|
||||
```
|
||||
|
||||
2. Copy repo and install:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'cp -r /src/. /workspace/ && cd /workspace && bun install'
|
||||
```
|
||||
✅ exit code 0, no missing peer deps
|
||||
|
||||
3. Build:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'cd /workspace && bun run build'
|
||||
```
|
||||
✅ exit code 0, no type errors
|
||||
|
||||
4. Lint:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'cd /workspace && bun run check'
|
||||
```
|
||||
✅ exit code 0
|
||||
|
||||
5. Unit tests:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'cd /workspace && bun test'
|
||||
```
|
||||
✅ all pass (ignore dist/ false positives)
|
||||
|
||||
6. Init CAS store:
|
||||
```bash
|
||||
docker exec json-cas-e2e bash -c 'mkdir -p /tmp/cas-test && cd /workspace && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test init && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test bootstrap'
|
||||
```
|
||||
|
||||
**Any failure here is a high-severity bug** — it means a clean environment can't build/run the project.
|
||||
|
||||
Set $status=ready if all steps pass. Set $status=setup_failed with failures list if anything breaks.
|
||||
|
||||
output: "Setup result summary."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "ready" }
|
||||
containerName: { type: string }
|
||||
storePath: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, containerName, storePath, repoPath]
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "setup_failed" }
|
||||
failures:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
title: { type: string }
|
||||
command: { type: string }
|
||||
expected: { type: string }
|
||||
actual: { type: string }
|
||||
severity: { type: string }
|
||||
phase: { type: string }
|
||||
required: [title, command, expected, actual, severity, phase]
|
||||
repoPath: { type: string }
|
||||
required: [$status, failures, repoPath]
|
||||
|
||||
tester:
|
||||
description: "Runs CLI scenarios against the prepared Docker environment"
|
||||
goal: "You are an exploratory QA agent. The Docker container is already running with the project built and a CAS store initialized. Run CLI test scenarios and report bugs."
|
||||
capabilities:
|
||||
- testing
|
||||
- cli
|
||||
procedure: |
|
||||
The container `{{{containerName}}}` is already running with the project built.
|
||||
Store path: `{{{storePath}}}`.
|
||||
|
||||
Run all commands via:
|
||||
```bash
|
||||
docker exec {{{containerName}}} bash -c 'cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}} <subcommand>'
|
||||
```
|
||||
|
||||
Define a shorthand in your notes:
|
||||
`CMD="cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}}"`
|
||||
|
||||
## Phase 1: CAS Core Operations
|
||||
|
||||
1. **bootstrap** — `$CMD bootstrap`
|
||||
Expected: prints meta-schema hash (13-char Base32)
|
||||
|
||||
2. **schema put** — Create `/tmp/test-schema.json` in container, then `$CMD schema put /tmp/test-schema.json`
|
||||
Schema: `{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"number"}},"required":["name"],"additionalProperties":false}`
|
||||
Expected: prints type hash
|
||||
|
||||
3. **schema get** — `$CMD schema get <type-hash>`
|
||||
Expected: returns the schema JSON
|
||||
|
||||
4. **schema list** — `$CMD schema list`
|
||||
Expected: lists registered schemas
|
||||
|
||||
5. **put** — Create data file, `$CMD put <type-hash> /tmp/test-node.json`
|
||||
Data: `{"name":"Alice","age":30}`
|
||||
Expected: prints node hash
|
||||
|
||||
6. **get** — `$CMD get <node-hash>`
|
||||
Expected: returns node JSON
|
||||
|
||||
7. **has (exists)** — `$CMD has <node-hash>`
|
||||
Expected: true
|
||||
|
||||
8. **has (not exists)** — `$CMD has AAAAAAAAAAAAA`
|
||||
Expected: false
|
||||
|
||||
9. **verify** — `$CMD verify <node-hash>`
|
||||
Expected: ok
|
||||
|
||||
10. **refs** — `$CMD refs <node-hash>`
|
||||
Expected: lists refs (may be empty)
|
||||
|
||||
11. **walk** — `$CMD walk <node-hash>`
|
||||
Expected: shows traversal tree
|
||||
|
||||
12. **hash (dry run)** — `$CMD hash <type-hash> /tmp/test-node.json`
|
||||
Expected: same hash as put
|
||||
|
||||
13. **cat** — `$CMD cat <node-hash>`
|
||||
Expected: full node output
|
||||
|
||||
14. **cat --payload** — `$CMD cat <node-hash> --payload`
|
||||
Expected: payload only (no type wrapper)
|
||||
|
||||
## Phase 2: Schema Validation
|
||||
|
||||
1. **Invalid node** — `$CMD put <type-hash> /tmp/bad-node.json` where bad-node = `{"name":123}`
|
||||
Expected: validation error, non-zero exit
|
||||
|
||||
2. **schema validate** — `$CMD schema validate <node-hash>`
|
||||
Expected: valid for good node
|
||||
|
||||
3. **Non-existent schema** — `$CMD put AAAAAAAAAAAAA /tmp/test-node.json`
|
||||
Expected: error about missing schema
|
||||
|
||||
## Phase 3: Variable System
|
||||
|
||||
1. **var set** — `$CMD var set myapp/config <node-hash>`
|
||||
Expected: creates variable
|
||||
|
||||
2. **var get** — `$CMD var get myapp/config --schema <type-hash>`
|
||||
Expected: returns variable
|
||||
|
||||
3. **var list** — `$CMD var list`
|
||||
Expected: shows all variables
|
||||
|
||||
4. **var list prefix** — `$CMD var list myapp/`
|
||||
Expected: filtered results
|
||||
|
||||
5. **var set (update)** — Put a second node, `$CMD var set myapp/config <new-hash>`
|
||||
Expected: upsert succeeds
|
||||
|
||||
6. **var tag** — `$CMD var tag myapp/config --schema <type-hash> env:prod important`
|
||||
Expected: adds tag and label
|
||||
|
||||
7. **var list --tag** — `$CMD var list --tag env:prod`
|
||||
Expected: finds tagged variable
|
||||
|
||||
8. **var list --tag (label)** — `$CMD var list --tag important`
|
||||
Expected: finds labeled variable
|
||||
|
||||
9. **var tag remove** — `$CMD var tag myapp/config --schema <type-hash> :important`
|
||||
Expected: removes label
|
||||
|
||||
10. **var delete** — `$CMD var delete myapp/config`
|
||||
Expected: deletes variable
|
||||
|
||||
11. **var get (deleted)** — `$CMD var get myapp/config --schema <type-hash>`
|
||||
Expected: not found error
|
||||
|
||||
## Phase 4: Template System
|
||||
|
||||
1. **template set** — Create template file, `$CMD template set <type-hash> /tmp/test.liquid`
|
||||
Template: `Name: {{ payload.name }}, Age: {{ payload.age }}`
|
||||
Expected: success
|
||||
|
||||
2. **template get** — `$CMD template get <type-hash>`
|
||||
Expected: returns template text
|
||||
|
||||
3. **template list** — `$CMD template list`
|
||||
Expected: lists templates
|
||||
|
||||
4. **template delete** — `$CMD template delete <type-hash>`
|
||||
Expected: success
|
||||
|
||||
5. **template get (deleted)** — `$CMD template get <type-hash>`
|
||||
Expected: not found error
|
||||
|
||||
## Phase 5: Render
|
||||
|
||||
1. Re-register template, then `$CMD render <node-hash>`
|
||||
Expected: rendered output with payload values filled in
|
||||
|
||||
2. **render --resolution** — `$CMD render <node-hash> --resolution 0.5`
|
||||
Expected: different resolution output
|
||||
|
||||
3. **render (bad hash)** — `$CMD render AAAAAAAAAAAAA`
|
||||
Expected: graceful error, non-zero exit
|
||||
|
||||
## Phase 6: GC
|
||||
|
||||
1. **gc basic** — `$CMD gc`
|
||||
Expected: runs without error
|
||||
|
||||
2. **gc preserves referenced** — Verify `$CMD has <node-hash>` still true
|
||||
|
||||
3. **gc collects orphans** — Put an orphan node (not in any variable), run gc, check it's gone
|
||||
|
||||
## Phase 7: Edge Cases & Error Handling
|
||||
|
||||
1. `$CMD get AAAAAAAAAAAAA` — non-existent
|
||||
2. `$CMD put <type-hash> /nonexistent/file.json` — missing file
|
||||
3. `$CMD var set "" <hash>` — empty name
|
||||
4. `$CMD var set "bad name!" <hash>` — invalid name chars
|
||||
5. `$CMD schema put /tmp/bad-schema.json` — `{"type":"invalid"}`
|
||||
6. `$CMD` with no subcommand — should show help
|
||||
7. `$CMD --store /nonexistent/path get <hash>` — bad store path
|
||||
|
||||
## Recording Results
|
||||
|
||||
For each scenario:
|
||||
- ✅ Pass: works as expected
|
||||
- ❌ Fail: unexpected behavior, crash, wrong output
|
||||
- ⚠️ Questionable: works but confusing UX
|
||||
|
||||
Collect all ❌ and ⚠️. For each, record:
|
||||
- Title (concise description)
|
||||
- Command (exact command run)
|
||||
- Expected behavior
|
||||
- Actual behavior (include actual output)
|
||||
- Severity: critical / high / medium / low
|
||||
- Phase: which test phase
|
||||
|
||||
## CRITICAL: Frontmatter Output Format
|
||||
|
||||
Your response MUST start with YAML frontmatter. The `bugs` field MUST be an array of objects, NOT strings.
|
||||
|
||||
Example of CORRECT frontmatter:
|
||||
```yaml
|
||||
---
|
||||
$status: bugs_found
|
||||
containerName: json-cas-e2e
|
||||
repoPath: /path/to/repo
|
||||
bugs:
|
||||
- title: "put does not validate data against schema"
|
||||
command: "json-cas put <hash> bad-data.json"
|
||||
expected: "Validation error, non-zero exit"
|
||||
actual: "Accepted invalid data, exit 0"
|
||||
severity: "high"
|
||||
phase: "Schema Validation"
|
||||
---
|
||||
```
|
||||
|
||||
Do NOT write bugs as plain strings like `- some bug description`. Each bug MUST be an object with all 6 fields.
|
||||
|
||||
If all tests pass:
|
||||
```yaml
|
||||
---
|
||||
$status: all_passed
|
||||
containerName: json-cas-e2e
|
||||
---
|
||||
```
|
||||
|
||||
output: "Summary of all phases with pass/fail counts. Set $status."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "bugs_found" }
|
||||
bugs:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
title: { type: string }
|
||||
command: { type: string }
|
||||
expected: { type: string }
|
||||
actual: { type: string }
|
||||
severity: { type: string }
|
||||
phase: { type: string }
|
||||
required: [title, command, expected, actual, severity, phase]
|
||||
containerName: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, bugs, containerName]
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "all_passed" }
|
||||
containerName: { type: string }
|
||||
required: [$status, containerName]
|
||||
|
||||
reporter:
|
||||
description: "Opens Gitea issues for each bug found by the tester"
|
||||
goal: "You are a bug reporter. You create well-formatted Gitea issues for each bug found during E2E testing."
|
||||
capabilities:
|
||||
- issue-management
|
||||
procedure: |
|
||||
1. Parse the bugs array from the tester's output
|
||||
2. Group bugs by severity (critical first)
|
||||
3. For each bug, create a Gitea issue:
|
||||
```bash
|
||||
tea issues create -r uncaged/json-cas \
|
||||
-t "[E2E] <title>" \
|
||||
-d "## Bug Report (E2E Check)
|
||||
|
||||
**Phase:** <phase>
|
||||
**Severity:** <severity>
|
||||
|
||||
**Command:**
|
||||
\`\`\`
|
||||
<exact command>
|
||||
\`\`\`
|
||||
|
||||
**Expected:** <expected>
|
||||
|
||||
**Actual:** <actual>
|
||||
|
||||
---
|
||||
_Reported by e2e-check workflow (Docker isolated)_"
|
||||
```
|
||||
|
||||
⚠️ If `tea issues create` fails with long body, use Gitea REST API:
|
||||
```bash
|
||||
eval "$(cfg env)" && GITEA_TOKEN=$(cfg get GITEA_TOKEN)
|
||||
curl -s -X POST "https://git.shazhou.work/api/v1/repos/uncaged/json-cas/issues" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"[E2E] ...","body":"..."}'
|
||||
```
|
||||
|
||||
4. Collect created issue numbers
|
||||
5. Include the containerName from your input in your frontmatter output (needed for cleanup)
|
||||
|
||||
output: "List created issues. Set $status."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "reported" }
|
||||
issues:
|
||||
type: array
|
||||
items: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, issues, containerName]
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "partial" }
|
||||
created: { type: number }
|
||||
failed: { type: number }
|
||||
containerName: { type: string }
|
||||
required: [$status, created, failed, containerName]
|
||||
|
||||
cleanup:
|
||||
description: "Stops and removes the Docker container"
|
||||
goal: "You clean up the Docker environment after testing is complete."
|
||||
capabilities:
|
||||
- docker
|
||||
procedure: |
|
||||
Stop and remove the container:
|
||||
```bash
|
||||
docker stop {{{containerName}}} && docker rm {{{containerName}}}
|
||||
```
|
||||
|
||||
Verify it's gone:
|
||||
```bash
|
||||
docker ps -a --filter name={{{containerName}}} --format '{{.Names}}'
|
||||
```
|
||||
Expected: empty output.
|
||||
|
||||
output: "Cleanup result."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "cleaned" }
|
||||
required: [$status]
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "cleanup_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "preparer", prompt: "Set up Docker environment for E2E testing. Repo at {{{repoPath}}}." }
|
||||
preparer:
|
||||
ready: { role: "tester", prompt: "Environment ready. Container: {{{containerName}}}, store: {{{storePath}}}. Run all test scenarios." }
|
||||
setup_failed: { role: "reporter", prompt: "Setup failures found. File these as bugs: {{{failures}}}" }
|
||||
tester:
|
||||
all_passed: { role: "cleanup", prompt: "All tests passed. Clean up container {{{containerName}}}." }
|
||||
bugs_found: { role: "reporter", prompt: "File these bugs as Gitea issues: {{{bugs}}}" }
|
||||
reporter:
|
||||
reported: { role: "cleanup", prompt: "Bugs filed: {{{issues}}}. Clean up container {{{containerName}}}." }
|
||||
partial: { role: "cleanup", prompt: "Filed {{{created}}} issues, {{{failed}}} failed. Clean up container {{{containerName}}}." }
|
||||
cleanup:
|
||||
cleaned: { role: "$END", prompt: "E2E check complete. Environment cleaned up." }
|
||||
cleanup_failed: { role: "$END", prompt: "E2E check complete but cleanup failed: {{{error}}}" }
|
||||
@@ -51,16 +51,19 @@ roles:
|
||||
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "clean" }
|
||||
summary: { type: string }
|
||||
required: [$status, summary]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "findings" }
|
||||
report: { type: string }
|
||||
targetWorkflow: { type: string }
|
||||
required: [$status, report, targetWorkflow]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "wrong_project" }
|
||||
workflowName: { type: string }
|
||||
required: [$status, workflowName]
|
||||
@@ -93,12 +96,14 @@ roles:
|
||||
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "no_action" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
@@ -128,12 +133,14 @@ roles:
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
@@ -157,12 +164,14 @@ roles:
|
||||
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
@@ -191,11 +200,13 @@ roles:
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
+22
-11
@@ -26,12 +26,14 @@ roles:
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
required: [$status]
|
||||
developer:
|
||||
@@ -67,12 +69,14 @@ roles:
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
@@ -105,12 +109,14 @@ roles:
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
@@ -133,16 +139,19 @@ roles:
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
@@ -169,11 +178,13 @@ roles:
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
- type: object
|
||||
properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, 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";
|
||||
|
||||
const pkgPath = resolve(import.meta.dir, "../package.json");
|
||||
const entrypoint = resolve(import.meta.dir, "index.ts");
|
||||
@@ -23,6 +25,25 @@ async function runCli(
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
async function runCliWithStdin(
|
||||
args: string[],
|
||||
storePath: string,
|
||||
stdin: string,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const finalArgs = ["bun", entrypoint, "--store", storePath, ...args];
|
||||
const proc = Bun.spawn(finalArgs, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
});
|
||||
proc.stdin.write(stdin);
|
||||
proc.stdin.end();
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
describe("ucas command alias", () => {
|
||||
test("T1: ucas bin entry exists in package.json", async () => {
|
||||
const pkg = await Bun.file(pkgPath).json();
|
||||
@@ -287,13 +308,14 @@ describe("ucas render command", () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
const { exitCode, stdout } = await runCli(
|
||||
const { exitCode, stderr } = await runCli(
|
||||
["render", "ZZZZZZZZZZZZZ"],
|
||||
tmpStore,
|
||||
);
|
||||
// Missing hash renders as cas: reference
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("cas:ZZZZZZZZZZZZZ");
|
||||
// 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 });
|
||||
}
|
||||
@@ -315,6 +337,594 @@ describe("ucas render command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
|
||||
// Verify node was stored
|
||||
const hash = stdout.trim();
|
||||
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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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(stdout.trim()).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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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(stdout.trim()).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(stdout.trim()).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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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(hasOutput.trim()).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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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(stdout.trim()).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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// 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 { exitCode } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
} 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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
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 { stdout: schemaHash } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
const payloadFile = join(tmpStore, "payload.json");
|
||||
writeFileSync(payloadFile, JSON.stringify({ name: 123 }));
|
||||
|
||||
const { stdout, stderr } = await runCli(
|
||||
["put", schemaHash.trim(), payloadFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 6: CLI Integration with Templates", () => {
|
||||
test("6.1 CLI with Template (Default Parameters)", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
@@ -596,6 +1206,241 @@ describe("Suite 6: CLI Integration with Templates", () => {
|
||||
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: nodeHash } = await runCli(
|
||||
["put", stringType, nodeFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
// Render the valid hash
|
||||
const { exitCode, stdout, stderr } = await runCli(
|
||||
["render", nodeHash.trim()],
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- schema put - invalid schema error handling ----
|
||||
|
||||
describe("schema put - invalid schema error handling", () => {
|
||||
test("invalid schema - unknown type value shows clean error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "invalid-schema.json");
|
||||
writeFileSync(schemaFile, JSON.stringify({ type: "invalid" }));
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Invalid schema");
|
||||
expect(stderr).not.toContain("at ");
|
||||
expect(stdout).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid schema - unknown key shows clean error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "invalid-schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({ type: "string", unknownKey: true }),
|
||||
);
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Invalid schema");
|
||||
expect(stderr).not.toContain("at ");
|
||||
expect(stdout).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid schema - invalid nested schema shows clean error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "invalid-schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "invalid" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Invalid schema");
|
||||
expect(stderr).not.toContain("at ");
|
||||
expect(stdout).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid schema - non-object root shows clean error", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "invalid-schema.json");
|
||||
writeFileSync(schemaFile, JSON.stringify(["type", "string"]));
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain("Invalid schema");
|
||||
expect(stderr).not.toContain("at ");
|
||||
expect(stdout).toBe("");
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("valid schema still works (regression)", async () => {
|
||||
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
|
||||
try {
|
||||
await runCli(["init"], tmpStore);
|
||||
|
||||
const schemaFile = join(tmpStore, "valid-schema.json");
|
||||
writeFileSync(
|
||||
schemaFile,
|
||||
JSON.stringify({
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["name"],
|
||||
}),
|
||||
);
|
||||
|
||||
const { exitCode, stderr, stdout } = await runCli(
|
||||
["schema", "put", schemaFile],
|
||||
tmpStore,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toBe("");
|
||||
expect(stdout.trim()).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
} finally {
|
||||
rmSync(tmpStore, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Store validation - non-existent paths (Issue #55)", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
refs,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
SchemaValidationError,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
validate,
|
||||
@@ -260,8 +261,15 @@ async function cmdSchemaPut(args: string[]): Promise<void> {
|
||||
if (!file) die("Usage: json-cas schema put <file.json>");
|
||||
const schema = readJsonFile(file) as JSONSchema;
|
||||
const store = openStore(true);
|
||||
const hash = await putSchema(store, schema);
|
||||
console.log(hash);
|
||||
try {
|
||||
const hash = await putSchema(store, schema);
|
||||
console.log(hash);
|
||||
} catch (e) {
|
||||
if (e instanceof SchemaValidationError) {
|
||||
die(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSchemaGet(args: string[]): Promise<void> {
|
||||
@@ -311,6 +319,23 @@ async function cmdPut(args: string[]): Promise<void> {
|
||||
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
|
||||
const payload = readJsonFile(file);
|
||||
const store = openStore(true);
|
||||
|
||||
// Check if schema exists
|
||||
const schema = getSchema(store, typeHash);
|
||||
if (schema === null) {
|
||||
console.error(`Schema not found: ${typeHash}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate payload against schema before storing
|
||||
const tempNode = { type: typeHash, payload, timestamp: Date.now() };
|
||||
if (!validate(store, tempNode)) {
|
||||
console.error(
|
||||
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hash = await store.put(typeHash, payload);
|
||||
console.log(hash);
|
||||
}
|
||||
@@ -503,6 +528,9 @@ async function cmdRender(args: string[]): Promise<void> {
|
||||
process.stdout.write(output);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof CasNodeNotFoundError) {
|
||||
die(`Error: Node not found: ${error.hash}`);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
die(error.message);
|
||||
}
|
||||
|
||||
@@ -1241,3 +1241,609 @@ describe("Suite 8: Performance & Scalability", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 9: E2E Template Variable Rendering (Issue #52)", () => {
|
||||
test("9.1 Direct Property Access - Should Render Empty", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema for person object
|
||||
const personSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
});
|
||||
|
||||
// Create node with data
|
||||
const personHash = await store.put(personSchema, {
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
});
|
||||
|
||||
// Register template using direct property access (incorrect syntax)
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"Name: {{ name }}, Age: {{ age }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
|
||||
|
||||
// Render - should produce empty values
|
||||
const output = await renderWithTemplate(store, varStore, personHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("Name: , Age: ");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.2 Correct Syntax with payload Prefix", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema for person object
|
||||
const personSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
});
|
||||
|
||||
// Create node with data
|
||||
const personHash = await store.put(personSchema, {
|
||||
name: "Alice",
|
||||
age: 30,
|
||||
});
|
||||
|
||||
// Register template using correct payload. prefix
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
|
||||
|
||||
// Render - should produce correct values
|
||||
const output = await renderWithTemplate(store, varStore, personHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("Name: Alice, Age: 30");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.3 CLI Render Command - Template Variable Access", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema and node
|
||||
const personSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
});
|
||||
|
||||
const personHash = await store.put(personSchema, {
|
||||
name: "Bob",
|
||||
age: 25,
|
||||
});
|
||||
|
||||
// Register template
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"User: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
|
||||
|
||||
// This simulates the CLI flow
|
||||
const output = await renderWithTemplate(store, varStore, personHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("User: Bob");
|
||||
expect(output).toContain("Age: 25");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.4 Top-Level Primitive Payload - String", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema for simple string
|
||||
const stringSchema = await putSchema(store, { type: "string" });
|
||||
|
||||
// Create node with string payload
|
||||
const stringHash = await store.put(stringSchema, "Hello World");
|
||||
|
||||
// Register template
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"Value is: {{ payload }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${stringSchema}`, templateHash);
|
||||
|
||||
// Render
|
||||
const output = await renderWithTemplate(store, varStore, stringHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("Value is: Hello World");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.5 Top-Level Primitive Payload - Number", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema for number
|
||||
const numberSchema = await putSchema(store, { type: "number" });
|
||||
|
||||
// Create node with number payload
|
||||
const numberHash = await store.put(numberSchema, 42);
|
||||
|
||||
// Register template
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"The answer is {{ payload }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${numberSchema}`, templateHash);
|
||||
|
||||
// Render
|
||||
const output = await renderWithTemplate(store, varStore, numberHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("The answer is 42");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.6 Nested Object Property Access", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema for nested object
|
||||
const userSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create node with nested data
|
||||
const userHash = await store.put(userSchema, {
|
||||
user: {
|
||||
name: "Bob",
|
||||
address: {
|
||||
city: "NYC",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Register template with deep property access
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"User {{ payload.user.name }} lives in {{ payload.user.address.city }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${userSchema}`, templateHash);
|
||||
|
||||
// Render
|
||||
const output = await renderWithTemplate(store, varStore, userHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("User Bob lives in NYC");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.7 Array Property Access and Iteration", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema with array
|
||||
const tagsSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create node with array data
|
||||
const tagsHash = await store.put(tagsSchema, {
|
||||
tags: ["javascript", "typescript", "bun"],
|
||||
});
|
||||
|
||||
// Register template with array iteration
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"Tags: {% for tag in payload.tags %}{{ tag }}{% unless forloop.last %}, {% endunless %}{% endfor %}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${tagsSchema}`, templateHash);
|
||||
|
||||
// Render
|
||||
const output = await renderWithTemplate(store, varStore, tagsHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("Tags: javascript, typescript, bun");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.8 Missing Property Access - Graceful Handling", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema
|
||||
const personSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
// Create node without age property
|
||||
const personHash = await store.put(personSchema, {
|
||||
name: "Alice",
|
||||
});
|
||||
|
||||
// Register template that references missing property
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"Name: {{ payload.name }}, Age: {{ payload.age }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
|
||||
|
||||
// Render - age should be empty
|
||||
const output = await renderWithTemplate(store, varStore, personHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("Name: Alice, Age: ");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.9 Null Property Value Rendering", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema allowing null
|
||||
const personSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
email: { type: ["string", "null"] },
|
||||
},
|
||||
});
|
||||
|
||||
// Create node with null email
|
||||
const personHash = await store.put(personSchema, {
|
||||
name: "Charlie",
|
||||
email: null,
|
||||
});
|
||||
|
||||
// Register template
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"Name: {{ payload.name }}, Email: {{ payload.email }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${personSchema}`, templateHash);
|
||||
|
||||
// Render - email should be empty
|
||||
const output = await renderWithTemplate(store, varStore, personHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("Name: Charlie, Email: ");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.10 Boolean Property Rendering", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema with boolean
|
||||
const userSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
active: { type: "boolean" },
|
||||
},
|
||||
});
|
||||
|
||||
// Create node with boolean
|
||||
const userHash = await store.put(userSchema, {
|
||||
name: "Dave",
|
||||
active: true,
|
||||
});
|
||||
|
||||
// Register template with conditional
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"User {{ payload.name }} is {% if payload.active %}active{% else %}inactive{% endif %}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${userSchema}`, templateHash);
|
||||
|
||||
// Render
|
||||
const output = await renderWithTemplate(store, varStore, userHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("User Dave is active");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.11 Zero and Empty String Values", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema
|
||||
const dataSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
count: { type: "number" },
|
||||
},
|
||||
});
|
||||
|
||||
// Create node with empty string and zero
|
||||
const dataHash = await store.put(dataSchema, {
|
||||
name: "",
|
||||
count: 0,
|
||||
});
|
||||
|
||||
// Register template
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"Name: '{{ payload.name }}', Count: {{ payload.count }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${dataSchema}`, templateHash);
|
||||
|
||||
// Render - zero and empty string should appear
|
||||
const output = await renderWithTemplate(store, varStore, dataHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toBe("Name: '', Count: 0");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("9.12 Special Characters in String Values", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create schema
|
||||
const textSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
// Create node with special characters
|
||||
const textHash = await store.put(textSchema, {
|
||||
text: 'Hello "World" & <tag>',
|
||||
});
|
||||
|
||||
// Register template
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const templateHash = await store.put(
|
||||
templateSchema,
|
||||
"Text: {{ payload.text }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${textSchema}`, templateHash);
|
||||
|
||||
// Render
|
||||
const output = await renderWithTemplate(store, varStore, textHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain('Hello "World" & <tag>');
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 10: Context Variable Completeness", () => {
|
||||
test("10.1 Context Propagation in Recursive Renders", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create child schema and node
|
||||
const childSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, { name: "child" });
|
||||
|
||||
// Create parent schema and node
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
name: "parent",
|
||||
child: childHash,
|
||||
});
|
||||
|
||||
// Register parent template
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const parentTemplateHash = await store.put(
|
||||
templateSchema,
|
||||
"Parent: {{ payload.name }}\n{% render payload.child %}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplateHash);
|
||||
|
||||
// Register child template that accesses context variables
|
||||
const childTemplateHash = await store.put(
|
||||
templateSchema,
|
||||
"Child: {{ payload.name }}, Hash: {{ hash }}, Resolution: {{ resolution }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${childSchema}`, childTemplateHash);
|
||||
|
||||
// Render
|
||||
const output = await renderWithTemplate(store, varStore, parentHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("Parent: parent");
|
||||
expect(output).toContain("Child: child");
|
||||
expect(output).toContain(`Hash: ${childHash}`);
|
||||
expect(output).toContain("Resolution: 0.5");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("10.2 Context Isolation Between Parent and Child", async () => {
|
||||
const { store, varStore, cleanup } = await createTempVarStore();
|
||||
|
||||
try {
|
||||
// Create child schema and node
|
||||
const childSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
custom: { type: "string" },
|
||||
},
|
||||
});
|
||||
const childHash = await store.put(childSchema, {
|
||||
custom: "child_value",
|
||||
});
|
||||
|
||||
// Create parent schema and node
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
custom: { type: "string" },
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
custom: "parent_value",
|
||||
child: childHash,
|
||||
});
|
||||
|
||||
// Register parent template
|
||||
const templateSchema = await putSchema(store, { type: "string" });
|
||||
const parentTemplateHash = await store.put(
|
||||
templateSchema,
|
||||
"Parent custom: {{ payload.custom }}\n{% render payload.child %}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${parentSchema}`, parentTemplateHash);
|
||||
|
||||
// Register child template
|
||||
const childTemplateHash = await store.put(
|
||||
templateSchema,
|
||||
"Child custom: {{ payload.custom }}",
|
||||
);
|
||||
varStore.set(`@ucas/template/text/${childSchema}`, childTemplateHash);
|
||||
|
||||
// Render
|
||||
const output = await renderWithTemplate(store, varStore, parentHash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.5,
|
||||
epsilon: 0.01,
|
||||
});
|
||||
|
||||
expect(output).toContain("Parent custom: parent_value");
|
||||
expect(output).toContain("Child custom: child_value");
|
||||
expect(output).not.toContain("Child custom: parent_value");
|
||||
} finally {
|
||||
varStore.close();
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bootstrap } from "./bootstrap.js";
|
||||
import { render, renderDirect } from "./render.js";
|
||||
import { render, renderAsync, renderDirect } from "./render.js";
|
||||
import { putSchema } from "./schema.js";
|
||||
import { createMemoryStore } from "./store.js";
|
||||
import type { Hash } from "./types.js";
|
||||
import { CasNodeNotFoundError } from "./variable-store.js";
|
||||
|
||||
describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
test("1.1 Render Simple Primitives", async () => {
|
||||
@@ -65,13 +66,14 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
|
||||
expect(output.trim()).toBe(`cas:${hash}`);
|
||||
});
|
||||
|
||||
test("1.5 Render Non-existent Hash", () => {
|
||||
test("1.5 Render Non-existent Hash Throws Error", () => {
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
|
||||
// Non-existent node renders as cas: reference
|
||||
const output = render(store, fakeHash);
|
||||
expect(output.trim()).toBe(`cas:${fakeHash}`);
|
||||
// Non-existent root node should throw
|
||||
expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError);
|
||||
expect(() => render(store, fakeHash)).toThrow("CAS node not found");
|
||||
expect(() => render(store, fakeHash)).toThrow(fakeHash);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -793,9 +795,10 @@ describe("Suite 6: Schema Integration", () => {
|
||||
|
||||
test("6.5 Schema-less Node (Bootstrap Node)", async () => {
|
||||
const store = createMemoryStore();
|
||||
const metaHash = await bootstrap(store);
|
||||
const types = await bootstrap(store);
|
||||
const schemaHash = types["@schema"];
|
||||
|
||||
const output = render(store, metaHash);
|
||||
const output = render(store, schemaHash);
|
||||
|
||||
// Should render without recursive expansion
|
||||
expect(output).toBeTruthy();
|
||||
@@ -1055,3 +1058,95 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
|
||||
expect(output).toContain("key: val");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
|
||||
test("10.1 renderAsync() throws CasNodeNotFoundError for missing root hash", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
const fakeHash = "AAAAAAAAAAAAA" as Hash;
|
||||
|
||||
await expect(renderAsync(store, fakeHash)).rejects.toThrow(
|
||||
CasNodeNotFoundError,
|
||||
);
|
||||
await expect(renderAsync(store, fakeHash)).rejects.toThrow(
|
||||
"CAS node not found",
|
||||
);
|
||||
await expect(renderAsync(store, fakeHash)).rejects.toThrow(fakeHash);
|
||||
});
|
||||
|
||||
test("10.2 render() throws CasNodeNotFoundError for missing root hash", () => {
|
||||
const store = createMemoryStore();
|
||||
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
|
||||
expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError);
|
||||
expect(() => render(store, fakeHash)).toThrow("CAS node not found");
|
||||
expect(() => render(store, fakeHash)).toThrow(fakeHash);
|
||||
});
|
||||
|
||||
test("10.3 renderDirect() does NOT throw for non-existent type hash", () => {
|
||||
const store = createMemoryStore();
|
||||
const fakeTypeHash = "0000000000000" as Hash;
|
||||
const output = renderDirect(fakeTypeHash, { key: "value" }, store, null);
|
||||
|
||||
expect(output).toContain("key: value");
|
||||
});
|
||||
|
||||
test("10.4 Missing nested node renders as cas: reference (no error)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const parentSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
child: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
});
|
||||
|
||||
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
|
||||
const parentHash = await store.put(parentSchema, {
|
||||
title: "root",
|
||||
child: fakeChildHash,
|
||||
});
|
||||
|
||||
const output = render(store, parentHash);
|
||||
|
||||
expect(output).toContain("title: root");
|
||||
expect(output).toContain(`cas:${fakeChildHash}`);
|
||||
});
|
||||
|
||||
test("10.5 Resolution below epsilon renders as cas: reference (no error)", async () => {
|
||||
const store = createMemoryStore();
|
||||
await bootstrap(store);
|
||||
|
||||
const nodeSchema = await putSchema(store, {
|
||||
type: "object",
|
||||
properties: {
|
||||
level: { type: "number" },
|
||||
next: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create 3-level chain
|
||||
let currentHash: Hash | null = null;
|
||||
for (let i = 2; i >= 0; i--) {
|
||||
currentHash = await store.put(nodeSchema, {
|
||||
level: i,
|
||||
next: currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
const output = render(store, currentHash as Hash, {
|
||||
resolution: 1.0,
|
||||
decay: 0.1,
|
||||
epsilon: 0.5,
|
||||
});
|
||||
|
||||
// Level 0 should be expanded (resolution = 1.0 > 0.5)
|
||||
expect(output).toContain("level: 0");
|
||||
// Level 1+ should be cas: references (0.1, 0.01 < 0.5)
|
||||
expect(output).toContain("cas:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { renderWithTemplate } from "./liquid-render.js";
|
||||
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
|
||||
import type { Hash, Store } from "./types.js";
|
||||
import type { VariableStore } from "./variable-store.js";
|
||||
import { CasNodeNotFoundError } from "./variable-store.js";
|
||||
|
||||
export type RenderOptions = {
|
||||
resolution?: number; // (0, 1], default 1.0
|
||||
@@ -55,6 +56,11 @@ export function render(
|
||||
): string {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
// Check if root node exists
|
||||
if (store.get(hash) === null) {
|
||||
throw new CasNodeNotFoundError(hash);
|
||||
}
|
||||
|
||||
const visited = new Set<Hash>();
|
||||
return renderNode(store, hash, resolution, decay, epsilon, visited);
|
||||
}
|
||||
@@ -70,6 +76,12 @@ export async function renderAsync(
|
||||
options?: RenderOptions,
|
||||
): Promise<string> {
|
||||
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
|
||||
|
||||
// Check if root node exists
|
||||
if (store.get(hash) === null) {
|
||||
throw new CasNodeNotFoundError(hash);
|
||||
}
|
||||
|
||||
const varStore = options?.varStore;
|
||||
|
||||
// If varStore provided, try template rendering first
|
||||
|
||||
@@ -36,8 +36,11 @@ export class SchemaMismatchError extends Error {
|
||||
}
|
||||
|
||||
export class CasNodeNotFoundError extends Error {
|
||||
constructor(hash: string) {
|
||||
super(`CAS node not found: ${hash}`);
|
||||
constructor(
|
||||
public readonly hash: string,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `CAS node not found: ${hash}`);
|
||||
this.name = "CasNodeNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user