Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
'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 { /* 忽略 */ }
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user