@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +148 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  11. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  12. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  13. package/docs/telemetry.md +31 -2
  14. package/dsh-setup.mjs +134 -40
  15. package/package.json +6 -5
  16. package/packages/dsh-remote-web/lib/client.js +799 -65
  17. package/packages/dsh-remote-web/lib/index.js +460 -10
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  20. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  21. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  22. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  23. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  24. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  25. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  26. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  28. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  29. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
@@ -0,0 +1,866 @@
1
+ // 「🤖 微信机器人通道」面板回归(浏览器半 client.js,**远程访问面板里的第三个 tab**)。
2
+ //
3
+ // 【0.6.11 变更】微信面板不再是独立的 settings.section 栏目(第二个栏目已删),而是「📱 远程访问」
4
+ // 面板主 tab 条里的第三个 tab。所以本文件有两处关键调整:
5
+ // ① 栏目注册断言:settings.section 里**只有** dsh-remote 一个(不再有 dsh-remote-wechat);
6
+ // ② harness 改为渲染 dsh-remote 根组件 → 递归展开函数子组件(WeChatBotSection)→ 且首次渲染后
7
+ // 自动点一下「微信机器人通道」tab(真实用户路径)再断言。覆盖的仍是同一个组件的行为。
8
+ //
9
+ // 覆盖(对应 docs/wechat-bot-channel.md §8 安全 / §9 两态模型 / §10 绑定流程):
10
+ // ① 入口:不再有独立栏目;tab 条(home 与 wechat 两个视图共用)含「微信机器人通道」+ 绿色图标;
11
+ // ② 主题不变量:--dru-* 令牌只在既有三个选择器上声明,新增样式只引用令牌、不落 :root/body;
12
+ // ③ 二维码:直接把 bridge 下发的 qrcode_svg(data: URL)喂给 <img src>,零 QR 库、零外部资源;
13
+ // ④ 配对码:need_verify_code 为真时**必须**出现数字输入框并能提交(这一步真机上很容易漏);
14
+ // ⑤ 轮询:确认/失败/取消/卸载即停;expired 给出「刷新二维码」;未知状态按可重试继续;
15
+ // ⑥ 解绑必须二次确认(解绑=所有微信通知立刻停止,不能一点就走);
16
+ // ⑦ 控制面缺失(bridge 没跑/版本旧)要说清楚怎么办,**绝不**留一个永久 spinner;
17
+ // ⑧ 无障碍:真 <button>、aria-live 播报、不靠颜色单独表意。
18
+ //
19
+ // 【0.6.12 新增】产品引导(业主口径:「动效引导」+ 微信通道要求已注册并登录):
20
+ // ⑨ 首屏导览:未绑定时内容体最上面三行讲清用途(推送 / 回数字拍板 / 微信里交代任务),
21
+ // DOM 顺序在「连接微信机器人」之前,且是小字紧凑排版(不把按钮挤出首屏);
22
+ // ⑩ 登录门:未登录**不渲染**微信 tab,登录后当场出现(不刷新页面);会话在 tab 开着时失效
23
+ // → 不渲染微信内容、view 回落 home,绝不留空面板;
24
+ // ⑪ 动效引导:未绑定时连接按钮带 .dru-wx-attn(慢呼吸 + 装饰点 aria-hidden),
25
+ // 样式表有 prefers-reduced-motion:reduce 关闭规则,绑定后动效全部消失。
26
+ // ⚠️ harness 默认「已登录」(/status 补 config.phone);未登录场景用 setSignedIn(false) ——
27
+ // 见 SESSION_CONFIG 上的注释。
28
+ import assert from "node:assert/strict";
29
+ import { readFileSync } from "node:fs";
30
+ import test from "node:test";
31
+ import vm from "node:vm";
32
+
33
+ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
34
+
35
+ const QR_DATA_URL = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=";
36
+ /** 面板**唯一**的 settings.section 栏目 id(0.6.11 起微信面板是它的子组件,不再是第二个栏目)。 */
37
+ const ROOT_SECTION_ID = "dsh-remote";
38
+ /**
39
+ * 【登录门 · 0.6.12】微信机器人通道要求**已注册并登录**:未登录时这个 tab 根本不存在。
40
+ * harness 默认给 /dsh-remote/status 的应答补一个 config.phone(= client.js 判 loggedIn 的依据),
41
+ * 绝大多数用例因此仍在「已登录」这条正常路径上跑(否则 tab 不渲染,auto-enter 也无处可点);
42
+ * 需要验证未登录行为的用例用 setSignedIn(false) 显式登出。
43
+ */
44
+ const SESSION_CONFIG = { phone: "13800138000" };
45
+
46
+ const NOT_BOUND = {
47
+ ok: true, disabled: false, bound: false, bot_id: "", bound_at: 0, connected_at: 0,
48
+ last_push_ok_at: 0, last_error: "", cooldown_ms: 0, pending_replies: 0,
49
+ binding: { active: false }, channel_running: false, events_running: false
50
+ };
51
+ const BOUND = {
52
+ ...NOT_BOUND, bound: true, bot_id: "bot_7788", bound_at: 1_700_000_000_000,
53
+ connected_at: 1_700_000_600_000, last_push_ok_at: 1_700_000_700_000,
54
+ pending_replies: 2, channel_running: true, events_running: true
55
+ };
56
+
57
+ function walk(node, visit) {
58
+ if (node == null) return;
59
+ if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
60
+ if (typeof node !== "object") return;
61
+ visit(node);
62
+ for (const child of node.children || []) walk(child, visit);
63
+ }
64
+ function find(tree, predicate) {
65
+ let match;
66
+ walk(tree, (node) => { if (!match && predicate(node)) match = node; });
67
+ return match;
68
+ }
69
+ function textHas(tree, substr) {
70
+ return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
71
+ }
72
+ /** 按文案取按钮(真 <button>)。 */
73
+ function buttonWithText(tree, substr) {
74
+ return find(tree, (n) => n.type === "button" && typeof n.props?.onClick === "function"
75
+ && (n.children || []).some((c) => typeof c === "string" && c.includes(substr)));
76
+ }
77
+ /** 该节点的 class 列表里是否有某一个类(精确匹配,避免 .dru-wx-attn 误配 .dru-wx-attn-dot)。 */
78
+ function hasClass(node, cls) {
79
+ return String(node?.props?.className || "").split(/\s+/).includes(cls);
80
+ }
81
+ /** 节点在树里的**前序位置**(对本 harness 的树形即 DOM 顺序);找不到返回 -1。 */
82
+ function domIndex(tree, predicate) {
83
+ let i = 0;
84
+ let found = -1;
85
+ walk(tree, (n) => { if (found < 0 && predicate(n)) found = i; i += 1; });
86
+ return found;
87
+ }
88
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
89
+
90
+ /**
91
+ * 迷你 React(真实 useEffect:依赖比较 + 提交后执行 + cleanup)+ 可控假定时器 +
92
+ * 可变的面板路由应答。骨架与 connect-ui.test.mjs 一致,便于两个用例互为对照。
93
+ */
94
+ function loadPlugin(opts = {}) {
95
+ let moduleFactory;
96
+ const registered = new Map();
97
+ const metas = [];
98
+ const injects = new Map();
99
+ const requests = [];
100
+ let unmounted = false;
101
+
102
+ // ── 迷你 React:真实 useState/useEffect + 递归展开**函数子组件** ──
103
+ // 0.6.11 起 WeChatBotSection 是 RemoteControlSection 的子组件(渲染路径里还有既有的
104
+ // SelfManageCard),所以 hook 槽位必须**按组件实例**隔离:每个实例按「树中位置路径」持有
105
+ // 自己的 state/effects(近似 React 的位置化协调)。
106
+ // ⚠️ 曾经用「全局递增计数器」实现:父组件 57 个 useState 之后,home 视图的 SelfManageCard
107
+ // 与 wechat 视图的 WeChatBotSection **共用同一批下标** → 子组件读到别人的 state(串号),
108
+ // 表现是切回微信 tab 后凭空显示「读不到本机微信通道」。按路径分作用域即根治。
109
+ const scopes = new Map(); // key -> { hook, effectCursor, state, effects, pending }
110
+ const scopeStack = [];
111
+ const currentScope = () => scopeStack[scopeStack.length - 1];
112
+
113
+ let clock = 0;
114
+ let timerSeq = 1;
115
+ const timers = new Map();
116
+ const job = (fn) => { try { fn(); } catch (e) { /* 内部错误由断言暴露 */ } };
117
+ const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
118
+ 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; };
119
+ const fakeClear = (id) => { timers.delete(id); };
120
+ async function advance(ms) {
121
+ const target = clock + ms;
122
+ for (let guard = 0; guard < 2000; guard++) {
123
+ let due = null;
124
+ for (const [id, t] of timers) if (t.at <= target && (!due || t.at < due[1].at)) due = [id, t];
125
+ if (!due) break;
126
+ const [id, t] = due;
127
+ clock = t.at;
128
+ if (t.every) t.at = clock + t.every; else timers.delete(id);
129
+ job(t.fn);
130
+ await flush();
131
+ }
132
+ clock = target;
133
+ await flush(); await flush();
134
+ }
135
+
136
+ const react = {
137
+ createElement(type, props, ...children) {
138
+ // 与真实 React 一致:**展平嵌套数组子节点**。`h("div", p, [a, b])` 这种写法在 client.js 里很常见,
139
+ // 不展平的话 textHas(只看直接字符串子节点)就看不到数组里的文案 —— 曾因此让「冷却提示」断言假红
140
+ //(产品其实渲染了,是脚手架看不见)。React 的语义就是展平,所以这里对齐它而不是改产品文案。
141
+ return { type, props: props || {}, children: children.flat(Infinity) };
142
+ },
143
+ useState(initial) {
144
+ const sc = currentScope();
145
+ const index = sc.hook++;
146
+ if (!(index in sc.state)) sc.state[index] = typeof initial === "function" ? initial() : initial;
147
+ return [sc.state[index], (value) => { sc.state[index] = typeof value === "function" ? value(sc.state[index]) : value; }];
148
+ },
149
+ useEffect(fn, deps) {
150
+ const sc = currentScope();
151
+ const index = sc.effectCursor++;
152
+ const prev = sc.effects[index];
153
+ const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
154
+ if (changed) sc.pending.push({ index, fn, deps: deps ? [...deps] : deps });
155
+ },
156
+ useCallback(fn) { return fn; },
157
+ useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); }
158
+ };
159
+
160
+ const response = (status, body) => Promise.resolve({
161
+ ok: status >= 200 && status < 300,
162
+ status,
163
+ text: async () => (typeof body === "string" ? body : JSON.stringify(body))
164
+ });
165
+
166
+ let statusReply = { status: opts.statusCode || 200, body: opts.status || NOT_BOUND };
167
+ // 登录态(会话)与 /wechat/status/**面板级** /status 的应答相互独立:
168
+ // · 面板级 GET /dsh-remote/status 决定 client.js 的 loggedIn(= st.config.phone / hasLocalKey),
169
+ // 也就是微信 tab 在不在;
170
+ // · signedIn=false 时连 config.phone 一起抹掉/不补 → loggedIn 为假 → 登录门生效。
171
+ let signedIn = opts.signedIn !== false;
172
+ let panelReply = { status: 200, body: opts.panelStatus || { ok: true, service: { running: false } } };
173
+ const withSession = (body) => {
174
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
175
+ if (signedIn) return body.config ? body : { ...body, config: { ...SESSION_CONFIG } };
176
+ const { config, ...rest } = body;
177
+ return config ? { ...rest, config: { ...config, phone: "", hasLocalKey: false } } : body;
178
+ };
179
+ let startReply = {
180
+ status: 200,
181
+ body: { ok: true, qrcode_svg: QR_DATA_URL, qrcode_url: "https://liteapp.weixin.qq.com/q/7GiQu1", message: "请用手机微信扫描二维码完成绑定。" }
182
+ };
183
+ let pollReplies = (opts.polls || [{ status: 200, body: { ok: true, state: "wait", bound: false } }]).slice();
184
+ let verifyReply = { status: 200, body: { ok: true } };
185
+ let cancelReply = { status: 200, body: { ok: true, cancelled: true } };
186
+ let unbindReply = { status: 200, body: { ok: true, notify_error: "" } };
187
+ const counts = { status: 0, start: 0, poll: 0, verify: 0, cancel: 0, unbind: 0 };
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
+ addEventListener() {}, removeEventListener() {}
210
+ };
211
+ class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
212
+ const localStorage = {
213
+ _s: new Map(),
214
+ getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
215
+ setItem(k, v) { this._s.set(k, String(v)); },
216
+ removeItem(k) { this._s.delete(k); }
217
+ };
218
+
219
+ const sandbox = {
220
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
221
+ document: doc,
222
+ localStorage,
223
+ MutationObserver: MutationObserverMock,
224
+ navigator: { clipboard: { writeText: async () => {} } },
225
+ setTimeout: fakeSetTimeout,
226
+ clearTimeout: fakeClear,
227
+ setInterval: fakeSetInterval,
228
+ clearInterval: fakeClear,
229
+ fetch(path, options = {}) {
230
+ requests.push({ path, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null });
231
+ if (path === "/dsh-remote/status") return response(panelReply.status, withSession(panelReply.body));
232
+ if (path === "/dsh-remote/wechat/status") { counts.status++; return response(statusReply.status, withSession(statusReply.body)); }
233
+ if (path === "/dsh-remote/wechat/bind/start") { counts.start++; return response(startReply.status, startReply.body); }
234
+ if (path === "/dsh-remote/wechat/bind/poll") { counts.poll++; const r = nextPoll(); return response(r.status, r.body); }
235
+ if (path === "/dsh-remote/wechat/bind/verify") { counts.verify++; return response(verifyReply.status, verifyReply.body); }
236
+ if (path === "/dsh-remote/wechat/bind/cancel") { counts.cancel++; return response(cancelReply.status, cancelReply.body); }
237
+ if (path === "/dsh-remote/wechat/unbind") { counts.unbind++; return response(unbindReply.status, unbindReply.body); }
238
+ return response(200, { ok: true });
239
+ },
240
+ Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console
241
+ };
242
+
243
+ vm.runInNewContext(SOURCE, sandbox);
244
+ const plugin = moduleFactory((name) => {
245
+ assert.equal(name, "react");
246
+ return react;
247
+ });
248
+ plugin.apply({
249
+ slots: {
250
+ inject(name, cb) { injects.set(name, cb); cb(); },
251
+ register(meta, component) { metas.push(meta); registered.set(meta.id, component); return () => {}; }
252
+ }
253
+ });
254
+
255
+ let entered = false;
256
+ let renders = 0;
257
+ /** 渲染一棵树:函数组件递归展开(各自独立作用域 + 提交后跑 effects),普通节点保留并递归子节点。 */
258
+ function renderNode(node, path) {
259
+ if (node == null) return node;
260
+ if (Array.isArray(node)) return node.map((n, i) => renderNode(n, path + "/" + i));
261
+ if (typeof node !== "object") return node;
262
+ if (typeof node.type === "function") {
263
+ const key = path + ":" + (node.type.name || "anon");
264
+ const sc = scopes.get(key) || { hook: 0, effectCursor: 0, state: [], effects: [], pending: [] };
265
+ sc.hook = 0; sc.effectCursor = 0; sc.pending = [];
266
+ scopes.set(key, sc);
267
+ scopeStack.push(sc);
268
+ let out;
269
+ try { out = node.type(node.props || {}); } finally { scopeStack.pop(); }
270
+ const expanded = renderNode(out, key);
271
+ const todos = sc.pending; sc.pending = [];
272
+ for (const t of todos) {
273
+ const prev = sc.effects[t.index];
274
+ if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
275
+ const cleanup = t.fn();
276
+ sc.effects[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
277
+ }
278
+ return expanded;
279
+ }
280
+ return { ...node, children: (node.children || []).map((c, i) => renderNode(c, path + "/" + i)) };
281
+ }
282
+ /** 主 tab 条里的「微信机器人通道」tab(点它 = 真实用户进入微信面板的路径)。 */
283
+ function weChatTab(tree) {
284
+ return find(tree, (n) => typeof n.props?.onClick === "function"
285
+ && (n.children || []).some((c) => typeof c === "string" && c.includes("微信机器人通道")));
286
+ }
287
+
288
+ return {
289
+ requests, metas, injects,
290
+ counts() { return { ...counts }; },
291
+ /** 设置页栏目列表(模拟 shell 的 ctx.slots.entries("settings.section"),按 order 排序)。 */
292
+ sectionList() { return metas.filter((m) => m.name === "settings.section").slice().sort((a, b) => a.order - b.order); },
293
+ setStatus(body, status = 200) { statusReply = { status, body }; },
294
+ /** 登录态开关:false = 会话失效/未登录(/status 不再带 config.phone)。 */
295
+ setSignedIn(v) { signedIn = !!v; },
296
+ setStart(body, status = 200) { startReply = { status, body }; },
297
+ setPolls(list) { pollReplies = list.slice(); },
298
+ setVerify(body, status = 200) { verifyReply = { status, body }; },
299
+ setUnbind(body, status = 200) { unbindReply = { status, body }; },
300
+ /** 主 tab 条里的「微信机器人通道」tab(未登录时**不存在** → undefined)。 */
301
+ weChatTabOf(tree) { return weChatTab(tree); },
302
+ render() {
303
+ if (unmounted) return null;
304
+ const root = { type: registered.get(ROOT_SECTION_ID), props: { close() {} }, children: [] };
305
+ let tree = renderNode(root, "root");
306
+ renders += 1;
307
+ if (!entered) {
308
+ // 真实用户路径:先进 📱 远程访问(home),再点第三个 tab(微信面板就在面板内,不是独立栏目)。
309
+ // ⚠️ 登录态来自 /dsh-remote/status,**第一次渲染时 st 还是 null**(= 未登录)→ 自然没有 tab。
310
+ // 所以这里不是「第一帧就必须有点击目标」,而是「状态读到之后必须能用」:先等一帧。
311
+ const tab = weChatTab(tree);
312
+ if (tab) {
313
+ entered = true;
314
+ tab.props.onClick();
315
+ tree = renderNode(root, "root");
316
+ } else if (!signedIn) {
317
+ entered = true; // 未登录:本来就不该有微信 tab(见登录门用例)
318
+ } else if (renders > 1) {
319
+ // 登录门:**已登录**且状态已读到之后,这个 tab 必须在(不变量不放松)。
320
+ assert.fail("已登录时远程访问面板的主 tab 条里必须有「微信机器人通道」tab");
321
+ }
322
+ }
323
+ return tree;
324
+ },
325
+ async settle(times = 3) {
326
+ for (let i = 0; i < times; i++) { this.render(); await flush(); await flush(); }
327
+ return this.render();
328
+ },
329
+ advance,
330
+ /** 卸载:跑掉所有组件 effect 的 cleanup(模拟组件从设置页消失)。 */
331
+ unmount() {
332
+ unmounted = true;
333
+ for (const sc of scopes.values()) {
334
+ for (const slot of sc.effects) if (slot && typeof slot.cleanup === "function") job(slot.cleanup);
335
+ }
336
+ scopes.clear();
337
+ }
338
+ };
339
+ }
340
+
341
+ /** 点「连接微信机器人」→ 拿到二维码的绑定视图。 */
342
+ async function enterBinding(plugin) {
343
+ let tree = await plugin.settle();
344
+ const btn = buttonWithText(tree, "连接微信机器人");
345
+ assert.ok(btn, "未绑定时必须有「连接微信机器人」按钮");
346
+ btn.props.onClick();
347
+ tree = await plugin.settle();
348
+ return tree;
349
+ }
350
+
351
+ // ───────────────────────────────────────────────────────────────────────────
352
+
353
+ test("入口:settings.section 里**只有**「📱 远程访问」一个栏目,不再有 dsh-remote-wechat", () => {
354
+ const plugin = loadPlugin();
355
+ const list = plugin.sectionList();
356
+ const ids = list.map((m) => m.id);
357
+ // 【0.6.11 改断言】旧断言 deepEqual(ids, ["dsh-remote", "dsh-remote-wechat"]) + order 31 + label 含
358
+ // 「🤖 微信机器人」编码的是**旧行为**(微信面板作为第二个设置栏目)。业主口径:「不要给它单独弄
359
+ // 一个菜单,直接放到『远程控制』面板里面」→ 第二个栏目已删,这里改为断言「只有一个栏目」。
360
+ assert.deepEqual(ids, [ROOT_SECTION_ID], `设置页只应有一个栏目,实际:${ids.join(",")}`);
361
+ assert.equal(list.length, 1, "settings.section 注册次数必须恰好是 1(不多不少)");
362
+ const only = list[0];
363
+ assert.equal(only.name, "settings.section");
364
+ assert.equal(only.order, 30);
365
+ assert.equal(typeof only.label, "function");
366
+ const label = only.label();
367
+ assert.ok(String(label).includes("远程访问"), `栏目名应含「远程访问」,实际:${label}`);
368
+ assert.ok(String(label).includes("📱"), "栏目名应带 📱 图标");
369
+ // 「不再有第二个栏目」在源码层面也必须成立(防止有人把 d2 加回来)
370
+ assert.doesNotMatch(SOURCE, /dsh-remote-wechat/, "不得再把微信面板注册成第二个 settings.section 栏目");
371
+ assert.doesNotMatch(SOURCE, /id: "dsh-remote-wechat"/);
372
+ // disposer 仍然只返回 d1 那一个(register 一次 → 只 dispose 一次)
373
+ const dispose = plugin.injects.get("settings.section")();
374
+ assert.equal(typeof dispose, "function", "setting.section 的 inject 回调必须返回 disposer");
375
+ });
376
+
377
+ test("tab 条(home 与 wechat 共用):第三个 tab 是「微信机器人通道」+ 微信绿泡泡图标;能进也能出", async () => {
378
+ const plugin = loadPlugin();
379
+ let tree = await plugin.settle(); // 首次渲染自动点在微信 tab 上
380
+ // 进入微信视图后 tab 条**仍在**(否则用户被关在微信页里出不来)
381
+ let strip = find(tree, (n) => n.props?.className === "dru-tabs" && n.props?.role === "tablist");
382
+ assert.ok(strip, "微信视图里也必须渲染主 tab 条(否则无法返回)");
383
+
384
+ // 三个 tab:云端服务 / 自建服务 / 微信机器人通道
385
+ const tabs = [];
386
+ walk(tree, (n) => { if (n.props?.role === "tab") tabs.push(n); });
387
+ const labels = tabs.map((t) => (t.children || []).filter((c) => typeof c === "string").join(""));
388
+ assert.deepEqual(labels, ["☁️ 云端服务", "🖥 自建服务", "微信机器人通道"], `主 tab 条应有三个 tab,实际:${labels.join("|")}`);
389
+
390
+ // 绿泡泡图标:装饰性 span.dru-wx-ico + aria-hidden(可访问名字来自文字标签)
391
+ const ico = find(tree, (n) => n.props?.className === "dru-wx-ico");
392
+ assert.ok(ico, "微信 tab 上必须有 .dru-wx-ico 绿泡泡图标");
393
+ assert.equal(ico.props["aria-hidden"], "true", "图标是装饰性的 → aria-hidden");
394
+ assert.equal(ico.type, "span");
395
+ assert.ok((ico.children || []).some((c) => typeof c === "string" && c.includes("💬")), "图标里应有 💬 气泡字符");
396
+
397
+ // 选中态:微信 tab active / aria-selected=true,两个模式 tab 都不 active(aria 与视觉同源)
398
+ const wxTab = tabs[2];
399
+ assert.match(wxTab.props.className, /dru-tab active/, "在微信视图里微信 tab 必须是 active");
400
+ assert.equal(wxTab.props["aria-selected"], "true");
401
+ assert.equal(tabs[0].props["aria-selected"], "false", "模式 tab 在微信视图里不得选中");
402
+ assert.equal(tabs[1].props["aria-selected"], "false");
403
+ assert.equal(tabs[0].props.tabIndex, 0, "tab 必须可聚焦(键盘可操作)");
404
+ assert.equal(wxTab.props["aria-selected"], "true", "aria-selected 与 .active 同源");
405
+
406
+ // 微信视图里没有第二个 .dru-settings-section 套娃(内嵌态省掉外壳)
407
+ assert.equal(tree.props.className, "dru-settings-section", "面板根仍是唯一的 .dru-settings-section");
408
+ assert.equal(find(tree, (n) => n.props?.className === "dru-settings-section" && n !== tree), undefined,
409
+ "内嵌态不得再嵌一层 .dru-settings-section");
410
+
411
+ // 出得去:点「🖥 自建服务」→ 回到 home(模式 tab 点击必须把 view 复位成 home)
412
+ tabs[1].props.onClick();
413
+ tree = plugin.render();
414
+ assert.equal(find(tree, (n) => n.props?.className === "dru-wx-embed"), undefined,
415
+ "点模式 tab 后必须离开微信视图(微信内容不再渲染)");
416
+ const modeTab = find(tree, (n) => n.props?.role === "tab" && (n.children || []).includes("🖥 自建服务"));
417
+ assert.equal(modeTab.props["aria-selected"], "true", "点自建服务后它应成为选中态(且 view 回到 home)");
418
+ assert.equal(find(tree, (n) => n.props?.role === "tab" && (n.children || []).some((c) => typeof c === "string" && c.includes("微信机器人通道"))).props["aria-selected"], "false",
419
+ "离开微信视图后微信 tab 不得再是选中态");
420
+ // 进得去:再点回微信 tab
421
+ const back = find(tree, (n) => n.props?.role === "tab" && (n.children || []).some((c) => typeof c === "string" && c.includes("微信机器人通道")));
422
+ back.props.onClick();
423
+ const again = plugin.render();
424
+ assert.ok(textHas(again, "未绑定") || textHas(again, "已绑定"), "再点回来仍是微信机器人内容");
425
+ assert.equal(find(again, (n) => n.props?.className === "dru-wx-ico") ? true : false, true, "tab 条(含绿泡泡)仍在");
426
+ });
427
+
428
+ test("绿泡泡与零外部资源(源码级):.dru-wx-ico 是微信品牌绿、不新增任何外部资源/依赖", () => {
429
+ // 图标颜色:微信品牌绿 #07c160(业主口径:「增加上微信的绿泡泡小图标」)
430
+ assert.match(SOURCE, /\.dru-wx-ico\{[^}]*color:#07c160/, "绿泡泡必须是微信品牌绿 #07c160");
431
+ // 装饰性:节点上 aria-hidden(可读名字来自同 tab 的文字标签,见上一条用例)
432
+ assert.match(SOURCE, /className: "dru-wx-ico", "aria-hidden": "true"/, "图标节点必须 aria-hidden");
433
+ assert.match(SOURCE, /"微信机器人通道"/, "tab 必须有真实文字标签(不靠颜色/图标单独表意)");
434
+ // 图标 = 一个 emoji 字符 + 一条 CSS 规则,不加载任何外部资源(字体/图片/脚本/样式)
435
+ assert.doesNotMatch(SOURCE, /@import/, "不得 @import 外部样式");
436
+ assert.doesNotMatch(SOURCE, /url\(\s*["']?https?:/, "CSS 里不得引用远程 url(...)");
437
+ assert.doesNotMatch(SOURCE, /createElement\("link"\)|createElement\('link'\)/, "不得注入外部 <link>");
438
+ assert.doesNotMatch(SOURCE, /createElement\("script"\)|createElement\('script'\)/, "不得注入外部 <script>");
439
+ assert.doesNotMatch(SOURCE, /qrcodejs|qrcode-generator|davidshimjs|jsqr|nayuki|qrcode\.min\.js/i, "不得引入 QR 库");
440
+ // 依赖侧同样零新增:dsh-remote-web 没有(也不得有)任何运行时依赖
441
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
442
+ assert.deepEqual(pkg.dependencies || {}, {}, "dsh-remote-web 不得有运行时依赖(零依赖不变量)");
443
+ });
444
+
445
+ test("主题不变量:根节点带 .dru-settings-section + data-dru-theme;新增样式只引用令牌、不落 :root/body", async () => {
446
+ const plugin = loadPlugin();
447
+ const tree = await plugin.settle();
448
+ // 本组件(WeChatBotSection)现在是 embedded 的**子组件**,所以「根节点」=面板根
449
+ // RemoteControlSection(唯一那个 .dru-settings-section)。令牌照旧挂在这一层,内嵌组件继承。
450
+ assert.equal(tree.props.className, "dru-settings-section", "根节点必须带 .dru-settings-section(--dru-* 令牌挂在这里)");
451
+ assert.equal(tree.props["data-dru-theme"], "light", "根节点必须带 data-dru-theme(JS 解析出的宿主主题)");
452
+ assert.equal(tree.props.role, "region");
453
+ // 内嵌态:微信内容体自己不再带 .dru-settings-section(否则就是 settings-section 套娃),
454
+ // 但**仍然**带 data-dru-theme(组件根的不变量照旧成立),且不新增任何令牌声明点。
455
+ const embed = find(tree, (n) => n.props?.className === "dru-wx-embed");
456
+ assert.ok(embed, "内嵌态应渲染 .dru-wx-embed 内容体");
457
+ assert.equal(embed.props.className.includes("dru-settings-section"), false, "内嵌态不得再嵌一层 .dru-settings-section");
458
+ assert.equal(embed.props["data-dru-theme"], "light", "内嵌态根节点仍带 data-dru-theme");
459
+ assert.equal(embed.props.role, "region");
460
+ assert.equal(embed.props["aria-label"], "微信机器人通道");
461
+
462
+ // 微信那段新样式:只能 var(--dru-*),不得自己声明令牌,也不得挂 :root/body
463
+ const start = SOURCE.indexOf("── 🤖 微信机器人(「📱 远程访问」面板里的第三个 tab:微信机器人通道) ──");
464
+ const end = SOURCE.indexOf("── 用户反馈模块 ──", start);
465
+ assert.ok(start > 0 && end > start, "应能定位到微信的样式块");
466
+ const block = SOURCE.slice(start, end);
467
+ assert.doesNotMatch(block, /--dru-[a-z0-9-]+\s*:/, "新样式不得**声明**令牌(只许引用)");
468
+ assert.doesNotMatch(block, /:root|\bbody\b/, "新样式不得挂在 :root/body 上(会污染整个 GUI)");
469
+ assert.match(block, /var\(--dru-/, "新样式应复用既有令牌");
470
+ // 令牌声明只允许出现在那三个选择器上(原有注释所述的不变量)
471
+ assert.match(SOURCE, /"\.dru-settings-section,\.dru-popup,\.dru-nav-remote\{"/, "亮色令牌块的选择器不得扩大");
472
+ assert.doesNotMatch(SOURCE, /":root\{/);
473
+ });
474
+
475
+ // ── 0.6.12:核心功能导览 / 登录门 / 动效引导 ────────────────────────────────
476
+
477
+ test("首屏导览:未绑定时就在最上面(DOM 顺序在「连接微信机器人」之前),三句话说清这是干什么用的", async () => {
478
+ const plugin = loadPlugin();
479
+ const tree = await plugin.settle();
480
+ const embed = find(tree, (n) => n.props?.className === "dru-wx-embed");
481
+ assert.ok(embed, "前提:已进入微信 tab");
482
+
483
+ const intro = find(tree, (n) => n.props?.className === "dru-wx-intro");
484
+ assert.ok(intro, "微信 tab 顶部必须有用途导览块(.dru-wx-intro)—— 这是绑定的理由,必须绑之前就看得见");
485
+ // 语义:说明性文字(role=note + 可读名字),不是又一个按钮/工具栏
486
+ assert.equal(intro.props.role, "note");
487
+ assert.ok(intro.props["aria-label"], "导览块要有可读的名字");
488
+ // 排版:小标题 + 2~4 行短句,不许变成大横幅
489
+ const rows = [];
490
+ walk(intro, (n) => { if (n.props?.className === "dru-wx-intro-row") rows.push(n); });
491
+ assert.ok(rows.length >= 3 && rows.length <= 4, `导览应是 3~4 行短句,实际 ${rows.length} 行`);
492
+
493
+ // 三件能力,各说各的(缺一不可)
494
+ assert.ok(textHas(intro, "完成") && textHas(intro, "出错") && textHas(intro, "停下"),
495
+ "① 要说清「任务完成 / 出错 / 停下」这些时刻");
496
+ assert.ok(textHas(intro, "推送"), "① 要明说是**推送到微信**(不用守着电脑)");
497
+ assert.ok(textHas(intro, "回一个数字"), "② 要明说「在微信里回一个数字」就完成决定");
498
+ assert.ok(textHas(intro, "拍板"), "② 要交代这是需要你拍板的时刻");
499
+ assert.ok(textHas(intro, "交代任务"), "③ 要明说可以**直接在微信里交代任务**");
500
+ assert.ok(textHas(intro, "追问"), "③ 要明说可以对同一个任务继续追问");
501
+
502
+ // DOM 顺序:导览在「连接微信机器人」按钮之前(用户先看懂用途,再看到按钮)
503
+ const introAt = domIndex(tree, (n) => n.props?.className === "dru-wx-intro");
504
+ const btnAt = domIndex(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("连接微信机器人")));
505
+ assert.ok(introAt >= 0 && btnAt >= 0, "导览与连接按钮都必须存在");
506
+ assert.ok(introAt < btnAt, `导览必须排在连接按钮之前(导览 #${introAt},按钮 #${btnAt})`);
507
+ // 而且导览是内容体的**第一个**孩子:不会把连接按钮推到需要滚动才看得见的地方
508
+ // (harness 渲染时会对普通节点做浅拷贝,所以拿树里的同一个节点比,不比组件里的原对象)
509
+ assert.equal(embed.children[0], find(embed, (n) => n.props?.className === "dru-wx-intro"),
510
+ "导览必须是内嵌内容体的第一个孩子(连接按钮仍在首屏)");
511
+ // 紧凑小字(不是宣传横幅):样式里明确给了小字号
512
+ assert.match(SOURCE, /\.dru-wx-intro-row\{[^}]*font-size:12px/, "导览行必须是小字(不挤占首屏)");
513
+
514
+ // 已绑定态同样保留导览(常驻用途说明,不占按钮、不影响状态卡)
515
+ plugin.setStatus(BOUND);
516
+ const refreshBtn = buttonWithText(tree, "刷新状态");
517
+ assert.ok(refreshBtn, "未绑定卡上应有「刷新状态」按钮(用它把新状态读回来)");
518
+ refreshBtn.props.onClick();
519
+ const boundTree = await plugin.settle();
520
+ assert.ok(find(boundTree, (n) => n.props?.className === "dru-wx-intro"), "已绑定态也应保留用途导览");
521
+ assert.ok(textHas(boundTree, "已绑定"));
522
+ });
523
+
524
+ test("登录门:未登录时**没有**「微信机器人通道」tab(只剩两个模式 tab);登录后当场出现,不用刷新页面", async () => {
525
+ const plugin = loadPlugin({ signedIn: false });
526
+ let tree = await plugin.settle();
527
+
528
+ // 未登录:tab 不存在(harness 的 auto-enter 也因此不会发生)
529
+ assert.equal(plugin.weChatTabOf(tree), undefined, "未登录时不得渲染「微信机器人通道」tab");
530
+ const labelsOf = (t) => {
531
+ const tabs = [];
532
+ walk(t, (n) => { if (n.props?.role === "tab") tabs.push(n); });
533
+ return tabs.map((x) => (x.children || []).filter((c) => typeof c === "string").join(""));
534
+ };
535
+ assert.deepEqual(labelsOf(tree), ["☁️ 云端服务", "🖥 自建服务"], "未登录时主 tab 条只剩两个模式 tab");
536
+ assert.equal(find(tree, (n) => n.props?.className === "dru-wx-embed"), undefined, "未登录时不得渲染微信内容");
537
+ // 不是空面板:账号/登录区就在 home
538
+ assert.ok(textHas(tree, "登录"), "未登录时应看到登录/账号区(不是空白)");
539
+
540
+ // 登录(/status 带上 config.phone)→ 不刷新页面,tab 当场出现
541
+ plugin.setSignedIn(true);
542
+ tree = await plugin.settle();
543
+ const tab = plugin.weChatTabOf(tree);
544
+ assert.ok(tab, "登录成功后微信 tab 必须自己出现(不需要刷新页面)");
545
+ assert.deepEqual(labelsOf(tree), ["☁️ 云端服务", "🖥 自建服务", "微信机器人通道"], "登录后 tab 条恢复三个 tab");
546
+ assert.ok(find(tree, (n) => n.props?.className === "dru-wx-ico"), "微信绿泡泡图标照旧");
547
+
548
+ // 且点得进去、内容正常
549
+ tab.props.onClick();
550
+ tree = await plugin.settle();
551
+ assert.ok(find(tree, (n) => n.props?.className === "dru-wx-embed"), "登录后点 tab 应进入微信机器人内容");
552
+ assert.ok(textHas(tree, "未绑定") || textHas(tree, "已绑定"), "内容读到状态(不是空面板)");
553
+ });
554
+
555
+ test("登录门(防御):微信 tab 开着时会话失效 → 不渲染微信内容、回落到 home,绝不留空面板", async () => {
556
+ const plugin = loadPlugin();
557
+ let tree = await plugin.settle(); // auto-enter:已进入微信 tab
558
+ assert.ok(find(tree, (n) => n.props?.className === "dru-wx-embed"), "前提:已在微信 tab 里");
559
+ assert.ok(find(tree, (n) => n.props?.["aria-label"] === "微信机器人通道"), "前提:微信 region 在渲染");
560
+
561
+ // 会话在 tab 开着的时候过期:/status 不再带 config.phone(登出 / 被踢下线 / 换账号)
562
+ plugin.setSignedIn(false);
563
+ plugin.setStatus(NOT_BOUND);
564
+ tree = await plugin.settle();
565
+
566
+ // ① 微信内容一个都不渲染(没有内容体、没有那个 region)
567
+ assert.equal(find(tree, (n) => n.props?.className === "dru-wx-embed"), undefined, "登出后不得再渲染微信内容体");
568
+ assert.equal(find(tree, (n) => n.props?.role === "region" && n.props?.["aria-label"] === "微信机器人通道"), undefined,
569
+ "登出后不得再渲染「微信机器人通道」region");
570
+ // ② 也不是空白:已经回落到 home(账号/登录卡 + 主 tab 条都在)
571
+ assert.ok(find(tree, (n) => n.props?.role === "tablist"), "应回到 home 的 tab 条");
572
+ assert.ok(textHas(tree, "登录") && textHas(tree, "注册"), "home 的账号卡(登录/注册)应在场,不留空面板");
573
+ assert.ok(textHas(tree, "后台服务") || textHas(tree, "远程访问"), "home 的其它卡片也应在场");
574
+ // ③ 微信 tab 自身也消失(登录门一致)
575
+ assert.equal(plugin.weChatTabOf(tree), undefined, "登出后微信 tab 必须消失");
576
+
577
+ // ④ 重新登录:user 不用刷新页面就能回到微信 tab
578
+ plugin.setSignedIn(true);
579
+ tree = await plugin.settle();
580
+ const tab = plugin.weChatTabOf(tree);
581
+ assert.ok(tab, "重新登录后微信 tab 应再次出现");
582
+ tab.props.onClick();
583
+ tree = await plugin.settle();
584
+ assert.ok(find(tree, (n) => n.props?.className === "dru-wx-embed"), "重新登录后可以正常再进微信 tab");
585
+ });
586
+
587
+ test("动效引导(未绑定):连接按钮带 .dru-wx-attn、装饰点 aria-hidden;样式表有 prefers-reduced-motion 关闭规则;绑定后消失", async () => {
588
+ const plugin = loadPlugin();
589
+ let tree = await plugin.settle();
590
+
591
+ const connect = buttonWithText(tree, "连接微信机器人");
592
+ assert.ok(connect, "未绑定态要有「连接微信机器人」按钮");
593
+ assert.ok(hasClass(connect, "dru-wx-attn"), "未绑定时连接按钮要带动效引导类 .dru-wx-attn");
594
+ // 无障碍:动效只是强调 —— 按钮文字与同卡片的文字提示本身已经把「要做什么」说清楚了
595
+ assert.ok(textHas(tree, "还没连接"), "未绑定态必须有文字提示(不许只靠动效表意)");
596
+ const dot = find(tree, (n) => hasClass(n, "dru-wx-attn-dot"));
597
+ assert.ok(dot, "未绑定态应有装饰性呼吸圆点(与按钮同一份引导)");
598
+ assert.equal(dot.props["aria-hidden"], "true", "纯装饰节点必须 aria-hidden");
599
+
600
+ // 样式表:CSS 动画(@keyframes,零外部资源)+ 明确的「减弱动态效果」豁免
601
+ const start = SOURCE.indexOf("── 🤖 微信机器人(「📱 远程访问」面板里的第三个 tab:微信机器人通道) ──");
602
+ const end = SOURCE.indexOf("── 用户反馈模块 ──", start);
603
+ const block = SOURCE.slice(start, end);
604
+ assert.match(block, /\.dru-wx-attn\{[^}]*animation:/, "引导必须是 CSS 动画");
605
+ assert.match(block, /@keyframes\s+dru-wx-breathe/, "@keyframes 必须就地在样式块里声明(不引外部资源)");
606
+ assert.match(block, /@media\s*\(\s*prefers-reduced-motion:\s*reduce\s*\)\s*\{[^}]*\.dru-wx-attn[^}]*animation:\s*none/,
607
+ "prefers-reduced-motion: reduce 必须关掉动画(无障碍硬要求)");
608
+ assert.match(block, /@media\s*\(\s*prefers-reduced-motion:\s*reduce\s*\)\s*\{[^}]*\.dru-wx-attn-dot[^}]*animation:\s*none/,
609
+ "装饰圆点的动画也要在 prefers-reduced-motion 下关掉");
610
+ // 节流:呼吸要「慢」(≥2s 一轮),不是刺眼的快闪
611
+ const period = /\.dru-wx-attn\{[^}]*?(\d+(?:\.\d+)?)s/.exec(block);
612
+ assert.ok(period && Number(period[1]) >= 2, `呼吸周期必须够慢(≥2s),实际:${period && period[1]}`);
613
+ assert.doesNotMatch(block, /animation:[^;"}]*\binfinite[^;"}]*\b(?:alternate-reverse|reverse)\b/, "不得做来回翻转式闪烁");
614
+ // 颜色只用既有令牌:动效相关规则里不得出现颜色字面量
615
+ for (const line of block.split("\n")) {
616
+ if (!/dru-wx-attn|dru-wx-breathe|dru-wx-pulse/.test(line)) continue;
617
+ assert.doesNotMatch(line, /#[0-9a-fA-F]{3,8}\b/, `动效样式不得硬编码颜色,只能用 --dru-* 令牌:${line.trim()}`);
618
+ }
619
+
620
+ // 已绑定:引导与动效一并消失(状态卡取代未绑定引导)
621
+ plugin.setStatus(BOUND);
622
+ buttonWithText(tree, "刷新状态").props.onClick(); // 让面板重读状态(refresh → tick)
623
+ tree = await plugin.settle();
624
+ assert.ok(textHas(tree, "已绑定"), "绑定后应是已绑定状态卡");
625
+ assert.equal(buttonWithText(tree, "连接微信机器人"), undefined, "已绑定后不得再有「连接微信机器人」按钮");
626
+ assert.equal(find(tree, (n) => hasClass(n, "dru-wx-attn")), undefined, "已绑定后不得再有任何 .dru-wx-attn 动效节点");
627
+ assert.equal(find(tree, (n) => hasClass(n, "dru-wx-attn-dot")), undefined, "已绑定后不得再有呼吸圆点");
628
+ });
629
+
630
+ test("未绑定:说明用途 + 「连接微信机器人」按钮;点了之后从 qrcode_svg 直接渲染二维码(零 QR 库/零外部资源)", async () => {
631
+ const plugin = loadPlugin();
632
+ let tree = await plugin.settle();
633
+ assert.ok(textHas(tree, "未绑定"), "未绑定态要有明确结论");
634
+ assert.ok(textHas(tree, "回一个数字"), "要说清「在微信里回一个数字就能决定」");
635
+ assert.ok(textHas(tree, "关键节点") || textHas(tree, "放行"), "要说清会推什么(需要你拍板/报错/停下)");
636
+
637
+ tree = await enterBinding(plugin);
638
+ const img = find(tree, (n) => n.type === "img");
639
+ assert.ok(img, "绑定中必须渲染二维码图片");
640
+ assert.equal(img.props.src, QR_DATA_URL, "img.src 必须是 bridge 下发的 qrcode_svg(data: URL)本身");
641
+ assert.equal(img.props.className, "dru-wx-qr");
642
+ assert.ok(img.props.alt && /二维码/.test(img.props.alt), "图片要有可读的 alt");
643
+ assert.ok(textHas(tree, "请用手机微信"), "要有「请用手机微信扫码」的提示");
644
+
645
+ // 零 QR 库 / 零外部资源:不引第三方编码器,组件区域里不出现任何 http(s) 外链
646
+ assert.doesNotMatch(SOURCE, /qrcodejs|qrcode-generator|davidshimjs|jsqr|nayuki|qrcode\.min\.js/i, "不得引入 QR 库");
647
+ assert.doesNotMatch(SOURCE, /createElement\("script"\)|createElement\('script'\)/, "不得注入外部脚本");
648
+ const compStart = SOURCE.indexOf("function WeChatBotSection(");
649
+ const compEnd = SOURCE.indexOf("// ── 插件入口 ──", compStart);
650
+ const comp = SOURCE.slice(compStart, compEnd);
651
+ assert.ok(comp.length > 500, "应能定位到微信栏目组件");
652
+ assert.doesNotMatch(comp, /https?:\/\//, "组件里不得出现外部资源地址(二维码只能来自 bridge 的 data: URL)");
653
+ });
654
+
655
+ test("扫码阶段:poll 依次推进 wait → scaned,且 1.5s 一轮、同一次只有一个请求在飞", async () => {
656
+ const plugin = loadPlugin({
657
+ polls: [
658
+ { status: 200, body: { ok: true, state: "wait", bound: false } },
659
+ { status: 200, body: { ok: true, state: "scaned", bound: false } }
660
+ ]
661
+ });
662
+ let tree = await enterBinding(plugin);
663
+ assert.ok(textHas(tree, "等待手机扫码"), "初始应提示等待扫码");
664
+
665
+ await plugin.advance(1600);
666
+ tree = await plugin.settle();
667
+ assert.equal(plugin.counts().poll, 1, "1.5s 应恰好推进一次(不是并发猛打)");
668
+
669
+ await plugin.advance(1600);
670
+ tree = await plugin.settle();
671
+ assert.equal(plugin.counts().poll, 2);
672
+ assert.ok(textHas(tree, "已扫码"), "scaned 应提示「已扫码,请在手机上确认」");
673
+ });
674
+
675
+ test("配对码:need_verify_code 为真 → 出现数字输入框;提交后 POST {code} 并继续轮询", async () => {
676
+ const plugin = loadPlugin({
677
+ polls: [
678
+ { status: 200, body: { ok: true, state: "need_verifycode", need_verify_code: true, bound: false } }
679
+ ]
680
+ });
681
+ let tree = await enterBinding(plugin);
682
+ await plugin.advance(1600);
683
+ tree = await plugin.settle();
684
+
685
+ const input = find(tree, (n) => n.type === "input" && n.props?.inputMode === "numeric");
686
+ assert.ok(input, "need_verify_code 为真时必须出现配对码输入框(这一步真机上极易漏)");
687
+ assert.ok(textHas(tree, "配对码"), "要说明这串数字来自手机微信");
688
+
689
+ // 非数字:不提交,给出可读提示
690
+ input.props.onChange({ target: { value: "abc" } });
691
+ tree = await plugin.settle();
692
+ const submit = buttonWithText(tree, "提交配对码");
693
+ assert.ok(submit, "必须有「提交配对码」按钮");
694
+ submit.props.onClick();
695
+ tree = await plugin.settle();
696
+ assert.equal(plugin.counts().verify, 0, "非数字不得提交");
697
+ assert.ok(textHas(tree, "1~8 位数字"), "应就地说明格式要求");
698
+
699
+ // 真数字:重新输入并提交
700
+ const input2 = find(plugin.render(), (n) => n.type === "input" && n.props?.inputMode === "numeric");
701
+ input2.props.onChange({ target: { value: "1234" } });
702
+ tree = await plugin.settle();
703
+ buttonWithText(tree, "提交配对码").props.onClick();
704
+ tree = await plugin.settle();
705
+ const hit = plugin.requests.filter((r) => r.path === "/dsh-remote/wechat/bind/verify").pop();
706
+ assert.ok(hit, "应请求 /dsh-remote/wechat/bind/verify");
707
+ assert.equal(hit.method, "POST");
708
+ assert.deepEqual(hit.body, { code: "1234" }, "必须把手机上的数字原样带回");
709
+ assert.ok(textHas(tree, "已提交"), "提交后要有反馈(否则用户会重复点)");
710
+ });
711
+
712
+ test("轮询停止①:confirmed —— 立即停轮询、切到已绑定视图并重取状态", async () => {
713
+ const plugin = loadPlugin({
714
+ polls: [{ status: 200, body: { ok: true, state: "confirmed", bound: true } }]
715
+ });
716
+ let tree = await enterBinding(plugin);
717
+ plugin.setStatus(BOUND); // bridge 此时已落盘凭据
718
+ await plugin.advance(1600);
719
+ tree = await plugin.settle();
720
+ assert.equal(plugin.counts().poll, 1);
721
+ assert.ok(textHas(tree, "已绑定"), "确认后应切到已绑定视图");
722
+ assert.ok(textHas(tree, "bot_7788"), "应显示机器人 ID");
723
+
724
+ const before = plugin.counts().poll;
725
+ await plugin.advance(20_000);
726
+ tree = await plugin.settle();
727
+ assert.equal(plugin.counts().poll, before, "confirmed 之后不得再轮询 binding(后台服务不该被打)");
728
+ });
729
+
730
+ test("轮询停止②:失败 —— 宿主代理报错时停下并原文照登", async () => {
731
+ const plugin = loadPlugin({
732
+ polls: [{ status: 400, body: { ok: false, code: "upstream_400", error: "轮询失败:二维码已失效,请重新发起绑定。" } }]
733
+ });
734
+ let tree = await enterBinding(plugin);
735
+ await plugin.advance(1600);
736
+ tree = await plugin.settle();
737
+ assert.equal(plugin.counts().poll, 1);
738
+ assert.ok(textHas(tree, "二维码已失效"), "失败原因要如实显示,不能只说「失败」");
739
+ const before = plugin.counts().poll;
740
+ await plugin.advance(20_000);
741
+ tree = await plugin.settle();
742
+ assert.equal(plugin.counts().poll, before, "失败后必须停止轮询");
743
+ assert.ok(buttonWithText(tree, "刷新二维码") || buttonWithText(tree, "换一张二维码"), "失败后要给出可操作的出路");
744
+ });
745
+
746
+ test("轮询停止③:取消 —— 点「取消连接」后不再轮询,且通知 bridge 取消", async () => {
747
+ const plugin = loadPlugin();
748
+ let tree = await enterBinding(plugin);
749
+ await plugin.advance(1600);
750
+ tree = await plugin.settle();
751
+ assert.equal(plugin.counts().poll, 1);
752
+
753
+ buttonWithText(tree, "取消连接").props.onClick();
754
+ tree = await plugin.settle();
755
+ assert.equal(plugin.counts().cancel, 1, "应通知 bridge 取消这次绑定");
756
+ const before = plugin.counts().poll;
757
+ await plugin.advance(20_000);
758
+ tree = await plugin.settle();
759
+ assert.equal(plugin.counts().poll, before, "取消后必须停止轮询");
760
+ assert.ok(textHas(tree, "已取消"), "取消要给出明确结果");
761
+ assert.ok(textHas(tree, "未绑定") || textHas(tree, "连接微信机器人"), "应回到未绑定视图");
762
+ });
763
+
764
+ test("轮询停止④:卸载 —— 组件从设置页消失后立刻停表", async () => {
765
+ const plugin = loadPlugin();
766
+ await enterBinding(plugin);
767
+ await plugin.advance(1600);
768
+ assert.equal(plugin.counts().poll, 1);
769
+ plugin.unmount();
770
+ await plugin.advance(20_000);
771
+ assert.equal(plugin.counts().poll, 1, "卸载后不得继续轮询(否则关掉设置页还在打后台服务)");
772
+ });
773
+
774
+ test("expired:告知二维码已过期,并给「刷新二维码」;不再对着作废的码轮询", async () => {
775
+ const plugin = loadPlugin({
776
+ polls: [{ status: 200, body: { ok: true, state: "expired", bound: false } }]
777
+ });
778
+ let tree = await enterBinding(plugin);
779
+ await plugin.advance(1600);
780
+ tree = await plugin.settle();
781
+ assert.ok(textHas(tree, "二维码已过期"), "要如实说二维码过期了");
782
+ assert.ok(buttonWithText(tree, "刷新二维码"), "过期后必须能一键换一张新码");
783
+ const before = plugin.counts().poll;
784
+ await plugin.advance(20_000);
785
+ assert.equal(plugin.counts().poll, before, "过期的码扫不出任何东西,继续轮询只是假装在等");
786
+ });
787
+
788
+ test("已绑定:两态模型 —— 健康提示只说「已绑定」,另加 ID/时间/最近错误;解绑要二次确认", async () => {
789
+ const plugin = loadPlugin({
790
+ status: { ...BOUND, last_error: "sendmessage 失败:session timeout", cooldown_ms: 3_600_000 }
791
+ });
792
+ let tree = await plugin.settle();
793
+ assert.ok(textHas(tree, "已绑定"), "应显示已绑定");
794
+ assert.ok(textHas(tree, "bot_7788"), "应显示机器人 ID");
795
+ assert.ok(textHas(tree, "绑定时间"), "应显示绑定时间");
796
+ // 健康字段是**提示**,绝不能变成第三种绑定状态
797
+ assert.ok(textHas(tree, "最近一次推送失败"), "要如实显示最近一次推送失败");
798
+ assert.ok(textHas(tree, "不改变绑定状态"), "必须写明健康提示不改变绑定状态");
799
+ assert.ok(!textHas(tree, "未绑定"), "已绑定时不得同时出现「未绑定」");
800
+ assert.ok(textHas(tree, "冷却"), "冷却中要说清楚(否则用户以为通知坏了)");
801
+
802
+ // 解绑:第一次点击只是进入确认,不发请求
803
+ const unbindBtn = buttonWithText(tree, "解绑");
804
+ assert.ok(unbindBtn, "应有「解绑」按钮");
805
+ unbindBtn.props.onClick();
806
+ tree = await plugin.settle();
807
+ assert.equal(plugin.counts().unbind, 0, "解绑必须先确认,不能一点就走");
808
+ assert.ok(buttonWithText(tree, "确认解绑"), "应出现「确认解绑」");
809
+ assert.ok(textHas(tree, "立即停止"), "要讲清解绑的后果");
810
+
811
+ // 反悔:不发请求
812
+ buttonWithText(tree, "先不解绑").props.onClick();
813
+ tree = await plugin.settle();
814
+ assert.equal(plugin.counts().unbind, 0);
815
+ assert.ok(buttonWithText(tree, "解绑"));
816
+
817
+ // 再来一次并确认
818
+ buttonWithText(tree, "解绑").props.onClick();
819
+ tree = await plugin.settle();
820
+ plugin.setStatus(NOT_BOUND);
821
+ buttonWithText(tree, "确认解绑").props.onClick();
822
+ tree = await plugin.settle();
823
+ assert.equal(plugin.counts().unbind, 1, "确认后才真的解绑");
824
+ assert.equal(plugin.requests.filter((r) => r.path === "/dsh-remote/wechat/unbind").pop().method, "POST");
825
+ assert.ok(textHas(tree, "已解绑"), "解绑成功后要有明确反馈");
826
+ });
827
+
828
+ test("控制面缺失(bridge 没跑/版本旧):如实说明 + 可重试 + 不留永久 spinner", async () => {
829
+ const plugin = loadPlugin({
830
+ statusCode: 503,
831
+ status: { ok: false, code: "no_control_file", error: "本机后台服务还没有运行微信机器人通道(找不到发现文件)…请先到「📱 远程访问」面板启动后台服务。" }
832
+ });
833
+ const tree = await plugin.settle();
834
+ assert.ok(textHas(tree, "还没有运行微信机器人通道"), "要把真实原因显示出来");
835
+ assert.ok(textHas(tree, "远程访问"), "要告诉用户去哪里处理");
836
+ assert.ok(textHas(tree, "原因代码:no_control_file"), "失败要带可排查的 code");
837
+ assert.ok(buttonWithText(tree, "重试"), "失败态必须能重试");
838
+ assert.ok(!find(tree, (n) => n.props?.className === "dru-spin"), "失败后不得继续转圈");
839
+
840
+ // 修好之后点重试 → 立刻恢复
841
+ plugin.setStatus(NOT_BOUND);
842
+ buttonWithText(plugin.render(), "重试").props.onClick();
843
+ const tree2 = await plugin.settle();
844
+ assert.ok(textHas(tree2, "未绑定"), "重试成功后应回到正常视图");
845
+ });
846
+
847
+ test("无障碍:真 <button>、状态区 aria-live 播报、按钮有文字标签", async () => {
848
+ const plugin = loadPlugin();
849
+ let tree = await plugin.settle();
850
+ const btns = [];
851
+ walk(tree, (n) => { if (n.type === "button") btns.push(n); });
852
+ assert.ok(btns.length >= 2, "至少要有「连接微信机器人」与「刷新状态」两个真按钮");
853
+ for (const b of btns) {
854
+ assert.equal(b.props.type, "button", "必须是真 <button type=button>(不是可点的 div)");
855
+ assert.ok((b.children || []).some((c) => typeof c === "string" && c.trim()), "按钮必须有可见文字");
856
+ }
857
+ const live = find(tree, (n) => n.props && n.props["aria-live"] === "polite" && n.props.role === "status");
858
+ assert.ok(live, "状态变化要有 aria-live 播报位");
859
+
860
+ // 绑定态:阶段行也要能被读屏播报
861
+ tree = await enterBinding(plugin);
862
+ const phaseRow = find(tree, (n) => n.props?.className === "dru-wx-phase");
863
+ assert.ok(phaseRow, "应有绑定阶段行");
864
+ assert.equal(phaseRow.props["aria-live"], "polite");
865
+ assert.equal(phaseRow.props.role, "status");
866
+ });