diff --git a/README.md b/README.md index e0c4437b..4d19823c 100644 --- a/README.md +++ b/README.md @@ -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 仍低于 300ms;150 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 diff --git a/apps/taro/config/index.ts b/apps/taro/config/index.ts index c760b571..4e15e1d6 100644 --- a/apps/taro/config/index.ts +++ b/apps/taro/config/index.ts @@ -43,7 +43,7 @@ export default { options: {}, }, h5: { - publicPath: './', + publicPath: '/', staticDirectory: 'static', output: { filename: 'js/[name].[contenthash:8].js', diff --git a/apps/taro/src/env.ts b/apps/taro/src/env.ts index 4f38c0fa..20f1d5a9 100644 --- a/apps/taro/src/env.ts +++ b/apps/taro/src/env.ts @@ -25,6 +25,15 @@ declare const process: { env: Record; }; +type ProcessLike = { + env?: Record; +}; + +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, 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 | 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'; +} diff --git a/apps/taro/src/pages/bootstrap/index.tsx b/apps/taro/src/pages/bootstrap/index.tsx index 8dfe78cb..8e049350 100644 --- a/apps/taro/src/pages/bootstrap/index.tsx +++ b/apps/taro/src/pages/bootstrap/index.tsx @@ -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 ''; } diff --git a/apps/taro/src/pages/student/ai-school/index.tsx b/apps/taro/src/pages/student/ai-school/index.tsx index ba40358b..4193110d 100644 --- a/apps/taro/src/pages/student/ai-school/index.tsx +++ b/apps/taro/src/pages/student/ai-school/index.tsx @@ -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 { diff --git a/apps/taro/src/pages/student/assets/index.tsx b/apps/taro/src/pages/student/assets/index.tsx index cca286b6..31781d4d 100644 --- a/apps/taro/src/pages/student/assets/index.tsx +++ b/apps/taro/src/pages/student/assets/index.tsx @@ -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() { 下载前请确认页面水印和追踪码。签名 URL 不会长期保存,过期后需要重新申请。 ) : null} - {previewState.kind === 'preview' && process.env.TARO_ENV === 'h5' ? ( + {previewState.kind === 'preview' && isH5Runtime() ? ( ) : null} - {previewState.kind === 'preview' && process.env.TARO_ENV !== 'h5' ? ( + {previewState.kind === 'preview' && !isH5Runtime() ? ( 已获取预览授权 当前小程序容器不直接嵌入私有文件。请先确认水印追踪码,再使用外部打开或复制签名链接。 diff --git a/apps/taro/src/pages/student/checkout/index.tsx b/apps/taro/src/pages/student/checkout/index.tsx index 88a4f941..020b9064 100644 --- a/apps/taro/src/pages/student/checkout/index.tsx +++ b/apps/taro/src/pages/student/checkout/index.tsx @@ -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) diff --git a/apps/taro/src/pages/student/order-detail/index.tsx b/apps/taro/src/pages/student/order-detail/index.tsx index 41e00965..2e062aa8 100644 --- a/apps/taro/src/pages/student/order-detail/index.tsx +++ b/apps/taro/src/pages/student/order-detail/index.tsx @@ -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 }); diff --git a/apps/taro/src/pages/tenant-admin/content/index.tsx b/apps/taro/src/pages/tenant-admin/content/index.tsx index fe3f095f..d6654e5c 100644 --- a/apps/taro/src/pages/tenant-admin/content/index.tsx +++ b/apps/taro/src/pages/tenant-admin/content/index.tsx @@ -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('模板内容为空,请重新加载模板。'); diff --git a/apps/taro/src/services/api-auth.ts b/apps/taro/src/services/api-auth.ts index 5993ff21..e2eb9913 100644 --- a/apps/taro/src/services/api-auth.ts +++ b/apps/taro/src/services/api-auth.ts @@ -1,9 +1,11 @@ +import { isH5Runtime } from '../env'; + export type ApiAuthMode = 'auto' | 'none' | 'supabase' | 'legacy'; export type SupabaseAccessTokenProvider = () => Promise; async function defaultH5SupabaseAccessTokenProvider() { - if (process.env.TARO_ENV !== 'h5') return null; + if (!isH5Runtime()) return null; const { getSupabaseAccessToken } = await import('./supabase'); return getSupabaseAccessToken(); } diff --git a/apps/taro/src/services/pronunciation.ts b/apps/taro/src/services/pronunciation.ts index e0345c4f..c482e7be 100644 --- a/apps/taro/src/services/pronunciation.ts +++ b/apps/taro/src/services/pronunciation.ts @@ -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'; diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 4f85c016..cc808330 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -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` 接其它积分活动和兑换。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 94b727b7..2e027029 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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.75ms;150 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.82ms;150 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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/production-launch-evidence.template.json b/docs/refactor/production-launch-evidence.template.json index d0ae1aea..4330e536 100644 --- a/docs/refactor/production-launch-evidence.template.json +++ b/docs/refactor/production-launch-evidence.template.json @@ -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", diff --git a/docs/refactor/taro-h5-deployment.md b/docs/refactor/taro-h5-deployment.md index 7d1f54d4..e02bd780 100644 --- a/docs/refactor/taro-h5-deployment.md +++ b/docs/refactor/taro-h5-deployment.md @@ -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 ``` diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index 63f773b6..e27f0926 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -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 ``` diff --git a/package.json b/package.json index e8d284bd..9f0a7304 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/production-launch-gate-test.js b/scripts/production-launch-gate-test.js index d9789903..b6745c70 100644 --- a/scripts/production-launch-gate-test.js +++ b/scripts/production-launch-gate-test.js @@ -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'); diff --git a/scripts/production-launch-gate.js b/scripts/production-launch-gate.js index 6a3746e9..f91367a5 100644 --- a/scripts/production-launch-gate.js +++ b/scripts/production-launch-gate.js @@ -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', diff --git a/scripts/taro-h5-interaction-smoke.js b/scripts/taro-h5-interaction-smoke.js new file mode 100644 index 00000000..9888dd79 --- /dev/null +++ b/scripts/taro-h5-interaction-smoke.js @@ -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();