feat: 支持配置全局网页元素等待

This commit is contained in:
yu committed 2026-10-28 11:17:28 +08:00
1 parent 5e9f0df57f
commit bec3d9daa4
8 files changed
+210 -12

No files matched your search

+52 -6
View File
@@ -2,6 +2,16 @@ import { BrowserWindow } from "electron";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { ElementPickResult } from "../../shared/types";
const DEFAULT_ELEMENT_WAIT_MS = 5000;
let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS;
export function setBrowserElementWaitMs(milliseconds: number): void {
const value = Number(milliseconds);
globalElementWaitMs = Number.isFinite(value)
? Math.min(600000, Math.max(0, Math.round(value)))
: DEFAULT_ELEMENT_WAIT_MS;
}
import {
RuntimeError,
type BrowserElementInfo,
@@ -137,6 +147,18 @@ export class BrowserSession implements BrowserLike {
}
private async withElement<T>(selector: unknown, operation: string): Promise<T> {
const deadline = Date.now() + globalElementWaitMs;
for (;;) {
try {
return await this.withElementOnce<T>(selector, operation);
} catch (error) {
if (!(error instanceof RuntimeError && error.code === "not_found") || Date.now() >= deadline) throw error;
await this.wait(120);
}
}
}
private async withElementOnce<T>(selector: unknown, operation: string): Promise<T> {
const spec = selectorSpec(selector);
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
const spec = ${JSON.stringify(spec)};
@@ -241,7 +263,31 @@ export class BrowserSession implements BrowserLike {
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
}
async locate(selector: unknown): Promise<ElementInfo & { selector: string }> {
private async wait(milliseconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async locate(selector: unknown, timeoutMs?: number): Promise<ElementInfo & { selector: string }> {
const waitMs = Math.max(0, timeoutMs ?? globalElementWaitMs);
const deadline = Date.now() + waitMs;
for (;;) {
try {
return await this.locateOnce(selector);
} catch (error) {
const canRetry = error instanceof RuntimeError && error.code === "not_found" && Date.now() < deadline;
if (!canRetry) {
if (error instanceof RuntimeError && error.code === "not_found" && waitMs > 0) {
error.detail = `已等待页面渲染 ${waitMs}ms 后仍未找到元素`;
error.recommendation = "确认页面已加载完成;如果元素在 iframe 或 Shadow DOM 中,请用拾取器重新采集";
}
throw error;
}
await this.wait(120);
}
}
}
private async locateOnce(selector: unknown): Promise<ElementInfo & { selector: string }> {
const spec = selectorSpec(selector);
const expression = `(() => {
const spec = ${JSON.stringify(spec)};
@@ -312,8 +358,8 @@ export class BrowserSession implements BrowserLike {
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", buttons: 0, clickCount: 1 });
}
async click(selector: unknown): Promise<unknown> {
const info = await this.locate(selector);
async click(selector: unknown, timeoutMs?: number): Promise<unknown> {
const info = await this.locate(selector, timeoutMs);
if (!info.visible) {
throw new RuntimeError("not_found", `元素不可见:${info.selector}`, { recommendation: "等待元素出现或检查页面状态" });
}
@@ -343,8 +389,8 @@ export class BrowserSession implements BrowserLike {
return { selector: info.selector, tag: info.tag, text: info.text };
}
async type(selector: unknown, text: string, clearFirst = true): Promise<unknown> {
const info = await this.locate(selector);
async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> {
const info = await this.locate(selector, timeoutMs);
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
if (clearFirst) {
await this.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
@@ -374,7 +420,7 @@ export class BrowserSession implements BrowserLike {
async exists(selector: unknown): Promise<boolean> {
try {
await this.locate(selector);
await this.locate(selector, 0);
return true;
} catch {
return false;
+1 -1
View File
@@ -3,7 +3,7 @@ import type { CatalogComponent, FlowDefinition, FlowNode, RunError, RunEvent } f
export interface BrowserLike {
navigate(url: string): Promise<{ url: string; title: string }>;
click(selector: unknown, timeoutMs?: number): Promise<unknown>;
type(selector: unknown, text: string, clearFirst?: boolean): Promise<unknown>;
type(selector: unknown, text: string, clearFirst?: boolean, timeoutMs?: number): Promise<unknown>;
getText(selector: unknown, attribute?: string): Promise<string>;
exists(selector: unknown, timeoutMs?: number): Promise<boolean>;
select(selector: unknown, value: string): Promise<unknown>;
+10 -2
View File
@@ -1,13 +1,14 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import path from "node:path";
import type { AppInfo, FlowDefinition } from "../shared/types";
import { BrowserSession, hasBrowserSession } from "./browser/session";
import type { AppInfo, AppSettings, FlowDefinition } from "../shared/types";
import { BrowserSession, hasBrowserSession, setBrowserElementWaitMs } from "./browser/session";
import "./capabilities";
import { catalogPath, loadCatalog } from "./catalog";
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
import { capabilityCount, capabilityIds } from "./engine/registry";
import { closeBrowserSession, startRun, stopRun } from "./run/manager";
import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server";
import { loadSettings, updateSettings } from "./settings";
import { logMain } from "./logger";
let mainWindow: BrowserWindow | null = null;
@@ -93,6 +94,12 @@ function registerIpc(): void {
ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender));
ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId));
ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() }));
ipcMain.handle("settings:get", () => loadSettings());
ipcMain.handle("settings:update", (_event, patch: Partial<AppSettings> = {}) => {
const settings = updateSettings(patch);
setBrowserElementWaitMs(settings.browserElementWaitMs);
return settings;
});
ipcMain.handle("browser:pick-element", async (_event, options: { url?: string } = {}) => {
const url = options.url?.trim();
if (!hasBrowserSession() && !url) {
@@ -160,6 +167,7 @@ if (!gotLock) {
app.whenReady().then(async () => {
logMain("app-ready", { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node });
setBrowserElementWaitMs(loadSettings().browserElementWaitMs);
try {
await startFixtureServer(Number(process.env.RPA_FIXTURE_PORT || 17863));
} catch (error) {
+35
View File
@@ -0,0 +1,35 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const electronState = vi.hoisted(() => ({ userData: "" }));
vi.mock("electron", () => ({
app: {
getPath: () => electronState.userData
}
}));
import { loadSettings, updateSettings } from "./settings";
describe("app settings", () => {
beforeEach(async () => {
electronState.userData = await mkdtemp(path.join(os.tmpdir(), "rpa-settings-"));
});
afterEach(async () => {
await rm(electronState.userData, { recursive: true, force: true });
});
it("returns the default element wait when no settings file exists", () => {
expect(loadSettings()).toEqual({ browserElementWaitMs: 5000 });
});
it("persists and clamps a user-adjustable element wait", async () => {
const saved = updateSettings({ browserElementWaitMs: 12500 });
expect(saved).toEqual({ browserElementWaitMs: 12500 });
expect(JSON.parse(await readFile(path.join(electronState.userData, "settings.json"), "utf8"))).toEqual(saved);
expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0 });
expect(loadSettings()).toEqual({ browserElementWaitMs: 0 });
});
});
+45
View File
@@ -0,0 +1,45 @@
import { app } from "electron";
import fs from "node:fs";
import path from "node:path";
import type { AppSettings } from "../shared/types";
export const DEFAULT_APP_SETTINGS: AppSettings = {
browserElementWaitMs: 5000
};
let cache: AppSettings | null = null;
function settingsPath(): string {
return path.join(app.getPath("userData"), "settings.json");
}
function normalizeSettings(value: unknown): AppSettings {
const source = (value && typeof value === "object" ? value : {}) as Partial<AppSettings>;
const wait = Number(source.browserElementWaitMs);
return {
browserElementWaitMs: Number.isFinite(wait)
? Math.min(600000, Math.max(0, Math.round(wait)))
: DEFAULT_APP_SETTINGS.browserElementWaitMs
};
}
export function loadSettings(): AppSettings {
try {
cache = normalizeSettings(JSON.parse(fs.readFileSync(settingsPath(), "utf8")));
} catch {
cache = { ...DEFAULT_APP_SETTINGS };
}
return cache;
}
export function saveSettings(settings: AppSettings): AppSettings {
const normalized = normalizeSettings(settings);
fs.mkdirSync(app.getPath("userData"), { recursive: true });
fs.writeFileSync(settingsPath(), JSON.stringify(normalized, null, 2), "utf8");
cache = normalized;
return normalized;
}
export function updateSettings(patch: Partial<AppSettings> = {}): AppSettings {
return saveSettings({ ...(cache || loadSettings()), ...patch });
}