import assert from 'node:assert/strict'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { chromium } from 'playwright'; // 真实客户与客服在 HUMAN 会话中发送消息;本检查不启动生成模型任务。 const base = process.env.FRONTEND_URL || 'http://127.0.0.1:18762'; const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8')); const platformAccount = JSON.parse(await readFile(new URL('../../.work/platform-accounts.json', import.meta.url), 'utf8')); const 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 }, timezoneId: 'Asia/Shanghai' }); const page = await context.newPage(); page.setDefaultTimeout(15000); const errors = [], checks = [], tokens = []; page.on('pageerror', failure => errors.push(failure.message)); let conversation; async function request(method, path, token, data, expected = 200) { const response = await context.request.fetch(`${base}${path}`, { method, data, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), 'Idempotency-Key': crypto.randomUUID() } }); assert.equal(response.status(), expected, `${method} ${path}: ${await response.text()}`); return expected === 204 ? null : response.json(); } async function loginToken(username) { const result = await request('POST', '/openapi/v1/auth/sessions', null, { tenant_id: accounts.tenant_id, username, password: accounts.password }); tokens.push(result.access_token); return result.access_token; } async function loginUi(product, username, password) { await page.goto(`${base}/#/${product}/login`); if (product === 'merchant') await page.getByLabel('商家标识', { exact: true }).fill(accounts.tenant_id); await page.getByLabel(product === 'customer' ? '账户' : '员工账户', { exact: true }).fill(username); await page.getByLabel('密码', { exact: true }).fill(password); if (product === 'customer') await page.getByRole('checkbox').check(); await page.getByRole('button', { name: product === 'customer' ? '登录并继续' : '登录', exact: true }).click(); await page.waitForURL(url => !url.hash.endsWith('/login')); } async function route(path) { await page.evaluate(value => { location.hash = `/${value}`; }, path); await page.waitForURL(`**/#/${path}`); await page.waitForFunction(() => !document.querySelector('.merchant-loading, .content[aria-busy="true"]')); } async function checkCollapsed(locator) { const content = locator.locator('.expandable-text__content'); await locator.getByRole('button', { name: '展开消息正文', exact: true }).waitFor(); assert.ok((await content.boundingBox()).height <= 241); await locator.getByRole('button', { name: '展开消息正文', exact: true }).click(); assert.ok((await content.boundingBox()).height > 240); assert.equal(await content.evaluate(node => getComputedStyle(node).overflowY), 'hidden'); await locator.getByRole('button', { name: '收起消息正文', exact: true }).click(); assert.ok((await content.boundingBox()).height <= 241); } try { const customer = await loginToken('customer01'); const staff = await loginToken('staff01'); // H5 根据当前服务的 UUID 游标顺序恢复首个会话,验证使用相同公开查询结果。 const conversations = await request('GET', `/openapi/v1/conversations?store_id=${accounts.store_id}&limit=20`, customer); conversation = conversations.items[0] || await request('POST', '/openapi/v1/conversations', customer, { store_id: accounts.store_id }, 201); const control = await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/takeover`, staff, { expected_control_version: conversation.control_version }); const version = control.payload.control_version; const marker = `消息展示检查 ${Date.now()}`; const longMessage = Array.from({ length: 32 }, (_, index) => `${marker} 第${index + 1}项:请保留订单编号并核对商品信息,处理结果会通过当前会话通知。`).join('\n'); for (let index = 0; index < 12; index++) { await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/messages`, staff, { text: index === 2 ? longMessage : `${marker} 客服回复 ${index + 1}`, expected_control_version: version }, 201); } await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/messages`, customer, { text: `${marker} 客户已补充资料`, expected_control_version: version }, 201); const events = await request('GET', `/openapi/v1/conversations/${conversation.conversation_id}/messages?limit=100`, customer); assert.ok(events.items.every(event => event.created_at && Number.isFinite(Date.parse(event.created_at))), '历史消息提供真实时间'); await loginUi('customer', 'customer01', accounts.password); await page.locator('.mobile[data-page="chat"] .message').filter({ hasText: `${marker} 客户已补充资料` }).waitFor(); const h5Long = page.locator('.mobile .message').filter({ hasText: `${marker} 第1项` }); await checkCollapsed(h5Long); const visibleText = await page.locator('.mobile').innerText(); assert.equal(visibleText.includes('control.changed'), false); assert.equal(visibleText.includes(conversation.conversation_id), false); checks.push('H5 真实消息身份、时间、240px正文展开与内部编号隐藏'); const history = page.getByRole('log', { name: '咨询记录', exact: true }); await history.evaluate(node => { node.scrollTop = 0; }); await page.waitForFunction(() => document.querySelector('.customer-chat-history').scrollTop === 0); await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/messages`, staff, { text: `${marker} 新到消息`, expected_control_version: version }, 201); const latest = page.getByRole('button', { name: /查看 \d+ 条新消息/ }); await latest.waitFor(); assert.ok(await history.evaluate(node => node.scrollTop < 2), '新消息保持历史阅读位置'); await latest.click(); assert.ok(await history.evaluate(node => node.scrollHeight - node.scrollTop - node.clientHeight < 2), '返回最新消息'); checks.push('H5 阅读历史时新消息提示与主动返回最新消息'); for (const [width, height] of [[320, 568], [390, 844], [844, 390], [1440, 900]]) { await page.setViewportSize({ width, height }); assert.ok(await page.locator('html').evaluate(node => node.scrollWidth <= node.clientWidth + 1), `${width}px 会话宽度`); assert.ok(await history.evaluate(node => node.clientHeight >= 80), `${width}×${height} 消息阅读区域至少80px`); const send = page.getByRole('button', { name: '发送', exact: true }); await send.scrollIntoViewIfNeeded(); const box = await send.boundingBox(); assert.ok(box.y >= 0 && box.y + box.height <= height + 1, `${width}px 发送操作可访问`); } checks.push('H5 四种视口下长会话与发送操作可访问'); const current = await request('GET', `/openapi/v1/conversations/${conversation.conversation_id}`, customer); assert.equal(current.active_run_id, null, 'HUMAN 消息未创建生成任务'); await page.setViewportSize({ width: 1440, height: 1000 }); await loginUi('merchant', 'staff01', accounts.password); await page.getByRole('button', { name: '我的会话', exact: true }).click(); let selected = false; for (let index = 0; index < 50; index++) { const row = page.locator('.session-row').filter({ hasText: conversation.conversation_id.slice(-8) }); if (await row.count()) { await row.click(); selected = true; break; } const more = page.getByRole('button', { name: '加载更多', exact: true }); assert.equal(await more.count(), 1, '会话分页可以找到本次会话'); const response = page.waitForResponse(item => item.request().method() === 'GET' && item.url().includes('/openapi/v1/conversations?')); await more.click(); assert.equal((await response).status(), 200); } assert.ok(selected); await page.locator('.merchant-pages .message').filter({ hasText: `${marker} 客户已补充资料` }).waitFor(); await checkCollapsed(page.locator('.merchant-pages .message').filter({ hasText: `${marker} 第1项` })); assert.ok(await page.locator('.merchant-pages time').count() > 0, '商家显示真实时间'); checks.push('商家真实队列选择、相同消息正文及展开收起'); await loginUi('platform', platformAccount.username, platformAccount.password); await route('platform/deliveries'); await page.locator('.platform-pages > [role="status"]').waitFor({ state: 'hidden' }); const details = page.locator('.platform-pages').getByRole('button', { name: '查看详情', exact: true }); const detailCount = await details.count(); for (let index = 0; index < Math.min(detailCount, 2); index++) { const row = details.nth(index).locator('xpath=ancestor::tr'); const event = await row.locator('.platform-identifier').innerText(); await details.nth(index).click(); const dialog = page.getByRole('dialog', { name: '投递详情', exact: true }); await dialog.getByText(event, { exact: true }).waitFor(); assert.ok((await dialog.locator('.platform-delivery-detail').boundingBox()).height <= 561); await dialog.getByRole('button', { name: '关闭详情', exact: true }).click(); } if (detailCount) checks.push(`平台从列表查看对应投递详情:${Math.min(detailCount, 2)} 条真实记录`); else { await page.getByText('暂无结果投递记录。', { exact: true }).waitFor(); checks.push('平台投递列表空内容提示;有记录详情另由真实投递专项验证'); } for (const name of ['tenants', 'audit', 'health']) { await route(`platform/${name}`); await page.locator('.platform-pages > [role="status"]').waitFor({ state: 'hidden' }); for (const table of await page.locator('.platform-pages .el-table').all()) { assert.ok((await table.boundingBox()).height <= 561, `${name} 表格高度`); } } checks.push('平台商家、审计及监控表格最高560px'); assert.deepEqual(errors, []); await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true }); await writeFile(new URL('../../.work/frontend-verification/presentation.json', import.meta.url), JSON.stringify({ date: new Date().toISOString(), checks, browserErrors: errors, generatedModelCalls: 0, screenshots: 0 }, null, 2)); console.log(JSON.stringify({ checks, browserErrors: errors, generatedModelCalls: 0 }, null, 2)); } finally { // 接管恢复使用当前版本;本次验证不会提交客户消息给生成模型。 if (conversation && tokens.length > 1) { const current = await request('GET', `/openapi/v1/conversations/${conversation.conversation_id}`, tokens[0]); if (current.control_owner === 'HUMAN') await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/release`, tokens[1], { expected_control_version: current.control_version }); } for (const token of tokens) await request('DELETE', '/openapi/v1/auth/sessions/current', token, undefined, 204); await browser.close(); }