Files
gongxue-base/scripts/taro-h5-interaction-smoke.js
2026-07-01 02:37:43 +08:00

925 lines
36 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',
};
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();