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