import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; import { spawn } from 'child_process'; import * as fs from 'fs'; import * as http from 'http'; import * as os from 'os'; import * as path from 'path'; const HOOK_PATH = path.join(__dirname, '../../lib/hooks/image-analyzer-transformer.cjs'); const TEST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-it-')); const TEST_PNG_PATH = path.join(TEST_DIR, 'thinking-block-test.png'); const CLIPROXY_API_KEY = 'test-api-key-12345'; interface HookResult { code: number; stdout: string; stderr: string; } interface MockRequest { method: string; path: string; body: unknown; } interface MockResponse { statusCode: number; body: unknown; } let mockServer: http.Server | null = null; let mockPort = 0; let requests: MockRequest[] = []; let queuedResponses: MockResponse[] = []; 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, ]); fs.writeFileSync(filepath, png); } function enqueueResponses(...responses: MockResponse[]): void { queuedResponses = responses; } function invokeHook(env: Record = {}): Promise { return new Promise((resolve, reject) => { const child = spawn('node', [HOOK_PATH], { env: { ...process.env, CCS_IMAGE_ANALYSIS_SKIP: '', // clear any inherited skip flag CCS_IMAGE_ANALYSIS_SKIP_HOOK: '', // clear any inherited MCP-ready bypass CCS_IMAGE_ANALYSIS_MODEL: '', // let each test control explicit model fallback behavior CCS_IMAGE_ANALYSIS_BACKEND_ID: '', CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '', CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: '', CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, CCS_CLIPROXY_PORT: String(mockPort), CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_CURRENT_PROVIDER: 'codex', CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-3-1-flash-preview', ...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', (err) => { clearTimeout(timer); reject(err); }); child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stdout, stderr }); }); child.stdin.end( JSON.stringify({ tool_name: 'Read', tool_input: { file_path: TEST_PNG_PATH }, }) ); }); } beforeAll(async () => { createTestPng(TEST_PNG_PATH); mockServer = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok' })); return; } let body = ''; req.on('data', (chunk) => { body += chunk.toString(); }); req.on('end', () => { requests.push({ method: req.method || 'GET', path: req.url || '/', body: body ? JSON.parse(body) : null, }); const nextResponse = queuedResponses.shift() || { statusCode: 200, body: { content: [{ type: 'text', text: 'default description' }] }, }; res.writeHead(nextResponse.statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(nextResponse.body)); }); }); await new Promise((resolve, reject) => { 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; } mockPort = address.port; resolve(); }); }); }); beforeEach(() => { requests = []; enqueueResponses({ statusCode: 200, body: { content: [{ type: 'text', text: 'default description' }] }, }); }); afterAll(async () => { await new Promise((resolve) => mockServer?.close(() => resolve())); fs.rmSync(TEST_DIR, { recursive: true, force: true }); }); describe('image analyzer hook regression coverage', () => { it('uses the first text block after thinking blocks', async () => { enqueueResponses({ statusCode: 200, body: { content: [ { type: 'thinking', thinking: 'internal reasoning' }, { type: 'text', text: 'blue square with white border' }, ], }, }); const result = await invokeHook(); expect(result.code).toBe(2); expect(requests).toHaveLength(1); const output = JSON.parse(result.stdout); expect(output.hookSpecificOutput.permissionDecisionReason).toContain( 'blue square with white border' ); }); it('does not retry onto a cross-provider default model when a provider-specific model is set', async () => { enqueueResponses({ statusCode: 500, body: { error: { message: 'mock failure' } }, }); const result = await invokeHook(); expect(result.code).toBe(2); expect(requests).toHaveLength(1); expect((requests[0].body as { model: string }).model).toBe('gpt-5.1-codex-mini'); }); it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => { const result = await invokeHook({ CCS_CURRENT_PROVIDER: 'unknown-provider', CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview', }); expect(result.code).toBe(0); expect(requests).toHaveLength(0); }); });