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 closedFlag = false;
|
||||
private readonly openPromise: Promise<void>;
|
||||
// 单命令超时:CDP 对端不回包时(页面冻结/debugger 忙/eval 挂起),send 必须在有限时间内失败,
|
||||
// 否则挂起会占住节点直到引擎级超时,且 pending 表项泄漏。
|
||||
private readonly commandTimeoutMs: number;
|
||||
|
||||
private constructor(
|
||||
wsUrl: string,
|
||||
readonly description: string
|
||||
readonly description: string,
|
||||
commandTimeoutMs = 30_000
|
||||
) {
|
||||
this.commandTimeoutMs = commandTimeoutMs;
|
||||
this.openPromise = new Promise<void>((resolve, reject) => {
|
||||
// perMessageDeflate 必须显式关闭:ws 客户端默认开启压缩协商后,若 ws 被打包进
|
||||
// 主进程 bundle,其可选依赖 bufferutil 的解析产物会破坏 mask fallback,
|
||||
@ -80,8 +85,13 @@ export class ExternalTransport implements CdpTransport {
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(wsUrl: string, description: string, timeoutMs = 10000): Promise<ExternalTransport> {
|
||||
const transport = new ExternalTransport(wsUrl, description);
|
||||
static async connect(
|
||||
wsUrl: string,
|
||||
description: string,
|
||||
timeoutMs = 10000,
|
||||
commandTimeoutMs = 30_000
|
||||
): Promise<ExternalTransport> {
|
||||
const transport = new ExternalTransport(wsUrl, description, commandTimeoutMs);
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs)
|
||||
);
|
||||
@ -101,6 +111,7 @@ export class ExternalTransport implements CdpTransport {
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const ws = this.ws as WebSocket;
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const reply = new Promise<unknown>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
try {
|
||||
@ -108,12 +119,28 @@ export class ExternalTransport implements CdpTransport {
|
||||
} catch (error) {
|
||||
this.pending.delete(id);
|
||||
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 {
|
||||
return (await reply) as T;
|
||||
} catch (error) {
|
||||
// RuntimeError(如上面的单命令超时)原样透传,不再经 mapCdpError 改写错误码
|
||||
if (error instanceof RuntimeError) throw error;
|
||||
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 { browserSession } from "../browser/session";
|
||||
import { selectorOf } from "../browser/selector";
|
||||
import { register } 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { browserSession } from "../browser/session";
|
||||
import { selectorOf } from "../browser/selector";
|
||||
import { register } 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { register } from "@rpa/core";
|
||||
import { RuntimeError } from "@rpa/core";
|
||||
import { browserSession, externalBrowserSession } from "../browser/session";
|
||||
import { selectorOf } from "../browser/selector";
|
||||
import type { BrowserLike, RunContext } from "@rpa/core";
|
||||
|
||||
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: "" };
|
||||
});
|
||||
|
||||
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) => {
|
||||
const browser = await session(ctx);
|
||||
const url = String(args.url ?? "");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user