hooks.test.ts
1 /** 2 * Tests for the plugin lifecycle hooks system. 3 */ 4 5 import { describe, it, expect, beforeEach } from 'vitest'; 6 import { 7 onStartup, 8 onBeforeExecute, 9 onAfterExecute, 10 emitHook, 11 clearAllHooks, 12 type HookContext, 13 } from './hooks.js'; 14 15 beforeEach(() => { 16 clearAllHooks(); 17 }); 18 19 describe('hook registration and emission', () => { 20 it('onBeforeExecute hook is called with context', async () => { 21 const calls: HookContext[] = []; 22 onBeforeExecute((ctx) => { calls.push({ ...ctx }); }); 23 24 await emitHook('onBeforeExecute', { command: 'test/cmd', args: { limit: 5 }, startedAt: 100 }); 25 26 expect(calls).toHaveLength(1); 27 expect(calls[0].command).toBe('test/cmd'); 28 expect(calls[0].args).toEqual({ limit: 5 }); 29 expect(calls[0].startedAt).toBe(100); 30 }); 31 32 it('onAfterExecute hook receives result', async () => { 33 const results: unknown[] = []; 34 onAfterExecute((_ctx, result) => { results.push(result); }); 35 36 const mockResult = [{ title: 'item1' }, { title: 'item2' }]; 37 await emitHook('onAfterExecute', { command: 'test/cmd', args: {} }, mockResult); 38 39 expect(results).toHaveLength(1); 40 expect(results[0]).toEqual(mockResult); 41 }); 42 43 it('onStartup hook fires', async () => { 44 let fired = false; 45 onStartup(() => { fired = true; }); 46 47 await emitHook('onStartup', { command: '__startup__', args: {} }); 48 expect(fired).toBe(true); 49 }); 50 51 it('multiple hooks on the same event fire in order', async () => { 52 const order: number[] = []; 53 onBeforeExecute(() => { order.push(1); }); 54 onBeforeExecute(() => { order.push(2); }); 55 onBeforeExecute(() => { order.push(3); }); 56 57 await emitHook('onBeforeExecute', { command: 'test/cmd', args: {} }); 58 expect(order).toEqual([1, 2, 3]); 59 }); 60 61 it('async hooks are awaited', async () => { 62 const order: string[] = []; 63 onBeforeExecute(async () => { 64 await new Promise((r) => setTimeout(r, 10)); 65 order.push('async-done'); 66 }); 67 onBeforeExecute(() => { order.push('sync'); }); 68 69 await emitHook('onBeforeExecute', { command: 'test/cmd', args: {} }); 70 expect(order).toEqual(['async-done', 'sync']); 71 }); 72 }); 73 74 describe('hook error isolation', () => { 75 it('failing hook does not prevent other hooks from running', async () => { 76 const calls: string[] = []; 77 78 onBeforeExecute(() => { calls.push('first'); }); 79 onBeforeExecute(() => { throw new Error('boom'); }); 80 onBeforeExecute(() => { calls.push('third'); }); 81 82 await emitHook('onBeforeExecute', { command: 'test/cmd', args: {} }); 83 84 // First and third should still run despite the second throwing 85 expect(calls).toEqual(['first', 'third']); 86 }); 87 88 it('async hook rejection does not prevent other hooks', async () => { 89 const calls: string[] = []; 90 91 onAfterExecute(() => { calls.push('before-reject'); }); 92 onAfterExecute(async () => { throw new Error('async boom'); }); 93 onAfterExecute(() => { calls.push('after-reject'); }); 94 95 await emitHook('onAfterExecute', { command: 'test/cmd', args: {} }, null); 96 97 expect(calls).toEqual(['before-reject', 'after-reject']); 98 }); 99 }); 100 101 describe('no-op when no hooks registered', () => { 102 it('emitHook with no registered hooks does nothing', async () => { 103 // Should not throw 104 await emitHook('onBeforeExecute', { command: 'test/cmd', args: {} }); 105 await emitHook('onAfterExecute', { command: 'test/cmd', args: {} }, []); 106 await emitHook('onStartup', { command: '__startup__', args: {} }); 107 }); 108 }); 109 110 describe('clearAllHooks', () => { 111 it('removes all hooks', async () => { 112 let called = false; 113 onStartup(() => { called = true; }); 114 115 clearAllHooks(); 116 await emitHook('onStartup', { command: '__startup__', args: {} }); 117 118 expect(called).toBe(false); 119 }); 120 }); 121 122 describe('globalThis singleton', () => { 123 it('uses globalThis.__opencli_hooks__ for shared state', () => { 124 expect(globalThis.__opencli_hooks__).toBeInstanceOf(Map); 125 }); 126 });