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.
519 lines
15 KiB
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);
|
|
});
|