|
|
import type { BrowserContext, Locator, Page } from "playwright";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
|
|
|
/** selectors 契约(与 SelectorGraph 对齐) */
|
|
|
export interface IRPASelectors {
|
|
|
resolve(key: string): string;
|
|
|
specs(envKey: string): string[];
|
|
|
widget(page: Page): Locator;
|
|
|
}
|
|
|
|
|
|
/** exec/runtime 契约(与 ExecutionLayer 对齐) */
|
|
|
export interface IRPAExecution {
|
|
|
evaluateOnLocator<T>(
|
|
|
locator: Locator,
|
|
|
script: string,
|
|
|
arg?: unknown,
|
|
|
): Promise<T>;
|
|
|
waitForBrowserFunction(
|
|
|
script: string,
|
|
|
arg: unknown,
|
|
|
timeoutMs: number,
|
|
|
): Promise<void>;
|
|
|
}
|
|
|
|
|
|
/** state 最小契约(address-side 等使用) */
|
|
|
export interface IRPAState {
|
|
|
markAddressSide(side: "pickup" | "delivery"): void;
|
|
|
}
|
|
|
|
|
|
/** Kernel 上下文契约(打破 StateEngine ↔ RPAContext 循环依赖) */
|
|
|
export interface IRPAContext {
|
|
|
readonly page: Page;
|
|
|
readonly browserContext?: BrowserContext;
|
|
|
readonly selectors: IRPASelectors;
|
|
|
readonly exec: IRPAExecution;
|
|
|
readonly runtime: IRPAExecution;
|
|
|
readonly state: IRPAState;
|
|
|
getWidgetScrollAt(): number;
|
|
|
setWidgetScrollAt(ms: number): void;
|
|
|
}
|
|
|
|
|
|
/** 数组字段统一兜底,禁止 undefined 流入业务 */
|
|
|
export function ensureArray<T>(value: T[] | null | undefined): T[] {
|
|
|
return Array.isArray(value) ? value : [];
|
|
|
}
|
|
|
|
|
|
function isPlaywrightPage(value: unknown): value is Page {
|
|
|
if (typeof value !== "object" || value === null) {
|
|
|
return false;
|
|
|
}
|
|
|
const candidate = value as Page;
|
|
|
return (
|
|
|
typeof candidate.locator === "function" &&
|
|
|
typeof candidate.waitForTimeout === "function" &&
|
|
|
typeof candidate.url === "function"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function hasContextContract(value: object): value is IRPAContext {
|
|
|
const ctx = value as IRPAContext;
|
|
|
return (
|
|
|
"selectors" in ctx &&
|
|
|
"exec" in ctx &&
|
|
|
"runtime" in ctx &&
|
|
|
"state" in ctx &&
|
|
|
"page" in ctx &&
|
|
|
typeof ctx.getWidgetScrollAt === "function" &&
|
|
|
typeof ctx.setWidgetScrollAt === "function"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** duck-typing 识别 RPAContext(避免跨 bundle instanceof 失效) */
|
|
|
export function isRPAContext(value: unknown): value is IRPAContext {
|
|
|
if (typeof value !== "object" || value === null) {
|
|
|
return false;
|
|
|
}
|
|
|
if (!hasContextContract(value)) {
|
|
|
return false;
|
|
|
}
|
|
|
return isPlaywrightPage((value as IRPAContext).page);
|
|
|
}
|
|
|
|
|
|
/** fail-fast:校验 context contract 完整 */
|
|
|
export function assertRPAContext(
|
|
|
ctx: unknown,
|
|
|
source = "RPAContext",
|
|
|
options?: { allowMissingState?: boolean },
|
|
|
): asserts ctx is IRPAContext {
|
|
|
if (typeof ctx !== "object" || ctx === null) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${source} 非法:非对象`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const typed = ctx as IRPAContext;
|
|
|
const hasCore =
|
|
|
typed.selectors instanceof Object &&
|
|
|
typed.exec instanceof Object &&
|
|
|
typed.runtime instanceof Object &&
|
|
|
isPlaywrightPage(typed.page) &&
|
|
|
typeof typed.getWidgetScrollAt === "function" &&
|
|
|
typeof typed.setWidgetScrollAt === "function";
|
|
|
|
|
|
if (!hasCore) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${source} contract 断裂:缺少 selectors/runtime/state/page`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if (!options?.allowMissingState && !(typed.state instanceof Object)) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${source}.state 未初始化`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export function assertPlaywrightPage(
|
|
|
page: unknown,
|
|
|
source = "page",
|
|
|
): asserts page is Page {
|
|
|
if (!isPlaywrightPage(page)) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${source} 不是有效 Playwright Page`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export type RPAContextContractSnapshot = {
|
|
|
hasPage: boolean;
|
|
|
hasSelectors: boolean;
|
|
|
hasExec: boolean;
|
|
|
hasRuntime: boolean;
|
|
|
hasState: boolean;
|
|
|
};
|
|
|
|
|
|
export function snapshotContextContract(
|
|
|
ctx: unknown,
|
|
|
): RPAContextContractSnapshot {
|
|
|
if (typeof ctx !== "object" || ctx === null) {
|
|
|
return {
|
|
|
hasPage: false,
|
|
|
hasSelectors: false,
|
|
|
hasExec: false,
|
|
|
hasRuntime: false,
|
|
|
hasState: false,
|
|
|
};
|
|
|
}
|
|
|
const typed = ctx as Partial<IRPAContext>;
|
|
|
return {
|
|
|
hasPage: isPlaywrightPage(typed.page),
|
|
|
hasSelectors: typed.selectors instanceof Object,
|
|
|
hasExec: typed.exec instanceof Object,
|
|
|
hasRuntime: typed.runtime instanceof Object,
|
|
|
hasState: typed.state instanceof Object,
|
|
|
};
|
|
|
}
|