chore: v0.6.0 baseline — 主题美化前存档

已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
This commit is contained in:
tamakyi
2026-10-13 18:31:58 +08:00
commit 3db3ce570a
31 changed files with 5136 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
/**
* 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 ?? '',
}), SELECTOR[view]);
check(`${view} 视图渲染 ${shown} 个条目`, info.count === shown, `count=${info.count}, 切换器=${info.activeBtn}`);
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); // 还原
}
// 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);
});