同步完整源码 - 2026-05-24

This commit is contained in:
xiaoxue 2026-05-24 23:05:48 +08:00
commit 21ef9025a7
425 changed files with 109915 additions and 0 deletions

View File

@ -0,0 +1,105 @@
{
"hydrated": false,
"properties": {
"helpUri": "https://eng.ms/docs/microsoft-security/security/azure-security/cloudai-security-fundamentals-engineering/security-integration/guardian-wiki/microsoft-guardian/general/suppressions",
"hydrationStatus": "This file does not contain identifying data. It is safe to check into your repo. To hydrate this file with identifying data, run `guardian hydrate --help` and follow the guidance."
},
"version": "1.0.0",
"suppressionSets": {
"default": {
"name": "default",
"createdDate": "2024-02-06 21:00:02Z",
"lastUpdatedDate": "2024-02-06 21:00:02Z"
}
},
"results": {
"bffa73d7410f5963f2538f06124ac5524c076da77867a0a19ccf60e508062dff": {
"signature": "bffa73d7410f5963f2538f06124ac5524c076da77867a0a19ccf60e508062dff",
"alternativeSignatures": [],
"memberOf": [
"default"
],
"createdDate": "2024-02-06 21:00:02Z"
},
"964642e3cd0f022d5b63f5d3c467d034df4b1664e58dd132b6cd54c98bdae6a1": {
"signature": "964642e3cd0f022d5b63f5d3c467d034df4b1664e58dd132b6cd54c98bdae6a1",
"alternativeSignatures": [
"f2d5560538c833834ca11e62fa6509618ab5454e1e71faf2847cb6fd07f4c4e0"
],
"memberOf": [
"default"
],
"createdDate": "2024-02-06 21:00:02Z"
},
"5b0f97262e176cd67207fd63a2c74b9984829286e9229d10efc32d6b73130e37": {
"signature": "5b0f97262e176cd67207fd63a2c74b9984829286e9229d10efc32d6b73130e37",
"alternativeSignatures": [
"29a18985690880b8caeebc339c7d2afd107510838cdc6561c1f5493478712581"
],
"memberOf": [
"default"
],
"createdDate": "2024-02-06 21:00:02Z"
},
"636fe8a4848f24231e94dc13a238022f90a2894cd47a483e351e467eeb98de52": {
"signature": "636fe8a4848f24231e94dc13a238022f90a2894cd47a483e351e467eeb98de52",
"alternativeSignatures": [
"e20632aa7941af4239fd857f802e05582c841fb9ae84e17c71ca6c7fc713246b"
],
"memberOf": [
"default"
],
"createdDate": "2024-02-06 21:00:02Z"
},
"67ae7118600b0793ec3f0a58a753888e13ce4badcc15575614ee6aa622e5009c": {
"signature": "67ae7118600b0793ec3f0a58a753888e13ce4badcc15575614ee6aa622e5009c",
"alternativeSignatures": [
"d1e68c2c7d9815f47331dd34c31db2634804b45b078a53d00843082747155ac9"
],
"memberOf": [
"default"
],
"createdDate": "2024-02-06 21:00:02Z"
},
"9b7d0de03b9e0e0b2711e31df7c804528c357bf5aa2d689fb5a5f42750e84077": {
"signature": "9b7d0de03b9e0e0b2711e31df7c804528c357bf5aa2d689fb5a5f42750e84077",
"alternativeSignatures": [
"e42bf5a49be2b1b815d1fde98ebf9d463fd2e70be1e8ca661f1210ce5b0c4953"
],
"memberOf": [
"default"
],
"createdDate": "2024-02-06 21:00:02Z"
},
"06ecbceae8bfd10acf8c35f21af3926d172c7930f24a204cc58b61efc6c4c770": {
"signature": "06ecbceae8bfd10acf8c35f21af3926d172c7930f24a204cc58b61efc6c4c770",
"alternativeSignatures": [
"035d6eb1444a809987923a39793fbb1ab9e4462405f38f94bc425c579705a9f2"
],
"memberOf": [
"default"
],
"createdDate": "2024-02-06 21:00:02Z"
},
"c103671af429c32de81f2dc2e7a92999de88a517d916a8f75c8e37448bb2efe9": {
"signature": "c103671af429c32de81f2dc2e7a92999de88a517d916a8f75c8e37448bb2efe9",
"alternativeSignatures": [
"3f904a503c12b62c2922900a2e689632e06272a815448939b1fdd435bcf74388"
],
"memberOf": [
"default"
],
"createdDate": "2024-02-06 21:00:02Z"
},
"d1196285a4e64cf6f0f7f22a29bf5b33b540137da1a89ed2af0c880d2a8c1d64": {
"signature": "d1196285a4e64cf6f0f7f22a29bf5b33b540137da1a89ed2af0c880d2a8c1d64",
"alternativeSignatures": [
"1c24094ca9e68a76a81c747853860e46fd139c9f47f0fdbad9133538e7d064b2"
],
"memberOf": [
"default"
],
"createdDate": "2024-02-06 21:00:02Z"
}
}
}

View File

@ -0,0 +1,82 @@
pr: none
trigger:
tags:
include:
- '*'
resources:
repositories:
- repository: 1esPipelines
type: git
name: 1ESPipelineTemplates/1ESPipelineTemplates
ref: refs/tags/release
extends:
template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines
parameters:
pool:
name: DevDivPlaywrightAzurePipelinesUbuntu2204
os: linux
sdl:
sourceAnalysisPool:
name: DevDivPlaywrightAzurePipelinesWindows2022
# The image must be windows-based due to restrictions of the SDL tools. See: https://aka.ms/AAo6v8e
# In the case of a windows build, this can be the same as the above pool image.
os: windows
suppression:
suppressionFile: $(Build.SourcesDirectory)\.azure-pipelines\guardian\SDL\.gdnsuppress
stages:
- stage: Stage
jobs:
- job: Build
templateContext:
outputs:
- output: pipelineArtifact
path: $(Build.ArtifactStagingDirectory)/esrp-build
artifact: esrp-build
steps:
- task: UsePythonVersion@0
inputs:
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 .
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'
- job: Publish
dependsOn: Build
templateContext:
type: releaseJob
isProduction: true
inputs:
- input: pipelineArtifact
artifactName: esrp-build
targetPath: $(Build.ArtifactStagingDirectory)/esrp-build
steps:
- checkout: none
- task: EsrpRelease@11
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'

View File

@ -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 = "<new>" # 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-<new>-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 (`<sha>~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-<new>` (or the matching tag if it exists).
Save the commit list, oldest first, scoped to `docs/src/api/`:
```sh
git -C ~/code/playwright log <prev-anchor>~1..release-<new> --oneline --reverse -- docs/src/api > /tmp/roll-<new>-commits.md
```
A normal roll yields 50100 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 <sha> -- 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/<module>.py`. Use the upstream JS implementation as a reference: `~/code/playwright/packages/playwright-core/src/client/<module>.ts`. Translate idioms:
| Upstream JS | Python |
|---|---|
| `async foo(): Promise<X>` | `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-<new>-commits.md` with one line: `[x] <sha> <subject> — <classification>: <one-liner>`.
### 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>\|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/<files-you-touched> tests/sync/<files-you-touched>
```
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: <Class>.<snake_method>
Parameter not implemented: <Class>.<snake_method>(<snake_param>=)
Parameter not documented: <Class>.<snake_method>(<snake_param>=)
Method vs property mismatch: <Class>.<snake_method>
Method not documented: <Class>.<snake_method>
Parameter type mismatch in <Class>.<snake_method>(<arg>=): documented as <T1>, code has <T2>
```
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 <sha> -- 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>|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-<new>-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.

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# text files must be lf for golden file tests to work
* text=auto eol=lf

96
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@ -0,0 +1,96 @@
name: Bug Report 🪲
description: Create a bug report to help us improve
title: '[Bug]: '
body:
- type: markdown
attributes:
value: |
# Please follow these steps first:
- type: markdown
attributes:
value: |
## Troubleshoot
If Playwright is not behaving the way you expect, we'd ask you to look at the [documentation](https://playwright.dev/python/docs/intro) and search the issue tracker for evidence supporting your expectation.
Please make reasonable efforts to troubleshoot and rule out issues with your code, the configuration, or any 3rd party libraries you might be using.
Playwright offers [several debugging tools](https://playwright.dev/python/docs/debug) that you can use to troubleshoot your issues.
- type: markdown
attributes:
value: |
## Ask for help through appropriate channels
If you feel unsure about the cause of the problem, consider asking for help on for example [StackOverflow](https://stackoverflow.com/questions/ask) or our [Discord channel](https://aka.ms/playwright/discord) before posting a bug report. The issue tracker is not a help forum.
- type: markdown
attributes:
value: |
## Make a minimal reproduction
To file the report, you will need a GitHub repository with a minimal (but complete) example and simple/clear steps on how to reproduce the bug.
The simpler you can make it, the more likely we are to successfully verify and fix the bug.
- type: markdown
attributes:
value: |
> [!IMPORTANT]
> Bug reports without a minimal reproduction will be rejected.
---
- type: input
id: version
attributes:
label: Version
description: |
The version of Playwright you are using.
Is it the [latest](https://github.com/microsoft/playwright-python/releases)? Test and see if the bug has already been fixed.
placeholder: ex. 1.41.1
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Please link to a repository with a minimal reproduction and describe accurately how we can reproduce/verify the bug.
value: |
Example steps (replace with your own):
1. Clone my repo at https://github.com/<myuser>/example
2. pip install -r requirements.txt
3. python test.py
4. You should see the error come up
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: A description of what you expect to happen.
placeholder: I expect to see X or Y
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: Actual behavior
description: |
A clear and concise description of the unexpected behavior.
Please include any relevant output here, especially any error messages.
placeholder: A bug happened!
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Anything else that might be relevant
validations:
required: false
- type: textarea
id: envinfo
attributes:
label: Environment
description: |
Please provide information about the environment you are running in.
value: |
- Operating System: [Ubuntu 22.04]
- CPU: [arm64]
- Browser: [All, Chromium, Firefox, WebKit]
- Python Version: [3.12]
- Other info:
render: Text
validations:
required: true

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Join our Discord Server
url: https://aka.ms/playwright/discord
about: Ask questions and discuss with other community members

View File

@ -0,0 +1,29 @@
name: Documentation 📖
description: Submit a request to add or update documentation
title: '[Docs]: '
labels: ['Documentation :book:']
body:
- type: markdown
attributes:
value: |
### Thank you for helping us improve our documentation!
Please be sure you are looking at [the Next version of the documentation](https://playwright.dev/python/docs/next/intro) before opening an issue here.
- type: textarea
id: links
attributes:
label: Page(s)
description: |
Links to one or more documentation pages that should be modified.
If you are reporting an issue with a specific section of a page, try to link directly to the nearest anchor.
If you are suggesting that a new page be created, link to the parent of the proposed page.
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: |
Describe the change you are requesting.
If the issue pertains to a single function or matcher, be sure to specify the entire call signature.
validations:
required: true

30
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@ -0,0 +1,30 @@
name: Feature Request 🚀
description: Submit a proposal for a new feature
title: '[Feature]: '
body:
- type: markdown
attributes:
value: |
### Thank you for taking the time to suggest a new feature!
- type: textarea
id: description
attributes:
label: '🚀 Feature Request'
description: A clear and concise description of what the feature is.
validations:
required: true
- type: textarea
id: example
attributes:
label: Example
description: Describe how this feature would be used.
validations:
required: false
- type: textarea
id: motivation
attributes:
label: Motivation
description: |
Outline your motivation for the proposal. How will it make Playwright better?
validations:
required: true

27
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: 'Questions / Help 💬'
description: If you have questions, please check StackOverflow or Discord
title: '[Please read the message below]'
labels: [':speech_balloon: Question']
body:
- type: markdown
attributes:
value: |
## Questions and Help 💬
This issue tracker is reserved for bug reports and feature requests.
For anything else, such as questions or getting help, please see:
- [The Playwright documentation](https://playwright.dev)
- [Our Discord server](https://aka.ms/playwright/discord)
- type: checkboxes
id: no-post
attributes:
label: |
Please do not submit this issue.
description: |
> [!IMPORTANT]
> This issue will be closed.
options:
- label: I understand
required: true

92
.github/ISSUE_TEMPLATE/regression.yml vendored Normal file
View File

@ -0,0 +1,92 @@
name: Report regression
description: Functionality that used to work and does not any more
title: "[Regression]: "
body:
- type: markdown
attributes:
value: |
# Please follow these steps first:
- type: markdown
attributes:
value: |
## Make a minimal reproduction
To file the report, you will need a GitHub repository with a minimal (but complete) example and simple/clear steps on how to reproduce the regression.
The simpler you can make it, the more likely we are to successfully verify and fix the regression.
- type: markdown
attributes:
value: |
> [!IMPORTANT]
> Regression reports without a minimal reproduction will be rejected.
---
- type: input
id: goodVersion
attributes:
label: Last Good Version
description: |
Last version of Playwright where the feature was working.
placeholder: ex. 1.40.1
validations:
required: true
- type: input
id: badVersion
attributes:
label: First Bad Version
description: |
First version of Playwright where the feature was broken.
Is it the [latest](https://github.com/microsoft/playwright-python/releases)? Test and see if the regression has already been fixed.
placeholder: ex. 1.41.1
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Please link to a repository with a minimal reproduction and describe accurately how we can reproduce/verify the bug.
value: |
Example steps (replace with your own):
1. Clone my repo at https://github.com/<myuser>/example
2. pip -r requirements.txt
3. python test.py
4. You should see the error come up
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: A description of what you expect to happen.
placeholder: I expect to see X or Y
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: Actual behavior
description: A clear and concise description of the unexpected behavior.
placeholder: A bug happened!
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Anything else that might be relevant
validations:
required: false
- type: textarea
id: envinfo
attributes:
label: Environment
description: |
Please provide information about the environment you are running in.
value: |
- Operating System: [Ubuntu 22.04]
- CPU: [arm64]
- Browser: [All, Chromium, Firefox, WebKit]
- Python Version: [3.12]
- Other info:
render: Text
validations:
required: true

14
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns:
- "*"

215
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,215 @@
name: CI
on:
push:
branches:
- main
- release-*
pull_request:
branches:
- main
- release-*
concurrency:
# For pull requests, cancel all currently-running jobs for this workflow
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
infra:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
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 -m build --wheel
python -m playwright install --with-deps
- name: Lint
run: pre-commit run --show-diff-on-failure --color=always --all-files
- name: Generate APIs
run: bash scripts/update_api.sh
- name: Verify generated API is up to date
run: git diff --exit-code
build:
name: Build
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.9', '3.10']
browser: [chromium, firefox, webkit]
exclude:
# WebKit on standard macOS-latest (currently macos-15-arm64) is unstable;
# upstream pins paid macos-15-xlarge for cross-browser webkit too.
- os: macos-latest
browser: webkit
include:
- os: macos-15-xlarge
python-version: '3.9'
browser: webkit
- os: macos-15-xlarge
python-version: '3.10'
browser: webkit
- os: windows-latest
python-version: '3.11'
browser: chromium
- os: macos-latest
python-version: '3.11'
browser: chromium
- os: ubuntu-latest
python-version: '3.11'
browser: chromium
- os: windows-latest
python-version: '3.12'
browser: chromium
- os: macos-latest
python-version: '3.12'
browser: chromium
- 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@v6
- name: Set up Python
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 -m build --wheel
python -m playwright install --with-deps ${{ matrix.browser }}
- name: Common Tests
run: pytest tests/common --browser=${{ matrix.browser }} --timeout 90
- name: Test Reference count
run: pytest tests/test_reference_count_async.py --browser=${{ matrix.browser }}
- name: Test Wheel Installation
run: pytest tests/test_installation.py --browser=${{ matrix.browser }}
- name: Test Sync API
if: matrix.os != 'ubuntu-latest'
run: pytest tests/sync --browser=${{ matrix.browser }} --timeout 90
- name: Test Sync API
if: matrix.os == 'ubuntu-latest'
run: xvfb-run pytest tests/sync --browser=${{ matrix.browser }} --timeout 90
- name: Test Async API
if: matrix.os != 'ubuntu-latest'
run: pytest tests/async --browser=${{ matrix.browser }} --timeout 90
- name: Test Async API
if: matrix.os == 'ubuntu-latest'
run: xvfb-run pytest tests/async --browser=${{ matrix.browser }} --timeout 90
test-stable:
name: Stable
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
browser-channel: [chrome]
include:
- os: windows-latest
browser-channel: msedge
- os: macos-latest
browser-channel: msedge
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Set up Python
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 -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
- name: Test Sync API
if: matrix.os != 'ubuntu-latest'
run: pytest tests/sync --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
- name: Test Sync API
if: matrix.os == 'ubuntu-latest'
run: xvfb-run pytest tests/sync --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
- name: Test Async API
if: matrix.os != 'ubuntu-latest'
run: pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
- name: Test Async API
if: matrix.os == 'ubuntu-latest'
run: xvfb-run pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} --timeout 90
build-conda:
name: Conda Build
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-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@v6
with:
fetch-depth: 0
- name: Get conda
uses: conda-incubator/setup-miniconda@v4
with:
python-version: '3.12'
channels: conda-forge
miniconda-version: latest
- name: Prepare
run: conda install -n base "conda-build>=26" conda-verify
- name: Build
run: conda build .
test_examples:
name: Examples
runs-on: ubuntu-22.04
defaults:
run:
working-directory: examples/todomvc/
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install dependencies & browsers
run: |
pip install -r requirements.txt
python -m playwright install --with-deps chromium
- name: Common Tests
run: pytest

50
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: Upload Python Package
on:
release:
types: [published]
workflow_dispatch:
jobs:
deploy-conda:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target-platform: linux-x86_64
- os: ubuntu-latest
target-platform: linux-aarch64
- os: windows-latest
target-platform: win-64
- os: macos-latest-large
target-platform: osx-intel
- os: macos-latest-xlarge
target-platform: osx-arm64
runs-on: ${{ matrix.os }}
defaults:
run:
# Required for conda-incubator/setup-miniconda@v4
shell: bash -el {0}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Get conda
uses: conda-incubator/setup-miniconda@v4
with:
python-version: '3.12'
channels: conda-forge
miniconda-version: latest
- name: Prepare
run: conda install -n base anaconda-client "conda-build>=26" conda-verify
- name: Build and Upload
env:
ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }}
run: |
conda config --set anaconda_upload yes
if [ "${{ matrix.target-platform }}" == "osx-arm64" ]; then
conda build --user microsoft . -m conda_build_config_osx_arm64.yaml
elif [ "${{ matrix.target-platform }}" == "linux-aarch64" ]; then
conda build --user microsoft . -m conda_build_config_linux_aarch64.yaml
else
conda build --user microsoft .
fi

41
.github/workflows/publish_docker.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: "publish release - Docker"
on:
workflow_dispatch:
release:
types: [published]
jobs:
publish-docker-release:
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@v6
- name: Azure login
uses: azure/login@v3
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@v6
with:
python-version: "3.10"
- name: Set up Docker QEMU for arm64 docker builds
uses: docker/setup-qemu-action@v4
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

58
.github/workflows/test_docker.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: Test Docker
on:
push:
paths:
- '.github/workflows/test_docker.yml'
- 'setup.py'
- '**/Dockerfile.*'
branches:
- main
- release-*
pull_request:
paths:
- '.github/workflows/test_docker.yml'
- 'setup.py'
- '**/Dockerfile.*'
branches:
- main
- release-*
jobs:
build:
timeout-minutes: 120
runs-on: ${{ matrix.runs-on }}
strategy:
fail-fast: false
matrix:
docker-image-variant:
- jammy
- noble
runs-on:
- ubuntu-24.04
- ubuntu-24.04-arm
steps:
- uses: actions/checkout@v6
- name: Set up Python
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: |
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 -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 -m build --wheel
docker exec "${CONTAINER_ID}" xvfb-run pytest tests/sync/
docker exec "${CONTAINER_ID}" xvfb-run pytest tests/async/

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
**/__pycache__/
driver/
playwright/driver/
playwright.egg-info/
build/
dist/
venv/
.idea/
**/*.pyc
env/
htmlcov/
.coverage*
.DS_Store
.vscode/
.eggs
_repo_version.py
coverage.xml
junit/
htmldocs/
utils/docker/dist/
Pipfile
Pipfile.lock
.venv/

49
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,49 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
exclude: tests/assets/har-sha1-main-response.txt
- id: check-yaml
- id: check-toml
- id: requirements-txt-fixer
- id: check-ast
- id: check-builtin-literals
- id: check-executables-have-shebangs
- id: check-merge-conflict
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.17.0
hooks:
- id: mypy
additional_dependencies: [types-pyOpenSSL==24.1.0.20240722, types-requests==2.32.4.20250611]
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
- repo: https://github.com/pycqa/isort
rev: 6.0.1
hooks:
- id: isort
- repo: local
hooks:
- id: pyright
name: pyright
entry: pyright
language: node
pass_filenames: false
types: [python]
additional_dependencies: ["pyright@1.1.403"]
- repo: local
hooks:
- id: check-license-header
name: Check License Header
entry: ./utils/linting/check_file_header.py
language: python
types: [python]

98
CLAUDE.md Normal file
View File

@ -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 <changed-files>
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
- <describe the change very! briefly>
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-<issue-number>`
**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.

9
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,9 @@
# Microsoft Open Source Code of Conduct
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
Resources:
- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)
- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)
- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns

78
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,78 @@
# Contributing
## How to Contribute
### Configuring python environment
The project development requires Python version 3.9+. To set it as default in the environment run the following commands:
```sh
# You may need to install python 3.9 venv if it's missing, on Ubuntu just run `sudo apt-get install python3.9-venv`
python3.9 -m venv env
source ./env/bin/activate
```
Install required dependencies:
```sh
python -m pip install --upgrade pip
pip install -r local-requirements.txt
```
Build and install drivers:
```sh
pip install -e .
python -m build --wheel
```
Run tests:
```sh
pytest --browser chromium
```
Checking for typing errors
```sh
mypy playwright
```
Format the code
```sh
pre-commit install
pre-commit run --all-files
```
For more details look at the [CI configuration](./.github/workflows/ci.yml).
Collect coverage
```sh
pytest --browser chromium --cov-report html --cov=playwright
open htmlcov/index.html
```
### Regenerating APIs
```bash
./scripts/update_api.sh
pre-commit run --all-files
```
## Contributor License Agreement
This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
When you submit a pull request, a CLA bot will automatically determine whether you need to provide
a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
provided by the bot. You will only need to do this once across all repos using our CLA.
## Code of Conduct
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.

202
LICENSE Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Portions Copyright (c) Microsoft Corporation.
Portions Copyright 2017 Google Inc.
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.

54
README.md Normal file
View File

@ -0,0 +1,54 @@
# 🎭 [Playwright](https://playwright.dev) for Python [![PyPI version](https://badge.fury.io/py/playwright.svg)](https://pypi.python.org/pypi/playwright/) [![Anaconda version](https://img.shields.io/conda/v/microsoft/playwright)](https://anaconda.org/Microsoft/playwright) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord)
Playwright is a Python library to automate [Chromium](https://www.chromium.org/Home), [Firefox](https://www.mozilla.org/en-US/firefox/new/) and [WebKit](https://webkit.org/) browsers with a single API. Playwright delivers automation that is **ever-green**, **capable**, **reliable** and **fast**. [See how Playwright is better](https://playwright.dev/python).
| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->148.0.7778.96<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
## Documentation
[https://playwright.dev/python/docs/intro](https://playwright.dev/python/docs/intro)
## API Reference
[https://playwright.dev/python/docs/api/class-playwright](https://playwright.dev/python/docs/api/class-playwright)
## Example
```py
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
for browser_type in [p.chromium, p.firefox, p.webkit]:
browser = browser_type.launch()
page = browser.new_page()
page.goto('http://playwright.dev')
page.screenshot(path=f'example-{browser_type.name}.png')
browser.close()
```
```py
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
for browser_type in [p.chromium, p.firefox, p.webkit]:
browser = await browser_type.launch()
page = await browser.new_page()
await page.goto('http://playwright.dev')
await page.screenshot(path=f'example-{browser_type.name}.png')
await browser.close()
asyncio.run(main())
```
## Other languages
More comfortable in another programming language? [Playwright](https://playwright.dev) is also available in
- [Node.js (JavaScript / TypeScript)](https://playwright.dev/docs/intro),
- [.NET](https://playwright.dev/dotnet/docs/intro),
- [Java](https://playwright.dev/java/docs/intro).

25
ROLLING.md Normal file
View File

@ -0,0 +1,25 @@
# Rolling Playwright-Python to the latest Playwright driver
* checkout repo: `git clone https://github.com/microsoft/playwright-python`
* make sure local python is 3.9
* 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 .
```
* change driver version in `setup.py`
* 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`

41
SECURITY.md Normal file
View File

@ -0,0 +1,41 @@
<!-- BEGIN MICROSOFT SECURITY.MD V0.0.5 BLOCK -->
## Security
Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/).
If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below.
## Reporting Security Issues
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report).
If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc).
You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc).
Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
* Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
* Full paths of source file(s) related to the manifestation of the issue
* The location of the affected source code (tag/branch/commit or direct URL)
* Any special configuration required to reproduce the issue
* Step-by-step instructions to reproduce the issue
* Proof-of-concept or exploit code (if possible)
* Impact of the issue, including how an attacker might exploit the issue
This information will help us triage your report more quickly.
If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs.
## Preferred Languages
We prefer all communications to be in English.
## Policy
Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd).
<!-- END MICROSOFT SECURITY.MD BLOCK -->

17
SUPPORT.md Normal file
View File

@ -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

View File

@ -0,0 +1,2 @@
target_platform:
- linux-aarch64

View File

@ -0,0 +1,2 @@
target_platform:
- osx-arm64

View File

View File

@ -0,0 +1,51 @@
# 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 Generator
import pytest
from playwright.sync_api import Page, expect
from .utils import TODO_ITEMS, create_default_todos
@pytest.fixture(autouse=True)
def run_around_tests(page: Page) -> Generator[None, None, None]:
# setup before a test
page.goto("https://demo.playwright.dev/todomvc")
create_default_todos(page)
# run the actual test
yield
# run any cleanup code
def test_should_display_the_correct_text(page: Page) -> None:
page.locator(".todo-list li .toggle").first.check()
expect(page.locator(".clear-completed")).to_have_text("Clear completed")
def test_should_clear_completed_items_when_clicked(page: Page) -> None:
todo_items = page.locator(".todo-list li")
todo_items.nth(1).locator(".toggle").check()
page.locator(".clear-completed").click()
expect(todo_items).to_have_count(2)
expect(todo_items).to_have_text([TODO_ITEMS[0], TODO_ITEMS[2]])
def test_should_be_hidden_when_there_are_no_items_that_are_completed(
page: Page,
) -> None:
page.locator(".todo-list li .toggle").first.check()
page.locator(".clear-completed").click()
expect(page.locator(".clear-completed")).to_be_hidden()

View File

@ -0,0 +1,41 @@
# 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 Generator
import pytest
from playwright.sync_api import Page, expect
from .utils import TODO_ITEMS, assert_number_of_todos_in_local_storage
@pytest.fixture(autouse=True)
def run_around_tests(page: Page) -> Generator[None, None, None]:
# setup before a test
page.goto("https://demo.playwright.dev/todomvc")
# run the actual test
yield
# run any cleanup code
def test_should_display_the_current_number_of_todo_items(page: Page) -> None:
page.locator(".new-todo").fill(TODO_ITEMS[0])
page.locator(".new-todo").press("Enter")
expect(page.locator(".todo-count")).to_contain_text("1")
page.locator(".new-todo").fill(TODO_ITEMS[1])
page.locator(".new-todo").press("Enter")
expect(page.locator(".todo-count")).to_contain_text("2")
assert_number_of_todos_in_local_storage(page, 2)

View File

@ -0,0 +1,97 @@
# 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 Generator
import pytest
from playwright.sync_api import Page, expect
from .utils import (
TODO_ITEMS,
assert_number_of_todos_in_local_storage,
check_todos_in_local_storage,
create_default_todos,
)
@pytest.fixture(autouse=True)
def run_around_tests(page: Page) -> Generator[None, None, None]:
# setup before a test
page.goto("https://demo.playwright.dev/todomvc")
create_default_todos(page)
assert_number_of_todos_in_local_storage(page, 3)
# run the actual test
yield
# run any cleanup code
def test_should_hide_other_controls_when_editing(page: Page) -> None:
todo_item = page.locator(".todo-list li").nth(1)
todo_item.dblclick()
expect(todo_item.locator(".toggle")).not_to_be_visible()
expect(todo_item.locator("label")).not_to_be_visible()
assert_number_of_todos_in_local_storage(page, 3)
def test_should_save_edits_on_blur(page: Page) -> None:
todo_items = page.locator(".todo-list li")
todo_items.nth(1).dblclick()
todo_items.nth(1).locator(".edit").fill("buy some sausages")
todo_items.nth(1).locator(".edit").dispatch_event("blur")
expect(todo_items).to_have_text(
[
TODO_ITEMS[0],
"buy some sausages",
TODO_ITEMS[2],
]
)
check_todos_in_local_storage(page, "buy some sausages")
def test_should_trim_entered_text(page: Page) -> None:
todo_items = page.locator(".todo-list li")
todo_items.nth(1).dblclick()
todo_items.nth(1).locator(".edit").fill(" buy some sausages ")
todo_items.nth(1).locator(".edit").press("Enter")
expect(todo_items).to_have_text(
[
TODO_ITEMS[0],
"buy some sausages",
TODO_ITEMS[2],
]
)
check_todos_in_local_storage(page, "buy some sausages")
def test_should_remove_the_item_if_an_empty_text_string_was_entered(page: Page) -> None:
todo_items = page.locator(".todo-list li")
todo_items.nth(1).dblclick()
todo_items.nth(1).locator(".edit").fill("")
todo_items.nth(1).locator(".edit").press("Enter")
expect(todo_items).to_have_text(
[
TODO_ITEMS[0],
TODO_ITEMS[2],
]
)
def test_should_cancel_edits_on_escape(page: Page) -> None:
todo_items = page.locator(".todo-list li")
todo_items.nth(1).dblclick()
todo_items.nth(1).locator(".edit").press("Escape")
expect(todo_items).to_have_text(TODO_ITEMS)

View File

@ -0,0 +1,89 @@
# 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 Generator
import pytest
from playwright.sync_api import Page, expect
from .utils import (
TODO_ITEMS,
check_number_of_completed_todos_in_local_storage,
check_todos_in_local_storage,
create_default_todos,
)
@pytest.fixture(autouse=True)
def run_around_tests(page: Page) -> Generator[None, None, None]:
# setup before a test
page.goto("https://demo.playwright.dev/todomvc")
# run the actual test
yield
# run any cleanup code
def test_should_allow_me_to_mark_items_as_completed(page: Page) -> None:
# Create two items.
for item in TODO_ITEMS[:2]:
page.locator(".new-todo").fill(item)
page.locator(".new-todo").press("Enter")
# Check first item.
firstTodo = page.locator(".todo-list li").nth(0)
firstTodo.locator(".toggle").check()
expect(firstTodo).to_have_class("completed")
# Check second item.
secondTodo = page.locator(".todo-list li").nth(1)
expect(secondTodo).not_to_have_class("completed")
secondTodo.locator(".toggle").check()
# Assert completed class.
expect(firstTodo).to_have_class("completed")
expect(secondTodo).to_have_class("completed")
def test_should_allow_me_to_un_mark_items_as_completed(page: Page) -> None:
# Create two items.
for item in TODO_ITEMS[:2]:
page.locator(".new-todo").fill(item)
page.locator(".new-todo").press("Enter")
firstTodo = page.locator(".todo-list li").nth(0)
secondTodo = page.locator(".todo-list li").nth(1)
firstTodo.locator(".toggle").check()
expect(firstTodo).to_have_class("completed")
expect(secondTodo).not_to_have_class("completed")
check_number_of_completed_todos_in_local_storage(page, 1)
firstTodo.locator(".toggle").uncheck()
expect(firstTodo).not_to_have_class("completed")
expect(secondTodo).not_to_have_class("completed")
check_number_of_completed_todos_in_local_storage(page, 0)
def test_should_allow_me_to_edit_an_item(page: Page) -> None:
create_default_todos(page)
todo_items = page.locator(".todo-list li")
secondTodo = todo_items.nth(1)
secondTodo.dblclick()
expect(secondTodo.locator(".edit")).to_have_value(TODO_ITEMS[1])
secondTodo.locator(".edit").fill("buy some sausages")
secondTodo.locator(".edit").press("Enter")
# Explicitly assert the new text value.
expect(todo_items).to_have_text([TODO_ITEMS[0], "buy some sausages", TODO_ITEMS[2]])
check_todos_in_local_storage(page, "buy some sausages")

View File

@ -0,0 +1,84 @@
# 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 Generator
import pytest
from playwright.sync_api import Page, expect
from .utils import (
assert_number_of_todos_in_local_storage,
check_number_of_completed_todos_in_local_storage,
create_default_todos,
)
@pytest.fixture(autouse=True)
def run_around_tests(page: Page) -> Generator[None, None, None]:
# setup before a test
page.goto("https://demo.playwright.dev/todomvc")
# run the actual test
yield
# run any cleanup code
def test_should_allow_me_to_mark_all_items_as_completed(page: Page) -> None:
create_default_todos(page)
assert_number_of_todos_in_local_storage(page, 3)
# Complete all todos.
page.locator(".toggle-all").check()
# Ensure all todos have 'completed' class.
expect(page.locator(".todo-list li")).to_have_class(
["completed", "completed", "completed"]
)
check_number_of_completed_todos_in_local_storage(page, 3)
assert_number_of_todos_in_local_storage(page, 3)
def test_should_allow_me_to_clear_the_complete_state_of_all_items(page: Page) -> None:
create_default_todos(page)
assert_number_of_todos_in_local_storage(page, 3)
# Check and then immediately uncheck.
page.locator(".toggle-all").check()
page.locator(".toggle-all").uncheck()
# Should be no completed classes.
expect(page.locator(".todo-list li")).to_have_class(["", "", ""])
assert_number_of_todos_in_local_storage(page, 3)
def test_complete_all_checkbox_should_update_state_when_items_are_completed_or_cleared(
page: Page,
) -> None:
create_default_todos(page)
assert_number_of_todos_in_local_storage(page, 3)
toggleAll = page.locator(".toggle-all")
toggleAll.check()
expect(toggleAll).to_be_checked()
check_number_of_completed_todos_in_local_storage(page, 3)
# Uncheck first todo.
firstTodo = page.locator(".todo-list li").nth(0)
firstTodo.locator(".toggle").uncheck()
# Reuse toggleAll locator and make sure its not checked.
expect(toggleAll).not_to_be_checked()
firstTodo.locator(".toggle").check()
check_number_of_completed_todos_in_local_storage(page, 3)
# Assert the toggle all is checked again.
expect(toggleAll).to_be_checked()
assert_number_of_todos_in_local_storage(page, 3)

View File

@ -0,0 +1,89 @@
# 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 re
from typing import Generator
import pytest
from playwright.sync_api import Page, expect
from .utils import (
TODO_ITEMS,
assert_number_of_todos_in_local_storage,
create_default_todos,
)
@pytest.fixture(autouse=True)
def run_around_tests(page: Page) -> Generator[None, None, None]:
# setup before a test
page.goto("https://demo.playwright.dev/todomvc")
# run the actual test
yield
# run any cleanup code
def test_new_todo_test_should_allow_me_to_add_todo_items(page: Page) -> None:
# Create 1st todo.
page.locator(".new-todo").fill(TODO_ITEMS[0])
page.locator(".new-todo").press("Enter")
# Make sure the list only has one todo item.
expect(page.locator(".view label")).to_have_text([TODO_ITEMS[0]])
# Create 2nd todo.
page.locator(".new-todo").fill(TODO_ITEMS[1])
page.locator(".new-todo").press("Enter")
# Make sure the list now has two todo items.
expect(page.locator(".view label")).to_have_text([TODO_ITEMS[0], TODO_ITEMS[1]])
assert_number_of_todos_in_local_storage(page, 2)
def test_new_todo_test_should_clear_text_input_field_when_an_item_is_added(
page: Page,
) -> None:
# Create one todo item.
page.locator(".new-todo").fill(TODO_ITEMS[0])
page.locator(".new-todo").press("Enter")
# Check that input is empty.
expect(page.locator(".new-todo")).to_be_empty()
assert_number_of_todos_in_local_storage(page, 1)
def test_new_todo_test_should_append_new_items_to_the_bottom_of_the_list(
page: Page,
) -> None:
# Create 3 items.
create_default_todos(page)
# Check test using different methods.
expect(page.locator(".todo-count")).to_have_text("3 items left")
expect(page.locator(".todo-count")).to_contain_text("3")
expect(page.locator(".todo-count")).to_have_text(re.compile("3"))
# Check all items in one call.
expect(page.locator(".view label")).to_have_text(TODO_ITEMS)
assert_number_of_todos_in_local_storage(page, 3)
def test_new_todo_should_show_main_and_foter_when_items_added(page: Page) -> None:
page.locator(".new-todo").fill(TODO_ITEMS[0])
page.locator(".new-todo").press("Enter")
expect(page.locator(".main")).to_be_visible()
expect(page.locator(".footer")).to_be_visible()
assert_number_of_todos_in_local_storage(page, 1)

View File

@ -0,0 +1,48 @@
# 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 Generator
import pytest
from playwright.sync_api import Page, expect
from .utils import TODO_ITEMS, check_number_of_completed_todos_in_local_storage
@pytest.fixture(autouse=True)
def run_around_tests(page: Page) -> Generator[None, None, None]:
# setup before a test
page.goto("https://demo.playwright.dev/todomvc")
# run the actual test
yield
# run any cleanup code
def test_should_persist_its_data(page: Page) -> None:
for item in TODO_ITEMS[:2]:
page.locator(".new-todo").fill(item)
page.locator(".new-todo").press("Enter")
todo_items = page.locator(".todo-list li")
todo_items.nth(0).locator(".toggle").check()
expect(todo_items).to_have_text([TODO_ITEMS[0], TODO_ITEMS[1]])
expect(todo_items).to_have_class(["completed", ""])
# Ensure there is 1 completed item.
check_number_of_completed_todos_in_local_storage(page, 1)
# Now reload.
page.reload()
expect(todo_items).to_have_text([TODO_ITEMS[0], TODO_ITEMS[1]])
expect(todo_items).to_have_class(["completed", ""])

View File

@ -0,0 +1,94 @@
# 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 Generator
import pytest
from playwright.sync_api import Page, expect
from .utils import (
TODO_ITEMS,
check_number_of_completed_todos_in_local_storage,
check_todos_in_local_storage,
create_default_todos,
)
@pytest.fixture(autouse=True)
def run_around_tests(page: Page) -> Generator[None, None, None]:
# setup before a test
page.goto("https://demo.playwright.dev/todomvc")
create_default_todos(page)
# make sure the app had a chance to save updated todos in storage
# before navigating to a new view, otherwise the items can get lost :(
# in some frameworks like Durandal
check_todos_in_local_storage(page, TODO_ITEMS[0])
# run the actual test
yield
# run any cleanup code
def test_should_allow_me_to_display_active_item(page: Page) -> None:
page.locator(".todo-list li .toggle").nth(1).check()
check_number_of_completed_todos_in_local_storage(page, 1)
page.locator(".filters >> text=Active").click()
expect(page.locator(".todo-list li")).to_have_count(2)
expect(page.locator(".todo-list li")).to_have_text([TODO_ITEMS[0], TODO_ITEMS[2]])
def test_should_respect_the_back_button(page: Page) -> None:
page.locator(".todo-list li .toggle").nth(1).check()
check_number_of_completed_todos_in_local_storage(page, 1)
# Showing all items
page.locator(".filters >> text=All").click()
expect(page.locator(".todo-list li")).to_have_count(3)
# Showing active items
page.locator(".filters >> text=Active").click()
# Showing completed items
page.locator(".filters >> text=Completed").click()
expect(page.locator(".todo-list li")).to_have_count(1)
page.go_back()
expect(page.locator(".todo-list li")).to_have_count(2)
page.go_back()
expect(page.locator(".todo-list li")).to_have_count(3)
def test_should_allow_me_to_display_completed_items(page: Page) -> None:
page.locator(".todo-list li .toggle").nth(1).check()
check_number_of_completed_todos_in_local_storage(page, 1)
page.locator(".filters >> text=Completed").click()
expect(page.locator(".todo-list li")).to_have_count(1)
def test_should_allow_me_to_display_all_items(page: Page) -> None:
page.locator(".todo-list li .toggle").nth(1).check()
check_number_of_completed_todos_in_local_storage(page, 1)
page.locator(".filters >> text=Active").click()
page.locator(".filters >> text=Completed").click()
page.locator(".filters >> text=All").click()
expect(page.locator(".todo-list li")).to_have_count(3)
def test_should_highlight_the_current_applied_filter(page: Page) -> None:
expect(page.locator(".filters >> text=All")).to_have_class("selected")
page.locator(".filters >> text=Active").click()
# Page change - active items.
expect(page.locator(".filters >> text=Active")).to_have_class("selected")
page.locator(".filters >> text=Completed").click()
# Page change - completed items.
expect(page.locator(".filters >> text=Completed")).to_have_class("selected")

View File

@ -0,0 +1,41 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from playwright.sync_api import Page
TODO_ITEMS = ["buy some cheese", "feed the cat", "book a doctors appointment"]
def create_default_todos(page: Page) -> None:
for item in TODO_ITEMS:
page.locator(".new-todo").fill(item)
page.locator(".new-todo").press("Enter")
def check_number_of_completed_todos_in_local_storage(page: Page, expected: int) -> None:
assert (
page.evaluate(
"JSON.parse(localStorage['react-todos']).filter(i => i.completed).length"
)
== expected
)
def assert_number_of_todos_in_local_storage(page: Page, expected: int) -> None:
assert len(page.evaluate("JSON.parse(localStorage['react-todos'])")) == expected
def check_todos_in_local_storage(page: Page, title: str) -> None:
assert title in page.evaluate(
"JSON.parse(localStorage['react-todos']).map(i => i.title)"
)

View File

@ -0,0 +1 @@
pytest-playwright

23
local-requirements.txt Normal file
View File

@ -0,0 +1,23 @@
asyncio-atexit==1.0.1
autobahn==23.1.2
black==25.1.0
build==1.3.0
flake8==7.3.0
mypy==1.17.1
objgraph==3.6.2
Pillow==11.3.0
pixelmatch==0.3.0
pre-commit==3.5.0
pyOpenSSL==26.0.0
pytest==8.4.1
pytest-asyncio==1.1.0
pytest-cov==7.1.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.20260107

61
meta.yaml Normal file
View File

@ -0,0 +1,61 @@
package:
name: playwright
version: "{{ environ.get('GIT_DESCRIBE_TAG') | replace('v', '') }}"
source:
path: .
build:
number: 0
script: "{{ PYTHON }} -m pip install . --no-deps -vv"
binary_relocation: False
missing_dso_whitelist: "*"
entry_points:
- playwright = playwright.__main__:main
requirements:
build:
- python >=3.9 # [build_platform != target_platform]
- pip # [build_platform != target_platform]
- cross-python_{{ target_platform }} # [build_platform != target_platform]
host:
- python >=3.9
- wheel
- pip
- curl
- setuptools_scm
run:
- python >=3.9
# This should be the same as the dependencies in pyproject.toml
- greenlet>=3.1.1,<4.0.0
- pyee>=13,<14
test: # [build_platform == target_platform]
files:
- scripts/example_sync.py
- scripts/example_async.py
requires:
- pip
imports:
- playwright
- playwright.sync_api
- playwright.async_api
commands:
- playwright --help
- playwright install --with-deps
- python scripts/example_sync.py
- python scripts/example_async.py
about:
home: https://github.com/microsoft/playwright-python
license: Apache-2.0
license_family: Apache
license_file: LICENSE
summary: Python version of the Playwright testing and automation library.
description: |
Playwright is a Python library to automate Chromium,
Firefox and WebKit browsers with a single API. Playwright
delivers automation that is ever-green, capable, reliable
and fast.
doc_url: https://playwright.dev/python/docs/intro/
dev_url: https://github.com/microsoft/playwright-python

19
playwright/__init__.py Normal file
View File

@ -0,0 +1,19 @@
# 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.
"""
Python package `playwright` is a Python library to automate Chromium,
Firefox and WebKit with a single API. Playwright is built to enable cross-browser
web automation that is ever-green, capable, reliable and fast.
"""

33
playwright/__main__.py Normal file
View File

@ -0,0 +1,33 @@
# 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 subprocess
import sys
from playwright._impl._driver import compute_driver_executable, get_driver_env
def main() -> None:
try:
driver_executable, driver_cli = compute_driver_executable()
completed_process = subprocess.run(
[driver_executable, driver_cli, *sys.argv[1:]], env=get_driver_env()
)
sys.exit(completed_process.returncode)
except KeyboardInterrupt:
sys.exit(130)
if __name__ == "__main__":
main()

View File

View File

@ -0,0 +1,20 @@
# 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 os
from typing import List
def get_hook_dirs() -> List[str]:
return [os.path.dirname(__file__)]

View File

@ -0,0 +1,17 @@
# 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 PyInstaller.utils.hooks import collect_data_files # type: ignore
datas = collect_data_files("playwright")

View File

@ -0,0 +1,17 @@
# 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 PyInstaller.utils.hooks import collect_data_files # type: ignore
datas = collect_data_files("playwright")

View File

@ -0,0 +1,350 @@
# 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 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
# reuse between SDKs / services. They are public and are a part of the
# stable API.
# Explicitly mark optional params as such for the documentation
# If there is at least one optional param, set total=False for better mypy handling.
class Cookie(TypedDict, total=False):
name: str
value: str
domain: str
path: str
expires: float
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.
class SetCookieParam(TypedDict, total=False):
name: str
value: str
url: Optional[str]
domain: Optional[str]
path: Optional[str]
expires: Optional[float]
httpOnly: Optional[bool]
secure: Optional[bool]
sameSite: Optional[Literal["Lax", "None", "Strict"]]
partitionKey: Optional[str]
class FloatRect(TypedDict):
x: float
y: float
width: float
height: float
class Geolocation(TypedDict, total=False):
latitude: float
longitude: float
accuracy: Optional[float]
class HttpCredentials(TypedDict, total=False):
username: str
password: str
origin: Optional[str]
send: Optional[Literal["always", "unauthorized"]]
class LocalStorageEntry(TypedDict):
name: str
value: str
class OriginState(TypedDict):
origin: str
localStorage: List[LocalStorageEntry]
class PdfMargins(TypedDict, total=False):
top: Optional[Union[str, float]]
right: Optional[Union[str, float]]
bottom: Optional[Union[str, float]]
left: Optional[Union[str, float]]
class Position(TypedDict):
x: float
y: float
class ProxySettings(TypedDict, total=False):
server: str
bypass: Optional[str]
username: Optional[str]
password: Optional[str]
class StorageState(TypedDict, total=False):
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
domainLookupEnd: float
connectStart: float
secureConnectionStart: float
connectEnd: float
requestStart: float
responseStart: float
responseEnd: float
class RequestSizes(TypedDict):
requestBodySize: int
requestHeadersSize: int
responseBodySize: int
responseHeadersSize: int
class ViewportSize(TypedDict):
width: int
height: int
class SourceLocation(TypedDict):
url: str
line: int
column: int
lineNumber: int
columnNumber: int
class WebErrorLocation(TypedDict):
url: str
line: int
column: int
class FilePayload(TypedDict):
name: str
mimeType: str
buffer: bytes
class DropPayload(TypedDict, total=False):
files: Optional[
Union[str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload]]
]
data: Optional[Dict[str, str]]
class RemoteAddr(TypedDict):
ipAddress: str
port: int
class BrowserBindResult(TypedDict):
endpoint: str
class SecurityDetails(TypedDict):
issuer: Optional[str]
protocol: Optional[str]
subjectName: Optional[str]
validFrom: Optional[float]
validTo: Optional[float]
class NameValue(TypedDict):
name: str
value: str
HeadersArray = List[NameValue]
Headers = Dict[str, str]
class ServerFilePayload(TypedDict):
name: str
mimeType: str
buffer: str
class FormField(TypedDict, total=False):
name: str
value: Optional[str]
file: Optional[ServerFilePayload]
class ExpectedTextValue(TypedDict, total=False):
string: str
regexSource: str
regexFlags: str
matchSubstring: bool
normalizeWhiteSpace: bool
ignoreCase: Optional[bool]
class FrameExpectOptions(TypedDict, total=False):
expressionArg: Any
expectedText: Optional[Sequence[ExpectedTextValue]]
expectedNumber: Optional[float]
expectedValue: Optional[Any]
useInnerText: Optional[bool]
isNot: bool
timeout: Optional[float]
pseudo: Optional[str]
class FrameExpectResult(TypedDict):
matches: bool
received: Any
log: List[str]
errorMessage: Optional[str]
AriaRole = Literal[
"alert",
"alertdialog",
"application",
"article",
"banner",
"blockquote",
"button",
"caption",
"cell",
"checkbox",
"code",
"columnheader",
"combobox",
"complementary",
"contentinfo",
"definition",
"deletion",
"dialog",
"directory",
"document",
"emphasis",
"feed",
"figure",
"form",
"generic",
"grid",
"gridcell",
"group",
"heading",
"img",
"insertion",
"link",
"list",
"listbox",
"listitem",
"log",
"main",
"marquee",
"math",
"menu",
"menubar",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"meter",
"navigation",
"none",
"note",
"option",
"paragraph",
"presentation",
"progressbar",
"radio",
"radiogroup",
"region",
"row",
"rowgroup",
"rowheader",
"scrollbar",
"search",
"searchbox",
"separator",
"slider",
"spinbutton",
"status",
"strong",
"subscript",
"superscript",
"switch",
"tab",
"table",
"tablist",
"tabpanel",
"term",
"textbox",
"time",
"timer",
"toolbar",
"tooltip",
"tree",
"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
viewportWidth: int
viewportHeight: int

View File

@ -0,0 +1,87 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pathlib
from pathlib import Path
from typing import Dict, Optional, Union, cast
from playwright._impl._connection import ChannelOwner, from_channel
from playwright._impl._helper import Error, make_dirs_for_file, patch_error_message
from playwright._impl._stream import Stream
class Artifact(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self.absolute_path = initializer["absolutePath"]
async def path_after_finished(self) -> pathlib.Path:
if self._connection.is_remote:
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",
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",
None,
)
),
)
make_dirs_for_file(path)
await stream.save_as(path)
async def failure(self) -> Optional[str]:
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",
None,
)
async def read_info_buffer(self) -> bytes:
stream = cast(
Stream,
from_channel(
await self._channel.send(
"stream",
None,
)
),
)
buffer = await stream.read_all()
return buffer
async def cancel(self) -> None: # pyright: ignore[reportIncompatibleMethodOverride]
await self._channel.send(
"cancel",
None,
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,105 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from contextlib import AbstractAsyncContextManager
from types import TracebackType
from typing import Any, Callable, Generic, Optional, Type, TypeVar, Union
from playwright._impl._impl_to_api_mapping import ImplToApiMapping, ImplWrapper
mapping = ImplToApiMapping()
T = TypeVar("T")
Self = TypeVar("Self", bound="AsyncContextManager")
class AsyncEventInfo(Generic[T]):
def __init__(self, future: "asyncio.Future[T]") -> None:
self._future = future
@property
async def value(self) -> T:
return mapping.from_maybe_impl(await self._future)
def _cancel(self) -> None:
self._future.cancel()
def is_done(self) -> bool:
return self._future.done()
class AsyncEventContextManager(Generic[T], AbstractAsyncContextManager):
def __init__(self, future: "asyncio.Future[T]") -> None:
self._event = AsyncEventInfo[T](future)
async def __aenter__(self) -> AsyncEventInfo[T]:
return self._event
async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
if exc_val:
self._event._cancel()
else:
await self._event.value
class AsyncBase(ImplWrapper):
def __init__(self, impl_obj: Any) -> None:
super().__init__(impl_obj)
self._loop = impl_obj._loop
def __str__(self) -> str:
return self._impl_obj.__str__()
def _wrap_handler(
self, handler: Union[Callable[..., Any], Any]
) -> Callable[..., None]:
if callable(handler):
return mapping.wrap_handler(handler)
return handler
def on(self, event: Any, f: Any) -> None:
"""Registers the function ``f`` to the event name ``event``."""
self._impl_obj.on(event, self._wrap_handler(f))
def once(self, event: Any, f: Any) -> None:
"""The same as ``self.on``, except that the listener is automatically
removed after being called.
"""
self._impl_obj.once(event, self._wrap_handler(f))
def remove_listener(self, event: Any, f: Any) -> None:
"""Removes the function ``f`` from ``event``."""
self._impl_obj.remove_listener(event, self._wrap_handler(f))
class AsyncContextManager(AsyncBase):
async def __aenter__(self: Self) -> Self:
return self
async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
await self.close()
async def close(self) -> None: ...

View File

@ -0,0 +1,293 @@
# 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 pathlib import Path
from types import SimpleNamespace
from typing import (
TYPE_CHECKING,
Dict,
List,
Optional,
Pattern,
Sequence,
Set,
Union,
cast,
)
from playwright._impl._api_structures import (
BrowserBindResult,
ClientCertificate,
Geolocation,
HttpCredentials,
ProxySettings,
StorageState,
ViewportSize,
)
from playwright._impl._artifact import Artifact
from playwright._impl._browser_context import BrowserContext
from playwright._impl._cdp_session import CDPSession
from playwright._impl._connection import ChannelOwner, from_channel
from playwright._impl._errors import is_target_closed_error
from playwright._impl._helper import (
ColorScheme,
Contrast,
ForcedColors,
HarContentPolicy,
HarMode,
ReducedMotion,
ServiceWorkersPolicy,
locals_to_params,
make_dirs_for_file,
)
from playwright._impl._page import Page
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._browser_type import BrowserType
class Browser(ChannelOwner):
Events = SimpleNamespace(
Context="context",
Disconnected="disconnected",
)
def __init__(
self, parent: "BrowserType", type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
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: 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"<Browser type={self._browser_type} version={self.version}>"
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)
self.emit(Browser.Events.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 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:
return self._is_connected
async def new_context(
self,
viewport: ViewportSize = None,
screen: ViewportSize = None,
noViewport: bool = None,
ignoreHTTPSErrors: bool = None,
javaScriptEnabled: bool = None,
bypassCSP: bool = None,
userAgent: str = None,
locale: str = None,
timezoneId: str = None,
geolocation: Geolocation = None,
permissions: Sequence[str] = None,
extraHTTPHeaders: Dict[str, str] = None,
offline: bool = None,
httpCredentials: HttpCredentials = None,
deviceScaleFactor: float = None,
isMobile: bool = None,
hasTouch: bool = None,
colorScheme: ColorScheme = None,
reducedMotion: ReducedMotion = None,
forcedColors: ForcedColors = None,
contrast: Contrast = None,
acceptDownloads: bool = None,
defaultBrowserType: str = None,
proxy: ProxySettings = None,
recordHarPath: Union[Path, str] = None,
recordHarOmitContent: bool = None,
recordVideoDir: Union[Path, str] = None,
recordVideoSize: ViewportSize = None,
storageState: Union[StorageState, str, Path] = None,
baseURL: str = None,
strictSelectors: bool = None,
serviceWorkers: ServiceWorkersPolicy = None,
recordHarUrlFilter: Union[Pattern[str], str] = None,
recordHarMode: HarMode = None,
recordHarContent: HarContentPolicy = None,
clientCertificates: List[ClientCertificate] = None,
) -> BrowserContext:
params = locals_to_params(locals())
assert self._browser_type is not None
await self._browser_type._prepare_browser_context_params(params)
channel = await self._channel.send("newContext", None, params)
context = cast(BrowserContext, from_channel(channel))
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(
self,
viewport: ViewportSize = None,
screen: ViewportSize = None,
noViewport: bool = None,
ignoreHTTPSErrors: bool = None,
javaScriptEnabled: bool = None,
bypassCSP: bool = None,
userAgent: str = None,
locale: str = None,
timezoneId: str = None,
geolocation: Geolocation = None,
permissions: Sequence[str] = None,
extraHTTPHeaders: Dict[str, str] = None,
offline: bool = None,
httpCredentials: HttpCredentials = None,
deviceScaleFactor: float = None,
isMobile: bool = None,
hasTouch: bool = None,
colorScheme: ColorScheme = None,
forcedColors: ForcedColors = None,
contrast: Contrast = None,
reducedMotion: ReducedMotion = None,
acceptDownloads: bool = None,
defaultBrowserType: str = None,
proxy: ProxySettings = None,
recordHarPath: Union[Path, str] = None,
recordHarOmitContent: bool = None,
recordVideoDir: Union[Path, str] = None,
recordVideoSize: ViewportSize = None,
storageState: Union[StorageState, str, Path] = None,
baseURL: str = None,
strictSelectors: bool = None,
serviceWorkers: ServiceWorkersPolicy = None,
recordHarUrlFilter: Union[Pattern[str], str] = None,
recordHarMode: HarMode = None,
recordHarContent: HarContentPolicy = None,
clientCertificates: List[ClientCertificate] = None,
) -> Page:
params = locals_to_params(locals())
async def inner() -> Page:
context = await self.new_context(**params)
page = await context.new_page()
page._owned_context = context
context._owner_page = page
return page
return await self._connection.wrap_api_call(inner, title="Create page")
async def close(self, reason: str = None) -> None:
self._close_reason = reason
try:
if self._should_close_connection_on_close:
await self._connection.stop_async()
else:
await self._channel.send("close", None, {"reason": reason})
except Exception as e:
if not is_target_closed_error(e):
raise e
@property
def version(self) -> str:
return self._initializer["version"]
async def new_browser_cdp_session(self) -> CDPSession:
return from_channel(await self._channel.send("newBrowserCDPSession", None))
async def bind(
self,
title: str,
workspaceDir: str = None,
host: str = None,
port: int = None,
) -> BrowserBindResult:
return await self._channel.send_return_as_dict(
"startServer", None, locals_to_params(locals())
)
async def unbind(self) -> None:
await self._channel.send("stopServer", None)
async def start_tracing(
self,
page: Page = None,
path: Union[str, Path] = None,
screenshots: bool = None,
categories: Sequence[str] = None,
) -> None:
params = locals_to_params(locals())
if page:
params["page"] = page._channel
if path:
self._cr_tracing_path = str(path)
params["path"] = str(path)
await self._channel.send("startTracing", None, params)
async def stop_tracing(self) -> bytes:
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:
make_dirs_for_file(self._cr_tracing_path)
with open(self._cr_tracing_path, "wb") as f:
f.write(buffer)
self._cr_tracing_path = None
return buffer

View File

@ -0,0 +1,743 @@
# 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 json
from pathlib import Path
from types import SimpleNamespace
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Literal,
Optional,
Pattern,
Sequence,
Set,
Union,
cast,
)
from playwright._impl._api_structures import (
Cookie,
Geolocation,
SetCookieParam,
StorageState,
WebErrorLocation,
)
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
from playwright._impl._frame import Frame
from playwright._impl._har_router import HarRouter
from playwright._impl._helper import (
HarContentPolicy,
HarMode,
RouteFromHarNotFoundPolicy,
RouteHandler,
RouteHandlerCallback,
TimeoutSettings,
URLMatch,
WebSocketRouteHandlerCallback,
async_readfile,
async_writefile,
locals_to_params,
parse_error,
to_impl,
)
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
from playwright._impl._waiter import Waiter
from playwright._impl._web_error import WebError
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._browser import Browser
class BrowserContext(ChannelOwner):
Events = SimpleNamespace(
# Deprecated in v1.56, never emitted anymore.
BackgroundPage="backgroundpage",
Close="close",
Console="console",
Dialog="dialog",
Download="download",
FrameAttached="frameattached",
FrameDetached="framedetached",
FrameNavigated="framenavigated",
Page="page",
PageClose="pageclose",
PageLoad="pageload",
WebError="weberror",
ServiceWorker="serviceworker",
Request="request",
Response="response",
RequestFailed="requestfailed",
RequestFinished="requestfinished",
)
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._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] = 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._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"])),
)
self._channel.on("close", lambda _: self._on_close())
self._channel.on(
"page", lambda params: self._on_page(from_channel(params["page"]))
)
self._channel.on(
"route",
lambda params: self._loop.create_task(
self._on_route(
from_channel(params.get("route")),
)
),
)
self._channel.on(
"webSocketRoute",
lambda params: self._loop.create_task(
self._on_web_socket_route(
from_channel(params["webSocketRoute"]),
)
),
)
self._channel.on(
"serviceWorker",
lambda params: self._on_service_worker(from_channel(params["worker"])),
)
self._channel.on(
"console",
lambda event: self._on_console_message(event),
)
self._channel.on(
"dialog", lambda params: self._on_dialog(from_channel(params["dialog"]))
)
self._channel.on(
"pageError",
lambda params: self._on_page_error(
parse_error(params["error"]["error"]),
from_nullable_channel(params["page"]),
cast(
WebErrorLocation,
params.get("location") or {"url": "", "line": 0, "column": 0},
),
),
)
self._channel.on(
"request",
lambda params: self._on_request(
from_channel(params["request"]),
from_nullable_channel(params.get("page")),
),
)
self._channel.on(
"response",
lambda params: self._on_response(
from_channel(params["response"]),
from_nullable_channel(params.get("page")),
),
)
self._channel.on(
"requestFailed",
lambda params: self._on_request_failed(
from_channel(params["request"]),
params["responseEndTiming"],
params.get("failureText"),
from_nullable_channel(params.get("page")),
),
)
self._channel.on(
"requestFinished",
lambda params: self._on_request_finished(
from_channel(params["request"]),
from_nullable_channel(params.get("response")),
params["responseEndTiming"],
from_nullable_channel(params.get("page")),
),
)
self._closed_future: asyncio.Future = asyncio.Future()
self.once(
self.Events.Close, lambda context: self._closed_future.set_result(True)
)
self._close_reason: Optional[str] = None
self._har_routers: List[HarRouter] = []
self._set_event_to_subscription_mapping(
{
BrowserContext.Events.Console: "console",
BrowserContext.Events.Dialog: "dialog",
BrowserContext.Events.Request: "request",
BrowserContext.Events.Response: "response",
BrowserContext.Events.RequestFinished: "requestFinished",
BrowserContext.Events.RequestFailed: "requestFailed",
}
)
self._closing_or_closed = False
def __repr__(self) -> str:
return f"<BrowserContext browser={self.browser}>"
def _on_page(self, page: Page) -> None:
self._pages.append(page)
self.emit(BrowserContext.Events.Page, page)
if page._opener and not page._opener.is_closed():
page._opener.emit(Page.Events.Popup, page)
async def _on_route(self, route: Route) -> None:
route._context = self
page = route.request._safe_page()
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._closing_or_closed:
return
if not route_handler.matches(route.request.url):
continue
if route_handler not in self._routes:
continue
if route_handler.will_expire:
self._routes.remove(route_handler)
try:
handled = await route_handler.handle(route)
finally:
if len(self._routes) == 0:
asyncio.create_task(
self._connection.wrap_api_call(
lambda: self._update_interception_patterns(), True
)
)
if handled:
return
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._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:
return
asyncio.create_task(binding_call.call(func))
def set_default_navigation_timeout(self, timeout: float) -> None:
return self._set_default_navigation_timeout_impl(timeout)
def _set_default_navigation_timeout_impl(self, timeout: Optional[float]) -> None:
self._timeout_settings.set_default_navigation_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)
@property
def pages(self) -> List[Page]:
return self._pages.copy()
@property
def browser(self) -> Optional["Browser"]:
return self._browser
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._tracing._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", 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", None, dict(urls=urls))
async def add_cookies(self, cookies: Sequence[SetCookieParam]) -> None:
await self._channel.send("addCookies", None, dict(cookies=cookies))
async def clear_cookies(
self,
name: Union[str, Pattern[str]] = None,
domain: Union[str, Pattern[str]] = None,
path: Union[str, Pattern[str]] = None,
) -> 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
),
"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
),
"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
),
},
)
async def grant_permissions(
self, permissions: Sequence[str], origin: str = None
) -> None:
await self._channel.send("grantPermissions", None, locals_to_params(locals()))
async def clear_permissions(self) -> None:
await self._channel.send("clearPermissions", None)
async def set_geolocation(self, geolocation: Geolocation = None) -> None:
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", None, dict(headers=serialize_headers(headers))
)
async def set_offline(self, offline: bool) -> None:
await self._channel.send("setOffline", None, dict(offline=offline))
async def add_init_script(
self, script: str = None, path: Union[str, Path] = 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")
return from_channel(
await self._channel.send("addInitScript", None, dict(source=script))
)
async def expose_binding(self, name: str, callback: Callable) -> Disposable:
for page in self._pages:
if name in page._bindings:
raise Error(
f'Function "{name}" has been already registered in one of the pages'
)
if name in self._bindings:
raise Error(f'Function "{name}" has been already registered')
self._bindings[name] = callback
return from_channel(
await self._channel.send("exposeBinding", None, dict(name=name))
)
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
) -> DisposableStub:
self._routes.insert(
0,
RouteHandler(
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
) -> None:
removed = []
remaining = []
for route in self._routes:
if route.url != url or (handler and route.handler != handler):
remaining.append(route)
else:
removed.append(route)
await self._unroute_internal(removed, remaining, "default")
async def _unroute_internal(
self,
removed: List[RouteHandler],
remaining: List[RouteHandler],
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()
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:
router.dispose()
self._har_routers = []
async def unroute_all(
self, behavior: Literal["default", "ignoreErrors", "wait"] = None
) -> None:
await self._unroute_internal(self._routes, [], behavior)
self._dispose_har_routers()
async def route_from_har(
self,
har: Union[Path, str],
url: Union[Pattern[str], str] = None,
notFound: RouteFromHarNotFoundPolicy = None,
update: bool = None,
updateContent: Literal["attach", "embed"] = None,
updateMode: HarMode = None,
) -> None:
if update:
await self._tracing._record_into_har(
har=har,
page=None,
url=url,
update_content=updateContent,
update_mode=updateMode,
)
return
router = await HarRouter.create(
local_utils=self._connection.local_utils,
file=str(har),
not_found_action=notFound or "abort",
url_matcher=url,
)
self._har_routers.append(router)
await router.add_context_route(self)
async def _update_interception_patterns(self) -> None:
patterns = RouteHandler.prepare_interception_patterns(self._routes)
await self._channel.send(
"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(
self,
event: str,
predicate: Callable = None,
timeout: float = None,
) -> EventContextManagerImpl:
if timeout is None:
timeout = self._timeout_settings.timeout()
waiter = Waiter(self, f"browser_context.expect_event({event})")
waiter.reject_on_timeout(
timeout, f'Timeout {timeout}ms exceeded while waiting for event "{event}"'
)
if event != BrowserContext.Events.Close:
waiter.reject_on_event(
self, BrowserContext.Events.Close, lambda: TargetClosedError()
)
waiter.wait_for_event(self, event, predicate)
return EventContextManagerImpl(waiter.result())
def _on_close(self) -> None:
self._closing_or_closed = True
if self._browser:
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._closing_or_closed:
return
self._close_reason = reason
self._closing_or_closed = True
await self.request.dispose(reason=reason)
async def _inner_close() -> None:
await self._tracing._export_all_hars()
await self._channel._connection.wrap_api_call(_inner_close, True)
await self._channel.send("close", None, {"reason": reason})
await self._closed_future
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
if self._browser:
return self._browser._close_reason
return None
async def wait_for_event(
self, event: str, predicate: Callable = None, timeout: float = None
) -> Any:
async with self.expect_event(event, predicate, timeout) as event_info:
pass
return await event_info
def expect_console_message(
self,
predicate: Callable[[ConsoleMessage], bool] = None,
timeout: float = None,
) -> EventContextManagerImpl[ConsoleMessage]:
return self.expect_event(Page.Events.Console, predicate, timeout)
def expect_page(
self,
predicate: Callable[[Page], bool] = None,
timeout: float = None,
) -> EventContextManagerImpl[Page]:
return self.expect_event(BrowserContext.Events.Page, predicate, timeout)
def _on_service_worker(self, worker: Worker) -> None:
worker._context = self
self._service_workers.add(worker)
self.emit(BrowserContext.Events.ServiceWorker, worker)
def _on_request_failed(
self,
request: Request,
response_end_timing: float,
failure_text: Optional[str],
page: Optional[Page],
) -> None:
request._failure_text = failure_text
request._set_response_end_timing(response_end_timing)
self.emit(BrowserContext.Events.RequestFailed, request)
if page:
page.emit(Page.Events.RequestFailed, request)
def _on_request_finished(
self,
request: Request,
response: Optional[Response],
response_end_timing: float,
page: Optional[Page],
) -> None:
request._set_response_end_timing(response_end_timing)
self.emit(BrowserContext.Events.RequestFinished, request)
if page:
page.emit(Page.Events.RequestFinished, request)
if response:
response._finished_future.set_result(True)
def _on_console_message(self, event: Dict) -> None:
message = ConsoleMessage(event, self._loop, self._dispatcher_fiber)
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)
page = dialog.page
if page:
has_listeners = page.emit(Page.Events.Dialog, dialog) or has_listeners
if not has_listeners:
# Although we do similar handling on the server side, we still need this logic
# on the client side due to a possible race condition between two async calls:
# a) removing "dialog" listener subscription (client->server)
# b) actual "dialog" event (server->client)
if dialog.type == "beforeunload":
asyncio.create_task(dialog.accept())
else:
asyncio.create_task(dialog.dismiss())
def _on_page_error(
self, error: Error, page: Optional[Page], location: WebErrorLocation
) -> None:
self.emit(
BrowserContext.Events.WebError,
WebError(self._loop, self._dispatcher_fiber, page, error, location),
)
if page:
page.emit(Page.Events.PageError, error)
def _on_request(self, request: Request, page: Optional[Page]) -> None:
self.emit(BrowserContext.Events.Request, request)
if page:
page.emit(Page.Events.Request, request)
def _on_response(self, response: Response, page: Optional[Page]) -> None:
self.emit(BrowserContext.Events.Response, response)
if page:
page.emit(Page.Events.Response, response)
@property
def background_pages(self) -> List[Page]:
return []
@property
def service_workers(self) -> List[Worker]:
return list(self._service_workers)
async def new_cdp_session(self, page: Union[Page, Frame]) -> CDPSession:
page = to_impl(page)
params = {}
if isinstance(page, Page):
params["page"] = page._channel
elif isinstance(page, Frame):
params["frame"] = page._channel
else:
raise Error("page: expected Page or Frame")
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

View File

@ -0,0 +1,370 @@
# 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 json
import pathlib
import sys
from pathlib import Path
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
from playwright._impl._browser_context import BrowserContext
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, to_client_certificates_protocol
from playwright._impl._waiter import throw_on_timeout
if TYPE_CHECKING:
from playwright._impl._playwright import Playwright
class BrowserType(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self._playwright: "Playwright"
def __repr__(self) -> str:
return f"<BrowserType name={self.name} executable_path={self.executable_path}>"
@property
def name(self) -> str:
return self._initializer["name"]
@property
def executable_path(self) -> str:
return self._initializer["executablePath"]
async def launch(
self,
executablePath: Union[str, Path] = None,
channel: str = None,
args: Sequence[str] = None,
ignoreDefaultArgs: Union[bool, Sequence[str]] = None,
handleSIGINT: bool = None,
handleSIGTERM: bool = None,
handleSIGHUP: bool = None,
timeout: float = None,
env: Env = None,
headless: 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", TimeoutSettings.launch_timeout, params
)
),
)
browser._connect_to_browser_type(
self, str(tracesDir) if tracesDir is not None else None
)
return browser
async def launch_persistent_context(
self,
userDataDir: Union[str, Path],
channel: str = None,
executablePath: Union[str, Path] = None,
args: Sequence[str] = None,
ignoreDefaultArgs: Union[bool, Sequence[str]] = None,
handleSIGINT: bool = None,
handleSIGTERM: bool = None,
handleSIGHUP: bool = None,
timeout: float = None,
env: Env = None,
headless: bool = None,
proxy: ProxySettings = None,
downloadsPath: Union[str, Path] = None,
slowMo: float = None,
viewport: ViewportSize = None,
screen: ViewportSize = None,
noViewport: bool = None,
ignoreHTTPSErrors: bool = None,
javaScriptEnabled: bool = None,
bypassCSP: bool = None,
userAgent: str = None,
locale: str = None,
timezoneId: str = None,
geolocation: Geolocation = None,
permissions: Sequence[str] = None,
extraHTTPHeaders: Dict[str, str] = None,
offline: bool = None,
httpCredentials: HttpCredentials = None,
deviceScaleFactor: float = None,
isMobile: bool = None,
hasTouch: bool = None,
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,
recordHarOmitContent: bool = None,
recordVideoDir: Union[Path, str] = None,
recordVideoSize: ViewportSize = None,
baseURL: str = None,
strictSelectors: bool = None,
serviceWorkers: ServiceWorkersPolicy = None,
recordHarUrlFilter: Union[Pattern[str], str] = None,
recordHarMode: HarMode = None,
recordHarContent: HarContentPolicy = None,
clientCertificates: List[ClientCertificate] = None,
) -> BrowserContext:
userDataDir = self._user_data_dir(userDataDir)
params = locals_to_params(locals())
await self._prepare_browser_context_params(params)
normalize_launch_params(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,
)
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,
noDefaults: 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", TimeoutSettings.launch_timeout, params
)
browser = cast(Browser, from_channel(response["browser"]))
browser._connect_to_browser_type(self, None)
return browser
async def connect(
self,
endpoint: str,
timeout: float = None,
slowMo: float = None,
headers: Dict[str, str] = None,
exposeNetwork: str = None,
) -> Browser:
if slowMo is None:
slowMo = 0
headers = {**(headers if headers else {}), "x-playwright-browser": self.name}
local_utils = self._connection.local_utils
pipe_channel = (
await local_utils._channel.send_return_as_dict(
"connect",
None,
{
"endpoint": endpoint,
"headers": headers,
"slowMo": slowMo,
"timeout": timeout if timeout is not None else 0,
"exposeNetwork": exposeNetwork,
},
)
)["pipe"]
transport = JsonPipeTransport(self._connection._loop, pipe_channel)
connection = Connection(
self._connection._dispatcher_fiber,
self._connection._object_factory,
transport,
self._connection._loop,
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 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,
)
if not playwright_future.done():
playwright_future.cancel()
if not timeout_future.done():
timeout_future.cancel()
playwright: "Playwright" = next(iter(done)).result()
playwright._set_selectors(self._playwright.selectors)
self._connection._child_ws_connections.append(connection)
pre_launched_browser = playwright._initializer.get("preLaunchedBrowser")
assert pre_launched_browser
browser = cast(Browser, from_channel(pre_launched_browser))
browser._should_close_connection_on_close = True
browser._connect_to_browser_type(self, None)
return browser
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
)
# 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:
if "env" in params:
params["env"] = [
{"name": name, "value": str(value)}
for [name, value] in params["env"].items()
]
if "ignoreDefaultArgs" in params:
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"]))

View File

@ -0,0 +1,46 @@
# 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
from playwright._impl._connection import ChannelOwner
from playwright._impl._helper import locals_to_params
class CDPSession(ChannelOwner):
Events = SimpleNamespace(
Event="event",
Close="close",
)
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self._channel.on("event", lambda params: self._on_event(params))
self._channel.on("close", lambda _: self.emit(CDPSession.Events.Close, self))
def _on_event(self, params: Any) -> None:
self.emit(params["method"], params.get("params"))
self.emit(CDPSession.Events.Event, params)
async def send(self, method: str, params: Dict = None) -> Dict:
return await self._channel.send("send", None, locals_to_params(locals()))
async def detach(self) -> None:
await self._channel.send(
"detach",
None,
)

104
playwright/_impl/_clock.py Normal file
View File

@ -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}

View File

@ -0,0 +1,673 @@
# 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 collections.abc
import contextvars
import datetime
import inspect
import sys
import traceback
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Mapping,
Optional,
TypedDict,
Union,
cast,
)
from pyee import EventEmitter
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
from playwright._impl._transport import Transport
if TYPE_CHECKING:
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:
super().__init__()
self._connection = connection
self._guid = object._guid
self._object = object
self.on("error", lambda exc: self._connection._on_event_listener_error(exc))
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, timeout_calculator, params, False),
is_internal,
title,
)
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, timeout_calculator, params, True),
is_internal,
title,
)
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,
_augment_params(params, timeout_calculator),
True,
),
is_internal,
title,
)
async def _inner_send(
self,
method: str,
timeout_calculator: TimeoutCalculator,
params: Optional[Dict],
return_as_dict: bool,
) -> Any:
if self._connection._error:
error = self._connection._error
self._connection._error = None
raise error
callback = self._connection._send_message_to_server(
self._object, method, _augment_params(params, timeout_calculator)
)
done, _ = await asyncio.wait(
{
self._connection._transport.on_error_future,
callback.future,
},
return_when=asyncio.FIRST_COMPLETED,
)
if not callback.future.done():
callback.future.cancel()
result = next(iter(done)).result()
# Protocol now has named return values, assume result is one level deeper unless
# there is explicit ambiguity.
if not result:
return None
assert isinstance(result, dict)
if return_as_dict:
return result
if len(result) == 0:
return None
assert len(result) == 1
key = next(iter(result))
return result[key]
class ChannelOwner(AsyncIOEventEmitter):
def __init__(
self,
parent: Union["ChannelOwner", "Connection"],
type: str,
guid: str,
initializer: Dict,
) -> None:
super().__init__(loop=parent._loop)
self._loop: asyncio.AbstractEventLoop = parent._loop
self._dispatcher_fiber: Any = parent._dispatcher_fiber
self._type = type
self._guid: str = guid
self._connection: Connection = (
parent._connection if isinstance(parent, ChannelOwner) else parent
)
self._parent: Optional[ChannelOwner] = (
parent if isinstance(parent, ChannelOwner) else None
)
self._objects: Dict[str, "ChannelOwner"] = {}
self._channel: Channel = Channel(self._connection, self)
self._initializer = initializer
self._was_collected = False
self._connection._objects[guid] = self
if self._parent:
self._parent._objects[guid] = self
self._event_to_subscription_mapping: Dict[str, str] = {}
def _dispose(self, reason: Optional[str]) -> None:
# Clean up from parent and connection.
if self._parent:
del self._parent._objects[self._guid]
del self._connection._objects[self._guid]
self._was_collected = reason == "gc"
# Dispose all children.
for object in list(self._objects.values()):
object._dispose(reason)
self._objects.clear()
def _adopt(self, child: "ChannelOwner") -> None:
del cast("ChannelOwner", child._parent)._objects[child._guid]
self._objects[child._guid] = child
child._parent = self
def _set_event_to_subscription_mapping(self, mapping: Dict[str, str]) -> None:
self._event_to_subscription_mapping = mapping
def _update_subscription(self, event: str, enabled: bool) -> None:
protocol_event = self._event_to_subscription_mapping.get(event)
if protocol_event:
self._connection.wrap_api_call_sync(
lambda: self._channel.send_no_reply(
"updateSubscription",
None,
{"event": protocol_event, "enabled": enabled},
),
True,
)
def _add_event_handler(self, event: str, k: Any, v: Any) -> None:
if not self.listeners(event):
self._update_subscription(event, True)
super()._add_event_handler(event, k, v)
def remove_listener(self, event: str, f: Any) -> None:
super().remove_listener(event, f)
if not self.listeners(event):
self._update_subscription(event, False)
class ProtocolCallback:
def __init__(self, loop: asyncio.AbstractEventLoop, no_reply: bool = False) -> None:
self.stack_trace: traceback.StackSummary
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()
def cb(task: asyncio.Task) -> None:
if current_task:
current_task.remove_done_callback(cb)
if task.cancelled():
self.future.cancel()
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
)
)
class RootChannelOwner(ChannelOwner):
def __init__(self, connection: "Connection") -> None:
super().__init__(connection, "Root", "", {})
async def initialize(self) -> "Playwright":
return from_channel(
await self._channel.send(
"initialize",
None,
{
"sdkLanguage": "python",
},
)
)
class Connection(EventEmitter):
def __init__(
self,
dispatcher_fiber: Any,
object_factory: Callable[[ChannelOwner, str, str, Dict], ChannelOwner],
transport: Transport,
loop: asyncio.AbstractEventLoop,
local_utils: Optional["LocalUtils"] = None,
) -> None:
super().__init__()
self._dispatcher_fiber = dispatcher_fiber
self._transport = transport
self._transport.on_message = lambda msg: self.dispatch(msg)
self._waiting_for_object: Dict[str, Callable[[ChannelOwner], None]] = {}
self._last_id = 0
self._objects: Dict[str, ChannelOwner] = {}
self._callbacks: Dict[int, ProtocolCallback] = {}
self._object_factory = object_factory
self._is_sync = False
self._child_ws_connections: List["Connection"] = []
self._loop = loop
self.playwright_future: asyncio.Future["Playwright"] = loop.create_future()
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._local_utils: Optional["LocalUtils"] = local_utils
self._tracing_count = 0
self._closed_error: Optional[Exception] = None
@property
def local_utils(self) -> "LocalUtils":
assert self._local_utils
return self._local_utils
def mark_as_remote(self) -> None:
self.is_remote = True
async def run_as_sync(self) -> None:
self._is_sync = True
await self.run()
async def run(self) -> None:
self._loop = asyncio.get_running_loop()
self._root_object = RootChannelOwner(self)
async def init() -> None:
self.playwright_future.set_result(await self._root_object.initialize())
await self._transport.connect()
self._init_task = self._loop.create_task(init())
await self._transport.run()
def stop_sync(self) -> None:
self._transport.request_stop()
self._dispatcher_fiber.switch()
self._loop.run_until_complete(self._transport.wait_until_stopped())
self.cleanup()
async def stop_async(self) -> None:
self._transport.request_stop()
await self._transport.wait_until_stopped()
self.cleanup()
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")
def call_on_object_with_known_name(
self, guid: str, callback: Callable[[ChannelOwner], None]
) -> None:
self._waiting_for_object[guid] = callback
def set_is_tracing(self, is_tracing: bool) -> None:
if is_tracing:
self._tracing_count += 1
else:
self._tracing_count -= 1
def _send_message_to_server(
self, object: ChannelOwner, method: str, params: Dict, no_reply: bool = False
) -> ProtocolCallback:
if self._closed_error:
raise self._closed_error
if object._was_collected:
raise Error(
"The object has been collected to prevent unbounded heap growth."
)
self._last_id += 1
id = self._last_id
callback = ProtocolCallback(self._loop, no_reply=no_reply)
task = asyncio.current_task(self._loop)
callback.stack_trace = cast(
traceback.StackSummary,
getattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10)),
)
callback.no_reply = no_reply
stack_trace_information = cast(ParsedStackTrace, self._api_zone.get())
frames = stack_trace_information.get("frames", [])
location = (
{
"file": frames[0]["file"],
"line": frames[0]["line"],
"column": frames[0]["column"],
}
if frames
else None
)
metadata = {
"wallTime": int(datetime.datetime.now().timestamp() * 1000),
"apiName": stack_trace_information["apiName"],
"internal": not stack_trace_information["apiName"],
}
if location:
metadata["location"] = location # type: ignore
title = stack_trace_information["title"]
if title:
metadata["title"] = title
message = {
"id": id,
"guid": object._guid,
"method": method,
"params": self._replace_channels_with_guids(params),
"metadata": metadata,
}
if self._tracing_count > 0 and frames and object._guid != "localUtils":
self.local_utils.add_stack_to_tracing_no_reply(id, frames)
self._callbacks[id] = callback
self._transport.send(message)
return callback
def dispatch(self, msg: ParsedMessagePayload) -> None:
if self._closed_error:
return
id = msg.get("id")
if id:
callback = self._callbacks.pop(id)
if callback.future.cancelled():
return
# No reply messages are used to e.g. waitForEventInfo(after) which returns exceptions on page close.
# To prevent 'Future exception was never retrieved' we just ignore such messages.
if callback.no_reply:
return
error = msg.get("error")
if error and not msg.get("result"):
parsed_error = parse_error(
error["error"], format_call_log(msg.get("log")) # type: ignore
)
parsed_error._stack = "".join(callback.stack_trace.format())
callback.future.set_exception(parsed_error)
else:
result = self._replace_guids_with_channels(msg.get("result"))
callback.future.set_result(result)
return
guid = msg["guid"]
method = msg["method"]
params = msg.get("params")
if method == "__create__":
assert params
parent = self._objects[guid]
self._create_remote_object(
parent, params["type"], params["guid"], params["initializer"]
)
return
object = self._objects.get(guid)
if not object:
raise Exception(f'Cannot find object to "{method}": {guid}')
if method == "__adopt__":
child_guid = cast(Dict[str, str], params)["guid"]
child = self._objects.get(child_guid)
if not child:
raise Exception(f"Unknown new child: {child_guid}")
object._adopt(child)
return
if method == "__dispose__":
assert isinstance(params, dict)
self._objects[guid]._dispose(cast(Optional[str], params.get("reason")))
return
object = self._objects[guid]
should_replace_guids_with_channels = "jsonPipe@" not in guid
try:
if self._is_sync:
for listener in object._channel.listeners(method):
# Event handlers like route/locatorHandlerTriggered require us to perform async work.
# In order to report their potential errors to the user, we need to catch it and store it in the connection
def _done_callback(future: asyncio.Future) -> None:
exc = future.exception()
if exc:
self._on_event_listener_error(exc)
def _listener_with_error_handler_attached(params: Any) -> None:
potential_future = listener(params)
if asyncio.isfuture(potential_future):
potential_future.add_done_callback(_done_callback)
# Each event handler is a potentilly blocking context, create a fiber for each
# and switch to them in order, until they block inside and pass control to each
# other and then eventually back to dispatcher as listener functions return.
g = EventGreenlet(_listener_with_error_handler_attached)
if should_replace_guids_with_channels:
g.switch(self._replace_guids_with_channels(params))
else:
g.switch(params)
else:
if should_replace_guids_with_channels:
object._channel.emit(
method, self._replace_guids_with_channels(params)
)
else:
object._channel.emit(method, params)
except BaseException as exc:
self._on_event_listener_error(exc)
def _on_event_listener_error(self, exc: BaseException) -> None:
print("Error occurred in event listener", file=sys.stderr)
traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr)
# Save the error to throw at the next API call. This "replicates" unhandled rejection in Node.js.
self._error = exc
def _create_remote_object(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> ChannelOwner:
initializer = self._replace_guids_with_channels(initializer)
result = self._object_factory(parent, type, guid, initializer)
if guid in self._waiting_for_object:
self._waiting_for_object.pop(guid)(result)
return result
def _replace_channels_with_guids(
self,
payload: Any,
) -> Any:
if payload is None:
return payload
if isinstance(payload, Path):
return str(payload)
if isinstance(payload, collections.abc.Sequence) and not isinstance(
payload, str
):
return list(map(self._replace_channels_with_guids, payload))
if isinstance(payload, Channel):
return dict(guid=payload._guid)
if isinstance(payload, dict):
result = {}
for key, value in payload.items():
result[key] = self._replace_channels_with_guids(value)
return result
return payload
def _replace_guids_with_channels(self, payload: Any) -> Any:
if payload is None:
return payload
if isinstance(payload, list):
return list(map(self._replace_guids_with_channels, payload))
if isinstance(payload, dict):
if payload.get("guid") in self._objects:
return self._objects[payload["guid"]]._channel
result = {}
for key, value in payload.items():
result[key] = self._replace_guids_with_channels(value)
return result
return payload
async def wrap_api_call(
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__", 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()
except Exception as error:
raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None
finally:
self._api_zone.set(None)
def wrap_api_call_sync(
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__", 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()
except Exception as error:
raise rewrite_error(error, f"{parsed_st['apiName']}: {error}") from None
finally:
self._api_zone.set(None)
def from_channel(channel: Channel) -> Any:
return channel._object
def from_nullable_channel(channel: Optional[Channel]) -> Optional[Any]:
return channel._object if channel else None
class StackFrame(TypedDict):
file: str
line: int
column: int
function: Optional[str]
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, 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 = ""
if "self" in frame[0].f_locals:
method_name = frame[0].f_locals["self"].__class__.__name__ + "."
method_name += frame[0].f_code.co_name
if not is_playwright_internal:
parsed_frames.append(
{
"file": frame.filename,
"line": frame.lineno,
"column": 0,
"function": method_name,
}
)
if is_playwright_internal:
last_internal_api_name = method_name
elif last_internal_api_name:
api_name = last_internal_api_name
last_internal_api_name = ""
if not api_name:
api_name = last_internal_api_name
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:
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:
if not log:
return ""
if len(list(filter(lambda x: x.strip(), log))) == 0:
return ""
return "\nCall log:\n" + "\n".join(log) + "\n"

View File

@ -0,0 +1,98 @@
# 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 asyncio import AbstractEventLoop
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
from playwright._impl._js_handle import JSHandle
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._page import Page
from playwright._impl._worker import Worker
class ConsoleMessage:
def __init__(
self, event: Dict, loop: AbstractEventLoop, dispatcher_fiber: Any
) -> None:
self._event = event
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"<ConsoleMessage type={self.type} text={self.text}>"
def __str__(self) -> str:
return self.text
@property
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
def text(self) -> str:
return self._event["text"]
@property
def args(self) -> List[JSHandle]:
return list(map(from_channel, self._event["args"]))
@property
def location(self) -> SourceLocation:
# Wire format uses `lineNumber`/`columnNumber`; docs expose both `line`/`column`
# (legacy) and `lineNumber`/`columnNumber` (added upstream in 1.60).
loc = self._event["location"]
return {
"url": loc["url"],
"line": loc["lineNumber"],
"column": loc["columnNumber"],
"lineNumber": loc["lineNumber"],
"columnNumber": loc["columnNumber"],
}
@property
def timestamp(self) -> float:
return self._event["timestamp"]
@property
def page(self) -> Optional["Page"]:
return self._page
@property
def worker(self) -> Optional["Worker"]:
return self._worker

View File

@ -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

View File

@ -0,0 +1,63 @@
# 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 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
from playwright._impl._page import Page
class Dialog(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self._page: Optional["Page"] = from_nullable_channel(initializer.get("page"))
def __repr__(self) -> str:
return f"<Dialog type={self.type} message={self.message} default_value={self.default_value}>"
@property
def type(self) -> str:
return self._initializer["type"]
@property
def message(self) -> str:
return self._initializer["message"]
@property
def default_value(self) -> str:
return self._initializer["defaultValue"]
@property
def page(self) -> Optional["Page"]:
return self._page
async def accept(self, promptText: str = None) -> None:
await self._channel.send("accept", None, locals_to_params(locals()))
async def dismiss(self) -> None:
try:
await self._channel.send(
"dismiss",
None,
)
except Exception as e:
if is_target_closed_error(e):
return
raise

View File

@ -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 "<Disposable>"
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 "<Disposable>"

View File

@ -0,0 +1,64 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pathlib
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Union
from playwright._impl._artifact import Artifact
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._page import Page
class Download:
def __init__(
self, page: "Page", url: str, suggested_filename: str, artifact: Artifact
) -> None:
self._page = page
self._loop = page._loop
self._dispatcher_fiber = page._dispatcher_fiber
self._url = url
self._suggested_filename = suggested_filename
self._artifact = artifact
def __repr__(self) -> str:
return f"<Download url={self.url!r} suggested_filename={self.suggested_filename!r}>"
@property
def page(self) -> "Page":
return self._page
@property
def url(self) -> str:
return self._url
@property
def suggested_filename(self) -> str:
return self._suggested_filename
async def delete(self) -> None:
await self._artifact.delete()
async def failure(self) -> Optional[str]:
return await self._artifact.failure()
async def path(self) -> pathlib.Path:
return await self._artifact.path_after_finished()
async def save_as(self, path: Union[str, Path]) -> None:
await self._artifact.save_as(path)
async def cancel(self) -> None:
return await self._artifact.cancel()

View File

@ -0,0 +1,41 @@
# 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 inspect
import os
import sys
from pathlib import Path
from typing import Tuple
import playwright
from playwright._repo_version import version
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 (
os.getenv("PLAYWRIGHT_NODEJS_PATH", str(driver_path / "node.exe")),
cli_path,
)
return (os.getenv("PLAYWRIGHT_NODEJS_PATH", str(driver_path / "node")), cli_path)
def get_driver_env() -> dict:
env = os.environ.copy()
env["PW_LANG_NAME"] = "python"
env["PW_LANG_NAME_VERSION"] = f"{sys.version_info.major}.{sys.version_info.minor}"
env["PW_CLI_DISPLAY_VERSION"] = version
return env

View File

@ -0,0 +1,466 @@
# 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
import mimetypes
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Literal,
Optional,
Sequence,
Union,
cast,
)
from playwright._impl._api_structures import FilePayload, FloatRect, Position
from playwright._impl._connection import ChannelOwner, from_nullable_channel
from playwright._impl._helper import (
Error,
KeyboardModifier,
MouseButton,
async_writefile,
locals_to_params,
make_dirs_for_file,
)
from playwright._impl._js_handle import (
JSHandle,
Serializable,
parse_result,
serialize_argument,
)
from playwright._impl._set_input_files_helpers import convert_input_files
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._frame import Frame
from playwright._impl._locator import Locator
class ElementHandle(JSHandle):
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", 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", None))
async def content_frame(self) -> Optional["Frame"]:
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", None, dict(name=name))
async def text_content(self) -> Optional[str]:
return await self._channel.send("textContent", None)
async def inner_text(self) -> str:
return await self._channel.send("innerText", None)
async def inner_html(self) -> str:
return await self._channel.send("innerHTML", None)
async def is_checked(self) -> bool:
return await self._channel.send("isChecked", None)
async def is_disabled(self) -> bool:
return await self._channel.send("isDisabled", None)
async def is_editable(self) -> bool:
return await self._channel.send("isEditable", None)
async def is_enabled(self) -> bool:
return await self._channel.send("isEnabled", None)
async def is_hidden(self) -> bool:
return await self._channel.send("isHidden", None)
async def is_visible(self) -> bool:
return await self._channel.send("isVisible", None)
async def dispatch_event(self, type: str, eventInit: Dict = None) -> None:
await self._channel.send(
"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", self._frame._timeout, locals_to_params(locals())
)
async def hover(
self,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
timeout: float = None,
noWaitAfter: bool = None,
force: bool = None,
trial: bool = None,
) -> None:
await self._channel.send(
"hover", self._frame._timeout, locals_to_params(locals())
)
async def click(
self,
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,
trial: bool = None,
steps: int = None,
) -> None:
await self._channel.send(
"click", self._frame._timeout, locals_to_params(locals())
)
async def dblclick(
self,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
delay: float = None,
button: MouseButton = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
steps: int = None,
) -> None:
await self._channel.send(
"dblclick", self._frame._timeout, locals_to_params(locals())
)
async def select_option(
self,
value: Union[str, Sequence[str]] = None,
index: Union[int, Sequence[int]] = None,
label: Union[str, Sequence[str]] = None,
element: Union["ElementHandle", Sequence["ElementHandle"]] = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
) -> List[str]:
params = locals_to_params(
dict(
timeout=timeout,
force=force,
**convert_select_option_values(value, index, label, element),
)
)
return await self._channel.send("selectOption", self._frame._timeout, params)
async def tap(
self,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
) -> None:
await self._channel.send(
"tap", self._frame._timeout, locals_to_params(locals())
)
async def fill(
self,
value: str,
timeout: float = None,
noWaitAfter: bool = None,
force: bool = None,
) -> None:
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", self._frame._timeout, locals_to_params(locals())
)
async def input_value(self, timeout: float = None) -> str:
return await self._channel.send(
"inputValue", self._frame._timeout, locals_to_params(locals())
)
async def set_input_files(
self,
files: Union[
str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload]
],
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
frame = await self.owner_frame()
if not frame:
raise Error("Cannot set input files to detached element")
converted = await convert_input_files(files, frame.page.context)
await self._channel.send(
"setInputFiles",
self._frame._timeout,
{
"timeout": timeout,
**converted,
},
)
async def focus(self) -> None:
await self._channel.send("focus", None)
async def type(
self,
text: str,
delay: float = None,
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
await self._channel.send(
"type", self._frame._timeout, locals_to_params(locals())
)
async def press(
self,
key: str,
delay: float = None,
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
await self._channel.send(
"press", self._frame._timeout, locals_to_params(locals())
)
async def set_checked(
self,
checked: bool,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
) -> None:
if checked:
await self.check(
position=position,
timeout=timeout,
force=force,
trial=trial,
)
else:
await self.uncheck(
position=position,
timeout=timeout,
force=force,
trial=trial,
)
async def check(
self,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
) -> None:
await self._channel.send(
"check", self._frame._timeout, locals_to_params(locals())
)
async def uncheck(
self,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
) -> None:
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", None)
async def screenshot(
self,
timeout: float = None,
type: Literal["jpeg", "png"] = None,
path: Union[str, Path] = None,
quality: int = None,
omitBackground: bool = None,
animations: Literal["allow", "disabled"] = None,
caret: Literal["hide", "initial"] = None,
scale: Literal["css", "device"] = None,
mask: Sequence["Locator"] = None,
maskColor: str = None,
style: str = None,
) -> 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(
map(
lambda locator: (
{
"frame": locator._frame._channel,
"selector": locator._selector,
}
),
params["mask"],
)
)
encoded_binary = await self._channel.send(
"screenshot", self._frame._timeout, params
)
decoded_binary = base64.b64decode(encoded_binary)
if path:
make_dirs_for_file(path)
await async_writefile(path, decoded_binary)
return decoded_binary
async def query_selector(self, selector: str) -> Optional["ElementHandle"]:
return from_nullable_channel(
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", None, dict(selector=selector)
),
)
)
async def eval_on_selector(
self,
selector: str,
expression: str,
arg: Serializable = None,
) -> Any:
return parse_result(
await self._channel.send(
"evalOnSelector",
None,
dict(
selector=selector,
expression=expression,
arg=serialize_argument(arg),
),
)
)
async def eval_on_selector_all(
self,
selector: str,
expression: str,
arg: Serializable = None,
) -> Any:
return parse_result(
await self._channel.send(
"evalOnSelectorAll",
None,
dict(
selector=selector,
expression=expression,
arg=serialize_argument(arg),
),
)
)
async def wait_for_element_state(
self,
state: Literal[
"disabled", "editable", "enabled", "hidden", "stable", "visible"
],
timeout: float = None,
) -> None:
await self._channel.send(
"waitForElementState", self._frame._timeout, locals_to_params(locals())
)
async def wait_for_selector(
self,
selector: str,
state: Literal["attached", "detached", "hidden", "visible"] = None,
timeout: float = None,
strict: bool = None,
) -> Optional["ElementHandle"]:
return from_nullable_channel(
await self._channel.send(
"waitForSelector", self._frame._timeout, locals_to_params(locals())
)
)
def convert_select_option_values(
value: Union[str, Sequence[str]] = None,
index: Union[int, Sequence[int]] = None,
label: Union[str, Sequence[str]] = None,
element: Union["ElementHandle", Sequence["ElementHandle"]] = None,
) -> Any:
if value is None and index is None and label is None and element is None:
return {}
options: Any = None
elements: Any = None
if value is not None:
if isinstance(value, str):
value = [value]
options = (options or []) + list(map(lambda e: dict(valueOrLabel=e), value))
if index is not None:
if isinstance(index, int):
index = [index]
options = (options or []) + list(map(lambda e: dict(index=e), index))
if label is not None:
if isinstance(label, str):
label = [label]
options = (options or []) + list(map(lambda e: dict(label=e), label))
if element:
if isinstance(element, ElementHandle):
element = [element]
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}')

View File

@ -0,0 +1,60 @@
# 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.
# These are types that we use in the API. They are public and are a part of the
# stable API.
from typing import Optional
def is_target_closed_error(error: Exception) -> bool:
return isinstance(error, TargetClosedError)
class Error(Exception):
def __init__(self, message: str) -> None:
self._message = message
self._name: Optional[str] = None
self._stack: Optional[str] = None
super().__init__(message)
@property
def message(self) -> str:
return self._message
@property
def name(self) -> Optional[str]:
return self._name
@property
def stack(self) -> Optional[str]:
return self._stack
class TimeoutError(Error):
pass
class TargetClosedError(Error):
def __init__(self, message: str = None) -> None:
super().__init__(message or "Target page, context or browser has been closed")
def rewrite_error(error: Exception, message: str) -> Exception:
rewritten_exc = type(error)(message)
if isinstance(rewritten_exc, Error) and isinstance(error, Error):
rewritten_exc._name = error.name
rewritten_exc._stack = error.stack
return rewritten_exc

View File

@ -0,0 +1,33 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from typing import Any, Generic, TypeVar
T = TypeVar("T")
class EventContextManagerImpl(Generic[T]):
def __init__(self, future: asyncio.Future) -> None:
self._future: asyncio.Future = future
@property
def future(self) -> asyncio.Future:
return self._future
async def __aenter__(self) -> asyncio.Future:
return self._future
async def __aexit__(self, *args: Any) -> None:
await self._future

599
playwright/_impl/_fetch.py Normal file
View File

@ -0,0 +1,599 @@
# 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
import json
import mimetypes
import pathlib
import typing
from pathlib import Path
from typing import Any, Dict, List, Optional, Union, cast
import playwright._impl._network as network
from playwright._impl._api_structures import (
ClientCertificate,
FilePayload,
FormField,
Headers,
HttpCredentials,
ProxySettings,
ServerFilePayload,
StorageState,
)
from playwright._impl._connection import ChannelOwner, from_channel
from playwright._impl._errors import is_target_closed_error
from playwright._impl._form_data import FormData
from playwright._impl._helper import (
Error,
NameValue,
TargetClosedError,
TimeoutSettings,
async_readfile,
async_writefile,
is_file_payload,
locals_to_params,
object_to_array,
to_impl,
)
from playwright._impl._network import serialize_headers, to_client_certificates_protocol
from playwright._impl._tracing import Tracing
if typing.TYPE_CHECKING:
from playwright._impl._playwright import Playwright
FormType = Union[Dict[str, Union[bool, float, str]], FormData]
DataType = Union[Any, bytes, str]
MultipartType = Union[Dict[str, Union[bytes, bool, float, str, FilePayload]], FormData]
ParamsType = Union[Dict[str, Union[bool, float, str]], str]
class APIRequest:
def __init__(self, playwright: "Playwright") -> None:
self.playwright = playwright
self._loop = playwright._loop
self._dispatcher_fiber = playwright._connection._dispatcher_fiber
async def new_context(
self,
baseURL: str = None,
extraHTTPHeaders: Dict[str, str] = None,
httpCredentials: HttpCredentials = None,
ignoreHTTPSErrors: bool = None,
proxy: ProxySettings = None,
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:
storage_state = params["storageState"]
if not isinstance(storage_state, dict) and storage_state:
params["storageState"] = json.loads(
(await async_readfile(storage_state)).decode()
)
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", None, params)
),
)
context._timeout_settings.set_default_timeout(timeout)
return context
class APIRequestContext(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> 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, reason: str = None) -> None:
self._close_reason = reason
await self._tracing._export_all_hars()
try:
await self._channel.send("dispose", None, {"reason": reason})
except Error as e:
if is_target_closed_error(e):
return
raise e
self._tracing._reset_stack_counter()
@property
def tracing(self) -> Tracing:
return self._tracing
async def delete(
self,
url: str,
params: ParamsType = None,
headers: Headers = None,
data: DataType = None,
form: FormType = None,
multipart: MultipartType = None,
timeout: float = None,
failOnStatusCode: bool = None,
ignoreHTTPSErrors: bool = None,
maxRedirects: int = None,
maxRetries: int = None,
) -> "APIResponse":
return await self.fetch(
url,
method="DELETE",
params=params,
headers=headers,
data=data,
form=form,
multipart=multipart,
timeout=timeout,
failOnStatusCode=failOnStatusCode,
ignoreHTTPSErrors=ignoreHTTPSErrors,
maxRedirects=maxRedirects,
maxRetries=maxRetries,
)
async def head(
self,
url: str,
params: ParamsType = None,
headers: Headers = None,
data: DataType = None,
form: FormType = None,
multipart: MultipartType = None,
timeout: float = None,
failOnStatusCode: bool = None,
ignoreHTTPSErrors: bool = None,
maxRedirects: int = None,
maxRetries: int = None,
) -> "APIResponse":
return await self.fetch(
url,
method="HEAD",
params=params,
headers=headers,
data=data,
form=form,
multipart=multipart,
timeout=timeout,
failOnStatusCode=failOnStatusCode,
ignoreHTTPSErrors=ignoreHTTPSErrors,
maxRedirects=maxRedirects,
maxRetries=maxRetries,
)
async def get(
self,
url: str,
params: ParamsType = None,
headers: Headers = None,
data: DataType = None,
form: FormType = None,
multipart: MultipartType = None,
timeout: float = None,
failOnStatusCode: bool = None,
ignoreHTTPSErrors: bool = None,
maxRedirects: int = None,
maxRetries: int = None,
) -> "APIResponse":
return await self.fetch(
url,
method="GET",
params=params,
headers=headers,
data=data,
form=form,
multipart=multipart,
timeout=timeout,
failOnStatusCode=failOnStatusCode,
ignoreHTTPSErrors=ignoreHTTPSErrors,
maxRedirects=maxRedirects,
maxRetries=maxRetries,
)
async def patch(
self,
url: str,
params: ParamsType = None,
headers: Headers = None,
data: DataType = None,
form: FormType = None,
multipart: MultipartType = None,
timeout: float = None,
failOnStatusCode: bool = None,
ignoreHTTPSErrors: bool = None,
maxRedirects: int = None,
maxRetries: int = None,
) -> "APIResponse":
return await self.fetch(
url,
method="PATCH",
params=params,
headers=headers,
data=data,
form=form,
multipart=multipart,
timeout=timeout,
failOnStatusCode=failOnStatusCode,
ignoreHTTPSErrors=ignoreHTTPSErrors,
maxRedirects=maxRedirects,
maxRetries=maxRetries,
)
async def put(
self,
url: str,
params: ParamsType = None,
headers: Headers = None,
data: DataType = None,
form: FormType = None,
multipart: MultipartType = None,
timeout: float = None,
failOnStatusCode: bool = None,
ignoreHTTPSErrors: bool = None,
maxRedirects: int = None,
maxRetries: int = None,
) -> "APIResponse":
return await self.fetch(
url,
method="PUT",
params=params,
headers=headers,
data=data,
form=form,
multipart=multipart,
timeout=timeout,
failOnStatusCode=failOnStatusCode,
ignoreHTTPSErrors=ignoreHTTPSErrors,
maxRedirects=maxRedirects,
maxRetries=maxRetries,
)
async def post(
self,
url: str,
params: ParamsType = None,
headers: Headers = None,
data: DataType = None,
form: FormType = None,
multipart: MultipartType = None,
timeout: float = None,
failOnStatusCode: bool = None,
ignoreHTTPSErrors: bool = None,
maxRedirects: int = None,
maxRetries: int = None,
) -> "APIResponse":
return await self.fetch(
url,
method="POST",
params=params,
headers=headers,
data=data,
form=form,
multipart=multipart,
timeout=timeout,
failOnStatusCode=failOnStatusCode,
ignoreHTTPSErrors=ignoreHTTPSErrors,
maxRedirects=maxRedirects,
maxRetries=maxRetries,
)
async def fetch(
self,
urlOrRequest: Union[str, network.Request],
params: ParamsType = None,
method: str = None,
headers: Headers = None,
data: DataType = None,
form: FormType = None,
multipart: MultipartType = None,
timeout: float = None,
failOnStatusCode: bool = None,
ignoreHTTPSErrors: bool = None,
maxRedirects: int = None,
maxRetries: int = None,
) -> "APIResponse":
url = urlOrRequest if isinstance(urlOrRequest, str) else None
request = (
cast(network.Request, to_impl(urlOrRequest))
if isinstance(to_impl(urlOrRequest), network.Request)
else None
)
assert request or isinstance(
urlOrRequest, str
), "First argument must be either URL string or Request"
return await self._inner_fetch(
request,
url,
method,
headers,
data,
params,
form,
multipart,
timeout,
failOnStatusCode,
ignoreHTTPSErrors,
maxRedirects,
maxRetries,
)
async def _inner_fetch(
self,
request: Optional[network.Request],
url: Optional[str],
method: str = None,
headers: Headers = None,
data: DataType = None,
params: ParamsType = None,
form: FormType = None,
multipart: MultipartType = None,
timeout: float = None,
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.
headers_obj = headers or (request.headers if request else None)
serialized_headers = serialize_headers(headers_obj) if headers_obj else None
json_data: Any = None
form_data: Optional[List[NameValue]] = None
multipart_data: Optional[List[FormField]] = None
post_data_buffer: Optional[bytes] = None
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)
else:
post_data_buffer = data.encode()
elif isinstance(data, bytes):
post_data_buffer = data
elif isinstance(data, (dict, list, int, bool)):
json_data = json.dumps(data)
else:
raise Error(f"Unsupported 'data' type: {type(data)}")
elif form:
if isinstance(form, FormData):
form_data = []
for fd_name, fd_value in form._fields:
if isinstance(fd_value, (pathlib.Path, dict)):
raise Error(
f"Form field {fd_name!r} must be a string, number or boolean. Use 'multipart' for file uploads."
)
form_data.append(NameValue(name=fd_name, value=str(fd_value)))
else:
form_data = object_to_array(form)
elif multipart:
multipart_data = []
if isinstance(multipart, FormData):
for fd_name, fd_value in multipart._fields:
multipart_data.append(
await _form_data_field_to_form_field(fd_name, fd_value)
)
else:
# Convert file-like values to ServerFilePayload structs.
for name, value in multipart.items():
if is_file_payload(value):
payload = cast(FilePayload, value)
assert isinstance(
payload["buffer"], bytes
), f"Unexpected buffer type of 'data.{name}'"
multipart_data.append(
FormField(name=name, file=file_payload_to_json(payload))
)
elif isinstance(value, str):
multipart_data.append(FormField(name=name, value=value))
if (
post_data_buffer is None
and json_data is None
and form_data is None
and multipart_data is None
):
post_data_buffer = request.post_data_buffer if request else None
post_data = (
base64.b64encode(post_data_buffer).decode() if post_data_buffer else None
)
response = await self._channel.send(
"fetch",
self._timeout_settings.timeout,
{
"url": url,
"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,
"failOnStatusCode": failOnStatusCode,
"ignoreHTTPSErrors": ignoreHTTPSErrors,
"maxRedirects": maxRedirects,
"maxRetries": maxRetries,
},
)
return APIResponse(self, response)
async def storage_state(
self,
path: Union[pathlib.Path, str] = 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
def file_payload_to_json(payload: FilePayload) -> ServerFilePayload:
return ServerFilePayload(
name=payload["name"],
mimeType=payload["mimeType"],
buffer=base64.b64encode(payload["buffer"]).decode(),
)
async def _form_data_field_to_form_field(name: str, value: Any) -> FormField:
if isinstance(value, pathlib.Path):
mime_type, _ = mimetypes.guess_type(str(value))
return FormField(
name=name,
file=ServerFilePayload(
name=value.name,
mimeType=mime_type or "application/octet-stream",
buffer=base64.b64encode(await async_readfile(str(value))).decode(),
),
)
if is_file_payload(value):
payload = cast(FilePayload, value)
assert isinstance(
payload["buffer"], bytes
), f"Unexpected buffer type of form field {name!r}"
return FormField(name=name, file=file_payload_to_json(payload))
if isinstance(value, (str, int, float, bool)):
return FormField(name=name, value=str(value))
raise Error(f"Unsupported form field {name!r} value type: {type(value).__name__}")
class APIResponse:
def __init__(self, context: APIRequestContext, initializer: Dict) -> None:
self._loop = context._loop
self._dispatcher_fiber = context._connection._dispatcher_fiber
self._request = context
self._initializer = initializer
self._headers = network.RawHeaders(initializer["headers"])
def __repr__(self) -> str:
return f"<APIResponse url={self.url!r} status={self.status!r} status_text={self.status_text!r}>"
@property
def ok(self) -> bool:
return self.status >= 200 and self.status <= 299
@property
def url(self) -> str:
return self._initializer["url"]
@property
def status(self) -> int:
return self._initializer["status"]
@property
def status_text(self) -> str:
return self._initializer["statusText"]
@property
def headers(self) -> Headers:
return self._headers.headers()
@property
def headers_array(self) -> network.HeadersArray:
return self._headers.headers_array()
async def body(self) -> bytes:
try:
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")
return base64.b64decode(result["binary"])
except Error as exc:
if is_target_closed_error(exc):
raise Error("Response has been disposed")
raise exc
async def text(self) -> str:
content = await self.body()
return content.decode()
async def json(self) -> Any:
content = await self.text()
return json.loads(content)
async def dispose(self) -> None:
await self._request._channel.send(
"disposeAPIResponse",
None,
{
"fetchUid": self._fetch_uid,
},
)
@property
def _fetch_uid(self) -> str:
return self._initializer["fetchUid"]
async def _fetch_log(self) -> List[str]:
return await self._request._channel.send(
"fetchLog",
None,
{
"fetchUid": self._fetch_uid,
},
)
def is_json_content_type(headers: network.HeadersArray = None) -> bool:
if not headers:
return False
for header in headers:
if header["name"] == "Content-Type":
return header["value"].startswith("application/json")
return False
def is_json_parsable(value: Any) -> bool:
if not isinstance(value, str):
return False
try:
json.loads(value)
return True
except json.JSONDecodeError:
return False

View File

@ -0,0 +1,57 @@
# 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 pathlib import Path
from typing import TYPE_CHECKING, Sequence, Union
from playwright._impl._api_structures import FilePayload
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._element_handle import ElementHandle
from playwright._impl._page import Page
class FileChooser:
def __init__(
self, page: "Page", element_handle: "ElementHandle", is_multiple: bool
) -> None:
self._page = page
self._loop = page._loop
self._dispatcher_fiber = page._dispatcher_fiber
self._element_handle = element_handle
self._is_multiple = is_multiple
def __repr__(self) -> str:
return f"<FileChooser page={self._page} element={self._element_handle}>"
@property
def page(self) -> "Page":
return self._page
@property
def element(self) -> "ElementHandle":
return self._element_handle
def is_multiple(self) -> bool:
return self._is_multiple
async def set_files(
self,
files: Union[
str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload]
],
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
await self._element_handle.set_input_files(files, timeout, noWaitAfter)

View File

@ -0,0 +1,34 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pathlib
from typing import List, Tuple, Union
from playwright._impl._api_structures import FilePayload
FormDataValue = Union[bool, float, str, pathlib.Path, FilePayload]
class FormData:
def __init__(self) -> None:
self._fields: List[Tuple[str, FormDataValue]] = []
def set(self, name: str, value: FormDataValue) -> "FormData":
self._fields = [(n, v) for (n, v) in self._fields if n != name]
self._fields.append((name, value))
return self
def append(self, name: str, value: FormDataValue) -> "FormData":
self._fields.append((name, value))
return self

952
playwright/_impl/_frame.py Normal file
View File

@ -0,0 +1,952 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Literal,
Optional,
Pattern,
Sequence,
Set,
Union,
cast,
)
from pyee import EventEmitter
from playwright._impl._api_structures import (
AriaRole,
DropPayload,
FilePayload,
FrameExpectOptions,
FrameExpectResult,
Position,
)
from playwright._impl._connection import (
ChannelOwner,
from_channel,
from_nullable_channel,
)
from playwright._impl._element_handle import ElementHandle, convert_select_option_values
from playwright._impl._errors import Error
from playwright._impl._event_context_manager import EventContextManagerImpl
from playwright._impl._helper import (
DocumentLoadState,
FrameNavigatedEvent,
KeyboardModifier,
MouseButton,
TimeoutSettings,
URLMatch,
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 (
FrameLocator,
Locator,
get_by_alt_text_selector,
get_by_label_selector,
get_by_placeholder_selector,
get_by_role_selector,
get_by_test_id_selector,
get_by_text_selector,
get_by_title_selector,
test_id_attribute_name,
)
from playwright._impl._network import Response
from playwright._impl._set_input_files_helpers import convert_input_files
from playwright._impl._waiter import Waiter
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._page import Page
class Frame(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self._parent_frame = from_nullable_channel(initializer.get("parentFrame"))
if self._parent_frame:
self._parent_frame._child_frames.append(self)
self._name = initializer["name"]
self._url = initializer["url"]
self._detached = False
self._child_frames: List[Frame] = []
self._page: Optional[Page] = None
self._load_states: Set[str] = set(initializer["loadStates"])
self._event_emitter = EventEmitter()
self._channel.on(
"loadstate",
lambda params: self._on_load_state(params.get("add"), params.get("remove")),
)
self._channel.on(
"navigated",
lambda params: self._on_frame_navigated(params),
)
def __repr__(self) -> str:
return f"<Frame name={self.name} url={self.url!r}>"
def _on_load_state(
self, add: DocumentLoadState = None, remove: DocumentLoadState = None
) -> None:
if add:
self._load_states.add(add)
self._event_emitter.emit("loadstate", add)
elif remove and remove in self._load_states:
self._load_states.remove(remove)
if not self._parent_frame and add == "load" and self._page:
self._page.emit("load", self._page)
self._page.context.emit("pageload", self._page)
if not self._parent_frame and add == "domcontentloaded" and self._page:
self._page.emit("domcontentloaded", self._page)
def _on_frame_navigated(self, event: FrameNavigatedEvent) -> None:
self._url = event["url"]
self._name = event["name"]
self._event_emitter.emit("navigated", event)
if "error" not in event and self._page:
self._page.emit("framenavigated", self)
self._page.context.emit("framenavigated", self)
async def _query_count(self, selector: str) -> int:
return await self._channel.send("queryCount", None, {"selector": selector})
@property
def page(self) -> "Page":
assert self._page
return self._page
async def goto(
self,
url: str,
timeout: float = None,
waitUntil: DocumentLoadState = None,
referer: str = None,
) -> Optional[Response]:
return cast(
Optional[Response],
from_nullable_channel(
await self._channel.send(
"goto", self._navigation_timeout, locals_to_params(locals())
)
),
)
def _setup_navigation_waiter(self, wait_name: str, timeout: float = None) -> Waiter:
assert self._page
waiter = Waiter(self._page, f"frame.{wait_name}")
waiter.reject_on_event(
self._page,
"close",
lambda: cast("Page", self._page)._close_error_with_reason(),
)
waiter.reject_on_event(
self._page, "crash", Error("Navigation failed because page crashed!")
)
waiter.reject_on_event(
self._page,
"framedetached",
Error("Navigating frame was detached!"),
lambda frame: frame == self,
)
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,
waitUntil: DocumentLoadState = None,
timeout: float = None,
) -> EventContextManagerImpl[Response]:
assert self._page
if not waitUntil:
waitUntil = "load"
if timeout is None:
timeout = self._page._timeout_settings.navigation_timeout()
deadline = monotonic_time() + timeout
waiter = self._setup_navigation_waiter("expect_navigation", timeout)
to_url = f' to "{url}"' if url else ""
waiter.log(f"waiting for navigation{to_url} until '{waitUntil}'")
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 url_matches(
cast("Page", self._page)._browser_context._base_url,
event["url"],
url,
)
waiter.wait_for_event(
self._event_emitter,
"navigated",
predicate=predicate,
)
async def continuation() -> Optional[Response]:
event = await waiter.result()
if "error" in event:
raise Error(event["error"])
if waitUntil not in self._load_states:
t = deadline - monotonic_time()
if t > 0:
await self._wait_for_load_state_impl(state=waitUntil, timeout=t)
if "newDocument" in event and "request" in event["newDocument"]:
request = from_channel(event["newDocument"]["request"])
return await request.response()
return None
return EventContextManagerImpl(asyncio.create_task(continuation()))
async def wait_for_url(
self,
url: URLMatch,
waitUntil: DocumentLoadState = None,
timeout: float = None,
) -> None:
assert self._page
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(
url=url, waitUntil=waitUntil, timeout=timeout
):
pass
async def wait_for_load_state(
self,
state: Literal["domcontentloaded", "load", "networkidle"] = None,
timeout: float = None,
) -> None:
return await self._wait_for_load_state_impl(state, timeout)
async def _wait_for_load_state_impl(
self, state: DocumentLoadState = None, timeout: float = None
) -> None:
if not state:
state = "load"
if state not in ("load", "domcontentloaded", "networkidle", "commit"):
raise Error(
"state: expected one of (load|domcontentloaded|networkidle|commit)"
)
waiter = self._setup_navigation_waiter("wait_for_load_state", timeout)
if state in self._load_states:
waiter.log(f' not waiting, "{state}" event already fired')
# TODO: align with upstream
waiter._fulfill(None)
else:
def handle_load_state_event(actual_state: str) -> bool:
waiter.log(f'"{actual_state}" event fired')
return actual_state == state
waiter.wait_for_event(
self._event_emitter,
"loadstate",
handle_load_state_event,
)
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", 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),
),
)
)
async def evaluate_handle(
self, expression: str, arg: Serializable = None
) -> JSHandle:
return from_channel(
await self._channel.send(
"evaluateExpressionHandle",
None,
dict(
expression=expression,
arg=serialize_argument(arg),
),
)
)
async def query_selector(
self, selector: str, strict: bool = None
) -> Optional[ElementHandle]:
return from_nullable_channel(
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", None, dict(selector=selector)
),
)
)
async def wait_for_selector(
self,
selector: str,
strict: bool = None,
timeout: float = None,
state: Literal["attached", "detached", "hidden", "visible"] = None,
) -> Optional[ElementHandle]:
return from_nullable_channel(
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", 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", 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", 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", self._timeout, 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) -> bool:
return await self._channel.send(
"isVisible", self._timeout, locals_to_params(locals())
)
async def dispatch_event(
self,
selector: str,
type: str,
eventInit: Dict = None,
strict: bool = None,
timeout: float = None,
) -> None:
await self._channel.send(
"dispatchEvent",
self._timeout,
locals_to_params(
dict(
selector=selector,
type=type,
eventInit=serialize_argument(eventInit),
strict=strict,
timeout=timeout,
),
),
)
async def eval_on_selector(
self,
selector: str,
expression: str,
arg: Serializable = None,
strict: bool = None,
) -> Any:
return parse_result(
await self._channel.send(
"evalOnSelector",
None,
locals_to_params(
dict(
selector=selector,
expression=expression,
arg=serialize_argument(arg),
strict=strict,
)
),
)
)
async def eval_on_selector_all(
self,
selector: str,
expression: str,
arg: Serializable = None,
) -> Any:
return parse_result(
await self._channel.send(
"evalOnSelectorAll",
None,
dict(
selector=selector,
expression=expression,
arg=serialize_argument(arg),
),
)
)
async def content(self) -> str:
return await self._channel.send("content", None)
async def set_content(
self,
html: str,
timeout: float = None,
waitUntil: DocumentLoadState = None,
) -> None:
await self._channel.send(
"setContent", self._navigation_timeout, locals_to_params(locals())
)
@property
def name(self) -> str:
return self._name or ""
@property
def url(self) -> str:
return self._url or ""
@property
def parent_frame(self) -> Optional["Frame"]:
return self._parent_frame
@property
def child_frames(self) -> List["Frame"]:
return self._child_frames.copy()
def is_detached(self) -> bool:
return self._detached
async def add_script_tag(
self,
url: str = None,
path: Union[str, Path] = None,
content: str = None,
type: str = None,
) -> ElementHandle:
params = locals_to_params(locals())
if path:
params["content"] = add_source_url_to_script(
(await async_readfile(path)).decode(), path
)
del params["path"]
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
) -> ElementHandle:
params = locals_to_params(locals())
if path:
params["content"] = (
(await async_readfile(path)).decode()
+ "\n/*# sourceURL="
+ str(Path(path))
+ "*/"
)
del params["path"]
return from_channel(await self._channel.send("addStyleTag", None, params))
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,
) -> None:
await self._click(**locals_to_params(locals()))
async def _click(
self,
selector: str,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
delay: float = None,
button: MouseButton = None,
clickCount: int = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
strict: bool = None,
trial: bool = None,
steps: int = None,
) -> None:
await self._channel.send("click", self._timeout, locals_to_params(locals()))
async def dblclick(
self,
selector: str,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
delay: float = None,
button: MouseButton = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
strict: bool = None,
trial: bool = None,
) -> None:
await self._channel.send(
"dblclick", self._timeout, locals_to_params(locals()), title="Double click"
)
async def tap(
self,
selector: str,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
strict: bool = None,
trial: bool = None,
) -> None:
await self._channel.send("tap", self._timeout, locals_to_params(locals()))
async def fill(
self,
selector: str,
value: str,
timeout: float = None,
noWaitAfter: bool = None,
strict: bool = None,
force: bool = None,
) -> None:
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,
selector: str,
hasText: Union[str, Pattern[str]] = None,
hasNotText: Union[str, Pattern[str]] = None,
has: Locator = None,
hasNot: Locator = None,
) -> Locator:
return Locator(
self,
selector,
has_text=hasText,
has_not_text=hasNotText,
has=has,
has_not=hasNot,
)
def get_by_alt_text(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_alt_text_selector(text, exact=exact))
def get_by_label(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_label_selector(text, exact=exact))
def get_by_placeholder(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_placeholder_selector(text, exact=exact))
def get_by_role(
self,
role: AriaRole,
checked: bool = None,
disabled: bool = None,
expanded: bool = None,
includeHidden: bool = None,
level: int = None,
name: Union[str, Pattern[str]] = None,
pressed: bool = None,
selected: bool = None,
exact: bool = None,
description: Union[str, Pattern[str]] = None,
) -> "Locator":
return self.locator(
get_by_role_selector(
role,
checked=checked,
disabled=disabled,
expanded=expanded,
includeHidden=includeHidden,
level=level,
name=name,
pressed=pressed,
selected=selected,
exact=exact,
description=description,
)
)
def get_by_test_id(self, testId: Union[str, Pattern[str]]) -> "Locator":
return self.locator(get_by_test_id_selector(test_id_attribute_name(), testId))
def get_by_text(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_text_selector(text, exact=exact))
def get_by_title(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_title_selector(text, exact=exact))
def frame_locator(self, selector: str) -> FrameLocator:
return FrameLocator(self, selector)
async def focus(
self, selector: str, strict: bool = None, timeout: float = None
) -> None:
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", 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", 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", 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", self._timeout, locals_to_params(locals())
)
async def hover(
self,
selector: str,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
timeout: float = None,
noWaitAfter: bool = None,
force: bool = None,
strict: bool = None,
trial: bool = None,
) -> None:
await self._channel.send("hover", self._timeout, locals_to_params(locals()))
async def drag_and_drop(
self,
source: str,
target: str,
sourcePosition: Position = None,
targetPosition: Position = None,
force: bool = None,
noWaitAfter: bool = None,
strict: bool = None,
timeout: float = None,
trial: bool = None,
steps: int = None,
) -> None:
await self._channel.send(
"dragAndDrop", self._timeout, locals_to_params(locals())
)
async def select_option(
self,
selector: str,
value: Union[str, Sequence[str]] = None,
index: Union[int, Sequence[int]] = None,
label: Union[str, Sequence[str]] = None,
element: Union["ElementHandle", Sequence["ElementHandle"]] = None,
timeout: float = None,
noWaitAfter: bool = None,
strict: bool = None,
force: bool = None,
) -> List[str]:
params = locals_to_params(
dict(
selector=selector,
timeout=timeout,
strict=strict,
force=force,
**convert_select_option_values(value, index, label, element),
)
)
return await self._channel.send("selectOption", self._timeout, params)
async def input_value(
self,
selector: str,
strict: bool = None,
timeout: float = None,
) -> str:
return await self._channel.send(
"inputValue", self._timeout, locals_to_params(locals())
)
async def set_input_files(
self,
selector: str,
files: Union[
str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload]
],
strict: bool = None,
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
converted = await convert_input_files(files, self.page.context)
await self._channel.send(
"setInputFiles",
self._timeout,
{
"selector": selector,
"strict": strict,
"timeout": self._timeout(timeout),
**converted,
},
)
async def _drop(
self,
selector: str,
payload: "DropPayload",
strict: bool = None,
position: Position = None,
timeout: float = None,
) -> None:
params: Dict[str, Any] = {
"selector": selector,
"strict": strict,
"position": position,
"timeout": self._timeout(timeout),
}
files = payload.get("files") if payload else None
if files is not None:
converted = await convert_input_files(files, self.page.context)
if "directoryStream" in converted or "directoryLocalPath" in converted:
raise Error(
"Dropping a directory is not supported, pass individual files instead."
)
params.update(converted)
data = payload.get("data") if payload else None
if data is not None:
params["data"] = [{"mimeType": k, "value": v} for k, v in data.items()]
await self._channel.send("drop", self._timeout, params)
async def type(
self,
selector: str,
text: str,
delay: float = None,
strict: bool = None,
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
await self._channel.send("type", self._timeout, locals_to_params(locals()))
async def press(
self,
selector: str,
key: str,
delay: float = None,
strict: bool = None,
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
await self._channel.send("press", self._timeout, locals_to_params(locals()))
async def check(
self,
selector: str,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
strict: bool = None,
trial: bool = None,
) -> None:
await self._channel.send("check", self._timeout, locals_to_params(locals()))
async def uncheck(
self,
selector: str,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
strict: bool = None,
trial: bool = None,
) -> None:
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", None, {"waitTimeout": timeout})
async def wait_for_function(
self,
expression: str,
arg: Serializable = None,
timeout: float = None,
polling: Union[float, Literal["raf"]] = None,
) -> JSHandle:
if isinstance(polling, str) and polling != "raf":
raise Error(f"Unknown polling option: {polling}")
params = locals_to_params(locals())
params["arg"] = serialize_argument(arg)
if polling is not None and polling != "raf":
params["pollingInterval"] = polling
return from_channel(
await self._channel.send("waitForFunction", self._timeout, params)
)
async def title(self) -> str:
return await self._channel.send("title", None)
async def set_checked(
self,
selector: str,
checked: bool,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
strict: bool = None,
trial: bool = None,
) -> None:
if checked:
await self.check(
selector=selector,
position=position,
timeout=timeout,
force=force,
strict=strict,
trial=trial,
)
else:
await self.uncheck(
selector=selector,
position=position,
timeout=timeout,
force=force,
strict=strict,
trial=trial,
)
async def _highlight(self, selector: str, style: str = None) -> None:
await self._channel.send(
"highlight", None, {"selector": selector, "style": style}
)
async def _hide_highlight(self, selector: str) -> None:
await self._channel.send("hideHighlight", None, {"selector": selector})

65
playwright/_impl/_glob.py Normal file
View File

@ -0,0 +1,65 @@
# 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.
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
escaped_chars = {"$", "^", "+", ".", "*", "(", ")", "|", "\\", "?", "{", "}", "[", "]"}
def glob_to_regex_pattern(glob: str) -> str:
tokens = ["^"]
in_group = False
i = 0
while i < len(glob):
c = glob[i]
if c == "\\" and i + 1 < len(glob):
char = glob[i + 1]
tokens.append("\\" + char if char in escaped_chars else char)
i += 1
elif c == "*":
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
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 == "{":
in_group = True
tokens.append("(")
elif c == "}":
in_group = False
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 "".join(tokens)

View File

@ -0,0 +1,49 @@
# 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 os
from typing import Tuple
import greenlet
def _greenlet_trace_callback(
event: str, args: Tuple[greenlet.greenlet, greenlet.greenlet]
) -> None:
if event in ("switch", "throw"):
origin, target = args
print(f"Transfer from {origin} to {target} with {event}")
if os.environ.get("INTERNAL_PW_GREENLET_DEBUG"):
greenlet.settrace(_greenlet_trace_callback)
class MainGreenlet(greenlet.greenlet):
def __str__(self) -> str:
return "<MainGreenlet>"
class RouteGreenlet(greenlet.greenlet):
def __str__(self) -> str:
return "<RouteGreenlet>"
class LocatorHandlerGreenlet(greenlet.greenlet):
def __str__(self) -> str:
return "<LocatorHandlerGreenlet>"
class EventGreenlet(greenlet.greenlet):
def __str__(self) -> str:
return "<EventGreenlet>"

View File

@ -0,0 +1,122 @@
# 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 base64
from typing import TYPE_CHECKING, Optional, cast
from playwright._impl._api_structures import HeadersArray
from playwright._impl._helper import (
HarLookupResult,
RouteFromHarNotFoundPolicy,
URLMatch,
)
from playwright._impl._local_utils import LocalUtils
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._browser_context import BrowserContext
from playwright._impl._network import Route
from playwright._impl._page import Page
class HarRouter:
def __init__(
self,
local_utils: LocalUtils,
har_id: str,
not_found_action: RouteFromHarNotFoundPolicy,
url_matcher: Optional[URLMatch] = None,
) -> None:
self._local_utils: LocalUtils = local_utils
self._har_id: str = har_id
self._not_found_action: RouteFromHarNotFoundPolicy = not_found_action
self._options_url_match: Optional[URLMatch] = url_matcher
@staticmethod
async def create(
local_utils: LocalUtils,
file: str,
not_found_action: RouteFromHarNotFoundPolicy,
url_matcher: Optional[URLMatch] = None,
) -> "HarRouter":
har_id = await local_utils._channel.send("harOpen", None, {"file": file})
return HarRouter(
local_utils=local_utils,
har_id=har_id,
not_found_action=not_found_action,
url_matcher=url_matcher,
)
async def _handle(self, route: "Route") -> None:
request = route.request
response: HarLookupResult = await self._local_utils.har_lookup(
harId=self._har_id,
url=request.url,
method=request.method,
headers=await request.headers_array(),
postData=request.post_data_buffer,
isNavigationRequest=request.is_navigation_request(),
)
action = response["action"]
if action == "redirect":
redirect_url = response["redirectURL"]
assert redirect_url
await route._redirected_navigation_request(redirect_url)
return
if action == "fulfill":
# If the response status is -1, the request was canceled or stalled, so we just stall it here.
# See https://github.com/microsoft/playwright/issues/29311.
# TODO: it'd be better to abort such requests, but then we likely need to respect the timing,
# because the request might have been stalled for a long time until the very end of the
# test when HAR was recorded but we'd abort it immediately.
if response.get("status") == -1:
return
body = response["body"]
assert body is not None
await route.fulfill(
status=response.get("status"),
headers={
v["name"]: v["value"]
for v in cast(HeadersArray, response.get("headers", []))
},
body=base64.b64decode(body),
)
return
if action == "error":
pass
# Report the error, but fall through to the default handler.
if self._not_found_action == "abort":
await route.abort()
return
await route.fallback()
async def add_context_route(self, context: "BrowserContext") -> None:
await context.route(
url=self._options_url_match or "**/*",
handler=lambda route, _: asyncio.create_task(self._handle(route)),
)
async def add_page_route(self, page: "Page") -> None:
await page.route(
url=self._options_url_match or "**/*",
handler=lambda route, _: asyncio.create_task(self._handle(route)),
)
def dispose(self) -> None:
asyncio.create_task(
self._local_utils._channel.send("harClose", None, {"harId": self._har_id})
)

587
playwright/_impl/_helper.py Normal file
View File

@ -0,0 +1,587 @@
# 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 datetime
import math
import os
import re
import time
import traceback
from pathlib import Path
from types import TracebackType
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
Literal,
Optional,
Pattern,
Set,
Tuple,
TypedDict,
TypeVar,
Union,
cast,
)
from urllib.parse import ParseResult, urljoin, urlparse, urlunparse
from playwright._impl._api_structures import NameValue
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, WebSocketRoute
URLMatch = Union[str, Pattern[str], Callable[[str], 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", "ControlOrMeta", "Meta", "Shift"]
MouseButton = Literal["left", "middle", "right"]
ServiceWorkersPolicy = Literal["allow", "block"]
HarMode = Literal["full", "minimal"]
HarContentPolicy = Literal["attach", "embed", "omit"]
RouteFromHarNotFoundPolicy = Literal["abort", "fallback"]
class ErrorPayload(TypedDict, total=False):
message: str
name: str
stack: str
value: Optional[Any]
class HarRecordingMetadata(TypedDict, total=False):
path: str
content: Optional[HarContentPolicy]
def prepare_record_har_options(params: Dict) -> Dict[str, Any]:
out_params: Dict[str, Any] = {"path": str(params["recordHarPath"])}
if "recordHarUrlFilter" in params:
opt = params["recordHarUrlFilter"]
if isinstance(opt, str):
out_params["urlGlob"] = opt
if isinstance(opt, Pattern):
out_params["urlRegexSource"] = opt.pattern
out_params["urlRegexFlags"] = escape_regex_flags(opt)
del params["recordHarUrlFilter"]
if "recordHarMode" in params:
out_params["mode"] = params["recordHarMode"]
del params["recordHarMode"]
new_content_api = None
old_content_api = None
if "recordHarContent" in params:
new_content_api = params["recordHarContent"]
del params["recordHarContent"]
if "recordHarOmitContent" in params:
old_content_api = params["recordHarOmitContent"]
del params["recordHarOmitContent"]
content = new_content_api or ("omit" if old_content_api else None)
if content:
out_params["content"] = content
return out_params
class ParsedMessageParams(TypedDict):
type: str
guid: str
initializer: Dict
class ParsedMessagePayload(TypedDict, total=False):
id: int
guid: str
method: str
params: ParsedMessageParams
result: Any
error: ErrorPayload
class Document(TypedDict):
request: Optional[Any]
class FrameNavigatedEvent(TypedDict):
url: str
name: str
newDocument: Optional[Document]
error: Optional[str]
Env = Dict[str, Union[str, float, bool]]
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:
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 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):
action: Literal["error", "redirect", "fulfill", "noentry"]
message: Optional[str]
redirectURL: Optional[str]
status: Optional[int]
headers: Optional["HeadersArray"]
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
self._default_navigation_timeout: Optional[float] = None
def set_default_timeout(self, timeout: Optional[float]) -> None:
self._default_timeout = timeout
def timeout(self, timeout: float = None) -> float:
if timeout is not None:
return timeout
if self._default_timeout is not None:
return self._default_timeout
if self._parent:
return self._parent.timeout()
return DEFAULT_PLAYWRIGHT_TIMEOUT_IN_MILLISECONDS
def set_default_navigation_timeout(
self, navigation_timeout: Optional[float]
) -> None:
self._default_navigation_timeout = navigation_timeout
def default_navigation_timeout(self) -> Optional[float]:
return self._default_navigation_timeout
def default_timeout(self) -> Optional[float]:
return self._default_timeout
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 DEFAULT_PLAYWRIGHT_TIMEOUT_IN_MILLISECONDS
def serialize_error(ex: Exception, tb: Optional[TracebackType]) -> ErrorPayload:
return ErrorPayload(
message=str(ex), name="Error", stack="".join(traceback.format_tb(tb))
)
def parse_error(error: ErrorPayload, log: Optional[str] = None) -> Error:
base_error_class = Error
if error.get("name") == "TimeoutError":
base_error_class = TimeoutError
if error.get("name") == "TargetClosedError":
base_error_class = TargetClosedError
if not log:
log = ""
exc = base_error_class(patch_error_message(error["message"]) + log)
exc._name = error["name"]
exc._stack = error["stack"]
return exc
def patch_error_message(message: str) -> str:
match = re.match(r"(\w+)(: expected .*)", message)
if match:
message = to_snake_case(match.group(1)) + match.group(2)
message = message.replace(
"Pass { acceptDownloads: true }", "Pass 'accept_downloads=True'"
)
return message
def locals_to_params(args: Dict) -> Dict:
copy = {}
for key in args:
if key == "self":
continue
if args[key] is not None:
copy[key] = (
args[key]
if not isinstance(args[key], Dict)
else locals_to_params(args[key])
)
return copy
def monotonic_time() -> int:
return math.floor(time.monotonic() * 1000)
class RouteHandlerInvocation:
complete: "asyncio.Future"
route: "Route"
def __init__(self, complete: "asyncio.Future", route: "Route") -> None:
self.complete = complete
self.route = route
class RouteHandler:
def __init__(
self,
base_url: Optional[str],
url: URLMatch,
handler: RouteHandlerCallback,
is_sync: bool,
times: Optional[int] = None,
):
self._base_url = base_url
self.url = url
self.handler = handler
self._times = times if times else math.inf
self._handled_count = 0
self._is_sync = is_sync
self._ignore_exception = False
self._active_invocations: Set[RouteHandlerInvocation] = set()
def matches(self, request_url: str) -> bool:
return url_matches(self._base_url, request_url, self.url)
async def handle(self, route: "Route") -> bool:
handler_invocation = RouteHandlerInvocation(
asyncio.get_running_loop().create_future(), route
)
self._active_invocations.add(handler_invocation)
try:
return await self._handle_internal(route)
except Exception as e:
# 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)
self._active_invocations.remove(handler_invocation)
async def _handle_internal(self, route: "Route") -> bool:
handled_future = route._start_handling()
self._handled_count += 1
if self._is_sync:
handler_finished_future = route._loop.create_future()
def _handler() -> None:
try:
self.handler(route, route.request) # type: ignore
handler_finished_future.set_result(None)
except Exception as e:
handler_finished_future.set_exception(e)
# As with event handlers, each route handler is a potentially blocking context
# so it needs a fiber.
g = RouteGreenlet(_handler)
g.switch()
await handler_finished_future
else:
coro_or_future = self.handler(route, route.request) # type: ignore
if coro_or_future:
# separate task so that we get a proper stack trace for exceptions / tracing api_name extraction
await asyncio.ensure_future(coro_or_future)
return await handled_future
async def stop(self, behavior: Literal["ignoreErrors", "wait"]) -> None:
# When a handler is manually unrouted or its page/context is closed we either
# - wait for the current handler invocations to finish
# - or do not wait, if the user opted out of it, but swallow all exceptions
# that happen after the unroute/close.
if behavior == "ignoreErrors":
self._ignore_exception = True
else:
tasks = []
for activation in self._active_invocations:
if not activation.route._did_throw:
tasks.append(activation.complete)
await asyncio.gather(*tasks)
@property
def will_expire(self) -> bool:
return self._handled_count + 1 >= self._times
@staticmethod
def prepare_interception_patterns(
handlers: List["RouteHandler"],
) -> List[Dict[str, str]]:
patterns = []
all = 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 = True
if all:
return [{"glob": "**/*"}]
return patterns
to_snake_case_regex = re.compile("((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))")
def to_snake_case(name: str) -> str:
return to_snake_case_regex.sub(r"_\1", name).lower()
def make_dirs_for_file(path: Union[Path, str]) -> None:
if not os.path.isabs(path):
path = Path.cwd() / path
os.makedirs(os.path.dirname(path), exist_ok=True)
async def async_writefile(file: Union[str, Path], data: Union[str, bytes]) -> None:
def inner() -> None:
with open(file, "w" if isinstance(data, str) else "wb") as fh:
fh.write(data)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, inner)
async def async_readfile(file: Union[str, Path]) -> bytes:
def inner() -> bytes:
with open(file, "rb") as fh:
return fh.read()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, inner)
T = TypeVar("T")
def to_impl(obj: T) -> T:
if hasattr(obj, "_impl_obj"):
return cast(Any, obj)._impl_obj
return obj
def object_to_array(obj: Optional[Dict]) -> Optional[List[NameValue]]:
if not obj:
return None
result = []
for key, value in obj.items():
result.append(NameValue(name=key, value=str(value)))
return result
def is_file_payload(value: Optional[Any]) -> bool:
return (
isinstance(value, dict)
and "name" in value
and "mimeType" in value
and "buffer" in value
)
TEXTUAL_MIME_TYPE = re.compile(
r"^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$"
)
def is_textual_mime_type(mime_type: str) -> bool:
return bool(TEXTUAL_MIME_TYPE.match(mime_type))
def to_milliseconds(
value: Union[float, datetime.timedelta, None],
) -> Optional[float]:
if value is None:
return None
if isinstance(value, datetime.timedelta):
return value / datetime.timedelta(milliseconds=1)
return value

View File

@ -0,0 +1,142 @@
# 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 inspect
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
from playwright._impl._errors import Error
from playwright._impl._map import Map
API_ATTR = "_pw_api_instance_"
IMPL_ATTR = "_pw_impl_instance_"
class ImplWrapper:
def __init__(self, impl_obj: Any) -> None:
self._impl_obj = impl_obj
def __repr__(self) -> str:
return self._impl_obj.__repr__()
class ImplToApiMapping:
def __init__(self) -> None:
self._mapping: Dict[type, type] = {}
def register(self, impl_class: type, api_class: type) -> None:
self._mapping[impl_class] = api_class
def from_maybe_impl(
self, obj: Any, visited: Optional[Map[Any, Union[List, Dict]]] = None
) -> Any:
# Python does share default arguments between calls, so we need to
# create a new map if it is not provided.
if not visited:
visited = Map()
if not obj:
return obj
if isinstance(obj, dict):
if obj in visited:
return visited[obj]
o: Dict = {}
visited[obj] = o
for name, value in obj.items():
o[name] = self.from_maybe_impl(value, visited)
return o
if isinstance(obj, list):
if obj in visited:
return visited[obj]
a: List = []
visited[obj] = a
for item in obj:
a.append(self.from_maybe_impl(item, visited))
return a
api_class = self._mapping.get(type(obj))
if api_class:
api_instance = getattr(obj, API_ATTR, None)
if not api_instance:
api_instance = api_class(obj)
setattr(obj, API_ATTR, api_instance)
return api_instance
else:
return obj
def from_impl(self, obj: Any) -> Any:
assert obj
result = self.from_maybe_impl(obj)
assert result
return result
def from_impl_nullable(self, obj: Any = None) -> Optional[Any]:
return self.from_impl(obj) if obj else None
def from_impl_list(self, items: Sequence[Any]) -> List[Any]:
return list(map(lambda a: self.from_impl(a), items))
def from_impl_dict(self, map: Dict[str, Any]) -> Dict[str, Any]:
return {name: self.from_impl(value) for name, value in map.items()}
def to_impl(
self, obj: Any, visited: Optional[Map[Any, Union[List, Dict]]] = None
) -> Any:
if visited is None:
visited = Map()
try:
if not obj:
return obj
if isinstance(obj, dict):
if obj in visited:
return visited[obj]
o: Dict = {}
visited[obj] = o
for name, value in obj.items():
o[name] = self.to_impl(value, visited)
return o
if isinstance(obj, list):
if obj in visited:
return visited[obj]
a: List = []
visited[obj] = a
for item in obj:
a.append(self.to_impl(item, visited))
return a
if isinstance(obj, ImplWrapper):
return obj._impl_obj
return obj
except RecursionError:
raise Error("Maximum argument depth exceeded")
def wrap_handler(self, handler: Callable[..., Any]) -> Callable[..., None]:
def wrapper_func(*args: Any) -> Any:
arg_count = len(inspect.signature(handler).parameters)
return handler(
*list(map(lambda a: self.from_maybe_impl(a), args))[:arg_count]
)
if inspect.ismethod(handler):
wrapper = getattr(handler.__self__, IMPL_ATTR + handler.__name__, None)
if not wrapper:
wrapper = wrapper_func
setattr(
handler.__self__,
IMPL_ATTR + handler.__name__,
wrapper,
)
return wrapper
wrapper = getattr(handler, IMPL_ATTR, None)
if not wrapper:
wrapper = wrapper_func
setattr(handler, IMPL_ATTR, wrapper)
return wrapper

111
playwright/_impl/_input.py Normal file
View File

@ -0,0 +1,111 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from playwright._impl._connection import Channel
from playwright._impl._helper import MouseButton, locals_to_params
class Keyboard:
def __init__(self, channel: Channel) -> None:
self._channel = channel
self._loop = channel._connection._loop
self._dispatcher_fiber = channel._connection._dispatcher_fiber
async def down(self, key: str) -> None:
await self._channel.send("keyboardDown", None, locals_to_params(locals()))
async def up(self, key: str) -> None:
await self._channel.send("keyboardUp", None, locals_to_params(locals()))
async def insert_text(self, text: str) -> None:
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", None, locals_to_params(locals()))
async def press(self, key: str, delay: float = None) -> None:
await self._channel.send("keyboardPress", None, locals_to_params(locals()))
class Mouse:
def __init__(self, channel: Channel) -> None:
self._channel = channel
self._loop = channel._connection._loop
self._dispatcher_fiber = channel._connection._dispatcher_fiber
async def move(self, x: float, y: float, steps: int = None) -> None:
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", None, locals_to_params(locals()))
async def up(
self,
button: MouseButton = None,
clickCount: int = None,
) -> None:
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,
x: float,
y: float,
delay: float = None,
button: MouseButton = None,
clickCount: int = None,
) -> None:
params = locals()
del params["self"]
await self._click(**params)
async def dblclick(
self,
x: float,
y: float,
delay: float = None,
button: MouseButton = None,
) -> None:
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", None, locals_to_params(locals()))
class Touchscreen:
def __init__(self, channel: Channel) -> None:
self._channel = channel
self._loop = channel._connection._loop
self._dispatcher_fiber = channel._connection._dispatcher_fiber
async def tap(self, x: float, y: float) -> None:
await self._channel.send("touchscreenTap", None, locals_to_params(locals()))

View File

@ -0,0 +1,336 @@
# 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
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 Error, is_target_closed_error
from playwright._impl._map import Map
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._element_handle import ElementHandle
Serializable = Any
class VisitorInfo:
visited: Map[Any, int]
last_id: int
def __init__(self) -> None:
self.visited = Map()
self.last_id = 0
def visit(self, obj: Any) -> int:
assert obj not in self.visited
self.last_id += 1
self.visited[obj] = self.last_id
return self.last_id
class JSHandle(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self._preview = self._initializer["preview"]
self._channel.on(
"previewUpdated", lambda params: self._on_preview_updated(params["preview"])
)
def __repr__(self) -> str:
return f"<JSHandle preview={self._preview}>"
def __str__(self) -> str:
return self._preview
def _on_preview_updated(self, preview: str) -> None:
self._preview = preview
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),
),
)
)
async def evaluate_handle(
self, expression: str, arg: Serializable = None
) -> "JSHandle":
return from_channel(
await self._channel.send(
"evaluateExpressionHandle",
None,
dict(
expression=expression,
arg=serialize_argument(arg),
),
)
)
async def get_property(self, propertyName: str) -> "JSHandle":
return from_channel(
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",
None,
)
}
def as_element(self) -> Optional["ElementHandle"]:
return None
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 json_value(self) -> Any:
return parse_result(
await self._channel.send(
"jsonValue",
None,
)
)
def serialize_value(
value: Any, handles: List[Channel], visitor_info: Optional[VisitorInfo] = None
) -> Any:
if visitor_info is None:
visitor_info = VisitorInfo()
if isinstance(value, JSHandle):
h = len(handles)
handles.append(value._channel)
return dict(h=h)
if value is None:
return dict(v="null")
if isinstance(value, float):
if value == float("inf"):
return dict(v="Infinity")
if value == float("-inf"):
return dict(v="-Infinity")
if value == float("-0"):
return dict(v="-0")
if math.isnan(value):
return dict(v="NaN")
if isinstance(value, datetime.datetime):
# Node.js Date objects are always in UTC.
return {
"d": datetime.datetime.strftime(
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)):
return {"n": value}
if isinstance(value, str):
return {"s": value}
if isinstance(value, ParseResult):
return {"u": urlunparse(value)}
if value in visitor_info.visited:
return dict(ref=visitor_info.visited[value])
if isinstance(value, collections.abc.Sequence) and not isinstance(value, str):
id = visitor_info.visit(value)
a = []
for e in value:
a.append(serialize_value(e, handles, visitor_info))
return dict(a=a, id=id)
if isinstance(value, dict):
id = visitor_info.visit(value)
o = []
for name in value:
o.append(
{"k": name, "v": serialize_value(value[name], handles, visitor_info)}
)
return dict(o=o, id=id)
return dict(v="undefined")
def serialize_argument(arg: Serializable = None) -> Any:
handles: List[Channel] = []
value = serialize_value(arg, handles)
return dict(value=value, handles=handles)
def parse_value(value: Any, refs: Optional[Dict[int, Any]] = None) -> Any:
if refs is None:
refs = {}
if value is None:
return None
if isinstance(value, dict):
if "ref" in value:
return refs[value["ref"]]
if "v" in value:
v = value["v"]
if v == "Infinity":
return float("inf")
if v == "-Infinity":
return float("-inf")
if v == "-0":
return float("-0")
if v == "NaN":
return float("nan")
if v == "undefined":
return None
if v == "null":
return None
return v
if "u" in value:
return urlparse(value["u"])
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
for e in value["a"]:
a.append(parse_value(e, refs))
return a
if "d" in value:
# Node.js Date objects are always in UTC.
return datetime.datetime.strptime(
value["d"], "%Y-%m-%dT%H:%M:%S.%fZ"
).replace(tzinfo=datetime.timezone.utc)
if "o" in value:
o: Dict = {}
refs[value["id"]] = o
for e in value["o"]:
o[e["k"]] = parse_value(e["v"], refs)
return o
if "n" in value:
return value["n"]
if "s" in value:
return value["s"]
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
def parse_result(result: Any) -> Any:
return parse_value(result)
def add_source_url_to_script(source: str, path: Union[str, Path]) -> str:
return source + "\n//# sourceURL=" + str(path).replace("\n", "")

View File

@ -0,0 +1,77 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from 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
class JsonPipeTransport(AsyncIOEventEmitter, Transport):
def __init__(
self,
loop: asyncio.AbstractEventLoop,
pipe_channel: Channel,
) -> None:
super().__init__(loop)
Transport.__init__(self, loop)
self._stop_requested = False
self._pipe_channel = pipe_channel
def request_stop(self) -> None:
self._stop_requested = True
self._pipe_channel.send_no_reply("close", None, {})
def dispose(self) -> None:
self.on_error_future.cancel()
self._stopped_future.cancel()
async def wait_until_stopped(self) -> None:
await self._stopped_future
async def connect(self) -> None:
self._stopped_future: asyncio.Future = asyncio.Future()
def handle_message(message: Dict) -> None:
if self._stop_requested:
return
self.on_message(cast(ParsedMessagePayload, message))
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(
"message",
lambda params: handle_message(params["message"]),
)
self._pipe_channel.on(
"closed",
lambda params: handle_closed(params.get("reason")),
)
async def run(self) -> None:
await self._stopped_future
def send(self, message: Dict) -> None:
if self._stop_requested:
raise Error("Playwright connection closed")
self._pipe_channel.send_no_reply("send", None, {"message": message})

View File

@ -0,0 +1,95 @@
# 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 typing import Dict, List, Optional, cast
from playwright._impl._api_structures import HeadersArray
from playwright._impl._connection import ChannelOwner, StackFrame
from playwright._impl._helper import HarLookupResult, locals_to_params
class LocalUtils(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self.devices = {
device["name"]: parse_device_descriptor(device["descriptor"])
for device in initializer["deviceDescriptors"]
}
async def zip(self, params: Dict) -> None:
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", None, params)
async def har_lookup(
self,
harId: str,
url: str,
method: str,
headers: HeadersArray,
isNavigationRequest: bool,
postData: Optional[bytes] = None,
) -> HarLookupResult:
params = locals_to_params(locals())
if "postData" in params:
params["postData"] = base64.b64encode(params["postData"]).decode()
return cast(
HarLookupResult,
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", None, params)
async def har_unzip(self, zipFile: str, harFile: str) -> None:
params = locals_to_params(locals())
await self._channel.send("harUnzip", None, params)
async def tracing_started(
self, tracesDir: Optional[str], traceName: str, live: bool = False
) -> str:
params = locals_to_params(locals())
return await self._channel.send("tracingStarted", None, params)
async def trace_discarded(self, stacks_id: str) -> None:
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,
"id": id,
}
},
)
def parse_device_descriptor(dict: Dict) -> Dict:
return {
"user_agent": dict["userAgent"],
"viewport": dict["viewport"],
"device_scale_factor": dict["deviceScaleFactor"],
"is_mobile": dict["isMobile"],
"has_touch": dict["hasTouch"],
"default_browser_type": dict["defaultBrowserType"],
}

View File

@ -0,0 +1,996 @@
# 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 json
import pathlib
import re
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
Dict,
List,
Literal,
Optional,
Pattern,
Sequence,
Tuple,
TypeVar,
Union,
)
from playwright._impl._api_structures import (
AriaRole,
DropPayload,
FilePayload,
FloatRect,
FrameExpectOptions,
FrameExpectResult,
Position,
)
from playwright._impl._element_handle import ElementHandle
from playwright._impl._helper import (
Error,
KeyboardModifier,
MouseButton,
locals_to_params,
monotonic_time,
to_impl,
)
from playwright._impl._js_handle import Serializable
from playwright._impl._str_utils import (
escape_for_attribute_selector,
escape_for_text_selector,
)
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._frame import Frame
from playwright._impl._js_handle import JSHandle
from playwright._impl._page import Page
T = TypeVar("T")
class Locator:
def __init__(
self,
frame: "Frame",
selector: str,
has_text: Union[str, Pattern[str]] = None,
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
self._loop = frame._loop
self._dispatcher_fiber = frame._connection._dispatcher_fiber
if has_text:
self._selector += f" >> internal:has-text={escape_for_text_selector(has_text, exact=False)}"
if has:
if has._frame != frame:
raise Error('Inner "has" locator must belong to the same frame.')
self._selector += " >> internal:has=" + json.dumps(
has._selector, ensure_ascii=False
)
if has_not_text:
self._selector += f" >> internal:has-not-text={escape_for_text_selector(has_not_text, exact=False)}"
if has_not:
locator = has_not
if locator._frame != frame:
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"<Locator frame={self._frame!r} selector={self._selector!r}>"
async def _with_element(
self,
task: Callable[[ElementHandle, float], Awaitable[T]],
timeout: float = None,
) -> T:
timeout = self._frame._timeout(timeout)
deadline = (monotonic_time() + timeout) if timeout else 0
handle = await self.element_handle(timeout=timeout)
if not handle:
raise Error(f"Could not resolve {self._selector} to DOM Element")
try:
return await task(
handle,
(deadline - monotonic_time()) if deadline else 0,
)
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
async def bounding_box(self, timeout: float = None) -> Optional[FloatRect]:
return await self._with_element(
lambda h, _: h.bounding_box(),
timeout,
)
async def check(
self,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.check(self._selector, strict=True, **params)
async def click(
self,
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,
trial: bool = None,
steps: int = None,
) -> None:
params = locals_to_params(locals())
return await self._frame._click(self._selector, strict=True, **params)
async def dblclick(
self,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
delay: float = None,
button: MouseButton = None,
timeout: float = None,
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)
async def dispatch_event(
self,
type: str,
eventInit: Dict = None,
timeout: float = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.dispatch_event(self._selector, strict=True, **params)
async def evaluate(
self, expression: str, arg: Serializable = None, timeout: float = None
) -> Any:
return await self._with_element(
lambda h, _: h.evaluate(expression, arg),
timeout,
)
async def evaluate_all(self, expression: str, arg: Serializable = None) -> Any:
params = locals_to_params(locals())
return await self._frame.eval_on_selector_all(self._selector, **params)
async def evaluate_handle(
self, expression: str, arg: Serializable = None, timeout: float = None
) -> "JSHandle":
return await self._with_element(
lambda h, _: h.evaluate_handle(expression, arg), timeout
)
async def fill(
self,
value: str,
timeout: float = None,
noWaitAfter: bool = None,
force: bool = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.fill(self._selector, strict=True, **params)
async def clear(
self,
timeout: float = None,
noWaitAfter: bool = None,
force: bool = None,
) -> None:
params = locals_to_params(locals())
await self._frame._fill(self._selector, value="", title="Clear", **params)
def locator(
self,
selectorOrLocator: Union[str, "Locator"],
hasText: Union[str, Pattern[str]] = None,
hasNotText: Union[str, Pattern[str]] = None,
has: "Locator" = None,
hasNot: "Locator" = None,
) -> "Locator":
if isinstance(selectorOrLocator, str):
return Locator(
self._frame,
f"{self._selector} >> {selectorOrLocator}",
has_text=hasText,
has_not_text=hasNotText,
has_not=hasNot,
has=has,
)
selectorOrLocator = to_impl(selectorOrLocator)
if selectorOrLocator._frame != self._frame:
raise Error("Locators must belong to the same frame.")
return Locator(
self._frame,
f"{self._selector} >> internal:chain={json.dumps(selectorOrLocator._selector)}",
has_text=hasText,
has_not_text=hasNotText,
has_not=hasNot,
has=has,
)
def get_by_alt_text(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_alt_text_selector(text, exact=exact))
def get_by_label(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_label_selector(text, exact=exact))
def get_by_placeholder(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_placeholder_selector(text, exact=exact))
def get_by_role(
self,
role: AriaRole,
checked: bool = None,
disabled: bool = None,
expanded: bool = None,
includeHidden: bool = None,
level: int = None,
name: Union[str, Pattern[str]] = None,
pressed: bool = None,
selected: bool = None,
exact: bool = None,
description: Union[str, Pattern[str]] = None,
) -> "Locator":
return self.locator(
get_by_role_selector(
role,
checked=checked,
disabled=disabled,
expanded=expanded,
includeHidden=includeHidden,
level=level,
name=name,
pressed=pressed,
selected=selected,
exact=exact,
description=description,
)
)
def get_by_test_id(self, testId: Union[str, Pattern[str]]) -> "Locator":
return self.locator(get_by_test_id_selector(test_id_attribute_name(), testId))
def get_by_text(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_text_selector(text, exact=exact))
def get_by_title(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_title_selector(text, exact=exact))
def frame_locator(self, selector: str) -> "FrameLocator":
return FrameLocator(self._frame, self._selector + " >> " + selector)
async def element_handle(
self,
timeout: float = None,
) -> ElementHandle:
params = locals_to_params(locals())
handle = await self._frame.wait_for_selector(
self._selector, strict=True, state="attached", **params
)
assert handle
return handle
async def element_handles(self) -> List[ElementHandle]:
return await self._frame.query_selector_all(self._selector)
@property
def first(self) -> "Locator":
return Locator(self._frame, f"{self._selector} >> nth=0")
@property
def last(self) -> "Locator":
return Locator(self._frame, f"{self._selector} >> nth=-1")
def nth(self, index: int) -> "Locator":
return Locator(self._frame, f"{self._selector} >> nth={index}")
@property
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,
self._selector,
has_text=hasText,
has_not_text=hasNotText,
has=has,
has_not=hasNot,
visible=visible,
)
def or_(self, locator: "Locator") -> "Locator":
if locator._frame != self._frame:
raise Error("Locators must belong to the same frame.")
return Locator(
self._frame,
self._selector + " >> internal:or=" + json.dumps(locator._selector),
)
def and_(self, locator: "Locator") -> "Locator":
if locator._frame != self._frame:
raise Error("Locators must belong to the same frame.")
return Locator(
self._frame,
self._selector + " >> internal:and=" + json.dumps(locator._selector),
)
async def focus(self, timeout: float = None) -> None:
params = locals_to_params(locals())
return await self._frame.focus(self._selector, strict=True, **params)
async def blur(self, timeout: float = None) -> None:
await self._frame._channel.send(
"blur",
self._frame._timeout,
{
"selector": self._selector,
"strict": True,
**locals_to_params(locals()),
},
)
async def all(
self,
) -> List["Locator"]:
result = []
for index in range(await self.count()):
result.append(self.nth(index))
return result
async def count(
self,
) -> int:
return await self._frame._query_count(self._selector)
async def drag_to(
self,
target: "Locator",
force: bool = None,
noWaitAfter: bool = None,
timeout: float = None,
trial: bool = None,
sourcePosition: Position = None,
targetPosition: Position = None,
steps: int = None,
) -> None:
params = locals_to_params(locals())
del params["target"]
return await self._frame.drag_and_drop(
self._selector, target._selector, strict=True, **params
)
async def drop(
self,
payload: DropPayload,
position: Position = None,
timeout: float = None,
) -> None:
await self._frame._drop(
self._selector,
payload,
strict=True,
position=position,
timeout=timeout,
)
async def get_attribute(self, name: str, timeout: float = None) -> Optional[str]:
params = locals_to_params(locals())
return await self._frame.get_attribute(
self._selector,
strict=True,
**params,
)
async def hover(
self,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
timeout: float = None,
noWaitAfter: bool = None,
force: bool = None,
trial: bool = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.hover(
self._selector,
strict=True,
**params,
)
async def inner_html(self, timeout: float = None) -> str:
params = locals_to_params(locals())
return await self._frame.inner_html(
self._selector,
strict=True,
**params,
)
async def inner_text(self, timeout: float = None) -> str:
params = locals_to_params(locals())
return await self._frame.inner_text(
self._selector,
strict=True,
**params,
)
async def input_value(self, timeout: float = None) -> str:
params = locals_to_params(locals())
return await self._frame.input_value(
self._selector,
strict=True,
**params,
)
async def is_checked(self, timeout: float = None) -> bool:
params = locals_to_params(locals())
return await self._frame.is_checked(
self._selector,
strict=True,
**params,
)
async def is_disabled(self, timeout: float = None) -> bool:
params = locals_to_params(locals())
return await self._frame.is_disabled(
self._selector,
strict=True,
**params,
)
async def is_editable(self, timeout: float = None) -> bool:
params = locals_to_params(locals())
return await self._frame.is_editable(
self._selector,
strict=True,
**params,
)
async def is_enabled(self, timeout: float = None) -> bool:
params = locals_to_params(locals())
return await self._frame.is_enabled(
self._selector,
strict=True,
**params,
)
async def is_hidden(self, timeout: float = None) -> bool:
# timeout is deprecated and does nothing
return await self._frame.is_hidden(
self._selector,
strict=True,
)
async def is_visible(self, timeout: float = None) -> bool:
# timeout is deprecated and does nothing
return await self._frame.is_visible(
self._selector,
strict=True,
)
async def press(
self,
key: str,
delay: float = None,
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.press(self._selector, strict=True, **params)
async def screenshot(
self,
timeout: float = None,
type: Literal["jpeg", "png"] = None,
path: Union[str, pathlib.Path] = None,
quality: int = None,
omitBackground: bool = None,
animations: Literal["allow", "disabled"] = None,
caret: Literal["hide", "initial"] = None,
scale: Literal["css", "device"] = None,
mask: Sequence["Locator"] = None,
maskColor: str = None,
style: str = None,
) -> bytes:
params = locals_to_params(locals())
return await self._with_element(
lambda h, timeout: h.screenshot(
**{**params, "timeout": timeout},
),
)
async def aria_snapshot(
self,
timeout: float = None,
depth: int = None,
mode: Literal["ai", "default"] = None,
boxes: bool = None,
) -> str:
return await self._frame._channel.send(
"ariaSnapshot",
self._frame._timeout,
{
"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,
) -> None:
return await self._with_element(
lambda h, timeout: h.scroll_into_view_if_needed(timeout=timeout),
timeout,
)
async def select_option(
self,
value: Union[str, Sequence[str]] = None,
index: Union[int, Sequence[int]] = None,
label: Union[str, Sequence[str]] = None,
element: Union["ElementHandle", Sequence["ElementHandle"]] = None,
timeout: float = None,
noWaitAfter: bool = None,
force: bool = None,
) -> List[str]:
params = locals_to_params(locals())
return await self._frame.select_option(
self._selector,
strict=True,
**params,
)
async def select_text(self, force: bool = None, timeout: float = None) -> None:
params = locals_to_params(locals())
return await self._with_element(
lambda h, timeout: h.select_text(**{**params, "timeout": timeout}),
timeout,
)
async def set_input_files(
self,
files: Union[
str,
pathlib.Path,
FilePayload,
Sequence[Union[str, pathlib.Path]],
Sequence[FilePayload],
],
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.set_input_files(
self._selector,
strict=True,
**params,
)
async def tap(
self,
modifiers: Sequence[KeyboardModifier] = None,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.tap(
self._selector,
strict=True,
**params,
)
async def text_content(self, timeout: float = None) -> Optional[str]:
params = locals_to_params(locals())
return await self._frame.text_content(
self._selector,
strict=True,
**params,
)
async def type(
self,
text: str,
delay: float = None,
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.type(
self._selector,
strict=True,
**params,
)
async def press_sequentially(
self,
text: str,
delay: float = None,
timeout: float = None,
noWaitAfter: bool = None,
) -> None:
await self.type(text, delay=delay, timeout=timeout)
async def uncheck(
self,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
) -> None:
params = locals_to_params(locals())
return await self._frame.uncheck(
self._selector,
strict=True,
**params,
)
async def all_inner_texts(
self,
) -> List[str]:
return await self._frame.eval_on_selector_all(
self._selector, "ee => ee.map(e => e.innerText)"
)
async def all_text_contents(
self,
) -> List[str]:
return await self._frame.eval_on_selector_all(
self._selector, "ee => ee.map(e => e.textContent || '')"
)
async def wait_for(
self,
timeout: float = None,
state: Literal["attached", "detached", "hidden", "visible"] = None,
) -> None:
await self._frame.wait_for_selector(
self._selector, strict=True, timeout=timeout, state=state
)
async def set_checked(
self,
checked: bool,
position: Position = None,
timeout: float = None,
force: bool = None,
noWaitAfter: bool = None,
trial: bool = None,
) -> None:
if checked:
await self.check(
position=position,
timeout=timeout,
force=force,
trial=trial,
)
else:
await self.uncheck(
position=position,
timeout=timeout,
force=force,
trial=trial,
)
async def _expect(
self,
expression: str,
options: FrameExpectOptions,
title: str = None,
) -> FrameExpectResult:
return await self._frame._expect(self._selector, expression, options, title)
async def highlight(self, style: str = None) -> None:
await self._frame._highlight(self._selector, style)
async def hide_highlight(self) -> None:
await self._frame._hide_highlight(self._selector)
class FrameLocator:
def __init__(self, frame: "Frame", frame_selector: str) -> None:
self._frame = frame
self._loop = frame._loop
self._dispatcher_fiber = frame._connection._dispatcher_fiber
self._frame_selector = frame_selector
def locator(
self,
selectorOrLocator: Union["Locator", str],
hasText: Union[str, Pattern[str]] = None,
hasNotText: Union[str, Pattern[str]] = None,
has: Locator = None,
hasNot: Locator = None,
) -> Locator:
if isinstance(selectorOrLocator, str):
return Locator(
self._frame,
f"{self._frame_selector} >> internal:control=enter-frame >> {selectorOrLocator}",
has_text=hasText,
has_not_text=hasNotText,
has=has,
has_not=hasNot,
)
selectorOrLocator = to_impl(selectorOrLocator)
if selectorOrLocator._frame != self._frame:
raise ValueError("Locators must belong to the same frame.")
return Locator(
self._frame,
f"{self._frame_selector} >> internal:control=enter-frame >> {selectorOrLocator._selector}",
has_text=hasText,
has_not_text=hasNotText,
has=has,
has_not=hasNot,
)
def get_by_alt_text(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_alt_text_selector(text, exact=exact))
def get_by_label(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_label_selector(text, exact=exact))
def get_by_placeholder(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_placeholder_selector(text, exact=exact))
def get_by_role(
self,
role: AriaRole,
checked: bool = None,
disabled: bool = None,
expanded: bool = None,
includeHidden: bool = None,
level: int = None,
name: Union[str, Pattern[str]] = None,
pressed: bool = None,
selected: bool = None,
exact: bool = None,
description: Union[str, Pattern[str]] = None,
) -> "Locator":
return self.locator(
get_by_role_selector(
role,
checked=checked,
disabled=disabled,
expanded=expanded,
includeHidden=includeHidden,
level=level,
name=name,
pressed=pressed,
selected=selected,
exact=exact,
description=description,
)
)
def get_by_test_id(self, testId: Union[str, Pattern[str]]) -> "Locator":
return self.locator(get_by_test_id_selector(test_id_attribute_name(), testId))
def get_by_text(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_text_selector(text, exact=exact))
def get_by_title(
self, text: Union[str, Pattern[str]], exact: bool = None
) -> "Locator":
return self.locator(get_by_title_selector(text, exact=exact))
def frame_locator(self, selector: str) -> "FrameLocator":
return FrameLocator(
self._frame,
f"{self._frame_selector} >> internal:control=enter-frame >> {selector}",
)
@property
def first(self) -> "FrameLocator":
return FrameLocator(self._frame, f"{self._frame_selector} >> nth=0")
@property
def last(self) -> "FrameLocator":
return FrameLocator(self._frame, f"{self._frame_selector} >> nth=-1")
@property
def owner(self) -> "Locator":
return Locator(self._frame, self._frame_selector)
def nth(self, index: int) -> "FrameLocator":
return FrameLocator(self._frame, f"{self._frame_selector} >> nth={index}")
def __repr__(self) -> str:
return f"<FrameLocator frame={self._frame!r} selector={self._frame_selector!r}>"
_test_id_attribute_name: str = "data-testid"
def test_id_attribute_name() -> str:
return _test_id_attribute_name
def set_test_id_attribute_name(attribute_name: str) -> None:
global _test_id_attribute_name
_test_id_attribute_name = attribute_name
def get_by_test_id_selector(
test_id_attribute_name: str, test_id: Union[str, Pattern[str]]
) -> str:
return f"internal:testid=[{test_id_attribute_name}={escape_for_attribute_selector(test_id, True)}]"
def get_by_attribute_text_selector(
attr_name: str, text: Union[str, Pattern[str]], exact: bool = None
) -> str:
return f"internal:attr=[{attr_name}={escape_for_attribute_selector(text, exact=exact)}]"
def get_by_label_selector(text: Union[str, Pattern[str]], exact: bool = None) -> str:
return "internal:label=" + escape_for_text_selector(text, exact=exact)
def get_by_alt_text_selector(text: Union[str, Pattern[str]], exact: bool = None) -> str:
return get_by_attribute_text_selector("alt", text, exact=exact)
def get_by_title_selector(text: Union[str, Pattern[str]], exact: bool = None) -> str:
return get_by_attribute_text_selector("title", text, exact=exact)
def get_by_placeholder_selector(
text: Union[str, Pattern[str]], exact: bool = None
) -> str:
return get_by_attribute_text_selector("placeholder", text, exact=exact)
def get_by_text_selector(text: Union[str, Pattern[str]], exact: bool = None) -> str:
return "internal:text=" + escape_for_text_selector(text, exact=exact)
def bool_to_js_bool(value: bool) -> str:
return "true" if value else "false"
def get_by_role_selector(
role: AriaRole,
checked: bool = None,
disabled: bool = None,
expanded: bool = None,
includeHidden: bool = None,
level: int = None,
name: Union[str, Pattern[str]] = None,
pressed: bool = None,
selected: bool = None,
exact: bool = None,
description: Union[str, Pattern[str]] = None,
) -> str:
props: List[Tuple[str, str]] = []
if checked is not None:
props.append(("checked", bool_to_js_bool(checked)))
if disabled is not None:
props.append(("disabled", bool_to_js_bool(disabled)))
if selected is not None:
props.append(("selected", bool_to_js_bool(selected)))
if expanded is not None:
props.append(("expanded", bool_to_js_bool(expanded)))
if includeHidden is not None:
props.append(("include-hidden", bool_to_js_bool(includeHidden)))
if level is not None:
props.append(("level", str(level)))
if name is not None:
props.append(
(
"name",
escape_for_attribute_selector(name, exact=exact),
)
)
if description is not None:
props.append(
(
"description",
escape_for_attribute_selector(description, exact=exact),
)
)
if pressed is not None:
props.append(("pressed", bool_to_js_bool(pressed)))
props_str = "".join([f"[{t[0]}={t[1]}]" for t in props])
return f"internal:role={role}{props_str}"

31
playwright/_impl/_map.py Normal file
View File

@ -0,0 +1,31 @@
# 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, Generic, Tuple, TypeVar
K = TypeVar("K")
V = TypeVar("V")
class Map(Generic[K, V]):
def __init__(self) -> None:
self._entries: Dict[int, Tuple[K, V]] = {}
def __contains__(self, item: K) -> bool:
return id(item) in self._entries
def __setitem__(self, idx: K, value: V) -> None:
self._entries[id(idx)] = (idx, value)
def __getitem__(self, obj: K) -> V:
return self._entries[id(obj)][1]

1056
playwright/_impl/_network.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,108 @@
# 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, cast
from playwright._impl._artifact import Artifact
from playwright._impl._browser import Browser
from playwright._impl._browser_context import BrowserContext
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,
WebSocketRoute,
)
from playwright._impl._page import BindingCall, Page, Worker
from playwright._impl._playwright import Playwright
from playwright._impl._stream import Stream
from playwright._impl._tracing import Tracing
from playwright._impl._writable_stream import WritableStream
class DummyObject(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
def create_remote_object(
parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> ChannelOwner:
if type == "Artifact":
return Artifact(parent, type, guid, initializer)
if type == "APIRequestContext":
return APIRequestContext(parent, type, guid, initializer)
if type == "BindingCall":
return BindingCall(parent, type, guid, initializer)
if type == "Browser":
return Browser(cast(BrowserType, parent), type, guid, initializer)
if type == "BrowserType":
return BrowserType(parent, type, guid, initializer)
if type == "BrowserContext":
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":
return Frame(parent, type, guid, initializer)
if type == "JSHandle":
return JSHandle(parent, type, guid, initializer)
if type == "LocalUtils":
local_utils = LocalUtils(parent, type, guid, initializer)
if not local_utils._connection._local_utils:
local_utils._connection._local_utils = local_utils
return local_utils
if type == "Page":
return Page(parent, type, guid, initializer)
if type == "Playwright":
return Playwright(parent, type, guid, initializer)
if type == "Request":
return Request(parent, type, guid, initializer)
if type == "Response":
return Response(parent, type, guid, initializer)
if type == "Route":
return Route(parent, type, guid, initializer)
if type == "Stream":
return Stream(parent, type, guid, initializer)
if type == "Tracing":
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)
return DummyObject(parent, type, guid, initializer)

1613
playwright/_impl/_page.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
# 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 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 = cast(FrameType, inspect.currentframe()).f_back
module = inspect.getmodule(frame)
assert module
assert module.__file__
return Path(module.__file__).parent.absolute()

View File

@ -0,0 +1,60 @@
# 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
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
class Playwright(ChannelOwner):
devices: Dict
selectors: Selectors
chromium: BrowserType
firefox: BrowserType
webkit: BrowserType
request: APIRequest
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self.request = APIRequest(self)
self.chromium = from_channel(initializer["chromium"])
self.chromium._playwright = self
self.firefox = from_channel(initializer["firefox"])
self.firefox._playwright = self
self.webkit = from_channel(initializer["webkit"])
self.webkit._playwright = self
self.selectors = Selectors(self._loop, self._dispatcher_fiber)
self.devices = self._connection.local_utils.devices
def __getitem__(self, value: str) -> "BrowserType":
if value == "chromium":
return self.chromium
elif value == "firefox":
return self.firefox
elif value == "webkit":
return self.webkit
raise ValueError("Invalid browser " + value)
def _set_selectors(self, selectors: Selectors) -> None:
self.selectors = selectors
async def stop(self) -> None:
pass

View File

@ -0,0 +1,146 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import base64
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union
from playwright._impl._api_structures import ScreencastFrame
from playwright._impl._artifact import Artifact
from playwright._impl._connection import from_nullable_channel
from playwright._impl._disposable import DisposableStub
from playwright._impl._errors import Error
from playwright._impl._helper import locals_to_params
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._page import Page
ScreencastFrameCallback = Callable[[ScreencastFrame], Any]
ScreencastPosition = Literal[
"bottom",
"bottom-left",
"bottom-right",
"top",
"top-left",
"top-right",
]
class Screencast:
def __init__(self, page: "Page") -> None:
self._page = page
self._loop = page._loop
self._dispatcher_fiber = page._dispatcher_fiber
self._started = False
self._save_path: Optional[Union[str, Path]] = None
self._on_frame: Optional[ScreencastFrameCallback] = None
self._artifact: Optional[Artifact] = None
page._channel.on("screencastFrame", lambda params: self._dispatch_frame(params))
def _dispatch_frame(self, params: dict) -> None:
if not self._on_frame:
return
data = params["data"]
if isinstance(data, str):
data = base64.b64decode(data)
result = self._on_frame(
{
"data": data,
"viewportWidth": params["viewportWidth"],
"viewportHeight": params["viewportHeight"],
}
)
if hasattr(result, "__await__"):
self._page._loop.create_task(result)
async def start(
self,
onFrame: ScreencastFrameCallback = None,
path: Union[str, Path] = None,
quality: int = None,
) -> DisposableStub:
if self._started:
raise Error("Screencast is already started")
self._started = True
self._on_frame = onFrame
result = await self._page._channel.send_return_as_dict(
"screencastStart",
None,
{
"quality": quality,
"sendFrames": bool(onFrame),
"record": bool(path),
},
)
artifact_channel = (result or {}).get("artifact")
if artifact_channel:
self._artifact = from_nullable_channel(artifact_channel)
self._save_path = path
return DisposableStub(lambda: self.stop(), self._page)
async def stop(self) -> None:
self._started = False
self._on_frame = None
await self._page._channel.send("screencastStop", None)
if self._save_path and self._artifact:
await self._artifact.save_as(self._save_path)
self._artifact = None
self._save_path = None
async def show_actions(
self,
duration: float = None,
position: ScreencastPosition = None,
fontSize: int = None,
) -> DisposableStub:
await self._page._channel.send(
"screencastShowActions", None, locals_to_params(locals())
)
return DisposableStub(lambda: self.hide_actions(), self._page)
async def hide_actions(self) -> None:
await self._page._channel.send("screencastHideActions", None)
async def show_overlay(self, html: str, duration: float = None) -> DisposableStub:
result = await self._page._channel.send_return_as_dict(
"screencastShowOverlay", None, locals_to_params(locals())
)
overlay_id = (result or {}).get("id")
return DisposableStub(
lambda: self._page._channel.send(
"screencastRemoveOverlay", None, {"id": overlay_id}
),
self._page,
)
async def show_chapter(
self,
title: str,
description: str = None,
duration: float = None,
) -> None:
await self._page._channel.send(
"screencastChapter", None, locals_to_params(locals())
)
async def show_overlays(self) -> None:
await self._page._channel.send(
"screencastSetOverlayVisible", None, {"visible": True}
)
async def hide_overlays(self) -> None:
await self._page._channel.send(
"screencastSetOverlayVisible", None, {"visible": False}
)

View File

@ -0,0 +1,67 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Union
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
class Selectors:
def __init__(self, loop: asyncio.AbstractEventLoop, dispatcher_fiber: Any) -> None:
self._loop = loop
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,
name: str,
script: str = None,
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()
engine: Dict[str, Any] = dict(name=name, source=script)
if contentScript:
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)
self._test_id_attribute_name = attributeName
for context in self._contexts_for_selectors:
context._channel.send_no_reply(
"setTestIdAttributeName",
None,
{"testIdAttributeName": attributeName},
)

View File

@ -0,0 +1,158 @@
# 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
import collections.abc
import os
import stat
from pathlib import Path
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
from playwright._impl._writable_stream import WritableStream
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._browser_context import BrowserContext
from playwright._impl._api_structures import FilePayload
SIZE_LIMIT_IN_BYTES = 50 * 1024 * 1024
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]
],
context: "BrowserContext",
) -> InputFilesList:
items = (
files
if isinstance(files, collections.abc.Sequence) and not isinstance(files, str)
else [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 = []
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,
)
),
},
)
)
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=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))])
> SIZE_LIMIT_IN_BYTES
)
if file_payload_exceeds_size_limit:
raise Error(
"Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead."
)
return InputFilesList(
payloads=[
{
"name": item["name"],
"mimeType": item["mimeType"],
"buffer": base64.b64encode(item["buffer"]).decode(),
}
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)

View File

@ -0,0 +1,76 @@
# 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 json
import re
from typing import Pattern, Union
def escape_regex_flags(pattern: Pattern) -> str:
flags = ""
if pattern.flags != 0:
flags = ""
if (pattern.flags & int(re.IGNORECASE)) != 0:
flags += "i"
if (pattern.flags & int(re.DOTALL)) != 0:
flags += "s"
if (pattern.flags & int(re.MULTILINE)) != 0:
flags += "m"
assert (
pattern.flags
& ~(int(re.MULTILINE) | int(re.IGNORECASE) | int(re.DOTALL) | int(re.UNICODE))
== 0
), "Unexpected re.Pattern flag, only MULTILINE, IGNORECASE and DOTALL are supported."
return flags
def escape_for_regex(text: str) -> str:
return re.sub(r"[.*+?^>${}()|[\]\\]", "\\$&", text)
def escape_regex_for_selector(text: Pattern) -> str:
# Even number of backslashes followed by the quote -> insert a backslash.
return (
"/"
+ re.sub(r'(^|[^\\])(\\\\)*(["\'`])', r"\1\2\\\3", text.pattern).replace(
">>", "\\>\\>"
)
+ "/"
+ escape_regex_flags(text)
)
def escape_for_text_selector(
text: Union[str, Pattern[str]], exact: bool = None, case_sensitive: bool = None
) -> str:
if isinstance(text, Pattern):
return escape_regex_for_selector(text)
return json.dumps(text) + ("s" if exact else "i")
def escape_for_attribute_selector(
value: Union[str, Pattern], exact: bool = None
) -> str:
if isinstance(value, Pattern):
return escape_regex_for_selector(value)
# TODO: this should actually be
# cssEscape(value).replace(/\\ /g, ' ')
# However, our attribute selectors do not conform to CSS parsing spec,
# so we escape them differently.
return (
'"'
+ value.replace("\\", "\\\\").replace('"', '\\"')
+ '"'
+ ("s" if exact else "i")
)

View File

@ -0,0 +1,46 @@
# 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 Dict, Union
from playwright._impl._connection import ChannelOwner
class Stream(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
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", None, {"size": 1024 * 1024})
if not binary:
break
await self._loop.run_in_executor(
None, lambda: file.write(base64.b64decode(binary))
)
await self._loop.run_in_executor(None, lambda: file.close())
async def read_all(self) -> bytes:
binary = b""
while True:
chunk = await self._channel.send("read", None, {"size": 1024 * 1024})
if not chunk:
break
binary += base64.b64decode(chunk)
return binary

View File

@ -0,0 +1,151 @@
# 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 contextlib import AbstractContextManager
from types import TracebackType
from typing import (
Any,
Callable,
Coroutine,
Generator,
Generic,
Optional,
Type,
TypeVar,
Union,
cast,
)
import greenlet
from playwright._impl._helper import Error
from playwright._impl._impl_to_api_mapping import ImplToApiMapping, ImplWrapper
mapping = ImplToApiMapping()
T = TypeVar("T")
Self = TypeVar("Self", bound="SyncContextManager")
class EventInfo(Generic[T]):
def __init__(self, sync_base: "SyncBase", future: "asyncio.Future[T]") -> None:
self._sync_base = sync_base
self._future = future
g_self = greenlet.getcurrent()
self._future.add_done_callback(lambda _: g_self.switch())
@property
def value(self) -> T:
while not self._future.done():
self._sync_base._dispatcher_fiber.switch()
asyncio._set_running_loop(self._sync_base._loop)
exception = self._future.exception()
if exception:
raise exception
return cast(T, mapping.from_maybe_impl(self._future.result()))
def _cancel(self) -> None:
self._future.cancel()
def is_done(self) -> bool:
return self._future.done()
class EventContextManager(Generic[T], AbstractContextManager):
def __init__(self, sync_base: "SyncBase", future: "asyncio.Future[T]") -> None:
self._event = EventInfo[T](sync_base, future)
def __enter__(self) -> EventInfo[T]:
return self._event
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
if exc_val:
self._event._cancel()
else:
self._event.value
class SyncBase(ImplWrapper):
def __init__(self, impl_obj: Any) -> None:
super().__init__(impl_obj)
self._loop: asyncio.AbstractEventLoop = impl_obj._loop
self._dispatcher_fiber = impl_obj._dispatcher_fiber
def __str__(self) -> str:
return self._impl_obj.__str__()
def _sync(
self,
coro: Union[Coroutine[Any, Any, Any], Generator[Any, Any, Any]],
) -> Any:
__tracebackhide__ = True
if self._loop.is_closed():
coro.close()
raise Error("Event loop is closed! Is Playwright already stopped?")
g_self = greenlet.getcurrent()
task: asyncio.tasks.Task[Any] = self._loop.create_task(coro)
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()
asyncio._set_running_loop(self._loop)
return task.result()
def _wrap_handler(
self, handler: Union[Callable[..., Any], Any]
) -> Callable[..., None]:
if callable(handler):
return mapping.wrap_handler(handler)
return handler
def on(self, event: Any, f: Any) -> None:
"""Registers the function ``f`` to the event name ``event``."""
self._impl_obj.on(event, self._wrap_handler(f))
def once(self, event: Any, f: Any) -> None:
"""The same as ``self.on``, except that the listener is automatically
removed after being called.
"""
self._impl_obj.once(event, self._wrap_handler(f))
def remove_listener(self, event: Any, f: Any) -> None:
"""Removes the function ``f`` from ``event``."""
self._impl_obj.remove_listener(event, self._wrap_handler(f))
class SyncContextManager(SyncBase):
def __enter__(self: Self) -> Self:
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
_traceback: Optional[TracebackType],
) -> None:
self.close()
def close(self) -> None: ...

View File

@ -0,0 +1,281 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pathlib
from typing import Any, Dict, Literal, Optional, Pattern, Union, cast
from playwright._impl._api_structures import TracingGroupLocation
from playwright._impl._artifact import Artifact
from playwright._impl._connection import (
ChannelOwner,
from_channel,
from_nullable_channel,
)
from playwright._impl._disposable import DisposableStub
from playwright._impl._helper import Error, locals_to_params
class Tracing(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
self._include_sources: bool = False
self._is_live: bool = False
self._stacks_id: Optional[str] = None
self._is_tracing: bool = False
self._traces_dir: Optional[str] = None
self._har_id: Optional[str] = None
self._har_recorders: Dict[str, Dict[str, str]] = {}
async def start(
self,
name: str = None,
title: str = None,
snapshots: bool = None,
screenshots: bool = None,
sources: bool = None,
live: bool = None,
) -> None:
params = locals_to_params(locals())
self._include_sources = bool(sources)
self._is_live = bool(live)
await self._channel.send(
"tracingStart",
None,
{
"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", 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_is_tracing(True)
self._stacks_id = await self._connection.local_utils.tracing_started(
self._traces_dir, trace_name, self._is_live
)
async def stop_chunk(self, path: Union[pathlib.Path, str] = None) -> None:
await self._do_stop_chunk(path)
async def stop(self, path: Union[pathlib.Path, str] = None) -> None:
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:
self._reset_stack_counter()
if not file_path:
# Not interested in any artifacts
await self._channel.send("tracingStopChunk", None, {"mode": "discard"})
if self._stacks_id:
await self._connection.local_utils.trace_discarded(self._stacks_id)
return
is_local = not self._connection.is_remote
if is_local:
result = await self._channel.send_return_as_dict(
"tracingStopChunk", None, {"mode": "entries"}
)
await self._connection.local_utils.zip(
{
"zipFile": str(file_path),
"entries": result["entries"],
"stacksId": self._stacks_id,
"mode": "write",
"includeSources": self._include_sources,
}
)
return
result = await self._channel.send_return_as_dict(
"tracingStopChunk",
None,
{
"mode": "archive",
},
)
artifact = cast(
Optional[Artifact],
from_nullable_channel(result.get("artifact")),
)
# The artifact may be missing if the browser closed while stopping tracing.
if not artifact:
if self._stacks_id:
await self._connection.local_utils.trace_discarded(self._stacks_id)
return
# Save trace to the final local file.
await artifact.save_as(file_path)
await artifact.delete()
await self._connection.local_utils.zip(
{
"zipFile": str(file_path),
"entries": [],
"stacksId": self._stacks_id,
"mode": "append",
"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,
)
async def start_har(
self,
path: Union[pathlib.Path, str],
content: Literal["attach", "embed", "omit"] = None,
mode: Literal["full", "minimal"] = None,
urlFilter: Union[str, Pattern[str]] = None,
) -> DisposableStub:
if self._har_id:
raise Error("HAR recording has already been started")
is_zip = str(path).endswith(".zip")
default_content: Literal["attach", "embed", "omit"] = (
"attach" if is_zip else "embed"
)
self._har_id = await self._record_into_har(
har=path,
page=None,
url=urlFilter,
update_content=content or default_content,
update_mode=mode or "full",
)
return DisposableStub(lambda: self.stop_har(), self)
async def _record_into_har(
self,
har: Union[pathlib.Path, str],
page: Optional[ChannelOwner],
url: Union[str, Pattern[str]] = None,
update_content: Literal["attach", "embed", "omit"] = None,
update_mode: Literal["full", "minimal"] = None,
resourcesDir: Optional[str] = None,
) -> str:
is_zip = str(har).endswith(".zip")
url_glob: Optional[str] = None
url_regex_source: Optional[str] = None
url_regex_flags: Optional[str] = None
if isinstance(url, str):
url_glob = url
elif url is not None:
url_regex_source = url.pattern
url_regex_flags = "".join(
flag
for flag, mask in (("i", 2), ("m", 8), ("s", 16))
if url.flags & mask
)
options: Dict[str, object] = {
"content": update_content or "attach",
"mode": update_mode or "minimal",
"harPath": None if is_zip else str(har),
}
if url_glob is not None:
options["urlGlob"] = url_glob
if url_regex_source is not None:
options["urlRegexSource"] = url_regex_source
if url_regex_flags is not None:
options["urlRegexFlags"] = url_regex_flags
if resourcesDir is not None:
options["resourcesDir"] = resourcesDir
params: Dict[str, Any] = {"options": options}
if page is not None:
params["page"] = page._channel
result = await self._channel.send_return_as_dict("harStart", None, params)
har_id = result["harId"]
self._har_recorders[har_id] = {"path": str(har)}
return har_id
async def _export_all_hars(self) -> None:
for har_id in list(self._har_recorders.keys()):
await self._export_har(har_id)
self._har_id = None
async def stop_har(self) -> None:
har_id = self._har_id
if not har_id:
return
self._har_id = None
await self._export_har(har_id)
async def _export_har(self, har_id: str) -> None:
params = self._har_recorders.pop(har_id, None)
if not params:
return
is_local = not self._connection.is_remote
is_zip = params["path"].endswith(".zip")
if is_local:
result = await self._channel.send_return_as_dict(
"harExport", None, {"harId": har_id, "mode": "entries"}
)
if not is_zip:
# Server wrote HAR and resources to the user's chosen paths.
return
await self._connection.local_utils.zip(
{
"zipFile": params["path"],
"entries": result["entries"],
"mode": "write",
"includeSources": False,
}
)
return
result = await self._channel.send_return_as_dict(
"harExport", None, {"harId": har_id, "mode": "archive"}
)
artifact = cast(Artifact, from_channel(result["artifact"]))
if is_zip:
await artifact.save_as(params["path"])
await artifact.delete()
return
# Uncompressed har is not supported in thin clients
await artifact.save_as(params["path"] + ".tmp")
await artifact.delete()

View File

@ -0,0 +1,184 @@
# 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 io
import json
import os
import subprocess
import sys
from abc import ABC, abstractmethod
from typing import Callable, Dict, Optional, Union
from playwright._impl._driver import compute_driver_executable, get_driver_env
from playwright._impl._helper import ParsedMessagePayload
# Sourced from: https://github.com/pytest-dev/pytest/blob/da01ee0a4bb0af780167ecd228ab3ad249511302/src/_pytest/faulthandler.py#L69-L77
def _get_stderr_fileno() -> Optional[int]:
try:
# when using pythonw, sys.stderr is None.
# when Pyinstaller is used, there is no closed attribute because Pyinstaller monkey-patches it with a NullWriter class
if sys.stderr is None or not hasattr(sys.stderr, "closed"):
return None
if sys.stderr.closed:
return None
return sys.stderr.fileno()
except (NotImplementedError, AttributeError, io.UnsupportedOperation):
# pytest-xdist monkeypatches sys.stderr with an object that is not an actual file.
# https://docs.python.org/3/library/faulthandler.html#issue-with-file-descriptors
# This is potentially dangerous, but the best we can do.
if not hasattr(sys, "__stderr__") or not sys.__stderr__:
return None
return sys.__stderr__.fileno()
class Transport(ABC):
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
self.on_message: Callable[[ParsedMessagePayload], None] = lambda _: None
self.on_error_future: asyncio.Future = loop.create_future()
@abstractmethod
def request_stop(self) -> None:
pass
def dispose(self) -> None:
pass
@abstractmethod
async def wait_until_stopped(self) -> None:
pass
@abstractmethod
async def connect(self) -> None:
pass
@abstractmethod
async def run(self) -> None:
pass
@abstractmethod
def send(self, message: Dict) -> None:
pass
def serialize_message(self, message: Dict) -> bytes:
msg = json.dumps(message)
if "DEBUGP" in os.environ: # pragma: no cover
print("\x1b[32mSEND>\x1b[0m", json.dumps(message, indent=2))
return msg.encode()
def deserialize_message(self, data: Union[str, bytes]) -> ParsedMessagePayload:
obj = json.loads(data)
if "DEBUGP" in os.environ: # pragma: no cover
print("\x1b[33mRECV>\x1b[0m", json.dumps(obj, indent=2))
return obj
class PipeTransport(Transport):
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
super().__init__(loop)
self._stopped = False
def request_stop(self) -> None:
assert self._output
self._stopped = True
self._output.close()
async def wait_until_stopped(self) -> None:
await self._stopped_future
async def connect(self) -> None:
self._stopped_future: asyncio.Future = asyncio.Future()
try:
# For pyinstaller and Nuitka
env = get_driver_env()
if getattr(sys, "frozen", False) or globals().get("__compiled__"):
env.setdefault("PLAYWRIGHT_BROWSERS_PATH", "0")
startupinfo = None
if sys.platform == "win32":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
executable_path, entrypoint_path = compute_driver_executable()
self._proc = await asyncio.create_subprocess_exec(
executable_path,
entrypoint_path,
"run-driver",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=_get_stderr_fileno(),
limit=32768,
env=env,
startupinfo=startupinfo,
)
except Exception as exc:
self.on_error_future.set_exception(exc)
raise exc
self._output = self._proc.stdin
async def run(self) -> None:
assert self._proc.stdout
assert self._proc.stdin
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
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
data = self.serialize_message(message)
self._output.write(
len(data).to_bytes(4, byteorder="little", signed=False) + data
)

View File

@ -0,0 +1,56 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pathlib
from typing import TYPE_CHECKING, Optional, Union
from playwright._impl._artifact import Artifact
from playwright._impl._helper import Error
if TYPE_CHECKING: # pragma: no cover
from playwright._impl._page import Page
class Video:
def __init__(self, page: "Page", artifact: Optional[Artifact]) -> None:
self._loop = page._loop
self._dispatcher_fiber = page._dispatcher_fiber
self._page = page
self._is_remote = page._connection.is_remote
self._artifact = artifact
def __repr__(self) -> str:
return f"<Video page={self._page}>"
async def path(self) -> pathlib.Path:
if self._is_remote:
raise Error(
"Path is not available when using browserType.connect(). Use save_as() to save a local copy."
)
if not self._artifact:
raise Error("Video recording has not been started.")
return self._artifact.absolute_path
async def save_as(self, path: Union[str, pathlib.Path]) -> None:
if self._page._connection._is_sync and not self._page._is_closed:
raise Error(
"Page is not yet closed. Close the page prior to calling save_as"
)
if not self._artifact:
raise Error("Video recording has not been started.")
await self._artifact.save_as(path)
async def delete(self) -> None:
if self._artifact:
await self._artifact.delete()

200
playwright/_impl/_waiter.py Normal file
View File

@ -0,0 +1,200 @@
# 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 math
import uuid
from asyncio.tasks import Task
from typing import Any, Callable, List, Optional, Tuple, Union
from pyee import EventEmitter
from playwright._impl._connection import ChannelOwner
from playwright._impl._errors import Error, TimeoutError
class Waiter:
def __init__(self, channel_owner: ChannelOwner, event: str) -> None:
self._result: asyncio.Future = asyncio.Future()
self._wait_id = uuid.uuid4().hex
self._loop = channel_owner._loop
self._pending_tasks: List[Task] = []
self._channel = channel_owner._channel
self._registered_listeners: List[Tuple[EventEmitter, str, Callable]] = []
self._logs: List[str] = []
self._wait_for_event_info_before(self._wait_id, event)
def _wait_for_event_info_before(self, wait_id: str, event: str) -> None:
self._channel.send_no_reply(
"waitForEventInfo",
None,
{
"info": {
"waitId": wait_id,
"phase": "before",
"event": event,
}
},
)
def _wait_for_event_info_after(self, wait_id: str, error: Exception = None) -> None:
self._channel._connection.wrap_api_call_sync(
lambda: self._channel.send_no_reply(
"waitForEventInfo",
None,
{
"info": {
"waitId": wait_id,
"phase": "after",
**({"error": str(error)} if error else {}),
},
},
),
True,
)
def reject_on_event(
self,
emitter: EventEmitter,
event: str,
error: Union[Error, Callable[..., Error]],
predicate: Callable = None,
) -> None:
def on_match() -> None:
self._reject(error() if callable(error) else error)
def listener(event_data: Any = None) -> None:
self._evaluate_predicate(predicate, event_data, on_match)
emitter.on(event, listener)
self._registered_listeners.append((emitter, event, listener))
def reject_on_timeout(self, timeout: float, message: str) -> None:
if timeout == 0:
return
async def reject() -> None:
await asyncio.sleep(timeout / 1000)
self._reject(TimeoutError(message))
self._pending_tasks.append(self._loop.create_task(reject()))
def _cleanup(self) -> None:
for task in self._pending_tasks:
if not task.done():
task.cancel()
for listener in self._registered_listeners:
listener[0].remove_listener(listener[1], listener[2])
def _fulfill(self, result: Any) -> None:
self._cleanup()
if not self._result.done():
self._result.set_result(result)
self._wait_for_event_info_after(self._wait_id)
def _reject(self, exception: Exception) -> None:
self._cleanup()
if exception:
base_class = TimeoutError if isinstance(exception, TimeoutError) else Error
exception = base_class(str(exception) + format_log_recording(self._logs))
if not self._result.done():
self._result.set_exception(exception)
self._wait_for_event_info_after(self._wait_id, exception)
def wait_for_event(
self,
emitter: EventEmitter,
event: str,
predicate: Callable = None,
) -> None:
def listener(event_data: Any = None) -> None:
self._evaluate_predicate(
predicate, event_data, lambda: self._fulfill(event_data)
)
emitter.on(event, listener)
self._registered_listeners.append((emitter, event, listener))
def _evaluate_predicate(
self,
predicate: Optional[Callable],
event_data: Any,
on_match: Callable[[], None],
) -> None:
if predicate is None:
on_match()
return
try:
result = predicate(event_data)
except Exception as e:
self._reject(e)
return
if inspect.iscoroutine(result):
async def _await_predicate(coro: Any) -> None:
try:
matched = await coro
except Exception as e:
self._reject(e)
return
if matched and not self._result.done():
on_match()
self._pending_tasks.append(self._loop.create_task(_await_predicate(result)))
return
if result:
on_match()
def result(self) -> asyncio.Future:
return self._result
def log(self, message: str) -> None:
self._logs.append(message)
try:
self._channel._connection.wrap_api_call_sync(
lambda: self._channel.send_no_reply(
"waitForEventInfo",
None,
{
"info": {
"waitId": self._wait_id,
"phase": "log",
"message": message,
},
},
),
True,
)
except Exception:
pass
def throw_on_timeout(timeout: float, exception: Exception) -> asyncio.Task:
async def throw() -> None:
await asyncio.sleep(timeout / 1000)
raise exception
return asyncio.create_task(throw())
def format_log_recording(log: List[str]) -> str:
if not log:
return ""
header = " logs "
header_length = 60
left_length = math.floor((header_length - len(header)) / 2)
right_length = header_length - len(header) - left_length
new_line = "\n"
return f"{new_line}{'=' * left_length}{header}{'=' * right_length}{new_line}{new_line.join(log)}{new_line}{'=' * header_length}"

View File

@ -0,0 +1,48 @@
# 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 asyncio import AbstractEventLoop
from typing import Any, Optional
from playwright._impl._api_structures import WebErrorLocation
from playwright._impl._helper import Error
from playwright._impl._page import Page
class WebError:
def __init__(
self,
loop: AbstractEventLoop,
dispatcher_fiber: Any,
page: Optional[Page],
error: Error,
location: WebErrorLocation,
) -> None:
self._loop = loop
self._dispatcher_fiber = dispatcher_fiber
self._page = page
self._error = error
self._location = location
@property
def page(self) -> Optional[Page]:
return self._page
@property
def error(self) -> Error:
return self._error
@property
def location(self) -> WebErrorLocation:
return self._location

View File

@ -0,0 +1,42 @@
# 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
import os
from pathlib import Path
from typing import Dict, Union
from playwright._impl._connection import ChannelOwner
# COPY_BUFSIZE is taken from shutil.py in the standard library
_WINDOWS = os.name == "nt"
COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024
class WritableStream(ChannelOwner):
def __init__(
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
) -> None:
super().__init__(parent, type, guid, initializer)
async def copy(self, path: Union[str, Path]) -> None:
with open(path, "rb") as f:
while True:
data = f.read(COPY_BUFSIZE)
if not data:
break
await self._channel.send(
"write", None, {"binary": base64.b64encode(data).decode()}
)
await self._channel.send("close", None)

View File

@ -0,0 +1,258 @@
# 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.
"""
Python package `playwright` is a Python library to automate Chromium,
Firefox and WebKit with a single API. Playwright is built to enable cross-browser
web automation that is ever-green, capable, reliable and fast.
"""
from typing import Any, Optional, Union, overload
import playwright._impl._api_structures
import playwright._impl._errors
import playwright._impl._form_data
import playwright.async_api._generated
from playwright._impl._assertions import (
APIResponseAssertions as APIResponseAssertionsImpl,
)
from playwright._impl._assertions import LocatorAssertions as LocatorAssertionsImpl
from playwright._impl._assertions import PageAssertions as PageAssertionsImpl
from playwright.async_api._context_manager import PlaywrightContextManager
from playwright.async_api._generated import (
APIRequest,
APIRequestContext,
APIResponse,
APIResponseAssertions,
Browser,
BrowserContext,
BrowserType,
CDPSession,
ConsoleMessage,
Dialog,
Download,
ElementHandle,
FileChooser,
Frame,
FrameLocator,
JSHandle,
Keyboard,
Locator,
LocatorAssertions,
Mouse,
Page,
PageAssertions,
Playwright,
Request,
Response,
Route,
Selectors,
Touchscreen,
Video,
WebError,
WebSocket,
WebSocketRoute,
Worker,
)
ChromiumBrowserContext = BrowserContext
Cookie = playwright._impl._api_structures.Cookie
FilePayload = playwright._impl._api_structures.FilePayload
FormData = playwright._impl._form_data.FormData
FloatRect = playwright._impl._api_structures.FloatRect
Geolocation = playwright._impl._api_structures.Geolocation
HttpCredentials = playwright._impl._api_structures.HttpCredentials
BrowserBindResult = playwright._impl._api_structures.BrowserBindResult
DebuggerLocation = playwright._impl._api_structures.DebuggerLocation
DebuggerPausedDetails = playwright._impl._api_structures.DebuggerPausedDetails
PdfMargins = playwright._impl._api_structures.PdfMargins
Position = playwright._impl._api_structures.Position
ProxySettings = playwright._impl._api_structures.ProxySettings
ResourceTiming = playwright._impl._api_structures.ResourceTiming
ScreencastFrame = playwright._impl._api_structures.ScreencastFrame
SourceLocation = playwright._impl._api_structures.SourceLocation
StorageState = playwright._impl._api_structures.StorageState
StorageStateCookie = playwright._impl._api_structures.StorageStateCookie
ViewportSize = playwright._impl._api_structures.ViewportSize
Error = playwright._impl._errors.Error
TimeoutError = playwright._impl._errors.TimeoutError
def async_playwright() -> PlaywrightContextManager:
return PlaywrightContextManager()
class Expect:
_unset: Any = object()
def __init__(self) -> None:
self._timeout: Optional[float] = None
def set_options(self, timeout: Optional[float] = _unset) -> None:
"""
This method sets global `expect()` options.
Args:
timeout (float): Timeout value in milliseconds. Default to 5000 milliseconds.
Returns:
None
"""
if timeout is not self._unset:
self._timeout = timeout
@overload
def __call__(
self, actual: Page, message: Optional[str] = None
) -> PageAssertions: ...
@overload
def __call__(
self, actual: Locator, message: Optional[str] = None
) -> LocatorAssertions: ...
@overload
def __call__(
self, actual: APIResponse, message: Optional[str] = None
) -> APIResponseAssertions: ...
def __call__(
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
return self._dispatch(actual, message, is_soft=False)
@overload
def soft(self, actual: Page, message: Optional[str] = None) -> PageAssertions: ...
@overload
def soft(
self, actual: Locator, message: Optional[str] = None
) -> LocatorAssertions: ...
@overload
def soft(
self, actual: APIResponse, message: Optional[str] = None
) -> APIResponseAssertions: ...
def soft(
self, actual: Union[Page, Locator, APIResponse], message: Optional[str] = None
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
"""
Creates a [soft assertion](https://playwright.dev/python/docs/test-assertions#soft-assertions).
Failing soft assertions do not abort test execution, but mark the test
as failed. Multiple failures from the same test are surfaced together
at the end of the test.
Requires the [pytest-playwright](https://pypi.org/project/pytest-playwright/)
plugin to establish the per-test scope that collects soft assertion
failures.
"""
return self._dispatch(actual, message, is_soft=True)
def _dispatch(
self,
actual: Union[Page, Locator, APIResponse],
message: Optional[str],
is_soft: bool,
) -> Union[PageAssertions, LocatorAssertions, APIResponseAssertions]:
if isinstance(actual, Page):
return PageAssertions(
PageAssertionsImpl(
actual._impl_obj,
self._timeout,
message=message,
is_soft=is_soft,
)
)
elif isinstance(actual, Locator):
return LocatorAssertions(
LocatorAssertionsImpl(
actual._impl_obj,
self._timeout,
message=message,
is_soft=is_soft,
)
)
elif isinstance(actual, APIResponse):
return APIResponseAssertions(
APIResponseAssertionsImpl(
actual._impl_obj,
self._timeout,
message=message,
is_soft=is_soft,
)
)
raise ValueError(f"Unsupported type: {type(actual)}")
expect = Expect()
__all__ = [
"expect",
"async_playwright",
"APIRequest",
"APIRequestContext",
"APIResponse",
"Browser",
"BrowserBindResult",
"BrowserContext",
"BrowserType",
"CDPSession",
"ChromiumBrowserContext",
"ConsoleMessage",
"Cookie",
"Dialog",
"Download",
"ElementHandle",
"Error",
"FileChooser",
"FilePayload",
"FloatRect",
"FormData",
"Frame",
"FrameLocator",
"Geolocation",
"HttpCredentials",
"JSHandle",
"Keyboard",
"Locator",
"Mouse",
"DebuggerLocation",
"DebuggerPausedDetails",
"Page",
"PdfMargins",
"Position",
"Playwright",
"ProxySettings",
"Request",
"ResourceTiming",
"Response",
"Route",
"ScreencastFrame",
"Selectors",
"SourceLocation",
"StorageState",
"StorageStateCookie",
"TimeoutError",
"Touchscreen",
"Video",
"ViewportSize",
"WebError",
"WebSocket",
"WebSocketRoute",
"Worker",
]

View File

@ -0,0 +1,57 @@
# Copyright (c) Microsoft Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from typing import Any
from playwright._impl._connection import Connection
from playwright._impl._object_factory import create_remote_object
from playwright._impl._transport import PipeTransport
from playwright.async_api._generated import Playwright as AsyncPlaywright
class PlaywrightContextManager:
def __init__(self) -> None:
self._connection: Connection
self._exit_was_called = False
async def __aenter__(self) -> AsyncPlaywright:
loop = asyncio.get_running_loop()
self._connection = Connection(
None,
create_remote_object,
PipeTransport(loop),
loop,
)
loop.create_task(self._connection.run())
playwright_future = self._connection.playwright_future
done, _ = await asyncio.wait(
{self._connection._transport.on_error_future, playwright_future},
return_when=asyncio.FIRST_COMPLETED,
)
if not playwright_future.done():
playwright_future.cancel()
playwright = AsyncPlaywright(next(iter(done)).result())
playwright.stop = self.__aexit__ # type: ignore
return playwright
async def start(self) -> AsyncPlaywright:
return await self.__aenter__()
async def __aexit__(self, *args: Any) -> None:
if self._exit_was_called:
return
self._exit_was_called = True
await self._connection.stop_async()

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More