/** * Recurrence gate for ISS-0025: a headless deploy interview must fail fast * instead of hanging forever when no responder is listening. The deploy path * routes every interview query through `busInterviewGuarded`, which calls * `ensureResponderForInterview` — so this guard is the choke point to protect. */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { defineEvents, openBus } from '@celilo/event-bus'; import { ensureResponderForInterview } from './responder-probe'; const NO_SCHEMAS = defineEvents({}); function setTTY(value: boolean | undefined): void { Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }); } describe('ensureResponderForInterview (ISS-0025)', () => { let dir: string; let dbPath: string; let origEnv: string | undefined; let origTTY: boolean | undefined; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'responder-guard-')); dbPath = join(dir, 'events.db'); origEnv = process.env.EVENT_BUS_DB; process.env.EVENT_BUS_DB = dbPath; origTTY = process.stdin.isTTY; }); afterEach(() => { if (origEnv === undefined) delete process.env.EVENT_BUS_DB; else process.env.EVENT_BUS_DB = origEnv; setTTY(origTTY); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('throws an actionable error when non-TTY and no responder is listening', async () => { setTTY(false); // No responder on the bus → the probe times out → fail fast, not a hang. await expect(ensureResponderForInterview('config.required.foo.bar')).rejects.toThrow( /No responder is listening/, ); }); it('names the blocked prompt and the remediations in the error', async () => { setTTY(false); let message = ''; try { await ensureResponderForInterview('secret.required.caddy.api_token'); } catch (err) { message = err instanceof Error ? err.message : String(err); } expect(message).toContain('secret.required.caddy.api_token'); expect(message).toContain('celilo events respond'); }); it('resolves when a responder answers the probe', async () => { setTTY(false); const responder = openBus({ dbPath, events: NO_SCHEMAS }); responder.watch('responder.probe', (event) => { responder.emitRaw( `${event.type}.reply`, { kind: 'programmatic', emittedBy: 'test' }, { replyFor: event.id, emittedBy: 'test' }, ); }); await expect(ensureResponderForInterview('config.required.foo.bar')).resolves.toBeUndefined(); responder.close(); }); it('skips the probe entirely on a TTY (the terminal-responder will answer)', async () => { setTTY(true); // No responder running, but a TTY short-circuits before probing — the // built-in terminal-responder is the responder. await expect(ensureResponderForInterview('config.required.foo.bar')).resolves.toBeUndefined(); }); });