From 71a2d755bf423336dbabf179dee1b35da75a984f Mon Sep 17 00:00:00 2001 From: Kumario1 Date: Fri, 5 Jun 2026 21:42:28 -0500 Subject: [PATCH 1/2] fix(humanize): tolerate post-scroll motion --- cloakbrowser/human/__init__.py | 26 ++++++++++++++++++++------ js/src/human/index.ts | 16 ++++++++++++---- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/cloakbrowser/human/__init__.py b/cloakbrowser/human/__init__.py index 4861f2ed..4ad8b7b6 100644 --- a/cloakbrowser/human/__init__.py +++ b/cloakbrowser/human/__init__.py @@ -53,6 +53,20 @@ logger = logging.getLogger("cloakbrowser.human") +def _try_ensure_stable(page: Any, selector: str, timeout: float) -> None: + try: + ensure_stable(page, selector, timeout=timeout) + except ElementNotStableError: + logger.debug("Continuing after post-scroll stability check for %r", selector) + + +async def _async_try_ensure_stable(page: Any, selector: str, timeout: float) -> None: + try: + await async_ensure_stable(page, selector, timeout=timeout) + except ElementNotStableError: + logger.debug("Continuing after post-scroll stability check for %r", selector) + + # ============================================================================ # CDP Isolated World — stealth DOM evaluation # ============================================================================ @@ -915,7 +929,7 @@ def _remaining_ms(): cursor.y = cy is_input = _is_input_element(page, selector) if not force and did_scroll: - ensure_stable(page, selector, timeout=_remaining_ms()) + _try_ensure_stable(page, selector, timeout=_remaining_ms()) box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box target = click_target(box, is_input, call_cfg) if not force: @@ -946,7 +960,7 @@ def _remaining_ms(): cursor.y = cy is_input = _is_input_element(page, selector) if not force and did_scroll: - ensure_stable(page, selector, timeout=_remaining_ms()) + _try_ensure_stable(page, selector, timeout=_remaining_ms()) box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box target = click_target(box, is_input, call_cfg) if not force: @@ -979,7 +993,7 @@ def _remaining_ms(): cursor.x = cx cursor.y = cy if not force and did_scroll: - ensure_stable(page, selector, timeout=_remaining_ms()) + _try_ensure_stable(page, selector, timeout=_remaining_ms()) box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box target = click_target(box, False, call_cfg) if not force: @@ -1840,7 +1854,7 @@ def _remaining_ms(): cursor.y = cy is_input = await _async_is_input_element(page, selector) if not force and did_scroll: - await async_ensure_stable(page, selector, timeout=_remaining_ms()) + await _async_try_ensure_stable(page, selector, timeout=_remaining_ms()) box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box target = click_target(box, is_input, call_cfg) if not force: @@ -1871,7 +1885,7 @@ def _remaining_ms(): cursor.y = cy is_input = await _async_is_input_element(page, selector) if not force and did_scroll: - await async_ensure_stable(page, selector, timeout=_remaining_ms()) + await _async_try_ensure_stable(page, selector, timeout=_remaining_ms()) box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box target = click_target(box, is_input, call_cfg) if not force: @@ -1904,7 +1918,7 @@ def _remaining_ms(): cursor.x = cx cursor.y = cy if not force and did_scroll: - await async_ensure_stable(page, selector, timeout=_remaining_ms()) + await _async_try_ensure_stable(page, selector, timeout=_remaining_ms()) box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box target = click_target(box, False, call_cfg) if not force: diff --git a/js/src/human/index.ts b/js/src/human/index.ts index 220970e9..f839979f 100644 --- a/js/src/human/index.ts +++ b/js/src/human/index.ts @@ -29,7 +29,7 @@ import { humanType } from './keyboard.js'; import { scrollToElement, humanScrollIntoView } from './scroll.js'; import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js'; import { - ensureActionable, ensureStable, checkPointerEvents, + ensureActionable, ensureStable, checkPointerEvents, ElementNotStableError, CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK, type CheckName, } from './actionability.js'; @@ -152,6 +152,14 @@ class CursorState { initialized = false; } +async function tryEnsureStable(pageOrFrame: Page | Frame, selector: string, timeout: number): Promise { + try { + await ensureStable(pageOrFrame, selector, timeout); + } catch (error) { + if (!(error instanceof ElementNotStableError)) throw error; + } +} + // ============================================================================ // Stealth DOM queries — isolated world with evaluate fallback @@ -333,7 +341,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void { const isInput = await isInputElement(stealth, page, selector); let finalBox = box; if (!force && didScroll) { - await ensureStable(page, selector, remainingMs()); + await tryEnsureStable(page, selector, remainingMs()); finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box; } const target = clickTarget(finalBox, isInput, callCfg); @@ -365,7 +373,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void { const isInput = await isInputElement(stealth, page, selector); let finalBox = box; if (!force && didScroll) { - await ensureStable(page, selector, remainingMs()); + await tryEnsureStable(page, selector, remainingMs()); finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box; } const target = clickTarget(finalBox, isInput, callCfg); @@ -399,7 +407,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void { cursor.y = cursorY; let finalBox = box; if (!force && didScroll) { - await ensureStable(page, selector, remainingMs()); + await tryEnsureStable(page, selector, remainingMs()); finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box; } const target = clickTarget(finalBox, false, callCfg); From cd4711efb4e8c28c54c1cd5396d9a8de61d48313 Mon Sep 17 00:00:00 2001 From: Kumario1 Date: Fri, 5 Jun 2026 21:45:22 -0500 Subject: [PATCH 2/2] test(humanize): cover moving post-scroll targets --- js/tests/humanize.test.ts | 57 ++++++++++++++++++++++++++++++++ tests/test_humanize_unit.py | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/js/tests/humanize.test.ts b/js/tests/humanize.test.ts index cbb787f4..d7815295 100644 --- a/js/tests/humanize.test.ts +++ b/js/tests/humanize.test.ts @@ -337,6 +337,63 @@ describe("patchPage check/uncheck idle", () => { }); }); +// ========================================================================= +// Post-scroll stability on dynamic pages +// ========================================================================= +describe("post-scroll stability", () => { + it("click continues when the target keeps moving after scroll", async () => { + const scrollMod = await import("../src/human/scroll.js"); + const { patchPage } = await import("../src/human/index.js"); + + const movingBoxes = [ + { x: 100, y: 100, width: 50, height: 30 }, + { x: 125, y: 100, width: 50, height: 30 }, + { x: 150, y: 100, width: 50, height: 30 }, + ]; + const loc: any = { + waitFor: vi.fn(async () => { }), + isVisible: vi.fn(async () => true), + isEnabled: vi.fn(async () => true), + isEditable: vi.fn(async () => true), + boundingBox: vi.fn(async () => movingBoxes.shift() ?? { x: 150, y: 100, width: 50, height: 30 }), + evaluate: vi.fn(async () => ({ hit: true })), + }; + loc.first = vi.fn(() => loc); + + const page = buildMockPage(); + page.locator = vi.fn(() => loc); + page.evaluate = vi.fn(async () => false); + + const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation( + async (_page, _raw, _sel, cx, cy) => ({ + box: { x: 100, y: 100, width: 50, height: 30 }, + cursorX: cx, + cursorY: cy, + didScroll: true, + }), + ); + + const cfg = resolveConfig("default", { + click_aim_delay_button: [0, 0], + click_hold_button: [0, 0], + idle_between_actions: false, + mouse_burst_pause: [0, 0], + mouse_max_steps: 1, + mouse_min_steps: 1, + mouse_overshoot_chance: 0, + }); + const cursor = { x: 100, y: 100, initialized: true }; + patchPage(page as any, cfg, cursor as any); + + try { + await expect((page as any).click("#moving", { timeout: 80 })).resolves.toBeUndefined(); + expect(page.mouse.down).toHaveBeenCalled(); + } finally { + scrollSpy.mockRestore(); + } + }); +}); + // ========================================================================= // patchPage behavioral: press focus check // ========================================================================= diff --git a/tests/test_humanize_unit.py b/tests/test_humanize_unit.py index 4c3c0ea1..779be2a4 100644 --- a/tests/test_humanize_unit.py +++ b/tests/test_humanize_unit.py @@ -1474,6 +1474,72 @@ def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000): assert 4900 <= captured.get("timeout", 0) <= 5000, f"expected ~5000, got {captured}" +class TestPostScrollStability: + """Moving elements should not fail a click solely because the extra + post-scroll stability check times out.""" + + def test_page_click_continues_after_post_scroll_stability_timeout(self): + import cloakbrowser.human as h + from cloakbrowser.human import _CursorState + from cloakbrowser.human.actionability import ElementNotStableError + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, patch + + cfg = resolve_config("default", { + "click_aim_delay_button": (0, 0), + "click_hold_button": (0, 0), + "idle_between_actions": False, + "mouse_burst_pause": (0, 0), + "mouse_max_steps": 1, + "mouse_min_steps": 1, + "mouse_overshoot_chance": 0, + }) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + + page = MagicMock() + page.click = MagicMock() + page.dblclick = MagicMock() + page.hover = MagicMock() + page.type = MagicMock() + page.fill = MagicMock() + page.goto = MagicMock() + page.is_checked = MagicMock(return_value=False) + page.viewport_size = {"width": 1280, "height": 720} + page.evaluate = MagicMock(return_value=False) + page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp")) + page.mouse = MagicMock() + page.keyboard = MagicMock() + page.query_selector = MagicMock(return_value=None) + page.query_selector_all = MagicMock(return_value=[]) + page.wait_for_selector = MagicMock(return_value=None) + page.main_frame = MagicMock() + page.main_frame.child_frames = [] + + loc = MagicMock() + loc.bounding_box = MagicMock(return_value={"x": 150, "y": 100, "width": 50, "height": 30}) + page.locator = MagicMock(return_value=MagicMock(first=loc)) + + with patch.object( + h, + "scroll_to_element", + return_value=({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, True), + ), patch.object(h, "ensure_actionable"), patch.object( + h, + "ensure_stable", + side_effect=ElementNotStableError("#moving"), + ), patch.object(h, "check_pointer_events") as pointer_check: + h.patch_page(page, cfg, cursor) + page.click("#moving", timeout=500) + + page.mouse.down.assert_called_once() + page.mouse.up.assert_called_once() + pointer_check.assert_called_once() + loc.bounding_box.assert_called_once() + + # ========================================================================= # 16. Per-call human_config override (typing speed customization) # =========================================================================