Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions cloakbrowser/human/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================================================
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 12 additions & 4 deletions js/src/human/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -152,6 +152,14 @@ class CursorState {
initialized = false;
}

async function tryEnsureStable(pageOrFrame: Page | Frame, selector: string, timeout: number): Promise<void> {
try {
await ensureStable(pageOrFrame, selector, timeout);
} catch (error) {
if (!(error instanceof ElementNotStableError)) throw error;
}
}


// ============================================================================
// Stealth DOM queries — isolated world with evaluate fallback
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
57 changes: 57 additions & 0 deletions js/tests/humanize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =========================================================================
Expand Down
66 changes: 66 additions & 0 deletions tests/test_humanize_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# =========================================================================
Expand Down