@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
@@ -29,6 +29,7 @@ import assert from "node:assert/strict";
29
29
  import { readFileSync } from "node:fs";
30
30
  import test from "node:test";
31
31
  import vm from "node:vm";
32
+ import { openEntry, clickChannelRow, findDialog } from "./lib-panel-ia.mjs";
32
33
 
33
34
  const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
34
35
 
@@ -305,19 +306,17 @@ function loadPlugin(opts = {}) {
305
306
  let tree = renderNode(root, "root");
306
307
  renders += 1;
307
308
  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();
309
+ // 真实用户路径(IA 重做 2026-11-07):主面板「通知与消息通道」入口卡 → 平台列表 →
310
+ // 「微信机器人(扫码绑定)」行 → 微信绑定面板。原来的顶部第三个 tab 已不存在。
311
+ entered = true;
312
+ const opened = openEntry(tree, "channel");
313
+ if (opened) {
315
314
  tree = renderNode(root, "root");
316
- } else if (!signedIn) {
317
- entered = true; // 未登录:本来就不该有微信 tab(见登录门用例)
318
- } else if (renders > 1) {
319
- // 登录门:**已登录**且状态已读到之后,这个 tab 必须在(不变量不放松)。
320
- assert.fail("已登录时远程访问面板的主 tab 条里必须有「微信机器人通道」tab");
315
+ const went = clickChannelRow(tree, "微信机器人");
316
+ if (went) tree = renderNode(root, "root");
317
+ else assert.fail("通道弹层里必须有「微信机器人(扫码绑定)」这一行");
318
+ } else {
319
+ assert.fail("主面板必须有「通知与消息通道」入口卡(登录不再隐藏任何功能)");
321
320
  }
322
321
  }
323
322
  return tree;
@@ -374,74 +373,37 @@ test("入口:settings.section 里**只有**「📱 远程访问」一个栏目
374
373
  assert.equal(typeof dispose, "function", "setting.section 的 inject 回调必须返回 disposer");
375
374
  });
376
375
 
377
- test("tab 条(home 与 wechat 共用):第三个 tab 是「微信机器人通道」+ 微信绿泡泡图标;能进也能出", async () => {
376
+ test("通道弹层:微信机器人一行能进能出;主面板不再有顶部 tab 条(IA 重做 2026-11-07)", async () => {
378
377
  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("微信机器人通道")));
378
+ let tree = await plugin.settle(); // 首次渲染自动走「通道入口 → 微信机器人行」
379
+ // 主面板不再有顶部 tab 条(旧结构:"云端服务 / 自建服务 / 微信机器人通道")
380
+ assert.equal(find(tree, (n) => n.props?.className === "dru-tabs"), undefined,
381
+ "主面板不得再有顶部 tab 条(已降级为弹层内的分段控件)");
382
+ // 微信绑定面板在弹层里(role=dialog),且弹层标题是「微信机器人(扫码绑定)」
383
+ const dlg = findDialog(tree);
384
+ assert.ok(dlg, "微信绑定内容必须在弹层里(role=dialog)");
385
+ assert.ok(textHas(tree, "微信机器人(扫码绑定)"), "弹层标题应是「微信机器人(扫码绑定)」");
386
+ // 出得去:点「返回平台列表」→ 弹层回到平台列表(不再是"进了就出不来"的 tab 视图)
387
+ const back = find(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("返回平台列表")));
388
+ assert.ok(back, "弹层里必须有「返回平台列表」出口");
422
389
  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 条(含绿泡泡)仍在");
390
+ tree = await plugin.settle();
391
+ assert.ok(textHas(tree, "企业微信智能机器人"), "返回后应是平台列表(能进也能出)");
426
392
  });
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 规则,不加载任何外部资源(字体/图片/脚本/样式)
393
+ test("图标:新 IA 一律内联 SVG(不用 emoji 当图标);零外部资源/零依赖不变量不变", () => {
394
+ // 业主 UX 规范:图标必须内联 SVG 16px currentColor,**禁止 emoji 当图标**
395
+ assert.match(SOURCE, /function druIcon\(name, size\)/, "必须有统一的 SVG 图标函数");
396
+ assert.match(SOURCE, /dangerouslySetInnerHTML: \{ __html: inner \}/, "图标以 inline SVG 渲染(不是 emoji/图片)");
397
+ assert.match(SOURCE, /"aria-hidden": "true", focusable: "false"/, "装饰性图标必须 aria-hidden");
398
+ // 零外部资源:不 @import、不引远程 url、不注入 link/script、不引 QR 库
435
399
  assert.doesNotMatch(SOURCE, /@import/, "不得 @import 外部样式");
436
400
  assert.doesNotMatch(SOURCE, /url\(\s*["']?https?:/, "CSS 里不得引用远程 url(...)");
437
401
  assert.doesNotMatch(SOURCE, /createElement\("link"\)|createElement\('link'\)/, "不得注入外部 <link>");
438
- assert.doesNotMatch(SOURCE, /createElement\("script"\)|createElement\('script'\)/, "不得注入外部 <script>");
439
402
  assert.doesNotMatch(SOURCE, /qrcodejs|qrcode-generator|davidshimjs|jsqr|nayuki|qrcode\.min\.js/i, "不得引入 QR 库");
440
- // 依赖侧同样零新增:dsh-remote-web 没有(也不得有)任何运行时依赖
403
+ // 依赖侧同样零新增
441
404
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
442
405
  assert.deepEqual(pkg.dependencies || {}, {}, "dsh-remote-web 不得有运行时依赖(零依赖不变量)");
443
406
  });
444
-
445
407
  test("主题不变量:根节点带 .dru-settings-section + data-dru-theme;新增样式只引用令牌、不落 :root/body", async () => {
446
408
  const plugin = loadPlugin();
447
409
  const tree = await plugin.settle();
@@ -533,69 +495,29 @@ test("首屏导览:未绑定时就在最上面(DOM 顺序在「连接微信
533
495
  assert.ok(textHas(boundTree, "已绑定"));
534
496
  });
535
497
 
536
- test("登录门:未登录时**没有**「微信机器人通道」tab(只剩两个模式 tab);登录后当场出现,不用刷新页面", async () => {
537
- const plugin = loadPlugin({ signedIn: false });
538
- let tree = await plugin.settle();
539
-
540
- // 未登录:tab 不存在(harness 的 auto-enter 也因此不会发生)
541
- assert.equal(plugin.weChatTabOf(tree), undefined, "未登录时不得渲染「微信机器人通道」tab");
542
- const labelsOf = (t) => {
543
- const tabs = [];
544
- walk(t, (n) => { if (n.props?.role === "tab") tabs.push(n); });
545
- return tabs.map((x) => (x.children || []).filter((c) => typeof c === "string").join(""));
546
- };
547
- assert.deepEqual(labelsOf(tree), ["☁️ 云端服务", "🖥 自建服务"], "未登录时主 tab 条只剩两个模式 tab");
548
- assert.equal(find(tree, (n) => n.props?.className === "dru-wx-embed"), undefined, "未登录时不得渲染微信内容");
549
- // 不是空面板:账号/登录区就在 home
550
- assert.ok(textHas(tree, "登录"), "未登录时应看到登录/账号区(不是空白)");
551
-
552
- // 登录(/status 带上 config.phone)→ 不刷新页面,tab 当场出现
553
- plugin.setSignedIn(true);
554
- tree = await plugin.settle();
555
- const tab = plugin.weChatTabOf(tree);
556
- assert.ok(tab, "登录成功后微信 tab 必须自己出现(不需要刷新页面)");
557
- assert.deepEqual(labelsOf(tree), ["☁️ 云端服务", "🖥 自建服务", "微信机器人通道"], "登录后 tab 条恢复三个 tab");
558
- assert.ok(find(tree, (n) => n.props?.className === "dru-wx-ico"), "微信绿泡泡图标照旧");
559
-
560
- // 且点得进去、内容正常
561
- tab.props.onClick();
562
- tree = await plugin.settle();
563
- assert.ok(find(tree, (n) => n.props?.className === "dru-wx-embed"), "登录后点 tab 应进入微信机器人内容");
564
- assert.ok(textHas(tree, "未绑定") || textHas(tree, "已绑定"), "内容读到状态(不是空面板)");
498
+ test("登录不再隐藏功能(2026-11-07 业主批评根治):未登录也能看到通道入口,进弹层后说明登录后才能绑定", async () => {
499
+ const plugin = loadPlugin();
500
+ plugin.setSignedIn(false);
501
+ const tree = await plugin.settle();
502
+ // ① 未登录时主面板照常渲染入口网格(不再隐藏整个功能)
503
+ assert.ok(textHas(tree, "通知与消息通道"), "未登录也必须有「通知与消息通道」入口卡");
504
+ // ② 通道弹层里微信一行仍在;点进去 → 说明要登录(而不是整块消失)
505
+ assert.ok(textHas(tree, "微信机器人(扫码绑定)"), "未登录也看得到微信通道这一行");
506
+ assert.ok(textHas(tree, "登录后可绑定微信机器人"), "微信步骤页要明确说明「登录后可绑定」");
507
+ const goLogin = find(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("去登录")));
508
+ assert.ok(goLogin, "给一个「去登录」的出口(不再让人自己找)");
565
509
  });
566
-
567
- test("登录门(防御):微信 tab 开着时会话失效 → 不渲染微信内容、回落到 home,绝不留空面板", async () => {
510
+ test("登录门(防御):微信步骤会话失效 → 只显示登录提示,绝不渲染绑定内容或空白面板", async () => {
568
511
  const plugin = loadPlugin();
569
- let tree = await plugin.settle(); // auto-enter:已进入微信 tab
570
- assert.ok(find(tree, (n) => n.props?.className === "dru-wx-embed"), "前提:已在微信 tab 里");
571
- assert.ok(find(tree, (n) => n.props?.["aria-label"] === "微信机器人通道"), "前提:微信 region 在渲染");
572
-
573
- // 会话在 tab 开着的时候过期:/status 不再带 config.phone(登出 / 被踢下线 / 换账号)
512
+ let tree = await plugin.settle();
513
+ assert.ok(textHas(tree, "连接微信机器人") || textHas(tree, "已绑定") || textHas(tree, "未绑定"), "登录态下应是真绑定面板");
514
+ // 会话中途失效(下一帧 /status 不再带 config.phone)
574
515
  plugin.setSignedIn(false);
575
- plugin.setStatus(NOT_BOUND);
576
- tree = await plugin.settle();
577
-
578
- // ① 微信内容一个都不渲染(没有内容体、没有那个 region)
579
- assert.equal(find(tree, (n) => n.props?.className === "dru-wx-embed"), undefined, "登出后不得再渲染微信内容体");
580
- assert.equal(find(tree, (n) => n.props?.role === "region" && n.props?.["aria-label"] === "微信机器人通道"), undefined,
581
- "登出后不得再渲染「微信机器人通道」region");
582
- // ② 也不是空白:已经回落到 home(账号/登录卡 + 主 tab 条都在)
583
- assert.ok(find(tree, (n) => n.props?.role === "tablist"), "应回到 home 的 tab 条");
584
- assert.ok(textHas(tree, "登录") && textHas(tree, "注册"), "home 的账号卡(登录/注册)应在场,不留空面板");
585
- assert.ok(textHas(tree, "后台服务") || textHas(tree, "远程访问"), "home 的其它卡片也应在场");
586
- // ③ 微信 tab 自身也消失(登录门一致)
587
- assert.equal(plugin.weChatTabOf(tree), undefined, "登出后微信 tab 必须消失");
588
-
589
- // ④ 重新登录:user 不用刷新页面就能回到微信 tab
590
- plugin.setSignedIn(true);
591
- tree = await plugin.settle();
592
- const tab = plugin.weChatTabOf(tree);
593
- assert.ok(tab, "重新登录后微信 tab 应再次出现");
594
- tab.props.onClick();
595
- tree = await plugin.settle();
596
- assert.ok(find(tree, (n) => n.props?.className === "dru-wx-embed"), "重新登录后可以正常再进微信 tab");
516
+ tree = await plugin.settle(4);
517
+ assert.ok(textHas(tree, "登录后可绑定微信机器人"), "失效后必须如实说明要登录(不留空面板)");
518
+ assert.equal(find(tree, (n) => n.props?.className === "dru-wx-embed"), undefined, "不得继续渲染绑定内容");
519
+ assert.ok(findDialog(tree), "弹层仍然可用(用户可以关掉或去登录)");
597
520
  });
598
-
599
521
  test("动效引导(未绑定):连接按钮带 .dru-wx-attn、装饰点 aria-hidden;样式表有 prefers-reduced-motion 关闭规则;绑定后消失", async () => {
600
522
  const plugin = loadPlugin();
601
523
  let tree = await plugin.settle();
@@ -864,7 +786,12 @@ test("无障碍:真 <button>、状态区 aria-live 播报、按钮有文字标
864
786
  assert.ok(btns.length >= 2, "至少要有「连接微信机器人」与「刷新状态」两个真按钮");
865
787
  for (const b of btns) {
866
788
  assert.equal(b.props.type, "button", "必须是真 <button type=button>(不是可点的 div)");
867
- assert.ok((b.children || []).some((c) => typeof c === "string" && c.trim()), "按钮必须有可见文字");
789
+ // 可访问名来自「可见文字(含子树里的 span)」或「aria-label」——图标按钮必须给 aria-label。
790
+ let deepText = "";
791
+ walk(b, (n) => { for (const c of n.children || []) if (typeof c === "string") deepText += c; });
792
+ assert.ok(
793
+ deepText.trim() || (b.props["aria-label"] && String(b.props["aria-label"]).trim()),
794
+ "按钮必须有可见文字或 aria-label(图标按钮必须给 aria-label):class=" + String(b.props?.className || ""));
868
795
  }
869
796
  const live = find(tree, (n) => n.props && n.props["aria-live"] === "polite" && n.props.role === "status");
870
797
  assert.ok(live, "状态变化要有 aria-live 播报位");