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:
parent
9b76dc6987
commit
1da00827ac
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/** 能力模块内部使用的默认引擎实例(与全局注册表配套的进程级单例)。 */
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user