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 CommonJS handler bundle 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 { 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.bundle.cjs'); await writeFile(handlerSource, 'module.exports = 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> { return readFile(p, 'utf-8').then((s) => JSON.parse(s) as Record); } function internalEntries(config: Record): Record { const hooks = config[HOOKS_KEY] as Record> | undefined; const internal = hooks?.['internal'] as Record | undefined; return (internal?.['entries'] as Record | 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; const ents = internalEntries(after); // Our entry added, the user entry preserved verbatim. expect((ents[HIVE_ENTRY_KEY] as Record)['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>; 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; expect((internalEntries(after)[HIVE_ENTRY_KEY] as Record)['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 a host-discoverable loader plus a byte-identical .cjs bundle', 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); expect(existsSync(join(env.hiveHookDir, 'handler.cjs'))).toBe(true); expect(await readFile(join(env.hiveHookDir, 'handler.cjs'), 'utf-8')).toBe(handlerBytes); expect(await readFile(join(env.hiveHookDir, 'handler.js'), 'utf-8')).toContain("require('./handler.cjs')"); expect(JSON.parse(await readFile(join(env.hiveHookDir, 'package.json'), 'utf-8'))).toMatchObject({ type: 'commonjs', private: true, }); // 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('loads below a type:module ancestor and runs with NODE_PATH empty', async () => { env = await bootstrap(undefined); await writeFile(join(env.home, 'package.json'), '{"type":"module"}\n', 'utf-8'); 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; 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; const hive = internalEntries(after)[HIVE_ENTRY_KEY] as Record; expect((hive['env'] as Record)['WAGGLE_HIVE_MIND_CLI']).toBe(cliPath); const pointer = await readPointer(result.pointerPath); expect(pointer['cli_path']).toBe(cliPath); }); });