diff --git a/.azure-pipelines/publish.yml b/.azure-pipelines/publish.yml index 10d6ead8b..1a2e0e7a4 100644 --- a/.azure-pipelines/publish.yml +++ b/.azure-pipelines/publish.yml @@ -29,29 +29,54 @@ extends: stages: - stage: Stage jobs: - - job: HostJob + - job: Build + templateContext: + outputs: + - output: pipelineArtifact + path: $(Build.ArtifactStagingDirectory)/esrp-build + artifact: esrp-build steps: - task: UsePythonVersion@0 inputs: - versionSpec: '3.8' + versionSpec: '3.9' displayName: 'Use Python' - script: | python -m pip install --upgrade pip pip install -r local-requirements.txt + pip install -r requirements.txt pip install -e . - python setup.py bdist_wheel --all + for wheel in $(python setup.py --list-wheels); do + PLAYWRIGHT_TARGET_WHEEL=$wheel python -m build --wheel --outdir $(Build.ArtifactStagingDirectory)/esrp-build + done displayName: 'Install & Build' - - task: EsrpRelease@4 + - job: Publish + dependsOn: Build + templateContext: + type: releaseJob + isProduction: true inputs: - ConnectedServiceName: 'Playwright-ESRP' - Intent: 'PackageDistribution' - ContentType: 'PyPi' - ContentSource: 'Folder' - FolderLocation: './dist/' - WaitForReleaseCompletion: true - Owners: 'maxschmitt@microsoft.com' - Approvers: 'maxschmitt@microsoft.com' - ServiceEndpointUrl: 'https://api.esrp.microsoft.com' - MainPublisher: 'Playwright' - DomainTenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47' - displayName: 'ESRP Release to PIP' + - input: pipelineArtifact + artifactName: esrp-build + targetPath: $(Build.ArtifactStagingDirectory)/esrp-build + steps: + - checkout: none + - task: EsrpRelease@9 + inputs: + connectedservicename: 'Playwright-ESRP-PME' + usemanagedidentity: true + keyvaultname: 'playwright-esrp-pme' + signcertname: 'ESRP-Release-Sign' + clientid: '13434a40-7de4-4c23-81a3-d843dc81c2c5' + intent: 'PackageDistribution' + contenttype: 'PyPi' + # Keeping it commented out as a workaround for: + # https://portal.microsofticm.com/imp/v3/incidents/incident/499972482/summary + # contentsource: 'folder' + folderlocation: '$(Build.ArtifactStagingDirectory)/esrp-build' + waitforreleasecompletion: true + owners: 'yurys@microsoft.com' + approvers: 'yurys@microsoft.com' + serviceendpointurl: 'https://api.esrp.microsoft.com' + mainpublisher: 'Playwright' + domaintenantid: '975f013f-7f24-47e8-a7d3-abc4752bf346' + displayName: 'ESRP Release to PIP' 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/dependabot.yml b/.github/dependabot.yml index 6a7695c06..33c127127 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,11 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c37bf348e..eb06caff0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,17 +21,18 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.10" - name: Install dependencies & browsers run: | python -m pip install --upgrade pip pip install -r local-requirements.txt + pip install -r requirements.txt pip install -e . - python setup.py bdist_wheel + python -m build --wheel python -m playwright install --with-deps - name: Lint run: pre-commit run --show-diff-on-failure --color=always --all-files @@ -47,18 +48,20 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: [3.8, 3.9] + python-version: ['3.9', '3.10'] browser: [chromium, firefox, webkit] - include: - - os: ubuntu-latest - python-version: '3.10' - browser: chromium - - os: windows-latest - python-version: '3.10' - browser: chromium + 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: chromium + browser: webkit - os: windows-latest python-version: '3.11' browser: chromium @@ -77,19 +80,29 @@ jobs: - os: ubuntu-latest python-version: '3.12' browser: chromium + - os: windows-latest + python-version: '3.13' + browser: chromium + - os: macos-latest + python-version: '3.13' + browser: chromium + - os: ubuntu-latest + python-version: '3.13' + browser: chromium runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies & browsers run: | python -m pip install --upgrade pip pip install -r local-requirements.txt + pip install -r requirements.txt pip install -e . - python setup.py bdist_wheel + python -m build --wheel python -m playwright install --with-deps ${{ matrix.browser }} - name: Common Tests run: pytest tests/common --browser=${{ matrix.browser }} --timeout 90 @@ -125,17 +138,18 @@ jobs: browser-channel: msedge runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.10" - name: Install dependencies & browsers run: | python -m pip install --upgrade pip pip install -r local-requirements.txt + pip install -r requirements.txt pip install -e . - python setup.py bdist_wheel + python -m build --wheel python -m playwright install ${{ matrix.browser-channel }} --with-deps - name: Common Tests run: pytest tests/common --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90 @@ -157,19 +171,27 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-20.04, macos-12, windows-2019] + os: [ubuntu-22.04, macos-latest, 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@v3 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Get conda - uses: conda-incubator/setup-miniconda@v2 + 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 . @@ -180,9 +202,9 @@ jobs: run: working-directory: examples/todomvc/ steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v4 + 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 cae28da1a..e9a7048c5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,9 +2,11 @@ name: Upload Python Package on: release: types: [published] + workflow_dispatch: jobs: deploy-conda: strategy: + fail-fast: false matrix: include: - os: ubuntu-latest @@ -23,18 +25,17 @@ jobs: # Required for conda-incubator/setup-miniconda@v3 shell: bash -el {0} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Get conda uses: conda-incubator/setup-miniconda@v3 with: - python-version: 3.12 + python-version: '3.12' channels: conda-forge miniconda-version: latest - name: Prepare - # Pinned because of https://github.com/conda/conda-build/issues/5267 - run: conda install anaconda-client conda-build=24.1.2 conda-verify py-lief=0.12.3 + run: conda install -n base anaconda-client "conda-build>=26" conda-verify - name: Build and Upload env: ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }} @@ -43,7 +44,6 @@ jobs: 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 install cross-python_linux-aarch64 conda build --user microsoft . -m conda_build_config_linux_aarch64.yaml else conda build --user microsoft . diff --git a/.github/workflows/publish_docker.yml b/.github/workflows/publish_docker.yml index d69645bee..7494f1abc 100644 --- a/.github/workflows/publish_docker.yml +++ b/.github/workflows/publish_docker.yml @@ -2,12 +2,6 @@ name: "publish release - Docker" on: workflow_dispatch: - inputs: - is_release: - required: false - type: boolean - description: "Is this a release image?" - release: types: [published] @@ -16,27 +10,32 @@ jobs: name: "publish to DockerHub" runs-on: ubuntu-22.04 if: github.repository == 'microsoft/playwright-python' + permissions: + id-token: write # This is required for OIDC login (azure/login) to succeed + contents: read # This is required for actions/checkout to succeed + environment: Docker steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 + - name: Azure login + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }} + - name: Login to ACR via OIDC + run: az acr login --name playwright - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.10" - - uses: azure/docker-login@v1 - with: - login-server: playwright.azurecr.io - username: playwright - password: ${{ secrets.DOCKER_PASSWORD }} - name: Set up Docker QEMU for arm64 docker builds - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 with: platforms: arm64 - name: Install dependencies & browsers run: | python -m pip install --upgrade pip pip install -r local-requirements.txt + pip install -r requirements.txt pip install -e . - run: ./utils/docker/publish_docker.sh stable - if: (github.event_name != 'workflow_dispatch' && !github.event.release.prerelease) || (github.event_name == 'workflow_dispatch' && github.event.inputs.is_release == 'true') - - run: ./utils/docker/publish_docker.sh canary - if: (github.event_name != 'workflow_dispatch' && github.event.release.prerelease) || (github.event_name == 'workflow_dispatch' && github.event.inputs.is_release != 'true') diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 5a3266197..464eb3b46 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -19,33 +19,40 @@ on: jobs: build: timeout-minutes: 120 - runs-on: ubuntu-22.04 + runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false matrix: docker-image-variant: - - focal - jammy + - noble + runs-on: + - ubuntu-24.04 + - ubuntu-24.04-arm steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r local-requirements.txt + pip install -r requirements.txt pip install -e . - name: Build Docker image - run: bash utils/docker/build.sh --amd64 ${{ matrix.docker-image-variant }} playwright-python:localbuild-${{ matrix.docker-image-variant }} + run: | + ARCH="${{ matrix.runs-on == 'ubuntu-24.04-arm' && 'arm64' || 'amd64' }}" + bash utils/docker/build.sh --$ARCH ${{ matrix.docker-image-variant }} playwright-python:localbuild-${{ matrix.docker-image-variant }} - name: Test run: | - CONTAINER_ID="$(docker run --rm -v $(pwd):/root/playwright --name playwright-docker-test --workdir /root/playwright/ -d -t playwright-python:localbuild-${{ matrix.docker-image-variant }} /bin/bash)" + CONTAINER_ID="$(docker run --rm -e CI -v $(pwd):/root/playwright --name playwright-docker-test --workdir /root/playwright/ -d -t playwright-python:localbuild-${{ matrix.docker-image-variant }} /bin/bash)" # Fix permissions for Git inside the container docker exec "${CONTAINER_ID}" chown -R root:root /root/playwright docker exec "${CONTAINER_ID}" pip install -r local-requirements.txt + docker exec "${CONTAINER_ID}" pip install -r requirements.txt docker exec "${CONTAINER_ID}" pip install -e . - docker exec "${CONTAINER_ID}" python setup.py bdist_wheel - docker exec "${CONTAINER_ID}" xvfb-run pytest -vv tests/sync/ - docker exec "${CONTAINER_ID}" xvfb-run pytest -vv tests/async/ + 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/.github/workflows/trigger_internal_tests.yml b/.github/workflows/trigger_internal_tests.yml deleted file mode 100644 index b4e6c21db..000000000 --- a/.github/workflows/trigger_internal_tests.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: "Internal Tests" - -on: - push: - branches: - - main - - release-* - -jobs: - trigger: - name: "trigger" - runs-on: ubuntu-20.04 - steps: - - run: | - curl -X POST \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: token ${GH_TOKEN}" \ - --data "{\"event_type\": \"playwright_tests_python\", \"client_payload\": {\"ref\": \"${GITHUB_SHA}\"}}" \ - https://api.github.com/repos/microsoft/playwright-browsers/dispatches - env: - GH_TOKEN: ${{ secrets.REPOSITORY_DISPATCH_PERSONAL_ACCESS_TOKEN }} diff --git a/.gitignore b/.gitignore index 919e041a6..8424e9bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ coverage.xml junit/ htmldocs/ utils/docker/dist/ +Pipfile +Pipfile.lock +.venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5198070e1..57fdca816 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -15,20 +15,20 @@ repos: - id: check-executables-have-shebangs - id: check-merge-conflict - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 25.1.0 hooks: - id: black - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.17.0 hooks: - id: mypy - additional_dependencies: [types-pyOpenSSL==23.2.0.2, types-requests==2.31.0.10] + additional_dependencies: [types-pyOpenSSL==24.1.0.20240722, types-requests==2.32.4.20250611] - repo: https://github.com/pycqa/flake8 - rev: 6.1.0 + rev: 7.3.0 hooks: - id: flake8 - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 6.0.1 hooks: - id: isort - repo: local @@ -39,7 +39,7 @@ repos: language: node pass_filenames: false types: [python] - additional_dependencies: ["pyright@1.1.278"] + additional_dependencies: ["pyright@1.1.403"] - repo: local hooks: - id: check-license-header diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..1cbaf3e1a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# 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. + +## 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 a5ab7d4bd..b59e281c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,9 +23,7 @@ Build and install drivers: ```sh pip install -e . -python setup.py bdist_wheel -# For all platforms -python setup.py bdist_wheel --all +python -m build --wheel ``` Run tests: @@ -47,7 +45,7 @@ pre-commit install pre-commit run --all-files ``` -For more details look at the [CI configuration](./blob/main/.github/workflows/ci.yml). +For more details look at the [CI configuration](./.github/workflows/ci.yml). Collect coverage diff --git a/README.md b/README.md index bdf616079..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 124.0.6367.29 | ✅ | ✅ | ✅ | -| WebKit 17.4 | ✅ | ✅ | ✅ | -| Firefox 124.0 | ✅ | ✅ | ✅ | +| Chromium 147.0.7727.15 | ✅ | ✅ | ✅ | +| WebKit 26.4 | ✅ | ✅ | ✅ | +| Firefox 148.0.2 | ✅ | ✅ | ✅ | ## Documentation diff --git a/ROLLING.md b/ROLLING.md index 5cd3240fa..811d7fcb3 100644 --- a/ROLLING.md +++ b/ROLLING.md @@ -5,12 +5,21 @@ * 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 setup.py bdist_wheel` +* download new driver: `python -m build --wheel` * generate API: `./scripts/update_api.sh` * commit changes & send PR * wait for bots to pass & merge the PR + + +## Fix typing issues with Playwright ToT + +1. `cd playwright` +1. `API_JSON_MODE=1 node utils/doclint/generateApiJson.js > ../playwright-python/playwright/driver/package/api.json` +1. `./scripts/update_api.sh` diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 000000000..0fd849315 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,17 @@ +# Support + +## How to file issues and get help + +This project uses GitHub issues to track bugs and feature requests. Please search the [existing issues][gh-issues] before filing new ones to avoid duplicates. For new issues, file your bug or feature request as a new issue using corresponding template. + +For help and questions about using this project, please see the [docs site for Playwright for Python][docs]. + +Join our community [Discord Server][discord-server] to connect with other developers using Playwright and ask questions in our 'help-playwright' forum. + +## Microsoft Support Policy + +Support for Playwright for Python is limited to the resources listed above. + +[gh-issues]: https://github.com/microsoft/playwright-python/issues/ +[docs]: https://playwright.dev/python/ +[discord-server]: https://aka.ms/playwright/discord diff --git a/examples/todomvc/requirements.txt b/examples/todomvc/requirements.txt index eb6fcbbd0..801cd515b 100644 --- a/examples/todomvc/requirements.txt +++ b/examples/todomvc/requirements.txt @@ -1 +1 @@ -pytest-playwright==0.3.0 +pytest-playwright diff --git a/local-requirements.txt b/local-requirements.txt index 51250c00b..5735ae2a0 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -1,24 +1,23 @@ -auditwheel==6.0.0 +asyncio-atexit==1.0.1 autobahn==23.1.2 -black==24.3.0 -flake8==7.0.0 -flaky==3.8.1 -mypy==1.9.0 -objgraph==3.6.1 -Pillow==10.3.0 +black==25.1.0 +build==1.3.0 +flake8==7.2.0 +mypy==1.17.1 +objgraph==3.6.2 +Pillow==11.3.0 pixelmatch==0.3.0 -pre-commit==3.4.0 -pyOpenSSL==24.1.0 -pytest==8.1.1 -pytest-asyncio==0.21.1 -pytest-cov==5.0.0 -pytest-repeat==0.9.3 -pytest-timeout==2.3.1 -pytest-xdist==3.5.0 -requests==2.31.0 -service_identity==24.1.0 -setuptools==69.2.0 -twisted==24.3.0 -types-pyOpenSSL==24.0.0.20240311 -types-requests==2.31.0.20240406 -wheel==0.42.0 +pre-commit==3.5.0 +pyOpenSSL==25.1.0 +pytest==8.4.1 +pytest-asyncio==1.1.0 +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.5 +service_identity==24.2.0 +twisted==25.5.0 +types-pyOpenSSL==24.1.0.20240722 +types-requests==2.32.4.20250809 diff --git a/meta.yaml b/meta.yaml index 98b774490..343f9b568 100644 --- a/meta.yaml +++ b/meta.yaml @@ -15,21 +15,25 @@ build: requirements: build: - - python >=3.8 # [build_platform != target_platform] + - python >=3.9 # [build_platform != target_platform] - pip # [build_platform != target_platform] - cross-python_{{ target_platform }} # [build_platform != target_platform] host: - - python >=3.8 + - python >=3.9 - wheel - pip - curl - setuptools_scm run: - - python >=3.8 - - greenlet ==3.0.3 - - pyee ==11.0.1 + - 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: @@ -38,6 +42,9 @@ test: # [build_platform == target_platform] - 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 diff --git a/playwright/_impl/_accessibility.py b/playwright/_impl/_accessibility.py deleted file mode 100644 index 010b4e8c5..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", 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 f06a6247e..256b59435 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path from typing import Any, Dict, List, Literal, Optional, Sequence, TypedDict, Union # These are the structures that we like keeping in a JSON form for their potential @@ -31,6 +32,18 @@ class Cookie(TypedDict, total=False): httpOnly: bool secure: bool sameSite: Literal["Lax", "None", "Strict"] + partitionKey: Optional[str] + + +class StorageStateCookie(TypedDict, total=False): + name: str + value: str + domain: str + path: str + expires: float + httpOnly: bool + secure: bool + sameSite: Literal["Lax", "None", "Strict"] # TODO: We are waiting for PEP705 so SetCookieParam can be readonly and matches Cookie. @@ -44,6 +57,7 @@ class SetCookieParam(TypedDict, total=False): httpOnly: Optional[bool] secure: Optional[bool] sameSite: Optional[Literal["Lax", "None", "Strict"]] + partitionKey: Optional[str] class FloatRect(TypedDict): @@ -63,6 +77,7 @@ class HttpCredentials(TypedDict, total=False): username: str password: str origin: Optional[str] + send: Optional[Literal["always", "unauthorized"]] class LocalStorageEntry(TypedDict): @@ -95,10 +110,21 @@ class ProxySettings(TypedDict, total=False): class StorageState(TypedDict, total=False): - cookies: List[Cookie] + cookies: List[StorageStateCookie] origins: List[OriginState] +class ClientCertificate(TypedDict, total=False): + origin: str + certPath: Optional[Union[str, Path]] + cert: Optional[bytes] + keyPath: Optional[Union[str, Path]] + key: Optional[bytes] + pfxPath: Optional[Union[str, Path]] + pfx: Optional[bytes] + passphrase: Optional[str] + + class ResourceTiming(TypedDict): startTime: float domainLookupStart: float @@ -140,6 +166,10 @@ class RemoteAddr(TypedDict): port: int +class BrowserBindResult(TypedDict): + endpoint: str + + class SecurityDetails(TypedDict): issuer: Optional[str] protocol: Optional[str] @@ -192,6 +222,7 @@ class FrameExpectResult(TypedDict): matches: bool received: Any log: List[str] + errorMessage: Optional[str] AriaRole = Literal[ @@ -278,3 +309,24 @@ class FrameExpectResult(TypedDict): "treegrid", "treeitem", ] + + +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/_artifact.py b/playwright/_impl/_artifact.py index 63833fe04..a08294cbe 100644 --- a/playwright/_impl/_artifact.py +++ b/playwright/_impl/_artifact.py @@ -33,24 +33,55 @@ async def path_after_finished(self) -> pathlib.Path: raise Error( "Path is not available when using browser_type.connect(). Use save_as() to save a local copy." ) - path = await self._channel.send("pathAfterFinished") + path = await self._channel.send( + "pathAfterFinished", + None, + ) return pathlib.Path(path) async def save_as(self, path: Union[str, Path]) -> None: - stream = cast(Stream, from_channel(await self._channel.send("saveAsStream"))) + stream = cast( + Stream, + from_channel( + await self._channel.send( + "saveAsStream", + None, + ) + ), + ) make_dirs_for_file(path) await stream.save_as(path) async def failure(self) -> Optional[str]: - return patch_error_message(await self._channel.send("failure")) + reason = await self._channel.send( + "failure", + None, + ) + if reason is None: + return None + return patch_error_message(reason) async def delete(self) -> None: - await self._channel.send("delete") + await self._channel.send( + "delete", + None, + ) async def read_info_buffer(self) -> bytes: - stream = cast(Stream, from_channel(await self._channel.send("stream"))) + stream = cast( + Stream, + from_channel( + await self._channel.send( + "stream", + None, + ) + ), + ) buffer = await stream.read_all() return buffer - async def cancel(self) -> None: - await self._channel.send("cancel") + async def cancel(self) -> None: # pyright: ignore[reportIncompatibleMethodOverride] + await self._channel.send( + "cancel", + None, + ) diff --git a/playwright/_impl/_assertions.py b/playwright/_impl/_assertions.py index 2c895e527..aea37d35c 100644 --- a/playwright/_impl/_assertions.py +++ b/playwright/_impl/_assertions.py @@ -16,7 +16,12 @@ from typing import Any, List, Optional, Pattern, Sequence, Union from urllib.parse import urljoin -from playwright._impl._api_structures import ExpectedTextValue, FrameExpectOptions +from playwright._impl._api_structures import ( + AriaRole, + ExpectedTextValue, + FrameExpectOptions, + FrameExpectResult, +) from playwright._impl._connection import format_call_log from playwright._impl._errors import Error from playwright._impl._fetch import APIResponse @@ -41,12 +46,20 @@ def __init__( self._is_not = is_not self._custom_message = message + async def _call_expect( + self, expression: str, expect_options: FrameExpectOptions, title: Optional[str] + ) -> FrameExpectResult: + raise NotImplementedError( + "_call_expect must be implemented in a derived class." + ) + async def _expect_impl( self, expression: str, expect_options: FrameExpectOptions, expected: Any, message: str, + title: str = None, ) -> None: __tracebackhide__ = True expect_options["isNot"] = self._is_not @@ -56,7 +69,7 @@ async def _expect_impl( message = message.replace("expected to", "expected not to") if "useInnerText" in expect_options and expect_options["useInnerText"] is None: del expect_options["useInnerText"] - result = await self._actual_locator._expect(expression, expect_options) + result = await self._call_expect(expression, expect_options, title) if result["matches"] == self._is_not: actual = result.get("received") if self._custom_message: @@ -67,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'))}" ) @@ -83,6 +98,14 @@ def __init__( super().__init__(page.locator(":root"), timeout, is_not, message) self._actual_page = page + async def _call_expect( + self, expression: str, expect_options: FrameExpectOptions, title: Optional[str] + ) -> FrameExpectResult: + __tracebackhide__ = True + return await self._actual_page.main_frame._expect( + None, expression, expect_options, title + ) + @property def _not(self) -> "PageAssertions": return PageAssertions( @@ -92,15 +115,16 @@ def _not(self) -> "PageAssertions": async def to_have_title( self, titleOrRegExp: Union[Pattern[str], str], timeout: float = None ) -> None: + __tracebackhide__ = True expected_values = to_expected_text_values( [titleOrRegExp], normalize_white_space=True ) - __tracebackhide__ = True await self._expect_impl( "to.have.title", FrameExpectOptions(expectedText=expected_values, timeout=timeout), titleOrRegExp, "Page title expected to be", + 'Expect "to_have_title"', ) async def not_to_have_title( @@ -110,25 +134,32 @@ async def not_to_have_title( await self._not.to_have_title(titleOrRegExp, timeout) async def to_have_url( - self, urlOrRegExp: Union[str, Pattern[str]], timeout: float = None + self, + urlOrRegExp: Union[str, Pattern[str]], + timeout: float = None, + ignoreCase: bool = None, ) -> None: __tracebackhide__ = True - base_url = self._actual_page.context._options.get("baseURL") + base_url = self._actual_page.context._base_url if isinstance(urlOrRegExp, str) and base_url: urlOrRegExp = urljoin(base_url, urlOrRegExp) - expected_text = to_expected_text_values([urlOrRegExp]) + expected_text = to_expected_text_values([urlOrRegExp], ignoreCase=ignoreCase) await self._expect_impl( "to.have.url", FrameExpectOptions(expectedText=expected_text, timeout=timeout), urlOrRegExp, "Page URL expected to be", + 'Expect "to_have_url"', ) async def not_to_have_url( - self, urlOrRegExp: Union[Pattern[str], str], timeout: float = None + self, + urlOrRegExp: Union[Pattern[str], str], + timeout: float = None, + ignoreCase: bool = None, ) -> None: __tracebackhide__ = True - await self._not.to_have_url(urlOrRegExp, timeout) + await self._not.to_have_url(urlOrRegExp, timeout, ignoreCase) class LocatorAssertions(AssertionsBase): @@ -142,6 +173,12 @@ def __init__( super().__init__(locator, timeout, is_not, message) self._actual_locator = locator + async def _call_expect( + self, expression: str, expect_options: FrameExpectOptions, title: Optional[str] + ) -> FrameExpectResult: + __tracebackhide__ = True + return await self._actual_locator._expect(expression, expect_options, title) + @property def _not(self) -> "LocatorAssertions": return LocatorAssertions( @@ -180,6 +217,7 @@ async def to_contain_text( ), expected, "Locator expected to contain text", + 'Expect "to_contain_text"', ) else: expected_text = to_expected_text_values( @@ -197,6 +235,7 @@ async def to_contain_text( ), expected, "Locator expected to contain text", + 'Expect "to_contain_text"', ) async def not_to_contain_text( @@ -231,6 +270,7 @@ async def to_have_attribute( ), value, "Locator expected to have attribute", + 'Expect "to_have_attribute"', ) async def not_to_have_attribute( @@ -266,6 +306,7 @@ async def to_have_class( FrameExpectOptions(expectedText=expected_text, timeout=timeout), expected, "Locator expected to have class", + 'Expect "to_have_class"', ) else: expected_text = to_expected_text_values([expected]) @@ -274,6 +315,7 @@ async def to_have_class( FrameExpectOptions(expectedText=expected_text, timeout=timeout), expected, "Locator expected to have class", + 'Expect "to_have_class"', ) async def not_to_have_class( @@ -290,6 +332,47 @@ async def not_to_have_class( __tracebackhide__ = True await self._not.to_have_class(expected, timeout) + async def to_contain_class( + self, + expected: Union[ + Sequence[str], + str, + ], + timeout: float = None, + ) -> None: + __tracebackhide__ = True + if isinstance(expected, collections.abc.Sequence) and not isinstance( + expected, str + ): + expected_text = to_expected_text_values(expected) + await self._expect_impl( + "to.contain.class.array", + FrameExpectOptions(expectedText=expected_text, timeout=timeout), + expected, + "Locator expected to contain class names", + 'Expect "to_contain_class"', + ) + else: + expected_text = to_expected_text_values([expected]) + await self._expect_impl( + "to.contain.class", + FrameExpectOptions(expectedText=expected_text, timeout=timeout), + expected, + "Locator expected to contain class", + 'Expect "to_contain_class"', + ) + + async def not_to_contain_class( + self, + expected: Union[ + Sequence[str], + str, + ], + timeout: float = None, + ) -> None: + __tracebackhide__ = True + await self._not.to_contain_class(expected, timeout) + async def to_have_count( self, count: int, @@ -301,6 +384,7 @@ async def to_have_count( FrameExpectOptions(expectedNumber=count, timeout=timeout), count, "Locator expected to have count", + 'Expect "to_have_count"', ) async def not_to_have_count( @@ -326,6 +410,7 @@ async def to_have_css( ), value, "Locator expected to have CSS", + 'Expect "to_have_css"', ) async def not_to_have_css( @@ -349,6 +434,7 @@ async def to_have_id( FrameExpectOptions(expectedText=expected_text, timeout=timeout), id, "Locator expected to have ID", + 'Expect "to_have_id"', ) async def not_to_have_id( @@ -373,6 +459,7 @@ async def to_have_js_property( ), value, "Locator expected to have JS Property", + 'Expect "to_have_property"', ) async def not_to_have_js_property( @@ -396,6 +483,7 @@ async def to_have_value( FrameExpectOptions(expectedText=expected_text, timeout=timeout), value, "Locator expected to have Value", + 'Expect "to_have_value"', ) async def not_to_have_value( @@ -420,6 +508,7 @@ async def to_have_values( FrameExpectOptions(expectedText=expected_text, timeout=timeout), values, "Locator expected to have Values", + 'Expect "to_have_values"', ) async def not_to_have_values( @@ -463,6 +552,7 @@ async def to_have_text( ), expected, "Locator expected to have text", + 'Expect "to_have_text"', ) else: expected_text = to_expected_text_values( @@ -477,6 +567,7 @@ async def to_have_text( ), expected, "Locator expected to have text", + 'Expect "to_have_text"', ) async def not_to_have_text( @@ -501,28 +592,40 @@ async def to_be_attached( timeout: float = None, ) -> None: __tracebackhide__ = True + if attached is None: + attached = True + attached_string = "attached" if attached else "detached" await self._expect_impl( - "to.be.attached" - if (attached is None or attached is True) - else "to.be.detached", + ("to.be.attached" if attached else "to.be.detached"), FrameExpectOptions(timeout=timeout), None, - "Locator expected to be attached", + f"Locator expected to be {attached_string}", + 'Expect "to_be_attached"', ) async def to_be_checked( self, timeout: float = None, checked: bool = None, + indeterminate: bool = None, ) -> None: __tracebackhide__ = True + expected_value = {} + if indeterminate is not None: + expected_value["indeterminate"] = indeterminate + if checked is not None: + expected_value["checked"] = checked + checked_string: str + if indeterminate: + checked_string = "indeterminate" + else: + checked_string = "unchecked" if checked is False else "checked" await self._expect_impl( - "to.be.checked" - if checked is None or checked is True - else "to.be.unchecked", - FrameExpectOptions(timeout=timeout), + "to.be.checked", + FrameExpectOptions(timeout=timeout, expectedValue=expected_value), None, - "Locator expected to be checked", + f"Locator expected to be {checked_string}", + 'Expect "to_be_checked"', ) async def not_to_be_attached( @@ -550,6 +653,7 @@ async def to_be_disabled( FrameExpectOptions(timeout=timeout), None, "Locator expected to be disabled", + 'Expect "to_be_disabled"', ) async def not_to_be_disabled( @@ -567,11 +671,13 @@ async def to_be_editable( __tracebackhide__ = True if editable is None: editable = True + editable_string = "editable" if editable else "readonly" await self._expect_impl( "to.be.editable" if editable else "to.be.readonly", FrameExpectOptions(timeout=timeout), None, - "Locator expected to be editable", + f"Locator expected to be {editable_string}", + 'Expect "to_be_editable"', ) async def not_to_be_editable( @@ -592,6 +698,7 @@ async def to_be_empty( FrameExpectOptions(timeout=timeout), None, "Locator expected to be empty", + 'Expect "to_be_empty"', ) async def not_to_be_empty( @@ -609,11 +716,13 @@ async def to_be_enabled( __tracebackhide__ = True if enabled is None: enabled = True + enabled_string = "enabled" if enabled else "disabled" await self._expect_impl( "to.be.enabled" if enabled else "to.be.disabled", FrameExpectOptions(timeout=timeout), None, - "Locator expected to be enabled", + f"Locator expected to be {enabled_string}", + 'Expect "to_be_enabled"', ) async def not_to_be_enabled( @@ -634,6 +743,7 @@ async def to_be_hidden( FrameExpectOptions(timeout=timeout), None, "Locator expected to be hidden", + 'Expect "to_be_hidden"', ) async def not_to_be_hidden( @@ -651,11 +761,13 @@ async def to_be_visible( __tracebackhide__ = True if visible is None: visible = True + visible_string = "visible" if visible else "hidden" await self._expect_impl( "to.be.visible" if visible else "to.be.hidden", FrameExpectOptions(timeout=timeout), None, - "Locator expected to be visible", + f"Locator expected to be {visible_string}", + 'Expect "to_be_visible"', ) async def not_to_be_visible( @@ -676,6 +788,7 @@ async def to_be_focused( FrameExpectOptions(timeout=timeout), None, "Locator expected to be focused", + 'Expect "to_be_focused"', ) async def not_to_be_focused( @@ -696,6 +809,7 @@ async def to_be_in_viewport( FrameExpectOptions(timeout=timeout, expectedNumber=ratio), None, "Locator expected to be in viewport", + 'Expect "to_be_in_viewport"', ) async def not_to_be_in_viewport( @@ -704,6 +818,124 @@ async def not_to_be_in_viewport( __tracebackhide__ = True await self._not.to_be_in_viewport(ratio=ratio, timeout=timeout) + async def to_have_accessible_description( + self, + description: Union[str, Pattern[str]], + ignoreCase: bool = None, + timeout: float = None, + ) -> None: + __tracebackhide__ = True + expected_values = to_expected_text_values( + [description], ignoreCase=ignoreCase, normalize_white_space=True + ) + await self._expect_impl( + "to.have.accessible.description", + FrameExpectOptions(expectedText=expected_values, timeout=timeout), + None, + "Locator expected to have accessible description", + 'Expect "to_have_accessible_description"', + ) + + async def not_to_have_accessible_description( + self, + name: Union[str, Pattern[str]], + ignoreCase: bool = None, + timeout: float = None, + ) -> None: + __tracebackhide__ = True + await self._not.to_have_accessible_description(name, ignoreCase, timeout) + + async def to_have_accessible_name( + self, + name: Union[str, Pattern[str]], + ignoreCase: bool = None, + timeout: float = None, + ) -> None: + __tracebackhide__ = True + expected_values = to_expected_text_values( + [name], ignoreCase=ignoreCase, normalize_white_space=True + ) + await self._expect_impl( + "to.have.accessible.name", + FrameExpectOptions(expectedText=expected_values, timeout=timeout), + None, + "Locator expected to have accessible name", + 'Expect "to_have_accessible_name"', + ) + + async def not_to_have_accessible_name( + self, + name: Union[str, Pattern[str]], + ignoreCase: bool = None, + timeout: float = None, + ) -> None: + __tracebackhide__ = True + await self._not.to_have_accessible_name(name, ignoreCase, timeout) + + async def to_have_role(self, role: AriaRole, timeout: float = None) -> None: + __tracebackhide__ = True + if isinstance(role, Pattern): + raise Error('"role" argument in to_have_role must be a string') + expected_values = to_expected_text_values([role]) + await self._expect_impl( + "to.have.role", + FrameExpectOptions(expectedText=expected_values, timeout=timeout), + None, + "Locator expected to have accessible role", + 'Expect "to_have_role"', + ) + + async def to_have_accessible_error_message( + self, + errorMessage: Union[str, Pattern[str]], + ignoreCase: bool = None, + timeout: float = None, + ) -> None: + __tracebackhide__ = True + expected_values = to_expected_text_values( + [errorMessage], ignoreCase=ignoreCase, normalize_white_space=True + ) + await self._expect_impl( + "to.have.accessible.error.message", + FrameExpectOptions(expectedText=expected_values, timeout=timeout), + None, + "Locator expected to have accessible error message", + 'Expect "to_have_accessible_error_message"', + ) + + async def not_to_have_accessible_error_message( + self, + errorMessage: Union[str, Pattern[str]], + ignoreCase: bool = None, + timeout: float = None, + ) -> None: + __tracebackhide__ = True + await self._not.to_have_accessible_error_message( + errorMessage=errorMessage, ignoreCase=ignoreCase, timeout=timeout + ) + + async def not_to_have_role(self, role: AriaRole, timeout: float = None) -> None: + __tracebackhide__ = True + await self._not.to_have_role(role, timeout) + + 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, + "Locator 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 APIResponseAssertions: def __init__( @@ -778,7 +1010,7 @@ def to_expected_text_values( ignoreCase: Optional[bool] = None, ) -> Sequence[ExpectedTextValue]: out: List[ExpectedTextValue] = [] - assert isinstance(items, list) + assert isinstance(items, (list, tuple)) for item in items: if isinstance(item, str): o = ExpectedTextValue( diff --git a/playwright/_impl/_async_base.py b/playwright/_impl/_async_base.py index e9544b733..db7e5d005 100644 --- a/playwright/_impl/_async_base.py +++ b/playwright/_impl/_async_base.py @@ -15,7 +15,7 @@ import asyncio from contextlib import AbstractAsyncContextManager from types import TracebackType -from typing import Any, Callable, Generic, Optional, Type, TypeVar +from typing import Any, Callable, Generic, Optional, Type, TypeVar, Union from playwright._impl._impl_to_api_mapping import ImplToApiMapping, ImplWrapper @@ -68,7 +68,9 @@ def __init__(self, impl_obj: Any) -> None: def __str__(self) -> str: return self._impl_obj.__str__() - def _wrap_handler(self, handler: Any) -> Callable[..., None]: + def _wrap_handler( + self, handler: Union[Callable[..., Any], Any] + ) -> Callable[..., None]: if callable(handler): return mapping.wrap_handler(handler) return handler @@ -94,11 +96,10 @@ async def __aenter__(self: Self) -> Self: async def __aexit__( self, - exc_type: Type[BaseException], - exc_val: BaseException, - traceback: TracebackType, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + traceback: Optional[TracebackType], ) -> None: await self.close() - async def close(self) -> None: - ... + async def close(self) -> None: ... diff --git a/playwright/_impl/_browser.py b/playwright/_impl/_browser.py index 8a248f703..6454f8c3f 100644 --- a/playwright/_impl/_browser.py +++ b/playwright/_impl/_browser.py @@ -12,12 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json from pathlib import Path from types import SimpleNamespace -from typing import TYPE_CHECKING, Dict, List, Optional, Pattern, Sequence, Union, cast +from typing import ( + TYPE_CHECKING, + Dict, + List, + Optional, + Pattern, + Sequence, + Set, + Union, + cast, +) from playwright._impl._api_structures import ( + BrowserBindResult, + ClientCertificate, Geolocation, HttpCredentials, ProxySettings, @@ -31,17 +42,15 @@ from playwright._impl._errors import is_target_closed_error from playwright._impl._helper import ( ColorScheme, + Contrast, ForcedColors, HarContentPolicy, HarMode, ReducedMotion, ServiceWorkersPolicy, - async_readfile, locals_to_params, make_dirs_for_file, - prepare_record_har_options, ) -from playwright._impl._network import serialize_headers from playwright._impl._page import Page if TYPE_CHECKING: # pragma: no cover @@ -57,28 +66,61 @@ def __init__( self, parent: "BrowserType", type: str, guid: str, initializer: Dict ) -> None: super().__init__(parent, type, guid, initializer) - self._browser_type = parent + self._browser_type: Optional["BrowserType"] = None self._is_connected = True self._should_close_connection_on_close = False self._cr_tracing_path: Optional[str] = None - self._contexts: List[BrowserContext] = [] + self._contexts: Set[BrowserContext] = set() + self._traces_dir: Optional[str] = None + self._channel.on( + "context", + lambda params: self._did_create_context( + cast(BrowserContext, from_channel(params["context"])) + ), + ) self._channel.on("close", lambda _: self._on_close()) self._close_reason: Optional[str] = None def __repr__(self) -> str: return f"" + def _connect_to_browser_type( + self, + browser_type: "BrowserType", + traces_dir: Optional[str] = None, + ) -> None: + # Note: when using connect(), `browserType` is different from `this.parent`. + # This is why browser type is not wired up in the constructor, and instead this separate method is called later on. + self._browser_type = browser_type + self._traces_dir = traces_dir + for context in self._contexts: + self._setup_browser_context(context) + + def _did_create_context(self, context: BrowserContext) -> None: + context._browser = self + self._contexts.add(context) + # Note: when connecting to a browser, initial contexts arrive before `_browserType` is set, + # and will be configured later in `ConnectToBrowserType`. + if self._browser_type: + self._setup_browser_context(context) + + def _setup_browser_context(self, context: BrowserContext) -> None: + context._tracing._traces_dir = self._traces_dir + assert self._browser_type is not None + self._browser_type._playwright.selectors._contexts_for_selectors.add(context) + def _on_close(self) -> None: self._is_connected = False self.emit(Browser.Events.Disconnected, self) @property def contexts(self) -> List[BrowserContext]: - return self._contexts.copy() + return list(self._contexts) @property def browser_type(self) -> "BrowserType": + assert self._browser_type is not None return self._browser_type def is_connected(self) -> bool: @@ -106,6 +148,7 @@ async def new_context( colorScheme: ColorScheme = None, reducedMotion: ReducedMotion = None, forcedColors: ForcedColors = None, + contrast: Contrast = None, acceptDownloads: bool = None, defaultBrowserType: str = None, proxy: ProxySettings = None, @@ -120,13 +163,21 @@ async def new_context( recordHarUrlFilter: Union[Pattern[str], str] = None, recordHarMode: HarMode = None, recordHarContent: HarContentPolicy = None, + clientCertificates: List[ClientCertificate] = None, ) -> BrowserContext: params = locals_to_params(locals()) - await prepare_browser_context_params(params) + assert self._browser_type is not None + await self._browser_type._prepare_browser_context_params(params) - channel = await self._channel.send("newContext", params) + channel = await self._channel.send("newContext", None, params) context = cast(BrowserContext, from_channel(channel)) - self._browser_type._did_create_context(context, params, {}) + await context._initialize_har_from_options( + record_har_content=recordHarContent, + record_har_mode=recordHarMode, + record_har_omit_content=recordHarOmitContent, + record_har_path=recordHarPath, + record_har_url_filter=recordHarUrlFilter, + ) return context async def new_page( @@ -150,6 +201,7 @@ async def new_page( hasTouch: bool = None, colorScheme: ColorScheme = None, forcedColors: ForcedColors = None, + contrast: Contrast = None, reducedMotion: ReducedMotion = None, acceptDownloads: bool = None, defaultBrowserType: str = None, @@ -165,6 +217,7 @@ async def new_page( recordHarUrlFilter: Union[Pattern[str], str] = None, recordHarMode: HarMode = None, recordHarContent: HarContentPolicy = None, + clientCertificates: List[ClientCertificate] = None, ) -> Page: params = locals_to_params(locals()) @@ -175,7 +228,7 @@ async def inner() -> Page: context._owner_page = page return page - return await self._connection.wrap_api_call(inner) + return await self._connection.wrap_api_call(inner, title="Create page") async def close(self, reason: str = None) -> None: self._close_reason = reason @@ -183,7 +236,7 @@ async def close(self, reason: str = None) -> None: if self._should_close_connection_on_close: await self._connection.stop_async() else: - await self._channel.send("close", {"reason": reason}) + await self._channel.send("close", None, {"reason": reason}) except Exception as e: if not is_target_closed_error(e): raise e @@ -193,7 +246,21 @@ def version(self) -> str: return self._initializer["version"] async def new_browser_cdp_session(self) -> CDPSession: - return from_channel(await self._channel.send("newBrowserCDPSession")) + 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, @@ -208,10 +275,12 @@ async def start_tracing( if path: self._cr_tracing_path = str(path) params["path"] = str(path) - await self._channel.send("startTracing", params) + await self._channel.send("startTracing", None, params) async def stop_tracing(self) -> bytes: - artifact = cast(Artifact, from_channel(await self._channel.send("stopTracing"))) + artifact = cast( + Artifact, from_channel(await self._channel.send("stopTracing", None)) + ) buffer = await artifact.read_info_buffer() await artifact.delete() if self._cr_tracing_path: @@ -220,36 +289,3 @@ async def stop_tracing(self) -> bytes: f.write(buffer) self._cr_tracing_path = None return buffer - - -async def prepare_browser_context_params(params: Dict) -> None: - if params.get("noViewport"): - del params["noViewport"] - params["noDefaultViewport"] = True - if "defaultBrowserType" in params: - del params["defaultBrowserType"] - if "extraHTTPHeaders" in params: - params["extraHTTPHeaders"] = serialize_headers(params["extraHTTPHeaders"]) - if "recordHarPath" in params: - params["recordHar"] = prepare_record_har_options(params) - del params["recordHarPath"] - if "recordVideoDir" in params: - params["recordVideo"] = {"dir": Path(params["recordVideoDir"]).absolute()} - if "recordVideoSize" in params: - params["recordVideo"]["size"] = params["recordVideoSize"] - del params["recordVideoSize"] - del params["recordVideoDir"] - if "storageState" in params: - storageState = params["storageState"] - if not isinstance(storageState, dict): - params["storageState"] = json.loads( - (await async_readfile(storageState)).decode() - ) - if params.get("colorScheme", None) == "null": - params["colorScheme"] = "no-override" - if params.get("reducedMotion", None) == "null": - params["reducedMotion"] = "no-override" - if params.get("forcedColors", None) == "null": - params["forcedColors"] = "no-override" - if "acceptDownloads" in params: - params["acceptDownloads"] = "accept" if params["acceptDownloads"] else "deny" diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index c540ce4c0..6839d7c7f 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -39,13 +39,16 @@ ) from playwright._impl._artifact import Artifact from playwright._impl._cdp_session import CDPSession +from playwright._impl._clock import Clock from playwright._impl._connection import ( ChannelOwner, from_channel, 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 @@ -60,15 +63,21 @@ RouteHandlerCallback, TimeoutSettings, URLMatch, - URLMatcher, + WebSocketRouteHandlerCallback, async_readfile, async_writefile, locals_to_params, parse_error, - prepare_record_har_options, to_impl, ) -from playwright._impl._network import Request, Response, Route, serialize_headers +from playwright._impl._network import ( + Request, + Response, + Route, + WebSocketRoute, + WebSocketRouteHandler, + serialize_headers, +) from playwright._impl._page import BindingCall, Page, Worker from playwright._impl._str_utils import escape_regex_flags from playwright._impl._tracing import Tracing @@ -81,6 +90,7 @@ class BrowserContext(ChannelOwner): Events = SimpleNamespace( + # Deprecated in v1.56, never emitted anymore. BackgroundPage="backgroundpage", Close="close", Console="console", @@ -98,22 +108,27 @@ def __init__( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict ) -> None: super().__init__(parent, type, guid, initializer) + # Browser is null for browser contexts created outside of normal browser, e.g. android or electron. # circular import workaround: self._browser: Optional["Browser"] = None if parent.__class__.__name__ == "Browser": self._browser = cast("Browser", parent) - self._browser._contexts.append(self) self._pages: List[Page] = [] self._routes: List[RouteHandler] = [] + self._web_socket_routes: List[WebSocketRouteHandler] = [] self._bindings: Dict[str, Any] = {} self._timeout_settings = TimeoutSettings(None) self._owner_page: Optional[Page] = None - self._options: Dict[str, Any] = {} - self._background_pages: Set[Page] = set() + self._options: Dict[str, Any] = initializer["options"] 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", lambda params: self._on_binding(from_channel(params["binding"])), @@ -130,10 +145,13 @@ def __init__( ) ), ) - self._channel.on( - "backgroundPage", - lambda params: self._on_background_page(from_channel(params["page"])), + "webSocketRoute", + lambda params: self._loop.create_task( + self._on_web_socket_route( + from_channel(params["webSocketRoute"]), + ) + ), ) self._channel.on( @@ -203,7 +221,7 @@ def __init__( BrowserContext.Events.RequestFailed: "requestFailed", } ) - self._close_was_called = False + self._closing_or_closed = False def __repr__(self) -> str: return f"" @@ -220,7 +238,7 @@ async def _on_route(self, route: Route) -> None: route_handlers = self._routes.copy() for route_handler in route_handlers: # If the page or the context was closed we stall all requests right away. - if (page and page._close_was_called) or self._close_was_called: + if (page and page._close_was_called) or self._closing_or_closed: return if not route_handler.matches(route.request.url): continue @@ -242,10 +260,24 @@ async def _on_route(self, route: Route) -> None: try: # If the page is closed or unrouteAll() was called without waiting and interception disabled, # the method will throw an error - silence it. - await route._internal_continue(is_internal=True) + await route._inner_continue(True) except Exception: pass + async def _on_web_socket_route(self, web_socket_route: WebSocketRoute) -> None: + route_handler = next( + ( + route_handler + for route_handler in self._web_socket_routes + if route_handler.matches(web_socket_route.url) + ), + None, + ) + if route_handler: + await route_handler.handle(web_socket_route) + else: + web_socket_route.connect_to_server() + def _on_binding(self, binding_call: BindingCall) -> None: func = self._bindings.get(binding_call._initializer["name"]) if func is None: @@ -257,19 +289,12 @@ def set_default_navigation_timeout(self, timeout: float) -> None: def _set_default_navigation_timeout_impl(self, timeout: Optional[float]) -> None: self._timeout_settings.set_default_navigation_timeout(timeout) - self._channel.send_no_reply( - "setDefaultNavigationTimeoutNoReply", - {} if timeout is None else {"timeout": timeout}, - ) def set_default_timeout(self, timeout: float) -> None: return self._set_default_timeout_impl(timeout) def _set_default_timeout_impl(self, timeout: Optional[float]) -> None: self._timeout_settings.set_default_timeout(timeout) - self._channel.send_no_reply( - "setDefaultTimeoutNoReply", {} if timeout is None else {"timeout": timeout} - ) @property def pages(self) -> List[Page]: @@ -279,29 +304,45 @@ def pages(self) -> List[Page]: def browser(self) -> Optional["Browser"]: return self._browser - def _set_options(self, context_options: Dict, browser_options: Dict) -> None: - self._options = context_options - if self._options.get("recordHar"): - self._har_recorders[""] = { - "path": self._options["recordHar"]["path"], - "content": self._options["recordHar"].get("content"), - } - self._tracing._traces_dir = browser_options.get("tracesDir") + async def _initialize_har_from_options( + self, + record_har_path: Optional[Union[Path, str]], + record_har_content: Optional[HarContentPolicy], + record_har_omit_content: Optional[bool], + record_har_url_filter: Optional[Union[Pattern[str], str]], + record_har_mode: Optional[HarMode], + ) -> None: + if not record_har_path: + return + record_har_path = str(record_har_path) + default_policy: HarContentPolicy = ( + "attach" if record_har_path.endswith(".zip") else "embed" + ) + content_policy: HarContentPolicy = record_har_content or ( + "omit" if record_har_omit_content is True else default_policy + ) + await self._record_into_har( + har=record_har_path, + page=None, + url=record_har_url_filter, + update_content=content_policy, + update_mode=(record_har_mode or "full"), + ) async def new_page(self) -> Page: if self._owner_page: raise Error("Please use browser.new_context()") - return from_channel(await self._channel.send("newPage")) + return from_channel(await self._channel.send("newPage", None)) async def cookies(self, urls: Union[str, Sequence[str]] = None) -> List[Cookie]: if urls is None: urls = [] if isinstance(urls, str): urls = [urls] - return await self._channel.send("cookies", dict(urls=urls)) + return await self._channel.send("cookies", None, dict(urls=urls)) async def add_cookies(self, cookies: Sequence[SetCookieParam]) -> None: - await self._channel.send("addCookies", dict(cookies=cookies)) + await self._channel.send("addCookies", None, dict(cookies=cookies)) async def clear_cookies( self, @@ -311,58 +352,61 @@ async def clear_cookies( ) -> None: await self._channel.send( "clearCookies", + None, { "name": name if isinstance(name, str) else None, "nameRegexSource": name.pattern if isinstance(name, Pattern) else None, - "nameRegexFlags": escape_regex_flags(name) - if isinstance(name, Pattern) - else None, + "nameRegexFlags": ( + escape_regex_flags(name) if isinstance(name, Pattern) else None + ), "domain": domain if isinstance(domain, str) else None, - "domainRegexSource": domain.pattern - if isinstance(domain, Pattern) - else None, - "domainRegexFlags": escape_regex_flags(domain) - if isinstance(domain, Pattern) - else None, + "domainRegexSource": ( + domain.pattern if isinstance(domain, Pattern) else None + ), + "domainRegexFlags": ( + escape_regex_flags(domain) if isinstance(domain, Pattern) else None + ), "path": path if isinstance(path, str) else None, "pathRegexSource": path.pattern if isinstance(path, Pattern) else None, - "pathRegexFlags": escape_regex_flags(path) - if isinstance(path, Pattern) - else None, + "pathRegexFlags": ( + escape_regex_flags(path) if isinstance(path, Pattern) else None + ), }, ) async def grant_permissions( self, permissions: Sequence[str], origin: str = None ) -> None: - await self._channel.send("grantPermissions", locals_to_params(locals())) + await self._channel.send("grantPermissions", None, locals_to_params(locals())) async def clear_permissions(self) -> None: - await self._channel.send("clearPermissions") + await self._channel.send("clearPermissions", None) async def set_geolocation(self, geolocation: Geolocation = None) -> None: - await self._channel.send("setGeolocation", locals_to_params(locals())) + await self._channel.send("setGeolocation", None, locals_to_params(locals())) async def set_extra_http_headers(self, headers: Dict[str, str]) -> None: await self._channel.send( - "setExtraHTTPHeaders", dict(headers=serialize_headers(headers)) + "setExtraHTTPHeaders", None, dict(headers=serialize_headers(headers)) ) async def set_offline(self, offline: bool) -> None: - await self._channel.send("setOffline", dict(offline=offline)) + await self._channel.send("setOffline", None, dict(offline=offline)) 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", 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: + ) -> Disposable: for page in self._pages: if name in page._bindings: raise Error( @@ -371,26 +415,30 @@ 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", dict(name=name, needsHandle=handle or False) + return from_channel( + await self._channel.send( + "exposeBinding", None, dict(name=name, needsHandle=handle or False) + ) ) - 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( - URLMatcher(self._options.get("baseURL"), url), + self._base_url, + url, handler, True if self._dispatcher_fiber else False, times, ), ) await self._update_interception_patterns() + return DisposableStub(lambda: self.unroute(url, handler), self) async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None @@ -398,7 +446,7 @@ async def unroute( removed = [] remaining = [] for route in self._routes: - if route.matcher.match != url or (handler and route.handler != handler): + if route.url != url or (handler and route.handler != handler): remaining.append(route) else: removed.append(route) @@ -411,10 +459,18 @@ async def _unroute_internal( behavior: Literal["default", "ignoreErrors", "wait"] = None, ) -> None: self._routes = remaining + if behavior is not None and behavior != "default": + await asyncio.gather(*map(lambda router: router.stop(behavior), removed)) # type: ignore await self._update_interception_patterns() - if behavior is None or behavior == "default": - return - await asyncio.gather(*map(lambda router: router.stop(behavior), removed)) # type: ignore + + async def route_web_socket( + self, url: URLMatch, handler: WebSocketRouteHandlerCallback + ) -> None: + self._web_socket_routes.insert( + 0, + WebSocketRouteHandler(self._base_url, url, handler), + ) + await self._update_web_socket_interception_patterns() def _dispose_har_routers(self) -> None: for router in self._har_routers: @@ -435,22 +491,25 @@ async def _record_into_har( update_content: HarContentPolicy = None, update_mode: HarMode = None, ) -> None: + update_content = update_content or "attach" params: Dict[str, Any] = { - "options": prepare_record_har_options( - { - "recordHarPath": har, - "recordHarContent": update_content or "attach", - "recordHarMode": update_mode or "minimal", - "recordHarUrlFilter": url, - } - ) + "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", params) + har_id = await self._channel.send("harStart", None, params) self._har_recorders[har_id] = { "path": str(har), - "content": update_content or "attach", + "content": update_content, } async def route_from_har( @@ -483,7 +542,15 @@ async def route_from_har( async def _update_interception_patterns(self) -> None: patterns = RouteHandler.prepare_interception_patterns(self._routes) await self._channel.send( - "setNetworkInterceptionPatterns", {"patterns": patterns} + "setNetworkInterceptionPatterns", None, {"patterns": patterns} + ) + + async def _update_web_socket_interception_patterns(self) -> None: + patterns = WebSocketRouteHandler.prepare_interception_patterns( + self._web_socket_routes + ) + await self._channel.send( + "setWebSocketInterceptionPatterns", None, {"patterns": patterns} ) def expect_event( @@ -506,24 +573,40 @@ def expect_event( return EventContextManagerImpl(waiter.result()) def _on_close(self) -> None: + self._closing_or_closed = True if self._browser: - self._browser._contexts.remove(self) + if self in self._browser._contexts: + self._browser._contexts.remove(self) + assert self._browser._browser_type is not None + if ( + self + in self._browser._browser_type._playwright.selectors._contexts_for_selectors + ): + self._browser._browser_type._playwright.selectors._contexts_for_selectors.remove( + self + ) self._dispose_har_routers() + 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._close_was_called: + if self._closing_or_closed: return self._close_reason = reason - self._close_was_called = True + self._closing_or_closed = True + + 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", {"harId": har_id}) + await self._channel.send("harExport", None, {"harId": har_id}) ), ) # Server side will compress artifact if content is attach or if file is .zip. @@ -542,15 +625,28 @@ async def _inner_close() -> None: await har.delete() await self._channel._connection.wrap_api_call(_inner_close, True) - await self._channel.send("close", {"reason": reason}) + await self._channel.send("close", None, {"reason": reason}) await self._closed_future - async def storage_state(self, path: Union[str, Path] = None) -> StorageState: - result = await self._channel.send_return_as_dict("storageState") + async def storage_state( + self, path: Union[str, Path] = None, indexedDB: bool = None + ) -> StorageState: + result = await self._channel.send_return_as_dict( + "storageState", None, {"indexedDB": indexedDB} + ) if path: 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 @@ -579,10 +675,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) @@ -617,10 +709,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) @@ -638,7 +733,10 @@ def _on_dialog(self, dialog: Dialog) -> None: asyncio.create_task(dialog.dismiss()) def _on_page_error(self, error: Error, page: Optional[Page]) -> None: - self.emit(BrowserContext.Events.WebError, WebError(self._loop, page, error)) + self.emit( + BrowserContext.Events.WebError, + WebError(self._loop, self._dispatcher_fiber, page, error), + ) if page: page.emit(Page.Events.PageError, error) @@ -654,7 +752,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]: @@ -669,12 +767,20 @@ async def new_cdp_session(self, page: Union[Page, Frame]) -> CDPSession: params["frame"] = page._channel else: raise Error("page: expected Page or Frame") - return from_channel(await self._channel.send("newCDPSession", params)) + return from_channel(await self._channel.send("newCDPSession", None, params)) @property def tracing(self) -> Tracing: return self._tracing + @property + def debugger(self) -> Debugger: + return self._debugger + @property def request(self) -> "APIRequestContext": return self._request + + @property + def clock(self) -> Clock: + return self._clock diff --git a/playwright/_impl/_browser_type.py b/playwright/_impl/_browser_type.py index 8393d69ee..88ea5fc52 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -13,37 +13,39 @@ # limitations under the License. import asyncio +import json import pathlib +import sys from pathlib import Path -from typing import TYPE_CHECKING, Dict, Optional, Pattern, Sequence, Union, cast +from typing import TYPE_CHECKING, Dict, List, Optional, Pattern, Sequence, Union, cast from playwright._impl._api_structures import ( + ClientCertificate, Geolocation, HttpCredentials, ProxySettings, ViewportSize, ) -from playwright._impl._browser import Browser, prepare_browser_context_params +from playwright._impl._browser import Browser from playwright._impl._browser_context import BrowserContext -from playwright._impl._connection import ( - ChannelOwner, - Connection, - from_channel, - from_nullable_channel, -) +from playwright._impl._connection import ChannelOwner, Connection, from_channel from playwright._impl._errors import Error from playwright._impl._helper import ( + PLAYWRIGHT_MAX_DEADLINE, ColorScheme, + Contrast, Env, ForcedColors, HarContentPolicy, HarMode, ReducedMotion, ServiceWorkersPolicy, + TimeoutSettings, + async_readfile, locals_to_params, ) from playwright._impl._json_pipe import JsonPipeTransport -from playwright._impl._network import serialize_headers +from playwright._impl._network import serialize_headers, to_client_certificates_protocol from playwright._impl._waiter import throw_on_timeout if TYPE_CHECKING: @@ -80,20 +82,27 @@ 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: params = locals_to_params(locals()) normalize_launch_params(params) browser = cast( - Browser, from_channel(await self._channel.send("launch", params)) + Browser, + from_channel( + await self._channel.send( + "launch", TimeoutSettings.launch_timeout, params + ) + ), + ) + browser._connect_to_browser_type( + self, str(tracesDir) if tracesDir is not None else None ) - self._did_launch_browser(browser) return browser async def launch_persistent_context( @@ -109,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, @@ -133,8 +141,10 @@ async def launch_persistent_context( colorScheme: ColorScheme = None, reducedMotion: ReducedMotion = None, forcedColors: ForcedColors = None, + 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, @@ -147,50 +157,70 @@ async def launch_persistent_context( recordHarUrlFilter: Union[Pattern[str], str] = None, recordHarMode: HarMode = None, recordHarContent: HarContentPolicy = None, + clientCertificates: List[ClientCertificate] = None, ) -> BrowserContext: - userDataDir = str(Path(userDataDir)) if userDataDir else "" + userDataDir = self._user_data_dir(userDataDir) params = locals_to_params(locals()) - await prepare_browser_context_params(params) + await self._prepare_browser_context_params(params) normalize_launch_params(params) - context = cast( - BrowserContext, - from_channel(await self._channel.send("launchPersistentContext", params)), + result = await self._channel.send_return_as_dict( + "launchPersistentContext", TimeoutSettings.launch_timeout, params + ) + browser = cast( + Browser, + from_channel(result["browser"]), + ) + browser._connect_to_browser_type( + self, str(tracesDir) if tracesDir is not None else None + ) + context = cast(BrowserContext, from_channel(result["context"])) + await context._initialize_har_from_options( + record_har_content=recordHarContent, + record_har_mode=recordHarMode, + record_har_omit_content=recordHarOmitContent, + record_har_path=recordHarPath, + record_har_url_filter=recordHarUrlFilter, ) - self._did_create_context(context, params, params) return context + def _user_data_dir(self, userDataDir: Optional[Union[str, Path]]) -> str: + if not userDataDir: + return "" + if not Path(userDataDir).is_absolute(): + # Can be dropped once we drop Python 3.9 support (10/2025): + # https://github.com/python/cpython/issues/82852 + if sys.platform == "win32" and sys.version_info[:2] < (3, 10): + return str(pathlib.Path.cwd() / userDataDir) + return str(Path(userDataDir).resolve()) + return str(Path(userDataDir)) + async def connect_over_cdp( self, endpointURL: str, timeout: float = None, slowMo: float = None, headers: Dict[str, str] = None, + isLocal: bool = None, ) -> Browser: params = locals_to_params(locals()) if params.get("headers"): params["headers"] = serialize_headers(params["headers"]) - response = await self._channel.send_return_as_dict("connectOverCDP", params) + response = await self._channel.send_return_as_dict( + "connectOverCDP", TimeoutSettings.launch_timeout, params + ) browser = cast(Browser, from_channel(response["browser"])) - self._did_launch_browser(browser) + browser._connect_to_browser_type(self, None) - default_context = cast( - Optional[BrowserContext], - from_nullable_channel(response.get("defaultContext")), - ) - if default_context: - self._did_create_context(default_context, {}, {}) return browser async def connect( self, - wsEndpoint: str, + endpoint: str, timeout: float = None, slowMo: float = None, headers: Dict[str, str] = None, exposeNetwork: str = None, ) -> Browser: - if timeout is None: - timeout = 30000 if slowMo is None: slowMo = 0 @@ -199,11 +229,12 @@ async def connect( pipe_channel = ( await local_utils._channel.send_return_as_dict( "connect", + None, { - "wsEndpoint": wsEndpoint, + "endpoint": endpoint, "headers": headers, "slowMo": slowMo, - "timeout": timeout, + "timeout": timeout if timeout is not None else 0, "exposeNetwork": exposeNetwork, }, ) @@ -218,11 +249,35 @@ async def connect( local_utils=self._connection.local_utils, ) connection.mark_as_remote() + + browser = None + + def handle_transport_close(reason: Optional[str]) -> None: + if browser: + for context in browser.contexts: + for page in context.pages: + page._on_close() + context._on_close() + browser._on_close() + connection.cleanup(reason) + # TODO: Backport https://github.com/microsoft/playwright/commit/d8d5289e8692c9b1265d23ee66988d1ac5122f33 + # Give a chance to any API call promises to reject upon page/context closure. + # This happens naturally when we receive page.onClose and browser.onClose from the server + # in separate tasks. However, upon pipe closure we used to dispatch them all synchronously + # here and promises did not have a chance to reject. + # The order of rejects vs closure is a part of the API contract and our test runner + # relies on it to attribute rejections to the right test. + + transport.once("close", handle_transport_close) + connection._is_sync = self._connection._is_sync connection._loop.create_task(connection.run()) playwright_future = connection.playwright_future - timeout_future = throw_on_timeout(timeout, Error("Connection timed out")) + timeout_future = throw_on_timeout( + timeout if timeout is not None else PLAYWRIGHT_MAX_DEADLINE, + Error("Connection timed out"), + ) done, pending = await asyncio.wait( {transport.on_error_future, playwright_future, timeout_future}, return_when=asyncio.FIRST_COMPLETED, @@ -237,28 +292,59 @@ async def connect( pre_launched_browser = playwright._initializer.get("preLaunchedBrowser") assert pre_launched_browser browser = cast(Browser, from_channel(pre_launched_browser)) - self._did_launch_browser(browser) browser._should_close_connection_on_close = True - - def handle_transport_close() -> None: - for context in browser.contexts: - for page in context.pages: - page._on_close() - context._on_close() - browser._on_close() - connection.cleanup() - - transport.once("close", handle_transport_close) + browser._connect_to_browser_type(self, None) return browser - def _did_create_context( - self, context: BrowserContext, context_options: Dict, browser_options: Dict - ) -> None: - context._set_options(context_options, browser_options) + async def _prepare_browser_context_params(self, params: Dict) -> None: + if params.get("noViewport"): + del params["noViewport"] + params["noDefaultViewport"] = True + if "defaultBrowserType" in params: + del params["defaultBrowserType"] + if "extraHTTPHeaders" in params: + params["extraHTTPHeaders"] = serialize_headers(params["extraHTTPHeaders"]) + if "recordVideoDir" in params: + params["recordVideo"] = {"dir": Path(params["recordVideoDir"]).absolute()} + if "recordVideoSize" in params: + params["recordVideo"]["size"] = params["recordVideoSize"] + del params["recordVideoSize"] + del params["recordVideoDir"] + if "storageState" in params: + storageState = params["storageState"] + if not isinstance(storageState, dict): + params["storageState"] = json.loads( + (await async_readfile(storageState)).decode() + ) + if params.get("colorScheme", None) == "null": + params["colorScheme"] = "no-override" + if params.get("reducedMotion", None) == "null": + params["reducedMotion"] = "no-override" + if params.get("forcedColors", None) == "null": + params["forcedColors"] = "no-override" + if params.get("contrast", None) == "null": + params["contrast"] = "no-override" + if "acceptDownloads" in params: + params["acceptDownloads"] = ( + "accept" if params["acceptDownloads"] else "deny" + ) + + if "clientCertificates" in params: + params["clientCertificates"] = await to_client_certificates_protocol( + params["clientCertificates"] + ) + params["selectorEngines"] = self._playwright.selectors._selector_engines + params["testIdAttributeName"] = ( + self._playwright.selectors._test_id_attribute_name + ) - def _did_launch_browser(self, browser: Browser) -> None: - browser._browser_type = self + # Remove HAR options + params.pop("recordHarPath", None) + params.pop("recordHarOmitContent", None) + params.pop("recordHarUrlFilter", None) + params.pop("recordHarMode", None) + params.pop("recordHarContent", None) def normalize_launch_params(params: Dict) -> None: @@ -271,9 +357,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 a6af32b90..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,17 +20,27 @@ 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["params"]) + 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", locals_to_params(locals())) + return await self._channel.send("send", None, locals_to_params(locals())) async def detach(self) -> None: - await self._channel.send("detach") + await self._channel.send( + "detach", + None, + ) diff --git a/playwright/_impl/_clock.py b/playwright/_impl/_clock.py new file mode 100644 index 000000000..f6eb7c42d --- /dev/null +++ b/playwright/_impl/_clock.py @@ -0,0 +1,104 @@ +# 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 datetime +from typing import TYPE_CHECKING, Dict, Union + +if TYPE_CHECKING: + from playwright._impl._browser_context import BrowserContext + + +class Clock: + def __init__(self, browser_context: "BrowserContext") -> None: + self._browser_context = browser_context + self._loop = browser_context._loop + self._dispatcher_fiber = browser_context._dispatcher_fiber + + async def install(self, time: Union[float, str, datetime.datetime] = None) -> None: + await self._browser_context._channel.send( + "clockInstall", + None, + parse_time(time) if time is not None else {}, + ) + + async def fast_forward( + self, + ticks: Union[int, str], + ) -> None: + await self._browser_context._channel.send( + "clockFastForward", + None, + parse_ticks(ticks), + ) + + async def pause_at( + self, + time: Union[float, str, datetime.datetime], + ) -> None: + await self._browser_context._channel.send( + "clockPauseAt", + None, + parse_time(time), + ) + + async def resume( + self, + ) -> None: + await self._browser_context._channel.send("clockResume", None) + + async def run_for( + self, + ticks: Union[int, str], + ) -> None: + await self._browser_context._channel.send( + "clockRunFor", + None, + parse_ticks(ticks), + ) + + async def set_fixed_time( + self, + time: Union[float, str, datetime.datetime], + ) -> None: + await self._browser_context._channel.send( + "clockSetFixedTime", + None, + parse_time(time), + ) + + async def set_system_time( + self, + time: Union[float, str, datetime.datetime], + ) -> None: + await self._browser_context._channel.send( + "clockSetSystemTime", + None, + parse_time(time), + ) + + +def parse_time( + time: Union[float, str, datetime.datetime], +) -> Dict[str, Union[int, str]]: + if isinstance(time, (float, int)): + return {"timeNumber": int(time * 1_000)} + if isinstance(time, str): + return {"timeString": time} + return {"timeNumber": int(time.timestamp() * 1_000)} + + +def parse_ticks(ticks: Union[int, str]) -> Dict[str, Union[int, str]]: + if isinstance(ticks, int): + return {"ticksNumber": ticks} + return {"ticksString": ticks} diff --git a/playwright/_impl/_connection.py b/playwright/_impl/_connection.py index 937ab3f8b..bbc42b6e1 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -37,6 +37,7 @@ from pyee.asyncio import AsyncIOEventEmitter import playwright +import playwright._impl._impl_to_api_mapping from playwright._impl._errors import TargetClosedError, rewrite_error from playwright._impl._greenlets import EventGreenlet from playwright._impl._helper import Error, ParsedMessagePayload, parse_error @@ -46,6 +47,8 @@ from playwright._impl._local_utils import LocalUtils from playwright._impl._playwright import Playwright +TimeoutCalculator = Optional[Callable[[Optional[float]], float]] + class Channel(AsyncIOEventEmitter): def __init__(self, connection: "Connection", object: "ChannelOwner") -> None: @@ -55,35 +58,67 @@ def __init__(self, connection: "Connection", object: "ChannelOwner") -> None: self._object = object self.on("error", lambda exc: self._connection._on_event_listener_error(exc)) - async def send(self, method: str, params: Dict = None) -> Any: + async def send( + self, + method: str, + timeout_calculator: TimeoutCalculator, + params: Dict = None, + is_internal: bool = False, + title: str = None, + ) -> Any: return await self._connection.wrap_api_call( - lambda: self.inner_send(method, params, False) + lambda: self._inner_send(method, timeout_calculator, params, False), + is_internal, + title, ) - async def send_return_as_dict(self, method: str, params: Dict = None) -> Any: + async def send_return_as_dict( + self, + method: str, + timeout_calculator: TimeoutCalculator, + params: Dict = None, + is_internal: bool = False, + title: str = None, + ) -> Any: return await self._connection.wrap_api_call( - lambda: self.inner_send(method, params, True) + lambda: self._inner_send(method, timeout_calculator, params, True), + is_internal, + title, ) - def send_no_reply(self, method: str, params: Dict = None) -> None: + def send_no_reply( + self, + method: str, + timeout_calculator: TimeoutCalculator, + params: Dict = None, + is_internal: bool = False, + title: str = None, + ) -> None: # No reply messages are used to e.g. waitForEventInfo(after). self._connection.wrap_api_call_sync( lambda: self._connection._send_message_to_server( - self._object, method, {} if params is None else params, True - ) + self._object, + method, + _augment_params(params, timeout_calculator), + True, + ), + is_internal, + title, ) - async def inner_send( - self, method: str, params: Optional[Dict], return_as_dict: bool + async def _inner_send( + self, + method: str, + timeout_calculator: TimeoutCalculator, + params: Optional[Dict], + return_as_dict: bool, ) -> Any: - if params is None: - params = {} if self._connection._error: error = self._connection._error self._connection._error = None raise error callback = self._connection._send_message_to_server( - self._object, method, _filter_none(params) + self._object, method, _augment_params(params, timeout_calculator) ) done, _ = await asyncio.wait( { @@ -164,7 +199,9 @@ def _update_subscription(self, event: str, enabled: bool) -> None: if protocol_event: self._connection.wrap_api_call_sync( lambda: self._channel.send_no_reply( - "updateSubscription", {"event": protocol_event, "enabled": enabled} + "updateSubscription", + None, + {"event": protocol_event, "enabled": enabled}, ), True, ) @@ -181,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() @@ -197,9 +236,9 @@ def cb(task: asyncio.Task) -> None: if current_task: current_task.add_done_callback(cb) self.future.add_done_callback( - lambda _: current_task.remove_done_callback(cb) - if current_task - else None + lambda _: ( + current_task.remove_done_callback(cb) if current_task else None + ) ) @@ -211,6 +250,7 @@ async def initialize(self) -> "Playwright": return from_channel( await self._channel.send( "initialize", + None, { "sdkLanguage": "python", }, @@ -243,9 +283,9 @@ def __init__( self._error: Optional[BaseException] = None self.is_remote = False self._init_task: Optional[asyncio.Task] = None - self._api_zone: contextvars.ContextVar[ - Optional[ParsedStackTrace] - ] = contextvars.ContextVar("ApiZone", default=None) + self._api_zone: contextvars.ContextVar[Optional[ParsedStackTrace]] = ( + contextvars.ContextVar("ApiZone", default=None) + ) self._local_utils: Optional["LocalUtils"] = local_utils self._tracing_count = 0 self._closed_error: Optional[Exception] = None @@ -284,15 +324,18 @@ async def stop_async(self) -> None: await self._transport.wait_until_stopped() self.cleanup() - def cleanup(self, cause: Exception = None) -> None: - self._closed_error = ( - TargetClosedError(str(cause)) if cause else TargetClosedError() - ) + def cleanup(self, cause: str = None) -> None: + self._closed_error = TargetClosedError(cause) if cause else TargetClosedError() if self._init_task and not self._init_task.done(): self._init_task.cancel() for ws_connection in self._child_ws_connections: ws_connection._transport.dispose() for callback in self._callbacks.values(): + # To prevent 'Future exception was never retrieved' we ignore all callbacks that are no_reply. + if callback.no_reply: + continue + if callback.future.cancelled(): + continue callback.future.set_exception(self._closed_error) self._callbacks.clear() self.emit("close") @@ -302,7 +345,7 @@ def call_on_object_with_known_name( ) -> None: self._waiting_for_object[guid] = callback - def set_in_tracing(self, is_tracing: bool) -> None: + def set_is_tracing(self, is_tracing: bool) -> None: if is_tracing: self._tracing_count += 1 else: @@ -319,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()), + 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 = ( @@ -345,6 +387,9 @@ def _send_message_to_server( } if location: metadata["location"] = location # type: ignore + title = stack_trace_information["title"] + if title: + metadata["title"] = title message = { "id": id, "guid": object._guid, @@ -355,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 @@ -377,9 +422,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None: parsed_error = parse_error( error["error"], format_call_log(msg.get("log")) # type: ignore ) - parsed_error._stack = "".join( - traceback.format_list(callback.stack_trace)[-10:] - ) + parsed_error._stack = "".join(callback.stack_trace.format()) callback.future.set_exception(parsed_error) else: result = self._replace_guids_with_channels(msg.get("result")) @@ -499,13 +542,16 @@ def _replace_guids_with_channels(self, payload: Any) -> Any: return payload async def wrap_api_call( - self, cb: Callable[[], Any], is_internal: bool = False + self, cb: Callable[[], Any], is_internal: bool = False, title: str = None ) -> Any: if self._api_zone.get(): return await cb() task = asyncio.current_task(self._loop) - st: List[inspect.FrameInfo] = getattr(task, "__pw_stack__", inspect.stack()) - parsed_st = _extract_stack_trace_information_from_stack(st, is_internal) + st: List[inspect.FrameInfo] = getattr( + task, "__pw_stack__", None + ) or inspect.stack(0) + + parsed_st = _extract_stack_trace_information_from_stack(st, is_internal, title) self._api_zone.set(parsed_st) try: return await cb() @@ -515,13 +561,15 @@ async def wrap_api_call( self._api_zone.set(None) def wrap_api_call_sync( - self, cb: Callable[[], Any], is_internal: bool = False + self, cb: Callable[[], Any], is_internal: bool = False, title: str = None ) -> Any: if self._api_zone.get(): return cb() task = asyncio.current_task(self._loop) - st: List[inspect.FrameInfo] = getattr(task, "__pw_stack__", inspect.stack()) - parsed_st = _extract_stack_trace_information_from_stack(st, is_internal) + st: List[inspect.FrameInfo] = getattr( + task, "__pw_stack__", None + ) or inspect.stack(0) + parsed_st = _extract_stack_trace_information_from_stack(st, is_internal, title) self._api_zone.set(parsed_st) try: return cb() @@ -549,16 +597,23 @@ class StackFrame(TypedDict): class ParsedStackTrace(TypedDict): frames: List[StackFrame] apiName: Optional[str] + title: Optional[str] def _extract_stack_trace_information_from_stack( - st: List[inspect.FrameInfo], is_internal: bool + st: List[inspect.FrameInfo], is_internal: bool, title: str = None ) -> ParsedStackTrace: playwright_module_path = str(Path(playwright.__file__).parents[0]) last_internal_api_name = "" api_name = "" parsed_frames: List[StackFrame] = [] for frame in st: + # Sync and Async implementations can have event handlers. When these are sync, they + # get evaluated in the context of the event loop, so they contain the stack trace of when + # the message was received. _impl_to_api_mapping is glue between the user-code and internal + # code to translate impl classes to api classes. We want to ignore these frames. + if playwright._impl._impl_to_api_mapping.__file__ == frame.filename: + continue is_playwright_internal = frame.filename.startswith(playwright_module_path) method_name = "" @@ -586,11 +641,28 @@ def _extract_stack_trace_information_from_stack( return { "frames": parsed_frames, "apiName": "" if is_internal else api_name, + "title": title, } +def _augment_params( + params: Optional[Dict], + timeout_calculator: Optional[Callable[[Optional[float]], float]], +) -> Dict: + if params is None: + params = {} + if timeout_calculator: + params["timeout"] = timeout_calculator(params.get("timeout")) + return _filter_none(params) + + def _filter_none(d: Mapping) -> Dict: - return {k: v for k, v in d.items() if v is not None} + result = {} + for k, v in d.items(): + if v is None: + continue + result[k] = _filter_none(v) if isinstance(v, dict) else v + return result def format_call_log(log: Optional[List[str]]) -> str: @@ -598,4 +670,4 @@ def format_call_log(log: Optional[List[str]]) -> str: return "" if len(list(filter(lambda x: x.strip(), log))) == 0: return "" - return "\nCall log:\n" + "\n - ".join(log) + "\n" + return "\nCall log:\n" + "\n".join(log) + "\n" diff --git a/playwright/_impl/_console_message.py b/playwright/_impl/_console_message.py index ba8fc0a38..d98901d34 100644 --- a/playwright/_impl/_console_message.py +++ b/playwright/_impl/_console_message.py @@ -13,7 +13,7 @@ # limitations under the License. from asyncio import AbstractEventLoop -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union from playwright._impl._api_structures import SourceLocation from playwright._impl._connection import from_channel, from_nullable_channel @@ -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"" @@ -39,7 +41,27 @@ def __str__(self) -> str: return self.text @property - def type(self) -> str: + def type(self) -> Union[ + Literal["assert"], + Literal["clear"], + Literal["count"], + Literal["debug"], + Literal["dir"], + Literal["dirxml"], + Literal["endGroup"], + Literal["error"], + Literal["info"], + Literal["log"], + Literal["profile"], + Literal["profileEnd"], + Literal["startGroup"], + Literal["startGroupCollapsed"], + Literal["table"], + Literal["time"], + Literal["timeEnd"], + Literal["trace"], + Literal["warning"], + ]: return self._event["type"] @property @@ -54,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 a0c6ca77f..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 @@ -48,7 +49,15 @@ def page(self) -> Optional["Page"]: return self._page async def accept(self, promptText: str = None) -> None: - await self._channel.send("accept", locals_to_params(locals())) + await self._channel.send("accept", None, locals_to_params(locals())) async def dismiss(self) -> None: - await self._channel.send("dismiss") + 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/_driver.py b/playwright/_impl/_driver.py index 9e8cdc1e7..22b53b8e7 100644 --- a/playwright/_impl/_driver.py +++ b/playwright/_impl/_driver.py @@ -26,7 +26,10 @@ def compute_driver_executable() -> Tuple[str, str]: driver_path = Path(inspect.getfile(playwright)).parent / "driver" cli_path = str(driver_path / "package" / "cli.js") if sys.platform == "win32": - return (str(driver_path / "node.exe"), cli_path) + return ( + os.getenv("PLAYWRIGHT_NODEJS_PATH", str(driver_path / "node.exe")), + cli_path, + ) return (os.getenv("PLAYWRIGHT_NODEJS_PATH", str(driver_path / "node")), cli_path) diff --git a/playwright/_impl/_element_handle.py b/playwright/_impl/_element_handle.py index 74e5bdff9..3854669d0 100644 --- a/playwright/_impl/_element_handle.py +++ b/playwright/_impl/_element_handle.py @@ -13,6 +13,7 @@ # limitations under the License. import base64 +import mimetypes from pathlib import Path from typing import ( TYPE_CHECKING, @@ -55,56 +56,63 @@ def __init__( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict ) -> None: super().__init__(parent, type, guid, initializer) + self._frame = cast("Frame", parent) async def _createSelectorForTest(self, name: str) -> Optional[str]: - return await self._channel.send("createSelectorForTest", dict(name=name)) + return await self._channel.send( + "createSelectorForTest", self._frame._timeout, dict(name=name) + ) def as_element(self) -> Optional["ElementHandle"]: return self async def owner_frame(self) -> Optional["Frame"]: - return from_nullable_channel(await self._channel.send("ownerFrame")) + return from_nullable_channel(await self._channel.send("ownerFrame", None)) async def content_frame(self) -> Optional["Frame"]: - return from_nullable_channel(await self._channel.send("contentFrame")) + return from_nullable_channel(await self._channel.send("contentFrame", None)) async def get_attribute(self, name: str) -> Optional[str]: - return await self._channel.send("getAttribute", dict(name=name)) + return await self._channel.send("getAttribute", None, dict(name=name)) async def text_content(self) -> Optional[str]: - return await self._channel.send("textContent") + return await self._channel.send("textContent", None) async def inner_text(self) -> str: - return await self._channel.send("innerText") + return await self._channel.send("innerText", None) async def inner_html(self) -> str: - return await self._channel.send("innerHTML") + return await self._channel.send("innerHTML", None) async def is_checked(self) -> bool: - return await self._channel.send("isChecked") + return await self._channel.send("isChecked", None) async def is_disabled(self) -> bool: - return await self._channel.send("isDisabled") + return await self._channel.send("isDisabled", None) async def is_editable(self) -> bool: - return await self._channel.send("isEditable") + return await self._channel.send("isEditable", None) async def is_enabled(self) -> bool: - return await self._channel.send("isEnabled") + return await self._channel.send("isEnabled", None) async def is_hidden(self) -> bool: - return await self._channel.send("isHidden") + return await self._channel.send("isHidden", None) async def is_visible(self) -> bool: - return await self._channel.send("isVisible") + return await self._channel.send("isVisible", None) async def dispatch_event(self, type: str, eventInit: Dict = None) -> None: await self._channel.send( - "dispatchEvent", dict(type=type, eventInit=serialize_argument(eventInit)) + "dispatchEvent", + None, + dict(type=type, eventInit=serialize_argument(eventInit)), ) async def scroll_into_view_if_needed(self, timeout: float = None) -> None: - await self._channel.send("scrollIntoViewIfNeeded", locals_to_params(locals())) + await self._channel.send( + "scrollIntoViewIfNeeded", self._frame._timeout, locals_to_params(locals()) + ) async def hover( self, @@ -115,7 +123,9 @@ async def hover( force: bool = None, trial: bool = None, ) -> None: - await self._channel.send("hover", locals_to_params(locals())) + await self._channel.send( + "hover", self._frame._timeout, locals_to_params(locals()) + ) async def click( self, @@ -128,8 +138,11 @@ async def click( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: - await self._channel.send("click", locals_to_params(locals())) + await self._channel.send( + "click", self._frame._timeout, locals_to_params(locals()) + ) async def dblclick( self, @@ -141,8 +154,11 @@ async def dblclick( force: bool = None, noWaitAfter: bool = None, trial: bool = None, + steps: int = None, ) -> None: - await self._channel.send("dblclick", locals_to_params(locals())) + await self._channel.send( + "dblclick", self._frame._timeout, locals_to_params(locals()) + ) async def select_option( self, @@ -157,12 +173,11 @@ async def select_option( params = locals_to_params( dict( timeout=timeout, - noWaitAfter=noWaitAfter, force=force, - **convert_select_option_values(value, index, label, element) + **convert_select_option_values(value, index, label, element), ) ) - return await self._channel.send("selectOption", params) + return await self._channel.send("selectOption", self._frame._timeout, params) async def tap( self, @@ -173,7 +188,9 @@ async def tap( noWaitAfter: bool = None, trial: bool = None, ) -> None: - await self._channel.send("tap", locals_to_params(locals())) + await self._channel.send( + "tap", self._frame._timeout, locals_to_params(locals()) + ) async def fill( self, @@ -182,13 +199,19 @@ async def fill( noWaitAfter: bool = None, force: bool = None, ) -> None: - await self._channel.send("fill", locals_to_params(locals())) + await self._channel.send( + "fill", self._frame._timeout, locals_to_params(locals()) + ) async def select_text(self, force: bool = None, timeout: float = None) -> None: - await self._channel.send("selectText", locals_to_params(locals())) + await self._channel.send( + "selectText", self._frame._timeout, locals_to_params(locals()) + ) async def input_value(self, timeout: float = None) -> str: - return await self._channel.send("inputValue", locals_to_params(locals())) + return await self._channel.send( + "inputValue", self._frame._timeout, locals_to_params(locals()) + ) async def set_input_files( self, @@ -204,15 +227,15 @@ async def set_input_files( converted = await convert_input_files(files, frame.page.context) await self._channel.send( "setInputFiles", + self._frame._timeout, { "timeout": timeout, - "noWaitAfter": noWaitAfter, **converted, }, ) async def focus(self) -> None: - await self._channel.send("focus") + await self._channel.send("focus", None) async def type( self, @@ -221,7 +244,9 @@ async def type( timeout: float = None, noWaitAfter: bool = None, ) -> None: - await self._channel.send("type", locals_to_params(locals())) + await self._channel.send( + "type", self._frame._timeout, locals_to_params(locals()) + ) async def press( self, @@ -230,7 +255,9 @@ async def press( timeout: float = None, noWaitAfter: bool = None, ) -> None: - await self._channel.send("press", locals_to_params(locals())) + await self._channel.send( + "press", self._frame._timeout, locals_to_params(locals()) + ) async def set_checked( self, @@ -246,7 +273,6 @@ async def set_checked( position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, trial=trial, ) else: @@ -254,7 +280,6 @@ async def set_checked( position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, trial=trial, ) @@ -266,7 +291,9 @@ async def check( noWaitAfter: bool = None, trial: bool = None, ) -> None: - await self._channel.send("check", locals_to_params(locals())) + await self._channel.send( + "check", self._frame._timeout, locals_to_params(locals()) + ) async def uncheck( self, @@ -276,10 +303,12 @@ async def uncheck( noWaitAfter: bool = None, trial: bool = None, ) -> None: - await self._channel.send("uncheck", locals_to_params(locals())) + await self._channel.send( + "uncheck", self._frame._timeout, locals_to_params(locals()) + ) async def bounding_box(self) -> Optional[FloatRect]: - return await self._channel.send("boundingBox") + return await self._channel.send("boundingBox", None) async def screenshot( self, @@ -297,6 +326,8 @@ async def screenshot( ) -> bytes: params = locals_to_params(locals()) if "path" in params: + if "type" not in params: + params["type"] = determine_screenshot_type(params["path"]) del params["path"] if "mask" in params: params["mask"] = list( @@ -310,7 +341,9 @@ async def screenshot( params["mask"], ) ) - encoded_binary = await self._channel.send("screenshot", params) + encoded_binary = await self._channel.send( + "screenshot", self._frame._timeout, params + ) decoded_binary = base64.b64decode(encoded_binary) if path: make_dirs_for_file(path) @@ -319,14 +352,16 @@ async def screenshot( async def query_selector(self, selector: str) -> Optional["ElementHandle"]: return from_nullable_channel( - await self._channel.send("querySelector", dict(selector=selector)) + await self._channel.send("querySelector", None, dict(selector=selector)) ) async def query_selector_all(self, selector: str) -> List["ElementHandle"]: return list( map( cast(Callable[[Any], Any], from_nullable_channel), - await self._channel.send("querySelectorAll", dict(selector=selector)), + await self._channel.send( + "querySelectorAll", None, dict(selector=selector) + ), ) ) @@ -339,6 +374,7 @@ async def eval_on_selector( return parse_result( await self._channel.send( "evalOnSelector", + None, dict( selector=selector, expression=expression, @@ -356,6 +392,7 @@ async def eval_on_selector_all( return parse_result( await self._channel.send( "evalOnSelectorAll", + None, dict( selector=selector, expression=expression, @@ -371,7 +408,9 @@ async def wait_for_element_state( ], timeout: float = None, ) -> None: - await self._channel.send("waitForElementState", locals_to_params(locals())) + await self._channel.send( + "waitForElementState", self._frame._timeout, locals_to_params(locals()) + ) async def wait_for_selector( self, @@ -381,7 +420,9 @@ async def wait_for_selector( strict: bool = None, ) -> Optional["ElementHandle"]: return from_nullable_channel( - await self._channel.send("waitForSelector", locals_to_params(locals())) + await self._channel.send( + "waitForSelector", self._frame._timeout, locals_to_params(locals()) + ) ) @@ -396,15 +437,15 @@ def convert_select_option_values( options: Any = None elements: Any = None - if value: + if value is not None: if isinstance(value, str): value = [value] options = (options or []) + list(map(lambda e: dict(valueOrLabel=e), value)) - if index: + if index is not None: if isinstance(index, int): index = [index] options = (options or []) + list(map(lambda e: dict(index=e), index)) - if label: + if label is not None: if isinstance(label, str): label = [label] options = (options or []) + list(map(lambda e: dict(label=e), label)) @@ -414,3 +455,12 @@ def convert_select_option_values( elements = list(map(lambda e: e._channel, element)) return dict(options=options, elements=elements) + + +def determine_screenshot_type(path: Union[str, Path]) -> Literal["jpeg", "png"]: + mime_type, _ = mimetypes.guess_type(path) + if mime_type == "image/png": + return "png" + if mime_type == "image/jpeg": + return "jpeg" + raise Error(f'Unsupported screenshot mime type for path "{path}": {mime_type}') diff --git a/playwright/_impl/_fetch.py b/playwright/_impl/_fetch.py index 53c457ba7..50bf4ad4a 100644 --- a/playwright/_impl/_fetch.py +++ b/playwright/_impl/_fetch.py @@ -21,6 +21,7 @@ import playwright._impl._network as network from playwright._impl._api_structures import ( + ClientCertificate, FilePayload, FormField, Headers, @@ -34,6 +35,8 @@ from playwright._impl._helper import ( Error, NameValue, + TargetClosedError, + TimeoutSettings, async_readfile, async_writefile, is_file_payload, @@ -41,7 +44,7 @@ object_to_array, to_impl, ) -from playwright._impl._network import serialize_headers +from playwright._impl._network import serialize_headers, to_client_certificates_protocol from playwright._impl._tracing import Tracing if typing.TYPE_CHECKING: @@ -51,7 +54,7 @@ FormType = Dict[str, Union[bool, float, str]] DataType = Union[Any, bytes, str] MultipartType = Dict[str, Union[bytes, bool, float, str, FilePayload]] -ParamsType = Dict[str, Union[bool, float, str]] +ParamsType = Union[Dict[str, Union[bool, float, str]], str] class APIRequest: @@ -70,6 +73,9 @@ async def new_context( userAgent: str = None, timeout: float = None, storageState: Union[StorageState, str, Path] = None, + clientCertificates: List[ClientCertificate] = None, + failOnStatusCode: bool = None, + maxRedirects: int = None, ) -> "APIRequestContext": params = locals_to_params(locals()) if "storageState" in params: @@ -80,10 +86,16 @@ async def new_context( ) if "extraHTTPHeaders" in params: params["extraHTTPHeaders"] = serialize_headers(params["extraHTTPHeaders"]) + params["clientCertificates"] = await to_client_certificates_protocol( + params.get("clientCertificates") + ) context = cast( APIRequestContext, - from_channel(await self.playwright._channel.send("newRequest", params)), + from_channel( + await self.playwright._channel.send("newRequest", None, params) + ), ) + context._timeout_settings.set_default_timeout(timeout) return context @@ -93,9 +105,18 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._tracing: Tracing = from_channel(initializer["tracing"]) + self._close_reason: Optional[str] = None + self._timeout_settings = TimeoutSettings(None) - async def dispose(self) -> None: - await self._channel.send("dispose") + async def dispose(self, reason: str = None) -> None: + self._close_reason = reason + try: + await self._channel.send("dispose", None, {"reason": reason}) + except Error as e: + if is_target_closed_error(e): + return + raise e + self._tracing._reset_stack_counter() async def delete( self, @@ -109,6 +130,7 @@ async def delete( failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, maxRedirects: int = None, + maxRetries: int = None, ) -> "APIResponse": return await self.fetch( url, @@ -122,6 +144,7 @@ async def delete( failOnStatusCode=failOnStatusCode, ignoreHTTPSErrors=ignoreHTTPSErrors, maxRedirects=maxRedirects, + maxRetries=maxRetries, ) async def head( @@ -136,6 +159,7 @@ async def head( failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, maxRedirects: int = None, + maxRetries: int = None, ) -> "APIResponse": return await self.fetch( url, @@ -149,6 +173,7 @@ async def head( failOnStatusCode=failOnStatusCode, ignoreHTTPSErrors=ignoreHTTPSErrors, maxRedirects=maxRedirects, + maxRetries=maxRetries, ) async def get( @@ -163,6 +188,7 @@ async def get( failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, maxRedirects: int = None, + maxRetries: int = None, ) -> "APIResponse": return await self.fetch( url, @@ -176,6 +202,7 @@ async def get( failOnStatusCode=failOnStatusCode, ignoreHTTPSErrors=ignoreHTTPSErrors, maxRedirects=maxRedirects, + maxRetries=maxRetries, ) async def patch( @@ -190,6 +217,7 @@ async def patch( failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, maxRedirects: int = None, + maxRetries: int = None, ) -> "APIResponse": return await self.fetch( url, @@ -203,6 +231,7 @@ async def patch( failOnStatusCode=failOnStatusCode, ignoreHTTPSErrors=ignoreHTTPSErrors, maxRedirects=maxRedirects, + maxRetries=maxRetries, ) async def put( @@ -217,6 +246,7 @@ async def put( failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, maxRedirects: int = None, + maxRetries: int = None, ) -> "APIResponse": return await self.fetch( url, @@ -230,6 +260,7 @@ async def put( failOnStatusCode=failOnStatusCode, ignoreHTTPSErrors=ignoreHTTPSErrors, maxRedirects=maxRedirects, + maxRetries=maxRetries, ) async def post( @@ -244,6 +275,7 @@ async def post( failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, maxRedirects: int = None, + maxRetries: int = None, ) -> "APIResponse": return await self.fetch( url, @@ -257,6 +289,7 @@ async def post( failOnStatusCode=failOnStatusCode, ignoreHTTPSErrors=ignoreHTTPSErrors, maxRedirects=maxRedirects, + maxRetries=maxRetries, ) async def fetch( @@ -272,6 +305,7 @@ async def fetch( failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, maxRedirects: int = None, + maxRetries: int = None, ) -> "APIResponse": url = urlOrRequest if isinstance(urlOrRequest, str) else None request = ( @@ -295,6 +329,7 @@ async def fetch( failOnStatusCode, ignoreHTTPSErrors, maxRedirects, + maxRetries, ) async def _inner_fetch( @@ -311,13 +346,19 @@ async def _inner_fetch( failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, maxRedirects: int = None, + maxRetries: int = None, ) -> "APIResponse": + if self._close_reason: + raise TargetClosedError(self._close_reason) assert ( (1 if data else 0) + (1 if form else 0) + (1 if multipart else 0) ) <= 1, "Only one of 'data', 'form' or 'multipart' can be specified" assert ( maxRedirects is None or maxRedirects >= 0 ), "'max_redirects' must be greater than or equal to '0'" + assert ( + maxRetries is None or maxRetries >= 0 + ), "'max_retries' must be greater than or equal to '0'" url = url or (request.url if request else url) method = method or (request.method if request else "GET") # Cannot call allHeaders() here as the request may be paused inside route handler. @@ -327,7 +368,7 @@ async def _inner_fetch( form_data: Optional[List[NameValue]] = None multipart_data: Optional[List[FormField]] = None post_data_buffer: Optional[bytes] = None - if data: + if data is not None: if isinstance(data, str): if is_json_content_type(serialized_headers): json_data = data if is_json_parsable(data) else json.dumps(data) @@ -368,27 +409,34 @@ async def _inner_fetch( response = await self._channel.send( "fetch", + self._timeout_settings.timeout, { "url": url, - "params": object_to_array(params), + "timeout": timeout, + "params": object_to_array(params) if isinstance(params, dict) else None, + "encodedParams": params if isinstance(params, str) else None, "method": method, "headers": serialized_headers, "postData": post_data, "jsonData": json_data, "formData": form_data, "multipartData": multipart_data, - "timeout": timeout, "failOnStatusCode": failOnStatusCode, "ignoreHTTPSErrors": ignoreHTTPSErrors, "maxRedirects": maxRedirects, + "maxRetries": maxRetries, }, ) return APIResponse(self, response) async def storage_state( - self, path: Union[pathlib.Path, str] = None + self, + path: Union[pathlib.Path, str] = None, + indexedDB: bool = None, ) -> StorageState: - result = await self._channel.send_return_as_dict("storageState") + result = await self._channel.send_return_as_dict( + "storageState", None, {"indexedDB": indexedDB} + ) if path: await async_writefile(path, json.dumps(result)) return result @@ -439,11 +487,15 @@ def headers_array(self) -> network.HeadersArray: async def body(self) -> bytes: try: - result = await self._request._channel.send_return_as_dict( - "fetchResponseBody", - { - "fetchUid": self._fetch_uid, - }, + result = await self._request._connection.wrap_api_call( + lambda: self._request._channel.send_return_as_dict( + "fetchResponseBody", + None, + { + "fetchUid": self._fetch_uid, + }, + ), + True, ) if result is None: raise Error("Response has been disposed") @@ -464,6 +516,7 @@ async def json(self) -> Any: async def dispose(self) -> None: await self._request._channel.send( "disposeAPIResponse", + None, { "fetchUid": self._fetch_uid, }, @@ -476,6 +529,7 @@ def _fetch_uid(self) -> str: async def _fetch_log(self) -> List[str]: return await self._request._channel.send( "fetchLog", + None, { "fetchUid": self._fetch_uid, }, diff --git a/playwright/_impl/_frame.py b/playwright/_impl/_frame.py index bfeef1489..b976667e7 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -19,6 +19,7 @@ Any, Dict, List, + Literal, Optional, Pattern, Sequence, @@ -29,7 +30,13 @@ from pyee import EventEmitter -from playwright._impl._api_structures import AriaRole, FilePayload, Position +from playwright._impl._api_structures import ( + AriaRole, + FilePayload, + FrameExpectOptions, + FrameExpectResult, + Position, +) from playwright._impl._connection import ( ChannelOwner, from_channel, @@ -42,19 +49,20 @@ DocumentLoadState, FrameNavigatedEvent, KeyboardModifier, - Literal, MouseButton, + TimeoutSettings, URLMatch, - URLMatcher, async_readfile, locals_to_params, monotonic_time, + url_matches, ) from playwright._impl._js_handle import ( JSHandle, Serializable, add_source_url_to_script, parse_result, + parse_value, serialize_argument, ) from playwright._impl._locator import ( @@ -125,7 +133,7 @@ def _on_frame_navigated(self, event: FrameNavigatedEvent) -> None: self._page.emit("framenavigated", self) async def _query_count(self, selector: str) -> int: - return await self._channel.send("queryCount", {"selector": selector}) + return await self._channel.send("queryCount", None, {"selector": selector}) @property def page(self) -> "Page": @@ -142,7 +150,9 @@ async def goto( return cast( Optional[Response], from_nullable_channel( - await self._channel.send("goto", locals_to_params(locals())) + await self._channel.send( + "goto", self._navigation_timeout, locals_to_params(locals()) + ) ), ) @@ -163,11 +173,33 @@ def _setup_navigation_waiter(self, wait_name: str, timeout: float = None) -> Wai Error("Navigating frame was detached!"), lambda frame: frame == self, ) - if timeout is None: - timeout = self._page._timeout_settings.navigation_timeout() + timeout = self._page._timeout_settings.navigation_timeout(timeout) waiter.reject_on_timeout(timeout, f"Timeout {timeout}ms exceeded.") return waiter + async def _expect( + self, + selector: Optional[str], + expression: str, + options: FrameExpectOptions, + title: str = None, + ) -> FrameExpectResult: + if "expectedValue" in options: + options["expectedValue"] = serialize_argument(options["expectedValue"]) + result = await self._channel.send_return_as_dict( + "expect", + self._timeout, + { + "selector": selector, + "expression": expression, + **options, + }, + title=title, + ) + if result.get("received"): + result["received"] = parse_value(result["received"]) + return result + def expect_navigation( self, url: URLMatch = None, @@ -185,18 +217,17 @@ def expect_navigation( to_url = f' to "{url}"' if url else "" waiter.log(f"waiting for navigation{to_url} until '{waitUntil}'") - matcher = ( - URLMatcher(self._page._browser_context._options.get("baseURL"), url) - if url - else None - ) def predicate(event: Any) -> bool: # Any failed navigation results in a rejection. if event.get("error"): return True waiter.log(f' navigated to "{event["url"]}"') - return not matcher or matcher.matches(event["url"]) + return url_matches( + cast("Page", self._page)._browser_context._base_url, + event["url"], + url, + ) waiter.wait_for_event( self._event_emitter, @@ -226,8 +257,7 @@ async def wait_for_url( timeout: float = None, ) -> None: assert self._page - matcher = URLMatcher(self._page._browser_context._options.get("baseURL"), url) - if matcher.matches(self.url): + if url_matches(self._page._browser_context._base_url, self.url, url): await self._wait_for_load_state_impl(state=waitUntil, timeout=timeout) return async with self.expect_navigation( @@ -270,13 +300,26 @@ def handle_load_state_event(actual_state: str) -> bool: ) await waiter.result() + def _timeout(self, timeout: Optional[float]) -> float: + timeout_settings = ( + self._page._timeout_settings if self._page else TimeoutSettings(None) + ) + return timeout_settings.timeout(timeout) + + def _navigation_timeout(self, timeout: Optional[float]) -> float: + timeout_settings = ( + self._page._timeout_settings if self._page else TimeoutSettings(None) + ) + return timeout_settings.navigation_timeout(timeout) + async def frame_element(self) -> ElementHandle: - return from_channel(await self._channel.send("frameElement")) + return from_channel(await self._channel.send("frameElement", None)) async def evaluate(self, expression: str, arg: Serializable = None) -> Any: return parse_result( await self._channel.send( "evaluateExpression", + None, dict( expression=expression, arg=serialize_argument(arg), @@ -290,6 +333,7 @@ async def evaluate_handle( return from_channel( await self._channel.send( "evaluateExpressionHandle", + None, dict( expression=expression, arg=serialize_argument(arg), @@ -301,14 +345,16 @@ async def query_selector( self, selector: str, strict: bool = None ) -> Optional[ElementHandle]: return from_nullable_channel( - await self._channel.send("querySelector", locals_to_params(locals())) + await self._channel.send("querySelector", None, locals_to_params(locals())) ) async def query_selector_all(self, selector: str) -> List[ElementHandle]: return list( map( from_channel, - await self._channel.send("querySelectorAll", dict(selector=selector)), + await self._channel.send( + "querySelectorAll", None, dict(selector=selector) + ), ) ) @@ -320,38 +366,48 @@ async def wait_for_selector( state: Literal["attached", "detached", "hidden", "visible"] = None, ) -> Optional[ElementHandle]: return from_nullable_channel( - await self._channel.send("waitForSelector", locals_to_params(locals())) + await self._channel.send( + "waitForSelector", self._timeout, locals_to_params(locals()) + ) ) async def is_checked( self, selector: str, strict: bool = None, timeout: float = None ) -> bool: - return await self._channel.send("isChecked", locals_to_params(locals())) + return await self._channel.send( + "isChecked", self._timeout, locals_to_params(locals()) + ) async def is_disabled( self, selector: str, strict: bool = None, timeout: float = None ) -> bool: - return await self._channel.send("isDisabled", locals_to_params(locals())) + return await self._channel.send( + "isDisabled", self._timeout, locals_to_params(locals()) + ) async def is_editable( self, selector: str, strict: bool = None, timeout: float = None ) -> bool: - return await self._channel.send("isEditable", locals_to_params(locals())) + return await self._channel.send( + "isEditable", self._timeout, locals_to_params(locals()) + ) async def is_enabled( self, selector: str, strict: bool = None, timeout: float = None ) -> bool: - return await self._channel.send("isEnabled", locals_to_params(locals())) + return await self._channel.send( + "isEnabled", self._timeout, locals_to_params(locals()) + ) - async def is_hidden( - self, selector: str, strict: bool = None, timeout: float = None - ) -> bool: - return await self._channel.send("isHidden", locals_to_params(locals())) + async def is_hidden(self, selector: str, strict: bool = None) -> bool: + return await self._channel.send( + "isHidden", self._timeout, locals_to_params(locals()) + ) - async def is_visible( - self, selector: str, strict: bool = None, timeout: float = None - ) -> bool: - return await self._channel.send("isVisible", locals_to_params(locals())) + async def is_visible(self, selector: str, strict: bool = None) -> bool: + return await self._channel.send( + "isVisible", self._timeout, locals_to_params(locals()) + ) async def dispatch_event( self, @@ -363,6 +419,7 @@ async def dispatch_event( ) -> None: await self._channel.send( "dispatchEvent", + self._timeout, locals_to_params( dict( selector=selector, @@ -384,6 +441,7 @@ async def eval_on_selector( return parse_result( await self._channel.send( "evalOnSelector", + None, locals_to_params( dict( selector=selector, @@ -404,6 +462,7 @@ async def eval_on_selector_all( return parse_result( await self._channel.send( "evalOnSelectorAll", + None, dict( selector=selector, expression=expression, @@ -413,7 +472,7 @@ async def eval_on_selector_all( ) async def content(self) -> str: - return await self._channel.send("content") + return await self._channel.send("content", None) async def set_content( self, @@ -421,7 +480,9 @@ async def set_content( timeout: float = None, waitUntil: DocumentLoadState = None, ) -> None: - await self._channel.send("setContent", locals_to_params(locals())) + await self._channel.send( + "setContent", self._navigation_timeout, locals_to_params(locals()) + ) @property def name(self) -> str: @@ -455,7 +516,7 @@ async def add_script_tag( (await async_readfile(path)).decode(), path ) del params["path"] - return from_channel(await self._channel.send("addScriptTag", params)) + return from_channel(await self._channel.send("addScriptTag", None, params)) async def add_style_tag( self, url: str = None, path: Union[str, Path] = None, content: str = None @@ -469,7 +530,7 @@ async def add_style_tag( + "*/" ) del params["path"] - return from_channel(await self._channel.send("addStyleTag", params)) + return from_channel(await self._channel.send("addStyleTag", None, params)) async def click( self, @@ -485,7 +546,24 @@ async def click( strict: bool = None, trial: bool = None, ) -> None: - await self._channel.send("click", locals_to_params(locals())) + 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())) async def dblclick( self, @@ -500,7 +578,9 @@ async def dblclick( strict: bool = None, trial: bool = None, ) -> None: - await self._channel.send("dblclick", locals_to_params(locals())) + await self._channel.send( + "dblclick", self._timeout, locals_to_params(locals()), title="Double click" + ) async def tap( self, @@ -513,7 +593,7 @@ async def tap( strict: bool = None, trial: bool = None, ) -> None: - await self._channel.send("tap", locals_to_params(locals())) + await self._channel.send("tap", self._timeout, locals_to_params(locals())) async def fill( self, @@ -524,7 +604,19 @@ async def fill( strict: bool = None, force: bool = None, ) -> None: - await self._channel.send("fill", locals_to_params(locals())) + await self._fill(**locals_to_params(locals())) + + async def _fill( + self, + selector: str, + value: str, + timeout: float = None, + noWaitAfter: bool = None, + strict: bool = None, + force: bool = None, + title: str = None, + ) -> None: + await self._channel.send("fill", self._timeout, locals_to_params(locals())) def locator( self, @@ -605,27 +697,35 @@ def frame_locator(self, selector: str) -> FrameLocator: async def focus( self, selector: str, strict: bool = None, timeout: float = None ) -> None: - await self._channel.send("focus", locals_to_params(locals())) + await self._channel.send("focus", self._timeout, locals_to_params(locals())) async def text_content( self, selector: str, strict: bool = None, timeout: float = None ) -> Optional[str]: - return await self._channel.send("textContent", locals_to_params(locals())) + return await self._channel.send( + "textContent", self._timeout, locals_to_params(locals()) + ) async def inner_text( self, selector: str, strict: bool = None, timeout: float = None ) -> str: - return await self._channel.send("innerText", locals_to_params(locals())) + return await self._channel.send( + "innerText", self._timeout, locals_to_params(locals()) + ) async def inner_html( self, selector: str, strict: bool = None, timeout: float = None ) -> str: - return await self._channel.send("innerHTML", locals_to_params(locals())) + return await self._channel.send( + "innerHTML", self._timeout, locals_to_params(locals()) + ) async def get_attribute( self, selector: str, name: str, strict: bool = None, timeout: float = None ) -> Optional[str]: - return await self._channel.send("getAttribute", locals_to_params(locals())) + return await self._channel.send( + "getAttribute", self._timeout, locals_to_params(locals()) + ) async def hover( self, @@ -638,7 +738,7 @@ async def hover( strict: bool = None, trial: bool = None, ) -> None: - await self._channel.send("hover", locals_to_params(locals())) + await self._channel.send("hover", self._timeout, locals_to_params(locals())) async def drag_and_drop( self, @@ -651,8 +751,11 @@ async def drag_and_drop( strict: bool = None, timeout: float = None, trial: bool = None, + steps: int = None, ) -> None: - await self._channel.send("dragAndDrop", locals_to_params(locals())) + await self._channel.send( + "dragAndDrop", self._timeout, locals_to_params(locals()) + ) async def select_option( self, @@ -670,13 +773,12 @@ async def select_option( dict( selector=selector, timeout=timeout, - noWaitAfter=noWaitAfter, strict=strict, force=force, **convert_select_option_values(value, index, label, element), ) ) - return await self._channel.send("selectOption", params) + return await self._channel.send("selectOption", self._timeout, params) async def input_value( self, @@ -684,7 +786,9 @@ async def input_value( strict: bool = None, timeout: float = None, ) -> str: - return await self._channel.send("inputValue", locals_to_params(locals())) + return await self._channel.send( + "inputValue", self._timeout, locals_to_params(locals()) + ) async def set_input_files( self, @@ -699,11 +803,11 @@ async def set_input_files( converted = await convert_input_files(files, self.page.context) await self._channel.send( "setInputFiles", + self._timeout, { "selector": selector, "strict": strict, - "timeout": timeout, - "noWaitAfter": noWaitAfter, + "timeout": self._timeout(timeout), **converted, }, ) @@ -717,7 +821,7 @@ async def type( timeout: float = None, noWaitAfter: bool = None, ) -> None: - await self._channel.send("type", locals_to_params(locals())) + await self._channel.send("type", self._timeout, locals_to_params(locals())) async def press( self, @@ -728,7 +832,7 @@ async def press( timeout: float = None, noWaitAfter: bool = None, ) -> None: - await self._channel.send("press", locals_to_params(locals())) + await self._channel.send("press", self._timeout, locals_to_params(locals())) async def check( self, @@ -740,7 +844,7 @@ async def check( strict: bool = None, trial: bool = None, ) -> None: - await self._channel.send("check", locals_to_params(locals())) + await self._channel.send("check", self._timeout, locals_to_params(locals())) async def uncheck( self, @@ -752,10 +856,10 @@ async def uncheck( strict: bool = None, trial: bool = None, ) -> None: - await self._channel.send("uncheck", locals_to_params(locals())) + await self._channel.send("uncheck", self._timeout, locals_to_params(locals())) async def wait_for_timeout(self, timeout: float) -> None: - await self._channel.send("waitForTimeout", locals_to_params(locals())) + await self._channel.send("waitForTimeout", None, {"waitTimeout": timeout}) async def wait_for_function( self, @@ -770,10 +874,12 @@ async def wait_for_function( params["arg"] = serialize_argument(arg) if polling is not None and polling != "raf": params["pollingInterval"] = polling - return from_channel(await self._channel.send("waitForFunction", params)) + return from_channel( + await self._channel.send("waitForFunction", self._timeout, params) + ) async def title(self) -> str: - return await self._channel.send("title") + return await self._channel.send("title", None) async def set_checked( self, @@ -792,7 +898,6 @@ async def set_checked( position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, strict=strict, trial=trial, ) @@ -802,10 +907,9 @@ async def set_checked( position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, strict=strict, trial=trial, ) async def _highlight(self, selector: str) -> None: - await self._channel.send("highlight", {"selector": selector}) + await self._channel.send("highlight", None, {"selector": selector}) diff --git a/playwright/_impl/_glob.py b/playwright/_impl/_glob.py index 2d899a789..b38826996 100644 --- a/playwright/_impl/_glob.py +++ b/playwright/_impl/_glob.py @@ -11,13 +11,12 @@ # 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 re # https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping escaped_chars = {"$", "^", "+", ".", "*", "(", ")", "|", "\\", "?", "{", "}", "[", "]"} -def glob_to_regex(glob: str) -> "re.Pattern[str]": +def glob_to_regex_pattern(glob: str) -> str: tokens = ["^"] in_group = False @@ -29,40 +28,38 @@ def glob_to_regex(glob: str) -> "re.Pattern[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: - if c == "?": - tokens.append(".") - elif c == "[": - tokens.append("[") - elif c == "]": - tokens.append("]") - elif c == "{": + if c == "{": in_group = True tokens.append("(") elif c == "}": in_group = False tokens.append(")") - elif c == "," and in_group: - tokens.append("|") + elif c == ",": + if in_group: + tokens.append("|") + else: + tokens.append("\\" + c) else: tokens.append("\\" + c if c in escaped_chars else c) i += 1 tokens.append("$") - return re.compile("".join(tokens)) + return "".join(tokens) diff --git a/playwright/_impl/_har_router.py b/playwright/_impl/_har_router.py index 33ff37871..1fa1b0433 100644 --- a/playwright/_impl/_har_router.py +++ b/playwright/_impl/_har_router.py @@ -49,7 +49,7 @@ async def create( not_found_action: RouteFromHarNotFoundPolicy, url_matcher: Optional[URLMatch] = None, ) -> "HarRouter": - har_id = await local_utils._channel.send("harOpen", {"file": file}) + har_id = await local_utils._channel.send("harOpen", None, {"file": file}) return HarRouter( local_utils=local_utils, har_id=har_id, @@ -118,5 +118,5 @@ async def add_page_route(self, page: "Page") -> None: def dispose(self) -> None: asyncio.create_task( - self._local_utils._channel.send("harClose", {"harId": self._har_id}) + self._local_utils._channel.send("harClose", None, {"harId": self._har_id}) ) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 0e6b91cd2..dc0a2479d 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -22,6 +22,7 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Dict, List, @@ -29,35 +30,48 @@ Optional, Pattern, Set, + Tuple, TypedDict, TypeVar, Union, cast, ) -from urllib.parse import urljoin +from urllib.parse import ParseResult, urljoin, urlparse, urlunparse from playwright._impl._api_structures import NameValue -from playwright._impl._errors import Error, TargetClosedError, TimeoutError -from playwright._impl._glob import glob_to_regex +from playwright._impl._errors import ( + Error, + TargetClosedError, + TimeoutError, + is_target_closed_error, + rewrite_error, +) +from playwright._impl._glob import glob_to_regex_pattern from playwright._impl._greenlets import RouteGreenlet from playwright._impl._str_utils import escape_regex_flags if TYPE_CHECKING: # pragma: no cover from playwright._impl._api_structures import HeadersArray - from playwright._impl._network import Request, Response, Route + 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] ] +WebSocketRouteHandlerCallback = Callable[["WebSocketRoute"], Any] ColorScheme = Literal["dark", "light", "no-preference", "null"] ForcedColors = Literal["active", "none", "null"] +Contrast = Literal["more", "no-preference", "null"] ReducedMotion = Literal["no-preference", "null", "reduce"] DocumentLoadState = Literal["commit", "domcontentloaded", "load", "networkidle"] -KeyboardModifier = Literal["Alt", "Control", "Meta", "Shift"] +KeyboardModifier = Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"] MouseButton = Literal["left", "middle", "right"] ServiceWorkersPolicy = Literal["allow", "block"] HarMode = Literal["full", "minimal"] @@ -135,27 +149,131 @@ class FrameNavigatedEvent(TypedDict): Env = Dict[str, Union[str, float, bool]] -class URLMatcher: - def __init__(self, base_url: Union[str, None], match: URLMatch) -> None: - self._callback: Optional[Callable[[str], bool]] = None - self._regex_obj: Optional[Pattern[str]] = None - if isinstance(match, str): - if base_url and not match.startswith("*"): - match = urljoin(base_url, match) - regex = glob_to_regex(match) - self._regex_obj = re.compile(regex) - elif isinstance(match, Pattern): - self._regex_obj = match +def url_matches( + base_url: Optional[str], + url_string: str, + match: Optional[URLMatch], + websocket_url: bool = None, +) -> bool: + if not match: + return True + if isinstance(match, str): + match = re.compile( + resolve_glob_to_regex_pattern(base_url, match, websocket_url) + ) + if isinstance(match, Pattern): + return bool(match.search(url_string)) + return match(url_string) + + +def resolve_glob_to_regex_pattern( + base_url: Optional[str], glob: str, websocket_url: bool = None +) -> str: + if websocket_url: + base_url = to_websocket_base_url(base_url) + glob = resolve_glob_base(base_url, glob) + return glob_to_regex_pattern(glob) + + +def to_websocket_base_url(base_url: Optional[str]) -> Optional[str]: + if base_url is not None and re.match(r"^https?://", base_url): + base_url = re.sub(r"^http", "ws", base_url) + return base_url + + +def resolve_glob_base(base_url: Optional[str], match: str) -> str: + if match[0] == "*": + return match + + token_map: Dict[str, str] = {} + + def map_token(original: str, replacement: str) -> str: + if len(original) == 0: + return "" + token_map[replacement] = original + return replacement + + # Escaped `\\?` behaves the same as `?` in our glob patterns. + match = match.replace(r"\\?", "?") + # Special case about: URLs as they are not relative to base_url + if ( + match.startswith("about:") + or match.startswith("data:") + or match.startswith("chrome:") + or match.startswith("edge:") + or match.startswith("file:") + ): + # about: and data: URLs are not relative to base_url, so we return them as is. + return match + # Glob symbols may be escaped in the URL and some of them such as ? affect resolution, + # so we replace them with safe components first. + processed_parts = [] + for index, token in enumerate(match.split("/")): + if token in (".", "..", ""): + processed_parts.append(token) + continue + # 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(":"): + # 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: + processed_parts.append(map_token(token, f"$_{index}_$")) else: - self._callback = match - self.match = match + new_prefix = map_token(token[:question_index], f"$_{index}_$") + new_suffix = map_token(token[question_index:], f"?$_{index}_$") + processed_parts.append(new_prefix + new_suffix) + + relative_path = "/".join(processed_parts) + 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 matches(self, url: str) -> bool: - if self._callback: - return self._callback(url) - if self._regex_obj: - return cast(bool, self._regex_obj.search(url)) - return False + +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): @@ -167,7 +285,21 @@ class HarLookupResult(TypedDict, total=False): body: Optional[str] +DEFAULT_PLAYWRIGHT_TIMEOUT_IN_MILLISECONDS = 30000 +DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT_IN_MILLISECONDS = 180000 +PLAYWRIGHT_MAX_DEADLINE = 2147483647 # 2^31-1 + + class TimeoutSettings: + + @staticmethod + def launch_timeout(timeout: Optional[float] = None) -> float: + return ( + timeout + if timeout is not None + else DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT_IN_MILLISECONDS + ) + def __init__(self, parent: Optional["TimeoutSettings"]) -> None: self._parent = parent self._default_timeout: Optional[float] = None @@ -183,7 +315,7 @@ def timeout(self, timeout: float = None) -> float: return self._default_timeout if self._parent: return self._parent.timeout() - return 30000 + return DEFAULT_PLAYWRIGHT_TIMEOUT_IN_MILLISECONDS def set_default_navigation_timeout( self, navigation_timeout: Optional[float] @@ -196,12 +328,16 @@ def default_navigation_timeout(self) -> Optional[float]: def default_timeout(self) -> Optional[float]: return self._default_timeout - def navigation_timeout(self) -> float: + def navigation_timeout(self, timeout: float = None) -> float: + if timeout is not None: + return timeout if self._default_navigation_timeout is not None: return self._default_navigation_timeout + if self._default_timeout is not None: + return self._default_timeout if self._parent: return self._parent.navigation_timeout() - return 30000 + return DEFAULT_PLAYWRIGHT_TIMEOUT_IN_MILLISECONDS def serialize_error(ex: Exception, tb: Optional[TracebackType]) -> ErrorPayload: @@ -229,7 +365,7 @@ def patch_error_message(message: str) -> str: if match: message = to_snake_case(match.group(1)) + match.group(2) message = message.replace( - "Pass { acceptDownloads: true }", "Pass { accept_downloads: True }" + "Pass { acceptDownloads: true }", "Pass 'accept_downloads=True'" ) return message @@ -240,7 +376,11 @@ def locals_to_params(args: Dict) -> Dict: if key == "self": continue if args[key] is not None: - copy[key] = args[key] + copy[key] = ( + args[key] + if not isinstance(args[key], Dict) + else locals_to_params(args[key]) + ) return copy @@ -260,12 +400,14 @@ def __init__(self, complete: "asyncio.Future", route: "Route") -> None: class RouteHandler: def __init__( self, - matcher: URLMatcher, + base_url: Optional[str], + url: URLMatch, handler: RouteHandlerCallback, is_sync: bool, times: Optional[int] = None, ): - self.matcher = matcher + self._base_url = base_url + self.url = url self.handler = handler self._times = times if times else math.inf self._handled_count = 0 @@ -274,7 +416,7 @@ def __init__( self._active_invocations: Set[RouteHandlerInvocation] = set() def matches(self, request_url: str) -> bool: - return self.matcher.matches(request_url) + return url_matches(self._base_url, request_url, self.url) async def handle(self, route: "Route") -> bool: handler_invocation = RouteHandlerInvocation( @@ -287,6 +429,14 @@ async def handle(self, route: "Route") -> bool: # If the handler was stopped (without waiting for completion), we ignore all exceptions. if self._ignore_exception: return False + if is_target_closed_error(e): + # We are failing in the handler because the target has closed. + # Give user a hint! + optional_async_prefix = "await " if not self._is_sync else "" + raise rewrite_error( + e, + f"\"{str(e)}\" while running route callback.\nConsider awaiting `{optional_async_prefix}page.unroute_all(behavior='ignoreErrors')`\nbefore the end of the test to ignore remaining routes in flight.", + ) raise e finally: handler_invocation.complete.set_result(None) @@ -343,13 +493,13 @@ def prepare_interception_patterns( patterns = [] all = False for handler in handlers: - if isinstance(handler.matcher.match, str): - patterns.append({"glob": handler.matcher.match}) - elif isinstance(handler.matcher._regex_obj, re.Pattern): + if isinstance(handler.url, str): + patterns.append({"glob": handler.url}) + elif isinstance(handler.url, re.Pattern): patterns.append( { - "regexSource": handler.matcher._regex_obj.pattern, - "regexFlags": escape_regex_flags(handler.matcher._regex_obj), + "regexSource": handler.url.pattern, + "regexFlags": escape_regex_flags(handler.url), } ) else: diff --git a/playwright/_impl/_impl_to_api_mapping.py b/playwright/_impl/_impl_to_api_mapping.py index 4315e1868..e26d22025 100644 --- a/playwright/_impl/_impl_to_api_mapping.py +++ b/playwright/_impl/_impl_to_api_mapping.py @@ -117,7 +117,7 @@ def to_impl( except RecursionError: raise Error("Maximum argument depth exceeded") - def wrap_handler(self, handler: Callable[..., None]) -> Callable[..., None]: + def wrap_handler(self, handler: Callable[..., Any]) -> Callable[..., None]: def wrapper_func(*args: Any) -> Any: arg_count = len(inspect.signature(handler).parameters) return handler( diff --git a/playwright/_impl/_input.py b/playwright/_impl/_input.py index a97ba5d11..8a39242ee 100644 --- a/playwright/_impl/_input.py +++ b/playwright/_impl/_input.py @@ -23,19 +23,19 @@ def __init__(self, channel: Channel) -> None: self._dispatcher_fiber = channel._connection._dispatcher_fiber async def down(self, key: str) -> None: - await self._channel.send("keyboardDown", locals_to_params(locals())) + await self._channel.send("keyboardDown", None, locals_to_params(locals())) async def up(self, key: str) -> None: - await self._channel.send("keyboardUp", locals_to_params(locals())) + await self._channel.send("keyboardUp", None, locals_to_params(locals())) async def insert_text(self, text: str) -> None: - await self._channel.send("keyboardInsertText", locals_to_params(locals())) + await self._channel.send("keyboardInsertText", None, locals_to_params(locals())) async def type(self, text: str, delay: float = None) -> None: - await self._channel.send("keyboardType", locals_to_params(locals())) + await self._channel.send("keyboardType", None, locals_to_params(locals())) async def press(self, key: str, delay: float = None) -> None: - await self._channel.send("keyboardPress", locals_to_params(locals())) + await self._channel.send("keyboardPress", None, locals_to_params(locals())) class Mouse: @@ -45,21 +45,34 @@ def __init__(self, channel: Channel) -> None: self._dispatcher_fiber = channel._connection._dispatcher_fiber async def move(self, x: float, y: float, steps: int = None) -> None: - await self._channel.send("mouseMove", locals_to_params(locals())) + await self._channel.send("mouseMove", None, locals_to_params(locals())) async def down( self, button: MouseButton = None, clickCount: int = None, ) -> None: - await self._channel.send("mouseDown", locals_to_params(locals())) + await self._channel.send("mouseDown", None, locals_to_params(locals())) async def up( self, button: MouseButton = None, clickCount: int = None, ) -> None: - await self._channel.send("mouseUp", locals_to_params(locals())) + await self._channel.send("mouseUp", None, locals_to_params(locals())) + + async def _click( + self, + x: float, + y: float, + delay: float = None, + button: MouseButton = None, + clickCount: int = None, + title: str = None, + ) -> None: + await self._channel.send( + "mouseClick", None, locals_to_params(locals()), title=title + ) async def click( self, @@ -69,7 +82,9 @@ async def click( button: MouseButton = None, clickCount: int = None, ) -> None: - await self._channel.send("mouseClick", locals_to_params(locals())) + params = locals() + del params["self"] + await self._click(**params) async def dblclick( self, @@ -78,10 +93,12 @@ async def dblclick( delay: float = None, button: MouseButton = None, ) -> None: - await self.click(x, y, delay=delay, button=button, clickCount=2) + await self._click( + x, y, delay=delay, button=button, clickCount=2, title="Double click" + ) async def wheel(self, deltaX: float, deltaY: float) -> None: - await self._channel.send("mouseWheel", locals_to_params(locals())) + await self._channel.send("mouseWheel", None, locals_to_params(locals())) class Touchscreen: @@ -91,4 +108,4 @@ def __init__(self, channel: Channel) -> None: self._dispatcher_fiber = channel._connection._dispatcher_fiber async def tap(self, x: float, y: float) -> None: - await self._channel.send("touchscreenTap", locals_to_params(locals())) + await self._channel.send("touchscreenTap", None, locals_to_params(locals())) diff --git a/playwright/_impl/_js_handle.py b/playwright/_impl/_js_handle.py index 415d79a76..84ef40d18 100644 --- a/playwright/_impl/_js_handle.py +++ b/playwright/_impl/_js_handle.py @@ -12,15 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 import collections.abc import datetime import math +import struct +import traceback from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from urllib.parse import ParseResult, urlparse, urlunparse from playwright._impl._connection import Channel, ChannelOwner, from_channel -from playwright._impl._errors import is_target_closed_error +from playwright._impl._errors import Error, is_target_closed_error from playwright._impl._map import Map if TYPE_CHECKING: # pragma: no cover @@ -68,6 +71,7 @@ async def evaluate(self, expression: str, arg: Serializable = None) -> Any: return parse_result( await self._channel.send( "evaluateExpression", + None, dict( expression=expression, arg=serialize_argument(arg), @@ -81,6 +85,7 @@ async def evaluate_handle( return from_channel( await self._channel.send( "evaluateExpressionHandle", + None, dict( expression=expression, arg=serialize_argument(arg), @@ -90,13 +95,16 @@ async def evaluate_handle( async def get_property(self, propertyName: str) -> "JSHandle": return from_channel( - await self._channel.send("getProperty", dict(name=propertyName)) + await self._channel.send("getProperty", None, dict(name=propertyName)) ) async def get_properties(self) -> Dict[str, "JSHandle"]: return { prop["name"]: from_channel(prop["value"]) - for prop in await self._channel.send("getPropertyList") + for prop in await self._channel.send( + "getPropertyList", + None, + ) } def as_element(self) -> Optional["ElementHandle"]: @@ -104,13 +112,21 @@ def as_element(self) -> Optional["ElementHandle"]: async def dispose(self) -> None: try: - await self._channel.send("dispose") + await self._channel.send( + "dispose", + None, + ) except Exception as e: if not is_target_closed_error(e): raise e async def json_value(self) -> Any: - return parse_result(await self._channel.send("jsonValue")) + return parse_result( + await self._channel.send( + "jsonValue", + None, + ) + ) def serialize_value( @@ -140,6 +156,24 @@ def serialize_value( value.astimezone(datetime.timezone.utc), "%Y-%m-%dT%H:%M:%S.%fZ" ) } + if isinstance(value, Exception): + return { + "e": { + "m": str(value), + "n": ( + (value.name or "") + if isinstance(value, Error) + else value.__class__.__name__ + ), + "s": ( + (value.stack or "") + if isinstance(value, Error) + else "".join( + traceback.format_exception(type(value), value=value, tb=None) + ) + ), + } + } if isinstance(value, bool): return {"b": value} if isinstance(value, (int, float)): @@ -207,6 +241,12 @@ def parse_value(value: Any, refs: Optional[Dict[int, Any]] = None) -> Any: if "bi" in value: return int(value["bi"]) + if "e" in value: + error = Error(value["e"]["m"]) + error._name = value["e"]["n"] + error._stack = value["e"]["s"] + return error + if "a" in value: a: List = [] refs[value["id"]] = a @@ -235,6 +275,56 @@ def parse_value(value: Any, refs: Optional[Dict[int, Any]] = None) -> Any: if "b" in value: return value["b"] + + if "ta" in value: + encoded_bytes = value["ta"]["b"] + decoded_bytes = base64.b64decode(encoded_bytes) + array_type = value["ta"]["k"] + if array_type == "i8": + word_size = 1 + fmt = "b" + elif array_type == "ui8" or array_type == "ui8c": + word_size = 1 + fmt = "B" + elif array_type == "i16": + word_size = 2 + fmt = "h" + elif array_type == "ui16": + word_size = 2 + fmt = "H" + elif array_type == "i32": + word_size = 4 + fmt = "i" + elif array_type == "ui32": + word_size = 4 + fmt = "I" + elif array_type == "f32": + word_size = 4 + fmt = "f" + elif array_type == "f64": + word_size = 8 + fmt = "d" + elif array_type == "bi64": + word_size = 8 + fmt = "q" + elif array_type == "bui64": + word_size = 8 + fmt = "Q" + else: + raise ValueError(f"Unsupported array type: {array_type}") + + byte_len = len(decoded_bytes) + if byte_len % word_size != 0: + raise ValueError( + f"Decoded bytes length {byte_len} is not a multiple of word size {word_size}" + ) + + if byte_len == 0: + return [] + array_len = byte_len // word_size + # "<" denotes little-endian + format_string = f"<{array_len}{fmt}" + return list(struct.unpack(format_string, decoded_bytes)) return value diff --git a/playwright/_impl/_json_pipe.py b/playwright/_impl/_json_pipe.py index 12d3a886f..41973b8c7 100644 --- a/playwright/_impl/_json_pipe.py +++ b/playwright/_impl/_json_pipe.py @@ -13,11 +13,12 @@ # limitations under the License. import asyncio -from typing import Dict, cast +from typing import Dict, Optional, cast from pyee.asyncio import AsyncIOEventEmitter from playwright._impl._connection import Channel +from playwright._impl._errors import TargetClosedError from playwright._impl._helper import Error, ParsedMessagePayload from playwright._impl._transport import Transport @@ -32,11 +33,10 @@ def __init__( Transport.__init__(self, loop) self._stop_requested = False self._pipe_channel = pipe_channel - self._loop: asyncio.AbstractEventLoop def request_stop(self) -> None: self._stop_requested = True - self._pipe_channel.send_no_reply("close", {}) + self._pipe_channel.send_no_reply("close", None, {}) def dispose(self) -> None: self.on_error_future.cancel() @@ -53,8 +53,10 @@ def handle_message(message: Dict) -> None: return self.on_message(cast(ParsedMessagePayload, message)) - def handle_closed() -> None: - self.emit("close") + def handle_closed(reason: Optional[str]) -> None: + self.emit("close", reason) + if reason: + self.on_error_future.set_exception(TargetClosedError(reason)) self._stopped_future.set_result(None) self._pipe_channel.on( @@ -63,7 +65,7 @@ def handle_closed() -> None: ) self._pipe_channel.on( "closed", - lambda _: handle_closed(), + lambda params: handle_closed(params.get("reason")), ) async def run(self) -> None: @@ -72,4 +74,4 @@ async def run(self) -> None: def send(self, message: Dict) -> None: if self._stop_requested: raise Error("Playwright connection closed") - self._pipe_channel.send_no_reply("send", {"message": message}) + self._pipe_channel.send_no_reply("send", None, {"message": message}) diff --git a/playwright/_impl/_local_utils.py b/playwright/_impl/_local_utils.py index 7172ee58a..cd8fd8343 100644 --- a/playwright/_impl/_local_utils.py +++ b/playwright/_impl/_local_utils.py @@ -31,11 +31,11 @@ def __init__( } async def zip(self, params: Dict) -> None: - await self._channel.send("zip", params) + await self._channel.send("zip", None, params) async def har_open(self, file: str) -> None: params = locals_to_params(locals()) - await self._channel.send("harOpen", params) + await self._channel.send("harOpen", None, params) async def har_lookup( self, @@ -51,27 +51,30 @@ async def har_lookup( params["postData"] = base64.b64encode(params["postData"]).decode() return cast( HarLookupResult, - await self._channel.send_return_as_dict("harLookup", params), + await self._channel.send_return_as_dict("harLookup", None, params), ) async def har_close(self, harId: str) -> None: params = locals_to_params(locals()) - await self._channel.send("harClose", params) + await self._channel.send("harClose", None, params) async def har_unzip(self, zipFile: str, harFile: str) -> None: params = locals_to_params(locals()) - await self._channel.send("harUnzip", params) + 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", params) + return await self._channel.send("tracingStarted", None, params) async def trace_discarded(self, stacks_id: str) -> None: - return await self._channel.send("traceDiscarded", {"stacksId": stacks_id}) + return await self._channel.send("traceDiscarded", None, {"stacksId": stacks_id}) def add_stack_to_tracing_no_reply(self, id: int, frames: List[StackFrame]) -> None: self._channel.send_no_reply( "addStackToTracingNoReply", + None, { "callData": { "stack": frames, diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index c5e92d874..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, @@ -47,7 +48,7 @@ monotonic_time, to_impl, ) -from playwright._impl._js_handle import Serializable, parse_value, serialize_argument +from playwright._impl._js_handle import Serializable from playwright._impl._str_utils import ( escape_for_attribute_selector, escape_for_text_selector, @@ -70,6 +71,7 @@ def __init__( has_not_text: Union[str, Pattern[str]] = None, has: "Locator" = None, has_not: "Locator" = None, + visible: bool = None, ) -> None: self._frame = frame self._selector = selector @@ -95,6 +97,9 @@ def __init__( raise Error('Inner "has_not" locator must belong to the same frame.') self._selector += " >> internal:has-not=" + json.dumps(locator._selector) + if visible is not None: + self._selector += f" >> visible={bool_to_js_bool(visible)}" + def __repr__(self) -> str: return f"" @@ -103,7 +108,7 @@ async def _with_element( task: Callable[[ElementHandle, float], Awaitable[T]], timeout: float = None, ) -> T: - timeout = self._frame.page._timeout_settings.timeout(timeout) + timeout = self._frame._timeout(timeout) deadline = (monotonic_time() + timeout) if timeout else 0 handle = await self.element_handle(timeout=timeout) if not handle: @@ -116,6 +121,9 @@ async def _with_element( finally: await handle.dispose() + def _equals(self, locator: "Locator") -> bool: + return self._frame == locator._frame and self._selector == locator._selector + @property def page(self) -> "Page": return self._frame.page @@ -148,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, @@ -162,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) @@ -210,7 +220,8 @@ async def clear( noWaitAfter: bool = None, force: bool = None, ) -> None: - await self.fill("", timeout=timeout, noWaitAfter=noWaitAfter, force=force) + params = locals_to_params(locals()) + await self._frame._fill(self._selector, value="", title="Clear", **params) def locator( self, @@ -329,12 +340,33 @@ def nth(self, index: int) -> "Locator": def content_frame(self) -> "FrameLocator": return FrameLocator(self._frame, self._selector) + def describe(self, description: str) -> "Locator": + return Locator( + self._frame, + 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, hasNotText: Union[str, Pattern[str]] = None, has: "Locator" = None, hasNot: "Locator" = None, + visible: bool = None, ) -> "Locator": return Locator( self._frame, @@ -343,6 +375,7 @@ def filter( has_not_text=hasNotText, has=has, has_not=hasNot, + visible=visible, ) def or_(self, locator: "Locator") -> "Locator": @@ -368,6 +401,7 @@ async def focus(self, timeout: float = None) -> None: async def blur(self, timeout: float = None) -> None: await self._frame._channel.send( "blur", + self._frame._timeout, { "selector": self._selector, "strict": True, @@ -397,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"] @@ -478,26 +513,24 @@ async def is_editable(self, timeout: float = None) -> bool: async def is_enabled(self, timeout: float = None) -> bool: params = locals_to_params(locals()) - return await self._frame.is_editable( + return await self._frame.is_enabled( self._selector, strict=True, **params, ) async def is_hidden(self, timeout: float = None) -> bool: - params = locals_to_params(locals()) + # timeout is deprecated and does nothing return await self._frame.is_hidden( self._selector, strict=True, - **params, ) async def is_visible(self, timeout: float = None) -> bool: - params = locals_to_params(locals()) + # timeout is deprecated and does nothing return await self._frame.is_visible( self._selector, strict=True, - **params, ) async def press( @@ -531,6 +564,29 @@ async def screenshot( ), ) + 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, + { + "selector": self._selector, + **locals_to_params(locals()), + }, + ) + + 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, @@ -628,7 +684,7 @@ async def press_sequentially( timeout: float = None, noWaitAfter: bool = None, ) -> None: - await self.type(text, delay=delay, timeout=timeout, noWaitAfter=noWaitAfter) + await self.type(text, delay=delay, timeout=timeout) async def uncheck( self, @@ -682,7 +738,6 @@ async def set_checked( position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, trial=trial, ) else: @@ -690,26 +745,16 @@ async def set_checked( position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, trial=trial, ) async def _expect( - self, expression: str, options: FrameExpectOptions + self, + expression: str, + options: FrameExpectOptions, + title: str = None, ) -> FrameExpectResult: - if "expectedValue" in options: - options["expectedValue"] = serialize_argument(options["expectedValue"]) - result = await self._frame._channel.send_return_as_dict( - "expect", - { - "selector": self._selector, - "expression": expression, - **options, - }, - ) - if result.get("received"): - result["received"] = parse_value(result["received"]) - return result + return await self._frame._expect(self._selector, expression, options, title) async def highlight(self) -> None: await self._frame._highlight(self._selector) diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 1fe436c80..06bf88267 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -18,6 +18,7 @@ import json import json as json_utils import mimetypes +import re from collections import defaultdict from pathlib import Path from types import SimpleNamespace @@ -36,6 +37,7 @@ from urllib import parse from playwright._impl._api_structures import ( + ClientCertificate, Headers, HeadersArray, RemoteAddr, @@ -50,14 +52,21 @@ ) from playwright._impl._errors import Error from playwright._impl._event_context_manager import EventContextManagerImpl -from playwright._impl._helper import locals_to_params +from playwright._impl._helper import ( + URLMatch, + WebSocketRouteHandlerCallback, + async_readfile, + locals_to_params, + url_matches, +) +from playwright._impl._str_utils import escape_regex_flags from playwright._impl._waiter import Waiter if TYPE_CHECKING: # pragma: no cover 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): @@ -83,6 +92,40 @@ def serialize_headers(headers: Dict[str, str]) -> HeadersArray: ] +async def to_client_certificates_protocol( + clientCertificates: Optional[List[ClientCertificate]], +) -> Optional[List[Dict[str, str]]]: + if not clientCertificates: + return None + out = [] + for clientCertificate in clientCertificates: + out_record = { + "origin": clientCertificate["origin"], + } + if passphrase := clientCertificate.get("passphrase"): + out_record["passphrase"] = passphrase + if pfx := clientCertificate.get("pfx"): + out_record["pfx"] = base64.b64encode(pfx).decode() + if pfx_path := clientCertificate.get("pfxPath"): + out_record["pfx"] = base64.b64encode( + await async_readfile(pfx_path) + ).decode() + if cert := clientCertificate.get("cert"): + out_record["cert"] = base64.b64encode(cert).decode() + if cert_path := clientCertificate.get("certPath"): + out_record["cert"] = base64.b64encode( + await async_readfile(cert_path) + ).decode() + if key := clientCertificate.get("key"): + out_record["key"] = base64.b64encode(key).decode() + if key_path := clientCertificate.get("keyPath"): + out_record["key"] = base64.b64encode( + await async_readfile(key_path) + ).decode() + out.append(out_record) + return out + + class Request(ChannelOwner): def __init__( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict @@ -111,11 +154,7 @@ def __init__( self._fallback_overrides: SerializedFallbackOverrides = ( SerializedFallbackOverrides() ) - base64_post_data = initializer.get("postData") - if base64_post_data is not None: - self._fallback_overrides.post_data_buffer = base64.b64decode( - base64_post_data - ) + self._response: Optional["Response"] = None def __repr__(self) -> str: return f"" @@ -146,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"]) @@ -154,14 +200,20 @@ async def sizes(self) -> RequestSizes: response = await self.response() if not response: raise Error("Unable to fetch sizes for failed request") - return await response._channel.send("sizes") + return await response._channel.send( + "sizes", + None, + ) @property def post_data(self) -> Optional[str]: data = self._fallback_overrides.post_data_buffer - if not data: - return None - return data.decode() + if data: + return data.decode() + base64_post_data = self._initializer.get("postData") + if base64_post_data is not None: + return base64.b64decode(base64_post_data).decode() + return None @property def post_data_json(self) -> Optional[Any]: @@ -178,10 +230,23 @@ def post_data_json(self) -> Optional[Any]: @property def post_data_buffer(self) -> Optional[bytes]: - return self._fallback_overrides.post_data_buffer + if self._fallback_overrides.post_data_buffer: + return self._fallback_overrides.post_data_buffer + if self._initializer.get("postData"): + return base64.b64decode(self._initializer["postData"]) + return None async def response(self) -> Optional["Response"]: - return from_nullable_channel(await self._channel.send("response")) + return from_nullable_channel( + await self._channel.send( + "response", + None, + ) + ) + + @property + def existing_response(self) -> Optional["Response"]: + return self._response @property def frame(self) -> "Frame": @@ -246,7 +311,9 @@ async def _actual_headers(self) -> "RawHeaders": return RawHeaders(serialize_headers(override)) if not self._all_headers_future: self._all_headers_future = asyncio.Future() - headers = await self._channel.send("rawRequestHeaders") + headers = await self._channel.send( + "rawRequestHeaders", None, is_internal=True + ) self._all_headers_future.set_result(RawHeaders(headers)) return await self._all_headers_future @@ -303,9 +370,9 @@ async def abort(self, errorCode: str = None) -> None: lambda: self._race_with_page_close( self._channel.send( "abort", + None, { "errorCode": errorCode, - "requestUrl": self.request._initializer["url"], }, ) ) @@ -388,9 +455,8 @@ async def _inner_fulfill( if length and "content-length" not in headers: headers["content-length"] = str(length) params["headers"] = serialize_headers(headers) - params["requestUrl"] = self.request._initializer["url"] - await self._race_with_page_close(self._channel.send("fulfill", params)) + await self._race_with_page_close(self._channel.send("fulfill", None, params)) async def _handle_route(self, callback: Callable) -> None: self._check_not_handled() @@ -408,6 +474,7 @@ async def fetch( headers: Dict[str, str] = None, postData: Union[Any, str, bytes] = None, maxRedirects: int = None, + maxRetries: int = None, timeout: float = None, ) -> "APIResponse": return await self._connection.wrap_api_call( @@ -418,6 +485,7 @@ async def fetch( headers, postData, maxRedirects=maxRedirects, + maxRetries=maxRetries, timeout=timeout, ) ) @@ -445,48 +513,36 @@ async def continue_( async def _inner() -> None: self.request._apply_fallback_overrides(overrides) - await self._internal_continue() + await self._inner_continue(False) return await self._handle_route(_inner) - def _internal_continue( - self, is_internal: bool = False - ) -> Coroutine[Any, Any, None]: - async def continue_route() -> None: - try: - params: Dict[str, Any] = {} - params["url"] = self.request._fallback_overrides.url - params["method"] = self.request._fallback_overrides.method - params["headers"] = self.request._fallback_overrides.headers - if self.request._fallback_overrides.post_data_buffer is not None: - params["postData"] = base64.b64encode( - self.request._fallback_overrides.post_data_buffer - ).decode() - params = locals_to_params(params) - - if "headers" in params: - params["headers"] = serialize_headers(params["headers"]) - params["requestUrl"] = self.request._initializer["url"] - params["isFallback"] = is_internal - await self._connection.wrap_api_call( - lambda: self._race_with_page_close( - self._channel.send( - "continue", - params, - ) + async def _inner_continue(self, is_fallback: bool = False) -> None: + options = self.request._fallback_overrides + await self._race_with_page_close( + self._channel.send( + "continue", + None, + { + "url": options.url, + "method": options.method, + "headers": ( + serialize_headers(options.headers) if options.headers else None ), - is_internal, - ) - except Exception as e: - if not is_internal: - raise e - - return continue_route() + "postData": ( + base64.b64encode(options.post_data_buffer).decode() + if options.post_data_buffer is not None + else None + ), + "isFallback": is_fallback, + }, + ) + ) async def _redirected_navigation_request(self, url: str) -> None: await self._handle_route( lambda: self._race_with_page_close( - self._channel.send("redirectNavigationRequest", {"url": url}) + self._channel.send("redirectNavigationRequest", None, {"url": url}) ) ) @@ -496,7 +552,7 @@ async def _race_with_page_close(self, future: Coroutine) -> None: setattr( fut, "__pw_stack__", - getattr(asyncio.current_task(self._loop), "__pw_stack__", inspect.stack()), + getattr(asyncio.current_task(self._loop), "__pw_stack__", inspect.stack(0)), ) target_closed_future = self.request._target_closed_future() await asyncio.wait( @@ -509,12 +565,246 @@ async def _race_with_page_close(self, future: Coroutine) -> None: await asyncio.gather(fut, return_exceptions=True) +def _create_task_and_ignore_exception( + loop: asyncio.AbstractEventLoop, coro: Coroutine +) -> None: + async def _ignore_exception() -> None: + try: + await coro + except Exception: + pass + + loop.create_task(_ignore_exception()) + + +class ServerWebSocketRoute: + def __init__(self, ws: "WebSocketRoute"): + self._ws = ws + + def on_message(self, handler: Callable[[Union[str, bytes]], Any]) -> None: + self._ws._on_server_message = handler + + def on_close(self, handler: Callable[[Optional[int], Optional[str]], Any]) -> None: + self._ws._on_server_close = handler + + def connect_to_server(self) -> None: + raise NotImplementedError( + "connectToServer must be called on the page-side WebSocketRoute" + ) + + @property + def url(self) -> str: + return self._ws._initializer["url"] + + def close(self, code: int = None, reason: str = None) -> None: + _create_task_and_ignore_exception( + self._ws._loop, + self._ws._channel.send( + "closeServer", + None, + { + "code": code, + "reason": reason, + "wasClean": True, + }, + ), + ) + + def send(self, message: Union[str, bytes]) -> None: + if isinstance(message, str): + _create_task_and_ignore_exception( + self._ws._loop, + self._ws._channel.send( + "sendToServer", None, {"message": message, "isBase64": False} + ), + ) + else: + _create_task_and_ignore_exception( + self._ws._loop, + self._ws._channel.send( + "sendToServer", + None, + {"message": base64.b64encode(message).decode(), "isBase64": True}, + ), + ) + + +class WebSocketRoute(ChannelOwner): + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + self._on_page_message: Optional[Callable[[Union[str, bytes]], Any]] = None + self._on_page_close: Optional[Callable[[Optional[int], Optional[str]], Any]] = ( + None + ) + self._on_server_message: Optional[Callable[[Union[str, bytes]], Any]] = None + self._on_server_close: Optional[ + Callable[[Optional[int], Optional[str]], Any] + ] = None + self._server = ServerWebSocketRoute(self) + self._connected = False + + self._channel.on("messageFromPage", self._channel_message_from_page) + self._channel.on("messageFromServer", self._channel_message_from_server) + self._channel.on("closePage", self._channel_close_page) + self._channel.on("closeServer", self._channel_close_server) + + def _channel_message_from_page(self, event: Dict) -> None: + if self._on_page_message: + self._on_page_message( + base64.b64decode(event["message"]) + if event["isBase64"] + else event["message"] + ) + elif self._connected: + _create_task_and_ignore_exception( + self._loop, self._channel.send("sendToServer", None, event) + ) + + def _channel_message_from_server(self, event: Dict) -> None: + if self._on_server_message: + self._on_server_message( + base64.b64decode(event["message"]) + if event["isBase64"] + else event["message"] + ) + else: + _create_task_and_ignore_exception( + self._loop, self._channel.send("sendToPage", None, event) + ) + + def _channel_close_page(self, event: Dict) -> None: + if self._on_page_close: + self._on_page_close(event["code"], event["reason"]) + else: + _create_task_and_ignore_exception( + self._loop, self._channel.send("closeServer", None, event) + ) + + def _channel_close_server(self, event: Dict) -> None: + if self._on_server_close: + self._on_server_close(event["code"], event["reason"]) + else: + _create_task_and_ignore_exception( + self._loop, self._channel.send("closePage", None, event) + ) + + @property + def url(self) -> str: + return self._initializer["url"] + + async def close(self, code: int = None, reason: str = None) -> None: + try: + await self._channel.send( + "closePage", None, {"code": code, "reason": reason, "wasClean": True} + ) + except Exception: + pass + + def connect_to_server(self) -> "WebSocketRoute": + if self._connected: + raise Error("Already connected to the server") + self._connected = True + asyncio.create_task( + self._channel.send( + "connect", + None, + ) + ) + return cast("WebSocketRoute", self._server) + + def send(self, message: Union[str, bytes]) -> None: + if isinstance(message, str): + _create_task_and_ignore_exception( + self._loop, + self._channel.send( + "sendToPage", None, {"message": message, "isBase64": False} + ), + ) + else: + _create_task_and_ignore_exception( + self._loop, + self._channel.send( + "sendToPage", + None, + { + "message": base64.b64encode(message).decode(), + "isBase64": True, + }, + ), + ) + + def on_message(self, handler: Callable[[Union[str, bytes]], Any]) -> None: + self._on_page_message = handler + + def on_close(self, handler: Callable[[Optional[int], Optional[str]], Any]) -> None: + self._on_page_close = handler + + async def _after_handle(self) -> None: + if self._connected: + return + # Ensure that websocket is "open" and can send messages without an actual server connection. + try: + await self._channel.send( + "ensureOpened", + None, + ) + except Exception: + pass + + +class WebSocketRouteHandler: + def __init__( + self, + base_url: Optional[str], + url: URLMatch, + handler: WebSocketRouteHandlerCallback, + ): + self._base_url = base_url + self.url = url + self.handler = handler + + @staticmethod + def prepare_interception_patterns( + handlers: List["WebSocketRouteHandler"], + ) -> List[dict]: + patterns = [] + all_urls = False + for handler in handlers: + if isinstance(handler.url, str): + patterns.append({"glob": handler.url}) + elif isinstance(handler.url, re.Pattern): + patterns.append( + { + "regexSource": handler.url.pattern, + "regexFlags": escape_regex_flags(handler.url), + } + ) + else: + all_urls = True + + if all_urls: + return [{"glob": "**/*"}] + return patterns + + def matches(self, ws_url: str) -> bool: + return url_matches(self._base_url, ws_url, self.url, True) + + async def handle(self, websocket_route: "WebSocketRoute") -> None: + coro_or_future = self.handler(websocket_route) + if asyncio.iscoroutine(coro_or_future): + await coro_or_future + await websocket_route._after_handle() + + class Response(ChannelOwner): def __init__( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict ) -> 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"] @@ -575,15 +865,33 @@ async def header_values(self, name: str) -> List[str]: async def _actual_headers(self) -> "RawHeaders": if not self._raw_headers_future: self._raw_headers_future = asyncio.Future() - headers = cast(HeadersArray, await self._channel.send("rawResponseHeaders")) + headers = cast( + HeadersArray, + await self._channel.send( + "rawResponseHeaders", + None, + ), + ) self._raw_headers_future.set_result(RawHeaders(headers)) return await self._raw_headers_future async def server_addr(self) -> Optional[RemoteAddr]: - return await self._channel.send("serverAddr") + return await self._channel.send( + "serverAddr", + None, + ) async def security_details(self) -> Optional[SecurityDetails]: - return await self._channel.send("securityDetails") + return await self._channel.send( + "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: @@ -602,7 +910,10 @@ async def on_finished() -> None: await on_finished_task async def body(self) -> bytes: - binary = await self._channel.send("body") + binary = await self._channel.send( + "body", + None, + ) return base64.b64decode(binary) async def text(self) -> str: diff --git a/playwright/_impl/_object_factory.py b/playwright/_impl/_object_factory.py index 2652e41fe..7911ddb30 100644 --- a/playwright/_impl/_object_factory.py +++ b/playwright/_impl/_object_factory.py @@ -20,16 +20,23 @@ 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 from playwright._impl._js_handle import JSHandle from playwright._impl._local_utils import LocalUtils -from playwright._impl._network import Request, Response, Route, WebSocket +from playwright._impl._network import ( + Request, + Response, + Route, + WebSocket, + WebSocketRoute, +) from playwright._impl._page import BindingCall, Page, Worker from playwright._impl._playwright import Playwright -from playwright._impl._selectors import SelectorsOwner from playwright._impl._stream import Stream from playwright._impl._tracing import Tracing from playwright._impl._writable_stream import WritableStream @@ -59,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": @@ -88,10 +99,10 @@ def create_remote_object( return Tracing(parent, type, guid, initializer) if type == "WebSocket": return WebSocket(parent, type, guid, initializer) + if type == "WebSocketRoute": + return WebSocketRoute(parent, type, guid, initializer) if type == "Worker": return Worker(parent, type, guid, initializer) if type == "WritableStream": return WritableStream(parent, type, guid, initializer) - if type == "Selectors": - return SelectorsOwner(parent, type, guid, initializer) return DummyObject(parent, type, guid, initializer) diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index db6cf13b8..5a8444624 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, @@ -33,7 +34,6 @@ cast, ) -from playwright._impl._accessibility import Accessibility from playwright._impl._api_structures import ( AriaRole, FilePayload, @@ -43,14 +43,16 @@ ViewportSize, ) from playwright._impl._artifact import Artifact +from playwright._impl._clock import Clock from playwright._impl._connection import ( ChannelOwner, from_channel, 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 +from playwright._impl._element_handle import ElementHandle, determine_screenshot_type from playwright._impl._errors import Error, TargetClosedError, is_target_closed_error from playwright._impl._event_context_manager import EventContextManagerImpl from playwright._impl._file_chooser import FileChooser @@ -59,6 +61,7 @@ from playwright._impl._har_router import HarRouter from playwright._impl._helper import ( ColorScheme, + Contrast, DocumentLoadState, ForcedColors, HarMode, @@ -70,14 +73,16 @@ RouteHandlerCallback, TimeoutSettings, URLMatch, - URLMatcher, URLMatchRequest, URLMatchResponse, + WebSocketRouteHandlerCallback, async_readfile, async_writefile, locals_to_params, make_dirs_for_file, + parse_error, serialize_error, + url_matches, ) from playwright._impl._input import Keyboard, Mouse, Touchscreen from playwright._impl._js_handle import ( @@ -87,7 +92,15 @@ parse_result, serialize_argument, ) -from playwright._impl._network import Request, Response, Route, serialize_headers +from playwright._impl._network import ( + Request, + Response, + Route, + WebSocketRoute, + WebSocketRouteHandler, + serialize_headers, +) +from playwright._impl._screencast import Screencast from playwright._impl._video import Video from playwright._impl._waiter import Waiter @@ -98,6 +111,25 @@ from playwright._impl._network import WebSocket +class LocatorHandler: + locator: "Locator" + handler: Union[Callable[["Locator"], Any], Callable[..., Any]] + times: Union[int, None] + + def __init__( + self, locator: "Locator", handler: Callable[..., Any], times: Union[int, None] + ) -> None: + self.locator = locator + self._handler = handler + self.times = times + + def __call__(self) -> Any: + arg_count = len(inspect.signature(self._handler).parameters) + if arg_count == 0: + return self._handler() + return self._handler(self.locator) + + class Page(ChannelOwner): Events = SimpleNamespace( Close="close", @@ -120,7 +152,6 @@ class Page(ChannelOwner): WebSocket="websocket", Worker="worker", ) - accessibility: Accessibility keyboard: Keyboard mouse: Mouse touchscreen: Touchscreen @@ -130,7 +161,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) @@ -143,16 +173,21 @@ def __init__( self._workers: List["Worker"] = [] self._bindings: Dict[str, Any] = {} self._routes: List[RouteHandler] = [] + self._web_socket_routes: List[WebSocketRouteHandler] = [] self._owned_context: Optional["BrowserContext"] = None 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 self._har_routers: List[HarRouter] = [] - self._locator_handlers: Dict[str, Callable] = {} + self._locator_handlers: Dict[str, LocatorHandler] = {} self._channel.on( "bindingCall", @@ -190,7 +225,13 @@ def __init__( self._on_route(from_channel(params["route"])) ), ) - self._channel.on("video", lambda params: self._on_video(params)) + self._channel.on( + "webSocketRoute", + lambda params: self._loop.create_task( + self._on_web_socket_route(from_channel(params["webSocketRoute"])) + ), + ) + self._channel.on("viewportSizeChanged", self._on_viewport_size_changed) self._channel.on( "webSocket", lambda params: self.emit( @@ -250,7 +291,7 @@ async def _on_route(self, route: Route) -> None: route_handlers = self._routes.copy() for route_handler in route_handlers: # If the page was closed we stall all requests right away. - if self._close_was_called or self.context._close_was_called: + if self._close_was_called or self.context._closing_or_closed: return if not route_handler.matches(route.request.url): continue @@ -278,6 +319,20 @@ async def _update_interceptor_patterns_ignore_exceptions() -> None: return await self._browser_context._on_route(route) + async def _on_web_socket_route(self, web_socket_route: WebSocketRoute) -> None: + route_handler = next( + ( + route_handler + for route_handler in self._web_socket_routes + if route_handler.matches(web_socket_route.url) + ), + None, + ) + if route_handler: + await route_handler.handle(web_socket_route) + else: + await self._browser_context._on_web_socket_route(web_socket_route) + def _on_binding(self, binding_call: "BindingCall") -> None: func = self._bindings.get(binding_call._initializer["name"]) if func: @@ -293,8 +348,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) @@ -309,14 +362,17 @@ 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"]) - cast(Video, self.video)._artifact_ready(artifact) + def _on_viewport_size_changed(self, params: Any) -> None: + self._viewport_size = params["viewportSize"] @property def context(self) -> "BrowserContext": return self._browser_context + @property + def clock(self) -> Clock: + return self._browser_context.clock + async def opener(self) -> Optional["Page"]: if self._opener and self._opener.is_closed(): return None @@ -327,16 +383,12 @@ def main_frame(self) -> Frame: return self._main_frame def frame(self, name: str = None, url: URLMatch = None) -> Optional[Frame]: - matcher = ( - URLMatcher(self._browser_context._options.get("baseURL"), url) - if url - else None - ) for frame in self._frames: if name and frame.name == name: return frame - if url and matcher and matcher.matches(frame.url): + if url and url_matches(self._browser_context._base_url, frame.url, url): return frame + return None @property @@ -345,13 +397,9 @@ def frames(self) -> List[Frame]: def set_default_navigation_timeout(self, timeout: float) -> None: self._timeout_settings.set_default_navigation_timeout(timeout) - self._channel.send_no_reply( - "setDefaultNavigationTimeoutNoReply", dict(timeout=timeout) - ) def set_default_timeout(self, timeout: float) -> None: self._timeout_settings.set_default_timeout(timeout) - self._channel.send_no_reply("setDefaultTimeoutNoReply", dict(timeout=timeout)) async def query_selector( self, @@ -395,12 +443,14 @@ async def is_enabled( async def is_hidden( self, selector: str, strict: bool = None, timeout: float = None ) -> bool: - return await self._main_frame.is_hidden(**locals_to_params(locals())) + # timeout is deprecated and does nothing + return await self._main_frame.is_hidden(selector=selector, strict=strict) async def is_visible( self, selector: str, strict: bool = None, timeout: float = None ) -> bool: - return await self._main_frame.is_visible(**locals_to_params(locals())) + # timeout is deprecated and does nothing + return await self._main_frame.is_visible(selector=selector, strict=strict) async def dispatch_event( self, @@ -453,12 +503,12 @@ 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: + ) -> Disposable: if name in self._bindings: raise Error(f'Function "{name}" has been already registered') if name in self._browser_context._bindings: @@ -466,13 +516,19 @@ async def expose_binding( f'Function "{name}" has been already registered in the browser context' ) self._bindings[name] = callback - await self._channel.send( - "exposeBinding", dict(name=name, needsHandle=handle or False) + return from_channel( + await self._channel.send( + "exposeBinding", + None, + dict(name=name, needsHandle=handle or False), + ) ) async def set_extra_http_headers(self, headers: Dict[str, str]) -> None: await self._channel.send( - "setExtraHTTPHeaders", dict(headers=serialize_headers(headers)) + "setExtraHTTPHeaders", + None, + dict(headers=serialize_headers(headers)), ) @property @@ -505,7 +561,11 @@ async def reload( waitUntil: DocumentLoadState = None, ) -> Optional[Response]: return from_nullable_channel( - await self._channel.send("reload", locals_to_params(locals())) + await self._channel.send( + "reload", + self._timeout_settings.navigation_timeout, + locals_to_params(locals()), + ) ) async def wait_for_load_state( @@ -536,7 +596,11 @@ async def go_back( waitUntil: DocumentLoadState = None, ) -> Optional[Response]: return from_nullable_channel( - await self._channel.send("goBack", locals_to_params(locals())) + await self._channel.send( + "goBack", + self._timeout_settings.navigation_timeout, + locals_to_params(locals()), + ) ) async def go_forward( @@ -545,15 +609,23 @@ async def go_forward( waitUntil: DocumentLoadState = None, ) -> Optional[Response]: return from_nullable_channel( - await self._channel.send("goForward", locals_to_params(locals())) + await self._channel.send( + "goForward", + self._timeout_settings.navigation_timeout, + locals_to_params(locals()), + ) ) + async def request_gc(self) -> None: + await self._channel.send("requestGC", None) + async def emulate_media( self, media: Literal["null", "print", "screen"] = None, colorScheme: ColorScheme = None, reducedMotion: ReducedMotion = None, forcedColors: ForcedColors = None, + contrast: Contrast = None, ) -> None: params = locals_to_params(locals()) if "media" in params: @@ -570,43 +642,55 @@ async def emulate_media( params["forcedColors"] = ( "no-override" if params["forcedColors"] == "null" else forcedColors ) - await self._channel.send("emulateMedia", params) + if "contrast" in params: + params["contrast"] = ( + "no-override" if params["contrast"] == "null" else contrast + ) + await self._channel.send("emulateMedia", None, params) async def set_viewport_size(self, viewportSize: ViewportSize) -> None: self._viewport_size = viewportSize - await self._channel.send("setViewportSize", locals_to_params(locals())) + await self._channel.send( + "setViewportSize", + None, + locals_to_params(locals()), + ) @property def viewport_size(self) -> Optional[ViewportSize]: return self._viewport_size async def bring_to_front(self) -> None: - await self._channel.send("bringToFront") + await self._channel.send("bringToFront", 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", 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( - URLMatcher(self._browser_context._options.get("baseURL"), url), + self._browser_context._base_url, + url, handler, True if self._dispatcher_fiber else False, times, ), ) await self._update_interception_patterns() + return DisposableStub(lambda: self.unroute(url, handler), self) async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None @@ -614,7 +698,7 @@ async def unroute( removed = [] remaining = [] for route in self._routes: - if route.matcher.match != url or (handler and route.handler != handler): + if route.url != url or (handler and route.handler != handler): remaining.append(route) else: removed.append(route) @@ -627,15 +711,23 @@ async def _unroute_internal( behavior: Literal["default", "ignoreErrors", "wait"] = None, ) -> None: self._routes = remaining - await self._update_interception_patterns() - if behavior is None or behavior == "default": - return - await asyncio.gather( - *map( - lambda route: route.stop(behavior), # type: ignore - removed, + if behavior is not None and behavior != "default": + await asyncio.gather( + *map( + lambda route: route.stop(behavior), # type: ignore + removed, + ) ) + await self._update_interception_patterns() + + async def route_web_socket( + self, url: URLMatch, handler: WebSocketRouteHandlerCallback + ) -> None: + self._web_socket_routes.insert( + 0, + WebSocketRouteHandler(self._browser_context._base_url, url, handler), ) + await self._update_web_socket_interception_patterns() def _dispose_har_routers(self) -> None: for router in self._har_routers: @@ -678,7 +770,19 @@ async def route_from_har( async def _update_interception_patterns(self) -> None: patterns = RouteHandler.prepare_interception_patterns(self._routes) await self._channel.send( - "setNetworkInterceptionPatterns", {"patterns": patterns} + "setNetworkInterceptionPatterns", + None, + {"patterns": patterns}, + ) + + async def _update_web_socket_interception_patterns(self) -> None: + patterns = WebSocketRouteHandler.prepare_interception_patterns( + self._web_socket_routes + ) + await self._channel.send( + "setWebSocketInterceptionPatterns", + None, + {"patterns": patterns}, ) async def screenshot( @@ -699,6 +803,8 @@ async def screenshot( ) -> bytes: params = locals_to_params(locals()) if "path" in params: + if "type" not in params: + params["type"] = determine_screenshot_type(params["path"]) del params["path"] if "mask" in params: params["mask"] = list( @@ -712,7 +818,9 @@ async def screenshot( params["mask"], ) ) - encoded_binary = await self._channel.send("screenshot", params) + encoded_binary = await self._channel.send( + "screenshot", self._timeout_settings.timeout, params + ) decoded_binary = base64.b64decode(encoded_binary) if path: make_dirs_for_file(path) @@ -722,11 +830,23 @@ 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 try: - await self._channel.send("close", locals_to_params(locals())) + await self._channel.send("close", None, locals_to_params(locals())) if self._owned_context: await self._owned_context.close() except Exception as e: @@ -750,7 +870,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, @@ -913,6 +1033,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())) @@ -1025,7 +1146,9 @@ async def pause(self) -> None: try: await asyncio.wait( [ - asyncio.create_task(self._browser_context._channel.send("pause")), + asyncio.create_task( + self._browser_context._channel.send("pause", None) + ), self._closed_or_crashed_future, ], return_when=asyncio.FIRST_COMPLETED, @@ -1057,7 +1180,7 @@ async def pdf( params = locals_to_params(locals()) if "path" in params: del params["path"] - encoded_binary = await self._channel.send("pdf", params) + encoded_binary = await self._channel.send("pdf", None, params) decoded_binary = base64.b64decode(encoded_binary) if path: make_dirs_for_file(path) @@ -1065,13 +1188,17 @@ async def pdf( return decoded_binary @property - def video( - self, - ) -> Optional[Video]: - if not self._video: - self._video = Video(self) + 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 screencast(self) -> Screencast: + return self._screencast + def _close_error_with_reason(self) -> TargetClosedError: return TargetClosedError( self._close_reason or self._browser_context._effective_close_reason() @@ -1152,21 +1279,14 @@ def expect_request( urlOrPredicate: URLMatchRequest, timeout: float = None, ) -> EventContextManagerImpl[Request]: - matcher = ( - None - if callable(urlOrPredicate) - else URLMatcher( - self._browser_context._options.get("baseURL"), urlOrPredicate - ) - ) - predicate = urlOrPredicate if callable(urlOrPredicate) else None - - def my_predicate(request: Request) -> bool: - if matcher: - return matcher.matches(request.url) - if predicate: - return predicate(request) - return True + def my_predicate(request: Request) -> Union[bool, Awaitable[bool]]: + if not callable(urlOrPredicate): + return url_matches( + self._browser_context._base_url, + request.url, + urlOrPredicate, + ) + return urlOrPredicate(request) trimmed_url = trim_url(urlOrPredicate) log_line = f"waiting for request {trimmed_url}" if trimmed_url else None @@ -1191,21 +1311,14 @@ def expect_response( urlOrPredicate: URLMatchResponse, timeout: float = None, ) -> EventContextManagerImpl[Response]: - matcher = ( - None - if callable(urlOrPredicate) - else URLMatcher( - self._browser_context._options.get("baseURL"), urlOrPredicate - ) - ) - predicate = urlOrPredicate if callable(urlOrPredicate) else None - - def my_predicate(response: Response) -> bool: - if matcher: - return matcher.matches(response.url) - if predicate: - return predicate(response) - return True + def my_predicate(request: Response) -> Union[bool, Awaitable[bool]]: + if not callable(urlOrPredicate): + return url_matches( + self._browser_context._base_url, + request.url, + urlOrPredicate, + ) + return urlOrPredicate(request) trimmed_url = trim_url(urlOrPredicate) log_line = f"waiting for response {trimmed_url}" if trimmed_url else None @@ -1247,7 +1360,6 @@ async def set_checked( position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, strict=strict, trial=trial, ) @@ -1257,61 +1369,130 @@ async def set_checked( position=position, timeout=timeout, force=force, - noWaitAfter=noWaitAfter, strict=strict, trial=trial, ) - async def add_locator_handler(self, locator: "Locator", handler: Callable) -> None: + async def add_locator_handler( + self, + locator: "Locator", + handler: Union[Callable[["Locator"], Any], Callable[[], Any]], + noWaitAfter: bool = None, + times: int = None, + ) -> None: if locator._frame != self._main_frame: raise Error("Locator must belong to the main frame of this page") + if times == 0: + return uid = await self._channel.send( "registerLocatorHandler", + None, { "selector": locator._selector, + "noWaitAfter": noWaitAfter, }, ) - self._locator_handlers[uid] = handler + self._locator_handlers[uid] = LocatorHandler( + handler=handler, times=times, locator=locator + ) async def _on_locator_handler_triggered(self, uid: str) -> None: + remove = False try: - if self._dispatcher_fiber: - handler_finished_future = self._loop.create_future() - - def _handler() -> None: - try: - self._locator_handlers[uid]() - handler_finished_future.set_result(None) - except Exception as e: - handler_finished_future.set_exception(e) - - g = LocatorHandlerGreenlet(_handler) - g.switch() - await handler_finished_future - else: - coro_or_future = self._locator_handlers[uid]() - if coro_or_future: - await coro_or_future - + handler = self._locator_handlers.get(uid) + if handler and handler.times != 0: + if handler.times is not None: + handler.times -= 1 + if self._dispatcher_fiber: + handler_finished_future = self._loop.create_future() + + def _handler() -> None: + try: + handler() + handler_finished_future.set_result(None) + except Exception as e: + handler_finished_future.set_exception(e) + + g = LocatorHandlerGreenlet(_handler) + g.switch() + await handler_finished_future + else: + coro_or_future = handler() + if coro_or_future: + await coro_or_future + remove = handler.times == 0 finally: + if remove: + del self._locator_handlers[uid] try: await self._connection.wrap_api_call( lambda: self._channel.send( - "resolveLocatorHandlerNoReply", {"uid": uid} + "resolveLocatorHandlerNoReply", + None, + {"uid": uid, "remove": remove}, ), is_internal=True, ) except Error: pass + async def remove_locator_handler(self, locator: "Locator") -> None: + for uid, data in self._locator_handlers.copy().items(): + if data.locator._equals(locator): + del self._locator_handlers[uid] + self._channel.send_no_reply( + "unregisterLocatorHandler", + 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 @@ -1334,6 +1515,7 @@ async def evaluate(self, expression: str, arg: Serializable = None) -> Any: return parse_result( await self._channel.send( "evaluateExpression", + None, dict( expression=expression, arg=serialize_argument(arg), @@ -1347,6 +1529,7 @@ async def evaluate_handle( return from_channel( await self._channel.send( "evaluateExpressionHandle", + None, dict( expression=expression, arg=serialize_argument(arg), @@ -1354,6 +1537,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__( @@ -1372,12 +1580,14 @@ async def call(self, func: Callable) -> None: result = func(source, *func_args) if inspect.iscoroutine(result): result = await result - await self._channel.send("resolve", dict(result=serialize_argument(result))) + await self._channel.send( + "resolve", None, dict(result=serialize_argument(result)) + ) except Exception as e: tb = sys.exc_info()[2] asyncio.create_task( self._channel.send( - "reject", dict(error=dict(error=serialize_error(e, tb))) + "reject", None, dict(error=dict(error=serialize_error(e, tb))) ) ) diff --git a/playwright/_impl/_path_utils.py b/playwright/_impl/_path_utils.py index 267a82ab0..b405a0675 100644 --- a/playwright/_impl/_path_utils.py +++ b/playwright/_impl/_path_utils.py @@ -14,12 +14,14 @@ import inspect from pathlib import Path +from types import FrameType +from typing import cast def get_file_dirname() -> Path: """Returns the callee (`__file__`) directory name""" - frame = inspect.stack()[1] - module = inspect.getmodule(frame[0]) + frame = cast(FrameType, inspect.currentframe()).f_back + module = inspect.getmodule(frame) assert module assert module.__file__ return Path(module.__file__).parent.absolute() diff --git a/playwright/_impl/_playwright.py b/playwright/_impl/_playwright.py index c02e73316..5c0151158 100644 --- a/playwright/_impl/_playwright.py +++ b/playwright/_impl/_playwright.py @@ -17,7 +17,7 @@ from playwright._impl._browser_type import BrowserType from playwright._impl._connection import ChannelOwner, from_channel from playwright._impl._fetch import APIRequest -from playwright._impl._selectors import Selectors, SelectorsOwner +from playwright._impl._selectors import Selectors class Playwright(ChannelOwner): @@ -41,12 +41,7 @@ def __init__( self.webkit._playwright = self self.selectors = Selectors(self._loop, self._dispatcher_fiber) - selectors_owner: SelectorsOwner = from_channel(initializer["selectors"]) - self.selectors._add_channel(selectors_owner) - self._connection.on( - "close", lambda: self.selectors._remove_channel(selectors_owner) - ) self.devices = self._connection.local_utils.devices def __getitem__(self, value: str) -> "BrowserType": @@ -59,10 +54,7 @@ def __getitem__(self, value: str) -> "BrowserType": raise ValueError("Invalid browser " + value) def _set_selectors(self, selectors: Selectors) -> None: - selectors_owner = from_channel(self._initializer["selectors"]) - self.selectors._remove_channel(selectors_owner) self.selectors = selectors - self.selectors._add_channel(selectors_owner) async def stop(self) -> None: pass diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py new file mode 100644 index 000000000..1f1da3c4f --- /dev/null +++ b/playwright/_impl/_screencast.py @@ -0,0 +1,140 @@ +# 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}) + 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/_selectors.py b/playwright/_impl/_selectors.py index cf8af8c06..c3bac78e5 100644 --- a/playwright/_impl/_selectors.py +++ b/playwright/_impl/_selectors.py @@ -14,20 +14,21 @@ import asyncio from pathlib import Path -from typing import Any, Dict, List, Set, Union +from typing import Any, Dict, List, Optional, Set, Union -from playwright._impl._connection import ChannelOwner +from playwright._impl._browser_context import BrowserContext from playwright._impl._errors import Error from playwright._impl._helper import async_readfile -from playwright._impl._locator import set_test_id_attribute_name, test_id_attribute_name +from playwright._impl._locator import set_test_id_attribute_name class Selectors: def __init__(self, loop: asyncio.AbstractEventLoop, dispatcher_fiber: Any) -> None: self._loop = loop - self._channels: Set[SelectorsOwner] = set() - self._registrations: List[Dict] = [] + self._contexts_for_selectors: Set[BrowserContext] = set() + self._selector_engines: List[Dict] = [] self._dispatcher_fiber = dispatcher_fiber + self._test_id_attribute_name: Optional[str] = None async def register( self, @@ -36,41 +37,31 @@ async def register( path: Union[str, Path] = None, contentScript: bool = None, ) -> None: + if any(engine for engine in self._selector_engines if engine["name"] == name): + raise Error( + f'Selectors.register: "{name}" selector engine has been already registered' + ) if not script and not path: raise Error("Either source or path should be specified") if path: script = (await async_readfile(path)).decode() - params: Dict[str, Any] = dict(name=name, source=script) + engine: Dict[str, Any] = dict(name=name, source=script) if contentScript: - params["contentScript"] = True - for channel in self._channels: - await channel._channel.send("register", params) - self._registrations.append(params) + engine["contentScript"] = contentScript + for context in self._contexts_for_selectors: + await context._channel.send( + "registerSelectorEngine", + None, + {"selectorEngine": engine}, + ) + self._selector_engines.append(engine) def set_test_id_attribute(self, attributeName: str) -> None: set_test_id_attribute_name(attributeName) - for channel in self._channels: - channel._channel.send_no_reply( - "setTestIdAttributeName", {"testIdAttributeName": attributeName} - ) - - def _add_channel(self, channel: "SelectorsOwner") -> None: - self._channels.add(channel) - for params in self._registrations: - # This should not fail except for connection closure, but just in case we catch. - channel._channel.send_no_reply("register", params) - channel._channel.send_no_reply( + self._test_id_attribute_name = attributeName + for context in self._contexts_for_selectors: + context._channel.send_no_reply( "setTestIdAttributeName", - {"testIdAttributeName": test_id_attribute_name()}, + None, + {"testIdAttributeName": attributeName}, ) - - def _remove_channel(self, channel: "SelectorsOwner") -> None: - if channel in self._channels: - self._channels.remove(channel) - - -class SelectorsOwner(ChannelOwner): - def __init__( - self, parent: ChannelOwner, type: str, guid: str, initializer: Dict - ) -> None: - super().__init__(parent, type, guid, initializer) diff --git a/playwright/_impl/_set_input_files_helpers.py b/playwright/_impl/_set_input_files_helpers.py index e47946be7..65307e0a2 100644 --- a/playwright/_impl/_set_input_files_helpers.py +++ b/playwright/_impl/_set_input_files_helpers.py @@ -14,8 +14,19 @@ import base64 import collections.abc import os +import stat from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, TypedDict, Union, cast +from typing import ( + TYPE_CHECKING, + Dict, + List, + Optional, + Sequence, + Tuple, + TypedDict, + Union, + cast, +) from playwright._impl._connection import Channel, from_channel from playwright._impl._helper import Error @@ -31,10 +42,20 @@ class InputFilesList(TypedDict, total=False): streams: Optional[List[Channel]] + directoryStream: Optional[Channel] + localDirectory: Optional[str] localPaths: Optional[List[str]] payloads: Optional[List[Dict[str, Union[str, bytes]]]] +def _list_files(directory: str) -> List[str]: + files = [] + for root, _, filenames in os.walk(directory): + for filename in filenames: + files.append(os.path.join(root, filename)) + return files + + async def convert_input_files( files: Union[ str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload] @@ -50,31 +71,52 @@ async def convert_input_files( if any([isinstance(item, (str, Path)) for item in items]): if not all([isinstance(item, (str, Path)) for item in items]): raise Error("File paths cannot be mixed with buffers") + + (local_paths, local_directory) = resolve_paths_and_directory_for_input_files( + cast(Sequence[Union[str, Path]], items) + ) + if context._channel._connection.is_remote: + files_to_stream = cast( + List[str], + (_list_files(local_directory) if local_directory else local_paths), + ) streams = [] - for item in items: - assert isinstance(item, (str, Path)) - last_modified_ms = int(os.path.getmtime(item) * 1000) - stream: WritableStream = from_channel( - await context._connection.wrap_api_call( - lambda: context._channel.send( - "createTempFile", - { - "name": os.path.basename(cast(str, item)), - "lastModifiedMs": last_modified_ms, - }, - ) - ) + result = await context._connection.wrap_api_call( + lambda: context._channel.send_return_as_dict( + "createTempFiles", + None, + { + "rootDirName": ( + os.path.basename(local_directory) + if local_directory + else None + ), + "items": list( + map( + lambda file: dict( + name=( + os.path.relpath(file, local_directory) + if local_directory + else os.path.basename(file) + ), + lastModifiedMs=int(os.path.getmtime(file) * 1000), + ), + files_to_stream, + ) + ), + }, ) - await stream.copy(item) + ) + for i, file in enumerate(result["writableStreams"]): + stream: WritableStream = from_channel(file) + await stream.copy(files_to_stream[i]) streams.append(stream._channel) - return InputFilesList(streams=streams) - return InputFilesList( - localPaths=[ - str(Path(cast(Union[str, Path], item)).absolute().resolve()) - for item in items - ] - ) + return InputFilesList( + streams=None if local_directory else streams, + directoryStream=result.get("rootDir"), + ) + return InputFilesList(localPaths=local_paths, localDirectory=local_directory) file_payload_exceeds_size_limit = ( sum([len(f.get("buffer", "")) for f in items if not isinstance(f, (str, Path))]) @@ -95,3 +137,22 @@ async def convert_input_files( for item in cast(List[FilePayload], items) ] ) + + +def resolve_paths_and_directory_for_input_files( + items: Sequence[Union[str, Path]], +) -> Tuple[Optional[List[str]], Optional[str]]: + local_paths: Optional[List[str]] = None + local_directory: Optional[str] = None + for item in items: + 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()) + else: + local_paths = local_paths or [] + local_paths.append(str(Path(item).resolve())) + if local_paths and local_directory: + raise Error("File paths must be all files or a single directory") + return (local_paths, local_directory) diff --git a/playwright/_impl/_stream.py b/playwright/_impl/_stream.py index d27427589..04afa48e1 100644 --- a/playwright/_impl/_stream.py +++ b/playwright/_impl/_stream.py @@ -28,7 +28,7 @@ def __init__( async def save_as(self, path: Union[str, Path]) -> None: file = await self._loop.run_in_executor(None, lambda: open(path, "wb")) while True: - binary = await self._channel.send("read", {"size": 1024 * 1024}) + binary = await self._channel.send("read", None, {"size": 1024 * 1024}) if not binary: break await self._loop.run_in_executor( @@ -39,7 +39,7 @@ async def save_as(self, path: Union[str, Path]) -> None: async def read_all(self) -> bytes: binary = b"" while True: - chunk = await self._channel.send("read", {"size": 1024 * 1024}) + chunk = await self._channel.send("read", None, {"size": 1024 * 1024}) if not chunk: break binary += base64.b64decode(chunk) diff --git a/playwright/_impl/_sync_base.py b/playwright/_impl/_sync_base.py index f07b947b2..3fef433b5 100644 --- a/playwright/_impl/_sync_base.py +++ b/playwright/_impl/_sync_base.py @@ -105,8 +105,8 @@ def _sync( g_self = greenlet.getcurrent() task: asyncio.tasks.Task[Any] = self._loop.create_task(coro) - setattr(task, "__pw_stack__", inspect.stack()) - setattr(task, "__pw_stack_trace__", traceback.extract_stack()) + 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(): @@ -114,7 +114,9 @@ def _sync( asyncio._set_running_loop(self._loop) return task.result() - def _wrap_handler(self, handler: Any) -> Callable[..., None]: + def _wrap_handler( + self, handler: Union[Callable[..., Any], Any] + ) -> Callable[..., None]: if callable(handler): return mapping.wrap_handler(handler) return handler @@ -140,11 +142,10 @@ def __enter__(self: Self) -> Self: def __exit__( self, - exc_type: Type[BaseException], - exc_val: BaseException, - _traceback: TracebackType, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + _traceback: Optional[TracebackType], ) -> None: self.close() - def close(self) -> None: - ... + def close(self) -> None: ... diff --git a/playwright/_impl/_tracing.py b/playwright/_impl/_tracing.py index 7f7972372..2798b89d9 100644 --- a/playwright/_impl/_tracing.py +++ b/playwright/_impl/_tracing.py @@ -15,8 +15,10 @@ import pathlib from typing import Dict, Optional, 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._disposable import DisposableStub from playwright._impl._helper import locals_to_params @@ -26,6 +28,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 @@ -37,50 +40,56 @@ 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) - async def _inner_start() -> str: - await self._channel.send("tracingStart", params) - return await self._channel.send( - "tracingStartChunk", {"title": title, "name": name} - ) - - trace_name = await self._connection.wrap_api_call(_inner_start, True) + 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} + ) await self._start_collecting_stacks(trace_name) async def start_chunk(self, title: str = None, name: str = None) -> None: params = locals_to_params(locals()) - trace_name = await self._channel.send("tracingStartChunk", params) + trace_name = await self._channel.send("tracingStartChunk", None, params) await self._start_collecting_stacks(trace_name) async def _start_collecting_stacks(self, trace_name: str) -> None: if not self._is_tracing: self._is_tracing = True - self._connection.set_in_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: - await self._connection.wrap_api_call(lambda: self._do_stop_chunk(path), True) + await self._do_stop_chunk(path) async def stop(self, path: Union[pathlib.Path, str] = None) -> None: - async def _inner() -> None: - await self._do_stop_chunk(path) - await self._channel.send("tracingStop") - - await self._connection.wrap_api_call(_inner, True) + await self._do_stop_chunk(path) + await self._channel.send( + "tracingStop", + None, + ) async def _do_stop_chunk(self, file_path: Union[pathlib.Path, str] = None) -> None: - if self._is_tracing: - self._is_tracing = False - self._connection.set_in_tracing(False) + self._reset_stack_counter() if not file_path: # Not interested in any artifacts - await self._channel.send("tracingStopChunk", {"mode": "discard"}) + await self._channel.send("tracingStopChunk", None, {"mode": "discard"}) if self._stacks_id: await self._connection.local_utils.trace_discarded(self._stacks_id) return @@ -89,7 +98,7 @@ async def _do_stop_chunk(self, file_path: Union[pathlib.Path, str] = None) -> No if is_local: result = await self._channel.send_return_as_dict( - "tracingStopChunk", {"mode": "entries"} + "tracingStopChunk", None, {"mode": "entries"} ) await self._connection.local_utils.zip( { @@ -104,6 +113,7 @@ async def _do_stop_chunk(self, file_path: Union[pathlib.Path, str] = None) -> No result = await self._channel.send_return_as_dict( "tracingStopChunk", + None, { "mode": "archive", }, @@ -133,3 +143,20 @@ async def _do_stop_chunk(self, file_path: Union[pathlib.Path, str] = None) -> No "includeSources": self._include_sources, } ) + + def _reset_stack_counter(self) -> None: + if self._is_tracing: + self._is_tracing = False + self._connection.set_is_tracing(False) + + 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, + ) diff --git a/playwright/_impl/_transport.py b/playwright/_impl/_transport.py index f07d31dcd..3cc029e18 100644 --- a/playwright/_impl/_transport.py +++ b/playwright/_impl/_transport.py @@ -105,9 +105,9 @@ async def connect(self) -> None: self._stopped_future: asyncio.Future = asyncio.Future() try: - # For pyinstaller + # For pyinstaller and Nuitka env = get_driver_env() - if getattr(sys, "frozen", False): + if getattr(sys, "frozen", False) or globals().get("__compiled__"): env.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0") startupinfo = None @@ -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.wait() - 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"