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