Spaces:
Sleeping
Sleeping
| /** | |
| * 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); | |
| } | |