This commit is contained in:
155
packages/hive-mind-hooks-claude-desktop/tests/install.test.ts
Normal file
155
packages/hive-mind-hooks-claude-desktop/tests/install.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { install, MCP_SERVER_NAME } from '../src/install.js';
|
||||
import { resolvePaths } from '../src/paths.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
mcpEntry: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial?: Record<string, unknown>): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-install-'));
|
||||
const mcpEntry = join(home, 'memory-mcp.js');
|
||||
await writeFile(mcpEntry, 'export {};\n', 'utf-8');
|
||||
if (initial !== undefined) {
|
||||
const paths = resolvePaths({ home, platform: 'linux' });
|
||||
await mkdir(paths.claudeConfigDir, { recursive: true });
|
||||
await writeFile(paths.configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
return { home, mcpEntry };
|
||||
}
|
||||
|
||||
function installOpts(env: TestEnv, mcpEntry = env.mcpEntry) {
|
||||
return {
|
||||
home: env.home,
|
||||
platform: 'linux' as const,
|
||||
mcpEntry,
|
||||
};
|
||||
}
|
||||
|
||||
describe('install (claude-desktop)', () => {
|
||||
const homes: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const home of homes.splice(0)) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a config and ownership pointer on a fresh install', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
|
||||
const result = await install({
|
||||
...installOpts(env),
|
||||
cliPath: ' /opt/hive-mind-cli/dist/index.js ',
|
||||
});
|
||||
const config = JSON.parse(await readFile(result.paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, { command: string; args: string[] }>;
|
||||
};
|
||||
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
|
||||
expect(result.createdByUs).toBe(true);
|
||||
expect(result.backupPath).toBeNull();
|
||||
expect(config.mcpServers[MCP_SERVER_NAME]).toEqual({
|
||||
command: process.env.WAGGLE_HOOK_NODE_PATH?.trim() || process.execPath,
|
||||
args: [env.mcpEntry],
|
||||
});
|
||||
expect(Object.keys(config.mcpServers[MCP_SERVER_NAME])).toEqual(['command', 'args']);
|
||||
expect(pointer['created_by_us']).toBe(true);
|
||||
expect(pointer['settings_backup']).toBeNull();
|
||||
expect(pointer['hooks_dir']).toBeNull();
|
||||
expect(pointer['installed_hooks']).toEqual(['mcp:waggle-memory']);
|
||||
expect(pointer['cli_path']).toBe('/opt/hive-mind-cli/dist/index.js');
|
||||
});
|
||||
|
||||
it('backs up a pre-existing config byte-identically and preserves other servers', async () => {
|
||||
const existingServer = {
|
||||
command: 'python',
|
||||
args: ['server.py'],
|
||||
env: { KEEP_ME: 'yes' },
|
||||
};
|
||||
const env = await bootstrap({
|
||||
theme: 'dark',
|
||||
mcpServers: { userServer: existingServer },
|
||||
});
|
||||
homes.push(env.home);
|
||||
const paths = resolvePaths({ home: env.home, platform: 'linux' });
|
||||
const originalBytes = await readFile(paths.configPath);
|
||||
|
||||
const result = await install(installOpts(env));
|
||||
const config = JSON.parse(await readFile(paths.configPath, 'utf-8')) as {
|
||||
theme: string;
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(result.createdByUs).toBe(false);
|
||||
expect(result.backupPath).not.toBeNull();
|
||||
expect(await readFile(result.backupPath as string)).toEqual(originalBytes);
|
||||
expect(config.theme).toBe('dark');
|
||||
expect(config.mcpServers['userServer']).toEqual(existingServer);
|
||||
expect(config.mcpServers[MCP_SERVER_NAME]).toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses to clobber a foreign 'waggle-memory' entry without a pointer", async () => {
|
||||
const foreignEntry = { command: 'node', args: ['/user/server.js'] };
|
||||
const env = await bootstrap({ mcpServers: { [MCP_SERVER_NAME]: foreignEntry } });
|
||||
homes.push(env.home);
|
||||
|
||||
await expect(install(installOpts(env))).rejects.toThrow(
|
||||
/was not installed by this tool; remove or rename it first/,
|
||||
);
|
||||
const paths = resolvePaths({ home: env.home, platform: 'linux' });
|
||||
const config = JSON.parse(await readFile(paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(config.mcpServers[MCP_SERVER_NAME]).toEqual(foreignEntry);
|
||||
expect(existsSync(paths.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces its entry on reinstall without replacing the original backup or ownership', async () => {
|
||||
const env = await bootstrap({ mcpServers: { userServer: { command: 'user', args: [] } } });
|
||||
homes.push(env.home);
|
||||
const first = await install(installOpts(env));
|
||||
const firstPointer = JSON.parse(await readFile(first.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
const replacementEntry = join(env.home, 'memory-mcp-v2.js');
|
||||
await writeFile(replacementEntry, 'export const version = 2;\n', 'utf-8');
|
||||
|
||||
const second = await install(installOpts(env, replacementEntry));
|
||||
const secondPointer = JSON.parse(await readFile(second.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
const config = JSON.parse(await readFile(second.paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, { args: string[] }>;
|
||||
};
|
||||
const backups = (await readdir(second.paths.claudeConfigDir))
|
||||
.filter((name) => name.includes('hive-mind-backup'));
|
||||
|
||||
expect(config.mcpServers[MCP_SERVER_NAME].args).toEqual([replacementEntry]);
|
||||
expect(Object.keys(config.mcpServers).filter((name) => name === MCP_SERVER_NAME)).toHaveLength(1);
|
||||
expect(second.backupPath).toBe(first.backupPath);
|
||||
expect(secondPointer['settings_backup']).toBe(firstPointer['settings_backup']);
|
||||
expect(secondPointer['created_by_us']).toBe(firstPointer['created_by_us']);
|
||||
expect(backups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws when the MCP entry cannot be resolved', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
|
||||
await expect(install({
|
||||
home: env.home,
|
||||
platform: 'linux',
|
||||
mcpEntry: ' ',
|
||||
})).rejects.toThrow(/cannot resolve waggle-memory-mcp\/dist\/index\.js/);
|
||||
});
|
||||
});
|
||||
76
packages/hive-mind-hooks-claude-desktop/tests/paths.test.ts
Normal file
76
packages/hive-mind-hooks-claude-desktop/tests/paths.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { resolvePaths } from '../src/paths.js';
|
||||
|
||||
describe('resolvePaths (claude-desktop)', () => {
|
||||
const homes: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const home of homes.splice(0)) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function tempHome(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-paths-'));
|
||||
homes.push(home);
|
||||
return home;
|
||||
}
|
||||
|
||||
it('uses the Windows Claude Desktop config path', async () => {
|
||||
const home = await tempHome();
|
||||
const paths = resolvePaths({ home, platform: 'win32' });
|
||||
expect(paths.configPath).toBe(join(
|
||||
home,
|
||||
'AppData',
|
||||
'Roaming',
|
||||
'Claude',
|
||||
'claude_desktop_config.json',
|
||||
));
|
||||
});
|
||||
|
||||
it('uses the macOS Claude Desktop config path', async () => {
|
||||
const home = await tempHome();
|
||||
const paths = resolvePaths({ home, platform: 'darwin' });
|
||||
expect(paths.configPath).toBe(join(
|
||||
home,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Claude',
|
||||
'claude_desktop_config.json',
|
||||
));
|
||||
});
|
||||
|
||||
it('uses the Linux Claude Desktop config path', async () => {
|
||||
const home = await tempHome();
|
||||
const paths = resolvePaths({ home, platform: 'linux' });
|
||||
expect(paths.configPath).toBe(join(
|
||||
home,
|
||||
'.config',
|
||||
'Claude',
|
||||
'claude_desktop_config.json',
|
||||
));
|
||||
});
|
||||
|
||||
it('lets configDir override the platform default', async () => {
|
||||
const home = await tempHome();
|
||||
const configDir = join(home, 'custom-claude-config');
|
||||
const paths = resolvePaths({ home, platform: 'linux', configDir });
|
||||
expect(paths.claudeConfigDir).toBe(configDir);
|
||||
expect(paths.configPath).toBe(join(configDir, 'claude_desktop_config.json'));
|
||||
});
|
||||
|
||||
it('uses one platform-neutral home-relative pointer path', async () => {
|
||||
const home = await tempHome();
|
||||
for (const platform of ['win32', 'darwin', 'linux'] as const) {
|
||||
expect(resolvePaths({ home, platform }).pointerPath).toBe(join(
|
||||
home,
|
||||
'.waggle',
|
||||
'claude-desktop',
|
||||
'hive-mind-install.json',
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
112
packages/hive-mind-hooks-claude-desktop/tests/uninstall.test.ts
Normal file
112
packages/hive-mind-hooks-claude-desktop/tests/uninstall.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { install, MCP_SERVER_NAME } from '../src/install.js';
|
||||
import { resolvePaths } from '../src/paths.js';
|
||||
import { uninstall } from '../src/uninstall.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
mcpEntry: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial?: Record<string, unknown>): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-uninstall-'));
|
||||
const mcpEntry = join(home, 'memory-mcp.js');
|
||||
await writeFile(mcpEntry, 'export {};\n', 'utf-8');
|
||||
if (initial !== undefined) {
|
||||
const paths = resolvePaths({ home, platform: 'linux' });
|
||||
await mkdir(paths.claudeConfigDir, { recursive: true });
|
||||
await writeFile(paths.configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
return { home, mcpEntry };
|
||||
}
|
||||
|
||||
function installOpts(env: TestEnv) {
|
||||
return {
|
||||
home: env.home,
|
||||
platform: 'linux' as const,
|
||||
mcpEntry: env.mcpEntry,
|
||||
};
|
||||
}
|
||||
|
||||
describe('uninstall (claude-desktop)', () => {
|
||||
const homes: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const home of homes.splice(0)) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('restores an untouched config byte-identically and removes backup and pointer', async () => {
|
||||
const env = await bootstrap({
|
||||
mcpServers: { userServer: { command: 'python', args: ['server.py'] } },
|
||||
setting: true,
|
||||
});
|
||||
homes.push(env.home);
|
||||
const paths = resolvePaths({ home: env.home, platform: 'linux' });
|
||||
const before = await readFile(paths.configPath);
|
||||
const installed = await install(installOpts(env));
|
||||
|
||||
const result = await uninstall({ home: env.home, platform: 'linux' });
|
||||
const after = await readFile(paths.configPath);
|
||||
|
||||
expect(after).toEqual(before);
|
||||
expect(result.surgical).toBe(false);
|
||||
expect(result.backupRemoved).toBe(true);
|
||||
expect(result.createdRemoved).toBe(false);
|
||||
expect(existsSync(installed.backupPath as string)).toBe(false);
|
||||
expect(existsSync(installed.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes a config created by the installer', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
const installed = await install(installOpts(env));
|
||||
|
||||
const result = await uninstall({ home: env.home, platform: 'linux' });
|
||||
|
||||
expect(result.createdRemoved).toBe(true);
|
||||
expect(result.restoredFrom).toBeNull();
|
||||
expect(result.surgical).toBe(false);
|
||||
expect(existsSync(installed.paths.configPath)).toBe(false);
|
||||
expect(existsSync(installed.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('surgically removes only waggle-memory when the config changed after install', async () => {
|
||||
const originalServer = { command: 'python', args: ['original.py'] };
|
||||
const env = await bootstrap({ mcpServers: { originalServer } });
|
||||
homes.push(env.home);
|
||||
const installed = await install(installOpts(env));
|
||||
const edited = JSON.parse(await readFile(installed.paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
const userAddedServer = { command: 'node', args: ['/user/added.js'] };
|
||||
edited.mcpServers['userAddedServer'] = userAddedServer;
|
||||
await writeFile(installed.paths.configPath, JSON.stringify(edited, null, 2) + '\n', 'utf-8');
|
||||
|
||||
const result = await uninstall({ home: env.home, platform: 'linux' });
|
||||
const after = JSON.parse(await readFile(installed.paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(result.surgical).toBe(true);
|
||||
expect(result.backupRemoved).toBe(false);
|
||||
expect(after.mcpServers[MCP_SERVER_NAME]).toBeUndefined();
|
||||
expect(after.mcpServers['originalServer']).toEqual(originalServer);
|
||||
expect(after.mcpServers['userAddedServer']).toEqual(userAddedServer);
|
||||
expect(existsSync(installed.backupPath as string)).toBe(true);
|
||||
expect(existsSync(installed.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when the install pointer is missing', async () => {
|
||||
const env = await bootstrap({});
|
||||
homes.push(env.home);
|
||||
|
||||
await expect(uninstall({ home: env.home, platform: 'linux' }))
|
||||
.rejects.toThrow(/no install pointer/);
|
||||
});
|
||||
});
|
||||
135
packages/hive-mind-hooks-claude-desktop/tests/verify.test.ts
Normal file
135
packages/hive-mind-hooks-claude-desktop/tests/verify.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { install, MCP_SERVER_NAME } from '../src/install.js';
|
||||
import { resolvePaths } from '../src/paths.js';
|
||||
import { verify } from '../src/verify.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
mcpEntry: string;
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-verify-'));
|
||||
const mcpEntry = join(home, 'memory-mcp.js');
|
||||
await writeFile(mcpEntry, 'export const ready = true;\n', 'utf-8');
|
||||
return { home, mcpEntry };
|
||||
}
|
||||
|
||||
async function writeConfig(home: string, config: Record<string, unknown>): Promise<void> {
|
||||
const paths = resolvePaths({ home, platform: 'linux' });
|
||||
await mkdir(paths.claudeConfigDir, { recursive: true });
|
||||
await writeFile(paths.configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function hangingSpawn(onSpawn: () => void): typeof import('node:child_process').spawn {
|
||||
return ((_command: string, _args: readonly string[], _options?: unknown) => {
|
||||
onSpawn();
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: vi.fn(() => true),
|
||||
}) as unknown as ChildProcess;
|
||||
return child;
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
}
|
||||
|
||||
describe('verify (claude-desktop)', () => {
|
||||
const homes: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
for (const home of homes.splice(0)) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('passes with an installed config, readable entry, valid syntax, and pointer', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
await install({ home: env.home, platform: 'linux', mcpEntry: env.mcpEntry });
|
||||
|
||||
const result = await verify({ home: env.home, platform: 'linux' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.checks.map((check) => check.name)).toEqual([
|
||||
'claude_desktop_config.json exists',
|
||||
'config parses as JSON',
|
||||
"mcpServers contains 'waggle-memory' entry",
|
||||
'server entry points at waggle-memory-mcp',
|
||||
'memory-mcp entry readable on disk',
|
||||
'memory-mcp entry parses (node --check)',
|
||||
'install pointer present',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails the config existence check when the config is missing', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
|
||||
const result = await verify({ home: env.home, platform: 'linux' });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks).toEqual([{
|
||||
name: 'claude_desktop_config.json exists',
|
||||
ok: false,
|
||||
detail: resolvePaths({ home: env.home, platform: 'linux' }).configPath,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("fails the mcpServers contains 'waggle-memory' entry check when absent", async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
await writeConfig(env.home, { mcpServers: { other: { command: 'node', args: [] } } });
|
||||
|
||||
const result = await verify({ home: env.home, platform: 'linux' });
|
||||
const check = result.checks.find((item) => item.name.includes("contains 'waggle-memory'"));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(check?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('fails the entry readability check when args[0] is missing on disk', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
const missingEntry = join(env.home, 'missing-memory-mcp.js');
|
||||
await writeConfig(env.home, {
|
||||
mcpServers: {
|
||||
[MCP_SERVER_NAME]: { command: process.execPath, args: [missingEntry] },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await verify({ home: env.home, platform: 'linux' });
|
||||
const check = result.checks.find((item) => item.name === 'memory-mcp entry readable on disk');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(check?.ok).toBe(false);
|
||||
expect(check?.detail).toBe(missingEntry);
|
||||
});
|
||||
|
||||
it('times out and kills a hung node --check probe through spawnImpl', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
await install({ home: env.home, platform: 'linux', mcpEntry: env.mcpEntry });
|
||||
vi.useFakeTimers();
|
||||
let spawned = false;
|
||||
const spawnImpl = hangingSpawn(() => { spawned = true; });
|
||||
|
||||
const resultPromise = verify({ home: env.home, platform: 'linux', spawnImpl });
|
||||
await vi.waitFor(() => expect(spawned).toBe(true));
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
const result = await resultPromise;
|
||||
const check = result.checks.find(
|
||||
(item) => item.name === 'memory-mcp entry parses (node --check)',
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(check?.ok).toBe(false);
|
||||
expect(check?.detail).toContain('timed out');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user