@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
@@ -0,0 +1,234 @@
1
+ import { test, expect, Page } from '@playwright/test';
2
+
3
+ const BASE = process.env.TEST_URL || 'http://localhost:3100';
4
+
5
+ // 07-31 batch (通信龙): 3 hard tests for the NodeList/ChatPane batch.
6
+ // Each test asserts an interaction that has no error surface if broken
7
+ // (would ship as "点了没反应" / "打了草稿没了" / "菜单少两项"):
8
+ //
9
+ // ① settings drawer open/close does NOT unmount TaskChatPanel
10
+ // (draft-in-input survives). Witnessed-red by mutating the drawer
11
+ // to KEY-remount the chat (parent-controlled key change → React
12
+ // remount → draft lost).
13
+ // ② keyboard focus discipline: ↓ moves URL when focus is on the
14
+ // rail, ↓ does NOT move URL when focus is in a text input.
15
+ // ③ right-click near list bottom → context menu flips upward
16
+ // (menu.getBoundingClientRect().bottom <= viewport.bottom).
17
+ //
18
+ // Not covered here (already visible in vf.mjs on prod real-data run):
19
+ // - pin/mute badge color swap (unit-level; SPEC §11.3 tokens hardcoded)
20
+ // - view-tasks / search buttons render (trivially visible)
21
+
22
+ // Per-worker login cache — dashboard rate-limit is 10 logins / 15min / IP.
23
+ let cachedCookie: string | undefined;
24
+
25
+ async function login(context: Page['context'] extends () => infer C ? C : never) {
26
+ if (cachedCookie) {
27
+ await context.addCookies([{
28
+ name: 'anet_dashboard_session',
29
+ value: cachedCookie,
30
+ domain: new URL(BASE).hostname,
31
+ path: '/',
32
+ }]);
33
+ return;
34
+ }
35
+ const res = await context.request.post(`${BASE}/api/auth/login`, {
36
+ data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
37
+ });
38
+ const setCookie = res.headers()['set-cookie'] || '';
39
+ const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
40
+ if (m) {
41
+ cachedCookie = decodeURIComponent(m[1]);
42
+ await context.addCookies([{
43
+ name: 'anet_dashboard_session',
44
+ value: cachedCookie,
45
+ domain: new URL(BASE).hostname,
46
+ path: '/',
47
+ }]);
48
+ }
49
+ }
50
+
51
+ const FIXTURE_SESSIONS = {
52
+ sessions: [
53
+ { alias: 'node-alpha', status: 'idle', network_id: 'net_x' },
54
+ { alias: 'node-beta', status: 'idle', network_id: 'net_x' },
55
+ { alias: 'node-gamma', status: 'idle', network_id: 'net_x' },
56
+ ],
57
+ };
58
+ const FIXTURE_NODES = {
59
+ nodes: [
60
+ { alias: 'node-alpha', node_id: 'n_alpha', team: null, tags: [], attrs_revision: 0 },
61
+ { alias: 'node-beta', node_id: 'n_beta', team: null, tags: [], attrs_revision: 0 },
62
+ { alias: 'node-gamma', node_id: 'n_gamma', team: null, tags: [], attrs_revision: 0 },
63
+ ],
64
+ };
65
+ const FIXTURE_HEALTH = {
66
+ ok: true, version: '0.9.0-test', sse_connections: 3,
67
+ sse_sessions: {
68
+ 'net_x:node-alpha': 1, 'net_x:node-beta': 1, 'net_x:node-gamma': 1,
69
+ },
70
+ };
71
+
72
+ async function stubHub(page: Page) {
73
+ await page.route('**/api/hub/status**', r =>
74
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_SESSIONS) }));
75
+ await page.route('**/api/hub/nodes**', r =>
76
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_NODES) }));
77
+ await page.route('**/api/hub/networks**', r =>
78
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ networks: [{ network_id: 'net_x', network_name: 'net_x' }] }) }));
79
+ await page.route('**/api/hub/health', r =>
80
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_HEALTH) }));
81
+ await page.route('**/api/hub/tasks**', r =>
82
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: [] }) }));
83
+ await page.route('**/api/hub/stats**', r =>
84
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }));
85
+ await page.route('**/api/hub/messages**', r =>
86
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
87
+ await page.route('**/api/hub/events**', r =>
88
+ r.fulfill({ status: 200, contentType: 'text/event-stream', body: '' }));
89
+ // NodeSettingsPanel loads a per-node config snapshot — return an
90
+ // empty stub so the drawer mounts without console noise.
91
+ await page.route('**/api/anet/node-config**', r =>
92
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ model: '', flags: {}, channels: [], config_revision: 0 }) }));
93
+ }
94
+
95
+ test.describe('Batch — list actions (07-31)', () => {
96
+ test('① settings drawer open/close does NOT unmount TaskChatPanel (draft survives)', async ({ page, context }) => {
97
+ await login(context);
98
+ await stubHub(page);
99
+ await page.goto(`${BASE}/nodes/node-alpha`);
100
+ await expect(page.locator('[data-testid="chat-pane"]').first()).toBeVisible({ timeout: 15000 });
101
+
102
+ // Find the chat's text input. TaskChatPanel renders one primary
103
+ // <textarea> / <input> for message composition — grab the first
104
+ // textbox inside the chat pane.
105
+ const chatInput = page.locator('[data-testid="chat-pane"] textarea, [data-testid="chat-pane"] input[type="text"]').first();
106
+ await expect(chatInput).toBeVisible({ timeout: 10000 });
107
+ // Type a distinctive draft that would be hard to reproduce by accident.
108
+ const DRAFT = 'preserve-across-settings-toggle-abc123';
109
+ await chatInput.fill(DRAFT);
110
+ await expect(chatInput).toHaveValue(DRAFT);
111
+
112
+ // Open settings drawer → close → text MUST still be there.
113
+ await page.locator('[data-testid="chat-pane-settings"]').click();
114
+ await expect(page.locator('[role="dialog"][aria-label*="节点设置"]').first()).toBeVisible({ timeout: 5000 });
115
+ // Close via the backdrop (absolute-positioned inside chat pane).
116
+ await page.keyboard.press('Escape');
117
+ // Wait a tick for React to reconcile.
118
+ await page.waitForTimeout(200);
119
+
120
+ // 🔴 The core assertion — the input still holds the same text.
121
+ // If TaskChatPanel got unmounted+remounted, state resets and this
122
+ // reads as empty (or reset to any autosaved draft, which is
123
+ // different — the point is "same value, no interruption").
124
+ await expect(chatInput).toHaveValue(DRAFT);
125
+ });
126
+
127
+ test('② kbd focus discipline — ↓ moves URL from rail focus, does NOT from input focus', async ({ page, context }) => {
128
+ await login(context);
129
+ await stubHub(page);
130
+ await page.goto(`${BASE}/nodes/node-alpha`);
131
+ await expect(page.locator('[data-testid="chat-pane"]').first()).toBeVisible({ timeout: 15000 });
132
+
133
+ // Wait for rail to actually have entries — the kbd handler
134
+ // bails on sessions.length === 0, so pressing arrow before SWR
135
+ // populates would test nothing.
136
+ await expect(page.locator('[data-node-list-item]').first()).toBeVisible({ timeout: 10000 });
137
+ const railCount = await page.locator('[data-node-list-item]').count();
138
+ expect(railCount).toBeGreaterThanOrEqual(3);
139
+
140
+ // Capture browser console to diagnose whether the handler fires.
141
+ const consoleMsgs: string[] = [];
142
+ page.on('console', msg => consoleMsgs.push(`${msg.type()}: ${msg.text()}`));
143
+
144
+ // Phase 1 — focus a NON-editable element (a rail entry button).
145
+ // TaskChatPanel refocuses its textarea via setTimeout(300) on
146
+ // mount (TaskChatPanel.tsx:745), so we MUST wait past that
147
+ // window before setting our own focus — otherwise the textarea
148
+ // steals it back mid-test. Wait 500ms > 300ms delay, then focus
149
+ // the button and press the key with no interceding await so no
150
+ // subsequent focus event can preempt.
151
+ await page.waitForTimeout(500);
152
+ await page.locator('[data-node-list-item]').first().focus();
153
+ const activeTag = await page.evaluate(() => document.activeElement?.tagName);
154
+ expect(activeTag).toBe('BUTTON');
155
+
156
+ const beforeUrl = new URL(page.url()).pathname;
157
+ expect(beforeUrl).toContain('/nodes/');
158
+ await page.keyboard.press('ArrowDown');
159
+ await page.waitForTimeout(500);
160
+ const afterUrl = new URL(page.url()).pathname;
161
+ expect(afterUrl).not.toBe(beforeUrl);
162
+ expect(afterUrl).toContain('node-beta');
163
+
164
+ // Phase 2 — dispatch same ArrowDown but with document.activeElement
165
+ // pointing at a text control. My handler bails when active.tagName
166
+ // is INPUT/TEXTAREA. Verify URL does NOT change.
167
+ const chatInput = page.locator('[data-testid="chat-pane"] textarea, [data-testid="chat-pane"] input[type="text"]').first();
168
+ await chatInput.focus();
169
+ await page.waitForTimeout(100);
170
+ const inputUrl = new URL(page.url()).pathname;
171
+ // Sanity: confirm the input actually took focus BEFORE dispatching,
172
+ // otherwise this test would spuriously pass (URL wouldn't change
173
+ // because the handler THINKS active is body → moves URL, but I'd
174
+ // observe unchanged URL for a different reason).
175
+ const activeInInput = await page.evaluate(() => document.activeElement?.tagName);
176
+ expect(['INPUT', 'TEXTAREA']).toContain(activeInInput ?? 'NONE');
177
+ await page.evaluate(() => {
178
+ const ev = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true });
179
+ window.dispatchEvent(ev);
180
+ });
181
+ await page.waitForTimeout(400);
182
+ const stillInputUrl = new URL(page.url()).pathname;
183
+ expect(stillInputUrl).toBe(inputUrl);
184
+ });
185
+
186
+ test('③ right-click near list bottom → menu flips upward + stays in viewport', async ({ page, context }) => {
187
+ await login(context);
188
+ await stubHub(page);
189
+ // Small viewport height so any entry the user right-clicks is
190
+ // "near the bottom" — a 120px menu rendered downward would
191
+ // overflow. This is the exact scenario the flip logic protects.
192
+ await page.setViewportSize({ width: 1280, height: 300 });
193
+ // Go to a node page (not /nodes) — the rail renders in both, but
194
+ // going through /nodes/<alias> exercises the exact code path
195
+ // tests 1 & 2 already validated the auth on. Then click the rail
196
+ // entry to force the "list-only" shape (chatAlias unchanged in
197
+ // URL is fine — the point is right-click on the last rail row).
198
+ await page.goto(`${BASE}/nodes/node-alpha`);
199
+ await expect(page.locator('[data-node-list-alias="node-gamma"]').first()).toBeVisible({ timeout: 15000 });
200
+
201
+ // Right-click on the last rail entry (node-gamma). Playwright's
202
+ // .click({button:'right'}) fires a native contextmenu with the
203
+ // element's center as clientX/clientY — for the last entry in a
204
+ // 300px-tall viewport, that lands well into the "would overflow
205
+ // downward" zone.
206
+ await page.locator('[data-node-list-alias="node-gamma"]').click({ button: 'right' });
207
+
208
+ const menu = page.locator('[data-testid="node-context-menu"]');
209
+ await expect(menu).toBeVisible({ timeout: 3000 });
210
+ // Wait for the useLayoutEffect + re-render — flip only applies
211
+ // after the second render. If flip did NOT fire, this attribute
212
+ // stays absent → the timeout below reads "expected 'true', got null"
213
+ // and the test reds precisely on the flip contract.
214
+ await expect(menu).toHaveAttribute('data-flipped-y', 'true', { timeout: 2000 });
215
+
216
+ // 🔴 Core assertion — menu is fully inside viewport (bottom edge
217
+ // ≤ viewport bottom). Without the flip, the menu would render
218
+ // from clientY down and have bottom > viewport.height.
219
+ const rect = await menu.evaluate(el => {
220
+ const r = el.getBoundingClientRect();
221
+ return { top: r.top, bottom: r.bottom, left: r.left, right: r.right };
222
+ });
223
+ // Read current viewport (we set it to 1280×300 above).
224
+ const vp = page.viewportSize()!;
225
+ expect(rect.bottom).toBeLessThanOrEqual(vp.height);
226
+ expect(rect.top).toBeGreaterThanOrEqual(0);
227
+ expect(rect.left).toBeGreaterThanOrEqual(0);
228
+ expect(rect.right).toBeLessThanOrEqual(vp.width);
229
+
230
+ // Sibling positive — the flip attribute is set (proves it took
231
+ // the flip branch, not just happened to fit by luck).
232
+ await expect(menu).toHaveAttribute('data-flipped-y', 'true');
233
+ });
234
+ });
@@ -0,0 +1,244 @@
1
+ import { test, expect, Page, BrowserContext } from '@playwright/test';
2
+
3
+ // /messages visual-alignment guardrail — pins the numbers that SPEC.md
4
+ // §2/§5/§6 dictate for the chat area.
5
+ //
6
+ // Method (per 07-31 通信龙 判据 "断言事实,不是声明"):
7
+ // values are read from getComputedStyle() on the real rendered DOM,
8
+ // NOT from any data-* attribute that would just restate the class
9
+ // value. If the visual token drifts, the number drifts, the assertion
10
+ // goes red — assertion side is same-source with reality (the browser's
11
+ // computed style), not with the declaration (the Tailwind class).
12
+ //
13
+ // Witnessed-red discipline (per 07-31 通信龙 判据 "一条一条打,不是整体打"):
14
+ // each assertion is its own `test(...)` block so witnessed-red on any
15
+ // ONE token (revert only that class, re-run) turns exactly ONE test
16
+ // red. If reverting shell bg turned two assertions red, that would
17
+ // prove they're coupled — not each guarding its own thing.
18
+
19
+ const BASE = process.env.TEST_URL || 'http://localhost:3200';
20
+
21
+ const MOCK_MESSAGES = {
22
+ messages: [
23
+ // First bubble in the list — outgoing task alpha→beta, so bubble
24
+ // background hits the SPEC #1E3A5F "self" color.
25
+ {
26
+ id: 'm1',
27
+ type: 'task',
28
+ from_alias: 'alpha',
29
+ to_alias: 'beta',
30
+ priority: 'normal',
31
+ content: 'ALPHA_TO_BETA_BODY',
32
+ created_at: '2026-07-31 12:00:00',
33
+ task_id: 't1',
34
+ },
35
+ // Second — incoming reply from beta→alpha, so we can also probe
36
+ // the SPEC #1E293B "other" color separately.
37
+ {
38
+ id: 'm2',
39
+ type: 'reply',
40
+ from_alias: 'beta',
41
+ to_alias: 'alpha',
42
+ priority: 'normal',
43
+ content: 'BETA_TO_ALPHA_BODY',
44
+ created_at: '2026-07-31 12:00:05',
45
+ },
46
+ // Third — broadcast, verifies the SPEC §5 "left 2px accent bar"
47
+ // pattern that we reused for broadcast distinction.
48
+ {
49
+ id: 'm3',
50
+ type: 'broadcast',
51
+ from_alias: 'admin',
52
+ priority: 'high',
53
+ content: 'BROADCAST_BODY',
54
+ created_at: '2026-07-31 12:00:10',
55
+ },
56
+ ],
57
+ };
58
+
59
+ // Rate-limit avoidance (10/15min/IP): one login per worker, reuse the
60
+ // cookie across all tests in this file. With --workers=1 the module
61
+ // var is truly shared. If the first login gets 429 (limit exhausted),
62
+ // throw immediately — otherwise every subsequent test would re-attempt
63
+ // and burn budget further while all failing the same way.
64
+ let cachedSessionCookie: string | null = null;
65
+ let loginErr: Error | null = null;
66
+ async function login(context: BrowserContext) {
67
+ if (loginErr) throw loginErr;
68
+ if (!cachedSessionCookie) {
69
+ const res = await context.request.post(`${BASE}/api/auth/login`, {
70
+ data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
71
+ });
72
+ if (res.status() === 429) {
73
+ loginErr = new Error(
74
+ 'Login rate-limited (10/15min). Restart dev to clear the counter.',
75
+ );
76
+ throw loginErr;
77
+ }
78
+ // playwright merges multi-value headers with newlines in headers();
79
+ // set-cookie is often multi-value so parse from headersArray instead.
80
+ const headers = res.headersArray();
81
+ const cookies = headers.filter((h) => h.name.toLowerCase() === 'set-cookie');
82
+ for (const c of cookies) {
83
+ const m = c.value.match(/anet_dashboard_session=([^;]+)/);
84
+ if (m) {
85
+ cachedSessionCookie = decodeURIComponent(m[1]);
86
+ break;
87
+ }
88
+ }
89
+ if (!cachedSessionCookie) {
90
+ loginErr = new Error(
91
+ `login 200 but no anet_dashboard_session cookie in headers: ${JSON.stringify(cookies)}`,
92
+ );
93
+ throw loginErr;
94
+ }
95
+ }
96
+ await context.addCookies([
97
+ {
98
+ name: 'anet_dashboard_session',
99
+ value: cachedSessionCookie,
100
+ domain: new URL(BASE).hostname,
101
+ path: '/',
102
+ },
103
+ ]);
104
+ }
105
+
106
+ async function mockAll(page: Page) {
107
+ await page.route('**/api/hub/messages**', (r) =>
108
+ r.fulfill({
109
+ status: 200,
110
+ contentType: 'application/json',
111
+ body: JSON.stringify(MOCK_MESSAGES),
112
+ }),
113
+ );
114
+ await page.route('**/api/hub/status**', (r) =>
115
+ r.fulfill({ status: 200, contentType: 'application/json', body: '{"sessions":[]}' }),
116
+ );
117
+ await page.route('**/api/hub/nodes**', (r) =>
118
+ r.fulfill({ status: 200, contentType: 'application/json', body: '{"nodes":[]}' }),
119
+ );
120
+ await page.route('**/api/hub/health', (r) =>
121
+ r.fulfill({
122
+ status: 200,
123
+ contentType: 'application/json',
124
+ body: '{"ok":true,"sse_sessions":{}}',
125
+ }),
126
+ );
127
+ }
128
+
129
+ async function gotoMessages(page: Page) {
130
+ await login(page.context());
131
+ await mockAll(page);
132
+ await page.setViewportSize({ width: 1440, height: 900 });
133
+ await page.goto(`${BASE}/messages`, { waitUntil: 'domcontentloaded' });
134
+ // Wait for the actual mocked message content — NOT just any
135
+ // rounded-2xl, because the loading skeleton is also rounded-2xl
136
+ // (h-20 rounded-2xl bg-gray-800/20). Waiting for the literal
137
+ // mock body text pins that SWR has settled and rendered.
138
+ // See feedback_checker_scope_bug_vacuous_pass.md.
139
+ await page.waitForFunction(
140
+ () => document.body.textContent?.includes('ALPHA_TO_BETA_BODY') || false,
141
+ undefined,
142
+ // Turbopack sometimes rebuilds after a source edit — first mount
143
+ // can take 10-20s. Give the ceiling headroom for CI-cold dev servers.
144
+ { timeout: 25_000 },
145
+ );
146
+ }
147
+
148
+ // Read computedStyle from the bubble that contains a known mock text.
149
+ // Text-anchored so the query is stable against other rounded-2xl chrome
150
+ // (headers, nav, etc.) — and independent of DOM ordering.
151
+ async function bubbleStyleByContent(page: Page, needle: string) {
152
+ return await page.evaluate((n) => {
153
+ const bubbles = Array.from(document.querySelectorAll('div.rounded-2xl'));
154
+ const bubble = bubbles.find(
155
+ (el) =>
156
+ (el.textContent || '').includes(n) &&
157
+ Array.from(el.classList).some((c) => c.startsWith('bg-')),
158
+ );
159
+ if (!bubble) return null;
160
+ const cs = getComputedStyle(bubble);
161
+ return {
162
+ maxWidth: cs.maxWidth,
163
+ backgroundColor: cs.backgroundColor,
164
+ borderLeftColor: cs.borderLeftColor,
165
+ borderLeftWidth: cs.borderLeftWidth,
166
+ };
167
+ }, needle);
168
+ }
169
+
170
+ async function broadcastBubbleStyle(page: Page) {
171
+ return bubbleStyleByContent(page, 'BROADCAST_BODY');
172
+ }
173
+
174
+ test.describe('/messages · SPEC visual alignment', () => {
175
+ // ── 1. SHELL BG · SPEC §2 chat area #141826 ──────────────────
176
+ test('shell bg is SPEC #141826 (rgb 20,24,38)', async ({ page }) => {
177
+ await gotoMessages(page);
178
+ const bg = await page.evaluate(() => {
179
+ const shell = document.querySelector('[data-testid="messages-shell"]');
180
+ if (!shell) return null;
181
+ return getComputedStyle(shell).backgroundColor;
182
+ });
183
+ // #141826 rendered as computed rgb(20, 24, 38) by every browser.
184
+ expect(bg).toBe('rgb(20, 24, 38)');
185
+ });
186
+
187
+ // ── 2. BUBBLE MAX-WIDTH · SPEC §5 min(640px, 65%) ───────────
188
+ test('bubble max-width is SPEC min(640px, 65%)', async ({ page }) => {
189
+ await gotoMessages(page);
190
+ // Non-broadcast bubble — mock m1 body "ALPHA_TO_BETA_BODY".
191
+ const s = await bubbleStyleByContent(page, 'ALPHA_TO_BETA_BODY');
192
+ expect(s).not.toBeNull();
193
+ // Browser normalizes without spaces around the comma.
194
+ expect(s!.maxWidth).toBe('min(640px, 65%)');
195
+ });
196
+
197
+ // ── 3. BUBBLE BG · SPEC §6 tokens (either #1E3A5F self or #1E293B other) ──
198
+ test('bubble bg is one of the SPEC bubble tokens', async ({ page }) => {
199
+ await gotoMessages(page);
200
+ // Non-broadcast bubble — mock m1 body "ALPHA_TO_BETA_BODY".
201
+ const s = await bubbleStyleByContent(page, 'ALPHA_TO_BETA_BODY');
202
+ expect(s).not.toBeNull();
203
+ // Accept-set = exactly the SPEC-defined bubble bg tokens; NOT a
204
+ // shape match, NOT "any dark color" (see
205
+ // feedback_tolerant_assertion_accepts_noncompliance.md +
206
+ // feedback_allowlist_must_be_exact_value_not_shape_match.md).
207
+ expect(['rgb(30, 41, 59)', 'rgb(30, 58, 95)']).toContain(s!.backgroundColor);
208
+ });
209
+
210
+ // ── 4. BROADCAST · SPEC §5 left 2px accent bar #7DD3FC ──────
211
+ test('broadcast bubble has 2px left accent bar (#7DD3FC)', async ({ page }) => {
212
+ await gotoMessages(page);
213
+ const s = await broadcastBubbleStyle(page);
214
+ expect(s).not.toBeNull();
215
+ // #1E293B = the "other" bubble token — broadcast is not self.
216
+ expect(s!.backgroundColor).toBe('rgb(30, 41, 59)');
217
+ // 2px accent bar in SPEC accent color #7DD3FC = rgb(125, 211, 252).
218
+ expect(s!.borderLeftWidth).toBe('2px');
219
+ expect(s!.borderLeftColor).toBe('rgb(125, 211, 252)');
220
+ });
221
+
222
+ // ── 5. TIMESTAMP · SPEC §5 11px / #64748B ───────────────────
223
+ test('timestamp is 11px in SPEC #64748B', async ({ page }) => {
224
+ await gotoMessages(page);
225
+ // The timestamp span sits at the end of each bubble header — pinned
226
+ // by its ml-auto + text-[11px] combo. Query by containing formatted
227
+ // time text ("ago" wording from timeAgo). We assert on ANY such
228
+ // span — pipeline gives us multiple.
229
+ const stamp = await page.evaluate(() => {
230
+ const spans = Array.from(document.querySelectorAll('span'));
231
+ // Pick the first span whose Tailwind class list includes the
232
+ // 11px arbitrary utility. That class is set only by the
233
+ // timestamp render sites we changed.
234
+ const el = spans.find((s) => s.className.includes('text-[11px]'));
235
+ if (!el) return null;
236
+ const cs = getComputedStyle(el);
237
+ return { fontSize: cs.fontSize, color: cs.color };
238
+ });
239
+ expect(stamp).not.toBeNull();
240
+ expect(stamp!.fontSize).toBe('11px');
241
+ // #64748B rendered as rgb(100, 116, 139).
242
+ expect(stamp!.color).toBe('rgb(100, 116, 139)');
243
+ });
244
+ });
@@ -0,0 +1,171 @@
1
+ import { test, expect, Page } from '@playwright/test';
2
+
3
+ const BASE = process.env.TEST_URL || 'http://localhost:3100';
4
+
5
+ // 通信龙 07-31: attribute-always-present guarantee for pin/mute.
6
+ //
7
+ // The wire that USED to emit `data-node-pinned="true" | undefined` made a
8
+ // gate script that counts "rows with the attribute" indistinguishable
9
+ // from a wire that was ripped out — both looked like zero. This spec
10
+ // enforces the new invariant AND proves the attribute tracks real state
11
+ // (not a hardcoded literal that would also pass the capability check).
12
+ //
13
+ // TWO assertions, both required:
14
+ // (A) capability — every rail row emits data-node-pinned AND
15
+ // data-node-muted, and the value is 'true' | 'false'
16
+ // (never absent, never something else). If a future
17
+ // refactor drops the emit, THIS reds.
18
+ // (B) real state — toggling pin on one row flips ITS attribute from
19
+ // 'false' to 'true' — and leaves the others alone.
20
+ // If someone hardcodes both to 'false' (would pass
21
+ // A), THIS reds. Same shape for muted.
22
+ //
23
+ // Rate-limit guard (10 logins / 15min / IP): per-worker cachedCookie
24
+ // (same as e2e-batch-list-actions.spec.ts).
25
+
26
+ let cachedCookie: string | undefined;
27
+
28
+ async function login(context: Page['context'] extends () => infer C ? C : never) {
29
+ if (cachedCookie) {
30
+ await context.addCookies([{
31
+ name: 'anet_dashboard_session',
32
+ value: cachedCookie,
33
+ domain: new URL(BASE).hostname,
34
+ path: '/',
35
+ }]);
36
+ return;
37
+ }
38
+ const res = await context.request.post(`${BASE}/api/auth/login`, {
39
+ data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
40
+ });
41
+ const setCookie = res.headers()['set-cookie'] || '';
42
+ const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
43
+ if (m) {
44
+ cachedCookie = decodeURIComponent(m[1]);
45
+ await context.addCookies([{
46
+ name: 'anet_dashboard_session',
47
+ value: cachedCookie,
48
+ domain: new URL(BASE).hostname,
49
+ path: '/',
50
+ }]);
51
+ }
52
+ }
53
+
54
+ const FIXTURE_SESSIONS = {
55
+ sessions: [
56
+ { alias: 'node-alpha', status: 'idle', network_id: 'net_x' },
57
+ { alias: 'node-beta', status: 'idle', network_id: 'net_x' },
58
+ { alias: 'node-gamma', status: 'idle', network_id: 'net_x' },
59
+ ],
60
+ };
61
+ const FIXTURE_NODES = {
62
+ nodes: [
63
+ { alias: 'node-alpha', node_id: 'n_alpha', team: null, tags: [], attrs_revision: 0 },
64
+ { alias: 'node-beta', node_id: 'n_beta', team: null, tags: [], attrs_revision: 0 },
65
+ { alias: 'node-gamma', node_id: 'n_gamma', team: null, tags: [], attrs_revision: 0 },
66
+ ],
67
+ };
68
+
69
+ async function stubHub(page: Page) {
70
+ await page.route('**/api/hub/status**', r =>
71
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_SESSIONS) }));
72
+ await page.route('**/api/hub/nodes**', r =>
73
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_NODES) }));
74
+ await page.route('**/api/hub/networks**', r =>
75
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ networks: [{ network_id: 'net_x', network_name: 'net_x' }] }) }));
76
+ await page.route('**/api/hub/health', r =>
77
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, version: '0.9.0-test', sse_connections: 0, sse_sessions: {} }) }));
78
+ await page.route('**/api/hub/tasks**', r =>
79
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: [] }) }));
80
+ await page.route('**/api/hub/stats**', r =>
81
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }));
82
+ await page.route('**/api/hub/messages**', r =>
83
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
84
+ await page.route('**/api/hub/events**', r =>
85
+ r.fulfill({ status: 200, contentType: 'text/event-stream', body: '' }));
86
+ await page.route('**/api/anet/node-config**', r =>
87
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ model: '', flags: {}, channels: [], config_revision: 0 }) }));
88
+ }
89
+
90
+ test.describe('Pin/mute attr — always present + tracks real state', () => {
91
+ test('every rail row emits data-node-pinned AND data-node-muted with value ∈ {true,false}', async ({ page, context }) => {
92
+ await login(context);
93
+ await stubHub(page);
94
+ // Ensure no leftover state from prior runs would tint the "false"
95
+ // starting point — clear both storage keys before navigating.
96
+ await page.addInitScript(() => {
97
+ try { window.localStorage.removeItem('anet_chat_pin_v1'); } catch {}
98
+ try { window.localStorage.removeItem('anet_chat_mute_v1'); } catch {}
99
+ });
100
+ await page.goto(`${BASE}/nodes/node-alpha`);
101
+ await expect(page.locator('[data-node-list-alias="node-alpha"]').first()).toBeVisible({ timeout: 15000 });
102
+
103
+ const rows = page.locator('[data-node-list-item]');
104
+ const count = await rows.count();
105
+ expect(count).toBeGreaterThanOrEqual(3); // premise: fixture landed
106
+
107
+ const pinnedVals: string[] = [];
108
+ const mutedVals: string[] = [];
109
+ for (let i = 0; i < count; i++) {
110
+ const row = rows.nth(i);
111
+ const pin = await row.getAttribute('data-node-pinned');
112
+ const mute = await row.getAttribute('data-node-muted');
113
+ pinnedVals.push(pin ?? '__ABSENT__');
114
+ mutedVals.push(mute ?? '__ABSENT__');
115
+ }
116
+ // Report cardinality alongside failure — if this reds, we want to
117
+ // see whether N of N were absent (wire ripped) vs 1 of N was 'foo'
118
+ // (someone typo'd the emit).
119
+ for (const v of pinnedVals) {
120
+ expect(v, `pinned attr values across ${count} rows: ${JSON.stringify(pinnedVals)}`)
121
+ .toMatch(/^(true|false)$/);
122
+ }
123
+ for (const v of mutedVals) {
124
+ expect(v, `muted attr values across ${count} rows: ${JSON.stringify(mutedVals)}`)
125
+ .toMatch(/^(true|false)$/);
126
+ }
127
+ });
128
+
129
+ test('toggling pin on node-beta flips its data-node-pinned from false to true (others unchanged)', async ({ page, context }) => {
130
+ await login(context);
131
+ await stubHub(page);
132
+ await page.addInitScript(() => {
133
+ try { window.localStorage.removeItem('anet_chat_pin_v1'); } catch {}
134
+ try { window.localStorage.removeItem('anet_chat_mute_v1'); } catch {}
135
+ });
136
+ await page.goto(`${BASE}/nodes/node-alpha`);
137
+ await expect(page.locator('[data-node-list-alias="node-beta"]').first()).toBeVisible({ timeout: 15000 });
138
+
139
+ // Baseline — all three rows read 'false' on pinned.
140
+ // This is the PREMISE, not the conclusion — if the fixture happens
141
+ // to have someone already pinned, the toggle would flip 'true' →
142
+ // 'false' and the conclusion assert reverses direction. Assert
143
+ // premise first to catch that.
144
+ const alphaBefore = await page.locator('[data-node-list-alias="node-alpha"]').getAttribute('data-node-pinned');
145
+ const betaBefore = await page.locator('[data-node-list-alias="node-beta"]').getAttribute('data-node-pinned');
146
+ const gammaBefore = await page.locator('[data-node-list-alias="node-gamma"]').getAttribute('data-node-pinned');
147
+ expect(alphaBefore).toBe('false');
148
+ expect(betaBefore).toBe('false');
149
+ expect(gammaBefore).toBe('false');
150
+
151
+ // Toggle pin on node-beta via the context menu (real user path —
152
+ // togglePin from ../lib/chat-pin). Right-click → ctx-pin.
153
+ await page.locator('[data-node-list-alias="node-beta"]').click({ button: 'right' });
154
+ await expect(page.locator('[data-testid="node-context-menu"]')).toBeVisible({ timeout: 3000 });
155
+ await page.locator('[data-testid="ctx-pin"]').click();
156
+ // Storage event → useSyncExternalStore rerender — allow a tick.
157
+ await page.waitForTimeout(200);
158
+
159
+ // 🔴 Core: node-beta flipped from 'false' → 'true'. If the attribute
160
+ // were hardcoded to the literal 'false' (which would pass the
161
+ // capability test above), THIS reds precisely.
162
+ const alphaAfter = await page.locator('[data-node-list-alias="node-alpha"]').getAttribute('data-node-pinned');
163
+ const betaAfter = await page.locator('[data-node-list-alias="node-beta"]').getAttribute('data-node-pinned');
164
+ const gammaAfter = await page.locator('[data-node-list-alias="node-gamma"]').getAttribute('data-node-pinned');
165
+ expect(betaAfter).toBe('true');
166
+ // Others must NOT have changed — sanity that the toggle scoped to
167
+ // the right alias (not e.g. accidentally pinning every row).
168
+ expect(alphaAfter).toBe('false');
169
+ expect(gammaAfter).toBe('false');
170
+ });
171
+ });