import assert from 'node:assert/strict'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import { chromium } from 'playwright'; import { checkCustomer } from './customer-functional.mjs'; // 读取浏览器公开布局及命中元素,检查被容器裁切的内容和实际可点击范围。 export async function checkCustomerResponsive(page, baseUrl, accounts) { await checkCustomer(page, baseUrl, accounts); const pages = ['home','categories','product','cart','profile','messages','address','favorites','settings','chat','login','orders','order','policy','apply','proposal','ticket','memory','identity']; const sizes = [[320,568],[360,640],[375,667],[390,844],[414,896],[430,932],[490,886],[768,1024],[844,390],[390,420],[1440,900]]; const checks = [], failures = []; for (const [width, height] of sizes) { await page.setViewportSize({ width, height }); for (const name of pages) { await page.evaluate(value => { location.hash = `/customer/${value}`; }, name); await page.locator(`.mobile[data-page="${name}"]`).waitFor(); await page.waitForFunction(() => !document.querySelector('.content[aria-busy="true"]')); await page.evaluate(() => window.scrollTo(0, 0)); const issues = await page.locator('.mobile').evaluate(mobile => { const problems = [], bounds = mobile.getBoundingClientRect(); const visible = node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden'; const label = node => `${node.className}: ${node.textContent.trim().slice(0,45)}`; if (document.documentElement.scrollWidth > innerWidth + 1) problems.push('页面横向溢出'); for (const node of mobile.querySelectorAll('.goods-card,.goods-info,.van-button,.van-button__text,.mobile-header strong,.order-product,.van-field__control,.box,.van-tabbar-item,.catalog')) { if (!visible(node)) continue; const rect = node.getBoundingClientRect(); if (rect.left < bounds.left - 1 || rect.right > bounds.right + 1) problems.push(`容器裁切 ${label(node)}`); if (node.matches('.van-button')) { if (rect.height < 43.5 || rect.width < 43.5) problems.push(`触控不足44px ${label(node)} ${rect.width}×${rect.height}`); const text = node.querySelector('.van-button__text'); if (text) { const t = text.getBoundingClientRect(); if (t.top < rect.top - 1 || t.bottom > rect.bottom + 1 || t.left < rect.left - 1 || t.right > rect.right + 1) problems.push(`按钮文字超出 ${label(node)}`); } } } const tabs = mobile.querySelector('.shop-tabs'); if (tabs) { const rect = tabs.getBoundingClientRect(); if (rect.top < 0 || rect.bottom > innerHeight + 1) problems.push('底部导航未显示在视口内'); } return [...new Set(problems)]; }); checks.push({ page:name, width, height, passed:issues.length === 0 }); if (issues.length) failures.push({ page:name, width, height, issues }); } await page.evaluate(() => { location.hash='/customer/home'; }); await page.locator('.mobile[data-page="home"]').waitFor(); await page.locator('.mobile .content').evaluate(node => node.scrollTo(0,node.scrollHeight)); const tabs = page.locator('.shop-tabs'); await tabs.getByRole('tab',{name:'分类',exact:true}).click(); await page.locator('.mobile[data-page="categories"]').waitFor(); await page.waitForFunction(() => window.scrollY === 0); await page.waitForFunction(() => document.querySelector('.mobile .content').scrollTop === 0); const header = await page.locator('.mobile-header').boundingBox(); if (header.y < 0) failures.push({width,height,issues:['页面切换后标题不可见']}); // 检查视口底部入口的实际命中范围,包含内容滚动后的状态。 for (const selector of ['.shop-tabs [role="tab"]','.ai-float']) { const uncovered = await page.locator(selector).evaluateAll(nodes => nodes.every(node => { const rect=node.getBoundingClientRect(), hit=document.elementFromPoint(rect.x+rect.width/2,rect.y+rect.height/2); return hit && node.contains(hit); })); if (!uncovered) failures.push({width,height,issues:[`入口受到遮挡 ${selector}`]}); } } const popupChecks = []; for (const [width,height] of [[320,568],[390,420],[844,390]]) { await page.setViewportSize({width,height}); await page.evaluate(() => { location.hash='/customer/memory'; }); await page.locator('.mobile[data-page="memory"]').waitFor(); await page.waitForFunction(() => !document.querySelector('.content[aria-busy="true"]')); const authorize=page.getByRole('button',{name:'授权保存',exact:true}); if (await authorize.count()) { await authorize.click(); await page.getByRole('dialog').getByRole('button',{name:'确认操作',exact:true}).click(); await page.getByRole('dialog').waitFor({state:'hidden'}); } await page.getByRole('button',{name:'撤销授权',exact:true}).click(); const dialog=page.getByRole('dialog'); await dialog.waitFor(); const dialogFits=await dialog.evaluate(node => {const r=node.getBoundingClientRect(); return r.x>=0 && r.y>=0 && r.right<=innerWidth+1 && r.bottom<=innerHeight+1;}); if (!dialogFits) failures.push({width,height,issues:['确认窗口超出视口']}); await dialog.getByRole('button',{name:'返回检查',exact:true}).click(); await dialog.waitFor({state:'hidden'}); for (const [field,option] of [['.memory-style-field','详细回复'],['.memory-language-field','English']]) { if (field === '.memory-language-field') await page.locator(field).getByRole('button').evaluate(node => node.scrollIntoView({block:'end'})); await page.locator(field).getByRole('button').click(); const menu=page.locator(field).getByRole('menu'); await menu.waitFor(); // 等待组件自身进入动画结束后测量最终布局。 await page.waitForFunction(() => !document.querySelector('.van-popup-slide-top-enter-active,.van-popup-slide-bottom-enter-active')); const fits=await menu.evaluate(node => {const r=node.getBoundingClientRect(),m=document.querySelector('.mobile').getBoundingClientRect(); return r.left>=m.left-1 && r.right<=m.right+1 && r.top>=0 && r.bottom<=innerHeight+1;}); if (!fits) failures.push({width,height,issues:[`下拉菜单超出手机容器 ${field}`]}); await menu.getByRole('menuitem').filter({hasText:option}).click(); await menu.waitFor({state:'hidden'}); } popupChecks.push({width,height,dialogFits}); } await page.getByRole('button',{name:'撤销授权',exact:true}).click(); await page.getByRole('dialog').getByRole('button',{name:'确认操作',exact:true}).click(); await page.getByRole('dialog').waitFor({state:'hidden'}); await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive:true }); await writeFile(new URL('../../.work/frontend-verification/customer-responsive.json', import.meta.url), JSON.stringify({ checks, popupChecks, failures, screenshots:0 }, null, 2)); assert.equal(failures.length, 0, `适配问题见 .work/frontend-verification/customer-responsive.json:${JSON.stringify(failures.slice(0,5))}`); return checks; } if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8')); 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'}); try { const page = await browser.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); const checks = await checkCustomerResponsive(page, process.env.FRONTEND_URL || 'http://127.0.0.1:18763', accounts); assert.deepEqual(errors, []); console.log(JSON.stringify({passed:checks.length,browserErrors:errors})); } finally { await browser.close(); } }