f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
548 lines
23 KiB
TypeScript
548 lines
23 KiB
TypeScript
import { describe, it, expect } from 'bun:test';
|
|
import { readFileSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { spawnSync } from 'child_process';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { buildCodexWindowsCommand, buildShellCommand } from '../../src/build/hook-shell-template.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(__dirname, '../..');
|
|
|
|
function readJson(relativePath: string): any {
|
|
return JSON.parse(readFileSync(path.join(projectRoot, relativePath), 'utf-8'));
|
|
}
|
|
|
|
function commandHooksFrom(relativePath: string): string[] {
|
|
const parsed = readJson(relativePath);
|
|
return Object.values(parsed.hooks ?? {}).flatMap((matchers: any) =>
|
|
matchers.flatMap((matcher: any) =>
|
|
(matcher.hooks ?? [])
|
|
.filter((hook: any) => hook.type === 'command')
|
|
.map((hook: any) => String(hook.command ?? ''))
|
|
)
|
|
);
|
|
}
|
|
|
|
function commandHookEntriesFrom(relativePath: string): any[] {
|
|
const parsed = readJson(relativePath);
|
|
return Object.values(parsed.hooks ?? {}).flatMap((matchers: any) =>
|
|
matchers.flatMap((matcher: any) =>
|
|
(matcher.hooks ?? []).filter((hook: any) => hook.type === 'command')
|
|
)
|
|
);
|
|
}
|
|
|
|
function mcpStartupCommandFrom(relativePath: string): string {
|
|
const parsed = readJson(relativePath);
|
|
return parsed.mcpServers['mcp-search'].args[1];
|
|
}
|
|
|
|
describe('Plugin Distribution - Skills', () => {
|
|
const skillPath = path.join(projectRoot, 'plugin/skills/mem-search/SKILL.md');
|
|
|
|
it('should include plugin/skills/mem-search/SKILL.md', () => {
|
|
expect(existsSync(skillPath)).toBe(true);
|
|
});
|
|
|
|
it('should have valid YAML frontmatter with name and description', () => {
|
|
const content = readFileSync(skillPath, 'utf-8');
|
|
|
|
expect(content.startsWith('---\n')).toBe(true);
|
|
|
|
const frontmatterEnd = content.indexOf('\n---\n', 4);
|
|
expect(frontmatterEnd).toBeGreaterThan(0);
|
|
|
|
const frontmatter = content.slice(4, frontmatterEnd);
|
|
expect(frontmatter).toContain('name:');
|
|
expect(frontmatter).toContain('description:');
|
|
});
|
|
|
|
it('should reference the 3-layer search workflow', () => {
|
|
const content = readFileSync(skillPath, 'utf-8');
|
|
expect(content).toContain('search');
|
|
expect(content).toContain('timeline');
|
|
expect(content).toContain('get_observations');
|
|
});
|
|
});
|
|
|
|
describe('Plugin Distribution - Required Files', () => {
|
|
const requiredFiles = [
|
|
'plugin/hooks/hooks.json',
|
|
'plugin/hooks/codex-hooks.json',
|
|
'plugin/.claude-plugin/plugin.json',
|
|
'plugin/.codex-plugin/plugin.json',
|
|
'plugin/.mcp.json',
|
|
'plugin/sqlite/SessionStore.js',
|
|
'plugin/sqlite/observations/files.js',
|
|
'plugin/skills/mem-search/SKILL.md',
|
|
'.agents/plugins/marketplace.json',
|
|
];
|
|
|
|
for (const filePath of requiredFiles) {
|
|
it(`should include ${filePath}`, () => {
|
|
const fullPath = path.join(projectRoot, filePath);
|
|
expect(existsSync(fullPath)).toBe(true);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('Plugin Distribution - Codex Marketplace', () => {
|
|
it('points Codex at the bundled plugin root', () => {
|
|
const marketplacePath = path.join(projectRoot, '.agents/plugins/marketplace.json');
|
|
const marketplace = JSON.parse(readFileSync(marketplacePath, 'utf-8'));
|
|
|
|
expect(marketplace.plugins[0].source.path).toBe('./plugin');
|
|
});
|
|
|
|
it('ships Codex hooks with only Codex-supported root keys', () => {
|
|
const codexHooks = readJson('plugin/hooks/codex-hooks.json');
|
|
expect(Object.keys(codexHooks).sort()).toEqual(['hooks']);
|
|
});
|
|
|
|
it('sets the Codex hook marker on every Codex command', () => {
|
|
for (const command of commandHooksFrom('plugin/hooks/codex-hooks.json')) {
|
|
expect(command).toContain('CLAUDE_MEM_CODEX_HOOK=1');
|
|
}
|
|
});
|
|
|
|
it('sets Windows Codex hook overrides without POSIX-only shell syntax', () => {
|
|
const entries = commandHookEntriesFrom('plugin/hooks/codex-hooks.json');
|
|
const posixOnlyTokens = ['$(', '${', '[ -', 'printenv', 'export PATH', 'command -v', '2>/dev/null', 'while IFS'];
|
|
|
|
expect(entries.length).toBeGreaterThan(0);
|
|
for (const entry of entries) {
|
|
expect(typeof entry.commandWindows).toBe('string');
|
|
expect(entry.commandWindows).toContain('node -e');
|
|
expect(entry.commandWindows).toContain('CLAUDE_MEM_CODEX_HOOK');
|
|
expect(entry.commandWindows).toContain('bun-runner.js');
|
|
expect(entry.commandWindows).toContain('worker-service.cjs');
|
|
expect(entry.commandWindows).toContain('plugins');
|
|
expect(entry.commandWindows).toContain('cache');
|
|
expect(entry.commandWindows).toContain('marketplaces');
|
|
for (const token of posixOnlyTokens) {
|
|
expect(entry.commandWindows).not.toContain(token);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('ships a single Codex SessionStart command', () => {
|
|
const codexHooks = readJson('plugin/hooks/codex-hooks.json');
|
|
expect(codexHooks.hooks.SessionStart[0].hooks).toHaveLength(1);
|
|
expect(codexHooks.hooks.SessionStart[0].hooks[0].commandWindows).toContain('version-check.js');
|
|
});
|
|
|
|
it('MCP launcher can recover without plugin root environment variables', () => {
|
|
const mcpPath = path.join(projectRoot, 'plugin/.mcp.json');
|
|
const mcp = JSON.parse(readFileSync(mcpPath, 'utf-8'));
|
|
const command = mcp.mcpServers['mcp-search'].args.join(' ');
|
|
|
|
expect(command).toContain('.codex/plugins/cache/claude-mem-local/claude-mem');
|
|
expect(command).toContain('plugins/cache/thedotmack/claude-mem');
|
|
expect(command).toContain('claude-mem: mcp server not found');
|
|
});
|
|
});
|
|
|
|
describe('Plugin Distribution - hooks.json Integrity', () => {
|
|
it('should have valid JSON in hooks.json', () => {
|
|
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
|
const content = readFileSync(hooksPath, 'utf-8');
|
|
const parsed = JSON.parse(content);
|
|
expect(parsed.hooks).toBeDefined();
|
|
});
|
|
|
|
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands', () => {
|
|
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
|
|
expect(command).toContain('CLAUDE_PLUGIN_ROOT');
|
|
}
|
|
});
|
|
|
|
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#1215)', () => {
|
|
const expectedFallbackPath = '$_C/plugins/marketplaces/thedotmack/plugin';
|
|
|
|
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
|
|
expect(command).toContain(expectedFallbackPath);
|
|
}
|
|
});
|
|
|
|
it('should try cache path before marketplaces fallback in all hook commands (#1533)', () => {
|
|
const cachePath = '$_C/plugins/cache/thedotmack/claude-mem';
|
|
const marketplacesPath = '$_C/plugins/marketplaces/thedotmack/plugin';
|
|
|
|
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
|
|
expect(command).toContain(cachePath);
|
|
expect(command.indexOf(cachePath)).toBeLessThan(command.indexOf(marketplacesPath));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Plugin Distribution - Startup Root Resolution', () => {
|
|
it('MCP startup command resolves the plugin root cross-platform (#2792)', () => {
|
|
// The launcher is now a cross-platform `node -e` payload (no `sh`), so it
|
|
// spawns on Windows without Git Bash. It must still resolve the plugin root
|
|
// with config-dir + env fallbacks and try cache roots before marketplaces.
|
|
const command = mcpStartupCommandFrom('plugin/.mcp.json');
|
|
|
|
expect(command).toContain('CLAUDE_CONFIG_DIR');
|
|
expect(command).toContain('.claude');
|
|
expect(command).toContain('CLAUDE_PLUGIN_ROOT');
|
|
expect(command).toContain('PLUGIN_ROOT');
|
|
expect(command).toContain('plugins/marketplaces/thedotmack/plugin');
|
|
expect(command).toContain('plugins/cache/thedotmack/claude-mem');
|
|
expect(command).toContain('mcp-server.cjs');
|
|
// No bare absolute "/scripts/..." path leaks through.
|
|
expect(command).not.toContain('"/scripts/mcp-server.cjs"');
|
|
expect(command.indexOf('plugins/cache/thedotmack/claude-mem')).toBeLessThan(
|
|
command.indexOf('plugins/marketplaces/thedotmack/plugin')
|
|
);
|
|
});
|
|
|
|
it('Codex hook commands should have config-dir based non-empty fallbacks', () => {
|
|
for (const command of commandHooksFrom('plugin/hooks/codex-hooks.json')) {
|
|
expect(command).toContain('${CLAUDE_CONFIG_DIR:-$HOME/.claude}');
|
|
expect(command).toContain('export PATH=');
|
|
expect(command).toContain('while IFS= read -r _R');
|
|
expect(command).toContain('$_C/plugins/marketplaces/thedotmack/plugin');
|
|
expect(command).toContain('$_C/plugins/cache/thedotmack/claude-mem');
|
|
expect(command).toContain('[ -f "$_Q/scripts/');
|
|
expect(command).toContain('command -v cygpath');
|
|
expect(command.indexOf('$_C/plugins/cache/thedotmack/claude-mem')).toBeLessThan(
|
|
command.indexOf('$_C/plugins/marketplaces/thedotmack/plugin')
|
|
);
|
|
}
|
|
});
|
|
|
|
it('Claude hook commands should have config-dir based non-empty fallbacks', () => {
|
|
for (const command of commandHooksFrom('plugin/hooks/hooks.json')) {
|
|
expect(command).toContain('${CLAUDE_CONFIG_DIR:-$HOME/.claude}');
|
|
expect(command).toContain('while IFS= read -r _R');
|
|
expect(command).toContain('$_C/plugins/marketplaces/thedotmack/plugin');
|
|
expect(command).toContain('$_C/plugins/cache/thedotmack/claude-mem');
|
|
expect(command).toContain('[ -f "$_Q/scripts/');
|
|
expect(command).not.toContain('$HOME/.claude/plugins/');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Plugin Distribution - package.json Files Field', () => {
|
|
it('should include bundled plugin entries in root package.json files field', () => {
|
|
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
expect(packageJson.files).toBeDefined();
|
|
expect(packageJson.files).toContain('plugin/.codex-plugin');
|
|
expect(packageJson.files).toContain('plugin/.mcp.json');
|
|
expect(packageJson.files).toContain('plugin/hooks');
|
|
expect(packageJson.files).toContain('plugin/skills');
|
|
expect(packageJson.files).toContain('plugin/scripts/*.cjs');
|
|
expect(packageJson.files).toContain('plugin/sqlite');
|
|
});
|
|
|
|
it('npm tarball includes sqlite runtime modules required by the worker', () => {
|
|
const result = spawnSync('npm', ['pack', '--dry-run', '--json'], {
|
|
cwd: projectRoot,
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
const packed = JSON.parse(result.stdout);
|
|
const filePaths = new Set(packed[0].files.map((file: { path: string }) => file.path));
|
|
|
|
expect(filePaths.has('plugin/sqlite/SessionStore.js')).toBe(true);
|
|
expect(filePaths.has('plugin/sqlite/observations/files.js')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Plugin Distribution - Build Script Verification', () => {
|
|
it('should verify distribution files in build-hooks.js', () => {
|
|
const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js');
|
|
const content = readFileSync(buildScriptPath, 'utf-8');
|
|
|
|
expect(content).toContain('plugin/skills/mem-search/SKILL.md');
|
|
expect(content).toContain('plugin/hooks/hooks.json');
|
|
expect(content).toContain('plugin/sqlite/SessionStore.js');
|
|
expect(content).toContain('plugin/sqlite/observations/files.js');
|
|
expect(content).toContain('plugin/.claude-plugin/plugin.json');
|
|
});
|
|
});
|
|
|
|
describe('Plugin Distribution - Setup Hook (#1547)', () => {
|
|
it('should not reference removed setup.sh in Setup hook', () => {
|
|
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
|
const content = readFileSync(hooksPath, 'utf-8');
|
|
expect(content).not.toContain('setup.sh');
|
|
});
|
|
|
|
it('should call version-check.js in the Setup hook', () => {
|
|
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
|
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
const setupHooks: any[] = parsed.hooks['Setup'] ?? [];
|
|
|
|
const commandHooks = setupHooks.flatMap((matcher: any) =>
|
|
(matcher.hooks ?? []).filter((h: any) => h.type === 'command')
|
|
);
|
|
|
|
expect(commandHooks.length).toBeGreaterThan(0);
|
|
|
|
const versionCheckHooks = commandHooks.filter((h: any) =>
|
|
h.command?.includes('version-check.js')
|
|
);
|
|
expect(versionCheckHooks.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('version-check.js referenced by Setup hook should exist on disk', () => {
|
|
const versionCheckPath = path.join(projectRoot, 'plugin/scripts/version-check.js');
|
|
expect(existsSync(versionCheckPath)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Plugin Distribution - Non-blocking bookkeeping hooks (#3206)', () => {
|
|
it('runs observation, file context, and summarization asynchronously', () => {
|
|
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
|
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
|
|
const postToolUse = parsed.hooks.PostToolUse[0].hooks[0];
|
|
const preToolUse = parsed.hooks.PreToolUse[0].hooks[0];
|
|
const stop = parsed.hooks.Stop[0].hooks[0];
|
|
|
|
expect(postToolUse.command).toContain('observation');
|
|
expect(postToolUse.async).toBe(true);
|
|
expect(preToolUse.command).toContain('file-context');
|
|
expect(preToolUse.async).toBe(true);
|
|
expect(stop.command).toContain('summarize');
|
|
expect(stop.async).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spawn-contract templating (plans/02-spawn-contract-templating.md)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ccTrailing = (...tail: string[]) => [
|
|
'node', '"$_P/scripts/bun-runner.js"', '"$_P/scripts/worker-service.cjs"', ...tail,
|
|
];
|
|
const claudeHook = (tail: string[], extra: Record<string, unknown> = {}) => buildShellCommand({
|
|
host: 'claude-code', requireFile: 'bun-runner.js', requireFileSecondary: 'worker-service.cjs',
|
|
trailingCommand: ccTrailing(...tail), notFoundMessage: 'claude-mem: plugin scripts not found', ...extra,
|
|
});
|
|
const codexHook = (tail: string[]) => buildShellCommand({
|
|
host: 'codex-cli', requireFile: 'bun-runner.js', requireFileSecondary: 'worker-service.cjs',
|
|
trailingCommand: ccTrailing(...tail), notFoundMessage: 'claude-mem: plugin scripts not found',
|
|
extraEnv: { CLAUDE_MEM_CODEX_HOOK: '1' },
|
|
});
|
|
const codexStartupHook = () => buildShellCommand({
|
|
host: 'codex-cli', requireFile: 'bun-runner.js', requireFileSecondary: 'worker-service.cjs',
|
|
trailingCommand: [
|
|
'_V=$(CLAUDE_MEM_CODEX_HOOK=1 node "$_P/scripts/version-check.js" || true);',
|
|
'if [ -n "$_V" ]; then printf \'%s\\n\' "$_V"; else',
|
|
'CLAUDE_MEM_CODEX_HOOK=1', ...ccTrailing('hook', 'codex', 'context'),
|
|
'; fi',
|
|
],
|
|
notFoundMessage: 'claude-mem: plugin scripts not found',
|
|
});
|
|
const codexHookPair = (tail: string[], options: { startupVersionCheck?: boolean } = {}) => ({
|
|
command: options.startupVersionCheck ? codexStartupHook() : codexHook(tail),
|
|
commandWindows: buildCodexWindowsCommand(tail, options),
|
|
});
|
|
|
|
type RuleAExpectation = string | { command: string; commandWindows: string };
|
|
|
|
const RULE_A_EXPECTATIONS: Record<string, Record<string, RuleAExpectation>> = {
|
|
'plugin/hooks/hooks.json': {
|
|
'Setup.0.0': buildShellCommand({
|
|
host: 'claude-code-setup', requireFile: 'version-check.js',
|
|
trailingCommand: ['node', '"$_P/scripts/version-check.js"'],
|
|
notFoundMessage: 'claude-mem: version-check.js not found',
|
|
}),
|
|
'SessionStart.0.0': claudeHook(['start'], { trailingJson: { continue: true, suppressOutput: true } }),
|
|
'SessionStart.0.1': claudeHook(['hook', 'claude-code', 'context']),
|
|
'UserPromptSubmit.0.0': claudeHook(['hook', 'claude-code', 'session-init']),
|
|
'PostToolUse.0.0': claudeHook(['hook', 'claude-code', 'observation']),
|
|
'PreToolUse.0.0': claudeHook(['hook', 'claude-code', 'file-context']),
|
|
'Stop.0.0': claudeHook(['hook', 'claude-code', 'summarize']),
|
|
},
|
|
'plugin/hooks/codex-hooks.json': {
|
|
'SessionStart.0.0': codexHookPair(['hook', 'codex', 'context'], { startupVersionCheck: true }),
|
|
'UserPromptSubmit.0.0': codexHookPair(['hook', 'codex', 'session-init']),
|
|
'PreToolUse.0.0': codexHookPair(['hook', 'codex', 'file-context']),
|
|
'PostToolUse.0.0': codexHookPair(['hook', 'codex', 'observation']),
|
|
'Stop.0.0': codexHookPair(['hook', 'codex', 'summarize']),
|
|
},
|
|
};
|
|
|
|
const MCP_EXPECTED = buildShellCommand({
|
|
// The mcp Node launcher derives its spawn target from requireFile; it ignores
|
|
// trailingCommand, so none is passed (see buildMcpNodeLauncher).
|
|
host: 'mcp', requireFile: 'mcp-server.cjs',
|
|
notFoundMessage: 'claude-mem: mcp server not found',
|
|
mcpExtraCandidates: ['$PWD/plugin', '$PWD'],
|
|
mcpExtraCacheRoots: [
|
|
'$HOME/.codex/plugins/cache/claude-mem-local/claude-mem',
|
|
'$HOME/.codex/plugins/cache/thedotmack/claude-mem',
|
|
],
|
|
});
|
|
|
|
function hookEntryByPath(parsed: any, dottedPath: string): any | null {
|
|
const [event, groupIdx, hookIdx] = dottedPath.split('.');
|
|
return parsed.hooks?.[event]?.[Number(groupIdx)]?.hooks?.[Number(hookIdx)] ?? null;
|
|
}
|
|
|
|
function hookCommandByPath(parsed: any, dottedPath: string): string | null {
|
|
return hookEntryByPath(parsed, dottedPath)?.command ?? null;
|
|
}
|
|
|
|
describe('Spawn-Contract Templating - Rule A generator parity', () => {
|
|
for (const [filePath, commands] of Object.entries(RULE_A_EXPECTATIONS)) {
|
|
for (const [dottedPath, expected] of Object.entries(commands)) {
|
|
it(`${filePath} [${dottedPath}] equals buildShellCommand output`, () => {
|
|
const parsed = readJson(filePath);
|
|
const entry = hookEntryByPath(parsed, dottedPath);
|
|
const expectedCommand = typeof expected === 'string' ? expected : expected.command;
|
|
expect(entry?.command ?? null).toBe(expectedCommand);
|
|
if (typeof expected !== 'string') {
|
|
expect(entry?.commandWindows ?? null).toBe(expected.commandWindows);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
it('plugin/.mcp.json mcp-search command equals buildShellCommand output', () => {
|
|
const parsed = readJson('plugin/.mcp.json');
|
|
expect(parsed.mcpServers['mcp-search'].args[1]).toBe(MCP_EXPECTED);
|
|
});
|
|
|
|
it('never leaks a raw ${CLAUDE_PLUGIN_ROOT} into the resolved trailing command', () => {
|
|
// The placeholder may appear only inside the _E="${CLAUDE_PLUGIN_ROOT:-...}"
|
|
// expansion, never as a bare `${CLAUDE_PLUGIN_ROOT}` token that would reach
|
|
// the binary unsubstituted.
|
|
const shCommands = Object.values(RULE_A_EXPECTATIONS).flatMap((c) =>
|
|
Object.values(c).map((expectation) =>
|
|
typeof expectation === 'string' ? expectation : expectation.command
|
|
)
|
|
);
|
|
for (const command of shCommands) {
|
|
expect(command).not.toMatch(/\$\{CLAUDE_PLUGIN_ROOT\}(?!:-)/);
|
|
expect(command).toContain('_E="${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-}}"');
|
|
}
|
|
// The MCP node launcher reads env vars directly — it has no `${...}` shell
|
|
// tokens at all, so a raw placeholder can never reach the binary.
|
|
expect(MCP_EXPECTED).not.toContain('${CLAUDE_PLUGIN_ROOT}');
|
|
expect(MCP_EXPECTED).toContain('process.env.CLAUDE_PLUGIN_ROOT');
|
|
expect(MCP_EXPECTED).toContain('process.env.PLUGIN_ROOT');
|
|
});
|
|
});
|
|
|
|
describe('Spawn-Contract Templating - Rule A shell resolution matrix', () => {
|
|
// Actually shell-evaluate the generated commands across resolution sources:
|
|
// (a) CLAUDE_PLUGIN_ROOT injected, (b) cache fallback hit, (c) all miss.
|
|
// Replace the trailing exec with `echo "_P=$_P"` so we observe the resolved
|
|
// root without launching node.
|
|
function instrument(command: string): string {
|
|
// Strip everything from the resolved-root guard onward, keep the resolution
|
|
// pipeline, then print _P. We cut at the cygpath clause / trailing command
|
|
// by replacing the not-found guard's exit with a print of _P.
|
|
const cut = command.indexOf('[ -n "$_P" ]');
|
|
const resolution = cut >= 0 ? command.slice(0, cut) : command;
|
|
return `${resolution} echo "RESOLVED=$_P"`;
|
|
}
|
|
|
|
function shellEval(command: string, env: Record<string, string>): { status: number | null; stdout: string; stderr: string } {
|
|
const result = spawnSync('bash', ['-c', command], {
|
|
env: { PATH: process.env.PATH ?? '', ...env },
|
|
encoding: 'utf-8',
|
|
});
|
|
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
}
|
|
|
|
const claudeCommands = () => {
|
|
const parsed = readJson('plugin/hooks/hooks.json');
|
|
return Object.entries(RULE_A_EXPECTATIONS['plugin/hooks/hooks.json']).map(
|
|
([dottedPath]) => ({ dottedPath, command: hookCommandByPath(parsed, dottedPath)! })
|
|
);
|
|
};
|
|
|
|
it('resolves _P from CLAUDE_PLUGIN_ROOT when the env var points at a valid root', () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'cm-root-'));
|
|
mkdirSync(path.join(root, 'scripts'), { recursive: true });
|
|
writeFileSync(path.join(root, 'scripts', 'version-check.js'), '');
|
|
writeFileSync(path.join(root, 'scripts', 'bun-runner.js'), '');
|
|
writeFileSync(path.join(root, 'scripts', 'worker-service.cjs'), '');
|
|
try {
|
|
for (const { command } of claudeCommands()) {
|
|
const { stdout } = shellEval(instrument(command), {
|
|
CLAUDE_PLUGIN_ROOT: root,
|
|
HOME: mkdtempSync(path.join(tmpdir(), 'cm-home-')),
|
|
});
|
|
expect(stdout).toContain(`RESOLVED=${root}`);
|
|
}
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('resolves _P from the cache directory when CLAUDE_PLUGIN_ROOT is unset', () => {
|
|
const home = mkdtempSync(path.join(tmpdir(), 'cm-home-'));
|
|
const cacheRoot = path.join(home, '.claude', 'plugins', 'cache', 'thedotmack', 'claude-mem', '99.0.0');
|
|
mkdirSync(path.join(cacheRoot, 'scripts'), { recursive: true });
|
|
writeFileSync(path.join(cacheRoot, 'scripts', 'version-check.js'), '');
|
|
writeFileSync(path.join(cacheRoot, 'scripts', 'bun-runner.js'), '');
|
|
writeFileSync(path.join(cacheRoot, 'scripts', 'worker-service.cjs'), '');
|
|
try {
|
|
for (const { command } of claudeCommands()) {
|
|
const { stdout } = shellEval(instrument(command), { HOME: home });
|
|
// ls -dt yields a trailing slash; the hook trims it via _R="${_R%/}".
|
|
expect(stdout).toContain(`RESOLVED=${cacheRoot}`);
|
|
}
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('fails cleanly with the canonical not-found message when no candidate exists', () => {
|
|
const home = mkdtempSync(path.join(tmpdir(), 'cm-empty-'));
|
|
try {
|
|
const parsed = readJson('plugin/hooks/hooks.json');
|
|
const command = hookCommandByPath(parsed, 'UserPromptSubmit.0.0')!;
|
|
const result = spawnSync('bash', ['-c', command], {
|
|
env: { PATH: process.env.PATH ?? '', HOME: home },
|
|
encoding: 'utf-8',
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr ?? '').toMatch(/claude-mem: .* not found/);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Spawn-Contract Templating - Rule B installers bake absolute paths', () => {
|
|
const installerFiles = [
|
|
'src/services/integrations/CursorHooksInstaller.ts',
|
|
'src/services/integrations/WindsurfHooksInstaller.ts',
|
|
'src/services/integrations/McpIntegrations.ts',
|
|
'src/services/integrations/AntigravityCliHooksInstaller.ts',
|
|
];
|
|
|
|
for (const file of installerFiles) {
|
|
it(`${file} emits no raw \${CLAUDE_PLUGIN_ROOT} placeholder`, () => {
|
|
const content = readFileSync(path.join(projectRoot, file), 'utf-8');
|
|
expect(content).not.toMatch(/\$\{CLAUDE_PLUGIN_ROOT\}/);
|
|
});
|
|
}
|
|
|
|
it('install-paths.ts centralizes the Rule B helpers', () => {
|
|
const content = readFileSync(
|
|
path.join(projectRoot, 'src/services/integrations/install-paths.ts'),
|
|
'utf-8',
|
|
);
|
|
for (const name of [
|
|
'getMcpServerAbsolutePath',
|
|
'getWorkerServiceAbsolutePath',
|
|
'getBunAbsolutePath',
|
|
'getNodeAbsolutePath',
|
|
'getPluginRootAbsolutePath',
|
|
]) {
|
|
expect(content).toContain(`export function ${name}`);
|
|
}
|
|
});
|
|
});
|