Files
shiroko-monitor/scripts/verify-layout.cjs
T
tamakyi ea846dee31 fix(layout): 表格流式列宽,任何内容与视口都不再需要横向滚动
硬要求(用户明确):不允许出现需要滚动才能看全的情况。

改动:
- 表格 table-layout: fixed + 百分比列宽(8/16/21/8/7/11/17/12%),
  表头允许换行并裁剪、单元格裁剪、心率图与可用率条随宽度伸缩
- 卡片网格 minmax(min(300px,100%),1fr) + .card{min-width:0}
  (修 grid 的 min-width:auto 撑破:320px 下曾溢出 495px)
- 截断的名称/目标补 title,悬停可看完整值
- 新增 scripts/verify-layout.cjs:自带极端配置(40 目标 / 6 分组 /
  超长名称与 URL)+ 临时静态服务,扫描 13 视口 × 2 视图,26 项断言零横向滚动

验证:五套 115 项全通过(17+30+26+20+22)
2026-10-13 22:37:02 +08:00

121 lines
5.4 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 布局回归:任何宽度、任何内容都不应出现横向滚动
*
* 硬要求(用户明确):不允许出现「需要滚动才能看全」的情况。
* 因此这里用**极端内容**(超长名称 / 超长 URL / 6 个分组 / 40 个目标)+ 13 种视口 × 2 种视图
* 做扫描,断言页面与表格容器都不能有横向溢出。
*
* 为什么要用极端内容:常规内容下很多撑破问题不会暴露(实测卡片视图在 320px 下
* 曾因 grid 的 min-width:auto 被长 URL 撑到 801px,溢出 495px)。
*
* 用法:node scripts/verify-layout.cjs
*/
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 ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const PORT = 8893;
const WIDTHS = [320, 360, 390, 480, 600, 768, 820, 900, 1024, 1280, 1440, 1920, 2560];
const results = [];
const check = (name, pass, detail) => {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
};
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' };
// 极端配置:长名称 + 长 URL + 多分组 + 多目标
function hostileConfig() {
const groups = ['超长分组名称测试一二三四五', '公共服务', '基础设施与内网设备', 'UptimeRobot', '外部依赖', '测试用极端分组名'];
const monitors = [];
for (let i = 0; i < 40; i++) {
monitors.push({
id: `hostile-${i}`,
name: i % 3 === 0 ? `这是一个非常非常长的监控目标名称用于测试极端情况下的布局表现与截断行为-${i + 1}` : `目标-${i + 1}`,
group: groups[i % groups.length],
type: 'http',
url: i % 2 === 0 ? `https://very-long-subdomain-name-for-testing-layout.example.com/very/long/path/segment?with=query&and=more&even=more&idx=${i}` : `https://s${i}.example.com`,
expectStatus: [200],
interval: 60,
timeout: 5,
});
}
return monitors;
}
function runOnce(args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [PROBER, ...args], { stdio: 'ignore' });
child.on('exit', (code) => resolve(code));
});
}
async function main() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-layout-'));
const cfg = path.join(tmp, 'monitors.json');
fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true });
const base = JSON.parse(fs.readFileSync(cfg, 'utf8'));
base.settings.dataFilter = 'config';
base.monitors = hostileConfig();
fs.writeFileSync(cfg, `${JSON.stringify(base, null, 2)}\n`);
await runOnce(['--once', '--demo', `--config=${cfg}`, `--local-config=${path.join(tmp, 'none.json')}`, `--out=${path.join(tmp, 'status.json')}`]);
console.log(`极端内容:${base.monitors.length} 个目标 / 6 个分组,视口 ${WIDTHS.length} 种 × 2 视图\n`);
const server = http.createServer((req, res) => {
const rel = decodeURIComponent(req.url.split('?')[0]);
const file = path.join(tmp, rel === '/' ? 'index.html' : rel);
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
res.end(data);
});
});
await new Promise((r) => server.listen(PORT, r));
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
let worst = null;
try {
for (const view of ['table', 'cards']) {
for (const width of WIDTHS) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
await page.goto(`http://localhost:${PORT}/?source=static&view=${view}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.card, table.monitor-table', { timeout: 20000 });
await page.waitForTimeout(400);
const r = await page.evaluate(() => ({
page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
wrap: (() => { const w = document.querySelector('.table-wrap'); return w ? w.scrollWidth - w.clientWidth : 0; })(),
grid: (() => { const g = document.querySelector('.grid'); return g ? g.scrollWidth - g.clientWidth : 0; })(),
}));
const bad = Math.max(r.page, r.wrap, r.grid);
if (!worst || bad > worst.bad) worst = { bad, view, width, r };
check(`${String(width).padStart(4)}px ${view} 无横向溢出`, bad <= 0, `页面 ${r.page} / 表格 ${r.wrap} / 网格 ${r.grid}`);
await page.close();
}
}
} finally {
await browser.close();
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
if (worst && worst.bad > 0) console.log(`\n最大溢出:${worst.width}px ${worst.view} → ${worst.bad}px`);
const failed = results.filter((x) => !x.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);
});