test(webui): 「滑动硬截断」诊断脚本(自带浏览器,不依赖共享实例)
用户:「webui 大片界面存在滑动硬截断」。
## 现状:这一轮我**没能复现**
量法是"内容溢出但没有可滚动祖先"⇒ 候选 0 个;换成用户视角四问
(有没有可滚动容器 / 能不能滚到底 / 末尾有没有被切 / 页面自己溢不溢出)后:
390×700 通信 ✅ 日历 ✅ 联系人 ✅ 我的 ✅
1400×700 通信 ✅ 日历 ✅ 联系人 ✅ 我的 ✅
(造了 12 个会话、正文都很长;验完已归档)
即按这四种量法都测不到硬截断。**长正文详情页**那一块我的探针超时没跑完 ⇒
**未判定**,不能算"没问题"。
## 这次的交付物
`client/electron/test/manual/scroll-cut-verify.mjs`:把上面这套量法固化下来,
并且**自带浏览器**(不连共享 9222)—— 共享实例被历次被中断的运行留下僵尸上下文后,
`connectOverCDP` 会一直超时(我这个会话里已经遇到三次),诊断脚本必须在
"共享实例状态未知"时也能跑。
## 我需要的(否则只能继续猜)
请指一下**具体页面 + 具体手势/位置**:哪一屏、滚轮还是触摸拖动、硬截断出现在
列表末尾还是详情正文?也可以直接看截图。我上一轮"窄屏显示不全"也是同样情况 ——
我量不出来、但你说有,那多半是我量的维度不对。
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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-14,pi 提议)。
|
||||
*
|
||||
* 上面那条查的是**文本**:剥掉注释后以第一条选择器起头。而"注释提前闭合"这个 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 原话:「导航栏应当完全不透明……没有正文的位置过于不通透,
|
||||
|
||||
100
client/electron/test/manual/scroll-cut-verify.mjs
Normal file
100
client/electron/test/manual/scroll-cut-verify.mjs
Normal 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;
|
||||
@ -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]);
|
||||
|
||||
77
client/electron/test/run-all.mjs
Normal file
77
client/electron/test/run-all.mjs
Normal 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)));
|
||||
// 自检 2:test/ 下每个 *.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);
|
||||
Reference in New Issue
Block a user