fix(browser): W3 迁前必修两缺陷——transport 单命令超时 + 定位器统一
目标:ADR-R4 前置修复——①CDP 对端不回包时 send 在有限时间内失败;②同名定位参数在全部网页能力下行为一致 代码逻辑:①ExternalTransport.send 增单命令超时(默认 30s,connect 可传),超时以稳定码 timeout 拒绝并清理 pending(不再依赖引擎级超时兜底+泄漏表项);RuntimeError 原样透传不经 mapCdpError 改写;②selectorOf 三份重复定义(browser/browser-extended/browser-dom)收敛为 src/main/browser/selector.ts 单一事实源,键集合取并集(补 uiWindowSelector);browser-common 的 element_position 位置感知定位语义不同,有意保留 验证:宿主 browser+extended 测试 49/49 全绿;rpa-core 引擎域 32/32 全绿;typecheck 归零 风险与回滚:低——超时默认值对既有快流程无感知;定位器统一仅补齐兜底键,行为为超集
This commit is contained in:
parent
43ae705629
commit
08e798c087
@ -54,11 +54,16 @@ export class ExternalTransport implements CdpTransport {
|
|||||||
private handlers = new Map<string, Set<CdpEventHandler>>();
|
private handlers = new Map<string, Set<CdpEventHandler>>();
|
||||||
private closedFlag = false;
|
private closedFlag = false;
|
||||||
private readonly openPromise: Promise<void>;
|
private readonly openPromise: Promise<void>;
|
||||||
|
// 单命令超时:CDP 对端不回包时(页面冻结/debugger 忙/eval 挂起),send 必须在有限时间内失败,
|
||||||
|
// 否则挂起会占住节点直到引擎级超时,且 pending 表项泄漏。
|
||||||
|
private readonly commandTimeoutMs: number;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
wsUrl: string,
|
wsUrl: string,
|
||||||
readonly description: string
|
readonly description: string,
|
||||||
|
commandTimeoutMs = 30_000
|
||||||
) {
|
) {
|
||||||
|
this.commandTimeoutMs = commandTimeoutMs;
|
||||||
this.openPromise = new Promise<void>((resolve, reject) => {
|
this.openPromise = new Promise<void>((resolve, reject) => {
|
||||||
// perMessageDeflate 必须显式关闭:ws 客户端默认开启压缩协商后,若 ws 被打包进
|
// perMessageDeflate 必须显式关闭:ws 客户端默认开启压缩协商后,若 ws 被打包进
|
||||||
// 主进程 bundle,其可选依赖 bufferutil 的解析产物会破坏 mask fallback,
|
// 主进程 bundle,其可选依赖 bufferutil 的解析产物会破坏 mask fallback,
|
||||||
@ -80,8 +85,13 @@ export class ExternalTransport implements CdpTransport {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async connect(wsUrl: string, description: string, timeoutMs = 10000): Promise<ExternalTransport> {
|
static async connect(
|
||||||
const transport = new ExternalTransport(wsUrl, description);
|
wsUrl: string,
|
||||||
|
description: string,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
commandTimeoutMs = 30_000
|
||||||
|
): Promise<ExternalTransport> {
|
||||||
|
const transport = new ExternalTransport(wsUrl, description, commandTimeoutMs);
|
||||||
const timeout = new Promise<never>((_, reject) =>
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs)
|
setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs)
|
||||||
);
|
);
|
||||||
@ -101,6 +111,7 @@ export class ExternalTransport implements CdpTransport {
|
|||||||
}
|
}
|
||||||
const id = this.nextId++;
|
const id = this.nextId++;
|
||||||
const ws = this.ws as WebSocket;
|
const ws = this.ws as WebSocket;
|
||||||
|
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const reply = new Promise<unknown>((resolve, reject) => {
|
const reply = new Promise<unknown>((resolve, reject) => {
|
||||||
this.pending.set(id, { resolve, reject });
|
this.pending.set(id, { resolve, reject });
|
||||||
try {
|
try {
|
||||||
@ -108,12 +119,28 @@ export class ExternalTransport implements CdpTransport {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.pending.delete(id);
|
this.pending.delete(id);
|
||||||
reject(error as Error);
|
reject(error as Error);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 单命令超时:以稳定错误码失败并清理 pending,避免对端不回包时永久挂起
|
||||||
|
timeoutTimer = setTimeout(() => {
|
||||||
|
if (this.pending.delete(id)) {
|
||||||
|
reject(
|
||||||
|
new RuntimeError("timeout", `CDP 命令超时:${method}`, {
|
||||||
|
detail: `单命令超时上限 ${this.commandTimeoutMs}ms,对端未返回响应`,
|
||||||
|
recommendation: "检查目标页面是否冻结或弹出了模态对话框;必要时调大节点超时时间"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, this.commandTimeoutMs);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
return (await reply) as T;
|
return (await reply) as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// RuntimeError(如上面的单命令超时)原样透传,不再经 mapCdpError 改写错误码
|
||||||
|
if (error instanceof RuntimeError) throw error;
|
||||||
throw mapCdpError(error, "external");
|
throw mapCdpError(error, "external");
|
||||||
|
} finally {
|
||||||
|
if (timeoutTimer !== undefined) clearTimeout(timeoutTimer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
src/main/browser/selector.ts
Normal file
18
src/main/browser/selector.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { RuntimeError } from "@rpa/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器能力统一的元素选择器入参解析(W3 定位器统一修复)。
|
||||||
|
*
|
||||||
|
* 两个浏览器能力文件(browser.ts / browser-extended.ts)此前各持一份 selectorOf,
|
||||||
|
* 兜底键集合不一致(extended 缺 uiWindowSelector),同一流程里同名参数在
|
||||||
|
* 两个能力下行为不同。现收敛为单一事实源,键集合为两者并集。
|
||||||
|
*/
|
||||||
|
export function selectorOf(args: Record<string, unknown>): unknown {
|
||||||
|
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
|
||||||
|
if (selector === undefined || selector === null || selector === "") {
|
||||||
|
throw new RuntimeError("validation_error", "元素选择器为空", {
|
||||||
|
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return selector;
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { browserSession } from "../browser/session";
|
import { browserSession } from "../browser/session";
|
||||||
|
import { selectorOf } from "../browser/selector";
|
||||||
import { register } from "@rpa/core";
|
import { register } from "@rpa/core";
|
||||||
import { RuntimeError, type BrowserLike, type RunContext } from "@rpa/core";
|
import { RuntimeError, type BrowserLike, type RunContext } from "@rpa/core";
|
||||||
|
|
||||||
@ -21,14 +22,6 @@ async function session(ctx: RunContext): Promise<BrowserLike> {
|
|||||||
return ctx.browser;
|
return ctx.browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectorOf(args: Record<string, unknown>): unknown {
|
|
||||||
const selector = args.uiSelector ?? args.selector ?? args.target;
|
|
||||||
if (selector === undefined || selector === null || selector === "") {
|
|
||||||
throw new RuntimeError("validation_error", "元素选择器为空", { recommendation: "请拾取或填写网页元素" });
|
|
||||||
}
|
|
||||||
return selector;
|
|
||||||
}
|
|
||||||
|
|
||||||
function enabled(value: unknown): boolean {
|
function enabled(value: unknown): boolean {
|
||||||
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
|
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { browserSession } from "../browser/session";
|
import { browserSession } from "../browser/session";
|
||||||
|
import { selectorOf } from "../browser/selector";
|
||||||
import { register } from "@rpa/core";
|
import { register } from "@rpa/core";
|
||||||
import { RuntimeError, type BrowserLike, type RunContext } from "@rpa/core";
|
import { RuntimeError, type BrowserLike, type RunContext } from "@rpa/core";
|
||||||
|
|
||||||
@ -24,16 +25,6 @@ async function session(ctx: RunContext): Promise<BrowserLike> {
|
|||||||
return ctx.browser;
|
return ctx.browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectorOf(args: Record<string, unknown>): unknown {
|
|
||||||
const selector = args.uiSelector ?? args.selector ?? args.target;
|
|
||||||
if (selector === undefined || selector === null || selector === "") {
|
|
||||||
throw new RuntimeError("validation_error", "元素选择器为空", {
|
|
||||||
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return selector;
|
|
||||||
}
|
|
||||||
|
|
||||||
function enabled(value: unknown): boolean {
|
function enabled(value: unknown): boolean {
|
||||||
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
|
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { register } from "@rpa/core";
|
import { register } from "@rpa/core";
|
||||||
import { RuntimeError } from "@rpa/core";
|
import { RuntimeError } from "@rpa/core";
|
||||||
import { browserSession, externalBrowserSession } from "../browser/session";
|
import { browserSession, externalBrowserSession } from "../browser/session";
|
||||||
|
import { selectorOf } from "../browser/selector";
|
||||||
import type { BrowserLike, RunContext } from "@rpa/core";
|
import type { BrowserLike, RunContext } from "@rpa/core";
|
||||||
|
|
||||||
async function session(ctx: RunContext): Promise<BrowserLike> {
|
async function session(ctx: RunContext): Promise<BrowserLike> {
|
||||||
@ -63,16 +64,6 @@ register(["WebBrowser_CreateV3", "WebBrowser_CreateV2", "WebBrowser_Create", "We
|
|||||||
return { url: await browser.currentUrl(), title: "" };
|
return { url: await browser.currentUrl(), title: "" };
|
||||||
});
|
});
|
||||||
|
|
||||||
function selectorOf(args: Record<string, unknown>): unknown {
|
|
||||||
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
|
|
||||||
if (selector === undefined || selector === null || selector === "") {
|
|
||||||
throw new RuntimeError("validation_error", "元素选择器为空", {
|
|
||||||
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return selector;
|
|
||||||
}
|
|
||||||
|
|
||||||
register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => {
|
register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => {
|
||||||
const browser = await session(ctx);
|
const browser = await session(ctx);
|
||||||
const url = String(args.url ?? "");
|
const url = String(args.url ?? "");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user