test(web): close initial-load perf harness for U1/U3/U7 evidence
This commit is contained in:
@@ -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 集成 / 部署 / 远端:分别待记。
|
||||||
+19
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+1237
File diff suppressed because it is too large
Load Diff
+19
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
+48
@@ -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
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
+15
@@ -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."
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -4,7 +4,9 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { expect, test, type Page, type Request } from '@playwright/test';
|
import { expect, test, type Page, type Request } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
countInitRequests,
|
countInitRequests,
|
||||||
|
firstPaintHeavyHits,
|
||||||
redactResourceUrl,
|
redactResourceUrl,
|
||||||
|
summarizeLatencies,
|
||||||
summarizeResources,
|
summarizeResources,
|
||||||
type RequestSample,
|
type RequestSample,
|
||||||
type ResourceSample,
|
type ResourceSample,
|
||||||
@@ -13,17 +15,27 @@ import {
|
|||||||
/**
|
/**
|
||||||
* U1 登录态基线。只测导航、资源瀑布和初始化请求次数。
|
* U1 登录态基线。只测导航、资源瀑布和初始化请求次数。
|
||||||
* 不发送聊天消息,不把查询串、令牌或正文写入证据。
|
* 不发送聊天消息,不把查询串、令牌或正文写入证据。
|
||||||
|
*
|
||||||
|
* 默认 INITIAL_LOAD_SAMPLES=20(方案要求每场景 ≥20)。
|
||||||
|
* 预算失败时不得通过放宽阈值“关账”;设 INITIAL_LOAD_ENFORCE_BUDGETS=1 时硬断言。
|
||||||
*/
|
*/
|
||||||
const enabled = process.env.RUN_INITIAL_LOAD_BASELINE === '1';
|
const enabled = process.env.RUN_INITIAL_LOAD_BASELINE === '1';
|
||||||
const username = process.env.ADMIN_USERNAME?.trim() || '';
|
const username = process.env.ADMIN_USERNAME?.trim() || '';
|
||||||
const password = process.env.ADMIN_PASSWORD?.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 evidenceName = process.env.INITIAL_LOAD_EVIDENCE || 'login-waterfall.json';
|
||||||
const evidenceDir = path.resolve(
|
const evidenceDir = path.resolve(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
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<string, number> = {
|
||||||
|
'10mbps-cpu4': 1_500,
|
||||||
|
'2mbps-cpu4': 3_000,
|
||||||
|
};
|
||||||
|
|
||||||
test.skip(!enabled, 'set RUN_INITIAL_LOAD_BASELINE=1 against the real dev stack');
|
test.skip(!enabled, 'set RUN_INITIAL_LOAD_BASELINE=1 against the real dev stack');
|
||||||
|
|
||||||
interface Capture {
|
interface Capture {
|
||||||
@@ -104,6 +116,7 @@ async function login(page: Page) {
|
|||||||
await page.fill('input[placeholder*="登录密码"]', password);
|
await page.fill('input[placeholder*="登录密码"]', password);
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
await page.click('button:has-text("进入医疗协同工作台")');
|
await page.click('button:has-text("进入医疗协同工作台")');
|
||||||
|
// 工作台首可操作:新建会话 + 输入区。不得依赖 katex/mermaid/docx 等延迟模块。
|
||||||
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.locator('textarea[placeholder*="向项目助手提问"]')).toBeVisible({ timeout: 30_000 });
|
await expect(page.locator('textarea[placeholder*="向项目助手提问"]')).toBeVisible({ timeout: 30_000 });
|
||||||
return Date.now() - started;
|
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 capture = watchRequests(page);
|
||||||
const anonymousVisibleMs = await openAnonymous(page);
|
const anonymousVisibleMs = await openAnonymous(page);
|
||||||
const anonymousPerf = await readPerformance(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);
|
const workbenchReadyMs = await login(page);
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
const loggedInPerf = await readPerformance(page);
|
const loggedInPerf = await readPerformance(page);
|
||||||
const counts = countInitRequests(capture.requests);
|
|
||||||
const anonymousResources = anonymousPerf.resources as ResourceSample[];
|
|
||||||
const loggedInResources = loggedInPerf.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 = {
|
const row = {
|
||||||
sample,
|
sample,
|
||||||
anonymousVisibleMs,
|
anonymousVisibleMs,
|
||||||
@@ -135,12 +155,14 @@ test('logged-in workbench records waterfall and init request counts', async ({ b
|
|||||||
navigation: anonymousPerf.navigation,
|
navigation: anonymousPerf.navigation,
|
||||||
paint: anonymousPerf.paint,
|
paint: anonymousPerf.paint,
|
||||||
summary: summarizeResources(anonymousResources),
|
summary: summarizeResources(anonymousResources),
|
||||||
|
heavyHits: anonymousHeavy,
|
||||||
longTaskCount: anonymousPerf.longTasks.length,
|
longTaskCount: anonymousPerf.longTasks.length,
|
||||||
},
|
},
|
||||||
loggedIn: {
|
loggedIn: {
|
||||||
navigation: loggedInPerf.navigation,
|
navigation: loggedInPerf.navigation,
|
||||||
paint: loggedInPerf.paint,
|
paint: loggedInPerf.paint,
|
||||||
summary: summarizeResources(loggedInResources),
|
summary: summarizeResources(loggedInResources),
|
||||||
|
heavyHits: loggedInHeavy,
|
||||||
longTaskCount: loggedInPerf.longTasks.length,
|
longTaskCount: loggedInPerf.longTasks.length,
|
||||||
},
|
},
|
||||||
initRequests: counts,
|
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));
|
writeFileSync(path.join(evidenceDir, evidenceName), JSON.stringify(rows, null, 2));
|
||||||
await context.close();
|
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];
|
const last = rows[rows.length - 1];
|
||||||
expect(last?.anonymous.summary.requestCount).toBeGreaterThan(0);
|
expect(last?.anonymous.summary.requestCount).toBeGreaterThan(0);
|
||||||
expect(last?.workbenchReadyMs).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;
|
const memberCap = process.env.EXPECT_INIT_MEMBER_PROJECTS;
|
||||||
if (memberCap) {
|
if (memberCap) {
|
||||||
expect(last?.initRequests.distinctMemberProjects).toBeLessThanOrEqual(Number(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: '10mbps-cpu4', download: 1_250_000, upload: 625_000 },
|
||||||
{ name: '2mbps-cpu4', download: 250_000, upload: 125_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 (const profile of profiles) {
|
||||||
for (let sample = 1; sample <= samples; sample += 1) {
|
for (let sample = 1; sample <= samples; sample += 1) {
|
||||||
const context = await browser.newContext();
|
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 client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||||
await page.route(/fonts\.(googleapis|gstatic)\.com/, (route) => route.abort());
|
await page.route(/fonts\.(googleapis|gstatic)\.com/, (route) => route.abort());
|
||||||
const anonymousVisibleMs = await openAnonymous(page);
|
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);
|
const workbenchReadyMs = await login(page);
|
||||||
rows.push({ profile: profile.name, sample, anonymousVisibleMs, workbenchReadyMs });
|
await page.waitForTimeout(800);
|
||||||
writeFileSync(path.join(evidenceDir, process.env.INITIAL_LOAD_THROTTLE_EVIDENCE || 'throttled.json'), JSON.stringify(rows, null, 2));
|
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();
|
await context.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const byProfile: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
samples: number;
|
||||||
|
budgetFormP75Ms: number;
|
||||||
|
anonymousVisibleMs: ReturnType<typeof summarizeLatencies>;
|
||||||
|
workbenchReadyMs: ReturnType<typeof summarizeLatencies>;
|
||||||
|
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);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
+3
-1
@@ -17,7 +17,9 @@
|
|||||||
"test:e2e:project143-regression": "RUN_UI_ACCEPTANCE=1 playwright test e2e/project143-regression.spec.ts --project=chromium",
|
"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: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: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": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.14.11",
|
"@tanstack/react-virtual": "^3.14.11",
|
||||||
|
|||||||
@@ -43,7 +43,13 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
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' }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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', () => {
|
test('redactResourceUrl drops query and origin', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -20,6 +27,23 @@ test('summarizeResources flags deferred heavy chunks', () => {
|
|||||||
assert.deepEqual(summary.heavyChunks, ['/assets/katex-xyz.js']);
|
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', () => {
|
test('countInitRequests separates member fanout from session lists', () => {
|
||||||
const counts = countInitRequests([
|
const counts = countInitRequests([
|
||||||
{ method: 'GET', path: '/api/v1/projects/1/members' },
|
{ 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([], 75), null);
|
||||||
assert.equal(percentile([10, 20, 30, 40], 75), 30);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,7 +13,17 @@ export interface RequestSample {
|
|||||||
path: string;
|
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 {
|
export function redactResourceUrl(raw: string): string {
|
||||||
const noQuery = raw.split('?')[0] ?? raw;
|
const noQuery = raw.split('?')[0] ?? raw;
|
||||||
@@ -32,6 +42,21 @@ export function percentile(values: number[], p: number): number | null {
|
|||||||
return sorted[rank] ?? 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[]) {
|
export function summarizeResources(resources: ResourceSample[]) {
|
||||||
const transferBytes = resources.reduce((sum, row) => sum + (row.transferBytes || 0), 0);
|
const transferBytes = resources.reduce((sum, row) => sum + (row.transferBytes || 0), 0);
|
||||||
const js = resources.filter((row) => row.path.endsWith('.js'));
|
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[]) {
|
export function countInitRequests(requests: RequestSample[]) {
|
||||||
const members = requests.filter(
|
const members = requests.filter(
|
||||||
(row) => row.method === 'GET' && /^\/api\/v1\/projects\/\d+\/members$/.test(row.path),
|
(row) => row.method === 'GET' && /^\/api\/v1\/projects\/\d+\/members$/.test(row.path),
|
||||||
|
|||||||
Reference in New Issue
Block a user