SPB Git

Repository Public

hooks.test.ts 9.9 KB 177 lines typescript

1import fs from 'node:fs';2import os from 'node:os';3import path from 'node:path';4import { spawn } from 'node:child_process';5import { fileURLToPath } from 'node:url';6import { afterAll, beforeAll, describe, expect, it } from 'vitest';7import type { Runtime } from './main.js';8import { HOOK_EVENTS, installClaudeHooks, parseHookRequest, uninstallClaudeHooks } from './hooks.js';9import { collectEvents, makeTestEnv, request, startRuntime, waitUntil, type TestEnv } from './core-testkit.js';1011const HOOK_SCRIPT = fileURLToPath(new URL('../bin/cr-hook', import.meta.url));12const SID = '11111111-2222-4333-8444-555555555555';1314describe('installClaudeHooks / uninstallClaudeHooks (temp settings file only)', () => {15  let dir: string;16  beforeAll(() => {17    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cr-hooks-'));18  });19  afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));2021  const existing = {22    model: 'opus',23    permissions: { allow: ['Bash(ls:*)'] },24    hooks: {25      PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo pre' }] }],26      SessionStart: [{ hooks: [{ type: 'command', command: '/opt/other/session_start.sh', statusMessage: 'x' }] }],27    },28  };29  const read = (file: string) => JSON.parse(fs.readFileSync(file, 'utf8')) as typeof existing & { hooks: Record<string, { hooks: { command: string }[] }[]> };30  const ours = (file: string, event: string) =>31    (read(file).hooks[event] ?? []).flatMap((g) => g.hooks).filter((h) => h.command.includes('cr-hook'));3233  it('merges our four events, preserves everything else, and is idempotent', async () => {34    const file = path.join(dir, 'settings.json');35    fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o640 });36    expect(await installClaudeHooks(file, HOOK_SCRIPT)).toBe(true);37    const after = read(file);38    expect(after.model).toBe('opus');39    expect(after.permissions).toEqual(existing.permissions);40    expect(after.hooks.PreToolUse).toEqual(existing.hooks.PreToolUse);41    expect(after.hooks.SessionStart![0]).toEqual(existing.hooks.SessionStart[0]); // the foreign hook is intact42    for (const event of HOOK_EVENTS) {43      expect(ours(file, event)).toEqual([{ type: 'command', command: `'${HOOK_SCRIPT}'`, timeout: 5 }]);44    }45    expect(fs.statSync(file).mode & 0o777).toBe(0o640); // mode preserved4647    const snapshot = fs.readFileSync(file, 'utf8');48    expect(await installClaudeHooks(file, HOOK_SCRIPT)).toBe(false);49    expect(await installClaudeHooks(file, HOOK_SCRIPT)).toBe(false);50    expect(fs.readFileSync(file, 'utf8')).toBe(snapshot);5152    // A redeploy to another directory replaces the entry instead of adding a second one.53    expect(await installClaudeHooks(file, '/new/deploy dir/bin/cr-hook')).toBe(true);54    for (const event of HOOK_EVENTS) expect(ours(file, event).map((h) => h.command)).toEqual([`'/new/deploy dir/bin/cr-hook'`]);5556    expect(await uninstallClaudeHooks(file)).toBe(true);57    expect(read(file)).toEqual(existing); // exactly the original content58    expect(await uninstallClaudeHooks(file)).toBe(false);59  });6061  it('creates the file when missing and removes its own block cleanly', async () => {62    const file = path.join(dir, 'fresh', 'settings.json');63    expect(await uninstallClaudeHooks(file)).toBe(false);64    expect(fs.existsSync(file)).toBe(false);65    expect(await installClaudeHooks(file, HOOK_SCRIPT)).toBe(true);66    expect(Object.keys(read(file).hooks).sort()).toEqual([...HOOK_EVENTS].sort());67    expect(await uninstallClaudeHooks(file)).toBe(true);68    expect(read(file)).toEqual({});69  });7071  it('refuses to touch an unparsable settings file and odd script paths', async () => {72    const file = path.join(dir, 'broken.json');73    fs.writeFileSync(file, '{ not json');74    await expect(installClaudeHooks(file, HOOK_SCRIPT)).rejects.toThrow();75    expect(fs.readFileSync(file, 'utf8')).toBe('{ not json');76    await expect(installClaudeHooks(path.join(dir, 'x.json'), 'relative/cr-hook')).rejects.toThrow();77    await expect(installClaudeHooks(path.join(dir, 'x.json'), '/abs/other-script')).rejects.toThrow();78  });79});8081describe('parseHookRequest', () => {82  it('extracts conversation id, event and a truncated Notification message from raw hook JSON', () => {83    const raw = { session_id: 'conv-1', hook_event_name: 'Notification', message: 'x'.repeat(500), transcript_path: '/t', cwd: '/c' };84    const parsed = parseHookRequest(raw, SID);85    expect(parsed).toMatchObject({ id: SID, event: 'Notification', conversationId: 'conv-1' });86    expect(parsed.message).toHaveLength(200);87    // `message`-like fields of other events (e.g. a user prompt) are never forwarded.88    expect(parseHookRequest({ hook_event_name: 'UserPromptSubmit', session_id: 'c', prompt: 'secret', message: 'm' }, SID).message).toBeNull();89    expect(parseHookRequest({ hook_event_name: 'Stop' }, SID)).toEqual({ id: SID, event: 'Stop', conversationId: null, message: null });90  });9192  it('accepts the contract body and rejects malformed input', () => {93    expect(parseHookRequest({ sessionId: SID, event: 'Stop', conversationId: 'c9' }, undefined)).toEqual({ id: SID, event: 'Stop', conversationId: 'c9', message: null });94    expect(() => parseHookRequest({ hook_event_name: 'Stop' }, undefined)).toThrow();95    expect(() => parseHookRequest({ hook_event_name: 'Stop' }, 'not-a-uuid')).toThrow();96    expect(() => parseHookRequest({ sessionId: 'nope', event: 'Stop' }, undefined)).toThrow();97    expect(() => parseHookRequest({ hook_event_name: 'bad event!' }, SID)).toThrow();98    expect(() => parseHookRequest('string', SID)).toThrow();99  });100});101102describe('cr-hook script + POST /hooks/claude', () => {103  let env: TestEnv;104  let rt: Runtime;105  beforeAll(async () => {106    env = makeTestEnv();107    rt = await startRuntime(env.config);108  });109  afterAll(async () => {110    await rt.stop();111    env.cleanup();112  });113114  // Async spawn: the runtime under test lives in this process, so the event loop must keep turning.115  const runHook = (extraEnv: Record<string, string>, input: string) =>116    new Promise<{ status: number | null; stdout: string; stderr: string; ms: number }>((resolve, reject) => {117      const started = Date.now();118      const child = spawn(HOOK_SCRIPT, [], { env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...extraEnv }, stdio: ['pipe', 'pipe', 'pipe'] });119      let stdout = '';120      let stderr = '';121      child.stdout.on('data', (d: Buffer) => (stdout += d.toString()));122      child.stderr.on('data', (d: Buffer) => (stderr += d.toString()));123      child.on('error', reject);124      child.on('close', (status) => resolve({ status, stdout, stderr, ms: Date.now() - started }));125      child.stdin.on('error', () => undefined); // the no-op path exits without reading stdin126      child.stdin.end(input);127    });128129  it('is a silent, fast no-op outside a Claude Remote session', async () => {130    const res = await runHook({ CR_HOOK_SOCKET: env.config.socketPath, CR_HOOK_TOKEN: rt.core.hookToken }, '{"hook_event_name":"Stop"}');131    expect(res).toMatchObject({ status: 0, stdout: '', stderr: '' });132    expect(res.ms).toBeLessThan(1000);133  });134135  it('exits 0 silently even when the runtime is unreachable or the token is wrong', async () => {136    const dead = await runHook({ CR_SESSION_ID: SID, CR_HOOK_SOCKET: '/tmp/cr-no-such-socket.sock', CR_HOOK_TOKEN: 'x' }, '{"hook_event_name":"Stop"}');137    expect(dead).toMatchObject({ status: 0, stdout: '', stderr: '' });138    const wrong = await runHook({ CR_SESSION_ID: SID, CR_HOOK_SOCKET: env.config.socketPath, CR_HOOK_TOKEN: 'wrong' }, '{"hook_event_name":"Stop"}');139    expect(wrong).toMatchObject({ status: 0, stdout: '', stderr: '' });140  });141142  it('relays the hook JSON as a claude.hook event', async () => {143    const events = await collectEvents(env.config.socketPath, env.config.token);144    const res = await runHook(145      { CR_SESSION_ID: SID, CR_HOOK_SOCKET: env.config.socketPath, CR_HOOK_TOKEN: rt.core.hookToken },146      JSON.stringify({ session_id: 'conv-xyz', hook_event_name: 'Stop', transcript_path: '/x', stop_hook_active: false }),147    );148    expect(res).toMatchObject({ status: 0, stdout: '', stderr: '' });149    expect(res.ms).toBeLessThan(1500); // answered, not timed out150    await waitUntil(() => events.events.length > 0, 5000, 'claude.hook event');151    expect(events.events).toEqual([{ type: 'claude.hook', id: SID, event: 'Stop', conversationId: 'conv-xyz', message: null }]);152    events.close();153  });154155  it('the hook token works on /hooks/claude and nowhere else', async () => {156    const sock = env.config.socketPath;157    const hook = rt.core.hookToken;158    const body = { sessionId: SID, event: 'Notification', message: 'hi' };159    expect((await request(sock, 'POST', '/hooks/claude', { token: hook, body })).status).toBe(200);160    expect((await request(sock, 'POST', '/hooks/claude', { token: env.config.token, body })).status).toBe(200);161    expect((await request(sock, 'POST', '/hooks/claude', { token: null, body })).status).toBe(401);162    expect((await request(sock, 'POST', '/hooks/claude', { token: 'nope', body })).status).toBe(401);163    expect((await request(sock, 'POST', '/hooks/claude', { token: hook, body: { event: 'Stop' } })).status).toBe(400);164    for (const [method, url] of [['GET', '/sessions'], ['GET', '/health'], ['GET', '/tasks'], ['GET', '/host/metrics'], ['GET', '/hooks/claude'], ['POST', '/sessions'], ['POST', '/tasks']] as const) {165      expect((await request(sock, method, url, { token: hook, body: method === 'POST' ? {} : undefined })).status, `${method} ${url}`).toBe(401);166    }167    expect(fs.statSync(path.join(env.config.dataDir, 'run', 'hook-token')).mode & 0o777).toBe(0o600);168    expect(fs.readFileSync(path.join(env.config.dataDir, 'run', 'hook-token'), 'utf8').trim()).toBe(hook);169  });170171  it('never installs hooks implicitly (CR_INSTALL_CLAUDE_HOOKS unset)', () => {172    expect(process.env.CR_INSTALL_CLAUDE_HOOKS).toBeUndefined();173    const real = path.join(os.homedir(), '.claude', 'settings.json');174    if (fs.existsSync(real)) expect(fs.readFileSync(real, 'utf8')).not.toContain('cr-hook');175  });176});177
Files main
Menu

Theme