'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 = 'wpy@wpy.email'; 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 { /* 忽略 */ } } })();