mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
246 lines
8.3 KiB
TypeScript
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);
|
|
});
|
|
});
|