From fb71c0551b9f2c5fc2f9cb4515500b34ebac727e Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Tue, 3 Nov 2026 16:04:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BD=91=E9=A1=B5=E7=9B=91=E5=90=AC?= =?UTF-8?q?=E9=80=9A=E9=85=8D=E8=BF=87=E6=BB=A4=E5=8E=BB=E9=94=9A=E5=AE=9A?= =?UTF-8?q?(=E5=85=B3=E9=94=AE=E5=AD=97=E4=BB=BB=E6=84=8F=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E5=91=BD=E4=B8=AD),=20=E7=BD=91=E5=9D=80=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E4=B8=B4=E6=97=B6=E9=94=9A=E7=82=B9=E6=94=B9=E5=8F=AF?= =?UTF-8?q?=E8=A7=811px=E8=A7=A6=E5=8F=91(=E9=9A=90=E8=97=8F=E5=85=83?= =?UTF-8?q?=E7=B4=A0=E8=A2=AB=E7=82=B9=E5=87=BB=E8=A7=A3=E6=9E=90=E8=BF=87?= =?UTF-8?q?=E6=BB=A4),=20=E6=B5=8F=E8=A7=88=E5=99=A8=E6=88=AA=E5=9B=BEpath?= =?UTF-8?q?=E4=BC=A0=E7=9B=AE=E5=BD=95=E8=87=AA=E5=8A=A8=E8=A1=A5=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8D,=20=E8=A1=A5=E7=BD=91=E9=A1=B5=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=96=E7=9C=9F=E5=AE=9EChrome=E5=86=92=E7=83=9F11?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/browser/session.ts | 3 +- src/main/capabilities/browser-control.ts | 16 +- src/main/capabilities/web-real-smoke.test.ts | 239 +++++++++++++++++++ 3 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 src/main/capabilities/web-real-smoke.test.ts diff --git a/src/main/browser/session.ts b/src/main/browser/session.ts index 0e22917..9e44d58 100644 --- a/src/main/browser/session.ts +++ b/src/main/browser/session.ts @@ -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> = []; diff --git a/src/main/capabilities/browser-control.ts b/src/main/capabilities/browser-control.ts index 2efc477..3f573bd 100644 --- a/src/main/capabilities/browser-control.ts +++ b/src/main/capabilities/browser-control.ts @@ -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 {} } }); diff --git a/src/main/capabilities/web-real-smoke.test.ts b/src/main/capabilities/web-real-smoke.test.ts new file mode 100644 index 0000000..29476c8 --- /dev/null +++ b/src/main/capabilities/web-real-smoke.test.ts @@ -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 = `RPA冒烟主页 + + + +初始文本 + + +下一页 +外链 + + + +
商品数量
苹果5
香蕉8
橙子3
+ +
长页面
+
页底标记
+`; + +const PAGE_SECOND = `RPA冒烟第二页

第二页内容

返回`; + +const PAGE_DISABLED = ``; + +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): Promise { + 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((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((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; + 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); +});