Files
waggle-os/packages/agent/tests/hooks.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

108 lines
3.6 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { HookRegistry, type HookEvent, type HookContext } from '../src/hooks.js';
describe('HookRegistry', () => {
it('forks global hooks while isolating concurrent request handlers', async () => {
const parent = new HookRegistry();
const inherited: string[] = [];
parent.on('pre:tool', (ctx) => { inherited.push(String(ctx.sessionId)); });
const requestA = parent.fork();
const requestB = parent.fork();
requestA.on('pre:tool', (ctx) => ctx.sessionId === 'a'
? { cancel: true, reason: 'request-a-only' }
: undefined);
const [a, b] = await Promise.all([
requestA.fire('pre:tool', { toolName: 'bash', sessionId: 'a' }),
requestB.fire('pre:tool', { toolName: 'bash', sessionId: 'b' }),
]);
expect(a).toEqual({ cancelled: true, reason: 'request-a-only' });
expect(b).toEqual({ cancelled: false });
expect(inherited.sort()).toEqual(['a', 'b']);
});
it('registers and fires pre:tool hooks', async () => {
const registry = new HookRegistry();
const fn = vi.fn();
registry.on('pre:tool', fn);
const ctx: HookContext = { toolName: 'bash', args: { command: 'ls' } };
await registry.fire('pre:tool', ctx);
expect(fn).toHaveBeenCalledWith(ctx);
});
it('registers and fires post:tool hooks', async () => {
const registry = new HookRegistry();
const fn = vi.fn();
registry.on('post:tool', fn);
const ctx: HookContext = { toolName: 'bash', result: 'file1.txt' };
await registry.fire('post:tool', ctx);
expect(fn).toHaveBeenCalledWith(ctx);
});
it('fires session:start and session:end hooks', async () => {
const registry = new HookRegistry();
const startFn = vi.fn();
const endFn = vi.fn();
registry.on('session:start', startFn);
registry.on('session:end', endFn);
await registry.fire('session:start', { sessionId: 'abc' });
await registry.fire('session:end', { sessionId: 'abc' });
expect(startFn).toHaveBeenCalledWith({ sessionId: 'abc' });
expect(endFn).toHaveBeenCalledWith({ sessionId: 'abc' });
});
it('supports multiple hooks for same event', async () => {
const registry = new HookRegistry();
const fn1 = vi.fn();
const fn2 = vi.fn();
registry.on('pre:tool', fn1);
registry.on('pre:tool', fn2);
await registry.fire('pre:tool', { toolName: 'read' });
expect(fn1).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();
});
it('pre:tool hooks can cancel by returning { cancel: true, reason }', async () => {
const registry = new HookRegistry();
registry.on('pre:tool', () => ({ cancel: true, reason: 'blocked by policy' }));
const result = await registry.fire('pre:tool', { toolName: 'bash', args: { command: 'rm -rf /' } });
expect(result.cancelled).toBe(true);
expect(result.reason).toBe('blocked by policy');
});
it('unregisters hooks via unsub function', async () => {
const registry = new HookRegistry();
const fn = vi.fn();
const unsub = registry.on('post:tool', fn);
unsub();
await registry.fire('post:tool', { toolName: 'bash' });
expect(fn).not.toHaveBeenCalled();
});
it('handles hook errors gracefully — second hook still runs', async () => {
const registry = new HookRegistry();
const errorFn = vi.fn(() => { throw new Error('boom'); });
const okFn = vi.fn();
registry.on('pre:response', errorFn);
registry.on('pre:response', okFn);
const result = await registry.fire('pre:response', { content: 'hello' });
expect(errorFn).toHaveBeenCalled();
expect(okFn).toHaveBeenCalled();
expect(result.cancelled).toBe(false);
});
});