/** * Hook-dispatch tests. The planner is pure (no I/O); the runner goes * through the ordinary hook executor (module-orchestrator-primitives * slice 7). We test: * * - planHookDispatch: rule matching (match logic unchanged since the * bash-spawn days). * - runUpstreamHook: routes a matched entry through runNamedHook with * the event as the `event` input and the matched entry's definition * (script + timeout). The seam under the mock is run-named-hook; * the real executor hop (child spawn, jail, context) is covered * end-to-end by hook-dispatch-executor.test.ts. */ import { describe, expect, mock, test } from 'bun:test'; import type { PublishEvent } from '@celilo/event-bus/build-bus'; import type { HookLogger } from '../../hooks/types'; import { type HookDispatchPlan, planHookDispatch, runUpstreamHook } from './hook-dispatch'; function buildEvent(overrides: Partial = {}): PublishEvent { return { eventId: 'evt-1', timestamp: new Date().toISOString(), registry: 'npm', tag: 'latest', package: { name: '@celilo/cli', version: '0.4.0' }, ...overrides, }; } const SILENT_LOGGER = { info: () => {}, warn: () => {}, error: () => {}, success: () => {}, } as unknown as HookLogger; describe('planHookDispatch', () => { test('empty module list → empty plan', () => { expect(planHookDispatch(buildEvent(), [])).toEqual([]); }); test('module with no matching hooks → empty plan', () => { const modules = [ { moduleId: 'lunacycle', sourcePath: '/modules/lunacycle', hooks: [{ match: { registry: 'celilo-registry' }, script: './reload.ts' }], }, ]; expect(planHookDispatch(buildEvent({ registry: 'npm' }), modules)).toEqual([]); }); test('matching hook → plan for that module + entry', () => { const modules = [ { moduleId: 'lunacycle', sourcePath: '/modules/lunacycle', hooks: [ { name: 'rerun-e2e', match: { registry: 'npm', package_pattern: '@celilo/*' }, script: './hooks/rerun.ts', }, ], }, ]; const plan = planHookDispatch(buildEvent(), modules); expect(plan).toHaveLength(1); expect(plan[0].module.moduleId).toBe('lunacycle'); expect(plan[0].hook.script).toBe('./hooks/rerun.ts'); }); test('multiple modules + hooks → one plan entry per match', () => { const modules = [ { moduleId: 'lunacycle', sourcePath: '/modules/lunacycle', hooks: [ { match: { registry: 'npm', package_pattern: '@celilo/cli' }, script: './a.ts' }, { match: { registry: 'npm', package_pattern: '@celilo/e2e' }, script: './b.ts' }, ], }, { moduleId: 'mgmt', sourcePath: '/modules/mgmt', hooks: [{ match: { registry: 'celilo-registry' }, script: './c.ts' }], }, ]; const plan = planHookDispatch( buildEvent({ package: { name: '@celilo/cli', version: '0.4.0' } }), modules, ); expect(plan).toHaveLength(1); expect(plan[0].module.moduleId).toBe('lunacycle'); expect(plan[0].hook.script).toBe('./a.ts'); }); test('snake_case package_pattern in manifest maps to camelCase packagePattern in matcher', () => { // Documents the YAML↔JS naming bridge. const modules = [ { moduleId: 'x', sourcePath: '/m', hooks: [{ match: { package_pattern: '@celilo/*' }, script: './h.ts' }], }, ]; expect(planHookDispatch(buildEvent(), modules)).toHaveLength(1); expect( planHookDispatch(buildEvent({ package: { name: '@other/x', version: '1.0.0' } }), modules), ).toHaveLength(0); }); }); describe('runUpstreamHook (routes through runNamedHook)', () => { const calls: Array<{ moduleId: string; hookName: string; options: Record }> = []; mock.module('../../hooks/run-named-hook', () => ({ runNamedHook: async ( moduleId: string, hookName: string, _db: unknown, _logger: unknown, options: Record, ) => { calls.push({ moduleId, hookName, options }); return { success: true, outputs: {}, duration: 7 }; }, })); test('passes the event as input and the matched entry as the hook definition', async () => { calls.length = 0; const plan: HookDispatchPlan = { module: { moduleId: 'lunacycle', sourcePath: '/m', hooks: [] }, hook: { name: 'rerun-e2e', match: {}, script: './hooks/rerun.ts', timeout: 90_000 }, }; const event = buildEvent(); const result = await runUpstreamHook(plan, event, { db: {} as Parameters[2]['db'], logger: SILENT_LOGGER, }); expect(result).toEqual({ module: 'lunacycle', hookName: 'rerun-e2e', success: true, durationMs: expect.any(Number), }); expect(calls).toHaveLength(1); expect(calls[0].moduleId).toBe('lunacycle'); expect(calls[0].hookName).toBe('on_upstream_publish'); expect(calls[0].options.inputs).toEqual({ event }); expect(calls[0].options.hookDefinition).toEqual({ script: './hooks/rerun.ts', timeout: 90_000, }); expect(calls[0].options.timeoutMs).toBe(90_000); }); test('a runNamedHook throw becomes an unsuccessful result, not a rejection', async () => { mock.module('../../hooks/run-named-hook', () => ({ runNamedHook: async () => { throw new Error('boom'); }, })); const result = await runUpstreamHook( { module: { moduleId: 'lunacycle', sourcePath: '/m', hooks: [] }, hook: { match: {}, script: './hooks/rerun.ts' }, }, buildEvent(), { db: {} as never, logger: SILENT_LOGGER }, ); expect(result.success).toBe(false); expect(result.error).toContain('boom'); }); });