moving
This commit is contained in:
@@ -5,6 +5,31 @@ import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
|
||||
const SOURCE_RESOURCES = path.join(ROOT, 'app', 'src-tauri', 'resources');
|
||||
const SOURCE_BUNDLED_NODE = path.join(
|
||||
SOURCE_RESOURCES,
|
||||
process.platform === 'win32' ? 'node.exe' : 'node',
|
||||
);
|
||||
const SOURCE_STAGED_NODE_MODULES = path.join(SOURCE_RESOURCES, 'node_modules');
|
||||
const SOURCE_STAGED_SERVICE = path.join(SOURCE_RESOURCES, 'service.js');
|
||||
const SOURCE_STAGED_MEMORY_MCP = path.join(
|
||||
SOURCE_STAGED_NODE_MODULES,
|
||||
'waggle-memory-mcp',
|
||||
'dist',
|
||||
'index.js',
|
||||
);
|
||||
const REQUIRE_STAGED_RUNTIME = process.env.WAGGLE_VERIFY_STAGED_HOOK_RUNTIME === '1';
|
||||
const ANY_STAGED_RUNTIME = [
|
||||
SOURCE_BUNDLED_NODE,
|
||||
SOURCE_STAGED_NODE_MODULES,
|
||||
SOURCE_STAGED_SERVICE,
|
||||
].some((entry) => fs.existsSync(entry));
|
||||
const COMPLETE_STAGED_RUNTIME = [
|
||||
SOURCE_BUNDLED_NODE,
|
||||
SOURCE_STAGED_NODE_MODULES,
|
||||
SOURCE_STAGED_SERVICE,
|
||||
SOURCE_STAGED_MEMORY_MCP,
|
||||
].every((entry) => fs.existsSync(entry));
|
||||
|
||||
function makeTempRoot(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-hook-packages-'));
|
||||
@@ -23,19 +48,29 @@ async function runInCwd(
|
||||
cwd: string,
|
||||
home: string,
|
||||
stripPath = false,
|
||||
hookNodePath = process.execPath,
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
APPDATA: path.join(home, 'AppData', 'Roaming'),
|
||||
HERMES_HOME: path.join(home, '.hermes'),
|
||||
NO_COLOR: '1',
|
||||
...(stripPath ? {
|
||||
PATH: '',
|
||||
Path: '',
|
||||
WAGGLE_HOOK_NODE_PATH: hookNodePath,
|
||||
} : {}),
|
||||
};
|
||||
delete env.NODE_PATH;
|
||||
delete env.NODE_OPTIONS;
|
||||
delete env.WAGGLE_MEMORY_MCP_ENTRY;
|
||||
delete env.WAGGLE_CLAUDE_DESKTOP_CONFIG_DIR;
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
NO_COLOR: '1',
|
||||
...(stripPath ? {
|
||||
PATH: '', Path: '', WAGGLE_HOOK_NODE_PATH: process.execPath,
|
||||
} : {}),
|
||||
},
|
||||
env,
|
||||
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
@@ -51,48 +86,85 @@ async function runInCwd(
|
||||
interface HookPackageCase {
|
||||
id: string;
|
||||
packageName: string;
|
||||
configDir: string;
|
||||
configFile: string;
|
||||
layout: (home: string) => HookLayout;
|
||||
precreateConfig?: string;
|
||||
}
|
||||
|
||||
interface HookLayout {
|
||||
configDir: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
}
|
||||
|
||||
function standardLayout(configDirName: string, configFile: string) {
|
||||
return (home: string): HookLayout => {
|
||||
const configDir = path.join(home, configDirName);
|
||||
return {
|
||||
configDir,
|
||||
configPath: path.join(configDir, configFile),
|
||||
pointerPath: path.join(configDir, 'hive-mind-install.json'),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function claudeDesktopLayout(home: string): HookLayout {
|
||||
let configDir: string;
|
||||
if (process.platform === 'win32') {
|
||||
configDir = path.join(home, 'AppData', 'Roaming', 'Claude');
|
||||
} else if (process.platform === 'darwin') {
|
||||
configDir = path.join(home, 'Library', 'Application Support', 'Claude');
|
||||
} else {
|
||||
configDir = path.join(home, '.config', 'Claude');
|
||||
}
|
||||
return {
|
||||
configDir,
|
||||
configPath: path.join(configDir, 'claude_desktop_config.json'),
|
||||
pointerPath: path.join(home, '.waggle', 'claude-desktop', 'hive-mind-install.json'),
|
||||
};
|
||||
}
|
||||
|
||||
const HOOK_PACKAGE_CASES: HookPackageCase[] = [
|
||||
{
|
||||
id: 'claude-code',
|
||||
packageName: '@waggle/hive-mind-hooks-claude-code',
|
||||
configDir: '.claude',
|
||||
configFile: 'settings.json',
|
||||
layout: standardLayout('.claude', 'settings.json'),
|
||||
precreateConfig: '{}\n',
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
packageName: '@waggle/hive-mind-hooks-claude-desktop',
|
||||
layout: claudeDesktopLayout,
|
||||
precreateConfig: '{}\n',
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
packageName: '@waggle/hive-mind-hooks-codex',
|
||||
configDir: '.codex',
|
||||
configFile: 'hooks.json',
|
||||
layout: standardLayout('.codex', 'hooks.json'),
|
||||
precreateConfig: '{ "custom": "preserve-codex", "hooks": {} }\n',
|
||||
},
|
||||
{
|
||||
id: 'codex-desktop',
|
||||
packageName: '@waggle/hive-mind-hooks-codex-desktop',
|
||||
configDir: '.codex',
|
||||
configFile: 'hooks.json',
|
||||
layout: standardLayout('.codex', 'hooks.json'),
|
||||
precreateConfig: '{ "custom": "preserve-codex-desktop", "hooks": {} }\n',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
packageName: '@waggle/hive-mind-hooks-cursor',
|
||||
configDir: '.cursor',
|
||||
configFile: 'hooks.json',
|
||||
layout: standardLayout('.cursor', 'hooks.json'),
|
||||
precreateConfig: '{ "version": 1, "custom": "preserve-cursor", "hooks": {} }\n',
|
||||
},
|
||||
{
|
||||
id: 'hermes',
|
||||
packageName: '@waggle/hive-mind-hooks-hermes',
|
||||
configDir: '.hermes',
|
||||
configFile: 'config.yaml',
|
||||
layout: standardLayout('.hermes', 'config.yaml'),
|
||||
precreateConfig: '# preserve Hermes comment\nmodel: existing\nhooks: {}\n',
|
||||
},
|
||||
{
|
||||
id: 'openclaw',
|
||||
packageName: '@waggle/hive-mind-hooks-openclaw',
|
||||
configDir: '.openclaw',
|
||||
configFile: 'openclaw.json',
|
||||
layout: standardLayout('.openclaw', 'openclaw.json'),
|
||||
precreateConfig: '{\n // preserve OpenClaw comment\n model: "existing",\n}\n',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -130,6 +202,45 @@ function expectCommandOk(
|
||||
).toBe(0);
|
||||
}
|
||||
|
||||
function expectNoSourceRuntimePaths(contents: string, label: string): void {
|
||||
for (const sourcePath of [ROOT, SOURCE_RESOURCES, SOURCE_BUNDLED_NODE, process.execPath]) {
|
||||
expect(contents, `${label} leaked source runtime path ${sourcePath}`).not.toContain(sourcePath);
|
||||
if (sourcePath.includes('\\')) {
|
||||
expect(contents, `${label} leaked JSON-escaped source runtime path ${sourcePath}`).not.toContain(
|
||||
sourcePath.replace(/\\/g, '\\\\'),
|
||||
);
|
||||
expect(contents, `${label} leaked slash-normalized source runtime path ${sourcePath}`).not.toContain(
|
||||
sourcePath.replace(/\\/g, '/'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('hook runtime clean-build contract', () => {
|
||||
it('orders workspace declaration prerequisites before packaged runtimes', () => {
|
||||
const buildScript = fs.readFileSync(
|
||||
path.join(ROOT, 'scripts', 'build-hook-runtime.mjs'),
|
||||
'utf8',
|
||||
);
|
||||
const projectOrder = Array.from(
|
||||
buildScript.matchAll(/['"](packages\/[^'"]+\/tsconfig\.json)['"]/g),
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
const requiredOrder = [
|
||||
'packages/shared/tsconfig.json',
|
||||
'packages/hive-mind-core/tsconfig.json',
|
||||
'packages/core/tsconfig.json',
|
||||
'packages/wiki-compiler/tsconfig.json',
|
||||
'packages/memory-mcp/tsconfig.json',
|
||||
];
|
||||
|
||||
expect(projectOrder.filter((project) => requiredOrder.includes(project))).toEqual(
|
||||
requiredOrder,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook package installed lifecycle UX', () => {
|
||||
it('runs the packaged CLI and hook lifecycles through Node with npm and npx absent', async () => {
|
||||
const tempRoot = makeTempRoot();
|
||||
@@ -147,10 +258,8 @@ describe('hook package installed lifecycle UX', () => {
|
||||
|
||||
for (const hookPackage of HOOK_PACKAGE_CASES) {
|
||||
const home = path.join(tempRoot, `home-${hookPackage.id}`);
|
||||
const toolDir = path.join(home, hookPackage.configDir);
|
||||
const configPath = path.join(toolDir, hookPackage.configFile);
|
||||
const pointerPath = path.join(toolDir, 'hive-mind-install.json');
|
||||
fs.mkdirSync(toolDir, { recursive: true });
|
||||
const { configDir, configPath, pointerPath } = hookPackage.layout(home);
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
if (hookPackage.precreateConfig !== undefined) {
|
||||
fs.writeFileSync(configPath, hookPackage.precreateConfig, 'utf8');
|
||||
}
|
||||
@@ -160,15 +269,18 @@ describe('hook package installed lifecycle UX', () => {
|
||||
bin: Record<string, string>;
|
||||
};
|
||||
const hookEntry = path.join(packageDir, Object.values(manifest.bin)[0]);
|
||||
const runHook = (action: 'install' | 'verify' | 'uninstall') => runInCwd(
|
||||
process.execPath,
|
||||
action === 'install'
|
||||
? [hookEntry, action, '--cli-path', fakeCliPath]
|
||||
: [hookEntry, action],
|
||||
projectDir,
|
||||
home,
|
||||
true,
|
||||
);
|
||||
const runHook = (action: 'install' | 'verify' | 'uninstall') => {
|
||||
const args = [hookEntry, action];
|
||||
if (action === 'install' || (action === 'verify' && hookPackage.id === 'openclaw')) {
|
||||
args.push('--cli-path', fakeCliPath);
|
||||
}
|
||||
if (action === 'install') {
|
||||
if (hookPackage.id === 'claude-desktop') {
|
||||
args.push('--mcp-entry', fakeCliPath);
|
||||
}
|
||||
}
|
||||
return runInCwd(process.execPath, args, projectDir, home, true);
|
||||
};
|
||||
|
||||
const installResult = await runHook('install');
|
||||
expectCommandOk(installResult, `${hookPackage.id} install`);
|
||||
@@ -177,9 +289,15 @@ describe('hook package installed lifecycle UX', () => {
|
||||
expect(fs.existsSync(pointerPath)).toBe(true);
|
||||
if (hookPackage.id !== 'openclaw') {
|
||||
const installedConfig = fs.readFileSync(configPath, 'utf8');
|
||||
const nodePathHaystack = process.platform === 'win32'
|
||||
&& (hookPackage.id === 'codex' || hookPackage.id === 'codex-desktop')
|
||||
? [...installedConfig.matchAll(/-EncodedCommand ([A-Za-z0-9+/=]+)/g)]
|
||||
.map(match => Buffer.from(match[1], 'base64').toString('utf16le'))
|
||||
.join('\n')
|
||||
: installedConfig;
|
||||
expect(
|
||||
installedConfig.includes(process.execPath)
|
||||
|| installedConfig.includes(process.execPath.replace(/\\/g, '\\\\')),
|
||||
nodePathHaystack.includes(process.execPath)
|
||||
|| nodePathHaystack.includes(process.execPath.replace(/\\/g, '\\\\')),
|
||||
`${hookPackage.id} did not pin the bundled Node path`,
|
||||
).toBe(true);
|
||||
}
|
||||
@@ -203,4 +321,202 @@ describe('hook package installed lifecycle UX', () => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
it.runIf(ANY_STAGED_RUNTIME || REQUIRE_STAGED_RUNTIME)(
|
||||
'runs staged Tauri hook lifecycles from a physical runtime copy',
|
||||
async () => {
|
||||
expect(
|
||||
COMPLETE_STAGED_RUNTIME,
|
||||
'staged hook verification requires service.js, bundled Node, node_modules, and memory MCP',
|
||||
).toBe(true);
|
||||
|
||||
const tempRoot = makeTempRoot();
|
||||
try {
|
||||
const isolatedResources = path.join(tempRoot, 'isolated-resources');
|
||||
const isolatedNodeModules = path.join(isolatedResources, 'node_modules');
|
||||
const bundledNode = path.join(
|
||||
isolatedResources,
|
||||
process.platform === 'win32' ? 'node.exe' : 'node',
|
||||
);
|
||||
fs.mkdirSync(isolatedResources, { recursive: true });
|
||||
fs.copyFileSync(SOURCE_BUNDLED_NODE, bundledNode);
|
||||
fs.chmodSync(bundledNode, fs.statSync(SOURCE_BUNDLED_NODE).mode);
|
||||
await fs.promises.cp(SOURCE_STAGED_NODE_MODULES, isolatedNodeModules, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
|
||||
const relativeToRepo = path.relative(ROOT, isolatedResources);
|
||||
const copiedInsideRepo = relativeToRepo === ''
|
||||
|| (!relativeToRepo.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeToRepo));
|
||||
expect(copiedInsideRepo).toBe(false);
|
||||
|
||||
const projectDir = path.join(tempRoot, 'project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
const stagedCli = path.join(
|
||||
isolatedNodeModules,
|
||||
'@waggle',
|
||||
'hive-mind-cli',
|
||||
'dist',
|
||||
'index.js',
|
||||
);
|
||||
const stagedMemoryMcp = path.join(
|
||||
isolatedNodeModules,
|
||||
'waggle-memory-mcp',
|
||||
'dist',
|
||||
'index.js',
|
||||
);
|
||||
expect(fs.existsSync(stagedMemoryMcp)).toBe(true);
|
||||
const cliHelp = await runInCwd(
|
||||
bundledNode,
|
||||
[stagedCli, '--help'],
|
||||
projectDir,
|
||||
tempRoot,
|
||||
true,
|
||||
bundledNode,
|
||||
);
|
||||
expectCommandOk(cliHelp, 'isolated staged hive-mind-cli');
|
||||
|
||||
for (const hookPackage of HOOK_PACKAGE_CASES) {
|
||||
const home = path.join(tempRoot, `staged-home-${hookPackage.id}`);
|
||||
const { configDir, configPath, pointerPath } = hookPackage.layout(home);
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
if (hookPackage.precreateConfig !== undefined) {
|
||||
fs.writeFileSync(configPath, hookPackage.precreateConfig, 'utf8');
|
||||
}
|
||||
|
||||
const packageDir = path.join(
|
||||
isolatedNodeModules,
|
||||
...hookPackage.packageName.split('/'),
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'),
|
||||
) as { bin: Record<string, string> };
|
||||
const hookEntry = path.join(packageDir, Object.values(manifest.bin)[0]);
|
||||
const runHook = (action: 'install' | 'verify' | 'uninstall') => {
|
||||
const shouldPinCli = action === 'install'
|
||||
|| (action === 'verify' && hookPackage.id === 'openclaw');
|
||||
const args = shouldPinCli
|
||||
? [hookEntry, action, '--cli-path', stagedCli]
|
||||
: [hookEntry, action];
|
||||
return runInCwd(
|
||||
bundledNode,
|
||||
args,
|
||||
projectDir,
|
||||
home,
|
||||
true,
|
||||
bundledNode,
|
||||
);
|
||||
};
|
||||
|
||||
const installResult = await runHook('install');
|
||||
expectCommandOk(installResult, `isolated staged ${hookPackage.id} install`);
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
expect(fs.existsSync(pointerPath)).toBe(true);
|
||||
|
||||
const installedConfig = fs.readFileSync(configPath, 'utf8');
|
||||
const installedPointer = fs.readFileSync(pointerPath, 'utf8');
|
||||
expectNoSourceRuntimePaths(installedConfig, `${hookPackage.id} config`);
|
||||
expectNoSourceRuntimePaths(installedPointer, `${hookPackage.id} pointer`);
|
||||
|
||||
if (hookPackage.id === 'openclaw') {
|
||||
const installedHandler = path.join(
|
||||
home,
|
||||
'.openclaw',
|
||||
'hooks',
|
||||
'hive-mind',
|
||||
'handler.js',
|
||||
);
|
||||
const installedBundle = path.join(
|
||||
home,
|
||||
'.openclaw',
|
||||
'hooks',
|
||||
'hive-mind',
|
||||
'handler.cjs',
|
||||
);
|
||||
expect(fs.existsSync(installedHandler)).toBe(true);
|
||||
expect(fs.readFileSync(installedHandler, 'utf8')).toBe(
|
||||
[
|
||||
"'use strict';",
|
||||
"const handler = require('./handler.cjs');",
|
||||
`module.exports = (event) => handler(event, ${JSON.stringify({
|
||||
cliPath: stagedCli,
|
||||
nodePath: bundledNode,
|
||||
})});`,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(fs.readFileSync(installedBundle)).toEqual(
|
||||
fs.readFileSync(path.join(packageDir, 'dist', 'handler.bundle.cjs')),
|
||||
);
|
||||
expect(installedConfig).toContain(stagedCli.replace(/\\/g, '\\\\'));
|
||||
expect(installedConfig).toContain(bundledNode.replace(/\\/g, '\\\\'));
|
||||
expect(JSON.parse(installedPointer)).toMatchObject({
|
||||
cli_path: stagedCli,
|
||||
extra: {
|
||||
runtime_binding: {
|
||||
version: 1,
|
||||
cli_path: stagedCli,
|
||||
node_path: bundledNode,
|
||||
},
|
||||
},
|
||||
});
|
||||
expectNoSourceRuntimePaths(
|
||||
fs.readFileSync(installedHandler, 'utf8'),
|
||||
'openclaw installed handler loader',
|
||||
);
|
||||
expectNoSourceRuntimePaths(
|
||||
fs.readFileSync(installedBundle, 'utf8'),
|
||||
'openclaw installed handler bundle',
|
||||
);
|
||||
} else if (hookPackage.id === 'claude-desktop') {
|
||||
const config = JSON.parse(installedConfig) as {
|
||||
mcpServers: Record<string, { command: string; args: string[] }>;
|
||||
};
|
||||
expect(config.mcpServers['waggle-memory']).toEqual({
|
||||
command: bundledNode,
|
||||
args: [stagedMemoryMcp],
|
||||
});
|
||||
} else {
|
||||
const nodePathHaystacks = process.platform === 'win32'
|
||||
&& (hookPackage.id === 'codex' || hookPackage.id === 'codex-desktop')
|
||||
? [...installedConfig.matchAll(/-EncodedCommand ([A-Za-z0-9+/=]+)/g)]
|
||||
.map(match => Buffer.from(match[1], 'base64').toString('utf16le'))
|
||||
: [installedConfig];
|
||||
if (process.platform === 'win32'
|
||||
&& (hookPackage.id === 'codex' || hookPackage.id === 'codex-desktop')) {
|
||||
expect(nodePathHaystacks).toHaveLength(4);
|
||||
}
|
||||
for (const nodePathHaystack of nodePathHaystacks) {
|
||||
expect(
|
||||
nodePathHaystack.includes(bundledNode)
|
||||
|| nodePathHaystack.includes(bundledNode.replace(/\\/g, '\\\\')),
|
||||
`${hookPackage.id} did not pin every command to the copied bundled Node path`,
|
||||
).toBe(true);
|
||||
expectNoSourceRuntimePaths(
|
||||
nodePathHaystack,
|
||||
`${hookPackage.id} decoded runtime command`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const verifyResult = await runHook('verify');
|
||||
expectCommandOk(verifyResult, `isolated staged ${hookPackage.id} verify`);
|
||||
expect(verifyResult.stdout).toContain('All checks passed.');
|
||||
|
||||
const uninstallResult = await runHook('uninstall');
|
||||
expectCommandOk(uninstallResult, `isolated staged ${hookPackage.id} uninstall`);
|
||||
expect(fs.existsSync(pointerPath)).toBe(false);
|
||||
if (hookPackage.precreateConfig !== undefined) {
|
||||
expect(fs.readFileSync(configPath, 'utf8')).toBe(hookPackage.precreateConfig);
|
||||
} else {
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
600_000,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user