test: add taro h5 static launch smoke

This commit is contained in:
Codex
2026-07-01 01:06:49 +08:00
parent d2273e9b12
commit 26ab499a80
8 changed files with 431 additions and 11 deletions

View File

@@ -138,6 +138,12 @@ apps/taro/dist/h5-platform-admin
推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `apps/taro/src/services/api.ts` 请求层,业务数据默认调用 `apps/api`,不要在页面里直写 Supabase 表。 推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `apps/taro/src/services/api.ts` 请求层,业务数据默认调用 `apps/api`,不要在页面里直写 Supabase 表。
构建后可以先跑静态启动烟测,确认三套 H5 产物能被普通静态服务器托管、`runtime-config.json` 只含公开字段、JS/CSS 资源不 404并用 mock 后端验证 `/api/tenant/resolve` 契约:
```bash
npm run smoke:taro:h5
```
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包: H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
```text ```text
@@ -563,10 +569,11 @@ Taro H5 发布产物守卫:
npm run build:taro:h5:student npm run build:taro:h5:student
npm run build:taro:h5:tenant npm run build:taro:h5:tenant
npm run build:taro:h5:platform npm run build:taro:h5:platform
npm run smoke:taro:h5
node scripts\taro-h5-release-guardrails-test.js --require-dist node scripts\taro-h5-release-guardrails-test.js --require-dist
``` ```
该守卫会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json` `smoke:taro:h5` 会启动临时静态服务器和 mock API验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`
最近一次本地真实迁移库数据规模约为 74,117 题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、3,690 个用户、113,810 条答题记录、38,207 条错题和 458 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS再折算在线容量。 最近一次本地真实迁移库数据规模约为 74,117 题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、3,690 个用户、113,810 条答题记录、38,207 条错题和 458 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS再折算在线容量。
@@ -574,6 +581,9 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms | | 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms |
| 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms | | 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms |
| 50 | 60s | 10% | 51,425 | 0.00% | 845.06 req/s | 110.39 ms | 138.79 ms |
| 100 | 60s | 8% | 51,103 | 0.00% | 838.42 req/s | 210.07 ms | 272.15 ms |
| 150 | 60s | 6% | 44,889 | 0.00% | 735.22 req/s | 357.74 ms | 469.28 ms |
| 30 | 120s | 10% | 112,896 | 0.00% | 934.74 req/s | 64.26 ms | 81.14 ms | | 30 | 120s | 10% | 112,896 | 0.00% | 934.74 req/s | 64.26 ms | 81.14 ms |
| 50 | 120s | 10% | 92,737 | 0.00% | 767.24 req/s | 121.51 ms | 159.12 ms | | 50 | 120s | 10% | 92,737 | 0.00% | 767.24 req/s | 121.51 ms | 159.12 ms |
| 100 | 120s | 8% | 84,608 | 0.00% | 699.27 req/s | 254.69 ms | 331.84 ms | | 100 | 120s | 8% | 84,608 | 0.00% | 699.27 req/s | 254.69 ms | 331.84 ms |
@@ -587,7 +597,7 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。 使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
本地结论100 个无停顿 worker 内 P95 仍低于 300ms150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。最新只读 30 worker/120s 为 897.04 req/s粗略按单学生 0.05 到 0.2 req/s 可换算为 4,485 到 17,940 名活跃在线学生请求吞吐,但这不是生产 SLA。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on``statement_timeout``idle_in_transaction_session_timeout``lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见: 本地结论100 个无停顿 worker 内 P95 仍低于 300ms150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。最新只读 30 worker/120s 为 897.04 req/s最新混合读写 100 worker/60s/8% 写入为 838.42 req/s、P95 210.07ms、0 错误。粗略按单学生 0.05 到 0.2 req/s 可换算为数千到一万多名活跃在线学生请求吞吐,但这不是生产 SLA。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on``statement_timeout``idle_in_transaction_session_timeout``lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见:
```text ```text
docs/refactor/performance-benchmark-summary-20260630.md docs/refactor/performance-benchmark-summary-20260630.md

View File

@@ -21,13 +21,14 @@ apps/taro/dist/h5-platform-admin
可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request` 可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`
H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前运行: H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前从仓库根目录运行:
```bash ```bash
node ../../scripts/taro-h5-release-guardrails-test.js --require-dist npm run smoke:taro:h5
node scripts/taro-h5-release-guardrails-test.js --require-dist
``` ```
守卫会扫描三套 H5 产物和运行配置边界,阻断旧 PocketBase 引用、`x-user-id`、平台本地 key、数据库连接串和服务端密钥形态进入前端产物。正式部署时仍需要在每个 H5 目录根部放置对应的 `runtime-config.json` `smoke:taro:h5` 会用临时静态服务器验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 `runtime-config.json``/api/tenant/resolve` 契约。发布守卫会扫描三套 H5 产物和运行配置边界,阻断旧 PocketBase 引用、`x-user-id`、平台本地 key、数据库连接串和服务端密钥形态进入前端产物。正式部署时仍需要在每个 H5 目录根部放置对应的 `runtime-config.json`
## 当前学生端页面 ## 当前学生端页面

View File

@@ -1,6 +1,6 @@
# 后续开发 TODO # 后续开发 TODO
更新时间2026-06-30 更新时间2026-07-01
## 当前后端基线 ## 当前后端基线
@@ -44,7 +44,7 @@
- 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 和可选外部 HTTP scanner正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft``security_scan_status=failed`,外部 scanner 不可用默认 fail-closed并写入审计、扫描事件和安全标记。 - 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 和可选外部 HTTP scanner正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft``security_scan_status=failed`,外部 scanner 不可用默认 fail-closed并写入审计、扫描事件和安全标记。
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包写入对象存储或本地开发存储创建 `content_assets` 并回填 `assetId/hash/size``daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。 - 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包写入对象存储或本地开发存储创建 `content_assets` 并回填 `assetId/hash/size``daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。
- 租户后台媒体运营报表已完成:`/api/tenant-content/media-analytics/summary``asset-events``video-events` 可按 7/30/90 天、资源、视频、用户和水印 traceId 查询资料访问、视频播放、拒绝访问、Top 资源/视频和日趋势;仅开放给 owner/admin/operator 或 `content:analytics:read` 权限角色,前端不会拿到签名 URL 或播放 token。 - 租户后台媒体运营报表已完成:`/api/tenant-content/media-analytics/summary``asset-events``video-events` 可按 7/30/90 天、资源、视频、用户和水印 traceId 查询资料访问、视频播放、拒绝访问、Top 资源/视频和日趋势;仅开放给 owner/admin/operator 或 `content:analytics:read` 权限角色,前端不会拿到签名 URL 或播放 token。
- 本地验证:`npm run check:refactor` 已通过。 - 本地验证:`npm run check:refactor` 已通过;三套 Taro H5 已补 `index.html` 构建模板、发布产物扫描和 `npm run smoke:taro:h5` 静态启动烟测,能验证发布目录、公开 runtime config、history fallback、静态资源和租户解析契约
当前更适合进入前端联调前阅读的总览文档: 当前更适合进入前端联调前阅读的总览文档:
@@ -94,7 +94,7 @@
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。 - 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
- 确认数据库迁移流程、备份恢复、日志、告警。 - 确认数据库迁移流程、备份恢复、日志、告警。
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。 - 准备 API 容器部署和 Supabase 云端/自托管连接方案。
- 已补 `npm run perf:api:local``npm run perf:summary``npm run perf:postgres:evidence``npm run smoke:launch-persona``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据三类角色旅程烟测。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;混合读写 50 worker/60s/10% 写入为 52979 请求、0 错误、870.42 req/s、P95 104.92ms、P99 136.22ms - 已补 `npm run perf:api:local``npm run perf:summary``npm run perf:postgres:evidence``npm run smoke:launch-persona``npm run smoke:taro:h5``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据三类角色旅程烟测和 H5 发布目录启动烟测。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;混合读写复测 50 worker/60s/10% 写入为 51425 请求、0 错误、845.06 req/s、P95 110.39ms、P99 138.79ms100 worker/60s/8% 写入为 51103 请求、0 错误、838.42 req/s、P95 210.07ms、P99 272.15ms150 worker/60s/6% 写入为 44889 请求、0 错误、735.22 req/s、P95 357.74ms、P99 469.28ms,说明本地压力上沿仍在 100 到 150 个无停顿 worker 之间
- 当前本地 PostgreSQL evidence 仍提示 `jit=on``statement_timeout=0``idle_in_transaction_session_timeout=0``lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。 - 当前本地 PostgreSQL evidence 仍提示 `jit=on``statement_timeout=0``idle_in_transaction_session_timeout=0``lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。
### P1 商用功能完善 ### P1 商用功能完善

View File

@@ -44,13 +44,23 @@
| 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms | 通过只读上线门禁 | | 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms | 通过只读上线门禁 |
| 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms | 通过混合读写观察线 | | 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms | 通过混合读写观察线 |
### 2026-07-01 H5 发布烟测后读写复核
本轮在新增 `npm run smoke:taro:h5` 后复跑,确认后端真实数据读写容量未受前端发布守卫改动影响。压测仍使用本地 Docker/Supabase 真实迁移库,排行榜未纳入默认负载。
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| 50 | 60s | 10% | 51,425 | 0.00% | 845.06 req/s | 110.39 ms | 138.79 ms | 通过混合读写观察线 |
| 100 | 60s | 8% | 51,103 | 0.00% | 838.42 req/s | 210.07 ms | 272.15 ms | 本地舒适区内 |
| 150 | 60s | 6% | 44,889 | 0.00% | 735.22 req/s | 357.74 ms | 469.28 ms | 零错误但接近本机压力上沿 |
同轮 `npm run smoke:launch-persona` 已通过,覆盖普通学生 SVIP 后刷题、收藏、错题复习入口,租户管理员 dashboard/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及学生越权后台和跨租户访问拒绝。 同轮 `npm run smoke:launch-persona` 已通过,覆盖普通学生 SVIP 后刷题、收藏、错题复习入口,租户管理员 dashboard/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及学生越权后台和跨租户访问拒绝。
同轮 `npm run perf:postgres:evidence` 运行成功,但本地默认 PostgreSQL 仍有生产前必须调优的 warning`jit=on``statement_timeout=0``idle_in_transaction_session_timeout=0``lock_timeout=0`。上云后要按 `docs/refactor/postgresql-4c16g-tuning.md` 调整并重启需要重启的参数,再复跑证据采集和压测。 同轮 `npm run perf:postgres:evidence` 运行成功,但本地默认 PostgreSQL 仍有生产前必须调优的 warning`jit=on``statement_timeout=0``idle_in_transaction_session_timeout=0``lock_timeout=0`。上云后要按 `docs/refactor/postgresql-4c16g-tuning.md` 调整并重启需要重启的参数,再复跑证据采集和压测。
## 初步结论 ## 初步结论
- 本地 Docker 环境下100 个无停顿 worker 内 P95 仍低于 300ms可以作为当前代码和索引状态的本地舒适区参考。 - 本地 Docker 环境下100 个无停顿 worker 内 P95 仍低于 300ms可以作为当前代码和索引状态的本地舒适区参考2026-07-01 最新 100 worker/60s/8% 写入为 838.42 req/s、P95 210.07ms、0 错误
- 150 个无停顿 worker 仍然 0 错误,但 P95 在不同轮次中接近或超过 300ms已经能看到本地压力拐点。 - 150 个无停顿 worker 仍然 0 错误,但 P95 在不同轮次中接近或超过 300ms已经能看到本地压力拐点。
- 按最新 897 req/s 只读吞吐粗略折算,如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s则理论请求吞吐约对应 4,485 到 17,940 名活跃在线学生;按更保守的 700 req/s 估算约为 3,500 到 14,000 名。这只是吞吐换算,不是生产 SLA。 - 按最新 897 req/s 只读吞吐粗略折算,如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s则理论请求吞吐约对应 4,485 到 17,940 名活跃在线学生;按更保守的 700 req/s 估算约为 3,500 到 14,000 名。这只是吞吐换算,不是生产 SLA。
- 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。 - 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。

View File

@@ -191,10 +191,11 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth并观察业务
构建后再运行 H5 发布守卫脚本: 构建后再运行 H5 发布守卫脚本:
```bash ```bash
npm run smoke:taro:h5
node scripts/taro-h5-release-guardrails-test.js --require-dist node scripts/taro-h5-release-guardrails-test.js --require-dist
``` ```
该脚本会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning正式发布前必须在每个 H5 目录根部补齐该文件。 `smoke:taro:h5` 会用临时静态服务器检查三套 H5 产物可托管、资源可加载、history fallback 可用,并用 mock API 验证租户解析契约。发布守卫会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning正式发布前必须在每个 H5 目录根部补齐该文件。
7. 收集生产上线证据并运行 launch gate 7. 收集生产上线证据并运行 launch gate

View File

@@ -81,11 +81,12 @@ npm run audit:runtime
npm run check:api npm run check:api
npm run check:worker npm run check:worker
npm run check:taro npm run check:taro
npm run smoke:taro:h5
node scripts/taro-h5-release-guardrails-test.js --require-dist node scripts/taro-h5-release-guardrails-test.js --require-dist
npm run smoke:launch-persona npm run smoke:launch-persona
``` ```
`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id``x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning正式部署目录必须补齐。 `smoke:taro:h5` 会启动临时静态服务器和 mock API验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。它是发布目录级启动烟测,不替代真实浏览器/真机点击验收。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id``x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning正式部署目录必须补齐。
`@codex-security` 插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。 `@codex-security` 插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。

View File

@@ -59,6 +59,7 @@
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
"test:launch-gate": "node scripts/production-launch-gate-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", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
"smoke:taro:h5": "node scripts/taro-h5-static-smoke.js",
"test:pb:dry-run": "node scripts/pb-dry-run-report-test.js", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js",
"test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js", "test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js",
"test:perf:summary": "node scripts/performance-summary-test.js", "test:perf:summary": "node scripts/performance-summary-test.js",

View File

@@ -0,0 +1,396 @@
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import process from 'node:process';
const repoRoot = process.cwd();
const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist');
const portals = [
{
portal: 'student',
dist: 'h5-student',
landingPath: '/pages/student/home/index',
},
{
portal: 'tenant-admin',
dist: 'h5-tenant-admin',
landingPath: '/pages/tenant-admin/workbench/index',
},
{
portal: 'platform-admin',
dist: 'h5-platform-admin',
landingPath: '/pages/platform-admin/workbench/index',
},
];
const allowedRuntimeConfigKeys = new Set([
'portal',
'apiBaseUrl',
'supabaseUrl',
'supabasePublishableKey',
'tenantCode',
]);
const forbiddenRuntimeConfigKeys = [
'SUPABASE_SERVICE_ROLE_KEY',
'SUPABASE_SECRET_KEY',
'DATABASE_URL',
'ALIYUN_OSS_ACCESS_KEY_SECRET',
'TENCENT_COS_SECRET_KEY',
'WECHAT_PAY_PRIVATE_KEY',
'ALIPAY_APP_PRIVATE_KEY',
'AUTH_SESSION_SECRET',
'PLATFORM_ADMIN_API_KEY',
];
const forbiddenValuePatterns = [
/postgres(?:ql)?:\/\//i,
/-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
/\bAKIA[0-9A-Z]{16}\b/,
/\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/,
];
function contentTypeFor(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.html') return 'text/html; charset=utf-8';
if (ext === '.js') return 'application/javascript; charset=utf-8';
if (ext === '.css') return 'text/css; charset=utf-8';
if (ext === '.json') return 'application/json; charset=utf-8';
if (ext === '.svg') return 'image/svg+xml';
if (ext === '.png') return 'image/png';
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
if (ext === '.webp') return 'image/webp';
if (ext === '.woff2') return 'font/woff2';
if (ext === '.woff') return 'font/woff';
return 'application/octet-stream';
}
function readText(filePath) {
return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
}
function normalizeSlashes(value) {
return value.replace(/\\/g, '/');
}
function relative(filePath) {
return normalizeSlashes(path.relative(repoRoot, filePath));
}
function jsonResponse(response, statusCode, payload) {
response.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
});
response.end(`${JSON.stringify(payload)}\n`);
}
function textResponse(response, statusCode, body, headers = {}) {
response.writeHead(statusCode, headers);
response.end(body);
}
async function request(input, options = {}) {
const response = await fetch(input, {
method: options.method || 'GET',
headers: options.headers || {},
});
const text = await response.text();
return {
ok: response.ok,
status: response.status,
headers: response.headers,
text,
};
}
async function createMockApiServer() {
const requests = [];
const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
requests.push({
method: req.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
host: req.headers.host || '',
});
if (url.pathname === '/health') {
jsonResponse(res, 200, { ok: true });
return;
}
if (url.pathname === '/api/tenant/resolve') {
jsonResponse(res, 200, {
item: {
tenantId: '00000000-0000-4000-8000-000000000001',
tenantSlug: url.searchParams.get('tenantCode') || 'master',
branding: {
name: '工学题库',
theme: {
primaryColor: '#2563eb',
accentColor: '#16a34a',
},
},
features: {
enableLeaderboard: false,
},
adminFeatures: {
tenantAdmin: true,
platformAdmin: true,
},
publicConfig: {
smoke: true,
},
},
});
return;
}
jsonResponse(res, 404, { code: 'NOT_FOUND', message: 'mock endpoint not found' });
});
const port = await listen(server);
return {
baseUrl: `http://127.0.0.1:${port}`,
requests,
close: () => closeServer(server),
};
}
async function createStaticServer(portal, apiBaseUrl) {
const distDir = path.join(distRoot, portal.dist);
const runtimeConfig = {
portal: portal.portal,
apiBaseUrl,
supabaseUrl: 'https://auth.example.test',
supabasePublishableKey: 'sb_publishable_mock_key_for_static_smoke',
tenantCode: 'master',
};
const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
const requestPath = decodeURIComponent(url.pathname);
if (requestPath === '/runtime-config.json') {
jsonResponse(res, 200, runtimeConfig);
return;
}
const filePath = resolveStaticPath(distDir, requestPath);
if (!filePath) {
textResponse(res, 403, 'forbidden', { 'content-type': 'text/plain; charset=utf-8' });
return;
}
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
? filePath
: path.join(distDir, 'index.html');
if (!fs.existsSync(finalPath)) {
textResponse(res, 404, 'not found', { 'content-type': 'text/plain; charset=utf-8' });
return;
}
const headers = {
'content-type': contentTypeFor(finalPath),
'cache-control': finalPath.endsWith('index.html') ? 'no-store' : 'public, max-age=31536000, immutable',
};
res.writeHead(200, headers);
fs.createReadStream(finalPath).pipe(res);
});
const port = await listen(server);
return {
baseUrl: `http://127.0.0.1:${port}`,
runtimeConfig,
close: () => closeServer(server),
};
}
function resolveStaticPath(distDir, requestPath) {
const cleanPath = requestPath === '/' ? '/index.html' : requestPath;
const resolved = path.resolve(distDir, `.${cleanPath}`);
const normalizedRoot = path.resolve(distDir);
if (resolved !== normalizedRoot && !resolved.startsWith(`${normalizedRoot}${path.sep}`)) return null;
return resolved;
}
function listen(server) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
resolve(address.port);
});
});
}
function closeServer(server) {
return new Promise(resolve => {
server.close(() => resolve());
});
}
function parseAssetUrls(indexHtml) {
const urls = new Set();
for (const match of indexHtml.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)) {
urls.add(match[1]);
}
for (const match of indexHtml.matchAll(/<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/gi)) {
const tag = match[0];
if (/rel=["']stylesheet["']/i.test(tag)) urls.add(match[1]);
}
return [...urls];
}
function validateRuntimeConfig(config, portal) {
const errors = [];
const keys = Object.keys(config);
const unknownKeys = keys.filter(key => !allowedRuntimeConfigKeys.has(key));
const forbiddenKeys = keys.filter(key => forbiddenRuntimeConfigKeys.includes(key));
if (unknownKeys.length) errors.push(`unknown runtime config keys: ${unknownKeys.join(', ')}`);
if (forbiddenKeys.length) errors.push(`forbidden runtime config keys: ${forbiddenKeys.join(', ')}`);
if (config.portal !== portal.portal) errors.push(`portal mismatch: expected ${portal.portal}, got ${config.portal}`);
if (!String(config.apiBaseUrl || '').startsWith('http://127.0.0.1:')) errors.push('apiBaseUrl must point to the local mock API in smoke');
if (!String(config.supabaseUrl || '').startsWith('https://')) errors.push('supabaseUrl must be HTTPS even in smoke runtime config');
if (!config.supabasePublishableKey) errors.push('supabasePublishableKey is required');
for (const [key, value] of Object.entries(config)) {
if (forbiddenValuePatterns.some(pattern => pattern.test(String(value)))) errors.push(`secret-looking value in ${key}`);
}
return errors;
}
function assertDistExists(portal) {
const distDir = path.join(distRoot, portal.dist);
const indexPath = path.join(distDir, 'index.html');
if (!fs.existsSync(distDir)) throw new Error(`${relative(distDir)} does not exist. Build H5 before running smoke:taro:h5.`);
if (!fs.existsSync(indexPath)) throw new Error(`${relative(indexPath)} does not exist. Build output is not deployable.`);
}
async function smokePortal(portal, api) {
assertDistExists(portal);
const staticServer = await createStaticServer(portal, api.baseUrl);
try {
const checks = [];
const index = await request(`${staticServer.baseUrl}/index.html`);
checks.push({
id: `${portal.portal}.index`,
ok: index.ok && /<div id="app">/.test(index.text) && /<script\b/i.test(index.text),
detail: `status=${index.status}`,
});
const assetUrls = parseAssetUrls(index.text);
checks.push({
id: `${portal.portal}.assets.discovered`,
ok: assetUrls.length >= 2,
detail: `${assetUrls.length} asset reference(s)`,
});
for (const assetUrl of assetUrls) {
const target = new URL(assetUrl, `${staticServer.baseUrl}/index.html`);
const asset = await request(target);
checks.push({
id: `${portal.portal}.asset.${assetUrl}`,
ok: asset.ok && asset.text.length > 0,
detail: `status=${asset.status} bytes=${asset.text.length}`,
});
}
const runtime = await request(`${staticServer.baseUrl}/runtime-config.json`);
let runtimeConfig = null;
try {
runtimeConfig = JSON.parse(runtime.text);
} catch {
runtimeConfig = null;
}
const runtimeErrors = runtimeConfig ? validateRuntimeConfig(runtimeConfig, portal) : ['runtime-config.json is not valid JSON'];
checks.push({
id: `${portal.portal}.runtime_config`,
ok: runtime.ok && runtimeErrors.length === 0,
detail: runtimeErrors.length ? runtimeErrors.join('; ') : 'public runtime config accepted',
});
const fallback = await request(`${staticServer.baseUrl}${portal.landingPath}`);
checks.push({
id: `${portal.portal}.history_fallback`,
ok: fallback.ok && /<div id="app">/.test(fallback.text),
detail: `status=${fallback.status} path=${portal.landingPath}`,
});
if (runtimeConfig) {
const resolveUrl = new URL('/api/tenant/resolve', runtimeConfig.apiBaseUrl);
resolveUrl.searchParams.set('tenantCode', runtimeConfig.tenantCode);
resolveUrl.searchParams.set('host', new URL(staticServer.baseUrl).host);
const tenantResolve = await request(resolveUrl, { headers: { 'x-smoke-portal': portal.portal } });
let tenantPayload = null;
try {
tenantPayload = JSON.parse(tenantResolve.text);
} catch {
tenantPayload = null;
}
checks.push({
id: `${portal.portal}.tenant_resolve_contract`,
ok: tenantResolve.ok && Boolean(tenantPayload?.item?.tenantId) && tenantPayload.item.features?.enableLeaderboard === false,
detail: `status=${tenantResolve.status}`,
});
} else {
checks.push({
id: `${portal.portal}.tenant_resolve_contract`,
ok: false,
detail: 'runtime config unavailable',
});
}
return {
portal: portal.portal,
staticBaseUrl: staticServer.baseUrl,
landingPath: portal.landingPath,
checks,
};
} finally {
await staticServer.close();
}
}
function summarize(results) {
const checks = results.flatMap(result => result.checks);
return {
pass: checks.filter(item => item.ok).length,
fail: checks.filter(item => !item.ok).length,
checks,
};
}
async function main() {
const api = await createMockApiServer();
try {
const results = [];
for (const portal of portals) {
results.push(await smokePortal(portal, api));
}
const summary = summarize(results);
console.log(`Taro H5 static smoke: ${summary.fail} fail(s), ${summary.pass} pass(es)`);
for (const result of results) {
console.log(`[PORTAL] ${result.portal} ${result.staticBaseUrl} -> ${result.landingPath}`);
for (const check of result.checks) {
console.log(`[${check.ok ? 'PASS' : 'FAIL'}] ${check.id}: ${check.detail}`);
}
}
const resolveRequests = api.requests.filter(item => item.path === '/api/tenant/resolve');
console.log(`[MOCK API] tenant resolve requests=${resolveRequests.length}`);
if (resolveRequests.length !== portals.length) {
console.log(`[FAIL] mock_api.tenant_resolve_count: expected ${portals.length}, got ${resolveRequests.length}`);
process.exitCode = 1;
return;
}
if (summary.fail > 0) process.exitCode = 1;
} finally {
await api.close();
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});