|
|
#!/usr/bin/env tsx
|
|
|
/**
|
|
|
* 可视全链验收:MotherShip widget 填址 → 货物 → 提交报价(单浏览器 headed)
|
|
|
*
|
|
|
* 用法:
|
|
|
* npm run visual:quote-full-chain
|
|
|
* $env:RPA_HEADLESS="false"; npm run visual:quote-full-chain
|
|
|
*/
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
import { join } from "node:path";
|
|
|
import { loadDevEnv } from "@/lib/dev-env";
|
|
|
import { shouldKeepBrowserOpen } from "@/lib/rpa/env";
|
|
|
import { hostFetchMothershipCandidates } from "@/lib/frontend/api-client";
|
|
|
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
|
|
|
import { RPAContext } from "@/workers/rpa/kernel/context";
|
|
|
import { runMothershipQuoteKernel } from "@/workers/rpa/kernel/orchestrator";
|
|
|
import {
|
|
|
stabilizeQuoteLandingPage,
|
|
|
waitForQuoterWidgetHydrated,
|
|
|
} from "@/workers/rpa/page-prep";
|
|
|
import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager";
|
|
|
import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter";
|
|
|
import type { QuoteRequest } from "@/modules/providers/quote-provider";
|
|
|
|
|
|
process.env.RPA_MOCK_MODE = "false";
|
|
|
process.env.RPA_ADDRESS_MODE = "real";
|
|
|
process.env.RPA_VISUAL_RUSH = process.env.RPA_VISUAL_RUSH ?? "true";
|
|
|
process.env.RPA_DUAL_ADDRESS_PATCH = process.env.RPA_DUAL_ADDRESS_PATCH ?? "false";
|
|
|
process.env.RPA_DWELL_MS = process.env.RPA_DWELL_MS ?? "3000";
|
|
|
process.env.RPA_HEADLESS = process.env.RPA_HEADLESS ?? "false";
|
|
|
process.env.RPA_SLOW_MO_MS = process.env.RPA_SLOW_MO_MS ?? "0";
|
|
|
process.env.RPA_HUMAN_PACING = process.env.RPA_HUMAN_PACING ?? "false";
|
|
|
process.env.RPA_KEEP_BROWSER_OPEN = process.env.RPA_KEEP_BROWSER_OPEN ?? "true";
|
|
|
|
|
|
loadDevEnv();
|
|
|
|
|
|
const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000";
|
|
|
const TOKEN = "demo-host-token";
|
|
|
const CUSTOMER_ID = "CUST_001";
|
|
|
|
|
|
const PAIR = {
|
|
|
label: "真人录证 5519e0ea:293 Pike St → 826 Ocean Dr",
|
|
|
pickup: { street: "293 Pike St", city: "Seattle", state: "WA" },
|
|
|
delivery: { street: "826 Ocean Dr", city: "Miami Beach", state: "FL" },
|
|
|
cargo: {
|
|
|
pallet_count: 2,
|
|
|
weight_lb: 500,
|
|
|
length_in: 48,
|
|
|
width_in: 40,
|
|
|
height_in: 48,
|
|
|
},
|
|
|
};
|
|
|
|
|
|
function draftAddress(d: { street: string; city: string; state: string }) {
|
|
|
return {
|
|
|
street: d.street.trim(),
|
|
|
city: d.city.trim(),
|
|
|
state: d.state.trim(),
|
|
|
zip: "",
|
|
|
place_id: `draft_${d.street}_${d.city}_${d.state}`.replace(/\s+/g, "_").slice(0, 64),
|
|
|
formatted_address: `${d.street}, ${d.city}, ${d.state}`,
|
|
|
selected_from_suggestions: true,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
|
let opened:
|
|
|
| Awaited<ReturnType<typeof openQuotePage>>
|
|
|
| null = null;
|
|
|
const runId = randomUUID().slice(0, 8);
|
|
|
const startedAt = Date.now();
|
|
|
|
|
|
console.log(`[visual] 全链验收 ${runId}:${PAIR.label}`);
|
|
|
console.log("[visual] 阶段:候选 API → 打开浏览器 → 填址 → 货物 → 报价");
|
|
|
console.log("[visual] RPA_VISUAL_RUSH=true(跳过锁定/契约闸门,填完即点报价)");
|
|
|
|
|
|
const userPickup = draftAddress(PAIR.pickup);
|
|
|
const userDelivery = draftAddress(PAIR.delivery);
|
|
|
|
|
|
const cand = await hostFetchMothershipCandidates(
|
|
|
BASE,
|
|
|
TOKEN,
|
|
|
CUSTOMER_ID,
|
|
|
userPickup,
|
|
|
userDelivery,
|
|
|
);
|
|
|
if (cand.code !== 0 || !cand.data?.pickup_candidates[0] || !cand.data.delivery_candidates[0]) {
|
|
|
throw new Error(cand.message ?? "候选 API 失败(请先 npm run dev:start)");
|
|
|
}
|
|
|
|
|
|
const pc = cand.data.pickup_candidates[0];
|
|
|
const dc = cand.data.delivery_candidates[0];
|
|
|
const pickupApplied = applyMothershipCandidate(userPickup, pc);
|
|
|
const deliveryApplied = applyMothershipCandidate(userDelivery, dc);
|
|
|
|
|
|
const req: QuoteRequest = {
|
|
|
cargoHash: `visual_${runId}`,
|
|
|
pickup: {
|
|
|
street: pickupApplied.street,
|
|
|
city: pickupApplied.city,
|
|
|
state: pickupApplied.state,
|
|
|
zip: pickupApplied.zip ?? "",
|
|
|
placeId: pc.option_id,
|
|
|
formattedAddress: pickupApplied.formatted_address,
|
|
|
selectedFromSuggestions: true,
|
|
|
mothershipOptionId: pc.option_id,
|
|
|
mothershipDisplayLabel: pc.display_label,
|
|
|
selectedFromMothership: true,
|
|
|
},
|
|
|
delivery: {
|
|
|
street: deliveryApplied.street,
|
|
|
city: deliveryApplied.city,
|
|
|
state: deliveryApplied.state,
|
|
|
zip: deliveryApplied.zip ?? "",
|
|
|
placeId: dc.option_id,
|
|
|
formattedAddress: deliveryApplied.formatted_address,
|
|
|
selectedFromSuggestions: true,
|
|
|
mothershipOptionId: dc.option_id,
|
|
|
mothershipDisplayLabel: dc.display_label,
|
|
|
selectedFromMothership: true,
|
|
|
},
|
|
|
palletCount: PAIR.cargo.pallet_count,
|
|
|
weightLb: PAIR.cargo.weight_lb,
|
|
|
dimsIn: {
|
|
|
l: PAIR.cargo.length_in,
|
|
|
w: PAIR.cargo.width_in,
|
|
|
h: PAIR.cargo.height_in,
|
|
|
},
|
|
|
cargoType: "general_freight",
|
|
|
};
|
|
|
|
|
|
console.log(`[visual] 提货:${pc.display_label}`);
|
|
|
console.log(`[visual] 派送:${dc.display_label}`);
|
|
|
|
|
|
opened = await openQuotePage(quotePageAdapter, false, undefined, {
|
|
|
freshCargoContext: true,
|
|
|
});
|
|
|
const ctx = RPAContext.fromSession(opened.page, opened.context);
|
|
|
|
|
|
console.log("[visual] 等待 widget 加载…");
|
|
|
await stabilizeQuoteLandingPage(ctx);
|
|
|
await waitForQuoterWidgetHydrated(ctx.page, 90_000);
|
|
|
|
|
|
console.log("[visual] ▶ 开始 kernel:填址(提货 → 派送)…");
|
|
|
const tAddr = Date.now();
|
|
|
const items = await runMothershipQuoteKernel(ctx, req);
|
|
|
const addrMs = Date.now() - tAddr;
|
|
|
|
|
|
const finalUrl = opened.page.url();
|
|
|
const report = {
|
|
|
runId,
|
|
|
pair: PAIR.label,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
durationMs: Date.now() - startedAt,
|
|
|
kernelMs: addrMs,
|
|
|
finalUrl,
|
|
|
tierCount: items.length,
|
|
|
tiers: items.map((q) => ({
|
|
|
tier: `${q.serviceLevel}/${q.rateOption}`,
|
|
|
final_total: q.rawTotal,
|
|
|
})),
|
|
|
verdict:
|
|
|
items.length >= 4
|
|
|
? "PASS"
|
|
|
: items.length > 0
|
|
|
? "PARTIAL"
|
|
|
: finalUrl.includes("sign-up-quote")
|
|
|
? "PASS_PAGE"
|
|
|
: "FAIL",
|
|
|
};
|
|
|
|
|
|
const outDir = join(process.cwd(), ".rpa", "visual-full-chain");
|
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
const reportPath = join(outDir, `visual-${runId}.json`);
|
|
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
|
|
|
|
console.log("\n════════ 可视全链结果 ════════");
|
|
|
console.log(`档位数量: ${items.length}`);
|
|
|
for (const q of items.slice(0, 6)) {
|
|
|
console.log(` ${q.serviceLevel}/${q.rateOption}: $${q.rawTotal}`);
|
|
|
}
|
|
|
console.log(`verdict: ${report.verdict}`);
|
|
|
console.log(`报告: ${reportPath}`);
|
|
|
|
|
|
const success =
|
|
|
report.verdict === "PASS" ||
|
|
|
report.verdict === "PARTIAL" ||
|
|
|
report.verdict === "PASS_PAGE";
|
|
|
|
|
|
if (!shouldKeepBrowserOpen()) {
|
|
|
const dwellMs = Number(process.env.RPA_DWELL_MS ?? 8_000);
|
|
|
if (dwellMs > 0) {
|
|
|
console.log(`[visual] 完成后停留 ${dwellMs}ms 便于核对页面…`);
|
|
|
await opened.page.waitForTimeout(dwellMs).catch(() => undefined);
|
|
|
}
|
|
|
await opened.page.close().catch(() => undefined);
|
|
|
await opened.context.close().catch(() => undefined);
|
|
|
await closeBrowser();
|
|
|
process.exit(success ? 0 : 1);
|
|
|
}
|
|
|
|
|
|
console.log("\n[visual] 浏览器保持打开,请核对页面报价。按 Ctrl+C 结束。");
|
|
|
await new Promise<void>((resolve) => {
|
|
|
process.once("SIGINT", () => resolve());
|
|
|
process.once("SIGTERM", () => resolve());
|
|
|
});
|
|
|
await opened.page.close().catch(() => undefined);
|
|
|
await opened.context.close().catch(() => undefined);
|
|
|
await closeBrowser();
|
|
|
process.exit(success ? 0 : 1);
|
|
|
}
|
|
|
|
|
|
main().catch(async (e) => {
|
|
|
console.error(e);
|
|
|
const dwellMs = Number(process.env.RPA_DWELL_MS ?? 15_000);
|
|
|
if (process.env.RPA_HEADLESS === "false" && dwellMs > 0) {
|
|
|
console.log(`[visual] 失败后停留 ${dwellMs}ms,便于观察卡点…`);
|
|
|
await new Promise((resolve) => setTimeout(resolve, dwellMs));
|
|
|
}
|
|
|
process.exit(1);
|
|
|
});
|