'use strict'; /** * 账号界面验收(v3):注册 / 忘记密码 / 账号设置。 * * 验的是「真的能用」,而且**真的打通了服务器**: * 浏览器 → 客户端本地后端(/api/account/*) → 服务器公网账号入口(https://mail.example.com:9443) → 服务端 v2.2.0 * 所以最后一步是拿一个**故意写错的邀请码**去提交,断言界面上出现服务端返回的「邀请码不正确」—— * 这条链路只要有一环断掉,这个断言就过不了。 * * 用法:node tools/ui-check-v3.js [url] [--creds ] */ 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', 'uitest@wpy.email'); 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 { } } })();