fix: 修复质量检查发现的选择器与资源问题

修复浏览器通配符过滤和 ARIA 选择器转义,收敛表单窗口生命周期,释放邮件连接,清理日志复制定时器,并校准 Word COM 测试超时。
This commit is contained in:
yu 2026-10-30 10:51:27 +08:00
parent 745da55e4e
commit cc28d49ab5
7 changed files with 70 additions and 43 deletions

View File

@ -162,6 +162,10 @@ export class BrowserSession implements BrowserLike {
const spec = selectorSpec(selector);
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
const spec = ${JSON.stringify(spec)};
const escapeSelectorValue = (value) => {
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => '\\\\' + char);
};
const find = () => {
if (spec.kind === 'xpath') {
return document.evaluate(spec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
@ -175,7 +179,7 @@ export class BrowserSession implements BrowserLike {
return matches.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length)[0] || null;
}
if (spec.kind === 'aria') {
const role = spec.role || spec.value;
const role = escapeSelectorValue(spec.role || spec.value);
const name = spec.name || '';
return [...document.querySelectorAll('[role="' + role + '"], ' + role)].find((node) => {
const accessibleName = node.getAttribute('aria-label') || node.innerText || '';
@ -850,7 +854,7 @@ export class BrowserSession implements BrowserLike {
if (options.url) {
const keyword = options.url;
captures = options.wildcard
? captures.filter(c => new RegExp("^" + keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\ async currentUrl(): Promise<string> {").replace(/\*/g, ".*")).test(c.url))
? captures.filter(c => new RegExp("^" + keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")).test(c.url))
: captures.filter(c => c.url.includes(keyword));
}
const result: Array<Record<string, unknown>> = [];

View File

@ -11,19 +11,18 @@ function inputText(args: Record<string, unknown>, nodeId: string): string {
}
async function readMaybeFile(value: string): Promise<string> {
if (/\.(txt|md|csv|json|log)$/i.test(value) && value.includes("/") && value.length < 260) {
try {
const fs = await import("node:fs");
if (fs.existsSync(value)) return fs.readFileSync(value, "utf8");
} catch { /* not a file */ }
}
const looksLikeFile = /\.(txt|md|csv|json|log)$/i.test(value) && value.length < 260 && !value.includes("\n");
if (!looksLikeFile) return value;
try {
const fs = await import("node:fs/promises");
const stat = await fs.stat(value);
if (stat.isFile()) return await fs.readFile(value, "utf8");
} catch { /* treat the value as literal text */ }
return value;
}
async function complete(prompt: string, input: string, nodeId: string, temperature = 0.3): Promise<string> {
if (!(await Promise.resolve(true))) return "";
void nodeId;
const config = requireAiConfig();
async function complete(prompt: string, input: string, temperature = 0.3): Promise<string> {
requireAiConfig();
return chatCompletion([
{ role: "system", content: prompt },
{ role: "user", content: input }
@ -49,7 +48,7 @@ for (const task of TEXT_TASKS) {
register(task.ids, async (ctx, node, args) => {
const rawInput = inputText(args, node.id);
const input = await readMaybeFile(rawInput);
const result = await complete(task.system, input, node.id);
const result = await complete(task.system, input);
ctx.log(task.task + "完成", "info", node.id);
return result;
});

View File

@ -59,16 +59,30 @@ async function showForm(options: { title: string; fields: FormFieldSpec[]; timeo
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }
});
let settled = false;
const timer = setInterval(async () => {
let timer: NodeJS.Timeout | undefined;
let timeout: NodeJS.Timeout | undefined;
const finish = (value: Record<string, string>) => {
if (settled) return;
settled = true;
if (timer) clearInterval(timer);
if (timeout) clearTimeout(timeout);
if (!win.isDestroyed()) win.destroy();
resolve(value);
};
timer = setInterval(async () => {
try {
const values = await win.webContents.executeJavaScript("window.__formValues || null", true);
if (values) { clearInterval(timer); win.destroy(); resolve(values as Record<string, string>); }
} catch { clearInterval(timer); resolve({}); }
if (values) finish(values as Record<string, string>);
} catch {
finish({});
}
}, 250);
const timeout = setTimeout(() => {
try { void win.webContents.executeJavaScript("document.getElementById(\"ok\").click()"); } catch {}
timeout = setTimeout(() => {
try { void win.webContents.executeJavaScript("document.getElementById(\"ok\").click()"); } catch {
finish({});
}
}, Math.max(5, options.timeoutSec) * 1000);
win.on("closed", () => { clearInterval(timer); clearTimeout(timeout); resolve({}); });
win.on("closed", () => finish({}));
void win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html));
});
}

View File

@ -27,14 +27,18 @@ register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], a
const port = Number(text({ ...configured, ...args }, ["port", "smtpPort"], "25"));
const secure = ["true", "1", "是", "True"].includes(text({ ...configured, ...args }, ["is_ssl", "secure", "ssl"], "False"));
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
const result = await transporter.sendMail({
from: account,
to: list(text(args, ["to", "receiver"])),
cc: list(text(args, ["Cc", "cc"])),
bcc: list(text(args, ["Bcc", "bcc"])),
subject: text(args, ["subject"]),
text: text(args, ["content", "body"]),
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
});
return { messageId: result.messageId, accepted: result.accepted };
try {
const result = await transporter.sendMail({
from: account,
to: list(text(args, ["to", "receiver"])),
cc: list(text(args, ["Cc", "cc"])),
bcc: list(text(args, ["Bcc", "bcc"])),
subject: text(args, ["subject"]),
text: text(args, ["content", "body"]),
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
});
return { messageId: result.messageId, accepted: result.accepted };
} finally {
transporter.close();
}
});

View File

@ -32,5 +32,5 @@ describe("Word extended capabilities", () => {
await run("Word_SetFontStyle", { objWord: handle, isBold: "True", isItalic: "False", isUnderline: "False" });
await run("Word_Enter", { objWord: handle });
expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000);
}, 60000);
}, 120000);
});

View File

@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { app, BrowserWindow, screen } from "electron";
import { RuntimeError } from "./engine/types";
@ -31,7 +31,7 @@ export async function pickDesktopElement(): Promise<DesktopPickResult> {
const appWindows = BrowserWindow.getAllWindows().filter(w => !w.isDestroyed() && w.isVisible());
for (const w of appWindows) w.minimize();
const { stdout } = await run("powershell.exe", ["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath()], { timeout: 180000, windowsHide: false, maxBuffer: 4 * 1024 * 1024 });
const jsonLine = stdout.trim().split(/\\r?\\n/).reverse().find(l => l.trim().startsWith("{"));
const jsonLine = stdout.trim().split(/\r?\n/).reverse().find(l => l.trim().startsWith("{"));
if (!jsonLine) throw new RuntimeError("picker_failed", "拾取未返回结果");
const parsed = JSON.parse(jsonLine) as Omit<DesktopPickResult, "selector">;
if (parsed.cancelled) return { ...parsed, selector: "" };
@ -45,7 +45,7 @@ export async function pickDesktopElement(): Promise<DesktopPickResult> {
export async function listVisibleWindows(): Promise<Array<{ title: string; process: string }>> {
const { stdout } = await run("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", pickerScriptPath(), "-Mode", "windows"], { timeout: 30000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
const jsonLine = stdout.trim().split(/\\r?\\n/).reverse().find(l => l.trim().startsWith("["));
const jsonLine = stdout.trim().split(/\r?\n/).reverse().find(l => l.trim().startsWith("["));
if (!jsonLine) return [];
return JSON.parse(jsonLine) as Array<{ title: string; process: string }>;
}
@ -93,17 +93,17 @@ export async function pickScreenRegion(): Promise<{ x: number; y: number; width:
win.destroy();
if (!region) throw new RuntimeError("pick_cancelled", "未完成框选");
await new Promise(r => setTimeout(r, 300));
const outDir = "D:/RPA/test-output";
const outDir = path.join(app.getPath("userData"), "region-picks");
fs.mkdirSync(outDir, { recursive: true });
const imagePath = outDir + "/pick-region-" + Date.now() + ".png";
const imagePath = path.join(outDir, `pick-region-${Date.now()}.png`);
const psScript = [
"Add-Type -AssemblyName System.Drawing | Out-Null;",
" = New-Object System.Drawing.Bitmap " + region.width + ", " + region.height + ";",
" = [System.Drawing.Graphics]::FromImage();",
".CopyFromScreen(" + region.x + ", " + region.y + ", 0, 0, .Size);",
".Dispose();",
".Save(" + JSON.stringify(imagePath) + ");",
".Dispose();",
"$bitmap = New-Object System.Drawing.Bitmap " + region.width + ", " + region.height + ";",
"$graphics = [System.Drawing.Graphics]::FromImage();",
"$graphics.CopyFromScreen(" + region.x + ", " + region.y + ", 0, 0, $bitmap.Size);",
"$graphics.Dispose();",
"$bitmap.Save(" + JSON.stringify(imagePath) + ");",
"$bitmap.Dispose();",
"Write-Output ok"
].join("");
await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", psScript], { timeout: 30000, windowsHide: true });

View File

@ -1,4 +1,4 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ArrowDown, Check, Copy, Download } from "lucide-react";
import type { RunLog } from "../store/runStore";
@ -25,6 +25,11 @@ export function RunLogView({ logs, nameOf, fileName = "rpa-run-logs.log", emptyT
const [pinned, setPinned] = useState(true);
const consoleRef = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const copiedTimerRef = useRef<number>();
useEffect(() => () => {
if (copiedTimerRef.current !== undefined) window.clearTimeout(copiedTimerRef.current);
}, []);
const counts = useMemo(() => ({
all: logs.length,
@ -66,7 +71,8 @@ export function RunLogView({ logs, nameOf, fileName = "rpa-run-logs.log", emptyT
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
if (copiedTimerRef.current !== undefined) window.clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1600);
} catch {
// Clipboard access can be blocked by system policy; download remains available.
}