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