chore: v0.6.0 baseline — 主题美化前存档
已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、 数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、 刷新接口滥用防护与信息泄露控制。
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user