customer-functional.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import assert from 'node:assert/strict';
  2. import { readFile } from 'node:fs/promises';
  3. // 从浏览器可见页面验证业务行为,不读取组件状态或替换 HTTP 响应。
  4. export async function checkCustomer(page, baseUrl, accounts) {
  5. const results = [];
  6. const content = page.locator('.mobile .content');
  7. async function navigate(target) {
  8. await page.evaluate(value => { window.location.hash = `/customer/${value}`; }, target);
  9. await page.waitForURL(new RegExp(`#/?customer/${target}$`));
  10. await page.locator(`.mobile[data-page="${target}"]`).waitFor();
  11. await page.waitForFunction(() => !document.querySelector('.mobile .content[aria-busy="true"]'));
  12. }
  13. async function click(name) { await content.getByRole('button', { name, exact: true }).click(); }
  14. async function confirm() { await page.getByRole('dialog').getByRole('button', { name: '确认操作', exact: true }).click(); await page.getByRole('dialog').waitFor({ state: 'hidden' }); }
  15. async function expectText(text) { await content.getByText(text, { exact: false }).first().waitFor(); }
  16. await page.goto(`${baseUrl}/#customer/login`);
  17. await content.getByLabel('账户', { exact: true }).fill('customer01');
  18. await content.getByLabel('密码', { exact: true }).fill(`${accounts.password}-invalid`);
  19. await content.getByRole('checkbox').check();
  20. await click('登录并继续');
  21. await content.getByRole('alert').waitFor();
  22. assert.equal(await content.getByLabel('密码', { exact: true }).inputValue(), '');
  23. assert.equal(await content.getByLabel('账户', { exact: true }).inputValue(), 'customer01');
  24. await content.getByLabel('密码', { exact: true }).fill(accounts.password);
  25. await click('登录并继续');
  26. await page.waitForURL(/#\/?customer\/chat$/);
  27. await expectText('已登录');
  28. results.push('H5 真实身份登录与返回咨询');
  29. // 限制真实网络速度,检查旧页面仍在读取时进入新页面的行为,不替换任何服务响应。
  30. await navigate('profile');
  31. const network = await page.context().newCDPSession(page);
  32. await network.send('Network.enable');
  33. await network.send('Network.emulateNetworkConditions', { offline: false, latency: 180, downloadThroughput: 375000, uploadThroughput: 375000 });
  34. try {
  35. const readingChat = page.waitForRequest(request => request.method() === 'GET' && request.url().includes('/openapi/v1/conversations'));
  36. await page.evaluate(() => { location.hash = '/customer/chat'; });
  37. await readingChat;
  38. await navigate('orders');
  39. await content.locator('.order-card').filter({ hasText: accounts.order_id }).waitFor();
  40. } finally {
  41. await network.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
  42. await network.detach();
  43. }
  44. results.push('真实慢速网络中从咨询快速进入订单,订单请求及内容正常');
  45. const pages = ['home','categories','product','cart','profile','messages','address','favorites','settings','chat','orders','order','policy','apply','proposal','ticket','memory','identity','login'];
  46. for (const target of pages) {
  47. await navigate(target);
  48. assert.equal(await content.getByRole('alert').count(), 0, `${target} 页面出现服务错误`);
  49. assert.ok((await page.locator('.mobile-header strong').last().innerText()).length > 0, `${target} 缺少标题`);
  50. for (const width of [390, 1440]) {
  51. await page.setViewportSize({ width, height: 900 });
  52. const geometry = await page.locator('body').evaluate(element => ({ width: document.documentElement.clientWidth, scroll: element.scrollWidth }));
  53. assert.ok(geometry.scroll <= geometry.width + 1, `${target} ${width}px 页面横向溢出`);
  54. }
  55. }
  56. results.push('19 个 H5 页面可访问,390px 与 1440px 宽度无横向溢出');
  57. const catalogResponse = await page.request.get(`${baseUrl}/openapi/v1/stores/${accounts.store_id}/catalog?limit=100`);
  58. assert.equal(catalogResponse.status(), 200);
  59. const catalog = await catalogResponse.json();
  60. await navigate('login');
  61. assert.equal(await content.locator('.customer-login-store strong').innerText(), catalog.store_name);
  62. await navigate('home');
  63. assert.equal(await content.locator('.shop-banner small').innerText(), catalog.store_name);
  64. assert.deepEqual(await content.locator('.goods-info strong').allTextContents(), catalog.items.slice(0, 2).map(item => item.name));
  65. results.push('首页商品、分类与登录店铺名称来自实际目录接口');
  66. await page.locator('.shop-tabs').getByRole('tab', { name: '分类', exact: true }).click();
  67. await page.waitForURL(/#\/?customer\/categories$/);
  68. await page.locator('.shop-tabs').getByRole('tab', { name: '首页', exact: true }).click();
  69. await page.waitForURL(/#\/?customer\/home$/);
  70. await content.locator('.ai-entry').click();
  71. await page.waitForURL(/#\/?customer\/chat$/);
  72. await page.waitForFunction(() => !document.querySelector('.mobile .content[aria-busy="true"]'));
  73. const humanButton = content.getByRole('button', { name: '申请人工协助', exact: true });
  74. if (!await humanButton.isDisabled()) await humanButton.click();
  75. await expectText('等待客服接管');
  76. assert.equal(await content.getByRole('button', { name: '申请人工协助', exact: true }).isDisabled(), true);
  77. await navigate('profile');
  78. await navigate('chat');
  79. await expectText('等待客服接管');
  80. results.push('人工协助真实持久登记、重新进入恢复及重复操作禁用');
  81. // 使用实际客服身份接管会话后验证客户消息,全程不产生生成模型请求。
  82. await navigate('profile');
  83. const conversationResponse = page.waitForResponse(response => response.request().method() === 'GET' && /\/conversations\/[0-9a-f-]+$/.test(new URL(response.url()).pathname));
  84. await navigate('chat');
  85. const conversation = await (await conversationResponse).json();
  86. const staffAuth = await page.request.post(`${baseUrl}/openapi/v1/auth/sessions`, { data: { tenant_id: accounts.tenant_id, username: 'staff01', password: accounts.password } });
  87. assert.equal(staffAuth.status(), 200);
  88. const staffToken = (await staffAuth.json()).access_token;
  89. const staffHeaders = { Authorization: `Bearer ${staffToken}` };
  90. const conversationPath = `${baseUrl}/openapi/v1/conversations/${conversation.conversation_id}`;
  91. let taken = false;
  92. try {
  93. const takeover = await page.request.post(`${conversationPath}/takeover`, { headers: { ...staffHeaders, 'Idempotency-Key': crypto.randomUUID() }, data: { expected_control_version: conversation.control_version } });
  94. assert.equal(takeover.status(), 200, await takeover.text());
  95. taken = true;
  96. await navigate('orders');
  97. await content.locator('.order-card').filter({ hasText: accounts.order_id }).getByRole('button', { name: '联系 AI 客服', exact: true }).click();
  98. await expectText('人工客服正在为你服务');
  99. const question = `请核实当前订单的服务进度 ${Date.now()}`;
  100. await content.getByLabel('补充问题', { exact: true }).fill(question);
  101. const sent = page.waitForResponse(response => response.request().method() === 'POST' && response.url() === `${conversationPath}/messages`);
  102. await click('发送');
  103. const saved = await sent;
  104. assert.equal(saved.status(), 201, await saved.text());
  105. const event = await saved.json();
  106. assert.equal(event.payload.text, `咨询对象:订单 ${accounts.order_id}\n${question}`);
  107. assert.equal(event.payload.run_id, null);
  108. await navigate('profile');
  109. await navigate('chat');
  110. await expectText(question);
  111. results.push('订单咨询上下文和客户问题真实保存,重新进入恢复同一消息');
  112. } finally {
  113. if (taken) {
  114. const latest = await page.request.get(conversationPath, { headers: staffHeaders });
  115. assert.equal(latest.status(), 200);
  116. const release = await page.request.post(`${conversationPath}/release`, { headers: { ...staffHeaders, 'Idempotency-Key': crypto.randomUUID() }, data: { expected_control_version: (await latest.json()).control_version } });
  117. assert.equal(release.status(), 200, await release.text());
  118. }
  119. const logout = await page.request.delete(`${baseUrl}/openapi/v1/auth/sessions/current`, { headers: staffHeaders });
  120. assert.equal(logout.status(), 204);
  121. }
  122. // 开发验收订单可能保留已登记的申请,通过本人确认取消后创建本次独立方案。
  123. let prepared = false;
  124. let cancelled = 0;
  125. for (let attempt = 0; attempt < 100; attempt++) {
  126. await navigate('orders');
  127. const order = content.locator('.order-card').filter({ hasText: accounts.order_id });
  128. await order.getByRole('button', { name: '查看订单', exact: true }).click();
  129. await expectText(accounts.order_id);
  130. await click('填写换货申请');
  131. await page.waitForFunction(() => !document.querySelector('.mobile .content[aria-busy="true"]'));
  132. const createButton = content.getByRole('button', { name: '生成方案并检查', exact: true });
  133. if (await createButton.count() && await createButton.isEnabled()) { prepared = true; break; }
  134. await navigate('proposal');
  135. assert.equal(await content.getByRole('button', { name: '取消申请', exact: true }).isEnabled(), true, '验收订单存在不可取消申请,须准备可创建方案的真实订单');
  136. const cancelling = page.waitForResponse(response => response.request().method() === 'POST' && /\/tickets\/[^/]+\/cancel$/.test(new URL(response.url()).pathname));
  137. await click('取消申请');
  138. await confirm();
  139. const cancelledResponse = await cancelling;
  140. assert.equal(cancelledResponse.status(), 200, await cancelledResponse.text());
  141. assert.equal((await cancelledResponse.json()).status, 'CANCELLED');
  142. cancelled++;
  143. }
  144. assert.equal(prepared, true, '验收订单在限定次数内未进入可创建方案状态');
  145. if (cancelled) results.push(`通过本人确认取消 ${cancelled} 个既有验收申请,准备独立方案`);
  146. await content.getByLabel('申请数量', { exact: true }).fill('1');
  147. await content.getByLabel('故障描述', { exact: true }).fill('键盘空格键无法使用,申请换货。');
  148. const creatingProposal = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === '/openapi/v1/after-sales/proposals');
  149. await click('生成方案并检查');
  150. const proposalResponse = await creatingProposal;
  151. assert.equal(proposalResponse.status(), 201, await proposalResponse.text());
  152. const proposal = await proposalResponse.json();
  153. assert.equal(proposal.proposal_version, 1);
  154. assert.equal(proposal.order_id, accounts.order_id);
  155. await page.waitForURL(/#\/?customer\/proposal$/);
  156. await expectText('待客户确认');
  157. await content.getByRole('checkbox').check();
  158. await click('确认并提交审批');
  159. await page.waitForURL(/#\/?customer\/ticket$/);
  160. await expectText('等待主管审批');
  161. await click('修订方案');
  162. await content.getByLabel('故障描述', { exact: true }).fill('键盘空格键和回车键无法使用,申请换货。');
  163. await click('保存新版本并检查');
  164. await page.waitForURL(/#\/?customer\/proposal$/);
  165. await expectText('方案版本:V2');
  166. await click('取消申请');
  167. await confirm();
  168. await expectText('申请已取消');
  169. assert.equal(await content.getByRole('button', { name: '取消申请', exact: true }).isDisabled(), true);
  170. results.push('真实订单查询、方案创建、本人确认、修订失效及取消');
  171. await navigate('memory');
  172. const authorize = content.getByRole('button', { name: '授权保存', exact: true });
  173. if (await authorize.count()) { await authorize.click(); await confirm(); }
  174. await content.locator('.memory-style-field').getByRole('button').click();
  175. await page.getByRole('menuitem', { name: '详细回复', exact: true }).click();
  176. await content.locator('.memory-language-field').getByRole('button').click();
  177. await page.getByRole('menuitem', { name: 'English', exact: true }).click();
  178. await click('保存沟通习惯');
  179. await expectText('已保存:详细回复 / English');
  180. await navigate('profile');
  181. await navigate('memory');
  182. await expectText('已保存:详细回复 / English');
  183. await click('清空已保存内容');
  184. await confirm();
  185. await expectText('当前没有已保存的沟通习惯');
  186. assert.match(await content.locator('.memory-style-field').innerText(), /简洁回复/);
  187. assert.match(await content.locator('.memory-language-field').innerText(), /中文/);
  188. await click('撤销授权');
  189. await confirm();
  190. await expectText('尚未授权');
  191. results.push('真实沟通习惯授权、保存、清空及撤销');
  192. await navigate('identity');
  193. if (!await content.getByText('Webhook 渠道已关联', { exact: true }).count()) {
  194. const creating = page.waitForResponse(response => response.request().method() === 'POST' && response.url().includes('/me/identity-challenges'));
  195. await click('生成验证代码');
  196. const created = await creating;
  197. if (created.status() === 409) {
  198. assert.equal((await created.json()).code, 'CHANNEL_UNAVAILABLE');
  199. await expectText('渠道尚未启用');
  200. assert.equal(await content.getByRole('button', { name: '确认关联', exact: true }).isDisabled(), true);
  201. results.push('渠道未启用时真实拒绝验证且关联按钮保持禁用');
  202. return results;
  203. }
  204. assert.equal(created.status(), 200, await created.text());
  205. await content.locator('.identity-code').waitFor();
  206. assert.equal(await content.getByRole('button', { name: '确认关联', exact: true }).isDisabled(), true);
  207. const challengeId = await content.locator('.identity-code').innerText();
  208. const credentials = JSON.parse(await readFile(new URL('../../.work/storefront-channel.json', import.meta.url), 'utf8'));
  209. const response = await page.request.post(`${baseUrl}/openapi/v1/auth/sessions`, { data: { tenant_id: credentials.tenant_id, username: credentials.username, password: credentials.password } });
  210. assert.equal(response.status(), 200);
  211. const token = (await response.json()).access_token;
  212. const verified = await page.request.post(`${baseUrl}/openapi/v1/channel/identity-challenges/${challengeId}/verify`, {
  213. headers: { Authorization: `Bearer ${token}` },
  214. data: { store_id: credentials.store_id, channel: 'webhook', external_subject: 'frontend-channel-customer01', display_name: '渠道客户 customer01' },
  215. });
  216. assert.equal(verified.status(), 200, await verified.text());
  217. await click('查询验证结果');
  218. await expectText('渠道客户 customer01');
  219. await click('确认关联');
  220. await confirm();
  221. await page.request.delete(`${baseUrl}/openapi/v1/auth/sessions/current`, { headers: { Authorization: `Bearer ${token}` } });
  222. }
  223. await expectText('Webhook 渠道已关联');
  224. await navigate('profile');
  225. await navigate('identity');
  226. await expectText('Webhook 渠道已关联');
  227. results.push('真实渠道服务验证、本人再次确认及持久关联恢复');
  228. return results;
  229. }