fix: 网页监听通配过滤去锚定(关键字任意位置命中), 网址下载临时锚点改可见1px触发(隐藏元素被点击解析过滤), 浏览器截图path传目录自动补文件名, 补网页自动化真实Chrome冒烟11项
This commit is contained in:
parent
58aae7c92f
commit
fb71c0551b
@ -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>> = [];
|
||||
|
||||
@ -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 {} }
|
||||
});
|
||||
|
||||
|
||||
239
src/main/capabilities/web-real-smoke.test.ts
Normal file
239
src/main/capabilities/web-real-smoke.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user