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
package/package.json
CHANGED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 订阅自动分配器 — 纯函数单元测试
|
|
3
|
+
*
|
|
4
|
+
* 覆盖需求讨论拍板的规则卡 v0:
|
|
5
|
+
* 规则1 配满 / 规则2 可用集 / 规则3 粘性 / 规则4 节点内摊开 /
|
|
6
|
+
* 规则5 跨机器错开 / 规则6 重复合法 / 规则7 空保护 / 规则8 决定性
|
|
7
|
+
*
|
|
8
|
+
* 运行: node scripts/test-subscription-allocator.mjs
|
|
9
|
+
*/
|
|
10
|
+
import assert from "assert";
|
|
11
|
+
import {
|
|
12
|
+
allocateSubscriptions,
|
|
13
|
+
isSubAvailable,
|
|
14
|
+
ALLOC_MAX_INSTANCES,
|
|
15
|
+
} from "../src/watch/subscription-allocator.js";
|
|
16
|
+
|
|
17
|
+
const GB = 1073741824;
|
|
18
|
+
|
|
19
|
+
/** 构造池子视图(池子顺序 = 数组顺序) */
|
|
20
|
+
function mkView(name, { code = "ok", enabled = true } = {}) {
|
|
21
|
+
const colors = {
|
|
22
|
+
ok: "#4ade80",
|
|
23
|
+
expiring: "#f87171",
|
|
24
|
+
"quota-critical": "#f87171",
|
|
25
|
+
"probe-failed": "#fbbf24",
|
|
26
|
+
unknown: "#6b7280",
|
|
27
|
+
expired: "#f87171",
|
|
28
|
+
exhausted: "#f87171",
|
|
29
|
+
disabled: "#6b7280",
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
id: "sub_" + name,
|
|
33
|
+
name,
|
|
34
|
+
url: `https://${name.toLowerCase()}.airport.example/sub?token=${name}`,
|
|
35
|
+
enabled: code === "disabled" ? false : enabled, // 与服务端口径一致: 禁用订阅 enabled=false
|
|
36
|
+
status: { code, label: code, color: colors[code] || "#6b7280" },
|
|
37
|
+
used: 30 * GB,
|
|
38
|
+
total: 100 * GB,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 全局占用数: 各机器 alloc 里 URL 出现次数(现算口径,与 reconcileAutoAlloc 一致) */
|
|
43
|
+
function usageOf(allocs, exceptIdx = -1) {
|
|
44
|
+
const usage = {};
|
|
45
|
+
allocs.forEach((a, i) => {
|
|
46
|
+
if (i === exceptIdx) return;
|
|
47
|
+
for (const it of a) usage[it.url] = (usage[it.url] || 0) + 1;
|
|
48
|
+
});
|
|
49
|
+
return usage;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 粘性可用性判定(与 isAllocUrlAvailable 同口径: byUrl 含全部池子视图,含死订阅) */
|
|
53
|
+
function mkIsAvail(allViews) {
|
|
54
|
+
const byUrl = new Map(allViews.map((v) => [v.url, v]));
|
|
55
|
+
return (u) => {
|
|
56
|
+
const v = byUrl.get(u);
|
|
57
|
+
return !v || isSubAvailable(v);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const urls = (alloc) => alloc.map((s) => s.url.split("?token=")[1]);
|
|
62
|
+
let passed = 0;
|
|
63
|
+
function t(name, fn) {
|
|
64
|
+
fn();
|
|
65
|
+
passed++;
|
|
66
|
+
console.log(` ✓ ${name}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log("\n[规则2] 可用性判定: 只排除 已禁用/已到期/已耗尽");
|
|
70
|
+
t("禁用/到期/耗尽不可用;临期/告急/探测失败/未知都可用", () => {
|
|
71
|
+
assert.strictEqual(isSubAvailable(mkView("a", { code: "disabled" })), false);
|
|
72
|
+
assert.strictEqual(isSubAvailable(mkView("a", { code: "expired" })), false);
|
|
73
|
+
assert.strictEqual(isSubAvailable(mkView("a", { code: "exhausted" })), false);
|
|
74
|
+
assert.strictEqual(isSubAvailable(mkView("a", { code: "ok" })), true);
|
|
75
|
+
assert.strictEqual(isSubAvailable(mkView("a", { code: "expiring" })), true);
|
|
76
|
+
assert.strictEqual(isSubAvailable(mkView("a", { code: "quota-critical" })), true);
|
|
77
|
+
assert.strictEqual(isSubAvailable(mkView("a", { code: "probe-failed" })), true);
|
|
78
|
+
assert.strictEqual(isSubAvailable(mkView("a", { code: "unknown" })), true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
console.log("\n[规则1/4/5/6/8] 需求文档示例: 池子 [A,B,C,D] 全可用");
|
|
82
|
+
const A = mkView("A");
|
|
83
|
+
const B = mkView("B");
|
|
84
|
+
const C = mkView("C");
|
|
85
|
+
const D = mkView("D");
|
|
86
|
+
const avail4 = [A, B, C, D];
|
|
87
|
+
const isAvail4 = mkIsAvail(avail4);
|
|
88
|
+
|
|
89
|
+
t("F-1 配 3 → [A,B,C](全局占用全 0,按池序)", () => {
|
|
90
|
+
const f1 = allocateSubscriptions({ available: avail4, count: 3, prevAlloc: [], usage: {}, isAvailUrl: isAvail4 });
|
|
91
|
+
assert.deepStrictEqual(urls(f1.alloc), ["A", "B", "C"]);
|
|
92
|
+
assert.strictEqual(f1.warn, null);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const f1 = allocateSubscriptions({ available: avail4, count: 3, prevAlloc: [], usage: {}, isAvailUrl: isAvail4 }).alloc;
|
|
96
|
+
|
|
97
|
+
t("F-2 配 2 → [D,A](D 全局占用 0 优先;A 池序靠前)", () => {
|
|
98
|
+
const f2 = allocateSubscriptions({
|
|
99
|
+
available: avail4, count: 2, prevAlloc: [], usage: usageOf([f1]), isAvailUrl: isAvail4,
|
|
100
|
+
});
|
|
101
|
+
assert.deepStrictEqual(urls(f2.alloc), ["D", "A"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const f2 = allocateSubscriptions({
|
|
105
|
+
available: avail4, count: 2, prevAlloc: [], usage: usageOf([f1]), isAvailUrl: isAvail4,
|
|
106
|
+
}).alloc;
|
|
107
|
+
|
|
108
|
+
t("F-3 配 1 → [B](当前占用最少之一,按池序)", () => {
|
|
109
|
+
const f3 = allocateSubscriptions({
|
|
110
|
+
available: avail4, count: 1, prevAlloc: [], usage: usageOf([f1, f2]), isAvailUrl: isAvail4,
|
|
111
|
+
});
|
|
112
|
+
assert.deepStrictEqual(urls(f3.alloc), ["B"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const f3 = allocateSubscriptions({
|
|
116
|
+
available: avail4, count: 1, prevAlloc: [], usage: usageOf([f1, f2]), isAvailUrl: isAvail4,
|
|
117
|
+
}).alloc;
|
|
118
|
+
|
|
119
|
+
console.log("\n[规则3] B 到期失效: 只有持 B 的槽位换血,其余纹丝不动");
|
|
120
|
+
const availNoB = [A, C, D]; // 可用集(B 已到期出列)
|
|
121
|
+
const isAvailNoB = mkIsAvail([A, mkView("B", { code: "expired" }), C, D]); // byUrl 含死的 B
|
|
122
|
+
|
|
123
|
+
t("F-1 槽2(B) 换血 → [A,D,C](补全局最少占用的 D)", () => {
|
|
124
|
+
const f1b = allocateSubscriptions({
|
|
125
|
+
available: availNoB, count: 3, prevAlloc: f1, usage: usageOf([f2]), isAvailUrl: isAvailNoB,
|
|
126
|
+
});
|
|
127
|
+
assert.deepStrictEqual(urls(f1b.alloc), ["A", "D", "C"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const f1b = allocateSubscriptions({
|
|
131
|
+
available: availNoB, count: 3, prevAlloc: f1, usage: usageOf([f2]), isAvailUrl: isAvailNoB,
|
|
132
|
+
}).alloc;
|
|
133
|
+
|
|
134
|
+
t("F-2 不含 B → 纹丝不动 [D,A]", () => {
|
|
135
|
+
const f2b = allocateSubscriptions({
|
|
136
|
+
available: availNoB, count: 2, prevAlloc: f2, usage: usageOf([f1b]), isAvailUrl: isAvailNoB,
|
|
137
|
+
});
|
|
138
|
+
assert.deepStrictEqual(urls(f2b.alloc), ["D", "A"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
t("F-3 槽1(B) 换血 → [C](C/D 占用并列,按池序取 C)", () => {
|
|
142
|
+
const f3b = allocateSubscriptions({
|
|
143
|
+
available: availNoB, count: 1, prevAlloc: f3, usage: usageOf([f1b, f2]), isAvailUrl: isAvailNoB,
|
|
144
|
+
});
|
|
145
|
+
assert.deepStrictEqual(urls(f3b.alloc), ["C"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
console.log("\n[规则1/6] 极端: 可用只剩 A → 回绕重复配满");
|
|
149
|
+
const availOnlyA = [A];
|
|
150
|
+
// B/C/D 均确凿死亡(byUrl 含全量死视图)
|
|
151
|
+
const isAvailOnlyA = mkIsAvail([
|
|
152
|
+
A,
|
|
153
|
+
mkView("B", { code: "expired" }),
|
|
154
|
+
mkView("C", { code: "exhausted" }),
|
|
155
|
+
mkView("D", { code: "disabled" }),
|
|
156
|
+
]);
|
|
157
|
+
t("F-1 = A,A,A;F-2 = A,A(死槽也回绕补满)", () => {
|
|
158
|
+
const x1 = allocateSubscriptions({ available: availOnlyA, count: 3, prevAlloc: f1, usage: usageOf([f2, f3]), isAvailUrl: isAvailOnlyA });
|
|
159
|
+
assert.deepStrictEqual(urls(x1.alloc), ["A", "A", "A"]);
|
|
160
|
+
const x2 = allocateSubscriptions({ available: availOnlyA, count: 2, prevAlloc: f2, usage: usageOf([x1.alloc, f3]), isAvailUrl: isAvailOnlyA });
|
|
161
|
+
assert.deepStrictEqual(urls(x2.alloc), ["A", "A"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
console.log("\n[规则3] 存量 URL 不在池子(迁移无感): 视为未知 → 原位保留");
|
|
165
|
+
t("非池子 URL 粘性保留", () => {
|
|
166
|
+
const legacy = { name: "legacy", url: "https://legacy.example/sub?token=X" };
|
|
167
|
+
const r = allocateSubscriptions({
|
|
168
|
+
available: avail4, count: 1, prevAlloc: [legacy], usage: {}, isAvailUrl: (u) => u === legacy.url,
|
|
169
|
+
});
|
|
170
|
+
assert.deepStrictEqual(r.alloc, [legacy]); // 原对象字段原样保留
|
|
171
|
+
assert.strictEqual(r.warn, null);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
console.log("\n[规则7] 空保护: 可用集为空 → alloc 原地不动 + 标黄");
|
|
175
|
+
t("有空槽补不上 → 原样返回存量 + warn=pool-empty", () => {
|
|
176
|
+
const prev = [{ name: "x", url: "https://x.example/1" }, { name: "y", url: "https://y.example/2" }];
|
|
177
|
+
const r = allocateSubscriptions({ available: [], count: 3, prevAlloc: prev, usage: {}, isAvailUrl: () => false });
|
|
178
|
+
assert.deepStrictEqual(r.alloc, prev, "绝不换血也不下发空列表");
|
|
179
|
+
assert.strictEqual(r.warn, "pool-empty");
|
|
180
|
+
});
|
|
181
|
+
t("存量槽全部粘性可用(纯保留)→ 不算告警", () => {
|
|
182
|
+
const prev = [{ name: "x", url: "https://x.example/1" }];
|
|
183
|
+
const r = allocateSubscriptions({ available: [], count: 1, prevAlloc: prev, usage: {}, isAvailUrl: () => true });
|
|
184
|
+
assert.deepStrictEqual(r.alloc, prev);
|
|
185
|
+
assert.strictEqual(r.warn, null);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
console.log("\n[规则1] count 缩小: 砍尾部实例");
|
|
189
|
+
t("5 槽缩到 2 → 保留前 2 个槽位", () => {
|
|
190
|
+
const prev = ["v", "w", "x", "y", "z"].map((n) => ({ name: n, url: `https://${n}.example/sub?token=${n}` }));
|
|
191
|
+
const r = allocateSubscriptions({
|
|
192
|
+
available: avail4, count: 2, prevAlloc: prev, usage: {}, isAvailUrl: () => true,
|
|
193
|
+
});
|
|
194
|
+
assert.deepStrictEqual(urls(r.alloc), ["v", "w"]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
console.log("\n[规则8] 决定性: 输入不变 → 输出恒等(心跳重跑零写盘的前提)");
|
|
198
|
+
t("重复计算结果一致;对结果再分配幂等", () => {
|
|
199
|
+
const args = { available: avail4, count: 4, prevAlloc: f1, usage: usageOf([f2, f3]), isAvailUrl: isAvail4 };
|
|
200
|
+
const r1 = allocateSubscriptions(args);
|
|
201
|
+
const r2 = allocateSubscriptions(args);
|
|
202
|
+
assert.deepStrictEqual(r1, r2, "同输入同输出");
|
|
203
|
+
const again = allocateSubscriptions({ ...args, prevAlloc: r1.alloc });
|
|
204
|
+
assert.deepStrictEqual(again.alloc, r1.alloc, "粘性: 对结果重算不再变化");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
t("count 超上限钳制到 8(实例端口段上限)", () => {
|
|
208
|
+
const r = allocateSubscriptions({ available: [A], count: 99, prevAlloc: [], usage: {}, isAvailUrl: isAvail4 });
|
|
209
|
+
assert.strictEqual(r.alloc.length, ALLOC_MAX_INSTANCES);
|
|
210
|
+
assert.ok(r.alloc.every((s) => s.url === A.url));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
t("空池 + 空存量 → 返回空列表 + pool-empty(无害兜底)", () => {
|
|
214
|
+
const r = allocateSubscriptions({ available: [], count: 2, prevAlloc: [], usage: {}, isAvailUrl: () => false });
|
|
215
|
+
assert.deepStrictEqual(r.alloc, []);
|
|
216
|
+
assert.strictEqual(r.warn, "pool-empty");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
console.log(`\n订阅分配器单元测试通过 ✅ (${passed} 用例)\n`);
|
|
220
|
+
process.exit(0);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 订阅自动分配 UI 冒烟测试 — 真实 watch 服务 + 无头 Edge
|
|
3
|
+
*
|
|
4
|
+
* 流程:
|
|
5
|
+
* 0. 临时 HOME + 预置订阅池(A/B/C/D)+ F-1(auto, count=3) / F-2(manual) 配置
|
|
6
|
+
* 1. 起 watch 服务(动态端口)+ 伪造两台客户端心跳上线
|
|
7
|
+
* 2. F-1 卡片「配置 ⚙️」→ 弹窗应为自动分配视图(数量输入 + alloc 端口映射表 + 徽标)
|
|
8
|
+
* 3. F-2 卡片 → 弹窗应为手动视图(textarea + 切换为自动入口)
|
|
9
|
+
* 4. 手动视图点「⚡ 切换为自动分配」→ 服务端迁移 + 弹窗切到自动视图
|
|
10
|
+
* 5. 截图落盘 /tmp/suballoc-ui-*.png
|
|
11
|
+
*
|
|
12
|
+
* 运行: node scripts/test-subscription-auto-alloc-ui.mjs(自行起停服务)
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "child_process";
|
|
15
|
+
import { mkdtempSync, writeFileSync } from "fs";
|
|
16
|
+
import { tmpdir } from "os";
|
|
17
|
+
import { join, dirname } from "path";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
import { chromium } from "playwright";
|
|
20
|
+
|
|
21
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
|
+
const HOME = mkdtempSync(join(tmpdir(), "suballoc-ui-"));
|
|
23
|
+
const PORT = 3127;
|
|
24
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
25
|
+
const GB = 1073741824;
|
|
26
|
+
|
|
27
|
+
// mock 机场(探测/代报都用不上,仅池子状态 unknown=可用即可分配)
|
|
28
|
+
const subUrl = (n) => `https://${n.toLowerCase()}.airport.example/sub?token=${n}`;
|
|
29
|
+
|
|
30
|
+
// ── 预置配置: F-1 auto count=3(alloc: LEGACY+A+C),F-2 手动 1 条 ──
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
writeFileSync(
|
|
33
|
+
join(HOME, ".tt-help-watchdog-subs.json"),
|
|
34
|
+
JSON.stringify(
|
|
35
|
+
{
|
|
36
|
+
subscriptions: ["A", "B", "C", "D"].map((n, i) => ({
|
|
37
|
+
id: "sub_" + n.toLowerCase(),
|
|
38
|
+
name: `订阅${n}`,
|
|
39
|
+
url: subUrl(n),
|
|
40
|
+
note: "",
|
|
41
|
+
enabled: true,
|
|
42
|
+
addedAt: now,
|
|
43
|
+
updatedAt: now,
|
|
44
|
+
probe: {
|
|
45
|
+
at: now,
|
|
46
|
+
ok: true,
|
|
47
|
+
httpCode: 200,
|
|
48
|
+
error: null,
|
|
49
|
+
upload: 0,
|
|
50
|
+
download: 30 * GB,
|
|
51
|
+
total: 100 * GB,
|
|
52
|
+
expire: Math.floor(now / 1000) + 20 * 86400,
|
|
53
|
+
suspiciousBody: false,
|
|
54
|
+
},
|
|
55
|
+
lastNodeReport: null,
|
|
56
|
+
history: [],
|
|
57
|
+
resets: [],
|
|
58
|
+
_i: i,
|
|
59
|
+
})),
|
|
60
|
+
},
|
|
61
|
+
null,
|
|
62
|
+
2,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
writeFileSync(
|
|
66
|
+
join(HOME, ".tt-help-watchdog.json"),
|
|
67
|
+
JSON.stringify(
|
|
68
|
+
{
|
|
69
|
+
proxySettings: {
|
|
70
|
+
"F-1": {
|
|
71
|
+
mode: "auto",
|
|
72
|
+
count: 3,
|
|
73
|
+
subscriptions: [
|
|
74
|
+
{ name: "", url: "https://legacy-airport.example/sub?token=OLD" },
|
|
75
|
+
{ name: "", url: subUrl("A") },
|
|
76
|
+
{ name: "", url: subUrl("C") },
|
|
77
|
+
],
|
|
78
|
+
allocWarn: null,
|
|
79
|
+
allocatedAt: now,
|
|
80
|
+
},
|
|
81
|
+
"F-2": { subscriptions: [{ name: "", url: subUrl("B") }] },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
null,
|
|
85
|
+
2,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ── 起 watch 服务 ──
|
|
90
|
+
const srv = spawn("node", [join(root, "src", "main.js"), "watch", "-o", join(HOME, "t.db"), "-p", String(PORT)], {
|
|
91
|
+
cwd: root,
|
|
92
|
+
env: { ...process.env, HOME },
|
|
93
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
94
|
+
});
|
|
95
|
+
srv.stdout.on("data", () => {});
|
|
96
|
+
srv.stderr.on("data", (d) => process.stderr.write(`[srv] ${d}`));
|
|
97
|
+
const srvReady = new Promise((resolve, reject) => {
|
|
98
|
+
const timer = setTimeout(() => reject(new Error("服务 15s 未就绪")), 15000);
|
|
99
|
+
srv.stderr.on("data", (d) => {
|
|
100
|
+
if (String(d).includes("监控服务已启动")) {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
resolve();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
await srvReady;
|
|
107
|
+
console.log("✓ watch 服务已启动");
|
|
108
|
+
|
|
109
|
+
// ── 伪造心跳上线(F-1 / F-2) ──
|
|
110
|
+
for (const u of ["F-1", "F-2"]) {
|
|
111
|
+
const r = await fetch(`${BASE}/api/watchdog/heartbeat?clientId=${u}@host`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ hostname: "host-" + u, config: { userId: u }, processes: [] }),
|
|
115
|
+
});
|
|
116
|
+
if (!r.ok) throw new Error("心跳失败 " + r.status);
|
|
117
|
+
}
|
|
118
|
+
console.log("✓ F-1/F-2 心跳上线");
|
|
119
|
+
// 触发 F-1 分配器重算(心跳路径)——顺便让服务端把池子状态应用一遍
|
|
120
|
+
|
|
121
|
+
const browser = await chromium.launch({
|
|
122
|
+
executablePath: "/usr/bin/microsoft-edge",
|
|
123
|
+
headless: true,
|
|
124
|
+
});
|
|
125
|
+
const ctx = await browser.newContext({ viewport: { width: 1560, height: 1000 }, deviceScaleFactor: 1.5 });
|
|
126
|
+
const page = await ctx.newPage();
|
|
127
|
+
page.on("dialog", (d) => d.accept());
|
|
128
|
+
const errors = [];
|
|
129
|
+
page.on("pageerror", (e) => errors.push(String(e)));
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
// 登录
|
|
133
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
134
|
+
const d = new Date();
|
|
135
|
+
await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
|
|
136
|
+
await page.fill("#password", `tt@${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`);
|
|
137
|
+
const loginResp = page.waitForResponse((r) => r.url().includes("/api/login"), { timeout: 15000 });
|
|
138
|
+
await page.locator("#login-form").evaluate((f) => f.submit());
|
|
139
|
+
await loginResp;
|
|
140
|
+
await page.waitForTimeout(600);
|
|
141
|
+
console.log("✓ 已登录");
|
|
142
|
+
|
|
143
|
+
// 看门狗页 → 等客户端卡片出现
|
|
144
|
+
await page.goto(BASE + "/#watchdog", { waitUntil: "domcontentloaded" });
|
|
145
|
+
await page.waitForFunction(
|
|
146
|
+
() => document.body.textContent.includes("F-1") && document.body.textContent.includes("⚡自动"),
|
|
147
|
+
{ timeout: 20000 },
|
|
148
|
+
);
|
|
149
|
+
console.log("✓ 客户端卡片渲染,F-1 显示 ⚡自动 徽标");
|
|
150
|
+
|
|
151
|
+
// ── F-1: 自动分配视图 ──
|
|
152
|
+
const cards = page.locator("div", { hasText: "F-1" });
|
|
153
|
+
const f1Btn = page.locator('button[onclick*="wdShowProxyModal"]').first();
|
|
154
|
+
await f1Btn.click();
|
|
155
|
+
await page.waitForFunction(() => {
|
|
156
|
+
const v = document.getElementById("wdProxyAutoView");
|
|
157
|
+
return v && v.style.display === "block" && document.getElementById("wdProxyAllocTable").children.length > 0;
|
|
158
|
+
}, { timeout: 5000 });
|
|
159
|
+
const countVal = await page.inputValue("#wdProxyCountInput");
|
|
160
|
+
if (countVal !== "3") throw new Error("F-1 数量输入应为 3,实际 " + countVal);
|
|
161
|
+
const tableText = await page.textContent("#wdProxyAllocTable");
|
|
162
|
+
for (const expect of ["实例1", "实例2", "实例3", "7897", "7899", "LEGACY".replace("LEGACY", "legacy-airport"), "订阅A", "订阅C"]) {
|
|
163
|
+
if (!tableText.includes(expect)) throw new Error(`alloc 表缺少: ${expect}\n---\n${tableText}`);
|
|
164
|
+
}
|
|
165
|
+
if (!tableText.includes("未入池")) throw new Error("非池子存量 URL 应标注「未入池」");
|
|
166
|
+
const manualHidden = await page.isHidden("#wdProxySubsText");
|
|
167
|
+
if (!manualHidden) throw new Error("auto 模式下 textarea 应隐藏");
|
|
168
|
+
await page.screenshot({ path: "/tmp/suballoc-ui-1-auto.png" });
|
|
169
|
+
console.log("✓ F-1 自动分配视图: 数量输入=3,alloc 表含端口映射/订阅名/未入池标注(截图1)");
|
|
170
|
+
|
|
171
|
+
// 切回手动(确认弹窗自动接受)→ textarea 出现
|
|
172
|
+
await page.click("#wdProxySwitchManualBtn");
|
|
173
|
+
await page.waitForFunction(() => !document.getElementById("wdProxySubsText").hidden && document.getElementById("wdProxySubsText").style.display !== "none", { timeout: 5000 });
|
|
174
|
+
const taVal = await page.inputValue("#wdProxySubsText");
|
|
175
|
+
if (!taVal.includes("legacy-airport")) throw new Error("切回手动后列表应包含存量 URL");
|
|
176
|
+
console.log("✓ 切回手动视图: textarea 展示且包含当前 alloc");
|
|
177
|
+
await page.screenshot({ path: "/tmp/suballoc-ui-2-manual.png" });
|
|
178
|
+
|
|
179
|
+
// 切回自动(恢复初始状态供后续用例)— 用 API 直接切,避免 confirm 时序
|
|
180
|
+
await page.evaluate(() => fetch("/api/watchdog/set-proxy-auto", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: "F-1" }) }));
|
|
181
|
+
await page.waitForTimeout(400);
|
|
182
|
+
|
|
183
|
+
// 关闭弹窗(取消按钮无 id,用 evaluate)
|
|
184
|
+
await page.evaluate(() => { document.getElementById("wdProxyModal").style.display = "none"; });
|
|
185
|
+
|
|
186
|
+
// ── F-2: 手动视图 + 切自动入口 ──
|
|
187
|
+
const f2Btn = page.locator('button[onclick*="wdShowProxyModal(\'' + "F-2" + '\')"]');
|
|
188
|
+
await f2Btn.click();
|
|
189
|
+
await page.waitForFunction(() => document.getElementById("wdProxySubsText").style.display !== "none", { timeout: 5000 });
|
|
190
|
+
const autoBtnVisible = await page.isVisible("#wdProxySwitchAutoBtn");
|
|
191
|
+
if (!autoBtnVisible) throw new Error("手动视图应有「切换为自动分配」入口");
|
|
192
|
+
await page.screenshot({ path: "/tmp/suballoc-ui-3-f2-manual.png" });
|
|
193
|
+
console.log("✓ F-2 手动视图 + 切自动入口(截图3)");
|
|
194
|
+
|
|
195
|
+
// 点切换 → confirm 自动接受 → 应变自动视图
|
|
196
|
+
await page.click("#wdProxySwitchAutoBtn");
|
|
197
|
+
await page.waitForFunction(() => document.getElementById("wdProxyAutoView").style.display === "block", { timeout: 5000 });
|
|
198
|
+
const f2ps = await (await fetch(`${BASE}/api/watchdog/clients`, { headers: { cookie: await ctx.cookies().then((cs) => cs.map((c) => `${c.name}=${c.value}`).join("; ")) } })).json();
|
|
199
|
+
if (f2ps.proxySettings["F-2"].mode !== "auto") throw new Error("F-2 应已迁移为 auto");
|
|
200
|
+
console.log("✓ F-2 一键迁移: 弹窗切到自动视图,服务端 mode=auto");
|
|
201
|
+
|
|
202
|
+
if (errors.length) throw new Error("页面 JS 错误: " + errors.join(" | "));
|
|
203
|
+
console.log("\n订阅自动分配 UI 冒烟测试通过 ✅\n");
|
|
204
|
+
} finally {
|
|
205
|
+
await browser.close().catch(() => {});
|
|
206
|
+
srv.kill("SIGKILL");
|
|
207
|
+
}
|
|
208
|
+
process.exit(0);
|