tt-help-cli-ycl 1.4.183 → 1.4.185

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.
@@ -1,25 +1,32 @@
1
1
  /**
2
2
  * 共享登录池单元测试 — 模块级直驱(不起 HTTP)
3
3
  *
4
- * 覆盖:
5
- * 1. 入池(cookies 归一化/username 兜底/去重/池满)+ 视图脱敏(明文永不出)
4
+ * 覆盖(2026-11-04 对齐 7be35e8 账号中心简化 + 1cda80f 连败计数改算法):
5
+ * 1. 入池(cookies 归一化/username 兜底/去重)+ 视图脱敏(明文永不出)
6
6
  * 2. 指派独占约束(同账号双租拒绝 / 目标端口占用拒绝 / force 替换 / suspect 需 force)
7
7
  * 3. 心跳下发(applyLogin/clearLogin 一次性取走,cookies 仅在心跳响应出现)
8
- * 4. 续租 + 健康采集(lastBusinessOkAt/emptyStreak)
9
- * 5. 掉线回收(leaseExpired)+ 命令超时作废(deliveryExpired 回收)
8
+ * 4. 心跳零状态变更(续租/健康采集已物理删除)+ 节点执行回报写回(applied/applyFailed/cleared)
9
+ * 5. 僵尸回收(lease.assignedAt 硬顶 2h → zombieReaped,reapZombieLeases)+ 命令超时作废(deliveryExpired)
10
10
  * 6. release(含 clearOnNode)+ update 状态迁移 + delete 保护
11
+ * 7. 取号排序(K1/K2/K3 + 30min 休息窗)· 自动释放(一实例一号)· retired 排除
12
+ * 8. 登录态导入 import(登记语义 + lastImportFrom)
13
+ * 9. 重登录 relogin(X 方案:使用中仅换 cookies;suspect 回池 / retired 复活)
14
+ * 10. 巡检 healthTargets/recordHealth/checkPoolAccounts + prepareRelogin(X 方案守卫同步放宽)
11
15
  *
12
- * 跑法: HOME=$(mktemp -d) node scripts/test-login-pool.mjs
16
+ * 跑法: node scripts/test-login-pool.mjs
17
+ * (自建临时 HOME 并在 import 前覆盖环境变量——绝不读写真实 ~/.tt-help-watchdog-logins.json)
13
18
  */
14
19
  import assert from "assert";
15
- import { readFileSync, existsSync } from "fs";
20
+ import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "fs";
21
+ import { tmpdir } from "os";
16
22
  import { join } from "path";
17
23
 
24
+ // ── 测试基建:临时 HOME 隔离 ──
25
+ // login-pool.js 的 POOL_FILE 在模块加载期由 os.homedir() 计算(POSIX 下 homedir() 实时读
26
+ // process.env.HOME),故必须在动态 import 之前覆盖 HOME/USERPROFILE;生产代码零改动即可隔离。
27
+ process.env.HOME = mkdtempSync(join(tmpdir(), "loginpool-test-"));
28
+ process.env.USERPROFILE = process.env.HOME; // Windows 语义兜底
18
29
  const HOME = process.env.HOME;
19
- if (!HOME || HOME === "/") {
20
- console.error("请用隔离 HOME 运行: HOME=$(mktemp -d) node scripts/test-login-pool.mjs");
21
- process.exit(1);
22
- }
23
30
 
24
31
  const {
25
32
  getLoginPoolView,
@@ -31,7 +38,7 @@ const {
31
38
  acquireAccount,
32
39
  importAccount,
33
40
  heartbeatLoginPool,
34
- reapExpiredLeases,
41
+ reapZombieLeases,
35
42
  reloginAccount,
36
43
  prepareRelogin,
37
44
  healthTargets,
@@ -197,19 +204,27 @@ console.log("\n[3] 心跳下发");
197
204
  check("GET 视图持续无明文", !JSON.stringify(getLoginPoolView()).includes("sid-b"));
198
205
  }
199
206
 
200
- // ── 4. 续租 + 健康采集 + 节点执行回报 ──
201
- console.log("\n[4] 续租 + 健康采集 + 节点执行回报");
207
+ // ── 4. 心跳零状态变更 + 节点执行回报(7be35e8:续租/健康采集已删,心跳唯一写路径=applied 写回) ──
208
+ console.log("\n[4] 心跳零状态变更 + 节点执行回报");
202
209
  {
203
- const okAt = Date.now() - 60000;
204
- heartbeatLoginPool("F-2", {
205
- loginPool: { ports: { 9224: { username: "userC", lastBusinessOkAt: okAt, emptyStreak: 2 } } },
210
+ const cBefore = getLoginPoolView().accounts.find((x) => x.username === "userC"); // 在租(F-2:9224)
211
+ const snapBefore = JSON.stringify(getLoginPoolView());
212
+
213
+ // 无命令、无回报的心跳(携带旧版健康采集 payload)→ 返回 undefined 且池状态一个字节都不动
214
+ const hb = heartbeatLoginPool("F-ZERO", {
215
+ loginPool: { ports: { 9224: { username: "userC", lastBusinessOkAt: Date.now() - 60000, emptyStreak: 2 } } },
206
216
  });
217
+ check("无命令无回报的心跳返回 undefined", hb === undefined, JSON.stringify(hb));
218
+ const snapAfter = JSON.stringify(getLoginPoolView());
219
+ check("心跳零状态变更(整池快照逐字节一致)", snapBefore === snapAfter);
220
+
207
221
  let b = getLoginPoolView().accounts.find((x) => x.username === "userC");
208
- check("业务出活时间入库", b.lease.lastBusinessOkAt === okAt);
209
- check("空响应连击入库", b.lease.emptyStreak === 2);
210
- check("续租刷新 lastHealthAt", b.lease.lastHealthAt > 0);
222
+ check("健康采集已删:lastBusinessOkAt/emptyStreak 不再入库",
223
+ b.lease.lastBusinessOkAt === undefined && b.lease.emptyStreak === undefined);
224
+ check("心跳不刷新 lastUsedAt/lastHealthAt(盖章/镜像已删)",
225
+ b.lastUsedAt === cBefore.lastUsedAt && b.lastHealthAt === (cBefore.lastHealthAt || null));
211
226
 
212
- // 节点 applyLogin 成功回报(含持久化)
227
+ // 节点 applyLogin 成功回报(含持久化)→ 写回租约(心跳唯一写路径)
213
228
  heartbeatLoginPool("F-2", {
214
229
  loginPool: {
215
230
  applied: [{ action: "applyLogin", accountId: b.id, port: 9224, username: "userC", ok: true, persisted: true, at: Date.now() }],
@@ -228,31 +243,55 @@ console.log("\n[4] 续租 + 健康采集 + 节点执行回报");
228
243
  b = getLoginPoolView().accounts.find((x) => x.username === "userC");
229
244
  check("失败回报覆盖状态(最新为准)", b.lease.lastAppliedOk === false);
230
245
  check("历史记录 applyFailed", b.history.some((h) => h.event === "applyFailed"));
246
+
247
+ // clearLogin 执行回报
248
+ heartbeatLoginPool("F-2", {
249
+ loginPool: {
250
+ applied: [{ action: "clearLogin", accountId: b.id, port: 9224, at: Date.now() }],
251
+ },
252
+ });
253
+ b = getLoginPoolView().accounts.find((x) => x.username === "userC");
254
+ check("clearLogin 回报记 cleared", b.history.some((h) => h.event === "cleared"));
231
255
  }
232
256
 
233
- // ── 5. 掉线回收 + 命令超时作废 ──
234
- console.log("\n[5] 掉线回收 + 命令超时作废");
257
+ // ── 5. 僵尸回收(assignedAt 硬顶 2h,reapZombieLeases)+ 命令超时作废 ──
258
+ console.log("\n[5] 僵尸回收(2h 硬顶)+ 命令超时作废");
235
259
  {
236
- // 直接改内存时间不可行(模块内部 now),改用字段注入验证 reap 逻辑:
237
- // 给 A 重新指派,然后人为把 lease.lastHealthAt 拨回 13 小时前
260
+ // 5.1 边界内(≤2h)不回收
238
261
  const aLeased = assignAccount({ id: accA.id, userId: "F-3", port: 9225 });
239
262
  check("A 再指派成功", aLeased.status === "leased");
240
- // 取走 F-3 的命令,避免干扰
241
- heartbeatLoginPool("F-3", {});
242
-
243
- // 内部状态篡改:用 update 不行(不暴露 lease 写),通过文件回写模拟时间流逝
244
- const file = join(HOME, ".tt-help-watchdog-logins.json");
245
- // 强制落盘:scheduleSave 是 3s debounce;reapExpiredLeases 内部 ensureLoaded 读文件,
246
- // 这里直接改文件 + 清模块缓存不可行(ESM 无缓存清理)→ 用 lastHealthAt 拨回方式:
247
- // 模块导出 _accountsInternal 供测试直改内存
248
- const internal = (await import("../src/watch/login-pool.js"))._accountsInternal();
249
- const aInternal = internal.find((x) => x.username === "userA");
250
- aInternal.lease.lastHealthAt = Date.now() - 13 * 60 * 60 * 1000; // 13h 前
251
- const reaped = reapExpiredLeases();
252
- check("超时租约被回收", reaped === 1, `reaped=${reaped}`);
263
+ heartbeatLoginPool("F-3", {}); // 取走 F-3 命令,避免干扰
264
+
265
+ // 内部状态直改(_accountsInternal 供测试直改内存):assignedAt 拨回 1h59m
266
+ const aInternal = _accountsInternal().find((x) => x.username === "userA");
267
+ aInternal.lease.assignedAt = Date.now() - (2 * 60 * 60 * 1000 - 60 * 1000);
268
+ check("持有 1h59m 未超顶不回收", reapZombieLeases() === 0);
269
+ check("边界内租约保持", getLoginPoolView().accounts.find((x) => x.username === "userA").status === "leased");
270
+
271
+ // 5.2 超顶(>2h)→ zombieReaped 回收回池
272
+ aInternal.lease.assignedAt = Date.now() - (2 * 60 * 60 * 1000 + 60 * 1000);
273
+ const reaped = reapZombieLeases();
274
+ check("超 2h 租约被回收", reaped === 1, `reaped=${reaped}`);
253
275
  const aView = getLoginPoolView().accounts.find((x) => x.username === "userA");
254
276
  check("回收后回池", aView.status === "pool" && !aView.lease);
255
- check("历史记录 leaseExpired", aView.history.some((h) => h.event === "leaseExpired"));
277
+ check("历史留痕 zombieReaped", aView.history.some((h) => h.event === "zombieReaped"));
278
+ check("回收盖 lastUsedAt(休息窗从回收起算)", aView.lastUsedAt > 0 && Math.abs(aView.lastUsedAt - Date.now()) < 60000);
279
+
280
+ // 5.3 命令 10min 未被取走 → deliveryExpired + 租约回收(区别于僵尸路径)
281
+ assignAccount({ id: aView.id, userId: "F-5", port: 9227 }); // 排队命令,故意不心跳取走
282
+ await new Promise((r) => setTimeout(r, 3200)); // 等防抖落盘,保证磁盘=内存(外部改文件前的安全窗)
283
+ const file = join(HOME, ".tt-help-watchdog-logins.json");
284
+ const disk = JSON.parse(readFileSync(file, "utf-8"));
285
+ const cmd = disk.queue.find((c) => c.userId === "F-5");
286
+ check("待发命令已随池文件持久化", !!cmd);
287
+ cmd.queuedAt = Date.now() - 11 * 60 * 1000; // 拨回 11 分钟前(mtime 变化 → 模块自动重载)
288
+ writeFileSync(file, JSON.stringify(disk, null, 2), "utf-8");
289
+ const zombieCount = reapZombieLeases(); // 内部先 dropStaleCommands
290
+ check("超时命令走 deliveryExpired 路径(僵尸计数 0)", zombieCount === 0, `zombie=${zombieCount}`);
291
+ const aAfter = getLoginPoolView().accounts.find((x) => x.username === "userA");
292
+ check("超时命令作废并回收租约回池", aAfter.status === "pool" && !aAfter.lease, `status=${aAfter.status}`);
293
+ check("历史留痕 deliveryExpired", aAfter.history.some((h) => h.event === "deliveryExpired"));
294
+ check("作废后 F-5 心跳收不到命令", heartbeatLoginPool("F-5", {}) === undefined);
256
295
  }
257
296
 
258
297
  // ── 6. release / update / delete ──
@@ -294,7 +333,7 @@ console.log("\n[6] release / update / delete");
294
333
  }
295
334
  }
296
335
 
297
- // ── 7. 取号排序(双键+冷却)· 自动释放(一实例一号)· retired 排除 ──
336
+ // ── 7. 取号排序(K1/K2/K3 + 30min 休息窗)· 自动释放(一实例一号)· retired 排除 ──
298
337
  console.log("\n[7] 取号·导入·自动释放");
299
338
  {
300
339
  // 清池重建:全量 release→retired→delete,再导入 4 个新号(uc 全 0,createdAt 递增)
@@ -311,25 +350,25 @@ console.log("\n[7] 取号·导入·自动释放");
311
350
  mk("userE"); mk("userF"); mk("userG"); mk("userH");
312
351
  check("重建后池=4", getLoginPoolView().total === 4);
313
352
 
314
- // 7.1 双键排序:uc 全 0 → createdAt 升序 → userE
353
+ // 7.1 K1 从未用过最前(组内 K3 退化为 createdAt 升序)→ userE
315
354
  const t1 = acquireAccount({ userId: "n1" });
316
355
  check("永不用过的优先(userE)", t1.username === "userE", `got ${t1.username}`);
317
356
  check("取号次数 +1", t1.useCount === 1);
318
357
 
319
- // 7.2 第二低(userF)[自动释放:n2 无旧租约,无操作]
358
+ // 7.2 其次 userF [自动释放:n2 无旧租约,无操作]
320
359
  const t2 = acquireAccount({ userId: "n2" });
321
360
  check("其次取 userF", t2.username === "userF", `got ${t2.username}`);
322
361
 
323
- // 7.3 错峰冷却:还回 userE(lastUsedAt=now → 10min 冷却)→ 不立即复用
362
+ // 7.3 休息窗(30min):还回 userE(lastUsedAt=now → 窗内不可领取)→ 不立即复用
324
363
  releaseAccount({ id: t1.id });
325
364
  const t3 = acquireAccount({ userId: "n3" });
326
365
  check("刚还的号不立即复用(取 userG)", t3.username === "userG", `got ${t3.username}`);
327
366
 
328
- // 7.4 最后一个非冷却空闲号(userH)
367
+ // 7.4 最后一个非休息窗空闲号(userH)
329
368
  const t4 = acquireAccount({ userId: "n4" });
330
369
  check("取 userH", t4.username === "userH", `got ${t4.username}`);
331
370
 
332
- // 7.5 ③ 自动释放:池内放入新号 → n1 再取 → 拿新号 + n1 名下旧租约(userE)自动归还
371
+ // 7.5 自动释放:池内放入新号 → n1 再取 → 拿新号 + n1 名下旧租约(userE)自动归还
333
372
  importAccount({ userId: "x", username: "userZ", cookies: [{ name: "sessionid", value: "sid-z" }] });
334
373
  const t5 = acquireAccount({ userId: "n1" });
335
374
  check("n1 取到新放入的 userZ", t5.username === "userZ", `got ${t5.username}`);
@@ -343,7 +382,7 @@ console.log("\n[7] 取号·导入·自动释放");
343
382
 
344
383
  // 7.7 retired 不参与取号:停用 userZ → 取号拿到别的
345
384
  const zId = getLoginPoolView().accounts.find((x) => x.username === "userZ").id;
346
- releaseAccount({ id: zId }).account; // 若在租则释放
385
+ releaseAccount({ id: zId });
347
386
  updateAccount({ id: zId, status: "retired" });
348
387
  const t6 = acquireAccount({ userId: "n5" });
349
388
  check("retired 不被取用", t6.username !== "userZ", `got ${t6.username}`);
@@ -383,7 +422,7 @@ console.log("\n[8] 登录态导入 import");
383
422
  check("缺 cookies 确实抛错", threw);
384
423
  }
385
424
 
386
- console.log("\n[9] 重登录 relogin(覆盖刷新 + 守卫)");
425
+ console.log("\n[9] 重登录 relogin(X 方案:使用中仅换 cookies + 守卫 + 复活)");
387
426
  {
388
427
  for (const a of getLoginPoolView().accounts) {
389
428
  try { releaseAccount({ id: a.id }); } catch {}
@@ -405,7 +444,7 @@ console.log("\n[9] 重登录 relogin(覆盖刷新 + 守卫)");
405
444
  const after = JSON.parse(JSON.stringify(_accountsInternal().find((a) => a.username === "userD")));
406
445
  check("覆盖刷新(新 cookies 落池)", JSON.stringify(after.cookies).includes("sid-d-new") && JSON.stringify(after.cookies).includes("poc_marker"));
407
446
  check("历史含 relogin 事件", (after.history || []).some((h) => h.event === "relogin"));
408
- check("正常刷新不改状态不复活", r.revived === false && r.wasSuspect === false && r.account.status === "pool");
447
+ check("空闲刷新不改状态不复活", r.revived === false && r.wasSuspect === false && r.account.status === "pool");
409
448
 
410
449
  let threw = false;
411
450
  try { reloginAccount({ username: "ghost", cookies: D2, userId: "cli" }); } catch (e) { threw = /不在池中/.test(e.message); }
@@ -417,10 +456,14 @@ console.log("\n[9] 重登录 relogin(覆盖刷新 + 守卫)");
417
456
  try { reloginAccount({ username: "userD", userId: "cli" }); } catch (e) { threw = /cookies/.test(e.message); }
418
457
  check("缺 cookies 拒绝", threw);
419
458
 
459
+ // X 方案(2026-11-03):使用中也可重登录——仅替换池内 cookies,状态与租约原样保留
420
460
  acquireAccount({ userId: "T1", port: 9400 });
421
- threw = false;
422
- try { reloginAccount({ username: "userD", cookies: D2, userId: "cli" }); } catch (e) { threw = /使用中/.test(e.message); }
423
- check("使用中拒绝重登录", threw);
461
+ r = reloginAccount({ username: "userD", cookies: D2, userId: "cli", note: "使用中刷新" });
462
+ check("使用中可重登录(X 方案)", !!r && r.account.status === "leased", JSON.stringify(r && r.account && r.account.status));
463
+ check("使用中重登录不动租约", r.account.lease && r.account.lease.userId === "T1" && r.account.lease.port === 9400);
464
+ const afterUse = JSON.parse(JSON.stringify(_accountsInternal().find((a) => a.username === "userD")));
465
+ check("使用中重登录 cookies 已覆盖", JSON.stringify(afterUse.cookies).includes("poc_marker"));
466
+ check("使用中重登录历史注明仅换 cookies", (afterUse.history || []).some((h) => h.event === "relogin" && h.detail.includes("使用中")));
424
467
  releaseAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id });
425
468
 
426
469
  updateAccount({ id: getLoginPoolView().accounts.find((a) => a.username === "userD").id, status: "suspect" });
@@ -470,7 +513,7 @@ console.log("\n[10] 巡检 healthTargets/recordHealth/checkPoolAccounts(注入
470
513
  const aliveAcc = getLoginPoolView().accounts.find((a) => a.username === "u-alive");
471
514
  check("活号不写 history(防刷屏)", !(aliveAcc.history || []).some((h) => h.event === "health"));
472
515
 
473
- acquireAccount({ userId: "T2", port: 9401 }); // 租走最久未用的(u-alive 或 u-dead 中 LRU 者)
516
+ acquireAccount({ userId: "T2", port: 9401 }); // 租走空闲号(u-alive;u-dead 是 suspect 不会被取)
474
517
  const leased = getLoginPoolView().accounts.find((a) => a.status === "leased");
475
518
  const rec = recordHealth({ id: leased.id, ok: "dead", detail: "单测" });
476
519
  check("leased 死号仅记录不迁移状态", rec.status === "leased" && leased.username === rec.username);
@@ -478,10 +521,10 @@ console.log("\n[10] 巡检 healthTargets/recordHealth/checkPoolAccounts(注入
478
521
  const notLeased = getLoginPoolView().accounts.find((a) => a.status !== "leased").username;
479
522
  const prep = prepareRelogin({ username: notLeased });
480
523
  check("prepare 返回明文 cookies", Array.isArray(prep.account.cookies) && prep.account.cookies.length > 0);
524
+ // X 方案:prepareRelogin 守卫与 reloginAccount 同步放宽——使用中也放行(仅在池即可)
525
+ const prepLeased = prepareRelogin({ username: leased.username });
526
+ check("prepare 使用中也返回 cookies(X 方案守卫放宽)", Array.isArray(prepLeased.account.cookies) && prepLeased.account.cookies.length > 0);
481
527
  let threw = false;
482
- try { prepareRelogin({ username: leased.username }); } catch (e) { threw = /使用中/.test(e.message); }
483
- check("prepare 使用中拒绝", threw);
484
- threw = false;
485
528
  try { prepareRelogin({ username: "ghost" }); } catch (e) { threw = /不在池中/.test(e.message); }
486
529
  check("prepare 不在池拒绝", threw);
487
530
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * watchdog 进程日志前缀标签单测 — deriveDisplayLabel 纯函数直驱
3
+ *
4
+ * 覆盖(2026-11-04 定稿规则表全部 5 条分支 + 引号/路径边界):
5
+ * 1. --base-port 单端口 → [explore:9223]
6
+ * 2. --port 单端口(仅浏览器类)→ [refresh-mini:5222];webserver/watch 的 --port 不吃
7
+ * 3. --base-port --port-count → 区间压缩 [explore:9223-9225]
8
+ * 4. 解析不到端口 → [comments](不猜默认端口)
9
+ * 5. 程序名也解析不出 → 兜底 fallbackId(即 cmdDef.name || id)
10
+ * 边界: 引号形态 "tt-help" "explore" / node+脚本路径取子命令 / 脚本本体即程序 /
11
+ * Windows 启动器(.cmd/.exe)/ 值含引号空格不影响端口提取
12
+ *
13
+ * 跑法: node scripts/test-process-manager-label.mjs
14
+ */
15
+ import assert from "assert";
16
+ import { deriveDisplayLabel } from "../src/watchdog/process-manager.js";
17
+
18
+ let pass = 0;
19
+ let fail = 0;
20
+ function check(name, actual, expected) {
21
+ try {
22
+ assert.strictEqual(actual, expected);
23
+ pass++;
24
+ console.log(` ✓ ${name}`);
25
+ } catch {
26
+ fail++;
27
+ console.error(` ✗ ${name}\n 期望: ${expected}\n 实际: ${actual}`);
28
+ }
29
+ }
30
+
31
+ console.log("规则 1:--base-port 单端口");
32
+ check(
33
+ "explore --base-port 9223",
34
+ deriveDisplayLabel("tt-help explore --base-port 9223 --server http://127.0.0.1:3001", "1793630915534"),
35
+ "explore:9223",
36
+ );
37
+ check(
38
+ "refresh --base-port 8222(无 port-count 默认 1 个端口)",
39
+ deriveDisplayLabel("tt-help refresh --base-port 8222", "r1"),
40
+ "refresh:8222",
41
+ );
42
+
43
+ console.log("规则 2:--port 单端口(仅浏览器类命令提取,防误吃服务端口)");
44
+ check(
45
+ "refresh-mini --port 5222",
46
+ deriveDisplayLabel("tt-help refresh-mini --port 5222 --headless", "m1"),
47
+ "refresh-mini:5222",
48
+ );
49
+ check(
50
+ "webserver --port 3000 不吃端口(非浏览器类命令)",
51
+ deriveDisplayLabel("tt-help webserver --port 3000", "w1"),
52
+ "webserver",
53
+ );
54
+ check(
55
+ "watch --port 3000 不吃端口(非浏览器类命令)",
56
+ deriveDisplayLabel("tt-help watch --port 3000 -o data.db", "w2"),
57
+ "watch",
58
+ );
59
+
60
+ console.log("规则 3:--base-port + --port-count 区间压缩");
61
+ check(
62
+ "explore --base-port 9223 --port-count 3 → 9223-9225",
63
+ deriveDisplayLabel("tt-help explore --base-port 9223 --port-count 3", "e1"),
64
+ "explore:9223-9225",
65
+ );
66
+ check(
67
+ "attach --base-port 9222 --port-count 5 → 9222-9226",
68
+ deriveDisplayLabel("tt-help attach --base-port 9222 --port-count 5", "a1"),
69
+ "attach:9222-9226",
70
+ );
71
+
72
+ console.log("规则 4:解析不到端口 → 只显示程序名(不猜默认端口)");
73
+ check("comments 无端口参数", deriveDisplayLabel("tt-help comments --server http://127.0.0.1:3001", "c1"), "comments");
74
+ check("videostats 无端口参数", deriveDisplayLabel("tt-help videostats data/result.db -p 3", "v1"), "videostats");
75
+ check("recheck 无端口参数", deriveDisplayLabel("tt-help recheck", "rc1"), "recheck");
76
+
77
+ console.log("规则 5:程序名也解析不出 → 兜底 fallbackId");
78
+ check("空命令行", deriveDisplayLabel("", "1793630915534"), "1793630915534");
79
+ check("纯空白", deriveDisplayLabel(" ", "fallback-id"), "fallback-id");
80
+ check("null 命令行", deriveDisplayLabel(null, "fb"), "fb");
81
+ check("裸路径分隔符解析不出程序名", deriveDisplayLabel("/ --base-port 9223", "fb2"), "fb2");
82
+
83
+ console.log("边界:引号形态");
84
+ check(
85
+ '"tt-help" "explore" 全引号',
86
+ deriveDisplayLabel('"tt-help" "explore" --base-port 9223', "q1"),
87
+ "explore:9223",
88
+ );
89
+ check(
90
+ "引号值含空格不影响端口提取",
91
+ deriveDisplayLabel('tt-help explore --name "my explore" --base-port 9223', "q2"),
92
+ "explore:9223",
93
+ );
94
+ check(
95
+ "单引号启动器",
96
+ deriveDisplayLabel("'tt-help' refresh-mini --port 5222", "q3"),
97
+ "refresh-mini:5222",
98
+ );
99
+
100
+ console.log("边界:node/路径启动形态");
101
+ check(
102
+ "node + 脚本路径 → 取子命令 token",
103
+ deriveDisplayLabel("node /usr/local/lib/cli.js explore --base-port 9223", "p1"),
104
+ "explore:9223",
105
+ );
106
+ check(
107
+ "node src/main.js watch → 取子命令 watch(--port 不吃,watch 非浏览器类)",
108
+ deriveDisplayLabel("node src/main.js watch --port 3000 -o tmp.db", "p2"),
109
+ "watch",
110
+ );
111
+ check(
112
+ "node scraper.js --port 9222 → 脚本本体即程序;scraper 不属浏览器类词表,--port 不吃",
113
+ deriveDisplayLabel("node scraper.js --port 9222", "p3"),
114
+ "scraper",
115
+ );
116
+ check(
117
+ "node explore.js --port 9222 → 脚本名属浏览器类词表,吃端口",
118
+ deriveDisplayLabel("node explore.js --port 9222", "p3b"),
119
+ "explore:9222",
120
+ );
121
+ check(
122
+ "node.exe + 相对脚本路径",
123
+ deriveDisplayLabel("node.exe cli.js refresh-mini --port 5222", "p4"),
124
+ "refresh-mini:5222",
125
+ );
126
+ check(
127
+ "Windows 完整路径 tt-help.cmd 启动器",
128
+ deriveDisplayLabel("C:\\tools\\tt-help.cmd explore --base-port 9223 --port-count 2", "p5"),
129
+ "explore:9223-9224",
130
+ );
131
+ check(
132
+ "裸命令(无启动器)直接取 token[0]",
133
+ deriveDisplayLabel("explore --base-port 9223", "p6"),
134
+ "explore:9223",
135
+ );
136
+
137
+ console.log("边界:cmdDef.args 数组形态(startCommand join 后派生)");
138
+ check(
139
+ "args 数组 join 成命令行后解析",
140
+ deriveDisplayLabel(["tt-help", "explore", "--base-port", "9223"].join(" "), "ar1"),
141
+ "explore:9223",
142
+ );
143
+
144
+ console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
145
+ process.exit(fail > 0 ? 1 : 0);