修复点击验证误报与相似元素列表全重复等运行时问题
- 点击可信验证增加两类生效旁证: 页面跳转/新开标签页(淘宝搜索按钮), 点击后元素整体消失(关闭登录弹窗); 此前均被误报为"目标未收到可信点击" 并在节点超时内反复重试, 造成"点击生效却等很久还报错" - 相似元素列表引用增加 index 序号: 动态id泛化后各卡片选择器相同, 46 条引用全部命中第一张卡导致抓取结果全重复; 修复 NodeList 无 indexOf 的 TypeError 被静默吞掉导致序号从未写入 - 元素定位改为两段式: 先在当前附着页查找, 找不到才跟随前台标签页, 避免多标签环境下被无条件切到 pages[0] 导致找错页面 - 新开标签页优先复用启动残留的 about:blank 空白页, 不再堆积空标签 - AI 能力输入识别增加 hint_word 字段(此前提示词读到的是空) - try_catch 捕获子步骤失败时记录 warn 日志, 不再静默 - Excel 落盘遇 EBUSY(文件被打开占用)时自动另存为带时间戳的文件 - 新增 similar-list.test.ts 端到端回归(46 卡片动态 id 真实浏览器验证) - 新增 export-last-run.js: 从运行记录导出比价 Excel
This commit is contained in:
parent
47b7a43be2
commit
e25121660d
@ -152,8 +152,24 @@ register("Bivartable_PivotDf", (_ctx, _node, args) => {
|
||||
async function exportExcel(source: Table, filePath: string): Promise<string> {
|
||||
const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet("Sheet1"); sheet.addRows(source as ExcelJS.CellValue[][]); await fs.mkdir(path.dirname(filePath), { recursive: true }); await workbook.xlsx.writeFile(filePath); return filePath;
|
||||
}
|
||||
register(["Bivartable_DfWriteToFile", "Bivartable_DoSave"], async (_ctx, node, args) => {
|
||||
const filePath = path.resolve(required(args.filePath ?? args.path ?? args.savePath, "Excel路径", node.id, node.componentId)); return exportExcel(table(args.df), filePath);
|
||||
register(["Bivartable_DfWriteToFile", "Bivartable_DoSave"], async (ctx, node, args) => {
|
||||
const filePath = path.resolve(required(args.filePath ?? args.path ?? args.savePath, "Excel路径", node.id, node.componentId));
|
||||
try {
|
||||
return await exportExcel(table(args.df), filePath);
|
||||
} catch (error) {
|
||||
// 目标文件被占用(正被 WPS/Excel 打开等)时,自动另存为带时间戳的文件,不让流程失败
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (code === "EBUSY" || code === "EPERM" || /EBUSY|resource busy|being used by another/i.test(message)) {
|
||||
const info = path.parse(filePath);
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "").replace(/^(\d{8})(\d{6})$/, "$1_$2");
|
||||
const fallback = path.join(info.dir, `${info.name}_${stamp}${info.ext}`);
|
||||
await exportExcel(table(args.df), fallback);
|
||||
ctx.log(`目标文件被占用(可能正被打开),已另存为:${fallback}`, "warn", node.id);
|
||||
return fallback;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
register("Bivartable_DoOpenExcel", async (_ctx, node, args) => {
|
||||
const filePath = path.resolve(required(args.filePath, "Excel路径", node.id, node.componentId)); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); const sheet = workbook.getWorksheet(String(args.sheetName || "Sheet1")) || workbook.worksheets[0]; return sheet.getSheetValues().slice(1).map((row) => Array.isArray(row) ? row.slice(1).map((cell) => cell ?? "") : []);
|
||||
|
||||
@ -463,6 +463,9 @@ async function executeContainer(
|
||||
} catch (error) {
|
||||
if (error instanceof BreakSignal || error instanceof ContinueSignal || error instanceof StopSignal) throw error;
|
||||
ctx.vars.error = asRuntimeError(error, node, componentId);
|
||||
// 明确告知:捕获 ≠ 运行失败,属于"可选步骤"的正常跳过路径
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
await ctx.log(`「${node.label || node.id}」子步骤未成功,已捕获并继续执行:${reason}`, "warn", node.id);
|
||||
await runSlot("except_fn");
|
||||
return "catch";
|
||||
}
|
||||
|
||||
@ -67,6 +67,8 @@ export interface BrowserElementReference {
|
||||
tag: string;
|
||||
text: string;
|
||||
elementValue: string;
|
||||
/** 相似元素列表场景:该元素在选择器全部匹配结果中的序号(动态 id 泛化后引用选择器相同,需靠序号区分) */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export interface BrowserElementInfo extends BrowserElementReference {
|
||||
|
||||
31
scripts/export-last-run.js
Normal file
31
scripts/export-last-run.js
Normal file
@ -0,0 +1,31 @@
|
||||
// 从运行记录的 tmp_table.json 重建比价表并导出 Excel
|
||||
const ExcelJS = require("exceljs");
|
||||
const fs = require("fs");
|
||||
|
||||
const table = JSON.parse(fs.readFileSync("C:/Users/Administrator/AppData/Roaming/rpa-studio/tmp_table.json", "utf8"));
|
||||
// table[0] 是损坏的表头行(["标题"]),数据行从 index 1 开始:6 列 = 标题/价格/店铺/销量/空/AI
|
||||
const dataRows = table.slice(1).filter((row) => row.length >= 4 && row[0]);
|
||||
const aiText = table.map((row) => (row.length >= 6 ? row[5] : null)).find((c) => c && String(c).trim() && c !== null && String(c) !== "None") || "";
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("比价结果");
|
||||
sheet.addRow(["标题", "价格", "店铺", "销量"]);
|
||||
for (const row of dataRows) {
|
||||
sheet.addRow([row[0], row[1], row[2], row[3]]);
|
||||
}
|
||||
// AI 分析单独放一个 sheet
|
||||
const aiSheet = workbook.addWorksheet("AI分析");
|
||||
aiSheet.addRow(["AI 比价分析结论"]);
|
||||
aiSheet.getRow(1).font = { bold: true };
|
||||
aiSheet.addRow([String(aiText)]);
|
||||
aiSheet.getColumn(1).width = 120;
|
||||
sheet.getColumn(1).width = 60;
|
||||
sheet.getColumn(2).width = 12;
|
||||
sheet.getColumn(3).width = 24;
|
||||
sheet.getColumn(4).width = 14;
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
|
||||
const out = "C:/Users/Administrator/Desktop/淘宝手机比价.xlsx";
|
||||
workbook.xlsx.writeFile(out).then(() => {
|
||||
console.log("written:", out, "dataRows:", dataRows.length, "aiLen:", String(aiText).length);
|
||||
});
|
||||
@ -58,11 +58,18 @@ interface ElementInfo {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function selectorSpec(selector: unknown): { kind: string; value: string; role?: string; name?: string; attr?: string } {
|
||||
function selectorSpec(selector: unknown): { kind: string; value: string; role?: string; name?: string; attr?: string; index?: number } {
|
||||
if (selector && typeof selector === "object") {
|
||||
const record = selector as Record<string, unknown>;
|
||||
if (record.kind && record.value) {
|
||||
return { kind: String(record.kind), value: String(record.value), role: record.role as string, name: record.name as string };
|
||||
const parsedIndex = Math.trunc(Number(record.index));
|
||||
return {
|
||||
kind: String(record.kind),
|
||||
value: String(record.value),
|
||||
role: record.role as string,
|
||||
name: record.name as string,
|
||||
index: Number.isFinite(parsedIndex) && parsedIndex > 0 ? parsedIndex : undefined
|
||||
};
|
||||
}
|
||||
const common = record.commonProps as Record<string, unknown> | undefined;
|
||||
if (common?.cssSelector) {
|
||||
@ -206,8 +213,8 @@ export class BrowserSession implements BrowserLike {
|
||||
await this.activateTab(page.id);
|
||||
}
|
||||
|
||||
private async evaluate<T = unknown>(expression: string): Promise<T> {
|
||||
await this.followForegroundTab().catch(() => {});
|
||||
private async evaluate<T = unknown>(expression: string, follow = true): Promise<T> {
|
||||
if (follow) await this.followForegroundTab().catch(() => {});
|
||||
const result = await this.cdp<{ result?: { value?: T }; exceptionDetails?: { exception?: { description?: string }; text?: string } }>(
|
||||
"Runtime.evaluate",
|
||||
{ expression, returnByValue: true, awaitPromise: true }
|
||||
@ -235,7 +242,7 @@ export class BrowserSession implements BrowserLike {
|
||||
|
||||
private async withElementOnce<T>(selector: unknown, operation: string): Promise<T> {
|
||||
const spec = selectorSpec(selector);
|
||||
const result = await this.evaluate<{ found: boolean; value?: T }>(`(() => {
|
||||
const expression = `(() => {
|
||||
const spec = ${JSON.stringify(spec)};
|
||||
const escapeSelectorValue = (value) => {
|
||||
if (globalThis.CSS && typeof CSS.escape === 'function') return CSS.escape(String(value));
|
||||
@ -243,7 +250,8 @@ export class BrowserSession implements BrowserLike {
|
||||
};
|
||||
const find = () => {
|
||||
if (spec.kind === 'xpath') {
|
||||
return document.evaluate(spec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||
const snapshot = document.evaluate(spec.value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
||||
return snapshot.snapshotItem(spec.index > 0 ? spec.index : 0);
|
||||
}
|
||||
if (spec.kind === 'text') {
|
||||
const target = spec.value;
|
||||
@ -265,9 +273,15 @@ export class BrowserSession implements BrowserLike {
|
||||
const segments = String(spec.value).split('>>>').map((item) => item.trim()).filter(Boolean);
|
||||
let root = document;
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const last = index === segments.length - 1;
|
||||
if (last && spec.index > 0) {
|
||||
// 相似元素引用:同一段选择器匹配多个元素时按序号取
|
||||
const all = root.querySelectorAll(segments[index]);
|
||||
return all[spec.index] || null;
|
||||
}
|
||||
const found = root.querySelector(segments[index]);
|
||||
if (!found) return null;
|
||||
if (index === segments.length - 1) return found;
|
||||
if (last) return found;
|
||||
root = found.shadowRoot;
|
||||
if (!root) return null;
|
||||
}
|
||||
@ -278,8 +292,21 @@ export class BrowserSession implements BrowserLike {
|
||||
const el = find();
|
||||
if (!el) return { found: false };
|
||||
return { found: true, value: (() => { ${operation} })() };
|
||||
})()`);
|
||||
if (!result.found) {
|
||||
})()`;
|
||||
// 先在当前激活标签页查找(跟随 pages[0] 在多标签环境下会切错页);
|
||||
// 找不到再跟随前台标签页重试一次——覆盖"点击后跳转/新开标签页"的场景
|
||||
let firstError: unknown = null;
|
||||
let result = await this.evaluate<{ found: boolean; value?: T }>(expression, false).catch((error) => {
|
||||
firstError = error;
|
||||
return null;
|
||||
});
|
||||
if (!result || !result.found) {
|
||||
await this.followForegroundTab().catch(() => {});
|
||||
const retry = await this.evaluate<{ found: boolean; value?: T }>(expression, false).catch(() => null);
|
||||
if (retry) result = retry;
|
||||
}
|
||||
if (!result || !result.found) {
|
||||
if (firstError) throw firstError;
|
||||
throw new RuntimeError("not_found", `Element not found: ${spec.kind}:${spec.value}`);
|
||||
}
|
||||
return result.value as T;
|
||||
@ -465,7 +492,14 @@ export class BrowserSession implements BrowserLike {
|
||||
return !name || acc.includes(name);
|
||||
}));
|
||||
} else {
|
||||
matches = visible([...document.querySelectorAll(spec.value)]);
|
||||
const all = [...document.querySelectorAll(spec.value)];
|
||||
if (spec.index > 0) {
|
||||
// 相似元素引用:按序号取指定项,再做可见性校验
|
||||
const picked = all[spec.index];
|
||||
matches = picked ? visible([picked]) : [];
|
||||
} else {
|
||||
matches = visible(all);
|
||||
}
|
||||
}
|
||||
const el = matches[0] || null;
|
||||
if (!el) return null;
|
||||
@ -481,7 +515,12 @@ export class BrowserSession implements BrowserLike {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
};
|
||||
})()`;
|
||||
const info = await this.evaluate<ElementInfo | null>(expression);
|
||||
// 先在当前激活页定位;找不到再跟随前台标签页重试一次(多标签环境下 pages[0] 未必是流程页)
|
||||
let info = await this.evaluate<ElementInfo | null>(expression, false);
|
||||
if (!info) {
|
||||
await this.followForegroundTab().catch(() => {});
|
||||
info = await this.evaluate<ElementInfo | null>(expression, false);
|
||||
}
|
||||
if (!info) {
|
||||
throw new RuntimeError("not_found", `未找到元素:${spec.kind}:${spec.value}`, {
|
||||
recommendation: "检查选择器,或先用拾取器重新采集"
|
||||
@ -504,6 +543,22 @@ export class BrowserSession implements BrowserLike {
|
||||
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", buttons: 0, clickCount: 1 });
|
||||
}
|
||||
|
||||
/** 点击生效的旁证:新开标签页、URL 发生跳转,或原页面 context 因导航销毁。
|
||||
* 典型如淘宝搜索按钮——点击本身成功并触发跳转,但点击后的 token 回读必然失败。 */
|
||||
private async navigationEvidence(beforeTargets: string[], beforeUrl: string): Promise<boolean> {
|
||||
if (this.httpBase) {
|
||||
const pages = await listPageTargets(this.httpBase).catch(() => []);
|
||||
if (pages.length && pages.some((page) => !beforeTargets.includes(page.id))) return true;
|
||||
}
|
||||
try {
|
||||
const url = await this.evaluate<string>("location.href");
|
||||
return Boolean(url) && url !== beforeUrl;
|
||||
} catch {
|
||||
// evaluate 失败多半是原页面正在导航(Execution context was destroyed),本身就是点击生效的证据
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async click(selector: unknown, timeoutMs?: number): Promise<unknown> {
|
||||
const info = await this.locate(selector, timeoutMs);
|
||||
if (!info.visible) {
|
||||
@ -524,14 +579,44 @@ export class BrowserSession implements BrowserLike {
|
||||
el.addEventListener('click', () => { el.dataset.rpaClickToken = token; }, { once: true });
|
||||
return true;
|
||||
`);
|
||||
const beforeTargets = this.httpBase
|
||||
? (await listPageTargets(this.httpBase).catch(() => [])).map((page) => page.id)
|
||||
: [];
|
||||
const beforeUrl = await this.evaluate<string>("location.href", false).catch(() => "");
|
||||
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
const clicked = await this.withElement<boolean>(selector, `
|
||||
const matched = el.dataset.rpaClickToken === ${JSON.stringify(clickToken)};
|
||||
delete el.dataset.rpaClickToken;
|
||||
return matched;
|
||||
`);
|
||||
if (!clicked) throw new RuntimeError("not_found", `目标未收到可信点击:${info.selector}`, { recommendation: "检查元素是否被遮挡或重新拾取元素" });
|
||||
// token 只在"点击后页面未跳转"时才读得到;页面一旦导航,回读必然失败,因此不做长重试,快速进入跳转判定
|
||||
let elementGone = false;
|
||||
const readToken = async (): Promise<boolean> => {
|
||||
try {
|
||||
return await this.withElementOnce<boolean>(selector, `
|
||||
const matched = el.dataset.rpaClickToken === ${JSON.stringify(clickToken)};
|
||||
delete el.dataset.rpaClickToken;
|
||||
return matched;
|
||||
`);
|
||||
} catch (error) {
|
||||
// 元素整体找不到了:点击把它连同弹层一起移除了(如关闭登录弹窗),是点击生效的另一种证据
|
||||
if (error instanceof RuntimeError && error.code === "not_found") elementGone = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let clicked = await readToken();
|
||||
if (!clicked) {
|
||||
await this.wait(400);
|
||||
clicked = await readToken();
|
||||
}
|
||||
if (!clicked) {
|
||||
// 回读不到 token:先看页面是否已跳转/新开标签页——那是点击生效的直接证据,不应误报失败
|
||||
const moved = await this.navigationEvidence(beforeTargets, beforeUrl);
|
||||
if (moved) {
|
||||
return { selector: info.selector, tag: info.tag, text: info.text, navigated: true };
|
||||
}
|
||||
if (elementGone) {
|
||||
// 元素已从页面消失且页面未跳转:典型如点击关闭按钮后弹层被整体移除
|
||||
return { selector: info.selector, tag: info.tag, text: info.text, dismissed: true };
|
||||
}
|
||||
throw new RuntimeError("not_found", `目标未收到可信点击:${info.selector}`, { recommendation: "检查元素是否被遮挡或重新拾取元素" });
|
||||
}
|
||||
return { selector: info.selector, tag: info.tag, text: info.text };
|
||||
}
|
||||
|
||||
@ -842,7 +927,16 @@ export class BrowserSession implements BrowserLike {
|
||||
if (filter) children = children.filter((item) => item.matches(filter));
|
||||
const index = ${normalizedIndex};
|
||||
if (index > 0) children = children.slice(index - 1, index);
|
||||
return children.map(describe);
|
||||
return children.map((node) => {
|
||||
const reference = describe(node);
|
||||
try {
|
||||
if (reference.kind === 'css') {
|
||||
const all = [...document.querySelectorAll(reference.value)];
|
||||
if (all.length > 1) reference.index = all.indexOf(node);
|
||||
}
|
||||
} catch {}
|
||||
return reference;
|
||||
});
|
||||
`);
|
||||
if (normalizedIndex > 0 && result.length === 0) {
|
||||
throw new RuntimeError("not_found", `未找到第 ${normalizedIndex} 个子元素`);
|
||||
@ -955,7 +1049,18 @@ export class BrowserSession implements BrowserLike {
|
||||
const startIndex = ${start} - 1;
|
||||
const endValue = ${end};
|
||||
const endIndex = endValue < 0 ? nodes.length + endValue + 1 : endValue;
|
||||
return { parentFound: true, items: nodes.slice(startIndex, Math.max(startIndex, endIndex)).map(describe) };
|
||||
return { parentFound: true, items: nodes.slice(startIndex, Math.max(startIndex, endIndex)).map((node) => {
|
||||
const reference = describe(node);
|
||||
try {
|
||||
// 动态 id 泛化后各卡片引用选择器可能相同,记录序号以便后续读取时区分
|
||||
// 注意 NodeList 没有 indexOf,必须先展开为数组
|
||||
if (reference.kind === 'css') {
|
||||
const all = [...root.querySelectorAll(reference.value)];
|
||||
if (all.length > 1) reference.index = all.indexOf(node);
|
||||
}
|
||||
} catch {}
|
||||
return reference;
|
||||
}) };
|
||||
})()`);
|
||||
if (!result.parentFound) throw new RuntimeError("not_found", "未找到相似元素的父元素范围");
|
||||
return result.items;
|
||||
@ -1131,6 +1236,14 @@ export class BrowserSession implements BrowserLike {
|
||||
const result = await this.navigate(url);
|
||||
return { index: 0, targetId: "", title: result.title, url: result.url, active: true };
|
||||
}
|
||||
// 优先复用启动浏览器时残留的空白标签页(about:blank / 新标签页),避免越积越多
|
||||
const pages = await listPageTargets(this.httpBase).catch(() => []);
|
||||
const blank = pages.find((page) => /^(about:blank$|chrome:\/\/newtab|edge:\/\/newtab)/i.test(page.url ?? ""));
|
||||
if (blank) {
|
||||
await this.activateTab(blank.id);
|
||||
const result = await this.navigate(url);
|
||||
return { index: -1, targetId: blank.id, title: result.title, url: result.url, active: true };
|
||||
}
|
||||
const target = await openNewPageTarget(this.httpBase, assertUrl(url).toString());
|
||||
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl, `tab:${target.id}`);
|
||||
this.tabTransports.set(target.id, transport);
|
||||
@ -1330,6 +1443,15 @@ const PICKER_BOOTSTRAP_SOURCE = `(() => {
|
||||
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
|
||||
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||||
}
|
||||
// 图片类元素:src 文件名比结构序号稳定得多(body 下 div 序号会随页面动态内容漂移)
|
||||
if (item.tagName === 'IMG') {
|
||||
const src = item.getAttribute('src') || '';
|
||||
const file = src.split('?')[0].split('/').pop();
|
||||
if (file && file.length >= 3 && file.length <= 60) {
|
||||
const candidate = 'img[src$="' + cssEscape(file) + '"]';
|
||||
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const parts = [];
|
||||
|
||||
@ -3,7 +3,7 @@ import { RuntimeError } from "@rpa/core";
|
||||
import { chatCompletion, requireAiConfig } from "@rpa/core";
|
||||
|
||||
function inputText(args: Record<string, unknown>, nodeId: string): string {
|
||||
for (const key of ["input", "text", "content", "sentence", "prompt", "sourceFilePath", "filePath", "source", "document"]) {
|
||||
for (const key of ["input", "hint_word", "text", "content", "sentence", "prompt", "sourceFilePath", "filePath", "source", "document"]) {
|
||||
const v = String(args[key] ?? "").trim();
|
||||
if (v) return v;
|
||||
}
|
||||
|
||||
@ -110,7 +110,10 @@ register("WebBrowser_Close", async (ctx) => {
|
||||
register(["Element_Click_V6", "Element_Click_V3", "Element_Click"], async (ctx, node, args) => {
|
||||
const browser = await session(ctx);
|
||||
const result = await browser.click(selectorOf(args));
|
||||
ctx.log(`已点击 ${(result as { selector?: string }).selector || ""}`, "info", node.id);
|
||||
const navigated = Boolean((result as { navigated?: boolean }).navigated);
|
||||
const dismissed = Boolean((result as { dismissed?: boolean }).dismissed);
|
||||
const note = navigated ? "(页面已跳转,点击生效)" : dismissed ? "(弹窗已关闭,点击生效)" : "";
|
||||
ctx.log(`已点击 ${(result as { selector?: string }).selector || ""}${note}`, "info", node.id);
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
137
src/main/capabilities/similar-list.test.ts
Normal file
137
src/main/capabilities/similar-list.test.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import { getHandler } from "@rpa/core";
|
||||
import type { RunContext } from "@rpa/core";
|
||||
import { externalBrowserSession } from "../browser/session";
|
||||
import "./browser";
|
||||
import "./browser-common";
|
||||
|
||||
// 复现淘宝场景:46 张卡片共用动态 id 前缀 item_id_<长数字>,泛化后引用选择器相同,
|
||||
// 相似元素列表必须靠 index 区分,四列文本应各不相同
|
||||
const CARDS = Array.from({ length: 46 }, (_, i) => `
|
||||
<div class="doubleCardWrapperJ_1dpR7" id="item_id_${1000000000000 + i * 7919}">
|
||||
<div><div>
|
||||
<div style="height:1px"></div>
|
||||
<div>占位</div>
|
||||
<div>
|
||||
<div><span class="titleSpan">商品标题第${i + 1}号</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div><span class="innerPriceWrapper"><span>¥</span><span>${100 + i}.00</span></span></div>
|
||||
<div><span class="shopNameText">店铺${i + 1}号</span></div>
|
||||
<div><span class="realSales">${i === 3 ? "补贴价" : `${i + 1}人付款`}</span></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>`);
|
||||
|
||||
const PAGE = `<!DOCTYPE html><html><head><title>列表测试</title></head><body>
|
||||
<div class="searchResults">${CARDS.join("")}</div>
|
||||
</body></html>`;
|
||||
|
||||
const component = {} as CatalogComponent;
|
||||
|
||||
function node(componentId: string): FlowNode {
|
||||
return { id: `sim_${componentId}`, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
|
||||
}
|
||||
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
const outDir = mkdtempSync(path.join(tmpdir(), "rpa-similar-"));
|
||||
let browser: ReturnType<typeof externalBrowserSession> extends Promise<infer T> ? T : never;
|
||||
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 new Promise<void>((resolve) => {
|
||||
server = createServer((_req, res) => {
|
||||
res.setHeader("content-type", "text/html; charset=utf-8");
|
||||
res.end(PAGE);
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
browser = (await externalBrowserSession({
|
||||
mode: "launch",
|
||||
browserType: "chrome",
|
||||
port: 9444,
|
||||
extraArgs: ["--headless=new"]
|
||||
})) as typeof browser;
|
||||
ctx = {
|
||||
runId: "similar-run", flowId: "similar-flow", flow: {} as RunContext["flow"],
|
||||
vars: {}, secrets: {}, signal: new AbortController().signal, evidenceDir: outDir,
|
||||
browser, emit: () => {}, log: () => {}, componentOf: () => undefined
|
||||
} as unknown as RunContext;
|
||||
await invoke("WebBrowser_GoUrl", { url: `${baseUrl}/` });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
try { await browser?.close(); } catch { /* 进程退出兜底 */ }
|
||||
await new Promise<void>((resolve) => { server.close(() => resolve()); });
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("相似元素列表(动态 id 泛化 + index 消歧)", () => {
|
||||
it("引用全部带 index 消歧(泛化选择器相同也各指各卡)", async () => {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const refs = await browser.getSimilarElements(
|
||||
'css:[id^="item_id_"] [class*="innerPriceWrapper"]',
|
||||
{ start: 1, end: -1 }
|
||||
);
|
||||
expect(refs).toHaveLength(46);
|
||||
// 动态 id 泛化后引用选择器相同,必须全部携带不同 index
|
||||
expect(new Set(refs.map((r) => r.value)).size).toBe(1);
|
||||
expect(new Set(refs.map((r) => r.index)).size).toBe(46);
|
||||
// 文本快照本身也应各不相同(描述阶段就知道各自是谁)
|
||||
expect(new Set(refs.map((r) => r.text)).size).toBe(46);
|
||||
});
|
||||
|
||||
it("标题列 46 条各不相同", async () => {
|
||||
const titles = await invoke("Element_SimilarElementList_V2", {
|
||||
uiSelector: 'css:[id^="item_id_"] > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3) > div > span',
|
||||
action: "text",
|
||||
start_index: "1",
|
||||
end_index: "-1"
|
||||
}) as string[];
|
||||
expect(titles).toHaveLength(46);
|
||||
expect(new Set(titles).size).toBe(46);
|
||||
expect(titles[0]).toContain("第1号");
|
||||
expect(titles[45]).toContain("第46号");
|
||||
});
|
||||
|
||||
it("价格列 46 条各不相同", async () => {
|
||||
const prices = await invoke("Element_SimilarElementList_V2", {
|
||||
uiSelector: 'css:[id^="item_id_"] [class*="innerPriceWrapper"]',
|
||||
action: "text",
|
||||
start_index: "1",
|
||||
end_index: "-1"
|
||||
}) as string[];
|
||||
expect(prices).toHaveLength(46);
|
||||
expect(new Set(prices).size).toBe(46);
|
||||
expect(prices[0]).toContain("100.00");
|
||||
expect(prices[45]).toContain("145.00");
|
||||
});
|
||||
|
||||
it("指定 parent 范围后仍各不相同", async () => {
|
||||
const shops = await invoke("Element_SimilarElementList_V2", {
|
||||
uiSelector: 'css:[id^="item_id_"] [class*="shopNameText"]',
|
||||
parent_selector: "body",
|
||||
relate_parent: "1",
|
||||
action: "text",
|
||||
start_index: "1",
|
||||
end_index: "-1"
|
||||
}) as string[];
|
||||
expect(shops).toHaveLength(46);
|
||||
expect(new Set(shops).size).toBe(46);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user