/** * 「首页导航防爆紧急替换」页签 · 前端界面验证 * * 目的:验证页签位置、两个按钮、滚动日志、最终 token / 短链接展示都正常工作。 * * ⚠️ 本脚本不真实触发换密钥:对 /api/token_lab/nav/emergency/* 做请求拦截, * 返回预置的假响应,只验证前端渲染与交互,绝不改动生产的密钥与短链库。 * * 运行:node dev_test_scripts/debug/debug_token_lab_emergency_ui.js */ const fs = require('fs') const puppeteer = require('puppeteer') const BASE = process.env.TL_BASE || 'http://127.0.0.1:8976' const CHROME_CANDIDATES = [ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', ] const findChrome = () => CHROME_CANDIDATES.find(p => fs.existsSync(p)) || undefined const sleep = ms => new Promise(r => setTimeout(r, ms)) let passed = 0 let failed = 0 const check = (name, ok, detail) => { if (ok) { passed += 1; console.log(` ✔ ${name}`) } else { failed += 1; console.log(` ✘ ${name}${detail ? ' → ' + detail : ''}`) } } const FAKE_TOKEN = 'eyJmYWtlLXRva2VuLWZvci11aS10ZXN0.' + 'A'.repeat(120) + '.signature' const FAKE_SHORT = 'https://maise.pro/to/UiTest1' const ROTATE_LOGS = [ '【第 1 步】轮换导航密钥 + 签发新 token', '目标有效期:30 天', '已备份原有文件 → config/_nav-key-backup-2026-09-30T16-00-00/(nav-private.jwk.json、nav.jwk.json、short_link.db)', '已生成新的 RSA-2048 密钥对(公钥指纹 abcdef1234567890)', '已写入 config/nav-private.jwk.json(含私钥,签发用)', '已写入 config/nav.jwk.json(仅公钥,信任池用)', '两处已换成同一把新钥匙 → 旧公钥彻底离开信任池,旧 token 自此刻起失效', '已用新私钥签发 token(iss=TRAE-NAV,jti=fake-jti)', '自校验:签名=通过|有效期=正常|iss=通过', '完成 ✅ 新 token 过期时间:2026/10/30 16:00:00(北京时间)', ] const RELINK_LOGS = [ '【第 2 步】更换首页短链接', '正在操作短链接数据库 data/short_link.db', 'token 校验通过(jti=fake-jti,过期 2026/10/30 16:00:00)', '已停用旧短链 /to/PSq6xv → 立即失效', '已创建新短链:https://maise.pro/to/UiTest1', '自校验:新短链返回 302,跳转目标与新 token 一致 ✅', '完成 ✅ 最终短链接:https://maise.pro/to/UiTest1', ] ;(async () => { const browser = await puppeteer.launch({ headless: true, executablePath: findChrome(), args: ['--no-sandbox', '--disable-setuid-sandbox'], }) const page = await browser.newPage() await page.setViewport({ width: 1280, height: 1000 }) await page.setCookie( { name: 'nav_gate', value: '1', domain: '127.0.0.1', path: '/' }, { name: 'token_lab_gate', value: '1', domain: '127.0.0.1', path: '/' }, ) const pageErrors = [] page.on('pageerror', e => pageErrors.push(String(e && e.message || e))) // 拦截两个紧急接口 → 返回预置响应,绝不真实执行 await page.setRequestInterception(true) let rotateHit = 0 let relinkHit = 0 let rotateBody = null page.on('request', req => { const url = req.url() if (url.includes('/api/token_lab/nav/emergency/rotate')) { rotateHit += 1 try { rotateBody = JSON.parse(req.postData() || '{}') } catch { rotateBody = null } return req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, token: FAKE_TOKEN, exp: Math.floor(Date.now() / 1000) + 30 * 86400, months: (rotateBody && rotateBody.months) || 1, days: ((rotateBody && rotateBody.months) || 1) * 30, fingerprint: 'abcdef1234567890', backupDir: '_nav-key-backup-2026-09-30T16-00-00', logs: ROTATE_LOGS, }), }) } if (url.includes('/api/token_lab/nav/emergency/relink')) { relinkHit += 1 return req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, code: 'UiTest1', shortUrl: FAKE_SHORT, disabledCodes: ['PSq6xv'], verified: true, logs: RELINK_LOGS, }), }) } return req.continue() }) console.log('=== 防爆紧急替换页签 · 前端界面验证(接口已拦截,不会真实执行) ===\n') await page.goto(`${BASE}/tools/token_lab/index.html`, { waitUntil: 'networkidle2', timeout: 30000 }) await page.waitForFunction(() => { const m = document.getElementById('appMain') return m && !m.hidden }, { timeout: 15000 }) // ---------- 1. 页签位置与文案 ---------- console.log('[1] 页签与按钮') const tabs = await page.$$eval('.tab-btn', els => els.map(e => ({ text: e.textContent.trim(), tab: e.dataset.tab, active: e.classList.contains('active') }))) check('共有 3 个页签', tabs.length === 3, JSON.stringify(tabs.map(t => t.text))) check('新页签排在第一位', tabs[0] && tabs[0].tab === 'emergency', JSON.stringify(tabs[0] || {})) check('新页签名称正确', tabs[0] && tabs[0].text === '首页导航防爆紧急替换', tabs[0] && tabs[0].text) check('原「首页导航生成器」仍在且为默认激活', tabs[1] && tabs[1].tab === 'nav' && tabs[1].active === true, JSON.stringify(tabs[1] || {})) check('原「Tool 令牌生成器」仍在', tabs[2] && tabs[2].tab === 'tool') const btnTexts = await page.evaluate(() => ({ b1: document.getElementById('btnEmergencyRotate') ? document.getElementById('btnEmergencyRotate').textContent.trim() : '', b2: document.getElementById('btnEmergencyRelink') ? document.getElementById('btnEmergencyRelink').textContent.trim() : '', })) check('按钮 1 文案为动态月份(默认 1 个月)', btnTexts.b1 === '自动生成向后 1 个月的鉴权,且当前鉴权立即失效', btnTexts.b1) check('按钮 2 文案正确', btnTexts.b2 === '将新的首页鉴权token,自动生成新的短链接,且当前短链接立即失效', btnTexts.b2) // ---------- 1b. 月份下拉 ---------- console.log('\n[1b] 有效期月份下拉(1~12,默认 1)') const selInfo = await page.evaluate(() => { const sel = document.getElementById('emergencyMonths') if (!sel) return null return { exists: true, value: sel.value, options: Array.from(sel.options).map(o => o.value), labels: Array.from(sel.options).map(o => o.textContent.trim()), // 是否紧跟按钮 1 之后 afterButton: (() => { const btn = document.getElementById('btnEmergencyRotate') return !!(btn && btn.nextElementSibling === sel) })(), } }) check('下拉存在', !!(selInfo && selInfo.exists)) check('默认选中 1 个月', selInfo && selInfo.value === '1', selInfo && selInfo.value) check('共 12 个选项', !!(selInfo && selInfo.options.length === 12), selInfo && selInfo.options.length) check('选项值为 1~12', !!(selInfo && JSON.stringify(selInfo.options) === JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])), selInfo && JSON.stringify(selInfo.options)) check('选项文案形如「N 个月」', !!(selInfo && selInfo.labels[0] === '1 个月' && selInfo.labels[11] === '12 个月'), selInfo && JSON.stringify(selInfo.labels)) check('下拉位于按钮 1 之后(同一行)', !!(selInfo && selInfo.afterButton)) // 切换下拉 → 按钮文案应跟着变 await page.select('#emergencyMonths', '6') await sleep(200) const btnAfterChange = await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim()) check('切到 6 个月后按钮文案同步更新', btnAfterChange === '自动生成向后 6 个月的鉴权,且当前鉴权立即失效', btnAfterChange) await page.select('#emergencyMonths', '12') await sleep(200) const btnAfter12 = await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim()) check('切到 12 个月后按钮文案同步更新', btnAfter12 === '自动生成向后 12 个月的鉴权,且当前鉴权立即失效', btnAfter12) // ---------- 2. 切到新页签 ---------- console.log('\n[2] 切换到新页签') await page.evaluate(() => document.querySelector('.tab-btn[data-tab="emergency"]').click()) await sleep(250) const panelShown = await page.evaluate(() => { const p = document.querySelector('.tab-panel[data-panel="emergency"]') return !!p && p.classList.contains('active') && window.getComputedStyle(p).display !== 'none' }) check('新页签面板已显示', panelShown) // ---------- 3. 点按钮 1 ---------- console.log('\n[3] 点击按钮 1(自动生成向后一个月的鉴权…)') await page.click('#btnEmergencyRotate') await sleep(2500) // 等日志逐行播放完 const after1 = await page.evaluate(() => ({ log: document.getElementById('emergencyLog').value, token: document.getElementById('emergencyTokenOut').value, meta: document.getElementById('emergencyMeta').innerText, })) check('确实调用了 rotate 接口', rotateHit === 1, `命中 ${rotateHit} 次`) check('请求体带上了所选月份 months=12', !!(rotateBody && Number(rotateBody.months) === 12), JSON.stringify(rotateBody)) check('日志框出现滚动内容', after1.log.length > 100, `长度 ${after1.log.length}`) check('日志含第 1 步标题', after1.log.includes('【第 1 步】')) check('日志含"完成"', after1.log.includes('完成 ✅')) check('日志逐行带时间戳', /\[\d{2}:\d{2}:\d{2}\]/.test(after1.log)) check('最终 token 已展示', after1.token === FAKE_TOKEN, `长度 ${after1.token.length}`) check('元信息展示公钥指纹', after1.meta.includes('abcdef1234567890'), after1.meta.slice(0, 80)) check('元信息展示有效期 12 个月', after1.meta.includes('12 个月'), after1.meta.slice(0, 120)) check('执行后按钮文案恢复为所选月份', (await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())) === '自动生成向后 12 个月的鉴权,且当前鉴权立即失效') // ---------- 4. 点按钮 2 ---------- console.log('\n[4] 点击按钮 2(将新 token 绑到新短链…)') await page.click('#btnEmergencyRelink') await sleep(2000) const after2 = await page.evaluate(() => ({ log: document.getElementById('emergencyLog').value, shortUrl: document.getElementById('emergencyShortUrlOut').value, meta: document.getElementById('emergencyMeta').innerText, })) check('确实调用了 relink 接口', relinkHit === 1, `命中 ${relinkHit} 次`) check('日志含第 2 步标题', after2.log.includes('【第 2 步】')) check('日志含停用旧短链步骤', after2.log.includes('已停用旧短链')) check('最终短链接已展示', after2.shortUrl === FAKE_SHORT, after2.shortUrl) check('元信息展示已停用的旧短码', after2.meta.includes('PSq6xv'), after2.meta.slice(0, 120)) check('元信息展示自校验结果', after2.meta.includes('通过'), after2.meta.slice(0, 120)) // ---------- 5. 其它页签未受影响 ---------- console.log('\n[5] 原有功能未受影响') for (const t of ['nav', 'tool']) { await page.evaluate(tab => document.querySelector(`.tab-btn[data-tab="${tab}"]`).click(), t) await sleep(150) const ok = await page.evaluate(tab => { const p = document.querySelector(`.tab-panel[data-panel="${tab}"]`) return !!p && p.classList.contains('active') }, t) check(`原页签 ${t} 仍可切换`, ok) } check('页面无 JS 报错', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | ')) await browser.close() console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`) process.exit(failed === 0 ? 0 : 1) })().catch(e => { console.log('\n✘ 执行异常:' + String((e && e.stack) || e)) process.exit(1) })