Files

504 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { /* 忽略 */ }
}
})();