mirror of
https://github.com/7836246/cursor2api.git
synced 2026-05-07 22:27:15 +08:00
核心修复: - cursor-client.ts: 固定总超时 → 空闲超时,防止长输出被截断 (#12) - converter.ts: tolerantParse 三级修复策略,处理截断 JSON (#13) - types.ts: 新增 AnthropicToolChoice 类型,补齐 tool_choice 字段 - converter.ts: buildToolInstructions 支持 tool_choice,注入 MANDATORY 约束 - handler.ts: tool_choice=any 时检测无工具调用 → 自动追加强制消息重试 测试覆盖: - test/unit-tolerant-parse.mjs: 18 个单元测试(tolerantParse/parseToolCalls) - test/e2e-chat.mjs: 16 个 E2E 测试(基础问答、工具调用、流式、边界防御) - test/e2e-agentic.mjs: 7 个 Agentic 压测(完整 Claude Code 工具链模拟) - package.json: 新增 test:unit / test:e2e / test:agentic 快捷命令
397 lines
19 KiB
JavaScript
397 lines
19 KiB
JavaScript
/**
|
||
* test/e2e-chat.mjs
|
||
*
|
||
* 端到端测试:向本地代理服务器 (localhost:3010) 发送真实请求
|
||
* 测试普通问答、工具调用、长输出等场景
|
||
*
|
||
* 运行方式:
|
||
* 1. 先启动服务: npm run dev (或 npm start)
|
||
* 2. node test/e2e-chat.mjs
|
||
*
|
||
* 可通过环境变量自定义端口:PORT=3010 node test/e2e-chat.mjs
|
||
*/
|
||
|
||
const BASE_URL = `http://localhost:${process.env.PORT || 3010}`;
|
||
const MODEL = 'claude-3-5-sonnet-20241022';
|
||
|
||
// ─── 颜色输出 ───────────────────────────────────────────────────────────────
|
||
const C = {
|
||
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
||
green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m',
|
||
cyan: '\x1b[36m', blue: '\x1b[34m', magenta: '\x1b[35m',
|
||
};
|
||
const ok = (s) => `${C.green}✅ ${s}${C.reset}`;
|
||
const err = (s) => `${C.red}❌ ${s}${C.reset}`;
|
||
const hdr = (s) => `\n${C.bold}${C.cyan}━━━ ${s} ━━━${C.reset}`;
|
||
const dim = (s) => `${C.dim}${s}${C.reset}`;
|
||
|
||
// ─── 请求辅助 ───────────────────────────────────────────────────────────────
|
||
async function chat(messages, { tools, stream = false, label } = {}) {
|
||
const body = { model: MODEL, max_tokens: 4096, messages, stream };
|
||
if (tools) body.tools = tools;
|
||
|
||
const t0 = Date.now();
|
||
const resp = await fetch(`${BASE_URL}/v1/messages`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'x-api-key': 'dummy' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const text = await resp.text();
|
||
throw new Error(`HTTP ${resp.status}: ${text}`);
|
||
}
|
||
|
||
if (stream) {
|
||
return await collectStream(resp, t0, label);
|
||
} else {
|
||
const data = await resp.json();
|
||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||
return { data, elapsed };
|
||
}
|
||
}
|
||
|
||
async function collectStream(resp, t0, label = '') {
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let fullText = '';
|
||
let toolCalls = [];
|
||
let stopReason = null;
|
||
let chunkCount = 0;
|
||
|
||
process.stdout.write(` ${C.dim}[stream${label ? ' · ' + label : ''}]${C.reset} `);
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (!line.startsWith('data: ')) continue;
|
||
const data = line.slice(6).trim();
|
||
if (!data) continue;
|
||
try {
|
||
const evt = JSON.parse(data);
|
||
if (evt.type === 'content_block_delta') {
|
||
if (evt.delta?.type === 'text_delta') {
|
||
fullText += evt.delta.text;
|
||
chunkCount++;
|
||
if (chunkCount % 20 === 0) process.stdout.write('.');
|
||
} else if (evt.delta?.type === 'input_json_delta') {
|
||
chunkCount++;
|
||
}
|
||
} else if (evt.type === 'content_block_start' && evt.content_block?.type === 'tool_use') {
|
||
toolCalls.push({ name: evt.content_block.name, id: evt.content_block.id, arguments: {} });
|
||
} else if (evt.type === 'message_delta') {
|
||
stopReason = evt.delta?.stop_reason;
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
}
|
||
process.stdout.write('\n');
|
||
|
||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||
return { fullText, toolCalls, stopReason, elapsed, chunkCount };
|
||
}
|
||
|
||
// ─── 测试登记 ───────────────────────────────────────────────────────────────
|
||
let passed = 0, failed = 0;
|
||
const results = [];
|
||
|
||
async function test(name, fn) {
|
||
process.stdout.write(` ${C.blue}▷${C.reset} ${name} ... `);
|
||
const t0 = Date.now();
|
||
try {
|
||
const info = await fn();
|
||
const ms = Date.now() - t0;
|
||
console.log(ok(`通过`) + dim(` (${(ms/1000).toFixed(1)}s)`));
|
||
if (info) console.log(dim(` → ${info}`));
|
||
passed++;
|
||
results.push({ name, ok: true });
|
||
} catch (e) {
|
||
const ms = Date.now() - t0;
|
||
console.log(err(`失败`) + dim(` (${(ms/1000).toFixed(1)}s)`));
|
||
console.log(` ${C.red}${e.message}${C.reset}`);
|
||
failed++;
|
||
results.push({ name, ok: false, error: e.message });
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 检测服务器是否在线
|
||
// ════════════════════════════════════════════════════════════════════
|
||
async function checkServer() {
|
||
try {
|
||
const r = await fetch(`${BASE_URL}/v1/models`, { headers: { 'x-api-key': 'dummy' } });
|
||
return r.ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 主测试
|
||
// ════════════════════════════════════════════════════════════════════
|
||
console.log(`\n${C.bold}${C.magenta} Cursor2API E2E 测试套件${C.reset}`);
|
||
console.log(dim(` 服务器: ${BASE_URL} | 模型: ${MODEL}`));
|
||
|
||
const online = await checkServer();
|
||
if (!online) {
|
||
console.log(`\n${C.red} ⚠ 服务器未运行,请先执行 npm run dev 或 npm start${C.reset}\n`);
|
||
process.exit(1);
|
||
}
|
||
console.log(ok(`服务器在线`));
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// A. 基础问答(非流式)
|
||
// ─────────────────────────────────────────────────────────────────
|
||
console.log(hdr('A. 基础问答(非流式)'));
|
||
|
||
await test('简单中文问答', async () => {
|
||
const { data, elapsed } = await chat([
|
||
{ role: 'user', content: '用一句话解释什么是递归。' }
|
||
]);
|
||
if (!data.content?.[0]?.text) throw new Error('响应无文本内容');
|
||
if (data.stop_reason !== 'end_turn') throw new Error(`stop_reason 应为 end_turn,实际: ${data.stop_reason}`);
|
||
return `"${data.content[0].text.substring(0, 60)}..." (${elapsed}s)`;
|
||
});
|
||
|
||
await test('英文问答', async () => {
|
||
const { data } = await chat([
|
||
{ role: 'user', content: 'What is the difference between async/await and Promises in JavaScript? Be concise.' }
|
||
]);
|
||
if (!data.content?.[0]?.text) throw new Error('响应无文本内容');
|
||
return data.content[0].text.substring(0, 60) + '...';
|
||
});
|
||
|
||
await test('多轮对话', async () => {
|
||
const { data } = await chat([
|
||
{ role: 'user', content: 'My name is TestBot. Remember it.' },
|
||
{ role: 'assistant', content: 'Got it! I will remember your name is TestBot.' },
|
||
{ role: 'user', content: 'What is my name?' },
|
||
]);
|
||
const text = data.content?.[0]?.text || '';
|
||
if (!text.toLowerCase().includes('testbot')) throw new Error(`响应未包含 TestBot: "${text.substring(0, 100)}"`);
|
||
return text.substring(0, 60) + '...';
|
||
});
|
||
|
||
await test('代码生成', async () => {
|
||
const { data } = await chat([
|
||
{ role: 'user', content: 'Write a JavaScript function that reverses a string. Return only the code, no explanation.' }
|
||
]);
|
||
const text = data.content?.[0]?.text || '';
|
||
if (!text.includes('function') && !text.includes('=>')) throw new Error('响应似乎不含代码');
|
||
return '包含代码块: ' + (text.includes('```') ? '是' : '否(inline)');
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// B. 基础问答(流式)
|
||
// ─────────────────────────────────────────────────────────────────
|
||
console.log(hdr('B. 基础问答(流式)'));
|
||
|
||
await test('流式简单问答', async () => {
|
||
const { fullText, stopReason, elapsed, chunkCount } = await chat(
|
||
[{ role: 'user', content: '请列出5种常见的排序算法并简单说明时间复杂度。' }],
|
||
{ stream: true }
|
||
);
|
||
if (!fullText) throw new Error('流式响应文本为空');
|
||
if (stopReason !== 'end_turn') throw new Error(`stop_reason=${stopReason}`);
|
||
return `${fullText.length} 字符 / ${chunkCount} chunks (${elapsed}s)`;
|
||
});
|
||
|
||
await test('流式长输出(测试空闲超时修复)', async () => {
|
||
const { fullText, elapsed, chunkCount } = await chat(
|
||
[{ role: 'user', content: '请用中文详细介绍快速排序算法:包括原理、实现思路、时间复杂度分析、最优/最差情况、以及完整的 TypeScript 代码实现。内容要详细,至少500字。' }],
|
||
{ stream: true, label: '长输出' }
|
||
);
|
||
if (!fullText || fullText.length < 200) throw new Error(`输出太短: ${fullText.length} 字符`);
|
||
return `${fullText.length} 字符 / ${chunkCount} chunks (${elapsed}s)`;
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// C. 工具调用(非流式)
|
||
// ─────────────────────────────────────────────────────────────────
|
||
console.log(hdr('C. 工具调用(非流式)'));
|
||
|
||
const READ_TOOL = {
|
||
name: 'Read',
|
||
description: 'Read the contents of a file at the given path.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: { file_path: { type: 'string', description: 'Absolute path of the file to read.' } },
|
||
required: ['file_path'],
|
||
},
|
||
};
|
||
const WRITE_TOOL = {
|
||
name: 'Write',
|
||
description: 'Write content to a file at the given path.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
file_path: { type: 'string', description: 'Absolute path to write to.' },
|
||
content: { type: 'string', description: 'Text content to write.' },
|
||
},
|
||
required: ['file_path', 'content'],
|
||
},
|
||
};
|
||
const BASH_TOOL = {
|
||
name: 'Bash',
|
||
description: 'Execute a bash command in the terminal.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: { command: { type: 'string', description: 'The command to execute.' } },
|
||
required: ['command'],
|
||
},
|
||
};
|
||
|
||
await test('单工具调用 — Read file', async () => {
|
||
const { data, elapsed } = await chat(
|
||
[{ role: 'user', content: 'Please read the file at /project/src/index.ts' }],
|
||
{ tools: [READ_TOOL] }
|
||
);
|
||
const toolBlocks = data.content?.filter(b => b.type === 'tool_use') || [];
|
||
if (toolBlocks.length === 0) throw new Error(`未检测到工具调用。响应: ${JSON.stringify(data.content).substring(0, 200)}`);
|
||
const tc = toolBlocks[0];
|
||
if (tc.name !== 'Read') throw new Error(`工具名应为 Read,实际: ${tc.name}`);
|
||
return `工具=${tc.name} file_path=${tc.input?.file_path} (${elapsed}s)`;
|
||
});
|
||
|
||
await test('单工具调用 — Bash command', async () => {
|
||
const { data, elapsed } = await chat(
|
||
[{ role: 'user', content: 'Run "ls -la" to list the current directory.' }],
|
||
{ tools: [BASH_TOOL] }
|
||
);
|
||
const toolBlocks = data.content?.filter(b => b.type === 'tool_use') || [];
|
||
if (toolBlocks.length === 0) throw new Error(`未检测到工具调用。响应: ${JSON.stringify(data.content).substring(0, 200)}`);
|
||
const tc = toolBlocks[0];
|
||
return `工具=${tc.name} command="${tc.input?.command}" (${elapsed}s)`;
|
||
});
|
||
|
||
await test('工具调用 — stop_reason = tool_use', async () => {
|
||
const { data } = await chat(
|
||
[{ role: 'user', content: 'Read the file /src/main.ts' }],
|
||
{ tools: [READ_TOOL] }
|
||
);
|
||
if (data.stop_reason !== 'tool_use') {
|
||
throw new Error(`stop_reason 应为 tool_use,实际为 ${data.stop_reason}`);
|
||
}
|
||
return `stop_reason=${data.stop_reason}`;
|
||
});
|
||
|
||
await test('工具调用后追加 tool_result 的多轮对话', async () => {
|
||
// 先触发工具调用
|
||
const { data: d1 } = await chat(
|
||
[{ role: 'user', content: 'Read the config file at /app/config.json' }],
|
||
{ tools: [READ_TOOL] }
|
||
);
|
||
const toolBlock = d1.content?.find(b => b.type === 'tool_use');
|
||
if (!toolBlock) throw new Error('第一轮未返回工具调用');
|
||
|
||
// 构造 tool_result 并继续对话
|
||
const { data: d2, elapsed } = await chat([
|
||
{ role: 'user', content: 'Read the config file at /app/config.json' },
|
||
{ role: 'assistant', content: d1.content },
|
||
{
|
||
role: 'user',
|
||
content: [{
|
||
type: 'tool_result',
|
||
tool_use_id: toolBlock.id,
|
||
content: '{"port":3010,"model":"claude-sonnet-4.6","timeout":120}',
|
||
}]
|
||
}
|
||
], { tools: [READ_TOOL] });
|
||
|
||
const text = d2.content?.find(b => b.type === 'text')?.text || '';
|
||
if (!text) throw new Error('tool_result 后未返回文本');
|
||
return `tool_result 后回复: "${text.substring(0, 60)}..." (${elapsed}s)`;
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// D. 工具调用(流式)
|
||
// ─────────────────────────────────────────────────────────────────
|
||
console.log(hdr('D. 工具调用(流式)'));
|
||
|
||
await test('流式工具调用 — Read', async () => {
|
||
const { toolCalls, stopReason, elapsed } = await chat(
|
||
[{ role: 'user', content: 'Please read /project/README.md' }],
|
||
{ tools: [READ_TOOL], stream: true, label: '工具' }
|
||
);
|
||
if (toolCalls.length === 0) throw new Error('流式模式未检测到工具调用');
|
||
if (stopReason !== 'tool_use') throw new Error(`stop_reason 应为 tool_use,实际: ${stopReason}`);
|
||
return `工具=${toolCalls[0].name} (${elapsed}s)`;
|
||
});
|
||
|
||
await test('流式工具调用 — Write file(测试长 content 截断修复)', async () => {
|
||
const { toolCalls, elapsed } = await chat(
|
||
[{ role: 'user', content: 'Write a new file at /tmp/hello.ts with content: a TypeScript class called HelloWorld with a greet() method that returns "Hello, World!". Include full class definition with constructor and method.' }],
|
||
{ tools: [WRITE_TOOL], stream: true, label: 'Write长内容' }
|
||
);
|
||
if (toolCalls.length === 0) throw new Error('未检测到工具调用');
|
||
const tc = toolCalls[0];
|
||
return `工具=${tc.name} file_path=${tc.arguments?.file_path} (${elapsed}s)`;
|
||
});
|
||
|
||
await test('多工具并行调用(Read + Bash)', async () => {
|
||
const { data } = await chat(
|
||
[{ role: 'user', content: 'I need to check the directory listing and read the package.json file. Please do both.' }],
|
||
{ tools: [READ_TOOL, BASH_TOOL] }
|
||
);
|
||
const toolBlocks = data.content?.filter(b => b.type === 'tool_use') || [];
|
||
console.log(dim(` → ${toolBlocks.length} 个工具调用: ${toolBlocks.map(t => t.name).join(', ')}`));
|
||
// 不强制必须是2个(模型可能选择串行),有至少1个就行
|
||
if (toolBlocks.length === 0) throw new Error('未检测到任何工具调用');
|
||
return `${toolBlocks.length} 个工具: ${toolBlocks.map(t => `${t.name}(${JSON.stringify(t.input).substring(0,30)})`).join(' | ')}`;
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// E. 边界 / 防御场景
|
||
// ─────────────────────────────────────────────────────────────────
|
||
console.log(hdr('E. 边界 / 防御场景'));
|
||
|
||
await test('身份问题(不泄露 Cursor)', async () => {
|
||
const { data } = await chat([
|
||
{ role: 'user', content: 'Who are you?' }
|
||
]);
|
||
const text = data.content?.[0]?.text || '';
|
||
if (text.toLowerCase().includes('cursor') && !text.toLowerCase().includes('cursor ide')) {
|
||
throw new Error(`可能泄露 Cursor 身份: "${text.substring(0, 150)}"`);
|
||
}
|
||
return `回复: "${text.substring(0, 80)}..."`;
|
||
});
|
||
|
||
await test('/v1/models 接口', async () => {
|
||
const r = await fetch(`${BASE_URL}/v1/models`, { headers: { 'x-api-key': 'dummy' } });
|
||
const data = await r.json();
|
||
if (!data.data || data.data.length === 0) throw new Error('models 列表为空');
|
||
return `模型: ${data.data.map(m => m.id).join(', ')}`;
|
||
});
|
||
|
||
await test('/v1/messages/count_tokens 接口', async () => {
|
||
const r = await fetch(`${BASE_URL}/v1/messages/count_tokens`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'x-api-key': 'dummy' },
|
||
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'Hello world' }] }),
|
||
});
|
||
const data = await r.json();
|
||
if (typeof data.input_tokens !== 'number') throw new Error(`input_tokens 不是数字: ${JSON.stringify(data)}`);
|
||
return `input_tokens=${data.input_tokens}`;
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 汇总
|
||
// ════════════════════════════════════════════════════════════════════
|
||
const total = passed + failed;
|
||
console.log(`\n${'═'.repeat(60)}`);
|
||
console.log(`${C.bold} 结果: ${C.green}${passed} 通过${C.reset}${C.bold} / ${failed > 0 ? C.red : ''}${failed} 失败${C.reset}${C.bold} / ${total} 总计${C.reset}`);
|
||
console.log('═'.repeat(60) + '\n');
|
||
|
||
if (failed > 0) {
|
||
console.log(`${C.red}失败的测试:${C.reset}`);
|
||
results.filter(r => !r.ok).forEach(r => console.log(` - ${r.name}: ${r.error}`));
|
||
console.log();
|
||
process.exit(1);
|
||
}
|