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', vocabularyUnit: '00000000-0000-4000-8000-000000000901', vocabularyWord: '00000000-0000-4000-8000-000000000902', handbookSubject: '00000000-0000-4000-8000-000000001001', handbookChapter: '00000000-0000-4000-8000-000000001002', handbookEntry: '00000000-0000-4000-8000-000000001003', asset: '00000000-0000-4000-8000-000000001101', video: '00000000-0000-4000-8000-000000001201', school: '00000000-0000-4000-8000-000000001301', major: '00000000-0000-4000-8000-000000001302', scorelineField: '00000000-0000-4000-8000-000000001303', scorelineRecord: '00000000-0000-4000-8000-000000001304', notification: '00000000-0000-4000-8000-000000001401', aiReport: '00000000-0000-4000-8000-000000001501', }; 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: '天津', schoolId: ids.school, schoolName: '天津职业大学', majorId: ids.major, majorName: '软件工程' }, membership: { isSvip: true }, 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: [{ id: ids.notification, notificationType: 'badge_granted', status: query.get('status') || 'unread', severity: 'success', title: '入门勋章已发放', message: '完成首次练习后,系统已自动发放坚持练习勋章。', actionLabel: '查看个人中心', actionPath: '/pages/student/profile/index', createdAt: new Date().toISOString(), }], summary: { unread: 1, read: 0, archived: 0, dismissed: 0 }, }; } if (pathname === '/api/profile/notifications/status') return { item: { updatedCount: body.notificationIds?.length || 1, status: body.status || 'read' } }; 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: [{ questionId: ids.question, content: question.content, type: question.type, typeLabel: question.typeLabel, wrongCount: 2, lastWrongAt: new Date().toISOString(), }], nextAction: { mode: 'wrong_review', questionLimit: 30 }, }; } if (pathname === '/api/learning/wrong-questions/resolve') return { ok: true }; if (pathname === '/api/learning/wrong-questions') return { items: [question] }; 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/catalog/vocabulary-units') { return { items: [{ id: ids.vocabularyUnit, name: '英语核心词汇', description: '专升本高频词', wordCount: 1, regionId: ids.region }] }; } if (pathname === '/api/catalog/vocabulary-words') { return { items: [{ id: ids.vocabularyWord, wordId: ids.vocabularyWord, unitId: ids.vocabularyUnit, word: 'analysis', phonetic: "ə'næləsɪs", meaning: '分析', example: 'Careful analysis helps students improve.', exampleTranslation: '细致分析能帮助学生提升。', status: 'new', }], }; } if (pathname === '/api/learning/vocabulary/review-plan') { return { item: { words: [{ id: ids.vocabularyWord, wordId: ids.vocabularyWord, unitId: ids.vocabularyUnit, word: 'analysis', phonetic: "ə'næləsɪs", meaning: '分析', example: 'Careful analysis helps students improve.', exampleTranslation: '细致分析能帮助学生提升。', dueLevel: 'new', }], dueCount: 1, newCount: 1, totalPlanned: 1, }, }; } if (pathname === '/api/learning/vocabulary/stats') { return { item: { totalWords: 1, progressedWords: 0, masteredWords: 0, learningWords: 0, todayReviewed: 0, favoriteWords: 0 } }; } if (pathname === '/api/learning/vocabulary/favorites' && method === 'POST') return { ok: true, favorite: body.favorite !== false }; if (pathname === '/api/learning/vocabulary/favorites') return { items: [] }; if (pathname === '/api/learning/vocabulary/review') return { item: { wordId: body.wordId || ids.vocabularyWord, result: body.result || 'known' } }; if (pathname === '/api/catalog/handbook-subjects') { return { items: [{ id: ids.handbookSubject, name: '高等数学手册', type: 'handbook', description: '章节化知识点' }] }; } if (pathname === '/api/catalog/handbook-chapters') { return { items: [{ id: ids.handbookChapter, subjectId: ids.handbookSubject, name: '函数与极限', description: '基础章节' }] }; } if (pathname === '/api/catalog/handbook-entries') { return { items: [{ id: ids.handbookEntry, chapterId: ids.handbookChapter, title: '导数定义', summary: '导数描述函数在某一点附近的变化率。', content: '设函数 y=f(x),当自变量增量趋近于 0 时,差商的极限称为导数。$f(x)=x^2$ 的导数是 $2x$。', }], }; } if (pathname === '/api/catalog/assets') { return { items: [{ id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', categoryLabel: '真题资料', description: '用于验证私有资料短签名、水印和预览入口。', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members', previewStatus: 'ready', }], }; } if (pathname === '/api/catalog/assets/preview') { return { item: { id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members' }, preview: { url: 'https://assets.example.test/tj-english-paper-preview.pdf?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' }, watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-preview-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' }, }; } if (pathname === '/api/catalog/assets/download') { return { item: { id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members' }, download: { url: 'https://assets.example.test/tj-english-paper.pdf?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' }, watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-download-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' }, }; } if (pathname === '/api/questions/videos') { return { videos: [{ id: ids.video, questionId: query.get('questionId') || ids.question, title: '本题视频解析', description: '后端签名播放烟测视频', duration: 180, accessMode: 'svip', playable: true, }], total: 1, }; } if (pathname === '/api/videos/play') { return { item: { id: body.videoId || ids.video, questionId: body.questionId || ids.question, title: '本题视频解析', duration: 180, accessMode: 'svip' }, playToken: 'video-play-token-smoke', playback: { url: 'https://assets.example.test/video-smoke.mp4?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' }, watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-video-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' }, access: { accessMode: 'svip', consumedQuota: 0 }, }; } if (pathname === '/api/scoreline/fields') { return { items: [{ id: ids.scorelineField, regionId: ids.region, fieldKey: 'min_score', fieldName: '最低分', fieldType: 'number', unit: '分', isFilter: true, isVisible: true, isTrend: true, sortOrder: 1 }] }; } if (pathname === '/api/scoreline/schools') { return { items: [{ id: ids.school, regionId: ids.region, name: '天津职业大学', shortName: '天职大', isHot: true, order: 1 }] }; } if (pathname === '/api/scoreline/majors') { return { items: [{ id: ids.major, regionId: ids.region, schoolId: ids.school, name: '软件工程', order: 1, hasRestriction: false }] }; } if (pathname === '/api/scoreline/years') return { items: [2026, 2025, 2024] }; if (pathname === '/api/scoreline/trend' || pathname === '/api/scoreline/records') { return { items: [{ id: ids.scorelineRecord, regionId: ids.region, schoolId: ids.school, majorId: ids.major, year: 2025, schoolName: '天津职业大学', majorName: '软件工程', fieldValues: { min_score: 218 }, }], total: 1, }; } if (pathname === '/api/ai/school-recommendations') { return { items: [{ id: ids.aiReport, tenantId: ids.tenant, userId: ids.user, regionId: ids.region, status: 'generated', provider: 'local_rules', promptVersion: 'smoke-v1', inputPayload: { estimatedScore: 210, riskPreference: 'balanced' }, contextPayload: {}, resultPayload: { schemaVersion: 'school-recommendation-report-v1', summary: '建议优先关注天津职业大学软件工程方向。', riskLevel: 'balanced', recommendedSchools: [{ schoolId: ids.school, schoolName: '天津职业大学', majorId: ids.major, majorName: '软件工程', latestYear: 2025, latestScore: 218, averageScore: 214, scoreGap: -8, riskLevel: 'balanced', confidence: 0.76, reason: '与预估分接近,适合作为稳中带冲选择。', tags: ['天津', '工科'], }], actionPlan: ['复盘公共课错题', '补齐英语词汇'], disclaimers: ['烟测报告仅用于页面联调。'], dataCoverage: { scorelines: 1 }, }, generatedAt: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }], }; } if (pathname === '/api/ai/school-recommendations/generate') { return mockApiPayload('/api/ai/school-recommendations', 'GET', query, body); } if (pathname === '/api/ai/school-recommendations/export') { return { item: { reportId: ids.aiReport, format: query.get('format') || 'markdown', fileName: 'school-report-smoke.md', mimeType: 'text/markdown', contentBase64: 'IyBTbW9rZQ==', contentText: '# Smoke', sha256: 'smoke', sizeBytes: 7 } }; } 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 navigateAndExpect(page, baseUrl, path, text, timeoutMs = 10_000) { await page.navigate(`${baseUrl}${path}`); await waitForPath(page, path.split('?')[0], timeoutMs); await waitForText(page, text, timeoutMs); } 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) }); await navigateAndExpect(page, portal.baseUrl, '/pages/student/review/index?type=wrong', '错题本'); await waitForText(page, '开始复习'); await waitForText(page, '这是 H5 交互烟测题目'); await clickText(page, '收藏'); await waitForText(page, '收藏夹'); await clickText(page, '开始复习'); await waitForPath(page, '/pages/student/practice/index'); checks.push({ id: 'student.review.wrong_favorite', status: 'pass', detail: '错题本、收藏夹和后端组卷入口可打开' }); await navigateAndExpect(page, portal.baseUrl, '/pages/student/vocabulary/index', '背单词'); await waitForText(page, '学习概览'); await waitForText(page, 'analysis'); await clickText(page, '认识'); await waitForApiRequest(api, '/api/learning/vocabulary/review', 'POST'); checks.push({ id: 'student.vocabulary.review', status: 'pass', detail: '背单词计划和复习提交 API 已触发' }); await navigateAndExpect(page, portal.baseUrl, '/pages/student/handbook/index', '知识手册'); await waitForText(page, '高等数学手册'); await waitForText(page, '导数定义'); await waitForText(page, '变化率'); checks.push({ id: 'student.handbook.rendered', status: 'pass', detail: '手册科目、章节和知识点渲染正常' }); await navigateAndExpect(page, portal.baseUrl, '/pages/student/assets/index', '资料下载'); await waitForText(page, '天津专升本英语真题 PDF'); await clickText(page, '预览'); await waitForText(page, '追踪码'); await waitForApiRequest(api, '/api/catalog/assets/preview', 'GET'); await clickText(page, '下载'); await waitForText(page, '确认下载'); await waitForApiRequest(api, '/api/catalog/assets/download', 'GET'); checks.push({ id: 'student.assets.signed_watermark', status: 'pass', detail: '资料预览/下载短签名和水印面板可用' }); await navigateAndExpect(page, portal.baseUrl, `/pages/student/video/index?questionId=${ids.question}`, '视频解析'); await waitForText(page, '本题视频解析'); await clickText(page, '本题视频解析'); await waitForApiRequest(api, '/api/videos/play', 'POST'); checks.push({ id: 'student.video.play_auth', status: 'pass', detail: '视频列表和播放授权 API 已触发' }); await navigateAndExpect(page, portal.baseUrl, '/pages/student/scoreline/index', '历年分数线'); await waitForText(page, '天津职业大学'); await waitForText(page, '最低分'); checks.push({ id: 'student.scoreline.rendered', status: 'pass', detail: '分数线筛选、趋势和结果渲染正常' }); await navigateAndExpect(page, portal.baseUrl, '/pages/student/ai-school/index', 'AI择校推荐'); await waitForText(page, '推荐结果'); await waitForText(page, '天津职业大学'); checks.push({ id: 'student.ai_school.rendered', status: 'pass', detail: 'AI 择校报告列表和推荐结果可渲染' }); await navigateAndExpect(page, portal.baseUrl, '/pages/student/notifications/index', '消息中心'); await waitForText(page, '入门勋章已发放'); await clickText(page, '全部已读'); await waitForApiRequest(api, '/api/profile/notifications/status', 'POST'); checks.push({ id: 'student.notifications.status', status: 'pass', detail: '消息筛选和批量已读 API 已触发' }); 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) }); const moduleChecks = [ { label: '数据看板', path: '/pages/tenant-admin/dashboard/index' }, { label: '学生运营', path: '/pages/tenant-admin/students/index' }, { label: '营销中心', path: '/pages/tenant-admin/marketing/index' }, { label: '租户设置', path: '/pages/tenant-admin/settings/index' }, ]; for (const item of moduleChecks) { await page.navigate(`${portal.baseUrl}${portal.landingPath}`); await waitForText(page, item.label); await clickText(page, item.label); await waitForPath(page, item.path); await waitForText(page, item.label); checks.push({ id: `tenant.workbench.to_${item.path.split('/').at(-2)}`, 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) }); const moduleChecks = [ { label: '公共题库', path: '/pages/platform-admin/question-banks/index', expected: '公共题库授权' }, { label: '平台员工', path: '/pages/platform-admin/staff/index', expected: '平台员工' }, ]; for (const item of moduleChecks) { await page.navigate(`${portal.baseUrl}${portal.landingPath}`); await waitForText(page, item.label); await clickText(page, item.label); await waitForPath(page, item.path); await waitForText(page, item.expected); checks.push({ id: `platform.workbench.to_${item.path.split('/').at(-2)}`, 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();