Skip to content

fix(humanize): re-scroll after the settle wait so clicks land (#329) - #468

Closed
Cloak-HQ wants to merge 1 commit into
mainfrom
fix/humanize-rescroll-after-settle
Closed

fix(humanize): re-scroll after the settle wait so clicks land (#329)#468
Cloak-HQ wants to merge 1 commit into
mainfrom
fix/humanize-rescroll-after-settle

Conversation

@Cloak-HQ

Copy link
Copy Markdown
Contributor

Fixes the real cause behind #329.

Symptom

humanize=True silently misses clicks on pages that are still loading. No exception, the call returns as if it worked, and the click simply never happened.

Root cause

The click pipeline scrolls the element into view, then waits for its position to settle if the page is reflowing. It never scrolls again. On a page that keeps reflowing, the element is pushed back off screen during that wait, so the coordinates we compute are outside the viewport and the click lands on nothing.

Instrumented run, settle_ms=15000, viewport 1920x959, on current main:

t= 4.65s  scroll_to_element EXIT   box y=607, did_scroll=True
t= 4.65s  ensure_stable ENTER
t=16.05s  ensure_stable EXIT ok             <- waited out the reflow, correctly
t=16.05s  check_pointer_events at (58,1238) <- y=1238, viewport is 959 tall
t=30.18s  check_pointer_events EXIT ok      <- 14s of retries, then failed open
t=31.50s  human_move EXIT
t=31.67s  mousedown y=1238 -> hit <HTML>
          err=NONE  clicked=False

The element was at y=607 when we scrolled. Eleven seconds later, when it finally stopped moving, it was at y=1219 — below the fold. We clicked at 1238.

A second defect made it silent rather than loud. check_pointer_events correctly detected there was no element at those coordinates and retried for 14 seconds. But its bounding_box timeout is computed as max(1, min(deadline - now, 1000)), so as the deadline approaches it clamps to ~1ms and always throws. The except treats a throw as indeterminate and fails open. A miss the check had already proven became "unknown", and the click was allowed through.

What we tried first, and why it did not work

The obvious readings of #329 are all wrong, and each was tested before being discarded.

Is the stability check too strict? _boxes_differ has a 1px tolerance and retries with backoff, so it only fires on real movement. But it samples 100ms apart, where Playwright compares two consecutive animation frames (rafCountForStablePosition() returns 1 on Chromium). Rewrote it to match Playwright's definition using an in-page rAF comparison. Result: the check passed, and the click then silently missed. Loosening the check does not produce a landed click, it produces a click on whatever moved into that spot.

Is the element moving during the mouse travel? Added a re-aim step after human_move, re-reading the box and correcting. Missed by 5px. Changed it to re-aim at the element centre with an edge inset. Missed by 12px, landed on <HTML>.

At that point the numbers stopped making sense — the corrections were not converging on the element at all — which is what prompted instrumenting the pipeline stage by stage. That trace is above, and it showed the element was never on screen to begin with.

Is our own scroll animation the cause? Ruled out by measurement: a fully static page passes 20/20.

Does #355's approach work? Tested it directly. Swallowing ElementNotStableError lets ensure_stable consume the whole budget, so the next line's bounding_box gets max(1, ~0) and the user receives Timeout 1ms exceeded instead of a message describing the problem. Still fails, with a worse error. Declined in #355 with these results.

The fix

1. Re-scroll after the settle wait. The existing bounding_box() re-read refreshed the coordinates but never checked they were still on screen. Replaced with the same scroll_to_element call already used before the wait. It returns did_scroll=False when the element is already visible, so it is a no-op on pages that never moved — which is why the static-page result is unchanged.

2. Do not launder a confirmed miss. check_pointer_events now remembers a determined miss. A later attempt that merely errored can no longer turn it into a pass at the deadline. Genuinely indeterminate results still fail open, exactly as before, since failing closed would block legitimate clicks.

Results

Test page: button below the fold, content growing above it every 50ms, stopping after settle_ms. Real Chromium in Docker, default 30s click timeout.

page reflows for before after (Python) after (JS)
0–5s PASS PASS PASS
10s silent miss, 32.5s PASS, 16.4s PASS, 14.3s
15s silent miss, 32.0s PASS, 19.9s PASS, 19.9s
20s silent miss, 32.6s PASS, 25.9s PASS, 26.2s
25s silent miss, 32.4s PASS, 30.7s
30s+ ElementNotStableError ElementNotStableError ElementNotStableError
static page ×20 20/20 20/20

Pages that reflow longer than the call's own timeout still raise, which is correct — the page never held still long enough to click.

Scope

Python (sync + async), JavaScript, .NET. Click, dblclick, hover.

Verification

  • Python: 94 passed. The one failure is environmental, Playwright's own chrome-headless-shell is not installed on the dev machine.
  • JavaScript: 512 passed across 16 files. Includes the existing checkPointerEventsHandle still throws when genuinely covered test, so the fail-open change did not open the closed path.
  • .NET: builds clean. Not runtime-verified, dotnet test could not run locally. CI should exercise it.
  • Browser sweep above run for both Python and JavaScript. JS matched Python at every row.

Asking for a review and a test run

@evelaa123 you wrote most of this layer — could you review this and run it against your own setup?

  1. Test it on real pages you use humanize on. This changes the click path for every click, dblclick and hover under humanize=True, so it touches everything. We verified on a synthetic reflowing page and a static one, not against real sites. If you have flows that were intermittently missing clicks, this is the change most likely to move them, in either direction.
  2. Does the second scroll read as natural? It emits real wheel input where previously there was only a box read. It fires only when the element is genuinely off screen, and scrolling again is what a person would do, but you have the better instinct for this.
  3. Is the fail-open line drawn in the right place? The comment at the original except says failing closed would block legitimate clicks, and I did not want to undo that reasoning — only to stop a proven miss being reclassified.

The repro is one HTML file plus a script. Say the word and I will attach both so you can reproduce before and after directly.

@eofreternal this touches the pointer-events check from #303 — the iframe offset logic is untouched, only the fail-open branch. A look at that part would be appreciated.

Not included

No regression test in the suites. The mocked unit tests cannot catch this — they do not observe whether the click landed, which is why this survived. It needs a real-browser test asserting a click handler fired. Happy to add one here rather than as a follow-up.

An element scrolled into view can be pushed off screen again while we wait
for a reflowing page to settle. Nothing scrolled a second time, so the click
was dispatched outside the viewport and hit nothing, with no error raised.

Also stop the pointer-events check from turning a confirmed miss back into
'undetermined' when a late probe times out, which let that click through.

Measured on a page reflowing 10-25s: silent miss at ~32s before, clean click
now. Static pages and pages reflowing past the timeout are unchanged.
@evelaa123

evelaa123 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Thank you!

I will definitely test this on the existing code. However, I don't have any scripts that interact with pages where this could potentially be an issue. I’ll need approximately 1–3 days to verify this code. I hope you understand!

ETA: 1–3 days

@Cloak-HQ

Copy link
Copy Markdown
Contributor Author

Thanks, and no rush at all.

Since you mentioned you don't have a page that hits this, here is the repro so you can see the before and after directly rather than only checking for regressions.

Two files, saved next to each other.

page.html:

<!doctype html>
<html><head><meta charset="utf-8"><title>settle-sweep</title>
<style>body{font-family:sans-serif;margin:0}.spacer{height:1800px;background:linear-gradient(#eee,#ccc)}
.row{height:3px}button{padding:14px 22px;font-size:18px}</style></head>
<body>
  <div class="spacer">scroll</div>
  <div id="grow"></div>
  <button id="moving" onclick="window.__clicked=true">TARGET</button>
  <div class="spacer"></div>
<script>
  window.__clicked=false;
  // settle_ms = how long the page keeps reflowing before it goes quiet.
  const SETTLE = parseInt(new URLSearchParams(location.search).get('settle_ms') || '0', 10);
  const grow=document.getElementById('grow');
  const t0=performance.now();
  const t=setInterval(()=>{
    if (performance.now()-t0 >= SETTLE) { clearInterval(t); return; }
    const d=document.createElement('div'); d.className='row'; grow.appendChild(d);
  }, 50);
</script>
</body></html>

sweep.py:

import threading, http.server, socketserver, functools, time, os
PORT = 8801
ROOT = os.path.dirname(os.path.abspath(__file__))

def serve():
    h = functools.partial(http.server.SimpleHTTPRequestHandler, directory=ROOT)
    socketserver.TCPServer.allow_reuse_address = True
    with socketserver.TCPServer(("127.0.0.1", PORT), h) as s:
        s.serve_forever()

threading.Thread(target=serve, daemon=True).start()
time.sleep(1)
BASE = f"http://127.0.0.1:{PORT}/page.html"

from cloakbrowser import launch

print(f"{'settle':>8} | {'result':>6} | {'elapsed':>8} | note")
print("-" * 62)
for settle_ms in [0, 5000, 10000, 15000, 20000, 25000, 30000]:
    b = launch(headless=True, humanize=True)
    try:
        p = b.new_page()
        p.goto(f"{BASE}?settle_ms={settle_ms}", wait_until="load")
        err = None
        t0 = time.monotonic()
        try:
            p.click("#moving")          # default 30s timeout, as a user would
        except Exception as e:
            err = e
        dt = time.monotonic() - t0
        ok = p.evaluate("window.__clicked")
        res = "PASS" if (ok and err is None) else "FAIL"
        note = f"{type(err).__name__}: {str(err)[:50]}" if err else ("" if ok else "no exception, click MISSED")
        print(f"{settle_ms:>8} | {res:>6} | {dt:>7.1f}s | {note}")
    finally:
        b.close()

Run python sweep.py on main, then on this branch. What we get, Chromium 150 in Docker:

settle_ms main this branch
0 PASS 5.8s PASS 5.8s
5000 FAIL 31.8s, silent miss PASS 6.7s
10000 FAIL 31.8s, silent miss PASS 14.9s
15000 FAIL 32.2s, silent miss PASS 21.6s
20000 FAIL 32.2s, silent miss PASS 25.9s
25000 FAIL 32.6s, silent miss PASS 31.4s
30000 ElementNotStableError ElementNotStableError

settle_ms=0 is the static control and passes on both.
settle_ms=30000 exceeds the click's own 30s timeout, so raising is correct on both.
Everything in between is the bug: on main the click never happens and nothing says so.

The 5000 row sits near the boundary and is the least stable of the set, so treat a single run of it as indicative rather than definitive. The 10000 to 25000 rows are the reliable signal.

One note if you go digging: when swapping between main and this branch, confirm the module actually being imported is the one you think. We fooled ourselves once with a stale copy in site-packages and got a run that looked like the fix had failed. grep -c last_miss $(python -c "import cloakbrowser.human.actionability as m; print(m.__file__)") returns 4 on this branch and 0 on main.

The reason a normal page rarely shows this is that the reflow has to outlast the initial scroll but finish inside the click timeout. Lazy-loaded images or comments resolving over ten to twenty seconds above the target is the usual shape.

Regression coverage matters just as much here, so if your existing flows still behave, that is a meaningful result on its own.

@evelaa123

Copy link
Copy Markdown
Contributor

After tests and checks, as well as experiments with other approaches, I haven't yet managed to find a better solution to this problem. I will keep searching, but for now this approach is the best one. Thank you so much for submitting this PR and for keeping such a close eye on the issues of the humanize layer. I've had very little free time lately.

@Cloak-HQ

Cloak-HQ commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @evelaa123 for testing this and confirming it's the best approach for the case.

Merged to main as e4d4c68 (cherry-picked on top of the latest release, which is why this PR didn't auto-close). It'll go out in the next wrapper release under the Unreleased changelog entry.

@Cloak-HQ Cloak-HQ closed this Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants