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.
chajia/scripts/probe-flock-logged-in-form-...

519 lines
15 KiB

/**
* 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<string> {
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<boolean> {
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<CaseResult> {
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<void> {
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);
});