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.
@@ -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