mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-17 10:14:08 +00:00
test(image-analysis): cover runtime auth and fallback
This commit is contained in:
1 parent
813b2dd4d0
commit
f379a4001f
15 files changed
+1077
-52
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user