files 能力增强与 vision 校验修复;落盘 rpa-core 抽离改造任务清单与开发规范
This commit is contained in:
1 parent
1ff6f59990
commit
f35f0ac979
10 files changed
+577
-81
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./files";
|
||||
|
||||
function run(id: string, args: Record<string, unknown>): Promise<string> {
|
||||
const node: FlowNode = { id: "file-test", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
|
||||
const handler = getHandler(id)!;
|
||||
return Promise.resolve(handler({ signal: new AbortController().signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent)) as Promise<string>;
|
||||
}
|
||||
|
||||
const temporary: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of temporary.splice(0)) rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("file compression path semantics", () => {
|
||||
it("defaults to a sibling archive and creates a copy instead of overwriting", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-compress-"));
|
||||
temporary.push(dir);
|
||||
const source = path.join(dir, "report.txt");
|
||||
await writeFile(source, "hello", "utf8");
|
||||
const first = await run("File_CompressFile", { file_type: "openFile", file_path: source, direct_type: "1" });
|
||||
expect(first).toBe(path.join(dir, "report.txt.zip"));
|
||||
expect(existsSync(first)).toBe(true);
|
||||
const second = await run("File_CompressFile", { file_type: "openFile", file_path: source, direct_type: "1" });
|
||||
expect(second).toBe(path.join(dir, "report.txt (1).zip"));
|
||||
expect(existsSync(second)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an output directory when custom destination is selected", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-compress-"));
|
||||
temporary.push(dir);
|
||||
const source = path.join(dir, "logs");
|
||||
const output = path.join(dir, "output");
|
||||
await Promise.all([mkdir(source), mkdir(output)]);
|
||||
await writeFile(path.join(source, "app.log"), "log", "utf8");
|
||||
const target = await run("File_CompressFile", {
|
||||
file_type: "openDirectory", folder_path: source, direct_type: "0", out_path: output
|
||||
});
|
||||
expect(target).toBe(path.join(output, "logs.zip"));
|
||||
expect(existsSync(target)).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts beside the archive by default and to the selected folder for custom mode", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "rpa-file-extract-"));
|
||||
temporary.push(dir);
|
||||
const source = path.join(dir, "logs.zip");
|
||||
const AdmZip = (await import("adm-zip")).default;
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("app.log", Buffer.from("log", "utf8"));
|
||||
zip.writeZip(source);
|
||||
const defaultTarget = await run("File_DecompressFile", { folder_path: source, direct_type: "1" });
|
||||
expect(defaultTarget).toBe(dir);
|
||||
expect(readFileSync(path.join(dir, "app.log"), "utf8")).toBe("log");
|
||||
|
||||
const custom = path.join(dir, "custom-output");
|
||||
const customTarget = await run("File_DecompressFile", { folder_path: source, direct_type: "0", out_path: custom });
|
||||
expect(customTarget).toBe(custom);
|
||||
expect(readdirSync(custom)).toContain("app.log");
|
||||
});
|
||||
});
|
||||
@@ -196,10 +196,45 @@ register("File_WaitFile", async (ctx, node, args) => {
|
||||
});
|
||||
});
|
||||
|
||||
function directoryOutputPath(outputPath: string, sourcePath: string): string {
|
||||
const output = path.resolve(outputPath);
|
||||
if (/[\\/]$/.test(outputPath) || path.extname(output).toLowerCase() !== ".zip") {
|
||||
return path.join(output, path.basename(sourcePath) + ".zip");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function uniqueZipPath(targetPath: string): Promise<string> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch {
|
||||
return targetPath;
|
||||
}
|
||||
const dir = path.dirname(targetPath);
|
||||
const ext = path.extname(targetPath);
|
||||
const base = path.basename(targetPath, ext);
|
||||
for (let index = 1; ; index++) {
|
||||
const candidate = path.join(dir, `${base} (${index})${ext}`);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
} catch {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register("File_CompressFile", async (_ctx, node, args) => {
|
||||
// catalog 字段为 file_path(文件模式)/folder_path(文件夹模式)+ out_path,兼容旧键
|
||||
const source = requirePath(pick(args, ["file_path", "pathSrc", "path", "folder_path"]), "源路径", node.id, node.componentId);
|
||||
const target = requirePath(pick(args, ["pathDest", "zip_path", "out_path"]), "压缩包路径", node.id, node.componentId);
|
||||
const contentType = String(args.file_type ?? "openFile");
|
||||
const sourceKeys = contentType === "openDirectory"
|
||||
? ["folder_path", "pathSrc", "path"]
|
||||
: ["file_path", "pathSrc", "path"];
|
||||
const source = requirePath(pick(args, sourceKeys), contentType === "openDirectory" ? "待压缩文件夹路径" : "待压缩文件路径", node.id, node.componentId);
|
||||
const saveToSourceDir = String(args.direct_type ?? "1") !== "0";
|
||||
const requestedTarget = saveToSourceDir
|
||||
? path.join(path.dirname(source), path.basename(source) + ".zip")
|
||||
: directoryOutputPath(requirePath(pick(args, ["pathDest", "zip_path", "out_path"]), "压缩包输出位置", node.id, node.componentId), source);
|
||||
const target = await uniqueZipPath(requestedTarget);
|
||||
const zip = new AdmZip();
|
||||
const stat = await fs.stat(source);
|
||||
if (stat.isDirectory()) zip.addLocalFolder(source);
|
||||
@@ -211,7 +246,10 @@ register("File_CompressFile", async (_ctx, node, args) => {
|
||||
|
||||
register("File_DecompressFile", async (_ctx, node, args) => {
|
||||
const source = requirePath(pick(args, ["folder_path", "path", "zip_path"]), "压缩包路径", node.id, node.componentId);
|
||||
const target = requirePath(pick(args, ["out_path", "pathDest", "target"]), "解压目录", node.id, node.componentId);
|
||||
const saveToSourceDir = String(args.direct_type ?? "1") !== "0";
|
||||
const target = saveToSourceDir
|
||||
? path.dirname(source)
|
||||
: requirePath(pick(args, ["out_path", "pathDest", "target"]), "解压目标目录", node.id, node.componentId);
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
new AdmZip(source).extractAllTo(target, true);
|
||||
return target;
|
||||
|
||||
@@ -79,3 +79,18 @@ describe("clickPoint", () => {
|
||||
expect(clickPoint(region, "bogus")).toEqual({ x: 130, y: 220 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy position aliases", () => {
|
||||
it("accepts kebab-case anchors saved by older catalogs", () => {
|
||||
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
|
||||
expect(clickPoint(region, "left-top")).toEqual({ x: 100, y: 200 });
|
||||
expect(clickPoint(region, "right-top")).toEqual({ x: 160, y: 200 });
|
||||
expect(clickPoint(region, "left-bottom")).toEqual({ x: 100, y: 240 });
|
||||
expect(clickPoint(region, "right-bottom")).toEqual({ x: 160, y: 240 });
|
||||
});
|
||||
|
||||
it("still falls back to center for unknown anchors", () => {
|
||||
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
|
||||
expect(clickPoint(region, "middle")).toEqual({ x: 130, y: 220 });
|
||||
});
|
||||
});
|
||||
@@ -406,7 +406,7 @@ async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId
|
||||
await fs.writeFile(screenshot, PNG.sync.write(cropPng(shot.png, cropRect)));
|
||||
offsetX = rect.x;
|
||||
offsetY = rect.y;
|
||||
} else if (rangeMode === "3") {
|
||||
} else if (rangeMode === "3" || rangeMode === "4") {
|
||||
const regionRaw = text(args, ["shot_position", "action", "position"]);
|
||||
if (!regionRaw.trim()) throw new RuntimeError("validation_error", "查找范围为「指定区域」时,需要填写区域 JSON", { nodeId, recommendation: '在 shot_position 填写 {"x":0,"y":0,"width":100,"height":80} 或 [x, y, width, height]' });
|
||||
// 区域 JSON 是 DIP 坐标(框选工具按 DIP 返回, 支持副屏负坐标),换算物理坐标后减画布原点裁剪
|
||||
@@ -460,8 +460,8 @@ for (const id of ["Image_Exists", "Image_Exists_V1"]) register(id, async (ctx, n
|
||||
});
|
||||
// 等待图像: 在超时窗口内每 300ms 重试一次查找, 命中即返回坐标, 超时仍无则抛 not_found
|
||||
for (const id of ["Image_Wait", "Image_Wait_V1"]) register(id, async (ctx, node, args) => {
|
||||
const rawTimeout = Number(text(args, ["timeout", "time_out"], "10"));
|
||||
const timeout = Number.isFinite(rawTimeout) && rawTimeout >= 0 ? rawTimeout : 10;
|
||||
const rawTimeout = Number(text(args, ["timeout", "time_out"], "20"));
|
||||
const timeout = Number.isFinite(rawTimeout) && rawTimeout >= 0 ? rawTimeout : 20;
|
||||
const deadline = Date.now() + timeout * 1000;
|
||||
for (;;) {
|
||||
try {
|
||||
@@ -476,8 +476,9 @@ for (const id of ["Image_Wait", "Image_Wait_V1"]) register(id, async (ctx, node,
|
||||
// SetCursorPos 传物理坐标, 脚本必须声明 per-monitor DPI 感知(与 injectClick 同理), 否则被系统虚拟化导致偏移
|
||||
for (const id of ["Image_Move", "Image_Move_V1", "Image_Move_V2"]) register(id, async (ctx, node, args) => {
|
||||
const hit = await locate(args, ctx.evidenceDir, node.id);
|
||||
const x = Math.round(hit.x + hit.width / 2);
|
||||
const y = Math.round(hit.y + hit.height / 2);
|
||||
const point = clickPoint(hit, text(args, ["position"], "center"));
|
||||
const x = point.x;
|
||||
const y = point.y;
|
||||
await powershell("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y);' -Name RpaImageMoveInput -Namespace RpaImageMove | Out-Null; try { [void][RpaImageMove.RpaImageMoveInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}; [RpaImageMove.RpaImageMoveInput]::SetCursorPos(" + x + "," + y + ")");
|
||||
return { ...hit, x, y };
|
||||
});
|
||||
@@ -504,7 +505,7 @@ export function clickPoint(result: MatchResult, position: string): { x: number;
|
||||
// 非法 JSON 落回中心
|
||||
}
|
||||
}
|
||||
const anchor = POSITION_ANCHORS[raw] ?? POSITION_ANCHORS[raw.toLowerCase()];
|
||||
const anchor = POSITION_ANCHORS[raw] ?? POSITION_ANCHORS[raw.toLowerCase()] ?? POSITION_ANCHORS[raw.replace(/-([a-z])/g, (_: string, char: string) => char.toUpperCase())];
|
||||
if (anchor) {
|
||||
return { x: Math.round(result.x + result.width * anchor[0]), y: Math.round(result.y + result.height * anchor[1]) };
|
||||
}
|
||||
|
||||
@@ -35,6 +35,30 @@ export function fieldIsActive(componentId: string, key: string, values: Record<s
|
||||
if (key === "path") return b64mode === "encode";
|
||||
if (key === "val" || key === "output") return b64mode === "decode";
|
||||
}
|
||||
// 文件压缩: 源字段随对象类型切换, 输出位置仅在非默认位置时显示
|
||||
if (componentId === "File_CompressFile") {
|
||||
if (key === "file_path") return String(values.file_type ?? "openFile") !== "openDirectory";
|
||||
if (key === "folder_path") return String(values.file_type ?? "openFile") === "openDirectory";
|
||||
if (key === "out_path") return String(values.direct_type ?? "1") === "0";
|
||||
}
|
||||
// 文件解压: 自定义目标目录仅在其他位置模式显示
|
||||
if (componentId === "File_DecompressFile" && key === "out_path") {
|
||||
return String(values.direct_type ?? "1") === "0";
|
||||
}
|
||||
// 图像查找: window_obj 仅指定窗口时显示; 区域字段仅指定屏幕范围时显示(4 为旧流程兼容值)
|
||||
if (componentId === "Image_Move_V2" || componentId === "Image_Wait_V1") {
|
||||
const range = String(values.find_range ?? "1");
|
||||
if (key === "window_obj") return range === "2";
|
||||
if (componentId === "Image_Move_V2" && (key === "action" || key === "shot_position")) {
|
||||
return range === "3" || range === "4";
|
||||
}
|
||||
if (componentId === "Image_Wait_V1" && key === "position") {
|
||||
return range === "3" || range === "4";
|
||||
}
|
||||
if (componentId === "Image_Wait_V1" && key === "action") {
|
||||
return range === "3" || range === "4";
|
||||
}
|
||||
}
|
||||
// Image_Screenshot: 区域坐标仅截图范围为「框选区域」时显示
|
||||
if (componentId === "Image_Screenshot" && key === "position") return String(values.shotRange ?? "1") === "2";
|
||||
// Window_*_v1 series: conditional fields based on get_type mode
|
||||
|
||||
@@ -61,3 +61,50 @@ describe("Window component conditional fields", () => {
|
||||
expect(fieldIsActive("App_KillV1", "pid", { close_type: "id" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Image find range conditional fields", () => {
|
||||
it("Move V2 shows window field only for specified window", () => {
|
||||
expect(fieldIsActive("Image_Move_V2", "window_obj", { find_range: "1" })).toBe(false);
|
||||
expect(fieldIsActive("Image_Move_V2", "window_obj", { find_range: "2" })).toBe(true);
|
||||
expect(fieldIsActive("Image_Move_V2", "window_obj", { find_range: "3" })).toBe(false);
|
||||
});
|
||||
|
||||
it("Move V2 shows region fields only for specified screen range", () => {
|
||||
const values = { find_range: "3" };
|
||||
expect(fieldIsActive("Image_Move_V2", "action", values)).toBe(true);
|
||||
expect(fieldIsActive("Image_Move_V2", "shot_position", values)).toBe(true);
|
||||
expect(fieldIsActive("Image_Move_V2", "position", values)).toBe(true);
|
||||
expect(fieldIsActive("Image_Move_V2", "action", { find_range: "1" })).toBe(false);
|
||||
expect(fieldIsActive("Image_Move_V2", "shot_position", { find_range: "2" })).toBe(false);
|
||||
});
|
||||
|
||||
it("Move V2 click anchor remains visible in all modes", () => {
|
||||
for (const range of ["1", "2", "3", "4"]) {
|
||||
expect(fieldIsActive("Image_Move_V2", "position", { find_range: range })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("Wait V1 treats position as region and legacy range 4 remains compatible", () => {
|
||||
const values = { find_range: "4" };
|
||||
expect(fieldIsActive("Image_Wait_V1", "position", values)).toBe(true);
|
||||
expect(fieldIsActive("Image_Wait_V1", "window_obj", values)).toBe(false);
|
||||
expect(fieldIsActive("Image_Wait_V1", "position", { find_range: "1" })).toBe(false);
|
||||
expect(fieldIsActive("Image_Wait_V1", "window_obj", { find_range: "2" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("File compression conditional fields", () => {
|
||||
it("compress source follows selected content type", () => {
|
||||
expect(fieldIsActive("File_CompressFile", "file_path", { file_type: "openFile" })).toBe(true);
|
||||
expect(fieldIsActive("File_CompressFile", "folder_path", { file_type: "openFile" })).toBe(false);
|
||||
expect(fieldIsActive("File_CompressFile", "file_path", { file_type: "openDirectory" })).toBe(false);
|
||||
expect(fieldIsActive("File_CompressFile", "folder_path", { file_type: "openDirectory" })).toBe(true);
|
||||
});
|
||||
|
||||
it("custom output paths follow destination mode", () => {
|
||||
expect(fieldIsActive("File_CompressFile", "out_path", { direct_type: "1" })).toBe(false);
|
||||
expect(fieldIsActive("File_CompressFile", "out_path", { direct_type: "0" })).toBe(true);
|
||||
expect(fieldIsActive("File_DecompressFile", "out_path", { direct_type: "1" })).toBe(false);
|
||||
expect(fieldIsActive("File_DecompressFile", "out_path", { direct_type: "0" })).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user