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/demo/v2.html
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Checkout (v2)</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<main>
|
|
10
|
+
<h1>Checkout</h1>
|
|
11
|
+
|
|
12
|
+
<!-- v2: the same page after a front-end redesign. The consent block is now a
|
|
13
|
+
design-system component and both controls lost their ids (the checkbox has a
|
|
14
|
+
build-generated one). #accept-terms and #pay match nothing, but the checkbox
|
|
15
|
+
role and its visible label text are unchanged. -->
|
|
16
|
+
<form id="checkout" method="post" action="/checkout">
|
|
17
|
+
<label for="email">Email</label>
|
|
18
|
+
<input id="email" name="email" type="email" autocomplete="email">
|
|
19
|
+
|
|
20
|
+
<fieldset class="legal">
|
|
21
|
+
<ds-checkbox>
|
|
22
|
+
<label class="ds-checkbox__control">
|
|
23
|
+
<input type="checkbox" name="agreement" id="mui-generated-8842" data-testid="legal-consent">
|
|
24
|
+
<span>I accept the terms and conditions</span>
|
|
25
|
+
</label>
|
|
26
|
+
</ds-checkbox>
|
|
27
|
+
</fieldset>
|
|
28
|
+
|
|
29
|
+
<button class="ds-button ds-button--primary" type="submit" data-testid="checkout-submit">Pay now</button>
|
|
30
|
+
</form>
|
|
31
|
+
</main>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Browser drivers: the only library-specific layer.
|
|
2
|
+
|
|
3
|
+
Built in: the sync Playwright API (also camoufox and patchright) and Selenium WebDriver.
|
|
4
|
+
Both are registered below and import their library lazily. wrap() handles sync objects
|
|
5
|
+
only: async Playwright pages go through mendwright.aio.open(), which uses
|
|
6
|
+
drivers/playwright_async.py. Add your own with register_driver(detect, make).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .base import Driver, driver_act_on, driver_describe, driver_inventory, is_driver
|
|
10
|
+
from .playwright_sync import PlaywrightSyncDriver, detect as _pw_detect
|
|
11
|
+
from .selenium import SeleniumDriver, detect as _sel_detect
|
|
12
|
+
from .registry import register_driver, register_driver_front, wrap
|
|
13
|
+
|
|
14
|
+
# Built-ins: Playwright sync family (also camoufox, patchright) + Selenium. Detection is
|
|
15
|
+
# disjoint (Playwright: locator+frame_locator on a Page/Frame; Selenium: find_elements +
|
|
16
|
+
# switch_to), so order is irrelevant. Both lazy-use their library, so importing mendwright
|
|
17
|
+
# needs neither installed.
|
|
18
|
+
register_driver(_pw_detect, PlaywrightSyncDriver)
|
|
19
|
+
register_driver(_sel_detect, SeleniumDriver)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
'Driver', 'is_driver', 'wrap', 'register_driver', 'register_driver_front',
|
|
23
|
+
'PlaywrightSyncDriver', 'SeleniumDriver',
|
|
24
|
+
'driver_act_on', 'driver_describe', 'driver_inventory',
|
|
25
|
+
]
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""The browser extension point. Drivers are the only code in mendwright that knows a
|
|
2
|
+
browser library.
|
|
3
|
+
|
|
4
|
+
Resolution (resolver.py) and the facade talk to a Driver, never to a concrete browser
|
|
5
|
+
library. A Driver wraps one live page/session and answers a handful of selector queries,
|
|
6
|
+
each against an optional frame chain (frame selectors, outermost first). Selectors may be
|
|
7
|
+
XPath, CSS or Playwright engine selectors (see mendwright.selectors); a driver that
|
|
8
|
+
cannot run a kind raises mendwright.InvalidSelector. `handle()` returns the native
|
|
9
|
+
locator or element, so callers keep acting on it (loc.click(), loc.fill(), ...) as before.
|
|
10
|
+
|
|
11
|
+
`outer_html(scope)` must capture the same scope that resolution runs against. Otherwise a
|
|
12
|
+
selector can be unique in the snapshot and not on the live page.
|
|
13
|
+
|
|
14
|
+
Required: count, is_visible and handle (what is_driver checks); scroll_into_view, act,
|
|
15
|
+
url and outer_html are expected as well. Optional, and absent from older custom drivers:
|
|
16
|
+
|
|
17
|
+
act_on(native, verb, *args, **kwargs) run a Playwright-style verb on a handle() result
|
|
18
|
+
describe(selector, frame_chain=()) {'tag', 'role', 'text', 'outer_html'} or None
|
|
19
|
+
inventory(limit=200) [{'tag', 'role', 'id', 'name', 'text', 'aria_label'}]
|
|
20
|
+
|
|
21
|
+
Callers should go through driver_act_on, driver_describe and driver_inventory below, which
|
|
22
|
+
fall back to getattr(native, verb)(...), None and [] when a driver lacks the method.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import inspect
|
|
26
|
+
import re
|
|
27
|
+
from typing import Optional, Protocol, runtime_checkable
|
|
28
|
+
|
|
29
|
+
import lxml.html
|
|
30
|
+
|
|
31
|
+
from ..validation import _norm, _role_of
|
|
32
|
+
|
|
33
|
+
FrameChain = tuple # tuple[str, ...] of frame selectors, outermost first
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@runtime_checkable
|
|
37
|
+
class Driver(Protocol):
|
|
38
|
+
"""The methods a driver is expected to have. act_on and describe are optional extras
|
|
39
|
+
(see the module docstring) and are left out on purpose, so isinstance(x, Driver) stays
|
|
40
|
+
true for custom drivers written before they existed. inventory was listed here from
|
|
41
|
+
the start; callers still treat it as optional through driver_inventory."""
|
|
42
|
+
|
|
43
|
+
def count(self, selector: str, frame_chain: FrameChain = ()) -> int: ...
|
|
44
|
+
def is_visible(self, selector: str, frame_chain: FrameChain = ()) -> bool: ...
|
|
45
|
+
def scroll_into_view(self, selector: str, frame_chain: FrameChain = ()) -> None: ...
|
|
46
|
+
def handle(self, selector: str, frame_chain: FrameChain = ()): # native locator/element
|
|
47
|
+
...
|
|
48
|
+
def act(self, selector: str, verb: str, *args, frame_chain: FrameChain = ()) -> None: ...
|
|
49
|
+
def url(self) -> str: ...
|
|
50
|
+
def outer_html(self, scope: Optional[str] = None) -> str: ...
|
|
51
|
+
def inventory(self, limit: int = 200) -> list: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_driver(obj) -> bool:
|
|
55
|
+
"""Already a Driver? (duck-typed on the resolution-critical methods)."""
|
|
56
|
+
return all(callable(getattr(obj, m, None)) for m in ('count', 'is_visible', 'handle'))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- optional methods, with a fallback for drivers that lack them -----------------------
|
|
60
|
+
|
|
61
|
+
def driver_act_on(driver, native, verb, *args, **kwargs):
|
|
62
|
+
"""Run ``verb`` on ``native`` (a handle() result) through driver.act_on when the driver
|
|
63
|
+
has it, else as getattr(native, verb)(*args, **kwargs). Errors propagate: a wrong verb
|
|
64
|
+
is an AttributeError (raised before any browser call by the built-in drivers), a bad
|
|
65
|
+
argument a TypeError or ValueError, and anything else came from the action itself."""
|
|
66
|
+
act_on = getattr(driver, 'act_on', None)
|
|
67
|
+
if callable(act_on):
|
|
68
|
+
return act_on(native, verb, *args, **kwargs)
|
|
69
|
+
return getattr(native, verb)(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def driver_describe(driver, selector, frame_chain=()):
|
|
73
|
+
"""driver.describe(selector, frame_chain) when the driver has it, else None.
|
|
74
|
+
InvalidSelector propagates; any other error gives None."""
|
|
75
|
+
from ..selectors import InvalidSelector
|
|
76
|
+
describe = getattr(driver, 'describe', None)
|
|
77
|
+
if not callable(describe):
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
out = describe(selector, frame_chain)
|
|
81
|
+
except InvalidSelector:
|
|
82
|
+
raise
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
return out if isinstance(out, dict) else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def driver_inventory(driver, limit=200):
|
|
89
|
+
"""driver.inventory(limit) when the driver has it, else []. Also [] on any error or a
|
|
90
|
+
result that is not a list. Accepts an older inventory() that takes no argument."""
|
|
91
|
+
inventory = getattr(driver, 'inventory', None)
|
|
92
|
+
if not callable(inventory):
|
|
93
|
+
return []
|
|
94
|
+
try:
|
|
95
|
+
try:
|
|
96
|
+
out = inventory(limit)
|
|
97
|
+
except TypeError:
|
|
98
|
+
out = inventory()
|
|
99
|
+
except Exception:
|
|
100
|
+
return []
|
|
101
|
+
return out if isinstance(out, list) else []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- detection helpers shared by the built-in detectors and wrap() ----------------------
|
|
105
|
+
|
|
106
|
+
# Methods whose async form gives an async API away: Playwright's async Page/Frame/Locator,
|
|
107
|
+
# and async WebDriver-like libraries (selenium_driverless and similar).
|
|
108
|
+
_ASYNC_PROBES = ('content', 'goto', 'wait_for_timeout', 'query_selector', 'count',
|
|
109
|
+
'is_visible', 'click', 'evaluate', 'find_elements', 'find_element',
|
|
110
|
+
'execute_script', 'get')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _member(obj, name):
|
|
114
|
+
"""obj's attribute ``name`` without running a property (a Selenium property such as
|
|
115
|
+
``title`` makes a browser round trip). Falls back to a normal lookup for objects that
|
|
116
|
+
forward attributes through __getattr__."""
|
|
117
|
+
try:
|
|
118
|
+
attr = inspect.getattr_static(obj, name)
|
|
119
|
+
except AttributeError:
|
|
120
|
+
try:
|
|
121
|
+
return getattr(obj, name, None)
|
|
122
|
+
except Exception:
|
|
123
|
+
return None
|
|
124
|
+
if isinstance(attr, (staticmethod, classmethod)):
|
|
125
|
+
return attr.__func__
|
|
126
|
+
if isinstance(attr, property):
|
|
127
|
+
return None
|
|
128
|
+
return attr
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def is_async_api(obj) -> bool:
|
|
132
|
+
"""True if obj comes from an async browser API (its query methods are coroutines)."""
|
|
133
|
+
for name in _ASYNC_PROBES:
|
|
134
|
+
attr = _member(obj, name)
|
|
135
|
+
if attr is not None and inspect.iscoroutinefunction(attr):
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def looks_like_locator(obj) -> bool:
|
|
141
|
+
"""True for a Playwright Locator or FrameLocator: chainable (locator + frame_locator)
|
|
142
|
+
with nth(), but no document of its own (no content()). A Page or Frame has content()
|
|
143
|
+
and no nth(). Such an object can resolve elements, but a heal can never pass because
|
|
144
|
+
there is no page HTML to capture."""
|
|
145
|
+
return (callable(_member(obj, 'locator')) and callable(_member(obj, 'frame_locator'))
|
|
146
|
+
and callable(_member(obj, 'nth')) and not callable(_member(obj, 'content')))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _looks_like_playwright(obj, module):
|
|
150
|
+
"""An async object from the Playwright family (Page, Frame, Locator, FrameLocator, also
|
|
151
|
+
from patchright or camoufox) rather than an async WebDriver-like driver."""
|
|
152
|
+
return '.async_api' in module or (callable(_member(obj, 'locator'))
|
|
153
|
+
and callable(_member(obj, 'frame_locator')))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def unsupported_message(obj) -> str:
|
|
157
|
+
"""The TypeError text wrap() uses when no registered driver accepts obj."""
|
|
158
|
+
name = type(obj).__name__
|
|
159
|
+
module = type(obj).__module__ or ''
|
|
160
|
+
# The module name is only a hint for the message (an async FrameLocator has no
|
|
161
|
+
# coroutine methods to give it away); detection itself never looks at it.
|
|
162
|
+
if is_async_api(obj) or '.async_api' in module:
|
|
163
|
+
if not _looks_like_playwright(obj, module):
|
|
164
|
+
return ('mendwright: ' + name + ' (from ' + module + ') is an async driver. '
|
|
165
|
+
'mendwright supports the sync Playwright API and sync Selenium, plus async '
|
|
166
|
+
'Playwright through mendwright.aio. Async Selenium-style drivers are not '
|
|
167
|
+
'supported. Use a selenium.webdriver driver with mendwright.open(), or '
|
|
168
|
+
'switch to Playwright: playwright.sync_api with mendwright.open(), or '
|
|
169
|
+
'playwright.async_api with mendwright.aio.open().')
|
|
170
|
+
if looks_like_locator(obj):
|
|
171
|
+
what = ('an async Playwright ' + name + ', which points at elements, not at a '
|
|
172
|
+
'page')
|
|
173
|
+
step = ('pass the page instead: heal = await mendwright.aio.open(page), with '
|
|
174
|
+
'locator.page for a Locator, or a Frame from page.frames to work inside '
|
|
175
|
+
'an iframe')
|
|
176
|
+
else:
|
|
177
|
+
what = 'an async Playwright object'
|
|
178
|
+
step = 'use the async entry point: heal = await mendwright.aio.open(page)'
|
|
179
|
+
return ('mendwright: ' + name + ' (from ' + module + ') is ' + what + '. '
|
|
180
|
+
'mendwright.open() takes the sync Playwright API and sync Selenium only. '
|
|
181
|
+
'For async Playwright, ' + step + '. To stay sync, open the browser with '
|
|
182
|
+
'playwright.sync_api.sync_playwright() instead.')
|
|
183
|
+
if looks_like_locator(obj):
|
|
184
|
+
return ('mendwright: got a Playwright ' + name + ', which points at elements, not at a '
|
|
185
|
+
'page. mendwright needs the page to capture HTML when it heals. Pass the page '
|
|
186
|
+
'instead (locator.page for a Locator), or a Frame from page.frames to work '
|
|
187
|
+
'inside an iframe.')
|
|
188
|
+
return ('mendwright: could not detect a browser driver for ' + name + '. Pass a '
|
|
189
|
+
'Playwright/Selenium page, or register one with mendwright.register_driver(detect, make).')
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- element summaries for describe() and inventory() -----------------------------------
|
|
193
|
+
|
|
194
|
+
OUTER_HTML_LIMIT = 2000 # describe(): outer_html is cut to this many characters
|
|
195
|
+
DESCRIBE_TEXT_LIMIT = 200 # describe(): text is cut to this many characters
|
|
196
|
+
INVENTORY_TEXT_LIMIT = 80 # inventory(): text and aria_label are cut to this many characters
|
|
197
|
+
|
|
198
|
+
# value="..." attributes can hold what a user typed (some frameworks mirror the live value
|
|
199
|
+
# into the attribute), so they never leave describe().
|
|
200
|
+
_VALUE_ATTR = re.compile(r"""\svalue=("[^"]*"|'[^']*'|[^\s>]+)""", re.IGNORECASE)
|
|
201
|
+
|
|
202
|
+
# describe(): run against every match of a locator; null unless exactly one matched.
|
|
203
|
+
DESCRIBE_JS = ('els => els.length === 1 ? '
|
|
204
|
+
'{tag: els[0].tagName.toLowerCase(), html: els[0].outerHTML} : null')
|
|
205
|
+
|
|
206
|
+
# inventory(): one pass over the document's interactive elements. Form fields and
|
|
207
|
+
# editable regions report no text, so typed values never leave the page.
|
|
208
|
+
INVENTORY_JS = r"""(limit) => {
|
|
209
|
+
const out = [];
|
|
210
|
+
const nodes = document.querySelectorAll('button, a, input, select, textarea, [role]');
|
|
211
|
+
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
|
|
212
|
+
const fieldRole = /^(textbox|searchbox|combobox|spinbutton|slider)$/i;
|
|
213
|
+
for (const e of nodes) {
|
|
214
|
+
if (out.length >= limit) break;
|
|
215
|
+
const tag = e.tagName.toLowerCase();
|
|
216
|
+
const type = (e.getAttribute('type') || '').toLowerCase();
|
|
217
|
+
if (tag === 'input' && type === 'hidden') continue;
|
|
218
|
+
const role = e.getAttribute('role') || '';
|
|
219
|
+
const field = tag === 'input' || tag === 'textarea' || tag === 'select'
|
|
220
|
+
|| e.isContentEditable || fieldRole.test(role);
|
|
221
|
+
out.push({tag: tag, role: role, type: type, id: e.getAttribute('id') || '',
|
|
222
|
+
name: e.getAttribute('name') || '',
|
|
223
|
+
text: field ? '' : norm(e.textContent).slice(0, 80),
|
|
224
|
+
aria_label: e.getAttribute('aria-label') || ''});
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}"""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _bare_element(tag):
|
|
231
|
+
try:
|
|
232
|
+
return lxml.html.Element(tag or 'span'), False
|
|
233
|
+
except Exception: # a tag name lxml will not take (rare custom or namespaced tags)
|
|
234
|
+
return lxml.html.Element('span'), True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def role_for(tag, role=None, type_=None):
|
|
238
|
+
"""The gate's role (validation._role_of) for an element known only by its tag, role
|
|
239
|
+
attribute and type attribute. Used by inventory()."""
|
|
240
|
+
tag = (tag or '').lower()
|
|
241
|
+
el, stand_in = _bare_element(tag)
|
|
242
|
+
if role:
|
|
243
|
+
el.set('role', str(role))
|
|
244
|
+
if type_:
|
|
245
|
+
el.set('type', str(type_))
|
|
246
|
+
out = _role_of(el)
|
|
247
|
+
return (tag or 'other') if stand_in and out == 'span' else out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_element(tag, outer_html):
|
|
251
|
+
"""The element itself, parsed from its outerHTML. fragment_fromstring handles almost
|
|
252
|
+
everything; <html>/<body>/<head> need a document parse; a bare element is the last
|
|
253
|
+
resort so the role still reflects the tag."""
|
|
254
|
+
try:
|
|
255
|
+
el = lxml.html.fragment_fromstring(outer_html)
|
|
256
|
+
if not tag or (isinstance(el.tag, str) and el.tag.lower() == tag):
|
|
257
|
+
return el
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
try:
|
|
261
|
+
for el in lxml.html.document_fromstring(outer_html).iter(tag):
|
|
262
|
+
return el
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
return _bare_element(tag)[0]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def element_summary(tag, outer_html):
|
|
269
|
+
"""describe()'s result for one element: {'tag', 'role', 'text', 'outer_html'}.
|
|
270
|
+
|
|
271
|
+
role and text are computed the way the gate computes them (validation._role_of and
|
|
272
|
+
the normalized text_content() that validate() compares for destructive keys), on the
|
|
273
|
+
element parsed from its full outerHTML. outer_html is returned without value=
|
|
274
|
+
attributes and cut to OUTER_HTML_LIMIT characters; a textarea reports no text."""
|
|
275
|
+
tag = (tag or '').lower()
|
|
276
|
+
outer = outer_html or ''
|
|
277
|
+
el = _parse_element(tag, outer)
|
|
278
|
+
text = '' if tag == 'textarea' else _norm(el.text_content())[:DESCRIBE_TEXT_LIMIT]
|
|
279
|
+
return {'tag': tag or (el.tag if isinstance(el.tag, str) else ''),
|
|
280
|
+
'role': _role_of(el), 'text': text,
|
|
281
|
+
'outer_html': _VALUE_ATTR.sub('', outer)[:OUTER_HTML_LIMIT]}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def summary_from_js(data):
|
|
285
|
+
"""element_summary() for DESCRIBE_JS's result, or None when it did not match once."""
|
|
286
|
+
if not isinstance(data, dict) or not isinstance(data.get('html'), str):
|
|
287
|
+
return None
|
|
288
|
+
return element_summary(data.get('tag'), data['html'])
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def inventory_rows(raw, limit=200):
|
|
292
|
+
"""Turn INVENTORY_JS's result into inventory() rows:
|
|
293
|
+
{'tag', 'role', 'id', 'name', 'text', 'aria_label'}, role computed with role_for().
|
|
294
|
+
Anything malformed is skipped; a non-list gives []."""
|
|
295
|
+
if not isinstance(raw, list):
|
|
296
|
+
return []
|
|
297
|
+
try:
|
|
298
|
+
limit = max(0, int(limit))
|
|
299
|
+
except Exception:
|
|
300
|
+
limit = 200
|
|
301
|
+
rows = []
|
|
302
|
+
for item in raw:
|
|
303
|
+
if len(rows) >= limit:
|
|
304
|
+
break
|
|
305
|
+
if not isinstance(item, dict):
|
|
306
|
+
continue
|
|
307
|
+
tag = str(item.get('tag') or '').lower()
|
|
308
|
+
rows.append({
|
|
309
|
+
'tag': tag,
|
|
310
|
+
'role': role_for(tag, item.get('role'), item.get('type')),
|
|
311
|
+
'id': str(item.get('id') or '')[:200],
|
|
312
|
+
'name': str(item.get('name') or '')[:200],
|
|
313
|
+
'text': _norm(str(item.get('text') or ''))[:INVENTORY_TEXT_LIMIT],
|
|
314
|
+
'aria_label': _norm(str(item.get('aria_label') or ''))[:INVENTORY_TEXT_LIMIT],
|
|
315
|
+
})
|
|
316
|
+
return rows
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Driver for the async Playwright API (playwright.async_api, and the async forms of
|
|
2
|
+
patchright and camoufox, detected by duck typing). mendwright.aio uses it; wrap() and
|
|
3
|
+
mendwright.open() stay sync and reject async pages.
|
|
4
|
+
|
|
5
|
+
It mirrors PlaywrightSyncDriver with coroutines: selectors of any kind (XPath runs as
|
|
6
|
+
'xpath=...', CSS as 'css=...', engine selectors pass through), frame chains of frame
|
|
7
|
+
selectors (outermost first), InvalidSelector straight away for a malformed selector, and a
|
|
8
|
+
safe default (0, False, None) for any other error on the read methods so resolution keeps
|
|
9
|
+
polling. page.locator() and frame_locator() are plain calls in the async API; only the
|
|
10
|
+
queries and actions are awaited.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
|
|
15
|
+
from .base import (DESCRIBE_JS, INVENTORY_JS, _member, inventory_rows, is_async_api,
|
|
16
|
+
looks_like_locator, summary_from_js)
|
|
17
|
+
from .playwright_sync import _raise_if_invalid, child_frames, frame_info, playwright_selector
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _awaited(value):
|
|
21
|
+
"""value, awaited when it is awaitable."""
|
|
22
|
+
if inspect.isawaitable(value):
|
|
23
|
+
return await value
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncPlaywrightDriver:
|
|
28
|
+
"""Wraps an async Playwright Page or Frame (or a patchright / camoufox one).
|
|
29
|
+
|
|
30
|
+
Every method is a coroutine: count, is_visible, scroll_into_view, handle, act, act_on,
|
|
31
|
+
url, outer_html, describe and inventory, with the same arguments and results as
|
|
32
|
+
PlaywrightSyncDriver. handle() returns the native async Locator."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, page):
|
|
35
|
+
self._page = page
|
|
36
|
+
|
|
37
|
+
def _targets(self, selector, frame_chain):
|
|
38
|
+
"""Convert the selector and frame chain up front so a bad one raises here,
|
|
39
|
+
outside any try that would swallow it."""
|
|
40
|
+
return playwright_selector(selector), tuple(playwright_selector(f) for f in frame_chain)
|
|
41
|
+
|
|
42
|
+
def _build(self, target, frames):
|
|
43
|
+
ctx = self._page
|
|
44
|
+
for frame in frames:
|
|
45
|
+
ctx = ctx.frame_locator(frame)
|
|
46
|
+
return ctx.locator(target)
|
|
47
|
+
|
|
48
|
+
async def _probe(self, selector, frame_chain, op, default):
|
|
49
|
+
target, frames = self._targets(selector, frame_chain)
|
|
50
|
+
try:
|
|
51
|
+
return await op(self._build(target, frames))
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
_raise_if_invalid(exc, selector, frame_chain)
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
async def count(self, selector, frame_chain=()):
|
|
57
|
+
async def op(loc):
|
|
58
|
+
return await loc.count()
|
|
59
|
+
return await self._probe(selector, frame_chain, op, 0)
|
|
60
|
+
|
|
61
|
+
async def is_visible(self, selector, frame_chain=()):
|
|
62
|
+
async def op(loc):
|
|
63
|
+
return bool(await loc.is_visible())
|
|
64
|
+
return await self._probe(selector, frame_chain, op, False)
|
|
65
|
+
|
|
66
|
+
async def scroll_into_view(self, selector, frame_chain=()):
|
|
67
|
+
async def op(loc):
|
|
68
|
+
await loc.scroll_into_view_if_needed()
|
|
69
|
+
await self._probe(selector, frame_chain, op, None)
|
|
70
|
+
|
|
71
|
+
async def handle(self, selector, frame_chain=()):
|
|
72
|
+
"""The native async Playwright Locator, for the caller to act on."""
|
|
73
|
+
return self._build(*self._targets(selector, frame_chain))
|
|
74
|
+
|
|
75
|
+
async def act(self, selector, verb, *args, frame_chain=(), **kwargs):
|
|
76
|
+
loc = await self.handle(selector, frame_chain)
|
|
77
|
+
try:
|
|
78
|
+
return await self.act_on(loc, verb, *args, **kwargs)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
_raise_if_invalid(exc, selector, frame_chain)
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
async def act_on(self, native, verb, *args, **kwargs):
|
|
84
|
+
"""Run a Playwright verb on a handle() result and await it:
|
|
85
|
+
await getattr(native, verb)(*args, **kwargs). Keyword options such as timeout= or
|
|
86
|
+
force= pass straight through. A verb the Locator does not have raises
|
|
87
|
+
AttributeError, and arguments it does not take raise TypeError, before anything
|
|
88
|
+
reaches the browser."""
|
|
89
|
+
return await _awaited(getattr(native, verb)(*args, **kwargs))
|
|
90
|
+
|
|
91
|
+
async def url(self):
|
|
92
|
+
try:
|
|
93
|
+
return self._page.url
|
|
94
|
+
except Exception:
|
|
95
|
+
return ''
|
|
96
|
+
|
|
97
|
+
async def outer_html(self, scope=None):
|
|
98
|
+
try:
|
|
99
|
+
if scope:
|
|
100
|
+
loc = await self.handle(scope)
|
|
101
|
+
return await loc.evaluate('e => e.outerHTML')
|
|
102
|
+
return await self._page.content()
|
|
103
|
+
except Exception:
|
|
104
|
+
return ''
|
|
105
|
+
|
|
106
|
+
async def describe(self, selector, frame_chain=()):
|
|
107
|
+
"""{'tag', 'role', 'text', 'outer_html'} for the one element the selector matches,
|
|
108
|
+
or None when it matches none or several (see PlaywrightSyncDriver.describe)."""
|
|
109
|
+
async def op(loc):
|
|
110
|
+
return summary_from_js(await loc.evaluate_all(DESCRIBE_JS))
|
|
111
|
+
return await self._probe(selector, frame_chain, op, None)
|
|
112
|
+
|
|
113
|
+
async def frame_matches(self, selector):
|
|
114
|
+
"""The frames inside this page (any depth) in which selector matches at least one
|
|
115
|
+
element, as [{'name', 'url'}] (see PlaywrightSyncDriver.frame_matches)."""
|
|
116
|
+
target = playwright_selector(selector)
|
|
117
|
+
found = []
|
|
118
|
+
for frame in child_frames(self._page):
|
|
119
|
+
try:
|
|
120
|
+
if await frame.locator(target).count() > 0:
|
|
121
|
+
found.append(frame_info(frame))
|
|
122
|
+
except Exception:
|
|
123
|
+
continue
|
|
124
|
+
return found
|
|
125
|
+
|
|
126
|
+
async def inventory(self, limit=200):
|
|
127
|
+
"""Up to ``limit`` interactive elements of the page (or frame) this driver wraps,
|
|
128
|
+
as PlaywrightSyncDriver.inventory() returns them. Never includes input values.
|
|
129
|
+
[] on any error."""
|
|
130
|
+
try:
|
|
131
|
+
raw = await self._page.evaluate(INVENTORY_JS, limit)
|
|
132
|
+
except Exception:
|
|
133
|
+
return []
|
|
134
|
+
return inventory_rows(raw, limit)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def detect(obj) -> bool:
|
|
138
|
+
"""Playwright ASYNC family: has .locator + .frame_locator, its queries are coroutines,
|
|
139
|
+
and it is a Page or Frame rather than a Locator or FrameLocator."""
|
|
140
|
+
return (hasattr(obj, 'locator') and hasattr(obj, 'frame_locator')
|
|
141
|
+
and is_async_api(obj) and not looks_like_locator(obj))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def is_async_driver(obj) -> bool:
|
|
145
|
+
"""Already an async driver: count, is_visible and handle are coroutine functions.
|
|
146
|
+
Read without running properties, like the other detectors."""
|
|
147
|
+
return all(inspect.iscoroutinefunction(_member(obj, m))
|
|
148
|
+
for m in ('count', 'is_visible', 'handle'))
|