From 4364f1328ffcee1080f7643583879fe3464e3110 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:45:10 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'benchmarks?= =?UTF-8?q?/'=20with=20remote=20'benchmarks/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/209 --- benchmarks/automation_cases.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/benchmarks/automation_cases.py b/benchmarks/automation_cases.py index eb961b3..2d0b5f1 100644 --- a/benchmarks/automation_cases.py +++ b/benchmarks/automation_cases.py @@ -4292,7 +4292,9 @@ def page_event_waiters_reject_on_page_crash(page): @case def page_errors_history_and_clear(page): page.set_content("
page error history
") - page.evaluate("() => setTimeout(() => { throw new Error('parity page boom'); }, 0)") + page.add_script_tag( + content="setTimeout(() => { throw new Error('parity page boom'); }, 0)" + ) deadline = time.monotonic() + 3 errors = [] @@ -4313,7 +4315,9 @@ def page_errors_since_navigation_filter(page): after = "page error after navigation filter" after_set_content = "page error after set content filter" page.set_content("
page error filter
") - page.evaluate("(text) => setTimeout(() => { throw new Error(text); }, 0)", before) + page.add_script_tag( + content=f"setTimeout(() => {{ throw new Error({json.dumps(before)}); }}, 0)" + ) deadline = time.monotonic() + 3 while time.monotonic() < deadline: From 1364f4a8599d15cfe7463acc19e14131586d2b21 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:45:10 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'python/'?= =?UTF-8?q?=20with=20remote=20'python/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/209 --- python/rustwright/_async_generated.py | 2 +- python/rustwright/_compat/__init__.py | 204 +++++-- .../_compat/pytest_playwright/__init__.py | 11 +- python/rustwright/sync_api.py | 508 ++++++++++++++++-- 4 files changed, 620 insertions(+), 105 deletions(-) diff --git a/python/rustwright/_async_generated.py b/python/rustwright/_async_generated.py index 106c543..a6fcdf8 100644 --- a/python/rustwright/_async_generated.py +++ b/python/rustwright/_async_generated.py @@ -1,5 +1,5 @@ # This file is generated by tools/generate_async_api.py. Do not edit. -# sync_api.py sha256: ee8829c2aee8bbcf37bb44950e754223ecc0f71492c5faedfb1dc48389827067 +# sync_api.py sha256: ea3c8280513eef43c9f177ffa60f88315945b86a549b42513aa9249933d28240 from __future__ import annotations from pathlib import Path diff --git a/python/rustwright/_compat/__init__.py b/python/rustwright/_compat/__init__.py index c47eeb8..ae4c9ab 100644 --- a/python/rustwright/_compat/__init__.py +++ b/python/rustwright/_compat/__init__.py @@ -1,14 +1,29 @@ -"""Explicit opt-in Playwright/Patchright/Cloakbrowser import compatibility.""" +"""Explicit opt-in Playwright/Patchright/Cloakbrowser import compatibility. + +Aliases are installed eagerly. If pytest is not importable when +:func:`enable_playwright_compat` runs, pytest-plugin aliases are skipped. Call +the function again after pytest becomes importable to add them. Pytest users +normally enable compatibility inside a process where pytest is importable. + +Target imports happen before alias publication. If enable fails, canonical +``rustwright._compat.*`` modules and pytest imported during that phase stay in +``sys.modules``. Complete rollback covers only legacy alias entries and their +parent attributes; removing canonical imports could disturb unrelated users. + +Do not enable or disable compatibility concurrently with in-flight imports of +aliased names. Direct ``sys.modules`` aliasing cannot make those imports atomic. +""" from __future__ import annotations import importlib +import importlib.util import sys +from threading import RLock from types import ModuleType -from typing import Optional - +from typing import NamedTuple -_ALIASES = ( +_CORE_ALIASES = ( ("playwright", "rustwright._compat.playwright"), ("playwright.__main__", "rustwright._compat.playwright.__main__"), ("playwright._impl", "rustwright._compat.playwright._impl"), @@ -16,7 +31,6 @@ ("playwright._impl._errors", "rustwright._compat.playwright._impl._errors"), ("playwright.async_api", "rustwright._compat.playwright.async_api"), ("playwright.async_api._generated", "rustwright._compat.playwright.async_api._generated"), - ("playwright.pytest_plugin", "rustwright._compat.playwright.pytest_plugin"), ("playwright.sync_api", "rustwright._compat.playwright.sync_api"), ("playwright.sync_api._generated", "rustwright._compat.playwright.sync_api._generated"), ("patchright", "rustwright._compat.patchright"), @@ -26,69 +40,163 @@ ("patchright._impl._errors", "rustwright._compat.patchright._impl._errors"), ("patchright.async_api", "rustwright._compat.patchright.async_api"), ("patchright.async_api._generated", "rustwright._compat.patchright.async_api._generated"), - ("patchright.pytest_plugin", "rustwright._compat.patchright.pytest_plugin"), ("patchright.sync_api", "rustwright._compat.patchright.sync_api"), ("patchright.sync_api._generated", "rustwright._compat.patchright.sync_api._generated"), ("cloakbrowser", "rustwright._compat.cloakbrowser"), - # The pytest_playwright aliases re-export the full rustwright plugin. A - # real pytest-playwright distribution's entry point resolving here loads - # the plugin a second time, which is safe by construction: option - # registration skips already-taken flags and browser_name parametrization - # is guarded to run at most once per test. +) + +_PYTEST_ALIASES = ( ("pytest_playwright", "rustwright._compat.pytest_playwright"), + ("playwright.pytest_plugin", "rustwright._compat.playwright.pytest_plugin"), + ("patchright.pytest_plugin", "rustwright._compat.patchright.pytest_plugin"), ("pytest_playwright.pytest_playwright", "rustwright._compat.pytest_playwright.pytest_playwright"), ) -_PREVIOUS_MODULES: dict[str, Optional[ModuleType]] = {} +_MISSING = object() +_PREVIOUS_MODULES: dict[str, object] = {} +_PREVIOUS_PARENT_ATTRIBUTES: dict[str, tuple[ModuleType, object]] = {} +_STATE_LOCK = RLock() _ENABLED = False +_PYTEST_ALIASES_ENABLED = False -def _set_parent_attribute(module_name: str, module: ModuleType) -> None: - parent_name, _, child_name = module_name.rpartition(".") - if not parent_name: - return - parent = sys.modules.get(parent_name) - if parent is not None: - setattr(parent, child_name, module) +class PlaywrightCompatEnableResult(NamedTuple): + """Aliases registered or skipped by the active compatibility state.""" + enabled: bool + registered_aliases: tuple[str, ...] + skipped_aliases: tuple[str, ...] -def enable_playwright_compat() -> None: - """Enable legacy Playwright-compatible import names for this Python process. - After this is called, subsequent imports such as ``playwright.sync_api`` or - ``patchright.async_api`` resolve to Rustwright's compatibility shims. - """ +_LAST_ENABLE_RESULT = PlaywrightCompatEnableResult(False, (), ()) - global _ENABLED - if _ENABLED: - return - loaded_modules = [(alias_name, importlib.import_module(target_name)) for alias_name, target_name in _ALIASES] - for alias_name, module in loaded_modules: - _PREVIOUS_MODULES[alias_name] = sys.modules.get(alias_name) - sys.modules[alias_name] = module - _set_parent_attribute(alias_name, module) +def _compat_transaction_hook(event: str, alias_name: str | None = None) -> None: + """Stable no-op event seam for compatibility transaction tests.""" - _ENABLED = True - -def disable_playwright_compat() -> None: - """Undo aliases installed by :func:`enable_playwright_compat`.""" - - global _ENABLED - if not _ENABLED: +def _set_parent_attribute( + module_name: str, + module: ModuleType, + previous_parent_attributes: dict[str, tuple[ModuleType, object]], +) -> None: + parent_name, _, child_name = module_name.rpartition(".") + parent = sys.modules.get(parent_name) + if not isinstance(parent, ModuleType): return - - for alias_name, _target_name in _ALIASES: - previous = _PREVIOUS_MODULES.get(alias_name) - if previous is None: + if module_name not in previous_parent_attributes: + previous_parent_attributes[module_name] = ( + parent, + vars(parent).get(child_name, _MISSING), + ) + ModuleType.__setattr__(parent, child_name, module) + + +def _restore_aliases( + previous_modules: dict[str, object], + previous_parent_attributes: dict[str, tuple[ModuleType, object]], +) -> None: + for alias_name in sorted(previous_modules, key=lambda name: name.count("."), reverse=True): + previous_module = previous_modules[alias_name] + if previous_module is _MISSING: sys.modules.pop(alias_name, None) else: - sys.modules[alias_name] = previous - _set_parent_attribute(alias_name, previous) + sys.modules[alias_name] = previous_module + + parent_snapshot = previous_parent_attributes.get(alias_name) + if parent_snapshot is None: + continue + parent, previous_attribute = parent_snapshot + child_name = alias_name.rpartition(".")[2] + if previous_attribute is _MISSING: + if child_name in vars(parent): + ModuleType.__delattr__(parent, child_name) + else: + ModuleType.__setattr__(parent, child_name, previous_attribute) - _PREVIOUS_MODULES.clear() - _ENABLED = False +def enable_playwright_compat() -> PlaywrightCompatEnableResult: + """Enable legacy aliases and report any aliases skipped without pytest. -__all__ = ["disable_playwright_compat", "enable_playwright_compat"] + Every target import completes before the compatibility lock is acquired. + Calling again after pytest becomes importable upgrades an enabled core-only + state with the pytest aliases. + + Target imports are outside the rollback boundary. Successfully imported + canonical compatibility modules, including pytest dependencies, remain in + ``sys.modules`` if enable later fails. Legacy alias entries and their parent + attributes are the complete transactional publication boundary. + """ + + global _ENABLED, _LAST_ENABLE_RESULT, _PYTEST_ALIASES_ENABLED + + pytest_available = importlib.util.find_spec("pytest") is not None + aliases_to_import = _CORE_ALIASES + (_PYTEST_ALIASES if pytest_available else ()) + _compat_transaction_hook("enable-before-import") + loaded_modules = tuple( + (alias_name, importlib.import_module(target_name)) + for alias_name, target_name in aliases_to_import + ) + _compat_transaction_hook("enable-after-import") + + with _STATE_LOCK: + _compat_transaction_hook("enable-lock-acquired") + if _ENABLED: + if not pytest_available or _PYTEST_ALIASES_ENABLED: + return _LAST_ENABLE_RESULT + modules_to_publish = loaded_modules[len(_CORE_ALIASES) :] + else: + modules_to_publish = loaded_modules + + previous_modules = { + alias_name: sys.modules.get(alias_name, _MISSING) + for alias_name, _module in modules_to_publish + } + previous_parent_attributes: dict[str, tuple[ModuleType, object]] = {} + try: + for alias_name, module in modules_to_publish: + sys.modules[alias_name] = module + _set_parent_attribute(alias_name, module, previous_parent_attributes) + _compat_transaction_hook("enable-after-alias-publish", alias_name) + except BaseException: + _restore_aliases(previous_modules, previous_parent_attributes) + raise + + _PREVIOUS_MODULES.update(previous_modules) + _PREVIOUS_PARENT_ATTRIBUTES.update(previous_parent_attributes) + _ENABLED = True + _PYTEST_ALIASES_ENABLED = _PYTEST_ALIASES_ENABLED or pytest_available + skipped_aliases = ( + () + if _PYTEST_ALIASES_ENABLED + else tuple(alias_name for alias_name, _target_name in _PYTEST_ALIASES) + ) + _LAST_ENABLE_RESULT = PlaywrightCompatEnableResult( + True, + tuple(_PREVIOUS_MODULES), + skipped_aliases, + ) + return _LAST_ENABLE_RESULT + + +def disable_playwright_compat() -> None: + """Restore modules and parent attributes replaced by compatibility.""" + + global _ENABLED, _LAST_ENABLE_RESULT, _PYTEST_ALIASES_ENABLED + + with _STATE_LOCK: + if not _ENABLED: + return + _restore_aliases(_PREVIOUS_MODULES, _PREVIOUS_PARENT_ATTRIBUTES) + _PREVIOUS_MODULES.clear() + _PREVIOUS_PARENT_ATTRIBUTES.clear() + _ENABLED = False + _PYTEST_ALIASES_ENABLED = False + _LAST_ENABLE_RESULT = PlaywrightCompatEnableResult(False, (), ()) + + +__all__ = [ + "PlaywrightCompatEnableResult", + "disable_playwright_compat", + "enable_playwright_compat", +] diff --git a/python/rustwright/_compat/pytest_playwright/__init__.py b/python/rustwright/_compat/pytest_playwright/__init__.py index 82b3f29..8e39df2 100644 --- a/python/rustwright/_compat/pytest_playwright/__init__.py +++ b/python/rustwright/_compat/pytest_playwright/__init__.py @@ -1,3 +1,10 @@ -from .pytest_playwright import CreateContextCallback +"""Compatibility surface for the optional pytest plugin.""" -__all__ = ["CreateContextCallback"] +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .pytest_playwright import * + +if TYPE_CHECKING: + from .pytest_playwright import CreateContextCallback as CreateContextCallback diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 835b8ce..5c2e2db 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -3092,12 +3092,12 @@ def _file_chooser_upload_paths(files: Any, temporary_directories: list[tempfile. def _contains_file_payload(files: Any) -> bool: - if isinstance(files, dict): + if isinstance(files, (dict, bytes, bytearray)): return True - if files is None or isinstance(files, (str, Path, bytes, bytearray)): + if files is None or isinstance(files, (str, Path)): return False try: - return any(isinstance(item, dict) for item in files) + return any(isinstance(item, (dict, bytes, bytearray)) for item in files) except TypeError: return False @@ -3407,6 +3407,48 @@ def _wait_for_url_timeout_error(timeout_ms: float) -> TimeoutError: def _method_timeout_error(method: str, timeout_ms: float) -> TimeoutError: return TimeoutError(f"{method}: Timeout {_format_timeout_value(timeout_ms)}ms exceeded.") +def _file_chooser_deadline(timeout_ms: float) -> float: + return float("inf") if timeout_ms == 0 else time.monotonic() + timeout_ms / 1000 + + +def _file_chooser_remaining_ms(deadline: float, timeout_ms: float) -> float: + if math.isinf(deadline): + return 0.0 + remaining_ms = (deadline - time.monotonic()) * 1000 + if remaining_ms <= 0: + raise _method_timeout_error("FileChooser.set_files", timeout_ms) + return max(remaining_ms, 1.0) + + +def _file_chooser_cleanup_timeout_ms(deadline: float) -> Optional[float]: + if math.isinf(deadline): + return 1_000.0 + remaining_ms = (deadline - time.monotonic()) * 1000 + # Rust floors positive sub-millisecond command timeouts to 1 ms. Skip the + # release instead of exceeding the operation deadline; teardown reclaims it. + if remaining_ms < 1.0: + return None + return min(remaining_ms, 1_000.0) + + +def _file_chooser_directory_inventory( + directories: list[Path], + *, + deadline: float, + timeout_ms: float, +) -> list[str]: + expected_files: list[str] = [] + for directory in directories: + _file_chooser_remaining_ms(deadline, timeout_ms) + for child in directory.rglob("*"): + _file_chooser_remaining_ms(deadline, timeout_ms) + is_file = child.is_file() + _file_chooser_remaining_ms(deadline, timeout_ms) + if is_file: + expected_files.append(f"{directory.name}/{child.relative_to(directory).as_posix()}") + _file_chooser_remaining_ms(deadline, timeout_ms) + return expected_files + def _resolve_url_match_base(base_url: Optional[str], expected: Any) -> Any: if not base_url or not isinstance(expected, str) or expected.startswith("*"): @@ -9637,8 +9679,22 @@ def __init__(self, page: Optional["Page"], payload: dict[str, Any], *, worker: O self.type = str(payload.get("type") or "log") self.text = str(payload.get("text") or "") self.timestamp = float(payload.get("timestamp") or time.time() * 1000) + owner_frame = None + if page is not None and worker is None: + session_id = payload.get("session_id") + execution_context_id = payload.get("execution_context_id") + if session_id is not None and execution_context_id is not None: + try: + frame_id = page._core.execution_context_frame_id( + str(session_id), + str(execution_context_id), + ) + if frame_id: + owner_frame = page._frame_for_id(str(frame_id)) + except (AttributeError, Error): + owner_frame = None self.args = [ - JSHandle(page or worker, _console_arg_handle_payload(arg)) + JSHandle(page or worker, _console_arg_handle_payload(arg), owner_frame=owner_frame) for arg in list(payload.get("args") or []) ] location = payload.get("location") @@ -9732,6 +9788,7 @@ def __init__(self, page: "Page", payload: dict[str, Any]): self._frame_id = str(payload.get("frame_id") or "") self._backend_node_id = int(payload.get("backend_node_id") or 0) self._mode = str(payload.get("mode") or "") + self._session_id = str(payload.get("session_id") or "") self._temporary_upload_dirs: list[tempfile.TemporaryDirectory[str]] = [] def is_multiple(self) -> bool: @@ -9741,59 +9798,228 @@ def is_multiple(self) -> bool: def page(self) -> "Page": return self._page + def _resolve_backend_node_element( + self, + *, + method: str, + command_timeout_ms: float, + mutation: bool, + ) -> "ElementHandle": + core_session = ( + _call_with_method_prefix( + method, + self._page._core.cdp_session_for_id, + self._session_id, + ) + if self._session_id + else _call_with_method_prefix(method, self._page._core.cdp_session) + ) + payload = json.loads( + _call_with_method_prefix( + method, + core_session.send, + "DOM.resolveNode", + json.dumps({"backendNodeId": self._backend_node_id}), + command_timeout_ms, + ) + ) + remote = payload.get("object") + if not isinstance(remote, dict) or not remote.get("objectId"): + raise Error(f"{method}: Element is not attached to the DOM") + class_name = remote.get("className") + if remote.get("subtype") != "node" and not ( + isinstance(class_name, str) and class_name.endswith("Element") + ): + raise Error(f"{method}: JSHandle is not an Element") + if self._session_id: + remote["__rustwright_session_id"] = self._session_id + if self._frame_id: + remote["__rustwright_realm_identity"] = f"frame:{self._frame_id}" + owner_frame = ( + self._page.main_frame + if mutation or not self._frame_id + else self._page._frame_for_id(self._frame_id) + ) + handle = JSHandle(self._page, remote, owner_frame=owner_frame) + return ElementHandle(owner_frame.locator("*").nth(0), handle=handle) + + def _mutation_element(self, *, deadline: float, timeout_ms: float) -> "ElementHandle": + command_timeout_ms = _file_chooser_remaining_ms(deadline, timeout_ms) + try: + if self._backend_node_id: + return self._resolve_backend_node_element( + method="FileChooser.set_files", + command_timeout_ms=command_timeout_ms, + mutation=True, + ) + locator = self._page.locator("input[type=file]").first + handle = locator._evaluate_handle_with_method( + "(element) => element", + timeout=command_timeout_ms, + method="FileChooser.set_files", + ) + return ElementHandle(locator, handle=handle) + except TimeoutError: + raise _method_timeout_error("FileChooser.set_files", timeout_ms) from None + + def _dispose_mutation_element(self, element: "ElementHandle", *, deadline: float) -> None: + cleanup_timeout_ms = _file_chooser_cleanup_timeout_ms(deadline) + if cleanup_timeout_ms is None: + return + try: + element._dispose_with_timeout(cleanup_timeout_ms) + except Exception: + # This release is best-effort and must not replace the set_files result. + pass + @property def element(self) -> "ElementHandle": if self._backend_node_id: - handle: Optional[JSHandle] = None try: - session = CDPSession(_call(self._page._core.cdp_session)) - payload = session.send("DOM.resolveNode", {"backendNodeId": self._backend_node_id}) - remote = payload.get("object") - if isinstance(remote, dict): - handle = JSHandle(self._page, remote) - element = handle.as_element() - if element is not None: - handle = None - return element + return self._resolve_backend_node_element( + method="FileChooser.element", + command_timeout_ms=30_000.0, + mutation=False, + ) except Exception: pass - finally: - if handle is not None: - handle.dispose() - handle = _element_handle_from_locator(self._page.locator("input[type=file]").first) - return handle + return _element_handle_from_locator(self._page.locator("input[type=file]").first) def set_files(self, files: Any, *, timeout: Optional[float] = None, no_wait_after: Optional[bool] = None) -> None: timeout_ms = _default_timeout_for_method(self._page, timeout, method="FileChooser.set_files") + deadline = _file_chooser_deadline(timeout_ms) upload_files = files if files is not None and not isinstance(files, (str, Path, bytes, bytearray, dict)): try: upload_files = list(files) except TypeError: upload_files = files + _file_chooser_remaining_ms(deadline, timeout_ms) if _contains_file_payload(upload_files): payloads = _file_payloads(upload_files) + _file_chooser_remaining_ms(deadline, timeout_ms) if len(payloads) > 1 and not self.is_multiple(): raise Error("FileChooser.set_files: Error: Non-multiple file input can only accept single file") - element = self.element - locator = element._live_locator("set_input_files") - assert locator is not None - locator._set_input_files_impl( - "FileChooser.set_files", - upload_files, - timeout=timeout_ms, - no_wait_after=no_wait_after, - ) + element = self._mutation_element(deadline=deadline, timeout_ms=timeout_ms) + try: + try: + element._evaluate_with_timeout( + """(input, payloads) => { + if (!(input instanceof HTMLInputElement) || input.type !== 'file') + throw new Error('Element is not a file input'); + const transfer = new DataTransfer(); + for (const payload of payloads) { + const binary = atob(payload.buffer || ''); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) + bytes[index] = binary.charCodeAt(index); + transfer.items.add(new File( + [bytes], + payload.name || 'file', + { type: payload.mime_type || '' }, + )); + } + input.files = transfer.files; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }""", + payloads, + timeout_ms=_file_chooser_remaining_ms(deadline, timeout_ms), + method="FileChooser.set_files", + ) + except TimeoutError: + raise _method_timeout_error("FileChooser.set_files", timeout_ms) from None + finally: + self._dispose_mutation_element(element, deadline=deadline) return paths = _file_chooser_upload_paths(upload_files, self._temporary_upload_dirs) + _file_chooser_remaining_ms(deadline, timeout_ms) if len(paths) > 1 and not self.is_multiple(): raise Error("FileChooser.set_files: Error: Non-multiple file input can only accept single file") - _call( - self._page._core.set_file_input_files, - self._backend_node_id, - json_module_dumps(paths), - timeout_ms, - ) + if not paths: + element = self._mutation_element(deadline=deadline, timeout_ms=timeout_ms) + try: + try: + element._evaluate_with_timeout( + """input => { + input.files = new DataTransfer().files; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }""", + timeout_ms=_file_chooser_remaining_ms(deadline, timeout_ms), + method="FileChooser.set_files", + ) + except TimeoutError: + raise _method_timeout_error("FileChooser.set_files", timeout_ms) from None + finally: + self._dispose_mutation_element(element, deadline=deadline) + return + try: + _call_with_method_prefix( + "FileChooser.set_files", + self._page._core.set_file_input_files, + self._backend_node_id, + json_module_dumps(paths), + _file_chooser_remaining_ms(deadline, timeout_ms), + self._session_id or None, + ) + except TimeoutError: + raise _method_timeout_error("FileChooser.set_files", timeout_ms) from None + directories: list[Path] = [] + for path in paths: + _file_chooser_remaining_ms(deadline, timeout_ms) + candidate = Path(path) + is_directory = candidate.is_dir() + _file_chooser_remaining_ms(deadline, timeout_ms) + if is_directory: + directories.append(candidate) + if directories: + expected_files = _file_chooser_directory_inventory( + directories, + deadline=deadline, + timeout_ms=timeout_ms, + ) + ready_expression = """(input, expected) => { + const actual = new Map(); + for (const file of input.files) { + const path = file.webkitRelativePath; + actual.set(path, (actual.get(path) || 0) + 1); + } + return input.files.length === expected.length && expected.every(value => { + const count = actual.get(value) || 0; + if (!count) return false; + actual.set(value, count - 1); + return true; + }); + }""" + else: + expected_files = [] + for path in paths: + _file_chooser_remaining_ms(deadline, timeout_ms) + expected_files.append(Path(path).name) + ready_expression = """(input, expected) => { + const actual = Array.from(input.files, file => file.name); + return actual.length === expected.length + && actual.every((value, index) => value === expected[index]); + }""" + element = self._mutation_element(deadline=deadline, timeout_ms=timeout_ms) + try: + while True: + try: + ready = element._evaluate_with_timeout( + ready_expression, + expected_files, + timeout_ms=_file_chooser_remaining_ms(deadline, timeout_ms), + method="FileChooser.set_files", + ) + except TimeoutError: + raise _method_timeout_error("FileChooser.set_files", timeout_ms) from None + if ready: + return + _file_chooser_remaining_ms(deadline, timeout_ms) + _sleep_until_next_poll(deadline) + finally: + self._dispose_mutation_element(element, deadline=deadline) class JSHandle(_EventEmitter): @@ -9802,12 +10028,31 @@ def __init__(self, page: Any, payload: dict[str, Any], *, owner_frame: Optional[ self._payload = payload self._object_id = payload.get("objectId") self._session_id = payload.get("__rustwright_session_id") + self._realm_identity_override = payload.get("__rustwright_realm_identity") self._owner_frame = owner_frame self._disposed = False def _session_args(self) -> tuple[str, ...]: return () if self._session_id is None else (str(self._session_id),) + def _realm_identity(self) -> Optional[str]: + if self._realm_identity_override is not None: + return str(self._realm_identity_override) + if self._owner_frame is not None: + frame_id = getattr(self._owner_frame, "_frame_id", None) + if frame_id: + return f"frame:{frame_id}" + target_id = getattr(self._page, "_target_id", None) + if target_id: + return f"worker:{target_id}" + return None + + def _serialized_owner_args(self) -> tuple[Any, ...]: + realm_identity = self._realm_identity() + if self._session_id is None: + return () if self._owner_frame is None else (None, realm_identity) + return (str(self._session_id), realm_identity) + def _preview(self) -> str: if self._payload.get("subtype") == "node": return "JSHandle@node" @@ -9848,7 +10093,7 @@ def json_value(self) -> Any: self._page._core.js_handle_json_value, self._object_id, self._page._default_timeout, - *self._session_args(), + *self._serialized_owner_args(), ) ) ) @@ -9871,7 +10116,7 @@ def _truthy(self, timeout_ms: Optional[float] = None) -> bool: None, True, self._page._default_timeout if timeout_ms is None else timeout_ms, - *self._session_args(), + *self._serialized_owner_args(), ) return bool(_decode_json_result(json.loads(result))) if self._payload.get("type") == "undefined" or self._payload.get("subtype") == "null": @@ -9950,8 +10195,17 @@ def as_element(self) -> Optional["ElementHandle"]: except Error: return None - def _evaluate_with_method(self, expression: str, arg: Any = None, *, method: str) -> Any: + def _evaluate_with_method( + self, + expression: str, + arg: Any = None, + *, + method: str, + timeout_ms: Optional[float] = None, + ) -> Any: expression = _normalize_string_option(expression, method=method, name="expression") + if arg is not None: + _ensure_evaluate_argument_context(self._page, self._owner_frame, arg, method=method) if hasattr(self._page, "_mark_history_events_may_arrive"): self._page._mark_history_events_may_arrive() if not self._object_id: @@ -9976,8 +10230,8 @@ def _evaluate_with_method(self, expression: str, arg: Any = None, *, method: str _evaluate_handle_argument_function(expression), json_module_dumps(prepared.cdp_arguments()), True, - None, - *self._session_args(), + timeout_ms, + *self._serialized_owner_args(), ) return _decode_json_result(json.loads(result)) finally: @@ -9991,8 +10245,8 @@ def _evaluate_with_method(self, expression: str, arg: Any = None, *, method: str expression, arg_json, True, - None, - *self._session_args(), + timeout_ms, + *self._serialized_owner_args(), ) return _decode_json_result(json.loads(result)) @@ -10002,6 +10256,8 @@ def evaluate(self, expression: str, arg: Any = None) -> Any: def _evaluate_handle_with_method(self, expression: str, arg: Any = None, *, method: str) -> "JSHandle": expression = _normalize_string_option(expression, method=method, name="expression") + if arg is not None: + _ensure_evaluate_argument_context(self._page, self._owner_frame, arg, method=method) if hasattr(self._page, "_mark_history_events_may_arrive"): self._page._mark_history_events_may_arrive() if not self._object_id: @@ -10028,7 +10284,7 @@ def _evaluate_handle_with_method(self, expression: str, arg: Any = None, *, meth json_module_dumps(prepared.cdp_arguments()), False, None, - *self._session_args(), + *self._serialized_owner_args(), ) ) return JSHandle(self._page, payload, owner_frame=self._owner_frame) @@ -10045,7 +10301,7 @@ def _evaluate_handle_with_method(self, expression: str, arg: Any = None, *, meth arg_json, False, None, - *self._session_args(), + *self._serialized_owner_args(), ) ) return JSHandle(self._page, payload, owner_frame=self._owner_frame) @@ -10054,18 +10310,21 @@ def evaluate_handle(self, expression: str, arg: Any = None) -> "JSHandle": self._ensure_not_disposed("evaluate_handle") return self._evaluate_handle_with_method(expression, arg, method="JSHandle.evaluate_handle") - def dispose(self) -> None: + def _dispose_with_timeout(self, timeout_ms: Optional[float]) -> None: if self._disposed: return if self._object_id: _call( self._page._core.js_handle_dispose, self._object_id, - None, + timeout_ms, *self._session_args(), ) self._disposed = True + def dispose(self) -> None: + self._dispose_with_timeout(None) + def json_module_dumps(value: Any) -> str: return __import__("json").dumps(value, separators=(",", ":")) @@ -13478,6 +13737,10 @@ def __init__( self._uses_direct_evaluation = False self._child_frame_cache: list["Frame"] = [] + def _raise_if_detached(self, method: str) -> None: + if self.is_detached(): + raise Error(f"{method}: Frame was detached") + def _remember_child_frame(self, frame: "Frame") -> None: if all(existing is not frame for existing in self._child_frame_cache): self._child_frame_cache.append(frame) @@ -13774,7 +14037,15 @@ def wait_for_selector( return _element_handle_from_locator(locator.nth(0)) if attached else None def evaluate(self, expression: str, arg: Any = None) -> Any: + self._raise_if_detached("Frame.evaluate") expression = _normalize_string_option(expression, method="Frame.evaluate", name="expression") + if arg is not None: + _ensure_evaluate_argument_context( + self._page, + self, + arg, + method="Frame.evaluate", + ) self._page._mark_request_cookie_sync_required() self._page._mark_history_events_may_arrive() if self._is_main: @@ -13808,6 +14079,23 @@ def evaluate(self, expression: str, arg: Any = None) -> Any: if result is not None: self._uses_direct_evaluation = True return _decode_json_result(json.loads(result)) + if arg is not None and _argument_contains_handle(arg): + prepared = _prepare_evaluate_argument(self._page, arg) + try: + anchor = prepared.handles[0] + result = _call_with_method_prefix( + "Frame.evaluate", + self._page._core.js_handle_evaluate_with_call_arguments, + anchor._object_id, + _evaluate_argument_function(expression), + json_module_dumps(prepared.cdp_arguments()), + True, + None, + *anchor._serialized_owner_args(), + ) + return _decode_json_result(json.loads(result)) + finally: + prepared.dispose_temporaries() if self._frame_spec is not None: return self.locator(":root")._evaluate_with_method( """(el, payload) => { @@ -13860,11 +14148,38 @@ def evaluate(self, expression: str, arg: Any = None) -> Any: ) def evaluate_handle(self, expression: str, arg: Any = None) -> JSHandle: + self._raise_if_detached("Frame.evaluate_handle") expression = _normalize_string_option(expression, method="Frame.evaluate_handle", name="expression") + if arg is not None: + _ensure_evaluate_argument_context( + self._page, + self, + arg, + method="Frame.evaluate_handle", + ) self._page._mark_request_cookie_sync_required() self._page._mark_history_events_may_arrive() if self._is_main: return self._page._evaluate_handle_with_timeout(expression, arg, method="Frame.evaluate_handle") + if arg is not None and _argument_contains_handle(arg): + prepared = _prepare_evaluate_argument(self._page, arg) + try: + anchor = prepared.handles[0] + payload = json.loads( + _call_with_method_prefix( + "Frame.evaluate_handle", + self._page._core.js_handle_evaluate_with_call_arguments, + anchor._object_id, + _evaluate_argument_function(expression), + json_module_dumps(prepared.cdp_arguments()), + False, + None, + *anchor._serialized_owner_args(), + ) + ) + return JSHandle(self._page, payload, owner_frame=self) + finally: + prepared.dispose_temporaries() handle = self.locator(":root")._evaluate_handle_with_method( """(el, payload) => { const [expression, arg] = payload; @@ -17503,6 +17818,8 @@ def emulate_media( def _evaluate_with_method(self, expression: str, arg: Any = None, *, method: str) -> Any: expression = _normalize_string_option(expression, method=method, name="expression") + if arg is not None: + _ensure_evaluate_argument_context(self, self._main_frame, arg, method=method) self._mark_request_cookie_sync_required() self._mark_history_events_may_arrive() console_marker = self._console_dispatch_marker_if_listening() @@ -17562,6 +17879,8 @@ def _evaluate_handle_with_timeout( method: str = "Page.evaluate_handle", ) -> JSHandle: expression = _normalize_string_option(expression, method=method, name="expression") + if arg is not None: + _ensure_evaluate_argument_context(self, self._main_frame, arg, method=method) self._mark_request_cookie_sync_required() self._mark_history_events_may_arrive() command_timeout = timeout_ms @@ -17578,7 +17897,7 @@ def _evaluate_handle_with_timeout( command_timeout, ) ) - return JSHandle(self, payload) + return JSHandle(self, payload, owner_frame=self._main_frame) finally: prepared.dispose_temporaries() arg = prepared.value if prepared is not None else arg @@ -17592,7 +17911,7 @@ def _evaluate_handle_with_timeout( command_timeout, ) ) - return JSHandle(self, payload) + return JSHandle(self, payload, owner_frame=self._main_frame) def evaluate_handle(self, expression: str, arg: Any = None) -> JSHandle: return self._evaluate_handle_with_timeout(expression, arg) @@ -21690,6 +22009,12 @@ def _wait_for_fill_ready(self, action: str, *, timeout: Optional[float] = None) f"timed out waiting for locator to be editable while trying to {action}; {detail}" ) + def _owner_frame(self) -> Frame: + frame_spec = _frame_scope_spec_from_element_spec(self._spec) + if frame_spec is None: + return self._page.main_frame + return self._page._frame_from_spec(frame_spec) + def _evaluate_with_method( self, expression: str, @@ -21757,7 +22082,7 @@ def _evaluate_handle_with_method( self._page._default_timeout if timeout is None else timeout, ) ) - return JSHandle(self._page, payload) + return JSHandle(self._page, payload, owner_frame=self._owner_frame()) def evaluate_handle(self, expression: str, arg: Any = None, *, timeout: Optional[float] = None) -> JSHandle: return self._evaluate_handle_with_method(expression, arg, timeout=timeout, method="Locator.evaluate_handle") @@ -24687,6 +25012,29 @@ def dispatch_event(self, type: str, event_init: Optional[dict[str, Any]] = None) raise TargetClosedError(f"ElementHandle.dispatch_event: {_TARGET_CLOSED_MESSAGE}") self._locator.dispatch_event(type, event_init) + def _evaluate_with_timeout( + self, + expression: str, + arg: Any = None, + *, + timeout_ms: float, + method: str, + ) -> Any: + self._ensure_not_disposed("evaluate") + if self._handle is not None and not self._handle._disposed: + return self._handle._evaluate_with_method( + expression, + arg, + method=method, + timeout_ms=timeout_ms, + ) + return self._locator._evaluate_with_method( + expression, + arg, + timeout=timeout_ms, + method=method, + ) + def evaluate(self, expression: str, arg: Any = None) -> Any: self._ensure_not_disposed("evaluate") if self._handle is not None and not self._handle._disposed: @@ -25208,16 +25556,16 @@ def content_frame(self) -> Optional[Frame]: return frame def owner_frame(self) -> Frame: - frame_spec = _frame_scope_spec_from_element_spec(self._locator._spec) - if frame_spec is None: - return self._locator._page.main_frame - return self._locator._page._frame_from_spec(frame_spec) + return self._locator._owner_frame() - def dispose(self) -> None: + def _dispose_with_timeout(self, timeout_ms: Optional[float]) -> None: if self._handle is not None: - self._handle.dispose() + self._handle._dispose_with_timeout(timeout_ms) self._disposed = True + def dispose(self) -> None: + self._dispose_with_timeout(None) + _EVALUATE_HANDLE_MARKER = "__rustwright_handle_index__" @@ -25243,6 +25591,54 @@ def dispose_temporaries(self) -> None: pass +def _same_evaluate_frame(left: Any, right: Any) -> bool: + if left is right: + return True + if left is None or right is None: + return False + left_id = getattr(left, "_frame_id", None) + right_id = getattr(right, "_frame_id", None) + return bool(left_id and right_id and left_id == right_id) + + +def _ensure_evaluate_argument_context( + expected_owner: Any, + expected_frame: Optional[Any], + arg: Any, + *, + method: str, +) -> None: + def validate(value: Any) -> None: + handle: Optional[JSHandle] = None + actual_owner: Any = None + owner_frame: Optional[Any] = None + if isinstance(value, JSHandle): + handle = value + actual_owner = value._page + owner_frame = value._owner_frame + elif isinstance(value, ElementHandle): + handle = value._handle + actual_owner = handle._page if handle is not None else value._locator._page + owner_frame = value.owner_frame() + elif isinstance(value, (list, tuple)): + for item in value: + validate(item) + return + elif isinstance(value, dict): + for item in value.values(): + validate(item) + return + else: + return + + if actual_owner is not expected_owner: + raise Error(f"{method}: JSHandles can be evaluated only in the context they were created!") + if owner_frame is not None and not _same_evaluate_frame(owner_frame, expected_frame): + raise Error(f"{method}: JSHandles can be evaluated only in the context they were created!") + + validate(arg) + + def _prepare_evaluate_argument(page: "Page", arg: Any) -> _PreparedEvaluateArgument: handles: list[JSHandle] = [] temporaries: list[JSHandle] = [] @@ -26620,6 +27016,8 @@ def url(self) -> str: def evaluate(self, expression: str, arg: Any = None) -> Any: if self._core is None: raise Error("worker is not attached") + if arg is not None: + _ensure_evaluate_argument_context(self, None, arg, method="Worker.evaluate") prepared = _prepare_evaluate_argument(self, arg) if arg is not None else None if prepared is not None and prepared.has_handles: try: @@ -26641,6 +27039,8 @@ def evaluate(self, expression: str, arg: Any = None) -> Any: def evaluate_handle(self, expression: str, arg: Any = None) -> JSHandle: if self._core is None: raise Error("worker is not attached") + if arg is not None: + _ensure_evaluate_argument_context(self, None, arg, method="Worker.evaluate_handle") prepared = _prepare_evaluate_argument(self, arg) if arg is not None else None if prepared is not None and prepared.has_handles: try: From bb5246d5ed134c0ee41b94ea29598bbd3094a082 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:45:10 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'src/'=20wi?= =?UTF-8?q?th=20remote=20'src/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/209 --- src/lib.rs | 2473 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 2273 insertions(+), 200 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 77dcf80..d0af95c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3161,7 +3161,7 @@ mod tests { } impl InputCdpPeer { - async fn next_command(&mut self) -> Value { + async fn next_protocol_command(&mut self) -> Value { let outgoing = match self.write_rx.recv().await { Some(outgoing) => outgoing, None if self.allow_close => { @@ -3179,6 +3179,15 @@ mod tests { CdpOutgoing::Close => panic!("unexpected transport close"), }; let command = serde_json::from_str::(&payload).expect("valid CDP command"); + let evaluation_source = match command["method"].as_str() { + Some("Runtime.evaluate") => command["params"]["expression"].as_str().unwrap_or(""), + Some("Runtime.callFunctionOn") => command["params"]["functionDeclaration"] + .as_str() + .unwrap_or(""), + _ => "", + }; + let installing_serializer = + evaluation_source.contains("__rustwright_serializer_factory__"); let delete_raw_down = command["method"] == "Input.dispatchKeyEvent" && command["params"]["type"] == "rawKeyDown" && command["params"]["key"] == "Delete"; @@ -3205,8 +3214,18 @@ mod tests { json!({ "frameTree": { "frame": { "id": "test-frame" } } }) } Some("Page.createIsolatedWorld") => json!({ "executionContextId": 1 }), - Some("Runtime.evaluate") => { - let expression = command["params"]["expression"].as_str().unwrap_or(""); + Some("Runtime.evaluate") | Some("Runtime.callFunctionOn") + if installing_serializer => + { + json!({ + "result": { + "type": "object", + "objectId": "input-serializer", + } + }) + } + Some("Runtime.evaluate") | Some("Runtime.callFunctionOn") => { + let expression = evaluation_source; let value = if expression.contains("doc.activeElement !== fallback") { Value::Bool(!self.focus_target) } else if let Some(fill_value) = &self.fill_value { @@ -3290,7 +3309,9 @@ mod tests { } _ => json!({}), }; - let response_delay = if self.fill_guard_lost + let response_delay = if installing_serializer { + None + } else if self.fill_guard_lost && self.delay_resolution_after_guard_loss && command["method"] == "Page.getFrameTree" { @@ -3301,11 +3322,7 @@ mod tests { if let Some(delay) = response_delay { tokio::time::sleep(delay).await; } - let response = if command["method"] == "Runtime.evaluate" - && command["params"]["expression"] - .as_str() - .is_some_and(|expression| expression.contains("fallback.focus();")) - { + let response = if evaluation_source.contains("fallback.focus();") { self.focus_response_error_after_execution .take() .map_or_else( @@ -3315,10 +3332,8 @@ mod tests { } else { json!({ "id": command["id"], "result": result }) }; - if let Some(expression) = command["params"]["expression"] - .as_str() - .filter(|expression| expression.contains("commitment: 'commencing'")) - { + if evaluation_source.contains("commitment: 'commencing'") { + let expression = evaluation_source; let dispatch_id = action_dispatch_string_literal(expression, "dispatchId"); let token = action_dispatch_string_literal(expression, "token"); let payload = json!({ @@ -3350,6 +3365,26 @@ mod tests { ); command } + async fn next_command(&mut self) -> Value { + loop { + let mut command = self.next_protocol_command().await; + let source = command["params"]["expression"] + .as_str() + .or_else(|| command["params"]["functionDeclaration"].as_str()) + .unwrap_or(""); + if source.contains("__rustwright_serializer_factory__") { + continue; + } + if command["method"] == "Runtime.callFunctionOn" + && source.contains("__rustwright_evaluate_wrapper__") + { + let source = source.to_string(); + command["method"] = Value::String("Runtime.evaluate".to_string()); + command["params"]["expression"] = Value::String(source); + } + return command; + } + } async fn commands(&mut self, count: usize) -> Vec { let mut commands = Vec::with_capacity(count); @@ -3377,6 +3412,7 @@ mod tests { events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -4960,7 +4996,7 @@ multiline-compatible = """4.5.6""" matches!( &result, Err(RwError::Cdp { method, message }) - if method == "Runtime.evaluate" && message == FOCUS_ERROR + if method == "Runtime.callFunctionOn" && message == FOCUS_ERROR ), "{entry_path:?}: {result:?}" ); @@ -5173,6 +5209,7 @@ multiline-compatible = """4.5.6""" events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -5242,6 +5279,14 @@ multiline-compatible = """4.5.6""" } "Page.createIsolatedWorld" => json!({ "executionContextId": 1 }), "Runtime.evaluate" => { + json!({ + "result": { + "type": "object", + "objectId": "typing-serializer", + } + }) + } + "Runtime.callFunctionOn" => { json!({ "result": { "type": "boolean", "value": true } }) } "Input.dispatchKeyEvent" => json!({}), @@ -5312,6 +5357,7 @@ multiline-compatible = """4.5.6""" events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -5422,6 +5468,7 @@ multiline-compatible = """4.5.6""" events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -5820,6 +5867,7 @@ multiline-compatible = """4.5.6""" events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -5897,6 +5945,7 @@ multiline-compatible = """4.5.6""" events, event_log, traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -5997,6 +6046,7 @@ multiline-compatible = """4.5.6""" events, event_log: Arc::new(Mutex::new(CdpEventLog::new())), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -7016,6 +7066,20 @@ multiline-compatible = """4.5.6""" assert!(args.iter().any(|arg| arg == "--use-mock-keychain")); } + #[test] + fn chromium_keychain_defaults_honor_user_override_filtering() { + let ignored = vec![ + "--password-store".to_string(), + "--use-mock-keychain".to_string(), + ]; + + assert!(launch_default_arg_ignored( + "--password-store=basic", + &ignored + )); + assert!(launch_default_arg_ignored("--use-mock-keychain", &ignored)); + } + #[test] fn chromium_launch_failure_message_includes_stderr_tail() { let stderr = NamedTempFile::new().unwrap(); @@ -8667,6 +8731,7 @@ multiline-compatible = """4.5.6""" events, event_log: Arc::new(Mutex::new(CdpEventLog::new())), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -8734,6 +8799,7 @@ multiline-compatible = """4.5.6""" events, event_log: Arc::new(Mutex::new(CdpEventLog::new())), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -8819,6 +8885,7 @@ multiline-compatible = """4.5.6""" events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -8896,6 +8963,7 @@ multiline-compatible = """4.5.6""" events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -8980,6 +9048,7 @@ multiline-compatible = """4.5.6""" events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -9054,6 +9123,7 @@ multiline-compatible = """4.5.6""" events, event_log: Arc::new(Mutex::new(CdpEventLog::new())), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -9228,6 +9298,7 @@ multiline-compatible = """4.5.6""" events, event_log: Arc::new(Mutex::new(CdpEventLog::new())), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -9959,11 +10030,10 @@ multiline-compatible = """4.5.6""" .await .expect("timed out waiting for CDP test command") .expect("CDP test command"); - let command: Value = match outgoing { + match outgoing { CdpOutgoing::Text { payload, .. } => serde_json::from_str(&payload).unwrap(), CdpOutgoing::Close => panic!("unexpected transport close"), - }; - command + } } async fn next_command(&mut self, expected_method: &str) -> Value { @@ -9990,11 +10060,32 @@ multiline-compatible = """4.5.6""" ); } + fn reply_error_with_code(&self, command: &Value, code: i64, message: &str) { + dispatch_cdp_payload( + json!({ + "id": command["id"], + "error": { "code": code, "message": message }, + }), + Arc::clone(&self.pending), + self.events.clone(), + Arc::clone(&self.event_log), + ); + } + async fn reply_next(&mut self, expected_method: &str, result: Value) { let command = self.next_command(expected_method).await; self.reply(&command, result); } + fn observe_runtime_event(&self, event: &Value) { + self.page + .browser + .client + .runtime_state + .lock() + .unwrap() + .observe_event(event); + } async fn reply_action_dispatch_binding(&mut self, expected_session_id: &str) { let binding = self.next_command("Runtime.addBinding").await; assert_eq!(binding["sessionId"], expected_session_id); @@ -10004,8 +10095,8 @@ multiline-compatible = """4.5.6""" ); self.reply(&binding, json!({})); } - fn emit(&self, event: Value) { + self.observe_runtime_event(&event); dispatch_cdp_payload( event, Arc::clone(&self.pending), @@ -10032,6 +10123,7 @@ multiline-compatible = """4.5.6""" events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -11723,6 +11815,31 @@ multiline-compatible = """4.5.6""" ); } } + "Runtime.callFunctionOn" + if session_id == Some("click-child-session") => + { + let function = command["params"]["functionDeclaration"] + .as_str() + .expect("function declaration"); + if function.contains("__rustwright_serializer_factory__") { + harness.reply( + &command, + json!({ + "result": { + "type": "object", + "objectId": "frame-serializer", + } + }), + ); + } else if function.contains("receives_events") { + harness.reply( + &command, + json!({ "result": { "type": "object", "value": actionable } }), + ); + } else { + panic!("unexpected frame function: {function}"); + } + } "DOM.getBoxModel" => harness.reply( &command, json!({ "model": { "border": [10, 15, 14, 15, 14, 21, 10, 21] } }), @@ -13975,13 +14092,29 @@ multiline-compatible = """4.5.6""" futures_util::poll!(&mut operation), std::task::Poll::Pending )); - let snapshot = harness.next_command("Runtime.evaluate").await; - assert_eq!( - snapshot - .pointer("/params/expression") - .and_then(Value::as_str), - Some("document.body.textContent") + let serializer = harness.next_command("Runtime.evaluate").await; + assert!(serializer + .pointer("/params/expression") + .and_then(Value::as_str) + .is_some_and(|expression| expression.contains("__rustwright_serializer_factory__"))); + harness.reply( + &serializer, + json!({ + "result": { + "type": "object", + "objectId": "goto-snapshot-serializer", + } + }), ); + assert!(matches!( + futures_util::poll!(&mut operation), + std::task::Poll::Pending + )); + let snapshot = harness.next_command("Runtime.callFunctionOn").await; + assert!(snapshot + .pointer("/params/functionDeclaration") + .and_then(Value::as_str) + .is_some_and(|function| function.contains("document.body.textContent"))); harness.reply( &snapshot, json!({ "result": { "type": "string", "value": "Goto complete" } }), @@ -14145,13 +14278,29 @@ multiline-compatible = """4.5.6""" futures_util::poll!(&mut operation), std::task::Poll::Pending )); - let snapshot = harness.next_command("Runtime.evaluate").await; - assert_eq!( - snapshot - .pointer("/params/expression") - .and_then(Value::as_str), - Some("document.body.textContent") + let serializer = harness.next_command("Runtime.evaluate").await; + assert!(serializer + .pointer("/params/expression") + .and_then(Value::as_str) + .is_some_and(|expression| expression.contains("__rustwright_serializer_factory__"))); + harness.reply( + &serializer, + json!({ + "result": { + "type": "object", + "objectId": "reload-snapshot-serializer", + } + }), ); + assert!(matches!( + futures_util::poll!(&mut operation), + std::task::Poll::Pending + )); + let snapshot = harness.next_command("Runtime.callFunctionOn").await; + assert!(snapshot + .pointer("/params/functionDeclaration") + .and_then(Value::as_str) + .is_some_and(|function| function.contains("document.body.textContent"))); harness.reply( &snapshot, json!({ "result": { "type": "string", "value": "Status waiting" } }), @@ -14879,6 +15028,17 @@ multiline-compatible = """4.5.6""" harness .reply_next( "Runtime.evaluate", + json!({ + "result": { + "type": "object", + "objectId": "dialog-action-serializer", + } + }), + ) + .await; + harness + .reply_next( + "Runtime.callFunctionOn", json!({ "result": { "type": "object", @@ -15057,9 +15217,20 @@ multiline-compatible = """4.5.6""" Some("frame-main") ); harness.reply(&utility_world, json!({ "executionContextId": 1 })); + harness + .reply_next( + "Runtime.evaluate", + json!({ + "result": { + "type": "object", + "objectId": "public-action-serializer", + } + }), + ) + .await; if matches!(action, PublicSettledAction::Scroll) { - let dispatch = harness.next_command("Runtime.evaluate").await; + let dispatch = harness.next_command("Runtime.callFunctionOn").await; assert!( harness.events.receiver_count() >= 1, "scroll settlement must subscribe before its dispatch" @@ -15079,7 +15250,7 @@ multiline-compatible = """4.5.6""" harness .reply_next( - "Runtime.evaluate", + "Runtime.callFunctionOn", json!({ "result": { "type": "object", @@ -15135,7 +15306,7 @@ multiline-compatible = """4.5.6""" harness.reply(&utility_world, json!({ "executionContextId": 3 })); harness .reply_next( - "Runtime.evaluate", + "Runtime.callFunctionOn", json!({ "result": { "type": "string", @@ -15180,7 +15351,7 @@ multiline-compatible = """4.5.6""" harness.reply(&utility_world, json!({ "executionContextId": 4 })); harness .reply_next( - "Runtime.evaluate", + "Runtime.callFunctionOn", json!({ "result": { "type": "boolean", "value": true } }), ) .await; @@ -15213,6 +15384,597 @@ multiline-compatible = """4.5.6""" } } + #[tokio::test] + async fn evaluate_data_result_is_one_runtime_command_and_handle_path_is_unchanged() { + let mut harness = navigation_test_harness(4); + + let client = Arc::clone(&harness.page.browser.client); + let warmup = tokio::spawn(async move { + evaluate_expression_in_session_before( + &client, + "page-session", + None, + make_evaluate_expression("0", None), + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let install_command = harness.next_command("Runtime.evaluate").await; + assert_eq!(install_command["params"]["returnByValue"], false); + assert_eq!( + install_command["params"]["expression"], + format!("({})()", runtime_value_serializer_factory()) + ); + harness.reply( + &install_command, + json!({ + "result": { + "type": "function", + "objectId": "serializer-main", + }, + }), + ); + let warmup_command = harness.next_command("Runtime.callFunctionOn").await; + assert_eq!(warmup_command["params"]["objectId"], "serializer-main"); + assert_eq!( + warmup_command["params"]["arguments"], + json!([{ "objectId": "serializer-main" }]) + ); + harness.reply( + &warmup_command, + json!({ "result": { "type": "number", "value": 0 } }), + ); + assert_eq!(warmup.await.unwrap().unwrap(), "0"); + + let client = Arc::clone(&harness.page.browser.client); + let object_evaluate = tokio::spawn(async move { + evaluate_expression_in_session_before( + &client, + "page-session", + None, + "({ answer: 42 })".to_string(), + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let object_command = harness.next_command("Runtime.callFunctionOn").await; + assert_eq!(object_command["params"]["objectId"], "serializer-main"); + assert_eq!(object_command["params"]["returnByValue"], true); + assert_eq!( + object_command["params"]["arguments"], + json!([{ "objectId": "serializer-main" }]) + ); + assert_eq!( + object_command["params"]["functionDeclaration"], + serialize_evaluate_result_function("({ answer: 42 })") + ); + assert!(object_command["params"]["functionDeclaration"] + .as_str() + .unwrap() + .contains("__rustwright_evaluate_wrapper__")); + assert!(!object_command["params"]["functionDeclaration"] + .as_str() + .unwrap() + .contains(RUNTIME_VALUE_SERIALIZER)); + harness.reply( + &object_command, + json!({ + "result": { + "type": "object", + "value": { + "__rustwright_cdp_object__": 1, + "entries": { "answer": 42 }, + }, + }, + }), + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), object_evaluate) + .await + .expect("steady object evaluate should finish after one Runtime reply") + .unwrap() + .unwrap(), + json!({ + "__rustwright_cdp_object__": 1, + "entries": { "answer": 42 }, + }) + .to_string() + ); + assert!(matches!( + harness.write_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + + let client = Arc::clone(&harness.page.browser.client); + let primitive_evaluate = tokio::spawn(async move { + evaluate_expression_in_session_before( + &client, + "page-session", + None, + make_evaluate_expression("1", None), + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let primitive_command = harness.next_command("Runtime.callFunctionOn").await; + assert_eq!(primitive_command["params"]["objectId"], "serializer-main"); + assert_eq!( + primitive_command["params"]["arguments"], + json!([{ "objectId": "serializer-main" }]) + ); + harness.reply( + &primitive_command, + json!({ "result": { "type": "number", "value": 1 } }), + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), primitive_evaluate) + .await + .expect("primitive evaluate should keep its one-command path") + .unwrap() + .unwrap(), + "1" + ); + assert!(matches!( + harness.write_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + + let page = Arc::clone(&harness.page); + let call_function_evaluate = tokio::spawn(async move { + evaluate_locator_for_element_handle( + page, + "element-handle-1".to_string(), + Some("page-session".to_string()), + r##"{"kind":"css","selector":"#target"}"##.to_string(), + 0, + "return { answer: 42 };".to_string(), + Duration::from_secs(1), + true, + false, + Duration::ZERO, + ) + .await + }); + let call_function_command = harness.next_command("Runtime.callFunctionOn").await; + assert_eq!( + call_function_command["params"]["objectId"], + "element-handle-1" + ); + assert_eq!( + call_function_command["params"]["arguments"], + json!([{ "objectId": "serializer-main" }]) + ); + assert!(call_function_command["params"]["functionDeclaration"] + .as_str() + .unwrap() + .contains("__rustwright_evaluate_wrapper__")); + assert!(!call_function_command["params"]["functionDeclaration"] + .as_str() + .unwrap() + .contains(RUNTIME_VALUE_SERIALIZER)); + harness.reply( + &call_function_command, + json!({ + "result": { + "type": "object", + "value": { + "__rustwright_cdp_object__": 1, + "entries": { "answer": 42 }, + }, + }, + }), + ); + assert_eq!( + call_function_evaluate.await.unwrap().unwrap(), + json!({ + "__rustwright_cdp_object__": 1, + "entries": { "answer": 42 }, + }) + .to_string() + ); + + let client = Arc::clone(&harness.page.browser.client); + let handle_evaluate = tokio::spawn(async move { + evaluate_handle_expression_in_session( + &client, + "page-session", + "globalThis".to_string(), + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let handle_command = harness.next_command("Runtime.evaluate").await; + assert_eq!(handle_command["params"]["returnByValue"], false); + assert_eq!(handle_command["params"]["expression"], "globalThis"); + harness.reply( + &handle_command, + json!({ "result": { "type": "object", "objectId": "remote-handle-1" } }), + ); + assert_eq!( + handle_evaluate.await.unwrap().unwrap(), + json!({ "type": "object", "objectId": "remote-handle-1" }) + ); + assert!(matches!( + harness.write_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + } + + #[tokio::test] + async fn declaration_helper_user_wrapper_name_survives_inline_exception_conversion() { + let mut harness = navigation_test_harness(4); + let client = Arc::clone(&harness.page.browser.client); + let source = concat!( + "const marker = 1; function __rustwright_evaluate_wrapper__() {", + " throw new Error('user boom');", + " } __rustwright_evaluate_wrapper__();" + ); + let expression = make_evaluate_expression(source, None); + assert!(is_script_goal_evaluate_expression(&expression)); + let evaluate = tokio::spawn(async move { + evaluate_expression_in_session_before( + &client, + "page-session", + None, + expression, + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let command = harness.next_command("Runtime.evaluate").await; + let description = concat!( + "Error: user boom\n", + " at __rustwright_evaluate_wrapper__ (:2:9)\n", + " at caller (https://example.test/app.js:7:3)" + ); + harness.reply( + &command, + json!({ + "exceptionDetails": { + "scriptId": "user-script", + "exception": { "description": description }, + "stackTrace": { + "callFrames": [{ + "functionName": "__rustwright_evaluate_wrapper__", + "scriptId": "user-script", + "url": "", + "lineNumber": 1, + "columnNumber": 8, + }], + }, + }, + "result": { "type": "object", "subtype": "error" }, + }), + ); + + assert_eq!( + evaluate.await.unwrap().unwrap_err().to_string(), + description + ); + } + + #[tokio::test] + async fn serializer_cache_is_one_install_per_realm_across_new_handles() { + let mut harness = navigation_test_harness(4); + let client = Arc::clone(&harness.page.browser.client); + let realm = client.serializer_realm_key("page-session", Some("frame:main")); + + for index in 0..8 { + let client = Arc::clone(&client); + let realm = realm.clone(); + let object_id = format!("handle-{index}"); + let call = tokio::spawn(async move { + call_function_with_serialized_result_before( + &client, + &realm, + &object_id, + "function() { return this.value; }", + None, + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + if index == 0 { + let install = harness.next_command("Runtime.callFunctionOn").await; + assert_eq!(install["params"]["objectId"], "handle-0"); + assert!(install["params"]["functionDeclaration"] + .as_str() + .unwrap() + .contains("__rustwright_serializer_factory__")); + harness.reply( + &install, + json!({ + "result": { + "type": "function", + "objectId": "realm-serializer", + }, + }), + ); + } + let command = harness.next_command("Runtime.callFunctionOn").await; + assert_eq!(command["params"]["objectId"], format!("handle-{index}")); + assert_eq!( + command["params"]["arguments"], + json!([{ "objectId": "realm-serializer" }]) + ); + harness.reply( + &command, + json!({ "result": { "type": "number", "value": index } }), + ); + assert_eq!( + call.await.unwrap().unwrap()["result"]["value"], + json!(index) + ); + } + + assert_eq!(client.runtime_state.lock().unwrap().serializers.len(), 1); + assert_eq!( + client + .runtime_state + .lock() + .unwrap() + .serializer_install_locks + .len(), + 1 + ); + assert!(matches!( + harness.write_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + } + + #[tokio::test] + async fn detached_realm_cannot_commit_in_flight_serializer_install() { + let mut harness = navigation_test_harness(4); + let client = Arc::clone(&harness.page.browser.client); + + for index in 0..8 { + let realm = + client.serializer_realm_key("page-session", Some(&format!("frame:child-{index}"))); + let install_client = Arc::clone(&client); + let install_realm = realm.clone(); + let install = tokio::spawn(async move { + serializer_for_realm( + &install_client, + &install_realm, + None, + None, + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let install_command = harness.next_command("Runtime.evaluate").await; + harness.reply( + &install_command, + json!({ + "result": { + "type": "function", + "objectId": format!("invalidated-serializer-{index}"), + }, + }), + ); + // The current-thread test runtime does not resume the install task until + // this test yields, so the detach deterministically wins the commit. + harness.observe_runtime_event(&json!({ + "method": "Target.detachedFromTarget", + "params": { "sessionId": "page-session" }, + })); + { + let state = client.runtime_state.lock().unwrap(); + assert!(!state.serializer_generations.contains_key(&realm)); + assert!(state.serializer_install_locks.is_empty()); + } + + assert_eq!( + install.await.unwrap().unwrap_err().to_string(), + "Execution context was destroyed, most likely because of a navigation." + ); + let state = client.runtime_state.lock().unwrap(); + assert!(state.serializers.is_empty()); + assert!( + state.serializer_install_locks.is_empty(), + "cycle {index} retained an install lock" + ); + assert!( + state.serializer_generations.is_empty(), + "cycle {index} retained a serializer generation" + ); + } + } + + #[tokio::test] + async fn failed_serializer_install_removes_exact_install_lock() { + let mut harness = navigation_test_harness(4); + let client = Arc::clone(&harness.page.browser.client); + let realm = client.serializer_realm_key("page-session", Some("frame:main")); + let install_client = Arc::clone(&client); + let install_realm = realm.clone(); + let install = tokio::spawn(async move { + serializer_for_realm( + &install_client, + &install_realm, + None, + None, + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let install_command = harness.next_command("Runtime.evaluate").await; + harness.reply_error_with_code(&install_command, -32_000, "serializer install failed"); + + assert!(install.await.unwrap().is_err()); + let state = client.runtime_state.lock().unwrap(); + assert!(state.serializer_install_locks.is_empty()); + assert!(state.serializer_generations.is_empty()); + } + + #[tokio::test] + async fn serializer_navigation_eviction_releases_live_object() { + let mut harness = navigation_test_harness(4); + let client = Arc::clone(&harness.page.browser.client); + let (release_tx, release_rx) = mpsc::unbounded_channel(); + client.runtime_state.lock().unwrap().release_tx = Some(release_tx); + spawn_serializer_release_pump(release_rx, client.write_tx.clone()); + let realm = client.serializer_realm_key("page-session", Some("frame:main")); + let (generation, install_lock) = client.serializer_install_lock(&realm); + { + let _install_guard = install_lock.lock().await; + assert!(client.remember_serializer( + &realm, + generation, + &install_lock, + "live-serializer".to_string(), + Some("7".to_string()), + )); + } + + harness.observe_runtime_event(&json!({ + "method": "Page.frameNavigated", + "sessionId": "page-session", + "params": { "frame": { "id": "main", "loaderId": "loader-a" } } + })); + harness.observe_runtime_event(&json!({ + "method": "Page.frameNavigated", + "sessionId": "page-session", + "params": { "frame": { "id": "main", "loaderId": "loader-b" } } + })); + + let release = harness.next_command("Runtime.releaseObject").await; + assert_eq!(release["sessionId"], "page-session"); + assert_eq!(release["params"]["objectId"], "live-serializer"); + assert!(client.serializer_handle(&realm).is_none()); + assert!(client + .runtime_state + .lock() + .unwrap() + .serializer_install_locks + .is_empty()); + } + + #[tokio::test] + async fn execution_context_destroyed_never_retries_user_code() { + let mut harness = navigation_test_harness(4); + let client = Arc::clone(&harness.page.browser.client); + let evaluate_client = Arc::clone(&client); + let evaluate = tokio::spawn(async move { + evaluate_expression_in_session_before( + &evaluate_client, + "page-session", + Some("frame:main"), + make_evaluate_expression("globalThis.beaconCount += 1", None), + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let install = harness.next_command("Runtime.evaluate").await; + harness.reply( + &install, + json!({ + "result": { + "type": "function", + "objectId": "serializer-before-navigation", + }, + }), + ); + let user_command = harness.next_command("Runtime.callFunctionOn").await; + harness.reply_error_with_code(&user_command, -32_000, "Execution context was destroyed."); + + assert_eq!( + evaluate.await.unwrap().unwrap_err().to_string(), + "Execution context was destroyed, most likely because of a navigation." + ); + assert!(matches!( + harness.write_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + assert!(client + .serializer_handle(&client.serializer_realm_key("page-session", Some("frame:main"),)) + .is_none()); + } + + #[tokio::test] + async fn evaluate_serializer_cache_reinstalls_once_after_navigation_recreates_context() { + let mut harness = navigation_test_harness(4); + let client = Arc::clone(&harness.page.browser.client); + let initial = tokio::spawn(async move { + evaluate_expression_in_session_before( + &client, + "page-session", + None, + make_evaluate_expression("1", None), + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let install_command = harness.next_command("Runtime.evaluate").await; + harness.reply( + &install_command, + json!({ + "result": { + "type": "function", + "objectId": "serializer-before-navigation", + }, + }), + ); + let initial_call = harness.next_command("Runtime.callFunctionOn").await; + assert_eq!( + initial_call["params"]["objectId"], + "serializer-before-navigation" + ); + harness.reply( + &initial_call, + json!({ "result": { "type": "number", "value": 1 } }), + ); + assert_eq!(initial.await.unwrap().unwrap(), "1"); + + let client = Arc::clone(&harness.page.browser.client); + let after_navigation = tokio::spawn(async move { + evaluate_expression_in_session_before( + &client, + "page-session", + None, + make_evaluate_expression("2", None), + OperationDeadline::new(Duration::from_secs(1)), + ) + .await + }); + let stale_cache_command = harness.next_command("Runtime.callFunctionOn").await; + assert_eq!( + stale_cache_command["params"]["objectId"], + "serializer-before-navigation" + ); + harness.reply_error_with_code( + &stale_cache_command, + -32_000, + "Could not find object with given id", + ); + let reinstall_command = harness.next_command("Runtime.evaluate").await; + assert_eq!(reinstall_command["params"]["returnByValue"], false); + harness.reply( + &reinstall_command, + json!({ + "result": { + "type": "function", + "objectId": "serializer-after-navigation", + }, + }), + ); + let retry_command = harness.next_command("Runtime.callFunctionOn").await; + assert_eq!( + retry_command["params"]["objectId"], + "serializer-after-navigation" + ); + harness.reply( + &retry_command, + json!({ "result": { "type": "number", "value": 2 } }), + ); + assert_eq!(after_navigation.await.unwrap().unwrap(), "2"); + assert!(matches!( + harness.write_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + } + #[tokio::test] async fn navigation_wait_completes_on_expected_same_document_url() { let harness = navigation_test_harness(4); @@ -17008,6 +17770,378 @@ impl CdpTrafficLog { } } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct SerializerRealmKey { + session_id: String, + realm_identity: String, +} + +impl SerializerRealmKey { + fn new(session_id: &str, realm_identity: impl Into) -> Self { + Self { + session_id: session_id.to_string(), + realm_identity: realm_identity.into(), + } + } + + fn in_world(mut self, world_name: &str) -> Self { + self.realm_identity = format!("{}|world:{world_name}", self.realm_identity); + self + } + + fn frame_id(&self) -> Option<&str> { + self.realm_identity + .strip_prefix("frame:") + .and_then(|identity| identity.split("|world:").next()) + } +} + +#[cfg(test)] +mod serializer_realm_key_tests { + use super::*; + + #[test] + fn utility_world_key_does_not_alias_the_default_frame_realm() { + let default_realm = SerializerRealmKey::new("page-session", "frame:main"); + let utility_realm = default_realm.clone().in_world(FRAME_UTILITY_WORLD_NAME); + + assert_ne!(utility_realm, default_realm); + assert_eq!(default_realm.frame_id(), Some("main")); + assert_eq!(utility_realm.frame_id(), Some("main")); + } +} + +#[derive(Clone, Debug)] +struct SerializerCacheEntry { + object_id: String, + execution_context_id: Option, +} + +#[derive(Clone, Debug)] +struct RuntimeExecutionRealm { + realm_identity: String, + frame_id: Option, +} + +#[derive(Clone, Debug)] +struct SerializerRelease { + session_id: String, + object_id: String, +} + +struct CdpRuntimeState { + serializers: HashMap, + serializer_install_locks: HashMap>>, + serializer_generations: HashMap, + next_serializer_generation: u64, + execution_realms: HashMap<(String, String), RuntimeExecutionRealm>, + session_realms: HashMap, + frame_loaders: HashMap<(String, String), String>, + release_tx: Option>, + #[cfg(any(test, feature = "test-support"))] + serializer_release_count: usize, +} + +impl CdpRuntimeState { + fn new(release_tx: Option>) -> Self { + Self { + serializers: HashMap::new(), + serializer_install_locks: HashMap::new(), + serializer_generations: HashMap::new(), + next_serializer_generation: 0, + execution_realms: HashMap::new(), + session_realms: HashMap::new(), + frame_loaders: HashMap::new(), + #[cfg(any(test, feature = "test-support"))] + serializer_release_count: 0, + release_tx, + } + } + + fn default_realm_identity(&self, session_id: &str) -> Option { + self.session_realms.get(session_id).cloned() + } + + fn execution_context_for_realm(&self, realm: &SerializerRealmKey) -> Option { + self.execution_realms + .iter() + .find_map(|((session_id, context_id), execution_realm)| { + (session_id == &realm.session_id + && execution_realm.realm_identity == realm.realm_identity) + .then(|| context_id.clone()) + }) + } + + fn frame_id_for_execution_context( + &self, + session_id: &str, + execution_context_id: &str, + ) -> Option { + self.execution_realms + .get(&(session_id.to_string(), execution_context_id.to_string())) + .and_then(|realm| realm.frame_id.clone()) + } + + fn next_serializer_generation(&mut self) -> u64 { + self.next_serializer_generation = self + .next_serializer_generation + .checked_add(1) + .expect("serializer generation counter exhausted"); + self.next_serializer_generation + } + + fn ensure_serializer_generation(&mut self, realm: &SerializerRealmKey) -> u64 { + if let Some(generation) = self.serializer_generations.get(realm) { + return *generation; + } + let generation = self.next_serializer_generation(); + self.serializer_generations + .insert(realm.clone(), generation); + generation + } + + fn enqueue_serializer_release(&mut self, release: SerializerRelease) { + let release_enqueued = self + .release_tx + .as_ref() + .is_some_and(|release_tx| release_tx.send(release).is_ok()); + #[cfg(any(test, feature = "test-support"))] + { + self.serializer_release_count += usize::from(release_enqueued); + } + #[cfg(not(any(test, feature = "test-support")))] + let _ = release_enqueued; + } + + fn evict_serializer_realms( + &mut self, + realms: impl IntoIterator, + release_live: bool, + ) { + for realm in realms.into_iter().collect::>() { + let had_generation = self.serializer_generations.remove(&realm).is_some(); + let had_lock = self.serializer_install_locks.remove(&realm).is_some(); + let entry = self.serializers.remove(&realm); + if !had_generation && !had_lock && entry.is_none() { + continue; + } + if release_live { + if let Some(entry) = entry { + self.enqueue_serializer_release(SerializerRelease { + session_id: realm.session_id, + object_id: entry.object_id, + }); + } + } + } + } + + fn evict_matching_serializer_realms( + &mut self, + mut should_evict: impl FnMut(&SerializerRealmKey) -> bool, + release_live: bool, + ) { + let evicted = self + .serializers + .keys() + .chain(self.serializer_install_locks.keys()) + .chain(self.serializer_generations.keys()) + .filter(|realm| should_evict(realm)) + .cloned() + .collect::>(); + self.evict_serializer_realms(evicted, release_live); + } + + fn observe_event(&mut self, event: &Value) { + let method = event.get("method").and_then(Value::as_str).unwrap_or(""); + if method == "Target.attachedToTarget" { + let child_session_id = event.pointer("/params/sessionId").and_then(Value::as_str); + let target_info = event.pointer("/params/targetInfo"); + if let (Some(child_session_id), Some(target_info)) = (child_session_id, target_info) { + let target_id = target_info.get("targetId").and_then(Value::as_str); + let target_type = target_info.get("type").and_then(Value::as_str); + if let (Some(target_id), Some(target_type)) = (target_id, target_type) { + let realm_identity = match target_type { + "page" | "iframe" => Some(format!("frame:{target_id}")), + "worker" | "service_worker" | "shared_worker" => { + Some(format!("worker:{target_id}")) + } + _ => None, + }; + if let Some(realm_identity) = realm_identity { + self.session_realms + .insert(child_session_id.to_string(), realm_identity); + } + } + } + return; + } + + if method == "Target.detachedFromTarget" { + if let Some(detached_session_id) = + event.pointer("/params/sessionId").and_then(Value::as_str) + { + self.evict_session(detached_session_id, false); + } + return; + } + + let Some(session_id) = event.get("sessionId").and_then(Value::as_str) else { + return; + }; + let params = event.get("params").unwrap_or(&Value::Null); + match method { + "Runtime.executionContextCreated" => { + let context = params.get("context").unwrap_or(&Value::Null); + let Some(context_id) = context.get("id") else { + return; + }; + let context_id = context_id.to_string(); + let frame_id = context + .pointer("/auxData/frameId") + .and_then(Value::as_str) + .map(ToString::to_string); + let realm_identity = frame_id + .as_deref() + .map(|frame_id| format!("frame:{frame_id}")) + .or_else(|| self.session_realms.get(session_id).cloned()) + .unwrap_or_else(|| format!("session:{session_id}")); + self.execution_realms.insert( + (session_id.to_string(), context_id), + RuntimeExecutionRealm { + realm_identity, + frame_id, + }, + ); + } + "Runtime.executionContextDestroyed" => { + let context_id = params + .get("executionContextId") + .map(Value::to_string) + .or_else(|| { + params + .get("executionContextUniqueId") + .and_then(Value::as_str) + .map(ToString::to_string) + }); + if let Some(context_id) = context_id { + let execution_realm = self + .execution_realms + .remove(&(session_id.to_string(), context_id.clone())); + let mut evicted = self + .serializers + .iter() + .filter(|(realm, entry)| { + realm.session_id == session_id + && entry.execution_context_id.as_deref() + == Some(context_id.as_str()) + }) + .map(|(realm, _)| realm.clone()) + .collect::>(); + if let Some(execution_realm) = execution_realm { + let realm = + SerializerRealmKey::new(session_id, execution_realm.realm_identity); + if self.serializer_generations.contains_key(&realm) + || self.serializer_install_locks.contains_key(&realm) + || self.serializers.contains_key(&realm) + { + evicted.push(realm); + } + } + self.evict_serializer_realms(evicted, false); + } + } + "Runtime.executionContextsCleared" => self.evict_session(session_id, false), + "Page.frameNavigated" => { + let frame = params.get("frame").unwrap_or(&Value::Null); + let frame_id = frame.get("id").and_then(Value::as_str); + let loader_id = frame.get("loaderId").and_then(Value::as_str); + if let (Some(frame_id), Some(loader_id)) = (frame_id, loader_id) { + let loader_key = (session_id.to_string(), frame_id.to_string()); + let loader_changed = self + .frame_loaders + .insert(loader_key, loader_id.to_string()) + .is_some_and(|previous| previous != loader_id); + if loader_changed { + self.evict_frame(session_id, frame_id, true); + } + } + } + "Page.frameDetached" => { + let reason = params.get("reason").and_then(Value::as_str); + let release_live = match reason { + Some("remove") => Some(false), + Some("swap") => Some(true), + _ => None, + }; + if let (Some(frame_id), Some(release_live)) = + (params.get("frameId").and_then(Value::as_str), release_live) + { + self.evict_frame(session_id, frame_id, release_live); + if reason == Some("remove") { + self.frame_loaders + .remove(&(session_id.to_string(), frame_id.to_string())); + } + } + } + "Page.frameSwapped" | "Page.frameSwappedByActivation" => { + if let Some(frame_id) = params.get("frameId").and_then(Value::as_str) { + self.evict_frame(session_id, frame_id, true); + } + } + _ => {} + } + } + + fn evict_frame(&mut self, session_id: &str, frame_id: &str, release_live: bool) { + self.evict_matching_serializer_realms( + |realm| realm.session_id == session_id && realm.frame_id() == Some(frame_id), + release_live, + ); + self.execution_realms + .retain(|(realm_session_id, _), realm| { + realm_session_id != session_id || realm.frame_id.as_deref() != Some(frame_id) + }); + } + + fn evict_session(&mut self, session_id: &str, release_live: bool) { + self.evict_matching_serializer_realms(|realm| realm.session_id == session_id, release_live); + self.execution_realms + .retain(|(realm_session_id, _), _| realm_session_id != session_id); + self.session_realms.remove(session_id); + self.frame_loaders + .retain(|(loader_session_id, _), _| loader_session_id != session_id); + } +} + +fn spawn_serializer_release_pump( + mut release_rx: mpsc::UnboundedReceiver, + write_tx: mpsc::UnboundedSender, +) { + static NEXT_RELEASE_ID: AtomicU64 = AtomicU64::new(8_000_000_000_000_000); + tokio::spawn(async move { + while let Some(release) = release_rx.recv().await { + let id = NEXT_RELEASE_ID.fetch_add(1, Ordering::SeqCst); + let payload = json!({ + "id": id, + "method": "Runtime.releaseObject", + "params": { "objectId": release.object_id }, + "sessionId": release.session_id, + }); + if write_tx + .send(CdpOutgoing::Text { + payload: payload.to_string(), + tracker: None, + diagnostic_id: None, + }) + .is_err() + { + break; + } + } + }); +} + struct CdpClientDiagnosticSnapshot { captured_at: Instant, traffic: Vec, @@ -17127,6 +18261,7 @@ struct CdpClient { events: broadcast::Sender, event_log: Arc>, traffic_log: Arc>, + runtime_state: Arc>, next_id: AtomicU64, sent_runtime_enable_count: AtomicU64, sent_target_close_count: AtomicU64, @@ -17374,6 +18509,7 @@ fn dispatch_cdp_payload_with_diagnostics( events: broadcast::Sender, event_log: Arc>, traffic_log: Arc>, + runtime_state: Arc>, ) { if let Some(id) = payload.get("id").and_then(Value::as_u64) { let sender = pending.lock().unwrap().remove(&id); @@ -17402,6 +18538,7 @@ fn dispatch_cdp_payload_with_diagnostics( let _ = sender.send(result); } } else { + runtime_state.lock().unwrap().observe_event(&payload); traffic_log .lock() .unwrap() @@ -17426,6 +18563,7 @@ fn dispatch_cdp_payload( events, event_log, Arc::new(Mutex::new(CdpTrafficLog::new())), + Arc::new(Mutex::new(CdpRuntimeState::new(None))), ); } @@ -17486,6 +18624,140 @@ fn ensure_ws_request_path( } impl CdpClient { + fn serializer_realm_key( + &self, + session_id: &str, + realm_identity: Option<&str>, + ) -> SerializerRealmKey { + let realm_identity = realm_identity + .map(ToString::to_string) + .or_else(|| { + self.runtime_state + .lock() + .unwrap() + .default_realm_identity(session_id) + }) + .unwrap_or_else(|| format!("session:{session_id}")); + SerializerRealmKey::new(session_id, realm_identity) + } + + fn serializer_handle(&self, realm: &SerializerRealmKey) -> Option { + self.runtime_state + .lock() + .unwrap() + .serializers + .get(realm) + .map(|entry| entry.object_id.clone()) + } + + fn serializer_install_lock( + &self, + realm: &SerializerRealmKey, + ) -> (u64, Arc>) { + let mut runtime_state = self.runtime_state.lock().unwrap(); + let generation = runtime_state.ensure_serializer_generation(realm); + let install_lock = runtime_state + .serializer_install_locks + .entry(realm.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone(); + (generation, install_lock) + } + + fn serializer_install_is_current( + &self, + realm: &SerializerRealmKey, + generation: u64, + install_lock: &Arc>, + ) -> bool { + let runtime_state = self.runtime_state.lock().unwrap(); + runtime_state.serializer_generations.get(realm) == Some(&generation) + && runtime_state + .serializer_install_locks + .get(realm) + .is_some_and(|current| Arc::ptr_eq(current, install_lock)) + } + + fn remember_serializer( + &self, + realm: &SerializerRealmKey, + generation: u64, + install_lock: &Arc>, + object_id: String, + execution_context_id: Option, + ) -> bool { + let mut runtime_state = self.runtime_state.lock().unwrap(); + let generation_is_current = + runtime_state.serializer_generations.get(realm) == Some(&generation); + let lock_is_current = runtime_state + .serializer_install_locks + .get(realm) + .is_some_and(|current| Arc::ptr_eq(current, install_lock)); + if !generation_is_current || !lock_is_current { + return false; + } + let execution_context_id = + execution_context_id.or_else(|| runtime_state.execution_context_for_realm(realm)); + runtime_state.serializers.insert( + realm.clone(), + SerializerCacheEntry { + object_id, + execution_context_id, + }, + ); + true + } + + fn cleanup_serializer_install( + &self, + realm: &SerializerRealmKey, + generation: u64, + install_lock: &Arc>, + ) { + let mut runtime_state = self.runtime_state.lock().unwrap(); + let exact_lock = runtime_state + .serializer_install_locks + .get(realm) + .is_some_and(|current| Arc::ptr_eq(current, install_lock)); + if exact_lock { + runtime_state.serializer_install_locks.remove(realm); + } + if exact_lock + && runtime_state.serializer_generations.get(realm) == Some(&generation) + && !runtime_state.serializers.contains_key(realm) + { + runtime_state.serializer_generations.remove(realm); + } + } + + fn release_uncommitted_serializer(&self, realm: &SerializerRealmKey, object_id: String) { + self.runtime_state + .lock() + .unwrap() + .enqueue_serializer_release(SerializerRelease { + session_id: realm.session_id.clone(), + object_id, + }); + } + + fn forget_serializer(&self, realm: &SerializerRealmKey) { + self.runtime_state + .lock() + .unwrap() + .evict_serializer_realms([realm.clone()], false); + } + + fn frame_id_for_execution_context( + &self, + session_id: &str, + execution_context_id: &str, + ) -> Option { + self.runtime_state + .lock() + .unwrap() + .frame_id_for_execution_context(session_id, execution_context_id) + } + async fn connect(ws_endpoint: &str) -> RwResult> { Self::connect_with_headers(ws_endpoint, &[]).await } @@ -17543,6 +18815,12 @@ impl CdpClient { let traffic_log = Arc::new(Mutex::new(CdpTrafficLog::new())); let traffic_log_writer = Arc::clone(&traffic_log); let traffic_log_reader = Arc::clone(&traffic_log); + let (serializer_release_tx, serializer_release_rx) = mpsc::unbounded_channel(); + let runtime_state = Arc::new(Mutex::new(CdpRuntimeState::new(Some( + serializer_release_tx, + )))); + let runtime_state_reader = Arc::clone(&runtime_state); + spawn_serializer_release_pump(serializer_release_rx, write_tx.clone()); let alive = Arc::new(AtomicBool::new(true)); let alive_writer = Arc::clone(&alive); let alive_reader = Arc::clone(&alive); @@ -17616,6 +18894,7 @@ impl CdpClient { events_reader.clone(), Arc::clone(&event_log_reader), Arc::clone(&traffic_log_reader), + Arc::clone(&runtime_state_reader), ); } alive_reader.store(false, Ordering::SeqCst); @@ -17630,6 +18909,7 @@ impl CdpClient { events, event_log, traffic_log, + runtime_state, next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -17658,6 +18938,12 @@ impl CdpClient { let traffic_log = Arc::new(Mutex::new(CdpTrafficLog::new())); let traffic_log_writer = Arc::clone(&traffic_log); let traffic_log_dispatcher = Arc::clone(&traffic_log); + let (serializer_release_tx, serializer_release_rx) = mpsc::unbounded_channel(); + let runtime_state = Arc::new(Mutex::new(CdpRuntimeState::new(Some( + serializer_release_tx, + )))); + let runtime_state_dispatcher = Arc::clone(&runtime_state); + spawn_serializer_release_pump(serializer_release_rx, write_tx.clone()); let alive = Arc::new(AtomicBool::new(true)); let alive_writer = Arc::clone(&alive); let alive_dispatcher = Arc::clone(&alive); @@ -17745,6 +19031,7 @@ impl CdpClient { events_dispatcher.clone(), Arc::clone(&event_log_dispatcher), Arc::clone(&traffic_log_dispatcher), + Arc::clone(&runtime_state_dispatcher), ); } alive_dispatcher.store(false, Ordering::SeqCst); @@ -17763,6 +19050,7 @@ impl CdpClient { events, event_log, traffic_log, + runtime_state, next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -21162,8 +22450,8 @@ struct PyDownloadEventWaiter { #[pyclass(name = "_FileChooserEventWaiter")] struct PyFileChooserEventWaiter { browser: Arc, + page: Arc, receiver: Mutex>>, - session_id: String, } #[cfg(feature = "python")] @@ -21807,16 +23095,24 @@ fn evaluate_locator_wait_probe_for_page( timeout_ms: Option, ) -> RwResult { let timeout = BrowserInner::command_timeout(timeout_ms); + let realm_identity = page + .main_frame_id + .lock() + .unwrap() + .as_deref() + .map(|frame_id| format!("frame:{frame_id}")); let browser = Arc::clone(&page.browser); browser.block_on(async move { let attempt_page = Arc::clone(&page); run_locator_wait_retry(page, timeout, move |deadline| { let page = Arc::clone(&attempt_page); let expression = expression.clone(); + let realm_identity = realm_identity.clone(); async move { evaluate_expression_in_session_before( &page.browser.client, &page.session_id, + realm_identity.as_deref(), expression, deadline, ) @@ -21832,8 +23128,20 @@ async fn evaluate_expression_for_page_async( expression: String, timeout: Duration, ) -> RwResult { - evaluate_expression_in_session(&page.browser.client, &page.session_id, expression, timeout) - .await + let realm_identity = page + .main_frame_id + .lock() + .unwrap() + .as_deref() + .map(|frame_id| format!("frame:{frame_id}")); + evaluate_expression_in_session( + &page.browser.client, + &page.session_id, + realm_identity.as_deref(), + expression, + timeout, + ) + .await } #[derive(Clone, Copy)] @@ -22350,6 +23658,7 @@ async fn evaluate_resolved_locator_body( evaluate_expression_in_session_before( &page.browser.client, &resolved.session_id, + None, expression, deadline, ) @@ -23041,6 +24350,359 @@ async fn settle_after_pointer_action( } } +fn invalid_serializer_handle_error(error: &RwError) -> bool { + // CDP resolves the Runtime.callFunctionOn receiver and every object/context + // argument before it dispatches the function. These two protocol rejections + // therefore prove that user JavaScript did not start. They are the complete + // retry allow-list: navigation-time context destruction is not proof of + // pre-dispatch failure and must never resend user code. + matches!( + error, + RwError::Cdp { method, message } + if method == "Runtime.callFunctionOn" + && matches!( + message.as_str(), + "Could not find object with given id" + | "Cannot find context with specified id" + ) + ) +} + +fn execution_context_destroyed_error(error: &RwError) -> bool { + matches!( + error, + RwError::Cdp { method, message } + if method == "Runtime.callFunctionOn" + && matches!( + message.as_str(), + "Execution context was destroyed." | "Inspected target navigated or closed" + ) + ) +} + +fn upstream_context_destroyed_error() -> RwError { + RwError::Message( + "Execution context was destroyed, most likely because of a navigation.".to_string(), + ) +} + +async fn install_runtime_value_serializer( + client: &CdpClient, + session_id: &str, + context_id: Option<&Value>, + receiver_object_id: Option<&str>, + timeout: Duration, +) -> RwResult { + let result = if let Some(object_id) = receiver_object_id { + client + .send( + "Runtime.callFunctionOn", + json!({ + "objectId": object_id, + "functionDeclaration": runtime_value_serializer_factory(), + "awaitPromise": false, + "returnByValue": false, + }), + Some(session_id), + timeout, + ) + .await? + } else { + let mut params = json!({ + "expression": format!("({})()", runtime_value_serializer_factory()), + "awaitPromise": false, + "returnByValue": false, + }); + if let Some(context_id) = context_id { + params["contextId"] = context_id.clone(); + } + client + .send("Runtime.evaluate", params, Some(session_id), timeout) + .await? + }; + if let Some(exception) = result.get("exceptionDetails") { + return Err(RwError::Message(runtime_exception_message(exception))); + } + result + .pointer("/result/objectId") + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| { + RwError::Message("CDP did not return a serializer object handle".to_string()) + }) +} + +async fn serializer_for_realm( + client: &CdpClient, + realm: &SerializerRealmKey, + context_id: Option<&Value>, + receiver_object_id: Option<&str>, + deadline: OperationDeadline, +) -> RwResult { + let (generation, install_lock) = client.serializer_install_lock(realm); + let _install_guard = install_lock.lock().await; + if !client.serializer_install_is_current(realm, generation, &install_lock) { + client.cleanup_serializer_install(realm, generation, &install_lock); + return Err(upstream_context_destroyed_error()); + } + if let Some(object_id) = client.serializer_handle(realm) { + return Ok(object_id); + } + let timeout = match deadline.remaining() { + Ok(timeout) => timeout, + Err(error) => { + client.cleanup_serializer_install(realm, generation, &install_lock); + return Err(error); + } + }; + let object_id = match install_runtime_value_serializer( + client, + &realm.session_id, + context_id, + receiver_object_id, + timeout, + ) + .await + { + Ok(object_id) => object_id, + Err(error) => { + client.cleanup_serializer_install(realm, generation, &install_lock); + return Err(error); + } + }; + if !client.remember_serializer( + realm, + generation, + &install_lock, + object_id.clone(), + context_id.map(Value::to_string), + ) { + client.cleanup_serializer_install(realm, generation, &install_lock); + client.release_uncommitted_serializer(realm, object_id); + return Err(upstream_context_destroyed_error()); + } + Ok(object_id) +} + +async fn send_inline_serialized_evaluate( + client: &CdpClient, + session_id: &str, + context_id: Option<&Value>, + expression: &str, + timeout: Duration, +) -> RwResult { + let mut params = json!({ + "expression": serialize_evaluate_result_expression(expression), + "awaitPromise": true, + "returnByValue": true, + "userGesture": true, + }); + if let Some(context_id) = context_id { + params["contextId"] = context_id.clone(); + } + client + .send("Runtime.evaluate", params, Some(session_id), timeout) + .await +} + +async fn send_serialized_evaluate( + client: &CdpClient, + session_id: &str, + serializer_object_id: &str, + function_declaration: &str, + timeout: Duration, +) -> RwResult { + client + .send( + "Runtime.callFunctionOn", + json!({ + "objectId": serializer_object_id, + "functionDeclaration": function_declaration, + "arguments": [{ "objectId": serializer_object_id }], + "awaitPromise": true, + "returnByValue": true, + "userGesture": true, + }), + Some(session_id), + timeout, + ) + .await +} + +async fn evaluate_serialized_expression_before( + client: &CdpClient, + realm: &SerializerRealmKey, + context_id: Option<&Value>, + expression: &str, + deadline: OperationDeadline, +) -> RwResult { + if is_script_goal_evaluate_expression(expression) { + let result = send_inline_serialized_evaluate( + client, + &realm.session_id, + context_id, + expression, + deadline.remaining()?, + ) + .await?; + return runtime_inline_evaluate_serialized_result_to_json(&result); + } + + let function_declaration = serialize_evaluate_result_function(expression); + let expected_wrapper_line = function_declaration.lines().count(); + let serializer = serializer_for_realm(client, realm, context_id, None, deadline).await?; + let result = match send_serialized_evaluate( + client, + &realm.session_id, + &serializer, + &function_declaration, + deadline.remaining()?, + ) + .await + { + Err(error) if invalid_serializer_handle_error(&error) => { + client.forget_serializer(realm); + let serializer = + serializer_for_realm(client, realm, context_id, None, deadline).await?; + send_serialized_evaluate( + client, + &realm.session_id, + &serializer, + &function_declaration, + deadline.remaining()?, + ) + .await? + } + Err(error) if execution_context_destroyed_error(&error) => { + client.forget_serializer(realm); + return Err(upstream_context_destroyed_error()); + } + result => result?, + }; + runtime_evaluate_serialized_result_to_json(&result, 1, expected_wrapper_line) +} + +fn arguments_with_serializer(arguments: Option<&Value>, serializer_object_id: &str) -> Value { + let mut arguments = arguments + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + arguments.push(json!({ "objectId": serializer_object_id })); + Value::Array(arguments) +} + +async fn send_serialized_call_function( + client: &CdpClient, + session_id: &str, + object_id: &str, + serializer_object_id: &str, + function_declaration: &str, + arguments: Option<&Value>, + timeout: Duration, +) -> RwResult { + client + .send( + "Runtime.callFunctionOn", + json!({ + "objectId": object_id, + "functionDeclaration": serialize_call_function_result(function_declaration), + "arguments": arguments_with_serializer(arguments, serializer_object_id), + "awaitPromise": true, + "returnByValue": true, + "userGesture": true, + }), + Some(session_id), + timeout, + ) + .await +} + +async fn call_function_with_serialized_result_before( + client: &CdpClient, + realm: &SerializerRealmKey, + object_id: &str, + function_declaration: &str, + arguments: Option<&Value>, + deadline: OperationDeadline, +) -> RwResult { + let serializer = serializer_for_realm(client, realm, None, Some(object_id), deadline).await?; + match send_serialized_call_function( + client, + &realm.session_id, + object_id, + &serializer, + function_declaration, + arguments, + deadline.remaining()?, + ) + .await + { + Err(error) if invalid_serializer_handle_error(&error) => { + client.forget_serializer(realm); + let serializer = + serializer_for_realm(client, realm, None, Some(object_id), deadline).await?; + send_serialized_call_function( + client, + &realm.session_id, + object_id, + &serializer, + function_declaration, + arguments, + deadline.remaining()?, + ) + .await + } + Err(error) if execution_context_destroyed_error(&error) => { + client.forget_serializer(realm); + Err(upstream_context_destroyed_error()) + } + result => result, + } +} + +async fn call_function_in_context_with_serialized_result_before( + client: &CdpClient, + realm: &SerializerRealmKey, + context_id: Option<&Value>, + function_declaration: &str, + arguments: Option<&Value>, + deadline: OperationDeadline, +) -> RwResult { + let serializer = serializer_for_realm(client, realm, context_id, None, deadline).await?; + match send_serialized_call_function( + client, + &realm.session_id, + &serializer, + &serializer, + function_declaration, + arguments, + deadline.remaining()?, + ) + .await + { + Err(error) if invalid_serializer_handle_error(&error) => { + client.forget_serializer(realm); + let serializer = + serializer_for_realm(client, realm, context_id, None, deadline).await?; + send_serialized_call_function( + client, + &realm.session_id, + &serializer, + &serializer, + function_declaration, + arguments, + deadline.remaining()?, + ) + .await + } + Err(error) if execution_context_destroyed_error(&error) => { + client.forget_serializer(realm); + Err(upstream_context_destroyed_error()) + } + result => result, + } +} + fn evaluate_expression_for_frame( page: Arc, frame_id: String, @@ -23051,6 +24713,7 @@ fn evaluate_expression_for_frame( let browser = Arc::clone(&page.browser); let client = Arc::clone(&browser.client); let session_id = page.session_for_frame_id(&frame_id)?; + let realm_identity = format!("frame:{frame_id}"); browser.block_on(async move { let world = client .send( @@ -23069,33 +24732,31 @@ fn evaluate_expression_for_frame( RwError::Message("CDP did not return an executionContextId".to_string()) })? .clone(); - let result = client - .send( - "Runtime.evaluate", - json!({ - "expression": expression, - "contextId": context_id, - "awaitPromise": true, - "returnByValue": false, - "userGesture": true, - }), - Some(&session_id), - timeout, - ) - .await?; - runtime_result_to_json_with_serializer(&client, &session_id, &result, timeout).await + let realm = client + .serializer_realm_key(&session_id, Some(&realm_identity)) + .in_world(FRAME_UTILITY_WORLD_NAME); + evaluate_serialized_expression_before( + &client, + &realm, + Some(&context_id), + &expression, + OperationDeadline::new(timeout), + ) + .await }) } async fn evaluate_expression_in_session( client: &CdpClient, session_id: &str, + realm_identity: Option<&str>, expression: String, timeout: Duration, ) -> RwResult { evaluate_expression_in_session_before( client, session_id, + realm_identity, expression, OperationDeadline::new(timeout), ) @@ -23105,23 +24766,12 @@ async fn evaluate_expression_in_session( async fn evaluate_expression_in_session_before( client: &CdpClient, session_id: &str, + realm_identity: Option<&str>, expression: String, deadline: OperationDeadline, ) -> RwResult { - let result = client - .send( - "Runtime.evaluate", - json!({ - "expression": expression, - "awaitPromise": true, - "returnByValue": false, - "userGesture": true, - }), - Some(session_id), - deadline.remaining()?, - ) - .await?; - runtime_result_to_json_with_serializer(client, session_id, &result, deadline.remaining()?).await + let realm = client.serializer_realm_key(session_id, realm_identity); + evaluate_serialized_expression_before(client, &realm, None, &expression, deadline).await } async fn evaluate_handle_expression_in_session( @@ -23157,32 +24807,30 @@ async fn evaluate_expression_in_frame_context( let context_id = create_isolated_world_for_frame(client, session_id, frame_id, deadline.remaining()?) .await?; - evaluate_expression_in_context_before(client, session_id, context_id, expression, deadline) - .await + evaluate_expression_in_context_before( + client, + session_id, + Some(format!("frame:{frame_id}").as_str()), + context_id, + expression, + deadline, + ) + .await } async fn evaluate_expression_in_context_before( client: &CdpClient, session_id: &str, + realm_identity: Option<&str>, context_id: Value, expression: String, deadline: OperationDeadline, ) -> RwResult { - let result = client - .send( - "Runtime.evaluate", - json!({ - "expression": expression, - "contextId": context_id, - "awaitPromise": true, - "returnByValue": false, - "userGesture": true, - }), - Some(session_id), - deadline.remaining()?, - ) - .await?; - runtime_result_to_json_with_serializer(client, session_id, &result, deadline.remaining()?).await + let realm = client + .serializer_realm_key(session_id, realm_identity) + .in_world(FRAME_UTILITY_WORLD_NAME); + evaluate_serialized_expression_before(client, &realm, Some(&context_id), &expression, deadline) + .await } async fn evaluate_handle_expression_in_frame_context( @@ -23277,6 +24925,11 @@ async fn evaluate_locator_resolution( evaluate_expression_in_context_before( &page.browser.client, &resolution.session_id, + resolution + .frame_id + .as_deref() + .map(|frame_id| format!("frame:{frame_id}")) + .as_deref(), context_id, expression, transport_deadline, @@ -23286,6 +24939,7 @@ async fn evaluate_locator_resolution( evaluate_expression_in_session_before( &page.browser.client, &resolution.session_id, + None, expression, transport_deadline, ) @@ -23818,6 +25472,7 @@ async fn evaluate_locator_for_element_handle( ) -> RwResult { let deadline = OperationDeadline::new(timeout); let session_id = session_id.unwrap_or_else(|| page.session_id.clone()); + let realm = page.browser.client.serializer_realm_key(&session_id, None); let expression = locator_script_for_root(&locator_json, index, &body, "this"); let expression = expression.trim(); let attached_guard = if require_attached { @@ -23835,24 +25490,35 @@ async fn evaluate_locator_for_element_handle( }}"# ); let transport_timeout = deadline.remaining()?.saturating_add(transport_slack); - let result = page - .browser - .client - .send( - "Runtime.callFunctionOn", - json!({ - "objectId": object_id, - "functionDeclaration": function_declaration, - "awaitPromise": true, - "returnByValue": return_by_value, - "userGesture": true, - }), - Some(&session_id), - transport_timeout, + let result = if return_by_value { + call_function_with_serialized_result_before( + &page.browser.client, + &realm, + &object_id, + &function_declaration, + None, + OperationDeadline::new(transport_timeout), ) - .await?; + .await? + } else { + page.browser + .client + .send( + "Runtime.callFunctionOn", + json!({ + "objectId": object_id, + "functionDeclaration": function_declaration, + "awaitPromise": true, + "returnByValue": false, + "userGesture": true, + }), + Some(&session_id), + transport_timeout, + ) + .await? + }; if return_by_value { - runtime_result_to_json(&result) + runtime_serialized_result_to_json(&result) } else { runtime_result_to_remote_object_with_session(&result, &session_id) } @@ -27498,6 +29164,27 @@ impl PyPage { self.inner.background_override_active.load(Ordering::SeqCst) } + #[cfg(any(test, feature = "test-support"))] + fn _runtime_state_test_hook(&self) -> String { + let state = self.inner.browser.client.runtime_state.lock().unwrap(); + let mut serializer_realms = state + .serializers + .keys() + .map(|realm| format!("{}|{}", realm.session_id, realm.realm_identity)) + .collect::>(); + serializer_realms.sort(); + json!({ + "serializers": state.serializers.len(), + "serializer_install_locks": state.serializer_install_locks.len(), + "serializer_generations": state.serializer_generations.len(), + "execution_realms": state.execution_realms.len(), + "frame_loaders": state.frame_loaders.len(), + "serializer_realms": serializer_realms, + "release_object_count": state.serializer_release_count, + }) + .to_string() + } + #[pyo3(signature = (url, wait_until=None, timeout_ms=None, referer=None))] fn goto_async( &self, @@ -27894,6 +29581,14 @@ el.click(); } } + fn cdp_session_for_id(&self, session_id: &str) -> PyCdpSession { + PyCdpSession { + browser: Arc::clone(&self.inner.browser), + session_id: Some(session_id.to_string()), + detached: AtomicBool::new(false), + } + } + #[pyo3(signature = (timeout_ms=None))] fn frame_tree(&self, timeout_ms: Option) -> PyResult { let page = Arc::clone(&self.inner); @@ -29123,6 +30818,11 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; ) -> PyResult { let arguments = serde_json::from_str::(arguments_json) .map_err(|error| PyValueError::new_err(error.to_string()))?; + if return_by_value { + return self + .call_function_in_default_context(function_declaration, Some(arguments), timeout_ms) + .map_err(py_err); + } let global_payload = self .evaluate_handle_expression("globalThis", timeout_ms) .map_err(py_err)?; @@ -29139,6 +30839,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; return_by_value, timeout_ms, None, + None, ); let _ = self.js_handle_dispose(object_id, timeout_ms, None); result.map_err(py_err) @@ -29208,12 +30909,13 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; .map_err(py_err) } - #[pyo3(signature = (object_id, timeout_ms=None, session_id=None))] + #[pyo3(signature = (object_id, timeout_ms=None, session_id=None, realm_identity=None))] fn js_handle_json_value( &self, object_id: &str, timeout_ms: Option, session_id: Option<&str>, + realm_identity: Option<&str>, ) -> PyResult { self.call_function_on_handle( object_id, @@ -29222,6 +30924,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; true, timeout_ms, session_id, + realm_identity, ) .map_err(py_err) } @@ -29242,6 +30945,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; false, timeout_ms, session_id, + None, ) .map_err(py_err) } @@ -29306,7 +31010,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; .map_err(py_err) } - #[pyo3(signature = (object_id, expression, arg_json=None, return_by_value=true, timeout_ms=None, session_id=None))] + #[pyo3(signature = (object_id, expression, arg_json=None, return_by_value=true, timeout_ms=None, session_id=None, realm_identity=None))] fn js_handle_evaluate( &self, object_id: &str, @@ -29315,6 +31019,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; return_by_value: bool, timeout_ms: Option, session_id: Option<&str>, + realm_identity: Option<&str>, ) -> PyResult { let trimmed = expression.trim(); let function = if arg_json.is_some() { @@ -29339,11 +31044,12 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; return_by_value, timeout_ms, session_id, + realm_identity, ) .map_err(py_err) } - #[pyo3(signature = (object_id, function_declaration, arguments_json, return_by_value=true, timeout_ms=None, session_id=None))] + #[pyo3(signature = (object_id, function_declaration, arguments_json, return_by_value=true, timeout_ms=None, session_id=None, realm_identity=None))] fn js_handle_evaluate_with_call_arguments( &self, object_id: &str, @@ -29352,6 +31058,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; return_by_value: bool, timeout_ms: Option, session_id: Option<&str>, + realm_identity: Option<&str>, ) -> PyResult { let arguments = serde_json::from_str::(arguments_json) .map_err(|error| PyValueError::new_err(error.to_string()))?; @@ -29362,6 +31069,7 @@ return win.__rustwrightCleanupDrag ? win.__rustwrightCleanupDrag() : false; return_by_value, timeout_ms, session_id, + realm_identity, ) .map_err(py_err) } @@ -30473,6 +32181,17 @@ return { ready: true, result: true, payload: null }; }) } + fn execution_context_frame_id( + &self, + session_id: &str, + execution_context_id: &str, + ) -> Option { + self.inner + .browser + .client + .frame_id_for_execution_context(session_id, execution_context_id) + } + #[pyo3(signature = (kind, request_id=None))] fn websocket_event_waiter( &self, @@ -30577,8 +32296,8 @@ return { ready: true, result: true, payload: null }; fn file_chooser_event_waiter(&self) -> PyFileChooserEventWaiter { PyFileChooserEventWaiter { browser: Arc::clone(&self.inner.browser), + page: Arc::clone(&self.inner), receiver: Mutex::new(Some(self.inner.browser.client.subscribe())), - session_id: self.inner.session_id.clone(), } } @@ -30692,12 +32411,13 @@ return { ready: true, result: true, payload: null }; .map_err(py_err) } - #[pyo3(signature = (backend_node_id, files_json, timeout_ms=None))] + #[pyo3(signature = (backend_node_id, files_json, timeout_ms=None, session_id=None))] fn set_file_input_files( &self, backend_node_id: u64, files_json: &str, timeout_ms: Option, + session_id: Option<&str>, ) -> PyResult<()> { let files: Value = serde_json::from_str(files_json) .map_err(|error| PyValueError::new_err(error.to_string()))?; @@ -30705,7 +32425,9 @@ return { ready: true, result: true, payload: null }; let timeout = BrowserInner::command_timeout(timeout_ms); let browser = Arc::clone(&page.browser); let client = Arc::clone(&browser.client); - let session_id = page.session_id.clone(); + let session_id = session_id + .map(ToString::to_string) + .unwrap_or_else(|| page.session_id.clone()); browser .block_on(async move { set_file_input_files_for_session( @@ -31034,24 +32756,16 @@ impl PyWorker { let browser = Arc::clone(&self.browser); let client = Arc::clone(&browser.client); let session_id = self.session_id.clone(); + let realm_identity = format!("worker:{}", self.target_id); let timeout = BrowserInner::command_timeout(timeout_ms); py.detach(move || { - browser.block_on(async move { - let result = client - .send( - "Runtime.evaluate", - json!({ - "expression": expression, - "awaitPromise": true, - "returnByValue": false, - "userGesture": true, - }), - Some(&session_id), - timeout, - ) - .await?; - runtime_result_to_json_with_serializer(&client, &session_id, &result, timeout).await - }) + browser.block_on(evaluate_expression_in_session( + &client, + &session_id, + Some(&realm_identity), + expression, + timeout, + )) }) .map_err(py_err) } @@ -31101,6 +32815,11 @@ impl PyWorker { ) -> PyResult { let arguments = serde_json::from_str::(arguments_json) .map_err(|error| PyValueError::new_err(error.to_string()))?; + if return_by_value { + return self + .call_function_in_default_context(function_declaration, Some(arguments), timeout_ms) + .map_err(py_err); + } let global_payload = self .evaluate_handle(py, "globalThis", None, timeout_ms) .map_err(|error| error)?; @@ -31308,6 +33027,35 @@ impl PyWorker { #[cfg(feature = "python")] impl PyWorker { + fn call_function_in_default_context( + &self, + function_declaration: &str, + arguments: Option, + timeout_ms: Option, + ) -> RwResult { + let browser = Arc::clone(&self.browser); + let client = Arc::clone(&browser.client); + let session_id = self.session_id.clone(); + let realm = client.serializer_realm_key( + &session_id, + Some(format!("worker:{}", self.target_id).as_str()), + ); + let function_declaration = function_declaration.to_string(); + let timeout = BrowserInner::command_timeout(timeout_ms); + browser.block_on(async move { + let result = call_function_in_context_with_serialized_result_before( + &client, + &realm, + None, + &function_declaration, + arguments.as_ref(), + OperationDeadline::new(timeout), + ) + .await?; + runtime_serialized_result_to_json(&result) + }) + } + fn call_function_on_handle( &self, object_id: &str, @@ -31319,25 +33067,41 @@ impl PyWorker { let browser = Arc::clone(&self.browser); let client = Arc::clone(&browser.client); let session_id = self.session_id.clone(); + let realm = client.serializer_realm_key( + &session_id, + Some(format!("worker:{}", self.target_id).as_str()), + ); let object_id = object_id.to_string(); let function_declaration = function_declaration.to_string(); let timeout = BrowserInner::command_timeout(timeout_ms); browser.block_on(async move { - let mut params = json!({ - "objectId": object_id, - "functionDeclaration": function_declaration, - "awaitPromise": true, - "returnByValue": false, - "userGesture": true, - }); - if let Some(arguments) = arguments { - params["arguments"] = arguments; - } - let result = client - .send("Runtime.callFunctionOn", params, Some(&session_id), timeout) - .await?; + let result = if return_by_value { + call_function_with_serialized_result_before( + &client, + &realm, + &object_id, + &function_declaration, + arguments.as_ref(), + OperationDeadline::new(timeout), + ) + .await? + } else { + let mut params = json!({ + "objectId": object_id, + "functionDeclaration": function_declaration, + "awaitPromise": true, + "returnByValue": false, + "userGesture": true, + }); + if let Some(arguments) = arguments { + params["arguments"] = arguments; + } + client + .send("Runtime.callFunctionOn", params, Some(&session_id), timeout) + .await? + }; if return_by_value { - runtime_result_to_json_with_serializer(&client, &session_id, &result, timeout).await + runtime_serialized_result_to_json(&result) } else { runtime_result_to_remote_object(&result) } @@ -31787,14 +33551,11 @@ impl PyFileChooserEventWaiter { .take() .ok_or_else(|| PyRuntimeError::new_err("file chooser waiter is already waiting"))?; let browser = Arc::clone(&self.browser); - let session_id = self.session_id.clone(); + let page = Arc::clone(&self.page); let timeout = BrowserInner::command_timeout(timeout_ms); let (result, receiver) = py.detach(move || { - let result = browser.block_on_raw(wait_for_file_chooser_event( - &mut receiver, - &session_id, - timeout, - )); + let result = + browser.block_on_raw(wait_for_file_chooser_event(&mut receiver, &page, timeout)); (result, receiver) }); *self.receiver.lock().unwrap() = Some(receiver); @@ -32076,6 +33837,39 @@ impl PyPage { }) } + fn call_function_in_default_context( + &self, + function_declaration: &str, + arguments: Option, + timeout_ms: Option, + ) -> RwResult { + let page = Arc::clone(&self.inner); + let function_declaration = function_declaration.to_string(); + let timeout = BrowserInner::command_timeout(timeout_ms); + let browser = Arc::clone(&page.browser); + let client = Arc::clone(&browser.client); + let session_id = page.session_id.clone(); + let realm_identity = page + .main_frame_id + .lock() + .unwrap() + .as_deref() + .map(|frame_id| format!("frame:{frame_id}")); + let realm = client.serializer_realm_key(&session_id, realm_identity.as_deref()); + browser.block_on(async move { + let result = call_function_in_context_with_serialized_result_before( + &client, + &realm, + None, + &function_declaration, + arguments.as_ref(), + OperationDeadline::new(timeout), + ) + .await?; + runtime_serialized_result_to_json(&result) + }) + } + fn call_function_on_handle( &self, object_id: &str, @@ -32084,6 +33878,7 @@ impl PyPage { return_by_value: bool, timeout_ms: Option, session_id: Option<&str>, + realm_identity: Option<&str>, ) -> RwResult { let page = Arc::clone(&self.inner); let object_id = object_id.to_string(); @@ -32094,22 +33889,35 @@ impl PyPage { let session_id = session_id .map(ToString::to_string) .unwrap_or_else(|| page.session_id.clone()); + let realm = client.serializer_realm_key(&session_id, realm_identity); browser.block_on(async move { - let mut params = json!({ - "objectId": object_id, - "functionDeclaration": function_declaration, - "awaitPromise": true, - "returnByValue": false, - "userGesture": true, - }); - if let Some(arguments) = arguments { - params["arguments"] = arguments; - } - let result = client - .send("Runtime.callFunctionOn", params, Some(&session_id), timeout) - .await?; + let result = if return_by_value { + call_function_with_serialized_result_before( + &client, + &realm, + &object_id, + &function_declaration, + arguments.as_ref(), + OperationDeadline::new(timeout), + ) + .await? + } else { + let mut params = json!({ + "objectId": object_id, + "functionDeclaration": function_declaration, + "awaitPromise": true, + "returnByValue": false, + "userGesture": true, + }); + if let Some(arguments) = arguments { + params["arguments"] = arguments; + } + client + .send("Runtime.callFunctionOn", params, Some(&session_id), timeout) + .await? + }; if return_by_value { - runtime_result_to_json_with_serializer(&client, &session_id, &result, timeout).await + runtime_serialized_result_to_json(&result) } else { runtime_result_to_remote_object_with_session(&result, &session_id) } @@ -32513,6 +34321,7 @@ impl RustwrightNavigationHarness { events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -38003,6 +39812,7 @@ mod native_console_record_tests { events: events.clone(), event_log: Arc::clone(&event_log), traffic_log: Arc::new(Mutex::new(CdpTrafficLog::new())), + runtime_state: Arc::new(Mutex::new(CdpRuntimeState::new(None))), next_id: AtomicU64::new(1), sent_runtime_enable_count: AtomicU64::new(0), sent_target_close_count: AtomicU64::new(0), @@ -41201,7 +43011,7 @@ async fn wait_for_download_event( async fn wait_for_file_chooser_event( events: &mut broadcast::Receiver, - session_id: &str, + page: &PageInner, timeout: Duration, ) -> RwResult { let deadline = tokio::time::Instant::now() + timeout; @@ -41216,7 +43026,7 @@ async fn wait_for_file_chooser_event( let matches_session = event .get("sessionId") .and_then(Value::as_str) - .map(|value| value == session_id) + .map(|session_id| page.frame_state.lock().unwrap().owns_session(session_id)) .unwrap_or(false); if !matches_session { continue; @@ -41862,6 +43672,7 @@ fn websocket_frame_from_event(event: &Value, request_id: Option<&str>) -> Option fn file_chooser_from_event(event: &Value) -> Option { let params = event.get("params")?; Some(json!({ + "session_id": event.get("sessionId").cloned().unwrap_or(Value::Null), "frame_id": params.get("frameId").cloned().unwrap_or(Value::Null), "backend_node_id": params.get("backendNodeId").cloned().unwrap_or(Value::Null), "mode": params.get("mode").cloned().unwrap_or(Value::Null), @@ -41892,11 +43703,19 @@ fn console_from_event(event: &Value) -> Option { .cloned() .unwrap_or_default(); let values: Vec = args.iter().map(console_arg_value).collect(); + let session_id = event.get("sessionId").and_then(Value::as_str); let handle_args: Vec = args .iter() .map(|arg| { + let mut remote = arg.clone(); + if let (Some(session_id), Some(remote)) = (session_id, remote.as_object_mut()) { + remote.insert( + "__rustwright_session_id".to_string(), + Value::String(session_id.to_string()), + ); + } json!({ - "__rustwright_cdp_remote_object__": arg, + "__rustwright_cdp_remote_object__": remote, }) }) .collect(); @@ -41928,6 +43747,8 @@ fn console_from_event(event: &Value) -> Option { "args": handle_args, "location": location, "timestamp": params.get("timestamp").cloned().unwrap_or(Value::Null), + "session_id": session_id, + "execution_context_id": params.get("executionContextId").cloned().unwrap_or(Value::Null), })) } @@ -43617,7 +45438,7 @@ mod wire_decode_tests { } } -const RUNTIME_VALUE_SERIALIZER: &str = r#"(function __rw_serialize(value) { +const RUNTIME_VALUE_SERIALIZER: &str = r#"(function (value) { const marker = "__rustwright_cdp_unserializable_value__"; const seen = new WeakMap(); let nextRef = 0; @@ -43719,6 +45540,44 @@ const RUNTIME_VALUE_SERIALIZER: &str = r#"(function __rw_serialize(value) { return serialize(value); })"#; +fn runtime_value_serializer_factory() -> String { + format!( + "function __rustwright_serializer_factory__(){{const holder=Object.create(null);holder.serialize={RUNTIME_VALUE_SERIALIZER};return holder;}}" + ) +} + +fn is_script_goal_evaluate_expression(expression: &str) -> bool { + expression.starts_with("{\n") && expression.ends_with("\n}") +} + +fn serialize_evaluate_result_expression(expression: &str) -> String { + format!("{expression}\n({RUNTIME_VALUE_SERIALIZER})(undefined)") +} + +fn serialize_evaluate_result_function(expression: &str) -> String { + format!( + "function __rustwright_evaluate_wrapper__(){{\ +var __rw_serializer=arguments[arguments.length-1].serialize;\ +var __rw_result=(function(){{return (\n{expression}\n);}}).call(globalThis);\ +return __rw_result!==null&&(typeof __rw_result==='object'||typeof __rw_result==='function')&&typeof __rw_result.then==='function'\ +?Promise.resolve(__rw_result).then(__rw_serializer):__rw_serializer(__rw_result);\ +}}" + ) +} + +fn serialize_call_function_result(function_declaration: &str) -> String { + format!( + "function __rustwright_evaluate_wrapper__(){{\ +var __rw_serializer=arguments[arguments.length-1].serialize;\ +var __rw_result=({function_declaration}).apply(\ +this===arguments[arguments.length-1]?globalThis:this,\ +Array.prototype.slice.call(arguments,0,-1));\ +return __rw_result!==null&&(typeof __rw_result==='object'||typeof __rw_result==='function')&&typeof __rw_result.then==='function'\ +?Promise.resolve(__rw_result).then(__rw_serializer):__rw_serializer(__rw_result);\ +}}" + ) +} + fn make_evaluate_expression(expression: &str, arg_json: Option<&str>) -> String { let trimmed = expression.trim(); if is_confident_function_expression(trimmed) { @@ -43728,7 +45587,7 @@ fn make_evaluate_expression(expression: &str, arg_json: Option<&str>) -> String // working on pages whose CSP blocks eval/new Function. let call_args = arg_json.unwrap_or(""); return format!( - "(async () => {{ const __rw_fn = ({trimmed}); return await __rw_fn({call_args}); }})()" + "( () => {{ const __rw_fn = ({trimmed}); return __rw_fn({call_args}); }})()" ); } if arg_json.is_none() { @@ -43754,7 +45613,7 @@ fn make_evaluate_expression(expression: &str, arg_json: Option<&str>) -> String let literal = serde_json::to_string(trimmed).unwrap_or_else(|_| "\"\"".to_string()); let call_args = arg_json.unwrap_or(""); format!( - r#"(async () => {{ + r#"( () => {{ const __rw_src = {literal}; let __rw_is_expression = true; try {{ @@ -43771,7 +45630,7 @@ fn make_evaluate_expression(expression: &str, arg_json: Option<&str>) -> String if (typeof __rw_result === "function") {{ __rw_result = __rw_result({call_args}); }} - return await __rw_result; + return __rw_result; }})()"# ) } @@ -44057,7 +45916,12 @@ fn is_js_identifier(value: &str) -> bool { #[cfg(test)] mod evaluate_expression_additional_tests { - use super::make_evaluate_expression; + use super::{ + make_evaluate_expression, normalize_serialized_evaluate_exception_stack, + serialize_call_function_result, serialize_evaluate_result_expression, + serialize_evaluate_result_function, + }; + use serde_json::json; #[test] fn declaration_helper_discovers_functions_mid_line() { @@ -44082,6 +45946,118 @@ mod evaluate_expression_additional_tests { 1 ); } + + #[test] + fn serializer_wrappers_keep_the_serializer_in_function_arguments() { + let evaluate = serialize_evaluate_result_function("typeof __rw_value"); + assert!(evaluate.contains("__rustwright_evaluate_wrapper__")); + assert!(evaluate.contains("arguments[arguments.length-1]")); + assert!(evaluate.contains(".call(globalThis)")); + + let call = serialize_call_function_result( + "function() { return typeof __rw_args + typeof __rw_value; }", + ); + assert!(call.contains("__rustwright_evaluate_wrapper__")); + assert!(call.contains("Array.prototype.slice.call(arguments,0,-1)")); + assert!(call.contains("this===arguments[arguments.length-1]?globalThis:this")); + assert!(!call.contains("const __rw_")); + assert!(!evaluate.contains("catch")); + assert!(!evaluate.contains(".stack")); + assert!(!call.contains("catch")); + assert!(!call.contains(".stack")); + } + + #[test] + fn declaration_helper_serialization_stays_at_script_goal() { + let source = "{\nconst x = 1; var y = 2; function helper() { return x + y; }\n}"; + let serialized = serialize_evaluate_result_expression(source); + assert!(serialized.starts_with(source)); + assert!(serialized.ends_with("(undefined)")); + assert!(!serialized.contains("async()=>")); + } + + #[test] + fn generated_evaluate_stack_frame_is_removed_and_positions_are_restored() { + let actual = concat!( + "Error: boom\n", + " at __rw_fn (:102:47)\n", + " at :102:82\n", + " at :102:95\n", + " at user__rustwright_evaluate_wrapper__helper (:102:97)\n", + " at __rustwright_evaluate_wrapper__ (:102:99)\n", + " at Object.__rustwright_evaluate_wrapper__ (:103:6)\n", + " at caller (https://example.test/app.js:50:7)" + ); + let exception = json!({ + "exception": { "description": actual }, + "stackTrace": { + "callFrames": [{ + "functionName": "__rustwright_evaluate_wrapper__", + "scriptId": "generated-script", + "url": "", + "lineNumber": 101, + "columnNumber": 98 + }, { + "functionName": "__rustwright_evaluate_wrapper__", + "scriptId": "generated-script", + "url": "", + "lineNumber": 102, + "columnNumber": 5 + }] + } + }); + assert_eq!( + normalize_serialized_evaluate_exception_stack(&exception, 101, 103), + concat!( + "Error: boom\n", + " at __rw_fn (:1:47)\n", + " at :1:82\n", + " at :1:95\n", + " at user__rustwright_evaluate_wrapper__helper (:1:97)\n", + " at __rustwright_evaluate_wrapper__ (:1:99)\n", + " at caller (https://example.test/app.js:50:7)" + ) + ); + + let custom = json!({ + "exception": { "description": "CUSTOM:10:20\nassigned stack text" } + }); + assert_eq!( + normalize_serialized_evaluate_exception_stack(&custom, 99, 101), + "CUSTOM:10:20\nassigned stack text" + ); + } + + #[test] + fn wrapper_stripping_requires_outermost_generated_frame_and_position() { + let user_stack = concat!( + "Error: user boom\n", + " at __rustwright_evaluate_wrapper__ (:8:4)\n", + " at caller (https://example.test/app.js:2:1)" + ); + let bare_script_id = json!({ + "scriptId": "user-script", + "exception": { "description": user_stack }, + }); + assert_eq!( + normalize_serialized_evaluate_exception_stack(&bare_script_id, 0, 2), + user_stack + ); + + let wrong_outermost_stack = concat!( + "Error: user boom\n", + " at __rustwright_evaluate_wrapper__ (:2:6)\n", + " at Object.__rustwright_evaluate_wrapper__ (:8:4)\n", + " at caller (https://example.test/app.js:2:1)" + ); + let wrong_outermost = json!({ + "exception": { "description": wrong_outermost_stack }, + }); + assert_eq!( + normalize_serialized_evaluate_exception_stack(&wrong_outermost, 0, 2), + wrong_outermost_stack + ); + } } fn is_js_identifier_start(ch: char) -> bool { @@ -44130,6 +46106,121 @@ fn runtime_exception_message(exception: &Value) -> String { .to_string() } +fn normalize_serialized_evaluate_exception_stack( + exception: &Value, + line_offset: usize, + expected_wrapper_line: usize, +) -> String { + const WRAPPER_FUNCTION: &str = "__rustwright_evaluate_wrapper__"; + let message = runtime_exception_message(exception); + let source_lines = message.lines().collect::>(); + let Some(wrapper_index) = source_lines.iter().rposition(|line| { + let Some(callee) = line + .strip_prefix(" at ") + .and_then(|frame| frame.split_once(" (")) + .map(|(callee, _)| callee) + else { + return false; + }; + // Runtime.callFunctionOn can qualify the generated function with the + // receiver class. The outermost exact-name frame is the only candidate; + // a user frame with the same name at any other position must survive. + callee == WRAPPER_FUNCTION + || callee + .rsplit_once('.') + .is_some_and(|(_, function)| function == WRAPPER_FUNCTION) + }) else { + return message; + }; + if !source_lines[wrapper_index].ends_with(format!(":{expected_wrapper_line}:6)").as_str()) { + return message; + } + + source_lines + .into_iter() + .enumerate() + .filter_map(|(line_index, line)| { + if line_index == wrapper_index { + return None; + } + if line_offset == 0 + || line_index > wrapper_index + || !line.starts_with(" at ") + || !line.contains("") + { + return Some(line.to_string()); + } + + let bytes = line.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b':' { + index += 1; + continue; + } + let line_start = index + 1; + let mut line_end = line_start; + while line_end < bytes.len() && bytes[line_end].is_ascii_digit() { + line_end += 1; + } + if line_end == line_start || line_end >= bytes.len() || bytes[line_end] != b':' { + index += 1; + continue; + } + let column_start = line_end + 1; + let mut column_end = column_start; + while column_end < bytes.len() && bytes[column_end].is_ascii_digit() { + column_end += 1; + } + if column_end == column_start { + index += 1; + continue; + } + let Ok(source_line) = line[line_start..line_end].parse::() else { + index += 1; + continue; + }; + if source_line <= line_offset { + index = column_end; + continue; + } + return Some(format!( + "{}:{}{}", + &line[..index], + source_line - line_offset, + &line[line_end..] + )); + } + Some(line.to_string()) + }) + .collect::>() + .join("\n") +} + +fn runtime_evaluate_serialized_result_to_json( + result: &Value, + stack_line_offset: usize, + expected_wrapper_line: usize, +) -> RwResult { + if let Some(exception) = result.get("exceptionDetails") { + return Err(RwError::Message( + normalize_serialized_evaluate_exception_stack( + exception, + stack_line_offset, + expected_wrapper_line, + ), + )); + } + runtime_serialized_result_to_json(result) +} + +fn runtime_inline_evaluate_serialized_result_to_json(result: &Value) -> RwResult { + if let Some(exception) = result.get("exceptionDetails") { + return Err(RwError::Message(runtime_exception_message(exception))); + } + runtime_serialized_result_to_json(result) +} + fn runtime_result_to_json(result: &Value) -> RwResult { if let Some(exception) = result.get("exceptionDetails") { return Err(RwError::Message(runtime_exception_message(exception))); @@ -44152,44 +46243,26 @@ fn runtime_result_to_json(result: &Value) -> RwResult { Ok(value.to_string()) } -async fn runtime_result_to_json_with_serializer( - client: &CdpClient, - session_id: &str, - result: &Value, - timeout: Duration, -) -> RwResult { - if result.get("exceptionDetails").is_some() { - return runtime_result_to_json(result); +fn runtime_serialized_result_to_json(result: &Value) -> RwResult { + if let Some(exception) = result.get("exceptionDetails") { + return Err(RwError::Message(runtime_exception_message(exception))); } - let remote = result.get("result").unwrap_or(&Value::Null); - let Some(object_id) = remote.get("objectId").and_then(Value::as_str) else { - return runtime_result_to_json(result); - }; - let serialized = client - .send( - "Runtime.callFunctionOn", - json!({ - "objectId": object_id, - "functionDeclaration": format!( - "function() {{ return ({RUNTIME_VALUE_SERIALIZER})(this); }}" - ), - "awaitPromise": true, - "returnByValue": true, - "userGesture": true, - }), - Some(session_id), - timeout, - ) - .await; - let _ = client - .send( - "Runtime.releaseObject", - json!({ "objectId": object_id }), - Some(session_id), - Duration::from_secs(1), - ) - .await; - runtime_result_to_json(&serialized?) + if let Some(value) = result.pointer("/result/value") { + if value + .as_object() + .map(|object| { + object.len() == 1 + && object + .get("__rustwright_cdp_undefined__") + .and_then(Value::as_bool) + == Some(true) + }) + .unwrap_or(false) + { + return Ok(Value::Null.to_string()); + } + } + runtime_result_to_json(result) } fn runtime_result_to_remote_object(result: &Value) -> RwResult { From feb547772e5ce9e9d56864571ff612062d586024 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:45:10 +0000 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'tests/'=20?= =?UTF-8?q?with=20remote=20'tests/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/209 --- tests/test_mind2web_benchmark.py | 13 + tests/test_playwright_compat_optin.py | 805 +++++++++++++++++++++----- tests/test_rustwright_sync_api.py | 733 ++++++++++++++++++++++- 3 files changed, 1407 insertions(+), 144 deletions(-) diff --git a/tests/test_mind2web_benchmark.py b/tests/test_mind2web_benchmark.py index 3bf3786..148247f 100644 --- a/tests/test_mind2web_benchmark.py +++ b/tests/test_mind2web_benchmark.py @@ -21,6 +21,19 @@ def load_tool(name: str, path: str): run_mind2web_benchmark = load_tool("run_mind2web_benchmark", "tools/run_mind2web_benchmark.py") +def test_raw_cdp_adapter_spawn_uses_mock_keychain(): + source = run_mind2web_benchmark.node_raw_cdp_adapter_code() + spawn_argv = source.split("const browser = spawn(executable, [", 1)[1].split( + "], { stdio: 'ignore' });", 1 + )[0] + + password_store = spawn_argv.index("'--password-store=basic',") + mock_keychain = spawn_argv.index("'--use-mock-keychain',") + about_blank = spawn_argv.index("'about:blank',") + assert password_store < about_blank + assert mock_keychain < about_blank + + def test_manifest_task_can_include_executable_action_fixture(tmp_path): source = tmp_path / "mind2web.json" source.write_text("[]", encoding="utf-8") diff --git a/tests/test_playwright_compat_optin.py b/tests/test_playwright_compat_optin.py index 37200f9..f1bffcd 100644 --- a/tests/test_playwright_compat_optin.py +++ b/tests/test_playwright_compat_optin.py @@ -1,14 +1,35 @@ from __future__ import annotations import json +import os import subprocess import sys import textwrap +from pathlib import Path +import pytest -def _run_probe(source: str) -> dict[str, object]: +_REPO_ROOT = Path(__file__).resolve().parents[1] +_PYTHON_ROOT = _REPO_ROOT / "python" +_PYTEST_ALIASES = [ + "pytest_playwright", + "playwright.pytest_plugin", + "patchright.pytest_plugin", + "pytest_playwright.pytest_playwright", +] + + +def _run_probe(source: str, *, no_site_packages: bool = False) -> dict[str, object]: + command = [sys.executable] + if no_site_packages: + command.append("-S") + command.extend(["-c", textwrap.dedent(source)]) + env = dict(os.environ) + if no_site_packages: + env["PYTHONPATH"] = str(_PYTHON_ROOT) result = subprocess.run( - [sys.executable, "-c", textwrap.dedent(source)], + command, + env=env, text=True, capture_output=True, check=True, @@ -38,53 +59,14 @@ def test_rustwright_import_does_not_install_legacy_aliases(): report = _run_probe( """ import importlib - import importlib.util import json - import pathlib import sys legacy_roots = ["playwright", "patchright", "cloakbrowser", "pytest_playwright"] before = {name: name in sys.modules for name in legacy_roots} - import rustwright - after_rustwright = {name: name in sys.modules for name in legacy_roots} - def root_probe(name): - spec = importlib.util.find_spec(name) - if spec is None: - return {"status": "missing", "origin": None, "rustwright_backed": False} - paths = [] - if spec.origin: - paths.append(pathlib.Path(spec.origin)) - for location in spec.submodule_search_locations or []: - package = pathlib.Path(location) - paths.extend( - candidate - for candidate in [ - package / "__init__.py", - package / "sync_api.py", - package / "async_api.py", - package / "pytest_playwright.py", - ] - if candidate.exists() - ) - rustwright_backed = False - for path in paths: - try: - if "rustwright" in path.read_text(encoding="utf-8"): - rustwright_backed = True - break - except OSError: - pass - return { - "status": "present", - "origin": spec.origin, - "rustwright_backed": rustwright_backed, - } - - probes = {name: root_probe(name) for name in legacy_roots} - compat_sync = importlib.import_module("rustwright._compat.playwright.sync_api") native_sync = importlib.import_module("rustwright.sync_api") after_direct_compat = {name: name in sys.modules for name in legacy_roots} @@ -94,108 +76,595 @@ def root_probe(name): "after_rustwright": after_rustwright, "after_direct_compat": after_direct_compat, "direct_compat_identity": compat_sync.sync_playwright is native_sync.sync_playwright, - "probes": probes, - "rustwright_all": sorted(name for name in rustwright.__all__ if name.endswith("playwright_compat")), + "rustwright_all": sorted( + name for name in rustwright.__all__ if name.endswith("playwright_compat") + ), }, sort_keys=True)) """ ) - assert report["before"] == { + expected_roots = { "playwright": False, "patchright": False, "cloakbrowser": False, "pytest_playwright": False, } - assert report["after_rustwright"] == report["before"] - assert report["after_direct_compat"] == report["before"] + assert report["before"] == expected_roots + assert report["after_rustwright"] == expected_roots + assert report["after_direct_compat"] == expected_roots assert report["direct_compat_identity"] is True - assert report["rustwright_all"] == ["disable_playwright_compat", "enable_playwright_compat"] - assert not any(item["rustwright_backed"] for item in report["probes"].values()) + assert report["rustwright_all"] == [ + "disable_playwright_compat", + "enable_playwright_compat", + ] -def test_enable_playwright_compat_installs_and_removes_aliases(): +def test_clean_python_without_pytest_enables_core_aliases_only(): report = _run_probe( """ import importlib + import importlib.util + import json + import sys + + assert importlib.util.find_spec("pytest") is None + import rustwright + + result = rustwright.enable_playwright_compat() + playwright_sync = importlib.import_module("playwright.sync_api") + try: + importlib.import_module("pytest_playwright") + except ModuleNotFoundError as error: + missing_plugin = error.name + else: + missing_plugin = None + + print(json.dumps({ + "core_identity": playwright_sync.sync_playwright is rustwright.sync_playwright, + "missing_plugin": missing_plugin, + "pytest_loaded": "pytest" in sys.modules, + "registered_aliases": list(result.registered_aliases), + "skipped_aliases": list(result.skipped_aliases), + "plugin_targets_loaded": sorted( + name for name in sys.modules + if name.startswith("rustwright._compat.pytest_playwright") + or name.endswith(".pytest_plugin") + ), + }, sort_keys=True)) + """, + no_site_packages=True, + ) + + assert report["core_identity"] is True + assert report["missing_plugin"] == "pytest_playwright" + assert report["pytest_loaded"] is False + assert report["skipped_aliases"] == _PYTEST_ALIASES + assert "playwright.sync_api" in report["registered_aliases"] + assert report["plugin_targets_loaded"] == [] + + +def test_enable_disable_leave_sys_meta_path_untouched(): + report = _run_probe( + """ import json import sys import rustwright - import rustwright.async_api as native_async - import rustwright.sync_api as native_sync + before = list(sys.meta_path) rustwright.enable_playwright_compat() + after_enable = list(sys.meta_path) + rustwright.disable_playwright_compat() + after_disable = list(sys.meta_path) + + print(json.dumps({ + "enable_unchanged": len(before) == len(after_enable) and all( + left is right for left, right in zip(before, after_enable) + ), + "disable_unchanged": len(before) == len(after_disable) and all( + left is right for left, right in zip(before, after_disable) + ), + }, sort_keys=True)) + """ + ) + + assert report == {"disable_unchanged": True, "enable_unchanged": True} + + +def test_pytest_playwright_callback_type_export_is_eager_and_exact(): + report = _run_probe( + """ + import importlib + import json + import sys + + import rustwright + rustwright.enable_playwright_compat() + package = importlib.import_module("pytest_playwright") + implementation_name = "rustwright._compat.pytest_playwright.pytest_playwright" + callback = package.CreateContextCallback - playwright_sync = importlib.import_module("playwright.sync_api") - playwright_async = importlib.import_module("playwright.async_api") - playwright_errors = importlib.import_module("playwright._impl._errors") - patchright_sync = importlib.import_module("patchright.sync_api") - patchright_async = importlib.import_module("patchright.async_api") - cloakbrowser = importlib.import_module("cloakbrowser") - pytest_playwright = importlib.import_module("pytest_playwright.pytest_playwright") - - marker = playwright_sync.backend_marker("playwright.sync_api") - installed = { - "playwright": sys.modules["playwright"].__name__, - "playwright.sync_api": sys.modules["playwright.sync_api"].__name__, - "patchright.sync_api": sys.modules["patchright.sync_api"].__name__, - "cloakbrowser": sys.modules["cloakbrowser"].__name__, - "pytest_playwright.pytest_playwright": sys.modules["pytest_playwright.pytest_playwright"].__name__, + from rustwright.pytest_plugin import CreateContextCallback + + print(json.dumps({ + "implementation_loaded": implementation_name in sys.modules, + "is_exact_protocol": callback is CreateContextCallback, + "is_protocol": callback._is_protocol, + "has_viewport_annotation": "viewport" in callback.__call__.__annotations__, + }, sort_keys=True)) + """ + ) + + assert report == { + "has_viewport_annotation": True, + "implementation_loaded": True, + "is_exact_protocol": True, + "is_protocol": True, + } + + +def test_pytest_playwright_callback_static_type_export(tmp_path, monkeypatch): + pytest.importorskip( + "mypy", + reason=( + "mypy is optional; install mypy and rerun this test to check the" + " static type export" + ), + ) + from mypy import api as mypy_api + + probe = tmp_path / "callback_type_probe.py" + probe.write_text( + textwrap.dedent( + """ + from rustwright._compat.pytest_playwright import CreateContextCallback + + def use_callback(callback: CreateContextCallback) -> None: + reveal_type(callback) + reveal_type(callback(viewport={"width": 1280, "height": 720})) + """ + ), + encoding="utf-8", + ) + monkeypatch.setenv("MYPYPATH", str(_PYTHON_ROOT)) + + stdout, stderr, status = mypy_api.run( + ["--strict", "--no-error-summary", str(probe)] + ) + + assert status == 0, stdout + stderr + revealed = [line for line in stdout.splitlines() if "Revealed type is" in line] + assert any("CreateContextCallback" in line for line in revealed), stdout + assert any("BrowserContext" in line for line in revealed), stdout + assert not any("Any" in line or "builtins.object" in line for line in revealed) + + +def test_enable_disable_two_cycles_restore_aliases_and_reload_child(): + report = _run_probe( + """ + import importlib + import json + import sys + + import rustwright + + tracked = [ + "playwright", + "playwright.sync_api", + "patchright", + "patchright.async_api", + "cloakbrowser", + "pytest_playwright", + "playwright.pytest_plugin", + "patchright.pytest_plugin", + "pytest_playwright.pytest_playwright", + ] + cycles = [] + for _ in range(2): + result = rustwright.enable_playwright_compat() + child = importlib.import_module("playwright.sync_api") + target = importlib.import_module("rustwright._compat.playwright.sync_api") + reloaded = importlib.reload(child) + cycles.append({ + "all_registered": all(name in sys.modules for name in tracked), + "child_is_target": child is target, + "reload_identity": reloaded is child, + "registered_result": all( + name in result.registered_aliases for name in tracked + ), + "skipped": list(result.skipped_aliases), + }) + rustwright.disable_playwright_compat() + cycles[-1]["all_restored"] = not any( + name in sys.modules for name in tracked + ) + + print(json.dumps({"cycles": cycles}, sort_keys=True)) + """ + ) + + assert report["cycles"] == [ + { + "all_registered": True, + "all_restored": True, + "child_is_target": True, + "registered_result": True, + "reload_identity": True, + "skipped": [], + }, + { + "all_registered": True, + "all_restored": True, + "child_is_target": True, + "registered_result": True, + "reload_identity": True, + "skipped": [], + }, + ] + + +def test_enable_import_failure_leaves_aliases_and_state_unchanged(): + report = _run_probe( + """ + import importlib + import json + import sys + from types import ModuleType + + import pytest + import rustwright + import rustwright._compat as compat + + canonical_root = importlib.import_module("rustwright._compat.playwright") + foreign_root = ModuleType("playwright") + foreign_child = ModuleType("playwright.sync_api") + sentinel = object() + foreign_root.sync_api = sentinel + sys.modules["playwright"] = foreign_root + sys.modules["playwright.sync_api"] = foreign_child + + aliases = [ + name for name, _target in compat._CORE_ALIASES + compat._PYTEST_ALIASES + ] + missing = object() + alias_snapshot = { + name: sys.modules.get(name, missing) + for name in aliases } - identities = { - "playwright_sync": playwright_sync.sync_playwright is native_sync.sync_playwright, - "playwright_async": playwright_async.async_playwright is native_async.async_playwright, - "playwright_errors": playwright_errors.Error is native_sync.Error, - "patchright_sync": patchright_sync.sync_playwright is native_sync.sync_playwright, - "patchright_async": patchright_async.async_playwright is native_async.async_playwright, - "cloakbrowser": callable(cloakbrowser.launch_async), - "pytest_playwright": pytest_playwright.CreateContextCallback.__name__ == "CreateContextCallback", + state_snapshot = { + "enabled": compat._ENABLED, + "pytest_enabled": compat._PYTEST_ALIASES_ENABLED, + "result": compat._LAST_ENABLE_RESULT, + "modules": dict(compat._PREVIOUS_MODULES), + "attributes": dict(compat._PREVIOUS_PARENT_ATTRIBUTES), } + real_import_module = importlib.import_module + + def failing_import_module(name, *args, **kwargs): + if name == "rustwright._compat.playwright._impl._api_structures": + raise RuntimeError("injected target import failure") + return real_import_module(name, *args, **kwargs) + + compat.importlib.import_module = failing_import_module + try: + rustwright.enable_playwright_compat() + except RuntimeError as error: + failure = str(error) + else: + failure = None + + print(json.dumps({ + "aliases_unchanged": all( + sys.modules.get(name, missing) is module + for name, module in alias_snapshot.items() + ), + "canonical_root_preserved": ( + sys.modules.get("rustwright._compat.playwright") is canonical_root + ), + "failure": failure, + "parent_unchanged": foreign_root.sync_api is sentinel, + "partial_canonical_import_preserved": ( + "rustwright._compat.playwright.__main__" in sys.modules + ), + "pytest_preserved": sys.modules.get("pytest") is pytest, + "state_unchanged": ( + compat._ENABLED is state_snapshot["enabled"] + and compat._PYTEST_ALIASES_ENABLED is state_snapshot["pytest_enabled"] + and compat._LAST_ENABLE_RESULT is state_snapshot["result"] + and compat._PREVIOUS_MODULES == state_snapshot["modules"] + and compat._PREVIOUS_PARENT_ATTRIBUTES == state_snapshot["attributes"] + ), + }, sort_keys=True)) + """ + ) + + assert report == { + "aliases_unchanged": True, + "canonical_root_preserved": True, + "failure": "injected target import failure", + "parent_unchanged": True, + "partial_canonical_import_preserved": True, + "pytest_preserved": True, + "state_unchanged": True, + } + + +def test_enable_rollback_restores_modules_and_parent_attributes(): + report = _run_probe( + """ + import json + import sys + from types import ModuleType + + import rustwright + import rustwright._compat as compat + + foreign_root = ModuleType("playwright") + foreign_impl = ModuleType("playwright._impl") + foreign_child = ModuleType("playwright._impl._api_structures") + foreign_root._impl = foreign_impl + foreign_impl._api_structures = foreign_child + sys.modules["playwright"] = foreign_root + sys.modules["playwright._impl"] = foreign_impl + sys.modules["playwright._impl._api_structures"] = foreign_child + sentinel = object() + observed = {} + + def fail_publish(event, alias_name=None): + if event == "enable-after-import": + canonical_parent = sys.modules[ + "rustwright._compat.playwright._impl" + ] + canonical_parent._api_structures = sentinel + observed["parent"] = canonical_parent + observed["target"] = sys.modules[ + "rustwright._compat.playwright._impl._api_structures" + ] + if ( + event == "enable-after-alias-publish" + and alias_name == "playwright._impl._api_structures" + ): + observed["child_was_published"] = ( + sys.modules.get(alias_name) is observed["target"] + ) + observed["parent_was_replaced"] = ( + observed["parent"]._api_structures is observed["target"] + ) + raise RuntimeError("injected publish failure") + + compat._compat_transaction_hook = fail_publish + try: + rustwright.enable_playwright_compat() + except RuntimeError as error: + failure = str(error) + else: + failure = None + + print(json.dumps({ + "child_restored": ( + sys.modules.get("playwright._impl._api_structures") + is foreign_child + ), + "child_was_published": observed.get("child_was_published", False), + "enabled": compat._ENABLED, + "failure": failure, + "impl_restored": sys.modules.get("playwright._impl") is foreign_impl, + "parent_restored": observed["parent"]._api_structures is sentinel, + "parent_was_replaced": observed.get("parent_was_replaced", False), + "root_restored": sys.modules.get("playwright") is foreign_root, + "unpublished_absent": "playwright.async_api" not in sys.modules, + }, sort_keys=True)) + """ + ) + + assert report == { + "child_restored": True, + "child_was_published": True, + "enabled": False, + "failure": "injected publish failure", + "impl_restored": True, + "parent_restored": True, + "parent_was_replaced": True, + "root_restored": True, + "unpublished_absent": True, + } + + +def test_concurrent_double_enable_is_deterministic(): + report = _run_probe( + """ + import json + import sys + import threading + import rustwright + import rustwright._compat as compat + + imported = threading.Barrier(2) + errors = [] + results = [] + + def transaction_hook(event, alias_name=None): + if event == "enable-after-import": + imported.wait(timeout=10) + + compat._compat_transaction_hook = transaction_hook + + def enable(): + try: + results.append(rustwright.enable_playwright_compat()) + except BaseException as error: + errors.append(repr(error)) + + threads = [threading.Thread(target=enable), threading.Thread(target=enable)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(10) + + aliases = [name for name, _target in compat._CORE_ALIASES + compat._PYTEST_ALIASES] + enabled_consistently = ( + compat._ENABLED + and len(results) == 2 + and results[0] == results[1] + and all(name in sys.modules for name in aliases) + ) rustwright.disable_playwright_compat() + + print(json.dumps({ + "all_threads_finished": not any(thread.is_alive() for thread in threads), + "enabled_consistently": enabled_consistently, + "errors": errors, + "restored": not any(name in sys.modules for name in aliases), + }, sort_keys=True)) + """ + ) + + assert report == { + "all_threads_finished": True, + "enabled_consistently": True, + "errors": [], + "restored": True, + } + + +def test_reenable_upgrades_skipped_pytest_aliases_and_restores_baseline(): + report = _run_probe( + """ + import importlib.util + import json + import sys + from types import ModuleType + + import rustwright + import rustwright._compat as compat + + canonical_playwright = importlib.import_module( + "rustwright._compat.playwright" + ) + importlib.import_module("rustwright._compat.playwright.sync_api") + canonical_playwright_attribute = object() + canonical_playwright.sync_api = canonical_playwright_attribute + + foreign_playwright = ModuleType("playwright") + foreign_playwright_child = ModuleType("playwright.sync_api") + foreign_pytest_playwright = ModuleType("pytest_playwright") + foreign_pytest_child = ModuleType( + "pytest_playwright.pytest_playwright" + ) + playwright_attribute = object() + pytest_attribute = object() + foreign_playwright.sync_api = playwright_attribute + foreign_pytest_playwright.pytest_playwright = pytest_attribute + sys.modules["playwright"] = foreign_playwright + sys.modules["playwright.sync_api"] = foreign_playwright_child + sys.modules["pytest_playwright"] = foreign_pytest_playwright + sys.modules["pytest_playwright.pytest_playwright"] = foreign_pytest_child + + aliases = [ + name for name, _target in compat._CORE_ALIASES + compat._PYTEST_ALIASES + ] + missing = object() + module_snapshot = { + name: sys.modules.get(name, missing) + for name in aliases + } + + real_find_spec = importlib.util.find_spec + pytest_available = False + + def conditional_find_spec(name, *args, **kwargs): + if name == "pytest" and not pytest_available: + return None + return real_find_spec(name, *args, **kwargs) + + compat.importlib.util.find_spec = conditional_find_spec + first = rustwright.enable_playwright_compat() + core_root = sys.modules["playwright"] + before_upgrade = { + "core_root_replaced": core_root is not foreign_playwright, + "core_parent_attribute_replaced": ( + canonical_playwright.sync_api is not canonical_playwright_attribute + ), + "pytest_loaded": "pytest" in sys.modules, + "pytest_root_preserved": ( + sys.modules["pytest_playwright"] is foreign_pytest_playwright + ), + "skipped": list(first.skipped_aliases), + } + + pytest_available = True + second = rustwright.enable_playwright_compat() + after_upgrade = { + "core_root_unchanged": sys.modules["playwright"] is core_root, + "pytest_loaded": "pytest" in sys.modules, + "pytest_root_replaced": ( + sys.modules["pytest_playwright"] is not foreign_pytest_playwright + ), + "pytest_aliases_registered": all( + sys.modules.get(name) is not module_snapshot[name] + for name, _target in compat._PYTEST_ALIASES + ), + "skipped": list(second.skipped_aliases), + } rustwright.disable_playwright_compat() after_disable = { - name: name in sys.modules - for name in [ - "playwright", - "playwright.sync_api", - "patchright", - "patchright.sync_api", - "cloakbrowser", - "pytest_playwright", - "pytest_playwright.pytest_playwright", - ] + "all_module_identities_restored": all( + sys.modules.get(name, missing) is module + for name, module in module_snapshot.items() + ), + "canonical_playwright_attribute_restored": ( + canonical_playwright.sync_api is canonical_playwright_attribute + ), + "playwright_attribute_restored": ( + foreign_playwright.sync_api is playwright_attribute + ), + "pytest_attribute_restored": ( + foreign_pytest_playwright.pytest_playwright is pytest_attribute + ), + "state_disabled": ( + not compat._ENABLED + and not compat._PYTEST_ALIASES_ENABLED + and not compat._PREVIOUS_MODULES + and not compat._PREVIOUS_PARENT_ATTRIBUTES + ), } print(json.dumps({ - "installed": installed, - "identities": identities, - "marker": marker, + "before_upgrade": before_upgrade, + "after_upgrade": after_upgrade, "after_disable": after_disable, }, sort_keys=True)) """ ) - assert report["installed"] == { - "playwright": "rustwright._compat.playwright", - "playwright.sync_api": "rustwright._compat.playwright.sync_api", - "patchright.sync_api": "rustwright._compat.patchright.sync_api", - "cloakbrowser": "rustwright._compat.cloakbrowser", - "pytest_playwright.pytest_playwright": "rustwright._compat.pytest_playwright.pytest_playwright", + assert report == { + "after_disable": { + "canonical_playwright_attribute_restored": True, + "all_module_identities_restored": True, + "playwright_attribute_restored": True, + "pytest_attribute_restored": True, + "state_disabled": True, + }, + "after_upgrade": { + "core_root_unchanged": True, + "pytest_aliases_registered": True, + "pytest_loaded": True, + "pytest_root_replaced": True, + "skipped": [], + }, + "before_upgrade": { + "core_parent_attribute_replaced": True, + "core_root_replaced": True, + "pytest_loaded": False, + "pytest_root_preserved": True, + "skipped": _PYTEST_ALIASES, + }, } - assert all(report["identities"].values()) - assert report["marker"]["implementation"] == "rustwright" - assert report["marker"]["api_module"] == "playwright.sync_api" - assert report["marker"]["api_package"] == "playwright" - assert not any(report["after_disable"].values()) def test_enable_playwright_compat_covers_private_import_paths(): - # Real-world libraries import Playwright's private modules directly - # (generated sync/async classes, API structures, error types). Those paths - # must resolve under the compat aliases with identity to Rustwright's own - # classes, or migrations crash at import time before any test runs. report = _run_probe( """ import json @@ -234,28 +703,25 @@ def test_enable_playwright_compat_covers_private_import_paths(): """ ) - assert report["sync_generated_page"] is True - assert report["async_generated_page"] is True - assert report["patchright_generated_page"] is True - assert report["viewport_size"] is True - assert report["patchright_viewport_size"] is True - assert report["geolocation"] is True - assert report["storage_state"] is True - assert report["target_closed_error"] is True + for identity in [ + "sync_generated_page", + "async_generated_page", + "patchright_generated_page", + "viewport_size", + "patchright_viewport_size", + "geolocation", + "storage_state", + "target_closed_error", + ]: + assert report[identity] is True assert "sameSite" in report["set_cookie_param_keys"] assert "certPath" in report["client_certificate_keys"] def test_browser_context_args_fixture_is_session_scoped(): - # pytest-playwright's documented pattern is a session-scoped - # browser_context_args override in conftest.py. If the plugin defines the - # fixture function-scoped, every test using that pattern dies with - # ScopeMismatch at collection. import rustwright.pytest_plugin as plugin fixture = plugin.browser_context_args - # pytest < 8.4 stores the marker on the function; >= 8.4 wraps the - # function in a FixtureFunctionDefinition carrying the marker. marker = getattr(fixture, "_pytestfixturefunction", None) or getattr( fixture, "_fixture_function_marker", None ) @@ -263,43 +729,84 @@ def test_browser_context_args_fixture_is_session_scoped(): assert marker.scope == "session" -def _run_pytest(tmp_path, target, *extra_args, env=None): +def _run_compat_pytest(tmp_path, target, plugin_name, *extra_args, env=None): + compat_env = dict(os.environ if env is None else env) + compat_env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" + plugin_args = [] if plugin_name is None else ["-p", plugin_name] return subprocess.run( [ sys.executable, - "-m", - "pytest", + "-c", + ( + "import rustwright; " + "rustwright.enable_playwright_compat(); " + "from pytest import console_main; " + "raise SystemExit(console_main())" + ), str(target), "-p", "no:cacheprovider", "-q", *extra_args, - *_plugin_args(), + *plugin_args, ], cwd=tmp_path, - env=env, + env=compat_env, text=True, capture_output=True, + check=False, ) -def test_pytest_plugin_tolerates_foreign_option_registration(tmp_path): - # pytest-base-url (and pytest-playwright) register --base-url/--browser - # too. `-p` plugins register before setuptools entry points, so a foreign - # plugin passed with `-p` claims the option strings first — exactly the - # load order that made rustwright's own registration abort pytest startup - # with "option names already added". Rustwright must tolerate the - # collision and read the surviving registration through its fallbacks. - import os +@pytest.mark.parametrize( + "plugin_name", + [ + "playwright.pytest_plugin", + "patchright.pytest_plugin", + "pytest_playwright.pytest_playwright", + ], +) +def test_pytest_loads_each_eager_plugin_alias(tmp_path, plugin_name): + test_file = tmp_path / "test_alias_plugin.py" + test_file.write_text( + textwrap.dedent( + """ + def test_alias_plugin_fixture(browser_context_args): + assert isinstance(browser_context_args, dict) + """ + ), + encoding="utf-8", + ) + + result = _run_compat_pytest(tmp_path, test_file, plugin_name) + assert result.returncode == 0, f"{plugin_name}\n{result.stdout}{result.stderr}" + assert "1 passed" in result.stdout, result.stdout + +def test_pytest_loads_root_plugin_alias(tmp_path): + test_file = tmp_path / "test_root_alias_plugin.py" + test_file.write_text( + textwrap.dedent( + """ + def test_root_alias_plugin_fixture(browser_context_args): + assert isinstance(browser_context_args, dict) + """ + ), + encoding="utf-8", + ) + + result = _run_compat_pytest(tmp_path, test_file, "pytest_playwright") + assert result.returncode == 0, result.stdout + result.stderr + assert "1 passed" in result.stdout, result.stdout + + +def test_pytest_plugin_tolerates_foreign_option_registration(tmp_path): foreign = tmp_path / "foreign_options_plugin.py" foreign.write_text( textwrap.dedent( """ def pytest_addoption(parser): parser.addoption("--base-url", default=None, help="foreign base url") - # Scalar (non-append) on purpose: the fallback read must not - # iterate a foreign string value character by character. parser.addoption("--browser", default=None, help="foreign browser") """ ), @@ -319,9 +826,10 @@ def test_fixture_fallbacks(browser_name, base_url, browser_context_args): ) env = dict(os.environ) env["PYTHONPATH"] = str(tmp_path) + os.pathsep + env.get("PYTHONPATH", "") - result = _run_pytest( + result = _run_compat_pytest( tmp_path, test_file, + "patchright.pytest_plugin", "-p", "foreign_options_plugin", "--browser", @@ -330,20 +838,18 @@ def test_fixture_fallbacks(browser_name, base_url, browser_context_args): ) assert result.returncode == 0, result.stdout + result.stderr assert "already added" not in result.stderr - # The scalar foreign value must yield exactly one parametrization. assert "1 passed" in result.stdout, result.stdout -def test_session_scoped_browser_context_args_override_collects(tmp_path): - # Regression test for the exact ScopeMismatch failure mode: a conftest - # override declared session-scoped (pytest-playwright's documented - # pattern) must collect and run against the plugin's fixture graph. +def test_conftest_pytest_plugins_alias_collects(tmp_path): conftest = tmp_path / "conftest.py" conftest.write_text( textwrap.dedent( """ import pytest + pytest_plugins = ["playwright.pytest_plugin"] + @pytest.fixture(scope="session") def browser_context_args(browser_context_args): return {**browser_context_args, "locale": "en-US"} @@ -361,6 +867,29 @@ def test_override_applies(browser_context_args): ), encoding="utf-8", ) - result = _run_pytest(tmp_path, test_file) + result = _run_compat_pytest(tmp_path, test_file, None) assert result.returncode == 0, result.stdout + result.stderr + assert "1 passed" in result.stdout assert "ScopeMismatch" not in result.stdout + result.stderr + + +def test_conftest_root_pytest_plugin_alias_collects(tmp_path): + conftest = tmp_path / "conftest.py" + conftest.write_text( + 'pytest_plugins = ["pytest_playwright"]\n', + encoding="utf-8", + ) + test_file = tmp_path / "test_root_scope.py" + test_file.write_text( + textwrap.dedent( + """ + def test_root_fixture_available(browser_context_args): + assert isinstance(browser_context_args, dict) + """ + ), + encoding="utf-8", + ) + + result = _run_compat_pytest(tmp_path, test_file, None) + assert result.returncode == 0, result.stdout + result.stderr + assert "1 passed" in result.stdout diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index 2799f79..6ba326f 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -78,6 +78,13 @@ def data_url(html: str) -> str: return f"data:text/html;charset=utf-8,{quote(html)}" +def runtime_state_test_data(page: Any) -> dict[str, Any]: + hook = getattr(page._core, "_runtime_state_test_hook", None) + if hook is None: + pytest.skip("rustwright core was built without the test-support feature") + return json.loads(hook()) + + def _serve_forever_fast(server: ThreadingHTTPServer) -> None: server.serve_forever(poll_interval=0.01) @@ -1107,6 +1114,23 @@ def do_GET(self): origin_127 = f"http://127.0.0.1:{port}" origin_localhost = f"http://localhost:{port}" path = self.path.split("?", 1)[0] + if path == "/oopif-file-chooser-top": + self._send_html( + f""" + + + + """ + ) + return + if path == "/oopif-file-chooser-child": + self._send_html( + """ + + + """ + ) + return if path == "/oopif-top": self._send_html( f""" @@ -3750,12 +3774,19 @@ def test_page_errors_history_does_not_enable_runtime(playwright): browser = playwright.chromium.launch(headless=True) try: page = browser.new_page() - page.goto(data_url("
page error history
")) - assert browser._core.sent_runtime_enable_count() == 0 - - page.evaluate( - "() => setTimeout(() => { throw new Error('passive page error without runtime'); }, 0)" + page.goto( + data_url( + """ +
page error history
+ + """ + ) ) + assert browser._core.sent_runtime_enable_count() == 0 errors = wait_until(lambda: [str(error) for error in page.page_errors()] or None) assert "passive page error without runtime" in errors @@ -9342,6 +9373,259 @@ def test_evaluate_expression_function_and_arg(page): assert page.evaluate("() => ({ ok: true, items: [1, 2, 3] })") == {"ok": True, "items": [1, 2, 3]} +def test_evaluate_serializer_wrappers_do_not_leak_bindings(page): + page.set_content("
page
") + frame = wait_until(lambda: page.frame(name="child")) + handle = page.evaluate_handle("() => ({ value: 1 })") + try: + assert page.evaluate("() => typeof __rw_value") == "undefined" + assert frame.evaluate("() => typeof __rw_value") == "undefined" + assert handle.evaluate("(value) => typeof __rw_args") == "undefined" + assert page.locator("#page").evaluate("(element) => typeof __rw_args") == "undefined" + finally: + handle.dispose() + + +def test_page_evaluate_with_multiple_handle_arguments_preserves_global_this(page): + first = page.evaluate_handle("() => ({ name: 'first' })") + second = page.evaluate_handle("() => ({ name: 'second' })") + try: + expression = ( + "(arg) => this === window && " + "arg.left.name + ':' + arg.right.name === arg.expected" + ) + assert page.evaluate( + expression, + {"left": first, "right": second, "expected": "first:second"}, + ) + assert page.evaluate( + expression, + {"left": second, "right": first, "expected": "second:first"}, + ) + finally: + first.dispose() + second.dispose() + + +def test_evaluate_rejects_foreign_context_handles_like_playwright(page): + page.set_content("") + child = wait_until(lambda: page.frame(name="child")) + page_handle = page.evaluate_handle("() => ({ realm: 'page' })") + child_handle = child.evaluate_handle("() => ({ realm: 'child' })") + try: + assert page.main_frame.evaluate("value => value.realm", page_handle) == "page" + assert child.evaluate("value => value.realm", child_handle) == "child" + + with pytest.raises(Error) as page_error: + page.evaluate("value => value.realm", child_handle) + assert str(page_error.value) == ( + "Page.evaluate: JSHandles can be evaluated only in the context they were created!" + ) + + with pytest.raises(Error) as frame_error: + child.evaluate("value => value.realm", page_handle) + assert str(frame_error.value) == ( + "Frame.evaluate: JSHandles can be evaluated only in the context they were created!" + ) + + with page.expect_worker() as worker_info: + page.evaluate( + """() => { + globalThis.__rustwrightWorker = new Worker(URL.createObjectURL( + new Blob(['onmessage = () => postMessage(1)'], {type: 'text/javascript'}) + )); + }""" + ) + worker = worker_info.value + with pytest.raises(Error) as worker_error: + worker.evaluate("value => value.realm", page_handle) + assert str(worker_error.value) == ( + "Worker.evaluate: JSHandles can be evaluated only in the context they were created!" + ) + finally: + child_handle.dispose() + page_handle.dispose() + +def test_console_handles_keep_their_execution_realm(page): + messages = [] + page.on("console", messages.append) + + page.evaluate("() => console.log({x: 13})") + main_handle = wait_until(lambda: messages[-1].args[0] if messages else None) + assert page.evaluate("(value) => value.x", main_handle) == 13 + + page.set_content("") + child = wait_until(lambda: page.frame(name="child")) + child.evaluate("() => console.log({x: 23})") + child_handle = wait_until( + lambda: messages[-1].args[0] + if messages and messages[-1].args and messages[-1].args[0] is not main_handle + else None + ) + assert child.evaluate("(value) => value.x", child_handle) == 23 + with pytest.raises(Error) as exc_info: + page.evaluate("(value) => value.x", child_handle) + assert str(exc_info.value) == ( + "Page.evaluate: JSHandles can be evaluated only in the context they were created!" + ) + + +def test_removed_iframe_evaluate_realms_do_not_accumulate(page): + page.set_content("
serializer cleanup
") + assert page.evaluate("() => ({ warm: true })") == {"warm": True} + baseline = runtime_state_test_data(page) + tracked_sizes = ( + "serializers", + "serializer_install_locks", + "serializer_generations", + "execution_realms", + "frame_loaders", + ) + + for index in range(8): + frame_name = f"serializer-child-{index}" + page.evaluate( + """name => { + const iframe = document.createElement('iframe'); + iframe.name = name; + document.body.appendChild(iframe); + }""", + frame_name, + ) + time.sleep(0.1) + iframe = page.query_selector(f"iframe[name='{frame_name}']") + assert iframe is not None + frame = wait_until(iframe.content_frame) + assert frame.evaluate("index => ({ index })", index) == {"index": index} + + page.evaluate("name => document.querySelector(`iframe[name='${name}']`).remove()", frame_name) + + def cleaned_state(): + state = runtime_state_test_data(page) + return state if all(state[key] == baseline[key] for key in tracked_sizes) else None + + state = wait_until(cleaned_state) + assert state["release_object_count"] == baseline["release_object_count"] + + + +def test_evaluate_serializer_argument_does_not_change_user_arguments_length(page): + handle = page.evaluate_handle("() => ({ value: 1 })") + try: + assert page.evaluate("function(value) { return arguments.length; }", handle) == 1 + finally: + handle.dispose() + + +def test_evaluate_user_error_matching_old_serializer_marker_executes_once(page): + page.evaluate("() => { globalThis.__rustwrightEvaluateRuns = 0; }") + with pytest.raises(Error) as exc_info: + page.evaluate( + """() => { + globalThis.__rustwrightEvaluateRuns += 1; + throw new Error('__rustwright value serializer is not defined__'); + }""" + ) + assert "__rustwright value serializer is not defined__" in str(exc_info.value) + assert page.evaluate("() => globalThis.__rustwrightEvaluateRuns") == 1 + + +def test_evaluate_serializer_handle_is_not_page_visible_or_spoofable(page): + assert page.evaluate( + """() => Object.getOwnPropertySymbols(globalThis).some( + symbol => Symbol.keyFor(symbol) === '__rustwright_value_serializer_v1__' + )""" + ) is False + assert page.evaluate("() => ({ answer: 42 })") == {"answer": 42} + page.evaluate( + r"""() => { + globalThis[Symbol.for("__rustwright_value_serializer_v1__")] = value => ({ + __rustwright_cdp_object__: 1, + entries: { spoofed: true }, + }); + }""" + ) + assert page.evaluate("() => ({ answer: 42 })") == {"answer": 42} + + +def test_evaluate_custom_prepare_stack_trace_uses_cdp_exception_details(page): + expression = r"""() => { + Error.prepareStackTrace = () => "CUSTOM\n at app.js:500:9"; + throw new Error("boom"); + }""" + with pytest.raises(Error) as exc_info: + page.evaluate(expression) + assert str(exc_info.value) == "Page.evaluate: Error: boom\n at app.js:500:9" + + +def test_evaluate_assigned_error_stack_uses_cdp_exception_details(page): + expression = r"""() => { + const error = new Error("boom"); + error.stack = "CUSTOM\n at app.js:500:9"; + throw error; + }""" + with pytest.raises(Error) as exc_info: + page.evaluate(expression) + assert str(exc_info.value) == "Page.evaluate: Error: boom\n at app.js:500:9" + +def test_evaluate_does_not_read_properties_from_thrown_values(page): + page.evaluate("() => { globalThis.__rustwrightStackGetterRead = false; }") + with pytest.raises(Error) as getter_error: + page.evaluate( + """() => { + const thrown = { + marker: 'original', + get stack() { + globalThis.__rustwrightStackGetterRead = true; + throw new Error('stack getter ran'); + } + }; + throw thrown; + }""" + ) + assert str(getter_error.value).splitlines()[0] == "Page.evaluate: Object" + assert page.evaluate("() => globalThis.__rustwrightStackGetterRead") is False + + with pytest.raises(Error) as forged_error: + page.evaluate("() => { throw {stack: 'forged', marker: 'original'}; }") + assert str(forged_error.value).splitlines()[0] == "Page.evaluate: Object" + + +def test_evaluate_keeps_user_frame_with_wrapper_name_substring(page): + with pytest.raises(Error) as exc_info: + page.evaluate( + """() => { + function user__rustwright_evaluate_wrapper__helper() { + throw new Error('user frame'); + } + user__rustwright_evaluate_wrapper__helper(); + }""" + ) + assert "at user__rustwright_evaluate_wrapper__helper" in str(exc_info.value) + +def test_evaluate_keeps_user_frame_with_exact_wrapper_name(page): + with pytest.raises(Error) as exc_info: + page.evaluate( + """() => { + function __rustwright_evaluate_wrapper__() { + throw new Error('exact user frame'); + } + __rustwright_evaluate_wrapper__(); + }""" + ) + + wrapper_frames = [ + line + for line in str(exc_info.value).splitlines() + if "at __rustwright_evaluate_wrapper__" in line + or "at Object.__rustwright_evaluate_wrapper__" in line + ] + assert len(wrapper_frames) == 1 + assert "at __rustwright_evaluate_wrapper__" in wrapper_frames[0] + + + + def test_evaluate_can_reinject_declaration_helper_script(page): script = """ // Skyvern helper prelude comment @@ -9384,6 +9668,92 @@ class __RustwrightSmokeCounter { assert page.evaluate("() => typeof __rustwrightNestedSmokeHelper") == "undefined" +def test_declaration_helper_keeps_script_goal_semantics(page): + script = """ + const __rwScriptLexical = "visible-to-helper"; + var __rwScriptVar = 41; + function __rwScriptHelper() { + return `${__rwScriptLexical}:${__rwScriptVar + 1}`; + } + """ + + assert page.evaluate(script) is None + assert page.evaluate("() => globalThis.__rwScriptVar") == 41 + assert page.evaluate("() => __rwScriptHelper()") == "visible-to-helper:42" + + with pytest.raises(Error) as exc_info: + page.evaluate( + "const sentinel = 1; function __rwReturnProbe() { return sentinel; } return 2;" + ) + assert str(exc_info.value) == "Page.evaluate: SyntaxError: Illegal return statement" + + +def test_evaluate_error_stacks_match_baseline(page): + assert page.evaluate("0") == 0 + + expected = { + "() => { throw new Error('boom') }": ( + "Page.evaluate: Error: boom\n" + " at __rw_fn (:1:47)\n" + " at :1:82\n" + " at :1:95" + ), + "throw new Error('boom')": ( + "Page.evaluate: Error: boom\n" + " at eval (eval at (:13:32), :1:7)\n" + " at eval ()\n" + " at :13:32\n" + " at :19:3" + ), + } + for expression, baseline_error in expected.items(): + with pytest.raises(Error) as exc_info: + page.evaluate(expression) + assert str(exc_info.value) == baseline_error + + +def test_evaluate_serializer_cache_recovers_after_navigation(page): + assert page.evaluate("1") == 1 + page.goto("data:text/html,new context
ready
") + assert page.evaluate("() => ({ value: 2 })") == {"value": 2} + +def test_evaluate_context_destroyed_after_dispatch_does_not_execute_twice(page, http_server): + page.goto(f"{http_server}/page") + page.evaluate("() => localStorage.removeItem('__rustwrightBeaconCount')") + + with pytest.raises(Error) as exc_info: + page.evaluate( + """() => { + const key = '__rustwrightBeaconCount'; + localStorage.setItem(key, String(Number(localStorage.getItem(key) || 0) + 1)); + location.href = '/page?after-beacon=1'; + return new Promise(() => {}); + }""" + ) + + assert str(exc_info.value).splitlines()[0] == ( + "Page.evaluate: Execution context was destroyed, most likely because of a navigation." + ) + page.wait_for_url("**/page?after-beacon=1") + assert page.evaluate("() => Number(localStorage.getItem('__rustwrightBeaconCount'))") == 1 + + + +def test_detached_frame_evaluate_and_evaluate_handle_match_playwright(page): + page.set_content("") + child = wait_until(lambda: page.frame(name="child")) + page.locator("iframe").evaluate("(element) => element.remove()") + wait_until(child.is_detached) + + with pytest.raises(Error) as evaluate_error: + child.evaluate("1") + assert str(evaluate_error.value) == "Frame.evaluate: Frame was detached" + + with pytest.raises(Error) as handle_error: + child.evaluate_handle("1") + assert str(handle_error.value) == "Frame.evaluate_handle: Frame was detached" + + def test_frame_evaluate_can_reinject_declaration_helper_script(page): page.set_content("") frame = wait_until(lambda: page.frame(name="child")) @@ -18480,6 +18850,94 @@ def test_forced_site_isolation_oopif_uses_iframe_target_session(playwright, oopi finally: browser.close() +def test_forced_site_isolation_oopif_file_chooser_uses_child_session( + playwright, oopif_test_server, tmp_path: Path +): + browser = playwright.chromium.launch( + headless=True, + args=[ + "--site-per-process", + "--no-proxy-server", + "--host-resolver-rules=MAP a.test 127.0.0.1, MAP b.test 127.0.0.1", + ], + ) + try: + page = browser.new_page() + page.goto(f"{oopif_test_server['a_test']}/oopif-file-chooser-top") + targets = page.context.new_cdp_session(page).send("Target.getTargets")["targetInfos"] + assert any( + target.get("type") == "iframe" + and target.get("url", "").startswith("http://b.test:") + for target in targets + ) + upload = tmp_path / "oopif-child.txt" + upload.write_text("child file body", encoding="utf-8") + + + with page.expect_file_chooser() as chooser_info: + page.frame_locator("#child").locator("#upload").click() + + chooser = chooser_info.value + assert chooser.page is page + assert chooser.element.evaluate("element => element.id") == "upload" + chooser.set_files(str(upload)) + assert chooser.element.evaluate( + """async element => ({ + name: element.files[0].name, + content: await element.files[0].text(), + })""" + ) == {"name": "oopif-child.txt", "content": "child file body"} + finally: + browser.close() + +def test_stale_oopif_file_chooser_clear_does_not_mutate_main_frame_input( + playwright, oopif_test_server, tmp_path: Path +): + browser = playwright.chromium.launch( + headless=True, + args=[ + "--site-per-process", + "--no-proxy-server", + "--host-resolver-rules=MAP a.test 127.0.0.1, MAP b.test 127.0.0.1", + ], + ) + try: + page = browser.new_page() + page.goto(f"{oopif_test_server['a_test']}/oopif-file-chooser-top") + target_session = page.context.new_cdp_session(page) + assert any( + target.get("type") == "iframe" + and target.get("url", "").startswith("http://b.test:") + for target in target_session.send("Target.getTargets")["targetInfos"] + ) + + with page.expect_file_chooser() as chooser_info: + page.frame_locator("#child").locator("#upload").click() + chooser = chooser_info.value + + main_upload = tmp_path / "main-frame.txt" + main_upload.write_text("main frame body", encoding="utf-8") + page.locator("#main-upload").set_input_files(main_upload) + page.locator("#child").evaluate("element => element.remove()") + wait_until( + lambda: not any( + target.get("type") == "iframe" + and target.get("url", "").startswith("http://b.test:") + for target in target_session.send("Target.getTargets")["targetInfos"] + ) + ) + + with pytest.raises(Error): + chooser.set_files([], timeout=500) + assert page.evaluate( + """async () => ({ + name: document.querySelector('#main-upload').files[0].name, + content: await document.querySelector('#main-upload').files[0].text(), + })""" + ) == {"name": "main-frame.txt", "content": "main frame body"} + finally: + browser.close() + def test_forced_site_isolation_real_oopif_process_swap_routes_each_input( playwright, oopif_test_server @@ -19379,6 +19837,237 @@ def test_expect_file_chooser_sets_files(page, tmp_path: Path): assert page.evaluate("async () => await document.querySelector('#file').files[0].text()") == "chosen" +def test_file_chooser_path_upload_supports_directory_multiple_and_clear( + page, tmp_path: Path +): + directory = tmp_path / "folder" + directory.mkdir() + (directory / "root.txt").write_text("root body", encoding="utf-8") + nested = directory / "nested" + nested.mkdir() + (nested / "child.txt").write_text("child body", encoding="utf-8") + first = tmp_path / "first.txt" + first.write_text("first body", encoding="utf-8") + second = tmp_path / "second.txt" + second.write_text("second body", encoding="utf-8") + page.set_content( + """ + + + """ + ) + + with page.expect_file_chooser() as directory_info: + page.locator("#directory").click() + directory_info.value.set_files(directory) + assert page.evaluate( + """async () => Promise.all( + Array.from(document.querySelector('#directory').files) + .map(async file => ({ + path: file.webkitRelativePath, + content: await file.text(), + })) + ).then(files => files.sort((left, right) => left.path.localeCompare(right.path)))""" + ) == [ + {"path": "folder/nested/child.txt", "content": "child body"}, + {"path": "folder/root.txt", "content": "root body"}, + ] + + with page.expect_file_chooser() as multiple_info: + page.locator("#multiple").click() + chooser = multiple_info.value + chooser.set_files([first, second]) + assert page.evaluate( + """async () => Promise.all( + Array.from(document.querySelector('#multiple').files) + .map(async file => ({ name: file.name, content: await file.text() })) + )""" + ) == [ + {"name": "first.txt", "content": "first body"}, + {"name": "second.txt", "content": "second body"}, + ] + + chooser.set_files([]) + assert page.evaluate("() => document.querySelector('#multiple').files.length") == 0 + + +def test_file_chooser_directory_upload_uses_one_operation_deadline( + page, tmp_path: Path, monkeypatch +): + directory = tmp_path / "slow-directory" + directory.mkdir() + for index in range(100): + (directory / f"{index:03}.txt").write_text("", encoding="utf-8") + page.set_content("") + with page.expect_file_chooser() as chooser_info: + page.locator("#directory").click() + + original_rglob = Path.rglob + traversed = 0 + + def slow_rglob(path: Path, pattern: str): + nonlocal traversed + for child in original_rglob(path, pattern): + time.sleep(0.02) + traversed += 1 + yield child + + monkeypatch.setattr(Path, "rglob", slow_rglob) + started = time.monotonic() + with pytest.raises(TimeoutError) as exc_info: + chooser_info.value.set_files(directory, timeout=300) + elapsed = time.monotonic() - started + + assert traversed > 0 + assert elapsed < 0.8 + assert str(exc_info.value).splitlines()[0] == "FileChooser.set_files: Timeout 300ms exceeded." + + +@pytest.mark.parametrize("blocked_access", ["payload", "clear", "probe"]) +def test_file_chooser_evaluation_timeout_does_not_run_unbudgeted_handle_cleanup( + page, tmp_path: Path, blocked_access: str +): + page.set_content("") + with page.expect_file_chooser() as chooser_info: + page.locator("#file").click() + chooser = chooser_info.value + page.evaluate( + """blockedAccess => { + const input = document.querySelector('#file'); + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'files'); + const block = () => { + const until = performance.now() + 4000; + while (performance.now() < until) {} + }; + Object.defineProperty(input, 'files', { + configurable: true, + get() { + if (blockedAccess === 'probe') block(); + return descriptor.get.call(this); + }, + set(value) { + if (blockedAccess !== 'probe') block(); + descriptor.set.call(this, value); + }, + }); + }""", + blocked_access, + ) + + upload = tmp_path / "blocked.txt" + upload.write_text("blocked", encoding="utf-8") + files: Any + if blocked_access == "payload": + files = {"name": "payload.txt", "mime_type": "text/plain", "buffer": b"payload"} + elif blocked_access == "clear": + files = [] + else: + files = upload + + original_core = page._core + cleanup_timeouts: list[float | None] = [] + + class CleanupFailureCore: + def __getattr__(self, name: str) -> Any: + return getattr(original_core, name) + + def js_handle_dispose( + self, + object_id: str, + timeout_ms: float | None, + *session_args: str, + ) -> None: + cleanup_timeouts.append(timeout_ms) + if timeout_ms is None: + time.sleep(3.1) + raise RuntimeError("Target closed during cleanup") + + page._core = CleanupFailureCore() + started = time.monotonic() + try: + with pytest.raises(Error) as exc_info: + chooser.set_files(files, timeout=300) + finally: + page._core = original_core + elapsed = time.monotonic() - started + + expected = "FileChooser.set_files: Timeout 300ms exceeded." + failures = [] + if elapsed >= 3: + failures.append(f"call took {elapsed:.3f}s") + if not isinstance(exc_info.value, TimeoutError) or str(exc_info.value).splitlines()[0] != expected: + failures.append(f"raised {type(exc_info.value).__name__}: {exc_info.value}") + assert not failures, "; ".join(failures) + assert cleanup_timeouts == [] + + +def test_file_chooser_path_upload_targets_input_inside_closed_shadow_root( + page, tmp_path: Path +): + upload = tmp_path / "closed-shadow.txt" + upload.write_text("closed shadow body", encoding="utf-8") + page.set_content( + """ +
+ + """ + ) + + with page.expect_file_chooser() as chooser_info: + page.evaluate("() => window.openClosedShadowChooser()") + chooser_info.value.set_files(upload) + + assert page.evaluate("() => window.readClosedShadowFile()") == { + "name": "closed-shadow.txt", + "content": "closed shadow body", + } + +def test_file_chooser_elements_keep_main_and_iframe_realms(page): + page.set_content( + """ + + + """ + ) + child = wait_until(lambda: page.frame(name="child")) + + with page.expect_file_chooser() as main_info: + page.locator("#main-upload").click() + main_chooser = main_info.value + assert main_chooser.element.evaluate( + "element => ({ id: element.id, owner: element.ownerDocument === document })" + ) == {"id": "main-upload", "owner": True} + + with page.expect_file_chooser() as child_info: + child.locator("#child-upload").click() + child_chooser = child_info.value + assert child_chooser.element.evaluate( + "element => ({ id: element.id, owner: element.ownerDocument === document })" + ) == {"id": "child-upload", "owner": True} + + state = runtime_state_test_data(page) + main_realm = f"frame:{page.main_frame._frame_id}" + child_realm = f"frame:{child._frame_id}" + serializer_realms = { + realm.split("|", 1)[1] + for realm in state["serializer_realms"] + } + assert {main_realm, child_realm}.issubset(serializer_realms) + + + def test_file_chooser_set_files_payload_and_timeout_validation_matches_playwright(page): from benchmarks.automation_cases import file_chooser_set_files_payload_and_timeout_validation_matches_playwright @@ -19909,6 +20598,36 @@ def test_expect_worker_captures_and_evaluates_dedicated_worker(page): assert worker.evaluate("(arg) => arg.base.answer + arg.extra.add", {"base": handle, "extra": extra}) == 46 nested = handle.evaluate_handle("(value, arg) => ({ total: value.answer + arg.extra.add })", {"extra": extra}) assert nested.json_value() == {"total": 46} + assert worker.evaluate("() => typeof __rw_value") == "undefined" + assert handle.evaluate("(value) => typeof __rw_args") == "undefined" + + expression = ( + "(arg) => this === self && " + "arg.left[arg.leftKey] === arg.leftValue && " + "arg.right[arg.rightKey] === arg.rightValue" + ) + assert worker.evaluate( + expression, + { + "left": handle, + "leftKey": "answer", + "leftValue": 41, + "right": extra, + "rightKey": "add", + "rightValue": 5, + }, + ) + assert worker.evaluate( + expression, + { + "left": extra, + "leftKey": "add", + "leftValue": 5, + "right": handle, + "rightKey": "answer", + "rightValue": 41, + }, + ) nested.dispose() extra.dispose() mapped.dispose() @@ -22299,7 +23018,9 @@ def test_page_errors_support_navigation_filter(page): before = "sync error before navigation filter" after = "sync error after navigation filter" page.set_content("
page error filter
") - page.evaluate("(text) => setTimeout(() => { throw new Error(text); }, 0)", before) + page.add_script_tag( + content=f"setTimeout(() => {{ throw new Error({json.dumps(before)}); }}, 0)" + ) wait_until(lambda: before in [str(error) for error in page.page_errors(filter="all")]) page.goto( From 22c08d8ce64abaf7530b22cce2d5478e1ec729f6 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:45:11 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'CHANGELOG.?= =?UTF-8?q?md'=20with=20remote=20'CHANGELOG.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/209 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2e2595..fd012e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,15 @@ All notable user-facing changes to Rustwright are documented in this file. ### Changed +- Reduced steady-state object-valued `evaluate()` results to one browser-protocol command after per-realm serializer setup; the first call in a new or recreated realm re-establishes that setup. +- Passive page-error history now records page-authored errors without enabling Chromium's `Runtime` domain; `LIMITATIONS.md` documents the full-detail limitation for evaluate-created asynchronous callbacks. - Verified support for standard CPython 3.9 through 3.14 and CPython 3.15.0rc1. Python 3.15 support remains pre-release, and CI tracks 3.15-dev until GA. - Added a public failure and retry contract for browser actions. Rust, Python, and MCP callers can inspect the failure phase, target kind, command-write status, and retry safety. A tracked input command with unknown delivery now raises `UnknownOutcomeError` in Python and is never reported as safe to retry. Other bindings keep their existing error types and receive a clear diagnostic message. +### Fixed + +- Fixed `enable_playwright_compat()` on installations without the optional pytest development dependency. `enable_playwright_compat()` now returns a `PlaywrightCompatEnableResult` describing what was registered instead of `None`. + ## [0.2.0] - 2026-08-03 ### Added From 4d875056e507fd9d11cbd2021d2ddd3555233e11 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:45:11 +0000 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'Cargo.toml?= =?UTF-8?q?'=20with=20remote=20'Cargo.toml'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/209 --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4d84ed2..fad379a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,9 @@ resolver = "2" [features] default = ["python"] python = ["dep:pyo3"] +# Internal runtime-state probes for explicit test-wheel builds only. +# Default and release wheel builds must not enable this feature. +test-support = ["python"] [lib] name = "_rustwright" From 5107d8497590a66cf132f016d8695d260017c4e8 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:45:11 +0000 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'LIMITATION?= =?UTF-8?q?S.md'=20with=20remote=20'LIMITATIONS.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/209 --- LIMITATIONS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/LIMITATIONS.md b/LIMITATIONS.md index fb8254a..03c77e9 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -19,5 +19,14 @@ Rustwright is an alpha, not a complete Playwright replacement. about 2 of 4 targets. Rustwright does not promise undetectability. - Drop-in compatibility import names are intended to be opt-in for the public alpha. The final compatibility-mode API is being finalized separately. +- Chromium security-masks window `ErrorEvent` detail (`message='Script error.'`, + `error=null`) for asynchronous callbacks created by inspector-compiled + `Runtime.callFunctionOn` declarations. With `Runtime.enable` disabled + (Rustwright's stealth default), passive page-error history does not promise + full detail for errors thrown by evaluate-created async callbacks. + Same-origin, same-document page-authored scripts retain full detail. Standard + web-platform cross-origin masking still applies to external scripts loaded + without CORS. Subscribing to the `pageerror` event enables the Runtime domain + and provides full detail for evaluate-created asynchronous callbacks. - The implementation still has large monolithic files. A module split is planned before beta.