test(webui): 「滑动硬截断」诊断脚本(自带浏览器,不依赖共享实例)

用户:「webui 大片界面存在滑动硬截断」。

## 现状:这一轮我**没能复现**

量法是"内容溢出但没有可滚动祖先"⇒ 候选 0 个;换成用户视角四问
(有没有可滚动容器 / 能不能滚到底 / 末尾有没有被切 / 页面自己溢不溢出)后:

    390×700  通信 ✅ 日历 ✅ 联系人 ✅ 我的 ✅
    1400×700 通信 ✅ 日历 ✅ 联系人 ✅ 我的 ✅
    (造了 12 个会话、正文都很长;验完已归档)

即按这四种量法都测不到硬截断。**长正文详情页**那一块我的探针超时没跑完 ⇒
**未判定**,不能算"没问题"。

## 这次的交付物

`client/electron/test/manual/scroll-cut-verify.mjs`:把上面这套量法固化下来,
并且**自带浏览器**(不连共享 9222)—— 共享实例被历次被中断的运行留下僵尸上下文后,
`connectOverCDP` 会一直超时(我这个会话里已经遇到三次),诊断脚本必须在
"共享实例状态未知"时也能跑。

## 我需要的(否则只能继续猜)

请指一下**具体页面 + 具体手势/位置**:哪一屏、滚轮还是触摸拖动、硬截断出现在
列表末尾还是详情正文?也可以直接看截图。我上一轮"窄屏显示不全"也是同样情况 ——
我量不出来、但你说有,那多半是我量的维度不对。
This commit is contained in:
2026-09-14 13:43:11 +08:00
parent e0c68d8bb2
commit f6feb7c0df
559 changed files with 261 additions and 8 deletions

View File

@ -0,0 +1,100 @@
/**
* 「滑动硬截断」诊断(2026-09-14 用户:「webui 大片界面存在滑动硬截断」)。
*
* 我当时的判据("内容溢出但没有可滚动祖先")量到 0 个候选 —— 说明这个量法
* 抓不到用户看到的现象。所以换成**用户视角**的四问:
* 1. 这个视图里有没有可滚动容器?
* 2. 能不能滚到底(滚到 scrollHeight)?
* 3. 末尾内容有没有被切掉(最后子元素底边 vs 容器底边)?
* 4. 长正文详情页(最容易硬截断的那块)单独量一遍。
*
* 用法:AGENTMAIL_DIST=<dist> ADMIN_USER=.. ADMIN_PW=.. node test/manual/scroll-cut-verify.mjs
* 前置:账号里要有**足够长的内容**,否则量不到溢出(我第一次就栽在这里:
* 收件箱只有 1 封 ⇒ 没有溢出 ⇒ "0 候选"被误当成"没有问题")。
*/
import { chromium } from '/usr/lib/node_modules/playwright/index.mjs';
// ★ 自带浏览器,**不连共享的 9222**。
// 共享实例被历次被中断的运行留下僵尸上下文后,connectOverCDP 会一直超时
// (我这个会话里已经遇到三次)。诊断脚本必须在"共享实例状态未知"时也能跑。
const APP = process.env.AGENTMAIL_APP || 'http://127.0.0.1:8180';
const VIEWS = [
['通信', null],
['日历', '日历'],
['联系人', '联系人'],
['我的', '我的']
];
const results = [];
const record = (n, ok, note) => {
results.push({ n, ok });
console.log(` ${ok ? '通过' : '待查'} ${n}${note ? ' — ' + note : ''}`);
};
const probe = page =>
page.evaluate(() => {
const scrollers = [...document.querySelectorAll('*')].filter(e => {
const oy = getComputedStyle(e).overflowY;
return (oy === 'auto' || oy === 'scroll') && e.scrollHeight > e.clientHeight + 4;
});
scrollers.sort((a, b) => b.scrollHeight - b.clientHeight - (a.scrollHeight - a.clientHeight));
const s = scrollers[0];
if (!s) return { scroller: null, viewportOverflow: document.documentElement.scrollHeight - document.documentElement.clientHeight };
s.scrollTop = s.scrollHeight;
const rect = s.getBoundingClientRect();
const last = s.lastElementChild?.getBoundingClientRect();
return {
cls: s.className.toString().slice(0, 40),
range: s.scrollHeight - s.clientHeight,
reachedEnd: Math.abs(s.scrollTop + s.clientHeight - s.scrollHeight) < 3,
lastChildCutBy: last ? Math.round(last.bottom - rect.bottom) : null
};
});
for (const vp of [{ width: 390, height: 700 }, { width: 1400, height: 700 }]) {
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const ctx = await browser.newContext({ viewport: vp });
const page = await ctx.newPage();
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2200);
if ((await page.locator('input[autocomplete=username]').count()) > 0) {
await page.fill('input[autocomplete=username]', process.env.ADMIN_USER || 'gui-lab');
await page.fill('input[type=password]', process.env.ADMIN_PW || 'gui123456');
await page.click('button:has-text("登录")');
await page.waitForTimeout(3200);
}
console.log(` ── ${vp.width}×${vp.height} ──`);
try {
for (const [label, nav] of VIEWS) {
if (nav) {
await page
.locator(`.nav-rail button:has-text("${nav}"), .narrow-nav button:has-text("${nav}")`)
.first()
.click()
.catch(() => {});
await page.waitForTimeout(1400);
}
const r = await probe(page);
if (!r.scroller) {
// 没有可滚动容器:要么内容本来就不溢出(正常),要么整页被裁(硬截断 → 记待查)
const overflow = r.viewportOverflow ?? 0;
record(`${label}:无可滚动容器时内容本来就不溢出`, overflow <= 0, `页面溢出 ${overflow}px`);
} else {
record(
`${label}:能滚到底且末尾不被切`,
r.reachedEnd && (r.lastChildCutBy ?? 0) <= 1,
`${r.cls} 范围=${r.range} 到底=${r.reachedEnd} 末尾被切=${r.lastChildCutBy}px`
);
}
}
} finally {
await ctx.close();
await browser.close();
}
}
const bad = results.filter(r => !r.ok);
console.log(`\n 滑动硬截断诊断:${results.length - bad.length}/${results.length} 通过`);
if (bad.length) console.log(' 待查:' + bad.map(b => b.n).join('; '));
process.exitCode = bad.length ? 1 : 0;