@sleep2agi/agent-network-dashboard 0.6.3-preview.50 → 0.6.3-preview.52

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 (262) hide show
  1. package/.next/BUILD_COMMIT +1 -1
  2. package/.next/BUILD_ID +1 -1
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/diagnostics/route-bundle-stats.json +50 -50
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_global-error.html +1 -1
  10. package/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +2 -2
  19. package/.next/server/app/_not-found.rsc +13 -13
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
  21. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
  23. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  26. package/.next/server/app/admin/page.js.nft.json +1 -1
  27. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  28. package/.next/server/app/admin.html +2 -2
  29. package/.next/server/app/admin.rsc +15 -15
  30. package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
  31. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  32. package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
  33. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  34. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  35. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  36. package/.next/server/app/index.html +2 -2
  37. package/.next/server/app/index.rsc +15 -15
  38. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  39. package/.next/server/app/index.segments/_full.segment.rsc +15 -15
  40. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  41. package/.next/server/app/index.segments/_index.segment.rsc +8 -8
  42. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  43. package/.next/server/app/login/page.js.nft.json +1 -1
  44. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  45. package/.next/server/app/login.html +2 -2
  46. package/.next/server/app/login.rsc +15 -15
  47. package/.next/server/app/login.segments/_full.segment.rsc +15 -15
  48. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  49. package/.next/server/app/login.segments/_index.segment.rsc +8 -8
  50. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  51. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  52. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  53. package/.next/server/app/logs/page.js.nft.json +1 -1
  54. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app/logs.html +2 -2
  56. package/.next/server/app/logs.rsc +15 -15
  57. package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
  58. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  59. package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
  60. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  61. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  62. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  63. package/.next/server/app/messages/page.js.nft.json +1 -1
  64. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  65. package/.next/server/app/messages.html +2 -2
  66. package/.next/server/app/messages.rsc +15 -15
  67. package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
  68. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  69. package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
  70. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  71. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  72. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  73. package/.next/server/app/node/page.js.nft.json +1 -1
  74. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/node.html +2 -2
  76. package/.next/server/app/node.rsc +15 -15
  77. package/.next/server/app/node.segments/_full.segment.rsc +15 -15
  78. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  79. package/.next/server/app/node.segments/_index.segment.rsc +8 -8
  80. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  81. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  82. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  83. package/.next/server/app/nodes/[alias]/page.js.nft.json +1 -1
  84. package/.next/server/app/nodes/[alias]/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/nodes/page.js.nft.json +1 -1
  86. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  87. package/.next/server/app/nodes.html +2 -2
  88. package/.next/server/app/nodes.rsc +15 -15
  89. package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
  90. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  91. package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
  92. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  93. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  94. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  95. package/.next/server/app/page.js.nft.json +1 -1
  96. package/.next/server/app/page_client-reference-manifest.js +1 -1
  97. package/.next/server/app/providers/page.js.nft.json +1 -1
  98. package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
  99. package/.next/server/app/providers.html +2 -2
  100. package/.next/server/app/providers.rsc +15 -15
  101. package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
  102. package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
  103. package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
  104. package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
  105. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
  106. package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
  107. package/.next/server/app/server-logs/page.js.nft.json +1 -1
  108. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  109. package/.next/server/app/server-logs.html +2 -2
  110. package/.next/server/app/server-logs.rsc +15 -15
  111. package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
  112. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  113. package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
  114. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  115. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  116. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  117. package/.next/server/app/servers/page.js.nft.json +1 -1
  118. package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
  119. package/.next/server/app/servers.html +2 -2
  120. package/.next/server/app/servers.rsc +15 -15
  121. package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
  122. package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
  123. package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
  124. package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
  125. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
  126. package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
  127. package/.next/server/app/settings/networks/page.js.nft.json +1 -1
  128. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  129. package/.next/server/app/settings/networks.html +2 -2
  130. package/.next/server/app/settings/networks.rsc +15 -15
  131. package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
  132. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  133. package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
  134. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  135. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  136. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  137. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  138. package/.next/server/app/settings/page.js.nft.json +1 -1
  139. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  140. package/.next/server/app/settings/tokens/page.js.nft.json +1 -1
  141. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  142. package/.next/server/app/settings/tokens.html +2 -2
  143. package/.next/server/app/settings/tokens.rsc +15 -15
  144. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
  145. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  146. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
  147. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  148. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  149. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  150. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  151. package/.next/server/app/settings.html +2 -2
  152. package/.next/server/app/settings.rsc +15 -15
  153. package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
  154. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  155. package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
  156. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  157. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  158. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  159. package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  160. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  161. package/.next/server/app/tasks/page.js.nft.json +1 -1
  162. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  163. package/.next/server/app/tasks.html +2 -2
  164. package/.next/server/app/tasks.rsc +15 -15
  165. package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
  166. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  167. package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
  168. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  169. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  170. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  171. package/.next/server/chunks/ssr/{0ld5_next_dist_00v047f._.js → 0ld5_next_dist_04cce2g._.js} +2 -2
  172. package/.next/server/chunks/ssr/0ld5_next_dist_04cce2g._.js.map +1 -0
  173. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js +1 -1
  174. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js.map +1 -1
  175. package/.next/server/chunks/ssr/[root-of-the-server]__0dcez4x._.js +11 -0
  176. package/.next/server/chunks/ssr/[root-of-the-server]__0dcez4x._.js.map +1 -0
  177. package/.next/server/chunks/ssr/{[root-of-the-server]__0jgqpfv._.js → [root-of-the-server]__0gyqilt._.js} +2 -2
  178. package/.next/server/chunks/ssr/[root-of-the-server]__0gyqilt._.js.map +1 -0
  179. package/.next/server/chunks/ssr/[root-of-the-server]__0j-def.._.js +11 -0
  180. package/.next/server/chunks/ssr/[root-of-the-server]__0j-def.._.js.map +1 -0
  181. package/.next/server/chunks/ssr/{[root-of-the-server]__11wln8.._.js → [root-of-the-server]__0oavw6j._.js} +2 -2
  182. package/.next/server/chunks/ssr/[root-of-the-server]__0oavw6j._.js.map +1 -0
  183. package/.next/server/chunks/ssr/[root-of-the-server]__0rct4.k._.js +3 -0
  184. package/.next/server/chunks/ssr/[root-of-the-server]__0rct4.k._.js.map +1 -0
  185. package/.next/server/chunks/ssr/{[root-of-the-server]__00n84ca._.js → [root-of-the-server]__0x9nh~a._.js} +2 -2
  186. package/.next/server/chunks/ssr/[root-of-the-server]__0x9nh~a._.js.map +1 -0
  187. package/.next/server/chunks/ssr/dash-stageb-wt_0q55dv1._.js +0 -0
  188. package/.next/server/chunks/ssr/dash-stageb-wt_0q55dv1._.js.map +1 -0
  189. package/.next/server/chunks/ssr/dash-stageb-wt_13184u1._.js +0 -0
  190. package/.next/server/chunks/ssr/dash-stageb-wt_13184u1._.js.map +1 -0
  191. package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js +2 -2
  192. package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js.map +1 -1
  193. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js +1 -1
  194. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js.map +1 -1
  195. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js +1 -1
  196. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js.map +1 -1
  197. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js +2 -2
  198. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js.map +1 -1
  199. package/.next/server/chunks/ssr/{dash-stageb-wt_0hzfl6x._.js → dash-stageb-wt_app_page_tsx_0iyrtvj._.js} +3 -3
  200. package/.next/server/chunks/ssr/dash-stageb-wt_app_page_tsx_0iyrtvj._.js.map +1 -0
  201. package/.next/server/middleware-build-manifest.js +3 -3
  202. package/.next/server/pages/404.html +2 -2
  203. package/.next/server/pages/500.html +1 -1
  204. package/.next/server/server-reference-manifest.js +1 -1
  205. package/.next/server/server-reference-manifest.json +1 -1
  206. package/.next/static/chunks/0-ot~88nectgp.js +2 -0
  207. package/.next/static/chunks/00adqh_rd04uf.js +9 -0
  208. package/.next/static/chunks/051p0pw4z~6ag.js +1 -0
  209. package/.next/static/chunks/0aaakzcw7tey0.js +0 -0
  210. package/.next/static/chunks/{0hm52b0y~k0~z.js → 0lf8e-h91s10h.js} +1 -1
  211. package/.next/static/chunks/0njt8wek.9aq6.js +1 -0
  212. package/.next/static/chunks/0ohx4jpm9~61..js +1 -0
  213. package/.next/static/chunks/0pg4pce.q5ne_.js +9 -0
  214. package/.next/static/chunks/0u5y.gmi~jkw6.js +1 -0
  215. package/.next/static/chunks/0wf03_efeii.w.css +1 -0
  216. package/.next/static/chunks/0ws0p1n62l~ub.js +1 -0
  217. package/.next/static/chunks/0zljk8~2jqw93.js +4 -0
  218. package/.next/static/chunks/{06q7-vhy~1-y3.js → 0~rx9_~1wupcp.js} +2 -2
  219. package/.next/static/chunks/{0ep78sq90x_yc.js → 14x094g58kg61.js} +1 -1
  220. package/.next/trace +2 -2
  221. package/.next/trace-build +1 -1
  222. package/app/components/ChatPane.tsx +133 -17
  223. package/app/components/NodeList.tsx +208 -46
  224. package/app/components/NodeListContextMenu.tsx +187 -0
  225. package/app/components/NodeSettingsPanel.tsx +29 -3
  226. package/app/components/TaskChatPanel.tsx +156 -31
  227. package/app/messages/page.tsx +22 -22
  228. package/app/nodes/NodesView.tsx +110 -11
  229. package/package.json +1 -1
  230. package/tests/e2e-9-nodes-team-tag-filter.spec.ts +17 -7
  231. package/tests/e2e-batch-list-actions.spec.ts +234 -0
  232. package/tests/e2e-messages-spec-visual-align.spec.ts +244 -0
  233. package/tests/e2e-pin-mute-attr-always-present.spec.ts +171 -0
  234. package/tests/e2e-stage-a-node-list.spec.ts +18 -7
  235. package/tests/e2e-stage-b-chat-pane.spec.ts +177 -10
  236. package/tests/e2e-status-labels.spec.ts +218 -0
  237. package/.next/server/chunks/ssr/0ld5_next_dist_00v047f._.js.map +0 -1
  238. package/.next/server/chunks/ssr/[root-of-the-server]__00n84ca._.js.map +0 -1
  239. package/.next/server/chunks/ssr/[root-of-the-server]__0idij1k._.js +0 -3
  240. package/.next/server/chunks/ssr/[root-of-the-server]__0idij1k._.js.map +0 -1
  241. package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js.map +0 -1
  242. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js +0 -9
  243. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js.map +0 -1
  244. package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js.map +0 -1
  245. package/.next/server/chunks/ssr/dash-stageb-wt_0hzfl6x._.js.map +0 -1
  246. package/.next/server/chunks/ssr/dash-stageb-wt_app_06.7qt9._.js +0 -0
  247. package/.next/server/chunks/ssr/dash-stageb-wt_app_06.7qt9._.js.map +0 -1
  248. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y~m8-6._.js +0 -0
  249. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y~m8-6._.js.map +0 -1
  250. package/.next/static/chunks/02.5_nifak.uu.js +0 -1
  251. package/.next/static/chunks/03hzj129k~odb.js +0 -1
  252. package/.next/static/chunks/0cey4i.y0a8~j.js +0 -1
  253. package/.next/static/chunks/0mhaf6lrw5nn..css +0 -1
  254. package/.next/static/chunks/0s293geid.j7e.js +0 -4
  255. package/.next/static/chunks/0zj_navpyhltx.js +0 -2
  256. package/.next/static/chunks/11zz15q37~bz5.js +0 -7
  257. package/.next/static/chunks/14y4x3de1lbug.js +0 -1
  258. package/.next/static/chunks/15ka68xi.w-i-.js +0 -1
  259. package/.next/static/chunks/17qkc714xy04..js +0 -1
  260. /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → RiXBfeE2zY5tzXRmF7kBD}/_buildManifest.js +0 -0
  261. /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → RiXBfeE2zY5tzXRmF7kBD}/_clientMiddlewareManifest.js +0 -0
  262. /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → RiXBfeE2zY5tzXRmF7kBD}/_ssgManifest.js +0 -0
@@ -40,16 +40,23 @@ const HUB_HEALTH = {
40
40
  },
41
41
  };
42
42
 
43
+ // Login rate limit is 10/15min/IP (app/lib/rate-limit.ts): with 4 spec
44
+ // files in one combined run, per-test logins blow the budget and late
45
+ // tests land on the sign-in page (false red). Login once per worker.
46
+ let cachedSessionCookie: string | null = null;
43
47
  async function login(context: Page['context'] extends () => infer C ? C : never) {
44
- const res = await context.request.post(`${BASE}/api/auth/login`, {
45
- data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
46
- });
47
- const setCookie = res.headers()['set-cookie'] || '';
48
- const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
49
- if (m) {
48
+ if (!cachedSessionCookie) {
49
+ const res = await context.request.post(`${BASE}/api/auth/login`, {
50
+ data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
51
+ });
52
+ const setCookie = res.headers()['set-cookie'] || '';
53
+ const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
54
+ if (m) cachedSessionCookie = decodeURIComponent(m[1]);
55
+ }
56
+ if (cachedSessionCookie) {
50
57
  await context.addCookies([{
51
58
  name: 'anet_dashboard_session',
52
- value: decodeURIComponent(m[1]),
59
+ value: cachedSessionCookie,
53
60
  domain: new URL(BASE).hostname,
54
61
  path: '/',
55
62
  }]);
@@ -57,6 +64,10 @@ async function login(context: Page['context'] extends () => infer C ? C : never)
57
64
  }
58
65
 
59
66
  async function mockCommon(page: Page) {
67
+ // #Vincent auto-select (07-31): these suites exercise the table/filter
68
+ // (manage) view — opt into it explicitly now that /nodes defaults to
69
+ // conversation-first with auto-select.
70
+ await page.addInitScript(() => window.localStorage.setItem('anet-nodes-manage-view', '1'));
60
71
  await page.route('**/api/hub/status**', r =>
61
72
  r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(HUB_STATUS) }));
62
73
  await page.route('**/api/hub/nodes**', r =>
@@ -84,6 +84,15 @@ async function mockCommon(page: Page) {
84
84
  r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
85
85
  }
86
86
 
87
+
88
+ // #Vincent auto-select (07-31): desktop /nodes now auto-selects the first
89
+ // conversation. Tests that exercise the UNSELECTED table-on-entry state
90
+ // opt into the persisted manage-view mode — the product's explicit doorway
91
+ // to that state — instead of weakening assertions.
92
+ async function manageMode(page: Page) {
93
+ await page.addInitScript(() => window.localStorage.setItem('anet-nodes-manage-view', '1'));
94
+ }
95
+
87
96
  const pane = (page: Page) => page.locator('[data-testid="chat-pane"]');
88
97
  const card = (page: Page, alias: string) => page.locator(`[data-node-card][data-node-alias="${alias}"]`);
89
98
 
@@ -91,6 +100,7 @@ test.describe('Stage B — persistent chat pane + /nodes/[alias] routing', () =>
91
100
  test('clicking a node renders chat inline in the right pane and rewrites the URL', async ({ page, context }) => {
92
101
  await login(context);
93
102
  await mockCommon(page);
103
+ await manageMode(page);
94
104
  await page.goto(`${BASE}/nodes`);
95
105
  await expect(card(page, 'stage-b-1')).toBeVisible({ timeout: 15000 });
96
106
 
@@ -108,6 +118,7 @@ test.describe('Stage B — persistent chat pane + /nodes/[alias] routing', () =>
108
118
  test('clicking another node swaps the SAME pane in place (still exactly one)', async ({ page, context }) => {
109
119
  await login(context);
110
120
  await mockCommon(page);
121
+ await manageMode(page);
111
122
  await page.goto(`${BASE}/nodes`);
112
123
  await expect(card(page, 'stage-b-1')).toBeVisible({ timeout: 15000 });
113
124
 
@@ -142,22 +153,86 @@ test.describe('Stage B — persistent chat pane + /nodes/[alias] routing', () =>
142
153
  await expect(page).toHaveURL(`${BASE}/nodes`);
143
154
  });
144
155
 
145
- test('< 768px: no right pane — chat opens as the full-screen overlay push', async ({ page, context }) => {
156
+ test('< 768px: two-level nav — list fullscreen, tap opens chat as a PAGE (no overlay), URL-addressable', async ({ page, context }) => {
146
157
  await login(context);
147
158
  await mockCommon(page);
159
+ await page.addInitScript(() => window.localStorage.setItem('anet-nodes-manage-view', '0'));
148
160
  await page.setViewportSize({ width: 390, height: 844 });
149
161
  await page.goto(`${BASE}/nodes`);
150
- await expect(card(page, 'stage-b-1')).toBeVisible({ timeout: 15000 });
162
+ // Level 1: conversation list fullscreen; the table does NOT render;
163
+ // and mobile does NOT auto-select (desktop-only behavior — 通信龙:
164
+ // two correct behaviors, don't test one shape with the other's
165
+ // assertions). URL must stay /nodes.
166
+ const rail = page.locator('[data-testid="node-list-rail"]');
167
+ await expect(rail).toBeVisible({ timeout: 15000 });
168
+ await page.waitForTimeout(900);
169
+ await expect(page).toHaveURL(`${BASE}/nodes`);
170
+ const railBox = await rail.boundingBox();
171
+ expect(Math.abs(railBox!.width - 390)).toBeLessThanOrEqual(2);
172
+ await expect(page.locator('[data-node-card]')).toHaveCount(0);
173
+ await expect(page.locator('[data-node-list-item]')).toHaveCount(3);
151
174
 
152
- await card(page, 'stage-b-1').click();
153
- // Overlay shell (fixed drawer) mounts; persistent pane does not.
154
- await expect(page.locator('.animate-slide-in')).toHaveCount(1);
155
- await expect(pane(page)).toHaveCount(0);
156
- // #Stage C only reshapes DESKTOP: the mobile middle column (with its
157
- // node rows) keeps rendering under the overlay.
158
- await expect(page.locator('[data-node-card]')).toHaveCount(3);
159
- // Still URL-addressable on mobile.
175
+ // Level 2: tap → chat page (not overlay), back arrow present.
176
+ await page.locator('[data-node-list-item][data-node-list-alias="stage-b-1"]').click();
177
+ await expect(pane(page)).toBeVisible();
178
+ await expect(page.locator('.animate-slide-in')).toHaveCount(0);
179
+ const paneBox = await pane(page).boundingBox();
180
+ expect(Math.abs(paneBox!.width - 390)).toBeLessThanOrEqual(2);
181
+ await expect(rail).toHaveCount(0); // one level at a time
182
+ await expect(page.locator('[data-testid="chat-back"]')).toBeVisible();
160
183
  await expect(page).toHaveURL(`${BASE}/nodes/stage-b-1`);
184
+
185
+ // Back arrow returns to the list.
186
+ await page.locator('[data-testid="chat-back"]').click();
187
+ await expect(rail).toBeVisible();
188
+ await expect(page).toHaveURL(`${BASE}/nodes`);
189
+ });
190
+
191
+ test('< 768px: back (arrow AND browser) restores list scroll position within 200px', async ({ page, context }) => {
192
+ await login(context);
193
+ await mockWide(page); // 60 nodes — enough to have a real scroll offset
194
+ await page.addInitScript(() => window.localStorage.setItem('anet-nodes-manage-view', '0'));
195
+ await page.setViewportSize({ width: 390, height: 844 });
196
+ await page.goto(`${BASE}/nodes`);
197
+ const scroll = page.locator('[data-testid="node-list-scroll"]');
198
+ await expect(page.locator('[data-node-list-item]')).toHaveCount(60, { timeout: 15000 });
199
+
200
+ const target = page.locator('[data-node-list-item][data-node-list-alias="stage-d-50"]');
201
+ await target.scrollIntoViewIfNeeded();
202
+ const before = await scroll.evaluate(el => el.scrollTop);
203
+ expect(before).toBeGreaterThan(500); // sanity: we really are deep in the list
204
+
205
+ await target.click();
206
+ await expect(pane(page)).toBeVisible();
207
+ await page.locator('[data-testid="chat-back"]').click();
208
+ await expect(scroll).toBeVisible();
209
+ const afterArrow = await scroll.evaluate(el => el.scrollTop);
210
+ expect(Math.abs(afterArrow - before)).toBeLessThanOrEqual(200);
211
+
212
+ // Browser back behaves the same (判据: 行为一致).
213
+ await target.click();
214
+ await expect(pane(page)).toBeVisible();
215
+ await page.goBack();
216
+ await expect(scroll).toBeVisible();
217
+ const afterBack = await scroll.evaluate(el => el.scrollTop);
218
+ expect(Math.abs(afterBack - before)).toBeLessThanOrEqual(200);
219
+ });
220
+
221
+ test('< 768px: manage mode still reaches the table, with a way back to conversations', async ({ page, context }) => {
222
+ await login(context);
223
+ await mockCommon(page);
224
+ await page.addInitScript(() => window.localStorage.setItem('anet-nodes-manage-view', '1'));
225
+ await page.setViewportSize({ width: 390, height: 844 });
226
+ await page.goto(`${BASE}/nodes`);
227
+ // Fact-side positive: the table is genuinely there in manage mode
228
+ // (guards against "mobile hides table" being green because the table
229
+ // broke everywhere).
230
+ await expect(page.locator('[data-node-card]')).toHaveCount(3, { timeout: 15000 });
231
+ await expect(page.locator('[data-testid="manage-view-toggle-mobile"]')).toBeVisible();
232
+
233
+ await page.locator('[data-testid="manage-view-toggle-mobile"]').click();
234
+ await expect(page.locator('[data-testid="node-list-rail"]')).toBeVisible();
235
+ await expect(page.locator('[data-node-card]')).toHaveCount(0);
161
236
  });
162
237
 
163
238
  test('slow-load regression: 1.5s-delayed first /api/hub/status on a deep link → no crash page', async ({ page, context }) => {
@@ -188,6 +263,7 @@ test.describe('Stage B — persistent chat pane + /nodes/[alias] routing', () =>
188
263
  test('#Stage C: selected = list+chat (table gone), unselected = table back — BOTH directions asserted', async ({ page, context }) => {
189
264
  await login(context);
190
265
  await mockCommon(page);
266
+ await manageMode(page);
191
267
  await page.goto(`${BASE}/nodes`);
192
268
  // Direction 1 (positive, same source): unselected /nodes renders the table.
193
269
  await expect(card(page, 'stage-b-1')).toBeVisible({ timeout: 15000 });
@@ -209,6 +285,7 @@ test.describe('Stage B — persistent chat pane + /nodes/[alias] routing', () =>
209
285
  test('#Stage C: dense-view user (rail hidden) selecting a node force-shows the rail', async ({ page, context }) => {
210
286
  await login(context);
211
287
  await mockCommon(page);
288
+ await manageMode(page);
212
289
  await page.addInitScript(() => window.localStorage.setItem('anet-nodes-rail', '0'));
213
290
  await page.goto(`${BASE}/nodes`);
214
291
  await expect(card(page, 'stage-b-1')).toBeVisible({ timeout: 15000 });
@@ -314,6 +391,7 @@ test.describe('Stage D — per-column scrolling, page never scrolls', () => {
314
391
  test('unselected + 60 nodes: document still does not scroll; the MIDDLE column scrolls to the last card', async ({ page, context }) => {
315
392
  await login(context);
316
393
  await mockWide(page);
394
+ await manageMode(page);
317
395
  await page.goto(`${BASE}/nodes`);
318
396
  await expect(page.locator('[data-node-card]')).toHaveCount(WIDE_N, { timeout: 15000 });
319
397
 
@@ -436,6 +514,7 @@ test.describe('Stage D ③ — rail-top node search', () => {
436
514
  test('unselected state: rail search filters rail AND table together (Stage A invariant holds)', async ({ page, context }) => {
437
515
  await login(context);
438
516
  await mockSearch(page);
517
+ await manageMode(page);
439
518
  await page.goto(`${BASE}/nodes`);
440
519
  await expect(page.locator('[data-node-card]')).toHaveCount(3, { timeout: 15000 });
441
520
 
@@ -507,3 +586,91 @@ test.describe('Stage D — chat column fixed, message area scrolls inside', () =
507
586
  expect(box!.y + box!.height).toBeLessThanOrEqual(innerH);
508
587
  });
509
588
  });
589
+
590
+ // ─────────────────────────────────────────────────────────────────────
591
+ // Vincent 插单 (07-31): desktop /nodes opens INTO a conversation.
592
+ // Traps pinned: ① replace-not-push (back must LEAVE /nodes, not bounce),
593
+ // ② the manage table keeps an explicit doorway, ③ an explicit ✕ is not
594
+ // answered by an immediate re-select, plus empty-list safety.
595
+ // ─────────────────────────────────────────────────────────────────────
596
+ test.describe('auto-select first conversation on /nodes (desktop)', () => {
597
+ test('entering /nodes lands in the first conversation: URL rewritten, table yields', async ({ page, context }) => {
598
+ await login(context);
599
+ await mockCommon(page);
600
+ await page.goto(`${BASE}/nodes`);
601
+ await expect(pane(page)).toBeVisible({ timeout: 15000 });
602
+ await expect(page).toHaveURL(/\/nodes\/[^/]+$/);
603
+ await expect(page.locator('[data-node-card]')).toHaveCount(0);
604
+ // deterministic first pick under this fixture's sort
605
+ await expect(pane(page)).toHaveAttribute('data-chat-alias', 'stage-b-1');
606
+ });
607
+
608
+ test('browser back LEAVES /nodes (auto-select uses replace, not push)', async ({ page, context }) => {
609
+ await login(context);
610
+ await mockCommon(page);
611
+ await page.goto(`${BASE}/tasks`);
612
+ await page.goto(`${BASE}/nodes`);
613
+ await expect(pane(page)).toBeVisible({ timeout: 15000 });
614
+ await expect(page).toHaveURL(/\/nodes\/[^/]+$/);
615
+
616
+ await page.goBack();
617
+ // With push instead of replace this bounces straight back into
618
+ // /nodes/<alias> — the assertion below goes red (sabotage-verified).
619
+ await expect(page).toHaveURL(`${BASE}/tasks`, { timeout: 10000 });
620
+ });
621
+
622
+ test('explicit ✕ close sticks: no immediate re-select', async ({ page, context }) => {
623
+ await login(context);
624
+ await mockCommon(page);
625
+ await page.goto(`${BASE}/nodes`);
626
+ await expect(pane(page)).toBeVisible({ timeout: 15000 });
627
+
628
+ await page.locator('[data-testid="chat-pane-close"]').click();
629
+ await expect(pane(page)).toHaveCount(0);
630
+ await expect(page).toHaveURL(`${BASE}/nodes`);
631
+ await expect(page.locator('[data-node-card]')).toHaveCount(3);
632
+ await page.waitForTimeout(900);
633
+ await expect(pane(page)).toHaveCount(0); // still closed — ✕ won
634
+ });
635
+
636
+ test('manage-view doorway: toggle to table, persists across reload, toggles back to conversation', async ({ page, context }) => {
637
+ await login(context);
638
+ await mockCommon(page);
639
+ await page.goto(`${BASE}/nodes`);
640
+ await expect(pane(page)).toBeVisible({ timeout: 15000 });
641
+
642
+ await page.locator('[data-testid="manage-view-toggle"]').click();
643
+ await expect(pane(page)).toHaveCount(0);
644
+ await expect(page.locator('[data-node-card]')).toHaveCount(3);
645
+
646
+ await page.goto(`${BASE}/nodes`); // persisted preference: table default, no auto-select
647
+ await expect(page.locator('[data-node-card]')).toHaveCount(3, { timeout: 15000 });
648
+ await page.waitForTimeout(900);
649
+ await expect(pane(page)).toHaveCount(0);
650
+
651
+ await page.locator('[data-testid="manage-view-toggle"]').click(); // 对话视图
652
+ await expect(pane(page)).toBeVisible();
653
+ await expect(page).toHaveURL(/\/nodes\/[^/]+$/);
654
+ });
655
+
656
+ test('empty list: no crash, no loop, URL stays /nodes', async ({ page, context }) => {
657
+ await login(context);
658
+ await page.route('**/api/hub/status**', r =>
659
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ sessions: [] }) }));
660
+ await page.route('**/api/hub/nodes**', r =>
661
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ nodes: [] }) }));
662
+ await page.route('**/api/hub/health', r =>
663
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, sse_sessions: {} }) }));
664
+ await page.route('**/api/hub/tasks**', r =>
665
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: [] }) }));
666
+ await page.route('**/api/hub/stats**', r =>
667
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }));
668
+ await page.route('**/api/hub/messages**', r =>
669
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
670
+ await page.goto(`${BASE}/nodes`);
671
+ await page.waitForTimeout(2500);
672
+ await expect(page).toHaveURL(`${BASE}/nodes`);
673
+ await expect(pane(page)).toHaveCount(0);
674
+ await expect(page.locator("text=This page couldn’t load")).toHaveCount(0);
675
+ });
676
+ });
@@ -0,0 +1,218 @@
1
+ import { test, expect, Page } from '@playwright/test';
2
+
3
+ const BASE = process.env.TEST_URL || 'http://localhost:3100';
4
+
5
+ // 通信龙 07-31 GAP 2nd tier: message status → human labels.
6
+ //
7
+ // This spec asserts BOTH:
8
+ // (A) each known status renders the exact expected Chinese label,
9
+ // NOT the raw English word ("已推送", not "delivered")
10
+ // (B) an unknown status (hub adds a new one; front-end hasn't caught
11
+ // up) is rendered fail-loudly — the raw string is visible AND a
12
+ // "未知状态" banner appears in the hover text
13
+ // (C) failed with `result` renders inline reason (first 40 chars);
14
+ // failed without `result` renders "节点未提供原因". Both branches
15
+ // — because a test that only covers "with reason" would let an
16
+ // empty-result bug fall back to "未知错误" silently.
17
+ //
18
+ // Injection strategy: /api/hub/tasks?to_name=... is the message-list
19
+ // endpoint (grepped from TaskChatPanel.tsx:686). We stub it with an
20
+ // array of tasks, one per status, and read the rendered labels off
21
+ // data-status-key / data-status-label attributes. NO mock of the
22
+ // component itself — the render is the wire under test.
23
+ //
24
+ // Note on `chat-pane` gate: TaskChatPanel is inside the ChatPane
25
+ // column, which mounts on /nodes/<alias>. We goto /nodes/node-a to
26
+ // force ChatPane mount, then feed all the varying-status tasks as
27
+ // to_name=node-a so they all render in that chat.
28
+
29
+ let cachedCookie: string | undefined;
30
+
31
+ async function login(context: Page['context'] extends () => infer C ? C : never) {
32
+ if (cachedCookie) {
33
+ await context.addCookies([{
34
+ name: 'anet_dashboard_session',
35
+ value: cachedCookie,
36
+ domain: new URL(BASE).hostname,
37
+ path: '/',
38
+ }]);
39
+ return;
40
+ }
41
+ const res = await context.request.post(`${BASE}/api/auth/login`, {
42
+ data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
43
+ });
44
+ const setCookie = res.headers()['set-cookie'] || '';
45
+ const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
46
+ if (m) {
47
+ cachedCookie = decodeURIComponent(m[1]);
48
+ await context.addCookies([{
49
+ name: 'anet_dashboard_session',
50
+ value: cachedCookie,
51
+ domain: new URL(BASE).hostname,
52
+ path: '/',
53
+ }]);
54
+ }
55
+ }
56
+
57
+ // One-line-per-status expectation table. Duplicated deliberately from
58
+ // the component — a test that reads the label out of STATUS_INFO would
59
+ // prove nothing (it'd assert the map equals itself). These strings ARE
60
+ // the contract 通信龙 signed off on; if they change, this test reds.
61
+ const CASES: Array<{ status: string; label: string; hoverContains: string; known: boolean }> = [
62
+ { status: 'queued', label: '排队中', hoverContains: '已入队,尚未推给节点', known: true },
63
+ { status: 'delivered', label: '已推送', hoverContains: 'hub 侧已标记推送', known: true },
64
+ { status: 'acked', label: '已接收', hoverContains: '未必已处理', known: true },
65
+ { status: 'replied', label: '已回复', hoverContains: '对方已针对此消息回复', known: true },
66
+ { status: 'failed', label: '失败', hoverContains: '发送失败', known: true },
67
+ { status: 'created', label: '发送中', hoverContains: '客户端已建消息', known: true },
68
+ { status: 'closed', label: '已关闭', hoverContains: '会话已被关闭', known: true },
69
+ { status: 'expired', label: '已超时', hoverContains: '在期限内未收到回复', known: true },
70
+ { status: 'cancelled', label: '已取消', hoverContains: '任务被主动取消', known: true },
71
+ { status: 'nonsense_hub_added_a_new_code', label: 'nonsense_hub_added_a_new_code', hoverContains: '未知状态', known: false },
72
+ ];
73
+
74
+ function makeFixtureTasks() {
75
+ return CASES.map((c, i) => ({
76
+ task_id: `t_${c.status}_${i}`,
77
+ from_node_id: 'n_a',
78
+ from_name: 'node-a',
79
+ to_node_id: 'n_a',
80
+ to_name: 'node-a',
81
+ priority: 'normal',
82
+ status: c.status,
83
+ content: `case-${c.status}`,
84
+ result: '',
85
+ in_reply_to: null,
86
+ requires_response: 0,
87
+ scope: 'inbox',
88
+ created_at: `2026-07-31T10:0${i}:00`,
89
+ delivered_at: `2026-07-31T10:0${i}:00`,
90
+ started_at: null,
91
+ completed_at: null,
92
+ expires_at: null,
93
+ meta_json: null,
94
+ }));
95
+ }
96
+
97
+ async function stubHub(page: Page, tasks: object[]) {
98
+ await page.route('**/api/hub/status**', r =>
99
+ r.fulfill({ status: 200, contentType: 'application/json',
100
+ body: JSON.stringify({ sessions: [{ alias: 'node-a', status: 'idle', network_id: 'net_x' }] }) }));
101
+ await page.route('**/api/hub/nodes**', r =>
102
+ r.fulfill({ status: 200, contentType: 'application/json',
103
+ body: JSON.stringify({ nodes: [{ alias: 'node-a', node_id: 'n_a', team: null, tags: [], attrs_revision: 0 }] }) }));
104
+ await page.route('**/api/hub/networks**', r =>
105
+ r.fulfill({ status: 200, contentType: 'application/json',
106
+ body: JSON.stringify({ networks: [{ network_id: 'net_x', network_name: 'net_x' }] }) }));
107
+ await page.route('**/api/hub/health', r =>
108
+ r.fulfill({ status: 200, contentType: 'application/json',
109
+ body: JSON.stringify({ ok: true, version: '0.9.0-test', sse_connections: 1, sse_sessions: { 'net_x:node-a': 1 } }) }));
110
+ await page.route('**/api/hub/stats**', r =>
111
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }));
112
+ await page.route('**/api/hub/messages**', r =>
113
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
114
+ await page.route('**/api/hub/events**', r =>
115
+ r.fulfill({ status: 200, contentType: 'text/event-stream', body: '' }));
116
+ // /api/hub/tasks — TaskChatPanel:686 uses this for the message list.
117
+ // Return our fixture regardless of query args (to_name, task_id, etc).
118
+ await page.route('**/api/hub/tasks**', r =>
119
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks }) }));
120
+ }
121
+
122
+ test.describe('Status labels — per-status render + unknown fail-loudly + failed both branches', () => {
123
+ test('every known status renders its exact Chinese label with matching hover text', async ({ page, context }) => {
124
+ await login(context);
125
+ await stubHub(page, makeFixtureTasks());
126
+ await page.goto(`${BASE}/nodes/node-a`);
127
+ await expect(page.locator('[data-testid="chat-pane"]').first()).toBeVisible({ timeout: 15000 });
128
+
129
+ // Premise: at least CASES.length StatusBars rendered — else we'd
130
+ // be asserting into an empty page (see feedback_checker_scope_bug_vacuous_pass).
131
+ const anyStatusBar = page.locator('[data-status-key]');
132
+ await expect(anyStatusBar.first()).toBeVisible({ timeout: 10000 });
133
+ const rendered = await anyStatusBar.count();
134
+ expect(rendered, `expected at least ${CASES.length} StatusBar renders (one per case), got ${rendered}`)
135
+ .toBeGreaterThanOrEqual(CASES.length);
136
+
137
+ // Now assert each case individually. Locate by data-status-key
138
+ // (unique per status in our fixture).
139
+ for (const c of CASES) {
140
+ const bar = page.locator(`[data-status-key="${c.status}"]`).first();
141
+ await expect(bar).toBeVisible();
142
+
143
+ // (A) label text is the exact Chinese string (or raw for unknown).
144
+ const labelAttr = await bar.getAttribute('data-status-label');
145
+ expect(labelAttr, `${c.status} label attr`).toBe(c.label);
146
+
147
+ const visibleText = await bar.locator('span').first().textContent();
148
+ expect(visibleText?.trim(), `${c.status} rendered text`).toContain(c.label);
149
+
150
+ // (A cont.) known/unknown attr matches expectation.
151
+ const knownAttr = await bar.getAttribute('data-status-known');
152
+ expect(knownAttr, `${c.status} known attr`).toBe(c.known ? 'true' : 'false');
153
+
154
+ // (B) hover title contains the semantic-specific string.
155
+ const titleAttr = await bar.locator('span[title]').first().getAttribute('title');
156
+ expect(titleAttr ?? '', `${c.status} hover text`).toContain(c.hoverContains);
157
+ }
158
+ });
159
+
160
+ test('failed WITH result renders inline reason (first 40 chars)', async ({ page, context }) => {
161
+ await login(context);
162
+ const REASON = 'codex-sdk 返回空响应 (in=15806 out=19) — 疑似 vendor 静默限流/配额';
163
+ const tasks = [{
164
+ task_id: 't_failed_with_reason', from_node_id: 'n_a', from_name: 'node-a',
165
+ to_node_id: 'n_a', to_name: 'node-a', priority: 'normal', status: 'failed',
166
+ content: 'failed case', result: REASON, in_reply_to: null,
167
+ requires_response: 0, scope: 'inbox',
168
+ created_at: '2026-07-31T10:00:00', delivered_at: '2026-07-31T10:00:00',
169
+ started_at: null, completed_at: null, expires_at: null, meta_json: null,
170
+ }];
171
+ await stubHub(page, tasks);
172
+ await page.goto(`${BASE}/nodes/node-a`);
173
+ await expect(page.locator('[data-testid="chat-pane"]').first()).toBeVisible({ timeout: 15000 });
174
+
175
+ const bar = page.locator('[data-status-key="failed"]').first();
176
+ await expect(bar).toBeVisible({ timeout: 10000 });
177
+
178
+ // Premise: `result` on the fixture is a specific non-empty string.
179
+ // If our stub fails to reach the component, the reason element
180
+ // would still exist with '节点未提供原因' — that'd look like a
181
+ // "wrong branch chosen" bug rather than "fixture never landed".
182
+ // Asserting the exact first-40 slice catches both.
183
+ const reason = bar.locator('[data-status-reason]');
184
+ await expect(reason).toBeVisible();
185
+ const reasonAttr = await reason.getAttribute('data-status-reason');
186
+ const expected40 = REASON.slice(0, 40);
187
+ expect(reasonAttr, `first-40 slice of "${REASON}"`).toBe(expected40);
188
+ // Visible text mirrors the attribute (truncated via CSS if wider).
189
+ expect(await reason.textContent()).toContain(expected40);
190
+ });
191
+
192
+ test('failed WITHOUT result renders "节点未提供原因" (not silently blank, not "未知错误")', async ({ page, context }) => {
193
+ await login(context);
194
+ const tasks = [{
195
+ task_id: 't_failed_no_reason', from_node_id: 'n_a', from_name: 'node-a',
196
+ to_node_id: 'n_a', to_name: 'node-a', priority: 'normal', status: 'failed',
197
+ content: 'failed no reason', result: '', // ← explicit empty
198
+ in_reply_to: null, requires_response: 0, scope: 'inbox',
199
+ created_at: '2026-07-31T10:00:00', delivered_at: '2026-07-31T10:00:00',
200
+ started_at: null, completed_at: null, expires_at: null, meta_json: null,
201
+ }];
202
+ await stubHub(page, tasks);
203
+ await page.goto(`${BASE}/nodes/node-a`);
204
+ await expect(page.locator('[data-testid="chat-pane"]').first()).toBeVisible({ timeout: 15000 });
205
+
206
+ const bar = page.locator('[data-status-key="failed"]').first();
207
+ await expect(bar).toBeVisible({ timeout: 10000 });
208
+
209
+ const reason = bar.locator('[data-status-reason]');
210
+ await expect(reason).toBeVisible();
211
+ const reasonAttr = await reason.getAttribute('data-status-reason');
212
+ expect(reasonAttr).toBe('节点未提供原因');
213
+ // Specifically NOT the "未知错误" placeholder — that would obscure
214
+ // the distinction between "hub has no reason field" and "reason
215
+ // exists but was bad", which is the whole point of separating them.
216
+ expect(reasonAttr).not.toContain('未知错误');
217
+ });
218
+ });