import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; // 整项最多运行 100 秒;浏览器发送一个问候,单次工作进程共享最多三次模型尝试。 const root = fileURLToPath(new URL('../../', import.meta.url)); const base = (process.env.FRONTEND_URL || 'http://127.0.0.1:18763').replace(/\/$/, ''); const python = fileURLToPath(new URL('../../.venv/Scripts/python.exe', import.meta.url)); const reportUrl = new URL('../../.work/frontend-verification/customer-model-worker.json', import.meta.url); const started = Date.now(); let worker, browser, page, runId, customerToken, prepared = false; const errors = []; const report = { maximumDurationSeconds: 100, maximumProviderAttempts: 3, requestedMessages: 1 }; // 执行阶段最多 85 秒,另外保留 15 秒关闭专项账户与保存检查记录。 const remaining = () => Math.max(1, 85000 - (Date.now() - started)); const deadline = setTimeout(() => { worker?.kill(); browser?.close(); }, 85000); async function helper(args) { return new Promise((resolve, reject) => { worker = spawn(python, ['scripts/check_browser_model.py', ...args], { cwd: root, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env, }); let stderr = ''; worker.stdout.on('data', () => {}); worker.stderr.on('data', chunk => { stderr += chunk.toString(); }); worker.on('error', reject); worker.on('close', code => { worker = null; if (code === 0) resolve(); else reject(new Error(`单次模型工作进程退出:${code}\n${stderr}`)); }); }); } try { assert.ok(process.env.DEEPSEEK_API_KEY, '进程环境必须提供 DEEPSEEK_API_KEY'); await helper(['--preflight', '--prepare-account', '--timeout-seconds', '10']); prepared = true; const accounts = JSON.parse(await readFile(new URL('../../.work/browser-model-account.json', import.meta.url), 'utf8')); browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || 'C:/Users/Administrator/AppData/Local/ms-playwright/chromium-1228/chrome-win64/chrome.exe' }); const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); page = await context.newPage(); page.setDefaultTimeout(12000); page.on('pageerror', error => errors.push(error.message)); const auth = await context.request.post(`${base}/openapi/v1/auth/sessions`, { data: { tenant_id: accounts.tenant_id, username: accounts.username, password: accounts.password }, }); assert.equal(auth.status(), 200); const preparationToken = (await auth.json()).access_token; const created = await context.request.post(`${base}/openapi/v1/conversations`, { data: { store_id: accounts.store_id }, headers: { Authorization: `Bearer ${preparationToken}`, 'Idempotency-Key': crypto.randomUUID() }, }); assert.equal(created.status(), 201); const conversation = await created.json(); assert.equal(conversation.control_owner, 'AGENT'); const preparatoryLogout = await context.request.delete(`${base}/openapi/v1/auth/sessions/current`, { headers: { Authorization: `Bearer ${preparationToken}` }, }); assert.equal(preparatoryLogout.status(), 204); report.conversationId = conversation.conversation_id; await page.goto(`${base}/#/customer/login`); await page.getByLabel('账户', { exact: true }).fill(accounts.username); await page.getByLabel('密码', { exact: true }).fill(accounts.password); await page.getByRole('checkbox', { name: '确认进入当前商家的客户服务' }).click(); const loggedIn = page.waitForResponse(response => response.url().endsWith('/openapi/v1/auth/sessions') && response.request().method() === 'POST'); const selectedConversation = page.waitForResponse(response => response.request().method() === 'GET' && new URL(response.url()).pathname === `/openapi/v1/conversations/${conversation.conversation_id}`); await page.getByRole('button', { name: '登录并继续', exact: true }).click(); const loginResponse = await loggedIn; assert.equal(loginResponse.status(), 200); customerToken = (await loginResponse.json()).access_token; assert.equal((await selectedConversation).status(), 200, '页面读取本次新建会话'); await page.getByLabel('补充问题', { exact: true }).fill('你好,请仅回复一句简短的中文问候。'); const accepted = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === `/openapi/v1/conversations/${conversation.conversation_id}/messages`); await page.getByRole('button', { name: '发送', exact: true }).click(); const submission = await accepted; assert.equal(submission.status(), 201); const event = await submission.json(); runId = event.payload.run_id; assert.ok(runId, '浏览器消息受理返回真实 Agent 运行'); report.runId = runId; await page.getByText('助手正在查询,请稍候。', { exact: false }).waitFor(); assert.equal(await page.getByRole('button', { name: '发送', exact: true }).isDisabled(), true); // 真实前端轮询必须取得本次运行的 assistant 事件,固定欢迎文字不能通过检查。 const answer = page.waitForResponse(async response => { if (response.request().method() !== 'GET' || new URL(response.url()).pathname !== `/openapi/v1/conversations/${conversation.conversation_id}/messages`) return false; if (!response.ok()) return false; return (await response.json()).items.some(item => item.type === 'message.assistant' && item.payload.run_id === runId); }, { timeout: remaining() }); const duration = Math.min(85, Math.floor((remaining() - 5000) / 1000)); assert.ok(duration > 0, '仍有单次模型执行时间'); const [, received] = await Promise.all([ helper(['--run-id', runId, '--conversation-id', conversation.conversation_id, '--report', fileURLToPath(reportUrl), '--timeout-seconds', String(duration)]), answer, ]); const assistant = (await received.json()).items.find(item => item.type === 'message.assistant' && item.payload.run_id === runId); assert.equal(typeof assistant.payload.text, 'string'); assert.ok(assistant.payload.text.trim()); await page.locator('.message').filter({ hasText: assistant.payload.text }).filter({ hasText: 'AI 购物助手' }).waitFor(); await page.getByText('助手正在查询,请稍候。', { exact: false }).waitFor({ state: 'hidden' }); await page.getByLabel('补充问题', { exact: true }).fill('下一条消息保持未提交'); assert.equal(await page.getByRole('button', { name: '发送', exact: true }).isEnabled(), true); const model = JSON.parse(await readFile(reportUrl, 'utf8')); assert.equal(model.run_status, 'COMPLETED'); assert.ok(model.provider_attempts_started >= 1 && model.provider_attempts_started <= 3); assert.ok(model.effective_max_output_tokens <= 1024); assert.equal(model.token_entries.length, model.provider_attempts_started); assert.ok(model.model_attempts.every(item => item.status === 'RECEIVED' && item.usage.total_tokens > 0)); assert.ok(model.token_entries.every(item => item.status === 'SETTLED' && item.usage.total_tokens > 0)); assert.deepEqual(errors, []); report.passed = true; report.assistantAnswer = assistant.payload.text; report.model = model; report.browserErrors = errors; } finally { clearTimeout(deadline); worker?.kill(); try { try { if (runId && customerToken && !report.passed) { // 浏览器关闭后仍通过真实 HTTP 取消本次运行,保留已发生用量。 await fetch(`${base}/openapi/v1/agent/runs/${runId}/cancel`, { method: 'POST', headers: { Authorization: `Bearer ${customerToken}` }, signal: AbortSignal.timeout(3000), }); } } finally { if (prepared) { await helper(['--cleanup-account', '--timeout-seconds', '5']); report.accountCleanup = '已停用专项账户、撤销全部会话并删除本地密码文件;实际用量与业务事件保留'; } report.elapsedSeconds = (Date.now() - started) / 1000; report.browserErrors = errors; await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true }); await writeFile(new URL('../../.work/frontend-verification/customer-model.json', import.meta.url), JSON.stringify(report, null, 2)); } } finally { await browser?.close(); } } console.log(JSON.stringify({ passed: true, providerAttempts: report.model.provider_attempts_started, maxOutputTokens: report.model.effective_max_output_tokens, generatedReplyVisible: true }));