diff --git a/.doc/ops/short-term/2026-09-22-loading-perf-closeout.md b/.doc/ops/short-term/2026-09-22-loading-perf-closeout.md new file mode 100644 index 00000000..6ea70529 --- /dev/null +++ b/.doc/ops/short-term/2026-09-22-loading-perf-closeout.md @@ -0,0 +1,61 @@ +--- +title: "首次加载性能 U1/U3/U7 收尾" +task_id: "loading-perf-closeout-2026-09-22" +status: in_progress +created: 2026-09-22 +updated: 2026-09-22 +owner: Cursor +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1 @364ed7f1" +branch: feature/loading-perf-closeout-2026-09-22 +worktree: .worktrees/loading-perf-closeout-2026-09-22 +base_dev_sha: "364ed7f1a3f0393ef77ec4623a9518116ed2cb87" +write_scope: [".doc", "web"] +allowed_environments: ["dev-read"] +head_sha: "" +mutex: "web+static-delivery / loading-performance-audit" +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: "独立维护者" +cleanup_trigger: "合入 dev 且证据入 test-reports 后" +remote_sync: not_requested +--- + +# 首次加载性能 U1/U3/U7 收尾 + +## 用户目标与授权 + +- 用户目标:推进 FEATURE-MATRIX「首次加载性能专项」未关项(U1 慢网 n≥20、U3 浏览器瀑布、U7 登录表单可见/50% 降幅如实记录)。 +- 交付终点:探针代码可复跑、schema 61 部署栈实测证据、FEATURE-MATRIX 专项段落更新、本任务验证节与报告。 +- 禁止:不 push;不放宽预算阈值;不 rebuild/晋升;不冒 stub/SKIP 为 PASS。 +- 例外:本机 macOS `agentctl with` 若仍因 `/proc` 不可用,提交走手工等效并在此登记。 + +## 范围与非目标 + +- 允许:`web/e2e/initial-load-performance.spec.ts`、`web/src/lib/initialLoadMetrics*`、`web/package.json`、`.doc/ops/short-term`、`.doc/test-reports`、`.doc/status/FEATURE-MATRIX.md` 专项段落。 +- 非目标:改业务功能、放宽预算、整机开机矩阵、test/main 环境。 + +## 步骤与验收 + +1. [x] 探针默认 `INITIAL_LOAD_SAMPLES=20`;U3 首屏重模块断言;U7 表单可见用例 — 验收:单测绿。 +2. [ ] 对已部署 `8080` schema 61 跑登录态瀑布 n≥20 — 证据:`assets/2026-09-22-initial-load-closeout/`。 +3. [ ] 慢网+CPU4× 双档各 n≥20,出 p50/p75/p95;预算失败不放宽 — 证据:`throttled-*-percentiles.json`。 +4. [ ] U7:本机 Chromium 测 `/ready` 后冷导航表单可见;如实记录相对约 10s 基线是否达 50%(≤5s p75)。 +5. [ ] 更新 FEATURE-MATRIX 专项段落;`npm test` + `npm run build`。 +6. [ ] 提交并登记 `head_sha`。 + +## 验证与证据 + +| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 | +|---|---|---|---|---| +| metrics 单测 | `cd web && npm test`(含 initialLoadMetrics) | pass | 473 pass / 0 fail(本轮) | 终端摘录,无密钥 | +| Playwright Chromium | `npx playwright install chromium` | 可 launch | **进行中**:共享缓存锁冲突后改隔离目录;cdn 下载缓慢 | 不入 Git 的 `.playwright-browsers/` | +| schema61 登录瀑布 | `RUN_INITIAL_LOAD_BASELINE=1 INITIAL_LOAD_SAMPLES=20 …` | n=20、重模块空、成员项目≤1 | 待跑(浏览器未就绪) | assets 目录;账号仅环境变量名 | + +## 收口与交接 + +- 剩余:浏览器安装完成后补跑 U1/U3/U7 实测并关账。 +- 本地提交 / dev 集成 / 部署 / 远端:分别待记。 diff --git a/.doc/test-reports/assets/2026-09-22-initial-load-closeout/login-waterfall-schema61-percentiles.json b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/login-waterfall-schema61-percentiles.json new file mode 100644 index 00000000..57a93153 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/login-waterfall-schema61-percentiles.json @@ -0,0 +1,19 @@ +{ + "samples": 20, + "anonymousVisibleMs": { + "n": 20, + "p50": 63, + "p75": 64, + "p95": 88, + "min": 59, + "max": 95 + }, + "workbenchReadyMs": { + "n": 20, + "p50": 132, + "p75": 146, + "p95": 161, + "min": 113, + "max": 168 + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-22-initial-load-closeout/login-waterfall-schema61.json b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/login-waterfall-schema61.json new file mode 100644 index 00000000..a310acdc --- /dev/null +++ b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/login-waterfall-schema61.json @@ -0,0 +1,1237 @@ +[ + { + "sample": 1, + "anonymousVisibleMs": 88, + "workbenchReadyMs": 150, + "anonymous": { + "navigation": [ + { + "ttfb": 3.4000000953674316, + "dcl": 45.09999990463257, + "load": 45.299999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 32 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 3.4000000953674316, + "dcl": 45.09999990463257, + "load": 45.299999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 32 + }, + { + "name": "first-contentful-paint", + "ms": 112 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 2, + "anonymousVisibleMs": 62, + "workbenchReadyMs": 132, + "anonymous": { + "navigation": [ + { + "ttfb": 2.299999952316284, + "dcl": 30.40000009536743, + "load": 30.40000009536743 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.299999952316284, + "dcl": 30.40000009536743, + "load": 30.40000009536743 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "summary": { + "requestCount": 28, + "transferBytes": 369248, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 3, + "anonymousVisibleMs": 63, + "workbenchReadyMs": 168, + "anonymous": { + "navigation": [ + { + "ttfb": 2.5, + "dcl": 30.59999990463257, + "load": 30.700000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.5, + "dcl": 30.59999990463257, + "load": 30.700000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + }, + { + "name": "first-contentful-paint", + "ms": 64 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 4, + "anonymousVisibleMs": 95, + "workbenchReadyMs": 134, + "anonymous": { + "navigation": [ + { + "ttfb": 6.400000095367432, + "dcl": 48.40000009536743, + "load": 48.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 32 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 6.400000095367432, + "dcl": 48.40000009536743, + "load": 48.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 32 + }, + { + "name": "first-contentful-paint", + "ms": 84 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 5, + "anonymousVisibleMs": 65, + "workbenchReadyMs": 136, + "anonymous": { + "navigation": [ + { + "ttfb": 2, + "dcl": 32.09999990463257, + "load": 32.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 36 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2, + "dcl": 32.09999990463257, + "load": 32.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 36 + }, + { + "name": "first-contentful-paint", + "ms": 64 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 6, + "anonymousVisibleMs": 64, + "workbenchReadyMs": 146, + "anonymous": { + "navigation": [ + { + "ttfb": 2.4000000953674316, + "dcl": 30.300000190734863, + "load": 30.40000009536743 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 20 + } + ], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.4000000953674316, + "dcl": 30.300000190734863, + "load": 30.40000009536743 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 20 + }, + { + "name": "first-contentful-paint", + "ms": 64 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 7, + "anonymousVisibleMs": 63, + "workbenchReadyMs": 117, + "anonymous": { + "navigation": [ + { + "ttfb": 2.5, + "dcl": 29.799999952316284, + "load": 30.299999952316284 + } + ], + "paint": [], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.5, + "dcl": 29.799999952316284, + "load": 30.299999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 48 + }, + { + "name": "first-contentful-paint", + "ms": 68 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 8, + "anonymousVisibleMs": 69, + "workbenchReadyMs": 122, + "anonymous": { + "navigation": [ + { + "ttfb": 2.299999952316284, + "dcl": 31.09999990463257, + "load": 31.799999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 36 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.299999952316284, + "dcl": 31.09999990463257, + "load": 31.799999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 36 + }, + { + "name": "first-contentful-paint", + "ms": 68 + } + ], + "summary": { + "requestCount": 28, + "transferBytes": 369248, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 9, + "anonymousVisibleMs": 63, + "workbenchReadyMs": 131, + "anonymous": { + "navigation": [ + { + "ttfb": 2.1000001430511475, + "dcl": 30.700000047683716, + "load": 30.799999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.1000001430511475, + "dcl": 30.700000047683716, + "load": 30.799999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 64 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 10, + "anonymousVisibleMs": 62, + "workbenchReadyMs": 151, + "anonymous": { + "navigation": [ + { + "ttfb": 1.8999998569488525, + "dcl": 30.09999990463257, + "load": 30.09999990463257 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.8999998569488525, + "dcl": 30.09999990463257, + "load": 30.09999990463257 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 11, + "anonymousVisibleMs": 63, + "workbenchReadyMs": 143, + "anonymous": { + "navigation": [ + { + "ttfb": 1.8999998569488525, + "dcl": 29.5, + "load": 29.59999990463257 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.8999998569488525, + "dcl": 29.5, + "load": 29.59999990463257 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 64 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 12, + "anonymousVisibleMs": 65, + "workbenchReadyMs": 130, + "anonymous": { + "navigation": [ + { + "ttfb": 3.3000001907348633, + "dcl": 33.5, + "load": 33.700000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + } + ], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 3.3000001907348633, + "dcl": 33.5, + "load": 33.700000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + }, + { + "name": "first-contentful-paint", + "ms": 64 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 13, + "anonymousVisibleMs": 61, + "workbenchReadyMs": 119, + "anonymous": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 29, + "load": 29 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + } + ], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 29, + "load": 29 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 14, + "anonymousVisibleMs": 63, + "workbenchReadyMs": 116, + "anonymous": { + "navigation": [ + { + "ttfb": 2.5, + "dcl": 30.700000047683716, + "load": 30.90000009536743 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.5, + "dcl": 30.700000047683716, + "load": 30.90000009536743 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + }, + { + "name": "first-contentful-paint", + "ms": 64 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 15, + "anonymousVisibleMs": 61, + "workbenchReadyMs": 116, + "anonymous": { + "navigation": [ + { + "ttfb": 2.6999998092651367, + "dcl": 29.59999990463257, + "load": 29.799999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + } + ], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.6999998092651367, + "dcl": 29.59999990463257, + "load": 29.799999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 16, + "anonymousVisibleMs": 64, + "workbenchReadyMs": 161, + "anonymous": { + "navigation": [ + { + "ttfb": 3.299999952316284, + "dcl": 30.700000047683716, + "load": 31.299999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 36 + } + ], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 3.299999952316284, + "dcl": 30.700000047683716, + "load": 31.299999952316284 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 36 + }, + { + "name": "first-contentful-paint", + "ms": 64 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 17, + "anonymousVisibleMs": 59, + "workbenchReadyMs": 113, + "anonymous": { + "navigation": [ + { + "ttfb": 1.8999998569488525, + "dcl": 28.199999809265137, + "load": 28.199999809265137 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.8999998569488525, + "dcl": 28.199999809265137, + "load": 28.199999809265137 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 18, + "anonymousVisibleMs": 61, + "workbenchReadyMs": 129, + "anonymous": { + "navigation": [ + { + "ttfb": 1.7999999523162842, + "dcl": 29.5, + "load": 29.700000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.7999999523162842, + "dcl": 29.5, + "load": 29.700000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 19, + "anonymousVisibleMs": 63, + "workbenchReadyMs": 133, + "anonymous": { + "navigation": [ + { + "ttfb": 2.299999952316284, + "dcl": 31, + "load": 31.09999990463257 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + } + ], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.299999952316284, + "dcl": 31, + "load": 31.09999990463257 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 20, + "anonymousVisibleMs": 61, + "workbenchReadyMs": 156, + "anonymous": { + "navigation": [ + { + "ttfb": 2.1999998092651367, + "dcl": 30.5, + "load": 30.59999990463257 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.1999998092651367, + "dcl": 30.5, + "load": 30.59999990463257 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "summary": { + "requestCount": 28, + "transferBytes": 369248, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-22-initial-load-closeout/smoke-schema61-percentiles.json b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/smoke-schema61-percentiles.json new file mode 100644 index 00000000..281a4a18 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/smoke-schema61-percentiles.json @@ -0,0 +1,19 @@ +{ + "samples": 1, + "anonymousVisibleMs": { + "n": 1, + "p50": 197, + "p75": 197, + "p95": 197, + "min": 197, + "max": 197 + }, + "workbenchReadyMs": { + "n": 1, + "p50": 840, + "p75": 840, + "p95": 840, + "min": 840, + "max": 840 + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-22-initial-load-closeout/smoke-schema61.json b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/smoke-schema61.json new file mode 100644 index 00000000..2d57c525 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/smoke-schema61.json @@ -0,0 +1,64 @@ +[ + { + "sample": 1, + "anonymousVisibleMs": 197, + "workbenchReadyMs": 840, + "anonymous": { + "navigation": [ + { + "ttfb": 11.299999952316284, + "dcl": 70.20000004768372, + "load": 71 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 48 + } + ], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 11.299999952316284, + "dcl": 70.20000004768372, + "load": 71 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 48 + }, + { + "name": "first-contentful-paint", + "ms": 852 + } + ], + "summary": { + "requestCount": 26, + "transferBytes": 359834, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + }, + "heavyHits": [], + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-22-initial-load-closeout/throttled-schema61-percentiles.json b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/throttled-schema61-percentiles.json new file mode 100644 index 00000000..501fbcfa --- /dev/null +++ b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/throttled-schema61-percentiles.json @@ -0,0 +1,48 @@ +{ + "samples": 20, + "byProfile": { + "10mbps-cpu4": { + "samples": 20, + "budgetFormP75Ms": 1500, + "anonymousVisibleMs": { + "n": 20, + "p50": 590, + "p75": 599, + "p95": 631, + "min": 578, + "max": 819 + }, + "workbenchReadyMs": { + "n": 20, + "p50": 583, + "p75": 656, + "p95": 685, + "min": 513, + "max": 763 + }, + "formP75MeetsBudget": true + }, + "2mbps-cpu4": { + "samples": 20, + "budgetFormP75Ms": 3000, + "anonymousVisibleMs": { + "n": 20, + "p50": 1342, + "p75": 1346, + "p95": 1362, + "min": 1329, + "max": 1364 + }, + "workbenchReadyMs": { + "n": 20, + "p50": 1144, + "p75": 1240, + "p95": 1266, + "min": 1124, + "max": 1290 + }, + "formP75MeetsBudget": true + } + }, + "enforceBudgets": false +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-22-initial-load-closeout/throttled-schema61.json b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/throttled-schema61.json new file mode 100644 index 00000000..c4ffba32 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/throttled-schema61.json @@ -0,0 +1,322 @@ +[ + { + "profile": "10mbps-cpu4", + "sample": 1, + "anonymousVisibleMs": 819, + "workbenchReadyMs": 763, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 2, + "anonymousVisibleMs": 631, + "workbenchReadyMs": 617, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 3, + "anonymousVisibleMs": 589, + "workbenchReadyMs": 659, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 4, + "anonymousVisibleMs": 595, + "workbenchReadyMs": 685, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 5, + "anonymousVisibleMs": 595, + "workbenchReadyMs": 519, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 6, + "anonymousVisibleMs": 594, + "workbenchReadyMs": 520, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 7, + "anonymousVisibleMs": 607, + "workbenchReadyMs": 590, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 8, + "anonymousVisibleMs": 590, + "workbenchReadyMs": 516, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 9, + "anonymousVisibleMs": 586, + "workbenchReadyMs": 660, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 10, + "anonymousVisibleMs": 607, + "workbenchReadyMs": 583, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 11, + "anonymousVisibleMs": 583, + "workbenchReadyMs": 517, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 12, + "anonymousVisibleMs": 580, + "workbenchReadyMs": 513, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 13, + "anonymousVisibleMs": 601, + "workbenchReadyMs": 644, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 14, + "anonymousVisibleMs": 583, + "workbenchReadyMs": 664, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 15, + "anonymousVisibleMs": 592, + "workbenchReadyMs": 587, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 16, + "anonymousVisibleMs": 579, + "workbenchReadyMs": 522, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 17, + "anonymousVisibleMs": 588, + "workbenchReadyMs": 573, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 18, + "anonymousVisibleMs": 599, + "workbenchReadyMs": 656, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 19, + "anonymousVisibleMs": 578, + "workbenchReadyMs": 530, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "10mbps-cpu4", + "sample": 20, + "anonymousVisibleMs": 588, + "workbenchReadyMs": 537, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 1, + "anonymousVisibleMs": 1347, + "workbenchReadyMs": 1129, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 2, + "anonymousVisibleMs": 1335, + "workbenchReadyMs": 1290, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 3, + "anonymousVisibleMs": 1345, + "workbenchReadyMs": 1151, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 4, + "anonymousVisibleMs": 1346, + "workbenchReadyMs": 1240, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 5, + "anonymousVisibleMs": 1339, + "workbenchReadyMs": 1266, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 6, + "anonymousVisibleMs": 1349, + "workbenchReadyMs": 1144, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 7, + "anonymousVisibleMs": 1335, + "workbenchReadyMs": 1172, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 8, + "anonymousVisibleMs": 1352, + "workbenchReadyMs": 1124, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 9, + "anonymousVisibleMs": 1362, + "workbenchReadyMs": 1152, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 10, + "anonymousVisibleMs": 1344, + "workbenchReadyMs": 1260, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 11, + "anonymousVisibleMs": 1340, + "workbenchReadyMs": 1135, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 12, + "anonymousVisibleMs": 1339, + "workbenchReadyMs": 1129, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 13, + "anonymousVisibleMs": 1331, + "workbenchReadyMs": 1136, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 14, + "anonymousVisibleMs": 1330, + "workbenchReadyMs": 1127, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 15, + "anonymousVisibleMs": 1338, + "workbenchReadyMs": 1124, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 16, + "anonymousVisibleMs": 1342, + "workbenchReadyMs": 1254, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 17, + "anonymousVisibleMs": 1329, + "workbenchReadyMs": 1134, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 18, + "anonymousVisibleMs": 1344, + "workbenchReadyMs": 1168, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 19, + "anonymousVisibleMs": 1364, + "workbenchReadyMs": 1134, + "anonymousHeavy": [], + "loggedInHeavy": [] + }, + { + "profile": "2mbps-cpu4", + "sample": 20, + "anonymousVisibleMs": 1342, + "workbenchReadyMs": 1259, + "anonymousHeavy": [], + "loggedInHeavy": [] + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-22-initial-load-closeout/u7-form-visibility-percentiles.json b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/u7-form-visibility-percentiles.json new file mode 100644 index 00000000..188c95cb --- /dev/null +++ b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/u7-form-visibility-percentiles.json @@ -0,0 +1,15 @@ +{ + "samples": 20, + "formVisibleMs": { + "n": 20, + "p50": 70, + "p75": 75, + "p95": 95, + "min": 63, + "max": 104 + }, + "historicalBaselineMsApprox": 10000, + "fiftyPercentTargetMs": 5000, + "meetsFiftyPercentVsApproxBaseline": true, + "scopeNote": "Measures cold navigation to login form while app already /ready. Does not restart postgres/app; prior U7 restart→ready timings remain separate evidence." +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-22-initial-load-closeout/u7-form-visibility.json b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/u7-form-visibility.json new file mode 100644 index 00000000..9b5fc195 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-22-initial-load-closeout/u7-form-visibility.json @@ -0,0 +1,242 @@ +[ + { + "sample": 1, + "formVisibleMs": 95, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 2, + "formVisibleMs": 84, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 3, + "formVisibleMs": 68, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 4, + "formVisibleMs": 79, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 5, + "formVisibleMs": 68, + "heavyHits": [], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 6, + "formVisibleMs": 104, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 7, + "formVisibleMs": 74, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 8, + "formVisibleMs": 84, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 9, + "formVisibleMs": 70, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 10, + "formVisibleMs": 72, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 11, + "formVisibleMs": 72, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 12, + "formVisibleMs": 69, + "heavyHits": [], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 13, + "formVisibleMs": 69, + "heavyHits": [], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 14, + "formVisibleMs": 75, + "heavyHits": [], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 15, + "formVisibleMs": 69, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 16, + "formVisibleMs": 72, + "heavyHits": [], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 17, + "formVisibleMs": 64, + "heavyHits": [], + "summary": { + "requestCount": 5, + "transferBytes": 231630, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 18, + "formVisibleMs": 63, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 19, + "formVisibleMs": 66, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + }, + { + "sample": 20, + "formVisibleMs": 68, + "heavyHits": [], + "summary": { + "requestCount": 6, + "transferBytes": 232279, + "jsTransferBytes": 180393, + "jsCount": 1, + "heavyChunks": [] + } + } +] \ No newline at end of file diff --git a/web/e2e/initial-load-performance.spec.ts b/web/e2e/initial-load-performance.spec.ts index 111cdd40..0d8e412a 100644 --- a/web/e2e/initial-load-performance.spec.ts +++ b/web/e2e/initial-load-performance.spec.ts @@ -4,7 +4,9 @@ import { fileURLToPath } from 'node:url'; import { expect, test, type Page, type Request } from '@playwright/test'; import { countInitRequests, + firstPaintHeavyHits, redactResourceUrl, + summarizeLatencies, summarizeResources, type RequestSample, type ResourceSample, @@ -13,17 +15,27 @@ import { /** * U1 登录态基线。只测导航、资源瀑布和初始化请求次数。 * 不发送聊天消息,不把查询串、令牌或正文写入证据。 + * + * 默认 INITIAL_LOAD_SAMPLES=20(方案要求每场景 ≥20)。 + * 预算失败时不得通过放宽阈值“关账”;设 INITIAL_LOAD_ENFORCE_BUDGETS=1 时硬断言。 */ const enabled = process.env.RUN_INITIAL_LOAD_BASELINE === '1'; const username = process.env.ADMIN_USERNAME?.trim() || ''; const password = process.env.ADMIN_PASSWORD?.trim() || ''; -const samples = Math.max(1, Number(process.env.INITIAL_LOAD_SAMPLES || '1')); +const samples = Math.max(1, Number(process.env.INITIAL_LOAD_SAMPLES || '20')); +const enforceBudgets = process.env.INITIAL_LOAD_ENFORCE_BUDGETS === '1'; const evidenceName = process.env.INITIAL_LOAD_EVIDENCE || 'login-waterfall.json'; const evidenceDir = path.resolve( path.dirname(fileURLToPath(import.meta.url)), - '../../.doc/test-reports/assets/2026-09-19-initial-load-u1', + '../../.doc/test-reports/assets/2026-09-22-initial-load-closeout', ); +/** 方案拟定预算:10 Mbps / 2 Mbps、CPU 4x 下登录表单可操作 p75。不得在失败后放宽。 */ +const BUDGET_FORM_P75_MS: Record = { + '10mbps-cpu4': 1_500, + '2mbps-cpu4': 3_000, +}; + test.skip(!enabled, 'set RUN_INITIAL_LOAD_BASELINE=1 against the real dev stack'); interface Capture { @@ -104,6 +116,7 @@ async function login(page: Page) { await page.fill('input[placeholder*="登录密码"]', password); const started = Date.now(); await page.click('button:has-text("进入医疗协同工作台")'); + // 工作台首可操作:新建会话 + 输入区。不得依赖 katex/mermaid/docx 等延迟模块。 await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); await expect(page.locator('textarea[placeholder*="向项目助手提问"]')).toBeVisible({ timeout: 30_000 }); return Date.now() - started; @@ -121,12 +134,19 @@ test('logged-in workbench records waterfall and init request counts', async ({ b const capture = watchRequests(page); const anonymousVisibleMs = await openAnonymous(page); const anonymousPerf = await readPerformance(page); + const anonymousResources = anonymousPerf.resources as ResourceSample[]; + const anonymousHeavy = firstPaintHeavyHits(anonymousResources); + expect(anonymousHeavy, `anonymous first-paint heavy chunks (sample ${sample})`).toEqual([]); + const workbenchReadyMs = await login(page); await page.waitForTimeout(1500); const loggedInPerf = await readPerformance(page); - const counts = countInitRequests(capture.requests); - const anonymousResources = anonymousPerf.resources as ResourceSample[]; const loggedInResources = loggedInPerf.resources as ResourceSample[]; + const loggedInHeavy = firstPaintHeavyHits(loggedInResources); + // U3:延迟模块不得挤进登录后首可操作路径;首按钮/输入区已可用时仍无 katex/mermaid/docx。 + expect(loggedInHeavy, `logged-in first-ready heavy chunks (sample ${sample})`).toEqual([]); + + const counts = countInitRequests(capture.requests); const row = { sample, anonymousVisibleMs, @@ -135,12 +155,14 @@ test('logged-in workbench records waterfall and init request counts', async ({ b navigation: anonymousPerf.navigation, paint: anonymousPerf.paint, summary: summarizeResources(anonymousResources), + heavyHits: anonymousHeavy, longTaskCount: anonymousPerf.longTasks.length, }, loggedIn: { navigation: loggedInPerf.navigation, paint: loggedInPerf.paint, summary: summarizeResources(loggedInResources), + heavyHits: loggedInHeavy, longTaskCount: loggedInPerf.longTasks.length, }, initRequests: counts, @@ -149,9 +171,24 @@ test('logged-in workbench records waterfall and init request counts', async ({ b writeFileSync(path.join(evidenceDir, evidenceName), JSON.stringify(rows, null, 2)); await context.close(); } + + const percentiles = { + samples, + anonymousVisibleMs: summarizeLatencies(rows.map((row) => row.anonymousVisibleMs)), + workbenchReadyMs: summarizeLatencies(rows.map((row) => row.workbenchReadyMs)), + }; + writeFileSync( + path.join(evidenceDir, evidenceName.replace(/\.json$/i, '-percentiles.json')), + JSON.stringify(percentiles, null, 2), + ); + const last = rows[rows.length - 1]; expect(last?.anonymous.summary.requestCount).toBeGreaterThan(0); expect(last?.workbenchReadyMs).toBeGreaterThan(0); + // Closeout requires n>=20; INITIAL_LOAD_ALLOW_SHORT=1 only for local smoke. + if (process.env.INITIAL_LOAD_ALLOW_SHORT !== '1') { + expect(samples).toBeGreaterThanOrEqual(20); + } const memberCap = process.env.EXPECT_INIT_MEMBER_PROJECTS; if (memberCap) { expect(last?.initRequests.distinctMemberProjects).toBeLessThanOrEqual(Number(memberCap)); @@ -168,7 +205,14 @@ test('throttled login still reaches the workbench', async ({ browser }) => { { name: '10mbps-cpu4', download: 1_250_000, upload: 625_000 }, { name: '2mbps-cpu4', download: 250_000, upload: 125_000 }, ]; - const rows = []; + const rows: { + profile: string; + sample: number; + anonymousVisibleMs: number; + workbenchReadyMs: number; + anonymousHeavy: string[]; + loggedInHeavy: string[]; + }[] = []; for (const profile of profiles) { for (let sample = 1; sample <= samples; sample += 1) { const context = await browser.newContext(); @@ -183,11 +227,106 @@ test('throttled login still reaches the workbench', async ({ browser }) => { await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); await page.route(/fonts\.(googleapis|gstatic)\.com/, (route) => route.abort()); const anonymousVisibleMs = await openAnonymous(page); + const anonymousPerf = await readPerformance(page); + const anonymousHeavy = firstPaintHeavyHits(anonymousPerf.resources as ResourceSample[]); + expect(anonymousHeavy, `${profile.name} anonymous heavy (sample ${sample})`).toEqual([]); + const workbenchReadyMs = await login(page); - rows.push({ profile: profile.name, sample, anonymousVisibleMs, workbenchReadyMs }); - writeFileSync(path.join(evidenceDir, process.env.INITIAL_LOAD_THROTTLE_EVIDENCE || 'throttled.json'), JSON.stringify(rows, null, 2)); + await page.waitForTimeout(800); + const loggedInPerf = await readPerformance(page); + const loggedInHeavy = firstPaintHeavyHits(loggedInPerf.resources as ResourceSample[]); + expect(loggedInHeavy, `${profile.name} logged-in heavy (sample ${sample})`).toEqual([]); + + rows.push({ + profile: profile.name, + sample, + anonymousVisibleMs, + workbenchReadyMs, + anonymousHeavy, + loggedInHeavy, + }); + writeFileSync( + path.join(evidenceDir, process.env.INITIAL_LOAD_THROTTLE_EVIDENCE || 'throttled.json'), + JSON.stringify(rows, null, 2), + ); await context.close(); } } + + const byProfile: Record< + string, + { + samples: number; + budgetFormP75Ms: number; + anonymousVisibleMs: ReturnType; + workbenchReadyMs: ReturnType; + formP75MeetsBudget: boolean | null; + } + > = {}; + for (const profile of profiles) { + const subset = rows.filter((row) => row.profile === profile.name); + const form = summarizeLatencies(subset.map((row) => row.anonymousVisibleMs)); + const ready = summarizeLatencies(subset.map((row) => row.workbenchReadyMs)); + const budget = BUDGET_FORM_P75_MS[profile.name] ?? Number.POSITIVE_INFINITY; + const meets = form.p75 == null ? null : form.p75 <= budget; + byProfile[profile.name] = { + samples: subset.length, + budgetFormP75Ms: budget, + anonymousVisibleMs: form, + workbenchReadyMs: ready, + formP75MeetsBudget: meets, + }; + expect(subset.length).toBeGreaterThanOrEqual( + process.env.INITIAL_LOAD_ALLOW_SHORT === '1' ? 1 : 20, + ); + if (enforceBudgets && form.p75 != null) { + expect( + form.p75, + `${profile.name} form p75 ${form.p75}ms exceeds budget ${budget}ms (do not relax)`, + ).toBeLessThanOrEqual(budget); + } + } + writeFileSync( + path.join(evidenceDir, process.env.INITIAL_LOAD_THROTTLE_PERCENTILES || 'throttled-percentiles.json'), + JSON.stringify({ samples, byProfile, enforceBudgets }, null, 2), + ); expect(rows.every((row) => row.workbenchReadyMs > 0)).toBe(true); }); + +test('post-ready login form visibility for U7 browser gap', async ({ browser }) => { + test.skip(process.env.INITIAL_LOAD_U7_FORM !== '1', 'set INITIAL_LOAD_U7_FORM=1 to measure form after /ready'); + if (!username || !password) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + mkdirSync(evidenceDir, { recursive: true }); + const formSamples = Math.max(1, Number(process.env.INITIAL_LOAD_U7_SAMPLES || String(samples))); + const rows = []; + for (let sample = 1; sample <= formSamples; sample += 1) { + const context = await browser.newContext(); + const page = await context.newPage(); + const started = Date.now(); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.locator('input[placeholder*="系统用户名"]').waitFor({ state: 'visible' }); + const formVisibleMs = Date.now() - started; + const perf = await readPerformance(page); + const heavy = firstPaintHeavyHits(perf.resources as ResourceSample[]); + expect(heavy, `U7 form heavy (sample ${sample})`).toEqual([]); + rows.push({ sample, formVisibleMs, heavyHits: heavy, summary: summarizeResources(perf.resources as ResourceSample[]) }); + writeFileSync(path.join(evidenceDir, 'u7-form-visibility.json'), JSON.stringify(rows, null, 2)); + await context.close(); + } + const summary = { + samples: formSamples, + formVisibleMs: summarizeLatencies(rows.map((row) => row.formVisibleMs)), + // 历史诊断「重启后首访约十几秒」→ 50% 即 ≤5s;此处测的是 /ready 已绿后的冷导航表单可见,不是整机重启。 + historicalBaselineMsApprox: 10_000, + fiftyPercentTargetMs: 5_000, + meetsFiftyPercentVsApproxBaseline: + summarizeLatencies(rows.map((row) => row.formVisibleMs)).p75 != null && + (summarizeLatencies(rows.map((row) => row.formVisibleMs)).p75 as number) <= 5_000, + scopeNote: + 'Measures cold navigation to login form while app already /ready. Does not restart postgres/app; prior U7 restart→ready timings remain separate evidence.', + }; + writeFileSync(path.join(evidenceDir, 'u7-form-visibility-percentiles.json'), JSON.stringify(summary, null, 2)); + expect(summary.formVisibleMs.n).toBe(formSamples); +}); diff --git a/web/package.json b/web/package.json index bed6f6c1..5f136cd3 100644 --- a/web/package.json +++ b/web/package.json @@ -17,7 +17,9 @@ "test:e2e:project143-regression": "RUN_UI_ACCEPTANCE=1 playwright test e2e/project143-regression.spec.ts --project=chromium", "test:e2e:workflow-plan-409-frozen-duties": "RUN_UI_ACCEPTANCE=1 playwright test e2e/workflow-plan-409-frozen-duties.spec.ts --project=chromium", "test:e2e:list": "playwright test --list", - "test:e2e:initial-load": "RUN_INITIAL_LOAD_BASELINE=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium" + "test:e2e:initial-load": "RUN_INITIAL_LOAD_BASELINE=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium", + "test:e2e:initial-load:throttle": "RUN_INITIAL_LOAD_BASELINE=1 INITIAL_LOAD_THROTTLE=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium -g throttled", + "test:e2e:initial-load:u7-form": "RUN_INITIAL_LOAD_BASELINE=1 INITIAL_LOAD_U7_FORM=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium -g 'U7 browser'" }, "dependencies": { "@tanstack/react-virtual": "^3.14.11", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 6d63c9e2..25a9a0d6 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -43,7 +43,13 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + // Prefer PLAYWRIGHT_CHANNEL (e.g. msedge) when bundled Chromium cannot be downloaded. + ...(process.env.PLAYWRIGHT_CHANNEL + ? { channel: process.env.PLAYWRIGHT_CHANNEL as 'chrome' | 'chrome-beta' | 'chrome-dev' | 'msedge' | 'msedge-beta' | 'msedge-dev' } + : {}), + }, }, ], }); diff --git a/web/src/lib/initialLoadMetrics.test.ts b/web/src/lib/initialLoadMetrics.test.ts index e4b24f25..6d674fcb 100644 --- a/web/src/lib/initialLoadMetrics.test.ts +++ b/web/src/lib/initialLoadMetrics.test.ts @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { countInitRequests, percentile, redactResourceUrl, summarizeResources } from './initialLoadMetrics'; +import { + countInitRequests, + firstPaintHeavyHits, + percentile, + redactResourceUrl, + summarizeLatencies, + summarizeResources, +} from './initialLoadMetrics'; test('redactResourceUrl drops query and origin', () => { assert.equal( @@ -20,6 +27,23 @@ test('summarizeResources flags deferred heavy chunks', () => { assert.deepEqual(summary.heavyChunks, ['/assets/katex-xyz.js']); }); +test('firstPaintHeavyHits lists only deferred module paths', () => { + assert.deepEqual( + firstPaintHeavyHits([ + { path: '/assets/index-abc.js', initiator: 'script', durationMs: 1, transferBytes: 10, decodedBytes: 40 }, + { path: '/assets/mermaid-abc.js', initiator: 'script', durationMs: 2, transferBytes: 20, decodedBytes: 80 }, + { path: '/assets/docx-abc.js', initiator: 'script', durationMs: 3, transferBytes: 30, decodedBytes: 120 }, + ]), + ['/assets/mermaid-abc.js', '/assets/docx-abc.js'], + ); + assert.deepEqual( + firstPaintHeavyHits([ + { path: '/assets/index-abc.js', initiator: 'script', durationMs: 1, transferBytes: 10, decodedBytes: 40 }, + ]), + [], + ); +}); + test('countInitRequests separates member fanout from session lists', () => { const counts = countInitRequests([ { method: 'GET', path: '/api/v1/projects/1/members' }, @@ -37,3 +61,23 @@ test('percentile uses nearest-rank and stays null on empty input', () => { assert.equal(percentile([], 75), null); assert.equal(percentile([10, 20, 30, 40], 75), 30); }); + +test('summarizeLatencies reports p50/p75/p95', () => { + assert.deepEqual(summarizeLatencies([]), { + n: 0, + p50: null, + p75: null, + p95: null, + min: null, + max: null, + }); + // 20 samples: p50=rank10 → 1000; p75=rank15 → 1500; p95=rank19 → 1900 + const values = Array.from({ length: 20 }, (_, i) => (i + 1) * 100); + const summary = summarizeLatencies(values); + assert.equal(summary.n, 20); + assert.equal(summary.p50, 1000); + assert.equal(summary.p75, 1500); + assert.equal(summary.p95, 1900); + assert.equal(summary.min, 100); + assert.equal(summary.max, 2000); +}); diff --git a/web/src/lib/initialLoadMetrics.ts b/web/src/lib/initialLoadMetrics.ts index 3a482909..3c342231 100644 --- a/web/src/lib/initialLoadMetrics.ts +++ b/web/src/lib/initialLoadMetrics.ts @@ -13,7 +13,17 @@ export interface RequestSample { path: string; } -const HEAVY_CHUNK = /katex|mermaid|docx|xlsx|AdminConsole|BlockEditor|FilePreview/i; +/** 首屏/首可操作前不得出现的延迟重模块路径片段。 */ +export const HEAVY_CHUNK = /katex|mermaid|docx|xlsx|AdminConsole|BlockEditor|FilePreview/i; + +export interface LatencySummary { + n: number; + p50: number | null; + p75: number | null; + p95: number | null; + min: number | null; + max: number | null; +} export function redactResourceUrl(raw: string): string { const noQuery = raw.split('?')[0] ?? raw; @@ -32,6 +42,21 @@ export function percentile(values: number[], p: number): number | null { return sorted[rank] ?? null; } +export function summarizeLatencies(values: number[]): LatencySummary { + if (values.length === 0) { + return { n: 0, p50: null, p75: null, p95: null, min: null, max: null }; + } + const sorted = [...values].sort((a, b) => a - b); + return { + n: values.length, + p50: percentile(sorted, 50), + p75: percentile(sorted, 75), + p95: percentile(sorted, 95), + min: sorted[0] ?? null, + max: sorted[sorted.length - 1] ?? null, + }; +} + export function summarizeResources(resources: ResourceSample[]) { const transferBytes = resources.reduce((sum, row) => sum + (row.transferBytes || 0), 0); const js = resources.filter((row) => row.path.endsWith('.js')); @@ -45,6 +70,11 @@ export function summarizeResources(resources: ResourceSample[]) { }; } +/** 首屏路径不得包含延迟重模块;空列表表示通过。 */ +export function firstPaintHeavyHits(resources: ResourceSample[]): string[] { + return [...new Set(resources.filter((row) => HEAVY_CHUNK.test(row.path)).map((row) => row.path))]; +} + export function countInitRequests(requests: RequestSample[]) { const members = requests.filter( (row) => row.method === 'GET' && /^\/api\/v1\/projects\/\d+\/members$/.test(row.path),