import { describe, it, expect } from 'vitest' import { parseHookEvent } from '../src/http/hook.js' import type { HookEventFull } from '../src/http/hook.js' const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' // ── Existing status / detail behaviour (must not regress) ─────────────────── // We use expect.objectContaining() so the new fields (at/eventClass/gate) do // not break these assertion — they verify only the status-mapping contract. describe('parseHookEvent — status mapping (regression guard)', () => { it('maps PreToolUse / PostToolUse / UserPromptSubmit to "working"', () => { for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) { expect(parseHookEvent(SID, { hook_event_name: e })).toEqual( expect.objectContaining({ sessionId: SID, status: 'working' }), ) } }) it('maps PermissionRequest to "waiting" with tool_name as detail', () => { expect( parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' }), ).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting', detail: 'Bash' })) }) it('maps Notification(permission_prompt) → "waiting"; other Notification → "idle"', () => { expect( parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt', }), ).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting' })) expect( parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt', }), ).toEqual(expect.objectContaining({ sessionId: SID, status: 'idle' })) }) it('maps Stop / SessionEnd to "idle"', () => { expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual( expect.objectContaining({ sessionId: SID, status: 'idle' }), ) expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual( expect.objectContaining({ sessionId: SID, status: 'idle' }), ) }) it('returns null for missing/empty sessionId, bad body, or unknown event', () => { expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull() expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull() expect(parseHookEvent(SID, null)).toBeNull() expect(parseHookEvent(SID, 'nope')).toBeNull() expect(parseHookEvent(SID, { hook_event_name: 'SomethingElse' })).toBeNull() expect(parseHookEvent(SID, {})).toBeNull() }) }) // ── HookEventFull — new A4 / B4 fields ────────────────────────────────────── describe('HookEventFull — at field (A4)', () => { it('includes a numeric timestamp in [before, after] around the call', () => { const before = Date.now() const ev = parseHookEvent(SID, { hook_event_name: 'Stop' }) as HookEventFull const after = Date.now() expect(ev).not.toBeNull() expect(typeof ev.at).toBe('number') expect(ev.at).toBeGreaterThanOrEqual(before) expect(ev.at).toBeLessThanOrEqual(after) }) it('is absent from null results', () => { const ev = parseHookEvent(SID, { hook_event_name: 'Unknown' }) expect(ev).toBeNull() }) }) describe('HookEventFull — eventClass field (A4)', () => { const EVENTS = [ 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'PermissionRequest', 'Stop', 'SessionEnd', 'Notification', ] it('carries the raw hook_event_name for every whitelisted event', () => { for (const e of EVENTS) { const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull expect(ev).not.toBeNull() expect(ev.eventClass).toBe(e) } }) it('is not present when parseHookEvent returns null', () => { expect(parseHookEvent(SID, { hook_event_name: 'NotAHook' })).toBeNull() }) }) describe('HookEventFull — toolInput passthrough (A4)', () => { it('includes toolInput when tool_input is present in the body', () => { const input = { path: '/src/foo.ts', content: 'hello world' } const ev = parseHookEvent(SID, { hook_event_name: 'PreToolUse', tool_input: input, }) as HookEventFull expect(ev).not.toBeNull() expect(ev.toolInput).toBe(input) // same reference, no copy/parse }) it('passes toolInput through as-is (does not validate inner shape)', () => { const ev = parseHookEvent(SID, { hook_event_name: 'PostToolUse', tool_input: 'just a string', }) as HookEventFull expect(ev.toolInput).toBe('just a string') }) it('omits toolInput when tool_input is absent', () => { const ev = parseHookEvent(SID, { hook_event_name: 'PreToolUse' }) as HookEventFull expect(ev).not.toBeNull() expect('toolInput' in ev).toBe(false) }) it('includes toolInput even when its value is null', () => { const ev = parseHookEvent(SID, { hook_event_name: 'PostToolUse', tool_input: null, }) as HookEventFull expect(ev).not.toBeNull() expect('toolInput' in ev).toBe(true) expect(ev.toolInput).toBeNull() }) }) describe('HookEventFull — gate field (B4 plan-gate recognition)', () => { it('sets gate="plan" for PermissionRequest with ExitPlanMode tool', () => { const ev = parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'ExitPlanMode', }) as HookEventFull expect(ev).not.toBeNull() expect(ev.gate).toBe('plan') }) it('sets gate="tool" for PermissionRequest with any other tool name', () => { const ev = parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash', }) as HookEventFull expect(ev).not.toBeNull() expect(ev.gate).toBe('tool') }) it('sets gate="tool" for PermissionRequest with no tool_name', () => { const ev = parseHookEvent(SID, { hook_event_name: 'PermissionRequest' }) as HookEventFull expect(ev).not.toBeNull() expect(ev.gate).toBe('tool') }) it('omits gate for non-PermissionRequest events', () => { const nonPermEvents = [ 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'SessionEnd', 'Notification', ] for (const e of nonPermEvents) { const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull expect(ev).not.toBeNull() expect('gate' in ev).toBe(false) } }) }) describe('HookEventFull — detail not set for Notification (no tool_name)', () => { it('Notification(permission_prompt) has status=waiting but no detail', () => { const ev = parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt', }) as HookEventFull expect(ev).not.toBeNull() expect(ev.status).toBe('waiting') expect('detail' in ev).toBe(false) }) }) describe('SEC-M7 — never throws on any body shape', () => { const malformed = [ [], 42, true, undefined, { hook_event_name: null }, { hook_event_name: 123 }, { hook_event_name: 'PreToolUse', tool_input: { toString: null } }, ] it('returns null without throwing for every malformed body', () => { for (const b of malformed) { expect(() => parseHookEvent(SID, b)).not.toThrow() } }) })