merge: integrate liuwenxin app-system fixes
This commit is contained in:
commit
510f0d041d
38 files changed
+19887
-147
No files matched your search
@@ -0,0 +1,85 @@
|
||||
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";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ powershell: vi.fn(), execFile: vi.fn() }));
|
||||
vi.mock("./desktop", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` }));
|
||||
vi.mock("node:child_process", () => ({ execFile: mocks.execFile }));
|
||||
import "./data";
|
||||
import "./control";
|
||||
import "./system-extended";
|
||||
import "./windows-system";
|
||||
|
||||
function run(id: string, args: Record<string, unknown> = {}, signal = new AbortController().signal) {
|
||||
const node: FlowNode = { id: "regression", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
|
||||
return getHandler(id)!({ signal, log: vi.fn(), vars: {} } as unknown as RunContext, node, args, {} as CatalogComponent);
|
||||
}
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); });
|
||||
|
||||
describe("应用与系统组件缺陷回归", () => {
|
||||
it("没有匹配进程时返回 [] / null,忽略空行和非法 PID", async () => {
|
||||
mocks.powershell.mockResolvedValue("");
|
||||
await expect(run("App_GetPids", { pattern: "missing.exe" })).resolves.toEqual([]);
|
||||
await expect(run("App_GetPid", { pattern: "missing.exe" })).resolves.toBeNull();
|
||||
mocks.powershell.mockResolvedValue("\r\n0\r\n123\r\nabc\r\n456\r\n");
|
||||
await expect(run("App_GetPids", { pattern: "fixture.exe" })).resolves.toEqual([123, 456]);
|
||||
});
|
||||
|
||||
it("毫秒等待读取 mileSecond,计时和取消均正确", async () => {
|
||||
vi.useFakeTimers();
|
||||
const waiting = Promise.resolve(run("Basic_Delay", { mileSecond: "250" }));
|
||||
let done = false;
|
||||
void waiting.then(() => { done = true; });
|
||||
await vi.advanceTimersByTimeAsync(249);
|
||||
expect(done).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(done).toBe(true);
|
||||
const controller = new AbortController();
|
||||
const cancelled = Promise.resolve(run("Basic_Delay", { mileSecond: "3000" }, controller.signal));
|
||||
const rejection = expect(cancelled).rejects.toMatchObject({ code: "cancelled" });
|
||||
controller.abort();
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it.each([
|
||||
[42, "number", true], ["42", "number", false], [42, "1", true],
|
||||
[[], "list", true], [{ key: 1 }, "dict", true], [null, "null", true],
|
||||
[false, "bool", true], ["中文", "string", true], [3.5, "int", false]
|
||||
])("按目标类型判断 %j → %s", (val, into_type, expected) => {
|
||||
expect(run("Basic_JudgeDataType", { val, into_type })).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["42.5", "number", 42.5], ["42", "1", 42], ["-3.9", "int", -3],
|
||||
["false", "bool", false], ["true", "bool", true], [42, "string", "42"]
|
||||
])("执行类型转换 %j → %s", (val, into_type, expected) => {
|
||||
expect(run("Basic_DataTypeConversion", { val, into_type })).toBe(expected);
|
||||
});
|
||||
|
||||
it("非法数值或未知目标类型给出定位错误,避免静默返回原值", () => {
|
||||
expect(() => run("Basic_DataTypeConversion", { val: "abc", into_type: "int" })).toThrow();
|
||||
expect(() => run("Basic_DataTypeConversion", { val: 1, into_type: "unknown" })).toThrow();
|
||||
expect(() => run("Basic_JudgeDataType", { val: 1, into_type: "unknown" })).toThrow();
|
||||
});
|
||||
|
||||
it("关闭组件按所选模式执行,非法 PID 不降级为按名称批量关闭", async () => {
|
||||
mocks.powershell.mockResolvedValue("");
|
||||
await expect(run("App_KillV1", { close_type: "id", pid: "invalid", p_name: "fixture.exe" })).rejects.toMatchObject({ code: "validation_error" });
|
||||
expect(mocks.powershell).not.toHaveBeenCalled();
|
||||
await run("App_KillV1", { close_type: "name", pid: 123, p_name: "fixture.exe", kill_all: "0" });
|
||||
expect(mocks.powershell.mock.calls[0][0]).toContain("SessionId");
|
||||
expect(mocks.powershell.mock.calls[0][0]).not.toContain("Stop-Process -Id 123");
|
||||
});
|
||||
|
||||
it("唤醒不声称已通过 Windows 登录解锁", async () => {
|
||||
mocks.execFile.mockImplementation((_file, _args, _opts, callback) => callback(null, { stdout: "", stderr: "" }));
|
||||
await expect(run("ScreenControl_ScreenUnlock")).resolves.toMatchObject({ awakened: true, loginUnlockSupported: false });
|
||||
});
|
||||
|
||||
it("屏保自定义密码不受支持时,在触发桌面操作前明确报错", async () => {
|
||||
await expect(run("System_ScreenSaverShowV1", { set_password: "1", password: "" })).rejects.toMatchObject({ code: "validation_error" });
|
||||
expect(mocks.execFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@ describe("capability registration smoke", () => {
|
||||
const execute = getHandler("System_ExecuteCommand");
|
||||
expect(execute).toBeTruthy();
|
||||
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output smoke" }, component)).resolves.toContain("smoke");
|
||||
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output '中文输出测试'" }, component)).resolves.toBe("中文输出测试");
|
||||
});
|
||||
|
||||
it("finds an exact PNG template inside a larger screenshot", async () => {
|
||||
|
||||
@@ -30,12 +30,21 @@ register("Basic_Delay_V2", async (ctx, node, args) => {
|
||||
return seconds;
|
||||
});
|
||||
|
||||
register(["Basic_Delay_V1", "Basic_Delay"], async (ctx, node, args) => {
|
||||
register("Basic_Delay_V1", async (ctx, node, args) => {
|
||||
const seconds = toNumber(args.second ?? args.time ?? 1, 1);
|
||||
await sleep(Math.max(0, seconds) * 1000, ctx.signal);
|
||||
return seconds;
|
||||
});
|
||||
|
||||
register("Basic_Delay", async (ctx, node, args) => {
|
||||
const milliseconds = Number(args.mileSecond ?? 1000);
|
||||
if (!Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > 2_147_483_647) {
|
||||
throw new RuntimeError("validation_error", "延时时间必须是 0~2147483647 范围内的毫秒数", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
await sleep(milliseconds, ctx.signal);
|
||||
return milliseconds / 1000;
|
||||
});
|
||||
|
||||
register("Basic_DebugOutput", (ctx, node, args) => {
|
||||
const message = String(args.msg ?? args.message ?? "");
|
||||
ctx.log(message || "(空)", "info", node.id);
|
||||
|
||||
@@ -498,12 +498,30 @@ register("Basic_Type", (_ctx, _node, args) => {
|
||||
if (Array.isArray(value)) return "list";
|
||||
return typeof value;
|
||||
});
|
||||
register("Basic_JudgeDataType", (_ctx, _node, args) => {
|
||||
// 原始目录缺少选项映射。新界面使用语义值;数字别名兼容此前默认的 "1"。
|
||||
const JUDGE_TYPE_ALIASES: Record<string, string> = { "1": "number", "2": "null", "3": "dict", "4": "string", "5": "list", "6": "bool", "7": "int" };
|
||||
const CONVERT_TYPE_ALIASES: Record<string, string> = { "1": "number", "2": "int", "3": "bool", "4": "string" };
|
||||
|
||||
function targetType(raw: unknown, aliases: Record<string, string>, supported: string[], nodeId: string, componentId: string): string {
|
||||
const text = String(raw ?? "number").trim().toLowerCase();
|
||||
const synonyms: Record<string, string> = { float: "number", boolean: "bool", str: "string", text: "string", array: "list", dictionary: "dict", object: "dict", empty: "null" };
|
||||
const type = aliases[text] || synonyms[text] || text;
|
||||
if (!supported.includes(type)) throw new RuntimeError("validation_error", "目标数据类型无效,请重新选择类型", { nodeId, componentId });
|
||||
return type;
|
||||
}
|
||||
|
||||
register("Basic_JudgeDataType", (_ctx, node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (Array.isArray(value)) return "list";
|
||||
if (typeof value === "object") return "dict";
|
||||
return typeof value;
|
||||
const type = targetType(args.into_type, JUDGE_TYPE_ALIASES, ["number", "null", "dict", "string", "list", "bool", "int"], node.id, node.componentId);
|
||||
switch (type) {
|
||||
case "number": return typeof value === "number" && Number.isFinite(value);
|
||||
case "int": return typeof value === "number" && Number.isInteger(value);
|
||||
case "null": return value === null || value === undefined || value === "";
|
||||
case "dict": return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
case "string": return typeof value === "string";
|
||||
case "list": return Array.isArray(value);
|
||||
case "bool": return typeof value === "boolean";
|
||||
}
|
||||
});
|
||||
register("Basic_Clone", (_ctx, _node, args) => structuredClone(first(args, ["val"])));
|
||||
register("Basic_RandomInt", (_ctx, _node, args) => {
|
||||
@@ -512,4 +530,23 @@ register("Basic_RandomInt", (_ctx, _node, args) => {
|
||||
return Math.floor(min + Math.random() * (max - min + 1));
|
||||
});
|
||||
register("Basic_Rnd", () => Math.random());
|
||||
register("Basic_DataTypeConversion", (_ctx, _node, args) => first(args, ["val"]));
|
||||
register("Basic_DataTypeConversion", (_ctx, node, args) => {
|
||||
const value = first(args, ["val"]);
|
||||
const type = targetType(args.into_type, CONVERT_TYPE_ALIASES, ["number", "int", "bool", "string"], node.id, node.componentId);
|
||||
if (type === "string") return str(value);
|
||||
if (type === "bool") {
|
||||
if (value == null || value === "") return false;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value !== 0;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (["true", "1", "是", "开启"].includes(text)) return true;
|
||||
if (["false", "0", "否", "关闭"].includes(text)) return false;
|
||||
throw new RuntimeError("validation_error", "输入值无法转换为布尔类型", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
if ((typeof value !== "number" && typeof value !== "string") || (typeof value === "string" && !value.trim()) || !Number.isFinite(Number(value))) {
|
||||
throw new RuntimeError("validation_error", "输入值不是有效数字", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
const result = type === "int" ? Math.trunc(Number(value)) : Number(value);
|
||||
if (type === "int" && !Number.isSafeInteger(result)) throw new RuntimeError("validation_error", "转换后的整数超出安全范围", { nodeId: node.id, componentId: node.componentId });
|
||||
return result;
|
||||
});
|
||||
@@ -80,7 +80,9 @@ function encoded(command: string): string {
|
||||
|
||||
export async function powershell(command: string, timeout = 30000): Promise<string> {
|
||||
try {
|
||||
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(command)], {
|
||||
// Windows PowerShell 的默认输出编码依机器代码页变化;Node 按 UTF-8 解码管道。
|
||||
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
|
||||
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(utf8 + command)], {
|
||||
windowsHide: true,
|
||||
timeout,
|
||||
maxBuffer: 4 * 1024 * 1024
|
||||
|
||||
@@ -75,21 +75,30 @@ register("System_GetSystemPath", (_ctx, _node, args) => {
|
||||
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);
|
||||
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
||||
.map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0);
|
||||
};
|
||||
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) {
|
||||
const mode = node.componentId === "App_Kill" ? "id" : value(args, ["close_type"], "id");
|
||||
if (mode === "id") {
|
||||
const rawPid = args.pid;
|
||||
const pid = typeof rawPid === "number" || typeof rawPid === "string" ? Number(rawPid) : NaN;
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
throw new RuntimeError("validation_error", "进程 PID 必须是正整数", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
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`);
|
||||
if (mode !== "name") throw new RuntimeError("validation_error", "请选择按 PID 或按进程名称关闭", { nodeId: node.id, componentId: node.componentId });
|
||||
const pattern = processPattern(required(args, ["p_name", "pattern"], "进程名称", node.id, node.componentId));
|
||||
if (pattern === "*") throw new RuntimeError("validation_error", "关闭应用程序必须指定具体的进程名称", { nodeId: node.id, componentId: node.componentId });
|
||||
const allSessions = [true, 1, "1", "true", "True"].includes(args.kill_all as never);
|
||||
const session = allSessions ? "" : " -and $_.SessionId -eq [System.Diagnostics.Process]::GetCurrentProcess().SessionId";
|
||||
await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} -and $_.Id -ne ${process.pid}${session} } | Stop-Process -Force -ErrorAction Stop`);
|
||||
return pattern;
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,20 @@ describe("utility extended capabilities", () => {
|
||||
await expect(run("System_ExecuteCommandV1", args)).resolves.toContain("utility-ok");
|
||||
});
|
||||
|
||||
it("executes BAT files whose path contains spaces and Chinese characters", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-中文 空格-"));
|
||||
directories.push(directory);
|
||||
const file = path.join(directory, "测试 脚本.bat");
|
||||
await writeFile(file, "@echo RPA-BAT-PATH-OK\r\n", "ascii");
|
||||
await expect(run("System_ExecuteCommandV1", { command_type: "2", bat_file: file, get_ret: "1" })).resolves.toBe("RPA-BAT-PATH-OK");
|
||||
});
|
||||
|
||||
it("keeps PowerShell command output in UTF-8", async () => {
|
||||
if (process.platform !== "win32") return;
|
||||
await expect(run("System_ExecuteCommandV1", { command_type: "3", ps_command: "Write-Output '中文输出测试'", get_ret: "1" })).resolves.toBe("中文输出测试");
|
||||
});
|
||||
|
||||
it("compares local text files with structured line changes", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-utility-"));
|
||||
directories.push(directory);
|
||||
|
||||
@@ -252,6 +252,7 @@ register("Basic_GetProcessInfo", (ctx) => ({
|
||||
|
||||
register("System_ExecuteCommandV1", async (_ctx, node, args) => {
|
||||
const mode = String(args.command_type || "1");
|
||||
if (!["1", "2", "3"].includes(mode)) throw new RuntimeError("validation_error", "请选择 DOS、BAT 或 PowerShell 命令类型", { nodeId: node.id, componentId: node.componentId });
|
||||
const cwd = String(args.cwd || "").trim() || undefined;
|
||||
const timeout = Math.max(1_000, Number(args.timeoutMs || 120_000));
|
||||
let executable: string;
|
||||
@@ -264,7 +265,9 @@ register("System_ExecuteCommandV1", async (_ctx, node, args) => {
|
||||
commandArgs = ["/d", "/s", "/c", `call "${file}"`];
|
||||
} else if (mode === "3") {
|
||||
executable = "powershell.exe";
|
||||
commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId)];
|
||||
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
|
||||
const script = utf8 + requiredText(args.ps_command, "PowerShell命令", node.id, node.componentId);
|
||||
commandArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
|
||||
} else {
|
||||
executable = process.env.ComSpec || "cmd.exe";
|
||||
commandArgs = ["/d", "/s", "/c", requiredText(args.dos_command, "DOS命令", node.id, node.componentId)];
|
||||
@@ -276,7 +279,8 @@ register("System_ExecuteCommandV1", async (_ctx, node, args) => {
|
||||
executable = "/bin/sh";
|
||||
commandArgs = ["-lc", command];
|
||||
}
|
||||
const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, maxBuffer: 10 * 1024 * 1024 });
|
||||
// cmd.exe 自己解析 /c 后的命令;不能让 Node 再把 BAT 路径引号转成反斜杠引号。
|
||||
const result = await execFileAsync(executable, commandArgs, { cwd, timeout, windowsHide: true, windowsVerbatimArguments: process.platform === "win32" && mode !== "3", maxBuffer: 10 * 1024 * 1024 });
|
||||
return bool(args.get_ret, true) ? String(result.stdout).trimEnd() : true;
|
||||
});
|
||||
|
||||
|
||||
@@ -22,13 +22,16 @@ register("ScreenControl_Screenlock", async () => {
|
||||
register("ScreenControl_ScreenUnlock", async () => {
|
||||
// 仅唤醒屏幕/解除屏保;系统锁屏需人工输入密码,无法通用自动解锁
|
||||
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, UIntPtr e);' -Name W32U -Namespace WinRpa | Out-Null; [WinRpa.W32U]::mouse_event(0x0001, 1, 1, 0, [UIntPtr]::Zero); Start-Sleep -Milliseconds 200; [WinRpa.W32U]::mouse_event(0x0001, 0xFFFFFFFF, 0, 0, [UIntPtr]::Zero)");
|
||||
return { awakened: true, note: "已唤醒屏幕;锁屏需人工输入密码" };
|
||||
return { awakened: true, loginUnlockSupported: false, note: "仅尝试唤醒屏幕;Windows 登录锁屏仍需本人手动解锁" };
|
||||
});
|
||||
|
||||
const SC_SCREENSAVE = 0xF140;
|
||||
const WM_SYSCOMMAND = 0x0112;
|
||||
register(["System_ScreenSaverShow", "System_ScreenSaverShowV1"], async () => {
|
||||
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr FindWindow(string cls, string win); [DllImport(\"user32.dll\")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32S -Namespace WinRpa | Out-Null; [WinRpa.W32S]::SendMessage([IntPtr]0xFFFF, [UIntPtr]0x0112, [IntPtr]0xF140, [IntPtr]::Zero) | Out-Null");
|
||||
register(["System_ScreenSaverShow", "System_ScreenSaverShowV1"], async (_ctx, node, args) => {
|
||||
if ([true, 1, "1", "true", "True"].includes(args.set_password as never)) {
|
||||
throw new RuntimeError("validation_error", "系统屏保不支持设置自定义解锁密码,请关闭该选项并使用 Windows 原有登录保护", { nodeId: node.id, componentId: node.componentId });
|
||||
}
|
||||
await ps("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);' -Name W32S -Namespace WinRpa | Out-Null; [WinRpa.W32S]::SendMessage([IntPtr]0xFFFF, [uint32]0x0112, [IntPtr]0xF140, [IntPtr]::Zero) | Out-Null");
|
||||
return { screensaver: true };
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { Catalog, CatalogField, FlowNode, FlowVariable } from "@shared/type
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { groupIcon } from "../components/icons";
|
||||
import { containerSlots } from "../canvas/ContainerNode";
|
||||
import { fieldIsActive } from "../validation/flowValidation";
|
||||
|
||||
type Tab = "props" | "vars" | "run";
|
||||
|
||||
@@ -247,8 +248,9 @@ function PropertyTab({
|
||||
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||
}) {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const basic = component.fields.filter((field) => !field.advanced && field.widget !== "hidden");
|
||||
const advanced = component.fields.filter((field) => field.advanced && field.widget !== "hidden");
|
||||
const active = component.fields.filter((field) => field.widget !== "hidden" && fieldIsActive(component.id, field.key, node.values));
|
||||
const basic = active.filter((field) => !field.advanced);
|
||||
const advanced = active.filter((field) => field.advanced);
|
||||
const slots = component.slots.length > 0 ? containerSlots(component) : [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Catalog, FlowDefinition } from "@shared/types";
|
||||
import { validateFlow } from "./flowValidation";
|
||||
import { fieldIsActive, validateFlow } from "./flowValidation";
|
||||
|
||||
const catalog = {
|
||||
schemaVersion: 1, generatedAt: "", counts: { total: 2, vendor: 1, native: 0, triggers: 1 }, groups: [],
|
||||
@@ -20,6 +20,24 @@ function flow(): FlowDefinition {
|
||||
}
|
||||
|
||||
describe("flow validation", () => {
|
||||
it("关闭程序按模式仅显示并校验 PID 或进程名称", () => {
|
||||
expect(fieldIsActive("App_KillV1", "pid", { close_type: "id" })).toBe(true);
|
||||
expect(fieldIsActive("App_KillV1", "p_name", { close_type: "id" })).toBe(false);
|
||||
expect(fieldIsActive("App_KillV1", "pid", { close_type: "name" })).toBe(false);
|
||||
expect(fieldIsActive("App_KillV1", "p_name", { close_type: "name" })).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["1", "2", "3"])("命令模式 %s 仅显示并校验当前命令输入", (mode) => {
|
||||
const fields = ["dos_command", "bat_file", "ps_command"];
|
||||
const active = fields.filter(key => fieldIsActive("System_ExecuteCommandV1", key, { command_type: mode }));
|
||||
expect(active).toEqual([fields[Number(mode) - 1]]);
|
||||
});
|
||||
|
||||
it("旧屏保元数据不再要求未启用的密码", () => {
|
||||
expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "0" })).toBe(false);
|
||||
expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("reports pending components, required fields, and disconnected nodes", () => {
|
||||
const issues = validateFlow(flow(), catalog);
|
||||
expect(issues).toEqual(expect.arrayContaining([
|
||||
|
||||
@@ -15,7 +15,25 @@ function truthy(value: unknown): boolean {
|
||||
return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never);
|
||||
}
|
||||
|
||||
/** 属性显示与必填校验使用相同的模式条件,切换模式时保留未激活字段的旧值。 */
|
||||
export function fieldIsActive(componentId: string, key: string, values: Record<string, unknown>): boolean {
|
||||
if (componentId === "App_KillV1") {
|
||||
const mode = String(values.close_type ?? "id");
|
||||
if (key === "pid") return mode === "id";
|
||||
if (key === "p_name" || key === "kill_all") return mode === "name";
|
||||
}
|
||||
if (componentId === "System_ExecuteCommandV1") {
|
||||
const mode = String(values.command_type ?? "1");
|
||||
if (["dos_command", "bat_file", "ps_command"].includes(key)) {
|
||||
return key === ({ "1": "dos_command", "2": "bat_file", "3": "ps_command" } as Record<string, string>)[mode];
|
||||
}
|
||||
}
|
||||
if (componentId === "System_ScreenSaverShowV1" && key === "password") return truthy(values.set_password);
|
||||
return true;
|
||||
}
|
||||
|
||||
function requiredInContext(componentId: string, key: string, values: Record<string, unknown>): boolean {
|
||||
if (!fieldIsActive(componentId, key, values)) return false;
|
||||
if (key === "designated_obj" && !truthy(values.designated_info)) return false;
|
||||
if (key === "cvSelector" && String(values.choose ?? "0") !== "1") return false;
|
||||
if (componentId === "Image_Screenshot" && ["image_path", "outImagePath", "outImageName"].includes(key)) return false;
|
||||
|
||||
Reference in new issue
Block a user