/** * 窄屏实测辅助:连本机共享 Chromium(CDP 127.0.0.1:9222)量真实盒子。 * * 这是**手工脚本**,不进 `npm test` —— 它需要一个跑着的浏览器与一个活的 * Gateway。日常回归靠 `../narrow-layout.test.mjs` 的结构性断言。 * * 两者分工:结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、 * 点下去命中谁」。抽屉遮挡底部导航那个 bug(`elementFromPoint` 命中抽屉里的 * SVG 而不是导航按钮)只有这样才能发现。 * * 用法: * ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs * ADMIN_PW=<密码> node client/electron/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 { readFile } = await import('node:fs/promises'); const { extname, join } = await import('node:path'); const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222'; const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, ''); const DIST = process.env.AGENTMAIL_DIST?.replace(/\/$/, ''); const contentTypes = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp', '.woff2': 'font/woff2' }; 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); // ★ 只把视口调窄**不等于**手机(2026-09-14 用户:"窄屏ui你还没改完")。 // // 桌面 Chromium 里 `(hover: hover) and (pointer: fine)` 仍然为真,于是 // 「悬停才显形的次要动作」在探针里被测成透明 —— 而真机上它们是可见的。 // 实测这类假失败把窄屏验收带偏了方向:我差点去"修"一个本来就对的规则。 // 因此这里开一个**触屏上下文**(hasTouch + isMobile + DPR),让媒体查询 // 与真机一致;只有这样才能测出"看不见却按得动"这类真问题。 const ctx = await browser.newContext({ viewport, hasTouch: true, isMobile: true, deviceScaleFactor: 3, userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Mobile Safari/537.36' }); const page = await ctx.newPage(); 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)); } }); // 在不替换、不重启现有 Gateway 的前提下验证本次构建:仅把页面壳和静态资源 // 从 dist 注入当前标签页,API/SSE 仍由 APP 指向的真实 Gateway 提供。 if (DIST) { const appOrigin = new URL(APP).origin; await page.route(`${appOrigin}/**`, async route => { const url = new URL(route.request().url()); if (url.pathname.startsWith('/api/')) { await route.continue(); return; } const relative = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, ''); const file = join(DIST, relative); if (!file.startsWith(DIST + '/') && file !== join(DIST, 'index.html')) { await route.abort('blockedbyclient'); return; } try { const body = await readFile(file); await route.fulfill({ status: 200, body, contentType: contentTypes[extname(file)] || 'application/octet-stream' }); } catch { await route.continue(); } }); } // 用完只关自己的上下文(CDP 连的是**共享**浏览器,close() 会把别人的标签页一起干掉) // 不能用 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, ctx, 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); } /** * 每个页面是否**有**纵向滚动容器。 * * 判据是「存在 overflow-y:auto|scroll 的容器」,不是「当前正在滚动」—— * 内容暂时不够高时后者为假,但页面是健康的。真正的 bug 是**根本没有**滚动 * 容器:内容一旦超过视口就被 `overflow-hidden` 的父级裁掉,没有任何办法看到。 * * 「我的」页就是这样坏的:内容(资料+权限+改密码+密钥+退出)在 390px 下需要 * 860px,容器只有 795px,超出那 65px 连同「退出登录」按钮一起消失。 * * @returns {{ hasScroller: boolean, scrollers: object[], clipped: object[] }} */ export async function scrollHealth(page) { return await page.evaluate(() => { const root = document.querySelector('#root'); const scrollers = []; for (const el of root.querySelectorAll('*')) { const cs = getComputedStyle(el); if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') { scrollers.push({ cls: (el.className || '').toString().slice(0, 45), scrollH: el.scrollHeight, clientH: el.clientHeight, needsScroll: el.scrollHeight > el.clientHeight + 4 }); } } // 内容超出但被 overflow:hidden 的父级裁掉 —— 这才是真正的问题 const clipped = []; for (const el of root.querySelectorAll('*')) { const cs = getComputedStyle(el); if (el.scrollHeight > el.clientHeight + 20 && cs.overflowY === 'visible') { const p = el.parentElement; const pcs = p ? getComputedStyle(p) : null; if (pcs && (pcs.overflow === 'hidden' || pcs.overflowY === 'hidden')) { clipped.push({ cls: (el.className || '').toString().slice(0, 50), have: el.clientHeight, need: el.scrollHeight }); } } } return { hasScroller: scrollers.length > 0, scrollers, clipped: clipped.slice(0, 4) }; }); }