This commit is contained in:
62
packages/hive-mind-hooks-codex/tests/hooks/_test-helpers.ts
Normal file
62
packages/hive-mind-hooks-codex/tests/hooks/_test-helpers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the frozen CC reference test helper (tests/hooks/_test-helpers.ts).
|
||||
* The codex hooks drive the SAME shared `runHook` contract, so the same
|
||||
* injectable CliBridge mock + stdout/exit captures apply unchanged.
|
||||
*/
|
||||
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,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runPreCompact } from '../../src/hooks/pre-compact.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
|
||||
describe('codex pre-compact handler', () => {
|
||||
it('calls cleanupFrames to merge superseded frames before host compaction', async () => {
|
||||
const bridge = makeMockBridge({ cleanupFramesResult: { pruned: 4 } });
|
||||
const cap = makeHookCaptures();
|
||||
await runPreCompact({
|
||||
readStdin: async () => JSON.stringify({ session_id: 'sess-3', trigger: 'auto' }),
|
||||
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/session present', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runPreCompact({
|
||||
readStdin: async () => JSON.stringify({ trigger: 'manual' }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.cleanupFrames).toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: 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,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { 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:codex event:stop] past observation',
|
||||
importance: 'important',
|
||||
source: 'system',
|
||||
score: 0.87,
|
||||
created_at: '2026-04-28T10:00:00.000Z',
|
||||
from: 'personal',
|
||||
};
|
||||
|
||||
describe('codex session-start handler', () => {
|
||||
it('recalls personal-scoped frames and injects them as additionalContext', 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,
|
||||
});
|
||||
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 1, scope: 'personal', workspace: null });
|
||||
expect(cap.stdout).toHaveLength(1);
|
||||
const parsed = JSON.parse(cap.stdout[0]) as {
|
||||
hookSpecificOutput: { source: string; additionalContext: string };
|
||||
};
|
||||
// Codex has no custom formatInject ⇒ the default CC hookSpecificOutput shape,
|
||||
// stamped with source 'codex'.
|
||||
expect(parsed.hookSpecificOutput.source).toBe('codex');
|
||||
expect(parsed.hookSpecificOutput.additionalContext).toContain('past observation');
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('handles an 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('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');
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: exits 0 even when the bridge throws', 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]);
|
||||
});
|
||||
});
|
||||
103
packages/hive-mind-hooks-codex/tests/hooks/stop.test.ts
Normal file
103
packages/hive-mind-hooks-codex/tests/hooks/stop.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, afterEach, vi } from 'vitest';
|
||||
import { runStop } from '../../src/hooks/stop.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
import type { HookFrame } from '@waggle/hive-mind-shim-core';
|
||||
|
||||
describe('codex stop handler', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('reads the codex last_assistant_message and saves an important frame', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
last_assistant_message: 'Here is the answer to your question about X.',
|
||||
cwd: '/proj/foo',
|
||||
session_id: 'sess-9',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame.source).toBe('codex');
|
||||
expect(frame.scope).toBe('sess-9');
|
||||
expect(['important', 'critical']).toContain(frame.importance);
|
||||
expect(frame.content.length).toBeGreaterThan(0);
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('links the Stop frame to its parent prompt frame when known', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
last_assistant_message: 'done',
|
||||
parent_frame_id: 'frame-prompt-1',
|
||||
session_id: 's',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame.parent).toBe('frame-prompt-1');
|
||||
});
|
||||
|
||||
it('still honours the CC response fallback keys (response / assistant_message)', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ response: 'classic CC key', session_id: 's' }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame.content.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('skips the save when no response is present', 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]);
|
||||
});
|
||||
|
||||
it('does NOT emit a discovery signal by default (WAGGLE_SIGNAL_EMIT off)', async () => {
|
||||
vi.stubEnv('WAGGLE_SIGNAL_EMIT', '');
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ last_assistant_message: 'hi', session_id: 's' }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
// callMcpTool is how the bridge would reach the sidecar; no emit by default.
|
||||
expect(bridge.callMcpTool).not.toHaveBeenCalled();
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('FAIL-OPEN: exits 0 even when saveMemory rejects', async () => {
|
||||
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli unreachable') });
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({ last_assistant_message: 'x', session_id: 's' }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runUserPromptSubmit } from '../../src/hooks/user-prompt-submit.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
import type { HookFrame } from '@waggle/hive-mind-shim-core';
|
||||
|
||||
describe('codex user-prompt-submit handler', () => {
|
||||
it('saves a temporary, codex-sourced 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 frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame).toMatchObject({
|
||||
content: 'How do I X?',
|
||||
importance: 'temporary',
|
||||
scope: 'sess-7',
|
||||
source: 'codex',
|
||||
});
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('reads the codex user_message fallback when prompt is absent', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runUserPromptSubmit({
|
||||
readStdin: async () => JSON.stringify({ user_message: 'hello from codex', session_id: 's1' }),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
|
||||
expect(frame.content).toBe('hello from codex');
|
||||
expect(frame.source).toBe('codex');
|
||||
});
|
||||
|
||||
it('skips the 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('FAIL-OPEN: 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]);
|
||||
});
|
||||
});
|
||||
184
packages/hive-mind-hooks-codex/tests/install.test.ts
Normal file
184
packages/hive-mind-hooks-codex/tests/install.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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 { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { install } from '../src/install.js';
|
||||
import { HIVE_MIND_MARKER } from '../src/adapter.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// This file's last test spawns the COMPILED CLI (dist/bin/codex-hooks.js).
|
||||
// dist/ is gitignored and a clean CI checkout runs no build step, so the
|
||||
// artifact is absent there — skip (don't fail) when it's missing. The other
|
||||
// tests import from ../src and need no build.
|
||||
const BIN_PATH = resolve(
|
||||
fileURLToPath(new URL('../dist/bin/codex-hooks.js', import.meta.url)),
|
||||
);
|
||||
const BIN_BUILT = existsSync(BIN_PATH);
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
hooksDir: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
}
|
||||
|
||||
/** Codex hooks.json is OPTIONAL — `withConfig=false` exercises create-if-missing. */
|
||||
async function bootstrap(
|
||||
initial: Record<string, unknown> | undefined,
|
||||
): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmcdx-install-'));
|
||||
const codexDir = join(home, '.codex');
|
||||
await mkdir(codexDir, { recursive: true });
|
||||
const configPath = join(codexDir, 'hooks.json');
|
||||
if (initial !== undefined) {
|
||||
await writeFile(configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
const hooksDir = resolve(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
return { home, hooksDir, configPath, pointerPath: join(codexDir, 'hive-mind-install.json') };
|
||||
}
|
||||
|
||||
describe('install (codex)', () => {
|
||||
let env: TestEnv;
|
||||
|
||||
afterEach(async () => {
|
||||
if (env) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws on malformed JSON in an existing hooks.json', async () => {
|
||||
env = await bootstrap({});
|
||||
await writeFile(env.configPath, '{ not valid json', 'utf-8');
|
||||
await expect(install({ home: env.home, hooksDir: env.hooksDir }))
|
||||
.rejects.toThrow(/parse/);
|
||||
});
|
||||
|
||||
// ── pre-existed branch ────────────────────────────────────────────────
|
||||
|
||||
it('writes a byte-identical backup before mutating a pre-existing hooks.json', async () => {
|
||||
env = await bootstrap({ hooks: {} });
|
||||
const original = await readFile(env.configPath, 'utf-8');
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.backupPath).not.toBeNull();
|
||||
const backupContent = await readFile(result.backupPath as string, 'utf-8');
|
||||
expect(backupContent).toBe(original);
|
||||
});
|
||||
|
||||
it('records created_by_us=false when hooks.json pre-existed', async () => {
|
||||
env = await bootstrap({ hooks: {} });
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.createdByUs).toBe(false);
|
||||
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(pointer['created_by_us']).toBe(false);
|
||||
expect(pointer['settings_backup']).toBe(result.backupPath);
|
||||
});
|
||||
|
||||
it('appends 4 hive groups and preserves the existing structure', async () => {
|
||||
const initial = {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ matcher: 'startup', 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.configPath, 'utf-8')) as {
|
||||
hooks: Record<string, Array<{ _hiveMindShim?: string; hooks: Array<{ command: string }> }>>;
|
||||
};
|
||||
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);
|
||||
});
|
||||
|
||||
// ── create-if-missing branch ──────────────────────────────────────────
|
||||
|
||||
it('creates a skeleton {hooks:{...}} when hooks.json is absent', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
const after = JSON.parse(await readFile(env.configPath, 'utf-8')) as { hooks: Record<string, unknown> };
|
||||
expect(after.hooks).toBeDefined();
|
||||
expect(Object.keys(after.hooks).sort()).toEqual(['PreCompact', 'SessionStart', 'Stop', 'UserPromptSubmit']);
|
||||
expect(result.createdByUs).toBe(true);
|
||||
});
|
||||
|
||||
it('records created_by_us=true and writes NO backup when hooks.json is absent', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.backupPath).toBeNull();
|
||||
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(pointer['created_by_us']).toBe(true);
|
||||
expect(pointer['settings_backup']).toBeNull();
|
||||
});
|
||||
|
||||
// ── pointer + cli-path ───────────────────────────────────────────────
|
||||
|
||||
it('drops a pointer file with installed_hooks + version', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
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['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
|
||||
expect(typeof pointer['version']).toBe('string');
|
||||
});
|
||||
|
||||
it('respects a custom now() for a deterministic backup filename', async () => {
|
||||
env = await bootstrap({ hooks: {} });
|
||||
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 as string);
|
||||
expect(stats.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('threads --cli-path into every generated hook command + records it in the pointer', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
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.configPath, 'utf-8')) as {
|
||||
hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>>;
|
||||
};
|
||||
expect(after.hooks.SessionStart[0].hooks[0].command).toContain(`--cli-path "${cliPath}"`);
|
||||
expect(after.hooks.Stop[0].hooks[0].command).toContain(`--cli-path "${cliPath}"`);
|
||||
|
||||
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(pointer['cli_path']).toBe(cliPath);
|
||||
});
|
||||
|
||||
it('rejects --cli-path values containing double-quote characters', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
await expect(install({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
cliPath: 'malicious" && rm -rf / "',
|
||||
})).rejects.toThrow(/double-quote/);
|
||||
});
|
||||
|
||||
// ── install UX: the /hooks trust step must be surfaced ────────────────
|
||||
|
||||
it.skipIf(!BIN_BUILT)('install output mentions the one-time /hooks trust step', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[BIN_PATH, 'install', '--hooks-dir', env.hooksDir],
|
||||
{ env: { ...process.env, HOME: env.home, USERPROFILE: env.home } },
|
||||
);
|
||||
expect(stdout).toContain('/hooks');
|
||||
expect(stdout.toLowerCase()).toContain('trust');
|
||||
});
|
||||
});
|
||||
74
packages/hive-mind-hooks-codex/tests/paths.test.ts
Normal file
74
packages/hive-mind-hooks-codex/tests/paths.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
allHookBasenames,
|
||||
backupPathFor,
|
||||
hookCommandFor,
|
||||
resolvePaths,
|
||||
} from '../src/paths.js';
|
||||
|
||||
describe('resolvePaths (codex)', () => {
|
||||
it('places hooks.json + pointer under <home>/.codex/', () => {
|
||||
const home = resolve('/fake/home');
|
||||
const paths = resolvePaths({ home, hooksDir: resolve('/some/dist/hooks') });
|
||||
expect(paths.codexDir).toBe(join(home, '.codex'));
|
||||
// Codex targets a STANDALONE hooks.json — NOT config.toml.
|
||||
expect(paths.configPath).toBe(join(home, '.codex', 'hooks.json'));
|
||||
expect(paths.pointerPath).toBe(join(home, '.codex', '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 (codex, 2-arg shared helper)', () => {
|
||||
it('produces a quoted node invocation around the absolute script path', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'));
|
||||
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
|
||||
});
|
||||
|
||||
it('appends --cli-path when supplied', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), '/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.js'), '');
|
||||
expect(cmd).not.toContain('--cli-path');
|
||||
});
|
||||
|
||||
it('preserves Windows-style paths (with spaces) inside the quotes', () => {
|
||||
const cmd = hookCommandFor('/abs/dist/hooks/stop.js', 'C:\\Program Files\\hive-mind\\dist\\index.js');
|
||||
expect(cmd).toContain('--cli-path "C:\\Program Files\\hive-mind\\dist\\index.js"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupPathFor (codex)', () => {
|
||||
it('replaces colons and dots in the timestamp for filesystem safety', () => {
|
||||
const backup = backupPathFor('/h/.codex/hooks.json', '2026-04-28T10:30:45.123Z');
|
||||
expect(backup).toBe('/h/.codex/hooks.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allHookBasenames (codex)', () => {
|
||||
it('returns the four canonical basenames (CC clone)', () => {
|
||||
expect([...allHookBasenames()].sort()).toEqual([
|
||||
'pre-compact',
|
||||
'session-start',
|
||||
'stop',
|
||||
'user-prompt-submit',
|
||||
]);
|
||||
});
|
||||
});
|
||||
150
packages/hive-mind-hooks-codex/tests/register.test.ts
Normal file
150
packages/hive-mind-hooks-codex/tests/register.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
jsonRegister,
|
||||
jsonUnregister,
|
||||
hasHiveEntries,
|
||||
type JsonRegisterEntry,
|
||||
} from '@waggle/hive-mind-hooks-core';
|
||||
import {
|
||||
codexRegisterSpec,
|
||||
HIVE_MIND_MARKER,
|
||||
SESSION_START_MATCHER,
|
||||
} from '../src/adapter.js';
|
||||
import { hookCommandFor } from '../src/paths.js';
|
||||
|
||||
const HOOKS_DIR = '/abs/dist/hooks';
|
||||
|
||||
function entry(
|
||||
lifecycle: JsonRegisterEntry['lifecycle'],
|
||||
basename: string,
|
||||
timeout = 5,
|
||||
): JsonRegisterEntry {
|
||||
return {
|
||||
lifecycle,
|
||||
command: hookCommandFor(`${HOOKS_DIR}/${basename}.js`),
|
||||
timeout,
|
||||
};
|
||||
}
|
||||
|
||||
const ALL_ENTRIES: readonly JsonRegisterEntry[] = [
|
||||
entry('session-start', 'session-start'),
|
||||
entry('user-prompt-submit', 'user-prompt-submit'),
|
||||
entry('stop', 'stop'),
|
||||
entry('pre-compact', 'pre-compact'),
|
||||
];
|
||||
|
||||
interface CodexGroup {
|
||||
matcher?: string;
|
||||
hooks: Array<{ type: string; command: string; timeout?: number }>;
|
||||
_hiveMindShim?: string;
|
||||
}
|
||||
|
||||
function groupsAt(config: Record<string, unknown>, eventKey: string): CodexGroup[] {
|
||||
const hooks = config['hooks'] as Record<string, unknown> | undefined;
|
||||
return (hooks?.[eventKey] as CodexGroup[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
describe('jsonRegister (codex {matcher,hooks:[...]} group shape)', () => {
|
||||
it('returns a NEW object — does not mutate input', () => {
|
||||
const original: Record<string, unknown> = { hooks: { SessionStart: [] } };
|
||||
const merged = jsonRegister(original, [entry('session-start', 'session-start')], codexRegisterSpec);
|
||||
expect(merged).not.toBe(original);
|
||||
// Input untouched (immutability contract).
|
||||
expect((original['hooks'] as Record<string, unknown>)['SessionStart']).toEqual([]);
|
||||
});
|
||||
|
||||
it('registers a group under each codex native event key', () => {
|
||||
const merged = jsonRegister({}, ALL_ENTRIES, codexRegisterSpec);
|
||||
expect(groupsAt(merged, 'SessionStart')).toHaveLength(1);
|
||||
expect(groupsAt(merged, 'UserPromptSubmit')).toHaveLength(1);
|
||||
expect(groupsAt(merged, 'Stop')).toHaveLength(1);
|
||||
expect(groupsAt(merged, 'PreCompact')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds the codex group shape: {hooks:[{type:command,command,timeout}], marker}', () => {
|
||||
const merged = jsonRegister({}, [entry('stop', 'stop', 9)], codexRegisterSpec);
|
||||
const g = groupsAt(merged, 'Stop')[0];
|
||||
expect(g._hiveMindShim).toBe(HIVE_MIND_MARKER);
|
||||
expect(g.hooks).toHaveLength(1);
|
||||
expect(g.hooks[0].type).toBe('command');
|
||||
expect(g.hooks[0].command).toContain('stop.js');
|
||||
expect(g.hooks[0].timeout).toBe(9);
|
||||
});
|
||||
|
||||
it('carries the lifecycle matcher ONLY on SessionStart', () => {
|
||||
const merged = jsonRegister({}, ALL_ENTRIES, codexRegisterSpec);
|
||||
expect(groupsAt(merged, 'SessionStart')[0].matcher).toBe(SESSION_START_MATCHER);
|
||||
expect(groupsAt(merged, 'UserPromptSubmit')[0].matcher).toBeUndefined();
|
||||
expect(groupsAt(merged, 'Stop')[0].matcher).toBeUndefined();
|
||||
expect(groupsAt(merged, 'PreCompact')[0].matcher).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves existing (user) hook groups verbatim — additive merge', () => {
|
||||
const existing: Record<string, unknown> = {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ matcher: 'startup', hooks: [{ type: 'command', command: 'node /existing/x.js' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const merged = jsonRegister(existing, [entry('session-start', 'session-start')], codexRegisterSpec);
|
||||
const arr = groupsAt(merged, 'SessionStart');
|
||||
expect(arr).toHaveLength(2);
|
||||
expect(arr[0].hooks[0].command).toBe('node /existing/x.js');
|
||||
expect(arr[0]._hiveMindShim).toBeUndefined();
|
||||
expect(arr[1]._hiveMindShim).toBe(HIVE_MIND_MARKER);
|
||||
});
|
||||
|
||||
it('preserves unrelated top-level keys (does not touch the user TOML-adjacent config)', () => {
|
||||
const merged = jsonRegister(
|
||||
{ schemaVersion: 2, hooks: {} },
|
||||
[entry('session-start', 'session-start')],
|
||||
codexRegisterSpec,
|
||||
);
|
||||
expect(merged['schemaVersion']).toBe(2);
|
||||
});
|
||||
|
||||
it('replaces our own marker-tagged group on re-install (idempotent dedup by command)', () => {
|
||||
const e = entry('session-start', 'session-start', 5);
|
||||
const merged1 = jsonRegister({}, [e], codexRegisterSpec);
|
||||
const merged2 = jsonRegister(merged1, [{ ...e, timeout: 11 }], codexRegisterSpec);
|
||||
const arr = groupsAt(merged2, 'SessionStart');
|
||||
expect(arr).toHaveLength(1); // never duplicated
|
||||
expect(arr[0].hooks[0].timeout).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHiveEntries (codex)', () => {
|
||||
it('false on empty / hookless config', () => {
|
||||
expect(hasHiveEntries(undefined, codexRegisterSpec)).toBe(false);
|
||||
expect(hasHiveEntries({}, codexRegisterSpec)).toBe(false);
|
||||
expect(hasHiveEntries({ hooks: {} }, codexRegisterSpec)).toBe(false);
|
||||
});
|
||||
|
||||
it('true once a marker-tagged group is present', () => {
|
||||
const merged = jsonRegister({}, [entry('stop', 'stop')], codexRegisterSpec);
|
||||
expect(hasHiveEntries(merged, codexRegisterSpec)).toBe(true);
|
||||
});
|
||||
|
||||
it('false for a config holding ONLY non-hive (user) groups', () => {
|
||||
const userOnly: Record<string, unknown> = {
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'node /user/own.js' }] }] },
|
||||
};
|
||||
expect(hasHiveEntries(userOnly, codexRegisterSpec)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonUnregister (codex)', () => {
|
||||
it('strips exactly our marker-tagged groups, preserves user groups', () => {
|
||||
const userGroup = { hooks: [{ type: 'command', command: 'node /user/own.js' }] };
|
||||
const withUser: Record<string, unknown> = { hooks: { Stop: [userGroup] } };
|
||||
const merged = jsonRegister(withUser, [entry('stop', 'stop')], codexRegisterSpec);
|
||||
expect(groupsAt(merged, 'Stop')).toHaveLength(2);
|
||||
|
||||
const stripped = jsonUnregister(merged, codexRegisterSpec);
|
||||
const arr = groupsAt(stripped, 'Stop');
|
||||
expect(arr).toHaveLength(1);
|
||||
expect(arr[0].hooks[0].command).toBe('node /user/own.js');
|
||||
expect(hasHiveEntries(stripped, codexRegisterSpec)).toBe(false);
|
||||
});
|
||||
});
|
||||
118
packages/hive-mind-hooks-codex/tests/uninstall.test.ts
Normal file
118
packages/hive-mind-hooks-codex/tests/uninstall.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
hooksDir: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial: Record<string, unknown> | undefined): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmcdx-uninstall-'));
|
||||
const codexDir = join(home, '.codex');
|
||||
await mkdir(codexDir, { recursive: true });
|
||||
const configPath = join(codexDir, 'hooks.json');
|
||||
if (initial !== undefined) {
|
||||
await writeFile(configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
const hooksDir = resolve(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
return { home, hooksDir, configPath, pointerPath: join(codexDir, 'hive-mind-install.json') };
|
||||
}
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash('sha256').update(s, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
describe('uninstall (codex)', () => {
|
||||
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({ hooks: {} });
|
||||
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
|
||||
.rejects.toThrow(/pointer/);
|
||||
});
|
||||
|
||||
it('throws when the pointer is malformed', async () => {
|
||||
env = await bootstrap({ hooks: {} });
|
||||
await writeFile(env.pointerPath, '{}', 'utf-8');
|
||||
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
|
||||
.rejects.toThrow(/malformed/);
|
||||
});
|
||||
|
||||
// ── created_by_us=false: byte-identical restore (§7.3 invariant 2) ─────
|
||||
|
||||
it('install + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
|
||||
const initial = {
|
||||
schemaVersion: 1,
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{ matcher: 'startup', hooks: [{ type: 'command', command: 'node /existing/ctx.js' }] },
|
||||
],
|
||||
PreCompact: [
|
||||
{ hooks: [{ type: 'command', command: 'node /existing/pre-compact.js', timeout: 10 }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
env = await bootstrap(initial);
|
||||
const preInstall = await readFile(env.configPath, 'utf-8');
|
||||
const preHash = sha256(preInstall);
|
||||
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const afterInstall = await readFile(env.configPath, 'utf-8');
|
||||
expect(sha256(afterInstall)).not.toBe(preHash); // install actually mutated
|
||||
|
||||
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(u.createdRemoved).toBe(false);
|
||||
const afterUninstall = await readFile(env.configPath, 'utf-8');
|
||||
expect(sha256(afterUninstall)).toBe(preHash);
|
||||
expect(afterUninstall).toBe(preInstall);
|
||||
});
|
||||
|
||||
it('removes backup + pointer by default after a restore', async () => {
|
||||
env = await bootstrap({ hooks: {} });
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(existsSync(result.backupPath as string)).toBe(true);
|
||||
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(u.backupRemoved).toBe(true);
|
||||
expect(existsSync(result.backupPath as string)).toBe(false);
|
||||
expect(existsSync(result.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the backup when cleanupBackup=false', async () => {
|
||||
env = await bootstrap({ hooks: {} });
|
||||
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 as string)).toBe(true);
|
||||
});
|
||||
|
||||
// ── created_by_us=true: delete-if-created, no orphan (§7.3 invariant 2) ─
|
||||
|
||||
it('deletes the hooks.json we created and leaves NO orphan (absent → install → uninstall)', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(result.createdByUs).toBe(true);
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
|
||||
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
expect(u.createdRemoved).toBe(true);
|
||||
expect(u.restoredFrom).toBeNull();
|
||||
// No orphaned config, no leftover backup, no leftover pointer.
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
expect(existsSync(env.pointerPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
206
packages/hive-mind-hooks-codex/tests/verify.test.ts
Normal file
206
packages/hive-mind-hooks-codex/tests/verify.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
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';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
hooksDir: string;
|
||||
codexDir: string;
|
||||
}
|
||||
|
||||
async function bootstrap(
|
||||
initial: Record<string, unknown> | undefined,
|
||||
withHookFiles: boolean,
|
||||
): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmcdx-verify-'));
|
||||
const codexDir = join(home, '.codex');
|
||||
await mkdir(codexDir, { recursive: true });
|
||||
if (initial !== undefined) {
|
||||
await writeFile(join(codexDir, 'hooks.json'), JSON.stringify(initial, null, 2) + '\n', '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, codexDir };
|
||||
}
|
||||
|
||||
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 (codex)', () => {
|
||||
const envs: TestEnv[] = [];
|
||||
afterEach(async () => {
|
||||
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports failure when hooks.json is missing', async () => {
|
||||
const env = await bootstrap(undefined, 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[0].name).toBe('hooks.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('hive-mind entries'))).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after install with hook files on disk and CLI reachable', async () => {
|
||||
const env = await bootstrap({ hooks: {} }, 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);
|
||||
// The entry-presence check + each hook-script-readable check passed.
|
||||
expect(result.checks.find((c) => c.name === 'hooks.json contains hive-mind entries')?.ok).toBe(true);
|
||||
expect(result.checks.filter((c) => c.name.includes('readable on disk')).every((c) => c.ok)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports CLI unreachable when the spawn exits non-zero', async () => {
|
||||
const env = await bootstrap({ hooks: {} }, 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('flags missing hook script files even when the settings entry is present', async () => {
|
||||
const env = await bootstrap({ hooks: {} }, 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);
|
||||
});
|
||||
|
||||
it('uses cli_path from the install pointer for the probe (node <path> --help)', async () => {
|
||||
const env = await bootstrap({ hooks: {} }, 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);
|
||||
const probeRecord = records[records.length - 1];
|
||||
expect(probeRecord.command).toBe(process.execPath);
|
||||
expect(probeRecord.args[0]).toBe(cliPath);
|
||||
expect(probeRecord.args[1]).toBe('--help');
|
||||
});
|
||||
|
||||
// ── codex-specific surfacings ─────────────────────────────────────────
|
||||
|
||||
it('always surfaces the one-time /hooks trust step as an advisory check', async () => {
|
||||
const env = await bootstrap({ hooks: {} }, 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 }),
|
||||
});
|
||||
const trust = result.checks.find((c) => c.name.includes('/hooks trust step'));
|
||||
expect(trust).toBeDefined();
|
||||
expect(trust?.detail).toContain('/hooks');
|
||||
});
|
||||
|
||||
it('surfaces allow_managed_hooks_only lockdown as a FAILING check', async () => {
|
||||
const env = await bootstrap({ hooks: {} }, true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
// Admin lockdown suppresses user hooks — install would silently no-op.
|
||||
await writeFile(
|
||||
join(env.codexDir, 'requirements.toml'),
|
||||
'allow_managed_hooks_only = true\n',
|
||||
'utf-8',
|
||||
);
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
const lockdown = result.checks.find((c) => c.name.includes('allow_managed_hooks_only'));
|
||||
expect(lockdown).toBeDefined();
|
||||
expect(lockdown?.ok).toBe(false);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag lockdown when requirements.toml does not set it', async () => {
|
||||
const env = await bootstrap({ hooks: {} }, true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
await writeFile(
|
||||
join(env.codexDir, 'requirements.toml'),
|
||||
'allow_managed_hooks_only = false\n',
|
||||
'utf-8',
|
||||
);
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
const lockdown = result.checks.find((c) => c.name.includes('allow_managed_hooks_only'));
|
||||
expect(lockdown?.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user