@sleep2agi/agent-network-dashboard 0.6.3-preview.51 → 0.6.3-preview.53

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 (244) 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 +48 -48
  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.js.nft.json +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +2 -2
  17. package/.next/server/app/_not-found.rsc +13 -13
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
  19. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  20. package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  23. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/server/app/admin/page.js.nft.json +1 -1
  25. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  26. package/.next/server/app/admin.html +2 -2
  27. package/.next/server/app/admin.rsc +15 -15
  28. package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
  29. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  30. package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
  31. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  32. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  33. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  34. package/.next/server/app/index.html +2 -2
  35. package/.next/server/app/index.rsc +15 -15
  36. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  37. package/.next/server/app/index.segments/_full.segment.rsc +15 -15
  38. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  39. package/.next/server/app/index.segments/_index.segment.rsc +8 -8
  40. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  41. package/.next/server/app/login/page.js.nft.json +1 -1
  42. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  43. package/.next/server/app/login.html +2 -2
  44. package/.next/server/app/login.rsc +15 -15
  45. package/.next/server/app/login.segments/_full.segment.rsc +15 -15
  46. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  47. package/.next/server/app/login.segments/_index.segment.rsc +8 -8
  48. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  49. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  50. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  51. package/.next/server/app/logs/page.js.nft.json +1 -1
  52. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  53. package/.next/server/app/logs.html +2 -2
  54. package/.next/server/app/logs.rsc +15 -15
  55. package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
  56. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  57. package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
  58. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  59. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  60. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  61. package/.next/server/app/messages/page.js.nft.json +1 -1
  62. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  63. package/.next/server/app/messages.html +2 -2
  64. package/.next/server/app/messages.rsc +15 -15
  65. package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
  66. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  67. package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
  68. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  69. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  70. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  71. package/.next/server/app/node/page.js.nft.json +1 -1
  72. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  73. package/.next/server/app/node.html +2 -2
  74. package/.next/server/app/node.rsc +15 -15
  75. package/.next/server/app/node.segments/_full.segment.rsc +15 -15
  76. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  77. package/.next/server/app/node.segments/_index.segment.rsc +8 -8
  78. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  79. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  80. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  81. package/.next/server/app/nodes/[alias]/page.js.nft.json +1 -1
  82. package/.next/server/app/nodes/[alias]/page_client-reference-manifest.js +1 -1
  83. package/.next/server/app/nodes/page.js.nft.json +1 -1
  84. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/nodes.html +2 -2
  86. package/.next/server/app/nodes.rsc +15 -15
  87. package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
  88. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  89. package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
  90. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  91. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  92. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  93. package/.next/server/app/page.js.nft.json +1 -1
  94. package/.next/server/app/page_client-reference-manifest.js +1 -1
  95. package/.next/server/app/providers/page.js.nft.json +1 -1
  96. package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
  97. package/.next/server/app/providers.html +2 -2
  98. package/.next/server/app/providers.rsc +15 -15
  99. package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
  100. package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
  101. package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
  102. package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
  103. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
  104. package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
  105. package/.next/server/app/server-logs/page.js.nft.json +1 -1
  106. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  107. package/.next/server/app/server-logs.html +2 -2
  108. package/.next/server/app/server-logs.rsc +15 -15
  109. package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
  110. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  111. package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
  112. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  113. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  114. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  115. package/.next/server/app/servers/page.js.nft.json +1 -1
  116. package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
  117. package/.next/server/app/servers.html +2 -2
  118. package/.next/server/app/servers.rsc +15 -15
  119. package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
  120. package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
  121. package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
  122. package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
  123. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
  124. package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
  125. package/.next/server/app/settings/networks/page.js.nft.json +1 -1
  126. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  127. package/.next/server/app/settings/networks.html +2 -2
  128. package/.next/server/app/settings/networks.rsc +15 -15
  129. package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
  130. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  131. package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
  132. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  133. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  134. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  135. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  136. package/.next/server/app/settings/page.js.nft.json +1 -1
  137. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  138. package/.next/server/app/settings/tokens/page.js.nft.json +1 -1
  139. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  140. package/.next/server/app/settings/tokens.html +2 -2
  141. package/.next/server/app/settings/tokens.rsc +15 -15
  142. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
  143. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  144. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
  145. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  146. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  147. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  148. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  149. package/.next/server/app/settings.html +2 -2
  150. package/.next/server/app/settings.rsc +15 -15
  151. package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
  152. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  153. package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
  154. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  155. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  156. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  157. package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  158. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  159. package/.next/server/app/tasks/page.js.nft.json +1 -1
  160. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  161. package/.next/server/app/tasks.html +2 -2
  162. package/.next/server/app/tasks.rsc +15 -15
  163. package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
  164. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  165. package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
  166. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  167. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  168. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  169. package/.next/server/chunks/[root-of-the-server]__0t--g3k._.js +1 -1
  170. package/.next/server/chunks/[root-of-the-server]__0t--g3k._.js.map +1 -1
  171. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js +1 -1
  172. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js.map +1 -1
  173. package/.next/server/chunks/ssr/[root-of-the-server]__0dcez4x._.js +11 -0
  174. package/.next/server/chunks/ssr/[root-of-the-server]__0dcez4x._.js.map +1 -0
  175. package/.next/server/chunks/ssr/{[root-of-the-server]__0jgqpfv._.js → [root-of-the-server]__0gyqilt._.js} +2 -2
  176. package/.next/server/chunks/ssr/[root-of-the-server]__0gyqilt._.js.map +1 -0
  177. package/.next/server/chunks/ssr/[root-of-the-server]__0j-def.._.js +11 -0
  178. package/.next/server/chunks/ssr/[root-of-the-server]__0j-def.._.js.map +1 -0
  179. package/.next/server/chunks/ssr/{[root-of-the-server]__11wln8.._.js → [root-of-the-server]__0oavw6j._.js} +2 -2
  180. package/.next/server/chunks/ssr/[root-of-the-server]__0oavw6j._.js.map +1 -0
  181. package/.next/server/chunks/ssr/[root-of-the-server]__0rct4.k._.js +3 -0
  182. package/.next/server/chunks/ssr/{[root-of-the-server]__02o~-py._.js.map → [root-of-the-server]__0rct4.k._.js.map} +1 -1
  183. package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js +2 -2
  184. package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js.map +1 -1
  185. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js +1 -1
  186. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js.map +1 -1
  187. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js +1 -1
  188. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js.map +1 -1
  189. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js +2 -2
  190. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js.map +1 -1
  191. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js +1 -1
  192. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js.map +1 -1
  193. package/.next/server/functions-config-manifest.json +4 -0
  194. package/.next/server/middleware-build-manifest.js +3 -3
  195. package/.next/server/pages/404.html +2 -2
  196. package/.next/server/pages/500.html +1 -1
  197. package/.next/server/server-reference-manifest.js +1 -1
  198. package/.next/server/server-reference-manifest.json +1 -1
  199. package/.next/static/chunks/051a-ut_4_f~3.js +9 -0
  200. package/.next/static/chunks/{0advoo2tzdcyi.js → 0hhij-plsekos.js} +1 -1
  201. package/.next/static/chunks/{0_4ljrbp.stxy.js → 0i465ex5.l573.js} +1 -1
  202. package/.next/static/chunks/{10px7ehmo7eq0.js → 0jbkmykrnr~q6.js} +1 -1
  203. package/.next/static/chunks/0lc115vavown3.css +1 -0
  204. package/.next/static/chunks/{0z_4d13crd_af.js → 0n_7389-uuzik.js} +1 -1
  205. package/.next/static/chunks/0oai8y23-yy20.js +9 -0
  206. package/.next/static/chunks/{0dkk_l872ocgm.js → 0ohx4jpm9~61..js} +1 -1
  207. package/.next/static/chunks/{0uk67ty_0h_36.js → 0p3f6dx723h6w.js} +1 -1
  208. package/.next/static/chunks/0sy8dlbnq8b.5.js +2 -0
  209. package/.next/static/chunks/{0fz7zk.ek.154.js → 0u5y.gmi~jkw6.js} +1 -1
  210. package/.next/static/chunks/0ws0p1n62l~ub.js +1 -0
  211. package/.next/static/chunks/{06q7-vhy~1-y3.js → 0~rx9_~1wupcp.js} +2 -2
  212. package/.next/static/{JL_QjNKv1KhsCF68ZK28x → wbJm2a-VxMJqS77WvU7--}/_clientMiddlewareManifest.js +4 -0
  213. package/.next/trace +2 -2
  214. package/.next/trace-build +1 -1
  215. package/CHANGELOG.md +143 -0
  216. package/app/components/ChatPane.tsx +66 -25
  217. package/app/components/ChatPaneOverflowMenu.tsx +152 -0
  218. package/app/components/NodeList.tsx +14 -4
  219. package/app/components/TaskChatPanel.tsx +172 -31
  220. package/app/messages/page.tsx +22 -22
  221. package/app/nodes/NodesView.tsx +52 -11
  222. package/app/tasks/TasksView.tsx +12 -2
  223. package/package.json +1 -1
  224. package/proxy.ts +50 -1
  225. package/tests/e2e-9-nodes-team-tag-filter.spec.ts +14 -7
  226. package/tests/e2e-batch-list-actions.spec.ts +6 -0
  227. package/tests/e2e-chatpane-header-menu.spec.ts +259 -0
  228. package/tests/e2e-messages-spec-visual-align.spec.ts +244 -0
  229. package/tests/e2e-pin-mute-attr-always-present.spec.ts +171 -0
  230. package/tests/e2e-proxy-auth-boundary.spec.ts +196 -0
  231. package/tests/e2e-stage-a-node-list.spec.ts +14 -7
  232. package/tests/e2e-stage-b-chat-pane.spec.ts +74 -10
  233. package/tests/e2e-status-labels.spec.ts +218 -0
  234. package/.next/server/chunks/ssr/[root-of-the-server]__02o~-py._.js +0 -3
  235. package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js.map +0 -1
  236. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js +0 -9
  237. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js.map +0 -1
  238. package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js.map +0 -1
  239. package/.next/static/chunks/05~lgfzsv.p~p.js +0 -2
  240. package/.next/static/chunks/092zqb~4un32l.css +0 -1
  241. package/.next/static/chunks/11zz15q37~bz5.js +0 -7
  242. package/.next/static/chunks/17qkc714xy04..js +0 -1
  243. /package/.next/static/{JL_QjNKv1KhsCF68ZK28x → wbJm2a-VxMJqS77WvU7--}/_buildManifest.js +0 -0
  244. /package/.next/static/{JL_QjNKv1KhsCF68ZK28x → wbJm2a-VxMJqS77WvU7--}/_ssgManifest.js +0 -0
@@ -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
+ });
@@ -0,0 +1,196 @@
1
+ import { test, expect, APIRequestContext } from '@playwright/test';
2
+
3
+ const BASE = process.env.TEST_URL || 'http://localhost:3100';
4
+
5
+ // 07-31 通信龙 catch — proxy.ts:22 matcher was missing `/nodes/:path*`.
6
+ // A no-cookie GET to `/nodes/<alias>` returned 200 with the full 36KB
7
+ // page shell, while every other matcher entry correctly returned
8
+ // 307 → /login.
9
+ //
10
+ // What this test verifies — and doesn't:
11
+ //
12
+ // This spec proves the ROUTE GATE (proxy.ts) redirects no-cookie
13
+ // requests. It does NOT prove authentication: `request.cookies.has()`
14
+ // checks presence only, so any garbage cookie value passes the gate.
15
+ // Real authentication is in /api/hub which validates the token
16
+ // contents when the page tries to fetch. See proxy.ts top comment
17
+ // for the full framing.
18
+ //
19
+ // The class of bug caught here: a matcher-list slip letting a
20
+ // dynamic-child route bypass the "no cookie → /login" bounce and
21
+ // serve page bytes to a visitor who has no cookie at all. Today
22
+ // /nodes/[alias] doesn't do a server-side data fetch (通信龙
23
+ // confirmed with a nonexistent alias — 200 + 36108 bytes, page
24
+ // echoes the alias but hits no DB), so no rows leak. Structural,
25
+ // not sensitive; leaks the moment the page grows a server-side
26
+ // fetch that uses its own credentials.
27
+ //
28
+ // The enumeration IS the contract: if you add a new page route
29
+ // with a dynamic child, add its :path* variant to proxy.ts AND
30
+ // add a representative case to CASES here.
31
+ //
32
+ // Uses playwright.request.newContext (no browser, no cookie state).
33
+ // maxRedirects:0 so we see the 307 directly instead of following it.
34
+
35
+ const CASES: Array<{ path: string; label: string }> = [
36
+ // Static pages in matcher.
37
+ { path: '/', label: 'root landing' },
38
+ { path: '/node', label: 'legacy /node singular' },
39
+ { path: '/tasks', label: '/tasks index' },
40
+ { path: '/nodes', label: '/nodes index' },
41
+ { path: '/messages', label: '/messages page' },
42
+ { path: '/logs', label: '/logs page' },
43
+ { path: '/settings', label: '/settings index' },
44
+ { path: '/admin', label: '/admin page' },
45
+
46
+ // Dynamic-child variants — one representative alias per, to prove
47
+ // the `/:path*` glob catches typical values (unicode alias, hex id,
48
+ // slugish segment). If a new dynamic route is added upstream,
49
+ // ADD IT HERE too — the enumeration IS the contract.
50
+ { path: '/nodes/node-alpha', label: '/nodes/<alias> ascii ← 07-31 regression case' },
51
+ { path: '/nodes/%E6%94%AF%E4%BB%98%E5%8A%A9%E6%89%8B', label: '/nodes/<支付助手> unicode ← prod-observed alias shape' },
52
+ { path: '/tasks/abc123', label: '/tasks/<id> hex' },
53
+ { path: '/settings/general', label: '/settings/<subroute>' },
54
+ ];
55
+
56
+ async function assertRedirectsToLogin(request: APIRequestContext, path: string, label: string) {
57
+ const res = await request.get(`${BASE}${path}`, {
58
+ // Do NOT follow the redirect — we want to see the 307 itself.
59
+ maxRedirects: 0,
60
+ });
61
+ const status = res.status();
62
+ const location = res.headers()['location'] || '';
63
+ const bodyLen = (await res.body()).length;
64
+
65
+ // Bundle both attributes in one assertion so we always see BOTH
66
+ // when it fails — not "status wrong" then discover on next line
67
+ // that location was also wrong. Message spells out the request so
68
+ // a red doesn't force digging into locators.
69
+ const actual = { status, location, bodyLen };
70
+ void actual; // referenced in the assertion below; explicit for readers
71
+ expect({ status, location }, `${label} (${path}) — no-cookie GET must 307 → /login`).toMatchObject({
72
+ status: 307,
73
+ location: expect.stringMatching(/\/login/),
74
+ });
75
+
76
+ // Extra floor: a redirect response body is basically empty
77
+ // (browsers ignore it). If bodyLen > 1024, the server is likely
78
+ // rendering the page anyway and only "suggesting" a redirect via
79
+ // a soft mechanism, which is not what proxy.ts is doing here —
80
+ // that would still be a security regression.
81
+ expect(bodyLen, `${label} redirect body should be small (<1KB), got ${bodyLen}B`).toBeLessThan(1024);
82
+ }
83
+
84
+ test.describe('proxy.ts cookie-presence gate — no-cookie access redirects to /login', () => {
85
+ test('every matcher-covered path redirects a no-cookie GET to /login (fresh cookieless request context)', async ({ playwright }) => {
86
+ // Fresh, cookie-less request context. Not `page`/`context` from
87
+ // the test fixture, which may have accumulated cookies via test
88
+ // parallelism or a prior test in this session.
89
+ const request = await playwright.request.newContext({
90
+ baseURL: BASE,
91
+ // Explicitly no storageState → no cookies. This is the whole
92
+ // premise of the test.
93
+ });
94
+ try {
95
+ // Sanity — verify the request context has no cookie. If a
96
+ // stray cookie somehow leaks in, our redirects would all
97
+ // become 200 and the loop would falsely pass. Check /nodes
98
+ // first (a matcher-covered path) and confirm 307 before
99
+ // running the enumeration.
100
+ const sanity = await request.get(`${BASE}/nodes`, { maxRedirects: 0 });
101
+ expect(sanity.status(), 'sanity: bare /nodes must 307 with no cookie (else test context has stray cookies)').toBe(307);
102
+
103
+ for (const c of CASES) {
104
+ await assertRedirectsToLogin(request, c.path, c.label);
105
+ }
106
+ } finally {
107
+ await request.dispose();
108
+ }
109
+ });
110
+
111
+ test('positive direction: with ANY cookie value, matcher-covered routes are NOT 307 (gate lets them through — else we broke logged-in users)', async ({ playwright }) => {
112
+ // Reviewer's ask: the previous two tests only prove "no cookie
113
+ // → gate redirects". A matcher like ['/(.*)'] would also make
114
+ // those pass — while breaking every logged-in flow. This test
115
+ // is the other direction of the two-direction discipline
116
+ // (feedback_verify_both_directions_with_real_data): the gate
117
+ // must LET THROUGH requests that hold the cookie.
118
+ //
119
+ // We assert "NOT 307" (not "== 200") deliberately. The gate is
120
+ // upstream of page rendering — proving the redirect doesn't
121
+ // fire is proving the gate let it pass. Whether the page then
122
+ // renders 200 or 500 or 404 depends on backend state / dev
123
+ // hardlink quirks / route existence — none of which is this
124
+ // gate's job. Assert only what THIS layer controls.
125
+ //
126
+ // Cookie value here is INTENTIONALLY garbage — same as the
127
+ // baseline 通信龙 established:
128
+ // Cookie: anet_dashboard_session=complete_garbage_xyz → 200
129
+ // "Has cookie" is the gate's whole judgment; value doesn't
130
+ // matter to it. Using a garbage value here also documents that
131
+ // fact in the test itself.
132
+ const request = await playwright.request.newContext({
133
+ baseURL: BASE,
134
+ extraHTTPHeaders: {
135
+ cookie: 'anet_dashboard_session=audit-probe-not-a-real-token',
136
+ },
137
+ });
138
+ try {
139
+ // Sanity — the cookie header we set is actually attached.
140
+ // If Playwright drops it (e.g. domain mismatch), every "not
141
+ // 307" assertion below would false-pass on a redirect that
142
+ // actually shouldn't happen. So: hit /login (NOT in matcher)
143
+ // with the cookie, expect 307 → / (session redirects logged-
144
+ // in users away from login) OR 200 (the login page itself).
145
+ // Actually cleanest sanity: hit / with cookie and expect
146
+ // NOT 307-to-/login — same shape as the assertions below.
147
+ const sanity = await request.get(`${BASE}/`, { maxRedirects: 0 });
148
+ const sanityLoc = sanity.headers()['location'] || '';
149
+ expect(
150
+ !(sanity.status() === 307 && sanityLoc.includes('/login')),
151
+ `sanity: bare / with cookie must NOT redirect to /login (got ${sanity.status()} → ${sanityLoc}). If it did, the cookie header did not attach and the loop below is meaningless.`,
152
+ ).toBe(true);
153
+
154
+ for (const c of CASES) {
155
+ const res = await request.get(`${BASE}${c.path}`, { maxRedirects: 0 });
156
+ const status = res.status();
157
+ const location = res.headers()['location'] || '';
158
+ // "NOT 307 → /login" — either any status other than 307,
159
+ // OR 307 that redirects somewhere other than /login (unlikely
160
+ // but not our concern; a 307 → /foo isn't proxy.ts kicking
161
+ // the user out).
162
+ const bouncedByGate = status === 307 && location.includes('/login');
163
+ expect(
164
+ bouncedByGate,
165
+ `${c.label} (${c.path}) — with cookie MUST NOT be gate-redirected. Got ${status} → ${location || '(no location)'}. This means proxy.ts is rejecting cookie-holders — the matcher is over-broad or the has() check is inverted.`,
166
+ ).toBe(false);
167
+ }
168
+ } finally {
169
+ await request.dispose();
170
+ }
171
+ });
172
+
173
+ test('🔴 07-31 regression: no-cookie /nodes/<alias> MUST redirect (this was reachable in prod v52)', async ({ playwright }) => {
174
+ // Standalone version of the specific case that broke, so a red
175
+ // here anchors the historical incident. If someone loosens the
176
+ // matcher again, this test names the exact prod URL that broke.
177
+ const request = await playwright.request.newContext({ baseURL: BASE });
178
+ try {
179
+ const res = await request.get(`${BASE}/nodes/node-alpha`, { maxRedirects: 0 });
180
+ const status = res.status();
181
+ const location = res.headers()['location'] || '';
182
+ const bodyLen = (await res.body()).length;
183
+ expect({ status, location, bodyLen }).toMatchObject({
184
+ status: 307,
185
+ location: expect.stringMatching(/\/login/),
186
+ });
187
+ // Pre-fix behavior: status 200, bodyLen ~= 36000. Assert an
188
+ // upper floor as belt-and-suspenders (the toMatchObject above
189
+ // catches the primary status miss; this catches "someone made
190
+ // proxy.ts return a soft redirect with a full-body page").
191
+ expect(bodyLen).toBeLessThan(1024);
192
+ } finally {
193
+ await request.dispose();
194
+ }
195
+ });
196
+ });
@@ -40,16 +40,23 @@ const HUB_HEALTH = {
40
40
  },
41
41
  };
42
42
 
43
+ // Login rate limit is 10/15min/IP (app/lib/rate-limit.ts): with 4 spec
44
+ // files in one combined run, per-test logins blow the budget and late
45
+ // tests land on the sign-in page (false red). Login once per worker.
46
+ let cachedSessionCookie: string | null = null;
43
47
  async function login(context: Page['context'] extends () => infer C ? C : never) {
44
- const res = await context.request.post(`${BASE}/api/auth/login`, {
45
- data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
46
- });
47
- const setCookie = res.headers()['set-cookie'] || '';
48
- const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
49
- if (m) {
48
+ if (!cachedSessionCookie) {
49
+ const res = await context.request.post(`${BASE}/api/auth/login`, {
50
+ data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
51
+ });
52
+ const setCookie = res.headers()['set-cookie'] || '';
53
+ const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
54
+ if (m) cachedSessionCookie = decodeURIComponent(m[1]);
55
+ }
56
+ if (cachedSessionCookie) {
50
57
  await context.addCookies([{
51
58
  name: 'anet_dashboard_session',
52
- value: decodeURIComponent(m[1]),
59
+ value: cachedSessionCookie,
53
60
  domain: new URL(BASE).hostname,
54
61
  path: '/',
55
62
  }]);