| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219 |
- import assert from 'node:assert/strict';
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
- import { chromium } from 'playwright';
- // 使用真实身份服务检查登录行为;布局通过 DOM 尺寸与命中元素验证。
- const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8'));
- const base = process.env.FRONTEND_URL || 'http://127.0.0.1:18762';
- 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(12000);
- const checks = [], viewportChecks = [], browserErrors = [], modelRequests = [];
- let loginRequests = 0, sessionToken = null;
- page.on('pageerror', error => browserErrors.push(error.message));
- page.on('request', request => {
- const path = new URL(request.url()).pathname;
- if (request.method() === 'POST' && path === '/openapi/v1/auth/sessions') loginRequests++;
- if (request.method() === 'POST' && (/\/conversations\/[^/]+\/messages$/.test(path) || path.startsWith('/openapi/v1/agent/runs'))) modelRequests.push(path);
- });
- const login = page.locator('.mobile[data-page="login"]');
- const account = login.getByLabel('账户', { exact: true });
- const password = login.getByLabel('密码', { exact: true });
- const consent = login.getByRole('checkbox', { name: '确认进入当前商家的客户服务', exact: true });
- const submit = login.getByRole('button', { name: '登录并继续', exact: true });
- async function waitReady() {
- await page.waitForFunction(() => !document.querySelector('.mobile .content[aria-busy="true"]'));
- }
- async function blockedSubmission(message) {
- const before = loginRequests;
- // 禁用按钮与字段校验均须在本地阻止缺失信息的登录请求。
- if (!await submit.isDisabled()) {
- await submit.click();
- await login.getByText(message, { exact: true }).waitFor();
- }
- assert.equal(loginRequests, before, '必填信息或商家确认缺失时不得请求身份服务');
- assert.match(page.url(), /#\/?customer\/login$/);
- }
- async function checkTarget(locator, name, touchContainer = null) {
- await locator.scrollIntoViewIfNeeded();
- const result = await locator.evaluate((node, containerSelector) => {
- const touch = containerSelector ? node.closest(containerSelector) : node;
- const rect = node.getBoundingClientRect(), touchRect = touch.getBoundingClientRect();
- const mobile = node.closest('.mobile').getBoundingClientRect();
- const x = rect.left + rect.width / 2, y = rect.top + rect.height / 2;
- const hit = document.elementFromPoint(x, y);
- return {
- contained: rect.left >= mobile.left - 1 && rect.right <= mobile.right + 1,
- inViewport: x >= 0 && x <= innerWidth && y >= 0 && y <= innerHeight,
- reachable: !!hit && (node === hit || node.contains(hit)),
- touchWidth: touchRect.width,
- touchHeight: touchRect.height,
- };
- }, touchContainer);
- assert.equal(result.contained, true, `${name} 超出手机容器`);
- assert.equal(result.inViewport, true, `${name} 滚动后仍未进入屏幕`);
- assert.equal(result.reachable, true, `${name} 受到其他元素遮挡`);
- assert.ok(result.touchWidth >= 43.5 && result.touchHeight >= 43.5, `${name} 触控区域不足 44px`);
- }
- try {
- // 未登录进入受保护订单页,由产品保存返回入口。
- await page.goto(`${base}/#/customer/orders`);
- await page.waitForURL(/#\/?customer\/login$/);
- await login.waitFor();
- await waitReady();
- assert.equal(await account.inputValue(), '', '首次进入不预填账户');
- assert.equal(await password.inputValue(), '', '首次进入密码为空');
- assert.equal(await password.getAttribute('type'), 'password');
- assert.equal(await consent.count(), 1, '登录表单包含一项商家服务确认');
- assert.ok((await consent.innerText()).includes('确认进入当前商家的客户服务'), '确认说明完整展示');
- assert.equal(await consent.isChecked(), false, '商家服务确认默认关闭');
- assert.equal(await account.getAttribute('autocomplete'), 'username');
- assert.equal(await password.getAttribute('autocomplete'), 'current-password');
- assert.equal(await login.getByRole('button', { name: '显示密码', exact: true }).count(), 1);
- checks.push('未登录访问订单进入登录,字段标签、自动填充属性与默认确认状态正确');
- const catalogResponse = await context.request.get(`${base}/openapi/v1/stores/${accounts.store_id}/catalog?limit=1`);
- assert.equal(catalogResponse.status(), 200, '读取真实店铺资料成功');
- const catalog = await catalogResponse.json();
- assert.ok((await login.innerText()).includes(catalog.store_name), '登录页展示真实店铺名称');
- checks.push('登录页店铺名称与真实目录服务一致');
- await blockedSubmission('请填写账户');
- await account.fill('customer01');
- await blockedSubmission('请填写密码');
- await password.fill(accounts.password);
- await blockedSubmission('请确认当前商家服务');
- assert.equal(loginRequests, 0, '字段校验期间没有发出登录请求');
- checks.push('账户、密码及商家确认校验阻止不完整提交');
- await login.getByRole('button', { name: '显示密码', exact: true }).click();
- assert.equal(await password.getAttribute('type'), 'text');
- assert.ok(await password.inputValue() === accounts.password, '显示密码时保留输入内容');
- await login.getByRole('button', { name: '隐藏密码', exact: true }).click();
- assert.equal(await password.getAttribute('type'), 'password');
- assert.ok(await password.inputValue() === accounts.password, '隐藏密码时保留输入内容');
- checks.push('密码显示和隐藏切换有效且保留当前输入');
- await password.fill(`${accounts.password}-invalid`);
- await consent.check();
- const failedResponse = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === '/openapi/v1/auth/sessions');
- await submit.click();
- assert.equal((await failedResponse).status(), 401, '错误密码由真实身份服务拒绝');
- await login.getByRole('alert').waitFor();
- await waitReady();
- assert.match(page.url(), /#\/?customer\/login$/);
- assert.equal(await account.inputValue(), 'customer01', '失败后保留账户');
- assert.equal(await password.inputValue(), '', '失败后清空密码');
- assert.equal(await password.getAttribute('type'), 'password', '失败后密码恢复隐藏状态');
- assert.equal(await consent.isChecked(), true, '失败后保留当前商家确认');
- assert.ok((await login.getByRole('alert').innerText()).trim().length > 0, '登录失败提示可读取');
- checks.push('错误密码返回 401、展示失败说明、保留账户与确认并清空密码');
- // 在真实失败提示存在时检查宽度和短屏,包含可滚动到每个控件的操作路径。
- 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]]) {
- await page.setViewportSize({ width, height });
- await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
- assert.ok(await page.locator('html').evaluate(node => node.scrollWidth <= node.clientWidth + 1), `${width}×${height} 页面横向溢出`);
- await checkTarget(account, '账户输入', '.van-field');
- await checkTarget(password, '密码输入', '.van-field');
- const visibility = login.getByRole('button', { name: '显示密码', exact: true });
- await checkTarget(visibility, '密码显示按钮');
- await visibility.click();
- assert.equal(await password.getAttribute('type'), 'text');
- await login.getByRole('button', { name: '隐藏密码', exact: true }).click();
- assert.equal(await password.getAttribute('type'), 'password');
- await checkTarget(consent, '商家确认');
- await consent.uncheck();
- assert.equal(await consent.isChecked(), false);
- await consent.check();
- await checkTarget(submit, '登录按钮');
- await login.getByRole('alert').scrollIntoViewIfNeeded();
- assert.equal(await login.getByRole('alert').isVisible(), true);
- viewportChecks.push({ width, height, passed: true });
- }
- checks.push('10 组屏幕尺寸无横向溢出,输入、密码切换、确认与登录按钮均可操作');
- await page.setViewportSize({ width: 390, height: 360 });
- await password.fill(accounts.password);
- await account.focus();
- await page.keyboard.press('Tab');
- assert.equal(await password.evaluate(node => document.activeElement === node), true, 'Tab 从账户进入密码输入');
- await page.keyboard.press('Tab');
- assert.equal(await login.getByRole('button', { name: '显示密码', exact: true }).evaluate(node => document.activeElement === node), true, 'Tab 可进入密码显示按钮');
- await page.keyboard.press('Enter');
- assert.equal(await password.getAttribute('type'), 'text', 'Enter 显示密码');
- await page.keyboard.press('Enter');
- assert.equal(await password.getAttribute('type'), 'password', 'Enter 隐藏密码');
- await page.keyboard.press('Tab');
- assert.equal(await consent.evaluate(node => document.activeElement === node), true, 'Tab 可进入商家确认');
- await page.keyboard.press('Space');
- assert.equal(await consent.isChecked(), false, '空格取消确认');
- await page.keyboard.press('Space');
- assert.equal(await consent.isChecked(), true, '空格完成确认');
- await page.keyboard.press('Tab');
- assert.equal(await submit.evaluate(node => document.activeElement === node), true, 'Tab 可进入登录按钮');
- checks.push('键盘 Tab 顺序、Enter 密码切换及空格商家确认可用');
- // 仅增加真实网络延迟,服务端响应与身份验证保持实际执行。
- const network = await context.newCDPSession(page);
- await network.send('Network.enable');
- await network.send('Network.emulateNetworkConditions', { offline: false, latency: 300, downloadThroughput: -1, uploadThroughput: -1 });
- const successfulResponse = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === '/openapi/v1/auth/sessions').then(async response => {
- if (response.status() === 200) sessionToken = (await response.json()).access_token;
- return response;
- });
- const ordersResponse = page.waitForResponse(response => response.request().method() === 'GET' && new URL(response.url()).pathname === '/openapi/v1/orders');
- try {
- await submit.click();
- const pending = login.getByRole('button', { name: '正在登录', exact: true });
- await pending.waitFor();
- assert.equal(await account.evaluate(node => node.readOnly), true, '提交期间账户只读');
- assert.equal(await password.evaluate(node => node.readOnly), true, '提交期间密码只读');
- assert.equal(await consent.isDisabled(), true, '提交期间商家确认禁用');
- assert.equal(await pending.isDisabled(), true, '提交期间登录按钮禁用');
- await password.press('Enter');
- const auth = await successfulResponse;
- assert.equal(auth.status(), 200, '正确账户完成真实身份认证');
- assert.ok(sessionToken, '成功响应包含当前会话凭据');
- await page.waitForURL(/#\/?customer\/orders$/);
- await page.locator('.mobile[data-page="orders"]').waitFor();
- await waitReady();
- assert.equal((await ordersResponse).status(), 200, '登录后读取本人订单成功');
- assert.equal(loginRequests, 2, '请求期间按 Enter 未重复提交身份认证');
- checks.push('真实网络增加 300ms 延迟时显示加载、限制编辑并阻止 Enter 重复提交');
- } finally {
- await network.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
- await network.detach();
- // 已受理的成功认证由外层清理,即使中途断言终止也保留清理凭据。
- if (loginRequests > 1) await successfulResponse;
- }
- assert.equal(await page.locator('.mobile').getByRole('alert').count(), 0);
- assert.equal(loginRequests, 2, '本次仅执行一次失败登录与一次成功登录');
- checks.push('390×360 短屏完成登录,返回原订单页面并读取真实订单');
- assert.deepEqual(browserErrors, []);
- assert.deepEqual(modelRequests, [], '登录检查不发送生成消息或创建 Agent 运行');
- const logout = await context.request.delete(`${base}/openapi/v1/auth/sessions/current`, { headers: { Authorization: `Bearer ${sessionToken}` } });
- assert.equal(logout.status(), 204, '撤销本次验收会话');
- sessionToken = null;
- await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true });
- 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));
- console.log(JSON.stringify({ passed: checks.length, viewports: viewportChecks.length, loginRequests, generatedModelCalls: 0 }));
- } finally {
- try {
- if (sessionToken) {
- const logout = await context.request.delete(`${base}/openapi/v1/auth/sessions/current`, { headers: { Authorization: `Bearer ${sessionToken}` } });
- assert.equal(logout.status(), 204, '清理本次验收会话');
- }
- } finally {
- await browser.close();
- }
- }
|