waitless 0.3.2__tar.gz → 1.0.0__tar.gz
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-0.3.2/waitless.egg-info → waitless-1.0.0}/PKG-INFO +44 -4
- {waitless-0.3.2 → waitless-1.0.0}/README.md +43 -3
- {waitless-0.3.2 → waitless-1.0.0}/pyproject.toml +1 -1
- {waitless-0.3.2 → waitless-1.0.0}/waitless/__init__.py +1 -1
- {waitless-0.3.2 → waitless-1.0.0}/waitless/__main__.py +6 -1
- waitless-1.0.0/waitless/adapters/__init__.py +20 -0
- waitless-1.0.0/waitless/adapters/angular.py +109 -0
- waitless-1.0.0/waitless/adapters/base.py +73 -0
- waitless-1.0.0/waitless/adapters/react.py +111 -0
- waitless-1.0.0/waitless/adapters/vue.py +117 -0
- {waitless-0.3.2 → waitless-1.0.0}/waitless/config.py +27 -1
- {waitless-0.3.2 → waitless-1.0.0}/waitless/diagnostics.py +26 -0
- {waitless-0.3.2 → waitless-1.0.0}/waitless/instrumentation.py +223 -1
- {waitless-0.3.2 → waitless-1.0.0}/waitless/signals.py +61 -0
- {waitless-0.3.2 → waitless-1.0.0/waitless.egg-info}/PKG-INFO +44 -4
- {waitless-0.3.2 → waitless-1.0.0}/waitless.egg-info/SOURCES.txt +6 -1
- {waitless-0.3.2 → waitless-1.0.0}/LICENSE +0 -0
- {waitless-0.3.2 → waitless-1.0.0}/setup.cfg +0 -0
- {waitless-0.3.2 → waitless-1.0.0}/waitless/engine.py +0 -0
- {waitless-0.3.2 → waitless-1.0.0}/waitless/exceptions.py +0 -0
- {waitless-0.3.2 → waitless-1.0.0}/waitless/selenium_integration.py +0 -0
- {waitless-0.3.2 → waitless-1.0.0}/waitless.egg-info/dependency_links.txt +0 -0
- {waitless-0.3.2 → waitless-1.0.0}/waitless.egg-info/entry_points.txt +0 -0
- {waitless-0.3.2 → waitless-1.0.0}/waitless.egg-info/requires.txt +0 -0
- {waitless-0.3.2 → waitless-1.0.0}/waitless.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: waitless
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Eliminate explicit waits in UI automation by detecting true UI stability
|
|
5
5
|
Author-email: Dhiraj Das <dhirajdas.666@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -36,6 +36,8 @@ Dynamic: license-file
|
|
|
36
36
|
|
|
37
37
|
Eliminate explicit waits and sleeps by automatically detecting true UI stability.
|
|
38
38
|
|
|
39
|
+
|
|
40
|
+
|
|
39
41
|
## Installation
|
|
40
42
|
|
|
41
43
|
```bash
|
|
@@ -88,6 +90,9 @@ Waitless monitors the **entire page** for stability signals:
|
|
|
88
90
|
- ✅ Pending network requests (XHR/fetch interception)
|
|
89
91
|
- ✅ CSS animations and transitions
|
|
90
92
|
- ✅ Layout stability (element movement)
|
|
93
|
+
- ✅ WebSocket/SSE activity (opt-in)
|
|
94
|
+
- ✅ Framework hooks (React/Angular/Vue, opt-in)
|
|
95
|
+
- ✅ iframe monitoring (opt-in)
|
|
91
96
|
|
|
92
97
|
When you interact, waitless ensures the page is truly ready.
|
|
93
98
|
|
|
@@ -214,11 +219,46 @@ element = driver.find_element(By.ID, "button")
|
|
|
214
219
|
original = element.unwrap() # Gets the real WebElement
|
|
215
220
|
```
|
|
216
221
|
|
|
217
|
-
##
|
|
222
|
+
## v1.0.0 New Features
|
|
223
|
+
|
|
224
|
+
- **WebSocket/SSE Awareness** - Track WebSocket and Server-Sent Events activity
|
|
225
|
+
- **Framework Adapters** - React, Angular, Vue hooks for framework-specific settling
|
|
226
|
+
- **iframe Support** - Monitor same-origin iframes
|
|
227
|
+
- **Performance Benchmarks** - Built-in benchmark suite
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
# Enable new v1.0 features
|
|
231
|
+
config = StabilizationConfig(
|
|
232
|
+
track_websocket=True, # WebSocket monitoring
|
|
233
|
+
track_sse=True, # SSE monitoring
|
|
234
|
+
framework_hooks=['react'], # React adapter
|
|
235
|
+
track_iframes=True, # iframe monitoring
|
|
236
|
+
)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Performance
|
|
240
|
+
|
|
241
|
+
| Metric | Typical Value |
|
|
242
|
+
|--------|---------------|
|
|
243
|
+
| Instrumentation injection | ~5-10ms |
|
|
244
|
+
| Per-poll overhead | ~1-2ms |
|
|
245
|
+
| Poll interval (default) | 50ms |
|
|
246
|
+
| Typical stabilization | 50-200ms after activity |
|
|
247
|
+
|
|
248
|
+
### SPA Navigation Handling
|
|
249
|
+
|
|
250
|
+
Waitless automatically re-injects instrumentation after SPA route changes:
|
|
251
|
+
|
|
252
|
+
1. Checks `__waitless__.isAlive()` before each wait
|
|
253
|
+
2. Detects URL changes via `driver.current_url`
|
|
254
|
+
3. Re-injects if instrumentation is missing
|
|
255
|
+
|
|
256
|
+
This works transparently with React Router, Vue Router, Angular Router, etc.
|
|
257
|
+
|
|
258
|
+
## Current Limitations
|
|
218
259
|
|
|
219
|
-
- **Selenium only** - Playwright support planned
|
|
260
|
+
- **Selenium only** - Playwright support planned
|
|
220
261
|
- **Sync only** - No async/await support yet
|
|
221
|
-
- **Main frame only** - iframes not monitored
|
|
222
262
|
- **No Service Workers** - SW network requests not intercepted
|
|
223
263
|
|
|
224
264
|
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
Eliminate explicit waits and sleeps by automatically detecting true UI stability.
|
|
8
8
|
|
|
9
|
+
|
|
10
|
+
|
|
9
11
|
## Installation
|
|
10
12
|
|
|
11
13
|
```bash
|
|
@@ -58,6 +60,9 @@ Waitless monitors the **entire page** for stability signals:
|
|
|
58
60
|
- ✅ Pending network requests (XHR/fetch interception)
|
|
59
61
|
- ✅ CSS animations and transitions
|
|
60
62
|
- ✅ Layout stability (element movement)
|
|
63
|
+
- ✅ WebSocket/SSE activity (opt-in)
|
|
64
|
+
- ✅ Framework hooks (React/Angular/Vue, opt-in)
|
|
65
|
+
- ✅ iframe monitoring (opt-in)
|
|
61
66
|
|
|
62
67
|
When you interact, waitless ensures the page is truly ready.
|
|
63
68
|
|
|
@@ -184,11 +189,46 @@ element = driver.find_element(By.ID, "button")
|
|
|
184
189
|
original = element.unwrap() # Gets the real WebElement
|
|
185
190
|
```
|
|
186
191
|
|
|
187
|
-
##
|
|
192
|
+
## v1.0.0 New Features
|
|
193
|
+
|
|
194
|
+
- **WebSocket/SSE Awareness** - Track WebSocket and Server-Sent Events activity
|
|
195
|
+
- **Framework Adapters** - React, Angular, Vue hooks for framework-specific settling
|
|
196
|
+
- **iframe Support** - Monitor same-origin iframes
|
|
197
|
+
- **Performance Benchmarks** - Built-in benchmark suite
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
# Enable new v1.0 features
|
|
201
|
+
config = StabilizationConfig(
|
|
202
|
+
track_websocket=True, # WebSocket monitoring
|
|
203
|
+
track_sse=True, # SSE monitoring
|
|
204
|
+
framework_hooks=['react'], # React adapter
|
|
205
|
+
track_iframes=True, # iframe monitoring
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Performance
|
|
210
|
+
|
|
211
|
+
| Metric | Typical Value |
|
|
212
|
+
|--------|---------------|
|
|
213
|
+
| Instrumentation injection | ~5-10ms |
|
|
214
|
+
| Per-poll overhead | ~1-2ms |
|
|
215
|
+
| Poll interval (default) | 50ms |
|
|
216
|
+
| Typical stabilization | 50-200ms after activity |
|
|
217
|
+
|
|
218
|
+
### SPA Navigation Handling
|
|
219
|
+
|
|
220
|
+
Waitless automatically re-injects instrumentation after SPA route changes:
|
|
221
|
+
|
|
222
|
+
1. Checks `__waitless__.isAlive()` before each wait
|
|
223
|
+
2. Detects URL changes via `driver.current_url`
|
|
224
|
+
3. Re-injects if instrumentation is missing
|
|
225
|
+
|
|
226
|
+
This works transparently with React Router, Vue Router, Angular Router, etc.
|
|
227
|
+
|
|
228
|
+
## Current Limitations
|
|
188
229
|
|
|
189
|
-
- **Selenium only** - Playwright support planned
|
|
230
|
+
- **Selenium only** - Playwright support planned
|
|
190
231
|
- **Sync only** - No async/await support yet
|
|
191
|
-
- **Main frame only** - iframes not monitored
|
|
192
232
|
- **No Service Workers** - SW network requests not intercepted
|
|
193
233
|
|
|
194
234
|
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
|
@@ -72,11 +72,16 @@ def run_doctor(args):
|
|
|
72
72
|
else:
|
|
73
73
|
# Show usage instructions
|
|
74
74
|
print("+-" + "-" * 66 + "-+")
|
|
75
|
-
print("|" + "WAITLESS DOCTOR".center(66) + "|")
|
|
75
|
+
print("|" + "WAITLESS DOCTOR v1.0".center(66) + "|")
|
|
76
76
|
print("+-" + "-" * 66 + "-+")
|
|
77
77
|
print("|".ljust(67) + "|")
|
|
78
78
|
print("| The doctor command analyzes stability diagnostics.".ljust(67) + "|")
|
|
79
79
|
print("|".ljust(67) + "|")
|
|
80
|
+
print("| v1.0 FEATURES:".ljust(67) + "|")
|
|
81
|
+
print("| - WebSocket/SSE tracking (track_websocket, track_sse)".ljust(67) + "|")
|
|
82
|
+
print("| - Framework adapters (framework_hooks=['react','angular','vue'])".ljust(67) + "|")
|
|
83
|
+
print("| - iframe monitoring (track_iframes)".ljust(67) + "|")
|
|
84
|
+
print("|".ljust(67) + "|")
|
|
80
85
|
print("| USAGE OPTIONS:".ljust(67) + "|")
|
|
81
86
|
print("|".ljust(67) + "|")
|
|
82
87
|
print("| 1. From a diagnostic file:".ljust(67) + "|")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Waitless Framework Adapters
|
|
3
|
+
|
|
4
|
+
This module provides optional framework-specific hooks for detecting
|
|
5
|
+
when React, Angular, or Vue have finished settling their internal work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .base import FrameworkAdapter, get_adapter, get_available_adapters
|
|
9
|
+
from .react import ReactAdapter
|
|
10
|
+
from .angular import AngularAdapter
|
|
11
|
+
from .vue import VueAdapter
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
'FrameworkAdapter',
|
|
15
|
+
'get_adapter',
|
|
16
|
+
'get_available_adapters',
|
|
17
|
+
'ReactAdapter',
|
|
18
|
+
'AngularAdapter',
|
|
19
|
+
'VueAdapter',
|
|
20
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Angular framework adapter for detecting Angular-specific settling work.
|
|
3
|
+
|
|
4
|
+
Detects Angular by looking for ng-version attribute or NgZone.
|
|
5
|
+
Monitors NgZone stability to detect when Angular has finished its work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .base import FrameworkAdapter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AngularAdapter(FrameworkAdapter):
|
|
12
|
+
"""
|
|
13
|
+
Adapter for Angular framework.
|
|
14
|
+
|
|
15
|
+
Hooks into Angular's NgZone to detect when all async tasks have completed
|
|
16
|
+
and Angular is in a stable state.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def name(self) -> str:
|
|
21
|
+
return 'angular'
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def detection_script(self) -> str:
|
|
25
|
+
return """
|
|
26
|
+
(function() {
|
|
27
|
+
// Check for Angular version attribute
|
|
28
|
+
if (document.querySelector('[ng-version]')) return true;
|
|
29
|
+
|
|
30
|
+
// Check for Angular global (older versions)
|
|
31
|
+
if (window.ng) return true;
|
|
32
|
+
|
|
33
|
+
// Check for getAllAngularRootElements
|
|
34
|
+
if (window.getAllAngularRootElements) return true;
|
|
35
|
+
|
|
36
|
+
return false;
|
|
37
|
+
})();
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def instrumentation_script(self) -> str:
|
|
42
|
+
return """
|
|
43
|
+
(function() {
|
|
44
|
+
if (!window.__waitless__) return false;
|
|
45
|
+
if (window.__waitless__._angularHooked) return true;
|
|
46
|
+
|
|
47
|
+
window.__waitless__.framework = window.__waitless__.framework || {};
|
|
48
|
+
window.__waitless__.framework.angular = {
|
|
49
|
+
isStable: true,
|
|
50
|
+
lastStableTime: Date.now(),
|
|
51
|
+
pendingTasks: 0
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Try to get NgZone from Angular's testability API
|
|
55
|
+
var testability = window.getAllAngularTestabilities && window.getAllAngularTestabilities();
|
|
56
|
+
if (testability && testability.length > 0) {
|
|
57
|
+
testability.forEach(function(t) {
|
|
58
|
+
t.whenStable(function() {
|
|
59
|
+
window.__waitless__.framework.angular.isStable = true;
|
|
60
|
+
window.__waitless__.framework.angular.lastStableTime = Date.now();
|
|
61
|
+
window.__waitless__._log('Angular became stable');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Also try to hook into Zone.js if available
|
|
67
|
+
if (window.Zone && window.Zone.current) {
|
|
68
|
+
var originalRun = Zone.prototype.run;
|
|
69
|
+
Zone.prototype.run = function(callback, applyThis, applyArgs) {
|
|
70
|
+
if (this.name === 'angular') {
|
|
71
|
+
window.__waitless__.framework.angular.isStable = false;
|
|
72
|
+
window.__waitless__.framework.angular.pendingTasks++;
|
|
73
|
+
}
|
|
74
|
+
var result = originalRun.apply(this, arguments);
|
|
75
|
+
if (this.name === 'angular') {
|
|
76
|
+
window.__waitless__.framework.angular.pendingTasks--;
|
|
77
|
+
if (window.__waitless__.framework.angular.pendingTasks === 0) {
|
|
78
|
+
window.__waitless__.framework.angular.isStable = true;
|
|
79
|
+
window.__waitless__.framework.angular.lastStableTime = Date.now();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
window.__waitless__._angularHooked = true;
|
|
87
|
+
window.__waitless__._log('Angular adapter installed');
|
|
88
|
+
return true;
|
|
89
|
+
})();
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def get_status_script(self) -> str:
|
|
93
|
+
return """
|
|
94
|
+
(function() {
|
|
95
|
+
if (!window.__waitless__ || !window.__waitless__.framework || !window.__waitless__.framework.angular) {
|
|
96
|
+
return { stable: true, details: 'Angular not detected' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
var angular = window.__waitless__.framework.angular;
|
|
100
|
+
var timeSinceStable = Date.now() - angular.lastStableTime;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
stable: angular.isStable,
|
|
104
|
+
details: angular.isStable
|
|
105
|
+
? 'NgZone stable, last activity ' + timeSinceStable + 'ms ago'
|
|
106
|
+
: 'NgZone unstable, pending=' + angular.pendingTasks
|
|
107
|
+
};
|
|
108
|
+
})();
|
|
109
|
+
"""
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base adapter interface for framework-specific hooks.
|
|
3
|
+
|
|
4
|
+
Adapters detect when a framework has finished its internal work
|
|
5
|
+
(rendering, effects, zone tasks) beyond what DOM observation captures.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FrameworkAdapter(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Base class for framework-specific stability detection.
|
|
15
|
+
|
|
16
|
+
Subclasses implement framework-specific JavaScript that detects
|
|
17
|
+
when the framework has finished its internal processing.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
"""Unique identifier for this adapter (e.g., 'react', 'angular')."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def detection_script(self) -> str:
|
|
29
|
+
"""
|
|
30
|
+
JavaScript that detects if this framework is present on the page.
|
|
31
|
+
Should return true if the framework is detected.
|
|
32
|
+
"""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def instrumentation_script(self) -> str:
|
|
38
|
+
"""
|
|
39
|
+
JavaScript to inject for monitoring framework activity.
|
|
40
|
+
Should set window.__waitless__.framework[name] with status info.
|
|
41
|
+
"""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def get_status_script(self) -> str:
|
|
46
|
+
"""
|
|
47
|
+
JavaScript that returns the current framework stability status.
|
|
48
|
+
Should return { stable: bool, details: string }.
|
|
49
|
+
"""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_adapter(name: str) -> Optional['FrameworkAdapter']:
|
|
54
|
+
"""Get a framework adapter by name."""
|
|
55
|
+
# Lazy imports to avoid circular dependency
|
|
56
|
+
from .react import ReactAdapter
|
|
57
|
+
from .angular import AngularAdapter
|
|
58
|
+
from .vue import VueAdapter
|
|
59
|
+
|
|
60
|
+
adapters = {
|
|
61
|
+
'react': ReactAdapter,
|
|
62
|
+
'angular': AngularAdapter,
|
|
63
|
+
'vue': VueAdapter,
|
|
64
|
+
}
|
|
65
|
+
adapter_class = adapters.get(name.lower())
|
|
66
|
+
if adapter_class:
|
|
67
|
+
return adapter_class()
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_available_adapters() -> list:
|
|
72
|
+
"""List available adapter names."""
|
|
73
|
+
return ['react', 'angular', 'vue']
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
React framework adapter for detecting React-specific settling work.
|
|
3
|
+
|
|
4
|
+
Detects React by looking for React DevTools hook or __REACT_DEVTOOLS_GLOBAL_HOOK__.
|
|
5
|
+
Monitors React commits and batch updates to detect when React has finished rendering.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .base import FrameworkAdapter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReactAdapter(FrameworkAdapter):
|
|
12
|
+
"""
|
|
13
|
+
Adapter for React framework.
|
|
14
|
+
|
|
15
|
+
Hooks into React's commit phase via the DevTools global hook to detect
|
|
16
|
+
when React has finished reconciliation and committed changes to the DOM.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def name(self) -> str:
|
|
21
|
+
return 'react'
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def detection_script(self) -> str:
|
|
25
|
+
return """
|
|
26
|
+
(function() {
|
|
27
|
+
// Check for React DevTools hook (most reliable)
|
|
28
|
+
if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__) return true;
|
|
29
|
+
|
|
30
|
+
// Check for React fiber root on body
|
|
31
|
+
var root = document.getElementById('root') || document.body;
|
|
32
|
+
for (var key in root) {
|
|
33
|
+
if (key.startsWith('__reactFiber') || key.startsWith('__reactContainer')) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return false;
|
|
39
|
+
})();
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def instrumentation_script(self) -> str:
|
|
44
|
+
return """
|
|
45
|
+
(function() {
|
|
46
|
+
if (!window.__waitless__) return false;
|
|
47
|
+
if (window.__waitless__._reactHooked) return true;
|
|
48
|
+
|
|
49
|
+
window.__waitless__.framework = window.__waitless__.framework || {};
|
|
50
|
+
window.__waitless__.framework.react = {
|
|
51
|
+
lastCommitTime: 0,
|
|
52
|
+
pendingUpdates: 0,
|
|
53
|
+
isSettled: true
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
57
|
+
if (hook && hook.onCommitFiberRoot) {
|
|
58
|
+
var originalOnCommit = hook.onCommitFiberRoot;
|
|
59
|
+
hook.onCommitFiberRoot = function(id, root, priority) {
|
|
60
|
+
window.__waitless__.framework.react.lastCommitTime = Date.now();
|
|
61
|
+
window.__waitless__.framework.react.isSettled = false;
|
|
62
|
+
window.__waitless__._log('React commit', { priority: priority });
|
|
63
|
+
|
|
64
|
+
// Mark as settled after a short delay (microtask completion)
|
|
65
|
+
setTimeout(function() {
|
|
66
|
+
window.__waitless__.framework.react.isSettled = true;
|
|
67
|
+
}, 50);
|
|
68
|
+
|
|
69
|
+
return originalOnCommit.apply(this, arguments);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Also try to intercept React's scheduler if available
|
|
74
|
+
if (window.scheduler && window.scheduler.unstable_scheduleCallback) {
|
|
75
|
+
var originalSchedule = window.scheduler.unstable_scheduleCallback;
|
|
76
|
+
window.scheduler.unstable_scheduleCallback = function(priority, callback) {
|
|
77
|
+
window.__waitless__.framework.react.pendingUpdates++;
|
|
78
|
+
var wrappedCallback = function() {
|
|
79
|
+
var result = callback.apply(this, arguments);
|
|
80
|
+
window.__waitless__.framework.react.pendingUpdates--;
|
|
81
|
+
return result;
|
|
82
|
+
};
|
|
83
|
+
return originalSchedule.call(this, priority, wrappedCallback);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
window.__waitless__._reactHooked = true;
|
|
88
|
+
window.__waitless__._log('React adapter installed');
|
|
89
|
+
return true;
|
|
90
|
+
})();
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def get_status_script(self) -> str:
|
|
94
|
+
return """
|
|
95
|
+
(function() {
|
|
96
|
+
if (!window.__waitless__ || !window.__waitless__.framework || !window.__waitless__.framework.react) {
|
|
97
|
+
return { stable: true, details: 'React not detected' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
var react = window.__waitless__.framework.react;
|
|
101
|
+
var timeSinceCommit = Date.now() - react.lastCommitTime;
|
|
102
|
+
var isStable = react.isSettled && timeSinceCommit > 100;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
stable: isStable,
|
|
106
|
+
details: isStable
|
|
107
|
+
? 'React idle, last commit ' + timeSinceCommit + 'ms ago'
|
|
108
|
+
: 'React updating, pending=' + react.pendingUpdates
|
|
109
|
+
};
|
|
110
|
+
})();
|
|
111
|
+
"""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vue framework adapter for detecting Vue-specific settling work.
|
|
3
|
+
|
|
4
|
+
Detects Vue by looking for __vue__ property or Vue DevTools hook.
|
|
5
|
+
Monitors Vue's nextTick queue to detect when Vue has finished updating.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .base import FrameworkAdapter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VueAdapter(FrameworkAdapter):
|
|
12
|
+
"""
|
|
13
|
+
Adapter for Vue framework (Vue 2 and Vue 3).
|
|
14
|
+
|
|
15
|
+
Hooks into Vue's nextTick mechanism and watcher queue to detect
|
|
16
|
+
when Vue has finished processing reactive updates.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def name(self) -> str:
|
|
21
|
+
return 'vue'
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def detection_script(self) -> str:
|
|
25
|
+
return """
|
|
26
|
+
(function() {
|
|
27
|
+
// Check for Vue DevTools hook
|
|
28
|
+
if (window.__VUE_DEVTOOLS_GLOBAL_HOOK__) return true;
|
|
29
|
+
|
|
30
|
+
// Check for Vue 3 app
|
|
31
|
+
if (window.__VUE__) return true;
|
|
32
|
+
|
|
33
|
+
// Check for Vue 2 instances on elements
|
|
34
|
+
var elements = document.querySelectorAll('[data-v-app], [id="app"]');
|
|
35
|
+
for (var i = 0; i < elements.length; i++) {
|
|
36
|
+
if (elements[i].__vue__ || elements[i].__vue_app__) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return false;
|
|
42
|
+
})();
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def instrumentation_script(self) -> str:
|
|
47
|
+
return """
|
|
48
|
+
(function() {
|
|
49
|
+
if (!window.__waitless__) return false;
|
|
50
|
+
if (window.__waitless__._vueHooked) return true;
|
|
51
|
+
|
|
52
|
+
window.__waitless__.framework = window.__waitless__.framework || {};
|
|
53
|
+
window.__waitless__.framework.vue = {
|
|
54
|
+
lastUpdateTime: 0,
|
|
55
|
+
pendingTicks: 0,
|
|
56
|
+
isSettled: true
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
var hook = window.__VUE_DEVTOOLS_GLOBAL_HOOK__;
|
|
60
|
+
if (hook) {
|
|
61
|
+
// Vue 3 DevTools hook
|
|
62
|
+
hook.on && hook.on('component:updated', function() {
|
|
63
|
+
window.__waitless__.framework.vue.lastUpdateTime = Date.now();
|
|
64
|
+
window.__waitless__.framework.vue.isSettled = false;
|
|
65
|
+
window.__waitless__._log('Vue component updated');
|
|
66
|
+
|
|
67
|
+
setTimeout(function() {
|
|
68
|
+
window.__waitless__.framework.vue.isSettled = true;
|
|
69
|
+
}, 50);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Vue 2 compatibility
|
|
73
|
+
if (hook.Vue && hook.Vue.nextTick) {
|
|
74
|
+
var originalNextTick = hook.Vue.nextTick;
|
|
75
|
+
hook.Vue.nextTick = function(callback, context) {
|
|
76
|
+
window.__waitless__.framework.vue.pendingTicks++;
|
|
77
|
+
window.__waitless__.framework.vue.isSettled = false;
|
|
78
|
+
|
|
79
|
+
var wrappedCallback = function() {
|
|
80
|
+
window.__waitless__.framework.vue.pendingTicks--;
|
|
81
|
+
window.__waitless__.framework.vue.lastUpdateTime = Date.now();
|
|
82
|
+
if (window.__waitless__.framework.vue.pendingTicks === 0) {
|
|
83
|
+
window.__waitless__.framework.vue.isSettled = true;
|
|
84
|
+
}
|
|
85
|
+
if (callback) callback.apply(this, arguments);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return originalNextTick.call(this, wrappedCallback, context);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
window.__waitless__._vueHooked = true;
|
|
94
|
+
window.__waitless__._log('Vue adapter installed');
|
|
95
|
+
return true;
|
|
96
|
+
})();
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def get_status_script(self) -> str:
|
|
100
|
+
return """
|
|
101
|
+
(function() {
|
|
102
|
+
if (!window.__waitless__ || !window.__waitless__.framework || !window.__waitless__.framework.vue) {
|
|
103
|
+
return { stable: true, details: 'Vue not detected' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
var vue = window.__waitless__.framework.vue;
|
|
107
|
+
var timeSinceUpdate = Date.now() - vue.lastUpdateTime;
|
|
108
|
+
var isStable = vue.isSettled && timeSinceUpdate > 100;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
stable: isStable,
|
|
112
|
+
details: isStable
|
|
113
|
+
? 'Vue idle, last update ' + timeSinceUpdate + 'ms ago'
|
|
114
|
+
: 'Vue updating, pending ticks=' + vue.pendingTicks
|
|
115
|
+
};
|
|
116
|
+
})();
|
|
117
|
+
"""
|
|
@@ -5,7 +5,7 @@ Provides sensible defaults with full customization options.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
|
-
from typing import Literal, Optional
|
|
8
|
+
from typing import Literal, Optional, List
|
|
9
9
|
from .exceptions import ConfigurationError
|
|
10
10
|
|
|
11
11
|
|
|
@@ -52,6 +52,22 @@ class StabilizationConfig:
|
|
|
52
52
|
|
|
53
53
|
reinject_on_navigation: Auto-reinject instrumentation after navigation.
|
|
54
54
|
Default True.
|
|
55
|
+
|
|
56
|
+
track_websocket: Whether to monitor WebSocket connections.
|
|
57
|
+
Default False. Enable for apps using WebSocket.
|
|
58
|
+
|
|
59
|
+
track_sse: Whether to monitor Server-Sent Events (EventSource).
|
|
60
|
+
Default False. Enable for apps using SSE.
|
|
61
|
+
|
|
62
|
+
websocket_quiet_time: Time (seconds) WS/SSE must be quiet for stability.
|
|
63
|
+
Default 0.5s. Only used when track_websocket/track_sse is True.
|
|
64
|
+
|
|
65
|
+
framework_hooks: List of framework adapters to use for stability detection.
|
|
66
|
+
Options: 'react', 'angular', 'vue'. Default empty (auto-detect off).
|
|
67
|
+
When specified, waitless will inject framework-specific hooks.
|
|
68
|
+
|
|
69
|
+
track_iframes: Whether to inject instrumentation into same-origin iframes.
|
|
70
|
+
Default False. Cross-origin iframes cannot be accessed.
|
|
55
71
|
"""
|
|
56
72
|
|
|
57
73
|
timeout: float = 10.0
|
|
@@ -64,6 +80,11 @@ class StabilizationConfig:
|
|
|
64
80
|
debug_mode: bool = False
|
|
65
81
|
poll_interval: float = 0.05
|
|
66
82
|
reinject_on_navigation: bool = True
|
|
83
|
+
track_websocket: bool = False # WebSocket monitoring (opt-in)
|
|
84
|
+
track_sse: bool = False # SSE/EventSource monitoring (opt-in)
|
|
85
|
+
websocket_quiet_time: float = 0.5 # Seconds of WS/SSE silence for stability
|
|
86
|
+
framework_hooks: List[str] = field(default_factory=list) # ['react', 'angular', 'vue']
|
|
87
|
+
track_iframes: bool = False # Same-origin iframe monitoring (opt-in)
|
|
67
88
|
|
|
68
89
|
def __post_init__(self):
|
|
69
90
|
"""Validate configuration values."""
|
|
@@ -125,6 +146,11 @@ class StabilizationConfig:
|
|
|
125
146
|
'debug_mode': self.debug_mode,
|
|
126
147
|
'poll_interval': self.poll_interval,
|
|
127
148
|
'reinject_on_navigation': self.reinject_on_navigation,
|
|
149
|
+
'track_websocket': self.track_websocket,
|
|
150
|
+
'track_sse': self.track_sse,
|
|
151
|
+
'websocket_quiet_time': self.websocket_quiet_time,
|
|
152
|
+
'framework_hooks': self.framework_hooks.copy(),
|
|
153
|
+
'track_iframes': self.track_iframes,
|
|
128
154
|
}
|
|
129
155
|
current.update(kwargs)
|
|
130
156
|
return StabilizationConfig(**current)
|
|
@@ -65,6 +65,32 @@ class DiagnosticReport:
|
|
|
65
65
|
if blocking.get('layout_shifting'):
|
|
66
66
|
lines.append("| [!] LAYOUT: Elements are still moving".ljust(67) + "|")
|
|
67
67
|
lines.append("|".ljust(67) + "|")
|
|
68
|
+
|
|
69
|
+
# WebSocket connections
|
|
70
|
+
ws_count = blocking.get('active_websockets', 0)
|
|
71
|
+
if ws_count > 0:
|
|
72
|
+
lines.append(f"| [i] WEBSOCKET: {ws_count} active connection(s)".ljust(67) + "|")
|
|
73
|
+
ws_details = blocking.get('websocket_details', [])
|
|
74
|
+
for ws in ws_details[:3]:
|
|
75
|
+
url = ws.get('url', 'unknown')[:45]
|
|
76
|
+
state = ws.get('state', 'unknown')
|
|
77
|
+
lines.append(f"| -> {state.upper()} {url}".ljust(67) + "|")
|
|
78
|
+
if len(ws_details) > 3:
|
|
79
|
+
lines.append(f"| ... and {len(ws_details) - 3} more".ljust(67) + "|")
|
|
80
|
+
lines.append("|".ljust(67) + "|")
|
|
81
|
+
|
|
82
|
+
# SSE connections
|
|
83
|
+
sse_count = blocking.get('active_sse', 0)
|
|
84
|
+
if sse_count > 0:
|
|
85
|
+
lines.append(f"| [i] SSE: {sse_count} active connection(s)".ljust(67) + "|")
|
|
86
|
+
sse_details = blocking.get('sse_details', [])
|
|
87
|
+
for sse in sse_details[:3]:
|
|
88
|
+
url = sse.get('url', 'unknown')[:45]
|
|
89
|
+
state = sse.get('state', 'unknown')
|
|
90
|
+
lines.append(f"| -> {state.upper()} {url}".ljust(67) + "|")
|
|
91
|
+
if len(sse_details) > 3:
|
|
92
|
+
lines.append(f"| ... and {len(sse_details) - 3} more".ljust(67) + "|")
|
|
93
|
+
lines.append("|".ljust(67) + "|")
|
|
68
94
|
|
|
69
95
|
status = self.diagnostics.get('last_status')
|
|
70
96
|
if status:
|
|
@@ -11,7 +11,7 @@ INSTRUMENTATION_SCRIPT = """
|
|
|
11
11
|
|
|
12
12
|
window.__waitless__ = {
|
|
13
13
|
_initialized: true,
|
|
14
|
-
_version: '
|
|
14
|
+
_version: '1.0.0',
|
|
15
15
|
|
|
16
16
|
// State tracking
|
|
17
17
|
pendingRequests: 0,
|
|
@@ -20,6 +20,17 @@ INSTRUMENTATION_SCRIPT = """
|
|
|
20
20
|
activeTransitions: 0,
|
|
21
21
|
layoutShifting: false,
|
|
22
22
|
|
|
23
|
+
// WebSocket/SSE tracking
|
|
24
|
+
activeWebSockets: 0,
|
|
25
|
+
activeSSEConnections: 0,
|
|
26
|
+
lastWebSocketActivity: 0,
|
|
27
|
+
lastSSEActivity: 0,
|
|
28
|
+
webSocketDetails: [],
|
|
29
|
+
sseDetails: [],
|
|
30
|
+
|
|
31
|
+
// iframe tracking
|
|
32
|
+
iframeStatus: [], // Status from child iframes
|
|
33
|
+
|
|
23
34
|
// Timeline for diagnostics (circular buffer)
|
|
24
35
|
timeline: [],
|
|
25
36
|
_maxTimelineEntries: 100,
|
|
@@ -31,6 +42,10 @@ INSTRUMENTATION_SCRIPT = """
|
|
|
31
42
|
config: {
|
|
32
43
|
trackLayout: true,
|
|
33
44
|
trackAnimations: true,
|
|
45
|
+
trackWebSocket: false,
|
|
46
|
+
trackSSE: false,
|
|
47
|
+
webSocketQuietTime: 500, // ms of silence for stability
|
|
48
|
+
trackIframes: false,
|
|
34
49
|
},
|
|
35
50
|
|
|
36
51
|
// Lifecycle
|
|
@@ -38,6 +53,8 @@ INSTRUMENTATION_SCRIPT = """
|
|
|
38
53
|
_originalFetch: null,
|
|
39
54
|
_originalXHROpen: null,
|
|
40
55
|
_originalXHRSend: null,
|
|
56
|
+
_originalWebSocket: null,
|
|
57
|
+
_originalEventSource: null,
|
|
41
58
|
|
|
42
59
|
// ===== INITIALIZATION =====
|
|
43
60
|
|
|
@@ -48,6 +65,15 @@ INSTRUMENTATION_SCRIPT = """
|
|
|
48
65
|
if (this.config.trackLayout) {
|
|
49
66
|
this._setupLayoutTracking();
|
|
50
67
|
}
|
|
68
|
+
if (this.config.trackWebSocket) {
|
|
69
|
+
this._setupWebSocketTracking();
|
|
70
|
+
}
|
|
71
|
+
if (this.config.trackSSE) {
|
|
72
|
+
this._setupSSETracking();
|
|
73
|
+
}
|
|
74
|
+
if (this.config.trackIframes) {
|
|
75
|
+
this._setupIframeTracking();
|
|
76
|
+
}
|
|
51
77
|
this._log('Waitless instrumentation initialized');
|
|
52
78
|
return this;
|
|
53
79
|
},
|
|
@@ -350,6 +376,189 @@ INSTRUMENTATION_SCRIPT = """
|
|
|
350
376
|
}
|
|
351
377
|
},
|
|
352
378
|
|
|
379
|
+
// ===== WEBSOCKET TRACKING =====
|
|
380
|
+
|
|
381
|
+
_setupWebSocketTracking: function() {
|
|
382
|
+
var self = this;
|
|
383
|
+
this._originalWebSocket = window.WebSocket;
|
|
384
|
+
|
|
385
|
+
window.WebSocket = function(url, protocols) {
|
|
386
|
+
var ws = protocols
|
|
387
|
+
? new self._originalWebSocket(url, protocols)
|
|
388
|
+
: new self._originalWebSocket(url);
|
|
389
|
+
|
|
390
|
+
self.activeWebSockets++;
|
|
391
|
+
self.webSocketDetails.push({
|
|
392
|
+
url: url,
|
|
393
|
+
openTime: Date.now(),
|
|
394
|
+
state: 'connecting'
|
|
395
|
+
});
|
|
396
|
+
self._log('WebSocket connecting', { url: url });
|
|
397
|
+
|
|
398
|
+
ws.addEventListener('open', function() {
|
|
399
|
+
self.lastWebSocketActivity = Date.now();
|
|
400
|
+
var detail = self.webSocketDetails.find(function(d) { return d.url === url; });
|
|
401
|
+
if (detail) detail.state = 'open';
|
|
402
|
+
self._log('WebSocket opened', { url: url });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
ws.addEventListener('message', function(e) {
|
|
406
|
+
self.lastWebSocketActivity = Date.now();
|
|
407
|
+
self._log('WebSocket message', { url: url, size: e.data ? e.data.length : 0 });
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
ws.addEventListener('close', function() {
|
|
411
|
+
self.activeWebSockets = Math.max(0, self.activeWebSockets - 1);
|
|
412
|
+
var idx = self.webSocketDetails.findIndex(function(d) { return d.url === url; });
|
|
413
|
+
if (idx > -1) self.webSocketDetails.splice(idx, 1);
|
|
414
|
+
self._log('WebSocket closed', { url: url });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
ws.addEventListener('error', function() {
|
|
418
|
+
self.activeWebSockets = Math.max(0, self.activeWebSockets - 1);
|
|
419
|
+
var idx = self.webSocketDetails.findIndex(function(d) { return d.url === url; });
|
|
420
|
+
if (idx > -1) self.webSocketDetails.splice(idx, 1);
|
|
421
|
+
self._log('WebSocket error', { url: url });
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
return ws;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// Preserve prototype chain
|
|
428
|
+
window.WebSocket.prototype = this._originalWebSocket.prototype;
|
|
429
|
+
window.WebSocket.CONNECTING = this._originalWebSocket.CONNECTING;
|
|
430
|
+
window.WebSocket.OPEN = this._originalWebSocket.OPEN;
|
|
431
|
+
window.WebSocket.CLOSING = this._originalWebSocket.CLOSING;
|
|
432
|
+
window.WebSocket.CLOSED = this._originalWebSocket.CLOSED;
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
// ===== SSE TRACKING =====
|
|
436
|
+
|
|
437
|
+
_setupSSETracking: function() {
|
|
438
|
+
var self = this;
|
|
439
|
+
this._originalEventSource = window.EventSource;
|
|
440
|
+
|
|
441
|
+
if (!this._originalEventSource) {
|
|
442
|
+
self._log('EventSource not supported in this browser');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
window.EventSource = function(url, config) {
|
|
447
|
+
var es = config
|
|
448
|
+
? new self._originalEventSource(url, config)
|
|
449
|
+
: new self._originalEventSource(url);
|
|
450
|
+
|
|
451
|
+
self.activeSSEConnections++;
|
|
452
|
+
self.sseDetails.push({
|
|
453
|
+
url: url,
|
|
454
|
+
openTime: Date.now(),
|
|
455
|
+
state: 'connecting'
|
|
456
|
+
});
|
|
457
|
+
self._log('SSE connecting', { url: url });
|
|
458
|
+
|
|
459
|
+
es.addEventListener('open', function() {
|
|
460
|
+
self.lastSSEActivity = Date.now();
|
|
461
|
+
var detail = self.sseDetails.find(function(d) { return d.url === url; });
|
|
462
|
+
if (detail) detail.state = 'open';
|
|
463
|
+
self._log('SSE opened', { url: url });
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
es.addEventListener('message', function(e) {
|
|
467
|
+
self.lastSSEActivity = Date.now();
|
|
468
|
+
self._log('SSE message', { url: url });
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
es.addEventListener('error', function() {
|
|
472
|
+
self.activeSSEConnections = Math.max(0, self.activeSSEConnections - 1);
|
|
473
|
+
var idx = self.sseDetails.findIndex(function(d) { return d.url === url; });
|
|
474
|
+
if (idx > -1) self.sseDetails.splice(idx, 1);
|
|
475
|
+
self._log('SSE error/closed', { url: url });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
return es;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
window.EventSource.prototype = this._originalEventSource.prototype;
|
|
482
|
+
window.EventSource.CONNECTING = this._originalEventSource.CONNECTING;
|
|
483
|
+
window.EventSource.OPEN = this._originalEventSource.OPEN;
|
|
484
|
+
window.EventSource.CLOSED = this._originalEventSource.CLOSED;
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
// ===== IFRAME TRACKING =====
|
|
488
|
+
|
|
489
|
+
_setupIframeTracking: function() {
|
|
490
|
+
var self = this;
|
|
491
|
+
|
|
492
|
+
// Observe for new iframes being added
|
|
493
|
+
var observer = new MutationObserver(function(mutations) {
|
|
494
|
+
mutations.forEach(function(m) {
|
|
495
|
+
m.addedNodes.forEach(function(node) {
|
|
496
|
+
if (node.tagName === 'IFRAME') {
|
|
497
|
+
self._injectIntoIframe(node);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
observer.observe(document.body || document.documentElement, {
|
|
504
|
+
childList: true,
|
|
505
|
+
subtree: true
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
this._observers.push(observer);
|
|
509
|
+
|
|
510
|
+
// Inject into existing iframes
|
|
511
|
+
document.querySelectorAll('iframe').forEach(function(iframe) {
|
|
512
|
+
self._injectIntoIframe(iframe);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
this._log('iframe tracking initialized');
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
_injectIntoIframe: function(iframe) {
|
|
519
|
+
var self = this;
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
var iframeDoc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document);
|
|
523
|
+
|
|
524
|
+
if (!iframeDoc) {
|
|
525
|
+
self._log('Cannot access iframe (no document)', { src: iframe.src });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Check if already instrumented
|
|
530
|
+
if (iframe.contentWindow.__waitless__) {
|
|
531
|
+
self._log('iframe already instrumented', { src: iframe.src });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Note: Full injection would require eval'ing the entire script
|
|
536
|
+
// For now, we track iframe load/ready state
|
|
537
|
+
self.iframeStatus.push({
|
|
538
|
+
src: iframe.src || 'inline',
|
|
539
|
+
loaded: iframeDoc.readyState === 'complete',
|
|
540
|
+
accessible: true
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
iframe.addEventListener('load', function() {
|
|
544
|
+
self._log('iframe loaded', { src: iframe.src });
|
|
545
|
+
var status = self.iframeStatus.find(function(s) { return s.src === (iframe.src || 'inline'); });
|
|
546
|
+
if (status) status.loaded = true;
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
self._log('iframe registered', { src: iframe.src });
|
|
550
|
+
} catch (e) {
|
|
551
|
+
// Cross-origin iframe - cannot access
|
|
552
|
+
self.iframeStatus.push({
|
|
553
|
+
src: iframe.src || 'inline',
|
|
554
|
+
loaded: false,
|
|
555
|
+
accessible: false,
|
|
556
|
+
error: 'cross-origin'
|
|
557
|
+
});
|
|
558
|
+
self._log('Cannot access iframe (cross-origin)', { src: iframe.src });
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
|
|
353
562
|
// ===== PUBLIC API =====
|
|
354
563
|
|
|
355
564
|
getStatus: function() {
|
|
@@ -361,6 +570,13 @@ INSTRUMENTATION_SCRIPT = """
|
|
|
361
570
|
active_animations: this.activeAnimations + this.activeTransitions,
|
|
362
571
|
layout_shifting: this.layoutShifting,
|
|
363
572
|
pending_request_details: this.pendingRequestDetails.slice(),
|
|
573
|
+
// WebSocket/SSE status
|
|
574
|
+
active_websockets: this.activeWebSockets,
|
|
575
|
+
active_sse: this.activeSSEConnections,
|
|
576
|
+
last_websocket_activity: this.lastWebSocketActivity,
|
|
577
|
+
last_sse_activity: this.lastSSEActivity,
|
|
578
|
+
websocket_details: this.webSocketDetails.slice(),
|
|
579
|
+
sse_details: this.sseDetails.slice(),
|
|
364
580
|
timeline: this.timeline.slice(-20)
|
|
365
581
|
};
|
|
366
582
|
},
|
|
@@ -396,6 +612,12 @@ INSTRUMENTATION_SCRIPT = """
|
|
|
396
612
|
if (this._layoutCheckInterval) {
|
|
397
613
|
clearInterval(this._layoutCheckInterval);
|
|
398
614
|
}
|
|
615
|
+
if (this._originalWebSocket) {
|
|
616
|
+
window.WebSocket = this._originalWebSocket;
|
|
617
|
+
}
|
|
618
|
+
if (this._originalEventSource) {
|
|
619
|
+
window.EventSource = this._originalEventSource;
|
|
620
|
+
}
|
|
399
621
|
|
|
400
622
|
this._initialized = false;
|
|
401
623
|
this._log('Waitless instrumentation destroyed');
|
|
@@ -23,6 +23,8 @@ class SignalType(Enum):
|
|
|
23
23
|
CSS_TRANSITIONS = auto()
|
|
24
24
|
LAYOUT_SHIFT = auto()
|
|
25
25
|
RAF_ACTIVITY = auto()
|
|
26
|
+
WEBSOCKET_ACTIVITY = auto()
|
|
27
|
+
SSE_ACTIVITY = auto()
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
class SignalState(Enum):
|
|
@@ -140,6 +142,15 @@ class SignalEvaluator:
|
|
|
140
142
|
layout_signal = self._evaluate_layout(browser_state)
|
|
141
143
|
signals.append(layout_signal)
|
|
142
144
|
|
|
145
|
+
# WebSocket/SSE signals (opt-in)
|
|
146
|
+
if self.config.track_websocket:
|
|
147
|
+
ws_signal = self._evaluate_websocket(browser_state, current_time)
|
|
148
|
+
signals.append(ws_signal)
|
|
149
|
+
|
|
150
|
+
if self.config.track_sse:
|
|
151
|
+
sse_signal = self._evaluate_sse(browser_state, current_time)
|
|
152
|
+
signals.append(sse_signal)
|
|
153
|
+
|
|
143
154
|
is_stable = all(
|
|
144
155
|
s.is_stable for s in signals if s.is_mandatory
|
|
145
156
|
)
|
|
@@ -245,3 +256,53 @@ class SignalEvaluator:
|
|
|
245
256
|
is_mandatory=self.config.strictness == 'strict',
|
|
246
257
|
details="Layout shifting detected" if is_shifting else "Layout stable",
|
|
247
258
|
)
|
|
259
|
+
|
|
260
|
+
def _evaluate_websocket(self, state: Dict[str, Any], current_time: float) -> Signal:
|
|
261
|
+
"""
|
|
262
|
+
Evaluate WebSocket activity.
|
|
263
|
+
|
|
264
|
+
WebSocket signals are stable when no recent activity (messages) detected.
|
|
265
|
+
Open connections that are idle are considered stable (they're just 'chilling').
|
|
266
|
+
"""
|
|
267
|
+
active_ws = state.get('active_websockets', 0)
|
|
268
|
+
last_activity = state.get('last_websocket_activity', 0)
|
|
269
|
+
|
|
270
|
+
quiet_time_ms = self.config.websocket_quiet_time * 1000
|
|
271
|
+
time_since_activity = (current_time * 1000) - last_activity if last_activity else float('inf')
|
|
272
|
+
|
|
273
|
+
# Stable if no recent activity (idle connections are OK)
|
|
274
|
+
is_stable = time_since_activity >= quiet_time_ms
|
|
275
|
+
|
|
276
|
+
return Signal(
|
|
277
|
+
signal_type=SignalType.WEBSOCKET_ACTIVITY,
|
|
278
|
+
state=SignalState.STABLE if is_stable else SignalState.UNSTABLE,
|
|
279
|
+
value={'active': active_ws, 'time_since_activity_ms': time_since_activity},
|
|
280
|
+
threshold=quiet_time_ms,
|
|
281
|
+
is_mandatory=True, # Mandatory when enabled
|
|
282
|
+
details=f"{active_ws} WebSocket(s), last activity {time_since_activity:.0f}ms ago",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def _evaluate_sse(self, state: Dict[str, Any], current_time: float) -> Signal:
|
|
286
|
+
"""
|
|
287
|
+
Evaluate Server-Sent Events (SSE) activity.
|
|
288
|
+
|
|
289
|
+
SSE signals are stable when no recent events received.
|
|
290
|
+
Open connections waiting for events are considered stable.
|
|
291
|
+
"""
|
|
292
|
+
active_sse = state.get('active_sse', 0)
|
|
293
|
+
last_activity = state.get('last_sse_activity', 0)
|
|
294
|
+
|
|
295
|
+
quiet_time_ms = self.config.websocket_quiet_time * 1000
|
|
296
|
+
time_since_activity = (current_time * 1000) - last_activity if last_activity else float('inf')
|
|
297
|
+
|
|
298
|
+
# Stable if no recent activity
|
|
299
|
+
is_stable = time_since_activity >= quiet_time_ms
|
|
300
|
+
|
|
301
|
+
return Signal(
|
|
302
|
+
signal_type=SignalType.SSE_ACTIVITY,
|
|
303
|
+
state=SignalState.STABLE if is_stable else SignalState.UNSTABLE,
|
|
304
|
+
value={'active': active_sse, 'time_since_activity_ms': time_since_activity},
|
|
305
|
+
threshold=quiet_time_ms,
|
|
306
|
+
is_mandatory=True, # Mandatory when enabled
|
|
307
|
+
details=f"{active_sse} SSE connection(s), last activity {time_since_activity:.0f}ms ago",
|
|
308
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: waitless
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Eliminate explicit waits in UI automation by detecting true UI stability
|
|
5
5
|
Author-email: Dhiraj Das <dhirajdas.666@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -36,6 +36,8 @@ Dynamic: license-file
|
|
|
36
36
|
|
|
37
37
|
Eliminate explicit waits and sleeps by automatically detecting true UI stability.
|
|
38
38
|
|
|
39
|
+
|
|
40
|
+
|
|
39
41
|
## Installation
|
|
40
42
|
|
|
41
43
|
```bash
|
|
@@ -88,6 +90,9 @@ Waitless monitors the **entire page** for stability signals:
|
|
|
88
90
|
- ✅ Pending network requests (XHR/fetch interception)
|
|
89
91
|
- ✅ CSS animations and transitions
|
|
90
92
|
- ✅ Layout stability (element movement)
|
|
93
|
+
- ✅ WebSocket/SSE activity (opt-in)
|
|
94
|
+
- ✅ Framework hooks (React/Angular/Vue, opt-in)
|
|
95
|
+
- ✅ iframe monitoring (opt-in)
|
|
91
96
|
|
|
92
97
|
When you interact, waitless ensures the page is truly ready.
|
|
93
98
|
|
|
@@ -214,11 +219,46 @@ element = driver.find_element(By.ID, "button")
|
|
|
214
219
|
original = element.unwrap() # Gets the real WebElement
|
|
215
220
|
```
|
|
216
221
|
|
|
217
|
-
##
|
|
222
|
+
## v1.0.0 New Features
|
|
223
|
+
|
|
224
|
+
- **WebSocket/SSE Awareness** - Track WebSocket and Server-Sent Events activity
|
|
225
|
+
- **Framework Adapters** - React, Angular, Vue hooks for framework-specific settling
|
|
226
|
+
- **iframe Support** - Monitor same-origin iframes
|
|
227
|
+
- **Performance Benchmarks** - Built-in benchmark suite
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
# Enable new v1.0 features
|
|
231
|
+
config = StabilizationConfig(
|
|
232
|
+
track_websocket=True, # WebSocket monitoring
|
|
233
|
+
track_sse=True, # SSE monitoring
|
|
234
|
+
framework_hooks=['react'], # React adapter
|
|
235
|
+
track_iframes=True, # iframe monitoring
|
|
236
|
+
)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Performance
|
|
240
|
+
|
|
241
|
+
| Metric | Typical Value |
|
|
242
|
+
|--------|---------------|
|
|
243
|
+
| Instrumentation injection | ~5-10ms |
|
|
244
|
+
| Per-poll overhead | ~1-2ms |
|
|
245
|
+
| Poll interval (default) | 50ms |
|
|
246
|
+
| Typical stabilization | 50-200ms after activity |
|
|
247
|
+
|
|
248
|
+
### SPA Navigation Handling
|
|
249
|
+
|
|
250
|
+
Waitless automatically re-injects instrumentation after SPA route changes:
|
|
251
|
+
|
|
252
|
+
1. Checks `__waitless__.isAlive()` before each wait
|
|
253
|
+
2. Detects URL changes via `driver.current_url`
|
|
254
|
+
3. Re-injects if instrumentation is missing
|
|
255
|
+
|
|
256
|
+
This works transparently with React Router, Vue Router, Angular Router, etc.
|
|
257
|
+
|
|
258
|
+
## Current Limitations
|
|
218
259
|
|
|
219
|
-
- **Selenium only** - Playwright support planned
|
|
260
|
+
- **Selenium only** - Playwright support planned
|
|
220
261
|
- **Sync only** - No async/await support yet
|
|
221
|
-
- **Main frame only** - iframes not monitored
|
|
222
262
|
- **No Service Workers** - SW network requests not intercepted
|
|
223
263
|
|
|
224
264
|
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
|
@@ -15,4 +15,9 @@ waitless.egg-info/SOURCES.txt
|
|
|
15
15
|
waitless.egg-info/dependency_links.txt
|
|
16
16
|
waitless.egg-info/entry_points.txt
|
|
17
17
|
waitless.egg-info/requires.txt
|
|
18
|
-
waitless.egg-info/top_level.txt
|
|
18
|
+
waitless.egg-info/top_level.txt
|
|
19
|
+
waitless/adapters/__init__.py
|
|
20
|
+
waitless/adapters/angular.py
|
|
21
|
+
waitless/adapters/base.py
|
|
22
|
+
waitless/adapters/react.py
|
|
23
|
+
waitless/adapters/vue.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|