@mrrisega/dsh-remote 0.7.11-beta.3 → 0.7.12-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 (38) hide show
  1. package/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
  2. package/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
  3. package/clients/dsh-remote/dsh-bridge.mjs +398 -80
  4. package/clients/dsh-remote/feishu-bot.mjs +1668 -0
  5. package/clients/dsh-remote/im-format.mjs +372 -0
  6. package/clients/dsh-remote/mirror-diag-script.js +53 -14
  7. package/clients/dsh-remote/mobile-adapter.mjs +355 -40
  8. package/clients/dsh-remote/test/bridge-http-abort.test.mjs +143 -0
  9. package/clients/dsh-remote/test/bridge-slow-response.test.mjs +107 -0
  10. package/clients/dsh-remote/test/bridge-tunnel-receiver.test.mjs +135 -0
  11. package/clients/dsh-remote/test/dingtalk-bot.test.mjs +550 -0
  12. package/clients/dsh-remote/test/feishu-bot.test.mjs +839 -0
  13. package/clients/dsh-remote/test/im-format.test.mjs +229 -0
  14. package/clients/dsh-remote/test/mirror-diag.test.mjs +47 -2
  15. package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +208 -9
  16. package/clients/dsh-remote/test/upstream-discovery.test.mjs +203 -2
  17. package/clients/dsh-remote/test/wecom-bot.test.mjs +281 -1
  18. package/clients/dsh-remote/upstream-discovery.mjs +495 -43
  19. package/clients/dsh-remote/wecom-bot.mjs +238 -12
  20. package/dsh-setup.mjs +78 -7
  21. package/package.json +2 -2
  22. package/packages/dsh-remote-web/lib/client.js +703 -115
  23. package/packages/dsh-remote-web/lib/index.js +487 -31
  24. package/packages/dsh-remote-web/package.json +1 -1
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +7 -1
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +372 -0
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
  29. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +94 -8
  30. package/packages/dsh-remote-web/test/im-bots.test.mjs +265 -2
  31. package/packages/dsh-remote-web/test/login-fast.test.mjs +104 -0
  32. package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +4 -1
  33. package/packages/dsh-remote-web/test/panel-ia.test.mjs +397 -34
  34. package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +4 -4
  35. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -113
  36. package/packages/dsh-remote-web/test/self-manage.test.mjs +5 -1
  37. package/packages/dsh-remote-web/test/settings-entry.test.mjs +13 -5
  38. package/packages/dsh-remote-web/test/telemetry.test.mjs +2 -2
@@ -75,6 +75,12 @@ function domIndex(tree, predicate) {
75
75
  }
76
76
  const flush = () => new Promise((resolve) => setImmediate(resolve));
77
77
 
78
+ /** 收集所有匹配节点(用例里要数"有几个单选"这类)。 */
79
+ function findAll(tree, predicate) {
80
+ const out = [];
81
+ walk(tree, (n) => { if (predicate(n)) out.push(n); });
82
+ return out;
83
+ }
78
84
  /** 子树全部文本(拼接;用于"卡片文案/按钮文案"断言)。 */
79
85
  function textOf(node) {
80
86
  let out = "";
@@ -92,6 +98,9 @@ function loadPlugin(opts = {}) {
92
98
  const metas = [];
93
99
  const injects = new Map();
94
100
  const requests = [];
101
+ const clipboard = [];
102
+ const depsCalls = [];
103
+ const depsInstalls = [];
95
104
  let unmounted = false;
96
105
 
97
106
  // ── 迷你 React:真实 useState/useEffect + 递归展开**函数子组件** ──
@@ -185,6 +194,7 @@ function loadPlugin(opts = {}) {
185
194
  let imBotsGetReply = { ok: true, wecom: { enabled: false, botId: "", hasSecret: false, url: "", targetChatId: "" }, status: { connected: false, authenticated: false, lastError: "" }, updatedAt: 0 };
186
195
  let imBotsPutReply = { status: 200, body: { ok: true, wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "" }, status: { connected: false, authenticated: false, lastError: "" }, updatedAt: 0 } };
187
196
  let imBotsTestReply = { status: 200, body: { ok: true, connected: true, authenticated: true, lastError: "" } };
197
+ let imDepsReply = { installed: false, phase: "idle", version: "", lastError: "" };
188
198
  const nextPoll = () => (pollReplies.length > 1 ? pollReplies.shift() : pollReplies[0]);
189
199
 
190
200
  const makeEl = () => ({
@@ -223,7 +233,7 @@ function loadPlugin(opts = {}) {
223
233
  document: doc,
224
234
  localStorage,
225
235
  MutationObserver: MutationObserverMock,
226
- navigator: { clipboard: { writeText: async () => {} } },
236
+ navigator: { clipboard: { writeText: async (t) => { clipboard.push(String(t)); } } },
227
237
  setTimeout: fakeSetTimeout,
228
238
  clearTimeout: fakeClear,
229
239
  setInterval: fakeSetInterval,
@@ -248,6 +258,15 @@ function loadPlugin(opts = {}) {
248
258
  imBotsCalls.push({ method: "POST", body: options.body ? JSON.parse(options.body) : null });
249
259
  return response(imBotsTestReply.status, imBotsTestReply.body);
250
260
  }
261
+ if (path === "/dsh-remote/im-deps") {
262
+ depsCalls.push(Date.now());
263
+ return response(200, { ok: true, feishu: imDepsReply });
264
+ }
265
+ if (path === "/dsh-remote/im-deps/feishu") {
266
+ depsInstalls.push(Date.now());
267
+ imDepsReply = { ...imDepsReply, phase: "downloading", installed: false };
268
+ return response(200, { ok: true, started: true, deps: { feishu: imDepsReply } });
269
+ }
251
270
  return response(200, { ok: true });
252
271
  },
253
272
  Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console
@@ -299,7 +318,7 @@ function loadPlugin(opts = {}) {
299
318
  }
300
319
 
301
320
  return {
302
- requests, metas, injects,
321
+ requests, metas, injects, clipboard,
303
322
  counts() { return { ...counts }; },
304
323
  /** 设置页栏目列表(模拟 shell 的 ctx.slots.entries("settings.section"),按 order 排序)。 */
305
324
  sectionList() { return metas.filter((m) => m.name === "settings.section").slice().sort((a, b) => a.order - b.order); },
@@ -314,6 +333,9 @@ function loadPlugin(opts = {}) {
314
333
  setImBotsGet(body) { imBotsGetReply = body; },
315
334
  setImBotsPut(body, status = 200) { imBotsPutReply = { status, body }; },
316
335
  setImBotsTest(body, status = 200) { imBotsTestReply = { status, body }; },
336
+ setImDeps(body) { imDepsReply = body; },
337
+ depsCalls() { return depsCalls.slice(); },
338
+ depsInstalls() { return depsInstalls.slice(); },
317
339
  imBotsCalls() { return imBotsCalls.slice(); },
318
340
  /** 向 document 派发键盘事件(测试 Esc / Tab 的弹层契约)。 */
319
341
  fireKey(key, init = {}) {
@@ -660,47 +682,168 @@ test("会保存:选中某会话 → PUT 带 sessionId + sessionTitle;选回
660
682
  assert.equal(sent.body.wecom.sessionTitle, "", "★ 清空时标题也必须清空(否则 UI 会显示错目标)");
661
683
  });
662
684
 
663
- // ───────────────────── ⑥ 飞书 / 钉钉:诚实的「即将支持」 ─────────────────────
685
+ // ───────────────────── ⑥ 钉钉 / 飞书通道(2026-11-08 本轮支持) ─────────────────────
664
686
 
665
- test("平台列表:飞书 / 钉钉都标注「即将支持」,不放进假表单", async () => {
687
+ test("平台列表:企业微信 / 钉钉 / 飞书 / Webhook 都是真入口(不再有「即将支持」),Webhook 注明免费", async () => {
666
688
  const plugin = loadPlugin();
667
689
  let tree = await plugin.settle();
668
690
  openEntry(tree, "channel");
669
691
  tree = await plugin.settle();
670
- const rows = byClass(tree, "dru-ia-row");
671
- const texts = rows.map((r) => textOf(r));
672
- assert.ok(texts.some((t) => t.includes("飞书机器人") && t.includes("即将支持")), "飞书行必须标注即将支持");
673
- assert.ok(texts.some((t) => t.includes("钉钉机器人") && t.includes("即将支持")), "钉钉行必须标注即将支持");
674
- assert.ok(texts.some((t) => t.includes("企业微信智能机器人") && !t.includes("即将支持")), "企微行是已支持(主推)");
692
+ const texts = byClass(tree, "dru-ia-row").map((r) => textOf(r));
693
+ assert.ok(texts.some((t) => t.includes("企业微信智能机器人")), "企业微信在列表里(已支持)");
694
+ assert.ok(texts.some((t) => t.includes("钉钉机器人")), "钉钉机器人必须在列表里(本轮支持)");
695
+ assert.ok(texts.some((t) => t.includes("飞书机器人")), "飞书机器人必须在列表里(本轮支持,按需下载依赖)");
696
+ assert.ok(texts.some((t) => t.includes("Webhook") && t.includes("免费")), "高级 Webhook 保留并注明免费");
697
+ assert.ok(!texts.some((t) => t.includes("钉钉机器人") && t.includes("即将支持")), "钉钉不再是即将支持");
698
+ assert.ok(!texts.some((t) => t.includes("飞书机器人") && t.includes("即将支持")), "飞书不再是即将支持");
675
699
  });
676
700
 
677
- test("飞书/钉钉:点进去只有说明 + 「告诉我,优先支持」;**没有输入框、没有提交**,点优先支持走现有反馈", async () => {
701
+ test("钉钉配置页:Client ID / Client Secret / Robot Code + 保存并连接/测试;提交体字段锁死", async () => {
678
702
  const plugin = loadPlugin();
679
- for (const [row, name] of [["飞书机器人", "飞书机器人"], ["钉钉机器人", "钉钉机器人"]]) {
680
- const p = loadPlugin();
681
- let tree = await p.settle();
682
- openEntry(tree, "channel");
683
- tree = await p.settle();
684
- assert.ok(clickChannelRow(tree, row), "应能点开 " + row);
685
- tree = await p.settle();
686
- const dlg = dialogOf(tree);
687
- assert.ok(dlg, row + " 必须打开弹层(不是假入口)");
688
- const text = textOf(dlg);
689
- assert.ok(text.includes("即将支持"), row + " 必须标注「即将支持」");
690
- assert.ok(text.includes("企业管理员"), row + " 说明要写清为什么现在还接不了(企业管理员配置应用)");
691
- assert.equal(find(dlg, (n) => n.type === "input" || n.type === "select"), undefined,
692
- "★ 不许放任何输入框/下拉(假表单)");
693
- const tell = btnWith(dlg, "告诉我,优先支持");
694
- assert.ok(tell, "必须有「告诉我,优先支持」按钮(复用反馈通道)");
695
- const before = p.imBotsCalls().length;
696
- tell.props.onClick();
697
- tree = await p.settle();
698
- assert.equal(dialogOf(tree), undefined, "点「优先支持」后关闭弹层");
699
- assert.equal(p.imBotsCalls().length, before, "★ 不得调用 im-bots 写接口(不新增后端)");
700
- const dbgText = textOf(tree);
701
- assert.ok(dbgText.includes("想要 " + name), "反馈视图应带一句引导(告知用户写什么);实际视图文本片段=" + dbgText.slice(0, 200));
702
- void plugin;
703
+ plugin.setImBotsGet({
704
+ ok: true,
705
+ wecom: { enabled: false, botId: "", hasSecret: false, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
706
+ dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "" },
707
+ feishu: { enabled: false, appId: "", hasSecret: false, receiveId: "", receiveIdType: "open_id" },
708
+ status: { connected: false, sessions: [], activeSessionId: "", boundSessionId: "", dingtalk: {}, feishu: {}, deps: { feishu: { installed: false, phase: "idle" } } },
709
+ });
710
+ let tree = await plugin.settle();
711
+ openEntry(tree, "channel");
712
+ tree = await plugin.settle();
713
+ assert.ok(clickChannelRow(tree, "钉钉机器人"), "应能进钉钉配置页");
714
+ tree = await plugin.settle();
715
+ const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
716
+ assert.ok(dlg, "钉钉配置页应打开");
717
+ for (const id of ["dru-ding-client", "dru-ding-secret", "dru-ding-robot"]) {
718
+ assert.ok(find(dlg, (n) => n.type === "input" && n.props?.id === id), "缺字段: " + id);
719
+ }
720
+ assert.ok(textOf(dlg).includes("AppKey") && textOf(dlg).includes("AppSecret") && textOf(dlg).includes("机器人编码"),
721
+ "说明要写清 Client ID/Secret = AppKey/AppSecret、Robot Code 是机器人编码");
722
+ assert.ok(btnWith(dlg, "保存并连接"), "要有「保存并连接」");
723
+ assert.ok(btnWith(dlg, "测试"), "要有「测试」");
724
+
725
+ find(dlg, (n) => n.props?.id === "dru-ding-client").props.onChange({ target: { value: "ding-client-1" } });
726
+ find(dlg, (n) => n.props?.id === "dru-ding-secret").props.onChange({ target: { value: "ding-secret-1" } });
727
+ find(dlg, (n) => n.props?.id === "dru-ding-robot").props.onChange({ target: { value: "ding-robot-1" } });
728
+ tree = await plugin.settle();
729
+ btnWith(tree, "仅保存").props.onClick();
730
+ await flush(); await flush();
731
+ const put = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
732
+ assert.ok(put, "应 PUT /dsh-remote/im-bots");
733
+ assert.equal(put.body.platform, "dingtalk", "提交体要带 platform=dingtalk");
734
+ assert.deepEqual(put.body.config, { clientId: "ding-client-1", clientSecret: "ding-secret-1", robotCode: "ding-robot-1" },
735
+ "★ 钉钉提交体字段必须锁死为 clientId/clientSecret/robotCode");
736
+ });
737
+
738
+ test("飞书:依赖未装 → 41MB 下载入口 + 「不下载也能用其它通道」;已装 → 表单/官方链接/8 步向导/顺序坑", async () => {
739
+ // 未装依赖
740
+ const p1 = loadPlugin();
741
+ p1.setImDeps({ installed: false, phase: "idle", version: "", lastError: "" });
742
+ p1.setImBotsGet({
743
+ ok: true, wecom: {}, dingtalk: {}, feishu: {},
744
+ status: { deps: { feishu: { installed: false, phase: "idle", version: "", lastError: "" } } },
745
+ });
746
+ let tree = await p1.settle();
747
+ openEntry(tree, "channel");
748
+ tree = await p1.settle();
749
+ assert.ok(clickChannelRow(tree, "飞书机器人"), "应能进飞书页");
750
+ tree = await p1.settle();
751
+ assert.ok(textOf(tree).includes("41MB"), "未装依赖要说明约 41MB、仅装一次");
752
+ const dlBtn = btnWith(tree, "下载依赖");
753
+ assert.ok(dlBtn, "要有「下载依赖」按钮");
754
+ assert.ok(textOf(tree).includes("不下载也能用其它通道"), "要说明不下载也能用其它通道");
755
+ assert.equal(find(tree, (n) => n.props?.id === "dru-fs-appid"), undefined, "未装依赖时不放配置表单");
756
+ dlBtn.props.onClick();
757
+ await flush(); await flush();
758
+ assert.ok(p1.requests.some((r) => r.path === "/dsh-remote/im-deps/feishu" && r.method === "POST"),
759
+ "点下载必须 POST /dsh-remote/im-deps/feishu");
760
+
761
+ // 已装依赖
762
+ const p2 = loadPlugin();
763
+ p2.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
764
+ p2.setImBotsGet({
765
+ ok: true, wecom: {}, dingtalk: {},
766
+ feishu: { enabled: false, appId: "", hasSecret: false, receiveId: "", receiveIdType: "open_id" },
767
+ status: { deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } } },
768
+ });
769
+ tree = await p2.settle();
770
+ openEntry(tree, "channel");
771
+ tree = await p2.settle();
772
+ clickChannelRow(tree, "飞书机器人");
773
+ tree = await p2.settle();
774
+ let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
775
+ assert.ok(dlg, "已装依赖应打开飞书配置页");
776
+ assert.ok(textOf(dlg).includes("1.40.0"), "应显示依赖版本");
777
+ for (const id of ["dru-fs-appid", "dru-fs-secret", "dru-fs-recv", "dru-fs-rtype"]) {
778
+ assert.ok(find(dlg, (n) => (n.type === "input" || n.type === "select") && n.props?.id === id), "缺字段: " + id);
703
779
  }
780
+ const links = findAll(dlg, (n) => n.type === "a");
781
+ assert.equal(links.length, 2, "配置页顶部要有两个官方文档外链,实际 " + links.length);
782
+ for (const a of links) {
783
+ assert.equal(a.props.target, "_blank");
784
+ assert.equal(a.props.rel, "noopener");
785
+ assert.match(String(a.props.href), /^https:\/\/open\.feishu\.cn\//, "外链必须是飞书官方文档");
786
+ }
787
+ const wizard = find(dlg, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("配置步骤(8 步)")));
788
+ assert.ok(wizard, "要有 8 步配置向导(可折叠)");
789
+ assert.equal(wizard.props["aria-expanded"], "false", "默认收起,避免文字堆砌");
790
+ wizard.props.onClick();
791
+ tree = await p2.settle();
792
+ dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
793
+ const steps = findAll(dlg, (n) => n.type === "li");
794
+ assert.equal(steps.length, 8, "向导必须正好 8 步,实际 " + steps.length);
795
+ assert.ok(textOf(dlg).includes("长连接必须已在线"), "必须写明顺序坑:保存事件配置时长连接必须已在线");
796
+ assert.ok(textOf(dlg).includes("先在第 8 步填凭据"), "必须写明正确顺序:先填凭据连上,再去后台保存事件配置");
797
+ });
798
+
799
+ test("飞书依赖:失败状态可重试,失败原因人话展示", async () => {
800
+ const plugin = loadPlugin();
801
+ plugin.setImDeps({ installed: false, phase: "failed", version: "", lastError: "网络不通:检查能否访问 npm registry" });
802
+ plugin.setImBotsGet({
803
+ ok: true, wecom: {}, dingtalk: {}, feishu: {},
804
+ status: { deps: { feishu: { installed: false, phase: "failed", version: "", lastError: "网络不通:检查能否访问 npm registry" } } },
805
+ });
806
+ let tree = await plugin.settle();
807
+ openEntry(tree, "channel");
808
+ tree = await plugin.settle();
809
+ clickChannelRow(tree, "飞书机器人");
810
+ tree = await plugin.settle();
811
+ assert.ok(textOf(tree).includes("网络不通"), "失败原因要人话展示");
812
+ assert.ok(btnWith(tree, "重试下载"), "失败后按钮变为「重试下载」");
813
+ btnWith(tree, "重试下载").props.onClick();
814
+ await flush(); await flush();
815
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/im-deps/feishu" && r.method === "POST"), "重试必须再次触发安装");
816
+ });
817
+
818
+ test("飞书表单提交体:appId/appSecret/receiveId/receiveIdType;secret 不回显", async () => {
819
+ const plugin = loadPlugin();
820
+ plugin.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
821
+ plugin.setImBotsGet({
822
+ ok: true, wecom: {}, dingtalk: {},
823
+ feishu: { enabled: true, appId: "cli_old", hasSecret: true, receiveId: "ou_old", receiveIdType: "open_id" },
824
+ status: { deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } } },
825
+ });
826
+ let tree = await plugin.settle();
827
+ openEntry(tree, "channel");
828
+ tree = await plugin.settle();
829
+ clickChannelRow(tree, "飞书机器人");
830
+ tree = await plugin.settle();
831
+ const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
832
+ // 已保存的 secret 不回显:password 输入框初始为空 + 占位提示
833
+ const sec = find(dlg, (n) => n.props?.id === "dru-fs-secret");
834
+ assert.equal(sec.props.value, "", "★ 已保存的 Secret 绝不能回显到输入框");
835
+ assert.ok(String(sec.props.placeholder || "").includes("已保存"), "要提示「已保存(留空表示不修改)」");
836
+ find(dlg, (n) => n.props?.id === "dru-fs-appid").props.onChange({ target: { value: "cli_new" } });
837
+ find(dlg, (n) => n.props?.id === "dru-fs-recv").props.onChange({ target: { value: "oc_chat_1" } });
838
+ find(dlg, (n) => n.props?.id === "dru-fs-rtype").props.onChange({ target: { value: "chat_id" } });
839
+ tree = await plugin.settle();
840
+ btnWith(tree, "仅保存").props.onClick();
841
+ await flush(); await flush();
842
+ const put = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
843
+ assert.ok(put, "应 PUT /dsh-remote/im-bots");
844
+ assert.equal(put.body.platform, "feishu");
845
+ assert.deepEqual(put.body.config, { appId: "cli_new", appSecret: "", receiveId: "oc_chat_1", receiveIdType: "chat_id" },
846
+ "★ 飞书提交体字段锁死;空 secret = 不修改(不覆盖已保存的)");
704
847
  });
705
848
 
706
849
  // ───────────────────── ⑦ 配置页收口:说明折叠 + 统一字段结构 ─────────────────────
@@ -796,3 +939,223 @@ test("发送预览卡片:已绑定(boundChatId 非空)→ 不显示未绑
796
939
  assert.ok(!textOf(tree).includes("先在企微里给机器人发一句话完成绑定"), "已绑定就不该再提示未绑定");
797
940
  assert.ok(textOf(tree).includes("上次预览:09:07"), "应显示上次预览时间(HH:MM)");
798
941
  });
942
+
943
+ // ───────────── ⑨ 未登录守卫 + 后台服务横排 + 卸载挽留(2026-11-07 业主逐条)─────────────
944
+
945
+ test("未登录「手机访问」卡:文案写「登录并生成访问码」,点击弹登录弹层;登录成功后自动回到手机访问", async () => {
946
+ const plugin = loadPlugin();
947
+ plugin.setSignedIn(false);
948
+ let tree = await plugin.settle();
949
+ const card = find(tree, (n) => String(n.props?.className || "").includes("dru-ia-card")
950
+ && textOf(n).includes("手机访问"));
951
+ assert.ok(card, "入口网格里应有「手机访问」卡");
952
+ assert.ok(textOf(card).includes("登录并生成访问码"), "未登录时按钮文案必须是「登录并生成访问码」(不是静默失败)");
953
+ const btn = find(card, (n) => n.type === "button");
954
+ btn.props.onClick();
955
+ tree = await plugin.settle();
956
+ assert.ok(textOf(tree).includes("登录后才能用手机访问"), "点击后弹登录弹层并说明原因");
957
+
958
+ // 登录成功 → 自动回到「手机访问」流程(iaAfterLogin 回跳),不再让用户自己再找一遍
959
+ plugin.setSignedIn(true);
960
+ tree = await plugin.settle(4);
961
+ assert.ok(textOf(tree).includes("手机访问"), "登录后应回到手机访问流程");
962
+ const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
963
+ assert.ok(dlg, "登录后应自动打开手机访问弹层");
964
+ });
965
+
966
+ test("后台服务区块(A6):显眼提示 + 横向紧凑网格(状态/启停/自启动/设备ID 不再竖排占屏)", async () => {
967
+ const plugin = loadPlugin();
968
+ const tree = await plugin.settle();
969
+ assert.ok(textOf(tree).includes("保持在线,才能用手机端远程控制这台电脑"), "必须有「保持在线」提示(业主点名)");
970
+ const grid = find(tree, (n) => String(n.props?.className || "") === "dru-bridge-grid");
971
+ assert.ok(grid, "后台服务应为横向网格布局(.dru-bridge-grid)");
972
+ const cells = byClass(grid, "dru-bridge-cell");
973
+ assert.ok(cells.length >= 4, "网格应含 4 格:状态 / 启停 / 自启动 / 设备 ID,实际 " + cells.length);
974
+ assert.ok(cells.some((c) => textOf(c).includes("设备 ID")), "设备 ID 在网格里(横向)");
975
+ });
976
+
977
+ test("彻底卸载(A7):先弹挽留反馈(原因单选 + 建议)→ 提交走现有反馈通道 → 才继续卸载", async () => {
978
+ const plugin = loadPlugin();
979
+ let tree = await plugin.settle();
980
+ const unBtn = btnWith(tree, "彻底卸载");
981
+ assert.ok(unBtn, "应有「彻底卸载」按钮");
982
+ unBtn.props.onClick();
983
+ tree = await plugin.settle();
984
+ const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
985
+ assert.ok(dlg, "点彻底卸载应先弹挽留反馈弹层");
986
+ assert.ok(textOf(dlg).includes("为什么要卸载"), "弹层要问「为什么要卸载?」;实际=" + textOf(dlg).slice(0, 200));
987
+ const radios = findAll(dlg, (n) => n.type === "input" && n.props?.type === "radio");
988
+ assert.ok(radios.length >= 4, "应有多个原因单选,实际 " + radios.length);
989
+ assert.ok(find(dlg, (n) => n.type === "textarea"), "应有可填建议的输入框");
990
+ assert.ok(btnWith(dlg, "我再想想"), "必须有「我再想想」退出选项");
991
+ // 未选原因就点「仍要卸载」→ 就近报错,且不提交/不卸载
992
+ const before = plugin.requests.length;
993
+ btnWith(dlg, "仍要卸载").props.onClick();
994
+ tree = await plugin.settle();
995
+ assert.ok(textOf(tree).includes("请选择一个卸载原因"), "未选原因要给就近错误");
996
+ const after = plugin.requests.slice(before).map((r) => r.path);
997
+ assert.ok(!after.includes("/dsh-remote/feedback"), "未选原因不得提交反馈(" + after.join(",") + ")");
998
+ assert.ok(!after.includes("/dsh-remote/self/uninstall"), "未选原因更不得开始卸载");
999
+
1000
+ // 选一个原因 + 写建议 → 仍要卸载:先 POST 反馈(kind=uninstall),再 POST 卸载
1001
+ const r0 = findAll(tree, (n) => n.type === "input" && n.props?.type === "radio")[0];
1002
+ r0.props.onChange({ target: { value: r0.props.value } });
1003
+ tree = await plugin.settle();
1004
+ const note = find(tree, (n) => n.type === "textarea");
1005
+ note.props.onChange({ target: { value: "加载太慢,希望优化" } });
1006
+ tree = await plugin.settle();
1007
+ btnWith(tree, "仍要卸载").props.onClick();
1008
+ await flush(); await flush(); await flush();
1009
+ const fb = plugin.requests.find((r) => r.path === "/dsh-remote/feedback" && r.method === "POST");
1010
+ assert.ok(fb, "★ 必须先提交挽留反馈(复用现有反馈通道,不新增后端)");
1011
+ assert.equal(fb.body.kind, "uninstall", "反馈必须带 kind=uninstall 标记");
1012
+ assert.ok(String(fb.body.content).includes("原因"), "反馈内容要含所选原因");
1013
+ assert.ok(String(fb.body.content).includes("加载太慢"), "用户填的建议要带上");
1014
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/self/uninstall" && r.method === "POST"),
1015
+ "★ 提交反馈后应继续执行卸载(反馈不能反过来卡住用户)");
1016
+ });
1017
+
1018
+ // ───────────────────── ⑩ 会员档位条 + 会话固定门禁(2026-11-07 桥侧判定) ─────────────────────
1019
+
1020
+ test("企微配置页档位条:免费档 → 「本月剩余 N 条指令(每月 20 条)」+ 「升级 Pro 不限量」链接", async () => {
1021
+ const plugin = loadPlugin();
1022
+ plugin.setImBotsGet({
1023
+ ok: true,
1024
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
1025
+ status: {
1026
+ connected: true, authenticated: true, tier: "free",
1027
+ messagesPerMonth: 20, remainingMessages: 7, usage: { month: "2026-11", messages: 13 },
1028
+ bindingGated: false, caps: ["wecom"], capsCount: 1,
1029
+ sessions: [], activeSessionId: "", boundSessionId: "",
1030
+ },
1031
+ });
1032
+ const tree = await openWecom(plugin);
1033
+ const tierBar = find(tree, (n) => String(n.props?.className || "").includes("dru-ia-tier"));
1034
+ assert.ok(tierBar, "免费档应渲染档位条");
1035
+ assert.ok(textOf(tierBar).includes("免费档"), "档位条要写明「免费档」");
1036
+ assert.ok(textOf(tierBar).includes("本月剩余 7 条指令"), "要显示剩余指令数");
1037
+ assert.ok(textOf(tierBar).includes("每月 20 条"), "要显示每月上限");
1038
+ const link = find(tierBar, (n) => n.type === "a");
1039
+ assert.ok(link, "免费档必须有升级入口");
1040
+ assert.equal(link.props.href, "/app/promo", "升级链接指向 /app/promo");
1041
+ assert.ok((link.children || []).join("").includes("升级 Pro 不限量"));
1042
+ });
1043
+
1044
+ test("企微配置页档位条:pro / pro_max → 「会员档 · 指令不限」,不出现升级链接", async () => {
1045
+ for (const tier of ["pro", "pro_max"]) {
1046
+ const plugin = loadPlugin();
1047
+ plugin.setImBotsGet({
1048
+ ok: true,
1049
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
1050
+ status: { connected: true, authenticated: true, tier, bindingGated: false, sessions: [], activeSessionId: "", boundSessionId: "" },
1051
+ });
1052
+ const tree = await openWecom(plugin);
1053
+ const tierBar = find(tree, (n) => String(n.props?.className || "").includes("dru-ia-tier"));
1054
+ assert.ok(tierBar, tier + " 应渲染档位条");
1055
+ assert.ok(textOf(tierBar).includes("会员档 · 指令不限"), tier + " 文案应为「会员档 · 指令不限」");
1056
+ assert.equal(find(tierBar, (n) => n.type === "a"), undefined, tier + " 会员档不该有升级链接");
1057
+ }
1058
+ });
1059
+
1060
+ test("企微配置页档位条:取不到 tier / 未知值 → 整行不渲染(绝不瞎猜)", async () => {
1061
+ for (const status of [
1062
+ { connected: true, sessions: [], activeSessionId: "", boundSessionId: "" }, // 旧桥:没有 tier
1063
+ { connected: true, tier: "gold", sessions: [], activeSessionId: "", boundSessionId: "" }, // 未知值
1064
+ ]) {
1065
+ const plugin = loadPlugin();
1066
+ plugin.setImBotsGet({
1067
+ ok: true,
1068
+ wecom: { enabled: false, botId: "", hasSecret: false, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
1069
+ status,
1070
+ });
1071
+ const tree = await openWecom(plugin);
1072
+ assert.equal(find(tree, (n) => String(n.props?.className || "").includes("dru-ia-tier")), undefined,
1073
+ "拿不到有效档位时不得渲染档位条(" + JSON.stringify(status.tier) + ")");
1074
+ }
1075
+ });
1076
+
1077
+ test("会话固定门禁:bindingGated=true → 下拉置灰 + 「会员功能」徽章 + 悬停说明;已选值被忽略时如实说明", async () => {
1078
+ const plugin = loadPlugin();
1079
+ plugin.setImBotsGet({
1080
+ ok: true,
1081
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "sess-1-abcdef123456", sessionTitle: "修复登录问题" },
1082
+ status: {
1083
+ connected: true, authenticated: true, tier: "free", bindingGated: true,
1084
+ sessions: [{ id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 1 }],
1085
+ activeSessionId: "sess-2-xyz", activeSessionTitle: "部署到 NAS", boundSessionId: "",
1086
+ },
1087
+ });
1088
+ const tree = await openWecom(plugin);
1089
+ const sel = find(tree, (n) => n.type === "select" && n.props?.id === "dru-wecom-session");
1090
+ assert.ok(sel, "下拉仍应存在(只是置灰)");
1091
+ assert.equal(sel.props.disabled, true, "★ 免费档必须置灰(不能假装能选)");
1092
+ assert.equal(sel.props.title, "升级后可固定转接会话", "悬停必须说明为什么不能选");
1093
+ assert.equal(sel.props.value, "", "被忽略的选择不能显示成生效值");
1094
+ const badge = find(tree, (n) => String(n.props?.className || "").includes("dru-ia-badge"));
1095
+ assert.ok(badge && textOf(badge).includes("会员功能"), "置灰处要有「会员功能」徽章");
1096
+ assert.ok(textOf(tree).includes("当前跟随活跃会话"), "已选值被忽略时要显示「当前跟随活跃会话」");
1097
+ assert.ok(textOf(tree).includes("免费档不能固定转接"), "小字要说明原因");
1098
+ });
1099
+
1100
+ // ───────────────────── ⑪ 品牌全名 + 安装命令(2026-11-07 业主) ─────────────────────
1101
+
1102
+ test("品牌:面板标题 / 关于卡片统一用全名 dsh-remote-web,旧的「关于 dsh-remote」不再出现", async () => {
1103
+ const plugin = loadPlugin();
1104
+ const tree = await plugin.settle();
1105
+ // 顶部面板标题(业主:「我的插件名字叫 dsh-remote-web,这个是全名」)
1106
+ const title = find(tree, (n) => String(n.props?.className || "").includes("dru-settings-title"));
1107
+ assert.ok(title, "应有面板标题");
1108
+ assert.equal((title.children || []).join(""), "dsh-remote-web", "顶部标题必须是品牌全名 dsh-remote-web");
1109
+ // 关于卡片
1110
+ assert.ok(textOf(tree).includes("关于 dsh-remote-web"), "关于段落标题应为「关于 dsh-remote-web」");
1111
+ // 精确断言:不得再出现裸品牌「关于 dsh-remote」(但不能误伤包名 @mrrisega/dsh-remote)
1112
+ assert.doesNotMatch(SOURCE, /关于 dsh-remote(?!-web)/, "旧品牌写法「关于 dsh-remote」不得再出现");
1113
+ assert.match(SOURCE, /@mrrisega\/dsh-remote/, "包名 @mrrisega/dsh-remote 必须保留(不是品牌文案)");
1114
+ assert.match(SOURCE, /~\/\.dsh-remote/, "配置目录 ~/.dsh-remote 必须保留(代码/数据路径,不是品牌文案)");
1115
+ });
1116
+
1117
+ test("版本与更新:有一行可复制的安装/更新命令 npx @mrrisega/dsh-remote@latest + 「重跑这条命令即可」说明", async () => {
1118
+ const plugin = loadPlugin();
1119
+ let tree = await plugin.settle();
1120
+ assert.ok(textOf(tree).includes("npx @mrrisega/dsh-remote@latest"), "版本卡必须给出安装/更新命令");
1121
+ assert.ok(textOf(tree).includes("已安装的更新:重跑这条命令即可"), "要说明更新方式是重跑该命令");
1122
+ const copyBtn = find(tree, (n) => n.type === "button" && n.props?.["aria-label"] === "复制安装命令");
1123
+ assert.ok(copyBtn, "命令旁必须有复制按钮(复用现有复制反馈样式)");
1124
+ const code = find(tree, (n) => String(n.props?.className || "").includes("dru-code-val"));
1125
+ assert.ok(code && (code.children || []).join("").includes("@mrrisega/dsh-remote@latest"), "命令要以可复制的代码样式展示");
1126
+
1127
+ copyBtn.props.onClick();
1128
+ await flush(); await flush();
1129
+ assert.deepEqual(plugin.clipboard, ["npx @mrrisega/dsh-remote@latest"], "★ 点复制必须把安装命令写进剪贴板");
1130
+ tree = await plugin.settle();
1131
+ assert.ok(textOf(tree).includes("✅ 已复制"), "复制成功要有与其它复制按钮一致的反馈");
1132
+ });
1133
+
1134
+ test("飞书依赖下载:点下载 → POST 安装 → 轮询 /im-deps,装好后页面自动切到配置表单(无需重开弹层)", async () => {
1135
+ const plugin = loadPlugin();
1136
+ plugin.setImDeps({ installed: false, phase: "idle", version: "", lastError: "" });
1137
+ plugin.setImBotsGet({
1138
+ ok: true, wecom: {}, dingtalk: {}, feishu: {},
1139
+ status: { deps: { feishu: { installed: false, phase: "idle", version: "", lastError: "" } } },
1140
+ });
1141
+ let tree = await plugin.settle();
1142
+ openEntry(tree, "channel");
1143
+ tree = await plugin.settle();
1144
+ clickChannelRow(tree, "飞书机器人");
1145
+ tree = await plugin.settle();
1146
+ assert.ok(btnWith(tree, "下载依赖"), "未装依赖先给下载入口");
1147
+
1148
+ btnWith(tree, "下载依赖").props.onClick();
1149
+ await flush(); await flush();
1150
+ assert.equal(plugin.depsInstalls().length, 1, "★ 点下载必须触发一次后台安装");
1151
+
1152
+ // 先渲染一次让"下载中"的轮询 effect 挂上定时器,再模拟安装完成
1153
+ tree = await plugin.settle(2);
1154
+ plugin.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
1155
+ await plugin.advance(1000); // 触发轮询定时器(真实环境 900ms 一次)
1156
+ await flush(); await flush();
1157
+ tree = await plugin.settle(4);
1158
+ assert.ok(plugin.depsCalls().length >= 1, "应轮询 /dsh-remote/im-deps 拿进度");
1159
+ assert.ok(textOf(tree).includes("1.40.0"), "装好后面板直接显示版本(用户不需要重开弹层)");
1160
+ assert.ok(find(tree, (n) => n.props?.id === "dru-fs-appid"), "装好后应出现飞书配置表单");
1161
+ });
@@ -266,7 +266,7 @@ test("① 免费用户账号卡:升级 PRO 按钮仍在,但协议文案/链
266
266
  const plugin = loadPlugin();
267
267
  const tree = await plugin.settle(4);
268
268
  // 购买入口(升级按钮)必须还在 —— 这次只删协议文案,不动购买能力
269
- assert.ok(nodeWithText(tree, "🚀 升级 PRO"), "免费用户的「🚀 升级 PRO」按钮必须保留");
269
+ assert.ok(nodeWithText(tree, "升级 PRO"), "免费用户的「升级 PRO」按钮必须保留(图标换成内联 SVG,文案不再用 emoji)");
270
270
  // 协议痕迹一个都不能有
271
271
  assertNoAgreementInPanel(tree);
272
272
  // 客户端也不再为了这句话去问 node 半(面板里已经没有消费者了)
@@ -278,7 +278,7 @@ test("② 会员账号卡(业主点名场景):续费会员按钮下方不
278
278
  account: { plan: "pro", plan_source: "subscription", plan_ends_at: 1893456000000, invite_code: "ABC12345" },
279
279
  });
280
280
  const tree = await plugin.settle(4);
281
- assert.ok(nodeWithText(tree, "🔄 续费会员"), "前提:会员的续费入口在(这正是业主看到那句话的位置)");
281
+ assert.ok(nodeWithText(tree, "续费会员"), "前提:会员的续费入口在(这正是业主看到那句话的位置)");
282
282
  assertNoAgreementInPanel(tree);
283
283
  });
284
284
 
@@ -314,7 +314,7 @@ test("④ 未登录:没有购买入口,也没有任何协议痕迹/请求",
314
314
  },
315
315
  });
316
316
  const tree = await plugin.settle(4);
317
- assert.equal(nodeWithText(tree, "🚀 升级 PRO"), undefined, "未登录没有购买入口");
317
+ assert.equal(nodeWithText(tree, "升级 PRO"), undefined, "未登录没有购买入口");
318
318
  assertNoAgreementInPanel(tree);
319
319
  assert.equal(plugin.agreementRequests().length, 0);
320
320
  });
@@ -334,7 +334,7 @@ test("⑤ 源码级回归锁:client.js 里不得再残留协议渲染痕迹(
334
334
  test("⑥ 只删文案、不动购买能力:点「升级 PRO」仍走既有购买流程(请求 /dsh-remote/access-key)", async () => {
335
335
  const plugin = loadPlugin();
336
336
  let tree = await plugin.settle(4);
337
- const btn = nodeWithText(tree, "🚀 升级 PRO");
337
+ const btn = nodeWithText(tree, "升级 PRO");
338
338
  assert.ok(btn, "升级按钮必须还在");
339
339
  btn.props.onClick();
340
340
  await plugin.settle(2);