moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
import { vi } from 'vitest';
import type { CliBridge, Logger, MemoryHit, ShimSource } from '@waggle/hive-mind-shim-core';
import type { EventAdapter, Lifecycle } from '../src/event-adapter.js';
import type { HookContext } from '../src/hook-shared.js';
// ── Mock CliBridge ──────────────────────────────────────────────────────
// Mirrors the CC `_test-helpers.ts` mock bridge so handler tests assert on
// the exact recallMemory / saveMemory / cleanupFrames calls without ever
// spawning hive-mind-cli.
export interface MockBridgeOverrides {
saveMemoryResult?: { id: string; success: boolean; workspace: string };
recallMemoryHits?: MemoryHit[];
cleanupFramesResult?: { pruned: number };
saveMemoryThrows?: Error;
recallMemoryThrows?: Error;
cleanupFramesThrows?: 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 = overrides.recallMemoryThrows
? vi.fn(async () => { throw overrides.recallMemoryThrows; })
: vi.fn(async () => overrides.recallMemoryHits ?? []);
const cleanupFrames = overrides.cleanupFramesThrows
? vi.fn(async () => { throw overrides.cleanupFramesThrows; })
: 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;
}
// ── Mock Logger ─────────────────────────────────────────────────────────
// The shared handler bodies log via ctx.logger.{debug,warn}. A no-op logger
// keeps test output clean while still satisfying the Logger interface.
export function makeMockLogger(): Logger & {
debug: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
} {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
export function makeCtx(bridge: CliBridge): HookContext {
return { bridge, logger: makeMockLogger() };
}
// ── Mock EventAdapter ───────────────────────────────────────────────────
// A minimal, configurable adapter that reads snake_case keys off a flat
// payload object — enough to drive every shared handler body.
export interface MockAdapterOverrides {
source?: ShimSource;
eventName?: Partial<Record<Lifecycle, string | undefined>>;
/** Provide a custom formatInject; pass `null` to leave it undefined. */
formatInject?: ((text: string) => unknown) | null;
/** Override extractResponse (e.g. to simulate an async transcript read). */
extractResponse?: EventAdapter['extractResponse'];
}
const DEFAULT_EVENT_NAME: Record<Lifecycle, string | undefined> = {
'session-start': 'SessionStart',
'user-prompt-submit': 'UserPromptSubmit',
stop: 'Stop',
'pre-compact': 'PreCompact',
};
function pick(payload: unknown, ...keys: string[]): string | undefined {
if (!payload || typeof payload !== 'object') return undefined;
const obj = payload as Record<string, unknown>;
for (const k of keys) {
const v = obj[k];
if (typeof v === 'string' && v.length > 0) return v;
}
return undefined;
}
export function makeMockAdapter(overrides: MockAdapterOverrides = {}): EventAdapter {
const eventName: Record<Lifecycle, string | undefined> = {
...DEFAULT_EVENT_NAME,
...overrides.eventName,
};
const adapter: EventAdapter = {
source: overrides.source ?? 'cursor',
eventName,
extractCwd: (p) => pick(p, 'cwd'),
extractSessionId: (p) => pick(p, 'session_id', 'sessionId'),
extractPrompt: (p) => pick(p, 'prompt'),
extractResponse:
overrides.extractResponse ??
((p) => pick(p, 'response', 'assistant_message')),
extractParent: (p) => pick(p, 'parent', 'parent_frame_id'),
};
if (overrides.formatInject === undefined) {
// Default: provide a renamed inject shape so we can assert the seam fires.
adapter.formatInject = (text: string): unknown => ({ additional_context: text });
} else if (overrides.formatInject !== null) {
adapter.formatInject = overrides.formatInject;
}
// formatInject === null → leave undefined (no inject seam).
return adapter;
}
export const HIT_FIXTURE: MemoryHit = {
id: 1,
content: '[hm src:cursor event:stop] past observation',
importance: 'important',
source: 'system',
score: 0.87,
created_at: '2026-05-28T10:00:00.000Z',
from: 'personal',
};
/** Run `fn` with `process.env[key]` temporarily set, then restore. */
export 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;
});
}

View File

@@ -0,0 +1,373 @@
import { describe, expect, it } from 'vitest';
import {
makeOpenclawHandler,
makePreCompactHandler,
makeSessionStartHandler,
makeStopHandler,
makeUserPromptSubmitHandler,
type OpenclawHandlerInput,
type PreCompactExtracted,
type SessionStartExtracted,
type StopExtracted,
type UserPromptExtracted,
} from '../src/handlers-core.js';
import type { EventAdapter, Lifecycle } from '../src/event-adapter.js';
import { HIT_FIXTURE, makeCtx, makeMockAdapter, makeMockBridge, withEnv } from './_helpers.js';
// ── make*Handler factories (stdin-JSON / exit-0 drive path) ─────────────
describe('makeSessionStartHandler', () => {
it('recalls personal-scoped frames and returns the adapter inject shape', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
const h = makeSessionStartHandler(a);
const payload = h.parse({ cwd: '/proj', recall_limit: 7 });
expect(payload.recallLimit).toBe(7);
const out = await h.run(payload, makeCtx(bridge));
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 7, scope: 'personal', workspace: null });
// Mock adapter renames the inject seam to { additional_context }.
expect(out).toMatchObject({ additional_context: expect.stringContaining('past observation') });
});
it('defaults recallLimit to opts.recallLimit then 20', () => {
const a = makeMockAdapter();
expect(makeSessionStartHandler(a).parse({}).recallLimit).toBe(20);
expect(makeSessionStartHandler(a, { recallLimit: 5 }).parse({}).recallLimit).toBe(5);
// payload value wins over opts.
expect(makeSessionStartHandler(a, { recallLimit: 5 }).parse({ recall_limit: 9 }).recallLimit).toBe(9);
});
it('falls back to the CC hookSpecificOutput shape when the adapter has no formatInject', async () => {
const a = makeMockAdapter({ formatInject: null, source: 'codex' });
const bridge = makeMockBridge({ recallMemoryHits: [] });
const out = (await makeSessionStartHandler(a).run({ cwd: '/p', sessionId: undefined, recallLimit: 20 }, makeCtx(bridge))) as {
hookSpecificOutput: { hookEventName: string; source: string; additionalContext: string };
};
expect(out.hookSpecificOutput.hookEventName).toBe('SessionStart');
expect(out.hookSpecificOutput.source).toBe('codex');
expect(out.hookSpecificOutput.additionalContext).toContain('no recalled frames');
});
});
describe('makeUserPromptSubmitHandler', () => {
it('saves a temporary frame carrying the prompt', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeUserPromptSubmitHandler(a);
const payload = h.parse({ prompt: 'do the thing', cwd: '/proj', session_id: 's1' });
await h.run(payload, makeCtx(bridge));
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0];
expect(frame.importance).toBe('temporary');
expect(frame.content).toBe('do the thing');
expect(frame.source).toBe(a.source);
});
it('skips the save when the prompt is empty', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeUserPromptSubmitHandler(a);
await h.run(h.parse({ cwd: '/proj' }), makeCtx(bridge));
expect(bridge.saveMemory).not.toHaveBeenCalled();
});
});
describe('makeStopHandler', () => {
it('summarizes the turn and saves an important frame', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeStopHandler(a);
const longResp = 'First sentence. ' + 'X'.repeat(2000) + '.';
await h.run(h.parse({ response: longResp, cwd: '/proj', session_id: 's2' }), makeCtx(bridge));
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0];
expect(['important', 'critical']).toContain(frame.importance);
expect(frame.content.length).toBeLessThanOrEqual(401); // budget + ellipsis
});
it('promotes to critical on a "never" directive and attaches the parent frame id', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeStopHandler(a);
await h.run(
h.parse({ response: 'never commit secrets to the public repo.', cwd: '/proj', parent_frame_id: 'frame-99' }),
makeCtx(bridge),
);
const frame = bridge.saveMemory.mock.calls[0][0];
expect(frame.importance).toBe('critical');
expect(frame.parent).toBe('frame-99');
});
it('skips the save when the response is empty', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge();
const h = makeStopHandler(a);
await h.run(h.parse({ cwd: '/proj' }), makeCtx(bridge));
expect(bridge.saveMemory).not.toHaveBeenCalled();
});
});
describe('makePreCompactHandler', () => {
it('calls cleanupFrames', async () => {
const a = makeMockAdapter();
const bridge = makeMockBridge({ cleanupFramesResult: { pruned: 3 } });
const h = makePreCompactHandler(a);
await h.run(h.parse({ session_id: 's3' }), makeCtx(bridge));
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
});
});
// ── makeOpenclawHandler (in-process drive path) ─────────────────────────
// OpenClaw adapter: eventName values are the native `type:action` keys.
function openclawAdapter(): EventAdapter {
const eventName: Record<Lifecycle, string | undefined> = {
'session-start': 'agent:bootstrap',
'user-prompt-submit': 'message:received',
stop: 'message:sent',
// HOOK.md uses the session-prefixed key; runtime action is 'compact:before'.
'pre-compact': 'session:compact:before',
};
return makeMockAdapter({ source: 'openclaw', eventName, formatInject: null });
}
describe('makeOpenclawHandler — lifecycle dispatch', () => {
it('SessionStart (agent:bootstrap) recalls memory; consumer mutates bootstrapFiles', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
const handler = makeOpenclawHandler(a);
// The body returns the recalled/formatted text; the openclaw adapter is
// responsible for pushing it onto event.context.bootstrapFiles. We model
// that by capturing the recall and asserting the consumer can mutate.
const bootstrapFiles: string[] = [];
const input: OpenclawHandlerInput = {
event: { type: 'agent', action: 'bootstrap', context: { bootstrapFiles } },
extracted: { cwd: '/proj', sessionId: 's1', recallLimit: 20 } as SessionStartExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 20, scope: 'personal', workspace: null });
// Simulate the adapter's documented mutation seam working end-to-end.
bootstrapFiles.push('recalled');
expect((input.event.context as { bootstrapFiles: string[] }).bootstrapFiles).toEqual(['recalled']);
});
it('UserPromptSubmit (message:received) saves a temporary frame', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'message', action: 'received' },
extracted: { prompt: 'hi there', cwd: '/proj', sessionId: 's1' } as UserPromptExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(bridge.saveMemory.mock.calls[0][0].importance).toBe('temporary');
});
it('Stop (message:sent) saves an important/critical frame', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'message', action: 'sent' },
extracted: { cwd: '/proj', sessionId: 's1', response: 'we decided to ship it.', parent: undefined } as StopExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(['important', 'critical']).toContain(bridge.saveMemory.mock.calls[0][0].importance);
});
it('PreCompact matches on action "compact:before" (NOT the joined string) and cleans frames', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'session', action: 'compact:before' },
extracted: { scope: 's1' } as PreCompactExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
});
it('ignores an event with no lifecycle match (no bridge calls)', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'gateway', action: 'pre-restart' },
extracted: { scope: undefined } as PreCompactExtracted,
};
await handler.handle(input, makeCtx(bridge));
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(bridge.recallMemory).not.toHaveBeenCalled();
expect(bridge.cleanupFrames).not.toHaveBeenCalled();
});
it('does NOT map an unrelated type whose action suffix collides (suffix-match is pre-compact-only)', async () => {
// 'message:sent' is Stop's native key; a 'gateway:sent' event shares the
// ':sent' suffix but must NOT route to Stop — the action-suffix fallback is
// reserved for pre-compact's compact:before alias.
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a);
await handler.handle(
{ event: { type: 'gateway', action: 'sent' }, extracted: { cwd: '/p', sessionId: 's1', response: 'x' } as StopExtracted },
makeCtx(bridge),
);
expect(bridge.saveMemory).not.toHaveBeenCalled();
});
it('debounces message:sent — every dispatch resolves, only the last saves', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(a, { stopDebounceMs: 5 });
const mk = (response: string): OpenclawHandlerInput => ({
event: { type: 'message', action: 'sent', sessionKey: 'turn-1' },
extracted: { cwd: '/proj', sessionId: 's1', response, parent: undefined } as StopExtracted,
});
// Fire three message:sent for the same turn and AWAIT ALL of them. Each
// superseded dispatch MUST resolve (not hang) — the host awaits handlers
// sequentially, so a leaked promise would block the host event loop
// forever (fail-open invariant §7.3(1)). With the old code the first two
// promises never settled and this Promise.all would time out.
await Promise.all([
handler.handle(mk('first decided.'), makeCtx(bridge)),
handler.handle(mk('second decided.'), makeCtx(bridge)),
handler.handle(mk('third decided.'), makeCtx(bridge)),
]);
// Last-writer-wins: exactly one save, carrying the final payload.
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(bridge.saveMemory.mock.calls[0][0].content).toContain('third decided');
});
});
describe('makeOpenclawHandler — FAIL-OPEN (invariant §7.3(1))', () => {
it('swallows a body error, resolves the promise, and never throws', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli unreachable') });
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'message', action: 'received' },
extracted: { prompt: 'will explode on save', cwd: '/proj', sessionId: 's1' } as UserPromptExtracted,
};
// Must resolve (not reject) despite the bridge throwing.
await expect(handler.handle(input, makeCtx(bridge))).resolves.toBeUndefined();
});
it('fails open when recallMemory rejects on SessionStart', async () => {
const a = openclawAdapter();
const bridge = makeMockBridge({ recallMemoryThrows: new Error('boom') });
const handler = makeOpenclawHandler(a);
const input: OpenclawHandlerInput = {
event: { type: 'agent', action: 'bootstrap', context: {} },
extracted: { cwd: '/proj', sessionId: 's1', recallLimit: 20 } as SessionStartExtracted,
};
await expect(handler.handle(input, makeCtx(bridge))).resolves.toBeUndefined();
});
it('swallows a rejecting save in the DEBOUNCE branch (no unhandled rejection)', async () => {
// The debounced Stop save fires in a detached timer AFTER dispatch's
// try/catch returned, so a rejection there would escape as an unhandled
// rejection unless explicitly caught. Regression guard for the openclaw
// stopDebounceMs path (the only caller that sets it).
const a = openclawAdapter();
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli down') });
const handler = makeOpenclawHandler(a, { stopDebounceMs: 5 });
const rejections: unknown[] = [];
const onRej = (e: unknown): void => { rejections.push(e); };
process.on('unhandledRejection', onRej);
try {
await handler.handle(
{ event: { type: 'message', action: 'sent', sessionKey: 't1' }, extracted: { cwd: '/p', sessionId: 's1', response: 'final', parent: undefined } as StopExtracted },
makeCtx(bridge),
);
// Flush any detached microtask carrying an unhandled rejection.
await new Promise((r) => setTimeout(r, 20));
} finally {
process.off('unhandledRejection', onRej);
}
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
expect(rejections).toEqual([]);
});
});
// ── Stop signal-emit opt-in (mirrors CC stop.ts Phase 1E) ───────────────
describe('makeStopHandler — WAGGLE_SIGNAL_EMIT opt-in', () => {
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' } }), {
status: 201,
headers: { 'content-type': 'application/json' },
});
}) as typeof globalThis.fetch & { calls: typeof calls };
impl.calls = calls;
return impl;
}
it('does not emit when WAGGLE_SIGNAL_EMIT is unset', async () => {
const a = makeMockAdapter({ source: 'cursor' });
const bridge = makeMockBridge();
const f = makeOkFetch();
await withEnv('WAGGLE_SIGNAL_EMIT', undefined, () =>
withCapturedFetch(f, () =>
makeStopHandler(a).run(
makeStopHandler(a).parse({ response: 'never do that.', cwd: '/p' }),
makeCtx(bridge),
),
),
);
expect(f.calls).toHaveLength(0);
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
});
it('emits a discovery signal on a critical turn when WAGGLE_SIGNAL_EMIT=1', async () => {
const a = makeMockAdapter({ source: 'cursor' });
const bridge = makeMockBridge();
const f = makeOkFetch();
const h = makeStopHandler(a);
await withEnv('WAGGLE_SIGNAL_EMIT', '1', () =>
withCapturedFetch(f, () =>
h.run(h.parse({ response: 'never commit secrets.', cwd: '/p', session_id: 'sc' }), makeCtx(bridge)),
),
);
expect(f.calls).toHaveLength(1);
const body = f.calls[0].body as { content: Record<string, unknown>; senderId: string };
expect(body.senderId).toBe('cursor-hook');
expect(body.content.tool).toBe('cursor');
expect(body.content.importance).toBe('critical');
expect(body.content.frameId).toBe('frame-1');
expect(body.content.memoryWorkspace).toBe('personal');
expect(body.content.summary).toContain('never commit secrets');
});
it('still saves the frame even when the signal endpoint is unreachable (fail-open)', async () => {
const a = makeMockAdapter({ source: 'cursor' });
const bridge = makeMockBridge();
const unreachable = (async () => { throw new Error('ECONNREFUSED'); }) as typeof globalThis.fetch;
const h = makeStopHandler(a);
await withEnv('WAGGLE_SIGNAL_EMIT', 'true', () =>
withCapturedFetch(unreachable, () =>
h.run(h.parse({ response: 'never commit secrets.', cwd: '/p' }), makeCtx(bridge)),
),
);
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest';
import {
parseHookArgs,
pickStringField,
pickStringFromObject,
runHook,
safeJsonParse,
type HookContext,
type HookHandler,
} from '../src/hook-shared.js';
import { makeMockBridge, makeMockLogger } from './_helpers.js';
// A capture rig for stdout/exit (mirrors the CC _test-helpers shape).
function makeCaptures(): {
stdout: string[];
exits: number[];
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) };
}
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 (garbage)', () => {
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 the 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 non-empty 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();
});
});
describe('runHook — happy path', () => {
it('parses stdin, runs the handler, writes stdout JSON, and exits 0', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<{ value: string }, { echoed: string }> = {
parse(raw): { value: string } {
return { value: (raw as Record<string, unknown>).value as string };
},
async run(payload): Promise<{ echoed: string }> {
return { echoed: payload.value };
},
};
await runHook(handler, {
name: 'demo',
readStdin: async () => JSON.stringify({ value: 'hi' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
expect(cap.exits).toEqual([0]);
expect(JSON.parse(cap.stdout[0])).toEqual({ echoed: 'hi' });
});
it('threads --cli-path through to the handler context via the bridge override path', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
let sawCtx: HookContext | undefined;
const handler: HookHandler<unknown, undefined> = {
parse: (raw) => raw,
async run(_payload, ctx): Promise<undefined> {
sawCtx = ctx;
return undefined;
},
};
await runHook(handler, {
name: 'demo',
argv: ['--cli-path', '/abs/cli/dist/index.js'],
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
// No stdout emitted when run returns undefined.
expect(cap.stdout).toHaveLength(0);
expect(cap.exits).toEqual([0]);
expect(sawCtx?.bridge).toBe(bridge);
});
});
describe('runHook — FAIL-OPEN (invariant §7.3(1))', () => {
it('exits 0 when the handler body throws (injected bridge error)', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<unknown, undefined> = {
parse: (raw) => raw,
async run(): Promise<undefined> {
throw new Error('cli unreachable');
},
};
await runHook(handler, {
name: 'demo',
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
expect(cap.exits).toEqual([0]);
});
it('exits 0 when parse throws on malformed stdin (safeJsonParse yields {}, parse explodes)', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<unknown, undefined> = {
parse(): unknown {
throw new Error('bad shape');
},
async run(): Promise<undefined> {
return undefined;
},
};
await runHook(handler, {
name: 'demo',
readStdin: async () => 'not json at all',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
expect(cap.exits).toEqual([0]);
});
it('exits 0 when the stdin reader itself rejects', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<unknown, undefined> = {
parse: (raw) => raw,
async run(): Promise<undefined> {
return undefined;
},
};
await runHook(handler, {
name: 'demo',
readStdin: async () => { throw new Error('stdin broke'); },
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
});
expect(cap.exits).toEqual([0]);
});
it('never throws to the caller even on a thrown body (promise resolves)', async () => {
const cap = makeCaptures();
const bridge = makeMockBridge();
const handler: HookHandler<unknown, undefined> = {
parse: (raw) => raw,
async run(): Promise<undefined> { throw new Error('boom'); },
};
await expect(
runHook(handler, {
name: 'demo',
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
logger: makeMockLogger(),
}),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,233 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
backupByteIdentical,
readPointer,
restoreFromBackup,
writePointer,
type InstallPointer,
} from '../src/install-core.js';
import { backupPathFor } from '../src/paths-core.js';
const ISO = '2026-06-01T10:30:45.123Z';
function sha256(buf: Buffer | string): string {
return createHash('sha256').update(buf).digest('hex');
}
let dir: string;
async function tmp(): Promise<string> {
dir = await mkdtemp(join(tmpdir(), 'hmhc-install-'));
return dir;
}
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
function basePointer(over: Partial<InstallPointer> = {}): InstallPointer {
return {
version: '0.1.0',
installed_at: ISO,
config_path: '/x/cfg.json',
settings_backup: null,
created_by_us: false,
hooks_dir: null,
installed_hooks: ['stop'],
cli_path: null,
...over,
};
}
describe('backupByteIdentical', () => {
it('writes a byte-identical backup when the config pre-existed', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
// Include comments-as-content / odd bytes so we prove EXACT byte fidelity.
const original = '{\n "hooks": {},\n "user": "kept verbatim ✓"\n}\n';
await writeFile(configPath, original, 'utf-8');
const result = await backupByteIdentical(configPath, ISO);
expect(result.preExisted).toBe(true);
expect(result.backupPath).toBe(backupPathFor(configPath, ISO));
const backupBytes = await readFile(result.backupPath as string);
expect(sha256(backupBytes)).toBe(sha256(Buffer.from(original, 'utf-8')));
});
it('writes NO backup and reports preExisted=false when the config is absent', async () => {
const home = await tmp();
const configPath = join(home, 'does-not-exist.json');
const result = await backupByteIdentical(configPath, ISO);
expect(result.preExisted).toBe(false);
expect(result.backupPath).toBeNull();
expect(existsSync(backupPathFor(configPath, ISO))).toBe(false);
});
});
describe('writePointer / readPointer round-trip', () => {
it('round-trips a pointer through disk', async () => {
const home = await tmp();
const pointerPath = join(home, 'hive-mind-install.json');
const pointer = basePointer({
config_path: join(home, 'hooks.json'),
settings_backup: '/x/hooks.json.hive-mind-backup.stamp',
installed_hooks: ['session-start', 'stop'],
cli_path: '/abs/cli/dist/index.js',
extra: { dir: 'hive-mind' },
});
await writePointer(pointerPath, pointer);
const read = await readPointer(pointerPath);
expect(read).toEqual(pointer);
});
it('throws when the pointer file is absent', async () => {
const home = await tmp();
await expect(readPointer(join(home, 'missing.json'))).rejects.toThrow(/no install pointer/);
});
it('throws when the pointer JSON is malformed (not parseable)', async () => {
const home = await tmp();
const pointerPath = join(home, 'bad.json');
await writeFile(pointerPath, '{ not valid json', 'utf-8');
await expect(readPointer(pointerPath)).rejects.toThrow(/malformed/);
});
it('throws when the pointer JSON parses but fails the shape check', async () => {
const home = await tmp();
const pointerPath = join(home, 'shape.json');
await writeFile(pointerPath, JSON.stringify({ hello: 'world' }), 'utf-8');
await expect(readPointer(pointerPath)).rejects.toThrow(/malformed/);
});
});
describe('restoreFromBackup — created_by_us=false (byte-identical restore)', () => {
it('restores the pre-install bytes exactly and removes the backup by default', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
const original = '{\n "hooks": { "Stop": [] },\n "keep": "me"\n}\n';
await writeFile(configPath, original, 'utf-8');
const preHash = sha256(await readFile(configPath));
// Simulate install: byte-identical backup, then mutate the live config.
const { backupPath } = await backupByteIdentical(configPath, ISO);
await writeFile(configPath, '{ "hooks": { "Stop": ["MUTATED"] } }', 'utf-8');
expect(sha256(await readFile(configPath))).not.toBe(preHash);
const pointer = basePointer({
config_path: configPath,
settings_backup: backupPath,
created_by_us: false,
});
const result = await restoreFromBackup({ configPath, pointer });
expect(result.createdRemoved).toBe(false);
expect(result.restoredFrom).toBe(backupPath);
expect(result.backupRemoved).toBe(true);
// Invariant §7.3(2): config is byte-identical to pre-install state.
expect(sha256(await readFile(configPath))).toBe(preHash);
expect(await readFile(configPath, 'utf-8')).toBe(original);
expect(existsSync(backupPath as string)).toBe(false);
});
it('keeps the backup when cleanupBackup=false', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
await writeFile(configPath, 'ORIGINAL', 'utf-8');
const { backupPath } = await backupByteIdentical(configPath, ISO);
await writeFile(configPath, 'MUTATED', 'utf-8');
const pointer = basePointer({ config_path: configPath, settings_backup: backupPath });
const result = await restoreFromBackup({ configPath, pointer, cleanupBackup: false });
expect(result.backupRemoved).toBe(false);
expect(existsSync(backupPath as string)).toBe(true);
expect(await readFile(configPath, 'utf-8')).toBe('ORIGINAL');
});
it('refuses to delete the backup and throws when the readback does not match', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
await writeFile(configPath, 'ORIGINAL', 'utf-8');
const { backupPath } = await backupByteIdentical(configPath, ISO);
const pointer = basePointer({ config_path: configPath, settings_backup: backupPath });
// Force a round-trip mismatch via the io seam: the verify read returns
// bytes that differ from the backup, so the guard MUST throw and MUST NOT
// delete the backup (a failed verification leaves recovery possible).
await expect(
restoreFromBackup({
configPath,
pointer,
io: {
readFile: async (p) =>
p === backupPath ? Buffer.from('ORIGINAL') : Buffer.from('CORRUPTED'),
writeFile: async () => {},
},
}),
).rejects.toThrow(/verification failed/);
// The backup survives a failed verification — never deleted on mismatch.
expect(existsSync(backupPath as string)).toBe(true);
});
it('throws when created_by_us=false but settings_backup is null', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
await writeFile(configPath, 'x', 'utf-8');
const pointer = basePointer({ config_path: configPath, settings_backup: null, created_by_us: false });
await expect(restoreFromBackup({ configPath, pointer })).rejects.toThrow(/cannot restore/);
});
it('throws when the referenced backup file is missing', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
await writeFile(configPath, 'x', 'utf-8');
const pointer = basePointer({
config_path: configPath,
settings_backup: join(home, 'ghost-backup'),
created_by_us: false,
});
await expect(restoreFromBackup({ configPath, pointer })).rejects.toThrow(/backup file referenced/);
});
});
describe('restoreFromBackup — created_by_us=true (delete the file we created)', () => {
it('deletes the config file we created and leaves no orphan, no backup', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
// Install created this file fresh (no backup ever written).
await writeFile(configPath, '{ "hooks": {} }', 'utf-8');
const pointer = basePointer({
config_path: configPath,
settings_backup: null,
created_by_us: true,
});
const result = await restoreFromBackup({ configPath, pointer });
expect(result.createdRemoved).toBe(true);
expect(result.restoredFrom).toBeNull();
expect(result.backupRemoved).toBe(false);
// Invariant §7.3(2): the file we created is removed, no orphan.
expect(existsSync(configPath)).toBe(false);
expect(existsSync(backupPathFor(configPath, ISO))).toBe(false);
});
it('is a no-op-safe delete when the created file was already removed', async () => {
const home = await tmp();
const configPath = join(home, 'hooks.json');
const pointer = basePointer({ config_path: configPath, created_by_us: true });
const result = await restoreFromBackup({ configPath, pointer });
expect(result.createdRemoved).toBe(true);
expect(existsSync(configPath)).toBe(false);
});
});

View File

@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest';
import {
HIVE_MIND_MARKER_BASE,
hasHiveEntries,
jsonRegister,
jsonUnregister,
type JsonRegisterEntry,
type JsonRegisterSpec,
} from '../src/json-register.js';
import type { Lifecycle } from '../src/event-adapter.js';
const MARKER = `${HIVE_MIND_MARKER_BASE}/codex-hooks`;
// A codex-style spec: each event key holds an array of
// { matcher, hooks:[{type,command,timeout}], _hiveMindShim } groups.
const codexSpec: JsonRegisterSpec = {
hooksKey: 'hooks',
eventName: {
'session-start': 'SessionStart',
'user-prompt-submit': 'UserPromptSubmit',
stop: 'Stop',
'pre-compact': 'PreCompact',
},
buildGroup(_lifecycle: Lifecycle, command: string, timeout: number): Record<string, unknown> {
return {
hooks: [{ type: 'command', command, timeout }],
_hiveMindShim: MARKER,
};
},
isHiveGroup(group: unknown): boolean {
return (
!!group &&
typeof group === 'object' &&
(group as Record<string, unknown>)['_hiveMindShim'] === MARKER
);
},
groupCommand(group: unknown): string | undefined {
if (!group || typeof group !== 'object') return undefined;
const hooks = (group as Record<string, unknown>)['hooks'];
if (!Array.isArray(hooks) || hooks.length === 0) return undefined;
const first = hooks[0] as Record<string, unknown>;
return typeof first['command'] === 'string' ? (first['command'] as string) : undefined;
},
};
const ENTRIES: readonly JsonRegisterEntry[] = [
{ lifecycle: 'session-start', command: 'node /dist/hooks/session-start.js', timeout: 30 },
{ lifecycle: 'stop', command: 'node /dist/hooks/stop.js', timeout: 60 },
];
describe('jsonRegister — additive', () => {
it('preserves existing user (non-hive) entries verbatim', () => {
const userGroup = { hooks: [{ type: 'command', command: 'node /user/own.js' }] };
const config = { hooks: { Stop: [userGroup] }, somethingElse: { kept: true } };
const next = jsonRegister(config, ENTRIES, codexSpec);
const stopArr = (next.hooks as Record<string, unknown[]>).Stop;
// user group still present, byte-equal, and FIRST.
expect(stopArr[0]).toEqual(userGroup);
// top-level non-hooks keys untouched.
expect(next.somethingElse).toEqual({ kept: true });
});
it('appends a hive group per entry under the mapped event key', () => {
const next = jsonRegister({}, ENTRIES, codexSpec);
const hooks = next.hooks as Record<string, unknown[]>;
expect(hooks.SessionStart).toHaveLength(1);
expect(hooks.Stop).toHaveLength(1);
expect(codexSpec.isHiveGroup(hooks.SessionStart[0])).toBe(true);
expect(codexSpec.groupCommand(hooks.Stop[0])).toBe('node /dist/hooks/stop.js');
});
it('skips lifecycles the tool has no native event for', () => {
const spec: JsonRegisterSpec = {
...codexSpec,
eventName: { ...codexSpec.eventName, 'pre-compact': undefined },
};
const next = jsonRegister(
{},
[{ lifecycle: 'pre-compact', command: 'node /dist/hooks/pre-compact.js', timeout: 30 }],
spec,
);
// No event key created (the only entry maps to undefined).
expect(Object.keys(next.hooks as Record<string, unknown>)).toHaveLength(0);
});
it('applies the optional skeleton seed (e.g. cursor version:1)', () => {
const spec: JsonRegisterSpec = {
...codexSpec,
ensureSkeleton: (root) => ({ version: 1, ...root }),
};
const next = jsonRegister(undefined, ENTRIES, spec);
expect(next.version).toBe(1);
});
});
describe('jsonRegister — dedup / replace-in-place on re-run', () => {
it('replaces the same (eventKey, command) hive group in place rather than duplicating', () => {
const once = jsonRegister({}, ENTRIES, codexSpec);
const twice = jsonRegister(once, ENTRIES, codexSpec);
const stopArr = (twice.hooks as Record<string, unknown[]>).Stop;
expect(stopArr).toHaveLength(1); // not 2
const ssArr = (twice.hooks as Record<string, unknown[]>).SessionStart;
expect(ssArr).toHaveLength(1);
});
it('upgrades the timeout of an existing hive entry in place', () => {
const once = jsonRegister({}, [{ lifecycle: 'stop', command: 'node /dist/hooks/stop.js', timeout: 60 }], codexSpec);
const upgraded = jsonRegister(
once,
[{ lifecycle: 'stop', command: 'node /dist/hooks/stop.js', timeout: 120 }],
codexSpec,
);
const stopArr = upgraded.hooks as Record<string, unknown[]>;
expect(stopArr.Stop).toHaveLength(1);
const group = stopArr.Stop[0] as { hooks: Array<{ timeout: number }> };
expect(group.hooks[0].timeout).toBe(120);
});
it('keeps a different-command hive entry alongside (no false dedup)', () => {
const a = jsonRegister({}, [{ lifecycle: 'stop', command: 'node /a.js', timeout: 30 }], codexSpec);
const b = jsonRegister(a, [{ lifecycle: 'stop', command: 'node /b.js', timeout: 30 }], codexSpec);
expect((b.hooks as Record<string, unknown[]>).Stop).toHaveLength(2);
});
});
describe('jsonRegister — immutability (input never mutated)', () => {
it('does not mutate the input config object (deep-equal to a frozen snapshot)', () => {
const config = {
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'node /user/own.js' }] }] },
version: 1,
};
const snapshot = structuredClone(config);
Object.freeze(config); // any in-place mutation would throw
const next = jsonRegister(config, ENTRIES, codexSpec);
// Input unchanged…
expect(config).toEqual(snapshot);
// …and a NEW object was returned.
expect(next).not.toBe(config);
expect(next.hooks).not.toBe(config.hooks);
});
it('does not mutate the input on re-run dedup either', () => {
const once = jsonRegister({}, ENTRIES, codexSpec);
const snapshot = structuredClone(once);
// Deep-freeze the nested arrays so replace-in-place can't touch the input.
Object.freeze(once);
Object.freeze(once.hooks);
for (const v of Object.values(once.hooks as Record<string, unknown>)) Object.freeze(v);
const twice = jsonRegister(once, ENTRIES, codexSpec);
expect(once).toEqual(snapshot);
expect(twice).not.toBe(once);
});
});
describe('jsonUnregister', () => {
it('strips only marker-tagged hive groups, preserving user entries', () => {
const userGroup = { hooks: [{ type: 'command', command: 'node /user/own.js' }] };
const registered = jsonRegister({ hooks: { Stop: [userGroup] } }, ENTRIES, codexSpec);
const stripped = jsonUnregister(registered, codexSpec);
const stopArr = (stripped.hooks as Record<string, unknown[]>).Stop;
expect(stopArr).toEqual([userGroup]); // hive Stop group gone, user group kept
// SessionStart array now empty (only our group was there).
expect((stripped.hooks as Record<string, unknown[]>).SessionStart).toEqual([]);
});
it('does not mutate the input config', () => {
const registered = jsonRegister({}, ENTRIES, codexSpec);
const snapshot = structuredClone(registered);
Object.freeze(registered);
const stripped = jsonUnregister(registered, codexSpec);
expect(registered).toEqual(snapshot);
expect(stripped).not.toBe(registered);
});
it('returns the config unchanged shape when there is no hooks key', () => {
const stripped = jsonUnregister({ other: true }, codexSpec);
expect(stripped).toEqual({ other: true });
});
});
describe('hasHiveEntries', () => {
it('is false for empty / undefined / no-hive configs', () => {
expect(hasHiveEntries(undefined, codexSpec)).toBe(false);
expect(hasHiveEntries({}, codexSpec)).toBe(false);
expect(
hasHiveEntries({ hooks: { Stop: [{ hooks: [{ command: 'node /user.js' }] }] } }, codexSpec),
).toBe(false);
});
it('is true once a marker-tagged hive group is registered', () => {
const registered = jsonRegister({}, ENTRIES, codexSpec);
expect(hasHiveEntries(registered, codexSpec)).toBe(true);
});
it('is false again after unregister', () => {
const registered = jsonRegister({}, ENTRIES, codexSpec);
expect(hasHiveEntries(jsonUnregister(registered, codexSpec), codexSpec)).toBe(false);
});
});

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { pathToFileURL } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import {
backupPathFor,
hookCommandFor,
hooksDirFromModuleUrl,
hookScriptPath,
normalizeCliPath,
} from '../src/paths-core.js';
describe('backupPathFor', () => {
it('replaces colons and dots in the ISO timestamp for Windows safety', () => {
const backup = backupPathFor('/h/.codex/hooks.json', '2026-06-01T10:30:45.123Z');
expect(backup).toBe('/h/.codex/hooks.json.hive-mind-backup.2026-06-01T10-30-45-123Z');
});
it('contains no `:` or `.` after the configPath suffix', () => {
const backup = backupPathFor('/h/cfg.json', '2026-06-01T10:30:45.123Z');
const stampPart = backup.slice('/h/cfg.json.hive-mind-backup.'.length);
expect(stampPart).not.toContain(':');
expect(stampPart).not.toContain('.');
});
it('keeps the config path verbatim as the prefix', () => {
const backup = backupPathFor('C:\\Users\\Marko Markovic\\.codex\\hooks.json', '2026-06-01T00:00:00.000Z');
expect(backup.startsWith('C:\\Users\\Marko Markovic\\.codex\\hooks.json.hive-mind-backup.')).toBe(true);
});
});
describe('hookCommandFor', () => {
it('produces a quoted node invocation with no --cli-path when omitted', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'));
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
expect(cmd).not.toContain('--cli-path');
});
it('appends a quoted --cli-path when supplied', () => {
const cmd = hookCommandFor('/abs/dist/hooks/stop.js', '/abs/cli/dist/index.js');
expect(cmd).toBe('node "/abs/dist/hooks/stop.js" --cli-path "/abs/cli/dist/index.js"');
});
it('omits --cli-path when an empty string is passed', () => {
const cmd = hookCommandFor('/abs/dist/hooks/stop.js', '');
expect(cmd).not.toContain('--cli-path');
});
it('keeps spaces in the script path inside the quotes', () => {
const scriptPath = 'C:\\Program Files\\hive-mind\\dist\\hooks\\stop.js';
const cmd = hookCommandFor(scriptPath);
expect(cmd).toBe('node "C:\\Program Files\\hive-mind\\dist\\hooks\\stop.js"');
});
it('keeps spaces in the cli-path 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('normalizeCliPath', () => {
it('returns undefined for undefined / empty / whitespace-only input', () => {
expect(normalizeCliPath(undefined)).toBeUndefined();
expect(normalizeCliPath('')).toBeUndefined();
expect(normalizeCliPath(' ')).toBeUndefined();
});
it('trims surrounding whitespace', () => {
expect(normalizeCliPath(' /abs/cli/dist/index.js ')).toBe('/abs/cli/dist/index.js');
});
it('throws when the value contains an embedded double-quote', () => {
expect(() => normalizeCliPath('/abs/cli/has"quote/index.js')).toThrow(/double-quote/);
});
it('passes through a clean path with spaces (no double-quote)', () => {
expect(normalizeCliPath('C:\\Program Files\\hive-mind\\index.js')).toBe(
'C:\\Program Files\\hive-mind\\index.js',
);
});
});
describe('hooksDirFromModuleUrl', () => {
it('resolves <pkg>/dist/hooks from a dist/<file>.js module url', () => {
const moduleUrl = pathToFileURL(resolve('/pkg/dist/install.js')).href;
const hooksDir = hooksDirFromModuleUrl(moduleUrl);
expect(hooksDir).toBe(resolve('/pkg/dist/hooks'));
});
it('returns an absolute, platform-normalized path whose parent is the module dir', () => {
const moduleUrl = pathToFileURL(resolve('/some/where/dist/index.js')).href;
const hooksDir = hooksDirFromModuleUrl(moduleUrl);
expect(dirname(hooksDir)).toBe(resolve('/some/where/dist'));
});
});
describe('hookScriptPath', () => {
it('joins <hooksDir>/<basename>.js', () => {
const hooksDir = resolve('/pkg/dist/hooks');
expect(hookScriptPath(hooksDir, 'session-start')).toBe(join(hooksDir, 'session-start.js'));
expect(hookScriptPath(hooksDir, 'stop')).toBe(join(hooksDir, 'stop.js'));
});
});