tt-help-cli-ycl 1.4.168 → 1.4.171
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/clash/runtime.js +22 -1
- package/src/cli/info.js +6 -1
- package/src/lib/browser/cdp.js +7 -1
- package/src/watch/public/app.js +586 -15
- package/src/watch/public/index.html +70 -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 +5 -0
- 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,261 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 客户端异常监控单测(2026-08-28,2026-09-28 增补验证码路径,
|
|
3
|
-
* 2026-09-29 改为 clientId 本机关联 —— 克隆机 hostname 撞名修复)
|
|
4
|
-
*
|
|
5
|
-
* 验证:
|
|
6
|
-
* - detectClientError 检测函数:
|
|
7
|
-
* A. reportCount > 8 且最近一次上报在 2 分钟内
|
|
8
|
-
* B. captchaCount ≥ 3(webserver 验证码上报,10 分钟过期窗口内累计)
|
|
9
|
-
* - 按 clientId 过滤本机条目(每台唯一);hostname 撞名的兄弟机条目、
|
|
10
|
-
* 无 clientId 的旧条目均不参与关联
|
|
11
|
-
* - 命中 → onAlert 收到本机全部条目与命中列表;未命中 → 不回调
|
|
12
|
-
* - removeErrors 逐条 DELETE 并计数
|
|
13
|
-
*
|
|
14
|
-
* 运行: node --test scripts/test-client-error-monitor.mjs
|
|
15
|
-
*/
|
|
16
|
-
import { test, mock } from "node:test";
|
|
17
|
-
import assert from "node:assert/strict";
|
|
18
|
-
import {
|
|
19
|
-
detectClientError,
|
|
20
|
-
ClientErrorMonitor,
|
|
21
|
-
} from "../src/watchdog/client-error-monitor.js";
|
|
22
|
-
|
|
23
|
-
// 模拟克隆机群:hostname 全部相同(事故现场),clientId 每台唯一
|
|
24
|
-
const CLONED_HOST = "WIN-AADJ1HTCCAQ";
|
|
25
|
-
const CLIENT_ID = `F-9@${CLONED_HOST}`;
|
|
26
|
-
const BROTHER_CLIENT_ID = `F-10@${CLONED_HOST}`;
|
|
27
|
-
|
|
28
|
-
function makeErr(overrides = {}) {
|
|
29
|
-
return {
|
|
30
|
-
userId: "u1",
|
|
31
|
-
hostname: CLONED_HOST,
|
|
32
|
-
clientId: CLIENT_ID,
|
|
33
|
-
errorType: "consecutive_blocked",
|
|
34
|
-
reportCount: 10,
|
|
35
|
-
timestamp: Date.now() - 30_000,
|
|
36
|
-
...overrides,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function makeMonitor(opts = {}) {
|
|
41
|
-
let received = null;
|
|
42
|
-
let alerted = false;
|
|
43
|
-
const m = new ClientErrorMonitor({
|
|
44
|
-
serverUrl: "http://127.0.0.1:3000",
|
|
45
|
-
hostname: CLONED_HOST, // 仅展示用;关联只认 clientId
|
|
46
|
-
clientId: CLIENT_ID,
|
|
47
|
-
onAlert: async (mine, hit) => {
|
|
48
|
-
alerted = true;
|
|
49
|
-
received = { mine, hit };
|
|
50
|
-
},
|
|
51
|
-
...opts,
|
|
52
|
-
});
|
|
53
|
-
return { m, isAlerted: () => alerted, getReceived: () => received };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ── 检测函数 ──
|
|
57
|
-
|
|
58
|
-
test("D1 detectClientError:累计>8 且 2 分钟内 → 命中", () => {
|
|
59
|
-
assert.equal(
|
|
60
|
-
detectClientError(makeErr({ reportCount: 9 }), Date.now()),
|
|
61
|
-
true,
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("D2 detectClientError:reportCount=8 不命中(阈值是 >8)", () => {
|
|
66
|
-
assert.equal(
|
|
67
|
-
detectClientError(makeErr({ reportCount: 8 }), Date.now()),
|
|
68
|
-
false,
|
|
69
|
-
);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("D3 detectClientError:最近上报超过 2 分钟 → 不命中", () => {
|
|
73
|
-
assert.equal(
|
|
74
|
-
detectClientError(
|
|
75
|
-
makeErr({ timestamp: Date.now() - 2 * 60_000 - 1 }),
|
|
76
|
-
Date.now(),
|
|
77
|
-
),
|
|
78
|
-
false,
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("D4 detectClientError:缺 reportCount → 不命中", () => {
|
|
83
|
-
assert.equal(detectClientError({ timestamp: Date.now() }, Date.now()), false);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// ── 验证码路径(captchaCount ≥ 3) ──
|
|
87
|
-
|
|
88
|
-
test("C1 detectClientError:captchaCount=3 → 命中", () => {
|
|
89
|
-
assert.equal(
|
|
90
|
-
detectClientError(makeErr({ reportCount: 0, captchaCount: 3 }), Date.now()),
|
|
91
|
-
true,
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("C2 detectClientError:captchaCount=2 不命中(阈值是 ≥3)", () => {
|
|
96
|
-
assert.equal(
|
|
97
|
-
detectClientError(makeErr({ reportCount: 0, captchaCount: 2 }), Date.now()),
|
|
98
|
-
false,
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("C3 detectClientError:captchaCount=3 但最近上报超 2 分钟 → 不命中", () => {
|
|
103
|
-
assert.equal(
|
|
104
|
-
detectClientError(
|
|
105
|
-
makeErr({
|
|
106
|
-
reportCount: 0,
|
|
107
|
-
captchaCount: 3,
|
|
108
|
-
timestamp: Date.now() - 2 * 60_000 - 1,
|
|
109
|
-
}),
|
|
110
|
-
Date.now(),
|
|
111
|
-
),
|
|
112
|
-
false,
|
|
113
|
-
);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("C4 detectClientError:reportCount 未达标 + captchaCount 达标 → 命中", () => {
|
|
117
|
-
assert.equal(
|
|
118
|
-
detectClientError(makeErr({ reportCount: 1, captchaCount: 5 }), Date.now()),
|
|
119
|
-
true,
|
|
120
|
-
);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("C5 监控器:验证码条目命中 → onAlert 收到命中列表", async () => {
|
|
124
|
-
const captchaHit = makeErr({
|
|
125
|
-
userId: "webserver-host",
|
|
126
|
-
reportCount: 0,
|
|
127
|
-
captchaCount: 3,
|
|
128
|
-
});
|
|
129
|
-
const businessMiss = makeErr({ userId: "u-task", reportCount: 2 });
|
|
130
|
-
mock.method(globalThis, "fetch", async () => ({
|
|
131
|
-
ok: true,
|
|
132
|
-
json: async () => ({ clients: [captchaHit, businessMiss] }),
|
|
133
|
-
}));
|
|
134
|
-
const { m, getReceived } = makeMonitor();
|
|
135
|
-
const hit = await m._poll();
|
|
136
|
-
assert.equal(hit.length, 1, "仅验证码条目命中");
|
|
137
|
-
assert.equal(hit[0].userId, "webserver-host");
|
|
138
|
-
assert.equal(getReceived().hit.length, 1);
|
|
139
|
-
assert.equal(getReceived().mine.length, 2, "回调同时携带本机未命中条目");
|
|
140
|
-
mock.restoreAll();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// ── 监控器:本机关联(clientId) ──
|
|
144
|
-
|
|
145
|
-
test("M1 按 clientId 过滤:撞名兄弟机条目不触发本机回调", async () => {
|
|
146
|
-
// 克隆机事故回归:F-10 与 F-9 hostname 完全相同,仅 clientId 不同
|
|
147
|
-
mock.method(globalThis, "fetch", async () => ({
|
|
148
|
-
ok: true,
|
|
149
|
-
json: async () => ({
|
|
150
|
-
clients: [
|
|
151
|
-
makeErr({
|
|
152
|
-
userId: "F-10-8222-1",
|
|
153
|
-
clientId: BROTHER_CLIENT_ID,
|
|
154
|
-
captchaCount: 3,
|
|
155
|
-
reportCount: 0,
|
|
156
|
-
}),
|
|
157
|
-
],
|
|
158
|
-
}),
|
|
159
|
-
}));
|
|
160
|
-
const { m, isAlerted } = makeMonitor();
|
|
161
|
-
const hit = await m._poll();
|
|
162
|
-
assert.equal(isAlerted(), false, "兄弟机异常不应触发本机回调/强切");
|
|
163
|
-
assert.equal(hit, null, "无本机条目应返回 null");
|
|
164
|
-
mock.restoreAll();
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("M2 命中:onAlert 收到本机全部条目(含未命中条目)", async () => {
|
|
168
|
-
const mineHit = makeErr({ userId: "u-hit", reportCount: 10 });
|
|
169
|
-
const mineMiss = makeErr({ userId: "u-miss", reportCount: 2 });
|
|
170
|
-
const other = makeErr({
|
|
171
|
-
userId: "u-other",
|
|
172
|
-
clientId: BROTHER_CLIENT_ID, // hostname 相同,clientId 不同
|
|
173
|
-
});
|
|
174
|
-
mock.method(globalThis, "fetch", async () => ({
|
|
175
|
-
ok: true,
|
|
176
|
-
json: async () => ({ clients: [mineHit, mineMiss, other] }),
|
|
177
|
-
}));
|
|
178
|
-
const { m, getReceived } = makeMonitor();
|
|
179
|
-
const hit = await m._poll();
|
|
180
|
-
assert.equal(hit.length, 1, "命中 1 条");
|
|
181
|
-
assert.equal(hit[0].userId, "u-hit");
|
|
182
|
-
assert.equal(getReceived().mine.length, 2, "回调收到本机全部 2 条");
|
|
183
|
-
assert.deepEqual(
|
|
184
|
-
getReceived()
|
|
185
|
-
.mine.map((e) => e.userId)
|
|
186
|
-
.sort(),
|
|
187
|
-
["u-hit", "u-miss"],
|
|
188
|
-
"回调含命中与未命中的本机条目",
|
|
189
|
-
);
|
|
190
|
-
mock.restoreAll();
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("M3 无 clientId 的旧条目不参与本机关联(宁漏不误)", async () => {
|
|
194
|
-
mock.method(globalThis, "fetch", async () => ({
|
|
195
|
-
ok: true,
|
|
196
|
-
json: async () => ({
|
|
197
|
-
clients: [makeErr({ clientId: "", reportCount: 99 })],
|
|
198
|
-
}),
|
|
199
|
-
}));
|
|
200
|
-
const { m, isAlerted } = makeMonitor();
|
|
201
|
-
const hit = await m._poll();
|
|
202
|
-
assert.equal(isAlerted(), false, "旧版上报条目不应触发强切");
|
|
203
|
-
assert.equal(hit, null);
|
|
204
|
-
mock.restoreAll();
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("M4 未配置 clientId 的监控器:不匹配任何条目", async () => {
|
|
208
|
-
mock.method(globalThis, "fetch", async () => ({
|
|
209
|
-
ok: true,
|
|
210
|
-
json: async () => ({ clients: [makeErr({ reportCount: 99 })] }),
|
|
211
|
-
}));
|
|
212
|
-
let alerted = false;
|
|
213
|
-
const m = new ClientErrorMonitor({
|
|
214
|
-
serverUrl: "http://127.0.0.1:3000",
|
|
215
|
-
onAlert: async () => {
|
|
216
|
-
alerted = true;
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
const hit = await m._poll();
|
|
220
|
-
assert.equal(alerted, false, "clientId 未配置时监控不生效");
|
|
221
|
-
assert.equal(hit, null);
|
|
222
|
-
mock.restoreAll();
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test("M5 removeErrors:逐条 DELETE 并计数", async () => {
|
|
226
|
-
const deleted = [];
|
|
227
|
-
mock.method(globalThis, "fetch", async (url, opts) => {
|
|
228
|
-
if (opts && opts.method === "DELETE") {
|
|
229
|
-
deleted.push(String(url));
|
|
230
|
-
return { ok: true };
|
|
231
|
-
}
|
|
232
|
-
return { ok: true, json: async () => ({ clients: [] }) };
|
|
233
|
-
});
|
|
234
|
-
const m = new ClientErrorMonitor({ serverUrl: "http://127.0.0.1:3000" });
|
|
235
|
-
const removed = await m.removeErrors([
|
|
236
|
-
{ userId: "a" },
|
|
237
|
-
{ userId: "b" },
|
|
238
|
-
{ userId: "" },
|
|
239
|
-
null,
|
|
240
|
-
]);
|
|
241
|
-
assert.equal(removed, 2, "有效 userId 各删一条");
|
|
242
|
-
assert.equal(deleted.length, 2, "共 2 次 DELETE");
|
|
243
|
-
assert.ok(
|
|
244
|
-
deleted.every((u) =>
|
|
245
|
-
["a", "b"].some((id) => u.endsWith(`/api/client-error/${id}`)),
|
|
246
|
-
),
|
|
247
|
-
"DELETE 路径正确",
|
|
248
|
-
);
|
|
249
|
-
mock.restoreAll();
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("M6 拉取失败静默:fetch 抛错不回调不崩溃", async () => {
|
|
253
|
-
mock.method(globalThis, "fetch", async () => {
|
|
254
|
-
throw new Error("ECONNREFUSED");
|
|
255
|
-
});
|
|
256
|
-
const { m, isAlerted } = makeMonitor();
|
|
257
|
-
const hit = await m._poll();
|
|
258
|
-
assert.equal(isAlerted(), false);
|
|
259
|
-
assert.equal(hit, null);
|
|
260
|
-
mock.restoreAll();
|
|
261
|
-
});
|
|
@@ -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);
|