tt-help-cli-ycl 1.4.169 → 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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/scripts/test-subscription-allocator.mjs +220 -0
  3. package/scripts/test-subscription-auto-alloc-ui.mjs +208 -0
  4. package/scripts/test-subscription-auto-alloc.mjs +259 -0
  5. package/scripts/test-subscription-center-e2e.mjs +134 -0
  6. package/scripts/test-subscription-center-smoke.mjs +154 -0
  7. package/scripts/test-subscription-center-ui.mjs +111 -0
  8. package/scripts/vnc-login.sh +68 -0
  9. package/src/cli/info.js +6 -1
  10. package/src/lib/browser/cdp.js +7 -1
  11. package/src/watch/public/app.js +586 -15
  12. package/src/watch/public/index.html +70 -3
  13. package/src/watch/subscription-allocator.js +255 -0
  14. package/src/watch/subscription-center.js +590 -0
  15. package/src/watch/watchdog-server.js +210 -0
  16. package/src/watchdog/agent.js +5 -0
  17. package/scripts/test-api-interceptor-429.mjs +0 -122
  18. package/scripts/test-api-interceptor-captcha.mjs +0 -176
  19. package/scripts/test-claim-fix.mjs +0 -216
  20. package/scripts/test-clash-discover.mjs +0 -126
  21. package/scripts/test-clash-hysteresis.mjs +0 -131
  22. package/scripts/test-clash-multichannel.mjs +0 -120
  23. package/scripts/test-client-error-monitor.mjs +0 -261
  24. package/scripts/test-country-fix.mjs +0 -341
  25. package/scripts/test-daemon-failover.mjs +0 -301
  26. package/scripts/test-daemon-switch-history.mjs +0 -141
  27. package/scripts/test-daemon-whitelist.mjs +0 -308
  28. package/scripts/test-explore-captcha-wait.mjs +0 -170
  29. package/scripts/test-photo-post.mjs +0 -156
  30. package/scripts/test-precheck-perf.mjs +0 -270
  31. package/scripts/test-precise-index.mjs +0 -114
  32. package/scripts/test-probe-live.mjs +0 -83
  33. package/scripts/test-probe.mjs +0 -315
  34. package/scripts/test-product-fill-cdp.mjs +0 -336
  35. package/scripts/test-product-fill-db.mjs +0 -216
  36. package/scripts/test-product-fill-http.mjs +0 -117
  37. package/scripts/test-proxy-switch-restart.mjs +0 -198
  38. package/scripts/test-refill-order.mjs +0 -218
  39. package/scripts/test-sample-all-fail.mjs +0 -142
  40. package/scripts/test-seller-any-country.mjs +0 -167
  41. package/scripts/test-stats-smoke.mjs +0 -43
  42. package/scripts/test-upgrade-spawn.mjs +0 -179
  43. package/scripts/test-webserver-error-classify.mjs +0 -409
  44. package/scripts/test-wmi-free-upgrade.mjs +0 -98
  45. package/src/cli/test-real-attach.js +0 -0
  46. package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
@@ -1,179 +0,0 @@
1
- /**
2
- * 升级器进程级模拟测试(新版 spawn 方案)
3
- *
4
- * 结构:假全局包 tt-help-cli-ycl(package.json version 9.9.9 + 假 cli.js 保持存活)
5
- * → 假旧 watchdog A(SIGTERM 优雅退出)
6
- * → 真 U(upgrader.js --upgrade --old-pid A)
7
- * → 假 npm(FAKE_PKG/FAKE_VER 环境变量把 package.json version 改为 9.9.10)
8
- * → U:下载(假 npm 改版本) → 验证(9.9.10) → 杀 A → spawn 新版 B(detached) → 存活确认
9
- *
10
- * 断言:
11
- * - U 日志:验证通过 / 旧已退出 / 新版已启动 / 存活确认
12
- * - A 已退出
13
- * - B 进程存活(detached,U 退出后仍活)
14
- *
15
- * 用法:node scripts/test-upgrade-spawn.mjs
16
- */
17
- import {
18
- mkdtempSync,
19
- writeFileSync,
20
- copyFileSync,
21
- chmodSync,
22
- mkdirSync,
23
- rmSync,
24
- realpathSync,
25
- } from "fs";
26
- import { tmpdir } from "os";
27
- import { join } from "path";
28
- import { spawn, execFileSync } from "child_process";
29
-
30
- const SRC = "/Users/yinchengliang/soft/tiktok/tiktok-cli";
31
- const REAL_NPM = execFileSync("which npm", { shell: true }).toString().trim();
32
- const tmp = mkdtempSync(join(tmpdir(), "tt-upgrade-test-"));
33
- const pkgDir = join(tmp, "pkg", "node_modules", "tt-help-cli-ycl");
34
- mkdirSync(join(pkgDir, "src", "watchdog"), { recursive: true });
35
- mkdirSync(join(tmp, "bin"), { recursive: true });
36
-
37
- let passed = 0,
38
- failed = 0;
39
- function check(name, cond, detail = "") {
40
- if (cond) {
41
- passed++;
42
- console.log(` ✅ ${name}`);
43
- } else {
44
- failed++;
45
- console.log(` ❌ ${name} ${detail}`);
46
- }
47
- }
48
-
49
- // ── 准备假全局包 ──
50
- writeFileSync(
51
- join(pkgDir, "package.json"),
52
- JSON.stringify({ name: "tt-help-cli-ycl", version: "9.9.9" }, null, 2),
53
- );
54
- writeFileSync(
55
- join(pkgDir, "cli.js"),
56
- `console.log("[fake-B] 新版 watchdog 启动 pid="+process.pid); setInterval(()=>{},1000); process.on("SIGTERM",()=>process.exit(0));`,
57
- );
58
- copyFileSync(
59
- join(SRC, "src/watchdog/upgrader.js"),
60
- join(pkgDir, "src/watchdog/upgrader.js"),
61
- );
62
-
63
- // ── 假旧 watchdog A ──
64
- const fakeA = join(tmp, "fakeA.mjs");
65
- writeFileSync(
66
- fakeA,
67
- `console.log("[fake-A] 启动 pid="+process.pid); process.on("SIGTERM",()=>{console.log("[fake-A] 收到 SIGTERM 优雅退出");process.exit(0)}); setInterval(()=>{},1000);`,
68
- );
69
-
70
- // ── 假 npm ──
71
- const fakeNpm = join(tmp, "bin", "npm");
72
- writeFileSync(
73
- fakeNpm,
74
- `#!/bin/sh
75
- if [ "$1" = "install" ]; then
76
- node -e "const fs=require('fs');const p=process.env.FAKE_PKG;const j=JSON.parse(fs.readFileSync(p,'utf8'));j.version=process.env.FAKE_VER;fs.writeFileSync(p,JSON.stringify(j,null,2)+'\\n');console.log('[fake-npm] 版本改为 '+j.version)"
77
- exit 0
78
- fi
79
- exec "${REAL_NPM}" "$@"
80
- `,
81
- );
82
- chmodSync(fakeNpm, 0o755);
83
-
84
- console.log("── 启动假旧 watchdog A ──");
85
- const a = spawn(process.execPath, [fakeA], {
86
- stdio: ["ignore", "pipe", "pipe"],
87
- });
88
- let aOut = "";
89
- a.stdout.on("data", (d) => (aOut += d));
90
- a.stderr.on("data", (d) => (aOut += d));
91
- await new Promise((r) => setTimeout(r, 800));
92
-
93
- console.log("── 启动真升级器 U ──");
94
- let uOut = "";
95
- // mac 上 /var→/private/var 是 symlink:spawn 参数必须用 realpath,
96
- // 否则 process.argv[1] ≠ import.meta.url 的真实路径,--upgrade 守卫不成立直接退出
97
- const upgraderReal = realpathSync(join(pkgDir, "src/watchdog/upgrader.js"));
98
- const u = spawn(
99
- process.execPath,
100
- [
101
- upgraderReal,
102
- "--upgrade",
103
- "--old-pid",
104
- String(a.pid),
105
- "--server",
106
- "http://127.0.0.1:3000",
107
- "--client-id",
108
- "test-client",
109
- "--interval",
110
- "15",
111
- ],
112
- {
113
- env: {
114
- ...process.env,
115
- PATH: `${join(tmp, "bin")}:${process.env.PATH}`,
116
- FAKE_PKG: join(pkgDir, "package.json"),
117
- FAKE_VER: "9.9.10",
118
- },
119
- stdio: ["ignore", "pipe", "pipe"],
120
- },
121
- );
122
- u.stdout.on("data", (d) => (uOut += d));
123
- u.stderr.on("data", (d) => (uOut += d));
124
- const uExit = await new Promise((res) => u.on("exit", (c) => res(c)));
125
- await new Promise((r) => setTimeout(r, 500)); // 等 B 起来
126
-
127
- console.log("\n──── 假 A 输出 ────\n" + aOut.trim());
128
- console.log("\n──── 升级器 U 输出 ────\n" + uOut.trim());
129
- console.log(`\nU 退出码: ${uExit}\n`);
130
-
131
- // 提取新版 B 的 pid
132
- const pidMatch = uOut.match(/新版 watchdog 已启动 \(pid=(\d+)\)/);
133
- const bPid = pidMatch ? Number(pidMatch[1]) : null;
134
-
135
- console.log("──── 断言 ────");
136
- check("U 正常退出(exit 0)", uExit === 0, `(实际 ${uExit})`);
137
- check("版本验证通过 (9.9.10)", /新版本验证通过 \(9\.9\.10\)/.test(uOut));
138
- check(
139
- "旧 watchdog 已退出",
140
- /旧 watchdog 已退出/.test(uOut),
141
- uOut.includes("已退出")
142
- ? ""
143
- : uOut
144
- .split("\n")
145
- .filter((l) => l.includes("退出"))
146
- .join("|"),
147
- );
148
- check("新版 watchdog 已启动", /新版 watchdog 已启动 \(pid=\d+\)/.test(uOut));
149
- check("新版存活确认", /新版 watchdog 存活确认/.test(uOut));
150
- check(
151
- "假 A 已退出",
152
- a.exitCode !== null || a.signalCode !== null,
153
- `(exitCode=${a.exitCode} signal=${a.signalCode})`,
154
- );
155
- check(
156
- "新版 B 进程存活",
157
- bPid
158
- ? (() => {
159
- try {
160
- process.kill(bPid, 0);
161
- return true;
162
- } catch {
163
- return false;
164
- }
165
- })()
166
- : false,
167
- `(bPid=${bPid})`,
168
- );
169
-
170
- // 清理:杀 B(如还活着)
171
- if (bPid) {
172
- try {
173
- process.kill(bPid, "SIGKILL");
174
- } catch {}
175
- }
176
- rmSync(tmp, { recursive: true, force: true });
177
-
178
- console.log(`\n结果: ${passed} 通过 / ${failed} 失败`);
179
- process.exit(failed ? 1 : 0);
@@ -1,409 +0,0 @@
1
- // 回归测试:webserver 错误分类(IP 风控 → CDN 自愈,重启竞态 → 不计连续错误)
2
- // 运行:node scripts/test-webserver-error-classify.mjs
3
- import assert from "node:assert/strict";
4
- import { createServer } from "node:http";
5
- import { spawn } from "node:child_process";
6
- import {
7
- TikTokScraper,
8
- classifyGotoError,
9
- } from "../src/lib/tiktok-scraper.mjs";
10
- import {
11
- classifyError,
12
- switchClashNode,
13
- settleCooldownWindow,
14
- } from "../src/webserver/server.mjs";
15
- import { CDNBlockedError, TikTokStatusError } from "../src/lib/parse-ssr.mjs";
16
- import { configure, resetConfig } from "../src/clash/api.js";
17
- import { parseNetstatListeningPids } from "../src/lib/browser/cdp.js";
18
- import { findListeningPids, killListeningOnPort } from "../src/lib/port.js";
19
-
20
- let passed = 0;
21
- let failed = 0;
22
- const checks = [];
23
- function check(name, fn) {
24
- checks.push({ name, fn });
25
- }
26
-
27
- async function runAll() {
28
- for (const { name, fn } of checks) {
29
- try {
30
- await fn();
31
- passed++;
32
- console.error(`PASS ${name}`);
33
- } catch (e) {
34
- failed++;
35
- console.error(`FAIL ${name}: ${e.message}`);
36
- }
37
- }
38
- console.error(`\n结果: ${passed} 通过, ${failed} 失败`);
39
- process.exitCode = failed > 0 ? 1 : 0;
40
- }
41
-
42
- // ---- classifyGotoError ----
43
- check("goto 非2xx + status=403 → CDNBlockedError 带状态码", () => {
44
- const err = classifyGotoError(
45
- "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://www.tiktok.com/@x/video/1",
46
- 403,
47
- );
48
- assert.ok(err instanceof CDNBlockedError);
49
- assert.equal(err.name, "CDNBlockedError");
50
- assert.match(err.message, /status=403/);
51
- });
52
-
53
- check("goto 非2xx + status=429 → CDNBlockedError", () => {
54
- const err = classifyGotoError(
55
- "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://x",
56
- 429,
57
- );
58
- assert.ok(err instanceof CDNBlockedError);
59
- assert.match(err.message, /status=429/);
60
- });
61
-
62
- check("goto 非2xx + status=null → CDNBlockedError 标 unknown", () => {
63
- const err = classifyGotoError(
64
- "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://x",
65
- null,
66
- );
67
- assert.ok(err instanceof CDNBlockedError);
68
- assert.match(err.message, /status=unknown/);
69
- });
70
-
71
- check("goto 非2xx + status=404 → TikTokStatusError(业务结果)", () => {
72
- const err = classifyGotoError(
73
- "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://www.tiktok.com/@x/video/9",
74
- 404,
75
- );
76
- assert.ok(err instanceof TikTokStatusError);
77
- assert.equal(err.statusCode, 404);
78
- });
79
-
80
- check("goto ERR_ABORTED → null(原样冒泡)", () => {
81
- assert.equal(
82
- classifyGotoError(
83
- "page.goto: net::ERR_ABORTED at view-source:https://x",
84
- null,
85
- ),
86
- null,
87
- );
88
- });
89
-
90
- check("goto frame detached → null(原样冒泡)", () => {
91
- assert.equal(
92
- classifyGotoError("page.goto: net::ERR_ABORTED; maybe frame was detached?"),
93
- null,
94
- );
95
- });
96
-
97
- check("goto 非字符串 → null", () => {
98
- assert.equal(classifyGotoError(undefined), null);
99
- assert.equal(classifyGotoError(null), null);
100
- });
101
-
102
- // ---- classifyError ----
103
- check("CDNBlockedError → cdn/503/retryable/不计连续错误", () => {
104
- const c = classifyError(new CDNBlockedError("CDN限流"));
105
- assert.deepEqual(c, {
106
- kind: "cdn",
107
- status: 503,
108
- retryable: true,
109
- countConsecutive: false,
110
- });
111
- });
112
-
113
- check("goto 包装的 CDNBlockedError → cdn", () => {
114
- const err = classifyGotoError(
115
- "page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at view-source:https://www.tiktok.com/@x/video/2",
116
- 403,
117
- );
118
- const c = classifyError(err);
119
- assert.equal(c.kind, "cdn");
120
- assert.equal(c.status, 503);
121
- });
122
-
123
- check("TikTokStatusError(404) → business/200/不计连续错误", () => {
124
- const c = classifyError(new TikTokStatusError(404, "TikTok HTTP 404"));
125
- assert.deepEqual(c, {
126
- kind: "business",
127
- status: 200,
128
- retryable: false,
129
- countConsecutive: false,
130
- statusCode: 404,
131
- });
132
- });
133
-
134
- check("Server is shutting down → shutdown/503/不计", () => {
135
- const c = classifyError(new Error("Server is shutting down"));
136
- assert.deepEqual(c, {
137
- kind: "shutdown",
138
- status: 503,
139
- retryable: true,
140
- countConsecutive: false,
141
- });
142
- });
143
-
144
- check("Timeout → timeout/504/计", () => {
145
- const c = classifyError(new Error("page.goto: Timeout 15000ms exceeded"));
146
- assert.deepEqual(c, {
147
- kind: "timeout",
148
- status: 504,
149
- retryable: true,
150
- countConsecutive: true,
151
- });
152
- });
153
-
154
- check("重启竞态产物 → browser/503/不计", () => {
155
- const cases = [
156
- "browserContext.newPage: Target page, context or browser has been closed",
157
- "page.goto: net::ERR_ABORTED at view-source:https://www.tiktok.com/@x/video/3",
158
- "page.goto: net::ERR_ABORTED; maybe frame was detached?",
159
- "Cannot read properties of null (reading 'newPage')",
160
- "Execution context was destroyed, most likely because of a navigation.",
161
- "Protocol error (Page.navigate): Target closed.",
162
- ];
163
- for (const msg of cases) {
164
- const c = classifyError(new Error(msg));
165
- assert.equal(c.kind, "browser", msg);
166
- assert.equal(c.status, 503, msg);
167
- assert.equal(c.retryable, true, msg);
168
- assert.equal(c.countConsecutive, false, msg);
169
- }
170
- });
171
-
172
- check("未知错误 → unknown/500/计", () => {
173
- const c = classifyError(new Error("some random parse failure"));
174
- assert.deepEqual(c, {
175
- kind: "unknown",
176
- status: 500,
177
- retryable: false,
178
- countConsecutive: true,
179
- });
180
- });
181
-
182
- // ---- TikTokScraper 关闭标记(重启竞态防护)----
183
- check("closing=true 时 _ensurePage 抛 Server is shutting down", async () => {
184
- const s = new TikTokScraper({ poolSize: 1 });
185
- s.closing = true;
186
- await assert.rejects(() => s._ensurePage({}), /Server is shutting down/);
187
- });
188
-
189
- check("context=null 时 _ensurePage 抛 Server is shutting down", async () => {
190
- const s = new TikTokScraper({ poolSize: 1 });
191
- s.closing = false;
192
- s.context = null;
193
- await assert.rejects(() => s._ensurePage({}), /Server is shutting down/);
194
- });
195
-
196
- // ---- switchClashNode(mock Clash API,不碰真实 9097)----
197
- /** 起一个 mock Clash 控制端,返回处理函数闭包里的状态 */
198
- function startMockClash(handler) {
199
- return new Promise((resolve) => {
200
- const server = createServer((req, res) => {
201
- const url = new URL(req.url, "http://127.0.0.1");
202
- let body = "";
203
- req.on("data", (c) => (body += c));
204
- req.on("end", () => {
205
- const reply = (status, data) => {
206
- res.statusCode = status;
207
- if (data === undefined) {
208
- res.end();
209
- return;
210
- }
211
- res.setHeader("Content-Type", "application/json");
212
- res.end(JSON.stringify(data));
213
- };
214
- handler(req, url, body ? JSON.parse(body) : null, reply);
215
- });
216
- });
217
- server.listen(0, "127.0.0.1", () => resolve(server));
218
- });
219
- }
220
-
221
- check("switchClashNode:选第一个 delay 通过的候选并切换", async () => {
222
- let switchedTo = null;
223
- const server = await startMockClash((req, url, body, reply) => {
224
- if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
225
- reply(200, { now: "A", all: ["A", "B", "C"] });
226
- } else if (req.method === "GET" && url.pathname.endsWith("/delay")) {
227
- reply(200, { delay: 120, timeout: false });
228
- } else if (req.method === "PUT" && url.pathname === "/proxies/GLOBAL") {
229
- switchedTo = body.name;
230
- reply(204);
231
- } else {
232
- reply(404, {});
233
- }
234
- });
235
- const port = server.address().port;
236
- configure({ port });
237
- try {
238
- const picked = await switchClashNode("测试切节点");
239
- assert.equal(picked, "B"); // 跳过当前节点 A,选第一个探测通过的 B
240
- assert.equal(switchedTo, "B");
241
- } finally {
242
- resetConfig();
243
- await new Promise((r) => server.close(r));
244
- }
245
- });
246
-
247
- check("switchClashNode:批量测速(/group/{G}/delay)优先且覆盖候选时不逐个探测", async () => {
248
- let switchedTo = null;
249
- let perNodeDelayCalls = 0;
250
- const server = await startMockClash((req, url, body, reply) => {
251
- if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
252
- reply(200, { now: "A", all: ["A", "B", "C"] });
253
- } else if (req.method === "GET" && url.pathname === "/group/GLOBAL/delay") {
254
- reply(200, { B: 88, C: 999 });
255
- } else if (req.method === "GET" && url.pathname.endsWith("/delay")) {
256
- perNodeDelayCalls++; // 批量可用时不应走到这里
257
- reply(200, { delay: 120, timeout: false });
258
- } else if (req.method === "PUT" && url.pathname === "/proxies/GLOBAL") {
259
- switchedTo = body.name;
260
- reply(204);
261
- } else {
262
- reply(404, {});
263
- }
264
- });
265
- configure({ port: server.address().port });
266
- try {
267
- const picked = await switchClashNode("测试批量优先");
268
- assert.equal(picked, "B");
269
- assert.equal(switchedTo, "B");
270
- assert.equal(perNodeDelayCalls, 0);
271
- } finally {
272
- resetConfig();
273
- await new Promise((r) => server.close(r));
274
- }
275
- });
276
-
277
- check("switchClashNode:候选均探测不通 → 抛错", async () => {
278
- const server = await startMockClash((req, url, body, reply) => {
279
- if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
280
- reply(200, { now: "A", all: ["A", "B"] });
281
- } else if (req.method === "GET" && url.pathname.endsWith("/delay")) {
282
- reply(200, { delay: -1, timeout: true });
283
- } else {
284
- reply(404, {});
285
- }
286
- });
287
- configure({ port: server.address().port });
288
- try {
289
- await assert.rejects(() => switchClashNode("测试"), /候选均探测不通/);
290
- } finally {
291
- resetConfig();
292
- await new Promise((r) => server.close(r));
293
- }
294
- });
295
-
296
- check("switchClashNode:无候选节点(组内只有当前节点)→ 抛错", async () => {
297
- const server = await startMockClash((req, url, body, reply) => {
298
- if (req.method === "GET" && url.pathname === "/proxies/GLOBAL") {
299
- reply(200, { now: "A", all: ["A"] });
300
- } else {
301
- reply(404, {});
302
- }
303
- });
304
- configure({ port: server.address().port });
305
- try {
306
- await assert.rejects(() => switchClashNode("测试"), /无候选节点/);
307
- } finally {
308
- resetConfig();
309
- await new Promise((r) => server.close(r));
310
- }
311
- });
312
-
313
- // ---- 端口占用清理 ----
314
- check("parseNetstatListeningPids:精确端口匹配(3000 不误匹配 30001)", () => {
315
- const out = [
316
- " TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 1234",
317
- " TCP 0.0.0.0:30001 0.0.0.0:0 LISTENING 5678",
318
- " TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 1234",
319
- " TCP [::]:3000 [::]:0 LISTENING 4321",
320
- ].join("\r\n");
321
- assert.deepEqual(parseNetstatListeningPids(out, 3000), ["1234", "4321"]);
322
- });
323
-
324
- check("killListeningOnPort:杀掉占用端口的子进程并可重新监听", async () => {
325
- // 先占一个随机端口拿到端口号,再释放
326
- const holder = createServer();
327
- await new Promise((r) => holder.listen(0, "127.0.0.1", r));
328
- const port = holder.address().port;
329
- await new Promise((r) => holder.close(r));
330
-
331
- // spawn 独立子进程占住该端口
332
- const child = spawn(
333
- process.execPath,
334
- [
335
- "-e",
336
- `require("http").createServer((q,s)=>s.end("ok")).listen(${port},"127.0.0.1");setInterval(()=>{},1000);`,
337
- ],
338
- { stdio: "ignore" },
339
- );
340
- await new Promise((r) => setTimeout(r, 800)); // 等子进程监听
341
-
342
- // 确认端口确实被占用
343
- const before = findListeningPids(port);
344
- assert.ok(
345
- before.includes(String(child.pid)),
346
- `预期子进程 ${child.pid} 在监听 ${port},实际: ${before.join(",")}`,
347
- );
348
-
349
- // 先注册 exit 监听(kill 后进程瞬间退出,后注册会丢失事件)
350
- const exitedPromise = new Promise((resolve) => {
351
- const t = setTimeout(() => resolve(false), 3000);
352
- child.once("exit", () => {
353
- clearTimeout(t);
354
- resolve(true);
355
- });
356
- });
357
-
358
- await killListeningOnPort(port);
359
-
360
- const exited = await exitedPromise;
361
- assert.equal(exited, true, "子进程应被杀死");
362
-
363
- // 端口应可重新监听
364
- const s2 = createServer();
365
- await new Promise((r) => s2.listen(port, "127.0.0.1", r));
366
- await new Promise((r) => s2.close(r));
367
- });
368
-
369
- // ---- 5 分钟窗口结算(只数限流窗口,成功请求不参与)----
370
- const WINDOW = 5 * 60 * 1000;
371
- check("settleCooldownWindow:无窗口 → 不结算原样返回", () => {
372
- assert.deepEqual(settleCooldownWindow(0, 0, 1000, WINDOW, 2), {
373
- shouldSwitch: false,
374
- count: 0,
375
- windowStartAt: 0,
376
- });
377
- });
378
- check("settleCooldownWindow:窗口内 → 不结算原样返回", () => {
379
- assert.deepEqual(
380
- settleCooldownWindow(1, 1000, 1000 + WINDOW - 1, WINDOW, 2),
381
- {
382
- shouldSwitch: false,
383
- count: 1,
384
- windowStartAt: 1000,
385
- },
386
- );
387
- });
388
- check("settleCooldownWindow:超 5 分钟且限流窗口 ≥2 → 切换并重新计数", () => {
389
- assert.deepEqual(
390
- settleCooldownWindow(2, 1000, 1000 + WINDOW + 1, WINDOW, 2),
391
- {
392
- shouldSwitch: true,
393
- count: 0,
394
- windowStartAt: 0,
395
- },
396
- );
397
- });
398
- check("settleCooldownWindow:超 5 分钟但限流窗口 <2 → 重新计算不切换", () => {
399
- assert.deepEqual(
400
- settleCooldownWindow(1, 1000, 1000 + WINDOW + 1, WINDOW, 2),
401
- {
402
- shouldSwitch: false,
403
- count: 0,
404
- windowStartAt: 0,
405
- },
406
- );
407
- });
408
-
409
- await runAll();
@@ -1,98 +0,0 @@
1
- /**
2
- * 测试:升级链绕开 WMI 的新逻辑(2026-08-21)
3
- * 范围(macOS 可测部分):
4
- * 1. parseCsvLine:tasklist /FO CSV 输出解析(引号/逗号边界)
5
- * 2. 模拟 tasklist /V 输出的窗口标题匹配过滤逻辑(复算与实现一致的筛选规则)
6
- * 3. agent 构造即写存活标记文件(STARTED_FILE),内容含 pid/version
7
- */
8
- import assert from "assert";
9
- import { readFileSync, rmSync } from "fs";
10
- import { join, dirname } from "path";
11
- import { tmpdir } from "os";
12
- import { fileURLToPath } from "url";
13
-
14
- const { parseCsvLine } = await import("../src/watchdog/upgrader.js");
15
-
16
- let pass = 0;
17
- const ok = (msg) => {
18
- pass++;
19
- console.log(` ✓ ${msg}`);
20
- };
21
-
22
- // ── 1. parseCsvLine ──
23
- const L1 = '"conhost.exe","5234","Console","2","8,312 K","Running","x\\user","0:00:01","tt-help-watchdog (F-7@WIN)"';
24
- const f1 = parseCsvLine(L1);
25
- assert.strictEqual(f1[0], "conhost.exe", "映像名");
26
- assert.strictEqual(f1[1], "5234", "PID");
27
- assert.strictEqual(f1[3], "2", "会话#");
28
- assert.strictEqual(f1[4], "8,312 K", "带逗号字段 8,312 K");
29
- assert.strictEqual(f1[8], "tt-help-watchdog (F-7@WIN)", "窗口标题");
30
- ok("parseCsvLine: 常规行(含带逗号字段)");
31
-
32
- // 标题内含转义引号(CSV 标准:双引号 "" 转义)
33
- const L2 = '"cmd.exe","999","Console","2","1,024 K","Running","x\\user","0:00:00","say ""hi"", tt-help-watchdog"';
34
- const f2 = parseCsvLine(L2);
35
- assert.strictEqual(f2[8], 'say "hi", tt-help-watchdog', "标题含转义引号与逗号");
36
- ok("parseCsvLine: 标题含转义引号+逗号");
37
-
38
- // 无引号行(表头)
39
- const f3 = parseCsvLine("映像名称,PID,会话名,会话#,内存使用,状态,用户名,CPU 时间,窗口标题");
40
- assert.strictEqual(f3.length, 9, "表头 9 列");
41
- ok("parseCsvLine: 无引号表头");
42
-
43
- // ── 2. 模拟 tasklist /V 输出做标题匹配过滤(复算实现逻辑) ──
44
- const mockTasklist = [
45
- '"映像名称","PID","会话名","会话#","内存使用","状态","用户名","CPU 时间","窗口标题"',
46
- '"System Idle Process","0","Services","0","8 K","Unknown","NT AUTHORITY\\SYSTEM","1:23:45:55","N/A"',
47
- '"conhost.exe","5234","Console","2","8,312 K","Running","x\\user","0:00:01","tt-help-watchdog (F-7@WIN-AADJ1HTCCAQ)"',
48
- '"cmd.exe","4000","Console","2","4,444 K","Running","x\\user","0:00:00","N/A"',
49
- '"conhost.exe","7777","Console","2","2,048 K","Running","x\\user","0:00:00","C:\\Windows\\system32\\cmd.exe"',
50
- '"node.exe","6400","Console","2","120,000 K","Running","x\\user","0:00:10","N/A"',
51
- '"cmd.exe","6401","Console","2","3,000 K","Running","x\\user","0:00:00","tt-help-watchdog (old)"',
52
- ];
53
- const TAG = "tt-help-watchdog";
54
- const matched = [];
55
- for (const line of mockTasklist) {
56
- if (!line.toLowerCase().includes(TAG.toLowerCase())) continue;
57
- const fields = parseCsvLine(line);
58
- if (fields.length < 2) continue;
59
- const name = (fields[0] || "").toLowerCase();
60
- if (!name.includes("conhost") && !name.includes("cmd")) continue;
61
- const pid = parseInt(fields[1], 10);
62
- if (pid > 0) matched.push(pid);
63
- }
64
- assert.deepStrictEqual(
65
- [...new Set(matched)].sort((a, b) => a - b),
66
- [5234, 6401],
67
- "只匹配到标题含 tag 的 conhost/cmd,普通窗口/其他进程不误匹配",
68
- );
69
- ok("标题匹配过滤: 命中旧窗口 conhost+cmd,排除无关进程");
70
-
71
- // ── 3. agent 构造即写存活标记文件 ──
72
- const STARTED_FILE = join(tmpdir(), "tt-help-watchdog-started.json");
73
- rmSync(STARTED_FILE, { force: true });
74
- const { WatchdogAgent } = await import("../src/watchdog/agent.js");
75
- const agent = new WatchdogAgent({
76
- serverUrl: "http://127.0.0.1:3001",
77
- clientId: "test@unit",
78
- intervalMs: 15000,
79
- });
80
- assert.ok(agent, "agent 构造成功");
81
- const st = JSON.parse(readFileSync(STARTED_FILE, "utf-8"));
82
- assert.strictEqual(st.pid, process.pid, "pid=当前进程");
83
- assert.ok(typeof st.version === "string" && st.version.length > 0, "version 非空");
84
- assert.ok(st.ts > 0, "ts 存在");
85
- ok(`存活文件写入: pid=${st.pid}, version=${st.version}`);
86
-
87
- // 重复构造覆盖写(模拟新版 watchdog 启动覆盖旧文件)
88
- const agent2 = new WatchdogAgent({
89
- serverUrl: "http://127.0.0.1:3001",
90
- clientId: "test2@unit",
91
- });
92
- const st2 = JSON.parse(readFileSync(STARTED_FILE, "utf-8"));
93
- assert.strictEqual(st2.pid, process.pid, "覆盖写仍有效");
94
- ok("存活文件: 重复构造覆盖写正常");
95
-
96
- // 清理测试文件
97
- rmSync(STARTED_FILE, { force: true });
98
- console.log(`\n全部通过: ${pass} 项`);
File without changes