Guidance for coding agents working in this repository.
Python bindings for Playwright. The Python client talks JSON over a pipe to the Node-based driver bundled in playwright/driver/. The pipe protocol is defined upstream in packages/protocol/spec/*.yml.
playwright/_impl/— hand-written client implementation (one module per object:_browser.py,_page.py,_locator.py,_network.py, etc.). Edit these to add or change behavior. Internal types are in_internal_structures.py.playwright/async_api/_generated.py,playwright/sync_api/_generated.py, and the public TypedDicts inplaywright/_impl/_api_structures.py— auto-generated from the upstream API docs. Never edit by hand; rerun./scripts/update_api.shafter a roll or after changingscripts/codegen/.scripts/codegen/generate_api.js— the generator. A Node.js script that loads the upstream doc parser (utils/doclint/api_parser.js) from$PW_SRC_DIR, filters the docs for Python, and writes the whole public API: signatures, types, docstrings, event overloads, the calls into_impl/, and the TypedDicts. It does not read_impl/.scripts/codegen/python_rules.js— the Python facts that the docs do not contain (positional options, structtotal=, hidden members and options). Policy: the shipped Python API wins; each entry is a candidate for an upstream docs fix, and the generator fails on entries that no longer match anything. Every entry needs a one-line rationale comment above it.playwright/_impl/_protocol.py,_protocol_types.pyand_protocol_channels.py— auto-generated byscripts/codegen/generate_protocol.py(run by./scripts/update_api.sh) from the upstream protocol spec._protocol_channels.pyhas a typed channel class for each interface (PageChannel,FrameChannel, …), like upstream'schannels.d.ts. Each command is an async method with the protocol name and parameters (await self._channel.goto(url=..., waitUntil=..., referer=..., timeout=...)); each event hason<Event>/off<Event>methods. A result or event payload has every top-level field; a field that the driver left out isNone._protocol_types.pyhas the TypedDicts for initializers, results, events and nested types, named as upstream'sutils/generate_channels.jsnames them (PageInitializer,FrameGotoResult,PageRouteEvent, …)._protocol.pyhas the parameters of every command. WithPW_CHECK_PROTOCOL=1,playwright/_impl/_protocol_check.pychecks 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.
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.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.DRIVER_VERSION— the single source of truth for which Playwright release the driver is assembled from (one line, theplaywright-corenpm version, e.g.1.61.0, novprefix). Read bysetup.py,scripts/build_driver.py, and CI. The wheel build downloadsplaywright-coreat 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.NODE_VERSION— the Node.js version bundled with the driver (one line, e.g.24.16.0). Maintained at roll time byscripts/update_node_version.py(latest LTS, mirroring upstream'sutils/build/update-playwright-node.mjs).scripts/build_driver.py— assembles the per-platform driver bundles intodriver/by downloading theplaywright-corenpm package (DRIVER_VERSION) and the official Node.js binaries (NODE_VERSION). Fetchesplaywright-corewithnpm pack(needs Node.js/npm on PATH; honours a root.npmrc) and the Node.js binaries over plain HTTP; invoked fromsetup.py'sbdist_wheelwith the target platform's suffix (no arg builds all six).- The API docs are not shipped in the bundle.
scripts/update_api.shreads them from a separatemicrosoft/playwrightcheckout ($PW_SRC_DIR) on the exact release branch forDRIVER_VERSION, never from a checkout onmainor a feature branch. Needed only when regenerating the API, never at runtime. ROLLING.md,CONTRIBUTING.md— human-facing setup and roll docs.
CONTRIBUTING.md has the full sequence. The short version (needs Node.js and npm for the driver build):
python3 -m venv env && source env/bin/activate
pip install --upgrade pip
pip install -r local-requirements.txt
pip install -e .
python -m build --wheel # downloads playwright-core @ DRIVER_VERSION + Node.js and assembles the driver
pre-commit installIf the system lacks python3-venv, uv venv env is an acceptable substitute (then uv pip install --python env/bin/python --upgrade pip).
- Regenerate
_generated.py:./scripts/update_api.sh(runs codegen + pre-commit on the generated files). - Lint everything:
pre-commit run --all-files. - Type-check:
mypy playwright. - Run tests:
pytest --browser chromium [-k name]. Browsers are installed viaplaywright install chromium(do not use--with-deps, which requires sudo).
The public API comes from the upstream docs. After ./scripts/update_api.sh, run mypy playwright: the generated classes type their _impl_obj, so every new member or option that _impl/ does not implement yet is a mypy error in _generated.py. Fix it in _impl/, or record a justified difference in scripts/codegen/python_rules.js — not by hand-editing _generated.py.
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.
First rule: follow the upstream names and files.
- 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.
- Put the test in the Python file that matches the upstream file:
tests/page/expect-misc.spec.tsbecomestests/async/test_expect_misc.py. If that file does not exist, create it. - Name the test after the upstream title, in snake_case, with the
describetitle in front:test('should support pseudo element')intest.describe('toHaveCSS')becomestest_to_have_css_should_support_pseudo_element. - Add the sync mirror in
tests/sync/, with the same file name and test name.
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).
- Extend before you add: before you write a new test, look for an existing test that covers the same feature or code path. If one exists, add the new edge case or assertion to it. Create a new test only when no existing test is a good fit, or when the new scenario needs a different setup.
- New features: cover the mainstream scenario and the important edge cases only. Do not create matrices of tests for every scenario, option, or aspect.
- Regressions: generalize the provided regression cases into meaningful tests, and stop there. Do not add more tests around them.
This is the recurring high-stakes task. Use the dedicated skill:
→ .claude/skills/playwright-roll/SKILL.md
It documents the full process: the upstream commit-range diff over docs/src/api/, how to classify each commit (PORT / MISMATCH / N/A), how to handle the langs: filter, the recurring failure modes, and the tests/sync-mirroring conventions.
- Never post comments, replies, or reviews on GitHub PRs/issues under my account without my explicit approval. Draft the proposed text and wait for me to approve before sending.
- Don't hand-edit generated files.
- Don't add
# type: ignoreor modify_generated.pyto silence pyright; fix the source of the mismatch. - New public methods on impl classes need a sync test mirror under
tests/sync/. - Keep
scripts/codegen/python_rules.jsminimal — every entry needs a one-line rationale comment above it. Prefer fixing the upstream docs. - Implementation methods take the Python (snake_case) names that the generated API passes. Their options have no default: an option that can be absent is
Optional[X]without= None. So mypy reports each caller that does not pass a new option. - Send messages through the generated channel methods, never by name:
await self._channel.click(selector=selector, ..., clickCount=click_count, timeout=self._timeout(timeout)). Pass every parameter, with the protocol (camelCase) name; an absent option isNone, and the connection drops it. Declare the channel type on each ChannelOwner subclass (_channel: "PageChannel"). Listen to events withself._channel.on<Event>(handler).
Before committing, run mypy playwright and fix errors.
Semantic commit messages: label(scope): description
Labels: fix, feat, chore, docs, test, devops
git checkout -b fix-12345
# ... make changes ...
git add <changed-files>
git commit -m "$(cat <<'EOF'
fix(asyncio): do not deadlock in atexit handler
Fixes: https://github.com/microsoft/playwright-python/issues/12345
EOF
)"
git push origin fix-12345
gh pr create --repo microsoft/playwright-python --head username:fix-12345 \
--title "fix(asyncio): do not deadlock in atexit handler" \
--body "$(cat <<'EOF'
## Summary
- <describe the change very! briefly>
Fixes https://github.com/microsoft/playwright-python/issues/12345
EOF
)"Never add Co-Authored-By agents in commit message.
Never add "Generated with" in commit message.
Never add test plan to PR description. Keep PR description short — a few bullet points at most.
Branch naming for issue fixes: fix-<issue-number>
Never git push without an explicit instruction to push. Applies even when a PR is already open for the branch — additional commits are immediately visible to reviewers. Commit locally, report what was committed, and wait. Only push when the user's message contains "push", "upload", "create PR", "ship it", or equivalent.