From 0e43a341ef00a8091721605ba8421ed37182a5ef Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Tue, 29 Sep 2026 23:05:00 +0200 Subject: [PATCH] fix(humanize): tolerate sub-pixel box edges in the scroll-into-view pinned check Layout can report a top edge of -1/64 px on an unscrolled page, which skipped the pinned-at-top check and sent a pointless upward scroll burst before the click. Allow 1px slack in Python, JS and .NET, with regression tests. Also credit @Shub3am in README contributors (#554). --- README.md | 1 + cloakbrowser/human/scroll.py | 3 ++- cloakbrowser/human/scroll_async.py | 3 ++- dotnet/src/CloakBrowser/Human/HumanScroll.cs | 3 ++- .../CloakBrowser.Tests/ScrollFallbackTests.cs | 14 +++++++++++++ js/src/human/scroll.ts | 3 ++- js/tests/humanize.test.ts | 21 +++++++++++++++++++ tests/test_humanize_unit.py | 20 ++++++++++++++++++ 8 files changed, 64 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 34bd034..c669871 100644 --- a/README.md +++ b/README.md @@ -1479,6 +1479,7 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith - [@Kumario1](https://github.com/Kumario1) — cloakserve idle cleanup for seeded profiles - [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass) - [@ishiko732](https://github.com/ishiko732) — HTTP proxy credentials in GeoIP resolution +- [@Shub3am](https://github.com/Shub3am) — horizontal scroll-into-view for humanized clicks ## Star History diff --git a/cloakbrowser/human/scroll.py b/cloakbrowser/human/scroll.py index 1b06797..448f276 100644 --- a/cloakbrowser/human/scroll.py +++ b/cloakbrowser/human/scroll.py @@ -155,7 +155,8 @@ def _scroll_y_into_view( # Already fully visible but off-center, with the page pinned at the boundary # in the needed direction: scrolling can't help, so don't waste the budget. - fully_visible = box["y"] >= 0 and box["y"] + box["height"] <= viewport_height + # 1px slack: layout can report a top edge of -1/64 px on an unscrolled page. + fully_visible = box["y"] >= -1 and box["y"] + box["height"] <= viewport_height + 1 if fully_visible: zone_mid = viewport_height * (cfg.scroll_target_zone[0] + cfg.scroll_target_zone[1]) / 2 need_up = box["y"] + box["height"] / 2 < zone_mid diff --git a/cloakbrowser/human/scroll_async.py b/cloakbrowser/human/scroll_async.py index 7adbda3..a60d5ef 100644 --- a/cloakbrowser/human/scroll_async.py +++ b/cloakbrowser/human/scroll_async.py @@ -141,7 +141,8 @@ async def _async_scroll_y_into_view( # Already fully visible but off-center, with the page pinned at the boundary # in the needed direction: scrolling can't help, so don't waste the budget. - fully_visible = box["y"] >= 0 and box["y"] + box["height"] <= viewport_height + # 1px slack: layout can report a top edge of -1/64 px on an unscrolled page. + fully_visible = box["y"] >= -1 and box["y"] + box["height"] <= viewport_height + 1 if fully_visible: zone_mid = viewport_height * (cfg.scroll_target_zone[0] + cfg.scroll_target_zone[1]) / 2 need_up = box["y"] + box["height"] / 2 < zone_mid diff --git a/dotnet/src/CloakBrowser/Human/HumanScroll.cs b/dotnet/src/CloakBrowser/Human/HumanScroll.cs index aed5ae9..38b2147 100644 --- a/dotnet/src/CloakBrowser/Human/HumanScroll.cs +++ b/dotnet/src/CloakBrowser/Human/HumanScroll.cs @@ -143,7 +143,8 @@ public static class HumanScroll // Already fully visible but off-center, with the page pinned at the boundary // in the needed direction: scrolling can't help, so don't waste the budget. - bool fullyVisible = box.Value.Y >= 0 && box.Value.Y + box.Value.Height <= viewportHeight; + // 1px slack: layout can report a top edge of -1/64 px on an unscrolled page. + bool fullyVisible = box.Value.Y >= -1 && box.Value.Y + box.Value.Height <= viewportHeight + 1; if (fullyVisible) { double zoneMid = viewportHeight * (cfg.ScrollTargetZone.Min + cfg.ScrollTargetZone.Max) / 2; diff --git a/dotnet/tests/CloakBrowser.Tests/ScrollFallbackTests.cs b/dotnet/tests/CloakBrowser.Tests/ScrollFallbackTests.cs index fa01963..5a64241 100644 --- a/dotnet/tests/CloakBrowser.Tests/ScrollFallbackTests.cs +++ b/dotnet/tests/CloakBrowser.Tests/ScrollFallbackTests.cs @@ -138,6 +138,20 @@ public class ScrollFallbackTests Assert.Equal(0, mouse.WheelCalls); } + [Fact] + public async Task Subpixel_negative_top_at_page_top_bails_without_scrolling() + { + // Layout reports top=-1/64 px on an unscrolled page; still fully visible. + var page = new ViewportPage((1000, 700), scroll: (0, 0, 0, 0)); + var mouse = new CountingMouse(); + Func> getBox = () => Task.FromResult(new BoundingBox(200, -0.015625, 200, 21.5)); + + var result = await HumanScroll.HumanScrollIntoViewAsync(page, mouse, getBox, 0, 0, FastConfig()); + + Assert.False(result.DidScroll); + Assert.Equal(0, mouse.WheelCalls); + } + [Fact] public async Task Fully_visible_above_zone_with_room_still_scrolls() { diff --git a/js/src/human/scroll.ts b/js/src/human/scroll.ts index b4d54ed..05a3a87 100644 --- a/js/src/human/scroll.ts +++ b/js/src/human/scroll.ts @@ -141,7 +141,8 @@ async function scrollYIntoView( return { box, cursorX, cursorY, didScroll: false }; } - const fullyVisible = box.y >= 0 && box.y + box.height <= viewport.height; + // 1px slack: layout can report a top edge of -1/64 px on an unscrolled page. + const fullyVisible = box.y >= -1 && box.y + box.height <= viewport.height + 1; if (fullyVisible) { const zoneMid = viewport.height * (cfg.scroll_target_zone[0] + cfg.scroll_target_zone[1]) / 2; const needUp = box.y + box.height / 2 < zoneMid; diff --git a/js/tests/humanize.test.ts b/js/tests/humanize.test.ts index 30bbbf8..69dc7d5 100644 --- a/js/tests/humanize.test.ts +++ b/js/tests/humanize.test.ts @@ -1634,6 +1634,27 @@ describe("humanScrollIntoView", () => { expect(page.evaluate).not.toHaveBeenCalled(); }); + it("bails when layout reports a sub-pixel negative top on an unscrolled page", async () => { + const { humanScrollIntoView } = await import("../src/human/scroll.js"); + const cfg = resolveConfig("default"); + + const page: any = { + viewportSize: () => ({ width: 1000, height: 700 }), + evaluate: vi.fn(), + _stealth: { evaluate: vi.fn(async () => ({ y: 0, maxY: 0, x: 0, minX: 0, maxX: 0 })) }, + }; + const raw = { + move: vi.fn(async () => { }), down: vi.fn(async () => { }), + up: vi.fn(async () => { }), wheel: vi.fn(async () => { }), + }; + const topBox = { x: 200, y: -0.015625, width: 200, height: 21.5 }; + + const result = await humanScrollIntoView(page, raw, async () => topBox, 0, 0, cfg); + + expect(result.didScroll).toBe(false); + expect(raw.wheel).not.toHaveBeenCalled(); + }); + it("still scrolls a fully-visible above-zone element when the page CAN scroll up (no over-bail)", async () => { const { humanScrollIntoView } = await import("../src/human/scroll.js"); const cfg = resolveConfig("default", { diff --git a/tests/test_humanize_unit.py b/tests/test_humanize_unit.py index efe533e..2a5354f 100644 --- a/tests/test_humanize_unit.py +++ b/tests/test_humanize_unit.py @@ -2377,6 +2377,26 @@ class TestScrollIntoViewIfNeeded: assert box == clipped_box assert not raw.wheel.called + def test_human_scroll_into_view_subpixel_negative_top_at_page_top_bails(self): + """Layout reports top=-1/64 px on an unscrolled page. Still fully visible, + so the pinned-at-top check must stop a pointless upward scroll.""" + from cloakbrowser.human.scroll import human_scroll_into_view + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + page = MagicMock() + page.viewport_size = {"width": 1000, "height": 700} + page._stealth_world.evaluate.return_value = {"y": 0, "maxY": 0, "x": 0, "minX": 0, "maxX": 0} + raw = MagicMock() + top_box = {"x": 200, "y": -0.015625, "width": 200, "height": 21.5} + + _, _, _, did_scroll = human_scroll_into_view( + page, raw, lambda: top_box, 0, 0, cfg, + ) + assert not did_scroll + assert not raw.wheel.called + def test_human_scroll_into_view_scrolls_x_left_on_rtl_page(self): """RTL page at its start (scrollX 0, range -600..0): a box in the left overflow gets negative horizontal wheel events."""