From e25121660d6b9f719604e5f5b2d7c7e73717d496 Mon Sep 17 00:00:00 2001 From: yu <1919727607@qq.com> Date: Thu, 5 Nov 2026 14:57:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=82=B9=E5=87=BB=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E8=AF=AF=E6=8A=A5=E4=B8=8E=E7=9B=B8=E4=BC=BC=E5=85=83?= =?UTF-8?q?=E7=B4=A0=E5=88=97=E8=A1=A8=E5=85=A8=E9=87=8D=E5=A4=8D=E7=AD=89?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 点击可信验证增加两类生效旁证: 页面跳转/新开标签页(淘宝搜索按钮), 点击后元素整体消失(关闭登录弹窗); 此前均被误报为"目标未收到可信点击" 并在节点超时内反复重试, 造成"点击生效却等很久还报错" - 相似元素列表引用增加 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 --- .../src/capabilities/data-table-common.ts | 20 ++- packages/rpa-core/src/engine/runtime.ts | 3 + packages/rpa-core/src/engine/types.ts | 2 + scripts/export-last-run.js | 31 ++++ src/main/browser/session.ts | 160 +++++++++++++++--- src/main/capabilities/ai-caps.ts | 2 +- src/main/capabilities/browser.ts | 5 +- src/main/capabilities/similar-list.test.ts | 137 +++++++++++++++ 8 files changed, 337 insertions(+), 23 deletions(-) create mode 100644 scripts/export-last-run.js create mode 100644 src/main/capabilities/similar-list.test.ts diff --git a/packages/rpa-core/src/capabilities/data-table-common.ts b/packages/rpa-core/src/capabilities/data-table-common.ts index d7fd2ca..305e2bf 100644 --- a/packages/rpa-core/src/capabilities/data-table-common.ts +++ b/packages/rpa-core/src/capabilities/data-table-common.ts @@ -152,8 +152,24 @@ register("Bivartable_PivotDf", (_ctx, _node, args) => { async function exportExcel(source: Table, filePath: string): Promise { 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 ?? "") : []); diff --git a/packages/rpa-core/src/engine/runtime.ts b/packages/rpa-core/src/engine/runtime.ts index 90fba10..9e2e519 100644 --- a/packages/rpa-core/src/engine/runtime.ts +++ b/packages/rpa-core/src/engine/runtime.ts @@ -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"; } diff --git a/packages/rpa-core/src/engine/types.ts b/packages/rpa-core/src/engine/types.ts index 19f42e3..7661986 100644 --- a/packages/rpa-core/src/engine/types.ts +++ b/packages/rpa-core/src/engine/types.ts @@ -67,6 +67,8 @@ export interface BrowserElementReference { tag: string; text: string; elementValue: string; + /** 相似元素列表场景:该元素在选择器全部匹配结果中的序号(动态 id 泛化后引用选择器相同,需靠序号区分) */ + index?: number; } export interface BrowserElementInfo extends BrowserElementReference { diff --git a/scripts/export-last-run.js b/scripts/export-last-run.js new file mode 100644 index 0000000..3f4344d --- /dev/null +++ b/scripts/export-last-run.js @@ -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); +}); diff --git a/src/main/browser/session.ts b/src/main/browser/session.ts index 8d446f2..f32a721 100644 --- a/src/main/browser/session.ts +++ b/src/main/browser/session.ts @@ -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; 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 | undefined; if (common?.cssSelector) { @@ -206,8 +213,8 @@ export class BrowserSession implements BrowserLike { await this.activateTab(page.id); } - private async evaluate(expression: string): Promise { - await this.followForegroundTab().catch(() => {}); + private async evaluate(expression: string, follow = true): Promise { + 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(selector: unknown, operation: string): Promise { 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(expression); + // 先在当前激活页定位;找不到再跟随前台标签页重试一次(多标签环境下 pages[0] 未必是流程页) + let info = await this.evaluate(expression, false); + if (!info) { + await this.followForegroundTab().catch(() => {}); + info = await this.evaluate(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 { + 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("location.href"); + return Boolean(url) && url !== beforeUrl; + } catch { + // evaluate 失败多半是原页面正在导航(Execution context was destroyed),本身就是点击生效的证据 + return true; + } + } + async click(selector: unknown, timeoutMs?: number): Promise { 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("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(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 => { + try { + return await this.withElementOnce(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 = []; diff --git a/src/main/capabilities/ai-caps.ts b/src/main/capabilities/ai-caps.ts index f9b3626..6ea84b0 100644 --- a/src/main/capabilities/ai-caps.ts +++ b/src/main/capabilities/ai-caps.ts @@ -3,7 +3,7 @@ import { RuntimeError } from "@rpa/core"; import { chatCompletion, requireAiConfig } from "@rpa/core"; function inputText(args: Record, 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; } diff --git a/src/main/capabilities/browser.ts b/src/main/capabilities/browser.ts index 796de5a..8ec8cdb 100644 --- a/src/main/capabilities/browser.ts +++ b/src/main/capabilities/browser.ts @@ -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; }); diff --git a/src/main/capabilities/similar-list.test.ts b/src/main/capabilities/similar-list.test.ts new file mode 100644 index 0000000..a1763d8 --- /dev/null +++ b/src/main/capabilities/similar-list.test.ts @@ -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) => ` +
+
+
+
占位
+
+
商品标题第${i + 1}号
+
+
+
¥${100 + i}.00
+
店铺${i + 1}号
+
${i === 3 ? "补贴价" : `${i + 1}人付款`}
+
+
+
`); + +const PAGE = `列表测试 +
${CARDS.join("")}
+`; + +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 extends Promise ? T : never; +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 new Promise((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((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); + }); +});