cursorflow 1.3.7__py3-none-any.whl → 2.0.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,439 @@
1
+ """
2
+ CursorFlow v2.0 Hot Module Replacement (HMR) Detection System
3
+
4
+ This module provides intelligent detection and monitoring of Hot Module Replacement
5
+ events across different development frameworks, enabling precision timing for CSS
6
+ iteration workflows.
7
+
8
+ Core Philosophy: Pure observation of HMR events - we listen but never trigger.
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import time
15
+ from typing import Dict, List, Optional, Any, Callable
16
+ from urllib.parse import urlparse
17
+ import websockets
18
+ from websockets.exceptions import ConnectionClosed, InvalidURI
19
+
20
+
21
+ class HMRDetector:
22
+ """
23
+ v2.0 Enhancement: Intelligent Hot Module Replacement event detection
24
+
25
+ Supports auto-detection and monitoring for:
26
+ - Vite (port 5173, WebSocket path /__vite_hmr)
27
+ - Webpack Dev Server (port 3000, WebSocket path /sockjs-node)
28
+ - Next.js (port 3000, WebSocket path /_next/webpack-hmr)
29
+ - Parcel (port 1234, WebSocket path /hmr)
30
+ - Laravel Mix (port 3000, WebSocket path /browser-sync/socket.io)
31
+ """
32
+
33
+ def __init__(self, base_url: str):
34
+ self.base_url = base_url
35
+ self.logger = logging.getLogger(self.__class__.__name__)
36
+
37
+ # Parse the base URL to get host and port
38
+ parsed_url = urlparse(base_url)
39
+ self.host = parsed_url.hostname or 'localhost'
40
+ self.port = parsed_url.port or 3000
41
+
42
+ # HMR detection state
43
+ self.detected_framework = None
44
+ self.hmr_config = None
45
+ self.websocket = None
46
+ self.is_monitoring = False
47
+ self.hmr_events = []
48
+ self.event_callbacks = []
49
+
50
+ # Framework configurations
51
+ self.framework_configs = {
52
+ 'vite': {
53
+ 'name': 'Vite',
54
+ 'default_port': 5173,
55
+ 'ws_paths': ['/__vite_hmr', '/vite-hmr'],
56
+ 'event_patterns': ['vite:beforeUpdate', 'vite:afterUpdate', 'css-update'],
57
+ 'css_update_indicators': ['css-update', 'style-update']
58
+ },
59
+ 'webpack': {
60
+ 'name': 'Webpack Dev Server',
61
+ 'default_port': 3000,
62
+ 'ws_paths': ['/sockjs-node', '/webpack-hmr', '/ws'],
63
+ 'event_patterns': ['webpackHotUpdate', 'hot', 'hash'],
64
+ 'css_update_indicators': ['css', 'style', 'hot-update']
65
+ },
66
+ 'nextjs': {
67
+ 'name': 'Next.js',
68
+ 'default_port': 3000,
69
+ 'ws_paths': ['/_next/webpack-hmr', '/_next/static/hmr'],
70
+ 'event_patterns': ['building', 'built', 'sync'],
71
+ 'css_update_indicators': ['css', 'style']
72
+ },
73
+ 'parcel': {
74
+ 'name': 'Parcel',
75
+ 'default_port': 1234,
76
+ 'ws_paths': ['/hmr', '/parcel-hmr'],
77
+ 'event_patterns': ['buildSuccess', 'buildError', 'hmr:update'],
78
+ 'css_update_indicators': ['css', 'style']
79
+ },
80
+ 'laravel_mix': {
81
+ 'name': 'Laravel Mix',
82
+ 'default_port': 3000,
83
+ 'ws_paths': ['/browser-sync/socket.io', '/browsersync'],
84
+ 'event_patterns': ['file:changed', 'browser:reload'],
85
+ 'css_update_indicators': ['css', 'scss', 'sass']
86
+ }
87
+ }
88
+
89
+ async def auto_detect_framework(self) -> Optional[str]:
90
+ """
91
+ Auto-detect the development framework by probing WebSocket endpoints
92
+
93
+ Returns the detected framework key or None if no framework detected
94
+ """
95
+ self.logger.info(f"Auto-detecting HMR framework for {self.base_url}")
96
+
97
+ # Try to detect based on port first
98
+ port_hints = {
99
+ 5173: ['vite'],
100
+ 3000: ['webpack', 'nextjs', 'laravel_mix'],
101
+ 1234: ['parcel']
102
+ }
103
+
104
+ frameworks_to_check = port_hints.get(self.port, list(self.framework_configs.keys()))
105
+
106
+ for framework_key in frameworks_to_check:
107
+ config = self.framework_configs[framework_key]
108
+
109
+ # Test WebSocket connections for this framework
110
+ for ws_path in config['ws_paths']:
111
+ ws_url = f"ws://{self.host}:{self.port}{ws_path}"
112
+
113
+ try:
114
+ self.logger.debug(f"Testing WebSocket connection: {ws_url}")
115
+
116
+ # Quick connection test with timeout
117
+ websocket = await asyncio.wait_for(
118
+ websockets.connect(ws_url, ping_interval=None),
119
+ timeout=2.0
120
+ )
121
+
122
+ # Connection successful - this is likely our framework
123
+ await websocket.close()
124
+
125
+ self.detected_framework = framework_key
126
+ self.hmr_config = {
127
+ 'framework': framework_key,
128
+ 'name': config['name'],
129
+ 'ws_url': ws_url,
130
+ 'ws_path': ws_path,
131
+ 'event_patterns': config['event_patterns'],
132
+ 'css_indicators': config['css_update_indicators']
133
+ }
134
+
135
+ self.logger.info(f"✅ Detected HMR framework: {config['name']} at {ws_url}")
136
+ return framework_key
137
+
138
+ except (ConnectionClosed, InvalidURI, OSError, asyncio.TimeoutError) as e:
139
+ self.logger.debug(f"WebSocket test failed for {ws_url}: {e}")
140
+ continue
141
+
142
+ self.logger.warning("❌ No HMR framework detected - CSS iteration will use fallback timing")
143
+ return None
144
+
145
+ async def start_monitoring(self, on_hmr_event: Optional[Callable] = None) -> bool:
146
+ """
147
+ Start monitoring HMR events
148
+
149
+ Args:
150
+ on_hmr_event: Optional callback function for HMR events
151
+
152
+ Returns:
153
+ True if monitoring started successfully, False otherwise
154
+ """
155
+ if not self.hmr_config:
156
+ await self.auto_detect_framework()
157
+
158
+ if not self.hmr_config:
159
+ self.logger.warning("Cannot start HMR monitoring - no framework detected")
160
+ return False
161
+
162
+ try:
163
+ ws_url = self.hmr_config['ws_url']
164
+ self.logger.info(f"Starting HMR monitoring for {self.hmr_config['name']} at {ws_url}")
165
+
166
+ self.websocket = await websockets.connect(ws_url, ping_interval=20)
167
+ self.is_monitoring = True
168
+
169
+ if on_hmr_event:
170
+ self.event_callbacks.append(on_hmr_event)
171
+
172
+ # Start the monitoring loop
173
+ asyncio.create_task(self._monitor_hmr_events())
174
+
175
+ self.logger.info("✅ HMR monitoring started successfully")
176
+ return True
177
+
178
+ except Exception as e:
179
+ self.logger.error(f"Failed to start HMR monitoring: {e}")
180
+ self.is_monitoring = False
181
+ return False
182
+
183
+ async def _monitor_hmr_events(self):
184
+ """Internal method to monitor WebSocket messages for HMR events"""
185
+ try:
186
+ async for message in self.websocket:
187
+ await self._process_hmr_message(message)
188
+
189
+ except ConnectionClosed:
190
+ self.logger.info("HMR WebSocket connection closed")
191
+ self.is_monitoring = False
192
+ except Exception as e:
193
+ self.logger.error(f"HMR monitoring error: {e}")
194
+ self.is_monitoring = False
195
+
196
+ async def _process_hmr_message(self, message: str):
197
+ """Process incoming HMR WebSocket messages"""
198
+ try:
199
+ # Parse message (could be JSON or plain text depending on framework)
200
+ try:
201
+ data = json.loads(message)
202
+ message_type = 'json'
203
+ except json.JSONDecodeError:
204
+ data = message
205
+ message_type = 'text'
206
+
207
+ # Create HMR event record
208
+ hmr_event = {
209
+ 'timestamp': time.time(),
210
+ 'framework': self.hmr_config['framework'],
211
+ 'message_type': message_type,
212
+ 'raw_message': message,
213
+ 'parsed_data': data if message_type == 'json' else None,
214
+ 'event_type': self._classify_hmr_event(data, message),
215
+ 'is_css_update': self._is_css_update(data, message)
216
+ }
217
+
218
+ # Store the event
219
+ self.hmr_events.append(hmr_event)
220
+
221
+ # Keep only last 100 events to prevent memory issues
222
+ if len(self.hmr_events) > 100:
223
+ self.hmr_events = self.hmr_events[-100:]
224
+
225
+ # Notify callbacks
226
+ for callback in self.event_callbacks:
227
+ try:
228
+ await callback(hmr_event)
229
+ except Exception as e:
230
+ self.logger.error(f"HMR event callback error: {e}")
231
+
232
+ # Log important events
233
+ if hmr_event['is_css_update']:
234
+ self.logger.info(f"🎨 CSS update detected: {hmr_event['event_type']}")
235
+ elif hmr_event['event_type'] != 'heartbeat':
236
+ self.logger.debug(f"HMR event: {hmr_event['event_type']}")
237
+
238
+ except Exception as e:
239
+ self.logger.error(f"Error processing HMR message: {e}")
240
+
241
+ def _classify_hmr_event(self, data: Any, raw_message: str) -> str:
242
+ """Classify the type of HMR event based on message content"""
243
+ if not self.hmr_config:
244
+ return 'unknown'
245
+
246
+ framework = self.hmr_config['framework']
247
+ event_patterns = self.hmr_config['event_patterns']
248
+
249
+ # Convert to string for pattern matching
250
+ message_str = str(data).lower() if data else raw_message.lower()
251
+
252
+ # Framework-specific event classification
253
+ if framework == 'vite':
254
+ if 'vite:beforeupdate' in message_str:
255
+ return 'build_start'
256
+ elif 'vite:afterupdate' in message_str or 'css-update' in message_str:
257
+ return 'css_update'
258
+ elif 'connected' in message_str:
259
+ return 'connection'
260
+ elif 'ping' in message_str or 'pong' in message_str:
261
+ return 'heartbeat'
262
+
263
+ elif framework == 'webpack':
264
+ if 'webpackhotupdate' in message_str:
265
+ return 'hot_update'
266
+ elif 'building' in message_str:
267
+ return 'build_start'
268
+ elif 'built' in message_str:
269
+ return 'build_complete'
270
+ elif 'hash' in message_str:
271
+ return 'hash_update'
272
+
273
+ elif framework == 'nextjs':
274
+ if 'building' in message_str:
275
+ return 'build_start'
276
+ elif 'built' in message_str:
277
+ return 'build_complete'
278
+ elif 'sync' in message_str:
279
+ return 'sync'
280
+
281
+ elif framework == 'parcel':
282
+ if 'buildsuccess' in message_str:
283
+ return 'build_success'
284
+ elif 'builderror' in message_str:
285
+ return 'build_error'
286
+ elif 'hmr:update' in message_str:
287
+ return 'hmr_update'
288
+
289
+ elif framework == 'laravel_mix':
290
+ if 'file:changed' in message_str:
291
+ return 'file_change'
292
+ elif 'browser:reload' in message_str:
293
+ return 'browser_reload'
294
+
295
+ # Check against general patterns
296
+ for pattern in event_patterns:
297
+ if pattern.lower() in message_str:
298
+ return pattern
299
+
300
+ return 'unknown'
301
+
302
+ def _is_css_update(self, data: Any, raw_message: str) -> bool:
303
+ """Determine if this HMR event represents a CSS update"""
304
+ if not self.hmr_config:
305
+ return False
306
+
307
+ css_indicators = self.hmr_config['css_indicators']
308
+ message_str = str(data).lower() if data else raw_message.lower()
309
+
310
+ # Check for CSS-specific indicators
311
+ for indicator in css_indicators:
312
+ if indicator in message_str:
313
+ return True
314
+
315
+ # Additional CSS detection patterns
316
+ css_patterns = ['.css', '.scss', '.sass', '.less', 'style', 'stylesheet']
317
+ for pattern in css_patterns:
318
+ if pattern in message_str:
319
+ return True
320
+
321
+ return False
322
+
323
+ async def wait_for_css_update(self, timeout: float = 10.0) -> Optional[Dict]:
324
+ """
325
+ Wait for the next CSS update event with precision timing
326
+
327
+ This is the key method that replaces arbitrary waits in CSS iteration:
328
+
329
+ OLD WAY:
330
+ await page.screenshot("before.png")
331
+ # ... developer makes CSS changes ...
332
+ await page.wait_for_timeout(2000) # Arbitrary wait
333
+ await page.screenshot("after.png")
334
+
335
+ NEW WAY:
336
+ await page.screenshot("before.png")
337
+ # ... developer makes CSS changes ...
338
+ css_event = await hmr_detector.wait_for_css_update() # Precise timing
339
+ await page.screenshot("after.png")
340
+
341
+ Args:
342
+ timeout: Maximum time to wait for CSS update (seconds)
343
+
344
+ Returns:
345
+ HMR event dict if CSS update detected, None if timeout
346
+ """
347
+ if not self.is_monitoring:
348
+ self.logger.warning("HMR monitoring not active - cannot wait for CSS updates")
349
+ return None
350
+
351
+ start_time = time.time()
352
+ initial_event_count = len(self.hmr_events)
353
+
354
+ self.logger.info(f"⏱️ Waiting for CSS update (timeout: {timeout}s)")
355
+
356
+ while time.time() - start_time < timeout:
357
+ # Check for new CSS update events
358
+ for event in self.hmr_events[initial_event_count:]:
359
+ if event['is_css_update']:
360
+ self.logger.info(f"✅ CSS update detected after {time.time() - start_time:.2f}s")
361
+ return event
362
+
363
+ # Short sleep to prevent busy waiting
364
+ await asyncio.sleep(0.1)
365
+
366
+ self.logger.warning(f"⏰ CSS update wait timeout after {timeout}s")
367
+ return None
368
+
369
+ async def wait_for_build_complete(self, timeout: float = 30.0) -> Optional[Dict]:
370
+ """
371
+ Wait for build completion (useful for more complex changes)
372
+
373
+ Args:
374
+ timeout: Maximum time to wait for build completion (seconds)
375
+
376
+ Returns:
377
+ HMR event dict if build completed, None if timeout
378
+ """
379
+ if not self.is_monitoring:
380
+ self.logger.warning("HMR monitoring not active - cannot wait for build completion")
381
+ return None
382
+
383
+ start_time = time.time()
384
+ initial_event_count = len(self.hmr_events)
385
+
386
+ self.logger.info(f"⏱️ Waiting for build completion (timeout: {timeout}s)")
387
+
388
+ build_complete_indicators = ['build_complete', 'build_success', 'css_update', 'hot_update']
389
+
390
+ while time.time() - start_time < timeout:
391
+ # Check for build completion events
392
+ for event in self.hmr_events[initial_event_count:]:
393
+ if event['event_type'] in build_complete_indicators:
394
+ self.logger.info(f"✅ Build completed after {time.time() - start_time:.2f}s")
395
+ return event
396
+
397
+ await asyncio.sleep(0.1)
398
+
399
+ self.logger.warning(f"⏰ Build completion wait timeout after {timeout}s")
400
+ return None
401
+
402
+ async def stop_monitoring(self):
403
+ """Stop HMR monitoring and close WebSocket connection"""
404
+ self.is_monitoring = False
405
+
406
+ if self.websocket:
407
+ try:
408
+ await self.websocket.close()
409
+ self.logger.info("HMR monitoring stopped")
410
+ except Exception as e:
411
+ self.logger.error(f"Error stopping HMR monitoring: {e}")
412
+
413
+ self.websocket = None
414
+
415
+ def get_hmr_status(self) -> Dict[str, Any]:
416
+ """Get current HMR detection and monitoring status"""
417
+ return {
418
+ 'framework_detected': self.detected_framework,
419
+ 'framework_name': self.hmr_config['name'] if self.hmr_config else None,
420
+ 'is_monitoring': self.is_monitoring,
421
+ 'websocket_url': self.hmr_config['ws_url'] if self.hmr_config else None,
422
+ 'total_events': len(self.hmr_events),
423
+ 'css_events': len([e for e in self.hmr_events if e['is_css_update']]),
424
+ 'recent_events': self.hmr_events[-5:] if self.hmr_events else []
425
+ }
426
+
427
+ def get_framework_info(self) -> Dict[str, Any]:
428
+ """Get information about the detected framework"""
429
+ if not self.hmr_config:
430
+ return {'detected': False, 'supported_frameworks': list(self.framework_configs.keys())}
431
+
432
+ return {
433
+ 'detected': True,
434
+ 'framework': self.hmr_config['framework'],
435
+ 'name': self.hmr_config['name'],
436
+ 'websocket_path': self.hmr_config['ws_path'],
437
+ 'css_indicators': self.hmr_config['css_indicators'],
438
+ 'event_patterns': self.hmr_config['event_patterns']
439
+ }
@@ -0,0 +1,209 @@
1
+ """
2
+ Trace Manager for CursorFlow v2.0
3
+
4
+ Manages Playwright trace file recording for complete interaction history.
5
+ Pure observation - records everything without modifying application behavior.
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ import logging
11
+ from typing import Optional, Dict, Any
12
+ from pathlib import Path
13
+ from playwright.async_api import BrowserContext
14
+
15
+
16
+ class TraceManager:
17
+ """
18
+ Manages Playwright trace recording for comprehensive debugging data
19
+
20
+ Provides complete interaction history, screenshots, and network activity
21
+ in Playwright's native trace format for maximum debugging capability.
22
+ """
23
+
24
+ def __init__(self, artifacts_base: Path):
25
+ """
26
+ Initialize trace manager
27
+
28
+ Args:
29
+ artifacts_base: Base directory for storing trace files
30
+ """
31
+ self.artifacts_base = artifacts_base
32
+ self.traces_dir = artifacts_base / "traces"
33
+ self.traces_dir.mkdir(parents=True, exist_ok=True)
34
+
35
+ self.current_trace_path: Optional[Path] = None
36
+ self.is_recording = False
37
+ self.session_id: Optional[str] = None
38
+
39
+ self.logger = logging.getLogger(__name__)
40
+
41
+ async def start_trace(self, context: BrowserContext, session_id: str) -> str:
42
+ """
43
+ Start Playwright trace recording
44
+
45
+ Args:
46
+ context: Browser context to record
47
+ session_id: Unique session identifier
48
+
49
+ Returns:
50
+ Path where trace will be saved
51
+ """
52
+ try:
53
+ self.session_id = session_id
54
+ self.current_trace_path = self.traces_dir / f"{session_id}.zip"
55
+
56
+ # Start comprehensive trace recording
57
+ await context.tracing.start(
58
+ screenshots=True, # Capture screenshots at each action
59
+ snapshots=True, # Capture DOM snapshots
60
+ sources=True, # Include source code context
61
+ title=f"CursorFlow Session {session_id}"
62
+ )
63
+
64
+ self.is_recording = True
65
+ self.logger.info(f"Started trace recording: {self.current_trace_path}")
66
+
67
+ return str(self.current_trace_path)
68
+
69
+ except Exception as e:
70
+ self.logger.error(f"Failed to start trace recording: {e}")
71
+ self.is_recording = False
72
+ raise
73
+
74
+ async def stop_trace(self, context: BrowserContext) -> Optional[str]:
75
+ """
76
+ Stop trace recording and save file
77
+
78
+ Args:
79
+ context: Browser context being recorded
80
+
81
+ Returns:
82
+ Path to saved trace file, or None if recording wasn't active
83
+ """
84
+ if not self.is_recording or not self.current_trace_path:
85
+ return None
86
+
87
+ try:
88
+ await context.tracing.stop(path=str(self.current_trace_path))
89
+
90
+ trace_path = str(self.current_trace_path)
91
+ file_size = self.current_trace_path.stat().st_size if self.current_trace_path.exists() else 0
92
+
93
+ self.logger.info(f"Trace recording saved: {trace_path} ({file_size:,} bytes)")
94
+
95
+ # Reset state
96
+ self.is_recording = False
97
+ self.current_trace_path = None
98
+ self.session_id = None
99
+
100
+ return trace_path
101
+
102
+ except Exception as e:
103
+ self.logger.error(f"Failed to stop trace recording: {e}")
104
+ self.is_recording = False
105
+ return None
106
+
107
+ async def stop_trace_on_error(self, context: BrowserContext, error: Exception) -> Optional[str]:
108
+ """
109
+ Stop trace recording when an error occurs, with error context
110
+
111
+ Args:
112
+ context: Browser context being recorded
113
+ error: The error that occurred
114
+
115
+ Returns:
116
+ Path to saved trace file with error context
117
+ """
118
+ if not self.is_recording or not self.current_trace_path:
119
+ return None
120
+
121
+ try:
122
+ # Add error context to trace filename
123
+ error_trace_path = self.traces_dir / f"{self.session_id}_ERROR_{type(error).__name__}.zip"
124
+
125
+ await context.tracing.stop(path=str(error_trace_path))
126
+
127
+ file_size = error_trace_path.stat().st_size if error_trace_path.exists() else 0
128
+
129
+ self.logger.info(f"Error trace saved: {error_trace_path} ({file_size:,} bytes)")
130
+ self.logger.info(f"View trace: playwright show-trace {error_trace_path}")
131
+
132
+ # Reset state
133
+ self.is_recording = False
134
+ self.current_trace_path = None
135
+ self.session_id = None
136
+
137
+ return str(error_trace_path)
138
+
139
+ except Exception as trace_error:
140
+ self.logger.error(f"Failed to save error trace: {trace_error}")
141
+ self.is_recording = False
142
+ return None
143
+
144
+ def get_trace_info(self) -> Dict[str, Any]:
145
+ """
146
+ Get current trace recording information
147
+
148
+ Returns:
149
+ Dictionary with trace status and metadata
150
+ """
151
+ return {
152
+ "is_recording": self.is_recording,
153
+ "session_id": self.session_id,
154
+ "trace_path": str(self.current_trace_path) if self.current_trace_path else None,
155
+ "traces_directory": str(self.traces_dir),
156
+ "total_traces": len(list(self.traces_dir.glob("*.zip")))
157
+ }
158
+
159
+ def cleanup_old_traces(self, max_traces: int = 50) -> int:
160
+ """
161
+ Clean up old trace files to prevent disk space issues
162
+
163
+ Args:
164
+ max_traces: Maximum number of trace files to keep
165
+
166
+ Returns:
167
+ Number of traces deleted
168
+ """
169
+ try:
170
+ trace_files = sorted(
171
+ self.traces_dir.glob("*.zip"),
172
+ key=lambda p: p.stat().st_mtime,
173
+ reverse=True # Newest first
174
+ )
175
+
176
+ if len(trace_files) <= max_traces:
177
+ return 0
178
+
179
+ # Delete oldest traces
180
+ traces_to_delete = trace_files[max_traces:]
181
+ deleted_count = 0
182
+
183
+ for trace_file in traces_to_delete:
184
+ try:
185
+ trace_file.unlink()
186
+ deleted_count += 1
187
+ except Exception as e:
188
+ self.logger.warning(f"Failed to delete old trace {trace_file}: {e}")
189
+
190
+ if deleted_count > 0:
191
+ self.logger.info(f"Cleaned up {deleted_count} old trace files")
192
+
193
+ return deleted_count
194
+
195
+ except Exception as e:
196
+ self.logger.error(f"Trace cleanup failed: {e}")
197
+ return 0
198
+
199
+ def get_viewing_instructions(self, trace_path: str) -> str:
200
+ """
201
+ Get instructions for viewing a trace file
202
+
203
+ Args:
204
+ trace_path: Path to the trace file
205
+
206
+ Returns:
207
+ Command to view the trace
208
+ """
209
+ return f"playwright show-trace {trace_path}"