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
36 changes: 16 additions & 20 deletions .claude/skills/playwright-roll/SKILL.md
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1 +1 @@
87bb9ddbd78f329df18c2b24847bc9409240cd07
1cc5a90cfa3eaa430b1a991963100f95126caa47
6 changes: 3 additions & 3 deletions README.md
Original file line numberDiff line numberDiff 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 --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.5<!-- 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 numberDiff line numberDiff 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[List[str]]
errorMessage: Optional[str]
timedOut: Optional[bool]


AriaRole = Literal[
Expand DownExpand 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
6 changes: 6 additions & 0 deletions playwright/_impl/_browser_context.py
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
8 changes: 6 additions & 2 deletions playwright/_impl/_connection.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,7 +94,7 @@ def send_no_reply(
is_internal: bool = False,
title: str = None,
) -> None:
# No reply messages are used to e.g. waitForEventInfo(after).
# No reply messages are used to e.g. __waitInfo__(after).
self._connection.wrap_api_call_sync(
lambda: self._connection._send_message_to_server(
self._object,
Expand DownExpand Up@@ -413,7 +413,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
callback = self._callbacks.pop(id)
if callback.future.cancelled():
return
# No reply messages are used to e.g. waitForEventInfo(after) which returns exceptions on page close.
# No reply messages are used to e.g. __waitInfo__(after) which returns exceptions on page close.
# To prevent 'Future exception was never retrieved' we just ignore such messages.
if callback.no_reply:
return
Expand All@@ -422,6 +422,10 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._log = msg.get("log")
parsed_error._details = self._replace_guids_with_channels(
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
# stable API.


from typing import Optional
from typing import Any, List, 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[List[str]] = None
super().__init__(message)

@property
Expand DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,8 @@
Headers,
HttpCredentials,
ProxySettings,
RemoteAddr,
SecurityDetails,
ServerFilePayload,
StorageState,
)
Expand DownExpand 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 numberDiff line numberDiff 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
2 changes: 2 additions & 0 deletions playwright/_impl/_helper.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,8 @@ class ParsedMessagePayload(TypedDict, total=False):
params: ParsedMessageParams
result: Any
error: ErrorPayload
errorDetails: Any
log: List[str]


class Document(TypedDict):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
36 changes: 16 additions & 20 deletions .claude/skills/playwright-roll/SKILL.md
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1 +1 @@
87bb9ddbd78f329df18c2b24847bc9409240cd07
1cc5a90cfa3eaa430b1a991963100f95126caa47
6 changes: 3 additions & 3 deletions README.md
Original file line numberDiff line numberDiff 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 --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.5<!-- 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 numberDiff line numberDiff 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[List[str]]
errorMessage: Optional[str]
timedOut: Optional[bool]


AriaRole = Literal[
Expand DownExpand 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
6 changes: 6 additions & 0 deletions playwright/_impl/_browser_context.py
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
8 changes: 6 additions & 2 deletions playwright/_impl/_connection.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,7 +94,7 @@ def send_no_reply(
is_internal: bool = False,
title: str = None,
) -> None:
# No reply messages are used to e.g. waitForEventInfo(after).
# No reply messages are used to e.g. __waitInfo__(after).
self._connection.wrap_api_call_sync(
lambda: self._connection._send_message_to_server(
self._object,
Expand DownExpand Up@@ -413,7 +413,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
callback = self._callbacks.pop(id)
if callback.future.cancelled():
return
# No reply messages are used to e.g. waitForEventInfo(after) which returns exceptions on page close.
# No reply messages are used to e.g. __waitInfo__(after) which returns exceptions on page close.
# To prevent 'Future exception was never retrieved' we just ignore such messages.
if callback.no_reply:
return
Expand All@@ -422,6 +422,10 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._log = msg.get("log")
parsed_error._details = self._replace_guids_with_channels(
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
# stable API.


from typing import Optional
from typing import Any, List, 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[List[str]] = None
super().__init__(message)

@property
Expand DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,8 @@
Headers,
HttpCredentials,
ProxySettings,
RemoteAddr,
SecurityDetails,
ServerFilePayload,
StorageState,
)
Expand DownExpand 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 numberDiff line numberDiff 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
2 changes: 2 additions & 0 deletions playwright/_impl/_helper.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,8 @@ class ParsedMessagePayload(TypedDict, total=False):
params: ParsedMessageParams
result: Any
error: ErrorPayload
errorDetails: Any
log: List[str]


class Document(TypedDict):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
36 changes: 16 additions & 20 deletions .claude/skills/playwright-roll/SKILL.md
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1 +1 @@
87bb9ddbd78f329df18c2b24847bc9409240cd07
1cc5a90cfa3eaa430b1a991963100f95126caa47
6 changes: 3 additions & 3 deletions README.md
Original file line numberDiff line numberDiff 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 --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.5<!-- 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 numberDiff line numberDiff 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[List[str]]
errorMessage: Optional[str]
timedOut: Optional[bool]


AriaRole = Literal[
Expand DownExpand 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
6 changes: 6 additions & 0 deletions playwright/_impl/_browser_context.py
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
8 changes: 6 additions & 2 deletions playwright/_impl/_connection.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,7 +94,7 @@ def send_no_reply(
is_internal: bool = False,
title: str = None,
) -> None:
# No reply messages are used to e.g. waitForEventInfo(after).
# No reply messages are used to e.g. __waitInfo__(after).
self._connection.wrap_api_call_sync(
lambda: self._connection._send_message_to_server(
self._object,
Expand DownExpand Up@@ -413,7 +413,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
callback = self._callbacks.pop(id)
if callback.future.cancelled():
return
# No reply messages are used to e.g. waitForEventInfo(after) which returns exceptions on page close.
# No reply messages are used to e.g. __waitInfo__(after) which returns exceptions on page close.
# To prevent 'Future exception was never retrieved' we just ignore such messages.
if callback.no_reply:
return
Expand All@@ -422,6 +422,10 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._log = msg.get("log")
parsed_error._details = self._replace_guids_with_channels(
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
# stable API.


from typing import Optional
from typing import Any, List, 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[List[str]] = None
super().__init__(message)

@property
Expand DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,8 @@
Headers,
HttpCredentials,
ProxySettings,
RemoteAddr,
SecurityDetails,
ServerFilePayload,
StorageState,
)
Expand DownExpand 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 numberDiff line numberDiff 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
2 changes: 2 additions & 0 deletions playwright/_impl/_helper.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,8 @@ class ParsedMessagePayload(TypedDict, total=False):
params: ParsedMessageParams
result: Any
error: ErrorPayload
errorDetails: Any
log: List[str]


class Document(TypedDict):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
36 changes: 16 additions & 20 deletions .claude/skills/playwright-roll/SKILL.md
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1 +1 @@
87bb9ddbd78f329df18c2b24847bc9409240cd07
1cc5a90cfa3eaa430b1a991963100f95126caa47
6 changes: 3 additions & 3 deletions README.md
Original file line numberDiff line numberDiff 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 --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.5<!-- 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 numberDiff line numberDiff 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[List[str]]
errorMessage: Optional[str]
timedOut: Optional[bool]


AriaRole = Literal[
Expand DownExpand 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
6 changes: 6 additions & 0 deletions playwright/_impl/_browser_context.py
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
8 changes: 6 additions & 2 deletions playwright/_impl/_connection.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,7 +94,7 @@ def send_no_reply(
is_internal: bool = False,
title: str = None,
) -> None:
# No reply messages are used to e.g. waitForEventInfo(after).
# No reply messages are used to e.g. __waitInfo__(after).
self._connection.wrap_api_call_sync(
lambda: self._connection._send_message_to_server(
self._object,
Expand DownExpand Up@@ -413,7 +413,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
callback = self._callbacks.pop(id)
if callback.future.cancelled():
return
# No reply messages are used to e.g. waitForEventInfo(after) which returns exceptions on page close.
# No reply messages are used to e.g. __waitInfo__(after) which returns exceptions on page close.
# To prevent 'Future exception was never retrieved' we just ignore such messages.
if callback.no_reply:
return
Expand All@@ -422,6 +422,10 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._log = msg.get("log")
parsed_error._details = self._replace_guids_with_channels(
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
# stable API.


from typing import Optional
from typing import Any, List, 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[List[str]] = None
super().__init__(message)

@property
Expand DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,8 @@
Headers,
HttpCredentials,
ProxySettings,
RemoteAddr,
SecurityDetails,
ServerFilePayload,
StorageState,
)
Expand DownExpand 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 numberDiff line numberDiff 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
2 changes: 2 additions & 0 deletions playwright/_impl/_helper.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,8 @@ class ParsedMessagePayload(TypedDict, total=False):
params: ParsedMessageParams
result: Any
error: ErrorPayload
errorDetails: Any
log: List[str]


class Document(TypedDict):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
36 changes: 16 additions & 20 deletions .claude/skills/playwright-roll/SKILL.md
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1 +1 @@
87bb9ddbd78f329df18c2b24847bc9409240cd07
1cc5a90cfa3eaa430b1a991963100f95126caa47
6 changes: 3 additions & 3 deletions README.md
Original file line numberDiff line numberDiff 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 --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.5<!-- 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 numberDiff line numberDiff 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[List[str]]
errorMessage: Optional[str]
timedOut: Optional[bool]


AriaRole = Literal[
Expand DownExpand 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
6 changes: 6 additions & 0 deletions playwright/_impl/_browser_context.py
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
8 changes: 6 additions & 2 deletions playwright/_impl/_connection.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,7 +94,7 @@ def send_no_reply(
is_internal: bool = False,
title: str = None,
) -> None:
# No reply messages are used to e.g. waitForEventInfo(after).
# No reply messages are used to e.g. __waitInfo__(after).
self._connection.wrap_api_call_sync(
lambda: self._connection._send_message_to_server(
self._object,
Expand DownExpand Up@@ -413,7 +413,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
callback = self._callbacks.pop(id)
if callback.future.cancelled():
return
# No reply messages are used to e.g. waitForEventInfo(after) which returns exceptions on page close.
# No reply messages are used to e.g. __waitInfo__(after) which returns exceptions on page close.
# To prevent 'Future exception was never retrieved' we just ignore such messages.
if callback.no_reply:
return
Expand All@@ -422,6 +422,10 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._log = msg.get("log")
parsed_error._details = self._replace_guids_with_channels(
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
# stable API.


from typing import Optional
from typing import Any, List, 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[List[str]] = None
super().__init__(message)

@property
Expand DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,8 @@
Headers,
HttpCredentials,
ProxySettings,
RemoteAddr,
SecurityDetails,
ServerFilePayload,
StorageState,
)
Expand DownExpand 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 numberDiff line numberDiff 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
2 changes: 2 additions & 0 deletions playwright/_impl/_helper.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,8 @@ class ParsedMessagePayload(TypedDict, total=False):
params: ParsedMessageParams
result: Any
error: ErrorPayload
errorDetails: Any
log: List[str]


class Document(TypedDict):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
36 changes: 16 additions & 20 deletions .claude/skills/playwright-roll/SKILL.md
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1 +1 @@
87bb9ddbd78f329df18c2b24847bc9409240cd07
1cc5a90cfa3eaa430b1a991963100f95126caa47
6 changes: 3 additions & 3 deletions README.md
Original file line numberDiff line numberDiff 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 --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.5<!-- 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 numberDiff line numberDiff 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[List[str]]
errorMessage: Optional[str]
timedOut: Optional[bool]


AriaRole = Literal[
Expand DownExpand 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
6 changes: 6 additions & 0 deletions playwright/_impl/_browser_context.py
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
8 changes: 6 additions & 2 deletions playwright/_impl/_connection.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,7 +94,7 @@ def send_no_reply(
is_internal: bool = False,
title: str = None,
) -> None:
# No reply messages are used to e.g. waitForEventInfo(after).
# No reply messages are used to e.g. __waitInfo__(after).
self._connection.wrap_api_call_sync(
lambda: self._connection._send_message_to_server(
self._object,
Expand DownExpand Up@@ -413,7 +413,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
callback = self._callbacks.pop(id)
if callback.future.cancelled():
return
# No reply messages are used to e.g. waitForEventInfo(after) which returns exceptions on page close.
# No reply messages are used to e.g. __waitInfo__(after) which returns exceptions on page close.
# To prevent 'Future exception was never retrieved' we just ignore such messages.
if callback.no_reply:
return
Expand All@@ -422,6 +422,10 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._log = msg.get("log")
parsed_error._details = self._replace_guids_with_channels(
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
# stable API.


from typing import Optional
from typing import Any, List, 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[List[str]] = None
super().__init__(message)

@property
Expand DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,8 @@
Headers,
HttpCredentials,
ProxySettings,
RemoteAddr,
SecurityDetails,
ServerFilePayload,
StorageState,
)
Expand DownExpand 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 numberDiff line numberDiff 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
2 changes: 2 additions & 0 deletions playwright/_impl/_helper.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,8 @@ class ParsedMessagePayload(TypedDict, total=False):
params: ParsedMessageParams
result: Any
error: ErrorPayload
errorDetails: Any
log: List[str]


class Document(TypedDict):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
36 changes: 16 additions & 20 deletions .claude/skills/playwright-roll/SKILL.md
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1 +1 @@
87bb9ddbd78f329df18c2b24847bc9409240cd07
1cc5a90cfa3eaa430b1a991963100f95126caa47
6 changes: 3 additions & 3 deletions README.md
Original file line numberDiff line numberDiff 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 --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.5<!-- 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 numberDiff line numberDiff 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[List[str]]
errorMessage: Optional[str]
timedOut: Optional[bool]


AriaRole = Literal[
Expand DownExpand 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
6 changes: 6 additions & 0 deletions playwright/_impl/_browser_context.py
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
8 changes: 6 additions & 2 deletions playwright/_impl/_connection.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,7 +94,7 @@ def send_no_reply(
is_internal: bool = False,
title: str = None,
) -> None:
# No reply messages are used to e.g. waitForEventInfo(after).
# No reply messages are used to e.g. __waitInfo__(after).
self._connection.wrap_api_call_sync(
lambda: self._connection._send_message_to_server(
self._object,
Expand DownExpand Up@@ -413,7 +413,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
callback = self._callbacks.pop(id)
if callback.future.cancelled():
return
# No reply messages are used to e.g. waitForEventInfo(after) which returns exceptions on page close.
# No reply messages are used to e.g. __waitInfo__(after) which returns exceptions on page close.
# To prevent 'Future exception was never retrieved' we just ignore such messages.
if callback.no_reply:
return
Expand All@@ -422,6 +422,10 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._log = msg.get("log")
parsed_error._details = self._replace_guids_with_channels(
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
# stable API.


from typing import Optional
from typing import Any, List, 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[List[str]] = None
super().__init__(message)

@property
Expand DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,8 @@
Headers,
HttpCredentials,
ProxySettings,
RemoteAddr,
SecurityDetails,
ServerFilePayload,
StorageState,
)
Expand DownExpand 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 numberDiff line numberDiff 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
2 changes: 2 additions & 0 deletions playwright/_impl/_helper.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,8 @@ class ParsedMessagePayload(TypedDict, total=False):
params: ParsedMessageParams
result: Any
error: ErrorPayload
errorDetails: Any
log: List[str]


class Document(TypedDict):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
36 changes: 16 additions & 20 deletions .claude/skills/playwright-roll/SKILL.md
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff line change
@@ -1 +1 @@
87bb9ddbd78f329df18c2b24847bc9409240cd07
1cc5a90cfa3eaa430b1a991963100f95126caa47
6 changes: 3 additions & 3 deletions README.md
Original file line numberDiff line numberDiff 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 --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.5<!-- 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 numberDiff line numberDiff 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[List[str]]
errorMessage: Optional[str]
timedOut: Optional[bool]


AriaRole = Literal[
Expand DownExpand 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
6 changes: 6 additions & 0 deletions playwright/_impl/_browser_context.py
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
8 changes: 6 additions & 2 deletions playwright/_impl/_connection.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -94,7 +94,7 @@ def send_no_reply(
is_internal: bool = False,
title: str = None,
) -> None:
# No reply messages are used to e.g. waitForEventInfo(after).
# No reply messages are used to e.g. __waitInfo__(after).
self._connection.wrap_api_call_sync(
lambda: self._connection._send_message_to_server(
self._object,
Expand DownExpand Up@@ -413,7 +413,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
callback = self._callbacks.pop(id)
if callback.future.cancelled():
return
# No reply messages are used to e.g. waitForEventInfo(after) which returns exceptions on page close.
# No reply messages are used to e.g. __waitInfo__(after) which returns exceptions on page close.
# To prevent 'Future exception was never retrieved' we just ignore such messages.
if callback.no_reply:
return
Expand All@@ -422,6 +422,10 @@ def dispatch(self, msg: ParsedMessagePayload) -> None:
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._log = msg.get("log")
parsed_error._details = self._replace_guids_with_channels(
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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@
# stable API.


from typing import Optional
from typing import Any, List, 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[List[str]] = None
super().__init__(message)

@property
Expand DownExpand 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 numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,8 @@
Headers,
HttpCredentials,
ProxySettings,
RemoteAddr,
SecurityDetails,
ServerFilePayload,
StorageState,
)
Expand DownExpand 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 numberDiff line numberDiff 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
2 changes: 2 additions & 0 deletions playwright/_impl/_helper.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,8 @@ class ParsedMessagePayload(TypedDict, total=False):
params: ParsedMessageParams
result: Any
error: ErrorPayload
errorDetails: Any
log: List[str]


class Document(TypedDict):
Expand Down
Loading
Loading