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
292 changes: 277 additions & 15 deletions .claude/skills/playwright-roll/SKILL.md

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,18 +162,25 @@ jobs:
matrix:
os: [ubuntu-22.04, macos-13, windows-2022]
runs-on: ${{ matrix.os }}
defaults:
run:
# `setup-miniconda` activates the env only for login shells; using
# `bash -el` (recommended by the action) ensures `conda` and the
# installed `conda-build` are on PATH on every OS, including Windows
# where the default shell is pwsh and skips the activation hooks.
shell: bash -el {0}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Get conda
uses: conda-incubator/setup-miniconda@v3
with:
python-version: 3.9
python-version: '3.12'
channels: conda-forge
miniconda-version: latest
- name: Prepare
run: conda install conda-build conda-verify
run: conda install -n base "conda-build>=26" conda-verify
- name: Build
run: conda build .

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ jobs:
- name: Get conda
uses: conda-incubator/setup-miniconda@v3
with:
python-version: 3.9
python-version: '3.12'
channels: conda-forge
miniconda-version: latest
- name: Prepare
run: conda install anaconda-client conda-build conda-verify
run: conda install -n base anaconda-client "conda-build>=26" conda-verify
- name: Build and Upload
env:
ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }}
Expand Down
57 changes: 57 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# CLAUDE.md

Guidance for Claude when working in this repository.

## What this is

Python bindings for [Playwright](https://playwright.dev). The Python client talks JSON over a pipe to the Node-based driver bundled in `playwright/driver/`. The pipe protocol is defined upstream in `packages/protocol/src/protocol.yml`.

## Layout

- `playwright/_impl/` — hand-written client implementation (one module per object: `_browser.py`, `_page.py`, `_locator.py`, `_network.py`, etc.). Edit these to add or change behavior.
- `playwright/async_api/_generated.py`, `playwright/sync_api/_generated.py` — **auto-generated**. Never edit by hand; rerun `./scripts/update_api.sh` after changing `_impl/` or the driver.
- `scripts/generate_api.py`, `scripts/generate_async_api.py`, `scripts/generate_sync_api.py`, `scripts/documentation_provider.py` — codegen and validation. They diff the Python implementation against the driver's `playwright/driver/package/api.json` and abort if either side is out of sync.
- `scripts/expected_api_mismatch.txt` — explicit allowlist of "documented in JS, not in Python" or "named differently in Python" gaps. Lines that no longer apply must be removed.
- `tests/async/`, `tests/sync/` — pytest suites. Most new tests are added to the async file with a sync mirror.
- `setup.py` — `driver_version = "X.Y.Z"` is the source of truth for which driver build is downloaded from `cdn.playwright.dev`.
- `ROLLING.md`, `CONTRIBUTING.md` — human-facing setup and roll docs.

## Setup

`CONTRIBUTING.md` has the full sequence. The short version:

```sh
python3 -m venv env && source env/bin/activate
pip install --upgrade pip
pip install -r local-requirements.txt
pip install -e .
python -m build --wheel # downloads the driver listed in setup.py
pre-commit install
```

If the system lacks `python3-venv`, `uv venv env` is an acceptable substitute (then `uv pip install --python env/bin/python --upgrade pip`).

## Common commands

- Regenerate `_generated.py`: `./scripts/update_api.sh` (runs codegen + pre-commit on the generated files).
- Lint everything: `pre-commit run --all-files`.
- Type-check: `mypy playwright`.
- Run tests: `pytest --browser chromium [-k name]`. Browsers are installed via `playwright install chromium` (do **not** use `--with-deps`, which requires sudo).

When changing public API, edit `_impl/`, then run `./scripts/update_api.sh`. The script regenerates `_generated.py` and validates against the driver's `api.json`. If validation fails, fix the mismatch in `_impl/`, in `expected_api_mismatch.txt`, or in `documentation_provider.py` — not by hand-editing `_generated.py`.

## Rolling Playwright to a new version

This is the recurring high-stakes task. Use the dedicated skill:

→ **[`.claude/skills/playwright-roll/SKILL.md`](.claude/skills/playwright-roll/SKILL.md)**

It documents the full process: the upstream commit-range diff over `docs/src/api/`, how to classify each commit (PORT / MISMATCH / N/A), how to handle the `langs:` filter, the recurring failure modes, and the tests/sync-mirroring conventions.

## House style

- Don't hand-edit generated files.
- Don't add `# type: ignore` or modify `_generated.py` to silence pyright; fix the source of the mismatch.
- New public methods on impl classes need a sync test mirror under `tests/sync/`.
- Keep `expected_api_mismatch.txt` minimal — every entry needs a one-line rationale comment above it.
- Prefer `locals_to_params(locals())` for forwarding optional kwargs to channel sends, matching the rest of the codebase.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ Playwright is a Python library to automate [Chromium](https://www.chromium.org/H

| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->145.0.7632.6<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.0<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->146.0.1<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->147.0.7727.15<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->148.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |

## Documentation

Expand Down
19 changes: 19 additions & 0 deletions playwright/_impl/_api_structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ class RemoteAddr(TypedDict):
port: int


class BrowserBindResult(TypedDict):
endpoint: str


class SecurityDetails(TypedDict):
issuer: Optional[str]
protocol: Optional[str]
Expand Down Expand Up @@ -311,3 +315,18 @@ class TracingGroupLocation(TypedDict):
file: str
line: Optional[int]
column: Optional[int]


class DebuggerLocation(TypedDict):
file: str
line: Optional[int]
column: Optional[int]


class DebuggerPausedDetails(TypedDict):
location: DebuggerLocation
title: str


class ScreencastFrame(TypedDict):
data: bytes
15 changes: 15 additions & 0 deletions playwright/_impl/_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
)

from playwright._impl._api_structures import (
BrowserBindResult,
ClientCertificate,
Geolocation,
HttpCredentials,
Expand Down Expand Up @@ -247,6 +248,20 @@ def version(self) -> str:
async def new_browser_cdp_session(self) -> CDPSession:
return from_channel(await self._channel.send("newBrowserCDPSession", None))

async def bind(
self,
title: str,
workspaceDir: str = None,
host: str = None,
port: int = None,
) -> BrowserBindResult:
return await self._channel.send_return_as_dict(
"startServer", None, locals_to_params(locals())
)

async def unbind(self) -> None:
await self._channel.send("stopServer", None)

async def start_tracing(
self,
page: Page = None,
Expand Down
18 changes: 18 additions & 0 deletions playwright/_impl/_browser_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from_nullable_channel,
)
from playwright._impl._console_message import ConsoleMessage
from playwright._impl._debugger import Debugger
from playwright._impl._dialog import Dialog
from playwright._impl._errors import Error, TargetClosedError
from playwright._impl._event_context_manager import EventContextManagerImpl
Expand Down Expand Up @@ -122,6 +123,7 @@ def __init__(
self._base_url: Optional[str] = self._options.get("baseURL")
self._videos_dir: Optional[str] = self._options.get("recordVideo")
self._tracing = cast(Tracing, from_channel(initializer["tracing"]))
self._debugger: Debugger = cast(Debugger, from_channel(initializer["debugger"]))
self._har_recorders: Dict[str, HarRecordingMetadata] = {}
self._request: APIRequestContext = from_channel(initializer["requestContext"])
self._request._timeout_settings = self._timeout_settings
Expand Down Expand Up @@ -582,6 +584,9 @@ def _on_close(self) -> None:
self._tracing._reset_stack_counter()
self.emit(BrowserContext.Events.Close, self)

def is_closed(self) -> bool:
return self._closing_or_closed

async def close(self, reason: str = None) -> None:
if self._closing_or_closed:
return
Expand Down Expand Up @@ -627,6 +632,15 @@ async def storage_state(
await async_writefile(path, json.dumps(result))
return result

async def set_storage_state(
self, storageState: Union[StorageState, str, Path]
) -> None:
if isinstance(storageState, (str, Path)):
state = json.loads(await async_readfile(storageState))
else:
state = storageState
await self._channel.send("setStorageState", None, {"storageState": state})

def _effective_close_reason(self) -> Optional[str]:
if self._close_reason:
return self._close_reason
Expand Down Expand Up @@ -753,6 +767,10 @@ async def new_cdp_session(self, page: Union[Page, Frame]) -> CDPSession:
def tracing(self) -> Tracing:
return self._tracing

@property
def debugger(self) -> Debugger:
return self._debugger

@property
def request(self) -> "APIRequestContext":
return self._request
Expand Down
8 changes: 6 additions & 2 deletions playwright/_impl/_browser_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ async def launch(
downloadsPath: Union[str, Path] = None,
slowMo: float = None,
tracesDir: Union[pathlib.Path, str] = None,
artifactsDir: Union[pathlib.Path, str] = None,
chromiumSandbox: bool = None,
firefoxUserPrefs: Dict[str, Union[str, float, bool]] = None,
) -> Browser:
Expand Down Expand Up @@ -143,6 +144,7 @@ async def launch_persistent_context(
contrast: Contrast = None,
acceptDownloads: bool = None,
tracesDir: Union[pathlib.Path, str] = None,
artifactsDir: Union[pathlib.Path, str] = None,
chromiumSandbox: bool = None,
firefoxUserPrefs: Dict[str, Union[str, float, bool]] = None,
recordHarPath: Union[Path, str] = None,
Expand Down Expand Up @@ -213,7 +215,7 @@ async def connect_over_cdp(

async def connect(
self,
wsEndpoint: str,
endpoint: str,
timeout: float = None,
slowMo: float = None,
headers: Dict[str, str] = None,
Expand All @@ -229,7 +231,7 @@ async def connect(
"connect",
None,
{
"wsEndpoint": wsEndpoint,
"endpoint": endpoint,
"headers": headers,
"slowMo": slowMo,
"timeout": timeout if timeout is not None else 0,
Expand Down Expand Up @@ -361,3 +363,5 @@ def normalize_launch_params(params: Dict) -> None:
params["downloadsPath"] = str(Path(params["downloadsPath"]))
if "tracesDir" in params:
params["tracesDir"] = str(Path(params["tracesDir"]))
if "artifactsDir" in params:
params["artifactsDir"] = str(Path(params["artifactsDir"]))
8 changes: 8 additions & 0 deletions playwright/_impl/_cdp_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,29 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from types import SimpleNamespace
from typing import Any, Dict

from playwright._impl._connection import ChannelOwner
from playwright._impl._helper import locals_to_params


class CDPSession(ChannelOwner):
Events = SimpleNamespace(
Event="event",
Close="close",
)

def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self._channel.on("event", lambda params: self._on_event(params))
self._channel.on("close", lambda _: self.emit(CDPSession.Events.Close, self))

def _on_event(self, params: Any) -> None:
self.emit(params["method"], params.get("params"))
self.emit(CDPSession.Events.Event, params)

async def send(self, method: str, params: Dict = None) -> Dict:
return await self._channel.send("send", None, locals_to_params(locals()))
Expand Down
4 changes: 4 additions & 0 deletions playwright/_impl/_console_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ def args(self) -> List[JSHandle]:
def location(self) -> SourceLocation:
return self._event["location"]

@property
def timestamp(self) -> float:
return self._event["timestamp"]

@property
def page(self) -> Optional["Page"]:
return self._page
Expand Down
54 changes: 54 additions & 0 deletions playwright/_impl/_debugger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from types import SimpleNamespace
from typing import Any, Dict, Optional

from playwright._impl._api_structures import DebuggerLocation, DebuggerPausedDetails
from playwright._impl._connection import ChannelOwner


class Debugger(ChannelOwner):
Events = SimpleNamespace(
PausedStateChanged="pausedstatechanged",
)

def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self._paused_details: Optional[DebuggerPausedDetails] = None
self._channel.on(
"pausedStateChanged", lambda params: self._on_paused_state_changed(params)
)

def _on_paused_state_changed(self, params: Dict[str, Any]) -> None:
self._paused_details = params.get("pausedDetails")
self.emit(Debugger.Events.PausedStateChanged)

async def request_pause(self) -> None:
await self._channel.send("requestPause", None)

async def resume(self) -> None:
await self._channel.send("resume", None)

async def next(self) -> None:
await self._channel.send("next", None)

async def run_to(self, location: DebuggerLocation) -> None:
await self._channel.send("runTo", None, {"location": location})

@property
def paused_details(self) -> Optional[DebuggerPausedDetails]:
return self._paused_details
14 changes: 10 additions & 4 deletions playwright/_impl/_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import TYPE_CHECKING, Dict, Optional

from playwright._impl._connection import ChannelOwner, from_nullable_channel
from playwright._impl._errors import is_target_closed_error
from playwright._impl._helper import locals_to_params

if TYPE_CHECKING: # pragma: no cover
Expand Down Expand Up @@ -51,7 +52,12 @@ async def accept(self, promptText: str = None) -> None:
await self._channel.send("accept", None, locals_to_params(locals()))

async def dismiss(self) -> None:
await self._channel.send(
"dismiss",
None,
)
try:
await self._channel.send(
"dismiss",
None,
)
except Exception as e:
if is_target_closed_error(e):
return
raise
4 changes: 3 additions & 1 deletion playwright/_impl/_local_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ async def har_unzip(self, zipFile: str, harFile: str) -> None:
params = locals_to_params(locals())
await self._channel.send("harUnzip", None, params)

async def tracing_started(self, tracesDir: Optional[str], traceName: str) -> str:
async def tracing_started(
self, tracesDir: Optional[str], traceName: str, live: bool = False
) -> str:
params = locals_to_params(locals())
return await self._channel.send("tracingStarted", None, params)

Expand Down
Loading
Loading