Files
obelisk/tests/runtime-cli-envelope.test.mjs
T

122 lines
4.6 KiB
JavaScript
Raw Normal View History

// 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 tolerates FTS-special input via safe tokenization', () => {
// A hyphenated term is FTS5 operator syntax. search() falls back to safe
// per-token quoting instead of crashing, so the CLI returns an array, not an
// error. (The uniform { error, stack } envelope is exercised via --query/--attune.)
const home = tempHome();
const result = runRuntime(['--search', 'foo-bar'], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.ok(Array.isArray(JSON.parse(result.stdout)), 'search must return a JSON array');
});