Files
tamakyi 25b8b97970 fix: 表格完整显示 + 事件区块样式重做 + 探测范围可选(probeScope)
1) 表格显示不全
   table-layout:fixed 保证不横向滚动后,列内仍是 nowrap,实测长 URL 被截
   388px、名称截 219px、可用率条截 35px(1440 宽共 14 个单元格)。
   改为允许换行(地址 break-all)并重排列宽 —— 数据完整且仍零横向滚动。

2) 最近事件完全没样式
   根因:上次追加 CSS 的命令是 `node --check app.js && cat >> style.css`,
   --check 报错导致 && 短路,CSS 从未写入(只有 HTML 裸奔)。
   本次用编辑器写入;同时重做为时间线样式(竖线 + 类型着色圆点 + 左描边)、
   名称列按内容自适应(消除大片空白)、默认折叠最近 8 条可展开全部。

3) dataFilter=uptimerobot 时仍在探测本地目标
   新增 settings.probeScope(all|config|uptimerobot),不填则跟随 dataFilter;
   显式设 all 可保留探测。启动日志打印当前口径与范围。

顺带修复两个连带问题:
- UR 镜像目标此前不产生事件也不告警(evaluateRound 只遍历本地目标),
  配 uptimerobot 口径等于收不到任何提醒 —— 现在两类目标统一纳入
- 去抖计数被评估频率放大:evaluateRound 每秒执行,原实现每次评估都
  failStreak+1,持续离线目标 1 秒即达阈值告警。改为按探测时间戳去重,
  且无状态变化时不再反复落盘

测试:verify-views [J] 扩到 11 条(长内容不截断/仍不滚动/时间线布局/
类型着色/折叠展开),verify-notify 新增 [D3] 4 条(范围跟随、不再探测本地、
显式 all 恢复、与展示口径解耦)+ 去抖计数断言。六套 170 项全通过。
2026-10-14 10:22:05 +08:00

402 lines
22 KiB
JavaScript
Raw Permalink 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 通知与证书验证
*
* 覆盖:
* A. 邮件通道(假 SMTP 服务器):完整握手、AUTH LOGIN、多收件人、UTF-8 主题与正文、
* 5xx 报错路径、不支持 STARTTLS 时默认拒绝明文
* B. MeoW 通道(假 HTTP 服务器):成功(status=200)、404 昵称未注册、429 限流、
* 「HTTP 200 但业务 status≠200」、非 JSON 响应
* C. 静默时段(含跨零点)与 MeoW 本地限流器
* D. 集成:真跑探针 --once,验证去抖(连续失败 N 次才通知)、恢复通知、
* 事件时间线落盘、维护窗口抑制告警但仍记录事件
* E. 证书:tlsTargetOf 解析 + 真实握手(无网络则跳过,不算失败)
*
* 用法:node scripts/verify-notify.cjs
*/
const fs = require('node:fs');
const net = require('node:net');
const http = require('node:http');
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', 'notify.js');
const TLS_LIB = path.join(ROOT, 'prober', 'node', 'lib', 'tls-probe.js');
const SMTP_PORT = 8895;
const MEOW_PORT = 8896;
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}` : ''}`);
}
/* ---------- 假 SMTP 服务器 ---------- */
function startFakeSmtp() {
const received = [];
let failAt = null; // 'auth' | 'mail' 注入 5xx
const server = net.createServer((socket) => {
let buf = '';
let stage = 'greeting'; // greeting → ehlo → auth-user → auth-pass → ready → data
const send = (line) => socket.write(`${line}\r\n`);
send('220 fake-smtp ready');
socket.on('data', (chunk) => {
buf += chunk.toString('utf8');
if (!buf.endsWith('\r\n')) return;
const line = buf.trim();
buf = '';
const cmd = line.split(/\s+/)[0].toUpperCase();
// 不实现 TLS:EHLO 只通告 AUTH LOGIN(客户端会按 allowInsecure 决定是否明文继续)
if (stage === 'greeting' && cmd === 'EHLO') {
// SMTP 多行响应:最后一行必须是「250 + 空格」才算终止(客户端按此判定)
send('250-fake-smtp');
send('250-AUTH LOGIN PLAIN');
send('250 OK');
stage = 'ehlo';
} else if (cmd === 'AUTH' && line.toUpperCase().startsWith('AUTH LOGIN')) {
if (failAt === 'auth') return send('535 认证失败');
send('334 VXNlcm5hbWU6');
stage = 'auth-user';
} else if (stage === 'auth-user') {
send('334 UGFzc3dvcmQ6');
stage = 'auth-pass';
} else if (stage === 'auth-pass') {
send('235 认证成功');
stage = 'ready';
} else if (cmd === 'MAIL') {
if (failAt === 'mail') return send('550 发件人被拒');
received.push({ mailFrom: /<([^>]*)>/.exec(line)?.[1] ?? '' });
send('250 ok');
} else if (cmd === 'RCPT') {
const addr = /<([^>]*)>/.exec(line)?.[1] ?? '';
if (!received.length) received.push({});
received.at(-1).rcpt = [...(received.at(-1).rcpt ?? []), addr];
send('250 ok');
} else if (cmd === 'DATA') {
send('354 end with <CRLF>.<CRLF>');
stage = 'data';
} else if (stage === 'data') {
received.at(-1).raw = line;
stage = 'done';
send('250 queued');
} else if (cmd === 'QUIT') {
send('221 bye');
socket.end();
} else {
send('250 ok');
}
});
socket.on('error', () => {});
});
return new Promise((resolve) => server.listen(SMTP_PORT, '127.0.0.1', () => resolve({
received,
failAt: (v) => { failAt = v; },
close: () => new Promise((r) => server.close(r)),
})));
}
/* ---------- 假 MeoW 服务器 ---------- */
function startFakeMeoW() {
const received = [];
const server = http.createServer((req, res) => {
const nickname = decodeURIComponent(req.url.split('?')[0].replace(/^\//, ''));
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push({ nickname, url: req.url, body });
const json = (status, message) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status, message }));
};
// 注意:MeoW 的 HTTP 状态恒为 200,业务结果只在 body 里
if (nickname === 'ok') return json(200, '推送成功');
if (nickname === 'limited') return json(429, '请求过于频繁');
if (nickname === 'missing') return json(404, '昵称未注册');
res.writeHead(200, { 'content-type': 'text/html' });
res.end('<html>not json</html>');
});
});
return new Promise((resolve) => server.listen(MEOW_PORT, '127.0.0.1', () => resolve({
received,
close: () => new Promise((r) => server.close(r)),
})));
}
/* ---------- 探针进程 ---------- */
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 }));
});
}
function writeConfig(dir, overrides = {}) {
const cfg = {
settings: {
title: 'notify-test',
dataFilter: 'config',
refreshInterval: 60,
notify: {
enabled: true,
failureThreshold: 2,
recoveryNotify: true,
channels: {
email: {
enabled: true, host: '127.0.0.1', port: SMTP_PORT, secure: false, allowInsecure: true,
user: '[email protected]', pass: 'secret', from: '[email protected]',
to: ['[email protected]', '[email protected]'],
},
meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` },
},
},
},
monitors: [
// 拒绝连接 → 离线(127.0.0.1:1 一定连不上)
{ id: 'down-tcp', name: '必然离线的 TCP', group: '测试', type: 'tcp', host: '127.0.0.1', port: 1, interval: 60, timeout: 3 },
// data: URL 一定成功 → 在线
{ id: 'up-http', name: '必然在线的 HTTP', group: '测试', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
],
...overrides,
};
const file = path.join(dir, 'monitors.json');
fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`);
return { file, cfg };
}
async function main() {
const { sendEmail, sendMeoW, sendTestNotifications, notifyAll, parseQuietHours, inQuietHours, createMeoWLimiter } = await import(`file://${LIB.replace(/\\/g, '/')}`);
const { probeCertificate, tlsTargetOf } = await import(`file://${TLS_LIB.replace(/\\/g, '/')}`);
const smtp = await startFakeSmtp();
const meow = await startFakeMeoW();
try {
/* ---------- A. 邮件通道 ---------- */
console.log('\n[A] 邮件通道(假 SMTP)');
const emailCfg = {
enabled: true, host: '127.0.0.1', port: SMTP_PORT, secure: false, allowInsecure: true,
user: '[email protected]', pass: 's3cret!', from: '[email protected]', to: ['[email protected]', '[email protected]'],
};
await sendEmail(emailCfg, { subject: '【故障】测试目标 连接失败', text: '第一行\n第二行:中文内容' });
const mail = smtp.received.at(-1);
check('邮件已发出', Boolean(mail?.raw), '假服务器收到 DATA');
check('发件人与收件人正确', mail?.mailFrom === '[email protected]' && mail?.rcpt?.join(',') === '[email protected],[email protected]', `${mail?.mailFrom} → ${mail?.rcpt?.join(',')}`);
const headers = (mail?.raw ?? '').split('\r\n\r\n')[0] ?? '';
const bodyB64 = (mail?.raw ?? '').split('\r\n\r\n')[1] ?? '';
check('中文主题按 RFC2047 编码', /^Subject: =\?UTF-8\?B\?/m.test(headers), headers.split('\r\n').find((l) => l.startsWith('Subject:'))?.slice(0, 46) + '…');
check('正文 base64 且可解码出中文', Buffer.from(bodyB64.replace(/\r\n/g, ''), 'base64').toString('utf8').includes('第二行:中文内容'), '');
check('声明 UTF-8 字符集', /Content-Type: text\/plain; charset=UTF-8/.test(headers), '');
smtp.failAt('mail');
let err = null;
try { await sendEmail(emailCfg, { subject: 'x', text: 'y' }); } catch (e) { err = e; }
check('SMTP 5xx 被识别为失败', /发件人被拒/.test(err?.message ?? ''), err?.message ?? '未抛错');
smtp.failAt(null); // 复位注入的故障,避免污染后续用例
let err2 = null;
try { await sendEmail({ ...emailCfg, host: '127.0.0.1', port: 8999 }, { subject: 'x', text: 'y' }); } catch (e) { err2 = e; }
check('连不上 SMTP 时报错可读', /ECONNREFUSED|超时|连接/.test(err2?.message ?? ''), err2?.message?.slice(0, 60) ?? '未抛错');
let err3 = null;
try { await sendEmail({ ...emailCfg, allowInsecure: false }, { subject: 'x', text: 'y' }); } catch (e) { err3 = e; }
check('服务器无 STARTTLS 时默认拒绝明文发信', /STARTTLS/.test(err3?.message ?? ''), err3?.message?.slice(0, 70) ?? '未抛错');
/* ---------- B. MeoW 通道 ---------- */
console.log('\n[B] MeoW 通道(假 HTTP)');
const meowCfg = { nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` };
const r1 = await sendMeoW(meowCfg, { title: '标题', text: '正文' });
check('status=200 判定为成功', r1.ok === true, r1.detail ?? '');
const req1 = meow.received.at(-1);
check('昵称走路径、msgType 不作为 body', req1.nickname === 'ok' && !req1.body.includes('msgType'), `POST /${req1.nickname}${req1.url.includes('?') ? req1.url.slice(req1.url.indexOf('?')) : ''}`);
check('JSON body 含 title 与 msg', (() => { const b = JSON.parse(req1.body); return b.title === '标题' && b.msg === '正文'; })(), '');
for (const [nick, label] of [['limited', '429 限流'], ['missing', '404 昵称未注册']]) {
let e = null;
try { await sendMeoW({ ...meowCfg, nickname: nick }, { title: 't', text: 'm' }); } catch (err3) { e = err3; }
check(`${label} 被识别为失败`, Boolean(e), e?.message?.slice(0, 70) ?? '未抛错');
}
let e4 = null;
try { await sendMeoW({ ...meowCfg, nickname: 'broken' }, { title: 't', text: 'm' }); } catch (err4) { e4 = err4; }
check('非 JSON 响应被识别为失败', /不是 JSON/.test(e4?.message ?? ''), e4?.message?.slice(0, 50) ?? '');
let e5 = null;
try { await sendMeoW({ ...meowCfg, nickname: 'a/b' }, { title: 't', text: 'm' }); } catch (err5) { e5 = err5; }
check('昵称含斜杠被拒绝', /斜杠/.test(e5?.message ?? ''), e5?.message ?? '');
/* ---------- C. 静默时段与限流器 ---------- */
console.log('\n[C] 静默时段与限流器');
check('静默时段解析', parseQuietHours('23:00-07:00').from === 23 * 60, '');
check('跨零点判定(23:30 在静默中)', inQuietHours('23:00-07:00', new Date('2026-10-13T23:30:00')) === true, '');
check('跨零点判定(07:30 不在静默中)', inQuietHours('23:00-07:00', new Date('2026-10-13T07:30:00')) === false, '');
check('同时段判定(09:00-17:00 内)', inQuietHours('09:00-17:00', new Date('2026-10-13T12:00:00')) === true, '');
const limiter = createMeoWLimiter(() => 0);
check('限流器首次放行', limiter.allow() === true, '');
limiter.mark();
check('限流器 3 秒内拦截', limiter.allow() === false, '');
const quiet = await notifyAll(
{ enabled: true, quietHours: '23:00-07:00', channels: { meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` } } },
{ subject: '静默测试', text: '不应发送' },
{ logger: console, fetchImpl: fetch },
);
const before = meow.received.length;
const now = new Date();
const quietCfg = { enabled: true, quietHours: `${String(now.getHours()).padStart(2, '0')}:00-${String(now.getHours()).padStart(2, '0')}:59`, channels: { meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` } } };
const q = await notifyAll(quietCfg, { subject: '静默测试', text: '不应发送' }, { logger: console });
check('静默时段内不发送且标记 suppressed', q.suppressed === true && meow.received.length === before, `received ${before} → ${meow.received.length}`);
/* ---------- D. 集成:真探针 ---------- */
console.log('\n[D] 集成(探针 --once × 多轮)');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-notify-'));
fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true });
const { file } = writeConfig(tmp);
const dataDir = path.join(tmp, 'data');
const outFile = path.join(tmp, 'status.json');
const common = [`--config=${file}`, `--local-config=${file}`, `--out=${outFile}`, `--data-dir=${dataDir}`];
const beforeMail = smtp.received.length;
const beforeMeow = meow.received.length;
const run1 = await runOnce(['--once', ...common]);
check('第一轮探测成功', run1.code === 0, run1.out.split('\n').find((l) => l.includes('[once]')) ?? '');
const mailAfter1 = smtp.received.length - beforeMail;
const meowAfter1 = meow.received.length - beforeMeow;
check('离线目标连续 2 次失败才告警:第一轮不通知', mailAfter1 === 0 && meowAfter1 === 0, `邮件 ${mailAfter1} / MeoW ${meowAfter1}`);
// 事件文件只在有事件时才落盘,因此要容忍不存在
const readEvents = (dir) => {
const f = path.join(dir, 'events.json');
return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : [];
};
const events1 = readEvents(dataDir);
check('第一轮未记录事件(去抖中)', events1.length === 0, `events=${events1.length}`);
await runOnce(['--once', ...common]);
const mailAfter2 = smtp.received.length - beforeMail;
const meowAfter2 = meow.received.length - beforeMeow;
check('第二轮达到阈值:邮件与 MeoW 各 1 条', mailAfter2 === 1 && meowAfter2 === 1, `邮件 ${mailAfter2} / MeoW ${meowAfter2}`);
const decodeMail = (raw) => {
const [head, body = ''] = raw.split('\r\n\r\n');
const subject = /^Subject: =\?UTF-8\?B\?(.*)\?=/m.exec(head);
return {
subject: subject ? Buffer.from(subject[1], 'base64').toString('utf8') : (/^Subject: (.*)$/m.exec(head)?.[1] ?? ''),
body: Buffer.from(body.replace(/\r\n/g, ''), 'base64').toString('utf8'),
};
};
const downMail = smtp.received.at(-1);
const downDecoded = decodeMail(downMail.raw);
check('告警邮件含目标名称与详情', /必然离线的 TCP/.test(downDecoded.body) && /【故障】/.test(downDecoded.subject), `主题「${downDecoded.subject}」正文 ${downDecoded.body.length} 字符`);
const events2 = readEvents(dataDir);
check('事件时间线记录 down', events2.some((e) => e.type === 'down' && e.id === 'down-tcp'), JSON.stringify(events2.map((e) => e.type)));
// 去抖计数必须与「探测轮次」绑定:评估每秒跑一次,不能每秒累加
const ns2 = JSON.parse(fs.readFileSync(path.join(dataDir, 'notify-state.json'), 'utf8'));
check('连续失败计数=探测轮数(不被每秒评估放大)', ns2.monitors['down-tcp'].failStreak === 2, `failStreak=${ns2.monitors['down-tcp'].failStreak},期望 2`);
await runOnce(['--once', ...common]);
const mailAfter3 = smtp.received.length - beforeMail;
check('第三轮不重复告警(去抖状态已持久化)', mailAfter3 === 1, `邮件累计 ${mailAfter3}`);
// 恢复:把离线目标换成 data: URL,下一轮应发「恢复」
const upCfg = JSON.parse(fs.readFileSync(file, 'utf8'));
upCfg.monitors = upCfg.monitors.map((m) => (m.id === 'down-tcp' ? { ...m, type: 'http', url: 'data:text/plain,ok', expectStatus: [200] } : m));
delete upCfg.monitors.find((m) => m.id === 'down-tcp').host;
delete upCfg.monitors.find((m) => m.id === 'down-tcp').port;
fs.writeFileSync(file, `${JSON.stringify(upCfg, null, 2)}\n`);
await runOnce(['--once', ...common]);
const recovery = smtp.received.at(-1);
const recoveryDecoded = decodeMail(recovery.raw);
check('恢复时发送「恢复」通知', /【恢复】/.test(recoveryDecoded.subject) && /恢复时间/.test(recoveryDecoded.body), `主题「${recoveryDecoded.subject}」`);
const events3 = readEvents(dataDir);
check('事件时间线记录 up(恢复)', events3.some((e) => e.type === 'up' && e.id === 'down-tcp'), JSON.stringify(events3.map((e) => e.type)));
/* ---------- D2. 维护窗口 ---------- */
console.log('\n[D2] 维护窗口抑制告警');
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-maint-'));
fs.cpSync(path.join(ROOT, 'public'), tmp2, { recursive: true });
const { file: file2 } = writeConfig(tmp2);
const cfg2 = JSON.parse(fs.readFileSync(file2, 'utf8'));
cfg2.settings.maintenance = [{ from: new Date(Date.now() - 3600_000).toISOString(), to: new Date(Date.now() + 3600_000).toISOString(), note: '计划维护' }];
fs.writeFileSync(file2, `${JSON.stringify(cfg2, null, 2)}\n`);
const dataDir2 = path.join(tmp2, 'data');
const outFile2 = path.join(tmp2, 'status.json');
const beforeMail2 = smtp.received.length;
await runOnce(['--once', `--config=${file2}`, `--local-config=${file2}`, `--out=${outFile2}`, `--data-dir=${dataDir2}`]);
await runOnce(['--once', `--config=${file2}`, `--local-config=${file2}`, `--out=${outFile2}`, `--data-dir=${dataDir2}`]);
check('维护窗口内不发告警', smtp.received.length === beforeMail2, `新增 ${smtp.received.length - beforeMail2} 封`);
const events4 = readEvents(dataDir2);
check('维护窗口内仍记录事件(标记 maintenance)', events4.some((e) => e.type === 'down' && e.maintenance === true), JSON.stringify(events4.map((e) => `${e.type}:${e.maintenance}`)));
const snap2 = JSON.parse(fs.readFileSync(outFile2, 'utf8'));
check('快照中目标带维护标记', snap2.monitors['down-tcp'].maintenance?.note === '计划维护', '');
/* ---------- D3. 探测范围可选(probeScope) ---------- */
console.log('\n[D3] 探测范围(probeScope)');
const tmp3 = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-scope-'));
fs.cpSync(path.join(ROOT, 'public'), tmp3, { recursive: true });
const { file: file3 } = writeConfig(tmp3);
const scopeCfg = JSON.parse(fs.readFileSync(file3, 'utf8'));
scopeCfg.settings.dataFilter = 'uptimerobot'; // 只展示 UR 口径
scopeCfg.settings.uptimeRobot = { enabled: false }; // 本测试不连 UR,只看本地探测是否发生
scopeCfg.monitors.push({ id: 'local-must-not-run', name: '不该被探测的本地目标', group: '测试', type: 'tcp', host: '127.0.0.1', port: 1, interval: 60, timeout: 3 });
fs.writeFileSync(file3, `${JSON.stringify(scopeCfg, null, 2)}\n`);
const scopeArgs = (cfgFile, out, data) => ['--once', `--config=${cfgFile}`, `--local-config=${cfgFile}`, `--out=${out}`, `--data-dir=${data}`];
const outA = path.join(tmp3, 'a.json');
const resA = await runOnce(scopeArgs(file3, outA, path.join(tmp3, 'da')));
const snapA = JSON.parse(fs.readFileSync(outA, 'utf8'));
check('dataFilter=uptimerobot 时探测范围自动跟随', snapA.probeScope === 'uptimerobot', `probeScope=${snapA.probeScope}`);
check('跟随口径时不再探测本地配置目标', !/local-must-not-run/.test(resA.out), resA.out.split('\n').filter((l) => /\[up\]|\[down\]/.test(l)).join(' | ').slice(0, 80) || '本地探测日志为空');
// 对照:显式 probeScope=all 时应恢复本地探测(证明是「可选」而非「砍掉」)
scopeCfg.settings.probeScope = 'all';
fs.writeFileSync(file3, `${JSON.stringify(scopeCfg, null, 2)}\n`);
const outB = path.join(tmp3, 'b.json');
const resB = await runOnce(scopeArgs(file3, outB, path.join(tmp3, 'db')));
const snapB = JSON.parse(fs.readFileSync(outB, 'utf8'));
check('显式 probeScope=all 时恢复本地探测', snapB.probeScope === 'all' && /local-must-not-run/.test(resB.out), `probeScope=${snapB.probeScope}`);
check('探测范围与展示口径解耦(all + uptimerobot 展示)', snapB.dataFilter === 'uptimerobot' && snapB.probeScope === 'all', `filter=${snapB.dataFilter} scope=${snapB.probeScope}`);
/* ---------- E. 证书 ---------- */
console.log('\n[E] 证书');
check('tlsTargetOf 识别 https 目标', tlsTargetOf({ type: 'http', url: 'https://www.example.com:8443/x' }).port === 8443, '');
check('tlsTargetOf 忽略 http 目标', tlsTargetOf({ type: 'http', url: 'http://www.example.com' }) === null, '');
check('tlsTargetOf 尊重 tls:false', tlsTargetOf({ type: 'http', url: 'https://www.example.com', tls: false }) === null, '');
try {
const cert = await probeCertificate({ host: 'www.example.com', timeoutMs: 8000 });
if (cert.error) {
console.log(` - 真实握手失败(可能无外网):${cert.error} → 跳过`);
} else {
check('真实证书可取到剩余天数', Number.isFinite(cert.daysLeft) && cert.daysLeft > -1, `剩余 ${cert.daysLeft} 天,签发者 ${cert.issuer}`);
}
} catch (err) {
console.log(` - 真实握手异常:${err.message} → 跳过`);
}
/* ---------- F. 通道自检 CLI ---------- */
console.log('\n[F] --test-notify 自检命令');
const test = await runOnce(['--test-notify', ...common]);
check('--test-notify 退出码为 0 且报告两渠道', test.code === 0 && /email/.test(test.out) && /meow/i.test(test.out), test.out.split('\n').slice(-4).join(' | ').slice(0, 120));
} finally {
await smtp.close();
await meow.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);
});