selenium-selector-autocorrect 0.1.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_selector_autocorrect/__init__.py +54 -0
- selenium_selector_autocorrect/ai_providers.py +123 -0
- selenium_selector_autocorrect/auto_correct.py +254 -0
- selenium_selector_autocorrect/correction_tracker.py +226 -0
- selenium_selector_autocorrect/py.typed +1 -0
- selenium_selector_autocorrect/wait_hook.py +140 -0
- selenium_selector_autocorrect-0.1.0.dist-info/METADATA +277 -0
- selenium_selector_autocorrect-0.1.0.dist-info/RECORD +11 -0
- selenium_selector_autocorrect-0.1.0.dist-info/WHEEL +5 -0
- selenium_selector_autocorrect-0.1.0.dist-info/licenses/LICENSE +21 -0
- selenium_selector_autocorrect-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Selenium Selector AutoCorrect
|
|
2
|
+
|
|
3
|
+
A Python package that automatically corrects Selenium element selectors using AI
|
|
4
|
+
when they fail, reducing test maintenance and improving test reliability.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from selenium_selector_autocorrect import install_auto_correct_hook
|
|
8
|
+
|
|
9
|
+
install_auto_correct_hook()
|
|
10
|
+
|
|
11
|
+
Environment Variables:
|
|
12
|
+
LOCAL_AI_API_URL: URL of local AI service (default: http://localhost:8765)
|
|
13
|
+
SELENIUM_AUTO_CORRECT: Enable auto-correction (default: "1")
|
|
14
|
+
SELENIUM_SUGGEST_BETTER: Suggest better selectors for found elements (default: "0")
|
|
15
|
+
SELENIUM_AUTO_UPDATE_TESTS: Auto-update test files with corrections (default: "0")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__version__ = "1.0.0"
|
|
19
|
+
|
|
20
|
+
from .ai_providers import AIProvider, LocalAIProvider, configure_provider, get_provider
|
|
21
|
+
from .auto_correct import (
|
|
22
|
+
SelectorAutoCorrect,
|
|
23
|
+
configure_auto_correct,
|
|
24
|
+
get_auto_correct,
|
|
25
|
+
set_auto_correct_enabled,
|
|
26
|
+
)
|
|
27
|
+
from .correction_tracker import (
|
|
28
|
+
CorrectionTracker,
|
|
29
|
+
apply_corrections_to_test_files,
|
|
30
|
+
export_corrections_report,
|
|
31
|
+
get_correction_tracker,
|
|
32
|
+
record_correction,
|
|
33
|
+
)
|
|
34
|
+
from .wait_hook import install_auto_correct_hook, uninstall_auto_correct_hook
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"SelectorAutoCorrect",
|
|
39
|
+
"get_auto_correct",
|
|
40
|
+
"set_auto_correct_enabled",
|
|
41
|
+
"configure_auto_correct",
|
|
42
|
+
"CorrectionTracker",
|
|
43
|
+
"get_correction_tracker",
|
|
44
|
+
"record_correction",
|
|
45
|
+
"apply_corrections_to_test_files",
|
|
46
|
+
"export_corrections_report",
|
|
47
|
+
"AIProvider",
|
|
48
|
+
"LocalAIProvider",
|
|
49
|
+
"get_provider",
|
|
50
|
+
"configure_provider",
|
|
51
|
+
"install_auto_correct_hook",
|
|
52
|
+
"uninstall_auto_correct_hook",
|
|
53
|
+
]
|
|
54
|
+
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""AI provider for selector auto-correction using local AI service."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AIProvider(ABC):
|
|
14
|
+
"""Abstract base class for AI providers."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def suggest_selector(self, system_prompt: str, user_prompt: str) -> Optional[str]:
|
|
18
|
+
"""Request a selector suggestion from the AI service.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
system_prompt: System-level instructions for the AI
|
|
22
|
+
user_prompt: User query with failed selector and page context
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
JSON string with suggestion or None if failed
|
|
26
|
+
"""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def is_available(self) -> bool:
|
|
31
|
+
"""Check if the provider is available and operational."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LocalAIProvider(AIProvider):
|
|
36
|
+
"""AI provider using local AI service with OpenAI-compatible API.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
base_url: URL of the local AI service. If None, reads from
|
|
40
|
+
LOCAL_AI_API_URL environment variable (default: http://localhost:8765)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, base_url: str = None):
|
|
44
|
+
self.base_url = base_url or os.environ.get("LOCAL_AI_API_URL", "http://localhost:8765")
|
|
45
|
+
self._available = None
|
|
46
|
+
|
|
47
|
+
def is_available(self) -> bool:
|
|
48
|
+
if self._available is not None:
|
|
49
|
+
return self._available
|
|
50
|
+
try:
|
|
51
|
+
response = requests.post(
|
|
52
|
+
f"{self.base_url}/v1/chat/completions",
|
|
53
|
+
json={"messages": [{"role": "user", "content": "test"}], "max_tokens": 1},
|
|
54
|
+
timeout=5
|
|
55
|
+
)
|
|
56
|
+
self._available = response.status_code in (200, 400)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.info(f"Local AI service not available at {self.base_url}: {e}")
|
|
59
|
+
self._available = False
|
|
60
|
+
return self._available
|
|
61
|
+
|
|
62
|
+
def suggest_selector(self, system_prompt: str, user_prompt: str) -> Optional[str]:
|
|
63
|
+
"""Request a selector suggestion from the local AI service.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
system_prompt: System message describing the task
|
|
67
|
+
user_prompt: User message with page context and failed selector
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
AI response text or None if request fails
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
response = requests.post(
|
|
74
|
+
f"{self.base_url}/v1/chat/completions",
|
|
75
|
+
json={
|
|
76
|
+
"messages": [
|
|
77
|
+
{"role": "system", "content": system_prompt},
|
|
78
|
+
{"role": "user", "content": user_prompt}
|
|
79
|
+
],
|
|
80
|
+
"temperature": 0.3,
|
|
81
|
+
"max_tokens": 500
|
|
82
|
+
},
|
|
83
|
+
timeout=30
|
|
84
|
+
)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
data = response.json()
|
|
87
|
+
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
88
|
+
except requests.exceptions.HTTPError as e:
|
|
89
|
+
if e.response.status_code == 503:
|
|
90
|
+
logger.info(f"Local AI service unavailable (503). Disabling auto-correction.")
|
|
91
|
+
self._available = False
|
|
92
|
+
else:
|
|
93
|
+
logger.warning(f"Local AI HTTP error: {e}")
|
|
94
|
+
return None
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.warning(f"Local AI request failed: {e}")
|
|
97
|
+
self._available = False
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_provider() -> AIProvider:
|
|
102
|
+
"""Get the configured AI provider.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
LocalAIProvider instance
|
|
106
|
+
"""
|
|
107
|
+
global _provider_instance
|
|
108
|
+
if _provider_instance is None:
|
|
109
|
+
_provider_instance = LocalAIProvider()
|
|
110
|
+
return _provider_instance
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def configure_provider(provider: AIProvider):
|
|
114
|
+
"""Set a custom AI provider.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
provider: Instance of AIProvider to use
|
|
118
|
+
"""
|
|
119
|
+
global _provider_instance
|
|
120
|
+
_provider_instance = provider
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_provider_instance = None
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Core selector auto-correction functionality for Selenium WebDriver."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Dict, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from .ai_providers import get_provider
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SelectorAutoCorrect:
|
|
15
|
+
"""Auto-corrects element selectors by analyzing the page and requesting AI suggestions.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
enabled: Whether auto-correction is enabled
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, enabled: bool = True):
|
|
22
|
+
self.enabled = enabled
|
|
23
|
+
self._provider = None
|
|
24
|
+
self._correction_cache: Dict[str, str] = {}
|
|
25
|
+
self._suggestion_cache: Dict[str, str] = {}
|
|
26
|
+
self.suggest_better_selectors = os.environ.get("SELENIUM_SUGGEST_BETTER", "0").lower() in ("1", "true", "yes")
|
|
27
|
+
self._confidence_threshold = 50
|
|
28
|
+
self._cache_enabled = True
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def provider(self):
|
|
32
|
+
if self._provider is None:
|
|
33
|
+
self._provider = get_provider()
|
|
34
|
+
return self._provider
|
|
35
|
+
|
|
36
|
+
def set_provider(self, provider) -> None:
|
|
37
|
+
self._provider = provider
|
|
38
|
+
|
|
39
|
+
def is_service_available(self) -> bool:
|
|
40
|
+
if not self.enabled:
|
|
41
|
+
return False
|
|
42
|
+
provider = self.provider
|
|
43
|
+
return provider is not None and provider.is_available()
|
|
44
|
+
|
|
45
|
+
def get_visible_elements_summary(self, driver) -> str:
|
|
46
|
+
try:
|
|
47
|
+
script = """
|
|
48
|
+
function getElementSummary() {
|
|
49
|
+
const selectors = ['input', 'button', 'a', 'select', 'textarea',
|
|
50
|
+
'[role="button"]', '[role="link"]', '[data-testid]', '[data-test]', '[id]', '[name]'];
|
|
51
|
+
const elements = [];
|
|
52
|
+
selectors.forEach(selector => {
|
|
53
|
+
document.querySelectorAll(selector).forEach(el => {
|
|
54
|
+
if (el.offsetParent !== null) {
|
|
55
|
+
const info = {
|
|
56
|
+
tag: el.tagName.toLowerCase(),
|
|
57
|
+
id: el.id || null,
|
|
58
|
+
name: el.getAttribute('name') || null,
|
|
59
|
+
class: el.className || null,
|
|
60
|
+
type: el.getAttribute('type') || null,
|
|
61
|
+
text: (el.innerText || '').substring(0, 50),
|
|
62
|
+
placeholder: el.getAttribute('placeholder') || null,
|
|
63
|
+
ariaLabel: el.getAttribute('aria-label') || null,
|
|
64
|
+
dataTestId: el.getAttribute('data-testid') || el.getAttribute('data-test') || null,
|
|
65
|
+
role: el.getAttribute('role') || null
|
|
66
|
+
};
|
|
67
|
+
if (info.id || info.name || info.dataTestId || info.text || info.ariaLabel) {
|
|
68
|
+
elements.push(info);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
return elements.filter(el => {
|
|
75
|
+
const key = JSON.stringify(el);
|
|
76
|
+
if (seen.has(key)) return false;
|
|
77
|
+
seen.add(key);
|
|
78
|
+
return true;
|
|
79
|
+
}).slice(0, 100);
|
|
80
|
+
}
|
|
81
|
+
return JSON.stringify(getElementSummary());
|
|
82
|
+
"""
|
|
83
|
+
result = driver.execute_script(script)
|
|
84
|
+
return result if result else "[]"
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.warning(f"Failed to get element summary: {e}")
|
|
87
|
+
return "[]"
|
|
88
|
+
|
|
89
|
+
def suggest_selector(self, driver, failed_by: str, failed_value: str, error_message: str = "") -> Optional[Tuple[str, str]]:
|
|
90
|
+
if not self.enabled or not self.is_service_available():
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
cache_key = f"{failed_by}:{failed_value}"
|
|
94
|
+
if self._cache_enabled and cache_key in self._correction_cache:
|
|
95
|
+
logger.info(f"[AUTO-CORRECT] Using cached correction for {failed_value[:50]}")
|
|
96
|
+
return self._parse_selector_suggestion(self._correction_cache[cache_key])
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
elements_summary = self.get_visible_elements_summary(driver)
|
|
100
|
+
current_url = driver.current_url
|
|
101
|
+
|
|
102
|
+
system_prompt = """You are an expert at fixing Selenium element selectors.
|
|
103
|
+
When given a failed selector and available elements, suggest a working alternative.
|
|
104
|
+
ONLY respond with a JSON object containing:
|
|
105
|
+
- "by": the selector strategy ("css selector", "xpath", "id", "name", etc.)
|
|
106
|
+
- "value": the selector value
|
|
107
|
+
- "confidence": a number 0-100
|
|
108
|
+
- "reason": brief explanation
|
|
109
|
+
|
|
110
|
+
If no good alternative exists, respond with:
|
|
111
|
+
{"by": null, "value": null, "confidence": 0, "reason": "No suitable alternative found"}"""
|
|
112
|
+
|
|
113
|
+
user_prompt = f"""The following selector failed:
|
|
114
|
+
- Strategy: {failed_by}
|
|
115
|
+
- Value: {failed_value}
|
|
116
|
+
- URL: {current_url}
|
|
117
|
+
- Error: {error_message}
|
|
118
|
+
|
|
119
|
+
Available Elements:
|
|
120
|
+
```json
|
|
121
|
+
{elements_summary}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Suggest a working selector. Respond with ONLY a JSON object."""
|
|
125
|
+
|
|
126
|
+
logger.info(f"[AUTO-CORRECT] Requesting selector suggestion for: {failed_value[:50]}...")
|
|
127
|
+
response = self.provider.suggest_selector(system_prompt, user_prompt)
|
|
128
|
+
|
|
129
|
+
if response:
|
|
130
|
+
suggestion = self._parse_selector_suggestion(response)
|
|
131
|
+
if suggestion and self._cache_enabled:
|
|
132
|
+
self._correction_cache[cache_key] = response
|
|
133
|
+
return suggestion
|
|
134
|
+
else:
|
|
135
|
+
if self.provider and not self.provider.is_available():
|
|
136
|
+
logger.info("[AUTO-CORRECT] Service unavailable, auto-correction disabled for this session")
|
|
137
|
+
self.enabled = False
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.warning(f"[AUTO-CORRECT] Failed to get suggestion: {e}")
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def suggest_better_selector(self, driver, current_by: str, current_value: str, element) -> Optional[Tuple[str, str]]:
|
|
143
|
+
if not self.suggest_better_selectors or not self.enabled or not self.is_service_available():
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
if current_by in ("id", "name") or (current_by == "css selector" and "[data-testid=" in current_value):
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
cache_key = f"{current_by}:{current_value}"
|
|
150
|
+
if self._cache_enabled and cache_key in self._suggestion_cache:
|
|
151
|
+
cached = self._suggestion_cache[cache_key]
|
|
152
|
+
if cached == "OPTIMAL":
|
|
153
|
+
return None
|
|
154
|
+
return self._parse_selector_suggestion(cached)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
element_info = self._get_element_info(element)
|
|
158
|
+
elements_summary = self.get_visible_elements_summary(driver)
|
|
159
|
+
|
|
160
|
+
system_prompt = """You are an expert at improving Selenium element selectors.
|
|
161
|
+
Suggest a better selector ONLY if the current one is fragile.
|
|
162
|
+
Preference: data-testid > id > name > css class > xpath
|
|
163
|
+
|
|
164
|
+
Respond with JSON: {"by": ..., "value": ..., "confidence": ..., "reason": ...}
|
|
165
|
+
If current is optimal: {"by": null, "value": null, "confidence": 100, "reason": "Current selector is optimal"}"""
|
|
166
|
+
|
|
167
|
+
user_prompt = f"""Current Selector: {current_by}='{current_value}'
|
|
168
|
+
Element attributes: {json.dumps(element_info, indent=2)}
|
|
169
|
+
Available Elements:
|
|
170
|
+
```json
|
|
171
|
+
{elements_summary}
|
|
172
|
+
```"""
|
|
173
|
+
|
|
174
|
+
response = self.provider.suggest_selector(system_prompt, user_prompt)
|
|
175
|
+
if response:
|
|
176
|
+
suggestion = self._parse_selector_suggestion(response)
|
|
177
|
+
if suggestion:
|
|
178
|
+
if self._cache_enabled:
|
|
179
|
+
self._suggestion_cache[cache_key] = response
|
|
180
|
+
return suggestion
|
|
181
|
+
else:
|
|
182
|
+
if self._cache_enabled:
|
|
183
|
+
self._suggestion_cache[cache_key] = "OPTIMAL"
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.debug(f"[SUGGEST] Failed to get better selector: {e}")
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def _get_element_info(self, element) -> Dict[str, Any]:
|
|
189
|
+
try:
|
|
190
|
+
info = {
|
|
191
|
+
"tag": element.tag_name,
|
|
192
|
+
"id": element.get_attribute("id"),
|
|
193
|
+
"name": element.get_attribute("name"),
|
|
194
|
+
"class": element.get_attribute("class"),
|
|
195
|
+
"data-testid": element.get_attribute("data-testid") or element.get_attribute("data-test"),
|
|
196
|
+
"aria-label": element.get_attribute("aria-label"),
|
|
197
|
+
}
|
|
198
|
+
return {k: v for k, v in info.items() if v}
|
|
199
|
+
except Exception:
|
|
200
|
+
return {}
|
|
201
|
+
|
|
202
|
+
def _parse_selector_suggestion(self, content: str) -> Optional[Tuple[str, str]]:
|
|
203
|
+
try:
|
|
204
|
+
json_match = re.search(r'\{[^{}]*\}', content, re.DOTALL)
|
|
205
|
+
if json_match:
|
|
206
|
+
data = json.loads(json_match.group())
|
|
207
|
+
by = data.get("by")
|
|
208
|
+
value = data.get("value")
|
|
209
|
+
confidence = data.get("confidence", 0)
|
|
210
|
+
reason = data.get("reason", "")
|
|
211
|
+
|
|
212
|
+
if by and value and confidence >= self._confidence_threshold:
|
|
213
|
+
logger.info(f"[AUTO-CORRECT] Suggested selector (confidence: {confidence}%)")
|
|
214
|
+
logger.info(f" Strategy: {by}")
|
|
215
|
+
logger.info(f" Value: {value}")
|
|
216
|
+
logger.info(f" Reason: {reason}")
|
|
217
|
+
return (by, value)
|
|
218
|
+
elif reason:
|
|
219
|
+
logger.info(f"[AUTO-CORRECT] No good suggestion - {reason}")
|
|
220
|
+
except json.JSONDecodeError as e:
|
|
221
|
+
logger.warning(f"[AUTO-CORRECT] Failed to parse JSON: {e}")
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.warning(f"[AUTO-CORRECT] Error parsing suggestion: {e}")
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
def clear_cache(self):
|
|
227
|
+
self._correction_cache.clear()
|
|
228
|
+
self._suggestion_cache.clear()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_auto_correct_instance: Optional[SelectorAutoCorrect] = None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_auto_correct() -> SelectorAutoCorrect:
|
|
235
|
+
"""Get the global SelectorAutoCorrect instance."""
|
|
236
|
+
global _auto_correct_instance
|
|
237
|
+
if _auto_correct_instance is None:
|
|
238
|
+
enabled = os.environ.get("SELENIUM_AUTO_CORRECT", "1").lower() in ("1", "true", "yes")
|
|
239
|
+
_auto_correct_instance = SelectorAutoCorrect(enabled=enabled)
|
|
240
|
+
return _auto_correct_instance
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def set_auto_correct_enabled(enabled: bool):
|
|
244
|
+
"""Enable or disable auto-correction globally."""
|
|
245
|
+
get_auto_correct().enabled = enabled
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def configure_auto_correct(provider=None, enabled: bool = True, suggest_better: bool = False):
|
|
249
|
+
"""Configure auto-correction settings."""
|
|
250
|
+
auto_correct = get_auto_correct()
|
|
251
|
+
auto_correct.enabled = enabled
|
|
252
|
+
auto_correct.suggest_better_selectors = suggest_better
|
|
253
|
+
if provider:
|
|
254
|
+
auto_correct.set_provider(provider)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Correction tracker for recording and applying selector fixes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import traceback
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CorrectionTracker:
|
|
16
|
+
"""Tracks selector corrections and manages test file updates."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self._corrections: List[Dict[str, Any]] = []
|
|
20
|
+
self._local_ai_url = os.environ.get("LOCAL_AI_API_URL", "http://localhost:8765")
|
|
21
|
+
self._auto_update_enabled = os.environ.get("SELENIUM_AUTO_UPDATE_TESTS", "0").lower() in ("1", "true", "yes")
|
|
22
|
+
|
|
23
|
+
def record_correction(
|
|
24
|
+
self,
|
|
25
|
+
original_by: str,
|
|
26
|
+
original_value: str,
|
|
27
|
+
corrected_by: str,
|
|
28
|
+
corrected_value: str,
|
|
29
|
+
success: bool = True,
|
|
30
|
+
test_file: Optional[str] = None,
|
|
31
|
+
test_line: Optional[int] = None
|
|
32
|
+
):
|
|
33
|
+
if test_file is None or test_line is None:
|
|
34
|
+
# Extract from stack trace, prioritizing actual test files
|
|
35
|
+
for frame in traceback.extract_stack():
|
|
36
|
+
filename = frame.filename.replace('\\', '/')
|
|
37
|
+
filename_lower = filename.lower()
|
|
38
|
+
# Skip selenium packages, pytest, and our autocorrect packages
|
|
39
|
+
# Be specific to avoid skipping directories with "selenium" in the name
|
|
40
|
+
if ('/selenium/' in filename_lower or
|
|
41
|
+
'\\selenium\\' in filename or
|
|
42
|
+
'/site-packages/selenium/' in filename_lower or
|
|
43
|
+
'/pytest' in filename_lower or
|
|
44
|
+
'/_pytest' in filename_lower or
|
|
45
|
+
'/selenium_selector_autocorrect/' in filename_lower or
|
|
46
|
+
'\\selenium_selector_autocorrect\\' in filename):
|
|
47
|
+
continue
|
|
48
|
+
# Prioritize test files, then page objects, then ui_client
|
|
49
|
+
if ('test_library' in filename or
|
|
50
|
+
'test_' in filename or
|
|
51
|
+
'page_factory' in filename_lower or
|
|
52
|
+
'ui_client' in filename_lower):
|
|
53
|
+
test_file = filename
|
|
54
|
+
test_line = frame.lineno
|
|
55
|
+
# Don't break - keep looking for test files specifically
|
|
56
|
+
if 'test_' in filename or 'test_library' in filename:
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
correction = {
|
|
60
|
+
"original_by": original_by,
|
|
61
|
+
"original_value": original_value,
|
|
62
|
+
"corrected_by": corrected_by,
|
|
63
|
+
"corrected_value": corrected_value,
|
|
64
|
+
"success": success,
|
|
65
|
+
"test_file": test_file,
|
|
66
|
+
"test_line": test_line,
|
|
67
|
+
"timestamp": datetime.now().isoformat()
|
|
68
|
+
}
|
|
69
|
+
self._corrections.append(correction)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
logger.info(f"[CORRECTION TRACKED] {original_by}='{original_value[:30]}...' -> {corrected_by}='{corrected_value[:30]}...'")
|
|
73
|
+
if test_file:
|
|
74
|
+
logger.info(f"[CORRECTION SOURCE] File: {test_file}, Line: {test_line}")
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
if self._auto_update_enabled and success and test_file:
|
|
79
|
+
logger.info(f"[AUTO-UPDATE] Attempting to update {test_file}...")
|
|
80
|
+
self._auto_update_test_file(correction)
|
|
81
|
+
|
|
82
|
+
def get_corrections(self) -> List[Dict[str, Any]]:
|
|
83
|
+
return self._corrections.copy()
|
|
84
|
+
|
|
85
|
+
def get_successful_corrections(self) -> List[Dict[str, Any]]:
|
|
86
|
+
return [c for c in self._corrections if c.get("success", False)]
|
|
87
|
+
|
|
88
|
+
def clear_corrections(self):
|
|
89
|
+
self._corrections.clear()
|
|
90
|
+
|
|
91
|
+
def _auto_update_test_file(self, correction: Dict[str, Any]):
|
|
92
|
+
try:
|
|
93
|
+
test_file = correction.get("test_file")
|
|
94
|
+
if not test_file:
|
|
95
|
+
return
|
|
96
|
+
result = self.update_test_file_via_service(
|
|
97
|
+
test_file,
|
|
98
|
+
correction["original_by"],
|
|
99
|
+
correction["original_value"],
|
|
100
|
+
correction["corrected_by"],
|
|
101
|
+
correction["corrected_value"]
|
|
102
|
+
)
|
|
103
|
+
if result.get("success"):
|
|
104
|
+
logger.info(f"[AUTO-UPDATE] Successfully updated {test_file}")
|
|
105
|
+
else:
|
|
106
|
+
logger.warning(f"[AUTO-UPDATE] Failed to update {test_file}: {result.get('errors', [])}")
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.warning(f"[AUTO-UPDATE] Error updating test file: {e}")
|
|
109
|
+
|
|
110
|
+
def update_test_file_via_service(
|
|
111
|
+
self,
|
|
112
|
+
file_path: str,
|
|
113
|
+
original_by: str,
|
|
114
|
+
original_value: str,
|
|
115
|
+
corrected_by: str,
|
|
116
|
+
corrected_value: str
|
|
117
|
+
) -> Dict[str, Any]:
|
|
118
|
+
try:
|
|
119
|
+
read_url = f"{self._local_ai_url}/v1/workspace/files/read"
|
|
120
|
+
read_response = requests.post(read_url, json={"filePath": file_path}, timeout=30)
|
|
121
|
+
read_response.raise_for_status()
|
|
122
|
+
file_content = read_response.json()
|
|
123
|
+
|
|
124
|
+
if not file_content.get("success"):
|
|
125
|
+
return {"success": False, "errors": ["Could not read file"]}
|
|
126
|
+
|
|
127
|
+
content = file_content.get("content", "")
|
|
128
|
+
old_patterns = [
|
|
129
|
+
f'"{original_value}"',
|
|
130
|
+
f"'{original_value}'",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
found_pattern = None
|
|
134
|
+
new_pattern = None
|
|
135
|
+
for old_pattern in old_patterns:
|
|
136
|
+
if old_pattern in content:
|
|
137
|
+
found_pattern = old_pattern
|
|
138
|
+
new_pattern = f'"{corrected_value}"' if old_pattern.startswith('"') else f"'{corrected_value}'"
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
if not found_pattern:
|
|
142
|
+
return {"success": False, "errors": [f"Could not find selector: {original_value[:50]}..."]}
|
|
143
|
+
|
|
144
|
+
edit_url = f"{self._local_ai_url}/v1/workspace/files/edit"
|
|
145
|
+
edit_response = requests.post(
|
|
146
|
+
edit_url,
|
|
147
|
+
json={"filePath": file_path, "oldString": found_pattern, "newString": new_pattern},
|
|
148
|
+
timeout=30
|
|
149
|
+
)
|
|
150
|
+
edit_response.raise_for_status()
|
|
151
|
+
return edit_response.json()
|
|
152
|
+
except requests.exceptions.ConnectionError:
|
|
153
|
+
logger.warning(f"[LOCAL AI SERVICE] Not available at {self._local_ai_url}")
|
|
154
|
+
return {"success": False, "errors": ["Local AI service not available"]}
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.warning(f"[UPDATE ERROR] {e}")
|
|
157
|
+
return {"success": False, "errors": [str(e)]}
|
|
158
|
+
|
|
159
|
+
def export_corrections_report(self, output_file: str = "selector_corrections.json"):
|
|
160
|
+
with open(output_file, "w") as f:
|
|
161
|
+
json.dump({
|
|
162
|
+
"corrections": self._corrections,
|
|
163
|
+
"summary": {
|
|
164
|
+
"total": len(self._corrections),
|
|
165
|
+
"successful": len(self.get_successful_corrections()),
|
|
166
|
+
"generated_at": datetime.now().isoformat()
|
|
167
|
+
}
|
|
168
|
+
}, f, indent=2)
|
|
169
|
+
logger.info(f"[CORRECTIONS REPORT] Exported to {output_file}")
|
|
170
|
+
|
|
171
|
+
def apply_all_corrections_to_files(self) -> Dict[str, Any]:
|
|
172
|
+
results = {"total": 0, "success": 0, "failed": 0, "details": []}
|
|
173
|
+
for correction in self.get_successful_corrections():
|
|
174
|
+
test_file = correction.get("test_file")
|
|
175
|
+
if not test_file:
|
|
176
|
+
continue
|
|
177
|
+
results["total"] += 1
|
|
178
|
+
result = self.update_test_file_via_service(
|
|
179
|
+
test_file,
|
|
180
|
+
correction["original_by"],
|
|
181
|
+
correction["original_value"],
|
|
182
|
+
correction["corrected_by"],
|
|
183
|
+
correction["corrected_value"]
|
|
184
|
+
)
|
|
185
|
+
if result.get("success"):
|
|
186
|
+
results["success"] += 1
|
|
187
|
+
else:
|
|
188
|
+
results["failed"] += 1
|
|
189
|
+
results["details"].append({
|
|
190
|
+
"file": test_file,
|
|
191
|
+
"original": correction["original_value"][:50],
|
|
192
|
+
"corrected": correction["corrected_value"][:50],
|
|
193
|
+
"result": result
|
|
194
|
+
})
|
|
195
|
+
logger.info(f"[APPLIED CORRECTIONS] {results['success']}/{results['total']} successful")
|
|
196
|
+
return results
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_correction_tracker: Optional[CorrectionTracker] = None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_correction_tracker() -> CorrectionTracker:
|
|
203
|
+
"""Get the global CorrectionTracker instance."""
|
|
204
|
+
global _correction_tracker
|
|
205
|
+
if _correction_tracker is None:
|
|
206
|
+
_correction_tracker = CorrectionTracker()
|
|
207
|
+
return _correction_tracker
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def record_correction(
|
|
211
|
+
original_by: str, original_value: str, corrected_by: str, corrected_value: str, success: bool = True
|
|
212
|
+
):
|
|
213
|
+
"""Record a selector correction."""
|
|
214
|
+
get_correction_tracker().record_correction(
|
|
215
|
+
original_by, original_value, corrected_by, corrected_value, success
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def apply_corrections_to_test_files() -> Dict[str, Any]:
|
|
220
|
+
"""Apply all successful corrections to their source test files."""
|
|
221
|
+
return get_correction_tracker().apply_all_corrections_to_files()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def export_corrections_report(output_file: str = "selector_corrections.json"):
|
|
225
|
+
"""Export corrections report to JSON file."""
|
|
226
|
+
get_correction_tracker().export_corrections_report(output_file)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Type hints marker for PEP 561
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""WebDriverWait integration hook for selector auto-correction."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
from selenium.common.exceptions import TimeoutException
|
|
8
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
9
|
+
from selenium.webdriver.support.wait import WebDriverWait
|
|
10
|
+
|
|
11
|
+
from .auto_correct import get_auto_correct
|
|
12
|
+
from .correction_tracker import record_correction
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_original_until = WebDriverWait.until
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _patched_until(self, method: Callable, message: str = ""):
|
|
20
|
+
"""Patched until method with auto-correct support."""
|
|
21
|
+
screen = None
|
|
22
|
+
stacktrace = None
|
|
23
|
+
|
|
24
|
+
end_time = time.monotonic() + self._timeout
|
|
25
|
+
while True:
|
|
26
|
+
try:
|
|
27
|
+
value = method(self._driver)
|
|
28
|
+
if value:
|
|
29
|
+
return value
|
|
30
|
+
except self._ignored_exceptions as exc:
|
|
31
|
+
screen = getattr(exc, "screen", None)
|
|
32
|
+
stacktrace = getattr(exc, "stacktrace", None)
|
|
33
|
+
if time.monotonic() > end_time:
|
|
34
|
+
break
|
|
35
|
+
time.sleep(self._poll)
|
|
36
|
+
|
|
37
|
+
auto_correct = get_auto_correct()
|
|
38
|
+
if auto_correct.enabled:
|
|
39
|
+
locator = _extract_locator_from_method(method)
|
|
40
|
+
if locator:
|
|
41
|
+
by, value_str = locator
|
|
42
|
+
logger.warning(
|
|
43
|
+
f"[AUTO-CORRECT] Timeout waiting for element {by}='{value_str[:80]}...' - attempting auto-correction"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
driver = self._driver
|
|
47
|
+
if isinstance(driver, WebElement):
|
|
48
|
+
driver = driver.parent
|
|
49
|
+
|
|
50
|
+
suggestion = auto_correct.suggest_selector(
|
|
51
|
+
driver,
|
|
52
|
+
failed_by=by,
|
|
53
|
+
failed_value=value_str,
|
|
54
|
+
error_message=message or f"Timeout waiting for element with {by}={value_str}",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if suggestion:
|
|
58
|
+
suggested_by, suggested_value = suggestion
|
|
59
|
+
logger.warning(f"[AUTO-CORRECT] Trying suggested selector: {suggested_by}='{suggested_value}'")
|
|
60
|
+
|
|
61
|
+
corrected_method = _create_corrected_method(method, suggested_by, suggested_value)
|
|
62
|
+
if corrected_method:
|
|
63
|
+
try:
|
|
64
|
+
result = corrected_method(self._driver)
|
|
65
|
+
if result:
|
|
66
|
+
logger.warning(f"[AUTO-CORRECT] SUCCESS! Element found with corrected selector")
|
|
67
|
+
record_correction(
|
|
68
|
+
original_by=by,
|
|
69
|
+
original_value=value_str,
|
|
70
|
+
corrected_by=suggested_by,
|
|
71
|
+
corrected_value=suggested_value,
|
|
72
|
+
success=True,
|
|
73
|
+
)
|
|
74
|
+
return result
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.warning(f"[AUTO-CORRECT] Suggested selector also failed: {e}")
|
|
77
|
+
|
|
78
|
+
raise TimeoutException(message, screen, stacktrace)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extract_locator_from_method(method: Callable) -> Optional[tuple]:
|
|
82
|
+
"""Extract locator tuple (by, value) from an expected_conditions method."""
|
|
83
|
+
try:
|
|
84
|
+
if hasattr(method, "locator"):
|
|
85
|
+
logger.debug(f"[AUTO-CORRECT] Found locator attribute: {method.locator}")
|
|
86
|
+
return method.locator
|
|
87
|
+
|
|
88
|
+
if hasattr(method, "__closure__") and method.__closure__:
|
|
89
|
+
for cell in method.__closure__:
|
|
90
|
+
cell_contents = cell.cell_contents
|
|
91
|
+
logger.debug(f"[AUTO-CORRECT] Checking closure cell: {type(cell_contents)} = {cell_contents}")
|
|
92
|
+
if isinstance(cell_contents, tuple) and len(cell_contents) == 2:
|
|
93
|
+
if isinstance(cell_contents[0], str) and isinstance(cell_contents[1], str):
|
|
94
|
+
logger.debug(f"[AUTO-CORRECT] Extracted locator from closure: {cell_contents}")
|
|
95
|
+
return cell_contents
|
|
96
|
+
logger.warning(f"[AUTO-CORRECT] Could not extract locator from method: {method}")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.exception(f"[AUTO-CORRECT] Error extracting locator: {e}")
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _create_corrected_method(original_method: Callable, new_by: str, new_value: str) -> Optional[Callable]:
|
|
103
|
+
"""Create a new expected condition method with corrected locator."""
|
|
104
|
+
try:
|
|
105
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
106
|
+
|
|
107
|
+
method_name = None
|
|
108
|
+
if hasattr(original_method, '__qualname__'):
|
|
109
|
+
qualname = original_method.__qualname__
|
|
110
|
+
if '.<locals>._predicate' in qualname:
|
|
111
|
+
method_name = qualname.split('.<locals>._predicate')[0]
|
|
112
|
+
|
|
113
|
+
if not method_name:
|
|
114
|
+
method_name = original_method.__class__.__name__
|
|
115
|
+
|
|
116
|
+
method_map = {
|
|
117
|
+
"visibility_of_element_located": EC.visibility_of_element_located,
|
|
118
|
+
"presence_of_element_located": EC.presence_of_element_located,
|
|
119
|
+
"element_to_be_clickable": EC.element_to_be_clickable,
|
|
120
|
+
"invisibility_of_element_located": EC.invisibility_of_element_located,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
ec_method = method_map.get(method_name, EC.visibility_of_element_located)
|
|
124
|
+
return ec_method((new_by, new_value))
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.exception(f"[AUTO-CORRECT] Error creating corrected method: {e}")
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def install_auto_correct_hook():
|
|
132
|
+
"""Install the auto-correct hook into WebDriverWait."""
|
|
133
|
+
WebDriverWait.until = _patched_until
|
|
134
|
+
logger.info("[AUTO-CORRECT] Hook installed into WebDriverWait")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def uninstall_auto_correct_hook():
|
|
138
|
+
"""Remove the auto-correct hook from WebDriverWait."""
|
|
139
|
+
WebDriverWait.until = _original_until
|
|
140
|
+
logger.info("[AUTO-CORRECT] Hook removed from WebDriverWait")
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: selenium-selector-autocorrect
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatic Selenium selector correction using AI when elements fail to be found
|
|
5
|
+
Author-email: Marty Zhou <marty.zhou@example.com>
|
|
6
|
+
Maintainer-email: Marty Zhou <marty.zhou@example.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/MartyZhou/selenium-selector-autocorrect
|
|
9
|
+
Project-URL: Documentation, https://github.com/MartyZhou/selenium-selector-autocorrect#readme
|
|
10
|
+
Project-URL: Repository, https://github.com/MartyZhou/selenium-selector-autocorrect
|
|
11
|
+
Project-URL: Issues, https://github.com/MartyZhou/selenium-selector-autocorrect/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/MartyZhou/selenium-selector-autocorrect/blob/main/CHANGELOG.md
|
|
13
|
+
Keywords: selenium,testing,automation,ai,web-testing,test-automation,selector,auto-correction
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
26
|
+
Classifier: Framework :: Pytest
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: selenium>=4.0.0
|
|
31
|
+
Requires-Dist: requests>=2.25.0
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
37
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
38
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# Selenium Selector AutoCorrect
|
|
42
|
+
|
|
43
|
+
A Python package that automatically corrects Selenium element selectors using AI when they fail, reducing test maintenance and improving test reliability.
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Automatic Selector Correction**: When a WebDriverWait times out, the package uses AI to analyze the page and suggest working alternatives
|
|
48
|
+
- **Local AI Integration**: Uses a local AI service with OpenAI-compatible API
|
|
49
|
+
- **Correction Tracking**: Records all corrections with source file and line information
|
|
50
|
+
- **Optional Auto-Update**: Can automatically update test files with corrected selectors
|
|
51
|
+
- **Zero Code Changes**: Works by hooking into Selenium's WebDriverWait
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install selenium-selector-autocorrect
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from selenium import webdriver
|
|
63
|
+
from selenium.webdriver.common.by import By
|
|
64
|
+
from selenium.webdriver.support.wait import WebDriverWait
|
|
65
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
66
|
+
from selenium_selector_autocorrect import install_auto_correct_hook
|
|
67
|
+
|
|
68
|
+
install_auto_correct_hook()
|
|
69
|
+
|
|
70
|
+
driver = webdriver.Chrome()
|
|
71
|
+
driver.get("https://example.com")
|
|
72
|
+
|
|
73
|
+
element = WebDriverWait(driver, 10).until(
|
|
74
|
+
EC.presence_of_element_located((By.ID, "some-element"))
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## AI Service Setup
|
|
79
|
+
|
|
80
|
+
This package requires a local AI service with an OpenAI-compatible API. We recommend using **[VS Code Copilot as Service](https://marketplace.visualstudio.com/items?itemName=MartyZhou.vscode-copilot-as-service)**, which exposes GitHub Copilot through a local HTTP server.
|
|
81
|
+
|
|
82
|
+
### Installing VS Code Copilot as Service
|
|
83
|
+
|
|
84
|
+
1. Install from VS Code Marketplace or run:
|
|
85
|
+
```bash
|
|
86
|
+
code --install-extension MartyZhou.vscode-copilot-as-service
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
2. The extension automatically starts a server on `http://localhost:8765`
|
|
90
|
+
|
|
91
|
+
3. Requires an active GitHub Copilot subscription
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
Configure via environment variables:
|
|
97
|
+
|
|
98
|
+
- `LOCAL_AI_API_URL`: URL of local AI service (default: `http://localhost:8765`)
|
|
99
|
+
- `SELENIUM_AUTO_CORRECT`: Enable/disable auto-correction (default: `"1"`)
|
|
100
|
+
- `SELENIUM_SUGGEST_BETTER`: Suggest better selectors for found elements (default: `"0"`)
|
|
101
|
+
- `SELENIUM_AUTO_UPDATE_TESTS`: Auto-update test files with corrections (default: `"0"`)
|
|
102
|
+
|
|
103
|
+
### Example
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import os
|
|
107
|
+
|
|
108
|
+
os.environ['LOCAL_AI_API_URL'] = 'http://localhost:8765'
|
|
109
|
+
os.environ['SELENIUM_AUTO_CORRECT'] = '1'
|
|
110
|
+
os.environ['SELENIUM_AUTO_UPDATE_TESTS'] = '1' # Enable auto-update
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Usage
|
|
114
|
+
|
|
115
|
+
### Basic Usage
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from selenium_selector_autocorrect import install_auto_correct_hook
|
|
119
|
+
|
|
120
|
+
install_auto_correct_hook()
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Advanced Usage
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from selenium_selector_autocorrect import (
|
|
127
|
+
install_auto_correct_hook,
|
|
128
|
+
get_auto_correct,
|
|
129
|
+
get_correction_tracker,
|
|
130
|
+
export_corrections_report
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
install_auto_correct_hook()
|
|
134
|
+
|
|
135
|
+
auto_correct = get_auto_correct()
|
|
136
|
+
auto_correct.enabled = True
|
|
137
|
+
auto_correct.suggest_better_selectors = False
|
|
138
|
+
|
|
139
|
+
# Export corrections report at end of test run
|
|
140
|
+
tracker = get_correction_tracker()
|
|
141
|
+
export_corrections_report("corrections_report.json")
|
|
142
|
+
tracker = get_correction_tracker()
|
|
143
|
+
export_corrections_report("corrections_report.json")
|
|
144
|
+
|
|
145
|
+
print(f"Total corrections: {len(tracker.get_corrections())}")
|
|
146
|
+
print(f"Successful corrections: {len(tracker.get_successful_corrections())}")
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Custom AI Provider
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from selenium_selector_autocorrect import AIProvider, configure_provider
|
|
153
|
+
|
|
154
|
+
class CustomAIProvider(AIProvider):
|
|
155
|
+
def is_available(self) -> bool:
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def suggest_selector(self, system_prompt: str, user_prompt: str):))
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## How It Works
|
|
162
|
+
|
|
163
|
+
1. **Hook Installation**: Patches `WebDriverWait.until()` to add auto-correction
|
|
164
|
+
2. **Timeout Detection**: When a selector times out, the original exception is caught
|
|
165
|
+
3. **Page Analysis**: JavaScript extracts visible elements and their attributes
|
|
166
|
+
4. **AI Suggestion**: Sends page context to AI provider for selector suggestion
|
|
167
|
+
5. **Verification**: Tests the suggested selector
|
|
168
|
+
6. **Success Handling**: If successful, records the correction and optionally updates the test file
|
|
169
|
+
7. **Fallback**: If correction fails, raises the original TimeoutException
|
|
170
|
+
|
|
171
|
+
## AI Provider Setup
|
|
172
|
+
|
|
173
|
+
### Local AI Service
|
|
174
|
+
|
|
175
|
+
The package requires a local AI service with OpenAI-compatible API:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
POST http://localhost:8765/v1/chat/completions
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
For file auto-updates:
|
|
182
|
+
```bash
|
|
183
|
+
POST http://localhost:8765/v1/workspace/files/read
|
|
184
|
+
POST http://localhost:8765/v1/workspace/files/edit
|
|
185
|
+
## Correction Reports
|
|
186
|
+
|
|
187
|
+
Export correction reports in JSON format:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from selenium_selector_autocorrect import export_corrections_report
|
|
191
|
+
|
|
192
|
+
export_corrections_report("corrections_report.json")
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Report format:
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"corrections": [
|
|
199
|
+
{
|
|
200
|
+
"original_by": "id",
|
|
201
|
+
"original_value": "old-selector",
|
|
202
|
+
"corrected_by": "css selector",
|
|
203
|
+
"corrected_value": ".new-selector",
|
|
204
|
+
"success": true,
|
|
205
|
+
"test_file": "/path/to/test.py",
|
|
206
|
+
"test_line": 42,
|
|
207
|
+
"timestamp": "2024-01-31T10:30:00"
|
|
208
|
+
}
|
|
209
|
+
],
|
|
210
|
+
"summary": {
|
|
211
|
+
"total": 10,
|
|
212
|
+
"successful": 8,
|
|
213
|
+
"generated_at": "2024-01-31T10:35:00"
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Best Practices
|
|
219
|
+
|
|
220
|
+
1. **Install Once**: Call `install_auto_correct_hook()` once at test suite startup (e.g., in `conftest.py`)
|
|
221
|
+
2. **Review Corrections**: Regularly review correction reports to identify brittle selectors
|
|
222
|
+
3. **Update Tests**: Use auto-update sparingly and review changes before committing
|
|
223
|
+
4. **Monitor AI Service**: Ensure your AI service is running and responsive
|
|
224
|
+
5. **Use Strong Selectors**: The tool helps with failures but writing robust selectors is still preferred
|
|
225
|
+
|
|
226
|
+
## Requirements
|
|
227
|
+
|
|
228
|
+
- Python >= 3.8
|
|
229
|
+
- selenium >= 4.0.0
|
|
230
|
+
- requests >= 2.25.0
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
MITInstall hook once at test suite startup (e.g., in conftest.py)
|
|
235
|
+
2. Review correction reports regularly to identify brittle selectors
|
|
236
|
+
3. Use auto-update sparingly and review changes before committing
|
|
237
|
+
4. Ensure your AI service is running and responsive
|
|
238
|
+
5. Write robust selectors - the tool helps with failures but prevention is better
|
|
239
|
+
|
|
240
|
+
When contributing:
|
|
241
|
+
1. Follow PEP 8 style guidelines
|
|
242
|
+
2. Add tests for new features
|
|
243
|
+
3. Update documentation
|
|
244
|
+
4. No emojis in code or documentation
|
|
245
|
+
|
|
246
|
+
## Troubleshooting
|
|
247
|
+
|
|
248
|
+
### AI Service Not Available
|
|
249
|
+
|
|
250
|
+
Contributions are welcome! Please:
|
|
251
|
+
1. Follow PEP 8 style guidelines
|
|
252
|
+
2. Add tests for new features
|
|
253
|
+
3. Update documentation
|
|
254
|
+
4. Maintain consistency with existing code
|
|
255
|
+
|
|
256
|
+
**Possible causes**:
|
|
257
|
+
- `SELENIUM_AUTO_UPDATE_TESTS` not set to `"1"`
|
|
258
|
+
- Test file path not detected correctly
|
|
259
|
+
- Selector string not found in source file (check quotes)
|
|
260
|
+
|
|
261
|
+
### No Corrections Happening
|
|
262
|
+
Solution: Ensure your local AI service is running on the configured port.
|
|
263
|
+
|
|
264
|
+
### Test File Not Updated
|
|
265
|
+
|
|
266
|
+
Possible causes:
|
|
267
|
+
- `SELENIUM_AUTO_UPDATE_TESTS` not set to "1"
|
|
268
|
+
- Test file path not detected correctly
|
|
269
|
+
- Selector string not found in source file
|
|
270
|
+
|
|
271
|
+
### No Corrections Happening
|
|
272
|
+
|
|
273
|
+
Check:
|
|
274
|
+
1. Hook is installed - look for log message
|
|
275
|
+
2. AI service is available - check `get_auto_correct().is_service_available()`
|
|
276
|
+
3. Auto-correct is enabled - c
|
|
277
|
+
See CHANGELOG.md for version history and changes.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
selenium_selector_autocorrect/__init__.py,sha256=WjF3o-hlNlt_-tlFxQqIQ4P3OT9ZB0MhBYyJc9Ef8Hg,1665
|
|
2
|
+
selenium_selector_autocorrect/ai_providers.py,sha256=c0DHw1Kyfsa6QNF_Yt2zhEGw7-yynZ2_YT7NAXEIn1Y,4095
|
|
3
|
+
selenium_selector_autocorrect/auto_correct.py,sha256=73PhryN0HnMTqhQXZGIy3ahC65IfW2i0IJqf-aOSEVM,10822
|
|
4
|
+
selenium_selector_autocorrect/correction_tracker.py,sha256=tvw7S9q8y-wuPYJ1W8pBP72r8dI0okbVHSjaYhnKGbA,9282
|
|
5
|
+
selenium_selector_autocorrect/py.typed,sha256=FPdf-6Jjb4gsSRLK88ZhHbrQnHD9pcZQTqaxHzO8neM,33
|
|
6
|
+
selenium_selector_autocorrect/wait_hook.py,sha256=U4xD3G3rx6sjcN72qTpH3ET-gY5fhS_X6ADI99CP5uc,5857
|
|
7
|
+
selenium_selector_autocorrect-0.1.0.dist-info/licenses/LICENSE,sha256=VRPy6YXF2wA_3MeTDnpa_-6Zgjt8c2C0D_iIyhDkduc,1095
|
|
8
|
+
selenium_selector_autocorrect-0.1.0.dist-info/METADATA,sha256=mhCjJVP7EoA_n6_VUnHffytUImc-A0kozBntx5TiiHk,9221
|
|
9
|
+
selenium_selector_autocorrect-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
10
|
+
selenium_selector_autocorrect-0.1.0.dist-info/top_level.txt,sha256=nQ78Mk-XHDhYBckP0tMZvoFAZmZGO4Ec4-e1i61Fdz0,30
|
|
11
|
+
selenium_selector_autocorrect-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
selenium_selector_autocorrect
|