@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2

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 (29) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +148 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  11. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  12. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  13. package/docs/telemetry.md +31 -2
  14. package/dsh-setup.mjs +134 -40
  15. package/package.json +6 -5
  16. package/packages/dsh-remote-web/lib/client.js +799 -65
  17. package/packages/dsh-remote-web/lib/index.js +460 -10
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  20. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  21. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  22. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  23. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  24. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  25. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  26. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  28. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  29. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * 激活点唯一性 —— 配置不变量 + 安装器方向守护。
3
+ *
4
+ * 背景(2026-09-22 实测事故):插件被两处同时激活时,dsh web **启动即挂**:
5
+ * `duplicate loader entry id: dsh-remote-web` → `plugin tree failed to load`。
6
+ * 不是"插件不生效",是**dsh 起不来**。
7
+ *
8
+ * 两处激活来自:① `dsh.profile.bundles` 里有插件自带 bundle;② profile 的
9
+ * `cordis.patch.yml` 里又有激活行。而**自动维护流程会周期性把 bundle 写回** bundles,
10
+ * 所以"删 bundles、留 patch"的修法会被撤销 → 又变两处 → 再崩。
11
+ *
12
+ * 稳定方向只有一个(本文件就是把它钉死):
13
+ * **bundle 归包管理器管、我们不动它;我们保证自己不写第二个激活点。**
14
+ */
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { readFileSync, mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+ import { dirname, join } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+
23
+ import { activationPoints, checkInvariant } from "../../../scripts/check-profile-activation.mjs";
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+ const SETUP_SRC = readFileSync(join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8");
27
+
28
+ /** 造一个假 profile 目录。 */
29
+ function fakeProfile({ bundles = [], patch = "" } = {}) {
30
+ const dir = mkdtempSync(join(tmpdir(), "dsh-act-"));
31
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "p", dsh: { profile: { bundles } } }));
32
+ writeFileSync(join(dir, "cordis.patch.yml"), patch);
33
+ return dir;
34
+ }
35
+
36
+ const PATCH_ROW = `- insert:\n - id: dsh-remote-web\n name: 'dsh-remote-web'\n`;
37
+
38
+ test("不变量:只有 bundles 一处 → 成立", () => {
39
+ const dir = fakeProfile({ bundles: ["dsh-remote-web"], patch: "# 空 patch\n" });
40
+ const hits = activationPoints(dir);
41
+ assert.equal(hits.length, 1);
42
+ assert.equal(hits[0].source, "bundles");
43
+ });
44
+
45
+ test("★ 不变量红灯:bundles + patch 两处 → 必须判违规(dsh web 会启动即挂)", () => {
46
+ const dir = fakeProfile({ bundles: ["dsh-remote-web"], patch: PATCH_ROW });
47
+ const hits = activationPoints(dir);
48
+ assert.equal(hits.length, 2, "两处激活必须被数出来");
49
+ assert.deepEqual(hits.map((h) => h.source).sort(), ["bundles", "patch"]);
50
+ const r = checkInvariant([dir]);
51
+ assert.equal(r.ok, false, "两处激活必须让不变量检查变红");
52
+ assert.equal(r.violations.length, 1);
53
+ });
54
+
55
+ test("不变量:只有 patch 一处 → 成立(插件不在 bundles 时的正常形态)", () => {
56
+ const dir = fakeProfile({ bundles: [], patch: PATCH_ROW });
57
+ assert.equal(activationPoints(dir).length, 1);
58
+ assert.equal(checkInvariant([dir]).ok, true);
59
+ });
60
+
61
+ test("不变量:历史 id(dsh-remote-ui)的遗留激活行也要被数出来(改过名同样会撞)", () => {
62
+ const dir = fakeProfile({ bundles: ["dsh-remote-ui"], patch: "- insert:\n - id: dsh-remote-ui\n name: 'dsh-remote-ui'\n" });
63
+ assert.equal(activationPoints(dir).length, 2);
64
+ });
65
+
66
+ test("不变量:未启用本插件 → 0 个激活点,也算成立", () => {
67
+ const dir = fakeProfile({ bundles: ["other-plugin"], patch: "- insert:\n - id: other-plugin\n" });
68
+ assert.equal(activationPoints(dir).length, 0);
69
+ assert.equal(checkInvariant([dir]).ok, true);
70
+ });
71
+
72
+ test("★ 安装器方向守护:ensurePatchActivation 绝不得再调 removeBundleEntry(方向反了)", () => {
73
+ // 取 ensurePatchActivation 的函数体(按大括号配平)
74
+ const start = SETUP_SRC.indexOf("function ensurePatchActivation(");
75
+ assert.notEqual(start, -1, "找不到 ensurePatchActivation");
76
+ // ⚠️ 不能直接找第一个 `{` —— 签名里的 `opts = {}` 会立刻把它配平,取到空体。
77
+ // 必须先找到**参数列表的右括号**,再从它之后找函数体的左花括号。
78
+ const parenOpen = SETUP_SRC.indexOf("(", start);
79
+ let pd = 0, parenClose = -1;
80
+ for (let i = parenOpen; i < SETUP_SRC.length; i++) {
81
+ if (SETUP_SRC[i] === "(") pd++;
82
+ else if (SETUP_SRC[i] === ")") { pd--; if (pd === 0) { parenClose = i; break; } }
83
+ }
84
+ assert.notEqual(parenClose, -1, "参数列表不配平");
85
+ const open = SETUP_SRC.indexOf("{", parenClose);
86
+ let depth = 0, end = -1;
87
+ for (let i = open; i < SETUP_SRC.length; i++) {
88
+ if (SETUP_SRC[i] === "{") depth++;
89
+ else if (SETUP_SRC[i] === "}") { depth--; if (depth === 0) { end = i; break; } }
90
+ }
91
+ assert.notEqual(end, -1, "函数体不配平");
92
+ const body = SETUP_SRC.slice(open, end + 1);
93
+ assert.equal(
94
+ /removeBundleEntry\s*\(/.test(body),
95
+ false,
96
+ "ensurePatchActivation 里不得再出现 removeBundleEntry:那条路是「删 bundles、留 patch」," +
97
+ "而维护流程会把 bundle 写回 → 又变两处激活 → dsh web 启动即崩。稳定方向是留 bundle、删 patch 行。"
98
+ );
99
+ // 必须真的在"插件已在 bundles"时早退并声明 bundleOnly
100
+ assert.match(body, /inBundles\.length/, "必须先判断插件是否已在 bundles");
101
+ assert.match(body, /bundleOnly:\s*true/, "必须在 bundles 命中时以 bundleOnly 早退");
102
+ // 并清理历史遗留的重复 patch 行(收敛到单点)
103
+ assert.match(body, /stripPluginEntries\(/, "必须顺手清掉历史遗留的重复 patch 行");
104
+ });
@@ -182,21 +182,34 @@ test("回归护栏:plugin 子命令必须把激活结果 return 给 setup(
182
182
  assert.match(src, /pluginResult\.hotPatch/, "应据 hotPatch 判断是否走热挂载形态");
183
183
  });
184
184
 
185
- test("硬约束:源码归我们时**必须**把激活点从 bundles 转成 patch 行(否则热加载失效、且两处并存会崩)", () => {
186
- // 事故复盘:patch 行与插件自带 bundle patch 同时生效时,dsh web 启动即
187
- // TypeError: duplicate loader entry id: dsh-remote-web
188
- // Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include)
189
- // 所以两条激活路径必须互斥。而当装置器**要把本地这份源码装进去**时(升级/自装),
190
- // 正确做法不是"保持 bundles",而是**摘掉 bundles 条目 + 写 patch 行**:
191
- // · 只留 bundles → 只在启动时读取,装完看不到新版、也拿不到热加载(实测踩到);
192
- // · 两处并存 → 启动即崩。
185
+ test("★ 硬约束(2026-09-22 **方向反转**):bundles 已在时**不得**再写 patch 行", () => {
186
+ // 【为什么反转 —— 这是一条被推翻的既有硬约束,不是"测试写松了"】
187
+ // 旧约束是「源码归我们时**必须**把激活点从 bundles 转成 patch 行」,理由是**热加载**
188
+ // (装完即生效、不用重启)。但它在真实环境里暴露了代价:
189
+ // 自动维护流程会把插件自带 bundle **周期性写回** `dsh.profile.bundles`,
190
+ // 于是"删 bundles、留 patch"这个方向被不断撤销 → 又变两处激活 → dsh web 启动即
191
+ // TypeError: duplicate loader entry id: dsh-remote-web
192
+ // Error: dsh: plugin tree failed to load: failed to apply loader entry include
193
+ // —— 注意:这不是"插件不生效",是**整个 dsh 起不来**(面板/市场都在那个进程里,
194
+ // 用户没有任何自助修复入口)。2026-09-22 用真实启动复现。
195
+ //
196
+ // 所以方向反转为:**bundle 归包管理器管、我们不动它**;我们只保证自己不写第二个激活点,
197
+ // 并把历史遗留的重复 patch 行清掉。
198
+ // 代价:失去热加载 → 由安装器随后触发的重启让新版生效
199
+ // (activateLocalCopy 在此形态下返回 hotPatch:false,调用方会重启)。
193
200
  const profile = makeProfile({ bundles: [...PLUGIN_BUNDLES, "dsh-remote-web"] });
194
201
  const relay = join(profile, "relay");
195
202
  try {
196
- const out = installPlugin(profile, relay);
197
- assert.match(out, /激活点已转为 patch 行/, "应把激活点转成 patch 行(热加载)");
198
- assert.equal((readPatch(profile).match(/- id: dsh-remote-web/g) || []).length, 1, "应恰好写一条 patch 行");
199
- assert.ok(!readBundles(profile).includes("dsh-remote-web"), "bundles 条目必须被摘掉(单一激活点)");
203
+ installPlugin(profile, relay);
204
+ assert.equal(
205
+ (readPatch(profile).match(/- id: dsh-remote-web/g) || []).length,
206
+ 0,
207
+ "bundles 已在时**不得**再写 patch 行 —— 那会造出第二个激活点,dsh web 启动即崩"
208
+ );
209
+ assert.ok(
210
+ readBundles(profile).includes("dsh-remote-web"),
211
+ "bundles 条目必须**保留**(那是包管理器/维护流程管的激活点,我们不去动它)"
212
+ );
200
213
  } finally { cleanup(profile); }
201
214
  });
202
215
 
@@ -81,14 +81,29 @@ test("面板文案:额度数据缺失时不得断言任何限制", () => {
81
81
  // 有额度数据 + 限量 → 才允许出现带宽/用量文案,且带宽值必须来自服务端
82
82
  assert.match(CLIENT_SRC, /if \(quota && quota\.limit_enabled\) \{/, "有额度且限量时才展示用量");
83
83
  assert.doesNotMatch(CLIENT_SRC, /带宽 ≈1Mbps/, "不得硬编码带宽数值,应从服务端下发");
84
- assert.match(CLIENT_SRC, /quota\.max_mbps \? "带宽 ≈" \+ quota\.max_mbps/, "带宽值服务端有才写");
85
- // 无额度数据 → 只说明是免费用户,不得出现硬编码的限额
84
+ // 【0.6.11 改断言】旧断言是
85
+ // assert.match(CLIENT_SRC, /quota\.max_mbps \? "带宽 ≈" \+ quota\.max_mbps/, "带宽值服务端有才写")
86
+ // 它编码的是**旧行为**(账号卡「更多」档位明细里的「当前:带宽 ≈N Mbps」那一行),不是不变量。
87
+ // 业主口径:「插件面板里面不要展示『更多』里的 PRO 版本流量带宽,我看你把数字都展示出来了。
88
+ // 在本地的设置面板里面,把这个功能删掉。」→ 整块(renderPlanDetail + planSpecText/freeMbpsOf)
89
+ // 已删,那个字符串在代码里不再存在。保留下来的**不变量**(比原来更强):
90
+ // 客户端不得渲染任何带宽/流量/价格**数值** —— 服务端没下发时不许编(旧约束),下发了也不摆(新口径)。
91
+ assert.doesNotMatch(CLIENT_SRC, /" Mbps"/, "面板不再渲染带宽数值(业主口径:本地面板不展示规格数字)");
92
+ assert.doesNotMatch(CLIENT_SRC, /" GB\/月,¥"/, "面板不再渲染流量/价格数值(档位明细已删)");
93
+ // 但「已限速 / 不限速」这条**定性**提示仍在,且仍由服务端字段驱动(只是不再带数值)——
94
+ // 这是上面第 ① 条不变量「服务端没说的限制一个字都不许编」的正面表达,也是必须保留的行为。
95
+ assert.match(CLIENT_SRC, /quota\.max_mbps \? remainText : "已限速 · " \+ remainText/, "「已限速」提示仍由服务端字段驱动(不再带数值)");
96
+ // 无额度数据 → 不得出现硬编码的限额(首屏文案不再挂档位名,所以这里也不再断言「免费用户」前缀)
86
97
  assert.doesNotMatch(
87
98
  CLIENT_SRC,
88
99
  /quotaPct !== null \?[^:]*:[^"]*本月流量限额 1GB/,
89
100
  "不得在拿不到额度时硬编码“本月流量限额 1GB”"
90
101
  );
91
102
  assert.doesNotMatch(CLIENT_SRC, /:\s*" · 本月流量限额 1GB"/, "硬编码限额回退已移除");
92
- // 中继报告「不限量」时也不该说限额
93
- assert.match(CLIENT_SRC, /免费用户 · 当前不限速、不限流量/, "不限量应如实说明");
103
+ // 中继报告「不限量」时也不该说限额。
104
+ // 【0.6.10 改断言】旧断言是 /免费用户 · 当前不限速、不限流量/ —— 那是**旧产品文案**(首屏挂档位名、
105
+ // 用「流量」这个词),不是不变量;业主口径改为「首页面不展示会员相关信息」「流量一律叫额度」,
106
+ // 所以文案变成「当前不限速、不限额度」。这里保留的**不变量**是:中继明确报告不限量时必须如实说明
107
+ // (下面这条分支仍在、仍由服务端字段驱动),不是那句具体措辞。
108
+ assert.match(CLIENT_SRC, /当前不限速、不限额度/, "不限量应如实说明(首屏不再带档位名)");
94
109
  });
@@ -89,7 +89,8 @@ function loadPlugin(opts = {}) {
89
89
  class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
90
90
 
91
91
  const localStorage = {
92
- _store: new Map(),
92
+ // opts.localStorageSeed:预置 key/value(如 dsh-remote-marketing-more-v1=1 → 营销入口已收纳进「更多」)
93
+ _store: new Map(Object.entries(opts.localStorageSeed || {})),
93
94
  getItem(k) { return this._store.has(k) ? this._store.get(k) : null; },
94
95
  setItem(k, v) { this._store.set(k, String(v)); },
95
96
  removeItem(k) { this._store.delete(k); },
@@ -409,6 +410,55 @@ test("E2EE 徽标:未登录 / host 未下发 e2ee → 不打扰(不渲染状
409
410
  assert.equal(e2eeLine(oldHost.render()), undefined, "未下发 service.e2ee 时不应渲染状态行");
410
411
  });
411
412
 
413
+ test("「更多」只收纳营销入口:档位规格数字(Mbps / GB / 价格)不再出现在账号卡里", () => {
414
+ // 收纳态(localStorage 里记着用户关掉过引导)=「更多」里会出现那两条折叠入口的场景
415
+ const plugin = loadPlugin({ localStorageSeed: { "dsh-remote-marketing-more-v1": "1" } });
416
+ plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
417
+ let tree = plugin.render();
418
+
419
+ // 真实用户路径:点披露按钮展开「更多」
420
+ const toggle = find(tree, (n) => n.props?.id === "dru-account-more");
421
+ assert.ok(toggle, "账号卡应有「更多」披露按钮");
422
+ assert.equal(toggle.props["aria-expanded"], "false", "默认收起");
423
+ toggle.props.onClick();
424
+ tree = plugin.render();
425
+ const body = find(tree, (n) => n.props?.className === "dru-more-body");
426
+ assert.ok(body, "「更多」内容体应在 DOM 里");
427
+ assert.equal(body.props.hidden, false, "点击后应展开(hidden=false)");
428
+
429
+ // 【0.6.11】档位明细整块删除:标题「套餐与额度」、PRO / Pro Max 规格行、价格与结论句都不再渲染
430
+ assert.ok(!textHas(body, "套餐与额度"), "「套餐与额度」标题应删除");
431
+ assert.ok(!textHas(body, "Mbps"), "不得再渲染带宽数值(业主口径:本地面板不展示 PRO 版本流量带宽)");
432
+ assert.ok(!textHas(body, "GB"), "不得再渲染流量数值");
433
+ assert.ok(!textHas(body, "¥"), "不得再渲染价格");
434
+ assert.ok(!textHas(body, "以套餐页为准"), "随档位明细一起删掉的结论句不得残留");
435
+ // 整张账号卡同样一个规格数字都不许有
436
+ assert.ok(!textHas(tree, "Mbps"), "账号卡里不得出现 Mbps");
437
+ assert.ok(!textHas(tree, "¥"), "账号卡里不得出现价格");
438
+
439
+ // 但两条**折叠的营销入口**仍在(业主只要数字走,入口留着)
440
+ const upsell = find(body, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("升级 PRO")));
441
+ const invite = find(body, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("带新用户换会员")));
442
+ assert.ok(upsell, "「更多」里应保留「🚀 升级 PRO」入口");
443
+ assert.ok(invite, "「更多」里应保留「🎁 带新用户换会员」入口");
444
+ assert.equal(typeof upsell.props.onClick, "function");
445
+ assert.equal(typeof invite.props.onClick, "function");
446
+
447
+ // 源码级:函数与其调用都删掉了(不留死代码)。
448
+ // ⚠️ 断言「代码形态」而不是裸标识符 —— 注释里**故意**留着「这里原本是什么、为什么删」的说明
449
+ // (与 settings-entry.test.mjs 的同款约定:注释里允许出现说明文字)。
450
+ assert.doesNotMatch(SOURCE, /function renderPlanDetail\(/, "renderPlanDetail 函数必须删除");
451
+ assert.doesNotMatch(SOURCE, /^\s*renderPlanDetail\(\),/m, "renderPlanDetail 的调用点必须删除");
452
+ assert.doesNotMatch(SOURCE, /function planSpecText\(/, "planSpecText 定义必须删除");
453
+ assert.doesNotMatch(SOURCE, /planSpecText\("/, "planSpecText 的调用必须删除");
454
+ assert.doesNotMatch(SOURCE, /function freeMbpsOf\(/, "freeMbpsOf 定义必须删除");
455
+ assert.doesNotMatch(SOURCE, /=\s*freeMbpsOf\(\)/, "freeMbpsOf 的调用必须删除");
456
+ assert.doesNotMatch(SOURCE, /var FREE_FACTS\s*=/, "FREE_FACTS 定义必须删除(只被 freeMbpsOf 使用)");
457
+ assert.doesNotMatch(SOURCE, /" Mbps"|" GB\/月,¥"/, "不得残留渲染规格数字的字符串拼接");
458
+ // 不变量仍在:planFactsOf 仍被 upgradePath 用来取档位展示名(不是死代码)
459
+ assert.match(SOURCE, /var name = planFactsOf\(key\)\.name;/);
460
+ });
461
+
412
462
  test("E2EE 徽标(源码级约束):client 含徽标字段/文案与 reason 映射表", () => {
413
463
  assert.match(SOURCE, /service\.e2ee/);
414
464
  assert.match(SOURCE, /\.e2ee-state\.json/);
@@ -189,6 +189,23 @@ test("入口迁移:注册 settings.section 栏目(id/order/label),移除
189
189
  assert.equal(plugin.registered.has("dsh-remote-panel"), false, "浮动配置面板不应再注册");
190
190
  });
191
191
 
192
+ test("入口迁移:settings.section 里**只有一个**栏目(id=dsh-remote)——微信机器人不再是第二个栏目", () => {
193
+ const plugin = loadPlugin();
194
+ // 再跑一次 inject 回调(宿主重开设置页会重跑),断言不会冒出第二个栏目
195
+ plugin.injects.get("settings.section")();
196
+ const sections = [...plugin.metas.values()].filter((m) => m.name === "settings.section");
197
+ assert.deepEqual(sections.map((m) => m.id), ["dsh-remote"],
198
+ `settings.section 只应有 dsh-remote 一个栏目,实际:${sections.map((m) => m.id).join(",")}`);
199
+ assert.equal(plugin.metas.has("dsh-remote-wechat"), false,
200
+ "「🤖 微信机器人」不得再注册成独立栏目(0.6.11:搬进面板的第三个 tab「微信机器人通道」)");
201
+ assert.doesNotMatch(SOURCE, /dsh-remote-wechat/, "源码里也不得残留第二个栏目的 id");
202
+ // 搬进去之后仍然存在:面板 tab 条里的第三个 tab + 微信绿泡泡图标(行为断言见 wechat-bot-ui.test.mjs)
203
+ assert.match(SOURCE, /"微信机器人通道"/, "微信机器人 tab 必须在面板里");
204
+ assert.match(SOURCE, /dru-wx-ico/, "微信 tab 必须带绿泡泡图标 class");
205
+ // 槽注入次数仍是两次(settings.section + shell.overlay),没有偷偷加第三个 inject
206
+ assert.equal(plugin.injects.size, 2, `inject 的槽名应仍是 settings.section + shell.overlay,实际:${[...plugin.injects.keys()].join(",")}`);
207
+ });
208
+
192
209
  test("登录态账号区:无「切换账号」,有「退出登录」,头部/关于卡文案为「远程访问」", () => {
193
210
  const plugin = loadPlugin();
194
211
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-test" }, service: { running: false } };
@@ -15,9 +15,10 @@
15
15
  //
16
16
  // 手法与 connect-loop.test.mjs 一致:真实分支 + PATH 上的假 launchctl/pgrep/ps/npx,绝不碰本机真实服务。
17
17
  import assert from "node:assert/strict";
18
+ import { execFileSync } from "node:child_process";
18
19
  import http from "node:http";
19
20
  import { chmodSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
20
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
21
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
21
22
  import os from "node:os";
22
23
  import path from "node:path";
23
24
  import test from "node:test";
@@ -36,7 +37,9 @@ async function waitFor(fn, { timeout = 5000, step = 10 } = {}) {
36
37
  }
37
38
  }
38
39
 
39
- const ALLOWED_EVENT_KEYS = new Set(["name", "at", "version", "os", "arch", "node", "fail_code"]);
40
+ const ALLOWED_EVENT_KEYS = new Set(["name", "at", "version", "harness_version", "channel", "os", "arch", "node", "fail_code"]);
41
+ /** 服务端 TELEMETRY_MISC_RE 的等价物:不匹配的字符串字段会被服务端静默清空。 */
42
+ const SERVER_MISC_RE = /^[A-Za-z0-9._-]{0,32}$/;
40
43
  /** 禁止出现在遥测 payload 里的字段/取值(隐私边界;见 docs/telemetry.md「不采集什么」)。 */
41
44
  const FORBIDDEN = [
42
45
  "phone", "password", "passwd", "email", "token", "secret", "authorization", "bearer",
@@ -236,6 +239,11 @@ function assertContractRequest(req, { eventNames, failCodes }) {
236
239
  assert.equal(typeof ev.os, "string");
237
240
  assert.equal(typeof ev.arch, "string");
238
241
  assert.match(String(ev.node), /^\d+$/, "node 只能是主版本号数字字符串");
242
+ // 宿主 DSH 版本 + 更新通道:每条事件都必须带;值必须过服务端字符类(否则服务端会静默清空)
243
+ assert.equal(typeof ev.harness_version, "string", "每条事件必须带 harness_version(未知 = 空串,但不能缺字段)");
244
+ assert.equal(typeof ev.channel, "string", "每条事件必须带 channel(未知 = 空串,但不能缺字段)");
245
+ assert.match(ev.harness_version, SERVER_MISC_RE, "harness_version 必须匹配服务端 TELEMETRY_MISC_RE");
246
+ assert.match(ev.channel, SERVER_MISC_RE, "channel 必须匹配服务端 TELEMETRY_MISC_RE");
239
247
  if (ev.name === "install_failed" || ev.name === "update_failed") {
240
248
  assert.ok(failCodes.includes(ev.fail_code), "fail_code 必须在白名单内:" + ev.fail_code);
241
249
  }
@@ -365,12 +373,173 @@ test("隐私审计:对抗性 extra 字段(手机号/hostname/machine_fp/路
365
373
  for (const bad of ["phone", "password", "hostname", "machine_fp", "username", "device_id", "\\bip\\b", "path"]) {
366
374
  assert.ok(!new RegExp(bad).test(body), "payload 构造点里不得出现禁止字段:" + bad);
367
375
  }
368
- for (const allowed of ["name", "at", "version", "os", "arch", "node", "fail_code"]) {
376
+ for (const allowed of ["name", "at", "version", "harness_version", "channel", "os", "arch", "node", "fail_code"]) {
369
377
  assert.ok(body.includes(allowed), "payload 构造点应只含白名单字段:" + allowed);
370
378
  }
371
379
  } finally { await env.restore(); relay.srv.close(); }
372
380
  });
373
381
 
382
+ // ─────────────── 宿主 DSH 版本 + 更新通道(答得出"是不是 DSH 兼容性问题") ───────────────
383
+ //
384
+ // 现场(2026-09):注册 48 人只有 27 人成功,但"是不是兼容性问题"当天**答不出来** —— 遥测里的
385
+ // `version` 是**插件自己**的版本,插件跑在哪个 DSH 上从来没上报过;这类数据**不能回填**,
386
+ // 所以必须先埋上。下面锁死:
387
+ // ① 宿主版本是**运行时经验取到**的(按真实 dsh 启动形态:argv[1] → 上一级 package.json);
388
+ // ② 未知/非法一律退化为空串(绝不猜、绝不抛、绝不改造原始值 —— 改写过的版本号比"未知"更糟);
389
+ // ③ 通道 latest/beta 可分(否则预发用户与稳定用户混在一起,同样答不出问题);
390
+ // ④ 既有事件名与既有字段一字不改。
391
+
392
+ const INDEX_URL = new URL("../lib/index.js", import.meta.url).href;
393
+
394
+ /** 造一个**真实布局**的假 DSH 宿主:<root>/package.json + <root>/lib/bin.js(argv[1] 会指向它)。 */
395
+ async function fakeHarness(root, { name = "@deepseek-ai/dsh", version = "9.9.9-rc.1", entry = "" } = {}) {
396
+ const lib = path.join(root, "lib");
397
+ await mkdir(lib, { recursive: true });
398
+ await writeFile(path.join(root, "package.json"), JSON.stringify({ name, version, type: "module" }));
399
+ const entryFile = path.join(lib, "bin.js");
400
+ await writeFile(entryFile, entry);
401
+ return entryFile;
402
+ }
403
+
404
+ /** 子进程里按"真实 dsh 启动形态"跑一遍:argv[1] = 宿主 lib/bin.js,把一条事件打成 JSON。 */
405
+ function payloadOfHost(entryFile, extraEnv = {}) {
406
+ return JSON.parse(execFileSync(process.execPath, [entryFile], {
407
+ encoding: "utf8", env: { ...process.env, ...extraEnv },
408
+ }).trim());
409
+ }
410
+
411
+ /** 假宿主入口的内容:像真 dsh 一样"主模块就是自己的 lib/bin.js",插件在同一个进程里被装载。 */
412
+ const PRINT_EVENT = `
413
+ const m = await import(${JSON.stringify(INDEX_URL)});
414
+ console.log(JSON.stringify(m.__telemetryInternals.eventOf("plugin_loaded")));
415
+ `;
416
+
417
+ test("宿主版本:运行时从主模块解析(argv[1] → 上一级 package.json);未知一律空串且不抛", async () => {
418
+ const root = await mkdtemp(path.join(os.tmpdir(), "dsh-harness-"));
419
+ try {
420
+ const pkgDir = path.join(root, "pm", "node_modules", "@deepseek-ai", "dsh");
421
+ const entry = await fakeHarness(pkgDir, { version: "0.1.5-rc.2" });
422
+ assert.equal(TELEMETRY.harnessVersionFromEntry(entry), "0.1.5-rc.2", "应取到宿主 package.json 里的 version");
423
+ // 真实形态:`/opt/homebrew/bin/dsh` 是**软链** —— 不先 realpath 就会去读错的"上一级"
424
+ const link = path.join(root, "dsh");
425
+ try {
426
+ await symlink(entry, link);
427
+ assert.equal(TELEMETRY.harnessVersionFromEntry(link), "0.1.5-rc.2", "argv[1] 是软链时必须先落回真实文件");
428
+ } catch (e) {
429
+ if (e?.code !== "EPERM") throw e; // Windows 无权限建软链 → 跳过该断言(macOS/Linux 覆盖真实形态)
430
+ }
431
+ // 未知的四条路:不存在 / 空 / 不是 DSH / 清单损坏
432
+ assert.equal(TELEMETRY.harnessVersionFromEntry(path.join(root, "nope", "bin.js")), "", "入口不存在 → 空串");
433
+ assert.equal(TELEMETRY.harnessVersionFromEntry(""), "", "空路径 → 空串");
434
+ assert.equal(TELEMETRY.harnessVersionFromEntry(undefined), "", "undefined → 空串(绝不抛)");
435
+ const other = await fakeHarness(path.join(root, "other"), { name: "not-dsh-at-all", version: "1.2.3" });
436
+ assert.equal(TELEMETRY.harnessVersionFromEntry(other), "", "非 DSH 启动(Electron/嵌入/别的脚本)绝不猜版本");
437
+ await writeFile(path.join(root, "other", "package.json"), "{ 这不是 JSON");
438
+ assert.equal(TELEMETRY.harnessVersionFromEntry(other), "", "清单损坏 → 空串,且不得抛");
439
+ // 本进程(测试进程 argv[1] = 本用例文件)→ 不可判定 = 空串;事件照常构造
440
+ assert.equal(TELEMETRY.harnessVersion(), "", "宿主不可判定时必须退化为空串(不是猜、不是抛)");
441
+ assert.equal(TELEMETRY.eventOf("plugin_loaded").harness_version, "", "取不到宿主版本也要照常构造事件(空串)");
442
+ } finally { await rm(root, { recursive: true, force: true }); }
443
+ });
444
+
445
+ test("payload:宿主版本与通道按真实启动形态进事件;beta 可区分;非法值发空串不改写", async () => {
446
+ const root = await mkdtemp(path.join(os.tmpdir(), "dsh-harness-e2e-"));
447
+ try {
448
+ const dshRoot = path.join(root, "node_modules", "@deepseek-ai", "dsh");
449
+ const entry = await fakeHarness(dshRoot, { version: "0.1.5-rc.2", entry: PRINT_EVENT });
450
+ const stable = payloadOfHost(entry);
451
+ assert.equal(stable.harness_version, "0.1.5-rc.2", "★宿主版本必须真的进 payload(这正是今天答不出来的那个数)");
452
+ // 通道由**本插件当前版本**推导:预发版 → beta,正式版 → latest(见 lib/index.js 的 updateChannel)。
453
+ // ⚠️ 不能硬编码 "latest" —— 那样每次切到 beta 发版、这条用例必红,而产品行为其实完全正确。
454
+ // 这里真正要守的不变量是「通道与当前版本自洽」(以及下面「beta 装机必须能与稳定版区分」)。
455
+ const pluginVersion = TELEMETRY.eventOf("plugin_loaded").version;
456
+ assert.equal(stable.channel, /-/.test(String(pluginVersion)) ? "beta" : "latest",
457
+ `通道必须与当前插件版本自洽(预发→beta / 正式→latest);当前版本 ${pluginVersion}`);
458
+ assert.equal(stable.version, TELEMETRY.eventOf("plugin_loaded").version, "插件自身版本字段保持不变(两个 version 不是一回事)");
459
+ assert.equal(payloadOfHost(entry, { DSH_UPDATE_TAG: "beta" }).channel, "beta", "★beta 装机必须能与稳定版区分开");
460
+ assert.equal(payloadOfHost(entry, { DSH_UPDATE_TAG: "1.2.3-beta.1" }).channel, "1.2.3-beta.1", "显式指定版本号也要如实上报");
461
+ // 非 DSH 宿主:字段仍在(契约形状不变),值为空串 —— 降级而不是丢字段 / 报错
462
+ const plain = await fakeHarness(path.join(root, "plain"), { name: "some-other-cli", version: "9.9.9", entry: PRINT_EVENT });
463
+ assert.equal(payloadOfHost(plain).harness_version, "", "非 DSH 宿主 → 空串(不猜一个错的版本上去)");
464
+ // 服务端字符类不合法的版本串(semver 构建元数据 / 注入式取值):发空串,绝不改造后发出去
465
+ const weird = await fakeHarness(path.join(root, "weird"), { version: "1.2.3+build/../../etc", entry: PRINT_EVENT });
466
+ const w = payloadOfHost(weird);
467
+ assert.equal(w.harness_version, "", "非法字符 → 空串(不改写、不截断、不拼接)");
468
+ assert.match(w.channel, SERVER_MISC_RE, "非法宿主版本不得把其他字段带坏");
469
+ } finally { await rm(root, { recursive: true, force: true }); }
470
+ });
471
+
472
+ test("清洗:只放行服务端字符类([A-Za-z0-9._-] ≤32),其余发空串且绝不改造原始值", () => {
473
+ const t = TELEMETRY.harnessToken;
474
+ for (const ok of ["0.1.5-rc.2", "latest", "beta", "1.2.3", "2", "a".repeat(32)]) {
475
+ assert.equal(t(ok), ok, "合法值必须**原样**保留:" + ok);
476
+ }
477
+ for (const bad of ["1.2.3+build.7", "1.2.3/../etc", "mac.local hostname", "真.1.2.3", "a".repeat(33), " 1.2.3", "1.2.3 ", "", " ", "1.2.3\n"]) {
478
+ assert.equal(t(bad), "", "非法值必须发空串(不得截断/改写):" + JSON.stringify(bad));
479
+ }
480
+ assert.equal(t(undefined), "", "undefined → 空串");
481
+ assert.equal(t(null), "", "null → 空串");
482
+ // 清洗后必须 100% 落在服务端字符类里(否则服务端会静默把它抹成空 —— 数据看起来像"没上报")
483
+ for (const v of ["0.1.5-rc.2", "1.2.3+build", "a".repeat(33), "", "真", undefined]) {
484
+ assert.ok(SERVER_MISC_RE.test(t(v)), "清洗后的值必须 100% 落在服务端字符类内:" + JSON.stringify(v));
485
+ }
486
+ });
487
+
488
+ test("冻结契约:既有事件名与既有字段一字不改(本次只新增 harness_version / channel)", () => {
489
+ // 【本次改动】白名单**末尾追加** wechat_bound / wechat_unbound(微信机器人通道的绑定态跳变,
490
+ // 见 wechat-bind-telemetry.test.mjs);既有 12 个名字一字未动、顺序未动,
491
+ // 服务端 TELEMETRY_EVENTS 同步追加同样两个名字。
492
+ //
493
+ // 这条断言的本意是「**既有**事件名/字段不许删改」,而不是"白名单永远不许变长":
494
+ // 所以正确改法是**在预期集合里追加新名字**(下面那条 deepEqual 仍是全等比对,
495
+ // 任何删除 / 改名 / 重排 / 未声明的追加都会红),而不是把它弱化成"包含即可"。
496
+ // 为了把「既有项一个都不能少」这条不变量显式钉死(而不是只靠全等比对间接保证),
497
+ // 再单独比一次**前缀**:前 12 项必须与冻结前的历史名单逐字相同、顺序相同。
498
+ const FROZEN_EVENT_NAMES = [
499
+ "install_started", "install_failed", "runtime_ready", "bridge_started", "bridge_registered",
500
+ "tunnel_disconnected", "first_remote_ok", "plugin_loaded", "panel_opened", "harness_restart",
501
+ "update_started", "update_failed",
502
+ ];
503
+ assert.deepEqual(TELEMETRY.eventNames.slice(0, FROZEN_EVENT_NAMES.length), FROZEN_EVENT_NAMES,
504
+ "既有 12 个事件名一字不能改、不能删、不能重排(只允许在末尾追加新事件)");
505
+ assert.deepEqual(TELEMETRY.eventNames, [...FROZEN_EVENT_NAMES, "wechat_bound", "wechat_unbound"],
506
+ "事件名白名单只允许在末尾追加:本次追加微信通道的绑定/解绑,其余必须与服务端字面量冻结对齐");
507
+ const ev = TELEMETRY.eventOf("bridge_registered");
508
+ assert.deepEqual(Object.keys(ev), ["name", "at", "version", "harness_version", "channel", "os", "arch", "node"],
509
+ "字段白名单:既有的 7 个一个不少,只多 harness_version / channel");
510
+ assert.match(String(ev.version), /^\d+\.\d+\.\d+/, "version 仍是插件自身版本(非宿主版本)");
511
+ assert.equal(ev.os, process.platform);
512
+ assert.equal(ev.arch, process.arch);
513
+ assert.match(String(ev.node), /^\d+$/);
514
+ assert.ok(!("fail_code" in ev), "fail_code 仍只出现在 *_failed 事件上");
515
+ assert.equal(TELEMETRY.eventOf("user_login"), null, "白名单外事件名照旧不发");
516
+ // 通道与"一键更新"同源:面板看到的通道 = 上报的通道(两处口径必须是一处)
517
+ assert.equal(ev.channel, TELEMETRY.updateTag);
518
+ });
519
+
520
+ test("flush 路径:宿主版本/通道经磁盘队列读回后仍在,并随批次真实送达", async () => {
521
+ const relay = await startFakeRelay();
522
+ const env = await setup();
523
+ try {
524
+ await writeConfig(env.relayDir, relay.port);
525
+ // 模拟上一进程落盘的队列(含新字段)—— 重启前的事件不能丢归因
526
+ await writeFile(path.join(env.relayDir, ".telemetry-queue.json"), JSON.stringify({
527
+ v: 1,
528
+ events: [{
529
+ name: "harness_restart", at: Date.now() - 3000, version: "0.0.0-test",
530
+ harness_version: "0.1.5-rc.2", channel: "beta", os: process.platform, arch: process.arch, node: "22",
531
+ }],
532
+ }), { mode: 0o600 });
533
+ boot(env.relayDir); // 装载即读回磁盘队列并补发
534
+ const sent = await waitFor(() => (relay.telemetry.length ? relay.telemetry : false));
535
+ assert.ok(sent, "遗留队列必须在下次装载时补发");
536
+ const ev = sent[0].body.events.find((e) => e.name === "harness_restart");
537
+ assert.equal(ev.harness_version, "0.1.5-rc.2", "磁盘读回后宿主版本不得丢");
538
+ assert.equal(ev.channel, "beta", "磁盘读回后通道不得丢");
539
+ assertContractRequest(sent[0], { eventNames: TELEMETRY.eventNames, failCodes: TELEMETRY.failCodes });
540
+ } finally { await env.restore(); relay.srv.close(); }
541
+ });
542
+
374
543
  test("开关:DSH_REMOTE_TELEMETRY=0 → 零请求零文件(连 install_id 都不生成)", async () => {
375
544
  const relay = await startFakeRelay();
376
545
  const env = await setup({ runtime: false });
@@ -390,6 +559,10 @@ test("开关:DSH_REMOTE_TELEMETRY=0 → 零请求零文件(连 install_id
390
559
  assert.equal(TELEMETRY.installId(env.relayDir), null, "关闭后不得生成 install_id");
391
560
  assert.equal(TELEMETRY.queueOf(env.relayDir).length, 0);
392
561
  assert.equal(TELEMETRY.record(env.relayDir, "install_started"), false, "关闭后不得入队");
562
+ // 新增的宿主版本/通道字段不改变开关语义:关闭 = 连"带新字段的事件"也不入队、不出机器
563
+ assert.equal(TELEMETRY.record(env.relayDir, "plugin_loaded"), false, "关闭后带新字段的事件同样不得入队");
564
+ assert.equal(relay.count("/api/telemetry/events"), 0, "关闭后不得出现任何带 harness_version/channel 的请求");
565
+ assert.equal(relay.telemetry.some((r) => r.raw.includes("harness_version")), false, "关闭后不得有任何新字段上过网络");
393
566
  } finally { host.close(); routes.dispose(); }
394
567
  } finally { await env.restore(); relay.srv.close(); }
395
568
  });
@@ -602,6 +775,20 @@ test("面板可见性:关于卡片写明「匿名统计 + 关闭方式」,
602
775
  for (const line of ["不采集什么", "如何关闭", "hostname", "machine_fp", "IP", "DSH_REMOTE_TELEMETRY=0"]) {
603
776
  assert.ok(doc.includes(line), "docs/telemetry.md 必须覆盖:" + line);
604
777
  }
778
+ // 【本次改动】wechat_bound / wechat_unbound 已同步写进公开披露文档(docs/telemetry.md
779
+ // 的「事件名白名单」小节 + 微信通道绑定/解绑的语义与分母边界),因此这里**恢复成硬断言**:
780
+ // 每一个能出机器的事件名都必须在披露文档里逐条列出,没有豁免。
605
781
  for (const name of TELEMETRY.eventNames) assert.ok(doc.includes(name), "docs/telemetry.md 必须列出事件:" + name);
782
+ // 【本次改动】披露文档不只要列出名字,还必须写明这两条事件的**语义与分母边界** ——
783
+ // 否则看板上的"绑定数/存量"会被读成服务端的实时台账:
784
+ // ① 它们是客户端上报的事件(不是服务端的实时存量);
785
+ // ② 首次观测到 bound:true 不算绑定事件(这条规则写错就会把历史存量每天虚报一遍)。
786
+ for (const line of [
787
+ "首次观测不算绑定",
788
+ "客户端上报的事件,不是服务端的实时存量",
789
+ "bot_id",
790
+ ]) {
791
+ assert.ok(doc.includes(line), "docs/telemetry.md 必须写明微信通道事件的口径边界:" + line);
792
+ }
606
793
  for (const code of TELEMETRY.failCodes) assert.ok(doc.includes(code), "docs/telemetry.md 必须列出失败码:" + code);
607
794
  });
@@ -82,6 +82,11 @@ async function plantInstallation(tempHome, opts = {}) {
82
82
  await mkdir(path.join(relayDir, "clients", "dsh-remote"), { recursive: true });
83
83
  await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
84
84
  phone: "13800000000", password: "pw", device_id: "dev", device_private_key: "pk", local_key: "lk",
85
+ // ⚠️ api_url 必须显式指向本地不可达端口 —— 缺了它会回落到 DEFAULT_API(生产中继),
86
+ // 而插件 boot 时会跑 reportInstallOnce → relayToken → bridgeSecretOf → fetch /api/public-config,
87
+ // 于是这个「卸载」用例会去打**生产**。测试期网络护栏会把它拦成 ENETUNREACH(所以不会真污染),
88
+ // 但那意味着用例是在「断网」分支上通过的,而不是它声称的场景。
89
+ api_url: "http://127.0.0.1:1",
85
90
  }));
86
91
  await writeFile(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub");
87
92
  await writeFile(path.join(relayDir, "clients", "dsh-remote", "dsh-bridge.mjs"), "// bridge stub");