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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 订阅自动分配(Phase 2)端到端测试 — 直接驱动 watchdog-server 路由
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* 1. 迁移方案 A: 手动列表原样转 auto alloc(count=现状条数,切换无感,非池子 URL 保留)
|
|
6
|
+
* 2. 心跳惰性重算 + 同一心跳下发新 alloc(响应 proxySettings 即最新分配)
|
|
7
|
+
* 3. count 调整: 补槽从池子取(规则1/4/5)、缩槽砍尾(规则1)
|
|
8
|
+
* 4. 跨机器错开: F-2/F-3 按全局占用数选订阅(规则5,文档示例复刻)
|
|
9
|
+
* 5. 死订阅换血: 禁用后被粘性槽位自动替换(规则2/3)
|
|
10
|
+
* 6. 空保护: 池子可用为空 → alloc 原地不动 + allocWarn=pool-empty(规则7)
|
|
11
|
+
* 7. 决定性: 重复心跳不重写(allocatedAt 不变,规则8)
|
|
12
|
+
* 8. count>8 截断 + count-capped 标红持久
|
|
13
|
+
* 9. 切回手动: mode 丢弃,分配器不再触碰
|
|
14
|
+
*
|
|
15
|
+
* 运行: HOME=$(mktemp -d) node scripts/test-subscription-auto-alloc.mjs
|
|
16
|
+
*/
|
|
17
|
+
import http from "http";
|
|
18
|
+
import assert from "assert";
|
|
19
|
+
import { writeFileSync } from "fs";
|
|
20
|
+
import { join, dirname } from "path";
|
|
21
|
+
import { fileURLToPath } from "url";
|
|
22
|
+
|
|
23
|
+
const HOME = process.env.HOME;
|
|
24
|
+
if (!HOME || HOME === "/") {
|
|
25
|
+
console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-subscription-auto-alloc.mjs");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const GB = 1073741824;
|
|
30
|
+
|
|
31
|
+
// ── mock 机场: 按订阅名返回不同 subscription-userinfo ──
|
|
32
|
+
const mock = http.createServer((req, res) => {
|
|
33
|
+
const name = new URL(req.url, "http://x").searchParams.get("name") || "A";
|
|
34
|
+
const expire =
|
|
35
|
+
name === "B"
|
|
36
|
+
? Math.floor(Date.now() / 1000) - 86400 // B: 已到期
|
|
37
|
+
: Math.floor(Date.now() / 1000) + 20 * 86400;
|
|
38
|
+
res.writeHead(200, {
|
|
39
|
+
"subscription-userinfo": `upload=0; download=${30 * GB}; total=${100 * GB}; expire=${expire}`,
|
|
40
|
+
});
|
|
41
|
+
res.end("proxies: []");
|
|
42
|
+
});
|
|
43
|
+
await new Promise((r) => mock.listen(0, "127.0.0.1", r));
|
|
44
|
+
const mockBase = `http://127.0.0.1:${mock.address().port}/sub`;
|
|
45
|
+
const subUrl = (n) => `${mockBase}?name=${n}&token=tok-${n}`;
|
|
46
|
+
const LEGACY_URL = "https://legacy-airport.example/sub?token=OLD"; // 不入池的存量订阅
|
|
47
|
+
|
|
48
|
+
// ── 路由驱动(不起真实 HTTP 服务,与订阅中心 e2e 同款) ──
|
|
49
|
+
const wdMod = await import(
|
|
50
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "src", "watch", "watchdog-server.js")
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
function mockRes() {
|
|
54
|
+
return {
|
|
55
|
+
code: 0,
|
|
56
|
+
body: null,
|
|
57
|
+
writeHead(c) {
|
|
58
|
+
this.code = c;
|
|
59
|
+
return this;
|
|
60
|
+
},
|
|
61
|
+
end(b) {
|
|
62
|
+
this.body = b ? JSON.parse(b) : null;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function route(method, path, params, body) {
|
|
67
|
+
const res = mockRes();
|
|
68
|
+
const handled = await wdMod.tryHandleWatchdogRoute(path, method, params || {}, body || {}, res);
|
|
69
|
+
assert.ok(handled, "路由应被处理: " + method + " " + path);
|
|
70
|
+
return res;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const urlsOf = (entry) => (entry.subscriptions || []).map((s) => s.url);
|
|
74
|
+
const short = (u) => (u.includes("legacy") ? "LEGACY" : new URL(u).searchParams.get("name"));
|
|
75
|
+
|
|
76
|
+
let passed = 0;
|
|
77
|
+
async function t(name, fn) {
|
|
78
|
+
await fn();
|
|
79
|
+
passed++;
|
|
80
|
+
console.log(` ✓ ${name}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 准备: 池子入 4 条(A/B/C/D 池序),B 探测为已到期(退出可用集,规则2) ──
|
|
84
|
+
for (const n of ["A", "B", "C", "D"]) {
|
|
85
|
+
const add = await route("POST", "/api/watchdog/subscriptions", {}, { name: `订阅${n}`, url: subUrl(n) });
|
|
86
|
+
assert.strictEqual(add.code, 200, `入池 ${n}`);
|
|
87
|
+
}
|
|
88
|
+
const clients0 = await route("GET", "/api/watchdog/clients", {}, {});
|
|
89
|
+
const subB = clients0.body.subscriptions.find((s) => s.url === subUrl("B"));
|
|
90
|
+
const probeB = await route("POST", "/api/watchdog/subscriptions/probe", {}, { id: subB.id });
|
|
91
|
+
assert.strictEqual(probeB.code, 200);
|
|
92
|
+
assert.strictEqual(probeB.body.subscription.status.code, "expired", "B 探测后应判定已到期");
|
|
93
|
+
|
|
94
|
+
// ── 1. 迁移方案 A: F-1 手动 [LEGACY, A] → auto(count=2,原样保留,无感切换) ──
|
|
95
|
+
await t("迁移: 手动列表原样转 auto alloc,非池子 URL 粘性保留", async () => {
|
|
96
|
+
const set = await route("POST", "/api/watchdog/set-proxy-settings", {}, {
|
|
97
|
+
userId: "F-1",
|
|
98
|
+
subscriptions: [LEGACY_URL, subUrl("A")],
|
|
99
|
+
});
|
|
100
|
+
assert.strictEqual(set.code, 200);
|
|
101
|
+
assert.strictEqual(set.body.subscriptions.length, 2, "手动模式写入");
|
|
102
|
+
|
|
103
|
+
const auto = await route("POST", "/api/watchdog/set-proxy-auto", {}, { userId: "F-1" });
|
|
104
|
+
assert.strictEqual(auto.code, 200);
|
|
105
|
+
const ps = auto.body.proxySettings;
|
|
106
|
+
assert.strictEqual(ps.mode, "auto");
|
|
107
|
+
assert.strictEqual(ps.count, 2, "count 默认 = 现状条数");
|
|
108
|
+
assert.deepStrictEqual(ps.subscriptions.map((s) => s.url), [LEGACY_URL, subUrl("A")], "切换瞬间 = 现状,无感");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── 2. 心跳惰性重算(存量全可用 → 恒等)+ 同心跳下发 ──
|
|
112
|
+
async function heartbeat(userId) {
|
|
113
|
+
const hb = await route("POST", "/api/watchdog/heartbeat", { clientId: `${userId}@host` }, {
|
|
114
|
+
hostname: "host-" + userId,
|
|
115
|
+
config: { userId },
|
|
116
|
+
processes: [],
|
|
117
|
+
});
|
|
118
|
+
assert.strictEqual(hb.code, 200);
|
|
119
|
+
return hb.body.proxySettings;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await t("心跳: 存量可用 → alloc 恒等;响应即携带最新 alloc", async () => {
|
|
123
|
+
const ps = await heartbeat("F-1");
|
|
124
|
+
assert.strictEqual(ps.mode, "auto");
|
|
125
|
+
assert.deepStrictEqual(ps.subscriptions.map((s) => s.url), [LEGACY_URL, subUrl("A")]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── 3. count 调整: 3 槽 → 空槽从池子按序取 C(B 已到期被排除) ──
|
|
129
|
+
await t("扩容 3 槽: 粘性保留 + 空槽补池序可用订阅(跳过已到期 B)", async () => {
|
|
130
|
+
const r = await route("POST", "/api/watchdog/set-proxy-auto", {}, { userId: "F-1", count: 3 });
|
|
131
|
+
assert.strictEqual(r.code, 200);
|
|
132
|
+
assert.deepStrictEqual(
|
|
133
|
+
r.body.proxySettings.subscriptions.map((s) => short(s.url)),
|
|
134
|
+
["LEGACY", "A", "C"],
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── 4. 跨机器错开(规则5,文档示例复刻: F-2=[D,A],F-3=[C]——B 已到期出列) ──
|
|
139
|
+
await t("F-2 配 2 → [D,A]: D 全局占用 0 优先,A 池序靠前", async () => {
|
|
140
|
+
const r = await route("POST", "/api/watchdog/set-proxy-auto", {}, { userId: "F-2", count: 2 });
|
|
141
|
+
assert.strictEqual(r.code, 200);
|
|
142
|
+
assert.deepStrictEqual(
|
|
143
|
+
r.body.proxySettings.subscriptions.map((s) => short(s.url)),
|
|
144
|
+
["D", "A"],
|
|
145
|
+
"文档示例: A/C 各占 1 次(F-1),D 占 0 次优先;再补一槽回绕到 A",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await t("F-3 配 1 → [C]: 全局占用最少之一按池序", async () => {
|
|
150
|
+
const r = await route("POST", "/api/watchdog/set-proxy-auto", {}, { userId: "F-3", count: 1 });
|
|
151
|
+
assert.strictEqual(r.code, 200);
|
|
152
|
+
assert.deepStrictEqual(
|
|
153
|
+
r.body.proxySettings.subscriptions.map((s) => short(s.url)),
|
|
154
|
+
["C"],
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── 5. F-1 扩到 5 槽: 回绕重复合法(规则1/6) ──
|
|
159
|
+
await t("F-1 扩 5 槽 → [LEGACY,A,C,D,A]: 回绕按全局占用+池序", async () => {
|
|
160
|
+
const r = await route("POST", "/api/watchdog/set-proxy-auto", {}, { userId: "F-1", count: 5 });
|
|
161
|
+
assert.strictEqual(r.code, 200);
|
|
162
|
+
assert.deepStrictEqual(
|
|
163
|
+
r.body.proxySettings.subscriptions.map((s) => short(s.url)),
|
|
164
|
+
["LEGACY", "A", "C", "D", "A"],
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── 6. 缩槽砍尾(规则1) ──
|
|
169
|
+
await t("F-1 缩到 2 槽 → 保留前 2 槽 [LEGACY,A]", async () => {
|
|
170
|
+
const r = await route("POST", "/api/watchdog/set-proxy-auto", {}, { userId: "F-1", count: 2 });
|
|
171
|
+
assert.strictEqual(r.code, 200);
|
|
172
|
+
assert.deepStrictEqual(
|
|
173
|
+
r.body.proxySettings.subscriptions.map((s) => short(s.url)),
|
|
174
|
+
["LEGACY", "A"],
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── 7. 死订阅换血(规则2/3): 禁用 A → 所有持 A 死槽的机器各自换血,活槽纹丝不动 ──
|
|
179
|
+
await t("禁用 A: F-1 死槽换血 → [LEGACY,C];F-2 死槽换血 → [D,C];F-3 不动", async () => {
|
|
180
|
+
const cl = await route("GET", "/api/watchdog/clients", {}, {});
|
|
181
|
+
const subA = cl.body.subscriptions.find((s) => s.url === subUrl("A"));
|
|
182
|
+
const dis = await route("POST", "/api/watchdog/subscriptions/update", {}, { id: subA.id, enabled: false });
|
|
183
|
+
assert.strictEqual(dis.code, 200);
|
|
184
|
+
|
|
185
|
+
const ps1 = await heartbeat("F-1");
|
|
186
|
+
assert.deepStrictEqual(ps1.subscriptions.map((s) => short(s.url)), ["LEGACY", "C"], "A 被禁用 → 心跳换血为 C(池序在 D 前)");
|
|
187
|
+
assert.strictEqual(ps1.allocWarn, null);
|
|
188
|
+
|
|
189
|
+
const ps2 = await heartbeat("F-2");
|
|
190
|
+
assert.deepStrictEqual(ps2.subscriptions.map((s) => short(s.url)), ["D", "C"], "F-2 的 A 死槽同样换血(全局占用算定: C 剩余占用最少)");
|
|
191
|
+
|
|
192
|
+
const ps3 = await heartbeat("F-3");
|
|
193
|
+
assert.deepStrictEqual(ps3.subscriptions.map((s) => short(s.url)), ["C"], "F-3 持活的 C → 纹丝不动");
|
|
194
|
+
assert.strictEqual(ps3.allocWarn, null);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── 8. 空保护(规则7): C/D 也禁用 → 池子可用为空,alloc 原地不动 + 标黄 ──
|
|
198
|
+
await t("池子可用为空: alloc 原地不动 + allocWarn=pool-empty", async () => {
|
|
199
|
+
const cl = await route("GET", "/api/watchdog/clients", {}, {});
|
|
200
|
+
for (const n of ["C", "D"]) {
|
|
201
|
+
const sub = cl.body.subscriptions.find((s) => s.url === subUrl(n));
|
|
202
|
+
const dis = await route("POST", "/api/watchdog/subscriptions/update", {}, { id: sub.id, enabled: false });
|
|
203
|
+
assert.strictEqual(dis.code, 200);
|
|
204
|
+
}
|
|
205
|
+
const ps2 = await heartbeat("F-2");
|
|
206
|
+
assert.deepStrictEqual(ps2.subscriptions.map((s) => short(s.url)), ["D", "C"], "死槽也原地不动(绝不下发空列表/不换血)");
|
|
207
|
+
assert.strictEqual(ps2.allocWarn, "pool-empty", "标黄");
|
|
208
|
+
const ps3 = await heartbeat("F-3");
|
|
209
|
+
assert.deepStrictEqual(ps3.subscriptions.map((s) => short(s.url)), ["C"]);
|
|
210
|
+
assert.strictEqual(ps3.allocWarn, "pool-empty");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ── 9. 决定性(规则8): 重复心跳不重写 ──
|
|
214
|
+
await t("重复心跳: alloc/allocatedAt 恒等(零写盘)", async () => {
|
|
215
|
+
const before = (await heartbeat("F-3")).allocatedAt;
|
|
216
|
+
assert.ok(before, "已有 allocatedAt");
|
|
217
|
+
const mid = await heartbeat("F-3");
|
|
218
|
+
assert.strictEqual(mid.allocatedAt, before, "输入不变 → 不重写");
|
|
219
|
+
const cl = await route("GET", "/api/watchdog/clients", {}, {});
|
|
220
|
+
assert.strictEqual(cl.body.proxySettings["F-3"].allocatedAt, before, "落盘值一致");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── 10. count>8: 截断 + 标红持久(先启用 A 让池子有可用订阅) ──
|
|
224
|
+
await t("count=12 截断为 8 + count-capped 标红(心跳不丢)", async () => {
|
|
225
|
+
const cl = await route("GET", "/api/watchdog/clients", {}, {});
|
|
226
|
+
const subA = cl.body.subscriptions.find((s) => s.url === subUrl("A"));
|
|
227
|
+
const en = await route("POST", "/api/watchdog/subscriptions/update", {}, { id: subA.id, enabled: true });
|
|
228
|
+
assert.strictEqual(en.code, 200);
|
|
229
|
+
|
|
230
|
+
const r = await route("POST", "/api/watchdog/set-proxy-auto", {}, { userId: "F-1", count: 12 });
|
|
231
|
+
assert.strictEqual(r.code, 200);
|
|
232
|
+
const ps = r.body.proxySettings;
|
|
233
|
+
assert.strictEqual(ps.count, 8, "截断为实例上限");
|
|
234
|
+
assert.strictEqual(ps.allocWarn, "count-capped", "标红");
|
|
235
|
+
assert.strictEqual(ps.subscriptions.length, 8, "配满规则: count=8 → 8 槽(C 死槽换血 + 空槽回绕补 A)");
|
|
236
|
+
assert.deepStrictEqual(
|
|
237
|
+
ps.subscriptions.map((s) => short(s.url)),
|
|
238
|
+
["LEGACY", ...Array(7).fill("A")],
|
|
239
|
+
);
|
|
240
|
+
const ps2 = await heartbeat("F-1");
|
|
241
|
+
assert.strictEqual(ps2.count, 8);
|
|
242
|
+
assert.strictEqual(ps2.allocWarn, "count-capped", "下轮心跳标红不丢(countCapped 标志持久化)");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ── 11. 切回手动 ──
|
|
246
|
+
await t("切回手动: mode 丢弃,心跳不再触碰", async () => {
|
|
247
|
+
const before = await route("POST", "/api/watchdog/set-proxy-manual", {}, { userId: "F-1" });
|
|
248
|
+
assert.strictEqual(before.code, 200);
|
|
249
|
+
assert.strictEqual(before.body.proxySettings.mode, undefined);
|
|
250
|
+
assert.strictEqual(before.body.proxySettings.count, undefined);
|
|
251
|
+
assert.ok(Array.isArray(before.body.proxySettings.subscriptions), "alloc 原样变手动列表");
|
|
252
|
+
const ps = await heartbeat("F-1");
|
|
253
|
+
assert.strictEqual(ps.mode, undefined, "心跳不回写 mode");
|
|
254
|
+
assert.deepStrictEqual(ps.subscriptions, before.body.proxySettings.subscriptions);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
mock.close();
|
|
258
|
+
console.log(`\n订阅自动分配端到端测试通过 ✅ (${passed} 用例)\n`);
|
|
259
|
+
process.exit(0);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 订阅中心 API 端到端测试 — 直接驱动 watchdog-server 路由(不起真实 HTTP 服务)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* 1. POST /api/watchdog/heartbeat → 节点代报采集 + worker 惰性启动
|
|
6
|
+
* 2. GET /api/watchdog/clients → 响应含 subscriptions 视图
|
|
7
|
+
* 3. POST /api/watchdog/subscriptions → 新增
|
|
8
|
+
* 4. POST /api/watchdog/subscriptions/probe → 手动探测
|
|
9
|
+
* 5. POST /api/watchdog/subscriptions/update → 禁用/启用
|
|
10
|
+
* 6. POST /api/watchdog/subscriptions/delete → 删除(含 force 保护)
|
|
11
|
+
*
|
|
12
|
+
* 运行: HOME=$(mktemp -d) node scripts/test-subscription-center-e2e.mjs
|
|
13
|
+
*/
|
|
14
|
+
import http from "http";
|
|
15
|
+
import assert from "assert";
|
|
16
|
+
import { writeFileSync } from "fs";
|
|
17
|
+
import { join, dirname } from "path";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
19
|
+
|
|
20
|
+
const HOME = process.env.HOME;
|
|
21
|
+
if (!HOME || HOME === "/") {
|
|
22
|
+
console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-subscription-center-e2e.mjs");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const usedMock = { v: 30 * 1073741824 };
|
|
27
|
+
const mock = http.createServer((req, res) => {
|
|
28
|
+
res.writeHead(200, {
|
|
29
|
+
"subscription-userinfo": `upload=0; download=${usedMock.v}; total=${100 * 1073741824}; expire=${Math.floor(Date.now() / 1000) + 20 * 86400}`,
|
|
30
|
+
});
|
|
31
|
+
res.end("proxies: []");
|
|
32
|
+
});
|
|
33
|
+
await new Promise((r) => mock.listen(0, "127.0.0.1", r));
|
|
34
|
+
const subUrl = `http://127.0.0.1:${mock.address().port}/sub?token=E2E`;
|
|
35
|
+
|
|
36
|
+
// 预置节点订阅配置(供代报映射与引用计算)
|
|
37
|
+
writeFileSync(
|
|
38
|
+
join(HOME, ".tt-help-watchdog.json"),
|
|
39
|
+
JSON.stringify({ proxySettings: { "F-1": { subscriptions: [{ name: "", url: subUrl }] } } }),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const wdMod = await import(
|
|
43
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "src", "watch", "watchdog-server.js")
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// mock res
|
|
47
|
+
function mockRes() {
|
|
48
|
+
return {
|
|
49
|
+
code: 0,
|
|
50
|
+
body: null,
|
|
51
|
+
writeHead(c) {
|
|
52
|
+
this.code = c;
|
|
53
|
+
return this;
|
|
54
|
+
},
|
|
55
|
+
end(b) {
|
|
56
|
+
this.body = b ? JSON.parse(b) : null;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function route(method, path, params, body) {
|
|
61
|
+
const res = mockRes();
|
|
62
|
+
const handled = await wdMod.tryHandleWatchdogRoute(path, method, params || {}, body || {}, res);
|
|
63
|
+
assert.ok(handled, "路由应被处理: " + method + " " + path);
|
|
64
|
+
return res;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 1) 心跳: 带 clashHealthChannels(daemon 真实形状: meta.proxy.subscriptions)
|
|
68
|
+
const hb = await route("POST", "/api/watchdog/heartbeat", { clientId: "F-1@host" }, {
|
|
69
|
+
hostname: "host1",
|
|
70
|
+
config: { userId: "F-1" },
|
|
71
|
+
processes: [],
|
|
72
|
+
clashHealth: {
|
|
73
|
+
node: "US-01",
|
|
74
|
+
meta: { countryFilter: ["US"], proxy: { subscriptions: [{ name: "sub-1", upload: 0, download: usedMock.v, total: 100 * 1073741824, expire: 0 }] } },
|
|
75
|
+
},
|
|
76
|
+
clashHealthChannels: [
|
|
77
|
+
{ port: 7897, node: "US-01", meta: { proxy: { subscriptions: [{ name: "sub-1", upload: 0, download: usedMock.v, total: 100 * 1073741824, expire: 0 }] } } },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
assert.strictEqual(hb.code, 200, "心跳 200");
|
|
81
|
+
assert.ok(hb.body.ok, "心跳 ok");
|
|
82
|
+
|
|
83
|
+
// 2) clients 响应带订阅视图(此时池子为空 → 代报无处落,先验证空态)
|
|
84
|
+
const cl0 = await route("GET", "/api/watchdog/clients", {}, {});
|
|
85
|
+
assert.ok(Array.isArray(cl0.body.subscriptions), "clients 含 subscriptions 数组");
|
|
86
|
+
|
|
87
|
+
// 3) 新增订阅(URL 与节点配置一致)
|
|
88
|
+
const add = await route("POST", "/api/watchdog/subscriptions", {}, { name: "E2E订阅", url: subUrl, note: "e2e" });
|
|
89
|
+
assert.strictEqual(add.code, 200, "新增成功");
|
|
90
|
+
const subId = add.body.entry.id;
|
|
91
|
+
|
|
92
|
+
// 入池后下一拍心跳(生产 agent 每 15s 一拍)→ 代报落池
|
|
93
|
+
await route("POST", "/api/watchdog/heartbeat", { clientId: "F-1@host" }, {
|
|
94
|
+
hostname: "host1",
|
|
95
|
+
config: { userId: "F-1" },
|
|
96
|
+
processes: [],
|
|
97
|
+
clashHealthChannels: [
|
|
98
|
+
{ port: 7897, node: "US-01", meta: { proxy: { subscriptions: [{ name: "sub-1", upload: 0, download: usedMock.v, total: 100 * 1073741824, expire: 0 }] } } },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// 代报已有 → 立即出现在视图(source=node,兜底通道生效)
|
|
103
|
+
const cl1 = await route("GET", "/api/watchdog/clients", {}, {});
|
|
104
|
+
const v1 = cl1.body.subscriptions.find((s) => s.id === subId);
|
|
105
|
+
assert.ok(v1, "视图含新订阅");
|
|
106
|
+
assert.strictEqual(v1.source, "node", "节点代报兜底生效");
|
|
107
|
+
assert.strictEqual(v1.refs.length, 1, "引用计算(user F-1)");
|
|
108
|
+
|
|
109
|
+
// 4) 手动探测 → source 变 probe
|
|
110
|
+
const probe = await route("POST", "/api/watchdog/subscriptions/probe", {}, { id: subId });
|
|
111
|
+
assert.strictEqual(probe.code, 200, "探测成功");
|
|
112
|
+
assert.strictEqual(probe.body.subscription.source, "probe", "探测为主数据源");
|
|
113
|
+
assert.strictEqual(probe.body.subscription.used, usedMock.v, "探测用量正确");
|
|
114
|
+
|
|
115
|
+
// 5) 禁用/启用
|
|
116
|
+
const dis = await route("POST", "/api/watchdog/subscriptions/update", {}, { id: subId, enabled: false });
|
|
117
|
+
assert.strictEqual(dis.body.entry.enabled, false, "禁用生效");
|
|
118
|
+
const en = await route("POST", "/api/watchdog/subscriptions/update", {}, { id: subId, enabled: true });
|
|
119
|
+
assert.strictEqual(en.body.entry.enabled, true, "启用生效");
|
|
120
|
+
|
|
121
|
+
// 6) 删除: 无引用时直接删(引用来自节点配置,先删配置再验证 force 逻辑)
|
|
122
|
+
// 当前有引用 → 需 force
|
|
123
|
+
const del1 = await route("POST", "/api/watchdog/subscriptions/delete", {}, { id: subId });
|
|
124
|
+
assert.strictEqual(del1.code, 409, "有引用删除返回 409");
|
|
125
|
+
const del2 = await route("POST", "/api/watchdog/subscriptions/delete", {}, { id: subId, force: true });
|
|
126
|
+
assert.strictEqual(del2.code, 200, "force 删除成功");
|
|
127
|
+
|
|
128
|
+
// 重复删除 → 400
|
|
129
|
+
const del3 = await route("POST", "/api/watchdog/subscriptions/delete", {}, { id: subId, force: true });
|
|
130
|
+
assert.strictEqual(del3.code, 400, "重复删除 400");
|
|
131
|
+
|
|
132
|
+
mock.close();
|
|
133
|
+
console.log("\n订阅中心 API 端到端测试通过 ✅");
|
|
134
|
+
process.exit(0);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 订阅中心冒烟测试 — CRUD / 主动探测 / 重置检测 / 状态推导 / 节点代报映射 / 引用删除保护
|
|
3
|
+
*
|
|
4
|
+
* 运行(HOME 隔离到临时目录,绝不读写真实 ~/.tt-help-watchdog*.json):
|
|
5
|
+
* cd tiktok-cli
|
|
6
|
+
* HOME=$(mktemp -d) node scripts/test-subscription-center-smoke.mjs
|
|
7
|
+
*
|
|
8
|
+
* 本地起 mock 机场端点(返回 subscription-userinfo 头),无需外网。
|
|
9
|
+
*/
|
|
10
|
+
import http from "http";
|
|
11
|
+
import assert from "assert";
|
|
12
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
13
|
+
import { dirname, join } from "path";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
|
|
16
|
+
const HOME = process.env.HOME;
|
|
17
|
+
if (!HOME || HOME === "/") {
|
|
18
|
+
console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-subscription-center-smoke.mjs");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
mkdirSync(HOME, { recursive: true });
|
|
22
|
+
|
|
23
|
+
const usedA = { v: 50 * 1073741824 }; // 初始已用 50GB
|
|
24
|
+
const TOTAL = 100 * 1073741824;
|
|
25
|
+
const EXPIRE = Math.floor(Date.now() / 1000) + 30 * 86400; // 30 天后到期
|
|
26
|
+
let uaSeen = "";
|
|
27
|
+
|
|
28
|
+
const mock = http.createServer((req, res) => {
|
|
29
|
+
uaSeen = req.headers["user-agent"] || "";
|
|
30
|
+
const expireMs = req.url.includes("token=MS") ? (EXPIRE * 1000).toString() : null; // 机场发毫秒戳的兼容用例
|
|
31
|
+
res.writeHead(200, {
|
|
32
|
+
"content-type": "text/plain",
|
|
33
|
+
"subscription-userinfo": expireMs
|
|
34
|
+
? `upload=0; download=${usedA.v}; total=${TOTAL}; expire=${expireMs}`
|
|
35
|
+
: `upload=0; download=${usedA.v}; total=${TOTAL}; expire=${EXPIRE}`,
|
|
36
|
+
});
|
|
37
|
+
res.end("proxies:\n - name: node1");
|
|
38
|
+
});
|
|
39
|
+
await new Promise((r) => mock.listen(0, "127.0.0.1", r));
|
|
40
|
+
const port = mock.address().port;
|
|
41
|
+
|
|
42
|
+
// 预置节点配置: user1 引用两条订阅(一条在池中、一条不在),测引用计数与代报映射
|
|
43
|
+
writeFileSync(
|
|
44
|
+
join(HOME, ".tt-help-watchdog.json"),
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
proxySettings: {
|
|
47
|
+
user1: {
|
|
48
|
+
subscriptions: [
|
|
49
|
+
{ name: "", url: `http://127.0.0.1:${port}/sub?token=A` },
|
|
50
|
+
{ name: "custom", url: "http://127.0.0.1:9/sub?token=NOT-IN-POOL" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
user2: { subscriptions: [] },
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const sc = await import(
|
|
59
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "src", "watch", "subscription-center.js")
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// 1) CRUD
|
|
63
|
+
const add1 = sc.addSubscription({ name: "机场A-月付", url: `http://127.0.0.1:${port}/sub?token=A`, note: "每月1号重置" });
|
|
64
|
+
assert.ok(add1.ok, "新增订阅1");
|
|
65
|
+
const add2 = sc.addSubscription({ url: `http://127.0.0.1:${port}/sub?token=B` });
|
|
66
|
+
assert.ok(add2.ok, "新增订阅2(自动命名)");
|
|
67
|
+
assert.ok(!sc.addSubscription({ url: `http://127.0.0.1:${port}/sub?token=A` }).ok, "重复 URL 拒绝");
|
|
68
|
+
assert.ok(!sc.addSubscription({ url: "ftp://x" }).ok, "非法 URL 拒绝");
|
|
69
|
+
console.log("✓ CRUD(含重复/非法 URL 拒绝)");
|
|
70
|
+
|
|
71
|
+
// 2) 主动探测(UA / total / 历史)
|
|
72
|
+
const p1 = await sc.probeSubscription(add1.entry.id, { manual: true });
|
|
73
|
+
assert.ok(p1.ok && p1.entry.probe.ok, "探测成功");
|
|
74
|
+
assert.strictEqual(uaSeen, "clash.meta/1.19.31", "Clash UA 正确");
|
|
75
|
+
assert.strictEqual(p1.entry.probe.total, TOTAL, "total 解析");
|
|
76
|
+
assert.strictEqual(p1.entry.history.length, 1, "历史 +1");
|
|
77
|
+
|
|
78
|
+
// 2.1) 机场发毫秒 expire 的兼容(>1e12 按毫秒归一为秒)
|
|
79
|
+
const addMs = sc.addSubscription({ url: `http://127.0.0.1:${port}/sub?token=MS` });
|
|
80
|
+
assert.ok(addMs.ok, "新增毫秒用例订阅");
|
|
81
|
+
const pMs = await sc.probeSubscription(addMs.entry.id, { manual: true });
|
|
82
|
+
assert.ok(pMs.ok && pMs.entry.probe.ok, "毫秒用例探测成功");
|
|
83
|
+
assert.strictEqual(pMs.entry.probe.expire, EXPIRE, "毫秒 expire 归一为秒");
|
|
84
|
+
|
|
85
|
+
// 3) 流量重置检测(用量下降 50GB→5GB)
|
|
86
|
+
usedA.v = 5 * 1073741824;
|
|
87
|
+
await new Promise((r) => setTimeout(r, 1100)); // 保证快照 ts 单调
|
|
88
|
+
const p2 = await sc.probeSubscription(add1.entry.id, { manual: true });
|
|
89
|
+
assert.ok(p2.ok && p2.entry.resets.length === 1, "重置检测");
|
|
90
|
+
console.log("✓ 主动探测 + Clash UA + 重置检测");
|
|
91
|
+
|
|
92
|
+
// 4) 状态推导
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const mk = (over) =>
|
|
95
|
+
sc.deriveStatus(Object.assign({ enabled: true, probe: null, lastNodeReport: null }, over), now);
|
|
96
|
+
assert.strictEqual(mk({}).code, "unknown", "无数据→未知");
|
|
97
|
+
assert.strictEqual(mk({ probe: { ok: false, at: now, error: "x" } }).code, "probe-failed", "探测失败");
|
|
98
|
+
assert.strictEqual(
|
|
99
|
+
mk({ probe: { ok: false, at: now }, lastNodeReport: { at: now, upload: 1, download: 1, total: 100, expire: 0 } }).code,
|
|
100
|
+
"ok",
|
|
101
|
+
"探测失败但代报新鲜→正常(兜底)",
|
|
102
|
+
);
|
|
103
|
+
assert.strictEqual(
|
|
104
|
+
mk({ probe: { ok: true, at: now, upload: 0, download: 0, total: 100, expire: Math.floor(now / 1000) - 1 } }).code,
|
|
105
|
+
"expired",
|
|
106
|
+
"已到期",
|
|
107
|
+
);
|
|
108
|
+
assert.strictEqual(
|
|
109
|
+
mk({ probe: { ok: true, at: now, upload: 0, download: 0, total: 100, expire: Math.floor((now + 3 * 86400000) / 1000) } }).code,
|
|
110
|
+
"expiring",
|
|
111
|
+
"临期3天",
|
|
112
|
+
);
|
|
113
|
+
assert.strictEqual(
|
|
114
|
+
mk({ probe: { ok: true, at: now, upload: 95 * 1073741824, download: 0, total: 100 * 1073741824, expire: 0 } }).code,
|
|
115
|
+
"quota-critical",
|
|
116
|
+
"余量<10%",
|
|
117
|
+
);
|
|
118
|
+
assert.strictEqual(
|
|
119
|
+
mk({ probe: { ok: true, at: now, upload: 100 * 1073741824, download: 0, total: 100 * 1073741824, expire: 0 } }).code,
|
|
120
|
+
"exhausted",
|
|
121
|
+
"已耗尽",
|
|
122
|
+
);
|
|
123
|
+
assert.strictEqual(mk({ enabled: false, probe: { ok: false, at: now } }).code, "disabled", "已禁用优先");
|
|
124
|
+
assert.strictEqual(mk({ probe: { ok: true, at: now, upload: 0, download: 0, total: 0, expire: 0 } }).code, "ok", "expire=0/total=0 哨兵→正常");
|
|
125
|
+
console.log("✓ 状态推导(9 种场景含哨兵值)");
|
|
126
|
+
|
|
127
|
+
// 5) 节点代报映射(sub-1 按行序映射回池中 URL)
|
|
128
|
+
const entryA = sc.getSubscriptionView().find((s) => s.url.endsWith("token=A"));
|
|
129
|
+
assert.ok(entryA, "视图包含订阅A");
|
|
130
|
+
sc.ingestNodeReport(
|
|
131
|
+
"user1",
|
|
132
|
+
{ meta: { proxy: { subscriptions: [{ name: "sub-1", upload: 0, download: usedA.v, total: TOTAL, expire: EXPIRE }] } } },
|
|
133
|
+
[],
|
|
134
|
+
);
|
|
135
|
+
const viewA = sc.getSubscriptionView().find((s) => s.url.endsWith("token=A"));
|
|
136
|
+
assert.ok(viewA.lastNodeReport, "代报已入库");
|
|
137
|
+
assert.strictEqual(viewA.lastNodeReport.by, "user1", "代报来源");
|
|
138
|
+
|
|
139
|
+
// 6) 引用计数 + 删除保护
|
|
140
|
+
assert.strictEqual(entryA.refs.length, 1, "user1 引用订阅A");
|
|
141
|
+
assert.strictEqual(entryA.refs[0].count, 1, "引用计数=1");
|
|
142
|
+
const delGuard = sc.deleteSubscription(entryA.id);
|
|
143
|
+
assert.ok(delGuard.needForce && delGuard.refs.length === 1, "有引用删除需 force");
|
|
144
|
+
assert.ok(sc.deleteSubscription(entryA.id, { force: true }).ok, "force 删除成功");
|
|
145
|
+
console.log("✓ 节点代报映射 + 引用计数 + 删除保护");
|
|
146
|
+
|
|
147
|
+
// 7) 视图字段完整性
|
|
148
|
+
const v = sc.getSubscriptionView().find((s) => s.id === add2.entry.id);
|
|
149
|
+
for (const k of ["id", "name", "url", "enabled", "status", "used", "total", "remain", "usedRatio", "expireAt", "source", "lastProbe", "refs", "historyRecent"])
|
|
150
|
+
assert.ok(k in v, "视图字段 " + k);
|
|
151
|
+
console.log("✓ 视图字段完整");
|
|
152
|
+
|
|
153
|
+
mock.close();
|
|
154
|
+
console.log("\n全部冒烟测试通过 ✅");
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 订阅中心 UI 自动化截图(服务端无头 Edge,走真实页面流程)
|
|
3
|
+
*
|
|
4
|
+
* 前置: watch 服务 127.0.0.1:3123 + mock 机场 127.0.0.1:3199 已在跑
|
|
5
|
+
* 流程: 列表(1条正常) → 新增弹窗 → 保存并自动探测(故意 404 → 失败态置顶) → 禁用 → 恢复
|
|
6
|
+
* 输出: /tmp/subcenter-ui-*.png
|
|
7
|
+
*/
|
|
8
|
+
import { chromium } from "playwright";
|
|
9
|
+
|
|
10
|
+
const BASE = "http://127.0.0.1:3123";
|
|
11
|
+
const shots = (n) => `/tmp/subcenter-ui-${n}.png`;
|
|
12
|
+
|
|
13
|
+
const browser = await chromium.launch({
|
|
14
|
+
executablePath: "/usr/bin/microsoft-edge",
|
|
15
|
+
headless: true,
|
|
16
|
+
});
|
|
17
|
+
const ctx = await browser.newContext({
|
|
18
|
+
viewport: { width: 1560, height: 1000 },
|
|
19
|
+
deviceScaleFactor: 1.5,
|
|
20
|
+
});
|
|
21
|
+
const page = await ctx.newPage();
|
|
22
|
+
page.on("dialog", (d) => d.accept()); // alert/confirm 自动接受
|
|
23
|
+
|
|
24
|
+
// 0) 登录(当日密码 tt@YYYYMMDD)
|
|
25
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
26
|
+
const d = new Date();
|
|
27
|
+
const todayPwd = `tt@${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
|
28
|
+
await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
|
|
29
|
+
await page.fill("#password", todayPwd);
|
|
30
|
+
// 等登录接口真正返回(cookie 落定)再跳转,否则后续 API 全部 401
|
|
31
|
+
const loginResp = page.waitForResponse((r) => r.url().includes("/api/login"), { timeout: 15000 });
|
|
32
|
+
await page.locator("#login-form").evaluate((f) => f.submit());
|
|
33
|
+
await loginResp;
|
|
34
|
+
await page.waitForTimeout(800);
|
|
35
|
+
console.log("✓ 已登录(当日密码)");
|
|
36
|
+
|
|
37
|
+
// 1) 打开看门狗页,等订阅中心渲染出数据
|
|
38
|
+
await page.goto(BASE + "/#watchdog", { waitUntil: "domcontentloaded" });
|
|
39
|
+
await page.waitForFunction(
|
|
40
|
+
() => {
|
|
41
|
+
const el = document.getElementById("wdSubsSummary");
|
|
42
|
+
return el && el.textContent.includes("共");
|
|
43
|
+
},
|
|
44
|
+
{ timeout: 20000 },
|
|
45
|
+
);
|
|
46
|
+
const section = page.locator("#wdSubsSection");
|
|
47
|
+
await section.screenshot({ path: shots(1) });
|
|
48
|
+
console.log("✓ 截图1: 默认折叠汇总条");
|
|
49
|
+
|
|
50
|
+
// 0.5) 验证折叠态: 列表应为空
|
|
51
|
+
const collapsedRows = await page.evaluate(
|
|
52
|
+
() => document.querySelectorAll("#wdSubsList > div").length,
|
|
53
|
+
);
|
|
54
|
+
if (collapsedRows !== 0) throw new Error(`默认应折叠(列表无行),实际 ${collapsedRows} 行`);
|
|
55
|
+
console.log("✓ 默认折叠确认");
|
|
56
|
+
|
|
57
|
+
// 1.5) 清理上轮残留的演示订阅(保证脚本可重复跑)
|
|
58
|
+
const existing = await (await fetch(BASE + "/api/watchdog/subscriptions")).json();
|
|
59
|
+
for (const s of existing.subscriptions || []) {
|
|
60
|
+
if (s.url.includes("token=DEAD")) {
|
|
61
|
+
await fetch(BASE + "/api/watchdog/subscriptions/delete", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ id: s.id, force: true }),
|
|
65
|
+
});
|
|
66
|
+
console.log("⟳ 已清理残留演示订阅");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
await page.waitForTimeout(500);
|
|
70
|
+
|
|
71
|
+
// 2) 打开新增弹窗,填一条故意探测失败的地址(mock 只认 MOCK-A)
|
|
72
|
+
await page.getByRole("button", { name: /新增订阅/ }).click();
|
|
73
|
+
await page.fill("#wdSubName", "失效机场-测试");
|
|
74
|
+
await page.fill("#wdSubUrl", "http://127.0.0.1:3199/sub?token=DEAD");
|
|
75
|
+
await page.fill("#wdSubNote", "演示探测失败态");
|
|
76
|
+
await page.locator("#wdSubModal").screenshot({ path: shots(2) });
|
|
77
|
+
console.log("✓ 截图2: 新增弹窗(已填入失效地址)");
|
|
78
|
+
|
|
79
|
+
// 3) 保存 → 自动探测失败 → 点表头展开,失败态置顶
|
|
80
|
+
await page.locator("#wdSubModal").getByRole("button", { name: "保存" }).click();
|
|
81
|
+
await page.waitForTimeout(1500); // 等保存+自动探测+回拉数据
|
|
82
|
+
await page.click("#wdSubsHeader"); // 展开
|
|
83
|
+
await page.waitForFunction(
|
|
84
|
+
() => document.querySelectorAll("#wdSubsList > div").length >= 2,
|
|
85
|
+
{ timeout: 20000 },
|
|
86
|
+
);
|
|
87
|
+
await page.waitForTimeout(1500); // 等下一轮数据回填
|
|
88
|
+
await section.screenshot({ path: shots(3) });
|
|
89
|
+
console.log("✓ 截图3: 展开后两条订阅(失败态置顶)");
|
|
90
|
+
|
|
91
|
+
// 4) 对正常订阅点「检测」(手动探测)
|
|
92
|
+
const probeBtn = page.locator("#wdSubsList button", { hasText: "🔍 检测" }).last();
|
|
93
|
+
await probeBtn.click();
|
|
94
|
+
await page.waitForTimeout(2000);
|
|
95
|
+
await section.screenshot({ path: shots(4) });
|
|
96
|
+
console.log("✓ 截图4: 手动检测后");
|
|
97
|
+
|
|
98
|
+
// 5) 禁用失败态订阅 → 半透明 + 已禁用徽标
|
|
99
|
+
await page.locator("#wdSubsList button", { hasText: "⏸ 禁用" }).first().click();
|
|
100
|
+
await page.waitForFunction(
|
|
101
|
+
() => {
|
|
102
|
+
const el = document.getElementById("wdSubsSummary");
|
|
103
|
+
return el && el.textContent.includes("禁用 1");
|
|
104
|
+
},
|
|
105
|
+
{ timeout: 10000 },
|
|
106
|
+
);
|
|
107
|
+
await section.screenshot({ path: shots(5) });
|
|
108
|
+
console.log("✓ 截图5: 禁用态");
|
|
109
|
+
|
|
110
|
+
await browser.close();
|
|
111
|
+
console.log("\nUI 流程截图完成 ✅(/tmp/subcenter-ui-1..5.png)");
|