tt-help-cli-ycl 1.4.169 → 1.4.172
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-subscription-allocator.mjs +220 -0
- package/scripts/test-subscription-auto-alloc-ui.mjs +208 -0
- package/scripts/test-subscription-auto-alloc.mjs +259 -0
- package/scripts/test-subscription-center-e2e.mjs +134 -0
- package/scripts/test-subscription-center-smoke.mjs +154 -0
- package/scripts/test-subscription-center-ui.mjs +111 -0
- package/scripts/vnc-login.sh +68 -0
- package/src/cli/info.js +6 -1
- package/src/lib/browser/cdp.js +7 -1
- package/src/watch/public/app.js +713 -39
- package/src/watch/public/index.html +74 -3
- package/src/watch/subscription-allocator.js +255 -0
- package/src/watch/subscription-center.js +590 -0
- package/src/watch/watchdog-server.js +210 -0
- package/src/watchdog/agent.js +104 -10
- package/scripts/test-api-interceptor-429.mjs +0 -122
- package/scripts/test-api-interceptor-captcha.mjs +0 -176
- package/scripts/test-claim-fix.mjs +0 -216
- package/scripts/test-clash-discover.mjs +0 -126
- package/scripts/test-clash-hysteresis.mjs +0 -131
- package/scripts/test-clash-multichannel.mjs +0 -120
- package/scripts/test-client-error-monitor.mjs +0 -261
- package/scripts/test-country-fix.mjs +0 -341
- package/scripts/test-daemon-failover.mjs +0 -301
- package/scripts/test-daemon-switch-history.mjs +0 -141
- package/scripts/test-daemon-whitelist.mjs +0 -308
- package/scripts/test-explore-captcha-wait.mjs +0 -170
- package/scripts/test-photo-post.mjs +0 -156
- package/scripts/test-precheck-perf.mjs +0 -270
- package/scripts/test-precise-index.mjs +0 -114
- package/scripts/test-probe-live.mjs +0 -83
- package/scripts/test-probe.mjs +0 -315
- package/scripts/test-product-fill-cdp.mjs +0 -336
- package/scripts/test-product-fill-db.mjs +0 -216
- package/scripts/test-product-fill-http.mjs +0 -117
- package/scripts/test-proxy-switch-restart.mjs +0 -198
- package/scripts/test-refill-order.mjs +0 -218
- package/scripts/test-sample-all-fail.mjs +0 -142
- package/scripts/test-seller-any-country.mjs +0 -167
- package/scripts/test-stats-smoke.mjs +0 -43
- package/scripts/test-upgrade-spawn.mjs +0 -179
- package/scripts/test-webserver-error-classify.mjs +0 -409
- package/scripts/test-wmi-free-upgrade.mjs +0 -98
- package/src/cli/test-real-attach.js +0 -0
- package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
|
@@ -1,341 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* 回归测试:节点国家配置收敛修复(2026-08-12)
|
|
4
|
-
*
|
|
5
|
-
* 背景:daemon 启动硬编码 --country US,且服务端幂等基准是"是否下发过",
|
|
6
|
-
* 导致 daemon 重启(崩溃自愈/升级/机器重启)回退 US 后服务端不补发 → 一直用美国节点。
|
|
7
|
-
*
|
|
8
|
-
* 运行方式(务必用隔离 TMPDIR,避免污染真实 clash-*.json / 服务端配置):
|
|
9
|
-
* TMPDIR=$(mktemp -d) node scripts/test-country-fix.mjs
|
|
10
|
-
*
|
|
11
|
-
* 覆盖:
|
|
12
|
-
* A. 服务端幂等基准 = 客户端实际生效国家(核心修复)
|
|
13
|
-
* - daemon 重启回退 US 后,服务端补发 JP
|
|
14
|
-
* - daemon 已生效 JP 时,幂等不重复下发
|
|
15
|
-
* - daemon 未上报时,下发
|
|
16
|
-
* - 多国配置且已生效时,幂等不重复下发
|
|
17
|
-
* B. daemon 逗号分隔多国 --country 参数解析
|
|
18
|
-
* C. checker normalizeCountries / filterNodesByCountry 回归
|
|
19
|
-
* + 内置策略/机场信息节点过滤(isUsableProxyNode,2026-09-03 剩余流量 GB 修复)
|
|
20
|
-
* D. 客户端 daemon 启动兜底:读 clash-health.json 上次生效国家 → join(",")
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { mkdtempSync, writeFileSync } from "fs";
|
|
24
|
-
import { tmpdir } from "os";
|
|
25
|
-
import { join } from "path";
|
|
26
|
-
|
|
27
|
-
let passed = 0;
|
|
28
|
-
let failed = 0;
|
|
29
|
-
function ok(cond, name, extra = "") {
|
|
30
|
-
if (cond) {
|
|
31
|
-
passed++;
|
|
32
|
-
console.log(` ✅ ${name}`);
|
|
33
|
-
} else {
|
|
34
|
-
failed++;
|
|
35
|
-
console.log(` ❌ ${name} ${extra}`);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ── 隔离 HOME:避免读写真实服务端配置 ~/.tt-help-watchdog.json ──
|
|
40
|
-
const fakeHome = mkdtempSync(join(tmpdir(), "tt-wd-home-"));
|
|
41
|
-
process.env.HOME = fakeHome;
|
|
42
|
-
process.env.USERPROFILE = fakeHome;
|
|
43
|
-
|
|
44
|
-
function fakeRes() {
|
|
45
|
-
return {
|
|
46
|
-
status: 0,
|
|
47
|
-
body: null,
|
|
48
|
-
writeHead(code) {
|
|
49
|
-
this.status = code;
|
|
50
|
-
},
|
|
51
|
-
end(b) {
|
|
52
|
-
this.body = b;
|
|
53
|
-
},
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
async function wdCall(mod, path, method, params, body) {
|
|
57
|
-
const res = fakeRes();
|
|
58
|
-
await mod.tryHandleWatchdogRoute(path, method, params, body, res);
|
|
59
|
-
return { status: res.status, data: res.body ? JSON.parse(res.body) : null };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
console.log("\n═══ A. 服务端幂等基准 = 客户端实际生效国家 ═══");
|
|
63
|
-
const wd = await import("../src/watch/watchdog-server.js");
|
|
64
|
-
|
|
65
|
-
// A1 配置 T1 → JP
|
|
66
|
-
let r = await wdCall(
|
|
67
|
-
wd,
|
|
68
|
-
"/api/watchdog/set-country-filter",
|
|
69
|
-
"POST",
|
|
70
|
-
{},
|
|
71
|
-
{
|
|
72
|
-
userId: "T1",
|
|
73
|
-
countries: ["JP"],
|
|
74
|
-
},
|
|
75
|
-
);
|
|
76
|
-
ok(
|
|
77
|
-
r.status === 200 && JSON.stringify(r.data.countries) === '["JP"]',
|
|
78
|
-
"A1 配置 T1 → JP",
|
|
79
|
-
JSON.stringify(r.data),
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
// A2 心跳:daemon 回退 US(重启后)→ 服务端应补发 JP(核心修复点,旧逻辑因 lastSent 幂等不会下发)
|
|
83
|
-
r = await wdCall(
|
|
84
|
-
wd,
|
|
85
|
-
"/api/watchdog/heartbeat",
|
|
86
|
-
"POST",
|
|
87
|
-
{ clientId: "c1" },
|
|
88
|
-
{
|
|
89
|
-
config: { userId: "T1" },
|
|
90
|
-
clashHealth: { meta: { countryFilter: ["US"] } },
|
|
91
|
-
},
|
|
92
|
-
);
|
|
93
|
-
let cmds = (r.data && r.data.clashCommands) || [];
|
|
94
|
-
ok(
|
|
95
|
-
cmds.some(
|
|
96
|
-
(c) =>
|
|
97
|
-
c.action === "setCountryFilter" &&
|
|
98
|
-
JSON.stringify(c.countries) === '["JP"]',
|
|
99
|
-
),
|
|
100
|
-
"A2 daemon 生效 US(重启回退) → 补发 setCountryFilter JP",
|
|
101
|
-
JSON.stringify(cmds),
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
// A3 心跳:daemon 已生效 JP → 幂等不重复下发
|
|
105
|
-
r = await wdCall(
|
|
106
|
-
wd,
|
|
107
|
-
"/api/watchdog/heartbeat",
|
|
108
|
-
"POST",
|
|
109
|
-
{ clientId: "c1" },
|
|
110
|
-
{
|
|
111
|
-
config: { userId: "T1" },
|
|
112
|
-
clashHealth: { meta: { countryFilter: ["JP"] } },
|
|
113
|
-
},
|
|
114
|
-
);
|
|
115
|
-
cmds = (r.data && r.data.clashCommands) || [];
|
|
116
|
-
ok(
|
|
117
|
-
!cmds.some((c) => c.action === "setCountryFilter"),
|
|
118
|
-
"A3 daemon 生效 JP → 幂等不下发",
|
|
119
|
-
JSON.stringify(cmds),
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
// A4 心跳:daemon 未上报 clashHealth → 下发
|
|
123
|
-
r = await wdCall(
|
|
124
|
-
wd,
|
|
125
|
-
"/api/watchdog/heartbeat",
|
|
126
|
-
"POST",
|
|
127
|
-
{ clientId: "c1" },
|
|
128
|
-
{
|
|
129
|
-
config: { userId: "T1" },
|
|
130
|
-
clashHealth: null,
|
|
131
|
-
},
|
|
132
|
-
);
|
|
133
|
-
cmds = (r.data && r.data.clashCommands) || [];
|
|
134
|
-
ok(
|
|
135
|
-
cmds.some(
|
|
136
|
-
(c) =>
|
|
137
|
-
c.action === "setCountryFilter" &&
|
|
138
|
-
JSON.stringify(c.countries) === '["JP"]',
|
|
139
|
-
),
|
|
140
|
-
"A4 daemon 未上报 → 下发 setCountryFilter JP",
|
|
141
|
-
JSON.stringify(cmds),
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
// A5 多国配置 US+JP 且 daemon 已生效 US+JP → 幂等不下发
|
|
145
|
-
// 注:配置动作本身会立即入队一次下发(配置后本该生效一次),首轮心跳取走后再验证幂等
|
|
146
|
-
await wdCall(
|
|
147
|
-
wd,
|
|
148
|
-
"/api/watchdog/set-country-filter",
|
|
149
|
-
"POST",
|
|
150
|
-
{},
|
|
151
|
-
{
|
|
152
|
-
userId: "T2",
|
|
153
|
-
countries: ["US", "JP"],
|
|
154
|
-
},
|
|
155
|
-
);
|
|
156
|
-
r = await wdCall(
|
|
157
|
-
wd,
|
|
158
|
-
"/api/watchdog/heartbeat",
|
|
159
|
-
"POST",
|
|
160
|
-
{ clientId: "c2" },
|
|
161
|
-
{
|
|
162
|
-
config: { userId: "T2" },
|
|
163
|
-
clashHealth: { meta: { countryFilter: ["US", "JP"] } },
|
|
164
|
-
},
|
|
165
|
-
);
|
|
166
|
-
cmds = (r.data && r.data.clashCommands) || [];
|
|
167
|
-
ok(
|
|
168
|
-
cmds.some(
|
|
169
|
-
(c) =>
|
|
170
|
-
c.action === "setCountryFilter" &&
|
|
171
|
-
JSON.stringify(c.countries) === '["US","JP"]',
|
|
172
|
-
),
|
|
173
|
-
"A5a 配置后首次心跳取走命令",
|
|
174
|
-
JSON.stringify(cmds),
|
|
175
|
-
);
|
|
176
|
-
r = await wdCall(
|
|
177
|
-
wd,
|
|
178
|
-
"/api/watchdog/heartbeat",
|
|
179
|
-
"POST",
|
|
180
|
-
{ clientId: "c2" },
|
|
181
|
-
{
|
|
182
|
-
config: { userId: "T2" },
|
|
183
|
-
clashHealth: { meta: { countryFilter: ["US", "JP"] } },
|
|
184
|
-
},
|
|
185
|
-
);
|
|
186
|
-
cmds = (r.data && r.data.clashCommands) || [];
|
|
187
|
-
ok(
|
|
188
|
-
!cmds.some((c) => c.action === "setCountryFilter"),
|
|
189
|
-
"A5 多国且已生效 → 幂等不下发",
|
|
190
|
-
JSON.stringify(cmds),
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
console.log("\n═══ B. daemon 逗号分隔多国 --country 解析 ═══");
|
|
194
|
-
const { ClashDaemon } = await import("../src/clash/daemon.js");
|
|
195
|
-
const checker = await import("../src/clash/checker.js");
|
|
196
|
-
|
|
197
|
-
const cases = [
|
|
198
|
-
["US,JP", ["US", "JP"], "US,JP 逗号分隔 → 多国并集"],
|
|
199
|
-
["JP", ["JP"], "JP 单国"],
|
|
200
|
-
["us, jp", ["US", "JP"], "小写+空格归一"],
|
|
201
|
-
[undefined, null, "未传 country → 不过滤"],
|
|
202
|
-
];
|
|
203
|
-
for (const [input, expect, name] of cases) {
|
|
204
|
-
checker.CONFIG.nodeCountryFilter = null;
|
|
205
|
-
new ClashDaemon({ country: input });
|
|
206
|
-
const got = checker.CONFIG.nodeCountryFilter;
|
|
207
|
-
ok(JSON.stringify(got) === JSON.stringify(expect), name, JSON.stringify(got));
|
|
208
|
-
}
|
|
209
|
-
checker.CONFIG.nodeCountryFilter = null;
|
|
210
|
-
|
|
211
|
-
console.log("\n═══ C. checker 国家过滤回归 ═══");
|
|
212
|
-
ok(
|
|
213
|
-
JSON.stringify(checker.normalizeCountries(["us", " JP", "US"])) ===
|
|
214
|
-
'["US","JP"]',
|
|
215
|
-
"C1 数组去重大写",
|
|
216
|
-
);
|
|
217
|
-
ok(
|
|
218
|
-
JSON.stringify(checker.normalizeCountries("jp")) === '["JP"]',
|
|
219
|
-
"C2 字符串大写",
|
|
220
|
-
);
|
|
221
|
-
ok(checker.normalizeCountries(null).length === 0, "C3 null → 空");
|
|
222
|
-
ok(checker.normalizeCountries("").length === 0, "C4 空串 → 空");
|
|
223
|
-
// 注意:checker.normalizeCountries 不拆分逗号(拆分在 daemon constructor),单字符串"US,JP"保持为一个元素
|
|
224
|
-
ok(
|
|
225
|
-
JSON.stringify(checker.normalizeCountries("US,JP")) === '["US,JP"]',
|
|
226
|
-
"C5 单字符串逗号不拆分(保持向后兼容)",
|
|
227
|
-
);
|
|
228
|
-
const nodes = ["🇺🇸美国A", "🇯🇵日本A", "🇩🇪德国A", "无旗帜节点"];
|
|
229
|
-
let fr = checker.filterNodesByCountry(nodes, ["US", "JP"]);
|
|
230
|
-
ok(
|
|
231
|
-
fr.filtered.length === 2 && fr.excluded === 2,
|
|
232
|
-
"C6 多国并集过滤(US+JP 命中 2)",
|
|
233
|
-
JSON.stringify(fr),
|
|
234
|
-
);
|
|
235
|
-
fr = checker.filterNodesByCountry(nodes, ["ZZ"]);
|
|
236
|
-
ok(
|
|
237
|
-
fr.filtered.length === 4 && fr.excluded === 0,
|
|
238
|
-
"C7 未知国家向后兼容返回全部",
|
|
239
|
-
);
|
|
240
|
-
fr = checker.filterNodesByCountry(nodes, null);
|
|
241
|
-
ok(fr.filtered.length === 4 && fr.excluded === 0, "C8 null 不过滤");
|
|
242
|
-
|
|
243
|
-
// ── C9-C14 信息节点/内置策略过滤(2026-09-03:剩余流量 GB 混入候选集修复) ──
|
|
244
|
-
fr = checker.filterNodesByCountry(["剩余流量:5319.72 GB"], ["US", "GB", "JP"]);
|
|
245
|
-
ok(
|
|
246
|
-
fr.filtered.length === 0,
|
|
247
|
-
"C9 GB 模式不再命中流量单位(剩余流量:xxx GB)",
|
|
248
|
-
JSON.stringify(fr),
|
|
249
|
-
);
|
|
250
|
-
fr = checker.filterNodesByCountry(
|
|
251
|
-
["🇬🇧英国HY1-1000M专线", "🇬🇧英国trojan1", "UK Premium"],
|
|
252
|
-
["GB"],
|
|
253
|
-
);
|
|
254
|
-
ok(
|
|
255
|
-
fr.filtered.length === 3,
|
|
256
|
-
"C10 英国节点仍正常命中(英国/UK)",
|
|
257
|
-
JSON.stringify(fr),
|
|
258
|
-
);
|
|
259
|
-
const junkNodes = [
|
|
260
|
-
"DIRECT",
|
|
261
|
-
"REJECT",
|
|
262
|
-
"REJECT-DROP",
|
|
263
|
-
"GLOBAL",
|
|
264
|
-
"剩余流量:5319.72 GB",
|
|
265
|
-
"剩余流量:1862.27 MB",
|
|
266
|
-
"官网:example.com",
|
|
267
|
-
"套餐到期:2099-12-31",
|
|
268
|
-
"1.5TB",
|
|
269
|
-
];
|
|
270
|
-
ok(
|
|
271
|
-
junkNodes.every((n) => !checker.isUsableProxyNode(n)),
|
|
272
|
-
"C11 内置策略+信息节点全部被 isUsableProxyNode 拒绝",
|
|
273
|
-
JSON.stringify(junkNodes.filter((n) => checker.isUsableProxyNode(n))),
|
|
274
|
-
);
|
|
275
|
-
const goodNodes = [
|
|
276
|
-
"🇺🇸美国HY1轻量",
|
|
277
|
-
"🇬🇧英国HY1-1000M专线",
|
|
278
|
-
"🇯🇵日本Trojan1",
|
|
279
|
-
"🇹🇼台湾trojan直连AI",
|
|
280
|
-
];
|
|
281
|
-
ok(
|
|
282
|
-
goodNodes.every((n) => checker.isUsableProxyNode(n)),
|
|
283
|
-
"C12 正常业务节点全部通过 isUsableProxyNode",
|
|
284
|
-
JSON.stringify(goodNodes.filter((n) => !checker.isUsableProxyNode(n))),
|
|
285
|
-
);
|
|
286
|
-
// 读口兜底:已持久化的脏候选文件在读取时被过滤
|
|
287
|
-
writeFileSync(
|
|
288
|
-
join(tmpdir(), "clash-healthy-nodes.json"),
|
|
289
|
-
JSON.stringify({
|
|
290
|
-
updated: new Date().toISOString(),
|
|
291
|
-
nodes: ["🇺🇸美国A", "剩余流量:5319.72 GB", "DIRECT", "🇯🇵日本A"],
|
|
292
|
-
delays: {},
|
|
293
|
-
}),
|
|
294
|
-
);
|
|
295
|
-
const hl = checker.loadHealthyList();
|
|
296
|
-
ok(
|
|
297
|
-
hl.nodes.length === 2 &&
|
|
298
|
-
!hl.nodes.some((n) => checker.isUsableProxyNode(n) === false),
|
|
299
|
-
"C13 loadHealthyList 读口剔除已持久化的脏候选",
|
|
300
|
-
JSON.stringify(hl.nodes),
|
|
301
|
-
);
|
|
302
|
-
|
|
303
|
-
console.log("\n═══ D. 客户端 daemon 启动兜底(读上次生效国家)═══");
|
|
304
|
-
const { getClashHealth } = await import("../src/watchdog/reporter.js");
|
|
305
|
-
const healthPath = join(tmpdir(), "clash-health.json");
|
|
306
|
-
|
|
307
|
-
writeFileSync(
|
|
308
|
-
healthPath,
|
|
309
|
-
JSON.stringify({
|
|
310
|
-
meta: { countryFilter: ["JP"] },
|
|
311
|
-
lastCheck: new Date().toISOString(),
|
|
312
|
-
}),
|
|
313
|
-
);
|
|
314
|
-
let prev = getClashHealth()?.meta?.countryFilter;
|
|
315
|
-
ok(
|
|
316
|
-
Array.isArray(prev) && JSON.stringify(prev) === '["JP"]',
|
|
317
|
-
"D1 读到上次生效国家 JP",
|
|
318
|
-
JSON.stringify(prev),
|
|
319
|
-
);
|
|
320
|
-
let countryArg = Array.isArray(prev) && prev.length ? prev.join(",") : "US";
|
|
321
|
-
ok(countryArg === "JP", "D2 单国 join → 'JP'", countryArg);
|
|
322
|
-
|
|
323
|
-
writeFileSync(
|
|
324
|
-
healthPath,
|
|
325
|
-
JSON.stringify({
|
|
326
|
-
meta: { countryFilter: ["US", "JP"] },
|
|
327
|
-
lastCheck: new Date().toISOString(),
|
|
328
|
-
}),
|
|
329
|
-
);
|
|
330
|
-
prev = getClashHealth()?.meta?.countryFilter;
|
|
331
|
-
countryArg = Array.isArray(prev) && prev.length ? prev.join(",") : "US";
|
|
332
|
-
ok(countryArg === "US,JP", "D3 多国 join → 'US,JP'", countryArg);
|
|
333
|
-
|
|
334
|
-
// 无上次生效记录(clashHealth 缺失)→ 兜底 US
|
|
335
|
-
const nonePrev = null?.meta?.countryFilter;
|
|
336
|
-
const noneArg =
|
|
337
|
-
Array.isArray(nonePrev) && nonePrev.length ? nonePrev.join(",") : "US";
|
|
338
|
-
ok(noneArg === "US", "D4 无记录 → 兜底 US");
|
|
339
|
-
|
|
340
|
-
console.log(`\n结果: ${passed} 通过, ${failed} 失败`);
|
|
341
|
-
process.exit(failed ? 1 : 0);
|
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* daemon _handleFailover 单测(2026-08-25,时间锚点方案,无需 Clash 运行)
|
|
3
|
-
*
|
|
4
|
-
* 验证:
|
|
5
|
-
* - 探针不健康 → 立即切换(无视冷却)
|
|
6
|
-
* - 探针健康(含壳页)→ 距上次切换 < 冷却期仅检查不切换
|
|
7
|
-
* - 探针健康 → 距上次切换 >= 冷却期 → 按业务级封禁轮换一次
|
|
8
|
-
* - 强切(force=true)→ 探针健康且冷却期内仍强制轮换
|
|
9
|
-
* - 切换后(含手动/巡检/failover)更新 _lastSwitchAt 锚点,冷却期内不再切
|
|
10
|
-
* - 候选集为空 → 触发 delay 初测且不切换
|
|
11
|
-
*
|
|
12
|
-
* 运行: node --test scripts/test-daemon-failover.mjs
|
|
13
|
-
* (若当前 Node 版本 module mock 仍需 flag: --experimental-test-module-mocks)
|
|
14
|
-
*/
|
|
15
|
-
import { test, mock, before } from "node:test";
|
|
16
|
-
import assert from "node:assert/strict";
|
|
17
|
-
|
|
18
|
-
// ── 可配置假依赖状态 ──
|
|
19
|
-
const state = {
|
|
20
|
-
health: {
|
|
21
|
-
healthy: true,
|
|
22
|
-
httpCode: 200,
|
|
23
|
-
timeTotal: 1.2,
|
|
24
|
-
sizeDownload: 100000,
|
|
25
|
-
numRedirects: 0,
|
|
26
|
-
title: "TikTok - Make Your Day",
|
|
27
|
-
error: null,
|
|
28
|
-
},
|
|
29
|
-
nodes: ["🇭🇰 香港01", "🇲🇾 马来02"],
|
|
30
|
-
currentNode: "🇭🇰 香港01",
|
|
31
|
-
switches: [],
|
|
32
|
-
closes: 0, // closeAllConnections(全断)调用次数
|
|
33
|
-
idleCloses: 0, // closeIdleConnections(优雅断连)调用次数
|
|
34
|
-
failIdleClose: false, // 模拟优雅断连失败
|
|
35
|
-
// 订阅切换感知(巡检开头候选集存在性校验)相关
|
|
36
|
-
proxyMap: { "🇭🇰 香港01": {}, "🇲🇾 马来02": {} }, // 当前 /proxies 中的代理名
|
|
37
|
-
pruneResult: { kept: 2, removed: 0, allGone: false },
|
|
38
|
-
pruneCalls: 0,
|
|
39
|
-
probeCalls: 0,
|
|
40
|
-
failProxies: false,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
function resetState() {
|
|
44
|
-
state.health = {
|
|
45
|
-
healthy: true,
|
|
46
|
-
httpCode: 200,
|
|
47
|
-
timeTotal: 1.2,
|
|
48
|
-
sizeDownload: 100000,
|
|
49
|
-
numRedirects: 0,
|
|
50
|
-
title: "TikTok - Make Your Day",
|
|
51
|
-
error: null,
|
|
52
|
-
};
|
|
53
|
-
state.nodes = ["🇭🇰 香港01", "🇲🇾 马来02"];
|
|
54
|
-
state.currentNode = "🇭🇰 香港01";
|
|
55
|
-
state.switches = [];
|
|
56
|
-
state.closes = 0;
|
|
57
|
-
state.idleCloses = 0;
|
|
58
|
-
state.failIdleClose = false;
|
|
59
|
-
state.proxyMap = { "🇭🇰 香港01": {}, "🇲🇾 马来02": {} };
|
|
60
|
-
state.pruneResult = { kept: 2, removed: 0, allGone: false };
|
|
61
|
-
state.pruneCalls = 0;
|
|
62
|
-
state.probeCalls = 0;
|
|
63
|
-
state.failProxies = false;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ── 注册模块 mock(必须在 import daemon.js 之前) ──
|
|
67
|
-
before(async () => {
|
|
68
|
-
mock.module("../src/clash/checker.js", {
|
|
69
|
-
namedExports: {
|
|
70
|
-
normalizeCountries: (v) => (Array.isArray(v) ? [...v] : v ? [v] : []),
|
|
71
|
-
CONFIG: {
|
|
72
|
-
groupName: "GLOBAL",
|
|
73
|
-
testUrl: "https://www.tiktok.com/explore",
|
|
74
|
-
proxyUrl: "http://127.0.0.1:7897",
|
|
75
|
-
nodeCountryFilter: null,
|
|
76
|
-
},
|
|
77
|
-
checkNodeHealth: () => ({ ...state.health }),
|
|
78
|
-
loadHealthyList: () => ({ nodes: [...state.nodes] }),
|
|
79
|
-
switchToNode: async (n) => {
|
|
80
|
-
state.switches.push(n);
|
|
81
|
-
state.currentNode = n;
|
|
82
|
-
// 模拟真实世界:故障属于原节点,切换后的新节点健康(候选内容验证通过)
|
|
83
|
-
state.health = { ...state.health, healthy: true, error: null };
|
|
84
|
-
return true;
|
|
85
|
-
},
|
|
86
|
-
applyDiscovered: () => {},
|
|
87
|
-
checkClashAlive: () => true,
|
|
88
|
-
pruneHealthyListByCurrentProxies: () => {
|
|
89
|
-
state.pruneCalls += 1;
|
|
90
|
-
return { ...state.pruneResult };
|
|
91
|
-
},
|
|
92
|
-
runDelayProbe: async () => {
|
|
93
|
-
state.probeCalls += 1;
|
|
94
|
-
return {
|
|
95
|
-
nodes: [...state.nodes],
|
|
96
|
-
total: state.nodes.length,
|
|
97
|
-
};
|
|
98
|
-
},
|
|
99
|
-
saveHealthyList: () => {},
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
mock.module("../src/clash/api.js", {
|
|
103
|
-
namedExports: {
|
|
104
|
-
configure: () => {},
|
|
105
|
-
getProxies: async () => {
|
|
106
|
-
if (state.failProxies) throw new Error("mock /proxies 不可达");
|
|
107
|
-
return { proxies: { ...state.proxyMap } };
|
|
108
|
-
},
|
|
109
|
-
getCurrentNode: async () => state.currentNode,
|
|
110
|
-
closeAllConnections: async () => {
|
|
111
|
-
state.closes += 1;
|
|
112
|
-
},
|
|
113
|
-
closeIdleConnections: async () => {
|
|
114
|
-
if (state.failIdleClose) throw new Error("mock 优雅断连失败");
|
|
115
|
-
state.idleCloses += 1;
|
|
116
|
-
return { closed: 3, active: 2 };
|
|
117
|
-
},
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
mock.module("../src/clash/ipc.js", {
|
|
121
|
-
namedExports: {
|
|
122
|
-
writeHealth: () => {},
|
|
123
|
-
listCommandChannels: () => [],
|
|
124
|
-
readCommand: () => null,
|
|
125
|
-
clearCommand: () => {},
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
mock.module("../src/clash/discover.js", {
|
|
129
|
-
namedExports: {
|
|
130
|
-
discoverAll: async () => [],
|
|
131
|
-
invalidateDiscoverCache: () => {},
|
|
132
|
-
},
|
|
133
|
-
});
|
|
134
|
-
({ ClashDaemon } = await import("../src/clash/daemon.js"));
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
let ClashDaemon = null;
|
|
138
|
-
|
|
139
|
-
function makeCtx(opts = {}) {
|
|
140
|
-
const d = new ClashDaemon({
|
|
141
|
-
failoverCooldownMs: 600000,
|
|
142
|
-
failoverEscapeAfter: 3,
|
|
143
|
-
...opts,
|
|
144
|
-
});
|
|
145
|
-
const c = { proxyPort: 7897 };
|
|
146
|
-
return { d, c };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
test("F1 探针健康:冷却期外首次 failover 即轮换,并更新锚点", async () => {
|
|
150
|
-
resetState();
|
|
151
|
-
const { d, c } = makeCtx();
|
|
152
|
-
c._lastSwitchAt = Date.now() - 600_001; // 距上次切换 > 10min 冷却
|
|
153
|
-
await d._handleFailover({ reason: "F1" }, c);
|
|
154
|
-
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "冷却期外应轮换切换");
|
|
155
|
-
assert.equal(state.idleCloses, 1, "切换后应优雅断连(空闲回收)");
|
|
156
|
-
assert.ok(
|
|
157
|
-
c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
|
|
158
|
-
"切换后应更新锚点",
|
|
159
|
-
);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("F2 探针不健康:立即切换(无视冷却)", async () => {
|
|
163
|
-
resetState();
|
|
164
|
-
const { d, c } = makeCtx();
|
|
165
|
-
c._lastSwitchAt = Date.now(); // 刚切过,但探针不健康时不看锚点
|
|
166
|
-
state.health = { ...state.health, healthy: false, error: "timeout" };
|
|
167
|
-
await d._handleFailover({ reason: "F2" }, c);
|
|
168
|
-
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "不健康应立即切换");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("F3 壳页视为链路正常:冷却期内仅检查不切换", async () => {
|
|
172
|
-
resetState();
|
|
173
|
-
const { d, c } = makeCtx();
|
|
174
|
-
c._lastSwitchAt = Date.now(); // 冷却期内
|
|
175
|
-
// 壳页签名:200 + 无跳转 + 无 error + 无 title
|
|
176
|
-
state.health = {
|
|
177
|
-
healthy: false,
|
|
178
|
-
httpCode: 200,
|
|
179
|
-
timeTotal: 1.0,
|
|
180
|
-
sizeDownload: 43000,
|
|
181
|
-
numRedirects: 0,
|
|
182
|
-
title: "",
|
|
183
|
-
error: null,
|
|
184
|
-
};
|
|
185
|
-
await d._handleFailover({ reason: "F3" }, c);
|
|
186
|
-
assert.equal(state.switches.length, 0, "冷却期内壳页不应触发切换");
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test("F4 冷却期约束:切换后冷却期内不连续切换,过期后再切", async () => {
|
|
190
|
-
resetState();
|
|
191
|
-
const { d, c } = makeCtx();
|
|
192
|
-
// 首次 failover(无锚点)→ 按冷却期外处理 → 轮换
|
|
193
|
-
await d._handleFailover({ reason: "F4-1" }, c);
|
|
194
|
-
assert.equal(state.switches.length, 1, "首次应轮换切换");
|
|
195
|
-
// 锚点刚更新,冷却期内再次 failover(探针健康)→ 不切
|
|
196
|
-
await d._handleFailover({ reason: "F4-2" }, c);
|
|
197
|
-
assert.equal(state.switches.length, 1, "冷却期内不应再切换");
|
|
198
|
-
// 冷却期过后 → 再次轮换
|
|
199
|
-
c._lastSwitchAt = Date.now() - 600_001;
|
|
200
|
-
await d._handleFailover({ reason: "F4-3" }, c);
|
|
201
|
-
assert.equal(state.switches.length, 2, "冷却期过后应再切换");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("F5 手动切换同样更新锚点:切换后冷却期内 failover 不再切", async () => {
|
|
205
|
-
resetState();
|
|
206
|
-
const { d, c } = makeCtx();
|
|
207
|
-
// 手动切换(用户主动切,立即执行)
|
|
208
|
-
await d._handleSwitchNode({ node: "🇲🇾 马来02", proxyPort: 7897 }, c);
|
|
209
|
-
assert.equal(state.switches.length, 1, "手动切换应立即执行");
|
|
210
|
-
assert.ok(
|
|
211
|
-
c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
|
|
212
|
-
"手动切换应更新锚点",
|
|
213
|
-
);
|
|
214
|
-
// 手动切换后冷却期内,探针健康的 failover 不再切
|
|
215
|
-
await d._handleFailover({ reason: "F5-2" }, c);
|
|
216
|
-
assert.equal(
|
|
217
|
-
state.switches.length,
|
|
218
|
-
1,
|
|
219
|
-
"手动切换后冷却期内 failover 不应再切",
|
|
220
|
-
);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// F6 已移除:failoverEscapeAfter 计数方案已弃用(2026-08-25 改为时间锚点),
|
|
224
|
-
// 不再存在"连报 N 次"阈值;字段保留仅为兼容外部传参,不影响行为。
|
|
225
|
-
|
|
226
|
-
test("F7 候选集为空:触发 delay 初测且不切换", async () => {
|
|
227
|
-
resetState();
|
|
228
|
-
const { d, c } = makeCtx();
|
|
229
|
-
state.health = { ...state.health, healthy: false, error: "timeout" };
|
|
230
|
-
state.nodes = [];
|
|
231
|
-
await d._handleFailover({ reason: "F7" }, c);
|
|
232
|
-
assert.equal(state.switches.length, 0, "无候选不应切换");
|
|
233
|
-
assert.ok(c._lastProbeAt, "应触发过 delay 初测");
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test("F8 优雅断连失败:回退全断,切换仍生效", async () => {
|
|
237
|
-
resetState();
|
|
238
|
-
const { d, c } = makeCtx();
|
|
239
|
-
c._lastSwitchAt = Date.now() - 600_001; // 冷却期外
|
|
240
|
-
state.failIdleClose = true; // 优雅断连抛错
|
|
241
|
-
await d._handleFailover({ reason: "F8" }, c);
|
|
242
|
-
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换仍应执行");
|
|
243
|
-
assert.equal(state.idleCloses, 0, "优雅断连失败未计数");
|
|
244
|
-
assert.equal(state.closes, 1, "失败后应回退全断");
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("F9 disconnectMode=full:直接全断", async () => {
|
|
248
|
-
resetState();
|
|
249
|
-
const { d, c } = makeCtx({ disconnectMode: "full" });
|
|
250
|
-
c._lastSwitchAt = Date.now() - 600_001;
|
|
251
|
-
await d._handleFailover({ reason: "F9" }, c);
|
|
252
|
-
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换应执行");
|
|
253
|
-
assert.equal(state.idleCloses, 0, "full 模式不走优雅断连");
|
|
254
|
-
assert.equal(state.closes, 1, "full 模式直接全断");
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("F10 强切(force):冷却期内探针健康仍强制轮换", async () => {
|
|
258
|
-
resetState();
|
|
259
|
-
const { d, c } = makeCtx();
|
|
260
|
-
c._lastSwitchAt = Date.now(); // 刚切过,处于 10min 冷却期内
|
|
261
|
-
await d._handleFailover({ reason: "F10", force: true }, c);
|
|
262
|
-
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "force 应无视冷却直接轮换");
|
|
263
|
-
assert.ok(
|
|
264
|
-
c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
|
|
265
|
-
"强切后应更新锚点(给新节点观察期)",
|
|
266
|
-
);
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// ── 订阅切换感知:巡检开头候选集存在性校验(2026-08-29) ──
|
|
270
|
-
|
|
271
|
-
test("S1 候选集全部失效:当前节点健康也立即触发 delay 初测", async () => {
|
|
272
|
-
resetState();
|
|
273
|
-
const { d, c } = makeCtx();
|
|
274
|
-
state.health = { ...state.health, healthy: true }; // 当前节点健康
|
|
275
|
-
state.pruneResult = { kept: 0, removed: 2, allGone: true };
|
|
276
|
-
await d._healthCheckController(c);
|
|
277
|
-
assert.equal(state.pruneCalls, 1, "巡检应执行候选集校验");
|
|
278
|
-
// 初测经串行锁排队执行,等队列清空后断言
|
|
279
|
-
await d._withLock(() => {});
|
|
280
|
-
assert.equal(state.probeCalls, 1, "全失效应立即触发 delay 初测");
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
test("S2 候选集部分失效:剔除后不触发初测,巡检正常", async () => {
|
|
284
|
-
resetState();
|
|
285
|
-
const { d, c } = makeCtx();
|
|
286
|
-
state.pruneResult = { kept: 1, removed: 1, allGone: false };
|
|
287
|
-
await d._healthCheckController(c);
|
|
288
|
-
await d._withLock(() => {});
|
|
289
|
-
assert.equal(state.pruneCalls, 1, "应执行候选集校验");
|
|
290
|
-
assert.equal(state.probeCalls, 0, "部分失效不应触发初测");
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
test("S3 /proxies 拉取失败:静默跳过校验,不阻断巡检", async () => {
|
|
294
|
-
resetState();
|
|
295
|
-
const { d, c } = makeCtx();
|
|
296
|
-
state.failProxies = true;
|
|
297
|
-
await d._healthCheckController(c);
|
|
298
|
-
await d._withLock(() => {});
|
|
299
|
-
assert.equal(state.pruneCalls, 0, "API 失败不应执行校验");
|
|
300
|
-
assert.equal(state.probeCalls, 0, "API 失败不应触发初测");
|
|
301
|
-
});
|