fix(controlBrowser): never report moveMouse/search success without a real move
Some checks failed
Release / semantic-release (push) Successful in 19s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m15s
Release / build-linux (push) Failing after 7m7s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Some checks failed
Release / semantic-release (push) Successful in 19s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m15s
Release / build-linux (push) Failing after 7m7s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
moveMouse returned ok:true even when humanHover did nothing (no on-screen box) or the selector never matched — recreating the "claims it moved but didn't" bug. Now: humanHover returns a boolean (and brings the element into view first); moveMouse returns ok:false when the target isn't found or has no on-screen box, and when site=naver/... whose box isn't on the current page it navigates to the site home first before hovering. search now reports input=human|api-fallback|api so a silent fallback to cursor-less DOM input is visible, and the tool surfaces that note in the reply instead of implying a human-like search happened.
This commit is contained in:
@@ -125,23 +125,31 @@ try {
|
|||||||
// 2) Click the on-page search box, type the query, submit.
|
// 2) Click the on-page search box, type the query, submit.
|
||||||
const box = page.locator(s.box).first();
|
const box = page.locator(s.box).first();
|
||||||
await box.waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
|
await box.waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
|
||||||
|
// Report which input path actually ran: 'human' = real xdotool cursor
|
||||||
|
// move + char typing; 'api-fallback' = the humanClick path threw and we
|
||||||
|
// fell back to cursor-less DOM click/fill; 'api' = no xdotool at all. This
|
||||||
|
// makes "did the cursor really move" verifiable from the result.
|
||||||
|
let searchInput;
|
||||||
if (HAS_XDOTOOL && cmd.human !== false) {
|
if (HAS_XDOTOOL && cmd.human !== false) {
|
||||||
try {
|
try {
|
||||||
await human.humanClick(page, box);
|
await human.humanClick(page, box);
|
||||||
await human.humanType(q);
|
await human.humanType(q);
|
||||||
await human.pressKey('Return');
|
await human.pressKey('Return');
|
||||||
|
searchInput = 'human';
|
||||||
} catch {
|
} catch {
|
||||||
|
searchInput = 'api-fallback';
|
||||||
await box.click().catch(() => {});
|
await box.click().catch(() => {});
|
||||||
await box.fill(q).catch(() => {});
|
await box.fill(q).catch(() => {});
|
||||||
await page.keyboard.press('Enter').catch(() => {});
|
await page.keyboard.press('Enter').catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
searchInput = 'api';
|
||||||
await box.click().catch(() => {});
|
await box.click().catch(() => {});
|
||||||
await box.fill(q);
|
await box.fill(q);
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
}
|
}
|
||||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||||
out({ ok: true, site: SITES[siteKey] ? siteKey : 'google', query: q, url: page.url(), title: await page.title().catch(() => '') });
|
out({ ok: true, site: SITES[siteKey] ? siteKey : 'google', query: q, url: page.url(), title: await page.title().catch(() => ''), input: searchInput });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +220,10 @@ try {
|
|||||||
// Move/hover the REAL cursor onto an element WITHOUT clicking. Target is a
|
// Move/hover the REAL cursor onto an element WITHOUT clicking. Target is a
|
||||||
// CSS selector, or site=naver/google/... for that site's search box.
|
// CSS selector, or site=naver/google/... for that site's search box.
|
||||||
// Only meaningful with xdotool (the visible cursor); with no xdotool there
|
// Only meaningful with xdotool (the visible cursor); with no xdotool there
|
||||||
// is no cursor to move, so report that rather than faking success.
|
// is no cursor to move, so report that rather than faking success. Every
|
||||||
|
// failure to actually move (no xdotool, selector never matches, element
|
||||||
|
// has no on-screen box) returns ok:false — we must never claim the cursor
|
||||||
|
// moved when it did not (the exact bug the user reported).
|
||||||
const siteKey = String(cmd.site || '').toLowerCase();
|
const siteKey = String(cmd.site || '').toLowerCase();
|
||||||
const selector = String(cmd.selector || '').trim() || (SITES[siteKey] ? SITES[siteKey].box : '');
|
const selector = String(cmd.selector || '').trim() || (SITES[siteKey] ? SITES[siteKey].box : '');
|
||||||
if (!selector) throw new Error('moveMouse: no selector or known site');
|
if (!selector) throw new Error('moveMouse: no selector or known site');
|
||||||
@@ -221,9 +232,25 @@ try {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await front(page);
|
await front(page);
|
||||||
const locator = page.locator(selector).first();
|
let locator = page.locator(selector).first();
|
||||||
await locator.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
|
let visible = await locator.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false);
|
||||||
await human.humanHover(page, locator);
|
// A named site whose search box isn't on the current page: go to its home
|
||||||
|
// first (real omnibox), then target the box there.
|
||||||
|
if (!visible && SITES[siteKey]) {
|
||||||
|
try { await human.navigateOmnibox(SITES[siteKey].home); await page.waitForLoadState('domcontentloaded').catch(() => {}); }
|
||||||
|
catch { await page.goto(SITES[siteKey].home, { waitUntil: 'domcontentloaded' }).catch(() => {}); }
|
||||||
|
locator = page.locator(SITES[siteKey].box).first();
|
||||||
|
visible = await locator.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false);
|
||||||
|
}
|
||||||
|
if (!visible) {
|
||||||
|
out({ ok: false, error: `moveMouse: target not found (${cmd.selector || siteKey})` });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const moved = await human.humanHover(page, locator);
|
||||||
|
if (!moved) {
|
||||||
|
out({ ok: false, error: 'moveMouse: element has no on-screen box; cursor not moved' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
out({ ok: true, target: cmd.selector || siteKey, input: 'human' });
|
out({ ok: true, target: cmd.selector || siteKey, input: 'human' });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,14 +136,18 @@ export async function navigateOmnibox(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move the real cursor over an element (hover, no click) - e.g. to reveal a
|
// Move the real cursor over an element (hover, no click) - e.g. to reveal a
|
||||||
// video player's controls or to focus it for a keyboard shortcut.
|
// video player's controls or to focus it for a keyboard shortcut. Returns true
|
||||||
|
// only if the element had an on-screen box and the cursor was actually moved;
|
||||||
|
// returns false when there is nothing to move to (so callers must not report
|
||||||
|
// success). Brings the element into view with a real wheel scroll first.
|
||||||
export async function humanHover(page, locator) {
|
export async function humanHover(page, locator) {
|
||||||
const box = await locator.boundingBox().catch(() => null);
|
const box = await bringIntoView(page, locator);
|
||||||
if (!box) return;
|
if (!box) return false;
|
||||||
const g = await page.evaluate(() => ({ sx: window.screenX, sy: window.screenY, ow: window.outerWidth, oh: window.outerHeight, iw: window.innerWidth, ih: window.innerHeight }));
|
const g = await page.evaluate(() => ({ sx: window.screenX, sy: window.screenY, ow: window.outerWidth, oh: window.outerHeight, iw: window.innerWidth, ih: window.innerHeight }));
|
||||||
const bx = Math.max(0, Math.round((g.ow - g.iw) / 2));
|
const bx = Math.max(0, Math.round((g.ow - g.iw) / 2));
|
||||||
const oy = g.sy + Math.max(0, g.oh - g.ih - bx);
|
const oy = g.sy + Math.max(0, g.oh - g.ih - bx);
|
||||||
await humanMove(Math.round(g.sx + bx + box.x + box.width * 0.5), Math.round(oy + box.y + box.height * 0.4));
|
await humanMove(Math.round(g.sx + bx + box.x + box.width * 0.5), Math.round(oy + box.y + box.height * 0.4));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { sleep, rand };
|
export { sleep, rand };
|
||||||
|
|||||||
@@ -156,7 +156,12 @@ class ControlBrowserTool(Tool):
|
|||||||
if action == "navigate":
|
if action == "navigate":
|
||||||
return f"브라우저에서 {data.get('url', args.get('url'))} 로 이동했습니다."
|
return f"브라우저에서 {data.get('url', args.get('url'))} 로 이동했습니다."
|
||||||
if action == "search":
|
if action == "search":
|
||||||
return f"{data.get('site', '')}에서 '{data.get('query', args.get('query'))}'를 검색해 화면에 띄웠습니다."
|
base = f"{data.get('site', '')}에서 '{data.get('query', args.get('query'))}'를 검색해 화면에 띄웠습니다."
|
||||||
|
# Flag when the real cursor path didn't run, so a silent fallback to
|
||||||
|
# cursor-less DOM input is visible rather than reported as "human".
|
||||||
|
if data.get("input") in ("api", "api-fallback"):
|
||||||
|
base += " (참고: 실제 마우스 커서 이동 없이 처리됨)"
|
||||||
|
return base
|
||||||
if action in ("back", "forward", "refresh"):
|
if action in ("back", "forward", "refresh"):
|
||||||
return f"브라우저: {action} 완료 ({data.get('url', '')})."
|
return f"브라우저: {action} 완료 ({data.get('url', '')})."
|
||||||
if action in ("status", "listTabs"):
|
if action in ("status", "listTabs"):
|
||||||
|
|||||||
@@ -35,3 +35,22 @@ def test_description_distinguishes_cursor_paths(tool):
|
|||||||
desc = tool.description
|
desc = tool.description
|
||||||
assert "moveMouse" in desc
|
assert "moveMouse" in desc
|
||||||
assert "address bar" in desc # navigate is described as address-bar typing
|
assert "address bar" in desc # navigate is described as address-bar typing
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_summary_flags_cursorless_fallback(tool):
|
||||||
|
# When the real xdotool cursor path didn't run, the summary must say so
|
||||||
|
# rather than implying a human-like search happened.
|
||||||
|
human = tool._summarise("search", {"query": "날씨"}, {"ok": True, "site": "naver", "query": "날씨", "input": "human"})
|
||||||
|
assert "참고: 실제 마우스" not in human
|
||||||
|
|
||||||
|
fell_back = tool._summarise("search", {"query": "날씨"}, {"ok": True, "site": "naver", "query": "날씨", "input": "api-fallback"})
|
||||||
|
assert "실제 마우스 커서 이동 없이" in fell_back
|
||||||
|
|
||||||
|
|
||||||
|
def test_movemouse_summary_only_runs_on_success(tool):
|
||||||
|
# _summarise is only called on ok:true; an ok:false (target not found / no
|
||||||
|
# xdotool) is handled by run() as a failure reply, so a failed move can no
|
||||||
|
# longer be reported as "moved". Sanity-check the success summary names a
|
||||||
|
# target rather than a placeholder when one is present.
|
||||||
|
summary = tool._summarise("moveMouse", {"selector": "#query"}, {"ok": True, "target": "#query"})
|
||||||
|
assert "#query" in summary
|
||||||
|
|||||||
Reference in New Issue
Block a user