Files
MailUI4Agents/client/electron/test/manual/narrow-probe-helper.mjs

263 lines
9.6 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 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);
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));
}
});
// 在不替换、不重启现有 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();
}
});
}
// 不能用 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) };
});
}