Compare commits
7
Commits
a5d9f0bfee
...
afb2ed7c08
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afb2ed7c08 | ||
|
|
38a561a7cd | ||
|
|
d6165c910c | ||
|
|
4c84674b29 | ||
|
|
560134171f | ||
|
|
9b8bc0eb1a | ||
|
|
67a418129b |
No files matched your search
@@ -7,3 +7,6 @@ etest2/
|
||||
*.tmp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
test-output/
|
||||
web-capability-audit.txt
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>网页元素演练场</title>
|
||||
<style>
|
||||
body { font-family: "Microsoft YaHei", sans-serif; margin: 24px; color: #1f2937; }
|
||||
.card { border: 2px solid #2563eb; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
#result { margin-top: 12px; padding: 12px; background: #f0fdf4; border-radius: 6px; min-height: 24px; }
|
||||
#hover-area { width: 240px; height: 60px; background: #e5e7eb; line-height: 60px; text-align: center; border-radius: 6px; }
|
||||
#hover-area:hover { background: #fde68a; }
|
||||
#spacer { height: 1600px; background: linear-gradient(#ffffff, #dbeafe); }
|
||||
#bottom-area { padding: 20px; background: #fee2e2; border-radius: 8px; }
|
||||
label { margin-right: 16px; }
|
||||
button { padding: 6px 16px; }
|
||||
button:disabled { color: #9ca3af; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>网页元素演练场</h1>
|
||||
<div class="card" id="card">
|
||||
<h2>基础表单</h2>
|
||||
<label>名称 <input id="inp-name" placeholder="请输入名称" /></label><br/><br/>
|
||||
<label>密码 <input id="inp-pass" type="password" /></label><br/><br/>
|
||||
<label>城市
|
||||
<select id="sel-city">
|
||||
<option value="bj">北京</option>
|
||||
<option value="sh">上海</option>
|
||||
<option value="gz">广州</option>
|
||||
</select>
|
||||
</label><br/><br/>
|
||||
<label><input type="checkbox" id="chk-agree" /> 同意条款</label>
|
||||
<label><input type="radio" name="plan" id="rd-basic" checked /> 基础版</label>
|
||||
<label><input type="radio" name="plan" id="rd-pro" /> 专业版</label><br/><br/>
|
||||
<button id="btn-submit" onclick="document.getElementById('result').textContent = '已提交:' + document.getElementById('inp-name').value + ' / 城市:' + document.getElementById('sel-city').value + ' / 同意:' + document.getElementById('chk-agree').checked">提交</button>
|
||||
<button id="btn-disabled" disabled>禁用按钮</button>
|
||||
<div id="result">等待提交…</div>
|
||||
</div>
|
||||
<div class="card" id="attr-card">
|
||||
<h2>属性测试</h2>
|
||||
<div id="attr-target" data-info="hello-rpa" title="属性目标">属性目标元素</div><br/>
|
||||
<input type="file" id="file-input" />
|
||||
</div>
|
||||
<div id="hover-area">悬停我变色</div>
|
||||
<div id="spacer"></div>
|
||||
<div id="bottom-area" class="card">页底区域(滚动测试目标)</div>
|
||||
</body></html>
|
||||
+7
-1
@@ -49,7 +49,13 @@ async function verifyLaunch(executable, label) {
|
||||
const child = spawn(executable, [`--user-data-dir=${userData}`], {
|
||||
cwd: path.dirname(executable),
|
||||
stdio: "ignore",
|
||||
windowsHide: false
|
||||
windowsHide: false,
|
||||
env: {
|
||||
...process.env,
|
||||
RPA_SKIP_SINGLE_INSTANCE: "1",
|
||||
RPA_E2E: "1",
|
||||
RPA_FIXTURE_PORT: String(18000 + Math.floor(Math.random() * 1000))
|
||||
}
|
||||
});
|
||||
await delay(8000);
|
||||
const exited = child.exitCode !== null || child.signalCode !== null;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const executable = process.argv[2];
|
||||
if (!executable) {
|
||||
console.error("usage: node scripts/spawn-test.mjs <exe> [...args]");
|
||||
process.exit(2);
|
||||
}
|
||||
const env = { ...process.env };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const child = spawn(executable, process.argv.slice(3), {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: false
|
||||
});
|
||||
|
||||
child.stdout.on("data", (data) => process.stdout.write(`[out] ${data}`));
|
||||
child.stderr.on("data", (data) => process.stderr.write(`[err] ${data}`));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
console.log(`STILL_RUNNING pid=${child.pid}`);
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setTimeout(() => process.exit(0), 1000);
|
||||
}, 9000);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
console.log(`EXIT code=${code} signal=${signal}`);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
console.error(`SPAWN_ERROR ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -768,6 +768,58 @@ export class BrowserSession implements BrowserLike {
|
||||
return result.items;
|
||||
}
|
||||
|
||||
maximize(): void {
|
||||
if (!this.win.isDestroyed()) this.win.maximize();
|
||||
}
|
||||
|
||||
minimize(): void {
|
||||
if (!this.win.isDestroyed()) this.win.minimize();
|
||||
}
|
||||
|
||||
stopLoading(): void {
|
||||
if (!this.win.isDestroyed()) this.win.webContents.stop();
|
||||
}
|
||||
|
||||
async uploadFileToInput(selector: string, filePath: string): Promise<number> {
|
||||
const doc = await this.cdp<{ root?: { nodeId?: number } }>("DOM.getDocument");
|
||||
const rootNodeId = doc.root?.nodeId;
|
||||
if (!rootNodeId) throw new RuntimeError("browser_error", "无法定位页面 DOM 根节点");
|
||||
const css = selector.replace(/^css:/, "").trim();
|
||||
const found = await this.cdp<{ nodeId?: number }>("DOM.querySelector", { nodeId: rootNodeId, selector: css });
|
||||
if (!found.nodeId) {
|
||||
throw new RuntimeError("element_not_found", "未找到文件输入元素:" + css, { recommendation: "确认 input[type=file] 存在且已渲染" });
|
||||
}
|
||||
await this.cdp("DOM.setFileInputFiles", { files: [filePath], nodeId: found.nodeId });
|
||||
return found.nodeId;
|
||||
}
|
||||
|
||||
startAutoCloseDialogs(): boolean {
|
||||
if (!this.win.isDestroyed() && !this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3");
|
||||
this.cdp("Page.enable").catch(() => {});
|
||||
const flags = globalThis as { __rpaAutoCloseDialogs?: boolean; __rpaAutoCloseDialogListener?: unknown };
|
||||
if (flags.__rpaAutoCloseDialogs) return false;
|
||||
flags.__rpaAutoCloseDialogs = true;
|
||||
const listener = (_event: unknown, method: string) => {
|
||||
if (method === "Page.javascriptDialogOpening") {
|
||||
this.cdp("Page.handleJavaScriptDialog", { accept: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
flags.__rpaAutoCloseDialogListener = listener;
|
||||
this.win.webContents.debugger.on("message", listener);
|
||||
return true;
|
||||
}
|
||||
|
||||
stopAutoCloseDialogs(): boolean {
|
||||
const flags = globalThis as { __rpaAutoCloseDialogs?: boolean; __rpaAutoCloseDialogListener?: unknown };
|
||||
if (!flags.__rpaAutoCloseDialogs) return false;
|
||||
if (flags.__rpaAutoCloseDialogListener) {
|
||||
this.win.webContents.debugger.removeListener("message", flags.__rpaAutoCloseDialogListener as never);
|
||||
}
|
||||
flags.__rpaAutoCloseDialogs = false;
|
||||
flags.__rpaAutoCloseDialogListener = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
async currentUrl(): Promise<string> {
|
||||
return this.win.webContents.getURL();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,15 @@ register("WebBrowser_GetUrl", async (ctx) => session(ctx).then((browser) => brow
|
||||
register("WebBrowser_GetDocument", async (ctx) => session(ctx).then((browser) => browser.runJs("document.documentElement.outerHTML")));
|
||||
register(["WebBrowser_GetScrollPos", "WebBrowser_GetScrollPos_V1"], async (ctx) => session(ctx).then((browser) => browser.runJs("({x:window.scrollX,y:window.scrollY})")));
|
||||
register(["WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1"], async (ctx, _node, args) => {
|
||||
const browser = await session(ctx); const x = Number(args.x ?? args.left ?? 0); const y = Number(args.y ?? args.top ?? args.position ?? 0); await browser.runJs(`window.scrollTo(${Number.isFinite(x) ? x : 0},${Number.isFinite(y) ? y : 0}); true`); return { x, y };
|
||||
const browser = await session(ctx);
|
||||
const raw = String(args.scrollpos ?? "").trim();
|
||||
let x = Number(args.x ?? args.left ?? 0);
|
||||
let y = Number(args.y ?? args.top ?? args.position ?? 0);
|
||||
if (raw) {
|
||||
if (raw.includes(",")) { const parts = raw.split(","); x = Number(parts[0]) || 0; y = Number(parts[1]) || 0; }
|
||||
else { y = Number(raw) || 0; }
|
||||
}
|
||||
await browser.runJs(`window.scrollTo(${Number.isFinite(x) ? x : 0},${Number.isFinite(y) ? y : 0}); true`); return { x, y };
|
||||
});
|
||||
|
||||
register(["WebBrowser_GetCookies", "WebBrowser_GetCookieNew"], async (ctx) => {
|
||||
@@ -53,3 +61,4 @@ register(["WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadF
|
||||
});
|
||||
|
||||
export const BROWSER_CONTROL_IDS = ["WebBrowser_Reload", "WebBrowser_GoBack", "WebBrowser_GoForward", "WebBrowser_GetTitle", "WebBrowser_GetUrl", "WebBrowser_GetDocument", "WebBrowser_GetScrollPos", "WebBrowser_GetScrollPos_V1", "WebBrowser_SetScrollPos", "WebBrowser_SetScrollPos_V1", "WebBrowser_GetCookies", "WebBrowser_GetCookieNew", "WebBrowser_GetCookie", "WebBrowser_SetCookies", "WebBrowser_SetCookies_V1", "WebBrowser_Wait", "WebBrowser_CloseWeb", "WebBrowser_CloseWeb_V1", "WebBrowser_CloseWeb_V2", "WebBrowser_Close_V1", "WebBrowser_GetBrowserPic", "WebBrowser_GetBrowserPicV1", "Element_ExistsV1", "Element_WaitElement_v5", "WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadFile", "WebBrowser_DownloadFile_V1"] as const;
|
||||
|
||||
@@ -18,7 +18,7 @@ function selectorOf(args: Record<string, unknown>): unknown {
|
||||
return selector;
|
||||
}
|
||||
|
||||
register(["WebBrowser_CreateV3", "WebBrowser_Create", "WebBrowser_CreateBackground"], async (ctx, node, args) => {
|
||||
register(["WebBrowser_CreateV3", "WebBrowser_CreateV2", "WebBrowser_Create", "WebBrowser_CreateBackground"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const url = String(args.url ?? "");
|
||||
if (url) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import "./browser-control";
|
||||
import "./office";
|
||||
import "./desktop";
|
||||
import "./desktop-uia";
|
||||
import "./web-batch2";
|
||||
import "./pdf";
|
||||
import "./word";
|
||||
import "./word-extended";
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { browserSession } from "../browser/session";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
function cssValue(selector: unknown): string {
|
||||
const raw = typeof selector === "object" && selector !== null
|
||||
? String((selector as { value?: string }).value ?? "")
|
||||
: String(selector ?? "");
|
||||
const text = raw.trim();
|
||||
return text.startsWith("css:") ? text.slice(4).trim() : text;
|
||||
}
|
||||
|
||||
function requireUploadPath(args: Record<string, unknown>): string {
|
||||
const filePath = String(args.file_path ?? args.filePath ?? args.path ?? "").trim();
|
||||
const folder = String(args.folder_path ?? args.folder ?? "").trim();
|
||||
const target = filePath || folder;
|
||||
if (!target) {
|
||||
throw new RuntimeError("validation_error", "上传文件路径为空", { recommendation: "填写要上传的本地文件完整路径" });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
register(["Element_FileUploader", "WebBrowser_Upload", "WebBrowser_UploadDialog"], async (ctx, node, args) => {
|
||||
const browser = await browserSession();
|
||||
const selector = cssValue(args.uiSelector ?? "css:input[type=file]") || "input[type=file]";
|
||||
const filePath = requireUploadPath(args);
|
||||
const nodeId = await browser.uploadFileToInput(selector, filePath);
|
||||
ctx.log("已设置上传文件:" + filePath, "info", node.id);
|
||||
return { uploaded: true, selector, filePath, nodeId };
|
||||
});
|
||||
|
||||
register(["WebBrowser_GetTAB", "WebBrowser_GetTAB_V1", "WebBrowser_GetTAB_V2"], async (ctx, node) => {
|
||||
const browser = await browserSession();
|
||||
const url = await browser.currentUrl();
|
||||
ctx.log("已获取标签页列表(单窗口模式)", "info", node.id);
|
||||
return [{ index: 0, url, active: true }];
|
||||
});
|
||||
|
||||
register("WebBrowser_Switch_V1", async (ctx, node, args) => {
|
||||
const browser = await browserSession();
|
||||
const url = await browser.currentUrl();
|
||||
ctx.log("运行浏览器为单窗口模式,当前即唯一标签页", "info", node.id);
|
||||
return { switched: true, index: 0, url, active: String(args.is_active ?? "1") === "1" };
|
||||
});
|
||||
|
||||
register("WebBrowser_Maximize", async (ctx, node) => {
|
||||
browser.maximize();
|
||||
return true;
|
||||
});
|
||||
|
||||
register("WebBrowser_Minimize", async (ctx, node) => {
|
||||
browser.minimize();
|
||||
return true;
|
||||
});
|
||||
|
||||
register("WebBrowser_Stop", async (ctx, node) => {
|
||||
browser.stopLoading();
|
||||
return true;
|
||||
});
|
||||
|
||||
register(["Element_ElementAarray", "Element_ElementCaptureSimilar"], async (ctx, node, args) => {
|
||||
const browser = await browserSession();
|
||||
const selector = args.uiSelector ?? args.selector ?? args.target;
|
||||
if (!selector) {
|
||||
throw new RuntimeError("validation_error", "元素选择器为空", { nodeId: node.id, componentId: node.componentId, recommendation: "请拾取或填写相似元素" });
|
||||
}
|
||||
const references = await browser.getSimilarElements(selector, {
|
||||
start: Number.isFinite(Number(args.start_index)) ? Math.trunc(Number(args.start_index)) : 1,
|
||||
end: Number.isFinite(Number(args.end_index)) ? Math.trunc(Number(args.end_index)) : -1,
|
||||
reverse: String(args.reverse ?? "") === "1" || String(args.reverse ?? "") === "true"
|
||||
});
|
||||
ctx.log("已获取 " + references.length + " 个相似元素(数组)", "info", node.id);
|
||||
return references;
|
||||
});
|
||||
|
||||
register("Element_StartAutoClosePopup", async (ctx, node) => {
|
||||
const browser = await browserSession();
|
||||
const started = browser.startAutoCloseDialogs();
|
||||
ctx.log(started ? "弹窗自动关闭已开启" : "弹窗自动关闭已在运行中", "info", node.id);
|
||||
return { active: true, started };
|
||||
});
|
||||
|
||||
register("Element_StopAutoClosePopup", async (ctx, node) => {
|
||||
const browser = await browserSession();
|
||||
const stopped = browser.stopAutoCloseDialogs();
|
||||
ctx.log(stopped ? "弹窗自动关闭已停止" : "弹窗自动关闭未在运行", "info", node.id);
|
||||
return { active: false, stopped };
|
||||
});
|
||||
|
||||
@@ -28,6 +28,13 @@ export async function startFixtureServer(preferredPort = 17863): Promise<string>
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== "/" && url.pathname !== "/index.html") {
|
||||
const safe = path.basename(url.pathname);
|
||||
const file = path.join(root, safe);
|
||||
if (/\.(html|css|js|png|jpg|svg)$/i.test(safe) && fs.existsSync(file)) {
|
||||
response.writeHead(200, { "Content-Type": safe.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream", "Cache-Control": "no-store" });
|
||||
fs.createReadStream(file).pipe(response);
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
+2
-1
@@ -172,7 +172,8 @@ function registerIpc(): void {
|
||||
});
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
// 打包冒烟/自动化测试需要与用户日常实例隔离;正常启动仍然保持单实例。
|
||||
const gotLock = process.env.RPA_SKIP_SINGLE_INSTANCE === "1" ? true : app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user