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
waitless/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Waitless - Zero-wait UI automation stabilization library.
|
|
3
|
+
|
|
4
|
+
Eliminate explicit waits and sleeps in UI automation by automatically
|
|
5
|
+
waiting for true UI stability instead of time-based conditions.
|
|
6
|
+
|
|
7
|
+
Basic Usage:
|
|
8
|
+
from waitless import stabilize
|
|
9
|
+
|
|
10
|
+
driver = webdriver.Chrome()
|
|
11
|
+
driver = stabilize(driver) # That's it!
|
|
12
|
+
|
|
13
|
+
# All interactions now auto-wait for stability
|
|
14
|
+
driver.find_element(By.ID, "button").click()
|
|
15
|
+
|
|
16
|
+
Configuration:
|
|
17
|
+
from waitless import stabilize, StabilizationConfig
|
|
18
|
+
|
|
19
|
+
config = StabilizationConfig(
|
|
20
|
+
timeout=5, # Max wait time
|
|
21
|
+
strictness='strict', # All signals must be stable
|
|
22
|
+
debug_mode=True # Enable logging
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
driver = stabilize(driver, config=config)
|
|
26
|
+
|
|
27
|
+
Manual Stabilization:
|
|
28
|
+
from waitless import wait_for_stability
|
|
29
|
+
|
|
30
|
+
wait_for_stability(driver) # Explicit wait
|
|
31
|
+
driver.find_element(...).click()
|
|
32
|
+
|
|
33
|
+
Disable:
|
|
34
|
+
from waitless import unstabilize
|
|
35
|
+
|
|
36
|
+
driver = unstabilize(driver) # Back to original behavior
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__version__ = '0.1.0'
|
|
40
|
+
__author__ = 'Dhiraj Das'
|
|
41
|
+
|
|
42
|
+
# Public API
|
|
43
|
+
from .config import StabilizationConfig, DEFAULT_CONFIG
|
|
44
|
+
from .selenium_integration import (
|
|
45
|
+
stabilize,
|
|
46
|
+
unstabilize,
|
|
47
|
+
wait_for_stability,
|
|
48
|
+
get_diagnostics,
|
|
49
|
+
StabilizedWebDriver,
|
|
50
|
+
StabilizedWebElement,
|
|
51
|
+
)
|
|
52
|
+
from .exceptions import (
|
|
53
|
+
WaitlessError,
|
|
54
|
+
StabilizationTimeout,
|
|
55
|
+
InstrumentationError,
|
|
56
|
+
ConfigurationError,
|
|
57
|
+
NotStabilizedError,
|
|
58
|
+
)
|
|
59
|
+
from .engine import StabilizationEngine
|
|
60
|
+
from .diagnostics import DiagnosticReport, generate_report, print_report
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
# Version
|
|
64
|
+
'__version__',
|
|
65
|
+
|
|
66
|
+
# Main API
|
|
67
|
+
'stabilize',
|
|
68
|
+
'unstabilize',
|
|
69
|
+
'wait_for_stability',
|
|
70
|
+
'get_diagnostics',
|
|
71
|
+
|
|
72
|
+
# Configuration
|
|
73
|
+
'StabilizationConfig',
|
|
74
|
+
'DEFAULT_CONFIG',
|
|
75
|
+
|
|
76
|
+
# Types
|
|
77
|
+
'StabilizedWebDriver',
|
|
78
|
+
'StabilizedWebElement',
|
|
79
|
+
'StabilizationEngine',
|
|
80
|
+
|
|
81
|
+
# Exceptions
|
|
82
|
+
'WaitlessError',
|
|
83
|
+
'StabilizationTimeout',
|
|
84
|
+
'InstrumentationError',
|
|
85
|
+
'ConfigurationError',
|
|
86
|
+
'NotStabilizedError',
|
|
87
|
+
|
|
88
|
+
# Diagnostics
|
|
89
|
+
'DiagnosticReport',
|
|
90
|
+
'generate_report',
|
|
91
|
+
'print_report',
|
|
92
|
+
]
|
waitless/__main__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for waitless.
|
|
3
|
+
|
|
4
|
+
Provides the 'waitless doctor' command for diagnostics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
"""Main CLI entry point."""
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog='waitless',
|
|
17
|
+
description='Waitless - Zero-wait UI automation stabilization'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
|
21
|
+
|
|
22
|
+
# Doctor command
|
|
23
|
+
doctor_parser = subparsers.add_parser(
|
|
24
|
+
'doctor',
|
|
25
|
+
help='Analyze and diagnose stability issues'
|
|
26
|
+
)
|
|
27
|
+
doctor_parser.add_argument(
|
|
28
|
+
'--json',
|
|
29
|
+
action='store_true',
|
|
30
|
+
help='Output in JSON format for CI integration'
|
|
31
|
+
)
|
|
32
|
+
doctor_parser.add_argument(
|
|
33
|
+
'--file',
|
|
34
|
+
type=str,
|
|
35
|
+
help='Load diagnostics from a JSON file'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Version command
|
|
39
|
+
subparsers.add_parser('version', help='Show version')
|
|
40
|
+
|
|
41
|
+
args = parser.parse_args()
|
|
42
|
+
|
|
43
|
+
if args.command == 'version':
|
|
44
|
+
from . import __version__
|
|
45
|
+
print(f"waitless version {__version__}")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
elif args.command == 'doctor':
|
|
49
|
+
return run_doctor(args)
|
|
50
|
+
|
|
51
|
+
else:
|
|
52
|
+
parser.print_help()
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_doctor(args):
|
|
57
|
+
"""Run the doctor diagnostic command."""
|
|
58
|
+
from .diagnostics import DiagnosticReport
|
|
59
|
+
|
|
60
|
+
if args.file:
|
|
61
|
+
# Load from file
|
|
62
|
+
try:
|
|
63
|
+
with open(args.file, 'r') as f:
|
|
64
|
+
data = json.load(f)
|
|
65
|
+
diagnostics = data.get('diagnostics', data)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
except json.JSONDecodeError as e:
|
|
70
|
+
print(f"Error: Invalid JSON: {e}", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
else:
|
|
73
|
+
# Show usage instructions
|
|
74
|
+
print("╔" + "═" * 66 + "╗")
|
|
75
|
+
print("║" + "WAITLESS DOCTOR".center(66) + "║")
|
|
76
|
+
print("╠" + "═" * 66 + "╣")
|
|
77
|
+
print("║".ljust(67) + "║")
|
|
78
|
+
print("║ The doctor command analyzes stability diagnostics.".ljust(67) + "║")
|
|
79
|
+
print("║".ljust(67) + "║")
|
|
80
|
+
print("║ USAGE OPTIONS:".ljust(67) + "║")
|
|
81
|
+
print("║".ljust(67) + "║")
|
|
82
|
+
print("║ 1. From a diagnostic file:".ljust(67) + "║")
|
|
83
|
+
print("║ waitless doctor --file diagnostics.json".ljust(67) + "║")
|
|
84
|
+
print("║".ljust(67) + "║")
|
|
85
|
+
print("║ 2. In your test code, capture diagnostics on failure:".ljust(67) + "║")
|
|
86
|
+
print("║".ljust(67) + "║")
|
|
87
|
+
print("║ from waitless import get_diagnostics".ljust(67) + "║")
|
|
88
|
+
print("║ from waitless.diagnostics import print_report".ljust(67) + "║")
|
|
89
|
+
print("║".ljust(67) + "║")
|
|
90
|
+
print("║ try:".ljust(67) + "║")
|
|
91
|
+
print("║ driver.find_element(...).click()".ljust(67) + "║")
|
|
92
|
+
print("║ except StabilizationTimeout as e:".ljust(67) + "║")
|
|
93
|
+
print("║ print_report(engine) # Print diagnostic report".ljust(67) + "║")
|
|
94
|
+
print("║".ljust(67) + "║")
|
|
95
|
+
print("║ 3. Export diagnostics for CI:".ljust(67) + "║")
|
|
96
|
+
print("║".ljust(67) + "║")
|
|
97
|
+
print("║ diagnostics = get_diagnostics(driver)".ljust(67) + "║")
|
|
98
|
+
print("║ with open('diag.json', 'w') as f:".ljust(67) + "║")
|
|
99
|
+
print("║ json.dump(diagnostics, f)".ljust(67) + "║")
|
|
100
|
+
print("║".ljust(67) + "║")
|
|
101
|
+
print("╚" + "═" * 66 + "╝")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
# Generate report
|
|
105
|
+
report = DiagnosticReport(diagnostics)
|
|
106
|
+
|
|
107
|
+
if args.json:
|
|
108
|
+
print(report.to_json())
|
|
109
|
+
else:
|
|
110
|
+
print(report.generate_text_report())
|
|
111
|
+
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == '__main__':
|
|
116
|
+
sys.exit(main())
|
waitless/config.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Waitless configuration management.
|
|
3
|
+
|
|
4
|
+
Provides sensible defaults with full customization options.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Literal, Optional
|
|
9
|
+
from .exceptions import ConfigurationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
StrictnessLevel = Literal['strict', 'normal', 'relaxed']
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class StabilizationConfig:
|
|
17
|
+
"""
|
|
18
|
+
Configuration for UI stabilization behavior.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
timeout: Maximum time (seconds) to wait for stability. Default 10s.
|
|
22
|
+
Consider lowering to 5s for faster feedback loops.
|
|
23
|
+
|
|
24
|
+
dom_settle_time: Time (seconds) DOM must be quiet to be considered stable.
|
|
25
|
+
Default 0.1s (100ms).
|
|
26
|
+
|
|
27
|
+
network_idle_threshold: Maximum pending requests allowed for stability.
|
|
28
|
+
Default 0 (all requests must complete).
|
|
29
|
+
|
|
30
|
+
⚠️ WARNING: Many apps have background traffic:
|
|
31
|
+
- Analytics calls
|
|
32
|
+
- Long polling
|
|
33
|
+
- Feature flags
|
|
34
|
+
- WebSocket heartbeats
|
|
35
|
+
|
|
36
|
+
If your tests timeout frequently, try setting this to 1-2.
|
|
37
|
+
|
|
38
|
+
animation_detection: Whether to wait for CSS animations/transitions.
|
|
39
|
+
Default True. Disable for apps with infinite animations.
|
|
40
|
+
|
|
41
|
+
layout_stability: Whether to wait for element positions to stabilize.
|
|
42
|
+
Default True in 'strict' mode.
|
|
43
|
+
|
|
44
|
+
strictness: Overall strictness level.
|
|
45
|
+
- 'strict': All signals must be stable (recommended)
|
|
46
|
+
- 'normal': DOM + Network only (faster)
|
|
47
|
+
- 'relaxed': DOM only (fastest, least reliable)
|
|
48
|
+
|
|
49
|
+
debug_mode: Enable verbose logging for troubleshooting.
|
|
50
|
+
Default False.
|
|
51
|
+
|
|
52
|
+
poll_interval: How often to check stability (seconds).
|
|
53
|
+
Default 0.05s (50ms). Lower = more responsive but more CPU.
|
|
54
|
+
|
|
55
|
+
reinject_on_navigation: Auto-reinject instrumentation after navigation.
|
|
56
|
+
Default True.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
timeout: float = 10.0
|
|
60
|
+
dom_settle_time: float = 0.1
|
|
61
|
+
network_idle_threshold: int = 0
|
|
62
|
+
animation_detection: bool = True
|
|
63
|
+
layout_stability: bool = True
|
|
64
|
+
strictness: StrictnessLevel = 'normal'
|
|
65
|
+
debug_mode: bool = False
|
|
66
|
+
poll_interval: float = 0.05
|
|
67
|
+
reinject_on_navigation: bool = True
|
|
68
|
+
|
|
69
|
+
def __post_init__(self):
|
|
70
|
+
"""Validate configuration values."""
|
|
71
|
+
self._validate()
|
|
72
|
+
|
|
73
|
+
def _validate(self) -> None:
|
|
74
|
+
"""Validate all configuration values."""
|
|
75
|
+
if self.timeout <= 0:
|
|
76
|
+
raise ConfigurationError(f"timeout must be positive, got {self.timeout}")
|
|
77
|
+
|
|
78
|
+
if self.timeout > 60:
|
|
79
|
+
import warnings
|
|
80
|
+
warnings.warn(
|
|
81
|
+
f"timeout of {self.timeout}s is very high. "
|
|
82
|
+
"Consider investigating root cause of slow stability.",
|
|
83
|
+
UserWarning
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if self.dom_settle_time < 0:
|
|
87
|
+
raise ConfigurationError(
|
|
88
|
+
f"dom_settle_time must be non-negative, got {self.dom_settle_time}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if self.network_idle_threshold < 0:
|
|
92
|
+
raise ConfigurationError(
|
|
93
|
+
f"network_idle_threshold must be non-negative, got {self.network_idle_threshold}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if self.poll_interval <= 0:
|
|
97
|
+
raise ConfigurationError(
|
|
98
|
+
f"poll_interval must be positive, got {self.poll_interval}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if self.poll_interval > self.timeout:
|
|
102
|
+
raise ConfigurationError(
|
|
103
|
+
f"poll_interval ({self.poll_interval}) cannot exceed timeout ({self.timeout})"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if self.strictness not in ('strict', 'normal', 'relaxed'):
|
|
107
|
+
raise ConfigurationError(
|
|
108
|
+
f"strictness must be 'strict', 'normal', or 'relaxed', got '{self.strictness}'"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def with_overrides(self, **kwargs) -> 'StabilizationConfig':
|
|
112
|
+
"""
|
|
113
|
+
Create a new config with some values overridden.
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
new_config = config.with_overrides(timeout=5, debug_mode=True)
|
|
117
|
+
"""
|
|
118
|
+
current = {
|
|
119
|
+
'timeout': self.timeout,
|
|
120
|
+
'dom_settle_time': self.dom_settle_time,
|
|
121
|
+
'network_idle_threshold': self.network_idle_threshold,
|
|
122
|
+
'animation_detection': self.animation_detection,
|
|
123
|
+
'layout_stability': self.layout_stability,
|
|
124
|
+
'strictness': self.strictness,
|
|
125
|
+
'debug_mode': self.debug_mode,
|
|
126
|
+
'poll_interval': self.poll_interval,
|
|
127
|
+
'reinject_on_navigation': self.reinject_on_navigation,
|
|
128
|
+
}
|
|
129
|
+
current.update(kwargs)
|
|
130
|
+
return StabilizationConfig(**current)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def strict(cls) -> 'StabilizationConfig':
|
|
134
|
+
"""Factory for strict configuration (all signals, lower timeout)."""
|
|
135
|
+
return cls(
|
|
136
|
+
strictness='strict',
|
|
137
|
+
timeout=5.0,
|
|
138
|
+
animation_detection=True,
|
|
139
|
+
layout_stability=True,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def relaxed(cls) -> 'StabilizationConfig':
|
|
144
|
+
"""Factory for relaxed configuration (DOM only, higher threshold)."""
|
|
145
|
+
return cls(
|
|
146
|
+
strictness='relaxed',
|
|
147
|
+
network_idle_threshold=2,
|
|
148
|
+
animation_detection=False,
|
|
149
|
+
layout_stability=False,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def ci(cls) -> 'StabilizationConfig':
|
|
154
|
+
"""Factory for CI environments (longer timeout, verbose logging)."""
|
|
155
|
+
return cls(
|
|
156
|
+
timeout=15.0,
|
|
157
|
+
debug_mode=True,
|
|
158
|
+
strictness='normal',
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
DEFAULT_CONFIG = StabilizationConfig()
|
waitless/diagnostics.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diagnostics and doctor feature.
|
|
3
|
+
|
|
4
|
+
Provides detailed analysis of stability issues with actionable suggestions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Dict, Any, List, Optional, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .engine import StabilizationEngine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DiagnosticReport:
|
|
16
|
+
"""
|
|
17
|
+
Generates human-readable diagnostic reports for stability issues.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, diagnostics: Dict[str, Any]):
|
|
21
|
+
self.diagnostics = diagnostics
|
|
22
|
+
self.timestamp = datetime.now()
|
|
23
|
+
|
|
24
|
+
def generate_text_report(self) -> str:
|
|
25
|
+
"""Generate a text-based diagnostic report."""
|
|
26
|
+
lines = []
|
|
27
|
+
lines.append("╔" + "═" * 66 + "╗")
|
|
28
|
+
lines.append("║" + "WAITLESS STABILITY REPORT".center(66) + "║")
|
|
29
|
+
lines.append("╠" + "═" * 66 + "╣")
|
|
30
|
+
lines.append(f"║ Report generated at: {self.timestamp.strftime('%Y-%m-%d %H:%M:%S'):<43} ║")
|
|
31
|
+
|
|
32
|
+
config = self.diagnostics.get('config', {})
|
|
33
|
+
lines.append("╠" + "═" * 66 + "╣")
|
|
34
|
+
lines.append("║ CONFIGURATION:".ljust(67) + "║")
|
|
35
|
+
lines.append(f"║ Timeout: {config.get('timeout', 'N/A')}s".ljust(67) + "║")
|
|
36
|
+
lines.append(f"║ Strictness: {config.get('strictness', 'N/A')}".ljust(67) + "║")
|
|
37
|
+
lines.append(f"║ Network threshold: {config.get('network_idle_threshold', 'N/A')} pending requests".ljust(67) + "║")
|
|
38
|
+
lines.append(f"║ Animation detection: {config.get('animation_detection', 'N/A')}".ljust(67) + "║")
|
|
39
|
+
|
|
40
|
+
blocking = self.diagnostics.get('blocking_factors', {})
|
|
41
|
+
if blocking:
|
|
42
|
+
lines.append("╠" + "═" * 66 + "╣")
|
|
43
|
+
lines.append("║ BLOCKING FACTORS:".ljust(67) + "║")
|
|
44
|
+
lines.append("║".ljust(67) + "║")
|
|
45
|
+
|
|
46
|
+
pending = blocking.get('pending_requests', 0)
|
|
47
|
+
if pending > 0:
|
|
48
|
+
lines.append("║ ⚠ NETWORK: {} request(s) still pending".format(pending).ljust(67) + "║")
|
|
49
|
+
|
|
50
|
+
details = blocking.get('pending_request_details', [])
|
|
51
|
+
for req in details[:5]: # Show max 5
|
|
52
|
+
url = req.get('url', 'unknown')[:50]
|
|
53
|
+
started = req.get('startTime', 0)
|
|
54
|
+
lines.append(f"║ → {req.get('type', 'unknown').upper()} {url}".ljust(67) + "║")
|
|
55
|
+
|
|
56
|
+
if len(details) > 5:
|
|
57
|
+
lines.append(f"║ ... and {len(details) - 5} more".ljust(67) + "║")
|
|
58
|
+
lines.append("║".ljust(67) + "║")
|
|
59
|
+
|
|
60
|
+
animations = blocking.get('active_animations', 0)
|
|
61
|
+
if animations > 0:
|
|
62
|
+
lines.append(f"║ ⚠ ANIMATIONS: {animations} active animation(s)".ljust(67) + "║")
|
|
63
|
+
lines.append("║".ljust(67) + "║")
|
|
64
|
+
|
|
65
|
+
if blocking.get('layout_shifting'):
|
|
66
|
+
lines.append("║ ⚠ LAYOUT: Elements are still moving".ljust(67) + "║")
|
|
67
|
+
lines.append("║".ljust(67) + "║")
|
|
68
|
+
|
|
69
|
+
status = self.diagnostics.get('last_status')
|
|
70
|
+
if status:
|
|
71
|
+
lines.append("╠" + "═" * 66 + "╣")
|
|
72
|
+
lines.append("║ SIGNAL STATUS:".ljust(67) + "║")
|
|
73
|
+
|
|
74
|
+
for signal in status.get('signals', []):
|
|
75
|
+
state = "✓" if signal['state'] == 'STABLE' else "✗"
|
|
76
|
+
mandatory = "[M]" if signal['mandatory'] else "[O]"
|
|
77
|
+
line = f"║ {state} {mandatory} {signal['type']}: {signal.get('details', 'N/A')}"
|
|
78
|
+
lines.append(line[:66].ljust(67) + "║")
|
|
79
|
+
|
|
80
|
+
timeline = self.diagnostics.get('timeline', [])
|
|
81
|
+
if timeline:
|
|
82
|
+
lines.append("╠" + "═" * 66 + "╣")
|
|
83
|
+
lines.append("║ RECENT EVENTS (last 10):".ljust(67) + "║")
|
|
84
|
+
|
|
85
|
+
for entry in timeline[-10:]:
|
|
86
|
+
time_str = str(entry.get('time', ''))[-6:]
|
|
87
|
+
msg = entry.get('message', '')[:50]
|
|
88
|
+
lines.append(f"║ [{time_str}] {msg}".ljust(67) + "║")
|
|
89
|
+
|
|
90
|
+
lines.extend(self._generate_suggestions())
|
|
91
|
+
lines.append("╚" + "═" * 66 + "╝")
|
|
92
|
+
|
|
93
|
+
return "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
def _generate_suggestions(self) -> List[str]:
|
|
96
|
+
"""Generate actionable suggestions based on diagnostics."""
|
|
97
|
+
lines = []
|
|
98
|
+
suggestions = []
|
|
99
|
+
|
|
100
|
+
blocking = self.diagnostics.get('blocking_factors', {})
|
|
101
|
+
config = self.diagnostics.get('config', {})
|
|
102
|
+
pending = blocking.get('pending_requests', 0)
|
|
103
|
+
if pending > 0:
|
|
104
|
+
threshold = config.get('network_idle_threshold', 0)
|
|
105
|
+
if threshold == 0:
|
|
106
|
+
suggestions.append(
|
|
107
|
+
"Network requests are blocking stability. If your app has "
|
|
108
|
+
"background traffic (analytics, polling), consider:\n"
|
|
109
|
+
" config = StabilizationConfig(network_idle_threshold=2)"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
details = blocking.get('pending_request_details', [])
|
|
113
|
+
slow_apis = [r for r in details if '/api/' in r.get('url', '')]
|
|
114
|
+
if slow_apis:
|
|
115
|
+
suggestions.append(
|
|
116
|
+
"Slow API endpoints detected. Consider:\n"
|
|
117
|
+
" - Mocking slow endpoints in tests\n"
|
|
118
|
+
" - Increasing timeout if APIs are legitimately slow"
|
|
119
|
+
)
|
|
120
|
+
animations = blocking.get('active_animations', 0)
|
|
121
|
+
if animations > 0:
|
|
122
|
+
suggestions.append(
|
|
123
|
+
"CSS animations are blocking stability. If you have infinite "
|
|
124
|
+
"animations (spinners), consider:\n"
|
|
125
|
+
" config = StabilizationConfig(animation_detection=False)\n"
|
|
126
|
+
" OR use strictness='relaxed'"
|
|
127
|
+
)
|
|
128
|
+
if blocking.get('layout_shifting'):
|
|
129
|
+
suggestions.append(
|
|
130
|
+
"Layout is unstable (elements moving). This often indicates:\n"
|
|
131
|
+
" - Images loading without dimensions\n"
|
|
132
|
+
" - Font loading causing reflow\n"
|
|
133
|
+
" - Dynamic content insertion"
|
|
134
|
+
)
|
|
135
|
+
timeout = config.get('timeout', 10)
|
|
136
|
+
if timeout >= 10:
|
|
137
|
+
suggestions.append(
|
|
138
|
+
f"Timeout is {timeout}s (default). For faster feedback, consider:\n"
|
|
139
|
+
" config = StabilizationConfig(timeout=5)"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if suggestions:
|
|
143
|
+
lines.append("╠" + "═" * 66 + "╣")
|
|
144
|
+
lines.append("║ SUGGESTIONS:".ljust(67) + "║")
|
|
145
|
+
lines.append("║".ljust(67) + "║")
|
|
146
|
+
|
|
147
|
+
for i, suggestion in enumerate(suggestions, 1):
|
|
148
|
+
for line in f"{i}. {suggestion}".split('\n'):
|
|
149
|
+
lines.append(f"║ {line}".ljust(67) + "║")
|
|
150
|
+
lines.append("║".ljust(67) + "║")
|
|
151
|
+
|
|
152
|
+
return lines
|
|
153
|
+
|
|
154
|
+
def to_json(self) -> str:
|
|
155
|
+
"""Export diagnostics as JSON for CI integration."""
|
|
156
|
+
return json.dumps({
|
|
157
|
+
'timestamp': self.timestamp.isoformat(),
|
|
158
|
+
'diagnostics': self.diagnostics,
|
|
159
|
+
}, indent=2, default=str)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def generate_report(engine: 'StabilizationEngine') -> DiagnosticReport:
|
|
163
|
+
"""Generate a diagnostic report from an engine."""
|
|
164
|
+
diagnostics = engine.get_diagnostics()
|
|
165
|
+
return DiagnosticReport(diagnostics)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def print_report(engine: 'StabilizationEngine') -> None:
|
|
169
|
+
"""Print a diagnostic report to stdout."""
|
|
170
|
+
report = generate_report(engine)
|
|
171
|
+
print(report.generate_text_report())
|