cursorflow 2.6.3__py3-none-any.whl → 2.7.2__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,1342 @@
1
+ """
2
+ Query Engine - Fast Data Extraction from Test Results
3
+
4
+ Provides rapid filtering and extraction of test data without
5
+ manual JSON parsing. Pure data retrieval without analysis.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Dict, Any, List, Optional
11
+ import csv
12
+ from io import StringIO
13
+
14
+
15
+ class QueryEngine:
16
+ """
17
+ Query interface for CursorFlow test results.
18
+
19
+ Supports:
20
+ - Filtering by data type (errors, network, console, performance)
21
+ - Status code filtering for network requests
22
+ - Export in multiple formats (json, markdown, csv)
23
+ - Session comparison
24
+ """
25
+
26
+ def __init__(self, artifacts_dir: str = ".cursorflow/artifacts"):
27
+ self.artifacts_dir = Path(artifacts_dir)
28
+
29
+ def query_session(
30
+ self,
31
+ session_id: str,
32
+ query_type: Optional[str] = None,
33
+ filters: Optional[Dict] = None,
34
+ export_format: str = "json"
35
+ ) -> Any:
36
+ """
37
+ Query session data with optional filtering.
38
+
39
+ Args:
40
+ session_id: Session identifier
41
+ query_type: Type of data to query (errors, network, console, performance, summary)
42
+ filters: Additional filtering criteria
43
+ export_format: Output format (json, markdown, csv)
44
+
45
+ Returns:
46
+ Filtered data in requested format
47
+ """
48
+ session_dir = self.artifacts_dir / "sessions" / session_id
49
+
50
+ if not session_dir.exists():
51
+ raise ValueError(f"Session not found: {session_id}")
52
+
53
+ # Phase 3: Contextual queries
54
+ if filters and 'context_for_error' in filters:
55
+ return self._get_error_context(session_dir, filters, export_format)
56
+
57
+ if filters and 'group_by_url' in filters:
58
+ return self._group_by_url(session_dir, filters, export_format)
59
+
60
+ if filters and 'group_by_selector' in filters:
61
+ return self._group_by_selector(session_dir, filters, export_format)
62
+
63
+ # Load requested data
64
+ if query_type == "errors":
65
+ data = self._query_errors(session_dir, filters)
66
+ elif query_type == "network":
67
+ data = self._query_network(session_dir, filters)
68
+ elif query_type == "console":
69
+ data = self._query_console(session_dir, filters)
70
+ elif query_type == "performance":
71
+ data = self._query_performance(session_dir, filters)
72
+ elif query_type == "summary":
73
+ data = self._query_summary(session_dir)
74
+ elif query_type == "dom":
75
+ data = self._query_dom(session_dir, filters)
76
+ elif query_type == "server_logs":
77
+ data = self._query_server_logs(session_dir, filters)
78
+ elif query_type == "screenshots":
79
+ data = self._query_screenshots(session_dir, filters)
80
+ elif query_type == "mockup":
81
+ data = self._query_mockup(session_dir, filters)
82
+ elif query_type == "responsive":
83
+ data = self._query_responsive(session_dir, filters)
84
+ elif query_type == "css_iterations":
85
+ data = self._query_css_iterations(session_dir, filters)
86
+ elif query_type == "timeline":
87
+ data = self._query_timeline(session_dir, filters)
88
+ else:
89
+ # Return all data references
90
+ data = self._query_all(session_dir)
91
+
92
+ # Export in requested format
93
+ return self._export_data(data, export_format, query_type or "all")
94
+
95
+ def compare_sessions(
96
+ self,
97
+ session_id_a: str,
98
+ session_id_b: str,
99
+ query_type: Optional[str] = None
100
+ ) -> Dict[str, Any]:
101
+ """
102
+ Compare two sessions and identify differences.
103
+
104
+ Args:
105
+ session_id_a: First session ID
106
+ session_id_b: Second session ID
107
+ query_type: Optional specific data type to compare
108
+
109
+ Returns:
110
+ Comparison results showing differences
111
+ """
112
+ session_a = self.artifacts_dir / "sessions" / session_id_a
113
+ session_b = self.artifacts_dir / "sessions" / session_id_b
114
+
115
+ if not session_a.exists():
116
+ raise ValueError(f"Session not found: {session_id_a}")
117
+ if not session_b.exists():
118
+ raise ValueError(f"Session not found: {session_id_b}")
119
+
120
+ # Compare summaries
121
+ summary_a = self._load_json(session_a / "summary.json")
122
+ summary_b = self._load_json(session_b / "summary.json")
123
+
124
+ comparison = {
125
+ "session_a": session_id_a,
126
+ "session_b": session_id_b,
127
+ "summary_diff": self._compare_summaries(summary_a, summary_b)
128
+ }
129
+
130
+ # Compare specific data types if requested
131
+ if query_type == "errors":
132
+ errors_a = self._load_json(session_a / "errors.json")
133
+ errors_b = self._load_json(session_b / "errors.json")
134
+ comparison["errors_diff"] = self._compare_errors(errors_a, errors_b)
135
+
136
+ elif query_type == "network":
137
+ network_a = self._load_json(session_a / "network.json")
138
+ network_b = self._load_json(session_b / "network.json")
139
+ comparison["network_diff"] = self._compare_network(network_a, network_b)
140
+
141
+ elif query_type == "performance":
142
+ perf_a = self._load_json(session_a / "performance.json")
143
+ perf_b = self._load_json(session_b / "performance.json")
144
+ comparison["performance_diff"] = self._compare_performance(perf_a, perf_b)
145
+
146
+ return comparison
147
+
148
+ def list_sessions(self, limit: int = 10) -> List[Dict]:
149
+ """List recent sessions with basic info"""
150
+ sessions_dir = self.artifacts_dir / "sessions"
151
+
152
+ if not sessions_dir.exists():
153
+ return []
154
+
155
+ sessions = []
156
+ for session_dir in sorted(sessions_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True)[:limit]:
157
+ if session_dir.is_dir():
158
+ summary = self._load_json(session_dir / "summary.json")
159
+ sessions.append({
160
+ "session_id": session_dir.name,
161
+ "timestamp": summary.get('timestamp', 'unknown'),
162
+ "success": summary.get('success', False),
163
+ "errors": summary.get('metrics', {}).get('total_errors', 0),
164
+ "network_failures": summary.get('metrics', {}).get('failed_network_requests', 0)
165
+ })
166
+
167
+ return sessions
168
+
169
+ def _query_errors(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
170
+ """Query error data with optional filtering"""
171
+ errors = self._load_json(session_dir / "errors.json")
172
+
173
+ if not filters:
174
+ return errors
175
+
176
+ all_errors = errors.get('all_errors', [])
177
+ filtered_errors = all_errors
178
+
179
+ # Filter by error type
180
+ if 'error_type' in filters:
181
+ error_type = filters['error_type']
182
+ filtered_errors = [
183
+ err for err in filtered_errors
184
+ if self._categorize_error_type(err.get('message', '')) == error_type
185
+ ]
186
+
187
+ # Filter by severity (critical = has errors)
188
+ if 'severity' in filters:
189
+ severity = filters['severity']
190
+ if severity == 'critical':
191
+ filtered_errors = all_errors
192
+
193
+ # Phase 1: Enhanced filtering
194
+
195
+ # Filter by source file/pattern
196
+ if 'from_file' in filters:
197
+ file_pattern = filters['from_file']
198
+ filtered_errors = [
199
+ err for err in filtered_errors
200
+ if file_pattern in err.get('source', '')
201
+ ]
202
+
203
+ if 'from_pattern' in filters:
204
+ import fnmatch
205
+ pattern = filters['from_pattern']
206
+ filtered_errors = [
207
+ err for err in filtered_errors
208
+ if fnmatch.fnmatch(err.get('source', ''), pattern)
209
+ ]
210
+
211
+ # Filter by message content
212
+ if 'contains' in filters:
213
+ search_term = filters['contains']
214
+ filtered_errors = [
215
+ err for err in filtered_errors
216
+ if search_term.lower() in err.get('message', '').lower()
217
+ ]
218
+
219
+ if 'matches' in filters:
220
+ import re
221
+ regex = filters['matches']
222
+ filtered_errors = [
223
+ err for err in filtered_errors
224
+ if re.search(regex, err.get('message', ''), re.IGNORECASE)
225
+ ]
226
+
227
+ # Filter by timestamp range
228
+ if 'after' in filters:
229
+ after_time = float(filters['after'])
230
+ filtered_errors = [
231
+ err for err in filtered_errors
232
+ if err.get('timestamp', 0) >= after_time
233
+ ]
234
+
235
+ if 'before' in filters:
236
+ before_time = float(filters['before'])
237
+ filtered_errors = [
238
+ err for err in filtered_errors
239
+ if err.get('timestamp', 0) <= before_time
240
+ ]
241
+
242
+ if 'between' in filters:
243
+ times = filters['between'].split(',')
244
+ start_time = float(times[0].strip())
245
+ end_time = float(times[1].strip())
246
+ filtered_errors = [
247
+ err for err in filtered_errors
248
+ if start_time <= err.get('timestamp', 0) <= end_time
249
+ ]
250
+
251
+ # Phase 2: Cross-referencing
252
+ if 'with_network' in filters and filters['with_network']:
253
+ filtered_errors = self._add_related_network(session_dir, filtered_errors)
254
+
255
+ if 'with_console' in filters and filters['with_console']:
256
+ filtered_errors = self._add_related_console(session_dir, filtered_errors)
257
+
258
+ if 'with_server_logs' in filters and filters['with_server_logs']:
259
+ filtered_errors = self._add_related_server_logs(session_dir, filtered_errors)
260
+
261
+ return {
262
+ 'total_errors': len(filtered_errors),
263
+ 'errors': filtered_errors
264
+ }
265
+
266
+ def _categorize_error_type(self, error_message: str) -> str:
267
+ """Categorize error by type"""
268
+ error_message_lower = error_message.lower()
269
+
270
+ if 'syntaxerror' in error_message_lower or 'unexpected' in error_message_lower:
271
+ return 'syntax_error'
272
+ elif 'referenceerror' in error_message_lower or 'not defined' in error_message_lower:
273
+ return 'reference_error'
274
+ elif 'typeerror' in error_message_lower:
275
+ return 'type_error'
276
+ elif 'networkerror' in error_message_lower or 'failed to fetch' in error_message_lower:
277
+ return 'network_error'
278
+ elif 'load' in error_message_lower:
279
+ return 'load_error'
280
+ else:
281
+ return 'other_error'
282
+
283
+ def _query_network(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
284
+ """Query network data with optional filtering"""
285
+ network = self._load_json(session_dir / "network.json")
286
+
287
+ if not filters:
288
+ return network
289
+
290
+ all_requests = network.get('all_requests', [])
291
+ filtered_requests = all_requests
292
+
293
+ # Filter by status code
294
+ if 'status' in filters:
295
+ status_filter = filters['status']
296
+
297
+ # Support ranges like "4xx", "5xx"
298
+ if status_filter.endswith('xx'):
299
+ prefix = int(status_filter[0])
300
+ filtered_requests = [
301
+ req for req in filtered_requests
302
+ if str(req.get('status_code', 0)).startswith(str(prefix))
303
+ ]
304
+ # Support specific codes like "404,500"
305
+ elif ',' in status_filter:
306
+ codes = [int(c.strip()) for c in status_filter.split(',')]
307
+ filtered_requests = [
308
+ req for req in filtered_requests
309
+ if req.get('status_code', 0) in codes
310
+ ]
311
+ else:
312
+ code = int(status_filter)
313
+ filtered_requests = [
314
+ req for req in filtered_requests
315
+ if req.get('status_code', 0) == code
316
+ ]
317
+
318
+ # Filter failed requests only
319
+ if 'failed' in filters and filters['failed']:
320
+ # Use failed_requests array directly (already filtered)
321
+ filtered_requests = network.get('failed_requests', [])
322
+
323
+ # Phase 1: Enhanced network filtering
324
+
325
+ # Filter by URL patterns
326
+ if 'url_contains' in filters:
327
+ pattern = filters['url_contains']
328
+ filtered_requests = [
329
+ req for req in filtered_requests
330
+ if pattern in req.get('url', '')
331
+ ]
332
+
333
+ if 'url_matches' in filters:
334
+ import re
335
+ regex = filters['url_matches']
336
+ filtered_requests = [
337
+ req for req in filtered_requests
338
+ if re.search(regex, req.get('url', ''), re.IGNORECASE)
339
+ ]
340
+
341
+ # Filter by timing thresholds
342
+ if 'over' in filters:
343
+ # Parse timing (could be "500ms" or just "500")
344
+ threshold_str = str(filters['over']).replace('ms', '')
345
+ threshold = float(threshold_str)
346
+ filtered_requests = [
347
+ req for req in filtered_requests
348
+ if req.get('timing', {}).get('duration', 0) > threshold
349
+ ]
350
+
351
+ if 'between_timing' in filters:
352
+ times = filters['between_timing'].replace('ms', '').split(',')
353
+ min_time = float(times[0].strip())
354
+ max_time = float(times[1].strip())
355
+ filtered_requests = [
356
+ req for req in filtered_requests
357
+ if min_time <= req.get('timing', {}).get('duration', 0) <= max_time
358
+ ]
359
+
360
+ # Filter by HTTP method
361
+ if 'method' in filters:
362
+ methods = [m.strip().upper() for m in filters['method'].split(',')]
363
+ filtered_requests = [
364
+ req for req in filtered_requests
365
+ if req.get('method', 'GET').upper() in methods
366
+ ]
367
+
368
+ # Phase 2: Cross-referencing
369
+ if 'with_errors' in filters and filters['with_errors']:
370
+ filtered_requests = self._add_related_errors_to_network(session_dir, filtered_requests)
371
+
372
+ if 'with_console' in filters and filters['with_console']:
373
+ filtered_requests = self._add_related_console_to_network(session_dir, filtered_requests)
374
+
375
+ return {
376
+ 'total_requests': len(filtered_requests),
377
+ 'requests': filtered_requests
378
+ }
379
+
380
+ def _query_console(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
381
+ """Query console messages with optional filtering"""
382
+ console = self._load_json(session_dir / "console.json")
383
+
384
+ if not filters:
385
+ return console
386
+
387
+ # Filter by message type
388
+ if 'type' in filters:
389
+ msg_types = filters['type'].split(',')
390
+ messages_by_type = console.get('messages_by_type', {})
391
+
392
+ filtered_messages = []
393
+ for msg_type in msg_types:
394
+ filtered_messages.extend(messages_by_type.get(msg_type.strip(), []))
395
+
396
+ return {
397
+ 'total_messages': len(filtered_messages),
398
+ 'messages': filtered_messages
399
+ }
400
+
401
+ return console
402
+
403
+ def _query_performance(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
404
+ """Query performance data"""
405
+ return self._load_json(session_dir / "performance.json")
406
+
407
+ def _query_summary(self, session_dir: Path) -> Dict:
408
+ """Query summary data"""
409
+ return self._load_json(session_dir / "summary.json")
410
+
411
+ def _query_dom(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
412
+ """Query DOM analysis data"""
413
+ dom = self._load_json(session_dir / "dom_analysis.json")
414
+
415
+ if not filters:
416
+ return dom
417
+
418
+ elements = dom.get('elements', [])
419
+ filtered_elements = elements
420
+
421
+ # Phase 1: Enhanced DOM filtering
422
+
423
+ # Filter by selector (improved matching)
424
+ if 'selector' in filters or 'select' in filters:
425
+ selector = filters.get('selector') or filters.get('select')
426
+ filtered_elements = [
427
+ el for el in filtered_elements
428
+ if (selector in el.get('uniqueSelector', '') or
429
+ selector in el.get('tagName', '') or
430
+ selector == el.get('id', '') or
431
+ selector in el.get('classes', []))
432
+ ]
433
+
434
+ # Filter by attributes
435
+ if 'with_attr' in filters:
436
+ attr_name = filters['with_attr']
437
+ filtered_elements = [
438
+ el for el in filtered_elements
439
+ if attr_name in el.get('attributes', {})
440
+ ]
441
+
442
+ # Filter by ARIA role
443
+ if 'role' in filters:
444
+ role = filters['role']
445
+ filtered_elements = [
446
+ el for el in filtered_elements
447
+ if el.get('accessibility', {}).get('role') == role
448
+ ]
449
+
450
+ # Filter by visibility
451
+ if 'visible' in filters and filters['visible']:
452
+ filtered_elements = [
453
+ el for el in filtered_elements
454
+ if el.get('visual_context', {}).get('isVisible', True)
455
+ ]
456
+
457
+ # Filter by interactivity
458
+ if 'interactive' in filters and filters['interactive']:
459
+ filtered_elements = [
460
+ el for el in filtered_elements
461
+ if el.get('accessibility', {}).get('isInteractive', False)
462
+ ]
463
+
464
+ # Filter elements with errors (from console errors)
465
+ if 'has_errors' in filters and filters['has_errors']:
466
+ # This would need error context - skip for now or add cross-ref
467
+ pass
468
+
469
+ return {
470
+ 'total_elements': len(filtered_elements),
471
+ 'elements': filtered_elements
472
+ }
473
+
474
+ def _query_server_logs(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
475
+ """Query server logs with filtering"""
476
+ server_logs = self._load_json(session_dir / "server_logs.json")
477
+
478
+ if not filters:
479
+ return server_logs
480
+
481
+ all_logs = server_logs.get('all_logs', [])
482
+ filtered_logs = all_logs
483
+
484
+ # Filter by severity
485
+ if 'severity' in filters or 'level' in filters:
486
+ severity = filters.get('severity') or filters.get('level')
487
+ severities = [s.strip() for s in severity.split(',')]
488
+ filtered_logs = [
489
+ log for log in filtered_logs
490
+ if log.get('severity', 'info').lower() in [s.lower() for s in severities]
491
+ ]
492
+
493
+ # Filter by source (ssh, local, docker, systemd)
494
+ if 'source' in filters:
495
+ source = filters['source']
496
+ filtered_logs = [
497
+ log for log in filtered_logs
498
+ if log.get('source', '').lower() == source.lower()
499
+ ]
500
+
501
+ # Filter by file path
502
+ if 'file' in filters:
503
+ file_filter = filters['file']
504
+ filtered_logs = [
505
+ log for log in filtered_logs
506
+ if file_filter in log.get('file', '')
507
+ ]
508
+
509
+ # Filter by pattern (content search)
510
+ if 'pattern' in filters or 'contains' in filters:
511
+ pattern = filters.get('pattern') or filters.get('contains')
512
+ filtered_logs = [
513
+ log for log in filtered_logs
514
+ if pattern.lower() in log.get('content', '').lower()
515
+ ]
516
+
517
+ # Filter by regex match
518
+ if 'matches' in filters:
519
+ import re
520
+ regex = filters['matches']
521
+ filtered_logs = [
522
+ log for log in filtered_logs
523
+ if re.search(regex, log.get('content', ''), re.IGNORECASE)
524
+ ]
525
+
526
+ # Filter by timestamp
527
+ if 'after' in filters:
528
+ after_time = float(filters['after'])
529
+ filtered_logs = [
530
+ log for log in filtered_logs
531
+ if log.get('timestamp', 0) >= after_time
532
+ ]
533
+
534
+ if 'before' in filters:
535
+ before_time = float(filters['before'])
536
+ filtered_logs = [
537
+ log for log in filtered_logs
538
+ if log.get('timestamp', 0) <= before_time
539
+ ]
540
+
541
+ if 'between' in filters:
542
+ times = filters['between'].split(',')
543
+ start_time = float(times[0].strip())
544
+ end_time = float(times[1].strip())
545
+ filtered_logs = [
546
+ log for log in filtered_logs
547
+ if start_time <= log.get('timestamp', 0) <= end_time
548
+ ]
549
+
550
+ return {
551
+ 'total_logs': len(filtered_logs),
552
+ 'logs': filtered_logs
553
+ }
554
+
555
+ def _query_screenshots(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
556
+ """Query screenshot metadata"""
557
+ screenshots = self._load_json(session_dir / "screenshots.json")
558
+
559
+ if not filters:
560
+ return screenshots
561
+
562
+ screenshot_list = screenshots.get('screenshots', [])
563
+ filtered_screenshots = screenshot_list
564
+
565
+ # Filter by errors
566
+ if 'with_errors' in filters and filters['with_errors']:
567
+ filtered_screenshots = [s for s in filtered_screenshots if s.get('has_errors')]
568
+
569
+ # Filter by network failures
570
+ if 'with_network_failures' in filters and filters['with_network_failures']:
571
+ filtered_screenshots = [s for s in filtered_screenshots if s.get('has_network_failures')]
572
+
573
+ # Filter by timestamp
574
+ if 'at_timestamp' in filters:
575
+ timestamp = float(filters['at_timestamp'])
576
+ # Find screenshot closest to timestamp
577
+ filtered_screenshots = sorted(
578
+ filtered_screenshots,
579
+ key=lambda s: abs(s.get('timestamp', 0) - timestamp)
580
+ )[:1]
581
+
582
+ # Get specific screenshot by index
583
+ if 'index' in filters:
584
+ index = int(filters['index'])
585
+ if 0 <= index < len(screenshot_list):
586
+ filtered_screenshots = [screenshot_list[index]]
587
+ else:
588
+ filtered_screenshots = []
589
+
590
+ return {
591
+ 'total_screenshots': len(filtered_screenshots),
592
+ 'screenshots': filtered_screenshots
593
+ }
594
+
595
+ def _query_mockup(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
596
+ """Query mockup comparison data"""
597
+ mockup = self._load_json(session_dir / "mockup_comparison.json")
598
+
599
+ if not filters:
600
+ return mockup
601
+
602
+ # Filter by similarity threshold
603
+ if 'similarity_under' in filters:
604
+ threshold = float(filters['similarity_under'])
605
+ if mockup.get('similarity_score', 100) >= threshold:
606
+ return {}
607
+
608
+ if 'similarity_over' in filters:
609
+ threshold = float(filters['similarity_over'])
610
+ if mockup.get('similarity_score', 0) <= threshold:
611
+ return {}
612
+
613
+ # Show differences only
614
+ if 'differences' in filters and filters['differences']:
615
+ return {
616
+ 'similarity_score': mockup.get('similarity_score', 0),
617
+ 'differences': mockup.get('differences', [])
618
+ }
619
+
620
+ # Get specific iteration
621
+ if 'iteration' in filters:
622
+ iteration_idx = int(filters['iteration'])
623
+ iterations = mockup.get('iterations', [])
624
+ if 0 <= iteration_idx < len(iterations):
625
+ return {
626
+ 'iteration': iterations[iteration_idx],
627
+ 'index': iteration_idx
628
+ }
629
+
630
+ return mockup
631
+
632
+ def _query_responsive(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
633
+ """Query responsive testing results"""
634
+ responsive = self._load_json(session_dir / "responsive_results.json")
635
+
636
+ if not filters:
637
+ return responsive
638
+
639
+ # Get specific viewport
640
+ if 'viewport' in filters:
641
+ viewport_name = filters['viewport']
642
+ viewports = responsive.get('viewports', {})
643
+ if viewport_name in viewports:
644
+ return {
645
+ 'viewport': viewport_name,
646
+ 'data': viewports[viewport_name]
647
+ }
648
+
649
+ # Show differences only
650
+ if 'show_differences' in filters and filters['show_differences']:
651
+ return {
652
+ 'comparison': responsive.get('comparison', {})
653
+ }
654
+
655
+ return responsive
656
+
657
+ def _query_css_iterations(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
658
+ """Query CSS iteration results"""
659
+ css_iterations = self._load_json(session_dir / "css_iterations.json")
660
+
661
+ if not filters:
662
+ return css_iterations
663
+
664
+ iterations = css_iterations.get('iterations', [])
665
+
666
+ # Get specific iteration
667
+ if 'iteration' in filters:
668
+ iteration_idx = int(filters['iteration']) - 1 # User provides 1-based
669
+ if 0 <= iteration_idx < len(iterations):
670
+ return {
671
+ 'iteration': iterations[iteration_idx],
672
+ 'index': iteration_idx + 1
673
+ }
674
+
675
+ # Filter iterations with errors
676
+ if 'with_errors' in filters and filters['with_errors']:
677
+ filtered = [
678
+ it for it in iterations
679
+ if it.get('console_errors') or it.get('has_errors')
680
+ ]
681
+ return {
682
+ 'total_iterations': len(filtered),
683
+ 'iterations': filtered
684
+ }
685
+
686
+ # Compare specific iterations
687
+ if 'compare_iterations' in filters:
688
+ indices = [int(i.strip()) - 1 for i in filters['compare_iterations'].split(',')]
689
+ selected = [iterations[i] for i in indices if 0 <= i < len(iterations)]
690
+ return {
691
+ 'compared_iterations': selected,
692
+ 'indices': [i + 1 for i in indices]
693
+ }
694
+
695
+ return css_iterations
696
+
697
+ def _query_timeline(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
698
+ """Query timeline data with filtering"""
699
+ timeline = self._load_json(session_dir / "timeline.json")
700
+
701
+ if not filters:
702
+ return timeline
703
+
704
+ organized_timeline = timeline.get('organized_timeline', [])
705
+ filtered_events = organized_timeline
706
+
707
+ # Filter by event source
708
+ if 'source' in filters:
709
+ source = filters['source']
710
+ filtered_events = [
711
+ event for event in filtered_events
712
+ if event.get('source') == source
713
+ ]
714
+
715
+ # Filter by timestamp window
716
+ if 'around' in filters:
717
+ timestamp = float(filters['around'])
718
+ window = float(filters.get('window', 5))
719
+ filtered_events = [
720
+ event for event in filtered_events
721
+ if abs(event.get('timestamp', 0) - timestamp) <= window
722
+ ]
723
+
724
+ # Filter events before timestamp
725
+ if 'before' in filters:
726
+ timestamp = float(filters['before'])
727
+ filtered_events = [
728
+ event for event in filtered_events
729
+ if event.get('timestamp', 0) < timestamp
730
+ ]
731
+
732
+ # Filter events after timestamp
733
+ if 'after' in filters:
734
+ timestamp = float(filters['after'])
735
+ filtered_events = [
736
+ event for event in filtered_events
737
+ if event.get('timestamp', 0) > timestamp
738
+ ]
739
+
740
+ return {
741
+ 'total_events': len(filtered_events),
742
+ 'events': filtered_events
743
+ }
744
+
745
+ def _query_all(self, session_dir: Path) -> Dict:
746
+ """Get references to all data files"""
747
+ return {
748
+ "session_id": session_dir.name,
749
+ "data_files": {
750
+ "summary": str(session_dir / "summary.json"),
751
+ "errors": str(session_dir / "errors.json"),
752
+ "network": str(session_dir / "network.json"),
753
+ "console": str(session_dir / "console.json"),
754
+ "server_logs": str(session_dir / "server_logs.json"),
755
+ "dom_analysis": str(session_dir / "dom_analysis.json"),
756
+ "performance": str(session_dir / "performance.json"),
757
+ "timeline": str(session_dir / "timeline.json"),
758
+ "screenshots": str(session_dir / "screenshots.json")
759
+ },
760
+ "optional_files": {
761
+ "mockup_comparison": str(session_dir / "mockup_comparison.json"),
762
+ "responsive_results": str(session_dir / "responsive_results.json"),
763
+ "css_iterations": str(session_dir / "css_iterations.json")
764
+ },
765
+ "artifact_dirs": {
766
+ "screenshots": str(session_dir / "screenshots"),
767
+ "traces": str(session_dir / "traces")
768
+ },
769
+ "data_digest": str(session_dir / "data_digest.md")
770
+ }
771
+
772
+ def _compare_summaries(self, summary_a: Dict, summary_b: Dict) -> Dict:
773
+ """Compare summary metrics between sessions"""
774
+ metrics_a = summary_a.get('metrics', {})
775
+ metrics_b = summary_b.get('metrics', {})
776
+
777
+ return {
778
+ "errors": {
779
+ "session_a": metrics_a.get('total_errors', 0),
780
+ "session_b": metrics_b.get('total_errors', 0),
781
+ "difference": metrics_b.get('total_errors', 0) - metrics_a.get('total_errors', 0)
782
+ },
783
+ "network_failures": {
784
+ "session_a": metrics_a.get('failed_network_requests', 0),
785
+ "session_b": metrics_b.get('failed_network_requests', 0),
786
+ "difference": metrics_b.get('failed_network_requests', 0) - metrics_a.get('failed_network_requests', 0)
787
+ },
788
+ "execution_time": {
789
+ "session_a": summary_a.get('execution_time', 0),
790
+ "session_b": summary_b.get('execution_time', 0),
791
+ "difference": summary_b.get('execution_time', 0) - summary_a.get('execution_time', 0)
792
+ }
793
+ }
794
+
795
+ def _compare_errors(self, errors_a: Dict, errors_b: Dict) -> Dict:
796
+ """Compare errors between sessions - Phase 4 message-level comparison"""
797
+ all_errors_a = errors_a.get('all_errors', [])
798
+ all_errors_b = errors_b.get('all_errors', [])
799
+
800
+ # Get error messages for set operations
801
+ messages_a = set(err.get('message', '') for err in all_errors_a)
802
+ messages_b = set(err.get('message', '') for err in all_errors_b)
803
+
804
+ # Set operations
805
+ new_messages = messages_b - messages_a
806
+ fixed_messages = messages_a - messages_b
807
+ common_messages = messages_a & messages_b
808
+
809
+ # Find actual error objects for new/fixed
810
+ new_errors = [err for err in all_errors_b if err.get('message') in new_messages]
811
+ fixed_errors = [err for err in all_errors_a if err.get('message') in fixed_messages]
812
+
813
+ # Frequency changes for common errors
814
+ frequency_changes = []
815
+ for msg in common_messages:
816
+ count_a = sum(1 for err in all_errors_a if err.get('message') == msg)
817
+ count_b = sum(1 for err in all_errors_b if err.get('message') == msg)
818
+ if count_a != count_b:
819
+ frequency_changes.append({
820
+ "message": msg,
821
+ "count_a": count_a,
822
+ "count_b": count_b,
823
+ "change": count_b - count_a
824
+ })
825
+
826
+ return {
827
+ "total_errors_a": len(all_errors_a),
828
+ "total_errors_b": len(all_errors_b),
829
+ "change": len(all_errors_b) - len(all_errors_a),
830
+ "new_errors": {
831
+ "count": len(new_errors),
832
+ "errors": new_errors
833
+ },
834
+ "fixed_errors": {
835
+ "count": len(fixed_errors),
836
+ "errors": fixed_errors
837
+ },
838
+ "common_errors": {
839
+ "count": len(common_messages),
840
+ "messages": list(common_messages)
841
+ },
842
+ "frequency_changes": frequency_changes,
843
+ "error_types_a": list(errors_a.get('errors_by_type', {}).keys()),
844
+ "error_types_b": list(errors_b.get('errors_by_type', {}).keys())
845
+ }
846
+
847
+ def _compare_network(self, network_a: Dict, network_b: Dict) -> Dict:
848
+ """Compare network requests between sessions - Phase 4 URL-level comparison"""
849
+ all_requests_a = network_a.get('all_requests', [])
850
+ all_requests_b = network_b.get('all_requests', [])
851
+
852
+ # Get URLs for set operations
853
+ urls_a = set(req.get('url', '') for req in all_requests_a)
854
+ urls_b = set(req.get('url', '') for req in all_requests_b)
855
+
856
+ # Set operations
857
+ new_urls = urls_b - urls_a
858
+ removed_urls = urls_a - urls_b
859
+ common_urls = urls_a & urls_b
860
+
861
+ # Find requests with status code changes
862
+ status_changes = []
863
+ for url in common_urls:
864
+ reqs_a = [r for r in all_requests_a if r.get('url') == url]
865
+ reqs_b = [r for r in all_requests_b if r.get('url') == url]
866
+
867
+ if reqs_a and reqs_b:
868
+ status_a = reqs_a[0].get('status_code', 0)
869
+ status_b = reqs_b[0].get('status_code', 0)
870
+ if status_a != status_b:
871
+ status_changes.append({
872
+ "url": url,
873
+ "status_a": status_a,
874
+ "status_b": status_b
875
+ })
876
+
877
+ # Find requests with timing changes
878
+ timing_changes = []
879
+ for url in common_urls:
880
+ reqs_a = [r for r in all_requests_a if r.get('url') == url]
881
+ reqs_b = [r for r in all_requests_b if r.get('url') == url]
882
+
883
+ if reqs_a and reqs_b:
884
+ time_a = reqs_a[0].get('timing', {}).get('duration', 0)
885
+ time_b = reqs_b[0].get('timing', {}).get('duration', 0)
886
+ diff = abs(time_b - time_a)
887
+ if diff > 100: # Significant change threshold
888
+ timing_changes.append({
889
+ "url": url,
890
+ "timing_a": time_a,
891
+ "timing_b": time_b,
892
+ "difference": time_b - time_a
893
+ })
894
+
895
+ return {
896
+ "total_requests_a": len(all_requests_a),
897
+ "total_requests_b": len(all_requests_b),
898
+ "failed_requests_a": len(network_a.get('failed_requests', [])),
899
+ "failed_requests_b": len(network_b.get('failed_requests', [])),
900
+ "success_rate_a": network_a.get('summary', {}).get('success_rate', 100),
901
+ "success_rate_b": network_b.get('summary', {}).get('success_rate', 100),
902
+ "new_urls": {
903
+ "count": len(new_urls),
904
+ "urls": list(new_urls)
905
+ },
906
+ "removed_urls": {
907
+ "count": len(removed_urls),
908
+ "urls": list(removed_urls)
909
+ },
910
+ "status_changes": status_changes,
911
+ "timing_changes": timing_changes
912
+ }
913
+
914
+ def _compare_performance(self, perf_a: Dict, perf_b: Dict) -> Dict:
915
+ """Compare performance metrics between sessions"""
916
+ summary_a = perf_a.get('summary', {})
917
+ summary_b = perf_b.get('summary', {})
918
+
919
+ return {
920
+ "execution_time": {
921
+ "session_a": perf_a.get('execution_time', 0),
922
+ "session_b": perf_b.get('execution_time', 0),
923
+ "difference": perf_b.get('execution_time', 0) - perf_a.get('execution_time', 0)
924
+ },
925
+ "avg_load_time": {
926
+ "session_a": summary_a.get('average_page_load_time', 0),
927
+ "session_b": summary_b.get('average_page_load_time', 0),
928
+ "difference": summary_b.get('average_page_load_time', 0) - summary_a.get('average_page_load_time', 0)
929
+ },
930
+ "max_memory": {
931
+ "session_a": summary_a.get('max_memory_usage', 0),
932
+ "session_b": summary_b.get('max_memory_usage', 0),
933
+ "difference": summary_b.get('max_memory_usage', 0) - summary_a.get('max_memory_usage', 0)
934
+ }
935
+ }
936
+
937
+ def _export_data(self, data: Any, format: str, data_type: str) -> Any:
938
+ """Export data in requested format"""
939
+ if format == "json":
940
+ return json.dumps(data, indent=2, default=str)
941
+
942
+ elif format == "markdown":
943
+ return self._to_markdown(data, data_type)
944
+
945
+ elif format == "csv":
946
+ return self._to_csv(data, data_type)
947
+
948
+ else:
949
+ return data
950
+
951
+ def _to_markdown(self, data: Dict, data_type: str) -> str:
952
+ """Convert data to markdown format - Phase 5 enhanced formatting"""
953
+ md = f"# {data_type.replace('_', ' ').title()} Data\n\n"
954
+
955
+ # Format based on data type and structure
956
+ if data_type == "errors":
957
+ # Check if has 'all_errors' key (from errors.json structure)
958
+ if 'all_errors' in data:
959
+ data = {'errors': data['all_errors']}
960
+ md += self._format_errors_markdown(data)
961
+
962
+ elif data_type == "network":
963
+ # Check if has 'all_requests' key
964
+ if 'all_requests' in data:
965
+ data = {'requests': data['all_requests']}
966
+ md += self._format_network_markdown(data)
967
+
968
+ elif data_type == "server_logs":
969
+ # Check if has 'all_logs' key
970
+ if 'all_logs' in data:
971
+ data = {'logs': data['all_logs']}
972
+ md += self._format_server_logs_markdown(data)
973
+
974
+ elif data_type == "error_context":
975
+ md += self._format_error_context_markdown(data)
976
+
977
+ else:
978
+ # Fallback to JSON for unknown types
979
+ md += "```json\n"
980
+ md += json.dumps(data, indent=2, default=str)
981
+ md += "\n```\n"
982
+
983
+ return md
984
+
985
+ def _format_errors_markdown(self, data: Dict) -> str:
986
+ """Format errors as markdown tables"""
987
+ errors = data.get('errors', [])
988
+ md = f"**Total Errors:** {len(errors)}\n\n"
989
+
990
+ if not errors:
991
+ return md + "No errors found.\n"
992
+
993
+ md += "| # | Error | Source | Line:Col | Message |\n"
994
+ md += "|---|-------|--------|----------|----------|\n"
995
+
996
+ for idx, err in enumerate(errors[:20], 1): # Limit to 20
997
+ message = err.get('message', 'Unknown')[:80]
998
+ md += f"| {idx} | {err.get('type', 'Error')} | {err.get('source', 'Unknown')[:30]} | {err.get('line', '?')}:{err.get('column', '?')} | {message} |\n"
999
+
1000
+ if len(errors) > 20:
1001
+ md += f"\n*...and {len(errors) - 20} more errors*\n"
1002
+
1003
+ # Show related data if present
1004
+ if errors and 'related_network_count' in errors[0]:
1005
+ md += f"\n**Note:** Errors include related network requests (±5s window)\n"
1006
+
1007
+ return md + "\n"
1008
+
1009
+ def _format_network_markdown(self, data: Dict) -> str:
1010
+ """Format network requests as markdown tables"""
1011
+ requests = data.get('requests', [])
1012
+ md = f"**Total Requests:** {len(requests)}\n\n"
1013
+
1014
+ if not requests:
1015
+ return md + "No requests found.\n"
1016
+
1017
+ md += "| # | Method | URL | Status | Timing |\n"
1018
+ md += "|---|--------|-----|--------|--------|\n"
1019
+
1020
+ for idx, req in enumerate(requests[:20], 1):
1021
+ url = req.get('url', 'Unknown')[:60]
1022
+ timing = req.get('timing', {}).get('duration', 0)
1023
+ md += f"| {idx} | {req.get('method', 'GET')} | {url} | {req.get('status_code', '?')} | {timing}ms |\n"
1024
+
1025
+ if len(requests) > 20:
1026
+ md += f"\n*...and {len(requests) - 20} more requests*\n"
1027
+
1028
+ return md + "\n"
1029
+
1030
+ def _format_server_logs_markdown(self, data: Dict) -> str:
1031
+ """Format server logs as markdown"""
1032
+ logs = data.get('logs', [])
1033
+ md = f"**Total Server Logs:** {len(logs)}\n\n"
1034
+
1035
+ if not logs:
1036
+ return md + "No server logs found.\n"
1037
+
1038
+ # Group by severity
1039
+ by_severity = {}
1040
+ for log in logs:
1041
+ sev = log.get('severity', 'info')
1042
+ if sev not in by_severity:
1043
+ by_severity[sev] = []
1044
+ by_severity[sev].append(log)
1045
+
1046
+ for severity, severity_logs in by_severity.items():
1047
+ emoji = {
1048
+ 'error': '🚨',
1049
+ 'warning': '⚠️',
1050
+ 'info': 'ℹ️',
1051
+ 'debug': '🔍'
1052
+ }.get(severity.lower(), '📝')
1053
+
1054
+ md += f"## {emoji} {severity.title()} ({len(severity_logs)})\n\n"
1055
+
1056
+ for idx, log in enumerate(severity_logs[:10], 1):
1057
+ content = log.get('content', 'Unknown')[:150]
1058
+ md += f"**{idx}.** `{content}`\n"
1059
+ md += f" - Source: {log.get('source', 'unknown')} | File: {log.get('file', 'unknown')}\n\n"
1060
+
1061
+ if len(severity_logs) > 10:
1062
+ md += f"*...and {len(severity_logs) - 10} more {severity} logs*\n\n"
1063
+
1064
+ return md
1065
+
1066
+ def _format_error_context_markdown(self, data: Dict) -> str:
1067
+ """Format error context with related data"""
1068
+ error = data.get('error', {})
1069
+
1070
+ md = f"## Error Context\n\n"
1071
+ md += f"**Error:** `{error.get('message', 'Unknown')}`\n"
1072
+ md += f"**Source:** {error.get('source', 'Unknown')} (Line {error.get('line', '?')})\n"
1073
+ md += f"**Time Window:** {data.get('time_window', '±5s')}\n\n"
1074
+
1075
+ md += "### Related Network Requests\n\n"
1076
+ network = data.get('related_network', [])
1077
+ if network:
1078
+ for req in network[:5]:
1079
+ md += f"- {req.get('method', 'GET')} {req.get('url', 'Unknown')[:60]} → {req.get('status_code', '?')}\n"
1080
+ else:
1081
+ md += "*No related network requests*\n"
1082
+
1083
+ md += "\n### Related Console Messages\n\n"
1084
+ console = data.get('related_console', [])
1085
+ if console:
1086
+ for msg in console[:5]:
1087
+ md += f"- [{msg.get('type', 'log')}] {msg.get('message', 'Unknown')[:80]}\n"
1088
+ else:
1089
+ md += "*No related console messages*\n"
1090
+
1091
+ md += "\n### Related Server Logs\n\n"
1092
+ server_logs = data.get('related_server_logs', [])
1093
+ if server_logs:
1094
+ for log in server_logs[:5]:
1095
+ md += f"- [{log.get('severity', 'info')}] {log.get('content', 'Unknown')[:80]}\n"
1096
+ else:
1097
+ md += "*No related server logs*\n"
1098
+
1099
+ return md + "\n"
1100
+
1101
+ def _to_csv(self, data: Dict, data_type: str) -> str:
1102
+ """Convert data to CSV format"""
1103
+ output = StringIO()
1104
+
1105
+ # Handle different data types
1106
+ if data_type == "errors" and 'errors' in data:
1107
+ writer = csv.DictWriter(output, fieldnames=['message', 'source', 'line', 'column', 'screenshot_name'])
1108
+ writer.writeheader()
1109
+ for error in data.get('errors', []):
1110
+ writer.writerow({
1111
+ 'message': error.get('message', ''),
1112
+ 'source': error.get('source', ''),
1113
+ 'line': error.get('line', 0),
1114
+ 'column': error.get('column', 0),
1115
+ 'screenshot_name': error.get('screenshot_name', '')
1116
+ })
1117
+
1118
+ elif data_type == "network" and 'requests' in data:
1119
+ writer = csv.DictWriter(output, fieldnames=['url', 'method', 'status_code', 'screenshot_name'])
1120
+ writer.writeheader()
1121
+ for req in data.get('requests', []):
1122
+ writer.writerow({
1123
+ 'url': req.get('url', ''),
1124
+ 'method': req.get('method', ''),
1125
+ 'status_code': req.get('status_code', 0),
1126
+ 'screenshot_name': req.get('screenshot_name', '')
1127
+ })
1128
+
1129
+ else:
1130
+ # Generic CSV for other types
1131
+ output.write("# Data export - see JSON format for complete details\n")
1132
+
1133
+ return output.getvalue()
1134
+
1135
+ def _add_related_network(self, session_dir: Path, errors: List[Dict], window: float = 5.0) -> List[Dict]:
1136
+ """Add related network requests to errors (time-based correlation)"""
1137
+ network = self._load_json(session_dir / "network.json")
1138
+ all_requests = network.get('all_requests', [])
1139
+
1140
+ for error in errors:
1141
+ error_time = error.get('timestamp', 0)
1142
+ # Find network requests within time window
1143
+ related = [
1144
+ req for req in all_requests
1145
+ if abs(req.get('timestamp', 0) - error_time) <= window
1146
+ ]
1147
+ error['related_network'] = related
1148
+ error['related_network_count'] = len(related)
1149
+
1150
+ return errors
1151
+
1152
+ def _add_related_console(self, session_dir: Path, errors: List[Dict], window: float = 5.0) -> List[Dict]:
1153
+ """Add related console messages to errors (time-based correlation)"""
1154
+ console = self._load_json(session_dir / "console.json")
1155
+ all_messages = console.get('all_messages', [])
1156
+
1157
+ for error in errors:
1158
+ error_time = error.get('timestamp', 0)
1159
+ # Find console messages within time window
1160
+ related = [
1161
+ msg for msg in all_messages
1162
+ if abs(msg.get('timestamp', 0) - error_time) <= window
1163
+ and msg.get('message') != error.get('message') # Exclude self
1164
+ ]
1165
+ error['related_console'] = related
1166
+ error['related_console_count'] = len(related)
1167
+
1168
+ return errors
1169
+
1170
+ def _add_related_server_logs(self, session_dir: Path, errors: List[Dict], window: float = 5.0) -> List[Dict]:
1171
+ """Add related server logs to errors (time-based correlation)"""
1172
+ server_logs = self._load_json(session_dir / "server_logs.json")
1173
+ all_logs = server_logs.get('all_logs', [])
1174
+
1175
+ for error in errors:
1176
+ error_time = error.get('timestamp', 0)
1177
+ # Find server logs within time window
1178
+ related = [
1179
+ log for log in all_logs
1180
+ if abs(log.get('timestamp', 0) - error_time) <= window
1181
+ ]
1182
+ error['related_server_logs'] = related
1183
+ error['related_server_logs_count'] = len(related)
1184
+
1185
+ return errors
1186
+
1187
+ def _add_related_errors_to_network(self, session_dir: Path, requests: List[Dict], window: float = 5.0) -> List[Dict]:
1188
+ """Add related errors to network requests (time-based correlation)"""
1189
+ errors = self._load_json(session_dir / "errors.json")
1190
+ all_errors = errors.get('all_errors', [])
1191
+
1192
+ for req in requests:
1193
+ req_time = req.get('timestamp', 0)
1194
+ # Find errors within time window
1195
+ related = [
1196
+ err for err in all_errors
1197
+ if abs(err.get('timestamp', 0) - req_time) <= window
1198
+ ]
1199
+ req['related_errors'] = related
1200
+ req['related_errors_count'] = len(related)
1201
+
1202
+ return requests
1203
+
1204
+ def _add_related_console_to_network(self, session_dir: Path, requests: List[Dict], window: float = 5.0) -> List[Dict]:
1205
+ """Add related console messages to network requests (time-based correlation)"""
1206
+ console = self._load_json(session_dir / "console.json")
1207
+ all_messages = console.get('all_messages', [])
1208
+
1209
+ for req in requests:
1210
+ req_time = req.get('timestamp', 0)
1211
+ # Find console messages within time window
1212
+ related = [
1213
+ msg for msg in all_messages
1214
+ if abs(msg.get('timestamp', 0) - req_time) <= window
1215
+ ]
1216
+ req['related_console'] = related
1217
+ req['related_console_count'] = len(related)
1218
+
1219
+ return requests
1220
+
1221
+ def _get_error_context(self, session_dir: Path, filters: Dict, export_format: str) -> Any:
1222
+ """Phase 3: Get full context around specific error"""
1223
+ error_index = int(filters['context_for_error'])
1224
+ window = float(filters.get('window', 5.0))
1225
+
1226
+ # Load all data
1227
+ errors = self._load_json(session_dir / "errors.json")
1228
+ all_errors = errors.get('all_errors', [])
1229
+
1230
+ if error_index >= len(all_errors):
1231
+ return self._export_data({"error": "Error index out of range"}, export_format, "error")
1232
+
1233
+ target_error = all_errors[error_index]
1234
+ error_time = target_error.get('timestamp', 0)
1235
+
1236
+ # Gather all data within time window
1237
+ network = self._load_json(session_dir / "network.json")
1238
+ console = self._load_json(session_dir / "console.json")
1239
+ server_logs = self._load_json(session_dir / "server_logs.json")
1240
+ timeline = self._load_json(session_dir / "timeline.json")
1241
+
1242
+ context = {
1243
+ "error": target_error,
1244
+ "error_index": error_index,
1245
+ "time_window": f"±{window}s",
1246
+ "related_network": [
1247
+ req for req in network.get('all_requests', [])
1248
+ if abs(req.get('timestamp', 0) - error_time) <= window
1249
+ ],
1250
+ "related_console": [
1251
+ msg for msg in console.get('all_messages', [])
1252
+ if abs(msg.get('timestamp', 0) - error_time) <= window
1253
+ and msg.get('message') != target_error.get('message')
1254
+ ],
1255
+ "related_server_logs": [
1256
+ log for log in server_logs.get('all_logs', [])
1257
+ if abs(log.get('timestamp', 0) - error_time) <= window
1258
+ ],
1259
+ "timeline_events": [
1260
+ event for event in timeline.get('organized_timeline', [])
1261
+ if abs(event.get('timestamp', 0) - error_time) <= window
1262
+ ]
1263
+ }
1264
+
1265
+ return self._export_data(context, export_format, "error_context")
1266
+
1267
+ def _group_by_url(self, session_dir: Path, filters: Dict, export_format: str) -> Any:
1268
+ """Phase 3: Group all data by URL pattern"""
1269
+ url_pattern = filters['group_by_url']
1270
+
1271
+ # Load all data
1272
+ network = self._load_json(session_dir / "network.json")
1273
+ errors = self._load_json(session_dir / "errors.json")
1274
+ console = self._load_json(session_dir / "console.json")
1275
+ server_logs = self._load_json(session_dir / "server_logs.json")
1276
+
1277
+ # Filter by URL pattern
1278
+ matching_requests = [
1279
+ req for req in network.get('all_requests', [])
1280
+ if url_pattern in req.get('url', '')
1281
+ ]
1282
+
1283
+ # Get timestamps for cross-referencing
1284
+ request_times = [req.get('timestamp', 0) for req in matching_requests]
1285
+
1286
+ grouped = {
1287
+ "url_pattern": url_pattern,
1288
+ "matching_requests": matching_requests,
1289
+ "related_errors": [
1290
+ err for err in errors.get('all_errors', [])
1291
+ if any(abs(err.get('timestamp', 0) - t) <= 5.0 for t in request_times)
1292
+ ],
1293
+ "related_console": [
1294
+ msg for msg in console.get('all_messages', [])
1295
+ if any(abs(msg.get('timestamp', 0) - t) <= 5.0 for t in request_times)
1296
+ ],
1297
+ "related_server_logs": [
1298
+ log for log in server_logs.get('all_logs', [])
1299
+ if url_pattern in log.get('content', '')
1300
+ ]
1301
+ }
1302
+
1303
+ return self._export_data(grouped, export_format, "grouped_by_url")
1304
+
1305
+ def _group_by_selector(self, session_dir: Path, filters: Dict, export_format: str) -> Any:
1306
+ """Phase 3: Group all data by selector pattern"""
1307
+ selector = filters['group_by_selector']
1308
+
1309
+ # Load all data
1310
+ dom = self._load_json(session_dir / "dom_analysis.json")
1311
+ errors = self._load_json(session_dir / "errors.json")
1312
+ timeline = self._load_json(session_dir / "timeline.json")
1313
+
1314
+ # Find matching elements
1315
+ matching_elements = [
1316
+ el for el in dom.get('elements', [])
1317
+ if selector in el.get('uniqueSelector', '') or selector in el.get('tagName', '')
1318
+ ]
1319
+
1320
+ # Find click/interaction events with this selector
1321
+ interaction_events = [
1322
+ event for event in timeline.get('organized_timeline', [])
1323
+ if event.get('source') == 'browser' and selector in str(event.get('data', {}))
1324
+ ]
1325
+
1326
+ grouped = {
1327
+ "selector": selector,
1328
+ "matching_elements": matching_elements,
1329
+ "interaction_events": interaction_events,
1330
+ "related_errors": errors.get('all_errors', []) # Could filter by proximity
1331
+ }
1332
+
1333
+ return self._export_data(grouped, export_format, "grouped_by_selector")
1334
+
1335
+ def _load_json(self, path: Path) -> Dict:
1336
+ """Load JSON file, return empty dict if not found"""
1337
+ try:
1338
+ with open(path, 'r', encoding='utf-8') as f:
1339
+ return json.load(f)
1340
+ except (FileNotFoundError, json.JSONDecodeError):
1341
+ return {}
1342
+