From 91abf854fe5deeba782fe0f6efcebba1cac82bda Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 08:45:20 +0800 Subject: [PATCH] test: write stable launch persona evidence --- README.md | 16 ++- ...ackend-open-items-and-capacity-20260701.md | 27 +++-- .../refactor/performance-benchmark-runbook.md | 4 +- .../production-launch-evidence.template.json | 2 +- .../web-launch-acceptance-checklist.md | 5 +- package.json | 2 +- scripts/launch-persona-smoke-test.js | 48 +++++++++ scripts/launch-persona-smoke.js | 99 +++++++++++++++++-- 8 files changed, 176 insertions(+), 27 deletions(-) create mode 100644 scripts/launch-persona-smoke-test.js diff --git a/README.md b/README.md index 2b51e7a7..845d6564 100644 --- a/README.md +++ b/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 diff --git a/docs/refactor/backend-open-items-and-capacity-20260701.md b/docs/refactor/backend-open-items-and-capacity-20260701.md index 2d9c6205..c0562d62 100644 --- a/docs/refactor/backend-open-items-and-capacity-20260701.md +++ b/docs/refactor/backend-open-items-and-capacity-20260701.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 显式受限复核 diff --git a/docs/refactor/performance-benchmark-runbook.md b/docs/refactor/performance-benchmark-runbook.md index 319552a5..1a2fc83c 100644 --- a/docs/refactor/performance-benchmark-runbook.md +++ b/docs/refactor/performance-benchmark-runbook.md @@ -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` 引用。 ## 前置条件 diff --git a/docs/refactor/production-launch-evidence.template.json b/docs/refactor/production-launch-evidence.template.json index 48516ba7..4c5c082f 100644 --- a/docs/refactor/production-launch-evidence.template.json +++ b/docs/refactor/production-launch-evidence.template.json @@ -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": { diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index 87c61dba..4c42ffc9 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -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 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。 diff --git a/package.json b/package.json index b79422a8..815faf0d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/launch-persona-smoke-test.js b/scripts/launch-persona-smoke-test.js new file mode 100644 index 00000000..1843562b --- /dev/null +++ b/scripts/launch-persona-smoke-test.js @@ -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'); diff --git a/scripts/launch-persona-smoke.js b/scripts/launch-persona-smoke.js index 9453515a..117fcea1 100644 --- a/scripts/launch-persona-smoke.js +++ b/scripts/launch-persona-smoke.js @@ -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 };