|
|
#!/usr/bin/env tsx
|
|
|
/**
|
|
|
* 阶段0 录证产物机器验收
|
|
|
* 用法: npm run verify:phase0
|
|
|
*/
|
|
|
import fs from "node:fs";
|
|
|
import { countAxelQuoteTiers } from "./lib/axel-quote-contract";
|
|
|
import {
|
|
|
listHarEntries,
|
|
|
pickBestQuoteEntry,
|
|
|
readHarFile,
|
|
|
scoreQuoteBody,
|
|
|
} from "./lib/har-parse";
|
|
|
import {
|
|
|
BASELINE_HAR,
|
|
|
QUOTE_CONTRACT_SCHEMA,
|
|
|
SESSION_STORAGE_SNAPSHOT,
|
|
|
} from "./lib/paths";
|
|
|
|
|
|
const QUOTE_CONTRACT_SUMMARY = `${process.cwd()}/.rpa/phase0/quote-contract-summary.json`;
|
|
|
|
|
|
type AssertionResult = {
|
|
|
id: string;
|
|
|
description: string;
|
|
|
pass: boolean;
|
|
|
detail: string;
|
|
|
};
|
|
|
|
|
|
function collectPlaceIds(node: unknown, out: string[]): void {
|
|
|
if (node == null) {
|
|
|
return;
|
|
|
}
|
|
|
if (Array.isArray(node)) {
|
|
|
for (const item of node) {
|
|
|
collectPlaceIds(item, out);
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
if (typeof node !== "object") {
|
|
|
return;
|
|
|
}
|
|
|
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
|
if (/place[_-]?id/i.test(key) && typeof value === "string" && value.trim()) {
|
|
|
out.push(value.trim());
|
|
|
}
|
|
|
collectPlaceIds(value, out);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function countRateLeaves(node: unknown): {
|
|
|
pricedLeafCount: number;
|
|
|
fourTierOk: boolean;
|
|
|
availableRateCount: number;
|
|
|
} {
|
|
|
return countAxelQuoteTiers(node);
|
|
|
}
|
|
|
|
|
|
function runAssertions(): AssertionResult[] {
|
|
|
const results: AssertionResult[] = [];
|
|
|
|
|
|
// A1: baseline.har 存在且含网络条目
|
|
|
const harExists = fs.existsSync(BASELINE_HAR);
|
|
|
let harEntryCount = 0;
|
|
|
if (harExists) {
|
|
|
harEntryCount = listHarEntries(readHarFile(BASELINE_HAR)).length;
|
|
|
}
|
|
|
results.push({
|
|
|
id: "A1",
|
|
|
description: "baseline.har 存在且 entries.length ≥ 10",
|
|
|
pass: harExists && harEntryCount >= 10,
|
|
|
detail: harExists
|
|
|
? `entries=${harEntryCount}`
|
|
|
: `文件不存在: ${BASELINE_HAR}`,
|
|
|
});
|
|
|
|
|
|
// A2: HAR 中含可识别的 quote JSON 响应
|
|
|
let quotePicked: ReturnType<typeof pickBestQuoteEntry> = null;
|
|
|
if (harExists) {
|
|
|
quotePicked = pickBestQuoteEntry(listHarEntries(readHarFile(BASELINE_HAR)));
|
|
|
}
|
|
|
results.push({
|
|
|
id: "A2",
|
|
|
description: "HAR 中存在 score>0 的 quote JSON 响应",
|
|
|
pass: quotePicked != null,
|
|
|
detail: quotePicked
|
|
|
? `${quotePicked.entry.request.method} ${quotePicked.entry.request.url}`
|
|
|
: "未匹配 axel/quote 或 rates 结构",
|
|
|
});
|
|
|
|
|
|
// A3: axel/quote 四档齐全(data.rates)或 availableRates ≥ 4
|
|
|
const tierStats = quotePicked
|
|
|
? countRateLeaves(quotePicked.body)
|
|
|
: { pricedLeafCount: 0, fourTierOk: false, availableRateCount: 0 };
|
|
|
const a3Pass = tierStats.fourTierOk || tierStats.pricedLeafCount >= 4;
|
|
|
results.push({
|
|
|
id: "A3",
|
|
|
description:
|
|
|
"data.rates 四档齐全(std/guar × lowest/fastest)或 availableRates ≥ 4",
|
|
|
pass: a3Pass,
|
|
|
detail: `fourTier=${tierStats.fourTierOk} data.rates leaves=${tierStats.pricedLeafCount} availableRates=${tierStats.availableRateCount}`,
|
|
|
});
|
|
|
|
|
|
if (quotePicked) {
|
|
|
fs.writeFileSync(
|
|
|
QUOTE_CONTRACT_SUMMARY,
|
|
|
`${JSON.stringify(
|
|
|
{
|
|
|
verifiedAt: new Date().toISOString(),
|
|
|
sourceUrl: quotePicked.entry.request.url,
|
|
|
...tierStats,
|
|
|
endpoint: "POST https://services.mothership.com/axel/quote",
|
|
|
mappingNotes: {
|
|
|
standardLowest: "data.rates.standard.lowest",
|
|
|
standardFastest: "data.rates.standard.fastest",
|
|
|
guaranteedLowest: "data.rates.guaranteed.lowest",
|
|
|
guaranteedFastest: "data.rates.guaranteed.fastest",
|
|
|
allRates: "data.availableRates",
|
|
|
},
|
|
|
},
|
|
|
null,
|
|
|
2,
|
|
|
)}\n`,
|
|
|
"utf8",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// A4: place_id 非空且匹配正则
|
|
|
const PLACE_ID_RE = /^[A-Za-z0-9_-]{8,128}$/;
|
|
|
const placeIds: string[] = [];
|
|
|
if (quotePicked) {
|
|
|
collectPlaceIds(quotePicked.body, placeIds);
|
|
|
}
|
|
|
const snapshotRaw = fs.existsSync(SESSION_STORAGE_SNAPSHOT)
|
|
|
? fs.readFileSync(SESSION_STORAGE_SNAPSHOT, "utf8")
|
|
|
: "";
|
|
|
if (snapshotRaw) {
|
|
|
collectPlaceIds(JSON.parse(snapshotRaw) as unknown, placeIds);
|
|
|
}
|
|
|
const validPlaceIds = [...new Set(placeIds)].filter((id) =>
|
|
|
PLACE_ID_RE.test(id),
|
|
|
);
|
|
|
results.push({
|
|
|
id: "A4",
|
|
|
description: "place_id 字段非空且匹配 ^[A-Za-z0-9_-]{8,128}$",
|
|
|
pass: validPlaceIds.length >= 1,
|
|
|
detail:
|
|
|
validPlaceIds.length > 0
|
|
|
? `matched=${validPlaceIds.slice(0, 3).join(", ")}`
|
|
|
: `found=${placeIds.length} raw, none matched regex`,
|
|
|
});
|
|
|
|
|
|
// A5: session-storage-snapshot 含 cookies + origins
|
|
|
let snapshotOk = false;
|
|
|
let snapshotDetail = "文件不存在";
|
|
|
if (fs.existsSync(SESSION_STORAGE_SNAPSHOT)) {
|
|
|
const snap = JSON.parse(
|
|
|
fs.readFileSync(SESSION_STORAGE_SNAPSHOT, "utf8"),
|
|
|
) as {
|
|
|
storageState?: { cookies?: unknown[]; origins?: unknown[] };
|
|
|
checkpoint?: string;
|
|
|
};
|
|
|
const cookies = snap.storageState?.cookies?.length ?? 0;
|
|
|
const origins = snap.storageState?.origins?.length ?? 0;
|
|
|
const pageUrl =
|
|
|
typeof (snap as { pageUrl?: string }).pageUrl === "string"
|
|
|
? (snap as { pageUrl: string }).pageUrl
|
|
|
: "";
|
|
|
snapshotOk =
|
|
|
snap.checkpoint === "address_locked" &&
|
|
|
snap.storageState != null &&
|
|
|
pageUrl.includes("mothership");
|
|
|
snapshotDetail = `checkpoint=${snap.checkpoint ?? "?"} cookies=${cookies} origins=${origins} url=${pageUrl || "?"}`;
|
|
|
}
|
|
|
results.push({
|
|
|
id: "A5",
|
|
|
description:
|
|
|
"session-storage-snapshot.json 含 storageState(cookies + origins)且 checkpoint=address_locked",
|
|
|
pass: snapshotOk,
|
|
|
detail: snapshotDetail,
|
|
|
});
|
|
|
|
|
|
// A6: quote-contract.schema.json 为有效 JSON Schema 且非 incomplete 占位
|
|
|
let schemaOk = false;
|
|
|
let schemaDetail = "文件不存在";
|
|
|
if (fs.existsSync(QUOTE_CONTRACT_SCHEMA)) {
|
|
|
const schema = JSON.parse(
|
|
|
fs.readFileSync(QUOTE_CONTRACT_SCHEMA, "utf8"),
|
|
|
) as {
|
|
|
$schema?: string;
|
|
|
body?: unknown;
|
|
|
incomplete?: boolean;
|
|
|
sourceHar?: { url?: string };
|
|
|
};
|
|
|
schemaOk =
|
|
|
Boolean(schema.$schema) &&
|
|
|
schema.body != null &&
|
|
|
schema.incomplete !== true &&
|
|
|
Boolean(schema.sourceHar?.url);
|
|
|
schemaDetail = schemaOk
|
|
|
? `source=${schema.sourceHar?.url}`
|
|
|
: schema.incomplete
|
|
|
? "incomplete 占位,需重新录证"
|
|
|
: "缺少 $schema/body/sourceHar";
|
|
|
}
|
|
|
results.push({
|
|
|
id: "A6",
|
|
|
description:
|
|
|
"quote-contract.schema.json 有效且 sourceHar 指向真实 quote endpoint",
|
|
|
pass: schemaOk,
|
|
|
detail: schemaDetail,
|
|
|
});
|
|
|
|
|
|
// A7: quote body score ≥ 5(含 rates+standard+guaranteed)
|
|
|
const quoteScore = quotePicked ? scoreQuoteBody(quotePicked.body) : 0;
|
|
|
results.push({
|
|
|
id: "A7",
|
|
|
description: "quote 响应结构 score ≥ 5(含 rates/standard/guaranteed 信号)",
|
|
|
pass: quoteScore >= 5,
|
|
|
detail: `score=${quoteScore}`,
|
|
|
});
|
|
|
|
|
|
return results;
|
|
|
}
|
|
|
|
|
|
function main(): void {
|
|
|
const results = runAssertions();
|
|
|
const failed = results.filter((r) => !r.pass);
|
|
|
|
|
|
console.log("\n=== 阶段0 录证验收 ===\n");
|
|
|
for (const r of results) {
|
|
|
const mark = r.pass ? "PASS" : "FAIL";
|
|
|
console.log(`[${mark}] ${r.id}: ${r.description}`);
|
|
|
console.log(` ${r.detail}\n`);
|
|
|
}
|
|
|
|
|
|
if (failed.length > 0) {
|
|
|
console.error(`验收未通过: ${failed.length}/${results.length} 项失败`);
|
|
|
console.error("请重新执行 npm run record:phase0 并完成完整人工流程");
|
|
|
process.exit(1);
|
|
|
}
|
|
|
|
|
|
console.log(`验收通过: ${results.length}/${results.length}`);
|
|
|
}
|
|
|
|
|
|
main();
|