From 1da00827acc95a1f5b4e6e690bdc8395d8e846c4 Mon Sep 17 00:00:00 2001 From: yu <1919727607@qq.com> Date: Fri, 6 Nov 2026 10:20:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(engine):=20createEngine=20=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E5=8F=82=E6=95=B0=20+=20=E6=B6=88=E9=99=A4=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E7=BA=A7=20driver=20=E6=8D=95=E8=8E=B7=EF=BC=88?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E9=98=B6=E6=AE=B51-A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createEngine(options) 支持 driver 注入,新增 engine.setDriver; 缺省保持按平台自动选择的行为 - desktop/common-final/system-extended/window-extended 四文件模块加载时 捕获 defaultEngine.driver 改为惰性 getDriver():非 win32 import 不再抛错 (此前 darwin 上 import 即炸),调用时才触发 platform_unsupported - engine-driver.test 补注入/隔离/后置替换 4 例 --- .../rpa-core/src/capabilities/common-final.ts | 10 ++-- packages/rpa-core/src/capabilities/desktop.ts | 22 +++++---- .../src/capabilities/system-extended.ts | 8 ++-- .../src/capabilities/window-extended.ts | 22 ++++----- .../rpa-core/src/engine/engine-driver.test.ts | 48 +++++++++++++++++++ packages/rpa-core/src/engine/engine.ts | 15 +++++- .../src/engine/runtime.cancel.test.ts | 34 ++++++------- 7 files changed, 111 insertions(+), 48 deletions(-) diff --git a/packages/rpa-core/src/capabilities/common-final.ts b/packages/rpa-core/src/capabilities/common-final.ts index 1d67788..47eef3a 100644 --- a/packages/rpa-core/src/capabilities/common-final.ts +++ b/packages/rpa-core/src/capabilities/common-final.ts @@ -8,7 +8,7 @@ import { powershell, psString } from "../driver/windows/powershell"; import { defaultEngine } from "../engine/engine"; // 窗口/屏幕查询统一走驱动(任务 3.3) -const driver = defaultEngine.driver; +const getDriver = () => defaultEngine.driver; function text(args: Record, keys: string[], fallback = ""): string { for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); } @@ -24,21 +24,21 @@ register("Window_GetRect", delegate("Window_GetWindowRect")); register("Window_MaximumSize", (ctx, node, args, component) => delegate("Window_SetWindowState")(ctx, node, { ...args, WindowType: "maximize" }, component)); register(["Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2"], delegate("Keyboard_Send_V1")); -register("Window_GetScreenSize", async () => driver.screen.size()); +register("Window_GetScreenSize", async () => getDriver().screen.size()); register("Window_WindowList", async (_ctx, _node, args) => { const title = text(args, ["match_title", "select_title"]); const regex = String(args.is_regex ?? "0") === "1"; - return driver.window.list({ title, regex }); + return getDriver().window.list({ title, regex }); }); register("Window_SetWindowShow", async (_ctx, node, args) => { const title = text(args, ["窗口对象", "window_obj", "title"]); if (!title || title === "窗口对象") throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); const show = !text(args, ["WindowType"], "show").toLowerCase().includes("hide"); - await driver.window.setState(title, show ? 5 : 0); + await getDriver().window.setState(title, show ? 5 : 0); return show; }); register("Window_WaitWindow", async (_ctx, node, args) => { const title = text(args, ["match_title", "select_title", "window_obj", "target_window"]); if (!title) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); const expected = text(args, ["switchType"], "0") !== "1"; const deadline = Date.now() + Math.max(0, Number(args.second ?? 5)) * 1000; - do { const exists = await driver.window.exists(title); if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline); + do { const exists = await getDriver().window.exists(title); if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline); return false; }); diff --git a/packages/rpa-core/src/capabilities/desktop.ts b/packages/rpa-core/src/capabilities/desktop.ts index c72af81..b4684d8 100644 --- a/packages/rpa-core/src/capabilities/desktop.ts +++ b/packages/rpa-core/src/capabilities/desktop.ts @@ -6,7 +6,9 @@ import { powershell, psString } from "../driver/windows/powershell"; // 人为拟真设置本体挂在引擎实例上(任务 1.3),此处委托 defaultEngine。 const manualOperation: ManualOperationSettings = defaultEngine.manualOperation; // 桌面自动化驱动(任务 3.3):user32 注入原语统一走 WindowsDriver -const driver = defaultEngine.driver; +// 惰性获取驱动(迁移阶段1-A1):非 win32 上 import 本模块不再抛错, +// 调用桌面能力时才触发 platform_unsupported;也使注入 driver 的引擎可被测试/宿主使用 +const getDriver = () => defaultEngine.driver; function jitter(value: number, amount = 3): number { return Math.max(0, Math.round(value + (Math.random() * amount * 2 - amount))); @@ -63,7 +65,7 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { const isTextSend = node.componentId === "Keyboard_Send" || node.componentId === "Keyboard_Send_V1"; // 指定目标窗口时先激活再输入,内容才会进到目标窗口而不是运行时的焦点窗口 const targetTitle = text(args, ["window_title", "target_window", "window_obj", "match_title", "select_title"]); - await driver.keyboard.send({ keys, intervalMs, mode: mode === "2" ? "clipboard" : mode === "1" ? "manual" : "api", textMode: isTextSend, activateTitle: targetTitle || undefined }); + await getDriver().keyboard.send({ keys, intervalMs, mode: mode === "2" ? "clipboard" : mode === "1" ? "manual" : "api", textMode: isTextSend, activateTitle: targetTitle || undefined }); await manualDelay(); return keys; }; @@ -88,47 +90,47 @@ const disableManualOperation: CapabilityHandler = async () => { const mouseClick: CapabilityHandler = async (_ctx, node, args) => { const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), false, true); const button = text(args, ["side", "button"], "0").toLowerCase(); - await driver.mouse.click({ x, y }, { button: button.includes("right") || button === "1" ? "right" : "left" }); + await getDriver().mouse.click({ x, y }, { button: button.includes("right") || button === "1" ? "right" : "left" }); return { x, y }; }; const mouseMove: CapabilityHandler = async (_ctx, node, args) => { const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), true, false); - await driver.mouse.moveTo({ x, y }); + await getDriver().mouse.moveTo({ x, y }); return { x, y }; }; const mouseDrag: CapabilityHandler = async (_ctx, node, args) => { const [sx, sy] = point(args.Start ?? args.start ?? "[0,0]"); const [ex, ey] = point(args.End ?? args.end ?? "[0,0]"); - await driver.mouse.drag({ x: sx, y: sy }, { x: ex, y: ey }, { holdMs: 80, releaseDelayMs: 80 }); + await getDriver().mouse.drag({ x: sx, y: sy }, { x: ex, y: ey }, { holdMs: 80, releaseDelayMs: 80 }); return { start: [sx, sy], end: [ex, ey] }; }; const mouseRoll: CapabilityHandler = async (_ctx, node, args) => { const amount = number(args, ["RollNum", "rollNum", "amount"], 1) * 120; - await driver.mouse.scroll(amount); + await getDriver().mouse.scroll(amount); return amount; }; const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => { const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]); if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); - return driver.window.focus(selector); + return getDriver().window.focus(selector); }; -const getActiveWindow: CapabilityHandler = async () => driver.window.foregroundTitle(); +const getActiveWindow: CapabilityHandler = async () => getDriver().window.foregroundTitle(); const windowExists: CapabilityHandler = async (_ctx, node, args) => { const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]); if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); - return driver.window.exists(selector); + return getDriver().window.exists(selector); }; const closeWindow: CapabilityHandler = async (_ctx, node, args) => { const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]); if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); - return driver.window.close(selector); + return getDriver().window.close(selector); }; const runApp: CapabilityHandler = async (_ctx, node, args) => { diff --git a/packages/rpa-core/src/capabilities/system-extended.ts b/packages/rpa-core/src/capabilities/system-extended.ts index 39a3050..b0311c4 100644 --- a/packages/rpa-core/src/capabilities/system-extended.ts +++ b/packages/rpa-core/src/capabilities/system-extended.ts @@ -33,7 +33,7 @@ function selector(args: Record): string { } // 窗口矩形/状态操作统一走驱动(任务 3.3) -const driver = defaultEngine.driver; +const getDriver = () => defaultEngine.driver; register("System_GetEnv", (_ctx, node, args) => { const name = required(args, ["name"], "环境变量名称", node.id, node.componentId); @@ -104,14 +104,14 @@ register("Clipboard_ClearClipboard", async () => { register("Window_GetWindowRect", async (_ctx, node, args) => { const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId); - return driver.window.findByTitle(title); + return getDriver().window.findByTitle(title); }); register(["Window_SetWindowState", "Window_SetWindowState_v1"], async (_ctx, node, args) => { const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId); const state = value(args, ["WindowType", "switchType"], "maximize").toLowerCase(); const command = state.includes("min") ? 6 : state.includes("max") ? 3 : 9; - await driver.window.setState(title, command); + await getDriver().window.setState(title, command); return state; }); @@ -124,6 +124,6 @@ register("Window_SizeWindow_v1", async (_ctx, node, args) => { } const [width, height] = Array.isArray(size) ? size.map(Number) : [0, 0]; if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸无效", { nodeId: node.id, componentId: node.componentId }); - await driver.window.setSize(title, width, height); + await getDriver().window.setSize(title, width, height); return { width, height }; }); diff --git a/packages/rpa-core/src/capabilities/window-extended.ts b/packages/rpa-core/src/capabilities/window-extended.ts index baf9efa..f6c21e0 100644 --- a/packages/rpa-core/src/capabilities/window-extended.ts +++ b/packages/rpa-core/src/capabilities/window-extended.ts @@ -4,7 +4,7 @@ import { getHostBridge } from "../engine/host-bridge"; import { defaultEngine } from "../engine/engine"; // 桌面自动化驱动(任务 3.3):窗口矩形/鼠标注入原语统一走 WindowsDriver -const driver = defaultEngine.driver; +const getDriver = () => defaultEngine.driver; function value(args: Record, keys: string[], fallback = ""): string { for (const key of keys) { @@ -77,17 +77,17 @@ function physicalToDip(x: number, y: number): [number, number] { return [Math.round(x), Math.round(y)]; } -const getWindow: CapabilityHandler = async (_ctx, node, args) => driver.window.findByTitle(requiredTitle(args, node.id, node.componentId)); +const getWindow: CapabilityHandler = async (_ctx, node, args) => getDriver().window.findByTitle(requiredTitle(args, node.id, node.componentId)); register(["Window_Get", "Window_GetWindow", "Window_GetWindow_v1"], getWindow); register("Window_ActiveWindow_v1", async (_ctx, node, args) => { const title = requiredTitle(args, node.id, node.componentId); const maximize = [true, 1, "1", "true", "True"].includes(args.isMax as never); - return driver.window.activate(title, { maximize }); + return getDriver().window.activate(title, { maximize }); }); register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx, node, args) => { - const object = await driver.window.findByTitle(requiredTitle(args, node.id, node.componentId)); + const object = await getDriver().window.findByTitle(requiredTitle(args, node.id, node.componentId)); const type = value(args, ["switchType"], "all").toLowerCase(); if (type === "title") return object.title; if (type === "pid") return object.pid; @@ -98,7 +98,7 @@ register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx, register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow"], async (_ctx, node, args) => { const title = requiredTitle(args, node.id, node.componentId); - const current = await driver.window.findByTitle(title); + const current = await getDriver().window.findByTitle(title); const moveOnly = node.componentId.includes("MoveWindow"); const sizeOnly = node.componentId === "Window_SizeWindow"; const position = args.location !== undefined ? parsePoint(args.location, "窗口位置", node.id, node.componentId) : [Number(args.x ?? current.x), Number(args.y ?? current.y)] as [number, number]; @@ -109,7 +109,7 @@ register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_ const width = moveOnly ? Number(current.width) : size[0]; const height = moveOnly ? Number(current.height) : size[1]; if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸必须大于 0", { nodeId: node.id, componentId: node.componentId }); - return driver.window.setPos(title, { x, y, width, height }); + return getDriver().window.setPos(title, { x, y, width, height }); }); const moveMouse: CapabilityHandler = async (_ctx, node, args) => { @@ -122,7 +122,7 @@ const moveMouse: CapabilityHandler = async (_ctx, node, args) => { let dipTarget: [number, number]; if (relative) { // 相对激活窗口左上角:窗口矩形是物理像素,换回 DIP 再叠加偏移,最后统一转物理 - const { x: wx, y: wy } = await driver.window.foregroundPosition(); + const { x: wx, y: wy } = await getDriver().window.foregroundPosition(); if (!Number.isFinite(wx) || !Number.isFinite(wy)) throw new RuntimeError("browser_error", "无法获取激活窗口位置", { nodeId: node.id, componentId: node.componentId }); const [dipWx, dipWy] = physicalToDip(wx, wy); dipTarget = [dipWx + rawX, dipWy + rawY]; @@ -131,10 +131,10 @@ const moveMouse: CapabilityHandler = async (_ctx, node, args) => { } const [tx, ty] = dipToPhysical(dipTarget[0], dipTarget[1]); if (moveType === "2") { - await driver.mouse.moveTo({ x: tx, y: ty }); + await getDriver().mouse.moveTo({ x: tx, y: ty }); } else { // 平滑移动:从当前光标位置出发,按速度插值(路径全程物理像素,与目标同一坐标系) - await driver.mouse.moveSmooth({ x: tx, y: ty }, { ease: moveType === "0", speed }); + await getDriver().mouse.moveSmooth({ x: tx, y: ty }, { ease: moveType === "0", speed }); } return { x: Math.round(dipTarget[0]), y: Math.round(dipTarget[1]) }; }; @@ -147,7 +147,7 @@ const dragMouse: CapabilityHandler = async (_ctx, node, args) => { const [epx, epy] = dipToPhysical(ex, ey); const right = ["1", "right"].includes(value(args, ["side"], "0").toLowerCase()); // 旧实现按下后停 80ms、拖到终点立即松开(与桌面版 Window_Mouse 不同,松开前不停顿) - await driver.mouse.drag({ x: spx, y: spy }, { x: epx, y: epy }, { button: right ? "right" : "left", holdMs: 80, releaseDelayMs: 0 }); + await getDriver().mouse.drag({ x: spx, y: spy }, { x: epx, y: epy }, { button: right ? "right" : "left", holdMs: 80, releaseDelayMs: 0 }); return { start: [Math.round(sx), Math.round(sy)], end: [Math.round(ex), Math.round(ey)] }; }; register(["Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew"], dragMouse); @@ -159,6 +159,6 @@ register(["Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2" const right = value(args, ["click_key"], "left").toLowerCase() === "right"; const doubleClick = value(args, ["click_time"], "click").toLowerCase().includes("double"); // 旧实现每次 down-up 之后停 80ms - await driver.mouse.click({ x: px, y: py }, { button: right ? "right" : "left", double: doubleClick, settleMs: 80 }); + await getDriver().mouse.click({ x: px, y: py }, { button: right ? "right" : "left", double: doubleClick, settleMs: 80 }); return { x: Math.round(x), y: Math.round(y), button: right ? "right" : "left", clicks: doubleClick ? 2 : 1 }; }); diff --git a/packages/rpa-core/src/engine/engine-driver.test.ts b/packages/rpa-core/src/engine/engine-driver.test.ts index 36a9b5b..2a2c9ba 100644 --- a/packages/rpa-core/src/engine/engine-driver.test.ts +++ b/packages/rpa-core/src/engine/engine-driver.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { AutomationDriver } from "../driver/types"; import { createEngine } from "./engine"; import { WindowsDriver } from "../driver/windows/windows-driver"; @@ -20,3 +21,50 @@ describe("driver 平台自动选择(迁移指南五件小事 #2)", () => { } }); }); + +describe("createEngine 注入(迁移阶段1-A1)", () => { + const fakeDriver = (): AutomationDriver => + ({ + mouse: {}, + keyboard: {}, + window: {}, + screen: { size: async () => ({ x: 0, y: 0, width: 800, height: 600 }) }, + element: {} + }) as unknown as AutomationDriver; + + it("createEngine 可注入自定义 driver", () => { + const driver = fakeDriver(); + const engine = createEngine({ driver }); + expect(engine.driver).toBe(driver); + }); + + it("注入后不触发平台自动选择(darwin 上也可用)", () => { + const driver = fakeDriver(); + const engine = createEngine({ driver }); + const original = process.platform; + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + try { + expect(engine.driver).toBe(driver); + expect(engine.driver.screen.size).toBeTruthy(); + } finally { + Object.defineProperty(process, "platform", { value: original, configurable: true }); + } + }); + + it("两个引擎注入不同 driver 互不影响", () => { + const driverA = fakeDriver(); + const driverB = fakeDriver(); + const engineA = createEngine({ driver: driverA }); + const engineB = createEngine({ driver: driverB }); + expect(engineA.driver).toBe(driverA); + expect(engineB.driver).toBe(driverB); + expect(engineA.driver).not.toBe(engineB.driver); + }); + + it("setDriver 可后置替换", () => { + const engine = createEngine(); + const driver = fakeDriver(); + engine.setDriver(driver); + expect(engine.driver).toBe(driver); + }); +}); diff --git a/packages/rpa-core/src/engine/engine.ts b/packages/rpa-core/src/engine/engine.ts index d8ee81a..796ed46 100644 --- a/packages/rpa-core/src/engine/engine.ts +++ b/packages/rpa-core/src/engine/engine.ts @@ -59,10 +59,21 @@ export class RpaEngine { } return this._driver; } + /** 注入驱动实现(测试替身或宿主适配器);注入后不再走平台自动选择 */ + setDriver(driver: AutomationDriver): void { + this._driver = driver; + } } -export function createEngine(): RpaEngine { - return new RpaEngine(); +export interface EngineOptions { + /** 注入桌面自动化驱动(测试替身或宿主适配器);缺省按 process.platform 自动选择 */ + driver?: AutomationDriver; +} + +export function createEngine(options?: EngineOptions): RpaEngine { + const engine = new RpaEngine(); + if (options?.driver) engine.setDriver(options.driver); + return engine; } /** 能力模块内部使用的默认引擎实例(与全局注册表配套的进程级单例)。 */ diff --git a/packages/rpa-core/src/engine/runtime.cancel.test.ts b/packages/rpa-core/src/engine/runtime.cancel.test.ts index 7e41aaf..0eaf382 100644 --- a/packages/rpa-core/src/engine/runtime.cancel.test.ts +++ b/packages/rpa-core/src/engine/runtime.cancel.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { CatalogComponent, FlowDefinition, FlowNode } from "../../types"; +import type { CatalogComponent, FlowDefinition, FlowNode } from "../types"; import { validateFlowGraph, hasBlockingIssues } from "./graph-validator"; import { register } from "./registry"; import { RuntimeError } from "./types"; @@ -14,8 +14,9 @@ function makeFlow(partial: Partial & { nodes: FlowNode[] }): Flo name: "测试流程", variables: [], nodes: partial.nodes, - edges: partial.edges || [] - } as FlowDefinition; + edges: partial.edges || [], + meta: { createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", appVersion: "test" } + }; } function node(id: string, componentId: string, overrides: Partial = {}): FlowNode { @@ -29,19 +30,20 @@ function node(id: string, componentId: string, overrides: Partial = {} }; } -const fakeComponent = (id: string): CatalogComponent => ({ - id, - name: id, - version: "1.0.0", - tags: [], - platformTag: null, - icon: "", - risk: "low", - fields: [], - output: { type: "any", description: "" }, - slots: [], - functionCalls: [] -}); +const fakeComponent = (id: string): CatalogComponent => + ({ + id, + name: id, + version: "1.0.0", + tags: [], + platformTag: null, + icon: "", + risk: "low", + fields: [], + output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] }, + slots: [], + functionCalls: [] + }) as unknown as CatalogComponent; const baseOptions = (signal: AbortSignal): RunFlowOptions => ({ runId: "run_test",