证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 certimate(MIT)的 DNS-01 流程自行实现,不再依赖闭源 certd。
新增(纯 WebCrypto,零 npm 依赖):
- lib/acme.ts ACME v2 客户端:ES256 JWS(原始 r||s)、RFC7638
thumbprint、EAB、badNonce 重试、DNS-01、手写 DER CSR
- lib/dnsprovider.ts DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare
- lib/deployer.ts 部署适配:多吉云 CDN、1Panel 站点(幂等换证书)
- lib/certissue.ts 编排:探针判剩余天数 → 注册/复用账户 → 签发 → 落库
→ 逐目标部署;RENEW_BEFORE_DAYS=30
- routes/ssl.ts 新增 POST /ssl/issue、GET /ssl/renew-check、
POST /ssl/selfcheck(环境自检,只读不签发)
- index.ts + cron 每日 04:10 自动续期检查;cpu_ms 提到 60s
与 certimate 的差异:certimate 每个 workflow 每天无条件重跑,
这里改为先探针查剩余天数、低于阈值才签,省 CA 限速额度。
实测修正(易误判,勿回退):
- 多吉云 bind 参数是 {id, domain},非 {cert_id}(用假 id 对照实验确认:
cert_id 回「域名不存在」= 参数被无视)
- 多吉云上传私钥字段是 private;列域名用 /cdn/domain/list.json
- 1Panel 必须用 /api/v2/(v1 返回 HTTP 200 但正文是 HTML 停用页)
- 1Panel HTTPS 配置字段是 SSL(大写),写错会导致每次续期都重绑
- LiteSSL ACME 目录须带 /v2:acme.trustasia.com/acme/v2/directory
测试:selftest-acme 16/16(CSR 过 openssl 验签、JWS 过 Node crypto 验签)、
selftest-deploy 18/18、selftest:ssl 117/117、UI 全过、tsc 干净
368 lines
20 KiB
JavaScript
368 lines
20 KiB
JavaScript
/**
|
||
* 证书管家后台页面的**真实浏览器验证**。
|
||
*
|
||
* 为什么不能只靠 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 项。
|
||
*
|
||
* 踩坑记录:
|
||
* · 剥 <script> 时必须**保留 admin.js** 的标签,否则后续 mock 注入的正则
|
||
* 匹配不到任何东西 → 页面一个脚本都不加载 → 全白 + `state is not defined`
|
||
* · admin.js 是 IIFE,state/renderTabs/go 都不在全局上,别想着从控制台直接摸
|
||
*
|
||
* 用法:node tools/probe-ssl-ui.mjs
|
||
*/
|
||
import { spawn } from 'node:child_process';
|
||
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join, resolve, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const root = resolve(here, '..');
|
||
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
|
||
const PORT = 9418;
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
// ---------------------------------------------------------------- mock 数据
|
||
const DAY = 86400000;
|
||
const MOCK = {
|
||
'/api/v2/ssl/overview': {
|
||
domains: [
|
||
{ name: 'usj.cc', san: ['usj.cc', '*.usj.cc'], dns: 'tencent-usj', deploy: ['dogecloud', '1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 62 * DAY, expireText: '2026-12-07 06:59:59', issuer: 'TrustAsia Technologies, Inc.', updatedAt: Date.now(), daysLeft: 62, level: 'ok' },
|
||
{ name: 't-t.live', san: ['t-t.live', '*.t-t.live'], dns: 'tencent-tt', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 83 * DAY, expireText: '2026-12-27 16:02:51', issuer: "Let's Encrypt", updatedAt: Date.now(), daysLeft: 83, level: 'ok' },
|
||
{ name: '200181.xyz', san: ['200181.xyz'], dns: 'cloudflare', deploy: ['1panel'], disabled: false, hasCert: true, expireAt: Date.now() + 19 * DAY, expireText: '2026-10-25 12:56:26', issuer: 'LiteSSL', updatedAt: Date.now(), daysLeft: 19, level: 'warn' },
|
||
{ name: 'new.example.com', san: ['new.example.com'], dns: 'cloudflare', deploy: [], disabled: false, hasCert: false, expireAt: null, expireText: null, issuer: '', updatedAt: null, daysLeft: null, level: 'none' },
|
||
],
|
||
orphans: ['old.example.org'],
|
||
notify: { emails: ['177018615@qq.com'], daysBefore: 30 },
|
||
mailEnabled: true,
|
||
serverTime: '2026-10-06 12:00:00',
|
||
},
|
||
// 实测探针的返回形状 —— 与真接口一致(国内机 /ssl-probe 的字段)
|
||
'/api/v2/ssl/probe': {
|
||
ok: true, handshakeFailed: false, host: 'usj.cc', tlsVersion: 'TLSv1.3',
|
||
subject: 'usj.cc', issuer: 'TrustAsia Technologies, Inc.',
|
||
altNames: ['usj.cc', '*.usj.cc'],
|
||
notAfter: Date.now() + 62 * DAY, notBefore: Date.now() - 28 * DAY,
|
||
daysLeft: 62, notAfterText: '2026-12-07 06:59:59',
|
||
},
|
||
// 续期判定(只读)
|
||
'/api/v2/ssl/renew-check': {
|
||
threshold: 30,
|
||
items: [
|
||
{ domain: 'usj.cc', host: 'usj.cc', source: 'live', daysLeft: 62, action: 'ok', threshold: 30, issuer: 'TrustAsia Technologies, Inc.', error: null },
|
||
{ domain: 't-t.live', host: 't-t.live', source: 'live', daysLeft: 83, action: 'ok', threshold: 30, issuer: "Let's Encrypt", error: null },
|
||
{ domain: '200181.xyz', host: '200181.xyz', source: 'live', daysLeft: 19, action: 'renew', threshold: 30, issuer: 'LiteSSL', error: null },
|
||
],
|
||
},
|
||
// 环境自检(只读)
|
||
'/api/v2/ssl/selfcheck': {
|
||
threshold: 30,
|
||
summary: { total: 5, failed: 0 },
|
||
items: [
|
||
{ name: 'CA litessl', kind: 'ca', ok: true, detail: '目录可达,EAB 已配对' },
|
||
{ name: 'DNS tencent-usj', kind: 'dns', ok: true, detail: 'tencentcloud 凭据可用,能读取 usj.cc 的 TXT(现存 0 条)' },
|
||
{ name: 'DNS cloudflare', kind: 'dns', ok: true, detail: 'cloudflare 凭据可用,能读取 200181.xyz 的 TXT(现存 0 条)' },
|
||
{ name: '部署 dogecloud', kind: 'deploy', ok: true, detail: '多吉云凭据可用' },
|
||
{ name: '部署 1panel', kind: 'deploy', ok: true, detail: '1Panel 凭据可用,站点 usj.cc 已找到' },
|
||
],
|
||
},
|
||
'/api/v2/ssl/access': {
|
||
items: [
|
||
{ name: 'tencent-usj', type: 'tencentcloud', note: '小赵腾讯云', fields: { secretId: 'AKID********3f2a', secretKey: 'Qk9Y****gAAA' } },
|
||
{ name: 'cloudflare', type: 'cloudflare', note: '200181.xyz 的 DNS', fields: { apiToken: 'ALTp********9xQz' } },
|
||
{ name: 'dogecloud', type: 'dogecloud', note: '', fields: { accessKey: 'dk1a********8f00', secretKey: 'mmN7****11bc' } },
|
||
{ name: 'old-broken', type: '', note: '', fields: {}, unreadable: true },
|
||
],
|
||
types: ['tencentcloud', 'cloudflare', 'dogecloud', '1panel', 'acme-eab'],
|
||
},
|
||
'/api/v2/ssl/log': {
|
||
items: [
|
||
{ at: Date.now() - 60000, level: 'info', action: 'check', message: '群林: 检查 3 个域名,2 个一致,1 个需关注' },
|
||
{ at: Date.now() - 3600000, level: 'warn', action: 'check', message: '群林: 200181.xyz 剩余 19 天,低于阈值 30' },
|
||
{ at: Date.now() - 86400000, level: 'info', action: 'config', message: '群林: 保存配置:3 个域名,提醒邮箱 1 个' },
|
||
{ at: Date.now() - 172800000, level: 'error', action: 'notify', message: '群林: 邮件未配置(缺少 RESEND_API_KEY)' },
|
||
],
|
||
},
|
||
'/api/v2/ssl/certs': { items: [], warnDays: 30 },
|
||
};
|
||
|
||
// ---------------------------------------------------------------- 造静态页
|
||
const work = mkdtempSync(join(tmpdir(), 'ssl-ui-'));
|
||
const pubDir = join(work, 'public');
|
||
mkdirSync(join(pubDir, 'admin'), { recursive: true });
|
||
cpSync(join(root, 'public/admin/admin.css'), join(pubDir, 'admin/admin.css'));
|
||
cpSync(join(root, 'public/admin/admin.js'), join(pubDir, 'admin/admin.js'));
|
||
|
||
// 真实的 admin.html 骨架。剥掉外链脚本时**必须放过 admin.js**(负向先行),
|
||
// 否则后面往里注 mock 的正则会匹配不到 —— 这是上一版全白的根因。
|
||
const realHtml = readFileSync(join(root, 'public/admin/index.html'), 'utf8');
|
||
const html = realHtml.replace(
|
||
/<script(?![^>]*admin\.js)[^>]*\bsrc=["'][^"']*["'][^>]*><\/script>/g,
|
||
'',
|
||
);
|
||
|
||
// mock 注入脚本:在 admin.js 之前执行,把 fetch 换成路由表。
|
||
// /api/v2/user 动态读 localStorage —— 角色切换测试全靠它(改完 reload 即走真实 boot)。
|
||
const mockJs = `
|
||
(function () {
|
||
const MOCK = ${JSON.stringify(MOCK)};
|
||
const ok = (obj, status) => new Response(JSON.stringify(obj), {
|
||
status: status || 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
window.fetch = function (input, init) {
|
||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||
let path = url;
|
||
try { path = new URL(url, location.href).pathname; } catch (e) {}
|
||
if (path === '/api/v2/user') {
|
||
let u = null;
|
||
try { u = JSON.parse(localStorage.getItem('atk.user') || 'null'); } catch (e) {}
|
||
return Promise.resolve(ok({ user: u, is_login: !!u }));
|
||
}
|
||
// 让 /admin/session 明确返回「不是国内机模式」,走 Cloudflare 分支
|
||
if (path.endsWith('/admin/session')) {
|
||
return Promise.resolve(new Response('{"msg":"Not Found"}', { status: 404, headers: { 'Content-Type': 'application/json' } }));
|
||
}
|
||
if (MOCK[path]) return Promise.resolve(ok(MOCK[path]));
|
||
return Promise.resolve(ok({}));
|
||
};
|
||
// 预置一个已登录的管理员会话,跳过登录门。
|
||
// ★ 只在缺省时写入 —— 角色切换测试靠「改 atk.user → reload」,
|
||
// 如果这里每次都重置,reload 后角色又被改回管理员了。
|
||
if (!localStorage.getItem('atk.token')) localStorage.setItem('atk.token', 'fake-admin-token');
|
||
if (!localStorage.getItem('atk.user')) localStorage.setItem('atk.user', JSON.stringify({ id: 1, name: '群林', is_admin: true, role: 'admin' }));
|
||
})();
|
||
`;
|
||
writeFileSync(join(pubDir, 'admin', 'mock.js'), mockJs);
|
||
|
||
// 注入 mock.js(必须排在 admin.js 前面)
|
||
const finalHtml = html.replace(
|
||
/(<script[^>]*src=["'][^"']*admin\.js["'][^>]*><\/script>)/,
|
||
'<script src="./mock.js"></script>\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);
|