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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions .claude/skills/playwright-roll/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,6 @@ The upstream documentation source of truth is `docs/src/api/*.md` in the playwri

> **The mistake the 1.59 roll made twice over:** classifying things as "internal tooling, N/A for Python" based on the *name* of the API (Screencast, Debugger, pickLocator, clearConsoleMessages, artifactsDir, …). Almost all of those had empty `langs: {}` in `api.json` and were real Python APIs. Sounding tooling-y is not a `langs` filter. **The `langs` field on the member in `api.json` is the only authoritative signal.** When in doubt, dump it (see "Verifying classifications" below).

## Pre-flight

You will need two checkouts in the parent directory:
- `~/code/playwright-python` — this repo.
- `~/code/playwright` — the upstream playwright monorepo (used read-only for diffing).

Bring upstream up to date and ensure release branches/tags are present:

```sh
git -C ~/code/playwright fetch --tags
git -C ~/code/playwright fetch origin 'release-*:release-*'
```

There is sometimes no `vX.Y.0` tag for the latest release (the bots cut release branches first and tag later). Anchor on commits, not tags — see "Identify the commit range" below.

## Process

### 1. Set up the env
Expand Down Expand Up @@ -76,18 +61,29 @@ build + per-platform Node downloads).

### 3. Identify the commit range

The build step (step 2) clones the upstream monorepo into `driver/playwright-src`.
Bring it up to date and ensure release branches/tags are present before walking
the range:

```sh
git -C driver/playwright-src fetch --tags
git -C driver/playwright-src fetch origin 'release-*:release-*'
```

There is sometimes no `vX.Y.0` tag for the latest release (the bots cut release branches first and tag later). Anchor on commits, not tags.

The diff range is "every commit on the new release branch since the previous release was cut". Anchor commits:

- **Previous release end**: the `chore: bump version to vX.Y.0-next` commit on `main`. That commit is the first commit *after* the previous release (X.Y-1) was cut. Use its parent (`<sha>~1`) as the lower bound.
```sh
git -C ~/code/playwright log --all --grep="bump version to v" --oneline | head
git -C driver/playwright-src log --all --grep="bump version to v" --oneline | head
```
- **New release end**: the tip of `release-<new>` (or the matching tag if it exists).

Save the commit list, oldest first, scoped to `docs/src/api/`:

```sh
git -C ~/code/playwright log <prev-anchor>~1..release-<new> --oneline --reverse -- docs/src/api > /tmp/roll-<new>-commits.md
git -C driver/playwright-src log <prev-anchor>~1..release-<new> --oneline --reverse -- docs/src/api > /tmp/roll-<new>-commits.md
```

A normal roll yields 50–100 commits. If you see 0 or thousands, the range is wrong.
Expand All @@ -99,7 +95,7 @@ Format the file as a markdown checklist and add the standard preamble (status le
For each commit, in chronological order:

```sh
git -C ~/code/playwright show <sha> -- docs/src/api/
git -C driver/playwright-src show <sha> -- docs/src/api/
```

Look for:
Expand Down Expand Up @@ -144,7 +140,7 @@ A few rules of thumb that catch most "actually a PORT" cases:

#### PORT

Implement the change in `playwright/_impl/<module>.py`. Use the upstream JS implementation as a reference: `~/code/playwright/packages/playwright-core/src/client/<module>.ts`. Translate idioms:
Implement the change in `playwright/_impl/<module>.py`. Use the upstream JS implementation as a reference: `driver/playwright-src/packages/playwright-core/src/client/<module>.ts`. Translate idioms:

| Upstream JS | Python |
|---|---|
Expand Down Expand Up @@ -285,7 +281,7 @@ Class names use the upstream PascalCase (`BrowserContext`, `BrowserType`); metho
- **A cluster of suppressions on the same class is a smell.** If you're about to add five `Method not implemented: Foo.*` lines, you're almost certainly looking at a class that needs to be implemented. Implement the whole thing once and the suppressions disappear.
- **Watch for revert pairs in the same range.** 1.59 added and reverted `Browser.isRemote` (#39613 / #39620) inside the same release. Walking chronologically lets you skip the add when you see the revert later.
- **Watch for rename-revert pairs.** 1.59 had `Locator.normalize` → `Locator.toCode` (#39648) → `Locator.normalize` (#39754). Final state wins; only port the last.
- **Doc renames almost always include a wire-protocol rename.** Whenever you see `### param: X.y.oldName` → `### param: X.y.newName` in a doc commit, also `git -C ~/code/playwright show <sha> -- packages/protocol/src/protocol.yml` and the corresponding `*Dispatcher.ts` file. If the wire field changed too, the channel-send dict key in `_impl/` must change. Suppressing the doc-side mismatch is hiding a real bug — the previous Python code is silently sending an unknown field that the new server ignores.
- **Doc renames almost always include a wire-protocol rename.** Whenever you see `### param: X.y.oldName` → `### param: X.y.newName` in a doc commit, also `git -C driver/playwright-src show <sha> -- packages/protocol/src/protocol.yml` and the corresponding `*Dispatcher.ts` file. If the wire field changed too, the channel-send dict key in `_impl/` must change. Suppressing the doc-side mismatch is hiding a real bug — the previous Python code is silently sending an unknown field that the new server ignores.
- **TypedDicts beat `Dict[str, X]` for any structured return.** When the docs describe a return as `[Object]` with named fields (or even `[Object=Foo]`), define a `TypedDict` in `_api_structures.py`, re-export from both public `__init__.py` files, and use it. Zero runtime cost (it's still a `dict`), and the doc generator's type comparator matches by structure via `get_type_hints`.
- **Positional renames are free.** A param with no default before any `*` separator is positional-or-keyword in Python, but realistic call sites pass it positionally. Renaming such a param doesn't break callers.
- **The "Backport changes" GitHub issue can be misleading.** In the 1.59 roll its checkboxes were all marked `[x]` with annotations like "✅ IMPLEMENTED", but several of those features had not actually been merged into the Python port. Trust the `docs/src/api/` walk over the issue.
Expand Down
2 changes: 1 addition & 1 deletion DRIVER_SHA
Original file line number Diff line number Diff line change
@@ -1 +1 @@
87bb9ddbd78f329df18c2b24847bc9409240cd07
ac7cdd4bdf15f90fe7229243be6b35a53e0296d1
4 changes: 2 additions & 2 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 -->148.0.7778.96<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->149.0.7827.22<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->151.0<!-- GEN:stop --> | ✅ | ✅ | ✅ |

## Documentation

Expand Down
19 changes: 17 additions & 2 deletions playwright/_impl/_api_structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,12 @@ class FrameExpectOptions(TypedDict, total=False):
pseudo: Optional[str]


class FrameExpectResult(TypedDict):
class FrameExpectResult(TypedDict, total=False):
matches: bool
received: Any
log: List[str]
log: Optional[str]
errorMessage: Optional[str]
timedOut: Optional[bool]


AriaRole = Literal[
Expand Down Expand Up @@ -344,7 +345,21 @@ class DebuggerPausedDetails(TypedDict):
title: str


class ScreencastSize(TypedDict):
width: int
height: int


class VirtualCredential(TypedDict):
id: str
rpId: str
userHandle: str
privateKey: str
publicKey: str


class ScreencastFrame(TypedDict):
data: bytes
timestamp: float
viewportWidth: int
viewportHeight: int
3 changes: 2 additions & 1 deletion playwright/_impl/_assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,13 @@ async def _expect_impl(
)
error_message = result.get("errorMessage")
error_message = f"\n{error_message}" if error_message else ""
log = result.get("log") or ""
aria_snapshot_message = (
f"\nAria snapshot:\n{aria_snapshot}" if aria_snapshot else ""
)
_record_soft_or_raise(
AssertionError(
f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}{aria_snapshot_message}"
f"{out_message}\nActual value: {actual}{error_message} {log}{aria_snapshot_message}"
),
self._is_soft,
)
Expand Down
6 changes: 6 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._credentials import Credentials
from playwright._impl._debugger import Debugger
from playwright._impl._dialog import Dialog
from playwright._impl._disposable import Disposable, DisposableStub
Expand Down Expand Up @@ -133,6 +134,7 @@ def __init__(
self._request: APIRequestContext = from_channel(initializer["requestContext"])
self._request._timeout_settings = self._timeout_settings
self._clock = Clock(self)
self._credentials = Credentials(self)
self._channel.on(
"bindingCall",
lambda params: self._on_binding(from_channel(params["binding"])),
Expand Down Expand Up @@ -741,3 +743,7 @@ def request(self) -> "APIRequestContext":
@property
def clock(self) -> Clock:
return self._clock

@property
def credentials(self) -> Credentials:
return self._credentials
1 change: 1 addition & 0 deletions playwright/_impl/_browser_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ async def connect_over_cdp(
headers: Dict[str, str] = None,
isLocal: bool = None,
noDefaults: bool = None,
artifactsDir: Union[str, Path] = None,
) -> Browser:
params = locals_to_params(locals())
if params.get("headers"):
Expand Down
1 change: 1 addition & 0 deletions playwright/_impl/_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._details = msg.get("errorDetails")
parsed_error._stack = "".join(callback.stack_trace.format())
callback.future.set_exception(parsed_error)
else:
Expand Down
57 changes: 57 additions & 0 deletions playwright/_impl/_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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 typing import TYPE_CHECKING, List

from playwright._impl._api_structures import VirtualCredential
from playwright._impl._helper import locals_to_params

if TYPE_CHECKING:
from playwright._impl._browser_context import BrowserContext


class Credentials:
def __init__(self, browser_context: "BrowserContext") -> None:
self._browser_context = browser_context
self._loop = browser_context._loop
self._dispatcher_fiber = browser_context._dispatcher_fiber

async def install(self) -> None:
await self._browser_context._channel.send("credentialsInstall", None)

async def create(
self,
rpId: str,
id: str = None,
userHandle: str = None,
privateKey: str = None,
publicKey: str = None,
) -> VirtualCredential:
result = await self._browser_context._channel.send_return_as_dict(
"credentialsCreate", None, locals_to_params(locals())
)
return (result or {})["credential"]

async def delete(self, id: str) -> None:
await self._browser_context._channel.send("credentialsDelete", None, {"id": id})

async def get(
self,
rpId: str = None,
id: str = None,
) -> List[VirtualCredential]:
result = await self._browser_context._channel.send_return_as_dict(
"credentialsGet", None, locals_to_params(locals())
)
return (result or {}).get("credentials", [])
6 changes: 5 additions & 1 deletion playwright/_impl/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# stable API.


from typing import Optional
from typing import Any, Optional


def is_target_closed_error(error: Exception) -> bool:
Expand All @@ -28,6 +28,8 @@ def __init__(self, message: str) -> None:
self._message = message
self._name: Optional[str] = None
self._stack: Optional[str] = None
self._details: Optional[Any] = None
self._log: Optional[str] = None
super().__init__(message)

@property
Expand Down Expand Up @@ -57,4 +59,6 @@ def rewrite_error(error: Exception, message: str) -> Exception:
if isinstance(rewritten_exc, Error) and isinstance(error, Error):
rewritten_exc._name = error.name
rewritten_exc._stack = error.stack
rewritten_exc._details = error._details
rewritten_exc._log = error._log
return rewritten_exc
8 changes: 8 additions & 0 deletions playwright/_impl/_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
Headers,
HttpCredentials,
ProxySettings,
RemoteAddr,
SecurityDetails,
ServerFilePayload,
StorageState,
)
Expand Down Expand Up @@ -557,6 +559,12 @@ async def json(self) -> Any:
content = await self.text()
return json.loads(content)

async def security_details(self) -> Optional[SecurityDetails]:
return self._initializer.get("securityDetails") or None

async def server_addr(self) -> Optional[RemoteAddr]:
return self._initializer.get("serverAddr") or None

async def dispose(self) -> None:
await self._request._channel.send(
"disposeAPIResponse",
Expand Down
48 changes: 35 additions & 13 deletions playwright/_impl/_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,19 +189,41 @@ async def _expect(
) -> FrameExpectResult:
if "expectedValue" in options:
options["expectedValue"] = serialize_argument(options["expectedValue"])
result = await self._channel.send_return_as_dict(
"expect",
self._timeout,
{
"selector": selector,
"expression": expression,
**options,
},
title=title,
)
if result.get("received"):
result["received"] = parse_value(result["received"])
return result
try:
await self._channel.send(
"expect",
self._timeout,
{
"selector": selector,
"expression": expression,
**options,
},
title=title,
)
return {"matches": not options.get("isNot")}
except Error as e:
if not e._details:
raise e
details = cast(Dict[str, Any], e._details)
received = details.get("received")
if received:
received = {
"value": (
parse_value(received["value"]) if "value" in received else None
),
"ariaSnapshot": received.get("ariaSnapshot"),
}
return {
"matches": bool(options.get("isNot")),
"received": received,
"timedOut": details.get("timedOut"),
"errorMessage": (
"Error: " + details["customErrorMessage"]
if details.get("customErrorMessage")
else None
),
"log": e._log,
}

def expect_navigation(
self,
Expand Down
3 changes: 3 additions & 0 deletions playwright/_impl/_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class ErrorPayload(TypedDict, total=False):
name: str
stack: str
value: Optional[Any]
details: Optional[Any]


class HarRecordingMetadata(TypedDict, total=False):
Expand Down Expand Up @@ -358,6 +359,8 @@ def parse_error(error: ErrorPayload, log: Optional[str] = None) -> Error:
exc = base_error_class(patch_error_message(error["message"]) + log)
exc._name = error["name"]
exc._stack = error["stack"]
exc._details = error.get("details")
exc._log = log
return exc


Expand Down
Loading
Loading