test: add taro h5 interaction smoke

This commit is contained in:
Codex
2026-07-01 02:37:43 +08:00
parent c3e1f27a0a
commit ad1c69037b
20 changed files with 1052 additions and 36 deletions

View File

@@ -145,11 +145,13 @@ node scripts/taro-route-contract-test.js
node scripts/taro-api-contract-test.js
node scripts/taro-persona-contract-test.js
npm run smoke:taro:h5
npm run smoke:taro:h5:interaction
```
`taro-route-contract-test` 会校验 `apps/taro/src/app.config.ts`、真实 `pages/**/index.tsx`、启动页三端跳转、H5 静态烟测入口和前端交接文档中的页面引用保持一致。新增或删除页面时必须同步路由和文档,避免 H5/小程序构建后才发现入口漂移。
`taro-api-contract-test` 会比对 `apps/taro/src` 中所有 `apiRequest('/api/...')` 调用与 `apps/api/src/features/*/index.ts` 注册路由,阻断前端调用不存在 API、method 写错或绕过统一 `/api` 命名空间的漂移;动态导入和少量 server alias 需要在脚本 allowlist 中显式声明。
`taro-persona-contract-test` 会从学生、租户管理员、平台管理员三类前端视角检查关键页面、路由和服务调用,阻断刷题、会员订单、错题收藏、学生运营、内容导入、营销财务、租户设置、平台租户账务和公共题库授权入口被误删或漂移。
`smoke:taro:h5:interaction` 会启动三套 H5 发布产物、本地 mock API 和本机 Chrome/Edge在真实浏览器里点击学生刷题/收藏/开通会员、租户题库内容/财务运营、平台租户/账务中心关键路径,用来补足静态烟测无法发现的 H5 运行时空白页、history 路由和点击事件问题。
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
@@ -457,7 +459,7 @@ cp docs/refactor/production-launch-evidence.template.json docs/refactor/producti
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 构建、`smoke:taro:h5`、严格 `taro-h5-release-guardrails-test --require-runtime-config``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``smoke:taro:h5:interaction`严格 `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` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。
@@ -577,15 +579,17 @@ npm run build:taro:h5:student
npm run build:taro:h5:tenant
npm run build:taro:h5:platform
npm run smoke:taro:h5
npm run smoke:taro:h5:interaction
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 和租户解析契约。`smoke:taro:h5:interaction` 会再拉起真实 Chrome/Edge打开三套 H5 产物并点击学生、租户后台、平台后台关键入口,确认页面 JS 执行、路由跳转、答题收藏 API、后台入口点击没有空白页或运行时异常。`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
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
```
@@ -606,6 +610,8 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime
| 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 |
| 150 | 120s | 6% | 82,693 | 0.00% | 682.40 req/s | 369.90 ms | 493.69 ms |
| 100 | 120s | 8% | 84,694 | 0.00% | 699.54 req/s | 265.11 ms | 337.82 ms |
| 150 | 120s | 6% | 74,209 | 0.00% | 612.19 req/s | 446.37 ms | 579.39 ms |
只读上线门禁继续要求 `includeWrites=false`;混合读写报告需要显式使用 `--allow-writes` 做人工容量观察,例如:
@@ -615,7 +621,7 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
本地结论100 个无停顿 worker 内 P95 仍低于 300ms150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。最新只读 30 worker/120s 为 897.04 req/s最新混合读写 100 worker/60s/8% 写入为 737.22 req/s、P95 244.12ms、P99 320.26ms、0 错误150 worker/60s/6% 写入仍 0 错误,但 P95 已到 387.58ms、P99 510.79ms。工程上建议把本机 Docker 舒适区暂记为 100 个无停顿压测 worker按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,约可支撑 3,700 到 14,700 名活跃在线学生的请求吞吐观察区间;150 worker 压力区吞吐折算约为 3,400 到 13,600 名,但这不是生产 SLA。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on``statement_timeout``idle_in_transaction_session_timeout``lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见:
本地结论100 个无停顿 worker、120 秒、8% 刷题写入闭环复测为 84,694 请求、0 错误、699.54 req/s、P95 265.11ms、P99 337.82ms,仍可作为当前本机 Docker 舒适区150 个无停顿 worker、120 秒、6% 写入为 74,209 请求、0 错误、612.19 req/s、P95 446.37ms、P99 579.39ms,已进入压力区并出现吞吐回落。按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,100 worker 舒适区约对应 3,500 到 14,000 名活跃在线学生的请求吞吐观察区间150 worker 压力区约对应 3,000 到 12,200 名。这个折算不是生产 SLA,压测 worker 也不等于在线人数。当前 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

View File

@@ -43,7 +43,7 @@ export default {
options: {},
},
h5: {
publicPath: './',
publicPath: '/',
staticDirectory: 'static',
output: {
filename: 'js/[name].[contenthash:8].js',

View File

@@ -25,6 +25,15 @@ declare const process: {
env: Record<string, string | undefined>;
};
type ProcessLike = {
env?: Record<string, string | undefined>;
};
function envValue(key: string) {
const runtimeProcess = typeof process === 'undefined' ? undefined : (process as ProcessLike);
return runtimeProcess?.env?.[key];
}
const forbiddenFrontendKeys = [
'SUPABASE_SERVICE_ROLE_KEY',
'SUPABASE_SECRET_KEY',
@@ -72,11 +81,11 @@ function assertNoForbiddenKeys(input: Record<string, unknown>, source: string) {
}
export const appEnv: AppEnv = {
portal: (process.env.TARO_APP_PORTAL || 'student') as Portal,
apiBaseUrl: process.env.TARO_APP_API_BASE_URL || 'http://127.0.0.1:8787',
supabaseUrl: process.env.TARO_APP_SUPABASE_URL || '',
supabasePublishableKey: process.env.TARO_APP_SUPABASE_PUBLISHABLE_KEY || '',
tenantCode: process.env.TARO_APP_TENANT_CODE || '',
portal: (envValue('TARO_APP_PORTAL') || 'student') as Portal,
apiBaseUrl: envValue('TARO_APP_API_BASE_URL') || 'http://127.0.0.1:8787',
supabaseUrl: envValue('TARO_APP_SUPABASE_URL') || '',
supabasePublishableKey: envValue('TARO_APP_SUPABASE_PUBLISHABLE_KEY') || '',
tenantCode: envValue('TARO_APP_TENANT_CODE') || '',
};
let runtimeConfigPromise: Promise<AppEnv> | null = null;
@@ -103,7 +112,7 @@ export function applyRuntimeConfig(input: RuntimeConfigInput, source = 'runtime
}
export async function loadRuntimeConfig() {
if (process.env.TARO_ENV !== 'h5' || typeof window === 'undefined' || typeof window.fetch !== 'function') {
if (!isH5Runtime() || typeof window === 'undefined' || typeof window.fetch !== 'function') {
return appEnv;
}
@@ -138,8 +147,20 @@ export function ensureRuntimeConfigLoaded() {
}
export function assertFrontendSecretsAreAbsent() {
const leaked = forbiddenFrontendKeys.filter(key => process.env[key]);
const leaked = forbiddenFrontendKeys.filter(key => envValue(key));
if (leaked.length) {
throw new Error(`Forbidden secret env in Taro build: ${leaked.join(', ')}`);
}
}
export function taroRuntimeEnv() {
return envValue('TARO_ENV') || '';
}
export function isH5Runtime() {
return taroRuntimeEnv() === 'h5' || (typeof window !== 'undefined' && typeof document !== 'undefined');
}
export function isWeappRuntime() {
return taroRuntimeEnv() === 'weapp';
}

View File

@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded } from '@/env';
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env';
import { resolveTenant } from '@/services/api';
import './index.css';
function hostFromRuntime() {
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') return window.location.host;
if (isH5Runtime() && typeof window !== 'undefined') return window.location.host;
return '';
}

View File

@@ -7,6 +7,7 @@ import {
loadSchoolRecommendationReports,
type SchoolRecommendationReport,
} from '@/services/ai';
import { isH5Runtime } from '@/env';
import { loadProfile, type StudentProfile } from '@/services/profile';
import '../student.css';
@@ -25,7 +26,7 @@ function recommendationRows(report: SchoolRecommendationReport | null) {
}
function saveExportFile(fileName: string, content: string, mimeType: string) {
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' && typeof document !== 'undefined') {
if (isH5Runtime() && typeof window !== 'undefined' && typeof document !== 'undefined') {
const blob = new Blob([content], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
@@ -94,7 +95,7 @@ export default function StudentAiSchoolPage() {
const payload = await exportSchoolRecommendationReport(current.id, format);
if (!payload.item?.contentText) throw new Error('后端未返回报告内容');
saveExportFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType);
if (process.env.TARO_ENV === 'h5') Taro.showToast({ title: '报告已导出', icon: 'success' });
if (isH5Runtime()) Taro.showToast({ title: '报告已导出', icon: 'success' });
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '导出失败');
} finally {

View File

@@ -9,6 +9,7 @@ import {
type ContentAsset,
type SignedAssetLink,
} from '@/services/catalog';
import { isH5Runtime } from '@/env';
import '../student.css';
type PreviewState = {
@@ -20,7 +21,7 @@ type PreviewState = {
function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') {
if (!url) return;
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
if (isH5Runtime() && typeof window !== 'undefined') {
window.open(url, '_blank', 'noopener,noreferrer');
return;
}
@@ -129,10 +130,10 @@ export default function StudentAssetsPage() {
<Text className='row-meta'> URL </Text>
</View>
) : null}
{previewState.kind === 'preview' && process.env.TARO_ENV === 'h5' ? (
{previewState.kind === 'preview' && isH5Runtime() ? (
<WebView className='asset-preview-iframe' src={previewState.link.url || ''} />
) : null}
{previewState.kind === 'preview' && process.env.TARO_ENV !== 'h5' ? (
{previewState.kind === 'preview' && !isH5Runtime() ? (
<View className='asset-download-confirm'>
<Text className='row-main'></Text>
<Text className='row-meta'>使</Text>

View File

@@ -12,6 +12,7 @@ import {
type PaymentCreateResult,
type SvipPlan,
} from '@/services/commerce';
import { isH5Runtime, isWeappRuntime } from '@/env';
import { loadProfile, type StudentProfile } from '@/services/profile';
import '../student.css';
@@ -56,7 +57,7 @@ function buildPaymentUrl(result?: PaymentCreateResult) {
function tryOpenPaymentUrl(url: string) {
if (!url) return false;
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
if (isH5Runtime() && typeof window !== 'undefined') {
window.location.href = url;
return true;
}
@@ -167,8 +168,8 @@ export default function StudentCheckoutPage() {
const paymentPayload = await createPayment({
orderNo: nextOrder.orderNo,
provider,
returnUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
quitUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
});
setPayment(paymentPayload.item || null);
setMessage(provider === 'manual' ? '已生成线下支付记录,请联系教务或客服确认。' : '支付参数已生成,请继续完成支付。');
@@ -181,7 +182,7 @@ export default function StudentCheckoutPage() {
async function handleOpenPayment() {
if (!payment) return;
if (payment.provider === 'wechat_pay' && process.env.TARO_ENV === 'weapp') {
if (payment.provider === 'wechat_pay' && isWeappRuntime()) {
const paramsForWeapp = payment.paymentParams || {};
Taro.requestPayment(paramsForWeapp as unknown as Taro.requestPayment.Option)
.then(() => order?.orderNo ? pollStatus(order.orderNo) : undefined)

View File

@@ -10,6 +10,7 @@ import {
type PaymentCreateResult,
} from '@/services/commerce';
import { getTenantContext } from '@/services/api';
import { isH5Runtime } from '@/env';
import '../student.css';
function centsToYuan(value?: number | null) {
@@ -94,13 +95,13 @@ export default function StudentOrderDetailPage() {
const payload = await createPayment({
orderNo,
provider: provider || detail?.payProvider || status?.payProvider || 'alipay',
returnUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
quitUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
});
const nextPayment = payload.item || null;
setPayment(nextPayment);
const url = paymentUrl(nextPayment);
if (url && process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
if (url && isH5Runtime() && typeof window !== 'undefined') {
window.location.href = url;
} else if (url) {
await Taro.setClipboardData({ data: url });

View File

@@ -34,6 +34,7 @@ import {
type PublicQuestionBankItem,
type TenantContentNotificationItem,
} from '@/services/tenantAdmin';
import { isH5Runtime } from '@/env';
import '../admin.css';
function displayBankName(item: PublicQuestionBankItem) {
@@ -74,7 +75,7 @@ function readLocalFileAsText(file: File) {
function openH5FilePicker(format: ImportSourceFormat) {
return new Promise<{ fileName: string; text?: string; fileBase64?: string }>((resolve, reject) => {
if (process.env.TARO_ENV !== 'h5' || typeof document === 'undefined') {
if (!isH5Runtime() || typeof document === 'undefined') {
reject(new Error('当前端暂未接入文件选择,请粘贴 JSON/CSV 内容后预览导入。'));
return;
}
@@ -111,7 +112,7 @@ function base64ToUint8Array(base64: string) {
}
function downloadTemplateFile(template: ImportTemplateItem) {
if (process.env.TARO_ENV !== 'h5' || typeof document === 'undefined') {
if (!isH5Runtime() || typeof document === 'undefined') {
throw new Error('当前端暂不支持直接下载模板,请先使用模板预览。');
}
if (!template.contentBase64) throw new Error('模板内容为空,请重新加载模板。');

View File

@@ -1,9 +1,11 @@
import { isH5Runtime } from '../env';
export type ApiAuthMode = 'auto' | 'none' | 'supabase' | 'legacy';
export type SupabaseAccessTokenProvider = () => Promise<string | null>;
async function defaultH5SupabaseAccessTokenProvider() {
if (process.env.TARO_ENV !== 'h5') return null;
if (!isH5Runtime()) return null;
const { getSupabaseAccessToken } = await import('./supabase');
return getSupabaseAccessToken();
}

View File

@@ -1,4 +1,5 @@
import Taro from '@tarojs/taro';
import { isH5Runtime } from '@/env';
export type AccentType = 'us' | 'uk';
@@ -62,7 +63,7 @@ export async function playWordPronunciation(word: string, accent: AccentType = '
const url = pronunciationUrl(text, accent);
if (process.env.TARO_ENV === 'h5' && typeof Audio !== 'undefined') {
if (isH5Runtime() && typeof Audio !== 'undefined') {
try {
const audio = new Audio(url);
audio.preload = 'auto';

View File

@@ -44,9 +44,10 @@
- 新增、删除或重命名 Taro 页面时必须同步 `apps/taro/src/app.config.ts`、启动页跳转、H5 静态烟测入口和本文页面清单,并运行 `node scripts/taro-route-contract-test.js`。该脚本会阻断“页面文件存在但未注册”“路由注册但文件缺失”“启动页或烟测跳到不存在页面”的漂移。
- 新增或修改 Taro API service 时必须运行 `node scripts/taro-api-contract-test.js`。该脚本会比对前端 `apiRequest('/api/...')` 与后端 `RouteDefinition[]` 注册表阻断调用不存在的接口、method 写错或绕过统一 API client动态路由只能通过脚本 allowlist 明确声明。
- 修改学生端、租户后台或平台后台关键页面时必须运行 `node scripts/taro-persona-contract-test.js`。该脚本按学生刷题/会员订单/错题收藏、租户学生运营/内容导入/营销财务/品牌权限、平台租户/账务/公共题库/员工权限三类角色旅程检查路由和服务调用,避免前端样式重做时误删核心业务入口。
- H5 构建完成后必须运行 `npm run smoke:taro:h5:interaction` 做真实浏览器点击验证。它会覆盖学生首页到题库练习、答题、收藏、个人中心开通会员,租户后台工作台到题库内容/财务运营,以及平台后台工作台到租户管理/账务中心;如果 Chrome/Edge 缺失,可设置 `TARO_H5_SMOKE_BROWSER` 指向 Chromium 浏览器。
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api`
- H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl``supabaseUrl``supabasePublishableKey``tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。
- 上线前需要把三套 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`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。
- 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、`npm run smoke:taro:h5:interaction` 真实浏览器交互烟测、严格 `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`/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` 接其它积分活动和兑换。

View File

@@ -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` 已通过;三套 Taro H5 已补 `index.html` 构建模板、发布产物扫描`npm run smoke:taro:h5` 静态启动烟测,能验证发布目录、公开 runtime config、history fallback、静态资源租户解析契约。
- 本地验证:`npm run check:refactor` 已通过;三套 Taro H5 已补 `index.html` 构建模板、发布产物扫描`npm run smoke:taro:h5` 静态启动烟测`npm run smoke:taro:h5:interaction` 真实浏览器交互烟测,能验证发布目录、公开 runtime config、history fallback、静态资源租户解析契约、关键页面 JS 执行和三类角色入口点击
当前更适合进入前端联调前阅读的总览文档:
@@ -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``npm run smoke:taro:h5``node scripts/taro-api-contract-test.js``node scripts/taro-persona-contract-test.js``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测和前端 API 契约检查。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;最新混合读写复测 100 worker/60s/8% 写入为 43379 请求、0 错误、710.38 req/s、P95 257.59ms、P99 328.75ms150 worker/60s/6% 写入为 43738 请求、0 错误、716.06 req/s、P95 375.03ms、P99 485.60ms,说明本地舒适区暂按 100 个无停顿 worker 估算,压力上沿仍在 100 到 150 worker 之间
- 已补 `npm run perf:api:local``npm run perf:summary``npm run perf:postgres:evidence``npm run smoke:launch-persona``npm run smoke:taro:h5``npm run smoke:taro:h5:interaction``node scripts/taro-api-contract-test.js``node scripts/taro-persona-contract-test.js``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;最新混合读写复测 100 worker/120s/8% 写入为 84694 请求、0 错误、699.54 req/s、P95 265.11ms、P99 337.82ms150 worker/120s/6% 写入为 74209 请求、0 错误、612.19 req/s、P95 446.37ms、P99 579.39ms。当前本机 Docker 舒适区暂按 100 个无停顿 worker 估算,150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 3500 到 14000 名活跃在线学生的吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准
- 当前本地 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 商用功能完善
@@ -265,7 +265,7 @@
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、男女预设头像选择、收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、小程序支付容器、分享场景和状态管理。前端不得实现头像上传、头像裁剪或第三方头像同步。
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
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 引入优化。
5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`,先执行 `npm run smoke:taro:h5``npm run smoke:taro:h5:interaction``node scripts/taro-h5-release-guardrails-test.js --require-dist` 做本地发布目录烟测、真实浏览器交互烟测和发布守卫;写入生产上线证据时必须执行 `npm --silent run smoke:taro:h5 -- --json``npm --silent run smoke:taro:h5:interaction -- --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 或修复脚本,不手工临时修库。
7. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
8. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -225,6 +225,24 @@
"tenantResolveRequests": 3
}
},
{
"id": "taro.h5-interaction-smoke",
"status": "pass",
"command": "npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json",
"completedAt": "2026-06-30T12:13:00+08:00",
"artifact": "launch-artifacts/taro-h5-interaction-smoke.json",
"summary": {
"fail": 0,
"pass": 11,
"mockApi": {
"keyRequests": {
"answers": 1,
"favorites": 2,
"tenantResolve": 3
}
}
}
},
{
"id": "taro.h5-release-guardrails",
"status": "pass",

View File

@@ -192,15 +192,17 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth并观察业务
```bash
npm run smoke:taro:h5
npm run smoke:taro:h5:interaction
node scripts/taro-h5-release-guardrails-test.js --require-dist
```
`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 验证租户解析契约。`smoke:taro:h5:interaction` 会用真实 Chrome/Edge 打开三套发布产物并点击学生刷题/收藏/会员、租户题库内容/财务、平台租户/账务中心关键路径;若服务器没有默认浏览器,可设置 `TARO_H5_SMOKE_BROWSER=/path/to/chrome`。发布守卫会检查三套 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
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
```

View File

@@ -82,16 +82,18 @@ npm run check:api
npm run check:worker
npm run check:taro
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
```
`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` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题`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
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
```

View File

@@ -60,6 +60,7 @@
"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",
"smoke:taro:h5:interaction": "node scripts/taro-h5-interaction-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",

View File

@@ -24,7 +24,17 @@ function sampleSummaryValue(expectedValue) {
}
function sampleSummary(summarySpec) {
return Object.fromEntries(Object.entries(summarySpec || {}).map(([key, value]) => [key, sampleSummaryValue(value)]));
const result = {};
for (const [key, value] of Object.entries(summarySpec || {})) {
const parts = key.split('.');
let cursor = result;
for (const part of parts.slice(0, -1)) {
if (!cursor[part] || typeof cursor[part] !== 'object') cursor[part] = {};
cursor = cursor[part];
}
cursor[parts.at(-1)] = sampleSummaryValue(value);
}
return result;
}
function createEvidence(tempDir, overrides = {}) {
@@ -154,6 +164,17 @@ assert.ok(
'missing H5 static smoke should be reported as a blocker',
);
const missingTaroInteractionSmoke = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'taro.h5-interaction-smoke');
return evidence;
});
assert.notEqual(missingTaroInteractionSmoke.status, 0, 'missing H5 interaction smoke should fail launch gate');
assert.ok(
missingTaroInteractionSmoke.payload.checks?.some(item => item.id === 'check.taro.h5-interaction-smoke' && item.status === 'blocker'),
'missing H5 interaction smoke should be reported as a blocker',
);
const missingAttestation = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot');

View File

@@ -134,6 +134,18 @@ const gateChecks = [
tenantResolveRequests: 3,
},
},
{
id: 'taro.h5-interaction-smoke',
label: 'Taro H5 real browser interaction smoke',
commandIncludes: 'smoke:taro:h5:interaction',
summary: {
fail: 0,
pass: { gte: 10 },
'mockApi.keyRequests.answers': { gte: 1 },
'mockApi.keyRequests.favorites': { gte: 1 },
'mockApi.keyRequests.tenantResolve': { gte: 3 },
},
},
{
id: 'taro.h5-release-guardrails',
label: 'Taro H5 release artifact guardrails',

View File

@@ -0,0 +1,924 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
const repoRoot = process.cwd();
const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist');
const outputDir = process.env.TARO_H5_INTERACTION_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
const ids = {
tenant: '00000000-0000-4000-8000-000000000001',
user: '00000000-0000-4000-8000-000000000101',
region: '00000000-0000-4000-8000-000000000301',
entry: '00000000-0000-4000-8000-000000000401',
node: '00000000-0000-4000-8000-000000000402',
collection: '00000000-0000-4000-8000-000000000403',
blueprint: '00000000-0000-4000-8000-000000000404',
question: '00000000-0000-4000-8000-000000000501',
session: '00000000-0000-4000-8000-000000000601',
plan: '00000000-0000-4000-8000-000000000701',
order: '00000000-0000-4000-8000-000000000801',
};
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' },
];
function parseArgs(argv) {
return {
json: argv.includes('--json'),
keepBrowser: argv.includes('--keep-browser'),
};
}
function shanghaiTimestampForFile(date = new Date()) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(date).map(part => [part.type, part.value]),
);
return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`;
}
function normalizeSlashes(value) {
return value.replace(/\\/g, '/');
}
function relative(filePath) {
return normalizeSlashes(path.relative(repoRoot, filePath));
}
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 jsonResponse(response, statusCode, payload, extraHeaders = {}) {
response.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
'access-control-allow-headers': 'authorization,content-type,x-tenant-id,x-smoke-portal',
...extraHeaders,
});
response.end(`${JSON.stringify(payload)}\n`);
}
function textResponse(response, statusCode, body, headers = {}) {
response.writeHead(statusCode, headers);
response.end(body);
}
function requestBody(req) {
return new Promise(resolve => {
let raw = '';
req.on('data', chunk => {
raw += chunk.toString();
});
req.on('end', () => {
if (!raw.trim()) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
resolve({ raw });
}
});
});
}
function baseTenantPayload(query) {
return {
item: {
tenantId: ids.tenant,
tenantSlug: query.get('tenantCode') || 'master',
branding: {
brandName: '工学题库',
shortName: '工学',
theme: {
primaryColor: '#2563eb',
accentColor: '#16a34a',
},
},
features: {
enableLeaderboard: false,
},
adminFeatures: {
tenantAdmin: true,
platformAdmin: true,
},
publicConfig: {
h5InteractionSmoke: true,
},
},
};
}
const question = {
id: ids.question,
content: '这是 H5 交互烟测题目1 + 1 = ?',
type: 'single_choice',
typeLabel: '单选题',
options: ['2', '3', '4', '5'],
correctOptionIndex: 0,
correctOptionIndices: [0],
explanation: '1 + 1 = 2。此题用于验证普通学生刷题、答题和收藏入口。',
answerText: '2',
hasVideoExplanation: true,
};
const tenantDashboard = {
cards: {
students: { total: 128, newInRange: 12 },
learning: { answers: 3200, activeStudents: 86, accuracy: 0.78 },
content: { questions: 74117 },
activationCodes: { total: 500, used: 180 },
feedback: { pending: 3 },
},
paymentStats: {
revenueCentsInRange: 529900,
paidOrdersInRange: 26,
},
planSales: [{ planId: ids.plan, name: 'SVIP 年卡', paidOrders: 18, revenueCents: 358200 }],
recentActivities: [{ activityType: 'student_registered', title: '新增学生注册', occurredAt: new Date().toISOString() }],
};
function mockApiPayload(pathname, method, query, body) {
if (pathname === '/health') return { ok: true };
if (pathname === '/api/tenant/resolve') return baseTenantPayload(query);
if (pathname === '/api/auth/me') return { user: { id: ids.user, name: '测试学生' }, item: { id: ids.user, name: '测试学生' } };
if (pathname === '/api/catalog/content-entries') {
return {
items: [{
id: ids.entry,
name: '天津专升本题库',
entryType: 'question_bank',
description: '公共课、专业课和院校分类入口',
regionId: ids.region,
}],
};
}
if (pathname === '/api/catalog/banners' || pathname === '/api/catalog/announcements') return { items: [] };
if (pathname === '/api/catalog/regions') return { items: [{ id: ids.region, name: '天津', code: 'tj', isHot: true }] };
if (pathname === '/api/catalog/content-nodes') {
return { items: [{ id: ids.node, entryId: ids.entry, name: '文化课', nodeType: 'subject', markerType: '公共课', isLeaf: true }] };
}
if (pathname === '/api/catalog/question-collections') {
return { items: [{ id: ids.collection, entryId: ids.entry, nodeId: ids.node, name: '英语基础章节', collectionType: 'chapter', questionCount: 1 }] };
}
if (pathname === '/api/catalog/practice-blueprints') {
return { items: [{ id: ids.blueprint, entryId: ids.entry, nodeId: ids.node, collectionId: ids.collection, name: '顺序练习', mode: 'sequential', questionLimit: 1 }] };
}
if (pathname === '/api/catalog/question-collections/questions' || pathname === '/api/catalog/questions') return { items: [question] };
if (pathname === '/api/catalog/svip-plans') {
return { items: [{ id: ids.plan, name: 'SVIP 年卡', priceCents: 19900, days: 365, badge: '推荐', regionId: ids.region, desc: '题库、解析和资料权益' }] };
}
if (pathname === '/api/profile/me') {
return {
item: {
id: ids.user,
userId: ids.user,
name: '测试学生',
phone: '13800000000',
avatarPreset: 'male',
score: 120,
target: { regionId: ids.region, regionName: '天津' },
stats: { answers: { totalAnswered: 12 } },
},
};
}
if (pathname === '/api/profile/score-events') return { items: [] };
if (pathname === '/api/profile/activity-tasks') return { items: [] };
if (pathname === '/api/profile/exchange-items') return { items: [] };
if (pathname === '/api/profile/notifications') return { items: [], summary: { unread: 0, read: 0, archived: 0 } };
if (pathname === '/api/profile/badges') return { items: [{ id: 'badge-smoke', name: '坚持练习' }] };
if (pathname === '/api/profile/exam-countdowns') return { items: [] };
if (pathname === '/api/commerce/entitlements') return { items: [{ type: 'svip', status: 'active' }], summary: { svip: true } };
if (pathname === '/api/commerce/orders' && method === 'GET') return { items: [] };
if (pathname === '/api/commerce/orders' && method === 'POST') {
return { item: { id: ids.order, orderNo: 'SMOKE202607010001', status: 'pending_payment', amountCents: 19900, planId: body.planId || ids.plan } };
}
if (pathname === '/api/commerce/payments/create') {
return { item: { provider: body.provider || 'alipay', paymentParams: { url: 'https://pay.example.test/smoke' } } };
}
if (pathname === '/api/commerce/orders/status') return { item: { orderNo: query.get('orderNo') || 'SMOKE202607010001', status: 'pending_payment' } };
if (pathname === '/api/learning/practice-sessions' && method === 'POST') {
return {
item: {
id: ids.session,
mode: body.mode || 'sequential',
questionIds: [ids.question],
questionCount: 1,
durationMinutes: 30,
accessMode: 'svip',
consumedFreeQuota: 0,
},
};
}
if (pathname === '/api/learning/practice-sessions/detail') {
return {
item: {
id: ids.session,
mode: 'sequential',
questionIds: [ids.question],
questionCount: 1,
status: 'active',
questions: [question],
answersByQuestion: {},
},
};
}
if (pathname === '/api/learning/answers') {
return {
item: {
id: 'answer-smoke',
questionId: body.questionId || ids.question,
isCorrect: true,
selectedOptions: body.selectedOptions || ['0'],
answeredAt: new Date().toISOString(),
},
};
}
if (pathname === '/api/learning/favorites/questions' && method === 'POST') return { ok: true, favorite: body.favorite !== false };
if (pathname === '/api/learning/favorites/questions') return { items: [question] };
if (pathname === '/api/learning/wrong-questions/review-plan') return { items: [], nextAction: {} };
if (pathname === '/api/learning/wrong-questions') return { items: [] };
if (pathname === '/api/learning/stats') {
return {
item: {
windowDays: 30,
answers: { totalAnswered: 12, correctCount: 10, wrongCount: 2, todayAnswered: 3, accuracy: 0.83 },
sessions: { totalSessions: 2, activeSessions: 0, finishedSessions: 2 },
reports: { reportCount: 1, avgAccuracy: 0.83, bestScore: 83 },
wrongBook: { unresolvedWrong: 2, resolvedWrong: 1, totalWrongBook: 3 },
favorites: { favoriteQuestions: 1 },
questionTypes: [],
},
};
}
if (pathname === '/api/learning/trend') return { items: [{ date: '2026-07-01', answeredCount: 3, correctCount: 2, wrongCount: 1, accuracy: 0.66, sessionCount: 1, reportCount: 1, score: 66, totalScore: 100 }] };
if (pathname === '/api/learning/practice-sessions/history') return { items: [] };
if (pathname === '/api/tenant-admin/dashboard') return { item: tenantDashboard };
if (pathname === '/api/tenant-admin/overview') return { item: { slug: 'master', brandName: '工学题库商户后台' } };
if (pathname === '/api/tenant-admin/permissions') {
return {
current: {
role: 'owner',
menuPermissions: { dashboard: true, students: true, content: true, marketing: true, commerce: true, settings: true },
effectivePermissions: { '*': true },
},
roleDefaults: { owner: ['*'] },
};
}
if (pathname === '/api/tenant-content/content-entries') return { items: [{ id: ids.entry, name: '天津专升本题库', entryType: 'question_bank', status: 'active' }] };
if (pathname === '/api/tenant-content/public-question-banks') return { items: [{ grantId: 'grant-smoke', questionBankId: 'bank-smoke', name: '平台公共题库', regionName: '天津', grantScope: '套餐授权', questionCount: 128 }] };
if (pathname === '/api/tenant-content/imports/templates') return { item: { importType: query.get('importType') || 'questions', format: query.get('format') || 'json', content: '{}' } };
if (pathname === '/api/tenant-content/imports/field-mapping') return { item: { importType: query.get('importType') || 'questions', aliases: {}, allowedTargets: [] } };
if (pathname.startsWith('/api/tenant-content/') || pathname.startsWith('/api/tenant-admin/') || pathname.startsWith('/api/crm/') || pathname.startsWith('/api/commission/') || pathname.startsWith('/api/referral/')) {
return method === 'GET' ? { items: [], item: null, summary: {} } : { ok: true, item: { id: 'tenant-smoke' } };
}
if (pathname === '/api/platform-admin/overview') {
return { item: { tenants: { total: 6, active: 5 }, billing: { unpaidAmountCents: 360000 }, subscriptions: { expiringSoon: 1 }, usage: { questions: 74117 } } };
}
if (pathname === '/api/platform-admin/permissions') return { item: { primaryRole: 'platform_admin', effective: { '*': true }, catalog: [] } };
if (pathname === '/api/platform-admin/plans') return { items: [{ id: ids.plan, code: 'starter_yearly', name: '基础年费', baseAmountCents: 199900, status: 'active' }] };
if (pathname === '/api/platform-admin/tenants') return { items: [{ id: ids.tenant, slug: 'master', name: '工学题库', brandName: '工学题库', status: 'active', billingStatus: 'normal', planCode: 'starter_yearly', openBalanceCents: 0 }] };
if (pathname === '/api/platform-admin/invoices') return { items: [{ id: 'invoice-smoke', tenantId: ids.tenant, invoiceNo: 'INV-SMOKE', status: 'unpaid', totalCents: 199900, balanceCents: 199900 }] };
if (pathname === '/api/platform-admin/question-banks') return { items: [{ id: 'bank-smoke', name: '平台公共题库', status: 'active', questionCount: 128 }] };
if (pathname === '/api/platform-admin/question-bank-grants') return { items: [{ id: 'grant-smoke', questionBankId: 'bank-smoke', grantScope: 'all_active_tenants', status: 'active' }] };
if (pathname === '/api/platform-admin/staff') return { items: [{ id: ids.user, name: '平台管理员', primaryRole: 'platform_admin', status: 'active', authUserId: 'auth-smoke' }] };
if (pathname.startsWith('/api/platform-admin/')) return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'platform-smoke' } };
return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'smoke' } };
}
async function createMockApiServer() {
const requests = [];
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
if (req.method === 'OPTIONS') {
jsonResponse(res, 204, {});
return;
}
const body = await requestBody(req);
requests.push({
method: req.method || 'GET',
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
body,
});
try {
jsonResponse(res, 200, mockApiPayload(url.pathname, req.method || 'GET', url.searchParams, body));
} catch (error) {
jsonResponse(res, 500, { code: 'MOCK_API_ERROR', message: error instanceof Error ? error.message : String(error) });
}
});
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_h5_interaction_smoke',
tenantCode: 'master',
};
assertDistExists(portal);
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;
}
res.writeHead(200, {
'content-type': contentTypeFor(finalPath),
'cache-control': finalPath.endsWith('index.html') ? 'no-store' : 'public, max-age=31536000, immutable',
});
fs.createReadStream(finalPath).pipe(res);
});
const port = await listen(server);
return {
portal: portal.portal,
baseUrl: `http://127.0.0.1:${port}`,
landingPath: portal.landingPath,
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 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. Run npm run build:taro:h5 before interaction smoke.`);
if (!fs.existsSync(indexPath)) throw new Error(`${relative(indexPath)} does not exist. Run npm run build:taro:h5 before interaction smoke.`);
}
function listen(server) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
});
}
function closeServer(server) {
return new Promise(resolve => server.close(() => resolve()));
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
});
}
function findBrowserExecutable() {
const candidates = [
process.env.TARO_H5_SMOKE_BROWSER,
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/microsoft-edge',
].filter(Boolean);
return candidates.find(item => fs.existsSync(item));
}
async function waitForDebugEndpoint(port, timeoutMs = 15_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
if (response.ok) return await response.json();
} catch {
// Chrome is still starting.
}
await delay(200);
}
throw new Error('Browser DevTools endpoint did not become available.');
}
async function startBrowser(options) {
const executable = findBrowserExecutable();
if (!executable) {
throw new Error('Chrome/Edge executable not found. Set TARO_H5_SMOKE_BROWSER to a Chromium-based browser path.');
}
const debugPort = await getFreePort();
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-h5-interaction-'));
const args = [
'--headless=new',
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-default-browser-check',
'--disable-background-networking',
'--disable-extensions',
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${userDataDir}`,
'about:blank',
];
const child = spawn(executable, args, { stdio: 'ignore', windowsHide: true });
await waitForDebugEndpoint(debugPort);
return {
executable,
debugPort,
close: async () => {
if (!options.keepBrowser && !child.killed) child.kill();
if (!options.keepBrowser) {
await delay(300);
fs.rmSync(userDataDir, { recursive: true, force: true });
}
},
};
}
class CdpPage {
constructor(wsUrl) {
this.wsUrl = wsUrl;
this.id = 1;
this.pending = new Map();
this.events = [];
this.ws = new WebSocket(wsUrl);
}
async connect() {
await new Promise((resolve, reject) => {
this.ws.addEventListener('open', resolve, { once: true });
this.ws.addEventListener('error', reject, { once: true });
this.ws.addEventListener('message', event => {
const message = JSON.parse(event.data);
if (message.id && this.pending.has(message.id)) {
const { resolve: finish, reject: fail } = this.pending.get(message.id);
this.pending.delete(message.id);
if (message.error) fail(new Error(`${message.error.message}: ${message.error.data || ''}`));
else finish(message.result || {});
} else if (message.method) {
this.events.push(message);
}
});
});
await this.send('Runtime.enable');
await this.send('Page.enable');
await this.send('Log.enable');
await this.send('Network.enable');
return this;
}
send(method, params = {}) {
const id = this.id++;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id);
reject(new Error(`CDP command timed out: ${method}`));
}
}, 10_000);
});
}
async evaluate(expression) {
const result = await this.send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
});
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed');
}
return result.result?.value;
}
async navigate(url) {
await this.send('Page.navigate', { url });
}
close() {
this.ws.close();
}
diagnosticEvents() {
return this.events
.filter(event => [
'Runtime.exceptionThrown',
'Runtime.consoleAPICalled',
'Log.entryAdded',
'Network.loadingFailed',
'Network.responseReceived',
].includes(event.method))
.map(event => {
if (event.method === 'Runtime.exceptionThrown') {
return {
method: event.method,
text: event.params?.exceptionDetails?.text,
description: event.params?.exceptionDetails?.exception?.description,
};
}
if (event.method === 'Runtime.consoleAPICalled') {
return {
method: event.method,
type: event.params?.type,
args: (event.params?.args || []).map(arg => arg.value || arg.description).filter(Boolean).slice(0, 5),
};
}
if (event.method === 'Log.entryAdded') {
return {
method: event.method,
level: event.params?.entry?.level,
text: event.params?.entry?.text,
url: event.params?.entry?.url,
};
}
if (event.method === 'Network.loadingFailed') {
return {
method: event.method,
errorText: event.params?.errorText,
type: event.params?.type,
};
}
const response = event.params?.response || {};
if (response.status >= 400) {
return {
method: event.method,
status: response.status,
url: response.url,
};
}
return null;
})
.filter(Boolean)
.slice(-20);
}
}
async function newPage(browser, url) {
const response = await fetch(`http://127.0.0.1:${browser.debugPort}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
if (!response.ok) throw new Error(`Failed to create browser tab: ${response.status}`);
const target = await response.json();
return new CdpPage(target.webSocketDebuggerUrl).connect();
}
async function waitUntil(label, fn, timeoutMs = 10_000) {
const started = Date.now();
let lastValue;
while (Date.now() - started < timeoutMs) {
lastValue = await fn().catch(error => ({ error: error.message }));
if (lastValue) return lastValue;
await delay(200);
}
throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}`);
}
async function bodyText(page) {
return page.evaluate('document.body ? document.body.innerText : ""');
}
async function currentPath(page) {
return page.evaluate('location.pathname + location.search + location.hash');
}
async function waitForText(page, text, timeoutMs = 10_000) {
try {
await waitUntil(`text "${text}"`, async () => {
const textContent = await bodyText(page);
return textContent.includes(text);
}, timeoutMs);
} catch (error) {
const [pathValue, textContent] = await Promise.all([
currentPath(page).catch(() => ''),
bodyText(page).catch(() => ''),
]);
throw new Error(`${error.message}\nCurrent path: ${pathValue}\nBody excerpt: ${textContent.slice(0, 1200)}\nBrowser events: ${JSON.stringify(page.diagnosticEvents(), null, 2)}`);
}
}
async function assertNoText(page, text) {
const textContent = await bodyText(page);
if (textContent.includes(text)) throw new Error(`Unexpected text found in page: ${text}`);
}
async function waitForPath(page, pathPart, timeoutMs = 10_000) {
await waitUntil(`path "${pathPart}"`, async () => {
const pathValue = await currentPath(page);
return pathValue.includes(pathPart);
}, timeoutMs);
}
async function clickText(page, text) {
const result = await page.evaluate(`
(() => {
const expected = ${JSON.stringify(text)};
const clickableSelector = [
'button',
'taro-button-core',
'a',
'[role="button"]',
'[onclick]',
'.entry-tile',
'.list-row',
'.metric',
'.admin-row',
'.admin-button',
'.platform-row',
'.platform-button',
'[class*="button"]'
].join(',');
const visible = el => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
};
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
const textOf = el => normalized(el.innerText || el.textContent || '');
const byElement = new Map();
for (const el of Array.from(document.querySelectorAll('*'))) {
if (!visible(el)) continue;
const text = textOf(el);
if (!text.includes(expected)) continue;
const target = el.matches(clickableSelector) ? el : el.closest(clickableSelector);
if (!target || !visible(target)) continue;
const targetText = textOf(target);
if (!targetText.includes(expected)) continue;
const exact = targetText === expected ? 0 : 1;
const tagScore = ['BUTTON', 'TARO-BUTTON-CORE', 'A'].includes(target.tagName) ? 0 : 1;
const lengthScore = targetText.length;
const previous = byElement.get(target);
const next = { el: target, text: targetText, score: exact * 10000 + tagScore * 1000 + lengthScore };
if (!previous || next.score < previous.score) byElement.set(target, next);
}
const candidates = Array.from(byElement.values()).sort((a, b) => a.score - b.score);
const target = candidates[0]?.el;
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
target.scrollIntoView({ block: 'center', inline: 'center' });
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
}
target.click();
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120) };
})()
`);
if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`);
await delay(350);
return result;
}
async function waitForApiRequest(api, path, method) {
await waitUntil(`${method || ''} ${path} request`, async () => {
return api.requests.some(item => item.path === path && (!method || item.method === method));
}, 10_000);
}
async function runStudentJourney(browser, portal, api) {
const checks = [];
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
try {
await waitForText(page, '今日学习');
await waitForText(page, '功能导航');
await assertNoText(page, '排行榜');
checks.push({ id: 'student.home.rendered', status: 'pass', detail: '首页渲染并未默认展示排行榜' });
await clickText(page, '刷题');
await waitForPath(page, '/pages/student/catalog/index');
await waitForText(page, '题库练习');
checks.push({ id: 'student.home.to_catalog', status: 'pass', detail: await currentPath(page) });
await clickText(page, '顺序练习');
await waitForPath(page, '/pages/student/practice/index');
await waitForText(page, '这是 H5 交互烟测题目');
checks.push({ id: 'student.catalog.to_practice', status: 'pass', detail: await currentPath(page) });
await clickText(page, 'A.');
await waitForApiRequest(api, '/api/learning/answers', 'POST');
await waitForText(page, '正确');
await clickText(page, '收藏');
await waitForApiRequest(api, '/api/learning/favorites/questions', 'POST');
checks.push({ id: 'student.practice.answer_favorite', status: 'pass', detail: '答题和收藏 API 已触发' });
await page.navigate(`${portal.baseUrl}/pages/student/home/index`);
await waitForText(page, '个人中心');
await clickText(page, '个人中心');
await waitForPath(page, '/pages/student/profile/index');
await waitForText(page, '会员套餐');
await clickText(page, '开通会员');
await waitForPath(page, '/pages/student/checkout/index');
await waitForText(page, '会员收银台');
checks.push({ id: 'student.profile.to_checkout', status: 'pass', detail: await currentPath(page) });
return checks;
} finally {
page.close();
}
}
async function runTenantJourney(browser, portal) {
const checks = [];
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
try {
await waitForText(page, '工学题库商户后台');
await waitForText(page, '后台模块');
checks.push({ id: 'tenant.workbench.rendered', status: 'pass', detail: '工作台渲染并展示权限驱动模块' });
await clickText(page, '题库内容');
await waitForPath(page, '/pages/tenant-admin/content/index');
await waitForText(page, '题库内容');
checks.push({ id: 'tenant.workbench.to_content', status: 'pass', detail: await currentPath(page) });
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
await waitForText(page, '财务运营');
await clickText(page, '财务运营');
await waitForPath(page, '/pages/tenant-admin/finance/index');
await waitForText(page, '财务运营');
checks.push({ id: 'tenant.workbench.to_finance', status: 'pass', detail: await currentPath(page) });
return checks;
} finally {
page.close();
}
}
async function runPlatformJourney(browser, portal) {
const checks = [];
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
try {
await waitForText(page, 'SaaS 平台后台');
await waitForText(page, '后台模块');
checks.push({ id: 'platform.workbench.rendered', status: 'pass', detail: '平台工作台渲染' });
await clickText(page, '租户管理');
await waitForPath(page, '/pages/platform-admin/tenants/index');
await waitForText(page, '租户管理');
checks.push({ id: 'platform.workbench.to_tenants', status: 'pass', detail: await currentPath(page) });
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
await waitForText(page, '账务中心');
await clickText(page, '账务中心');
await waitForPath(page, '/pages/platform-admin/billing/index');
await waitForText(page, '账务中心');
checks.push({ id: 'platform.workbench.to_billing', status: 'pass', detail: await currentPath(page) });
return checks;
} finally {
page.close();
}
}
function markdownReport(payload) {
const lines = [
'# Taro H5 交互烟测报告',
'',
`生成时间:${payload.generatedAt}`,
'',
`浏览器:${payload.browser.executable}`,
'',
`结果:${payload.summary.fail} fail(s)${payload.summary.pass} pass(es)`,
'',
'| 检查项 | 状态 | 说明 |',
'| --- | --- | --- |',
];
for (const check of payload.checks) {
lines.push(`| ${check.id} | ${check.status} | ${String(check.detail || '').replace(/\|/g, '\\|')} |`);
}
lines.push('');
lines.push('说明:该烟测使用本地 mock API 和三套 H5 发布产物,覆盖真实浏览器中的学生、租户后台、平台后台关键入口点击。它不替代真实 Supabase Auth、支付、短信、对象存储和生产 provider 联调。');
return `${lines.join('\n')}\n`;
}
function writeReport(payload) {
fs.mkdirSync(outputDir, { recursive: true });
const stamp = shanghaiTimestampForFile();
const jsonPath = path.join(outputDir, `taro-h5-interaction-smoke-${stamp}.json`);
const mdPath = path.join(outputDir, `taro-h5-interaction-smoke-${stamp}.md`);
fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
fs.writeFileSync(mdPath, markdownReport(payload), 'utf8');
return { jsonPath, mdPath };
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const api = await createMockApiServer();
const staticServers = [];
let browser = null;
try {
for (const portal of portals) staticServers.push(await createStaticServer(portal, api.baseUrl));
browser = await startBrowser(options);
const checks = [];
checks.push(...await runStudentJourney(browser, staticServers.find(item => item.portal === 'student'), api));
checks.push(...await runTenantJourney(browser, staticServers.find(item => item.portal === 'tenant-admin')));
checks.push(...await runPlatformJourney(browser, staticServers.find(item => item.portal === 'platform-admin')));
const payload = {
generatedAt: new Date().toISOString(),
summary: {
pass: checks.filter(item => item.status === 'pass').length,
fail: checks.filter(item => item.status !== 'pass').length,
},
browser: {
executable: browser.executable,
},
staticServers: staticServers.map(item => ({ portal: item.portal, baseUrl: item.baseUrl, landingPath: item.landingPath })),
mockApi: {
baseUrl: api.baseUrl,
requestCount: api.requests.length,
keyRequests: {
answers: api.requests.filter(item => item.path === '/api/learning/answers').length,
favorites: api.requests.filter(item => item.path === '/api/learning/favorites/questions').length,
tenantResolve: api.requests.filter(item => item.path === '/api/tenant/resolve').length,
},
},
checks,
};
const files = writeReport(payload);
payload.artifacts = {
json: relative(files.jsonPath),
markdown: relative(files.mdPath),
};
if (options.json) {
console.log(JSON.stringify(payload, null, 2));
} else {
console.log(`Taro H5 interaction smoke: ${payload.summary.fail} fail(s), ${payload.summary.pass} pass(es)`);
for (const check of checks) console.log(`[${check.status.toUpperCase()}] ${check.id}: ${check.detail}`);
console.log(`[smoke] wrote ${relative(files.jsonPath)}`);
console.log(`[smoke] wrote ${relative(files.mdPath)}`);
}
if (payload.summary.fail > 0) process.exitCode = 1;
} catch (error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exitCode = 1;
} finally {
if (browser) await browser.close();
for (const server of staticServers) await server.close();
await api.close();
}
}
main();