Skip to content

Commit 36d698e

Browse files
authored
feat: make the port of the JS client the only implementation (#3280)
1 parent 4e1ce7a commit 36d698e

354 files changed

Lines changed: 3984 additions & 64422 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 & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -154,29 +154,28 @@ A few rules of thumb that catch most "actually a PORT" cases:
154154

155155
#### PORT
156156

157-
Implement the change in `playwright/_impl/<module>.py`. Use the upstream JS implementation as a reference: `$PW_SRC_DIR/packages/playwright-core/src/client/<module>.ts`. Translate idioms:
157+
Implement the change in `playwright/_impl/<module>.py`, the line-by-line port of `$PW_SRC_DIR/packages/playwright-core/src/client/<module>.ts` (snake_case: `browserContext.ts` is `browser_context.py`). The client change that implements the new API is usually in the same upstream commit as the docs change, or close to it: port it line by line with the rules of [`transform.md`](../../../transform.md), and walk the whole client range as section 14 of that file says (`git -C "$PW_SRC_DIR" log <prev>..<rev> -- packages/playwright-core/src/client packages/isomorphic packages/protocol`). A few reminders:
158158

159159
| Upstream JS | Python |
160160
|---|---|
161-
| `async foo(): Promise<X>` | `async def foo(self) -> X:` |
162161
| `foo(): X` (sync getter, no args, no body) | `@property def foo(self) -> X:` (the generator makes argument-less sync members properties — see `isPythonProperty` in `scripts/codegen/generate_api.js` — and reads `self._impl_obj.foo` without calling it. mypy does not catch a plain method here; the test does.) |
163-
| `await this._channel.foo({ a, b }, this._timeout(options))` | `await self._channel.foo(a=a, b=b, timeout=self._timeout(timeout))`. The generated channel method takes every protocol parameter, with no defaults; an absent option is `None`. The implementation method's own parameters use the Python (snake_case) names, with no defaults. |
162+
| `await this._channel.foo({ a, b }, this._timeout(options))` | `await self._channel.foo(a=a, b=b, timeout=self._timeout(timeout))`. The generated channel method takes every protocol parameter, with no defaults; an absent option is `None`. It sends the message at once and returns an awaitable (`transform.md`, 1.5). The implementation method's own parameters use the Python (snake_case) names, with no defaults. |
164163
| `(await this._channel.foo()).value` | `(await self._channel.foo())["value"]`. The result dict has every top-level field; a field that the driver left out is `None`. |
165164
| `this._channel.on('foo', ({ a }) => ...)` | `self._channel.onFoo(lambda event: ...)`; `event["a"]` is `None` when the driver left the optional field out. |
166-
| `try { ... } catch (e) { if (isTargetClosedError(e)) return; throw e; }` | `try: ...; except Exception as e: if is_target_closed_error(e): return; raise` (import from `playwright._impl._errors`) |
165+
| `try { ... } catch (e) { if (isTargetClosedError(e)) return; throw e; }` | `try: ...; except Exception as e: if is_target_closed_error(e): return; raise e` (import from `playwright._impl.errors`) |
167166
| Inline `[Object]` return like `{endpoint: string}` | A `TypedDict` in `playwright/_impl/_api_structures.py`, which the generator writes. It names the struct from the docs: `` "Name" <[Object]> ``, or an `* alias-python:` line under it. See `RemoteAddr`, `BrowserBindResult`, `DebuggerPausedDetails`. |
168-
| `binary` event/return field | The Python channel layer hands you a base64 string. Decode with `base64.b64decode(value)` before exposing as `bytes`. See `Screencast._dispatch_frame`. |
167+
| `binary` event/return field | The channel layer hands you a base64 string. Decode with `base64.b64decode(value)` before exposing as `bytes`. See the frame event in `screencast.py`. |
169168

170169
When implementing a new ChannelOwner subclass (one constructed by the protocol with `(parent, type, guid, initializer)`):
171-
1. Register it in `playwright/_impl/_object_factory.py:create_remote_object` — otherwise the guid resolves to `DummyObject` and downstream code breaks mysteriously.
170+
1. Register it in `_object_factories()` in `playwright/_impl/connection.py` (the port of the factory table of `connection.ts`); the connection raises for a type it has no factory for.
172171
2. Add it to `playwright/_impl/_api_classes.py` (the import and `__all__`). The generator emits every Python class in the docs, and the generated API imports each implementation from that file. If the class is missing there, `_generated.py` fails to import.
173172

174173
When implementing a non-ChannelOwner wrapper class (a plain class that holds a Page/Context reference, like `Screencast`, `Clock`):
175174
- Add it to `playwright/_impl/_api_classes.py`, as for a ChannelOwner subclass.
176-
- Set `self._loop = parent._loop` and `self._dispatcher_fiber = parent._dispatcher_fiber` in `__init__`. The generated `AsyncBase`/`SyncBase` wrappers read these; missing them gives `AttributeError: 'X' object has no attribute '_loop'` at first use.
175+
- Set `self._loop = parent._loop` and `self._dispatcher_fiber = parent._dispatcher_fiber` in `__init__`, with the `# Python:` comment that the other wrappers have. The generated `AsyncBase`/`SyncBase` wrappers read these; missing them gives `AttributeError: 'X' object has no attribute '_loop'` at first use. A user callback that the new code calls and awaits runs through `run_in_greenlet()` under the sync API (`transform.md`, section 13).
177176

178177
When the docs add a new struct (an inline `[Object]` with named fields):
179-
- The generator writes its TypedDict into `_api_structures.py`. Never edit that file by hand; internal types go in `_internal_structures.py`.
178+
- The generator writes its TypedDict into `_api_structures.py`. Never edit that file by hand; internal types go in `internal_structures.py`.
180179
- It needs a Python name in the docs: a name before the type (`` - `size` "ScreenSize" <[Object]> ``), or an `* alias-python:` line under the type. The generator fails with "No Python name for the struct at …" until one exists. Nested structs need names too.
181180
- Re-export it from both `playwright/async_api/__init__.py` and `playwright/sync_api/__init__.py`: assignment line plus an entry in `__all__`. Same pattern as `ViewportSize`, `RemoteAddr`.
182181

@@ -227,7 +226,7 @@ The script, for `sync` and then `async`:
227226
1. runs `node scripts/codegen/generate_api.js --flavor <flavor>` (reads the docs from `$PW_SRC_DIR`) straight into `playwright/<flavor>_api/_generated.py`,
228227
2. invokes `pre-commit run --files` on the generated file.
229228

230-
It then runs `python scripts/codegen/generate_protocol.py`, which reads `packages/protocol/spec/*.yml` from `$PW_SRC_DIR` and writes the command parameter tables to `playwright/_impl/_protocol.py`, the message types to `playwright/_impl/_protocol_types.py` and the typed channels to `playwright/_impl/_protocol_channels.py`.
229+
It then runs `python scripts/codegen/generate_protocol.py`, which reads `packages/protocol/spec/*.yml` from `$PW_SRC_DIR` and writes the command parameter tables to `playwright/_impl/_protocol.py`, the message types to `playwright/_impl/protocol_types.py`, the typed channels to `playwright/_impl/protocol_channels.py` and the validator to `playwright/_impl/protocol/validator.py`.
231230

232231
Then run `mypy playwright` and work through the errors in `_generated.py` — each one is a call into `_impl/` that the implementation does not support yet.
233232

@@ -260,7 +259,6 @@ Failure modes and fixes:
260259
| `pre-commit` keeps reformatting `_generated.py` on each run | First run after regen always reformats once; rerun until idle. | `pre-commit run --all-files` to settle. |
261260
| A kwarg in `_impl/` that the docs don't mention (e.g. legacy name from a doc rename) | Nothing fails: the generator simply never passes it. | If the param is positional with no default, just rename it in `_impl/`. Check `packages/protocol/spec/*.yml` and the server dispatcher — if the wire field renamed too, also update the channel-send dict key. Only use `extraOptions` for genuinely hidden internal kwargs (`default_browser_type`). |
262261
| `AttributeError: 'X' object has no attribute '_loop'` (or `_dispatcher_fiber`) at first use of a new wrapper class | The non-ChannelOwner wrapper isn't initializing the fields the generated `AsyncBase`/`SyncBase` reads. | In the wrapper's `__init__`, set `self._loop = parent._loop` and `self._dispatcher_fiber = parent._dispatcher_fiber`. |
263-
| `'NoneType' object has no attribute 'get'` after `send_return_as_dict` | Method's protocol response carries no fields and `send_return_as_dict` returned `None`. | `(result or {}).get(...)`. |
264262
| A test fails at teardown with `Protocol violations: X.y: params.z: unknown` (or `missing`, or `expected …`) | The client sends a message that the protocol spec does not allow. The driver would drop an unknown parameter without an error. | Fix the send in `_impl/`: rename the key after a wire rename, or remove a client-side option before the send. |
265263
| Frame/buffer payload arrives as a `str` instead of `bytes` | Protocol `binary` fields cross the wire as base64. | `base64.b64decode(value)` in the impl before exposing. |
266264

@@ -279,7 +277,7 @@ python scripts/check_test_registry.py --report
279277
- **Changed titles**: the checker names the expected name; rename the Python test and port the changed body.
280278
- **Removed identifiers**: delete the Python test (the checker reports it as not in the registry).
281279
- **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.
282-
- 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`).
280+
- 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`).
283281
- For event-info objects, `await message_info.value` (it's an `async` property).
284282
- 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.
285283
- `playwright install chromium` (no `--with-deps`) is sufficient for the test suite under sandbox.
@@ -296,7 +294,7 @@ Check that `$PW_SRC_DIR` is still at the `rev=` commit, with no local changes: t
296294

297295
```sh
298296
pre-commit run --all-files
299-
mypy playwright # 2 pre-existing errors in _json_pipe.py and _artifact.py are unrelated
297+
mypy playwright
300298
pytest --browser chromium tests/async/<files-you-touched> tests/sync/<files-you-touched>
301299
```
302300

@@ -321,7 +319,7 @@ All keys use docs names: `Class.member` or `Class.member.arg`, with the upstream
321319
- **A cluster of rules on the same class is a smell.** If you're about to add five entries for `Foo.*`, you're almost certainly looking at a class that needs to be implemented. Implement the whole thing once and the entries disappear.
322320
- **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.
323321
- **Watch for rename-revert pairs.** 1.59 had `Locator.normalize` → `Locator.toCode` (#39648) → `Locator.normalize` (#39754). Final state wins; only port the last.
324-
- **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 "$PW_SRC_DIR" show <sha> -- packages/protocol/spec/` and the corresponding `*Dispatcher.ts` file. If the wire field changed too, the regenerated `_protocol_channels.py` renames the argument of the channel method, and mypy reports each call in `_impl/` that passes the old name.
322+
- **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 "$PW_SRC_DIR" show <sha> -- packages/protocol/spec/` and the corresponding `*Dispatcher.ts` file. If the wire field changed too, the regenerated `protocol_channels.py` renames the argument of the channel method, and mypy reports each call in `_impl/` that passes the old name.
325323
- **TypedDicts beat `Dict[str, X]` for any structured return.** When the docs describe a return as `[Object]` with named fields, name the struct for the generator, which writes its `TypedDict` into `_api_structures.py`, and re-export it from both public `__init__.py` files. Zero runtime cost (it's still a `dict`), and the docstrings expand its fields from the docs.
326324
- **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.
327325
- **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.

‎.github/workflows/ci.yml‎

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939

4040
build:
4141
name: Build
42-
timeout-minutes: 45
42+
timeout-minutes: 90
4343
strategy:
4444
fail-fast: false
4545
matrix:
@@ -114,52 +114,20 @@ jobs:
114114
run: pytest tests/test_installation.py --browser=${{ matrix.browser }}
115115
- name: Test Sync API
116116
if: matrix.os != 'ubuntu-latest'
117-
run: pytest tests/sync --browser=${{ matrix.browser }} --timeout 90
117+
run: pytest tests/sync --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
118118
- name: Test Sync API
119119
if: matrix.os == 'ubuntu-latest'
120-
run: xvfb-run pytest tests/sync --browser=${{ matrix.browser }} --timeout 90
120+
run: xvfb-run pytest tests/sync --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
121121
- name: Test Async API
122122
if: matrix.os != 'ubuntu-latest'
123-
run: pytest tests/async --browser=${{ matrix.browser }} --timeout 90
123+
run: pytest tests/async --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
124124
- name: Test Async API
125125
if: matrix.os == 'ubuntu-latest'
126-
run: xvfb-run pytest tests/async --browser=${{ matrix.browser }} --timeout 90
127-
128-
build-v2:
129-
# playwright/_impl_v2, the line-by-line port of the JS client, behind PW_IMPL=v2.
130-
# It runs all of tests/sync and tests/async: the older tests, the ports of the
131-
# upstream tests and the Python-only tests of v2.
132-
name: Build (v2)
133-
timeout-minutes: 45
134-
strategy:
135-
fail-fast: false
136-
matrix:
137-
browser: [chromium, firefox, webkit]
138-
runs-on: ubuntu-22.04
139-
env:
140-
PW_IMPL: v2
141-
steps:
142-
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
143-
- name: Set up Python
144-
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
145-
with:
146-
python-version: '3.12'
147-
- name: Install dependencies & browsers
148-
run: |
149-
python -m pip install --upgrade pip
150-
pip install -r local-requirements.txt
151-
pip install -r requirements.txt
152-
pip install -e .
153-
python -m build --wheel
154-
python -m playwright install --with-deps ${{ matrix.browser }}
155-
- name: Test Sync API
156-
run: xvfb-run pytest tests/sync --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
157-
- name: Test Async API
158126
run: xvfb-run pytest tests/async --browser=${{ matrix.browser }} --timeout 90 --timeout-method thread -n auto
159127

160128
test-stable:
161129
name: Stable
162-
timeout-minutes: 45
130+
timeout-minutes: 90
163131
strategy:
164132
fail-fast: false
165133
matrix:
@@ -189,16 +157,16 @@ jobs:
189157
run: pytest tests/common --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
190158
- name: Test Sync API
191159
if: matrix.os != 'ubuntu-latest'
192-
run: pytest tests/sync --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
160+
run: pytest tests/sync --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90 --timeout-method thread -n auto
193161
- name: Test Sync API
194162
if: matrix.os == 'ubuntu-latest'
195-
run: xvfb-run pytest tests/sync --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
163+
run: xvfb-run pytest tests/sync --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90 --timeout-method thread -n auto
196164
- name: Test Async API
197165
if: matrix.os != 'ubuntu-latest'
198-
run: pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
166+
run: pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90 --timeout-method thread -n auto
199167
- name: Test Async API
200168
if: matrix.os == 'ubuntu-latest'
201-
run: xvfb-run pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
169+
run: xvfb-run pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90 --timeout-method thread -n auto
202170

203171
test_examples:
204172
name: Examples

0 commit comments

Comments
 (0)