/** * The slice-7 wiring, end to end: build-bus dispatcher → runNamedHook → * invokeHook → a real defineHook child, with the verified PublishEvent * arriving as the hook's `event` input (tasks.md 7.3/7.4). * * The scratch-module pattern is hook-state-dir.test.ts's: a temp module * whose scripts resolve the WORKSPACE @celilo/capabilities through a * symlink, because a real module in the store carries its own bundled copy * (modules run their bundled capabilities, celilo#173) and the jail binds * the module's tree and nothing above it. * * The database is an isolated client at a temp path, never the operator's * live store. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import type { PublishEvent } from '@celilo/event-bus/build-bus'; import { type DbClient, createDbClient } from '../../db/client'; import { buildBusHookRuns, modules } from '../../db/schema'; import { startHookDispatcher } from './hook-dispatcher'; const CAPABILITIES_PACKAGE = resolve( __dirname, '..', '..', '..', '..', '..', 'packages', 'capabilities', ); function buildEvent(overrides: Partial = {}): PublishEvent { return { eventId: 'evt-1', timestamp: new Date().toISOString(), registry: 'npm', tag: 'latest', package: { name: '@celilo/cli', version: '0.5.0' }, ...overrides, }; } /** * A module whose on_upstream_publish hook echoes the event fields it * received back as outputs, so the test can assert exactly what crossed * the executor boundary. When `failing` is true the handler throws * instead, for the ledger test below. */ function scratchUpstreamModule(failing = false): string { const root = mkdtempSync(join(tmpdir(), 'celilo-upstream-module-')); mkdirSync(join(root, 'scripts'), { recursive: true }); const handlerBody = failing ? ` throw new Error('boom from the jailed hook');` : ` const event = ctx.event as { package?: { name?: string; version?: string } }; // Two channels on purpose: the outputs come back through the broker, // and the file is an independent read the test can make without // trusting the dispatcher's result shape. writeFileSync( join(ctx.stateDir as string, 'received.json'), JSON.stringify({ name: event?.package?.name ?? null, version: event?.package?.version ?? null }), ); return { received_name: event?.package?.name ?? null, received_version: event?.package?.version ?? null };`; writeFileSync( join(root, 'scripts', 'on-upstream-publish.ts'), ` import { defineHook } from '@celilo/capabilities'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; export default defineHook({ requires: [] as const, handler: async (ctx) => { ${handlerBody} }, }); `, ); // See the file docblock: the fixture must carry its own capabilities link. mkdirSync(join(root, 'node_modules', '@celilo'), { recursive: true }); symlinkSync(CAPABILITIES_PACKAGE, join(root, 'node_modules', '@celilo', 'capabilities')); return root; } let db: DbClient; let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'celilo-upstream-dispatch-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); process.env.CELILO_EVENT_BUS_PATH = join(dir, 'events.db'); process.env.CELILO_DATA_DIR = join(dir, 'data'); mkdirSync(join(dir, 'data'), { recursive: true }); db = createDbClient({ path: process.env.CELILO_DB_PATH }); }); afterEach(() => { db.$client.close(); rmSync(dir, { recursive: true, force: true }); }); describe('the dispatcher dispatches on_upstream_publish through the ordinary executor', () => { test('the hook receives the verified PublishEvent as its event input', async () => { const moduleRoot = scratchUpstreamModule(); const hookEntry = { name: 'echo-publish', match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' }, script: './scripts/on-upstream-publish.ts', timeout: 60_000, }; db.insert(modules) .values({ id: 'upstream-fixture', name: 'upstream-fixture', version: '1.0.0', state: 'INSTALLED', sourcePath: moduleRoot, manifestData: { id: 'upstream-fixture', name: 'upstream-fixture', version: '1.0.0', celilo_contract: '1.0', provides: { capabilities: [] }, requires: { capabilities: [] }, hooks: { on_upstream_publish: [hookEntry] }, }, }) .run(); const dispatcher = await startHookDispatcher({ db, // The loader reads manifest.yml from disk; the fixture module keeps its // manifest in the DB row (runNamedHook's source) and the context here. loadModules: () => [ { moduleId: 'upstream-fixture', sourcePath: moduleRoot, hooks: [hookEntry] }, ], controlPlaneInstalled: () => false, }); try { const results = await dispatcher.handleEvent( buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }), ); expect(results).toHaveLength(1); expect(results[0].module).toBe('upstream-fixture'); expect(results[0].hookName).toBe('echo-publish'); expect(results[0].success).toBe(true); // The event crossed the executor into the hook's typed context, // proven by the hook's own file (independent of the dispatcher's // result shape, which carries no outputs). const received = JSON.parse( readFileSync(join(moduleRoot, 'state', 'received.json'), 'utf-8'), ) as { name: string | null; version: string | null }; expect(received).toEqual({ name: '@celilo/cli', version: '0.5.0' }); } finally { await dispatcher.stop(); rmSync(moduleRoot, { recursive: true, force: true }); } }); test('a non-matching event does not run the hook', async () => { const moduleRoot = scratchUpstreamModule(); const hookEntry = { name: 'echo-publish', match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' }, script: './scripts/on-upstream-publish.ts', timeout: 60_000, }; db.insert(modules) .values({ id: 'upstream-fixture', name: 'upstream-fixture', version: '1.0.0', state: 'INSTALLED', sourcePath: moduleRoot, manifestData: { id: 'upstream-fixture', name: 'upstream-fixture', version: '1.0.0', celilo_contract: '1.0', provides: { capabilities: [] }, requires: { capabilities: [] }, hooks: { on_upstream_publish: [hookEntry] }, }, }) .run(); const dispatcher = await startHookDispatcher({ db, loadModules: () => [ { moduleId: 'upstream-fixture', sourcePath: moduleRoot, hooks: [hookEntry] }, ], controlPlaneInstalled: () => false, }); try { const results = await dispatcher.handleEvent( buildEvent({ package: { name: '@celilo/e2e', version: '0.8.0' } }), ); expect(results).toEqual([]); } finally { await dispatcher.stop(); rmSync(moduleRoot, { recursive: true, force: true }); } }); }); /** * The durable outcome record (celilo#1304): every dispatched run lands in * the `build_bus_hook_runs` ledger via the production default recordRun — * no injectable capture between the dispatcher and the DB. The jailed * executor (slice 7) exposes no exit codes or captured stdio, so the row * carries the script path (the result doesn't), a null exit code, and the * executor's error text in stderr_tail. */ test('a failing hook run lands in the build_bus_hook_runs ledger', async () => { const moduleRoot = scratchUpstreamModule(true); const hookEntry = { name: 'echo-publish', match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' }, script: './scripts/on-upstream-publish.ts', timeout: 60_000, }; db.insert(modules) .values({ id: 'upstream-fixture', name: 'upstream-fixture', version: '1.0.0', state: 'INSTALLED', sourcePath: moduleRoot, manifestData: { id: 'upstream-fixture', name: 'upstream-fixture', version: '1.0.0', celilo_contract: '1.0', provides: { capabilities: [] }, requires: { capabilities: [] }, hooks: { on_upstream_publish: [hookEntry] }, }, }) .run(); const dispatcher = await startHookDispatcher({ db, loadModules: () => [ { moduleId: 'upstream-fixture', sourcePath: moduleRoot, hooks: [hookEntry] }, ], controlPlaneInstalled: () => false, }); try { await dispatcher.handleEvent( buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }), ); const rows = db.select().from(buildBusHookRuns).all(); expect(rows).toHaveLength(1); const row = rows[0]; expect(row.eventId).toBe('evt-1'); expect(row.packageName).toBe('@celilo/cli'); expect(row.moduleId).toBe('upstream-fixture'); expect(row.hookName).toBe('echo-publish'); expect(row.scriptPath).toBe('./scripts/on-upstream-publish.ts'); expect(row.exitCode).toBeNull(); expect(row.stderrTail).toContain('boom from the jailed hook'); } finally { await dispatcher.stop(); rmSync(moduleRoot, { recursive: true, force: true }); } });