diff --git a/.claude/skills/playwright-roll/SKILL.md b/.claude/skills/playwright-roll/SKILL.md index 54b399780..d075aa20c 100644 --- a/.claude/skills/playwright-roll/SKILL.md +++ b/.claude/skills/playwright-roll/SKILL.md @@ -5,7 +5,7 @@ description: Roll Playwright Python to a new driver version. Walks the upstream # Rolling Playwright Python -The goal of a roll is to move the driver pin in `DRIVER_SHA` to a new release, port every public API change introduced upstream during that interval, and suppress the rest, so that `./scripts/update_api.sh` runs clean and the test suite still passes. +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` runs clean and the test suite still passes. The previous human-facing summary lives in `../../../ROLLING.md`. This skill is the operational playbook — read it end to end before starting. @@ -15,7 +15,7 @@ The Python port is hand-written code in `playwright/_impl/`, plus a generator (` 1. introspects the Python `_impl` classes via `inspect`, 2. emits typed wrapper classes into `playwright/{async,sync}_api/_generated.py`, and -3. diffs the introspected surface against `playwright/driver/package/api.json` (built into the new driver from source). +3. diffs the introspected surface against the Playwright `api.json` (generated from the upstream docs — see step 2). Anything in `api.json` that is missing or differently typed in `_impl/` causes generation to fail. Three resolutions: @@ -37,37 +37,49 @@ The upstream documentation source of truth is `docs/src/api/*.md` in the playwri - 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 and build it from source +### 2. Bump the driver pin, download it, and generate api.json + +You need a nearby `microsoft/playwright` checkout for the docs walk and for +`api.json` generation. Point `PW_SRC_DIR` at it and check out the new tag there: ```sh -# Edit DRIVER_SHA (repo root): replace with the microsoft/playwright commit SHA -# for the new release, e.g. the commit that v points at. -# 87bb9ddbd78f329df18c2b24847bc9409240cd07 -# Update the "# microsoft/playwright @ v" comment in scripts/build_driver.sh too. +export PW_SRC_DIR=../playwright +git -C "$PW_SRC_DIR" fetch --tags origin +git -C "$PW_SRC_DIR" checkout v # e.g. v1.62.0 +``` + +Then bump the pins and assemble the driver: + +```sh +# Edit DRIVER_VERSION (repo root): the playwright-core npm version for the new +# release, no "v" prefix, e.g. 1.62.0 +python scripts/update_node_version.py # refresh NODE_VERSION to the current LTS source env/bin/activate -python -m build --wheel # clones microsoft/playwright @ DRIVER_SHA and builds the driver from source +python -m build --wheel # downloads playwright-core @ DRIVER_VERSION + Node.js, assembles the driver playwright install chromium # NOT --with-deps; sudo is denied + +# api.json isn't in the bundle, and the walk below inspects `langs` from it. +# Generate a copy to inspect (update_api.sh generates its own temp copy in step 6): +API_JSON_MODE=1 node "$PW_SRC_DIR/utils/doclint/generateApiJson.js" > /tmp/api.json ``` -The wheel build clones `microsoft/playwright` at the commit in `DRIVER_SHA` -into `driver/playwright-src`, runs `npm ci && npm run build`, and runs upstream's -`utils/build/build-playwright-driver.sh` to produce the per-platform driver -bundles (`driver/playwright--*.zip`), then unpacks the driver under -`playwright/driver/package/`. From this point, -`playwright/driver/package/api.json` reflects the new release. This requires -**Node.js, npm, git and bash** on PATH; the first build is slow (full upstream -build + per-platform Node downloads). +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/`. `api.json` is the +one piece not shipped in the bundle — it's generated from `$PW_SRC_DIR` on demand +(here to `/tmp/api.json` for the walk, and into a temp file passed via +`PW_API_JSON` by `./scripts/update_api.sh` during codegen). ### 3. Identify the commit range -The build step (step 2) clones the upstream monorepo into `driver/playwright-src`. +Use the nearby `microsoft/playwright` checkout at `$PW_SRC_DIR` (from step 2). Bring it up to date and ensure release branches/tags are present before walking the range: ```sh -git -C driver/playwright-src fetch --tags -git -C driver/playwright-src fetch origin 'release-*:release-*' +git -C "$PW_SRC_DIR" fetch --tags +git -C "$PW_SRC_DIR" fetch origin 'release-*:release-*' ``` There is sometimes no `vX.Y.0` tag for the latest release (the bots cut release branches first and tag later). Anchor on commits, not tags. @@ -76,14 +88,14 @@ The diff range is "every commit on the new release branch since the previous rel - **Previous release end**: the `chore: bump version to vX.Y.0-next` commit on `main`. That commit is the first commit *after* the previous release (X.Y-1) was cut. Use its parent (`~1`) as the lower bound. ```sh - git -C driver/playwright-src log --all --grep="bump version to v" --oneline | head + git -C "$PW_SRC_DIR" log --all --grep="bump version to v" --oneline | head ``` - **New release end**: the tip of `release-` (or the matching tag if it exists). Save the commit list, oldest first, scoped to `docs/src/api/`: ```sh -git -C driver/playwright-src log ~1..release- --oneline --reverse -- docs/src/api > /tmp/roll--commits.md +git -C "$PW_SRC_DIR" log ~1..release- --oneline --reverse -- docs/src/api > /tmp/roll--commits.md ``` A normal roll yields 50–100 commits. If you see 0 or thousands, the range is wrong. @@ -95,7 +107,7 @@ Format the file as a markdown checklist and add the standard preamble (status le For each commit, in chronological order: ```sh -git -C driver/playwright-src show -- docs/src/api/ +git -C "$PW_SRC_DIR" show -- docs/src/api/ ``` Look for: @@ -113,7 +125,7 @@ Before tagging anything as MISMATCH or N/A based on appearance, dump the actual ```python import json -data = json.load(open("playwright/driver/package/api.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) @@ -140,7 +152,7 @@ A few rules of thumb that catch most "actually a PORT" cases: #### PORT -Implement the change in `playwright/_impl/.py`. Use the upstream JS implementation as a reference: `driver/playwright-src/packages/playwright-core/src/client/.ts`. Translate idioms: +Implement the change in `playwright/_impl/.py`. Use the upstream JS implementation as a reference: `$PW_SRC_DIR/packages/playwright-core/src/client/.ts`. Translate idioms: | Upstream JS | Python | |---|---| @@ -205,13 +217,18 @@ Tick the box in `/tmp/roll--commits.md` with one line: `[x] ### 5. Regenerate ```sh -./scripts/update_api.sh +PW_SRC_DIR=../playwright ./scripts/update_api.sh # PW_SRC_DIR already exported in step 2 ``` The script does, in order: -1. `git checkout HEAD -- playwright/{async,sync}_api/_generated.py` (resets to last committed), -2. runs `scripts/generate_{sync,async}_api.py` which dumps to `.x` then renames into place, -3. invokes `pre-commit run --files` on the generated files. +1. generates `api.json` from `$PW_SRC_DIR` into a temp file and exports `PW_API_JSON`, +2. `git checkout HEAD -- playwright/{async,sync}_api/_generated.py` (resets to last committed), +3. runs `scripts/generate_{sync,async}_api.py` (they read `api.json` via `PW_API_JSON`), dumping to `.x` then renaming into place, +4. invokes `pre-commit run --files` on the generated files. + +**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: @@ -245,7 +262,7 @@ For each PORT, add one async test and a matching sync test. Conventions: ### 7. Update existing high-touch artifacts -- `DRIVER_SHA` (and the version comment in `scripts/build_driver.sh`): already done in step 2. +- `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. @@ -281,7 +298,7 @@ Class names use the upstream PascalCase (`BrowserContext`, `BrowserType`); metho - **A cluster of suppressions on the same class is a smell.** If you're about to add five `Method not implemented: Foo.*` lines, you're almost certainly looking at a class that needs to be implemented. Implement the whole thing once and the suppressions disappear. - **Watch for revert pairs in the same range.** 1.59 added and reverted `Browser.isRemote` (#39613 / #39620) inside the same release. Walking chronologically lets you skip the add when you see the revert later. - **Watch for rename-revert pairs.** 1.59 had `Locator.normalize` → `Locator.toCode` (#39648) → `Locator.normalize` (#39754). Final state wins; only port the last. -- **Doc renames almost always include a wire-protocol rename.** Whenever you see `### param: X.y.oldName` → `### param: X.y.newName` in a doc commit, also `git -C driver/playwright-src show -- packages/protocol/src/protocol.yml` and the corresponding `*Dispatcher.ts` file. If the wire field changed too, the channel-send dict key in `_impl/` must change. Suppressing the doc-side mismatch is hiding a real bug — the previous Python code is silently sending an unknown field that the new server ignores. +- **Doc renames almost always include a wire-protocol rename.** Whenever you see `### param: X.y.oldName` → `### param: X.y.newName` in a doc commit, also `git -C "$PW_SRC_DIR" show -- packages/protocol/src/protocol.yml` and the corresponding `*Dispatcher.ts` file. If the wire field changed too, the channel-send dict key in `_impl/` must change. Suppressing the doc-side mismatch is hiding a real bug — the previous Python code is silently sending an unknown field that the new server ignores. - **TypedDicts beat `Dict[str, X]` for any structured return.** When the docs describe a return as `[Object]` with named fields (or even `[Object=Foo]`), define a `TypedDict` in `_api_structures.py`, re-export from both public `__init__.py` files, and use it. Zero runtime cost (it's still a `dict`), and the doc generator's type comparator matches by structure via `get_type_hints`. - **Positional renames are free.** A param with no default before any `*` separator is positional-or-keyword in Python, but realistic call sites pass it positionally. Renaming such a param doesn't break callers. - **The "Backport changes" GitHub issue can be misleading.** In the 1.59 roll its checkboxes were all marked `[x]` with annotations like "✅ IMPLEMENTED", but several of those features had not actually been merged into the Python port. Trust the `docs/src/api/` walk over the issue. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7565cd3dd..0481046de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,31 +17,8 @@ concurrency: cancel-in-progress: true jobs: - build-driver: - name: Build driver - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: '24' - - name: Build driver bundles from source - run: bash scripts/build_driver.sh - - name: Upload driver bundles - uses: actions/upload-artifact@v7 - with: - name: driver-bundles - path: driver/playwright-*.zip - if-no-files-found: error - # The bundles are already-compressed zips; skip re-compression. - compression-level: 0 - retention-days: 1 - infra: name: Lint - needs: build-driver runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -49,29 +26,17 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.10" - - name: Download driver bundles - uses: actions/download-artifact@v8 - with: - name: driver-bundles - path: driver/ - - name: Install dependencies & browsers + - 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 - name: Lint run: pre-commit run --show-diff-on-failure --color=always --all-files - - name: Generate APIs - run: bash scripts/update_api.sh - - name: Verify generated API is up to date - run: git diff --exit-code build: name: Build - needs: build-driver timeout-minutes: 45 strategy: fail-fast: false @@ -125,11 +90,6 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Download driver bundles - uses: actions/download-artifact@v8 - with: - name: driver-bundles - path: driver/ - name: Install dependencies & browsers run: | python -m pip install --upgrade pip @@ -159,7 +119,6 @@ jobs: test-stable: name: Stable - needs: build-driver timeout-minutes: 45 strategy: fail-fast: false @@ -178,11 +137,6 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.10" - - name: Download driver bundles - uses: actions/download-artifact@v8 - with: - name: driver-bundles - path: driver/ - name: Install dependencies & browsers run: | python -m pip install --upgrade pip diff --git a/CLAUDE.md b/CLAUDE.md index af56130b0..e96c9c3a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,11 +10,13 @@ Python bindings for [Playwright](https://playwright.dev). The Python client talk - `playwright/_impl/` — hand-written client implementation (one module per object: `_browser.py`, `_page.py`, `_locator.py`, `_network.py`, etc.). Edit these to add or change behavior. - `playwright/async_api/_generated.py`, `playwright/sync_api/_generated.py` — **auto-generated**. Never edit by hand; rerun `./scripts/update_api.sh` after changing `_impl/` or the driver. -- `scripts/generate_api.py`, `scripts/generate_async_api.py`, `scripts/generate_sync_api.py`, `scripts/documentation_provider.py` — codegen and validation. They diff the Python implementation against the driver's `playwright/driver/package/api.json` and abort if either side is out of sync. +- `scripts/generate_api.py`, `scripts/generate_async_api.py`, `scripts/generate_sync_api.py`, `scripts/documentation_provider.py` — codegen and validation. They diff the Python implementation against Playwright's `api.json` (provided via the `PW_API_JSON` env var; see `scripts/update_api.sh`) and abort if either side is out of sync. - `scripts/expected_api_mismatch.txt` — explicit allowlist of "documented in JS, not in Python" or "named differently in Python" gaps. Lines that no longer apply must be removed. - `tests/async/`, `tests/sync/` — pytest suites. Most new tests are added to the async file with a sync mirror. -- `DRIVER_SHA` — the single source of truth for which Playwright commit the driver is built from (one line, the 40-char `microsoft/playwright` commit SHA). Read by `setup.py`, `scripts/build_driver.sh`, and CI. The wheel build clones `microsoft/playwright` at this commit and builds the driver from source (via `scripts/build_driver.sh` + upstream's `utils/build/build-playwright-driver.sh`). The SHA is baked into the staged bundle filenames (`driver/playwright--.zip`), so it doubles as the build cache key. -- `scripts/build_driver.sh` — clones and builds the upstream driver bundles into `driver/`. A portable bash script (shareable with the other language forks) that needs Node.js, npm, git and bash; invoked from `setup.py`'s `bdist_wheel`. Reads the pin from `DRIVER_SHA`; takes no arguments. +- `DRIVER_VERSION` — the single source of truth for which Playwright release the driver is assembled from (one line, the `playwright-core` npm version, e.g. `1.61.0`, no `v` prefix). Read by `setup.py`, `scripts/build_driver.py`, and CI. The wheel build downloads `playwright-core` at this version from npm plus the matching Node.js binary and assembles the per-platform bundles — no source build. The version is baked into the staged bundle filenames (`driver/playwright--.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`). Pure Python stdlib (no Node/npm/git); invoked from `setup.py`'s `bdist_wheel` with the target platform's suffix (no arg builds all six). +- `api.json` is **not** shipped in the bundle and is never written into the driver — `scripts/update_api.sh` generates it from a nearby `microsoft/playwright` checkout (`$PW_SRC_DIR`) into a temp file and passes it to codegen via `PW_API_JSON` (read by `scripts/documentation_provider.py`). Needed only when regenerating the API, never at runtime. - `ROLLING.md`, `CONTRIBUTING.md` — human-facing setup and roll docs. ## Setup @@ -26,7 +28,7 @@ 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 # clones microsoft/playwright @ DRIVER_SHA and builds the driver from source +python -m build --wheel # downloads playwright-core @ DRIVER_VERSION + Node.js and assembles the driver pre-commit install ``` @@ -39,7 +41,7 @@ If the system lacks `python3-venv`, `uv venv env` is an acceptable substitute (t - Type-check: `mypy playwright`. - Run tests: `pytest --browser chromium [-k name]`. Browsers are installed via `playwright install chromium` (do **not** use `--with-deps`, which requires sudo). -When changing public API, edit `_impl/`, then run `./scripts/update_api.sh`. The script regenerates `_generated.py` and validates against the driver's `api.json`. If validation fails, fix the mismatch in `_impl/`, in `expected_api_mismatch.txt`, or in `documentation_provider.py` — not by hand-editing `_generated.py`. +When changing public API, edit `_impl/`, then run `./scripts/update_api.sh`. The script regenerates `_generated.py` and validates against Playwright's `api.json` (which it generates from `$PW_SRC_DIR`). If validation fails, fix the mismatch in `_impl/`, in `expected_api_mismatch.txt`, or in `documentation_provider.py` — not by hand-editing `_generated.py`. ## Rolling Playwright to a new version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0efe08d2..c124806a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,11 +21,10 @@ pip install -r local-requirements.txt Build and install drivers: -The driver is built from upstream `microsoft/playwright` source, so building a -wheel requires **Node.js (with npm), git and bash** on your PATH. The commit to -build from is pinned in the `DRIVER_SHA` file. The first -`python -m build --wheel` clones that commit and runs its full -build, which is slow. +The driver is assembled from published artifacts — the `playwright-core` npm +package (version pinned in `DRIVER_VERSION`) and the official Node.js binary +(pinned in `NODE_VERSION`). Building a wheel just downloads them; no Node/npm/git +toolchain is required. ```sh pip install -e . @@ -62,8 +61,11 @@ open htmlcov/index.html ### Regenerating APIs +`update_api.sh` generates `api.json` from a nearby `microsoft/playwright` +checkout (at the tag matching `DRIVER_VERSION`); point `PW_SRC_DIR` at it. + ```bash -./scripts/update_api.sh +PW_SRC_DIR=../playwright ./scripts/update_api.sh pre-commit run --all-files ``` diff --git a/DRIVER_SHA b/DRIVER_SHA deleted file mode 100644 index c99ae0b03..000000000 --- a/DRIVER_SHA +++ /dev/null @@ -1 +0,0 @@ -1cc5a90cfa3eaa430b1a991963100f95126caa47 diff --git a/DRIVER_VERSION b/DRIVER_VERSION new file mode 100644 index 000000000..9b083fc63 --- /dev/null +++ b/DRIVER_VERSION @@ -0,0 +1 @@ +1.61.1-beta-1782139630000 diff --git a/NODE_VERSION b/NODE_VERSION new file mode 100644 index 000000000..1dd37d537 --- /dev/null +++ b/NODE_VERSION @@ -0,0 +1 @@ +24.17.0 diff --git a/ROLLING.md b/ROLLING.md index 8e0623dd8..67b333bc1 100644 --- a/ROLLING.md +++ b/ROLLING.md @@ -11,15 +11,14 @@ pip install -r local-requirements.txt pre-commit install pip install -e . ``` -* change the driver pin in `DRIVER_SHA` (the `microsoft/playwright` commit SHA to build from) -* build the new driver from source: `python -m build --wheel` (clones `microsoft/playwright` at that commit and builds it; requires Node.js, npm, git and bash) -* generate API: `./scripts/update_api.sh` +* change the driver pin in `DRIVER_VERSION` (the `playwright-core` npm version, e.g. `1.61.0`) and refresh `NODE_VERSION`: `python scripts/update_node_version.py` +* download the new driver: `python -m build --wheel` (fetches `playwright-core` from npm + the matching Node.js binary and assembles the bundle; no source build) +* generate API (needs a nearby `microsoft/playwright` checkout at `v`): `PW_SRC_DIR=../playwright ./scripts/update_api.sh` * commit changes & send PR * wait for bots to pass & merge the PR ## Fix typing issues with Playwright ToT -1. `cd playwright` -1. `API_JSON_MODE=1 node utils/doclint/generateApiJson.js > ../playwright-python/playwright/driver/package/api.json` -1. `./scripts/update_api.sh` +1. `API_JSON_MODE=1 node ../playwright/utils/doclint/generateApiJson.js > /tmp/api.json` +1. `PW_API_JSON=/tmp/api.json ./scripts/update_api.sh` diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 5d0232a8e..6dfd634a3 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -7291,9 +7291,9 @@ async def install(self) -> None: `navigator.credentials.get()` in all current and future pages. Call this before the page first touches `navigator.credentials`. - Required: until `install()` is called, no interception is in place and the page sees the platform's native (or - absent) WebAuthn behaviour. Seeding credentials with `credentials.create()` without `install()` populates - the authenticator, but the page will never see those credentials. + Required: until `credentials.install()` is called, no interception is in place and the page sees the + platform's native (or absent) WebAuthn behaviour. Seeding credentials with `credentials.create()` without + installing populates the authenticator, but the page will never see those credentials. """ return mapping.from_maybe_impl(await self._impl_obj.install()) @@ -7313,8 +7313,8 @@ async def create( With only `rpId`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential is discoverable (resident), so the page can resolve it from both username-then-passkey and usernameless passkey - flows. The returned object carries the `privateKey` and `publicKey`, so it can be persisted to disk and re-seeded - in a later test. + flows. The returned object carries the private and public keys, so it can be persisted to disk and re-seeded in a + later test. To **import a known credential**, supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. @@ -7372,9 +7372,8 @@ async def get( both credentials seeded with `credentials.create()` and credentials the page registered itself by calling `navigator.credentials.create()`. - Each returned credential includes its `privateKey` and `publicKey`, so a passkey the app just registered can be - saved and re-seeded into a later test with `credentials.create()` — see the second example in the class - overview. + Each returned credential includes its private and public keys, so a passkey the app just registered can be saved + and re-seeded into a later test with `credentials.create()` — see the second example in the class overview. Parameters ---------- @@ -13648,7 +13647,7 @@ class WebStorage(AsyncBase): async def items(self) -> typing.List[NameValue]: """WebStorage.items - Returns all items in the storage as `name`/`value` pairs. + Returns all items in the storage as name/value pairs. Returns ------- @@ -13660,7 +13659,7 @@ async def items(self) -> typing.List[NameValue]: async def get_item(self, name: str) -> typing.Optional[str]: """WebStorage.get_item - Returns the value for the given `name`, or `null` if the key is not present. + Returns the value for the given `name` if present. Parameters ---------- diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index e87cdde1a..f9d9287ae 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -7385,9 +7385,9 @@ def install(self) -> None: `navigator.credentials.get()` in all current and future pages. Call this before the page first touches `navigator.credentials`. - Required: until `install()` is called, no interception is in place and the page sees the platform's native (or - absent) WebAuthn behaviour. Seeding credentials with `credentials.create()` without `install()` populates - the authenticator, but the page will never see those credentials. + Required: until `credentials.install()` is called, no interception is in place and the page sees the + platform's native (or absent) WebAuthn behaviour. Seeding credentials with `credentials.create()` without + installing populates the authenticator, but the page will never see those credentials. """ return mapping.from_maybe_impl(self._sync(self._impl_obj.install())) @@ -7407,8 +7407,8 @@ def create( With only `rpId`, generates a fresh **ECDSA P-256** keypair, credential id and user handle. The seeded credential is discoverable (resident), so the page can resolve it from both username-then-passkey and usernameless passkey - flows. The returned object carries the `privateKey` and `publicKey`, so it can be persisted to disk and re-seeded - in a later test. + flows. The returned object carries the private and public keys, so it can be persisted to disk and re-seeded in a + later test. To **import a known credential**, supply all four of `id`, `userHandle`, `privateKey` and `publicKey` together. @@ -7468,9 +7468,8 @@ def get( both credentials seeded with `credentials.create()` and credentials the page registered itself by calling `navigator.credentials.create()`. - Each returned credential includes its `privateKey` and `publicKey`, so a passkey the app just registered can be - saved and re-seeded into a later test with `credentials.create()` — see the second example in the class - overview. + Each returned credential includes its private and public keys, so a passkey the app just registered can be saved + and re-seeded into a later test with `credentials.create()` — see the second example in the class overview. Parameters ---------- @@ -13730,7 +13729,7 @@ class WebStorage(SyncBase): def items(self) -> typing.List[NameValue]: """WebStorage.items - Returns all items in the storage as `name`/`value` pairs. + Returns all items in the storage as name/value pairs. Returns ------- @@ -13742,7 +13741,7 @@ def items(self) -> typing.List[NameValue]: def get_item(self, name: str) -> typing.Optional[str]: """WebStorage.get_item - Returns the value for the given `name`, or `null` if the key is not present. + Returns the value for the given `name` if present. Parameters ---------- diff --git a/scripts/build_driver.py b/scripts/build_driver.py new file mode 100755 index 000000000..d11cee5e4 --- /dev/null +++ b/scripts/build_driver.py @@ -0,0 +1,242 @@ +#!/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. + +"""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 + +Unlike the old source build this needs no Node.js, npm, git or bash — only the +Python standard library. + +Usage:: + + scripts/build_driver.py # assemble every platform bundle + scripts/build_driver.py # assemble a single bundle, e.g. mac-arm64 + +``setup.py`` invokes the single-suffix form so a wheel build only downloads the +one Node.js binary it needs. +""" + +import os +import shutil +import sys +import tarfile +import tempfile +import time +import urllib.request +import zipfile +from pathlib import Path +from typing import Iterable, List, NamedTuple, Set + +REPO_ROOT = Path(__file__).resolve().parent.parent +DRIVER_DIR = REPO_ROOT / "driver" + +NPM_REGISTRY = "https://registry.npmjs.org" +NODEJS_DIST = "https://nodejs.org/dist" + + +class Platform(NamedTuple): + suffix: str # bundle suffix; matches the "zip_name" values in setup.py + node_dir: str # the nodejs.org archive name infix: node-v- + windows: bool + + +# Keep in sync with the "zip_name" values in setup.py. +PLATFORMS: List[Platform] = [ + Platform("mac", "darwin-x64", False), + Platform("mac-arm64", "darwin-arm64", False), + Platform("linux", "linux-x64", False), + Platform("linux-arm64", "linux-arm64", False), + Platform("win32_x64", "win-x64", True), + Platform("win32_arm64", "win-arm64", True), +] + + +def read_pin(name: str) -> str: + value = (REPO_ROOT / name).read_text().strip() + if not value: + raise SystemExit(f"{name} is empty or missing at {REPO_ROOT / name}") + return value + + +def download(url: str, destination: Path) -> None: + last_error: Exception = RuntimeError("no attempt made") + for attempt in range(1, 6): + try: + print(f"Downloading {url}") + with urllib.request.urlopen(url) as response: # noqa: S310 + with open(destination, "wb") as out: + shutil.copyfileobj(response, out) + return + except Exception as error: # noqa: BLE001 + last_error = error + print(f" attempt {attempt} failed: {error}") + time.sleep(attempt) + raise SystemExit(f"Failed to download {url}: {last_error}") + + +def _extract_members( + tar: tarfile.TarFile, path: Path, members: List[tarfile.TarInfo] +) -> None: + # filter="data" sanitizes paths (rejects "..", absolute names) while keeping + # the read/write/execute bits we rely on. It is only available on 3.12+. + if sys.version_info >= (3, 12): + tar.extractall(path, members=members, filter="data") + else: + tar.extractall(path, members=members) # noqa: S202 + + +def _extract_tar_file(tar: tarfile.TarFile, name: str, destination: Path) -> None: + member = tar.getmember(name) + source = tar.extractfile(member) + if source is None: + raise SystemExit(f"{name} is not a regular file in the archive") + with source, open(destination, "wb") as out: + shutil.copyfileobj(source, out) + # Preserve the executable bit so the Node.js binary stays runnable end to end: + # setup.py's extractall() re-applies the mode it reads back from our zip. + os.chmod(destination, member.mode & 0o777) + + +def _extract_zip_file(archive: zipfile.ZipFile, name: str, destination: Path) -> None: + try: + info = archive.getinfo(name) + except KeyError: + raise SystemExit(f"{name} not found in the archive") + with archive.open(info) as source, open(destination, "wb") as out: + shutil.copyfileobj(source, out) + + +def fetch_playwright_core(version: str, work_dir: Path) -> Path: + """Download playwright-core@ and extract its package/ tree once.""" + url = f"{NPM_REGISTRY}/playwright-core/-/playwright-core-{version}.tgz" + tgz = work_dir / f"playwright-core-{version}.tgz" + download(url, tgz) + 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. + members = [ + m + for m in tar.getmembers() + if m.name == "package" or m.name.startswith("package/") + ] + if not members: + raise SystemExit(f"No package/ entries found in {url}") + _extract_members(tar, work_dir, members) + tgz.unlink() + return work_dir / "package" + + +def fetch_node( + platform: Platform, node_version: str, bundle_dir: Path, work_dir: Path +) -> None: + """Download the Node.js build for a platform and place node + LICENSE in the bundle.""" + node_dir = f"node-v{node_version}-{platform.node_dir}" + ext = "zip" if platform.windows else "tar.gz" + url = f"{NODEJS_DIST}/v{node_version}/{node_dir}.{ext}" + archive = work_dir / f"{node_dir}.{ext}" + download(url, archive) + if platform.windows: + with zipfile.ZipFile(archive) as zf: + _extract_zip_file(zf, f"{node_dir}/node.exe", bundle_dir / "node.exe") + _extract_zip_file(zf, f"{node_dir}/LICENSE", bundle_dir / "LICENSE") + else: + with tarfile.open(archive, "r:gz") as tar: + _extract_tar_file(tar, f"{node_dir}/bin/node", bundle_dir / "node") + _extract_tar_file(tar, f"{node_dir}/LICENSE", bundle_dir / "LICENSE") + archive.unlink() + + +def zip_bundle(bundle_dir: Path, destination: Path) -> None: + files = sorted(p for p in bundle_dir.rglob("*") if p.is_file()) + tmp = destination.with_name(destination.name + ".tmp") + with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for file in files: + # ZipFile.write records the on-disk st_mode in external_attr, so the + # node binary's executable bit survives into the bundle. + zf.write(file, file.relative_to(bundle_dir).as_posix()) + os.replace(tmp, destination) + + +def assemble( + platform: Platform, + version: str, + node_version: str, + package_dir: Path, + work_dir: Path, +) -> None: + bundle_dir = work_dir / platform.suffix + if bundle_dir.exists(): + shutil.rmtree(bundle_dir) + 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" + zip_bundle(bundle_dir, destination) + shutil.rmtree(bundle_dir) + print(f"Created {destination}") + + +def build(suffixes: Set[str]) -> None: + version = read_pin("DRIVER_VERSION") + node_version = read_pin("NODE_VERSION") + DRIVER_DIR.mkdir(exist_ok=True) + + todo = [ + p + for p in PLATFORMS + if p.suffix in suffixes + and not (DRIVER_DIR / f"playwright-{version}-{p.suffix}.zip").exists() + ] + if not todo: + print( + f"Driver bundles for {version} already present in {DRIVER_DIR}; nothing to do." + ) + return + + with tempfile.TemporaryDirectory(prefix="pw-driver-") as tmp_name: + work_dir = Path(tmp_name) + package_dir = fetch_playwright_core(version, work_dir) + for platform in todo: + assemble(platform, version, node_version, package_dir, work_dir) + + +def main(argv: Iterable[str]) -> None: + valid = {p.suffix for p in PLATFORMS} + requested = set(argv) + if requested: + unknown = requested - valid + if unknown: + raise SystemExit( + f"Unknown bundle suffix(es): {', '.join(sorted(unknown))}. " + f"Valid suffixes: {', '.join(p.suffix for p in PLATFORMS)}" + ) + else: + requested = valid + build(requested) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/scripts/build_driver.sh b/scripts/build_driver.sh deleted file mode 100755 index d19733c09..000000000 --- a/scripts/build_driver.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env bash -# 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. - -# Build the Playwright driver bundles from upstream source. -# -# This script checks out microsoft/playwright at the commit pinned in the -# DRIVER_SHA file (repo root) and runs upstream's -# utils/build/build-playwright-driver.sh. That script cross-builds the -# per-platform bundles, which this script stages into driver/ as -# playwright--.zip for setup.py to embed into the platform wheels. -# -# The pin is an immutable commit SHA (tags can be moved upstream) and lives in -# the neutral DRIVER_SHA file so setup.py and CI can read it without parsing -# this script. The SHA is baked into the staged bundle filenames, so the -# filename doubles as the build cache key: a roll changes DRIVER_SHA, which -# changes the filenames and invalidates the cache. -# -# A single host builds all platform bundles at once: the upstream script -# downloads the matching Node.js binary for each target, so the host platform -# does not constrain which bundles can be produced. -# -# This is intentionally a shell script (rather than language-specific code) so -# the same build step can be shared across the Playwright language forks. -# -# Usage: scripts/build_driver.sh (reads the pin from DRIVER_SHA; no arguments) - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -DRIVER_DIR="$REPO_ROOT/driver" -SOURCE_DIR="$DRIVER_DIR/playwright-src" -PLAYWRIGHT_REPO="https://github.com/microsoft/playwright" - -# The driver pin: an immutable commit in microsoft/playwright. -# microsoft/playwright @ main -EXPECTED_SHA="$(tr -d '[:space:]' < "$REPO_ROOT/DRIVER_SHA")" -if [[ -z "$EXPECTED_SHA" ]]; then - echo "DRIVER_SHA is empty or missing at $REPO_ROOT/DRIVER_SHA" >&2 - exit 2 -fi - -# Bundle suffixes produced by utils/build/build-playwright-driver.sh. Keep in -# sync with the "zip_name" values in setup.py. -SUFFIXES=(mac mac-arm64 linux linux-arm64 win32_x64 win32_arm64) - -bundles_present() { - local suffix - for suffix in "${SUFFIXES[@]}"; do - [[ -f "$DRIVER_DIR/playwright-$EXPECTED_SHA-$suffix.zip" ]] || return 1 - done - return 0 -} - -require_tools() { - local missing=() - local tool - for tool in git node npm bash; do - if ! command -v "$tool" >/dev/null 2>&1; then - missing+=("$tool") - fi - done - if [[ ${#missing[@]} -gt 0 ]]; then - echo "Building the Playwright driver from source requires the following tools," >&2 - echo "which were not found on PATH: ${missing[*]}." >&2 - echo "Install Node.js (with npm), git and bash, then retry. On Windows, run the" >&2 - echo "build from a bash shell (e.g. Git Bash)." >&2 - exit 1 - fi -} - -checked_out_sha() { - if [[ -d "$SOURCE_DIR/.git" ]]; then - git -C "$SOURCE_DIR" rev-parse HEAD 2>/dev/null || true - fi -} - -clone_source() { - # Reuse an existing checkout's git object store across rolls: only initialize - # a fresh repo when one isn't already present, then fetch and check out the - # pinned commit. This avoids re-cloning the repo (and re-running npm ci from - # scratch) every time the pin changes. - if [[ ! -d "$SOURCE_DIR/.git" ]]; then - rm -rf "$SOURCE_DIR" - mkdir -p "$SOURCE_DIR" - git -C "$SOURCE_DIR" init -q - git -C "$SOURCE_DIR" remote add origin "$PLAYWRIGHT_REPO" - fi - if [[ "$(checked_out_sha)" != "$EXPECTED_SHA" ]]; then - echo "Fetching $PLAYWRIGHT_REPO at $EXPECTED_SHA" - # Shallow-fetch a single commit. GitHub allows fetching an arbitrary commit - # by SHA, so a full clone is unnecessary. - git -C "$SOURCE_DIR" fetch --depth 1 origin "$EXPECTED_SHA" - git -C "$SOURCE_DIR" checkout -q --detach FETCH_HEAD - fi - # Make sure we landed on exactly the pinned commit. - if [[ "$(checked_out_sha)" != "$EXPECTED_SHA" ]]; then - echo "Checked out commit '$(checked_out_sha)' but '$EXPECTED_SHA' was requested." >&2 - exit 1 - fi -} - -build_source() { - echo "Installing Playwright dependencies (npm ci)" - (cd "$SOURCE_DIR" && npm ci) - # Drop build outputs left over from a previously checked-out commit when the - # checkout is reused across rolls (lib/ dirs are gitignored, so switching - # commits doesn't clear them). - echo "Cleaning previous build outputs (npm run clean)" - (cd "$SOURCE_DIR" && npm run clean) - echo "Building Playwright (npm run build)" - (cd "$SOURCE_DIR" && npm run build) - echo "Building driver bundles" - (cd "$SOURCE_DIR" && bash utils/build/build-playwright-driver.sh) -} - -copy_bundles() { - local output_dir="$SOURCE_DIR/utils/build/output" - # The output dir also holds build intermediates (downloaded Node.js binaries, - # tgz archives, extracted package dirs), so copy only the bundles. Upstream - # names them playwright--.zip; restage each one with the pin - # SHA in the name so the filename doubles as the build cache key. - local suffix matches - for suffix in "${SUFFIXES[@]}"; do - matches=("$output_dir"/playwright-*-"$suffix".zip) - if [[ ! -f "${matches[0]}" ]]; then - echo "Expected driver bundle for '$suffix' was not produced in $output_dir" >&2 - exit 1 - fi - cp "${matches[0]}" "$DRIVER_DIR/playwright-$EXPECTED_SHA-$suffix.zip" - done -} - -# Fast path: the bundles for this exact pin are already staged, so there is -# nothing to (re)build. This keeps repeat invocations cheap and lets consumers -# that only downloaded the prebuilt bundles skip the build entirely (no Node). -if bundles_present; then - echo "Driver bundles for $EXPECTED_SHA already present in $DRIVER_DIR; skipping build." - exit 0 -fi - -require_tools -clone_source -build_source -copy_bundles diff --git a/scripts/documentation_provider.py b/scripts/documentation_provider.py index ed4a70e93..32c43e5d2 100644 --- a/scripts/documentation_provider.py +++ b/scripts/documentation_provider.py @@ -13,9 +13,9 @@ # limitations under the License. import json +import os import pathlib import re -import subprocess from sys import stderr from typing import Any, Dict, List, Set, Union, get_args, get_origin, get_type_hints from urllib.parse import urljoin @@ -32,12 +32,14 @@ def __init__(self, is_async: bool) -> None: self.api: Any = {} self.links: Dict[str, str] = {} self.printed_entries: List[str] = [] - process_output = subprocess.run( - ["python", "-m", "playwright", "print-api-json"], - check=True, - capture_output=True, - ) - self.api = json.loads(process_output.stdout) + api_json_path = os.environ.get("PW_API_JSON") + if not api_json_path: + raise RuntimeError( + "PW_API_JSON must point to a Playwright api.json file. Run codegen " + "via ./scripts/update_api.sh, which generates api.json from a " + "microsoft/playwright checkout (PW_SRC_DIR)." + ) + self.api = json.loads(pathlib.Path(api_json_path).read_text(encoding="utf-8")) self.errors: Set[str] = set() self.class_aliases: Dict[str, str] = { "Disposable": "AsyncContextManager" if is_async else "SyncContextManager", diff --git a/scripts/update_api.sh b/scripts/update_api.sh index 616df730c..c2eae493a 100755 --- a/scripts/update_api.sh +++ b/scripts/update_api.sh @@ -1,5 +1,31 @@ #!/bin/bash +# Codegen needs Playwright's api.json (the documented API surface), which the +# assembled driver bundle does not ship — it is generated from the upstream docs +# and is only needed here, when regenerating the API. Generate it into a temp file +# and hand it to the generators via PW_API_JSON (documentation_provider.py reads it +# from there); nothing is written into the driver. If PW_API_JSON is already set, +# use that pre-generated api.json as-is; otherwise generate it from a nearby +# microsoft/playwright checkout pointed to by PW_SRC_DIR (at the tag in +# DRIVER_VERSION). +driver_version="$(cat DRIVER_VERSION)" +if [[ -z "$PW_API_JSON" ]]; then + if [[ -z "$PW_SRC_DIR" ]]; then + echo "Set PW_SRC_DIR to a microsoft/playwright checkout at v${driver_version}" >&2 + echo "(or PW_API_JSON to a pre-generated api.json), e.g.:" >&2 + echo " PW_SRC_DIR=../playwright ./scripts/update_api.sh" >&2 + exit 1 + fi + PW_API_JSON="$(mktemp "${TMPDIR:-/tmp}/playwright-api-json.XXXXXX")" + trap 'rm -f "$PW_API_JSON"' EXIT + echo "Generating api.json from $PW_SRC_DIR" + if ! API_JSON_MODE=1 node "$PW_SRC_DIR/utils/doclint/generateApiJson.js" > "$PW_API_JSON"; then + echo "Failed to generate api.json from $PW_SRC_DIR (a microsoft/playwright checkout at v${driver_version}?)" >&2 + exit 1 + fi +fi +export PW_API_JSON + function update_api { echo "Generating $1" file_name="$1" diff --git a/scripts/update_node_version.py b/scripts/update_node_version.py new file mode 100644 index 000000000..5982c9ef5 --- /dev/null +++ b/scripts/update_node_version.py @@ -0,0 +1,53 @@ +#!/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. + +"""Pin NODE_VERSION to the latest Node.js LTS. + +A Python port of upstream's utils/build/update-playwright-node.mjs: fetch the +Node.js release index and take the most recent LTS release, which is the Node.js +version Playwright bundles. Run this during a roll, alongside bumping +DRIVER_VERSION. (Unlike upstream we don't touch Dockerfiles — the +playwright-python images don't pin a Node.js version; the bundled driver carries +its own Node.js.) +""" + +import json +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +NODE_INDEX_URL = "https://nodejs.org/dist/index.json" + + +def latest_lts_version() -> str: + with urllib.request.urlopen(NODE_INDEX_URL) as response: # noqa: S310 + releases = json.load(response) + # The index is ordered newest-first; each release's "lts" field is false for + # non-LTS lines and the LTS codename otherwise. The first truthy one is the + # latest LTS release, e.g. {"version": "v24.16.0", "lts": "Krypton"}. + for release in releases: + if release.get("lts"): + return str(release["version"]).lstrip("v") + raise SystemExit("No LTS release found in the Node.js release index") + + +def main() -> None: + version = latest_lts_version() + (REPO_ROOT / "NODE_VERSION").write_text(version + "\n") + print(f"NODE_VERSION set to {version}") + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 7a355d705..2ceb4d18a 100644 --- a/setup.py +++ b/setup.py @@ -22,11 +22,13 @@ from pathlib import Path from typing import Dict -# The driver is built from microsoft/playwright at the commit pinned in the -# DRIVER_SHA file (the single source of truth, also read by scripts/build_driver.sh -# and CI). The SHA is baked into the staged bundle filenames, so it doubles as -# the build cache key: a roll changes DRIVER_SHA, which changes the filenames. -driver_sha = (Path(__file__).parent / "DRIVER_SHA").read_text().strip() +# 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 = (Path(__file__).parent / "DRIVER_VERSION").read_text().strip() base_wheel_bundles = [ { @@ -103,16 +105,17 @@ def extractall(zip: zipfile.ZipFile, path: str) -> None: def ensure_driver_bundle(zip_name: str) -> None: - destination_path = f"driver/playwright-{driver_sha}-{zip_name}.zip" + destination_path = f"driver/playwright-{driver_version}-{zip_name}.zip" if os.path.exists(destination_path): return - # Build the driver bundles from source (microsoft/playwright @ DRIVER_SHA). - # One invocation produces every platform's bundle, so later calls early-return. - build_script = os.path.join(os.path.dirname(__file__), "scripts", "build_driver.sh") - subprocess.check_call(["bash", build_script]) + # 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_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): raise RuntimeError( - f"Driver bundle {destination_path} was not produced by the source build." + f"Driver bundle {destination_path} was not produced by scripts/build_driver.py." ) @@ -154,7 +157,7 @@ def _build_wheel( # Although the build produces every platform's bundle, only this wheel's # target platform driver is extracted and packed below, so the wheel # stays single-platform. - zip_file = f"driver/playwright-{driver_sha}-{wheel_bundle['zip_name']}.zip" + zip_file = f"driver/playwright-{driver_version}-{wheel_bundle['zip_name']}.zip" extract_dir = f"driver/{wheel_bundle['zip_name']}" if os.path.exists(extract_dir): shutil.rmtree(extract_dir) @@ -202,7 +205,7 @@ def _download_and_extract_local_driver( assert len(zip_names_for_current_system) == 1 zip_name = zip_names_for_current_system.pop() ensure_driver_bundle(zip_name) - zip_file = f"driver/playwright-{driver_sha}-{zip_name}.zip" + zip_file = f"driver/playwright-{driver_version}-{zip_name}.zip" if os.path.exists("playwright/driver"): shutil.rmtree("playwright/driver") with zipfile.ZipFile(zip_file, "r") as zip: