/** * shiroko-monitor 四种视图验证(开发用,需 playwright) * * 覆盖:卡片 / 表格 / 大屏 / 极简 四视图的渲染数量、切换器、URL 指定、hideTargets 脱敏、无 JS 错误 * * 用法:NODE_PATH= node scripts/verify-views.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 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`); } // 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); });