mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
test(image-analysis): cover runtime auth and fallback
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
@@ -163,6 +163,56 @@ function invokeHook(
|
||||
};
|
||||
}
|
||||
|
||||
function invokeHookAsync(
|
||||
input: object,
|
||||
env: Record<string, string> = {}
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [HOOK_PATH], {
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
||||
CCS_CLIPROXY_PORT: String(MOCK_PORT),
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS,
|
||||
CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER,
|
||||
...env,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('Hook timed out'));
|
||||
}, 10000);
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
code: code ?? -1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
|
||||
child.stdin.end(JSON.stringify(input));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal valid PNG file (1x1 red pixel)
|
||||
*/
|
||||
@@ -272,15 +322,8 @@ function createTestTextFile(filepath: string, content: string): void {
|
||||
* Create a large file exceeding 10MB
|
||||
*/
|
||||
function createLargeFile(filepath: string, sizeMB: number): void {
|
||||
const bufferSize = 1024 * 1024; // 1MB
|
||||
const totalBuffers = sizeMB;
|
||||
const buffer = Buffer.alloc(bufferSize, 'A');
|
||||
const stream = fs.createWriteStream(filepath);
|
||||
|
||||
for (let i = 0; i < totalBuffers; i++) {
|
||||
stream.write(buffer);
|
||||
}
|
||||
stream.end();
|
||||
const totalBytes = sizeMB * 1024 * 1024;
|
||||
fs.writeFileSync(filepath, Buffer.alloc(totalBytes, 'A'));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -498,12 +541,12 @@ describe('Image Analyzer Hook', () => {
|
||||
// ==========================================================================
|
||||
|
||||
describe('File Size Limits', () => {
|
||||
it('should reject files larger than 10MB', () => {
|
||||
it('should reject files larger than 10MB', async () => {
|
||||
// Create 11MB file
|
||||
const largePath = path.join(TEST_DIR, 'large-test.png');
|
||||
createLargeFile(largePath, 11);
|
||||
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: largePath },
|
||||
@@ -533,7 +576,7 @@ describe('Image Analyzer Hook', () => {
|
||||
|
||||
it('should block when CLIProxy is unavailable to prevent context overflow', async () => {
|
||||
// Force hook to use a port that's definitely not running
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -553,7 +596,7 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('CLIProxy unavailable');
|
||||
});
|
||||
|
||||
it('should analyze PNG via mock CLIProxy and return analysis', () => {
|
||||
it('should analyze PNG via mock CLIProxy and return analysis', async () => {
|
||||
resetMockState();
|
||||
mockResponse = {
|
||||
content:
|
||||
@@ -561,7 +604,7 @@ describe('Image Analyzer Hook', () => {
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -577,14 +620,14 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('red square');
|
||||
});
|
||||
|
||||
it('should analyze JPEG via mock CLIProxy', () => {
|
||||
it('should analyze JPEG via mock CLIProxy', async () => {
|
||||
resetMockState();
|
||||
mockResponse = {
|
||||
content: 'A minimalist white image, possibly a blank canvas or placeholder.',
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testJpegPath },
|
||||
@@ -598,10 +641,10 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('white image');
|
||||
});
|
||||
|
||||
it('should include API key in request header', () => {
|
||||
it('should include API key in request header', async () => {
|
||||
resetMockState();
|
||||
|
||||
invokeHook(
|
||||
await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -614,10 +657,10 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(lastRequest?.headers['x-api-key']).toBe(CLIPROXY_API_KEY);
|
||||
});
|
||||
|
||||
it('should send correct request format to CLIProxy', () => {
|
||||
it('should send correct request format to CLIProxy', async () => {
|
||||
resetMockState();
|
||||
|
||||
invokeHook(
|
||||
await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -627,13 +670,14 @@ describe('Image Analyzer Hook', () => {
|
||||
CCS_PROFILE_TYPE: 'cliproxy',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
|
||||
}
|
||||
);
|
||||
|
||||
// Verify request format
|
||||
expect(lastRequest).not.toBeNull();
|
||||
expect(lastRequest?.method).toBe('POST');
|
||||
expect(lastRequest?.path).toBe('/v1/messages');
|
||||
expect(lastRequest?.path).toBe('/api/provider/agy/v1/messages');
|
||||
|
||||
const body = lastRequest?.body as {
|
||||
model: string;
|
||||
@@ -664,10 +708,10 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(imageContent?.source?.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use correct media type for JPEG', () => {
|
||||
it('should use correct media type for JPEG', async () => {
|
||||
resetMockState();
|
||||
|
||||
invokeHook(
|
||||
await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testJpegPath },
|
||||
@@ -687,10 +731,10 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(imageContent?.source?.media_type).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should respect debug mode and output debug messages', () => {
|
||||
it('should respect debug mode and output debug messages', async () => {
|
||||
resetMockState();
|
||||
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -703,14 +747,14 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(result.stderr).toContain('Starting image analysis');
|
||||
});
|
||||
|
||||
it('should handle API error response gracefully (pass through)', () => {
|
||||
it('should handle API error response gracefully (pass through)', async () => {
|
||||
resetMockState();
|
||||
mockResponse = {
|
||||
content: '',
|
||||
statusCode: 500,
|
||||
};
|
||||
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -726,10 +770,10 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error');
|
||||
});
|
||||
|
||||
it('should use model from provider_models mapping', () => {
|
||||
it('should use model from provider_models mapping', async () => {
|
||||
resetMockState();
|
||||
|
||||
invokeHook(
|
||||
await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -747,8 +791,8 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(body.model).toBe('gpt-5.1-codex-mini'); // Model from provider_models
|
||||
});
|
||||
|
||||
it('should skip when provider is not in provider_models', () => {
|
||||
const result = invokeHook(
|
||||
it('should skip when provider is not in provider_models', async () => {
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -770,10 +814,10 @@ describe('Image Analyzer Hook', () => {
|
||||
// ==========================================================================
|
||||
|
||||
describe('Output Format Validation', () => {
|
||||
it('should output valid JSON structure on success', () => {
|
||||
it('should output valid JSON structure on success', async () => {
|
||||
resetMockState();
|
||||
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -794,10 +838,10 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include filename in output', () => {
|
||||
it('should include filename in output', async () => {
|
||||
resetMockState();
|
||||
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -809,10 +853,10 @@ describe('Image Analyzer Hook', () => {
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('test-image.png');
|
||||
});
|
||||
|
||||
it('should include model name in output', () => {
|
||||
it('should include model name in output', async () => {
|
||||
resetMockState();
|
||||
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
@@ -826,7 +870,7 @@ describe('Image Analyzer Hook', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should output valid JSON structure on file read error', () => {
|
||||
it('should output valid JSON structure on file read error', async () => {
|
||||
// Create and immediately delete file to trigger error
|
||||
const errorPath = path.join(TEST_DIR, 'error-test.png');
|
||||
createTestPng(errorPath);
|
||||
@@ -834,7 +878,7 @@ describe('Image Analyzer Hook', () => {
|
||||
// Make file unreadable (simulate permission error)
|
||||
fs.chmodSync(errorPath, 0o000);
|
||||
|
||||
const result = invokeHook(
|
||||
const result = await invokeHookAsync(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: errorPath },
|
||||
|
||||
@@ -171,15 +171,22 @@ describe('profile lifecycle service', () => {
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 12\nwebsearch:\n enabled: false\n', 'utf8');
|
||||
|
||||
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
|
||||
throw new Error('copy should not run when WebSearch is disabled');
|
||||
const originalCopyFileSync = fs.copyFileSync.bind(fs);
|
||||
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
|
||||
const sourcePath = String(source);
|
||||
const destinationPath = String(destination);
|
||||
if (sourcePath.includes('websearch') || destinationPath.includes('websearch')) {
|
||||
throw new Error('websearch copy should not run when WebSearch is disabled');
|
||||
}
|
||||
return originalCopyFileSync(source, destination);
|
||||
});
|
||||
|
||||
const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: ['extra'] }));
|
||||
|
||||
expect(copyFileSpy).not.toHaveBeenCalled();
|
||||
expect(copyFileSpy).toHaveBeenCalled();
|
||||
expect(result.registered).toEqual(['extra']);
|
||||
expect(result.skipped).toEqual([]);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'websearch-transformer.cjs'))).toBe(false);
|
||||
});
|
||||
|
||||
it('registers malformed orphan settings when force bypasses validation', async () => {
|
||||
|
||||
@@ -130,8 +130,14 @@ describe('profile-writer Anthropic direct', () => {
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
|
||||
throw new Error('copy should not run when WebSearch is disabled');
|
||||
const originalCopyFileSync = fs.copyFileSync.bind(fs);
|
||||
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
|
||||
const sourcePath = String(source);
|
||||
const destinationPath = String(destination);
|
||||
if (sourcePath.includes('websearch') || destinationPath.includes('websearch')) {
|
||||
throw new Error('websearch copy should not run when WebSearch is disabled');
|
||||
}
|
||||
return originalCopyFileSync(source, destination);
|
||||
});
|
||||
|
||||
const result = createApiProfile(
|
||||
@@ -142,9 +148,12 @@ describe('profile-writer Anthropic direct', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(copyFileSpy).not.toHaveBeenCalled();
|
||||
expect(copyFileSpy).toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(tempHome, '.ccs', 'disabled-websearch.settings.json'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(fs.existsSync(path.join(tempHome, '.ccs', 'hooks', 'websearch-transformer.cjs'))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,6 +258,44 @@ describe('resolveCliproxyImageAnalysisEnv', () => {
|
||||
|
||||
expect(result.env.CCS_CURRENT_PROVIDER).toBe('agy');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('https://remote.example.com:9443');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
|
||||
it('pins local cliproxy image analysis to the resolved local API key', async () => {
|
||||
const result = await resolveCliproxyImageAnalysisEnv(
|
||||
{
|
||||
profileName: 'orq',
|
||||
provider: 'agy',
|
||||
profileSettingsPath: '/tmp/orq.settings.json',
|
||||
proxyTarget: {
|
||||
host: '127.0.0.1',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
},
|
||||
proxyReachable: true,
|
||||
},
|
||||
{
|
||||
getImageAnalysisHookEnv: () => ({
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
}),
|
||||
hasImageAnalysisProfileHook: () => true,
|
||||
hasImageAnalyzerHook: () => true,
|
||||
resolveImageAnalysisRuntimeStatus: async () => createImageAnalysisStatus(),
|
||||
getLocalRuntimeApiKey: () => 'local-runtime-token',
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,11 +136,15 @@ describe('generateCopilotEnv', () => {
|
||||
port: 8317,
|
||||
};
|
||||
},
|
||||
getLocalRuntimeApiKey: () => 'local-runtime-token',
|
||||
});
|
||||
|
||||
expect(ensureCalls).toBe(1);
|
||||
expect(result.env.CCS_CURRENT_PROVIDER).toBe('ghcp');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/ghcp');
|
||||
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token');
|
||||
expect(result.warning).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-image-analysis-server.cjs');
|
||||
|
||||
function encodeMessage(message: unknown): string {
|
||||
return `${JSON.stringify(message)}\n`;
|
||||
}
|
||||
|
||||
function collectResponses(
|
||||
child: ReturnType<typeof spawn>,
|
||||
expectedCount: number
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
const responses: Array<Record<string, unknown>> = [];
|
||||
const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 5000);
|
||||
|
||||
function tryParse(): void {
|
||||
while (true) {
|
||||
const newlineIndex = buffer.indexOf('\n');
|
||||
if (newlineIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = buffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
|
||||
responses.push(JSON.parse(body) as Record<string, unknown>);
|
||||
if (responses.length >= expectedCount) {
|
||||
clearTimeout(timer);
|
||||
resolve(responses);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
try {
|
||||
tryParse();
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createTestPng(filePath: string): void {
|
||||
const png = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
||||
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
|
||||
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
|
||||
0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00,
|
||||
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
writeFileSync(filePath, png);
|
||||
}
|
||||
|
||||
function createTestPdf(filePath: string): void {
|
||||
writeFileSync(filePath, '%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n', 'utf8');
|
||||
}
|
||||
|
||||
describe('ccs-image-analysis MCP server', () => {
|
||||
let tempDir = '';
|
||||
let imagePath = '';
|
||||
let mockServer: http.Server | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer?.close(() => resolve());
|
||||
if (!mockServer) resolve();
|
||||
});
|
||||
mockServer = null;
|
||||
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
imagePath = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('lists the ImageAnalysis tool and posts directly to the provider-scoped CLIProxy route', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-'));
|
||||
imagePath = join(tempDir, 'screen.png');
|
||||
createTestPng(imagePath);
|
||||
|
||||
let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null;
|
||||
const mockPort = await new Promise<number>((resolve, reject) => {
|
||||
mockServer = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
receivedRequest = {
|
||||
path: req.url || '/',
|
||||
apiKey: req.headers['x-api-key'] as string,
|
||||
body: body ? JSON.parse(body) : null,
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
content: [{ type: 'text', text: 'The screenshot shows a red pixel debug fixture.' }],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
mockServer.once('error', reject);
|
||||
mockServer.listen(0, '127.0.0.1', () => {
|
||||
const address = mockServer?.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Failed to resolve mock server port'));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
|
||||
const child = spawn('node', [serverPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`,
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
try {
|
||||
const responsesPromise = collectResponses(child, 3);
|
||||
child.stdin.write(
|
||||
encodeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'bun-test', version: '1.0.0' },
|
||||
},
|
||||
})
|
||||
);
|
||||
child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
|
||||
child.stdin.write(
|
||||
encodeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'ImageAnalysis',
|
||||
arguments: { filePath: imagePath, focus: 'Describe the visible issue' },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await responsesPromise;
|
||||
const toolsList = responses.find((message) => message.id === 2);
|
||||
const toolCall = responses.find((message) => message.id === 3);
|
||||
|
||||
expect(toolsList?.result).toEqual({
|
||||
tools: [
|
||||
{
|
||||
name: 'ImageAnalysis',
|
||||
description:
|
||||
'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Absolute or workspace-relative path to a local image or PDF file to analyze.',
|
||||
},
|
||||
focus: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".',
|
||||
},
|
||||
template: {
|
||||
type: 'string',
|
||||
enum: ['default', 'screenshot', 'document'],
|
||||
description:
|
||||
'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.',
|
||||
},
|
||||
},
|
||||
required: ['filePath'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages');
|
||||
expect(receivedRequest?.apiKey).toBe('test-api-key');
|
||||
expect(
|
||||
(((receivedRequest?.body as { messages: Array<{ content: Array<{ text?: string }> }> })
|
||||
?.messages[0]?.content[0] || {}) as { text?: string }).text
|
||||
).toContain('Specific focus');
|
||||
expect(toolCall?.result).toBeDefined();
|
||||
expect(
|
||||
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
|
||||
).toContain('red pixel debug fixture');
|
||||
expect(
|
||||
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
|
||||
).toContain('Model: gemini-3-1-flash-preview');
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a structured tool error when the file does not exist', async () => {
|
||||
const child = spawn('node', [serverPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
try {
|
||||
const responsesPromise = collectResponses(child, 2);
|
||||
child.stdin.write(
|
||||
encodeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'bun-test', version: '1.0.0' },
|
||||
},
|
||||
})
|
||||
);
|
||||
child.stdin.write(
|
||||
encodeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'ImageAnalysis',
|
||||
arguments: { filePath: join(tmpdir(), 'missing-image.png') },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await responsesPromise;
|
||||
const toolCall = responses.find((message) => message.id === 2);
|
||||
|
||||
expect(toolCall?.result).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `ImageAnalysis could not find file: ${join(tmpdir(), 'missing-image.png')}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends PDF files as document blocks to the provider-scoped route', async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-pdf-'));
|
||||
const pdfPath = join(tempDir, 'manual.pdf');
|
||||
createTestPdf(pdfPath);
|
||||
|
||||
let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null;
|
||||
const mockPort = await new Promise<number>((resolve, reject) => {
|
||||
mockServer = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
receivedRequest = {
|
||||
path: req.url || '/',
|
||||
apiKey: req.headers['x-api-key'] as string,
|
||||
body: body ? JSON.parse(body) : null,
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
content: [{ type: 'text', text: 'The PDF contains a one-page fixture.' }],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
mockServer.once('error', reject);
|
||||
mockServer.listen(0, '127.0.0.1', () => {
|
||||
const address = mockServer?.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Failed to resolve mock server port'));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
|
||||
const child = spawn('node', [serverPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '0',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`,
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
|
||||
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
try {
|
||||
const responsesPromise = collectResponses(child, 2);
|
||||
child.stdin.write(
|
||||
encodeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'bun-test', version: '1.0.0' },
|
||||
},
|
||||
})
|
||||
);
|
||||
child.stdin.write(
|
||||
encodeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'ImageAnalysis',
|
||||
arguments: { filePath: pdfPath },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await responsesPromise;
|
||||
const requestBody = receivedRequest?.body as {
|
||||
messages: Array<{ content: Array<{ type: string; source?: { media_type?: string } }> }>;
|
||||
};
|
||||
|
||||
expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages');
|
||||
expect(receivedRequest?.apiKey).toBe('test-api-key');
|
||||
expect(requestBody.messages[0]?.content[1]?.type).toBe('document');
|
||||
expect(requestBody.messages[0]?.content[1]?.source?.media_type).toBe('application/pdf');
|
||||
expect(((responses[1]?.result as { content: Array<{ text: string }> }).content[0] || {}).text).toContain(
|
||||
'one-page fixture'
|
||||
);
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read';
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
|
||||
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('settings profile ImageAnalysis launch', () => {
|
||||
let tmpHome = '';
|
||||
let ccsDir = '';
|
||||
let settingsPath = '';
|
||||
let fakeClaudePath = '';
|
||||
let claudeArgsLogPath = '';
|
||||
let claudeEnvLogPath = '';
|
||||
let baseEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-launch-'));
|
||||
ccsDir = path.join(tmpHome, '.ccs');
|
||||
settingsPath = path.join(ccsDir, 'glm.settings.json');
|
||||
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
|
||||
claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt');
|
||||
claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt');
|
||||
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'websearch:',
|
||||
' enabled: false',
|
||||
'image_analysis:',
|
||||
' enabled: true',
|
||||
' timeout: 60',
|
||||
' fallback_backend: agy',
|
||||
' provider_models:',
|
||||
' agy: gemini-3-1-flash-preview',
|
||||
'cliproxy:',
|
||||
' auth:',
|
||||
' api_key: current-token',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/provider/agy',
|
||||
ANTHROPIC_AUTH_TOKEN: 'stale-token',
|
||||
ANTHROPIC_MODEL: 'glm-5',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
fakeClaudePath,
|
||||
`#!/bin/sh
|
||||
printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||
{
|
||||
printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY"
|
||||
printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL"
|
||||
printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH"
|
||||
} > "${claudeEnvLogPath}"
|
||||
exit 0
|
||||
`,
|
||||
{ encoding: 'utf8', mode: 0o755 }
|
||||
);
|
||||
fs.chmodSync(fakeClaudePath, 0o755);
|
||||
|
||||
baseEnv = {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CLAUDE_PATH: fakeClaudePath,
|
||||
CCS_DEBUG: '1',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps launch non-fatal when the shared Read-hook fallback cannot be prepared', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).toContain('--append-system-prompt');
|
||||
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
|
||||
});
|
||||
|
||||
it('keeps launch non-fatal when Image Analysis is disabled', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
'version: 12\nwebsearch:\n enabled: false\nimage_analysis:\n enabled: false\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).toContain('--append-system-prompt');
|
||||
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
|
||||
});
|
||||
|
||||
it('pins bridge-backed image analysis to the current CLIProxy auth token', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
expect(launchedEnv).toContain('runtimeApiKey=current-token');
|
||||
expect(launchedEnv).not.toContain('stale-token');
|
||||
expect(launchedEnv).toContain('runtimePath=/api/provider/agy');
|
||||
});
|
||||
|
||||
it('pins direct settings image analysis to the current local CLIProxy auth token', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'stale-token',
|
||||
ANTHROPIC_MODEL: 'glm-5',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
expect(launchedEnv).toContain('runtimeApiKey=current-token');
|
||||
expect(launchedEnv).not.toContain('stale-token');
|
||||
expect(launchedEnv).toContain('runtimePath=/api/provider/');
|
||||
});
|
||||
});
|
||||
@@ -384,6 +384,41 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('headless executor prepares image-analysis MCP and compatibility hook fallback', async () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||
const settingsPath = path.join(ccsDir, 'glm.settings.json');
|
||||
fs.writeFileSync(settingsPath, '{}\n', 'utf8');
|
||||
process.env.CCS_CLAUDE_PATH = 'claude';
|
||||
|
||||
const result = await HeadlessExecutor.execute('glm', 'describe screenshot', {
|
||||
permissionMode: 'default',
|
||||
timeout: 1000,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
expect(persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true);
|
||||
|
||||
const claudeUserConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(claudeUserConfig.mcpServers?.['ccs-image-analysis']).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
|
||||
env: {},
|
||||
});
|
||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(true);
|
||||
});
|
||||
|
||||
it('headless executor propagates a WebSearch trace launch id when tracing is enabled', async () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('image-analysis-runtime-status', () => {
|
||||
expect(status.effectiveRuntimeReason).toContain('remote.example:443');
|
||||
});
|
||||
|
||||
it('keeps hook-missing on native read even when auth and proxy are ready', async () => {
|
||||
it('keeps hook-missing as a degraded fallback state while primary runtime stays active', async () => {
|
||||
const status = await hydrateImageAnalysisRuntimeStatus(
|
||||
createStatus({
|
||||
status: 'hook-missing',
|
||||
@@ -144,7 +144,7 @@ describe('image-analysis-runtime-status', () => {
|
||||
|
||||
expect(status.authReadiness).toBe('ready');
|
||||
expect(status.proxyReadiness).toBe('ready');
|
||||
expect(status.effectiveRuntimeMode).toBe('native-read');
|
||||
expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis');
|
||||
expect(status.effectiveRuntimeReason).toContain('Profile hook is missing');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
hasImageAnalyzerHook,
|
||||
installImageAnalyzerHook,
|
||||
} from '../../../../src/utils/hooks/image-analyzer-hook-installer';
|
||||
import { prepareImageAnalysisFallbackHook } from '../../../../src/utils/hooks';
|
||||
|
||||
describe('image-analyzer-hook-installer', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let runtimePath = '';
|
||||
let bundledRuntimePath = '';
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analyzer-hook-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
'version: 12\nimage_analysis:\n enabled: true\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
runtimePath = path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs');
|
||||
bundledRuntimePath = path.join(process.cwd(), 'lib', 'hooks', 'image-analysis-runtime.cjs');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (tempHome) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a missing runtime artifact as not ready and repairs it on prepare', () => {
|
||||
expect(installImageAnalyzerHook()).toBe(true);
|
||||
fs.unlinkSync(runtimePath);
|
||||
|
||||
expect(hasImageAnalyzerHook()).toBe(false);
|
||||
expect(prepareImageAnalysisFallbackHook()).toBe(true);
|
||||
expect(fs.existsSync(runtimePath)).toBe(true);
|
||||
expect(hasImageAnalyzerHook()).toBe(true);
|
||||
});
|
||||
|
||||
it('refreshes stale runtime content during fallback hook preparation', () => {
|
||||
expect(installImageAnalyzerHook()).toBe(true);
|
||||
fs.writeFileSync(runtimePath, 'stale runtime\n', 'utf8');
|
||||
|
||||
expect(hasImageAnalyzerHook()).toBe(false);
|
||||
expect(prepareImageAnalysisFallbackHook()).toBe(true);
|
||||
expect(fs.readFileSync(runtimePath, 'utf8')).toBe(fs.readFileSync(bundledRuntimePath, 'utf8'));
|
||||
});
|
||||
});
|
||||
@@ -67,4 +67,23 @@ describe('image-analyzer-profile-hook-injector', () => {
|
||||
expect(fs.existsSync(defaultSettingsPath)).toBe(false);
|
||||
expect(persisted.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips copilot hook persistence when the shared fallback hook is unavailable', () => {
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'copilot.settings.json');
|
||||
writeJson(settingsPath, {});
|
||||
|
||||
const ensured = ensureProfileHooks({
|
||||
profileName: 'copilot',
|
||||
profileType: 'copilot',
|
||||
settingsPath,
|
||||
sharedHookInstalled: false,
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
|
||||
expect(ensured).toBe(false);
|
||||
expect(persisted.hooks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
appendThirdPartyImageAnalysisToolArgs,
|
||||
getImageAnalysisSteeringPrompt,
|
||||
} from '../../../../src/utils/image-analysis';
|
||||
|
||||
describe('appendThirdPartyImageAnalysisToolArgs', () => {
|
||||
it('appends the steering prompt for image analysis', () => {
|
||||
const args = appendThirdPartyImageAnalysisToolArgs(['-p', 'describe the screenshot']);
|
||||
|
||||
expect(args).toEqual(['-p', 'describe the screenshot', '--append-system-prompt', getImageAnalysisSteeringPrompt()]);
|
||||
});
|
||||
|
||||
it('does not duplicate the steering prompt when already present', () => {
|
||||
const steeringPrompt = getImageAnalysisSteeringPrompt();
|
||||
const args = appendThirdPartyImageAnalysisToolArgs([
|
||||
'-p',
|
||||
'describe the screenshot',
|
||||
'--append-system-prompt',
|
||||
steeringPrompt,
|
||||
]);
|
||||
|
||||
expect(args.filter((arg) => arg === steeringPrompt)).toHaveLength(1);
|
||||
expect(args.filter((arg) => arg === '--append-system-prompt')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves trailing arguments after --', () => {
|
||||
const args = appendThirdPartyImageAnalysisToolArgs(['-p', 'describe', '--', 'extra']);
|
||||
|
||||
expect(args).toEqual([
|
||||
'-p',
|
||||
'describe',
|
||||
'--append-system-prompt',
|
||||
getImageAnalysisSteeringPrompt(),
|
||||
'--',
|
||||
'extra',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
ensureImageAnalysisMcp,
|
||||
getImageAnalysisMcpRuntimePath,
|
||||
getImageAnalysisMcpServerName,
|
||||
getImageAnalysisMcpServerPath,
|
||||
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());
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -171,7 +171,7 @@ describe('image-analysis routes', () => {
|
||||
expect.objectContaining({
|
||||
name: 'glm',
|
||||
target: 'claude',
|
||||
currentTargetMode: 'setup',
|
||||
currentTargetMode: 'fallback',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'codexProfile',
|
||||
|
||||
@@ -12,9 +12,17 @@ function writeJson(filePath: string, value: Record<string, unknown>): void {
|
||||
}
|
||||
|
||||
function installSharedHook(tempHome: string): string {
|
||||
const hookPath = path.join(tempHome, '.ccs', 'hooks', 'image-analyzer-transformer.cjs');
|
||||
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
||||
fs.writeFileSync(hookPath, '#!/usr/bin/env node\n', 'utf8');
|
||||
const hooksDir = path.join(tempHome, '.ccs', 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
for (const fileName of ['image-analyzer-transformer.cjs', 'image-analysis-runtime.cjs']) {
|
||||
fs.copyFileSync(
|
||||
path.join(process.cwd(), 'lib', 'hooks', fileName),
|
||||
path.join(hooksDir, fileName)
|
||||
);
|
||||
}
|
||||
|
||||
const hookPath = path.join(hooksDir, 'image-analyzer-transformer.cjs');
|
||||
return hookPath;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user