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
+171
View File
@@ -0,0 +1,171 @@
/**
* shiroko-monitor 安全与探测能力验证(开发用)
*
* 覆盖:
* A. banner 探测:SSH 标识串识别、expectBanner 策略匹配/不符、静默端口超时、默认不回显版本
* B. 信息泄露控制:detail 不含地址/版本、redactDetails 脱敏、公开配置不含真实地址
* C. 私有配置分层:local.monitors.json 覆盖 host/port 后探测才可连通
* D. /api/refresh 滥用防护:冷却限流 429 + Retry-After、allowRefresh=false 时 403
* E. 来源判定 isPrivateAddress 单元校验
*
* 用法:node scripts/verify-security.cjs
*/
const fs = require('node:fs');
const os = require('node:os');
const net = require('node:net');
const path = require('node:path');
const { spawn } = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-sec-'));
const SSH_PORT = 2222;
const SILENT_PORT = 2223;
const results = [];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function check(name, pass, detail) {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
async function waitForHttp(url, timeoutMs = 8000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fetch(url, { cache: 'no-store' });
return true;
} catch {
await sleep(200);
}
}
return false;
}
function startProber(args, env = {}) {
return spawn(process.execPath, [PROBER, ...args], {
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function runOnce(args) {
return new Promise((resolve) => {
const child = startProber(args);
let out = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (out += d));
child.on('exit', (code) => resolve({ code, out }));
});
}
async function main() {
// ---------- 准备:本地假 SSH 服务 + 静默端口 ----------
const sshServer = net.createServer((sock) => {
// 忠实模拟 OpenSSH:连接建立后立即发送标识串(RFC 4253)
sock.write('SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13\r\n');
});
const silentServer = net.createServer(() => { /* 接受连接但从不发送数据 */ });
await new Promise((r) => sshServer.listen(SSH_PORT, '127.0.0.1', r));
await new Promise((r) => silentServer.listen(SILENT_PORT, '127.0.0.1', r));
console.log(`[准备] 假 SSH 服务 127.0.0.1:${SSH_PORT},静默端口 127.0.0.1:${SILENT_PORT},临时目录 ${TMP}`);
// ---------- A/B/C:banner 探测 + 脱敏 + 私有配置分层 ----------
const pubConfig = path.join(TMP, 'monitors.json');
const localConfig = path.join(TMP, 'local.monitors.json');
const outJson = path.join(TMP, 'status.json');
fs.writeFileSync(pubConfig, JSON.stringify({
settings: { title: 'sec-test', redactDetails: false },
monitors: [
{ id: 'ssh-banner', name: 'SSH 服务', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-' },
{ id: 'ssh-reveal', name: 'SSH(回显)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-', revealBanner: true },
{ id: 'ssh-mismatch', name: 'SSH(策略不符)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-1\\.99-' },
{ id: 'silent-banner', name: '静默端口 banner', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 },
{ id: 'silent-tcp', name: '静默端口 TCP', group: '基础设施', type: 'tcp', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 },
],
}, null, 2));
// 真实地址只存在于私有覆盖中(模拟「公开仓库不含内网地址」)
fs.writeFileSync(localConfig, JSON.stringify({
monitors: [
{ id: 'ssh-banner', host: '127.0.0.1', port: SSH_PORT },
{ id: 'ssh-reveal', host: '127.0.0.1', port: SSH_PORT },
{ id: 'ssh-mismatch', host: '127.0.0.1', port: SSH_PORT },
{ id: 'silent-banner', host: '127.0.0.1', port: SILENT_PORT },
{ id: 'silent-tcp', host: '127.0.0.1', port: SILENT_PORT },
],
}, null, 2));
console.log('\n[A] banner 探测(私有配置覆盖地址后执行一轮探测)');
const once = await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outJson}`]);
const snap = JSON.parse(fs.readFileSync(outJson, 'utf8'));
const m = snap.monitors;
check('私有配置分层生效(占位地址被覆盖为本地地址后探测成功)', m['ssh-banner'].status === 'up', `ssh-banner=${m['ssh-banner'].status} · ${m['ssh-banner'].detail}`);
check('SSH 标识串识别为在线且只给协议族摘要', m['ssh-banner'].detail === 'SSH 响应正常', `detail="${m['ssh-banner'].detail}"`);
check('默认不回显版本号(detail 不含 OpenSSH)', !/OpenSSH/.test(m['ssh-banner'].detail), m['ssh-banner'].detail);
check('expectBanner 策略不符时判为离线且不回显实际标识串', m['ssh-mismatch'].status === 'down' && !/OpenSSH/.test(m['ssh-mismatch'].detail), `detail="${m['ssh-mismatch'].detail}"`);
check('显式开启 revealBanner 后才回显标识串', /OpenSSH/.test(m['ssh-reveal'].detail), `detail="${m['ssh-reveal'].detail}"`);
check('静默端口 banner 探测超时判离线', m['silent-banner'].status === 'down', `detail="${m['silent-banner'].detail}"`);
check('同一静默端口 TCP 探测可达(证明两者判定逻辑不同)', m['silent-tcp'].status === 'up', `detail="${m['silent-tcp'].detail}"`);
check('TCP detail 不再包含地址', !m['silent-tcp'].detail.includes('127.0.0.1') && !m['silent-tcp'].detail.includes(':'), m['silent-tcp'].detail);
const rawSnapshot = fs.readFileSync(outJson, 'utf8');
check('快照中不出现公开配置的占位地址', !rawSnapshot.includes('placeholder.invalid'), '未泄露 host');
check('快照中不出现真实内网地址', !rawSnapshot.includes('127.0.0.1'), '未泄露 host');
console.log('\n[B] redactDetails 脱敏');
const redacted = JSON.parse(fs.readFileSync(pubConfig, 'utf8'));
redacted.settings.redactDetails = true;
fs.writeFileSync(pubConfig, JSON.stringify(redacted, null, 2));
const outRedacted = path.join(TMP, 'status-redacted.json');
await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outRedacted}`]);
const rSnap = JSON.parse(fs.readFileSync(outRedacted, 'utf8'));
check('redactDetails 开启后 detail 只保留正常/异常', rSnap.monitors['ssh-reveal'].detail === '正常' && rSnap.monitors['silent-tcp'].detail === '正常', JSON.stringify(Object.fromEntries(Object.entries(rSnap.monitors).map(([k, v]) => [k, v.detail]))));
// ---------- D:刷新接口滥用防护 ----------
console.log('\n[D] /api/refresh 滥用防护');
const cooldownCfg = path.join(TMP, 'cooldown.json');
fs.writeFileSync(cooldownCfg, JSON.stringify({ settings: { title: 't', refreshCooldownSec: 3 }, monitors: [] }, null, 2));
const srvA = startProber([`--config=${cooldownCfg}`, `--local-config=${cooldownCfg}`, `--out=${path.join(TMP, 'a.json')}`], { PORT: '8897' });
await waitForHttp('http://127.0.0.1:8897/api/status');
const r1 = await fetch('http://127.0.0.1:8897/api/refresh');
const r2 = await fetch('http://127.0.0.1:8897/api/refresh');
check('首次刷新放行', r1.status === 200, `status=${r1.status}`);
check('冷却期内再次刷新被限流', r2.status === 429, `status=${r2.status}, retry-after=${r2.headers.get('retry-after')}s`);
check('限流响应带 Retry-After 头', Number(r2.headers.get('retry-after')) >= 1, `retry-after=${r2.headers.get('retry-after')}`);
await sleep(3300);
const r3 = await fetch('http://127.0.0.1:8897/api/refresh');
check('冷却结束后可再次刷新', r3.status === 200, `status=${r3.status}`);
srvA.kill();
const disabledCfg = path.join(TMP, 'disabled.json');
fs.writeFileSync(disabledCfg, JSON.stringify({ settings: { title: 't', allowRefresh: false }, monitors: [] }, null, 2));
const srvB = startProber([`--config=${disabledCfg}`, `--local-config=${disabledCfg}`, `--out=${path.join(TMP, 'b.json')}`], { PORT: '8898' });
await waitForHttp('http://127.0.0.1:8898/api/status');
const r4 = await fetch('http://127.0.0.1:8898/api/refresh');
const body4 = await r4.json().catch(() => ({}));
check('allowRefresh:false 时刷新被拒绝', r4.status === 403, `status=${r4.status}, error=${body4.error}`);
check('拒绝响应不泄露目标信息', !JSON.stringify(body4).includes('host') && !JSON.stringify(body4).includes('/'), JSON.stringify(body4));
const r5 = await fetch('http://127.0.0.1:8898/api/status');
check('只读模式下面板数据接口仍可用', r5.status === 200, `status=${r5.status}`);
srvB.kill();
// ---------- E:来源判定 ----------
console.log('\n[E] 来源判定');
const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`);
const priv = ['127.0.0.1', '::1', '10.1.2.3', '192.168.1.10', '172.16.0.1', '172.31.255.254', 'fc00::1', 'fe80::1', '::ffff:127.0.0.1'];
const pub = ['8.8.8.8', '1.1.1.1', '172.32.0.1', '11.0.0.1', '2001:4860:4860::8888'];
check('内网/回环地址被允许', priv.every(isPrivateAddress), priv.filter((i) => !isPrivateAddress(i)).join(',') || '全部通过');
check('公网地址被拒绝', pub.every((i) => !isPrivateAddress(i)), pub.filter(isPrivateAddress).join(',') || '全部通过');
// ---------- 收尾 ----------
sshServer.close();
silentServer.close();
fs.rmSync(TMP, { recursive: true, force: true });
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);
});
+193
View File
@@ -0,0 +1,193 @@
/**
* shiroko-monitor UptimeRobot 集成验证(开发用)
*
* 覆盖:
* A. 映射单测(无网络):用合成 payload 校验状态/延迟/状态码/心率/聚合比率/元信息,且不泄露 Key
* B. 真实 Key 冒烟(需 UPTIMEROBOT_API_KEY,未设置则跳过)
* C. UptimeRobot 兼容只读接口 /api/v2/getMonitors
* D. 失败路径:Key 无效时只记录错误、不把任何目标误判为离线
*
* 用法:UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs
*/
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const LIB = path.join(ROOT, 'prober', 'node', 'lib', 'uptimerobot.js');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-ur-'));
const LIVE_KEY = process.env.UPTIMEROBOT_API_KEY || '';
const results = [];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function check(name, pass, detail) {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
async function waitForHttp(url, timeoutMs = 8000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fetch(url, { cache: 'no-store' });
return true;
} catch {
await sleep(200);
}
}
return false;
}
function runOnce(args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [PROBER, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (out += d));
child.on('exit', (code) => resolve({ code, out }));
});
}
// 合成的 getMonitors 响应:结构照搬真实字段,但内容全部虚构
const FIXTURE = {
stat: 'ok',
pagination: { offset: 0, limit: 50, total: 3 },
monitors: [
{
id: 111111111,
friendly_name: '演示站点 A',
url: 'https://a.example.test',
type: 1,
interval: 300,
status: 2,
create_datetime: 1700000000,
custom_uptime_ratio: '100.000-99.500-99.900-99.950',
custom_down_durations: '0-100-200-300',
average_response_time: 250.5,
response_times: [
{ datetime: 1791880000, value: 300 },
{ datetime: 1791879700, value: 200 },
{ datetime: 1791879400, value: 0 },
],
logs: [
{ type: 2, datetime: 1791879700, duration: 300, reason: { code: '200', detail: 'OK' } },
{ type: 1, datetime: 1791879400, duration: 300, reason: { code: '500', detail: 'Internal Server Error' } },
],
},
{
id: 222222222,
friendly_name: '演示端口 B',
url: 'b.example.test',
type: 4,
port: 22,
interval: 300,
status: 9,
custom_uptime_ratio: '0.000-0.000-0.000-0.000',
custom_down_durations: '86400-86400-86400-86400',
average_response_time: 0,
response_times: [],
logs: [{ type: 1, datetime: 1791000000, duration: 0, reason: { code: '403', detail: 'Forbidden' } }],
},
{
id: 333333333,
friendly_name: '演示暂停项 C',
url: 'https://c.example.test',
type: 1,
interval: 300,
status: 0,
custom_uptime_ratio: '0.000-0.000-0.000-0.000',
response_times: [],
logs: [],
},
],
};
async function main() {
const { mapUptimeRobotMonitors } = await import(`file://${LIB.replace(/\\/g, '/')}`);
// ---------- A. 映射单测 ----------
console.log('\n[A] 映射单测(合成 payload,无网络)');
const mapped = mapUptimeRobotMonitors(FIXTURE, { group: 'UR 测试', now: 1791880300000 });
const [a, b, c] = mapped;
check('监控数量与 id 前缀', mapped.length === 3 && a.id === 'ur-111111111', `${mapped.length} 个,首个 id=${a.id}`);
check('status 映射:2→up / 9→down / 0→pending', a.status === 'up' && b.status === 'down' && c.status === 'pending', `${a.status}/${b.status}/${c.status}`);
check('延迟取最新一次样本', a.latency === 300 && b.latency === 0, `A=${a.latency}ms B=${b.latency}ms`);
check('故障时取故障日志里的 HTTP 状态码', b.code === 403 && a.code === null, `B.code=${b.code}`);
check('detail 含 UR 侧原因', b.detail === 'UptimeRobot:403 Forbidden', b.detail);
check('心率来自 response_times 且按时间升序', a.history.length === 3 && a.history[0].ms === 0 && a.history[2].ms === 300, JSON.stringify(a.history.map((h) => h.ms)));
check('离线目标的心率末尾补一条离线记录', b.history.length === 1 && b.history[0].ok === false, `${b.history.length} 条`);
check('聚合比率解析(1/7/30/90)', a.uptimeRatios['90'] === 99.95 && a.downSeconds['30'] === 200, JSON.stringify(a.uptimeRatios));
check('端口型目标拼出 host:port 形式', b.url === 'b.example.test:22', b.url);
check('来源标识与协议标签', a.source === 'uptimerobot' && a.sourceLabel === 'HTTP' && b.sourceLabel === 'PORT', `${a.sourceLabel}/${b.sourceLabel}`);
check('映射结果不含 API Key 字段', !JSON.stringify(mapped).toLowerCase().includes('apikey'), '无泄露');
// ---------- C. 兼容接口 ----------
console.log('\n[C] UptimeRobot 兼容只读接口');
const cfg = path.join(TMP, 'monitors.json');
const out = path.join(TMP, 'status.json');
fs.writeFileSync(cfg, JSON.stringify({
settings: { title: 't' },
monitors: [
{ id: 'ok-site', name: '正常站点', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
],
}, null, 2));
const child = spawn(process.execPath, [PROBER, `--config=${cfg}`, `--local-config=${cfg}`, `--out=${out}`], { env: { ...process.env, PORT: '8891' }, stdio: ['ignore', 'pipe', 'pipe'] });
await waitForHttp('http://127.0.0.1:8891/api/status');
const compat = await fetch('http://127.0.0.1:8891/api/v2/getMonitors').then((r) => r.json());
const first = compat.monitors?.[0] ?? {};
check('返回 stat=ok 与 pagination', compat.stat === 'ok' && compat.pagination?.total === 1, JSON.stringify(compat.pagination));
check('字段与 UptimeRobot 对齐', ['id', 'friendly_name', 'url', 'type', 'status', 'interval', 'custom_uptime_ratio', 'average_response_time', 'response_times'].every((k) => k in first), Object.keys(first).join(','));
check('id 为数字、type/status 编码一致', typeof first.id === 'number' && first.type === 1 && [1, 2, 9].includes(first.status), `id=${first.id} type=${first.type} status=${first.status}`);
check('custom_uptime_ratio 为 1-7-30-90 四位三位小数', /^\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}-\d+\.\d{3}$/.test(first.custom_uptime_ratio), first.custom_uptime_ratio);
child.kill();
// ---------- D. 失败路径:Key 无效不得误判目标状态 ----------
console.log('\n[D] 失败路径(无效 Key)');
const badCfg = path.join(TMP, 'bad.json');
const badOut = path.join(TMP, 'bad-status.json');
fs.writeFileSync(badCfg, JSON.stringify({
settings: { title: 't', uptimeRobot: { enabled: true, apiKey: 'ur000000-this-key-is-not-valid' } },
monitors: [
{ id: 'only-site', name: '唯一目标', group: '网站', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
],
}, null, 2));
const r = await runOnce([`--once`, `--config=${badCfg}`, `--local-config=${badCfg}`, `--out=${badOut}`]);
const badSnap = JSON.parse(fs.readFileSync(badOut, 'utf8'));
check('同步失败被记录为 ok:false', badSnap.sources.uptimerobot.ok === false, String(badSnap.sources.uptimerobot.error).slice(0, 60));
check('同步失败不影响本地目标(无目标被误判为离线)', badSnap.stats.total === 1 && badSnap.stats.up === 1, `total=${badSnap.stats.total} up=${badSnap.stats.up}`);
check('未创建任何 UptimeRobot 镜像目标', Object.values(badSnap.monitors).every((m) => m.meta?.source !== 'uptimerobot'), '无镜像目标');
// ---------- B. 真实 Key 冒烟 ----------
console.log('\n[B] 真实 Key 冒烟');
if (!LIVE_KEY) {
console.log(' - 跳过(未设置 UPTIMEROBOT_API_KEY)');
} else {
const liveCfg = path.join(TMP, 'live.json');
const liveOut = path.join(TMP, 'live-status.json');
fs.writeFileSync(liveCfg, JSON.stringify({
settings: { title: 't', uptimeRobot: { enabled: true, apiKey: LIVE_KEY, pollIntervalSec: 300, group: 'UptimeRobot' } },
monitors: [],
}, null, 2));
await runOnce([`--once`, `--config=${liveCfg}`, `--local-config=${liveCfg}`, `--out=${liveOut}`]);
const live = JSON.parse(fs.readFileSync(liveOut, 'utf8'));
const mirrors = Object.entries(live.monitors).filter(([, m]) => m.meta?.source === 'uptimerobot');
check('真实账号同步成功', live.sources.uptimerobot.ok === true, `count=${live.sources.uptimerobot.count}`);
check('拉到监控并全部映射', mirrors.length > 0 && mirrors.every(([, m]) => ['up', 'down', 'pending'].includes(m.status)), `${mirrors.length} 个`);
check('每个镜像目标都带展示元信息', mirrors.every(([, m]) => m.meta.name && Array.isArray(m.history) && m.uptimeRatios), 'name/history/uptimeRatios 齐备');
check('快照中不含 API Key', !JSON.stringify(live).includes(LIVE_KEY.slice(-12)), '无泄露');
for (const [id, m] of mirrors) console.log(` · ${id.padEnd(14)} ${m.status.padEnd(7)} ${String(m.latency ?? '-').padStart(5)}ms UR90天=${m.uptimeRatios?.['90']} ${m.meta.name}`);
}
fs.rmSync(TMP, { recursive: true, force: true });
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);
fs.rmSync(TMP, { recursive: true, force: true });
process.exit(1);
});
+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);
});
+191
View File
@@ -0,0 +1,191 @@
/**
* shiroko-monitor 端到端验证脚本(开发用,需 playwright)
*
* 覆盖:
* A. 热重载回归:改写 monitors.json 触发配置热重载后,/api/status 必须继续更新(历史 bug:接口冻结)
* B. 接口与文件一致性:/api/status 与 status.json 的 lastCheck 必须一致
* C. 面板重渲染:跨越一个刷新周期后,页脚「更新于」必须前进
* D. UI 断言:卡片数、心率图、90 格可用率条、统计栏
*
* 用法:NODE_PATH=<playwright node_modules> node scripts/verify.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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
function check(name, pass, detail) {
results.push({ name, pass, detail });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
async function getJson(url) {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`);
return res.json();
}
async function main() {
// ---------- A/B. 服务端数据新鲜度与一致性 ----------
const cfgA = JSON.parse(fs.readFileSync(CONFIG, 'utf8'));
const cfgIdsA = new Set(cfgA.monitors.map((m) => m.id));
const first = (await getJson(`${BASE}/api/status`)).monitors;
// 优先选配置里的自建探测目标;若当前 dataFilter 只展示外部数据源,则退化为一致性/新鲜度断言
const probeId = Object.keys(first).find((id) => cfgIdsA.has(id)) ?? Object.keys(first)[0];
const isProbeTarget = cfgIdsA.has(probeId);
const before = first[probeId].lastCheck;
console.log(`\n[A] 触发配置热重载(改写 monitors.json),监控目标 ${probeId}${isProbeTarget ? '(自建探测)' : '(外部数据源镜像)'}`);
console.log(` 热重载前 lastCheck: ${before}`);
// 原样写回(不改格式)即可触发 fs.watch 的 change 事件
fs.writeFileSync(CONFIG, fs.readFileSync(CONFIG, 'utf8'));
await sleep(12000);
const mid = await getJson(`${BASE}/api/status`);
if (isProbeTarget) {
check('/api/status 热重载后 12s 内已产生新检测', mid.monitors[probeId].lastCheck > before, `lastCheck=${mid.monitors[probeId].lastCheck}`);
} else {
// 外部镜像目标由数据源按自身节奏更新,热重载不会让它产生新检测;改为校验服务端仍在产出数据
const ageSec = Math.round((Date.now() - new Date(mid.dataUpdatedAt).getTime()) / 1000);
check('热重载后接口仍持续服务且数据在节奏内', mid.monitors[probeId] != null && ageSec <= Number(mid.updateIntervalSec) + 30, `数据距今 ${ageSec}s / 节奏 ${mid.updateIntervalSec}s`);
}
await sleep(60000);
const api = await getJson(`${BASE}/api/status`);
const file = await getJson(`${BASE}/status.json`);
if (isProbeTarget) {
check('/api/status 跨越一个探测周期后仍在更新', api.monitors[probeId].lastCheck > mid.monitors[probeId].lastCheck, `lastCheck=${api.monitors[probeId].lastCheck}`);
}
check('接口与 status.json 数据一致', api.monitors[probeId].lastCheck === file.monitors[probeId].lastCheck, `api=${api.monitors[probeId].lastCheck} file=${file.monitors[probeId].lastCheck}`);
const dailyKeys = Object.keys(api.monitors[probeId].daily ?? {});
check('90 天可用率数据已聚合且不超过 90 天', dailyKeys.length > 0 && dailyKeys.length <= 90, `days=${dailyKeys.length}, today=${JSON.stringify(api.monitors[probeId].daily[dailyKeys[dailyKeys.length - 1]])}`);
check('统计包含 avgUptime90', api.stats.avgUptime90 != null, `avgUptime90=${api.stats.avgUptime90}`);
// ---------- E. 刷新即重新检测 ----------
console.log('\n[E] 刷新即重新检测');
const cfg = JSON.parse(fs.readFileSync(CONFIG, 'utf8'));
const snapMeta = await getJson(`${BASE}/api/status`);
const expectedCards = Object.keys(snapMeta.monitors).length;
// 外部数据源(UptimeRobot)镜像目标不参与本地强制探测,其「最近检测」由数据源给出
const externalIds = Object.entries(snapMeta.monitors).filter(([, m]) => m.meta?.source === 'uptimerobot').map(([id]) => id);
// dataFilter 只展示外部数据源时,配置目标不在快照里,探测类断言不适用
const cfgIds = new Set(cfg.monitors.map((m) => m.id));
const probeIds = Object.keys(snapMeta.monitors).filter((id) => cfgIds.has(id));
const probeMode = probeIds.length > 0;
const forceIds = cfg.monitors.filter((m) => m.probeOnRefresh !== false && probeIds.includes(m.id)).map((m) => m.id);
// 显式 opt-out 的配置目标:不参与任何强制探测
const optOutIds = cfg.monitors.filter((m) => m.probeOnRefresh === false && probeIds.includes(m.id)).map((m) => m.id);
const pre = await getJson(`${BASE}/api/status`);
const t0 = Date.now();
const roundStartIso = new Date(t0).toISOString();
const fresh = await fetch(`${BASE}/api/refresh`).then((r) => r.json());
const roundMs = Date.now() - t0;
check('/api/refresh 等待本轮探测完成后才返回', roundMs > 100, `round=${roundMs}ms`);
if (probeMode) {
const ages = forceIds.map((id) => [id, Math.round((Date.now() - new Date(fresh.monitors[id].lastCheck)) / 1000)]);
const worst = ages.length ? Math.max(...ages.map(([, a]) => a)) : 0;
check('刷新后参与强制检测的目标全部产生新检测', forceIds.every((id) => fresh.monitors[id].lastCheck > pre.monitors[id].lastCheck), `${forceIds.filter((id) => !(fresh.monitors[id].lastCheck > pre.monitors[id].lastCheck)).join(',') || '全部前进'}`);
check('刷新后最旧数据不超过本轮耗时', worst <= Math.ceil(roundMs / 1000) + 3, `最旧 ${worst}s(轮次 ${Math.round(roundMs / 1000)}s)`);
// 外部镜像目标会被顺带同步(受 60s 最小间隔约束),故不在此断言其时间不变
check('probeOnRefresh:false 的目标不参与强制检测', optOutIds.length === 0 || optOutIds.every((id) => fresh.monitors[id].lastCheck <= roundStartIso), optOutIds.length ? `${optOutIds.join(',')} 未参与(另有 ${externalIds.length} 个外部镜像目标由数据源同步)` : '无此类目标');
} else {
console.log(` - 跳过强制探测断言:当前 dataFilter=${snapMeta.dataFilter},快照中无配置目标(${externalIds.length} 个外部镜像目标由数据源同步)`);
}
// ---------- G. 刷新节奏对齐服务端 ----------
console.log('\n[G] 刷新节奏对齐服务端(不再每访客重新计时)');
const align = await getJson(`${BASE}/api/status`);
const cadence = Number(align.updateIntervalSec);
const updatedMs = new Date(align.dataUpdatedAt).getTime();
check('快照带 dataUpdatedAt 与 updateIntervalSec', Number.isFinite(updatedMs) && updatedMs > 0 && cadence >= 5, `更新于 ${align.dataUpdatedAt},节奏 ${cadence}s`);
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
const pageG = await browser.newPage({ viewport: { width: 1280, height: 900 } });
await pageG.goto(`${BASE}/?source=api&view=table`, { waitUntil: 'domcontentloaded' });
await pageG.waitForSelector('table.monitor-table tbody tr[data-status]', { timeout: 20000 });
await pageG.waitForTimeout(1200);
const shownText = await pageG.evaluate(() => document.getElementById('countdown').textContent);
const shownSec = Number((shownText.match(/(\d+)/) ?? [])[1]);
// 期望值需在当前时刻重算:面板显示的是「服务端上次更新 + 一个周期 - 现在」
const expectSec = Math.round((updatedMs + cadence * 1000 - Date.now()) / 1000);
check('倒计时对齐服务端下次更新时刻', Math.abs(shownSec - expectSec) <= 4, `面板 ${shownText}(服务端还有约 ${expectSec}s,节奏 ${cadence}s)`);
check('倒计时不会退化为从整周期重新计时', shownSec <= Math.max(5, Math.min(cadence, Number(cfg.settings.refreshInterval) || cadence)), `显示 ${shownSec}s`);
await pageG.close();
// ---------- F. 面板渲染与自动刷新 ----------
console.log('\n[F] 面板渲染与刷新');
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
await page.goto(`${BASE}/?source=api&view=cards`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.card', { timeout: 15000 });
await page.waitForTimeout(1200);
const read = () => page.evaluate(() => ({
cards: document.querySelectorAll('.card').length,
bars: document.querySelectorAll('.heartbeat svg rect').length,
uptimeCells: document.querySelectorAll('.uptime-cell').length,
uptimeCellsPerCard: document.querySelector('.card .uptime-bar')?.children.length ?? 0,
uptimeLabel: document.querySelector('.uptime-label')?.textContent ?? '',
uptimeLabelTip: document.querySelector('.uptime-label')?.getAttribute('title') ?? '',
statUptime: document.getElementById('stat-uptime').textContent,
stats: document.querySelectorAll('.summary .stat').length,
updated: document.getElementById('updated-at').textContent,
lastCheckTitle: document.querySelector('.card .metric b[title]')?.getAttribute('title') ?? '',
countdown: document.getElementById('countdown').textContent,
version: document.querySelector('.version')?.textContent ?? '',
}));
const a = await read();
console.log(` 首次读取: 卡片 ${a.cards},可用率条 ${a.uptimeCellsPerCard} 格/卡,标签「${a.uptimeLabel}」`);
check('卡片全部渲染', a.cards === expectedCards, `cards=${a.cards}/${expectedCards}`);
check('每卡 90 格可用率条', a.uptimeCellsPerCard === 90, `perCard=${a.uptimeCellsPerCard}`);
check('可用率标签含百分比', /90天\s*[\d.]+%/.test(a.uptimeLabel), `label=${a.uptimeLabel}`);
check('可用率悬浮提示含统计明细', /次在线/.test(a.uptimeLabelTip), `title=${a.uptimeLabelTip}`);
check('顶部 90天可用率统计已渲染', a.statUptime !== '–', `stat=${a.statUptime}`);
check('最近检测带绝对时间提示', a.lastCheckTitle !== '–' && a.lastCheckTitle.length > 0, `title=${a.lastCheckTitle}`);
check('版本号已渲染', /^shiroko-monitor v\d+\.\d+\.\d+$/.test(a.version), a.version);
console.log(' 等待一个检查周期(70s)…');
await sleep(70000);
const b = await read();
// 自动刷新是只读的:页脚「数据更新于」只在服务端产出新数据时变化,故用倒计时重新对齐来判断面板仍在按节奏轮询
check('面板持续按服务端节奏轮询(倒计时重新对齐)', a.countdown !== b.countdown, `${a.countdown} -> ${b.countdown}`);
if (probeMode) {
check('探测目标数据已流入面板(最近检测前进)', b.lastCheckTitle > a.lastCheckTitle, `${a.lastCheckTitle} -> ${b.lastCheckTitle}`);
}
// 手动刷新按钮:参与的卡片都必须重新检测,opt-out 的卡片不应被带动(历史 bug:只有部分目标更新)
const readCards = () => page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.card')].map((c) => [c.dataset.id, c.querySelector('.metric b[title]')?.getAttribute('title') ?? ''])));
const beforeCards = await readCards();
await page.click('#refresh-btn');
await page.waitForFunction(() => document.getElementById('refresh-btn').textContent === '刷新' && !document.getElementById('refresh-btn').disabled, null, { timeout: 45000 });
await page.waitForTimeout(400);
const afterCards = await readCards();
const notAdvanced = forceIds.filter((id) => !(afterCards[id] > beforeCards[id]));
const optOutAdvanced = optOutIds.filter((id) => afterCards[id] > beforeCards[id]);
if (probeMode) {
check('参与检测的卡片全部重新检测(手动刷新)', notAdvanced.length === 0, notAdvanced.length ? `${notAdvanced.join(',')} 未前进` : `${forceIds.length} 张全部前进`);
check('probeOnRefresh:false 的卡片不被手动刷新带动', optOutAdvanced.length === 0, optOutIds.length ? `${optOutIds.length} 张保持原时间${externalIds.length ? `(另有 ${externalIds.length} 张外部镜像由数据源同步,允许更新)` : ''}` : '无此类目标');
}
check('刷新过程中无 PageError', errors.length === 0, errors.length ? errors.join(' | ') : '无');
await page.screenshot({ path: path.join(__dirname, '..', 'docs', 'screenshot-desktop.png'), fullPage: true });
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);
});