@mrrisega/dsh-remote 0.7.12-beta.2 → 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 +49 -6
- package/clients/dsh-remote/docs/feishu-bot-contract.md +56 -16
- package/clients/dsh-remote/dsh-bridge.mjs +6 -4
- package/clients/dsh-remote/feishu-bot.mjs +197 -13
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +81 -1
- package/clients/dsh-remote/test/feishu-bot.test.mjs +260 -10
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +237 -69
- package/packages/dsh-remote-web/lib/index.js +215 -7
- 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 -90
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +1668 -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/dsh-setup.mjs +1 -1
- package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +14 -1
- package/packages/dsh-remote-web/test/im-bots.test.mjs +157 -5
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +261 -132
|
@@ -100,6 +100,7 @@ function loadPlugin(opts = {}) {
|
|
|
100
100
|
const requests = [];
|
|
101
101
|
const clipboard = [];
|
|
102
102
|
const depsCalls = [];
|
|
103
|
+
const dingAuthCalls = [];
|
|
103
104
|
const depsInstalls = [];
|
|
104
105
|
let unmounted = false;
|
|
105
106
|
|
|
@@ -195,6 +196,8 @@ function loadPlugin(opts = {}) {
|
|
|
195
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 } };
|
|
196
197
|
let imBotsTestReply = { status: 200, body: { ok: true, connected: true, authenticated: true, lastError: "" } };
|
|
197
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 = [];
|
|
198
201
|
const nextPoll = () => (pollReplies.length > 1 ? pollReplies.shift() : pollReplies[0]);
|
|
199
202
|
|
|
200
203
|
const makeEl = () => ({
|
|
@@ -258,6 +261,15 @@ function loadPlugin(opts = {}) {
|
|
|
258
261
|
imBotsCalls.push({ method: "POST", body: options.body ? JSON.parse(options.body) : null });
|
|
259
262
|
return response(imBotsTestReply.status, imBotsTestReply.body);
|
|
260
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
|
+
}
|
|
261
273
|
if (path === "/dsh-remote/im-deps") {
|
|
262
274
|
depsCalls.push(Date.now());
|
|
263
275
|
return response(200, { ok: true, feishu: imDepsReply });
|
|
@@ -334,6 +346,9 @@ function loadPlugin(opts = {}) {
|
|
|
334
346
|
setImBotsPut(body, status = 200) { imBotsPutReply = { status, body }; },
|
|
335
347
|
setImBotsTest(body, status = 200) { imBotsTestReply = { status, body }; },
|
|
336
348
|
setImDeps(body) { imDepsReply = body; },
|
|
349
|
+
setDingAuthBegin(body, status = 200) { dingAuthBeginReply = { status, body }; },
|
|
350
|
+
setDingAuthStatus(list) { dingAuthStatusReplies = (list || []).slice(); },
|
|
351
|
+
dingAuthCalls() { return dingAuthCalls.slice(); },
|
|
337
352
|
depsCalls() { return depsCalls.slice(); },
|
|
338
353
|
depsInstalls() { return depsInstalls.slice(); },
|
|
339
354
|
imBotsCalls() { return imBotsCalls.slice(); },
|
|
@@ -698,30 +713,108 @@ test("平台列表:企业微信 / 钉钉 / 飞书 / Webhook 都是真入口(
|
|
|
698
713
|
assert.ok(!texts.some((t) => t.includes("飞书机器人") && t.includes("即将支持")), "飞书不再是即将支持");
|
|
699
714
|
});
|
|
700
715
|
|
|
701
|
-
test("
|
|
716
|
+
test("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成功)+ 顶部「不用去后台」说明;手动配置折叠", async () => {
|
|
702
717
|
const plugin = loadPlugin();
|
|
703
718
|
plugin.setImBotsGet({
|
|
704
719
|
ok: true,
|
|
705
|
-
wecom: {
|
|
706
|
-
dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "" },
|
|
707
|
-
|
|
708
|
-
status: { connected: false, sessions: [], activeSessionId: "", boundSessionId: "", dingtalk: {}, feishu: {}, deps: { feishu: { installed: false, phase: "idle" } } },
|
|
720
|
+
wecom: {}, feishu: {},
|
|
721
|
+
dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "", credentialSource: "" },
|
|
722
|
+
status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false, phase: "idle" } } },
|
|
709
723
|
});
|
|
724
|
+
plugin.setDingAuthStatus([{ status: "waiting" }, { status: "success", clientIdTail: "8899", credentialSource: "scan", hasSecret: true }]);
|
|
710
725
|
let tree = await plugin.settle();
|
|
711
726
|
openEntry(tree, "channel");
|
|
712
727
|
tree = await plugin.settle();
|
|
713
728
|
assert.ok(clickChannelRow(tree, "钉钉机器人"), "应能进钉钉配置页");
|
|
714
729
|
tree = await plugin.settle();
|
|
715
|
-
|
|
730
|
+
let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
716
731
|
assert.ok(dlg, "钉钉配置页应打开");
|
|
717
|
-
|
|
718
|
-
|
|
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("等待你在钉钉里确认"), "成功后不再显示等待态");
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test("钉钉手动模式:折叠展开后 7 步 + 三个坑 + 三字段;官方链接全部 target=_blank rel=noopener 且提交体字段锁死", async () => {
|
|
770
|
+
const plugin = loadPlugin();
|
|
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");
|
|
719
812
|
}
|
|
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
813
|
|
|
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
|
+
}
|
|
725
818
|
find(dlg, (n) => n.props?.id === "dru-ding-client").props.onChange({ target: { value: "ding-client-1" } });
|
|
726
819
|
find(dlg, (n) => n.props?.id === "dru-ding-secret").props.onChange({ target: { value: "ding-secret-1" } });
|
|
727
820
|
find(dlg, (n) => n.props?.id === "dru-ding-robot").props.onChange({ target: { value: "ding-robot-1" } });
|
|
@@ -730,12 +823,89 @@ test("钉钉配置页:Client ID / Client Secret / Robot Code + 保存并连接
|
|
|
730
823
|
await flush(); await flush();
|
|
731
824
|
const put = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
|
|
732
825
|
assert.ok(put, "应 PUT /dsh-remote/im-bots");
|
|
733
|
-
assert.equal(put.body.platform, "dingtalk"
|
|
826
|
+
assert.equal(put.body.platform, "dingtalk");
|
|
734
827
|
assert.deepEqual(put.body.config, { clientId: "ding-client-1", clientSecret: "ding-secret-1", robotCode: "ding-robot-1" },
|
|
735
|
-
"★
|
|
828
|
+
"★ 手动提交体字段锁死为 clientId/clientSecret/robotCode");
|
|
736
829
|
});
|
|
737
830
|
|
|
738
|
-
test("
|
|
831
|
+
test("钉钉顶部判断口诀 + Robot Code 缺失时「主动推送/卡片」置灰提示", async () => {
|
|
832
|
+
const plugin = loadPlugin();
|
|
833
|
+
plugin.setImBotsGet({
|
|
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 } } },
|
|
837
|
+
});
|
|
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
|
+
});
|
|
854
|
+
|
|
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, "钉钉机器人");
|
|
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
|
+
});
|
|
872
|
+
|
|
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();
|
|
884
|
+
await flush(); await flush();
|
|
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 () => {
|
|
739
909
|
// 未装依赖
|
|
740
910
|
const p1 = loadPlugin();
|
|
741
911
|
p1.setImDeps({ installed: false, phase: "idle", version: "", lastError: "" });
|
|
@@ -758,13 +928,13 @@ test("飞书:依赖未装 → 41MB 下载入口 + 「不下载也能用其它
|
|
|
758
928
|
assert.ok(p1.requests.some((r) => r.path === "/dsh-remote/im-deps/feishu" && r.method === "POST"),
|
|
759
929
|
"点下载必须 POST /dsh-remote/im-deps/feishu");
|
|
760
930
|
|
|
761
|
-
//
|
|
931
|
+
// 已装依赖:只有两个字段
|
|
762
932
|
const p2 = loadPlugin();
|
|
763
933
|
p2.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
|
|
764
934
|
p2.setImBotsGet({
|
|
765
935
|
ok: true, wecom: {}, dingtalk: {},
|
|
766
|
-
feishu: { enabled: false, appId: "", hasSecret: false
|
|
767
|
-
status: { deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } } },
|
|
936
|
+
feishu: { enabled: false, appId: "", hasSecret: false },
|
|
937
|
+
status: { feishu: { connected: false, boundChatId: "" }, deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } } },
|
|
768
938
|
});
|
|
769
939
|
tree = await p2.settle();
|
|
770
940
|
openEntry(tree, "channel");
|
|
@@ -774,26 +944,81 @@ test("飞书:依赖未装 → 41MB 下载入口 + 「不下载也能用其它
|
|
|
774
944
|
let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
775
945
|
assert.ok(dlg, "已装依赖应打开飞书配置页");
|
|
776
946
|
assert.ok(textOf(dlg).includes("1.40.0"), "应显示依赖版本");
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|
780
958
|
const links = findAll(dlg, (n) => n.type === "a");
|
|
781
|
-
assert.equal(links.length, 2, "
|
|
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
|
+
"必须有卡片交互机器人文档链接");
|
|
782
964
|
for (const a of links) {
|
|
783
965
|
assert.equal(a.props.target, "_blank");
|
|
784
966
|
assert.equal(a.props.rel, "noopener");
|
|
785
|
-
assert.match(String(a.props.href), /^https:\/\/open\.feishu\.cn\//, "外链必须是飞书官方文档");
|
|
786
967
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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();
|
|
791
976
|
tree = await p2.settle();
|
|
792
977
|
dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
793
|
-
const
|
|
794
|
-
assert.equal(
|
|
795
|
-
assert.ok(textOf(dlg).includes("长连接必须已在线"), "
|
|
796
|
-
assert.ok(textOf(dlg).includes("
|
|
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
|
+
});
|
|
983
|
+
|
|
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("无需配置"), "自动绑定要说明是自动发现、无需配置");
|
|
797
1022
|
});
|
|
798
1023
|
|
|
799
1024
|
test("飞书依赖:失败状态可重试,失败原因人话展示", async () => {
|
|
@@ -815,13 +1040,13 @@ test("飞书依赖:失败状态可重试,失败原因人话展示", async ()
|
|
|
815
1040
|
assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/im-deps/feishu" && r.method === "POST"), "重试必须再次触发安装");
|
|
816
1041
|
});
|
|
817
1042
|
|
|
818
|
-
test("
|
|
1043
|
+
test("飞书表单提交体:只提交 appId/appSecret(不含接收人);secret 不回显", async () => {
|
|
819
1044
|
const plugin = loadPlugin();
|
|
820
1045
|
plugin.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
|
|
821
1046
|
plugin.setImBotsGet({
|
|
822
1047
|
ok: true, wecom: {}, dingtalk: {},
|
|
823
|
-
feishu: { enabled: true, appId: "cli_old", hasSecret: true
|
|
824
|
-
status: { deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } } },
|
|
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: "" } } },
|
|
825
1050
|
});
|
|
826
1051
|
let tree = await plugin.settle();
|
|
827
1052
|
openEntry(tree, "channel");
|
|
@@ -834,110 +1059,14 @@ test("飞书表单提交体:appId/appSecret/receiveId/receiveIdType;secret
|
|
|
834
1059
|
assert.equal(sec.props.value, "", "★ 已保存的 Secret 绝不能回显到输入框");
|
|
835
1060
|
assert.ok(String(sec.props.placeholder || "").includes("已保存"), "要提示「已保存(留空表示不修改)」");
|
|
836
1061
|
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
1062
|
tree = await plugin.settle();
|
|
840
1063
|
btnWith(tree, "仅保存").props.onClick();
|
|
841
1064
|
await flush(); await flush();
|
|
842
1065
|
const put = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
|
|
843
1066
|
assert.ok(put, "应 PUT /dsh-remote/im-bots");
|
|
844
1067
|
assert.equal(put.body.platform, "feishu");
|
|
845
|
-
assert.deepEqual(put.body.config, { appId: "cli_new", appSecret: ""
|
|
846
|
-
"★
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
// ───────────────────── ⑦ 配置页收口:说明折叠 + 统一字段结构 ─────────────────────
|
|
850
|
-
|
|
851
|
-
test("配置页收口:说明默认折叠(「为什么要填这些」),四个字段统一 dru-ia-field 结构", async () => {
|
|
852
|
-
const plugin = loadPlugin();
|
|
853
|
-
const tree = await openWecom(plugin);
|
|
854
|
-
// 折叠:默认看不到服务地址字段
|
|
855
|
-
assert.equal(find(tree, (n) => n.props?.id === "dru-wecom-url"), undefined, "高级项默认折叠(不在主表单里)");
|
|
856
|
-
const disclose = find(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("为什么要填这些")));
|
|
857
|
-
assert.ok(disclose, "必须有「为什么要填这些」折叠按钮");
|
|
858
|
-
assert.equal(disclose.props["aria-expanded"], "false");
|
|
859
|
-
disclose.props.onClick();
|
|
860
|
-
const tree2 = await plugin.settle();
|
|
861
|
-
assert.ok(find(tree2, (n) => n.props?.id === "dru-wecom-url"), "展开后才出现高级字段");
|
|
862
|
-
// 四个字段(会话 / Bot ID / Secret / 接收会话 ID)都在 .dru-ia-field 里,label 可见
|
|
863
|
-
const fields = byClass(tree2, "dru-ia-field");
|
|
864
|
-
assert.ok(fields.length >= 4, "字段必须用统一 dru-ia-field 结构(实际 " + fields.length + ")");
|
|
865
|
-
for (const id of ["dru-wecom-session", "dru-wecom-bot", "dru-wecom-secret", "dru-wecom-chat"]) {
|
|
866
|
-
const ctl = find(tree2, (n) => (n.type === "input" || n.type === "select") && n.props?.id === id);
|
|
867
|
-
assert.ok(ctl, "缺字段控件: " + id);
|
|
868
|
-
const label = find(tree2, (n) => n.type === "label" && n.props?.htmlFor === id);
|
|
869
|
-
assert.ok(label, "每个字段必须有可见 label: " + id);
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
// ───────────────────── ⑧ 发送预览卡片(task-25 尾,桥侧已就绪) ─────────────────────
|
|
874
|
-
|
|
875
|
-
test("发送预览卡片:次要按钮在「测试」旁边;点击 PUT 带 previewCardAt 且不丢 botId/sessionId/targetChatId", async () => {
|
|
876
|
-
const plugin = loadPlugin();
|
|
877
|
-
plugin.setImBotsGet({
|
|
878
|
-
ok: true,
|
|
879
|
-
wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "chat-9", sessionId: "", sessionTitle: "" },
|
|
880
|
-
status: {
|
|
881
|
-
connected: true, authenticated: true, lastError: "", boundChatId: "chat-9",
|
|
882
|
-
sessions: [{ id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 1 }],
|
|
883
|
-
activeSessionId: "sess-1-abcdef123456", activeSessionTitle: "修复登录问题", boundSessionId: "",
|
|
884
|
-
},
|
|
885
|
-
});
|
|
886
|
-
let tree = await openWecom(plugin);
|
|
887
|
-
const previewBtn = btnWith(tree, "发送预览卡片");
|
|
888
|
-
assert.ok(previewBtn, "配置页必须有次要按钮「发送预览卡片」");
|
|
889
|
-
// 次要按钮:不是 primary(不抢主操作)、与「测试」同组摆在底部
|
|
890
|
-
assert.ok(!String(previewBtn.props.className || "").includes("is-primary"), "「发送预览卡片」不能是主按钮");
|
|
891
|
-
assert.ok(btnWith(tree, "测试"), "「测试」按钮仍在(两者并列)");
|
|
892
|
-
|
|
893
|
-
// 先选一个会话,验证预览请求会带上会话绑定
|
|
894
|
-
const sel = find(tree, (n) => n.type === "select" && n.props?.id === "dru-wecom-session");
|
|
895
|
-
sel.props.onChange({ target: { value: "sess-1-abcdef123456" } });
|
|
896
|
-
tree = await plugin.settle();
|
|
897
|
-
|
|
898
|
-
const before = Date.now();
|
|
899
|
-
btnWith(tree, "发送预览卡片").props.onClick();
|
|
900
|
-
await flush(); await flush();
|
|
901
|
-
tree = await plugin.settle();
|
|
902
|
-
|
|
903
|
-
const sent = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
|
|
904
|
-
assert.ok(sent, "★ 点击必须 PUT /dsh-remote/im-bots(桥监听 previewCardAt)");
|
|
905
|
-
assert.equal(typeof sent.body.wecom.previewCardAt, "number", "previewCardAt 必须是数字(ms 时间戳)");
|
|
906
|
-
assert.ok(sent.body.wecom.previewCardAt >= before, "previewCardAt 应是本次点击的时间戳(值变大才会触发)");
|
|
907
|
-
assert.equal(sent.body.wecom.botId, "aibdemo1234", "★ 不丢 Bot ID(覆盖写不许冲掉)");
|
|
908
|
-
assert.equal(sent.body.wecom.targetChatId, "chat-9", "★ 不丢接收会话 ID");
|
|
909
|
-
assert.equal(sent.body.wecom.sessionId, "sess-1-abcdef123456", "★ 不丢会话绑定");
|
|
910
|
-
assert.equal(sent.body.wecom.sessionTitle, "修复登录问题");
|
|
911
|
-
assert.ok(!("secret" in sent.body.wecom), "空 Secret 不提交(= 保留已保存的)");
|
|
912
|
-
// 成功状态行
|
|
913
|
-
assert.ok(textOf(tree).includes("已发送预览卡片,去企业微信看看"), "发送成功要有状态行");
|
|
914
|
-
});
|
|
915
|
-
|
|
916
|
-
test("发送预览卡片:没有接收方(boundChatId 与 targetChatId 都空)→ 行内提示,但仍允许点击", async () => {
|
|
917
|
-
const plugin = loadPlugin();
|
|
918
|
-
plugin.setImBotsGet({
|
|
919
|
-
ok: true,
|
|
920
|
-
wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
|
|
921
|
-
status: { connected: true, authenticated: true, lastError: "", boundChatId: "", sessions: [], activeSessionId: "", boundSessionId: "" },
|
|
922
|
-
});
|
|
923
|
-
const tree = await openWecom(plugin);
|
|
924
|
-
assert.ok(textOf(tree).includes("先在企微里给机器人发一句话完成绑定"), "未绑定给机器人时要有行内提示");
|
|
925
|
-
const btn = btnWith(tree, "发送预览卡片");
|
|
926
|
-
assert.ok(btn, "按钮必须存在");
|
|
927
|
-
assert.notEqual(btn.props.disabled, true, "★ 仍允许点击(桥会记日志跳过,不拦用户)");
|
|
928
|
-
});
|
|
929
|
-
|
|
930
|
-
test("发送预览卡片:已绑定(boundChatId 非空)→ 不显示未绑定提示;有 lastPreviewCardAt → 显示「上次预览:HH:MM」", async () => {
|
|
931
|
-
const plugin = loadPlugin();
|
|
932
|
-
const ts = new Date(2026, 0, 2, 9, 7, 0).getTime();
|
|
933
|
-
plugin.setImBotsGet({
|
|
934
|
-
ok: true,
|
|
935
|
-
wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
|
|
936
|
-
status: { connected: true, authenticated: true, lastError: "", boundChatId: "chat-9", lastPreviewCardAt: ts, sessions: [], activeSessionId: "", boundSessionId: "" },
|
|
937
|
-
});
|
|
938
|
-
const tree = await openWecom(plugin);
|
|
939
|
-
assert.ok(!textOf(tree).includes("先在企微里给机器人发一句话完成绑定"), "已绑定就不该再提示未绑定");
|
|
940
|
-
assert.ok(textOf(tree).includes("上次预览:09:07"), "应显示上次预览时间(HH:MM)");
|
|
1068
|
+
assert.deepEqual(put.body.config, { appId: "cli_new", appSecret: "" },
|
|
1069
|
+
"★ 飞书提交体只有两个凭据字段(接收人由桥自动发现;空 secret = 不修改)");
|
|
941
1070
|
});
|
|
942
1071
|
|
|
943
1072
|
// ───────────── ⑨ 未登录守卫 + 后台服务横排 + 卸载挽留(2026-11-07 业主逐条)─────────────
|