diff --git a/.azure-pipelines/publish-docker.yml b/.azure-pipelines/publish-docker.yml index c3bba857d..49a02c95b 100644 --- a/.azure-pipelines/publish-docker.yml +++ b/.azure-pipelines/publish-docker.yml @@ -82,7 +82,7 @@ extends: echo '{ "data-root": "/mnt/docker" }' | sudo tee /etc/docker/daemon.json sudo service docker start - # scripts/build_driver.py fetches playwright-core with `npm pack`, which + # The wheel build builds the driver from .upstream/ with `npm ci`, which # picks up the registry and credentials from this .npmrc. - task: Bash@3 displayName: "setup .npmrc" @@ -102,6 +102,9 @@ extends: pip install -e . --disable-pip-version-check displayName: "Install dependencies" + - script: python scripts/upstream.py checkout + displayName: "Get Playwright" + - task: AzureCLI@2 displayName: "Login to ACR via OIDC" inputs: diff --git a/.claude/skills/playwright-release/SKILL.md b/.claude/skills/playwright-release/SKILL.md index c0d05e58e..e20060107 100644 --- a/.claude/skills/playwright-release/SKILL.md +++ b/.claude/skills/playwright-release/SKILL.md @@ -6,8 +6,9 @@ description: Prepare a Playwright Python release. Checks open bugs, adds Python- # Preparing a Playwright Python release In this skill, `1.xx` is the release (for example `1.64`), and `v1.xx.0` is its tag. -`$PW_SRC_DIR` is the roll checkout of `microsoft/playwright` (see the -`playwright-roll` skill, step 2). +`.upstream/` is the checkout of `microsoft/playwright` at the commit in +`DRIVER_VERSION` (`python scripts/upstream.py checkout`). Only read from it: do not +commit or switch branches there. Remotes: find the remote for `microsoft/playwright-python` with `git remote -v`. The commands below call it `upstream`. @@ -31,24 +32,35 @@ gh issue list --repo microsoft/playwright-python --state open \ If any are open, list them for the user and ask whether to continue. Do not close or comment on issues. -Check that the driver is rolled. `DRIVER_VERSION` (`npm=`) must be the driver -for the release: a `1.xx.y` build, or a `1.xx.0-beta-…` build from `release-1.xx`, -as the user says, and `rev=` the commit that it is built from. If it is not, roll with the `playwright-roll` skill. This is often -already done. The roll commit goes into the release commit PR (step 3). When the -API roll for 1.xx is already on `main`, that PR only moves `DRIVER_VERSION`. +Check that the driver is rolled. `DRIVER_VERSION` must be a commit of +`release-1.xx`: the tag `v1.xx.y`, or the tip of the branch, as the user says. The +driver reports the version of `playwright-core` at that commit, so it must be +`1.xx.y`: + +```sh +python scripts/upstream.py checkout +git -C .upstream fetch origin release-1.xx +git -C .upstream merge-base --is-ancestor "$(cat DRIVER_VERSION)" FETCH_HEAD && echo "on release-1.xx" +git -C .upstream show "$(cat DRIVER_VERSION)":packages/playwright-core/package.json | grep '"version"' +``` + +If it is not, roll with the `playwright-roll` skill (`python scripts/upstream.py pin v1.xx.y`, +or `pin release-1.xx`). This is often already done. The roll commit goes into the +release commit PR (step 3). When the API roll for 1.xx is already on `main`, that PR +only moves `DRIVER_VERSION`. ## 2. Release notes (microsoft/playwright) The Python notes in `docs/src/release-notes-python.md` are written in `microsoft/playwright` together with the Node.js notes. The `playwright-release-notes` skill there -(`$PW_SRC_DIR/.claude/skills/playwright-release-notes/SKILL.md`) covers that, +(`.claude/skills/playwright-release-notes/SKILL.md` on `main`) covers that, including the Python code style. Check that the `## Version 1.xx` section is on the release branch: ```sh -git -C "$PW_SRC_DIR" fetch origin release-1.xx -git -C "$PW_SRC_DIR" show FETCH_HEAD:docs/src/release-notes-python.md | grep -n '^## Version 1.xx$' +git -C .upstream fetch origin release-1.xx +git -C .upstream show FETCH_HEAD:docs/src/release-notes-python.md | grep -n '^## Version 1.xx$' ``` If it is missing, tell the user: it should have landed with the Node.js notes. @@ -66,11 +78,13 @@ different exception type. Leave out minor bug fixes, internal changes, and anyth the notes already cover. If nothing is significant, go to step 3. Otherwise list the changes for the user and ask whether to add them to the notes. -To add them, write them in the roll checkout, on a branch from `origin/main`: +To add them, write them in a worktree of `.upstream/` next to this repository, on a +branch from `origin/main`. `.upstream/` itself stays at the pin. ```sh -git -C "$PW_SRC_DIR" fetch origin main -git -C "$PW_SRC_DIR" checkout -b release-notes-1.xx-python origin/main +notes="$(dirname "$PWD")/playwright-release-notes-1.xx" +git -C .upstream fetch origin main +git -C .upstream worktree add -b release-notes-1.xx-python "$notes" origin/main ``` Commit, push to the user's fork of `microsoft/playwright`, and open the PR. If @@ -78,8 +92,8 @@ the checkout has no remote for the fork, add one. `gh api user --jq .login` gives the user's login. ```sh -git -C "$PW_SRC_DIR" commit -am "docs: release notes for v1.xx Python" -git -C "$PW_SRC_DIR" push release-notes-1.xx-python +git -C "$notes" commit -am "docs: release notes for v1.xx Python" +git -C "$notes" push release-notes-1.xx-python gh pr create --repo microsoft/playwright --head :release-notes-1.xx-python \ --title "docs: release notes for v1.xx Python" \ --body "- Update the \`Version 1.xx\` Python release notes with upstream and Python-specific changes" @@ -88,12 +102,10 @@ gh pr create --repo microsoft/playwright --head :release-notes-1.xx-pytho After the PR merges, it needs a cherry-pick to `release-1.xx` (the `playwright-cherry-pick` skill in `microsoft/playwright`). -Then put the roll checkout back on the release branch. `./scripts/update_api.sh` -reads the docs from it. Run the checkout checks from step 2 of the -`playwright-roll` skill. +Then remove the worktree: ```sh -git -C "$PW_SRC_DIR" checkout release-1.xx +git -C .upstream worktree remove "$notes" ``` ## 3. Release commit (playwright-python) @@ -101,7 +113,8 @@ git -C "$PW_SRC_DIR" checkout release-1.xx Open a PR from the branch with the roll commit: - Title: `chore: roll Playwright to v1.xx.y`, where `v1.xx.y` is the Python - release version, even when `DRIVER_VERSION` is a beta build. + release version, even when `DRIVER_VERSION` is the tip of `release-1.xx` and not + a tag. - Description: the Python release notes, in GitHub markdown (see below). The user copies this description into the GitHub release, so for this PR it replaces the "keep PR descriptions short" rule in `AGENTS.md`. diff --git a/.claude/skills/playwright-roll/SKILL.md b/.claude/skills/playwright-roll/SKILL.md index 8886026e2..faf289ef2 100644 --- a/.claude/skills/playwright-roll/SKILL.md +++ b/.claude/skills/playwright-roll/SKILL.md @@ -1,328 +1,214 @@ --- name: playwright-roll -description: Roll Playwright Python to a new driver version. Walks the upstream `docs/src/api/` commit range, regenerates the public API from the docs, ports each change into `_impl/`, records justified differences in `scripts/codegen/python_rules.js`, and adds tests. +description: Roll Playwright Python to a new microsoft/playwright commit. Pins the commit in DRIVER_VERSION, builds the driver and generates the API from .upstream/, and ports the upstream diff of the client and the tests line by line. --- # Rolling Playwright Python -The goal of a roll is to move the driver pin in `DRIVER_VERSION` to a new release, port every public API change introduced upstream during that interval, and suppress the rest, so that `./scripts/update_api.sh` and `mypy playwright` run clean and the test suite still passes. +`DRIVER_VERSION` holds one `microsoft/playwright` commit. `.upstream/` (gitignored) is +a full clone of `microsoft/playwright` at that commit. Everything comes from it: -The previous human-facing summary lives in `../../../ROLLING.md`. This skill is the operational playbook — read it end to end before starting. +- the driver: `python -m build --wheel` builds it from `.upstream/`; +- the generated files: `./scripts/update_api.sh` writes the API and the protocol files, + and copies the test registry; +- the hand-written port: `playwright/_impl/` ports the client + (`packages/playwright-core/src/client` and the parts of `packages/isomorphic`, + `packages/protocol/src` and `packages/utils` that it uses), and `tests/async/`, + `tests/sync/` port `tests/page/` and `tests/library/`. -## Mental model +A roll moves `DRIVER_VERSION` from the old commit to the new one, and ports the diff +between the two. Do not commit, branch or edit in `.upstream/`. -The Python port is hand-written code in `playwright/_impl/`, plus a generator (`scripts/codegen/generate_api.js`) that: +## 0. Set up -1. parses the upstream docs (`$PW_SRC_DIR/docs/src/api`) with the upstream parser and filters them for Python, -2. applies the Python facts that the docs do not contain, from `scripts/codegen/python_rules.js`, and -3. emits the whole public API into `playwright/{async,sync}_api/_generated.py`: signatures, types, docstrings, event overloads, and the call into `_impl/` for every member. +Follow steps 1 and 2 of `CONTRIBUTING.md`: the venv, the dependencies, and +`python scripts/upstream.py checkout`. Activate the venv before every `pip`, `pytest` +or `pre-commit`. In the agent harness, `sudo` is denied: if `python3-venv` is +missing, use `uv venv env`, and install browsers without `--with-deps`. -The generator never reads `_impl/`. A new member or option in the docs therefore shows up in `_generated.py` right away, as a call into `_impl/` that does not exist yet. Each generated class types its `_impl_obj`, so **`mypy playwright` lists every such gap** as an error in `_generated.py`. Three resolutions: - -- **PORT** — the new API is intended for Python (no `langs.only` filter, or `langs.only` includes `"python"`). Implement it in `_impl/` until mypy is clean. -- **MISMATCH** — the shipped Python API differs from the docs (a callback signature uses unions, a kwarg uses a legacy name, etc.). The shipped implementation wins. Add an entry to `scripts/codegen/python_rules.js` with a comment explaining *why*, and propose the upstream docs change that would make the entry unnecessary. -- **N/A** — the commit only touches docs, has `* langs: js` (or any other filter that excludes Python), is server-side, Electron-only, or was reverted later in the same release. No action; the generator already leaves it out. - -The upstream documentation source of truth is `docs/src/api/*.md` in the playwright repo. Every `## method:` / `## property:` / `## event:` / `### option:` / `### param:` block has an optional `* langs: js` (or `js, python`, etc.) filter. The Python doclint resolves these into `langs` fields on each member of `api.json`. **An empty `langs: {}` means "all languages including Python" — *implement it*, don't suppress it.** - -> **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). - -## Process - -### 1. Set up the env - -`CONTRIBUTING.md` covers this. Notes from past rolls: - -- The repo requires Python 3.10+. If `python3.10` isn't available, use `python3` (3.12 is fine). -- If `python3-venv` is missing system-wide, use `uv venv env` instead, then `uv pip install --python env/bin/python --upgrade pip`. Don't try to `apt install` — sudo is denied in the harness. -- Always activate the venv before any `pip`, `pytest`, `mypy`, or `pre-commit` invocation. - -### 2. Bump the driver pin, download it, and generate api.json - -`DRIVER_VERSION` has two lines: `npm=`, the `playwright-core` npm version -(a release such as `1.62.0`, a beta, or a nightly such as -`1.65.0-alpha-2026-10-19`), and `rev=`, the `microsoft/playwright` commit that -the package is built from. npm records that commit: +## 1. Pin ```sh -npm view playwright-core@ gitHead +python scripts/upstream.py pin # a branch (main, release-1.66), a tag (v1.66.0) or a commit +python scripts/update_node_version.py # NODE_VERSION: the current Node.js LTS +old=$(git show HEAD:DRIVER_VERSION) +new=$(cat DRIVER_VERSION) ``` -The docs walk and codegen read the upstream docs from a `microsoft/playwright` -checkout at `$PW_SRC_DIR`. **Use a separate checkout that exists only for -rolls, at exactly the `rev=` commit.** Never point `PW_SRC_DIR` at a checkout -that someone works in: on any other commit the docs differ, and the generator -silently emits that API instead of the driver's. `./scripts/update_api.sh` -refuses to run when the checkout is not at `rev=` or has local changes. +`pin` writes the commit to `DRIVER_VERSION` and checks it out in `.upstream/`. The +driver reports the version of `packages/playwright-core/package.json` at that commit: +`1.66.0` on a release tag, `1.67.0-next` on `main`. -```sh -export PW_SRC_DIR=../playwright-for-python # the roll checkout, nothing else -# First roll on this machine: clone it (no npm install needed). -git clone https://github.com/microsoft/playwright.git "$PW_SRC_DIR" -# Every roll: move it to the new rev. -git -C "$PW_SRC_DIR" fetch origin -git -C "$PW_SRC_DIR" checkout --detach -``` - -Then bump the pins and assemble the driver: +## 2. Build ```sh -# Edit DRIVER_VERSION (repo root), for example: -# npm=1.62.0 -# rev= -python scripts/update_node_version.py # refresh NODE_VERSION to the current LTS - -source env/bin/activate -python -m build --wheel # downloads playwright-core @ DRIVER_VERSION + Node.js, assembles the driver -playwright install chromium # NOT --with-deps; sudo is denied - -# The walk below inspects `langs` from api.json. Codegen does not need it. -node "$PW_SRC_DIR/utils/doclint/generateApiJson.js" > /tmp/api.json +python -m build --wheel # the driver from .upstream/, and the wheel +playwright install chromium ``` -The wheel build just downloads the `playwright-core` npm package at -`DRIVER_VERSION` and the matching Node.js binary (no source build, no Node/npm/git -toolchain), and unpacks the driver under `playwright/driver/`. The docs are the -one piece not shipped in the bundle — `./scripts/update_api.sh` reads them from -`$PW_SRC_DIR`. `/tmp/api.json` is only a convenient dump of the same docs for -the walk. - -### 3. Identify the commit range - -The range is every commit between the old `rev=` (in `DRIVER_VERSION` before the -bump) and the new one. Save the commit list, oldest first, scoped to -`docs/src/api/`: +## 3. Generate ```sh -git -C "$PW_SRC_DIR" log .. --oneline --reverse -- docs/src/api > /tmp/roll--commits.md +./scripts/update_api.sh +mypy playwright ``` -If the old `DRIVER_VERSION` has no `rev=` line (rolls before the two-line -format), take the old commit from npm: `npm view playwright-core@ -gitHead`. A release-to-release roll yields 50–100 commits; a nightly-to-nightly -roll a handful. If you see thousands, the range is wrong. - -Format the file as a markdown checklist and add the standard preamble (status legend, where to look up `api.json` etc.) — see the file from the 1.58→1.59 roll for the template. +The API generator (`scripts/codegen/generate_api.js`) reads the docs in +`.upstream/docs/src/api`, keeps what is for Python, applies +`scripts/codegen/python_rules.js`, and writes every member with its call into +`_impl/`. It never reads `_impl/`. So a new member or option in the docs is a mypy +error in `_generated.py`, and a new parameter in the protocol spec is a mypy error at +the channel call in `_impl/`: each `ChannelOwner` subclass declares its channel type +(`_channel: "PageChannel"`), so mypy checks every call. Step 4 fixes them. Until then, +the client may fail to talk to the new driver. -### 4. Walk the commit list - -For each commit, in chronological order: +To see what changed in the public API: ```sh -git -C "$PW_SRC_DIR" show -- docs/src/api/ -``` - -Look for: -- `## (async )?method:` / `## property:` / `## event:` additions or removals; -- `* langs: ...` lines on those blocks; -- `### param:` / `### option:` additions or removals; -- new `class-X.md` files (whole new classes — usually `langs: js`); -- type changes in `- returns:` lines. - -Classify and act. - -#### Verifying classifications (do this before suppressing anything) - -Before tagging anything as MISMATCH or N/A based on appearance, dump the actual `langs` from `api.json`: - -```python -import json -data = json.load(open("/tmp/api.json")) -classes = {c["name"]: c for c in data} -for cls_name in ["Page", "BrowserContext", "Screencast", "Debugger"]: - cls = classes.get(cls_name) - if not cls: - continue - print(f"\n{cls_name}: cls_langs={cls.get('langs', {})}") - for m in cls["members"]: - print(f" {m['name']} kind={m.get('kind')} langs={m.get('langs', {})}") +git show HEAD:playwright/async_api/_generated.py > /tmp/old.py +python scripts/codegen/compare_api.py /tmp/old.py playwright/async_api/_generated.py --show all ``` -For options/params nested inside an `Object`-typed arg, walk one level deeper: +## 4. Port -```python -for a in member.get("args", []): - if a["name"] == "options": - for prop in a.get("type", {}).get("properties", []): - print(prop["name"], prop.get("langs", {})) +```sh +python scripts/upstream.py changes ``` -A few rules of thumb that catch most "actually a PORT" cases: -- If the *containing class* has empty `langs: {}` and the *member* has empty `langs: {}`, it's for Python — implement it. -- If the member is empty but a single *option* has `langs: js`, the method is for Python and you only skip that option (e.g. `Screencast.start.size` is `langs: js` while `Screencast.start` itself isn't). -- If you're about to add three or more `Method not implemented:` entries for the same class, stop — you almost certainly need to implement the class. - -#### PORT - -Implement the change in `playwright/_impl/.py`, the line-by-line port of `$PW_SRC_DIR/packages/playwright-core/src/client/.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 [`playwright/_impl/transform.md`](../../../playwright/_impl/transform.md), and walk the whole client range as section 14 of that file says (`git -C "$PW_SRC_DIR" log .. -- packages/playwright-core/src/client packages/isomorphic packages/protocol`). A few reminders: - -| Upstream JS | Python | -|---|---| -| `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.) | -| `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. | -| `(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`. | -| `this._channel.on('foo', ({ a }) => ...)` | `self._channel.onFoo(lambda event: ...)`; `event["a"]` is `None` when the driver left the optional field out. | -| `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`) | -| 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`. | -| `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`. | - -When implementing a new ChannelOwner subclass (one constructed by the protocol with `(parent, type, guid, initializer)`): -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. -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. - -When implementing a non-ChannelOwner wrapper class (a plain class that holds a Page/Context reference, like `Screencast`, `Clock`): -- Add it to `playwright/_impl/_api_classes.py`, as for a ChannelOwner subclass. -- 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). - -When the docs add a new struct (an inline `[Object]` with named fields): -- The generator writes its TypedDict into `_api_structures.py`. Never edit that file by hand; internal types go in `internal_structures.py`. -- 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. -- 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`. - -If the new API was previously recorded in `python_rules.js` (for example in `extraMembers`), **remove that entry** when implementing it. The generator fails on entries that no longer match anything, so stale entries cannot pile up. - -If a doc rename involves a *positional* parameter (no default, before any `*`), users almost certainly call it positionally — you can rename freely. The 1.59 `BrowserType.connect.wsEndpoint` → `endpoint` is the canonical example. Don't suppress this kind of rename; just rename in `_impl/`. **Important corollary:** when docs rename a param, the wire-protocol field usually also changed in `packages/protocol/spec/*.yml` and the server-side dispatcher in `packages/playwright-core/src/server/dispatchers/*Dispatcher.ts`. If so, you must also update the channel-send dict key (e.g. `{"wsEndpoint": …}` → `{"endpoint": …}`). Keeping the old name in Python for a renamed param hides a wire-protocol bug. - -#### MISMATCH +`changes` lists each upstream file that changed between `$old` and `$new`, in groups, +with the Python file to port it to. The files that upstream generates from the +protocol spec (`channels.d.ts`, `validator.ts`) are in the "Generated" group with the +docs and the spec: step 3 regenerated their Python counterparts. A spec file that +`tests/upstream_not_ported.txt` lists shows its reason instead of a Python file. For +each other file, read the diff and port each hunk, in order: -A MISMATCH is a divergence between the docs and the shipped Python surface. The shipped implementation wins. Use it sparingly — most apparent mismatches turn out to be PORTs you skipped. Fix a type, a name or a position in the upstream docs: declare the param or option for Python only, with `* langs: python`, and limit the shared declaration to the other languages. Example from `class-page.md`, where Python accepts a callback with one or two arguments: - -```md -### param: Page.route.handler -* since: v1.8 -* langs: python -- `handler` <[function]\([Route]\)|[function]\([Route], [Request]\)> +```sh +git -C .upstream diff "$old" "$new" -- ``` -`scripts/codegen/python_rules.js` only holds what the docs cannot say, for example hidden internal kwargs (`extraOptions`: `Browser.new_context(default_browser_type=)`). Add an entry to the right table, with a one-line `//` comment above it explaining *why*. Keys use docs names (`Class.member.arg`, camelCase). - -The generator fails with "the entry … no longer matches anything; remove it" when an upstream change makes an entry unnecessary. Delete that entry. - -**Do not suppress** these — they're PORTs in disguise: - -- "Internal tooling" classes/methods whose `langs` field is empty (`Screencast.*`, `Debugger.*`, `Page.pick_locator`, `BrowserContext.debugger`, `Browser.bind/unbind`, `Page.{clear,console}_*`). The 1.59 roll suppressed all of these initially, then had to walk every one back. Verify `langs` first. -- A renamed positional parameter (mypy: unexpected keyword argument `newName` for the impl method). Just rename in `_impl/`; if the wire name changed too, mypy reports the generated channel call. -- A struct return the generator cannot name ("No Python name for the struct at …"). Name it, so that the generator writes a TypedDict — don't type it `Dict`. - -#### N/A - -Common N/A flavors: -- Whole new class with `langs: js` (Inspector/Screencast, Debugger, Overlay). -- Members with `langs: js` (most "tooling" / MCP / agentic features). -- Doc-only edits (typo fixes, "Improve `not` property sections", etc.). -- Reverts that cancel an earlier add in the same range (always check the rest of the range before porting something that gets reverted). -- Java/C# `langs:` blocks. -- Electron-only changes (`docs/src/api/class-electron.md`). - -Tick the box in `/tmp/roll--commits.md` with one line: `[x] — : `. - -### 5. Regenerate +**Client.** Port each hunk line by line into the Python module, with the rules of +[`playwright/_impl/transform.md`](../../../playwright/_impl/transform.md). Change the +lines that the hunk changes, so that a reviewer can read the two diffs side by side. +Mark each difference from the JS with `# Python:`, and remove a mark whose reason is +gone. + +- A new client file (`(no Python port yet)`): a new module, named in snake_case + (`browserContext.ts` is `browser_context.py`), with the docstring + `"""Port of .` and the members in the JS order. A new `ChannelOwner` + goes in `_object_factories()` in `connection.py`. A new class of the public API goes + in `_api_classes.py`. A plain wrapper class (like `Clock`) sets `self._loop` and + `self._dispatcher_fiber` from its parent. +- A deleted or renamed file: delete or rename the module. +- Shared code that Python does not port: port it only when the client starts to use it. + +**Tests.** Port each hunk line by line into the async test and the sync test that +`changes` names, with the rules of [`tests/transform.md`](../../../tests/transform.md): +zero deviations, and never change a port to make it pass. +`python scripts/check_test_registry.py --missing ` prints the name of a +new test. A test that can not exist in Python goes in `tests/upstream_not_ported.txt`, +with the reason. Run each ported file on the three browsers, the async and the sync +files in separate runs (a sync test can not run in a process where an async test has +run: `Runner.run() cannot be called from a running event loop`): ```sh -./scripts/update_api.sh # uses PW_SRC_DIR from step 2; run the checkout checks there first +xvfb-run -a pytest tests/async/page/test_page_click.py --browser firefox +xvfb-run -a pytest tests/sync/page/test_page_click.py --browser firefox ``` -The script, for `sync` and then `async`: -1. runs `node scripts/codegen/generate_api.js --flavor ` (reads the docs from `$PW_SRC_DIR`) straight into `playwright/_api/_generated.py`, -2. invokes `pre-commit run --files` on the generated file. +A failure is a bug in the client, the driver or the environment, not in the test. -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`. +**Test assets.** Copy each changed file from `.upstream/tests/assets/` to +`tests/assets/`; delete the deleted ones. Only these files: a few files in +`tests/assets/` are Python only. -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. +**Snapshot images.** Upstream keeps the image of `toMatchSnapshot()` next to the spec +(`page-screenshot.spec.ts-snapshots/-.png`); the port keeps it in +`tests/golden-/.png`. `changes` names the golden file of each changed +image: copy it there. The BiDi and the Firefox Nightly images are not used. -To see exactly what a regeneration changed in the public API, compare against the committed file: +Then run `mypy playwright` until it is clean. + +## 5. Check ```sh -git show HEAD:playwright/async_api/_generated.py > /tmp/old.py -python scripts/codegen/compare_api.py /tmp/old.py playwright/async_api/_generated.py --show all +pre-commit run --all-files +python scripts/check_test_registry.py +./test.sh +./scripts/update_api.sh && git status # no new changes: the generated files come from .upstream/ +playwright install chromium firefox webkit +python scripts/update_versions.py # the browser versions in README.md ``` -**CI no longer verifies that `_generated.py` is in sync** (the Lint job dropped the -"Verify generated API is up to date" step so it needn't check out upstream). So -regenerating here and committing the result is on you — don't skip it. - -Failure modes and fixes: - -| Symptom | Cause | Fix | -|---|---|---| -| mypy: `"XImpl" has no attribute "y"` in `_generated.py` | The docs have a new member `X.y`; `_impl/` does not. | PORT it. | -| mypy: `Unexpected keyword argument "z" for "y" of "XImpl"` | New parameter or option on an existing method. | Add the parameter in `_impl/` under its Python (snake_case) name, as `Optional[X]` with no default, and pass it in the channel call. mypy then reports each caller that must pass it. If Python must not have it, fix the docs: `* langs:` on the option. | -| mypy: `Missing named argument "z" for "y" of "XChannel"` | The protocol added a parameter to a command. | Pass the value in the channel call. If the implementation method needs a new parameter for it, add it as above. | -| mypy: incompatible argument type in `_generated.py` | The docs type changed, or `_impl/` declares a narrower type. | Match the type in `_impl/`, or fix the docs with a Python-only declaration (`* langs: python`). | -| generator: `No Python name for the struct at X.y.z` | A new inline `[Object]` with no name in the docs. | Name it in the docs: `` "Name" <[Object]> ``. | -| generator: `The struct X has different fields at A and B` | Two places in the docs share one struct name, but their fields differ. | Give one place its own name in the docs. | -| `_generated.py` gains or loses members that the commit walk never mentioned | `PW_SRC_DIR` is not at the `rev=` commit, or the range of the walk is wrong. | Check out `rev=` (step 2), regenerate, and recompute the range (step 3). | -| generator: `Unknown docs type "T" at X.y` | A docs type the type table in `generate_api.js` (`toPy`) does not know. | Add it to `toPy`. | -| generator: `required parameter z follows an optional one` | The docs order params so that Python would need a default before a non-default. | Fix the docs, for example with a Python-only `### param:` or `### option:`, as `select_option(value)` has. | -| generator: `the entry "…" no longer matches anything; remove it` | An upstream change made a rules entry unnecessary (or renamed what it points at). | Delete it, or re-key it to the new docs name. | -| `pyright … reportInconsistentOverload` (single-event class) | A class gained its first event, so there is one `Literal[…]` overload + impl, which pyright wants ≥2 of. | The generator already handles this — `emitEvents` adds a second `@typing.overload` with `event: str` when a class has exactly one event. | -| `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. | -| 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`). | -| `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`. | -| 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. | -| 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. | - -After the script settles, run `pre-commit run --all-files` once more to confirm everything is idle. - -### 6. Add tests - -Copy the upstream registry of the new release, then bring the Python tests in line with it: +`./test.sh` runs every test, on many workers. For a failure in a file that the roll did +not change: -```sh -cp "$PW_SRC_DIR/tests/registry.md" tests/upstream_registry.md -python scripts/check_test_registry.py --report -``` +1. Run the test alone. A test that passes alone failed from the load of the full run. +2. Run it against the old driver. Its bundle stays in `driver/` + (`playwright--.zip`): extract it into `playwright/driver/`, keeping the + file modes, run the test, then `python -m build --wheel` puts the new driver back. + A test that fails with the old driver too is not a regression of the roll: list it + in the PR. -- **New identifiers**: port each new upstream test, as an async test and a matching sync test. `python scripts/check_test_registry.py --missing ` 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)). -- **Changed titles**: the checker names the expected name; rename the Python test and port the changed body. -- **Removed identifiers**: delete the Python test (the checker reports it as not in the registry). -- **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. -- 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`). -- For event-info objects, `await message_info.value` (it's an `async` property). -- 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. -- `playwright install chromium` (no `--with-deps`) is sufficient for the test suite under sandbox. +Title the PR `chore: roll Playwright to ()`, +with the version from step 1. List what you ported and what you could not finish, and +put `git -C .upstream log --oneline --reverse "$old..$new"` of the changed paths in a +`
` block. -### 7. Update existing high-touch artifacts +## Reference: the docs decide the public API -- `DRIVER_VERSION` and `NODE_VERSION`: already done in step 2. -- `README.md`: gets the chromium/firefox/webkit version table updated automatically by `scripts/update_versions.py` (called from `update_api.sh`). Don't edit by hand. -- The "Backport changes" tracking issue on GitHub (filed by `microsoft-playwright-automation`) is the *intent* tracker, but it's frequently out of sync with what's actually been ported. Treat it as a starting point, not the source of truth — the `docs/src/api/` commit walk is authoritative. +A new member or option comes from the docs into `_generated.py`; the client hunk +implements it. -### 8. Final verification +- **For Python**: no `langs` filter, or `langs` includes `python`. Port it. +- **Not for Python**: `* langs: js` (or another filter without Python). The generator + leaves it out. Mark the client member `# Not ported: langs: js`. +- **Different in Python**: the shipped Python API wins. Fix the upstream docs with a + Python-only declaration (`* langs: python`). Put in `python_rules.js` only what the + docs can not say, with a one-line `//` comment above the entry that says why. -Check that `$PW_SRC_DIR` is still at the `rev=` commit, with no local changes: the committed `_generated.py` must come from it. +**`langs` is the only filter.** Do not decide by the name of an API: the 1.59 roll left +out Screencast, Debugger and `pickLocator` because they sounded like tooling, and they +were Python APIs. An empty `langs` means every language. To check it: ```sh -pre-commit run --all-files -mypy playwright -pytest --browser chromium tests/async/ tests/sync/ +node .upstream/utils/doclint/generateApiJson.js > /tmp/api.json +python -c "import json; c = {x['name']: x for x in json.load(open('/tmp/api.json'))}['Page']; print([(m['name'], m.get('langs', {})) for m in c['members']])" ``` -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. +Other docs changes: -## Reference: `python_rules.js` tables +- A new inline `[Object]` needs a Python name in the docs (`` "Name" <[Object]> `` or + `* alias-python:`). The generator writes its TypedDict into `_api_structures.py`; + re-export it from `playwright/async_api/__init__.py` and `playwright/sync_api/__init__.py`. +- A renamed positional parameter: rename it in `_impl/`. +- A member now in the docs that `python_rules.js` lists: remove the entry. The + generator fails on entries that match nothing. -All keys use docs names: `Class.member` or `Class.member.arg`, with the upstream PascalCase class and camelCase member/arg names. `*` matches one name segment. +`python_rules.js` tables (keys use docs names, `Class.member.arg`; `*` matches one segment): | Table | Use | |---|---| -| `handWrittenClasses` | Classes in the Python docs that are written by hand (`Error`, `TimeoutError`, `FormData`). | +| `handWrittenClasses` | Classes written by hand (`Error`, `TimeoutError`, `FormData`). | | `contextManagerClasses`, `tracebackHideClasses` | Base-class and `__tracebackhide__` choices. | -| `extraMembers`, `extraOptions` | Python-only members and hidden options; generated without docstrings. | +| `extraMembers`, `extraOptions` | Python-only members and hidden options. | | `positionalOptions` | Option names that are positional everywhere (`predicate`). | -| `structTotal` | `total=` of a TypedDict, where it differs from "total=False if any field is optional". | - -## Tips & gotchas - -- **`langs.only` is your filter — and the only filter.** Don't classify by name (`Screencast`, `Debugger`, `pickLocator`) or by intuition ("looks like internal tooling"). Always check `langs` in `api.json`. The 1.59 roll cost two extra audit passes by trusting names over `langs`. -- **Audit your own classifications a second time.** After the first walk through the commit range, before opening the PR, re-read every entry in `python_rules.js` you added during this roll and ask "is this divergence justified, or did I skip a port?" Run the `langs`-dump snippet on each suspicious entry. The 1.59 roll's first PR had ~20 wrong suppressions; the second pass cut them to 0. -- **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. -- **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 "$PW_SRC_DIR" show -- 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. -- **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. -- **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. -- **The docs parser has quirks.** In a type expression, everything after a function's `:` is its return type, so `[function]([A]):[any]|[B]` is one function returning `any|B`. Put a function with a return type last in a union. And when a `### param:` header name differs from the backticked name on its type line, the header name is the docs name and the type-line name becomes the alias. -- **Don't edit `_generated.py` to fix lint or typing.** Fix `_impl/`, `scripts/codegen/generate_api.js`, or `python_rules.js` instead. Hand-editing the generated file is reverted on the next regen. -- **`/tmp/roll--commits.md` is a working artifact, not a deliverable.** Don't commit it. The commit message and PR description are where the audit summary belongs. +| `structTotal` | `total=` of a TypedDict, where it differs from the default. | + +## Reference: failure modes + +| Symptom | Fix | +|---|---| +| `.upstream/ is not at ` | `python scripts/upstream.py checkout`. | +| `.upstream has local changes` | `git -C .upstream status`; discard the changes. | +| mypy: `"XImpl" has no attribute "y"` in `_generated.py` | A new member in the docs: port the client hunk. | +| mypy: `Unexpected keyword argument "z"` for an impl method | A new option: port the client hunk (`Optional[X]`, no default, passed to the channel call), or fix the docs if Python must not have it. | +| mypy: `Missing named argument "z"` for a channel method | A new protocol parameter: port the client hunk that passes it. | +| generator: `No Python name for the struct at X.y.z` | Name the `[Object]` in the docs. | +| generator: `The struct X has different fields at A and B` | Give one place its own name in the docs. | +| generator: `Unknown docs type "T"` | Add it to `toPy` in `generate_api.js`. | +| generator: `required parameter z follows an optional one` | Fix the docs with a Python-only `### param:` or `### option:`. | +| generator: `the entry "…" no longer matches anything` | Delete or re-key the `python_rules.js` entry. | +| `AttributeError: … '_loop'` on a new wrapper class | Set `self._loop` and `self._dispatcher_fiber` in its `__init__`. | +| `Protocol violations: X.y: params.z: …` at test teardown | The client sends what the spec does not allow: port the hunk that renamed or removed it. | +| A `binary` field arrives as `str` | Decode it with `base64.b64decode()`. | +| `timeout: expected float, got undefined` at launch | `playwright/driver/` is older than the pin: `python -m build --wheel`. | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7a0fe8af..8b3214ca7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,16 +26,26 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r local-requirements.txt pip install -r requirements.txt pip install -e . + - name: Get Playwright + run: python scripts/upstream.py checkout - name: Lint run: pre-commit run --show-diff-on-failure --color=always --all-files - name: Check the tests against the upstream test registry run: python scripts/check_test_registry.py --report + - name: Check that the generated files are up to date + run: | + ./scripts/update_api.sh + git diff --exit-code build: name: Build @@ -103,15 +113,23 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies & browsers - id: install + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r local-requirements.txt pip install -r requirements.txt pip install -e . - python -m build --wheel - python -m playwright install --with-deps ${{ matrix.browser }} + - name: Get Playwright + run: python scripts/upstream.py checkout + - name: Build the driver and the wheel + run: python -m build --wheel + - name: Install browsers + id: install + run: python -m playwright install --with-deps ${{ matrix.browser }} - name: Common Tests run: pytest tests/common --browser=${{ matrix.browser }} - name: Test Reference count @@ -155,15 +173,23 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" - - name: Install dependencies & browsers - id: install + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r local-requirements.txt pip install -r requirements.txt pip install -e . - python -m build --wheel - python -m playwright install ${{ matrix.browser-channel }} --with-deps + - name: Get Playwright + run: python scripts/upstream.py checkout + - name: Build the driver and the wheel + run: python -m build --wheel + - name: Install browsers + id: install + run: python -m playwright install ${{ matrix.browser-channel }} --with-deps - name: Common Tests run: pytest tests/common --browser=chromium --browser-channel=${{ matrix.browser-channel }} - name: Test Sync API diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 62e98f769..18d139ee1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -44,7 +44,11 @@ jobs: node-version: '24' - name: Install dependencies run: python -m pip install "$(grep '^build==' local-requirements.txt)" - - name: Build wheels + - name: Get Playwright + run: python scripts/upstream.py checkout + # Each wheel build builds the driver of its platform; the build of + # .upstream/ is shared. + - name: Build the driver and the wheels run: | for wheel in $(python setup.py --list-wheels); do PLAYWRIGHT_TARGET_WHEEL=$wheel python -m build --wheel --outdir dist diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 3b646fe10..14b45e1fb 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -46,22 +46,31 @@ jobs: pip install -r local-requirements.txt pip install -r requirements.txt pip install -e . + - name: Get Playwright + run: python scripts/upstream.py checkout + # build.sh builds the wheels, and with them the driver. - name: Build Docker image run: | ARCH="${{ matrix.runs-on == 'ubuntu-24.04-arm' && 'arm64' || 'amd64' }}" bash utils/docker/build.sh --$ARCH ${{ matrix.docker-image-variant }} playwright-python:localbuild-${{ matrix.docker-image-variant }} - - name: Test + - name: Start the container + id: container run: | - CONTAINER_ID="$(docker run --rm -e CI -v $(pwd):/root/playwright --name playwright-docker-test --workdir /root/playwright/ -d -t playwright-python:localbuild-${{ matrix.docker-image-variant }} /bin/bash)" + docker run --rm -e CI -e PYTHONPATH=scripts -v $(pwd):/root/playwright --name playwright-docker-test --workdir /root/playwright/ -d -t playwright-python:localbuild-${{ matrix.docker-image-variant }} /bin/bash # Fix permissions for Git inside the container - docker exec "${CONTAINER_ID}" chown -R root:root /root/playwright - docker exec "${CONTAINER_ID}" pip install -r local-requirements.txt - docker exec "${CONTAINER_ID}" pip install -r requirements.txt - docker exec "${CONTAINER_ID}" pip install -e . - # build.sh (above) already built the driver bundles into driver/ on the - # host. The repo is bind-mounted into the container, so this in-container - # wheel build reuses those bundles (setup.py skips the source build when - # the zip already exists) and therefore needs no Node.js in the image. - docker exec "${CONTAINER_ID}" python -m build --wheel - docker exec "${CONTAINER_ID}" xvfb-run pytest tests/sync/ - docker exec "${CONTAINER_ID}" xvfb-run pytest tests/async/ + docker exec playwright-docker-test chown -R root:root /root/playwright + docker exec playwright-docker-test pip install -r local-requirements.txt + docker exec playwright-docker-test pip install -r requirements.txt + docker exec playwright-docker-test pip install -e . + # The repo, with the driver that build.sh built into driver/, is bind-mounted + # into the container: this wheel build reuses that driver, and needs no + # Node.js in the image. + docker exec playwright-docker-test python -m build --wheel + # As in ci.yml: the dot reporter (scripts/pytest_dot_reporter.py, found through + # PYTHONPATH), and two workers, upstream's default of half the cores of these runners. + - name: Test Sync API + run: docker exec playwright-docker-test xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pytest tests/sync -n 2 -p pytest_dot_reporter -rfE --color=yes + - name: Test Async API + # Also after a failed sync step: the two suites report on their own. + if: ${{ !cancelled() && steps.container.outcome == 'success' }} + run: docker exec playwright-docker-test xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pytest tests/async -n 2 -p pytest_dot_reporter -rfE --color=yes diff --git a/.gitignore b/.gitignore index 76cb0329a..694602688 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ **/__pycache__/ +# The microsoft/playwright checkout at the commit in DRIVER_VERSION (scripts/upstream.py). +/.upstream/ driver/ playwright/driver/ playwright.egg-info/ diff --git a/AGENTS.md b/AGENTS.md index 8e5df6b78..aa1435ff6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,9 @@ Python bindings for [Playwright](https://playwright.dev). The Python client talk ## Layout -- `playwright/_impl/` — the client, a line-by-line port of upstream's JS client (`packages/playwright-core/src/client/*.ts`): one module per client file in snake_case (`browserContext.ts` is `browser_context.py`), members in the JS order. `playwright/_impl/transform.md` has the rules of the port; each difference from the JS code has a `# Python:` comment. `isomorphic/`, `protocol/` and `utils/` hold the ports of the parts of `packages/isomorphic`, `packages/protocol` and `packages/utils` that the client uses. The modules whose names start with an underscore are Python only, and the generated API imports most of them by name: `_api_classes.py` (the implementation of each API class), `_async_base.py` / `_sync_base.py` (the base classes of the API objects), `_impl_to_api_mapping.py`, `_errors.py` (the public error classes), `_form_data.py`, `_helper.py`. `_greenlets.py` runs the sync API (see `transform.md`, section 13), `_driver.py` finds the bundled driver, and pytest-playwright uses `_assertions.py` (the soft-assertion scope); the matchers are in `assertions.py`. -- `playwright/async_api/_generated.py`, `playwright/sync_api/_generated.py`, and the public TypedDicts in `playwright/_impl/_api_structures.py` — **auto-generated** from the upstream API docs. Never edit by hand; rerun `./scripts/update_api.sh` after a roll or after changing `scripts/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/`. +- `playwright/_impl/` — the client, a line-by-line port of upstream's JS client (`packages/playwright-core/src/client/*.ts`): one module per client file in snake_case (`browserContext.ts` is `browser_context.py`), members in the JS order. `playwright/_impl/transform.md` has the rules of the port; each difference from the JS code has a `# Python:` comment. `isomorphic/`, `protocol/` and `utils/` hold the ports of the parts of `packages/isomorphic`, `packages/protocol` and `packages/utils` that the client uses. The docstring of each ported module names its upstream file (`"""Port of packages/playwright-core/src/client/page.ts.`); `scripts/upstream.py changes` relies on it. The modules whose names start with an underscore are Python only, and the generated API imports most of them by name: `_api_classes.py` (the implementation of each API class), `_async_base.py` / `_sync_base.py` (the base classes of the API objects), `_impl_to_api_mapping.py`, `_errors.py` (the public error classes), `_form_data.py`, `_helper.py`. `_greenlets.py` runs the sync API (see `transform.md`, section 13), `_driver.py` finds the bundled driver, and pytest-playwright uses `_assertions.py` (the soft-assertion scope); the matchers are in `assertions.py`. +- `playwright/async_api/_generated.py`, `playwright/sync_api/_generated.py`, and the public TypedDicts in `playwright/_impl/_api_structures.py` — **auto-generated** from the upstream API docs, and committed so that they can be read and reviewed. Never edit by hand; rerun `./scripts/update_api.sh` after a roll or after changing `scripts/codegen/`. The Lint job of CI generates them again and fails when they differ. +- `scripts/codegen/generate_api.js` — the generator. A Node.js script that loads the upstream doc parser (`utils/doclint/api_parser.js`) from `.upstream/`, 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, struct `total=`, 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_channels.py`, `protocol_types.py`, `protocol/validator.py` and `_protocol.py` — **auto-generated** by `scripts/codegen/generate_protocol.py` (run by `./scripts/update_api.sh`) from the upstream protocol spec. - `protocol_channels.py` has a typed channel class for each interface (`PageChannel`, `FrameChannel`, …), like upstream's `channels.d.ts`. Each command is a method with the protocol name and parameters; as on upstream's channel proxy, calling it sends the message at once and returns an awaitable of the result (`await self._channel.goto(url=..., waitUntil=..., referer=..., timeout=...)`). Each event has `on` / `off` methods. A result or event payload has every top-level field; a field that the driver left out is `None`. @@ -18,30 +18,31 @@ Python bindings for [Playwright](https://playwright.dev). The Python client talk - `protocol/validator.py` is the port of upstream's `validator.ts`; the connection validates every message with it. - `_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. - `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 test for test from the upstream tests, which `tests/upstream_registry.md` lists by identifier. See [Tests](#tests). `tests/{async,sync}/page/` and `library/` hold the ports; `tests/{async,sync}/python/` hold the Python-only tests (the sync API, asyncio, Python types, regressions of this repository), and so do `tests/common/` (threads, signals, events) and the two files at the top of `tests/` (memory, wheel installation). -- `DRIVER_VERSION` — the single source of truth for which Playwright build the driver is assembled from. Two lines: `npm=`, the `playwright-core` npm version (e.g. `npm=1.61.0`, no `v` prefix), and `rev=`, the `microsoft/playwright` commit that the package is built from (`npm view playwright-core@ gitHead`). Read by `setup.py`, `scripts/build_driver.py`, `scripts/update_api.sh`, and CI (through `setup.py`). 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--.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 by `scripts/update_node_version.py` (latest LTS, mirroring upstream's `utils/build/update-playwright-node.mjs`). -- `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). -- The API docs are **not** shipped in the bundle. `scripts/update_api.sh` reads them from a separate `microsoft/playwright` checkout (`$PW_SRC_DIR`) at the exact commit in `DRIVER_VERSION` (`rev=`), and refuses to run on any other commit or on local changes. Needed only when regenerating the API, never at runtime. -- `ROLLING.md`, `CONTRIBUTING.md` — human-facing setup and roll docs. +- `tests/async/`, `tests/sync/` — pytest suites, ported test for test from the upstream tests, which `tests/upstream_registry.md` (a copy of upstream's `tests/registry.md`, written by `./scripts/update_api.sh`) lists by identifier. See [Tests](#tests). `tests/{async,sync}/page/` and `library/` hold the ports; `tests/{async,sync}/python/` hold the Python-only tests (the sync API, asyncio, Python types, regressions of this repository), and so do `tests/common/` (threads, signals, events) and the two files at the top of `tests/` (memory, wheel installation). +- `DRIVER_VERSION` — one line: the `microsoft/playwright` commit that the driver, the generated files and the port come from. +- `.upstream/` — a full clone of `microsoft/playwright` at that commit, gitignored. Read upstream code there; do not commit, branch or edit in it. `scripts/upstream.py` manages it: `checkout` (clone it, check out `DRIVER_VERSION`), `pin ` (move `DRIVER_VERSION` to a branch, a tag or a commit), `changes` (what changed upstream since `DRIVER_VERSION` at `HEAD`, with the Python file to port each change to), `resolve `. +- `scripts/build_driver.py` — the wheel build (`setup.py`) runs it: it builds `playwright-core` in `.upstream/` (`npm ci`, `npm run build`), and adds the Node.js binary of `NODE_VERSION` (kept at the latest LTS by `scripts/update_node_version.py`). The bundles are `driver/playwright--.zip`. The driver reports the version of `packages/playwright-core/package.json` at the commit (`1.65.0-next` on `main`). +- `scripts/update_api.sh` — generates the API files, the protocol files and `tests/upstream_registry.md` from `.upstream/`. +- `CONTRIBUTING.md` (build and test) and `ROLLING.md` (rolls) — the same steps, for people. ## Setup -`CONTRIBUTING.md` has the full sequence. The short version (needs Node.js and npm for the driver build): +The steps are in `CONTRIBUTING.md`. They need Python 3.10+, git, Node.js and npm: ```sh 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 +python scripts/upstream.py checkout # clone microsoft/playwright into .upstream/ at DRIVER_VERSION +python -m build --wheel # build the driver from .upstream/, and the wheel ``` -If the system lacks `python3-venv`, `uv venv env` is an acceptable substitute (then `uv pip install --python env/bin/python --upgrade pip`). +Run the last two again when `DRIVER_VERSION` changes. If the system lacks `python3-venv`, `uv venv env` is an acceptable substitute (then `uv pip install --python env/bin/python --upgrade pip`). ## Common commands -- Regenerate `_generated.py`: `./scripts/update_api.sh` (runs codegen + pre-commit on the generated files). +- Regenerate the generated files: `./scripts/update_api.sh` (runs codegen + pre-commit on the generated files). - Lint everything: `pre-commit run --all-files` (what the Lint job of CI runs; the checks are not a git hook, do not `pre-commit install`). - Type-check: `mypy playwright`. - Run tests: `pytest tests/sync tests/async --browser chromium [-k name]`, or `./test.sh [browser]` (both suites, in parallel, under xvfb). Browsers are installed via `playwright install chromium` (do **not** use `--with-deps`, which requires sudo). @@ -74,7 +75,7 @@ This is the recurring high-stakes task. Use the dedicated skill: → **[`.claude/skills/playwright-roll/SKILL.md`](.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. +The steps are pin, build, generate, port and check (also in `ROLLING.md`). The port is line by line: the client with `playwright/_impl/transform.md`, the tests with `tests/transform.md`. ## Working on PRs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c975f494..eb6f0f6b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,77 +17,70 @@ To ensure the maintainability of the project, please note the following: ## How to Contribute -### Configuring python environment +### Build and test -The project development requires Python version 3.10+. To set it as default in the environment run the following commands: +You need Python 3.10+, git, and Node.js with npm. The steps are the same on your +machine and in CI. -```sh -# You may need to install python 3.10 venv if it's missing, on Ubuntu just run `sudo apt-get install python3.10-venv` -python3.10 -m venv env -source ./env/bin/activate -``` - -Install required dependencies: +**1. Get the code and the Python dependencies.** ```sh +git clone https://github.com/microsoft/playwright-python +cd playwright-python +python3 -m venv env # on Ubuntu, you may need: sudo apt-get install python3-venv +source env/bin/activate python -m pip install --upgrade pip pip install -r local-requirements.txt -``` - -Build and install drivers: - -The driver is assembled from published artifacts — the `playwright-core` npm -package (version pinned in `DRIVER_VERSION`, `npm=`) and the official Node.js binary -(pinned in `NODE_VERSION`). Building a wheel downloads them with `npm pack` and -plain HTTP, so Node.js/npm must be installed; no git or source build is needed. - -```sh pip install -e . -python -m build --wheel ``` -Install the browsers to test against: +**2. Get Playwright.** The driver and the generated API come from +`microsoft/playwright`, at the commit in `DRIVER_VERSION`. This clones it into +`.upstream/` (gitignored) and checks out that commit. Run it again when +`DRIVER_VERSION` changes, for example after a pull. ```sh -playwright install chromium +python scripts/upstream.py checkout ``` -Run tests: +**3. Build.** This builds the driver from `.upstream/` (`npm ci`, `npm run build`, +plus the Node.js binary of `NODE_VERSION`) and the wheel in `dist/`. It also puts the +driver in `playwright/driver/`, for the editable install of step 1. The first build +takes a few minutes; later builds reuse the driver until `DRIVER_VERSION` changes. ```sh -pytest --browser chromium +python -m build --wheel ``` -Checking for typing errors +**4. Test.** ```sh -mypy playwright +playwright install chromium +./test.sh # tests/sync and tests/async, in parallel, under xvfb +pytest tests/async --browser chromium -k # or a part of them ``` -Lint and format the code (what the Lint job of CI runs; the checks are not a git hook) +**5. Lint.** This is what the Lint job of CI runs, including mypy. The checks are +not a git hook. ```sh pre-commit run --all-files ``` -For more details look at the [CI configuration](./.github/workflows/ci.yml). +### Regenerating the API -### Regenerating APIs +`playwright/{async,sync}_api/_generated.py`, `playwright/_impl/_api_structures.py`, +the protocol files of `playwright/_impl` and `tests/upstream_registry.md` are +generated from `.upstream/`, and committed. Generate them again after you change +`scripts/codegen/`: -`update_api.sh` generates `playwright/{async,sync}_api/_generated.py` and the -public TypedDicts in `playwright/_impl/_api_structures.py` from the API docs in -a separate `microsoft/playwright` checkout at the commit in `DRIVER_VERSION` -(`rev=`, the commit that the `npm=` package is built from); point `PW_SRC_DIR` -at it. The -generator is -`scripts/codegen/generate_api.js`; Python facts that the docs do not contain -live in `scripts/codegen/python_rules.js`. - -```bash -PW_SRC_DIR=../playwright-for-python ./scripts/update_api.sh -pre-commit run --all-files +```sh +./scripts/update_api.sh ``` +Python facts that the docs do not contain are in `scripts/codegen/python_rules.js`. +To move to a new Playwright commit, see [ROLLING.md](./ROLLING.md). + ## Contributor License Agreement This project welcomes contributions and suggestions. Most contributions require you to agree to a diff --git a/DRIVER_VERSION b/DRIVER_VERSION index 69e351f8e..e95c523d9 100644 --- a/DRIVER_VERSION +++ b/DRIVER_VERSION @@ -1,2 +1 @@ -npm=1.65.0-alpha-2026-10-19 -rev=b335271cdc2b57cac2be8ad37c5dd283ac6b8938 +b335271cdc2b57cac2be8ad37c5dd283ac6b8938 diff --git a/ROLLING.md b/ROLLING.md index e544f0ac7..8e11e34df 100644 --- a/ROLLING.md +++ b/ROLLING.md @@ -1,18 +1,45 @@ -# Rolling Playwright-Python to the latest Playwright driver - -* checkout repo: `git clone https://github.com/microsoft/playwright-python` -* make sure local python is 3.10 or newer - * create virtual environment, if don't have one: `python -m venv env` -* activate venv: `source env/bin/activate` -* install all deps: -``` -python -m pip install --upgrade pip -pip install -r local-requirements.txt -pip install -e . -``` -* change the driver pin in `DRIVER_VERSION`: `npm=` the `playwright-core` npm version (e.g. `1.61.0`) and `rev=` the commit it is built from (`npm view playwright-core@ gitHead`); refresh `NODE_VERSION`: `python scripts/update_node_version.py` -* download the new driver: `python -m build --wheel` (fetches `playwright-core` via `npm pack` + the matching Node.js binary and assembles the bundle; no source build). -* generate API from the docs. This needs a separate `microsoft/playwright` checkout used only for rolls, at the `rev=` commit of `DRIVER_VERSION` and clean (`git -C ../playwright-for-python fetch origin && git -C ../playwright-for-python checkout --detach `): `PW_SRC_DIR=../playwright-for-python ./scripts/update_api.sh` -* run `mypy playwright`: every error in `_generated.py` points at an implementation method in `playwright/_impl/` that lacks a new member or option. Implement it there, or record the difference in `scripts/codegen/python_rules.js`. -* commit changes & send PR -* wait for bots to pass & merge the PR +# Rolling to a new Playwright commit + +`DRIVER_VERSION` holds the `microsoft/playwright` commit that the driver, the +generated API and the ported code come from. A roll moves it to a new commit and +ports what changed in between. Set up the repo first ([CONTRIBUTING.md](./CONTRIBUTING.md), +steps 1 and 2). The full process, for people and agents, is in +[.claude/skills/playwright-roll/SKILL.md](./.claude/skills/playwright-roll/SKILL.md). + +1. **Pin** the new commit, a branch or a tag. This writes the commit to + `DRIVER_VERSION` and checks it out in `.upstream/`. + + ```sh + python scripts/upstream.py pin main + python scripts/update_node_version.py + ``` + +2. **Build** the driver and install the browsers. + + ```sh + python -m build --wheel + playwright install chromium firefox webkit + ``` + +3. **Generate** the API, the protocol files and the test registry. + + ```sh + ./scripts/update_api.sh + ``` + +4. **Port** what changed upstream, line by line: the client into `playwright/_impl/` + ([transform.md](./playwright/_impl/transform.md)), the tests into `tests/async/` and + `tests/sync/` ([tests/transform.md](./tests/transform.md)). + + ```sh + python scripts/upstream.py changes # each changed upstream file, with its Python file + ``` + +5. **Check**, then commit and open the PR. + + ```sh + pre-commit run --all-files + python scripts/check_test_registry.py + ./test.sh + python scripts/update_versions.py # the browser versions in README.md + ``` diff --git a/playwright/_impl/channel_owner.py b/playwright/_impl/channel_owner.py index 4e68fd661..0412582ae 100644 --- a/playwright/_impl/channel_owner.py +++ b/playwright/_impl/channel_owner.py @@ -34,6 +34,7 @@ Tuple, TypeVar, Union, + cast, ) from playwright._impl.client_stack_trace import capture_library_stack_trace @@ -220,7 +221,7 @@ def __init__( self._parent._objects[guid] = self self._logger = self._parent._logger - self._channel: Any = self._create_channel() + self._channel: Channel = self._create_channel() self._initializer = initializer def _report_unhandled_error(self, error: BaseException) -> None: @@ -234,7 +235,9 @@ def _update_subscription(self, event: str, enabled: bool) -> None: if protocol_event: # Python: .catch(() => {}). fire_and_forget( - self._channel.updateSubscription(event=protocol_event, enabled=enabled) + cast(Any, self._channel).updateSubscription( + event=protocol_event, enabled=enabled + ) ) def on(self, type: str, listener: Listener) -> "ChannelOwner": diff --git a/playwright/_impl/connection.py b/playwright/_impl/connection.py index 11b234f70..845f403d3 100644 --- a/playwright/_impl/connection.py +++ b/playwright/_impl/connection.py @@ -66,9 +66,12 @@ if TYPE_CHECKING: # pragma: no cover from playwright._impl.local_utils import LocalUtils from playwright._impl.playwright import Playwright + from playwright._impl.protocol_channels import RootChannel class Root(ChannelOwner): + _channel: "RootChannel" + def __init__(self, connection: "Connection") -> None: super().__init__(connection, "Root", "", {}) @@ -362,13 +365,13 @@ def dispatch(self, message: Dict[str, Any]) -> None: validator = find_validator(object._type, method or "", "Event") params = validator(params, "", self._validator_from_wire_context()) if not self._dispatcher_fiber: - object._channel.emit(method, params) + cast(Any, object._channel).emit(method, params) return # Python: under the sync API, a listener may block in a sync call. Each # listener runs in its own greenlet, so that it can switch to the dispatcher # fiber then, and the loop goes on with the next listener. The greenlet # resumes when the sync call completes. - channel = object._channel + channel = cast(Any, object._channel) for listener in channel.raw_listeners(method): EventGreenlet(channel._call_handler).switch(method, listener, (params,)) diff --git a/playwright/_impl/transform.md b/playwright/_impl/transform.md index 5e542cda4..34779593a 100644 --- a/playwright/_impl/transform.md +++ b/playwright/_impl/transform.md @@ -2,7 +2,8 @@ These rules say how to write `playwright/_impl` from `packages/playwright-core/src/client` in `microsoft/playwright`. Use them at each roll, for every new or changed line of the -client. +client. The upstream code is in `.upstream/`, the checkout at the commit in +`DRIVER_VERSION`. The steps of a roll are in the `playwright-roll` skill. The port is line by line. **The structure of the Python follows the JavaScript. The timing of the Python follows asyncio.** A reviewer reads the `.ts` file and the `.py` file side by @@ -535,14 +536,3 @@ The sync API is not a port. It runs on greenlets, in (`wait_for_response()`): an `async def` wrapper would call the predicate in a task. - Test with `_dispatcher_fiber`, not with a flag: `if route._dispatcher_fiber:`. - The async API is the port; the sync branch is always the `# Python:` marked one. - -## 14. At each roll - -1. List the upstream commits of the range for `packages/playwright-core/src/client`, - `packages/isomorphic`, `packages/protocol` and the parts of `packages/utils` that are - ported. -2. For each hunk, find the Python line and apply the rules. Keep the order. -3. Regenerate the protocol files with `./scripts/update_api.sh`. -4. Run `pre-commit run --all-files` and `./test.sh`. -5. Read every `# Python:` mark in the changed members again. Remove a mark whose reason - is gone. Add one for each new deviation. diff --git a/scripts/build_driver.py b/scripts/build_driver.py index 9183a678b..e5265d59c 100755 --- a/scripts/build_driver.py +++ b/scripts/build_driver.py @@ -13,33 +13,32 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Assemble the Playwright driver bundles from published artifacts. - -Instead of building the driver from upstream source, this downloads the -already-published ``playwright-core`` npm package (pinned in ``DRIVER_VERSION``) -and the official Node.js binaries (pinned in ``NODE_VERSION``) and assembles -per-platform bundles into ``driver/playwright--.zip``. The -layout mirrors what upstream's ``utils/build/build-playwright-driver.sh`` -produces, which is what ``setup.py`` extracts into ``playwright/driver/`` when -building a wheel:: - - node | node.exe - the Node.js binary - LICENSE - the Node.js license - package/** - the playwright-core npm package - -``playwright-core`` is fetched with ``npm pack`` (run from the repository root, so -a root-level ``.npmrc`` -- e.g. the one the release pipeline writes to point at -the internal Azure Artifacts feed -- and its credentials are honoured). The -Node.js binaries are downloaded directly from nodejs.org. Apart from Node.js/npm -this needs only the Python standard library. +"""Build the Playwright driver bundles from .upstream/. -Usage:: +The driver is the playwright-core package of microsoft/playwright, built from +``.upstream/`` at the commit in ``DRIVER_VERSION``, plus the official Node.js binary +of ``NODE_VERSION``. ``setup.py`` runs this when it builds a wheel. It: + +1. builds ``.upstream/`` with ``npm ci`` and ``npm run build`` (once per commit); +2. packs ``packages/playwright-core`` with ``npm pack``; +3. adds the Node.js binary of each platform, and writes + ``driver/playwright--.zip``:: + + node | node.exe - the Node.js binary + LICENSE - the Node.js license + package/** - the playwright-core npm package - scripts/build_driver.py # assemble every platform bundle - scripts/build_driver.py # assemble a single bundle, e.g. mac-arm64 +A bundle that exists is not built again. ``.upstream/`` must be checked out at the +commit in ``DRIVER_VERSION`` (``python scripts/upstream.py checkout``), with no local +changes. npm uses the registry and the credentials of a root-level ``.npmrc`` when +there is one, for example the one that the release pipeline writes to point at +the internal Azure Artifacts feed. Apart from git and Node.js/npm this needs only +the Python standard library. -``setup.py`` invokes the single-suffix form so a wheel build only downloads the -one Node.js binary it needs. +Usage:: + + python scripts/build_driver.py # build every platform bundle + python scripts/build_driver.py # build one bundle, e.g. mac-arm64 """ import os @@ -54,6 +53,8 @@ from pathlib import Path from typing import Iterable, List, NamedTuple, Set +import upstream + REPO_ROOT = Path(__file__).resolve().parent.parent DRIVER_DIR = REPO_ROOT / "driver" @@ -84,21 +85,6 @@ def read_pin(name: str) -> str: return value -def read_driver_version() -> str: - # DRIVER_VERSION has two lines: npm= and - # rev=. - pins = dict( - line.split("=", 1) - for line in read_pin("DRIVER_VERSION").splitlines() - if line.strip() - ) - if "npm" not in pins: - raise SystemExit( - f"DRIVER_VERSION has no npm= line at {REPO_ROOT / 'DRIVER_VERSION'}" - ) - return pins["npm"].strip() - - def download(url: str, destination: Path) -> None: last_error: Exception = RuntimeError("no attempt made") for attempt in range(1, 6): @@ -147,27 +133,52 @@ def _extract_zip_file(archive: zipfile.ZipFile, name: str, destination: Path) -> shutil.copyfileobj(source, out) -def fetch_playwright_core(version: str, work_dir: Path) -> Path: - """Download playwright-core@ and extract its package/ tree once.""" +def run_npm(args: List[str], cwd: Path) -> None: npm = "npm.cmd" if sys.platform == "win32" else "npm" - spec = f"playwright-core@{version}" - # npm is run from the repository root so that a root-level .npmrc (registry - # and credentials) is honoured. `npm pack` writes -.tgz. - print(f"Downloading {spec} with npm pack", flush=True) + env = dict(os.environ) + npmrc = REPO_ROOT / ".npmrc" + if npmrc.is_file(): + # The release pipeline writes the registry and its credentials here. + env["npm_config_userconfig"] = str(npmrc) + print(f"Running npm {' '.join(args)} in {cwd}", flush=True) try: - subprocess.check_call( - [npm, "pack", spec, "--pack-destination", str(work_dir)], - cwd=REPO_ROOT, - ) + subprocess.check_call([npm, *args], cwd=cwd, env=env) except FileNotFoundError: raise SystemExit( - "npm was not found on PATH; Node.js/npm are required to assemble the driver." + "npm was not found on PATH; Node.js/npm are required to build the driver." ) except subprocess.CalledProcessError as error: - raise SystemExit(f"npm pack {spec} failed with exit code {error.returncode}") - tgz = work_dir / f"playwright-core-{version}.tgz" - if not tgz.is_file(): - raise SystemExit(f"npm pack did not produce {tgz}") + raise SystemExit( + f"npm {' '.join(args)} failed with exit code {error.returncode}" + ) + + +def build_playwright_core(commit: str, work_dir: Path) -> Path: + """Build .upstream/ and extract its playwright-core package into work_dir.""" + if upstream.local_changes(): + raise SystemExit( + f"{upstream.UPSTREAM_DIR} has local changes. The driver bundles are named " + "after the commit, so build them from a clean checkout." + ) + core_dir = upstream.UPSTREAM_DIR / "packages" / "playwright-core" + # npm ci removes node_modules, and the stamp with it. + stamp = upstream.UPSTREAM_DIR / "node_modules" / ".playwright-python-build" + built = ( + stamp.is_file() + and stamp.read_text().strip() == commit + and (core_dir / "lib").is_dir() + ) + if not built: + run_npm(["ci", "--no-audit", "--no-fund"], upstream.UPSTREAM_DIR) + run_npm(["run", "build"], upstream.UPSTREAM_DIR) + stamp.write_text(commit + "\n") + run_npm(["pack", "--pack-destination", str(work_dir)], core_dir) + tgzs = list(work_dir.glob("playwright-core-*.tgz")) + if len(tgzs) != 1: + raise SystemExit( + f"npm pack did not produce one playwright-core-*.tgz in {work_dir}" + ) + tgz = tgzs[0] with tarfile.open(tgz, "r:gz") as tar: # npm tarballs nest every file under a top-level "package/" directory, # which is exactly the bundle layout we want. @@ -180,6 +191,10 @@ def fetch_playwright_core(version: str, work_dir: Path) -> Path: raise SystemExit(f"No package/ entries found in {tgz.name}") _extract_members(tar, work_dir, members) tgz.unlink() + # Upstream copies these into the package before it publishes it + # (LICENCE_FILES in utils/workspace.js). + for name in ("NOTICE", "LICENSE"): + shutil.copyfile(upstream.UPSTREAM_DIR / name, work_dir / "package" / name) return work_dir / "package" @@ -216,7 +231,7 @@ def zip_bundle(bundle_dir: Path, destination: Path) -> None: def assemble( platform: Platform, - version: str, + commit: str, node_version: str, package_dir: Path, work_dir: Path, @@ -227,14 +242,14 @@ def assemble( bundle_dir.mkdir(parents=True) fetch_node(platform, node_version, bundle_dir, work_dir) shutil.copytree(package_dir, bundle_dir / "package") - destination = DRIVER_DIR / f"playwright-{version}-{platform.suffix}.zip" + destination = DRIVER_DIR / f"playwright-{commit}-{platform.suffix}.zip" zip_bundle(bundle_dir, destination) shutil.rmtree(bundle_dir) print(f"Created {destination}") def build(suffixes: Set[str]) -> None: - version = read_driver_version() + commit = upstream.read_pin() node_version = read_pin("NODE_VERSION") DRIVER_DIR.mkdir(exist_ok=True) @@ -242,19 +257,20 @@ def build(suffixes: Set[str]) -> None: p for p in PLATFORMS if p.suffix in suffixes - and not (DRIVER_DIR / f"playwright-{version}-{p.suffix}.zip").exists() + and not (DRIVER_DIR / f"playwright-{commit}-{p.suffix}.zip").exists() ] if not todo: print( - f"Driver bundles for {version} already present in {DRIVER_DIR}; nothing to do." + f"Driver bundles for {commit} already present in {DRIVER_DIR}; nothing to do." ) return + upstream.require_checkout() with tempfile.TemporaryDirectory(prefix="pw-driver-") as tmp_name: work_dir = Path(tmp_name) - package_dir = fetch_playwright_core(version, work_dir) + package_dir = build_playwright_core(commit, work_dir) for platform in todo: - assemble(platform, version, node_version, package_dir, work_dir) + assemble(platform, commit, node_version, package_dir, work_dir) def main(argv: Iterable[str]) -> None: diff --git a/scripts/codegen/generate_api.js b/scripts/codegen/generate_api.js index 1fdd05ec4..8e31b3bdb 100644 --- a/scripts/codegen/generate_api.js +++ b/scripts/codegen/generate_api.js @@ -22,7 +22,10 @@ // Playwright docs (docs/src/api in microsoft/playwright). // // Usage: -// PW_SRC_DIR= node scripts/codegen/generate_api.js --flavor async|sync|structs [--out ] +// node scripts/codegen/generate_api.js --flavor async|sync|structs [--out ] +// +// The docs come from .upstream/, the microsoft/playwright checkout at the commit +// in DRIVER_VERSION (scripts/upstream.py). // // The docs are parsed with the upstream parser (utils/doclint/api_parser.js) and // filtered for Python. Facts that the docs do not contain come from @@ -113,12 +116,12 @@ function fail(message) { const options = parseArgs(process.argv.slice(2)); const isAsync = options.flavor === 'async'; -const PW_SRC_DIR = process.env.PW_SRC_DIR; -if (!PW_SRC_DIR) - fail('Set PW_SRC_DIR to a microsoft/playwright checkout at the commit in DRIVER_VERSION (rev=).'); -const { parseApi } = require(path.join(PW_SRC_DIR, 'utils', 'doclint', 'api_parser')); -const { Type, parseCodeLang } = require(path.join(PW_SRC_DIR, 'utils', 'doclint', 'documentation')); -const md = require(path.join(PW_SRC_DIR, 'utils', 'markdown')); +const UPSTREAM_DIR = path.join(__dirname, '..', '..', '.upstream'); +if (!fs.existsSync(path.join(UPSTREAM_DIR, 'docs', 'src', 'api'))) + fail('.upstream/ is missing. Run: python scripts/upstream.py checkout'); +const { parseApi } = require(path.join(UPSTREAM_DIR, 'utils', 'doclint', 'api_parser')); +const { Type, parseCodeLang } = require(path.join(UPSTREAM_DIR, 'utils', 'doclint', 'documentation')); +const md = require(path.join(UPSTREAM_DIR, 'utils', 'markdown')); const BASE_CLASS = isAsync ? 'AsyncBase' : 'SyncBase'; const CONTEXT_MANAGER_CLASS = isAsync ? 'AsyncContextManager' : 'SyncContextManager'; @@ -185,7 +188,7 @@ function escapeRegExp(text) { // Docs. function loadDocumentation() { - const documentation = parseApi(path.join(PW_SRC_DIR, 'docs', 'src', 'api')); + const documentation = parseApi(path.join(UPSTREAM_DIR, 'docs', 'src', 'api')); documentation.filterForLanguage('python'); return documentation; } diff --git a/scripts/codegen/generate_protocol.py b/scripts/codegen/generate_protocol.py index 0ba4b4710..4f68c17d2 100644 --- a/scripts/codegen/generate_protocol.py +++ b/scripts/codegen/generate_protocol.py @@ -20,16 +20,18 @@ # - playwright/_impl/protocol/validator.py: the port of validator.ts. # # Usage: -# PW_SRC_DIR= python scripts/codegen/generate_protocol.py \ +# python scripts/codegen/generate_protocol.py \ # --out --types-out --channels-out --validator-out # +# The spec comes from .upstream/, the microsoft/playwright checkout at the commit +# in DRIVER_VERSION (scripts/upstream.py). +# # The spec is loaded the same way as utils/protocol_spec.js does it. The output # is not formatted; run black on it. import argparse import json import keyword -import os import re import sys from pathlib import Path @@ -737,12 +739,13 @@ def main() -> None: parser.add_argument("--channels-out", required=True) parser.add_argument("--validator-out", required=True) args = parser.parse_args() - pw_src_dir = os.environ.get("PW_SRC_DIR") - if not pw_src_dir: + upstream_dir = Path(__file__).resolve().parent.parent.parent / ".upstream" + spec_dir = upstream_dir / "packages" / "protocol" / "spec" + if not spec_dir.is_dir(): raise SystemExit( - "Set PW_SRC_DIR to a microsoft/playwright checkout at the release branch in DRIVER_VERSION." + ".upstream/ is missing. Run: python scripts/upstream.py checkout" ) - protocol = load_protocol(Path(pw_src_dir) / "packages" / "protocol" / "spec") + protocol = load_protocol(spec_dir) text = generate(protocol) types_text, interfaces, type_names = generate_types(protocol) channels_text = generate_channels(interfaces, type_names) diff --git a/scripts/example_async.py b/scripts/example_async.py deleted file mode 100644 index 9fe5b6b11..000000000 --- a/scripts/example_async.py +++ /dev/null @@ -1,30 +0,0 @@ -# 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. - -import asyncio - -from playwright.async_api import async_playwright - - -async def main() -> None: - async with async_playwright() as p: - for browser_type in [p.chromium, p.firefox, p.webkit]: - browser = await browser_type.launch() - page = await browser.new_page() - assert await page.evaluate("() => 11 * 11") == 121 - await browser.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/scripts/example_sync.py b/scripts/example_sync.py deleted file mode 100644 index 0c65a5f07..000000000 --- a/scripts/example_sync.py +++ /dev/null @@ -1,28 +0,0 @@ -# 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 playwright.sync_api import sync_playwright - - -def main() -> None: - with sync_playwright() as p: - for browser_type in [p.chromium, p.firefox, p.webkit]: - browser = browser_type.launch() - page = browser.new_page() - assert page.evaluate("() => 11 * 11") == 121 - browser.close() - - -if __name__ == "__main__": - main() diff --git a/scripts/update_api.sh b/scripts/update_api.sh index 6d6290d9b..799a840a2 100755 --- a/scripts/update_api.sh +++ b/scripts/update_api.sh @@ -1,22 +1,22 @@ #!/bin/bash -# Generates the public API (playwright/{async,sync}_api/_generated.py) and its -# TypedDicts (playwright/_impl/_api_structures.py) from the Playwright API docs -# (docs/src/api) in a microsoft/playwright checkout, pointed to by PW_SRC_DIR, -# and the protocol files of playwright/_impl (_protocol.py, protocol_types.py, -# protocol_channels.py, protocol/validator.py) from its protocol spec -# (packages/protocol/spec). -# Use a separate checkout on the exact release branch for the version in -# DRIVER_VERSION (for example release-1.64). The generator is -# scripts/codegen/generate_api.js; Python facts that the docs do not contain -# are in scripts/codegen/python_rules.js. -driver_version="$(cat DRIVER_VERSION)" -if [[ -z "$PW_SRC_DIR" ]]; then - echo "Set PW_SRC_DIR to a separate microsoft/playwright checkout on the release branch for v${driver_version}, e.g.:" >&2 - echo " PW_SRC_DIR=../playwright-for-python ./scripts/update_api.sh" >&2 +# Generates the files that come from microsoft/playwright, from .upstream/ at the +# commit in DRIVER_VERSION (python scripts/upstream.py checkout): +# - playwright/{async,sync}_api/_generated.py and playwright/_impl/_api_structures.py, +# from the API docs (docs/src/api), by scripts/codegen/generate_api.js; +# - playwright/_impl/_protocol.py, protocol_types.py, protocol_channels.py and +# protocol/validator.py, from the protocol spec (packages/protocol/spec), by +# scripts/codegen/generate_protocol.py; +# - tests/upstream_registry.md, a copy of tests/registry.md. +# The generated files are committed. The Lint job of CI runs this script and fails +# when they differ. +cd "$(dirname "$0")/.." || exit 1 + +pin="$(cat DRIVER_VERSION)" +if [[ "$(git -C .upstream rev-parse HEAD 2>/dev/null)" != "$pin" ]]; then + echo ".upstream/ is not at $pin, the commit in DRIVER_VERSION. Run: python scripts/upstream.py checkout" >&2 exit 1 fi -export PW_SRC_DIR function update_api { flavor="$1" @@ -24,7 +24,6 @@ function update_api { echo "Generating $file_name" if node scripts/codegen/generate_api.js --flavor "$flavor" --out "$file_name"; then pre-commit run --files "$file_name" - echo "Regenerated APIs" else echo "Exited due to errors" exit 1 @@ -44,6 +43,5 @@ else exit 1 fi -playwright install - -python scripts/update_versions.py +echo "Copying tests/upstream_registry.md" +cp .upstream/tests/registry.md tests/upstream_registry.md diff --git a/scripts/upstream.py b/scripts/upstream.py new file mode 100644 index 000000000..06d39277c --- /dev/null +++ b/scripts/upstream.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +# 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. + +"""Manage .upstream/, the microsoft/playwright checkout that this repository is built from. + +DRIVER_VERSION holds one microsoft/playwright commit. .upstream/ (gitignored) is a +full clone of microsoft/playwright, checked out at that commit. The wheel build +builds the driver from it (setup.py runs scripts/build_driver.py), and +scripts/update_api.sh generates the API from it. Both stop when .upstream/ is not +at the commit in DRIVER_VERSION; run `checkout` then. + +Usage:: + + python scripts/upstream.py checkout # clone .upstream/ the first time; check out DRIVER_VERSION + python scripts/upstream.py pin # move DRIVER_VERSION to a branch, a tag or a commit, + # and check it out + python scripts/upstream.py changes [] # list what changed upstream since (default: + # DRIVER_VERSION at HEAD), with the Python files to port it to + python scripts/upstream.py resolve # print the commit of a branch, a tag or a commit + +Apart from git, this needs only the Python standard library. +""" + +import ast +import re +import subprocess +import sys +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Optional, Sequence + +from check_test_registry import Entry, Rule, read_rules + +REPO_ROOT = Path(__file__).resolve().parent.parent +UPSTREAM_DIR = REPO_ROOT / ".upstream" +UPSTREAM_URL = "https://github.com/microsoft/playwright.git" +DRIVER_VERSION = REPO_ROOT / "DRIVER_VERSION" + +_COMMIT = re.compile(r"^[0-9a-f]{40}$") + +# The upstream code that playwright/_impl ports, line by line. +CLIENT_PATHS = [ + "packages/playwright-core/src/client", + "packages/playwright-core/src/outofprocess.ts", +] +SHARED_PATHS = ["packages/isomorphic", "packages/protocol/src", "packages/utils"] +# The upstream files that scripts/update_api.sh generates the Python files from, and +# the files that upstream generates from the protocol spec (utils/generate_channels.js), +# as update_api.sh generates their Python counterparts. +GENERATED_FROM_PATHS = ["docs/src/api", "packages/protocol/spec", "tests/registry.md"] +UPSTREAM_GENERATED = [ + "packages/isomorphic/protocolMetainfo.ts", + "packages/playwright-core/src/client/channels.d.ts", + "packages/protocol/src/structs.d.ts", + "packages/protocol/src/validator.ts", +] +TEST_PATHS = ["tests/page", "tests/library"] +ASSET_PATHS = ["tests/assets"] + + +def _git(*args: str, cwd: Path = UPSTREAM_DIR, capture: bool = True) -> str: + try: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + text=True, + stdout=subprocess.PIPE if capture else None, + ) + except FileNotFoundError: + raise SystemExit("git was not found on PATH.") + except subprocess.CalledProcessError as error: + raise SystemExit( + f"git {' '.join(args)} failed with exit code {error.returncode}" + ) + return result.stdout.strip() if capture else "" + + +def _has_commit(commit: str) -> bool: + return ( + subprocess.run( + ["git", "cat-file", "-e", f"{commit}^{{commit}}"], + cwd=UPSTREAM_DIR, + stderr=subprocess.DEVNULL, + ).returncode + == 0 + ) + + +def read_pin() -> str: + pin = DRIVER_VERSION.read_text().strip() + if not _COMMIT.match(pin): + raise SystemExit( + f"{DRIVER_VERSION} must hold one microsoft/playwright commit (40 hex digits), got {pin!r}" + ) + return pin + + +def head() -> str: + return _git("rev-parse", "HEAD") + + +def local_changes() -> bool: + return bool(_git("status", "--porcelain")) + + +def fetch(commit: str) -> None: + if _has_commit(commit): + return + _git("fetch", "--quiet", "--tags", "origin", capture=False) + if not _has_commit(commit): + # A commit that no branch or tag reaches any more, for example on a deleted branch. + _git("fetch", "--quiet", "origin", commit, capture=False) + + +def checkout(commit: Optional[str] = None) -> str: + """Check out commit (default: DRIVER_VERSION) in .upstream/, after cloning it the first time.""" + commit = commit or read_pin() + cloned = False + if not (UPSTREAM_DIR / ".git").exists(): + print(f"Cloning {UPSTREAM_URL} into {UPSTREAM_DIR}", flush=True) + _git( + "clone", + "--no-checkout", + UPSTREAM_URL, + str(UPSTREAM_DIR), + cwd=REPO_ROOT, + capture=False, + ) + cloned = True + if not cloned and head() == commit: + print(f"{UPSTREAM_DIR} is at {commit}", flush=True) + return commit + if not cloned and local_changes(): + raise SystemExit( + f"{UPSTREAM_DIR} has local changes. Commit or discard them before checking out {commit}." + ) + fetch(commit) + _git("checkout", "--quiet", "--detach", commit, capture=False) + print(f"{UPSTREAM_DIR} is at {commit}", flush=True) + return commit + + +def require_checkout() -> str: + """Return the commit in DRIVER_VERSION; stop unless .upstream/ is checked out at it.""" + commit = read_pin() + if not (UPSTREAM_DIR / ".git").exists() or head() != commit: + raise SystemExit( + f"{UPSTREAM_DIR} is not at {commit}, the commit in DRIVER_VERSION. " + "Run: python scripts/upstream.py checkout" + ) + return commit + + +def resolve(ref: str) -> str: + """Return the commit of a branch, a tag or a commit of microsoft/playwright.""" + if _COMMIT.match(ref): + return ref + # "^{}" is the commit that an annotated tag points to. + names = [f"refs/tags/{ref}^{{}}", f"refs/tags/{ref}", f"refs/heads/{ref}"] + refs: Dict[str, str] = {} + for line in _git("ls-remote", UPSTREAM_URL, *names, cwd=REPO_ROOT).splitlines(): + commit, name = line.split("\t") + refs[name] = commit + for name in names: + if name in refs: + return refs[name] + raise SystemExit(f"{ref} is not a branch, a tag or a full commit of {UPSTREAM_URL}") + + +def pin(ref: str) -> None: + old = read_pin() + new = resolve(ref) + checkout(new) + DRIVER_VERSION.write_text(new + "\n") + print(f"DRIVER_VERSION: {old} -> {new}") + + +def _python_ports() -> Dict[str, List[str]]: + """Map each upstream file to the Python modules whose docstring says that they port it.""" + ports: Dict[str, List[str]] = defaultdict(list) + for module in sorted((REPO_ROOT / "playwright" / "_impl").rglob("*.py")): + docstring = ast.get_docstring(ast.parse(module.read_text(encoding="utf-8"))) + for path in re.findall(r"packages/[\w./-]+\.ts", docstring or ""): + ports[path].append(module.relative_to(REPO_ROOT).as_posix()) + return ports + + +def _python_test_file(path: str, rules: List[Rule]) -> Optional[str]: + # tests/page/page-check.spec.ts -> tests/{async,sync}/page/test_page_check.py + if not path.endswith(".spec.ts"): + return None + file = path.split("/", 1)[1].removesuffix(".spec.ts") + # A rule of tests/upstream_not_ported.txt that covers the whole file. + rule = next((r for r in rules if r.matches(Entry("", file, "", 1))), None) + if rule: + return f"not ported: {rule.reason}" + directory, name = file.rsplit("/", 1) + return f"tests/{{async,sync}}/{directory}/test_{re.sub(r'[^A-Za-z0-9]+', '_', name)}.py" + + +def _golden_file(path: str) -> Optional[str]: + # tests/page/page-screenshot.spec.ts-snapshots/screenshot-chromium.png + # -> tests/golden-chromium/screenshot.png + match = re.fullmatch( + r"tests/.+\.spec\.ts-snapshots/(.+)-(chromium|firefox|webkit)(\.\w+)", path + ) + if not match or match[1].endswith("-bidi"): + return None # The BiDi and the Firefox Nightly images are not used. + return f"tests/golden-{match[2]}/{match[1]}{match[3]}" + + +def changes(since: Optional[str]) -> None: + new = require_checkout() + old = ( + resolve(since) if since else _git("show", "HEAD:DRIVER_VERSION", cwd=REPO_ROOT) + ) + if old == new: + print(f"DRIVER_VERSION is {new} at HEAD too; pass the commit to compare with.") + return + fetch(old) + count = _git("rev-list", "--count", f"{old}..{new}") + print(f"microsoft/playwright {old[:10]}..{new[:10]}: {count} commits") + + def diff(paths: Sequence[str]) -> List[str]: + # One row per file: "M path", "A path", "D path" or "R old -> new". + output = _git("diff", "--name-status", "--find-renames", old, new, "--", *paths) + rows: List[str] = [] + for line in output.splitlines(): + status, *names = line.split("\t") + rows.append(f"{status[0]} {' -> '.join(names)}") + return rows + + def section(title: str, rows: List[str]) -> None: + if rows: + print(f"\n{title}") + for row in rows: + print(f" {row}") + + ports = _python_ports() + client: List[str] = [] + shared: List[str] = [] + for row in diff(CLIENT_PATHS + SHARED_PATHS): + names = row[2:].split(" -> ") + if names[-1] in UPSTREAM_GENERATED: + continue + target = ", ".join(ports.get(names[-1]) or ports.get(names[0]) or []) + if target: + client.append(f"{row} => {target}") + elif any(names[-1].startswith(path) for path in CLIENT_PATHS): + client.append(f"{row} => (no Python port yet)") + else: + shared.append(row) + rules = read_rules([]) + tests: List[str] = [] + for row in diff(TEST_PATHS): + path = row[2:].split(" -> ")[-1] + test_file = _python_test_file(path, rules) or _golden_file(path) + tests.append(f"{row} => {test_file}" if test_file else row) + + section( + "Client: port each hunk line by line (playwright/_impl/transform.md)", client + ) + section( + "Shared code that Python does not port: port it if the client starts to use it", + shared, + ) + section( + "Tests: port each hunk line by line, async and sync (tests/transform.md);" + " copy each snapshot image to its golden file", + tests, + ) + section("Test assets: copy to tests/assets", diff(ASSET_PATHS)) + section( + "Generated: scripts/update_api.sh generates the Python files", + diff(GENERATED_FROM_PATHS + UPSTREAM_GENERATED), + ) + + +def main(argv: List[str]) -> None: + command, args = (argv[0], argv[1:]) if argv else ("", []) + if command == "checkout" and not args: + checkout() + elif command == "pin" and len(args) == 1: + pin(args[0]) + elif command == "changes" and len(args) <= 1: + changes(args[0] if args else None) + elif command == "resolve" and len(args) == 1: + print(resolve(args[0])) + else: + raise SystemExit(__doc__) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/setup.py b/setup.py index 1cb06ace6..786df1c4d 100644 --- a/setup.py +++ b/setup.py @@ -23,21 +23,12 @@ from typing import Dict -# The driver is assembled by scripts/build_driver.py from published artifacts: -# the playwright-core npm package (version pinned in DRIVER_VERSION) and the -# official Node.js binaries (pinned in NODE_VERSION). DRIVER_VERSION is the -# single source of truth (also read by CI) and is baked into the staged bundle -# filenames, so it doubles as the build cache key: a roll changes DRIVER_VERSION, -# which changes the filenames. -# DRIVER_VERSION has two lines: npm= and -# rev=. +# A wheel build builds the driver: scripts/build_driver.py builds playwright-core +# in .upstream/, the microsoft/playwright checkout at the commit in DRIVER_VERSION, +# and adds the Node.js binary of NODE_VERSION. The bundles are named after the +# commit (driver/playwright--.zip), so a roll builds new ones. def read_driver_version() -> str: - pins = dict( - line.split("=", 1) - for line in (Path(__file__).parent / "DRIVER_VERSION").read_text().splitlines() - if line.strip() - ) - return pins["npm"].strip() + return (Path(__file__).parent / "DRIVER_VERSION").read_text().strip() driver_version = read_driver_version() @@ -120,9 +111,8 @@ def ensure_driver_bundle(zip_name: str) -> None: destination_path = f"driver/playwright-{driver_version}-{zip_name}.zip" if os.path.exists(destination_path): return - # Assemble this platform's bundle by downloading the playwright-core npm - # package and the matching Node.js binary. Only the requested platform is - # built, so a wheel build downloads just the one Node.js binary it needs. + # Build only this platform's bundle, so that a wheel build downloads just the + # one Node.js binary it needs. build_script = os.path.join(os.path.dirname(__file__), "scripts", "build_driver.py") subprocess.check_call([sys.executable, build_script, zip_name]) if not os.path.exists(destination_path): @@ -133,6 +123,9 @@ def ensure_driver_bundle(zip_name: str) -> None: class PlaywrightBDistWheelCommand(BDistWheelCommand): def run(self) -> None: + # setuptools copies the package into build/lib and keeps what is there: a + # module that was deleted since the last build would go into the wheel. + shutil.rmtree("build", ignore_errors=True) super().run() os.makedirs("driver", exist_ok=True) os.makedirs("playwright/driver", exist_ok=True) diff --git a/tests/transform.md b/tests/transform.md index e30558a6c..bc173bc6b 100644 --- a/tests/transform.md +++ b/tests/transform.md @@ -2,7 +2,9 @@ These rules say how to write `tests/async/{page,library}` and `tests/sync/{page,library}` from `tests/page` and `tests/library` in `microsoft/playwright`, at the commit in -`DRIVER_VERSION`. Use them at each roll, for every new or changed upstream test. +`DRIVER_VERSION`. Use them at each roll, for every new or changed upstream test. The +upstream tests are in `.upstream/`, the checkout at that commit. The steps of a roll are +in the `playwright-roll` skill. The port is line by line, with **zero deviations**. A reviewer reads the `.spec.ts` file and the two `.py` files side by side. Each upstream statement maps to one Python @@ -140,13 +142,3 @@ A behavior that no upstream test covers (the sync API, asyncio, Python types, `_ a regression of this repository) gets a test in `tests/async/python/` or `tests/sync/python/`, in the file named after the upstream file of the feature. These are not ports: see the second rule of tests in `AGENTS.md`, and keep them few. - -## 7. At each roll - -1. Copy the registry: `cp "$PW_SRC_DIR/tests/registry.md" tests/upstream_registry.md`. -2. `python scripts/check_test_registry.py --report` lists the identifiers to port, the - titles that changed and the tests that upstream removed. -3. For each changed upstream test, diff its `.spec.ts` between the two commits and port - each hunk into both flavors, line by line. -4. Run the ported files on the three browsers, in both flavors. A failure is a bug to - fix in the client, the driver or the environment, not in the test. diff --git a/utils/docker/build.sh b/utils/docker/build.sh index d46af9f10..e9f6fb7c7 100755 --- a/utils/docker/build.sh +++ b/utils/docker/build.sh @@ -10,7 +10,7 @@ if [[ ($1 == '--help') || ($1 == '-h') || ($1 == '') || ($2 == '') ]]; then echo "" echo " docker run --rm -it playwright:localbuild-noble /bin/bash" echo "" - echo "NOTE: this requires on Playwright PIP dependencies to be installed" + echo "NOTE: this requires the Playwright PIP dependencies and .upstream/ (python scripts/upstream.py checkout)" echo "" exit 0 fi