helixwright 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.
Files changed (35) hide show
  1. helixwright-0.1.0/PKG-INFO +240 -0
  2. helixwright-0.1.0/README.md +221 -0
  3. helixwright-0.1.0/helixwright/__init__.py +58 -0
  4. helixwright-0.1.0/helixwright/_impl/__init__.py +1 -0
  5. helixwright-0.1.0/helixwright/_impl/_assertions.py +184 -0
  6. helixwright-0.1.0/helixwright/_impl/_consent.py +80 -0
  7. helixwright-0.1.0/helixwright/_impl/_errors.py +44 -0
  8. helixwright-0.1.0/helixwright/_impl/_forensics.py +202 -0
  9. helixwright-0.1.0/helixwright/_impl/_humanize.py +250 -0
  10. helixwright-0.1.0/helixwright/_impl/_input.py +238 -0
  11. helixwright-0.1.0/helixwright/_impl/_locator.py +1322 -0
  12. helixwright-0.1.0/helixwright/_impl/_network.py +265 -0
  13. helixwright-0.1.0/helixwright/_impl/_operator.py +746 -0
  14. helixwright-0.1.0/helixwright/_impl/_page.py +2585 -0
  15. helixwright-0.1.0/helixwright/_impl/_search.py +2 -0
  16. helixwright-0.1.0/helixwright/_impl/_selectors.py +322 -0
  17. helixwright-0.1.0/helixwright/_impl/_settings.py +51 -0
  18. helixwright-0.1.0/helixwright/_impl/_transport.py +127 -0
  19. helixwright-0.1.0/helixwright/_impl/_waits.py +204 -0
  20. helixwright-0.1.0/helixwright/client.py +504 -0
  21. helixwright-0.1.0/helixwright/errors.py +50 -0
  22. helixwright-0.1.0/helixwright/launcher.py +267 -0
  23. helixwright-0.1.0/helixwright/models.py +117 -0
  24. helixwright-0.1.0/helixwright/py.typed +1 -0
  25. helixwright-0.1.0/helixwright/solver.py +107 -0
  26. helixwright-0.1.0/helixwright.egg-info/PKG-INFO +240 -0
  27. helixwright-0.1.0/helixwright.egg-info/SOURCES.txt +33 -0
  28. helixwright-0.1.0/helixwright.egg-info/dependency_links.txt +1 -0
  29. helixwright-0.1.0/helixwright.egg-info/top_level.txt +1 -0
  30. helixwright-0.1.0/pyproject.toml +36 -0
  31. helixwright-0.1.0/setup.cfg +4 -0
  32. helixwright-0.1.0/tests/test_helixwright_local_api.py +342 -0
  33. helixwright-0.1.0/tests/test_public_api.py +144 -0
  34. helixwright-0.1.0/tests/test_rpc_contract.py +80 -0
  35. helixwright-0.1.0/tests/test_selector_grammar.py +60 -0
@@ -0,0 +1,240 @@
1
+ Metadata-Version: 2.4
2
+ Name: helixwright
3
+ Version: 0.1.0
4
+ Summary: Python automation SDK for Helix Browser Local API and the private Helix RPC page-control plane.
5
+ Author: Helix Browser
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://pypi.org/project/helixwright/
8
+ Project-URL: Documentation, https://pypi.org/project/helixwright/
9
+ Keywords: automation,browser,fingerprint,chromium,local-api,helix
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Typing :: Typed
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Helixwright
21
+
22
+ Helixwright is the Python automation SDK for Helix Browser.
23
+
24
+ It does not create fingerprints locally, manage browser profile folders, or
25
+ start `chrome.exe` directly. Profile creation, fingerprint generation, proxy
26
+ configuration, browser launch, and profile sync are delegated to Helix Browser
27
+ Desktop through its Local API. Page actions then use the private Helix RPC
28
+ control plane built into the Helix Chromium fork, not CDP, DevTools, WebDriver,
29
+ Playwright, or Selenium.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install helixwright
35
+ ```
36
+
37
+ Requirements:
38
+
39
+ 1. Helix Browser Desktop is installed and running.
40
+ 2. The desktop app is logged in.
41
+ 3. The desktop Local API is available, usually at `http://127.0.0.1:59777`.
42
+ 4. The desktop build supports `/api/v1/automation/sessions`.
43
+
44
+ Helixwright discovers the Local API from:
45
+
46
+ ```text
47
+ %APPDATA%\Helix Browser\local_api.json
48
+ ```
49
+
50
+ You can override it explicitly:
51
+
52
+ ```powershell
53
+ $env:HELIX_LOCAL_API = "http://127.0.0.1:59777"
54
+ ```
55
+
56
+ ## Quick Start
57
+
58
+ Reuse an existing Helix profile:
59
+
60
+ ```python
61
+ import helixwright as hw
62
+
63
+ with hw.launch(profile_id="profile_123", url="https://example.com") as page:
64
+ page.ele("#email").input("user@example.com")
65
+ page.ele("#submit").click()
66
+ ```
67
+
68
+ Create or reuse a profile by `account_id`:
69
+
70
+ ```python
71
+ import helixwright as hw
72
+
73
+ with hw.launch(
74
+ "https://example.com",
75
+ account_id="shop-001",
76
+ name="Shop 001",
77
+ proxy="http://user:pass@127.0.0.1:7890",
78
+ fingerprint=hw.Fingerprint(locale="en-US", region="US", group="octo"),
79
+ update_existing=True,
80
+ ) as page:
81
+ page.wait_for("body", timeout=10_000)
82
+ print(page.title())
83
+ ```
84
+
85
+ Use a reusable launch config:
86
+
87
+ ```python
88
+ import helixwright as hw
89
+
90
+ cfg = hw.LaunchConfig(
91
+ account_id="shop-002",
92
+ name="Shop 002",
93
+ url="https://example.com",
94
+ start_pages=["https://example.com", "https://example.com/login"],
95
+ proxy="http://127.0.0.1:7890",
96
+ fingerprint=hw.Fingerprint(locale="en-US", region="US"),
97
+ update_existing=True,
98
+ )
99
+
100
+ with hw.launch(config=cfg) as page:
101
+ page.ele("text=Login").click()
102
+ ```
103
+
104
+ ## Client API
105
+
106
+ Use `Client` when you want to manage profiles, fingerprints, proxies, cores, or
107
+ automation sessions explicitly:
108
+
109
+ ```python
110
+ import helixwright as hw
111
+
112
+ client = hw.Client()
113
+ print(client.health())
114
+ print(client.session())
115
+
116
+ profile = client.profiles.ensure(
117
+ account_id="shop-003",
118
+ name="Shop 003",
119
+ proxy=hw.Proxy("http", "127.0.0.1", 7890),
120
+ fingerprint=hw.Fingerprint(locale="en-US", region="US"),
121
+ update_existing=True,
122
+ )
123
+
124
+ with profile.launch(url="https://example.com") as page:
125
+ print(page.url)
126
+ ```
127
+
128
+ Common managers:
129
+
130
+ ```python
131
+ client.profiles.list(page=1, size=20)
132
+ client.profiles.get("profile_123")
133
+ client.profiles.create(name="A", account_id="acct_A")
134
+ client.profiles.ensure(account_id="acct_A")
135
+ client.profiles.clone("profile_123", count=2)
136
+ client.profiles.sync_up("profile_123")
137
+ client.profiles.sync_down("profile_123")
138
+
139
+ client.fingerprint.options()
140
+ client.fingerprint.draw(account_id="acct_A", group="octo")
141
+ client.fingerprint.serialize({"ua": "..."}, seed=123)
142
+ client.fingerprint.redraw({"ua": "..."}, "ua")
143
+
144
+ client.proxy.check("http://127.0.0.1:7890")
145
+ client.cores.list()
146
+ client.cores.ensure("chromium", "150")
147
+ client.browser.active()
148
+ client.browser.start("profile_123")
149
+ client.browser.stop("profile_123")
150
+ client.browser.stop_all()
151
+ client.automation.list()
152
+ client.automation.start("profile_123", url="https://example.com")
153
+ client.automation.stop("auto_123")
154
+ ```
155
+
156
+ ## Launch Rules
157
+
158
+ `hw.launch()` requires either `profile_id` or `account_id`/`account`.
159
+
160
+ Important behavior:
161
+
162
+ - `profile_id` means reuse that exact Helix profile.
163
+ - `account_id` means find an existing profile by account ID, or create one
164
+ when `create=True`.
165
+ - `fingerprint=hw.Fingerprint(...)` only sends creation hints to Helix Local
166
+ API. The SDK does not generate fingerprints itself.
167
+ - `proxy` can be a string, dict, or `hw.Proxy`.
168
+ - `start_pages` is passed to the Local API automation session.
169
+ - `config=hw.LaunchConfig(...)` should not be mixed with explicit
170
+ `profile_id`, `account_id`, `name`, `proxy`, or `fingerprint` arguments.
171
+
172
+ Unsupported legacy direct-launch arguments are rejected, including `chrome`,
173
+ `user_data_dir`, `persona`, `seed`, `geoip`, and `extra_args`.
174
+
175
+ ## Errors
176
+
177
+ Common error types:
178
+
179
+ | Error | Meaning |
180
+ | --- | --- |
181
+ | `LocalApiUnavailable` | Helix Desktop is not running, the Local API address is wrong, or the port is unreachable. |
182
+ | `DesktopNotLoggedIn` | Desktop is reachable but not logged in. |
183
+ | `LocalApiError` | Local API returned a business error. |
184
+ | `ProfileNotFound` | The requested profile does not exist. |
185
+ | `AutomationEndpointUnavailable` | The desktop build did not return a usable automation RPC endpoint. |
186
+ | `AutomationSessionNotFound` | The requested automation session does not exist. |
187
+ | `TransportError` | The private page RPC connection failed, usually because the browser exited. |
188
+ | `WaitTimeoutError` | A page wait condition timed out. |
189
+
190
+ ## Examples
191
+
192
+ From the source repository:
193
+
194
+ ```powershell
195
+ cd "E:\Helix Browser\repos\helixwright"
196
+ python examples\99_offline_mock_demo.py
197
+ ```
198
+
199
+ The offline demo does not need Helix Browser. It starts a mock Local API and
200
+ mock RPC endpoint, then exercises the public SDK surface.
201
+
202
+ Real desktop examples require Helix Browser Desktop to be running and logged in:
203
+
204
+ ```powershell
205
+ python examples\00_helixwright_quickstart.py
206
+ python examples\01_client_managers.py
207
+ python examples\02_launch_config_and_actions.py
208
+ ```
209
+
210
+ Useful environment variables:
211
+
212
+ ```powershell
213
+ $env:HELIX_LOCAL_API = "http://127.0.0.1:59777"
214
+ $env:HELIX_EXAMPLE_ACCOUNT = "demo-account"
215
+ $env:HELIX_EXAMPLE_URL = "https://example.com"
216
+ $env:HELIX_EXAMPLE_PROXY = "http://127.0.0.1:7890"
217
+ $env:HELIX_EXAMPLE_TIMEOUT_MS = "30000"
218
+ ```
219
+
220
+ ## Development
221
+
222
+ ```powershell
223
+ cd "E:\Helix Browser\repos\helixwright"
224
+ pip install -e .
225
+ python tests\ci.py
226
+ ```
227
+
228
+ Build and validate a distribution:
229
+
230
+ ```powershell
231
+ python -m build
232
+ python -m twine check dist/*
233
+ ```
234
+
235
+ ## Boundary
236
+
237
+ Helixwright is only the automation SDK. The authoritative source for profiles,
238
+ fingerprints, proxies, browser core downloads, cloud sync, and launch lifecycle
239
+ is Helix Browser Desktop plus its Local API.
240
+
@@ -0,0 +1,221 @@
1
+ # Helixwright
2
+
3
+ Helixwright is the Python automation SDK for Helix Browser.
4
+
5
+ It does not create fingerprints locally, manage browser profile folders, or
6
+ start `chrome.exe` directly. Profile creation, fingerprint generation, proxy
7
+ configuration, browser launch, and profile sync are delegated to Helix Browser
8
+ Desktop through its Local API. Page actions then use the private Helix RPC
9
+ control plane built into the Helix Chromium fork, not CDP, DevTools, WebDriver,
10
+ Playwright, or Selenium.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install helixwright
16
+ ```
17
+
18
+ Requirements:
19
+
20
+ 1. Helix Browser Desktop is installed and running.
21
+ 2. The desktop app is logged in.
22
+ 3. The desktop Local API is available, usually at `http://127.0.0.1:59777`.
23
+ 4. The desktop build supports `/api/v1/automation/sessions`.
24
+
25
+ Helixwright discovers the Local API from:
26
+
27
+ ```text
28
+ %APPDATA%\Helix Browser\local_api.json
29
+ ```
30
+
31
+ You can override it explicitly:
32
+
33
+ ```powershell
34
+ $env:HELIX_LOCAL_API = "http://127.0.0.1:59777"
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ Reuse an existing Helix profile:
40
+
41
+ ```python
42
+ import helixwright as hw
43
+
44
+ with hw.launch(profile_id="profile_123", url="https://example.com") as page:
45
+ page.ele("#email").input("user@example.com")
46
+ page.ele("#submit").click()
47
+ ```
48
+
49
+ Create or reuse a profile by `account_id`:
50
+
51
+ ```python
52
+ import helixwright as hw
53
+
54
+ with hw.launch(
55
+ "https://example.com",
56
+ account_id="shop-001",
57
+ name="Shop 001",
58
+ proxy="http://user:pass@127.0.0.1:7890",
59
+ fingerprint=hw.Fingerprint(locale="en-US", region="US", group="octo"),
60
+ update_existing=True,
61
+ ) as page:
62
+ page.wait_for("body", timeout=10_000)
63
+ print(page.title())
64
+ ```
65
+
66
+ Use a reusable launch config:
67
+
68
+ ```python
69
+ import helixwright as hw
70
+
71
+ cfg = hw.LaunchConfig(
72
+ account_id="shop-002",
73
+ name="Shop 002",
74
+ url="https://example.com",
75
+ start_pages=["https://example.com", "https://example.com/login"],
76
+ proxy="http://127.0.0.1:7890",
77
+ fingerprint=hw.Fingerprint(locale="en-US", region="US"),
78
+ update_existing=True,
79
+ )
80
+
81
+ with hw.launch(config=cfg) as page:
82
+ page.ele("text=Login").click()
83
+ ```
84
+
85
+ ## Client API
86
+
87
+ Use `Client` when you want to manage profiles, fingerprints, proxies, cores, or
88
+ automation sessions explicitly:
89
+
90
+ ```python
91
+ import helixwright as hw
92
+
93
+ client = hw.Client()
94
+ print(client.health())
95
+ print(client.session())
96
+
97
+ profile = client.profiles.ensure(
98
+ account_id="shop-003",
99
+ name="Shop 003",
100
+ proxy=hw.Proxy("http", "127.0.0.1", 7890),
101
+ fingerprint=hw.Fingerprint(locale="en-US", region="US"),
102
+ update_existing=True,
103
+ )
104
+
105
+ with profile.launch(url="https://example.com") as page:
106
+ print(page.url)
107
+ ```
108
+
109
+ Common managers:
110
+
111
+ ```python
112
+ client.profiles.list(page=1, size=20)
113
+ client.profiles.get("profile_123")
114
+ client.profiles.create(name="A", account_id="acct_A")
115
+ client.profiles.ensure(account_id="acct_A")
116
+ client.profiles.clone("profile_123", count=2)
117
+ client.profiles.sync_up("profile_123")
118
+ client.profiles.sync_down("profile_123")
119
+
120
+ client.fingerprint.options()
121
+ client.fingerprint.draw(account_id="acct_A", group="octo")
122
+ client.fingerprint.serialize({"ua": "..."}, seed=123)
123
+ client.fingerprint.redraw({"ua": "..."}, "ua")
124
+
125
+ client.proxy.check("http://127.0.0.1:7890")
126
+ client.cores.list()
127
+ client.cores.ensure("chromium", "150")
128
+ client.browser.active()
129
+ client.browser.start("profile_123")
130
+ client.browser.stop("profile_123")
131
+ client.browser.stop_all()
132
+ client.automation.list()
133
+ client.automation.start("profile_123", url="https://example.com")
134
+ client.automation.stop("auto_123")
135
+ ```
136
+
137
+ ## Launch Rules
138
+
139
+ `hw.launch()` requires either `profile_id` or `account_id`/`account`.
140
+
141
+ Important behavior:
142
+
143
+ - `profile_id` means reuse that exact Helix profile.
144
+ - `account_id` means find an existing profile by account ID, or create one
145
+ when `create=True`.
146
+ - `fingerprint=hw.Fingerprint(...)` only sends creation hints to Helix Local
147
+ API. The SDK does not generate fingerprints itself.
148
+ - `proxy` can be a string, dict, or `hw.Proxy`.
149
+ - `start_pages` is passed to the Local API automation session.
150
+ - `config=hw.LaunchConfig(...)` should not be mixed with explicit
151
+ `profile_id`, `account_id`, `name`, `proxy`, or `fingerprint` arguments.
152
+
153
+ Unsupported legacy direct-launch arguments are rejected, including `chrome`,
154
+ `user_data_dir`, `persona`, `seed`, `geoip`, and `extra_args`.
155
+
156
+ ## Errors
157
+
158
+ Common error types:
159
+
160
+ | Error | Meaning |
161
+ | --- | --- |
162
+ | `LocalApiUnavailable` | Helix Desktop is not running, the Local API address is wrong, or the port is unreachable. |
163
+ | `DesktopNotLoggedIn` | Desktop is reachable but not logged in. |
164
+ | `LocalApiError` | Local API returned a business error. |
165
+ | `ProfileNotFound` | The requested profile does not exist. |
166
+ | `AutomationEndpointUnavailable` | The desktop build did not return a usable automation RPC endpoint. |
167
+ | `AutomationSessionNotFound` | The requested automation session does not exist. |
168
+ | `TransportError` | The private page RPC connection failed, usually because the browser exited. |
169
+ | `WaitTimeoutError` | A page wait condition timed out. |
170
+
171
+ ## Examples
172
+
173
+ From the source repository:
174
+
175
+ ```powershell
176
+ cd "E:\Helix Browser\repos\helixwright"
177
+ python examples\99_offline_mock_demo.py
178
+ ```
179
+
180
+ The offline demo does not need Helix Browser. It starts a mock Local API and
181
+ mock RPC endpoint, then exercises the public SDK surface.
182
+
183
+ Real desktop examples require Helix Browser Desktop to be running and logged in:
184
+
185
+ ```powershell
186
+ python examples\00_helixwright_quickstart.py
187
+ python examples\01_client_managers.py
188
+ python examples\02_launch_config_and_actions.py
189
+ ```
190
+
191
+ Useful environment variables:
192
+
193
+ ```powershell
194
+ $env:HELIX_LOCAL_API = "http://127.0.0.1:59777"
195
+ $env:HELIX_EXAMPLE_ACCOUNT = "demo-account"
196
+ $env:HELIX_EXAMPLE_URL = "https://example.com"
197
+ $env:HELIX_EXAMPLE_PROXY = "http://127.0.0.1:7890"
198
+ $env:HELIX_EXAMPLE_TIMEOUT_MS = "30000"
199
+ ```
200
+
201
+ ## Development
202
+
203
+ ```powershell
204
+ cd "E:\Helix Browser\repos\helixwright"
205
+ pip install -e .
206
+ python tests\ci.py
207
+ ```
208
+
209
+ Build and validate a distribution:
210
+
211
+ ```powershell
212
+ python -m build
213
+ python -m twine check dist/*
214
+ ```
215
+
216
+ ## Boundary
217
+
218
+ Helixwright is only the automation SDK. The authoritative source for profiles,
219
+ fingerprints, proxies, browser core downloads, cloud sync, and launch lifecycle
220
+ is Helix Browser Desktop plus its Local API.
221
+
@@ -0,0 +1,58 @@
1
+ """Helixwright automation SDK.
2
+
3
+ The SDK talks to Helix Browser through the desktop Local API. It does not spawn
4
+ Chrome, generate fingerprints, or manage browser profile directories by itself.
5
+ """
6
+
7
+ from .client import Client, Profile, discover_base_url
8
+ from .errors import (
9
+ AutomationEndpointUnavailable,
10
+ AutomationSessionNotFound,
11
+ DesktopNotLoggedIn,
12
+ ElementNotFoundError,
13
+ HelixwrightError,
14
+ LocalApiError,
15
+ LocalApiUnavailable,
16
+ LoginRejected,
17
+ ProfileNotFound,
18
+ ReachabilityError,
19
+ TransportError,
20
+ WaitTimeoutError,
21
+ )
22
+ from ._impl._network import Response, Route
23
+ from ._impl._operator import El, NoneEl, Op
24
+ from .launcher import HelixBrowser, attach, launch
25
+ from .models import AutomationRpc, AutomationSession, Fingerprint, LaunchConfig, Proxy
26
+
27
+ __all__ = [
28
+ "launch",
29
+ "attach",
30
+ "Client",
31
+ "Profile",
32
+ "Fingerprint",
33
+ "Proxy",
34
+ "LaunchConfig",
35
+ "AutomationRpc",
36
+ "AutomationSession",
37
+ "HelixBrowser",
38
+ "Op",
39
+ "El",
40
+ "NoneEl",
41
+ "Response",
42
+ "Route",
43
+ "discover_base_url",
44
+ "HelixwrightError",
45
+ "LocalApiError",
46
+ "LocalApiUnavailable",
47
+ "DesktopNotLoggedIn",
48
+ "ProfileNotFound",
49
+ "AutomationSessionNotFound",
50
+ "AutomationEndpointUnavailable",
51
+ "TransportError",
52
+ "ReachabilityError",
53
+ "ElementNotFoundError",
54
+ "WaitTimeoutError",
55
+ "LoginRejected",
56
+ ]
57
+
58
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Private implementation package (unstable internals). Import the public API from `helixwright`, not from here."""
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import time
4
+ from ._errors import HelixwrightError
5
+
6
+ # The accessibility matchers now read via NATIVE RPC helpers on the Locator (_aria_role /
7
+ # _accessible_name / _accessible_description over get_attribute + element_property('tagName')),
8
+ # so NO JS runs. They remain APPROXIMATE (heuristic, not Chromium's computed accessibility tree).
9
+
10
+
11
+ class _AssertionsBase:
12
+ """Polling assertion base: shared timeout, negation (not_/is_not), and the poll loop."""
13
+
14
+ def __init__(self, target, timeout_ms=5000, is_not=False):
15
+ self._t = target
16
+ self._to = timeout_ms
17
+ self._not = is_not
18
+
19
+ @property
20
+ def not_(self):
21
+ return type(self)(self._t, self._to, not self._not)
22
+
23
+ is_not = not_
24
+
25
+ def _poll(self, pred, desc):
26
+ deadline = time.time() + self._to / 1000.0
27
+ last = None
28
+ while True:
29
+ try:
30
+ last = bool(pred())
31
+ except (HelixwrightError, TypeError, AttributeError):
32
+ # transient (element gone / null evaluate) -> failed-but-retryable, never crash
33
+ last = False
34
+ if last != self._not:
35
+ return
36
+ if time.time() >= deadline:
37
+ raise AssertionError("expect %s%s failed (last=%r)"
38
+ % ("not_." if self._not else "", desc, last))
39
+ time.sleep(0.08)
40
+
41
+
42
+ class LocatorAssertions(_AssertionsBase):
43
+ """Assertions about a Locator — visibility, text, value, count, state."""
44
+
45
+ def to_be_visible(self):
46
+ self._poll(self._t.is_visible, "to_be_visible")
47
+
48
+ def to_be_hidden(self):
49
+ self._poll(lambda: not self._t.is_visible(), "to_be_hidden")
50
+
51
+ def to_be_enabled(self):
52
+ self._poll(self._t.is_enabled, "to_be_enabled")
53
+
54
+ def to_be_disabled(self):
55
+ self._poll(lambda: not self._t.is_enabled(), "to_be_disabled")
56
+
57
+ def to_have_text(self, txt):
58
+ self._poll(lambda: self._t.text().strip() == txt, "to_have_text(%r)" % txt)
59
+
60
+ def to_contain_text(self, txt):
61
+ self._poll(lambda: txt in self._t.text(), "to_contain_text(%r)" % txt)
62
+
63
+ def to_have_value(self, v):
64
+ self._poll(lambda: self._t.input_value() == v, "to_have_value(%r)" % v)
65
+
66
+ def to_have_count(self, n):
67
+ self._poll(lambda: self._t.count() == n, "to_have_count(%d)" % n)
68
+
69
+ def to_be_checked(self):
70
+ self._poll(self._t.is_checked, "to_be_checked")
71
+
72
+ def to_be_editable(self):
73
+ self._poll(self._t.is_editable, "to_be_editable")
74
+
75
+ def to_be_focused(self):
76
+ self._poll(self._t.is_focused, "to_be_focused")
77
+
78
+ def to_have_attribute(self, name, value=None):
79
+ def _p():
80
+ v = self._t.get_attribute(name)
81
+ return (v is not None) if value is None else (v == value)
82
+ self._poll(_p, "to_have_attribute(%r,%r)" % (name, value))
83
+
84
+ # --- Playwright-parity matchers (pure-Python over Locator.evaluate/get_attribute/count) ----
85
+ def to_have_class(self, value):
86
+ """The full class attribute equals |value| (Playwright string form)."""
87
+ self._poll(lambda: (self._t.get_attribute("class") or "") == value, "to_have_class(%r)" % value)
88
+
89
+ def to_contain_class(self, value):
90
+ """The class token list contains |value|."""
91
+ self._poll(lambda: value in (self._t.get_attribute("class") or "").split(),
92
+ "to_contain_class(%r)" % value)
93
+
94
+ def to_have_id(self, id_):
95
+ self._poll(lambda: (self._t.get_attribute("id") or "") == id_, "to_have_id(%r)" % id_)
96
+
97
+ def to_have_css(self, name, value):
98
+ # [no-JS] native WebElement::GetComputedValue via Locator.computed_style.
99
+ self._poll(lambda: self._t.computed_style(name) == value, "to_have_css(%r,%r)" % (name, value))
100
+
101
+ def to_have_js_property(self, name, value):
102
+ self._poll(lambda: self._t.evaluate("el[%s]" % json.dumps(name)) == value,
103
+ "to_have_js_property(%r,%r)" % (name, value))
104
+
105
+ def to_have_values(self, values):
106
+ """The selected <option> values of a multi-select equal |values| (list)."""
107
+ want = list(values)
108
+ self._poll(lambda: self._t.evaluate(
109
+ "Array.prototype.map.call(el.selectedOptions||[],function(o){return o.value;})") == want,
110
+ "to_have_values(%r)" % (want,))
111
+
112
+ def to_be_empty(self):
113
+ """No child elements and no text (Playwright to_be_empty). [no-JS] native InnerHTML()=="" check."""
114
+ self._poll(lambda: (self._t.inner_html() or "").strip() == "", "to_be_empty")
115
+
116
+ def to_be_attached(self):
117
+ self._poll(lambda: self._t.count() > 0, "to_be_attached")
118
+
119
+ def to_be_in_viewport(self):
120
+ # [no-JS] VisibleBoundsInWidget() is viewport-clipped, so IsVisible() == visible-in-viewport.
121
+ self._poll(self._t.is_visible, "to_be_in_viewport")
122
+
123
+ def to_have_role(self, role):
124
+ """APPROXIMATE computed ARIA role (explicit role attr or a tag heuristic — not the engine AOM).
125
+ [no-JS] via Locator._aria_role (get_attribute + element_property('tagName'))."""
126
+ self._poll(lambda: self._t._aria_role() == role, "to_have_role(%r)" % role)
127
+
128
+ def to_have_accessible_name(self, name):
129
+ """APPROXIMATE accessible name (aria-label/text/alt/title heuristic). [no-JS] via native RPCs."""
130
+ self._poll(lambda: (self._t._accessible_name() or "").strip() == name,
131
+ "to_have_accessible_name(%r)" % name)
132
+
133
+ def to_have_accessible_description(self, desc):
134
+ """APPROXIMATE accessible description (title heuristic). [no-JS] via native RPC."""
135
+ self._poll(lambda: (self._t._accessible_description() or "").strip() == desc,
136
+ "to_have_accessible_description(%r)" % desc)
137
+
138
+
139
+ class PageAssertions(_AssertionsBase):
140
+ """Assertions about a Page — url, title."""
141
+
142
+ def _page(self):
143
+ # target is normally the Page; tolerate a Locator (resolve its page) for the
144
+ # combined Expect's back-compat.
145
+ t = self._t
146
+ return t if hasattr(t, "goto") else getattr(t, "_page", t)
147
+
148
+ def to_have_url(self, url):
149
+ self._poll(lambda: url in self._page().evaluate("location.href"),
150
+ "to_have_url(%r)" % url)
151
+
152
+ def to_have_title(self, title):
153
+ self._poll(lambda: title in self._page().evaluate("document.title"),
154
+ "to_have_title(%r)" % title)
155
+
156
+
157
+ class Expect(LocatorAssertions, PageAssertions):
158
+ """Back-compat combined assertions (the public ``Expect`` export). Prefer ``expect()``,
159
+ which returns the precise LocatorAssertions / PageAssertions for the target."""
160
+
161
+
162
+ def expect(target, timeout_ms=5000):
163
+ """Polling assertion proxy for |target|: a Page -> PageAssertions (url/title), a
164
+ Locator -> LocatorAssertions (visibility/text/value/state). Async facade objects
165
+ (AsyncPage/AsyncLocator) are unwrapped to their sync object (assertions are
166
+ synchronous). For a bare Element use page.locator(css) instead."""
167
+ inner = getattr(target, "_p", None) # AsyncPage wraps the sync Page as _p
168
+ if inner is None:
169
+ inner = getattr(target, "_l", None) # AsyncLocator wraps the sync Locator as _l
170
+ if inner is not None:
171
+ target = inner
172
+ if hasattr(target, "goto"): # a Page
173
+ return PageAssertions(target, timeout_ms)
174
+ # Locator OR Element -> BOTH expose _as_locator() (Locator returns self; Element re-resolves to a
175
+ # Locator from its originating selector, or raises clearly for a selector-less Element). Resolve to
176
+ # a Locator so assertions POLL across re-render/detach (a fixed node_id snapshot can't survive one).
177
+ # We no longer inspect the .text descriptor to tell Element from Locator: that property-vs-method
178
+ # hack breaks once the two unify, and an instance .text read would fire a get_text RPC on a
179
+ # navigating page. _as_locator() is a plain method (no RPC) present on both.
180
+ loc_fn = getattr(target, "_as_locator", None)
181
+ if callable(loc_fn):
182
+ return LocatorAssertions(loc_fn(), timeout_ms)
183
+ raise HelixwrightError(
184
+ "expect() needs a Page, Locator, or selector-based Element; got %r" % type(target).__name__)