Files
shiroko-monitor/scripts/verify-views.cjs
T
tamakyi 25b8b97970 fix: 表格完整显示 + 事件区块样式重做 + 探测范围可选(probeScope)
1) 表格显示不全
   table-layout:fixed 保证不横向滚动后,列内仍是 nowrap,实测长 URL 被截
   388px、名称截 219px、可用率条截 35px(1440 宽共 14 个单元格)。
   改为允许换行(地址 break-all)并重排列宽 —— 数据完整且仍零横向滚动。

2) 最近事件完全没样式
   根因:上次追加 CSS 的命令是 `node --check app.js && cat >> style.css`,
   --check 报错导致 && 短路,CSS 从未写入(只有 HTML 裸奔)。
   本次用编辑器写入;同时重做为时间线样式(竖线 + 类型着色圆点 + 左描边)、
   名称列按内容自适应(消除大片空白)、默认折叠最近 8 条可展开全部。

3) dataFilter=uptimerobot 时仍在探测本地目标
   新增 settings.probeScope(all|config|uptimerobot),不填则跟随 dataFilter;
   显式设 all 可保留探测。启动日志打印当前口径与范围。

顺带修复两个连带问题:
- UR 镜像目标此前不产生事件也不告警(evaluateRound 只遍历本地目标),
  配 uptimerobot 口径等于收不到任何提醒 —— 现在两类目标统一纳入
- 去抖计数被评估频率放大:evaluateRound 每秒执行,原实现每次评估都
  failStreak+1,持续离线目标 1 秒即达阈值告警。改为按探测时间戳去重,
  且无状态变化时不再反复落盘

测试:verify-views [J] 扩到 11 条(长内容不截断/仍不滚动/时间线布局/
类型着色/折叠展开),verify-notify 新增 [D3] 4 条(范围跟随、不再探测本地、
显式 all 恢复、与展示口径解耦)+ 去抖计数断言。六套 170 项全通过。
2026-10-14 10:22:05 +08:00

382 lines
22 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)
*
* 覆盖:卡片 / 表格 / 大屏 / 极简 四视图的渲染数量、切换器、URL 指定、hideTargets 脱敏、无 JS 错误
*
* 用法:NODE_PATH=<playwright node_modules> node scripts/verify-views.cjs [baseUrl]
*/
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
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 results = [];
function check(name, pass, detail) {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
const SELECTOR = { cards: '.card', table: 'table.monitor-table tbody tr[data-status]' };
async function main() {
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
const snap = await fetch(`${BASE}/api/status`, { cache: 'no-store' }).then((r) => r.json());
const shown = Object.keys(snap.monitors).length;
// 默认视图:不带 ?view= 时应为表格
await page.goto(`${BASE}/?source=api`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(SELECTOR.table, { timeout: 15000 });
check('默认视图为表格', (await page.evaluate(() => document.body.dataset.view)) === 'table', `data-view=${await page.evaluate(() => document.body.dataset.view)}`);
check('切换器只有两个选项(表格/卡片)', (await page.evaluate(() => document.querySelectorAll('.view-btn').length)) === 2, '2 个');
for (const view of ['table', 'cards']) {
await page.goto(`${BASE}/?source=api&view=${view}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(SELECTOR[view], { timeout: 15000 });
// 等首次快照渲染完成(不再有 pending 条目)再截图,避免拍到「等待检测」中间态
await page.waitForFunction(() => !document.querySelector('[data-status="pending"]'), null, { timeout: 40000 });
await page.waitForTimeout(400);
const info = await page.evaluate((sel) => ({
bodyView: document.body.dataset.view,
count: document.querySelectorAll(sel).length,
activeBtn: document.querySelector('.view-btn.active')?.textContent ?? '',
// 页面与表格都不应出现横向溢出(历史上立绘避让的 padding 曾导致桌面端需要横向拖动)
overflowPage: document.documentElement.scrollWidth - document.documentElement.clientWidth,
overflowTable: (() => {
const w = document.querySelector('.table-wrap');
return w ? Math.round(w.scrollWidth - w.clientWidth) : 0;
})(),
}), SELECTOR[view]);
check(`${view} 视图渲染 ${shown} 个条目`, info.count === shown, `count=${info.count}, 切换器=${info.activeBtn}`);
check(`${view} 视图无横向溢出(无需拖动)`, info.overflowPage <= 0 && info.overflowTable <= 0, `页面溢出 ${info.overflowPage}px,表格溢出 ${info.overflowTable}px`);
// 立绘若存在:要么不与内容重叠,要么确实位于不透明面板之下(立绘 z-index 低于内容,不应影响阅读)
const artOverlap = await page.evaluate(() => {
const art = document.querySelector('.artwork');
if (!art || getComputedStyle(art).display === 'none') return null;
const a = art.getBoundingClientRect();
const opaque = (bg) => {
const m = bg.match(/rgba?\(([^)]+)\)/);
if (!m) return false;
const parts = m[1].split(',').map(Number);
return parts.length < 4 || parts[3] === 1;
};
const hits = [...document.querySelectorAll('table.monitor-table tbody tr, .card')]
.map((el) => ({ r: el.getBoundingClientRect(), el }))
.filter(({ r }) => r.right > a.left + 8 && r.bottom > a.top && r.top < a.bottom)
.map(({ el }) => {
// 不透明判定要同时看背景色与背景图(卡片用的是渐变背景,backgroundColor 仍是透明)
const cs = getComputedStyle(el);
if (cs.backgroundImage !== 'none') return true;
const chain = [cs, getComputedStyle(el.closest('.table-wrap, .grid') ?? el)];
return chain.some((s) => s.backgroundImage !== 'none' || opaque(s.backgroundColor));
});
return { count: hits.length, allOpaque: hits.every(Boolean) };
});
if (artOverlap) check(`${view} 视图立绘不影响阅读(不重叠或位于面板之下)`, artOverlap.count === 0 || artOverlap.allOpaque, `重叠 ${artOverlap.count} 个,均在面板之下=${artOverlap.allOpaque}`);
await page.screenshot({ path: path.join(__dirname, '..', 'docs', `screenshot-view-${view}.png`), fullPage: true });
}
// 切换器持久化:点击后写入 localStorage,刷新后仍生效(URL 不带 ?view= 时不覆盖用户选择)
await page.goto(`${BASE}/?source=api`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector(SELECTOR.table, { timeout: 15000 });
await page.click('.view-btn:nth-child(2)'); // 卡片
await page.waitForSelector(SELECTOR.cards, { timeout: 10000 });
const saved = await page.evaluate(() => localStorage.getItem('shiroko.view'));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector(SELECTOR.cards, { timeout: 15000 });
check('视图切换被记住(localStorage),刷新后仍为卡片', saved === 'cards' && (await page.evaluate(() => document.body.dataset.view)) === 'cards', `saved=${saved}`);
await page.evaluate(() => localStorage.removeItem('shiroko.view'));
// dataFilter:仅配置 / 仅 UptimeRobot / 两者
const cfgAll = JSON.parse(fs.readFileSync(CONFIG, 'utf8'));
const configCount = cfgAll.monitors.length;
const urCount = Object.values(snap.monitors).filter((m) => m.meta?.source === 'uptimerobot').length;
console.log(` 配置目标 ${configCount} 个,UptimeRobot 镜像 ${urCount} 个`);
const rawCfg = fs.readFileSync(CONFIG, 'utf8');
try {
for (const [mode, expect] of [['config', configCount], ['uptimerobot', urCount], ['both', configCount + urCount]]) {
const c = JSON.parse(rawCfg);
c.settings.dataFilter = mode;
fs.writeFileSync(CONFIG, `${JSON.stringify(c, null, 2)}\n`);
await new Promise((r) => setTimeout(r, 900)); // 等探针热重载
await page.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' });
if (expect > 0) await page.waitForSelector(SELECTOR.table, { timeout: 15000 });
else await page.waitForTimeout(1500);
const n = await page.evaluate((sel) => document.querySelectorAll(sel).length, SELECTOR.table);
const snapNow = await fetch(`${BASE}/api/status`, { cache: 'no-store' }).then((r) => r.json());
check(`dataFilter=${mode} 时面板与快照口径一致(${expect} 个)`, n === expect && snapNow.dataFilter === mode, `面板 ${n} 个,快照 dataFilter=${snapNow.dataFilter}`);
}
} finally {
// 还原并校验:测试改配置必须原样恢复,否则会静默改坏仓库里的配置
fs.writeFileSync(CONFIG, rawCfg);
if (fs.readFileSync(CONFIG, 'utf8') !== rawCfg) {
fs.writeFileSync(CONFIG, rawCfg);
check('测试后配置原样还原', false, '首次还原后内容不一致,已重写');
} else {
check('测试后配置原样还原', true, 'monitors.json 未被测试改动');
}
}
// hideTargets:开启后面板不渲染任何目标地址
const raw = fs.readFileSync(CONFIG, 'utf8');
const cfg = JSON.parse(raw);
cfg.settings.hideTargets = true;
fs.writeFileSync(CONFIG, `${JSON.stringify(cfg, null, 2)}\n`);
try {
await page.goto(`${BASE}/?source=api&view=cards`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.card', { timeout: 15000 });
await page.waitForTimeout(800);
const leak = await page.evaluate(() => {
const html = document.getElementById('monitor-grid').innerHTML;
return /https?:\/\//.test(html) || /\d+\.\d+\.\d+\.\d+:\d+/.test(html);
});
const target = await page.evaluate(() => document.querySelector('.card-target')?.textContent ?? '');
check('hideTargets 开启后面板不渲染目标地址', !leak && target === '已隐藏', `target="${target}"`);
} finally {
fs.writeFileSync(CONFIG, raw); // 还原
}
// ---------- H. 移动端(窄屏) ----------
console.log('\n[H] 移动端窄屏');
const mCtx = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, deviceScaleFactor: 2 });
const mp = await mCtx.newPage();
const mErrors = [];
mp.on('pageerror', (e) => mErrors.push(e.message));
// 不带 ?view=:窄屏下应自动落到卡片视图(表格在手机上必须横向拖动)
await mp.goto(`${BASE}/?source=api`, { waitUntil: 'domcontentloaded' });
await mp.waitForSelector('.card', { timeout: 20000 });
await mp.waitForTimeout(1500);
const mob = await mp.evaluate(() => {
const top = (sel) => { const el = document.querySelector(sel); return el ? Math.round(el.getBoundingClientRect().top) : -1; };
const statsW = Math.round(document.querySelector('.stats')?.getBoundingClientRect().width ?? 0);
const lastFooter = [...document.querySelectorAll('.footer-item')].pop();
return {
view: document.body.dataset.view,
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
brandTop: top('.brand'), statsTop: top('.stats'), controlsTop: top('.controls'),
statsW, vw: window.innerWidth,
artwork: getComputedStyle(document.querySelector('.artwork') ?? document.body).display,
beforeDisplay: getComputedStyle(document.body, '::before').display,
afterDisplay: getComputedStyle(document.body, '::after').display,
footerRight: lastFooter ? Math.round(lastFooter.getBoundingClientRect().right) : -1,
cards: document.querySelectorAll('.card').length,
tabsWrapped: getComputedStyle(document.querySelector('.topbar')).flexDirection,
};
});
check('窄屏默认使用卡片视图', mob.view === 'cards', `data-view=${mob.view}`);
check('窄屏无横向溢出', mob.overflow <= 0, `溢出 ${mob.overflow}px`);
check('顶栏在窄屏改为纵向三行(标题/统计/控件)', mob.brandTop < mob.statsTop && mob.statsTop < mob.controlsTop && mob.tabsWrapped === 'column', `top: ${mob.brandTop}/${mob.statsTop}/${mob.controlsTop}, flex=${mob.tabsWrapped}`);
check('统计行铺满视口宽度', Math.abs(mob.statsW - mob.vw) <= 32, `stats=${mob.statsW} 视口=${mob.vw}`);
check('窄屏撤掉立绘与固定装饰', mob.artwork === 'none' && mob.beforeDisplay === 'none' && mob.afterDisplay === 'none', `立绘=${mob.artwork} 左装饰=${mob.beforeDisplay} 光环水印=${mob.afterDisplay}`);
check('页脚内容不被裁切', mob.footerRight > 0 && mob.footerRight <= mob.vw, `最后一项右边界 ${mob.footerRight} / 视口 ${mob.vw}`);
check('窄屏渲染无 JS 错误', mErrors.length === 0, mErrors.join(' | ') || '无');
await mp.screenshot({ path: path.join(__dirname, '..', 'docs', 'screenshot-mobile.png'), fullPage: true });
await mCtx.close();
// ---------- I. 无障碍:文字与图形对比度(WCAG AA) ----------
console.log('\n[I] 对比度(WCAG AA:正文 ≥4.5,图形 ≥3.0)');
// 对比度需在表格视图下采样(在线/离线状态文字在表格行里)
await page.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await page.waitForTimeout(1000);
const contrast = await page.evaluate(() => {
// 颜色可能是 rgb()/rgba() 或 color(srgb r g b / a)(color-mix 的结果),统一解析后逐层合成,
// 否则半透明底色会被当成不透明、0~1 分量会被当成 0~255,算出的比值是错的
const parseColor = (c) => {
const nums = (c.match(/[\d.]+/g) ?? []).map(Number);
const srgb = c.startsWith('color(srgb');
const [r, g, b] = srgb ? nums.slice(0, 3).map((v) => v * 255) : nums.slice(0, 3);
const a = srgb ? (nums.length > 3 ? nums[3] : 1) : (nums.length > 3 ? nums[3] : 1);
return { r, g, b, a };
};
const effectiveBg = (el) => {
const chain = [];
for (let n = el; n; n = n.parentElement) chain.push(n);
let bg = { r: 255, g: 255, b: 255 };
for (const n of chain.reverse()) {
const c = parseColor(getComputedStyle(n).backgroundColor);
if (!c.a) continue;
bg = { r: bg.r * (1 - c.a) + c.r * c.a, g: bg.g * (1 - c.a) + c.g * c.a, b: bg.b * (1 - c.a) + c.b * c.a };
}
return `rgb(${Math.round(bg.r)}, ${Math.round(bg.g)}, ${Math.round(bg.b)})`;
};
const luminance = ([r, g, b]) => {
const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const ratio = (fg, bg) => {
const a = luminance([parseColor(fg).r, parseColor(fg).g, parseColor(fg).b]);
const b = luminance([parseColor(bg).r, parseColor(bg).g, parseColor(bg).b]);
const [hi, lo] = a > b ? [a, b] : [b, a];
return (hi + 0.05) / (lo + 0.05);
};
const sample = (sel, label) => {
const el = document.querySelector(sel);
if (!el) return null;
return { label, text: el.textContent.trim().slice(0, 8), r: +ratio(getComputedStyle(el).color, effectiveBg(el)).toFixed(2) };
};
const out = [
sample('tr[data-status="up"] .t-state', '在线状态文字'),
sample('tr[data-status="down"] .t-state', '离线状态文字'),
sample('tr[data-status="pending"] .t-state', '等待状态文字'),
sample('#stat-up', '在线统计数字'),
sample('#stat-down', '离线统计数字'),
sample('#overall-pill', '顶部状态胶囊'),
sample('.t-target', '目标地址'),
sample('.footer-item', '页脚文字'),
].filter(Boolean);
// 图形:状态点边框色 vs 其所在卡片底色(图形标准 3:1)
const dot = document.querySelector('.dot');
if (dot) out.push({ label: '状态点图形', r: +ratio(getComputedStyle(dot).borderTopColor, effectiveBg(dot)).toFixed(2), graphic: true });
return out;
});
for (const c of contrast) {
if (c.r == null) continue;
const need = c.graphic ? 3 : 4.5;
check(`${c.label}对比度 ≥${need}`, c.r >= need, `${c.r}:1`);
}
// ---------- J. 事件区块与延迟趋势(演示快照,进程内临时服务) ----------
console.log('\n[J] 事件区块与延迟趋势');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-events-'));
try {
fs.cpSync(path.join(__dirname, '..', 'public'), tmpDir, { recursive: true });
const cfgPath = path.join(tmpDir, 'monitors.json');
const demoCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
demoCfg.settings.dataFilter = 'config';
fs.writeFileSync(cfgPath, `${JSON.stringify(demoCfg, null, 2)}\n`);
await new Promise((resolve) => {
const child = spawn(process.execPath, [path.join(__dirname, '..', 'prober', 'node', 'index.js'),
'--once', '--demo', `--config=${cfgPath}`, `--local-config=${path.join(tmpDir, 'none.json')}`,
`--out=${path.join(tmpDir, 'status.json')}`], { stdio: 'ignore' });
child.on('exit', (code) => resolve(code));
});
const demoServer = http.createServer((req, res) => {
const rel = decodeURIComponent(req.url.split('?')[0]);
const file = path.join(tmpDir, rel === '/' ? 'index.html' : rel);
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404);
res.end();
return;
}
const mime = { '.html': 'text/html;charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png' }[path.extname(file)] ?? 'application/octet-stream';
res.writeHead(200, { 'content-type': mime });
res.end(data);
});
});
await new Promise((r) => demoServer.listen(8894, r));
const dp = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await dp.goto('http://localhost:8894/?source=static&view=cards', { waitUntil: 'domcontentloaded' });
await dp.waitForSelector('.card', { timeout: 20000 });
await dp.waitForTimeout(1200);
const dj = await dp.evaluate(() => ({
cards: document.querySelectorAll('.card').length,
trends: document.querySelectorAll('.lat-trend').length,
cellsPerTrend: document.querySelector('.lat-trend')?.children[1]?.children.length ?? 0,
trendTip: document.querySelector('.lat-trend')?.title ?? '',
eventsHidden: document.getElementById('events').hidden,
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}));
check('卡片视图渲染 90 天延迟趋势条', dj.trends > 0 && dj.cellsPerTrend === 30, `${dj.trends} 条 × ${dj.cellsPerTrend} 格`);
check('趋势条 tooltip 含日均与峰值', /90 天日均延迟 \d+ ms · 峰值 \d+ ms/.test(dj.trendTip), dj.trendTip.slice(0, 60));
check('无事件时区块隐藏', dj.eventsHidden === true, '');
check('演示视图无横向溢出', dj.overflow <= 0, `${dj.overflow}px`);
// 表格视图:可用率列 tooltip 应带日均延迟
await dp.evaluate(() => document.querySelector('.view-btn').click());
await dp.waitForSelector('table.monitor-table', { timeout: 10000 });
await dp.waitForTimeout(500);
const dt = await dp.evaluate(() => document.querySelector('tr[data-status] .t-uptime')?.title ?? '');
check('表格可用率 tooltip 含 90 天日均延迟', /90 天日均延迟 \d+ ms/.test(dt), dt.slice(0, 60));
// ---- 极端内容:表格不得把数据截断,同时不得出现横向滚动 ----
const statusPath = path.join(tmpDir, 'status.json');
const snap = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
const ids = Object.keys(snap.monitors);
let injected = 0;
for (const id of ids.slice(0, 2)) {
snap.monitors[id].name = `这是一个相当长的监控目标名称用于验证表格完整显示-${id}`;
injected++;
}
// 注入事件,验证事件区块的时间线样式与折叠
snap.events = Array.from({ length: 12 }, (_, i) => ({
t: new Date(Date.now() - i * 60000).toISOString(),
id: ids[0],
name: snap.monitors[ids[0]].name,
type: i % 2 ? 'down' : 'up',
detail: i % 2 ? 'ETIMEDOUT' : '已恢复',
maintenance: false,
}));
fs.writeFileSync(statusPath, JSON.stringify(snap));
await dp.goto('http://localhost:8894/?source=static&view=table', { waitUntil: 'domcontentloaded' });
await dp.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await dp.waitForTimeout(900);
const clip = await dp.evaluate(() => {
let clipped = 0;
let worst = 0;
for (const tr of document.querySelectorAll('tr[data-status]')) {
for (const td of tr.querySelectorAll('td')) {
const d = td.scrollWidth - td.clientWidth;
if (d > 1) { clipped++; worst = Math.max(worst, d); }
}
}
const wrap = document.querySelector('.table-wrap');
return { clipped, worst, pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, tableOverflow: wrap ? wrap.scrollWidth - wrap.clientWidth : 0 };
});
check(`长名称/长地址不再被截断(注入 ${injected} 个)`, clip.clipped === 0, `截断单元格 ${clip.clipped} 个,最大 ${clip.worst}px`);
check('表格仍无横向滚动', clip.pageOverflow <= 0 && clip.tableOverflow <= 0, `页面 ${clip.pageOverflow} / 表格 ${clip.tableOverflow}`);
const evr = await dp.evaluate(() => {
const ev = document.querySelector('.ev');
const cs = ev ? getComputedStyle(ev) : null;
const dot = ev ? getComputedStyle(ev, '::before') : null;
const more = document.getElementById('events-more');
return {
shown: document.querySelectorAll('.ev').length,
grid: cs?.display ?? 'none',
dotVisible: dot ? dot.width !== '0px' && dot.backgroundColor !== 'rgba(0, 0, 0, 0)' : false,
leftBorder: cs?.borderLeftWidth ?? '',
moreHidden: more?.hidden ?? true,
moreText: more?.textContent ?? '',
count: document.getElementById('events-count')?.textContent ?? '',
};
});
check('事件区块使用时间线布局(grid + 圆点)', evr.grid === 'grid' && evr.dotVisible === true, `display=${evr.grid} 圆点=${evr.dotVisible}`);
check('事件行按类型着色(左描边)', evr.leftBorder === '3px', evr.leftBorder);
check('事件默认折叠为 8 条并可展开', evr.shown === 8 && evr.moreHidden === false && /展开全部 12 条/.test(evr.moreText), `显示 ${evr.shown} 条,按钮「${evr.moreText}」,计数「${evr.count}」`);
await dp.click('#events-more');
await dp.waitForTimeout(300);
const evExpanded = await dp.evaluate(() => ({ shown: document.querySelectorAll('.ev').length, text: document.getElementById('events-more').textContent }));
check('展开后显示全部事件', evExpanded.shown === 12 && evExpanded.text === '收起', `显示 ${evExpanded.shown} 条`);
await dp.close();
await new Promise((r) => demoServer.close(r));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
// 429 是刷新限流保护在起作用(快速连续导航各触发一次强制探测),面板已优雅降级为只读数据;
// 这里只把 429 以外的错误视为失败
const hardErrors = errors.filter((e) => !/429/.test(e));
check('两种视图无 JS 错误(除限流保护外)', hardErrors.length === 0, hardErrors.length ? hardErrors.join(' | ') : `无;另有 ${errors.length} 次 429 —— 快速连续刷新触发服务端限流,面板已回退到只读数据`);
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);
});