diff --git a/.azure-pipelines/publish.yml b/.azure-pipelines/publish.yml index d0d308342..1a2e0e7a4 100644 --- a/.azure-pipelines/publish.yml +++ b/.azure-pipelines/publish.yml @@ -74,8 +74,8 @@ extends: # contentsource: 'folder' folderlocation: '$(Build.ArtifactStagingDirectory)/esrp-build' waitforreleasecompletion: true - owners: 'maxschmitt@microsoft.com' - approvers: 'maxschmitt@microsoft.com' + owners: 'yurys@microsoft.com' + approvers: 'yurys@microsoft.com' serviceendpointurl: 'https://api.esrp.microsoft.com' mainpublisher: 'Playwright' domaintenantid: '975f013f-7f24-47e8-a7d3-abc4752bf346' diff --git a/.claude/skills/playwright-roll/SKILL.md b/.claude/skills/playwright-roll/SKILL.md new file mode 100644 index 000000000..9a6adbd03 --- /dev/null +++ b/.claude/skills/playwright-roll/SKILL.md @@ -0,0 +1,285 @@ +--- +name: playwright-roll +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. +--- + +# Rolling Playwright Python + +The goal of a roll is to move `driver_version` in `setup.py` to a new release, port every public API change introduced upstream during that interval, and suppress the rest, so that `./scripts/update_api.sh` runs clean and the test suite still passes. + +The previous human-facing summary lives in `../../../ROLLING.md`. This skill is the operational playbook — read it end to end before starting. + +## Mental model + +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` (downloaded inside the new driver wheel). + +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 download it + +```sh +# Edit setup.py +driver_version = "" # e.g. "1.59.1" + +source env/bin/activate +python -m build --wheel # downloads the new driver from cdn.playwright.dev +playwright install chromium # NOT --with-deps; sudo is denied +``` + +The wheel build prints `Fetching https://cdn.playwright.dev/builds/driver/playwright--linux.zip` and unpacks the driver under `playwright/driver/package/`. From this point, `playwright/driver/package/api.json` reflects the new release. + +### 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 + +- `setup.py`: 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 200b2a65a..ecb593832 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.10" - name: Install dependencies & browsers @@ -82,7 +82,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies & browsers @@ -129,7 +129,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.10" - name: Install dependencies & browsers @@ -162,6 +162,13 @@ jobs: matrix: os: [ubuntu-22.04, macos-13, windows-2022] runs-on: ${{ matrix.os }} + defaults: + run: + # `setup-miniconda` activates the env only for login shells; using + # `bash -el` (recommended by the action) ensures `conda` and the + # installed `conda-build` are on PATH on every OS, including Windows + # where the default shell is pwsh and skips the activation hooks. + shell: bash -el {0} steps: - uses: actions/checkout@v5 with: @@ -169,11 +176,11 @@ jobs: - name: Get conda uses: conda-incubator/setup-miniconda@v3 with: - python-version: 3.9 + python-version: '3.12' channels: conda-forge miniconda-version: latest - name: Prepare - run: conda install conda-build conda-verify + run: conda install -n base "conda-build>=26" conda-verify - name: Build run: conda build . @@ -186,7 +193,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install dependencies & browsers diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c6e71028a..e9a7048c5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,11 +31,11 @@ jobs: - name: Get conda uses: conda-incubator/setup-miniconda@v3 with: - python-version: 3.9 + python-version: '3.12' channels: conda-forge miniconda-version: latest - name: Prepare - run: conda install anaconda-client conda-build conda-verify + run: conda install -n base anaconda-client "conda-build>=26" conda-verify - name: Build and Upload env: ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }} diff --git a/.github/workflows/publish_docker.yml b/.github/workflows/publish_docker.yml index 7c2b73e13..7494f1abc 100644 --- a/.github/workflows/publish_docker.yml +++ b/.github/workflows/publish_docker.yml @@ -25,7 +25,7 @@ jobs: - name: Login to ACR via OIDC run: az acr login --name playwright - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.10" - name: Set up Docker QEMU for arm64 docker builds diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index e5252e389..464eb3b46 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -32,7 +32,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.10" - name: Install dependencies diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..ce4ec7c07 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# 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. +- `setup.py` — `driver_version = "X.Y.Z"` is the source of truth for which driver build is downloaded from `cdn.playwright.dev`. +- `ROLLING.md`, `CONTRIBUTING.md` — human-facing setup and roll docs. + +## Setup + +`CONTRIBUTING.md` has the full sequence. The short version: + +```sh +python3 -m venv env && source env/bin/activate +pip install --upgrade pip +pip install -r local-requirements.txt +pip install -e . +python -m build --wheel # downloads the driver listed in setup.py +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. + +## 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. diff --git a/README.md b/README.md index fa9e246a9..f0a4fc423 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Playwright is a Python library to automate [Chromium](https://www.chromium.org/H | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 139.0.7258.5 | ✅ | ✅ | ✅ | -| WebKit 26.0 | ✅ | ✅ | ✅ | -| Firefox 140.0.2 | ✅ | ✅ | ✅ | +| Chromium 147.0.7727.15 | ✅ | ✅ | ✅ | +| WebKit 26.4 | ✅ | ✅ | ✅ | +| Firefox 148.0.2 | ✅ | ✅ | ✅ | ## Documentation diff --git a/ROLLING.md b/ROLLING.md index f5f500a3f..811d7fcb3 100644 --- a/ROLLING.md +++ b/ROLLING.md @@ -5,10 +5,12 @@ * create virtual environment, if don't have one: `python -m venv env` * activate venv: `source env/bin/activate` * install all deps: - - `python -m pip install --upgrade pip` - - `pip install -r local-requirements.txt` - - `pre-commit install` - - `pip install -e .` +``` +python -m pip install --upgrade pip +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` * generate API: `./scripts/update_api.sh` diff --git a/local-requirements.txt b/local-requirements.txt index 2f4f0d488..8a72b5745 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -4,18 +4,18 @@ build==1.3.0 flake8==7.2.0 mypy==1.17.1 objgraph==3.6.2 -Pillow==11.2.1 +Pillow==11.3.0 pixelmatch==0.3.0 pre-commit==3.5.0 pyOpenSSL==25.1.0 pytest==8.4.1 pytest-asyncio==1.1.0 -pytest-cov==6.2.1 +pytest-cov==6.3.0 pytest-repeat==0.9.4 pytest-rerunfailures==15.1 pytest-timeout==2.4.0 pytest-xdist==3.8.0 -requests==2.32.4 +requests==2.32.5 service_identity==24.2.0 twisted==25.5.0 types-pyOpenSSL==24.1.0.20240722 diff --git a/playwright/_impl/_accessibility.py b/playwright/_impl/_accessibility.py deleted file mode 100644 index fe6909c21..000000000 --- a/playwright/_impl/_accessibility.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from typing import Dict, Optional - -from playwright._impl._connection import Channel -from playwright._impl._element_handle import ElementHandle -from playwright._impl._helper import locals_to_params - - -def _ax_node_from_protocol(axNode: Dict) -> Dict: - result = {**axNode} - if "valueNumber" in axNode: - result["value"] = axNode["valueNumber"] - elif "valueString" in axNode: - result["value"] = axNode["valueString"] - - if "checked" in axNode: - result["checked"] = ( - True - if axNode.get("checked") == "checked" - else ( - False if axNode.get("checked") == "unchecked" else axNode.get("checked") - ) - ) - - if "pressed" in axNode: - result["pressed"] = ( - True - if axNode.get("pressed") == "pressed" - else ( - False if axNode.get("pressed") == "released" else axNode.get("pressed") - ) - ) - - if axNode.get("children"): - result["children"] = list(map(_ax_node_from_protocol, axNode["children"])) - if "valueNumber" in result: - del result["valueNumber"] - if "valueString" in result: - del result["valueString"] - return result - - -class Accessibility: - def __init__(self, channel: Channel) -> None: - self._channel = channel - self._loop = channel._connection._loop - self._dispatcher_fiber = channel._connection._dispatcher_fiber - - async def snapshot( - self, interestingOnly: bool = None, root: ElementHandle = None - ) -> Optional[Dict]: - params = locals_to_params(locals()) - if root: - params["root"] = root._channel - result = await self._channel.send("accessibilitySnapshot", None, params) - return _ax_node_from_protocol(result) if result else None diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index 0afa0d02e..256b59435 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -166,6 +166,10 @@ class RemoteAddr(TypedDict): port: int +class BrowserBindResult(TypedDict): + endpoint: str + + class SecurityDetails(TypedDict): issuer: Optional[str] protocol: Optional[str] @@ -218,6 +222,7 @@ class FrameExpectResult(TypedDict): matches: bool received: Any log: List[str] + errorMessage: Optional[str] AriaRole = Literal[ @@ -310,3 +315,18 @@ 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 diff --git a/playwright/_impl/_assertions.py b/playwright/_impl/_assertions.py index 3aadbf5fe..aea37d35c 100644 --- a/playwright/_impl/_assertions.py +++ b/playwright/_impl/_assertions.py @@ -80,8 +80,10 @@ async def _expect_impl( out_message = ( f"{message} '{expected}'" if expected is not None else f"{message}" ) + error_message = result.get("errorMessage") + error_message = f"\n{error_message}" if error_message else "" raise AssertionError( - f"{out_message}\nActual value: {actual} {format_call_log(result.get('log'))}" + f"{out_message}\nActual value: {actual}{error_message} {format_call_log(result.get('log'))}" ) diff --git a/playwright/_impl/_browser.py b/playwright/_impl/_browser.py index 5a9a87450..6454f8c3f 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, @@ -247,6 +248,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 391e61ec6..4b1c19c40 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -46,6 +46,7 @@ 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._errors import Error, TargetClosedError from playwright._impl._event_context_manager import EventContextManagerImpl @@ -88,6 +89,7 @@ class BrowserContext(ChannelOwner): Events = SimpleNamespace( + # Deprecated in v1.56, never emitted anymore. BackgroundPage="backgroundpage", Close="close", Console="console", @@ -117,13 +119,14 @@ def __init__( self._timeout_settings = TimeoutSettings(None) self._owner_page: Optional[Page] = None self._options: Dict[str, Any] = initializer["options"] - self._background_pages: Set[Page] = set() self._service_workers: Set[Worker] = set() 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._debugger: Debugger = cast(Debugger, from_channel(initializer["debugger"])) self._har_recorders: Dict[str, HarRecordingMetadata] = {} self._request: APIRequestContext = from_channel(initializer["requestContext"]) + self._request._timeout_settings = self._timeout_settings self._clock = Clock(self) self._channel.on( "bindingCall", @@ -149,10 +152,6 @@ def __init__( ) ), ) - self._channel.on( - "backgroundPage", - lambda params: self._on_background_page(from_channel(params["page"])), - ) self._channel.on( "serviceWorker", @@ -585,6 +584,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 @@ -630,6 +632,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 @@ -658,10 +669,6 @@ def expect_page( ) -> EventContextManagerImpl[Page]: return self.expect_event(BrowserContext.Events.Page, predicate, timeout) - def _on_background_page(self, page: Page) -> None: - self._background_pages.add(page) - self.emit(BrowserContext.Events.BackgroundPage, page) - def _on_service_worker(self, worker: Worker) -> None: worker._context = self self._service_workers.add(worker) @@ -696,10 +703,13 @@ def _on_request_finished( def _on_console_message(self, event: Dict) -> None: message = ConsoleMessage(event, self._loop, self._dispatcher_fiber) - self.emit(BrowserContext.Events.Console, message) + worker = message.worker + if worker: + worker.emit(Worker.Events.Console, message) page = message.page if page: page.emit(Page.Events.Console, message) + self.emit(BrowserContext.Events.Console, message) def _on_dialog(self, dialog: Dialog) -> None: has_listeners = self.emit(BrowserContext.Events.Dialog, dialog) @@ -736,7 +746,7 @@ def _on_response(self, response: Response, page: Optional[Page]) -> None: @property def background_pages(self) -> List[Page]: - return list(self._background_pages) + return [] @property def service_workers(self) -> List[Worker]: @@ -757,6 +767,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 93173160c..ba376c336 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -82,11 +82,11 @@ async def launch( timeout: float = None, env: Env = None, headless: bool = None, - devtools: bool = None, proxy: ProxySettings = None, 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: @@ -118,7 +118,6 @@ async def launch_persistent_context( timeout: float = None, env: Env = None, headless: bool = None, - devtools: bool = None, proxy: ProxySettings = None, downloadsPath: Union[str, Path] = None, slowMo: float = None, @@ -145,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, @@ -200,6 +200,7 @@ async def connect_over_cdp( timeout: float = None, slowMo: float = None, headers: Dict[str, str] = None, + isLocal: bool = None, ) -> Browser: params = locals_to_params(locals()) if params.get("headers"): @@ -214,7 +215,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, @@ -230,7 +231,7 @@ async def connect( "connect", None, { - "wsEndpoint": wsEndpoint, + "endpoint": endpoint, "headers": headers, "slowMo": slowMo, "timeout": timeout if timeout is not None else 0, @@ -362,3 +363,5 @@ def normalize_launch_params(params: Dict) -> None: 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/_connection.py b/playwright/_impl/_connection.py index a837500b1..bbc42b6e1 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -218,10 +218,12 @@ def remove_listener(self, event: str, f: Any) -> None: class ProtocolCallback: - def __init__(self, loop: asyncio.AbstractEventLoop) -> None: + def __init__(self, loop: asyncio.AbstractEventLoop, no_reply: bool = False) -> None: self.stack_trace: traceback.StackSummary - self.no_reply: bool + self.no_reply = no_reply self.future = loop.create_future() + if no_reply: + self.future.set_result(None) # The outer task can get cancelled by the user, this forwards the cancellation to the inner task. current_task = asyncio.current_task() @@ -360,14 +362,13 @@ def _send_message_to_server( ) self._last_id += 1 id = self._last_id - callback = ProtocolCallback(self._loop) + callback = ProtocolCallback(self._loop, no_reply=no_reply) task = asyncio.current_task(self._loop) callback.stack_trace = cast( traceback.StackSummary, getattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10)), ) callback.no_reply = no_reply - self._callbacks[id] = callback stack_trace_information = cast(ParsedStackTrace, self._api_zone.get()) frames = stack_trace_information.get("frames", []) location = ( @@ -399,8 +400,8 @@ def _send_message_to_server( if self._tracing_count > 0 and frames and object._guid != "localUtils": self.local_utils.add_stack_to_tracing_no_reply(id, frames) - self._transport.send(message) self._callbacks[id] = callback + self._transport.send(message) return callback diff --git a/playwright/_impl/_console_message.py b/playwright/_impl/_console_message.py index 53c0dee95..d98901d34 100644 --- a/playwright/_impl/_console_message.py +++ b/playwright/_impl/_console_message.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: # pragma: no cover from playwright._impl._page import Page + from playwright._impl._worker import Worker class ConsoleMessage: @@ -31,6 +32,7 @@ def __init__( self._loop = loop self._dispatcher_fiber = dispatcher_fiber self._page: Optional["Page"] = from_nullable_channel(event.get("page")) + self._worker: Optional["Worker"] = from_nullable_channel(event.get("worker")) def __repr__(self) -> str: return f"" @@ -55,6 +57,7 @@ def type(self) -> Union[ Literal["startGroup"], Literal["startGroupCollapsed"], Literal["table"], + Literal["time"], Literal["timeEnd"], Literal["trace"], Literal["warning"], @@ -73,6 +76,14 @@ def args(self) -> List[JSHandle]: def location(self) -> SourceLocation: return self._event["location"] + @property + def timestamp(self) -> float: + return self._event["timestamp"] + @property def page(self) -> Optional["Page"]: return self._page + + @property + def worker(self) -> Optional["Worker"]: + return self._worker 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/_element_handle.py b/playwright/_impl/_element_handle.py index 1561e19fc..3854669d0 100644 --- a/playwright/_impl/_element_handle.py +++ b/playwright/_impl/_element_handle.py @@ -138,6 +138,7 @@ async def click( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: await self._channel.send( "click", self._frame._timeout, locals_to_params(locals()) @@ -153,6 +154,7 @@ async def dblclick( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: await self._channel.send( "dblclick", self._frame._timeout, locals_to_params(locals()) diff --git a/playwright/_impl/_frame.py b/playwright/_impl/_frame.py index fe19a576d..b976667e7 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -545,6 +545,23 @@ async def click( noWaitAfter: bool = None, strict: bool = None, trial: bool = None, + ) -> None: + await self._click(**locals_to_params(locals())) + + async def _click( + self, + selector: str, + modifiers: Sequence[KeyboardModifier] = None, + position: Position = None, + delay: float = None, + button: MouseButton = None, + clickCount: int = None, + timeout: float = None, + force: bool = None, + noWaitAfter: bool = None, + strict: bool = None, + trial: bool = None, + steps: int = None, ) -> None: await self._channel.send("click", self._timeout, locals_to_params(locals())) @@ -734,6 +751,7 @@ async def drag_and_drop( strict: bool = None, timeout: float = None, trial: bool = None, + steps: int = None, ) -> None: await self._channel.send( "dragAndDrop", self._timeout, locals_to_params(locals()) diff --git a/playwright/_impl/_glob.py b/playwright/_impl/_glob.py index 08b7ce466..b38826996 100644 --- a/playwright/_impl/_glob.py +++ b/playwright/_impl/_glob.py @@ -28,20 +28,21 @@ def glob_to_regex_pattern(glob: str) -> str: tokens.append("\\" + char if char in escaped_chars else char) i += 1 elif c == "*": - before_deep = glob[i - 1] if i > 0 else None + char_before = glob[i - 1] if i > 0 else None star_count = 1 while i + 1 < len(glob) and glob[i + 1] == "*": star_count += 1 i += 1 - after_deep = glob[i + 1] if i + 1 < len(glob) else None - is_deep = ( - star_count > 1 - and (before_deep == "/" or before_deep is None) - and (after_deep == "/" or after_deep is None) - ) - if is_deep: - tokens.append("((?:[^/]*(?:/|$))*)") - i += 1 + if star_count > 1: + char_after = glob[i + 1] if i + 1 < len(glob) else None + if char_after == "/": + if char_before == "/": + tokens.append("((.+/)|)") + else: + tokens.append("(.*/)") + i += 1 + else: + tokens.append("(.*)") else: tokens.append("([^/]*)") else: diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 66e59c65f..1d7e4f67b 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -29,12 +29,13 @@ Optional, Pattern, Set, + Tuple, TypedDict, TypeVar, Union, cast, ) -from urllib.parse import urljoin, urlparse +from urllib.parse import ParseResult, urljoin, urlparse, urlunparse from playwright._impl._api_structures import NameValue from playwright._impl._errors import ( @@ -209,8 +210,12 @@ def map_token(original: str, replacement: str) -> str: # Handle special case of http*://, note that the new schema has to be # a web schema so that slashes are properly inserted after domain. if index == 0 and token.endswith(":"): - # Using a simple replacement for the scheme part - processed_parts.append(map_token(token, "http:")) + # Replace any pattern with http: + if "*" in token or "{" in token: + processed_parts.append(map_token(token, "http:")) + else: + # Preserve explicit schema as is as it may affect trailing slashes after domain. + processed_parts.append(token) continue question_index = token.find("?") if question_index == -1: @@ -221,34 +226,49 @@ def map_token(original: str, replacement: str) -> str: processed_parts.append(new_prefix + new_suffix) relative_path = "/".join(processed_parts) - resolved_url = urljoin(base_url if base_url is not None else "", relative_path) - - for replacement, original in token_map.items(): - resolved_url = resolved_url.replace(replacement, original, 1) - - return ensure_trailing_slash(resolved_url) - - -# In Node.js, new URL('http://localhost') returns 'http://localhost/'. -# To ensure the same url matching behavior, do the same. -def ensure_trailing_slash(url: str) -> str: - split = url.split("://", maxsplit=1) - if len(split) == 2: - # URL parser doesn't like strange/unknown schemes, so we replace it for parsing, then put it back - parsable_url = "http://" + split[1] - else: - # Given current rules, this should never happen _and_ still be a valid matcher. We require the protocol to be part of the match, - # so either the user is using a glob that starts with "*" (and none of this code is running), or the user actually has `something://` in `match` - parsable_url = url - parsed = urlparse(parsable_url, allow_fragments=True) - if len(split) == 2: - # Replace the scheme that we removed earlier - parsed = parsed._replace(scheme=split[0]) - if parsed.path == "": - parsed = parsed._replace(path="/") - url = parsed.geturl() - - return url + resolved, case_insensitive_part = resolve_base_url(base_url, relative_path) + + for token, original in token_map.items(): + normalize = case_insensitive_part and token in case_insensitive_part + resolved = resolved.replace( + token, original.lower() if normalize else original, 1 + ) + + return resolved + + +def resolve_base_url( + base_url: Optional[str], given_url: str +) -> Tuple[str, Optional[str]]: + try: + url = nodelike_urlparse( + urljoin(base_url if base_url is not None else "", given_url) + ) + resolved = urlunparse(url) + # Schema and domain are case-insensitive. + hostname_port = ( + url.hostname or "" + ) # can't use parsed.netloc because it includes userinfo (username:password) + if url.port: + hostname_port += f":{url.port}" + case_insensitive_prefix = f"{url.scheme}://{hostname_port}" + return resolved, case_insensitive_prefix + except Exception: + return given_url, None + + +def nodelike_urlparse(url: str) -> ParseResult: + parsed = urlparse(url, allow_fragments=True) + + # https://url.spec.whatwg.org/#special-scheme + is_special_url = parsed.scheme in ["http", "https", "ws", "wss", "ftp", "file"] + if is_special_url: + # special urls have a list path, list paths are serialized as follows: https://url.spec.whatwg.org/#url-path-serializer + # urllib diverges, so we patch it here + if parsed.path == "": + parsed = parsed._replace(path="/") + + return parsed class HarLookupResult(TypedDict, total=False): 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 a65b68266..5f1b8f29a 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -14,6 +14,7 @@ import json import pathlib +import re from typing import ( TYPE_CHECKING, Any, @@ -155,9 +156,10 @@ async def click( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: params = locals_to_params(locals()) - return await self._frame.click(self._selector, strict=True, **params) + return await self._frame._click(self._selector, strict=True, **params) async def dblclick( self, @@ -169,6 +171,7 @@ async def dblclick( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: params = locals_to_params(locals()) return await self._frame.dblclick(self._selector, strict=True, **params) @@ -343,6 +346,20 @@ def describe(self, description: str) -> "Locator": f"{self._selector} >> internal:describe={json.dumps(description)}", ) + @property + def description(self) -> Optional[str]: + try: + match = re.search( + r' >> internal:describe=("(?:[^"\\]|\\.)*")$', self._selector + ) + if match: + description = json.loads(match.group(1)) + if isinstance(description, str): + return description + except (json.JSONDecodeError, ValueError): + pass + return None + def filter( self, hasText: Union[str, Pattern[str]] = None, @@ -414,6 +431,7 @@ async def drag_to( trial: bool = None, sourcePosition: Position = None, targetPosition: Position = None, + steps: int = None, ) -> None: params = locals_to_params(locals()) del params["target"] @@ -546,7 +564,12 @@ 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, + ) -> str: return await self._frame._channel.send( "ariaSnapshot", self._frame._timeout, @@ -556,6 +579,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, diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index a999ce73c..06bf88267 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -66,7 +66,7 @@ from playwright._impl._browser_context import BrowserContext from playwright._impl._fetch import APIResponse from playwright._impl._frame import Frame - from playwright._impl._page import Page + from playwright._impl._page import Page, Worker class FallbackOverrideParameters(TypedDict, total=False): @@ -154,6 +154,7 @@ def __init__( self._fallback_overrides: SerializedFallbackOverrides = ( SerializedFallbackOverrides() ) + self._response: Optional["Response"] = None def __repr__(self) -> str: return f"" @@ -184,6 +185,13 @@ def url(self) -> str: def resource_type(self) -> str: return self._initializer["resourceType"] + @property + def service_worker(self) -> Optional["Worker"]: + return cast( + Optional["Worker"], + from_nullable_channel(self._initializer.get("serviceWorker")), + ) + @property def method(self) -> str: return cast(str, self._fallback_overrides.method or self._initializer["method"]) @@ -236,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"): @@ -792,6 +804,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"] @@ -874,6 +887,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..7abfb4b33 100644 --- a/playwright/_impl/_object_factory.py +++ b/playwright/_impl/_object_factory.py @@ -20,6 +20,7 @@ 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._element_handle import ElementHandle from playwright._impl._fetch import APIRequestContext @@ -64,6 +65,8 @@ 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 == "ElementHandle": diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 1019b2f6e..4cecbd64c 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -33,7 +33,6 @@ cast, ) -from playwright._impl._accessibility import Accessibility from playwright._impl._api_structures import ( AriaRole, FilePayload, @@ -79,6 +78,7 @@ async_writefile, locals_to_params, make_dirs_for_file, + parse_error, serialize_error, url_matches, ) @@ -98,6 +98,7 @@ WebSocketRouteHandler, serialize_headers, ) +from playwright._impl._screencast import Screencast from playwright._impl._video import Video from playwright._impl._waiter import Waiter @@ -149,7 +150,6 @@ class Page(ChannelOwner): WebSocket="websocket", Worker="worker", ) - accessibility: Accessibility keyboard: Keyboard mouse: Mouse touchscreen: Touchscreen @@ -159,7 +159,6 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._browser_context = cast("BrowserContext", parent) - self.accessibility = Accessibility(self._channel) self.keyboard = Keyboard(self._channel) self.mouse = Mouse(self._channel) self.touchscreen = Touchscreen(self._channel) @@ -177,7 +176,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 @@ -226,7 +229,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", @@ -344,8 +346,6 @@ def _on_close(self) -> None: self._is_closed = True if self in self._browser_context._pages: self._browser_context._pages.remove(self) - if self in self._browser_context._background_pages: - self._browser_context._background_pages.remove(self) self._dispose_har_routers() self.emit(Page.Events.Close, self) @@ -360,10 +360,6 @@ def _on_download(self, params: Any) -> None: 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) - def _on_viewport_size_changed(self, params: Any) -> None: self._viewport_size = params["viewportSize"] @@ -827,6 +823,18 @@ 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, + ) -> 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 @@ -855,7 +863,7 @@ async def click( trial: bool = None, strict: bool = None, ) -> None: - return await self._main_frame.click(**locals_to_params(locals())) + return await self._main_frame._click(**locals_to_params(locals())) async def dblclick( self, @@ -1018,6 +1026,7 @@ async def drag_and_drop( timeout: float = None, strict: bool = None, trial: bool = None, + steps: int = None, ) -> None: return await self._main_frame.drag_and_drop(**locals_to_params(locals())) @@ -1171,21 +1180,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( @@ -1434,14 +1439,53 @@ async def remove_locator_handler(self, locator: "Locator") -> None: {"uid": uid}, ) + 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, 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 + ) + for event in message_dicts + ] + + 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") + Events = SimpleNamespace(Close="close", Console="console") def __init__( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict ) -> None: super().__init__(parent, type, guid, initializer) + self._set_event_to_subscription_mapping({Worker.Events.Console: "console"}) self._channel.on("close", lambda _: self._on_close()) self._page: Optional[Page] = None self._context: Optional["BrowserContext"] = None @@ -1486,6 +1530,31 @@ async def evaluate_handle( ) ) + def expect_event( + self, + event: str, + predicate: Callable = None, + timeout: float = None, + ) -> EventContextManagerImpl: + if timeout is None: + if self._page: + timeout = self._page._timeout_settings.timeout() + elif self._context: + timeout = self._context._timeout_settings.timeout() + else: + timeout = 30000 + waiter = Waiter(self, f"worker.expect_event({event})") + waiter.reject_on_timeout( + cast(float, timeout), + f'Timeout {timeout}ms exceeded while waiting for event "{event}"', + ) + if event != Worker.Events.Close: + waiter.reject_on_event( + self, Worker.Events.Close, lambda: TargetClosedError() + ) + waiter.wait_for_event(self, event, predicate) + return EventContextManagerImpl(waiter.result()) + class BindingCall(ChannelOwner): def __init__( diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py new file mode 100644 index 000000000..1f19c7220 --- /dev/null +++ b/playwright/_impl/_screencast.py @@ -0,0 +1,130 @@ +# 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._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}) + 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, + ) -> None: + 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 + + 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, + ) -> None: + await self._page._channel.send( + "screencastShowActions", None, locals_to_params(locals()) + ) + + async def hide_actions(self) -> None: + await self._page._channel.send("screencastHideActions", None) + + async def show_overlay(self, html: str, duration: float = None) -> None: + await self._page._channel.send( + "screencastShowOverlay", None, locals_to_params(locals()) + ) + + 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/_set_input_files_helpers.py b/playwright/_impl/_set_input_files_helpers.py index 0f40d5b99..65307e0a2 100644 --- a/playwright/_impl/_set_input_files_helpers.py +++ b/playwright/_impl/_set_input_files_helpers.py @@ -14,6 +14,7 @@ import base64 import collections.abc import os +import stat from pathlib import Path from typing import ( TYPE_CHECKING, @@ -144,7 +145,8 @@ def resolve_paths_and_directory_for_input_files( local_paths: Optional[List[str]] = None local_directory: Optional[str] = None for item in items: - if os.path.isdir(item): + item_stat = os.stat(item) # Raises FileNotFoundError if doesn't exist + if stat.S_ISDIR(item_stat.st_mode): if local_directory: raise Error("Multiple directories are not supported") local_directory = str(Path(item).resolve()) diff --git a/playwright/_impl/_tracing.py b/playwright/_impl/_tracing.py index bbc6ec35e..8dda75994 100644 --- a/playwright/_impl/_tracing.py +++ b/playwright/_impl/_tracing.py @@ -27,6 +27,7 @@ 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 @@ -38,11 +39,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 +70,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: 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"