feat(engine): createEngine 注入参数 + 消除模块级 driver 捕获(迁移阶段1-A1)

- 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 例
This commit is contained in:
yu 2026-11-06 10:20:29 +08:00
parent 9b76dc6987
commit 1da00827ac
7 changed files with 111 additions and 48 deletions

View File

@ -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<string, unknown>, 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;
});

View File

@ -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) => {

View File

@ -33,7 +33,7 @@ function selector(args: Record<string, unknown>): 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 };
});

View File

@ -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<string, unknown>, 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 };
});

View File

@ -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);
});
});

View File

@ -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;
}
/** 能力模块内部使用的默认引擎实例(与全局注册表配套的进程级单例)。 */

View File

@ -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<FlowDefinition> & { 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> = {}): FlowNode {
@ -29,19 +30,20 @@ function node(id: string, componentId: string, overrides: Partial<FlowNode> = {}
};
}
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",