@mrrisega/dsh-remote 0.7.12-beta.1 → 0.7.12-beta.3
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.
- package/clients/dsh-remote/dingtalk-bot.mjs +1361 -0
- package/clients/dsh-remote/docs/feishu-bot-contract.md +217 -0
- package/clients/dsh-remote/dsh-bridge.mjs +404 -90
- package/clients/dsh-remote/feishu-bot.mjs +1852 -0
- package/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/clients/dsh-remote/test/bridge-http-abort.test.mjs +143 -0
- package/clients/dsh-remote/test/bridge-slow-response.test.mjs +107 -0
- package/clients/dsh-remote/test/bridge-tunnel-receiver.test.mjs +135 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +630 -0
- package/clients/dsh-remote/test/feishu-bot.test.mjs +1089 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +47 -2
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +208 -9
- package/dsh-setup.mjs +1 -1
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +500 -4
- package/packages/dsh-remote-web/lib/index.js +581 -27
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +404 -84
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +1668 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +372 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +78 -7
- package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +14 -1
- package/packages/dsh-remote-web/test/im-bots.test.mjs +376 -2
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +397 -103
|
@@ -99,6 +99,9 @@ function loadPlugin(opts = {}) {
|
|
|
99
99
|
const injects = new Map();
|
|
100
100
|
const requests = [];
|
|
101
101
|
const clipboard = [];
|
|
102
|
+
const depsCalls = [];
|
|
103
|
+
const dingAuthCalls = [];
|
|
104
|
+
const depsInstalls = [];
|
|
102
105
|
let unmounted = false;
|
|
103
106
|
|
|
104
107
|
// ── 迷你 React:真实 useState/useEffect + 递归展开**函数子组件** ──
|
|
@@ -192,6 +195,9 @@ function loadPlugin(opts = {}) {
|
|
|
192
195
|
let imBotsGetReply = { ok: true, wecom: { enabled: false, botId: "", hasSecret: false, url: "", targetChatId: "" }, status: { connected: false, authenticated: false, lastError: "" }, updatedAt: 0 };
|
|
193
196
|
let imBotsPutReply = { status: 200, body: { ok: true, wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "" }, status: { connected: false, authenticated: false, lastError: "" }, updatedAt: 0 } };
|
|
194
197
|
let imBotsTestReply = { status: 200, body: { ok: true, connected: true, authenticated: true, lastError: "" } };
|
|
198
|
+
let imDepsReply = { installed: false, phase: "idle", version: "", lastError: "" };
|
|
199
|
+
let dingAuthBeginReply = { status: 200, body: { ok: true, authId: "auth-1", url: "https://oapi.dingtalk.com/x?nonce=1", interval: 2, expiresIn: 120 } };
|
|
200
|
+
let dingAuthStatusReplies = [];
|
|
195
201
|
const nextPoll = () => (pollReplies.length > 1 ? pollReplies.shift() : pollReplies[0]);
|
|
196
202
|
|
|
197
203
|
const makeEl = () => ({
|
|
@@ -255,6 +261,24 @@ function loadPlugin(opts = {}) {
|
|
|
255
261
|
imBotsCalls.push({ method: "POST", body: options.body ? JSON.parse(options.body) : null });
|
|
256
262
|
return response(imBotsTestReply.status, imBotsTestReply.body);
|
|
257
263
|
}
|
|
264
|
+
if (path === "/dsh-remote/im-dingtalk-auth/begin") {
|
|
265
|
+
dingAuthCalls.push({ kind: "begin", method: options.method || "POST" });
|
|
266
|
+
return response(dingAuthBeginReply.status, dingAuthBeginReply.body);
|
|
267
|
+
}
|
|
268
|
+
if (path.startsWith("/dsh-remote/im-dingtalk-auth/status")) {
|
|
269
|
+
dingAuthCalls.push({ kind: "status", path });
|
|
270
|
+
const next = dingAuthStatusReplies.length ? dingAuthStatusReplies.shift() : { status: "waiting" };
|
|
271
|
+
return response(200, next);
|
|
272
|
+
}
|
|
273
|
+
if (path === "/dsh-remote/im-deps") {
|
|
274
|
+
depsCalls.push(Date.now());
|
|
275
|
+
return response(200, { ok: true, feishu: imDepsReply });
|
|
276
|
+
}
|
|
277
|
+
if (path === "/dsh-remote/im-deps/feishu") {
|
|
278
|
+
depsInstalls.push(Date.now());
|
|
279
|
+
imDepsReply = { ...imDepsReply, phase: "downloading", installed: false };
|
|
280
|
+
return response(200, { ok: true, started: true, deps: { feishu: imDepsReply } });
|
|
281
|
+
}
|
|
258
282
|
return response(200, { ok: true });
|
|
259
283
|
},
|
|
260
284
|
Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console
|
|
@@ -321,6 +345,12 @@ function loadPlugin(opts = {}) {
|
|
|
321
345
|
setImBotsGet(body) { imBotsGetReply = body; },
|
|
322
346
|
setImBotsPut(body, status = 200) { imBotsPutReply = { status, body }; },
|
|
323
347
|
setImBotsTest(body, status = 200) { imBotsTestReply = { status, body }; },
|
|
348
|
+
setImDeps(body) { imDepsReply = body; },
|
|
349
|
+
setDingAuthBegin(body, status = 200) { dingAuthBeginReply = { status, body }; },
|
|
350
|
+
setDingAuthStatus(list) { dingAuthStatusReplies = (list || []).slice(); },
|
|
351
|
+
dingAuthCalls() { return dingAuthCalls.slice(); },
|
|
352
|
+
depsCalls() { return depsCalls.slice(); },
|
|
353
|
+
depsInstalls() { return depsInstalls.slice(); },
|
|
324
354
|
imBotsCalls() { return imBotsCalls.slice(); },
|
|
325
355
|
/** 向 document 派发键盘事件(测试 Esc / Tab 的弹层契约)。 */
|
|
326
356
|
fireKey(key, init = {}) {
|
|
@@ -667,141 +697,376 @@ test("会保存:选中某会话 → PUT 带 sessionId + sessionTitle;选回
|
|
|
667
697
|
assert.equal(sent.body.wecom.sessionTitle, "", "★ 清空时标题也必须清空(否则 UI 会显示错目标)");
|
|
668
698
|
});
|
|
669
699
|
|
|
670
|
-
// ───────────────────── ⑥
|
|
700
|
+
// ───────────────────── ⑥ 钉钉 / 飞书通道(2026-11-08 本轮支持) ─────────────────────
|
|
671
701
|
|
|
672
|
-
test("
|
|
702
|
+
test("平台列表:企业微信 / 钉钉 / 飞书 / Webhook 都是真入口(不再有「即将支持」),Webhook 注明免费", async () => {
|
|
673
703
|
const plugin = loadPlugin();
|
|
674
704
|
let tree = await plugin.settle();
|
|
675
705
|
openEntry(tree, "channel");
|
|
676
706
|
tree = await plugin.settle();
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
assert.ok(texts.some((t) => t.includes("
|
|
680
|
-
assert.ok(texts.some((t) => t.includes("
|
|
681
|
-
assert.ok(texts.some((t) => t.includes("
|
|
707
|
+
const texts = byClass(tree, "dru-ia-row").map((r) => textOf(r));
|
|
708
|
+
assert.ok(texts.some((t) => t.includes("企业微信智能机器人")), "企业微信在列表里(已支持)");
|
|
709
|
+
assert.ok(texts.some((t) => t.includes("钉钉机器人")), "钉钉机器人必须在列表里(本轮支持)");
|
|
710
|
+
assert.ok(texts.some((t) => t.includes("飞书机器人")), "飞书机器人必须在列表里(本轮支持,按需下载依赖)");
|
|
711
|
+
assert.ok(texts.some((t) => t.includes("Webhook") && t.includes("免费")), "高级 Webhook 保留并注明免费");
|
|
712
|
+
assert.ok(!texts.some((t) => t.includes("钉钉机器人") && t.includes("即将支持")), "钉钉不再是即将支持");
|
|
713
|
+
assert.ok(!texts.some((t) => t.includes("飞书机器人") && t.includes("即将支持")), "飞书不再是即将支持");
|
|
682
714
|
});
|
|
683
715
|
|
|
684
|
-
test("
|
|
716
|
+
test("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成功)+ 顶部「不用去后台」说明;手动配置折叠", async () => {
|
|
685
717
|
const plugin = loadPlugin();
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
718
|
+
plugin.setImBotsGet({
|
|
719
|
+
ok: true,
|
|
720
|
+
wecom: {}, feishu: {},
|
|
721
|
+
dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "", credentialSource: "" },
|
|
722
|
+
status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false, phase: "idle" } } },
|
|
723
|
+
});
|
|
724
|
+
plugin.setDingAuthStatus([{ status: "waiting" }, { status: "success", clientIdTail: "8899", credentialSource: "scan", hasSecret: true }]);
|
|
725
|
+
let tree = await plugin.settle();
|
|
726
|
+
openEntry(tree, "channel");
|
|
727
|
+
tree = await plugin.settle();
|
|
728
|
+
assert.ok(clickChannelRow(tree, "钉钉机器人"), "应能进钉钉配置页");
|
|
729
|
+
tree = await plugin.settle();
|
|
730
|
+
let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
731
|
+
assert.ok(dlg, "钉钉配置页应打开");
|
|
732
|
+
// 主路径:一句说明 + 扫码按钮
|
|
733
|
+
assert.ok(textOf(dlg).includes("不用去开发者后台创建应用") || textOf(dlg).includes("不需要去开发者后台创建应用"),
|
|
734
|
+
"顶部要说明不需要去开发者后台创建应用");
|
|
735
|
+
const scanBtn = btnWith(dlg, "用钉钉扫码,一键创建机器人");
|
|
736
|
+
assert.ok(scanBtn, "主路径必须是一个「扫码一键创建」按钮");
|
|
737
|
+
// 手动配置默认折叠(不占主路径)
|
|
738
|
+
assert.equal(find(dlg, (n) => n.props?.id === "dru-ding-client"), undefined, "手动字段默认不应出现(折叠在高级里)");
|
|
739
|
+
|
|
740
|
+
// 点扫码 → begin
|
|
741
|
+
scanBtn.props.onClick();
|
|
742
|
+
await flush(); await flush();
|
|
743
|
+
tree = await plugin.settle(3);
|
|
744
|
+
assert.ok(plugin.dingAuthCalls().some((c) => c.kind === "begin"), "★ 点扫码必须 POST begin");
|
|
745
|
+
dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
746
|
+
assert.ok(textOf(dlg).includes("等待你在钉钉里确认"), "要显示等待确认中");
|
|
747
|
+
assert.ok(textOf(dlg).includes("用手机钉钉扫"), "没有二维码库时要提示用手机钉钉扫链接");
|
|
748
|
+
const link = find(dlg, (n) => n.type === "a" && String(n.props?.href || "").includes("oapi.dingtalk.com"));
|
|
749
|
+
assert.ok(link, "要给出可点击的授权链接");
|
|
750
|
+
assert.equal(link.props.target, "_blank");
|
|
751
|
+
assert.equal(link.props.rel, "noopener");
|
|
752
|
+
assert.ok(btnWith(dlg, "复制授权链接"), "要有复制授权链接按钮");
|
|
753
|
+
|
|
754
|
+
// 轮询:waiting → success(fake timers 推进 2 轮)
|
|
755
|
+
await plugin.advance(2100);
|
|
756
|
+
await flush(); await flush();
|
|
757
|
+
tree = await plugin.settle(3);
|
|
758
|
+
await plugin.advance(2100);
|
|
759
|
+
await flush(); await flush();
|
|
760
|
+
tree = await plugin.settle(4);
|
|
761
|
+
assert.ok(plugin.dingAuthCalls().filter((c) => c.kind === "status").length >= 1, "要轮询授权状态");
|
|
762
|
+
assert.ok(textOf(tree).includes("已创建机器人并建立连接"), "成功后要自动切到已连接状态");
|
|
763
|
+
assert.ok(textOf(tree).includes("8899"), "成功提示要带 Client ID 尾号");
|
|
764
|
+
dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
765
|
+
assert.ok(dlg, "成功后弹层仍开着(用户能直接看到结果/去钉钉试)");
|
|
766
|
+
assert.ok(!textOf(dlg).includes("等待你在钉钉里确认"), "成功后不再显示等待态");
|
|
711
767
|
});
|
|
712
768
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
test("配置页收口:说明默认折叠(「为什么要填这些」),四个字段统一 dru-ia-field 结构", async () => {
|
|
769
|
+
test("钉钉手动模式:折叠展开后 7 步 + 三个坑 + 三字段;官方链接全部 target=_blank rel=noopener 且提交体字段锁死", async () => {
|
|
716
770
|
const plugin = loadPlugin();
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
771
|
+
plugin.setImBotsGet({
|
|
772
|
+
ok: true, wecom: {}, feishu: {},
|
|
773
|
+
dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "", credentialSource: "" },
|
|
774
|
+
status: { dingtalk: { connected: false, robotCodeConfigured: false }, feishu: {}, deps: { feishu: { installed: false, phase: "idle" } } },
|
|
775
|
+
});
|
|
776
|
+
let tree = await plugin.settle();
|
|
777
|
+
openEntry(tree, "channel");
|
|
778
|
+
tree = await plugin.settle();
|
|
779
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
780
|
+
tree = await plugin.settle();
|
|
781
|
+
let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
782
|
+
const toggle = find(dlg, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("高级:去开发者后台手动创建")));
|
|
783
|
+
assert.ok(toggle, "折叠标题应为「高级:去开发者后台手动创建(或手动填凭据)」");
|
|
784
|
+
assert.equal(toggle.props["aria-expanded"], "false", "手动配置默认收起");
|
|
785
|
+
toggle.props.onClick();
|
|
786
|
+
tree = await plugin.settle();
|
|
787
|
+
dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
788
|
+
|
|
789
|
+
// 7 步(第一步在「步骤」列表里)+ 三个坑
|
|
790
|
+
const steps = findAll(dlg, (n) => n.type === "li");
|
|
791
|
+
assert.ok(steps.length >= 7, "手动创建要有 7 步(列表项 " + steps.length + ")");
|
|
792
|
+
const allText = textOf(dlg);
|
|
793
|
+
for (const bit of ["登录开发者后台", "创建应用", "凭证与基础信息", "机器人配置", "Stream 模式", "Robot Code", "版本管理与发布"]) {
|
|
794
|
+
assert.ok(allText.includes(bit), "7 步缺内容: " + bit);
|
|
795
|
+
}
|
|
796
|
+
for (const pit of ["建错机器人", "只保存没发布", "模式选错/凭证脏"]) {
|
|
797
|
+
assert.ok(allText.includes(pit), "三个高频坑缺: " + pit);
|
|
798
|
+
}
|
|
799
|
+
// 官方链接:全部新窗口 + noopener
|
|
800
|
+
const links = findAll(dlg, (n) => n.type === "a");
|
|
801
|
+
const hrefs = links.map((a) => a.props.href);
|
|
802
|
+
for (const u of ["https://open.dingtalk.com/document/orgapp/create-an-application",
|
|
803
|
+
"https://open.dingtalk.com/document/orgapp/add-application-capabilities",
|
|
804
|
+
"https://open.dingtalk.com/document/orgapp/configure-the-robot-application",
|
|
805
|
+
"https://open.dingtalk.com/document/orgapp/faq-robot",
|
|
806
|
+
"https://open.dingtalk.com/document/orgapp/robot-receive-message"]) {
|
|
807
|
+
assert.ok(hrefs.includes(u), "缺官方链接: " + u);
|
|
808
|
+
}
|
|
809
|
+
for (const a of links) {
|
|
810
|
+
assert.equal(a.props.target, "_blank", "官方链接必须新窗口打开");
|
|
811
|
+
assert.equal(a.props.rel, "noopener");
|
|
734
812
|
}
|
|
735
|
-
});
|
|
736
813
|
|
|
737
|
-
//
|
|
814
|
+
// 三字段仍在(兜底手填)
|
|
815
|
+
for (const id of ["dru-ding-client", "dru-ding-secret", "dru-ding-robot"]) {
|
|
816
|
+
assert.ok(find(dlg, (n) => n.type === "input" && n.props?.id === id), "展开后缺字段: " + id);
|
|
817
|
+
}
|
|
818
|
+
find(dlg, (n) => n.props?.id === "dru-ding-client").props.onChange({ target: { value: "ding-client-1" } });
|
|
819
|
+
find(dlg, (n) => n.props?.id === "dru-ding-secret").props.onChange({ target: { value: "ding-secret-1" } });
|
|
820
|
+
find(dlg, (n) => n.props?.id === "dru-ding-robot").props.onChange({ target: { value: "ding-robot-1" } });
|
|
821
|
+
tree = await plugin.settle();
|
|
822
|
+
btnWith(tree, "仅保存").props.onClick();
|
|
823
|
+
await flush(); await flush();
|
|
824
|
+
const put = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
|
|
825
|
+
assert.ok(put, "应 PUT /dsh-remote/im-bots");
|
|
826
|
+
assert.equal(put.body.platform, "dingtalk");
|
|
827
|
+
assert.deepEqual(put.body.config, { clientId: "ding-client-1", clientSecret: "ding-secret-1", robotCode: "ding-robot-1" },
|
|
828
|
+
"★ 手动提交体字段锁死为 clientId/clientSecret/robotCode");
|
|
829
|
+
});
|
|
738
830
|
|
|
739
|
-
test("
|
|
831
|
+
test("钉钉顶部判断口诀 + Robot Code 缺失时「主动推送/卡片」置灰提示", async () => {
|
|
740
832
|
const plugin = loadPlugin();
|
|
741
833
|
plugin.setImBotsGet({
|
|
742
|
-
ok: true,
|
|
743
|
-
|
|
744
|
-
status: {
|
|
745
|
-
connected: true, authenticated: true, lastError: "", boundChatId: "chat-9",
|
|
746
|
-
sessions: [{ id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 1 }],
|
|
747
|
-
activeSessionId: "sess-1-abcdef123456", activeSessionTitle: "修复登录问题", boundSessionId: "",
|
|
748
|
-
},
|
|
834
|
+
ok: true, wecom: {}, feishu: {},
|
|
835
|
+
dingtalk: { enabled: true, clientId: "ding-client-8899", hasSecret: true, robotCode: "", credentialSource: "scan" },
|
|
836
|
+
status: { dingtalk: { connected: true, robotCodeConfigured: false, capabilities: { openapi: true, card: true } }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
749
837
|
});
|
|
750
|
-
let tree = await
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
838
|
+
let tree = await plugin.settle();
|
|
839
|
+
openEntry(tree, "channel");
|
|
840
|
+
tree = await plugin.settle();
|
|
841
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
842
|
+
tree = await plugin.settle();
|
|
843
|
+
const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
844
|
+
const t = textOf(dlg);
|
|
845
|
+
// 判断口诀(醒目)
|
|
846
|
+
assert.ok(t.includes("消息接收地址"), "顶部要有判断口诀");
|
|
847
|
+
assert.ok(t.includes("群 webhook 机器人"), "口诀要指出群 webhook 机器人不能长连接");
|
|
848
|
+
// 缺 Robot Code:两个能力置灰 + 提示
|
|
849
|
+
assert.ok(t.includes("补 Robot Code 可开启"), "缺 Robot Code 要提示「补 Robot Code 可开启」");
|
|
850
|
+
const disabled = findAll(dlg, (n) => String(n.props?.className || "").includes("is-disabled"));
|
|
851
|
+
assert.ok(disabled.length >= 2, "主动推送 / 卡片都应为置灰态,实际 " + disabled.length);
|
|
852
|
+
for (const row of disabled) assert.equal(row.props["aria-disabled"], "true", "置灰能力要 aria-disabled=true");
|
|
853
|
+
});
|
|
756
854
|
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
|
|
855
|
+
test("钉钉能力:Robot Code 已配置 → 能力可用(不再置灰/提示)", async () => {
|
|
856
|
+
const plugin = loadPlugin();
|
|
857
|
+
plugin.setImBotsGet({
|
|
858
|
+
ok: true, wecom: {}, feishu: {},
|
|
859
|
+
dingtalk: { enabled: true, clientId: "ding-client-8899", hasSecret: true, robotCode: "rc-1", credentialSource: "manual" },
|
|
860
|
+
status: { dingtalk: { connected: true, robotCodeConfigured: true, capabilities: { openapi: true, card: true } }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
861
|
+
});
|
|
862
|
+
let tree = await plugin.settle();
|
|
863
|
+
openEntry(tree, "channel");
|
|
864
|
+
tree = await plugin.settle();
|
|
865
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
760
866
|
tree = await plugin.settle();
|
|
867
|
+
const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
868
|
+
assert.ok(!textOf(dlg).includes("补 Robot Code 可开启"), "已配置 Robot Code 就不该再提示补齐");
|
|
869
|
+
assert.equal(findAll(dlg, (n) => String(n.props?.className || "").includes("is-disabled")).length, 0, "能力不应置灰");
|
|
870
|
+
assert.ok(textOf(dlg).includes("来源:手动填写"), "来源显示手动填写");
|
|
871
|
+
});
|
|
761
872
|
|
|
762
|
-
|
|
763
|
-
|
|
873
|
+
test("钉钉扫码:begin 失败 → 人话 + 重试;已连接显示 clientId 尾号与来源(扫码/手动)", async () => {
|
|
874
|
+
// begin 失败
|
|
875
|
+
const p1 = loadPlugin();
|
|
876
|
+
p1.setImBotsGet({ ok: true, wecom: {}, feishu: {}, dingtalk: {}, status: { dingtalk: {}, feishu: {}, deps: { feishu: { installed: false } } } });
|
|
877
|
+
p1.setDingAuthBegin({ ok: false, code: "network_error", message: "网络不通:无法连接钉钉接口" }, 502);
|
|
878
|
+
let tree = await p1.settle();
|
|
879
|
+
openEntry(tree, "channel");
|
|
880
|
+
tree = await p1.settle();
|
|
881
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
882
|
+
tree = await p1.settle();
|
|
883
|
+
btnWith(tree, "用钉钉扫码,一键创建机器人").props.onClick();
|
|
764
884
|
await flush(); await flush();
|
|
765
|
-
tree = await
|
|
885
|
+
tree = await p1.settle(3);
|
|
886
|
+
assert.ok(textOf(tree).includes("发起授权失败"), "失败要给人话");
|
|
887
|
+
assert.ok(textOf(tree).includes("网络不通"), "保留后端原因");
|
|
888
|
+
assert.ok(btnWith(tree, "用钉钉扫码,一键创建机器人"), "失败后按钮仍可重试");
|
|
889
|
+
|
|
890
|
+
// 已连接:尾号 + 来源
|
|
891
|
+
const p2 = loadPlugin();
|
|
892
|
+
p2.setImBotsGet({
|
|
893
|
+
ok: true, wecom: {}, feishu: {},
|
|
894
|
+
dingtalk: { enabled: true, clientId: "ding-client-8899", hasSecret: true, robotCode: "rc", credentialSource: "scan" },
|
|
895
|
+
status: { dingtalk: { connected: true }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
896
|
+
});
|
|
897
|
+
tree = await p2.settle();
|
|
898
|
+
openEntry(tree, "channel");
|
|
899
|
+
tree = await p2.settle();
|
|
900
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
901
|
+
tree = await p2.settle();
|
|
902
|
+
const t = textOf(tree);
|
|
903
|
+
assert.ok(t.includes("已连接"), "已连接状态要显示");
|
|
904
|
+
assert.ok(t.includes("8899"), "要显示 Client ID 尾号");
|
|
905
|
+
assert.ok(t.includes("来源:扫码创建"), "要显示凭据来源(扫码)");
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("飞书:依赖未装 → 41MB 下载入口;已装 → 只有 App ID/Secret 两个字段(不再让用户填接收人)", async () => {
|
|
909
|
+
// 未装依赖
|
|
910
|
+
const p1 = loadPlugin();
|
|
911
|
+
p1.setImDeps({ installed: false, phase: "idle", version: "", lastError: "" });
|
|
912
|
+
p1.setImBotsGet({
|
|
913
|
+
ok: true, wecom: {}, dingtalk: {}, feishu: {},
|
|
914
|
+
status: { deps: { feishu: { installed: false, phase: "idle", version: "", lastError: "" } } },
|
|
915
|
+
});
|
|
916
|
+
let tree = await p1.settle();
|
|
917
|
+
openEntry(tree, "channel");
|
|
918
|
+
tree = await p1.settle();
|
|
919
|
+
assert.ok(clickChannelRow(tree, "飞书机器人"), "应能进飞书页");
|
|
920
|
+
tree = await p1.settle();
|
|
921
|
+
assert.ok(textOf(tree).includes("41MB"), "未装依赖要说明约 41MB、仅装一次");
|
|
922
|
+
const dlBtn = btnWith(tree, "下载依赖");
|
|
923
|
+
assert.ok(dlBtn, "要有「下载依赖」按钮");
|
|
924
|
+
assert.ok(textOf(tree).includes("不下载也能用其它通道"), "要说明不下载也能用其它通道");
|
|
925
|
+
assert.equal(find(tree, (n) => n.props?.id === "dru-fs-appid"), undefined, "未装依赖时不放配置表单");
|
|
926
|
+
dlBtn.props.onClick();
|
|
927
|
+
await flush(); await flush();
|
|
928
|
+
assert.ok(p1.requests.some((r) => r.path === "/dsh-remote/im-deps/feishu" && r.method === "POST"),
|
|
929
|
+
"点下载必须 POST /dsh-remote/im-deps/feishu");
|
|
930
|
+
|
|
931
|
+
// 已装依赖:只有两个字段
|
|
932
|
+
const p2 = loadPlugin();
|
|
933
|
+
p2.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
|
|
934
|
+
p2.setImBotsGet({
|
|
935
|
+
ok: true, wecom: {}, dingtalk: {},
|
|
936
|
+
feishu: { enabled: false, appId: "", hasSecret: false },
|
|
937
|
+
status: { feishu: { connected: false, boundChatId: "" }, deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } } },
|
|
938
|
+
});
|
|
939
|
+
tree = await p2.settle();
|
|
940
|
+
openEntry(tree, "channel");
|
|
941
|
+
tree = await p2.settle();
|
|
942
|
+
clickChannelRow(tree, "飞书机器人");
|
|
943
|
+
tree = await p2.settle();
|
|
944
|
+
let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
945
|
+
assert.ok(dlg, "已装依赖应打开飞书配置页");
|
|
946
|
+
assert.ok(textOf(dlg).includes("1.40.0"), "应显示依赖版本");
|
|
947
|
+
assert.ok(find(dlg, (n) => n.props?.id === "dru-fs-appid"), "要有 App ID");
|
|
948
|
+
assert.ok(find(dlg, (n) => n.props?.id === "dru-fs-secret"), "要有 App Secret");
|
|
949
|
+
// ★ 业主重点:接收人/接收类型不再出现(用户不知道怎么获取,默认自动发现)
|
|
950
|
+
assert.equal(find(dlg, (n) => n.props?.id === "dru-fs-recv"), undefined, "不得再出现「接收人」输入框");
|
|
951
|
+
assert.equal(find(dlg, (n) => n.props?.id === "dru-fs-rtype"), undefined, "不得再出现「接收类型」选择");
|
|
952
|
+
const inputs = findAll(dlg, (n) => n.type === "input");
|
|
953
|
+
assert.equal(inputs.length, 2, "飞书配置页只能有两个输入框(App ID / App Secret),实际 " + inputs.length);
|
|
954
|
+
assert.ok(textOf(dlg).includes("不需要操作") || textOf(dlg).includes("自动绑定"),
|
|
955
|
+
"未绑定时要说明「连接后自动绑定,不需要操作」");
|
|
956
|
+
|
|
957
|
+
// 两个官方链接(业主给的地址),target=_blank rel=noopener
|
|
958
|
+
const links = findAll(dlg, (n) => n.type === "a");
|
|
959
|
+
assert.equal(links.length, 2, "配置页顶部要有两个官方外链,实际 " + links.length);
|
|
960
|
+
const hrefs = links.map((a) => a.props.href);
|
|
961
|
+
assert.ok(hrefs.includes("https://open.feishu.cn/app?lang=zh-CN"), "必须有「创建企业自建应用」链接");
|
|
962
|
+
assert.ok(hrefs.includes("https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/quick-start/develop-a-card-interactive-bot"),
|
|
963
|
+
"必须有卡片交互机器人文档链接");
|
|
964
|
+
for (const a of links) {
|
|
965
|
+
assert.equal(a.props.target, "_blank");
|
|
966
|
+
assert.equal(a.props.rel, "noopener");
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// 3 步主路径 + 细步骤折叠(不再一上来就 8 步)
|
|
970
|
+
const mainSteps = findAll(dlg, (n) => n.type === "li");
|
|
971
|
+
assert.equal(mainSteps.length, 3, "主路径只有 3 步,实际 " + mainSteps.length);
|
|
972
|
+
const toggle = find(dlg, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("细步骤")));
|
|
973
|
+
assert.ok(toggle, "要有「细步骤」折叠入口");
|
|
974
|
+
assert.equal(toggle.props["aria-expanded"], "false", "细步骤默认收起");
|
|
975
|
+
toggle.props.onClick();
|
|
976
|
+
tree = await p2.settle();
|
|
977
|
+
dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
978
|
+
const allSteps = findAll(dlg, (n) => n.type === "li");
|
|
979
|
+
assert.equal(allSteps.length, 3 + 5, "展开后 3 条主路径 + 5 条细步骤,实际 " + allSteps.length);
|
|
980
|
+
assert.ok(textOf(dlg).includes("长连接必须已在线"), "顺序坑必须保留");
|
|
981
|
+
assert.ok(textOf(dlg).includes("先在上面填凭据"), "必须写明正确顺序:先填凭据连上,再去后台保存事件配置");
|
|
982
|
+
});
|
|
766
983
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
984
|
+
test("飞书:自动绑定状态行两态(未绑定提示自动发现 / 已绑定显示群名或私聊)", async () => {
|
|
985
|
+
// 已绑定私聊
|
|
986
|
+
const p1 = loadPlugin();
|
|
987
|
+
p1.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
|
|
988
|
+
p1.setImBotsGet({
|
|
989
|
+
ok: true, wecom: {}, dingtalk: {},
|
|
990
|
+
feishu: { enabled: true, appId: "cli_1", hasSecret: true },
|
|
991
|
+
status: {
|
|
992
|
+
feishu: { connected: true, boundChatId: "ou_user_1", boundChatType: "p2p", receiveIdSource: "auto" },
|
|
993
|
+
deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } },
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
let tree = await p1.settle();
|
|
997
|
+
openEntry(tree, "channel");
|
|
998
|
+
tree = await p1.settle();
|
|
999
|
+
clickChannelRow(tree, "飞书机器人");
|
|
1000
|
+
tree = await p1.settle();
|
|
1001
|
+
assert.ok(textOf(tree).includes("已自动绑定:私聊"), "已绑定私聊要显示「已自动绑定:私聊」");
|
|
1002
|
+
|
|
1003
|
+
// 已绑定群
|
|
1004
|
+
const p2 = loadPlugin();
|
|
1005
|
+
p2.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
|
|
1006
|
+
p2.setImBotsGet({
|
|
1007
|
+
ok: true, wecom: {}, dingtalk: {},
|
|
1008
|
+
feishu: { enabled: true, appId: "cli_1", hasSecret: true },
|
|
1009
|
+
status: {
|
|
1010
|
+
feishu: { connected: true, boundChatId: "oc_group_abcdef", boundChatType: "group", receiveIdSource: "auto" },
|
|
1011
|
+
deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } },
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
tree = await p2.settle();
|
|
1015
|
+
openEntry(tree, "channel");
|
|
1016
|
+
tree = await p2.settle();
|
|
1017
|
+
clickChannelRow(tree, "飞书机器人");
|
|
1018
|
+
tree = await p2.settle();
|
|
1019
|
+
const t = textOf(tree);
|
|
1020
|
+
assert.ok(t.includes("已自动绑定:群"), "已绑定群要显示「已自动绑定:群…」");
|
|
1021
|
+
assert.ok(t.includes("无需配置"), "自动绑定要说明是自动发现、无需配置");
|
|
778
1022
|
});
|
|
779
1023
|
|
|
780
|
-
test("
|
|
1024
|
+
test("飞书依赖:失败状态可重试,失败原因人话展示", async () => {
|
|
781
1025
|
const plugin = loadPlugin();
|
|
1026
|
+
plugin.setImDeps({ installed: false, phase: "failed", version: "", lastError: "网络不通:检查能否访问 npm registry" });
|
|
782
1027
|
plugin.setImBotsGet({
|
|
783
|
-
ok: true,
|
|
784
|
-
|
|
785
|
-
status: { connected: true, authenticated: true, lastError: "", boundChatId: "", sessions: [], activeSessionId: "", boundSessionId: "" },
|
|
1028
|
+
ok: true, wecom: {}, dingtalk: {}, feishu: {},
|
|
1029
|
+
status: { deps: { feishu: { installed: false, phase: "failed", version: "", lastError: "网络不通:检查能否访问 npm registry" } } },
|
|
786
1030
|
});
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1031
|
+
let tree = await plugin.settle();
|
|
1032
|
+
openEntry(tree, "channel");
|
|
1033
|
+
tree = await plugin.settle();
|
|
1034
|
+
clickChannelRow(tree, "飞书机器人");
|
|
1035
|
+
tree = await plugin.settle();
|
|
1036
|
+
assert.ok(textOf(tree).includes("网络不通"), "失败原因要人话展示");
|
|
1037
|
+
assert.ok(btnWith(tree, "重试下载"), "失败后按钮变为「重试下载」");
|
|
1038
|
+
btnWith(tree, "重试下载").props.onClick();
|
|
1039
|
+
await flush(); await flush();
|
|
1040
|
+
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/im-deps/feishu" && r.method === "POST"), "重试必须再次触发安装");
|
|
792
1041
|
});
|
|
793
1042
|
|
|
794
|
-
test("
|
|
1043
|
+
test("飞书表单提交体:只提交 appId/appSecret(不含接收人);secret 不回显", async () => {
|
|
795
1044
|
const plugin = loadPlugin();
|
|
796
|
-
|
|
1045
|
+
plugin.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
|
|
797
1046
|
plugin.setImBotsGet({
|
|
798
|
-
ok: true,
|
|
799
|
-
|
|
800
|
-
status: {
|
|
1047
|
+
ok: true, wecom: {}, dingtalk: {},
|
|
1048
|
+
feishu: { enabled: true, appId: "cli_old", hasSecret: true },
|
|
1049
|
+
status: { feishu: { connected: true, boundChatId: "oc_g" }, deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } } },
|
|
801
1050
|
});
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1051
|
+
let tree = await plugin.settle();
|
|
1052
|
+
openEntry(tree, "channel");
|
|
1053
|
+
tree = await plugin.settle();
|
|
1054
|
+
clickChannelRow(tree, "飞书机器人");
|
|
1055
|
+
tree = await plugin.settle();
|
|
1056
|
+
const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
1057
|
+
// 已保存的 secret 不回显:password 输入框初始为空 + 占位提示
|
|
1058
|
+
const sec = find(dlg, (n) => n.props?.id === "dru-fs-secret");
|
|
1059
|
+
assert.equal(sec.props.value, "", "★ 已保存的 Secret 绝不能回显到输入框");
|
|
1060
|
+
assert.ok(String(sec.props.placeholder || "").includes("已保存"), "要提示「已保存(留空表示不修改)」");
|
|
1061
|
+
find(dlg, (n) => n.props?.id === "dru-fs-appid").props.onChange({ target: { value: "cli_new" } });
|
|
1062
|
+
tree = await plugin.settle();
|
|
1063
|
+
btnWith(tree, "仅保存").props.onClick();
|
|
1064
|
+
await flush(); await flush();
|
|
1065
|
+
const put = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
|
|
1066
|
+
assert.ok(put, "应 PUT /dsh-remote/im-bots");
|
|
1067
|
+
assert.equal(put.body.platform, "feishu");
|
|
1068
|
+
assert.deepEqual(put.body.config, { appId: "cli_new", appSecret: "" },
|
|
1069
|
+
"★ 飞书提交体只有两个凭据字段(接收人由桥自动发现;空 secret = 不修改)");
|
|
805
1070
|
});
|
|
806
1071
|
|
|
807
1072
|
// ───────────── ⑨ 未登录守卫 + 后台服务横排 + 卸载挽留(2026-11-07 业主逐条)─────────────
|
|
@@ -994,3 +1259,32 @@ test("版本与更新:有一行可复制的安装/更新命令 npx @mrrisega/d
|
|
|
994
1259
|
tree = await plugin.settle();
|
|
995
1260
|
assert.ok(textOf(tree).includes("✅ 已复制"), "复制成功要有与其它复制按钮一致的反馈");
|
|
996
1261
|
});
|
|
1262
|
+
|
|
1263
|
+
test("飞书依赖下载:点下载 → POST 安装 → 轮询 /im-deps,装好后页面自动切到配置表单(无需重开弹层)", async () => {
|
|
1264
|
+
const plugin = loadPlugin();
|
|
1265
|
+
plugin.setImDeps({ installed: false, phase: "idle", version: "", lastError: "" });
|
|
1266
|
+
plugin.setImBotsGet({
|
|
1267
|
+
ok: true, wecom: {}, dingtalk: {}, feishu: {},
|
|
1268
|
+
status: { deps: { feishu: { installed: false, phase: "idle", version: "", lastError: "" } } },
|
|
1269
|
+
});
|
|
1270
|
+
let tree = await plugin.settle();
|
|
1271
|
+
openEntry(tree, "channel");
|
|
1272
|
+
tree = await plugin.settle();
|
|
1273
|
+
clickChannelRow(tree, "飞书机器人");
|
|
1274
|
+
tree = await plugin.settle();
|
|
1275
|
+
assert.ok(btnWith(tree, "下载依赖"), "未装依赖先给下载入口");
|
|
1276
|
+
|
|
1277
|
+
btnWith(tree, "下载依赖").props.onClick();
|
|
1278
|
+
await flush(); await flush();
|
|
1279
|
+
assert.equal(plugin.depsInstalls().length, 1, "★ 点下载必须触发一次后台安装");
|
|
1280
|
+
|
|
1281
|
+
// 先渲染一次让"下载中"的轮询 effect 挂上定时器,再模拟安装完成
|
|
1282
|
+
tree = await plugin.settle(2);
|
|
1283
|
+
plugin.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
|
|
1284
|
+
await plugin.advance(1000); // 触发轮询定时器(真实环境 900ms 一次)
|
|
1285
|
+
await flush(); await flush();
|
|
1286
|
+
tree = await plugin.settle(4);
|
|
1287
|
+
assert.ok(plugin.depsCalls().length >= 1, "应轮询 /dsh-remote/im-deps 拿进度");
|
|
1288
|
+
assert.ok(textOf(tree).includes("1.40.0"), "装好后面板直接显示版本(用户不需要重开弹层)");
|
|
1289
|
+
assert.ok(find(tree, (n) => n.props?.id === "dru-fs-appid"), "装好后应出现飞书配置表单");
|
|
1290
|
+
});
|