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(); } }