/** * End-to-end tests for the framework self-update on a build-bus publish * event (module-orchestrator-primitives slice 7, tasks.md 7.2). * * celilo-mgmt's on_upstream_publish bash hook is gone: self-update is * framework work in the dispatcher now (design D6 — class H's answer). * These tests pin the three things that must hold: * * 1. The real celilo-mgmt manifest declares NO on_upstream_publish — the * work lives in the framework, and a hook creeping back is a second * self-update racing the framework one. * 2. planSelfUpdate reproduces the old manifest's match rules exactly * (npm + latest + the two binary packages). * 3. The dispatcher runs the update ONLY when the control-plane module * is installed, and logs — never throws — when it fails. */ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { PublishEvent } from '@celilo/event-bus/build-bus'; import { parse as parseYaml } from 'yaml'; import { startHookDispatcher } from './hook-dispatcher'; import { type SelfUpdatePlan, planSelfUpdate } from './self-update'; const REPO_ROOT = join(__dirname, '..', '..', '..', '..', '..'); 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, }; } describe('celilo-mgmt declares no on_upstream_publish (self-update is framework work)', () => { test('the real manifest has no on_upstream_publish block', () => { const manifestPath = join(REPO_ROOT, 'modules', 'celilo-mgmt', 'manifest.yml'); const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as { hooks?: Record; }; expect(parsed.hooks?.on_upstream_publish).toBeUndefined(); // The module is still the canonical build-bus subscriber via its // registry-poll subscription — that half did not move. expect(Object.keys(parsed.hooks ?? {})).toContain('on_backup'); }); }); describe('planSelfUpdate (the old manifest match rules, now in the framework)', () => { test('a @celilo/cli@latest npm event self-updates the cli binary', () => { const plan = planSelfUpdate(buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } })); expect(plan).toEqual({ packageName: '@celilo/cli', version: '0.5.0', binary: 'celilo' }); }); test('a @celilo/e2e@latest npm event self-updates the e2e binary', () => { const plan = planSelfUpdate(buildEvent({ package: { name: '@celilo/e2e', version: '0.8.0' } })); expect(plan).toEqual({ packageName: '@celilo/e2e', version: '0.8.0', binary: 'cele2e' }); }); test('an alpha-tag event for @celilo/cli does NOT fire (tag must be "latest")', () => { expect( planSelfUpdate( buildEvent({ tag: 'alpha', package: { name: '@celilo/cli', version: '0.5.0-alpha.0' } }), ), ).toBeNull(); }); test('a non-celilo package does NOT fire', () => { expect( planSelfUpdate(buildEvent({ package: { name: 'left-pad', version: '1.3.0' } })), ).toBeNull(); }); test('a transitive @celilo package (no standalone binary) does NOT fire', () => { expect( planSelfUpdate(buildEvent({ package: { name: '@celilo/event-bus', version: '0.2.0' } })), ).toBeNull(); }); test('a non-npm registry event does NOT fire', () => { expect( planSelfUpdate( buildEvent({ registry: 'celilo-registry', package: { name: '@celilo/cli', version: '0.5.0' }, }), ), ).toBeNull(); }); }); describe('dispatcher runs the framework self-update behind the control-plane gate', () => { let selfUpdateCalls: SelfUpdatePlan[]; beforeEach(() => { selfUpdateCalls = []; }); afterEach(() => { mock.restore(); }); function start(controls: { installed: boolean }) { return startHookDispatcher({ loadModules: () => [], controlPlaneInstalled: () => controls.installed, selfUpdate: (plan) => { selfUpdateCalls.push(plan); return { packageName: plan.packageName, version: plan.version, beforeVersion: '0.4.0', updated: true, verified: true, }; }, }); } test('control-plane installed + matching event → update runs with the right plan', async () => { const dispatcher = await start({ installed: true }); const results = await dispatcher.handleEvent( buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }), ); await dispatcher.stop(); expect(results).toEqual([]); expect(selfUpdateCalls).toEqual([ { packageName: '@celilo/cli', version: '0.5.0', binary: 'celilo' }, ]); }); test('control-plane NOT installed → no self-update even on a matching event', async () => { const dispatcher = await start({ installed: false }); await dispatcher.handleEvent( buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }), ); await dispatcher.stop(); expect(selfUpdateCalls).toEqual([]); }); test('a non-binary package (e.g. @celilo/event-bus) never self-updates', async () => { const dispatcher = await start({ installed: true }); await dispatcher.handleEvent( buildEvent({ package: { name: '@celilo/event-bus', version: '0.2.0' } }), ); await dispatcher.stop(); expect(selfUpdateCalls).toEqual([]); }); }); // The durable outcome record (celilo#1304) is exercised in // hook-dispatch-executor.test.ts, against the real ledger and the real // jailed executor — the old injectable-capture form here asserted the // bash path's exit code and stderr, which slice 7 deleted.