presentation-functional.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. import assert from 'node:assert/strict';
  2. import { readFile, writeFile, mkdir } from 'node:fs/promises';
  3. import { chromium } from 'playwright';
  4. // 真实客户与客服在 HUMAN 会话中发送消息;本检查不启动生成模型任务。
  5. const base = process.env.FRONTEND_URL || 'http://127.0.0.1:18762';
  6. const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8'));
  7. const platformAccount = JSON.parse(await readFile(new URL('../../.work/platform-accounts.json', import.meta.url), 'utf8'));
  8. 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' });
  9. const context = await browser.newContext({ viewport: { width: 390, height: 844 }, timezoneId: 'Asia/Shanghai' });
  10. const page = await context.newPage();
  11. page.setDefaultTimeout(15000);
  12. const errors = [], checks = [], tokens = [];
  13. page.on('pageerror', failure => errors.push(failure.message));
  14. let conversation;
  15. async function request(method, path, token, data, expected = 200) {
  16. const response = await context.request.fetch(`${base}${path}`, { method, data, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), 'Idempotency-Key': crypto.randomUUID() } });
  17. assert.equal(response.status(), expected, `${method} ${path}: ${await response.text()}`);
  18. return expected === 204 ? null : response.json();
  19. }
  20. async function loginToken(username) {
  21. const result = await request('POST', '/openapi/v1/auth/sessions', null, { tenant_id: accounts.tenant_id, username, password: accounts.password });
  22. tokens.push(result.access_token);
  23. return result.access_token;
  24. }
  25. async function loginUi(product, username, password) {
  26. await page.goto(`${base}/#/${product}/login`);
  27. if (product === 'merchant') await page.getByLabel('商家标识', { exact: true }).fill(accounts.tenant_id);
  28. await page.getByLabel(product === 'customer' ? '账户' : '员工账户', { exact: true }).fill(username);
  29. await page.getByLabel('密码', { exact: true }).fill(password);
  30. if (product === 'customer') await page.getByRole('checkbox').check();
  31. await page.getByRole('button', { name: product === 'customer' ? '登录并继续' : '登录', exact: true }).click();
  32. await page.waitForURL(url => !url.hash.endsWith('/login'));
  33. }
  34. async function route(path) {
  35. await page.evaluate(value => { location.hash = `/${value}`; }, path);
  36. await page.waitForURL(`**/#/${path}`);
  37. await page.waitForFunction(() => !document.querySelector('.merchant-loading, .content[aria-busy="true"]'));
  38. }
  39. async function checkCollapsed(locator) {
  40. const content = locator.locator('.expandable-text__content');
  41. await locator.getByRole('button', { name: '展开消息正文', exact: true }).waitFor();
  42. assert.ok((await content.boundingBox()).height <= 241);
  43. await locator.getByRole('button', { name: '展开消息正文', exact: true }).click();
  44. assert.ok((await content.boundingBox()).height > 240);
  45. assert.equal(await content.evaluate(node => getComputedStyle(node).overflowY), 'hidden');
  46. await locator.getByRole('button', { name: '收起消息正文', exact: true }).click();
  47. assert.ok((await content.boundingBox()).height <= 241);
  48. }
  49. try {
  50. const customer = await loginToken('customer01');
  51. const staff = await loginToken('staff01');
  52. // H5 根据当前服务的 UUID 游标顺序恢复首个会话,验证使用相同公开查询结果。
  53. const conversations = await request('GET', `/openapi/v1/conversations?store_id=${accounts.store_id}&limit=20`, customer);
  54. conversation = conversations.items[0] || await request('POST', '/openapi/v1/conversations', customer, { store_id: accounts.store_id }, 201);
  55. const control = await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/takeover`, staff, { expected_control_version: conversation.control_version });
  56. const version = control.payload.control_version;
  57. const marker = `消息展示检查 ${Date.now()}`;
  58. const longMessage = Array.from({ length: 32 }, (_, index) => `${marker} 第${index + 1}项:请保留订单编号并核对商品信息,处理结果会通过当前会话通知。`).join('\n');
  59. for (let index = 0; index < 12; index++) {
  60. await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/messages`, staff, { text: index === 2 ? longMessage : `${marker} 客服回复 ${index + 1}`, expected_control_version: version }, 201);
  61. }
  62. await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/messages`, customer, { text: `${marker} 客户已补充资料`, expected_control_version: version }, 201);
  63. const events = await request('GET', `/openapi/v1/conversations/${conversation.conversation_id}/messages?limit=100`, customer);
  64. assert.ok(events.items.every(event => event.created_at && Number.isFinite(Date.parse(event.created_at))), '历史消息提供真实时间');
  65. await loginUi('customer', 'customer01', accounts.password);
  66. await page.locator('.mobile[data-page="chat"] .message').filter({ hasText: `${marker} 客户已补充资料` }).waitFor();
  67. const h5Long = page.locator('.mobile .message').filter({ hasText: `${marker} 第1项` });
  68. await checkCollapsed(h5Long);
  69. const visibleText = await page.locator('.mobile').innerText();
  70. assert.equal(visibleText.includes('control.changed'), false);
  71. assert.equal(visibleText.includes(conversation.conversation_id), false);
  72. checks.push('H5 真实消息身份、时间、240px正文展开与内部编号隐藏');
  73. const history = page.getByRole('log', { name: '咨询记录', exact: true });
  74. await history.evaluate(node => { node.scrollTop = 0; });
  75. await page.waitForFunction(() => document.querySelector('.customer-chat-history').scrollTop === 0);
  76. await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/messages`, staff, { text: `${marker} 新到消息`, expected_control_version: version }, 201);
  77. const latest = page.getByRole('button', { name: /查看 \d+ 条新消息/ });
  78. await latest.waitFor();
  79. assert.ok(await history.evaluate(node => node.scrollTop < 2), '新消息保持历史阅读位置');
  80. await latest.click();
  81. assert.ok(await history.evaluate(node => node.scrollHeight - node.scrollTop - node.clientHeight < 2), '返回最新消息');
  82. checks.push('H5 阅读历史时新消息提示与主动返回最新消息');
  83. for (const [width, height] of [[320, 568], [390, 844], [844, 390], [1440, 900]]) {
  84. await page.setViewportSize({ width, height });
  85. assert.ok(await page.locator('html').evaluate(node => node.scrollWidth <= node.clientWidth + 1), `${width}px 会话宽度`);
  86. assert.ok(await history.evaluate(node => node.clientHeight >= 80), `${width}×${height} 消息阅读区域至少80px`);
  87. const send = page.getByRole('button', { name: '发送', exact: true });
  88. await send.scrollIntoViewIfNeeded();
  89. const box = await send.boundingBox();
  90. assert.ok(box.y >= 0 && box.y + box.height <= height + 1, `${width}px 发送操作可访问`);
  91. }
  92. checks.push('H5 四种视口下长会话与发送操作可访问');
  93. const current = await request('GET', `/openapi/v1/conversations/${conversation.conversation_id}`, customer);
  94. assert.equal(current.active_run_id, null, 'HUMAN 消息未创建生成任务');
  95. await page.setViewportSize({ width: 1440, height: 1000 });
  96. await loginUi('merchant', 'staff01', accounts.password);
  97. await page.getByRole('button', { name: '我的会话', exact: true }).click();
  98. let selected = false;
  99. for (let index = 0; index < 50; index++) {
  100. const row = page.locator('.session-row').filter({ hasText: conversation.conversation_id.slice(-8) });
  101. if (await row.count()) { await row.click(); selected = true; break; }
  102. const more = page.getByRole('button', { name: '加载更多', exact: true });
  103. assert.equal(await more.count(), 1, '会话分页可以找到本次会话');
  104. const response = page.waitForResponse(item => item.request().method() === 'GET' && item.url().includes('/openapi/v1/conversations?'));
  105. await more.click();
  106. assert.equal((await response).status(), 200);
  107. }
  108. assert.ok(selected);
  109. await page.locator('.merchant-pages .message').filter({ hasText: `${marker} 客户已补充资料` }).waitFor();
  110. await checkCollapsed(page.locator('.merchant-pages .message').filter({ hasText: `${marker} 第1项` }));
  111. assert.ok(await page.locator('.merchant-pages time').count() > 0, '商家显示真实时间');
  112. checks.push('商家真实队列选择、相同消息正文及展开收起');
  113. await loginUi('platform', platformAccount.username, platformAccount.password);
  114. await route('platform/deliveries');
  115. await page.locator('.platform-pages > [role="status"]').waitFor({ state: 'hidden' });
  116. const details = page.locator('.platform-pages').getByRole('button', { name: '查看详情', exact: true });
  117. const detailCount = await details.count();
  118. for (let index = 0; index < Math.min(detailCount, 2); index++) {
  119. const row = details.nth(index).locator('xpath=ancestor::tr');
  120. const event = await row.locator('.platform-identifier').innerText();
  121. await details.nth(index).click();
  122. const dialog = page.getByRole('dialog', { name: '投递详情', exact: true });
  123. await dialog.getByText(event, { exact: true }).waitFor();
  124. assert.ok((await dialog.locator('.platform-delivery-detail').boundingBox()).height <= 561);
  125. await dialog.getByRole('button', { name: '关闭详情', exact: true }).click();
  126. }
  127. if (detailCount) checks.push(`平台从列表查看对应投递详情:${Math.min(detailCount, 2)} 条真实记录`);
  128. else { await page.getByText('暂无结果投递记录。', { exact: true }).waitFor(); checks.push('平台投递列表空内容提示;有记录详情另由真实投递专项验证'); }
  129. for (const name of ['tenants', 'audit', 'health']) {
  130. await route(`platform/${name}`);
  131. await page.locator('.platform-pages > [role="status"]').waitFor({ state: 'hidden' });
  132. for (const table of await page.locator('.platform-pages .el-table').all()) {
  133. assert.ok((await table.boundingBox()).height <= 561, `${name} 表格高度`);
  134. }
  135. }
  136. checks.push('平台商家、审计及监控表格最高560px');
  137. assert.deepEqual(errors, []);
  138. await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true });
  139. 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));
  140. console.log(JSON.stringify({ checks, browserErrors: errors, generatedModelCalls: 0 }, null, 2));
  141. } finally {
  142. // 接管恢复使用当前版本;本次验证不会提交客户消息给生成模型。
  143. if (conversation && tokens.length > 1) {
  144. const current = await request('GET', `/openapi/v1/conversations/${conversation.conversation_id}`, tokens[0]);
  145. if (current.control_owner === 'HUMAN') await request('POST', `/openapi/v1/conversations/${conversation.conversation_id}/release`, tokens[1], { expected_control_version: current.control_version });
  146. }
  147. for (const token of tokens) await request('DELETE', '/openapi/v1/auth/sessions/current', token, undefined, 204);
  148. await browser.close();
  149. }