Files
waggle-os/packages/hive-mind-hooks-claude-code/tests/settings-merger.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

167 lines
6.6 KiB
TypeScript

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('replaces stale marked entries when the install path changes', () => {
const oldCommand = hookCommandFor('/old/dist/hooks', 'session-start', '/old/cli.js');
const newCommand = hookCommandFor('/new/dist/hooks', 'session-start', '/new/cli.js');
const merged1 = mergeHiveHooks({}, [{ basename: 'session-start', command: oldCommand, timeout: 5 }]);
const merged2 = mergeHiveHooks(merged1, [{ basename: 'session-start', command: newCommand, timeout: 7 }]);
const markedGroups = merged2.hooks?.SessionStart?.filter(
(group) => group._hiveMindShim === HIVE_MIND_MARKER,
);
expect(markedGroups).toHaveLength(1);
expect(markedGroups?.[0].hooks[0]).toEqual({
type: 'command',
command: newCommand,
timeout: 7,
});
});
it('recognizes and replaces a hook after Claude strips the ownership marker', () => {
const oldCommand = hookCommandFor('/old/hive-mind-hooks-claude-code/dist/hooks', 'session-start', '/old/cli.js');
const newCommand = hookCommandFor('/new/hive-mind-hooks-claude-code/dist/hooks', 'session-start', '/new/cli.js');
const installed = mergeHiveHooks({}, [{ basename: 'session-start', command: oldCommand, timeout: 5 }]);
const normalized = structuredClone(installed);
delete normalized.hooks?.SessionStart?.[0]._hiveMindShim;
expect(hasHiveHooks(normalized)).toBe(true);
const upgraded = mergeHiveHooks(normalized, [{ basename: 'session-start', command: newCommand, timeout: 7 }]);
expect(upgraded.hooks?.SessionStart).toHaveLength(1);
expect(upgraded.hooks?.SessionStart?.[0].hooks[0].command).toBe(newCommand);
});
it('does not claim an unrelated command that only mentions a Waggle hook path', () => {
const unrelated: ClaudeCodeSettings = {
hooks: {
SessionStart: [{
hooks: [{
type: 'command',
command: 'echo "C:\\archive\\hive-mind-hooks-claude-code\\dist\\hooks\\session-start.js"',
}],
}],
},
};
expect(hasHiveHooks(unrelated)).toBe(false);
const command = hookCommandFor(
'/new/hive-mind-hooks-claude-code/dist/hooks',
'session-start',
'/new/cli.js',
);
const merged = mergeHiveHooks(unrelated, [{ basename: 'session-start', command, timeout: 5 }]);
expect(merged.hooks?.SessionStart).toHaveLength(2);
expect(merged.hooks?.SessionStart?.[0]).toEqual(unrelated.hooks?.SessionStart?.[0]);
});
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);
});
});