Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理

This commit is contained in:
WpyQwq
2026-09-19 11:20:43 +08:00
commit c7fb8f8f68
47 changed files with 11573 additions and 0 deletions
+503
View File
@@ -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 { /* 忽略 */ }
}
})();