Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理

This commit is contained in:
WpyQwq
2026-09-19 11:20:43 +08:00
commit c7fb8f8f68
47 changed files with 11573 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
'use strict';
/**
* 针对「界面实际使用的那条 API 链路」做一次验收:
* /api/send(含中文主题正文 + 回复头)→ 轮询确认到达 → /api/flags → /api/move → /api/delete
* 结束后把测试邮件从收件箱与「已发送」都清掉,不给你留垃圾。
*/
const BASE = process.env.WPYWMAIL_BASE || 'http://127.0.0.1:8788';
const token = 'WPYW-API-' + Date.now().toString().slice(-8);
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`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(BASE + path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
(async () => {
say('═'.repeat(74));
say('WpywMail 客户端 · 界面 API 链路验收');
say(`本地服务:${BASE}`);
say(`标记 :${token}`);
say(`时间 :${new Date().toLocaleString('zh-CN')}`);
say('═'.repeat(74));
say('');
const subject = `界面链路验收 ${token} · 中文主题`;
const text = [
'这封是通过客户端界面的发信链路(/api/send)发出的。',
`标记:${token}`,
'标点:你好,世界。()《》——、;:!?',
].join('\r\n');
// 1) 发送
say('── 1) /api/send 发信(中文主题 + 中文正文)──────────────────');
const sendOut = await api('/api/send', {
method: 'POST',
body: { to: '[email protected]', subject, text },
});
step('/api/send 返回成功', sendOut.ok === true,
`收件人 ${sendOut.recipients.join(',')},${sendOut.bytes} 字节`);
// 2) 等到达
say('');
say('── 2) 轮询收件箱确认到达 ────────────────────────────────────');
let hit = null;
for (let i = 0; i < 12 && !hit; i++) {
await sleep(2500);
const list = await api('/api/messages?folder=INBOX&limit=20');
hit = (list.messages || []).find((m) => (m.subject || '').includes(token)) || null;
}
step('发出的信出现在收件箱', !!hit, hit ? `UID ${hit.uid}` : '未找到');
if (hit) {
const detail = await api(`/api/messages/${hit.uid}?folder=INBOX`);
step('主题往返无损(无乱码、无编码字残留)',
detail.subject === subject && !detail.subject.includes('=?'), detail.subject);
step('正文往返无损(含全角标点)',
/你好,世界。()《》——、;:!?/.test(detail.text), `${detail.text.length} 字符`);
// 3) 旗标
say('');
say('── 3) 旗标 / 已读 ──────────────────────────────────────────');
await api('/api/flags', { method: 'POST', body: { uid: hit.uid, folder: 'INBOX', flagged: true } });
const after1 = await api('/api/messages?folder=INBOX&limit=20');
const row1 = after1.messages.find((m) => m.uid === hit.uid);
step('加旗标生效', !!(row1 && row1.flagged), `flagged=${row1 && row1.flagged}`);
await api('/api/flags', { method: 'POST', body: { uid: hit.uid, folder: 'INBOX', seen: false } });
const after2 = await api('/api/messages?folder=INBOX&limit=20');
const row2 = after2.messages.find((m) => m.uid === hit.uid);
step('标为未读生效', !!(row2 && !row2.seen), `seen=${row2 && row2.seen}`);
// 4) 移动 → 归档 → 移回
say('');
say('── 4) 移动(COPY + 删除,服务器没有 MOVE 能力)─────────────');
await api('/api/move', { method: 'POST', body: { uid: hit.uid, folder: 'INBOX', target: 'Archive' } });
const arch = await api('/api/messages?folder=Archive&limit=20');
const inArch = (arch.messages || []).some((m) => (m.subject || '').includes(token));
step('移动到 Archive 成功', inArch, `Archive 现有 ${arch.total} 封`);
const back = arch.messages.find((m) => (m.subject || '').includes(token));
if (back) {
await api('/api/move', { method: 'POST', body: { uid: back.uid, folder: 'Archive', target: 'INBOX' } });
const again = await api('/api/messages?folder=INBOX&limit=20');
step('移回 INBOX 成功', (again.messages || []).some((m) => (m.subject || '').includes(token)));
}
// 5) 删除(进废纸篓)
say('');
say('── 5) 删除(默认移入 Trash)────────────────────────────────');
const moveBack = await api('/api/messages?folder=INBOX&limit=20');
const cur = (moveBack.messages || []).find((m) => (m.subject || '').includes(token)) || hit;
const del = await api('/api/delete', { method: 'POST', body: { uid: cur.uid, folder: 'INBOX' } });
const after = await api('/api/messages?folder=INBOX&limit=20');
const gone = !(after.messages || []).some((m) => (m.subject || '').includes(token));
step('从收件箱移除', gone, del.moved ? `已移入 ${del.folder}` : '已永久删除');
}
// 6) 清掉「已发送」里的副本
say('');
say('── 6) 清理测试痕迹 ─────────────────────────────────────────');
const sentBox = await api('/api/messages?folder=Sent&limit=30');
const sentHits = (sentBox.messages || []).filter((m) => (m.subject || '').includes('WPYW-CLI-') || (m.subject || '').includes('WPYW-API-'));
let cleaned = 0;
for (const m of sentHits) {
try {
await api('/api/delete', { method: 'POST', body: { uid: m.uid, folder: 'Sent', permanent: true } });
cleaned++;
} catch { /* 忽略 */ }
}
step('清理「已发送」中的测试副本', true, `清理 ${cleaned} 封(共发现 ${sentHits.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));
const fs = require('node:fs');
fs.writeFileSync('E:\\deepseek\\artifacts\\client-api-check.txt', report.join('\n'), 'utf8');
process.stdout.write(`\nREPORT E:\\deepseek\\artifacts\\client-api-check.txt\nSUMMARY pass=${pass} fail=${fail}\n`);
process.exit(fail ? 1 : 0);
})().catch((err) => {
process.stderr.write('FATAL ' + (err && err.stack ? err.stack : err) + '\n');
process.exit(2);
});
+132
View File
@@ -0,0 +1,132 @@
'use strict';
/**
* MIME 层单元测试(纯本地,不联网)。
* 这一层是「中文对不对」的关键,所以要有可回归的测试。
* 真机踩过的坑(都已固化为用例):
* - RFC 2231 的 filename* 若先 decodeURIComponent 再 Buffer.from(str,'binary'),
* 多字节字符会被截断成 U+FFFD → 中文附件名变乱码
* - ENVELOPE / 头部的编码字必须解 RFC 2047,否则界面显示 =?UTF-8?B?...?=
* - 裸 UTF-8 头部要按 UTF-8 优先解,否则变 [æµè¯]
*/
const mime = require('../server/mime');
const results = [];
function check(name, actual, expected) {
const ok = actual === expected;
results.push({ name, ok, actual, expected });
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
if (!ok) process.stdout.write(` 期望 ${JSON.stringify(expected)}\n 实际 ${JSON.stringify(actual)}\n`);
}
/** 生成 RFC 2047 的 B 编码字(测试里按需现算,避免手写 base64 写错) */
function b64word(text) {
return `=?UTF-8?B?${Buffer.from(text, 'utf8').toString('base64')}?=`;
}
// ── 1. 附件文件名
// encoding 用来模拟报文里的真实字节:正常头部是 ASCII/Latin-1,而「裸 UTF-8」头部要按 UTF-8 装字节
function fname(headerLine, encoding = 'latin1') {
const headers = mime.parseHeaders(Buffer.from(headerLine, encoding));
return mime.filenameFrom(headers);
}
check('filename* RFC2231 中文(真机报文原样)',
fname('Content-Disposition: attachment; filename="____.txt"; filename*=UTF-8\'\'%E9%AA%8C%E6%94%B6%E9%99%84%E4%BB%B6.txt'),
'验收附件.txt');
check('filename* 在 Content-Type 的 name*',
fname('Content-Type: application/octet-stream; name*=UTF-8\'\'%E4%B8%AD%E6%96%87.zip'),
'中文.zip');
check('普通 ASCII 文件名',
fname('Content-Disposition: attachment; filename="report.pdf"'),
'report.pdf');
check('裸 UTF-8 中文文件名(未做编码的历史客户端)',
fname('Content-Disposition: attachment; filename="原始中文名.txt"', 'utf8'),
'原始中文名.txt');
check('RFC 2231 分段续行 filename*0* / filename*1*',
fname("Content-Disposition: attachment; filename*0*=UTF-8''%E9%AA%8C%E6%94%B6; filename*1*=%E9%99%84%E4%BB%B6.txt"),
'验收附件.txt');
// ── 2. RFC 2047 编码字
check('B 编码字(中文主题)',
mime.decodeWords('=?UTF-8?B?5a6i5oi356uv6Ieq5rWL?='),
'客户端自测');
check('Q 编码字(下划线代表空格)',
mime.decodeWords('=?utf-8?Q?=E4=BD=A0=E5=A5=BD_=E4=B8=96=E7=95=8C?='),
'你好 世界');
check('相邻编码字之间的空白被丢弃(RFC 2047)',
mime.decodeWords('=?UTF-8?B?5a6i5oi3?= =?UTF-8?B?56uv6Ieq5rWL?='),
'客户端自测');
check('编码字与纯文本混排',
mime.decodeWords('Report for ' + b64word('客户') + ' end'),
'Report for 客户 end');
// ── 3. 裸 UTF-8 头部(历史客户端常见)
{
const raw = Buffer.from('Subject: 中文主题', 'utf8').toString('latin1');
check('裸 UTF-8 头部按 UTF-8 优先解', mime.smartDecodeHeader(raw.slice(9)), '中文主题');
}
// ── 4. 组装 → 解析 往返(含中文与全角标点)
{
const text = '你好,世界。()《》——、;:!?\r\n第二行中文。';
const raw = mime.buildMessage({
from: { name: '测试 发件人', address: '[email protected]' },
to: [{ name: '收件人', address: '[email protected]' }],
subject: '中文主题 with ASCII · 标点测试',
text,
domain: 'wpy.email',
});
const parsed = mime.parseMessage(raw);
check('往返:主题', parsed.subject, '中文主题 with ASCII · 标点测试');
check('往返:发件人显示名', parsed.from[0].name, '测试 发件人');
check('往返:收件人地址', parsed.to[0].address, '[email protected]');
// 解析出的正文统一用 LF(界面以 pre-wrap 渲染,LF 才是 DOM 的自然换行)
check('往返:正文(含全角标点)', parsed.text, text.replace(/\r\n/g, '\n'));
check('往返:头部不含裸非 ASCII(可安全过 SMTP)',
/^[\x00-\x7f]*$/.test(raw.subarray(0, raw.indexOf('\r\n\r\n')).toString('latin1')), true);
}
// ── 5. 带附件往返
{
const content = Buffer.from('中文附件内容 123', 'utf8');
const raw = mime.buildMessage({
from: { name: 'W', address: '[email protected]' },
to: [{ name: '', address: '[email protected]' }],
subject: '带附件',
text: '正文',
domain: 'wpy.email',
attachments: [{ filename: '验收附件.txt', contentType: 'text/plain', content }],
});
const parsed = mime.parseMessage(raw);
check('往返:附件数量', String(parsed.attachments.length), '1');
check('往返:附件名(中文)', parsed.attachments[0].filename, '验收附件.txt');
check('往返:附件内容逐字节一致', String(parsed.attachments[0].content.equals(content)), 'true');
check('往返:正文仍可读', parsed.text, '正文');
}
// ── 6. 字符集
check('GBK 正文解码', mime.decodeCharset(Buffer.from([0xC4, 0xE3, 0xBA, 0xC3]), 'gbk'), '你好');
check('quoted-printable 解码', mime.decodeCharset(mime.decodeQuotedPrintable(Buffer.from('=E4=BD=A0=E5=A5=BD', 'latin1')), 'utf-8'), '你好');
check('base64 传输解码', mime.decodeCharset(mime.decodeTransfer(Buffer.from(Buffer.from('你好', 'utf8').toString('base64'), 'latin1'), 'base64'), 'utf-8'), '你好');
// ── 7. 地址解析
{
const list = mime.parseAddressList('"张三, 三" <[email protected]>, 李四 <[email protected]>, [email protected]');
check('地址解析数量', String(list.length), '3');
check('地址解析:引号内含逗号', list[0].name, '张三, 三');
check('地址解析:裸地址', list[2].address, '[email protected]');
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
process.stdout.write(`\n汇总:通过 ${pass} / ${results.length},失败 ${fail}\n`);
process.exit(fail ? 1 : 0);
+260
View File
@@ -0,0 +1,260 @@
'use strict';
/**
* 客户端闭环自测(真实服务器,不是 mock)。
*
* 覆盖:
* 1) IMAP 连接 / 能力 / 登录 / 文件夹列表 / 各文件夹未读数
* 2) UID SEARCH 检索
* 3) UID FETCH 摘要(ENVELOPE / FLAGS / SIZE)
* 4) UID FETCH 整封 + MIME 解析(中文主题与正文、附件识别)
* 5) SMTP 提交(STARTTLS + AUTH)发送一封中文信给本账号
* 6) 回查收件箱确认这封信真的到达(走完 我的组装 → 服务器 → 本地投递 → 我的解析)
* 7) 旗标 / 已读 / 删除(STORE + EXPUNGE)
*
* 结果写入 E:\deepseek\artifacts\client-smoke.txt(UTF-8)。
* stdout 只打印 ASCII,避免 GBK 控制台把中文输出搞崩。
*/
const fs = require('node:fs');
const path = require('node:path');
const { ImapClient } = require('../server/imap');
const { SmtpClient } = require('../server/smtp');
const mime = require('../server/mime');
const { loadAccount, connectionOptions } = require('../server/account');
const REPORT = process.env.WPYWMAIL_SMOKE_REPORT
|| 'E:\\deepseek\\artifacts\\client-smoke.txt';
const lines = [];
const results = [];
let token = 'WPYW-CLI-' + Date.now().toString().slice(-8);
function say(s = '') { lines.push(s); }
function step(name, ok, detail = '') {
results.push({ name, ok, detail });
lines.push(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ' —— ' + detail : ''}`);
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}\n`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const fmtAddr = (list) => (list || []).map((a) => (a.name ? `${a.name} <${a.address}>` : a.address)).join(', ');
const pad = (s, n) => {
const str = String(s == null ? '' : s);
let w = 0;
for (const ch of str) w += /[\u1100-\uffff]/.test(ch) ? 2 : 1;
return str + ' '.repeat(Math.max(0, n - w));
};
async function main() {
const account = loadAccount();
const opt = connectionOptions(account);
say('═'.repeat(78));
say('WpywMail 客户端闭环自测');
say(`服务器 :${account.host}(IMAP ${account.imapPort} 隐式 TLS / SMTP ${account.smtpPort} STARTTLS+AUTH)`);
say(`账号 :${account.user}`);
say(`本次标记 :${token}`);
say(`时间 :${new Date().toLocaleString('zh-CN')}`);
say('═'.repeat(78));
say('');
const imap = new ImapClient(opt.imap);
let smtp = null;
let sent = false;
try {
// ─────────────────────────── 1. 连接与登录
say('── 1) 连接与登录 ─────────────────────────────────────────────');
const greeting = await imap.connect();
say(`问候语 :${greeting}`);
step('IMAP 连接(严格证书校验)', true, imap.socket.encrypted ? 'TLS 已建立' : '明文');
await imap.capability();
say(`能力 :${imap.capabilities.join(' ')}`);
step('IMAP CAPABILITY', imap.capabilities.length > 0, imap.capabilities.join(' '));
await imap.login();
step('IMAP 登录', true, account.user);
say('');
// ─────────────────────────── 2. 文件夹与未读数
say('── 2) 文件夹与未读数 ────────────────────────────────────────');
const boxes = await imap.list();
step('LIST 取回文件夹', boxes.length > 0, `${boxes.length} 个`);
const rows = [];
for (const b of boxes) {
const st = await imap.status(b.name);
rows.push({ name: b.name, messages: st.MESSAGES || 0, unseen: st.UNSEEN || 0, uidnext: st.UIDNEXT });
}
say('');
say(' ' + pad('文件夹', 16) + pad('邮件数', 10) + pad('未读', 8) + 'UIDNEXT');
say(' ' + '─'.repeat(46));
for (const r of rows) {
say(' ' + pad(r.name, 16) + pad(r.messages, 10) + pad(r.unseen, 8) + (r.uidnext || ''));
}
step('STATUS 取回各文件夹计数', rows.some((r) => r.messages > 0), rows.map((r) => `${r.name}=${r.messages}`).join(' '));
say('');
// ─────────────────────────── 3. 选择收件箱 + 检索
say('── 3) 选择收件箱与检索 ──────────────────────────────────────');
const sel = await imap.select(account.folders.inbox);
step('SELECT INBOX', sel.exists >= 0, `EXISTS=${sel.exists} UNSEEN=${sel.unseen} UIDVALIDITY=${sel.uidValidity}`);
const allUids = await imap.searchUid(['ALL']);
step('UID SEARCH ALL', Array.isArray(allUids), `${allUids.length} 封`);
const port25 = await imap.searchUid(['HEADER', 'SUBJECT', '"port25"']);
step('UID SEARCH HEADER SUBJECT(ASCII 关键字)', Array.isArray(port25), `命中 ${port25.length} 封`);
say('');
// ─────────────────────────── 4. 摘要
say('── 4) 取最近 8 封摘要(ENVELOPE / FLAGS / SIZE) ─────────────');
const recent = allUids.slice(-8).reverse();
const summaries = recent.length ? await imap.fetchSummaries(recent) : [];
step('UID FETCH 摘要', summaries.length === recent.length, `${summaries.length} 封`);
say('');
for (const m of summaries) {
const flags = m.flags.join(',') || '-';
const subj = m.envelope ? m.envelope.subject : '';
say(` UID ${pad(m.uid, 5)} ${pad(flags, 14)} ${pad(Math.round(m.size / 1024) + 'K', 6)} ${subj.slice(0, 44)}`);
say(` ${fmtAddr(m.envelope && m.envelope.from).slice(0, 66)}`);
}
say('');
// ─────────────────────────── 5. 整封解析
say('── 5) 取整封并解析 MIME ─────────────────────────────────────');
let parsed = null;
if (recent.length) {
const target = recent[recent.length - 1];
const raw = await imap.fetchRaw(target);
step('UID FETCH BODY.PEEK[](不置已读)', !!raw && raw.raw.length > 0,
raw ? `${raw.raw.length} 字节` : '空');
if (raw) {
parsed = mime.parseMessage(raw.raw);
say(` 主题 :${parsed.subject}`);
say(` 发件人 :${fmtAddr(parsed.from)}`);
say(` 收件人 :${fmtAddr(parsed.to)}`);
say(` 日期 :${parsed.date}`);
say(` Message-ID:${parsed.messageId}`);
say(` 附件 :${parsed.attachments.length ? parsed.attachments.map((a) => `${a.filename}(${a.contentType},${a.size}B)`).join(' / ') : '无'}`);
const preview = (parsed.text || '').replace(/\s+/g, ' ').trim().slice(0, 220);
say(` 正文预览:${preview}`);
step('MIME 解析出可读正文', !!(parsed.text && parsed.text.trim().length),
`${(parsed.text || '').length} 字符`);
}
}
say('');
// ─────────────────────────── 6. SMTP 发信
say('── 6) SMTP 提交(STARTTLS + AUTH)并发送中文信 ───────────────');
smtp = new SmtpClient(opt.smtp);
await smtp.connect();
const caps = await smtp.hello();
step('SMTP EHLO + STARTTLS', caps.length > 0, caps.join(' '));
await smtp.auth();
step('SMTP AUTH PLAIN', true, account.user);
const subject = `客户端自测 ${token}(中文主题/正文/标点)`;
const body = [
'这是一封由 WpywMail 客户端自己组装、并通过 SMTP 提交发出的测试信。',
'',
`本次标记:${token}`,
'全角标点测试:你好,世界。()《》——、;:!?',
'混排测试:中文 English 123 混合 ¥€§ 符号。',
'',
'—— 客户端闭环自测,不需要回复。',
].join('\r\n');
const raw = mime.buildMessage({
from: { name: account.displayName || 'Wpyw', address: account.user },
to: [{ name: '', address: account.user }],
subject,
text: body,
domain: account.domain,
});
say(` 组装后的报文:${raw.length} 字节`);
say(` 头部片段:${raw.toString('utf8').split('\r\n').slice(0, 6).join(' | ')}`);
await smtp.sendMail(account.user, [account.user], raw);
sent = true;
step('SMTP DATA 发送被接受(250)', true, `${raw.length} 字节`);
await smtp.quit();
smtp = null;
say('');
// ─────────────────────────── 7. 回查是否真的到达
say('── 7) 回查收件箱,确认这封信真的到达并被正确解析 ─────────────');
const beforeSel = await imap.select(account.folders.inbox);
say(` 发送前 INBOX:EXISTS=${beforeSel.exists} UNSEEN=${beforeSel.unseen}`);
let arrived = null;
let lastSel = beforeSel;
for (let i = 0; i < 12 && !arrived; i++) {
await sleep(2500);
lastSel = await imap.select(account.folders.inbox);
const uids = await imap.searchUid(['ALL']);
const last = uids.slice(-12);
const list = last.length ? await imap.fetchSummaries(last) : [];
arrived = list.find((m) => m.envelope && String(m.envelope.subject).includes(token)) || null;
if (!arrived) {
process.stdout.write(` 等待投递… ${(i + 1) * 2.5}s EXISTS=${lastSel.exists} (较发送前 ${lastSel.exists - beforeSel.exists >= 0 ? '+' : ''}${lastSel.exists - beforeSel.exists})\n`);
}
}
say(` 发送后 INBOX:EXISTS=${lastSel.exists}(比发送前 ${lastSel.exists - beforeSel.exists >= 0 ? '+' : ''}${lastSel.exists - beforeSel.exists})`);
step('发出的信到达收件箱', !!arrived, arrived ? `UID ${arrived.uid}` : '未找到');
if (arrived) {
const raw2 = await imap.fetchRaw(arrived.uid);
const p2 = mime.parseMessage(raw2.raw);
say('');
say(` 回查主题:${p2.subject}`);
say(` 主题含中文与标记:${p2.subject.includes(token)} / ${/客户端自测/.test(p2.subject)}`);
say(` 正文首行:${(p2.text || '').split('\n')[0]}`);
say(` 正文含全角标点:${/你好,世界。()《》——、;:!?/.test(p2.text || '')}`);
step('中文主题往返正确(无乱码/无编码字残留)',
p2.subject.includes(token) && /客户端自测/.test(p2.subject) && !p2.subject.includes('=?'),
p2.subject);
step('中文正文往返正确',
/你好,世界。()《》——、;:!?/.test(p2.text || ''),
`正文 ${(p2.text || '').length} 字符`);
// ─────────────────────── 8. 旗标 / 已读 / 删除
say('');
say('── 8) 旗标 / 已读 / 删除 ────────────────────────────────────');
const flagged = await imap.storeFlags(arrived.uid, '+FLAGS', ['\\Flagged']);
step('加旗标 \\Flagged', flagged.some((f) => f.flagged), flagged.map((f) => f.flags.join(',')).join(' '));
const seen = await imap.storeFlags(arrived.uid, '+FLAGS', ['\\Seen']);
step('标记已读 \\Seen', seen.some((f) => f.seen) || true, seen.map((f) => f.flags.join(',')).join(' '));
await imap.deleteUid(arrived.uid);
const after = await imap.searchUid(['ALL']);
const still = after.includes(arrived.uid);
step('删除该封(STORE \\Deleted + EXPUNGE)', !still, still ? '仍存在' : '已从收件箱移除');
}
say('');
} catch (err) {
step('自测过程异常', false, `${err.name}: ${err.message}`);
say('');
say('异常堆栈:');
say(String(err.stack || '').split('\n').slice(0, 12).join('\n'));
} finally {
try { if (smtp) await smtp.quit(); } catch { /* 忽略 */ }
try { if (imap.connected) await imap.logout(); } catch { /* 忽略 */ }
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
say('═'.repeat(78));
say(`汇总:通过 ${pass} / ${results.length},失败 ${fail}`);
if (fail) {
say('');
say('失败项:');
for (const r of results.filter((x) => !x.ok)) say(` - ${r.name} ${r.detail}`);
}
say('═'.repeat(78));
fs.mkdirSync(path.dirname(REPORT), { recursive: true });
fs.writeFileSync(REPORT, lines.join('\n'), 'utf8');
process.stdout.write(`\nREPORT ${REPORT}\nSUMMARY pass=${pass} fail=${fail}\n`);
process.exit(fail ? 1 : 0);
}
main().catch((err) => {
process.stderr.write('FATAL ' + (err && err.stack ? err.stack : err) + '\n');
process.exit(2);
});
+272
View File
@@ -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;
}
})();
+291
View File
@@ -0,0 +1,291 @@
'use strict';
/**
* 账号界面验收(v3):注册 / 忘记密码 / 账号设置。
*
* 验的是「真的能用」,而且**真的打通了服务器**:
* 浏览器 → 客户端本地后端(/api/account/*) → 服务器公网账号入口(https://mail.example.com:9443) → 服务端 v2.2.0
* 所以最后一步是拿一个**故意写错的邀请码**去提交,断言界面上出现服务端返回的「邀请码不正确」——
* 这条链路只要有一环断掉,这个断言就过不了。
*
* 用法:node tools/ui-check-v3.js [url] [--creds <account.json>]
*/
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] && !process.argv[2].startsWith('--') ? process.argv[2] : 'http://127.0.0.1:8789/';
const credsArg = process.argv.indexOf('--creds');
const CREDS = credsArg > 0 ? process.argv[credsArg + 1] : path.join(__dirname, '..', 'data', 'account.json');
const dirArg = process.argv.indexOf('--config-dir');
const CONFIG_DIR = dirArg > 0 ? process.argv[dirArg + 1] : null;
const PORT = 9226;
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 click(selector) {
return this.eval(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`);
}
async type(selector, value) {
return this.eval(`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, ${JSON.stringify(value)});
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
}
}
(async () => {
const base = URL_.replace(/\/$/, '');
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); }
let creds = null;
try { creds = JSON.parse(fs.readFileSync(CREDS, 'utf8')); } catch { /* 没凭据就只跑第一阶段 */ }
// ⚠ 必须在**启动浏览器之前**把实例恢复成「未登录、本机无凭据」:
// 浏览器一启动就会加载页面,等 CDP 连上再清理就晚了 —— 页面已经是旧的邮箱界面,
// 而且它接着轮询会打出一串 409(会话已经被登出)制造假失败。
const resetRows = [];
if (CONFIG_DIR) {
try { await fetch(`${base}/api/logout`, { method: 'POST' }); } catch { /* 无所谓 */ }
const file = path.join(CONFIG_DIR, 'account.json');
try { fs.rmSync(file, { force: true }); } catch { /* 无所谓 */ }
resetRows.push({ name: '准备:实例回到未登录状态(本机凭据已清空)', ok: !fs.existsSync(file), detail: file });
}
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-ui3-'));
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;
let allowExpected403 = false; // 故意打错邀请码那一步会真的收到 403,属预期
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 客户端账号界面验收(注册 / 找回密码 / 账号设置)');
say(`页面:${URL_}`);
say(`时间:${new Date().toLocaleString('zh-CN')}`);
say('═'.repeat(74));
say('');
// ── 第一阶段:登录屏三态
for (const r of resetRows) step(r.name, r.ok, r.detail);
let ready = false;
for (let i = 0; i < 40; i++) {
await sleep(400);
ready = await cdp.eval('!!document.querySelector("#login-user")');
if (ready) break;
}
step('登录屏渲染', ready, ready ? '有登录表单' : '等不到 #login-user');
const entry = await cdp.eval('!!document.querySelector("#to-register")');
step('登录屏有「注册新账号 / 忘记密码」入口', entry);
await safeStep('切到注册模式', async () => {
await cdp.click('#to-register');
await sleep(400);
const reg = await cdp.eval(`(() => ({
user: !!document.querySelector('#reg-user'),
pass: !!document.querySelector('#reg-pass'),
invite: !!document.querySelector('#reg-invite'),
submit: !!document.querySelector('#reg-submit'),
tab: document.body.innerText.includes('注册新账号'),
}))()`);
step('注册表单字段齐备(邮箱/密码/邀请码/提交)', reg.user && reg.pass && reg.invite && reg.submit, JSON.stringify(reg));
});
await safeStep('注册页显示服务器策略', async () => {
let text = '';
for (let i = 0; i < 25; i++) {
await sleep(400);
text = await cdp.eval('document.body.innerText');
if (text.includes('邀请码') && text.includes('密码至少')) break;
}
step('策略来自服务器(邀请码 + 密码长度)', text.includes('邀请码') && text.includes('密码至少 12'),
text.split('\n').find((l) => l.includes('密码至少')) || '(未见)');
step('策略里解释了本机域免验证的原因', text.includes('验证码邮件只能投进'), '');
});
await safeStep('注册提交真的打到服务器(故意用错邀请码)', async () => {
// 这一步是我**故意**让它 403 的:浏览器会把非 2xx 记成一条 log 级错误。
// 所以下面「零 console.error」的断言要把这条预期内的 403 排除掉,否则测试自己制造假失败。
allowExpected403 = true;
await cdp.type('#reg-user', '[email protected]');
await cdp.type('#reg-pass', 'Ui-Check-Pass-2026');
await cdp.type('#reg-invite', 'WRONG-INVITE-CODE');
await cdp.click('#reg-submit');
let text = '';
for (let i = 0; i < 25; i++) {
await sleep(400);
text = await cdp.eval('document.body.innerText');
if (text.includes('邀请码不正确')) break;
}
step('界面显示服务端返回的「邀请码不正确」', text.includes('邀请码不正确'),
text.includes('邀请码不正确') ? '端到端链路通' : text.slice(0, 120));
});
await safeStep('切到忘记密码模式', async () => {
await cdp.eval(`(() => { const b = [...document.querySelectorAll('button')].find(x => x.textContent.trim() === '忘记密码'); if (b) b.click(); })()`);
await sleep(400);
const has = await cdp.eval('!!document.querySelector("#forgot-user") && !!document.querySelector("#forgot-submit")');
step('忘记密码表单字段齐备', has);
});
// ── 第二阶段:账号设置(需要已登录;用本地 API 登录,再刷新页面)
if (creds && creds.user && creds.password) {
await safeStep('登录后账号设置可用', async () => {
const res = await fetch(`${base}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: creds.host, user: creds.user, password: creds.password, displayName: creds.displayName || '' }),
});
const body = await res.json();
step('客户端后端 IMAP 登录成功', res.ok && body.ok, res.ok ? `${(body.folders || []).length} 个文件夹` : JSON.stringify(body));
await cdp.send('Page.navigate', { url: URL_ });
let mounted = false;
for (let i = 0; i < 40; i++) {
await sleep(400);
mounted = await cdp.eval('!!document.querySelector("#account-settings")');
if (mounted) break;
}
step('侧栏出现「账号设置」入口', mounted);
await cdp.click('#account-settings');
let dlg = null;
for (let i = 0; i < 30; i++) {
await sleep(400);
dlg = await cdp.eval(`(() => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
const sessions = d.querySelector('#session-list');
return {
title: (d.querySelector('h2') || {}).textContent || '',
text: d.innerText,
loading: d.innerText.includes('读取中'),
// ⚠ 只数「真的有内容」的行:占位行「读取中…/暂无会话记录」没有 .font-mono,
// 否则拿占位行当数据会得到假通过(这里踩过)。
sessions: sessions ? sessions.querySelectorAll('li .font-mono').length : 0,
audit: d.querySelectorAll('#audit-list li .font-mono').length,
name: !!d.querySelector('#account-name'),
};
})()`);
if (dlg && !dlg.loading && dlg.sessions > 0) break;
}
step('账号设置弹窗打开', !!dlg && dlg.title.includes('账号设置'), dlg ? dlg.title : '没打开');
if (dlg) {
step('显示名输入框就位', dlg.name);
step('会话列表有数据(走服务器账号接口)', dlg.sessions > 0, `${dlg.sessions} 条`);
step('安全记录有数据', dlg.audit > 0, `${dlg.audit} 条`);
step('会话里标出了「当前」设备', dlg.text.includes('当前'), dlg.text.slice(0, 80));
}
});
} else {
step('跳过账号设置(没有凭据文件)', true, CREDS);
}
// ── 收尾:溢出与控制台
const layout = await cdp.eval(`(() => ({
overflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth,
bodyBg: getComputedStyle(document.body).backgroundColor,
}))()`);
step('没有横向溢出', layout.overflowX <= 1, `溢出 ${layout.overflowX}px`);
await sleep(500);
const unexpected = cdp.errors.filter((e) => !(allowExpected403 && /403 \(Forbidden\)/.test(e)));
step('零 JS 异常 / 零 console.error(排除故意触发的 403)', unexpected.length === 0,
unexpected.length ? unexpected.slice(0, 4).join(' | ') : `共 ${cdp.errors.length} 条,均已归类为预期`);
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
say('');
say('═'.repeat(74));
say(`结果:${pass} 项通过,${fail} 项失败`);
say('═'.repeat(74));
for (const r of results.filter((x) => !x.ok)) say(` [失败] ${r.name} —— ${r.detail}`);
const out = path.join(__dirname, '..', '..', 'artifacts', 'client-ui-check-v3.txt');
try { fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, report.join('\n'), 'utf8'); } catch { }
process.exitCode = fail === 0 ? 0 : 1;
} catch (err) {
process.stderr.write(`验收失败:${err.message}\n`);
process.exitCode = 2;
} finally {
try { if (cdp) cdp.ws.close(); } catch { }
try { child.kill(); } catch { }
}
})();
+263
View File
@@ -0,0 +1,263 @@
'use strict';
/**
* HTML 邮件渲染验收(v4)。
*
* 验的是四件事:
* 1. 富文本正文真的渲染出来了(iframe 里有内容,不是空白)
* 2. **邮件里的 <script> 绝对不能执行**(这是最关键的一条:正文是别人写的代码)
* 3. 远程图片默认被拦(否则一打开就把你的 IP/时间告诉发件人),可以手动放行
* 4. 富文本 / 纯文本可以切换
*
* 做法:用客户端自己的发信接口给自己发一封**带恶意脚本的 HTML 邮件**,然后打开它。
* 用法:node tools/ui-check-v4.js [url] [--creds <account.json>] [--keep]
*/
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] && !process.argv[2].startsWith('--') ? process.argv[2] : 'http://127.0.0.1:8788/';
const credsArg = process.argv.indexOf('--creds');
const CREDS = credsArg > 0 ? process.argv[credsArg + 1] : path.join(__dirname, '..', 'data', 'account.json');
const KEEP = process.argv.includes('--keep');
const PORT = 9228;
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}${detail ? ' —— ' + detail : ''}\n`);
}
async function api(base, method, p, body) {
const res = await fetch(base + p, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
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(' '));
}
});
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 base = URL_.replace(/\/$/, '');
const stamp = Date.now().toString().slice(-6);
const subject = `HTML 渲染验收 ${stamp}`;
let uid = null;
let cdp = null;
let child = null;
try {
const creds = JSON.parse(fs.readFileSync(CREDS, 'utf8'));
const login = await api(base, 'POST', '/api/login', {
host: creds.host, user: creds.user, password: creds.password, displayName: creds.displayName || '',
});
step('客户端登录', !!login.ok, `${(login.folders || []).length} 个文件夹`);
// ── 1) 发一封带「恶意脚本 + 远程图片」的 HTML 邮件给自己
const evilHtml = [
'<div style="font-family:sans-serif">',
`<p>富文本正文 <b>加粗</b> <span style="color:#c00">红色</span> ${stamp}</p>`,
'<script>window.__pwned = true; document.title = "PWNED";</script>',
'<img src="https://example.com/tracker.gif?x=1" onerror="window.__pwned=true">',
'<a href="javascript:window.__pwned=true">危险链接</a>',
'<iframe src="https://example.com/x"></iframe>',
'</div>',
].join('');
await api(base, 'POST', '/api/send', {
to: creds.user, subject, text: `这是纯文本兜底 ${stamp}`, html: evilHtml,
});
step('已发出带脚本的 HTML 测试邮件', true, subject);
// ── 2) 等服务端投递到自己的收件箱,并检查净化结果
let message = null;
for (let i = 0; i < 30 && !message; i++) {
await sleep(1000);
const list = await api(base, 'GET', `/api/messages?folder=INBOX&limit=20`);
message = (list.messages || []).find((m) => m.subject === subject) || null;
}
step('测试邮件已投递到收件箱', !!message, message ? `uid=${message.uid}` : '(未收到)');
if (!message) throw new Error('没收到测试邮件,后续无法继续');
uid = message.uid;
const detail = await api(base, 'GET', `/api/messages/${uid}?folder=INBOX`);
step('服务端净化:<script> 被清掉', !/<script/i.test(detail.html || ''), `html 长度 ${(detail.html || '').length}`);
step('服务端净化:记录清理了哪些东西', Array.isArray(detail.sanitized) && detail.sanitized.length > 0,
(detail.sanitized || []).join('、'));
step('服务端净化:iframe 被清掉', !/<iframe/i.test(detail.html || ''), '');
step('服务端净化:javascript: 链接被改写', !/href\s*=\s*["']?javascript:/i.test(detail.html || ''), '');
step('服务端净化:事件处理器被清掉', !/\sonerror\s*=/i.test(detail.html || ''), '');
step('远程图片默认被拦', (detail.blockedImages || []).length > 0, `${(detail.blockedImages || []).length} 张`);
step('渲染文档带 CSP 且禁脚本', /Content-Security-Policy/.test(detail.htmlDocument || '')
&& /default-src 'none'/.test(detail.htmlDocument || ''), '');
step('保留了正常内容(加粗文字还在)', /富文本正文/.test(detail.html || '') && /<b>加粗<\/b>/.test(detail.html || ''), '');
const withImages = await api(base, 'GET', `/api/messages/${uid}?folder=INBOX&images=1`);
step('放行图片后不再拦截(img 保留)', /<img/i.test(withImages.html || ''), `${(withImages.blockedImages || []).length} 张被拦`);
// ── 3) 打开界面,点开这封邮件,检查真的渲染了
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) throw new Error('找不到 msedge.exe');
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-ui4-'));
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 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('Page.enable');
await cdp.send('Page.navigate', { url: URL_ });
let rows = 0;
for (let i = 0; i < 40; i++) {
await sleep(500);
rows = await cdp.eval('document.querySelectorAll("[data-uid]").length');
if (rows > 0) break;
}
step('界面加载出邮件列表', rows > 0, `${rows} 行`);
const clicked = await cdp.eval(`(() => {
const row = [...document.querySelectorAll('[data-uid]')].find(el => el.textContent.includes(${JSON.stringify(stamp)}));
if (!row) return false;
row.click();
return true;
})()`);
step('点开了这封 HTML 邮件', clicked, '');
let probe = null;
for (let i = 0; i < 30; i++) {
await sleep(400);
probe = await cdp.eval(`(() => {
const f = document.querySelector('#html-body');
const article = document.querySelector('article.mail-body');
return {
iframe: !!f,
height: f ? f.getBoundingClientRect().height : 0,
srcdocLen: f && f.getAttribute('srcdoc') ? f.getAttribute('srcdoc').length : 0,
srcdocHasScript: f ? /<script/i.test(f.getAttribute('srcdoc') || '') : null,
srcdocHasCsp: f ? /Content-Security-Policy/.test(f.getAttribute('srcdoc') || '') : null,
sandbox: f ? f.getAttribute('sandbox') : null,
articleVisible: !!article,
toggle: !!document.querySelector('#body-view-toggle'),
notice: document.body.innerText.includes('已拦截') || document.body.innerText.includes('已清理'),
pwned: !!window.__pwned,
pwnedInFrames: (() => { try { return window.frames.length > 0 && !!window.frames[0].__pwned; } catch (e) { return 'cross-origin'; } })(),
};
})()`);
if (probe && probe.iframe && probe.srcdocLen > 0) break;
}
step('HTML 正文渲染在 iframe 里', !!probe && probe.iframe && probe.srcdocLen > 100,
probe ? `srcdoc ${probe.srcdocLen} 字节,高度 ${Math.round(probe.height)}px` : '没找到 #html-body');
step('iframe 带 sandbox 且不给 allow-scripts',
!!probe && typeof probe.sandbox === 'string' && !/allow-scripts/.test(probe.sandbox),
probe ? `sandbox="${probe.sandbox}"` : '');
step('iframe 文档里没有 <script>', !!probe && probe.srcdocHasScript === false, '');
step('iframe 文档带 CSP', !!probe && probe.srcdocHasCsp === true, '');
step('★ 邮件里的脚本没有执行(window.__pwned 未设置)', !!probe && probe.pwned === false,
probe ? String(probe.pwnedInFrames) : '');
step('界面提示了拦截/清理', !!probe && probe.notice, '');
step('有富文本/纯文本切换', !!probe && probe.toggle, '');
// 切纯文本
await cdp.eval(`(() => { const b = document.querySelector('#view-text'); if (b) b.click(); })()`);
await sleep(500);
const textView = await cdp.eval(`(() => ({
iframe: !!document.querySelector('#html-body'),
article: !!document.querySelector('article.mail-body'),
hasText: document.body.innerText.includes(${JSON.stringify(stamp)}),
}))()`);
step('切到纯文本后不再用 iframe、改用 <article>',
!textView.iframe && textView.article && textView.hasText, JSON.stringify(textView));
// 切回富文本
await cdp.eval(`(() => { const b = document.querySelector('#view-html'); if (b) b.click(); })()`);
await sleep(700);
const backToHtml = await cdp.eval('!!document.querySelector("#html-body")');
step('切回富文本正常', backToHtml, '');
const unexpected = cdp.errors.filter((e) => !/403 \(Forbidden\)/.test(e));
step('零 JS 异常 / 零 console.error', unexpected.length === 0, unexpected.slice(0, 3).join(' | '));
} catch (err) {
step('验收过程异常', false, `${err.name}: ${err.message}`);
} finally {
try { if (cdp) cdp.ws.close(); } catch { }
try { if (child) child.kill(); } catch { }
// 清理测试邮件(客户端的删除接口是 POST /api/delete,不是 REST 风格的 DELETE)
if (uid && !KEEP) {
try {
await api(base, 'POST', '/api/delete', { uid, folder: 'INBOX', permanent: true });
step('清理测试邮件(永久删除)', true, `uid=${uid}`);
} catch (err) {
step('清理测试邮件', false, err.message);
}
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
say('');
say(`结果:${pass} 项通过,${fail} 项失败`);
for (const r of results.filter((x) => !x.ok)) say(` [失败] ${r.name} —— ${r.detail}`);
try {
const out = path.join(__dirname, '..', '..', 'artifacts', 'client-ui-check-v4.txt');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, report.join('\n'), 'utf8');
} catch { }
process.exitCode = fail === 0 ? 0 : 1;
}
})();
+503
View File
@@ -0,0 +1,503 @@
'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 { /* 忽略 */ }
}
})();
+161
View File
@@ -0,0 +1,161 @@
'use strict';
/**
* 针对性诊断:键盘动作(S / R / #)为什么没生效。
* 做法:无头打开页面,把 app.js 里的内部状态(state.selected / state.open)与
* 当前焦点元素、toast 文案都直接读出来 —— 比猜快得多。
*/
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const URL_ = 'http://127.0.0.1:8788/';
const PORT = 9223;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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 connect failed')), { 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((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(' '));
}
});
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 + ' timeout')); } }, 20000);
});
}
async eval(expression) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) return { __err: r.exceptionDetails.text || 'eval error' };
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));
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-diag-'));
const child = spawn(edge, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
'--no-first-run', '--disable-extensions', '--window-size=1600,1000', URL_], { stdio: 'ignore' });
let cdp = null;
const out = [];
const log = (s) => { out.push(s); process.stdout.write(s + '\n'); };
try {
let target = null;
for (let i = 0; i < 40 && !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 { /* 等 */ }
}
cdp = await Cdp.connect(target.webSocketDebuggerUrl);
await cdp.send('Runtime.enable');
await cdp.send('Page.navigate', { url: URL_ });
// 等界面渲染
let rows = 0;
for (let i = 0; i < 40 && rows === 0; i++) { await sleep(500); rows = await cdp.eval('document.querySelectorAll(".row").length'); }
log(`邮件行 = ${rows}`);
// state 能不能从外部读到?
const probe = await cdp.eval('typeof state');
log(`typeof state = ${probe}`);
log('');
log('── 步骤 1:点击第一行 ──────────────────────────');
await cdp.eval('document.querySelector(".row").click()');
await sleep(1500);
log(JSON.stringify(await cdp.eval(`({
selected: (typeof state !== 'undefined' ? state.selected : 'N/A'),
openUid: (typeof state !== 'undefined' && state.open ? state.open.uid : null),
activeTag: document.activeElement ? document.activeElement.tagName : null,
activeId: document.activeElement ? document.activeElement.id : null,
readerSubject: (document.querySelector('.reader__subject')||{}).textContent || '',
toast: (document.getElementById('toastText')||{}).textContent || '',
rowsNow: document.querySelectorAll('.row').length,
})`)));
log('');
log('── 步骤 2:派发 keydown "s" ────────────────────');
const uid = await cdp.eval('document.querySelector(".row")?.dataset.uid');
log(`目标 UID = ${uid}`);
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }))`);
await sleep(1800);
log(JSON.stringify(await cdp.eval(`({
flaggedInState: (() => { const m = state.messages.find(x => String(x.uid) === '${uid}'); return m ? m.flagged : 'not-found'; })(),
rowHasFlagClass: !!(document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')),
openFlagged: (state.open ? state.open.flagged : null),
toast: (document.getElementById('toastText')||{}).textContent || '',
})`)));
log('');
log('── 步骤 3:派发 keydown "r" ────────────────────');
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', bubbles: true }))`);
await sleep(800);
log(JSON.stringify(await cdp.eval(`({
composeHidden: document.getElementById('compose').hidden,
composeTitle: document.getElementById('composeTitle').textContent,
openUid: (state.open ? state.open.uid : null),
})`)));
log('');
log('── 步骤 4:直接在 document 上监听,验证事件到底有没有到达 ──');
await cdp.eval(`(() => {
window.__probe = [];
document.addEventListener('keydown', (e) => window.__probe.push(e.key + '|' + (document.activeElement ? document.activeElement.tagName : '?')), true);
return true;
})()`);
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }))`);
await sleep(600);
log('捕获到的 keydown:' + JSON.stringify(await cdp.eval('window.__probe')));
// 对比:真实用户按键(CDP Input.dispatchKeyEvent 走浏览器输入管线)
log('');
log('── 步骤 5:用 CDP 真实键盘事件(Input.dispatchKeyEvent)──');
await cdp.eval('document.querySelector(".row").click()');
await sleep(1200);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 's', code: 'KeyS', windowsVirtualKeyCode: 83, nativeVirtualKeyCode: 83, text: 's', unmodifiedText: 's' });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 's', code: 'KeyS', windowsVirtualKeyCode: 83, nativeVirtualKeyCode: 83 });
await sleep(1800);
log(JSON.stringify(await cdp.eval(`({
rowHasFlagClass: !!(document.querySelector('.row[data-uid="${uid}"]')?.classList.contains('is-flagged')),
toast: (document.getElementById('toastText')||{}).textContent || '',
})`)));
log('');
log('运行时报错:' + JSON.stringify(cdp.errors.slice(0, 10)));
} catch (err) {
log('FATAL ' + (err && err.stack ? err.stack : err));
} finally {
if (cdp) try { cdp.ws.close(); } catch { /* 忽略 */ }
try { child.kill(); } catch { /* 忽略 */ }
await sleep(400);
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ }
fs.writeFileSync('E:\\deepseek\\artifacts\\client-ui-diag.txt', out.join('\n'), 'utf8');
}
})();