'use strict'; /** * 界面运行时验收(新界面:shadcn/ui + Tailwind + Vite)。 * * 验的是「界面真的能用」而不是「代码看起来对」: * 1. 打开页面,收集所有 JS 异常与控制台报错 * 2. 断言真实数据渲染(文件夹、邮件行) * 3. 走一遍交互:点邮件 → 阅读区出主题与正文;打开撰写 → 弹窗与字段就位 * 4. 断言主题切换与暗色是生效的(对比 body 背景色) * * 用法: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 = 9224; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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`); } async function safeStep(name, fn) { try { return await fn(); } catch (err) { step(name, false, `${err.name}: ${err.message}`); return undefined; } } class Cdp { constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.errors = []; } static async connect(url) { const ws = new WebSocket(url); await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', () => rej(new Error('CDP 连接失败')), { once: true }); }); const c = new Cdp(ws); ws.addEventListener('message', (ev) => { let m; try { m = JSON.parse(ev.data); } catch { return; } if (m.id && c.pending.has(m.id)) { const { resolve, reject } = c.pending.get(m.id); c.pending.delete(m.id); if (m.error) reject(new Error(m.error.message)); else resolve(m.result); } else if (m.method === 'Runtime.exceptionThrown') { const d = m.params.exceptionDetails; c.errors.push('EXCEPTION: ' + (d.exception && d.exception.description ? d.exception.description.split('\n')[0] : d.text)); } else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') { c.errors.push('console.error: ' + m.params.args.map((a) => a.value || a.description || '').join(' ')); } else if (m.method === 'Log.entryAdded' && m.params.entry.level === 'error') { c.errors.push('log: ' + m.params.entry.text); } }); return c; } 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; } } (async () => { const edge = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'].find((p) => fs.existsSync(p)); if (!edge) { process.stderr.write('找不到 msedge.exe\n'); process.exit(2); } const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-ui2-')); 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=1440,900', URL_], { stdio: 'ignore' }); let cdp = null; try { let target = null; for (let i = 0; i < 50 && !target; i++) { await sleep(400); try { const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json(); target = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl); } catch { /* 等 */ } } if (!target) throw new Error('等不到 Edge 调试目标'); cdp = await Cdp.connect(target.webSocketDebuggerUrl); await cdp.send('Runtime.enable'); await cdp.send('Log.enable'); await cdp.send('Page.enable'); await cdp.send('Page.navigate', { url: URL_ }); say('═'.repeat(74)); say('WpywMail 客户端界面验收(shadcn/ui 版)'); say(`页面:${URL_}`); say(`时间:${new Date().toLocaleString('zh-CN')}`); say('═'.repeat(74)); say(''); // 等列表渲染 let rows = 0; for (let i = 0; i < 50; i++) { await sleep(500); rows = await cdp.eval('document.querySelectorAll("[data-uid]").length'); if (rows > 0) break; } step('页面加载并渲染出邮件列表', rows > 0, `${rows} 行`); const shell = await cdp.eval(`(() => ({ aside: !!document.querySelector('aside'), folderButtons: document.querySelectorAll('aside nav button').length, listHeader: (document.querySelectorAll('h2')[0] || {}).textContent || '', countText: [...document.querySelectorAll('span')].map(s => s.textContent).find(t => /^\\d+ 封$/.test(t || '')) || '', displaySubject: (document.querySelector('h1') || {}).textContent || '', bg: getComputedStyle(document.body).backgroundColor, }))()`); step('侧栏与文件夹渲染', shell.aside && shell.folderButtons >= 5, `${shell.folderButtons} 个文件夹按钮`); step('列表头与计数渲染', !!shell.listHeader, `${shell.listHeader} · ${shell.countText}`); // 点第一封 await safeStep('点击邮件', async () => { await cdp.eval('document.querySelector("[data-uid]").click()'); let ok = false; let subject = ''; let detail = ''; for (let i = 0; i < 30 && !ok; i++) { await sleep(500); const r = await cdp.eval(`(() => { const h1 = document.querySelector('h1'); const body = document.querySelector('.mail-body'); const frame = document.querySelector('#html-body'); return { subject: h1 ? h1.textContent : '', bodyLen: body ? body.textContent.length : 0, frameLen: frame && frame.getAttribute('srcdoc') ? frame.getAttribute('srcdoc').length : 0, }; })()`); subject = r.subject; detail = `纯文本 ${r.bodyLen} 字 / 富文本 iframe ${r.frameLen} 字节`; // 有 HTML 正文时阅读区用沙箱 iframe 渲染(见 ui-check-v4),所以两种形态都算通过 ok = r.bodyLen > 0 || r.frameLen > 0; } step('点击后阅读区显示主题与正文', ok, `${subject}(${detail})`); }); // 滚动:在列表上滚滚轮,整页必须纹丝不动、列表自己滚 await safeStep('滚动隔离', async () => { const page = await cdp.eval(`({ scrollH: document.documentElement.scrollHeight, innerH: window.innerHeight, bodyH: document.body.scrollHeight, })`); step('页面本身不产生滚动条(滚动只发生在栏内)', page.scrollH <= page.innerH + 1 && page.bodyH <= page.innerH + 1, `doc=${page.scrollH} body=${page.bodyH} 视口=${page.innerH}`); const box = await cdp.eval(`(() => { const el = document.querySelector('.scroll-pane'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), canScroll: el.scrollHeight > el.clientHeight, sh: el.scrollHeight, ch: el.clientHeight }; })()`); if (!box) { step('找到列表滚动容器', false, '没有 .scroll-pane'); return; } step('列表容器是可滚动的(内容高于容器)', box.canScroll, `scrollHeight=${box.sh} clientHeight=${box.ch}`); const before = await cdp.eval('document.querySelector(".scroll-pane").scrollTop'); // 真实滚轮事件(走浏览器输入管线,能测到事件冒泡与滚动穿透) await cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: box.x, y: box.y, deltaX: 0, deltaY: 400, }); await sleep(600); const after = await cdp.eval('({ listTop: document.querySelector(".scroll-pane").scrollTop, winY: window.scrollY })'); step('滚轮滚动的是列表而不是整页', after.listTop > before && after.winY === 0, `列表 scrollTop ${before} → ${after.listTop};window.scrollY=${after.winY}`); }); // 撰写弹窗 await safeStep('撰写弹窗', async () => { await cdp.eval(`(() => { const btn = [...document.querySelectorAll('button')].find(b => (b.textContent||'').includes('撰写新邮件')); if (btn) btn.click(); })()`); await sleep(700); const dlg = await cdp.eval(`(() => { const d = document.querySelector('[role="dialog"]'); if (!d) return { open: false }; const labels = [...d.querySelectorAll('span')].map(s => s.textContent).filter(Boolean); return { open: true, title: (d.querySelector('h2') || {}).textContent || '', hasTo: labels.includes('收件人'), hasCc: labels.includes('抄送'), hasSubject: labels.includes('主题'), textarea: !!d.querySelector('textarea'), sendBtn: [...d.querySelectorAll('button')].some(b => (b.textContent||'').includes('发送')), attachBtn: [...d.querySelectorAll('button')].some(b => (b.textContent||'').includes('添加附件')), }; })()`); step('撰写弹窗打开且字段齐备', dlg.open && dlg.hasTo && dlg.hasCc && dlg.hasSubject && dlg.textarea && dlg.sendBtn && dlg.attachBtn, `${dlg.title} · 收件人/抄送/主题/正文/附件/发送 = ${[dlg.hasTo, dlg.hasCc, dlg.hasSubject, dlg.textarea, dlg.attachBtn, dlg.sendBtn].join('/')}`); await cdp.eval(`document.querySelector('[role="dialog"] button[aria-label], [role="dialog"] button')?.click()`); await cdp.eval(`(() => { const d = document.querySelector('[role="dialog"]'); if (d) { const btn = d.querySelector('button[data-slot="dialog-close"]') || d.querySelector('button'); btn && btn.click(); } })()`); await sleep(400); }); // 主题切换 await safeStep('主题切换', async () => { const before = await cdp.eval('getComputedStyle(document.body).backgroundColor'); const wasDark = await cdp.eval('document.documentElement.classList.contains("dark")'); await cdp.eval(`(() => { const btn = [...document.querySelectorAll('button')].find(b => (b.title||'') === '切换主题'); if (btn) btn.click(); })()`); await sleep(500); const after = await cdp.eval('getComputedStyle(document.body).backgroundColor'); const isDark = await cdp.eval('document.documentElement.classList.contains("dark")'); step('切换主题后背景色确实改变', before !== after && wasDark !== isDark, `${before} → ${after}`); // 切回 await cdp.eval(`(() => { const btn = [...document.querySelectorAll('button')].find(b => (b.title||'') === '切换主题'); if (btn) btn.click(); })()`); await sleep(300); }); // 横向溢出 const ov = await cdp.eval('({ s: document.documentElement.scrollWidth, c: document.documentElement.clientWidth })'); step('无横向溢出', ov.s <= ov.c + 1, `scrollWidth=${ov.s} clientWidth=${ov.c}`); say(''); say('── 运行时报错 ─────────────────────────────────────────────'); const real = cdp.errors.filter((v) => !/favicon|DevTools/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} 条` : '无'); } catch (err) { step('验收脚本自身异常', false, String(err && err.message)); process.exitCode = 2; } finally { if (cdp) try { cdp.ws.close(); } catch { /* 忽略 */ } try { child.kill(); } catch { /* 忽略 */ } await sleep(400); try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ } 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-v2.txt', report.join('\n'), 'utf8'); process.stdout.write(`\nREPORT E:\\deepseek\\artifacts\\client-ui-check-v2.txt\nSUMMARY pass=${pass} fail=${fail}\n`); if (!process.exitCode) process.exitCode = fail ? 1 : 0; } })();