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

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 (258) 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 +97 -97
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/server/app/_global-error.html +1 -1
  8. package/.next/server/app/_global-error.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/_not-found.html +5 -5
  16. package/.next/server/app/_not-found.rsc +13 -13
  17. package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
  18. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  19. package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  24. package/.next/server/app/admin.html +5 -5
  25. package/.next/server/app/admin.rsc +15 -15
  26. package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
  27. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  28. package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
  29. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  30. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  31. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  32. package/.next/server/app/index.html +5 -5
  33. package/.next/server/app/index.rsc +15 -15
  34. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  35. package/.next/server/app/index.segments/_full.segment.rsc +15 -15
  36. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  37. package/.next/server/app/index.segments/_index.segment.rsc +8 -8
  38. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  39. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  40. package/.next/server/app/login.html +2 -2
  41. package/.next/server/app/login.rsc +15 -15
  42. package/.next/server/app/login.segments/_full.segment.rsc +15 -15
  43. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  44. package/.next/server/app/login.segments/_index.segment.rsc +8 -8
  45. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  46. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  47. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  48. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  49. package/.next/server/app/logs.html +5 -5
  50. package/.next/server/app/logs.rsc +15 -15
  51. package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
  52. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  53. package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
  54. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  55. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  56. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  57. package/.next/server/app/messages/page.js.nft.json +1 -1
  58. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  59. package/.next/server/app/messages.html +5 -5
  60. package/.next/server/app/messages.rsc +15 -15
  61. package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
  62. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  63. package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
  64. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  65. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  66. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  67. package/.next/server/app/node/page.js.nft.json +1 -1
  68. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  69. package/.next/server/app/node.html +5 -5
  70. package/.next/server/app/node.rsc +15 -15
  71. package/.next/server/app/node.segments/_full.segment.rsc +15 -15
  72. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  73. package/.next/server/app/node.segments/_index.segment.rsc +8 -8
  74. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  75. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  76. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  77. package/.next/server/app/nodes/[alias]/page.js.nft.json +1 -1
  78. package/.next/server/app/nodes/[alias]/page_client-reference-manifest.js +1 -1
  79. package/.next/server/app/nodes/page.js.nft.json +1 -1
  80. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  81. package/.next/server/app/nodes.html +5 -5
  82. package/.next/server/app/nodes.rsc +15 -15
  83. package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
  84. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  85. package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
  86. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  87. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  88. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  89. package/.next/server/app/page.js.nft.json +1 -1
  90. package/.next/server/app/page_client-reference-manifest.js +1 -1
  91. package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
  92. package/.next/server/app/providers.html +5 -5
  93. package/.next/server/app/providers.rsc +15 -15
  94. package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
  95. package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
  96. package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
  97. package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
  98. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
  99. package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
  100. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  101. package/.next/server/app/server-logs.html +5 -5
  102. package/.next/server/app/server-logs.rsc +15 -15
  103. package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
  104. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  105. package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
  106. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  107. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  108. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  109. package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
  110. package/.next/server/app/servers.html +5 -5
  111. package/.next/server/app/servers.rsc +15 -15
  112. package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
  113. package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
  114. package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
  115. package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
  116. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
  117. package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
  118. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  119. package/.next/server/app/settings/networks.html +5 -5
  120. package/.next/server/app/settings/networks.rsc +15 -15
  121. package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
  122. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  123. package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
  124. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  125. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  126. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  127. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  128. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  129. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  130. package/.next/server/app/settings/tokens.html +5 -5
  131. package/.next/server/app/settings/tokens.rsc +15 -15
  132. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
  133. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  134. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
  135. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  136. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  137. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  138. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  139. package/.next/server/app/settings.html +5 -5
  140. package/.next/server/app/settings.rsc +15 -15
  141. package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
  142. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  143. package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
  144. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  145. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  146. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  147. package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  148. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  149. package/.next/server/app/tasks/page.js.nft.json +1 -1
  150. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  151. package/.next/server/app/tasks.html +5 -5
  152. package/.next/server/app/tasks.rsc +15 -15
  153. package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
  154. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  155. package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
  156. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  157. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  158. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  159. package/.next/server/chunks/ssr/[root-of-the-server]__05z_qq6._.js +3 -0
  160. package/.next/server/chunks/ssr/[root-of-the-server]__05z_qq6._.js.map +1 -0
  161. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js +3 -0
  162. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js.map +1 -0
  163. package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js +3 -0
  164. package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js.map +1 -0
  165. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js +1 -1
  166. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js.map +1 -1
  167. package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js +3 -0
  168. package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js.map +1 -0
  169. package/.next/server/chunks/ssr/{dash-stageb-wt_app_0e7vp44._.js → dash-stageb-wt_app_045dcq5._.js} +2 -2
  170. package/.next/server/chunks/ssr/dash-stageb-wt_app_045dcq5._.js.map +1 -0
  171. package/.next/server/chunks/ssr/{dash-stageb-wt_app_0g~a.7i._.js → dash-stageb-wt_app_05u4_h~._.js} +8 -2
  172. package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js.map +1 -0
  173. package/.next/server/chunks/ssr/{dash-stageb-wt_app_0qpn85e._.js → dash-stageb-wt_app_0nuizh~._.js} +2 -2
  174. package/.next/server/chunks/ssr/dash-stageb-wt_app_0nuizh~._.js.map +1 -0
  175. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js +1 -1
  176. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js.map +1 -1
  177. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js +1 -1
  178. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js.map +1 -1
  179. package/.next/server/chunks/ssr/dash-stageb-wt_app_components_0vzsmx1._.js +5 -5
  180. package/.next/server/chunks/ssr/dash-stageb-wt_app_components_0vzsmx1._.js.map +1 -1
  181. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js +1 -1
  182. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js.map +1 -1
  183. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js +3 -0
  184. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js.map +1 -0
  185. package/.next/server/middleware-build-manifest.js +3 -3
  186. package/.next/server/pages/404.html +5 -5
  187. package/.next/server/pages/500.html +1 -1
  188. package/.next/server/server-reference-manifest.js +1 -1
  189. package/.next/server/server-reference-manifest.json +1 -1
  190. package/.next/static/chunks/02.5_nifak.uu.js +1 -0
  191. package/.next/static/chunks/03hzj129k~odb.js +1 -0
  192. package/.next/static/chunks/06q7-vhy~1-y3.js +7 -0
  193. package/.next/static/chunks/0_4ljrbp.stxy.js +1 -0
  194. package/.next/static/chunks/0cey4i.y0a8~j.js +1 -0
  195. package/.next/static/chunks/{0i8qbq4v47v3r.js → 0ep78sq90x_yc.js} +1 -1
  196. package/.next/static/chunks/{0_xyyy_x0k42j.js → 0hm52b0y~k0~z.js} +1 -1
  197. package/.next/static/chunks/0kncmrm2a_q43.js +1 -0
  198. package/.next/static/chunks/0mhaf6lrw5nn..css +1 -0
  199. package/.next/static/chunks/{12jbb28xy2hw..js → 0qf8xjihw3zi..js} +1 -1
  200. package/.next/static/chunks/0xyu92zdvvghl.js +7 -0
  201. package/.next/static/chunks/0zj_navpyhltx.js +2 -0
  202. package/.next/static/chunks/10px7ehmo7eq0.js +1 -0
  203. package/.next/static/chunks/{0tv-.l-_mw4ye.js → 11zz15q37~bz5.js} +1 -1
  204. package/.next/static/chunks/14y4x3de1lbug.js +1 -0
  205. package/.next/static/chunks/15ka68xi.w-i-.js +1 -0
  206. package/.next/static/chunks/17qkc714xy04..js +1 -0
  207. package/.next/trace +2 -2
  208. package/.next/trace-build +1 -1
  209. package/app/components/ChatPane.tsx +2 -2
  210. package/app/components/NodeList.tsx +40 -4
  211. package/app/components/Sidebar.tsx +180 -29
  212. package/app/components/TaskChatPanel.tsx +1 -1
  213. package/app/components/TaskDetail.tsx +407 -0
  214. package/app/components/TaskList.tsx +117 -0
  215. package/app/nodes/NodesView.tsx +28 -3
  216. package/app/tasks/TasksView.tsx +353 -0
  217. package/app/tasks/[id]/page.tsx +23 -176
  218. package/app/tasks/page.tsx +10 -453
  219. package/package.json +1 -1
  220. package/tests/e2e-sidebar-default.spec.ts +141 -0
  221. package/tests/e2e-sidebar-icon-rail.spec.ts +261 -0
  222. package/tests/e2e-stage-b-chat-pane.spec.ts +286 -0
  223. package/tests/e2e-stage-tasks-two-column.spec.ts +353 -0
  224. package/.next/server/chunks/ssr/[root-of-the-server]__0b-b6yh._.js +0 -3
  225. package/.next/server/chunks/ssr/[root-of-the-server]__0b-b6yh._.js.map +0 -1
  226. package/.next/server/chunks/ssr/[root-of-the-server]__0fs-j~c._.js +0 -3
  227. package/.next/server/chunks/ssr/[root-of-the-server]__0fs-j~c._.js.map +0 -1
  228. package/.next/server/chunks/ssr/[root-of-the-server]__0ma5-5u._.js +0 -3
  229. package/.next/server/chunks/ssr/[root-of-the-server]__0ma5-5u._.js.map +0 -1
  230. package/.next/server/chunks/ssr/[root-of-the-server]__0~hwpo-._.js +0 -3
  231. package/.next/server/chunks/ssr/[root-of-the-server]__0~hwpo-._.js.map +0 -1
  232. package/.next/server/chunks/ssr/dash-stageb-wt_app_095q0~q._.js +0 -3
  233. package/.next/server/chunks/ssr/dash-stageb-wt_app_095q0~q._.js.map +0 -1
  234. package/.next/server/chunks/ssr/dash-stageb-wt_app_0e7vp44._.js.map +0 -1
  235. package/.next/server/chunks/ssr/dash-stageb-wt_app_0g~a.7i._.js.map +0 -1
  236. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qpn85e._.js.map +0 -1
  237. package/.next/server/chunks/ssr/dash-stageb-wt_app_0tppjr_._.js +0 -9
  238. package/.next/server/chunks/ssr/dash-stageb-wt_app_0tppjr_._.js.map +0 -1
  239. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y764mt._.js +0 -3
  240. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y764mt._.js.map +0 -1
  241. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_page_tsx_0.fhtup._.js +0 -3
  242. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_page_tsx_0.fhtup._.js.map +0 -1
  243. package/.next/static/chunks/027yjbjm9afuk.js +0 -1
  244. package/.next/static/chunks/07wf61~wof.l~.js +0 -1
  245. package/.next/static/chunks/0ev252k-tf_aa.css +0 -1
  246. package/.next/static/chunks/0j-jjs3w89dei.js +0 -1
  247. package/.next/static/chunks/0ko39v-gezo7d.js +0 -1
  248. package/.next/static/chunks/0l8vf4q2t5x4d.js +0 -7
  249. package/.next/static/chunks/0xcazs6t8~l4q.js +0 -2
  250. package/.next/static/chunks/10sr651~7vm42.js +0 -7
  251. package/.next/static/chunks/13_i5b0qmko7z.js +0 -1
  252. package/.next/static/chunks/140suz13o9va6.js +0 -1
  253. package/.next/static/chunks/144cf7ds3_9~b.js +0 -1
  254. package/.next/static/chunks/17c4wlsbmmgk9.js +0 -1
  255. package/.next/static/chunks/17cfly-04z1j0.js +0 -1
  256. /package/.next/static/{HIKVEkFShWE481BxQDjwH → vc_aJHtYwefsVcl2_f14Z}/_buildManifest.js +0 -0
  257. /package/.next/static/{HIKVEkFShWE481BxQDjwH → vc_aJHtYwefsVcl2_f14Z}/_clientMiddlewareManifest.js +0 -0
  258. /package/.next/static/{HIKVEkFShWE481BxQDjwH → vc_aJHtYwefsVcl2_f14Z}/_ssgManifest.js +0 -0
@@ -0,0 +1,261 @@
1
+ import { test, expect, Page } from '@playwright/test';
2
+
3
+ const BASE = process.env.TEST_URL || 'http://localhost:3100';
4
+
5
+ // Sidebar 3rd mode ('icon-rail', Feishu-style) — 3 tests per 通信龙 brief:
6
+ // 1. Renders correctly (mode attr + width class + no text + aria-labels)
7
+ // 2. Toggle cycles expanded → collapsed → icon-rail → expanded AND
8
+ // localStorage persists across a fresh page context
9
+ // 3. Unread badge appears on the correct nav item when there are
10
+ // unread messages (data pulled from mocked /api/hub/messages —
11
+ // useChatUnread's real data path)
12
+ //
13
+ // NOT touched here — #492 / Stage A / Stage B tests. This file is
14
+ // scoped to Sidebar only; if any assertion needs an existing testid,
15
+ // it uses the ones already declared on the Sidebar element itself.
16
+
17
+ // Login cache — the dashboard's rate-limit is 10 logins / 15 min / IP
18
+ // (通信龙 07-31). Every test in this file logs in once per worker, and
19
+ // subsequent tests reuse the cached cookie value via context.addCookies.
20
+ // A per-file suite of 4 tests × 1 login each was fine on cold cache but
21
+ // combined with rerun-during-dev-iteration it walked us straight into
22
+ // the ceiling. Module-level var scopes per-Playwright-worker (each
23
+ // worker = fresh Node process), so parallel workers still each get one
24
+ // login. Cap workers via CLI to avoid parallel bursts.
25
+ let cachedCookie: string | undefined;
26
+
27
+ async function login(context: Page['context'] extends () => infer C ? C : never) {
28
+ if (cachedCookie) {
29
+ await context.addCookies([{
30
+ name: 'anet_dashboard_session',
31
+ value: cachedCookie,
32
+ domain: new URL(BASE).hostname,
33
+ path: '/',
34
+ }]);
35
+ return;
36
+ }
37
+ const res = await context.request.post(`${BASE}/api/auth/login`, {
38
+ data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
39
+ });
40
+ const setCookie = res.headers()['set-cookie'] || '';
41
+ const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
42
+ if (m) {
43
+ cachedCookie = decodeURIComponent(m[1]);
44
+ await context.addCookies([{
45
+ name: 'anet_dashboard_session',
46
+ value: cachedCookie,
47
+ domain: new URL(BASE).hostname,
48
+ path: '/',
49
+ }]);
50
+ }
51
+ }
52
+
53
+ async function stubHub(page: Page, messages: object[] = []) {
54
+ // Minimum surface — Sidebar itself doesn't need most of these but
55
+ // pages it's mounted next to might, and we want the page to render
56
+ // past the loading skeleton so the sidebar is measurable.
57
+ await page.route('**/api/hub/status**', r =>
58
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ sessions: [] }) }));
59
+ await page.route('**/api/hub/nodes**', r =>
60
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ nodes: [] }) }));
61
+ await page.route('**/api/hub/networks**', r =>
62
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ networks: [{ network_id: 'net_x', network_name: 'net_x' }] }) }));
63
+ await page.route('**/api/hub/health', r =>
64
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, version: '0.9.0-test', sse_connections: 0, sse_sessions: {} }) }));
65
+ await page.route('**/api/hub/tasks**', r =>
66
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: [] }) }));
67
+ await page.route('**/api/hub/stats**', r =>
68
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }));
69
+ await page.route('**/api/hub/messages**', r =>
70
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages }) }));
71
+ }
72
+
73
+ test.describe('Sidebar icon-rail (3rd mode)', () => {
74
+ test('icon-rail mode renders w-14 + no text labels + aria-labels on every nav item', async ({ page, context }) => {
75
+ await login(context);
76
+ await stubHub(page);
77
+ // Pre-set mode BEFORE navigation so we test the render path
78
+ // straight into icon-rail, not the cycle path.
79
+ await page.addInitScript(() => window.localStorage.setItem('anet-sidebar-mode', 'icon-rail'));
80
+ await page.goto(`${BASE}/`);
81
+ // Sidebar always renders; the mode attribute is the switch.
82
+ await expect(page.locator('[data-anet-sidebar="true"]')).toHaveAttribute('data-sidebar-mode', 'icon-rail', { timeout: 15000 });
83
+ // Width — Tailwind w-14 = 3.5rem = 56px at default root size.
84
+ // Assert numerically rather than by class to survive class-name
85
+ // refactors.
86
+ const w = await page.locator('[data-anet-sidebar="true"]').first().evaluate(el => el.getBoundingClientRect().width);
87
+ expect(w).toBeGreaterThanOrEqual(48);
88
+ expect(w).toBeLessThanOrEqual(64);
89
+ // No visible text labels on nav items — icon-only. Test asserts
90
+ // that the aria-label carries semantic name (accessibility) while
91
+ // the visible text of each item is empty/whitespace.
92
+ const navItems = page.locator('[data-nav-item][data-nav-href]');
93
+ const count = await navItems.count();
94
+ expect(count).toBeGreaterThanOrEqual(6); // Overview/Tasks/Nodes/Servers/Providers/Admin at minimum
95
+ for (let i = 0; i < count; i++) {
96
+ const item = navItems.nth(i);
97
+ const aria = await item.getAttribute('aria-label');
98
+ expect(aria, `nav item #${i} must have aria-label`).toBeTruthy();
99
+ const visibleText = (await item.innerText()).trim();
100
+ // "innerText" for a link with only an <svg> child returns empty
101
+ // or newline — assert it's not the visible label text.
102
+ expect(visibleText.length).toBeLessThanOrEqual(3); // '', or a badge digit
103
+ }
104
+ });
105
+
106
+ test('cycle toggle: expanded → collapsed → icon-rail → expanded, and localStorage updates', async ({ page, context }) => {
107
+ await login(context);
108
+ await stubHub(page);
109
+ // Seed the starting point explicitly instead of relying on the
110
+ // default. 07-31 flipped the default from 'expanded' to
111
+ // 'icon-rail' — this test asserts the CYCLE mechanic (walks the
112
+ // three modes and updates localStorage), not the default choice,
113
+ // so we seed 'expanded' up front to keep the assertion sequence
114
+ // stable across default flips.
115
+ await page.addInitScript(() => window.localStorage.setItem('anet-sidebar-mode', 'expanded'));
116
+ await page.goto(`${BASE}/`);
117
+ await expect(page.locator('[data-anet-sidebar="true"]')).toHaveAttribute('data-sidebar-mode', 'expanded', { timeout: 15000 });
118
+
119
+ // Click 1 → collapsed
120
+ await page.locator('[data-testid="sidebar-mode-cycle"]').click();
121
+ await expect(page.locator('[data-anet-sidebar="true"]')).toHaveAttribute('data-sidebar-mode', 'collapsed');
122
+ expect(await page.evaluate(() => window.localStorage.getItem('anet-sidebar-mode'))).toBe('collapsed');
123
+
124
+ // Click 2 → icon-rail
125
+ await page.locator('[data-testid="sidebar-mode-cycle"]').click();
126
+ await expect(page.locator('[data-anet-sidebar="true"]')).toHaveAttribute('data-sidebar-mode', 'icon-rail');
127
+ expect(await page.evaluate(() => window.localStorage.getItem('anet-sidebar-mode'))).toBe('icon-rail');
128
+
129
+ // Click 3 → back to expanded (cycle wraps)
130
+ await page.locator('[data-testid="sidebar-mode-cycle"]').click();
131
+ await expect(page.locator('[data-anet-sidebar="true"]')).toHaveAttribute('data-sidebar-mode', 'expanded');
132
+ expect(await page.evaluate(() => window.localStorage.getItem('anet-sidebar-mode'))).toBe('expanded');
133
+ });
134
+
135
+ test('unread badge appears on /nodes nav only in icon-rail mode, and matches badgeLabel(totalUnread)', async ({ page, context }) => {
136
+ await login(context);
137
+ // 3 unread messages from a non-self alias — useChatUnread reads
138
+ // /api/hub/messages, filters out self-sent and already-read
139
+ // (localStorage is empty → nothing read yet), then sums into
140
+ // totalUnread. Fresh localStorage means all 3 count.
141
+ // useChatUnread uses from_alias / to_alias (NOT from_session), and
142
+ // the message MUST be addressed TO a userish name ('admin' /
143
+ // 'dashboard' / '') and FROM a non-userish alias — this is the
144
+ // "message for the current operator, not fleet-chatter" filter.
145
+ //
146
+ // 🔴 Also: readStoredMap auto-seeds a `__baseline` = new Date() on
147
+ // first read (chat-unread.ts:55-63 — synchronous "first-visit
148
+ // floor" to prevent a badge storm on first login). If our mock
149
+ // messages have `created_at <= baseline`, they'd be treated as
150
+ // "already read". So future-date them by 1 minute — well past the
151
+ // baseline set during hook init.
152
+ const futureIso = new Date(Date.now() + 60_000).toISOString();
153
+ const msgs = [
154
+ { id: 'm1', from_alias: 'peer-a', to_alias: 'admin', created_at: futureIso, content: 'ping 1' },
155
+ { id: 'm2', from_alias: 'peer-b', to_alias: 'admin', created_at: futureIso, content: 'ping 2' },
156
+ { id: 'm3', from_alias: 'peer-a', to_alias: 'admin', created_at: futureIso, content: 'ping 3' },
157
+ ];
158
+ await stubHub(page, msgs);
159
+ await page.addInitScript(() => {
160
+ window.localStorage.setItem('anet-sidebar-mode', 'icon-rail');
161
+ // Clear any stray chat-read baselines so all mock messages count.
162
+ Object.keys(window.localStorage)
163
+ .filter(k => k.startsWith('anet_chat_read_v1:'))
164
+ .forEach(k => window.localStorage.removeItem(k));
165
+ });
166
+ await page.goto(`${BASE}/`);
167
+ await expect(page.locator('[data-anet-sidebar="true"]')).toHaveAttribute('data-sidebar-mode', 'icon-rail', { timeout: 15000 });
168
+
169
+ // Give the SWR fetch + unread computation a moment.
170
+ await page.waitForTimeout(1500);
171
+
172
+ // Badge attribute on the /nodes nav item — count as data-badge.
173
+ // Also assert the visible red pill exists with the right label.
174
+ const nodesNav = page.locator('[data-nav-item][data-nav-href="/nodes"]');
175
+ const badgeAttr = await nodesNav.getAttribute('data-badge');
176
+ expect(badgeAttr, 'nodes nav should carry data-badge in icon-rail with unread > 0').toBe('3');
177
+ const badge = nodesNav.locator('[data-nav-badge]');
178
+ await expect(badge).toHaveText('3');
179
+
180
+ // Sibling assertion: an entry with NO unread (e.g. /admin) MUST
181
+ // NOT carry the data-badge attribute. This is the exclusion side
182
+ // of the coverage — without it, a bug that puts the badge on all
183
+ // items would pass the badge=3 check silently.
184
+ const adminNav = page.locator('[data-nav-item][data-nav-href="/admin"]');
185
+ expect(await adminNav.getAttribute('data-badge')).toBeNull();
186
+ expect(await adminNav.locator('[data-nav-badge]').count()).toBe(0);
187
+ });
188
+
189
+ // 通信龙 07-31 round-1 feedback — "断言'名字文本不可见'是弱的,组件
190
+ // 整个没渲染出来那条也会绿。必配同源正向断言 (feedback_silent_
191
+ // failure_needs_independent_review)." This test is that companion:
192
+ // in EXPANDED mode the labels ARE visible with the exact text
193
+ // ("Overview" / "Tasks" / ...) AND the sidebar is measurably WIDER
194
+ // than icon-rail. Without this, test 1 could pass on a bug where
195
+ // the whole nav failed to mount (0 items = 0 visible labels = pass).
196
+ test('symmetry — expanded shows labels visibly, icon-rail hides them, widths ordered, item count preserved', async ({ page, context }) => {
197
+ await login(context);
198
+ await stubHub(page);
199
+
200
+ // Round 1 — start EXPANDED, count items + assert labels visible.
201
+ await page.addInitScript(() => window.localStorage.setItem('anet-sidebar-mode', 'expanded'));
202
+ await page.goto(`${BASE}/`);
203
+ await expect(page.locator('[data-anet-sidebar="true"]')).toHaveAttribute('data-sidebar-mode', 'expanded', { timeout: 15000 });
204
+
205
+ const expandedWidth = await page.locator('[data-anet-sidebar="true"]').first().evaluate(el => el.getBoundingClientRect().width);
206
+ const expandedItemCount = await page.locator('[data-nav-item]').count();
207
+ // Sanity — expected 7 items in NAV_ITEMS (Overview through Settings).
208
+ expect(expandedItemCount).toBeGreaterThanOrEqual(6);
209
+ // Positive label visibility — at least one KNOWN label must be
210
+ // present in the visible text of its nav item. This kills the
211
+ // "nav failed to mount" false green.
212
+ const overviewNav = page.locator('[data-nav-item][data-nav-href="/"]');
213
+ await expect(overviewNav).toContainText('Overview');
214
+ const tasksNav = page.locator('[data-nav-item][data-nav-href="/tasks"]');
215
+ await expect(tasksNav).toContainText('Tasks');
216
+
217
+ // Round 2 — cycle to icon-rail via the toggle button (2 clicks:
218
+ // expanded → collapsed → icon-rail). page.reload() would re-run
219
+ // addInitScript and clobber the localStorage we set, so we use
220
+ // in-place mode changes instead.
221
+ await page.locator('[data-testid="sidebar-mode-cycle"]').click();
222
+ await page.locator('[data-testid="sidebar-mode-cycle"]').click();
223
+ await expect(page.locator('[data-anet-sidebar="true"]')).toHaveAttribute('data-sidebar-mode', 'icon-rail', { timeout: 15000 });
224
+
225
+ const railWidth = await page.locator('[data-anet-sidebar="true"]').first().evaluate(el => el.getBoundingClientRect().width);
226
+ const railItemCount = await page.locator('[data-nav-item]').count();
227
+ // Transitivity: same nav items exist, just collapsed. Kills the
228
+ // "icon-rail dropped nav items" bug that would otherwise render
229
+ // as "text absent" (which the "no visible text" assertion would
230
+ // spuriously interpret as success).
231
+ expect(railItemCount).toBe(expandedItemCount);
232
+ // Width delta must be REAL — >100px difference is the min anyone
233
+ // could reasonably call "narrower". Numeric assertion so a bug
234
+ // that leaves the class but breaks the width still reds.
235
+ expect(expandedWidth - railWidth).toBeGreaterThan(100);
236
+ // 🔴 Counting assertion FIRST (通信龙 07-31 R3 feedback: witnessed-
237
+ // red should show numbers, not just "expected true got false").
238
+ // Count how many nav items in icon-rail mode have any of the
239
+ // known labels visible in their rendered innerText.
240
+ // correct value = 0
241
+ // mutation "labels leak back" = N (== nav item count)
242
+ // Fires BEFORE the substring assertion below so a "labels leaked"
243
+ // regression reds here with the exact leak count, not with a
244
+ // substring hit that hides the magnitude.
245
+ const KNOWN_LABELS = ['Overview', 'Tasks', 'Nodes', 'Servers', 'Providers', 'Admin', 'Settings'];
246
+ const visibleLabelCount = await page.locator('[data-nav-item]').evaluateAll((nodes, labels) => {
247
+ return nodes.filter(n => {
248
+ const text = (n as HTMLElement).innerText.trim();
249
+ return (labels as string[]).some(l => text.includes(l));
250
+ }).length;
251
+ }, KNOWN_LABELS);
252
+ expect(visibleLabelCount).toBe(0);
253
+
254
+ // Substring narrow-check on a single well-known item — same
255
+ // invariant, redundant but useful signal on which item leaked.
256
+ const nodesNav = page.locator('[data-nav-item][data-nav-href="/nodes"]');
257
+ const visibleText = (await nodesNav.innerText()).trim();
258
+ expect(visibleText).not.toContain('Nodes');
259
+ expect(await nodesNav.getAttribute('aria-label')).toBe('Nodes');
260
+ });
261
+ });
@@ -221,3 +221,289 @@ test.describe('Stage B — persistent chat pane + /nodes/[alias] routing', () =>
221
221
  await expect(page.locator('[data-node-card]')).toHaveCount(0);
222
222
  });
223
223
  });
224
+
225
+ // ─────────────────────────────────────────────────────────────────────
226
+ // Stage D — the page never scrolls; each column scrolls itself.
227
+ // 通信龙's production measurement after Stage C: page height 11806px,
228
+ // rail 11775px (199 rows, no own scroll), chat-pane sticky top=-791px —
229
+ // sticky referenced ancestors with overflow auto while the DOCUMENT was
230
+ // the actual scroller, so the pane scrolled away and the chat looked
231
+ // dead. Fix under test: root h-[100dvh] overflow-hidden, rail + middle
232
+ // + chat each overflow-y-auto, sticky gone. Wide fixture (60 nodes) so
233
+ // the assertions actually discriminate — with 3 nodes the page was
234
+ // never tall enough to fail.
235
+ // Plus SPEC.md §2 column base colors: list #0F1320, chat #141826,
236
+ // 1px rgba(255,255,255,0.06) between them.
237
+ // ─────────────────────────────────────────────────────────────────────
238
+ const WIDE_N = 60;
239
+ const WIDE_STATUS = {
240
+ sessions: Array.from({ length: WIDE_N }, (_, i) => ({
241
+ alias: `stage-d-${i + 1}`, status: 'idle', network_id: 'net_x',
242
+ })),
243
+ };
244
+ const WIDE_NODES = {
245
+ nodes: Array.from({ length: WIDE_N }, (_, i) => ({
246
+ alias: `stage-d-${i + 1}`, team: null, tags: [],
247
+ })),
248
+ };
249
+ const WIDE_HEALTH = {
250
+ ok: true, version: '0.9.0-test', sse_connections: WIDE_N,
251
+ sse_sessions: Object.fromEntries(
252
+ Array.from({ length: WIDE_N }, (_, i) => [`net_x:stage-d-${i + 1}`, 1]),
253
+ ),
254
+ };
255
+
256
+ async function mockWide(page: Page) {
257
+ await page.route('**/api/hub/status**', r =>
258
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(WIDE_STATUS) }));
259
+ await page.route('**/api/hub/nodes**', r =>
260
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(WIDE_NODES) }));
261
+ await page.route('**/api/hub/health', r =>
262
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(WIDE_HEALTH) }));
263
+ await page.route('**/api/hub/tasks**', r =>
264
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: [] }) }));
265
+ await page.route('**/api/hub/stats**', r =>
266
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }));
267
+ await page.route('**/api/hub/messages**', r =>
268
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
269
+ }
270
+
271
+ test.describe('Stage D — per-column scrolling, page never scrolls', () => {
272
+ test('selected + 60 nodes: document does not scroll; rail scrolls to the last node; pane stays put; composer visible', async ({ page, context }) => {
273
+ await login(context);
274
+ await mockWide(page);
275
+ await page.goto(`${BASE}/nodes/stage-d-1`);
276
+ await expect(pane(page)).toBeVisible({ timeout: 15000 });
277
+ await expect(page.locator('[data-node-list-item]')).toHaveCount(WIDE_N);
278
+
279
+ // 1. The DOCUMENT is not the scroller: page height ≈ viewport height.
280
+ const { scrollH, innerH } = await page.evaluate(() => ({
281
+ scrollH: document.documentElement.scrollHeight, innerH: window.innerHeight,
282
+ }));
283
+ expect(scrollH).toBeLessThanOrEqual(innerH + 2);
284
+
285
+ // Capture the pane's resting position BEFORE any rail scrolling —
286
+ // the invariant is "does not move", not "sits at literal 0": the
287
+ // AppShell HealthBanner keeps ~28px in flow above the page, so the
288
+ // pane's top equals the banner height, and asserting a hardcoded 0
289
+ // would encode the banner out of existence.
290
+ const paneTop0 = await pane(page).evaluate(el => el.getBoundingClientRect().top);
291
+ expect(paneTop0).toBeGreaterThanOrEqual(0);
292
+ expect(paneTop0).toBeLessThanOrEqual(40);
293
+
294
+ // 2. The RAIL is a real scroller (positive direction: it genuinely
295
+ // overflows — otherwise "can scroll" would be vacuous)…
296
+ const railScroll = page.locator('[data-testid="node-list-scroll"]');
297
+ const { railSH, railCH } = await railScroll.evaluate(el => ({ railSH: el.scrollHeight, railCH: el.clientHeight }));
298
+ expect(railSH).toBeGreaterThan(railCH + 200);
299
+ // …and it reaches the LAST node (通信龙 判据: don't swap "page can't
300
+ // scroll" in for "rail can't scroll either").
301
+ await railScroll.evaluate(el => { el.scrollTop = el.scrollHeight; });
302
+ await expect(page.locator(`[data-node-list-item][data-node-list-alias="stage-d-${WIDE_N}"]`)).toBeInViewport();
303
+
304
+ // 3. With the rail scrolled to the bottom, the chat pane has not moved.
305
+ const paneTop = await pane(page).evaluate(el => el.getBoundingClientRect().top);
306
+ expect(paneTop).toBe(paneTop0);
307
+
308
+ // 4. Composer stays inside the viewport.
309
+ const box = await pane(page).locator('textarea').boundingBox();
310
+ expect(box).not.toBeNull();
311
+ expect(box!.y + box!.height).toBeLessThanOrEqual(innerH);
312
+ });
313
+
314
+ test('unselected + 60 nodes: document still does not scroll; the MIDDLE column scrolls to the last card', async ({ page, context }) => {
315
+ await login(context);
316
+ await mockWide(page);
317
+ await page.goto(`${BASE}/nodes`);
318
+ await expect(page.locator('[data-node-card]')).toHaveCount(WIDE_N, { timeout: 15000 });
319
+
320
+ const { scrollH, innerH } = await page.evaluate(() => ({
321
+ scrollH: document.documentElement.scrollHeight, innerH: window.innerHeight,
322
+ }));
323
+ expect(scrollH).toBeLessThanOrEqual(innerH + 2);
324
+
325
+ // The table view must still reach node 60 via its own scroller —
326
+ // the same both-directions discipline as Stage C.
327
+ const last = page.locator(`[data-node-card][data-node-alias="stage-d-${WIDE_N}"]`);
328
+ await last.scrollIntoViewIfNeeded();
329
+ await expect(last).toBeInViewport();
330
+ });
331
+
332
+ test('SPEC §2 colors: list #0F1320, chat #141826, 1px rgba(255,255,255,0.06) between', async ({ page, context }) => {
333
+ await login(context);
334
+ await mockCommon(page);
335
+ await page.goto(`${BASE}/nodes/stage-b-1`);
336
+ await expect(pane(page)).toBeVisible({ timeout: 15000 });
337
+
338
+ const railBg = await page.locator('[data-node-list-rail]').evaluate(el => getComputedStyle(el).backgroundColor);
339
+ expect(railBg).toBe('rgb(15, 19, 32)'); // #0F1320
340
+ const paneBg = await pane(page).evaluate(el => getComputedStyle(el).backgroundColor);
341
+ expect(paneBg).toBe('rgb(20, 24, 38)'); // #141826
342
+ const railBorder = await page.locator('[data-node-list-rail]').evaluate(el => {
343
+ const cs = getComputedStyle(el);
344
+ return { color: cs.borderRightColor, width: cs.borderRightWidth };
345
+ });
346
+ expect(railBorder.width).toBe('1px');
347
+ expect(railBorder.color).toBe('rgba(255, 255, 255, 0.06)');
348
+ // Exactly ONE line between list and chat: the pane must not add its own left border.
349
+ const paneLeft = await pane(page).evaluate(el => getComputedStyle(el).borderLeftWidth);
350
+ expect(paneLeft).toBe('0px');
351
+ });
352
+ });
353
+
354
+ // ─────────────────────────────────────────────────────────────────────
355
+ // Stage D ③ (Vincent's red box) — rail-top node search, persistent in
356
+ // the SELECTED state (where the middle table + its magnifier search no
357
+ // longer render, so without this the user couldn't search at all).
358
+ // Backed by the same search state + pinyinMatch chain as the table.
359
+ // ─────────────────────────────────────────────────────────────────────
360
+ const PAY_ALIAS = '支付助手';
361
+ const SEARCH_STATUS = {
362
+ sessions: [
363
+ { alias: PAY_ALIAS, status: 'idle', network_id: 'net_x' },
364
+ { alias: 'stage-e-1', status: 'idle', network_id: 'net_x' },
365
+ { alias: 'stage-e-2', status: 'working', network_id: 'net_x' },
366
+ ],
367
+ };
368
+ const SEARCH_NODES = { nodes: SEARCH_STATUS.sessions.map(s => ({ alias: s.alias, team: null, tags: [] })) };
369
+ const SEARCH_HEALTH = {
370
+ ok: true, version: '0.9.0-test', sse_connections: 3,
371
+ sse_sessions: Object.fromEntries(SEARCH_STATUS.sessions.map(s => [`net_x:${s.alias}`, 1])),
372
+ };
373
+
374
+ async function mockSearch(page: Page) {
375
+ await page.route('**/api/hub/status**', r =>
376
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SEARCH_STATUS) }));
377
+ await page.route('**/api/hub/nodes**', r =>
378
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SEARCH_NODES) }));
379
+ await page.route('**/api/hub/health', r =>
380
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SEARCH_HEALTH) }));
381
+ await page.route('**/api/hub/tasks**', r =>
382
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: [] }) }));
383
+ await page.route('**/api/hub/stats**', r =>
384
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }));
385
+ await page.route('**/api/hub/messages**', r =>
386
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
387
+ }
388
+
389
+ test.describe('Stage D ③ — rail-top node search', () => {
390
+ const items = (page: Page) => page.locator('[data-node-list-item]');
391
+ const railSearch = (page: Page) => page.locator('[data-testid="rail-search"]');
392
+
393
+ test('search box exists in the SELECTED state and filters by pinyin initials ("zf" → 支付助手)', async ({ page, context }) => {
394
+ await login(context);
395
+ await mockSearch(page);
396
+ await page.goto(`${BASE}/nodes/stage-e-1`);
397
+ await expect(pane(page)).toBeVisible({ timeout: 15000 });
398
+ // The whole point: the table (and its magnifier search) is gone here,
399
+ // but the rail search is present.
400
+ await expect(page.locator('[data-node-card]')).toHaveCount(0);
401
+ await expect(railSearch(page)).toBeVisible();
402
+
403
+ await railSearch(page).fill('zf');
404
+ await expect(items(page)).toHaveCount(1, { timeout: 10000 }); // pinyin dict loads lazily
405
+ await expect(page.locator(`[data-node-list-item][data-node-list-alias="${PAY_ALIAS}"]`)).toBeVisible();
406
+
407
+ await railSearch(page).fill('');
408
+ await expect(items(page)).toHaveCount(3);
409
+ });
410
+
411
+ test('filters by Chinese substring ("支付" → 支付助手)', async ({ page, context }) => {
412
+ await login(context);
413
+ await mockSearch(page);
414
+ await page.goto(`${BASE}/nodes/stage-e-1`);
415
+ await expect(railSearch(page)).toBeVisible({ timeout: 15000 });
416
+
417
+ await railSearch(page).fill('支付');
418
+ await expect(items(page)).toHaveCount(1, { timeout: 10000 });
419
+ await expect(page.locator(`[data-node-list-item][data-node-list-alias="${PAY_ALIAS}"]`)).toBeVisible();
420
+ });
421
+
422
+ test('no-match search shows count 0 PLUS an empty-state hint echoing the query', async ({ page, context }) => {
423
+ await login(context);
424
+ await mockSearch(page);
425
+ await page.goto(`${BASE}/nodes/stage-e-1`);
426
+ await expect(railSearch(page)).toBeVisible({ timeout: 15000 });
427
+
428
+ await railSearch(page).fill('zzz-no-such-node-999');
429
+ await expect(items(page)).toHaveCount(0);
430
+ // 通信龙 判据: "search broke and returned nothing" must be
431
+ // distinguishable from "genuinely no match" — the hint echoes the query.
432
+ await expect(page.locator('[data-testid="rail-search-empty"]')).toBeVisible();
433
+ await expect(page.locator('[data-testid="rail-search-empty"]')).toContainText('zzz-no-such-node-999');
434
+ });
435
+
436
+ test('unselected state: rail search filters rail AND table together (Stage A invariant holds)', async ({ page, context }) => {
437
+ await login(context);
438
+ await mockSearch(page);
439
+ await page.goto(`${BASE}/nodes`);
440
+ await expect(page.locator('[data-node-card]')).toHaveCount(3, { timeout: 15000 });
441
+
442
+ await railSearch(page).fill('支付');
443
+ await expect(items(page)).toHaveCount(1, { timeout: 10000 });
444
+ await expect(page.locator('[data-node-card]')).toHaveCount(1);
445
+ await expect(page.locator(`[data-node-card][data-node-alias="${PAY_ALIAS}"]`)).toBeVisible();
446
+ });
447
+ });
448
+
449
+ // ─────────────────────────────────────────────────────────────────────
450
+ // Stage D — Vincent's exact scroll spec, BOTH halves pinned:
451
+ // "右侧不能上下滑动" = no PAGE-level scroll from the chat column. It
452
+ // does NOT mean the message history can't scroll — that would delete
453
+ // the feature (and "page doesn't scroll" assertions alone would stay
454
+ // green for that regression, 通信龙 判据). So this test loads enough
455
+ // messages to overflow and asserts: page fixed + column fixed + the
456
+ // MESSAGE AREA scrolls internally + composer never leaves the viewport.
457
+ // ─────────────────────────────────────────────────────────────────────
458
+ test.describe('Stage D — chat column fixed, message area scrolls inside', () => {
459
+ test('30 messages: page & pane do not move; history scrolls to the earliest message; composer stays visible', async ({ page, context }) => {
460
+ await login(context);
461
+ const tasks = Array.from({ length: 30 }, (_, i) => ({
462
+ task_id: `t-${i + 1}`,
463
+ from_name: 'dashboard',
464
+ to_name: 'stage-e-1',
465
+ status: 'replied',
466
+ priority: 'normal',
467
+ content: `chat msg ${i + 1}`,
468
+ result: `reply ${i + 1}`,
469
+ created_at: `2026-07-31 10:${String(10 + Math.floor(i / 2)).padStart(2, '0')}:${String((i % 2) * 30).padStart(2, '0')}`,
470
+ }));
471
+ await page.route('**/api/hub/status**', r =>
472
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SEARCH_STATUS) }));
473
+ await page.route('**/api/hub/nodes**', r =>
474
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SEARCH_NODES) }));
475
+ await page.route('**/api/hub/health', r =>
476
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SEARCH_HEALTH) }));
477
+ await page.route('**/api/hub/tasks**', r =>
478
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks }) }));
479
+ await page.route('**/api/hub/stats**', r =>
480
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }));
481
+ await page.route('**/api/hub/messages**', r =>
482
+ r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) }));
483
+
484
+ await page.goto(`${BASE}/nodes/stage-e-1`);
485
+ await expect(pane(page)).toBeVisible({ timeout: 15000 });
486
+ const ms = page.locator('[data-testid="chat-messages-scroll"]');
487
+ await expect(ms.getByText('chat msg 30').first()).toBeVisible({ timeout: 15000 });
488
+
489
+ // Page level: nothing scrolls.
490
+ const { scrollH, innerH } = await page.evaluate(() => ({
491
+ scrollH: document.documentElement.scrollHeight, innerH: window.innerHeight,
492
+ }));
493
+ expect(scrollH).toBeLessThanOrEqual(innerH + 2);
494
+ const paneTop0 = await pane(page).evaluate(el => el.getBoundingClientRect().top);
495
+
496
+ // Message area level: it DOES scroll (positive half — it genuinely overflows)…
497
+ const { msSH, msCH } = await ms.evaluate(el => ({ msSH: el.scrollHeight, msCH: el.clientHeight }));
498
+ expect(msSH).toBeGreaterThan(msCH + 200);
499
+ // …and scrolling to the top reveals the EARLIEST message…
500
+ await ms.evaluate(el => { el.scrollTop = 0; });
501
+ await expect(ms.getByText('chat msg 1').first()).toBeVisible();
502
+ // …while the column itself has not moved and the composer is still on screen.
503
+ const paneTop1 = await pane(page).evaluate(el => el.getBoundingClientRect().top);
504
+ expect(paneTop1).toBe(paneTop0);
505
+ const box = await pane(page).locator('textarea').boundingBox();
506
+ expect(box).not.toBeNull();
507
+ expect(box!.y + box!.height).toBeLessThanOrEqual(innerH);
508
+ });
509
+ });