Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,11 @@ describe('doctor — hook checks', () => {
});

it('should fail when a subcommand is missing from settings', async () => {
// Missing 'contribute-check' subcommand
// Missing 'hook-dispatch' subcommand (the only required one now)
mockedReadFileSafe.mockImplementation(async (filePath: string) => {
if (filePath.includes('settings.json')) {
return buildPartialHooksContent(['contribute-check']);
// Return settings without hook-dispatch
return '{ "hooks": { "command": "bash -lc \\"teamai pull\\"" } }';
}
if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) {
return '# [teamai:env:start]';
Expand Down Expand Up @@ -136,14 +137,8 @@ describe('doctor — hook checks', () => {
});

it('should check all TEAMAI_HOOK_SUBCOMMANDS', () => {
// Verify the subcommands list is what we expect
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('pull');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('update');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track-slash');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('dashboard-report');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('contribute-check');
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('auto-recall');
expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(7);
// With the merged dispatch format, only hook-dispatch is needed
expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('hook-dispatch');
expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(1);
});
});
210 changes: 210 additions & 0 deletions src/__tests__/hook-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// ── Test doubles ────────────────────────────────────────

/** Minimal handler interface for testing. */
interface TestHandler {
name: string;
execute: ReturnType<typeof vi.fn>;
}

function createHandler(name: string, output?: string): TestHandler {
return {
name,
execute: vi.fn().mockResolvedValue(output ?? null),
};
}

// ── Import after understanding module shape ─────────────

import {
createDispatcher,
type HookHandler,
type DispatchResult,
} from '../hook-dispatch.js';

// ── Tests ───────────────────────────────────────────────

describe('hook-dispatch', () => {
describe('routing', () => {
it('dispatches to all handlers registered for the given event+matcher', async () => {
const pullHandler = createHandler('pull');
const dashboardHandler = createHandler('dashboard-report');

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: pullHandler },
{ event: 'session-start', matcher: '*', handler: dashboardHandler },
{ event: 'stop', matcher: '*', handler: createHandler('update') },
],
});

const stdin = { session_id: 'test-123', cwd: '/tmp' };
await dispatcher.dispatch('session-start', '*', stdin, 'claude');

expect(pullHandler.execute).toHaveBeenCalledOnce();
expect(dashboardHandler.execute).toHaveBeenCalledOnce();
});

it('does not invoke handlers for a different event', async () => {
const stopHandler = createHandler('update');

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: createHandler('pull') },
{ event: 'stop', matcher: '*', handler: stopHandler },
],
});

await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(stopHandler.execute).not.toHaveBeenCalled();
});

it('does not invoke handlers with a different matcher', async () => {
const skillHandler = createHandler('track');

const dispatcher = createDispatcher({
handlers: [
{ event: 'post-tool-use', matcher: '*', handler: createHandler('dashboard') },
{ event: 'post-tool-use', matcher: 'Skill', handler: skillHandler },
],
});

await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude');

expect(skillHandler.execute).not.toHaveBeenCalled();
});

it('wildcard matcher handlers also fire when a specific matcher is dispatched', async () => {
const wildcardHandler = createHandler('dashboard');
const bashHandler = createHandler('auto-recall');

const dispatcher = createDispatcher({
handlers: [
{ event: 'post-tool-use', matcher: '*', handler: wildcardHandler },
{ event: 'post-tool-use', matcher: 'Bash', handler: bashHandler },
],
});

await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude');

expect(wildcardHandler.execute).toHaveBeenCalledOnce();
expect(bashHandler.execute).toHaveBeenCalledOnce();
});
});

describe('isolation', () => {
it('a failing handler does not prevent other handlers from executing', async () => {
const failingHandler = createHandler('failing');
failingHandler.execute.mockRejectedValue(new Error('boom'));
const successHandler = createHandler('success');

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: failingHandler },
{ event: 'session-start', matcher: '*', handler: successHandler },
],
});

await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(successHandler.execute).toHaveBeenCalledOnce();
});

it('returns errors from failed handlers in the result', async () => {
const failingHandler = createHandler('failing');
failingHandler.execute.mockRejectedValue(new Error('boom'));

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: failingHandler },
{ event: 'session-start', matcher: '*', handler: createHandler('ok') },
],
});

const result = await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(result.errors).toHaveLength(1);
expect(result.errors[0].handlerName).toBe('failing');
expect(result.errors[0].error.message).toBe('boom');
});
});

describe('output merging', () => {
it('returns output from the handler that produces one', async () => {
const outputHandler = createHandler('auto-recall', '{"hookSpecificOutput":{"additionalContext":"found stuff"}}');
const silentHandler = createHandler('dashboard');

const dispatcher = createDispatcher({
handlers: [
{ event: 'post-tool-use', matcher: 'Bash', handler: outputHandler },
{ event: 'post-tool-use', matcher: '*', handler: silentHandler },
],
});

const result = await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude');

expect(result.output).toBe('{"hookSpecificOutput":{"additionalContext":"found stuff"}}');
});

it('returns null output when no handler produces output', async () => {
const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: createHandler('pull') },
{ event: 'session-start', matcher: '*', handler: createHandler('dashboard') },
],
});

const result = await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(result.output).toBeNull();
});
});

describe('stdin sharing', () => {
it('passes the same stdin object to all handlers', async () => {
const handler1 = createHandler('h1');
const handler2 = createHandler('h2');

const dispatcher = createDispatcher({
handlers: [
{ event: 'stop', matcher: '*', handler: handler1 },
{ event: 'stop', matcher: '*', handler: handler2 },
],
});

const stdin = { session_id: 'abc', cwd: '/project' };
await dispatcher.dispatch('stop', '*', stdin, 'claude');

expect(handler1.execute).toHaveBeenCalledWith(stdin, 'claude');
expect(handler2.execute).toHaveBeenCalledWith(stdin, 'claude');
});
});

describe('timeout', () => {
it('aborts a handler that exceeds its timeout', async () => {
const slowHandler: TestHandler = {
name: 'slow',
execute: vi.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve('late'), 5000)),
),
};
const fastHandler = createHandler('fast', 'quick');

const dispatcher = createDispatcher({
handlers: [
{ event: 'session-start', matcher: '*', handler: slowHandler, timeoutMs: 50 },
{ event: 'session-start', matcher: '*', handler: fastHandler },
],
});

const result = await dispatcher.dispatch('session-start', '*', {}, 'claude');

expect(result.errors).toHaveLength(1);
expect(result.errors[0].handlerName).toBe('slow');
expect(result.errors[0].error.message).toContain('timeout');
expect(result.output).toBe('quick');
});
});
});
146 changes: 146 additions & 0 deletions src/__tests__/hook-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// ── Mocks ────────────────────────────────────────────────
// Mock the underlying modules so handlers don't do real I/O

const mockPull = vi.fn().mockResolvedValue(undefined);
const mockDashboardReport = vi.fn().mockResolvedValue(undefined);
const mockParseHookEvent = vi.fn().mockResolvedValue({ type: 'session_start', timestamp: '2026-01-01', sessionId: 'test', tool: 'claude' });
const mockAppendEvent = vi.fn().mockResolvedValue(undefined);
const mockTrackFromParsed = vi.fn().mockResolvedValue(undefined);
const mockTrackSlashFromParsed = vi.fn().mockResolvedValue(undefined);
const mockAutoRecallFromParsed = vi.fn().mockResolvedValue(null);
const mockContributeCheckForSession = vi.fn().mockResolvedValue({ hint: null });
const mockDoUpdate = vi.fn().mockResolvedValue(undefined);

vi.mock('../pull.js', () => ({
pull: mockPull,
}));

vi.mock('../dashboard-collector.js', () => ({
parseHookEvent: mockParseHookEvent,
appendEvent: mockAppendEvent,
compactEvents: vi.fn().mockResolvedValue(undefined),
dashboardReport: mockDashboardReport,
}));

vi.mock('../usage-tracker.js', () => ({
trackFromStdin: mockTrackFromParsed,
trackSlashCommand: mockTrackSlashFromParsed,
extractSkillName: vi.fn(),
isValidSkillName: vi.fn().mockReturnValue(true),
appendUsageEvent: vi.fn().mockResolvedValue(undefined),
updateKnownSkills: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('../auto-recall.js', () => ({
autoRecall: mockAutoRecallFromParsed,
}));

vi.mock('../contribute-check.js', () => ({
contributeCheck: vi.fn().mockResolvedValue(undefined),
contributeCheckForSession: mockContributeCheckForSession,
}));

vi.mock('../update.js', () => ({
doUpdate: mockDoUpdate,
checkForUpdate: vi.fn().mockResolvedValue({ available: false, current: '1.0.0' }),
}));

vi.mock('../config.js', () => ({
autoDetectInit: vi.fn().mockResolvedValue({
localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' },
teamConfig: { team: 'test', repo: '', toolPaths: {} },
}),
}));

vi.mock('../utils/logger.js', () => ({
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));

import { buildHandlerRegistry, type HandlerRegistration } from '../hook-handlers.js';

// ── Tests ────────────────────────────────────────────────

describe('hook-handlers registry', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns registrations for all expected events', () => {
const registry = buildHandlerRegistry();
const events = new Set(registry.map((r) => r.event));
expect(events).toContain('session-start');
expect(events).toContain('stop');
expect(events).toContain('post-tool-use');
expect(events).toContain('prompt-submit');
});

it('session-start has pull and dashboard-report handlers', () => {
const registry = buildHandlerRegistry();
const sessionStartHandlers = registry
.filter((r) => r.event === 'session-start' && r.matcher === '*')
.map((r) => r.handler.name);
expect(sessionStartHandlers).toContain('pull');
expect(sessionStartHandlers).toContain('dashboard-report');
});

it('stop has update, contribute-check, and dashboard-report handlers', () => {
const registry = buildHandlerRegistry();
const stopHandlers = registry
.filter((r) => r.event === 'stop' && r.matcher === '*')
.map((r) => r.handler.name);
expect(stopHandlers).toContain('update');
expect(stopHandlers).toContain('contribute-check');
expect(stopHandlers).toContain('dashboard-report');
});

it('post-tool-use wildcard has dashboard-report', () => {
const registry = buildHandlerRegistry();
const wildcardHandlers = registry
.filter((r) => r.event === 'post-tool-use' && r.matcher === '*')
.map((r) => r.handler.name);
expect(wildcardHandlers).toContain('dashboard-report');
});

it('post-tool-use Skill matcher has track', () => {
const registry = buildHandlerRegistry();
const skillHandlers = registry
.filter((r) => r.event === 'post-tool-use' && r.matcher === 'Skill')
.map((r) => r.handler.name);
expect(skillHandlers).toContain('track');
});

it('post-tool-use Bash/Grep/WebSearch/WebFetch have auto-recall', () => {
const registry = buildHandlerRegistry();
for (const matcher of ['Bash', 'Grep', 'WebSearch', 'WebFetch']) {
const handlers = registry
.filter((r) => r.event === 'post-tool-use' && r.matcher === matcher)
.map((r) => r.handler.name);
expect(handlers).toContain('auto-recall');
}
});

it('prompt-submit has track-slash and dashboard-report', () => {
const registry = buildHandlerRegistry();
const handlers = registry
.filter((r) => r.event === 'prompt-submit' && r.matcher === '*')
.map((r) => r.handler.name);
expect(handlers).toContain('track-slash');
expect(handlers).toContain('dashboard-report');
});

it('all handlers have timeoutMs set', () => {
const registry = buildHandlerRegistry();
for (const reg of registry) {
expect(reg.timeoutMs).toBeGreaterThan(0);
}
});

it('pull handler has a longer timeout than dashboard-report', () => {
const registry = buildHandlerRegistry();
const pull = registry.find((r) => r.handler.name === 'pull');
const dashboard = registry.find((r) => r.handler.name === 'dashboard-report');
expect(pull!.timeoutMs).toBeGreaterThan(dashboard!.timeoutMs!);
});
});
Loading
Loading