import type { BrowserContext, Page } from "playwright"; import { RpaError } from "@/modules/rpa/errors"; /** Kernel 上下文契约(打破 StateEngine ↔ RPAContext 循环依赖) */ export interface IRPAContext { readonly page: Page; readonly browserContext?: BrowserContext; readonly selectors: object; readonly exec: object; readonly runtime: object; readonly state: object; getWidgetScrollAt(): number; setWidgetScrollAt(ms: number): void; } /** 数组字段统一兜底,禁止 undefined 流入业务 */ export function ensureArray(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; 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, }; }