/** * 窄屏实测辅助:连本机共享 Chromium(CDP 127.0.0.1:9222)量真实盒子。 * * 这是**手工脚本**,不进 `npm test` —— 它需要一个跑着的浏览器与一个活的 * Gateway。日常回归靠 `../narrow-layout.test.mjs` 的结构性断言。 * * 两者分工:结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、 * 点下去命中谁」。抽屉遮挡底部导航那个 bug(`elementFromPoint` 命中抽屉里的 * SVG 而不是导航按钮)只有这样才能发现。 * * 用法: * ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs * ADMIN_PW=<密码> node web/test/manual/wide-regression.mjs * * 环境变量: * ADMIN_PW 必填,管理员密码 * AGENTMAIL_URL 目标地址,默认 https://mail.jianfgit.xyz * CDP_URL 浏览器 CDP 端点,默认 http://127.0.0.1:9222 * PLAYWRIGHT playwright 入口,默认 /usr/lib/node_modules/playwright/index.mjs */ const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs'; const { chromium } = await import(PLAYWRIGHT); const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222'; const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, ''); export const PHONE = { width: 390, height: 844 }; // iPhone 14 Pro export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代 export const WIDE = { width: 1280, height: 800 }; export async function openApp(viewport = PHONE) { const browser = await chromium.connectOverCDP(CDP); const ctx = browser.contexts()[0] ?? (await browser.newContext()); const page = await ctx.newPage(); await page.setViewportSize(viewport); const issues = []; page.on('pageerror', e => issues.push('pageerror: ' + String(e).slice(0, 220))); page.on('console', m => { if (m.type() === 'error') { const t = m.text(); // 401 是未登录时的正常探测,不算问题 if (!t.includes('401')) issues.push('console: ' + t.slice(0, 200)); } }); // 不能用 networkidle:SSE 是一条永不结束的长连接,networkidle 永远不触发 await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForTimeout(2500); // 需要登录时登录(同一 context 共享 cookie,通常只登一次) if ((await page.locator('input[autocomplete=username]').count()) > 0) { if (!process.env.ADMIN_PW) throw new Error('需要 ADMIN_PW 环境变量'); await page.fill('input[autocomplete=username]', process.env.ADMIN_USER || 'admin'); await page.fill('input[type=password]', process.env.ADMIN_PW); await page.click('button:has-text("登录")'); await page.waitForTimeout(3500); } return { browser, page, issues }; } /** 量一个元素的盒子;不存在返回 null。 */ export async function box(page, sel) { const el = page.locator(sel).first(); if ((await el.count()) === 0) return null; return await el.boundingBox(); } /** * 有没有横向溢出 —— 窄屏最常见的毛病。 * * 只报 `right` 超过文档宽度的元素:溢出到左边通常是有意的负 margin。 */ export async function overflowX(page) { return await page.evaluate(() => { const de = document.documentElement; const over = []; for (const el of document.querySelectorAll('*')) { const r = el.getBoundingClientRect(); if (r.width > 0 && r.right > de.clientWidth + 1) { over.push({ tag: el.tagName.toLowerCase(), cls: (el.className || '').toString().slice(0, 70), right: Math.round(r.right), text: (el.textContent || '').trim().slice(0, 40) }); } } return { docWidth: de.clientWidth, scrollWidth: de.scrollWidth, bodyScrollWidth: document.body.scrollWidth, offenders: over.slice(0, 8) }; }); } /** * 点击目标够不够大。 * * 量的是**视觉尺寸**;有 `.tap` 的按钮视觉上仍然很小,命中区在 * `::after` 伪元素上 —— 用 tapTargets() 才能看到真实命中区。 */ export async function smallTargets(page, min = 40) { return await page.evaluate(min => { const bad = []; for (const el of document.querySelectorAll( 'button, a, [role=button], input[type=checkbox]' )) { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; // 隐藏的不算 if (r.height < min || r.width < min) { bad.push({ tag: el.tagName.toLowerCase(), w: Math.round(r.width), h: Math.round(r.height), text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28) }); } } return bad; }, min); } /** * 量 `.tap` 按钮的真实命中区(`::after` 伪元素的尺寸)。 * * @param labels 只看这些文字的按钮 */ export async function tapTargets(page, labels) { return await page.evaluate(labels => { const out = []; for (const b of document.querySelectorAll('button')) { const t = (b.textContent || '').trim(); if (labels.length && !labels.includes(t)) continue; const bb = b.getBoundingClientRect(); if (bb.width === 0) continue; const cs = getComputedStyle(b, '::after'); out.push({ t, visual: `${Math.round(bb.width)}x${Math.round(bb.height)}`, hitW: Math.round(parseFloat(cs.width) || 0), hitH: Math.round(parseFloat(cs.height) || 0) }); } return out; }, labels); } /** * 每个元素点下去是否命中自己。 * * 这是抽屉遮挡 bug 的检测手段:按钮明明在那里、尺寸也够, * 但上面盖了一层 `fixed z-50`,`elementFromPoint` 命中的是别人。 */ export async function hitTest(page, selector) { return await page.evaluate(selector => { const out = []; for (const el of document.querySelectorAll(selector)) { const bb = el.getBoundingClientRect(); if (bb.width === 0) continue; const top = document.elementFromPoint( Math.round(bb.x + bb.width / 2), Math.round(bb.y + bb.height / 2) ); out.push({ text: (el.textContent || '').replace(/\s+/g, '').slice(0, 8), hit: el.contains(top) || el === top }); } return out; }, selector); }