You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

246 lines
7.3 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/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 含 storageStatecookies + 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();