chore: v0.6.0 baseline — 主题美化前存档

已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
This commit is contained in:
tamakyi
2026-10-13 18:31:58 +08:00
commit 3db3ce570a
31 changed files with 5136 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
/**
* 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);
});