diff --git a/.azure-pipelines/publish.yml b/.azure-pipelines/publish.yml index 1a2e0e7a4..2d5f9a0a3 100644 --- a/.azure-pipelines/publish.yml +++ b/.azure-pipelines/publish.yml @@ -40,6 +40,10 @@ extends: inputs: versionSpec: '3.9' displayName: 'Use Python' + - task: NodeTool@0 + inputs: + versionSpec: '24.x' + displayName: 'Use Node.js' - script: | python -m pip install --upgrade pip pip install -r local-requirements.txt @@ -60,7 +64,7 @@ extends: targetPath: $(Build.ArtifactStagingDirectory)/esrp-build steps: - checkout: none - - task: EsrpRelease@9 + - task: EsrpRelease@11 inputs: connectedservicename: 'Playwright-ESRP-PME' usemanagedidentity: true diff --git a/.claude/skills/playwright-roll/SKILL.md b/.claude/skills/playwright-roll/SKILL.md index ece920353..c63669f2e 100644 --- a/.claude/skills/playwright-roll/SKILL.md +++ b/.claude/skills/playwright-roll/SKILL.md @@ -1,23 +1,294 @@ --- name: playwright-roll -description: Roll Playwright Python to a new version +description: Roll Playwright Python to a new driver version. Walks the upstream `docs/src/api/` commit range, ports each public-API change, suppresses the rest in `expected_api_mismatch.txt`, regenerates the typed surface, and adds tests. --- -Help the user roll to a new version of Playwright. -../../../ROLLING.md contains general instructions and scripts. +# Rolling Playwright Python -Start with updating the version and generating the API to see the state of things. +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. -Afterwards, work through the list of changes that need to be backported. -You can find a list of pull requests that might need to be taking into account in the issue titled "Backport changes". -Work through them one-by-one and check off the items that you have handled. -Not all of them will be relevant, some might have partially been reverted, etc. - so feel free to check with the upstream release branch. +The previous human-facing summary lives in `../../../ROLLING.md`. This skill is the operational playbook — read it end to end before starting. -Rolling includes: -- updating client implementation to match changes in the upstream JS implementation (see ../playwright/packages/playwright-core/src/client) -- adding a couple of new tests to verify new/changed functionality +## Mental model -## Tips & Tricks -- Project checkouts are in the parent directory (`../`). -- when updating checkboxes, store the issue content into /tmp and edit it there, then update the issue based on the file -- use the "gh" cli to interact with GitHub +The Python port is hand-written code in `playwright/_impl/`, plus a generator (`scripts/generate_*.py`, `scripts/documentation_provider.py`) that: + +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). + +Anything in `api.json` that is missing or differently typed in `_impl/` causes generation to fail. Three resolutions: + +- **PORT** — the new API is intended for Python (no `langs.only` filter, or `langs.only` includes `"python"`). Implement it in `_impl/`. +- **MISMATCH** — the API genuinely exists for Python but is shaped differently (a callback signature uses unions, a kwarg uses a legacy name, etc.) and there's a justified reason to keep the divergence. Add a precise line to `scripts/expected_api_mismatch.txt` with a comment explaining *why*. +- **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 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). + +## Pre-flight + +You will need two checkouts in the parent directory: +- `~/code/playwright-python` — this repo. +- `~/code/playwright` — the upstream playwright monorepo (used read-only for diffing). + +Bring upstream up to date and ensure release branches/tags are present: + +```sh +git -C ~/code/playwright fetch --tags +git -C ~/code/playwright 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 — see "Identify the commit range" below. + +## Process + +### 1. Set up the env + +`CONTRIBUTING.md` covers this. Notes from past rolls: + +- The repo says Python 3.9 is required, but 3.9+ works. If `python3.9` 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 and build it from source + +```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. + +source env/bin/activate +python -m build --wheel # clones microsoft/playwright @ DRIVER_SHA and builds the driver from source +playwright install chromium # NOT --with-deps; sudo is denied +``` + +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). + +### 3. Identify the commit range + +The diff range is "every commit on the new release branch since the previous release was cut". Anchor commits: + +- **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 ~/code/playwright 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 ~/code/playwright 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. + +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. + +### 4. Walk the commit list + +For each commit, in chronological order: + +```sh +git -C ~/code/playwright 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("playwright/driver/package/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', {})}") +``` + +For options/params nested inside an `Object`-typed arg, walk one level deeper: + +```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", {})) +``` + +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`. Use the upstream JS implementation as a reference: `~/code/playwright/packages/playwright-core/src/client/.ts`. Translate idioms: + +| Upstream JS | Python | +|---|---| +| `async foo(): Promise` | `async def foo(self) -> X:` | +| `foo(): X` (sync getter, no args, no body) | `@property def foo(self) -> X:` (the doc generator treats argument-less sync getters as properties — see `documentation_provider.py:133`. If you make it a method instead, you'll get a "Method vs property mismatch" error.) | +| `await this._channel.foo({ a, b })` | `await self._channel.send("foo", None, locals_to_params(locals()))` | +| `(await this._channel.foo()).value` | `await self._channel.send("foo", None)` (Python's `send()` auto-unwraps single-key responses; only call `send_return_as_dict` when the protocol returns multiple keys.) | +| `(await this._channel.foo()).artifact` (multi-key, may be empty) | `result = await self._channel.send_return_as_dict("foo", None); (result or {}).get("artifact")` — `send_return_as_dict` returns **`None`** (not `{}`) when the protocol response carries no fields. | +| `try { ... } catch (e) { if (isTargetClosedError(e)) return; throw e; }` | `try: ...; except Exception as e: if is_target_closed_error(e): return; raise` (import from `playwright._impl._errors`) | +| Inline `[Object]` return like `{endpoint: string}` | A `TypedDict` in `playwright/_impl/_api_structures.py` — *not* `Dict[str, str]`. The doc generator serializes TypedDicts as `{field: type, ...}` via `get_type_hints` and that matches the inline-object form exactly. See `RemoteAddr`, `BrowserBindResult`, `DebuggerPausedDetails`. | +| `binary` event/return field | The Python channel layer hands you a base64 string. Decode with `base64.b64decode(value)` before exposing as `bytes`. See `Screencast._dispatch_frame`. | + +When implementing a new ChannelOwner subclass (one constructed by the protocol with `(parent, type, guid, initializer)`): +1. Register it in `playwright/_impl/_object_factory.py:create_remote_object` — otherwise the guid resolves to `DummyObject` and downstream code breaks mysteriously. +2. Import it and add it to `generated_types` in `scripts/generate_api.py`, plus add a `XxxImpl` import in the `header` string. + +When implementing a non-ChannelOwner wrapper class (a plain class that holds a Page/Context reference, like `Screencast`, `Clock`): +- Set `self._loop = parent._loop` and `self._dispatcher_fiber = parent._dispatcher_fiber` in `__init__`. The generated `AsyncBase`/`SyncBase` wrappers read these; missing them gives `AttributeError: 'X' object has no attribute '_loop'` at first use. + +When adding a new TypedDict in `_api_structures.py`: +- Add it to the `from playwright._impl._api_structures import …` line in `scripts/generate_api.py` so the generator can resolve it as a forward reference in type hints. +- 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 suppressed in `expected_api_mismatch.txt`, **remove that line** when implementing it. + +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/src/protocol.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": …}`). A "Parameter not documented" suppression for a renamed param is a code smell hiding a wire-protocol bug. + +#### MISMATCH + +A MISMATCH is a *justified, durable* divergence between the docs and the Python surface. Use it sparingly — most apparent mismatches turn out to be PORTs you skipped. Legitimate examples in the current `expected_api_mismatch.txt`: + +- Hidden internal kwargs (`Browser.new_context(default_browser_type=)`). +- Callback signatures where Python explicitly unions one-arg and two-arg variants but the docs document only the canonical form (`Page.route(handler=)`, `WebSocketRoute.on_close(handler=)`). + +Add a precise line to `scripts/expected_api_mismatch.txt` with a `# comment` group header explaining *why* the divergence is intentional. The exact wording comes from the generator's error message. Examples: + +``` +# One vs two arguments in the callback, Python explicitly unions. +Parameter type mismatch in Page.route(handler=): documented as Callable[[Route, Request], Union[Any, Any]], code has Union[Callable[[Route, Request], Any], Callable[[Route], Any]] +``` + +The generator removes lines from `expected_api_mismatch.txt` that no longer match an error. If you see "No longer there: …" in the script's stderr, delete that line. + +**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 (`Parameter not documented: X.y(old_name=)` + `Parameter not implemented: X.y(new_name=)`). Just rename in `_impl/` and update the channel-send dict key. +- A `Parameter type mismatch in X.y(return=): documented as {field: T}, code has Dict[str, T]`. Use a TypedDict. + +#### N/A + +Common N/A flavors: +- Whole new class with `langs: js` (Disposable, 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 + +```sh +./scripts/update_api.sh +``` + +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. + +Failure modes and fixes: + +| Symptom | Cause | Fix | +|---|---|---| +| `Method not implemented: X.y` | `api.json` documents `X.y`, no Python impl exists. | PORT it, or add a MISMATCH line. | +| `Parameter not implemented: X.y(z=)` | New parameter on existing method. | Add the kwarg in `_impl/`, or MISMATCH. | +| `Method vs property mismatch: X.y` | You implemented as a method but the doc treats it as a property (sync, no args, has return type). | Add `@property` in `_impl/`. | +| `Method not documented: X.y` | Python has it but `api.json` doesn't. | The upstream removed the API; remove from `_impl/` and from `_generated.py` callers. | +| `Parameter type mismatch in X.y(z=): documented as ..., code has ...` | Type signature doesn't line up. | Match the type in `_impl/`, or MISMATCH it for known historical divergences. | +| `pyright … reportInconsistentOverload` (single-event class) | A class gained its first event in `api.json`, so the generator emits one `Literal[…]` overload + impl, which pyright wants ≥2 of. | The generator already handles this — `documentation_provider.print_events` emits a second `@typing.overload` with `event: str` plus a generic impl. If you regress this, see how 1.59 handled `CDPSession` getting a `close` 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. | +| `Parameter not documented: X.y(z=)` | Python has a kwarg the docs don't mention (e.g. legacy name from a doc rename). | If the param is positional with no default, just rename it in `_impl/`. Check `protocol.yml` and the server dispatcher — if the wire field renamed too, also update the channel-send dict key. Only MISMATCH for genuinely hidden internal kwargs (`default_browser_type`). | +| `KeyError: 'templates'` deep in `inner_serialize_doc_type` | A `Promise\|X` union upstream collapsed to a bare `Promise` with no templates in `api.json`. | `documentation_provider.inner_serialize_doc_type` should treat that as `Any` (`if "templates" not in type: return "Any"`). | +| `"void" is not defined (reportUndefinedVariable)` in generated event handlers | `api.json` has a `void`-typed event payload that the serializer left as the literal string `"void"`. | `documentation_provider.inner_serialize_doc_type` should map `"void"` to `"None"` alongside `"null"`. | +| `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`. | +| `'NoneType' object has no attribute 'get'` after `send_return_as_dict` | Method's protocol response carries no fields and `send_return_as_dict` returned `None`. | `(result or {}).get(...)`. | +| 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 + +For each PORT, add one async test and a matching sync test. Conventions: + +- Tests go in the existing topic file (`test_page_network_request.py`, `test_browsercontext.py`, `test_dialog.py`, …) — don't create new files unless there's no obvious home. +- 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. + +### 7. Update existing high-touch artifacts + +- `DRIVER_SHA` (and the version comment in `scripts/build_driver.sh`): 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. + +### 8. Final verification + +```sh +pre-commit run --all-files +mypy playwright # 2 pre-existing errors in _json_pipe.py and _artifact.py are unrelated +pytest --browser chromium tests/async/ tests/sync/ +``` + +Then a smoke regression on a few neighboring suites (`tests/async/test_browser*.py`, `test_cdp_session.py`, `test_tracing.py`, `test_dialog.py`, `test_page_*.py`) to make sure nothing inherent to the port shifted. + +## Reference: `expected_api_mismatch.txt` line forms + +Exact strings the generator emits (and that this file must contain to suppress): + +``` +Method not implemented: . +Parameter not implemented: .(=) +Parameter not documented: .(=) +Method vs property mismatch: . +Method not documented: . +Parameter type mismatch in .(=): documented as , code has +``` + +Class names use the upstream PascalCase (`BrowserContext`, `BrowserType`); method/param names are converted to `snake_case` matching the Python surface. + +## 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 line in `expected_api_mismatch.txt` 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 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 ~/code/playwright 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. +- **`api.json` may carry doclint quirks.** 1.59 hit two: `Promise|X` collapsed to a bare `Promise` with no `templates`, and `void`-typed events serialized as the literal string `"void"`. Both are upstream artifacts; patch `inner_serialize_doc_type` to handle them rather than fighting the api.json side. +- **Don't edit `_generated.py` to fix lint or typing.** Fix `_impl/`, `documentation_provider.py`, or `expected_api_mismatch.txt` 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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65ba1f433..e486f6983 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,15 +17,43 @@ 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@v4 + with: + node-version: '24' + - name: Build driver bundles from source + run: bash scripts/build_driver.sh + - name: Upload driver bundles + uses: actions/upload-artifact@v4 + 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@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.10" + - name: Download driver bundles + uses: actions/download-artifact@v4 + with: + name: driver-bundles + path: driver/ - name: Install dependencies & browsers run: | python -m pip install --upgrade pip @@ -43,6 +71,7 @@ jobs: build: name: Build + needs: build-driver timeout-minutes: 45 strategy: fail-fast: false @@ -50,7 +79,18 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ['3.9', '3.10'] browser: [chromium, firefox, webkit] + exclude: + # WebKit on standard macOS-latest (currently macos-15-arm64) is unstable; + # upstream pins paid macos-15-xlarge for cross-browser webkit too. + - os: macos-latest + browser: webkit include: + - os: macos-15-xlarge + python-version: '3.9' + browser: webkit + - os: macos-15-xlarge + python-version: '3.10' + browser: webkit - os: windows-latest python-version: '3.11' browser: chromium @@ -80,11 +120,16 @@ jobs: browser: chromium runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + - name: Download driver bundles + uses: actions/download-artifact@v4 + with: + name: driver-bundles + path: driver/ - name: Install dependencies & browsers run: | python -m pip install --upgrade pip @@ -114,6 +159,7 @@ jobs: test-stable: name: Stable + needs: build-driver timeout-minutes: 45 strategy: fail-fast: false @@ -127,11 +173,16 @@ jobs: browser-channel: msedge runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.10" + - name: Download driver bundles + uses: actions/download-artifact@v4 + with: + name: driver-bundles + path: driver/ - name: Install dependencies & browsers run: | python -m pip install --upgrade pip @@ -155,28 +206,6 @@ jobs: if: matrix.os == 'ubuntu-latest' run: xvfb-run pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90 - build-conda: - name: Conda Build - strategy: - fail-fast: false - matrix: - os: [ubuntu-22.04, macos-13, windows-2022] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: Get conda - uses: conda-incubator/setup-miniconda@v3 - with: - python-version: 3.9 - channels: conda-forge - miniconda-version: latest - - name: Prepare - run: conda install conda-build conda-verify - - name: Build - run: conda build . - test_examples: name: Examples runs-on: ubuntu-22.04 @@ -184,7 +213,7 @@ jobs: run: working-directory: examples/todomvc/ steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index c6e71028a..000000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Upload Python Package -on: - release: - types: [published] - workflow_dispatch: -jobs: - deploy-conda: - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - target-platform: linux-x86_64 - - os: ubuntu-latest - target-platform: linux-aarch64 - - os: windows-latest - target-platform: win-64 - - os: macos-latest-large - target-platform: osx-intel - - os: macos-latest-xlarge - target-platform: osx-arm64 - runs-on: ${{ matrix.os }} - defaults: - run: - # Required for conda-incubator/setup-miniconda@v3 - shell: bash -el {0} - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: Get conda - uses: conda-incubator/setup-miniconda@v3 - with: - python-version: 3.9 - channels: conda-forge - miniconda-version: latest - - name: Prepare - run: conda install anaconda-client conda-build conda-verify - - name: Build and Upload - env: - ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }} - run: | - conda config --set anaconda_upload yes - if [ "${{ matrix.target-platform }}" == "osx-arm64" ]; then - conda build --user microsoft . -m conda_build_config_osx_arm64.yaml - elif [ "${{ matrix.target-platform }}" == "linux-aarch64" ]; then - conda build --user microsoft . -m conda_build_config_linux_aarch64.yaml - else - conda build --user microsoft . - fi diff --git a/.github/workflows/publish_docker.yml b/.github/workflows/publish_docker.yml index 7494f1abc..710b70193 100644 --- a/.github/workflows/publish_docker.yml +++ b/.github/workflows/publish_docker.yml @@ -15,9 +15,9 @@ jobs: contents: read # This is required for actions/checkout to succeed environment: Docker steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Azure login - uses: azure/login@v2 + uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }} @@ -28,8 +28,12 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.10" + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' - name: Set up Docker QEMU for arm64 docker builds - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 with: platforms: arm64 - name: Install dependencies & browsers diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 464eb3b46..56d3420ce 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -30,11 +30,15 @@ jobs: - ubuntu-24.04 - ubuntu-24.04-arm steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.10" + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -53,6 +57,10 @@ jobs: 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/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..af56130b0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +Guidance for Claude when working in this repository. + +## What this is + +Python bindings for [Playwright](https://playwright.dev). The Python client talks JSON over a pipe to the Node-based driver bundled in `playwright/driver/`. The pipe protocol is defined upstream in `packages/protocol/src/protocol.yml`. + +## Layout + +- `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/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. +- `ROLLING.md`, `CONTRIBUTING.md` — human-facing setup and roll docs. + +## Setup + +`CONTRIBUTING.md` has the full sequence. The short version (needs Node.js, npm, git and bash for the driver build): + +```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 # clones microsoft/playwright @ DRIVER_SHA and builds the driver from source +pre-commit install +``` + +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). +- Lint everything: `pre-commit run --all-files`. +- 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`. + +## Rolling Playwright to a new version + +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. + +## Working on PRs + +- Never post comments, replies, or reviews on GitHub PRs/issues under my account without my explicit approval. Draft the proposed text and wait for me to approve before sending. + +## House style + +- Don't hand-edit generated files. +- Don't add `# type: ignore` or modify `_generated.py` to silence pyright; fix the source of the mismatch. +- New public methods on impl classes need a sync test mirror under `tests/sync/`. +- Keep `expected_api_mismatch.txt` minimal — every entry needs a one-line rationale comment above it. +- Prefer `locals_to_params(locals())` for forwarding optional kwargs to channel sends, matching the rest of the codebase. + +## Commit Convention + +Before committing, run `mypy playwright` and fix errors. + +Semantic commit messages: `label(scope): description` + +Labels: `fix`, `feat`, `chore`, `docs`, `test`, `devops` + +```bash +git checkout -b fix-12345 +# ... make changes ... +git add +git commit -m "$(cat <<'EOF' +fix(asyncio): do not deadlock in atexit handler + +Fixes: https://github.com/microsoft/playwright-python/issues/12345 +EOF +)" +git push origin fix-12345 +gh pr create --repo microsoft/playwright-python --head username:fix-12345 \ + --title "fix(asyncio): do not deadlock in atexit handler" \ + --body "$(cat <<'EOF' +## Summary +- + +Fixes https://github.com/microsoft/playwright-python/issues/12345 +EOF +)" +``` + +Never add Co-Authored-By agents in commit message. +Never add "Generated with" in commit message. +Never add test plan to PR description. Keep PR description short — a few bullet points at most. +Branch naming for issue fixes: `fix-` + +**Never `git push` without an explicit instruction to push.** Applies even when a PR is already open for the branch — additional commits are immediately visible to reviewers. Commit locally, report what was committed, and wait. Only push when the user's message contains "push", "upload", "create PR", "ship it", or equivalent. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b59e281c8..ed7165fe0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,12 @@ 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. + ```sh pip install -e . python -m build --wheel diff --git a/DRIVER_SHA b/DRIVER_SHA new file mode 100644 index 000000000..cce0793a8 --- /dev/null +++ b/DRIVER_SHA @@ -0,0 +1 @@ +87bb9ddbd78f329df18c2b24847bc9409240cd07 diff --git a/README.md b/README.md index c5e2d70b0..c9ce32470 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# 🎭 [Playwright](https://playwright.dev) for Python [![PyPI version](https://badge.fury.io/py/playwright.svg)](https://pypi.python.org/pypi/playwright/) [![Anaconda version](https://img.shields.io/conda/v/microsoft/playwright)](https://anaconda.org/Microsoft/playwright) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) +# 🎭 [Playwright](https://playwright.dev) for Python [![PyPI](https://badge.fury.io/py/playwright.svg)](https://pypi.python.org/pypi/playwright/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) Playwright is a Python library to automate [Chromium](https://www.chromium.org/Home), [Firefox](https://www.mozilla.org/en-US/firefox/new/) and [WebKit](https://webkit.org/) browsers with a single API. Playwright delivers automation that is **ever-green**, **capable**, **reliable** and **fast**. [See how Playwright is better](https://playwright.dev/python). | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 145.0.7632.6 | ✅ | ✅ | ✅ | -| WebKit 26.0 | ✅ | ✅ | ✅ | -| Firefox 146.0.1 | ✅ | ✅ | ✅ | +| Chromium 148.0.7778.96 | ✅ | ✅ | ✅ | +| WebKit 26.4 | ✅ | ✅ | ✅ | +| Firefox 150.0.2 | ✅ | ✅ | ✅ | ## Documentation diff --git a/ROLLING.md b/ROLLING.md index 811d7fcb3..601c6c5b1 100644 --- a/ROLLING.md +++ b/ROLLING.md @@ -11,8 +11,8 @@ pip install -r local-requirements.txt pre-commit install pip install -e . ``` -* change driver version in `setup.py` -* download new driver: `python -m build --wheel` +* 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` * commit changes & send PR * wait for bots to pass & merge the PR diff --git a/conda_build_config_linux_aarch64.yaml b/conda_build_config_linux_aarch64.yaml deleted file mode 100644 index 68dceb2e3..000000000 --- a/conda_build_config_linux_aarch64.yaml +++ /dev/null @@ -1,2 +0,0 @@ -target_platform: -- linux-aarch64 diff --git a/conda_build_config_osx_arm64.yaml b/conda_build_config_osx_arm64.yaml deleted file mode 100644 index d535f7252..000000000 --- a/conda_build_config_osx_arm64.yaml +++ /dev/null @@ -1,2 +0,0 @@ -target_platform: -- osx-arm64 diff --git a/local-requirements.txt b/local-requirements.txt index 8a72b5745..fb5512ea5 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -1,22 +1,23 @@ +asyncio-atexit==1.0.1 autobahn==23.1.2 -black==25.1.0 -build==1.3.0 -flake8==7.2.0 +black==25.11.0 +build==1.4.4 +flake8==7.3.0 mypy==1.17.1 objgraph==3.6.2 Pillow==11.3.0 pixelmatch==0.3.0 pre-commit==3.5.0 -pyOpenSSL==25.1.0 +pyOpenSSL==26.0.0 pytest==8.4.1 pytest-asyncio==1.1.0 -pytest-cov==6.3.0 +pytest-cov==7.1.0 pytest-repeat==0.9.4 -pytest-rerunfailures==15.1 +pytest-rerunfailures==16.0.1 pytest-timeout==2.4.0 pytest-xdist==3.8.0 requests==2.32.5 service_identity==24.2.0 twisted==25.5.0 types-pyOpenSSL==24.1.0.20240722 -types-requests==2.32.4.20250809 +types-requests==2.32.4.20260107 diff --git a/meta.yaml b/meta.yaml deleted file mode 100644 index 343f9b568..000000000 --- a/meta.yaml +++ /dev/null @@ -1,61 +0,0 @@ -package: - name: playwright - version: "{{ environ.get('GIT_DESCRIBE_TAG') | replace('v', '') }}" - -source: - path: . - -build: - number: 0 - script: "{{ PYTHON }} -m pip install . --no-deps -vv" - binary_relocation: False - missing_dso_whitelist: "*" - entry_points: - - playwright = playwright.__main__:main - -requirements: - build: - - python >=3.9 # [build_platform != target_platform] - - pip # [build_platform != target_platform] - - cross-python_{{ target_platform }} # [build_platform != target_platform] - host: - - python >=3.9 - - wheel - - pip - - curl - - setuptools_scm - run: - - python >=3.9 - # This should be the same as the dependencies in pyproject.toml - - greenlet>=3.1.1,<4.0.0 - - pyee>=13,<14 - -test: # [build_platform == target_platform] - files: - - scripts/example_sync.py - - scripts/example_async.py - requires: - - pip - imports: - - playwright - - playwright.sync_api - - playwright.async_api - commands: - - playwright --help - - playwright install --with-deps - - python scripts/example_sync.py - - python scripts/example_async.py - -about: - home: https://github.com/microsoft/playwright-python - license: Apache-2.0 - license_family: Apache - license_file: LICENSE - summary: Python version of the Playwright testing and automation library. - description: | - Playwright is a Python library to automate Chromium, - Firefox and WebKit browsers with a single API. Playwright - delivers automation that is ever-green, capable, reliable - and fast. - doc_url: https://playwright.dev/python/docs/intro/ - dev_url: https://github.com/microsoft/playwright-python diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index c0d0ee442..2b9a331c2 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -151,21 +151,40 @@ class ViewportSize(TypedDict): class SourceLocation(TypedDict): url: str + line: int + column: int lineNumber: int columnNumber: int +class WebErrorLocation(TypedDict): + url: str + line: int + column: int + + class FilePayload(TypedDict): name: str mimeType: str buffer: bytes +class DropPayload(TypedDict, total=False): + files: Optional[ + Union[str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload]] + ] + data: Optional[Dict[str, str]] + + class RemoteAddr(TypedDict): ipAddress: str port: int +class BrowserBindResult(TypedDict): + endpoint: str + + class SecurityDetails(TypedDict): issuer: Optional[str] protocol: Optional[str] @@ -212,6 +231,7 @@ class FrameExpectOptions(TypedDict, total=False): useInnerText: Optional[bool] isNot: bool timeout: Optional[float] + pseudo: Optional[str] class FrameExpectResult(TypedDict): @@ -311,3 +331,20 @@ class TracingGroupLocation(TypedDict): file: str line: Optional[int] column: Optional[int] + + +class DebuggerLocation(TypedDict): + file: str + line: Optional[int] + column: Optional[int] + + +class DebuggerPausedDetails(TypedDict): + location: DebuggerLocation + title: str + + +class ScreencastFrame(TypedDict): + data: bytes + viewportWidth: int + viewportHeight: int diff --git a/playwright/_impl/_assertions.py b/playwright/_impl/_assertions.py index aea37d35c..47b4e2d8b 100644 --- a/playwright/_impl/_assertions.py +++ b/playwright/_impl/_assertions.py @@ -13,7 +13,8 @@ # limitations under the License. import collections.abc -from typing import Any, List, Optional, Pattern, Sequence, Union +from contextlib import contextmanager +from typing import Any, Iterator, List, Literal, Optional, Pattern, Sequence, Union from urllib.parse import urljoin from playwright._impl._api_structures import ( @@ -26,10 +27,38 @@ from playwright._impl._errors import Error from playwright._impl._fetch import APIResponse from playwright._impl._helper import is_textual_mime_type +from playwright._impl._js_handle import parse_value from playwright._impl._locator import Locator from playwright._impl._page import Page from playwright._impl._str_utils import escape_regex_flags +_soft_errors: Optional[List[AssertionError]] = None + + +@contextmanager +def _soft_scope() -> Iterator[List[AssertionError]]: + global _soft_errors + assert _soft_errors is None, "nested soft assertion scopes are not supported" + _soft_errors = [] + try: + yield _soft_errors + finally: + _soft_errors = None + + +def _record_soft_or_raise(error: AssertionError, is_soft: bool) -> None: + __tracebackhide__ = True + if is_soft: + if _soft_errors is None: + raise RuntimeError( + "expect.soft(...) requires pytest-playwright>=0.7.3 " + "(or pytest-playwright-asyncio>=0.7.3). Upgrade the plugin, " + "or use a regular expect(...) assertion." + ) + _soft_errors.append(error) + return + raise error + class AssertionsBase: def __init__( @@ -38,6 +67,7 @@ def __init__( timeout: float = None, is_not: bool = False, message: Optional[str] = None, + is_soft: bool = False, ) -> None: self._actual_locator = locator self._loop = locator._loop @@ -45,6 +75,7 @@ def __init__( self._timeout = timeout self._is_not = is_not self._custom_message = message + self._is_soft = is_soft async def _call_expect( self, expression: str, expect_options: FrameExpectOptions, title: Optional[str] @@ -71,7 +102,14 @@ async def _expect_impl( del expect_options["useInnerText"] result = await self._call_expect(expression, expect_options, title) if result["matches"] == self._is_not: - actual = result.get("received") + received = result.get("received") or {} + aria_snapshot = None + if isinstance(received, dict): + aria_snapshot = received.get("ariaSnapshot") + value = received.get("value") + actual = parse_value(value) if value is not None else None + else: + actual = received if self._custom_message: out_message = self._custom_message if expected is not None: @@ -82,8 +120,14 @@ async def _expect_impl( ) error_message = result.get("errorMessage") error_message = f"\n{error_message}" if error_message else "" - raise AssertionError( - f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}" + aria_snapshot_message = ( + f"\nAria snapshot:\n{aria_snapshot}" if aria_snapshot else "" + ) + _record_soft_or_raise( + AssertionError( + f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}{aria_snapshot_message}" + ), + self._is_soft, ) @@ -94,8 +138,9 @@ def __init__( timeout: float = None, is_not: bool = False, message: Optional[str] = None, + is_soft: bool = False, ) -> None: - super().__init__(page.locator(":root"), timeout, is_not, message) + super().__init__(page.locator(":root"), timeout, is_not, message, is_soft) self._actual_page = page async def _call_expect( @@ -109,7 +154,11 @@ async def _call_expect( @property def _not(self) -> "PageAssertions": return PageAssertions( - self._actual_page, self._timeout, not self._is_not, self._custom_message + self._actual_page, + self._timeout, + not self._is_not, + self._custom_message, + self._is_soft, ) async def to_have_title( @@ -161,6 +210,24 @@ async def not_to_have_url( __tracebackhide__ = True await self._not.to_have_url(urlOrRegExp, timeout, ignoreCase) + async def to_match_aria_snapshot( + self, expected: str, timeout: float = None + ) -> None: + __tracebackhide__ = True + await self._expect_impl( + "to.match.aria", + FrameExpectOptions(expectedValue=expected, timeout=timeout), + expected, + "Page expected to match Aria snapshot", + 'Expect "to_match_aria_snapshot"', + ) + + async def not_to_match_aria_snapshot( + self, expected: str, timeout: float = None + ) -> None: + __tracebackhide__ = True + await self._not.to_match_aria_snapshot(expected, timeout) + class LocatorAssertions(AssertionsBase): def __init__( @@ -169,8 +236,9 @@ def __init__( timeout: float = None, is_not: bool = False, message: Optional[str] = None, + is_soft: bool = False, ) -> None: - super().__init__(locator, timeout, is_not, message) + super().__init__(locator, timeout, is_not, message, is_soft) self._actual_locator = locator async def _call_expect( @@ -182,7 +250,11 @@ async def _call_expect( @property def _not(self) -> "LocatorAssertions": return LocatorAssertions( - self._actual_locator, self._timeout, not self._is_not, self._custom_message + self._actual_locator, + self._timeout, + not self._is_not, + self._custom_message, + self._is_soft, ) async def to_contain_text( @@ -400,13 +472,17 @@ async def to_have_css( name: str, value: Union[str, Pattern[str]], timeout: float = None, + pseudo: Literal["after", "before"] = None, ) -> None: __tracebackhide__ = True expected_text = to_expected_text_values([value]) await self._expect_impl( "to.have.css", FrameExpectOptions( - expressionArg=name, expectedText=expected_text, timeout=timeout + expressionArg=name, + expectedText=expected_text, + timeout=timeout, + pseudo=pseudo, ), value, "Locator expected to have CSS", @@ -944,6 +1020,7 @@ def __init__( timeout: float = None, is_not: bool = False, message: Optional[str] = None, + is_soft: bool = False, ) -> None: self._loop = response._loop self._dispatcher_fiber = response._dispatcher_fiber @@ -951,11 +1028,16 @@ def __init__( self._is_not = is_not self._actual = response self._custom_message = message + self._is_soft = is_soft @property def _not(self) -> "APIResponseAssertions": return APIResponseAssertions( - self._actual, self._timeout, not self._is_not, self._custom_message + self._actual, + self._timeout, + not self._is_not, + self._custom_message, + self._is_soft, ) async def to_be_ok( @@ -976,7 +1058,7 @@ async def to_be_ok( if text is not None: out_message += f"\n Response Text:\n{text[:1000]}" - raise AssertionError(out_message) + _record_soft_or_raise(AssertionError(out_message), self._is_soft) async def not_to_be_ok(self) -> None: __tracebackhide__ = True diff --git a/playwright/_impl/_browser.py b/playwright/_impl/_browser.py index 5a9a87450..21b6e4d84 100644 --- a/playwright/_impl/_browser.py +++ b/playwright/_impl/_browser.py @@ -27,6 +27,7 @@ ) from playwright._impl._api_structures import ( + BrowserBindResult, ClientCertificate, Geolocation, HttpCredentials, @@ -58,6 +59,7 @@ class Browser(ChannelOwner): Events = SimpleNamespace( + Context="context", Disconnected="disconnected", ) @@ -103,6 +105,7 @@ def _did_create_context(self, context: BrowserContext) -> None: # and will be configured later in `ConnectToBrowserType`. if self._browser_type: self._setup_browser_context(context) + self.emit(Browser.Events.Context, context) def _setup_browser_context(self, context: BrowserContext) -> None: context._tracing._traces_dir = self._traces_dir @@ -247,6 +250,20 @@ def version(self) -> str: async def new_browser_cdp_session(self) -> CDPSession: return from_channel(await self._channel.send("newBrowserCDPSession", None)) + async def bind( + self, + title: str, + workspaceDir: str = None, + host: str = None, + port: int = None, + ) -> BrowserBindResult: + return await self._channel.send_return_as_dict( + "startServer", None, locals_to_params(locals()) + ) + + async def unbind(self) -> None: + await self._channel.send("stopServer", None) + async def start_tracing( self, page: Page = None, diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index e27a9437a..38cccd4a3 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -36,8 +36,8 @@ Geolocation, SetCookieParam, StorageState, + WebErrorLocation, ) -from playwright._impl._artifact import Artifact from playwright._impl._cdp_session import CDPSession from playwright._impl._clock import Clock from playwright._impl._connection import ( @@ -46,7 +46,9 @@ from_nullable_channel, ) from playwright._impl._console_message import ConsoleMessage +from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog +from playwright._impl._disposable import Disposable, DisposableStub from playwright._impl._errors import Error, TargetClosedError from playwright._impl._event_context_manager import EventContextManagerImpl from playwright._impl._fetch import APIRequestContext @@ -55,7 +57,6 @@ from playwright._impl._helper import ( HarContentPolicy, HarMode, - HarRecordingMetadata, RouteFromHarNotFoundPolicy, RouteHandler, RouteHandlerCallback, @@ -93,7 +94,13 @@ class BrowserContext(ChannelOwner): Close="close", Console="console", Dialog="dialog", + Download="download", + FrameAttached="frameattached", + FrameDetached="framedetached", + FrameNavigated="framenavigated", Page="page", + PageClose="pageclose", + PageLoad="pageload", WebError="weberror", ServiceWorker="serviceworker", Request="request", @@ -122,7 +129,7 @@ def __init__( self._base_url: Optional[str] = self._options.get("baseURL") self._videos_dir: Optional[str] = self._options.get("recordVideo") self._tracing = cast(Tracing, from_channel(initializer["tracing"])) - self._har_recorders: Dict[str, HarRecordingMetadata] = {} + self._debugger: Debugger = cast(Debugger, from_channel(initializer["debugger"])) self._request: APIRequestContext = from_channel(initializer["requestContext"]) self._request._timeout_settings = self._timeout_settings self._clock = Clock(self) @@ -168,6 +175,10 @@ def __init__( lambda params: self._on_page_error( parse_error(params["error"]["error"]), from_nullable_channel(params["page"]), + cast( + WebErrorLocation, + params.get("location") or {"url": "", "line": 0, "column": 0}, + ), ), ) self._channel.on( @@ -318,7 +329,7 @@ async def _initialize_har_from_options( content_policy: HarContentPolicy = record_har_content or ( "omit" if record_har_omit_content is True else default_policy ) - await self._record_into_har( + await self._tracing._record_into_har( har=record_har_path, page=None, url=record_har_url_filter, @@ -392,16 +403,16 @@ async def set_offline(self, offline: bool) -> None: async def add_init_script( self, script: str = None, path: Union[str, Path] = None - ) -> None: + ) -> Disposable: if path: script = (await async_readfile(path)).decode() if not isinstance(script, str): raise Error("Either path or script parameter must be specified") - await self._channel.send("addInitScript", None, dict(source=script)) + return from_channel( + await self._channel.send("addInitScript", None, dict(source=script)) + ) - async def expose_binding( - self, name: str, callback: Callable, handle: bool = None - ) -> None: + async def expose_binding(self, name: str, callback: Callable) -> Disposable: for page in self._pages: if name in page._bindings: raise Error( @@ -410,16 +421,16 @@ async def expose_binding( if name in self._bindings: raise Error(f'Function "{name}" has been already registered') self._bindings[name] = callback - await self._channel.send( - "exposeBinding", None, dict(name=name, needsHandle=handle or False) + return from_channel( + await self._channel.send("exposeBinding", None, dict(name=name)) ) - async def expose_function(self, name: str, callback: Callable) -> None: - await self.expose_binding(name, lambda source, *args: callback(*args)) + async def expose_function(self, name: str, callback: Callable) -> Disposable: + return await self.expose_binding(name, lambda source, *args: callback(*args)) async def route( self, url: URLMatch, handler: RouteHandlerCallback, times: int = None - ) -> None: + ) -> DisposableStub: self._routes.insert( 0, RouteHandler( @@ -431,6 +442,7 @@ async def route( ), ) await self._update_interception_patterns() + return DisposableStub(lambda: self.unroute(url, handler), self) async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None @@ -475,35 +487,6 @@ async def unroute_all( await self._unroute_internal(self._routes, [], behavior) self._dispose_har_routers() - async def _record_into_har( - self, - har: Union[Path, str], - page: Optional[Page] = None, - url: Union[Pattern[str], str] = None, - update_content: HarContentPolicy = None, - update_mode: HarMode = None, - ) -> None: - update_content = update_content or "attach" - params: Dict[str, Any] = { - "options": { - "zip": str(har).endswith(".zip"), - "content": update_content, - "urlGlob": url if isinstance(url, str) else None, - "urlRegexSource": url.pattern if isinstance(url, Pattern) else None, - "urlRegexFlags": ( - escape_regex_flags(url) if isinstance(url, Pattern) else None - ), - "mode": update_mode or "minimal", - } - } - if page: - params["page"] = page._channel - har_id = await self._channel.send("harStart", None, params) - self._har_recorders[har_id] = { - "path": str(har), - "content": update_content, - } - async def route_from_har( self, har: Union[Path, str], @@ -514,7 +497,7 @@ async def route_from_har( updateMode: HarMode = None, ) -> None: if update: - await self._record_into_har( + await self._tracing._record_into_har( har=har, page=None, url=url, @@ -582,6 +565,9 @@ def _on_close(self) -> None: self._tracing._reset_stack_counter() self.emit(BrowserContext.Events.Close, self) + def is_closed(self) -> bool: + return self._closing_or_closed + async def close(self, reason: str = None) -> None: if self._closing_or_closed: return @@ -591,27 +577,7 @@ async def close(self, reason: str = None) -> None: await self.request.dispose(reason=reason) async def _inner_close() -> None: - for har_id, params in self._har_recorders.items(): - har = cast( - Artifact, - from_channel( - await self._channel.send("harExport", None, {"harId": har_id}) - ), - ) - # Server side will compress artifact if content is attach or if file is .zip. - is_compressed = params.get("content") == "attach" or params[ - "path" - ].endswith(".zip") - need_compressed = params["path"].endswith(".zip") - if is_compressed and not need_compressed: - tmp_path = params["path"] + ".tmp" - await har.save_as(tmp_path) - await self._connection.local_utils.har_unzip( - zipFile=tmp_path, harFile=params["path"] - ) - else: - await har.save_as(params["path"]) - await har.delete() + await self._tracing._export_all_hars() await self._channel._connection.wrap_api_call(_inner_close, True) await self._channel.send("close", None, {"reason": reason}) @@ -627,6 +593,15 @@ async def storage_state( await async_writefile(path, json.dumps(result)) return result + async def set_storage_state( + self, storageState: Union[StorageState, str, Path] + ) -> None: + if isinstance(storageState, (str, Path)): + state = json.loads(await async_readfile(storageState)) + else: + state = storageState + await self._channel.send("setStorageState", None, {"storageState": state}) + def _effective_close_reason(self) -> Optional[str]: if self._close_reason: return self._close_reason @@ -712,10 +687,12 @@ def _on_dialog(self, dialog: Dialog) -> None: else: asyncio.create_task(dialog.dismiss()) - def _on_page_error(self, error: Error, page: Optional[Page]) -> None: + def _on_page_error( + self, error: Error, page: Optional[Page], location: WebErrorLocation + ) -> None: self.emit( BrowserContext.Events.WebError, - WebError(self._loop, self._dispatcher_fiber, page, error), + WebError(self._loop, self._dispatcher_fiber, page, error, location), ) if page: page.emit(Page.Events.PageError, error) @@ -753,6 +730,10 @@ async def new_cdp_session(self, page: Union[Page, Frame]) -> CDPSession: def tracing(self) -> Tracing: return self._tracing + @property + def debugger(self) -> Debugger: + return self._debugger + @property def request(self) -> "APIRequestContext": return self._request diff --git a/playwright/_impl/_browser_type.py b/playwright/_impl/_browser_type.py index 3aef7dd6d..8abac6061 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -86,6 +86,7 @@ async def launch( downloadsPath: Union[str, Path] = None, slowMo: float = None, tracesDir: Union[pathlib.Path, str] = None, + artifactsDir: Union[pathlib.Path, str] = None, chromiumSandbox: bool = None, firefoxUserPrefs: Dict[str, Union[str, float, bool]] = None, ) -> Browser: @@ -143,6 +144,7 @@ async def launch_persistent_context( contrast: Contrast = None, acceptDownloads: bool = None, tracesDir: Union[pathlib.Path, str] = None, + artifactsDir: Union[pathlib.Path, str] = None, chromiumSandbox: bool = None, firefoxUserPrefs: Dict[str, Union[str, float, bool]] = None, recordHarPath: Union[Path, str] = None, @@ -199,6 +201,7 @@ async def connect_over_cdp( slowMo: float = None, headers: Dict[str, str] = None, isLocal: bool = None, + noDefaults: bool = None, ) -> Browser: params = locals_to_params(locals()) if params.get("headers"): @@ -213,7 +216,7 @@ async def connect_over_cdp( async def connect( self, - wsEndpoint: str, + endpoint: str, timeout: float = None, slowMo: float = None, headers: Dict[str, str] = None, @@ -229,7 +232,7 @@ async def connect( "connect", None, { - "wsEndpoint": wsEndpoint, + "endpoint": endpoint, "headers": headers, "slowMo": slowMo, "timeout": timeout if timeout is not None else 0, @@ -355,9 +358,13 @@ def normalize_launch_params(params: Dict) -> None: if params["ignoreDefaultArgs"] is True: params["ignoreAllDefaultArgs"] = True del params["ignoreDefaultArgs"] + elif params["ignoreDefaultArgs"] is False: + del params["ignoreDefaultArgs"] if "executablePath" in params: params["executablePath"] = str(Path(params["executablePath"])) if "downloadsPath" in params: params["downloadsPath"] = str(Path(params["downloadsPath"])) if "tracesDir" in params: params["tracesDir"] = str(Path(params["tracesDir"])) + if "artifactsDir" in params: + params["artifactsDir"] = str(Path(params["artifactsDir"])) diff --git a/playwright/_impl/_cdp_session.py b/playwright/_impl/_cdp_session.py index 95e65c57a..07d291ae2 100644 --- a/playwright/_impl/_cdp_session.py +++ b/playwright/_impl/_cdp_session.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from types import SimpleNamespace from typing import Any, Dict from playwright._impl._connection import ChannelOwner @@ -19,14 +20,21 @@ class CDPSession(ChannelOwner): + Events = SimpleNamespace( + Event="event", + Close="close", + ) + def __init__( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict ) -> None: super().__init__(parent, type, guid, initializer) self._channel.on("event", lambda params: self._on_event(params)) + self._channel.on("close", lambda _: self.emit(CDPSession.Events.Close, self)) def _on_event(self, params: Any) -> None: self.emit(params["method"], params.get("params")) + self.emit(CDPSession.Events.Event, params) async def send(self, method: str, params: Dict = None) -> Dict: return await self._channel.send("send", None, locals_to_params(locals())) diff --git a/playwright/_impl/_console_message.py b/playwright/_impl/_console_message.py index f37e3dd4d..66879194e 100644 --- a/playwright/_impl/_console_message.py +++ b/playwright/_impl/_console_message.py @@ -74,7 +74,20 @@ def args(self) -> List[JSHandle]: @property def location(self) -> SourceLocation: - return self._event["location"] + # Wire format uses `lineNumber`/`columnNumber`; docs expose both `line`/`column` + # (legacy) and `lineNumber`/`columnNumber` (added upstream in 1.60). + loc = self._event["location"] + return { + "url": loc["url"], + "line": loc["lineNumber"], + "column": loc["columnNumber"], + "lineNumber": loc["lineNumber"], + "columnNumber": loc["columnNumber"], + } + + @property + def timestamp(self) -> float: + return self._event["timestamp"] @property def page(self) -> Optional["Page"]: diff --git a/playwright/_impl/_debugger.py b/playwright/_impl/_debugger.py new file mode 100644 index 000000000..36e4bd989 --- /dev/null +++ b/playwright/_impl/_debugger.py @@ -0,0 +1,54 @@ +# 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 types import SimpleNamespace +from typing import Any, Dict, Optional + +from playwright._impl._api_structures import DebuggerLocation, DebuggerPausedDetails +from playwright._impl._connection import ChannelOwner + + +class Debugger(ChannelOwner): + Events = SimpleNamespace( + PausedStateChanged="pausedstatechanged", + ) + + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + self._paused_details: Optional[DebuggerPausedDetails] = None + self._channel.on( + "pausedStateChanged", lambda params: self._on_paused_state_changed(params) + ) + + def _on_paused_state_changed(self, params: Dict[str, Any]) -> None: + self._paused_details = params.get("pausedDetails") + self.emit(Debugger.Events.PausedStateChanged) + + async def request_pause(self) -> None: + await self._channel.send("requestPause", None) + + async def resume(self) -> None: + await self._channel.send("resume", None) + + async def next(self) -> None: + await self._channel.send("next", None) + + async def run_to(self, location: DebuggerLocation) -> None: + await self._channel.send("runTo", None, {"location": location}) + + @property + def paused_details(self) -> Optional[DebuggerPausedDetails]: + return self._paused_details diff --git a/playwright/_impl/_dialog.py b/playwright/_impl/_dialog.py index 226e703b9..f6750e396 100644 --- a/playwright/_impl/_dialog.py +++ b/playwright/_impl/_dialog.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Dict, Optional from playwright._impl._connection import ChannelOwner, from_nullable_channel +from playwright._impl._errors import is_target_closed_error from playwright._impl._helper import locals_to_params if TYPE_CHECKING: # pragma: no cover @@ -51,7 +52,12 @@ async def accept(self, promptText: str = None) -> None: await self._channel.send("accept", None, locals_to_params(locals())) async def dismiss(self) -> None: - await self._channel.send( - "dismiss", - None, - ) + try: + await self._channel.send( + "dismiss", + None, + ) + except Exception as e: + if is_target_closed_error(e): + return + raise diff --git a/playwright/_impl/_disposable.py b/playwright/_impl/_disposable.py new file mode 100644 index 000000000..c0b7e85a1 --- /dev/null +++ b/playwright/_impl/_disposable.py @@ -0,0 +1,93 @@ +# 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 +import inspect +import traceback +from typing import Awaitable, Callable, Dict + +import greenlet + +from playwright._impl._connection import ChannelOwner +from playwright._impl._errors import Error, is_target_closed_error + + +class Disposable(ChannelOwner): + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + + async def dispose(self) -> None: + try: + await self._channel.send( + "dispose", + None, + ) + except Exception as e: + if not is_target_closed_error(e): + raise e + + async def close(self) -> None: + await self.dispose() + + def __repr__(self) -> str: + return "" + + +class DisposableStub: + def __init__( + self, + dispose_fn: Callable[[], Awaitable[None]], + parent: ChannelOwner, + ) -> None: + self._dispose_fn = dispose_fn + self._loop = parent._loop + self._dispatcher_fiber = parent._dispatcher_fiber + + async def dispose(self) -> None: + await self._dispose_fn() + + async def __aenter__(self) -> "DisposableStub": + return self + + async def __aexit__(self, *args: object) -> None: + await self.dispose() + + def __enter__(self) -> "DisposableStub": + return self + + def __exit__(self, *args: object) -> None: + self._sync(self.dispose()) + + def _sync(self, coro: object) -> object: + __tracebackhide__ = True + if self._loop.is_closed(): + coro.close() # type: ignore + raise Error("Event loop is closed! Is Playwright already stopped?") + g_self = greenlet.getcurrent() + task = self._loop.create_task(coro) # type: ignore + setattr(task, "__pw_stack__", inspect.stack(0)) + setattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10)) + task.add_done_callback(lambda _: g_self.switch()) + while not task.done(): + self._dispatcher_fiber.switch() # type: ignore + asyncio._set_running_loop(self._loop) + return task.result() + + async def close(self) -> None: + await self.dispose() + + def __repr__(self) -> str: + return "" diff --git a/playwright/_impl/_fetch.py b/playwright/_impl/_fetch.py index 50bf4ad4a..a14378149 100644 --- a/playwright/_impl/_fetch.py +++ b/playwright/_impl/_fetch.py @@ -14,6 +14,7 @@ import base64 import json +import mimetypes import pathlib import typing from pathlib import Path @@ -32,6 +33,7 @@ ) from playwright._impl._connection import ChannelOwner, from_channel from playwright._impl._errors import is_target_closed_error +from playwright._impl._form_data import FormData from playwright._impl._helper import ( Error, NameValue, @@ -51,9 +53,9 @@ from playwright._impl._playwright import Playwright -FormType = Dict[str, Union[bool, float, str]] +FormType = Union[Dict[str, Union[bool, float, str]], FormData] DataType = Union[Any, bytes, str] -MultipartType = Dict[str, Union[bytes, bool, float, str, FilePayload]] +MultipartType = Union[Dict[str, Union[bytes, bool, float, str, FilePayload]], FormData] ParamsType = Union[Dict[str, Union[bool, float, str]], str] @@ -110,6 +112,7 @@ def __init__( async def dispose(self, reason: str = None) -> None: self._close_reason = reason + await self._tracing._export_all_hars() try: await self._channel.send("dispose", None, {"reason": reason}) except Error as e: @@ -118,6 +121,10 @@ async def dispose(self, reason: str = None) -> None: raise e self._tracing._reset_stack_counter() + @property + def tracing(self) -> Tracing: + return self._tracing + async def delete( self, url: str, @@ -212,7 +219,7 @@ async def patch( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -241,7 +248,7 @@ async def put( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -270,7 +277,7 @@ async def post( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -300,7 +307,7 @@ async def fetch( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -341,7 +348,7 @@ async def _inner_fetch( data: DataType = None, params: ParamsType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -381,21 +388,36 @@ async def _inner_fetch( else: raise Error(f"Unsupported 'data' type: {type(data)}") elif form: - form_data = object_to_array(form) + if isinstance(form, FormData): + form_data = [] + for fd_name, fd_value in form._fields: + if isinstance(fd_value, (pathlib.Path, dict)): + raise Error( + f"Form field {fd_name!r} must be a string, number or boolean. Use 'multipart' for file uploads." + ) + form_data.append(NameValue(name=fd_name, value=str(fd_value))) + else: + form_data = object_to_array(form) elif multipart: multipart_data = [] - # Convert file-like values to ServerFilePayload structs. - for name, value in multipart.items(): - if is_file_payload(value): - payload = cast(FilePayload, value) - assert isinstance( - payload["buffer"], bytes - ), f"Unexpected buffer type of 'data.{name}'" + if isinstance(multipart, FormData): + for fd_name, fd_value in multipart._fields: multipart_data.append( - FormField(name=name, file=file_payload_to_json(payload)) + await _form_data_field_to_form_field(fd_name, fd_value) ) - elif isinstance(value, str): - multipart_data.append(FormField(name=name, value=value)) + else: + # Convert file-like values to ServerFilePayload structs. + for name, value in multipart.items(): + if is_file_payload(value): + payload = cast(FilePayload, value) + assert isinstance( + payload["buffer"], bytes + ), f"Unexpected buffer type of 'data.{name}'" + multipart_data.append( + FormField(name=name, file=file_payload_to_json(payload)) + ) + elif isinstance(value, str): + multipart_data.append(FormField(name=name, value=value)) if ( post_data_buffer is None and json_data is None @@ -450,6 +472,28 @@ def file_payload_to_json(payload: FilePayload) -> ServerFilePayload: ) +async def _form_data_field_to_form_field(name: str, value: Any) -> FormField: + if isinstance(value, pathlib.Path): + mime_type, _ = mimetypes.guess_type(str(value)) + return FormField( + name=name, + file=ServerFilePayload( + name=value.name, + mimeType=mime_type or "application/octet-stream", + buffer=base64.b64encode(await async_readfile(str(value))).decode(), + ), + ) + if is_file_payload(value): + payload = cast(FilePayload, value) + assert isinstance( + payload["buffer"], bytes + ), f"Unexpected buffer type of form field {name!r}" + return FormField(name=name, file=file_payload_to_json(payload)) + if isinstance(value, (str, int, float, bool)): + return FormField(name=name, value=str(value)) + raise Error(f"Unsupported form field {name!r} value type: {type(value).__name__}") + + class APIResponse: def __init__(self, context: APIRequestContext, initializer: Dict) -> None: self._loop = context._loop diff --git a/playwright/_impl/_form_data.py b/playwright/_impl/_form_data.py new file mode 100644 index 000000000..384806abd --- /dev/null +++ b/playwright/_impl/_form_data.py @@ -0,0 +1,34 @@ +# 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 pathlib +from typing import List, Tuple, Union + +from playwright._impl._api_structures import FilePayload + +FormDataValue = Union[bool, float, str, pathlib.Path, FilePayload] + + +class FormData: + def __init__(self) -> None: + self._fields: List[Tuple[str, FormDataValue]] = [] + + def set(self, name: str, value: FormDataValue) -> "FormData": + self._fields = [(n, v) for (n, v) in self._fields if n != name] + self._fields.append((name, value)) + return self + + def append(self, name: str, value: FormDataValue) -> "FormData": + self._fields.append((name, value)) + return self diff --git a/playwright/_impl/_frame.py b/playwright/_impl/_frame.py index b976667e7..2422f2b1a 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -32,6 +32,7 @@ from playwright._impl._api_structures import ( AriaRole, + DropPayload, FilePayload, FrameExpectOptions, FrameExpectResult, @@ -122,6 +123,7 @@ def _on_load_state( self._load_states.remove(remove) if not self._parent_frame and add == "load" and self._page: self._page.emit("load", self._page) + self._page.context.emit("pageload", self._page) if not self._parent_frame and add == "domcontentloaded" and self._page: self._page.emit("domcontentloaded", self._page) @@ -131,6 +133,7 @@ def _on_frame_navigated(self, event: FrameNavigatedEvent) -> None: self._event_emitter.emit("navigated", event) if "error" not in event and self._page: self._page.emit("framenavigated", self) + self._page.context.emit("framenavigated", self) async def _query_count(self, selector: str) -> int: return await self._channel.send("queryCount", None, {"selector": selector}) @@ -662,6 +665,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self.locator( get_by_role_selector( @@ -675,6 +679,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -812,6 +817,33 @@ async def set_input_files( }, ) + async def _drop( + self, + selector: str, + payload: "DropPayload", + strict: bool = None, + position: Position = None, + timeout: float = None, + ) -> None: + params: Dict[str, Any] = { + "selector": selector, + "strict": strict, + "position": position, + "timeout": self._timeout(timeout), + } + files = payload.get("files") if payload else None + if files is not None: + converted = await convert_input_files(files, self.page.context) + if "directoryStream" in converted or "directoryLocalPath" in converted: + raise Error( + "Dropping a directory is not supported, pass individual files instead." + ) + params.update(converted) + data = payload.get("data") if payload else None + if data is not None: + params["data"] = [{"mimeType": k, "value": v} for k, v in data.items()] + await self._channel.send("drop", self._timeout, params) + async def type( self, selector: str, @@ -911,5 +943,10 @@ async def set_checked( trial=trial, ) - async def _highlight(self, selector: str) -> None: - await self._channel.send("highlight", None, {"selector": selector}) + async def _highlight(self, selector: str, style: str = None) -> None: + await self._channel.send( + "highlight", None, {"selector": selector, "style": style} + ) + + async def _hide_highlight(self, selector: str) -> None: + await self._channel.send("hideHighlight", None, {"selector": selector}) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 1d7e4f67b..213fdc1e3 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import asyncio +import datetime import math import os import re @@ -22,6 +23,7 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Dict, List, @@ -54,8 +56,12 @@ from playwright._impl._network import Request, Response, Route, WebSocketRoute URLMatch = Union[str, Pattern[str], Callable[[str], bool]] -URLMatchRequest = Union[str, Pattern[str], Callable[["Request"], bool]] -URLMatchResponse = Union[str, Pattern[str], Callable[["Response"], bool]] +URLMatchRequest = Union[ + str, Pattern[str], Callable[["Request"], Union[bool, Awaitable[bool]]] +] +URLMatchResponse = Union[ + str, Pattern[str], Callable[["Response"], Union[bool, Awaitable[bool]]] +] RouteHandlerCallback = Union[ Callable[["Route"], Any], Callable[["Route", "Request"], Any] ] @@ -569,3 +575,13 @@ def is_file_payload(value: Optional[Any]) -> bool: def is_textual_mime_type(mime_type: str) -> bool: return bool(TEXTUAL_MIME_TYPE.match(mime_type)) + + +def to_milliseconds( + value: Union[float, datetime.timedelta, None], +) -> Optional[float]: + if value is None: + return None + if isinstance(value, datetime.timedelta): + return value / datetime.timedelta(milliseconds=1) + return value diff --git a/playwright/_impl/_impl_to_api_mapping.py b/playwright/_impl/_impl_to_api_mapping.py index e26d22025..8b3cbd756 100644 --- a/playwright/_impl/_impl_to_api_mapping.py +++ b/playwright/_impl/_impl_to_api_mapping.py @@ -119,7 +119,23 @@ def to_impl( def wrap_handler(self, handler: Callable[..., Any]) -> Callable[..., None]: def wrapper_func(*args: Any) -> Any: - arg_count = len(inspect.signature(handler).parameters) + parameters = inspect.signature(handler).parameters + has_varargs = any( + parameter.kind == inspect.Parameter.VAR_POSITIONAL + for parameter in parameters.values() + ) + arg_count = ( + len(args) + if has_varargs + else sum( + parameter.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + for parameter in parameters.values() + ) + ) return handler( *list(map(lambda a: self.from_maybe_impl(a), args))[:arg_count] ) diff --git a/playwright/_impl/_local_utils.py b/playwright/_impl/_local_utils.py index c2d2d3fca..cd8fd8343 100644 --- a/playwright/_impl/_local_utils.py +++ b/playwright/_impl/_local_utils.py @@ -62,7 +62,9 @@ async def har_unzip(self, zipFile: str, harFile: str) -> None: params = locals_to_params(locals()) await self._channel.send("harUnzip", None, params) - async def tracing_started(self, tracesDir: Optional[str], traceName: str) -> str: + async def tracing_started( + self, tracesDir: Optional[str], traceName: str, live: bool = False + ) -> str: params = locals_to_params(locals()) return await self._channel.send("tracingStarted", None, params) diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index 2e6a7abed..c76b248ce 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -33,6 +33,7 @@ from playwright._impl._api_structures import ( AriaRole, + DropPayload, FilePayload, FloatRect, FrameExpectOptions, @@ -279,6 +280,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self.locator( get_by_role_selector( @@ -292,6 +294,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -439,6 +442,20 @@ async def drag_to( self._selector, target._selector, strict=True, **params ) + async def drop( + self, + payload: DropPayload, + position: Position = None, + timeout: float = None, + ) -> None: + await self._frame._drop( + self._selector, + payload, + strict=True, + position=position, + timeout=timeout, + ) + async def get_attribute(self, name: str, timeout: float = None) -> Optional[str]: params = locals_to_params(locals()) return await self._frame.get_attribute( @@ -564,7 +581,13 @@ async def screenshot( ), ) - async def aria_snapshot(self, timeout: float = None) -> str: + async def aria_snapshot( + self, + timeout: float = None, + depth: int = None, + mode: Literal["ai", "default"] = None, + boxes: bool = None, + ) -> str: return await self._frame._channel.send( "ariaSnapshot", self._frame._timeout, @@ -574,6 +597,14 @@ async def aria_snapshot(self, timeout: float = None) -> str: }, ) + async def normalize(self) -> "Locator": + result = await self._frame._channel.send( + "resolveSelector", + None, + {"selector": self._selector}, + ) + return Locator(self._frame, result) + async def scroll_into_view_if_needed( self, timeout: float = None, @@ -743,8 +774,11 @@ async def _expect( ) -> FrameExpectResult: return await self._frame._expect(self._selector, expression, options, title) - async def highlight(self) -> None: - await self._frame._highlight(self._selector) + async def highlight(self, style: str = None) -> None: + await self._frame._highlight(self._selector, style) + + async def hide_highlight(self) -> None: + await self._frame._hide_highlight(self._selector) class FrameLocator: @@ -810,6 +844,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self.locator( get_by_role_selector( @@ -823,6 +858,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -925,6 +961,7 @@ def get_by_role_selector( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> str: props: List[Tuple[str, str]] = [] if checked is not None: @@ -946,6 +983,13 @@ def get_by_role_selector( escape_for_attribute_selector(name, exact=exact), ) ) + if description is not None: + props.append( + ( + "description", + escape_for_attribute_selector(description, exact=exact), + ) + ) if pressed is not None: props.append(("pressed", bool_to_js_bool(pressed))) props_str = "".join([f"[{t[0]}={t[1]}]" for t in props]) diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 9f2c29f6e..852b5fac7 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -154,6 +154,7 @@ def __init__( self._fallback_overrides: SerializedFallbackOverrides = ( SerializedFallbackOverrides() ) + self._response: Optional["Response"] = None def __repr__(self) -> str: return f"" @@ -219,8 +220,8 @@ def post_data_json(self) -> Optional[Any]: post_data = self.post_data if not post_data: return None - content_type = self.headers["content-type"] - if "application/x-www-form-urlencoded" in content_type: + content_type = self.headers.get("content-type") + if content_type and "application/x-www-form-urlencoded" in content_type: return dict(parse.parse_qsl(post_data)) try: return json.loads(post_data) @@ -243,6 +244,10 @@ async def response(self) -> Optional["Response"]: ) ) + @property + def existing_response(self) -> Optional["Response"]: + return self._response + @property def frame(self) -> "Frame": if not self._initializer.get("frame"): @@ -591,6 +596,10 @@ def connect_to_server(self) -> None: def url(self) -> str: return self._ws._initializer["url"] + @property + def protocols(self) -> List[str]: + return list(self._ws._initializer.get("protocols", [])) + def close(self, code: int = None, reason: str = None) -> None: _create_task_and_ignore_exception( self._ws._loop, @@ -689,6 +698,10 @@ def _channel_close_server(self, event: Dict) -> None: def url(self) -> str: return self._initializer["url"] + @property + def protocols(self) -> List[str]: + return list(self._initializer.get("protocols", [])) + async def close(self, code: int = None, reason: str = None) -> None: try: await self._channel.send( @@ -799,6 +812,7 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._request: Request = from_channel(self._initializer["request"]) + self._request._response = self timing = self._initializer["timing"] self._request._timing["startTime"] = timing["startTime"] self._request._timing["domainLookupStart"] = timing["domainLookupStart"] @@ -881,6 +895,12 @@ async def security_details(self) -> Optional[SecurityDetails]: None, ) + async def http_version(self) -> str: + return await self._channel.send( + "httpVersion", + None, + ) + async def finished(self) -> None: async def on_finished() -> None: await self._request._target_closed_future() diff --git a/playwright/_impl/_object_factory.py b/playwright/_impl/_object_factory.py index b44009bc3..7911ddb30 100644 --- a/playwright/_impl/_object_factory.py +++ b/playwright/_impl/_object_factory.py @@ -20,7 +20,9 @@ from playwright._impl._browser_type import BrowserType from playwright._impl._cdp_session import CDPSession from playwright._impl._connection import ChannelOwner +from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog +from playwright._impl._disposable import Disposable from playwright._impl._element_handle import ElementHandle from playwright._impl._fetch import APIRequestContext from playwright._impl._frame import Frame @@ -64,8 +66,12 @@ def create_remote_object( return BrowserContext(parent, type, guid, initializer) if type == "CDPSession": return CDPSession(parent, type, guid, initializer) + if type == "Debugger": + return Debugger(parent, type, guid, initializer) if type == "Dialog": return Dialog(parent, type, guid, initializer) + if type == "Disposable": + return Disposable(parent, type, guid, initializer) if type == "ElementHandle": return ElementHandle(parent, type, guid, initializer) if type == "Frame": diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 1f05a9048..9bf59c313 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -22,6 +22,7 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Dict, List, @@ -49,6 +50,7 @@ from_nullable_channel, ) from playwright._impl._console_message import ConsoleMessage +from playwright._impl._disposable import Disposable, DisposableStub from playwright._impl._download import Download from playwright._impl._element_handle import ElementHandle, determine_screenshot_type from playwright._impl._errors import Error, TargetClosedError, is_target_closed_error @@ -98,6 +100,7 @@ WebSocketRouteHandler, serialize_headers, ) +from playwright._impl._screencast import Screencast from playwright._impl._video import Video from playwright._impl._waiter import Waiter @@ -175,7 +178,11 @@ def __init__( self._timeout_settings: TimeoutSettings = TimeoutSettings( self._browser_context._timeout_settings ) - self._video: Optional[Video] = None + self._video: Video = Video( + self, + cast(Optional[Artifact], from_nullable_channel(initializer.get("video"))), + ) + self._screencast: Screencast = Screencast(self) self._opener = cast("Page", from_nullable_channel(initializer.get("opener"))) self._close_reason: Optional[str] = None self._close_was_called = False @@ -224,7 +231,6 @@ def __init__( self._on_web_socket_route(from_channel(params["webSocketRoute"])) ), ) - self._channel.on("video", lambda params: self._on_video(params)) self._channel.on("viewportSizeChanged", self._on_viewport_size_changed) self._channel.on( "webSocket", @@ -274,11 +280,13 @@ def _on_frame_attached(self, frame: Frame) -> None: frame._page = self self._frames.append(frame) self.emit(Page.Events.FrameAttached, frame) + self._browser_context.emit("frameattached", frame) def _on_frame_detached(self, frame: Frame) -> None: self._frames.remove(frame) frame._detached = True self.emit(Page.Events.FrameDetached, frame) + self._browser_context.emit("framedetached", frame) async def _on_route(self, route: Route) -> None: route._context = self.context @@ -344,6 +352,7 @@ def _on_close(self) -> None: self._browser_context._pages.remove(self) self._dispose_har_routers() self.emit(Page.Events.Close, self) + self._browser_context.emit("pageclose", self) def _on_crash(self) -> None: self.emit(Page.Events.Crash, self) @@ -352,13 +361,9 @@ def _on_download(self, params: Any) -> None: url = params["url"] suggested_filename = params["suggestedFilename"] artifact = cast(Artifact, from_channel(params["artifact"])) - self.emit( - Page.Events.Download, Download(self, url, suggested_filename, artifact) - ) - - def _on_video(self, params: Any) -> None: - artifact = from_channel(params["artifact"]) - self._force_video()._artifact_ready(artifact) + download = Download(self, url, suggested_filename, artifact) + self.emit(Page.Events.Download, download) + self._browser_context.emit("download", download) def _on_viewport_size_changed(self, params: Any) -> None: self._viewport_size = params["viewportSize"] @@ -501,12 +506,10 @@ async def add_style_tag( ) -> ElementHandle: return await self._main_frame.add_style_tag(**locals_to_params(locals())) - async def expose_function(self, name: str, callback: Callable) -> None: - await self.expose_binding(name, lambda source, *args: callback(*args)) + async def expose_function(self, name: str, callback: Callable) -> Disposable: + return await self.expose_binding(name, lambda source, *args: callback(*args)) - async def expose_binding( - self, name: str, callback: Callable, handle: bool = None - ) -> None: + async def expose_binding(self, name: str, callback: Callable) -> Disposable: if name in self._bindings: raise Error(f'Function "{name}" has been already registered') if name in self._browser_context._bindings: @@ -514,10 +517,12 @@ async def expose_binding( f'Function "{name}" has been already registered in the browser context' ) self._bindings[name] = callback - await self._channel.send( - "exposeBinding", - None, - dict(name=name, needsHandle=handle or False), + return from_channel( + await self._channel.send( + "exposeBinding", + None, + dict(name=name), + ) ) async def set_extra_http_headers(self, headers: Dict[str, str]) -> None: @@ -659,20 +664,25 @@ def viewport_size(self) -> Optional[ViewportSize]: async def bring_to_front(self) -> None: await self._channel.send("bringToFront", None) + async def hide_highlight(self) -> None: + await self._channel.send("hideHighlight", None) + async def add_init_script( self, script: str = None, path: Union[str, Path] = None - ) -> None: + ) -> Disposable: if path: script = add_source_url_to_script( (await async_readfile(path)).decode(), path ) if not isinstance(script, str): raise Error("Either path or script parameter must be specified") - await self._channel.send("addInitScript", None, dict(source=script)) + return from_channel( + await self._channel.send("addInitScript", None, dict(source=script)) + ) async def route( self, url: URLMatch, handler: RouteHandlerCallback, times: int = None - ) -> None: + ) -> DisposableStub: self._routes.insert( 0, RouteHandler( @@ -684,6 +694,7 @@ async def route( ), ) await self._update_interception_patterns() + return DisposableStub(lambda: self.unroute(url, handler), self) async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None @@ -743,7 +754,7 @@ async def route_from_har( updateMode: HarMode = None, ) -> None: if update: - await self._browser_context._record_into_har( + await self._browser_context._tracing._record_into_har( har=har, page=self, url=url, @@ -823,6 +834,19 @@ async def screenshot( async def title(self) -> str: return await self._main_frame.title() + async def aria_snapshot( + self, + timeout: float = None, + depth: int = None, + mode: Literal["ai", "default"] = None, + boxes: bool = None, + ) -> str: + return await self._main_frame._channel.send( + "ariaSnapshot", + self._main_frame._timeout, + locals_to_params(locals()), + ) + async def close(self, runBeforeUnload: bool = None, reason: str = None) -> None: self._close_reason = reason self._close_was_called = True @@ -935,6 +959,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self._main_frame.get_by_role( role, @@ -947,6 +972,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) def get_by_test_id(self, testId: Union[str, Pattern[str]]) -> "Locator": @@ -1168,21 +1194,17 @@ async def pdf( await async_writefile(path, decoded_binary) return decoded_binary - def _force_video(self) -> Video: - if not self._video: - self._video = Video(self) + @property + def video(self) -> Optional[Video]: + # Video is only exposed when the page actually produced a recording artifact. + # The initializer carries the artifact; if absent, no video was recorded. + if not self._video._artifact: + return None return self._video @property - def video( - self, - ) -> Optional[Video]: - # Note: we are creating Video object lazily, because we do not know - # BrowserContextOptions when constructing the page - it is assigned - # too late during launchPersistentContext. - if not self._browser_context._videos_dir: - return None - return self._force_video() + def screencast(self) -> Screencast: + return self._screencast def _close_error_with_reason(self) -> TargetClosedError: return TargetClosedError( @@ -1264,7 +1286,7 @@ def expect_request( urlOrPredicate: URLMatchRequest, timeout: float = None, ) -> EventContextManagerImpl[Request]: - def my_predicate(request: Request) -> bool: + def my_predicate(request: Request) -> Union[bool, Awaitable[bool]]: if not callable(urlOrPredicate): return url_matches( self._browser_context._base_url, @@ -1296,7 +1318,7 @@ def expect_response( urlOrPredicate: URLMatchResponse, timeout: float = None, ) -> EventContextManagerImpl[Response]: - def my_predicate(request: Response) -> bool: + def my_predicate(request: Response) -> Union[bool, Awaitable[bool]]: if not callable(urlOrPredicate): return url_matches( self._browser_context._base_url, @@ -1435,8 +1457,12 @@ async def requests(self) -> List[Request]: request_objects = await self._channel.send("requests", None) return [from_channel(r) for r in request_objects] - async def console_messages(self) -> List[ConsoleMessage]: - message_dicts = await self._channel.send("consoleMessages", None) + async def console_messages( + self, filter: Literal["all", "since-navigation"] = None + ) -> List[ConsoleMessage]: + message_dicts = await self._channel.send( + "consoleMessages", None, locals_to_params(locals()) + ) return [ ConsoleMessage( {**event, "page": self._channel}, self._loop, self._dispatcher_fiber @@ -1444,10 +1470,27 @@ async def console_messages(self) -> List[ConsoleMessage]: for event in message_dicts ] - async def page_errors(self) -> List[Error]: - error_objects = await self._channel.send("pageErrors", None) + async def page_errors( + self, filter: Literal["all", "since-navigation"] = None + ) -> List[Error]: + error_objects = await self._channel.send( + "pageErrors", None, locals_to_params(locals()) + ) return [parse_error(error["error"]) for error in error_objects] + async def clear_console_messages(self) -> None: + await self._channel.send("clearConsoleMessages", None) + + async def clear_page_errors(self) -> None: + await self._channel.send("clearPageErrors", None) + + async def pick_locator(self) -> "Locator": + selector = await self._channel.send("pickLocator", None, {}) + return self.locator(selector) + + async def cancel_pick_locator(self) -> None: + await self._channel.send("cancelPickLocator", None, {}) + class Worker(ChannelOwner): Events = SimpleNamespace(Close="close", Console="console") diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py new file mode 100644 index 000000000..600297203 --- /dev/null +++ b/playwright/_impl/_screencast.py @@ -0,0 +1,146 @@ +# 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 base64 +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union + +from playwright._impl._api_structures import ScreencastFrame +from playwright._impl._artifact import Artifact +from playwright._impl._connection import from_nullable_channel +from playwright._impl._disposable import DisposableStub +from playwright._impl._errors import Error +from playwright._impl._helper import locals_to_params + +if TYPE_CHECKING: # pragma: no cover + from playwright._impl._page import Page + + +ScreencastFrameCallback = Callable[[ScreencastFrame], Any] +ScreencastPosition = Literal[ + "bottom", + "bottom-left", + "bottom-right", + "top", + "top-left", + "top-right", +] + + +class Screencast: + def __init__(self, page: "Page") -> None: + self._page = page + self._loop = page._loop + self._dispatcher_fiber = page._dispatcher_fiber + self._started = False + self._save_path: Optional[Union[str, Path]] = None + self._on_frame: Optional[ScreencastFrameCallback] = None + self._artifact: Optional[Artifact] = None + page._channel.on("screencastFrame", lambda params: self._dispatch_frame(params)) + + def _dispatch_frame(self, params: dict) -> None: + if not self._on_frame: + return + data = params["data"] + if isinstance(data, str): + data = base64.b64decode(data) + result = self._on_frame( + { + "data": data, + "viewportWidth": params["viewportWidth"], + "viewportHeight": params["viewportHeight"], + } + ) + if hasattr(result, "__await__"): + self._page._loop.create_task(result) + + async def start( + self, + onFrame: ScreencastFrameCallback = None, + path: Union[str, Path] = None, + quality: int = None, + ) -> DisposableStub: + if self._started: + raise Error("Screencast is already started") + self._started = True + self._on_frame = onFrame + result = await self._page._channel.send_return_as_dict( + "screencastStart", + None, + { + "quality": quality, + "sendFrames": bool(onFrame), + "record": bool(path), + }, + ) + artifact_channel = (result or {}).get("artifact") + if artifact_channel: + self._artifact = from_nullable_channel(artifact_channel) + self._save_path = path + return DisposableStub(lambda: self.stop(), self._page) + + async def stop(self) -> None: + self._started = False + self._on_frame = None + await self._page._channel.send("screencastStop", None) + if self._save_path and self._artifact: + await self._artifact.save_as(self._save_path) + self._artifact = None + self._save_path = None + + async def show_actions( + self, + duration: float = None, + position: ScreencastPosition = None, + fontSize: int = None, + ) -> DisposableStub: + await self._page._channel.send( + "screencastShowActions", None, locals_to_params(locals()) + ) + return DisposableStub(lambda: self.hide_actions(), self._page) + + async def hide_actions(self) -> None: + await self._page._channel.send("screencastHideActions", None) + + async def show_overlay(self, html: str, duration: float = None) -> DisposableStub: + result = await self._page._channel.send_return_as_dict( + "screencastShowOverlay", None, locals_to_params(locals()) + ) + overlay_id = (result or {}).get("id") + return DisposableStub( + lambda: self._page._channel.send( + "screencastRemoveOverlay", None, {"id": overlay_id} + ), + self._page, + ) + + async def show_chapter( + self, + title: str, + description: str = None, + duration: float = None, + ) -> None: + await self._page._channel.send( + "screencastChapter", None, locals_to_params(locals()) + ) + + async def show_overlays(self) -> None: + await self._page._channel.send( + "screencastSetOverlayVisible", None, {"visible": True} + ) + + async def hide_overlays(self) -> None: + await self._page._channel.send( + "screencastSetOverlayVisible", None, {"visible": False} + ) diff --git a/playwright/_impl/_tracing.py b/playwright/_impl/_tracing.py index bbc6ec35e..be79deef1 100644 --- a/playwright/_impl/_tracing.py +++ b/playwright/_impl/_tracing.py @@ -13,12 +13,17 @@ # limitations under the License. import pathlib -from typing import Dict, Optional, Union, cast +from typing import Any, Dict, Literal, Optional, Pattern, Union, cast from playwright._impl._api_structures import TracingGroupLocation from playwright._impl._artifact import Artifact -from playwright._impl._connection import ChannelOwner, from_nullable_channel -from playwright._impl._helper import locals_to_params +from playwright._impl._connection import ( + ChannelOwner, + from_channel, + from_nullable_channel, +) +from playwright._impl._disposable import DisposableStub +from playwright._impl._helper import Error, locals_to_params class Tracing(ChannelOwner): @@ -27,9 +32,12 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._include_sources: bool = False + self._is_live: bool = False self._stacks_id: Optional[str] = None self._is_tracing: bool = False self._traces_dir: Optional[str] = None + self._har_id: Optional[str] = None + self._har_recorders: Dict[str, Dict[str, str]] = {} async def start( self, @@ -38,11 +46,22 @@ async def start( snapshots: bool = None, screenshots: bool = None, sources: bool = None, + live: bool = None, ) -> None: params = locals_to_params(locals()) self._include_sources = bool(sources) + self._is_live = bool(live) - await self._channel.send("tracingStart", None, params) + await self._channel.send( + "tracingStart", + None, + { + "name": name, + "snapshots": snapshots, + "screenshots": screenshots, + "live": live, + }, + ) trace_name = await self._channel.send( "tracingStartChunk", None, {"title": title, "name": name} ) @@ -58,7 +77,7 @@ async def _start_collecting_stacks(self, trace_name: str) -> None: self._is_tracing = True self._connection.set_is_tracing(True) self._stacks_id = await self._connection.local_utils.tracing_started( - self._traces_dir, trace_name + self._traces_dir, trace_name, self._is_live ) async def stop_chunk(self, path: Union[pathlib.Path, str] = None) -> None: @@ -136,11 +155,127 @@ def _reset_stack_counter(self) -> None: self._is_tracing = False self._connection.set_is_tracing(False) - async def group(self, name: str, location: TracingGroupLocation = None) -> None: + async def group( + self, name: str, location: TracingGroupLocation = None + ) -> DisposableStub: await self._channel.send("tracingGroup", None, locals_to_params(locals())) + return DisposableStub(lambda: self.group_end(), self) async def group_end(self) -> None: await self._channel.send( "tracingGroupEnd", None, ) + + async def start_har( + self, + path: Union[pathlib.Path, str], + content: Literal["attach", "embed", "omit"] = None, + mode: Literal["full", "minimal"] = None, + urlFilter: Union[str, Pattern[str]] = None, + ) -> DisposableStub: + if self._har_id: + raise Error("HAR recording has already been started") + is_zip = str(path).endswith(".zip") + default_content: Literal["attach", "embed", "omit"] = ( + "attach" if is_zip else "embed" + ) + self._har_id = await self._record_into_har( + har=path, + page=None, + url=urlFilter, + update_content=content or default_content, + update_mode=mode or "full", + ) + return DisposableStub(lambda: self.stop_har(), self) + + async def _record_into_har( + self, + har: Union[pathlib.Path, str], + page: Optional[ChannelOwner], + url: Union[str, Pattern[str]] = None, + update_content: Literal["attach", "embed", "omit"] = None, + update_mode: Literal["full", "minimal"] = None, + resourcesDir: Optional[str] = None, + ) -> str: + is_zip = str(har).endswith(".zip") + url_glob: Optional[str] = None + url_regex_source: Optional[str] = None + url_regex_flags: Optional[str] = None + if isinstance(url, str): + url_glob = url + elif url is not None: + url_regex_source = url.pattern + url_regex_flags = "".join( + flag + for flag, mask in (("i", 2), ("m", 8), ("s", 16)) + if url.flags & mask + ) + options: Dict[str, object] = { + "content": update_content or "attach", + "mode": update_mode or "minimal", + "harPath": None if is_zip else str(har), + } + if url_glob is not None: + options["urlGlob"] = url_glob + if url_regex_source is not None: + options["urlRegexSource"] = url_regex_source + if url_regex_flags is not None: + options["urlRegexFlags"] = url_regex_flags + if resourcesDir is not None: + options["resourcesDir"] = resourcesDir + params: Dict[str, Any] = {"options": options} + if page is not None: + params["page"] = page._channel + result = await self._channel.send_return_as_dict("harStart", None, params) + har_id = result["harId"] + self._har_recorders[har_id] = {"path": str(har)} + return har_id + + async def _export_all_hars(self) -> None: + for har_id in list(self._har_recorders.keys()): + await self._export_har(har_id) + self._har_id = None + + async def stop_har(self) -> None: + har_id = self._har_id + if not har_id: + return + self._har_id = None + await self._export_har(har_id) + + async def _export_har(self, har_id: str) -> None: + params = self._har_recorders.pop(har_id, None) + if not params: + return + is_local = not self._connection.is_remote + is_zip = params["path"].endswith(".zip") + + if is_local: + result = await self._channel.send_return_as_dict( + "harExport", None, {"harId": har_id, "mode": "entries"} + ) + if not is_zip: + # Server wrote HAR and resources to the user's chosen paths. + return + await self._connection.local_utils.zip( + { + "zipFile": params["path"], + "entries": result["entries"], + "mode": "write", + "includeSources": False, + } + ) + return + + result = await self._channel.send_return_as_dict( + "harExport", None, {"harId": har_id, "mode": "archive"} + ) + artifact = cast(Artifact, from_channel(result["artifact"])) + if is_zip: + await artifact.save_as(params["path"]) + await artifact.delete() + return + # Uncompressed har is not supported in thin clients + await artifact.save_as(params["path"] + ".tmp") + await artifact.delete() diff --git a/playwright/_impl/_transport.py b/playwright/_impl/_transport.py index 2ca84d459..3cc029e18 100644 --- a/playwright/_impl/_transport.py +++ b/playwright/_impl/_transport.py @@ -137,38 +137,44 @@ async def connect(self) -> None: async def run(self) -> None: assert self._proc.stdout assert self._proc.stdin - while not self._stopped: - try: - buffer = await self._proc.stdout.readexactly(4) - if self._stopped: - break - length = int.from_bytes(buffer, byteorder="little", signed=False) - buffer = bytes(0) - while length: - to_read = min(length, 32768) - data = await self._proc.stdout.readexactly(to_read) + try: + while not self._stopped: + try: + buffer = await self._proc.stdout.readexactly(4) + if self._stopped: + break + length = int.from_bytes(buffer, byteorder="little", signed=False) + buffer = bytes(0) + while length: + to_read = min(length, 32768) + data = await self._proc.stdout.readexactly(to_read) + if self._stopped: + break + length -= to_read + if len(buffer): + buffer = buffer + data + else: + buffer = data if self._stopped: break - length -= to_read - if len(buffer): - buffer = buffer + data - else: - buffer = data - if self._stopped: - break - obj = self.deserialize_message(buffer) - self.on_message(obj) - except asyncio.IncompleteReadError: - if not self._stopped: - self.on_error_future.set_exception( - Exception("Connection closed while reading from the driver") - ) - break - await asyncio.sleep(0) - - await self._proc.communicate() - self._stopped_future.set_result(None) + obj = self.deserialize_message(buffer) + self.on_message(obj) + except asyncio.IncompleteReadError: + if not self._stopped: + self.on_error_future.set_exception( + Exception("Connection closed while reading from the driver") + ) + break + await asyncio.sleep(0) + + await self._proc.communicate() + finally: + # Release waiters on wait_until_stopped() even if this task was + # cancelled before reaching the end (e.g. by asyncio.run()'s + # task-cancellation phase that runs before asyncio-atexit hooks). + if not self._stopped_future.done(): + self._stopped_future.set_result(None) def send(self, message: Dict) -> None: assert self._output diff --git a/playwright/_impl/_video.py b/playwright/_impl/_video.py index 68dedf6f8..e991803e3 100644 --- a/playwright/_impl/_video.py +++ b/playwright/_impl/_video.py @@ -13,7 +13,7 @@ # limitations under the License. import pathlib -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Optional, Union from playwright._impl._artifact import Artifact from playwright._impl._helper import Error @@ -23,49 +23,34 @@ class Video: - def __init__(self, page: "Page") -> None: + def __init__(self, page: "Page", artifact: Optional[Artifact]) -> None: self._loop = page._loop self._dispatcher_fiber = page._dispatcher_fiber self._page = page - self._artifact_future = page._loop.create_future() - if page.is_closed(): - self._page_closed() - else: - page.on("close", lambda page: self._page_closed()) + self._is_remote = page._connection.is_remote + self._artifact = artifact def __repr__(self) -> str: return f"