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.
Files changed (49) hide show
  1. mendwright/__init__.py +92 -0
  2. mendwright/__main__.py +6 -0
  3. mendwright/aio.py +686 -0
  4. mendwright/capture.py +162 -0
  5. mendwright/cli.py +689 -0
  6. mendwright/client.py +216 -0
  7. mendwright/coordinator.py +289 -0
  8. mendwright/demo/__init__.py +35 -0
  9. mendwright/demo/_page.py +171 -0
  10. mendwright/demo/_story.py +470 -0
  11. mendwright/demo/v1.html +29 -0
  12. mendwright/demo/v2.html +33 -0
  13. mendwright/drivers/__init__.py +25 -0
  14. mendwright/drivers/base.py +316 -0
  15. mendwright/drivers/playwright_async.py +148 -0
  16. mendwright/drivers/playwright_sync.py +193 -0
  17. mendwright/drivers/registry.py +37 -0
  18. mendwright/drivers/selenium.py +596 -0
  19. mendwright/exceptions.py +10 -0
  20. mendwright/facade.py +783 -0
  21. mendwright/gate.py +62 -0
  22. mendwright/journal.py +180 -0
  23. mendwright/limits.py +236 -0
  24. mendwright/prompt.py +67 -0
  25. mendwright/providers/__init__.py +24 -0
  26. mendwright/providers/_errors.py +376 -0
  27. mendwright/providers/anthropic.py +177 -0
  28. mendwright/providers/base.py +25 -0
  29. mendwright/providers/bedrock.py +126 -0
  30. mendwright/providers/detect.py +170 -0
  31. mendwright/providers/gateway.py +69 -0
  32. mendwright/providers/gemini.py +171 -0
  33. mendwright/providers/litellm.py +176 -0
  34. mendwright/providers/openai_compat.py +255 -0
  35. mendwright/publish.py +74 -0
  36. mendwright/py.typed +0 -0
  37. mendwright/pytest_plugin.py +505 -0
  38. mendwright/resolver.py +133 -0
  39. mendwright/roles.py +232 -0
  40. mendwright/selectors.py +181 -0
  41. mendwright/store.py +722 -0
  42. mendwright/types.py +56 -0
  43. mendwright/validation.py +98 -0
  44. mendwright-0.3.0.dist-info/METADATA +282 -0
  45. mendwright-0.3.0.dist-info/RECORD +49 -0
  46. mendwright-0.3.0.dist-info/WHEEL +5 -0
  47. mendwright-0.3.0.dist-info/entry_points.txt +5 -0
  48. mendwright-0.3.0.dist-info/licenses/LICENSE +21 -0
  49. 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 + '>'