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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable user-facing changes to Rustwright are documented in this file.
- Added native pending-file-chooser events and mirror-profile MCP file upload and cancellation with workspace-confined paths.
- Added native physical element dragging and the mirror-profile `browser_drag` MCP tool.
- Added alpha bindings for Go, Java, C#/.NET, Ruby, and PHP, plus a native Rust API, backed by the shared Rust engine.
- Added shadow DOM support to frame enumeration: iframes attached inside a shadow root now appear in `page.frames` and `frame.child_frames`, so widgets that mount their cross-origin iframe that way (Cloudflare Turnstile, hCaptcha, and embedded payment fields among them) are reachable.

### Changed

Expand All @@ -26,6 +27,12 @@ All notable user-facing changes to Rustwright are documented in this file.

### Fixed

- Fixed `page.evaluate()` treating an already-invoked IIFE as a function literal when its body contained an arrow function anywhere, which wrapped and re-called the value it had returned and failed with `__rw_fn is not a function`.
Comment thread
DemonMartin marked this conversation as resolved.
- Fixed the best-effort frame-tree refresh spending the caller's whole timeout per session, which made `Request.frame` inside a route handler stall the navigation it belonged to until that timeout expired, and hang indefinitely when the timeout was disabled.
- Fixed the stealth user-agent override pinning `Accept-Language` to `en-US,en`, which silently overrode `--accept-lang`/`--lang` and left a browser configured for one region reporting that region's timezone alongside an `en-US` locale.
- Fixed dedicated workers being given their identity by rewriting the page's `Worker` constructor to load a generated blob that `importScripts` the real script, which moved every worker off its own URL and changed its `location` and origin; worker identity is now installed over the worker's own CDP session, as it already was for service workers, and the worker keeps its real script URL.
- Fixed the stealth init script removing `navigator.webdriver` from browsers that already report `false`, replacing a value every real Chrome exposes with a missing property.
- Fixed child-frame enumeration pairing the DOM query and the protocol's children by position, which gave a light-DOM frame the identity of a shadow-root frame that preceded it; the two are now correlated by frame identity.
- Fixed locator waits so they re-arm after mid-wait navigation against the original timeout instead of surfacing execution-context errors.
- Fixed remote-CDP actionability probes so they receive the full remaining action budget rather than a short per-probe cap.
- Fixed Node.js evaluation decoding for special numeric values, BigInt, and regular expressions; Go/C-ABI and native Rust now use the core's canonical wire decoder.
Expand Down
51 changes: 44 additions & 7 deletions python/rustwright/sync_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13386,17 +13386,50 @@ def _child_frame_entries(self) -> list[dict[str, Any]]:
if isinstance(child, dict)
]
if isinstance(entries, list):
for index, entry in enumerate(entries):
if not isinstance(entry, dict) or index >= len(cdp_children):
# The DOM query orders and names the frames it can see; the protocol decides which
# exist. A frame in a shadow root is an ordinary child of the tree and invisible to
# `querySelectorAll`, so pairing the two by position would hand a light-DOM frame a
# shadow frame's identity. Match on URL, fall back to order, keep what was missed.
available = [child for child in cdp_children if isinstance(child, dict)]
claimed: set[int] = set()

def claim(entry: dict[str, Any]) -> Optional[dict[str, Any]]:
entry_url = str(entry.get("url") or "")
if entry_url:
for position, child in enumerate(available):
if position not in claimed and str(child.get("url") or "") == entry_url:
claimed.add(position)
return child
for position, child in enumerate(available):
if position not in claimed:
claimed.add(position)
return child
return None

for entry in entries:
if not isinstance(entry, dict):
continue
cdp_frame = claim(entry)
if cdp_frame is None:
continue
cdp_frame = cdp_children[index]
frame_id = cdp_frame.get("id")
if frame_id:
entry["id"] = str(frame_id)
if not entry.get("url") and cdp_frame.get("url"):
entry["url"] = str(cdp_frame.get("url") or "")
if not entry.get("name") and cdp_frame.get("name"):
entry["name"] = str(cdp_frame.get("name") or "")
for position, cdp_frame in enumerate(available):
if position in claimed:
continue
entries.append(
{
"id": cdp_frame.get("id"),
"name": cdp_frame.get("name") or "",
"url": cdp_frame.get("url") or "",
"frame_index": len(entries),
}
)
return entries if isinstance(entries, list) else []

def _wrap_spec(self, spec: Dict[str, Any]) -> Dict[str, Any]:
Expand Down Expand Up @@ -14849,13 +14882,17 @@ def _frame_from_spec(self, frame_spec: Dict[str, Any]) -> Frame:
def _cdp_frame_tree_root(self) -> Optional[dict[str, Any]]:
frame_tree = getattr(self._core, "frame_tree", None)
if frame_tree is not None:
# The core owns this tree: it answers from the frame state it maintains from protocol
# events and refreshes it on a bounded best-effort budget. An empty tree is an answer
# -- a document whose request is still paused has not committed a frame tree yet --
# so re-asking over a raw session here would only re-run the round trip the core just
# bounded, at `CDPSession.send`'s fixed 30s, once per frame walked.
try:
payload = json.loads(_call(frame_tree, self._default_timeout))
root = payload.get("frameTree") if isinstance(payload, dict) else None
if isinstance(root, dict):
return root
except Exception:
pass
return None
root = payload.get("frameTree") if isinstance(payload, dict) else None
return root if isinstance(root, dict) else None
try:
session = CDPSession(_call(self._core.cdp_session))
payload = session.send("Page.getFrameTree")
Expand Down
Loading