cursorflow 2.6.2__py3-none-any.whl → 2.7.1__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,1345 @@
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
+ failed_reqs = network.get('failed_requests', [])
321
+ filtered_requests = [
322
+ req for req in all_requests
323
+ if req.get('url') in [f.get('url') for f in failed_reqs]
324
+ ]
325
+
326
+ # Phase 1: Enhanced network filtering
327
+
328
+ # Filter by URL patterns
329
+ if 'url_contains' in filters:
330
+ pattern = filters['url_contains']
331
+ filtered_requests = [
332
+ req for req in filtered_requests
333
+ if pattern in req.get('url', '')
334
+ ]
335
+
336
+ if 'url_matches' in filters:
337
+ import re
338
+ regex = filters['url_matches']
339
+ filtered_requests = [
340
+ req for req in filtered_requests
341
+ if re.search(regex, req.get('url', ''), re.IGNORECASE)
342
+ ]
343
+
344
+ # Filter by timing thresholds
345
+ if 'over' in filters:
346
+ # Parse timing (could be "500ms" or just "500")
347
+ threshold_str = str(filters['over']).replace('ms', '')
348
+ threshold = float(threshold_str)
349
+ filtered_requests = [
350
+ req for req in filtered_requests
351
+ if req.get('timing', {}).get('duration', 0) > threshold
352
+ ]
353
+
354
+ if 'between_timing' in filters:
355
+ times = filters['between_timing'].replace('ms', '').split(',')
356
+ min_time = float(times[0].strip())
357
+ max_time = float(times[1].strip())
358
+ filtered_requests = [
359
+ req for req in filtered_requests
360
+ if min_time <= req.get('timing', {}).get('duration', 0) <= max_time
361
+ ]
362
+
363
+ # Filter by HTTP method
364
+ if 'method' in filters:
365
+ methods = [m.strip().upper() for m in filters['method'].split(',')]
366
+ filtered_requests = [
367
+ req for req in filtered_requests
368
+ if req.get('method', 'GET').upper() in methods
369
+ ]
370
+
371
+ # Phase 2: Cross-referencing
372
+ if 'with_errors' in filters and filters['with_errors']:
373
+ filtered_requests = self._add_related_errors_to_network(session_dir, filtered_requests)
374
+
375
+ if 'with_console' in filters and filters['with_console']:
376
+ filtered_requests = self._add_related_console_to_network(session_dir, filtered_requests)
377
+
378
+ return {
379
+ 'total_requests': len(filtered_requests),
380
+ 'requests': filtered_requests
381
+ }
382
+
383
+ def _query_console(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
384
+ """Query console messages with optional filtering"""
385
+ console = self._load_json(session_dir / "console.json")
386
+
387
+ if not filters:
388
+ return console
389
+
390
+ # Filter by message type
391
+ if 'type' in filters:
392
+ msg_types = filters['type'].split(',')
393
+ messages_by_type = console.get('messages_by_type', {})
394
+
395
+ filtered_messages = []
396
+ for msg_type in msg_types:
397
+ filtered_messages.extend(messages_by_type.get(msg_type.strip(), []))
398
+
399
+ return {
400
+ 'total_messages': len(filtered_messages),
401
+ 'messages': filtered_messages
402
+ }
403
+
404
+ return console
405
+
406
+ def _query_performance(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
407
+ """Query performance data"""
408
+ return self._load_json(session_dir / "performance.json")
409
+
410
+ def _query_summary(self, session_dir: Path) -> Dict:
411
+ """Query summary data"""
412
+ return self._load_json(session_dir / "summary.json")
413
+
414
+ def _query_dom(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
415
+ """Query DOM analysis data"""
416
+ dom = self._load_json(session_dir / "dom_analysis.json")
417
+
418
+ if not filters:
419
+ return dom
420
+
421
+ elements = dom.get('elements', [])
422
+ filtered_elements = elements
423
+
424
+ # Phase 1: Enhanced DOM filtering
425
+
426
+ # Filter by selector (improved matching)
427
+ if 'selector' in filters or 'select' in filters:
428
+ selector = filters.get('selector') or filters.get('select')
429
+ filtered_elements = [
430
+ el for el in filtered_elements
431
+ if (selector in el.get('uniqueSelector', '') or
432
+ selector in el.get('tagName', '') or
433
+ selector == el.get('id', '') or
434
+ selector in el.get('classes', []))
435
+ ]
436
+
437
+ # Filter by attributes
438
+ if 'with_attr' in filters:
439
+ attr_name = filters['with_attr']
440
+ filtered_elements = [
441
+ el for el in filtered_elements
442
+ if attr_name in el.get('attributes', {})
443
+ ]
444
+
445
+ # Filter by ARIA role
446
+ if 'role' in filters:
447
+ role = filters['role']
448
+ filtered_elements = [
449
+ el for el in filtered_elements
450
+ if el.get('accessibility', {}).get('role') == role
451
+ ]
452
+
453
+ # Filter by visibility
454
+ if 'visible' in filters and filters['visible']:
455
+ filtered_elements = [
456
+ el for el in filtered_elements
457
+ if el.get('visual_context', {}).get('isVisible', True)
458
+ ]
459
+
460
+ # Filter by interactivity
461
+ if 'interactive' in filters and filters['interactive']:
462
+ filtered_elements = [
463
+ el for el in filtered_elements
464
+ if el.get('accessibility', {}).get('isInteractive', False)
465
+ ]
466
+
467
+ # Filter elements with errors (from console errors)
468
+ if 'has_errors' in filters and filters['has_errors']:
469
+ # This would need error context - skip for now or add cross-ref
470
+ pass
471
+
472
+ return {
473
+ 'total_elements': len(filtered_elements),
474
+ 'elements': filtered_elements
475
+ }
476
+
477
+ def _query_server_logs(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
478
+ """Query server logs with filtering"""
479
+ server_logs = self._load_json(session_dir / "server_logs.json")
480
+
481
+ if not filters:
482
+ return server_logs
483
+
484
+ all_logs = server_logs.get('all_logs', [])
485
+ filtered_logs = all_logs
486
+
487
+ # Filter by severity
488
+ if 'severity' in filters or 'level' in filters:
489
+ severity = filters.get('severity') or filters.get('level')
490
+ severities = [s.strip() for s in severity.split(',')]
491
+ filtered_logs = [
492
+ log for log in filtered_logs
493
+ if log.get('severity', 'info').lower() in [s.lower() for s in severities]
494
+ ]
495
+
496
+ # Filter by source (ssh, local, docker, systemd)
497
+ if 'source' in filters:
498
+ source = filters['source']
499
+ filtered_logs = [
500
+ log for log in filtered_logs
501
+ if log.get('source', '').lower() == source.lower()
502
+ ]
503
+
504
+ # Filter by file path
505
+ if 'file' in filters:
506
+ file_filter = filters['file']
507
+ filtered_logs = [
508
+ log for log in filtered_logs
509
+ if file_filter in log.get('file', '')
510
+ ]
511
+
512
+ # Filter by pattern (content search)
513
+ if 'pattern' in filters or 'contains' in filters:
514
+ pattern = filters.get('pattern') or filters.get('contains')
515
+ filtered_logs = [
516
+ log for log in filtered_logs
517
+ if pattern.lower() in log.get('content', '').lower()
518
+ ]
519
+
520
+ # Filter by regex match
521
+ if 'matches' in filters:
522
+ import re
523
+ regex = filters['matches']
524
+ filtered_logs = [
525
+ log for log in filtered_logs
526
+ if re.search(regex, log.get('content', ''), re.IGNORECASE)
527
+ ]
528
+
529
+ # Filter by timestamp
530
+ if 'after' in filters:
531
+ after_time = float(filters['after'])
532
+ filtered_logs = [
533
+ log for log in filtered_logs
534
+ if log.get('timestamp', 0) >= after_time
535
+ ]
536
+
537
+ if 'before' in filters:
538
+ before_time = float(filters['before'])
539
+ filtered_logs = [
540
+ log for log in filtered_logs
541
+ if log.get('timestamp', 0) <= before_time
542
+ ]
543
+
544
+ if 'between' in filters:
545
+ times = filters['between'].split(',')
546
+ start_time = float(times[0].strip())
547
+ end_time = float(times[1].strip())
548
+ filtered_logs = [
549
+ log for log in filtered_logs
550
+ if start_time <= log.get('timestamp', 0) <= end_time
551
+ ]
552
+
553
+ return {
554
+ 'total_logs': len(filtered_logs),
555
+ 'logs': filtered_logs
556
+ }
557
+
558
+ def _query_screenshots(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
559
+ """Query screenshot metadata"""
560
+ screenshots = self._load_json(session_dir / "screenshots.json")
561
+
562
+ if not filters:
563
+ return screenshots
564
+
565
+ screenshot_list = screenshots.get('screenshots', [])
566
+ filtered_screenshots = screenshot_list
567
+
568
+ # Filter by errors
569
+ if 'with_errors' in filters and filters['with_errors']:
570
+ filtered_screenshots = [s for s in filtered_screenshots if s.get('has_errors')]
571
+
572
+ # Filter by network failures
573
+ if 'with_network_failures' in filters and filters['with_network_failures']:
574
+ filtered_screenshots = [s for s in filtered_screenshots if s.get('has_network_failures')]
575
+
576
+ # Filter by timestamp
577
+ if 'at_timestamp' in filters:
578
+ timestamp = float(filters['at_timestamp'])
579
+ # Find screenshot closest to timestamp
580
+ filtered_screenshots = sorted(
581
+ filtered_screenshots,
582
+ key=lambda s: abs(s.get('timestamp', 0) - timestamp)
583
+ )[:1]
584
+
585
+ # Get specific screenshot by index
586
+ if 'index' in filters:
587
+ index = int(filters['index'])
588
+ if 0 <= index < len(screenshot_list):
589
+ filtered_screenshots = [screenshot_list[index]]
590
+ else:
591
+ filtered_screenshots = []
592
+
593
+ return {
594
+ 'total_screenshots': len(filtered_screenshots),
595
+ 'screenshots': filtered_screenshots
596
+ }
597
+
598
+ def _query_mockup(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
599
+ """Query mockup comparison data"""
600
+ mockup = self._load_json(session_dir / "mockup_comparison.json")
601
+
602
+ if not filters:
603
+ return mockup
604
+
605
+ # Filter by similarity threshold
606
+ if 'similarity_under' in filters:
607
+ threshold = float(filters['similarity_under'])
608
+ if mockup.get('similarity_score', 100) >= threshold:
609
+ return {}
610
+
611
+ if 'similarity_over' in filters:
612
+ threshold = float(filters['similarity_over'])
613
+ if mockup.get('similarity_score', 0) <= threshold:
614
+ return {}
615
+
616
+ # Show differences only
617
+ if 'differences' in filters and filters['differences']:
618
+ return {
619
+ 'similarity_score': mockup.get('similarity_score', 0),
620
+ 'differences': mockup.get('differences', [])
621
+ }
622
+
623
+ # Get specific iteration
624
+ if 'iteration' in filters:
625
+ iteration_idx = int(filters['iteration'])
626
+ iterations = mockup.get('iterations', [])
627
+ if 0 <= iteration_idx < len(iterations):
628
+ return {
629
+ 'iteration': iterations[iteration_idx],
630
+ 'index': iteration_idx
631
+ }
632
+
633
+ return mockup
634
+
635
+ def _query_responsive(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
636
+ """Query responsive testing results"""
637
+ responsive = self._load_json(session_dir / "responsive_results.json")
638
+
639
+ if not filters:
640
+ return responsive
641
+
642
+ # Get specific viewport
643
+ if 'viewport' in filters:
644
+ viewport_name = filters['viewport']
645
+ viewports = responsive.get('viewports', {})
646
+ if viewport_name in viewports:
647
+ return {
648
+ 'viewport': viewport_name,
649
+ 'data': viewports[viewport_name]
650
+ }
651
+
652
+ # Show differences only
653
+ if 'show_differences' in filters and filters['show_differences']:
654
+ return {
655
+ 'comparison': responsive.get('comparison', {})
656
+ }
657
+
658
+ return responsive
659
+
660
+ def _query_css_iterations(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
661
+ """Query CSS iteration results"""
662
+ css_iterations = self._load_json(session_dir / "css_iterations.json")
663
+
664
+ if not filters:
665
+ return css_iterations
666
+
667
+ iterations = css_iterations.get('iterations', [])
668
+
669
+ # Get specific iteration
670
+ if 'iteration' in filters:
671
+ iteration_idx = int(filters['iteration']) - 1 # User provides 1-based
672
+ if 0 <= iteration_idx < len(iterations):
673
+ return {
674
+ 'iteration': iterations[iteration_idx],
675
+ 'index': iteration_idx + 1
676
+ }
677
+
678
+ # Filter iterations with errors
679
+ if 'with_errors' in filters and filters['with_errors']:
680
+ filtered = [
681
+ it for it in iterations
682
+ if it.get('console_errors') or it.get('has_errors')
683
+ ]
684
+ return {
685
+ 'total_iterations': len(filtered),
686
+ 'iterations': filtered
687
+ }
688
+
689
+ # Compare specific iterations
690
+ if 'compare_iterations' in filters:
691
+ indices = [int(i.strip()) - 1 for i in filters['compare_iterations'].split(',')]
692
+ selected = [iterations[i] for i in indices if 0 <= i < len(iterations)]
693
+ return {
694
+ 'compared_iterations': selected,
695
+ 'indices': [i + 1 for i in indices]
696
+ }
697
+
698
+ return css_iterations
699
+
700
+ def _query_timeline(self, session_dir: Path, filters: Optional[Dict]) -> Dict:
701
+ """Query timeline data with filtering"""
702
+ timeline = self._load_json(session_dir / "timeline.json")
703
+
704
+ if not filters:
705
+ return timeline
706
+
707
+ organized_timeline = timeline.get('organized_timeline', [])
708
+ filtered_events = organized_timeline
709
+
710
+ # Filter by event source
711
+ if 'source' in filters:
712
+ source = filters['source']
713
+ filtered_events = [
714
+ event for event in filtered_events
715
+ if event.get('source') == source
716
+ ]
717
+
718
+ # Filter by timestamp window
719
+ if 'around' in filters:
720
+ timestamp = float(filters['around'])
721
+ window = float(filters.get('window', 5))
722
+ filtered_events = [
723
+ event for event in filtered_events
724
+ if abs(event.get('timestamp', 0) - timestamp) <= window
725
+ ]
726
+
727
+ # Filter events before timestamp
728
+ if 'before' in filters:
729
+ timestamp = float(filters['before'])
730
+ filtered_events = [
731
+ event for event in filtered_events
732
+ if event.get('timestamp', 0) < timestamp
733
+ ]
734
+
735
+ # Filter events after timestamp
736
+ if 'after' in filters:
737
+ timestamp = float(filters['after'])
738
+ filtered_events = [
739
+ event for event in filtered_events
740
+ if event.get('timestamp', 0) > timestamp
741
+ ]
742
+
743
+ return {
744
+ 'total_events': len(filtered_events),
745
+ 'events': filtered_events
746
+ }
747
+
748
+ def _query_all(self, session_dir: Path) -> Dict:
749
+ """Get references to all data files"""
750
+ return {
751
+ "session_id": session_dir.name,
752
+ "data_files": {
753
+ "summary": str(session_dir / "summary.json"),
754
+ "errors": str(session_dir / "errors.json"),
755
+ "network": str(session_dir / "network.json"),
756
+ "console": str(session_dir / "console.json"),
757
+ "server_logs": str(session_dir / "server_logs.json"),
758
+ "dom_analysis": str(session_dir / "dom_analysis.json"),
759
+ "performance": str(session_dir / "performance.json"),
760
+ "timeline": str(session_dir / "timeline.json"),
761
+ "screenshots": str(session_dir / "screenshots.json")
762
+ },
763
+ "optional_files": {
764
+ "mockup_comparison": str(session_dir / "mockup_comparison.json"),
765
+ "responsive_results": str(session_dir / "responsive_results.json"),
766
+ "css_iterations": str(session_dir / "css_iterations.json")
767
+ },
768
+ "artifact_dirs": {
769
+ "screenshots": str(session_dir / "screenshots"),
770
+ "traces": str(session_dir / "traces")
771
+ },
772
+ "data_digest": str(session_dir / "data_digest.md")
773
+ }
774
+
775
+ def _compare_summaries(self, summary_a: Dict, summary_b: Dict) -> Dict:
776
+ """Compare summary metrics between sessions"""
777
+ metrics_a = summary_a.get('metrics', {})
778
+ metrics_b = summary_b.get('metrics', {})
779
+
780
+ return {
781
+ "errors": {
782
+ "session_a": metrics_a.get('total_errors', 0),
783
+ "session_b": metrics_b.get('total_errors', 0),
784
+ "difference": metrics_b.get('total_errors', 0) - metrics_a.get('total_errors', 0)
785
+ },
786
+ "network_failures": {
787
+ "session_a": metrics_a.get('failed_network_requests', 0),
788
+ "session_b": metrics_b.get('failed_network_requests', 0),
789
+ "difference": metrics_b.get('failed_network_requests', 0) - metrics_a.get('failed_network_requests', 0)
790
+ },
791
+ "execution_time": {
792
+ "session_a": summary_a.get('execution_time', 0),
793
+ "session_b": summary_b.get('execution_time', 0),
794
+ "difference": summary_b.get('execution_time', 0) - summary_a.get('execution_time', 0)
795
+ }
796
+ }
797
+
798
+ def _compare_errors(self, errors_a: Dict, errors_b: Dict) -> Dict:
799
+ """Compare errors between sessions - Phase 4 message-level comparison"""
800
+ all_errors_a = errors_a.get('all_errors', [])
801
+ all_errors_b = errors_b.get('all_errors', [])
802
+
803
+ # Get error messages for set operations
804
+ messages_a = set(err.get('message', '') for err in all_errors_a)
805
+ messages_b = set(err.get('message', '') for err in all_errors_b)
806
+
807
+ # Set operations
808
+ new_messages = messages_b - messages_a
809
+ fixed_messages = messages_a - messages_b
810
+ common_messages = messages_a & messages_b
811
+
812
+ # Find actual error objects for new/fixed
813
+ new_errors = [err for err in all_errors_b if err.get('message') in new_messages]
814
+ fixed_errors = [err for err in all_errors_a if err.get('message') in fixed_messages]
815
+
816
+ # Frequency changes for common errors
817
+ frequency_changes = []
818
+ for msg in common_messages:
819
+ count_a = sum(1 for err in all_errors_a if err.get('message') == msg)
820
+ count_b = sum(1 for err in all_errors_b if err.get('message') == msg)
821
+ if count_a != count_b:
822
+ frequency_changes.append({
823
+ "message": msg,
824
+ "count_a": count_a,
825
+ "count_b": count_b,
826
+ "change": count_b - count_a
827
+ })
828
+
829
+ return {
830
+ "total_errors_a": len(all_errors_a),
831
+ "total_errors_b": len(all_errors_b),
832
+ "change": len(all_errors_b) - len(all_errors_a),
833
+ "new_errors": {
834
+ "count": len(new_errors),
835
+ "errors": new_errors
836
+ },
837
+ "fixed_errors": {
838
+ "count": len(fixed_errors),
839
+ "errors": fixed_errors
840
+ },
841
+ "common_errors": {
842
+ "count": len(common_messages),
843
+ "messages": list(common_messages)
844
+ },
845
+ "frequency_changes": frequency_changes,
846
+ "error_types_a": list(errors_a.get('errors_by_type', {}).keys()),
847
+ "error_types_b": list(errors_b.get('errors_by_type', {}).keys())
848
+ }
849
+
850
+ def _compare_network(self, network_a: Dict, network_b: Dict) -> Dict:
851
+ """Compare network requests between sessions - Phase 4 URL-level comparison"""
852
+ all_requests_a = network_a.get('all_requests', [])
853
+ all_requests_b = network_b.get('all_requests', [])
854
+
855
+ # Get URLs for set operations
856
+ urls_a = set(req.get('url', '') for req in all_requests_a)
857
+ urls_b = set(req.get('url', '') for req in all_requests_b)
858
+
859
+ # Set operations
860
+ new_urls = urls_b - urls_a
861
+ removed_urls = urls_a - urls_b
862
+ common_urls = urls_a & urls_b
863
+
864
+ # Find requests with status code changes
865
+ status_changes = []
866
+ for url in common_urls:
867
+ reqs_a = [r for r in all_requests_a if r.get('url') == url]
868
+ reqs_b = [r for r in all_requests_b if r.get('url') == url]
869
+
870
+ if reqs_a and reqs_b:
871
+ status_a = reqs_a[0].get('status_code', 0)
872
+ status_b = reqs_b[0].get('status_code', 0)
873
+ if status_a != status_b:
874
+ status_changes.append({
875
+ "url": url,
876
+ "status_a": status_a,
877
+ "status_b": status_b
878
+ })
879
+
880
+ # Find requests with timing changes
881
+ timing_changes = []
882
+ for url in common_urls:
883
+ reqs_a = [r for r in all_requests_a if r.get('url') == url]
884
+ reqs_b = [r for r in all_requests_b if r.get('url') == url]
885
+
886
+ if reqs_a and reqs_b:
887
+ time_a = reqs_a[0].get('timing', {}).get('duration', 0)
888
+ time_b = reqs_b[0].get('timing', {}).get('duration', 0)
889
+ diff = abs(time_b - time_a)
890
+ if diff > 100: # Significant change threshold
891
+ timing_changes.append({
892
+ "url": url,
893
+ "timing_a": time_a,
894
+ "timing_b": time_b,
895
+ "difference": time_b - time_a
896
+ })
897
+
898
+ return {
899
+ "total_requests_a": len(all_requests_a),
900
+ "total_requests_b": len(all_requests_b),
901
+ "failed_requests_a": len(network_a.get('failed_requests', [])),
902
+ "failed_requests_b": len(network_b.get('failed_requests', [])),
903
+ "success_rate_a": network_a.get('summary', {}).get('success_rate', 100),
904
+ "success_rate_b": network_b.get('summary', {}).get('success_rate', 100),
905
+ "new_urls": {
906
+ "count": len(new_urls),
907
+ "urls": list(new_urls)
908
+ },
909
+ "removed_urls": {
910
+ "count": len(removed_urls),
911
+ "urls": list(removed_urls)
912
+ },
913
+ "status_changes": status_changes,
914
+ "timing_changes": timing_changes
915
+ }
916
+
917
+ def _compare_performance(self, perf_a: Dict, perf_b: Dict) -> Dict:
918
+ """Compare performance metrics between sessions"""
919
+ summary_a = perf_a.get('summary', {})
920
+ summary_b = perf_b.get('summary', {})
921
+
922
+ return {
923
+ "execution_time": {
924
+ "session_a": perf_a.get('execution_time', 0),
925
+ "session_b": perf_b.get('execution_time', 0),
926
+ "difference": perf_b.get('execution_time', 0) - perf_a.get('execution_time', 0)
927
+ },
928
+ "avg_load_time": {
929
+ "session_a": summary_a.get('average_page_load_time', 0),
930
+ "session_b": summary_b.get('average_page_load_time', 0),
931
+ "difference": summary_b.get('average_page_load_time', 0) - summary_a.get('average_page_load_time', 0)
932
+ },
933
+ "max_memory": {
934
+ "session_a": summary_a.get('max_memory_usage', 0),
935
+ "session_b": summary_b.get('max_memory_usage', 0),
936
+ "difference": summary_b.get('max_memory_usage', 0) - summary_a.get('max_memory_usage', 0)
937
+ }
938
+ }
939
+
940
+ def _export_data(self, data: Any, format: str, data_type: str) -> Any:
941
+ """Export data in requested format"""
942
+ if format == "json":
943
+ return json.dumps(data, indent=2, default=str)
944
+
945
+ elif format == "markdown":
946
+ return self._to_markdown(data, data_type)
947
+
948
+ elif format == "csv":
949
+ return self._to_csv(data, data_type)
950
+
951
+ else:
952
+ return data
953
+
954
+ def _to_markdown(self, data: Dict, data_type: str) -> str:
955
+ """Convert data to markdown format - Phase 5 enhanced formatting"""
956
+ md = f"# {data_type.replace('_', ' ').title()} Data\n\n"
957
+
958
+ # Format based on data type and structure
959
+ if data_type == "errors":
960
+ # Check if has 'all_errors' key (from errors.json structure)
961
+ if 'all_errors' in data:
962
+ data = {'errors': data['all_errors']}
963
+ md += self._format_errors_markdown(data)
964
+
965
+ elif data_type == "network":
966
+ # Check if has 'all_requests' key
967
+ if 'all_requests' in data:
968
+ data = {'requests': data['all_requests']}
969
+ md += self._format_network_markdown(data)
970
+
971
+ elif data_type == "server_logs":
972
+ # Check if has 'all_logs' key
973
+ if 'all_logs' in data:
974
+ data = {'logs': data['all_logs']}
975
+ md += self._format_server_logs_markdown(data)
976
+
977
+ elif data_type == "error_context":
978
+ md += self._format_error_context_markdown(data)
979
+
980
+ else:
981
+ # Fallback to JSON for unknown types
982
+ md += "```json\n"
983
+ md += json.dumps(data, indent=2, default=str)
984
+ md += "\n```\n"
985
+
986
+ return md
987
+
988
+ def _format_errors_markdown(self, data: Dict) -> str:
989
+ """Format errors as markdown tables"""
990
+ errors = data.get('errors', [])
991
+ md = f"**Total Errors:** {len(errors)}\n\n"
992
+
993
+ if not errors:
994
+ return md + "No errors found.\n"
995
+
996
+ md += "| # | Error | Source | Line:Col | Message |\n"
997
+ md += "|---|-------|--------|----------|----------|\n"
998
+
999
+ for idx, err in enumerate(errors[:20], 1): # Limit to 20
1000
+ message = err.get('message', 'Unknown')[:80]
1001
+ md += f"| {idx} | {err.get('type', 'Error')} | {err.get('source', 'Unknown')[:30]} | {err.get('line', '?')}:{err.get('column', '?')} | {message} |\n"
1002
+
1003
+ if len(errors) > 20:
1004
+ md += f"\n*...and {len(errors) - 20} more errors*\n"
1005
+
1006
+ # Show related data if present
1007
+ if errors and 'related_network_count' in errors[0]:
1008
+ md += f"\n**Note:** Errors include related network requests (±5s window)\n"
1009
+
1010
+ return md + "\n"
1011
+
1012
+ def _format_network_markdown(self, data: Dict) -> str:
1013
+ """Format network requests as markdown tables"""
1014
+ requests = data.get('requests', [])
1015
+ md = f"**Total Requests:** {len(requests)}\n\n"
1016
+
1017
+ if not requests:
1018
+ return md + "No requests found.\n"
1019
+
1020
+ md += "| # | Method | URL | Status | Timing |\n"
1021
+ md += "|---|--------|-----|--------|--------|\n"
1022
+
1023
+ for idx, req in enumerate(requests[:20], 1):
1024
+ url = req.get('url', 'Unknown')[:60]
1025
+ timing = req.get('timing', {}).get('duration', 0)
1026
+ md += f"| {idx} | {req.get('method', 'GET')} | {url} | {req.get('status_code', '?')} | {timing}ms |\n"
1027
+
1028
+ if len(requests) > 20:
1029
+ md += f"\n*...and {len(requests) - 20} more requests*\n"
1030
+
1031
+ return md + "\n"
1032
+
1033
+ def _format_server_logs_markdown(self, data: Dict) -> str:
1034
+ """Format server logs as markdown"""
1035
+ logs = data.get('logs', [])
1036
+ md = f"**Total Server Logs:** {len(logs)}\n\n"
1037
+
1038
+ if not logs:
1039
+ return md + "No server logs found.\n"
1040
+
1041
+ # Group by severity
1042
+ by_severity = {}
1043
+ for log in logs:
1044
+ sev = log.get('severity', 'info')
1045
+ if sev not in by_severity:
1046
+ by_severity[sev] = []
1047
+ by_severity[sev].append(log)
1048
+
1049
+ for severity, severity_logs in by_severity.items():
1050
+ emoji = {
1051
+ 'error': '🚨',
1052
+ 'warning': '⚠️',
1053
+ 'info': 'ℹ️',
1054
+ 'debug': '🔍'
1055
+ }.get(severity.lower(), '📝')
1056
+
1057
+ md += f"## {emoji} {severity.title()} ({len(severity_logs)})\n\n"
1058
+
1059
+ for idx, log in enumerate(severity_logs[:10], 1):
1060
+ content = log.get('content', 'Unknown')[:150]
1061
+ md += f"**{idx}.** `{content}`\n"
1062
+ md += f" - Source: {log.get('source', 'unknown')} | File: {log.get('file', 'unknown')}\n\n"
1063
+
1064
+ if len(severity_logs) > 10:
1065
+ md += f"*...and {len(severity_logs) - 10} more {severity} logs*\n\n"
1066
+
1067
+ return md
1068
+
1069
+ def _format_error_context_markdown(self, data: Dict) -> str:
1070
+ """Format error context with related data"""
1071
+ error = data.get('error', {})
1072
+
1073
+ md = f"## Error Context\n\n"
1074
+ md += f"**Error:** `{error.get('message', 'Unknown')}`\n"
1075
+ md += f"**Source:** {error.get('source', 'Unknown')} (Line {error.get('line', '?')})\n"
1076
+ md += f"**Time Window:** {data.get('time_window', '±5s')}\n\n"
1077
+
1078
+ md += "### Related Network Requests\n\n"
1079
+ network = data.get('related_network', [])
1080
+ if network:
1081
+ for req in network[:5]:
1082
+ md += f"- {req.get('method', 'GET')} {req.get('url', 'Unknown')[:60]} → {req.get('status_code', '?')}\n"
1083
+ else:
1084
+ md += "*No related network requests*\n"
1085
+
1086
+ md += "\n### Related Console Messages\n\n"
1087
+ console = data.get('related_console', [])
1088
+ if console:
1089
+ for msg in console[:5]:
1090
+ md += f"- [{msg.get('type', 'log')}] {msg.get('message', 'Unknown')[:80]}\n"
1091
+ else:
1092
+ md += "*No related console messages*\n"
1093
+
1094
+ md += "\n### Related Server Logs\n\n"
1095
+ server_logs = data.get('related_server_logs', [])
1096
+ if server_logs:
1097
+ for log in server_logs[:5]:
1098
+ md += f"- [{log.get('severity', 'info')}] {log.get('content', 'Unknown')[:80]}\n"
1099
+ else:
1100
+ md += "*No related server logs*\n"
1101
+
1102
+ return md + "\n"
1103
+
1104
+ def _to_csv(self, data: Dict, data_type: str) -> str:
1105
+ """Convert data to CSV format"""
1106
+ output = StringIO()
1107
+
1108
+ # Handle different data types
1109
+ if data_type == "errors" and 'errors' in data:
1110
+ writer = csv.DictWriter(output, fieldnames=['message', 'source', 'line', 'column', 'screenshot_name'])
1111
+ writer.writeheader()
1112
+ for error in data.get('errors', []):
1113
+ writer.writerow({
1114
+ 'message': error.get('message', ''),
1115
+ 'source': error.get('source', ''),
1116
+ 'line': error.get('line', 0),
1117
+ 'column': error.get('column', 0),
1118
+ 'screenshot_name': error.get('screenshot_name', '')
1119
+ })
1120
+
1121
+ elif data_type == "network" and 'requests' in data:
1122
+ writer = csv.DictWriter(output, fieldnames=['url', 'method', 'status_code', 'screenshot_name'])
1123
+ writer.writeheader()
1124
+ for req in data.get('requests', []):
1125
+ writer.writerow({
1126
+ 'url': req.get('url', ''),
1127
+ 'method': req.get('method', ''),
1128
+ 'status_code': req.get('status_code', 0),
1129
+ 'screenshot_name': req.get('screenshot_name', '')
1130
+ })
1131
+
1132
+ else:
1133
+ # Generic CSV for other types
1134
+ output.write("# Data export - see JSON format for complete details\n")
1135
+
1136
+ return output.getvalue()
1137
+
1138
+ def _add_related_network(self, session_dir: Path, errors: List[Dict], window: float = 5.0) -> List[Dict]:
1139
+ """Add related network requests to errors (time-based correlation)"""
1140
+ network = self._load_json(session_dir / "network.json")
1141
+ all_requests = network.get('all_requests', [])
1142
+
1143
+ for error in errors:
1144
+ error_time = error.get('timestamp', 0)
1145
+ # Find network requests within time window
1146
+ related = [
1147
+ req for req in all_requests
1148
+ if abs(req.get('timestamp', 0) - error_time) <= window
1149
+ ]
1150
+ error['related_network'] = related
1151
+ error['related_network_count'] = len(related)
1152
+
1153
+ return errors
1154
+
1155
+ def _add_related_console(self, session_dir: Path, errors: List[Dict], window: float = 5.0) -> List[Dict]:
1156
+ """Add related console messages to errors (time-based correlation)"""
1157
+ console = self._load_json(session_dir / "console.json")
1158
+ all_messages = console.get('all_messages', [])
1159
+
1160
+ for error in errors:
1161
+ error_time = error.get('timestamp', 0)
1162
+ # Find console messages within time window
1163
+ related = [
1164
+ msg for msg in all_messages
1165
+ if abs(msg.get('timestamp', 0) - error_time) <= window
1166
+ and msg.get('message') != error.get('message') # Exclude self
1167
+ ]
1168
+ error['related_console'] = related
1169
+ error['related_console_count'] = len(related)
1170
+
1171
+ return errors
1172
+
1173
+ def _add_related_server_logs(self, session_dir: Path, errors: List[Dict], window: float = 5.0) -> List[Dict]:
1174
+ """Add related server logs to errors (time-based correlation)"""
1175
+ server_logs = self._load_json(session_dir / "server_logs.json")
1176
+ all_logs = server_logs.get('all_logs', [])
1177
+
1178
+ for error in errors:
1179
+ error_time = error.get('timestamp', 0)
1180
+ # Find server logs within time window
1181
+ related = [
1182
+ log for log in all_logs
1183
+ if abs(log.get('timestamp', 0) - error_time) <= window
1184
+ ]
1185
+ error['related_server_logs'] = related
1186
+ error['related_server_logs_count'] = len(related)
1187
+
1188
+ return errors
1189
+
1190
+ def _add_related_errors_to_network(self, session_dir: Path, requests: List[Dict], window: float = 5.0) -> List[Dict]:
1191
+ """Add related errors to network requests (time-based correlation)"""
1192
+ errors = self._load_json(session_dir / "errors.json")
1193
+ all_errors = errors.get('all_errors', [])
1194
+
1195
+ for req in requests:
1196
+ req_time = req.get('timestamp', 0)
1197
+ # Find errors within time window
1198
+ related = [
1199
+ err for err in all_errors
1200
+ if abs(err.get('timestamp', 0) - req_time) <= window
1201
+ ]
1202
+ req['related_errors'] = related
1203
+ req['related_errors_count'] = len(related)
1204
+
1205
+ return requests
1206
+
1207
+ def _add_related_console_to_network(self, session_dir: Path, requests: List[Dict], window: float = 5.0) -> List[Dict]:
1208
+ """Add related console messages to network requests (time-based correlation)"""
1209
+ console = self._load_json(session_dir / "console.json")
1210
+ all_messages = console.get('all_messages', [])
1211
+
1212
+ for req in requests:
1213
+ req_time = req.get('timestamp', 0)
1214
+ # Find console messages within time window
1215
+ related = [
1216
+ msg for msg in all_messages
1217
+ if abs(msg.get('timestamp', 0) - req_time) <= window
1218
+ ]
1219
+ req['related_console'] = related
1220
+ req['related_console_count'] = len(related)
1221
+
1222
+ return requests
1223
+
1224
+ def _get_error_context(self, session_dir: Path, filters: Dict, export_format: str) -> Any:
1225
+ """Phase 3: Get full context around specific error"""
1226
+ error_index = int(filters['context_for_error'])
1227
+ window = float(filters.get('window', 5.0))
1228
+
1229
+ # Load all data
1230
+ errors = self._load_json(session_dir / "errors.json")
1231
+ all_errors = errors.get('all_errors', [])
1232
+
1233
+ if error_index >= len(all_errors):
1234
+ return self._export_data({"error": "Error index out of range"}, export_format, "error")
1235
+
1236
+ target_error = all_errors[error_index]
1237
+ error_time = target_error.get('timestamp', 0)
1238
+
1239
+ # Gather all data within time window
1240
+ network = self._load_json(session_dir / "network.json")
1241
+ console = self._load_json(session_dir / "console.json")
1242
+ server_logs = self._load_json(session_dir / "server_logs.json")
1243
+ timeline = self._load_json(session_dir / "timeline.json")
1244
+
1245
+ context = {
1246
+ "error": target_error,
1247
+ "error_index": error_index,
1248
+ "time_window": f"±{window}s",
1249
+ "related_network": [
1250
+ req for req in network.get('all_requests', [])
1251
+ if abs(req.get('timestamp', 0) - error_time) <= window
1252
+ ],
1253
+ "related_console": [
1254
+ msg for msg in console.get('all_messages', [])
1255
+ if abs(msg.get('timestamp', 0) - error_time) <= window
1256
+ and msg.get('message') != target_error.get('message')
1257
+ ],
1258
+ "related_server_logs": [
1259
+ log for log in server_logs.get('all_logs', [])
1260
+ if abs(log.get('timestamp', 0) - error_time) <= window
1261
+ ],
1262
+ "timeline_events": [
1263
+ event for event in timeline.get('organized_timeline', [])
1264
+ if abs(event.get('timestamp', 0) - error_time) <= window
1265
+ ]
1266
+ }
1267
+
1268
+ return self._export_data(context, export_format, "error_context")
1269
+
1270
+ def _group_by_url(self, session_dir: Path, filters: Dict, export_format: str) -> Any:
1271
+ """Phase 3: Group all data by URL pattern"""
1272
+ url_pattern = filters['group_by_url']
1273
+
1274
+ # Load all data
1275
+ network = self._load_json(session_dir / "network.json")
1276
+ errors = self._load_json(session_dir / "errors.json")
1277
+ console = self._load_json(session_dir / "console.json")
1278
+ server_logs = self._load_json(session_dir / "server_logs.json")
1279
+
1280
+ # Filter by URL pattern
1281
+ matching_requests = [
1282
+ req for req in network.get('all_requests', [])
1283
+ if url_pattern in req.get('url', '')
1284
+ ]
1285
+
1286
+ # Get timestamps for cross-referencing
1287
+ request_times = [req.get('timestamp', 0) for req in matching_requests]
1288
+
1289
+ grouped = {
1290
+ "url_pattern": url_pattern,
1291
+ "matching_requests": matching_requests,
1292
+ "related_errors": [
1293
+ err for err in errors.get('all_errors', [])
1294
+ if any(abs(err.get('timestamp', 0) - t) <= 5.0 for t in request_times)
1295
+ ],
1296
+ "related_console": [
1297
+ msg for msg in console.get('all_messages', [])
1298
+ if any(abs(msg.get('timestamp', 0) - t) <= 5.0 for t in request_times)
1299
+ ],
1300
+ "related_server_logs": [
1301
+ log for log in server_logs.get('all_logs', [])
1302
+ if url_pattern in log.get('content', '')
1303
+ ]
1304
+ }
1305
+
1306
+ return self._export_data(grouped, export_format, "grouped_by_url")
1307
+
1308
+ def _group_by_selector(self, session_dir: Path, filters: Dict, export_format: str) -> Any:
1309
+ """Phase 3: Group all data by selector pattern"""
1310
+ selector = filters['group_by_selector']
1311
+
1312
+ # Load all data
1313
+ dom = self._load_json(session_dir / "dom_analysis.json")
1314
+ errors = self._load_json(session_dir / "errors.json")
1315
+ timeline = self._load_json(session_dir / "timeline.json")
1316
+
1317
+ # Find matching elements
1318
+ matching_elements = [
1319
+ el for el in dom.get('elements', [])
1320
+ if selector in el.get('uniqueSelector', '') or selector in el.get('tagName', '')
1321
+ ]
1322
+
1323
+ # Find click/interaction events with this selector
1324
+ interaction_events = [
1325
+ event for event in timeline.get('organized_timeline', [])
1326
+ if event.get('source') == 'browser' and selector in str(event.get('data', {}))
1327
+ ]
1328
+
1329
+ grouped = {
1330
+ "selector": selector,
1331
+ "matching_elements": matching_elements,
1332
+ "interaction_events": interaction_events,
1333
+ "related_errors": errors.get('all_errors', []) # Could filter by proximity
1334
+ }
1335
+
1336
+ return self._export_data(grouped, export_format, "grouped_by_selector")
1337
+
1338
+ def _load_json(self, path: Path) -> Dict:
1339
+ """Load JSON file, return empty dict if not found"""
1340
+ try:
1341
+ with open(path, 'r', encoding='utf-8') as f:
1342
+ return json.load(f)
1343
+ except (FileNotFoundError, json.JSONDecodeError):
1344
+ return {}
1345
+