① 事件表「故障」「狼的评论系统」「UptimeRobot:403 Forbidden」不在同一行 根因(实测定位,非推断):四个 td 的 top/height 完全相同(696/72),错位在单元格内部。 .clamp2 为保证行等高固定了 height:2.8em(36px),而「故障」徽标只有 17px; 单元格按 vertical-align:middle 对齐时,两者居中基准不同,徽标比其它三列低约 9px。 修法:事件表单元格改顶部对齐 + clamp2 在事件表内改为 max-height。 实测修复后四列文字顶端错位 0px。 ② 跨域模式隐藏刷新按钮 /api/refresh 在跨域下必然被 refreshLocalOnly 挡下(对端是代理地址而非私网), 给一个必然失败的按钮不如隐藏。setSource 里按 source==='remote' 切换, 同源模式保留(并补断言守住这个行为)。 ③ 新增 docs/DEPLOY-PAGES.md GitHub Pages 面板 + 本地探针的完整部署指南:架构图、先决条件(含混合内容硬限制)、 本地探针启动与 2FA 绑定、Pages 配置三处填法、验证清单、安全要点、 Actions 快照备选方案、六条常见问题排查(Mixed Content / CORS / Failed to fetch 等)。 测试:verify-views 47→49 项(事件行文字顶端对齐 0px、同源保留刷新按钮)。 全项目 8 套 336 项通过。
465 lines
29 KiB
JavaScript
465 lines
29 KiB
JavaScript
/**
|
||
* 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}」`);
|
||
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);
|
||
});
|