From 26ab499a80cd45ea9c40a1f153b352b0cf46e4f8 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 01:06:49 +0800 Subject: [PATCH] test: add taro h5 static launch smoke --- README.md | 14 +- apps/taro/README.md | 7 +- docs/refactor/next-development-todo.md | 6 +- .../performance-benchmark-summary-20260630.md | 12 +- docs/refactor/taro-h5-deployment.md | 3 +- .../web-launch-acceptance-checklist.md | 3 +- package.json | 1 + scripts/taro-h5-static-smoke.js | 396 ++++++++++++++++++ 8 files changed, 431 insertions(+), 11 deletions(-) create mode 100644 scripts/taro-h5-static-smoke.js diff --git a/README.md b/README.md index eb377e1a..4f8e157f 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,12 @@ apps/taro/dist/h5-platform-admin 推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `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 域名变化时重打包: ```text @@ -563,10 +569,11 @@ Taro H5 发布产物守卫: npm run build:taro:h5:student npm run build:taro:h5:tenant npm run build:taro:h5:platform +npm run smoke:taro:h5 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,再折算在线容量。 @@ -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 | | 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 | | 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 | @@ -587,7 +597,7 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark- 使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。 -本地结论:100 个无停顿 worker 内 P95 仍低于 300ms;150 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 仍低于 300ms;150 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 docs/refactor/performance-benchmark-summary-20260630.md diff --git a/apps/taro/README.md b/apps/taro/README.md index 26d5d568..5fc14a3c 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -21,13 +21,14 @@ apps/taro/dist/h5-platform-admin 可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`。 -H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前运行: +H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前从仓库根目录运行: ```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`。 ## 当前学生端页面 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index f938be92..5e7c1b81 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -1,6 +1,6 @@ # 后续开发 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 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。 -- 本地验证:`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` 扫描工具时,该项只能标为待补,不能伪造完成。 - 确认数据库迁移流程、备份恢复、日志、告警。 - 准备 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.79ms;100 worker/60s/8% 写入为 51103 请求、0 错误、838.42 req/s、P95 210.07ms、P99 272.15ms;150 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 阶梯并发复跑并归档到本地上线证据。 ### P1 商用功能完善 diff --git a/docs/refactor/performance-benchmark-summary-20260630.md b/docs/refactor/performance-benchmark-summary-20260630.md index 88dc79d0..fc825adb 100644 --- a/docs/refactor/performance-benchmark-summary-20260630.md +++ b/docs/refactor/performance-benchmark-summary-20260630.md @@ -44,13 +44,23 @@ | 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 | 通过混合读写观察线 | +### 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 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,已经能看到本地压力拐点。 - 按最新 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、真实前端请求节奏和生产网络下复跑。 diff --git a/docs/refactor/taro-h5-deployment.md b/docs/refactor/taro-h5-deployment.md index 8db8bb56..8d6e7a27 100644 --- a/docs/refactor/taro-h5-deployment.md +++ b/docs/refactor/taro-h5-deployment.md @@ -191,10 +191,11 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务 构建后再运行 H5 发布守卫脚本: ```bash + npm run smoke:taro:h5 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: diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index 7b18eaea..40885738 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -81,11 +81,12 @@ npm run audit:runtime npm run check:api npm run check:worker npm run check:taro +npm run smoke:taro:h5 node scripts/taro-h5-release-guardrails-test.js --require-dist 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 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。 diff --git a/package.json b/package.json index 13bfe534..f5a5b792 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "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", + "smoke:taro:h5": "node scripts/taro-h5-static-smoke.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:perf:summary": "node scripts/performance-summary-test.js", diff --git a/scripts/taro-h5-static-smoke.js b/scripts/taro-h5-static-smoke.js new file mode 100644 index 00000000..e0145b61 --- /dev/null +++ b/scripts/taro-h5-static-smoke.js @@ -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(/]*\bsrc=["']([^"']+)["'][^>]*>/gi)) { + urls.add(match[1]); + } + for (const match of indexHtml.matchAll(/]*\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 && /
/.test(index.text) && /= 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 && /
/.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; +});