Files
wpywmail-client/tools/ui-check-v3.js
T

292 lines
14 KiB
JavaScript
Raw 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';
/**
* 账号界面验收(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 { }
}
})();