Files

273 lines
13 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';
/**
* 界面运行时验收(新界面: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;
}
})();