waitless 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.
- waitless/__init__.py +92 -0
- waitless/__main__.py +116 -0
- waitless/config.py +162 -0
- waitless/diagnostics.py +171 -0
- waitless/engine.py +275 -0
- waitless/exceptions.py +108 -0
- waitless/instrumentation.py +328 -0
- waitless/selenium_integration.py +296 -0
- waitless/signals.py +215 -0
- waitless-0.1.0.dist-info/METADATA +243 -0
- waitless-0.1.0.dist-info/RECORD +14 -0
- waitless-0.1.0.dist-info/WHEEL +5 -0
- waitless-0.1.0.dist-info/entry_points.txt +2 -0
- waitless-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JavaScript instrumentation for browser-side stability monitoring.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
INSTRUMENTATION_SCRIPT = """
|
|
6
|
+
(function() {
|
|
7
|
+
// Avoid re-initialization
|
|
8
|
+
if (window.__waitless__ && window.__waitless__._initialized) {
|
|
9
|
+
return window.__waitless__;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
window.__waitless__ = {
|
|
13
|
+
_initialized: true,
|
|
14
|
+
_version: '0.1.0',
|
|
15
|
+
|
|
16
|
+
// State tracking
|
|
17
|
+
pendingRequests: 0,
|
|
18
|
+
lastMutationTime: Date.now(),
|
|
19
|
+
activeAnimations: 0,
|
|
20
|
+
activeTransitions: 0,
|
|
21
|
+
layoutShifting: false,
|
|
22
|
+
|
|
23
|
+
// Timeline for diagnostics (circular buffer)
|
|
24
|
+
timeline: [],
|
|
25
|
+
_maxTimelineEntries: 100,
|
|
26
|
+
|
|
27
|
+
// Request tracking for diagnostics
|
|
28
|
+
pendingRequestDetails: [],
|
|
29
|
+
|
|
30
|
+
// Configuration (updated from Python)
|
|
31
|
+
config: {
|
|
32
|
+
trackLayout: true,
|
|
33
|
+
trackAnimations: true,
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// Lifecycle
|
|
37
|
+
_observers: [],
|
|
38
|
+
_originalFetch: null,
|
|
39
|
+
_originalXHROpen: null,
|
|
40
|
+
_originalXHRSend: null,
|
|
41
|
+
|
|
42
|
+
// ===== INITIALIZATION =====
|
|
43
|
+
|
|
44
|
+
init: function() {
|
|
45
|
+
this._setupMutationObserver();
|
|
46
|
+
this._setupNetworkInterceptors();
|
|
47
|
+
this._setupAnimationTracking();
|
|
48
|
+
if (this.config.trackLayout) {
|
|
49
|
+
this._setupLayoutTracking();
|
|
50
|
+
}
|
|
51
|
+
this._log('Waitless instrumentation initialized');
|
|
52
|
+
return this;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// ===== LOGGING =====
|
|
56
|
+
|
|
57
|
+
_log: function(message, data) {
|
|
58
|
+
var entry = {
|
|
59
|
+
time: Date.now(),
|
|
60
|
+
message: message,
|
|
61
|
+
data: data || null
|
|
62
|
+
};
|
|
63
|
+
this.timeline.push(entry);
|
|
64
|
+
if (this.timeline.length > this._maxTimelineEntries) {
|
|
65
|
+
this.timeline.shift();
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// ===== MUTATION OBSERVER =====
|
|
70
|
+
|
|
71
|
+
_setupMutationObserver: function() {
|
|
72
|
+
var self = this;
|
|
73
|
+
var observer = new MutationObserver(function(mutations) {
|
|
74
|
+
self.lastMutationTime = Date.now();
|
|
75
|
+
self._log('DOM mutation', { count: mutations.length });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
observer.observe(document.documentElement || document.body, {
|
|
79
|
+
childList: true,
|
|
80
|
+
subtree: true,
|
|
81
|
+
attributes: true,
|
|
82
|
+
characterData: true
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this._observers.push(observer);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// ===== NETWORK INTERCEPTORS =====
|
|
89
|
+
|
|
90
|
+
_setupNetworkInterceptors: function() {
|
|
91
|
+
var self = this;
|
|
92
|
+
|
|
93
|
+
// Intercept fetch
|
|
94
|
+
this._originalFetch = window.fetch;
|
|
95
|
+
window.fetch = function(input, init) {
|
|
96
|
+
var url = typeof input === 'string' ? input : input.url;
|
|
97
|
+
self._requestStarted(url, 'fetch');
|
|
98
|
+
|
|
99
|
+
return self._originalFetch.apply(window, arguments)
|
|
100
|
+
.then(function(response) {
|
|
101
|
+
self._requestEnded(url, 'fetch', response.status);
|
|
102
|
+
return response;
|
|
103
|
+
})
|
|
104
|
+
.catch(function(error) {
|
|
105
|
+
self._requestEnded(url, 'fetch', 'error');
|
|
106
|
+
throw error;
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Intercept XMLHttpRequest
|
|
111
|
+
this._originalXHROpen = XMLHttpRequest.prototype.open;
|
|
112
|
+
this._originalXHRSend = XMLHttpRequest.prototype.send;
|
|
113
|
+
|
|
114
|
+
XMLHttpRequest.prototype.open = function(method, url) {
|
|
115
|
+
this._waitless_url = url;
|
|
116
|
+
this._waitless_method = method;
|
|
117
|
+
return self._originalXHROpen.apply(this, arguments);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
XMLHttpRequest.prototype.send = function() {
|
|
121
|
+
var xhr = this;
|
|
122
|
+
var url = xhr._waitless_url || 'unknown';
|
|
123
|
+
|
|
124
|
+
self._requestStarted(url, 'xhr');
|
|
125
|
+
|
|
126
|
+
xhr.addEventListener('loadend', function() {
|
|
127
|
+
self._requestEnded(url, 'xhr', xhr.status);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return self._originalXHRSend.apply(this, arguments);
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
_requestStarted: function(url, type) {
|
|
135
|
+
this.pendingRequests++;
|
|
136
|
+
this.pendingRequestDetails.push({
|
|
137
|
+
url: url,
|
|
138
|
+
type: type,
|
|
139
|
+
startTime: Date.now()
|
|
140
|
+
});
|
|
141
|
+
this._log('Request started', { url: url, type: type, pending: this.pendingRequests });
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
_requestEnded: function(url, type, status) {
|
|
145
|
+
this.pendingRequests = Math.max(0, this.pendingRequests - 1);
|
|
146
|
+
|
|
147
|
+
// Remove from pending details
|
|
148
|
+
var idx = this.pendingRequestDetails.findIndex(function(r) {
|
|
149
|
+
return r.url === url && r.type === type;
|
|
150
|
+
});
|
|
151
|
+
if (idx > -1) {
|
|
152
|
+
this.pendingRequestDetails.splice(idx, 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this._log('Request ended', { url: url, type: type, status: status, pending: this.pendingRequests });
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// ===== ANIMATION TRACKING =====
|
|
159
|
+
|
|
160
|
+
_setupAnimationTracking: function() {
|
|
161
|
+
var self = this;
|
|
162
|
+
|
|
163
|
+
// CSS Animations
|
|
164
|
+
document.addEventListener('animationstart', function(e) {
|
|
165
|
+
self.activeAnimations++;
|
|
166
|
+
self._log('Animation started', { name: e.animationName });
|
|
167
|
+
}, true);
|
|
168
|
+
|
|
169
|
+
document.addEventListener('animationend', function(e) {
|
|
170
|
+
self.activeAnimations = Math.max(0, self.activeAnimations - 1);
|
|
171
|
+
self._log('Animation ended', { name: e.animationName });
|
|
172
|
+
}, true);
|
|
173
|
+
|
|
174
|
+
document.addEventListener('animationcancel', function(e) {
|
|
175
|
+
self.activeAnimations = Math.max(0, self.activeAnimations - 1);
|
|
176
|
+
self._log('Animation cancelled', { name: e.animationName });
|
|
177
|
+
}, true);
|
|
178
|
+
|
|
179
|
+
// CSS Transitions
|
|
180
|
+
document.addEventListener('transitionstart', function(e) {
|
|
181
|
+
self.activeTransitions++;
|
|
182
|
+
self._log('Transition started', { property: e.propertyName });
|
|
183
|
+
}, true);
|
|
184
|
+
|
|
185
|
+
document.addEventListener('transitionend', function(e) {
|
|
186
|
+
self.activeTransitions = Math.max(0, self.activeTransitions - 1);
|
|
187
|
+
self._log('Transition ended', { property: e.propertyName });
|
|
188
|
+
}, true);
|
|
189
|
+
|
|
190
|
+
document.addEventListener('transitioncancel', function(e) {
|
|
191
|
+
self.activeTransitions = Math.max(0, self.activeTransitions - 1);
|
|
192
|
+
self._log('Transition cancelled', { property: e.propertyName });
|
|
193
|
+
}, true);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// ===== LAYOUT TRACKING =====
|
|
197
|
+
|
|
198
|
+
_setupLayoutTracking: function() {
|
|
199
|
+
var self = this;
|
|
200
|
+
this._lastPositions = new Map();
|
|
201
|
+
this._layoutCheckInterval = null;
|
|
202
|
+
|
|
203
|
+
// Periodic layout stability check
|
|
204
|
+
this._layoutCheckInterval = setInterval(function() {
|
|
205
|
+
self._checkLayoutStability();
|
|
206
|
+
}, 50);
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
_checkLayoutStability: function() {
|
|
210
|
+
// Track key interactive elements
|
|
211
|
+
var elements = document.querySelectorAll('button, a, input, [onclick], [role="button"]');
|
|
212
|
+
var isShifting = false;
|
|
213
|
+
var self = this;
|
|
214
|
+
|
|
215
|
+
elements.forEach(function(el) {
|
|
216
|
+
var rect = el.getBoundingClientRect();
|
|
217
|
+
var key = el.id || el.className || el.tagName;
|
|
218
|
+
var lastPos = self._lastPositions.get(el);
|
|
219
|
+
|
|
220
|
+
if (lastPos) {
|
|
221
|
+
var dx = Math.abs(rect.left - lastPos.left);
|
|
222
|
+
var dy = Math.abs(rect.top - lastPos.top);
|
|
223
|
+
if (dx > 1 || dy > 1) {
|
|
224
|
+
isShifting = true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
self._lastPositions.set(el, {
|
|
229
|
+
left: rect.left,
|
|
230
|
+
top: rect.top,
|
|
231
|
+
width: rect.width,
|
|
232
|
+
height: rect.height
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (this.layoutShifting !== isShifting) {
|
|
237
|
+
this.layoutShifting = isShifting;
|
|
238
|
+
this._log('Layout stability changed', { shifting: isShifting });
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
// ===== PUBLIC API =====
|
|
243
|
+
|
|
244
|
+
getStatus: function() {
|
|
245
|
+
return {
|
|
246
|
+
stable: this.isStable(),
|
|
247
|
+
pending_requests: this.pendingRequests,
|
|
248
|
+
last_mutation_time: this.lastMutationTime,
|
|
249
|
+
active_animations: this.activeAnimations + this.activeTransitions,
|
|
250
|
+
layout_shifting: this.layoutShifting,
|
|
251
|
+
pending_request_details: this.pendingRequestDetails.slice(),
|
|
252
|
+
timeline: this.timeline.slice(-20)
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
isStable: function() {
|
|
257
|
+
if (this.pendingRequests > 0) return false;
|
|
258
|
+
|
|
259
|
+
var timeSinceLastMutation = Date.now() - this.lastMutationTime;
|
|
260
|
+
if (timeSinceLastMutation < 100) return false;
|
|
261
|
+
|
|
262
|
+
return true;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
isAlive: function() {
|
|
266
|
+
return this._initialized === true;
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
// Cleanup (for testing)
|
|
270
|
+
destroy: function() {
|
|
271
|
+
this._observers.forEach(function(obs) {
|
|
272
|
+
obs.disconnect();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (this._originalFetch) {
|
|
276
|
+
window.fetch = this._originalFetch;
|
|
277
|
+
}
|
|
278
|
+
if (this._originalXHROpen) {
|
|
279
|
+
XMLHttpRequest.prototype.open = this._originalXHROpen;
|
|
280
|
+
}
|
|
281
|
+
if (this._originalXHRSend) {
|
|
282
|
+
XMLHttpRequest.prototype.send = this._originalXHRSend;
|
|
283
|
+
}
|
|
284
|
+
if (this._layoutCheckInterval) {
|
|
285
|
+
clearInterval(this._layoutCheckInterval);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this._initialized = false;
|
|
289
|
+
this._log('Waitless instrumentation destroyed');
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return window.__waitless__.init();
|
|
294
|
+
})();
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
# Script to check if instrumentation is alive
|
|
298
|
+
CHECK_ALIVE_SCRIPT = """
|
|
299
|
+
return window.__waitless__ && window.__waitless__.isAlive && window.__waitless__.isAlive();
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
# Script to get current stability status
|
|
303
|
+
GET_STATUS_SCRIPT = """
|
|
304
|
+
if (window.__waitless__ && window.__waitless__.getStatus) {
|
|
305
|
+
return window.__waitless__.getStatus();
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
# Script to get full timeline for diagnostics
|
|
311
|
+
GET_TIMELINE_SCRIPT = """
|
|
312
|
+
if (window.__waitless__) {
|
|
313
|
+
return {
|
|
314
|
+
timeline: window.__waitless__.timeline,
|
|
315
|
+
pending_request_details: window.__waitless__.pendingRequestDetails
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
# Script to update configuration
|
|
322
|
+
UPDATE_CONFIG_SCRIPT = """
|
|
323
|
+
if (window.__waitless__) {
|
|
324
|
+
window.__waitless__.config = Object.assign(window.__waitless__.config, arguments[0]);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
"""
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Selenium WebDriver integration layer.
|
|
3
|
+
|
|
4
|
+
Provides transparent stabilization for Selenium interactions through
|
|
5
|
+
the wrapper pattern (safer than monkey-patching).
|
|
6
|
+
|
|
7
|
+
Note: Wrapped elements behave like WebElements but are not identical.
|
|
8
|
+
This may affect equality checks or isinstance() calls in test code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Optional, Any, List, Dict, TYPE_CHECKING
|
|
14
|
+
from weakref import WeakValueDictionary
|
|
15
|
+
|
|
16
|
+
from .config import StabilizationConfig, DEFAULT_CONFIG
|
|
17
|
+
from .engine import StabilizationEngine
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from selenium.webdriver.remote.webdriver import WebDriver
|
|
22
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger('waitless')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_stabilized_drivers: WeakValueDictionary = WeakValueDictionary()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StabilizedWebElement:
|
|
32
|
+
"""
|
|
33
|
+
Wrapper around WebElement that auto-waits for stability before interactions.
|
|
34
|
+
|
|
35
|
+
This wrapper:
|
|
36
|
+
- Intercepts click(), send_keys(), submit(), clear() to wait for stability
|
|
37
|
+
- Delegates all other attributes/methods to the underlying element
|
|
38
|
+
- Preserves the original element for direct access if needed
|
|
39
|
+
|
|
40
|
+
IMPORTANT: This wrapper is NOT a WebElement subclass.
|
|
41
|
+
- isinstance(wrapped, WebElement) will return False
|
|
42
|
+
- Equality checks may behave unexpectedly
|
|
43
|
+
- Use .unwrap() to get the original element if needed
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
INTERACTION_METHODS = {'click', 'send_keys', 'submit', 'clear'}
|
|
47
|
+
|
|
48
|
+
def __init__(self, element: 'WebElement', engine: StabilizationEngine):
|
|
49
|
+
self._element = element
|
|
50
|
+
self._engine = engine
|
|
51
|
+
|
|
52
|
+
def __getattr__(self, name: str) -> Any:
|
|
53
|
+
"""Delegate attribute access to the underlying element."""
|
|
54
|
+
attr = getattr(self._element, name)
|
|
55
|
+
if name in self.INTERACTION_METHODS and callable(attr):
|
|
56
|
+
return self._create_stabilized_method(attr, name)
|
|
57
|
+
|
|
58
|
+
return attr
|
|
59
|
+
|
|
60
|
+
def _create_stabilized_method(self, method: callable, name: str) -> callable:
|
|
61
|
+
"""Create a wrapper that stabilizes before calling the method."""
|
|
62
|
+
@functools.wraps(method)
|
|
63
|
+
def stabilized_method(*args, **kwargs):
|
|
64
|
+
if self._engine.config.debug_mode:
|
|
65
|
+
logger.debug(f"[waitless] Stabilizing before {name}()")
|
|
66
|
+
|
|
67
|
+
self._engine.wait_for_stability()
|
|
68
|
+
return method(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
return stabilized_method
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def wrapped_element(self) -> 'WebElement':
|
|
74
|
+
"""Access the underlying WebElement directly."""
|
|
75
|
+
return self._element
|
|
76
|
+
|
|
77
|
+
def unwrap(self) -> 'WebElement':
|
|
78
|
+
"""Get the original WebElement (alias for wrapped_element)."""
|
|
79
|
+
return self._element
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return f"<StabilizedWebElement wrapping {self._element}>"
|
|
83
|
+
|
|
84
|
+
def __eq__(self, other: Any) -> bool:
|
|
85
|
+
"""Compare underlying elements for equality."""
|
|
86
|
+
if isinstance(other, StabilizedWebElement):
|
|
87
|
+
return self._element == other._element
|
|
88
|
+
return self._element == other
|
|
89
|
+
|
|
90
|
+
def __hash__(self) -> int:
|
|
91
|
+
return hash(self._element)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class StabilizedWebDriver:
|
|
95
|
+
"""
|
|
96
|
+
Wrapper around WebDriver that returns stabilized elements.
|
|
97
|
+
|
|
98
|
+
This wrapper:
|
|
99
|
+
- Wraps find_element/find_elements to return StabilizedWebElement
|
|
100
|
+
- Triggers stabilization before get() navigation
|
|
101
|
+
- Preserves all other WebDriver functionality
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, driver: 'WebDriver', engine: StabilizationEngine):
|
|
105
|
+
self._driver = driver
|
|
106
|
+
self._engine = engine
|
|
107
|
+
|
|
108
|
+
def __getattr__(self, name: str) -> Any:
|
|
109
|
+
"""Delegate attribute access to the underlying driver."""
|
|
110
|
+
attr = getattr(self._driver, name)
|
|
111
|
+
if name == 'find_element':
|
|
112
|
+
return self._stabilized_find_element
|
|
113
|
+
elif name == 'find_elements':
|
|
114
|
+
return self._stabilized_find_elements
|
|
115
|
+
|
|
116
|
+
return attr
|
|
117
|
+
|
|
118
|
+
def _stabilized_find_element(self, *args, **kwargs) -> StabilizedWebElement:
|
|
119
|
+
"""Find element and wrap it for stabilization."""
|
|
120
|
+
element = self._driver.find_element(*args, **kwargs)
|
|
121
|
+
return StabilizedWebElement(element, self._engine)
|
|
122
|
+
|
|
123
|
+
def _stabilized_find_elements(self, *args, **kwargs) -> List[StabilizedWebElement]:
|
|
124
|
+
"""Find elements and wrap them for stabilization."""
|
|
125
|
+
elements = self._driver.find_elements(*args, **kwargs)
|
|
126
|
+
return [StabilizedWebElement(el, self._engine) for el in elements]
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def unwrapped(self) -> 'WebDriver':
|
|
130
|
+
"""Access the underlying WebDriver directly."""
|
|
131
|
+
return self._driver
|
|
132
|
+
|
|
133
|
+
def wait_for_stability(self, timeout: Optional[float] = None):
|
|
134
|
+
"""Manually trigger stabilization."""
|
|
135
|
+
return self._engine.wait_for_stability(timeout)
|
|
136
|
+
|
|
137
|
+
def __repr__(self) -> str:
|
|
138
|
+
return f"<StabilizedWebDriver wrapping {self._driver}>"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SeleniumIntegration:
|
|
142
|
+
"""
|
|
143
|
+
Main integration class for Selenium.
|
|
144
|
+
|
|
145
|
+
Provides the stabilize() and unstabilize() functions.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self):
|
|
149
|
+
self._engines: Dict[int, StabilizationEngine] = {}
|
|
150
|
+
self._original_drivers: Dict[int, 'WebDriver'] = {}
|
|
151
|
+
self._wrapped_drivers: Dict[int, StabilizedWebDriver] = {}
|
|
152
|
+
|
|
153
|
+
def stabilize(
|
|
154
|
+
self,
|
|
155
|
+
driver: 'WebDriver',
|
|
156
|
+
config: Optional[StabilizationConfig] = None
|
|
157
|
+
) -> StabilizedWebDriver:
|
|
158
|
+
"""
|
|
159
|
+
Enable automatic stabilization for a WebDriver.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
driver: Selenium WebDriver instance
|
|
163
|
+
config: Optional configuration overrides
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
StabilizedWebDriver that auto-waits before interactions
|
|
167
|
+
|
|
168
|
+
Note:
|
|
169
|
+
The returned driver wraps the original but is not a true WebDriver.
|
|
170
|
+
If you need the original for framework integration, use .unwrapped
|
|
171
|
+
"""
|
|
172
|
+
driver_id = id(driver)
|
|
173
|
+
|
|
174
|
+
if driver_id in self._wrapped_drivers:
|
|
175
|
+
existing = self._wrapped_drivers[driver_id]
|
|
176
|
+
if config:
|
|
177
|
+
existing._engine.config = config
|
|
178
|
+
return existing
|
|
179
|
+
|
|
180
|
+
effective_config = config or DEFAULT_CONFIG
|
|
181
|
+
engine = StabilizationEngine(driver, effective_config)
|
|
182
|
+
wrapped = StabilizedWebDriver(driver, engine)
|
|
183
|
+
|
|
184
|
+
self._engines[driver_id] = engine
|
|
185
|
+
self._original_drivers[driver_id] = driver
|
|
186
|
+
self._wrapped_drivers[driver_id] = wrapped
|
|
187
|
+
|
|
188
|
+
if effective_config.debug_mode:
|
|
189
|
+
logger.info(f"[waitless] Stabilization enabled for driver {driver_id}")
|
|
190
|
+
|
|
191
|
+
return wrapped
|
|
192
|
+
|
|
193
|
+
def unstabilize(self, driver: 'WebDriver') -> 'WebDriver':
|
|
194
|
+
"""
|
|
195
|
+
Disable stabilization and return the original driver.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
driver: Either the original driver or a StabilizedWebDriver
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The original unwrapped WebDriver
|
|
202
|
+
"""
|
|
203
|
+
if isinstance(driver, StabilizedWebDriver):
|
|
204
|
+
original = driver.unwrapped
|
|
205
|
+
driver_id = id(original)
|
|
206
|
+
else:
|
|
207
|
+
original = driver
|
|
208
|
+
driver_id = id(driver)
|
|
209
|
+
|
|
210
|
+
self._engines.pop(driver_id, None)
|
|
211
|
+
self._original_drivers.pop(driver_id, None)
|
|
212
|
+
self._wrapped_drivers.pop(driver_id, None)
|
|
213
|
+
|
|
214
|
+
logger.info(f"[waitless] Stabilization disabled for driver {driver_id}")
|
|
215
|
+
|
|
216
|
+
return original
|
|
217
|
+
|
|
218
|
+
def get_engine(self, driver: 'WebDriver') -> Optional[StabilizationEngine]:
|
|
219
|
+
"""Get the engine for a driver (for diagnostics)."""
|
|
220
|
+
if isinstance(driver, StabilizedWebDriver):
|
|
221
|
+
return driver._engine
|
|
222
|
+
return self._engines.get(id(driver))
|
|
223
|
+
|
|
224
|
+
def is_stabilized(self, driver: 'WebDriver') -> bool:
|
|
225
|
+
"""Check if a driver is currently stabilized."""
|
|
226
|
+
if isinstance(driver, StabilizedWebDriver):
|
|
227
|
+
return True
|
|
228
|
+
return id(driver) in self._wrapped_drivers
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_integration = SeleniumIntegration()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def stabilize(
|
|
235
|
+
driver: 'WebDriver',
|
|
236
|
+
config: Optional[StabilizationConfig] = None
|
|
237
|
+
) -> StabilizedWebDriver:
|
|
238
|
+
"""
|
|
239
|
+
Enable automatic stabilization for a WebDriver.
|
|
240
|
+
|
|
241
|
+
This is the main entry point for waitless.
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
from waitless import stabilize
|
|
245
|
+
|
|
246
|
+
driver = webdriver.Chrome()
|
|
247
|
+
driver = stabilize(driver) # Now auto-waits!
|
|
248
|
+
|
|
249
|
+
driver.find_element(By.ID, "button").click() # Auto-stabilizes
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
driver: Selenium WebDriver instance
|
|
253
|
+
config: Optional StabilizationConfig for customization
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
StabilizedWebDriver that auto-waits before interactions
|
|
257
|
+
"""
|
|
258
|
+
return _integration.stabilize(driver, config)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def unstabilize(driver: 'WebDriver') -> 'WebDriver':
|
|
262
|
+
"""
|
|
263
|
+
Disable stabilization and return the original driver.
|
|
264
|
+
|
|
265
|
+
Example:
|
|
266
|
+
driver = unstabilize(driver) # Back to normal
|
|
267
|
+
"""
|
|
268
|
+
return _integration.unstabilize(driver)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def wait_for_stability(
|
|
272
|
+
driver: 'WebDriver',
|
|
273
|
+
timeout: Optional[float] = None
|
|
274
|
+
) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Manually wait for UI stability.
|
|
277
|
+
|
|
278
|
+
Use this for explicit stabilization without wrapping interactions.
|
|
279
|
+
|
|
280
|
+
Example:
|
|
281
|
+
wait_for_stability(driver)
|
|
282
|
+
driver.find_element(By.ID, "button").click()
|
|
283
|
+
"""
|
|
284
|
+
if isinstance(driver, StabilizedWebDriver):
|
|
285
|
+
driver.wait_for_stability(timeout)
|
|
286
|
+
else:
|
|
287
|
+
engine = StabilizationEngine(driver)
|
|
288
|
+
engine.wait_for_stability(timeout)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_diagnostics(driver: 'WebDriver') -> Optional[Dict[str, Any]]:
|
|
292
|
+
"""Get diagnostic information for a stabilized driver."""
|
|
293
|
+
engine = _integration.get_engine(driver)
|
|
294
|
+
if engine:
|
|
295
|
+
return engine.get_diagnostics()
|
|
296
|
+
return None
|