mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
220 lines
6.5 KiB
TypeScript
220 lines
6.5 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 * as lockfile from 'proper-lockfile';
|
|
import {
|
|
ensureImageAnalysisMcp,
|
|
getImageAnalysisMcpRuntimePath,
|
|
getImageAnalysisMcpServerName,
|
|
getImageAnalysisMcpServerPath,
|
|
hasImageAnalysisMcpReady,
|
|
uninstallImageAnalysisMcp,
|
|
} from '../../../../src/utils/image-analysis';
|
|
|
|
describe('ensureImageAnalysisMcp', () => {
|
|
let tempHome: string | undefined;
|
|
let originalCcsHome: string | undefined;
|
|
|
|
function setupTempHome(): string {
|
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-mcp-'));
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
process.env.CCS_HOME = tempHome;
|
|
return tempHome;
|
|
}
|
|
|
|
function getCcsDir(): string {
|
|
if (!tempHome) {
|
|
throw new Error('tempHome not initialized');
|
|
}
|
|
return path.join(tempHome, '.ccs');
|
|
}
|
|
|
|
function writeEnabledConfig(): void {
|
|
const ccsDir = getCcsDir();
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'config.yaml'),
|
|
[
|
|
'version: 12',
|
|
'image_analysis:',
|
|
' enabled: true',
|
|
' timeout: 60',
|
|
' fallback_backend: agy',
|
|
' provider_models:',
|
|
' agy: gemini-3-1-flash-preview',
|
|
'',
|
|
].join('\n'),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
function getManagedConfig() {
|
|
return {
|
|
type: 'stdio',
|
|
command: 'node',
|
|
args: [getImageAnalysisMcpServerPath()],
|
|
env: {},
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
mock.restore();
|
|
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
|
|
if (tempHome && fs.existsSync(tempHome)) {
|
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
}
|
|
|
|
tempHome = undefined;
|
|
originalCcsHome = undefined;
|
|
});
|
|
|
|
it('installs the MCP server and preserves existing user mcpServers entries', () => {
|
|
setupTempHome();
|
|
writeEnabledConfig();
|
|
|
|
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
|
fs.writeFileSync(
|
|
claudeUserConfigPath,
|
|
JSON.stringify(
|
|
{
|
|
mcpServers: {
|
|
existing: { command: 'uvx', args: ['some-server'] },
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
) + '\n',
|
|
'utf8'
|
|
);
|
|
|
|
expect(ensureImageAnalysisMcp()).toBe(true);
|
|
expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(true);
|
|
expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(true);
|
|
|
|
const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
|
|
mcpServers: Record<string, unknown>;
|
|
};
|
|
|
|
expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] });
|
|
expect(config.mcpServers[getImageAnalysisMcpServerName()]).toEqual(getManagedConfig());
|
|
expect(hasImageAnalysisMcpReady(claudeUserConfigPath)).toBe(true);
|
|
});
|
|
|
|
it('removes the managed MCP runtime while preserving unrelated server entries', () => {
|
|
setupTempHome();
|
|
writeEnabledConfig();
|
|
|
|
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
|
fs.writeFileSync(
|
|
claudeUserConfigPath,
|
|
JSON.stringify(
|
|
{
|
|
mcpServers: {
|
|
existing: { command: 'uvx', args: ['some-server'] },
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
) + '\n',
|
|
'utf8'
|
|
);
|
|
|
|
expect(ensureImageAnalysisMcp()).toBe(true);
|
|
|
|
const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work');
|
|
fs.mkdirSync(instancePath, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(instancePath, '.claude.json'),
|
|
JSON.stringify(
|
|
{
|
|
mcpServers: {
|
|
existing: { command: 'uvx', args: ['instance-server'] },
|
|
[getImageAnalysisMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] },
|
|
},
|
|
otherKey: 'keep-me',
|
|
},
|
|
null,
|
|
2
|
|
) + '\n',
|
|
'utf8'
|
|
);
|
|
|
|
expect(uninstallImageAnalysisMcp()).toBe(true);
|
|
expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(false);
|
|
expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(false);
|
|
|
|
const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
|
|
mcpServers: Record<string, { command: string; args: string[] }>;
|
|
};
|
|
expect(globalConfig.mcpServers).toEqual({
|
|
existing: { command: 'uvx', args: ['some-server'] },
|
|
});
|
|
|
|
const instanceConfig = JSON.parse(
|
|
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
|
|
) as {
|
|
otherKey: string;
|
|
mcpServers: Record<string, { command: string; args: string[] }>;
|
|
};
|
|
expect(instanceConfig.otherKey).toBe('keep-me');
|
|
expect(instanceConfig.mcpServers).toEqual({
|
|
existing: { command: 'uvx', args: ['instance-server'] },
|
|
});
|
|
});
|
|
|
|
it('installs the first-class MCP runtime even when the legacy hooks path is unusable', () => {
|
|
setupTempHome();
|
|
writeEnabledConfig();
|
|
|
|
const hooksPath = path.join(getCcsDir(), 'hooks');
|
|
fs.writeFileSync(hooksPath, 'not-a-directory', 'utf8');
|
|
|
|
expect(ensureImageAnalysisMcp()).toBe(true);
|
|
expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(true);
|
|
expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(true);
|
|
});
|
|
|
|
it('serializes ~/.claude.json updates with a file lock', () => {
|
|
setupTempHome();
|
|
writeEnabledConfig();
|
|
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
|
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
|
|
|
|
const lockSpy = spyOn(lockfile, 'lockSync');
|
|
|
|
expect(ensureImageAnalysisMcp()).toBe(true);
|
|
expect(lockSpy).toHaveBeenCalled();
|
|
expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock'));
|
|
});
|
|
|
|
it('returns false instead of throwing when ~/.claude.json is already locked', () => {
|
|
setupTempHome();
|
|
writeEnabledConfig();
|
|
|
|
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
|
|
fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8');
|
|
fs.writeFileSync(path.join(tempHome as string, '.claude.json.ccs-lock'), '', 'utf8');
|
|
|
|
const release = lockfile.lockSync(path.join(tempHome as string, '.claude.json.ccs-lock'), {
|
|
stale: 10000,
|
|
}) as () => void;
|
|
|
|
try {
|
|
let result: boolean | undefined;
|
|
expect(() => {
|
|
result = ensureImageAnalysisMcp();
|
|
}).not.toThrow();
|
|
expect(result).toBe(false);
|
|
} finally {
|
|
release();
|
|
}
|
|
});
|
|
});
|