fix: 网页监听通配过滤去锚定(关键字任意位置命中), 网址下载临时锚点改可见1px触发(隐藏元素被点击解析过滤), 浏览器截图path传目录自动补文件名, 补网页自动化真实Chrome冒烟11项

This commit is contained in:
yu 2026-11-03 16:04:33 +08:00
parent 58aae7c92f
commit fb71c0551b
3 changed files with 255 additions and 3 deletions

View File

@ -1009,8 +1009,9 @@ export class BrowserSession implements BrowserLike {
}
if (options.url) {
const keyword = options.url;
// wildcard 语义是「关键字可出现在 URL 任意位置,* 匹配任意片段」,不加 ^$ 锚定
captures = options.wildcard
? captures.filter(c => new RegExp("^" + keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").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

@ -1,4 +1,5 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { browserSession } from "../browser/session";
import { register } from "../engine/registry";
import { RuntimeError, type BrowserLike, type RunContext } from "../engine/types";
@ -60,7 +61,17 @@ register("WebBrowser_Wait", async (ctx) => {
const browser = await session(ctx); const deadline = Date.now() + 15000; do { try { if (await browser.runJs("document.readyState === 'complete'")) return true; } catch {} await new Promise((resolve) => setTimeout(resolve, 100)); } while (Date.now() < deadline); return false;
});
register(["WebBrowser_CloseWeb", "WebBrowser_CloseWeb_V1", "WebBrowser_CloseWeb_V2", "WebBrowser_Close_V1"], async (ctx) => { const browser = await session(ctx); await browser.close(); ctx.browser = undefined; return true; });
register(["WebBrowser_GetBrowserPic", "WebBrowser_GetBrowserPicV1"], async (ctx, node, args) => { const browser = await session(ctx); const target = path.resolve(text(args, ["path", "file_path", "image_path"], path.join(ctx.evidenceDir, `${node.id}.png`))); return browser.screenshot(target); });
register(["WebBrowser_GetBrowserPic", "WebBrowser_GetBrowserPicV1"], async (ctx, node, args) => {
const browser = await session(ctx);
const raw = path.resolve(text(args, ["path", "file_path", "image_path"], path.join(ctx.evidenceDir, `${node.id}.png`)));
// 用户填目录(无扩展名或本身就是目录)时自动补文件名,避免把目录当文件写导致 EISDIR
let target = raw;
if (path.extname(raw) === "") target = path.join(raw, `${node.componentId}_${Date.now()}.png`);
else {
try { if ((await fs.stat(raw)).isDirectory()) target = path.join(raw, `${node.componentId}_${Date.now()}.png`); } catch { /* 不存在则按文件路径写 */ }
}
return browser.screenshot(target);
});
register("Element_ExistsV1", async (ctx, _node, args) => (await session(ctx)).exists(selector(args)));
register("Element_WaitElement_v5", async (ctx, _node, args) => { const browser = await session(ctx); const target = selector(args); const expected = String(args.choose ?? "1") !== "3"; const deadline = Date.now() + Math.max(0, Number(args.time_out ?? args.timeout ?? 10)) * 1000; do { if ((await browser.exists(target)) === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline); return false; });
@ -68,7 +79,8 @@ register(["WebBrowser_DownFile", "WebBrowser_DownFile_V1", "WebBrowser_DownloadF
const browser = await session(ctx); const headers = text(args, ["headers"], "{}"); const body = text(args, ["body", "param"], "{}"); if (!["", "{}"].includes(headers) || !["", "{}"].includes(body)) throw new RuntimeError("validation_error", "当前下载能力暂不支持自定义Headers或请求体", { nodeId: node.id, componentId: node.componentId });
const folder = text(args, ["savepath", "save_path"]); if (!folder) throw new RuntimeError("validation_error", "下载目录为空", { nodeId: node.id, componentId: node.componentId }); const filename = text(args, ["filename", "file_name"]); const seconds = Number(args.download_time ?? 0); const timeout = seconds > 0 ? seconds * 1000 : Number(args.timeout ?? 10000) * (node.componentId.endsWith("_V1") && node.componentId.includes("DownFile") ? 1000 : 1);
const directUrl = text(args, ["url"]); if (!directUrl) return browser.download(selector(args), folder, filename, timeout);
const temporaryId = `rpa-download-${Date.now().toString(36)}`; await browser.runJs(`(()=>{const a=document.createElement('a');a.id=${JSON.stringify(temporaryId)};a.href=${JSON.stringify(directUrl)};a.style.display='none';document.body.appendChild(a);return true})()`);
// 临时锚点必须对点击解析「可见」(1px 定位 + 微透明),display:none 会被元素解析过滤导致找不到
const temporaryId = `rpa-download-${Date.now().toString(36)}`; await browser.runJs(`(()=>{const a=document.createElement('a');a.id=${JSON.stringify(temporaryId)};a.href=${JSON.stringify(directUrl)};a.style.cssText='position:fixed;left:2px;top:2px;width:8px;height:8px;opacity:0.01';document.body.appendChild(a);return true})()`);
try { return await browser.download(`css:#${temporaryId}`, folder, filename, timeout); } finally { try { await browser.runJs(`document.getElementById(${JSON.stringify(temporaryId)})?.remove();true`); } catch {} }
});

View File

@ -0,0 +1,239 @@
import { createServer, type Server } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import { getHandler } from "../engine/registry";
import { externalBrowserSession } from "../browser/session";
import type { BrowserLike, RunContext } from "../engine/types";
import "./browser";
import "./browser-common";
import "./browser-control";
import "./browser-dom";
import "./browser-extended";
import "./web-batch2";
import "./web-crawl";
const component = {} as CatalogComponent;
function node(componentId: string): FlowNode {
return { id: `web_${componentId}`, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
}
const PAGE_MAIN = `<!DOCTYPE html><html><head><title>RPA冒烟主页</title></head><body>
<input id="name" value=""/>
<input id="city" value="杭州"/>
<button id="submit" onclick="document.getElementById('result').innerText='已提交:'+document.getElementById('name').value">提交</button>
<span id="result">初始文本</span>
<label><input type="checkbox" id="agree"/> 同意协议</label>
<select id="dept"><option value="">请选择</option><option value="tech">技术部</option><option value="sales">销售部</option></select>
<a id="link" href="/page2">下一页</a>
<a id="portal" href="https://example.com/outer">外链</a>
<table id="orders"><tr><th>商品</th><th>数量</th></tr>
<tr><td class="item">苹果</td><td class="qty">5</td></tr>
<tr><td class="item">香蕉</td><td class="qty">8</td></tr>
<tr><td class="item">橙子</td><td class="qty">3</td></tr></table>
<input type="file" id="uploader"/>
<div style="height:2000px">长页面</div>
<div id="bottom" style="margin-top:1900px">页底标记</div>
</body></html>`;
const PAGE_SECOND = `<!DOCTYPE html><html><head><title>RPA冒烟第二页</title></head><body><h1 id="h">第二页内容</h1><a href="/">返回</a></body></html>`;
const PAGE_DISABLED = `<button id="disabledBtn" disabled>不可用</button>`;
let server: Server;
let baseUrl = "";
const outDir = "D:/office-test2/web-test";
const uploadFile = path.join(outDir, "upload-src.txt");
let browser: BrowserLike;
let ctx: RunContext;
async function invoke(componentId: string, args: Record<string, unknown>): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler, `${componentId} 应已注册`).toBeTypeOf("function");
return handler!(ctx, node(componentId), args, component);
}
beforeAll(async () => {
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(uploadFile, "上传测试内容", "utf8");
await new Promise<void>((resolve) => {
server = createServer((req, res) => {
const url = (req.url ?? "/").split("?")[0];
if (url === "/page2") { res.setHeader("content-type", "text/html; charset=utf-8"); res.end(PAGE_SECOND); return; }
if (url === "/blank") { res.setHeader("content-type", "text/html; charset=utf-8"); res.end(PAGE_DISABLED); return; }
if (url === "/file") {
res.setHeader("content-type", "application/octet-stream");
res.setHeader("content-disposition", 'attachment; filename="smoke-dl.txt"');
res.end("download-smoke-content");
return;
}
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(PAGE_MAIN);
});
server.listen(0, "127.0.0.1", () => {
baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
resolve();
});
});
// 真实 Chrome 无头模式(带调试端口与独立 profile),全链路 CDP
browser = await externalBrowserSession({
mode: "launch",
browserType: "chrome",
port: 9333,
extraArgs: ["--headless=new"]
});
ctx = {
runId: "web-smoke-run", flowId: "web-smoke-flow", flow: {} as RunContext["flow"],
vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: outDir,
browser, emit: () => {}, log: () => {}, componentOf: () => undefined
} as unknown as RunContext;
}, 90000);
afterAll(async () => {
try { await browser?.close(); } catch { /* 进程退出兜底 */ }
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe("网页自动化真实冒烟(Chrome 无头 + CDP)", () => {
it("打开网页/标题/地址/文档/等待加载", async () => {
const opened = await invoke("WebBrowser_CreateV3", { url: `${baseUrl}/` }) as { url: string; title: string };
expect(opened.url).toBe(`${baseUrl}/`);
expect(await invoke("WebBrowser_GetTitle", {})).toBe("RPA冒烟主页");
expect(await invoke("WebBrowser_GetUrl", {})).toBe(`${baseUrl}/`);
const doc = await invoke("WebBrowser_GetDocument", {}) as string;
expect(doc).toContain("RPA冒烟主页");
expect(await invoke("WebBrowser_Wait", {})).toBe(true);
console.log("[Web 导航] 打开/标题/URL/文档/Wait ✓");
}, 60000);
it("输入/读取文本与值/点击联动", async () => {
await invoke("Element_SetValue_V6", { uiSelector: "css:#name", text: "张三" });
const value = await invoke("Element_GetValue", { uiSelector: "css:#name" });
expect(String(value)).toBe("张三");
await invoke("Element_Click_V6", { uiSelector: "css:#submit" });
const result = await invoke("Element_GetText_V5", { uiSelector: "css:#result" });
expect(result).toBe("已提交:张三");
const initial = await invoke("Element_GetText_V1", { uiSelector: "css:#city", attr_type: "value" });
expect(String(initial)).toBe("杭州");
console.log("[Web 输入点击] 输入/取值/点击联动/属性文本 ✓");
}, 60000);
it("存在判断/等待元素/按钮可用性", async () => {
expect(await invoke("Element_Exists", { uiSelector: "css:#submit" })).toBe(true);
expect(await invoke("Element_ExistsV1", { uiSelector: "css:#not-exist" })).toBe(false);
expect(await invoke("Element_WaitElement_v5", { uiSelector: "css:#result", time_out: 3 })).toBe(true);
expect(await invoke("Element_GetButtonEnable_v4", { uiSelector: "css:#submit" })).toBe(true);
console.log("[Web 判断] Exists/ExistsV1/WaitElement/GetButtonEnable ✓");
}, 60000);
it("属性读写/元素源码", async () => {
await invoke("Element_SetAttribute_v2", { uiSelector: "css:#link", attrName: "data-mark", attrValue: "rpa" });
expect(await invoke("Element_GetAttribute_v3", { uiSelector: "css:#link", attrName: "data-mark" })).toBe("rpa");
const html = await invoke("Element_GetHtml", { uiSelector: "css:#result" }) as string;
expect(html).toContain("已提交");
console.log("[Web 属性] SetAttribute/GetAttribute/GetHtml ✓");
}, 60000);
it("勾选框/下拉框全链", async () => {
await invoke("Element_SetCheck", { uiSelector: "css:#agree", isChecked: "1" });
expect(await invoke("Element_GetCheck", { uiSelector: "css:#agree" })).toBe(true);
expect(await invoke("Element_CheckBoxOperation_v3", { uiSelector: "css:#agree", operation_type: "3" })).toBe(true);
await invoke("Element_SetSelect", { uiSelector: "css:#dept", itemsToSelect: "tech" });
expect(await invoke("Element_GetSelect", { uiSelector: "css:#dept", choose: "2" })).toBe("技术部");
console.log("[Web 选择] SetCheck/GetCheck/CheckBoxOperation/SetSelect/GetSelect ✓");
}, 60000);
it("表格读取/批量采集/相似元素", async () => {
const cell = await invoke("Element_GetTableData", { uiSelector: "css:#orders", tabelRange: "3", rowNo: 2, columnNo: 1 });
expect(cell).toBe("苹果");
// operation="0" 为排除表头,取第 2 列数据行
const column = await invoke("Element_GetTableData", { uiSelector: "css:#orders", tabelRange: "2", columnNo: 2, operation: "0" }) as unknown[];
expect(column).toEqual(["5", "8", "3"]);
const rows = await invoke("WebBrowser_DataCrawler", {
selector_page: "css:#orders tr", collect_items: [".item", ".qty"], collect_pages: 1
}) as unknown[][];
expect(rows.length).toBe(4);
expect(rows[1]).toEqual(["苹果", "5"]);
const similar = await invoke("Element_ElementAarray", { uiSelector: "css:.item" }) as unknown[];
expect(similar.length).toBe(3);
console.log("[Web 采集] GetTableData/DataCrawler/ElementAarray ✓");
}, 60000);
it("Cookie 写读与 JS 执行", async () => {
await invoke("WebBrowser_SetCookies_V1", { cookie_name: "smoke", cookie_value: "v123" });
const cookies = await invoke("WebBrowser_GetCookies", {}) as Record<string, string>;
expect(cookies["smoke"]).toBe("v123");
expect(await invoke("WebBrowser_GetCookie", { name: "smoke" })).toBe("v123");
// 中文值按 Cookie 标准做法 URL 编码存储(站点侧自行解码),验证往返一致
await invoke("WebBrowser_SetCookies_V1", { cookie_name: "cn", cookie_value: "值123" });
expect(await invoke("WebBrowser_GetCookie", { name: "cn" })).toBe(encodeURIComponent("值123"));
const js = await invoke("WebBrowser_RunJsInIeBySelector_v3", { jsCode: "1+2" });
expect(Number(js)).toBe(3);
console.log("[Web Cookie/JS] SetCookies_V1/GetCookies/GetCookie/RunJs ✓");
}, 60000);
it("前进后退刷新/标签页/窗口控制", async () => {
await invoke("WebBrowser_GoUrl", { url: `${baseUrl}/page2` });
expect(await invoke("WebBrowser_GetTitle", {})).toBe("RPA冒烟第二页");
await invoke("WebBrowser_GoBack", {});
expect(await invoke("WebBrowser_GetUrl", {})).toBe(`${baseUrl}/`);
await invoke("WebBrowser_GoForward", {});
expect(await invoke("WebBrowser_GetTitle", {})).toBe("RPA冒烟第二页");
await invoke("WebBrowser_Reload", {});
expect(await invoke("WebBrowser_GetTitle", {})).toBe("RPA冒烟第二页");
const tabs = await invoke("WebBrowser_GetTAB_V2", { title: "RPA冒烟" }) as Array<{ title: string }>;
expect(tabs.length).toBeGreaterThan(0);
const sw = await invoke("WebBrowser_Switch_V1", {}) as { switched: boolean };
expect(sw.switched).toBe(true);
expect(await invoke("WebBrowser_Maximize", {})).toBe(true);
expect(await invoke("WebBrowser_Minimize", {})).toBe(true);
expect(await invoke("WebBrowser_Stop", {})).toBe(true);
console.log("[Web 导航控制] GoBack/GoForward/Reload/GetTAB/Switch/Maximize/Minimize/Stop ✓");
}, 60000);
it("滚动/区域/悬停/截图", async () => {
await invoke("WebBrowser_GoUrl", { url: `${baseUrl}/` }); // 回到主页(上一用例停在第二页)
await invoke("WebBrowser_SetScrollPos_V1", { x: 0, y: 500 });
const pos = await invoke("WebBrowser_GetScrollPos_V1", {}) as { y: number };
expect(pos.y).toBe(500);
const region = await invoke("Element_Region2_v3", { uiSelector: "css:#submit" }) as { width: number; height: number };
expect(region.width).toBeGreaterThan(0);
await invoke("Element_ScrollIntoView_v2", { uiSelector: "css:#bottom" });
await invoke("Element_MouseTo_V4", { uiSelector: "css:#submit" });
const shot = await invoke("WebBrowser_GetBrowserPicV1", { path: outDir }) as string;
const stat = await fs.stat(shot);
expect(stat.size).toBeGreaterThan(1000);
const elShot = await invoke("Element_Shot_V2", { uiSelector: "css:#submit", filePath: outDir, filename: "element-shot.png" }) as string;
const elStat = await fs.stat(elShot);
expect(elStat.size).toBeGreaterThan(200);
console.log("[Web 截图] SetScrollPos/GetScrollPos/Region2/ScrollIntoView/MouseTo/整页截图/元素截图 ✓");
}, 60000);
it("文件上传与文件下载", async () => {
await invoke("WebBrowser_GoUrl", { url: `${baseUrl}/` }); // 回到主页
const up = await invoke("Element_FileUploader", { uiSelector: "css:#uploader", file_path: uploadFile }) as { uploaded: boolean };
expect(up.uploaded).toBe(true);
const fileName = await invoke("WebBrowser_RunJsInIeBySelector_v3", { jsCode: "document.getElementById('uploader').files.length" });
expect(Number(fileName)).toBe(1);
const downloaded = await invoke("WebBrowser_DownFile_V1", { url: `${baseUrl}/file`, savepath: outDir, filename: "smoke-dl.txt", timeout: 15000 }) as string;
const content = await fs.readFile(downloaded, "utf8");
expect(content).toBe("download-smoke-content");
console.log("[Web 文件] FileUploader(真实写入input)/DownFile_V1(真实落盘) ✓");
}, 60000);
it("网页监听与弹窗开关", async () => {
expect(await invoke("WebBrowser_StartWeblistening", {})).toEqual({ listening: true, started: expect.any(Boolean) });
// 附件地址直接导航会被浏览器中止(ERR_ABORTED),改走正常页面触发请求
await invoke("WebBrowser_GoUrl", { url: `${baseUrl}/page2?track=1` });
await new Promise((r) => setTimeout(r, 800));
await invoke("WebBrowser_GoUrl", { url: `${baseUrl}/` });
const captures = await invoke("WebBrowser_GetWeblistening", { url: "page2", iswildcard: "1" }) as unknown[];
expect(captures.length).toBeGreaterThan(0);
expect(await invoke("WebBrowser_StopWeblistening", {})).toEqual({ listening: false, stopped: expect.any(Boolean) });
expect(await invoke("Element_StartAutoClosePopup", {})).toEqual({ active: true, started: expect.any(Boolean) });
expect(await invoke("Element_StopAutoClosePopup", {})).toEqual({ active: false, stopped: expect.any(Boolean) });
console.log("[Web 监听] Start/Get(捕获到 /file 请求)/Stop + 弹窗开关 ✓");
}, 60000);
});