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,216 +0,0 @@
|
|
|
1
|
-
// claimNextJob 修复验证:功能语义 + 性能(合成 32 万行模拟生产行数)
|
|
2
|
-
// 用完即删
|
|
3
|
-
import fs from "fs";
|
|
4
|
-
import { createStore } from "../src/watch/data-store.js";
|
|
5
|
-
import { getDb } from "../src/watch/db-schema.js";
|
|
6
|
-
|
|
7
|
-
const TMP = "/tmp/tiktok-claim-test";
|
|
8
|
-
fs.rmSync(TMP, { recursive: true, force: true });
|
|
9
|
-
fs.mkdirSync(TMP, { recursive: true });
|
|
10
|
-
|
|
11
|
-
function freshStore(name) {
|
|
12
|
-
const p = `${TMP}/${name}.db`;
|
|
13
|
-
const store = createStore(p, {});
|
|
14
|
-
return { store, db: getDb(), path: p };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function insertRows(db, rows) {
|
|
18
|
-
const stmt = db.prepare(`
|
|
19
|
-
INSERT OR REPLACE INTO task_queue
|
|
20
|
-
(unique_id, status, guessed_location, video_count, follower_count,
|
|
21
|
-
tt_seller, verified, status_code, pinned, sources, created_at, claimed_at, claimed_by)
|
|
22
|
-
VALUES
|
|
23
|
-
(@unique_id, @status, @guessed_location, @video_count, @follower_count,
|
|
24
|
-
@tt_seller, @verified, @status_code, @pinned, @sources, @created_at, @claimed_at, @claimed_by)
|
|
25
|
-
`);
|
|
26
|
-
const tx = db.transaction((list) => {
|
|
27
|
-
for (const r of list) stmt.run(r);
|
|
28
|
-
});
|
|
29
|
-
tx(rows);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const baseRow = {
|
|
33
|
-
status: "pending",
|
|
34
|
-
guessed_location: "",
|
|
35
|
-
video_count: 5,
|
|
36
|
-
follower_count: 1000,
|
|
37
|
-
tt_seller: 0,
|
|
38
|
-
verified: 0,
|
|
39
|
-
status_code: null,
|
|
40
|
-
pinned: 0,
|
|
41
|
-
sources: '["explore"]',
|
|
42
|
-
created_at: "2026-01-01T00:00:00Z",
|
|
43
|
-
claimed_at: null,
|
|
44
|
-
claimed_by: null,
|
|
45
|
-
};
|
|
46
|
-
const row = (over) => ({ ...baseRow, unique_id: `u_${Math.random().toString(36).slice(2, 9)}`, ...over });
|
|
47
|
-
|
|
48
|
-
let pass = 0, fail = 0;
|
|
49
|
-
function check(name, cond, detail = "") {
|
|
50
|
-
if (cond) { pass++; console.log(` ✅ ${name}${detail ? " " + detail : ""}`); }
|
|
51
|
-
else { fail++; console.log(` ❌ ${name}${detail ? " " + detail : ""}`); }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ───────── B. 功能语义(小数据集,各用独立库) ─────────
|
|
55
|
-
console.log("\n== B1. 首国等值严格优先于空位置(即使空位置粉丝更高)==");
|
|
56
|
-
{
|
|
57
|
-
const { store, db } = freshStore("b1");
|
|
58
|
-
insertRows(db, [
|
|
59
|
-
row({ unique_id: "empty-high", guessed_location: "", follower_count: 9999 }),
|
|
60
|
-
row({ unique_id: "mx-low", guessed_location: "mx", follower_count: 100 }),
|
|
61
|
-
]);
|
|
62
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
63
|
-
check("领到 mx-low", j?.uniqueId === "mx-low", `got=${j?.uniqueId}`);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
console.log("== B2. 首国无候选时空位置可领 ==");
|
|
67
|
-
{
|
|
68
|
-
const { store, db } = freshStore("b2");
|
|
69
|
-
insertRows(db, [row({ unique_id: "only-empty", guessed_location: "" })]);
|
|
70
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
71
|
-
check("领到 only-empty", j?.uniqueId === "only-empty", `got=${j?.uniqueId}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
console.log("== B3. hasVideo 层整体优先于 noVideoReq 层(含空位置)==");
|
|
75
|
-
{
|
|
76
|
-
const { store, db } = freshStore("b3");
|
|
77
|
-
insertRows(db, [
|
|
78
|
-
row({ unique_id: "mx-novideo", guessed_location: "mx", video_count: 0, follower_count: 5000 }),
|
|
79
|
-
row({ unique_id: "br-video", guessed_location: "br", video_count: 3, follower_count: 100 }),
|
|
80
|
-
]);
|
|
81
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
82
|
-
check("mx-noVideo 优先于 br-video(与原版层级一致)", j?.uniqueId === "mx-novideo", `got=${j?.uniqueId}`);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
console.log("== B4. 国家顺序优先:MX 行优先于 BR 行 ==");
|
|
86
|
-
{
|
|
87
|
-
const { store, db } = freshStore("b4");
|
|
88
|
-
insertRows(db, [
|
|
89
|
-
row({ unique_id: "br-high", guessed_location: "br", follower_count: 9999 }),
|
|
90
|
-
row({ unique_id: "mx-low", guessed_location: "mx", follower_count: 10 }),
|
|
91
|
-
]);
|
|
92
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
93
|
-
check("领到 mx-low", j?.uniqueId === "mx-low", `got=${j?.uniqueId}`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
console.log("== B5. pinned:空池记忆后 pin 立即可领(缓存失效生效)==");
|
|
97
|
-
{
|
|
98
|
-
const { store, db } = freshStore("b5");
|
|
99
|
-
insertRows(db, [
|
|
100
|
-
row({ unique_id: "plain", guessed_location: "mx", follower_count: 100 }),
|
|
101
|
-
row({ unique_id: "pinned-later", guessed_location: "br", follower_count: 50 }),
|
|
102
|
-
]);
|
|
103
|
-
// 第一次领取:pinned 分支执行且 miss → 写入空池记忆
|
|
104
|
-
const j1 = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
105
|
-
check("第一次领到 plain", j1?.uniqueId === "plain", `got=${j1?.uniqueId}`);
|
|
106
|
-
// pin 一个低优先级任务
|
|
107
|
-
const pinRet = store.togglePin("pinned-later");
|
|
108
|
-
check("togglePin 成功", pinRet?.saved === true);
|
|
109
|
-
// 立即换客户端领取:应直接领到 pinned 任务(不受空池记忆影响)
|
|
110
|
-
const j2 = await store.claimNextJob("c2", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
111
|
-
check("pin 后立即领到 pinned-later", j2?.uniqueId === "pinned-later", `got=${j2?.uniqueId}`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
console.log("== B6. 未登录客户端不领商家 ==");
|
|
115
|
-
{
|
|
116
|
-
const { store, db } = freshStore("b6");
|
|
117
|
-
insertRows(db, [row({ unique_id: "seller-only", guessed_location: "mx", tt_seller: 1 })]);
|
|
118
|
-
const jOut = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], false);
|
|
119
|
-
check("未登录领不到", jOut === null, `got=${jOut?.uniqueId}`);
|
|
120
|
-
const jIn = await store.claimNextJob("c2", 5 * 60 * 1000, ["MX"], true);
|
|
121
|
-
check("登录可领", jIn?.uniqueId === "seller-only", `got=${jIn?.uniqueId}`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
console.log("== B7. status_code=209002 仅登录客户端可领 ==");
|
|
125
|
-
{
|
|
126
|
-
const { store, db } = freshStore("b7");
|
|
127
|
-
insertRows(db, [row({ unique_id: "code-209002", guessed_location: "mx", status_code: 209002 })]);
|
|
128
|
-
const jOut = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], false);
|
|
129
|
-
check("未登录领不到", jOut === null, `got=${jOut?.uniqueId}`);
|
|
130
|
-
const jIn = await store.claimNextJob("c2", 5 * 60 * 1000, ["MX"], true);
|
|
131
|
-
check("登录可领", jIn?.uniqueId === "code-209002", `got=${jIn?.uniqueId}`);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
console.log("== B8. 超时 processing 回收优先 ==");
|
|
135
|
-
{
|
|
136
|
-
const { store, db } = freshStore("b8");
|
|
137
|
-
const now = Date.now();
|
|
138
|
-
insertRows(db, [
|
|
139
|
-
row({ unique_id: "stale", guessed_location: "mx", status: "processing", claimed_at: now - 10 * 60 * 1000, claimed_by: "dead-client" }),
|
|
140
|
-
row({ unique_id: "fresh", guessed_location: "mx", follower_count: 99999 }),
|
|
141
|
-
]);
|
|
142
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], true);
|
|
143
|
-
check("回收的 stale 优先于高粉丝 fresh", j?.uniqueId === "stale", `got=${j?.uniqueId}`);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
console.log("== B9. 商家任意国家兜底(sellerAnyCountry)==");
|
|
147
|
-
{
|
|
148
|
-
const { store, db } = freshStore("b9");
|
|
149
|
-
insertRows(db, [
|
|
150
|
-
row({ unique_id: "zz-seller", guessed_location: "zz", tt_seller: 1, verified: 0, follower_count: 10 }),
|
|
151
|
-
row({ unique_id: "mx-normal", guessed_location: "mx", follower_count: 9999 }),
|
|
152
|
-
]);
|
|
153
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], true);
|
|
154
|
-
check("商家级高于 other 级:zz-seller 先领", j?.uniqueId === "zz-seller", `got=${j?.uniqueId}`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
console.log("== B10. seed 级优先于 follow/other ==");
|
|
158
|
-
{
|
|
159
|
-
const { store, db } = freshStore("b10");
|
|
160
|
-
insertRows(db, [
|
|
161
|
-
row({ unique_id: "mx-seed", guessed_location: "mx", sources: '["explore"]', follower_count: 10 }),
|
|
162
|
-
row({ unique_id: "br-seed", guessed_location: "br", sources: '["seed"]', follower_count: 20 }),
|
|
163
|
-
]);
|
|
164
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
165
|
-
check("seed 级 br-seed 胜过 other 级 mx(国家级让位于来源级)", j?.uniqueId === "br-seed", `got=${j?.uniqueId}`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ───────── A. 性能(32 万行)─────────
|
|
169
|
-
console.log("\n== A. 性能:320,600 行 pending,候选国家池全空,模拟生产全 miss 场景 ==");
|
|
170
|
-
{
|
|
171
|
-
const { store, db } = freshStore("perf");
|
|
172
|
-
// 32 万行:位置全 ZZ(配置外),含 3000 个高粉丝商家(seller any-country 分支秒命中用)
|
|
173
|
-
db.exec(`
|
|
174
|
-
WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < 320600)
|
|
175
|
-
INSERT INTO task_queue
|
|
176
|
-
(unique_id, status, guessed_location, video_count, follower_count, tt_seller, verified, status_code, pinned, sources, created_at)
|
|
177
|
-
SELECT
|
|
178
|
-
'bulk_' || i,
|
|
179
|
-
'pending',
|
|
180
|
-
'zz',
|
|
181
|
-
CASE WHEN i % 3 = 0 THEN 5 ELSE 0 END,
|
|
182
|
-
(i * 7919) % 1000000,
|
|
183
|
-
CASE WHEN i <= 3000 THEN 1 ELSE 0 END,
|
|
184
|
-
0,
|
|
185
|
-
NULL,
|
|
186
|
-
0,
|
|
187
|
-
'["explore"]',
|
|
188
|
-
'2026-01-01T00:00:00Z'
|
|
189
|
-
FROM seq
|
|
190
|
-
`);
|
|
191
|
-
// 保证 3 个最高粉丝都是商家 → seller any-country 命中不扫表
|
|
192
|
-
db.exec(`UPDATE task_queue SET follower_count = 100000000 + CAST(unique_id AS INTEGER) % 1000 WHERE tt_seller = 1 AND CAST(REPLACE(unique_id,'bulk_','') AS INTEGER) <= 3`);
|
|
193
|
-
|
|
194
|
-
const t1 = Date.now();
|
|
195
|
-
const j1 = await store.claimNextJob("perf-c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
196
|
-
const t1ms = Date.now() - t1;
|
|
197
|
-
check("首领成功(命中商家兜底)", j1?.uniqueId?.startsWith("bulk_"), `got=${j1?.uniqueId} ${t1ms}ms`);
|
|
198
|
-
|
|
199
|
-
const t2 = Date.now();
|
|
200
|
-
const j2 = await store.claimNextJob("perf-c2", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
201
|
-
const t2ms = Date.now() - t2;
|
|
202
|
-
check("次领成功", j2?.uniqueId?.startsWith("bulk_"), `got=${j2?.uniqueId} ${t2ms}ms`);
|
|
203
|
-
check("次领显著快于首领(pinned/seed 空池缓存生效)", t2ms < t1ms, `t1=${t1ms}ms t2=${t2ms}ms`);
|
|
204
|
-
check("次领 < 150ms", t2ms < 150, `${t2ms}ms`);
|
|
205
|
-
|
|
206
|
-
// EXPLAIN:确认单国等值分支走 fast 索引
|
|
207
|
-
const plan = db
|
|
208
|
-
.prepare(`EXPLAIN QUERY PLAN SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND video_count > 0 AND UPPER(COALESCE(guessed_location, '')) IN ('MX') AND (status_code IS NULL OR status_code = 0 OR status_code = 209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`)
|
|
209
|
-
.all();
|
|
210
|
-
const detail = plan.map((r) => r.detail).join(" | ");
|
|
211
|
-
check("单国分支走 idx_tq_claim_fast", detail.includes("idx_tq_claim_fast"), detail);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
console.log(`\n===== 结果: ${pass} passed, ${fail} failed =====`);
|
|
215
|
-
fs.rmSync(TMP, { recursive: true, force: true });
|
|
216
|
-
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* clash 控制端自动发现 — 验证脚本
|
|
4
|
-
* 用法: node scripts/test-clash-discover.mjs
|
|
5
|
-
*/
|
|
6
|
-
import {
|
|
7
|
-
parseFlatYaml,
|
|
8
|
-
parseController,
|
|
9
|
-
verifyController,
|
|
10
|
-
discover,
|
|
11
|
-
invalidateDiscoverCache,
|
|
12
|
-
listListeningPorts,
|
|
13
|
-
getSystemProxyPorts,
|
|
14
|
-
} from "../src/clash/discover.js";
|
|
15
|
-
import { configure, getBaseConfig, resetConfig } from "../src/clash/api.js";
|
|
16
|
-
import { readFileSync, existsSync } from "fs";
|
|
17
|
-
import { homedir } from "os";
|
|
18
|
-
import { join } from "path";
|
|
19
|
-
|
|
20
|
-
let failed = 0;
|
|
21
|
-
function check(name, cond, extra = "") {
|
|
22
|
-
console.log(`${cond ? "✓" : "✗"} ${name}${extra ? ` — ${extra}` : ""}`);
|
|
23
|
-
if (!cond) failed++;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ── 单元 ──
|
|
27
|
-
const yaml = parseFlatYaml(
|
|
28
|
-
`external-controller: 127.0.0.1:19835\nsecret: "abc123"\nmode: rule\nproxies:\n - a\n`,
|
|
29
|
-
);
|
|
30
|
-
check(
|
|
31
|
-
"parseFlatYaml 顶层字段",
|
|
32
|
-
yaml["external-controller"] === "127.0.0.1:19835" && yaml.secret === "abc123",
|
|
33
|
-
);
|
|
34
|
-
check("parseFlatYaml 跳过嵌套/列表", !("proxies" in yaml));
|
|
35
|
-
check("parseController 标准格式", parseController("127.0.0.1:9097") === 9097);
|
|
36
|
-
check("parseController 空值", parseController("") === null);
|
|
37
|
-
check("listListeningPorts 枚举非空", listListeningPorts().length > 0);
|
|
38
|
-
|
|
39
|
-
// ── 集成: 按当前环境自适应验证 ──
|
|
40
|
-
const listening = listListeningPorts();
|
|
41
|
-
const clvUp = listening.includes(9097);
|
|
42
|
-
// 69cloud 控制端口会漂移(19835 → 19216 …),从配置文件动态读取
|
|
43
|
-
const n69CfgPath = join(
|
|
44
|
-
homedir(),
|
|
45
|
-
"Library/Application Support/69cloud",
|
|
46
|
-
"config",
|
|
47
|
-
"config.yaml",
|
|
48
|
-
);
|
|
49
|
-
let n69Port = null;
|
|
50
|
-
if (existsSync(n69CfgPath)) {
|
|
51
|
-
n69Port = parseController(
|
|
52
|
-
parseFlatYaml(readFileSync(n69CfgPath, "utf-8"))["external-controller"],
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
const n69Up = n69Port !== null && listening.includes(n69Port);
|
|
56
|
-
|
|
57
|
-
if (clvUp) {
|
|
58
|
-
check(
|
|
59
|
-
"verifyController 9097 + abcdefg",
|
|
60
|
-
await verifyController(9097, "abcdefg"),
|
|
61
|
-
);
|
|
62
|
-
} else {
|
|
63
|
-
console.log("○ 跳过 9097 验证(Clash Verge 未运行)");
|
|
64
|
-
}
|
|
65
|
-
if (n69Up) {
|
|
66
|
-
check(
|
|
67
|
-
"verifyController 69cloud + 错误secret 应失败",
|
|
68
|
-
!(await verifyController(n69Port, "wrong-secret")),
|
|
69
|
-
);
|
|
70
|
-
} else {
|
|
71
|
-
console.log("○ 跳过 69cloud 验证(69cloud 未运行)");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const sysPorts = getSystemProxyPorts();
|
|
75
|
-
console.log(` 系统代理端口: [${sysPorts.join(", ") || "无"}]`);
|
|
76
|
-
|
|
77
|
-
// ── 集成: 自动发现 ──
|
|
78
|
-
invalidateDiscoverCache();
|
|
79
|
-
const d1 = await discover();
|
|
80
|
-
if (clvUp || n69Up) {
|
|
81
|
-
check(
|
|
82
|
-
"discover 自动模式(有客户端在线)",
|
|
83
|
-
!d1.fallback && listening.includes(d1.port),
|
|
84
|
-
`选定 ${d1.clientName} @${d1.port} 来源=${d1.source} proxy=${d1.proxyUrl}`,
|
|
85
|
-
);
|
|
86
|
-
console.log(` -> ${JSON.stringify(d1)}`);
|
|
87
|
-
|
|
88
|
-
// 显式覆盖(用自动发现选定的控制端做正向验证)
|
|
89
|
-
invalidateDiscoverCache();
|
|
90
|
-
const d2 = await discover({ apiPort: d1.port, secret: d1.secret });
|
|
91
|
-
check(
|
|
92
|
-
"discover 显式覆盖",
|
|
93
|
-
d2.port === d1.port && d2.source === "手动指定",
|
|
94
|
-
`@${d2.port} 来源=${d2.source}`,
|
|
95
|
-
);
|
|
96
|
-
} else {
|
|
97
|
-
check(
|
|
98
|
-
"discover 无客户端 → 回退默认值",
|
|
99
|
-
d1.fallback === true &&
|
|
100
|
-
d1.port === 9097 &&
|
|
101
|
-
d1.source === "未发现可用控制端",
|
|
102
|
-
`选定 ${d1.clientName} @${d1.port} 来源=${d1.source}`,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// 显式覆盖错误端口 → 必须抛错(任何环境都验证)
|
|
107
|
-
invalidateDiscoverCache();
|
|
108
|
-
let threw = false;
|
|
109
|
-
try {
|
|
110
|
-
await discover({ apiPort: 12345, secret: "x" });
|
|
111
|
-
} catch (e) {
|
|
112
|
-
threw = true;
|
|
113
|
-
console.log(` 预期报错: ${e.message}`);
|
|
114
|
-
}
|
|
115
|
-
check("discover 错误端口必须报错", threw);
|
|
116
|
-
|
|
117
|
-
// ── api.configure 注入 ──
|
|
118
|
-
resetConfig();
|
|
119
|
-
configure({ port: 19835, secret: "demo" });
|
|
120
|
-
const bc = getBaseConfig();
|
|
121
|
-
check("api.configure 注入", bc.port === 19835 && bc.secret === "demo");
|
|
122
|
-
resetConfig();
|
|
123
|
-
check("api.resetConfig 还原", getBaseConfig().port === 9097);
|
|
124
|
-
|
|
125
|
-
console.log(failed ? `\n✗ ${failed} 项失败` : "\n✓ 全部通过");
|
|
126
|
-
process.exit(failed ? 1 : 0);
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 候选集迟滞(mergeHealthyWithHysteresis)单测(离线,不碰真实健康文件)
|
|
3
|
-
*
|
|
4
|
-
* 背景:delay 初测为单点采样,瞬时抖动会把健康节点误踢出池(2026-09-28 生产实锤:
|
|
5
|
-
* 英国节点被白名单守卫切回)。迟滞规则:连续 2 轮初测失败才除名;失败第 1 轮保留
|
|
6
|
-
* 在池内作"降级观察",写盘顺序沉底(幸存者在前后降级在后,轮换优先实测存活节点)。
|
|
7
|
-
*
|
|
8
|
-
* 覆盖:
|
|
9
|
-
* - 首轮(无上一轮池)→ 仅幸存者,无降级
|
|
10
|
-
* - 本轮失败第 1 轮 → 保留 + 计数 1,排幸存者之后
|
|
11
|
-
* - 连续第 2 轮失败(prevDegraded=1)→ 除名
|
|
12
|
-
* - 降级后恢复 → 计数自动清零
|
|
13
|
-
* - maxRounds=1 → 关闭迟滞(失败即除名)
|
|
14
|
-
* - 容错:prev 非法值 / 空 prevDegraded
|
|
15
|
-
* - 文件往返:saveHealthyList(degraded) → loadHealthyList 读回
|
|
16
|
-
* - pruneHealthyListByCurrentProxies 剔除节点时携带留存节点的 degraded
|
|
17
|
-
*
|
|
18
|
-
* 运行: node scripts/test-clash-hysteresis.mjs
|
|
19
|
-
*/
|
|
20
|
-
import assert from "node:assert/strict";
|
|
21
|
-
import { unlinkSync, existsSync } from "fs";
|
|
22
|
-
import { tmpdir } from "os";
|
|
23
|
-
import { join } from "path";
|
|
24
|
-
import {
|
|
25
|
-
mergeHealthyWithHysteresis,
|
|
26
|
-
saveHealthyList,
|
|
27
|
-
loadHealthyList,
|
|
28
|
-
pruneHealthyListByCurrentProxies,
|
|
29
|
-
applyDiscovered,
|
|
30
|
-
} from "../src/clash/checker.js";
|
|
31
|
-
|
|
32
|
-
let passed = 0;
|
|
33
|
-
const failures = [];
|
|
34
|
-
async function check(name, fn) {
|
|
35
|
-
try {
|
|
36
|
-
await fn();
|
|
37
|
-
passed++;
|
|
38
|
-
console.log(`PASS ${name}`);
|
|
39
|
-
} catch (e) {
|
|
40
|
-
failures.push({ name, e });
|
|
41
|
-
console.log(`FAIL ${name}: ${e.message}`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ── 纯函数:mergeHealthyWithHysteresis ──
|
|
46
|
-
|
|
47
|
-
await check("首轮(无上一轮池)→ 仅幸存者,无降级", () => {
|
|
48
|
-
const r = mergeHealthyWithHysteresis(["A", "B"], [], {}, 2);
|
|
49
|
-
assert.deepEqual(r.nodes, ["A", "B"]);
|
|
50
|
-
assert.deepEqual(r.degraded, {});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
await check("本轮失败第 1 轮 → 保留 + 计数 1,排幸存者之后", () => {
|
|
54
|
-
// 上一轮池 [A,B,C],本轮只有 A 可达:B/C 首次失败,保留观察
|
|
55
|
-
const r = mergeHealthyWithHysteresis(["A"], ["A", "B", "C"], {}, 2);
|
|
56
|
-
assert.deepEqual(r.nodes, ["A", "B", "C"]); // 幸存者在前、降级在后(B/C 保持原相对序)
|
|
57
|
-
assert.deepEqual(r.degraded, { B: 1, C: 1 });
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
await check("连续第 2 轮失败(prevDegraded=1)→ 除名", () => {
|
|
61
|
-
// B 上一轮已失败 1 次,本轮再失败 → 达到 maxRounds=2,除名;C 首次失败保留
|
|
62
|
-
const r = mergeHealthyWithHysteresis(["A"], ["A", "B", "C"], { B: 1 }, 2);
|
|
63
|
-
assert.deepEqual(r.nodes, ["A", "C"]);
|
|
64
|
-
assert.deepEqual(r.degraded, { C: 1 });
|
|
65
|
-
assert.ok(!("B" in r.degraded));
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
await check("降级后恢复 → 计数自动清零", () => {
|
|
69
|
-
// C 上轮降级(计数 1),本轮恢复 → 回到幸存者区,degraded 中无 C;
|
|
70
|
-
// B 本轮首次失败,照常保留观察
|
|
71
|
-
const r = mergeHealthyWithHysteresis(
|
|
72
|
-
["A", "C"],
|
|
73
|
-
["A", "B", "C"],
|
|
74
|
-
{ C: 1 },
|
|
75
|
-
2,
|
|
76
|
-
);
|
|
77
|
-
assert.deepEqual(r.nodes, ["A", "C", "B"]);
|
|
78
|
-
assert.deepEqual(r.degraded, { B: 1 });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
await check("maxRounds=1 → 关闭迟滞(失败即除名)", () => {
|
|
82
|
-
const r = mergeHealthyWithHysteresis(["A"], ["A", "B"], {}, 1);
|
|
83
|
-
assert.deepEqual(r.nodes, ["A"]);
|
|
84
|
-
assert.deepEqual(r.degraded, {});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
await check("容错:prev 非法 / prevDegraded 非法", () => {
|
|
88
|
-
const r = mergeHealthyWithHysteresis(["A"], null, "bad", 2);
|
|
89
|
-
assert.deepEqual(r.nodes, ["A"]);
|
|
90
|
-
assert.deepEqual(r.degraded, {});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// ── 文件往返(隔离端口通道,不碰真实 clash-healthy-nodes.json) ──
|
|
94
|
-
|
|
95
|
-
const TEST_PORT = 64000 + Math.floor(Math.random() * 1000);
|
|
96
|
-
const TEST_FILE = join(tmpdir(), `clash-healthy-nodes-${TEST_PORT}.json`);
|
|
97
|
-
applyDiscovered({ proxyPort: TEST_PORT });
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
await check("saveHealthyList(degraded) → loadHealthyList 读回", () => {
|
|
101
|
-
saveHealthyList(
|
|
102
|
-
["🇺🇸美国HY1", "🇬🇧英国HY1"],
|
|
103
|
-
{ "🇺🇸美国HY1": 800 },
|
|
104
|
-
{ "🇬🇧英国HY1": 1 },
|
|
105
|
-
);
|
|
106
|
-
const d = loadHealthyList();
|
|
107
|
-
assert.deepEqual(d.nodes, ["🇺🇸美国HY1", "🇬🇧英国HY1"]);
|
|
108
|
-
assert.equal(d.delays["🇺🇸美国HY1"], 800);
|
|
109
|
-
assert.equal(d.degraded["🇬🇧英国HY1"], 1);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
await check("pruneHealthyListByCurrentProxies 携带留存节点的 degraded", () => {
|
|
113
|
-
// 内核中只剩美国节点:英国被剔除,美国的降级计数(若有)应保留
|
|
114
|
-
saveHealthyList(
|
|
115
|
-
["🇺🇸美国HY1", "🇺🇸美国HY2", "🇬🇧英国HY1"],
|
|
116
|
-
{ "🇺🇸美国HY1": 800 },
|
|
117
|
-
{ "🇺🇸美国HY2": 1, "🇬🇧英国HY1": 1 },
|
|
118
|
-
);
|
|
119
|
-
const r = pruneHealthyListByCurrentProxies(["🇺🇸美国HY1", "🇺🇸美国HY2"]);
|
|
120
|
-
assert.equal(r.removed, 1);
|
|
121
|
-
const d = loadHealthyList();
|
|
122
|
-
assert.deepEqual(d.nodes, ["🇺🇸美国HY1", "🇺🇸美国HY2"]);
|
|
123
|
-
assert.equal(d.degraded["🇺🇸美国HY2"], 1);
|
|
124
|
-
assert.ok(!("🇬🇧英国HY1" in d.degraded));
|
|
125
|
-
});
|
|
126
|
-
} finally {
|
|
127
|
-
if (existsSync(TEST_FILE)) unlinkSync(TEST_FILE);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
console.log(`\n结果: ${passed} 通过, ${failures.length} 失败`);
|
|
131
|
-
if (failures.length) process.exit(1);
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* clash 多控制端多通道 — 验证脚本
|
|
4
|
-
* 用法: node scripts/test-clash-multichannel.mjs
|
|
5
|
-
*/
|
|
6
|
-
import { discoverAll, getSystemProxyPorts } from "../src/clash/discover.js";
|
|
7
|
-
import * as ipc from "../src/clash/ipc.js";
|
|
8
|
-
|
|
9
|
-
let failed = 0;
|
|
10
|
-
function check(name, cond, extra = "") {
|
|
11
|
-
console.log(`${cond ? "✓" : "✗"} ${name}${extra ? ` — ${extra}` : ""}`);
|
|
12
|
-
if (!cond) failed++;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// ── 1. discoverAll:多控制端发现 ──
|
|
16
|
-
const all = await discoverAll();
|
|
17
|
-
console.log(` 发现 ${all.length} 个控制端:`);
|
|
18
|
-
for (const c of all) {
|
|
19
|
-
console.log(
|
|
20
|
-
` - [${c.proxyPort}] ${c.clientName} @${c.port}(${c.source}${c.fallback ? ",回退默认值" : ""})`,
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
check("discoverAll 返回数组", Array.isArray(all) && all.length > 0);
|
|
24
|
-
check(
|
|
25
|
-
"每个控制端含 proxyPort/proxyUrl",
|
|
26
|
-
all.every((c) => c.proxyPort > 0 && c.proxyUrl?.startsWith("http://")),
|
|
27
|
-
);
|
|
28
|
-
const sysPorts = getSystemProxyPorts();
|
|
29
|
-
if (sysPorts.length && all.length > 1) {
|
|
30
|
-
check(
|
|
31
|
-
"system-proxy 命中者排第一(主控制端)",
|
|
32
|
-
sysPorts.includes(all[0].proxyPort),
|
|
33
|
-
`主控制端 proxyPort=${all[0].proxyPort}, 系统代理=[${sysPorts.join(",")}]`,
|
|
34
|
-
);
|
|
35
|
-
} else {
|
|
36
|
-
console.log(
|
|
37
|
-
`○ 跳过主控制端排序断言(系统代理=[${sysPorts.join(",")}] 或单控制端)`,
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ── 2. IPC 多通道读写 ──
|
|
42
|
-
const TEST_PORT = 7890;
|
|
43
|
-
ipc.writeCommand(
|
|
44
|
-
{ action: "failover", reason: "multichannel-test", proxyPort: TEST_PORT },
|
|
45
|
-
TEST_PORT,
|
|
46
|
-
);
|
|
47
|
-
const readBack = ipc.readCommand(TEST_PORT);
|
|
48
|
-
check(
|
|
49
|
-
"按端口写读命令通道",
|
|
50
|
-
Array.isArray(readBack) &&
|
|
51
|
-
readBack[0]?.action === "failover" &&
|
|
52
|
-
readBack[0]?.proxyPort === TEST_PORT,
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
ipc.writeHealth({ node: "test", healthy: true }, TEST_PORT);
|
|
56
|
-
const hBack = ipc.readHealth(TEST_PORT);
|
|
57
|
-
check(
|
|
58
|
-
"按端口写读健康通道",
|
|
59
|
-
!!hBack && hBack.node === "test" && hBack.healthy === true,
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
// 默认通道与端口通道互不影响
|
|
63
|
-
const legacyCmd = ipc.readCommand(0);
|
|
64
|
-
check(
|
|
65
|
-
"默认通道独立(未被端口通道污染)",
|
|
66
|
-
!legacyCmd || legacyCmd[0]?.action !== "failover" || !legacyCmd[0]?.proxyPort,
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
const channels = ipc.listCommandChannels();
|
|
70
|
-
check(
|
|
71
|
-
"listCommandChannels 含测试通道",
|
|
72
|
-
channels.includes(TEST_PORT),
|
|
73
|
-
`channels=[${channels.join(",")}]`,
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
ipc.clearCommand(TEST_PORT);
|
|
77
|
-
check("clearCommand 清空端口通道", ipc.readCommand(TEST_PORT) === null);
|
|
78
|
-
|
|
79
|
-
// ── 3. 路由逻辑(用 daemon 实例验证,不启动守护) ──
|
|
80
|
-
import { ClashDaemon } from "../src/clash/daemon.js";
|
|
81
|
-
const d = new ClashDaemon({ dryRun: true });
|
|
82
|
-
d._controllers = all;
|
|
83
|
-
d._primary = all[0];
|
|
84
|
-
const routed = d._routeController({
|
|
85
|
-
action: "failover",
|
|
86
|
-
proxyPort: all[0].proxyPort,
|
|
87
|
-
});
|
|
88
|
-
check(
|
|
89
|
-
"命令按 proxyPort 路由到对应控制端",
|
|
90
|
-
!!routed && routed.proxyPort === all[0].proxyPort,
|
|
91
|
-
);
|
|
92
|
-
const routedMiss = d._routeController({ action: "failover", proxyPort: 59999 });
|
|
93
|
-
check("未知端口回退主控制端", !!routedMiss && routedMiss === d._primary);
|
|
94
|
-
|
|
95
|
-
// 多控制端时:第二个控制端也能被路由命中
|
|
96
|
-
if (all.length > 1) {
|
|
97
|
-
const routed2 = d._routeController({
|
|
98
|
-
action: "switchNode",
|
|
99
|
-
node: "x",
|
|
100
|
-
proxyPort: all[1].proxyPort,
|
|
101
|
-
});
|
|
102
|
-
check(
|
|
103
|
-
"第二个控制端按端口路由命中",
|
|
104
|
-
!!routed2 && routed2.proxyPort === all[1].proxyPort,
|
|
105
|
-
`${routed2?.clientName} @${routed2?.port}`,
|
|
106
|
-
);
|
|
107
|
-
} else {
|
|
108
|
-
console.log("○ 单控制端,跳过第二控制端路由断言");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// 清理测试产生的健康文件
|
|
112
|
-
try {
|
|
113
|
-
const { rmSync } = await import("fs");
|
|
114
|
-
const { tmpdir } = await import("os");
|
|
115
|
-
const { join } = await import("path");
|
|
116
|
-
rmSync(join(tmpdir(), `clash-health-${TEST_PORT}.json`), { force: true });
|
|
117
|
-
} catch {}
|
|
118
|
-
|
|
119
|
-
console.log(failed ? `\n✗ ${failed} 项失败` : "\n✓ 全部通过");
|
|
120
|
-
process.exit(failed ? 1 : 0);
|