import { describe, expect, test } from 'bun:test'; import { MissingProviderInputError, isMissingProviderInputError } from '@celilo/capabilities'; import { type ChildFrame, HOOK_PROTOCOL_VERSION, type ParentFrame, createLineReader, deserializeError, encodeFrame, parseChildFrame, parseParentFrame, serializeError, versionMismatch, } from './hook-protocol'; const CHILD_FRAMES: ChildFrame[] = [ { type: 'ready', protocolVersion: HOOK_PROTOCOL_VERSION }, { type: 'call', id: 'c1', capability: 'public_web', method: 'register_route', args: [{ path: '/x' }], }, { type: 'log', level: 'info', message: 'hello' }, { type: 'log', level: 'success', message: 'done' }, { type: 'result', outputs: { api_key: 'k' } }, { type: 'throw', error: { name: 'Error', message: 'boom', stack: 'at x' } }, ]; const PARENT_FRAMES: ParentFrame[] = [ { type: 'context', protocolVersion: HOOK_PROTOCOL_VERSION, scriptPath: '/m/scripts/h.ts', context: { config: { a: 1 }, secrets: {}, systems: [], debug: false, screenshotDir: '/tmp/a' }, }, { type: 'capabilities', shape: { firewall: { methods: ['exposeService'], data: { providerModuleId: 'iptables' } } }, }, { type: 'return', id: 'c1', value: { success: true } }, { type: 'throw', id: 'c1', error: { name: 'Error', message: 'nope' } }, ]; describe('hook protocol', () => { describe('round trip', () => { for (const frame of CHILD_FRAMES) { test(`child ${frame.type}${'level' in frame ? `/${frame.level}` : ''}`, () => { const parsed = parseChildFrame(encodeFrame(frame).trimEnd()); expect(parsed.ok).toBe(true); if (parsed.ok) expect(parsed.frame).toEqual(frame); }); } for (const frame of PARENT_FRAMES) { test(`parent ${frame.type}`, () => { const parsed = parseParentFrame(encodeFrame(frame).trimEnd()); expect(parsed.ok).toBe(true); if (parsed.ok) expect(parsed.frame).toEqual(frame); }); } test('every frame ends in exactly one newline', () => { for (const frame of [...CHILD_FRAMES, ...PARENT_FRAMES]) { const encoded = encodeFrame(frame); expect(encoded.endsWith('\n')).toBe(true); expect(encoded.slice(0, -1)).not.toContain('\n'); } }); }); describe('malformed input is a value, never a throw', () => { // The boundary's whole claim is that the child cannot take celilo down. A // reader that throws on a bad line hands that back. const bad = [ '', 'not json at all', '{', '{"type":"nope"}', '{"type":"call"}', 'null', '[]', '"a string"', ]; for (const line of bad) { test(`child reader survives ${JSON.stringify(line)}`, () => { const parsed = parseChildFrame(line); expect(parsed.ok).toBe(false); if (!parsed.ok) expect(parsed.error.length).toBeGreaterThan(0); }); } test('a very long malformed line is truncated in the message', () => { const parsed = parseChildFrame('x'.repeat(5000)); expect(parsed.ok).toBe(false); if (!parsed.ok) expect(parsed.error.length).toBeLessThan(200); }); test('a parent frame is not a child frame', () => { expect(parseChildFrame(encodeFrame(PARENT_FRAMES[1]).trimEnd()).ok).toBe(false); }); }); describe('line reader', () => { test('reassembles a frame split across chunks', () => { const lines: string[] = []; const feed = createLineReader((l) => lines.push(l)); const encoded = encodeFrame(CHILD_FRAMES[1]); feed(encoded.slice(0, 7)); expect(lines).toEqual([]); feed(encoded.slice(7)); expect(lines).toHaveLength(1); expect(parseChildFrame(lines[0]).ok).toBe(true); }); test('splits several frames arriving in one chunk', () => { const lines: string[] = []; const feed = createLineReader((l) => lines.push(l)); feed(CHILD_FRAMES.map(encodeFrame).join('')); expect(lines).toHaveLength(CHILD_FRAMES.length); }); test('holds a partial tail rather than emitting it', () => { const lines: string[] = []; const feed = createLineReader((l) => lines.push(l)); feed(`${encodeFrame(CHILD_FRAMES[0])}{"type":"log"`); expect(lines).toHaveLength(1); }); }); describe('handshake', () => { test('matching versions pass', () => { expect(versionMismatch(HOOK_PROTOCOL_VERSION, 'the hook runner')).toBeNull(); }); test('a mismatch names both numbers', () => { const message = versionMismatch(99, 'the hook runner'); expect(message).toContain('99'); expect(message).toContain(String(HOOK_PROTOCOL_VERSION)); expect(message).toContain('the hook runner'); }); }); describe('errors', () => { test('a plain Error keeps name, message and stack', () => { const rebuilt = deserializeError(serializeError(new TypeError('bad shape'))); expect(rebuilt.name).toBe('TypeError'); expect(rebuilt.message).toBe('bad shape'); expect(rebuilt.stack).toBeTruthy(); }); test('a non-Error throw still crosses', () => { expect(deserializeError(serializeError('just a string')).message).toBe('just a string'); }); test('MissingProviderInputError survives, fields intact', () => { // The one error the framework READS rather than displays. If the four // fields do not survive, the cross-module ensure interview never runs // and the deploy fails with a message instead of a question. const original = new MissingProviderInputError({ providerModuleId: 'caddy', ensureId: 'hostnames', value: 'foo.example.com', humanContext: 'so the route resolves', }); const rebuilt = deserializeError(serializeError(original)); expect(isMissingProviderInputError(rebuilt)).toBe(true); if (!isMissingProviderInputError(rebuilt)) throw new Error('unreachable'); expect(rebuilt.providerModuleId).toBe('caddy'); expect(rebuilt.ensureId).toBe('hostnames'); expect(rebuilt.value).toBe('foo.example.com'); expect(rebuilt.humanContext).toBe('so the route resolves'); }); test('an absent humanContext does not become the string "undefined"', () => { const rebuilt = deserializeError( serializeError( new MissingProviderInputError({ providerModuleId: 'p', ensureId: 'e', value: 'v' }), ), ); expect((rebuilt as unknown as Record).humanContext).toBeUndefined(); }); test('an ordinary Error carries no fields envelope', () => { expect(serializeError(new Error('x')).fields).toBeUndefined(); }); }); });