This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { CliBridge, MemoryHit } from '@waggle/hive-mind-shim-core';
|
||||
|
||||
export interface MockBridgeOverrides {
|
||||
saveMemoryResult?: { id: string; success: boolean; workspace: string };
|
||||
recallMemoryHits?: MemoryHit[];
|
||||
cleanupFramesResult?: { pruned: number };
|
||||
saveMemoryThrows?: Error;
|
||||
}
|
||||
|
||||
export interface MockBridge extends CliBridge {
|
||||
saveMemory: ReturnType<typeof vi.fn>;
|
||||
recallMemory: ReturnType<typeof vi.fn>;
|
||||
cleanupFrames: ReturnType<typeof vi.fn>;
|
||||
callMcpTool: ReturnType<typeof vi.fn>;
|
||||
setWorkspaceById: ReturnType<typeof vi.fn>;
|
||||
getActiveWorkspaceId: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
export function makeMockBridge(overrides: MockBridgeOverrides = {}): MockBridge {
|
||||
let activeWorkspaceId: string | undefined;
|
||||
const saveMemory = overrides.saveMemoryThrows
|
||||
? vi.fn(async () => { throw overrides.saveMemoryThrows; })
|
||||
: vi.fn(async () => overrides.saveMemoryResult ?? { id: 'frame-1', success: true, workspace: 'personal' });
|
||||
const recallMemory = vi.fn(async () => overrides.recallMemoryHits ?? []);
|
||||
const cleanupFrames = vi.fn(async () => overrides.cleanupFramesResult ?? { pruned: 0 });
|
||||
const callMcpTool = vi.fn(async () => ({}));
|
||||
const setWorkspaceById = vi.fn((id?: string) => { activeWorkspaceId = id; });
|
||||
const getActiveWorkspaceId = vi.fn(() => activeWorkspaceId);
|
||||
return {
|
||||
saveMemory,
|
||||
recallMemory,
|
||||
cleanupFrames,
|
||||
callMcpTool,
|
||||
setWorkspaceById,
|
||||
getActiveWorkspaceId,
|
||||
} as unknown as MockBridge;
|
||||
}
|
||||
|
||||
export interface CapturedHookOutput {
|
||||
stdout: string[];
|
||||
exits: number[];
|
||||
}
|
||||
|
||||
export function makeHookCaptures(): CapturedHookOutput & {
|
||||
writeStdout: (s: string) => void;
|
||||
exit: (code: number) => void;
|
||||
} {
|
||||
const stdout: string[] = [];
|
||||
const exits: number[] = [];
|
||||
return {
|
||||
stdout,
|
||||
exits,
|
||||
writeStdout: (s) => stdout.push(s),
|
||||
exit: (c) => exits.push(c),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { preCompactHandler, runPreCompact } from '../../src/hooks/pre-compact.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
|
||||
describe('pre-compact handler', () => {
|
||||
it('extracts scope from session_id, sessionId, or scope', () => {
|
||||
expect(preCompactHandler.parse({ session_id: 'a' }).scope).toBe('a');
|
||||
expect(preCompactHandler.parse({ sessionId: 'b' }).scope).toBe('b');
|
||||
expect(preCompactHandler.parse({ scope: 'c' }).scope).toBe('c');
|
||||
expect(preCompactHandler.parse({}).scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls cleanupFrames (Commit 1.4 renamed from compactMemory)', async () => {
|
||||
const bridge = makeMockBridge({ cleanupFramesResult: { pruned: 4 } });
|
||||
const cap = makeHookCaptures();
|
||||
await runPreCompact({
|
||||
readStdin: async () => JSON.stringify({ session_id: 'sess-3' }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('still calls cleanupFrames even when no scope present', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runPreCompact({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.cleanupFrames).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits 0 when cleanupFrames rejects', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
bridge.cleanupFrames.mockRejectedValueOnce(new Error('cli unreachable'));
|
||||
const cap = makeHookCaptures();
|
||||
await runPreCompact({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sessionStartHandler, runSessionStart } from '../../src/hooks/session-start.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
import type { MemoryHit } from '@waggle/hive-mind-shim-core';
|
||||
|
||||
const HIT_FIXTURE: MemoryHit = {
|
||||
id: 1,
|
||||
content: '[hm src:claude-code event:stop] past observation',
|
||||
importance: 'important',
|
||||
source: 'system',
|
||||
score: 0.87,
|
||||
created_at: '2026-04-28T10:00:00.000Z',
|
||||
from: 'personal',
|
||||
};
|
||||
|
||||
describe('session-start handler', () => {
|
||||
it('parses cwd from payload and falls back to process.cwd()', () => {
|
||||
expect(sessionStartHandler.parse({ cwd: '/proj/x' }).cwd).toBe('/proj/x');
|
||||
expect(sessionStartHandler.parse({}).cwd).toBe(process.cwd());
|
||||
});
|
||||
|
||||
it('parses recallLimit number with default 20', () => {
|
||||
expect(sessionStartHandler.parse({}).recallLimit).toBe(20);
|
||||
expect(sessionStartHandler.parse({ recall_limit: 5 }).recallLimit).toBe(5);
|
||||
expect(sessionStartHandler.parse({ recallLimit: 'not-a-number' }).recallLimit).toBe(20);
|
||||
});
|
||||
|
||||
it('calls recallMemory with personal scope and formats hits into context', async () => {
|
||||
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => JSON.stringify({ cwd: '/proj/x', recall_limit: 1 }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
// Commit 1.4: switchWorkspace removed — only recallMemory should fire.
|
||||
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 1, scope: 'personal', workspace: null });
|
||||
expect(cap.stdout).toHaveLength(1);
|
||||
const parsed = JSON.parse(cap.stdout[0]) as { hookSpecificOutput: { additionalContext: string } };
|
||||
expect(parsed.hookSpecificOutput.additionalContext).toContain('past observation');
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('handles empty recall result gracefully', async () => {
|
||||
const bridge = makeMockBridge({ recallMemoryHits: [] });
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const parsed = JSON.parse(cap.stdout[0]) as { hookSpecificOutput: { additionalContext: string } };
|
||||
expect(parsed.hookSpecificOutput.additionalContext).toContain('no recalled frames');
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('exits 0 even when bridge throws (fail-open)', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
bridge.recallMemory.mockRejectedValueOnce(new Error('cli unreachable'));
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('annotates hits with their workspace origin when from != personal', async () => {
|
||||
const bridge = makeMockBridge({ recallMemoryHits: [{ ...HIT_FIXTURE, from: 'workspace:team-foo' }] });
|
||||
const cap = makeHookCaptures();
|
||||
await runSessionStart({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const parsed = JSON.parse(cap.stdout[0]) as { hookSpecificOutput: { additionalContext: string } };
|
||||
expect(parsed.hookSpecificOutput.additionalContext).toContain('workspace:team-foo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseHookArgs,
|
||||
pickStringField,
|
||||
pickStringFromObject,
|
||||
safeJsonParse,
|
||||
} from '../../src/hooks/_shared.js';
|
||||
|
||||
describe('safeJsonParse', () => {
|
||||
it('returns {} for empty / whitespace input', () => {
|
||||
expect(safeJsonParse('')).toEqual({});
|
||||
expect(safeJsonParse(' ')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parsed JSON when valid', () => {
|
||||
expect(safeJsonParse('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('returns {} when JSON is malformed', () => {
|
||||
expect(safeJsonParse('not json')).toEqual({});
|
||||
expect(safeJsonParse('{')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHookArgs', () => {
|
||||
it('extracts --cli-path value when present', () => {
|
||||
expect(parseHookArgs(['--cli-path', '/abs/cli.js'])).toEqual({ cliPath: '/abs/cli.js' });
|
||||
});
|
||||
|
||||
it('returns {} when --cli-path is absent', () => {
|
||||
expect(parseHookArgs([])).toEqual({});
|
||||
expect(parseHookArgs(['--other-flag', 'value'])).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when --cli-path has no following value', () => {
|
||||
expect(parseHookArgs(['--cli-path'])).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects empty-string value as missing', () => {
|
||||
expect(parseHookArgs(['--cli-path', ''])).toEqual({});
|
||||
});
|
||||
|
||||
it('handles flag in the middle of argv', () => {
|
||||
expect(parseHookArgs(['--foo', 'bar', '--cli-path', '/x.js', '--baz'])).toEqual({ cliPath: '/x.js' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickStringField / pickStringFromObject', () => {
|
||||
it('returns the first non-empty string match', () => {
|
||||
expect(pickStringField({ a: 'x', b: 'y' }, 'a', 'b')).toBe('x');
|
||||
expect(pickStringField({ a: '', b: 'y' }, 'a', 'b')).toBe('y');
|
||||
});
|
||||
|
||||
it('returns undefined when no key resolves', () => {
|
||||
expect(pickStringField({}, 'a')).toBeUndefined();
|
||||
expect(pickStringField(null, 'a')).toBeUndefined();
|
||||
expect(pickStringField(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pickStringFromObject only treats strings as hits', () => {
|
||||
expect(pickStringFromObject({ a: 1 } as Record<string, unknown>, 'a')).toBeUndefined();
|
||||
expect(pickStringFromObject({ a: 'ok' }, 'a')).toBe('ok');
|
||||
expect(pickStringFromObject({ a: '' }, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
262
packages/hive-mind-hooks-claude-code/tests/hooks/stop.test.ts
Normal file
262
packages/hive-mind-hooks-claude-code/tests/hooks/stop.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runStop, stopHandler } from '../../src/hooks/stop.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
|
||||
describe('stop handler', () => {
|
||||
it('extracts response from payload.response or payload.assistant_message', () => {
|
||||
expect(stopHandler.parse({ response: 'r' }).response).toBe('r');
|
||||
expect(stopHandler.parse({ assistant_message: 'a' }).response).toBe('a');
|
||||
expect(stopHandler.parse({}).response).toBe('');
|
||||
});
|
||||
|
||||
it('summarizes long responses and saves an important frame', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
const longResp = 'First sentence. ' + 'X'.repeat(2000) + '.';
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
response: longResp,
|
||||
cwd: '/proj',
|
||||
session_id: 'sess-2',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
const arg = bridge.saveMemory.mock.calls[0][0];
|
||||
expect(['important', 'critical']).toContain(arg.importance);
|
||||
expect(typeof arg.content).toBe('string');
|
||||
expect(arg.content.length).toBeLessThanOrEqual(401); // budget + ellipsis
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('promotes to critical when response contains a "never" directive', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
response: 'never commit secrets to the public repo.',
|
||||
cwd: '/proj',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const arg = bridge.saveMemory.mock.calls[0][0];
|
||||
expect(arg.importance).toBe('critical');
|
||||
});
|
||||
|
||||
it('attaches parent frame id when supplied', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
response: 'something happened.',
|
||||
parent_frame_id: 'frame-99',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const arg = bridge.saveMemory.mock.calls[0][0];
|
||||
expect(arg.parent).toBe('frame-99');
|
||||
});
|
||||
|
||||
it('skips save when response is empty', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AI-OS Phase 1E — opt-in v2 signal emission ─────────────────────
|
||||
|
||||
describe('stop handler — WAGGLE_SIGNAL_EMIT (Phase 1E)', () => {
|
||||
// Capture the global fetch so we can assert on the emitter call.
|
||||
// maybeEmitDiscovery uses globalThis.fetch when no fetchImpl is
|
||||
// passed — the production hook does not pass one.
|
||||
function withCapturedFetch<T>(
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = fetchImpl;
|
||||
return fn().finally(() => {
|
||||
globalThis.fetch = original;
|
||||
});
|
||||
}
|
||||
|
||||
function makeOkFetch(): typeof globalThis.fetch & {
|
||||
calls: Array<{ url: string; body: unknown }>;
|
||||
} {
|
||||
const calls: Array<{ url: string; body: unknown }> = [];
|
||||
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
calls.push({ url: String(url), body });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
dispatched: true,
|
||||
message: {
|
||||
id: 'srv-1',
|
||||
teamId: 'personal::claude-code-hook',
|
||||
senderId: 'claude-code-hook',
|
||||
type: 'broadcast',
|
||||
subtype: 'discovery',
|
||||
content: body?.content ?? {},
|
||||
referenceId: null,
|
||||
routing: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
{ status: 201, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}) as typeof globalThis.fetch & { calls: typeof calls };
|
||||
impl.calls = calls;
|
||||
return impl;
|
||||
}
|
||||
|
||||
function withEnv<T>(
|
||||
key: string,
|
||||
value: string | undefined,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const prev = process.env[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
return fn().finally(() => {
|
||||
if (prev === undefined) delete process.env[key];
|
||||
else process.env[key] = prev;
|
||||
});
|
||||
}
|
||||
|
||||
it('does not emit when WAGGLE_SIGNAL_EMIT is unset', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
const f = makeOkFetch();
|
||||
await withEnv('WAGGLE_SIGNAL_EMIT', undefined, () =>
|
||||
withCapturedFetch(f, () =>
|
||||
runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
response: 'never commit secrets to the public repo.',
|
||||
cwd: '/proj',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not emit when WAGGLE_SIGNAL_EMIT=0', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
const f = makeOkFetch();
|
||||
await withEnv('WAGGLE_SIGNAL_EMIT', '0', () =>
|
||||
withCapturedFetch(f, () =>
|
||||
runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
response: 'never commit secrets to the public repo.',
|
||||
cwd: '/proj',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emits on critical importance when WAGGLE_SIGNAL_EMIT=1', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
const f = makeOkFetch();
|
||||
await withEnv('WAGGLE_SIGNAL_EMIT', '1', () =>
|
||||
withCapturedFetch(f, () =>
|
||||
runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
response: 'never commit secrets to the public repo.',
|
||||
cwd: '/proj',
|
||||
session_id: 'sess-cc',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(f.calls).toHaveLength(1);
|
||||
expect(f.calls[0].url).toContain('/api/waggle-dance/signal');
|
||||
const body = f.calls[0].body as Record<string, unknown>;
|
||||
expect(body.type).toBe('broadcast');
|
||||
expect(body.subtype).toBe('discovery');
|
||||
expect(body.senderId).toBe('claude-code-hook');
|
||||
const content = body.content as Record<string, unknown>;
|
||||
expect(content.tool).toBe('claude-code');
|
||||
expect(content.eventType).toBe('stop');
|
||||
// Critical "never" sentence → critical importance → high-or-critical
|
||||
// emission per the Importance→emit mapping (critical → critical).
|
||||
expect(content.importance).toBe('critical');
|
||||
expect(content.sessionId).toBe('sess-cc');
|
||||
expect(content.frameId).toBe('frame-1');
|
||||
expect(content.memoryWorkspace).toBe('personal');
|
||||
expect(content.summary).toContain('never commit secrets');
|
||||
});
|
||||
|
||||
it('does not emit on a normal-importance turn (emission policy floor)', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
const f = makeOkFetch();
|
||||
// A short benign response → classifyImportance returns 'normal',
|
||||
// which our mapping bumps to 'normal' (not high/critical) → the
|
||||
// maybeEmitDiscovery policy skips emission.
|
||||
await withEnv('WAGGLE_SIGNAL_EMIT', '1', () =>
|
||||
withCapturedFetch(f, () =>
|
||||
runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
response: 'Hello.',
|
||||
cwd: '/proj',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(f.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('saves frame even when the signal endpoint is unreachable (fail-open)', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
const unreachable = (async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}) as typeof globalThis.fetch;
|
||||
await withEnv('WAGGLE_SIGNAL_EMIT', 'true', () =>
|
||||
withCapturedFetch(unreachable, () =>
|
||||
runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
response: 'never commit secrets to the public repo.',
|
||||
cwd: '/proj',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Frame save still happened — emitter failure does not block.
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runUserPromptSubmit, userPromptSubmitHandler } from '../../src/hooks/user-prompt-submit.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
|
||||
describe('user-prompt-submit handler', () => {
|
||||
it('extracts prompt from payload.prompt or payload.user_message', () => {
|
||||
expect(userPromptSubmitHandler.parse({ prompt: 'hi' }).prompt).toBe('hi');
|
||||
expect(userPromptSubmitHandler.parse({ user_message: 'hello' }).prompt).toBe('hello');
|
||||
expect(userPromptSubmitHandler.parse({}).prompt).toBe('');
|
||||
});
|
||||
|
||||
it('saves a temporary frame containing the prompt', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => JSON.stringify({
|
||||
prompt: 'How do I X?',
|
||||
cwd: '/proj/foo',
|
||||
session_id: 'sess-7',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
const arg = bridge.saveMemory.mock.calls[0][0];
|
||||
expect(arg).toMatchObject({
|
||||
content: 'How do I X?',
|
||||
importance: 'temporary',
|
||||
scope: 'sess-7',
|
||||
source: 'claude-code',
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('skips save when no prompt is present', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => '{}',
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('exits 0 even if saveMemory rejects', async () => {
|
||||
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli down') });
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => JSON.stringify({ prompt: 'x' }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
});
|
||||
150
packages/hive-mind-hooks-claude-code/tests/install.test.ts
Normal file
150
packages/hive-mind-hooks-claude-code/tests/install.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { install } from '../src/install.js';
|
||||
import { HIVE_MIND_MARKER, type ClaudeCodeSettings } from '../src/settings-merger.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
hooksDir: string;
|
||||
settingsPath: string;
|
||||
pointerPath: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial: ClaudeCodeSettings): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmc-install-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
const settingsPath = join(claudeDir, 'settings.json');
|
||||
await writeFile(settingsPath, JSON.stringify(initial, null, 2), 'utf-8');
|
||||
const hooksDir = resolve(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
return { home, hooksDir, settingsPath, pointerPath: join(claudeDir, 'hive-mind-install.json') };
|
||||
}
|
||||
|
||||
async function cleanup(env: TestEnv): Promise<void> {
|
||||
await rm(env.home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
describe('install', () => {
|
||||
let env: TestEnv;
|
||||
|
||||
afterEach(async () => {
|
||||
if (env) await cleanup(env);
|
||||
});
|
||||
|
||||
it('throws when settings.json is missing', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmc-install-no-settings-'));
|
||||
try {
|
||||
await expect(install({
|
||||
home,
|
||||
hooksDir: join(home, 'dist', 'hooks'),
|
||||
})).rejects.toThrow(/settings/);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws on malformed JSON in settings.json', async () => {
|
||||
env = await bootstrap({} as ClaudeCodeSettings);
|
||||
await writeFile(env.settingsPath, '{ not valid json', 'utf-8');
|
||||
await expect(install({ home: env.home, hooksDir: env.hooksDir }))
|
||||
.rejects.toThrow(/parse/);
|
||||
});
|
||||
|
||||
it('writes a byte-identical backup before mutating', async () => {
|
||||
env = await bootstrap({ env: { FOO: '1' } } as ClaudeCodeSettings);
|
||||
const original = await readFile(env.settingsPath, 'utf-8');
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const backupContent = await readFile(result.backupPath, 'utf-8');
|
||||
expect(backupContent).toBe(original);
|
||||
});
|
||||
|
||||
it('appends 4 hive entries and preserves existing structure', async () => {
|
||||
const initial: ClaudeCodeSettings = {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ hooks: [{ type: 'command', command: 'node /existing/x.js' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
env = await bootstrap(initial);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
|
||||
expect(after.hooks?.SessionStart).toHaveLength(2);
|
||||
expect(after.hooks?.SessionStart?.[0].hooks[0].command).toBe('node /existing/x.js');
|
||||
expect(after.hooks?.SessionStart?.[1]._hiveMindShim).toBe(HIVE_MIND_MARKER);
|
||||
expect(after.hooks?.UserPromptSubmit).toHaveLength(1);
|
||||
expect(after.hooks?.Stop).toHaveLength(1);
|
||||
expect(after.hooks?.PreCompact).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops a pointer file with the backup path + version', async () => {
|
||||
env = await bootstrap({});
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(existsSync(result.pointerPath)).toBe(true);
|
||||
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(pointer['settings_backup']).toBe(result.backupPath);
|
||||
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
|
||||
expect(typeof pointer['version']).toBe('string');
|
||||
});
|
||||
|
||||
it('respects a custom now() for deterministic backup filename', async () => {
|
||||
env = await bootstrap({});
|
||||
const fixedTs = '2026-04-28T10:30:45.123Z';
|
||||
const result = await install({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
now: () => new Date(fixedTs),
|
||||
});
|
||||
expect(result.backupPath).toContain('hive-mind-backup.2026-04-28T10-30-45-123Z');
|
||||
const stats = await stat(result.backupPath);
|
||||
expect(stats.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('threads --cli-path into every generated hook command', async () => {
|
||||
env = await bootstrap({});
|
||||
const cliPath = '/abs/path/to/dist/index.js';
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
|
||||
expect(result.cliPath).toBe(cliPath);
|
||||
|
||||
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
|
||||
const sessionStart = after.hooks?.SessionStart?.[0];
|
||||
expect(sessionStart?.hooks[0].command).toContain(`--cli-path "${cliPath}"`);
|
||||
const stop = after.hooks?.Stop?.[0];
|
||||
expect(stop?.hooks[0].command).toContain(`--cli-path "${cliPath}"`);
|
||||
});
|
||||
|
||||
it('records cli_path in the install pointer for verify to pick up', async () => {
|
||||
env = await bootstrap({});
|
||||
const cliPath = '/abs/cli.js';
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
|
||||
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(pointer['cli_path']).toBe(cliPath);
|
||||
});
|
||||
|
||||
it('records cli_path: null when --cli-path is omitted', async () => {
|
||||
env = await bootstrap({});
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.cliPath).toBeUndefined();
|
||||
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(pointer['cli_path']).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects --cli-path values that contain double-quote characters', async () => {
|
||||
env = await bootstrap({});
|
||||
await expect(install({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
cliPath: 'malicious" && rm -rf / "',
|
||||
})).rejects.toThrow(/double-quote/);
|
||||
});
|
||||
|
||||
it('treats whitespace-only --cli-path as unset', async () => {
|
||||
env = await bootstrap({});
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir, cliPath: ' ' });
|
||||
expect(result.cliPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
73
packages/hive-mind-hooks-claude-code/tests/paths.test.ts
Normal file
73
packages/hive-mind-hooks-claude-code/tests/paths.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
allHookBasenames,
|
||||
backupPathFor,
|
||||
hookCommandFor,
|
||||
resolvePaths,
|
||||
} from '../src/paths.js';
|
||||
|
||||
describe('resolvePaths', () => {
|
||||
it('places settings.json + pointer under <home>/.claude/', () => {
|
||||
const home = resolve('/fake/home');
|
||||
const paths = resolvePaths({ home, hooksDir: resolve('/some/dist/hooks') });
|
||||
expect(paths.claudeDir).toBe(join(home, '.claude'));
|
||||
expect(paths.settingsPath).toBe(join(home, '.claude', 'settings.json'));
|
||||
expect(paths.pointerPath).toBe(join(home, '.claude', 'hive-mind-install.json'));
|
||||
});
|
||||
|
||||
it('hooksDir override wins over moduleUrl', () => {
|
||||
const explicit = resolve('/x/y/hooks');
|
||||
const paths = resolvePaths({
|
||||
home: resolve('/h'),
|
||||
hooksDir: explicit,
|
||||
moduleUrl: 'file:///irrelevant/dist/install.js',
|
||||
});
|
||||
expect(paths.hooksDir).toBe(explicit);
|
||||
});
|
||||
|
||||
it('falls back to cwd/dist/hooks when neither moduleUrl nor hooksDir is given', () => {
|
||||
const paths = resolvePaths({ home: resolve('/h') });
|
||||
expect(paths.hooksDir).toBe(resolve(process.cwd(), 'dist', 'hooks'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('hookCommandFor', () => {
|
||||
it('produces a quoted node invocation with absolute path', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/hooks'), 'session-start');
|
||||
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
|
||||
});
|
||||
|
||||
it('appends --cli-path when supplied', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/hooks'), 'session-start', '/abs/cli/dist/index.js');
|
||||
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
|
||||
});
|
||||
|
||||
it('omits --cli-path when empty string is passed', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/hooks'), 'session-start', '');
|
||||
expect(cmd).not.toContain('--cli-path');
|
||||
});
|
||||
|
||||
it('preserves Windows-style paths inside quotes', () => {
|
||||
const cmd = hookCommandFor('/abs/dist/hooks', 'stop', 'C:\\Program Files\\hive-mind\\dist\\index.js');
|
||||
expect(cmd).toContain('--cli-path "C:\\Program Files\\hive-mind\\dist\\index.js"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupPathFor', () => {
|
||||
it('replaces colons and dots in the timestamp for filesystem safety', () => {
|
||||
const backup = backupPathFor('/h/.claude/settings.json', '2026-04-28T10:30:45.123Z');
|
||||
expect(backup).toBe('/h/.claude/settings.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allHookBasenames', () => {
|
||||
it('returns the four canonical basenames', () => {
|
||||
expect([...allHookBasenames()].sort()).toEqual([
|
||||
'pre-compact',
|
||||
'session-start',
|
||||
'stop',
|
||||
'user-prompt-submit',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HIVE_MIND_MARKER,
|
||||
HOOK_EVENT_BY_BASENAME,
|
||||
defaultHookEntries,
|
||||
hasHiveHooks,
|
||||
mergeHiveHooks,
|
||||
type ClaudeCodeSettings,
|
||||
type HookEntrySpec,
|
||||
} from '../src/settings-merger.js';
|
||||
import { hookCommandFor } from '../src/paths.js';
|
||||
|
||||
const HOOKS_DIR = '/abs/dist/hooks';
|
||||
|
||||
function makeEntry(basename: 'session-start' | 'user-prompt-submit' | 'stop' | 'pre-compact'): HookEntrySpec {
|
||||
return {
|
||||
basename,
|
||||
command: hookCommandFor(HOOKS_DIR, basename),
|
||||
timeout: 5,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeHiveHooks', () => {
|
||||
it('returns a new object — does not mutate input', () => {
|
||||
const original: ClaudeCodeSettings = { hooks: { SessionStart: [] } };
|
||||
const merged = mergeHiveHooks(original, [makeEntry('session-start')]);
|
||||
expect(merged).not.toBe(original);
|
||||
expect(original.hooks?.SessionStart).toEqual([]);
|
||||
});
|
||||
|
||||
it('appends a hive group to each requested event array', () => {
|
||||
const merged = mergeHiveHooks({}, [
|
||||
makeEntry('session-start'),
|
||||
makeEntry('user-prompt-submit'),
|
||||
makeEntry('stop'),
|
||||
makeEntry('pre-compact'),
|
||||
]);
|
||||
expect(merged.hooks?.SessionStart).toHaveLength(1);
|
||||
expect(merged.hooks?.UserPromptSubmit).toHaveLength(1);
|
||||
expect(merged.hooks?.Stop).toHaveLength(1);
|
||||
expect(merged.hooks?.PreCompact).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves existing hook entries verbatim', () => {
|
||||
const existing: ClaudeCodeSettings = {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ hooks: [{ type: 'command', command: 'node /existing/gsd-context.js', timeout: 10 }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const merged = mergeHiveHooks(existing, [makeEntry('session-start')]);
|
||||
const arr = merged.hooks?.SessionStart;
|
||||
expect(arr).toHaveLength(2);
|
||||
expect(arr?.[0].hooks[0].command).toBe('node /existing/gsd-context.js');
|
||||
expect(arr?.[1]._hiveMindShim).toBe(HIVE_MIND_MARKER);
|
||||
});
|
||||
|
||||
it('replaces an existing hive entry when the same command is re-installed (idempotent)', () => {
|
||||
const cmd = hookCommandFor(HOOKS_DIR, 'session-start');
|
||||
const merged1 = mergeHiveHooks({}, [{ basename: 'session-start', command: cmd, timeout: 5 }]);
|
||||
const merged2 = mergeHiveHooks(merged1, [{ basename: 'session-start', command: cmd, timeout: 7 }]);
|
||||
expect(merged2.hooks?.SessionStart).toHaveLength(1);
|
||||
expect(merged2.hooks?.SessionStart?.[0].hooks[0].timeout).toBe(7);
|
||||
});
|
||||
|
||||
it('preserves unrelated top-level fields', () => {
|
||||
const merged = mergeHiveHooks(
|
||||
{ env: { SOMETHING: '1' }, statusLine: { type: 'command', command: 'foo' }, hooks: {} } as ClaudeCodeSettings,
|
||||
[makeEntry('session-start')],
|
||||
);
|
||||
expect(merged['env']).toEqual({ SOMETHING: '1' });
|
||||
expect(merged['statusLine']).toEqual({ type: 'command', command: 'foo' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHiveHooks', () => {
|
||||
it('returns false on empty settings', () => {
|
||||
expect(hasHiveHooks(undefined)).toBe(false);
|
||||
expect(hasHiveHooks({})).toBe(false);
|
||||
expect(hasHiveHooks({ hooks: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when at least one event array has the marker', () => {
|
||||
const merged = mergeHiveHooks({}, [makeEntry('stop')]);
|
||||
expect(hasHiveHooks(merged)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultHookEntries', () => {
|
||||
it('builds 4 entries — one per canonical hook', () => {
|
||||
const entries = defaultHookEntries(HOOKS_DIR, 5, hookCommandFor);
|
||||
expect(entries).toHaveLength(4);
|
||||
const events = entries.map((e) => HOOK_EVENT_BY_BASENAME[e.basename]);
|
||||
expect([...events].sort()).toEqual(['PreCompact', 'SessionStart', 'Stop', 'UserPromptSubmit']);
|
||||
});
|
||||
|
||||
it('every entry carries the requested timeout', () => {
|
||||
const entries = defaultHookEntries(HOOKS_DIR, 9, hookCommandFor);
|
||||
expect(entries.every((e) => e.timeout === 9)).toBe(true);
|
||||
});
|
||||
|
||||
it('threads cliPath into every generated hook command', () => {
|
||||
const cliPath = '/abs/cli.js';
|
||||
const entries = defaultHookEntries(HOOKS_DIR, 5, hookCommandFor, cliPath);
|
||||
expect(entries.every((e) => e.command.includes(`--cli-path "${cliPath}"`))).toBe(true);
|
||||
});
|
||||
|
||||
it('omits --cli-path entirely when none supplied', () => {
|
||||
const entries = defaultHookEntries(HOOKS_DIR, 5, hookCommandFor);
|
||||
expect(entries.every((e) => !e.command.includes('--cli-path'))).toBe(true);
|
||||
});
|
||||
});
|
||||
100
packages/hive-mind-hooks-claude-code/tests/uninstall.test.ts
Normal file
100
packages/hive-mind-hooks-claude-code/tests/uninstall.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { install } from '../src/install.js';
|
||||
import { uninstall } from '../src/uninstall.js';
|
||||
import type { ClaudeCodeSettings } from '../src/settings-merger.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
hooksDir: string;
|
||||
settingsPath: string;
|
||||
pointerPath: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial: ClaudeCodeSettings): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmc-uninstall-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
const settingsPath = join(claudeDir, 'settings.json');
|
||||
await writeFile(settingsPath, JSON.stringify(initial, null, 2), 'utf-8');
|
||||
const hooksDir = resolve(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
return { home, hooksDir, settingsPath, pointerPath: join(claudeDir, 'hive-mind-install.json') };
|
||||
}
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash('sha256').update(s, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
describe('uninstall', () => {
|
||||
let env: TestEnv;
|
||||
|
||||
afterEach(async () => {
|
||||
if (env) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws when no pointer file exists', async () => {
|
||||
env = await bootstrap({});
|
||||
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
|
||||
.rejects.toThrow(/install pointer/);
|
||||
});
|
||||
|
||||
it('throws when pointer is malformed', async () => {
|
||||
env = await bootstrap({});
|
||||
await writeFile(env.pointerPath, '{}', 'utf-8');
|
||||
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
|
||||
.rejects.toThrow(/malformed/);
|
||||
});
|
||||
|
||||
it('install + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
|
||||
const initialSettings: ClaudeCodeSettings = {
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' },
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ hooks: [{ type: 'command', command: 'node /existing/gsd-context.js' }] },
|
||||
],
|
||||
PreCompact: [
|
||||
{ hooks: [{ type: 'command', command: 'node /existing/pre-compact.js', timeout: 10 }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
env = await bootstrap(initialSettings);
|
||||
const preInstall = await readFile(env.settingsPath, 'utf-8');
|
||||
const preHash = sha256(preInstall);
|
||||
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const afterInstall = await readFile(env.settingsPath, 'utf-8');
|
||||
expect(sha256(afterInstall)).not.toBe(preHash);
|
||||
|
||||
await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
const afterUninstall = await readFile(env.settingsPath, 'utf-8');
|
||||
expect(sha256(afterUninstall)).toBe(preHash);
|
||||
expect(afterUninstall).toBe(preInstall);
|
||||
});
|
||||
|
||||
it('removes the backup file by default after restore', async () => {
|
||||
env = await bootstrap({});
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(existsSync(result.backupPath)).toBe(true);
|
||||
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(u.backupRemoved).toBe(true);
|
||||
expect(existsSync(result.backupPath)).toBe(false);
|
||||
expect(existsSync(result.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the backup when cleanupBackup=false', async () => {
|
||||
env = await bootstrap({});
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const u = await uninstall({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
cleanupBackup: false,
|
||||
});
|
||||
expect(u.backupRemoved).toBe(false);
|
||||
expect(existsSync(result.backupPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
145
packages/hive-mind-hooks-claude-code/tests/verify.test.ts
Normal file
145
packages/hive-mind-hooks-claude-code/tests/verify.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { install } from '../src/install.js';
|
||||
import { verify } from '../src/verify.js';
|
||||
import type { ClaudeCodeSettings } from '../src/settings-merger.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
hooksDir: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial: ClaudeCodeSettings, withHookFiles: boolean): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmc-verify-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
const settingsPath = join(claudeDir, 'settings.json');
|
||||
await writeFile(settingsPath, JSON.stringify(initial, null, 2), 'utf-8');
|
||||
const hooksDir = join(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
if (withHookFiles) {
|
||||
for (const b of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
|
||||
await writeFile(join(hooksDir, `${b}.js`), '/* mock hook */', 'utf-8');
|
||||
}
|
||||
}
|
||||
return { home, hooksDir };
|
||||
}
|
||||
|
||||
function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: string }): typeof import('node:child_process').spawn {
|
||||
return ((_cmd: string, _args: readonly string[], _options?: unknown) => {
|
||||
const emitter = new EventEmitter();
|
||||
const stdout = Readable.from([Buffer.from(opts.stdout ?? 'hive-mind-cli help text\n')]);
|
||||
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
|
||||
const child = Object.assign(emitter, {
|
||||
stdout,
|
||||
stderr,
|
||||
kill: vi.fn(() => true),
|
||||
}) as unknown as ChildProcess;
|
||||
setImmediate(() => emitter.emit('exit', opts.exitCode));
|
||||
return child;
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
}
|
||||
|
||||
describe('verify', () => {
|
||||
const envs: TestEnv[] = [];
|
||||
afterEach(async () => {
|
||||
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports failure when settings.json is missing', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmc-verify-missing-'));
|
||||
envs.push({ home, hooksDir: '' });
|
||||
const result = await verify({
|
||||
home,
|
||||
hooksDir: join(home, 'fake-dist', 'hooks'),
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks[0].name).toBe('settings.json exists');
|
||||
expect(result.checks[0].ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reports failure when hooks are not yet installed', async () => {
|
||||
const env = await bootstrap({ hooks: {} }, true);
|
||||
envs.push(env);
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.some((c) => !c.ok && c.name.includes('contains hive-mind entry'))).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after a successful install with hook files on disk and CLI reachable', async () => {
|
||||
const env = await bootstrap({}, true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
|
||||
expect(cliCheck?.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('reports CLI unreachable when the spawn exits non-zero', async () => {
|
||||
const env = await bootstrap({}, true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 127, stderr: 'command not found' }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
|
||||
expect(cliCheck?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('uses cli_path from the install pointer for the probe', async () => {
|
||||
const env = await bootstrap({}, true);
|
||||
envs.push(env);
|
||||
const cliPath = '/abs/from/pointer.js';
|
||||
await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
|
||||
|
||||
const records: Array<{ command: string; args: readonly string[] }> = [];
|
||||
const recordingSpawn = ((cmd: string, args: readonly string[]) => {
|
||||
records.push({ command: cmd, args });
|
||||
return mockSpawnImpl({ exitCode: 0 })(cmd, args);
|
||||
}) as typeof import('node:child_process').spawn;
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: recordingSpawn,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
// For a .js cli_path, verify should spawn `node <path> --help`.
|
||||
const probeRecord = records[records.length - 1];
|
||||
expect(probeRecord.command).toBe(process.execPath);
|
||||
expect(probeRecord.args[0]).toBe(cliPath);
|
||||
expect(probeRecord.args[1]).toBe('--help');
|
||||
});
|
||||
|
||||
it('flags missing hook script files even when settings entry is present', async () => {
|
||||
const env = await bootstrap({}, false); // no hook .js files
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
const fileCheck = result.checks.find((c) => c.name.includes('readable on disk'));
|
||||
expect(fileCheck?.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user