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

179 lines
7.5 KiB
TypeScript

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('creates minimal settings and records ownership when settings.json is missing', async () => {
const home = await mkdtemp(join(tmpdir(), 'hmc-install-no-settings-'));
try {
const result = await install({
home,
hooksDir: join(home, 'dist', 'hooks'),
});
const settings = JSON.parse(await readFile(result.paths.settingsPath, 'utf-8')) as ClaudeCodeSettings;
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(Object.keys(settings)).toEqual(['hooks']);
expect(settings.hooks?.SessionStart).toHaveLength(1);
expect(pointer['created_by_us']).toBe(true);
expect(pointer['config_path']).toBe(result.paths.settingsPath);
expect(await readFile(result.backupPath, 'utf-8')).toBe('{}\n');
} 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('gives cold SessionStart more time than write hooks by default', async () => {
env = await bootstrap({});
await install({ home: env.home, hooksDir: env.hooksDir });
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
const timeouts = Object.values(after.hooks ?? {}).flatMap((groups) => (
groups.map((group) => group.hooks[0]?.timeout)
));
expect(timeouts).toEqual([15, 12, 12, 12]);
});
it('uses an explicit timeout override for every hook', async () => {
env = await bootstrap({});
await install({ home: env.home, hooksDir: env.hooksDir, hookTimeoutSeconds: 9 });
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
const timeouts = Object.values(after.hooks ?? {}).flatMap((groups) => (
groups.map((group) => group.hooks[0]?.timeout)
));
expect(timeouts).toEqual([9, 9, 9, 9]);
});
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['created_by_us']).toBe(false);
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();
});
});