253 lines
12 KiB
TypeScript
253 lines
12 KiB
TypeScript
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<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.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<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 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<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);
|
|
});
|
|
});
|