customer-login-presentation.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import assert from 'node:assert/strict';
  2. import { readFile, writeFile, mkdir } from 'node:fs/promises';
  3. import { chromium } from 'playwright';
  4. // 使用真实身份服务检查登录行为;布局通过 DOM 尺寸与命中元素验证。
  5. const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8'));
  6. const base = process.env.FRONTEND_URL || 'http://127.0.0.1:18762';
  7. const browser = await chromium.launch({
  8. headless: true,
  9. executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || 'C:/Users/Administrator/AppData/Local/ms-playwright/chromium-1228/chrome-win64/chrome.exe',
  10. });
  11. const context = await browser.newContext({ viewport: { width: 390, height: 844 }, timezoneId: 'Asia/Shanghai' });
  12. const page = await context.newPage();
  13. page.setDefaultTimeout(12000);
  14. const checks = [], viewportChecks = [], browserErrors = [], modelRequests = [];
  15. let loginRequests = 0, sessionToken = null;
  16. page.on('pageerror', error => browserErrors.push(error.message));
  17. page.on('request', request => {
  18. const path = new URL(request.url()).pathname;
  19. if (request.method() === 'POST' && path === '/openapi/v1/auth/sessions') loginRequests++;
  20. if (request.method() === 'POST' && (/\/conversations\/[^/]+\/messages$/.test(path) || path.startsWith('/openapi/v1/agent/runs'))) modelRequests.push(path);
  21. });
  22. const login = page.locator('.mobile[data-page="login"]');
  23. const account = login.getByLabel('账户', { exact: true });
  24. const password = login.getByLabel('密码', { exact: true });
  25. const consent = login.getByRole('checkbox', { name: '确认进入当前商家的客户服务', exact: true });
  26. const submit = login.getByRole('button', { name: '登录并继续', exact: true });
  27. async function waitReady() {
  28. await page.waitForFunction(() => !document.querySelector('.mobile .content[aria-busy="true"]'));
  29. }
  30. async function blockedSubmission(message) {
  31. const before = loginRequests;
  32. // 禁用按钮与字段校验均须在本地阻止缺失信息的登录请求。
  33. if (!await submit.isDisabled()) {
  34. await submit.click();
  35. await login.getByText(message, { exact: true }).waitFor();
  36. }
  37. assert.equal(loginRequests, before, '必填信息或商家确认缺失时不得请求身份服务');
  38. assert.match(page.url(), /#\/?customer\/login$/);
  39. }
  40. async function checkTarget(locator, name, touchContainer = null) {
  41. await locator.scrollIntoViewIfNeeded();
  42. const result = await locator.evaluate((node, containerSelector) => {
  43. const touch = containerSelector ? node.closest(containerSelector) : node;
  44. const rect = node.getBoundingClientRect(), touchRect = touch.getBoundingClientRect();
  45. const mobile = node.closest('.mobile').getBoundingClientRect();
  46. const x = rect.left + rect.width / 2, y = rect.top + rect.height / 2;
  47. const hit = document.elementFromPoint(x, y);
  48. return {
  49. contained: rect.left >= mobile.left - 1 && rect.right <= mobile.right + 1,
  50. inViewport: x >= 0 && x <= innerWidth && y >= 0 && y <= innerHeight,
  51. reachable: !!hit && (node === hit || node.contains(hit)),
  52. touchWidth: touchRect.width,
  53. touchHeight: touchRect.height,
  54. };
  55. }, touchContainer);
  56. assert.equal(result.contained, true, `${name} 超出手机容器`);
  57. assert.equal(result.inViewport, true, `${name} 滚动后仍未进入屏幕`);
  58. assert.equal(result.reachable, true, `${name} 受到其他元素遮挡`);
  59. assert.ok(result.touchWidth >= 43.5 && result.touchHeight >= 43.5, `${name} 触控区域不足 44px`);
  60. }
  61. try {
  62. // 未登录进入受保护订单页,由产品保存返回入口。
  63. await page.goto(`${base}/#/customer/orders`);
  64. await page.waitForURL(/#\/?customer\/login$/);
  65. await login.waitFor();
  66. await waitReady();
  67. assert.equal(await account.inputValue(), '', '首次进入不预填账户');
  68. assert.equal(await password.inputValue(), '', '首次进入密码为空');
  69. assert.equal(await password.getAttribute('type'), 'password');
  70. assert.equal(await consent.count(), 1, '登录表单包含一项商家服务确认');
  71. assert.ok((await consent.innerText()).includes('确认进入当前商家的客户服务'), '确认说明完整展示');
  72. assert.equal(await consent.isChecked(), false, '商家服务确认默认关闭');
  73. assert.equal(await account.getAttribute('autocomplete'), 'username');
  74. assert.equal(await password.getAttribute('autocomplete'), 'current-password');
  75. assert.equal(await login.getByRole('button', { name: '显示密码', exact: true }).count(), 1);
  76. checks.push('未登录访问订单进入登录,字段标签、自动填充属性与默认确认状态正确');
  77. const catalogResponse = await context.request.get(`${base}/openapi/v1/stores/${accounts.store_id}/catalog?limit=1`);
  78. assert.equal(catalogResponse.status(), 200, '读取真实店铺资料成功');
  79. const catalog = await catalogResponse.json();
  80. assert.ok((await login.innerText()).includes(catalog.store_name), '登录页展示真实店铺名称');
  81. checks.push('登录页店铺名称与真实目录服务一致');
  82. await blockedSubmission('请填写账户');
  83. await account.fill('customer01');
  84. await blockedSubmission('请填写密码');
  85. await password.fill(accounts.password);
  86. await blockedSubmission('请确认当前商家服务');
  87. assert.equal(loginRequests, 0, '字段校验期间没有发出登录请求');
  88. checks.push('账户、密码及商家确认校验阻止不完整提交');
  89. await login.getByRole('button', { name: '显示密码', exact: true }).click();
  90. assert.equal(await password.getAttribute('type'), 'text');
  91. assert.ok(await password.inputValue() === accounts.password, '显示密码时保留输入内容');
  92. await login.getByRole('button', { name: '隐藏密码', exact: true }).click();
  93. assert.equal(await password.getAttribute('type'), 'password');
  94. assert.ok(await password.inputValue() === accounts.password, '隐藏密码时保留输入内容');
  95. checks.push('密码显示和隐藏切换有效且保留当前输入');
  96. await password.fill(`${accounts.password}-invalid`);
  97. await consent.check();
  98. const failedResponse = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === '/openapi/v1/auth/sessions');
  99. await submit.click();
  100. assert.equal((await failedResponse).status(), 401, '错误密码由真实身份服务拒绝');
  101. await login.getByRole('alert').waitFor();
  102. await waitReady();
  103. assert.match(page.url(), /#\/?customer\/login$/);
  104. assert.equal(await account.inputValue(), 'customer01', '失败后保留账户');
  105. assert.equal(await password.inputValue(), '', '失败后清空密码');
  106. assert.equal(await password.getAttribute('type'), 'password', '失败后密码恢复隐藏状态');
  107. assert.equal(await consent.isChecked(), true, '失败后保留当前商家确认');
  108. assert.ok((await login.getByRole('alert').innerText()).trim().length > 0, '登录失败提示可读取');
  109. checks.push('错误密码返回 401、展示失败说明、保留账户与确认并清空密码');
  110. // 在真实失败提示存在时检查宽度和短屏,包含可滚动到每个控件的操作路径。
  111. for (const [width, height] of [[320, 568], [360, 640], [375, 667], [390, 844], [414, 896], [430, 932], [768, 1024], [844, 390], [390, 360], [1440, 900]]) {
  112. await page.setViewportSize({ width, height });
  113. await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
  114. assert.ok(await page.locator('html').evaluate(node => node.scrollWidth <= node.clientWidth + 1), `${width}×${height} 页面横向溢出`);
  115. await checkTarget(account, '账户输入', '.van-field');
  116. await checkTarget(password, '密码输入', '.van-field');
  117. const visibility = login.getByRole('button', { name: '显示密码', exact: true });
  118. await checkTarget(visibility, '密码显示按钮');
  119. await visibility.click();
  120. assert.equal(await password.getAttribute('type'), 'text');
  121. await login.getByRole('button', { name: '隐藏密码', exact: true }).click();
  122. assert.equal(await password.getAttribute('type'), 'password');
  123. await checkTarget(consent, '商家确认');
  124. await consent.uncheck();
  125. assert.equal(await consent.isChecked(), false);
  126. await consent.check();
  127. await checkTarget(submit, '登录按钮');
  128. await login.getByRole('alert').scrollIntoViewIfNeeded();
  129. assert.equal(await login.getByRole('alert').isVisible(), true);
  130. viewportChecks.push({ width, height, passed: true });
  131. }
  132. checks.push('10 组屏幕尺寸无横向溢出,输入、密码切换、确认与登录按钮均可操作');
  133. await page.setViewportSize({ width: 390, height: 360 });
  134. await password.fill(accounts.password);
  135. await account.focus();
  136. await page.keyboard.press('Tab');
  137. assert.equal(await password.evaluate(node => document.activeElement === node), true, 'Tab 从账户进入密码输入');
  138. await page.keyboard.press('Tab');
  139. assert.equal(await login.getByRole('button', { name: '显示密码', exact: true }).evaluate(node => document.activeElement === node), true, 'Tab 可进入密码显示按钮');
  140. await page.keyboard.press('Enter');
  141. assert.equal(await password.getAttribute('type'), 'text', 'Enter 显示密码');
  142. await page.keyboard.press('Enter');
  143. assert.equal(await password.getAttribute('type'), 'password', 'Enter 隐藏密码');
  144. await page.keyboard.press('Tab');
  145. assert.equal(await consent.evaluate(node => document.activeElement === node), true, 'Tab 可进入商家确认');
  146. await page.keyboard.press('Space');
  147. assert.equal(await consent.isChecked(), false, '空格取消确认');
  148. await page.keyboard.press('Space');
  149. assert.equal(await consent.isChecked(), true, '空格完成确认');
  150. await page.keyboard.press('Tab');
  151. assert.equal(await submit.evaluate(node => document.activeElement === node), true, 'Tab 可进入登录按钮');
  152. checks.push('键盘 Tab 顺序、Enter 密码切换及空格商家确认可用');
  153. // 仅增加真实网络延迟,服务端响应与身份验证保持实际执行。
  154. const network = await context.newCDPSession(page);
  155. await network.send('Network.enable');
  156. await network.send('Network.emulateNetworkConditions', { offline: false, latency: 300, downloadThroughput: -1, uploadThroughput: -1 });
  157. const successfulResponse = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === '/openapi/v1/auth/sessions').then(async response => {
  158. if (response.status() === 200) sessionToken = (await response.json()).access_token;
  159. return response;
  160. });
  161. const ordersResponse = page.waitForResponse(response => response.request().method() === 'GET' && new URL(response.url()).pathname === '/openapi/v1/orders');
  162. try {
  163. await submit.click();
  164. const pending = login.getByRole('button', { name: '正在登录', exact: true });
  165. await pending.waitFor();
  166. assert.equal(await account.evaluate(node => node.readOnly), true, '提交期间账户只读');
  167. assert.equal(await password.evaluate(node => node.readOnly), true, '提交期间密码只读');
  168. assert.equal(await consent.isDisabled(), true, '提交期间商家确认禁用');
  169. assert.equal(await pending.isDisabled(), true, '提交期间登录按钮禁用');
  170. await password.press('Enter');
  171. const auth = await successfulResponse;
  172. assert.equal(auth.status(), 200, '正确账户完成真实身份认证');
  173. assert.ok(sessionToken, '成功响应包含当前会话凭据');
  174. await page.waitForURL(/#\/?customer\/orders$/);
  175. await page.locator('.mobile[data-page="orders"]').waitFor();
  176. await waitReady();
  177. assert.equal((await ordersResponse).status(), 200, '登录后读取本人订单成功');
  178. assert.equal(loginRequests, 2, '请求期间按 Enter 未重复提交身份认证');
  179. checks.push('真实网络增加 300ms 延迟时显示加载、限制编辑并阻止 Enter 重复提交');
  180. } finally {
  181. await network.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
  182. await network.detach();
  183. // 已受理的成功认证由外层清理,即使中途断言终止也保留清理凭据。
  184. if (loginRequests > 1) await successfulResponse;
  185. }
  186. assert.equal(await page.locator('.mobile').getByRole('alert').count(), 0);
  187. assert.equal(loginRequests, 2, '本次仅执行一次失败登录与一次成功登录');
  188. checks.push('390×360 短屏完成登录,返回原订单页面并读取真实订单');
  189. assert.deepEqual(browserErrors, []);
  190. assert.deepEqual(modelRequests, [], '登录检查不发送生成消息或创建 Agent 运行');
  191. const logout = await context.request.delete(`${base}/openapi/v1/auth/sessions/current`, { headers: { Authorization: `Bearer ${sessionToken}` } });
  192. assert.equal(logout.status(), 204, '撤销本次验收会话');
  193. sessionToken = null;
  194. await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true });
  195. await writeFile(new URL('../../.work/frontend-verification/customer-login-presentation.json', import.meta.url), JSON.stringify({ date: new Date().toISOString(), checks, viewportChecks, loginRequests, networkLatencyMs: 300, browserErrors, generatedModelCalls: 0, screenshots: 0 }, null, 2));
  196. console.log(JSON.stringify({ passed: checks.length, viewports: viewportChecks.length, loginRequests, generatedModelCalls: 0 }));
  197. } finally {
  198. try {
  199. if (sessionToken) {
  200. const logout = await context.request.delete(`${base}/openapi/v1/auth/sessions/current`, { headers: { Authorization: `Bearer ${sessionToken}` } });
  201. assert.equal(logout.status(), 204, '清理本次验收会话');
  202. }
  203. } finally {
  204. await browser.close();
  205. }
  206. }