tt-help-cli-ycl 1.4.131 → 1.4.132
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/test-daemon-failover.mjs +47 -43
- package/scripts/test-probe-live.mjs +83 -0
- package/scripts/test-probe.mjs +315 -0
- package/src/clash/checker.js +1 -1
- package/src/clash/daemon.js +39 -31
- package/src/watchdog/agent.js +5 -0
- package/src/watchdog/process-manager.js +8 -2
package/package.json
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* daemon _handleFailover
|
|
2
|
+
* daemon _handleFailover 单测(2026-08-25,时间锚点方案,无需 Clash 运行)
|
|
3
3
|
*
|
|
4
4
|
* 验证:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* - 窗口内到达 failoverEscapeAfter 次 → 按业务级封禁轮换一次
|
|
10
|
-
* - 实际切换后 → 窗口与计数重置
|
|
11
|
-
* - escapeAfter 可配置
|
|
5
|
+
* - 探针不健康 → 立即切换(无视冷却)
|
|
6
|
+
* - 探针健康(含壳页)→ 距上次切换 < 冷却期仅检查不切换
|
|
7
|
+
* - 探针健康 → 距上次切换 >= 冷却期 → 按业务级封禁轮换一次
|
|
8
|
+
* - 切换后(含手动/巡检/failover)更新 _lastSwitchAt 锚点,冷却期内不再切
|
|
12
9
|
* - 候选集为空 → 触发 delay 初测且不切换
|
|
13
10
|
*
|
|
14
11
|
* 运行: node --test scripts/test-daemon-failover.mjs
|
|
@@ -116,29 +113,32 @@ function makeCtx(opts = {}) {
|
|
|
116
113
|
return { d, c };
|
|
117
114
|
}
|
|
118
115
|
|
|
119
|
-
test("F1
|
|
116
|
+
test("F1 探针健康:冷却期外首次 failover 即轮换,并更新锚点", async () => {
|
|
120
117
|
resetState();
|
|
121
118
|
const { d, c } = makeCtx();
|
|
122
|
-
|
|
123
|
-
await d._handleFailover({ reason: "F1
|
|
124
|
-
assert.
|
|
125
|
-
assert.equal(state.closes, 0, "前 2 次不应断开连接");
|
|
126
|
-
await d._handleFailover({ reason: "F1-3" }, c);
|
|
127
|
-
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "第 3 次按轮换切换到候选");
|
|
119
|
+
c._lastSwitchAt = Date.now() - 600_001; // 距上次切换 > 10min 冷却
|
|
120
|
+
await d._handleFailover({ reason: "F1" }, c);
|
|
121
|
+
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "冷却期外应轮换切换");
|
|
128
122
|
assert.equal(state.closes, 1, "切换后应断开旧连接");
|
|
123
|
+
assert.ok(
|
|
124
|
+
c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
|
|
125
|
+
"切换后应更新锚点",
|
|
126
|
+
);
|
|
129
127
|
});
|
|
130
128
|
|
|
131
|
-
test("F2
|
|
129
|
+
test("F2 探针不健康:立即切换(无视冷却)", async () => {
|
|
132
130
|
resetState();
|
|
133
131
|
const { d, c } = makeCtx();
|
|
132
|
+
c._lastSwitchAt = Date.now(); // 刚切过,但探针不健康时不看锚点
|
|
134
133
|
state.health = { ...state.health, healthy: false, error: "timeout" };
|
|
135
134
|
await d._handleFailover({ reason: "F2" }, c);
|
|
136
135
|
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "不健康应立即切换");
|
|
137
136
|
});
|
|
138
137
|
|
|
139
|
-
test("F3
|
|
138
|
+
test("F3 壳页视为链路正常:冷却期内仅检查不切换", async () => {
|
|
140
139
|
resetState();
|
|
141
140
|
const { d, c } = makeCtx();
|
|
141
|
+
c._lastSwitchAt = Date.now(); // 冷却期内
|
|
142
142
|
// 壳页签名:200 + 无跳转 + 无 error + 无 title
|
|
143
143
|
state.health = {
|
|
144
144
|
healthy: false,
|
|
@@ -150,41 +150,45 @@ test("F3 壳页视为链路正常:仅检查不切换", async () => {
|
|
|
150
150
|
error: null,
|
|
151
151
|
};
|
|
152
152
|
await d._handleFailover({ reason: "F3" }, c);
|
|
153
|
-
assert.equal(state.switches.length, 0, "
|
|
153
|
+
assert.equal(state.switches.length, 0, "冷却期内壳页不应触发切换");
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
-
test("F4
|
|
156
|
+
test("F4 冷却期约束:切换后冷却期内不连续切换,过期后再切", async () => {
|
|
157
157
|
resetState();
|
|
158
|
-
const { d, c } = makeCtx(
|
|
159
|
-
|
|
160
|
-
await
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
158
|
+
const { d, c } = makeCtx();
|
|
159
|
+
// 首次 failover(无锚点)→ 按冷却期外处理 → 轮换
|
|
160
|
+
await d._handleFailover({ reason: "F4-1" }, c);
|
|
161
|
+
assert.equal(state.switches.length, 1, "首次应轮换切换");
|
|
162
|
+
// 锚点刚更新,冷却期内再次 failover(探针健康)→ 不切
|
|
163
|
+
await d._handleFailover({ reason: "F4-2" }, c);
|
|
164
|
+
assert.equal(state.switches.length, 1, "冷却期内不应再切换");
|
|
165
|
+
// 冷却期过后 → 再次轮换
|
|
166
|
+
c._lastSwitchAt = Date.now() - 600_001;
|
|
167
|
+
await d._handleFailover({ reason: "F4-3" }, c);
|
|
168
|
+
assert.equal(state.switches.length, 2, "冷却期过后应再切换");
|
|
166
169
|
});
|
|
167
170
|
|
|
168
|
-
test("F5
|
|
171
|
+
test("F5 手动切换同样更新锚点:切换后冷却期内 failover 不再切", async () => {
|
|
169
172
|
resetState();
|
|
170
173
|
const { d, c } = makeCtx();
|
|
171
|
-
|
|
172
|
-
await d.
|
|
173
|
-
|
|
174
|
-
assert.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
174
|
+
// 手动切换(用户主动切,立即执行)
|
|
175
|
+
await d._handleSwitchNode({ node: "🇲🇾 马来02", proxyPort: 7897 }, c);
|
|
176
|
+
assert.equal(state.switches.length, 1, "手动切换应立即执行");
|
|
177
|
+
assert.ok(
|
|
178
|
+
c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
|
|
179
|
+
"手动切换应更新锚点",
|
|
180
|
+
);
|
|
181
|
+
// 手动切换后冷却期内,探针健康的 failover 不再切
|
|
182
|
+
await d._handleFailover({ reason: "F5-2" }, c);
|
|
183
|
+
assert.equal(
|
|
184
|
+
state.switches.length,
|
|
185
|
+
1,
|
|
186
|
+
"手动切换后冷却期内 failover 不应再切",
|
|
187
|
+
);
|
|
180
188
|
});
|
|
181
189
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const { d, c } = makeCtx({ failoverEscapeAfter: 1 });
|
|
185
|
-
await d._handleFailover({ reason: "F6" }, c);
|
|
186
|
-
assert.equal(state.switches.length, 1, "escapeAfter=1 应首次即切换");
|
|
187
|
-
});
|
|
190
|
+
// F6 已移除:failoverEscapeAfter 计数方案已弃用(2026-08-25 改为时间锚点),
|
|
191
|
+
// 不再存在"连报 N 次"阈值;字段保留仅为兼容外部传参,不影响行为。
|
|
188
192
|
|
|
189
193
|
test("F7 候选集为空:触发 delay 初测且不切换", async () => {
|
|
190
194
|
resetState();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 探测模块集成测试(真实浏览器 + 真实 TikTok)
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* node scripts/test-probe-live.mjs [ports] [points]
|
|
6
|
+
* 例: node scripts/test-probe-live.mjs 4222
|
|
7
|
+
* node scripts/test-probe-live.mjs 4222 video,comment
|
|
8
|
+
*
|
|
9
|
+
* 说明:
|
|
10
|
+
* - 连接本地已启动的 CDP 端口(不 kill 业务浏览器)
|
|
11
|
+
* - 会对 TikTok 真实发起访问(探测本身是访问行为)
|
|
12
|
+
* - 断言输出结构 + 至少能完成调度;业务可用性结果只打印不判成败
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { runProbe, POINT_NAMES } from "../src/lib/probe/index.js";
|
|
16
|
+
|
|
17
|
+
const ports = (process.argv[2] || "4222")
|
|
18
|
+
.split(",")
|
|
19
|
+
.map((s) => Number(s.trim()))
|
|
20
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
21
|
+
const points = process.argv[3]
|
|
22
|
+
? process.argv[3].split(",").map((s) => s.trim()).filter(Boolean)
|
|
23
|
+
: POINT_NAMES;
|
|
24
|
+
|
|
25
|
+
console.error(`[live-test] ports=${ports.join(",")} points=${points.join(",")}`);
|
|
26
|
+
console.error(`[live-test] 说明:将对 TikTok 真实访问,属正常探测行为\n`);
|
|
27
|
+
|
|
28
|
+
const t0 = Date.now();
|
|
29
|
+
const result = await runProbe({ ports, points, log: console.error });
|
|
30
|
+
const totalMs = Date.now() - t0;
|
|
31
|
+
|
|
32
|
+
console.error(`\n[结果] 总耗时 ${totalMs}ms`);
|
|
33
|
+
console.log(JSON.stringify(result, null, 2));
|
|
34
|
+
|
|
35
|
+
// ── 结构断言(不判业务成败,只判调度/输出正确) ──
|
|
36
|
+
let failed = 0;
|
|
37
|
+
try {
|
|
38
|
+
assert.ok(Array.isArray(result.ports), "ports 应为数组");
|
|
39
|
+
assert.equal(result.ports.length, ports.length, "端口数应一致");
|
|
40
|
+
assert.ok(result.ports.every((p) => Number.isInteger(p.port)), "端口号应为整数");
|
|
41
|
+
assert.ok(
|
|
42
|
+
result.ports.every((p) => typeof p.loggedIn === "boolean"),
|
|
43
|
+
"loggedIn 应为布尔",
|
|
44
|
+
);
|
|
45
|
+
// 每端口包含请求的功能点键
|
|
46
|
+
for (const p of result.ports) {
|
|
47
|
+
for (const name of points) {
|
|
48
|
+
assert.ok(
|
|
49
|
+
name in p.points,
|
|
50
|
+
`端口 ${p.port} 应包含功能点 ${name}`,
|
|
51
|
+
);
|
|
52
|
+
const r = p.points[name];
|
|
53
|
+
assert.equal(typeof r.ok, "boolean", `${name}.ok 应为布尔`);
|
|
54
|
+
assert.equal(typeof r.count, "number", `${name}.count 应为数字`);
|
|
55
|
+
assert.equal(typeof r.elapsedMs, "number", `${name}.elapsedMs 应为数字`);
|
|
56
|
+
assert.ok(
|
|
57
|
+
typeof r.error === "string" || r.error === null,
|
|
58
|
+
`${name}.error 应为 string|null`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
console.error(`\n[断言] 结构校验全部通过 ✓`);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
failed = 1;
|
|
65
|
+
console.error(`\n[断言] 失败: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 汇总业务结果
|
|
69
|
+
console.error("\n[业务汇总]");
|
|
70
|
+
for (const p of result.ports) {
|
|
71
|
+
for (const name of points) {
|
|
72
|
+
const r = p.points[name];
|
|
73
|
+
if (!r) continue;
|
|
74
|
+
const tag = r.skipped
|
|
75
|
+
? `跳过(${r.skipReason})`
|
|
76
|
+
: r.ok
|
|
77
|
+
? `✓ ${r.count}`
|
|
78
|
+
: `✗ ${r.error}`;
|
|
79
|
+
console.error(` port ${p.port} ${name.padEnd(8)} ${tag} [${r.elapsedMs}ms]`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
process.exit(failed);
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 探测模块单元测试(离线,不连浏览器)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - common: classifyError / withTimeout / cleanMessage / makePointResult
|
|
6
|
+
* - targets: DEFAULT_TARGETS / resolveTargets
|
|
7
|
+
* - index: POINT_NAMES / normalizePoints / runProbe 空端口与异常端口
|
|
8
|
+
* - points: comment 无视频 skipped、follow 未登录 skipped、POINT_NAME 注册
|
|
9
|
+
*
|
|
10
|
+
* 运行: node scripts/test-probe.mjs
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import {
|
|
14
|
+
classifyError,
|
|
15
|
+
withTimeout,
|
|
16
|
+
cleanMessage,
|
|
17
|
+
makePointResult,
|
|
18
|
+
isTransientKind,
|
|
19
|
+
retryOnTransient,
|
|
20
|
+
} from "../src/lib/probe/common.js";
|
|
21
|
+
import { DEFAULT_TARGETS, resolveTargets } from "../src/lib/probe/targets.js";
|
|
22
|
+
import {
|
|
23
|
+
POINTS,
|
|
24
|
+
POINT_NAMES,
|
|
25
|
+
normalizePoints,
|
|
26
|
+
runProbe,
|
|
27
|
+
} from "../src/lib/probe/index.js";
|
|
28
|
+
import {
|
|
29
|
+
POINT_NAME as COMMENT_POINT_NAME,
|
|
30
|
+
probeComment,
|
|
31
|
+
} from "../src/lib/probe/points/comment.js";
|
|
32
|
+
import {
|
|
33
|
+
POINT_NAME as FOLLOW_POINT_NAME,
|
|
34
|
+
probeFollow,
|
|
35
|
+
} from "../src/lib/probe/points/follow.js";
|
|
36
|
+
|
|
37
|
+
let passed = 0;
|
|
38
|
+
const failures = [];
|
|
39
|
+
const allTests = [];
|
|
40
|
+
function test(name, fn) {
|
|
41
|
+
allTests.push(
|
|
42
|
+
Promise.resolve().then(() => {
|
|
43
|
+
try {
|
|
44
|
+
fn();
|
|
45
|
+
passed++;
|
|
46
|
+
console.log(`✓ ${name}`);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
failures.push({ name, error: e });
|
|
49
|
+
console.error(`✗ ${name}: ${e.message}`);
|
|
50
|
+
}
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
function testAsync(name, fn) {
|
|
55
|
+
allTests.push(
|
|
56
|
+
Promise.resolve().then(async () => {
|
|
57
|
+
try {
|
|
58
|
+
await fn();
|
|
59
|
+
passed++;
|
|
60
|
+
console.log(`✓ ${name}`);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
failures.push({ name, error: e });
|
|
63
|
+
console.error(`✗ ${name}: ${e.message}`);
|
|
64
|
+
}
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── common.classifyError ──
|
|
70
|
+
test("classifyError: 被封 → blocked", () => {
|
|
71
|
+
assert.equal(classifyError(new Error("被封: abc")).kind, "blocked");
|
|
72
|
+
});
|
|
73
|
+
test("classifyError: CDN限流 → rate_limited", () => {
|
|
74
|
+
assert.equal(classifyError(new Error("CDN限流: abc")).kind, "rate_limited");
|
|
75
|
+
});
|
|
76
|
+
test("classifyError: Access Denied → rate_limited", () => {
|
|
77
|
+
assert.equal(classifyError("Access Denied").kind, "rate_limited");
|
|
78
|
+
});
|
|
79
|
+
test("classifyError: 验证码 → captcha", () => {
|
|
80
|
+
assert.equal(classifyError("验证码出现").kind, "captcha");
|
|
81
|
+
});
|
|
82
|
+
test("classifyError: captcha → captcha", () => {
|
|
83
|
+
assert.equal(classifyError("captcha verify").kind, "captcha");
|
|
84
|
+
});
|
|
85
|
+
test("classifyError: Timeout → timeout", () => {
|
|
86
|
+
assert.equal(classifyError(new Error("Timeout 123")).kind, "timeout");
|
|
87
|
+
});
|
|
88
|
+
test("classifyError: 超时 → timeout", () => {
|
|
89
|
+
assert.equal(classifyError("请求超时").kind, "timeout");
|
|
90
|
+
});
|
|
91
|
+
test("classifyError: login_required → login_required", () => {
|
|
92
|
+
assert.equal(classifyError("login_required").kind, "login_required");
|
|
93
|
+
});
|
|
94
|
+
test("classifyError: 未登录 → login_required", () => {
|
|
95
|
+
assert.equal(classifyError("请先登录").kind, "login_required");
|
|
96
|
+
});
|
|
97
|
+
test("classifyError: region → region_blocked", () => {
|
|
98
|
+
assert.equal(
|
|
99
|
+
classifyError("not available in your region").kind,
|
|
100
|
+
"region_blocked",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
test("classifyError: 普通错误 → error", () => {
|
|
104
|
+
assert.equal(classifyError("boom something").kind, "error");
|
|
105
|
+
});
|
|
106
|
+
test("classifyError: 空 → error + 未知错误", () => {
|
|
107
|
+
const c = classifyError(null);
|
|
108
|
+
assert.equal(c.kind, "error");
|
|
109
|
+
assert.equal(c.message, "未知错误");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── common.cleanMessage ──
|
|
113
|
+
test("cleanMessage: 去 ANSI + 首行 + 限长", () => {
|
|
114
|
+
const out = cleanMessage("\x1b[31mline1\x1b[0m\nline2\n".repeat(50));
|
|
115
|
+
assert.ok(!out.includes("\x1b"));
|
|
116
|
+
assert.ok(!out.includes("\n"));
|
|
117
|
+
assert.ok(out.length <= 300);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── common.withTimeout ──
|
|
121
|
+
testAsync("withTimeout: 正常完成返回结果", async () => {
|
|
122
|
+
const r = await withTimeout(Promise.resolve("ok"), 2000, "t");
|
|
123
|
+
assert.equal(r, "ok");
|
|
124
|
+
});
|
|
125
|
+
testAsync("withTimeout: 超时 reject", async () => {
|
|
126
|
+
let timedOut = false;
|
|
127
|
+
try {
|
|
128
|
+
await withTimeout(new Promise(() => {}), 30, "超时测试");
|
|
129
|
+
} catch (e) {
|
|
130
|
+
timedOut = true;
|
|
131
|
+
assert.ok(e.message.includes("超时测试"));
|
|
132
|
+
}
|
|
133
|
+
assert.ok(timedOut, "应超时 reject");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── common.makePointResult ──
|
|
137
|
+
test("makePointResult: 默认空 → kind=empty", () => {
|
|
138
|
+
const r = makePointResult({ ok: false });
|
|
139
|
+
assert.equal(r.kind, "empty");
|
|
140
|
+
assert.equal(r.count, 0);
|
|
141
|
+
assert.equal(r.skipped, false);
|
|
142
|
+
});
|
|
143
|
+
test("makePointResult: ok → kind=null + extra 合并", () => {
|
|
144
|
+
const r = makePointResult({ ok: true, count: 3, extra: { a: 1 } });
|
|
145
|
+
assert.equal(r.kind, null);
|
|
146
|
+
assert.equal(r.a, 1);
|
|
147
|
+
});
|
|
148
|
+
test("makePointResult: skipped + 显式 kind", () => {
|
|
149
|
+
const r = makePointResult({
|
|
150
|
+
ok: false,
|
|
151
|
+
skipped: true,
|
|
152
|
+
skipReason: "no_video",
|
|
153
|
+
kind: "login_required",
|
|
154
|
+
});
|
|
155
|
+
assert.equal(r.kind, "login_required");
|
|
156
|
+
assert.equal(r.skipReason, "no_video");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ── common.isTransientKind / retryOnTransient ──
|
|
160
|
+
test("isTransientKind: 瞬时型为 true", () => {
|
|
161
|
+
for (const k of ["timeout", "empty", "error", "waf_challenge"]) {
|
|
162
|
+
assert.equal(isTransientKind(k), true, `${k} 应为瞬时`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
test("isTransientKind: 确定型与空为 false", () => {
|
|
166
|
+
for (const k of [
|
|
167
|
+
"blocked",
|
|
168
|
+
"rate_limited",
|
|
169
|
+
"captcha",
|
|
170
|
+
"login_required",
|
|
171
|
+
"region_blocked",
|
|
172
|
+
null,
|
|
173
|
+
undefined,
|
|
174
|
+
"",
|
|
175
|
+
]) {
|
|
176
|
+
assert.equal(isTransientKind(k), false, `${k} 不应为瞬时`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
testAsync("retryOnTransient: 成功即返回(只调 1 次)", async () => {
|
|
180
|
+
let calls = 0;
|
|
181
|
+
const r = await retryOnTransient(async () => {
|
|
182
|
+
calls++;
|
|
183
|
+
return makePointResult({ ok: true, count: 3 });
|
|
184
|
+
}, { retries: 2, retryDelayMs: 0 });
|
|
185
|
+
assert.equal(r.ok, true);
|
|
186
|
+
assert.equal(calls, 1);
|
|
187
|
+
});
|
|
188
|
+
testAsync("retryOnTransient: 确定性失败不重试(只调 1 次)", async () => {
|
|
189
|
+
let calls = 0;
|
|
190
|
+
const r = await retryOnTransient(async () => {
|
|
191
|
+
calls++;
|
|
192
|
+
return makePointResult({ ok: false, kind: "blocked" });
|
|
193
|
+
}, { retries: 2, retryDelayMs: 0 });
|
|
194
|
+
assert.equal(r.ok, false);
|
|
195
|
+
assert.equal(r.kind, "blocked");
|
|
196
|
+
assert.equal(calls, 1);
|
|
197
|
+
});
|
|
198
|
+
testAsync("retryOnTransient: 瞬时失败后第 2 次成功", async () => {
|
|
199
|
+
let calls = 0;
|
|
200
|
+
const r = await retryOnTransient(async () => {
|
|
201
|
+
calls++;
|
|
202
|
+
if (calls === 1) return makePointResult({ ok: false, kind: "waf_challenge" });
|
|
203
|
+
return makePointResult({ ok: true, count: 5 });
|
|
204
|
+
}, { retries: 2, retryDelayMs: 0 });
|
|
205
|
+
assert.equal(r.ok, true);
|
|
206
|
+
assert.equal(calls, 2);
|
|
207
|
+
});
|
|
208
|
+
testAsync("retryOnTransient: 瞬时失败耗尽返回最后结果", async () => {
|
|
209
|
+
let calls = 0;
|
|
210
|
+
const r = await retryOnTransient(async () => {
|
|
211
|
+
calls++;
|
|
212
|
+
return makePointResult({ ok: false, kind: "timeout" });
|
|
213
|
+
}, { retries: 2, retryDelayMs: 0 });
|
|
214
|
+
assert.equal(r.ok, false);
|
|
215
|
+
assert.equal(r.kind, "timeout");
|
|
216
|
+
assert.equal(calls, 3); // 1 次初始 + 2 次重试
|
|
217
|
+
});
|
|
218
|
+
testAsync("retryOnTransient: 重试间有间隔延迟", async () => {
|
|
219
|
+
const t0 = Date.now();
|
|
220
|
+
let calls = 0;
|
|
221
|
+
await retryOnTransient(
|
|
222
|
+
async () => {
|
|
223
|
+
calls++;
|
|
224
|
+
return makePointResult({ ok: false, kind: "empty" });
|
|
225
|
+
},
|
|
226
|
+
{ retries: 1, retryDelayMs: 150 },
|
|
227
|
+
);
|
|
228
|
+
assert.equal(calls, 2);
|
|
229
|
+
assert.ok(Date.now() - t0 >= 150, "应有重试间隔");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── targets ──
|
|
233
|
+
test("targets: 默认值", () => {
|
|
234
|
+
assert.equal(DEFAULT_TARGETS.author, "tiktok");
|
|
235
|
+
assert.equal(DEFAULT_TARGETS.searchQuery, "dog");
|
|
236
|
+
assert.equal(DEFAULT_TARGETS.video, null);
|
|
237
|
+
});
|
|
238
|
+
test("targets: resolveTargets 覆盖 + 保留默认", () => {
|
|
239
|
+
const t = resolveTargets({ author: "foo", video: "x" });
|
|
240
|
+
assert.equal(t.author, "foo");
|
|
241
|
+
assert.equal(t.searchQuery, "dog");
|
|
242
|
+
assert.equal(t.video, "x");
|
|
243
|
+
});
|
|
244
|
+
test("targets: resolveTargets 空覆盖 → 全默认", () => {
|
|
245
|
+
const t = resolveTargets();
|
|
246
|
+
assert.deepEqual(t, DEFAULT_TARGETS);
|
|
247
|
+
});
|
|
248
|
+
test("targets: resolveTargets null → 全默认", () => {
|
|
249
|
+
const t = resolveTargets(null);
|
|
250
|
+
assert.deepEqual(t, DEFAULT_TARGETS);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ── index.normalizePoints ──
|
|
254
|
+
test("normalizePoints: 空 → 全部功能点", () => {
|
|
255
|
+
assert.deepEqual(normalizePoints(), [...POINT_NAMES]);
|
|
256
|
+
assert.deepEqual(normalizePoints([]), [...POINT_NAMES]);
|
|
257
|
+
assert.deepEqual(normalizePoints(null), [...POINT_NAMES]);
|
|
258
|
+
});
|
|
259
|
+
test("normalizePoints: 全非法 → 全部功能点", () => {
|
|
260
|
+
assert.deepEqual(normalizePoints(["bad", "xxx"]), [...POINT_NAMES]);
|
|
261
|
+
});
|
|
262
|
+
test("normalizePoints: 过滤非法 + 去重 + 保序", () => {
|
|
263
|
+
assert.deepEqual(normalizePoints(["video", "video", "bad", "comment"]), [
|
|
264
|
+
"video",
|
|
265
|
+
"comment",
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
test("POINTS 注册表完整", () => {
|
|
269
|
+
assert.equal(POINT_NAMES.length, 5);
|
|
270
|
+
for (const n of POINT_NAMES) assert.equal(typeof POINTS[n], "function");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ── points 注册与离线可判定分支 ──
|
|
274
|
+
test("points: 各模块 POINT_NAME 常量正确", () => {
|
|
275
|
+
assert.equal(COMMENT_POINT_NAME, "comment");
|
|
276
|
+
assert.equal(FOLLOW_POINT_NAME, "follow");
|
|
277
|
+
});
|
|
278
|
+
testAsync("probeComment: 无视频 → skipped(no_video)", async () => {
|
|
279
|
+
const r = await probeComment(null, {
|
|
280
|
+
target: { video: null },
|
|
281
|
+
videoHref: null,
|
|
282
|
+
});
|
|
283
|
+
assert.equal(r.skipped, true);
|
|
284
|
+
assert.equal(r.skipReason, "no_video");
|
|
285
|
+
assert.equal(r.ok, false);
|
|
286
|
+
});
|
|
287
|
+
testAsync("probeFollow: 未登录 → skipped(not_logged_in)", async () => {
|
|
288
|
+
const r = await probeFollow(null, { target: {}, loggedIn: false });
|
|
289
|
+
assert.equal(r.skipped, true);
|
|
290
|
+
assert.equal(r.skipReason, "not_logged_in");
|
|
291
|
+
assert.equal(r.kind, "login_required");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── runProbe 空端口 / 非法端口 ──
|
|
295
|
+
testAsync("runProbe: 空端口 → 空结果结构", async () => {
|
|
296
|
+
const r = await runProbe({ ports: [], log: () => {} });
|
|
297
|
+
assert.deepEqual(r.ports, []);
|
|
298
|
+
assert.ok(Array.isArray(r.points));
|
|
299
|
+
assert.equal(typeof r.checkedAt, "string");
|
|
300
|
+
assert.equal(typeof r.durationMs, "number");
|
|
301
|
+
});
|
|
302
|
+
testAsync("runProbe: 非法端口被过滤", async () => {
|
|
303
|
+
const r = await runProbe({ ports: [0, -1, NaN, "abc"], log: () => {} });
|
|
304
|
+
assert.deepEqual(r.ports, []);
|
|
305
|
+
});
|
|
306
|
+
// 注:真实端口连接(存在/不存在端口)属集成测试范畴(会启动/连接 Edge),见 scripts/test-probe-live.mjs
|
|
307
|
+
|
|
308
|
+
// ── 汇总(等全部测试完成) ──
|
|
309
|
+
await Promise.all(allTests);
|
|
310
|
+
console.log(`\n${passed} 通过, ${failures.length} 失败`);
|
|
311
|
+
if (failures.length > 0) {
|
|
312
|
+
for (const f of failures)
|
|
313
|
+
console.error(` FAIL: ${f.name}\n ${f.error.stack}`);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
package/src/clash/checker.js
CHANGED
|
@@ -26,7 +26,7 @@ const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
|
|
|
26
26
|
let _healthyFile = HEALTHY_FILE;
|
|
27
27
|
|
|
28
28
|
export const CONFIG = {
|
|
29
|
-
testUrl: "https://www.tiktok.com
|
|
29
|
+
testUrl: "https://www.tiktok.com/@tiktok",
|
|
30
30
|
proxyUrl: "http://127.0.0.1:7897",
|
|
31
31
|
apiPort: 9097,
|
|
32
32
|
groupName: "GLOBAL",
|
package/src/clash/daemon.js
CHANGED
|
@@ -20,9 +20,10 @@ function fmtCountries(v) {
|
|
|
20
20
|
return list.length ? list.join(",") : "无";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// failover
|
|
23
|
+
// failover 冷却期(默认 10 分钟,与候选集 delay 初测周期对齐;可经 opts.failoverCooldownMs 覆盖)
|
|
24
|
+
// 锚点 = 最近一次实际切换节点的时间(_lastSwitchAt):冷却期内探针健康不切换
|
|
24
25
|
const FAILOVER_COOLDOWN_MS = 600_000;
|
|
25
|
-
//
|
|
26
|
+
// 已弃用(2026-08-25 改为时间锚点方案):旧版"窗口内连报 N 次"计数,字段保留仅为兼容外部传参
|
|
26
27
|
const FAILOVER_ESCAPE_AFTER = 3;
|
|
27
28
|
|
|
28
29
|
// ══════════════════════════════════════════════
|
|
@@ -97,6 +98,11 @@ export class ClashDaemon {
|
|
|
97
98
|
c._switchHistory.set(nodeName, Date.now());
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/** 记录最近一次实际切换节点的时间(failover/巡检/手动切换共用锚点,冷却期判断依据) */
|
|
102
|
+
_markSwitchTime(c) {
|
|
103
|
+
if (c) c._lastSwitchAt = Date.now();
|
|
104
|
+
}
|
|
105
|
+
|
|
100
106
|
/**
|
|
101
107
|
* 壳页判定:TikTok SSR 降级响应(HTTP 200 + 无跳转 + 无 error + 无 title)。
|
|
102
108
|
* 壳页 = 加载页(data-downgrade 骨架屏),链路正常、浏览器会自行渲染,不算节点故障。
|
|
@@ -428,6 +434,7 @@ export class ClashDaemon {
|
|
|
428
434
|
// 全部候选不可用,恢复原节点
|
|
429
435
|
await checker.switchToNode(node).catch(() => {});
|
|
430
436
|
this._markSwitch(c, node);
|
|
437
|
+
this._markSwitchTime(c);
|
|
431
438
|
await api.closeAllConnections().catch(() => {});
|
|
432
439
|
this._log(
|
|
433
440
|
`✗ [${c.proxyPort}] 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`,
|
|
@@ -447,6 +454,7 @@ export class ClashDaemon {
|
|
|
447
454
|
// 切换后断开所有旧连接:mihomo 不主动掐旧连接,浏览器 keep-alive 会继续走旧节点
|
|
448
455
|
// 导致"时好时坏"(实测旧连接可存活 8+ 分钟),必须强制重建才即时生效
|
|
449
456
|
await api.closeAllConnections().catch(() => {});
|
|
457
|
+
this._markSwitchTime(c);
|
|
450
458
|
this._writeHealth(c, {
|
|
451
459
|
node: target,
|
|
452
460
|
healthy: h2.healthy,
|
|
@@ -585,6 +593,7 @@ export class ClashDaemon {
|
|
|
585
593
|
});
|
|
586
594
|
}
|
|
587
595
|
this._markSwitch(c, cmd.node);
|
|
596
|
+
this._markSwitchTime(c);
|
|
588
597
|
const h = checker.checkNodeHealth();
|
|
589
598
|
// 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
|
|
590
599
|
await api.closeAllConnections().catch(() => {});
|
|
@@ -609,40 +618,36 @@ export class ClashDaemon {
|
|
|
609
618
|
* 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
|
|
610
619
|
* 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
|
|
611
620
|
*
|
|
612
|
-
* failover
|
|
621
|
+
* failover 处理(2026-08-25,改为时间锚点方案):
|
|
613
622
|
* 1. 每次 failover 先检查当前节点(消除冷却期外盲切);
|
|
614
|
-
* 2. 探针不健康 →
|
|
615
|
-
* 3.
|
|
616
|
-
*
|
|
617
|
-
* TikTok
|
|
618
|
-
*
|
|
619
|
-
*
|
|
623
|
+
* 2. 探针不健康 → 立即切换(有依据才切,无视冷却);
|
|
624
|
+
* 3. 探针健康(含壳页)→ 距上次实际切换(含巡检/手动/failover,见
|
|
625
|
+
* _lastSwitchAt)< failoverCooldownMs(默认 10 分钟)→ 仅检查不切换;
|
|
626
|
+
* 距上次切换 >= 冷却期 → 视为业务级封禁(curl 探针感知不到 TikTok 业务
|
|
627
|
+
* 风控)→ 按轮换逻辑切一次。
|
|
628
|
+
* 每次实际切换(成功采用候选或恢复原节点)后更新 _lastSwitchAt 锚点,保证
|
|
629
|
+
* 10 分钟内最多切 1 次,给新节点干净观察期。
|
|
620
630
|
* 按控制端独立记录(多控制端互不影响);对外接口(命令动作/IPC/health)不变。
|
|
621
631
|
*/
|
|
622
632
|
async _handleFailover(cmd, c) {
|
|
623
633
|
try {
|
|
624
634
|
const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
|
|
625
635
|
const now = Date.now();
|
|
626
|
-
const w =
|
|
627
|
-
c._failoverWindow || (c._failoverWindow = { startAt: 0, count: 0 });
|
|
628
|
-
if (now - w.startAt >= this.failoverCooldownMs) {
|
|
629
|
-
w.startAt = now;
|
|
630
|
-
w.count = 0;
|
|
631
|
-
}
|
|
632
|
-
w.count += 1;
|
|
633
636
|
|
|
634
637
|
// 第 1 级:先检查当前节点(壳页 = 链路正常,按健康对待,与巡检口径一致)
|
|
635
638
|
const h = checker.checkNodeHealth();
|
|
636
639
|
const probeOk = !!h && (h.healthy || this._isShellPage(h));
|
|
637
|
-
if (probeOk && w.count < this.failoverEscapeAfter) {
|
|
638
|
-
this._log(
|
|
639
|
-
`🔄 [${c.proxyPort}] 探针健康,窗口内第 ${w.count}/${this.failoverEscapeAfter} 次 failover 仅检查不切换 [${reason}]`,
|
|
640
|
-
);
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
640
|
if (probeOk) {
|
|
641
|
+
// 探针健康:受冷却期约束(锚点 = 最近一次实际切换节点的时间 _lastSwitchAt)
|
|
642
|
+
const gap = now - (c._lastSwitchAt || 0);
|
|
643
|
+
if (gap < this.failoverCooldownMs) {
|
|
644
|
+
this._log(
|
|
645
|
+
`🔄 [${c.proxyPort}] 探针健康,距上次切换 ${Math.round(gap / 1000)}s(<${this.failoverCooldownMs / 60000}min 冷却),仅检查不切换 [${reason}]`,
|
|
646
|
+
);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
644
649
|
this._log(
|
|
645
|
-
`🔄 [${c.proxyPort}]
|
|
650
|
+
`🔄 [${c.proxyPort}] 探针健康但业务持续报错,距上次切换已过冷却期,按业务级封禁轮换 [${reason}]`,
|
|
646
651
|
);
|
|
647
652
|
} else {
|
|
648
653
|
this._log(
|
|
@@ -658,11 +663,15 @@ export class ClashDaemon {
|
|
|
658
663
|
);
|
|
659
664
|
const { nodes } = checker.loadHealthyList();
|
|
660
665
|
if (!nodes.length) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
+
const gap = now - (c._lastProbeAt || 0);
|
|
667
|
+
if (gap >= this.benchmarkMs) {
|
|
668
|
+
this._log("🔄 候选集为空,触发 delay 初测");
|
|
669
|
+
await this._runDelayProbeController(c);
|
|
670
|
+
} else {
|
|
671
|
+
this._log(
|
|
672
|
+
`🔄 候选集为空(初测冷却中 ${Math.round((this.benchmarkMs - gap) / 1000)}s)`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
666
675
|
return;
|
|
667
676
|
}
|
|
668
677
|
// 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
|
|
@@ -685,9 +694,8 @@ export class ClashDaemon {
|
|
|
685
694
|
}
|
|
686
695
|
this._log(` ↺ ${cand} 内容验证未通过,下一个`);
|
|
687
696
|
}
|
|
688
|
-
// 本次 failover
|
|
689
|
-
|
|
690
|
-
w.count = 0;
|
|
697
|
+
// 本次 failover 已尽力处置(成功切换或恢复原节点):更新锚点,给新节点干净观察期
|
|
698
|
+
this._markSwitchTime(c);
|
|
691
699
|
|
|
692
700
|
if (!target) {
|
|
693
701
|
await checker.switchToNode(node).catch(() => {});
|
package/src/watchdog/agent.js
CHANGED
|
@@ -251,6 +251,11 @@ export class WatchdogAgent {
|
|
|
251
251
|
this._graceEnded = false; // 宽限期是否已结束
|
|
252
252
|
// 业务日志错误统计 → 触发节点切换
|
|
253
253
|
this._errorStates = new Map(); // cmdId -> {consecutive, firstErrorAt, lastErrorAt}
|
|
254
|
+
// 进程(重新)启动 → 重置该命令的错误计数(新进程实例从干净观察窗口开始,
|
|
255
|
+
// 避免把上一个进程实例的错误延续到新实例而误触发节点切换)
|
|
256
|
+
this.processManager.on("process-started", ({ cmdId }) => {
|
|
257
|
+
this._errorStates.delete(cmdId);
|
|
258
|
+
});
|
|
254
259
|
this._errorTriggerTimer = null;
|
|
255
260
|
this._errorTriggerEnabled = false; // 默认不启用(后端下发开启才生效)
|
|
256
261
|
this._errorTriggerCount = ERROR_TRIGGER_COUNT;
|
|
@@ -377,6 +377,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
377
377
|
logs: [],
|
|
378
378
|
lastLogTime: Date.now(), // 初始化为启动时间,用于假死检测
|
|
379
379
|
});
|
|
380
|
+
// 通知上层:进程(重新)启动,可重置该命令的跨实例状态(如错误计数)
|
|
381
|
+
this.emit("process-started", { cmdId: id, cmdDef });
|
|
380
382
|
}
|
|
381
383
|
|
|
382
384
|
/**
|
|
@@ -420,7 +422,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
420
422
|
} catch {}
|
|
421
423
|
}, 2000);
|
|
422
424
|
}
|
|
423
|
-
|
|
424
425
|
} catch (e) {
|
|
425
426
|
console.error(`[watchdog] 停止命令 ${cmdId} 失败: ${e.message}`);
|
|
426
427
|
}
|
|
@@ -487,7 +488,9 @@ export class ProcessManager extends EventEmitter {
|
|
|
487
488
|
const result = [];
|
|
488
489
|
for (const [cmdId, info] of this.processes) {
|
|
489
490
|
if (info.stopped || !info.logs || info.logs.length === 0) continue;
|
|
490
|
-
// 日志可能被 LLM
|
|
491
|
+
// 日志可能被 LLM 分析清空过(_errSeen 未同步):长度小于偏移说明
|
|
492
|
+
// 是清空后重建的日志,重置偏移,确保新日志从头扫描不遗漏
|
|
493
|
+
if ((info._errSeen || 0) > info.logs.length) info._errSeen = 0;
|
|
491
494
|
const seen = Math.min(info._errSeen || 0, info.logs.length);
|
|
492
495
|
const newLines = info.logs.slice(seen);
|
|
493
496
|
info._errSeen = info.logs.length;
|
|
@@ -515,6 +518,9 @@ export class ProcessManager extends EventEmitter {
|
|
|
515
518
|
});
|
|
516
519
|
// 清空已读取的日志,避免重复分析
|
|
517
520
|
info.logs = [];
|
|
521
|
+
// 同步重置增量扫描偏移:否则 scanErrorLogs 会把清空后新日志
|
|
522
|
+
// 误判为"已扫描"(logs.length < _errSeen 时 slice 到空),造成错误漏统计
|
|
523
|
+
info._errSeen = 0;
|
|
518
524
|
}
|
|
519
525
|
return result;
|
|
520
526
|
}
|