Files
buzz/beehive/test/binding-preview.test.ts
2026-09-10 04:27:03 -04:00

114 lines
6.5 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { existsSync, mkdtempSync, realpathSync, mkdirSync, readFileSync, statSync, rmSync, rmdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { newKey, publicKey, message, type Message } from '../src/protocol.ts';
import { readPrivate, writePrivate } from '../src/storage.ts';
import { bindingFingerprint, host, provision } from '../src/host.ts';
import { relay } from '../src/relay.ts';
import { connect } from '../src/client.ts';
import { installationSlots, bindingConfirmation, retireHarnessBinding, addHarnessBinding, addSlot, migrateSlots, removeSlotKey } from '../src/slots.ts';
import { createGenesis } from '../src/assignment.ts';
import { hostReady } from './host-driver.ts';
import { provisionSetup } from './provision.ts';
import { profileRevision } from '../src/profiles.ts';
async function until(check: () => boolean) {
for (let i = 0; i < 600; i++) { if (check()) return; await delay(20); }
throw Error('Missing named-configuration evidence');
}
type Step = { prompt: string; answer: string; gate?: () => boolean; observedRevision?: number };
async function terminal(args: string[], steps: Step[], expectedCode = 0) {
const child = spawn(process.execPath, ['src/cli.ts', ...args], { stdio: ['pipe', 'pipe', 'pipe'] });
let output = '', error = '', cursor = 0, index = 0, pending = false;
child.stderr.on('data', c => { error += c.toString(); });
let failure: unknown;
child.stdout.on('data', c => {
output += c.toString(); const step = steps[index];
if (!step || pending) return;
const position = output.indexOf(step.prompt, cursor); if (position < 0) return;
pending = true;
void (async () => {
if (step.gate) await until(step.gate);
if (step.observedRevision !== undefined) {
// Journal commit is not proof the remote TUI has consumed inventory.
const deadline = Date.now() + 8000;
for (;;) {
const from = output.length; child.stdin.write('show\n');
await until(() => output.indexOf('beehive> ', from) >= 0);
if (output.slice(from).includes(`\n "revision": ${step.observedRevision},\n`)) break;
assert.ok(Date.now() < deadline, 'TUI did not observe committed revision');
await delay(20);
}
}
await delay(80); cursor = output.length; index++; pending = false;
child.stdin.write(`${step.answer}\n`);
})().catch(e => { failure = e; child.kill('SIGTERM'); });
});
const timer = setTimeout(() => child.kill('SIGTERM'), 25000);
try {
const [code] = await once(child, 'exit');
if (failure) throw new Error(`TUI step ${index} (${steps[index]?.answer}): ${String(failure)}\n${error}\n${output.slice(-24000)}`);
assert.equal(code, expectedCode, `${error}\n${output}`); assert.equal(index, steps.length, output);
return output;
} finally { clearTimeout(timer); if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); }
}
for (const action of ['retire-binding', 'replace-binding']) for (const stale of [false, true]) test(`${action}: coherent affected sibling preview and ${stale ? 'post-preview mutation rejection' : 'unchanged confirmation'}`, { timeout: 15000 }, async () => {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'bh-binding-preview-')));
try {
const ownerSecret = newKey(), agentSecret = newKey();
provisionSetup(join(dir, 'setup.json'), { host: 'preview', ownerSecret, agentSecret, runner: realpathSync(process.execPath), args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' });
migrateSlots(dir);
const original = installationSlots(dir)[0]!;
const originalJournal = readFileSync(original.path);
const oldFingerprint = bindingFingerprint(original.setup);
const siblingSecret = newKey(), sibling = publicKey(siblingSecret);
let retained: Buffer, manifest: Buffer;
const afterPreview = () => {
assert.equal(existsSync(join(dir, 'host.lock')), false, 'no lock across human think time');
if (stale) {
const secret = newKey();
addSlot(dir, secret, createGenesis(publicKey(ownerSecret), publicKey(secret), 'preview'));
}
manifest = readFileSync(join(dir, 'setup.json'));
return true;
};
const output = await terminal(['local-setup', dir], [
{ prompt: 'Local action [', answer: action, gate: () => {
addSlot(dir, siblingSecret, createGenesis(publicKey(ownerSecret), sibling, 'preview'));
retained = readFileSync(installationSlots(dir).find(e => e.agent === sibling)!.path);
return true;
} },
{ prompt: 'Existing binding ID to reuse: ', answer: 'default' },
...(action === 'retire-binding' ? [
{ prompt: 'Retire default for new selection/execution, preserving history? [yes/no]: ', answer: 'yes', gate: afterPreview },
] : [
{ prompt: 'NEW immutable binding ID: ', answer: 'replacement', gate: afterPreview },
{ prompt: 'Absolute compatible executable: ', answer: realpathSync(process.execPath) },
{ prompt: 'Allowed workspace (absolute directory): ', answer: dir },
{ prompt: 'Absolute fixture TypeScript script: ', answer: resolve('test/runner.ts') },
{ prompt: 'Retire default and replace with replacement, without selecting it? [yes/no]: ', answer: 'yes' },
{ prompt: 'Save NEW binding only (no selection, key change or restart)? [yes/no]: ', answer: 'yes' },
]),
], stale ? 1 : 0);
assert.ok(output.includes(`Agent ${sibling}: affected choices default; selected true`), 'new affected sibling must be displayed');
const current = installationSlots(dir)[0]!;
assert.equal(bindingFingerprint(current.bindings.default!), oldFingerprint);
assert.deepEqual(readFileSync(original.path), originalJournal);
assert.deepEqual(readFileSync(installationSlots(dir).find(e => e.agent === sibling)!.path), retained!);
if (stale) {
assert.deepEqual(readFileSync(join(dir, 'setup.json')), manifest!, 'stale confirmation must be inert');
assert.equal(current.retiredBindings?.default, undefined);
assert.equal(current.bindings.replacement, undefined);
} else {
assert.equal(current.retiredBindings?.default, oldFingerprint);
if (action === 'replace-binding') assert.ok(current.bindings.replacement);
}
} finally { rmSync(dir, { recursive:true, force:true }); }
});