import { afterEach, beforeEach, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type BusEvent, defineEvents, openBus } from '@celilo/event-bus'; import { type WireInterview, startRemoteResponder } from './remote-responder'; const NO_SCHEMAS = defineEvents({}); let dir: string; let busDbPath: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'celilo-remresp-')); busDbPath = join(dir, 'events.db'); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); test('forwards an interview.required question to ask and replies with the answer', async () => { const asked: WireInterview[] = []; const responder = startRemoteResponder({ busDbPath, ask: async (iv) => { asked.push(iv); return 'acme.example.com'; }, }); const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS }); try { const replies = (await bus.query( 'interview.required.site.hostname' as never, { scope: 'site', key: 'hostname', kind: 'text', message: 'Hostname for the site?', required: true, } as never, { timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never, )) as BusEvent[]; expect(replies).toHaveLength(1); expect((replies[0].payload as { value: unknown }).value).toBe('acme.example.com'); expect(asked).toHaveLength(1); expect(asked[0].message).toBe('Hostname for the site?'); expect(asked[0].kind).toBe('text'); } finally { bus.close(); responder.close(); } }, 15_000); test('answers responder.probe with kind daemon', async () => { const responder = startRemoteResponder({ busDbPath, ask: async () => 'unused' }); const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS }); try { const replies = (await bus.query( 'responder.probe' as never, {} as never, { timeoutMs: 8000, pollIntervalMs: 100, expect: 'first', } as never, )) as BusEvent[]; expect(replies).toHaveLength(1); expect((replies[0].payload as { kind: string }).kind).toBe('daemon'); } finally { bus.close(); responder.close(); } }, 15_000); /** * A responder that cannot reach a decider must emit NOTHING (celilo#609). * * PR #607 had it reply `{error}` here, which reads as "fail loudly" but carries * `replyFor: ` — it *consumes the query*. The question then no longer * exists for anyone else to answer, and the command dies with it. Leaving it * unanswered parks the command instead, so a later responder can still decide. * * Asserted on the value that comes back, not on liveness: the reply emitted * afterwards correlates to the original query, which is only true if the * responder left it standing. */ test('an ask that cannot be answered emits no reply — the query stays answerable', async () => { const responder = startRemoteResponder({ busDbPath, ask: async () => { throw new Error("stdin isn't a terminal and no answer was pre-staged"); }, }); const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS }); const type = 'interview.required.module-upgrade:iptables.apply_breaking'; try { const query = bus.emitRaw(type, { scope: 'module-upgrade:iptables', key: 'apply_breaking', kind: 'confirm', message: 'Apply breaking update for iptables?', required: true, defaultValue: 'false', }); // Well past the responder's watch latency: still nobody has answered. await new Promise((r) => setTimeout(r, 600)); expect(bus.recentEvents({ type: `${type}.reply` })).toHaveLength(0); // And the query is still live: a reply emitted now is a genuine answer. bus.emitRaw( `${type}.reply`, { value: false }, { replyFor: query.id, emittedBy: 'later-responder' }, ); const replies = bus.recentEvents({ type: `${type}.reply` }); expect(replies).toHaveLength(1); expect((replies[0].payload as { value: unknown }).value).toBe(false); expect(replies[0].replyFor).toBe(query.id); } finally { bus.close(); responder.close(); } }, 15_000); test('forwards the question scope/key so a client can pre-stage an answer', async () => { const asked: WireInterview[] = []; const responder = startRemoteResponder({ busDbPath, ask: async (iv) => { asked.push(iv); return true; }, }); const bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS }); try { await bus.query( 'interview.required.module-upgrade:iptables.apply_breaking' as never, { scope: 'module-upgrade:iptables', key: 'apply_breaking', kind: 'confirm', message: 'Apply breaking update for iptables?', required: true, } as never, { timeoutMs: 8000, pollIntervalMs: 100, expect: 'first' } as never, ); expect(asked[0].scope).toBe('module-upgrade:iptables'); expect(asked[0].key).toBe('apply_breaking'); } finally { bus.close(); responder.close(); } }, 15_000);