Files
MailUI4Agents/web/test/manual/narrow-probe-helper.mjs
JianFeeeee 514f443e54 fix: 「我的」页无法滚动 —— 缺滚动容器;顺带修矮屏登录页
用户反馈「我的页面点击进入后无法滑动」。

## 根因:AccountPage 根本没有滚动容器

窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是
`flex-1 min-w-0 flex flex-col`,中间缺一层 `overflow-y-auto` ——
内容超出的部分**直接被裁**,滚不到也点不到。

实测 390px 下内容(资料 + 权限 + 改密码 + 密钥 + 退出)需 860px、容器只有
795px,「退出登录」按钮连同下面 65px 一起消失;1280x800 的桌面上同样看不到。

其余六个页面级组件(AdminUsersPage / MailView / ComposePage / ThreadView /
ContactPanel / MailList)都有这一层,只有它漏了 —— 上一轮把退出登录搬进这页
之后内容变高,问题才显形。

## 顺带:登录页与初始化页在矮屏滚不到底

卡片高约 371px,而 `h-full flex items-center` 在内容超高时让它上下**同时**溢出,
溢出到顶部那段是滚不到的(`scrollTop` 最小是 0)—— 实测 568x280(横屏手机,
或软键盘弹出后的可视高度)下「登录」按钮完全在视口外,光加 `overflow-y-auto`
也够不着。

改用卡片自己的 `my-auto` 而不是容器的 `items-center`:auto margin 在空间不足时
自动退化为 0,矮屏变成正常的顶对齐可滚布局,高屏仍然垂直居中
(390x844 与 1280x800 实测 centered=true,568x280 下滚到底能看到按钮)。

## 两侧都加了检查

- 结构断言:七个页面级组件都必须含 `overflow-y-auto`;
  登录/初始化页必须有 `my-auto` 且不用 `h-full ... items-center`
- 实测脚本新增 `scrollHealth()`:每页都有滚动容器,且没有内容被
  `overflow-hidden` 的父级裁掉

判据刻意是「**有**滚动容器」而不是「当前正在滚动」—— 内容暂时不够高时后者
为假,但页面是健康的;真正的 bug 是根本没有那一层。

## 验证

窄屏实测 18 项 + 宽屏回归 5 项全通过;结构断言从 28 条扩到 37 条。
生产已部署。
2026-09-03 07:39:52 +08:00

221 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 窄屏实测辅助:连本机共享 ChromiumCDP 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));
}
});
// 不能用 networkidleSSE 是一条永不结束的长连接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);
}
/**
* 每个页面是否**有**纵向滚动容器。
*
* 判据是「存在 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) };
});
}