// Tier 1 contract golden tests (see docs/adr/0002-two-tier-runtime-contract.md). // // These lock the four-verb CLI I/O envelope at the process boundary so the // upcoming TypeScript / runtime-core refactor cannot silently change what an // agent (or the skill/CLI/MCP transports) observes on stdout: // --build -> { ok: true, db } // --search -> JSON array // --query -> pretty-printed JSON result, or { error, stack } + exit 1 on throw // --attune -> pretty-printed JSON result, or { error, stack } + exit 1 on throw // // The sandbox contract (query cannot call attune helpers, attune exposes only // remember/forget, etc.) is covered separately in runtime.test.mjs; this file is // only about the transport envelope. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); function runRuntime(args, { home }) { return spawnSync(process.execPath, ['scripts/runtime.mjs', ...args], { cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8', }); } function tempHome() { const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-envelope-')); mkdirSync(join(home, '.claude'), { recursive: true }); return home; } test('--build emits { ok: true, db } pointing at the resolved db path', () => { const home = tempHome(); const result = runRuntime(['--build'], { home }); assert.equal(result.status, 0, result.stderr || result.stdout); const payload = JSON.parse(result.stdout); assert.equal(payload.ok, true); assert.equal(typeof payload.db, 'string'); assert.ok( payload.db.endsWith(join('.obelisk', 'obelisk.sqlite')), `db path should resolve under HOME/.obelisk, got ${payload.db}`, ); }); test('--search emits a JSON array envelope', () => { const home = tempHome(); const result = runRuntime(['--search', 'zzznomatchzzz'], { home }); assert.equal(result.status, 0, result.stderr || result.stdout); const payload = JSON.parse(result.stdout); assert.ok(Array.isArray(payload), 'search must return a JSON array'); }); test('--query returns a pretty-printed JSON result on success', () => { const home = tempHome(); const scriptPath = join(home, 'ok.mjs'); writeFileSync(scriptPath, 'return { answer: 42 };'); const result = runRuntime(['--query', scriptPath], { home }); assert.equal(result.status, 0, result.stderr || result.stdout); assert.deepEqual(JSON.parse(result.stdout), { answer: 42 }); // Pretty-printed with two-space indentation (JSON.stringify(r, null, 2)). assert.match(result.stdout, /\n {2}"answer": 42/); }); test('--query surfaces a throw as { error, stack } and exits 1', () => { const home = tempHome(); const scriptPath = join(home, 'boom.mjs'); writeFileSync(scriptPath, "throw new Error('boom-envelope');"); const result = runRuntime(['--query', scriptPath], { home }); assert.equal(result.status, 1); const payload = JSON.parse(result.stdout); assert.equal(payload.error, 'boom-envelope'); assert.equal(typeof payload.stack, 'string'); }); test('--attune surfaces a throw as { error, stack } and exits 1', () => { const home = tempHome(); const scriptPath = join(home, 'attune-boom.mjs'); writeFileSync(scriptPath, "throw new Error('attune-envelope');"); const result = runRuntime(['--attune', scriptPath], { home }); assert.equal(result.status, 1); const payload = JSON.parse(result.stdout); assert.equal(payload.error, 'attune-envelope'); assert.equal(typeof payload.stack, 'string'); }); test('unknown verb prints usage to stderr and exits non-zero', () => { const home = tempHome(); const result = runRuntime(['--nonsense'], { home }); assert.notEqual(result.status, 0); assert.equal(result.stdout, ''); assert.match(result.stderr, /Usage:/); assert.match(result.stderr, /--build/); }); test('--search shares the { error, stack } envelope instead of crashing to stderr', () => { // A hyphenated term is FTS5 syntax and makes search() throw. The uniform // error envelope (see docs/adr/0002) requires this surface as { error, stack } // on stdout with exit 1 — the same shape as --query/--attune — not a raw crash. const home = tempHome(); const result = runRuntime(['--search', 'foo-bar'], { home }); assert.equal(result.status, 1); const payload = JSON.parse(result.stdout); assert.equal(typeof payload.error, 'string'); assert.equal(typeof payload.stack, 'string'); });