mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-10-18 23:07:13 -05:00
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)
This commit is contained in:
22 files changed
+1094
-40
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
>
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <input>, <textarea> or <select> element' };
|
||||
return { value: e.value };
|
||||
},
|
||||
textContent(id) { return { value: get(id).textContent }; },
|
||||
innerText(id) {
|
||||
const e = get(id);
|
||||
if (e.namespaceURI !== 'http://www.w3.org/1999/xhtml') return { error: 'Node is not an HTMLElement' };
|
||||
return { value: e.innerText };
|
||||
},
|
||||
innerHTML(id) { return { value: get(id).innerHTML }; },
|
||||
getAttribute(id, name) { return { value: get(id).getAttribute(name) }; },
|
||||
dispatchEvent(id, type, eventInit) {
|
||||
try { I.dispatchEvent(get(id), type, eventInit); return { value: true }; }
|
||||
catch (err) { return { error: err && err.message ? err.message : String(err) }; }
|
||||
},
|
||||
activeValue() {
|
||||
const a = document.activeElement;
|
||||
if (!a || a === document.body) return { tag: null };
|
||||
|
||||
@@ -220,6 +220,7 @@ foreach (var frame in page.Frames) { /* wrapped IFrame */ }
|
||||
| `FillAsync`, `TypeAsync` / `PressSequentiallyAsync`, `ClearAsync` | instant value set / fast type | click into the field, select-all (persona-aware), per-character typing with variable delays, pauses and self-corrected typos; text continues at the end |
|
||||
| `FillAsync` on date/time/range, `SelectOptionAsync` | programmatic value | operated like a person with trusted events (segments typed, slider clicked + arrow keys, dropdown opened + arrows + Enter); `type=color` throws |
|
||||
| `PressAsync` | direct | clicks to focus first if needed, human key timing, `Delay` honoured |
|
||||
| `InputValueAsync`, `TextContentAsync`, `InnerTextAsync`, `InnerHTMLAsync`, `GetAttributeAsync`, `DispatchEventAsync` on `IPage` / `IFrame` / `ILocator` / `IElementHandle` | direct Playwright read / dispatch | resolved and read / dispatched in the same isolated world the rest of the engine runs in: same values, same event shape for a page listener; `Timeout` honoured |
|
||||
| `CheckAsync`, `UncheckAsync`, `SetCheckedAsync`, `DragToAsync` / `DragAndDropAsync`, `FocusAsync`, `ScrollIntoViewIfNeededAsync` | direct | humanized click / curved drag / wheel scroll |
|
||||
| `IMouse` `MoveAsync`, `ClickAsync`, `DblClickAsync` | direct | curved, eased motion (`Steps` = exact raw steps), real `detail=1,2` sequence |
|
||||
| `IMouse` `DownAsync`, `UpAsync`, `WheelAsync`; `IKeyboard` `DownAsync`, `UpAsync`, `PressAsync`, `InsertTextAsync` | direct | passed through 1:1 (they already are single human inputs) |
|
||||
|
||||
@@ -41,6 +41,24 @@ internal sealed class RetryException : Exception
|
||||
public RetryException(string reason) : base(reason) { }
|
||||
}
|
||||
|
||||
/// <summary>A read / DispatchEvent on an ElementHandle the isolated world cannot locate
|
||||
/// (no box: hidden; or a box shared with another element). The wrapper runs Playwright's
|
||||
/// own call instead, which reads hidden elements. Also a DispatchEvent whose eventInit
|
||||
/// holds a JSHandle (it lives in the page's main world, where Playwright dispatches).</summary>
|
||||
internal sealed class RawFallbackException : Exception
|
||||
{
|
||||
/// <summary>A JSHandle anywhere in an eventInit (dictionary, list or plain/anonymous object).</summary>
|
||||
public static bool HasHandle(object? v) => v switch
|
||||
{
|
||||
null or string => false,
|
||||
IJSHandle => true,
|
||||
System.Collections.IDictionary d => d.Values.Cast<object?>().Any(HasHandle),
|
||||
System.Collections.IEnumerable e => e.Cast<object?>().Any(HasHandle),
|
||||
_ when v.GetType().IsValueType => false,
|
||||
_ => v.GetType().GetProperties().Any(p => p.GetIndexParameters().Length == 0 && HasHandle(p.GetValue(v))),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Options every humanized action understands (Playwright names + ours).</summary>
|
||||
internal sealed class ActOpts
|
||||
{
|
||||
@@ -331,14 +349,17 @@ internal sealed partial class HumanEngine
|
||||
throw Err("unreachable");
|
||||
}
|
||||
|
||||
public async Task<Resolved> ResolveHandleAsync(IElementHandle handle)
|
||||
/// <summary><paramref name="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
|
||||
/// throws <see cref="RawFallbackException"/>.</summary>
|
||||
public async Task<Resolved> ResolveHandleAsync(IElementHandle handle, bool exact = false)
|
||||
{
|
||||
var frame = PlaywrightInternals.HandleFrame(handle);
|
||||
var guid = PlaywrightInternals.HandleGuid(handle);
|
||||
// Element ids are local to one isolated world, and a new document gets a new
|
||||
// world: an id cached before a navigation may now name a different element.
|
||||
int ctx = await World.ContextIdAsync(frame).ConfigureAwait(false);
|
||||
if (_handleIds.TryGetValue(guid, out var cached) && cached.Frame == frame && cached.Ctx == ctx &&
|
||||
if (!exact && _handleIds.TryGetValue(guid, out var cached) && cached.Frame == frame && cached.Ctx == ctx &&
|
||||
await World.CallAsync(frame, "connected", cached.Id).ConfigureAwait(false) is { ValueKind: JsonValueKind.True })
|
||||
return new Resolved(frame, cached.Id);
|
||||
// An ElementHandle carries no identity our context can read, so find the element
|
||||
@@ -346,16 +367,20 @@ internal sealed partial class HumanEngine
|
||||
var box = await handle.BoundingBoxAsync().ConfigureAwait(false);
|
||||
if (box == null)
|
||||
{
|
||||
if (exact) throw new RawFallbackException();
|
||||
// Resolved in an earlier document of this frame and gone now
|
||||
// (a same-document navigation keeps the element, and its box).
|
||||
if (_handleIds.TryGetValue(guid, out var old) && old.Frame == frame) throw Err("Element is not attached to the DOM");
|
||||
throw new RetryException("element is not visible");
|
||||
}
|
||||
var (ox, oy, _) = await World.FrameGeometryAsync(frame).ConfigureAwait(false);
|
||||
var res = (await World.CallAsync(frame, "matchRect", box.X - ox, box.Y - oy, box.Width, box.Height).ConfigureAwait(false))!.Value;
|
||||
var res = (await World.CallAsync(frame, "matchRect", box.X - ox, box.Y - oy, box.Width, box.Height, exact).ConfigureAwait(false))!.Value;
|
||||
if (res.GetProperty("count").GetInt32() != 1)
|
||||
{
|
||||
if (exact) throw new RawFallbackException();
|
||||
throw Err("cloakbrowser humanize: cannot identify the element behind this ElementHandle inside the isolated " +
|
||||
"world (it shares its box with another element); use a Locator instead");
|
||||
}
|
||||
int id = res.GetProperty("id").GetInt32();
|
||||
_handleIds[guid] = (frame, ctx, id);
|
||||
return new Resolved(frame, id);
|
||||
@@ -380,6 +405,62 @@ internal sealed partial class HumanEngine
|
||||
throw new RetryException($"element is not {missing}");
|
||||
}
|
||||
|
||||
// -- reads --------------------------------------------------------------
|
||||
|
||||
private static object?[] WithId(int id, object?[] extra)
|
||||
{
|
||||
var args = new object?[extra.Length + 1];
|
||||
args[0] = id;
|
||||
Array.Copy(extra, 0, args, 1, extra.Length);
|
||||
return args;
|
||||
}
|
||||
|
||||
private static string? WorldValue(JsonElement res) =>
|
||||
res.TryGetProperty("value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
/// <summary>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 — the same isolated world
|
||||
/// the actions use.</summary>
|
||||
/// <summary>For an ElementHandle: cache its exact isolated-world id, or throw
|
||||
/// <see cref="RawFallbackException"/> at once when it has no box or shares it
|
||||
/// (Playwright reads hidden elements; a read must not land on a same-box wrapper).</summary>
|
||||
private async Task HandleOrRawAsync(Target t, Deadline d, string api)
|
||||
{
|
||||
if (t.Handle is { } h)
|
||||
await RetryAsync(api, t, d, () => ResolveHandleAsync(h, exact: true)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<string?> ReadAsync(Target t, string op, ActOpts o, string api, params object?[] extra)
|
||||
{
|
||||
var d = DeadlineFor(t, o);
|
||||
await HandleOrRawAsync(t, d, api).ConfigureAwait(false);
|
||||
var r = await WaitForAsync(t, Array.Empty<string>(), d, api).ConfigureAwait(false);
|
||||
var res = await World.CallAsync(r.Frame, op, WithId(r.Id, extra)).ConfigureAwait(false);
|
||||
if (res is JsonElement e && e.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (e.TryGetProperty("error", out var err)) throw Err($"{api}: Error: {err.GetString()}");
|
||||
return WorldValue(e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public async Task DispatchAsync(Target t, ActOpts o, string api, string type, object? eventInit = null)
|
||||
{
|
||||
if (RawFallbackException.HasHandle(eventInit)) throw new RawFallbackException();
|
||||
var d = DeadlineFor(t, o);
|
||||
await HandleOrRawAsync(t, d, api).ConfigureAwait(false);
|
||||
var r = await WaitForAsync(t, Array.Empty<string>(), d, api).ConfigureAwait(false);
|
||||
var res = await World.CallAsync(r.Frame, "dispatchEvent", r.Id, type, eventInit).ConfigureAwait(false);
|
||||
if (res is JsonElement e && e.ValueKind == JsonValueKind.Object && e.TryGetProperty("error", out var err))
|
||||
throw Err($"{api}: Error: {err.GetString()}");
|
||||
}
|
||||
|
||||
public async Task<T> RetryAsync<T>(string api, Target t, Deadline d, Func<Task<T>> attempt)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
@@ -45,7 +45,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 = [];
|
||||
@@ -59,6 +59,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
|
||||
@@ -97,6 +99,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 <input>, <textarea> or <select> element' };
|
||||
return { value: e.value };
|
||||
},
|
||||
textContent(id) { return { value: get(id).textContent }; },
|
||||
innerText(id) {
|
||||
const e = get(id);
|
||||
if (e.namespaceURI !== 'http://www.w3.org/1999/xhtml') return { error: 'Node is not an HTMLElement' };
|
||||
return { value: e.innerText };
|
||||
},
|
||||
innerHTML(id) { return { value: get(id).innerHTML }; },
|
||||
getAttribute(id, name) { return { value: get(id).getAttribute(name) }; },
|
||||
dispatchEvent(id, type, eventInit) {
|
||||
try { I.dispatchEvent(get(id), type, eventInit); return { value: true }; }
|
||||
catch (err) { return { error: err && err.message ? err.message : String(err) }; }
|
||||
},
|
||||
activeValue() {
|
||||
const a = document.activeElement;
|
||||
if (!a || a === document.body) return { tag: null };
|
||||
|
||||
@@ -6,10 +6,13 @@ namespace CloakBrowser.Wrappers;
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IElementHandle"/>.
|
||||
///
|
||||
/// Interaction methods run through the unified <see cref="HumanEngine"/>: the handle is
|
||||
/// Interaction methods and the element reads (InputValue/TextContent/InnerText/
|
||||
/// InnerHTML/GetAttribute) / DispatchEvent run through the unified
|
||||
/// <see cref="HumanEngine"/>: the handle is
|
||||
/// identified inside the isolated world by its protocol-level bounding box (no page
|
||||
/// script runs); when two elements share that exact box the action raises and asks for a
|
||||
/// Locator instead. Handle-returning queries are re-wrapped; everything else is delegated
|
||||
/// Locator instead (a read / DispatchEvent on a hidden or same-box handle runs
|
||||
/// Playwright's own call). Handle-returning queries are re-wrapped; everything else is delegated
|
||||
/// by the generator.
|
||||
/// </summary>
|
||||
[GenerateInterfaceDelegation(typeof(IElementHandle))]
|
||||
@@ -56,6 +59,43 @@ public sealed partial class HumanizedElementHandle : IElementHandle
|
||||
public Task ScrollIntoViewIfNeededAsync(ElementHandleScrollIntoViewIfNeededOptions? options = null) =>
|
||||
E.ScrollIntoViewIfNeededAsync(T(), Opt(options), "ElementHandle.ScrollIntoViewIfNeededAsync");
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Element reads and event dispatch. The handle is located inside the isolated
|
||||
// world by its protocol-level bounding box (like the actions above); a hidden
|
||||
// handle or one sharing its box runs Playwright's own call instead
|
||||
// (RawFallbackException). The raw methods also stay reachable through Original.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async Task<string?> Read(string op, string api, ActOpts o, Func<Task<string?>> raw, params object?[] extra)
|
||||
{
|
||||
try { return await E.ReadAsync(T(), op, o, api, extra).ConfigureAwait(false); }
|
||||
catch (RawFallbackException) { return await raw().ConfigureAwait(false); }
|
||||
}
|
||||
|
||||
public async Task<string> InputValueAsync(ElementHandleInputValueOptions? options = null) =>
|
||||
(await Read("inputValue", "ElementHandle.InputValueAsync", Opt(options),
|
||||
async () => await _inner.InputValueAsync(options).ConfigureAwait(false)).ConfigureAwait(false))!;
|
||||
|
||||
public Task<string?> TextContentAsync() =>
|
||||
Read("textContent", "ElementHandle.TextContentAsync", new ActOpts(), () => _inner.TextContentAsync());
|
||||
|
||||
public async Task<string> InnerTextAsync() =>
|
||||
(await Read("innerText", "ElementHandle.InnerTextAsync", new ActOpts(),
|
||||
async () => await _inner.InnerTextAsync().ConfigureAwait(false)).ConfigureAwait(false))!;
|
||||
|
||||
public async Task<string> InnerHTMLAsync() =>
|
||||
(await Read("innerHTML", "ElementHandle.InnerHTMLAsync", new ActOpts(),
|
||||
async () => await _inner.InnerHTMLAsync().ConfigureAwait(false)).ConfigureAwait(false))!;
|
||||
|
||||
public Task<string?> GetAttributeAsync(string name) =>
|
||||
Read("getAttribute", "ElementHandle.GetAttributeAsync", new ActOpts(), () => _inner.GetAttributeAsync(name), name);
|
||||
|
||||
public async Task DispatchEventAsync(string type, object? eventInit = null)
|
||||
{
|
||||
try { await E.DispatchAsync(T(), new ActOpts(), "ElementHandle.DispatchEventAsync", type, eventInit).ConfigureAwait(false); }
|
||||
catch (RawFallbackException) { await _inner.DispatchEventAsync(type, eventInit).ConfigureAwait(false); }
|
||||
}
|
||||
|
||||
private static ILocator Unwrap(ILocator l) => l is HumanizedLocator w ? w.Original : l;
|
||||
|
||||
// #549: unwrap masked locators in place (rebuilding options would drop future fields).
|
||||
|
||||
@@ -8,7 +8,8 @@ namespace CloakBrowser.Wrappers;
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IFrame"/>.
|
||||
///
|
||||
/// Frames have no Mouse/Keyboard of their own (those belong to the page): selector
|
||||
/// actions run through the page's <see cref="HumanEngine"/>, resolved in this frame's
|
||||
/// actions and the element reads / DispatchEvent run through the page's
|
||||
/// <see cref="HumanEngine"/>, resolved in this frame's
|
||||
/// own isolated world, with wheel scrolling and hit-tests through every ancestor
|
||||
/// iframe. Locator/frame returning members are re-wrapped; everything else is
|
||||
/// delegated by the generator.
|
||||
@@ -60,6 +61,37 @@ public sealed partial class HumanizedFrame : IFrame
|
||||
public Task DragAndDropAsync(string source, string target, FrameDragAndDropOptions? options = null) =>
|
||||
E.DragAsync(T(source, options), T(target, options), Opt(options), "Frame.DragAndDropAsync");
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Element reads and event dispatch: resolved and executed by the engine in THIS
|
||||
// frame's isolated world, like the actions above. The raw Playwright methods stay
|
||||
// reachable through Original (escape hatch).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private Task<string?> Read(string selector, string op, object? options, string api, params object?[] extra) =>
|
||||
E.ReadAsync(T(selector, options), op, Opt(options), api, extra);
|
||||
|
||||
public async Task<string> InputValueAsync(string selector, FrameInputValueOptions? options = null) =>
|
||||
(await Read(selector, "inputValue", options, "Frame.InputValueAsync").ConfigureAwait(false))!;
|
||||
|
||||
public Task<string?> TextContentAsync(string selector, FrameTextContentOptions? options = null) =>
|
||||
Read(selector, "textContent", options, "Frame.TextContentAsync");
|
||||
|
||||
public async Task<string> InnerTextAsync(string selector, FrameInnerTextOptions? options = null) =>
|
||||
(await Read(selector, "innerText", options, "Frame.InnerTextAsync").ConfigureAwait(false))!;
|
||||
|
||||
public async Task<string> InnerHTMLAsync(string selector, FrameInnerHTMLOptions? options = null) =>
|
||||
(await Read(selector, "innerHTML", options, "Frame.InnerHTMLAsync").ConfigureAwait(false))!;
|
||||
|
||||
public Task<string?> GetAttributeAsync(string selector, string name, FrameGetAttributeOptions? options = null) =>
|
||||
Read(selector, "getAttribute", options, "Frame.GetAttributeAsync", name);
|
||||
|
||||
public async Task DispatchEventAsync(string selector, string type, object? eventInit = null, FrameDispatchEventOptions? options = null)
|
||||
{
|
||||
// A JSHandle in eventInit lives in the main world: Playwright's own call.
|
||||
try { await E.DispatchAsync(T(selector, options), Opt(options), "Frame.DispatchEventAsync", type, eventInit).ConfigureAwait(false); }
|
||||
catch (RawFallbackException) { await _inner.DispatchEventAsync(selector, type, eventInit, options).ConfigureAwait(false); }
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<string>> Select(string selector, SelectValues v, FrameSelectOptionOptions? options) =>
|
||||
E.SelectOptionAsync(T(selector, options), v.Options, v.Handles, Opt(options), "Frame.SelectOptionAsync");
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ namespace CloakBrowser.Wrappers;
|
||||
///
|
||||
/// Intercepted (humanized, unified engine, strict mode): Click/DblClick/Hover/Tap/Fill/
|
||||
/// Clear/Type/PressSequentially/Press/Check/Uncheck/SetChecked/SelectOption/Focus/DragTo/
|
||||
/// ScrollIntoViewIfNeeded, for every locator shape (GetBy*, chains, Filter, FrameLocator).
|
||||
/// ScrollIntoViewIfNeeded and the element reads (InputValue/TextContent/InnerText/
|
||||
/// InnerHTML/GetAttribute) / DispatchEvent, for every locator shape (GetBy*, chains,
|
||||
/// Filter, FrameLocator).
|
||||
/// All other members - assertions, queries, waits, getters - are delegated to the inner
|
||||
/// locator by the source generator. Locator-returning members are re-wrapped so chaining stays
|
||||
/// humanized.
|
||||
@@ -83,6 +85,37 @@ public sealed partial class HumanizedLocator : ILocator
|
||||
return E.DragAsync(T(), new Target(frame, selector, strict: true), Opt(options), "Locator.DragToAsync");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Element reads and event dispatch: resolved and executed by the engine in this
|
||||
// locator's frame's isolated world (strict mode, like the actions above).
|
||||
// The raw Playwright methods stay reachable through Original (escape hatch).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private Task<string?> Read(string op, string api, object? options, params object?[] extra) =>
|
||||
E.ReadAsync(T(), op, Opt(options), api, extra);
|
||||
|
||||
public async Task<string> InputValueAsync(LocatorInputValueOptions? options = null) =>
|
||||
(await Read("inputValue", "Locator.InputValueAsync", options).ConfigureAwait(false))!;
|
||||
|
||||
public Task<string?> TextContentAsync(LocatorTextContentOptions? options = null) =>
|
||||
Read("textContent", "Locator.TextContentAsync", options);
|
||||
|
||||
public async Task<string> InnerTextAsync(LocatorInnerTextOptions? options = null) =>
|
||||
(await Read("innerText", "Locator.InnerTextAsync", options).ConfigureAwait(false))!;
|
||||
|
||||
public async Task<string> InnerHTMLAsync(LocatorInnerHTMLOptions? options = null) =>
|
||||
(await Read("innerHTML", "Locator.InnerHTMLAsync", options).ConfigureAwait(false))!;
|
||||
|
||||
public Task<string?> GetAttributeAsync(string name, LocatorGetAttributeOptions? options = null) =>
|
||||
Read("getAttribute", "Locator.GetAttributeAsync", options, name);
|
||||
|
||||
public async Task DispatchEventAsync(string type, object? eventInit = null, LocatorDispatchEventOptions? options = null)
|
||||
{
|
||||
// A JSHandle in eventInit lives in the main world: Playwright's own call.
|
||||
try { await E.DispatchAsync(T(), Opt(options), "Locator.DispatchEventAsync", type, eventInit).ConfigureAwait(false); }
|
||||
catch (RawFallbackException) { await _inner.DispatchEventAsync(type, eventInit, options).ConfigureAwait(false); }
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<string>> Select(SelectValues v, LocatorSelectOptionOptions? options) =>
|
||||
E.SelectOptionAsync(T(), v.Options, v.Handles, Opt(options), "Locator.SelectOptionAsync");
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ namespace CloakBrowser.Wrappers;
|
||||
/// <summary>
|
||||
/// Transparent humanizing decorator over Playwright's <see cref="IPage"/>.
|
||||
///
|
||||
/// Selector-based interaction methods (Click/Fill/Type/Hover/Press/Tap/Check/...) are
|
||||
/// routed through the unified <see cref="HumanEngine"/>. <c>Mouse</c> and
|
||||
/// Selector-based interaction methods (Click/Fill/Type/Hover/Press/Tap/Check/...), the
|
||||
/// element reads (InputValue/TextContent/InnerText/InnerHTML/GetAttribute) and
|
||||
/// DispatchEvent are routed through the unified <see cref="HumanEngine"/>. <c>Mouse</c> and
|
||||
/// <c>Keyboard</c> return humanized wrappers; <c>Locator</c>/<c>GetBy*</c>/frames return
|
||||
/// re-wrapped objects so the whole chain stays humanized. Everything else is delegated
|
||||
/// to the inner page by the source generator.
|
||||
@@ -157,6 +158,37 @@ public sealed partial class HumanizedPage : IPage
|
||||
public Task<IReadOnlyList<string>> SelectOptionAsync(string selector, IEnumerable<SelectOptionValue> values, PageSelectOptionOptions? options = null) =>
|
||||
Select(selector, SelectValues.Of(values), options);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Element reads and event dispatch: resolved and executed by the engine in the
|
||||
// frame's isolated world, like the actions above. The raw Playwright methods stay
|
||||
// reachable through Original / Humanize.Unwrap(page) (escape hatch).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private Task<string?> Read(string selector, string op, object? options, string api, params object?[] extra) =>
|
||||
_engine.ReadAsync(T(selector, options), op, Opt(options), api, extra);
|
||||
|
||||
public async Task<string> InputValueAsync(string selector, PageInputValueOptions? options = null) =>
|
||||
(await Read(selector, "inputValue", options, "Page.InputValueAsync").ConfigureAwait(false))!;
|
||||
|
||||
public Task<string?> TextContentAsync(string selector, PageTextContentOptions? options = null) =>
|
||||
Read(selector, "textContent", options, "Page.TextContentAsync");
|
||||
|
||||
public async Task<string> InnerTextAsync(string selector, PageInnerTextOptions? options = null) =>
|
||||
(await Read(selector, "innerText", options, "Page.InnerTextAsync").ConfigureAwait(false))!;
|
||||
|
||||
public async Task<string> InnerHTMLAsync(string selector, PageInnerHTMLOptions? options = null) =>
|
||||
(await Read(selector, "innerHTML", options, "Page.InnerHTMLAsync").ConfigureAwait(false))!;
|
||||
|
||||
public Task<string?> GetAttributeAsync(string selector, string name, PageGetAttributeOptions? options = null) =>
|
||||
Read(selector, "getAttribute", options, "Page.GetAttributeAsync", name);
|
||||
|
||||
public async Task DispatchEventAsync(string selector, string type, object? eventInit = null, PageDispatchEventOptions? options = null)
|
||||
{
|
||||
// A JSHandle in eventInit lives in the main world: Playwright's own call.
|
||||
try { await _engine.DispatchAsync(T(selector, options), Opt(options), "Page.DispatchEventAsync", type, eventInit).ConfigureAwait(false); }
|
||||
catch (RawFallbackException) { await _inner.DispatchEventAsync(selector, type, eventInit, options).ConfigureAwait(false); }
|
||||
}
|
||||
|
||||
private static ILocator Unwrap(ILocator l) => l is HumanizedLocator hl ? hl.Original : l;
|
||||
|
||||
// #549: Playwright down-casts ILocator args to concrete Locator; unwrap ours first.
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using CloakBrowser.Human;
|
||||
using CloakBrowser.Wrappers;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace CloakBrowser.Tests.HumanEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Element reads (InputValueAsync / TextContentAsync / InnerTextAsync / InnerHTMLAsync /
|
||||
/// GetAttributeAsync) and DispatchEventAsync: resolved and executed in the humanize
|
||||
/// isolated world, exactly like the actions. The probe (<c>window.__marks</c>) records
|
||||
/// what the page observes, and the control test proves the probe works.
|
||||
/// </summary>
|
||||
[Collection("RealBrowser")]
|
||||
public class HumanReadsTests : IClassFixture<EngineFixture>, IAsyncLifetime
|
||||
{
|
||||
private readonly EngineFixture _f;
|
||||
private readonly List<IPage> _pages = new();
|
||||
|
||||
public HumanReadsTests(EngineFixture f) => _f = f;
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
foreach (var p in _pages) { try { await p.CloseAsync(); } catch { } }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wiring
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Open a humanized page with the probe armed before the document loads.</summary>
|
||||
private async Task<IPage> OpenHumanized(string path = "index.html")
|
||||
{
|
||||
var p = await _f.Handle!.Browser.NewPageAsync();
|
||||
_pages.Add(p);
|
||||
await p.AddInitScriptAsync(ProbeScript());
|
||||
await p.GotoAsync(_f.Url + path);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static Task<int> Marks(IFrame f) => f.EvaluateAsync<int>("() => window.__marks.length");
|
||||
private static Task<int> Marks(IPage p) => Marks(p.MainFrame);
|
||||
|
||||
/// <summary>Records the events the page observes (<c>window.__marks</c>). The names
|
||||
/// are read from the installed driver, so the probe never hard-codes them and
|
||||
/// cannot silently go stale when Playwright changes them.</summary>
|
||||
private static string ProbeScript()
|
||||
{
|
||||
var names = JsonSerializer.Serialize(TraceEventNames());
|
||||
return "(() => {\n"
|
||||
+ " window.__marks = window.__marks || [];\n"
|
||||
+ $" const names = {names};\n"
|
||||
+ " for (const n of names) document.addEventListener(n, () => window.__marks.push(n), true);\n"
|
||||
+ "})()";
|
||||
}
|
||||
|
||||
/// <summary>Event names from the installed driver's injected script.</summary>
|
||||
private static IReadOnlyList<string> TraceEventNames() =>
|
||||
Regex.Matches(InjectedSource.FindLiteral(), @"CustomEvent\(\s*\\?(?:""|')([^""\\']+)")
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Distinct()
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Guards the probe: without names to listen for, the control test below
|
||||
/// would be the only thing that notices.</summary>
|
||||
[Fact]
|
||||
public void The_probe_has_events_to_listen_for()
|
||||
{
|
||||
Assert.NotEmpty(TraceEventNames());
|
||||
Assert.Contains("window.__marks", ProbeScript());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reads
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Humanized_reads_return_the_values_and_the_probe_stays_empty()
|
||||
{
|
||||
var p = await OpenHumanized();
|
||||
var handle = await p.QuerySelectorAsync("#name");
|
||||
await p.FillAsync("#name", "laptop");
|
||||
var before = await Marks(p);
|
||||
|
||||
Assert.Equal("laptop", await p.InputValueAsync("#name"));
|
||||
Assert.Equal("laptop", await p.Locator("#name").InputValueAsync());
|
||||
Assert.Equal("laptop", await handle!.InputValueAsync());
|
||||
Assert.Equal("Press me", await p.TextContentAsync("#btn"));
|
||||
Assert.Equal("Press me", await p.InnerTextAsync("#btn"));
|
||||
Assert.Equal("Press me", await p.InnerHTMLAsync("#btn"));
|
||||
Assert.Equal("btn", await p.GetAttributeAsync("#btn", "id"));
|
||||
Assert.Equal("Press me", await p.Locator("#btn").TextContentAsync());
|
||||
Assert.Equal("Press me", await p.Locator("#btn").InnerTextAsync());
|
||||
|
||||
Assert.Equal(before, await Marks(p));
|
||||
}
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Humanized_read_waits_for_the_selector_to_match()
|
||||
{
|
||||
var p = await OpenHumanized();
|
||||
await p.EvaluateAsync(@"() => setTimeout(() => {
|
||||
const d = document.createElement('div');
|
||||
d.id = 'late'; d.textContent = 'ready';
|
||||
document.body.appendChild(d);
|
||||
}, 300)");
|
||||
|
||||
Assert.Equal("ready", await p.InnerTextAsync("#late", new PageInnerTextOptions { Timeout = 10000 }));
|
||||
}
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Humanized_read_maps_world_errors_nulls_and_timeouts()
|
||||
{
|
||||
var p = await OpenHumanized();
|
||||
|
||||
Assert.Null(await p.GetAttributeAsync("#btn", "data-missing"));
|
||||
|
||||
var error = await Assert.ThrowsAsync<PlaywrightException>(() => p.InputValueAsync("#btn"));
|
||||
Assert.Contains("Page.InputValueAsync: Error: Node is not an <input>", error.Message);
|
||||
|
||||
var timeout = await Assert.ThrowsAsync<TimeoutException>(
|
||||
() => p.Locator("#never-there").InputValueAsync(new LocatorInputValueOptions { Timeout = 300 }));
|
||||
Assert.Contains("Locator.InputValueAsync: Timeout", timeout.Message);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Humanized_dispatch_event_reaches_a_page_listener()
|
||||
{
|
||||
var p = await OpenHumanized();
|
||||
var handle = await p.QuerySelectorAsync("#btn");
|
||||
await p.EvaluateAsync(@"() => {
|
||||
window.__clicks = [];
|
||||
document.addEventListener('click', (e) => window.__clicks.push({
|
||||
mouse: e instanceof MouseEvent, bubbles: e.bubbles, x: e.clientX,
|
||||
}));
|
||||
}");
|
||||
var before = await Marks(p);
|
||||
|
||||
await p.DispatchEventAsync("#btn", "click", new { clientX = 7, clientY = 9 });
|
||||
await p.Locator("#btn").DispatchEventAsync("click", new { clientX = 3, clientY = 4 });
|
||||
await handle!.DispatchEventAsync("click", new { clientX = 5, clientY = 6 });
|
||||
|
||||
var clicks = JsonSerializer.Deserialize<List<JsonElement>>(
|
||||
await p.EvaluateAsync<string>("() => JSON.stringify(window.__clicks)"))!;
|
||||
Assert.Equal(3, clicks.Count);
|
||||
Assert.True(clicks[0].GetProperty("mouse").GetBoolean());
|
||||
Assert.True(clicks[0].GetProperty("bubbles").GetBoolean());
|
||||
Assert.Equal(7, clicks[0].GetProperty("x").GetInt32());
|
||||
Assert.Equal(3, clicks[1].GetProperty("x").GetInt32());
|
||||
Assert.Equal(5, clicks[2].GetProperty("x").GetInt32());
|
||||
|
||||
Assert.Equal(before, await Marks(p));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frames
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Humanized_frame_reads_and_dispatch_run_in_the_frame()
|
||||
{
|
||||
var p = await OpenHumanized();
|
||||
var f = p.Frame("f")!;
|
||||
await f.WaitForLoadStateAsync();
|
||||
await f.EvaluateAsync(ProbeScript());
|
||||
await f.EvaluateAsync(@"() => {
|
||||
window.__clicks = [];
|
||||
document.addEventListener('click', (e) => window.__clicks.push(e.clientX), true);
|
||||
}");
|
||||
var before = await Marks(f);
|
||||
|
||||
Assert.Equal("old", await f.InputValueAsync("#finput"));
|
||||
Assert.Equal("old", await p.FrameLocator("#frame").Locator("#finput").InputValueAsync());
|
||||
|
||||
await f.DispatchEventAsync("#fcovered", "click", new { clientX = 11 });
|
||||
await p.FrameLocator("#frame").Locator("#fcovered").DispatchEventAsync("click", new { clientX = 12 });
|
||||
|
||||
var clicks = JsonSerializer.Deserialize<List<JsonElement>>(
|
||||
await f.EvaluateAsync<string>("() => JSON.stringify(window.__clicks)"))!;
|
||||
Assert.Equal(new[] { 11, 12 }, clicks.Select(c => c.GetInt32()).ToArray());
|
||||
Assert.Equal(before, await Marks(f));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fallback to Playwright's own call
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Handle_reads_of_hidden_or_same_box_elements_use_the_raw_path_at_once()
|
||||
{
|
||||
var p = await OpenHumanized();
|
||||
await p.EvaluateAsync(@"() => {
|
||||
document.head.insertAdjacentHTML('beforeend', '<meta name=""csrf"" content=""tok123"">');
|
||||
document.body.insertAdjacentHTML('beforeend', '<input id=""hid"" type=""hidden"" value=""secret"">'
|
||||
+ '<div id=""outer"" style=""width:90px;height:30px""><div id=""inner"" style=""width:90px;height:30px""></div></div>');
|
||||
}");
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
Assert.Equal("tok123", await (await p.QuerySelectorAsync("meta[name=csrf]"))!.GetAttributeAsync("content"));
|
||||
Assert.Equal("secret", await (await p.QuerySelectorAsync("#hid"))!.InputValueAsync());
|
||||
Assert.True(sw.ElapsedMilliseconds < 5000, $"hidden handle reads took {sw.ElapsedMilliseconds}ms");
|
||||
// A read must land on the handle's own element, not its same-box wrapper.
|
||||
Assert.Equal("inner", await (await p.QuerySelectorAsync("#inner"))!.GetAttributeAsync("id"));
|
||||
Assert.Equal("outer", await (await p.QuerySelectorAsync("#outer"))!.GetAttributeAsync("id"));
|
||||
}
|
||||
|
||||
[BrowserFact]
|
||||
public async Task Dispatch_event_with_a_js_handle_in_event_init_uses_the_raw_path()
|
||||
{
|
||||
var p = await OpenHumanized();
|
||||
await p.EvaluateAsync(@"() => {
|
||||
window.__dt = [];
|
||||
document.addEventListener('dragstart', (e) => window.__dt.push(e.dataTransfer instanceof DataTransfer));
|
||||
}");
|
||||
var dt = await p.EvaluateHandleAsync("() => new DataTransfer()");
|
||||
|
||||
await p.DispatchEventAsync("#btn", "dragstart", new { dataTransfer = dt });
|
||||
await p.Locator("#btn").DispatchEventAsync("dragstart", new Dictionary<string, object> { ["dataTransfer"] = dt });
|
||||
|
||||
Assert.Equal("[true,true]", await p.EvaluateAsync<string>("() => JSON.stringify(window.__dt)"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Control: Playwright's own read path is caught by the probe
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[BrowserFact]
|
||||
public async Task The_probe_sees_the_raw_read_path()
|
||||
{
|
||||
var p = await OpenHumanized();
|
||||
var before = await Marks(p);
|
||||
|
||||
// The raw, un-humanized page (escape hatch): Playwright's own read path.
|
||||
var raw = Humanize.Unwrap(p);
|
||||
Assert.Equal("firstname", await raw.InputValueAsync("#name"));
|
||||
|
||||
Assert.True(await Marks(p) > before,
|
||||
"the raw read path is expected to be visible to the page probe");
|
||||
}
|
||||
}
|
||||
@@ -41,19 +41,20 @@ public class GeneratorDelegationTests
|
||||
public async Task Method_arguments_are_forwarded_unchanged()
|
||||
{
|
||||
var (human, rec) = Locator();
|
||||
rec.On("GetAttributeAsync", Task.FromResult<string?>("yes"));
|
||||
await human.GetAttributeAsync("data-id");
|
||||
Assert.Equal("data-id", rec.Last("GetAttributeAsync")!.Args[0]);
|
||||
rec.On("WaitForAsync", Task.CompletedTask);
|
||||
var options = new LocatorWaitForOptions();
|
||||
await human.WaitForAsync(options);
|
||||
Assert.Same(options, rec.Last("WaitForAsync")!.Args[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Method_with_optional_options_delegates_without_throwing()
|
||||
{
|
||||
var (human, rec) = Locator();
|
||||
rec.On("InnerTextAsync", Task.FromResult("text"));
|
||||
rec.On("WaitForAsync", Task.CompletedTask);
|
||||
// Call without supplying the optional options argument.
|
||||
Assert.Equal("text", await human.InnerTextAsync());
|
||||
Assert.True(rec.WasCalled("InnerTextAsync"));
|
||||
await human.WaitForAsync();
|
||||
Assert.True(rec.WasCalled("WaitForAsync"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -88,8 +89,8 @@ public class GeneratorDelegationTests
|
||||
public async Task Delegated_exception_propagates_with_same_type_and_message()
|
||||
{
|
||||
var (human, rec) = Locator();
|
||||
rec.On("InnerHTMLAsync", _ => throw new TimeoutException("timed out"));
|
||||
var ex = await Assert.ThrowsAsync<TimeoutException>(() => human.InnerHTMLAsync());
|
||||
rec.On("WaitForAsync", _ => throw new TimeoutException("timed out"));
|
||||
var ex = await Assert.ThrowsAsync<TimeoutException>(() => human.WaitForAsync());
|
||||
Assert.Equal("timed out", ex.Message);
|
||||
}
|
||||
|
||||
@@ -202,6 +203,31 @@ public class GeneratorDelegationTests
|
||||
new object[] { typeof(HumanizedMouse), "DblClickAsync" },
|
||||
// HumanizedKeyboard
|
||||
new object[] { typeof(HumanizedKeyboard), "TypeAsync" },
|
||||
// Element reads + event dispatch (isolated world, never Playwright's own path)
|
||||
new object[] { typeof(HumanizedPage), "InputValueAsync" },
|
||||
new object[] { typeof(HumanizedPage), "TextContentAsync" },
|
||||
new object[] { typeof(HumanizedPage), "InnerTextAsync" },
|
||||
new object[] { typeof(HumanizedPage), "InnerHTMLAsync" },
|
||||
new object[] { typeof(HumanizedPage), "GetAttributeAsync" },
|
||||
new object[] { typeof(HumanizedPage), "DispatchEventAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "InputValueAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "TextContentAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "InnerTextAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "InnerHTMLAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "GetAttributeAsync" },
|
||||
new object[] { typeof(HumanizedFrame), "DispatchEventAsync" },
|
||||
new object[] { typeof(HumanizedLocator), "InputValueAsync" },
|
||||
new object[] { typeof(HumanizedLocator), "TextContentAsync" },
|
||||
new object[] { typeof(HumanizedLocator), "InnerTextAsync" },
|
||||
new object[] { typeof(HumanizedLocator), "InnerHTMLAsync" },
|
||||
new object[] { typeof(HumanizedLocator), "GetAttributeAsync" },
|
||||
new object[] { typeof(HumanizedLocator), "DispatchEventAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "InputValueAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "TextContentAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "InnerTextAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "InnerHTMLAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "GetAttributeAsync" },
|
||||
new object[] { typeof(HumanizedElementHandle), "DispatchEventAsync" },
|
||||
};
|
||||
|
||||
/// <summary>Every member that hands out an element handle, frame or frame locator
|
||||
|
||||
@@ -101,16 +101,18 @@ public class LocatorWrapperTests
|
||||
var (page, _, _) = BuildPage();
|
||||
var (inner, innerRec) = Fake.Of<ILocator>();
|
||||
innerRec.On("CountAsync", Task.FromResult(7));
|
||||
innerRec.On("TextContentAsync", Task.FromResult<string?>("hello"));
|
||||
// A pure query; the element reads (TextContentAsync, GetAttributeAsync, ...) are
|
||||
// humanized instead (see GeneratorDelegationTests).
|
||||
innerRec.On("AllTextContentsAsync", Task.FromResult<IReadOnlyList<string>>(new[] { "hello" }));
|
||||
innerRec.On("IsVisibleAsync", Task.FromResult(true));
|
||||
|
||||
var human = new HumanizedLocator(inner, new HumanCursor(page), FastConfig());
|
||||
|
||||
Assert.Equal(7, await human.CountAsync());
|
||||
Assert.Equal("hello", await human.TextContentAsync());
|
||||
Assert.Equal(new[] { "hello" }, await human.AllTextContentsAsync());
|
||||
Assert.True(await human.IsVisibleAsync());
|
||||
Assert.True(innerRec.WasCalled("CountAsync"));
|
||||
Assert.True(innerRec.WasCalled("TextContentAsync"));
|
||||
Assert.True(innerRec.WasCalled("AllTextContentsAsync"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+61
-3
@@ -82,6 +82,20 @@ class Retry extends Error {
|
||||
constructor(public reason: string) { super(reason); }
|
||||
}
|
||||
|
||||
/** A read / dispatchEvent 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
|
||||
* dispatchEvent whose eventInit holds a JSHandle (it lives in the page's main
|
||||
* world, where Playwright dispatches). */
|
||||
export class RawFallback extends Error {}
|
||||
|
||||
/** A JSHandle (or ElementHandle) anywhere in `v`. */
|
||||
function hasHandle(v: any): boolean {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
if (typeof v.jsonValue === 'function' && typeof v.evaluateHandle === 'function') return true;
|
||||
return Object.values(v).some(hasHandle);
|
||||
}
|
||||
|
||||
/** Options every humanized action understands (Playwright names + ours). */
|
||||
export interface Opts {
|
||||
timeout?: number;
|
||||
@@ -239,24 +253,29 @@ export class Human {
|
||||
throw err('unreachable');
|
||||
}
|
||||
|
||||
async resolveHandle(handle: ElementHandle, _deadline?: Deadline): Promise<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 throws RawFallback. */
|
||||
async resolveHandle(handle: ElementHandle, _deadline?: Deadline, exact = false): Promise<Resolved> {
|
||||
const frame: Frame = (handle as any)._frame;
|
||||
const rec = await this.worlds.ready(frame);
|
||||
const guid: string = (handle as any)._guid;
|
||||
const cached = this.handleIds.get(guid);
|
||||
if (cached && cached[0] === frame && cached[1] === rec.ctx) return new Resolved(frame, cached[2]);
|
||||
if (!exact && cached && cached[0] === frame && cached[1] === rec.ctx) return new Resolved(frame, cached[2]);
|
||||
// An ElementHandle carries no identity our context can read, so find
|
||||
// the element with exactly its border box.
|
||||
const box = await handle.boundingBox();
|
||||
if (!box) {
|
||||
if (exact) throw new RawFallback();
|
||||
// Resolved in an earlier document of this frame and gone now
|
||||
// (a same-document navigation keeps the element, and its box).
|
||||
if (cached && cached[0] === frame) throw err('Element is not attached to the DOM');
|
||||
throw new Retry('element is not visible');
|
||||
}
|
||||
const [ox, oy] = await this.worlds.frameGeometry(frame);
|
||||
const res = await this.worlds.call(frame, 'matchRect', box.x - ox, box.y - oy, box.width, box.height);
|
||||
const res = await this.worlds.call(frame, 'matchRect', box.x - ox, box.y - oy, box.width, box.height, exact);
|
||||
if (res.count !== 1) {
|
||||
if (exact) throw new RawFallback();
|
||||
throw err('cloakbrowser humanize: cannot identify the element behind this ElementHandle inside the ' +
|
||||
'isolated world (it shares its box with another element); use a Locator instead');
|
||||
}
|
||||
@@ -282,6 +301,44 @@ export class Human {
|
||||
throw new Retry(`element is not ${res.missing}`);
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
/** For an ElementHandle: cache its exact isolated-world id, or throw
|
||||
* 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). */
|
||||
async handleOrRaw(target: Target, deadline: Deadline, api: string): Promise<void> {
|
||||
if (target.handle) await this.retry(api, target, deadline, () => this.resolveHandle(target.handle!, deadline, true));
|
||||
}
|
||||
|
||||
async read(target: Target, op: string, opts: Opts, api: string, ...extra: any[]): Promise<any> {
|
||||
const deadline = this.deadline(target, opts);
|
||||
await this.handleOrRaw(target, deadline, api);
|
||||
const r = await this.waitFor(target, [], deadline, api);
|
||||
const res = await this.worlds.call(r.frame, op, r.id, ...extra);
|
||||
if (res && typeof res === 'object' && !Array.isArray(res)) {
|
||||
if (res.error) throw err(`${api}: Error: ${res.error}`);
|
||||
return res.value;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
async dispatch(target: Target, opts: Opts, api: string, type: string, eventInit?: any): Promise<void> {
|
||||
if (hasHandle(eventInit)) throw new RawFallback();
|
||||
const deadline = this.deadline(target, opts);
|
||||
await this.handleOrRaw(target, deadline, api);
|
||||
const r = await this.waitFor(target, [], deadline, api);
|
||||
const res = await this.worlds.call(r.frame, 'dispatchEvent', r.id, type, eventInit ?? null);
|
||||
if (res && typeof res === 'object' && res.error) throw err(`${api}: Error: ${res.error}`);
|
||||
}
|
||||
|
||||
async retry<T>(api: string, target: Target, deadline: Deadline, attempt: () => Promise<T>): Promise<T> {
|
||||
const reasons: string[] = [];
|
||||
const note = (r: string) => { if (reasons[reasons.length - 1] !== r) reasons.push(r); };
|
||||
@@ -289,6 +346,7 @@ export class Human {
|
||||
try {
|
||||
return await attempt();
|
||||
} catch (e: any) {
|
||||
if (e instanceof RawFallback) throw e;
|
||||
if (e instanceof StaleElement) {
|
||||
if (target.handle) throw err(`${api}: Element is not attached to the DOM`);
|
||||
note('element was detached from the DOM, retrying');
|
||||
|
||||
+38
-4
@@ -13,7 +13,10 @@
|
||||
* `modifiers`, `position`, `trial`, `force`, `timeout` including page
|
||||
* defaults and `0` = no limit, `strict`);
|
||||
* - throws Playwright `Error` / `TimeoutError` instead of silently falling
|
||||
* back to Playwright's stock implementation.
|
||||
* back to Playwright's stock implementation. One exception: an ElementHandle
|
||||
* read / dispatchEvent on an element the isolated world cannot locate
|
||||
* (hidden, or sharing its box), and any dispatchEvent whose eventInit holds
|
||||
* a JSHandle, run Playwright's own call (`RawFallback`).
|
||||
*
|
||||
* playwright-core does not export its client classes, so prototypes are
|
||||
* reached through live instances: the page itself, `page.mainFrame()`,
|
||||
@@ -24,7 +27,7 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Browser, BrowserContext, ElementHandle, Frame, Page } from 'playwright-core';
|
||||
import { type HumanConfig, rand } from './config.js';
|
||||
import { CursorState, Human, Target, type Opts, type RawInput } from './engine.js';
|
||||
import { CursorState, Human, RawFallback, Target, type Opts, type RawInput } from './engine.js';
|
||||
|
||||
type Kind = 'Page' | 'Frame' | 'Locator' | 'ElementHandle' | 'Mouse' | 'Keyboard';
|
||||
type Handler = (h: Human, self: any, args: any[]) => Promise<any>;
|
||||
@@ -105,6 +108,7 @@ function target(kind: Kind, self: any, args: any[], o: Opts): { t: Target; rest:
|
||||
const OPT_INDEX: Record<string, number> = {
|
||||
click: 0, dblclick: 0, hover: 0, tap: 0, check: 0, uncheck: 0, focus: 0, clear: 0, scrollIntoViewIfNeeded: 0,
|
||||
fill: 1, type: 1, pressSequentially: 1, press: 1, setChecked: 1, selectOption: 1, dragTo: 1,
|
||||
inputValue: 0, textContent: 0, innerText: 0, innerHTML: 0, getAttribute: 1, dispatchEvent: 2,
|
||||
};
|
||||
|
||||
function handlers(kind: Kind): Record<string, Handler> {
|
||||
@@ -139,6 +143,30 @@ function handlers(kind: Kind): Record<string, Handler> {
|
||||
const { o, t } = prep('focus', h, self, args);
|
||||
await h.focus(t, o, `${api}.focus`, kind === 'ElementHandle');
|
||||
},
|
||||
inputValue: async (h, self, args) => {
|
||||
const { o, t } = prep('inputValue', h, self, args);
|
||||
return h.read(t, 'inputValue', o, `${api}.inputValue`);
|
||||
},
|
||||
textContent: async (h, self, args) => {
|
||||
const { o, t } = prep('textContent', h, self, args);
|
||||
return h.read(t, 'textContent', o, `${api}.textContent`);
|
||||
},
|
||||
innerText: async (h, self, args) => {
|
||||
const { o, t } = prep('innerText', h, self, args);
|
||||
return h.read(t, 'innerText', o, `${api}.innerText`);
|
||||
},
|
||||
innerHTML: async (h, self, args) => {
|
||||
const { o, t } = prep('innerHTML', h, self, args);
|
||||
return h.read(t, 'innerHTML', o, `${api}.innerHTML`);
|
||||
},
|
||||
getAttribute: async (h, self, args) => {
|
||||
const { o, t, rest } = prep('getAttribute', h, self, args);
|
||||
return h.read(t, 'getAttribute', o, `${api}.getAttribute`, String(rest[0]));
|
||||
},
|
||||
dispatchEvent: async (h, self, args) => {
|
||||
const { o, t, rest } = prep('dispatchEvent', h, self, args);
|
||||
await h.dispatch(t, o, `${api}.dispatchEvent`, String(rest[0]), rest[1]);
|
||||
},
|
||||
};
|
||||
if (kind === 'Page' || kind === 'Frame') {
|
||||
table.dragAndDrop = async (h, self, args) => {
|
||||
@@ -191,7 +219,12 @@ function wrap(kind: Kind, proto: any, name: string, handler: Handler): void {
|
||||
const wrapper = async function (this: any, ...args: any[]) {
|
||||
const h = humanFor(kind, this);
|
||||
if (!h) return orig.apply(this, args);
|
||||
return handler(h, this, args);
|
||||
try {
|
||||
return await handler(h, this, args);
|
||||
} catch (e) {
|
||||
if (e instanceof RawFallback) return orig.apply(this, args);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
Object.defineProperty(wrapper, 'name', { value: name });
|
||||
(wrapper as any)[MARK] = true;
|
||||
@@ -288,7 +321,8 @@ function rawInput(page: Page): RawInput {
|
||||
}
|
||||
|
||||
const PAGE_ORIGINALS = ['click', 'dblclick', 'hover', 'type', 'fill', 'check', 'uncheck', 'selectOption', 'press', 'tap',
|
||||
'focus', 'setChecked', 'dragAndDrop'];
|
||||
'focus', 'setChecked', 'dragAndDrop',
|
||||
'inputValue', 'textContent', 'innerText', 'innerHTML', 'getAttribute', 'dispatchEvent'];
|
||||
|
||||
/** `page._original`: raw, un-humanized Playwright calls for this page. */
|
||||
function originals(page: Page): Record<string, (...a: any[]) => any> {
|
||||
|
||||
@@ -41,7 +41,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 = [];
|
||||
@@ -55,6 +55,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
|
||||
@@ -93,6 +95,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 <input>, <textarea> or <select> element' };
|
||||
return { value: e.value };
|
||||
},
|
||||
textContent(id) { return { value: get(id).textContent }; },
|
||||
innerText(id) {
|
||||
const e = get(id);
|
||||
if (e.namespaceURI !== 'http://www.w3.org/1999/xhtml') return { error: 'Node is not an HTMLElement' };
|
||||
return { value: e.innerText };
|
||||
},
|
||||
innerHTML(id) { return { value: get(id).innerHTML }; },
|
||||
getAttribute(id, name) { return { value: get(id).getAttribute(name) }; },
|
||||
dispatchEvent(id, type, eventInit) {
|
||||
try { I.dispatchEvent(get(id), type, eventInit); return { value: true }; }
|
||||
catch (err) { return { error: err && err.message ? err.message : String(err) }; }
|
||||
},
|
||||
activeValue() {
|
||||
const a = document.activeElement;
|
||||
if (!a || a === document.body) return { tag: null };
|
||||
|
||||
@@ -94,6 +94,85 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)('humanize engine (real br
|
||||
|
||||
// --- wiring -------------------------------------------------------------
|
||||
|
||||
it('humanized reads resolve in the isolated world: correct values, page state untouched', async () => {
|
||||
const p: any = await open();
|
||||
const dispatched = async () => p.evaluate(() => (window as any).__dispatched.length);
|
||||
const before = await dispatched();
|
||||
|
||||
await p.fill('#name', 'laptop');
|
||||
const reads = {
|
||||
inputValue: await p.inputValue('#name'),
|
||||
locatorInputValue: await p.locator('#name').inputValue(),
|
||||
handleInputValue: await (await p.$('#name')).inputValue(),
|
||||
textContent: await p.textContent('#btn'),
|
||||
innerText: await p.innerText('#btn'),
|
||||
innerHTML: await p.innerHTML('#btn'),
|
||||
getAttribute: await p.getAttribute('#btn', 'id'),
|
||||
locatorTextContent: await p.locator('#btn').textContent(),
|
||||
};
|
||||
|
||||
expect(reads.inputValue).toBe('laptop');
|
||||
expect(reads.locatorInputValue).toBe('laptop');
|
||||
expect(reads.handleInputValue).toBe('laptop');
|
||||
expect(reads.getAttribute).toBe('btn');
|
||||
expect(reads.textContent).toBe(reads.locatorTextContent);
|
||||
expect(reads.innerText).toBe(reads.textContent);
|
||||
expect(reads.innerHTML).toBe(reads.textContent);
|
||||
expect(await dispatched()).toBe(before);
|
||||
});
|
||||
|
||||
it('dispatchEvent reaches a page listener with the same event shape', async () => {
|
||||
const p: any = await open();
|
||||
await p.evaluate(() => {
|
||||
(window as any).__clicks = [];
|
||||
document.addEventListener('click', (e) => {
|
||||
(window as any).__clicks.push({ mouse: e instanceof MouseEvent, bubbles: e.bubbles, x: (e as MouseEvent).clientX });
|
||||
});
|
||||
});
|
||||
|
||||
await p.dispatchEvent('#btn', 'click', { clientX: 7, clientY: 9 });
|
||||
await p.locator('#btn').dispatchEvent('click', { clientX: 3, clientY: 4 });
|
||||
|
||||
const clicks = await p.evaluate(() => (window as any).__clicks);
|
||||
expect(clicks).toHaveLength(2);
|
||||
expect(clicks[0]).toEqual({ mouse: true, bubbles: true, x: 7 });
|
||||
});
|
||||
|
||||
it('handle reads of hidden or same-box elements use the raw path at once', async () => {
|
||||
const p: any = await open();
|
||||
await p.evaluate(() => {
|
||||
document.head.insertAdjacentHTML('beforeend', '<meta name="csrf" content="tok123">');
|
||||
document.body.insertAdjacentHTML('beforeend', '<input id="hid" type="hidden" value="secret">'
|
||||
+ '<div id="outer" style="width:90px;height:30px"><div id="inner" style="width:90px;height:30px"></div></div>');
|
||||
});
|
||||
const t = Date.now();
|
||||
expect(await (await p.$('meta[name="csrf"]')).getAttribute('content')).toBe('tok123');
|
||||
expect(await (await p.$('#hid')).inputValue()).toBe('secret');
|
||||
expect(Date.now() - t).toBeLessThan(5000);
|
||||
// A read must land on the handle's own element, not its same-box wrapper.
|
||||
expect(await (await p.$('#inner')).getAttribute('id')).toBe('inner');
|
||||
expect(await (await p.$('#outer')).getAttribute('id')).toBe('outer');
|
||||
});
|
||||
|
||||
it('dispatchEvent with a JSHandle in eventInit uses the raw path', async () => {
|
||||
const p: any = await open();
|
||||
await p.evaluate(() => {
|
||||
(window as any).__dt = [];
|
||||
document.addEventListener('dragstart', (e) => (window as any).__dt.push(e.dataTransfer instanceof DataTransfer));
|
||||
});
|
||||
const dt = await p.evaluateHandle(() => new DataTransfer());
|
||||
await p.dispatchEvent('#btn', 'dragstart', { dataTransfer: dt });
|
||||
await p.locator('#btn').dispatchEvent('dragstart', { dataTransfer: dt });
|
||||
expect(await p.evaluate(() => (window as any).__dt)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('the probe works: the page records events dispatched in its own world', async () => {
|
||||
const p: any = await open();
|
||||
const before = await p.evaluate(() => (window as any).__dispatched.length);
|
||||
await p.evaluate(() => document.getElementById('btn')!.dispatchEvent(new Event('probe')));
|
||||
expect(await p.evaluate(() => (window as any).__dispatched.length)).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('page is humanized and keeps the compat attributes', async () => {
|
||||
const p: any = await open();
|
||||
expect(p._humanCfg.mistype_chance).toBe(0);
|
||||
|
||||
@@ -73,6 +73,15 @@ const INDEX_HTML = `<!doctype html>
|
||||
note('document.querySelector', sel);
|
||||
return origQS.call(this, sel);
|
||||
};
|
||||
|
||||
// Records the event types the page sees dispatched, so tests can assert that a
|
||||
// humanized read leaves the page's own state alone.
|
||||
window.__dispatched = [];
|
||||
const origDispatch = EventTarget.prototype.dispatchEvent;
|
||||
EventTarget.prototype.dispatchEvent = function (e) {
|
||||
if (e && typeof e.type === 'string') window.__dispatched.push(e.type);
|
||||
return origDispatch.call(this, e);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
@@ -201,6 +210,15 @@ const FRAME_HTML = `<!doctype html>
|
||||
note('document.querySelector', sel);
|
||||
return origQS.call(this, sel);
|
||||
};
|
||||
|
||||
// Records the event types the page sees dispatched, so tests can assert that a
|
||||
// humanized read leaves the page's own state alone.
|
||||
window.__dispatched = [];
|
||||
const origDispatch = EventTarget.prototype.dispatchEvent;
|
||||
EventTarget.prototype.dispatchEvent = function (e) {
|
||||
if (e && typeof e.type === 'string') window.__dispatched.push(e.type);
|
||||
return origDispatch.call(this, e);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
|
||||
@@ -535,6 +535,35 @@ def test_handle_sharing_its_box_with_an_unrelated_element_raises(page):
|
||||
page.query_selector("#s1").click()
|
||||
|
||||
|
||||
def test_handle_reads_of_hidden_or_same_box_elements(page):
|
||||
"""A handle read needs the handle's own element: hidden (no box) and same-box
|
||||
handles run Playwright's own read at once; a unique visible one reads in the
|
||||
isolated world."""
|
||||
page.evaluate("""() => {
|
||||
document.head.insertAdjacentHTML('beforeend', '<meta name="csrf" content="tok123">');
|
||||
document.body.insertAdjacentHTML('beforeend', '<input id="hid" type="hidden" value="secret">'
|
||||
+ '<div id="outer" style="width:90px;height:30px"><div id="inner" style="width:90px;height:30px"></div></div>'
|
||||
+ '<p id="uniq" data-x="shown">u</p>'); }""")
|
||||
t = time.monotonic()
|
||||
assert page.query_selector('meta[name="csrf"]').get_attribute("content") == "tok123"
|
||||
assert page.query_selector("#hid").input_value() == "secret"
|
||||
assert time.monotonic() - t < 5
|
||||
assert page.query_selector("#inner").get_attribute("id") == "inner"
|
||||
assert page.query_selector("#outer").get_attribute("id") == "outer"
|
||||
assert page.query_selector("#uniq").get_attribute("data-x") == "shown"
|
||||
|
||||
|
||||
def test_dispatch_event_with_a_js_handle_in_event_init(page):
|
||||
"""Playwright's documented drag pattern: the DataTransfer handle lives in the
|
||||
page's main world, so the dispatch runs Playwright's own call."""
|
||||
page.evaluate("""() => { window.__dt = [];
|
||||
document.addEventListener('dragstart', e => __dt.push(e.dataTransfer instanceof DataTransfer)); }""")
|
||||
dt = page.evaluate_handle("() => new DataTransfer()")
|
||||
page.dispatch_event("#btn", "dragstart", {"dataTransfer": dt})
|
||||
page.locator("#btn").dispatch_event("dragstart", {"dataTransfer": dt})
|
||||
assert page.evaluate("() => __dt") == [True, True]
|
||||
|
||||
|
||||
def test_press_and_type_reach_non_focusable_targets(page):
|
||||
"""A <canvas> (or a game area with document-level key handlers) does not take
|
||||
focus. press()/type() click it and send the keys anyway, like Playwright."""
|
||||
|
||||
@@ -877,3 +877,179 @@ def test_check_playwright_still_flags_missing_internals(monkeypatch):
|
||||
monkeypatch.setattr(ElementHandle, "__init__", __init__)
|
||||
with pytest.raises(RuntimeError, match="missing internals: ElementHandle._frame"):
|
||||
patch._check_playwright()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Reads: resolved and read in the isolated world, never Playwright's own
|
||||
# read path (which a humanized flow otherwise never touches)
|
||||
# =========================================================================
|
||||
|
||||
READ_OPS = {
|
||||
"input_value": "inputValue",
|
||||
"text_content": "textContent",
|
||||
"inner_text": "innerText",
|
||||
"inner_html": "innerHTML",
|
||||
"get_attribute": "getAttribute",
|
||||
}
|
||||
|
||||
|
||||
class _StubWorlds:
|
||||
"""Stands in for human.world.Worlds: records ops, answers resolve/states."""
|
||||
|
||||
def __init__(self, result):
|
||||
self.result = result
|
||||
self.calls = []
|
||||
|
||||
async def call(self, frame, op, *args):
|
||||
self.calls.append((op, args))
|
||||
if op == "resolve":
|
||||
return {"status": "ok", "id": 7, "count": 1}
|
||||
if op == "states":
|
||||
return None
|
||||
return self.result
|
||||
|
||||
|
||||
class _StubFrame:
|
||||
def _timeout(self, timeout):
|
||||
return 5000.0 if timeout is None else float(timeout)
|
||||
|
||||
|
||||
def _human_with(result):
|
||||
from cloakbrowser.human.engine import Human, Target
|
||||
|
||||
human = Human.__new__(Human) # read() needs only worlds + the target frame
|
||||
human.worlds = _StubWorlds(result)
|
||||
return human, Target(_StubFrame(), "input[name='q']")
|
||||
|
||||
|
||||
def test_read_returns_the_world_value_without_playwright():
|
||||
from cloakbrowser.human.engine import Human
|
||||
|
||||
human, target = _human_with({"value": "laptop"})
|
||||
got = asyncio.run(human.read(target, "inputValue", {}, "page.input_value"))
|
||||
assert got == "laptop"
|
||||
ops = [op for op, _ in human.worlds.calls]
|
||||
assert ops == ["resolve", "states", "inputValue"], ops
|
||||
assert human.worlds.calls[-1][1] == (7,)
|
||||
|
||||
|
||||
def test_read_passes_extra_args_to_the_world_op():
|
||||
from cloakbrowser.human.engine import Human
|
||||
|
||||
human, target = _human_with({"value": "data-x"})
|
||||
got = asyncio.run(human.read(target, "getAttribute", {}, "page.get_attribute", "data-x"))
|
||||
assert got == "data-x"
|
||||
assert human.worlds.calls[-1] == ("getAttribute", (7, "data-x"))
|
||||
|
||||
|
||||
def test_read_maps_null_and_missing_values():
|
||||
for result in ({"value": None}, {}):
|
||||
human, target = _human_with(result)
|
||||
assert asyncio.run(human.read(target, "textContent", {}, "page.text_content")) is None
|
||||
|
||||
|
||||
def test_read_raises_playwright_error_on_world_error():
|
||||
from playwright.sync_api import Error
|
||||
from cloakbrowser.human.engine import Human
|
||||
|
||||
human, target = _human_with(
|
||||
{"error": "Node is not an <input>, <textarea> or <select> element"}
|
||||
)
|
||||
with pytest.raises(Error, match="page.input_value: Error: Node is not an <input>"):
|
||||
asyncio.run(human.read(target, "inputValue", {}, "page.input_value"))
|
||||
|
||||
|
||||
def test_read_api_label_follows_the_kind():
|
||||
"""Handlers pass ``<Kind>.<method>`` (same as click/type), e.g. Page.input_value."""
|
||||
from cloakbrowser.human import patch
|
||||
|
||||
class _StubHuman:
|
||||
def __init__(self):
|
||||
self.api = None
|
||||
|
||||
async def read(self, target, op, opts, api, *extra):
|
||||
self.api = api
|
||||
return None
|
||||
|
||||
impl = MagicMock()
|
||||
human = _StubHuman()
|
||||
asyncio.run(patch._handlers("Locator")["input_value"](human, impl, {"timeout": None, "human_config": None}))
|
||||
assert human.api == "Locator.input_value"
|
||||
|
||||
|
||||
def test_read_handlers_registered_for_every_class():
|
||||
from cloakbrowser.human import patch
|
||||
|
||||
for kind in ("Page", "Frame", "Locator", "ElementHandle"):
|
||||
table = patch._handlers(kind)
|
||||
for name in READ_OPS:
|
||||
assert name in table, f"{kind}.{name} not patched"
|
||||
|
||||
|
||||
def test_read_handlers_use_the_matching_world_op():
|
||||
from cloakbrowser.human import patch
|
||||
|
||||
class _StubHuman:
|
||||
def __init__(self):
|
||||
self.seen = []
|
||||
|
||||
async def read(self, target, op, opts, api, *extra):
|
||||
self.seen.append((op, api, extra))
|
||||
return "ok"
|
||||
|
||||
impl = MagicMock()
|
||||
for name, op in READ_OPS.items():
|
||||
human = _StubHuman()
|
||||
args = {"selector": "#x", "name": "data-x", "timeout": None, "strict": None,
|
||||
"human_config": None}
|
||||
asyncio.run(patch._handlers("Page")[name](human, impl, dict(args)))
|
||||
extra = ("data-x",) if name == "get_attribute" else ()
|
||||
assert human.seen == [(op, f"Page.{name}", extra)], (name, human.seen)
|
||||
|
||||
|
||||
def test_originals_expose_the_raw_reads():
|
||||
from cloakbrowser.human import patch
|
||||
|
||||
for name in READ_OPS:
|
||||
assert name in patch._Originals._PAGE, f"page._original.{name} missing"
|
||||
|
||||
|
||||
def test_dispatch_event_handlers_pass_type_and_init():
|
||||
from cloakbrowser.human import patch
|
||||
|
||||
class _StubHuman:
|
||||
def __init__(self):
|
||||
self.seen = []
|
||||
|
||||
async def dispatch(self, target, opts, api, event_type, event_init=None):
|
||||
self.seen.append((api, event_type, event_init))
|
||||
return None
|
||||
|
||||
impl = MagicMock()
|
||||
human = _StubHuman()
|
||||
asyncio.run(patch._handlers("Page")["dispatch_event"](
|
||||
human, impl, {"selector": "#x", "type": "click", "event_init": {"bubbles": False},
|
||||
"timeout": None, "strict": None, "human_config": None}))
|
||||
assert human.seen == [("Page.dispatch_event", "click", {"bubbles": False})]
|
||||
|
||||
human = _StubHuman()
|
||||
asyncio.run(patch._handlers("ElementHandle")["dispatch_event"](
|
||||
human, impl, {"type": "input", "event_init": None, "human_config": None}))
|
||||
assert human.seen == [("ElementHandle.dispatch_event", "input", None)]
|
||||
|
||||
|
||||
def test_dispatch_event_uses_the_world_op():
|
||||
from cloakbrowser.human.engine import Human
|
||||
|
||||
human, target = _human_with({"value": True})
|
||||
asyncio.run(human.dispatch(target, {}, "page.dispatch_event", "click", {"clientX": 7}))
|
||||
assert human.worlds.calls[-1] == ("dispatchEvent", (7, "click", {"clientX": 7}))
|
||||
|
||||
|
||||
def test_dispatch_event_raises_on_world_error():
|
||||
from playwright.sync_api import Error
|
||||
from cloakbrowser.human.engine import Human
|
||||
|
||||
human, target = _human_with({"error": "boom"})
|
||||
with pytest.raises(Error, match="page.dispatch_event: Error: boom"):
|
||||
asyncio.run(human.dispatch(target, {}, "page.dispatch_event", "click"))
|
||||
Reference in new issue
Block a user