Files
gongxue-base/scripts/taro-h5-interaction-smoke.js

1259 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 clickText(page, '提交订单');
await waitForApiRequest(api, '/api/commerce/orders', 'POST');
await waitForApiRequest(api, '/api/commerce/payments/create', 'POST');
await waitForText(page, '支付参数已生成');
await waitForText(page, 'SMOKE202607010001');
await clickText(page, '刷新状态');
await waitForApiRequest(api, '/api/commerce/orders/status', 'GET');
checks.push({ id: 'student.checkout.order_payment', status: 'pass', detail: '收银台下单、支付参数生成和状态刷新 API 已触发' });
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,
ordersCreated: api.requests.filter(item => item.path === '/api/commerce/orders' && item.method === 'POST').length,
paymentsCreated: api.requests.filter(item => item.path === '/api/commerce/payments/create' && item.method === 'POST').length,
orderStatus: api.requests.filter(item => item.path === '/api/commerce/orders/status' && item.method === 'GET').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();