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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 6 additions & 2 deletions benchmarks/automation_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -4292,7 +4292,9 @@ def page_event_waiters_reject_on_page_crash(page):
@case
def page_errors_history_and_clear(page):
page.set_content("<main>page error history</main>")
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 = []
Expand All @@ -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("<main>page error filter</main>")
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:
Expand Down
2 changes: 1 addition & 1 deletion python/rustwright/_async_generated.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
204 changes: 156 additions & 48 deletions python/rustwright/_compat/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
"""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"),
("playwright._impl._api_structures", "rustwright._compat.playwright._impl._api_structures"),
("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"),
Expand All @@ -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",
]
11 changes: 9 additions & 2 deletions python/rustwright/_compat/pytest_playwright/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading