119 行
3.6 KiB
JavaScript
119 行
3.6 KiB
JavaScript
#!/usr/bin/env node
|
|
import process from "node:process";
|
|
|
|
const baseUrl = normalizeBaseUrl(process.argv[2] ?? process.env.PUBLIC_BASE_URL ?? process.env.LIVE_SMOKE_BASE_URL ?? "https://oa.hyinda.com");
|
|
const focusedAssetPattern = /(MobileReimbursementDetailPage|ReimbursementDetailPage|page-mobile|page-assets|page-travel)/;
|
|
const forbiddenTerms = ["绑定整单", "不绑定明细", "识别完成后可在", "发票池", "OCR"];
|
|
const expectedBuildSha = normalizeBuildSha(process.env.PUBLIC_EXPECTED_BUILD_SHA);
|
|
const forbiddenFullBuildSha = normalizeBuildSha(process.env.PUBLIC_FORBIDDEN_BUILD_SHA, { keepFull: true });
|
|
|
|
function normalizeBaseUrl(value) {
|
|
return String(value).replace(/\/+$/, "");
|
|
}
|
|
|
|
function normalizeBuildSha(value, { keepFull = false } = {}) {
|
|
const normalized = String(value ?? "").trim().toLowerCase();
|
|
if (!/^[0-9a-f]{7,64}$/.test(normalized)) {
|
|
return null;
|
|
}
|
|
return keepFull ? normalized : normalized.slice(0, 12);
|
|
}
|
|
|
|
function toAssetPath(value) {
|
|
if (value.startsWith("/")) {
|
|
return value;
|
|
}
|
|
return `/assets/${value.replace(/^\.\//, "")}`;
|
|
}
|
|
|
|
async function fetchText(path) {
|
|
const response = await fetch(`${baseUrl}${path}`);
|
|
if (!response.ok) {
|
|
throw new Error(`GET ${path} failed: ${response.status}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
async function collectAssets() {
|
|
const seen = new Set();
|
|
const queue = [];
|
|
const sources = new Map();
|
|
const html = await fetchText("/");
|
|
|
|
for (const match of html.matchAll(/(?:src|href)=["'](\/assets\/[^"']+\.js)["']/g)) {
|
|
const assetPath = toAssetPath(match[1]);
|
|
if (!seen.has(assetPath)) {
|
|
queue.push(assetPath);
|
|
seen.add(assetPath);
|
|
}
|
|
}
|
|
|
|
for (let index = 0; index < queue.length; index += 1) {
|
|
const assetPath = queue[index];
|
|
const source = await fetchText(assetPath);
|
|
sources.set(assetPath, source);
|
|
for (const match of source.matchAll(/\.\/([A-Za-z0-9_./-]+\.js)/g)) {
|
|
const nextPath = toAssetPath(match[1]);
|
|
if (!seen.has(nextPath)) {
|
|
queue.push(nextPath);
|
|
seen.add(nextPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
assets: Array.from(seen).sort(),
|
|
sources,
|
|
};
|
|
}
|
|
|
|
const { assets, sources } = await collectAssets();
|
|
const findings = [];
|
|
let expectedBuildShaFound = expectedBuildSha === null;
|
|
let forbiddenFullBuildShaFound = false;
|
|
|
|
for (const assetPath of assets) {
|
|
const source = sources.get(assetPath) ?? "";
|
|
if (expectedBuildSha && source.toLowerCase().includes(expectedBuildSha)) {
|
|
expectedBuildShaFound = true;
|
|
}
|
|
if (
|
|
forbiddenFullBuildSha
|
|
&& forbiddenFullBuildSha !== expectedBuildSha
|
|
&& source.toLowerCase().includes(forbiddenFullBuildSha)
|
|
) {
|
|
forbiddenFullBuildShaFound = true;
|
|
}
|
|
|
|
if (!focusedAssetPattern.test(assetPath)) {
|
|
continue;
|
|
}
|
|
|
|
for (const term of forbiddenTerms) {
|
|
if (source.includes(term)) {
|
|
findings.push({ assetPath, category: "forbidden_copy", term });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!expectedBuildShaFound) {
|
|
findings.push({ assetPath: "public-bundle", category: "build_sha_missing" });
|
|
}
|
|
if (forbiddenFullBuildShaFound) {
|
|
findings.push({ assetPath: "public-bundle", category: "full_build_sha_exposed" });
|
|
}
|
|
|
|
const buildMarkerStatus = expectedBuildSha === null
|
|
? "NOT_CHECKED"
|
|
: expectedBuildShaFound && !forbiddenFullBuildShaFound
|
|
? "PASS"
|
|
: "FAIL";
|
|
console.log(`[public-bundle-copy] base=${baseUrl} scanned=${assets.length} focused=${assets.filter((item) => focusedAssetPattern.test(item)).length} buildMarker=${buildMarkerStatus} findings=${findings.length}`);
|
|
for (const finding of findings) {
|
|
console.log(`ERROR ${finding.assetPath} category=${finding.category}`);
|
|
}
|
|
|
|
if (findings.length) {
|
|
process.exitCode = 1;
|
|
}
|