@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.
- package/.next/BUILD_COMMIT +1 -1
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +3 -3
- package/.next/diagnostics/route-bundle-stats.json +97 -97
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +5 -5
- package/.next/server/app/_not-found.rsc +13 -13
- package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
- package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
- package/.next/server/app/admin.html +5 -5
- package/.next/server/app/admin.rsc +15 -15
- package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
- package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
- package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
- package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
- package/.next/server/app/index.html +5 -5
- package/.next/server/app/index.rsc +15 -15
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/server/app/index.segments/_index.segment.rsc +8 -8
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +2 -2
- package/.next/server/app/login.rsc +15 -15
- package/.next/server/app/login.segments/_full.segment.rsc +15 -15
- package/.next/server/app/login.segments/_head.segment.rsc +4 -4
- package/.next/server/app/login.segments/_index.segment.rsc +8 -8
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/login.segments/login.segment.rsc +3 -3
- package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/logs.html +5 -5
- package/.next/server/app/logs.rsc +15 -15
- package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
- package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
- package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
- package/.next/server/app/messages/page.js.nft.json +1 -1
- package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
- package/.next/server/app/messages.html +5 -5
- package/.next/server/app/messages.rsc +15 -15
- package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
- package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
- package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
- package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
- package/.next/server/app/node/page.js.nft.json +1 -1
- package/.next/server/app/node/page_client-reference-manifest.js +1 -1
- package/.next/server/app/node.html +5 -5
- package/.next/server/app/node.rsc +15 -15
- package/.next/server/app/node.segments/_full.segment.rsc +15 -15
- package/.next/server/app/node.segments/_head.segment.rsc +4 -4
- package/.next/server/app/node.segments/_index.segment.rsc +8 -8
- package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/node.segments/node.segment.rsc +3 -3
- package/.next/server/app/nodes/[alias]/page.js.nft.json +1 -1
- package/.next/server/app/nodes/[alias]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/nodes/page.js.nft.json +1 -1
- package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
- package/.next/server/app/nodes.html +5 -5
- package/.next/server/app/nodes.rsc +15 -15
- package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
- package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
- package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
- package/.next/server/app/providers.html +5 -5
- package/.next/server/app/providers.rsc +15 -15
- package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
- package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
- package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
- package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
- package/.next/server/app/server-logs.html +5 -5
- package/.next/server/app/server-logs.rsc +15 -15
- package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
- package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
- package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
- package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
- package/.next/server/app/servers.html +5 -5
- package/.next/server/app/servers.rsc +15 -15
- package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
- package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
- package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
- package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
- package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/networks.html +5 -5
- package/.next/server/app/settings/networks.rsc +15 -15
- package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
- package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
- package/.next/server/app/settings/tokens.html +5 -5
- package/.next/server/app/settings/tokens.rsc +15 -15
- package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
- package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
- package/.next/server/app/settings.html +5 -5
- package/.next/server/app/settings.rsc +15 -15
- package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
- package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
- package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
- package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
- package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
- package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks/page.js.nft.json +1 -1
- package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
- package/.next/server/app/tasks.html +5 -5
- package/.next/server/app/tasks.rsc +15 -15
- package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
- package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
- package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__05z_qq6._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__05z_qq6._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js.map +1 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js.map +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js.map +1 -0
- package/.next/server/chunks/ssr/{dash-stageb-wt_app_0e7vp44._.js → dash-stageb-wt_app_045dcq5._.js} +2 -2
- package/.next/server/chunks/ssr/dash-stageb-wt_app_045dcq5._.js.map +1 -0
- package/.next/server/chunks/ssr/{dash-stageb-wt_app_0g~a.7i._.js → dash-stageb-wt_app_05u4_h~._.js} +8 -2
- package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js.map +1 -0
- package/.next/server/chunks/ssr/{dash-stageb-wt_app_0qpn85e._.js → dash-stageb-wt_app_0nuizh~._.js} +2 -2
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0nuizh~._.js.map +1 -0
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_components_0vzsmx1._.js +5 -5
- package/.next/server/chunks/ssr/dash-stageb-wt_app_components_0vzsmx1._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js.map +1 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js +3 -0
- package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js.map +1 -0
- package/.next/server/middleware-build-manifest.js +3 -3
- package/.next/server/pages/404.html +5 -5
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/02.5_nifak.uu.js +1 -0
- package/.next/static/chunks/03hzj129k~odb.js +1 -0
- package/.next/static/chunks/06q7-vhy~1-y3.js +7 -0
- package/.next/static/chunks/0_4ljrbp.stxy.js +1 -0
- package/.next/static/chunks/0cey4i.y0a8~j.js +1 -0
- package/.next/static/chunks/{0i8qbq4v47v3r.js → 0ep78sq90x_yc.js} +1 -1
- package/.next/static/chunks/{0_xyyy_x0k42j.js → 0hm52b0y~k0~z.js} +1 -1
- package/.next/static/chunks/0kncmrm2a_q43.js +1 -0
- package/.next/static/chunks/0mhaf6lrw5nn..css +1 -0
- package/.next/static/chunks/{12jbb28xy2hw..js → 0qf8xjihw3zi..js} +1 -1
- package/.next/static/chunks/0xyu92zdvvghl.js +7 -0
- package/.next/static/chunks/0zj_navpyhltx.js +2 -0
- package/.next/static/chunks/10px7ehmo7eq0.js +1 -0
- package/.next/static/chunks/{0tv-.l-_mw4ye.js → 11zz15q37~bz5.js} +1 -1
- package/.next/static/chunks/14y4x3de1lbug.js +1 -0
- package/.next/static/chunks/15ka68xi.w-i-.js +1 -0
- package/.next/static/chunks/17qkc714xy04..js +1 -0
- package/.next/trace +2 -2
- package/.next/trace-build +1 -1
- package/app/components/ChatPane.tsx +2 -2
- package/app/components/NodeList.tsx +40 -4
- package/app/components/Sidebar.tsx +180 -29
- package/app/components/TaskChatPanel.tsx +1 -1
- package/app/components/TaskDetail.tsx +407 -0
- package/app/components/TaskList.tsx +117 -0
- package/app/nodes/NodesView.tsx +28 -3
- package/app/tasks/TasksView.tsx +353 -0
- package/app/tasks/[id]/page.tsx +23 -176
- package/app/tasks/page.tsx +10 -453
- package/package.json +1 -1
- package/tests/e2e-sidebar-default.spec.ts +141 -0
- package/tests/e2e-sidebar-icon-rail.spec.ts +261 -0
- package/tests/e2e-stage-b-chat-pane.spec.ts +286 -0
- package/tests/e2e-stage-tasks-two-column.spec.ts +353 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__0b-b6yh._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0b-b6yh._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0fs-j~c._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0fs-j~c._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0ma5-5u._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0ma5-5u._.js.map +0 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0~hwpo-._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0~hwpo-._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_095q0~q._.js +0 -3
- package/.next/server/chunks/ssr/dash-stageb-wt_app_095q0~q._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0e7vp44._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0g~a.7i._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0qpn85e._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0tppjr_._.js +0 -9
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0tppjr_._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0y764mt._.js +0 -3
- package/.next/server/chunks/ssr/dash-stageb-wt_app_0y764mt._.js.map +0 -1
- package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_page_tsx_0.fhtup._.js +0 -3
- package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_page_tsx_0.fhtup._.js.map +0 -1
- package/.next/static/chunks/027yjbjm9afuk.js +0 -1
- package/.next/static/chunks/07wf61~wof.l~.js +0 -1
- package/.next/static/chunks/0ev252k-tf_aa.css +0 -1
- package/.next/static/chunks/0j-jjs3w89dei.js +0 -1
- package/.next/static/chunks/0ko39v-gezo7d.js +0 -1
- package/.next/static/chunks/0l8vf4q2t5x4d.js +0 -7
- package/.next/static/chunks/0xcazs6t8~l4q.js +0 -2
- package/.next/static/chunks/10sr651~7vm42.js +0 -7
- package/.next/static/chunks/13_i5b0qmko7z.js +0 -1
- package/.next/static/chunks/140suz13o9va6.js +0 -1
- package/.next/static/chunks/144cf7ds3_9~b.js +0 -1
- package/.next/static/chunks/17c4wlsbmmgk9.js +0 -1
- package/.next/static/chunks/17cfly-04z1j0.js +0 -1
- /package/.next/static/{HIKVEkFShWE481BxQDjwH → vc_aJHtYwefsVcl2_f14Z}/_buildManifest.js +0 -0
- /package/.next/static/{HIKVEkFShWE481BxQDjwH → vc_aJHtYwefsVcl2_f14Z}/_clientMiddlewareManifest.js +0 -0
- /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
|
+
});
|