keres-sdk 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.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: keres-sdk
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,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keres-sdk"
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_sdk = ["py.typed"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,38 @@
1
+ """keres-sdk — 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("_")]
38
+
@@ -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
+ )