#!/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 { let opened: | Awaited> | 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((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); });