问题(用户报告移动端异常):窄屏下顶栏统计挤成竖排并与标题重叠, 表格在手机上必须横向拖动,固定装饰在小屏上可能溢出或压住内容。 修复: - 顶栏结构拆为「标题 / 统计(.stats) / 控件(.controls)」三段; ≤820px 改为纵向三行,统计均分视口宽度,页脚改纵向堆叠 - ≤700px 且用户未显式选择视图时自动用卡片(横竖屏切换时重算, 显式选择仍尊重 localStorage) - ≤820px(及横屏手机)撤掉立绘、公路车、光环水印等固定装饰 验证: - 390x844 / 360x640 / 414x896 / 844x390 四种视口:0 横向溢出,顶栏三行 - verify-views 新增移动端 7 条断言(22/22 通过) - 四套合计 81 项全通过(17+22+20+22)
194 lines
13 KiB
JavaScript
194 lines
13 KiB
JavaScript
/**
|
||
* 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.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);
|
||
});
|