selenium-python-ai-agent 0.2.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.
- selenium_agent/__init__.py +20 -0
- selenium_agent/agents/__init__.py +5 -0
- selenium_agent/agents/coder.py +505 -0
- selenium_agent/agents/definitions.py +152 -0
- selenium_agent/agents/healer.py +638 -0
- selenium_agent/agents/planner.py +279 -0
- selenium_agent/bdd/__init__.py +15 -0
- selenium_agent/bdd/gherkin_advisor.py +189 -0
- selenium_agent/bdd/templates.py +98 -0
- selenium_agent/cli.py +314 -0
- selenium_agent/core/__init__.py +3 -0
- selenium_agent/core/orchestrator.py +189 -0
- selenium_agent/scanner/__init__.py +3 -0
- selenium_agent/scanner/project_scanner.py +452 -0
- selenium_agent/selenium/__init__.py +6 -0
- selenium_agent/selenium/base_page.py +447 -0
- selenium_agent/selenium/driver_factory.py +222 -0
- selenium_agent/selenium/error_map.py +235 -0
- selenium_agent/selenium/locator_advisor.py +247 -0
- selenium_agent/selenium/locator_scanner.py +502 -0
- selenium_agent/utils/__init__.py +26 -0
- selenium_agent/utils/code_validator.py +96 -0
- selenium_agent/utils/config_manager.py +81 -0
- selenium_agent/utils/json_utils.py +103 -0
- selenium_agent/utils/llm.py +240 -0
- selenium_agent/utils/logger.py +37 -0
- selenium_agent/utils/paths.py +57 -0
- selenium_agent/utils/spec_writer.py +126 -0
- selenium_agent/utils/url_extractor.py +52 -0
- selenium_python_ai_agent-0.2.0.dist-info/METADATA +412 -0
- selenium_python_ai_agent-0.2.0.dist-info/RECORD +35 -0
- selenium_python_ai_agent-0.2.0.dist-info/WHEEL +5 -0
- selenium_python_ai_agent-0.2.0.dist-info/entry_points.txt +2 -0
- selenium_python_ai_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- selenium_python_ai_agent-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LOCATOR SCANNER
|
|
3
|
+
===============
|
|
4
|
+
Opens a real browser, scans the page DOM, and returns BOTH CSS and XPath
|
|
5
|
+
for every interactive element — so LLM never has to guess.
|
|
6
|
+
|
|
7
|
+
CSS → preferred (faster, readable)
|
|
8
|
+
XPath → fallback (when CSS not expressive enough)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from selenium_agent.utils.logger import setup_logger
|
|
12
|
+
|
|
13
|
+
logger = setup_logger("LocatorScanner")
|
|
14
|
+
|
|
15
|
+
# ── JS: scan every interactive element → CSS + XPath ──────────────────────────
|
|
16
|
+
# NOTE: the leading `return` is load-bearing — Selenium's execute_script only
|
|
17
|
+
# hands the value back to Python if the script body explicitly returns it.
|
|
18
|
+
_SCAN_JS = """
|
|
19
|
+
return (function () {
|
|
20
|
+
// ── XPath builder (relative, attribute-based — never absolute) ────────────
|
|
21
|
+
function buildXPath(el) {
|
|
22
|
+
const tag = el.tagName.toLowerCase();
|
|
23
|
+
|
|
24
|
+
if (el.getAttribute('data-test'))
|
|
25
|
+
return '//' + tag + '[@data-test="' + el.getAttribute('data-test') + '"]';
|
|
26
|
+
if (el.getAttribute('data-testid'))
|
|
27
|
+
return '//' + tag + '[@data-testid="' + el.getAttribute('data-testid') + '"]';
|
|
28
|
+
if (el.getAttribute('data-cy'))
|
|
29
|
+
return '//' + tag + '[@data-cy="' + el.getAttribute('data-cy') + '"]';
|
|
30
|
+
if (el.id)
|
|
31
|
+
return '//' + tag + '[@id="' + el.id + '"]';
|
|
32
|
+
if (el.name)
|
|
33
|
+
return '//' + tag + '[@name="' + el.name + '"]';
|
|
34
|
+
if (el.getAttribute('aria-label'))
|
|
35
|
+
return '//' + tag + '[@aria-label="' + el.getAttribute('aria-label') + '"]';
|
|
36
|
+
|
|
37
|
+
const txt = (el.innerText || el.value || '').trim().slice(0, 40);
|
|
38
|
+
if (txt && (tag === 'button' || tag === 'a'))
|
|
39
|
+
return '//' + tag + '[normalize-space()="' + txt + '"]';
|
|
40
|
+
|
|
41
|
+
if (el.placeholder)
|
|
42
|
+
return '//' + tag + '[@placeholder="' + el.placeholder + '"]';
|
|
43
|
+
|
|
44
|
+
// Last resort: type attribute
|
|
45
|
+
if (el.type)
|
|
46
|
+
return '//' + tag + '[@type="' + el.type + '"]';
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── CSS builder (priority: data-test > id > name > placeholder > type) ────
|
|
52
|
+
function buildCSS(el) {
|
|
53
|
+
const tag = el.tagName.toLowerCase();
|
|
54
|
+
|
|
55
|
+
if (el.getAttribute('data-test'))
|
|
56
|
+
return '[data-test="' + el.getAttribute('data-test') + '"]';
|
|
57
|
+
if (el.getAttribute('data-testid'))
|
|
58
|
+
return '[data-testid="' + el.getAttribute('data-testid') + '"]';
|
|
59
|
+
if (el.getAttribute('data-cy'))
|
|
60
|
+
return '[data-cy="' + el.getAttribute('data-cy') + '"]';
|
|
61
|
+
if (el.id)
|
|
62
|
+
return '#' + el.id;
|
|
63
|
+
if (el.name)
|
|
64
|
+
return tag + '[name="' + el.name + '"]';
|
|
65
|
+
if (el.placeholder)
|
|
66
|
+
return tag + '[placeholder="' + el.placeholder + '"]';
|
|
67
|
+
if (el.type && el.type !== 'text')
|
|
68
|
+
return tag + '[type="' + el.type + '"]';
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Uniqueness helpers — a locator that matches several elements is a trap ──
|
|
74
|
+
function cssCount(sel) {
|
|
75
|
+
try { return document.querySelectorAll(sel).length; } catch (e) { return 0; }
|
|
76
|
+
}
|
|
77
|
+
function xpathCount(xp) {
|
|
78
|
+
try {
|
|
79
|
+
return document.evaluate('count(' + xp + ')', document, null,
|
|
80
|
+
XPathResult.NUMBER_TYPE, null).numberValue;
|
|
81
|
+
} catch (e) { return 0; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const INTERACTIVE = [
|
|
85
|
+
'input', 'button', 'a[href]', 'select', 'textarea',
|
|
86
|
+
'[role="button"]', '[role="link"]', '[role="textbox"]',
|
|
87
|
+
'[role="checkbox"]', '[role="radio"]', '[type="submit"]'
|
|
88
|
+
].join(',');
|
|
89
|
+
|
|
90
|
+
const results = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
|
|
93
|
+
document.querySelectorAll(INTERACTIVE).forEach(function (el) {
|
|
94
|
+
// Skip hidden elements
|
|
95
|
+
const rect = el.getBoundingClientRect();
|
|
96
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
97
|
+
|
|
98
|
+
let css = buildCSS(el);
|
|
99
|
+
let xpath = buildXPath(el);
|
|
100
|
+
|
|
101
|
+
const info = {
|
|
102
|
+
tag: el.tagName.toLowerCase(),
|
|
103
|
+
href: el.href || null,
|
|
104
|
+
options: el.tagName === 'SELECT'
|
|
105
|
+
? [...el.options].slice(0, 12)
|
|
106
|
+
.map(o => o.textContent.trim()).filter(Boolean)
|
|
107
|
+
: null,
|
|
108
|
+
type: el.type || null,
|
|
109
|
+
id: el.id || null,
|
|
110
|
+
name: el.name || null,
|
|
111
|
+
placeholder: el.placeholder || null,
|
|
112
|
+
text: (el.innerText || el.value || '').trim().slice(0, 60) || null,
|
|
113
|
+
data_test: el.getAttribute('data-test') || null,
|
|
114
|
+
data_testid: el.getAttribute('data-testid') || null,
|
|
115
|
+
data_cy: el.getAttribute('data-cy') || null,
|
|
116
|
+
aria_label: el.getAttribute('aria-label') || null,
|
|
117
|
+
css: css,
|
|
118
|
+
xpath: xpath,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ── Uniquify ambiguous selectors by scoping to an ancestor with an id ──
|
|
122
|
+
// e.g. 'input[type="submit"]' matching both a search icon and the real
|
|
123
|
+
// form button becomes '#create_customer input[type="submit"]'.
|
|
124
|
+
if ((css && cssCount(css) > 1) || (xpath && xpathCount(xpath) > 1)) {
|
|
125
|
+
let anc = el.parentElement;
|
|
126
|
+
while (anc && anc.tagName !== 'BODY') {
|
|
127
|
+
if (anc.id) {
|
|
128
|
+
if (css && cssCount(css) > 1) {
|
|
129
|
+
const scoped = '#' + anc.id + ' ' + css;
|
|
130
|
+
if (cssCount(scoped) === 1) { info.css = scoped; css = scoped; }
|
|
131
|
+
}
|
|
132
|
+
if (xpath && xpathCount(xpath) > 1 && xpath.indexOf('//') === 0) {
|
|
133
|
+
const sx = '//*[@id="' + anc.id + '"]//' + xpath.slice(2);
|
|
134
|
+
if (xpathCount(sx) === 1) { info.xpath = sx; xpath = sx; }
|
|
135
|
+
}
|
|
136
|
+
if ((!css || cssCount(css) === 1) && (!xpath || xpathCount(xpath) === 1))
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
anc = anc.parentElement;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Annotate whatever is STILL ambiguous so the LLM never guesses wrong
|
|
144
|
+
if (css) {
|
|
145
|
+
const n = cssCount(css);
|
|
146
|
+
if (n > 1) info.css_matches = n;
|
|
147
|
+
}
|
|
148
|
+
if (xpath) {
|
|
149
|
+
const n = xpathCount(xpath);
|
|
150
|
+
if (n > 1) info.xpath_matches = n;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const key = (css || '') + '|' + (xpath || '');
|
|
154
|
+
if (key !== '|' && !seen.has(key)) {
|
|
155
|
+
seen.add(key);
|
|
156
|
+
results.push(info);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── Text-bearing leaf elements (labels, displayed values, messages) ──
|
|
161
|
+
// Workflows often need to READ text from the page ("grab the username
|
|
162
|
+
// shown in the credentials section"). Capture a bounded set of short
|
|
163
|
+
// leaf text elements so the LLM has real locators for them too.
|
|
164
|
+
const TEXTY = 'p, span, label, h1, h2, h3, h4, h5, h6, td, th, li, code, pre, strong, b, div[id]';
|
|
165
|
+
let textCount = 0;
|
|
166
|
+
document.querySelectorAll(TEXTY).forEach(function (el) {
|
|
167
|
+
if (textCount >= 30) return;
|
|
168
|
+
if (el.children.length !== 0) return;
|
|
169
|
+
const txt = (el.textContent || '').trim();
|
|
170
|
+
if (txt.length < 3 || txt.length > 80) return;
|
|
171
|
+
const rect = el.getBoundingClientRect();
|
|
172
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
173
|
+
|
|
174
|
+
const tag = el.tagName.toLowerCase();
|
|
175
|
+
let css = null;
|
|
176
|
+
if (el.getAttribute('data-test'))
|
|
177
|
+
css = '[data-test="' + el.getAttribute('data-test') + '"]';
|
|
178
|
+
else if (el.getAttribute('data-testid'))
|
|
179
|
+
css = '[data-testid="' + el.getAttribute('data-testid') + '"]';
|
|
180
|
+
else if (el.id)
|
|
181
|
+
css = '#' + el.id;
|
|
182
|
+
else if (typeof el.className === 'string' && el.className.trim())
|
|
183
|
+
css = tag + '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.');
|
|
184
|
+
|
|
185
|
+
// A CSS that matches several elements is worse than none —
|
|
186
|
+
// reading text through it silently returns the WRONG element.
|
|
187
|
+
if (css && cssCount(css) !== 1) css = null;
|
|
188
|
+
|
|
189
|
+
let xpath = null;
|
|
190
|
+
if (txt.indexOf('"') === -1) {
|
|
191
|
+
// "LABEL: value" pattern → anchor on the stable label part so the
|
|
192
|
+
// locator survives when the value changes (status badges, etc.)
|
|
193
|
+
const colon = txt.indexOf(':');
|
|
194
|
+
if (colon > 0 && colon < 40) {
|
|
195
|
+
const prefixXp = '//' + tag + '[contains(normalize-space(), "'
|
|
196
|
+
+ txt.slice(0, colon + 1) + '")]';
|
|
197
|
+
if (xpathCount(prefixXp) === 1) xpath = prefixXp;
|
|
198
|
+
}
|
|
199
|
+
if (!xpath) {
|
|
200
|
+
const exactXp = '//' + tag + '[normalize-space()="' + txt + '"]';
|
|
201
|
+
if (xpathCount(exactXp) === 1) xpath = exactXp;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!css && !xpath) return;
|
|
206
|
+
const key = 'txt|' + (css || '') + '|' + txt;
|
|
207
|
+
if (seen.has(key)) return;
|
|
208
|
+
seen.add(key);
|
|
209
|
+
textCount++;
|
|
210
|
+
results.push({
|
|
211
|
+
tag: tag, kind: 'text', href: null, type: null,
|
|
212
|
+
id: el.id || null, name: null, placeholder: null,
|
|
213
|
+
text: txt.slice(0, 60),
|
|
214
|
+
data_test: el.getAttribute('data-test') || null,
|
|
215
|
+
data_testid: el.getAttribute('data-testid') || null,
|
|
216
|
+
data_cy: null, aria_label: null,
|
|
217
|
+
css: css, xpath: xpath,
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── Bot-protection detection ──
|
|
222
|
+
// A captcha (hCaptcha/reCAPTCHA/Turnstile) means automated flows that
|
|
223
|
+
// trigger it CANNOT pass — agents must report this instead of endlessly
|
|
224
|
+
// "fixing" locators.
|
|
225
|
+
if (document.querySelector(
|
|
226
|
+
'iframe[src*="hcaptcha"], iframe[src*="recaptcha"], ' +
|
|
227
|
+
'iframe[src*="turnstile"], [data-sitekey]')) {
|
|
228
|
+
results.push({
|
|
229
|
+
tag: 'iframe', kind: 'captcha', href: null, type: null,
|
|
230
|
+
id: null, name: null, placeholder: null,
|
|
231
|
+
text: 'CAPTCHA / bot protection active on this page',
|
|
232
|
+
data_test: null, data_testid: null, data_cy: null,
|
|
233
|
+
aria_label: null, css: null, xpath: null,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return results;
|
|
238
|
+
})();
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def scan_page_locators(url: str, headless: bool = True, max_wait: int = 25) -> list[dict]:
|
|
243
|
+
"""
|
|
244
|
+
Open browser, navigate to url, scan DOM, return elements with real CSS + XPath.
|
|
245
|
+
|
|
246
|
+
SPA-aware: client-side apps (React/Vue/Angular) render the form long after
|
|
247
|
+
document.readyState is 'complete' — some behind boot animations where
|
|
248
|
+
elements exist in the DOM but are still zero-sized. So instead of one shot,
|
|
249
|
+
the scan JS is POLLED every second until visible interactive elements
|
|
250
|
+
appear (up to max_wait seconds).
|
|
251
|
+
|
|
252
|
+
Returns [] on any failure — callers handle gracefully.
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
from selenium_agent.selenium.driver_factory import DriverFactory
|
|
256
|
+
driver = DriverFactory.create(browser="chrome", headless=headless)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.warning(f"⚠️ Browser launch failed for scan: {e}")
|
|
259
|
+
return []
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
logger.info(f"🔍 Scanning DOM: {url}")
|
|
263
|
+
driver.get(url)
|
|
264
|
+
|
|
265
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
266
|
+
import time
|
|
267
|
+
|
|
268
|
+
# Wait for full page load
|
|
269
|
+
WebDriverWait(driver, 10).until(
|
|
270
|
+
lambda d: d.execute_script("return document.readyState") == "complete"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Wait for redirect to settle — URL stops changing
|
|
274
|
+
initial_url = driver.current_url
|
|
275
|
+
time.sleep(1.5)
|
|
276
|
+
final_url = driver.current_url
|
|
277
|
+
if final_url != initial_url:
|
|
278
|
+
logger.info(f"🔄 Redirected to: {final_url}")
|
|
279
|
+
WebDriverWait(driver, 10).until(
|
|
280
|
+
lambda d: d.execute_script("return document.readyState") == "complete"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Poll until VISIBLE interactive elements render (SPA hydration,
|
|
284
|
+
# boot animations, lazy data fetches)
|
|
285
|
+
deadline = time.time() + max_wait
|
|
286
|
+
elements: list[dict] = []
|
|
287
|
+
while time.time() < deadline:
|
|
288
|
+
elements = driver.execute_script(_SCAN_JS) or []
|
|
289
|
+
if elements:
|
|
290
|
+
# settle pass — catch elements that render moments later
|
|
291
|
+
time.sleep(1.0)
|
|
292
|
+
more = driver.execute_script(_SCAN_JS) or []
|
|
293
|
+
if len(more) > len(elements):
|
|
294
|
+
elements = more
|
|
295
|
+
break
|
|
296
|
+
time.sleep(1.0)
|
|
297
|
+
|
|
298
|
+
if not elements:
|
|
299
|
+
logger.warning(f"⚠️ No visible interactive elements after {max_wait}s on: {driver.current_url}")
|
|
300
|
+
logger.info(f"✅ {len(elements)} interactive elements found on: {driver.current_url}")
|
|
301
|
+
return elements
|
|
302
|
+
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.warning(f"⚠️ DOM scan failed: {e}")
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
finally:
|
|
308
|
+
try:
|
|
309
|
+
driver.quit()
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def rank_links_by_relevance(links: list[dict], instruction: str) -> list[str]:
|
|
315
|
+
"""
|
|
316
|
+
Order candidate links so the ones RELEVANT to the instruction come first.
|
|
317
|
+
|
|
318
|
+
Exploring the first N links in document order wastes the budget on nav
|
|
319
|
+
noise (search, about-us, ...) while the page the flow actually needs
|
|
320
|
+
(e.g. /account/register for a sign-up instruction) is never scanned.
|
|
321
|
+
Scores each link by how many instruction words appear in its visible
|
|
322
|
+
text or href; ties keep document order.
|
|
323
|
+
|
|
324
|
+
links: [{"href": ..., "text": ...}] → ordered list of hrefs
|
|
325
|
+
"""
|
|
326
|
+
import re
|
|
327
|
+
|
|
328
|
+
stop = {"the", "and", "for", "with", "then", "that", "this", "page",
|
|
329
|
+
"open", "click", "verify", "form", "button", "link", "fill",
|
|
330
|
+
"random", "generated", "runtime", "unique", "finally", "was"}
|
|
331
|
+
tokens = {
|
|
332
|
+
w for w in re.findall(r"[a-z]+", instruction.lower())
|
|
333
|
+
if len(w) >= 3 and w not in stop
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
def score(link: dict) -> int:
|
|
337
|
+
haystack = f"{link.get('text') or ''} {link.get('href') or ''}".lower()
|
|
338
|
+
return sum(1 for t in tokens if t in haystack)
|
|
339
|
+
|
|
340
|
+
ranked = sorted(
|
|
341
|
+
enumerate(links),
|
|
342
|
+
key=lambda pair: (-score(pair[1]), pair[0]),
|
|
343
|
+
)
|
|
344
|
+
return [link.get("href") for _, link in ranked]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def scan_site_locators(
|
|
348
|
+
url: str,
|
|
349
|
+
headless: bool = True,
|
|
350
|
+
max_extra_pages: int = 3,
|
|
351
|
+
instruction: str = "",
|
|
352
|
+
) -> dict[str, list[dict]]:
|
|
353
|
+
"""
|
|
354
|
+
Bounded same-origin exploration (Playwright-planner style):
|
|
355
|
+
scan the target URL, then follow up to `max_extra_pages` same-origin
|
|
356
|
+
links discovered on it and scan those pages too. When an instruction
|
|
357
|
+
is given, links relevant to it are explored first.
|
|
358
|
+
|
|
359
|
+
Returns {page_url: [elements]} — the target URL is always first.
|
|
360
|
+
Pages behind auth simply return whatever the redirect target renders.
|
|
361
|
+
"""
|
|
362
|
+
from urllib.parse import urlparse
|
|
363
|
+
|
|
364
|
+
results: dict[str, list[dict]] = {}
|
|
365
|
+
elements = scan_page_locators(url, headless=headless)
|
|
366
|
+
results[url] = elements
|
|
367
|
+
if not elements or max_extra_pages <= 0:
|
|
368
|
+
return results
|
|
369
|
+
|
|
370
|
+
origin = urlparse(url).netloc
|
|
371
|
+
seen = {url.rstrip("/"), url.rstrip("/") + "/"}
|
|
372
|
+
|
|
373
|
+
def collect_links(page_elements: list[dict]) -> list[dict]:
|
|
374
|
+
found = []
|
|
375
|
+
for el in page_elements:
|
|
376
|
+
href = el.get("href") or ""
|
|
377
|
+
if not href.startswith("http"):
|
|
378
|
+
continue
|
|
379
|
+
clean = href.split("#")[0].rstrip("/")
|
|
380
|
+
if urlparse(href).netloc == origin and clean and clean not in seen:
|
|
381
|
+
seen.add(clean)
|
|
382
|
+
found.append({"href": clean, "text": el.get("text") or ""})
|
|
383
|
+
return found
|
|
384
|
+
|
|
385
|
+
# Multi-hop, relevance-first exploration: links discovered on explored
|
|
386
|
+
# pages join the pool, so a page one hop deeper (e.g. a register form
|
|
387
|
+
# linked only from the login page) is still reachable within budget.
|
|
388
|
+
pool = collect_links(elements)
|
|
389
|
+
for _ in range(max_extra_pages):
|
|
390
|
+
if not pool:
|
|
391
|
+
break
|
|
392
|
+
if instruction:
|
|
393
|
+
best = rank_links_by_relevance(pool, instruction)[0]
|
|
394
|
+
else:
|
|
395
|
+
best = pool[0]["href"]
|
|
396
|
+
pool = [c for c in pool if c["href"] != best]
|
|
397
|
+
|
|
398
|
+
logger.info(f"🧭 Exploring same-origin page: {best}")
|
|
399
|
+
extra_elements = scan_page_locators(best, headless=headless)
|
|
400
|
+
if extra_elements:
|
|
401
|
+
results[best] = extra_elements
|
|
402
|
+
pool.extend(collect_links(extra_elements))
|
|
403
|
+
|
|
404
|
+
return results
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def format_site_for_llm(site: dict[str, list[dict]], context: str = "general") -> str:
|
|
408
|
+
"""Format multi-page scan results, one labeled block per page."""
|
|
409
|
+
blocks = []
|
|
410
|
+
for page_url, elements in site.items():
|
|
411
|
+
if not elements:
|
|
412
|
+
continue
|
|
413
|
+
block = format_for_llm(elements, context=context)
|
|
414
|
+
blocks.append(f"═══ PAGE: {page_url} ═══\n{block}")
|
|
415
|
+
return "\n\n".join(blocks)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def format_for_llm(elements: list[dict], context: str = "general") -> str:
|
|
419
|
+
"""
|
|
420
|
+
Format scanned elements into a clear prompt block for LLM.
|
|
421
|
+
context: 'planning' | 'healing' | 'general'
|
|
422
|
+
"""
|
|
423
|
+
if not elements:
|
|
424
|
+
return ""
|
|
425
|
+
|
|
426
|
+
header = {
|
|
427
|
+
"planning": "🔍 REAL DOM LOCATORS — scanned before generating plan. Use ONLY these:",
|
|
428
|
+
"healing": "🔍 REAL DOM LOCATORS — re-scanned from live page. Fix failed locators using these:",
|
|
429
|
+
"general": "🔍 REAL DOM LOCATORS (from actual browser scan):",
|
|
430
|
+
}.get(context, "🔍 REAL DOM LOCATORS:")
|
|
431
|
+
|
|
432
|
+
lines = [header, ""]
|
|
433
|
+
|
|
434
|
+
if any(el.get("kind") == "captcha" for el in elements):
|
|
435
|
+
lines += [
|
|
436
|
+
"🚫 CAPTCHA / BOT PROTECTION IS ACTIVE ON THIS PAGE.",
|
|
437
|
+
" Flows that trigger it (form submits, logins, sign-ups) CANNOT pass",
|
|
438
|
+
" with automation and MUST NOT be bypassed. Do not keep fixing",
|
|
439
|
+
" locators — report this as the root cause instead.",
|
|
440
|
+
"",
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
for el in elements:
|
|
444
|
+
if el.get("kind") == "captcha":
|
|
445
|
+
continue
|
|
446
|
+
label = (
|
|
447
|
+
el.get("placeholder") or
|
|
448
|
+
el.get("text") or
|
|
449
|
+
el.get("aria_label") or
|
|
450
|
+
el.get("data_test") or
|
|
451
|
+
el.get("id") or
|
|
452
|
+
el.get("name") or
|
|
453
|
+
f"<{el['tag']}>"
|
|
454
|
+
)
|
|
455
|
+
css = el.get("css")
|
|
456
|
+
xpath = el.get("xpath")
|
|
457
|
+
|
|
458
|
+
line = f" [{label[:40]}]"
|
|
459
|
+
if css:
|
|
460
|
+
line += f" CSS → By.CSS_SELECTOR, '{css}'"
|
|
461
|
+
if el.get("css_matches"):
|
|
462
|
+
line += f" ⚠️ NOT UNIQUE (matches {el['css_matches']} elements — use XPATH)"
|
|
463
|
+
if xpath:
|
|
464
|
+
line += f" | XPATH → By.XPATH, '{xpath}'"
|
|
465
|
+
if el.get("xpath_matches"):
|
|
466
|
+
line += f" ⚠️ NOT UNIQUE (matches {el['xpath_matches']} elements)"
|
|
467
|
+
if el.get("options"):
|
|
468
|
+
line += (f" OPTIONS(sample): {el['options'][:8]}"
|
|
469
|
+
f"{' …' if len(el['options']) > 8 else ''}"
|
|
470
|
+
f" ← use option text EXACTLY as listed")
|
|
471
|
+
lines.append(line)
|
|
472
|
+
|
|
473
|
+
lines += [
|
|
474
|
+
"",
|
|
475
|
+
"RULES:",
|
|
476
|
+
" • Prefer CSS over XPath — but NEVER use a CSS marked NOT UNIQUE",
|
|
477
|
+
" for a single element (it silently returns the WRONG element);",
|
|
478
|
+
" use that element's XPATH instead",
|
|
479
|
+
" • Use XPath only when CSS cannot express the condition",
|
|
480
|
+
" • NEVER invent locators not listed above",
|
|
481
|
+
"",
|
|
482
|
+
]
|
|
483
|
+
return "\n".join(lines)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def extract_failed_locator_value(pytest_output: str) -> str | None:
|
|
487
|
+
"""
|
|
488
|
+
Parse pytest output and extract the locator value that caused
|
|
489
|
+
NoSuchElementException or TimeoutException.
|
|
490
|
+
"""
|
|
491
|
+
import re
|
|
492
|
+
patterns = [
|
|
493
|
+
r'Message:\s*Unable to locate element:\s*["{]?([^"}\n]+)',
|
|
494
|
+
r'NoSuchElementException.*?["\']([^"\']+)["\']',
|
|
495
|
+
r'TimeoutException.*?["\']([^"\']+)["\']',
|
|
496
|
+
r'find_element.*?["\']([^"\']+)["\']',
|
|
497
|
+
]
|
|
498
|
+
for pattern in patterns:
|
|
499
|
+
m = re.search(pattern, pytest_output, re.IGNORECASE | re.DOTALL)
|
|
500
|
+
if m:
|
|
501
|
+
return m.group(1).strip()
|
|
502
|
+
return None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from selenium_agent.utils.logger import setup_logger
|
|
2
|
+
from selenium_agent.utils.llm import (
|
|
3
|
+
DEFAULT_PROVIDER,
|
|
4
|
+
create_llm_client,
|
|
5
|
+
format_missing_api_key_error,
|
|
6
|
+
get_api_key_env_var,
|
|
7
|
+
get_default_model,
|
|
8
|
+
normalize_provider,
|
|
9
|
+
resolve_api_key,
|
|
10
|
+
)
|
|
11
|
+
from selenium_agent.utils.paths import get_output_root, resolve_input_path, safe_output_path
|
|
12
|
+
from selenium_agent.utils import config_manager
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DEFAULT_PROVIDER",
|
|
16
|
+
"create_llm_client",
|
|
17
|
+
"format_missing_api_key_error",
|
|
18
|
+
"get_api_key_env_var",
|
|
19
|
+
"get_default_model",
|
|
20
|
+
"normalize_provider",
|
|
21
|
+
"resolve_api_key",
|
|
22
|
+
"setup_logger",
|
|
23
|
+
"get_output_root",
|
|
24
|
+
"resolve_input_path",
|
|
25
|
+
"safe_output_path",
|
|
26
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CODE VALIDATOR
|
|
3
|
+
==============
|
|
4
|
+
Static validation of LLM-generated Python before it touches disk.
|
|
5
|
+
|
|
6
|
+
Catches, without running a browser:
|
|
7
|
+
- syntax errors (ast.parse)
|
|
8
|
+
- forbidden patterns (time.sleep, find_element in tests, By in test files)
|
|
9
|
+
|
|
10
|
+
Used by the Coder (validate before save, retry on failure) and the
|
|
11
|
+
Healer (never overwrite a working file with a syntactically broken fix).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ValidationResult:
|
|
23
|
+
filename: str
|
|
24
|
+
valid: bool
|
|
25
|
+
errors: list[str] = field(default_factory=list)
|
|
26
|
+
warnings: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_test_file(filename: str) -> bool:
|
|
30
|
+
name = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
|
31
|
+
return name.startswith("test_") and name.endswith(".py")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_python(filename: str, content: str) -> ValidationResult:
|
|
35
|
+
"""Validate one generated/fixed Python file. Feature files pass through."""
|
|
36
|
+
result = ValidationResult(filename=filename, valid=True)
|
|
37
|
+
|
|
38
|
+
if not filename.endswith(".py"):
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
ast.parse(content)
|
|
43
|
+
except SyntaxError as exc:
|
|
44
|
+
result.valid = False
|
|
45
|
+
result.errors.append(
|
|
46
|
+
f"SyntaxError at line {exc.lineno}: {exc.msg}\n"
|
|
47
|
+
f" {(exc.text or '').rstrip()}"
|
|
48
|
+
)
|
|
49
|
+
return result # no point pattern-checking broken code
|
|
50
|
+
|
|
51
|
+
if re.search(r"\btime\.sleep\s*\(", content):
|
|
52
|
+
result.warnings.append("time.sleep() found — use fluent_wait/explicit waits instead")
|
|
53
|
+
|
|
54
|
+
if _is_test_file(filename):
|
|
55
|
+
if re.search(r"from\s+selenium\.webdriver\.common\.by\s+import\s+By", content):
|
|
56
|
+
result.warnings.append("By import in a test file — locators belong in page objects")
|
|
57
|
+
if re.search(r"\bBy\.[A-Z_]+\s*,", content):
|
|
58
|
+
result.warnings.append("Raw locator tuple in a test file — move it to the page object")
|
|
59
|
+
# The driver fixture is deterministic framework scaffolding (conftest.py).
|
|
60
|
+
# LLM-invented fixtures/drivers are the #1 source of collection errors.
|
|
61
|
+
if re.search(r"def\s+driver\w*\s*\(", content):
|
|
62
|
+
result.valid = False
|
|
63
|
+
result.errors.append(
|
|
64
|
+
"Test file defines its own driver fixture — conftest.py already "
|
|
65
|
+
"provides `driver`; test functions must just accept it as a parameter"
|
|
66
|
+
)
|
|
67
|
+
if "DriverFactory" in content:
|
|
68
|
+
result.valid = False
|
|
69
|
+
result.errors.append(
|
|
70
|
+
"Test file imports/uses DriverFactory — drivers come only from "
|
|
71
|
+
"the conftest.py `driver` fixture"
|
|
72
|
+
)
|
|
73
|
+
if re.search(r"pytest\.skip\s*\(", content):
|
|
74
|
+
result.valid = False
|
|
75
|
+
result.errors.append(
|
|
76
|
+
"Generated tests must not contain pytest.skip placeholders — "
|
|
77
|
+
"implement every step with the best available locator; the "
|
|
78
|
+
"healer verifies and corrects locators against the live DOM"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_files(files: list[dict]) -> list[ValidationResult]:
|
|
85
|
+
"""Validate a list of {"filename": ..., "content": ...} dicts."""
|
|
86
|
+
return [validate_python(f.get("filename", ""), f.get("content", "")) for f in files]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_errors(results: list[ValidationResult]) -> str:
|
|
90
|
+
"""Human/LLM-readable summary of validation failures."""
|
|
91
|
+
lines = []
|
|
92
|
+
for r in results:
|
|
93
|
+
if not r.valid:
|
|
94
|
+
lines.append(f"❌ {r.filename}:")
|
|
95
|
+
lines.extend(f" {e}" for e in r.errors)
|
|
96
|
+
return "\n".join(lines)
|