|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 4 | +import type { Mock } from 'vitest'; |
| 5 | +import { ObjectKernel } from './kernel'; |
| 6 | +import type { ObjectKernelConfig } from './kernel'; |
| 7 | +import type { Plugin } from './types'; |
| 8 | + |
| 9 | +/** |
| 10 | + * `ObjectKernelConfig.bootPhaseHooks` — a kernel bootstrapped WITHOUT the three |
| 11 | + * boot-phase hooks, for a host that needs the registered definitions and the |
| 12 | + * started services and nothing the boot phase does (a repair kernel for an |
| 13 | + * environment whose normal boot cannot finish). |
| 14 | + * |
| 15 | + * Both halves of the option's docblock are pinned here, against one |
| 16 | + * composition: |
| 17 | + * |
| 18 | + * - what it GUARANTEES: a definition a plugin registers in `init()` or |
| 19 | + * `start()` is present, and the kernel is running; |
| 20 | + * - what it does NOT: a definition a plugin registers in a `kernel:ready` |
| 21 | + * handler is absent under the option and present without it. "No ready |
| 22 | + * handler registers a definition" is a reading of one set of plugins, |
| 23 | + * never a property of the option, so the absent half is a pin, not a |
| 24 | + * footnote. |
| 25 | + * |
| 26 | + * The controls run the same composition with the option absent, `true` and |
| 27 | + * `undefined`: today's boot, unchanged. |
| 28 | + */ |
| 29 | + |
| 30 | +const BOOT_PHASE_HOOKS = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'] as const; |
| 31 | +type BootPhaseHook = (typeof BOOT_PHASE_HOOKS)[number]; |
| 32 | + |
| 33 | +/** The "definition registry" the composition's plugins write to. */ |
| 34 | +type Definitions = Map<string, { registeredIn: string }>; |
| 35 | + |
| 36 | +const REGISTRY = 'test.definition-registry'; |
| 37 | + |
| 38 | +function newKernel(extra: ObjectKernelConfig = {}): ObjectKernel { |
| 39 | + return new ObjectKernel({ |
| 40 | + logger: { level: 'error' }, |
| 41 | + gracefulShutdown: false, |
| 42 | + skipSystemValidation: true, |
| 43 | + ...extra, |
| 44 | + }); |
| 45 | +} |
| 46 | + |
| 47 | +/** |
| 48 | + * A registry plugin plus a declarer that registers one definition per place a |
| 49 | + * plugin can register one — `init()`, `start()`, a `kernel:ready` handler — |
| 50 | + * and a spy on each of the three boot-phase hooks. |
| 51 | + */ |
| 52 | +function composition() { |
| 53 | + const spies: Record<BootPhaseHook, Mock<() => void>> = { |
| 54 | + 'kernel:ready': vi.fn<() => void>(), |
| 55 | + 'kernel:bootstrapped': vi.fn<() => void>(), |
| 56 | + 'kernel:listening': vi.fn<() => void>(), |
| 57 | + }; |
| 58 | + const order: string[] = []; |
| 59 | + |
| 60 | + const registry: Plugin = { |
| 61 | + name: REGISTRY, |
| 62 | + version: '1.0.0', |
| 63 | + init: async (ctx) => { |
| 64 | + ctx.registerService('definitions', new Map() as Definitions); |
| 65 | + }, |
| 66 | + }; |
| 67 | + |
| 68 | + const declarer: Plugin = { |
| 69 | + name: 'test.declarer', |
| 70 | + version: '1.0.0', |
| 71 | + dependencies: [REGISTRY], |
| 72 | + init: async (ctx) => { |
| 73 | + const definitions = ctx.getService<Definitions>('definitions'); |
| 74 | + definitions.set('obj_from_init', { registeredIn: 'init' }); |
| 75 | + ctx.hook('kernel:ready', async () => { |
| 76 | + definitions.set('obj_from_ready', { registeredIn: 'kernel:ready' }); |
| 77 | + }); |
| 78 | + for (const hook of BOOT_PHASE_HOOKS) { |
| 79 | + ctx.hook(hook, async () => { |
| 80 | + order.push(hook); |
| 81 | + spies[hook](); |
| 82 | + }); |
| 83 | + } |
| 84 | + }, |
| 85 | + start: async (ctx) => { |
| 86 | + ctx.getService<Definitions>('definitions').set('obj_from_start', { registeredIn: 'start' }); |
| 87 | + }, |
| 88 | + }; |
| 89 | + |
| 90 | + return { plugins: [registry, declarer], spies, order }; |
| 91 | +} |
| 92 | + |
| 93 | +async function boot(kernel: ObjectKernel, plugins: Plugin[]): Promise<Definitions> { |
| 94 | + for (const plugin of plugins) await kernel.use(plugin); |
| 95 | + await kernel.bootstrap(); |
| 96 | + return kernel.getService<Definitions>('definitions'); |
| 97 | +} |
| 98 | + |
| 99 | +const spyOnLog = (k: ObjectKernel, level: 'warn' | 'info') => |
| 100 | + vi.spyOn((k as unknown as { logger: Record<'warn' | 'info', (...a: unknown[]) => void> }).logger, level); |
| 101 | + |
| 102 | +/** The `withheldHandlers` meta of every `warn` the withholding boot emitted. */ |
| 103 | +function withheldReports(warnSpy: ReturnType<typeof spyOnLog>): unknown[] { |
| 104 | + return warnSpy.mock.calls |
| 105 | + .map((call) => (call[1] as { withheldHandlers?: unknown } | undefined)?.withheldHandlers) |
| 106 | + .filter((meta) => meta !== undefined); |
| 107 | +} |
| 108 | + |
| 109 | +describe('ObjectKernel bootPhaseHooks', () => { |
| 110 | + const kernels: ObjectKernel[] = []; |
| 111 | + const track = (k: ObjectKernel) => { kernels.push(k); return k; }; |
| 112 | + |
| 113 | + afterEach(async () => { |
| 114 | + for (const k of kernels.splice(0)) { |
| 115 | + if (k.isRunning()) await k.shutdown(); |
| 116 | + } |
| 117 | + vi.restoreAllMocks(); |
| 118 | + }); |
| 119 | + |
| 120 | + describe('bootPhaseHooks: false', () => { |
| 121 | + it('keeps every definition registered in init() and start(), and the kernel runs', async () => { |
| 122 | + const { plugins } = composition(); |
| 123 | + const kernel = track(newKernel({ bootPhaseHooks: false })); |
| 124 | + |
| 125 | + const definitions = await boot(kernel, plugins); |
| 126 | + |
| 127 | + expect(definitions.get('obj_from_init')).toEqual({ registeredIn: 'init' }); |
| 128 | + expect(definitions.get('obj_from_start')).toEqual({ registeredIn: 'start' }); |
| 129 | + expect(kernel.isRunning()).toBe(true); |
| 130 | + expect(kernel.getState()).toBe('running'); |
| 131 | + }); |
| 132 | + |
| 133 | + it('leaves a definition registered in a kernel:ready handler absent', async () => { |
| 134 | + const { plugins } = composition(); |
| 135 | + const kernel = track(newKernel({ bootPhaseHooks: false })); |
| 136 | + |
| 137 | + const definitions = await boot(kernel, plugins); |
| 138 | + |
| 139 | + expect(definitions.has('obj_from_ready')).toBe(false); |
| 140 | + expect([...definitions.keys()].sort()).toEqual(['obj_from_init', 'obj_from_start']); |
| 141 | + }); |
| 142 | + |
| 143 | + it('dispatches none of kernel:ready, kernel:bootstrapped, kernel:listening', async () => { |
| 144 | + const { plugins, spies, order } = composition(); |
| 145 | + const kernel = track(newKernel({ bootPhaseHooks: false })); |
| 146 | + |
| 147 | + await boot(kernel, plugins); |
| 148 | + |
| 149 | + for (const hook of BOOT_PHASE_HOOKS) { |
| 150 | + expect(spies[hook], hook).not.toHaveBeenCalled(); |
| 151 | + } |
| 152 | + expect(order).toEqual([]); |
| 153 | + }); |
| 154 | + |
| 155 | + // The case the option exists for: a boot-phase handler that throws, or |
| 156 | + // never settles, no longer decides whether the definitions are reachable. |
| 157 | + it('completes the boot when a kernel:ready handler would throw or never settle', async () => { |
| 158 | + const kernel = track(newKernel({ bootPhaseHooks: false })); |
| 159 | + const stuck: Plugin = { |
| 160 | + name: 'test.stuck-boot-phase', |
| 161 | + version: '1.0.0', |
| 162 | + init: async (ctx) => { |
| 163 | + ctx.hook('kernel:ready', async () => { throw new Error('seed heal failed'); }); |
| 164 | + ctx.hook('kernel:bootstrapped', () => new Promise<void>(() => { /* never settles */ })); |
| 165 | + }, |
| 166 | + }; |
| 167 | + |
| 168 | + await kernel.use(stuck); |
| 169 | + await expect(kernel.bootstrap()).resolves.toBeUndefined(); |
| 170 | + expect(kernel.getState()).toBe('running'); |
| 171 | + }); |
| 172 | + |
| 173 | + it('reports the withheld handlers once, per hook', async () => { |
| 174 | + const { plugins } = composition(); |
| 175 | + const kernel = track(newKernel({ bootPhaseHooks: false })); |
| 176 | + const warnSpy = spyOnLog(kernel, 'warn'); |
| 177 | + |
| 178 | + await boot(kernel, plugins); |
| 179 | + |
| 180 | + // The declarer registers two kernel:ready handlers (the definition |
| 181 | + // and the spy) and one each on the other two. |
| 182 | + expect(withheldReports(warnSpy)).toEqual([ |
| 183 | + { 'kernel:ready': 2, 'kernel:bootstrapped': 1, 'kernel:listening': 1 }, |
| 184 | + ]); |
| 185 | + }); |
| 186 | + |
| 187 | + it('shuts down cleanly: kernel:shutdown, destroy() and onShutdown run, nothing waits on a withheld hook', async () => { |
| 188 | + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); |
| 189 | + const { plugins, spies } = composition(); |
| 190 | + const reached: string[] = []; |
| 191 | + const teardown: Plugin = { |
| 192 | + name: 'test.teardown', |
| 193 | + version: '1.0.0', |
| 194 | + init: async (ctx) => { |
| 195 | + ctx.hook('kernel:shutdown', async () => { reached.push('kernel:shutdown'); }); |
| 196 | + }, |
| 197 | + destroy: async () => { reached.push('destroy'); }, |
| 198 | + }; |
| 199 | + // A short guard: a teardown that waited on something a withheld |
| 200 | + // hook would have resolved trips it and calls process.exit(1). |
| 201 | + const kernel = newKernel({ bootPhaseHooks: false, shutdownTimeout: 2_000 }); |
| 202 | + kernel.onShutdown(async () => { reached.push('onShutdown'); }); |
| 203 | + |
| 204 | + await boot(kernel, [...plugins, teardown]); |
| 205 | + await kernel.shutdown(); |
| 206 | + |
| 207 | + expect(reached).toEqual(['kernel:shutdown', 'destroy', 'onShutdown']); |
| 208 | + expect(kernel.getState()).toBe('stopped'); |
| 209 | + expect(exitSpy).not.toHaveBeenCalled(); |
| 210 | + for (const hook of BOOT_PHASE_HOOKS) { |
| 211 | + expect(spies[hook], hook).not.toHaveBeenCalled(); |
| 212 | + } |
| 213 | + }); |
| 214 | + }); |
| 215 | + |
| 216 | + // Controls: the same composition on today's boot. Only an explicit `false` |
| 217 | + // withholds; an absent option and an explicit `undefined` (a host |
| 218 | + // forwarding an unset value) are the default. |
| 219 | + describe.each([ |
| 220 | + ['absent', {}], |
| 221 | + ['true', { bootPhaseHooks: true }], |
| 222 | + ['undefined', { bootPhaseHooks: undefined }], |
| 223 | + ] as Array<[string, ObjectKernelConfig]>)('bootPhaseHooks %s (control)', (_label, extra) => { |
| 224 | + it('fires all three hooks in order and keeps every definition, the kernel:ready one included', async () => { |
| 225 | + const { plugins, spies, order } = composition(); |
| 226 | + const kernel = track(newKernel(extra)); |
| 227 | + const warnSpy = spyOnLog(kernel, 'warn'); |
| 228 | + |
| 229 | + const definitions = await boot(kernel, plugins); |
| 230 | + |
| 231 | + expect(order).toEqual([...BOOT_PHASE_HOOKS]); |
| 232 | + for (const hook of BOOT_PHASE_HOOKS) { |
| 233 | + expect(spies[hook], hook).toHaveBeenCalledTimes(1); |
| 234 | + } |
| 235 | + expect([...definitions.keys()].sort()).toEqual(['obj_from_init', 'obj_from_ready', 'obj_from_start']); |
| 236 | + expect(withheldReports(warnSpy)).toEqual([]); |
| 237 | + expect(kernel.isRunning()).toBe(true); |
| 238 | + }); |
| 239 | + }); |
| 240 | +}); |
0 commit comments