customer-responsive.mjs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. import assert from 'node:assert/strict';
  2. import { readFile, writeFile, mkdir } from 'node:fs/promises';
  3. import { pathToFileURL } from 'node:url';
  4. import { chromium } from 'playwright';
  5. import { checkCustomer } from './customer-functional.mjs';
  6. // 读取浏览器公开布局及命中元素,检查被容器裁切的内容和实际可点击范围。
  7. export async function checkCustomerResponsive(page, baseUrl, accounts) {
  8. await checkCustomer(page, baseUrl, accounts);
  9. const pages = ['home','categories','product','cart','profile','messages','address','favorites','settings','chat','login','orders','order','policy','apply','proposal','ticket','memory','identity'];
  10. const sizes = [[320,568],[360,640],[375,667],[390,844],[414,896],[430,932],[490,886],[768,1024],[844,390],[390,420],[1440,900]];
  11. const checks = [], failures = [];
  12. for (const [width, height] of sizes) {
  13. await page.setViewportSize({ width, height });
  14. for (const name of pages) {
  15. await page.evaluate(value => { location.hash = `/customer/${value}`; }, name);
  16. await page.locator(`.mobile[data-page="${name}"]`).waitFor();
  17. await page.waitForFunction(() => !document.querySelector('.content[aria-busy="true"]'));
  18. await page.evaluate(() => window.scrollTo(0, 0));
  19. const issues = await page.locator('.mobile').evaluate(mobile => {
  20. const problems = [], bounds = mobile.getBoundingClientRect();
  21. const visible = node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden';
  22. const label = node => `${node.className}: ${node.textContent.trim().slice(0,45)}`;
  23. if (document.documentElement.scrollWidth > innerWidth + 1) problems.push('页面横向溢出');
  24. 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')) {
  25. if (!visible(node)) continue;
  26. const rect = node.getBoundingClientRect();
  27. if (rect.left < bounds.left - 1 || rect.right > bounds.right + 1) problems.push(`容器裁切 ${label(node)}`);
  28. if (node.matches('.van-button')) {
  29. if (rect.height < 43.5 || rect.width < 43.5) problems.push(`触控不足44px ${label(node)} ${rect.width}×${rect.height}`);
  30. const text = node.querySelector('.van-button__text');
  31. 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)}`); }
  32. }
  33. }
  34. const tabs = mobile.querySelector('.shop-tabs');
  35. if (tabs) { const rect = tabs.getBoundingClientRect(); if (rect.top < 0 || rect.bottom > innerHeight + 1) problems.push('底部导航未显示在视口内'); }
  36. return [...new Set(problems)];
  37. });
  38. checks.push({ page:name, width, height, passed:issues.length === 0 });
  39. if (issues.length) failures.push({ page:name, width, height, issues });
  40. }
  41. await page.evaluate(() => { location.hash='/customer/home'; });
  42. await page.locator('.mobile[data-page="home"]').waitFor();
  43. await page.locator('.mobile .content').evaluate(node => node.scrollTo(0,node.scrollHeight));
  44. const tabs = page.locator('.shop-tabs');
  45. await tabs.getByRole('tab',{name:'分类',exact:true}).click();
  46. await page.locator('.mobile[data-page="categories"]').waitFor();
  47. await page.waitForFunction(() => window.scrollY === 0);
  48. await page.waitForFunction(() => document.querySelector('.mobile .content').scrollTop === 0);
  49. const header = await page.locator('.mobile-header').boundingBox();
  50. if (header.y < 0) failures.push({width,height,issues:['页面切换后标题不可见']});
  51. // 检查视口底部入口的实际命中范围,包含内容滚动后的状态。
  52. for (const selector of ['.shop-tabs [role="tab"]','.ai-float']) {
  53. const uncovered = await page.locator(selector).evaluateAll(nodes => nodes.every(node => {
  54. const rect=node.getBoundingClientRect(), hit=document.elementFromPoint(rect.x+rect.width/2,rect.y+rect.height/2);
  55. return hit && node.contains(hit);
  56. }));
  57. if (!uncovered) failures.push({width,height,issues:[`入口受到遮挡 ${selector}`]});
  58. }
  59. }
  60. const popupChecks = [];
  61. for (const [width,height] of [[320,568],[390,420],[844,390]]) {
  62. await page.setViewportSize({width,height});
  63. await page.evaluate(() => { location.hash='/customer/memory'; });
  64. await page.locator('.mobile[data-page="memory"]').waitFor();
  65. await page.waitForFunction(() => !document.querySelector('.content[aria-busy="true"]'));
  66. const authorize=page.getByRole('button',{name:'授权保存',exact:true});
  67. if (await authorize.count()) {
  68. await authorize.click();
  69. await page.getByRole('dialog').getByRole('button',{name:'确认操作',exact:true}).click();
  70. await page.getByRole('dialog').waitFor({state:'hidden'});
  71. }
  72. await page.getByRole('button',{name:'撤销授权',exact:true}).click();
  73. const dialog=page.getByRole('dialog');
  74. await dialog.waitFor();
  75. 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;});
  76. if (!dialogFits) failures.push({width,height,issues:['确认窗口超出视口']});
  77. await dialog.getByRole('button',{name:'返回检查',exact:true}).click();
  78. await dialog.waitFor({state:'hidden'});
  79. for (const [field,option] of [['.memory-style-field','详细回复'],['.memory-language-field','English']]) {
  80. if (field === '.memory-language-field') await page.locator(field).getByRole('button').evaluate(node => node.scrollIntoView({block:'end'}));
  81. await page.locator(field).getByRole('button').click();
  82. const menu=page.locator(field).getByRole('menu');
  83. await menu.waitFor();
  84. // 等待组件自身进入动画结束后测量最终布局。
  85. await page.waitForFunction(() => !document.querySelector('.van-popup-slide-top-enter-active,.van-popup-slide-bottom-enter-active'));
  86. 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;});
  87. if (!fits) failures.push({width,height,issues:[`下拉菜单超出手机容器 ${field}`]});
  88. await menu.getByRole('menuitem').filter({hasText:option}).click();
  89. await menu.waitFor({state:'hidden'});
  90. }
  91. popupChecks.push({width,height,dialogFits});
  92. }
  93. await page.getByRole('button',{name:'撤销授权',exact:true}).click();
  94. await page.getByRole('dialog').getByRole('button',{name:'确认操作',exact:true}).click();
  95. await page.getByRole('dialog').waitFor({state:'hidden'});
  96. await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive:true });
  97. await writeFile(new URL('../../.work/frontend-verification/customer-responsive.json', import.meta.url), JSON.stringify({ checks, popupChecks, failures, screenshots:0 }, null, 2));
  98. assert.equal(failures.length, 0, `适配问题见 .work/frontend-verification/customer-responsive.json:${JSON.stringify(failures.slice(0,5))}`);
  99. return checks;
  100. }
  101. if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
  102. const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8'));
  103. 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'});
  104. try {
  105. const page = await browser.newPage();
  106. const errors = []; page.on('pageerror', error => errors.push(error.message));
  107. const checks = await checkCustomerResponsive(page, process.env.FRONTEND_URL || 'http://127.0.0.1:18763', accounts);
  108. assert.deepEqual(errors, []);
  109. console.log(JSON.stringify({passed:checks.length,browserErrors:errors}));
  110. } finally { await browser.close(); }
  111. }