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

@ -19,7 +19,7 @@
"build:linux": "vite build && electron-builder --linux",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && node test/background.test.mjs && node test/cross-client-theme.test.mjs && node --experimental-strip-types --no-warnings --test test/harmony-logic.test.mjs && node test/packaging.test.mjs && vitest run",
"test": "node test/run-all.mjs && vitest run",
"test:narrow": "node test/manual/narrow-verify.mjs",
"test:wide": "node test/manual/wide-regression.mjs",
"test:components": "vitest run",

View File

@ -10,6 +10,7 @@
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import postcss from 'postcss';
const here = dirname(fileURLToPath(import.meta.url));
const read = p => readFileSync(join(here, p), 'utf8');
@ -213,7 +214,8 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
};
walk(join(here, '../src'));
const PAGE_BASE = new Set(['bg-gray-50', 'bg-slate-100']); // 页面底必须保持全透明
const missing = [...used].filter(c => !PAGE_BASE.has(c) && !gen.includes(`.${c} {`));
const manifest = [...used].filter(c => !PAGE_BASE.has(c));
const missing = manifest.filter(c => !gen.includes(`.${c} {`));
check('源码里用到的浅色表面类全被接管', missing.length === 0, `漏了:${missing.join(', ')}`);
/*
* ★ 生成文件的**头注释不能提前闭合**2026-09-14 修,与上面那条同一根因的两个后果)。
@ -242,6 +244,57 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
check('页面底没有被写进半透明清单', [...PAGE_BASE].every(b => !gen.includes(`.${b} {`)));
check('清单不是空跑(至少扫到 10 个类)', used.size >= 10, `实际 ${used.size}`);
/*
* ★ 生成产物"能被 CSS 解析器吃下"还是"只有浏览器能发现"2026-09-14pi 提议)。
*
* 上面那条查的是**文本**:剥掉注释后以第一条选择器起头。而"注释提前闭合"这个 bug
* 更狠的形态是:那条规则在文本里**还在**,甚至在 postcss 里**也还是一条规则**
* —— 但它的选择器前面粘上了注释的尾巴(当年构建只给一句
* `[WARNING] Unexpected "14"`),于是浏览器按"选择器不匹配任何元素"整条丢掉。
*
* 所以光"能解析"不够实测postcss 对坏产物照样解析出 1 条规则,只是选择器变了),
* 判据要卡的是**选择器的形状** + **规则条数**
* - 每条规则的选择器必须**恰好**是 `html[data-bg='on'] .bg-xxx`(多一个字符都不行);
* - 规则条数必须等于源码里扫到的接管清单条数(多一条少一条都要红)。
* 收获:把"生成器写坏产物"从"只有浏览器能发现"变成"跑判据就红"。
*/
const RULE_SHAPE = /^html\[data-bg='on'\] \.bg-[a-z]+-\d{2,3}$/;
let parsedSelectors = null;
let parseErr = '';
try {
parsedSelectors = postcss
.parse(gen)
.nodes.filter(n => n.type === 'rule')
.map(n => n.selector);
} catch (e) {
parseErr = String(e && e.message ? e.message : e);
}
check('生成的接管 CSS 能被 CSS 解析器读下来', parseErr === '', parseErr);
if (parsedSelectors !== null) {
const malformed = parsedSelectors.filter(s => !RULE_SHAPE.test(s));
check(
'每条接管规则的选择器形状都与生成器一致(防注释尾巴粘进选择器)',
malformed.length === 0,
`异常选择器:${malformed.slice(0, 3).join(' | ')}`
);
check(
'接管规则条数与源码扫到的清单条数一致',
parsedSelectors.length === manifest.length,
`规则 ${parsedSelectors.length} 条 / 清单 ${manifest.length}`
);
// 反向对照:用生成器**当年**的坏写法(注释里带"星号紧接斜杠")验证判据真的会红
const broken = "/* 来自 src/**/*.tsx 的用法 */\nhtml[data-bg='on'] .bg-amber-100 { background: red }";
const brokenSels = postcss
.parse(broken)
.nodes.filter(n => n.type === 'rule')
.map(n => n.selector);
check(
'判据自检:注释提前闭合后,第一条规则的选择器必须被判为异常形状',
brokenSels.length === 0 || brokenSels.some(s => !RULE_SHAPE.test(s)),
`解析出:${brokenSels.join(' | ')}`
);
}
// 18) ★ 导航栏必须**完全不透明**、内容面板必须更通透、整体圆角玻璃化。
//
// 用户 2026-09-14 原话:「导航栏应当完全不透明……没有正文的位置过于不通透,

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;

View File

@ -28,6 +28,8 @@ const uiStore = read('stores', 'uiStore.ts');
const css = read('index.css');
const mailView = read('components', 'MailView.tsx');
const addr = read('components', 'AddressInput.tsx');
// 「授权多选胶囊」现在住在共用组件里MailView 只是用它)
const chip = read('components', 'Composer.tsx');
test('① 导航只剩 通信/日历/联系人 三项(桌面与窄屏都要)', () => {
for (const [name, src] of [['Sidebar', sidebar], ['NarrowNav', narrow]]) {
@ -45,7 +47,17 @@ test('① 导航只剩 通信/日历/联系人 三项(桌面与窄屏都要)
test('① 「通信」的选中态覆盖三个子页签,点击回到上次那个', () => {
assert.match(sidebar, /modes:\s*\['inbox',\s*'sent',\s*'permissions'\]/);
assert.match(narrow, /modes:\s*\['inbox',\s*'sent',\s*'permissions'\]/);
assert.match(sidebar, /setViewMode\(target \|\| commTab/, '桌面点通信要回 commTab');
/*
* 判据要的是**行为**:点「通信」(没有明确 target 时)回到上次那个子页签。
*
* 这里原先钉的是一条字面表达式 `setViewMode(target || commTab`,而代码后来写成了
* `setViewMode(target ?? (isComm ? commTab : modes[0]))` —— 行为一样(点通信回 commTab
* 点日历/联系人落到该项第一个模式),字面却不一样,于是判据红、代码没错。
* 钉字面表达式的判据会在每次等价改写时误报;钉"isComm 时落到 commTab"这个行为不会。
*/
assert.match(sidebar, /setViewMode\([^)]*isComm\s*\?\s*commTab/, '桌面点通信要回 commTab');
const desktopHandler = sidebar.match(/setViewMode\([^)]*isComm\s*\?\s*commTab[^)]*\)/)[0];
assert.match(desktopHandler, /target/, '桌面点非通信项时要有明确的 target否则日历/联系人点不动)');
assert.match(narrow, /setViewMode\(isComm \? commTab : mode\)/, '窄屏点通信要回 commTab');
});
@ -83,12 +95,19 @@ test('② uiStore 记住通信子页签,且 reset 能清掉', () => {
});
test('③ 复选框与地址建议用"控件档"透度(比正文面更透)', () => {
assert.match(mailView, /glass-control/, '授权多选胶囊');
/*
* 判据要的是**那颗胶囊的样式**,不是"MailView.tsx 这个文件里出现过 glass-control"。
*
* 授权多选的选项胶囊后来抽成了共用组件 `ComposerChip`MailView 与 Composer 共用),
* 于是"在 MailView.tsx 里 grep glass-control"必然红 —— 而实际样式是对的
* ComposerChip 的 neutral 未选中态就是 glass-control。文件是会搬的
* 组件不会:所以这里先钉"MailView 用的是 ComposerChip",再钉"ComposerChip 用控件档"。
*/
assert.match(mailView, /import \{[^}]*ComposerChip[^}]*\} from '\.\/Composer'/, 'MailView 要用共用的胶囊组件');
assert.match(mailView, /<ComposerChip/, '授权选项必须是那颗胶囊');
assert.match(chip, /glass-control/, '授权多选胶囊ComposerChip 未选中态)要用控件档');
assert.match(addr, /glass-control/, '地址建议菜单');
// 两处都不能再是 bg-white我的 .bg-white 接管会把它们拉回正文面那一档
const chip = mailView.match(/glass-control[^']*/);
assert.ok(chip, 'chip 类名');
assert.ok(!/glass-control[^']*bg-white/.test(mailView), '不能再同时写 bg-white');
assert.ok(!/glass-control[^']*bg-white/.test(chip), '胶囊不能再同时写 bg-white');
assert.ok(!/glass-control[^"]*bg-white/.test(addr), '建议菜单不能再同时写 bg-white');
// 三档透度必须真的递减(正文 > 嵌套 > 控件)
const num = re => Number(css.match(re)[1]);

View File

@ -0,0 +1,77 @@
/**
* 判据总入口 —— **全部跑完再算退出码**。
*
* 为什么不再用 `&&` 串起来:
*
* 原先 `npm test` 是 `a && b && c …`。这种行为有个不起眼但很贵的后果 ——
* **前面红一条,后面全部不跑**。于是"只红了一条"看起来像"只有一个问题"
* 实际上后面那些判据连跑都没跑(这次就真发生了:`background` 红着,
* `packaging` 从来没跑到过,而它正是能发现"界面改了没重打包"的那条)。
* 换句话说:`&&` 链下的"全绿"是可信的,**"红"是不可信的**。
*
* 现在:每条判据都跑,红的收集起来,最后一起报、一起退出。
*
* 另外两条防"判据自己不会跑"的自检(与 process.exit 之后写判据是同一族问题):
* 1. 清单里的文件必须存在(名字写错 = 静默跳过一条判据);
* 2. `test/` 下的每个 `*.test.mjs` 都必须在清单里
* —— 这次 `cross-client-theme.test.mjs` 就是"写好了但没接进套件"
* 在它进套件之前一直是隐身状态。加了这条,**新增判据忘了接线会直接红**。
*/
import { spawnSync } from 'node:child_process';
import { existsSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
/**
* 判据清单:[文件, 额外 node 参数]。
*
* `--test` 给用 node:test 写的判据;鸿蒙那条要 `--experimental-strip-types`
* 才能直接执行 `client/harmony/.../MailGrouping.ts`(判据跑的是客户端真正引用的那份逻辑)。
*/
const SUITE = [
['test/markdown-xss.test.mjs', []],
['test/narrow-layout.test.mjs', []],
['test/nav-merge.test.mjs', ['--test']],
['test/theme.test.mjs', []],
['test/background.test.mjs', []],
['test/cross-client-theme.test.mjs', ['--test']],
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings', '--test']],
['test/build-stamp.test.mjs', ['--test']],
['test/packaging.test.mjs', []]
];
// 自检 1清单里的文件必须真的存在写错名字 = 那条判据永远不跑)
const ghosts = SUITE.map(([f]) => f).filter((f) => !existsSync(join(ROOT, f)));
// 自检 2test/ 下每个 *.test.mjs 都要在清单里(防"写好了没接线"
const onDisk = readdirSync(join(ROOT, 'test'))
.filter((f) => f.endsWith('.test.mjs'))
.map((f) => `test/${f}`);
const unwired = onDisk.filter((f) => !SUITE.some(([s]) => s === f));
if (ghosts.length || unwired.length) {
if (ghosts.length) console.error(`清单里的判据文件不存在:${ghosts.join('、')}`);
if (unwired.length) {
console.error(`这些判据文件没接进套件(写了却不会跑):${unwired.join('、')}`);
}
process.exit(1);
}
const reds = [];
for (const [file, flags] of SUITE) {
console.log(`\n========== ${file} ==========`);
const r = spawnSync(process.execPath, [...flags, join(ROOT, file)], { stdio: 'inherit' });
// 不 break后面每条都要跑出来否则"红了几条"这个信息本身是假的
if (r.status !== 0) reds.push(`${file}(退出码 ${r.status}`);
}
console.log(`\n========== 判据汇总 ==========`);
if (reds.length === 0) {
console.log(`全部通过(${SUITE.length} 个判据文件:${SUITE.map(([f]) => f.replace('test/', '').replace('.test.mjs', '')).join('、')}`);
process.exit(0);
}
console.error(`红的判据(${reds.length}/${SUITE.length}`);
for (const r of reds) console.error(` - ${r}`);
process.exit(1);