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/capture.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Build the redacted heal bundle that goes to the model provider or gateway.
|
|
2
|
+
|
|
3
|
+
Redaction is best-effort. Before upload, quoted value="..." attributes are removed, row
|
|
4
|
+
secrets (the secrets, proxy and profile strings) are replaced with [redacted], absolute
|
|
5
|
+
URLs become [URL] and runs of 12 to 19 digits become [redacted number], in the HTML, the
|
|
6
|
+
broken selectors, the expected text and every string of the DOM inventory. Visible text
|
|
7
|
+
and other attribute values (aria-label, title, placeholder, data-*) are sent as they are;
|
|
8
|
+
SECURITY.md lists the gaps.
|
|
9
|
+
|
|
10
|
+
The HTML that goes into the bundle is also cleaned first. Scripts, styles, <noscript>,
|
|
11
|
+
<template>, comments, the inside of <svg> and everything in <head> except <title> are
|
|
12
|
+
removed, and runs of whitespace are collapsed. Real pages are often mostly inline
|
|
13
|
+
script and CSS, and the prompt keeps only the first 60,000 characters, so without this a
|
|
14
|
+
button near the end of a large page never reaches the model. Cleaning changes only what
|
|
15
|
+
the model sees: the gate still validates every proposal against the full captured HTML.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import html as _html
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
import lxml.html
|
|
22
|
+
from lxml import etree
|
|
23
|
+
|
|
24
|
+
_VALUE_ATTR = re.compile(r"""\svalue=("[^"]*"|'[^']*')""")
|
|
25
|
+
_URL = re.compile(r'https?://[^\s"\'<>]+')
|
|
26
|
+
_LONGNUM = re.compile(r'(?<!\d)\d{12,19}(?!\d)')
|
|
27
|
+
|
|
28
|
+
# Only ASCII whitespace: a non-breaking space is part of a label's text, and collapsing it
|
|
29
|
+
# would show the model text that an xpath could not match on the live page.
|
|
30
|
+
_WHITESPACE = re.compile(r'[ \t\n\r\f\v]+')
|
|
31
|
+
_XML_DECL = re.compile(r'^\s*<\?xml[^>]*\?>')
|
|
32
|
+
_DROP_TAGS = ('script', 'style', 'noscript', 'template')
|
|
33
|
+
_HEAD_ONLY = ('meta', 'link', 'base')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _scrub(text):
|
|
37
|
+
text = _URL.sub('[URL]', text)
|
|
38
|
+
return _LONGNUM.sub('[redacted number]', text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _secret_values(row):
|
|
42
|
+
if not row:
|
|
43
|
+
return []
|
|
44
|
+
values = list((row.get('secrets') or {}).values()) + list((row.get('proxy') or {}).values())
|
|
45
|
+
|
|
46
|
+
def collect(node):
|
|
47
|
+
if isinstance(node, dict):
|
|
48
|
+
for value in node.values():
|
|
49
|
+
collect(value)
|
|
50
|
+
elif isinstance(node, str) and node:
|
|
51
|
+
values.append(node)
|
|
52
|
+
|
|
53
|
+
collect(row.get('profile', {}))
|
|
54
|
+
return sorted({v for v in values if isinstance(v, str) and v}, key=len, reverse=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _secret_forms(values):
|
|
58
|
+
"""Each secret as written in plain text and as HTML-escaped (``&`` -> ``&``,
|
|
59
|
+
quotes -> entities), longest first, so a secret serialized into markup still matches."""
|
|
60
|
+
forms = set()
|
|
61
|
+
for value in values:
|
|
62
|
+
forms.add(value)
|
|
63
|
+
forms.add(_html.escape(value, quote=False))
|
|
64
|
+
forms.add(_html.escape(value, quote=True))
|
|
65
|
+
return sorted(forms, key=len, reverse=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _redact_text(text, forms):
|
|
69
|
+
"""The one redaction used for the HTML snapshot and for every inventory string:
|
|
70
|
+
drop value="..." attributes, replace secrets, then scrub URLs and long numbers."""
|
|
71
|
+
out = _VALUE_ATTR.sub('', text) # drop filled input/option values
|
|
72
|
+
for value in forms:
|
|
73
|
+
if len(value) < 4:
|
|
74
|
+
out = re.sub(r'(?<!\w)' + re.escape(value) + r'(?!\w)', '[redacted]', out)
|
|
75
|
+
else:
|
|
76
|
+
out = out.replace(value, '[redacted]')
|
|
77
|
+
return _scrub(out) # URLs + long numbers
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def redact(text, row=None):
|
|
81
|
+
if not isinstance(text, str):
|
|
82
|
+
return text
|
|
83
|
+
return _redact_text(text, _secret_forms(_secret_values(row)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _redact_tree(node, forms):
|
|
87
|
+
"""Redact every string in an inventory structure. A ``value`` key is dropped, the way
|
|
88
|
+
the value="..." attribute is dropped from the HTML: it holds what was typed or chosen."""
|
|
89
|
+
if isinstance(node, dict):
|
|
90
|
+
return {k: _redact_tree(v, forms) for k, v in node.items()
|
|
91
|
+
if not (isinstance(k, str) and k.lower() == 'value')}
|
|
92
|
+
if isinstance(node, (list, tuple)):
|
|
93
|
+
return [_redact_tree(v, forms) for v in node]
|
|
94
|
+
if isinstance(node, str):
|
|
95
|
+
return _redact_text(node, forms)
|
|
96
|
+
return node
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def clean_html(text):
|
|
100
|
+
"""Return ``text`` without the parts that never help a model pick a selector.
|
|
101
|
+
|
|
102
|
+
Removes <script>, <style>, <noscript>, <template>, comments and processing
|
|
103
|
+
instructions, empties <svg> (the tag and its attributes stay), keeps only <title> inside
|
|
104
|
+
<head>, and collapses runs of whitespace in text. Text that follows a removed element
|
|
105
|
+
is kept. Returns ``text`` unchanged if it is not a string, is blank, or cannot be parsed.
|
|
106
|
+
"""
|
|
107
|
+
if not isinstance(text, str) or not text.strip():
|
|
108
|
+
return text
|
|
109
|
+
try:
|
|
110
|
+
root = lxml.html.fromstring(_XML_DECL.sub('', text, count=1))
|
|
111
|
+
if not isinstance(root.tag, str):
|
|
112
|
+
return text
|
|
113
|
+
if root.tag.lower() in _DROP_TAGS:
|
|
114
|
+
return ''
|
|
115
|
+
etree.strip_elements(root, *_DROP_TAGS, etree.Comment, etree.ProcessingInstruction,
|
|
116
|
+
with_tail=False)
|
|
117
|
+
for svg in list(root.iter('svg')):
|
|
118
|
+
for child in list(svg):
|
|
119
|
+
svg.remove(child)
|
|
120
|
+
svg.text = None
|
|
121
|
+
# <head> keeps only <title>: script/style/noscript/template are already gone, and
|
|
122
|
+
# meta/link/base go here. Anything else stays, because libxml2 parses a fragment
|
|
123
|
+
# that starts with <script> by putting the following body elements into <head>.
|
|
124
|
+
for head in list(root.iter('head')):
|
|
125
|
+
for child in list(head):
|
|
126
|
+
if isinstance(child.tag, str) and child.tag.lower() in _HEAD_ONLY:
|
|
127
|
+
head.remove(child)
|
|
128
|
+
for el in root.iter():
|
|
129
|
+
if el.text:
|
|
130
|
+
el.text = _WHITESPACE.sub(' ', el.text)
|
|
131
|
+
if el.tail:
|
|
132
|
+
el.tail = _WHITESPACE.sub(' ', el.tail)
|
|
133
|
+
return lxml.html.tostring(root, encoding='unicode')
|
|
134
|
+
except Exception:
|
|
135
|
+
return text
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_bundle(key, meta, broken_candidates, inventory, html, context, row=None):
|
|
139
|
+
forms = _secret_forms(_secret_values(row))
|
|
140
|
+
# Redact before and after cleaning: collapsing whitespace could split a multi-line
|
|
141
|
+
# secret, and re-serializing can turn an entity-encoded secret into plain text.
|
|
142
|
+
page = html
|
|
143
|
+
if isinstance(page, str):
|
|
144
|
+
page = _redact_text(clean_html(_redact_text(page, forms)), forms)
|
|
145
|
+
# The broken selectors and the expected text can be built from the same row values (a
|
|
146
|
+
# data-driven test), so they get the same redaction as the page before the prompt.
|
|
147
|
+
text = meta.get('expectedText') or []
|
|
148
|
+
if isinstance(text, str):
|
|
149
|
+
text = [text]
|
|
150
|
+
xpaths = [c.get('xpath') if isinstance(c, dict) else c for c in (broken_candidates or [])]
|
|
151
|
+
return {
|
|
152
|
+
'key': key,
|
|
153
|
+
'expectedRole': meta.get('expectedRole'),
|
|
154
|
+
'expectedText': [_redact_text(t, forms) if isinstance(t, str) else t for t in text],
|
|
155
|
+
'destructive': bool(meta.get('destructive')),
|
|
156
|
+
'candidates': [_redact_text(x, forms) for x in xpaths if isinstance(x, str) and x],
|
|
157
|
+
'inventory': _redact_tree(inventory, forms),
|
|
158
|
+
'html': page,
|
|
159
|
+
'url': _scrub(redact(context.get('url') or '', row)) or None,
|
|
160
|
+
'stage': context.get('stage'),
|
|
161
|
+
'version': context.get('version'),
|
|
162
|
+
}
|