Skip to content

Commit 786d689

Browse files
committed
test: port the upstream tests by registry id
Port every test in upstream's tests/registry.md to tests/async and tests/sync, named after its id and title, or list it in tests/upstream_not_ported.txt with the reason. scripts/check_test_registry.py checks this in CI. The older tests move to tests/{async,sync}/legacy. Fix the client bugs that the ports found, so that the same tests pass as with Node.js, and roll the driver to 1.65.0-alpha-2026-10-17. CI runs the tests in parallel with pytest-xdist, on fewer bots. test.sh runs the library tests under xvfb.
1 parent 3e7abc4 commit 786d689

1,179 files changed

Lines changed: 208253 additions & 754 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/playwright-roll/SKILL.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,17 @@ After the script settles, run `pre-commit run --all-files` once more to confirm
282282

283283
### 6. Add tests
284284

285-
For each PORT, port the upstream test that covers it, as an async test and a matching sync test. Conventions:
285+
Copy the upstream registry of the new release, then bring the Python tests in line with it:
286286

287-
- The upstream file decides the Python file: `tests/page/expect-misc.spec.ts` becomes `tests/async/test_expect_misc.py` and `tests/sync/test_expect_misc.py`. Create the file if it does not exist.
288-
- The upstream title decides the test name: the `describe` title and the test title, in snake_case. `test('should support pseudo element')` in `test.describe('toHaveCSS')` becomes `test_to_have_css_should_support_pseudo_element`.
289-
- When no upstream test covers the change, extend an existing test before you add a new one.
287+
```sh
288+
cp "$PW_SRC_DIR/tests/registry.md" tests/upstream_registry.md
289+
python scripts/check_test_registry.py --report
290+
```
291+
292+
- **New identifiers**: port each new upstream test, as an async test and a matching sync test. `python scripts/check_test_registry.py --missing <code or file glob>` prints the file and the expected name of each. The upstream file decides the Python file (`tests/page/expect-misc.spec.ts` becomes `tests/async/page/test_expect_misc.py` and `tests/sync/page/test_expect_misc.py`); the identifier and the title decide the name (see [Tests](../../../AGENTS.md#tests)).
293+
- **Changed titles**: the checker names the expected name; rename the Python test and port the changed body.
294+
- **Removed identifiers**: delete the Python test (the checker reports it as not in the registry).
295+
- **Not ported**: a test that can not exist in Python goes in `tests/upstream_not_ported.txt`, with the reason. The checker fails on a rule that no longer matches a test.
290296
- Use `from playwright.async_api import …`, **not** `from playwright._impl._page import Page` (the impl class doesn't have the public wrappers like `expect_console_message`).
291297
- For event-info objects, `await message_info.value` (it's an `async` property).
292298
- Don't write tests that hang the page (e.g. `page.evaluate(... fetch slow ...)` followed by `page.close()` from a fixture) — the request task gets a `TargetClosedError`. Use `page.on("event", handler)` to capture state at event time instead.
@@ -308,7 +314,7 @@ mypy playwright # 2 pre-existing errors in _json_pipe.py and _ar
308314
pytest --browser chromium tests/async/<files-you-touched> tests/sync/<files-you-touched>
309315
```
310316

311-
Then a smoke regression on a few neighboring suites (`tests/async/test_browser*.py`, `test_cdp_session.py`, `test_tracing.py`, `test_dialog.py`, `test_page_*.py`) to make sure nothing inherent to the port shifted.
317+
Then run `python scripts/check_test_registry.py`, and a smoke regression on a few neighboring suites (for example `tests/async/library/test_browser*.py`, `tests/async/library/chromium/test_session.py`, `tests/async/library/test_tracing.py`, `tests/async/page/test_page_*.py`) to make sure nothing inherent to the port shifted.
312318

313319
## Reference: `python_rules.js` tables
314320

‎.github/workflows/ci.yml‎

Lines changed: 13 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -34,58 +34,30 @@ jobs:
3434
pip install -e .
3535
- name: Lint
3636
run: pre-commit run --show-diff-on-failure --color=always --all-files
37+
- name: Check the tests against the upstream test registry
38+
run: python scripts/check_test_registry.py --report
3739

3840
build:
3941
name: Build
40-
timeout-minutes: 45
42+
timeout-minutes: 120
4143
strategy:
4244
fail-fast: false
4345
matrix:
44-
os: [ubuntu-latest, windows-latest, macos-latest]
45-
python-version: ['3.14']
46+
os: [ubuntu-latest, windows-latest, macos-latest, macos-15-xlarge]
47+
python-version: ['3.12']
4648
browser: [chromium, firefox, webkit]
4749
exclude:
4850
# WebKit on standard macOS-latest (currently macos-15-arm64) is unstable;
4951
# upstream pins paid macos-15-xlarge for cross-browser webkit too.
5052
- os: macos-latest
5153
browser: webkit
5254
include:
53-
- os: windows-latest
54-
python-version: '3.10'
55-
browser: chromium
56-
- os: macos-latest
57-
python-version: '3.10'
58-
browser: chromium
5955
- os: ubuntu-latest
6056
python-version: '3.10'
6157
browser: chromium
62-
- os: macos-15-xlarge
63-
python-version: '3.14'
64-
browser: webkit
65-
- os: windows-latest
66-
python-version: '3.11'
67-
browser: chromium
68-
- os: macos-latest
69-
python-version: '3.11'
70-
browser: chromium
7158
- os: ubuntu-latest
7259
python-version: '3.11'
7360
browser: chromium
74-
- os: windows-latest
75-
python-version: '3.12'
76-
browser: chromium
77-
- os: macos-latest
78-
python-version: '3.12'
79-
browser: chromium
80-
- os: ubuntu-latest
81-
python-version: '3.12'
82-
browser: chromium
83-
- os: windows-latest
84-
python-version: '3.13'
85-
browser: chromium
86-
- os: macos-latest
87-
python-version: '3.13'
88-
browser: chromium
8961
- os: ubuntu-latest
9062
python-version: '3.13'
9163
browser: chromium
@@ -112,59 +84,18 @@ jobs:
11284
run: pytest tests/test_installation.py --browser=${{ matrix.browser }}
11385
- name: Test Sync API
11486
if: matrix.os != 'ubuntu-latest'
115-
run: pytest tests/sync --browser=${{ matrix.browser }} --timeout 90
116-
- name: Test Sync API
117-
if: matrix.os == 'ubuntu-latest'
118-
run: xvfb-run pytest tests/sync --browser=${{ matrix.browser }} --timeout 90
119-
- name: Test Async API
120-
if: matrix.os != 'ubuntu-latest'
121-
run: pytest tests/async --browser=${{ matrix.browser }} --timeout 90
122-
- name: Test Async API
123-
if: matrix.os == 'ubuntu-latest'
124-
run: xvfb-run pytest tests/async --browser=${{ matrix.browser }} --timeout 90
125-
126-
test-stable:
127-
name: Stable
128-
timeout-minutes: 45
129-
strategy:
130-
fail-fast: false
131-
matrix:
132-
os: [ubuntu-latest, windows-latest, macos-latest]
133-
browser-channel: [chrome]
134-
include:
135-
- os: windows-latest
136-
browser-channel: msedge
137-
- os: macos-latest
138-
browser-channel: msedge
139-
runs-on: ${{ matrix.os }}
140-
steps:
141-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
142-
- name: Set up Python
143-
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
144-
with:
145-
python-version: "3.10"
146-
- name: Install dependencies & browsers
147-
run: |
148-
python -m pip install --upgrade pip
149-
pip install -r local-requirements.txt
150-
pip install -r requirements.txt
151-
pip install -e .
152-
python -m build --wheel
153-
python -m playwright install ${{ matrix.browser-channel }} --with-deps
154-
- name: Common Tests
155-
run: pytest tests/common --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
156-
- name: Test Sync API
157-
if: matrix.os != 'ubuntu-latest'
158-
run: pytest tests/sync --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
87+
run: pytest tests/sync --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
15988
- name: Test Sync API
16089
if: matrix.os == 'ubuntu-latest'
161-
run: xvfb-run pytest tests/sync --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
90+
run: xvfb-run pytest tests/sync --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
16291
- name: Test Async API
163-
if: matrix.os != 'ubuntu-latest'
164-
run: pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
92+
# Run it after a failure of the sync tests too, to see the failures of both.
93+
if: ${{ !cancelled() && matrix.os != 'ubuntu-latest' }}
94+
run: pytest tests/async --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
16595
- name: Test Async API
166-
if: matrix.os == 'ubuntu-latest'
167-
run: xvfb-run pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
96+
# Run it after a failure of the sync tests too, to see the failures of both.
97+
if: ${{ !cancelled() && matrix.os == 'ubuntu-latest' }}
98+
run: xvfb-run pytest tests/async --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
16899

169100
test_examples:
170101
name: Examples

‎.pre-commit-config.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# See https://pre-commit.com for more information
22
# See https://pre-commit.com/hooks.html for more hooks
3+
# The test assets are copied from upstream byte for byte; the hooks must not change them.
4+
exclude: ^tests/assets/
35
repos:
46
- repo: https://github.com/pre-commit/pre-commit-hooks
57
rev: v5.0.0
68
hooks:
79
- id: trailing-whitespace
810
- id: end-of-file-fixer
9-
exclude: tests/assets/har-sha1-main-response.txt
1011
- id: check-yaml
1112
- id: check-toml
1213
- id: requirements-txt-fixer

‎AGENTS.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Python bindings for [Playwright](https://playwright.dev). The Python client talk
1717
- `_protocol_types.py` has the TypedDicts for initializers, results, events and nested types, named as upstream's `utils/generate_channels.js` names them (`PageInitializer`, `FrameGotoResult`, `PageRouteEvent`, …).
1818
- `_protocol.py` has the parameters of every command. With `PW_CHECK_PROTOCOL=1`, `playwright/_impl/_protocol_check.py` checks each message that the client sends against it. The test suite sets this, and fails a test that sends an unknown command, an unknown or missing parameter, or a value of the wrong type.
1919
- `scripts/codegen/compare_api.py` — compares two generated files (signatures, annotations, call bodies, docstrings) and lists every difference. Use it to check that a generator change does not change the API.
20-
- `tests/async/`, `tests/sync/` — pytest suites, ported from the upstream tests. Most new tests are added to the async file with a sync mirror. See [Tests](#tests).
20+
- `tests/async/`, `tests/sync/` — pytest suites, ported test for test from the upstream tests, which `tests/upstream_registry.md` lists by identifier. See [Tests](#tests).
2121
- `DRIVER_VERSION` — the single source of truth for which Playwright release the driver is assembled from (one line, the `playwright-core` npm version, e.g. `1.61.0`, no `v` prefix). Read by `setup.py`, `scripts/build_driver.py`, and CI. The wheel build downloads `playwright-core` at this version from npm plus the matching Node.js binary and assembles the per-platform bundles — no source build. The version is baked into the staged bundle filenames (`driver/playwright-<version>-<suffix>.zip`), so it doubles as the build cache key.
2222
- `NODE_VERSION` — the Node.js version bundled with the driver (one line, e.g. `24.16.0`). Maintained at roll time by `scripts/update_node_version.py` (latest LTS, mirroring upstream's `utils/build/update-playwright-node.mjs`).
2323
- `scripts/build_driver.py` — assembles the per-platform driver bundles into `driver/` by downloading the `playwright-core` npm package (`DRIVER_VERSION`) and the official Node.js binaries (`NODE_VERSION`). Fetches `playwright-core` with `npm pack` (needs Node.js/npm on PATH; honours a root `.npmrc`) and the Node.js binaries over plain HTTP; invoked from `setup.py`'s `bdist_wheel` with the target platform's suffix (no arg builds all six).
@@ -50,14 +50,16 @@ The public API comes from the upstream docs. After `./scripts/update_api.sh`, ru
5050

5151
## Tests
5252

53-
The tests are a port of the upstream tests in `microsoft/playwright` (`tests/page/`, `tests/library/`). Keep them close to upstream, so that each upstream test has one place in this repo.
53+
The tests are a port of the upstream tests in `microsoft/playwright` (`tests/page/`, `tests/library/`), test for test. Upstream gives each test an identifier, the first token of its title: `it('CH-001 should check the box', ...)`. `tests/upstream_registry.md` is a copy of upstream's `tests/registry.md`, which lists every identifier with its file and title.
5454

55-
**First rule: follow the upstream names and files.**
55+
**First rule: every upstream test is ported, or listed as not ported.**
5656

57-
- When a change needs a test, find the upstream test for the same behavior and port it. Do not write a new test when an upstream test exists.
58-
- Put the test in the Python file that matches the upstream file: `tests/page/expect-misc.spec.ts` becomes `tests/async/test_expect_misc.py`. If that file does not exist, create it.
59-
- Name the test after the upstream title, in snake_case, with the `describe` title in front: `test('should support pseudo element')` in `test.describe('toHaveCSS')` becomes `test_to_have_css_should_support_pseudo_element`.
60-
- Add the sync mirror in `tests/sync/`, with the same file name and test name.
57+
- Port each upstream test twice, to `tests/async/` and to `tests/sync/`, in the file that matches the upstream file: `tests/page/page-check.spec.ts` becomes `tests/async/page/test_page_check.py` and `tests/sync/page/test_page_check.py`; `tests/library/chromium/oopif.spec.ts` becomes `tests/async/library/chromium/test_oopif.py`.
58+
- Name the test `test_<ID>_<title>`: the identifier with `_`, then the `describe` titles and the title in snake_case. `test('EX-181 toHaveCount pass')` in `test.describe('toHaveCount')` becomes `test_EX_181_to_have_count_to_have_count_pass`. A test that a loop declares several times is one function with `@pytest.mark.parametrize`.
59+
- Port faithfully: the same steps, assertions and skip conditions. A test that fails because of a bug in the Python client stays, marked `@pytest.mark.xfail(reason="python: ...", strict=True)`.
60+
- A test that can not exist in Python (a JavaScript-only API, Node.js client internals, tools that run in the driver, `@playwright/test` features) goes in `tests/upstream_not_ported.txt`, with the reason.
61+
- `python scripts/check_test_registry.py` checks all of this. `--missing <code or file glob>` prints the expected names of the tests still to port; `--report` prints the coverage per subsystem.
62+
- Do not write a new test when an upstream test exists. `tests/async/legacy/` and `tests/sync/legacy/` hold older ports named after old upstream titles; do not add tests there.
6163

6264
**Second rule: keep the number of tests small.** This applies to every test, and most of all to Python-only behavior that has no upstream test (the sync API, asyncio, the driver).
6365

‎DRIVER_VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.65.0-alpha-1792021904000
1+
1.65.0-alpha-2026-10-17

‎local-requirements.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pytest==9.0.3
1111
pytest-asyncio==1.4.0
1212
pytest-rerunfailures==16.7
1313
pytest-timeout==2.4.0
14+
pytest-xdist==3.8.0
1415
PyYAML==6.0.3
1516
service_identity==24.2.0
1617
twisted==25.5.0

‎playwright/_impl/_artifact.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def __init__(
3737
async def path_after_finished(self) -> pathlib.Path:
3838
if self._connection.is_remote:
3939
raise Error(
40-
"Path is not available when using browser_type.connect(). Use save_as() to save a local copy."
40+
"Path is not available when connecting remotely. Use save_as() to save a local copy."
4141
)
4242
path = (await self._channel.pathAfterFinished())["value"]
4343
return pathlib.Path(path)

‎playwright/_impl/_assertions.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import collections.abc
16+
import re
1617
from contextlib import contextmanager
1718
from typing import Any, Iterator, List, Literal, Optional, Pattern, Sequence, Union
1819
from urllib.parse import urljoin
@@ -29,7 +30,6 @@
2930
from playwright._impl._locator import Locator
3031
from playwright._impl._mime_type import is_textual_mime_type
3132
from playwright._impl._page import Page
32-
from playwright._impl._serializers import parse_value
3333
from playwright._impl._str_utils import escape_regex_flags
3434

3535
_soft_errors: Optional[List[AssertionError]] = None
@@ -60,6 +60,17 @@ def _record_soft_or_raise(error: AssertionError, is_soft: bool) -> None:
6060
raise error
6161

6262

63+
# Port of the checks in toContainClass() in packages/playwright/src/matchers/matchers.ts.
64+
def _check_contain_class_expected(expected: Any) -> None:
65+
if isinstance(expected, collections.abc.Sequence) and not isinstance(expected, str):
66+
if any(isinstance(e, re.Pattern) for e in expected):
67+
raise Error(
68+
'"expected" argument in to_contain_class cannot contain RegExp values'
69+
)
70+
elif isinstance(expected, re.Pattern):
71+
raise Error('"expected" argument in to_contain_class cannot be a RegExp value')
72+
73+
6374
class AssertionsBase:
6475
def __init__(
6576
self,
@@ -108,10 +119,14 @@ async def _expect_impl(
108119
aria_snapshot = None
109120
if isinstance(received, dict):
110121
aria_snapshot = received.get("ariaSnapshot")
111-
value = received.get("value")
112-
actual = parse_value(value) if value is not None else None
122+
# Frame._expect() has already parsed the received value.
123+
actual = received.get("value")
113124
else:
114125
actual = received
126+
if expression == "to.match.aria" and isinstance(actual, dict):
127+
# The driver sends {raw, regex}; toMatchAriaSnapshot() in
128+
# matchers/toMatchAriaSnapshot.ts prints the raw snapshot.
129+
actual = actual.get("raw")
115130
if self._custom_message:
116131
out_message = self._custom_message
117132
if expected is not None:
@@ -438,6 +453,7 @@ async def to_contain_class(
438453
timeout: Optional[float],
439454
) -> None:
440455
__tracebackhide__ = True
456+
_check_contain_class_expected(expected)
441457
if isinstance(expected, collections.abc.Sequence) and not isinstance(
442458
expected, str
443459
):

‎playwright/_impl/_async_base.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from types import TracebackType
1818
from typing import Any, Callable, Generic, Optional, Type, TypeVar, Union
1919

20+
from playwright._impl._connection import event_future_error
2021
from playwright._impl._impl_to_api_mapping import ImplToApiMapping, ImplWrapper
2122

2223
mapping = ImplToApiMapping()
@@ -32,7 +33,14 @@ def __init__(self, future: "asyncio.Future[T]") -> None:
3233

3334
@property
3435
async def value(self) -> T:
35-
return mapping.from_maybe_impl(await self._future)
36+
try:
37+
result = await self._future
38+
except Exception as error:
39+
rewritten = event_future_error(self._future, error)
40+
if rewritten is error:
41+
raise
42+
raise rewritten from None
43+
return mapping.from_maybe_impl(result)
3644

3745
def _cancel(self) -> None:
3846
self._future.cancel()

0 commit comments

Comments
 (0)