@mrrisega/dsh-remote 0.7.10-beta.3 → 0.7.12-beta.1

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 (74) hide show
  1. package/clients/dsh-remote/agent/README.md +412 -0
  2. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  3. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  4. package/clients/dsh-remote/agent/codex.mjs +736 -0
  5. package/clients/dsh-remote/agent/detect.mjs +399 -0
  6. package/clients/dsh-remote/agent/events.mjs +511 -0
  7. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  8. package/clients/dsh-remote/agent/index.mjs +339 -0
  9. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  10. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  11. package/clients/dsh-remote/agent/proc.mjs +179 -0
  12. package/clients/dsh-remote/agent/version.mjs +148 -0
  13. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  14. package/clients/dsh-remote/decision-poller.mjs +235 -0
  15. package/clients/dsh-remote/dsh-bridge.mjs +220 -1
  16. package/clients/dsh-remote/im-format.mjs +372 -0
  17. package/clients/dsh-remote/push-reporter.mjs +54 -0
  18. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  19. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  20. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  21. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  22. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  23. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  24. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  25. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  26. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  27. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  28. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  29. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  30. package/clients/dsh-remote/test/im-format.test.mjs +229 -0
  31. package/clients/dsh-remote/test/upstream-discovery.test.mjs +203 -2
  32. package/clients/dsh-remote/test/wecom-bot.test.mjs +1718 -0
  33. package/clients/dsh-remote/upstream-discovery.mjs +495 -43
  34. package/clients/dsh-remote/wecom-bot.mjs +2670 -0
  35. package/dsh-setup.mjs +95 -9
  36. package/package.json +2 -2
  37. package/packages/dsh-remote-web/lib/client.js +1377 -292
  38. package/packages/dsh-remote-web/lib/index.js +419 -12
  39. package/packages/dsh-remote-web/package.json +1 -1
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  53. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  54. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +258 -0
  55. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  56. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +2444 -0
  57. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +18 -3
  58. package/packages/dsh-remote-web/test/activation-entry.test.mjs +11 -1
  59. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +8 -3
  60. package/packages/dsh-remote-web/test/connect-ui.test.mjs +16 -4
  61. package/packages/dsh-remote-web/test/im-bots.test.mjs +400 -0
  62. package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
  63. package/packages/dsh-remote-web/test/login-fast.test.mjs +104 -0
  64. package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +17 -6
  65. package/packages/dsh-remote-web/test/panel-ia.test.mjs +996 -0
  66. package/packages/dsh-remote-web/test/password-reset.test.mjs +11 -1
  67. package/packages/dsh-remote-web/test/plugin-link.test.mjs +78 -0
  68. package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +14 -5
  69. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +69 -119
  70. package/packages/dsh-remote-web/test/self-manage.test.mjs +5 -1
  71. package/packages/dsh-remote-web/test/settings-entry.test.mjs +42 -25
  72. package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
  73. package/packages/dsh-remote-web/test/telemetry.test.mjs +6 -4
  74. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +57 -130
@@ -11,6 +11,7 @@ import os from "node:os";
11
11
  import path from "node:path";
12
12
  import test from "node:test";
13
13
  import vm from "node:vm";
14
+ import { openEntry } from "./lib-panel-ia.mjs";
14
15
  import { apply } from "../lib/index.js";
15
16
 
16
17
  const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
@@ -135,6 +136,7 @@ function loadPlugin(opts = {}) {
135
136
  const removed = [];
136
137
  const states = [];
137
138
  let hook = 0;
139
+ let iaOpened = false;
138
140
 
139
141
  const react = {
140
142
  createElement(type, props, ...children) { return { type, props: props || {}, children }; },
@@ -218,7 +220,15 @@ function loadPlugin(opts = {}) {
218
220
  requests,
219
221
  removed,
220
222
  states,
221
- render() { hook = 0; return registered.get("dsh-remote")({ close() {} }); },
223
+ render() {
224
+ hook = 0;
225
+ let tree = registered.get("dsh-remote")({ close() {} });
226
+ if (!iaOpened) {
227
+ iaOpened = true;
228
+ if (openEntry(tree, opts.autoModal || "account")) { hook = 0; tree = registered.get("dsh-remote")({ close() {} }); }
229
+ }
230
+ return tree;
231
+ },
222
232
  };
223
233
  }
224
234
 
@@ -107,6 +107,59 @@ test("pluginLinkUsable:空目录/畸形链接一律判为不可用(不能只
107
107
  }
108
108
  });
109
109
 
110
+ // ─────────────── ①-b 拷贝形态的版本漂移:桌面端 pnpm hoisted 残留(2026-11-07 实测)───────────────
111
+
112
+ /**
113
+ * 背景:桌面 profile 用 pnpm `nodeLinker: hoisted`,`file:` 依赖被**复制**进 profile 的
114
+ * `node_modules/<id>`(不是链接)。npx 安装器只替换 `dsh-remote-web-plugin/`(file: 的源目录),
115
+ * 那份复制品还是上一版;相邻 beta 之间 `lib/index.js` 往往逐字节相同,只有 package.json 的版本号变了。
116
+ * 旧判据只看入口 ⇒ 判「已就绪」⇒ 宿主继续 import 旧 package.json:
117
+ * 面板版本号是旧的、`/self/update-check` 一直 outdated,用户反复 npx 都"装不上新版"。
118
+ */
119
+
120
+ test("pluginLinkUsable:入口相同但 package.json 是旧版 → 判为不可用(版本号永远旧的根因)", () => {
121
+ const { pluginLinkUsable } = loadLinkFns();
122
+ const root = tmpDir();
123
+ try {
124
+ const local = makePluginDir(path.join(root, "local"));
125
+ fs.writeFileSync(path.join(local, "package.json"), JSON.stringify({ name: PLUGIN_ID, version: "0.7.10-beta.10" }));
126
+ const nm = path.join(root, "node_modules", PLUGIN_ID);
127
+ fs.mkdirSync(path.dirname(nm), { recursive: true });
128
+
129
+ // 与源目录完全一致的整目录拷贝 → 可用
130
+ fs.cpSync(local, nm, { recursive: true });
131
+ assert.equal(pluginLinkUsable(nm, local), true, "同版本整目录拷贝 → 可用");
132
+
133
+ // ★ 桌面端实测形态:hoisted 复制品还是上一版,入口逐字节相同
134
+ fs.writeFileSync(path.join(nm, "package.json"), JSON.stringify({ name: PLUGIN_ID, version: "0.7.10-beta.3" }));
135
+ assert.equal(pluginLinkUsable(nm, local), false,
136
+ "★旧版 package.json 的拷贝不得判为已就绪(否则宿主 import 旧 package.json,版本号永远旧)");
137
+ } finally {
138
+ fs.rmSync(root, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test("ensurePluginLinked:旧版整目录拷贝会被重建,装载版本随之更新", () => {
143
+ const { ensurePluginLinked } = loadLinkFns();
144
+ const root = tmpDir();
145
+ try {
146
+ const local = makePluginDir(path.join(root, "local"));
147
+ fs.writeFileSync(path.join(local, "package.json"), JSON.stringify({ name: PLUGIN_ID, version: "0.7.10-beta.10" }));
148
+ const profile = path.join(root, "profile");
149
+ const nm = path.join(profile, "node_modules", PLUGIN_ID);
150
+ fs.mkdirSync(path.dirname(nm), { recursive: true });
151
+ fs.cpSync(local, nm, { recursive: true });
152
+ fs.writeFileSync(path.join(nm, "package.json"), JSON.stringify({ name: PLUGIN_ID, version: "0.7.10-beta.3" }));
153
+
154
+ const changed = ensurePluginLinked(profile, local);
155
+ assert.equal(changed, true, "旧版拷贝必须被判为需要重建(幂等判据不得放行)");
156
+ assert.equal(JSON.parse(fs.readFileSync(path.join(nm, "package.json"), "utf8")).version, "0.7.10-beta.10",
157
+ "★重建后 node_modules/<id>/package.json 必须与源目录同版");
158
+ } finally {
159
+ fs.rmSync(root, { recursive: true, force: true });
160
+ }
161
+ });
162
+
110
163
  // ─────────────── ② 注入 Windows 的失败形态:不抛错但落盘是空壳 ───────────────
111
164
 
112
165
  /** 造一个 fs 代理:可按需让某个策略"静默失败"(不抛错、也不产出任何东西)。 */
@@ -292,6 +345,31 @@ test("端到端:挂载失败必须中止安装,且**不写** patch(宁可
292
345
  }
293
346
  });
294
347
 
348
+ test("端到端:node_modules/<id> 是旧版整目录拷贝时,重装必须把版本刷到当前包(桌面端 hoisted 现场)", async () => {
349
+ const root = tmpDir();
350
+ try {
351
+ const profile = makeProfile(root);
352
+ await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
353
+
354
+ // 复现桌面端现场:pnpm hoisted 的 file: 依赖是**整目录拷贝**,源目录升级后这份还是上一版,
355
+ // 且相邻 beta 的 lib/index.js 常逐字节相同 —— 只有 package.json 的版本号能说明它旧了。
356
+ const nm = path.join(profile, "node_modules", PLUGIN_ID);
357
+ fs.rmSync(nm, { recursive: true, force: true });
358
+ fs.cpSync(PLUGIN_SRC, nm, { recursive: true });
359
+ const currentPkg = JSON.parse(fs.readFileSync(path.join(PLUGIN_SRC, "package.json"), "utf8"));
360
+ fs.writeFileSync(path.join(nm, "package.json"),
361
+ JSON.stringify({ ...currentPkg, version: "0.0.0-old" }, null, 2) + "\n");
362
+
363
+ const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
364
+ assert.equal(r.code, 0, `重装应成功:${r.stdout}\n${r.stderr}`);
365
+ const after = JSON.parse(fs.readFileSync(path.join(nm, "package.json"), "utf8")).version;
366
+ assert.equal(after, currentPkg.version,
367
+ "★旧版整目录拷贝必须被重建,否则宿主继续 import 旧 package.json(面板版本号永远是旧的)");
368
+ } finally {
369
+ fs.rmSync(root, { recursive: true, force: true });
370
+ }
371
+ });
372
+
295
373
  // ─────────────── ④ 自救入口:repair ───────────────
296
374
 
297
375
  test("repair:空壳现场能被修好,并打印挂载诊断三行", async () => {
@@ -18,6 +18,7 @@ import assert from "node:assert/strict";
18
18
  import { readFileSync } from "node:fs";
19
19
  import test from "node:test";
20
20
  import vm from "node:vm";
21
+ import { openEntry } from "./lib-panel-ia.mjs";
21
22
 
22
23
  const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
23
24
  // 复制自 community-invite-ui 的迷你 React harness 里,交流群/弹层接口仍有默认夹具,统一给一个假二维码地址。
@@ -74,6 +75,7 @@ const flush = () => new Promise((resolve) => setImmediate(resolve));
74
75
  * @param {object} opts - community:/dsh-remote/community 的返回值(默认已配置二维码)
75
76
  */
76
77
  function loadPlugin(opts = {}) {
78
+ let iaOpened = false;
77
79
  let moduleFactory;
78
80
  const registered = new Map();
79
81
  const injects = new Map();
@@ -249,7 +251,12 @@ function loadPlugin(opts = {}) {
249
251
  },
250
252
  render() {
251
253
  const root = { type: registered.get("dsh-remote"), props: { close() {} }, children: [] };
252
- return renderNode(root, "root");
254
+ let tree = renderNode(root, "root");
255
+ if (!iaOpened) {
256
+ iaOpened = true;
257
+ if (openEntry(tree, opts.autoModal || "account")) tree = renderNode(root, "root");
258
+ }
259
+ return tree;
253
260
  },
254
261
  };
255
262
  }
@@ -259,7 +266,7 @@ test("① 免费用户账号卡:升级 PRO 按钮仍在,但协议文案/链
259
266
  const plugin = loadPlugin();
260
267
  const tree = await plugin.settle(4);
261
268
  // 购买入口(升级按钮)必须还在 —— 这次只删协议文案,不动购买能力
262
- assert.ok(nodeWithText(tree, "🚀 升级 PRO"), "免费用户的「🚀 升级 PRO」按钮必须保留");
269
+ assert.ok(nodeWithText(tree, "升级 PRO"), "免费用户的「升级 PRO」按钮必须保留(图标换成内联 SVG,文案不再用 emoji)");
263
270
  // 协议痕迹一个都不能有
264
271
  assertNoAgreementInPanel(tree);
265
272
  // 客户端也不再为了这句话去问 node 半(面板里已经没有消费者了)
@@ -271,12 +278,14 @@ test("② 会员账号卡(业主点名场景):续费会员按钮下方不
271
278
  account: { plan: "pro", plan_source: "subscription", plan_ends_at: 1893456000000, invite_code: "ABC12345" },
272
279
  });
273
280
  const tree = await plugin.settle(4);
274
- assert.ok(nodeWithText(tree, "🔄 续费会员"), "前提:会员的续费入口在(这正是业主看到那句话的位置)");
281
+ assert.ok(nodeWithText(tree, "续费会员"), "前提:会员的续费入口在(这正是业主看到那句话的位置)");
275
282
  assertNoAgreementInPanel(tree);
276
283
  });
277
284
 
278
285
  test("③ 连接偏慢双路块:升级(购买)路旁也没有协议链接,协议地址零请求", async () => {
286
+ // 双路块在「手机访问」弹层(访问卡)里 → 打开该弹层
279
287
  const plugin = loadPlugin({
288
+ autoModal: "mobile",
280
289
  status: {
281
290
  ok: true,
282
291
  config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
@@ -305,7 +314,7 @@ test("④ 未登录:没有购买入口,也没有任何协议痕迹/请求",
305
314
  },
306
315
  });
307
316
  const tree = await plugin.settle(4);
308
- assert.equal(nodeWithText(tree, "🚀 升级 PRO"), undefined, "未登录没有购买入口");
317
+ assert.equal(nodeWithText(tree, "升级 PRO"), undefined, "未登录没有购买入口");
309
318
  assertNoAgreementInPanel(tree);
310
319
  assert.equal(plugin.agreementRequests().length, 0);
311
320
  });
@@ -325,7 +334,7 @@ test("⑤ 源码级回归锁:client.js 里不得再残留协议渲染痕迹(
325
334
  test("⑥ 只删文案、不动购买能力:点「升级 PRO」仍走既有购买流程(请求 /dsh-remote/access-key)", async () => {
326
335
  const plugin = loadPlugin();
327
336
  let tree = await plugin.settle(4);
328
- const btn = nodeWithText(tree, "🚀 升级 PRO");
337
+ const btn = nodeWithText(tree, "升级 PRO");
329
338
  assert.ok(btn, "升级按钮必须还在");
330
339
  btn.props.onClick();
331
340
  await plugin.settle(2);
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
4
4
  import { readFileSync } from "node:fs";
5
5
  import test from "node:test";
6
6
  import vm from "node:vm";
7
+ import { openEntry } from "./lib-panel-ia.mjs";
7
8
 
8
9
  const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
9
10
 
@@ -50,6 +51,7 @@ function loadPlugin(opts = {}) {
50
51
  const states = [];
51
52
  let hook = 0;
52
53
  let sessionsCalls = 0;
54
+ let iaOpened = false;
53
55
 
54
56
  const react = {
55
57
  createElement(type, props, ...children) { return { type, props: props || {}, children }; },
@@ -153,7 +155,17 @@ function loadPlugin(opts = {}) {
153
155
  requests,
154
156
  opened,
155
157
  states,
156
- render() { hook = 0; return registered.get("dsh-remote")({ close() {} }); },
158
+ render() {
159
+ hook = 0;
160
+ let tree = registered.get("dsh-remote")({ close() {} });
161
+ // IA 重做(2026-11-07):原「📱 远程访问卡」搬进「手机访问」弹层 —— 用例按真实用户路径点开入口卡。
162
+ if (!iaOpened) {
163
+ iaOpened = true;
164
+ const kind = opts.autoModal || "mobile";
165
+ if (openEntry(tree, kind)) { hook = 0; tree = registered.get("dsh-remote")({ close() {} }); }
166
+ }
167
+ return tree;
168
+ },
157
169
  };
158
170
  }
159
171
 
@@ -166,7 +178,9 @@ test("📱 远程访问卡:bridge 在线 → 绿点文案;登录态点「生
166
178
 
167
179
  // 状态行:bridge 运行 → “已连接(可远程访问)”
168
180
  assert.ok(textHas(tree, "已连接(可远程访问)"), "bridge 在线状态文案应显示");
169
- assert.ok(find(tree, (n) => n.children?.includes("📱 远程访问")), "应渲染「📱 远程访问」卡标题");
181
+ // A1(2026-11-07 业主截图):正文里不再重复「远程访问」标题 —— 顶部与弹层标题已经表达了。
182
+ assert.equal(find(tree, (n) => n.children?.includes("📱 远程访问")), undefined,
183
+ "正文里不得再有重复的「远程访问」卡片标题(业主截图框出的那行)");
170
184
 
171
185
  // 未生成 key 的空态按钮 → 点击触发 /dsh-remote/access-key
172
186
  const genBtn = find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("生成访问链接")));
@@ -191,7 +205,7 @@ test("📱 远程访问卡:bridge 在线 → 绿点文案;登录态点「生
191
205
  });
192
206
 
193
207
  test("已授权设备:展开列表(label/os/browser/时间)→ 取消配对二次确认 → 成功提示并刷新", async () => {
194
- const plugin = loadPlugin();
208
+ const plugin = loadPlugin({ autoModal: "devices" });
195
209
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
196
210
  let tree = plugin.render();
197
211
 
@@ -232,7 +246,7 @@ test("已授权设备:展开列表(label/os/browser/时间)→ 取消配
232
246
  });
233
247
 
234
248
  test("已授权设备行内操作:活跃行有 取消配对+删除记录,已取消行有 删除记录;删除记录二次确认 → DELETE delete {id}", async () => {
235
- const plugin = loadPlugin({ sessions: SESSIONS_1 }); // 固定列表:操作后重拉仍保留,便于断言行内按钮
249
+ const plugin = loadPlugin({ sessions: SESSIONS_1, autoModal: "devices" }); // 固定列表 + 设备弹层(IA 重做:列表在弹层里)
236
250
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
237
251
  let tree = plugin.render();
238
252
  const openBtn = find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("已授权设备")));
@@ -271,7 +285,7 @@ test("已授权设备行内操作:活跃行有 取消配对+删除记录,已
271
285
  });
272
286
 
273
287
  test("已授权设备:卡片底部「清理已解绑」→ 二次确认 → POST purge → 提示条数 + 刷新列表", async () => {
274
- const plugin = loadPlugin({ sessions: SESSIONS_1 });
288
+ const plugin = loadPlugin({ sessions: SESSIONS_1, autoModal: "devices" });
275
289
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
276
290
  let tree = plugin.render();
277
291
  const openBtn = find(tree, (n) => typeof n.props?.onClick === "function" && (n.children || []).some((c) => typeof c === "string" && c.includes("已授权设备")));
@@ -302,7 +316,7 @@ test("已授权设备:卡片底部「清理已解绑」→ 二次确认 → PO
302
316
  });
303
317
 
304
318
  test("升级/续费按钮:点击 → GET /dsh-remote/access-key → window.open(url)(带登录态打开)", async () => {
305
- const plugin = loadPlugin();
319
+ const plugin = loadPlugin({ autoModal: "account" });
306
320
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
307
321
  let tree = plugin.render();
308
322
 
@@ -410,130 +424,66 @@ test("E2EE 徽标:未登录 / host 未下发 e2ee → 不打扰(不渲染状
410
424
  assert.equal(e2eeLine(oldHost.render()), undefined, "未下发 service.e2ee 时不应渲染状态行");
411
425
  });
412
426
 
413
- test("「更多」只收纳营销入口:档位规格数字(Mbps / GB / 价格)不再出现在账号卡里", () => {
414
- // 收纳态(localStorage 里记着用户关掉过引导)=「更多」里会出现那两条折叠入口的场景
415
- const plugin = loadPlugin({ localStorageSeed: { "dsh-remote-marketing-more-v1": "1" } });
427
+ test("账号弹层平铺(A2):所有功能直接可见,不再有「更多」折叠;且不渲染档位数值", () => {
428
+ const plugin = loadPlugin({ autoModal: "account", localStorageSeed: { "dsh-remote-marketing-more-v1": "1" } });
416
429
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
417
- let tree = plugin.render();
430
+ const tree = plugin.render();
418
431
 
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
- // 2026-10-17 版式简化(业主):正面只留「升级/续费 + 用户反馈 + 更多」;
440
- // 「带新用户换会员 / 修改密码」收进「更多」。升级入口**永远在正面**(不再随收纳状态搬家)。
441
- const upsell = find(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("升级 PRO")));
442
- const invite = find(body, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("带新用户换会员")));
443
- assert.ok(upsell, "正面应保留「🚀 升级 PRO」入口(主行动,位置稳定)");
444
- assert.ok(invite, "「更多」里应保留「🎁 带新用户换会员」入口");
445
- assert.equal(typeof upsell.props.onClick, "function");
446
- assert.equal(typeof invite.props.onClick, "function");
447
- // 正面不应再出现「带新用户换会员」(已收进「更多」)
448
- const actions = find(tree, (n) => n.props?.className === "dru-actions");
449
- assert.ok(actions, "应有主按钮行");
450
- assert.ok(!textHas(actions, "带新用户换会员"), "带新用户入口不再占正面按钮行");
451
- // 「更多」现在与主行动同排:按钮本体带 .dru-btn(原来的整宽 .dru-disclose 披露条已去掉)
452
- const moreBtn = find(tree, (n) => n.props?.id === "dru-account-more");
453
- assert.ok(moreBtn, "「更多」按钮应存在");
454
- assert.match(String(moreBtn.props.className || ""), /dru-btn/, "「更多」必须与主行动同排(按钮样式,不再是整宽披露条)");
455
- assert.doesNotMatch(String(moreBtn.props.className || ""), /dru-disclose(?!-inline)/, "不得再继承整宽披露条样式");
456
-
457
- // 源码级:函数与其调用都删掉了(不留死代码)。
458
- // ⚠️ 断言「代码形态」而不是裸标识符 —— 注释里**故意**留着「这里原本是什么、为什么删」的说明
459
- // (与 settings-entry.test.mjs 的同款约定:注释里允许出现说明文字)。
460
- assert.doesNotMatch(SOURCE, /function renderPlanDetail\(/, "renderPlanDetail 函数必须删除");
461
- assert.doesNotMatch(SOURCE, /^\s*renderPlanDetail\(\),/m, "renderPlanDetail 的调用点必须删除");
462
- assert.doesNotMatch(SOURCE, /function planSpecText\(/, "planSpecText 定义必须删除");
463
- assert.doesNotMatch(SOURCE, /planSpecText\("/, "planSpecText 的调用必须删除");
464
- assert.doesNotMatch(SOURCE, /function freeMbpsOf\(/, "freeMbpsOf 定义必须删除");
465
- assert.doesNotMatch(SOURCE, /=\s*freeMbpsOf\(\)/, "freeMbpsOf 的调用必须删除");
466
- assert.doesNotMatch(SOURCE, /var FREE_FACTS\s*=/, "FREE_FACTS 定义必须删除(只被 freeMbpsOf 使用)");
467
- assert.doesNotMatch(SOURCE, /" Mbps"|" GB\/月,¥"/, "不得残留渲染规格数字的字符串拼接");
468
- // 不变量仍在:planFactsOf 仍被 upgradePath 用来取档位展示名(不是死代码)
469
- assert.match(SOURCE, /var name = planFactsOf\(key\)\.name;/);
470
- });
432
+ // 不再有「更多」披露按钮 / 折叠体(业主:账号弹层不要折叠,空间足够)
433
+ assert.equal(find(tree, (n) => n.props?.id === "dru-account-more"), undefined,
434
+ "账号弹层不得再有「更多」折叠按钮");
435
+ assert.equal(find(tree, (n) => n.props?.className === "dru-more-body"), undefined,
436
+ "账号弹层不得再有「更多」折叠体");
471
437
 
472
- test("E2EE 徽标(源码级约束):client 含徽标字段/文案与 reason 映射表", () => {
473
- assert.match(SOURCE, /service\.e2ee/);
474
- assert.match(SOURCE, /\.e2ee-state\.json/);
475
- assert.match(SOURCE, /🔒 端到端加密已启用(手机解锁后生效)/);
476
- assert.match(SOURCE, /端到端加密暂不可用(当前为普通安全连接 HTTPS)/);
477
- assert.doesNotMatch(SOURCE, /等待服务端开启 E2EE/); // 已正式开启,不再出现“灰度等待”措辞
478
- assert.match(SOURCE, /当前为普通安全连接(HTTPS)/);
479
- assert.match(SOURCE, /server_disabled/);
480
- assert.match(SOURCE, /params_unreachable/);
481
- assert.match(SOURCE, /disabled_by_config/);
482
- assert.match(SOURCE, /derive_failed/);
483
- assert.match(SOURCE, /describeE2ee/);
484
- assert.match(SOURCE, /dru-e2ee-line/);
485
- });
438
+ // 各功能直接平铺(无需展开就能看到)
439
+ for (const label of ["用户反馈", "带新用户换会员", "修改密码"]) {
440
+ assert.ok(find(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes(label))),
441
+ "应直接平铺按钮: " + label);
442
+ }
486
443
 
487
- // ── 2026-10-17 版式与文案精简(业主 UX 反馈)──────────────────────────────────
488
- // 动线:账号(我是谁 / 退出)→ 主行动(升级或续费)→ 次要(反馈)→ 更多(其它入口)。
444
+ // 档位明细/数值始终不渲染(用户约定:本地面板不展示 PRO 版本流量带宽)
445
+ assert.ok(!textHas(tree, "套餐与额度"), "「套餐与额度」标题应删除");
446
+ assert.ok(!textHas(tree, "Mbps"), "不得再渲染带宽数值");
447
+ assert.ok(!textHas(tree, "GB"), "不得再渲染流量数值");
448
+ assert.ok(!textHas(tree, "¥"), "不得再渲染价格");
449
+ });
489
450
 
490
- test("★ 正面按钮收敛为三个:升级/续费 + 用户反馈 + 更多(不再一排按钮疯狂换行)", () => {
491
- const plugin = loadPlugin();
451
+ test("账号弹层:一屏一个主操作(只有升级/续费是 primary);退出登录在状态头(A3)", () => {
452
+ const plugin = loadPlugin({ autoModal: "account" });
492
453
  plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
493
454
  const tree = plugin.render();
494
- // 面板里可能有多处 .dru-actions(访问密钥卡等);账号卡那一处用「更多」按钮定位。
495
- const actions = find(tree, (n) => {
496
- if (n.props?.className !== "dru-actions") return false;
497
- let hit = false;
498
- walk(n, (x) => { if (x.props?.id === "dru-account-more") hit = true; });
499
- return hit;
500
- });
501
- assert.ok(actions, "应有账号卡主按钮行");
502
-
503
- const btns = [];
504
- walk(actions, (n) => { if (n.type === "button") btns.push(n); });
505
- // 三个按钮:主行动、用户反馈、更多
506
- assert.equal(btns.length, 3, `正面按钮应为 3 个,实际 ${btns.length} 个(多了就是又回到"按钮墙")`);
507
- const labels = btns.map((b) => JSON.stringify(b.children || []));
508
- assert.ok(labels.some((t) => t.includes("升级 PRO") || t.includes("续费会员")), "第一个是主行动");
509
- assert.ok(labels.some((t) => t.includes("用户反馈")), "用户反馈留在正面");
510
- assert.ok(labels.some((t) => t.includes("更多")), "「更多」与主行动同排");
511
-
512
- // 退出登录**不在**主按钮行里,而在账号行(.dru-user)右侧
513
- const logoutInActions = btns.some((b) => (b.children || []).some((c) => c === "退出登录"));
514
- assert.equal(logoutInActions, false, "退出登录不得再占主按钮行");
515
- const userRow = find(tree, (n) => n.props?.className === "dru-user");
516
- assert.ok(userRow, "应有账号行");
517
- const logout = find(userRow, (n) => n.type === "button" && (n.children || []).includes("退出登录"));
518
- assert.ok(logout, "退出登录应在账号行里(业主:放到账号那一行旁边)");
519
- assert.match(String(logout.props.className || ""), /dru-user-logout/);
455
+
456
+ const primary = findAll(tree, (n) => n.type === "button" && String(n.props?.className || "").includes("dru-btn-primary"));
457
+ assert.ok(primary.length <= 1, `一屏最多一个主操作,实际 ${primary.length} 个 primary 按钮`);
458
+
459
+ // 登录/退出登录同一位置:状态头右侧(`.dru-ia-head-actions`)
460
+ const headActions = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-head-actions"));
461
+ assert.ok(headActions, "状态头右侧应有按钮组(登录/退出登录与打开手机端同一处)");
462
+ assert.ok(find(headActions, (n) => n.type === "button" && (n.children || []).includes("退出登录")),
463
+ "已登录时状态头应有「退出登录」");
464
+ // 账号弹层内部不再重复一个退出登录按钮
465
+ const dlg = find(tree, (n) => String(n.props?.className || "").includes("dru-ia-dialog"));
466
+ assert.equal(find(dlg, (n) => n.type === "button" && (n.children || []).includes("退出登录")), undefined,
467
+ "退出登录不得在账号弹层里重复出现(同一动作只在一个位置)");
520
468
  });
521
469
 
522
- test("★ 次要入口全部在「更多」里:带新用户 / 修改密码 / 加入交流群(展开后可见)", () => {
470
+ test("未登录:状态头显示「登录 / 注册」;点「打开手机端」直接弹登录弹层并说明原因(A3/A4)", () => {
523
471
  const plugin = loadPlugin();
524
- plugin.states[0] = { config: { phone: "13800000000", deviceId: "dev-x" }, service: { running: true } };
472
+ // 该 harness 无 setSignedIn:直接给状态槽一个"未登录"的 st(useEffect 在本 harness 是空实现)
473
+ plugin.states[0] = { ok: true, config: {}, service: { running: false } };
525
474
  let tree = plugin.render();
526
- const toggle = find(tree, (n) => n.props?.id === "dru-account-more");
527
- assert.ok(toggle, "应有「更多」按钮");
528
- toggle.props.onClick(); // 展开
529
- tree = plugin.render();
530
- const body = find(tree, (n) => n.props?.className === "dru-more-body");
531
- assert.ok(body && body.props.hidden === false, "展开后「更多」内容体可见");
532
- const btns = [];
533
- walk(body, (n) => { if (n.type === "button") btns.push(n); });
534
- const labels = btns.map((b) => JSON.stringify(b.children || []));
535
- assert.ok(labels.some((t) => t.includes("带新用户换会员")), "带新用户换会员在「更多」里");
536
- assert.ok(labels.some((t) => t.includes("修改密码")), "修改密码在「更多」里");
475
+ const headActions = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-head-actions"));
476
+ assert.ok(headActions, "未登录也要有状态头按钮组");
477
+ const loginBtn = find(headActions, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("登录 / 注册")));
478
+ assert.ok(loginBtn, "未登录时同一位置显示「登录 / 注册」入口");
479
+
480
+ // 点状态头的「打开手机端」→ 不许静默失败:直接弹登录弹层 + 说明
481
+ const openPhone = find(headActions, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("打开手机端")));
482
+ assert.ok(openPhone, "应有「打开手机端」主按钮");
483
+ openPhone.props.onClick();
484
+ tree = plugin.settle ? plugin.render() : plugin.render();
485
+ assert.ok(find(tree, (n) => String(n.props?.className || "").includes("dru-ia-dialog")), "应弹出登录弹层(不是静默失败)");
486
+ assert.ok(textHas(tree, "登录后才能用手机访问"), "弹层里要说明「登录后才能用手机访问」");
537
487
  });
538
488
 
539
489
  test("★ 删除了两段解释性文案:升级带登录态说明 / 重启 DeepSeek Harness 说明", () => {
@@ -231,7 +231,11 @@ test("源码约束:浏览器半提供版本与更新卡片(含彻底卸载
231
231
  assert.match(source, /一键更新/);
232
232
  assert.match(source, /彻底卸载/);
233
233
  assert.match(source, /dsh-remote\/self\/uninstall/);
234
- assert.match(source, /再点一次确认彻底卸载/);
234
+ // A7(2026-11-07 业主):彻底卸载前先弹**挽留反馈**(原因单选 + 建议),提交后继续卸载。
235
+ assert.match(source, /为什么要卸载/, "必须询问卸载原因(挽留反馈)");
236
+ assert.match(source, /我再想想/, "必须给「我再想想」的退出选项");
237
+ assert.match(source, /仍要卸载/, "确认按钮必须是「仍要卸载」");
238
+ assert.match(source, /kind: "uninstall"/, "反馈必须复用现有反馈通道(kind=uninstall)");
235
239
  });
236
240
 
237
241
  test("★ 用户反馈锁:已是最新版时,修复入口必须**看得出是修复入口**(不能只叫「重新检查」)", () => {
@@ -5,6 +5,7 @@ import assert from "node:assert/strict";
5
5
  import { readFileSync } from "node:fs";
6
6
  import test from "node:test";
7
7
  import vm from "node:vm";
8
+ import { openEntry } from "./lib-panel-ia.mjs";
8
9
 
9
10
  const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
10
11
 
@@ -59,6 +60,7 @@ function makeEl(tag) {
59
60
  * localStorageSeed(预置 key/value)。
60
61
  */
61
62
  function loadPlugin(opts = {}) {
63
+ let iaOpened = false;
62
64
  let moduleFactory;
63
65
  const registered = new Map();
64
66
  const metas = new Map();
@@ -158,7 +160,16 @@ function loadPlugin(opts = {}) {
158
160
  registered, metas, injects, requests, removed, states, localStorage,
159
161
  intervals,
160
162
  lastObserver: () => lastObserver,
161
- renderSection() { hook = 0; return registered.get("dsh-remote")({ close() {} }); },
163
+ renderSection() {
164
+ hook = 0;
165
+ let tree = registered.get("dsh-remote")({ close() {} });
166
+ // IA 重做(2026-11-07):账号/退出登录在「账号」弹层里 —— 按真实用户路径点开入口卡。
167
+ if (!iaOpened) {
168
+ iaOpened = true;
169
+ if (openEntry(tree, opts.autoModal || "account")) { hook = 0; tree = registered.get("dsh-remote")({ close() {} }); }
170
+ }
171
+ return tree;
172
+ },
162
173
  };
163
174
  }
164
175
 
@@ -212,9 +223,10 @@ test("入口迁移:settings.section 里**只有一个**栏目(id=dsh-remote
212
223
  assert.equal(plugin.metas.has("dsh-remote-wechat"), false,
213
224
  "「🤖 微信机器人」不得再注册成独立栏目(0.6.11:搬进面板的第三个 tab「微信机器人通道」)");
214
225
  assert.doesNotMatch(SOURCE, /dsh-remote-wechat/, "源码里也不得残留第二个栏目的 id");
215
- // 搬进去之后仍然存在:面板 tab 条里的第三个 tab + 微信绿泡泡图标(行为断言见 wechat-bot-ui.test.mjs)
216
- assert.match(SOURCE, /"微信机器人通道"/, "微信机器人 tab 必须在面板里");
217
- assert.match(SOURCE, /dru-wx-ico/, "微信 tab 必须带绿泡泡图标 class");
226
+ // IA 重做(2026-11-07):顶部 tab 与绿泡泡 emoji 均已删除 —— 微信通道在「通知与消息通道」弹层里,
227
+ // 主面板保留「通知与消息通道」入口卡(行为断言见 wechat-bot-ui.test.mjs)。
228
+ assert.match(SOURCE, /"通知与消息通道"/, "必须有「通知与消息通道」入口(微信/企微两条通道都在里面)");
229
+ assert.match(SOURCE, /"微信机器人(扫码绑定)"/, "通道弹层里必须有微信机器人一行");
218
230
  // 槽注入次数:settings.section + shell.overlay + sidebar.footer.action(2026-10-10 加的侧栏入口)
219
231
  assert.equal(plugin.injects.size, 3,
220
232
  `inject 的槽名应是 settings.section + shell.overlay + sidebar.footer.action,实际:${[...plugin.injects.keys()].join(",")}`);
@@ -226,32 +238,33 @@ test("登录态账号区:无「切换账号」,有「退出登录」,头
226
238
  let tree = plugin.renderSection();
227
239
 
228
240
  // 栏目头部:标题「远程访问」+ 副文案(人在哪都能用,免公网 IP)
229
- assert.ok(find(tree, (n) => n.children?.includes("远程访问")), "头部标题应为「远程访问」");
230
- assert.ok(find(tree, (n) => n.children?.includes("通过手机或另一台电脑远程使用同一份 dsh web,人在哪都能用(免公网 IP)")), "应渲染新副文案(人在哪都能用,免公网 IP)");
241
+ // 2026-11-07 品牌统一:顶部面板标题改为插件全名 dsh-remote-web(「远程访问」保留在副标题/栏目名里)
242
+ assert.ok(find(tree, (n) => n.children?.includes("dsh-remote-web")), "头部标题应为品牌全名 dsh-remote-web");
243
+ assert.ok(find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("远程访问"))),
244
+ "副标题仍说明这是「远程访问」能力");
245
+ assert.ok(find(tree, (n) => (n.children || []).some((c) => typeof c === "string" && c.includes("通过手机或另一台电脑远程使用同一份 dsh web,人在哪都能用(免公网 IP)"))),
246
+ "应渲染新副文案(人在哪都能用,免公网 IP)");
231
247
 
232
248
  // 账号区按钮
233
249
  assert.ok(find(tree, (n) => n.children?.includes("退出登录")), "应保留「退出登录」");
234
250
  assert.ok(!find(tree, (n) => n.children?.includes("切换账号")), "「切换账号」按钮应移除");
235
251
 
236
- // 关于 dsh-remote 说明卡片(面板底部,v0.5+ 价值要点)
237
- assert.ok(find(tree, (n) => n.children?.includes("📖 关于 dsh-remote")), "应渲染「关于 dsh-remote」卡片标题");
238
- const points = [
239
- "📱 远程访问:用手机或另一台电脑的浏览器,随时随地使用同一份 dsh web——人在哪都能用(免公网 IP、免内网穿透);官方托管中继,4G/5G 即用,也可自建服务。",
240
- "🛠 电脑端一键安装:bridge 与「远程访问」面板一次到位——云端/自建切换、账号登录、bridge 启停、一次性扫码访问、已授权设备管理、意见反馈都在这里。",
241
- "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表。",
242
- "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。",
243
- ];
244
- for (const p of points) {
245
- assert.ok(find(tree, (n) => n.children?.includes(p)), `说明卡片应含要点: ${p.slice(0, 12)}…`);
252
+ // 关于 dsh-remote 卡片:在「更多设置」折叠区里 —— 标题不再带 emoji(图标一律内联 SVG)
253
+ assert.ok(find(tree, (n) => n.children?.includes("关于 dsh-remote-web")), "应渲染「关于 dsh-remote-web」卡片标题");
254
+ // 价值要点仍要在(不要求逐字长文案:IA 重做后关于卡精简成结论句,避免文字堆砌)
255
+ const aboutBits = ["免公网 IP", "端到端加密", "匿名装机统计"];
256
+ for (const bit of aboutBits) {
257
+ assert.ok(textHas(tree, bit), "关于卡片应含要点: " + bit);
246
258
  }
247
259
 
248
- // 面板主体仍在(连接模式 tab 不受影响)
249
- assert.ok(find(tree, (n) => n.children?.includes("☁️ 云端服务")), "云端服务 tab 应保留");
250
- assert.ok(find(tree, (n) => n.children?.includes("🖥 自建服务")), "自建服务 tab 应保留");
251
-
252
- // 📱 远程访问卡 / 📲 已授权设备卡在云端视图中渲染(bridge 未运行 → “等待设备连接”)
253
- assert.ok(find(tree, (n) => n.children?.includes("等待设备连接")), "无 bridge 时应显示「等待设备连接」状态行");
254
- assert.ok(textHas(tree, "已授权设备"), "应渲染「已授权设备」管理按钮");
260
+ // 新 IA(2026-11-07):顶部 tab 已删除 → 主面板是「状态头 + 2 列入口网格 + 文字入口」
261
+ for (const title of ["手机访问", "通知与消息通道", "已授权设备", "账号"]) {
262
+ assert.ok(find(tree, (n) => (n.children || []).includes(title)), "入口网格应有卡片: " + title);
263
+ }
264
+ assert.equal(find(tree, (n) => n.props?.className === "dru-tabs"), undefined, "主面板不得再有顶部 tab 条");
265
+ // 状态头:连接状态 pill(圆点 + 文案,不只靠颜色)+ 主按钮「打开手机端」
266
+ assert.ok(find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-pill")), "状态头应有连接状态 pill");
267
+ assert.ok(textHas(tree, "打开手机端"), "状态头应有主按钮「打开手机端」");
255
268
  });
256
269
 
257
270
  test("退出登录清除用户反馈线程凭据(localStorage dsh-feedback-threads)", async () => {
@@ -368,11 +381,15 @@ test("源码约束:侧栏入口走官方槽位/无浮动面板/无切换账号
368
381
  assert.doesNotMatch(SOURCE, /dru-backdrop\{/);
369
382
  assert.doesNotMatch(SOURCE, /切换账号/);
370
383
  assert.doesNotMatch(SOURCE, /dsh-remote-panel/);
371
- assert.doesNotMatch(SOURCE, /远程控制/); // 旧称呼「远程控制」不再出现(含 label/头部/说明)
384
+ // 旧称呼「远程控制」不再作为**面板名字**出现(label/头部/菜单)。
385
+ // 注:2026-11-07 业主新文案「保持在线,才能用手机端远程控制这台电脑」把该词当**动词**用,
386
+ // 因此不再对全文做一刀切禁止;这里锁"仍然叫远程访问"这个真正的约束。
387
+ assert.match(SOURCE, /"远程访问"/);
388
+ assert.doesNotMatch(SOURCE, /"远程控制"|远程控制(/);
372
389
  assert.match(SOURCE, /settings\.section/);
373
390
  assert.match(SOURCE, /dsh-remote-seen-dot/);
374
391
  assert.match(SOURCE, /fbClearThreads/);
375
- assert.match(SOURCE, /关于 dsh-remote/);
392
+ assert.match(SOURCE, /关于 dsh-remote-web/);
376
393
  assert.match(SOURCE, /免公网 IP/); // 关于卡 v0.5+ 价值要点
377
394
  assert.match(SOURCE, /端到端/);
378
395
  assert.match(SOURCE, /已授权设备/);