fix: 对齐浏览器可信输入并增加端到端门禁

This commit is contained in:
yu committed 2026-10-27 14:18:51 +08:00
1 parent 6adf772987
commit 65591322ee
6 files changed
+146 -22

No files matched your search

+46 -19
View File
@@ -40,6 +40,7 @@ interface ElementInfo {
visible: boolean;
disabled: boolean;
rect: { x: number; y: number; width: number; height: number };
count: number;
}
function selectorSpec(selector: unknown): { kind: string; value: string; role?: string; name?: string; attr?: string } {
@@ -93,7 +94,7 @@ export class BrowserSession implements BrowserLike {
const win = new BrowserWindow({
width: 1200,
height: 820,
show: true,
show: process.env.RPA_E2E !== "1",
title: "RPA 运行浏览器",
backgroundColor: "#ffffff",
webPreferences: {
@@ -200,35 +201,37 @@ export class BrowserSession implements BrowserLike {
const spec = selectorSpec(selector);
const expression = `(() => {
const spec = ${JSON.stringify(spec)};
const pick = (nodes) => {
const visible = (nodes) => {
const result = [];
for (const el of nodes) {
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
return el;
result.push(el);
}
return null;
return result;
};
let el = null;
let matches = [];
if (spec.kind === 'text') {
const target = spec.value;
const all = [...document.querySelectorAll('*')].filter((node) => {
const text = (node.innerText || node.textContent || '').trim();
return text === target || (text.includes(target) && text.length <= target.length + 20);
});
el = pick(all.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length));
matches = visible(all.sort((a, b) => (a.innerText || '').length - (b.innerText || '').length));
} else if (spec.kind === 'aria') {
const role = spec.role || spec.value;
const name = spec.name || '';
const all = [...document.querySelectorAll('[role="' + role + '"], ' + role)];
el = pick(all.filter((node) => {
matches = visible(all.filter((node) => {
const acc = node.getAttribute('aria-label') || node.innerText || '';
return !name || acc.includes(name);
}));
} else {
el = pick([...document.querySelectorAll(spec.value)]);
matches = visible([...document.querySelectorAll(spec.value)]);
}
const el = matches[0] || null;
if (!el) return null;
el.scrollIntoView({ block: 'center', inline: 'center' });
const rect = el.getBoundingClientRect();
@@ -238,6 +241,7 @@ export class BrowserSession implements BrowserLike {
value: el.value ?? '',
visible: rect.width > 0 && rect.height > 0,
disabled: Boolean(el.disabled),
count: matches.length,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
};
})()`;
@@ -247,14 +251,21 @@ export class BrowserSession implements BrowserLike {
recommendation: "检查选择器,或先用拾取器重新采集"
});
}
if (info.count > 1) {
throw new RuntimeError("ambiguous", `选择器匹配到 ${info.count} 个可见元素:${spec.kind}:${spec.value}`, {
recommendation: "使用更稳定的 data-testid、aria-label 或唯一 CSS 选择器"
});
}
return { ...info, selector: `${spec.kind}:${spec.value}` };
}
private async mouseClick(x: number, y: number): Promise<void> {
const point = { x: Math.round(x), y: Math.round(y) };
await this.cdp("Input.dispatchMouseEvent", { type: "mouseMoved", ...point, button: "none" });
await this.cdp("Input.dispatchMouseEvent", { type: "mousePressed", ...point, button: "left", clickCount: 1 });
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", clickCount: 1 });
await this.cdp("Input.dispatchMouseEvent", { type: "mouseMoved", ...point, button: "none", buttons: 0 });
await new Promise((resolve) => setTimeout(resolve, 35));
await this.cdp("Input.dispatchMouseEvent", { type: "mousePressed", ...point, button: "left", buttons: 1, clickCount: 1 });
await new Promise((resolve) => setTimeout(resolve, 25));
await this.cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, button: "left", buttons: 0, clickCount: 1 });
}
async click(selector: unknown): Promise<unknown> {
@@ -265,8 +276,26 @@ export class BrowserSession implements BrowserLike {
if (info.disabled) {
throw new RuntimeError("not_found", `元素已禁用:${info.selector}`);
}
const clickable = await this.withElement<boolean>(selector, `
const rect = el.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return Boolean(hit && (hit === el || el.contains(hit) || hit.contains(el)));
`);
if (!clickable) throw new RuntimeError("not_found", `目标元素被其他内容遮挡:${info.selector}`, { recommendation: "关闭遮挡弹窗或等待页面稳定" });
const clickToken = `rpa_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
await this.withElement(selector, `
const token = ${JSON.stringify(clickToken)};
el.addEventListener('click', () => { el.dataset.rpaClickToken = token; }, { once: true });
return true;
`);
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: "检查元素是否被遮挡或重新拾取元素" });
return { selector: info.selector, tag: info.tag, text: info.text };
}
@@ -274,17 +303,15 @@ export class BrowserSession implements BrowserLike {
const info = await this.locate(selector);
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
if (clearFirst) {
await this.evaluate(`(() => {
const el = document.activeElement;
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) { el.select(); return true; }
return false;
})()`);
await this.cdp("Input.dispatchKeyEvent", { type: "keyDown", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
await this.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 });
await this.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
await this.cdp("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 });
await new Promise((resolve) => setTimeout(resolve, 30));
}
await this.cdp("Input.insertText", { text });
await new Promise((resolve) => setTimeout(resolve, 120));
const actual = await this.evaluate<string>(`(() => { const el = document.activeElement; return el ? (el.value ?? '') : ''; })()`);
const expected = clearFirst ? text : `${info.value || ""}${text}`;
const actual = await this.evaluate<string>(`(() => { const el = document.activeElement; return el ? (el.value ?? el.textContent ?? '') : ''; })()`);
if (actual !== expected) throw new RuntimeError("evaluate_error", `输入校验失败:期望「${expected}」,实际「${actual}」`, { recommendation: "检查输入框状态或重新拾取元素" });
return { selector: info.selector, value: actual };
}
+3 -2
View File
@@ -11,6 +11,7 @@ import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/ser
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
const e2e = process.env.RPA_E2E === "1";
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
@@ -33,7 +34,7 @@ function createWindow(): void {
}
});
mainWindow.on("ready-to-show", () => mainWindow?.show());
mainWindow.on("ready-to-show", () => { if (!e2e) mainWindow?.show(); });
mainWindow.on("closed", () => {
mainWindow = null;
});
@@ -115,7 +116,7 @@ if (!gotLock) {
app.whenReady().then(async () => {
try {
await startFixtureServer();
await startFixtureServer(Number(process.env.RPA_FIXTURE_PORT || 17863));
} catch (error) {
console.warn("local fixture server unavailable", error);
}