@mrrisega/dsh-remote 0.7.10-beta.3 → 0.7.11-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.
Files changed (68) hide show
  1. package/clients/dsh-remote/agent/README.md +412 -0
  2. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  3. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  4. package/clients/dsh-remote/agent/codex.mjs +736 -0
  5. package/clients/dsh-remote/agent/detect.mjs +399 -0
  6. package/clients/dsh-remote/agent/events.mjs +511 -0
  7. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  8. package/clients/dsh-remote/agent/index.mjs +339 -0
  9. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  10. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  11. package/clients/dsh-remote/agent/proc.mjs +179 -0
  12. package/clients/dsh-remote/agent/version.mjs +148 -0
  13. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  14. package/clients/dsh-remote/decision-poller.mjs +235 -0
  15. package/clients/dsh-remote/dsh-bridge.mjs +214 -1
  16. package/clients/dsh-remote/push-reporter.mjs +54 -0
  17. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  18. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  19. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  20. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  21. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  22. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  23. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  24. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  25. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  26. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  27. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  28. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  29. package/clients/dsh-remote/test/wecom-bot.test.mjs +1438 -0
  30. package/clients/dsh-remote/wecom-bot.mjs +2444 -0
  31. package/dsh-setup.mjs +18 -3
  32. package/package.json +2 -2
  33. package/packages/dsh-remote-web/lib/client.js +1048 -223
  34. package/packages/dsh-remote-web/lib/index.js +299 -2
  35. package/packages/dsh-remote-web/package.json +1 -1
  36. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  37. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  38. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  39. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +258 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +2444 -0
  53. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
  54. package/packages/dsh-remote-web/test/activation-entry.test.mjs +11 -1
  55. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +8 -3
  56. package/packages/dsh-remote-web/test/connect-ui.test.mjs +16 -4
  57. package/packages/dsh-remote-web/test/im-bots.test.mjs +359 -0
  58. package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
  59. package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +13 -5
  60. package/packages/dsh-remote-web/test/panel-ia.test.mjs +798 -0
  61. package/packages/dsh-remote-web/test/password-reset.test.mjs +11 -1
  62. package/packages/dsh-remote-web/test/plugin-link.test.mjs +78 -0
  63. package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +10 -1
  64. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +20 -8
  65. package/packages/dsh-remote-web/test/settings-entry.test.mjs +30 -21
  66. package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
  67. package/packages/dsh-remote-web/test/telemetry.test.mjs +5 -3
  68. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +57 -130
@@ -0,0 +1,798 @@
1
+ /**
2
+ * 插件端「面板信息架构」回归(2026-11-07 业主 UX 重做)。
3
+ *
4
+ * 业主原话:顶部 3 个 tab;登录才有微信通道;卡片式平铺堆叠;全是文字堆砌、垂直往下垒。
5
+ * 本文件锁的是**新结构**本身(行为细节见 wechat-bot-ui / remote-access-ui 等既有用例):
6
+ * ① 主面板 = 状态头(连接 pill + 打开手机端)+ 2 列入口网格(≤3 行/卡)+ 一行文字入口;
7
+ * ② 顶部不再有 tab 条;云端/自建降级为「手机访问」弹层里的分段控件;
8
+ * ③ 弹层:role=dialog + aria-modal + 标题 + ✕;Esc 关闭;关闭回焦点(可测的部分);
9
+ * ④ 登录不隐藏功能:未登录入口照常显示,文案写「登录后可同步」;
10
+ * ⑤ 通道弹层:平台列表 → 企业微信配置(Bot ID / Secret 不回显 / 接收会话 ID 可选)
11
+ * → 保存并连接 / 测试 → 行内状态(连接中 / 已连接 / 失败原因);
12
+ * ⑥ 图标一律内联 SVG(16px / currentColor / aria-hidden),**禁止 emoji 当图标**;
13
+ * ⑦ 表单错误就近显示(aria-invalid + role=alert),而不是只弹一个全局错误。
14
+ */
15
+ import assert from "node:assert/strict";
16
+ import { readFileSync } from "node:fs";
17
+ import test from "node:test";
18
+ import vm from "node:vm";
19
+ import { openEntry, clickChannelRow, findDialog } from "./lib-panel-ia.mjs";
20
+
21
+ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
22
+
23
+ const QR_DATA_URL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=";
24
+ /** 面板**唯一**的 settings.section 栏目 id(0.6.11 起微信面板是它的子组件,不再是第二个栏目)。 */
25
+ const ROOT_SECTION_ID = "dsh-remote";
26
+ /**
27
+ * 【登录门 · 0.6.12】微信机器人通道要求**已注册并登录**:未登录时这个 tab 根本不存在。
28
+ * harness 默认给 /dsh-remote/status 的应答补一个 config.phone(= client.js 判 loggedIn 的依据),
29
+ * 绝大多数用例因此仍在「已登录」这条正常路径上跑(否则 tab 不渲染,auto-enter 也无处可点);
30
+ * 需要验证未登录行为的用例用 setSignedIn(false) 显式登出。
31
+ */
32
+ const SESSION_CONFIG = { phone: "13800138000" };
33
+
34
+ const NOT_BOUND = {
35
+ ok: true, disabled: false, bound: false, bot_id: "", bound_at: 0, connected_at: 0,
36
+ last_push_ok_at: 0, last_error: "", cooldown_ms: 0, pending_replies: 0,
37
+ binding: { active: false }, channel_running: false, events_running: false
38
+ };
39
+ const BOUND = {
40
+ ...NOT_BOUND, bound: true, bot_id: "bot_7788", bound_at: 1_700_000_000_000,
41
+ connected_at: 1_700_000_600_000, last_push_ok_at: 1_700_000_700_000,
42
+ pending_replies: 2, channel_running: true, events_running: true
43
+ };
44
+
45
+ function walk(node, visit) {
46
+ if (node == null) return;
47
+ if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
48
+ if (typeof node !== "object") return;
49
+ visit(node);
50
+ for (const child of node.children || []) walk(child, visit);
51
+ }
52
+ function find(tree, predicate) {
53
+ let match;
54
+ walk(tree, (node) => { if (!match && predicate(node)) match = node; });
55
+ return match;
56
+ }
57
+ function textHas(tree, substr) {
58
+ return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
59
+ }
60
+ /** 按文案取按钮(真 <button>)。 */
61
+ function buttonWithText(tree, substr) {
62
+ return find(tree, (n) => n.type === "button" && typeof n.props?.onClick === "function"
63
+ && (n.children || []).some((c) => typeof c === "string" && c.includes(substr)));
64
+ }
65
+ /** 该节点的 class 列表里是否有某一个类(精确匹配,避免 .dru-wx-attn 误配 .dru-wx-attn-dot)。 */
66
+ function hasClass(node, cls) {
67
+ return String(node?.props?.className || "").split(/\s+/).includes(cls);
68
+ }
69
+ /** 节点在树里的**前序位置**(对本 harness 的树形即 DOM 顺序);找不到返回 -1。 */
70
+ function domIndex(tree, predicate) {
71
+ let i = 0;
72
+ let found = -1;
73
+ walk(tree, (n) => { if (found < 0 && predicate(n)) found = i; i += 1; });
74
+ return found;
75
+ }
76
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
77
+
78
+ /** 子树全部文本(拼接;用于"卡片文案/按钮文案"断言)。 */
79
+ function textOf(node) {
80
+ let out = "";
81
+ walk(node, (n) => { for (const c of n.children || []) if (typeof c === "string") out += c; });
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * 迷你 React(真实 useEffect:依赖比较 + 提交后执行 + cleanup)+ 可控假定时器 +
87
+ * 可变的面板路由应答。骨架与 connect-ui.test.mjs 一致,便于两个用例互为对照。
88
+ */
89
+ function loadPlugin(opts = {}) {
90
+ let moduleFactory;
91
+ const registered = new Map();
92
+ const metas = [];
93
+ const injects = new Map();
94
+ const requests = [];
95
+ let unmounted = false;
96
+
97
+ // ── 迷你 React:真实 useState/useEffect + 递归展开**函数子组件** ──
98
+ // 0.6.11 起 WeChatBotSection 是 RemoteControlSection 的子组件(渲染路径里还有既有的
99
+ // SelfManageCard),所以 hook 槽位必须**按组件实例**隔离:每个实例按「树中位置路径」持有
100
+ // 自己的 state/effects(近似 React 的位置化协调)。
101
+ // ⚠️ 曾经用「全局递增计数器」实现:父组件 57 个 useState 之后,home 视图的 SelfManageCard
102
+ // 与 wechat 视图的 WeChatBotSection **共用同一批下标** → 子组件读到别人的 state(串号),
103
+ // 表现是切回微信 tab 后凭空显示「读不到本机微信通道」。按路径分作用域即根治。
104
+ const scopes = new Map(); // key -> { hook, effectCursor, state, effects, pending }
105
+ const scopeStack = [];
106
+ const currentScope = () => scopeStack[scopeStack.length - 1];
107
+
108
+ let clock = 0;
109
+ let timerSeq = 1;
110
+ const timers = new Map();
111
+ const job = (fn) => { try { fn(); } catch (e) { /* 内部错误由断言暴露 */ } };
112
+ const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
113
+ const fakeSetInterval = (fn, ms) => { const id = timerSeq++; const every = Math.max(1, Number(ms) || 1); timers.set(id, { at: clock + every, fn, every }); return id; };
114
+ const fakeClear = (id) => { timers.delete(id); };
115
+ async function advance(ms) {
116
+ const target = clock + ms;
117
+ for (let guard = 0; guard < 2000; guard++) {
118
+ let due = null;
119
+ for (const [id, t] of timers) if (t.at <= target && (!due || t.at < due[1].at)) due = [id, t];
120
+ if (!due) break;
121
+ const [id, t] = due;
122
+ clock = t.at;
123
+ if (t.every) t.at = clock + t.every; else timers.delete(id);
124
+ job(t.fn);
125
+ await flush();
126
+ }
127
+ clock = target;
128
+ await flush(); await flush();
129
+ }
130
+
131
+ const react = {
132
+ createElement(type, props, ...children) {
133
+ // 与真实 React 一致:**展平嵌套数组子节点**。`h("div", p, [a, b])` 这种写法在 client.js 里很常见,
134
+ // 不展平的话 textHas(只看直接字符串子节点)就看不到数组里的文案 —— 曾因此让「冷却提示」断言假红
135
+ //(产品其实渲染了,是脚手架看不见)。React 的语义就是展平,所以这里对齐它而不是改产品文案。
136
+ return { type, props: props || {}, children: children.flat(Infinity) };
137
+ },
138
+ useState(initial) {
139
+ const sc = currentScope();
140
+ const index = sc.hook++;
141
+ if (!(index in sc.state)) sc.state[index] = typeof initial === "function" ? initial() : initial;
142
+ return [sc.state[index], (value) => { sc.state[index] = typeof value === "function" ? value(sc.state[index]) : value; }];
143
+ },
144
+ useEffect(fn, deps) {
145
+ const sc = currentScope();
146
+ const index = sc.effectCursor++;
147
+ const prev = sc.effects[index];
148
+ const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
149
+ if (changed) sc.pending.push({ index, fn, deps: deps ? [...deps] : deps });
150
+ },
151
+ useCallback(fn) { return fn; },
152
+ useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); }
153
+ };
154
+
155
+ const response = (status, body) => Promise.resolve({
156
+ ok: status >= 200 && status < 300,
157
+ status,
158
+ text: async () => (typeof body === "string" ? body : JSON.stringify(body))
159
+ });
160
+
161
+ let statusReply = { status: opts.statusCode || 200, body: opts.status || NOT_BOUND };
162
+ // 登录态(会话)与 /wechat/status/**面板级** /status 的应答相互独立:
163
+ // · 面板级 GET /dsh-remote/status 决定 client.js 的 loggedIn(= st.config.phone / hasLocalKey),
164
+ // 也就是微信 tab 在不在;
165
+ // · signedIn=false 时连 config.phone 一起抹掉/不补 → loggedIn 为假 → 登录门生效。
166
+ let signedIn = opts.signedIn !== false;
167
+ let panelReply = { status: 200, body: opts.panelStatus || { ok: true, service: { running: false } } };
168
+ const withSession = (body) => {
169
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
170
+ if (signedIn) return body.config ? body : { ...body, config: { ...SESSION_CONFIG } };
171
+ const { config, ...rest } = body;
172
+ return config ? { ...rest, config: { ...config, phone: "", hasLocalKey: false } } : body;
173
+ };
174
+ let startReply = {
175
+ status: 200,
176
+ body: { ok: true, qrcode_svg: QR_DATA_URL, qrcode_url: "https://liteapp.weixin.qq.com/q/7GiQu1", message: "请用手机微信扫描二维码完成绑定。" }
177
+ };
178
+ let pollReplies = (opts.polls || [{ status: 200, body: { ok: true, state: "wait", bound: false } }]).slice();
179
+ let verifyReply = { status: 200, body: { ok: true } };
180
+ let cancelReply = { status: 200, body: { ok: true, cancelled: true } };
181
+ let unbindReply = { status: 200, body: { ok: true, notify_error: "" } };
182
+ const counts = { status: 0, start: 0, poll: 0, verify: 0, cancel: 0, unbind: 0 };
183
+ // ── 通道(im-bots)API 应答:用例可注入成功/失败形态 ──
184
+ const imBotsCalls = [];
185
+ let imBotsGetReply = { ok: true, wecom: { enabled: false, botId: "", hasSecret: false, url: "", targetChatId: "" }, status: { connected: false, authenticated: false, lastError: "" }, updatedAt: 0 };
186
+ 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
+ let imBotsTestReply = { status: 200, body: { ok: true, connected: true, authenticated: true, lastError: "" } };
188
+ const nextPoll = () => (pollReplies.length > 1 ? pollReplies.shift() : pollReplies[0]);
189
+
190
+ const makeEl = () => ({
191
+ tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
192
+ setAttribute(k, v) { this.attributes[k] = v; },
193
+ getAttribute(k) { return this.attributes[k] ?? null; },
194
+ hasAttribute(k) { return Object.prototype.hasOwnProperty.call(this.attributes, k); },
195
+ toggleAttribute(k, on) { if (on) this.attributes[k] = ""; else delete this.attributes[k]; },
196
+ appendChild(c) { this.children.push(c); return c; },
197
+ removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); },
198
+ addEventListener() {}, removeEventListener() {}, insertBefore() {}, contains() { return false; }, closest() { return null; }
199
+ });
200
+ const doc = {
201
+ hidden: false,
202
+ createElement: makeEl,
203
+ head: makeEl(),
204
+ body: makeEl(),
205
+ documentElement: { style: {}, attributes: {} },
206
+ querySelector() { return null; },
207
+ querySelectorAll() { return []; },
208
+ getElementById() { return null; },
209
+ _listeners: {},
210
+ addEventListener(t, fn) { (this._listeners[t] = this._listeners[t] || []).push(fn); },
211
+ removeEventListener(t, fn) { const a = this._listeners[t] || []; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); }
212
+ };
213
+ class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
214
+ const localStorage = {
215
+ _s: new Map(),
216
+ getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
217
+ setItem(k, v) { this._s.set(k, String(v)); },
218
+ removeItem(k) { this._s.delete(k); }
219
+ };
220
+
221
+ const sandbox = {
222
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
223
+ document: doc,
224
+ localStorage,
225
+ MutationObserver: MutationObserverMock,
226
+ navigator: { clipboard: { writeText: async () => {} } },
227
+ setTimeout: fakeSetTimeout,
228
+ clearTimeout: fakeClear,
229
+ setInterval: fakeSetInterval,
230
+ clearInterval: fakeClear,
231
+ fetch(path, options = {}) {
232
+ requests.push({ path, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null });
233
+ if (path === "/dsh-remote/status") return response(panelReply.status, withSession(panelReply.body));
234
+ if (path === "/dsh-remote/wechat/status") { counts.status++; return response(statusReply.status, withSession(statusReply.body)); }
235
+ if (path === "/dsh-remote/wechat/bind/start") { counts.start++; return response(startReply.status, startReply.body); }
236
+ if (path === "/dsh-remote/wechat/bind/poll") { counts.poll++; const r = nextPoll(); return response(r.status, r.body); }
237
+ if (path === "/dsh-remote/wechat/bind/verify") { counts.verify++; return response(verifyReply.status, verifyReply.body); }
238
+ if (path === "/dsh-remote/wechat/bind/cancel") { counts.cancel++; return response(cancelReply.status, cancelReply.body); }
239
+ if (path === "/dsh-remote/wechat/unbind") { counts.unbind++; return response(unbindReply.status, unbindReply.body); }
240
+ if (path === "/dsh-remote/im-bots") {
241
+ if (String(options.method || "GET").toUpperCase() === "PUT") {
242
+ imBotsCalls.push({ method: "PUT", body: options.body ? JSON.parse(options.body) : null });
243
+ return response(imBotsPutReply.status, imBotsPutReply.body);
244
+ }
245
+ return response(200, imBotsGetReply);
246
+ }
247
+ if (path === "/dsh-remote/im-bots/test") {
248
+ imBotsCalls.push({ method: "POST", body: options.body ? JSON.parse(options.body) : null });
249
+ return response(imBotsTestReply.status, imBotsTestReply.body);
250
+ }
251
+ return response(200, { ok: true });
252
+ },
253
+ Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console
254
+ };
255
+
256
+ vm.runInNewContext(SOURCE, sandbox);
257
+ const plugin = moduleFactory((name) => {
258
+ assert.equal(name, "react");
259
+ return react;
260
+ });
261
+ plugin.apply({
262
+ slots: {
263
+ inject(name, cb) { injects.set(name, cb); cb(); },
264
+ register(meta, component) { metas.push(meta); registered.set(meta.id, component); return () => {}; }
265
+ }
266
+ });
267
+
268
+ let entered = false;
269
+ let renders = 0;
270
+ /** 渲染一棵树:函数组件递归展开(各自独立作用域 + 提交后跑 effects),普通节点保留并递归子节点。 */
271
+ function renderNode(node, path) {
272
+ if (node == null) return node;
273
+ if (Array.isArray(node)) return node.map((n, i) => renderNode(n, path + "/" + i));
274
+ if (typeof node !== "object") return node;
275
+ if (typeof node.type === "function") {
276
+ const key = path + ":" + (node.type.name || "anon");
277
+ const sc = scopes.get(key) || { hook: 0, effectCursor: 0, state: [], effects: [], pending: [] };
278
+ sc.hook = 0; sc.effectCursor = 0; sc.pending = [];
279
+ scopes.set(key, sc);
280
+ scopeStack.push(sc);
281
+ let out;
282
+ try { out = node.type(node.props || {}); } finally { scopeStack.pop(); }
283
+ const expanded = renderNode(out, key);
284
+ const todos = sc.pending; sc.pending = [];
285
+ for (const t of todos) {
286
+ const prev = sc.effects[t.index];
287
+ if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
288
+ const cleanup = t.fn();
289
+ sc.effects[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
290
+ }
291
+ return expanded;
292
+ }
293
+ return { ...node, children: (node.children || []).map((c, i) => renderNode(c, path + "/" + i)) };
294
+ }
295
+ /** 主 tab 条里的「微信机器人通道」tab(点它 = 真实用户进入微信面板的路径)。 */
296
+ function weChatTab(tree) {
297
+ return find(tree, (n) => typeof n.props?.onClick === "function"
298
+ && (n.children || []).some((c) => typeof c === "string" && c.includes("微信机器人通道")));
299
+ }
300
+
301
+ return {
302
+ requests, metas, injects,
303
+ counts() { return { ...counts }; },
304
+ /** 设置页栏目列表(模拟 shell 的 ctx.slots.entries("settings.section"),按 order 排序)。 */
305
+ sectionList() { return metas.filter((m) => m.name === "settings.section").slice().sort((a, b) => a.order - b.order); },
306
+ setStatus(body, status = 200) { statusReply = { status, body }; },
307
+ /** 登录态开关:false = 会话失效/未登录(/status 不再带 config.phone)。 */
308
+ setSignedIn(v) { signedIn = !!v; },
309
+ setStart(body, status = 200) { startReply = { status, body }; },
310
+ setPolls(list) { pollReplies = list.slice(); },
311
+ setVerify(body, status = 200) { verifyReply = { status, body }; },
312
+ setUnbind(body, status = 200) { unbindReply = { status, body }; },
313
+ /** 通道 API 应答注入(GET / PUT / POST test)。 */
314
+ setImBotsGet(body) { imBotsGetReply = body; },
315
+ setImBotsPut(body, status = 200) { imBotsPutReply = { status, body }; },
316
+ setImBotsTest(body, status = 200) { imBotsTestReply = { status, body }; },
317
+ imBotsCalls() { return imBotsCalls.slice(); },
318
+ /** 向 document 派发键盘事件(测试 Esc / Tab 的弹层契约)。 */
319
+ fireKey(key, init = {}) {
320
+ const ev = { key, keyCode: key === "Escape" ? 27 : 0, shiftKey: false, defaultPrevented: false, stopPropagation() {}, preventDefault() { this.defaultPrevented = true; }, ...init };
321
+ for (const fn of (doc._listeners.keydown || []).slice()) fn(ev);
322
+ return ev;
323
+ },
324
+ /** 主 tab 条里的「微信机器人通道」tab(未登录时**不存在** → undefined)。 */
325
+ weChatTabOf(tree) { return weChatTab(tree); },
326
+ render() {
327
+ if (unmounted) return null;
328
+ const root = { type: registered.get(ROOT_SECTION_ID), props: { close() {} }, children: [] };
329
+ let tree = renderNode(root, "root");
330
+ renders += 1;
331
+ return tree;
332
+ },
333
+ async settle(times = 3) {
334
+ for (let i = 0; i < times; i++) { this.render(); await flush(); await flush(); }
335
+ return this.render();
336
+ },
337
+ advance,
338
+ /** 卸载:跑掉所有组件 effect 的 cleanup(模拟组件从设置页消失)。 */
339
+ unmount() {
340
+ unmounted = true;
341
+ for (const sc of scopes.values()) {
342
+ for (const slot of sc.effects) if (slot && typeof slot.cleanup === "function") job(slot.cleanup);
343
+ }
344
+ scopes.clear();
345
+ }
346
+ };
347
+ }
348
+
349
+
350
+
351
+
352
+ /** 当前渲染树里的弹层对话框节点(.dru-ia-dialog)。 */
353
+ const dialogOf = (tree) => find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
354
+ /** 按 class 取节点集合。 */
355
+ function byClass(tree, cls) {
356
+ const out = [];
357
+ walk(tree, (n) => { if (String(n.props?.className || "").split(/\s+/).includes(cls)) out.push(n); });
358
+ return out;
359
+ }
360
+ /** 按 input 的 id 取输入框。 */
361
+ const inputById = (tree, id) => find(tree, (n) => n.type === "input" && n.props?.id === id);
362
+ /** 按按钮文字取真按钮。 */
363
+ const btnWith = (tree, text) => find(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes(text)));
364
+
365
+ const EMOJI_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}]/u;
366
+
367
+ // ───────────────────────── ① 主面板结构 ─────────────────────────
368
+
369
+ test("IA:主面板 = 状态头(连接 pill + 打开手机端)+ 2 列入口网格(4 张卡,每卡 ≤3 行)", async () => {
370
+ const plugin = loadPlugin();
371
+ const tree = await plugin.settle();
372
+
373
+ // 状态头:pill(圆点 + 文案)+ 主按钮
374
+ const head = find(tree, (n) => String(n.props?.className || "") === "dru-ia-head");
375
+ assert.ok(head, "必须有状态头(.dru-ia-head)");
376
+ const pill = find(head, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-pill"));
377
+ assert.ok(pill, "状态头里必须有连接状态 pill");
378
+ assert.equal(pill.props.role, "status", "pill 是状态播报(role=status)");
379
+ assert.ok(find(pill, (n) => String(n.props?.className || "").includes("dru-ia-pill-dot")), "pill 必须有圆点(不靠颜色单独表意)");
380
+ assert.ok(btnWith(head, "打开手机端"), "状态头右侧必须有主按钮「打开手机端」");
381
+
382
+ // 入口网格:2 列布局 + 4 张卡;每卡只有 标题 / 一行状态 / 操作
383
+ const grid = find(tree, (n) => String(n.props?.className || "") === "dru-ia-grid");
384
+ assert.ok(grid, "必须有入口网格(.dru-ia-grid)");
385
+ const cards = byClass(grid, "dru-ia-card");
386
+ assert.equal(cards.length, 4, `入口网格应有 4 张卡(手机访问/通道/设备/账号),实际 ${cards.length}`);
387
+ const titles = cards.map((c) => {
388
+ const t = find(c, (n) => String(n.props?.className || "").includes("dru-ia-card-title"));
389
+ return t ? (t.children || []).join("") : "";
390
+ });
391
+ assert.deepEqual(titles, ["手机访问", "通知与消息通道", "已授权设备", "账号"], `卡片标题应对应 IA,实际:${titles.join("|")}`);
392
+ for (const c of cards) {
393
+ const meta = byClass(c, "dru-ia-card-meta");
394
+ assert.equal(meta.length, 1, "每张卡只能有一行状态文案(避免文字堆砌)");
395
+ assert.equal(find(c, (n) => n.type === "button") ? 1 : 0, 1, "每张卡只有一个主按钮(主按钮每屏一个的原则)");
396
+ }
397
+
398
+ // 旧结构必须消失:顶部 tab 条
399
+ assert.equal(find(tree, (n) => String(n.props?.className || "") === "dru-tabs"), undefined,
400
+ "顶部 3 个 tab 必须已删除(业主批评原话:顶部 3 个 tab 页)");
401
+ });
402
+
403
+ test("IA:图标一律内联 SVG 16px currentColor(禁止 emoji 当图标)—— 入口网格与状态头无 emoji", async () => {
404
+ const plugin = loadPlugin();
405
+ const tree = await plugin.settle();
406
+ const iaRoot = find(tree, (n) => String(n.props?.className || "") === "dru-ia");
407
+ assert.ok(iaRoot, "主面板根节点应为 .dru-ia");
408
+
409
+ // ① 每个图标槽(.dru-ia-ico)里必须是 <svg>,不是文字(emoji 也是文字节点)
410
+ const icos = byClass(iaRoot, "dru-ia-ico");
411
+ assert.ok(icos.length >= 4, "入口网格每张卡都要有图标(+ 状态头主按钮图标)");
412
+ for (const ico of icos) {
413
+ const svg = find(ico, (n) => n.type === "svg");
414
+ assert.ok(svg, "图标必须是内联 <svg>(不得用 emoji 当图标)");
415
+ assert.equal(String(svg.props.width), "16", "图标尺寸 16px");
416
+ assert.equal(svg.props.stroke, "currentColor", "图标必须 currentColor(跟随宿主主题)");
417
+ assert.equal(svg.props["aria-hidden"], "true", "装饰性图标必须 aria-hidden(可读名来自文字)");
418
+ assert.ok(!(ico.children || []).some((c) => typeof c === "string" && EMOJI_RE.test(c)), "图标槽里不得出现 emoji 字符");
419
+ }
420
+ // ② **可见 IA**(状态头 / 入口网格 / 文字入口 / 弹层)的文案里不得出现 emoji 当图标。
421
+ // 折叠的「更多设置」里仍是既有卡片(含少量历史 emoji),它不属于这次重做的主界面范围。
422
+ const visibleParts = ["dru-ia-head", "dru-ia-grid", "dru-ia-links"]
423
+ .map((cls) => find(iaRoot, (n) => String(n.props?.className || "").split(/\s+/).includes(cls)))
424
+ .filter(Boolean);
425
+ assert.equal(visibleParts.length, 3, "状态头 / 入口网格 / 文字入口都必须在");
426
+ for (const part of visibleParts) {
427
+ const text = textOf(part);
428
+ assert.ok(!EMOJI_RE.test(text), "可见 IA 区域不得用 emoji 当图标:命中 " + (text.match(EMOJI_RE) || []).join(""));
429
+ }
430
+ });
431
+
432
+ // ───────────────────────── ② 登录不隐藏功能 ─────────────────────────
433
+
434
+ test("登录不隐藏功能:未登录时主面板照常显示、入口卡写「登录后可同步」,点账号进弹层登录", async () => {
435
+ const plugin = loadPlugin();
436
+ plugin.setSignedIn(false);
437
+ const tree = await plugin.settle();
438
+
439
+ const grid = find(tree, (n) => String(n.props?.className || "") === "dru-ia-grid");
440
+ assert.ok(grid, "未登录时入口网格必须照常显示(绝不隐藏整个功能)");
441
+ assert.equal(byClass(grid, "dru-ia-card").length, 4, "四张入口卡都应在");
442
+ const text = textOf(tree);
443
+ assert.ok(text.includes("登录后可同步"), "未登录时卡片文案应是「登录后可同步」引导,而不是消失");
444
+ assert.ok(text.includes("通知与消息通道"), "通道入口未登录也必须在(旧结构:登录后才出现微信通道)");
445
+
446
+ // 点「账号」卡 → 打开账号弹层(就地登录,不跳转)
447
+ assert.ok(openEntry(tree, "account"), "应能点开账号弹层");
448
+ const tree2 = await plugin.settle();
449
+ const dlg = dialogOf(tree2);
450
+ assert.ok(dlg, "账号弹层必须打开");
451
+ assert.ok(find(dlg, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-seg-btn")), "登录/注册应是弹层内的分段控件");
452
+ });
453
+
454
+ // ───────────────────────── ③ 弹层契约 ─────────────────────────
455
+
456
+ test("弹层契约:手机访问弹层 = role=dialog + aria-modal + 云端/自建分段;Esc 关闭后焦点回触发按钮", async () => {
457
+ const plugin = loadPlugin();
458
+ let tree = await plugin.settle();
459
+ assert.ok(openEntry(tree, "mobile"), "应能点开「手机访问」入口");
460
+ tree = await plugin.settle();
461
+
462
+ const dlg = dialogOf(tree);
463
+ assert.ok(dlg, "必须渲染 .dru-ia-dialog");
464
+ assert.equal(dlg.props.role, "dialog");
465
+ assert.equal(dlg.props["aria-modal"], "true");
466
+ assert.ok(dlg.props["aria-labelledby"], "必须有 aria-labelledby(标题可被读屏关联)");
467
+ const title = find(dlg, (n) => n.props?.id === dlg.props["aria-labelledby"]);
468
+ assert.ok(title && (title.children || []).join("").includes("手机访问"), "标题应为「手机访问」");
469
+ const closeBtn = find(dlg, (n) => n.type === "button" && n.props?.["aria-label"] === "关闭");
470
+ assert.ok(closeBtn, "弹层必须有 ✕ 关闭按钮(aria-label=关闭)");
471
+
472
+ // 分段控件:原来的顶部 3 tab 降级到这里(云端 / 自建)
473
+ const seg = byClass(dlg, "dru-ia-seg-btn");
474
+ assert.equal(seg.length, 2, "手机访问弹层里应是 2 段(云端服务 / 自建服务)");
475
+ assert.deepEqual(seg.map((s) => (s.children || []).join("")), ["云端服务", "自建服务"]);
476
+
477
+ // Esc 关闭(document 级 keydown 捕获)—— 关闭后弹层消失
478
+ plugin.fireKey("Escape");
479
+ tree = await plugin.settle();
480
+ assert.equal(dialogOf(tree), undefined, "Esc 必须关闭弹层");
481
+ });
482
+
483
+ test("弹层契约:窄屏语义 —— 表头/底部操作用 flex 固定,正文只在内部滚动(不做整页滚动)", () => {
484
+ const src = SOURCE;
485
+ assert.match(src, /\.dru-ia-dialog\{[^}]*display:flex;flex-direction:column/, "弹层内部用纵向 flex");
486
+ assert.match(src, /\.dru-ia-dialog-body\{[^}]*overflow-y:auto/, "只有正文区允许滚动");
487
+ assert.match(src, /\.dru-ia-dialog-foot\{[^}]*flex:none/, "底部操作固定(不随正文滚走)");
488
+ assert.match(src, /max-height:min\(78vh/, "弹层限高(1.5 屏内),不做整页滚动");
489
+ });
490
+
491
+ // ───────────────────────── ④ 通道弹层 + 企微配置 ─────────────────────────
492
+
493
+ test("通道弹层:平台列表(企微 / 微信扫码 / 飞书 / Webhook)→ 点企业微信进入配置表单", async () => {
494
+ const plugin = loadPlugin();
495
+ let tree = await plugin.settle();
496
+ assert.ok(openEntry(tree, "channel"), "应能点开「通知与消息通道」入口");
497
+ tree = await plugin.settle();
498
+ assert.ok(dialogOf(tree), "通道弹层必须打开");
499
+
500
+ const rows = byClass(tree, "dru-ia-row");
501
+ assert.ok(rows.length >= 4, "平台列表至少 4 行(企业微信 / 微信扫码 / 飞书 / Webhook)");
502
+ const rowText = rows.map((r) => textOf(r));
503
+ assert.ok(rowText.some((t) => t.includes("企业微信智能机器人")), "应有「企业微信智能机器人」");
504
+ assert.ok(rowText.some((t) => t.includes("微信机器人(扫码绑定)")), "应保留既有微信扫码通道入口");
505
+ assert.ok(rowText.some((t) => t.includes("飞书")), "应有「飞书」");
506
+ assert.ok(rowText.some((t) => t.includes("Webhook")), "应有「Webhook」");
507
+
508
+ assert.ok(clickChannelRow(tree, "企业微信智能机器人"), "应能点进企业微信配置");
509
+ tree = await plugin.settle();
510
+ assert.ok(textOf(tree).includes("企业微信智能机器人"), "应进入企业微信配置步骤");
511
+ assert.ok(inputById(tree, "dru-wecom-bot"), "表单必须有 Bot ID 输入框");
512
+ const secret = inputById(tree, "dru-wecom-secret");
513
+ assert.ok(secret, "表单必须有 Secret 输入框");
514
+ assert.equal(secret.props.type, "password", "★ Secret 默认必须是 password(不回显)");
515
+ assert.equal(secret.props.value, "", "★ Secret 输入框初值必须为空(服务端不回传 secret)");
516
+ const chat = inputById(tree, "dru-wecom-chat");
517
+ assert.ok(chat, "表单必须有「接收会话 ID(可选)」输入(契约字段 targetChatId)");
518
+ assert.ok(textOf(tree).includes("不知道填什么就留空"), "接收会话 ID 必须有 lead 指定的提示文案");
519
+ });
520
+
521
+ test("通道表单:保存失败 → 错误**就近**显示(aria-invalid + role=alert),不是只弹全局错误", async () => {
522
+ const plugin = loadPlugin();
523
+ // 「保存并连接」= POST /im-bots/test(服务端先保存再等桥连接,与契约一致)
524
+ plugin.setImBotsPut({ ok: false, error: "invalid_bot_id", message: "Bot ID 格式不对:应形如 aibxxxxxxxx" }, 400);
525
+ plugin.setImBotsTest({ ok: false, error: "invalid_bot_id", message: "Bot ID 格式不对:应形如 aibxxxxxxxx" }, 400);
526
+ let tree = await plugin.settle();
527
+ openEntry(tree, "channel");
528
+ tree = await plugin.settle();
529
+ clickChannelRow(tree, "企业微信智能机器人");
530
+ tree = await plugin.settle();
531
+
532
+ const bot = inputById(tree, "dru-wecom-bot");
533
+ bot.props.onChange({ target: { value: "not-a-bot" } });
534
+ tree = await plugin.settle();
535
+ const save = btnWith(tree, "保存并连接");
536
+ assert.ok(save, "必须有「保存并连接」主按钮");
537
+ save.props.onClick();
538
+ await flush(); await flush();
539
+ tree = await plugin.settle();
540
+
541
+ const bot2 = inputById(tree, "dru-wecom-bot");
542
+ assert.equal(bot2.props["aria-invalid"], "true", "★ 出错的字段要标 aria-invalid(就近)");
543
+ const alert = find(tree, (n) => n.props?.role === "alert" && (n.children || []).some((c) => typeof c === "string" && c.includes("Bot ID 格式不对")));
544
+ assert.ok(alert, "★ 错误文案必须出现在字段旁(role=alert 可被读屏播报)");
545
+ const sent = plugin.imBotsCalls().filter((c) => c.method === "POST").pop();
546
+ assert.ok(sent, "「保存并连接」必须真的发出 POST /dsh-remote/im-bots/test");
547
+ assert.equal(sent.body.wecom.botId, "not-a-bot");
548
+ assert.ok(!("secret" in sent.body.wecom), "空 Secret 不随请求提交(= 保留已保存的,服务端契约)");
549
+ assert.equal(sent.body.wecom.targetChatId, "", "targetChatId 留空 = 不设置(显式传空串)");
550
+ });
551
+
552
+ test("通道测试:连接失败 → 弹层内如实显示桥给的原因(connect_failed + lastError)", async () => {
553
+ const plugin = loadPlugin();
554
+ plugin.setImBotsGet({ ok: true, wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "" }, status: { connected: false, authenticated: false, lastError: "" } });
555
+ plugin.setImBotsTest({ ok: false, error: "connect_failed", lastError: "invalid credential (errcode 40001)", message: "连接失败:invalid credential (errcode 40001)" }, 502);
556
+ let tree = await plugin.settle();
557
+ openEntry(tree, "channel");
558
+ tree = await plugin.settle();
559
+ clickChannelRow(tree, "企业微信智能机器人");
560
+ tree = await plugin.settle();
561
+
562
+ const testBtn = btnWith(tree, "测试");
563
+ assert.ok(testBtn, "必须有「测试」按钮");
564
+ testBtn.props.onClick();
565
+ await flush(); await flush();
566
+ tree = await plugin.settle();
567
+ const text = textOf(tree);
568
+ assert.ok(text.includes("40001"), "★ 失败原因必须原样显示(用户才能拿去搜/找客服)");
569
+ assert.ok(find(tree, (n) => n.props?.role === "status" && (n.children || []).some((c) => typeof c === "string" && c.includes("40001"))), "状态行要 aria-live/status 播报");
570
+ });
571
+
572
+ // ───────────────────── ⑤ 会话可选绑定(task-25) ─────────────────────
573
+
574
+ const openWecom = async (plugin) => {
575
+ let tree = await plugin.settle();
576
+ assert.ok(openEntry(tree, "channel"), "应能打开通道弹层");
577
+ tree = await plugin.settle();
578
+ assert.ok(clickChannelRow(tree, "企业微信智能机器人"), "应能进入企业微信配置页");
579
+ return plugin.settle();
580
+ };
581
+
582
+ test("会话下拉:有列表 → 默认项 + 会话项(标题截断 24 字 + 短 id);展示当前实际转发目标", async () => {
583
+ const plugin = loadPlugin();
584
+ plugin.setImBotsGet({
585
+ ok: true,
586
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
587
+ status: {
588
+ connected: true, authenticated: true, lastError: "",
589
+ sessions: [
590
+ { id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 111 },
591
+ { id: "sess-2-xyz", title: "这是一个非常非常长的会话标题用于验证截断逻辑是否生效", updatedAt: 222 },
592
+ ],
593
+ activeSessionId: "sess-2-xyz", activeSessionTitle: "这是一个非常非常长的会话标题用于验证截断逻辑是否生效",
594
+ boundSessionId: "sess-1-abcdef123456",
595
+ },
596
+ });
597
+ const tree = await openWecom(plugin);
598
+ const sel = find(tree, (n) => n.type === "select" && n.props?.id === "dru-wecom-session");
599
+ assert.ok(sel, "配置页必须有「转接到哪个会话」下拉");
600
+ const opts = sel.children.find((c) => c && c.type === "option") ? null : null; // 占位,真实取法见下
601
+ const options = [];
602
+ walk(tree, (n) => { if (n.type === "option") options.push(n); });
603
+ assert.equal(options.length, 3, "选项 = 跟随默认 + 2 个会话,实际 " + options.length);
604
+ assert.ok((options[0].children || []).join("").includes("跟随当前活跃会话(默认)"), "第一项必须是「跟随当前活跃会话(默认)」");
605
+ const longOpt = (options[2].children || []).join("");
606
+ assert.ok(longOpt.includes("…"), "长标题必须截断(最多 24 字 + …):" + longOpt);
607
+ assert.ok(longOpt.includes("sess-2-x"), "选项要带短 id(标题可能重复)");
608
+ // 当前实际转发目标要一行说清(绑定会话优先)
609
+ assert.ok(textOf(tree).includes("绑定会话:修复登录问题"), "应展示「绑定会话:<title>」");
610
+ });
611
+
612
+ test("会话下拉:列表为空 → 说明「还没有会话,去创建一个任务后这里会出现」", async () => {
613
+ const plugin = loadPlugin();
614
+ plugin.setImBotsGet({
615
+ ok: true,
616
+ wecom: { enabled: false, botId: "", hasSecret: false, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
617
+ status: { connected: false, authenticated: false, lastError: "", sessions: [], activeSessionId: "", boundSessionId: "" },
618
+ });
619
+ const tree = await openWecom(plugin);
620
+ assert.ok(textOf(tree).includes("还没有会话,去创建一个任务后这里会出现"), "空列表必须有引导文案(不是空白下拉)");
621
+ });
622
+
623
+ test("会保存:选中某会话 → PUT 带 sessionId + sessionTitle;选回默认 → 两者清空", async () => {
624
+ const plugin = loadPlugin();
625
+ plugin.setImBotsGet({
626
+ ok: true,
627
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
628
+ status: {
629
+ connected: true, authenticated: true, lastError: "",
630
+ sessions: [{ id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 111 }],
631
+ activeSessionId: "sess-1-abcdef123456", activeSessionTitle: "修复登录问题", boundSessionId: "",
632
+ },
633
+ });
634
+ let tree = await openWecom(plugin);
635
+ let sel = find(tree, (n) => n.type === "select" && n.props?.id === "dru-wecom-session");
636
+ sel.props.onChange({ target: { value: "sess-1-abcdef123456" } });
637
+ tree = await plugin.settle();
638
+ assert.ok(textOf(tree).includes("绑定会话:跟随活跃(修复登录问题)"), "未保存前展示的仍是当前实际转发目标(跟随活跃)");
639
+
640
+ const saveOnly = btnWith(tree, "仅保存");
641
+ assert.ok(saveOnly, "必须有「仅保存」按钮");
642
+ saveOnly.props.onClick();
643
+ await flush(); await flush();
644
+ tree = await plugin.settle();
645
+ let sent = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
646
+ assert.ok(sent, "应发出 PUT /dsh-remote/im-bots");
647
+ assert.equal(sent.body.wecom.sessionId, "sess-1-abcdef123456", "★ 选中会话必须写 sessionId");
648
+ assert.equal(sent.body.wecom.sessionTitle, "修复登录问题", "★ 同时写 sessionTitle(面板回显用)");
649
+
650
+ // 选回默认 → 清空
651
+ sel = find(tree, (n) => n.type === "select" && n.props?.id === "dru-wecom-session");
652
+ sel.props.onChange({ target: { value: "" } });
653
+ tree = await plugin.settle();
654
+ btnWith(tree, "仅保存").props.onClick();
655
+ await flush(); await flush();
656
+ tree = await plugin.settle();
657
+ const calls = plugin.imBotsCalls().filter((c) => c.method === "PUT");
658
+ sent = calls[calls.length - 1];
659
+ assert.equal(sent.body.wecom.sessionId, "", "★ 选「跟随当前活跃会话」= 清空 sessionId");
660
+ assert.equal(sent.body.wecom.sessionTitle, "", "★ 清空时标题也必须清空(否则 UI 会显示错目标)");
661
+ });
662
+
663
+ // ───────────────────── ⑥ 飞书 / 钉钉:诚实的「即将支持」 ─────────────────────
664
+
665
+ test("平台列表:飞书 / 钉钉都标注「即将支持」,不放进假表单", async () => {
666
+ const plugin = loadPlugin();
667
+ let tree = await plugin.settle();
668
+ openEntry(tree, "channel");
669
+ 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("即将支持")), "企微行是已支持(主推)");
675
+ });
676
+
677
+ test("飞书/钉钉:点进去只有说明 + 「告诉我,优先支持」;**没有输入框、没有提交**,点优先支持走现有反馈", async () => {
678
+ 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
+ }
704
+ });
705
+
706
+ // ───────────────────── ⑦ 配置页收口:说明折叠 + 统一字段结构 ─────────────────────
707
+
708
+ test("配置页收口:说明默认折叠(「为什么要填这些」),四个字段统一 dru-ia-field 结构", async () => {
709
+ const plugin = loadPlugin();
710
+ const tree = await openWecom(plugin);
711
+ // 折叠:默认看不到服务地址字段
712
+ assert.equal(find(tree, (n) => n.props?.id === "dru-wecom-url"), undefined, "高级项默认折叠(不在主表单里)");
713
+ const disclose = find(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("为什么要填这些")));
714
+ assert.ok(disclose, "必须有「为什么要填这些」折叠按钮");
715
+ assert.equal(disclose.props["aria-expanded"], "false");
716
+ disclose.props.onClick();
717
+ const tree2 = await plugin.settle();
718
+ assert.ok(find(tree2, (n) => n.props?.id === "dru-wecom-url"), "展开后才出现高级字段");
719
+ // 四个字段(会话 / Bot ID / Secret / 接收会话 ID)都在 .dru-ia-field 里,label 可见
720
+ const fields = byClass(tree2, "dru-ia-field");
721
+ assert.ok(fields.length >= 4, "字段必须用统一 dru-ia-field 结构(实际 " + fields.length + ")");
722
+ for (const id of ["dru-wecom-session", "dru-wecom-bot", "dru-wecom-secret", "dru-wecom-chat"]) {
723
+ const ctl = find(tree2, (n) => (n.type === "input" || n.type === "select") && n.props?.id === id);
724
+ assert.ok(ctl, "缺字段控件: " + id);
725
+ const label = find(tree2, (n) => n.type === "label" && n.props?.htmlFor === id);
726
+ assert.ok(label, "每个字段必须有可见 label: " + id);
727
+ }
728
+ });
729
+
730
+ // ───────────────────── ⑧ 发送预览卡片(task-25 尾,桥侧已就绪) ─────────────────────
731
+
732
+ test("发送预览卡片:次要按钮在「测试」旁边;点击 PUT 带 previewCardAt 且不丢 botId/sessionId/targetChatId", async () => {
733
+ const plugin = loadPlugin();
734
+ plugin.setImBotsGet({
735
+ ok: true,
736
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "chat-9", sessionId: "", sessionTitle: "" },
737
+ status: {
738
+ connected: true, authenticated: true, lastError: "", boundChatId: "chat-9",
739
+ sessions: [{ id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 1 }],
740
+ activeSessionId: "sess-1-abcdef123456", activeSessionTitle: "修复登录问题", boundSessionId: "",
741
+ },
742
+ });
743
+ let tree = await openWecom(plugin);
744
+ const previewBtn = btnWith(tree, "发送预览卡片");
745
+ assert.ok(previewBtn, "配置页必须有次要按钮「发送预览卡片」");
746
+ // 次要按钮:不是 primary(不抢主操作)、与「测试」同组摆在底部
747
+ assert.ok(!String(previewBtn.props.className || "").includes("is-primary"), "「发送预览卡片」不能是主按钮");
748
+ assert.ok(btnWith(tree, "测试"), "「测试」按钮仍在(两者并列)");
749
+
750
+ // 先选一个会话,验证预览请求会带上会话绑定
751
+ const sel = find(tree, (n) => n.type === "select" && n.props?.id === "dru-wecom-session");
752
+ sel.props.onChange({ target: { value: "sess-1-abcdef123456" } });
753
+ tree = await plugin.settle();
754
+
755
+ const before = Date.now();
756
+ btnWith(tree, "发送预览卡片").props.onClick();
757
+ await flush(); await flush();
758
+ tree = await plugin.settle();
759
+
760
+ const sent = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
761
+ assert.ok(sent, "★ 点击必须 PUT /dsh-remote/im-bots(桥监听 previewCardAt)");
762
+ assert.equal(typeof sent.body.wecom.previewCardAt, "number", "previewCardAt 必须是数字(ms 时间戳)");
763
+ assert.ok(sent.body.wecom.previewCardAt >= before, "previewCardAt 应是本次点击的时间戳(值变大才会触发)");
764
+ assert.equal(sent.body.wecom.botId, "aibdemo1234", "★ 不丢 Bot ID(覆盖写不许冲掉)");
765
+ assert.equal(sent.body.wecom.targetChatId, "chat-9", "★ 不丢接收会话 ID");
766
+ assert.equal(sent.body.wecom.sessionId, "sess-1-abcdef123456", "★ 不丢会话绑定");
767
+ assert.equal(sent.body.wecom.sessionTitle, "修复登录问题");
768
+ assert.ok(!("secret" in sent.body.wecom), "空 Secret 不提交(= 保留已保存的)");
769
+ // 成功状态行
770
+ assert.ok(textOf(tree).includes("已发送预览卡片,去企业微信看看"), "发送成功要有状态行");
771
+ });
772
+
773
+ test("发送预览卡片:没有接收方(boundChatId 与 targetChatId 都空)→ 行内提示,但仍允许点击", async () => {
774
+ const plugin = loadPlugin();
775
+ plugin.setImBotsGet({
776
+ ok: true,
777
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
778
+ status: { connected: true, authenticated: true, lastError: "", boundChatId: "", sessions: [], activeSessionId: "", boundSessionId: "" },
779
+ });
780
+ const tree = await openWecom(plugin);
781
+ assert.ok(textOf(tree).includes("先在企微里给机器人发一句话完成绑定"), "未绑定给机器人时要有行内提示");
782
+ const btn = btnWith(tree, "发送预览卡片");
783
+ assert.ok(btn, "按钮必须存在");
784
+ assert.notEqual(btn.props.disabled, true, "★ 仍允许点击(桥会记日志跳过,不拦用户)");
785
+ });
786
+
787
+ test("发送预览卡片:已绑定(boundChatId 非空)→ 不显示未绑定提示;有 lastPreviewCardAt → 显示「上次预览:HH:MM」", async () => {
788
+ const plugin = loadPlugin();
789
+ const ts = new Date(2026, 0, 2, 9, 7, 0).getTime();
790
+ plugin.setImBotsGet({
791
+ ok: true,
792
+ wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
793
+ status: { connected: true, authenticated: true, lastError: "", boundChatId: "chat-9", lastPreviewCardAt: ts, sessions: [], activeSessionId: "", boundSessionId: "" },
794
+ });
795
+ const tree = await openWecom(plugin);
796
+ assert.ok(!textOf(tree).includes("先在企微里给机器人发一句话完成绑定"), "已绑定就不该再提示未绑定");
797
+ assert.ok(textOf(tree).includes("上次预览:09:07"), "应显示上次预览时间(HH:MM)");
798
+ });