cursorflow 2.6.3__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.
- cursorflow/cli.py +345 -13
- cursorflow/core/cursorflow.py +8 -1
- cursorflow/core/data_presenter.py +518 -0
- cursorflow/core/output_manager.py +523 -0
- cursorflow/core/query_engine.py +1345 -0
- cursorflow/rules/cursorflow-usage.mdc +245 -6
- {cursorflow-2.6.3.dist-info → cursorflow-2.7.1.dist-info}/METADATA +59 -1
- {cursorflow-2.6.3.dist-info → cursorflow-2.7.1.dist-info}/RECORD +12 -9
- {cursorflow-2.6.3.dist-info → cursorflow-2.7.1.dist-info}/WHEEL +0 -0
- {cursorflow-2.6.3.dist-info → cursorflow-2.7.1.dist-info}/entry_points.txt +0 -0
- {cursorflow-2.6.3.dist-info → cursorflow-2.7.1.dist-info}/licenses/LICENSE +0 -0
- {cursorflow-2.6.3.dist-info → cursorflow-2.7.1.dist-info}/top_level.txt +0 -0
@@ -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
|
+
|