import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import process from 'node:process'; const repoRoot = process.cwd(); const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist'); 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'), }; } const allowedRuntimeConfigKeys = new Set([ 'portal', 'apiBaseUrl', 'supabaseUrl', 'supabasePublishableKey', 'tenantCode', ]); const forbiddenRuntimeConfigKeys = [ 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_SECRET_KEY', 'DATABASE_URL', 'ALIYUN_OSS_ACCESS_KEY_SECRET', 'TENCENT_COS_SECRET_KEY', 'WECHAT_PAY_PRIVATE_KEY', 'ALIPAY_APP_PRIVATE_KEY', 'AUTH_SESSION_SECRET', 'PLATFORM_ADMIN_API_KEY', ]; const forbiddenValuePatterns = [ /postgres(?:ql)?:\/\//i, /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, /\bAKIA[0-9A-Z]{16}\b/, /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/, ]; 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 readText(filePath) { return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); } function normalizeSlashes(value) { return value.replace(/\\/g, '/'); } function relative(filePath) { return normalizeSlashes(path.relative(repoRoot, filePath)); } function jsonResponse(response, statusCode, payload) { response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', }); response.end(`${JSON.stringify(payload)}\n`); } function textResponse(response, statusCode, body, headers = {}) { response.writeHead(statusCode, headers); response.end(body); } async function request(input, options = {}) { const response = await fetch(input, { method: options.method || 'GET', headers: options.headers || {}, }); const text = await response.text(); return { ok: response.ok, status: response.status, headers: response.headers, text, }; } async function createMockApiServer() { const requests = []; const server = http.createServer((req, res) => { const url = new URL(req.url || '/', 'http://127.0.0.1'); requests.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()), host: req.headers.host || '', }); if (url.pathname === '/health') { jsonResponse(res, 200, { ok: true }); return; } if (url.pathname === '/api/tenant/resolve') { jsonResponse(res, 200, { item: { tenantId: '00000000-0000-4000-8000-000000000001', tenantSlug: url.searchParams.get('tenantCode') || 'master', branding: { name: '工学题库', theme: { primaryColor: '#2563eb', accentColor: '#16a34a', }, }, features: { enableLeaderboard: false, }, adminFeatures: { tenantAdmin: true, platformAdmin: true, }, publicConfig: { smoke: true, }, }, }); return; } jsonResponse(res, 404, { code: 'NOT_FOUND', message: 'mock endpoint not found' }); }); 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_static_smoke', tenantCode: 'master', }; 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; } const headers = { 'content-type': contentTypeFor(finalPath), 'cache-control': finalPath.endsWith('index.html') ? 'no-store' : 'public, max-age=31536000, immutable', }; res.writeHead(200, headers); fs.createReadStream(finalPath).pipe(res); }); const port = await listen(server); return { baseUrl: `http://127.0.0.1:${port}`, runtimeConfig, 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 listen(server) { return new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); resolve(address.port); }); }); } function closeServer(server) { return new Promise(resolve => { server.close(() => resolve()); }); } function parseAssetUrls(indexHtml) { const urls = new Set(); for (const match of indexHtml.matchAll(/]*\bsrc=["']([^"']+)["'][^>]*>/gi)) { urls.add(match[1]); } for (const match of indexHtml.matchAll(/]*\bhref=["']([^"']+)["'][^>]*>/gi)) { const tag = match[0]; if (/rel=["']stylesheet["']/i.test(tag)) urls.add(match[1]); } return [...urls]; } function validateRuntimeConfig(config, portal) { const errors = []; const keys = Object.keys(config); const unknownKeys = keys.filter(key => !allowedRuntimeConfigKeys.has(key)); const forbiddenKeys = keys.filter(key => forbiddenRuntimeConfigKeys.includes(key)); if (unknownKeys.length) errors.push(`unknown runtime config keys: ${unknownKeys.join(', ')}`); if (forbiddenKeys.length) errors.push(`forbidden runtime config keys: ${forbiddenKeys.join(', ')}`); if (config.portal !== portal.portal) errors.push(`portal mismatch: expected ${portal.portal}, got ${config.portal}`); if (!String(config.apiBaseUrl || '').startsWith('http://127.0.0.1:')) errors.push('apiBaseUrl must point to the local mock API in smoke'); if (!String(config.supabaseUrl || '').startsWith('https://')) errors.push('supabaseUrl must be HTTPS even in smoke runtime config'); if (!config.supabasePublishableKey) errors.push('supabasePublishableKey is required'); for (const [key, value] of Object.entries(config)) { if (forbiddenValuePatterns.some(pattern => pattern.test(String(value)))) errors.push(`secret-looking value in ${key}`); } return errors; } 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. Build H5 before running smoke:taro:h5.`); if (!fs.existsSync(indexPath)) throw new Error(`${relative(indexPath)} does not exist. Build output is not deployable.`); } async function smokePortal(portal, api) { assertDistExists(portal); const staticServer = await createStaticServer(portal, api.baseUrl); try { const checks = []; const index = await request(`${staticServer.baseUrl}/index.html`); checks.push({ id: `${portal.portal}.index`, ok: index.ok && /
/.test(index.text) && /= 2, detail: `${assetUrls.length} asset reference(s)`, }); for (const assetUrl of assetUrls) { const target = new URL(assetUrl, `${staticServer.baseUrl}/index.html`); const asset = await request(target); checks.push({ id: `${portal.portal}.asset.${assetUrl}`, ok: asset.ok && asset.text.length > 0, detail: `status=${asset.status} bytes=${asset.text.length}`, }); } const runtime = await request(`${staticServer.baseUrl}/runtime-config.json`); let runtimeConfig = null; try { runtimeConfig = JSON.parse(runtime.text); } catch { runtimeConfig = null; } const runtimeErrors = runtimeConfig ? validateRuntimeConfig(runtimeConfig, portal) : ['runtime-config.json is not valid JSON']; checks.push({ id: `${portal.portal}.runtime_config`, ok: runtime.ok && runtimeErrors.length === 0, detail: runtimeErrors.length ? runtimeErrors.join('; ') : 'public runtime config accepted', }); const fallback = await request(`${staticServer.baseUrl}${portal.landingPath}`); checks.push({ id: `${portal.portal}.history_fallback`, ok: fallback.ok && /
/.test(fallback.text), detail: `status=${fallback.status} path=${portal.landingPath}`, }); if (runtimeConfig) { const resolveUrl = new URL('/api/tenant/resolve', runtimeConfig.apiBaseUrl); resolveUrl.searchParams.set('tenantCode', runtimeConfig.tenantCode); resolveUrl.searchParams.set('host', new URL(staticServer.baseUrl).host); const tenantResolve = await request(resolveUrl, { headers: { 'x-smoke-portal': portal.portal } }); let tenantPayload = null; try { tenantPayload = JSON.parse(tenantResolve.text); } catch { tenantPayload = null; } checks.push({ id: `${portal.portal}.tenant_resolve_contract`, ok: tenantResolve.ok && Boolean(tenantPayload?.item?.tenantId) && tenantPayload.item.features?.enableLeaderboard === false, detail: `status=${tenantResolve.status}`, }); } else { checks.push({ id: `${portal.portal}.tenant_resolve_contract`, ok: false, detail: 'runtime config unavailable', }); } return { portal: portal.portal, staticBaseUrl: staticServer.baseUrl, landingPath: portal.landingPath, checks, }; } finally { await staticServer.close(); } } function summarize(results) { const checks = results.flatMap(result => result.checks); return { pass: checks.filter(item => item.ok).length, fail: checks.filter(item => !item.ok).length, checks, }; } async function main() { const options = parseArgs(process.argv.slice(2)); const api = await createMockApiServer(); try { const results = []; for (const portal of portals) { results.push(await smokePortal(portal, api)); } const summary = summarize(results); const resolveRequests = api.requests.filter(item => item.path === '/api/tenant/resolve'); const payload = { summary: { fail: summary.fail + (resolveRequests.length === portals.length ? 0 : 1), pass: summary.pass + (resolveRequests.length === portals.length ? 1 : 0), portals: portals.length, tenantResolveRequests: resolveRequests.length, }, portals: results, checks: [ ...summary.checks.map(item => ({ status: item.ok ? 'pass' : 'fail', id: item.id, detail: item.detail, })), { status: resolveRequests.length === portals.length ? 'pass' : 'fail', id: 'mock_api.tenant_resolve_count', detail: `expected=${portals.length} actual=${resolveRequests.length}`, }, ], }; if (options.json) { console.log(JSON.stringify(payload, null, 2)); } else { console.log(`Taro H5 static smoke: ${payload.summary.fail} fail(s), ${payload.summary.pass} pass(es)`); for (const result of results) { console.log(`[PORTAL] ${result.portal} ${result.staticBaseUrl} -> ${result.landingPath}`); for (const check of result.checks) { console.log(`[${check.ok ? 'PASS' : 'FAIL'}] ${check.id}: ${check.detail}`); } } console.log(`[MOCK API] tenant resolve requests=${resolveRequests.length}`); } if (resolveRequests.length !== portals.length) { if (!options.json) console.log(`[FAIL] mock_api.tenant_resolve_count: expected ${portals.length}, got ${resolveRequests.length}`); process.exitCode = 1; return; } if (payload.summary.fail > 0) process.exitCode = 1; } finally { await api.close(); } } main().catch(error => { console.error(error); process.exitCode = 1; });