Files
ccs/tests/unit/utils/websearch/profile-hook-injector.test.ts
T

246 lines
8.3 KiB
TypeScript

import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ensureProfileHooks } from '../../../../src/utils/websearch/profile-hook-injector';
import { getHookPath } from '../../../../src/utils/websearch/hook-config';
import { getMigrationMarkerPath } from '../../../../src/utils/websearch/hook-installer';
describe('ensureProfileHooks', () => {
let tempHome: string | undefined;
let originalCcsHome: string | undefined;
let originalClaudeConfigDir: string | undefined;
function setupTempHome(): string {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-hook-test-'));
originalCcsHome = process.env.CCS_HOME;
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CCS_HOME = tempHome;
delete process.env.CLAUDE_CONFIG_DIR;
return tempHome;
}
function getCcsDir(): string {
if (!tempHome) {
throw new Error('tempHome not initialized');
}
return path.join(tempHome, '.ccs');
}
function getBundledHookContents(): string {
return fs.readFileSync(path.join(process.cwd(), 'lib', 'hooks', 'websearch-transformer.cjs'), 'utf8');
}
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
tempHome = undefined;
originalCcsHome = undefined;
originalClaudeConfigDir = undefined;
});
it('installs the hook binary before writing the profile hook command', () => {
setupTempHome();
const ensured = ensureProfileHooks('glm');
const hookPath = getHookPath();
const settingsPath = path.join(tempHome, '.ccs', 'glm.settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(ensured).toBe(true);
expect(fs.existsSync(hookPath)).toBe(true);
expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(`node "${hookPath}"`);
});
it('succeeds when the hook already exists on disk and installation is effectively a no-op', () => {
setupTempHome();
const hookPath = getHookPath();
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
fs.writeFileSync(hookPath, getBundledHookContents(), 'utf8');
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
throw new Error('copy skipped');
});
const ensured = ensureProfileHooks('glm');
const settingsPath = path.join(getCcsDir(), 'glm.settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(ensured).toBe(true);
expect(copyFileSpy).not.toHaveBeenCalled();
expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(`node "${hookPath}"`);
});
it('does not rewrite the shared hook binary when it is already installed', () => {
setupTempHome();
expect(ensureProfileHooks('glm')).toBe(true);
const firstMtime = fs.statSync(getHookPath()).mtimeMs;
const waitUntil = Date.now() + 25;
while (Date.now() < waitUntil) {
// Give the filesystem timestamp a chance to advance if a rewrite occurs.
}
expect(ensureProfileHooks('glm')).toBe(true);
const secondMtime = fs.statSync(getHookPath()).mtimeMs;
expect(secondMtime).toBe(firstMtime);
});
it('refreshes a stale shared hook binary when the bundled script has changed', () => {
setupTempHome();
const hookPath = getHookPath();
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
fs.writeFileSync(hookPath, '// stale hook', 'utf8');
expect(ensureProfileHooks('glm')).toBe(true);
const installedHook = fs.readFileSync(hookPath, 'utf8');
expect(installedHook).not.toBe('// stale hook');
expect(installedHook).toContain('CCS WebSearch Hook');
});
it('repairs an unreadable existing hook binary instead of failing the profile setup', () => {
if (process.platform === 'win32') return;
setupTempHome();
const hookPath = getHookPath();
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
fs.writeFileSync(hookPath, '// unreadable stale hook', 'utf8');
fs.chmodSync(hookPath, 0o200);
try {
const ensured = ensureProfileHooks('glm');
const settingsPath = path.join(getCcsDir(), 'glm.settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const installedHook = fs.readFileSync(hookPath, 'utf8');
expect(ensured).toBe(true);
expect(installedHook).not.toBe('// unreadable stale hook');
expect(installedHook).toContain('CCS WebSearch Hook');
expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(`node "${hookPath}"`);
} finally {
if (fs.existsSync(hookPath)) {
fs.chmodSync(hookPath, 0o644);
}
}
});
it('succeeds when another process installs the hook during a failed local install', () => {
setupTempHome();
const hookPath = getHookPath();
const originalCopyFileSync = fs.copyFileSync;
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
originalCopyFileSync(source, hookPath);
throw new Error(`simulated concurrent winner while copying to ${String(destination)}`);
});
const ensured = ensureProfileHooks('glm');
const settingsPath = path.join(getCcsDir(), 'glm.settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(ensured).toBe(true);
expect(copyFileSpy).toHaveBeenCalled();
expect(fs.existsSync(hookPath)).toBe(true);
expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(`node "${hookPath}"`);
});
it('returns false when the hook path exists but is unusable', () => {
setupTempHome();
const hookPath = getHookPath();
fs.mkdirSync(hookPath, { recursive: true });
const ensured = ensureProfileHooks('glm');
expect(ensured).toBe(false);
expect(fs.statSync(hookPath).isDirectory()).toBe(true);
expect(fs.existsSync(path.join(getCcsDir(), 'glm.settings.json'))).toBe(false);
});
it('returns false for invalid profile names without creating files', () => {
setupTempHome();
const ensured = ensureProfileHooks('../glm');
expect(ensured).toBe(false);
expect(fs.existsSync(getCcsDir())).toBe(false);
});
it('returns false when WebSearch is disabled without creating files', () => {
setupTempHome();
fs.mkdirSync(getCcsDir(), { recursive: true });
fs.writeFileSync(
path.join(getCcsDir(), 'config.yaml'),
'version: 12\nwebsearch:\n enabled: false\n',
'utf8'
);
const ensured = ensureProfileHooks('glm');
expect(ensured).toBe(false);
expect(fs.existsSync(getHookPath())).toBe(false);
expect(fs.existsSync(path.join(getCcsDir(), 'glm.settings.json'))).toBe(false);
});
it('returns false when hook installation fails and no hook exists on disk', () => {
setupTempHome();
const claudeSettingsPath = path.join(tempHome, '.claude', 'settings.json');
fs.mkdirSync(path.dirname(claudeSettingsPath), { recursive: true });
const globalSettings = {
hooks: {
PreToolUse: [
{
matcher: 'WebSearch',
hooks: [
{
type: 'command',
command: `node "${getHookPath()}"`,
timeout: 90,
},
],
},
],
},
};
fs.writeFileSync(claudeSettingsPath, JSON.stringify(globalSettings, null, 2), 'utf8');
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
throw new Error('copy failed');
});
const ensured = ensureProfileHooks('glm');
const persistedGlobalSettings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8'));
expect(ensured).toBe(false);
expect(copyFileSpy).toHaveBeenCalled();
expect(fs.existsSync(getHookPath())).toBe(false);
expect(fs.existsSync(getMigrationMarkerPath())).toBe(false);
expect(fs.existsSync(path.join(getCcsDir(), 'glm.settings.json'))).toBe(false);
expect(persistedGlobalSettings).toEqual(globalSettings);
});
});