/** * shiroko-monitor 端到端验证脚本(开发用,需 playwright) * * 覆盖: * A. 热重载回归:改写 monitors.json 触发配置热重载后,/api/status 必须继续更新(历史 bug:接口冻结) * B. 接口与文件一致性:/api/status 与 status.json 的 lastCheck 必须一致 * C. 面板重渲染:跨越一个刷新周期后,页脚「更新于」必须前进 * D. UI 断言:卡片数、心率图、90 格可用率条、统计栏 * * 用法:NODE_PATH= node scripts/verify.cjs [baseUrl] */ const fs = require('node:fs'); const path = require('node:path'); const { chromium } = require('playwright'); const BASE = process.argv[2] || 'http://localhost:8787'; const CONFIG = path.join(__dirname, '..', 'public', 'monitors.json'); const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const results = []; function check(name, pass, detail) { results.push({ name, pass, detail }); console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); } async function getJson(url) { const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`); return res.json(); } async function main() { // ---------- A/B. 服务端数据新鲜度与一致性 ---------- const cfgA = JSON.parse(fs.readFileSync(CONFIG, 'utf8')); const cfgIdsA = new Set(cfgA.monitors.map((m) => m.id)); const first = (await getJson(`${BASE}/api/status`)).monitors; // 优先选配置里的自建探测目标;若当前 dataFilter 只展示外部数据源,则退化为一致性/新鲜度断言 const probeId = Object.keys(first).find((id) => cfgIdsA.has(id)) ?? Object.keys(first)[0]; const isProbeTarget = cfgIdsA.has(probeId); const before = first[probeId].lastCheck; console.log(`\n[A] 触发配置热重载(改写 monitors.json),监控目标 ${probeId}${isProbeTarget ? '(自建探测)' : '(外部数据源镜像)'}`); console.log(` 热重载前 lastCheck: ${before}`); // 原样写回(不改格式)即可触发 fs.watch 的 change 事件 fs.writeFileSync(CONFIG, fs.readFileSync(CONFIG, 'utf8')); await sleep(12000); const mid = await getJson(`${BASE}/api/status`); if (isProbeTarget) { check('/api/status 热重载后 12s 内已产生新检测', mid.monitors[probeId].lastCheck > before, `lastCheck=${mid.monitors[probeId].lastCheck}`); } else { // 外部镜像目标由数据源按自身节奏更新,热重载不会让它产生新检测;改为校验服务端仍在产出数据 const ageSec = Math.round((Date.now() - new Date(mid.dataUpdatedAt).getTime()) / 1000); check('热重载后接口仍持续服务且数据在节奏内', mid.monitors[probeId] != null && ageSec <= Number(mid.updateIntervalSec) + 30, `数据距今 ${ageSec}s / 节奏 ${mid.updateIntervalSec}s`); } await sleep(60000); const api = await getJson(`${BASE}/api/status`); const file = await getJson(`${BASE}/status.json`); if (isProbeTarget) { check('/api/status 跨越一个探测周期后仍在更新', api.monitors[probeId].lastCheck > mid.monitors[probeId].lastCheck, `lastCheck=${api.monitors[probeId].lastCheck}`); } check('接口与 status.json 数据一致', api.monitors[probeId].lastCheck === file.monitors[probeId].lastCheck, `api=${api.monitors[probeId].lastCheck} file=${file.monitors[probeId].lastCheck}`); const dailyKeys = Object.keys(api.monitors[probeId].daily ?? {}); check('90 天可用率数据已聚合且不超过 90 天', dailyKeys.length > 0 && dailyKeys.length <= 90, `days=${dailyKeys.length}, today=${JSON.stringify(api.monitors[probeId].daily[dailyKeys[dailyKeys.length - 1]])}`); check('统计包含 avgUptime90', api.stats.avgUptime90 != null, `avgUptime90=${api.stats.avgUptime90}`); // ---------- E. 刷新即重新检测 ---------- console.log('\n[E] 刷新即重新检测'); const cfg = JSON.parse(fs.readFileSync(CONFIG, 'utf8')); const snapMeta = await getJson(`${BASE}/api/status`); const expectedCards = Object.keys(snapMeta.monitors).length; // 外部数据源(UptimeRobot)镜像目标不参与本地强制探测,其「最近检测」由数据源给出 const externalIds = Object.entries(snapMeta.monitors).filter(([, m]) => m.meta?.source === 'uptimerobot').map(([id]) => id); // dataFilter 只展示外部数据源时,配置目标不在快照里,探测类断言不适用 const cfgIds = new Set(cfg.monitors.map((m) => m.id)); const probeIds = Object.keys(snapMeta.monitors).filter((id) => cfgIds.has(id)); const probeMode = probeIds.length > 0; const forceIds = cfg.monitors.filter((m) => m.probeOnRefresh !== false && probeIds.includes(m.id)).map((m) => m.id); // 显式 opt-out 的配置目标:不参与任何强制探测 const optOutIds = cfg.monitors.filter((m) => m.probeOnRefresh === false && probeIds.includes(m.id)).map((m) => m.id); const pre = await getJson(`${BASE}/api/status`); const t0 = Date.now(); const roundStartIso = new Date(t0).toISOString(); const fresh = await fetch(`${BASE}/api/refresh`).then((r) => r.json()); const roundMs = Date.now() - t0; check('/api/refresh 等待本轮探测完成后才返回', roundMs > 100, `round=${roundMs}ms`); if (probeMode) { const ages = forceIds.map((id) => [id, Math.round((Date.now() - new Date(fresh.monitors[id].lastCheck)) / 1000)]); const worst = ages.length ? Math.max(...ages.map(([, a]) => a)) : 0; check('刷新后参与强制检测的目标全部产生新检测', forceIds.every((id) => fresh.monitors[id].lastCheck > pre.monitors[id].lastCheck), `${forceIds.filter((id) => !(fresh.monitors[id].lastCheck > pre.monitors[id].lastCheck)).join(',') || '全部前进'}`); check('刷新后最旧数据不超过本轮耗时', worst <= Math.ceil(roundMs / 1000) + 3, `最旧 ${worst}s(轮次 ${Math.round(roundMs / 1000)}s)`); // 外部镜像目标会被顺带同步(受 60s 最小间隔约束),故不在此断言其时间不变 check('probeOnRefresh:false 的目标不参与强制检测', optOutIds.length === 0 || optOutIds.every((id) => fresh.monitors[id].lastCheck <= roundStartIso), optOutIds.length ? `${optOutIds.join(',')} 未参与(另有 ${externalIds.length} 个外部镜像目标由数据源同步)` : '无此类目标'); } else { console.log(` - 跳过强制探测断言:当前 dataFilter=${snapMeta.dataFilter},快照中无配置目标(${externalIds.length} 个外部镜像目标由数据源同步)`); } // ---------- G. 刷新节奏对齐服务端 ---------- console.log('\n[G] 刷新节奏对齐服务端(不再每访客重新计时)'); const align = await getJson(`${BASE}/api/status`); const cadence = Number(align.updateIntervalSec); const updatedMs = new Date(align.dataUpdatedAt).getTime(); check('快照带 dataUpdatedAt 与 updateIntervalSec', Number.isFinite(updatedMs) && updatedMs > 0 && cadence >= 5, `更新于 ${align.dataUpdatedAt},节奏 ${cadence}s`); const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] }); const pageG = await browser.newPage({ viewport: { width: 1280, height: 900 } }); await pageG.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' }); await pageG.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 }); // 等首轮刷新结束(倒计时在刷新完成时才重置),否则读到的是加载中的旧值,偏差会被算进断言 await pageG.waitForFunction(() => document.getElementById('refresh-btn').textContent === '刷新' && !document.getElementById('refresh-btn').disabled, null, { timeout: 45000 }); const shownText = await pageG.evaluate(() => document.getElementById('countdown').textContent); const expectSec = Math.round((updatedMs + cadence * 1000 - Date.now()) / 1000); const shownSec = Number((shownText.match(/(\d+)/) ?? [])[1]); check('倒计时对齐服务端下次更新时刻', shownSec >= expectSec - 2 && shownSec <= expectSec + 4, `面板 ${shownText}(服务端还有约 ${expectSec}s,节奏 ${cadence}s)`); // 注:无头浏览器会冻结页面定时器(实测 performance.now() 会在采样间隔内跳跃十几秒), // 因此不断言「N 秒内递减」,只断言与公式一致、且不退化为整周期重计——这两条不依赖定时器 check('倒计时不会退化为从整周期重新计时', shownSec <= Math.max(5, Math.min(cadence, Number(cfg.settings.refreshInterval) || cadence)), `显示 ${shownSec}s`); await pageG.close(); // ---------- F. 面板渲染与自动刷新 ---------- console.log('\n[F] 面板渲染与刷新'); const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); const errors = []; page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`)); await page.goto(`${BASE}/?source=api&view=cards`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.card', { timeout: 15000 }); await page.waitForTimeout(1200); const read = () => page.evaluate(() => ({ cards: document.querySelectorAll('.card').length, bars: document.querySelectorAll('.heartbeat svg rect').length, uptimeCells: document.querySelectorAll('.uptime-cell').length, uptimeCellsPerCard: document.querySelector('.card .uptime-bar')?.children.length ?? 0, uptimeLabel: document.querySelector('.uptime-label')?.textContent ?? '', uptimeLabelTip: document.querySelector('.uptime-label')?.getAttribute('title') ?? '', statUptime: document.getElementById('stat-uptime').textContent, stats: document.querySelectorAll('.summary .stat').length, updated: document.getElementById('updated-at').textContent, lastCheckTitle: document.querySelector('.card .metric b[title]')?.getAttribute('title') ?? '', countdown: document.getElementById('countdown').textContent, version: document.querySelector('.version')?.textContent ?? '', })); const a = await read(); console.log(` 首次读取: 卡片 ${a.cards},可用率条 ${a.uptimeCellsPerCard} 格/卡,标签「${a.uptimeLabel}」`); check('卡片全部渲染', a.cards === expectedCards, `cards=${a.cards}/${expectedCards}`); check('每卡 90 格可用率条', a.uptimeCellsPerCard === 90, `perCard=${a.uptimeCellsPerCard}`); check('可用率标签含百分比', /90天\s*[\d.]+%/.test(a.uptimeLabel), `label=${a.uptimeLabel}`); check('可用率悬浮提示含统计明细', /次在线/.test(a.uptimeLabelTip), `title=${a.uptimeLabelTip}`); check('顶部 90天可用率统计已渲染', a.statUptime !== '–', `stat=${a.statUptime}`); check('最近检测带绝对时间提示', a.lastCheckTitle !== '–' && a.lastCheckTitle.length > 0, `title=${a.lastCheckTitle}`); check('版本号已渲染', /^shiroko-monitor v\d+\.\d+\.\d+$/.test(a.version), a.version); console.log(' 等待一个检查周期(70s)…'); await sleep(70000); const b = await read(); // 自动刷新是只读的:页脚「数据更新于」只在服务端产出新数据时变化,故用倒计时重新对齐来判断面板仍在按节奏轮询 check('面板持续按服务端节奏轮询(倒计时重新对齐)', a.countdown !== b.countdown, `${a.countdown} -> ${b.countdown}`); if (probeMode) { check('探测目标数据已流入面板(最近检测前进)', b.lastCheckTitle > a.lastCheckTitle, `${a.lastCheckTitle} -> ${b.lastCheckTitle}`); } // 手动刷新按钮:参与的卡片都必须重新检测,opt-out 的卡片不应被带动(历史 bug:只有部分目标更新) const readCards = () => page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.card')].map((c) => [c.dataset.id, c.querySelector('.metric b[title]')?.getAttribute('title') ?? '']))); const beforeCards = await readCards(); await page.click('#refresh-btn'); await page.waitForFunction(() => document.getElementById('refresh-btn').textContent === '刷新' && !document.getElementById('refresh-btn').disabled, null, { timeout: 45000 }); await page.waitForTimeout(400); const afterCards = await readCards(); const notAdvanced = forceIds.filter((id) => !(afterCards[id] > beforeCards[id])); const optOutAdvanced = optOutIds.filter((id) => afterCards[id] > beforeCards[id]); if (probeMode) { check('参与检测的卡片全部重新检测(手动刷新)', notAdvanced.length === 0, notAdvanced.length ? `${notAdvanced.join(',')} 未前进` : `${forceIds.length} 张全部前进`); check('probeOnRefresh:false 的卡片不被手动刷新带动', optOutAdvanced.length === 0, optOutIds.length ? `${optOutIds.length} 张保持原时间${externalIds.length ? `(另有 ${externalIds.length} 张外部镜像由数据源同步,允许更新)` : ''}` : '无此类目标'); } check('刷新过程中无 PageError', errors.length === 0, errors.length ? errors.join(' | ') : '无'); await page.screenshot({ path: path.join(__dirname, '..', 'docs', 'screenshot-desktop.png'), fullPage: true }); await browser.close(); const failed = results.filter((r) => !r.pass); console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`); if (failed.length) process.exit(1); } main().catch((err) => { console.error('FAILED:', err.stack || err.message); process.exit(1); });