keres-playwright 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keres_playwright-0.1.0/PKG-INFO +121 -0
- keres_playwright-0.1.0/README.md +97 -0
- keres_playwright-0.1.0/pyproject.toml +39 -0
- keres_playwright-0.1.0/setup.cfg +4 -0
- keres_playwright-0.1.0/src/keres_playwright/__init__.py +37 -0
- keres_playwright-0.1.0/src/keres_playwright/_errors.py +115 -0
- keres_playwright-0.1.0/src/keres_playwright/_expect.py +236 -0
- keres_playwright-0.1.0/src/keres_playwright/_operations.py +581 -0
- keres_playwright-0.1.0/src/keres_playwright/_protocol.py +94 -0
- keres_playwright-0.1.0/src/keres_playwright/_resolution.py +91 -0
- keres_playwright-0.1.0/src/keres_playwright/_transport.py +233 -0
- keres_playwright-0.1.0/src/keres_playwright/async_api.py +67 -0
- keres_playwright-0.1.0/src/keres_playwright/browser.py +118 -0
- keres_playwright-0.1.0/src/keres_playwright/locator.py +182 -0
- keres_playwright-0.1.0/src/keres_playwright/page.py +462 -0
- keres_playwright-0.1.0/src/keres_playwright/py.typed +0 -0
- keres_playwright-0.1.0/src/keres_playwright/sync_api.py +434 -0
- keres_playwright-0.1.0/src/keres_playwright.egg-info/PKG-INFO +121 -0
- keres_playwright-0.1.0/src/keres_playwright.egg-info/SOURCES.txt +26 -0
- keres_playwright-0.1.0/src/keres_playwright.egg-info/dependency_links.txt +1 -0
- keres_playwright-0.1.0/src/keres_playwright.egg-info/requires.txt +4 -0
- keres_playwright-0.1.0/src/keres_playwright.egg-info/top_level.txt +1 -0
- keres_playwright-0.1.0/tests/test_client.py +243 -0
- keres_playwright-0.1.0/tests/test_live_google.py +100 -0
- keres_playwright-0.1.0/tests/test_mappings.py +237 -0
- keres_playwright-0.1.0/tests/test_resolution.py +71 -0
- keres_playwright-0.1.0/tests/test_sync.py +70 -0
- keres_playwright-0.1.0/tests/test_wire.py +100 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keres-playwright
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Playwright-compatible automation client for Keres Browser. Connects to the local Keres Automation Gateway; Keres owns profiles, sessions, and the browser runtime.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: keres,playwright,automation,browser
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: websockets<16,>=12
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# keres-playwright
|
|
26
|
+
|
|
27
|
+
Playwright-compatible automation client for [Keres Browser](https://keres.browser).
|
|
28
|
+
Connects to the local Keres Automation Gateway; Keres owns profiles, sessions,
|
|
29
|
+
and the browser runtime (Chromium, Gecko, Camoufox).
|
|
30
|
+
|
|
31
|
+
> Keres is a web browser, profile manager, and automation platform. This client
|
|
32
|
+
> drives browsers through a governed local API — it makes no claims about
|
|
33
|
+
> bypassing third-party systems.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
Until the package is published to PyPI (tracked in Stage 17), install from the
|
|
38
|
+
repository checkout:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install -e path/to/keres/compatibility/python
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Requires Python 3.10+ and the Keres desktop app running locally.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from keres_playwright import keres
|
|
50
|
+
|
|
51
|
+
browser = keres.connect({"profile": "sales-profile"})
|
|
52
|
+
page = browser.new_page()
|
|
53
|
+
page.goto("https://example.com")
|
|
54
|
+
print(page.title())
|
|
55
|
+
page.get_by_role("button", name="Sign in").click()
|
|
56
|
+
browser.close() # disconnects; the profile stays alive in Keres
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Async flavor:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import asyncio
|
|
63
|
+
from keres_playwright.async_api import keres
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
browser = await keres.connect({"profile": "sales-profile"})
|
|
67
|
+
page = await browser.new_page()
|
|
68
|
+
await page.goto("https://example.com")
|
|
69
|
+
print(await page.title())
|
|
70
|
+
await browser.close()
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Concepts
|
|
76
|
+
|
|
77
|
+
- **Profile ≠ Context.** A `Browser` maps onto one Keres session bound to the
|
|
78
|
+
profile chosen at connect time. Persistent identity (cookies, storage,
|
|
79
|
+
network configuration) lives with the Keres profile — not with this handle.
|
|
80
|
+
- **`close()` is disconnect-only.** Sessions and profiles remain managed by
|
|
81
|
+
Keres after the client disconnects.
|
|
82
|
+
- **Explicit profiles.** `connect` requires `{"profile": ...}`; desktop-user
|
|
83
|
+
sessions are never adopted silently.
|
|
84
|
+
- **Auto-waiting is server-side**, delegated to the Keres Automation API wait
|
|
85
|
+
engine. The client builds no second waiting implementation.
|
|
86
|
+
|
|
87
|
+
## Errors
|
|
88
|
+
|
|
89
|
+
Error names follow Playwright conventions (`TimeoutError`, ...) while every
|
|
90
|
+
error carries the original structured Keres code:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from keres_playwright import TimeoutError, KeresError
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
page.goto("https://example.com", timeout_ms=5000)
|
|
97
|
+
except TimeoutError as exc:
|
|
98
|
+
print(exc.code) # e.g. NAVIGATION_TIMEOUT
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Endpoint and token resolution
|
|
102
|
+
|
|
103
|
+
1. Explicit options: `connect({"endpoint": ..., "token": ...})`
|
|
104
|
+
2. Environment: `KERES_GATEWAY_ENDPOINT`, `KERES_GATEWAY_TOKEN`
|
|
105
|
+
3. Discovery file published by the running app:
|
|
106
|
+
`%APPDATA%\com.keres.browser\gateway\endpoint.json` (Windows),
|
|
107
|
+
`~/Library/Application Support/com.keres.browser/gateway/endpoint.json`
|
|
108
|
+
(macOS), `$XDG_CONFIG_HOME/com.keres.browser/gateway/endpoint.json`
|
|
109
|
+
(Linux). Reveal the token in Keres Settings → Developer automation.
|
|
110
|
+
|
|
111
|
+
The token is sent as the first WebSocket frame — never placed in URLs or logs.
|
|
112
|
+
|
|
113
|
+
## Capability matrix
|
|
114
|
+
|
|
115
|
+
Engine support per operation is documented in
|
|
116
|
+
[`api/PLAYWRIGHT_API.md`](../../api/PLAYWRIGHT_API.md); unsupported operations
|
|
117
|
+
raise `UnsupportedCapabilityError` naming the engine.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# keres-playwright
|
|
2
|
+
|
|
3
|
+
Playwright-compatible automation client for [Keres Browser](https://keres.browser).
|
|
4
|
+
Connects to the local Keres Automation Gateway; Keres owns profiles, sessions,
|
|
5
|
+
and the browser runtime (Chromium, Gecko, Camoufox).
|
|
6
|
+
|
|
7
|
+
> Keres is a web browser, profile manager, and automation platform. This client
|
|
8
|
+
> drives browsers through a governed local API — it makes no claims about
|
|
9
|
+
> bypassing third-party systems.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Until the package is published to PyPI (tracked in Stage 17), install from the
|
|
14
|
+
repository checkout:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install -e path/to/keres/compatibility/python
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Python 3.10+ and the Keres desktop app running locally.
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from keres_playwright import keres
|
|
26
|
+
|
|
27
|
+
browser = keres.connect({"profile": "sales-profile"})
|
|
28
|
+
page = browser.new_page()
|
|
29
|
+
page.goto("https://example.com")
|
|
30
|
+
print(page.title())
|
|
31
|
+
page.get_by_role("button", name="Sign in").click()
|
|
32
|
+
browser.close() # disconnects; the profile stays alive in Keres
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Async flavor:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
from keres_playwright.async_api import keres
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
browser = await keres.connect({"profile": "sales-profile"})
|
|
43
|
+
page = await browser.new_page()
|
|
44
|
+
await page.goto("https://example.com")
|
|
45
|
+
print(await page.title())
|
|
46
|
+
await browser.close()
|
|
47
|
+
|
|
48
|
+
asyncio.run(main())
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Concepts
|
|
52
|
+
|
|
53
|
+
- **Profile ≠ Context.** A `Browser` maps onto one Keres session bound to the
|
|
54
|
+
profile chosen at connect time. Persistent identity (cookies, storage,
|
|
55
|
+
network configuration) lives with the Keres profile — not with this handle.
|
|
56
|
+
- **`close()` is disconnect-only.** Sessions and profiles remain managed by
|
|
57
|
+
Keres after the client disconnects.
|
|
58
|
+
- **Explicit profiles.** `connect` requires `{"profile": ...}`; desktop-user
|
|
59
|
+
sessions are never adopted silently.
|
|
60
|
+
- **Auto-waiting is server-side**, delegated to the Keres Automation API wait
|
|
61
|
+
engine. The client builds no second waiting implementation.
|
|
62
|
+
|
|
63
|
+
## Errors
|
|
64
|
+
|
|
65
|
+
Error names follow Playwright conventions (`TimeoutError`, ...) while every
|
|
66
|
+
error carries the original structured Keres code:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from keres_playwright import TimeoutError, KeresError
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
page.goto("https://example.com", timeout_ms=5000)
|
|
73
|
+
except TimeoutError as exc:
|
|
74
|
+
print(exc.code) # e.g. NAVIGATION_TIMEOUT
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Endpoint and token resolution
|
|
78
|
+
|
|
79
|
+
1. Explicit options: `connect({"endpoint": ..., "token": ...})`
|
|
80
|
+
2. Environment: `KERES_GATEWAY_ENDPOINT`, `KERES_GATEWAY_TOKEN`
|
|
81
|
+
3. Discovery file published by the running app:
|
|
82
|
+
`%APPDATA%\com.keres.browser\gateway\endpoint.json` (Windows),
|
|
83
|
+
`~/Library/Application Support/com.keres.browser/gateway/endpoint.json`
|
|
84
|
+
(macOS), `$XDG_CONFIG_HOME/com.keres.browser/gateway/endpoint.json`
|
|
85
|
+
(Linux). Reveal the token in Keres Settings → Developer automation.
|
|
86
|
+
|
|
87
|
+
The token is sent as the first WebSocket frame — never placed in URLs or logs.
|
|
88
|
+
|
|
89
|
+
## Capability matrix
|
|
90
|
+
|
|
91
|
+
Engine support per operation is documented in
|
|
92
|
+
[`api/PLAYWRIGHT_API.md`](../../api/PLAYWRIGHT_API.md); unsupported operations
|
|
93
|
+
raise `UnsupportedCapabilityError` naming the engine.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keres-playwright"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Playwright-compatible automation client for Keres Browser. Connects to the local Keres Automation Gateway; Keres owns profiles, sessions, and the browser runtime."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["websockets>=12,<16"]
|
|
13
|
+
keywords = ["keres", "playwright", "automation", "browser"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: Microsoft :: Windows",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Operating System :: MacOS",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Software Development :: Testing",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=8"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
keres_playwright = ["py.typed"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""keres-playwright — Playwright-compatible automation client for Keres Browser.
|
|
2
|
+
|
|
3
|
+
Connects to the local Keres Automation Gateway; Keres owns profiles, sessions,
|
|
4
|
+
and the browser runtime. See ``sync_api`` / ``async_api`` for entry points.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._errors import (
|
|
8
|
+
ConnectionError,
|
|
9
|
+
KeresError,
|
|
10
|
+
PermissionDeniedError,
|
|
11
|
+
ProfileError,
|
|
12
|
+
ServerBusyError,
|
|
13
|
+
TargetClosedError,
|
|
14
|
+
TimeoutError,
|
|
15
|
+
UnsupportedCapabilityError,
|
|
16
|
+
from_gateway_error,
|
|
17
|
+
)
|
|
18
|
+
from .sync_api import keres
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"keres",
|
|
24
|
+
"KeresError",
|
|
25
|
+
"ConnectionError",
|
|
26
|
+
"TimeoutError",
|
|
27
|
+
"UnsupportedCapabilityError",
|
|
28
|
+
"PermissionDeniedError",
|
|
29
|
+
"TargetClosedError",
|
|
30
|
+
"ProfileError",
|
|
31
|
+
"ServerBusyError",
|
|
32
|
+
"from_gateway_error",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
35
|
+
from ._expect import expect, KeresExpectationError
|
|
36
|
+
|
|
37
|
+
__all__ = [name for name in dir() if not name.startswith("_")]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Typed errors for the keres-playwright client.
|
|
2
|
+
|
|
3
|
+
Error names follow Playwright conventions (TimeoutError, ...) so existing
|
|
4
|
+
`try/except` code keeps working, while every error carries the original
|
|
5
|
+
structured Keres code under ``code`` for precise handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from ._protocol import ErrorShape
|
|
13
|
+
|
|
14
|
+
_CAPABILITY_CODES = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"CAPABILITY_UNSUPPORTED",
|
|
17
|
+
"ENGINE_FEATURE_UNSUPPORTED",
|
|
18
|
+
"ENGINE_PROTOCOL_UNSUPPORTED",
|
|
19
|
+
"ENGINE_VARIANT_UNAVAILABLE",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_PROFILE_CODES = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"PROFILE_NOT_FOUND",
|
|
26
|
+
"PROFILE_ACTIVE",
|
|
27
|
+
"PROFILE_LOCKED",
|
|
28
|
+
"PROFILE_DATA_MISSING",
|
|
29
|
+
"PROFILE_DATA_CORRUPTED",
|
|
30
|
+
"INVALID_PROFILE",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_TARGET_CLOSED_CODES = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"SESSION_CLOSED",
|
|
37
|
+
"SESSION_NOT_READY",
|
|
38
|
+
"SESSION_INTERRUPTED",
|
|
39
|
+
"PAGE_CLOSED",
|
|
40
|
+
"PAGE_NOT_FOUND",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class KeresError(Exception):
|
|
46
|
+
"""Base class for every error raised by this client."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
49
|
+
super().__init__(shape.message)
|
|
50
|
+
self.name = "KeresError"
|
|
51
|
+
self.code = shape.code
|
|
52
|
+
self.category = shape.category
|
|
53
|
+
self.recoverable = shape.recoverable
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ConnectionError(KeresError):
|
|
57
|
+
def __init__(self, message: str) -> None:
|
|
58
|
+
super().__init__(
|
|
59
|
+
ErrorShape(code="GATEWAY_CONNECTION_FAILED", message=message, category="GATEWAY")
|
|
60
|
+
)
|
|
61
|
+
self.name = "ConnectionError"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TimeoutError(KeresError):
|
|
65
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
66
|
+
super().__init__(shape)
|
|
67
|
+
self.name = "TimeoutError"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UnsupportedCapabilityError(KeresError):
|
|
71
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
72
|
+
super().__init__(shape)
|
|
73
|
+
self.name = "UnsupportedCapabilityError"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PermissionDeniedError(KeresError):
|
|
77
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
78
|
+
super().__init__(shape)
|
|
79
|
+
self.name = "PermissionDeniedError"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TargetClosedError(KeresError):
|
|
83
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
84
|
+
super().__init__(shape)
|
|
85
|
+
self.name = "TargetClosedError"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ProfileError(KeresError):
|
|
89
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
90
|
+
super().__init__(shape)
|
|
91
|
+
self.name = "ProfileError"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ServerBusyError(KeresError):
|
|
95
|
+
def __init__(self, shape: ErrorShape) -> None:
|
|
96
|
+
super().__init__(shape)
|
|
97
|
+
self.name = "ServerBusyError"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def from_gateway_error(error: ErrorShape) -> KeresError:
|
|
101
|
+
"""Maps a structured gateway error onto the typed surface above."""
|
|
102
|
+
code = error.code
|
|
103
|
+
if code.endswith("_TIMEOUT"):
|
|
104
|
+
return TimeoutError(error)
|
|
105
|
+
if code in _CAPABILITY_CODES:
|
|
106
|
+
return UnsupportedCapabilityError(error)
|
|
107
|
+
if code in ("GATEWAY_PERMISSION_DENIED", "SESSION_PERMISSION_DENIED"):
|
|
108
|
+
return PermissionDeniedError(error)
|
|
109
|
+
if code == "GATEWAY_SERVER_BUSY":
|
|
110
|
+
return ServerBusyError(error)
|
|
111
|
+
if code in _PROFILE_CODES:
|
|
112
|
+
return ProfileError(error)
|
|
113
|
+
if code in _TARGET_CLOSED_CODES:
|
|
114
|
+
return TargetClosedError(error)
|
|
115
|
+
return KeresError(error)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Web-first auto-retrying assertions (Playwright ``expect`` shape).
|
|
2
|
+
|
|
3
|
+
Polling is client-side over governed read operations — no new wire semantics.
|
|
4
|
+
Works from both API flavors: probes transparently await async locator/page
|
|
5
|
+
methods when driven through :mod:`keres_playwright.async_api`, and run
|
|
6
|
+
synchronously through the sync facade.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import fnmatch
|
|
13
|
+
import inspect
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Callable, Optional
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT_MS = 5_000
|
|
18
|
+
DEFAULT_INTERVAL_MS = 100
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class KeresExpectationError(AssertionError):
|
|
22
|
+
"""Raised when an assertion does not pass within its deadline."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, assertion: str, expected: str, received: str) -> None:
|
|
25
|
+
super().__init__(
|
|
26
|
+
f"expect({assertion}) failed\n\nExpected: {expected}\nReceived: {received}"
|
|
27
|
+
)
|
|
28
|
+
self.assertion = assertion
|
|
29
|
+
self.expected = expected
|
|
30
|
+
self.received = received
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _poll_until(
|
|
34
|
+
assertion: str,
|
|
35
|
+
expected_description: str,
|
|
36
|
+
deadline_ms: float,
|
|
37
|
+
interval_ms: float,
|
|
38
|
+
probe: Callable[[], Any],
|
|
39
|
+
) -> None:
|
|
40
|
+
"""``probe`` returns an ``(ok, received)`` tuple or an awaitable of one."""
|
|
41
|
+
started_ms = time.monotonic() * 1000
|
|
42
|
+
received = "unknown"
|
|
43
|
+
while time.monotonic() * 1000 - started_ms < deadline_ms:
|
|
44
|
+
result = probe()
|
|
45
|
+
if inspect.isawaitable(result):
|
|
46
|
+
result = await result
|
|
47
|
+
ok, received = result
|
|
48
|
+
if ok:
|
|
49
|
+
return
|
|
50
|
+
await asyncio.sleep(interval_ms / 1000)
|
|
51
|
+
raise KeresExpectationError(assertion, expected_description, received)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _bool_probe(value: Any) -> Any:
|
|
55
|
+
"""Adapts a sync-or-async boolean into an ``(ok, received)`` tuple."""
|
|
56
|
+
if inspect.isawaitable(value):
|
|
57
|
+
|
|
58
|
+
async def runner() -> tuple:
|
|
59
|
+
got = bool(await value)
|
|
60
|
+
return got, str(got)
|
|
61
|
+
|
|
62
|
+
return runner()
|
|
63
|
+
got = bool(value)
|
|
64
|
+
return got, str(got)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _text_probe(value: Any, predicate: Callable[[str], bool]) -> Any:
|
|
68
|
+
"""Adapts a sync-or-async text into a predicate-checked tuple."""
|
|
69
|
+
if inspect.isawaitable(value):
|
|
70
|
+
|
|
71
|
+
async def runner() -> tuple:
|
|
72
|
+
text = (await value) or ""
|
|
73
|
+
return predicate(text), text
|
|
74
|
+
|
|
75
|
+
return runner()
|
|
76
|
+
text = value or ""
|
|
77
|
+
return predicate(text), text
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _opts(options: Optional[dict]) -> dict:
|
|
81
|
+
return options or {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def expect(target: Any) -> Any:
|
|
85
|
+
"""Playwright-style entry point. Returns an assertion object."""
|
|
86
|
+
if target.__class__.__name__ in ("Page", "SyncPage"):
|
|
87
|
+
return PageAssertions(target)
|
|
88
|
+
return LocatorAssertions(target)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LocatorAssertions:
|
|
92
|
+
def __init__(self, locator: Any) -> None:
|
|
93
|
+
self._locator = locator
|
|
94
|
+
|
|
95
|
+
def to_be_visible(self, options: Optional[dict] = None) -> Any:
|
|
96
|
+
o = _opts(options)
|
|
97
|
+
return _poll_until(
|
|
98
|
+
"locator.to_be_visible",
|
|
99
|
+
"visible",
|
|
100
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
101
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
102
|
+
lambda: _bool_probe(self._locator.is_visible()),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def to_be_hidden(self, options: Optional[dict] = None) -> Any:
|
|
106
|
+
o = _opts(options)
|
|
107
|
+
|
|
108
|
+
def probe() -> Any:
|
|
109
|
+
value = self._locator.is_visible()
|
|
110
|
+
if inspect.isawaitable(value):
|
|
111
|
+
|
|
112
|
+
async def runner() -> tuple:
|
|
113
|
+
visible = bool(await value)
|
|
114
|
+
return not visible, "hidden" if not visible else "visible"
|
|
115
|
+
|
|
116
|
+
return runner()
|
|
117
|
+
return not value, "hidden" if not value else "visible"
|
|
118
|
+
|
|
119
|
+
return _poll_until(
|
|
120
|
+
"locator.to_be_hidden",
|
|
121
|
+
"hidden",
|
|
122
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
123
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
124
|
+
probe,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def to_be_enabled(self, options: Optional[dict] = None) -> Any:
|
|
128
|
+
o = _opts(options)
|
|
129
|
+
return _poll_until(
|
|
130
|
+
"locator.to_be_enabled",
|
|
131
|
+
"enabled",
|
|
132
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
133
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
134
|
+
lambda: _bool_probe(self._locator.is_enabled()),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def to_be_checked(self, expected: bool = True, options: Optional[dict] = None) -> Any:
|
|
138
|
+
o = _opts(options)
|
|
139
|
+
return _poll_until(
|
|
140
|
+
"locator.to_be_checked",
|
|
141
|
+
str(expected),
|
|
142
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
143
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
144
|
+
lambda: _bool_probe(self._locator.is_checked() == expected),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def to_be_editable(self, options: Optional[dict] = None) -> Any:
|
|
148
|
+
o = _opts(options)
|
|
149
|
+
return _poll_until(
|
|
150
|
+
"locator.to_be_editable",
|
|
151
|
+
"editable",
|
|
152
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
153
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
154
|
+
lambda: _bool_probe(self._locator.is_editable()),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def to_have_text(self, text: str, options: Optional[dict] = None) -> Any:
|
|
158
|
+
o = _opts(options)
|
|
159
|
+
exact = o.get("exact", True)
|
|
160
|
+
return _poll_until(
|
|
161
|
+
"locator.to_have_text",
|
|
162
|
+
text,
|
|
163
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
164
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
165
|
+
lambda: _text_probe(
|
|
166
|
+
self._locator.text_content(options=o),
|
|
167
|
+
(lambda actual: actual == text) if exact else (lambda actual: text in actual),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def to_contain_text(self, text: str, options: Optional[dict] = None) -> Any:
|
|
172
|
+
merged = dict(_opts(options))
|
|
173
|
+
merged["exact"] = False
|
|
174
|
+
return self.to_have_text(text, merged)
|
|
175
|
+
|
|
176
|
+
def to_have_value(self, value: str, options: Optional[dict] = None) -> Any:
|
|
177
|
+
o = _opts(options)
|
|
178
|
+
return _poll_until(
|
|
179
|
+
"locator.to_have_value",
|
|
180
|
+
value,
|
|
181
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
182
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
183
|
+
lambda: _text_probe(
|
|
184
|
+
self._locator.get_property("value", options=o),
|
|
185
|
+
lambda actual: str(actual) == value,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def to_have_attribute(self, name: str, value: str, options: Optional[dict] = None) -> Any:
|
|
190
|
+
o = _opts(options)
|
|
191
|
+
return _poll_until(
|
|
192
|
+
"locator.to_have_attribute",
|
|
193
|
+
f'{name}="{value}"',
|
|
194
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
195
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
196
|
+
lambda: _text_probe(
|
|
197
|
+
self._locator.get_attribute(name, options=o),
|
|
198
|
+
lambda actual: str(actual) == value,
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class PageAssertions:
|
|
204
|
+
def __init__(self, page: Any) -> None:
|
|
205
|
+
self._page = page
|
|
206
|
+
|
|
207
|
+
def to_have_title(self, title: str, options: Optional[dict] = None) -> Any:
|
|
208
|
+
o = _opts(options)
|
|
209
|
+
exact = o.get("exact", True)
|
|
210
|
+
return _poll_until(
|
|
211
|
+
"page.to_have_title",
|
|
212
|
+
title,
|
|
213
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
214
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
215
|
+
lambda: _text_probe(
|
|
216
|
+
self._page.title(options=o),
|
|
217
|
+
(lambda actual: actual == title) if exact else (lambda actual: title in actual),
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def to_have_url(self, pattern: str, options: Optional[dict] = None) -> Any:
|
|
222
|
+
o = _opts(options)
|
|
223
|
+
use_glob = o.get("glob", "*" in pattern)
|
|
224
|
+
|
|
225
|
+
def matcher(candidate: str) -> bool:
|
|
226
|
+
if use_glob:
|
|
227
|
+
return fnmatch.fnmatchcase(candidate, pattern)
|
|
228
|
+
return candidate == pattern
|
|
229
|
+
|
|
230
|
+
return _poll_until(
|
|
231
|
+
"page.to_have_url",
|
|
232
|
+
pattern,
|
|
233
|
+
o.get("timeoutMs", DEFAULT_TIMEOUT_MS),
|
|
234
|
+
o.get("intervalMs", DEFAULT_INTERVAL_MS),
|
|
235
|
+
lambda: (matcher(self._page.url()), self._page.url()),
|
|
236
|
+
)
|