forked from wangziqi/gongxue-base
430 lines
14 KiB
JavaScript
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;
|
|
});
|