test: lock runtime CLI I/O envelope; unify --build/--search error handling
This commit is contained in:
+16
-2
@@ -28,16 +28,30 @@ function executeAttune(db, scriptContent) {
|
|||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const args = process.argv.slice(2);
|
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') {
|
if (args[0] === '--build') {
|
||||||
|
try {
|
||||||
buildIndex({ force: true });
|
buildIndex({ force: true });
|
||||||
process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n');
|
process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n');
|
||||||
|
} catch (e) { fail(e); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (args[0] === '--search' && args[1]) {
|
if (args[0] === '--search' && args[1]) {
|
||||||
|
let db;
|
||||||
|
try {
|
||||||
buildIndex();
|
buildIndex();
|
||||||
const db = openDb();
|
db = openDb();
|
||||||
process.stdout.write(JSON.stringify(createQueryApi(db).search(args.slice(1).join(' ')), null, 2) + '\n');
|
process.stdout.write(JSON.stringify(createQueryApi(db).search(args.slice(1).join(' ')), null, 2) + '\n');
|
||||||
db.close();
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
} finally {
|
||||||
|
if (db) db.close();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (args[0] === '--query' && args[1]) {
|
if (args[0] === '--query' && args[1]) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user