chore: harden h5 launch evidence gate

This commit is contained in:
Codex
2026-07-01 01:15:29 +08:00
parent 26ab499a80
commit 7c0e55e419
9 changed files with 125 additions and 18 deletions

View File

@@ -450,7 +450,7 @@ cp docs/refactor/production-launch-evidence.template.json docs/refactor/producti
npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json
``` ```
`production-launch-evidence.json` 不入 Git里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过`readiness:production``readiness:production:db`、真实 `smoke:auth:remote``test:rls`、PocketBase production dry-run、`pb:import:validate``pb:import:sample`、真实数据 API 读路径压测、API/worker/Taro 构建、`audit:runtime``@codex-security` 扫描,以及备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。 `production-launch-evidence.json` 不入 Git里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过`readiness:production``readiness:production:db`、真实 `smoke:auth:remote``test:rls`、PocketBase production dry-run、`pb:import:validate``pb:import:sample`、真实数据 API 读路径压测、API/worker/Taro 构建、`smoke:taro:h5`、严格 `taro-h5-release-guardrails-test --require-runtime-config``audit:runtime`真实 `@codex-security` 扫描,以及备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。
补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime``npm run test:readiness``npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。 补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime``npm run test:readiness``npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。
@@ -575,6 +575,13 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist
`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` `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`
写入生产上线证据时使用严格模式,确保三套正式发布目录已经放好真实公开 `runtime-config.json`,且 warning 为 0
```powershell
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-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
```
最近一次本地真实迁移库数据规模约为 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再折算在线容量。
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | | 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 |

View File

@@ -43,7 +43,7 @@
- `apps/taro/src/services/api.ts` 现在默认 Supabase JWT 优先、迁移期 `tk_` 兜底;公共接口必须显式 `authMode='none'`。页面不要手写 `Authorization``x-tenant-id``x-user-id` - `apps/taro/src/services/api.ts` 现在默认 Supabase JWT 优先、迁移期 `tk_` 兜底;公共接口必须显式 `authMode='none'`。页面不要手写 `Authorization``x-tenant-id``x-user-id`
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api` - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api`
- H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl``supabaseUrl``supabasePublishableKey``tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。 - H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl``supabaseUrl``supabasePublishableKey``tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。
- 上线前需要把三套 H5 构建、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate` - 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config``runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve``branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme` - 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve``branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`
- 租户后台可以接入角色模板和成员 API`/api/tenant-admin/role-templates``/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。 - 租户后台可以接入角色模板和成员 API`/api/tenant-admin/role-templates``/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
- 租户后台可以接入勋章管理、手动发放、积分任务、每日签到奖励、积分兑换、积分风控只读报表和反馈运营报表:`GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants``GET/PUT /api/tenant-admin/point-activity-tasks``GET /api/tenant-admin/point-activity-claims``GET/PUT /api/tenant-admin/point-exchange-items``GET /api/tenant-admin/point-exchange-orders``GET /api/tenant-admin/points-risk-report``GET /api/tenant-admin/feedbacks/report`;学生端用 `GET /api/profile/badges` 展示成就,通过 `POST /api/profile/check-in` 自动领取 `daily_check_in` 任务奖励,并通过 `GET /api/profile/activity-tasks``POST /api/profile/activity-tasks/claim``GET /api/profile/exchange-items``POST /api/profile/exchange-items/redeem` 接其它积分活动和兑换。 - 租户后台可以接入勋章管理、手动发放、积分任务、每日签到奖励、积分兑换、积分风控只读报表和反馈运营报表:`GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants``GET/PUT /api/tenant-admin/point-activity-tasks``GET /api/tenant-admin/point-activity-claims``GET/PUT /api/tenant-admin/point-exchange-items``GET /api/tenant-admin/point-exchange-orders``GET /api/tenant-admin/points-risk-report``GET /api/tenant-admin/feedbacks/report`;学生端用 `GET /api/profile/badges` 展示成就,通过 `POST /api/profile/check-in` 自动领取 `daily_check_in` 任务奖励,并通过 `GET /api/profile/activity-tasks``POST /api/profile/activity-tasks/claim``GET /api/profile/exchange-items``POST /api/profile/exchange-items/redeem` 接其它积分活动和兑换。

View File

@@ -265,7 +265,7 @@
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、男女预设头像选择、收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、小程序支付容器、分享场景和状态管理。前端不得实现头像上传、头像裁剪或第三方头像同步。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、男女预设头像选择、收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、小程序支付容器、分享场景和状态管理。前端不得实现头像上传、头像裁剪或第三方头像同步。
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。 4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`,执行 `node scripts/taro-h5-release-guardrails-test.js --require-dist`人工打开学生端/租户后台/平台后台域名,确认入口、租户解析、登录态和 `Authorization + x-tenant-id` 请求正常。当前构建仍有 webpack 体积 warning后续做首屏拆包、按入口拆页面和 Supabase client 引入优化。 5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`执行 `npm run smoke:taro:h5` `node scripts/taro-h5-release-guardrails-test.js --require-dist` 做本地发布目录烟测;写入生产上线证据时必须执行 `npm --silent run smoke:taro:h5 -- --json``node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json`,确保 warning 为 0。随后人工打开学生端/租户后台/平台后台域名,确认入口、租户解析、登录态和 `Authorization + x-tenant-id` 请求正常。当前构建仍有 webpack 体积 warning后续做首屏拆包、按入口拆页面和 Supabase client 引入优化。
6. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 继续做人工复核和抽样验收;当前 dry-run/导入/校验链路已跑通,下一步重点是 7 个已支付缺用户订单、22 个待复核手册章节、2533 道旧分类缺失题目和 5298 条引用已删除题目的答题记录的运营处理结论。导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。 6. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 继续做人工复核和抽样验收;当前 dry-run/导入/校验链路已跑通,下一步重点是 7 个已支付缺用户订单、22 个待复核手册章节、2533 道旧分类缺失题目和 5298 条引用已删除题目的答题记录的运营处理结论。导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。
7. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 7. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
8. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 8. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -213,6 +213,29 @@
"failed": 0 "failed": 0
} }
}, },
{
"id": "taro.h5-static-smoke",
"status": "pass",
"command": "npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json",
"completedAt": "2026-06-30T12:12:00+08:00",
"artifact": "launch-artifacts/taro-h5-static-smoke.json",
"summary": {
"fail": 0,
"portals": 3,
"tenantResolveRequests": 3
}
},
{
"id": "taro.h5-release-guardrails",
"status": "pass",
"command": "node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json",
"completedAt": "2026-06-30T12:15:00+08:00",
"artifact": "launch-artifacts/taro-h5-release-guardrails.json",
"summary": {
"fail": 0,
"warn": 0
}
},
{ {
"id": "audit.runtime", "id": "audit.runtime",
"status": "pass", "status": "pass",
@@ -226,13 +249,13 @@
}, },
{ {
"id": "security.codex-scan", "id": "security.codex-scan",
"status": "pass", "status": "replace-with-pass-after-real-scan",
"command": "@codex-security scan; save findings to docs/refactor/launch-artifacts/codex-security.md", "command": "Run the real @codex-security scan only when the tool is exposed; save findings to docs/refactor/launch-artifacts/codex-security.md",
"completedAt": "2026-06-30T12:30:00+08:00", "completedAt": "replace-with-real-scan-time",
"artifact": "launch-artifacts/codex-security.md", "artifact": "launch-artifacts/codex-security.md",
"summary": { "summary": {
"critical": 0, "critical": "replace-with-number",
"high": 0 "high": "replace-with-number"
} }
} }
], ],

View File

@@ -197,6 +197,13 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth并观察业务
`smoke:taro:h5` 会用临时静态服务器检查三套 H5 产物可托管、资源可加载、history fallback 可用,并用 mock API 验证租户解析契约。发布守卫会检查三套 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 目录根部补齐该文件。
写入 `production-launch-evidence.json` 的正式证据必须使用严格模式,确保三套发布目录都已放置真实公开 `runtime-config.json` 且没有 warning
```bash
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-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
```
7. 收集生产上线证据并运行 launch gate 7. 收集生产上线证据并运行 launch gate
```bash ```bash

View File

@@ -88,6 +88,13 @@ npm run smoke:launch-persona
`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正式部署目录必须补齐。 `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正式部署目录必须补齐。
写入生产上线证据时,三套正式发布目录必须先放入真实公开 `runtime-config.json`,再运行严格模式:
```bash
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-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
```
`@codex-security` 插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。 `@codex-security` 插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。
生产 API 推荐: 生产 API 推荐:

View File

@@ -143,6 +143,17 @@ assert.ok(
'missing import business sampling should be reported as a blocker', 'missing import business sampling should be reported as a blocker',
); );
const missingTaroStaticSmoke = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'taro.h5-static-smoke');
return evidence;
});
assert.notEqual(missingTaroStaticSmoke.status, 0, 'missing H5 static smoke should fail launch gate');
assert.ok(
missingTaroStaticSmoke.payload.checks?.some(item => item.id === 'check.taro.h5-static-smoke' && item.status === 'blocker'),
'missing H5 static smoke should be reported as a blocker',
);
const missingAttestation = runGate(tempDir => { const missingAttestation = runGate(tempDir => {
const evidence = createEvidence(tempDir); const evidence = createEvidence(tempDir);
evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot'); evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot');

View File

@@ -124,6 +124,25 @@ const gateChecks = [
commandIncludes: 'build:taro:h5:platform', commandIncludes: 'build:taro:h5:platform',
summary: { failed: 0 }, summary: { failed: 0 },
}, },
{
id: 'taro.h5-static-smoke',
label: 'Taro H5 static startup smoke',
commandIncludes: 'smoke:taro:h5',
summary: {
fail: 0,
portals: 3,
tenantResolveRequests: 3,
},
},
{
id: 'taro.h5-release-guardrails',
label: 'Taro H5 release artifact guardrails',
commandIncludes: 'taro-h5-release-guardrails-test.js',
summary: {
fail: 0,
warn: 0,
},
},
{ {
id: 'audit.runtime', id: 'audit.runtime',
label: 'Runtime dependency audit', label: 'Runtime dependency audit',

View File

@@ -24,6 +24,12 @@ const portals = [
}, },
]; ];
function parseArgs(argv) {
return {
json: argv.includes('--json'),
};
}
const allowedRuntimeConfigKeys = new Set([ const allowedRuntimeConfigKeys = new Set([
'portal', 'portal',
'apiBaseUrl', 'apiBaseUrl',
@@ -363,6 +369,7 @@ function summarize(results) {
} }
async function main() { async function main() {
const options = parseArgs(process.argv.slice(2));
const api = await createMockApiServer(); const api = await createMockApiServer();
try { try {
const results = []; const results = [];
@@ -370,21 +377,47 @@ async function main() {
results.push(await smokePortal(portal, api)); results.push(await smokePortal(portal, api));
} }
const summary = summarize(results); 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'); const resolveRequests = api.requests.filter(item => item.path === '/api/tenant/resolve');
console.log(`[MOCK API] tenant resolve requests=${resolveRequests.length}`); const payload = {
summary: {
fail: summary.fail + (resolveRequests.length === portals.length ? 0 : 1),
pass: summary.pass + (resolveRequests.length === portals.length ? 1 : 0),
portals: portals.length,
tenantResolveRequests: resolveRequests.length,
},
portals: results,
checks: [
...summary.checks.map(item => ({
status: item.ok ? 'pass' : 'fail',
id: item.id,
detail: item.detail,
})),
{
status: resolveRequests.length === portals.length ? 'pass' : 'fail',
id: 'mock_api.tenant_resolve_count',
detail: `expected=${portals.length} actual=${resolveRequests.length}`,
},
],
};
if (options.json) {
console.log(JSON.stringify(payload, null, 2));
} else {
console.log(`Taro H5 static smoke: ${payload.summary.fail} fail(s), ${payload.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}`);
}
}
console.log(`[MOCK API] tenant resolve requests=${resolveRequests.length}`);
}
if (resolveRequests.length !== portals.length) { if (resolveRequests.length !== portals.length) {
console.log(`[FAIL] mock_api.tenant_resolve_count: expected ${portals.length}, got ${resolveRequests.length}`); if (!options.json) console.log(`[FAIL] mock_api.tenant_resolve_count: expected ${portals.length}, got ${resolveRequests.length}`);
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
if (summary.fail > 0) process.exitCode = 1; if (payload.summary.fail > 0) process.exitCode = 1;
} finally { } finally {
await api.close(); await api.close();
} }