From 25537d3a53b362118a7926c704c8ca1df936f92d Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Wed, 8 Jul 2026 16:20:48 +0800 Subject: [PATCH] test: lock runtime CLI I/O envelope; unify --build/--search error handling --- scripts/runtime.mjs | 26 ++++-- tests/runtime-cli-envelope.test.mjs | 123 ++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 tests/runtime-cli-envelope.test.mjs diff --git a/scripts/runtime.mjs b/scripts/runtime.mjs index 9119a3e..e3c1546 100644 --- a/scripts/runtime.mjs +++ b/scripts/runtime.mjs @@ -28,16 +28,30 @@ function executeAttune(db, scriptContent) { function main() { const args = process.argv.slice(2); + // Uniform error envelope across all four verbs: a failure is reported as + // { error, stack } on stdout with exit code 1, never a raw crash on stderr. + const fail = (e) => { + process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); + process.exitCode = 1; + }; if (args[0] === '--build') { - buildIndex({ force: true }); - process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n'); + try { + buildIndex({ force: true }); + process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n'); + } catch (e) { fail(e); } return; } if (args[0] === '--search' && args[1]) { - buildIndex(); - const db = openDb(); - process.stdout.write(JSON.stringify(createQueryApi(db).search(args.slice(1).join(' ')), null, 2) + '\n'); - db.close(); + let db; + try { + buildIndex(); + db = openDb(); + process.stdout.write(JSON.stringify(createQueryApi(db).search(args.slice(1).join(' ')), null, 2) + '\n'); + } catch (e) { + fail(e); + } finally { + if (db) db.close(); + } return; } if (args[0] === '--query' && args[1]) { diff --git a/tests/runtime-cli-envelope.test.mjs b/tests/runtime-cli-envelope.test.mjs new file mode 100644 index 0000000..fe7653d --- /dev/null +++ b/tests/runtime-cli-envelope.test.mjs @@ -0,0 +1,123 @@ +// 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'); +}); +