From e439d892149f0bb140ee871e7c0cc86f1eba5c11 Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Sun, 11 Oct 2026 03:09:10 +0200 Subject: [PATCH] feat(humanize): element reads and dispatch_event run in the humanize world page.input_value / text_content / inner_text / inner_html / get_attribute and dispatch_event now resolve and read through the humanize isolated world, like every other humanized call, for page, frame, locator and element handle (sync and async). The raw Playwright path stays reachable through page._original / Original / Humanize.Unwrap. - shared world helper library gains the inputValue / textContent / innerText / innerHTML / getAttribute / dispatchEvent ops (byte-identical across the Python, JS and .NET ports) - engine read() / dispatch() (Python), read / dispatch (JS), ReadAsync / DispatchAsync (.NET): deadline, resolve-until-match, one world call, {value} / {error} mapping, Playwright error labels - wrappers expose the six APIs on Page, Frame, Locator and ElementHandle; generator delegation guards updated so they stay hand-written - an ElementHandle read / dispatch on a hidden element, or one sharing its box with another element, and a dispatch whose event_init holds a JSHandle, run Playwright's own call, as before - tests: Python unit and real-browser tests, JS real-browser engine tests, .NET HumanReadsTests (values, late selector, null / world error / timeout mapping, dispatch event shape, frame reads, hidden / same-box handles, JSHandle event_init) --- CHANGELOG.md | 2 + README.md | 6 +- cloakbrowser/human/engine.py | 71 ++++- cloakbrowser/human/patch.py | 46 +++- cloakbrowser/human/world.py | 22 +- dotnet/README.md | 1 + dotnet/src/CloakBrowser/Human/HumanEngine.cs | 87 +++++- dotnet/src/CloakBrowser/Human/WorldHelpers.cs | 22 +- .../Wrappers/HumanizedElementHandle.cs | 44 +++- .../CloakBrowser/Wrappers/HumanizedFrame.cs | 34 ++- .../CloakBrowser/Wrappers/HumanizedLocator.cs | 35 ++- .../CloakBrowser/Wrappers/HumanizedPage.cs | 36 ++- .../HumanEngine/HumanReadsTests.cs | 248 ++++++++++++++++++ .../Wrappers/GeneratorDelegationTests.cs | 42 ++- .../Wrappers/LocatorWrapperTests.cs | 8 +- js/src/human/engine.ts | 64 ++++- js/src/human/patch.ts | 42 ++- js/src/human/worldHelpers.ts | 22 +- js/tests/humanizeEngine.test.ts | 79 ++++++ js/tests/humanizeHarness.ts | 18 ++ tests/test_humanize_engine.py | 29 ++ tests/test_humanize_unit.py | 176 +++++++++++++ 22 files changed, 1094 insertions(+), 40 deletions(-) create mode 100644 dotnet/tests/CloakBrowser.Tests/HumanEngine/HumanReadsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a938f..408cc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi ## [Unreleased] +- **[wrapper]** Element reads are now humanized: `page.input_value()`, `page.text_content()`, `page.inner_text()`, `page.inner_html()`, `page.get_attribute()` and `dispatch_event()` (page, frame, locator and element handle; sync and async) resolve and read through the humanize isolated world, like every other humanized call. A dispatched event is built by Playwright's own helper, so a page listener sees the same event it would from any other dispatch. `page._original.input_value(...)` / `page._original.dispatch_event(...)` still give the raw Playwright path. Python, JavaScript and .NET (`InputValueAsync`, `TextContentAsync`, `InnerTextAsync`, `InnerHTMLAsync`, `GetAttributeAsync`, `DispatchEventAsync` on every wrapper; `Original` / `Humanize.Unwrap(page)` for the raw path). + - **[wrapper]** Humanized `select_option()` on a dropdown now types the first letters of the option, like a person does on a long list, plus a few arrow keys when that is shorter, instead of pressing an arrow key once per row. A pick on a 200-option list takes about 1.5-2s instead of up to 30s, and `timeout=` now bounds it (#581). The keys typed land on the same option wherever the open popup's highlight starts, so a popup that opens under the mouse pointer no longer picks the wrong option on headed Linux (#580). On a macOS host the shortest unique prefix of the label is typed instead of the whole label. Python, JavaScript Playwright, and .NET. - **[wrapper]** Humanized `select_option()` now reaches dropdown options whose label starts with an emoji (e.g. a flag before a country name) with arrow keys, because the browser's dropdown search ignores a typed emoji. On a macOS host, where the dropdown also ignores arrow keys, such an option raises a clear error that points to `page._original.select_option(...)` instead of failing with a mismatched selection. Python, JavaScript Playwright, and .NET. diff --git a/README.md b/README.md index 9d4c936..3a5ba57 100644 --- a/README.md +++ b/README.md @@ -601,7 +601,7 @@ clearCache(); ## Human Behavior -Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls (`page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, Locator API) and Puppeteer calls (`page.click()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, ElementHandle API) are automatically replaced with human-like equivalents. No code changes needed. +Pass `humanize=True` to make all mouse, keyboard, scroll and element-read interactions indistinguishable from real users. All Playwright calls (`page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, `page.input_value()`, `page.text_content()`, `page.inner_text()`, `page.inner_html()`, `page.get_attribute()`, Locator API) and Puppeteer calls (`page.click()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, ElementHandle API) are automatically replaced with human-like equivalents. No code changes needed. ```python browser = launch(humanize=True) @@ -666,7 +666,9 @@ const browser = await launch({ }); ``` -Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call. +Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call — this includes the reads (`page._original.input_value(...)`, `text_content`, `inner_text`, `inner_html`, `get_attribute`), which are humanized by default. + +For an arbitrary expression, `page._stealth_world.evaluate("…")` evaluates it in the isolated world the humanize engine uses. > **Note (Playwright):** Always use `page.click(selector)`, `page.type(selector, text)`, `page.hover(selector)`, or `page.locator(selector).*` — these go through the full humanize pipeline. Avoid `page.query_selector()` — `ElementHandle` objects bypass all patches, so mouse movement teleports, keyboard events fire without timing, and scroll has no human curve. > diff --git a/cloakbrowser/human/engine.py b/cloakbrowser/human/engine.py index 4695547..1b733ee 100644 --- a/cloakbrowser/human/engine.py +++ b/cloakbrowser/human/engine.py @@ -26,6 +26,7 @@ import time from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Tuple from playwright._impl._errors import Error, TimeoutError +from playwright._impl._js_handle import JSHandle from . import fields from .config import HumanConfig, merge_config, rand, rand_int_range, rand_range @@ -109,6 +110,23 @@ class _Retry(Exception): self.reason = reason +class RawFallback(Exception): + """A read / dispatch on an ElementHandle the isolated world cannot locate + (no box: hidden; or a box shared with another element). The patched method + runs Playwright's own call instead, which reads hidden elements. Also a + dispatch_event whose event_init holds a JSHandle (it lives in the page's + main world, where Playwright dispatches).""" + + +def _has_handle(v: Any) -> bool: + """A JSHandle anywhere in ``v`` (public wrapper or impl object).""" + if isinstance(v, dict): + return any(_has_handle(x) for x in v.values()) + if isinstance(v, (list, tuple)): + return any(_has_handle(x) for x in v) + return isinstance(getattr(v, "_impl_obj", v), JSHandle) + + # --------------------------------------------------------------------------- # Human # --------------------------------------------------------------------------- @@ -242,16 +260,21 @@ class Human: frame = child raise Error("unreachable") # pragma: no cover - async def _resolve_handle(self, handle: Any, deadline: _Deadline) -> Resolved: + async def _resolve_handle(self, handle: Any, deadline: _Deadline, exact: bool = False) -> Resolved: + """``exact`` (reads): skip the cache (it may hold a click's pick from a + same-box chain) and accept only the one element with this box; anything + else raises RawFallback.""" frame = handle._frame rec = await self.worlds._ready(frame) cached = self._handle_ids.get(handle._guid) - if cached and cached[0] is frame and cached[1] == rec.ctx: + if not exact and cached and cached[0] is frame and cached[1] == rec.ctx: return Resolved(frame, cached[2]) # An ElementHandle carries no identity our context can read, so find # the element with exactly its border box. box = await handle.bounding_box() if box is None: + if exact: + raise RawFallback() if cached and cached[0] is frame: # Resolved in an earlier document of this frame and gone now # (a same-document navigation keeps the element, and its box). @@ -259,8 +282,10 @@ class Human: raise _Retry("element is not visible") ox, oy, _ = await self.worlds.frame_geometry(frame) res = await self.worlds.call(frame, "matchRect", box["x"] - ox, box["y"] - oy, - box["width"], box["height"]) + box["width"], box["height"], exact) if res.get("count") != 1: + if exact: + raise RawFallback() raise Error( "cloakbrowser humanize: cannot identify the element behind this ElementHandle " "inside the isolated world (it shares its box with another element); " @@ -291,6 +316,46 @@ class Human: raise _Retry("element was detached from the DOM, retrying") raise _Retry(f"element is not {missing}") + async def _handle_or_raw(self, target: Target, deadline: _Deadline, api: str) -> None: + """For an ElementHandle: cache its exact isolated-world id, or raise + RawFallback at once when it has no box or shares it (Playwright reads + hidden elements; a read must not land on a same-box wrapper).""" + if target.handle is not None: + await self.retry(api, target, deadline, + lambda: self._resolve_handle(target.handle, deadline, exact=True)) + + async def read(self, target: Target, op: str, opts: dict, api: str, *extra: Any) -> Any: + """Read a property through the isolated world. + + Resolve until the selector matches once (Playwright's read semantics), + then read the property in the world the engine already runs in. + """ + deadline = self.deadline(target, opts) + await self._handle_or_raw(target, deadline, api) + r = await self.wait_for(target, [], deadline, api) + res = await self.worlds.call(r.frame, op, r.id, *extra) + if isinstance(res, dict): + if "error" in res: + raise Error(f"{api}: Error: {res['error']}") + return res.get("value") + return res + + async def dispatch(self, target: Target, opts: dict, api: str, event_type: str, + event_init: Any = None) -> None: + """Dispatch a DOM event on the resolved element. + + The event is built by Playwright's own injected helper, evaluated in the + isolated world instead of the page's world. + """ + if _has_handle(event_init): + raise RawFallback() + deadline = self.deadline(target, opts) + await self._handle_or_raw(target, deadline, api) + r = await self.wait_for(target, [], deadline, api) + res = await self.worlds.call(r.frame, "dispatchEvent", r.id, event_type, event_init) + if isinstance(res, dict) and "error" in res: + raise Error(f"{api}: Error: {res['error']}") + async def retry(self, api: str, target: Target, deadline: _Deadline, attempt: Callable[[], Awaitable[Any]]) -> Any: reasons: List[str] = [] diff --git a/cloakbrowser/human/patch.py b/cloakbrowser/human/patch.py index 8646d5f..9f25f40 100644 --- a/cloakbrowser/human/patch.py +++ b/cloakbrowser/human/patch.py @@ -12,7 +12,10 @@ Playwright method. Every humanized method: ``click_count``, ``modifiers``, ``position``, ``trial``, ``force``, ``timeout`` including page defaults and ``0`` = no limit, ``strict``); * raises Playwright ``Error`` / ``TimeoutError`` instead of silently falling - back to Playwright's stock implementation. + back to Playwright's stock implementation. One exception: an ElementHandle + read / dispatch_event on an element the isolated world cannot locate (hidden, + or sharing its box), and any dispatch_event whose event_init holds a JSHandle, + run Playwright's own call (``RawFallback``). """ from __future__ import annotations @@ -24,7 +27,7 @@ import types from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple from .config import HumanConfig, rand -from .engine import CursorState, Human, Target +from .engine import CursorState, Human, RawFallback, Target _PATCHED = {"sync": False, "async": False} # (class, method name) -> original function @@ -206,6 +209,29 @@ def _handlers(kind: str) -> Dict[str, Handler]: options, elements = _select_options(a) return await h.select_option(t, options, elements, a, api=f"{api}.select_option") + async def input_value(h: Human, impl: Any, a: dict) -> Any: + return await h.read(_target(kind, impl, a), "inputValue", a, api=f"{api}.input_value") + + async def text_content(h: Human, impl: Any, a: dict) -> Any: + return await h.read(_target(kind, impl, a), "textContent", a, api=f"{api}.text_content") + + async def inner_text(h: Human, impl: Any, a: dict) -> Any: + return await h.read(_target(kind, impl, a), "innerText", a, api=f"{api}.inner_text") + + async def inner_html(h: Human, impl: Any, a: dict) -> Any: + return await h.read(_target(kind, impl, a), "innerHTML", a, api=f"{api}.inner_html") + + async def get_attribute(h: Human, impl: Any, a: dict) -> Any: + t = _target(kind, impl, a) + name = a.pop("name") + return await h.read(t, "getAttribute", a, f"{api}.get_attribute", name) + + async def dispatch_event(h: Human, impl: Any, a: dict) -> None: + t = _target(kind, impl, a) + event_type = a.pop("type") + event_init = a.pop("event_init", None) + await h.dispatch(t, a, f"{api}.dispatch_event", event_type, event_init) + async def focus(h: Human, impl: Any, a: dict) -> None: await h.focus(_target(kind, impl, a), a, api=f"{api}.focus", move=kind == "ElementHandle") @@ -227,6 +253,8 @@ def _handlers(kind: str) -> Dict[str, Handler]: "click": click, "dblclick": dblclick, "hover": hover, "tap": tap, "fill": fill, "type": type_, "press": press, "check": check, "uncheck": uncheck, "set_checked": set_checked, "select_option": select_option, "focus": focus, + "input_value": input_value, "text_content": text_content, "inner_text": inner_text, + "inner_html": inner_html, "get_attribute": get_attribute, "dispatch_event": dispatch_event, } if kind in ("Page", "Frame"): return {**common, "drag_and_drop": drag_and_drop} @@ -283,14 +311,20 @@ def _wrap(kind: str, cls: type, name: str, handler: Handler, is_async: bool) -> h = _human_for(kind, self) if h is None: return await orig(self, *args, **kwargs) - return await handler(h, self._impl_obj, bind(self, args, kwargs, human_config)) + try: + return await handler(h, self._impl_obj, bind(self, args, kwargs, human_config)) + except RawFallback: + return await orig(self, *args, **kwargs) else: @functools.wraps(orig) def wrapper(self: Any, *args: Any, human_config: Optional[dict] = None, **kwargs: Any) -> Any: h = _human_for(kind, self) if h is None: return orig(self, *args, **kwargs) - return self._sync(handler(h, self._impl_obj, bind(self, args, kwargs, human_config))) + try: + return self._sync(handler(h, self._impl_obj, bind(self, args, kwargs, human_config))) + except RawFallback: + return orig(self, *args, **kwargs) setattr(cls, name, wrapper) @@ -324,7 +358,9 @@ class _Originals: """``page._original``: raw, un-humanized Playwright calls for this page.""" _PAGE = ("click", "type", "fill", "hover", "dblclick", "select_option", "check", "uncheck", - "press", "tap", "focus", "set_checked", "drag_and_drop", "goto") + "press", "tap", "focus", "set_checked", "drag_and_drop", "goto", + "input_value", "text_content", "inner_text", "inner_html", "get_attribute", + "dispatch_event") def __init__(self, page: Any) -> None: cls, mouse, kb = type(page), page.mouse, page.keyboard diff --git a/cloakbrowser/human/world.py b/cloakbrowser/human/world.py index f0010af..aee17da 100644 --- a/cloakbrowser/human/world.py +++ b/cloakbrowser/human/world.py @@ -68,7 +68,7 @@ const contentsBox = (root) => { const parentOrHost = (n) => n.parentElement || (n.parentNode && n.parentNode.host) || null; const H = { el: (id) => get(id), - matchRect(x, y, w, h) { + matchRect(x, y, w, h, exact) { // An ElementHandle carries no identity this context can read, but // Playwright reports its border box. Find the element with exactly that box. const out = []; @@ -82,6 +82,8 @@ const H = { }; visit(document); if (out.length < 2) return out.length ? { count: 1, id: put(out[0]) } : { count: 0 }; + // A read needs the handle's own element, not a click-equivalent one. + if (exact) return { count: out.length }; // A wrapper and its same-size descendants form one nested chain: a click // lands the same on any of them. Take the element a real click would focus // (the deepest focusable one), else the outermost. Unrelated elements that @@ -120,6 +122,24 @@ const H = { }, connected(id) { return get(id).isConnected; }, value(id) { const e = get(id); return textValue(I.retarget(e, 'follow-label') || e); }, + inputValue(id) { + const e = I.retarget(get(id), 'follow-label'); + if (!e || (e.nodeName !== 'INPUT' && e.nodeName !== 'TEXTAREA' && e.nodeName !== 'SELECT')) + return { error: 'Node is not an ,