/** * shiroko-monitor 管理端验证:2FA 绑定、登录、会话、限流与管理接口 * * 覆盖: * [A] TOTP / Base32 —— RFC 4226 与 RFC 6238 官方测试向量(算法正确性的地基) * [B] 2FA 档案 —— 绑定流程、恢复码一次性、session 生命周期、防重放、失败限流 * [C] HTTP 接口 —— 未绑定时一律 403、越权路径、cookie 属性、私有配置写入 * [D] 页面 —— 未绑定只见绑定流程;绑定后需 2FA 才能进管理界面 * * 用法:node scripts/verify-admin.cjs */ const fs = require('node:fs'); const http = require('node:http'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const { chromium } = require('playwright'); const ROOT = path.join(__dirname, '..'); const PROBER = path.join(ROOT, 'prober', 'node', 'index.js'); const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const PORT = 8891; const SETUP_KEY = 'verify-admin-setup-key'; const results = []; const check = (name, pass, detail) => { results.push({ name, pass }); console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); }; const group = (t) => console.log(`\n${t}`); // 与服务端 lib/totp.js 同一套实现,用它算出「App 会显示的码」 const lib = path.join(ROOT, 'prober', 'node', 'lib', 'totp.js'); async function loadLib() { return import(`file:///${lib.replace(/\\/g, '/')}`); } async function withServer(env, fn) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-admin-')); fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true }); const cfg = path.join(tmp, 'monitors.json'); const base = JSON.parse(fs.readFileSync(cfg, 'utf8')); base.settings.dataFilter = 'config'; fs.writeFileSync(cfg, `${JSON.stringify(base, null, 2)}\n`); // 本地探测配置指向本地夹具,避免真实网络请求拖慢测试 const localCfg = path.join(tmp, 'local.json'); fs.writeFileSync(localCfg, JSON.stringify({ settings: {}, monitors: [] })); const child = spawn(process.execPath, [PROBER, '--once'], { env: { ...process.env, PORT: String(PORT), SHIROKO_DATA_DIR: tmp, SHIROKO_ADMIN_SETUP_KEY: env.setupKey ?? SETUP_KEY }, stdio: 'ignore', }); // --once 会退出,因此另起一个常驻进程供 HTTP 测试 const server = spawn(process.execPath, [PROBER, `--config=${cfg}`, `--local-config=${localCfg}`, `--out=${path.join(tmp, 'status.json')}`], { env: { ...process.env, PORT: String(PORT), SHIROKO_DATA_DIR: tmp, SHIROKO_ADMIN_SETUP_KEY: env.setupKey ?? SETUP_KEY }, stdio: 'ignore', }); await new Promise((r) => setTimeout(r, 4000)); try { await fn({ tmp, cfg, localCfg }); } finally { server.kill(); child.kill(); fs.rmSync(tmp, { recursive: true, force: true }); } } const req = async (path_, { method = 'GET', body, cookie } = {}) => { const res = await fetch(`http://localhost:${PORT}${path_}`, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(cookie ? { cookie } : {}), }, body: body ? JSON.stringify(body) : undefined, }); let data = null; try { data = await res.json(); } catch { /* 无 JSON 体 */ } return { status: res.status, data, cookie: res.headers.get('set-cookie') }; }; async function main() { const { totp, base32Decode, hotp, base32Encode, base32Decode: dec, generateSecret, generateRecoveryCodes, hashRecoveryCode, verifyTotp, otpauthUri } = await loadLib(); const { createAdminAuth } = await import(`file:///${path.join(ROOT, 'prober', 'node', 'lib', 'admin.js').replace(/\\/g, '/')}`); /* ============ [A] TOTP 算法(RFC 官方向量) ============ */ group('[A] TOTP / Base32 算法(RFC 4226 & RFC 6238 官方测试向量)'); const ascii20 = Buffer.from('12345678901234567890', 'ascii'); const hotpExpected = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489']; check('RFC 4226 HOTP 十个向量全部匹配', hotpExpected.every((v, i) => hotp(ascii20, i) === v), hotpExpected.slice(0, 3).join(',') + '…'); const totpTimes = [59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000]; const totpExpected = ['94287082', '07081804', '14050471', '89005924', '69279037', '65353130']; check('RFC 6238 TOTP(SHA1/8 位)六个向量全部匹配', totpTimes.every((t, i) => totp(ascii20, { atMs: t * 1000, digits: 8 }) === totpExpected[i]), totpExpected.join(',')); const sec = generateSecret(); check('生成密钥为 20 字节(160 位,RFC 4226 建议强度)', base32Decode(sec).length === 20, `${base32Decode(sec).length} 字节`); check('Base32 编解码可往返', base32Encode(base32Decode(sec)) === sec, sec.slice(0, 8) + '…'); check('Base32 解码容忍小写/空格/连字符', Buffer.compare(dec('ABCD EFGH-IJKL'), dec('abcdefghijkl')) === 0, `归一化后 ${dec('abcd efgh-ijkl').length} 字节`); const uri = otpauthUri({ secret: sec, label: 'shiroko', issuer: 'shiroko-monitor' }); check('otpauth 链接含 secret/digits/period/issuer', /secret=/.test(uri) && /digits=6/.test(uri) && /period=30/.test(uri) && /issuer=shiroko-monitor/.test(uri), uri.slice(0, 70) + '…'); const good = totp(base32Decode(sec)); check('正确验证码通过校验', verifyTotp(sec, good) === null, good); check('错误验证码被拒', verifyTotp(sec, '000000') === 'invalid-code' || verifyTotp(sec, good) === 'invalid-code'); check('格式非法的输入被拒', verifyTotp(sec, 'abc') === 'invalid-format', ''); check('验证码容错 ±1 个时间窗', verifyTotp(sec, totp(base32Decode(sec), { atMs: Date.now() - 30000 })) === null, '30 秒前'); check('超窗(5 分钟前)的码被拒', verifyTotp(sec, totp(base32Decode(sec), { atMs: Date.now() - 5 * 60000 })) === 'invalid-code', ''); const rc = generateRecoveryCodes(10); check('恢复码 10 枚且格式为 XXXXX-XXXXX', rc.length === 10 && rc.every((c) => /^[A-Z2-9]{5}-[A-Z2-9]{5}$/.test(c)), rc[0]); check('恢复码不含易混淆字符 0/O/1/I', rc.join('').replace(/-/g, '').split('').every((ch) => !"01IO".includes(ch)), ''); check('恢复码哈希对小写/去连字符/带空格均一致', (() => { const s = 'salt123'; const h = hashRecoveryCode(rc[0], s); return h === hashRecoveryCode(rc[0].toLowerCase(), s) && h === hashRecoveryCode(rc[0].replace('-', ''), s) && h === hashRecoveryCode(` ${rc[0]} `, s); })(), ''); /* ============ [B] 2FA 档案与会话 ============ */ group('[B] 2FA 档案:绑定 / 会话 / 防重放 / 限流'); const btmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-2fa-')); let clock = Date.now(); const A = createAdminAuth({ storePath: path.join(btmp, 'admin.json'), setupKey: SETUP_KEY, now: () => clock }); check('初始状态为未绑定', A.isBound() === false, JSON.stringify(A.status())); check('错误绑定口令被拒', A.beginSetup('wrong', '1.1.1.1').error === 'bad-setup-key'); const s1 = A.beginSetup(SETUP_KEY, '10.0.0.1'); check('正确口令换取密钥', s1.ok && s1.secret.length === 32 && s1.uri.startsWith('otpauth://'), `${s1.secret.slice(0, 8)}…`); check('未完成绑定时不可登录', A.login('123456', '10.0.0.1').error === 'not-bound'); check('错误验证码无法完成绑定', A.confirmSetup(s1.setupToken, '000000').ok === false); check('错误的 setupToken 被拒', A.confirmSetup('forged', '000000').error === 'no-setup'); clock += 11 * 60 * 1000; check('绑定流程 10 分钟后失效', A.confirmSetup(s1.setupToken, '000000').error === 'setup-expired'); const s2 = A.beginSetup(SETUP_KEY, '10.0.0.1'); const bound = A.confirmSetup(s2.setupToken, totp(base32Decode(s2.secret), { atMs: clock })); check('正确验证码完成绑定并生成 10 枚恢复码', bound.ok && bound.recoveryCodes.length === 10, `首枚 ${bound.recoveryCodes[0]}`); check('绑定后不能再次绑定', A.beginSetup(SETUP_KEY, '10.0.0.1').error === 'already-bound'); check('档案落盘在 data 目录(已 gitignore)', fs.existsSync(path.join(btmp, 'admin.json')), 'admin.json'); const stored = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8')); check('落盘文件不含恢复码明文', !stored.recovery?.some?.((h) => bound.recoveryCodes.includes(h)) && typeof stored.recovery[0] === 'string' && stored.recovery[0].length === 64, '仅存 sha256'); const l1 = A.login(bound.recoveryCodes[0], '2.2.2.2'); check('恢复码可登录', l1.ok && l1.via === 'recovery', ''); check('会话 id 为高熵随机串', l1.sessionId.length >= 40, `${l1.sessionId.length} 字符`); check('有效会话可校验通过', A.verifySession(l1.sessionId) !== null); check('伪造的会话 id 被拒', A.verifySession('forged-session') === null); check('同一枚恢复码不能复用', A.login(bound.recoveryCodes[0], '2.2.2.2').ok === false); check('剩余恢复码减 1', A.status().recoveryLeft === 9, `${A.status().recoveryLeft} 枚`); const store2 = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8')); const code2 = totp(base32Decode(store2.secret), { atMs: clock }); const l2 = A.login(code2, '3.3.3.3'); check('TOTP 可登录', l2.ok && l2.via === 'totp', code2); check('同一时间步的码不能重放', A.login(code2, '3.3.3.3').error === 'replayed'); clock += 31000; const code3 = totp(base32Decode(store2.secret), { atMs: clock }); check('下一个时间窗的码可用', A.login(code3, '3.3.3.3').ok === true, code3); // 登出与空闲超时 A.destroySession(l1.sessionId); check('登出后会话立即失效', A.verifySession(l1.sessionId) === null); clock += 31 * 60 * 1000; check('超过 30 分钟空闲的会话失效', A.verifySession(l2.sessionId) === null); check('超过 2 小时的最长会话失效', (() => { clock -= 31 * 60 * 1000; const l = A.login(totp(base32Decode(store2.secret), { atMs: clock }), '4.4.4.4'); clock += 2 * 60 * 60 * 1000 + 1000; return A.verifySession(l.sessionId) === null; })(), ''); // 限流 const L = createAdminAuth({ storePath: path.join(btmp, 'admin.json'), setupKey: SETUP_KEY, now: () => clock }); let last = null; for (let i = 0; i < 5; i++) last = L.login('000000', '9.9.9.9'); check('连续 5 次失败后锁定该 IP', last.locked === true, `失败 ${last.fails} 次`); check('锁定期间即使验证码正确也进不去', (() => { const fresh = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8')); clock += 31 * 1000; const r = L.login(totp(base32Decode(fresh.secret), { atMs: clock }), '9.9.9.9'); return r.ok === false && r.error === 'locked'; })(), ''); check('其他 IP 不受该 IP 锁定影响', (() => { const fresh = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8')); clock += 31 * 1000; return L.login(totp(base32Decode(fresh.secret), { atMs: clock }), '8.8.8.8').ok === true; })(), ''); check('登录成功即清零该 IP 的失败计数', (() => { const fresh = JSON.parse(fs.readFileSync(path.join(btmp, 'admin.json'), 'utf8')); clock += 31 * 1000; L.login('000000', '7.7.7.7'); clock += 31 * 1000; const r = L.login(totp(base32Decode(fresh.secret), { atMs: clock }), '7.7.7.7'); return r.ok === true && L.status() !== null; })(), ''); check('重置需正确的 setup key', A.reset('wrong').ok === false); check('重置后回到未绑定并清空会话', (() => { const r = A.reset(SETUP_KEY); return r.ok && A.isBound() === false && A.activeSessions() === 0; })(), ''); /* ============ [C] HTTP 接口 ============ */ group('[C] HTTP 接口:鉴权边界与写入落点'); await withServer({}, async ({ tmp, localCfg }) => { const st = await req('/api/admin/status'); check('未登录可查 2FA 状态(供页面决定显示绑定还是登录)', st.status === 200 && st.data.bound === false, JSON.stringify(st.data).slice(0, 80)); check('状态接口不回显 setup key 本身', !JSON.stringify(st.data).includes(SETUP_KEY), ''); const locked = await req('/api/admin/overview'); check('未绑定时概览接口 403', locked.status === 403 && locked.data.error === 'not-bound', `HTTP ${locked.status}`); const locked2 = await req('/api/admin/probe-now', { method: 'POST' }); check('未绑定时立即重探 403', locked2.status === 403, `HTTP ${locked2.status}`); const locked3 = await req('/api/admin/maintenance', { method: 'POST', body: { from: '2030-01-01T00:00:00Z', to: '2030-01-01T01:00:00Z' } }); check('未绑定时新增维护窗口 403', locked3.status === 403, `HTTP ${locked3.status}`); check('未绑定时写操作没有产生任何文件改动', !fs.existsSync(localCfg) || JSON.parse(fs.readFileSync(localCfg, 'utf8')).settings?.maintenance === undefined, ''); check('错误绑定口令返回 401', (await req('/api/admin/2fa/setup', { method: 'POST', body: { key: 'wrong' } })).status === 401); const setupRes = await req('/api/admin/2fa/setup', { method: 'POST', body: { key: SETUP_KEY } }); check('正确口令换取密钥', setupRes.status === 200 && setupRes.data.secret?.length === 32, setupRes.data.uri?.slice(0, 50) + '…'); check('绑定响应含 secret 与 uri 但不含恢复码', !setupRes.data.recoveryCodes, ''); const c0 = totp(base32Decode(setupRes.data.secret)); const badConfirm = await req('/api/admin/2fa/confirm', { method: 'POST', body: { setupToken: setupRes.data.setupToken, code: '000000' } }); check('错误验证码无法完成绑定', badConfirm.status === 400, `HTTP ${badConfirm.status}`); const confirm = await req('/api/admin/2fa/confirm', { method: 'POST', body: { setupToken: setupRes.data.setupToken, code: c0 } }); check('正确验证码完成绑定并返回恢复码', confirm.status === 200 && confirm.data.recoveryCodes?.length === 10, ''); check('重复绑定返回 409', (await req('/api/admin/2fa/setup', { method: 'POST', body: { key: SETUP_KEY } })).status === 409); // 会话边界 check('已绑定但无会话时概览 401', (await req('/api/admin/overview')).status === 401); check('伪造会话 cookie 无效', (await req('/api/admin/overview', { cookie: 'shiroko_admin=forged' })).status === 401); const badLogin = await req('/api/admin/login', { method: 'POST', body: { code: '000000' } }); check('错误验证码登录 401', badLogin.status === 401 && badLogin.data.error === 'invalid-code', `失败计数 ${badLogin.data.fails}`); const login = await req('/api/admin/login', { method: 'POST', body: { code: c0 } }); check('正确验证码登录 200', login.status === 200, `方式 ${login.data.via}`); const setCookie = login.cookie ?? ''; check('会话 cookie 为 HttpOnly', /HttpOnly/i.test(setCookie), setCookie.slice(0, 60) + '…'); check('会话 cookie 为 SameSite=Strict', /SameSite=Strict/i.test(setCookie), ''); check('会话 cookie 的 Path 覆盖接口路径', /Path=\//i.test(setCookie), /Path=\/admin/.test(setCookie) ? '仍是 /admin(接口收不到)' : 'Path=/'); const sid = /shiroko_admin=([^;]+)/.exec(setCookie)?.[1]; check('同码不能重放', (await req('/api/admin/login', { method: 'POST', body: { code: c0 } })).data.error === 'replayed'); const ov = await req('/api/admin/overview', { cookie: `shiroko_admin=${sid}` }); check('带会话可读概览', ov.status === 200 && typeof ov.data.stats?.total === 'number', `目标 ${ov.data.stats?.total} 个`); check('概览含 2FA 状态但不泄露 secret', Boolean(ov.data.twofa) && !JSON.stringify(ov.data.twofa).includes('secret'), ''); // 管理写操作:只落私有层 const pubBefore = fs.readFileSync(path.join(tmp, 'monitors.json'), 'utf8'); const mw = await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { from: '2030-01-01T00:00:00Z', to: '2030-01-01T01:00:00Z', note: '验证窗口' } }); check('新增维护窗口 200', mw.status === 200 && mw.data.entry.note === '验证窗口', ''); check('维护窗口落在私有配置里', JSON.parse(fs.readFileSync(localCfg, 'utf8')).settings?.maintenance?.length === 1, 'local.json'); check('公开配置未被改动', fs.readFileSync(path.join(tmp, 'monitors.json'), 'utf8') === pubBefore, ''); check('无效时间窗口被拒 400', (await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { from: '2030-01-02T00:00:00Z', to: '2030-01-01T00:00:00Z' } })).status === 400); check('非法 probeScope 被拒 400', (await req('/api/admin/probe-scope', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { probeScope: 'evil' } })).status === 400); const scope = await req('/api/admin/probe-scope', { cookie: `shiroko_admin=${sid}`, method: 'POST', body: { probeScope: 'all' } }); check('切换探测范围 200', scope.status === 200, scope.data.probeScope); check('无会话时切换范围 401', (await req('/api/admin/probe-scope', { method: 'POST', body: { probeScope: 'all' } })).status === 401); check('未知管理接口 404', (await req('/api/admin/nope', { cookie: `shiroko_admin=${sid}` })).status === 404); // 删除维护窗口 check('删除维护窗口 200', (await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'DELETE', body: { index: 0 } })).status === 200); check('删除后窗口为空', (JSON.parse(fs.readFileSync(localCfg, 'utf8')).settings?.maintenance ?? []).length === 0); check('删除越界序号被拒 400', (await req('/api/admin/maintenance', { cookie: `shiroko_admin=${sid}`, method: 'DELETE', body: { index: 99 } })).status === 400); // 登出 check('登出 200', (await req('/api/admin/logout', { cookie: `shiroko_admin=${sid}`, method: 'POST' })).status === 200); check('登出后会话失效', (await req('/api/admin/overview', { cookie: `shiroko_admin=${sid}` })).status === 401); // 超大请求体 const big = await req('/api/admin/login', { method: 'POST', body: { code: 'x'.repeat(200000) } }); check('超大请求体返回 413(而不是断开连接)', big.status === 413 && big.data?.error === 'body-too-large', `HTTP ${big.status}`); check('超大请求后服务仍存活', (await req('/api/admin/status')).status === 200, ''); const badJson = await fetch(`http://localhost:${PORT}/api/admin/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{not json' }); check('非法 JSON 返回 400', badJson.status === 400, `HTTP ${badJson.status}`); }); // 未设置 setup key 时绑定应关闭 group('[C2] 未设置 SHIROKO_ADMIN_SETUP_KEY 时绑定关闭'); await withServer({ setupKey: '' }, async () => { const st = await req('/api/admin/status'); check('状态接口声明未配置绑定口令', st.data.setupKeyConfigured === false, ''); const r = await req('/api/admin/2fa/setup', { method: 'POST', body: { key: '' } }); check('绑定接口返回 503(管理端未开放)', r.status === 503, `HTTP ${r.status}`); }); /* ============ [D] 页面 ============ */ group('[D] 管理页面:未绑定只见绑定流程,绑定后必须过 2FA'); const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] }); await withServer({}, async () => { const page = await browser.newPage({ viewport: { width: 1200, height: 900 } }); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.goto(`http://localhost:${PORT}/admin`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(700); check('/admin 页面可访问且是绑定流程', await page.isVisible('#bind-panel'), '仅显示绑定面板'); check('未绑定时不出现管理面板', !(await page.isVisible('#console-panel'))); check('页面 noindex(不被搜索引擎收录)', (await page.getAttribute('meta[name="robots"]', 'content'))?.includes('noindex'), ''); await page.fill('#setup-key', SETUP_KEY); await page.click('#setup-start'); await page.waitForSelector('#secret-text', { state: 'visible', timeout: 10000 }); const shownSecret = (await page.textContent('#secret-text')).replace(/\s/g, ''); check('页面展示 Base32 密钥并分组', shownSecret.length === 32, shownSecret.slice(0, 8) + '…'); const qrInk = await page.evaluate(() => { const c = document.getElementById('qr'); const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; let dark = 0; for (let i = 0; i < d.length; i += 4) if (d[i] < 128) dark++; return dark / (d.length / 4); }); check('二维码已渲染(非空白)', qrInk > 0.3 && qrInk < 0.6, `${(qrInk * 100).toFixed(1)}% 黑点`); await page.fill('#confirm-code', totp(base32Decode(shownSecret))); await page.click('#confirm-btn'); await page.waitForSelector('#recovery-list code', { timeout: 10000 }); const codes = await page.$$eval('#recovery-list code', (els) => els.map((e) => e.textContent)); check('绑定完成展示 10 枚恢复码', codes.length === 10, codes[0]); check('确认绑定前不展示管理面板', !(await page.isVisible('#console-panel'))); await page.click('#recovery-done'); await page.waitForSelector('#console-panel', { state: 'visible', timeout: 15000 }); check('绑定完成后凭本次 2FA 直接进入管理界面', await page.isVisible('#console-panel')); const stats = (await page.textContent('#admin-stats')).replace(/\s+/g, ' '); check('管理界面渲染运行统计', /\d+监控数/.test(stats), stats.slice(0, 60)); check('管理界面列出监控目标', (await page.$$('#monitor-list tr')).length > 0, `${(await page.$$('#monitor-list tr')).length} 个`); check('管理界面展示 2FA 摘要(恢复码余量)', /剩余恢复码\s*10\s*枚/.test((await page.textContent('#twofa-summary')).replace(/\s+/g, ' ')), ''); // 管理操作 await page.click('#mw-add'); await page.waitForTimeout(800); check('可新增维护窗口', (await page.$$('#mw-list .mw-item')).length === 1, ''); await page.selectOption('#probe-scope', 'all'); await page.waitForTimeout(800); check('可切换探测范围', /已改为 all/.test(await page.textContent('#action-msg')), ''); await page.selectOption('#data-filter', 'both'); await page.waitForTimeout(800); check('可切换展示口径', /已改为 both/.test(await page.textContent('#action-msg')), ''); // 会话持久性 + 登出 await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(900); check('刷新后仍处于登录态(cookie 有效)', await page.isVisible('#console-panel')); await page.click('#logout-btn'); await page.waitForTimeout(1200); check('登出后回到 2FA 登录页', await page.isVisible('#login-panel')); check('登出后看不到管理面板', !(await page.isVisible('#console-panel'))); // 错误验证码 await page.fill('#login-code', '000000'); await page.click('#login-btn'); await page.waitForTimeout(600); check('错误验证码给出可读提示', /验证码不正确/.test(await page.textContent('#login-msg')), (await page.textContent('#login-msg')).trim()); check('管理页面无 JS 错误', errors.length === 0, errors.join(' | ') || '无'); await page.close(); }); await browser.close(); fs.rmSync(btmp, { recursive: true, force: true }); const failed = results.filter((x) => !x.pass); console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`); if (failed.length) { console.log('失败项:'); for (const f of failed) console.log(` ✗ ${f.name}`); process.exit(1); } } main().catch((err) => { console.error('FAILED:', err.stack || err.message); process.exit(1); });