504 lines
24 KiB
JavaScript
504 lines
24 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 界面运行时验收(Edge 无头 + CDP,零依赖)。
|
||
*
|
||
* 为什么需要它:我看不到截图,但「界面能不能跑」是可以程序化验证的 ——
|
||
* 1. 打开页面,收集所有 JS 异常与控制台报错(拼错 id、undefined 属性都会在这里现形)
|
||
* 2. 断言真实数据渲染出来了(文件夹数、邮件行数)
|
||
* 3. 断言设计 token 真的生效(字重 400、画布色、胶囊圆角、零阴影、发丝线)
|
||
* 4. 走一遍交互:选中一封邮件 → 阅读窗格出现正文
|
||
*
|
||
* 用法:node tools/ui-check.js [url]
|
||
*/
|
||
|
||
const { spawn } = require('node:child_process');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
|
||
const URL_ = process.argv[2] || 'http://127.0.0.1:8788/';
|
||
const PORT = 9222;
|
||
const report = [];
|
||
const results = [];
|
||
|
||
function say(s = '') { report.push(s); }
|
||
function step(name, ok, detail = '') {
|
||
results.push({ name, ok, detail });
|
||
report.push(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ' —— ' + detail : ''}`);
|
||
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
|
||
}
|
||
/** 容错执行一步:任何异常都不应该让整个验收崩掉,而是记成一条 FAIL 并继续 */
|
||
async function safeStep(name, fn) {
|
||
try {
|
||
return await fn();
|
||
} catch (err) {
|
||
step(name, false, `${err.name}: ${err.message}`);
|
||
return undefined;
|
||
}
|
||
}
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
function findEdge() {
|
||
const candidates = [
|
||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
];
|
||
return candidates.find((p) => fs.existsSync(p)) || null;
|
||
}
|
||
|
||
async function getTarget(timeoutMs = 20000) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const res = await fetch(`http://127.0.0.1:${PORT}/json/list`);
|
||
const list = await res.json();
|
||
const page = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
|
||
if (page) return page;
|
||
} catch { /* 还没起来 */ }
|
||
await sleep(400);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
class Cdp {
|
||
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.events = []; }
|
||
static async connect(url) {
|
||
const ws = new WebSocket(url);
|
||
await new Promise((resolve, reject) => {
|
||
ws.addEventListener('open', resolve, { once: true });
|
||
ws.addEventListener('error', () => reject(new Error('CDP 连接失败')), { once: true });
|
||
});
|
||
const cdp = new Cdp(ws);
|
||
ws.addEventListener('message', (ev) => {
|
||
let msg;
|
||
try { msg = JSON.parse(ev.data); } catch { return; }
|
||
if (msg.id && cdp.pending.has(msg.id)) {
|
||
const { resolve, reject } = cdp.pending.get(msg.id);
|
||
cdp.pending.delete(msg.id);
|
||
if (msg.error) reject(new Error(msg.error.message));
|
||
else resolve(msg.result);
|
||
} else if (msg.method) {
|
||
cdp.events.push(msg);
|
||
}
|
||
});
|
||
return cdp;
|
||
}
|
||
send(method, params = {}) {
|
||
const id = ++this.id;
|
||
return new Promise((resolve, reject) => {
|
||
this.pending.set(id, { resolve, reject });
|
||
this.ws.send(JSON.stringify({ id, method, params }));
|
||
setTimeout(() => {
|
||
if (this.pending.has(id)) {
|
||
this.pending.delete(id);
|
||
reject(new Error(`${method} 超时`));
|
||
}
|
||
}, 20000);
|
||
});
|
||
}
|
||
async eval(expression) {
|
||
const r = await this.send('Runtime.evaluate', {
|
||
expression, returnByValue: true, awaitPromise: true,
|
||
});
|
||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text || 'evaluate 异常');
|
||
return r.result ? r.result.value : undefined;
|
||
}
|
||
close() { try { this.ws.close(); } catch { /* 忽略 */ } }
|
||
}
|
||
|
||
(async () => {
|
||
const edge = findEdge();
|
||
if (!edge) { process.stderr.write('找不到 msedge.exe\n'); process.exit(2); }
|
||
|
||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-ui-'));
|
||
const child = spawn(edge, [
|
||
'--headless=new',
|
||
`--remote-debugging-port=${PORT}`,
|
||
`--user-data-dir=${profile}`,
|
||
'--no-first-run', '--no-default-browser-check', '--disable-extensions',
|
||
'--window-size=1600,1000',
|
||
URL_,
|
||
], { stdio: 'ignore', detached: false });
|
||
|
||
let cdp = null;
|
||
try {
|
||
const target = await getTarget();
|
||
if (!target) throw new Error('等不到 Edge 的调试目标');
|
||
cdp = await Cdp.connect(target.webSocketDebuggerUrl);
|
||
|
||
const violations = [];
|
||
cdp.ws.addEventListener('message', (ev) => {
|
||
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
|
||
if (msg.method === 'Runtime.exceptionThrown') {
|
||
const d = msg.params.exceptionDetails;
|
||
violations.push('EXCEPTION: ' + (d.exception && d.exception.description ? d.exception.description.split('\n')[0] : d.text));
|
||
}
|
||
if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
|
||
violations.push('console.error: ' + msg.params.args.map((a) => a.value || a.description || '').join(' '));
|
||
}
|
||
if (msg.method === 'Log.entryAdded' && msg.params.entry.level === 'error') {
|
||
violations.push('log: ' + msg.params.entry.text + ' ' + (msg.params.entry.url || ''));
|
||
}
|
||
});
|
||
|
||
await cdp.send('Runtime.enable');
|
||
await cdp.send('Log.enable');
|
||
await cdp.send('Page.enable');
|
||
await cdp.send('Page.navigate', { url: URL_ });
|
||
|
||
// 等界面渲染出邮件行
|
||
let rows = 0;
|
||
for (let i = 0; i < 40; i++) {
|
||
await sleep(500);
|
||
try { rows = await cdp.eval('document.querySelectorAll(".row").length'); } catch { rows = 0; }
|
||
if (rows > 0) break;
|
||
}
|
||
|
||
say('═'.repeat(74));
|
||
say('WpywMail 客户端 · 界面运行时验收(Edge 无头 + CDP)');
|
||
say(`页面:${URL_}`);
|
||
say(`时间:${new Date().toLocaleString('zh-CN')}`);
|
||
say('═'.repeat(74));
|
||
say('');
|
||
|
||
step('页面加载且界面已渲染(无致命错误)', rows > 0, `邮件行 ${rows} 个`);
|
||
|
||
const folders = await cdp.eval('document.querySelectorAll(".rail__item").length');
|
||
step('文件夹栏渲染', folders >= 5, `${folders} 个文件夹`);
|
||
|
||
const title = await cdp.eval('document.getElementById("listTitle").textContent');
|
||
step('列表标题正确', !!title, title);
|
||
|
||
const count = await cdp.eval('document.getElementById("listCount").textContent');
|
||
step('列表计数文案存在', !!count, count);
|
||
|
||
const status = await cdp.eval('document.getElementById("statusText").textContent');
|
||
step('连接状态已显示', !!status, status);
|
||
|
||
const loginHidden = await cdp.eval('document.getElementById("login").hidden');
|
||
step('登录屏已隐藏(已连接)', loginHidden === true);
|
||
|
||
// ── 设计 token 是否真的生效
|
||
say('');
|
||
say('── 设计 token 实测(x.ai 规范)──────────────────────────────');
|
||
const tok = await cdp.eval(`(() => {
|
||
const cs = getComputedStyle(document.body);
|
||
const btn = document.getElementById('btnCompose');
|
||
const bcs = btn ? getComputedStyle(btn) : null;
|
||
const row = document.querySelector('.row');
|
||
const rcs = row ? getComputedStyle(row) : null;
|
||
const rail = document.getElementById('rail');
|
||
const railcs = rail ? getComputedStyle(rail) : null;
|
||
const eyebrow = document.querySelector('.rail__hd');
|
||
const mono = document.querySelector('.mono');
|
||
return {
|
||
bodyWeight: cs.fontWeight,
|
||
synth: cs.fontSynthesis || 'unsupported',
|
||
canvas: cs.backgroundColor,
|
||
bodyFont: cs.fontFamily,
|
||
btnRadius: bcs ? bcs.borderRadius : null,
|
||
btnShadow: bcs ? bcs.boxShadow : null,
|
||
rowBorderBottom: rcs ? rcs.borderBottomWidth + ' ' + rcs.borderBottomColor : null,
|
||
rowShadow: rcs ? rcs.boxShadow : null,
|
||
railBorder: railcs ? railcs.borderRightWidth : null,
|
||
monoFamily: mono ? getComputedStyle(mono).fontFamily : null,
|
||
monoSpacing: mono ? getComputedStyle(mono).letterSpacing : null,
|
||
monoUpper: mono ? getComputedStyle(mono).textTransform : null,
|
||
eyebrowText: eyebrow ? eyebrow.textContent : null,
|
||
};
|
||
})()`);
|
||
|
||
say(` 字重 : ${tok.bodyWeight}`);
|
||
say(` font-synthesis : ${tok.synth}`);
|
||
say(` 画布底色 : ${tok.canvas}`);
|
||
say(` 正文族 : ${String(tok.bodyFont).slice(0, 40)}`);
|
||
say(` 按钮圆角 : ${tok.btnRadius}(规范要求 9999px)`);
|
||
say(` 按钮阴影 : ${tok.btnShadow}(规范要求 none)`);
|
||
say(` 列表行下边框 : ${tok.rowBorderBottom}(发丝线 1px)`);
|
||
say(` 侧栏右分隔 : ${tok.railBorder}`);
|
||
say(` 等宽字体/字距/大写: ${String(tok.monoFamily).slice(0, 24)} / ${tok.monoSpacing} / ${tok.monoUpper}`);
|
||
say(` 眉标文案 : ${tok.eyebrowText}`);
|
||
say('');
|
||
|
||
step('字重全站 400', tok.bodyWeight === '400', tok.bodyWeight);
|
||
step('禁用字体合成(不会出现伪粗体)', tok.synth === 'none', tok.synth);
|
||
step('画布为规范色 #0a0a0a', tok.canvas === 'rgb(10, 10, 10)', tok.canvas);
|
||
step('交互元素为胶囊(9999px)', String(tok.btnRadius) === '9999px', String(tok.btnRadius));
|
||
step('零阴影(层次靠发丝线)', tok.btnShadow === 'none' && tok.rowShadow === 'none',
|
||
`button=${tok.btnShadow} row=${tok.rowShadow}`);
|
||
step('列表行发丝线分隔', /^1px/.test(String(tok.rowBorderBottom)), String(tok.rowBorderBottom));
|
||
step('等宽标签带正字距且大写', String(tok.monoSpacing) === '1.2px' && tok.monoUpper === 'uppercase',
|
||
`${tok.monoSpacing} / ${tok.monoUpper}`);
|
||
|
||
// ── 交互:点第一封邮件
|
||
say('── 交互 ───────────────────────────────────────────────────');
|
||
const clicked = await safeStep('点击列表第一封', () => cdp.eval(`(() => {
|
||
const row = document.querySelector('.row');
|
||
if (!row) return false;
|
||
row.click();
|
||
return true;
|
||
})()`));
|
||
if (!clicked) {
|
||
// 没有列表行 → 把列表容器的真实内容与 toast 文案抓出来,便于定位
|
||
const diag = await cdp.eval(`(() => {
|
||
const b = document.querySelector('.list__body');
|
||
return {
|
||
listBodyHtml: b ? b.innerHTML.slice(0, 400) : '(无 .list__body)',
|
||
toast: (document.getElementById('toastText') || {}).textContent || '',
|
||
skeletons: document.querySelectorAll('.skeleton').length,
|
||
empties: document.querySelectorAll('.empty').length,
|
||
};
|
||
})()`);
|
||
say(' 诊断 ▸ 列表容器内容:' + JSON.stringify(diag.listBodyHtml));
|
||
say(' 诊断 ▸ toast 文案:' + JSON.stringify(diag.toast));
|
||
say(` 诊断 ▸ 骨架屏 ${diag.skeletons} 个 / 空态 ${diag.empties} 个`);
|
||
report.push(`[FAIL] 点击列表第一封 —— 列表里没有 .row(骨架屏 ${diag.skeletons},空态 ${diag.empties})`);
|
||
} else {
|
||
let readerOk = false;
|
||
let subject = '';
|
||
for (let i = 0; i < 24 && !readerOk; i++) {
|
||
await sleep(500);
|
||
const r = await cdp.eval(`(() => {
|
||
const s = document.querySelector('.reader__subject');
|
||
const b = document.querySelector('.reader__body');
|
||
return { subject: s ? s.textContent : '', bodyLen: b ? b.textContent.length : 0 };
|
||
})()`);
|
||
subject = r.subject;
|
||
readerOk = r.bodyLen > 0;
|
||
}
|
||
step('点击列表项后阅读窗格显示正文', readerOk, subject);
|
||
const activeRow = await cdp.eval('document.querySelectorAll(".row.is-active").length');
|
||
step('选中行有 active 状态', activeRow === 1, `${activeRow} 行`);
|
||
}
|
||
|
||
// ── 撰写浮层
|
||
await safeStep('打开撰写浮层', async () => {
|
||
await cdp.eval('document.getElementById("btnCompose").click()');
|
||
await sleep(400);
|
||
const composeOpen = await cdp.eval('document.getElementById("compose").hidden === false');
|
||
const composeFocus = await cdp.eval('document.activeElement && document.activeElement.id');
|
||
step('撰写浮层可打开', composeOpen === true);
|
||
step('撰写时焦点落在收件人', composeFocus === 'c-to' || composeFocus === 'c-subject', String(composeFocus));
|
||
// 用 Esc 关闭(用户真实操作),并验证关闭后焦点被释放、快捷键恢复可用
|
||
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);
|
||
await sleep(400);
|
||
const closed = await cdp.eval('document.getElementById("compose").hidden === true');
|
||
const focusInside = await cdp.eval(`(() => {
|
||
const a = document.activeElement;
|
||
return !!(a && document.getElementById('compose').contains(a));
|
||
})()`);
|
||
step('按 Esc 关闭撰写浮层', closed === true);
|
||
step('关闭后焦点不再滞留在浮层内(否则快捷键会失灵)', focusInside === false,
|
||
focusInside ? '焦点仍在浮层内 —— 快捷键会被「正在输入」判定挡住' : '已释放');
|
||
});
|
||
|
||
// ══════════════════ 第二阶段:真实交互驱动 ══════════════════
|
||
// 上面验的是「渲染与设计」,下面验的是「界面上的动作真的能改变服务器状态」。
|
||
// 全部通过 CDP 派发真实事件(不是直接调内部函数),所以能测到事件绑定与状态同步。
|
||
const token = 'WPYW-UI-' + Date.now().toString().slice(-8);
|
||
say('');
|
||
say('── 交互链路(界面动作 → 服务器状态)─────────────────────────');
|
||
|
||
// 1) 搜索(中文)
|
||
await safeStep('界面搜索(中文)', async () => {
|
||
const before = await cdp.eval('document.querySelectorAll(".row").length');
|
||
await cdp.eval(`(() => {
|
||
const q = document.getElementById('q');
|
||
q.value = '验收';
|
||
q.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||
})()`);
|
||
await sleep(2500);
|
||
const after = await cdp.eval('document.querySelectorAll(".row").length');
|
||
const countText = await cdp.eval('document.getElementById("listCount").textContent');
|
||
step('界面搜索「验收」过滤出结果', after > 0 && after < before, `${before} 行 → ${after} 行(${countText})`);
|
||
await cdp.eval(`(() => {
|
||
document.getElementById('qClear').click();
|
||
})()`);
|
||
await sleep(1800);
|
||
const restored = await cdp.eval('document.querySelectorAll(".row").length');
|
||
step('清除搜索后恢复完整列表', restored === before, `${restored} 行`);
|
||
});
|
||
|
||
// 2) 旗标 / 已读(键盘快捷键)
|
||
await safeStep('键盘旗标与已读', async () => {
|
||
await cdp.eval('document.querySelector(".row").click()');
|
||
await sleep(1200);
|
||
const uid = await cdp.eval('document.querySelector(".row")?.dataset.uid');
|
||
const beforeFlag = await cdp.eval(`document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')`);
|
||
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }))`);
|
||
await sleep(1500);
|
||
const afterFlag = await cdp.eval(`document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')`);
|
||
step('按 S 加旗标后行状态变化', beforeFlag !== afterFlag, `${beforeFlag} → ${afterFlag}(UID ${uid})`);
|
||
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }))`);
|
||
await sleep(1500);
|
||
const restored = await cdp.eval(`document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')`);
|
||
step('再按 S 恢复原状', restored === beforeFlag, `${restored}`);
|
||
});
|
||
|
||
// 3) 回复预填
|
||
await safeStep('回复预填', async () => {
|
||
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', bubbles: true }))`);
|
||
await sleep(600);
|
||
const info = await cdp.eval(`(() => ({
|
||
open: document.getElementById('compose').hidden === false,
|
||
title: document.getElementById('composeTitle').textContent,
|
||
to: document.getElementById('c-to').value,
|
||
subject: document.getElementById('c-subject').value,
|
||
quoted: document.getElementById('c-text').value.includes('原邮件'),
|
||
}))()`);
|
||
step('按 R 打开回复并预填收件人/主题/引用原文',
|
||
info.open && !!info.to && /^Re:/i.test(info.subject) && info.quoted,
|
||
`${info.title} → ${info.subject} | 收件人 ${info.to}`);
|
||
await cdp.eval('document.getElementById("composeClose").click()');
|
||
await sleep(300);
|
||
});
|
||
|
||
// 4) 从界面写一封带附件的信并发送 → 回查到达 → 键盘删除
|
||
await safeStep('界面发信(含附件)', async () => {
|
||
const attachPath = path.join(os.tmpdir(), 'wpyw-ui-附件-测试.txt');
|
||
fs.writeFileSync(attachPath, '这是从客户端界面添加的附件内容。\n中文附件正文。\n', 'utf8');
|
||
|
||
await cdp.send('DOM.enable');
|
||
await cdp.eval('document.getElementById("btnCompose").click()');
|
||
await sleep(400);
|
||
await cdp.eval(`(() => {
|
||
document.getElementById('c-to').value = '[email protected]';
|
||
document.getElementById('c-subject').value = '界面发信验收 ${token} · 中文';
|
||
document.getElementById('c-text').value = '这封是从界面点\"发送\"发出来的。\\n标记:${token}\\n标点:你好,世界。()《》——';
|
||
})()`);
|
||
|
||
// 用 CDP 给 file input 塞真实文件(触发 change → 界面读成 base64)
|
||
const doc = await cdp.send('DOM.getDocument');
|
||
const node = await cdp.send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#c-att' });
|
||
if (node && node.nodeId) {
|
||
await cdp.send('DOM.setFileInputFiles', { files: [attachPath], nodeId: node.nodeId });
|
||
await sleep(800);
|
||
}
|
||
const attInfo = await cdp.eval('document.getElementById("c-status").textContent');
|
||
step('界面能读出所选附件', /附件/.test(attInfo), attInfo || '(无提示)');
|
||
|
||
await cdp.eval('document.getElementById("c-send").click()');
|
||
let toastText = '';
|
||
for (let i = 0; i < 30; i++) {
|
||
await sleep(1000);
|
||
toastText = await cdp.eval('document.getElementById("toastText").textContent');
|
||
if (/已发送|失败/.test(toastText)) break;
|
||
}
|
||
step('界面点发送后提示成功', /已发送/.test(toastText), toastText);
|
||
|
||
// 回查收件箱(走客户端自己的 API)
|
||
let arrived = null;
|
||
for (let i = 0; i < 15 && !arrived; i++) {
|
||
await sleep(2000);
|
||
const res = await fetch(`${URL_.replace(/\/$/, '')}/api/messages?folder=INBOX&limit=10`);
|
||
const data = await res.json();
|
||
arrived = (data.messages || []).find((m) => (m.subject || '').includes(token)) || null;
|
||
}
|
||
step('界面发出的信到达收件箱', !!arrived, arrived ? `UID ${arrived.uid}` : '未找到');
|
||
|
||
if (arrived) {
|
||
const detail = await fetch(`${URL_.replace(/\/$/, '')}/api/messages/${arrived.uid}?folder=INBOX`).then((r) => r.json());
|
||
step('附件经界面链路完整送达(文件名与大小)',
|
||
detail.attachments && detail.attachments.length === 1 && /测试\.txt$/.test(detail.attachments[0].filename),
|
||
detail.attachments && detail.attachments.length ? `${detail.attachments[0].filename} ${detail.attachments[0].size}B` : '无附件');
|
||
step('中文主题与正文经界面链路无损',
|
||
detail.subject.includes(token) && /你好,世界。()《》——/.test(detail.text || ''),
|
||
detail.subject);
|
||
|
||
// 键盘删除(#):把刚发的这封从收件箱移走,顺便验证快捷键
|
||
await cdp.eval('document.getElementById("btnRefresh").click()');
|
||
await sleep(2500);
|
||
await cdp.eval(`(() => {
|
||
const row = [...document.querySelectorAll('.row')].find(r => r.dataset.uid === '${arrived.uid}');
|
||
if (row) row.click();
|
||
})()`);
|
||
await sleep(1200);
|
||
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '#', bubbles: true }))`);
|
||
let gone = false;
|
||
for (let i = 0; i < 15 && !gone; i++) {
|
||
await sleep(1000);
|
||
gone = await cdp.eval(`!document.querySelector('.row[data-uid="${arrived.uid}"]')`);
|
||
}
|
||
step('按 # 删除后该行从列表消失', gone, `UID ${arrived.uid}`);
|
||
|
||
// 清理:把收件箱里这封(已进 Trash)与「已发送」的副本都彻底删掉,不留垃圾
|
||
try {
|
||
const trash = await fetch(`${URL_.replace(/\/$/, '')}/api/messages?folder=Trash&limit=30`).then((r) => r.json());
|
||
const inTrash = (trash.messages || []).filter((m) => (m.subject || '').includes(token));
|
||
for (const m of inTrash) {
|
||
await fetch(`${URL_.replace(/\/$/, '')}/api/delete`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ uid: m.uid, folder: 'Trash', permanent: true }),
|
||
});
|
||
}
|
||
const sent = await fetch(`${URL_.replace(/\/$/, '')}/api/messages?folder=Sent&limit=30`).then((r) => r.json());
|
||
const inSent = (sent.messages || []).filter((m) => (m.subject || '').includes(token));
|
||
for (const m of inSent) {
|
||
await fetch(`${URL_.replace(/\/$/, '')}/api/delete`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ uid: m.uid, folder: 'Sent', permanent: true }),
|
||
});
|
||
}
|
||
step('清理测试痕迹', true, `Trash ${inTrash.length} 封 / Sent ${inSent.length} 封已彻底删除`);
|
||
} catch (err) {
|
||
step('清理测试痕迹', false, err.message);
|
||
}
|
||
}
|
||
try { fs.rmSync(attachPath, { force: true }); } catch { /* 忽略 */ }
|
||
});
|
||
|
||
// ── 横向溢出(踩过的坑)
|
||
const overflow = await cdp.eval(`(() => {
|
||
const de = document.documentElement;
|
||
return { scrollW: de.scrollWidth, clientW: de.clientWidth };
|
||
})()`);
|
||
step('无横向溢出', overflow.scrollW <= overflow.clientW + 1,
|
||
`scrollWidth=${overflow.scrollW} clientWidth=${overflow.clientW}`);
|
||
|
||
// ── 报错汇总
|
||
say('');
|
||
say('── 运行时报错 ─────────────────────────────────────────────');
|
||
const real = violations.filter((v) => !/favicon/i.test(v));
|
||
if (real.length === 0) say(' 无');
|
||
else real.slice(0, 20).forEach((v) => say(' ' + v));
|
||
step('页面无 JS 异常与控制台报错', real.length === 0, real.length ? `${real.length} 条` : '无');
|
||
|
||
const pass = results.filter((r) => r.ok).length;
|
||
const fail = results.length - pass;
|
||
say('');
|
||
say('═'.repeat(74));
|
||
say(`汇总:通过 ${pass} / ${results.length},失败 ${fail}`);
|
||
if (fail) for (const r of results.filter((x) => !x.ok)) say(` - ${r.name} ${r.detail}`);
|
||
say('═'.repeat(74));
|
||
|
||
fs.writeFileSync('E:\\deepseek\\artifacts\\client-ui-check.txt', report.join('\n'), 'utf8');
|
||
process.stdout.write(`\nREPORT E:\\deepseek\\artifacts\\client-ui-check.txt\nSUMMARY pass=${pass} fail=${fail}\n`);
|
||
process.exitCode = fail ? 1 : 0;
|
||
} catch (err) {
|
||
process.stderr.write('FATAL ' + (err && err.stack ? err.stack : err) + '\n');
|
||
step('验收脚本自身异常(不代表界面有问题)', false, String(err && err.message));
|
||
process.exitCode = 2;
|
||
} finally {
|
||
if (cdp) cdp.close();
|
||
try { child.kill(); } catch { /* 忽略 */ }
|
||
await sleep(500);
|
||
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ }
|
||
// 无论如何都把报告落盘 —— 崩溃时更需要它
|
||
try {
|
||
if (report.length) {
|
||
const pass = results.filter((r) => r.ok).length;
|
||
const fail = results.length - pass;
|
||
report.push('');
|
||
report.push('═'.repeat(74));
|
||
report.push(`汇总:通过 ${pass} / ${results.length},失败 ${fail}`);
|
||
for (const r of results.filter((x) => !x.ok)) report.push(` - ${r.name} ${r.detail}`);
|
||
report.push('═'.repeat(74));
|
||
fs.mkdirSync('E:\\deepseek\\artifacts', { recursive: true });
|
||
fs.writeFileSync('E:\\deepseek\\artifacts\\client-ui-check.txt', report.join('\n'), 'utf8');
|
||
process.stdout.write(`\nREPORT E:\\deepseek\\artifacts\\client-ui-check.txt\nSUMMARY pass=${pass} fail=${fail}\n`);
|
||
}
|
||
} catch { /* 忽略 */ }
|
||
}
|
||
})();
|