124 lines
4.6 KiB
JavaScript
124 lines
4.6 KiB
JavaScript
// 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');
|
||
|
|
});
|
||
|
|
|