|
|
import type { Locator, Page } from "playwright";
|
|
|
import {
|
|
|
clickFirstVisibleFromSpecs,
|
|
|
getSelectorSpecs,
|
|
|
} from "@/lib/rpa/selector-specs";
|
|
|
import { pageLocator } from "@/lib/rpa/locator";
|
|
|
import { awaitHumanPacing } from "@/lib/rpa/human-pacing";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import { fillLocatorWithVerify } from "@/workers/rpa/cargo-field-fill";
|
|
|
import {
|
|
|
buildWeightDisplayForUnit,
|
|
|
inferWeightUnitFromFieldHint,
|
|
|
type MotherShipWeightDisplay,
|
|
|
} from "@/workers/rpa/cargo-weight";
|
|
|
import { dismissCookieConsent } from "@/workers/rpa/page-prep";
|
|
|
|
|
|
const CARGO_EDITOR_OPEN_WAIT_MS = 12_000;
|
|
|
const POLL_MS = 200;
|
|
|
|
|
|
const DRAWER_LOCATORS = [
|
|
|
'[role="dialog"]',
|
|
|
'[aria-modal="true"]',
|
|
|
'[data-state="open"][role="dialog"]',
|
|
|
] as const;
|
|
|
|
|
|
const CARGO_FIELD_PLACEHOLDER_RE =
|
|
|
/pallet|weight|length|width|height|托盘|重量|长度|宽度|高度/i;
|
|
|
|
|
|
/** MotherShip popover 内常见控件(无 role=dialog) */
|
|
|
function popoverEditorSignals(page: Page): Locator[] {
|
|
|
return [
|
|
|
page.getByRole("button", { name: /ADD ITEM/i }),
|
|
|
page.getByPlaceholder(/weight/i),
|
|
|
page.getByPlaceholder(/how many pallets/i),
|
|
|
page.locator('input[placeholder*="Weight" i]'),
|
|
|
page.locator('input[placeholder*="Pallet" i]'),
|
|
|
page.getByText(/^ADD ITEM$/i),
|
|
|
];
|
|
|
}
|
|
|
|
|
|
function cargoFieldLocators(widget: Locator): Locator[] {
|
|
|
return [
|
|
|
widget.getByPlaceholder(/how many pallets/i).first(),
|
|
|
widget.getByPlaceholder(/需要几个托盘/i).first(),
|
|
|
widget.getByPlaceholder(/weight/i).first(),
|
|
|
widget.getByPlaceholder(/重量/i).first(),
|
|
|
widget.getByPlaceholder(/length|width|height|长度|宽度|高度/i).first(),
|
|
|
widget.locator('input[placeholder*="Pallet" i]').first(),
|
|
|
widget.locator('input[placeholder*="Weight" i]').first(),
|
|
|
widget.locator('input[placeholder*="托盘" i]').first(),
|
|
|
widget.locator('input[placeholder*="重量" i]').first(),
|
|
|
];
|
|
|
}
|
|
|
|
|
|
/** widget 内是否已有可填货物字段(含中文向导内联步) */
|
|
|
export async function isCargoFieldsVisible(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<boolean> {
|
|
|
const widget = pageLocator(page, widgetSelector);
|
|
|
for (const loc of cargoFieldLocators(widget)) {
|
|
|
if (await loc.isVisible().catch(() => false)) {
|
|
|
const editable = await loc.isEditable().catch(() => false);
|
|
|
if (editable) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** popover/dialog 或 widget 内联货物字段可见 */
|
|
|
export async function isCargoEditable(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<boolean> {
|
|
|
if (await isCargoEditorOpen(page)) {
|
|
|
return true;
|
|
|
}
|
|
|
return isCargoFieldsVisible(page, widgetSelector);
|
|
|
}
|
|
|
|
|
|
/** drawer / popover 是否已打开(popover 以 editor 控件可见为准,非 dialog role) */
|
|
|
export async function isCargoEditorOpen(page: Page): Promise<boolean> {
|
|
|
for (const selector of DRAWER_LOCATORS) {
|
|
|
const loc = page.locator(selector).first();
|
|
|
if (await loc.isVisible().catch(() => false)) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
for (const loc of popoverEditorSignals(page)) {
|
|
|
if (await loc.first().isVisible().catch(() => false)) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 等待 cargo 可填(popover 或中文内联步) */
|
|
|
export async function waitForCargoEditorOpen(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<void> {
|
|
|
if (!(await isCargoUiPresent(page, widgetSelector))) {
|
|
|
throw new RpaError("STRUCT_CHANGE", "cargo editor UI 不存在,无法等待打开", {
|
|
|
retryable: false,
|
|
|
});
|
|
|
}
|
|
|
const deadline = Date.now() + CARGO_EDITOR_OPEN_WAIT_MS;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (await isCargoFieldsVisible(page, widgetSelector)) {
|
|
|
return;
|
|
|
}
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
if (await isCargoEditable(page, widgetSelector)) {
|
|
|
return;
|
|
|
}
|
|
|
throw new RpaError("STRUCT_CHANGE", "cargo editor 未在时限内就绪", {
|
|
|
retryable: true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/** 填表作用域:dialog / popover / widget 内联 */
|
|
|
export async function resolveCargoEditorScope(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<Locator> {
|
|
|
for (const selector of DRAWER_LOCATORS) {
|
|
|
const loc = page.locator(selector).last();
|
|
|
if (await loc.isVisible().catch(() => false)) {
|
|
|
return loc;
|
|
|
}
|
|
|
}
|
|
|
if (await isCargoFieldsVisible(page, widgetSelector)) {
|
|
|
return pageLocator(page, widgetSelector);
|
|
|
}
|
|
|
if (await isCargoEditorOpen(page)) {
|
|
|
return page.locator("body");
|
|
|
}
|
|
|
throw new RpaError("STRUCT_CHANGE", "cargo editor 未打开,禁止填表", {
|
|
|
retryable: true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function clickTrigger(loc: Locator): Promise<boolean> {
|
|
|
if (!(await loc.isVisible().catch(() => false))) {
|
|
|
return false;
|
|
|
}
|
|
|
await loc.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await loc.click({ timeout: 8_000, force: true });
|
|
|
await loc.page().waitForTimeout(500);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** widget 上是否存在可编辑的货物 UI(中/英)或可分步展开的货物步骤 */
|
|
|
export async function isCargoUiPresent(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<boolean> {
|
|
|
if (await isCargoFieldsVisible(page, widgetSelector)) {
|
|
|
return true;
|
|
|
}
|
|
|
const widget = pageLocator(page, widgetSelector);
|
|
|
const signals: Locator[] = [
|
|
|
widget.getByText(/what are you shipping/i).first(),
|
|
|
widget.getByText(/\d+\s*pallet/i).first(),
|
|
|
widget.getByText(/\d+\s*托盘/).first(),
|
|
|
widget.getByText(/\d+W,\s*x\s*\d+L/i).first(),
|
|
|
widget.getByText(/货运详情/).first(),
|
|
|
widget.getByText(/Freight details/i).first(),
|
|
|
widget.getByText(/Shipment details/i).first(),
|
|
|
widget.getByPlaceholder(/cargo/i).first(),
|
|
|
widget.getByRole("button", { name: /pallet/i }).first(),
|
|
|
widget
|
|
|
.getByRole("button", {
|
|
|
name: /获取货运报价|GET QUOTE|Get freight quote|Get a freight quote/i,
|
|
|
})
|
|
|
.first(),
|
|
|
];
|
|
|
for (const loc of signals) {
|
|
|
if (await loc.isVisible().catch(() => false)) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return isCargoWizardPastAddress(page, widgetSelector);
|
|
|
}
|
|
|
|
|
|
/** 双地址已锁定且不再处于地址搜索态 */
|
|
|
export async function isCargoWizardPastAddress(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<boolean> {
|
|
|
return page.evaluate((sel) => {
|
|
|
const widget = document.querySelector(sel);
|
|
|
if (!widget) {
|
|
|
return false;
|
|
|
}
|
|
|
const text = (
|
|
|
(widget as HTMLElement).innerText ??
|
|
|
widget.textContent ??
|
|
|
""
|
|
|
).replace(/\s+/g, " ");
|
|
|
const stateMatches = text.match(/,\s*[A-Z]{2}/g) ?? [];
|
|
|
if (stateMatches.length < 2) {
|
|
|
return false;
|
|
|
}
|
|
|
const searchInputs = widget.querySelectorAll<HTMLInputElement>("input");
|
|
|
for (const inp of searchInputs) {
|
|
|
const ph = inp.placeholder ?? "";
|
|
|
if (!/search|搜索/i.test(ph)) {
|
|
|
continue;
|
|
|
}
|
|
|
const visible = inp.offsetParent !== null;
|
|
|
const editable =
|
|
|
!inp.disabled &&
|
|
|
inp.readOnly === false &&
|
|
|
inp.getAttribute("aria-readonly") !== "true";
|
|
|
if (visible && editable) {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
return true;
|
|
|
}, widgetSelector);
|
|
|
}
|
|
|
|
|
|
/** 地址填完后等待货物步出现(MotherShip 渲染有延迟) */
|
|
|
export async function waitForCargoUiPresent(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
timeoutMs = 20_000,
|
|
|
): Promise<void> {
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (await isCargoUiPresent(page, widgetSelector)) {
|
|
|
return;
|
|
|
}
|
|
|
await dismissCookieConsent(page).catch(() => undefined);
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
throw new RpaError(
|
|
|
"QUOTE_ENTRY_UNAVAILABLE",
|
|
|
"货物编辑 UI 在时限内未出现",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function waitCargoEditableAfterClick(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<boolean> {
|
|
|
const deadline = Date.now() + 4_000;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (await isCargoEditable(page, widgetSelector)) {
|
|
|
return true;
|
|
|
}
|
|
|
await page.waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function clickFreightStepInWidget(widget: Locator): Promise<boolean> {
|
|
|
const labels = ["货运详情", "Freight details", "Shipment details"];
|
|
|
for (const label of labels) {
|
|
|
const exact = widget.getByText(label, { exact: true });
|
|
|
const count = await exact.count().catch(() => 0);
|
|
|
for (let i = count - 1; i >= 0; i -= 1) {
|
|
|
const target = exact.nth(i);
|
|
|
if (await clickTrigger(target)) {
|
|
|
return true;
|
|
|
}
|
|
|
const buttonAncestor = target.locator("xpath=ancestor::button[1]");
|
|
|
if (await clickTrigger(buttonAncestor)) {
|
|
|
return true;
|
|
|
}
|
|
|
const roleButtonAncestor = target.locator(
|
|
|
"xpath=ancestor::*[@role='button'][1]",
|
|
|
);
|
|
|
if (await clickTrigger(roleButtonAncestor)) {
|
|
|
return true;
|
|
|
}
|
|
|
const parent = target.locator("xpath=..");
|
|
|
if (await clickTrigger(parent)) {
|
|
|
return true;
|
|
|
}
|
|
|
const grandParent = target.locator("xpath=../..");
|
|
|
if (await clickTrigger(grandParent)) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 重新展开中文内联货物步(不抛错) */
|
|
|
export async function reopenInlineCargoStep(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<void> {
|
|
|
if (await isCargoFieldsVisible(page, widgetSelector)) {
|
|
|
return;
|
|
|
}
|
|
|
const widget = pageLocator(page, widgetSelector);
|
|
|
await clickFreightStepInWidget(widget);
|
|
|
await waitCargoEditableAfterClick(page, widgetSelector);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 点击 summary chip / 步骤标签,展开 cargo editor(popover 或中文内联步)。
|
|
|
*/
|
|
|
export async function expandCargoEditor(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
): Promise<void> {
|
|
|
if (!(await isCargoUiPresent(page, widgetSelector))) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"无法展开 cargo editor:widget 无货物编辑 UI",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if (await isCargoEditable(page, widgetSelector)) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
await dismissCookieConsent(page);
|
|
|
|
|
|
const widget = pageLocator(page, widgetSelector);
|
|
|
|
|
|
if (await clickFreightStepInWidget(widget)) {
|
|
|
if (await waitCargoEditableAfterClick(page, widgetSelector)) {
|
|
|
console.log("[rpa] cargo-editor: 内联货物步已展开(货运详情)");
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const triggers: Locator[] = [
|
|
|
widget.getByRole("button", { name: /\d+\s*Pallet/i }).first(),
|
|
|
widget.locator("button, [role='button']").filter({ hasText: /Pallet/i }).first(),
|
|
|
widget.getByText(/^\d+\s*Pallet$/i).first(),
|
|
|
widget.getByText(/\d+\s*x\s*\d+\s*x\s*\d+/i).first(),
|
|
|
widget.getByText(/what are you shipping/i).first(),
|
|
|
widget.getByText(/你在运送什么/i).first(),
|
|
|
widget.locator("text=/\\d+\\s*Pallet/i").first(),
|
|
|
widget.getByText(/\d+\s*Pallet.*\d+\s*lbs?/i).first(),
|
|
|
widget.locator('[class*="summary"], [data-testid*="cargo"], [data-testid*="shipment"]').first(),
|
|
|
];
|
|
|
|
|
|
for (const loc of triggers) {
|
|
|
if (await clickTrigger(loc)) {
|
|
|
if (await waitCargoEditableAfterClick(page, widgetSelector)) {
|
|
|
console.log("[rpa] cargo-editor: popover opened via summary chip");
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const clicked = await clickFirstVisibleFromSpecs(
|
|
|
page,
|
|
|
getSelectorSpecs("RPA_SELECTOR_CARGO_SECTION"),
|
|
|
);
|
|
|
if (clicked) {
|
|
|
await page.waitForTimeout(500);
|
|
|
if (await waitCargoEditableAfterClick(page, widgetSelector)) {
|
|
|
console.log("[rpa] cargo-editor: 内联步已展开(CARGO_SECTION spec)");
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"无法展开 cargo editor(步骤标签/chip 未响应)",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function resolveSpecOnScope(scope: Locator, spec: string): Locator {
|
|
|
const trimmed = spec.trim();
|
|
|
if (trimmed.startsWith("placeholder=")) {
|
|
|
const ph = trimmed.slice(12).replace(/^"|"$/g, "");
|
|
|
return scope.getByPlaceholder(ph);
|
|
|
}
|
|
|
const roleMatch = trimmed.match(/^role=(\w+)(?:\[name="([^"]*)"\])?$/);
|
|
|
if (roleMatch) {
|
|
|
const [, role, name] = roleMatch;
|
|
|
return name
|
|
|
? scope.getByRole(role as Parameters<Locator["getByRole"]>[0], { name })
|
|
|
: scope.getByRole(role as Parameters<Locator["getByRole"]>[0]);
|
|
|
}
|
|
|
if (trimmed.startsWith("text=")) {
|
|
|
const text = trimmed.slice(5);
|
|
|
if (text.startsWith("/") && text.endsWith("/")) {
|
|
|
return scope.getByText(new RegExp(text.slice(1, -1)));
|
|
|
}
|
|
|
return scope.getByText(text);
|
|
|
}
|
|
|
return scope.locator(trimmed);
|
|
|
}
|
|
|
|
|
|
const CARGO_FIELD_WAIT_MS = 20_000;
|
|
|
|
|
|
async function waitForFieldLocator(loc: Locator): Promise<boolean> {
|
|
|
const deadline = Date.now() + CARGO_FIELD_WAIT_MS;
|
|
|
while (Date.now() < deadline) {
|
|
|
const visible = await loc.isVisible().catch(() => false);
|
|
|
const editable = visible && (await loc.isEditable().catch(() => false));
|
|
|
if (editable) {
|
|
|
return true;
|
|
|
}
|
|
|
await loc.page().waitForTimeout(POLL_MS);
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 在已打开的 editor / 内联步内按 spec 填值 */
|
|
|
export async function fillInCargoEditor(
|
|
|
page: Page,
|
|
|
envKey: string,
|
|
|
value: string,
|
|
|
widgetSelector: string,
|
|
|
options?: { commit?: boolean; forceClear?: boolean },
|
|
|
): Promise<boolean> {
|
|
|
if (!(await isCargoFieldsVisible(page, widgetSelector))) {
|
|
|
await reopenInlineCargoStep(page, widgetSelector);
|
|
|
}
|
|
|
|
|
|
const specs = getSelectorSpecs(envKey);
|
|
|
const scopes: Locator[] = [];
|
|
|
|
|
|
if (await isCargoFieldsVisible(page, widgetSelector)) {
|
|
|
scopes.push(pageLocator(page, widgetSelector));
|
|
|
} else {
|
|
|
if (await isCargoUiPresent(page, widgetSelector)) {
|
|
|
await reopenInlineCargoStep(page, widgetSelector);
|
|
|
}
|
|
|
if (await isCargoFieldsVisible(page, widgetSelector)) {
|
|
|
scopes.push(pageLocator(page, widgetSelector));
|
|
|
} else {
|
|
|
const scope = await resolveCargoEditorScope(page, widgetSelector);
|
|
|
scopes.push(scope);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
for (const fillScope of scopes) {
|
|
|
for (const spec of specs) {
|
|
|
const loc = resolveSpecOnScope(fillScope, spec).first();
|
|
|
if (!(await waitForFieldLocator(loc))) {
|
|
|
continue;
|
|
|
}
|
|
|
try {
|
|
|
await fillLocatorWithVerify(page, loc, value, {
|
|
|
forceClear: options?.forceClear,
|
|
|
commit: options?.commit ?? false,
|
|
|
});
|
|
|
await awaitHumanPacing(page);
|
|
|
return true;
|
|
|
} catch {
|
|
|
/* 尝试下一 spec */
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 按实际可见 weight 输入框 placeholder 决定填入值并填写 */
|
|
|
export async function fillWeightInCargoEditor(
|
|
|
page: Page,
|
|
|
widgetSelector: string,
|
|
|
weightLbPerPallet: number,
|
|
|
options?: { commit?: boolean; forceClear?: boolean },
|
|
|
): Promise<MotherShipWeightDisplay> {
|
|
|
if (!(await isCargoFieldsVisible(page, widgetSelector))) {
|
|
|
await reopenInlineCargoStep(page, widgetSelector);
|
|
|
}
|
|
|
|
|
|
const specs = getSelectorSpecs("RPA_SELECTOR_WEIGHT");
|
|
|
const scopes: Locator[] = [];
|
|
|
|
|
|
if (await isCargoFieldsVisible(page, widgetSelector)) {
|
|
|
scopes.push(pageLocator(page, widgetSelector));
|
|
|
} else {
|
|
|
if (await isCargoUiPresent(page, widgetSelector)) {
|
|
|
await reopenInlineCargoStep(page, widgetSelector);
|
|
|
}
|
|
|
if (await isCargoFieldsVisible(page, widgetSelector)) {
|
|
|
scopes.push(pageLocator(page, widgetSelector));
|
|
|
} else {
|
|
|
scopes.push(await resolveCargoEditorScope(page, widgetSelector));
|
|
|
}
|
|
|
}
|
|
|
scopes.push(page.locator("body"));
|
|
|
|
|
|
let lastError: unknown;
|
|
|
for (const fillScope of scopes) {
|
|
|
for (const spec of specs) {
|
|
|
const loc = resolveSpecOnScope(fillScope, spec).first();
|
|
|
if (!(await waitForFieldLocator(loc))) {
|
|
|
continue;
|
|
|
}
|
|
|
const hint = `${(await loc.getAttribute("placeholder")) ?? ""} ${(await loc.getAttribute("aria-label")) ?? ""}`;
|
|
|
const display = buildWeightDisplayForUnit(
|
|
|
weightLbPerPallet,
|
|
|
inferWeightUnitFromFieldHint(hint),
|
|
|
);
|
|
|
try {
|
|
|
await fillLocatorWithVerify(page, loc, display.displayValue, {
|
|
|
forceClear: options?.forceClear,
|
|
|
commit: options?.commit ?? true,
|
|
|
});
|
|
|
await awaitHumanPacing(page);
|
|
|
return display;
|
|
|
} catch (error) {
|
|
|
lastError = error;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (lastError instanceof RpaError) {
|
|
|
throw lastError;
|
|
|
}
|
|
|
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"货物重量字段填写失败(drawer 内):RPA_SELECTOR_WEIGHT",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** @internal 单测 */
|
|
|
export function cargoPlaceholderMatches(placeholder: string): boolean {
|
|
|
return CARGO_FIELD_PLACEHOLDER_RE.test(placeholder);
|
|
|
}
|