Files
gongxue-base/scripts/taro-h5-static-smoke.js
2026-07-01 01:15:29 +08:00

430 lines
14 KiB
JavaScript

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(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)) {
urls.add(match[1]);
}
for (const match of indexHtml.matchAll(/<link\b[^>]*\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 && /<div id="app">/.test(index.text) && /<script\b/i.test(index.text),
detail: `status=${index.status}`,
});
const assetUrls = parseAssetUrls(index.text);
checks.push({
id: `${portal.portal}.assets.discovered`,
ok: assetUrls.length >= 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 && /<div id="app">/.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;
});