/** * 证书管家后台页面的**真实浏览器验证**。 * * 为什么不能只靠 node --check 和 tsc:这两个只保证「语法/类型对」, * 不保证「DOM 真的画出来了」。本项目历史上出现过「数据对了但按钮点了没反应」 * 这类只有浏览器才能发现的问题。 * * 打不通 wrangler dev 是意料之中 —— 本机沙箱会把 127.0.0.1 的连接掐掉 * (TCP 到 TIME_WAIT 但 HTTP 响应拿不到)。所以这里换一条路: * · 用 CDP 开一个 **静态页**,把 /api/v2/* 全部用 fetch mock 拦掉 * · 用真实的 admin.js + admin.css + index.html(不复制、不简化),只替换数据源 * · 角色切换走**真实 boot 流程**:改 localStorage 的 atk.user → reload 页面, * 由 admin.js 自己的 boot()/showApp()/renderTabs() 决定画什么 * 这样验证的是「前端代码画不画得出界面」,而不是「后端通不通」—— * 后端那一层已经由 tools/selftest-ssl.mjs 覆盖了 117 项。 * * 踩坑记录: * · 剥 \n$1', ); if (finalHtml === html) throw new Error('mock 注入失败:没找到 admin.js 的 script 标签'); writeFileSync(join(pubDir, 'admin', 'index.html'), finalHtml); // ---------------------------------------------------------------- 起服务 + CDP const { createServer } = await import('node:http'); const server = createServer((req, res) => { const p = new URL(req.url, 'http://x').pathname; const file = p === '/' || p === '/admin/' || p === '/admin' ? '/admin/index.html' : p; const full = join(pubDir, file); try { const buf = readFileSync(full); const ext = full.slice(full.lastIndexOf('.')); const ct = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }[ext] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': ct }); res.end(buf); } catch { res.writeHead(404); res.end('nf'); } }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const pagePort = server.address().port; const URL_ = `http://127.0.0.1:${pagePort}/admin/`; const userDir = mkdtempSync(join(tmpdir(), 'cdp-ssl-')); const chrome = spawn(CHROME, [ '--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`, '--no-first-run', '--disable-gpu', '--window-size=1320,1500', 'about:blank', ], { stdio: 'ignore' }); let fail = 0; const results = []; const t = (name, ok, extra) => { results.push((ok ? ' ✓ ' : ' ✗ ') + name + (ok || !extra ? '' : ' → ' + extra)); if (!ok) fail++; }; try { for (let i = 0; i < 50; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ } await sleep(300); } const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json(); const ws = new WebSocket(tab.webSocketDebuggerUrl); let id = 0; const pending = new Map(); ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); await new Promise((res) => ws.addEventListener('open', res)); const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); }); const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.result && r.result.exceptionDetails) return 'EXC: ' + JSON.stringify(r.result.exceptionDetails.exception); return r.result?.result?.value ?? null; }; // 收集页面错误 —— 这是「画不出来」类 bug 的第一手证据 const errs = []; ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.method === 'Runtime.exceptionThrown') { errs.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text); } }); await send('Runtime.enable'); await send('Page.enable'); await sleep(3500); // ---------- 基本渲染(管理员,走真实 boot) t('登录门已隐藏', (await ev(`document.querySelector('#gate').hidden`)) === true); t('主界面已显示', (await ev(`document.querySelector('#app').hidden`)) === false); t('身份栏是「群林」', String(await ev(`document.querySelector('#who')?.textContent`)).includes('群林')); // ---------- 导航里能看到「证书管家」(管理员全量菜单) const navText = await ev(`document.querySelector('#tabs').textContent`); t('导航含「证书管家」', String(navText).includes('证书管家'), String(navText)); // ---------- 点进证书管家 await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); return !!b; })()`); await sleep(2200); t('顶部标题为「证书管家」', String(await ev(`document.querySelector('.page-head h2')?.textContent`)).includes('证书管家')); const view = String(await ev(`document.querySelector('#view').innerHTML`)); t('域名表格已渲染', view.includes('usj.cc') && view.includes('t-t.live')); t('SAN 显示出来了', view.includes('*.usj.cc')); t('签发方显示出来了', view.includes('LiteSSL')); t('临期域名有告警横幅', view.includes('200181.xyz') && view.includes('临期')); t('待登记的域名有「无记录」标记', view.includes('new.example.com')); t('孤儿记录提示出现', view.includes('old.example.org')); // 剩余天数的数值 t('剩余天数渲染为数字', /61/.test(view) && /19/.test(view)); t('「实测」按钮存在', view.includes('ssl-probe')); t('「登记」按钮存在', view.includes('ssl-import')); t('「+ 域名」按钮存在', view.includes('ssl-add-domain')); // 本轮新增的三个动作 t('★ 「环境自检」按钮存在', view.includes('ssl-selfcheck')); t('★ 「该续期了吗」按钮存在', view.includes('ssl-renew-check')); t('★ 每行有「签发」按钮', view.includes('data-act="ssl-issue"')); // ---------- 环境自检弹窗(本轮新增) { const before = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`)); await ev(`(() => { const b = document.querySelector('button[data-act="ssl-selfcheck"]'); if (b) b.click(); })()`); await sleep(1500); const m = String(await ev(`document.querySelector('#modal')?.innerHTML || ''`)); t('★ 自检弹窗打开', m !== before && m.includes('环境自检'), m.slice(0, 80)); t('★ 自检列出各项', m.includes('CA') && m.includes('DNS') && m.includes('部署')); t('★ 自检说明「不会签发证书」', m.includes('不会签发证书')); await ev(`(() => { const b = document.querySelector('#modal button[data-act="modal-close"]'); if (b) b.click(); })()`); await sleep(400); } // ---------- 凭据区 await sleep(600); const acc = String(await ev(`document.querySelector('#ssl-access')?.innerHTML`)); t('凭据列表已渲染', acc.includes('tencent-usj') && acc.includes('cloudflare')); t('★ 凭据只显示脱敏值', acc.includes('AKID********3f2a') && !acc.includes('AKIDabcdefg')); t('解不开的凭据被标出来', acc.includes('old-broken') && acc.includes('读不出')); t('凭据有编辑/删除按钮', acc.includes('ssl-access-edit') && acc.includes('ssl-access-del')); // ---------- 日志区 const logHtml = String(await ev(`document.querySelector('#ssl-log')?.innerHTML`)); t('执行日志已渲染', logHtml.includes('检查 3 个域名') && logHtml.includes('低于阈值')); // ---------- 通知设置 const notify = String(await ev(`document.querySelector('#ssl-notify-cfg')?.innerHTML`)); t('通知收件人回显', notify.includes('177018615@qq.com')); t('提醒天数回显', notify.includes('value="30"')); // ---------- 打开「+ 域名」弹窗 await ev(`document.querySelector('[data-act="ssl-add-domain"]').click()`); await sleep(900); const modalTitle = await ev(`document.querySelector('#modalTitle')?.textContent`); t('「+ 域名」弹窗打开了', String(modalTitle).includes('新增域名'), String(modalTitle)); const modalBody = String(await ev(`document.querySelector('#modalBody')?.innerHTML`)); t('弹窗里有 DNS 凭据下拉', modalBody.includes('sdDns') && modalBody.includes('tencent-usj')); t('弹窗里有部署目标复选', modalBody.includes('sdDeploy_dogecloud') && modalBody.includes('sdDeploy_1panel')); t('弹窗里有 SAN 输入', modalBody.includes('sdSan')); await ev(`document.querySelector('#modal [data-act="modal-close"]')?.click()`); await sleep(400); // ---------- 打开凭据编辑弹窗(含脱敏回显) await ev(`document.querySelector('[data-act="ssl-access-edit"][data-name="tencent-usj"]').click()`); await sleep(900); const accModal = String(await ev(`document.querySelector('#modalBody')?.innerHTML`)); t('凭据弹窗打开了', accModal.includes('saName') && accModal.includes('saType')); t('★ 凭据弹窗里回显的是脱敏值', accModal.includes('AKID********3f2a')); t('凭据弹窗提示「留空则不改」', accModal.includes('留空则不改') || accModal.includes('留空')); await ev(`document.querySelector('#modal [data-act="modal-close"]')?.click()`); await sleep(400); // ---------- 页面无 JS 异常(管理员阶段) const realErrs = errs.filter((e) => !/favicon|ERR_|Failed to load resource/i.test(String(e))); t('★ 页面无未捕获 JS 异常', realErrs.length === 0, realErrs.slice(0, 3).join(' | ')); // ---------- ★ SSL 专属角色:改 localStorage → 真实 reload → 走真实 boot await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 9, name: '阿美', is_admin: false, role: 'ssl' })); 'ok'`); await send('Page.reload').catch(() => {}); await sleep(3000); const sslAppShown = (await ev(`document.querySelector('#app').hidden`)) === false; t('★ SSL 角色能进主界面', sslAppShown); const sslTabs = String(await ev(`document.querySelector('#tabs')?.textContent`)); t('★ SSL 角色只看到「证书管家」', sslAppShown && sslTabs.includes('证书管家') && !sslTabs.includes('评论管理') && !sslTabs.includes('文章编辑') && !sslTabs.includes('仪表盘'), sslTabs); const sslView = String(await ev(`document.querySelector('#view').innerHTML`)); t('★ SSL 角色落地页就是证书管家', sslAppShown && sslView.includes('usj.cc'), sslView.slice(0, 120)); const sslWho = String(await ev(`document.querySelector('#who')?.textContent`)); t('★ 身份栏标了 SSL', sslWho.includes('阿美') && sslWho.includes('SSL'), sslWho); // ---------- ★ 编辑角色:看不到证书管家 await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 8, name: '小明', is_admin: false, role: 'editor' })); 'ok'`); await send('Page.reload').catch(() => {}); await sleep(3000); const edAppShown = (await ev(`document.querySelector('#app').hidden`)) === false; const edTabs = String(await ev(`document.querySelector('#tabs')?.textContent`)); t('★ 编辑角色进得去主界面', edAppShown); t('★ 编辑角色看得到「文章编辑」', edAppShown && edTabs.includes('文章编辑'), edTabs); t('★ 编辑角色看不到「证书管家」', !edTabs.includes('证书管家'), edTabs); // ---------- 截图(人眼复核) await ev(`localStorage.setItem('atk.user', JSON.stringify({ id: 1, name: '群林', is_admin: true, role: 'admin' })); 'ok'`); await send('Page.reload').catch(() => {}); await sleep(3000); await ev(`(() => { const b = [...document.querySelectorAll('#tabs .tab')].find(x => x.textContent.includes('证书管家')); if (b) b.click(); })()`); await sleep(2000); // 点一次「环境自检」——本轮新增的核心能力,把结果弹窗截进画面。 await ev(`(() => { const b = document.querySelector('button[data-act="ssl-selfcheck"]'); if (b) b.click(); })()`); await sleep(2000); await ev(`document.documentElement.setAttribute('data-theme','light')`); await sleep(500); { const s = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true }); // ★ 落点必须用**项目**目录(root),不是临时目录:临时目录跑完整个被删, // 截图写进去等于没写(上一版就是这样,人眼永远看不到)。 const out = join(root, '.editor-tmp', 'ssl-panel-light.png'); mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, Buffer.from(s.result.data, 'base64')); console.log('\n截图:' + out); } console.log('\n' + results.join('\n')); console.log('\n' + '='.repeat(56)); console.log(fail ? `失败 ${fail} 项` : '全部通过 ✓'); ws.close(); } finally { chrome.kill(); server.close(); } process.exit(fail ? 1 : 0);