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,447 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BASE PAGE — Base class for all generated Page Objects.
|
|
3
|
+
Includes fluent waits, smart conditional waits, and method aliases.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import os
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from selenium.webdriver.common.action_chains import ActionChains
|
|
11
|
+
from selenium.webdriver.common.keys import Keys
|
|
12
|
+
from selenium.webdriver.remote.webdriver import WebDriver
|
|
13
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
14
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
15
|
+
from selenium.webdriver.support.ui import WebDriverWait, Select
|
|
16
|
+
from selenium.webdriver.support.wait import WebDriverWait as FluentWebDriverWait
|
|
17
|
+
from selenium.common.exceptions import (
|
|
18
|
+
TimeoutException,
|
|
19
|
+
NoSuchElementException,
|
|
20
|
+
StaleElementReferenceException,
|
|
21
|
+
ElementClickInterceptedException,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
Locator = tuple[str, str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_EDITABLE_TAGS = {"input", "textarea", "select"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_locator(locator) -> Locator:
|
|
31
|
+
"""
|
|
32
|
+
Accept ('css selector', '#x') tuples AND bare selector strings.
|
|
33
|
+
|
|
34
|
+
LLM-generated fixes occasionally pass a raw string where a locator tuple
|
|
35
|
+
is expected; Selenium then unpacks the string character-by-character into
|
|
36
|
+
find_element(*locator) and crashes with a bizarre arity error. Treat a
|
|
37
|
+
bare string as a CSS selector (or XPath when it looks like one).
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(locator, str):
|
|
40
|
+
from selenium.webdriver.common.by import By
|
|
41
|
+
if locator.startswith(("/", "(", "./")):
|
|
42
|
+
return (By.XPATH, locator)
|
|
43
|
+
return (By.CSS_SELECTOR, locator)
|
|
44
|
+
return locator
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _first_usable_element(locator: Locator, require_enabled: bool = False,
|
|
48
|
+
prefer_editable: bool = False):
|
|
49
|
+
"""
|
|
50
|
+
Expected condition: the first DISPLAYED (optionally enabled) element
|
|
51
|
+
matching the locator.
|
|
52
|
+
|
|
53
|
+
Real pages often contain duplicate matches for one selector — a desktop
|
|
54
|
+
form plus an off-canvas mobile drawer with the SAME element ids, or a
|
|
55
|
+
wrapper element sharing its id with the input nested inside it.
|
|
56
|
+
Selenium's stock conditions grab the first DOM match, which may be the
|
|
57
|
+
hidden twin or the non-editable wrapper, causing
|
|
58
|
+
ElementNotInteractable/InvalidElementState exceptions. This condition
|
|
59
|
+
considers every match and prefers, in order:
|
|
60
|
+
1. an editable form control (when prefer_editable — for type/clear)
|
|
61
|
+
2. an element laid out inside the viewport
|
|
62
|
+
"""
|
|
63
|
+
locator = _normalize_locator(locator)
|
|
64
|
+
|
|
65
|
+
def _condition(driver):
|
|
66
|
+
candidates = []
|
|
67
|
+
for el in driver.find_elements(*locator):
|
|
68
|
+
try:
|
|
69
|
+
if el.is_displayed() and (not require_enabled or el.is_enabled()):
|
|
70
|
+
candidates.append(el)
|
|
71
|
+
except StaleElementReferenceException:
|
|
72
|
+
continue
|
|
73
|
+
if not candidates:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
if prefer_editable and len(candidates) > 1:
|
|
77
|
+
editable = []
|
|
78
|
+
for el in candidates:
|
|
79
|
+
try:
|
|
80
|
+
if el.tag_name.lower() in _EDITABLE_TAGS or \
|
|
81
|
+
el.get_attribute("contenteditable") == "true":
|
|
82
|
+
editable.append(el)
|
|
83
|
+
except StaleElementReferenceException:
|
|
84
|
+
continue
|
|
85
|
+
if editable:
|
|
86
|
+
candidates = editable
|
|
87
|
+
|
|
88
|
+
if len(candidates) == 1:
|
|
89
|
+
return candidates[0]
|
|
90
|
+
try:
|
|
91
|
+
viewport_width = driver.execute_script("return window.innerWidth") or 0
|
|
92
|
+
for el in candidates:
|
|
93
|
+
rect = el.rect
|
|
94
|
+
if 0 <= rect.get("x", -1) < viewport_width:
|
|
95
|
+
return el
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return candidates[0]
|
|
99
|
+
return _condition
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BasePage:
|
|
103
|
+
def __init__(self, driver: WebDriver, timeout: int = 10):
|
|
104
|
+
self.driver = driver
|
|
105
|
+
self.timeout = timeout
|
|
106
|
+
self.wait = WebDriverWait(driver, timeout)
|
|
107
|
+
|
|
108
|
+
# ── Navigation ────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def open(self, url: str) -> "BasePage":
|
|
111
|
+
self.driver.get(url)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def get_title(self) -> str:
|
|
115
|
+
return self.driver.title
|
|
116
|
+
|
|
117
|
+
def get_url(self) -> str:
|
|
118
|
+
return self.driver.current_url
|
|
119
|
+
|
|
120
|
+
def refresh(self) -> "BasePage":
|
|
121
|
+
self.driver.refresh()
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def go_back(self) -> "BasePage":
|
|
125
|
+
self.driver.back()
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
# ── Finding Elements ──────────────────────────
|
|
129
|
+
|
|
130
|
+
def find(self, locator: Locator, timeout: int = None) -> WebElement:
|
|
131
|
+
wait = WebDriverWait(self.driver, timeout or self.timeout)
|
|
132
|
+
return wait.until(_first_usable_element(locator))
|
|
133
|
+
|
|
134
|
+
def find_clickable(self, locator: Locator, timeout: int = None) -> WebElement:
|
|
135
|
+
wait = WebDriverWait(self.driver, timeout or self.timeout)
|
|
136
|
+
return wait.until(_first_usable_element(locator, require_enabled=True))
|
|
137
|
+
|
|
138
|
+
def find_present(self, locator: Locator, timeout: int = None) -> WebElement:
|
|
139
|
+
"""Find element in DOM even if not visible (e.g. hidden inputs)."""
|
|
140
|
+
locator = _normalize_locator(locator)
|
|
141
|
+
wait = WebDriverWait(self.driver, timeout or self.timeout)
|
|
142
|
+
return wait.until(EC.presence_of_element_located(locator))
|
|
143
|
+
|
|
144
|
+
def find_editable(self, locator: Locator, timeout: int = None) -> WebElement:
|
|
145
|
+
"""Find the editable form control for a locator — when a selector also
|
|
146
|
+
matches a wrapper/label twin, this picks the input/textarea/select."""
|
|
147
|
+
wait = WebDriverWait(self.driver, timeout or self.timeout)
|
|
148
|
+
return wait.until(_first_usable_element(
|
|
149
|
+
locator, require_enabled=True, prefer_editable=True))
|
|
150
|
+
|
|
151
|
+
def find_all(self, locator: Locator, timeout: int = None) -> list[WebElement]:
|
|
152
|
+
locator = _normalize_locator(locator)
|
|
153
|
+
wait = WebDriverWait(self.driver, timeout or self.timeout)
|
|
154
|
+
wait.until(EC.presence_of_all_elements_located(locator))
|
|
155
|
+
return self.driver.find_elements(*locator)
|
|
156
|
+
|
|
157
|
+
def is_visible(self, locator: Locator, timeout: int = 3) -> bool:
|
|
158
|
+
locator = _normalize_locator(locator)
|
|
159
|
+
try:
|
|
160
|
+
WebDriverWait(self.driver, timeout).until(EC.visibility_of_element_located(locator))
|
|
161
|
+
return True
|
|
162
|
+
except (TimeoutException, NoSuchElementException):
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def is_present(self, locator: Locator, timeout: int = 3) -> bool:
|
|
166
|
+
locator = _normalize_locator(locator)
|
|
167
|
+
try:
|
|
168
|
+
WebDriverWait(self.driver, timeout).until(EC.presence_of_element_located(locator))
|
|
169
|
+
return True
|
|
170
|
+
except (TimeoutException, NoSuchElementException):
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
# ── Interactions ──────────────────────────────
|
|
174
|
+
|
|
175
|
+
def click(self, locator: Locator, timeout: int = None) -> "BasePage":
|
|
176
|
+
element = self.find_clickable(locator, timeout)
|
|
177
|
+
try:
|
|
178
|
+
element.click()
|
|
179
|
+
except ElementClickInterceptedException:
|
|
180
|
+
self.driver.execute_script("arguments[0].click();", element)
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
def safe_type(self, locator: Locator, text: str) -> "BasePage":
|
|
184
|
+
"""
|
|
185
|
+
Type text with auto JS fallback.
|
|
186
|
+
1. Regular click + type
|
|
187
|
+
2. Verify value was actually entered
|
|
188
|
+
3. If empty → JS inject + React event dispatch
|
|
189
|
+
4. Assert value present before continuing
|
|
190
|
+
"""
|
|
191
|
+
import time
|
|
192
|
+
self.type(locator, text)
|
|
193
|
+
time.sleep(0.3)
|
|
194
|
+
|
|
195
|
+
el = self.find_editable(locator)
|
|
196
|
+
actual = el.get_attribute("value") or ""
|
|
197
|
+
if actual.strip() == text.strip():
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
# JS fallback — pass text as an argument so quotes/newlines can't break the script
|
|
201
|
+
self.execute_js("arguments[0].value = arguments[1];", el, text)
|
|
202
|
+
self.execute_js("arguments[0].dispatchEvent(new Event('input', { bubbles: true }));", el)
|
|
203
|
+
self.execute_js("arguments[0].dispatchEvent(new Event('change', { bubbles: true }));", el)
|
|
204
|
+
time.sleep(0.2)
|
|
205
|
+
|
|
206
|
+
actual = self.find_editable(locator).get_attribute("value") or ""
|
|
207
|
+
assert actual.strip() == text.strip(), \
|
|
208
|
+
f"Field empty after JS inject! expected='{text}', got='{actual}'"
|
|
209
|
+
return self
|
|
210
|
+
|
|
211
|
+
def type(self, locator: Locator, text: str, clear_first: bool = True) -> "BasePage":
|
|
212
|
+
element = self.find_editable(locator)
|
|
213
|
+
element.click() # click first — required for React/SPA forms
|
|
214
|
+
if clear_first:
|
|
215
|
+
element.clear()
|
|
216
|
+
element.send_keys(text)
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
def clear(self, locator: Locator) -> "BasePage":
|
|
220
|
+
element = self.find_editable(locator)
|
|
221
|
+
element.send_keys(Keys.CONTROL + "a")
|
|
222
|
+
element.send_keys(Keys.DELETE)
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
def submit(self, locator: Locator) -> "BasePage":
|
|
226
|
+
self.find_editable(locator).send_keys(Keys.RETURN)
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def select_by_text(self, locator: Locator, text: str) -> "BasePage":
|
|
230
|
+
select = Select(self.find_editable(locator))
|
|
231
|
+
try:
|
|
232
|
+
select.select_by_visible_text(text)
|
|
233
|
+
except NoSuchElementException:
|
|
234
|
+
# Option labels rarely match test data verbatim ("United States"
|
|
235
|
+
# vs "United States of America (the)") — fall back to the first
|
|
236
|
+
# option containing the requested text, case-insensitively.
|
|
237
|
+
wanted = text.strip().lower()
|
|
238
|
+
for option in select.options:
|
|
239
|
+
label = (option.text or "").strip()
|
|
240
|
+
if wanted in label.lower():
|
|
241
|
+
select.select_by_visible_text(label)
|
|
242
|
+
break
|
|
243
|
+
else:
|
|
244
|
+
raise
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
def select_by_value(self, locator: Locator, value: str) -> "BasePage":
|
|
248
|
+
Select(self.find_editable(locator)).select_by_value(value)
|
|
249
|
+
return self
|
|
250
|
+
|
|
251
|
+
def select_by_index(self, locator: Locator, index: int) -> "BasePage":
|
|
252
|
+
Select(self.find_editable(locator)).select_by_index(index)
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
# ── Getting Values ────────────────────────────
|
|
256
|
+
|
|
257
|
+
def get_text(self, locator: Locator) -> str:
|
|
258
|
+
return self.find(locator).text
|
|
259
|
+
|
|
260
|
+
def get_value(self, locator: Locator) -> str:
|
|
261
|
+
return self.find_editable(locator).get_attribute("value") or ""
|
|
262
|
+
|
|
263
|
+
def get_attribute(self, locator: Locator, attribute: str) -> str:
|
|
264
|
+
return self.find(locator).get_attribute(attribute) or ""
|
|
265
|
+
|
|
266
|
+
def is_checked(self, locator: Locator) -> bool:
|
|
267
|
+
return self.find(locator).is_selected()
|
|
268
|
+
|
|
269
|
+
def is_enabled(self, locator: Locator) -> bool:
|
|
270
|
+
return self.find(locator).is_enabled()
|
|
271
|
+
|
|
272
|
+
# ── Smart Fluent Waits ────────────────────────
|
|
273
|
+
#
|
|
274
|
+
# fluent_wait() is the PREFERRED wait method.
|
|
275
|
+
# It retries every `poll` seconds, ignoring transient DOM exceptions
|
|
276
|
+
# (NoSuchElement, StaleElement) — much more robust than plain WebDriverWait.
|
|
277
|
+
#
|
|
278
|
+
# Use condition=:
|
|
279
|
+
# 'visible' → element rendered and visible (DEFAULT)
|
|
280
|
+
# 'present' → element in DOM, may be hidden
|
|
281
|
+
# 'clickable' → visible + enabled (use before clicks)
|
|
282
|
+
# 'invisible' → element gone/hidden (use after loaders)
|
|
283
|
+
#
|
|
284
|
+
# Examples:
|
|
285
|
+
# self.fluent_wait((By.CSS_SELECTOR, '[data-test="login-button"]'), 'clickable')
|
|
286
|
+
# self.fluent_wait((By.ID, 'username'), 'visible')
|
|
287
|
+
# self.fluent_wait((By.XPATH, '//div[@class="loader"]'), 'invisible', timeout=15)
|
|
288
|
+
|
|
289
|
+
def fluent_wait(
|
|
290
|
+
self,
|
|
291
|
+
locator: Locator,
|
|
292
|
+
condition: str = "visible",
|
|
293
|
+
timeout: int = None,
|
|
294
|
+
poll: float = 0.5,
|
|
295
|
+
) -> WebElement:
|
|
296
|
+
locator = _normalize_locator(locator)
|
|
297
|
+
_timeout = timeout or self.timeout
|
|
298
|
+
wait = FluentWebDriverWait(
|
|
299
|
+
self.driver,
|
|
300
|
+
_timeout,
|
|
301
|
+
poll_frequency=poll,
|
|
302
|
+
ignored_exceptions=[NoSuchElementException, StaleElementReferenceException],
|
|
303
|
+
)
|
|
304
|
+
_cond_map = {
|
|
305
|
+
"visible": _first_usable_element(locator),
|
|
306
|
+
"present": EC.presence_of_element_located(locator),
|
|
307
|
+
"clickable": _first_usable_element(locator, require_enabled=True),
|
|
308
|
+
"invisible": EC.invisibility_of_element_located(locator),
|
|
309
|
+
}
|
|
310
|
+
return wait.until(_cond_map.get(condition, _cond_map["visible"]))
|
|
311
|
+
|
|
312
|
+
# ── Standard Waits ───────────────────────────
|
|
313
|
+
|
|
314
|
+
def wait_for_url(self, url_contains: str, timeout: int = None) -> "BasePage":
|
|
315
|
+
WebDriverWait(self.driver, timeout or self.timeout).until(EC.url_contains(url_contains))
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
def wait_for_title(self, title_contains: str, timeout: int = None) -> "BasePage":
|
|
319
|
+
WebDriverWait(self.driver, timeout or self.timeout).until(EC.title_contains(title_contains))
|
|
320
|
+
return self
|
|
321
|
+
|
|
322
|
+
def wait_for_invisible(self, locator: Locator, timeout: int = None) -> "BasePage":
|
|
323
|
+
locator = _normalize_locator(locator)
|
|
324
|
+
WebDriverWait(self.driver, timeout or self.timeout).until(
|
|
325
|
+
EC.invisibility_of_element_located(locator))
|
|
326
|
+
return self
|
|
327
|
+
|
|
328
|
+
def wait_for_text(self, locator: Locator, text: str, timeout: int = None) -> "BasePage":
|
|
329
|
+
locator = _normalize_locator(locator)
|
|
330
|
+
WebDriverWait(self.driver, timeout or self.timeout).until(
|
|
331
|
+
EC.text_to_be_present_in_element(locator, text))
|
|
332
|
+
return self
|
|
333
|
+
|
|
334
|
+
# ── Scrolling & Hover ─────────────────────────
|
|
335
|
+
|
|
336
|
+
def scroll_to(self, locator: Locator) -> "BasePage":
|
|
337
|
+
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", self.find(locator))
|
|
338
|
+
return self
|
|
339
|
+
|
|
340
|
+
def scroll_to_top(self) -> "BasePage":
|
|
341
|
+
self.driver.execute_script("window.scrollTo(0, 0);")
|
|
342
|
+
return self
|
|
343
|
+
|
|
344
|
+
def scroll_to_bottom(self) -> "BasePage":
|
|
345
|
+
self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
def hover(self, locator: Locator) -> "BasePage":
|
|
349
|
+
ActionChains(self.driver).move_to_element(self.find(locator)).perform()
|
|
350
|
+
return self
|
|
351
|
+
|
|
352
|
+
def drag_and_drop(self, source: Locator, target: Locator) -> "BasePage":
|
|
353
|
+
ActionChains(self.driver).drag_and_drop(self.find(source), self.find(target)).perform()
|
|
354
|
+
return self
|
|
355
|
+
|
|
356
|
+
# ── Alerts & Frames ───────────────────────────
|
|
357
|
+
|
|
358
|
+
def accept_alert(self, timeout: int = 5) -> str:
|
|
359
|
+
WebDriverWait(self.driver, timeout).until(EC.alert_is_present())
|
|
360
|
+
alert = self.driver.switch_to.alert
|
|
361
|
+
text = alert.text
|
|
362
|
+
alert.accept()
|
|
363
|
+
return text
|
|
364
|
+
|
|
365
|
+
def dismiss_alert(self, timeout: int = 5) -> str:
|
|
366
|
+
WebDriverWait(self.driver, timeout).until(EC.alert_is_present())
|
|
367
|
+
alert = self.driver.switch_to.alert
|
|
368
|
+
text = alert.text
|
|
369
|
+
alert.dismiss()
|
|
370
|
+
return text
|
|
371
|
+
|
|
372
|
+
def switch_to_frame(self, locator: Locator) -> "BasePage":
|
|
373
|
+
self.driver.switch_to.frame(self.find(locator))
|
|
374
|
+
return self
|
|
375
|
+
|
|
376
|
+
def switch_to_default(self) -> "BasePage":
|
|
377
|
+
self.driver.switch_to.default_content()
|
|
378
|
+
return self
|
|
379
|
+
|
|
380
|
+
# ── Screenshots ───────────────────────────────
|
|
381
|
+
|
|
382
|
+
def screenshot(self, filename: str = None) -> str:
|
|
383
|
+
os.makedirs("screenshots", exist_ok=True)
|
|
384
|
+
if not filename:
|
|
385
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
386
|
+
filename = f"screenshots/screenshot_{timestamp}.png"
|
|
387
|
+
self.driver.save_screenshot(filename)
|
|
388
|
+
return filename
|
|
389
|
+
|
|
390
|
+
def screenshot_on_failure(self, test_name: str) -> str:
|
|
391
|
+
return self.screenshot(f"screenshots/FAILED_{test_name}.png")
|
|
392
|
+
|
|
393
|
+
def get_page_source(self) -> str:
|
|
394
|
+
return self.driver.page_source
|
|
395
|
+
|
|
396
|
+
def execute_js(self, script: str, *args) -> object:
|
|
397
|
+
return self.driver.execute_script(script, *args)
|
|
398
|
+
|
|
399
|
+
# ── Window & Tab ──────────────────────────────
|
|
400
|
+
|
|
401
|
+
def switch_to_new_window(self) -> "BasePage":
|
|
402
|
+
WebDriverWait(self.driver, self.timeout).until(EC.number_of_windows_to_be(2))
|
|
403
|
+
self.driver.switch_to.window(self.driver.window_handles[-1])
|
|
404
|
+
return self
|
|
405
|
+
|
|
406
|
+
def close_current_window(self) -> "BasePage":
|
|
407
|
+
self.driver.close()
|
|
408
|
+
self.driver.switch_to.window(self.driver.window_handles[0])
|
|
409
|
+
return self
|
|
410
|
+
|
|
411
|
+
# ── Aliases ───────────────────────────────────
|
|
412
|
+
|
|
413
|
+
def wait_for_element_to_be_visible(self, locator: Locator, timeout: int = None) -> WebElement:
|
|
414
|
+
return self.find(locator, timeout)
|
|
415
|
+
|
|
416
|
+
def wait_for_element_to_be_clickable(self, locator: Locator, timeout: int = None) -> WebElement:
|
|
417
|
+
return self.find_clickable(locator, timeout)
|
|
418
|
+
|
|
419
|
+
def wait_for_element_present(self, locator: Locator, timeout: int = None) -> WebElement:
|
|
420
|
+
return self.find(locator, timeout)
|
|
421
|
+
|
|
422
|
+
def get_element(self, locator: Locator, timeout: int = None) -> WebElement:
|
|
423
|
+
return self.find(locator, timeout)
|
|
424
|
+
|
|
425
|
+
def send_keys(self, locator: Locator, text: str) -> "BasePage":
|
|
426
|
+
return self.type(locator, text)
|
|
427
|
+
|
|
428
|
+
def input_text(self, locator: Locator, text: str) -> "BasePage":
|
|
429
|
+
return self.type(locator, text)
|
|
430
|
+
|
|
431
|
+
def enter_text(self, locator: Locator, text: str) -> "BasePage":
|
|
432
|
+
return self.type(locator, text)
|
|
433
|
+
|
|
434
|
+
def click_element(self, locator: Locator) -> "BasePage":
|
|
435
|
+
return self.click(locator)
|
|
436
|
+
|
|
437
|
+
def is_element_visible(self, locator: Locator, timeout: int = 3) -> bool:
|
|
438
|
+
return self.is_visible(locator, timeout)
|
|
439
|
+
|
|
440
|
+
def is_element_present(self, locator: Locator, timeout: int = 3) -> bool:
|
|
441
|
+
return self.is_present(locator, timeout)
|
|
442
|
+
|
|
443
|
+
def get_element_text(self, locator: Locator) -> str:
|
|
444
|
+
return self.get_text(locator)
|
|
445
|
+
|
|
446
|
+
def navigate_to(self, url: str) -> "BasePage":
|
|
447
|
+
return self.open(url)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DRIVER FACTORY — WebDriver setup utility.
|
|
3
|
+
Supports Chrome, Firefox, Edge with headless, proxy, and common options.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from selenium_agent.selenium.driver_factory import DriverFactory
|
|
7
|
+
driver = DriverFactory.create(browser="chrome", headless=True)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DriverConfig:
|
|
17
|
+
browser: str = "chrome"
|
|
18
|
+
headless: bool = False
|
|
19
|
+
implicit_wait: int = 0
|
|
20
|
+
page_load_timeout: int = 30
|
|
21
|
+
window_size: tuple = (1920, 1080)
|
|
22
|
+
proxy: Optional[str] = None
|
|
23
|
+
user_agent: Optional[str] = None
|
|
24
|
+
disable_notifications: bool = True
|
|
25
|
+
ignore_certificate_errors: bool = False
|
|
26
|
+
extra_args: list = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DriverFactory:
|
|
30
|
+
"""
|
|
31
|
+
Factory for creating Selenium WebDriver instances.
|
|
32
|
+
|
|
33
|
+
Primary method : DriverFactory.create(browser, headless, ...)
|
|
34
|
+
Aliases : DriverFactory.get_driver(...)
|
|
35
|
+
DriverFactory.get_chrome_driver(...)
|
|
36
|
+
DriverFactory.get_firefox_driver(...)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def create(
|
|
41
|
+
browser: str = "chrome",
|
|
42
|
+
headless: bool = False,
|
|
43
|
+
implicit_wait: int = 0,
|
|
44
|
+
page_load_timeout: int = 30,
|
|
45
|
+
window_size: tuple = (1920, 1080),
|
|
46
|
+
proxy: Optional[str] = None,
|
|
47
|
+
user_agent: Optional[str] = None,
|
|
48
|
+
disable_notifications: bool = True,
|
|
49
|
+
ignore_certificate_errors: bool = False,
|
|
50
|
+
extra_args: list = None,
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Create and return a configured WebDriver instance.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
browser: 'chrome' | 'firefox' | 'edge' (default: chrome)
|
|
57
|
+
headless: Run without UI — great for CI/CD (default: False)
|
|
58
|
+
implicit_wait: Global implicit wait seconds — prefer 0 + explicit waits
|
|
59
|
+
page_load_timeout: Max seconds to wait for page load (default: 30)
|
|
60
|
+
window_size: (width, height) tuple (default: 1920x1080)
|
|
61
|
+
proxy: Proxy URL e.g. 'http://proxy.example.com:8080'
|
|
62
|
+
user_agent: Custom User-Agent string
|
|
63
|
+
disable_notifications: Block browser popups (default: True)
|
|
64
|
+
ignore_certificate_errors: Ignore SSL errors (default: False)
|
|
65
|
+
extra_args: Additional browser arguments
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Configured WebDriver instance
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
driver = DriverFactory.create(browser="chrome", headless=True)
|
|
72
|
+
"""
|
|
73
|
+
config = DriverConfig(
|
|
74
|
+
browser=browser.lower().strip(),
|
|
75
|
+
headless=headless,
|
|
76
|
+
implicit_wait=implicit_wait,
|
|
77
|
+
page_load_timeout=page_load_timeout,
|
|
78
|
+
window_size=window_size,
|
|
79
|
+
proxy=proxy,
|
|
80
|
+
user_agent=user_agent,
|
|
81
|
+
disable_notifications=disable_notifications,
|
|
82
|
+
ignore_certificate_errors=ignore_certificate_errors,
|
|
83
|
+
extra_args=extra_args or [],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
creators = {
|
|
87
|
+
"chrome": DriverFactory._create_chrome,
|
|
88
|
+
"firefox": DriverFactory._create_firefox,
|
|
89
|
+
"edge": DriverFactory._create_edge,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if config.browser not in creators:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Unsupported browser: '{browser}'. Supported: {', '.join(creators)}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
driver = creators[config.browser](config)
|
|
98
|
+
driver.set_page_load_timeout(config.page_load_timeout)
|
|
99
|
+
if config.implicit_wait > 0:
|
|
100
|
+
driver.implicitly_wait(config.implicit_wait)
|
|
101
|
+
# maximize_window is more reliable than set_window_size on Mac/headless
|
|
102
|
+
if config.headless:
|
|
103
|
+
driver.set_window_size(*config.window_size)
|
|
104
|
+
else:
|
|
105
|
+
driver.maximize_window()
|
|
106
|
+
return driver
|
|
107
|
+
|
|
108
|
+
# ── Aliases — LLM commonly generates these names ──────────────────
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def get_driver(browser: str = "chrome", headless: bool = False, **kwargs):
|
|
112
|
+
"""Alias for create(). Use DriverFactory.create() in new code."""
|
|
113
|
+
return DriverFactory.create(browser=browser, headless=headless, **kwargs)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def get_chrome_driver(headless: bool = False, **kwargs):
|
|
117
|
+
"""Alias: create Chrome driver directly."""
|
|
118
|
+
return DriverFactory.create(browser="chrome", headless=headless, **kwargs)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def get_firefox_driver(headless: bool = False, **kwargs):
|
|
122
|
+
"""Alias: create Firefox driver directly."""
|
|
123
|
+
return DriverFactory.create(browser="firefox", headless=headless, **kwargs)
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def get_edge_driver(headless: bool = False, **kwargs):
|
|
127
|
+
"""Alias: create Edge driver directly."""
|
|
128
|
+
return DriverFactory.create(browser="edge", headless=headless, **kwargs)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def chrome(headless: bool = False, **kwargs):
|
|
132
|
+
"""Alias: DriverFactory.chrome(headless=True)"""
|
|
133
|
+
return DriverFactory.create(browser="chrome", headless=headless, **kwargs)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def firefox(headless: bool = False, **kwargs):
|
|
137
|
+
"""Alias: DriverFactory.firefox(headless=True)"""
|
|
138
|
+
return DriverFactory.create(browser="firefox", headless=headless, **kwargs)
|
|
139
|
+
|
|
140
|
+
# ── Private browser creators ──────────────────
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _create_chrome(config: DriverConfig):
|
|
144
|
+
from selenium import webdriver
|
|
145
|
+
from selenium.webdriver.chrome.service import Service
|
|
146
|
+
from webdriver_manager.chrome import ChromeDriverManager
|
|
147
|
+
|
|
148
|
+
options = webdriver.ChromeOptions()
|
|
149
|
+
if config.headless:
|
|
150
|
+
options.add_argument("--headless=new")
|
|
151
|
+
if config.disable_notifications:
|
|
152
|
+
options.add_argument("--disable-notifications")
|
|
153
|
+
if config.ignore_certificate_errors:
|
|
154
|
+
options.add_argument("--ignore-certificate-errors")
|
|
155
|
+
if config.user_agent:
|
|
156
|
+
options.add_argument(f"--user-agent={config.user_agent}")
|
|
157
|
+
if config.proxy:
|
|
158
|
+
options.add_argument(f"--proxy-server={config.proxy}")
|
|
159
|
+
|
|
160
|
+
options.add_argument("--no-sandbox")
|
|
161
|
+
options.add_argument("--disable-dev-shm-usage")
|
|
162
|
+
options.add_argument("--disable-blink-features=AutomationControlled")
|
|
163
|
+
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
|
164
|
+
options.add_experimental_option("useAutomationExtension", False)
|
|
165
|
+
# Disable password manager / save password / breach popups
|
|
166
|
+
options.add_experimental_option("prefs", {
|
|
167
|
+
"credentials_enable_service": False,
|
|
168
|
+
"profile.password_manager_enabled": False,
|
|
169
|
+
"profile.password_manager_leak_detection": False,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
for arg in config.extra_args:
|
|
173
|
+
options.add_argument(arg)
|
|
174
|
+
|
|
175
|
+
return webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=options)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _create_firefox(config: DriverConfig):
|
|
179
|
+
from selenium import webdriver
|
|
180
|
+
from selenium.webdriver.firefox.service import Service
|
|
181
|
+
from webdriver_manager.firefox import GeckoDriverManager
|
|
182
|
+
|
|
183
|
+
options = webdriver.FirefoxOptions()
|
|
184
|
+
if config.headless:
|
|
185
|
+
options.add_argument("--headless")
|
|
186
|
+
if config.user_agent:
|
|
187
|
+
options.set_preference("general.useragent.override", config.user_agent)
|
|
188
|
+
if config.disable_notifications:
|
|
189
|
+
options.set_preference("dom.webnotifications.enabled", False)
|
|
190
|
+
if config.ignore_certificate_errors:
|
|
191
|
+
options.set_preference("accept_untrusted_certs", True)
|
|
192
|
+
if config.proxy:
|
|
193
|
+
host, port = config.proxy.replace("http://", "").split(":")
|
|
194
|
+
options.set_preference("network.proxy.type", 1)
|
|
195
|
+
options.set_preference("network.proxy.http", host)
|
|
196
|
+
options.set_preference("network.proxy.http_port", int(port))
|
|
197
|
+
for arg in config.extra_args:
|
|
198
|
+
options.add_argument(arg)
|
|
199
|
+
|
|
200
|
+
return webdriver.Firefox(service=Service(GeckoDriverManager().install()), options=options)
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _create_edge(config: DriverConfig):
|
|
204
|
+
from selenium import webdriver
|
|
205
|
+
from selenium.webdriver.edge.service import Service
|
|
206
|
+
from webdriver_manager.microsoft import EdgeChromiumDriverManager
|
|
207
|
+
|
|
208
|
+
options = webdriver.EdgeOptions()
|
|
209
|
+
if config.headless:
|
|
210
|
+
options.add_argument("--headless=new")
|
|
211
|
+
if config.disable_notifications:
|
|
212
|
+
options.add_argument("--disable-notifications")
|
|
213
|
+
if config.ignore_certificate_errors:
|
|
214
|
+
options.add_argument("--ignore-certificate-errors")
|
|
215
|
+
if config.user_agent:
|
|
216
|
+
options.add_argument(f"--user-agent={config.user_agent}")
|
|
217
|
+
if config.proxy:
|
|
218
|
+
options.add_argument(f"--proxy-server={config.proxy}")
|
|
219
|
+
for arg in config.extra_args:
|
|
220
|
+
options.add_argument(arg)
|
|
221
|
+
|
|
222
|
+
return webdriver.Edge(service=Service(EdgeChromiumDriverManager().install()), options=options)
|