forked from wangziqi/gongxue-base
test: write stable launch persona evidence
This commit is contained in:
16
README.md
16
README.md
@@ -604,10 +604,12 @@ npm run perf:postgres:sql -- --profile=shared-host
|
||||
|
||||
```powershell
|
||||
$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
|
||||
npm run smoke:launch-persona
|
||||
$env:LAUNCH_SMOKE_AUTH_MODE="app_session"
|
||||
npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md
|
||||
Remove-Item Env:\LAUNCH_SMOKE_AUTH_MODE
|
||||
```
|
||||
|
||||
`smoke:launch-persona` 会从普通学生、租户管理员、平台管理员三个视角调用真实 API,默认使用 `LAUNCH_SMOKE_AUTH_MODE=app_session` 的 Bearer `tk_` session,不依赖旧 `x-user-id` 或平台本地 key;覆盖 SVIP 后刷题、收藏、错题复习入口、租户数据看板/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试记录,生产只建议在灰度或演练租户运行。
|
||||
`smoke:launch-persona` 会从普通学生、租户管理员、平台管理员三个视角调用真实 API,默认使用 `LAUNCH_SMOKE_AUTH_MODE=app_session` 的 Bearer `tk_` session,不依赖旧 `x-user-id` 或平台本地 key;覆盖 SVIP 后刷题、收藏、错题复习入口、租户数据看板/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试记录,生产只建议在灰度或演练租户运行。脚本始终保留带时间戳的 JSON/Markdown 报告;写生产证据时用 `--write` 和 `--write-md` 同步生成 `production-launch-evidence.json` 里引用的稳定 artifact。
|
||||
|
||||
Taro H5 发布产物守卫:
|
||||
|
||||
@@ -632,12 +634,16 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime
|
||||
npm --silent run manifest:taro:h5 -- --require-dist --require-runtime-config --json --write docs/refactor/launch-artifacts/taro-h5-release-manifest.json > docs/refactor/launch-artifacts/taro-h5-release-manifest.stdout.json
|
||||
```
|
||||
|
||||
最近一次本地真实迁移库已包含前期压测写入记录,当前规模约为 19 个租户、7.4 万题、1,630 个题目合集、3,120 个练习蓝图、3,519 个单词、2,692 条知识手册、3,722 个用户、7.3 万个练习 session、30.3 万条答题记录、3.8 万条错题、1,501 个内容资源和 472 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。
|
||||
最近一次本地真实迁移库已包含前期压测写入记录,当前规模约为 19 个租户、74,131 道题、1,631 个题目合集、3,120 个练习蓝图、3,519 个单词、2,692 条知识手册、3,722 个平台用户、3,712 个学生资料、81,160 个练习 session、327,925 条答题记录、38,207 条错题、1,501 个内容资源和 474 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。
|
||||
|
||||
README 只保留最新 Docker API 受限资源复核。更早的高资源 Docker Desktop 历史结果和每轮说明见 `docs/refactor/performance-benchmark-summary-20260630.md`。
|
||||
README 只保留最新本地真实数据压测摘要。更早的 Docker API 受限资源复核、高资源 Docker Desktop 历史结果和每轮说明见 `docs/refactor/performance-benchmark-summary-20260630.md` 与 `docs/refactor/backend-open-items-and-capacity-20260701.md`。
|
||||
|
||||
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| API 进程 / 30 | 120s | 0% | 37,520 | 0.00% | 310.34 req/s | 251.08 ms | 388.49 ms |
|
||||
| API 进程 / 50 | 60s | 10% | 25,047 | 0.00% | 410.49 req/s | 237.41 ms | 344.51 ms |
|
||||
| API 进程 / 100 | 60s | 8% | 24,116 | 0.00% | 393.56 req/s | 437.37 ms | 533.63 ms |
|
||||
| API 进程 / 150 | 60s | 6% | 22,864 | 0.00% | 371.29 req/s | 633.46 ms | 759.62 ms |
|
||||
| Docker API 2c4g / 30 | 120s | 0% | 36,800 | 0.00% | 305.59 req/s | 241.46 ms | 351.17 ms |
|
||||
| Docker API 2c4g / 50 | 60s | 10% | 24,120 | 0.00% | 398.79 req/s | 231.28 ms | 325.94 ms |
|
||||
| Docker API 2c4g / 100 | 60s | 8% | 22,462 | 0.00% | 370.86 req/s | 445.26 ms | 532.32 ms |
|
||||
@@ -655,7 +661,7 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-
|
||||
|
||||
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
|
||||
|
||||
本地结论:当前 Docker Desktop 分配 20 CPU、约 63GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 07:59 只限制 API 容器时,50 worker 混合读写为 24,120 请求、0 错误、398.79 req/s、P95 231.28ms,这是“DB 未受限”的乐观观察;按 0.05 到 0.2 req/s/人的页面节奏粗略折算,约对应 1,994 到 7,976 名活跃在线学生请求吞吐。100 worker 上沿为 370.86 req/s、P95 445.26ms,约对应 1,854 到 7,417 名;150 worker 进入压力区,为 340.74 req/s、P95 689.67ms,约对应 1,704 到 6,815 名。2026-07-01 06:53 同时限制 API 2C/4G 和 DB 2C/8G 时,30 worker 只读和 50 worker 混合读写仍 0 错误,但 P95 分别升至 1183.29ms 和 996.01ms,未通过上线延迟门禁,这是“DB 受限但未调 PostgreSQL 参数”的悲观观察;50 worker 109.61 req/s 只可粗略折算约 548 到 2,192 名活跃在线学生的下限吞吐,不能作为可售 SLA。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、`pg_stat_statements`、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
|
||||
本地结论:当前 Docker Desktop 分配 20 CPU、约 63GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 08:41 本地 API 进程压测中,50 worker 混合读写为 25,047 请求、0 错误、410.49 req/s、P95 237.41ms,这是当前真实迁移库的舒适观察;按 0.05 到 0.2 req/s/人的页面节奏粗略折算,约对应 2,052 到 8,210 名活跃在线学生请求吞吐。100 worker 为 393.56 req/s、P95 437.37ms,约对应 1,968 到 7,871 名,可作为本机可用上沿;150 worker 进入压力区,为 371.29 req/s、P95 633.46ms,约对应 1,856 到 7,426 名。2026-07-01 06:53 同时限制 API 2C/4G 和 DB 2C/8G 时,30 worker 只读和 50 worker 混合读写仍 0 错误,但 P95 分别升至 1183.29ms 和 996.01ms,未通过上线延迟门禁,这是“DB 受限但未调 PostgreSQL 参数”的悲观观察;50 worker 109.61 req/s 只可粗略折算约 548 到 2,192 名活跃在线学生的下限吞吐,不能作为可售 SLA。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、`pg_stat_statements`、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
|
||||
|
||||
```text
|
||||
docs/refactor/performance-benchmark-summary-20260630.md
|
||||
|
||||
@@ -43,9 +43,24 @@
|
||||
- 环境:Windows + Docker Desktop + 本地 Supabase/PostgreSQL + 本地 API 进程。
|
||||
- Docker Desktop 当前资源:20 CPU、约 62.7GB 内存。这个本机结果会高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。
|
||||
- 数据库:PocketBase 真实导入数据,并已包含前期写入压测产生的练习/答题记录。
|
||||
- 当前本地迁移库规模:19 个租户、约 7.4 万道题、3,722 个用户、1,631 个题目合集、3,120 个练习蓝图、3,519 个单词、2,692 条知识手册条目、7.3 万练习 session、30.3 万条答题记录、3.8 万条错题、1,501 个内容资源、472 条权益。后续 `db:smoke-seed`、集成测试和压测会继续写入练习/答题/测试租户数据,因此这里按当前本地观察描述,不把每次测试后的瞬时行数当作生产 SLA 口径。
|
||||
- 当前本地迁移库规模:19 个租户、74,131 道题、3,722 个平台用户、3,712 个学生资料、1,631 个题目合集、3,120 个练习蓝图、3,519 个单词、2,692 条知识手册条目、81,160 个练习 session、327,925 条答题记录、38,207 条错题、1,501 个内容资源、474 条权益。后续 `db:smoke-seed`、集成测试和压测会继续写入练习/答题/测试租户数据,因此这里按当前本地观察描述,不把每次测试后的瞬时行数当作生产 SLA 口径。
|
||||
- 排行榜未纳入默认负载,因为产品默认关闭。
|
||||
|
||||
### 2026-07-01 08:41 本地真实迁移库 API 进程压测
|
||||
|
||||
本轮使用本地 Node API 进程自动启动临时端口,数据库为 Docker Desktop 中的本地 Supabase/PostgreSQL,鉴权使用 `PERF_AUTH_MODE=app_session` 的 Bearer session。Docker Desktop 当前分配 20 CPU、约 62.7GB 内存,高于常见 4 核 16G 云服务器;因此这轮是“真实迁移数据 + 本机较高资源”的代码与索引能力观察,不是生产 SLA。
|
||||
|
||||
| 场景 | 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 只读上线门禁 | 30 | 120s | 0% | 37,520 | 0.00% | 310.34 req/s | 251.08 ms | 388.49 ms | 通过 |
|
||||
| 混合读写 | 50 | 60s | 10% | 25,047 | 0.00% | 410.49 req/s | 237.41 ms | 344.51 ms | 舒适 |
|
||||
| 混合读写 | 100 | 60s | 8% | 24,116 | 0.00% | 393.56 req/s | 437.37 ms | 533.63 ms | 可用上沿 |
|
||||
| 混合读写 | 150 | 60s | 6% | 22,864 | 0.00% | 371.29 req/s | 633.46 ms | 759.62 ms | 压力区 |
|
||||
|
||||
混合读写包含真实刷题闭环:创建练习 session、拉取 session detail、提交答案、交卷、读取报告。150 worker 仍保持 0 错误,但 P95 已超过 600ms;当前本机可把 100 worker 混合读写视为可用上沿,把 50 worker 混合读写视为舒适区。
|
||||
|
||||
同轮 `LAUNCH_SMOKE_AUTH_MODE=app_session npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md` 已通过,并生成稳定上线证据 artifact。角色旅程覆盖普通学生 SVIP 后刷题、收藏、错题复习入口,租户管理员 dashboard/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及学生越权后台和跨租户访问拒绝。
|
||||
|
||||
### 2026-07-01 07:59 真实迁移库 Docker API 受限资源复核
|
||||
|
||||
本轮使用 `npm run perf:api:docker-4c16g`,API 容器限制为 2 CPU/4G、`DB_POOL_MAX=10`、关闭 legacy `x-user-id` 和平台本地 key,压测鉴权使用 `PERF_AUTH_MODE=app_session` 的 Bearer session。数据库仍为本机 Docker Desktop Supabase/PostgreSQL,未限制为 4 核 16G,因此结果更接近“受限 API 容器 + 本机数据库”的预生产观察,不是最终云服务器 SLA。
|
||||
@@ -67,13 +82,13 @@
|
||||
|
||||
压测 worker 是无停顿请求流,不等于真实在线学生。刷题学生会读题、思考、翻页和等待网络,前端完成后还需要用真实埋点计算“单个活跃学生平均 RPS”。
|
||||
|
||||
按目前常见页面节奏先用 `0.05 到 0.2 req/s/人` 粗略折算:
|
||||
按目前常见页面节奏先用 `0.05 到 0.2 req/s/人` 粗略折算。本轮本地 API 进程压测的在线吞吐观察为:
|
||||
|
||||
- 50 worker 舒适场景 398.79 req/s,约等于 1,994 到 7,976 名活跃在线学生的请求吞吐。
|
||||
- 100 worker 上沿场景 370.86 req/s,约等于 1,854 到 7,417 名活跃在线学生的请求吞吐。
|
||||
- 150 worker 压力场景 340.74 req/s,约等于 1,704 到 6,815 名活跃在线学生的请求吞吐。
|
||||
- 50 worker 舒适场景 410.49 req/s,约等于 2,052 到 8,210 名活跃在线学生的请求吞吐。
|
||||
- 100 worker 可用上沿场景 393.56 req/s,约等于 1,968 到 7,871 名活跃在线学生的请求吞吐。
|
||||
- 150 worker 压力场景 371.29 req/s,约等于 1,856 到 7,426 名活跃在线学生的请求吞吐。
|
||||
|
||||
当前本地结论:在受限 API 容器下,后端真实刷题读写链路的舒适观察区间约为 399 req/s,折算约 1,994 到 7,976 名活跃在线学生;100 worker 仍可用但 P95 已超过 400ms,150 worker 仍 0 错误但 P95 已接近 700ms,视为压力区,不建议作为生产承诺。受限 API 容器会明显压低上沿,因此上线承诺仍以云端受限资源复测为准。
|
||||
当前本地结论:在真实迁移数据和本机较高资源下,后端刷题读写链路的舒适观察区间约为 410 req/s,折算约 2,052 到 8,210 名活跃在线学生;100 worker 仍满足混合写入观察线但 P95 已超过 400ms,可作为当前本机可用上沿;150 worker 仍 0 错误但 P95 已超过 600ms,视为压力区,不建议作为生产承诺。正式对外容量必须以云端 4 核 16G、PostgreSQL shared-host 参数、真实对象存储/CDN 和前端真实请求节奏复测为准。
|
||||
|
||||
### 2026-07-01 Docker API + DB 显式受限复核
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ npm run perf:postgres:sql -- --profile=shared-host
|
||||
角色旅程烟测:
|
||||
|
||||
```bash
|
||||
npm run smoke:launch-persona
|
||||
npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md
|
||||
```
|
||||
|
||||
该脚本从普通学生、租户管理员、平台管理员三个视角调用真实 API,覆盖 SVIP 后刷题、收藏、错题复习入口、租户 dashboard/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试练习与收藏记录,只建议在本地、预生产或灰度租户运行。
|
||||
该脚本从普通学生、租户管理员、平台管理员三个视角调用真实 API,覆盖 SVIP 后刷题、收藏、错题复习入口、租户 dashboard/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试练习与收藏记录,只建议在本地、预生产或灰度租户运行。默认会生成带时间戳的报告;生产证据必须额外带 `--write` 生成稳定 JSON,供 `production-launch-evidence.json` 和 `launch:gate` 引用。
|
||||
|
||||
## 前置条件
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
{
|
||||
"id": "api.launch-persona-smoke",
|
||||
"status": "pass",
|
||||
"command": "LAUNCH_SMOKE_AUTH_MODE=app_session npm run smoke:launch-persona > docs/refactor/launch-artifacts/launch-persona-smoke.log",
|
||||
"command": "LAUNCH_SMOKE_AUTH_MODE=app_session npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md > docs/refactor/launch-artifacts/launch-persona-smoke.log",
|
||||
"completedAt": "2026-06-30T10:49:30+08:00",
|
||||
"artifact": "launch-artifacts/launch-persona-smoke.json",
|
||||
"summary": {
|
||||
|
||||
@@ -87,10 +87,10 @@ npm run readiness:production:db
|
||||
npm run smoke:taro:h5
|
||||
npm run smoke:taro:h5:interaction
|
||||
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||
npm run smoke:launch-persona
|
||||
npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md
|
||||
```
|
||||
|
||||
`smoke:launch-persona` 是真实 API 角色旅程烟测,必须进入生产上线证据;正式证据必须使用 `LAUNCH_SMOKE_AUTH_MODE=app_session`,通过 Bearer `tk_` session 验证身份,不使用旧 `x-user-id` 或平台本地 key。普通学生要能在 SVIP 权益下创建练习、答题、收藏题目、进入收藏复习和错题复习入口;租户管理员要能读取看板/主题/学生/销售转化并拒绝学生或跨租户访问;平台管理员要能读取租户/套餐/审计入口并拒绝学生访问平台后台。
|
||||
`smoke:launch-persona` 是真实 API 角色旅程烟测,必须进入生产上线证据;正式证据必须使用 `LAUNCH_SMOKE_AUTH_MODE=app_session`,通过 Bearer `tk_` session 验证身份,不使用旧 `x-user-id` 或平台本地 key。普通学生要能在 SVIP 权益下创建练习、答题、收藏题目、进入收藏复习和错题复习入口;租户管理员要能读取看板/主题/学生/销售转化并拒绝学生或跨租户访问;平台管理员要能读取租户/套餐/审计入口并拒绝学生访问平台后台。生产证据命令必须带 `--write docs/refactor/launch-artifacts/launch-persona-smoke.json`,确保 `production-launch-evidence.json` 引用的 artifact 是稳定路径,不是只存在带时间戳的本地报告。
|
||||
|
||||
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题;当前脚本覆盖 32 项检查,包括学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台内容导入、公共题库采纳/同步/冲突处理、学生运营、营销/CRM/分佣、主题/角色/成员写操作,以及平台后台租户、账务、公共题库授权和员工写操作。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。
|
||||
|
||||
@@ -100,6 +100,7 @@ npm run smoke:launch-persona
|
||||
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json
|
||||
npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json
|
||||
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json
|
||||
npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md > docs/refactor/launch-artifacts/launch-persona-smoke.log
|
||||
```
|
||||
|
||||
`security:repo` 是仓库自带的静态安全扫描,会拦截密钥形态、前端旧鉴权头、真实 runtime-config 和生产证据误入 Git。它不能替代真实 `@codex-security`;如插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
||||
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
|
||||
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
||||
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/product-scope-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
|
||||
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/product-scope-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/launch-persona-smoke-test.js && node scripts/production-launch-gate-test.js",
|
||||
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
|
||||
"test:launch-gate": "node scripts/production-launch-gate-test.js",
|
||||
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
|
||||
|
||||
48
scripts/launch-persona-smoke-test.js
Normal file
48
scripts/launch-persona-smoke-test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { parseCliArgs } from './launch-persona-smoke.js';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
const writeOnly = parseCliArgs(['--write', 'docs/refactor/launch-artifacts/launch-persona-smoke.json']);
|
||||
assert.equal(
|
||||
writeOnly.writePath,
|
||||
path.resolve(repoRoot, 'docs/refactor/launch-artifacts/launch-persona-smoke.json'),
|
||||
'--write should resolve stable JSON evidence paths from the repo root',
|
||||
);
|
||||
assert.equal(writeOnly.writeMdPath, '', '--write should not imply a stable Markdown report path');
|
||||
|
||||
const equalsForm = parseCliArgs([
|
||||
'--output-dir=docs/refactor/launch-artifacts',
|
||||
'--write=docs/refactor/launch-artifacts/launch-persona-smoke.json',
|
||||
'--write-md=docs/refactor/launch-artifacts/launch-persona-smoke.md',
|
||||
'--json',
|
||||
]);
|
||||
assert.equal(
|
||||
equalsForm.outputDir,
|
||||
path.resolve(repoRoot, 'docs/refactor/launch-artifacts'),
|
||||
'--output-dir should resolve stable report directories',
|
||||
);
|
||||
assert.equal(
|
||||
equalsForm.writePath,
|
||||
path.resolve(repoRoot, 'docs/refactor/launch-artifacts/launch-persona-smoke.json'),
|
||||
'--write= should resolve stable JSON evidence paths',
|
||||
);
|
||||
assert.equal(
|
||||
equalsForm.writeMdPath,
|
||||
path.resolve(repoRoot, 'docs/refactor/launch-artifacts/launch-persona-smoke.md'),
|
||||
'--write-md= should resolve stable Markdown evidence paths',
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => parseCliArgs(['--write']),
|
||||
/requires a file path value/,
|
||||
'--write without a value should fail fast',
|
||||
);
|
||||
assert.throws(
|
||||
() => parseCliArgs(['--unknown']),
|
||||
/Unknown launch persona smoke option/,
|
||||
'unknown options should fail fast instead of silently dropping evidence paths',
|
||||
);
|
||||
|
||||
console.log('[PASS] launch persona smoke cli options');
|
||||
@@ -4,11 +4,16 @@ import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import pg from 'pg';
|
||||
|
||||
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const cliOptions = parseCliArgs(process.argv.slice(2));
|
||||
const databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL;
|
||||
const outputDir = process.env.LAUNCH_SMOKE_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
|
||||
const outputDir =
|
||||
cliOptions.outputDir || resolvePathOption(process.env.LAUNCH_SMOKE_OUTPUT_DIR || 'docs/refactor/launch-artifacts');
|
||||
const stableJsonPath = cliOptions.writePath || resolvePathOption(process.env.LAUNCH_SMOKE_WRITE_PATH || '');
|
||||
const stableMdPath = cliOptions.writeMdPath || resolvePathOption(process.env.LAUNCH_SMOKE_WRITE_MD_PATH || '');
|
||||
const startServer = boolEnv('LAUNCH_SMOKE_START_SERVER', !process.env.LAUNCH_SMOKE_API_BASE);
|
||||
const fixedPort = Number(process.env.LAUNCH_SMOKE_API_PORT || 0) || 0;
|
||||
const authMode = normalizeAuthMode(process.env.LAUNCH_SMOKE_AUTH_MODE || 'app_session');
|
||||
@@ -29,6 +34,62 @@ let serverProcess = null;
|
||||
let serverLogs = '';
|
||||
let authTokens = {};
|
||||
|
||||
function resolvePathOption(value) {
|
||||
if (!value) return '';
|
||||
return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
|
||||
}
|
||||
|
||||
function readValueArg(argv, index, flag) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a file path value.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseCliArgs(argv = []) {
|
||||
const options = {
|
||||
outputDir: '',
|
||||
writePath: '',
|
||||
writeMdPath: '',
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--json') {
|
||||
continue;
|
||||
}
|
||||
if (arg === '--output-dir') {
|
||||
options.outputDir = resolvePathOption(readValueArg(argv, index, arg));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--output-dir=')) {
|
||||
options.outputDir = resolvePathOption(arg.slice('--output-dir='.length));
|
||||
continue;
|
||||
}
|
||||
if (arg === '--write') {
|
||||
options.writePath = resolvePathOption(readValueArg(argv, index, arg));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--write=')) {
|
||||
options.writePath = resolvePathOption(arg.slice('--write='.length));
|
||||
continue;
|
||||
}
|
||||
if (arg === '--write-md') {
|
||||
options.writeMdPath = resolvePathOption(readValueArg(argv, index, arg));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--write-md=')) {
|
||||
options.writeMdPath = resolvePathOption(arg.slice('--write-md='.length));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown launch persona smoke option: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function boolEnv(key, fallback) {
|
||||
const value = process.env[key];
|
||||
if (value === undefined || value === '') return fallback;
|
||||
@@ -483,7 +544,8 @@ async function writeReport(report) {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const jsonPath = path.join(outputDir, `launch-persona-smoke-${shanghaiStamp()}.json`);
|
||||
const mdPath = jsonPath.replace(/\.json$/, '.md');
|
||||
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
const jsonPayload = `${JSON.stringify(report, null, 2)}\n`;
|
||||
await fs.writeFile(jsonPath, jsonPayload, 'utf8');
|
||||
const lines = [
|
||||
'# 上线前角色旅程烟测报告',
|
||||
'',
|
||||
@@ -498,8 +560,19 @@ async function writeReport(report) {
|
||||
'说明:该脚本会写入少量 `launch_persona_smoke` 测试权益、练习 session、答题和收藏记录。生产环境仅建议在灰度/演练租户运行。',
|
||||
'',
|
||||
];
|
||||
await fs.writeFile(mdPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
return { jsonPath, mdPath };
|
||||
const mdPayload = `${lines.join('\n')}\n`;
|
||||
await fs.writeFile(mdPath, mdPayload, 'utf8');
|
||||
|
||||
if (stableJsonPath) {
|
||||
await fs.mkdir(path.dirname(stableJsonPath), { recursive: true });
|
||||
await fs.writeFile(stableJsonPath, jsonPayload, 'utf8');
|
||||
}
|
||||
if (stableMdPath) {
|
||||
await fs.mkdir(path.dirname(stableMdPath), { recursive: true });
|
||||
await fs.writeFile(stableMdPath, mdPayload, 'utf8');
|
||||
}
|
||||
|
||||
return { jsonPath, mdPath, stableJsonPath, stableMdPath };
|
||||
}
|
||||
|
||||
async function runStep(name, fn) {
|
||||
@@ -543,6 +616,8 @@ async function main() {
|
||||
console.log(`[launch-smoke] status=${report.status}`);
|
||||
console.log(`[launch-smoke] wrote ${files.jsonPath}`);
|
||||
console.log(`[launch-smoke] wrote ${files.mdPath}`);
|
||||
if (files.stableJsonPath) console.log(`[launch-smoke] wrote ${files.stableJsonPath}`);
|
||||
if (files.stableMdPath) console.log(`[launch-smoke] wrote ${files.stableMdPath}`);
|
||||
if (report.status !== 'pass') process.exitCode = 1;
|
||||
} finally {
|
||||
await pool.end();
|
||||
@@ -550,9 +625,13 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
if (serverLogs) console.error(serverLogs);
|
||||
stopServer();
|
||||
process.exit(1);
|
||||
});
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
if (serverLogs) console.error(serverLogs);
|
||||
stopServer();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { parseCliArgs };
|
||||
|
||||
Reference in New Issue
Block a user