mendwright 0.3.0__py3-none-any.whl
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.
- mendwright/__init__.py +92 -0
- mendwright/__main__.py +6 -0
- mendwright/aio.py +686 -0
- mendwright/capture.py +162 -0
- mendwright/cli.py +689 -0
- mendwright/client.py +216 -0
- mendwright/coordinator.py +289 -0
- mendwright/demo/__init__.py +35 -0
- mendwright/demo/_page.py +171 -0
- mendwright/demo/_story.py +470 -0
- mendwright/demo/v1.html +29 -0
- mendwright/demo/v2.html +33 -0
- mendwright/drivers/__init__.py +25 -0
- mendwright/drivers/base.py +316 -0
- mendwright/drivers/playwright_async.py +148 -0
- mendwright/drivers/playwright_sync.py +193 -0
- mendwright/drivers/registry.py +37 -0
- mendwright/drivers/selenium.py +596 -0
- mendwright/exceptions.py +10 -0
- mendwright/facade.py +783 -0
- mendwright/gate.py +62 -0
- mendwright/journal.py +180 -0
- mendwright/limits.py +236 -0
- mendwright/prompt.py +67 -0
- mendwright/providers/__init__.py +24 -0
- mendwright/providers/_errors.py +376 -0
- mendwright/providers/anthropic.py +177 -0
- mendwright/providers/base.py +25 -0
- mendwright/providers/bedrock.py +126 -0
- mendwright/providers/detect.py +170 -0
- mendwright/providers/gateway.py +69 -0
- mendwright/providers/gemini.py +171 -0
- mendwright/providers/litellm.py +176 -0
- mendwright/providers/openai_compat.py +255 -0
- mendwright/publish.py +74 -0
- mendwright/py.typed +0 -0
- mendwright/pytest_plugin.py +505 -0
- mendwright/resolver.py +133 -0
- mendwright/roles.py +232 -0
- mendwright/selectors.py +181 -0
- mendwright/store.py +722 -0
- mendwright/types.py +56 -0
- mendwright/validation.py +98 -0
- mendwright-0.3.0.dist-info/METADATA +282 -0
- mendwright-0.3.0.dist-info/RECORD +49 -0
- mendwright-0.3.0.dist-info/WHEEL +5 -0
- mendwright-0.3.0.dist-info/entry_points.txt +5 -0
- mendwright-0.3.0.dist-info/licenses/LICENSE +21 -0
- mendwright-0.3.0.dist-info/top_level.txt +1 -0
mendwright/aio.py
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
"""Async Playwright support: the healing of mendwright.open(), for playwright.async_api.
|
|
2
|
+
|
|
3
|
+
import mendwright.aio
|
|
4
|
+
|
|
5
|
+
heal = await mendwright.aio.open(page)
|
|
6
|
+
await heal.locator('terms', '#terms', role='checkbox').check()
|
|
7
|
+
|
|
8
|
+
heal.locator() returns at once. The element is resolved, and healed if its selectors
|
|
9
|
+
miss, when you await an action on it or await the proxy itself
|
|
10
|
+
(``box = await heal.locator(...)``).
|
|
11
|
+
|
|
12
|
+
Every safety decision is made by the sync core, unchanged:
|
|
13
|
+
- Resolution polls on the event loop, trying candidates in the same order as the sync
|
|
14
|
+
Healer (the selector in your code first), with ``await asyncio.sleep()`` between polls.
|
|
15
|
+
- A heal runs the sync HealCoordinator in a worker thread (asyncio.to_thread). It checks
|
|
16
|
+
keys marked destructive, the guard and the rate limit, captures the page, asks the provider,
|
|
17
|
+
validates the proposal against the captured HTML and then live on the real page, and
|
|
18
|
+
saves it. It reaches the page through SyncBridge, which hands each driver call back to
|
|
19
|
+
the event loop and waits for the answer. So the live gate checks the page as it is now,
|
|
20
|
+
and a slow provider call blocks only the worker thread.
|
|
21
|
+
- A failed action quarantines a just-healed selector; a usage error does not.
|
|
22
|
+
|
|
23
|
+
Calls on one AsyncHealer take turns (an asyncio.Lock), so the worker thread never touches
|
|
24
|
+
the store while the event loop does. asyncio.gather() over several locators of one healer
|
|
25
|
+
is safe, but they resolve one after another. A step cancelled during a heal ends once the
|
|
26
|
+
heal thread has finished, because a thread cannot be stopped. Providers, stores and
|
|
27
|
+
journals stay sync.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import concurrent.futures
|
|
34
|
+
import inspect
|
|
35
|
+
import os
|
|
36
|
+
import time
|
|
37
|
+
from typing import Any, Callable, Iterable, Sequence
|
|
38
|
+
|
|
39
|
+
from .coordinator import HealCoordinator, resolve_with_heal
|
|
40
|
+
from .drivers.base import _member, is_async_api, is_driver, looks_like_locator, unsupported_message
|
|
41
|
+
from .drivers.playwright_async import AsyncPlaywrightDriver, is_async_driver
|
|
42
|
+
from .drivers.playwright_async import detect as _detect_async_playwright
|
|
43
|
+
from .drivers.registry import _REGISTRY
|
|
44
|
+
from .exceptions import AttentionRequired
|
|
45
|
+
from .facade import (_register, _unregister, _USAGE_ERRORS, DEFAULT_TIMEOUT_MS, ActionProxy, Healer,
|
|
46
|
+
HealEvent, Role, Selector, _healed_target_error, _locator_args, _setup)
|
|
47
|
+
from .limits import Guardrails
|
|
48
|
+
from .resolver import _frame_chain, _no_match_message, _require_visible, _split_runnable
|
|
49
|
+
from .selectors import InvalidSelector
|
|
50
|
+
|
|
51
|
+
# 'open' is left out on purpose, as in the package: `from mendwright.aio import *` must not
|
|
52
|
+
# shadow builtins.open. Call it as mendwright.aio.open(page).
|
|
53
|
+
__all__ = ['AsyncHealer', 'AsyncActionProxy', 'SyncBridge']
|
|
54
|
+
|
|
55
|
+
# expect= on an async action: a callable returning a bool, or an awaitable of one.
|
|
56
|
+
AsyncExpect = Callable[[], Any]
|
|
57
|
+
|
|
58
|
+
_UNRESOLVED = object()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _awaited(value):
|
|
62
|
+
if inspect.isawaitable(value):
|
|
63
|
+
return await value
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _in_thread(fn, *args):
|
|
68
|
+
"""fn(*args) in a worker thread (asyncio.to_thread).
|
|
69
|
+
|
|
70
|
+
A thread cannot be stopped. If the caller is cancelled meanwhile, this still waits for
|
|
71
|
+
the thread to finish before passing the cancellation on, so the healer's lock is held
|
|
72
|
+
for as long as the thread can touch the store."""
|
|
73
|
+
__tracebackhide__ = True # pytest shows the caller
|
|
74
|
+
worker = asyncio.ensure_future(asyncio.to_thread(fn, *args))
|
|
75
|
+
try:
|
|
76
|
+
return await asyncio.shield(worker)
|
|
77
|
+
except asyncio.CancelledError:
|
|
78
|
+
while not worker.done():
|
|
79
|
+
try:
|
|
80
|
+
await asyncio.wait([worker])
|
|
81
|
+
except asyncio.CancelledError:
|
|
82
|
+
pass
|
|
83
|
+
if not worker.cancelled():
|
|
84
|
+
worker.exception() # retrieved: the cancellation is what the caller sees
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- the bridge the sync heal core uses from its worker thread ---------------------------
|
|
89
|
+
|
|
90
|
+
class SyncBridge:
|
|
91
|
+
"""A sync driver over an async one, for the sync heal core running in a worker thread.
|
|
92
|
+
|
|
93
|
+
Each call is scheduled on the event loop that owns the page
|
|
94
|
+
(asyncio.run_coroutine_threadsafe) and waited for, up to ``timeout`` seconds, so the
|
|
95
|
+
page is only ever touched on its own thread. Calling the bridge on that event loop's
|
|
96
|
+
thread would wait for itself forever, so that raises RuntimeError at once.
|
|
97
|
+
|
|
98
|
+
It has the driver methods the coordinator uses (count, is_visible, scroll_into_view,
|
|
99
|
+
handle, url, outer_html, describe, inventory), and run() for any other callable.
|
|
100
|
+
describe is None when the async driver has none."""
|
|
101
|
+
|
|
102
|
+
timeout = 30.0
|
|
103
|
+
|
|
104
|
+
def __init__(self, driver: Any, loop: asyncio.AbstractEventLoop, *,
|
|
105
|
+
timeout: float | None = None) -> None:
|
|
106
|
+
self._driver = driver
|
|
107
|
+
self._loop = loop
|
|
108
|
+
if timeout is not None:
|
|
109
|
+
self.timeout = timeout
|
|
110
|
+
if not callable(_member(driver, 'describe')):
|
|
111
|
+
# The live gate reads the element through describe() when the driver has it
|
|
112
|
+
# and fails closed when that read fails. A custom async driver without one
|
|
113
|
+
# keeps the count and visibility checks only.
|
|
114
|
+
self.describe = None
|
|
115
|
+
|
|
116
|
+
def run(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
117
|
+
"""fn(*args, **kwargs) called on the event loop thread, awaited when it returns an
|
|
118
|
+
awaitable, and its result returned here. Errors from fn propagate."""
|
|
119
|
+
loop = self._loop
|
|
120
|
+
try:
|
|
121
|
+
running = asyncio.get_running_loop()
|
|
122
|
+
except RuntimeError:
|
|
123
|
+
running = None
|
|
124
|
+
if running is loop:
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
"mendwright: a sync driver call reached the event loop thread that owns the "
|
|
127
|
+
"page, where waiting for the page would hang forever. On the event loop, "
|
|
128
|
+
"await the async driver instead. The sync bridge is only for worker threads, "
|
|
129
|
+
"such as one started with asyncio.to_thread().")
|
|
130
|
+
if loop.is_closed() or not loop.is_running():
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
'mendwright: the event loop that owns the page is not running, so the page '
|
|
133
|
+
'cannot be reached. Keep the loop and the browser open until the step that '
|
|
134
|
+
'is healing has finished.')
|
|
135
|
+
|
|
136
|
+
async def call():
|
|
137
|
+
return await _awaited(fn(*args, **kwargs))
|
|
138
|
+
|
|
139
|
+
future = asyncio.run_coroutine_threadsafe(call(), loop)
|
|
140
|
+
try:
|
|
141
|
+
return future.result(self.timeout)
|
|
142
|
+
except concurrent.futures.TimeoutError:
|
|
143
|
+
if future.done():
|
|
144
|
+
raise # fn itself raised a TimeoutError
|
|
145
|
+
future.cancel()
|
|
146
|
+
raise TimeoutError(
|
|
147
|
+
'mendwright: the page did not answer within ' + format(self.timeout, 'g')
|
|
148
|
+
+ ' seconds. Check that nothing blocks the event loop while a step heals, '
|
|
149
|
+
'or raise mendwright.aio.SyncBridge.timeout.') from None
|
|
150
|
+
|
|
151
|
+
def count(self, selector, frame_chain=()):
|
|
152
|
+
return self.run(self._driver.count, selector, frame_chain)
|
|
153
|
+
|
|
154
|
+
def is_visible(self, selector, frame_chain=()):
|
|
155
|
+
return self.run(self._driver.is_visible, selector, frame_chain)
|
|
156
|
+
|
|
157
|
+
def scroll_into_view(self, selector, frame_chain=()):
|
|
158
|
+
return self.run(self._driver.scroll_into_view, selector, frame_chain)
|
|
159
|
+
|
|
160
|
+
def handle(self, selector, frame_chain=()):
|
|
161
|
+
return self.run(self._driver.handle, selector, frame_chain)
|
|
162
|
+
|
|
163
|
+
def url(self):
|
|
164
|
+
return self.run(self._driver.url)
|
|
165
|
+
|
|
166
|
+
def outer_html(self, scope=None):
|
|
167
|
+
return self.run(self._driver.outer_html, scope)
|
|
168
|
+
|
|
169
|
+
def describe(self, selector, frame_chain=()):
|
|
170
|
+
return self.run(self._driver.describe, selector, frame_chain)
|
|
171
|
+
|
|
172
|
+
def inventory(self, limit=200):
|
|
173
|
+
return self.run(self._driver.inventory, limit)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- opening -------------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _sync_detected(obj):
|
|
179
|
+
"""True when mendwright.open() would take obj: a sync driver or registered page type."""
|
|
180
|
+
if is_driver(obj):
|
|
181
|
+
return True
|
|
182
|
+
for detect, _make in list(_REGISTRY):
|
|
183
|
+
try:
|
|
184
|
+
if detect(obj):
|
|
185
|
+
return True
|
|
186
|
+
except Exception:
|
|
187
|
+
continue
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _unsupported(obj):
|
|
192
|
+
name = type(obj).__name__
|
|
193
|
+
module = type(obj).__module__ or ''
|
|
194
|
+
if looks_like_locator(obj) and (is_async_api(obj) or '.async_api' in module):
|
|
195
|
+
return ('mendwright: got a Playwright ' + name + ', which points at elements, not at a '
|
|
196
|
+
'page. mendwright needs the page to capture HTML when it heals. Pass the page '
|
|
197
|
+
'instead: heal = await mendwright.aio.open(locator.page), or a Frame from '
|
|
198
|
+
'page.frames to work inside an iframe.')
|
|
199
|
+
if is_async_api(obj) or '.async_api' in module:
|
|
200
|
+
return unsupported_message(obj)
|
|
201
|
+
if _sync_detected(obj):
|
|
202
|
+
return ('mendwright: ' + name + ' is a sync page or driver, and mendwright.aio.open() '
|
|
203
|
+
'takes an async Playwright Page or Frame. Pass it to mendwright.open(page) '
|
|
204
|
+
'instead, or open the browser with playwright.async_api.async_playwright().')
|
|
205
|
+
return ('mendwright: could not detect an async Playwright page in ' + name + '. Pass a '
|
|
206
|
+
'Page or Frame from playwright.async_api to mendwright.aio.open(), or a driver '
|
|
207
|
+
'whose count, is_visible and handle are coroutine functions.')
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _async_driver(page):
|
|
211
|
+
if is_async_driver(page):
|
|
212
|
+
return page
|
|
213
|
+
if _detect_async_playwright(page):
|
|
214
|
+
return AsyncPlaywrightDriver(page)
|
|
215
|
+
raise TypeError(_unsupported(page))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def open(page: Any, *, provider: Any = None, store: Any = None,
|
|
219
|
+
store_path: str | os.PathLike | None = None,
|
|
220
|
+
guard: Callable[[], Any] | None = None, dry_run: bool | None = None,
|
|
221
|
+
destructive_keys: Iterable[str] = frozenset(),
|
|
222
|
+
guardrails: Guardrails | None = None, journal: Any = None,
|
|
223
|
+
rate_per_key_per_hour: int | None = 3, rate_global_per_hour: int | None = 60,
|
|
224
|
+
timeout_ms: int | None = None) -> AsyncHealer:
|
|
225
|
+
"""Wrap an async Playwright Page or Frame into an AsyncHealer. Await it inside the
|
|
226
|
+
event loop that runs the page.
|
|
227
|
+
|
|
228
|
+
Takes the same arguments, environment variables (MENDWRIGHT_STORE,
|
|
229
|
+
MENDWRIGHT_TIMEOUT_MS, MENDWRIGHT_DRY_RUN, MENDWRIGHT_DISABLE) and defaults as
|
|
230
|
+
mendwright.open(). guard may be a plain or an async callable; it runs on the event
|
|
231
|
+
loop. The provider stays sync and is called in a worker thread. A driver whose count,
|
|
232
|
+
is_visible and handle are coroutine functions is used as it is.
|
|
233
|
+
"""
|
|
234
|
+
driver = _async_driver(page)
|
|
235
|
+
bridge = SyncBridge(driver, asyncio.get_running_loop())
|
|
236
|
+
if guard is not None:
|
|
237
|
+
user_guard = guard
|
|
238
|
+
|
|
239
|
+
def guard():
|
|
240
|
+
# Called by the coordinator in the worker thread. An async guard must be
|
|
241
|
+
# awaited (a bare coroutine is truthy and would always allow the heal).
|
|
242
|
+
return bool(bridge.run(user_guard))
|
|
243
|
+
|
|
244
|
+
store, coord, timeout_ms = _setup(
|
|
245
|
+
bridge, provider=provider, store=store, store_path=store_path, guard=guard,
|
|
246
|
+
dry_run=dry_run, destructive_keys=destructive_keys, guardrails=guardrails,
|
|
247
|
+
journal=journal, rate_per_key_per_hour=rate_per_key_per_hour,
|
|
248
|
+
rate_global_per_hour=rate_global_per_hour, timeout_ms=timeout_ms,
|
|
249
|
+
entry='mendwright.aio.open()')
|
|
250
|
+
return AsyncHealer(page, driver, store, coord, timeout_ms=timeout_ms)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- the async healer --------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
async def _match_handle(driver, candidate, require_visible):
|
|
256
|
+
"""resolver._match_handle, awaited: the native handle if the candidate matches exactly
|
|
257
|
+
one (visible) element, else None. InvalidSelector propagates."""
|
|
258
|
+
selector, fc = candidate['xpath'], _frame_chain(candidate)
|
|
259
|
+
try:
|
|
260
|
+
if (await driver.count(selector, fc)) == 1 and (
|
|
261
|
+
not require_visible or await driver.is_visible(selector, fc)):
|
|
262
|
+
return await driver.handle(selector, fc)
|
|
263
|
+
except InvalidSelector:
|
|
264
|
+
raise
|
|
265
|
+
except Exception:
|
|
266
|
+
return None
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def _url(driver):
|
|
271
|
+
url = getattr(driver, 'url', None)
|
|
272
|
+
if not callable(url):
|
|
273
|
+
return ''
|
|
274
|
+
try:
|
|
275
|
+
return await _awaited(url())
|
|
276
|
+
except Exception:
|
|
277
|
+
return ''
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class AsyncHealer:
|
|
281
|
+
"""The async Healer: resolves selector keys on one async page, heals them when they
|
|
282
|
+
break, and hands back an AsyncActionProxy. Made by mendwright.aio.open().
|
|
283
|
+
|
|
284
|
+
pending(), confirm_all() and heals work as on the sync Healer, and the healer appears
|
|
285
|
+
in mendwright.facade.live_healers(), so test-runner hooks can confirm its heals."""
|
|
286
|
+
|
|
287
|
+
def __init__(self, page: Any, driver: Any, store: Any, coordinator: HealCoordinator, *,
|
|
288
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
289
|
+
sleep: Callable[[float], Any] = asyncio.sleep,
|
|
290
|
+
clock: Callable[[], float] = time.monotonic) -> None:
|
|
291
|
+
self.page = page
|
|
292
|
+
self.driver = driver
|
|
293
|
+
self.store = store
|
|
294
|
+
self.coordinator = coordinator
|
|
295
|
+
# The sync Healer's own helpers (argument checks, metadata, role learning) run on
|
|
296
|
+
# the same store; only page work is async here. This healer stands in for it in
|
|
297
|
+
# live_healers().
|
|
298
|
+
self._core = Healer(page, driver, store, coordinator, timeout_ms=timeout_ms)
|
|
299
|
+
_unregister(self._core)
|
|
300
|
+
_register(self)
|
|
301
|
+
self._sleep = sleep
|
|
302
|
+
self._clock = clock
|
|
303
|
+
self._lock = asyncio.Lock()
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def timeout_ms(self) -> int:
|
|
307
|
+
"""The default locator() timeout in milliseconds."""
|
|
308
|
+
return self._core.timeout_ms
|
|
309
|
+
|
|
310
|
+
@timeout_ms.setter
|
|
311
|
+
def timeout_ms(self, value: int) -> None:
|
|
312
|
+
self._core.timeout_ms = value
|
|
313
|
+
|
|
314
|
+
def locator(self, key: str, selector: Selector | None = None, *, role: Role | None = None,
|
|
315
|
+
text: str | Sequence[str] | None = None, destructive: bool | None = None,
|
|
316
|
+
timeout_ms: int | None = None, poll_ms: int = 200,
|
|
317
|
+
inline_xpath: Selector | None = None) -> AsyncActionProxy:
|
|
318
|
+
"""Like Healer.locator(), but returns at once: the key is resolved (and healed and
|
|
319
|
+
retried if its candidates miss) when an action on the proxy is awaited, or the
|
|
320
|
+
proxy itself. The arguments mean the same as on Healer.locator().
|
|
321
|
+
|
|
322
|
+
A non-string selector (TypeError), a malformed one (InvalidSelector) or an unknown
|
|
323
|
+
role (ValueError) raises here, before anything is awaited. AttentionRequired, when
|
|
324
|
+
the element cannot be found or healed, is raised by the await."""
|
|
325
|
+
roles, code = _locator_args(key, selector, role, inline_xpath)
|
|
326
|
+
timeout = self.timeout_ms if timeout_ms is None else timeout_ms
|
|
327
|
+
return AsyncActionProxy(self, key, (roles, code, text, destructive, timeout, poll_ms))
|
|
328
|
+
|
|
329
|
+
async def present(self, key: str) -> bool:
|
|
330
|
+
"""True if one of key's stored selectors matches a single element right now.
|
|
331
|
+
No waiting and no healing."""
|
|
332
|
+
async with self._lock:
|
|
333
|
+
store = self.store
|
|
334
|
+
require_visible = _require_visible(store, key)
|
|
335
|
+
candidates = store.candidates(key)
|
|
336
|
+
runnable, errors = _split_runnable(key, candidates)
|
|
337
|
+
for candidate in runnable:
|
|
338
|
+
try:
|
|
339
|
+
if await _match_handle(self.driver, candidate, require_visible) is not None:
|
|
340
|
+
return True
|
|
341
|
+
except InvalidSelector as exc:
|
|
342
|
+
errors.append(exc.for_key(key))
|
|
343
|
+
if candidates and len(errors) == len(candidates):
|
|
344
|
+
raise errors[0]
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
# --- heals awaiting confirmation (hooks for test runners) ------------------------
|
|
348
|
+
|
|
349
|
+
def pending(self) -> dict:
|
|
350
|
+
"""{key: healed xpath} for heals whose canary has not reported yet. A copy."""
|
|
351
|
+
return self._core.pending()
|
|
352
|
+
|
|
353
|
+
def confirm_all(self, success: bool) -> int:
|
|
354
|
+
"""Report every pending heal as confirmed (True) or failed (False, which
|
|
355
|
+
quarantines it), and return how many there were. Sync, like Healer.confirm_all();
|
|
356
|
+
call it when no step of this healer is running."""
|
|
357
|
+
return self._core.confirm_all(success)
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def heals(self) -> list[HealEvent]:
|
|
361
|
+
"""The heals this healer applied, oldest first, as HealEvent objects."""
|
|
362
|
+
return self._core.heals
|
|
363
|
+
|
|
364
|
+
# --- resolution ----------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
async def _resolve_proxy(self, proxy):
|
|
367
|
+
__tracebackhide__ = True # pytest shows the caller
|
|
368
|
+
async with self._lock:
|
|
369
|
+
if proxy._native is _UNRESOLVED:
|
|
370
|
+
proxy._native = await self._locate(proxy._key, *proxy._plan)
|
|
371
|
+
return proxy._native
|
|
372
|
+
|
|
373
|
+
async def _locate(self, key, roles, code, text, destructive, timeout, poll_ms):
|
|
374
|
+
"""Healer.locator() after its argument checks, with the page work awaited.
|
|
375
|
+
Runs under self._lock."""
|
|
376
|
+
__tracebackhide__ = True # pytest shows the caller
|
|
377
|
+
self._core._prime(key, roles, code, text, destructive)
|
|
378
|
+
|
|
379
|
+
async def attempt():
|
|
380
|
+
return await self._resolve(key, timeout, poll_ms)
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
native = await attempt()
|
|
384
|
+
except AttentionRequired as exc:
|
|
385
|
+
await self._stop_if_in_a_frame(key, exc)
|
|
386
|
+
native = await self._heal(key, exc, attempt)
|
|
387
|
+
await self._check_healed_target(key)
|
|
388
|
+
await self._learn(key, roles)
|
|
389
|
+
return native
|
|
390
|
+
|
|
391
|
+
async def _check_healed_target(self, key):
|
|
392
|
+
"""Healer._check_healed_target(): a match that came from a heal, on a key marked
|
|
393
|
+
destructive, is quarantined, and AttentionRequired is raised before anything acts
|
|
394
|
+
on it."""
|
|
395
|
+
__tracebackhide__ = True # pytest shows the caller
|
|
396
|
+
core = self._core
|
|
397
|
+
xpath = core._destructive_heal(key)
|
|
398
|
+
if xpath is None:
|
|
399
|
+
return
|
|
400
|
+
coordinator = self.coordinator
|
|
401
|
+
if coordinator.pending.get(key) == xpath:
|
|
402
|
+
# A heal from earlier in this process. A gateway provider reports the outcome
|
|
403
|
+
# over HTTP.
|
|
404
|
+
await _in_thread(coordinator.confirm_pending, key, False)
|
|
405
|
+
else:
|
|
406
|
+
self.store.quarantine(key, xpath)
|
|
407
|
+
coordinator.journal.quarantine(key, xpath=xpath)
|
|
408
|
+
raise _healed_target_error(key, xpath, core._locked(key))
|
|
409
|
+
|
|
410
|
+
async def _resolve(self, key, timeout_ms, poll_ms):
|
|
411
|
+
"""mendwright.resolver.resolve(), awaited: the candidates in the same order, bad
|
|
412
|
+
selectors dropped (InvalidSelector at once when none can run), the first unique
|
|
413
|
+
(visible) match returned and recorded, AttentionRequired at the deadline."""
|
|
414
|
+
store, driver = self.store, self.driver
|
|
415
|
+
candidates = store.candidates(key)
|
|
416
|
+
if not candidates:
|
|
417
|
+
raise AttentionRequired('No selector candidates for: ' + key)
|
|
418
|
+
runnable, errors = _split_runnable(key, candidates)
|
|
419
|
+
if not runnable:
|
|
420
|
+
raise errors[0]
|
|
421
|
+
require_visible = _require_visible(store, key)
|
|
422
|
+
deadline = self._clock() + timeout_ms / 1000.0
|
|
423
|
+
while True:
|
|
424
|
+
for candidate in list(runnable):
|
|
425
|
+
try:
|
|
426
|
+
handle = await _match_handle(driver, candidate, require_visible)
|
|
427
|
+
except InvalidSelector as exc:
|
|
428
|
+
runnable.remove(candidate)
|
|
429
|
+
errors.append(exc.for_key(key))
|
|
430
|
+
if not runnable:
|
|
431
|
+
raise errors[0] from None
|
|
432
|
+
continue
|
|
433
|
+
if handle is not None:
|
|
434
|
+
try:
|
|
435
|
+
await driver.scroll_into_view(candidate['xpath'], _frame_chain(candidate))
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
store.record_success(key, candidate['xpath'])
|
|
439
|
+
return handle
|
|
440
|
+
if self._clock() >= deadline:
|
|
441
|
+
raise AttentionRequired(_no_match_message(key, candidates, errors))
|
|
442
|
+
await self._sleep(poll_ms / 1000.0)
|
|
443
|
+
|
|
444
|
+
async def _stop_if_in_a_frame(self, key, exc):
|
|
445
|
+
"""Healer._stop_if_in_a_frame(), with the frame lookups awaited."""
|
|
446
|
+
__tracebackhide__ = True
|
|
447
|
+
if not callable(_member(self.driver, 'frame_matches')):
|
|
448
|
+
return
|
|
449
|
+
for selector in self._core._frame_candidates(key):
|
|
450
|
+
try:
|
|
451
|
+
frames = await _awaited(self.driver.frame_matches(selector))
|
|
452
|
+
except Exception:
|
|
453
|
+
continue
|
|
454
|
+
if frames:
|
|
455
|
+
self._core._frame_stop(key, exc, selector, frames, 'await mendwright.aio.open')
|
|
456
|
+
|
|
457
|
+
async def _heal(self, key, exc, attempt):
|
|
458
|
+
"""Run the sync heal (coordinator.resolve_with_heal) in a worker thread, then retry
|
|
459
|
+
attempt() on the event loop. The coordinator reaches the page through SyncBridge."""
|
|
460
|
+
__tracebackhide__ = True # pytest shows the caller
|
|
461
|
+
meta = self.store.meta(key)
|
|
462
|
+
context = {'url': await _url(self.driver)}
|
|
463
|
+
missed = [exc]
|
|
464
|
+
|
|
465
|
+
def resolve_fn():
|
|
466
|
+
# resolve_with_heal calls this once to see the miss and once more after a heal
|
|
467
|
+
# was applied. That retry is awaited on the event loop below instead.
|
|
468
|
+
if missed:
|
|
469
|
+
raise missed.pop()
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
await _in_thread(resolve_with_heal, resolve_fn, self.coordinator, key, meta, context)
|
|
473
|
+
return await attempt()
|
|
474
|
+
|
|
475
|
+
async def _learn(self, key, roles):
|
|
476
|
+
"""Healer._learn(), with describe() awaited."""
|
|
477
|
+
core = self._core
|
|
478
|
+
todo = core._learn_todo(key, roles)
|
|
479
|
+
if todo is None:
|
|
480
|
+
return
|
|
481
|
+
core._learn_from(key, roles, todo, await self._describe_winner(key))
|
|
482
|
+
|
|
483
|
+
async def _describe_winner(self, key):
|
|
484
|
+
target = self._core._winner_target(key)
|
|
485
|
+
describe = _member(self.driver, 'describe')
|
|
486
|
+
if target is None or not callable(describe):
|
|
487
|
+
return None
|
|
488
|
+
try:
|
|
489
|
+
out = await _awaited(self.driver.describe(*target))
|
|
490
|
+
except Exception:
|
|
491
|
+
return None
|
|
492
|
+
return out if isinstance(out, dict) else None
|
|
493
|
+
|
|
494
|
+
async def _confirm(self, key, success):
|
|
495
|
+
"""coordinator.confirm_pending(), in a worker thread (a gateway provider reports
|
|
496
|
+
the outcome over HTTP) and under the lock."""
|
|
497
|
+
async with self._lock:
|
|
498
|
+
if key in self.coordinator.pending:
|
|
499
|
+
await _in_thread(self.coordinator.confirm_pending, key, bool(success))
|
|
500
|
+
|
|
501
|
+
def __repr__(self) -> str:
|
|
502
|
+
return '<AsyncHealer page=' + repr(self.page) + '>'
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
async def _unwrap(value):
|
|
506
|
+
if isinstance(value, AsyncActionProxy):
|
|
507
|
+
return await value._ensure()
|
|
508
|
+
if isinstance(value, ActionProxy):
|
|
509
|
+
return value.native
|
|
510
|
+
return value
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def _act_on(driver, native, verb, *args, **kwargs):
|
|
514
|
+
act_on = getattr(driver, 'act_on', None)
|
|
515
|
+
if callable(act_on):
|
|
516
|
+
return await _awaited(act_on(native, verb, *args, **kwargs))
|
|
517
|
+
return await _awaited(getattr(native, verb)(*args, **kwargs))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class AsyncActionProxy:
|
|
521
|
+
"""The element an AsyncHealer resolves, with the heal canary attached.
|
|
522
|
+
|
|
523
|
+
Actions (mendwright.facade.ACTIONS) are coroutines: ``await proxy.check()``. The first
|
|
524
|
+
one, or ``await proxy``, resolves the key (healing it if needed). Keyword arguments
|
|
525
|
+
pass through, and the native result comes back. If the action itself fails in the
|
|
526
|
+
browser, a selector healed for this step is quarantined. A usage error
|
|
527
|
+
(AttributeError, TypeError, ValueError) is raised as is and quarantines nothing.
|
|
528
|
+
|
|
529
|
+
expect= takes a callable returning a bool, or an awaitable of one, for example
|
|
530
|
+
``expect=lambda: box.native.is_checked()``. Its result confirms the heal (True) or
|
|
531
|
+
quarantines it (False or an error). Other attributes fall through to the native
|
|
532
|
+
Locator once the proxy is resolved; before that, a method call such as
|
|
533
|
+
``await heal.locator(...).inner_text()`` resolves first."""
|
|
534
|
+
|
|
535
|
+
def __init__(self, healer: AsyncHealer, key: str, plan: tuple) -> None:
|
|
536
|
+
self._healer = healer
|
|
537
|
+
self._key = key
|
|
538
|
+
self._plan = plan
|
|
539
|
+
self._native = _UNRESOLVED
|
|
540
|
+
|
|
541
|
+
@property
|
|
542
|
+
def key(self) -> str:
|
|
543
|
+
return self._key
|
|
544
|
+
|
|
545
|
+
@property
|
|
546
|
+
def resolved(self) -> bool:
|
|
547
|
+
"""True once the key has been resolved to an element."""
|
|
548
|
+
return self._native is not _UNRESOLVED
|
|
549
|
+
|
|
550
|
+
@property
|
|
551
|
+
def native(self) -> Any:
|
|
552
|
+
"""The native async Playwright Locator. Pass it to APIs that check the type, such as
|
|
553
|
+
``await expect(proxy.native).to_be_checked()``. Await the proxy first."""
|
|
554
|
+
if self._native is _UNRESOLVED:
|
|
555
|
+
raise RuntimeError(
|
|
556
|
+
"mendwright: '" + self._key + "' has not been resolved yet, so there is no "
|
|
557
|
+
"native Locator. Await the proxy first, for example "
|
|
558
|
+
"box = await heal.locator('" + self._key + "', ...), then use box.native.")
|
|
559
|
+
return self._native
|
|
560
|
+
|
|
561
|
+
async def _ensure(self):
|
|
562
|
+
__tracebackhide__ = True # pytest shows the caller
|
|
563
|
+
if self._native is _UNRESOLVED:
|
|
564
|
+
await self._healer._resolve_proxy(self)
|
|
565
|
+
return self._native
|
|
566
|
+
|
|
567
|
+
async def _resolved_self(self):
|
|
568
|
+
__tracebackhide__ = True # pytest shows the caller
|
|
569
|
+
await self._ensure()
|
|
570
|
+
return self
|
|
571
|
+
|
|
572
|
+
def __await__(self):
|
|
573
|
+
return self._resolved_self().__await__()
|
|
574
|
+
|
|
575
|
+
async def _act(self, verb: str, args: tuple, kwargs: dict, expect: AsyncExpect | None) -> Any:
|
|
576
|
+
__tracebackhide__ = True # pytest shows the caller
|
|
577
|
+
native = await self._ensure()
|
|
578
|
+
args = tuple([await _unwrap(a) for a in args])
|
|
579
|
+
kwargs = {k: await _unwrap(v) for k, v in kwargs.items()}
|
|
580
|
+
healer = self._healer
|
|
581
|
+
try:
|
|
582
|
+
result = await _act_on(healer.driver, native, verb, *args, **kwargs)
|
|
583
|
+
except _USAGE_ERRORS:
|
|
584
|
+
raise
|
|
585
|
+
except Exception:
|
|
586
|
+
await healer._confirm(self._key, False) # the browser action failed
|
|
587
|
+
raise
|
|
588
|
+
if expect is not None:
|
|
589
|
+
try:
|
|
590
|
+
ok = bool(await _awaited(expect()))
|
|
591
|
+
except Exception:
|
|
592
|
+
ok = False
|
|
593
|
+
await healer._confirm(self._key, ok)
|
|
594
|
+
return result
|
|
595
|
+
|
|
596
|
+
async def click(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
597
|
+
return await self._act('click', args, kwargs, expect)
|
|
598
|
+
|
|
599
|
+
async def dblclick(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
600
|
+
return await self._act('dblclick', args, kwargs, expect)
|
|
601
|
+
|
|
602
|
+
async def fill(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
603
|
+
return await self._act('fill', args, kwargs, expect)
|
|
604
|
+
|
|
605
|
+
async def type(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
606
|
+
return await self._act('type', args, kwargs, expect)
|
|
607
|
+
|
|
608
|
+
async def press(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
609
|
+
return await self._act('press', args, kwargs, expect)
|
|
610
|
+
|
|
611
|
+
async def press_sequentially(self, *args: Any, expect: AsyncExpect | None = None,
|
|
612
|
+
**kwargs: Any) -> Any:
|
|
613
|
+
return await self._act('press_sequentially', args, kwargs, expect)
|
|
614
|
+
|
|
615
|
+
async def check(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
616
|
+
return await self._act('check', args, kwargs, expect)
|
|
617
|
+
|
|
618
|
+
async def uncheck(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
619
|
+
return await self._act('uncheck', args, kwargs, expect)
|
|
620
|
+
|
|
621
|
+
async def set_checked(self, *args: Any, expect: AsyncExpect | None = None,
|
|
622
|
+
**kwargs: Any) -> Any:
|
|
623
|
+
return await self._act('set_checked', args, kwargs, expect)
|
|
624
|
+
|
|
625
|
+
async def select_option(self, *args: Any, expect: AsyncExpect | None = None,
|
|
626
|
+
**kwargs: Any) -> Any:
|
|
627
|
+
return await self._act('select_option', args, kwargs, expect)
|
|
628
|
+
|
|
629
|
+
async def hover(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
630
|
+
return await self._act('hover', args, kwargs, expect)
|
|
631
|
+
|
|
632
|
+
async def tap(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
633
|
+
return await self._act('tap', args, kwargs, expect)
|
|
634
|
+
|
|
635
|
+
async def clear(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
636
|
+
return await self._act('clear', args, kwargs, expect)
|
|
637
|
+
|
|
638
|
+
async def focus(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
639
|
+
return await self._act('focus', args, kwargs, expect)
|
|
640
|
+
|
|
641
|
+
async def set_input_files(self, *args: Any, expect: AsyncExpect | None = None,
|
|
642
|
+
**kwargs: Any) -> Any:
|
|
643
|
+
return await self._act('set_input_files', args, kwargs, expect)
|
|
644
|
+
|
|
645
|
+
async def drag_to(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
646
|
+
"""Drag to another element. The target may be another proxy."""
|
|
647
|
+
return await self._act('drag_to', args, kwargs, expect)
|
|
648
|
+
|
|
649
|
+
async def send_keys(self, *args: Any, expect: AsyncExpect | None = None,
|
|
650
|
+
**kwargs: Any) -> Any:
|
|
651
|
+
return await self._act('send_keys', args, kwargs, expect)
|
|
652
|
+
|
|
653
|
+
async def submit(self, *args: Any, expect: AsyncExpect | None = None, **kwargs: Any) -> Any:
|
|
654
|
+
return await self._act('submit', args, kwargs, expect)
|
|
655
|
+
|
|
656
|
+
def confirm(self, success: bool) -> AsyncActionProxy:
|
|
657
|
+
"""Confirm (True) or quarantine (False) a heal for this key, from a check of your
|
|
658
|
+
own. Sync, like ActionProxy.confirm()."""
|
|
659
|
+
self._healer.coordinator.confirm_pending(self._key, bool(success))
|
|
660
|
+
return self
|
|
661
|
+
|
|
662
|
+
def __getattr__(self, name: str) -> Any:
|
|
663
|
+
# Reads and anything else not in ACTIONS go to the native Locator. Before the
|
|
664
|
+
# proxy is resolved, a method call resolves it first.
|
|
665
|
+
native = self.__dict__.get('_native', _UNRESOLVED)
|
|
666
|
+
if native is not _UNRESOLVED:
|
|
667
|
+
return getattr(native, name)
|
|
668
|
+
if name.startswith('_'):
|
|
669
|
+
raise AttributeError(name)
|
|
670
|
+
|
|
671
|
+
async def deferred(*args, **kwargs):
|
|
672
|
+
target = await self._ensure()
|
|
673
|
+
attr = getattr(target, name)
|
|
674
|
+
if not callable(attr):
|
|
675
|
+
raise TypeError(
|
|
676
|
+
'mendwright: ' + name + ' is not a method of the native Locator. Await '
|
|
677
|
+
'the proxy first, then read it: box = await heal.locator(...); box.'
|
|
678
|
+
+ name)
|
|
679
|
+
return await _awaited(attr(*args, **kwargs))
|
|
680
|
+
|
|
681
|
+
deferred.__name__ = name
|
|
682
|
+
return deferred
|
|
683
|
+
|
|
684
|
+
def __repr__(self) -> str:
|
|
685
|
+
native = 'unresolved' if self._native is _UNRESOLVED else repr(self._native)
|
|
686
|
+
return '<AsyncActionProxy key=' + repr(self._key) + ' native=' + native + '>'
|