Spaces:
Sleeping
Sleeping
| /** | |
| * test/unit-openai-compat.mjs | |
| * | |
| * ๅๅ ๆต่ฏ๏ผOpenAI ๅค็ๅจๅ ผๅฎนๆงๅ่ฝ | |
| * - responsesToChatCompletions ่ฝฌๆข | |
| * - Cursor ๆๅนณๆ ผๅผๅทฅๅ ทๅ ผๅฎน | |
| * - ๆถๆฏ่ง่ฒๅๅนถ | |
| * | |
| * ่ฟ่กๆนๅผ๏ผnode test/unit-openai-compat.mjs | |
| */ | |
| // โโโ ๆต่ฏๆกๆถ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| let passed = 0; | |
| let failed = 0; | |
| function test(name, fn) { | |
| try { | |
| fn(); | |
| console.log(` โ ${name}`); | |
| passed++; | |
| } catch (e) { | |
| console.error(` โ ${name}`); | |
| console.error(` ${e.message}`); | |
| failed++; | |
| } | |
| } | |
| function assert(condition, msg) { | |
| if (!condition) throw new Error(msg || 'Assertion failed'); | |
| } | |
| function assertEqual(a, b, msg) { | |
| const as = JSON.stringify(a), bs = JSON.stringify(b); | |
| if (as !== bs) throw new Error(msg || `Expected ${bs}, got ${as}`); | |
| } | |
| function stringifyUnknownContent(value) { | |
| if (value === null || value === undefined) return ''; | |
| if (typeof value === 'string') return value; | |
| if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { | |
| return String(value); | |
| } | |
| try { | |
| return JSON.stringify(value); | |
| } catch { | |
| return String(value); | |
| } | |
| } | |
| function extractOpenAIContentBlocks(msg) { | |
| if (msg.content === null || msg.content === undefined) return ''; | |
| if (typeof msg.content === 'string') return msg.content; | |
| if (Array.isArray(msg.content)) { | |
| const blocks = []; | |
| for (const p of msg.content) { | |
| if ((p.type === 'text' || p.type === 'input_text') && p.text) { | |
| blocks.push({ type: 'text', text: p.text }); | |
| } else if (p.type === 'image_url' && p.image_url?.url) { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'url', media_type: 'image/jpeg', data: p.image_url.url }, | |
| }); | |
| } else if (p.type === 'input_image' && p.image_url?.url) { | |
| blocks.push({ | |
| type: 'image', | |
| source: { type: 'url', media_type: 'image/jpeg', data: p.image_url.url }, | |
| }); | |
| } | |
| } | |
| return blocks.length > 0 ? blocks : ''; | |
| } | |
| return stringifyUnknownContent(msg.content); | |
| } | |
| function extractOpenAIContent(msg) { | |
| const blocks = extractOpenAIContentBlocks(msg); | |
| if (typeof blocks === 'string') return blocks; | |
| return blocks.filter(b => b.type === 'text').map(b => b.text).join('\n'); | |
| } | |
| // โโโ ๅ ่ mergeConsecutiveRoles๏ผไธ src/openai-handler.ts ไฟๆๅๆญฅ๏ผโโโโ | |
| function toBlocks(content) { | |
| if (typeof content === 'string') { | |
| return content ? [{ type: 'text', text: content }] : []; | |
| } | |
| return content || []; | |
| } | |
| function mergeConsecutiveRoles(messages) { | |
| if (messages.length <= 1) return messages; | |
| const merged = []; | |
| for (const msg of messages) { | |
| const last = merged[merged.length - 1]; | |
| if (last && last.role === msg.role) { | |
| const lastBlocks = toBlocks(last.content); | |
| const newBlocks = toBlocks(msg.content); | |
| last.content = [...lastBlocks, ...newBlocks]; | |
| } else { | |
| merged.push({ ...msg }); | |
| } | |
| } | |
| return merged; | |
| } | |
| // โโโ ๅ ่ responsesToChatCompletions๏ผไธ src/openai-handler.ts ไฟๆๅๆญฅ๏ผ | |
| function responsesToChatCompletions(body) { | |
| const messages = []; | |
| if (body.instructions && typeof body.instructions === 'string') { | |
| messages.push({ role: 'system', content: body.instructions }); | |
| } | |
| const input = body.input; | |
| if (typeof input === 'string') { | |
| messages.push({ role: 'user', content: input }); | |
| } else if (Array.isArray(input)) { | |
| for (const item of input) { | |
| // function_call_output has type but no role โ check first | |
| if (item.type === 'function_call_output') { | |
| messages.push({ | |
| role: 'tool', | |
| content: stringifyUnknownContent(item.output), | |
| tool_call_id: item.call_id || '', | |
| }); | |
| continue; | |
| } | |
| const role = item.role || 'user'; | |
| if (role === 'system' || role === 'developer') { | |
| const text = extractOpenAIContent({ | |
| role: 'system', | |
| content: item.content ?? null, | |
| }); | |
| messages.push({ role: 'system', content: text }); | |
| } else if (role === 'user') { | |
| const rawContent = item.content ?? null; | |
| const normalizedContent = typeof rawContent === 'string' | |
| ? rawContent | |
| : Array.isArray(rawContent) && rawContent.every(b => b.type === 'input_text') | |
| ? rawContent.map(b => b.text || '').join('\n') | |
| : rawContent; | |
| messages.push({ | |
| role: 'user', | |
| content: normalizedContent, | |
| }); | |
| } else if (role === 'assistant') { | |
| const blocks = Array.isArray(item.content) ? item.content : []; | |
| const text = blocks.filter(b => b.type === 'output_text').map(b => b.text).join('\n'); | |
| const toolCallBlocks = blocks.filter(b => b.type === 'function_call'); | |
| const toolCalls = toolCallBlocks.map(b => ({ | |
| id: b.call_id || `call_${Math.random().toString(36).slice(2)}`, | |
| type: 'function', | |
| function: { | |
| name: b.name || '', | |
| arguments: b.arguments || '{}', | |
| }, | |
| })); | |
| messages.push({ | |
| role: 'assistant', | |
| content: text || null, | |
| ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), | |
| }); | |
| } | |
| } | |
| } | |
| const tools = Array.isArray(body.tools) | |
| ? body.tools.map(t => ({ | |
| type: 'function', | |
| function: { | |
| name: t.name || '', | |
| description: t.description, | |
| parameters: t.parameters, | |
| }, | |
| })) | |
| : undefined; | |
| return { | |
| model: body.model || 'gpt-4', | |
| messages, | |
| stream: body.stream ?? true, | |
| temperature: body.temperature, | |
| max_tokens: body.max_output_tokens || 8192, | |
| tools, | |
| }; | |
| } | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| // 1. responsesToChatCompletions โ ๅบๆฌ่ฝฌๆข | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| console.log('\n๐ฆ [1] responsesToChatCompletions โ ๅบๆฌ่ฝฌๆข\n'); | |
| test('็ฎๅๅญ็ฌฆไธฒ input โ user ๆถๆฏ', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| input: 'Hello, how are you?', | |
| }); | |
| assertEqual(result.model, 'gpt-4'); | |
| assertEqual(result.messages.length, 1); | |
| assertEqual(result.messages[0].role, 'user'); | |
| assertEqual(result.messages[0].content, 'Hello, how are you?'); | |
| }); | |
| test('ๅธฆ instructions โ system ๆถๆฏ', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| instructions: 'You are a helpful assistant.', | |
| input: 'Hello', | |
| }); | |
| assertEqual(result.messages.length, 2); | |
| assertEqual(result.messages[0].role, 'system'); | |
| assertEqual(result.messages[0].content, 'You are a helpful assistant.'); | |
| assertEqual(result.messages[1].role, 'user'); | |
| }); | |
| test('ๅค่ฝฎๅฏน่ฏ input ๆฐ็ป', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| input: [ | |
| { role: 'user', content: 'What is 2+2?' }, | |
| { role: 'assistant', content: [{ type: 'output_text', text: '4' }] }, | |
| { role: 'user', content: 'And 3+3?' }, | |
| ], | |
| }); | |
| assertEqual(result.messages.length, 3); | |
| assertEqual(result.messages[0].role, 'user'); | |
| assertEqual(result.messages[1].role, 'assistant'); | |
| assertEqual(result.messages[1].content, '4'); | |
| assertEqual(result.messages[2].role, 'user'); | |
| }); | |
| test('developer ่ง่ฒ โ system', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| input: [ | |
| { role: 'developer', content: 'You are a coding assistant.' }, | |
| { role: 'user', content: 'Write hello world' }, | |
| ], | |
| }); | |
| assertEqual(result.messages[0].role, 'system'); | |
| assertEqual(result.messages[0].content, 'You are a coding assistant.'); | |
| }); | |
| test('function_call_output โ tool ๆถๆฏ', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| input: [ | |
| { role: 'user', content: 'List files' }, | |
| { | |
| role: 'assistant', | |
| content: [{ | |
| type: 'function_call', | |
| call_id: 'call_123', | |
| name: 'list_dir', | |
| arguments: '{"path":"."}' | |
| }] | |
| }, | |
| { | |
| type: 'function_call_output', | |
| call_id: 'call_123', | |
| output: 'file1.ts\nfile2.ts' | |
| }, | |
| ], | |
| }); | |
| assertEqual(result.messages.length, 3); | |
| assertEqual(result.messages[2].role, 'tool'); | |
| assertEqual(result.messages[2].content, 'file1.ts\nfile2.ts'); | |
| assertEqual(result.messages[2].tool_call_id, 'call_123'); | |
| }); | |
| test('function_call_output ๅฏน่ฑก โ JSON ๅญ็ฌฆไธฒ', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| input: [ | |
| { role: 'user', content: 'Summarize tool output' }, | |
| { | |
| type: 'function_call_output', | |
| call_id: 'call_obj', | |
| output: { files: ['a.ts', 'b.ts'], count: 2 } | |
| }, | |
| ], | |
| }); | |
| assertEqual(result.messages.length, 2); | |
| assertEqual(result.messages[1].role, 'tool'); | |
| assertEqual(result.messages[1].content, '{"files":["a.ts","b.ts"],"count":2}'); | |
| assertEqual(result.messages[1].tool_call_id, 'call_obj'); | |
| }); | |
| test('ๅฉๆๆถๆฏๅธฆ function_call โ tool_calls', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| input: [ | |
| { role: 'user', content: 'Read file' }, | |
| { | |
| role: 'assistant', | |
| content: [{ | |
| type: 'function_call', | |
| call_id: 'call_abc', | |
| name: 'read_file', | |
| arguments: '{"path":"index.ts"}' | |
| }] | |
| }, | |
| ], | |
| }); | |
| assertEqual(result.messages[1].role, 'assistant'); | |
| assert(result.messages[1].tool_calls, 'should have tool_calls'); | |
| assertEqual(result.messages[1].tool_calls.length, 1); | |
| assertEqual(result.messages[1].tool_calls[0].function.name, 'read_file'); | |
| assertEqual(result.messages[1].tool_calls[0].function.arguments, '{"path":"index.ts"}'); | |
| }); | |
| test('ๅทฅๅ ทๅฎไน่ฝฌๆข', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| input: 'hello', | |
| tools: [ | |
| { | |
| type: 'function', | |
| name: 'read_file', | |
| description: 'Read a file', | |
| parameters: { type: 'object', properties: { path: { type: 'string' } } }, | |
| } | |
| ], | |
| }); | |
| assert(result.tools, 'should have tools'); | |
| assertEqual(result.tools.length, 1); | |
| assertEqual(result.tools[0].function.name, 'read_file'); | |
| }); | |
| test('input_text content ๆฐ็ป', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| input: [ | |
| { | |
| role: 'user', | |
| content: [ | |
| { type: 'input_text', text: 'Part 1' }, | |
| { type: 'input_text', text: 'Part 2' }, | |
| ] | |
| }, | |
| ], | |
| }); | |
| assertEqual(result.messages[0].content, 'Part 1\nPart 2'); | |
| }); | |
| test('Responses user input_image ไธๅบไธขๅคฑ', () => { | |
| const result = responsesToChatCompletions({ | |
| model: 'gpt-4', | |
| input: [ | |
| { | |
| role: 'user', | |
| content: [ | |
| { type: 'input_text', text: '่ฏทๆ่ฟฐ่ฟๅผ ๅพ' }, | |
| { type: 'input_image', image_url: { url: 'https://example.com/image.jpg' } }, | |
| ] | |
| }, | |
| ], | |
| }); | |
| assertEqual(result.messages.length, 1); | |
| assert(Array.isArray(result.messages[0].content), 'content should remain multimodal blocks'); | |
| assertEqual(result.messages[0].content[0], { type: 'input_text', text: '่ฏทๆ่ฟฐ่ฟๅผ ๅพ' }); | |
| assertEqual(result.messages[0].content[1], { type: 'input_image', image_url: { url: 'https://example.com/image.jpg' } }); | |
| }); | |
| test('stream ้ป่ฎคไธบ true', () => { | |
| const result = responsesToChatCompletions({ model: 'gpt-4', input: 'hi' }); | |
| assertEqual(result.stream, true); | |
| }); | |
| test('stream ๆพๅผ่ฎพไธบ false', () => { | |
| const result = responsesToChatCompletions({ model: 'gpt-4', input: 'hi', stream: false }); | |
| assertEqual(result.stream, false); | |
| }); | |
| test('max_output_tokens ่ฝฌๆข', () => { | |
| const result = responsesToChatCompletions({ model: 'gpt-4', input: 'hi', max_output_tokens: 4096 }); | |
| assertEqual(result.max_tokens, 4096); | |
| }); | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| // 2. mergeConsecutiveRoles โ ๆถๆฏๅๅนถ | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| console.log('\n๐ฆ [2] mergeConsecutiveRoles โ ๆถๆฏๅๅนถ\n'); | |
| test('ไบคๆฟ่ง่ฒไธๅๅนถ', () => { | |
| const msgs = [ | |
| { role: 'user', content: 'Hello' }, | |
| { role: 'assistant', content: 'Hi' }, | |
| { role: 'user', content: 'Bye' }, | |
| ]; | |
| const result = mergeConsecutiveRoles(msgs); | |
| assertEqual(result.length, 3); | |
| }); | |
| test('่ฟ็ปญ user ๆถๆฏๅๅนถ', () => { | |
| const msgs = [ | |
| { role: 'user', content: 'Message 1' }, | |
| { role: 'user', content: 'Message 2' }, | |
| { role: 'assistant', content: 'Response' }, | |
| ]; | |
| const result = mergeConsecutiveRoles(msgs); | |
| assertEqual(result.length, 2); | |
| assertEqual(result[0].role, 'user'); | |
| // ๅๅนถๅๅบไธบ block ๆฐ็ป | |
| assert(Array.isArray(result[0].content), 'merged content should be array'); | |
| assertEqual(result[0].content.length, 2); | |
| assertEqual(result[0].content[0].text, 'Message 1'); | |
| assertEqual(result[0].content[1].text, 'Message 2'); | |
| }); | |
| test('่ฟ็ปญ assistant ๆถๆฏๅๅนถ', () => { | |
| const msgs = [ | |
| { role: 'user', content: 'Hello' }, | |
| { role: 'assistant', content: 'Part 1' }, | |
| { role: 'assistant', content: 'Part 2' }, | |
| ]; | |
| const result = mergeConsecutiveRoles(msgs); | |
| assertEqual(result.length, 2); | |
| assertEqual(result[1].role, 'assistant'); | |
| assert(Array.isArray(result[1].content)); | |
| assertEqual(result[1].content.length, 2); | |
| }); | |
| test('tool result + text user ๆถๆฏๅๅนถ', () => { | |
| const msgs = [ | |
| { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'id1', content: 'output' }] }, | |
| { role: 'user', content: 'Follow up question' }, | |
| ]; | |
| const result = mergeConsecutiveRoles(msgs); | |
| assertEqual(result.length, 1); | |
| assert(Array.isArray(result[0].content)); | |
| assertEqual(result[0].content.length, 2); // tool_result + text | |
| }); | |
| test('็ฉบๆถๆฏๅ่กจ', () => { | |
| assertEqual(mergeConsecutiveRoles([]).length, 0); | |
| }); | |
| test('ๅๆกๆถๆฏไธๅๅนถ', () => { | |
| const result = mergeConsecutiveRoles([{ role: 'user', content: 'solo' }]); | |
| assertEqual(result.length, 1); | |
| }); | |
| test('ไธๆก่ฟ็ปญ user ๅ จ้จๅๅนถ', () => { | |
| const msgs = [ | |
| { role: 'user', content: 'A' }, | |
| { role: 'user', content: 'B' }, | |
| { role: 'user', content: 'C' }, | |
| ]; | |
| const result = mergeConsecutiveRoles(msgs); | |
| assertEqual(result.length, 1); | |
| assert(Array.isArray(result[0].content)); | |
| assertEqual(result[0].content.length, 3); | |
| }); | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| // 3. Cursor ๆๅนณๆ ผๅผๅทฅๅ ทๅ ผๅฎน | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| console.log('\n๐ฆ [3] Cursor ๆๅนณๆ ผๅผๅทฅๅ ทๅ ผๅฎน\n'); | |
| function convertTools(tools) { | |
| return tools.map(t => { | |
| if ('function' in t && t.function) { | |
| return { | |
| name: t.function.name, | |
| description: t.function.description, | |
| input_schema: t.function.parameters || { type: 'object', properties: {} }, | |
| }; | |
| } | |
| return { | |
| name: t.name || '', | |
| description: t.description, | |
| input_schema: t.input_schema || { type: 'object', properties: {} }, | |
| }; | |
| }); | |
| } | |
| test('ๆ ๅ OpenAI ๆ ผๅผๅทฅๅ ท', () => { | |
| const tools = convertTools([{ | |
| type: 'function', | |
| function: { | |
| name: 'read_file', | |
| description: 'Read file contents', | |
| parameters: { type: 'object', properties: { path: { type: 'string' } } }, | |
| }, | |
| }]); | |
| assertEqual(tools[0].name, 'read_file'); | |
| assertEqual(tools[0].description, 'Read file contents'); | |
| assert(tools[0].input_schema.properties.path); | |
| }); | |
| test('Cursor ๆๅนณๆ ผๅผๅทฅๅ ท', () => { | |
| const tools = convertTools([{ | |
| name: 'write_file', | |
| description: 'Write file', | |
| input_schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } } }, | |
| }]); | |
| assertEqual(tools[0].name, 'write_file'); | |
| assertEqual(tools[0].description, 'Write file'); | |
| assert(tools[0].input_schema.properties.path); | |
| assert(tools[0].input_schema.properties.content); | |
| }); | |
| test('ๆททๅๆ ผๅผๅทฅๅ ทๅ่กจ', () => { | |
| const tools = convertTools([ | |
| { | |
| type: 'function', | |
| function: { name: 'tool_a', description: 'A', parameters: {} }, | |
| }, | |
| { | |
| name: 'tool_b', | |
| description: 'B', | |
| input_schema: {}, | |
| }, | |
| ]); | |
| assertEqual(tools.length, 2); | |
| assertEqual(tools[0].name, 'tool_a'); | |
| assertEqual(tools[1].name, 'tool_b'); | |
| }); | |
| test('็ผบๅฐ input_schema ็ๆๅนณๆ ผๅผ', () => { | |
| const tools = convertTools([{ name: 'simple_tool' }]); | |
| assertEqual(tools[0].name, 'simple_tool'); | |
| assert(tools[0].input_schema, 'should have default input_schema'); | |
| assertEqual(tools[0].input_schema.type, 'object'); | |
| }); | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| // 4. ๅข้ๆตๅผๅทฅๅ ท่ฐ็จ้ช่ฏ | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| console.log('\n๐ฆ [4] ๅข้ๆตๅผๅทฅๅ ท่ฐ็จ้ช่ฏ\n'); | |
| test('128 ๅญ่ๅๅ๏ผshort arguments', () => { | |
| const args = '{"path":"src/index.ts"}'; | |
| const CHUNK_SIZE = 128; | |
| const chunks = []; | |
| for (let j = 0; j < args.length; j += CHUNK_SIZE) { | |
| chunks.push(args.slice(j, j + CHUNK_SIZE)); | |
| } | |
| // ็ญๅๆฐๅบไธๅธงๅๅฎ | |
| assertEqual(chunks.length, 1); | |
| assertEqual(chunks[0], args); | |
| }); | |
| test('128 ๅญ่ๅๅ๏ผlong arguments', () => { | |
| const longContent = 'A'.repeat(400); | |
| const args = JSON.stringify({ path: 'test.ts', content: longContent }); | |
| const CHUNK_SIZE = 128; | |
| const chunks = []; | |
| for (let j = 0; j < args.length; j += CHUNK_SIZE) { | |
| chunks.push(args.slice(j, j + CHUNK_SIZE)); | |
| } | |
| // ๆผๆฅๅๅบ็ญไบๅๅงๆฐๆฎ | |
| assertEqual(chunks.join(''), args); | |
| // ๅบๆๅคๅธง | |
| assert(chunks.length > 1, `Expected multiple chunks, got ${chunks.length}`); | |
| // ๆฏๅธงๆๅค 128 ๅญ่ | |
| for (const c of chunks) { | |
| assert(c.length <= CHUNK_SIZE, `Chunk too long: ${c.length}`); | |
| } | |
| }); | |
| test('็ฉบ arguments ้ถๅธง', () => { | |
| const args = ''; | |
| const CHUNK_SIZE = 128; | |
| const chunks = []; | |
| for (let j = 0; j < args.length; j += CHUNK_SIZE) { | |
| chunks.push(args.slice(j, j + CHUNK_SIZE)); | |
| } | |
| assertEqual(chunks.length, 0); | |
| }); | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| // ๆฑๆป | |
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| console.log('\n' + 'โ'.repeat(55)); | |
| console.log(` ็ปๆ: ${passed} ้่ฟ / ${failed} ๅคฑ่ดฅ / ${passed + failed} ๆป่ฎก`); | |
| console.log('โ'.repeat(55) + '\n'); | |
| if (failed > 0) process.exit(1); | |