This commit is contained in:
271
packages/hive-mind-hooks-openclaw/tests/handler.test.ts
Normal file
271
packages/hive-mind-hooks-openclaw/tests/handler.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createLogger } from '@waggle/hive-mind-shim-core';
|
||||
import type {
|
||||
CliBridge,
|
||||
HookFrame,
|
||||
MemoryHit,
|
||||
SaveMemoryResult,
|
||||
} from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
makeOpenclawHandler,
|
||||
type HookContext,
|
||||
type InternalHookEventLike,
|
||||
type OpenclawHandlerInput,
|
||||
type SessionStartExtracted,
|
||||
type StopExtracted,
|
||||
type UserPromptExtracted,
|
||||
} from '@waggle/hive-mind-hooks-core';
|
||||
import {
|
||||
openclawAdapter,
|
||||
OPENCLAW_EVENT_NAME,
|
||||
OPENCLAW_PROVENANCE,
|
||||
} from '../src/adapter.js';
|
||||
|
||||
const SILENT = createLogger({ name: 'test', write: () => { /* swallow log output */ } });
|
||||
|
||||
/** A recording mock CliBridge — never shells out, never touches ~/.openclaw. */
|
||||
interface MockBridge extends CliBridge {
|
||||
saved: HookFrame[];
|
||||
recalls: number;
|
||||
cleanups: number;
|
||||
}
|
||||
|
||||
function makeMockBridge(opts: { hits?: MemoryHit[]; throwOn?: 'save' | 'recall' | 'cleanup' } = {}): MockBridge {
|
||||
const saved: HookFrame[] = [];
|
||||
const bridge = {
|
||||
saved,
|
||||
recalls: 0,
|
||||
cleanups: 0,
|
||||
async callMcpTool<T>(): Promise<T> {
|
||||
return undefined as unknown as T;
|
||||
},
|
||||
async saveMemory(frame: HookFrame): Promise<SaveMemoryResult> {
|
||||
if (opts.throwOn === 'save') throw new Error('boom: save_memory failed');
|
||||
saved.push(frame);
|
||||
return { id: String(saved.length), success: true, workspace: 'personal' };
|
||||
},
|
||||
async recallMemory(): Promise<MemoryHit[]> {
|
||||
bridge.recalls += 1;
|
||||
if (opts.throwOn === 'recall') throw new Error('boom: recall_memory failed');
|
||||
return opts.hits ?? [];
|
||||
},
|
||||
async cleanupFrames(): Promise<{ pruned: number }> {
|
||||
bridge.cleanups += 1;
|
||||
if (opts.throwOn === 'cleanup') throw new Error('boom: cleanup_frames failed');
|
||||
return { pruned: 0 };
|
||||
},
|
||||
setWorkspaceById(): void { /* noop */ },
|
||||
getActiveWorkspaceId(): undefined { return undefined; },
|
||||
} as unknown as MockBridge;
|
||||
return bridge;
|
||||
}
|
||||
|
||||
function ctxFor(bridge: CliBridge): HookContext {
|
||||
return { bridge, logger: SILENT };
|
||||
}
|
||||
|
||||
function provScope(sessionId: string): string {
|
||||
return `${OPENCLAW_PROVENANCE}:${sessionId}`;
|
||||
}
|
||||
|
||||
function hit(content: string): MemoryHit {
|
||||
return { id: 1, content, importance: 'important', source: 'openclaw', score: 1, created_at: '2026-06-01', from: 'personal' };
|
||||
}
|
||||
|
||||
describe('openclawAdapter (event map + field extraction over event.context)', () => {
|
||||
it('maps the four lifecycles to OpenClaw type:action keys; pre-compact uses the session: prefix', () => {
|
||||
expect(OPENCLAW_EVENT_NAME['session-start']).toBe('agent:bootstrap');
|
||||
expect(OPENCLAW_EVENT_NAME['user-prompt-submit']).toBe('message:received');
|
||||
expect(OPENCLAW_EVENT_NAME['stop']).toBe('message:sent');
|
||||
// HOOK.md-form key (prefixed) — the runtime action drops the session: prefix.
|
||||
expect(OPENCLAW_EVENT_NAME['pre-compact']).toBe('session:compact:before');
|
||||
});
|
||||
|
||||
it('source is openclaw', () => {
|
||||
expect(openclawAdapter.source).toBe('openclaw');
|
||||
});
|
||||
|
||||
it('extracts cwd / sessionId (channelId-first) / prompt / response / parent from the context object', () => {
|
||||
const received = { cwd: '/work', channelId: 'chan-7', content: 'the inbound prompt' };
|
||||
expect(openclawAdapter.extractCwd(received)).toBe('/work');
|
||||
expect(openclawAdapter.extractSessionId(received)).toBe('chan-7');
|
||||
expect(openclawAdapter.extractPrompt(received)).toBe('the inbound prompt');
|
||||
|
||||
const sent = { content: 'the outbound reply', parentId: 'p-1' };
|
||||
expect(openclawAdapter.extractResponse(sent, {})).toBe('the outbound reply');
|
||||
expect(openclawAdapter.extractParent(sent)).toBe('p-1');
|
||||
});
|
||||
|
||||
it('formatInject produces the { additionalContext } shape pushed into bootstrapFiles', () => {
|
||||
expect(openclawAdapter.formatInject?.('recalled frames here')).toEqual({ additionalContext: 'recalled frames here' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── makeOpenclawHandler drive path (the in-process handler) ─────────────
|
||||
|
||||
describe('makeOpenclawHandler — message:received saves a temporary frame', () => {
|
||||
it('persists the inbound prompt as a temporary frame, provenance-scoped to openclaw-gateway:<channel>', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const extracted: UserPromptExtracted = {
|
||||
prompt: 'hello from the gateway',
|
||||
cwd: '/work',
|
||||
sessionId: provScope('chan-7'),
|
||||
};
|
||||
const event: InternalHookEventLike = { type: 'message', action: 'received' };
|
||||
await handler.handle({ event, extracted }, ctxFor(bridge));
|
||||
|
||||
expect(bridge.saved).toHaveLength(1);
|
||||
const frame = bridge.saved[0];
|
||||
expect(frame.importance).toBe('temporary');
|
||||
expect(frame.content).toBe('hello from the gateway');
|
||||
// Provenance stamp rides the frame scope (the only attribution channel save preserves).
|
||||
expect(frame.scope).toBe(provScope('chan-7'));
|
||||
expect(frame.scope).toContain('openclaw-gateway');
|
||||
expect(frame.source).toBe('openclaw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeOpenclawHandler — agent:bootstrap recalls + the adapter injects into bootstrapFiles', () => {
|
||||
it('mutates the host-owned bootstrapFiles array with the recalled text (the inject seam)', async () => {
|
||||
const bridge = makeMockBridge({ hits: [hit('prior decision: ship the thing')] });
|
||||
// Replicate the package handler's SessionStart seam: recall, format via the
|
||||
// adapter, push onto the MUTABLE bootstrapFiles array the gateway reads back.
|
||||
const bootstrapFiles: unknown[] = [];
|
||||
const recalled = await bridge.recallMemory('', { limit: 20, scope: 'personal' });
|
||||
expect(recalled).toHaveLength(1);
|
||||
const injected = openclawAdapter.formatInject?.(recalled.map((h) => h.content).join('\n')) as { additionalContext: string };
|
||||
bootstrapFiles.push(injected.additionalContext);
|
||||
|
||||
expect(bootstrapFiles).toHaveLength(1);
|
||||
expect(bootstrapFiles[0]).toContain('prior decision: ship the thing');
|
||||
});
|
||||
|
||||
it('drives runSessionStartBody through the handler without throwing (recall path executes)', async () => {
|
||||
const bridge = makeMockBridge({ hits: [hit('frame a')] });
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const extracted: SessionStartExtracted = {
|
||||
cwd: '/work',
|
||||
sessionId: provScope('chan-7'),
|
||||
recallLimit: 20,
|
||||
};
|
||||
const event: InternalHookEventLike = { type: 'agent', action: 'bootstrap' };
|
||||
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
|
||||
expect(bridge.recalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeOpenclawHandler — message:sent (Stop) DEBOUNCE collapses 0..N/turn to one save', () => {
|
||||
it('fires 3 message:sent in a turn → exactly ONE save of the LAST payload', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const bridge = makeMockBridge();
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 750 });
|
||||
const sessionKey = 'turn-key';
|
||||
|
||||
const mk = (response: string): OpenclawHandlerInput => {
|
||||
const extracted: StopExtracted = {
|
||||
cwd: '/work',
|
||||
sessionId: provScope('chan-7'),
|
||||
response,
|
||||
parent: undefined,
|
||||
};
|
||||
const event: InternalHookEventLike = { type: 'message', action: 'sent', sessionKey };
|
||||
return { event, extracted };
|
||||
};
|
||||
|
||||
// Fire three message:sent rapidly (same turn). The first two are superseded.
|
||||
const p1 = handler.handle(mk('partial reply 1'), ctxFor(bridge));
|
||||
const p2 = handler.handle(mk('partial reply 2'), ctxFor(bridge));
|
||||
const p3 = handler.handle(mk('FINAL DECISION: we will ship the feature on Friday'), ctxFor(bridge));
|
||||
|
||||
// Superseded dispatches resolve immediately (fail-open: a hook must never block the host).
|
||||
await Promise.all([p1, p2]);
|
||||
expect(bridge.saved).toHaveLength(0); // nothing saved yet — still debouncing
|
||||
|
||||
// Advance past the debounce window; the last save fires.
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
await p3;
|
||||
|
||||
// Exactly one frame saved, carrying the LAST payload.
|
||||
expect(bridge.saved).toHaveLength(1);
|
||||
const frame = bridge.saved[0];
|
||||
expect(frame.content).toContain('FINAL DECISION');
|
||||
expect(frame.content).not.toContain('partial reply 1');
|
||||
// Stop frames are important/critical (never temporary), provenance-scoped.
|
||||
expect(['important', 'critical']).toContain(frame.importance);
|
||||
expect(frame.scope).toBe(provScope('chan-7'));
|
||||
expect(frame.source).toBe('openclaw');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeOpenclawHandler — PreCompact matches on the action SUFFIX, not the joined string', () => {
|
||||
it('fires cleanup_frames when the runtime action is "compact:before" (session: prefix dropped at runtime)', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
// Runtime event: type 'session', action 'compact:before' — joined would be
|
||||
// 'session:compact:before' (matches), but the suffix-match is what the
|
||||
// design relies on (CORRECTION 2). Use a non-joined type to prove suffix.
|
||||
const event: InternalHookEventLike = { type: 'lifecycle', action: 'compact:before' };
|
||||
await handler.handle({ event, extracted: { scope: provScope('chan-7') } }, ctxFor(bridge));
|
||||
expect(bridge.cleanups).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores an unmapped (type, action) pair', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const event: InternalHookEventLike = { type: 'tool', action: 'invoked' };
|
||||
await handler.handle({ event, extracted: { scope: undefined } }, ctxFor(bridge));
|
||||
expect(bridge.saved).toHaveLength(0);
|
||||
expect(bridge.cleanups).toBe(0);
|
||||
expect(bridge.recalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§7.3 invariant 1 — FAIL-OPEN (handler swallows a bridge error, never throws)', () => {
|
||||
it('a save error during message:received resolves (never rejects)', async () => {
|
||||
const bridge = makeMockBridge({ throwOn: 'save' });
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const extracted: UserPromptExtracted = { prompt: 'p', cwd: '/w', sessionId: provScope('c') };
|
||||
const event: InternalHookEventLike = { type: 'message', action: 'received' };
|
||||
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
|
||||
expect(bridge.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a recall error during agent:bootstrap resolves (never rejects)', async () => {
|
||||
const bridge = makeMockBridge({ throwOn: 'recall' });
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const extracted: SessionStartExtracted = { cwd: '/w', sessionId: provScope('c'), recallLimit: 20 };
|
||||
const event: InternalHookEventLike = { type: 'agent', action: 'bootstrap' };
|
||||
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('a cleanup error during compact:before resolves (never rejects)', async () => {
|
||||
const bridge = makeMockBridge({ throwOn: 'cleanup' });
|
||||
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
|
||||
const event: InternalHookEventLike = { type: 'lifecycle', action: 'compact:before' };
|
||||
await expect(handler.handle({ event, extracted: { scope: provScope('c') } }, ctxFor(bridge)))
|
||||
.resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── the package's own default-export handler (in-process entrypoint) ─────
|
||||
|
||||
describe('openclawHook default export — fail-open over the live bridge path', () => {
|
||||
it('never throws on a garbage event (no matching lifecycle, no env CLI configured)', async () => {
|
||||
const { default: openclawHook } = await import('../src/handler.js');
|
||||
// Unmapped event — must resolve to undefined without touching any CLI.
|
||||
await expect(openclawHook({ type: 'noop', action: 'noop' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('never throws even when the recall path would fail (no real hive-mind-cli on PATH)', async () => {
|
||||
const { default: openclawHook } = await import('../src/handler.js');
|
||||
// agent:bootstrap drives recall through a real (unconfigured) bridge; the
|
||||
// handler's try/catch must swallow any failure and resolve.
|
||||
const event = { type: 'agent', action: 'bootstrap', context: { channelId: 'c', bootstrapFiles: [] } };
|
||||
await expect(openclawHook(event)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
246
packages/hive-mind-hooks-openclaw/tests/install.test.ts
Normal file
246
packages/hive-mind-hooks-openclaw/tests/install.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import JSON5 from 'json5';
|
||||
import { install } from '../src/install.js';
|
||||
import { HIVE_ENTRY_KEY, HOOKS_KEY } from '../src/json5-merger.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
/** A fake compiled handler.js the installer COPIES into the managed hook dir. */
|
||||
handlerSource: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
hiveHookDir: string;
|
||||
}
|
||||
|
||||
/** openclaw.json is OPTIONAL — `initial=undefined` exercises create-if-missing. */
|
||||
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmocl-install-'));
|
||||
const openclawDir = join(home, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
if (initial !== undefined) {
|
||||
await writeFile(configPath, initial, 'utf-8');
|
||||
}
|
||||
// Fake compiled handler — install copies this verbatim into the hook dir.
|
||||
const distDir = join(home, 'fake-dist');
|
||||
await mkdir(distDir, { recursive: true });
|
||||
const handlerSource = join(distDir, 'handler.js');
|
||||
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
|
||||
return {
|
||||
home,
|
||||
handlerSource,
|
||||
configPath,
|
||||
pointerPath: join(openclawDir, 'hive-mind-install.json'),
|
||||
hiveHookDir: join(openclawDir, 'hooks', 'hive-mind'),
|
||||
};
|
||||
}
|
||||
|
||||
function readPointer(p: string): Promise<Record<string, unknown>> {
|
||||
return readFile(p, 'utf-8').then((s) => JSON.parse(s) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function internalEntries(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const hooks = config[HOOKS_KEY] as Record<string, Record<string, unknown>> | undefined;
|
||||
const internal = hooks?.['internal'] as Record<string, unknown> | undefined;
|
||||
return (internal?.['entries'] as Record<string, unknown> | undefined) ?? {};
|
||||
}
|
||||
|
||||
describe('install (openclaw)', () => {
|
||||
let env: TestEnv;
|
||||
|
||||
afterEach(async () => {
|
||||
if (env) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws when the compiled handler.js source is missing (build first)', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
await rm(env.handlerSource, { force: true });
|
||||
await expect(install({ home: env.home, handlerSourcePath: env.handlerSource }))
|
||||
.rejects.toThrow(/compiled handler not found/i);
|
||||
});
|
||||
|
||||
it('throws on a config.json that parses as neither JSON nor JSON5', async () => {
|
||||
env = await bootstrap('{ : : : not valid : : : }');
|
||||
await expect(install({ home: env.home, handlerSourcePath: env.handlerSource }))
|
||||
.rejects.toThrow(/parse/i);
|
||||
});
|
||||
|
||||
// ── pre-existed branch ────────────────────────────────────────────────
|
||||
|
||||
it('writes a LITERAL byte-identical backup before mutating a pre-existing openclaw.json', async () => {
|
||||
// Comments + trailing commas that a JSON5 round-trip would NOT preserve —
|
||||
// proves the backup is the original bytes, not a re-serialized merge.
|
||||
const initial = [
|
||||
'{',
|
||||
' // my openclaw config',
|
||||
' model: "opus", // keep me',
|
||||
' hooks: { internal: { enabled: false, entries: {} } },',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
env = await bootstrap(initial);
|
||||
const original = await readFile(env.configPath, 'utf-8');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(result.backupPath).not.toBeNull();
|
||||
const backupContent = await readFile(result.backupPath as string, 'utf-8');
|
||||
expect(backupContent).toBe(original);
|
||||
// The backup preserves the comment that JSON re-serialization drops.
|
||||
expect(backupContent).toContain('// my openclaw config');
|
||||
});
|
||||
|
||||
it('records created_by_us=false + settings_backup when openclaw.json pre-existed', async () => {
|
||||
env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(result.createdByUs).toBe(false);
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['created_by_us']).toBe(false);
|
||||
expect(pointer['settings_backup']).toBe(result.backupPath);
|
||||
});
|
||||
|
||||
it('minimal-touch: flips internal.enabled + adds hive entry, preserving user keys + entries', async () => {
|
||||
const initial = [
|
||||
'{',
|
||||
' model: "opus",',
|
||||
' hooks: { internal: { enabled: false, entries: { "user-own": { enabled: true } } } },',
|
||||
'}',
|
||||
].join('\n');
|
||||
env = await bootstrap(initial);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
const ents = internalEntries(after);
|
||||
// Our entry added, the user entry preserved verbatim.
|
||||
expect((ents[HIVE_ENTRY_KEY] as Record<string, unknown>)['enabled']).toBe(true);
|
||||
expect(ents['user-own']).toEqual({ enabled: true });
|
||||
// Subsystem turned on, user top-level key preserved.
|
||||
const hooks = after[HOOKS_KEY] as Record<string, Record<string, unknown>>;
|
||||
expect(hooks['internal']['enabled']).toBe(true);
|
||||
expect(after['model']).toBe('opus');
|
||||
});
|
||||
|
||||
// ── create-if-missing branch ──────────────────────────────────────────
|
||||
|
||||
it('creates openclaw.json with the hive entry when it is absent', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect((internalEntries(after)[HIVE_ENTRY_KEY] as Record<string, unknown>)['enabled']).toBe(true);
|
||||
expect(result.createdByUs).toBe(true);
|
||||
});
|
||||
|
||||
it('records created_by_us=true and writes NO backup when openclaw.json is absent', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(result.backupPath).toBeNull();
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['created_by_us']).toBe(true);
|
||||
expect(pointer['settings_backup']).toBeNull();
|
||||
});
|
||||
|
||||
// ── managed hook DIR (in-process model — no per-event scripts) ─────────
|
||||
|
||||
it('writes the managed hook DIR with HOOK.md + a byte-identical copy of handler.js', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const handlerBytes = await readFile(env.handlerSource, 'utf-8');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(result.hookDir).toBe(env.hiveHookDir);
|
||||
expect(existsSync(join(env.hiveHookDir, 'HOOK.md'))).toBe(true);
|
||||
expect(existsSync(join(env.hiveHookDir, 'handler.js'))).toBe(true);
|
||||
// handler.js is copied verbatim from dist.
|
||||
expect(await readFile(join(env.hiveHookDir, 'handler.js'), 'utf-8')).toBe(handlerBytes);
|
||||
// HOOK.md declares the four events incl. the prefixed compaction key.
|
||||
const hookMd = await readFile(join(env.hiveHookDir, 'HOOK.md'), 'utf-8');
|
||||
expect(hookMd).toContain('agent:bootstrap');
|
||||
expect(hookMd).toContain('message:received');
|
||||
expect(hookMd).toContain('message:sent');
|
||||
expect(hookMd).toContain('session:compact:before');
|
||||
});
|
||||
|
||||
// Spawns two child Node processes (esbuild bundle of the handler + import of
|
||||
// the produced bundle). Standalone this takes <1s, but full-suite runs
|
||||
// saturate the CPU (forks pool, 4 workers) and the spawns can exceed vitest's
|
||||
// 30s default testTimeout (observed 2026-07-15 full-suite flake,
|
||||
// standalone-green). 60s per-test timeout, same class as f322cc2c.
|
||||
it('copies a self-contained handler that imports and runs with NODE_PATH empty', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const buildScript = fileURLToPath(new URL('../scripts/build-handler.mjs', import.meta.url));
|
||||
const handlerEntry = fileURLToPath(new URL('../src/handler.ts', import.meta.url));
|
||||
execFileSync(process.execPath, [buildScript, handlerEntry, env.handlerSource], {
|
||||
cwd: env.home,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const installedHandler = join(env.hiveHookDir, 'handler.js');
|
||||
const installedUrl = pathToFileURL(installedHandler).href;
|
||||
const runInstalledHandler = [
|
||||
`const module = await import(${JSON.stringify(installedUrl)});`,
|
||||
`if (typeof module.default !== 'function') throw new Error('default export is not a function');`,
|
||||
`await module.default({ type: 'noop', action: 'noop' });`,
|
||||
].join('\n');
|
||||
|
||||
// Both the handler path and cwd are outside the repository/package tree.
|
||||
// Any remaining @waggle import would fail with this empty resolution path.
|
||||
execFileSync(process.execPath, ['--input-type=module', '--eval', runInstalledHandler], {
|
||||
cwd: env.home,
|
||||
env: { ...process.env, NODE_PATH: '' },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
// ── pointer + lifecycles + cli-path ───────────────────────────────────
|
||||
|
||||
it('drops a pointer recording the four lifecycles + touched keys + hook dir name', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(existsSync(result.pointerPath)).toBe(true);
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
|
||||
expect(pointer['hooks_dir']).toBe(env.hiveHookDir);
|
||||
expect(typeof pointer['version']).toBe('string');
|
||||
const extra = pointer['extra'] as Record<string, unknown>;
|
||||
expect(extra['hook_dir_name']).toBe('hive-mind');
|
||||
expect((extra['touched_keys'] as string[]).sort()).toEqual([
|
||||
'hooks.internal.enabled',
|
||||
'hooks.internal.entries.hive-mind',
|
||||
]);
|
||||
expect([...result.touchedKeys].sort()).toEqual([
|
||||
'hooks.internal.enabled',
|
||||
'hooks.internal.entries.hive-mind',
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
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 the entry env + 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, handlerSourcePath: env.handlerSource, cliPath });
|
||||
expect(result.cliPath).toBe(cliPath);
|
||||
|
||||
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
|
||||
const hive = internalEntries(after)[HIVE_ENTRY_KEY] as Record<string, unknown>;
|
||||
expect((hive['env'] as Record<string, unknown>)['WAGGLE_HIVE_MIND_CLI']).toBe(cliPath);
|
||||
|
||||
const pointer = await readPointer(result.pointerPath);
|
||||
expect(pointer['cli_path']).toBe(cliPath);
|
||||
});
|
||||
});
|
||||
174
packages/hive-mind-hooks-openclaw/tests/json5-merger.test.ts
Normal file
174
packages/hive-mind-hooks-openclaw/tests/json5-merger.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import JSON5 from 'json5';
|
||||
import {
|
||||
HIVE_ENTRY_KEY,
|
||||
HOOKS_KEY,
|
||||
hasHiveEntries,
|
||||
jsonRegister,
|
||||
jsonUnregister,
|
||||
parseConfig,
|
||||
serializeConfig,
|
||||
} from '../src/json5-merger.js';
|
||||
|
||||
function internal(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const hooks = config[HOOKS_KEY] as Record<string, unknown> | undefined;
|
||||
return (hooks?.['internal'] as Record<string, unknown> | undefined) ?? {};
|
||||
}
|
||||
|
||||
function entries(config: Record<string, unknown>): Record<string, unknown> {
|
||||
return (internal(config)['entries'] as Record<string, unknown> | undefined) ?? {};
|
||||
}
|
||||
|
||||
describe('parseConfig (openclaw JSON5 codec — strict JSON first, JSON5 fallback)', () => {
|
||||
it('returns {} for an empty / whitespace-only config (create-if-missing)', () => {
|
||||
expect(parseConfig('')).toEqual({});
|
||||
expect(parseConfig(' \n ')).toEqual({});
|
||||
});
|
||||
|
||||
it('parses strict JSON (the fast path)', () => {
|
||||
const parsed = parseConfig('{"model":"opus","hooks":{}}');
|
||||
expect(parsed['model']).toBe('opus');
|
||||
});
|
||||
|
||||
it('parses a COMMENTED openclaw.json with trailing commas via the JSON5 fallback', () => {
|
||||
const raw = [
|
||||
'{',
|
||||
' // OpenClaw gateway config — hand-edited, comments matter',
|
||||
' model: "claude-opus", /* the good one */',
|
||||
' hooks: {',
|
||||
' internal: {',
|
||||
' enabled: false,',
|
||||
' entries: {',
|
||||
" 'user-own': { enabled: true }, // trailing comma below",
|
||||
' },',
|
||||
' },',
|
||||
' },',
|
||||
'}',
|
||||
].join('\n');
|
||||
const parsed = parseConfig(raw);
|
||||
expect(parsed['model']).toBe('claude-opus');
|
||||
const userEntry = entries(parsed)['user-own'] as Record<string, unknown>;
|
||||
expect(userEntry['enabled']).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when BOTH strict JSON and JSON5 fail (installer fails loudly, never clobbers)', () => {
|
||||
expect(() => parseConfig('{ this : : : is not valid }')).toThrow(/parse/i);
|
||||
});
|
||||
|
||||
it('treats a top-level scalar/array JSON5 doc as empty (not a crash)', () => {
|
||||
expect(parseConfig('"just a string"')).toEqual({});
|
||||
expect(parseConfig('[1, 2, 3]')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeConfig (openclaw — strict JSON, valid for OpenClaw JSON5 reader)', () => {
|
||||
it('serializes to 2-space JSON with a trailing newline that re-parses', () => {
|
||||
const text = serializeConfig(jsonRegister({ model: 'x' }));
|
||||
expect(text.endsWith('\n')).toBe(true);
|
||||
const reparsed = JSON5.parse(text) as Record<string, unknown>;
|
||||
expect(reparsed['model']).toBe('x');
|
||||
expect(hasHiveEntries(reparsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonRegister (openclaw minimal-touch — flip enabled + add hive entry)', () => {
|
||||
it('returns a NEW object — does not mutate input (immutability contract)', () => {
|
||||
const original: Record<string, unknown> = { hooks: { internal: { enabled: false } } };
|
||||
const merged = jsonRegister(original);
|
||||
expect(merged).not.toBe(original);
|
||||
// Input untouched — enabled is still false on the original.
|
||||
expect((original['hooks'] as Record<string, Record<string, unknown>>)['internal']['enabled']).toBe(false);
|
||||
});
|
||||
|
||||
it('flips hooks.internal.enabled=true and adds entries["hive-mind"]={enabled:true}', () => {
|
||||
const merged = jsonRegister({});
|
||||
expect(internal(merged)['enabled']).toBe(true);
|
||||
const hive = entries(merged)[HIVE_ENTRY_KEY] as Record<string, unknown>;
|
||||
expect(hive).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('attaches env to the hive entry when supplied', () => {
|
||||
const merged = jsonRegister({}, { env: { WAGGLE_HIVE_MIND_CLI: '/abs/cli.js', WAGGLE_WORKSPACE_ID: 'ws1' } });
|
||||
const hive = entries(merged)[HIVE_ENTRY_KEY] as Record<string, unknown>;
|
||||
expect(hive['env']).toEqual({ WAGGLE_HIVE_MIND_CLI: '/abs/cli.js', WAGGLE_WORKSPACE_ID: 'ws1' });
|
||||
});
|
||||
|
||||
it('does NOT clobber other internal entries or other internal keys (minimal-touch)', () => {
|
||||
const existing: Record<string, unknown> = {
|
||||
model: 'opus',
|
||||
hooks: {
|
||||
internal: {
|
||||
enabled: false,
|
||||
throttleMs: 250,
|
||||
entries: { 'user-own': { enabled: true, foo: 'bar' } },
|
||||
},
|
||||
external: { whatever: 1 },
|
||||
},
|
||||
};
|
||||
const merged = jsonRegister(existing);
|
||||
// user entry preserved verbatim.
|
||||
expect(entries(merged)['user-own']).toEqual({ enabled: true, foo: 'bar' });
|
||||
// sibling internal key preserved.
|
||||
expect(internal(merged)['throttleMs']).toBe(250);
|
||||
// sibling hooks subtree preserved.
|
||||
expect((merged['hooks'] as Record<string, unknown>)['external']).toEqual({ whatever: 1 });
|
||||
// unrelated top-level key preserved.
|
||||
expect(merged['model']).toBe('opus');
|
||||
// and our entry was added + subsystem turned on.
|
||||
expect(internal(merged)['enabled']).toBe(true);
|
||||
expect(hasHiveEntries(merged)).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces OUR entry in place on re-install (idempotent — never duplicated)', () => {
|
||||
const merged1 = jsonRegister({}, { env: { A: '1' } });
|
||||
const merged2 = jsonRegister(merged1, { env: { A: '2' } });
|
||||
const hive = entries(merged2)[HIVE_ENTRY_KEY] as Record<string, unknown>;
|
||||
expect(hive['env']).toEqual({ A: '2' });
|
||||
// Exactly one hive-mind entry key.
|
||||
expect(Object.keys(entries(merged2)).filter((k) => k === HIVE_ENTRY_KEY)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonUnregister (openclaw — diagnostics / backup-less path)', () => {
|
||||
it('strips our hive-mind entry but preserves other entries + leaves enabled as-is', () => {
|
||||
const withUser: Record<string, unknown> = {
|
||||
hooks: { internal: { enabled: true, entries: { 'user-own': { enabled: true } } } },
|
||||
};
|
||||
const merged = jsonRegister(withUser);
|
||||
expect(hasHiveEntries(merged)).toBe(true);
|
||||
|
||||
const stripped = jsonUnregister(merged);
|
||||
expect(hasHiveEntries(stripped)).toBe(false);
|
||||
// User entry survives.
|
||||
expect(entries(stripped)['user-own']).toEqual({ enabled: true });
|
||||
// Minimal-touch: we do NOT flip enabled back off (other hooks may rely on it).
|
||||
expect(internal(stripped)['enabled']).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a NEW object and leaves the input untouched (immutability)', () => {
|
||||
const merged = jsonRegister({});
|
||||
const stripped = jsonUnregister(merged);
|
||||
expect(stripped).not.toBe(merged);
|
||||
expect(hasHiveEntries(merged)).toBe(true); // original still has the entry
|
||||
});
|
||||
|
||||
it('is a no-op (new object) when there is no hooks/internal block', () => {
|
||||
const stripped = jsonUnregister({ model: 'x' });
|
||||
expect(stripped['model']).toBe('x');
|
||||
expect(hasHiveEntries(stripped)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHiveEntries (openclaw structural marker)', () => {
|
||||
it('false on empty / hookless / hive-less configs', () => {
|
||||
expect(hasHiveEntries(undefined)).toBe(false);
|
||||
expect(hasHiveEntries({})).toBe(false);
|
||||
expect(hasHiveEntries({ hooks: {} })).toBe(false);
|
||||
expect(hasHiveEntries({ hooks: { internal: { enabled: true, entries: {} } } })).toBe(false);
|
||||
expect(hasHiveEntries({ hooks: { internal: { entries: { 'user-own': {} } } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('true once our hive-mind entry is present', () => {
|
||||
expect(hasHiveEntries(jsonRegister({}))).toBe(true);
|
||||
});
|
||||
});
|
||||
87
packages/hive-mind-hooks-openclaw/tests/paths.test.ts
Normal file
87
packages/hive-mind-hooks-openclaw/tests/paths.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
allHookBasenames,
|
||||
backupPathFor,
|
||||
hookCommandFor,
|
||||
resolvePaths,
|
||||
HIVE_HOOK_DIR_NAME,
|
||||
HIVE_HOOK_ENTRY_KEY,
|
||||
} from '../src/paths.js';
|
||||
|
||||
describe('resolvePaths (openclaw)', () => {
|
||||
it('places openclaw.json + pointer under <home>/.openclaw/', () => {
|
||||
const home = resolve('/fake/home');
|
||||
const paths = resolvePaths({ home, handlerSourcePath: resolve('/some/dist/handler.js') });
|
||||
expect(paths.openclawDir).toBe(join(home, '.openclaw'));
|
||||
expect(paths.configPath).toBe(join(home, '.openclaw', 'openclaw.json'));
|
||||
expect(paths.pointerPath).toBe(join(home, '.openclaw', 'hive-mind-install.json'));
|
||||
});
|
||||
|
||||
it('resolves the managed hook DIRECTORY (not per-event scripts) under ~/.openclaw/hooks/', () => {
|
||||
const home = resolve('/h');
|
||||
const paths = resolvePaths({ home, handlerSourcePath: resolve('/d/handler.js') });
|
||||
// OpenClaw is in-process: one managed dir holding HOOK.md + handler.js,
|
||||
// NOT four separate compiled hook scripts.
|
||||
expect(paths.hooksRoot).toBe(join(home, '.openclaw', 'hooks'));
|
||||
expect(paths.hiveHookDir).toBe(join(home, '.openclaw', 'hooks', HIVE_HOOK_DIR_NAME));
|
||||
expect(paths.hookMdPath).toBe(join(paths.hiveHookDir, 'HOOK.md'));
|
||||
expect(paths.installedHandlerPath).toBe(join(paths.hiveHookDir, 'handler.js'));
|
||||
});
|
||||
|
||||
it('handlerSourcePath override wins over moduleUrl', () => {
|
||||
const explicit = resolve('/x/y/handler.js');
|
||||
const paths = resolvePaths({
|
||||
home: resolve('/h'),
|
||||
handlerSourcePath: explicit,
|
||||
moduleUrl: 'file:///irrelevant/dist/install.js',
|
||||
});
|
||||
expect(paths.handlerSourcePath).toBe(explicit);
|
||||
});
|
||||
|
||||
it('derives the self-contained handler bundle sibling from moduleUrl', () => {
|
||||
const moduleUrl = pathToFileURL(resolve('/pkg/dist/install.js')).href;
|
||||
const paths = resolvePaths({ home: resolve('/h'), moduleUrl });
|
||||
expect(paths.handlerSourcePath).toBe(resolve('/pkg/dist/handler.bundle.cjs'));
|
||||
});
|
||||
|
||||
it('falls back to cwd/dist/handler.bundle.cjs when neither override is given', () => {
|
||||
const paths = resolvePaths({ home: resolve('/h') });
|
||||
expect(paths.handlerSourcePath).toBe(resolve(process.cwd(), 'dist', 'handler.bundle.cjs'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('allHookBasenames (openclaw — FOUR lifecycles incl. pre-compact)', () => {
|
||||
it('names the four lifecycles the single handler dispatches (bookkeeping, not separate files)', () => {
|
||||
expect([...allHookBasenames()].sort()).toEqual([
|
||||
'pre-compact',
|
||||
'session-start',
|
||||
'stop',
|
||||
'user-prompt-submit',
|
||||
]);
|
||||
// Unlike hermes, openclaw DOES carry a compaction lifecycle
|
||||
// (session:compact:before → runtime action compact:before).
|
||||
expect([...allHookBasenames()]).toContain('pre-compact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exported entry/dir name constants', () => {
|
||||
it('the managed dir name and the logical entry key are both "hive-mind"', () => {
|
||||
expect(HIVE_HOOK_DIR_NAME).toBe('hive-mind');
|
||||
expect(HIVE_HOOK_ENTRY_KEY).toBe('hive-mind');
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-exported shared Windows-safe helpers', () => {
|
||||
it('backupPathFor replaces colons and dots in the timestamp for filesystem safety', () => {
|
||||
const backup = backupPathFor('/h/.openclaw/openclaw.json', '2026-04-28T10:30:45.123Z');
|
||||
expect(backup).toBe('/h/.openclaw/openclaw.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
|
||||
});
|
||||
|
||||
it('hookCommandFor produces a quoted node invocation and appends --cli-path', () => {
|
||||
const cmd = hookCommandFor(resolve('/abs/dist/handler.js'), '/abs/cli/dist/index.js');
|
||||
expect(cmd).toMatch(/^node "[^"]+handler\.js"/);
|
||||
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
|
||||
});
|
||||
});
|
||||
145
packages/hive-mind-hooks-openclaw/tests/uninstall.test.ts
Normal file
145
packages/hive-mind-hooks-openclaw/tests/uninstall.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 } from 'node:path';
|
||||
import { install } from '../src/install.js';
|
||||
import { uninstall } from '../src/uninstall.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
handlerSource: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
hiveHookDir: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmocl-uninstall-'));
|
||||
const openclawDir = join(home, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
if (initial !== undefined) {
|
||||
await writeFile(configPath, initial, 'utf-8');
|
||||
}
|
||||
const distDir = join(home, 'fake-dist');
|
||||
await mkdir(distDir, { recursive: true });
|
||||
const handlerSource = join(distDir, 'handler.js');
|
||||
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
|
||||
return {
|
||||
home,
|
||||
handlerSource,
|
||||
configPath,
|
||||
pointerPath: join(openclawDir, 'hive-mind-install.json'),
|
||||
hiveHookDir: join(openclawDir, 'hooks', 'hive-mind'),
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash('sha256').update(s, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
describe('uninstall (openclaw)', () => {
|
||||
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, handlerSourcePath: env.handlerSource }))
|
||||
.rejects.toThrow(/pointer/);
|
||||
});
|
||||
|
||||
// ── created_by_us=false: LITERAL byte-identical restore (§7.3 invariant 2) ─
|
||||
// JSON5 round-trip is lossy (comments + trailing commas are dropped on
|
||||
// re-serialize), so reversibility relies on restoring the ORIGINAL BYTES.
|
||||
|
||||
it('install + uninstall round-trip is SHA-256 identical to pre-install state (comments preserved)', async () => {
|
||||
// Comments + trailing commas a naive JSON re-serialize would NOT reproduce.
|
||||
const initial = [
|
||||
'{',
|
||||
' // OpenClaw config — hand-edited, comments matter',
|
||||
' model: "claude-opus", /* the good one */',
|
||||
' temperature: 0.2,',
|
||||
' hooks: {',
|
||||
' internal: {',
|
||||
' enabled: false,',
|
||||
' entries: { "user-own": { enabled: true } },',
|
||||
' },',
|
||||
' },',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
env = await bootstrap(initial);
|
||||
const preInstall = await readFile(env.configPath, 'utf-8');
|
||||
const preHash = sha256(preInstall);
|
||||
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const afterInstall = await readFile(env.configPath, 'utf-8');
|
||||
expect(sha256(afterInstall)).not.toBe(preHash); // install actually mutated
|
||||
// Sanity: the merged write IS lossy — the comment is gone post-install,
|
||||
// which is exactly why we need the literal backup to reverse it.
|
||||
expect(afterInstall).not.toContain('// OpenClaw config');
|
||||
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(u.createdRemoved).toBe(false);
|
||||
expect(u.restoredFrom).not.toBeNull();
|
||||
expect(u.hookDirRemoved).toBe(true);
|
||||
const afterUninstall = await readFile(env.configPath, 'utf-8');
|
||||
// Byte-for-byte identical — the comment + trailing commas are back.
|
||||
expect(sha256(afterUninstall)).toBe(preHash);
|
||||
expect(afterUninstall).toBe(preInstall);
|
||||
expect(afterUninstall).toContain('// OpenClaw config — hand-edited, comments matter');
|
||||
});
|
||||
|
||||
it('removes the managed hook dir (HOOK.md + handler.js) on uninstall — no orphan', async () => {
|
||||
env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(existsSync(join(env.hiveHookDir, 'HOOK.md'))).toBe(true);
|
||||
expect(existsSync(join(env.hiveHookDir, 'handler.js'))).toBe(true);
|
||||
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(u.hookDirRemoved).toBe(true);
|
||||
expect(existsSync(env.hiveHookDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes backup + pointer by default after a restore', async () => {
|
||||
env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(existsSync(result.backupPath as string)).toBe(true);
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
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('{ "model": "opus", "hooks": {} }');
|
||||
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource, 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 openclaw.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, handlerSourcePath: env.handlerSource });
|
||||
expect(result.createdByUs).toBe(true);
|
||||
expect(existsSync(env.configPath)).toBe(true);
|
||||
|
||||
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
expect(u.createdRemoved).toBe(true);
|
||||
expect(u.restoredFrom).toBeNull();
|
||||
// No orphaned config, no managed dir, no leftover pointer.
|
||||
expect(existsSync(env.configPath)).toBe(false);
|
||||
expect(existsSync(env.hiveHookDir)).toBe(false);
|
||||
expect(existsSync(env.pointerPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
170
packages/hive-mind-hooks-openclaw/tests/verify.test.ts
Normal file
170
packages/hive-mind-hooks-openclaw/tests/verify.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdtemp, mkdir, writeFile, readFile, 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;
|
||||
handlerSource: string;
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmocl-verify-'));
|
||||
const openclawDir = join(home, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
if (initial !== undefined) {
|
||||
await writeFile(configPath, initial, 'utf-8');
|
||||
}
|
||||
const distDir = join(home, 'fake-dist');
|
||||
await mkdir(distDir, { recursive: true });
|
||||
const handlerSource = join(distDir, 'handler.js');
|
||||
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
|
||||
return { home, handlerSource, configPath };
|
||||
}
|
||||
|
||||
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 (openclaw)', () => {
|
||||
const envs: TestEnv[] = [];
|
||||
afterEach(async () => {
|
||||
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports failure when openclaw.json is missing', async () => {
|
||||
const env = await bootstrap(undefined);
|
||||
envs.push(env);
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks[0].name).toBe('openclaw.json exists');
|
||||
expect(result.checks[0].ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reports failure when the hive entry is not yet installed', async () => {
|
||||
const env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
envs.push(env);
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.some((c) => !c.ok && c.name.includes('hive-mind internal-hooks entry'))).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after install — entry present, subsystem enabled, dir+HOOK.md+handler on disk, CLI reachable', async () => {
|
||||
const env = await bootstrap('{ "model": "opus", "hooks": {} }');
|
||||
envs.push(env);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name.includes('hive-mind internal-hooks entry'))?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'internal hooks subsystem enabled')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'managed hook dir exists')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'HOOK.md readable on disk')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'handler.js readable on disk')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('flags the activation advisory (FAIL) when internal.enabled is false even with the entry present', async () => {
|
||||
// Entry present but subsystem OFF — hooks are inert until opted in (§5.5/§6.2).
|
||||
const config = '{ "hooks": { "internal": { "enabled": false, "entries": { "hive-mind": { "enabled": true } } } } }';
|
||||
const env = await bootstrap(config);
|
||||
envs.push(env);
|
||||
// Write the managed dir so only the activation check is at fault.
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
// Re-disable the subsystem post-install to isolate the advisory.
|
||||
await writeFile(env.configPath, config, 'utf-8');
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
const enabledCheck = result.checks.find((c) => c.name === 'internal hooks subsystem enabled');
|
||||
expect(enabledCheck?.ok).toBe(false);
|
||||
expect(enabledCheck?.detail?.toLowerCase()).toContain('hooks are off');
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reports CLI unreachable when the spawn exits non-zero', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 127, stderr: 'command not found' }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('flags a missing handler.js even when the config entry is present', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
// Remove the installed handler from the managed dir.
|
||||
await rm(join(env.home, '.openclaw', 'hooks', 'hive-mind', 'handler.js'), { force: true });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'handler.js readable on disk')?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('uses cli_path from the install pointer for the probe (node <path> --help)', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
const cliPath = '/abs/from/pointer.js';
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource, 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,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
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');
|
||||
// Sanity: the pinned cli path was actually written into the pointer.
|
||||
const pointer = JSON.parse(await readFile(join(env.home, '.openclaw', 'hive-mind-install.json'), 'utf-8')) as Record<string, unknown>;
|
||||
expect(pointer['cli_path']).toBe(cliPath);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user