From f379a4001f768cb7885fbffb322e63d678244aec Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 2 Apr 2026 15:43:09 -0400 Subject: [PATCH] test(image-analysis): cover runtime auth and fallback --- tests/e2e/image-analyzer-hook.e2e.test.ts | 124 ++++-- .../api/profile-lifecycle-service.test.ts | 13 +- .../unit/api/profile-writer-anthropic.test.ts | 15 +- .../env-resolver-codex-fallback.test.ts | 38 ++ .../unit/copilot/copilot-executor-env.test.ts | 4 + .../ccs-image-analysis-mcp-server.test.ts | 380 ++++++++++++++++++ ...ings-profile-image-analysis-launch.test.ts | 198 +++++++++ .../utils/claudecode-env-stripping.test.ts | 35 ++ .../image-analysis-runtime-status.test.ts | 4 +- .../image-analyzer-hook-installer.test.ts | 64 +++ ...age-analyzer-profile-hook-injector.test.ts | 19 + .../image-analysis/claude-tool-args.test.ts | 39 ++ .../image-analysis/mcp-installer.test.ts | 180 +++++++++ .../web-server/image-analysis-routes.test.ts | 2 +- ...tings-routes-image-analysis-status.test.ts | 14 +- 15 files changed, 1077 insertions(+), 52 deletions(-) create mode 100644 tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts create mode 100644 tests/unit/targets/settings-profile-image-analysis-launch.test.ts create mode 100644 tests/unit/utils/hooks/image-analyzer-hook-installer.test.ts create mode 100644 tests/unit/utils/image-analysis/claude-tool-args.test.ts create mode 100644 tests/unit/utils/image-analysis/mcp-installer.test.ts diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index d9a3015b..332f95da 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -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 = {} +): 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 }, diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index bd832b47..310d9af5 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -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 () => { diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index d3c52fa7..6eab981b 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -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 + ); }); }); diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 0978ede3..24cb3d18 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -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(); }); }); diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts index 609be2b5..982287eb 100644 --- a/tests/unit/copilot/copilot-executor-env.test.ts +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -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(); }); }); diff --git a/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts b/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts new file mode 100644 index 00000000..6e77eb3b --- /dev/null +++ b/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts @@ -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, + expectedCount: number +): Promise>> { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const responses: Array> = []; + 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); + 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((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((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((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(); + } + }); +}); diff --git a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts new file mode 100644 index 00000000..5baed5f7 --- /dev/null +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -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/'); + }); +}); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index acd83eb7..aa13eefd 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -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; + }; + 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'); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts index dd099371..40ae163d 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts @@ -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'); }); }); diff --git a/tests/unit/utils/hooks/image-analyzer-hook-installer.test.ts b/tests/unit/utils/hooks/image-analyzer-hook-installer.test.ts new file mode 100644 index 00000000..ca8671e2 --- /dev/null +++ b/tests/unit/utils/hooks/image-analyzer-hook-installer.test.ts @@ -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')); + }); +}); diff --git a/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts index 96c07f32..f0e47f19 100644 --- a/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts +++ b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts @@ -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(); + }); }); diff --git a/tests/unit/utils/image-analysis/claude-tool-args.test.ts b/tests/unit/utils/image-analysis/claude-tool-args.test.ts new file mode 100644 index 00000000..8f62322b --- /dev/null +++ b/tests/unit/utils/image-analysis/claude-tool-args.test.ts @@ -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', + ]); + }); +}); diff --git a/tests/unit/utils/image-analysis/mcp-installer.test.ts b/tests/unit/utils/image-analysis/mcp-installer.test.ts new file mode 100644 index 00000000..b958bb5a --- /dev/null +++ b/tests/unit/utils/image-analysis/mcp-installer.test.ts @@ -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; + }; + + 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; + }; + 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; + }; + 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); + }); +}); diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts index 557a9916..fb34f566 100644 --- a/tests/unit/web-server/image-analysis-routes.test.ts +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -171,7 +171,7 @@ describe('image-analysis routes', () => { expect.objectContaining({ name: 'glm', target: 'claude', - currentTargetMode: 'setup', + currentTargetMode: 'fallback', }), expect.objectContaining({ name: 'codexProfile', diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts index bb375874..2352830c 100644 --- a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -12,9 +12,17 @@ function writeJson(filePath: string, value: Record): 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; }