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.
Files changed (35) hide show
  1. selenium_agent/__init__.py +20 -0
  2. selenium_agent/agents/__init__.py +5 -0
  3. selenium_agent/agents/coder.py +505 -0
  4. selenium_agent/agents/definitions.py +152 -0
  5. selenium_agent/agents/healer.py +638 -0
  6. selenium_agent/agents/planner.py +279 -0
  7. selenium_agent/bdd/__init__.py +15 -0
  8. selenium_agent/bdd/gherkin_advisor.py +189 -0
  9. selenium_agent/bdd/templates.py +98 -0
  10. selenium_agent/cli.py +314 -0
  11. selenium_agent/core/__init__.py +3 -0
  12. selenium_agent/core/orchestrator.py +189 -0
  13. selenium_agent/scanner/__init__.py +3 -0
  14. selenium_agent/scanner/project_scanner.py +452 -0
  15. selenium_agent/selenium/__init__.py +6 -0
  16. selenium_agent/selenium/base_page.py +447 -0
  17. selenium_agent/selenium/driver_factory.py +222 -0
  18. selenium_agent/selenium/error_map.py +235 -0
  19. selenium_agent/selenium/locator_advisor.py +247 -0
  20. selenium_agent/selenium/locator_scanner.py +502 -0
  21. selenium_agent/utils/__init__.py +26 -0
  22. selenium_agent/utils/code_validator.py +96 -0
  23. selenium_agent/utils/config_manager.py +81 -0
  24. selenium_agent/utils/json_utils.py +103 -0
  25. selenium_agent/utils/llm.py +240 -0
  26. selenium_agent/utils/logger.py +37 -0
  27. selenium_agent/utils/paths.py +57 -0
  28. selenium_agent/utils/spec_writer.py +126 -0
  29. selenium_agent/utils/url_extractor.py +52 -0
  30. selenium_python_ai_agent-0.2.0.dist-info/METADATA +412 -0
  31. selenium_python_ai_agent-0.2.0.dist-info/RECORD +35 -0
  32. selenium_python_ai_agent-0.2.0.dist-info/WHEEL +5 -0
  33. selenium_python_ai_agent-0.2.0.dist-info/entry_points.txt +2 -0
  34. selenium_python_ai_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
  35. 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)