feat: 接入系统能力并完善运行中心

This commit is contained in:
yu committed 2026-10-27 11:55:27 +08:00
1 parent aff2d5cd95
commit f9688251ea
8 files changed
+357 -34

No files matched your search

+1
View File
@@ -10,5 +10,6 @@ import "./word";
import "./message";
import "./vision";
import "./extended";
import "./system-extended";
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
@@ -0,0 +1,51 @@
import { spawn } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./system-extended";
const context = {} as RunContext;
const component = {} as CatalogComponent;
const children: Array<ReturnType<typeof spawn>> = [];
const node = (componentId: string): FlowNode => ({ id: componentId, componentId, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(id: string, args: Record<string, unknown> = {}) {
const handler = getHandler(id);
expect(handler, id).toBeTruthy();
return handler!(context, node(id), args, component);
}
afterEach(() => {
for (const child of children.splice(0)) if (!child.killed) child.kill();
delete process.env.RPA_STUDIO_TEST_ENV;
});
describe("system extended capabilities", () => {
it("reads and writes process environment values and standard paths", async () => {
await expect(run("System_SetEnv", { name: "RPA_STUDIO_TEST_ENV", value: "connected" })).resolves.toBe("connected");
await expect(run("System_GetEnv", { name: "RPA_STUDIO_TEST_ENV" })).resolves.toBe("connected");
await expect(run("System_GetTempPath")).resolves.toBe(os.tmpdir());
await expect(run("System_GetHomePath")).resolves.toBe(os.homedir());
await expect(run("System_GetSystemPath", { path_name: "documents" })).resolves.toBe(path.join(os.homedir(), "Documents"));
});
it("finds and terminates a test child process by PID", async () => {
const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { windowsHide: true });
children.push(child);
await new Promise((resolve) => setTimeout(resolve, 300));
const pids = await run("App_GetPids", { pattern: "node.exe" }) as number[];
expect(pids).toContain(child.pid);
const exited = new Promise<number | null>((resolve) => child.once("exit", resolve));
await run("App_Kill", { pid: child.pid });
const exitCode = await exited;
expect(exitCode).not.toBeUndefined();
}, 10000);
it("registers safe clipboard and window variants", () => {
for (const id of ["Clipboard_GetTextV1", "Clipboard_SetTextV1", "Clipboard_ClearClipboard", "Window_GetWindowRect", "Window_SetWindowState", "Window_SetWindowState_v1", "Window_SizeWindow_v1"]) {
expect(getHandler(id), id).toBeTruthy();
}
});
});
+132
View File
@@ -0,0 +1,132 @@
import os from "node:os";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
const current = args[key];
if (current !== undefined && current !== null && current !== "") return String(current);
}
return fallback;
}
function required(args: Record<string, unknown>, keys: string[], label: string, nodeId: string, componentId: string): string {
const result = value(args, keys).trim();
if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId, recommendation: `请配置${label}` });
return result;
}
function processPattern(raw: string): string {
return path.basename(raw).replace(/\.exe$/i, "").replace(/\*/g, "*") || "*";
}
function selector(args: Record<string, unknown>): string {
const raw = args.window_obj ?? args["窗口对象"] ?? args.match_title ?? args.select_title ?? args.target_window ?? args.title;
if (typeof raw === "object" && raw !== null) {
const object = raw as Record<string, unknown>;
return String(object.title ?? object.windowTitle ?? object.pid ?? object.hwnd ?? "");
}
return String(raw ?? "");
}
const WINDOW_API = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaWindowApi {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
}
'@`;
function findWindowScript(title: string): string {
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`;
}
register("System_GetEnv", (_ctx, node, args) => {
const name = required(args, ["name"], "环境变量名称", node.id, node.componentId);
return process.env[name] ?? "";
});
register("System_SetEnv", (_ctx, node, args) => {
const name = required(args, ["name"], "环境变量名称", node.id, node.componentId);
const content = value(args, ["value"]);
process.env[name] = content;
return content;
});
register("System_GetTempPath", () => os.tmpdir());
register("System_GetHomePath", () => os.homedir());
register("System_GetSystemPath", (_ctx, _node, args) => {
const name = value(args, ["path_name"], "desktop").toLowerCase();
const home = os.homedir();
const paths: Record<string, string> = {
desktop: path.join(home, "Desktop"), documents: path.join(home, "Documents"), document: path.join(home, "Documents"),
downloads: path.join(home, "Downloads"), download: path.join(home, "Downloads"), pictures: path.join(home, "Pictures"),
music: path.join(home, "Music"), videos: path.join(home, "Videos"), home,
temp: os.tmpdir(), system: process.env.SystemRoot || "C:\\Windows", appdata: process.env.APPDATA || path.join(home, "AppData", "Roaming")
};
return paths[name] || value(args, ["path"]) || path.join(home, name);
});
const getPids: CapabilityHandler = async (_ctx, node, args) => {
const pattern = processPattern(required(args, ["pattern", "p_name", "name"], "进程名称", node.id, node.componentId));
const output = await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Select-Object -ExpandProperty Id`);
return output.split(/\r?\n/).map(Number).filter(Number.isFinite);
};
register("App_GetPids", getPids);
register("App_GetPid", async (ctx, node, args, component) => (await getPids(ctx, node, args, component) as number[])[0] ?? null);
register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => {
const pid = Number(value(args, ["pid"]));
const name = value(args, ["p_name", "pattern"]);
if (Number.isFinite(pid) && pid > 0) {
if (pid === process.pid) throw new RuntimeError("validation_error", "拒绝关闭 RPA Studio 自身进程", { nodeId: node.id, componentId: node.componentId });
await powershell(`Stop-Process -Id ${pid} -Force -ErrorAction Stop`);
return pid;
}
const pattern = processPattern(required({ name }, ["name"], "进程名称", node.id, node.componentId));
await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Stop-Process -Force -ErrorAction Stop`);
return pattern;
});
register("Clipboard_GetTextV1", () => powershell("Get-Clipboard -Raw"));
register("Clipboard_SetTextV1", async (_ctx, _node, args) => {
const content = value(args, ["text", "value"]);
await powershell(`Set-Clipboard -Value ${psString(content)}`);
return content;
});
register("Clipboard_ClearClipboard", async () => {
await powershell("Set-Clipboard -Value $null");
return true;
});
register("Window_GetWindowRect", async (_ctx, node, args) => {
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
const output = await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top;title=$p.MainWindowTitle;pid=$p.Id} | ConvertTo-Json -Compress`);
return JSON.parse(output);
});
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 powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowApi]::ShowWindow($p.MainWindowHandle,${command}) | Out-Null`);
return state;
});
register("Window_SizeWindow_v1", async (_ctx, node, args) => {
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
let size: unknown = args.window_size;
if (typeof size === "string") {
const source = size;
try { size = JSON.parse(source); } catch { size = source.split(/[,,xX ]+/).map(Number); }
}
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 powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; [RpaWindowApi]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,$r.Left,$r.Top,${width},${height},4) | Out-Null`);
return { width, height };
});
+48
View File
@@ -0,0 +1,48 @@
import { mkdir, mkdtemp, rm, writeFile } 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,
getVersion: () => "test"
}
}));
import { createFlow, listFlows, saveFlow } from "./flows";
describe("flow summaries", () => {
beforeEach(async () => {
electronState.userData = await mkdtemp(path.join(os.tmpdir(), "rpa-flow-summary-"));
});
afterEach(async () => {
await rm(electronState.userData, { recursive: true, force: true });
});
it("loads the latest persisted run into the task summary", async () => {
const flow = saveFlow(createFlow("运行记录任务"));
const olderDir = path.join(electronState.userData, "runs", "older");
const latestDir = path.join(electronState.userData, "runs", "latest");
await mkdir(olderDir, { recursive: true });
await mkdir(latestDir, { recursive: true });
await writeFile(path.join(olderDir, "run.json"), JSON.stringify({
flowId: flow.id, status: "succeeded", finishedAt: "2026-10-27T01:00:00.000Z", durationMs: 120
}));
await writeFile(path.join(latestDir, "run.json"), JSON.stringify({
flowId: flow.id, status: "failed", finishedAt: "2026-10-27T02:00:00.000Z", durationMs: 456,
error: { reason: "测试失败原因" }
}));
expect(listFlows()).toContainEqual(expect.objectContaining({
id: flow.id,
name: "运行记录任务",
lastRunStatus: "failed",
lastRunAt: "2026-10-27T02:00:00.000Z",
lastRunDurationMs: 456,
lastRunError: "测试失败原因"
}));
});
});