moving
This commit is contained in:
@@ -1,11 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createLogger } from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
buildHookBridgeOptions,
|
||||
parseHookArgs,
|
||||
pickStringField,
|
||||
pickStringFromObject,
|
||||
safeJsonParse,
|
||||
} from '../../src/hooks/_shared.js';
|
||||
|
||||
describe('buildHookBridgeOptions', () => {
|
||||
it('uses one bounded attempt for every Claude hook', () => {
|
||||
const logger = createLogger({ name: 'claude-hook-test' });
|
||||
const sessionStart = buildHookBridgeOptions(
|
||||
logger,
|
||||
'C:\\waggle\\hive-mind-cli.js',
|
||||
);
|
||||
expect(sessionStart).toMatchObject({
|
||||
logger,
|
||||
cli_path: 'C:\\waggle\\hive-mind-cli.js',
|
||||
timeout_ms: 10_000,
|
||||
max_retries: 0,
|
||||
});
|
||||
const stop = buildHookBridgeOptions(logger, 'C:\\waggle\\hive-mind-cli.js');
|
||||
expect(stop.timeout_ms).toBe(10_000);
|
||||
expect(stop.max_retries).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeJsonParse', () => {
|
||||
it('returns {} for empty / whitespace input', () => {
|
||||
expect(safeJsonParse('')).toEqual({});
|
||||
|
||||
@@ -3,12 +3,39 @@ 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', () => {
|
||||
it('extracts response from current and legacy Claude Stop payload fields', () => {
|
||||
expect(stopHandler.parse({ response: 'r' }).response).toBe('r');
|
||||
expect(stopHandler.parse({ assistant_message: 'a' }).response).toBe('a');
|
||||
expect(stopHandler.parse({ last_assistant_message: 'latest' }).response).toBe('latest');
|
||||
expect(stopHandler.parse({
|
||||
last_assistant_message: 'current',
|
||||
response: 'legacy',
|
||||
}).response).toBe('current');
|
||||
expect(stopHandler.parse({}).response).toBe('');
|
||||
});
|
||||
|
||||
it('saves the assistant turn from a Claude Code 2.1 host-shaped payload', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
session_id: 'host-session',
|
||||
transcript_path: 'C:\\tmp\\transcript.jsonl',
|
||||
cwd: 'C:\\project',
|
||||
hook_event_name: 'Stop',
|
||||
stop_hook_active: false,
|
||||
last_assistant_message: 'CLAUDE_HOST_CANARY_OK',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
expect(bridge.saveMemory.mock.calls[0][0].content).toContain('CLAUDE_HOST_CANARY_OK');
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('summarizes long responses and saves an important frame', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
|
||||
@@ -35,13 +35,20 @@ describe('install', () => {
|
||||
if (env) await cleanup(env);
|
||||
});
|
||||
|
||||
it('throws when settings.json is missing', async () => {
|
||||
it('creates minimal settings and records ownership when settings.json is missing', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmc-install-no-settings-'));
|
||||
try {
|
||||
await expect(install({
|
||||
const result = await install({
|
||||
home,
|
||||
hooksDir: join(home, 'dist', 'hooks'),
|
||||
})).rejects.toThrow(/settings/);
|
||||
});
|
||||
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 });
|
||||
}
|
||||
@@ -81,12 +88,33 @@ describe('install', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -64,6 +64,59 @@ describe('mergeHiveHooks', () => {
|
||||
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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { install } from '../src/install.js';
|
||||
import { uninstall } from '../src/uninstall.js';
|
||||
import { verify } from '../src/verify.js';
|
||||
import type { ClaudeCodeSettings } from '../src/settings-merger.js';
|
||||
|
||||
interface TestEnv {
|
||||
@@ -50,7 +51,7 @@ describe('uninstall', () => {
|
||||
.rejects.toThrow(/malformed/);
|
||||
});
|
||||
|
||||
it('install + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
|
||||
it('reinstall + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
|
||||
const initialSettings: ClaudeCodeSettings = {
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' },
|
||||
hooks: {
|
||||
@@ -69,6 +70,7 @@ describe('uninstall', () => {
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const afterInstall = await readFile(env.settingsPath, 'utf-8');
|
||||
expect(sha256(afterInstall)).not.toBe(preHash);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
|
||||
await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
const afterUninstall = await readFile(env.settingsPath, 'utf-8');
|
||||
@@ -76,6 +78,36 @@ describe('uninstall', () => {
|
||||
expect(afterUninstall).toBe(preInstall);
|
||||
});
|
||||
|
||||
it('install + verify + uninstall round-trips an absent settings file', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmc-uninstall-fresh-'));
|
||||
const hooksDir = resolve(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
for (const basename of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
|
||||
await writeFile(join(hooksDir, `${basename}.js`), '/* mock hook */', 'utf-8');
|
||||
}
|
||||
const cliPath = join(home, 'fake-cli.mjs');
|
||||
await writeFile(cliPath, 'process.stdout.write("ok\\n");', 'utf-8');
|
||||
env = {
|
||||
home,
|
||||
hooksDir,
|
||||
settingsPath: join(home, '.claude', 'settings.json'),
|
||||
pointerPath: join(home, '.claude', 'hive-mind-install.json'),
|
||||
};
|
||||
|
||||
expect(existsSync(env.settingsPath)).toBe(false);
|
||||
const installed = await install({ home, hooksDir, cliPath });
|
||||
const reinstalled = await install({ home, hooksDir, cliPath });
|
||||
expect(reinstalled.alreadyInstalled).toBe(true);
|
||||
expect(reinstalled.backupPath).toBe(installed.backupPath);
|
||||
await expect(verify({ home, hooksDir })).resolves.toMatchObject({ ok: true });
|
||||
|
||||
const removed = await uninstall({ home, hooksDir });
|
||||
expect(removed.settingsRemoved).toBe(true);
|
||||
expect(existsSync(env.settingsPath)).toBe(false);
|
||||
expect(existsSync(installed.pointerPath)).toBe(false);
|
||||
expect(existsSync(installed.backupPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the backup file by default after restore', async () => {
|
||||
env = await bootstrap({});
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
@@ -20,7 +20,7 @@ async function bootstrap(initial: ClaudeCodeSettings, withHookFiles: boolean): P
|
||||
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');
|
||||
const hooksDir = join(home, 'hive-mind-hooks-claude-code', 'dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
if (withHookFiles) {
|
||||
for (const b of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
|
||||
@@ -48,6 +48,7 @@ function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: strin
|
||||
describe('verify', () => {
|
||||
const envs: TestEnv[] = [];
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -90,6 +91,25 @@ describe('verify', () => {
|
||||
expect(cliCheck?.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after Claude normalizes away marker keys but preserves hook commands', async () => {
|
||||
const env = await bootstrap({}, true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const settingsPath = join(env.home, '.claude', 'settings.json');
|
||||
const settings = JSON.parse(await readFile(settingsPath, 'utf-8')) as ClaudeCodeSettings;
|
||||
for (const groups of Object.values(settings.hooks ?? {})) {
|
||||
for (const group of groups) delete group._hiveMindShim;
|
||||
}
|
||||
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('reports CLI unreachable when the spawn exits non-zero', async () => {
|
||||
const env = await bootstrap({}, true);
|
||||
envs.push(env);
|
||||
@@ -142,4 +162,44 @@ describe('verify', () => {
|
||||
const fileCheck = result.checks.find((c) => c.name.includes('readable on disk'));
|
||||
expect(fileCheck?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('flags missing hook scripts when the installed command pins a quoted Windows Node path', async () => {
|
||||
vi.stubEnv('WAGGLE_HOOK_NODE_PATH', 'C:\\Program Files\\nodejs\\node.exe');
|
||||
const env = await bootstrap({}, false);
|
||||
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 fileChecks = result.checks.filter((c) => c.name.includes('readable on disk'));
|
||||
expect(fileChecks).toHaveLength(4);
|
||||
expect(fileChecks.every((check) => check.ok === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a readable hook command from a stale install directory', async () => {
|
||||
const env = await bootstrap({}, true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const currentHooksDir = join(env.home, 'current', 'hive-mind-hooks-claude-code', 'dist', 'hooks');
|
||||
await mkdir(currentHooksDir, { recursive: true });
|
||||
for (const basename of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
|
||||
await writeFile(join(currentHooksDir, `${basename}.js`), '/* current hook */', 'utf-8');
|
||||
}
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: currentHooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.filter((check) => (
|
||||
!check.ok && check.name.includes('contains hive-mind entry')
|
||||
))).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user