Files
shiroko-monitor/scripts/verify.cjs
T
tamakyi 3db3ce570a chore: v0.6.0 baseline — 主题美化前存档
已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
2026-10-13 18:31:58 +08:00

192 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* shiroko-monitor 端到端验证脚本(开发用,需 playwright)
*
* 覆盖:
* A. 热重载回归:改写 monitors.json 触发配置热重载后,/api/status 必须继续更新(历史 bug:接口冻结)
* B. 接口与文件一致性:/api/status 与 status.json 的 lastCheck 必须一致
* C. 面板重渲染:跨越一个刷新周期后,页脚「更新于」必须前进
* D. UI 断言:卡片数、心率图、90 格可用率条、统计栏
*
* 用法:NODE_PATH=<playwright node_modules> 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.waitForTimeout(1200);
const shownText = await pageG.evaluate(() => document.getElementById('countdown').textContent);
const shownSec = Number((shownText.match(/(\d+)/) ?? [])[1]);
// 期望值需在当前时刻重算:面板显示的是「服务端上次更新 + 一个周期 - 现在」
const expectSec = Math.round((updatedMs + cadence * 1000 - Date.now()) / 1000);
check('倒计时对齐服务端下次更新时刻', Math.abs(shownSec - expectSec) <= 4, `面板 ${shownText}(服务端还有约 ${expectSec}s,节奏 ${cadence}s)`);
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);
});