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