/** * Flock 登录态 Quick 表单矩阵探针:只填表不提交,截图并对比选项是否在官网选中 * * RPA_HEADLESS=false npm run probe:flock-form-matrix * npm run probe:flock-form-matrix -- --max=5 * npm run probe:flock-form-matrix -- --only-failed */ import fs from "node:fs"; import path from "node:path"; import type { Browser, Page } from "playwright"; import { FLOCK_LOCATION_ACCESSORIALS, FLOCK_LOCATION_TYPES, } from "@/components/flock/flock-logged-in-quote-form"; import { defaultFlockPickupDateIso } from "@/lib/flock/pickup-date"; import { launchRpaBrowser } from "@/lib/rpa/browser-launch"; import { createRpaBrowserContext } from "@/lib/rpa/browser-context"; import { ensureFlockLoggedIn } from "@/workers/rpa/flock/login"; import { fillFlockLoggedInQuoteForm, openFlockLoggedInQuoteEntry, } from "@/workers/rpa/flock/logged-in-form"; import { verifyLoggedInFormOnPage, type LoggedInFormVerifyExpect, type LoggedInFormVerifyIssue, } from "@/workers/rpa/flock/logged-in-form-verify"; import type { LoggedInFormInput } from "@/workers/rpa/flock/logged-in-form"; const OUT_DIR = path.join(process.cwd(), ".rpa", "flock-form-matrix"); const BASE_PICKUP_ZIP = "30326"; const BASE_DELIVERY_ZIP = "15222"; type MatrixCase = { id: string; label: string; pickupTypeId: string; deliveryTypeId: string; }; type CaseResult = { id: string; label: string; fillOk: boolean; fillError?: string; verifyOk: boolean; issues: LoggedInFormVerifyIssue[]; screenshot: string; durationMs: number; }; function isoToDisplay(iso: string): string { const [y, m, d] = iso.split("-"); return y && m && d ? `${m}/${d}/${y}` : iso; } function accessorialBools( typeId: string, side: "pickup" | "delivery", ): Pick< LoggedInFormInput, | "pickupLiftgate" | "pickupInside" | "pickupPalletJack" | "deliveryLiftgate" | "deliveryInside" | "deliveryPalletJack" > { const flags = FLOCK_LOCATION_ACCESSORIALS[typeId as keyof typeof FLOCK_LOCATION_ACCESSORIALS]; if (!flags) return {}; const on = { liftgate: flags.liftgate, inside: flags.inside, palletJack: flags.palletJack, }; if (side === "pickup") { return { pickupLiftgate: on.liftgate, pickupInside: on.inside, pickupPalletJack: on.palletJack, }; } return { deliveryLiftgate: on.liftgate, deliveryInside: on.inside, deliveryPalletJack: on.palletJack, }; } function buildMatrixCases(): MatrixCase[] { const ids = FLOCK_LOCATION_TYPES.map((t) => t.id); const baseline = "business_with_dock"; const cases: MatrixCase[] = []; for (const pickupId of ids) { cases.push({ id: `pickup_${pickupId}__delivery_${baseline}`, label: `提货=${pickupId} / 派送=${baseline}`, pickupTypeId: pickupId, deliveryTypeId: baseline, }); } for (const deliveryId of ids) { if (deliveryId === baseline) continue; cases.push({ id: `pickup_${baseline}__delivery_${deliveryId}`, label: `提货=${baseline} / 派送=${deliveryId}`, pickupTypeId: baseline, deliveryTypeId: deliveryId, }); } const crossPairs: Array<[string, string]> = [ ["residential", "limited_farm"], ["residential", "limited_construction"], ["business_without_dock", "residential"], ["limited_farm", "residential"], ["trade_show", "limited_school"], ["limited_worship", "limited_school"], ["limited_airport", "limited_port"], ["limited_military", "limited_other"], ]; for (const [p, d] of crossPairs) { cases.push({ id: `cross_${p}__${d}`, label: `交叉 ${p} → ${d}`, pickupTypeId: p, deliveryTypeId: d, }); } return cases; } function buildInput(caseDef: MatrixCase): LoggedInFormInput { const pickupDate = isoToDisplay(defaultFlockPickupDateIso()); return { pickupDate, pickupZip: BASE_PICKUP_ZIP, deliveryZip: BASE_DELIVERY_ZIP, palletCount: 2, totalWeightLb: 500, lengthIn: 48, widthIn: 40, heightIn: 48, pickupLocationType: caseDef.pickupTypeId, deliveryLocationType: caseDef.deliveryTypeId, packagingType: "pallets_48x40", freightClass: "density", description: "matrix probe", stackable: true, turnable: false, additionalServices: ["unloading"], vehicleTypes: ["box_truck", "dry_van", "refrigerated"], ...accessorialBools(caseDef.pickupTypeId, "pickup"), ...accessorialBools(caseDef.deliveryTypeId, "delivery"), }; } function buildVerifyExpect(caseDef: MatrixCase): LoggedInFormVerifyExpect { const input = buildInput(caseDef); return { pickupTypeId: caseDef.pickupTypeId, deliveryTypeId: caseDef.deliveryTypeId, pickupZip: BASE_PICKUP_ZIP, deliveryZip: BASE_DELIVERY_ZIP, pickupLiftgate: input.pickupLiftgate, pickupInside: input.pickupInside, pickupPalletJack: input.pickupPalletJack, deliveryLiftgate: input.deliveryLiftgate, deliveryInside: input.deliveryInside, deliveryPalletJack: input.deliveryPalletJack, }; } function parseArgs(): { max: number | null; headed: boolean; onlyFailed: boolean; onlyIds: string[] | null; pauseMs: number; slowMo: number; } { const argv = process.argv.slice(2); let max: number | null = null; let headed = process.env.RPA_HEADLESS === "false"; let onlyFailed = false; let onlyIds: string[] | null = null; let pauseMs = 0; let slowMo = 0; for (let i = 0; i < argv.length; i++) { const a = argv[i] ?? ""; if (a === "--headed") headed = true; if (a === "--only-failed" || a === "--failed") { onlyFailed = true; } if (a.startsWith("--only=")) { onlyIds = a .slice("--only=".length) .split(",") .map((s) => s.trim()) .filter(Boolean); } if (a === "--only" && argv[i + 1]) { onlyIds = String(argv[++i]) .split(",") .map((s) => s.trim()) .filter(Boolean); } if (a.startsWith("--max=")) { const n = Number(a.split("=")[1]); if (Number.isFinite(n) && n > 0) max = Math.floor(n); } if (a === "--max" && argv[i + 1]) { const n = Number(argv[i + 1]); if (Number.isFinite(n) && n > 0) max = Math.floor(n); } if (a.startsWith("--pause-ms=")) { const n = Number(a.split("=")[1]); if (Number.isFinite(n) && n >= 0) pauseMs = Math.floor(n); } if (a.startsWith("--slowmo=")) { const n = Number(a.split("=")[1]); if (Number.isFinite(n) && n >= 0) slowMo = Math.floor(n); } } if (headed && pauseMs === 0) pauseMs = 2500; if (headed && slowMo === 0) slowMo = 60; return { max, headed, onlyFailed, onlyIds, pauseMs, slowMo }; } function loadFailedCaseIds(): Set { const reportPath = path.join(OUT_DIR, "report.json"); if (!fs.existsSync(reportPath)) { throw new Error(`无历史报告 ${reportPath},无法 --only-failed`); } const raw = JSON.parse(fs.readFileSync(reportPath, "utf8")) as { results?: CaseResult[]; }; const ids = new Set( (raw.results ?? []) .filter((r) => !r.fillOk || !r.verifyOk) .map((r) => r.id), ); if (ids.size === 0) { throw new Error("历史报告中无失败用例"); } return ids; } async function openFreshQuickForm(page: Page): Promise { const home = process.env.FLOCK_LOGGED_IN_HOME_URL?.trim() || "https://app.flockfreight.com/home"; await page.goto(home, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(800); return openFlockLoggedInQuoteEntry(page); } async function runCase( page: Page, caseDef: MatrixCase, index: number, total: number, options?: { pauseMs?: number }, ): Promise { const started = Date.now(); const screenshot = path.join(OUT_DIR, `${caseDef.id}.png`); console.log(`\n[matrix] ${index}/${total} ${caseDef.id}`); console.log(`[matrix] ${caseDef.label}`); const opened = await openFreshQuickForm(page); if (!opened) { await page.screenshot({ path: screenshot, fullPage: true }).catch(() => undefined); return { id: caseDef.id, label: caseDef.label, fillOk: false, fillError: "无法打开 Quick 查价表单", verifyOk: false, issues: [ { field: "表单入口", expected: "Let's build a quote / Quick", actual: page.url(), analysis: "登录态未进入 Quick 表单,检查 storage 或 FLOCK_LOGIN_*", }, ], screenshot, durationMs: Date.now() - started, }; } const input = buildInput(caseDef); console.log( `[matrix] 期望附属 Pickup Lg/In/Pj=` + `${input.pickupLiftgate}/${input.pickupInside}/${input.pickupPalletJack}` + ` Delivery=${input.deliveryLiftgate}/${input.deliveryInside}/${input.deliveryPalletJack}`, ); const filled = await fillFlockLoggedInQuoteForm(page, input); await page .getByText(/Pickup.*Delivery|Shipment Pickup/i) .first() .scrollIntoViewIfNeeded() .catch(() => undefined); await page.waitForTimeout(400); await page.screenshot({ path: screenshot, fullPage: true }); if (!filled.ok) { if (options?.pauseMs) { console.log(`[matrix] 填表失败,暂停 ${options.pauseMs}ms 便于观察…`); await page.waitForTimeout(options.pauseMs); } return { id: caseDef.id, label: caseDef.label, fillOk: false, fillError: filled.error, verifyOk: false, issues: [ { field: "RPA 填表", expected: "fill ok", actual: filled.error ?? "unknown", analysis: "填表阶段即失败,未进入校验;对照 logged-in-form.ts 对应字段 selector", }, ], screenshot, durationMs: Date.now() - started, }; } const verified = await verifyLoggedInFormOnPage( page, buildVerifyExpect(caseDef), ); if (!verified.ok) { for (const issue of verified.issues) { console.warn( ` ✗ ${issue.field}: 期望=${issue.expected} 实际=${issue.actual}`, ); console.warn(` → ${issue.analysis}`); } } else { console.log(" ✓ 官网选项与系统一致"); } if (options?.pauseMs) { console.log(`[matrix] 暂停 ${options.pauseMs}ms 观察页面…`); await page.waitForTimeout(options.pauseMs); } return { id: caseDef.id, label: caseDef.label, fillOk: true, verifyOk: verified.ok, issues: verified.issues, screenshot, durationMs: Date.now() - started, }; } function writeReport( results: CaseResult[], options?: { mergePrevious?: boolean }, ): void { let merged = results; if (options?.mergePrevious) { const reportPath = path.join(OUT_DIR, "report.json"); if (fs.existsSync(reportPath)) { const prev = JSON.parse(fs.readFileSync(reportPath, "utf8")) as { results?: CaseResult[]; }; const byId = new Map((prev.results ?? []).map((r) => [r.id, r])); for (const r of results) byId.set(r.id, r); merged = [...byId.values()]; } } const passed = merged.filter((r) => r.fillOk && r.verifyOk); const failed = merged.filter((r) => !r.fillOk || !r.verifyOk); const jsonPath = path.join(OUT_DIR, "report.json"); fs.writeFileSync( jsonPath, JSON.stringify( { at: new Date().toISOString(), total: merged.length, passed: passed.length, failed: failed.length, results: merged, }, null, 2, ), "utf8", ); const lines: string[] = [ "# Flock 登录态表单矩阵探针", "", `- 时间:${new Date().toLocaleString("zh-CN")}`, `- 合计:${merged.length},通过:${passed.length},失败:${failed.length}`, `- 截图目录:\`.rpa/flock-form-matrix/\``, "", ]; if (failed.length > 0) { lines.push("## 失败用例", ""); for (const r of failed) { lines.push(`### ${r.id}`, ""); lines.push(`- 说明:${r.label}`); if (r.fillError) lines.push(`- 填表错误:${r.fillError}`); lines.push(`- 截图:\`${path.basename(r.screenshot)}\``); for (const issue of r.issues) { lines.push( `- **${issue.field}**:期望 \`${issue.expected}\` / 实际 \`${issue.actual}\``, ); lines.push(` - 分析:${issue.analysis}`); } lines.push(""); } } if (passed.length > 0) { lines.push("## 通过用例", ""); for (const r of passed) { lines.push(`- ${r.id} (${r.durationMs}ms)`); } } fs.writeFileSync(path.join(OUT_DIR, "report.md"), lines.join("\n"), "utf8"); console.log(`\n[matrix] 报告:${jsonPath}`); } async function main(): Promise { const { max, headed, onlyFailed, onlyIds, pauseMs, slowMo } = parseArgs(); fs.mkdirSync(OUT_DIR, { recursive: true }); let cases = buildMatrixCases(); if (onlyFailed) { const failedIds = loadFailedCaseIds(); cases = cases.filter((c) => failedIds.has(c.id)); console.log(`[matrix] --only-failed 过滤后 cases=${cases.length}`); } if (onlyIds && onlyIds.length > 0) { cases = cases.filter((c) => onlyIds.some((q) => c.id.includes(q) || c.label.includes(q)), ); console.log( `[matrix] --only 过滤后 cases=${cases.length} keys=${onlyIds.join(",")}`, ); } if (max != null) cases = cases.slice(0, max); console.log( `[matrix] cases=${cases.length} headed=${headed} pauseMs=${pauseMs} slowMo=${slowMo} out=${OUT_DIR}`, ); const storagePath = process.env.FLOCK_LOGGED_IN_STORAGE_STATE_PATH?.trim() || path.join(process.cwd(), ".rpa", "flock-logged-in-storage.json"); const storageOpts = fs.existsSync(storagePath) ? { storageState: storagePath } : undefined; if (storageOpts) { console.log(`[matrix] 使用 storage: ${storagePath}`); } const browser: Browser = await launchRpaBrowser({ headless: !headed, incognito: !storageOpts, ...(slowMo > 0 ? { slowMo } : {}), }); const results: CaseResult[] = []; try { const context = await createRpaBrowserContext(browser, storageOpts); const page = await context.newPage(); const login = await ensureFlockLoggedIn(page, context); if (!login.ok) { console.error(`[matrix] 登录失败: ${login.errorMessage}`); process.exit(1); } for (let i = 0; i < cases.length; i++) { results.push( await runCase(page, cases[i]!, i + 1, cases.length, { pauseMs: headed ? pauseMs : 0, }), ); } } finally { await browser.close(); } writeReport(results, { mergePrevious: onlyFailed || Boolean(onlyIds?.length) }); const failCount = results.filter((r) => !r.fillOk || !r.verifyOk).length; console.log( `[matrix] 完成 通过=${results.length - failCount} 失败=${failCount}`, ); process.exit(failCount > 0 ? 1 : 0); } main().catch((error) => { console.error("[matrix] fatal:", error); process.exit(1); });