/ src / hooks.test.ts
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  });