forked from wangziqi/gongxue-base
test: add taro h5 static launch smoke
This commit is contained in:
396
scripts/taro-h5-static-smoke.js
Normal file
396
scripts/taro-h5-static-smoke.js
Normal file
@@ -0,0 +1,396 @@
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
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 api = await createMockApiServer();
|
||||
try {
|
||||
const results = [];
|
||||
for (const portal of portals) {
|
||||
results.push(await smokePortal(portal, api));
|
||||
}
|
||||
const summary = summarize(results);
|
||||
console.log(`Taro H5 static smoke: ${summary.fail} fail(s), ${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}`);
|
||||
}
|
||||
}
|
||||
const resolveRequests = api.requests.filter(item => item.path === '/api/tenant/resolve');
|
||||
console.log(`[MOCK API] tenant resolve requests=${resolveRequests.length}`);
|
||||
if (resolveRequests.length !== portals.length) {
|
||||
console.log(`[FAIL] mock_api.tenant_resolve_count: expected ${portals.length}, got ${resolveRequests.length}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (summary.fail > 0) process.exitCode = 1;
|
||||
} finally {
|
||||
await api.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user