Files
shiroko-monitor/scripts/verify-views.cjs
T
tamakyi bf27f7b385 feat(ui): 最近事件随分组页签联动过滤 + favicon 提供 WebP
① 最近事件随筛选过滤

此前事件只受后端 dataFilter 约束,与面板上方的分组页签无关 —— 切页签后
监控列表变了,事件列表却纹丝不动。

两个真实缺陷(都是实测发现,不是推断):
1. setFilter() 只调 renderTabs() + renderView(),**没调 renderEvents()**
2. 过滤依据用 allMonitors(),它只受 dataFilter 约束、不受分组影响
   → 改为 visibleGroups().flatMap(...)
3. 无匹配事件时提前 return,没清空 list.innerHTML → 旧行仍留在 DOM 里,
   换个分组再切回来会看到过期内容

实测:「全部」8 条 → 「UptimeRobot」1 条(正是「狼的评论系统 /
UptimeRobot:403 Forbidden」)→「基础设施」0 条且区块自动隐藏。

过滤用「当前可见目标 id 集合」反查,而不是事件自身的 group 字段 ——
外部数据源(UptimeRobot)的事件在快照里没有 group 字段,按名字反查才准。

② favicon 提供 WebP(保留原图与 PNG)

先核实了实际情况:assets-src 下三个原图(bike 1.8MB / figure 366KB /
icon 277KB)此前已转成 WebP,public/assets 里唯一的 PNG 是 favicon-shiroko.png。
因此本次只处理 favicon:新增 64×64 WebP(2.7KB,比 PNG 的 5.6KB 省一半)。

**PNG 不删** —— favicon 是浏览器最早的请求,WebP 覆盖虽广但旧浏览器会直接
不显示图标,留 PNG 作回退代价只有几 KB,换来「任何浏览器都有图标」更稳妥。
原图 assets-src/ 完全未动,prepare-artwork.py 可随时重新生成。

页面用两个 <link rel="icon" type=...> 让浏览器按格式自动挑选
(认识 WebP 就取 WebP,否则回落 PNG / SVG)。

③ 顺带修一个既存缺陷
探针的 MIME 表**缺 .webp**,导致三张 webp 素材一直被当作
application/octet-stream 伺服(能显示但类型不对)。已补。

测试:verify-views 49→52 项(事件随页签过滤、切回全部恢复、无事件时清空 DOM)。
verify.cjs 实为 24 项(此前 README 记的 17 是旧数据)。全项目 8 套 346 项通过。
2026-10-14 17:35:01 +08:00

496 lines
31 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));
// ---- 行高一致性:先在「内容等长」下取基准 ----
// 注意:名称来自 monitors.json,不是 status.json(改错文件会假通过)
const statusPath = path.join(tmpDir, 'status.json');
const snap = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
const cfgPath2 = path.join(tmpDir, 'monitors.json');
const cfgJson = JSON.parse(fs.readFileSync(cfgPath2, 'utf8'));
const ids = Object.keys(snap.monitors);
cfgJson.monitors.forEach((m, i) => { m.name = `目标 ${i + 1}`; });
fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
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 heights = await dp.evaluate(() => {
const hs = [...document.querySelectorAll('table.monitor-table tbody tr[data-status]')].map((tr) => Math.round(tr.getBoundingClientRect().height));
return { hs, unique: [...new Set(hs)] };
});
check('内容等长时表格各行等高(可用率条不再撑高行)', heights.unique.length === 1, `行高 ${heights.unique.join('/')}(共 ${heights.hs.length} 行)`);
const baseRowH = heights.unique[0] ?? 0;
const groupHs = await dp.evaluate(() => [...new Set([...document.querySelectorAll('table.monitor-table tbody tr.group-row')].map((tr) => Math.round(tr.getBoundingClientRect().height)))]);
check('分组标题行自身等高且不与数据行混淆', groupHs.length === 1 && groupHs[0] !== baseRowH, `分组行高 ${groupHs.join('/')},数据行 ${baseRowH}px`);
// ---- 极端内容:行高仍须完全一致,且不得出现横向滚动 ----
// 注入超长名称与超长地址(外加一条超长事件详情),要求行高与基准逐像素相同
let injected = 0;
cfgJson.monitors.forEach((m, i) => {
if (i % 3 === 0) { m.name = `这是一个相当长的监控目标名称用于验证绝对等高-${'X'.repeat(40)}-${m.id}`; injected++; }
if (i % 3 === 1) { m.url = `https://very-long-subdomain.example.com/${'segment/'.repeat(12)}?q=${'y'.repeat(30)}`; injected++; }
});
fs.writeFileSync(cfgPath2, `${JSON.stringify(cfgJson, null, 2)}\n`);
// 注入事件,验证事件区块的时间线样式、折叠与等高
snap.events = Array.from({ length: 12 }, (_, i) => ({
t: new Date(Date.now() - i * 60000).toISOString(),
id: ids[0],
name: i % 2 ? `事件目标-${'长'.repeat(50)}` : (snap.monitors[ids[0]]?.name ?? 'x'),
type: i % 2 ? 'down' : 'up',
detail: i % 2 ? `connect ETIMEDOUT ${'203.0.113.'.repeat(6)}` : '已恢复',
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 eqLong = await dp.evaluate(() => {
const hs = [...document.querySelectorAll('table.monitor-table tbody tr[data-status]')].map((tr) => Math.round(tr.getBoundingClientRect().height));
const ev = [...document.querySelectorAll('.events-table tbody tr')].map((tr) => Math.round(tr.getBoundingClientRect().height));
return { hs, hsU: [...new Set(hs)], ev, evU: [...new Set(ev)] };
});
check('极端内容下表格各行仍绝对等高(与等长内容逐像素相同)', eqLong.hsU.length === 1 && eqLong.hsU[0] === baseRowH, `行高 ${eqLong.hsU.join('/')},基准 ${baseRowH}px(注入 ${injected} 条长内容,共 ${eqLong.hs.length} 行)`);
check('极端内容下事件表各行等高且与监控表格一致', eqLong.evU.length === 1 && eqLong.evU[0] === baseRowH, `事件行高 ${eqLong.evU.join('/')}`);
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 };
});
// 横向方向:单元格内容一律不得溢出(限高不换行时最容易被长 URL 顶破)
check(`表格单元格无横向溢出(注入 ${injected} 条长内容)`, clip.clipped === 0, `溢出单元格 ${clip.clipped} 个,最大 ${clip.worst}px`);
check('表格仍无横向滚动', clip.pageOverflow <= 0 && clip.tableOverflow <= 0, `页面 ${clip.pageOverflow} / 表格 ${clip.tableOverflow}`);
// 纵向方向:长内容改为「限两行 + title 悬停看全文」,完整值仍必须可取
// 注意 display 的计算值不可作为判据:Chrome 实现了标准 line-clamp 后会把
// display 从 -webkit-box 规范化为 flow-root,这里改查 -webkit-line-clamp 计算值
const clamp = await dp.evaluate(() => {
const els = [...document.querySelectorAll('.monitor-table .clamp2, .events-table .clamp2')];
const clamped = els.filter((e) => e.scrollHeight - e.clientHeight > 1);
const noTitle = [...document.querySelectorAll('.t-name, .t-target, .ev-name, .ev-detail')].filter((td) => !td.title);
const cs = els[0] ? getComputedStyle(els[0]) : null;
return {
total: els.length,
clamped: clamped.length,
noTitle: noTitle.length,
overflow: cs?.overflow ?? '',
clamp: cs?.webkitLineClamp ?? cs?.lineClamp ?? '',
h: els[0] ? Math.round(els[0].getBoundingClientRect().height) : 0,
};
});
check('长文本限两行显示且 overflow:hidden(等高的前提)', clamp.clamped > 0 && clamp.overflow === 'hidden' && clamp.clamp === '2', `限行 ${clamp.clamped}/${clamp.total},-webkit-line-clamp=${clamp.clamp},元素高 ${clamp.h}px`);
check('被截断的单元格仍带 title(悬停可看完整值)', clamp.noTitle === 0, `缺 title 的单元格 ${clamp.noTitle} 个`);
const evr = await dp.evaluate(() => {
const table = document.querySelector('.events-table');
const evRow = table?.querySelector('tbody tr');
const cs = evRow ? getComputedStyle(evRow) : null;
const more = document.getElementById('events-more');
return {
shown: (evRow ? table.querySelectorAll('tbody tr').length : 0),
tag: table?.tagName ?? 'none',
hasThead: Boolean(table?.querySelector('thead th')),
headerSameSize: (() => {
const mon = document.querySelector('.monitor-table thead th');
const ev = table?.querySelector('thead th');
return mon && ev ? getComputedStyle(mon).fontSize === getComputedStyle(ev).fontSize : false;
})(),
rowBg: cs?.backgroundColor ?? '',
cellBorder: evRow ? getComputedStyle(evRow.querySelector('td')).borderBottomWidth : '',
moreHidden: more?.hidden ?? true,
moreText: more?.textContent ?? '',
count: document.getElementById('events-count')?.textContent ?? '',
};
});
check('事件区块与监控表格同构(table + thead)', evr.tag === 'TABLE' && evr.hasThead === true && evr.headerSameSize === true, `${evr.tag},表头字号一致=${evr.headerSameSize}`);
const evBg = await dp.evaluate(() => {
const rows = [...document.querySelectorAll('.events-table tbody tr')];
const down = rows.find((r) => r.dataset.status === 'down');
const up = rows.find((r) => r.dataset.status === 'up');
return { down: down ? getComputedStyle(down).backgroundColor : '无', up: up ? getComputedStyle(up).backgroundColor : '无', downCells: down ? down.querySelectorAll('td').length : 0 };
});
check('事件行复用表格状态底色(故障行着色、恢复行不染)', /^(color\(|rgba?\(\d)/.test(evBg.down) && evBg.down !== 'rgba(0, 0, 0, 0)' && evBg.up === 'rgba(0, 0, 0, 0)' && evBg.downCells === 4, `故障=${evBg.down.slice(0, 30)} 恢复=${evBg.up} 列数=${evBg.downCells}`);
// 事件行的四列文字必须落在同一水平线上。此前 clamp2 固定 height:2.8em(36px)、
// 而徽标只有 17px,单元格按 middle 对齐时两者居中基准不同,
// 「类型」会比「时间/目标/详情」低约 9px(实测截图确认)。
const evAlign = await dp.evaluate(() => {
const rows = [...document.querySelectorAll('.events-table tbody tr')];
const spreads = [];
for (const tr of rows) {
const tops = [];
for (const td of tr.querySelectorAll('td')) {
// Range 要用独立实例:selectNodeContents 返回的是布尔值,不是 Range
const range = document.createRange();
range.selectNodeContents(td);
const rects = [...range.getClientRects()];
if (rects.length) tops.push(Math.round(rects[0].top));
}
if (tops.length) spreads.push(Math.max(...tops) - Math.min(...tops));
}
return { worst: spreads.length ? Math.max(...spreads) : -1, rows: spreads.length };
});
check('事件行各列文字顶端对齐(徽标与文本同一水平线)', evAlign.worst === 0, `最大错位 ${evAlign.worst}px(${evAlign.rows} 行)`);
// 跨域模式下隐藏刷新按钮(该模式下 /api/refresh 必然被 refreshLocalOnly 挡下)
const refreshBtnShown = await dp.evaluate(() => !document.getElementById('refresh-btn').hidden);
check('同源模式下保留刷新按钮', refreshBtnShown === true, `可见=${refreshBtnShown}`);
check('事件默认折叠为 8 条并可展开', evr.shown === 8 && evr.moreHidden === false && /展开全部 12 条/.test(evr.moreText), `显示 ${evr.shown} 条,按钮「${evr.moreText}」,计数「${evr.count}」`);
// ---- 事件随分组页签联动 ----
// 此前 setFilter 只重渲染视图不重渲染事件,且过滤依据用的是 allMonitors()
// (只受 dataFilter 约束、不受分组影响),导致切页签后事件纹丝不动。
const evTabs = await dp.evaluate(() => [...document.querySelectorAll('#group-tabs .tab')].map((t) => t.textContent.trim()));
// 找一个「事件里没有目标」的分组:演示数据的 12 条事件全部挂在 ids[0] 上,
// 因此要排除 ids[0] 所属的那个分组
const ids0Group = cfgJson.monitors.find((m) => m.id === ids[0])?.group || '未分组';
const quietTab = evTabs.find((t) => !t.startsWith('全部') && !t.startsWith(ids0Group));
check('存在可用于验证的分组页签', Boolean(quietTab), `事件目标属于「${ids0Group}」,可用安静分组:${quietTab ?? '无'}`);
if (quietTab) {
await dp.click(`#group-tabs .tab:text-is("${quietTab}")`);
await dp.waitForTimeout(500);
const evAfter = await dp.evaluate(() => {
const visIds = [...document.querySelectorAll('table.monitor-table tbody tr[data-status]')].map((tr) => tr.dataset.id);
const rows = [...document.querySelectorAll('.events-table tbody tr')];
return {
visibleTargets: visIds,
events: rows.length,
// 事件行里的目标名必须都属于当前页签可见的目标
names: rows.map((tr) => tr.querySelector('.ev-name')?.textContent?.trim()),
hidden: document.getElementById('events').hidden,
};
});
check('切分组页签后事件同步过滤(不再显示其它分组的事件)', evAfter.events === 0 && evAfter.hidden === true,
`「${quietTab}」页签下事件 ${evAfter.events} 条、区块隐藏=${evAfter.hidden}`);
}
await dp.click('#group-tabs .tab:text-matches("^全部")');
await dp.waitForTimeout(400);
const evBack = await dp.evaluate(() => document.querySelectorAll('.events-table tbody tr').length);
check('切回「全部」恢复完整事件', evBack === 8, `${evBack} 条`);
await dp.click('#events-more');
await dp.waitForTimeout(300);
const evExpanded = await dp.evaluate(() => ({ shown: document.querySelectorAll('.events-table tbody tr').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);
});