forked from wangziqi/gongxue-base
1259 lines
52 KiB
JavaScript
1259 lines
52 KiB
JavaScript
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();
|