capydash 0.1.1 → 0.1.3

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,1007 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+ require 'time'
4
+ require 'erb'
5
+
6
+ module CapyDash
7
+ class ReportGenerator
8
+ class << self
9
+ def generate_report
10
+ # Find the most recent test run data
11
+ test_runs = CapyDash::Persistence.list_test_runs(1)
12
+ return nil if test_runs.empty?
13
+
14
+ latest_run = test_runs.first
15
+ test_data = CapyDash::Persistence.load_test_run(latest_run[:id])
16
+ return nil unless test_data
17
+
18
+ # Create report directory
19
+ report_dir = File.join(Dir.pwd, "capydash_report")
20
+ FileUtils.mkdir_p(report_dir)
21
+
22
+ # Create assets directory
23
+ assets_dir = File.join(report_dir, "assets")
24
+ FileUtils.mkdir_p(assets_dir)
25
+
26
+ # Create screenshots directory
27
+ screenshots_dir = File.join(report_dir, "screenshots")
28
+ FileUtils.mkdir_p(screenshots_dir)
29
+
30
+ # Copy screenshots from test data
31
+ copy_screenshots(test_data, screenshots_dir)
32
+
33
+ # Generate HTML report
34
+ html_content = generate_html(test_data, latest_run[:created_at])
35
+ html_path = File.join(report_dir, "index.html")
36
+ File.write(html_path, html_content)
37
+
38
+ # Generate CSS
39
+ css_content = generate_css
40
+ css_path = File.join(assets_dir, "dashboard.css")
41
+ File.write(css_path, css_content)
42
+
43
+ # Generate JavaScript
44
+ js_content = generate_javascript
45
+ js_path = File.join(assets_dir, "dashboard.js")
46
+ File.write(js_path, js_content)
47
+
48
+ html_path
49
+ end
50
+
51
+ private
52
+
53
+ def copy_screenshots(test_data, screenshots_dir)
54
+ return unless test_data[:tests]
55
+
56
+ test_data[:tests].each do |test|
57
+ next unless test[:steps]
58
+
59
+ test[:steps].each do |step|
60
+ next unless step[:screenshot]
61
+
62
+ # Try multiple possible paths for the screenshot
63
+ screenshot_paths = [
64
+ step[:screenshot],
65
+ File.join(Dir.pwd, step[:screenshot]),
66
+ File.join(Dir.pwd, "tmp", "capybara", step[:screenshot]),
67
+ File.join(Dir.pwd, "tmp", "capybara", "tmp", "capydash_screenshots", File.basename(step[:screenshot]))
68
+ ]
69
+
70
+ actual_path = screenshot_paths.find { |path| File.exist?(path) }
71
+ next unless actual_path
72
+
73
+ # Copy screenshot to report directory
74
+ filename = File.basename(step[:screenshot])
75
+ dest_path = File.join(screenshots_dir, filename)
76
+ FileUtils.cp(actual_path, dest_path) unless File.exist?(dest_path)
77
+ end
78
+ end
79
+ end
80
+
81
+ def generate_html(test_data, created_at)
82
+ # Process test data into a structured format
83
+ processed_tests = process_test_data(test_data)
84
+
85
+ # Calculate summary statistics
86
+ total_tests = processed_tests.sum { |test| test[:methods].length }
87
+ passed_tests = processed_tests.sum { |test| test[:methods].count { |method| method[:status] == 'passed' } }
88
+ failed_tests = total_tests - passed_tests
89
+
90
+ # Generate HTML using ERB template
91
+ template = File.read(File.join(__dir__, 'templates', 'report.html.erb'))
92
+ erb = ERB.new(template)
93
+
94
+ erb.result(binding)
95
+ end
96
+
97
+ def process_test_data(test_data)
98
+ return [] unless test_data[:tests]
99
+
100
+ # Group tests by class
101
+ tests_by_class = {}
102
+
103
+ test_data[:tests].each do |test|
104
+ # Handle different test data structures
105
+ test_name = test[:name] || test[:test_name] || 'UnknownTest'
106
+
107
+ # Extract actual class and method names from test name like "ApiTest#test_page_elements_are_present"
108
+ if test_name.include?('#')
109
+ class_name, method_name = test_name.split('#', 2)
110
+ else
111
+ # Fallback to old behavior if no # separator
112
+ class_name = extract_class_name(test_name)
113
+ method_name = extract_method_name(test_name)
114
+ end
115
+
116
+ tests_by_class[class_name] ||= {
117
+ class_name: class_name,
118
+ methods: []
119
+ }
120
+
121
+ # Process steps - handle different step structures
122
+ steps = test[:steps] || []
123
+ processed_steps = steps.map do |step|
124
+ {
125
+ name: step[:step_name] || step[:name] || 'unknown_step',
126
+ detail: step[:detail] || step[:description] || '',
127
+ status: step[:status] || 'unknown',
128
+ screenshot: step[:screenshot] ? File.basename(step[:screenshot]) : nil,
129
+ error: step[:error] || step[:message]
130
+ }
131
+ end
132
+
133
+ # Filter out "running" steps - only show "passed" or "failed"
134
+ processed_steps = processed_steps.reject { |step| step[:status] == 'running' }
135
+
136
+ # Determine method status
137
+ method_status = if processed_steps.any? { |s| s[:status] == 'failed' }
138
+ 'failed'
139
+ elsif processed_steps.any? { |s| s[:status] == 'passed' }
140
+ 'passed'
141
+ else
142
+ 'running'
143
+ end
144
+
145
+ tests_by_class[class_name][:methods] << {
146
+ name: method_name,
147
+ status: method_status,
148
+ steps: processed_steps
149
+ }
150
+ end
151
+
152
+ tests_by_class.values
153
+ end
154
+
155
+ def extract_class_name(test_name)
156
+ return 'UnknownTest' if test_name.nil? || test_name.empty?
157
+
158
+ if test_name.include?('#')
159
+ test_name.split('#').first
160
+ elsif test_name.start_with?('test_')
161
+ # Extract meaningful words from test method name
162
+ words = test_name.gsub('test_', '').split('_')
163
+ words.map(&:capitalize).join('') + 'Test'
164
+ else
165
+ test_name
166
+ end
167
+ end
168
+
169
+ def extract_method_name(test_name)
170
+ return 'unknown_method' if test_name.nil? || test_name.empty?
171
+
172
+ if test_name.include?('#')
173
+ test_name.split('#').last
174
+ else
175
+ test_name
176
+ end
177
+ end
178
+
179
+ def generate_css
180
+ <<~CSS
181
+ * {
182
+ margin: 0;
183
+ padding: 0;
184
+ box-sizing: border-box;
185
+ }
186
+
187
+ body {
188
+ font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
189
+ line-height: 1.6;
190
+ color: #333;
191
+ background-color: #f8f9fa;
192
+ }
193
+
194
+ .container {
195
+ max-width: 1400px;
196
+ margin: 0 auto;
197
+ padding: 1rem;
198
+ }
199
+
200
+ .header {
201
+ background: white;
202
+ padding: 1.5rem;
203
+ border-radius: 8px;
204
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
205
+ margin-bottom: 1.5rem;
206
+ }
207
+
208
+ .header h1 {
209
+ font-size: 2rem;
210
+ margin-bottom: 0.5rem;
211
+ color: #2c3e50;
212
+ }
213
+
214
+ .header .subtitle {
215
+ color: #666;
216
+ font-size: 0.9rem;
217
+ margin-bottom: 1rem;
218
+ }
219
+
220
+ .search-container {
221
+ margin-top: 1rem;
222
+ }
223
+
224
+ .search-input-wrapper {
225
+ position: relative;
226
+ max-width: 500px;
227
+ }
228
+
229
+ .search-input {
230
+ width: 100%;
231
+ padding: 0.75rem 1rem;
232
+ border: 2px solid #ddd;
233
+ border-radius: 6px;
234
+ font-size: 1rem;
235
+ transition: border-color 0.2s;
236
+ }
237
+
238
+ .search-input:focus {
239
+ outline: none;
240
+ border-color: #3498db;
241
+ box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
242
+ }
243
+
244
+ .typeahead-dropdown {
245
+ position: absolute;
246
+ top: 100%;
247
+ left: 0;
248
+ right: 0;
249
+ background: white;
250
+ border: 1px solid #ddd;
251
+ border-top: none;
252
+ border-radius: 0 0 6px 6px;
253
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
254
+ max-height: 300px;
255
+ overflow-y: auto;
256
+ z-index: 1000;
257
+ display: none;
258
+ }
259
+
260
+ .typeahead-suggestion {
261
+ padding: 0.75rem 1rem;
262
+ cursor: pointer;
263
+ border-bottom: 1px solid #f0f0f0;
264
+ transition: background-color 0.2s;
265
+ }
266
+
267
+ .typeahead-suggestion:hover,
268
+ .typeahead-suggestion.highlighted {
269
+ background-color: #f8f9fa;
270
+ }
271
+
272
+ .typeahead-suggestion:last-child {
273
+ border-bottom: none;
274
+ }
275
+
276
+ .typeahead-suggestion .suggestion-text {
277
+ font-weight: 500;
278
+ color: #2c3e50;
279
+ }
280
+
281
+ .typeahead-suggestion .suggestion-category {
282
+ font-size: 0.8rem;
283
+ color: #666;
284
+ margin-top: 0.25rem;
285
+ }
286
+
287
+ .typeahead-suggestion .suggestion-count {
288
+ font-size: 0.75rem;
289
+ color: #999;
290
+ float: right;
291
+ margin-top: 0.25rem;
292
+ }
293
+
294
+ .search-stats {
295
+ margin-top: 0.5rem;
296
+ font-size: 0.85rem;
297
+ color: #666;
298
+ }
299
+
300
+ .summary {
301
+ display: grid;
302
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
303
+ gap: 1rem;
304
+ margin-bottom: 1.5rem;
305
+ }
306
+
307
+ .summary-card {
308
+ background: white;
309
+ padding: 1.5rem;
310
+ border-radius: 8px;
311
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
312
+ text-align: center;
313
+ }
314
+
315
+ .summary-card .number {
316
+ font-size: 2.5rem;
317
+ font-weight: bold;
318
+ margin-bottom: 0.5rem;
319
+ }
320
+
321
+ .summary-card.total .number {
322
+ color: #3498db;
323
+ }
324
+
325
+ .summary-card.passed .number {
326
+ color: #27ae60;
327
+ }
328
+
329
+ .summary-card.failed .number {
330
+ color: #e74c3c;
331
+ }
332
+
333
+ .summary-card .label {
334
+ color: #666;
335
+ font-size: 0.9rem;
336
+ text-transform: uppercase;
337
+ letter-spacing: 0.5px;
338
+ }
339
+
340
+ .test-results {
341
+ background: white;
342
+ border-radius: 8px;
343
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
344
+ overflow: hidden;
345
+ }
346
+
347
+ .test-class {
348
+ border-bottom: 1px solid #eee;
349
+ }
350
+
351
+ .test-class:last-child {
352
+ border-bottom: none;
353
+ }
354
+
355
+ .test-class h2 {
356
+ background: #f8f9fa;
357
+ padding: 1rem 1.5rem;
358
+ margin: 0;
359
+ font-size: 1.25rem;
360
+ color: #2c3e50;
361
+ border-bottom: 1px solid #eee;
362
+ }
363
+
364
+ .test-method {
365
+ padding: 1.5rem;
366
+ border-bottom: 1px solid #f0f0f0;
367
+ }
368
+
369
+ .test-method:last-child {
370
+ border-bottom: none;
371
+ }
372
+
373
+ .test-method-header {
374
+ display: flex;
375
+ align-items: center;
376
+ margin-bottom: 1rem;
377
+ cursor: pointer;
378
+ gap: 0.75rem;
379
+ }
380
+
381
+ .test-method h3 {
382
+ margin: 0;
383
+ font-size: 1.1rem;
384
+ color: #34495e;
385
+ flex: 1;
386
+ }
387
+
388
+ .expand-toggle {
389
+ background: none;
390
+ border: none;
391
+ cursor: pointer;
392
+ padding: 0.5rem;
393
+ border-radius: 4px;
394
+ transition: background-color 0.2s;
395
+ display: flex;
396
+ align-items: center;
397
+ justify-content: center;
398
+ }
399
+
400
+ .expand-toggle:hover {
401
+ background-color: #f0f0f0;
402
+ }
403
+
404
+ .expand-icon {
405
+ font-size: 0.8rem;
406
+ color: #666;
407
+ transition: transform 0.2s;
408
+ }
409
+
410
+ .expand-toggle.collapsed .expand-icon {
411
+ transform: rotate(-90deg);
412
+ }
413
+
414
+ .steps {
415
+ display: flex;
416
+ flex-direction: column;
417
+ gap: 1rem;
418
+ transition: max-height 0.3s ease-out;
419
+ overflow: hidden;
420
+ }
421
+
422
+ .steps.collapsed {
423
+ max-height: 0;
424
+ margin: 0;
425
+ }
426
+
427
+ .step {
428
+ border: 1px solid #ddd;
429
+ border-radius: 6px;
430
+ padding: 1rem;
431
+ background: #fafafa;
432
+ }
433
+
434
+ .step.passed {
435
+ border-color: #27ae60;
436
+ background: #f8fff8;
437
+ }
438
+
439
+ .step.failed {
440
+ border-color: #e74c3c;
441
+ background: #fff8f8;
442
+ }
443
+
444
+ .step.running {
445
+ border-color: #3498db;
446
+ background: #f8fcff;
447
+ }
448
+
449
+ .step-header {
450
+ display: flex;
451
+ justify-content: space-between;
452
+ align-items: center;
453
+ margin-bottom: 0.5rem;
454
+ }
455
+
456
+ .step-name {
457
+ font-weight: 600;
458
+ color: #2c3e50;
459
+ }
460
+
461
+ .step-status {
462
+ padding: 0.25rem 0.5rem;
463
+ border-radius: 4px;
464
+ font-size: 0.8rem;
465
+ font-weight: 600;
466
+ text-transform: uppercase;
467
+ }
468
+
469
+ .step.passed .step-status {
470
+ background: #27ae60;
471
+ color: white;
472
+ }
473
+
474
+ .step.failed .step-status {
475
+ background: #e74c3c;
476
+ color: white;
477
+ }
478
+
479
+ .step.running .step-status {
480
+ background: #3498db;
481
+ color: white;
482
+ }
483
+
484
+ .step-detail {
485
+ color: #666;
486
+ font-size: 0.9rem;
487
+ margin-bottom: 0.5rem;
488
+ }
489
+
490
+ .screenshot-toggle {
491
+ background: #3498db;
492
+ color: white;
493
+ border: none;
494
+ padding: 0.5rem 1rem;
495
+ border-radius: 4px;
496
+ cursor: pointer;
497
+ font-size: 0.8rem;
498
+ font-weight: 600;
499
+ transition: background-color 0.2s;
500
+ }
501
+
502
+ .screenshot-toggle:hover {
503
+ background: #2980b9;
504
+ }
505
+
506
+ .screenshot-container {
507
+ margin-top: 1rem;
508
+ border: 1px solid #ddd;
509
+ border-radius: 6px;
510
+ background: white;
511
+ overflow: hidden;
512
+ }
513
+
514
+ .screenshot {
515
+ text-align: center;
516
+ padding: 1rem;
517
+ }
518
+
519
+ .screenshot img {
520
+ max-width: 100%;
521
+ height: auto;
522
+ border: 1px solid #ddd;
523
+ border-radius: 4px;
524
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
525
+ }
526
+
527
+ .error-log {
528
+ margin-top: 1rem;
529
+ padding: 1rem;
530
+ background: #fff5f5;
531
+ border: 1px solid #fed7d7;
532
+ border-radius: 6px;
533
+ }
534
+
535
+ .error-log h4 {
536
+ color: #e53e3e;
537
+ margin: 0 0 0.5rem 0;
538
+ font-size: 0.9rem;
539
+ }
540
+
541
+ .error-log pre {
542
+ background: #2d3748;
543
+ color: #e2e8f0;
544
+ padding: 1rem;
545
+ border-radius: 4px;
546
+ overflow-x: auto;
547
+ font-size: 0.8rem;
548
+ line-height: 1.4;
549
+ margin: 0;
550
+ white-space: pre-wrap;
551
+ word-wrap: break-word;
552
+ }
553
+
554
+ .highlight {
555
+ background-color: #ffeb3b;
556
+ padding: 0.1rem 0.2rem;
557
+ border-radius: 3px;
558
+ font-weight: bold;
559
+ }
560
+
561
+ .hidden {
562
+ display: none !important;
563
+ }
564
+
565
+ @media (max-width: 768px) {
566
+ .container {
567
+ padding: 0.5rem;
568
+ }
569
+
570
+ .header {
571
+ padding: 1rem;
572
+ }
573
+
574
+ .header h1 {
575
+ font-size: 1.5rem;
576
+ }
577
+
578
+ .summary {
579
+ grid-template-columns: 1fr;
580
+ }
581
+
582
+ .test-method {
583
+ padding: 1rem;
584
+ }
585
+ }
586
+ CSS
587
+ end
588
+
589
+ def generate_javascript
590
+ <<~JS
591
+ class CapyDashDashboard {
592
+ constructor() {
593
+ this.searchInput = document.getElementById('searchInput');
594
+ this.searchStats = document.getElementById('searchStats');
595
+ this.typeaheadDropdown = document.getElementById('typeaheadDropdown');
596
+ this.testResults = document.querySelector('.test-results');
597
+ this.allTestClasses = Array.from(document.querySelectorAll('.test-class'));
598
+ this.allTestMethods = Array.from(document.querySelectorAll('.test-method'));
599
+ this.allSteps = Array.from(document.querySelectorAll('.step'));
600
+
601
+ this.suggestions = [];
602
+ this.selectedIndex = -1;
603
+ this.isTypeaheadVisible = false;
604
+
605
+ this.init();
606
+ }
607
+
608
+ init() {
609
+ this.setupSearch();
610
+ this.updateSearchStats();
611
+ this.setupScreenshotToggles();
612
+ }
613
+
614
+ setupSearch() {
615
+ if (this.searchInput) {
616
+ this.searchInput.addEventListener('input', (e) => {
617
+ this.handleSearchInput(e.target.value);
618
+ });
619
+
620
+ this.searchInput.addEventListener('keydown', (e) => {
621
+ this.handleKeydown(e);
622
+ });
623
+
624
+ this.searchInput.addEventListener('blur', () => {
625
+ setTimeout(() => this.hideTypeahead(), 150);
626
+ });
627
+
628
+ this.searchInput.addEventListener('focus', () => {
629
+ if (this.searchInput.value.length > 0) {
630
+ this.showTypeahead();
631
+ }
632
+ });
633
+ }
634
+ }
635
+
636
+ setupScreenshotToggles() {
637
+ // Screenshot toggles are handled by the global function
638
+ }
639
+
640
+ handleSearchInput(query) {
641
+ this.performSearch(query);
642
+
643
+ if (query.length >= 2) {
644
+ this.generateSuggestions(query);
645
+ this.showTypeahead();
646
+ } else {
647
+ this.hideTypeahead();
648
+ }
649
+ }
650
+
651
+ handleKeydown(e) {
652
+ if (!this.isTypeaheadVisible) return;
653
+
654
+ switch (e.key) {
655
+ case 'ArrowDown':
656
+ e.preventDefault();
657
+ this.selectedIndex = Math.min(this.selectedIndex + 1, this.suggestions.length - 1);
658
+ this.updateHighlightedSuggestion();
659
+ break;
660
+ case 'ArrowUp':
661
+ e.preventDefault();
662
+ this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
663
+ this.updateHighlightedSuggestion();
664
+ break;
665
+ case 'Enter':
666
+ e.preventDefault();
667
+ if (this.selectedIndex >= 0) {
668
+ this.selectSuggestion(this.suggestions[this.selectedIndex]);
669
+ }
670
+ break;
671
+ case 'Escape':
672
+ this.hideTypeahead();
673
+ break;
674
+ }
675
+ }
676
+
677
+ generateSuggestions(query) {
678
+ const searchTerm = query.toLowerCase();
679
+ this.suggestions = [];
680
+
681
+ const suggestionsMap = new Map();
682
+
683
+ this.allTestClasses.forEach(testClass => {
684
+ const className = testClass.querySelector('h2')?.textContent || '';
685
+ if (className.toLowerCase().includes(searchTerm)) {
686
+ const key = `class:${className}`;
687
+ if (!suggestionsMap.has(key)) {
688
+ suggestionsMap.set(key, {
689
+ text: className,
690
+ category: 'Test Class',
691
+ type: 'class',
692
+ count: 1
693
+ });
694
+ }
695
+ }
696
+ });
697
+
698
+ this.allTestMethods.forEach(method => {
699
+ const methodName = method.querySelector('h3')?.textContent || '';
700
+ if (methodName.toLowerCase().includes(searchTerm)) {
701
+ const key = `method:${methodName}`;
702
+ if (!suggestionsMap.has(key)) {
703
+ suggestionsMap.set(key, {
704
+ text: methodName,
705
+ category: 'Test Method',
706
+ type: 'method',
707
+ count: 1
708
+ });
709
+ }
710
+ }
711
+ });
712
+
713
+ this.allSteps.forEach(step => {
714
+ const stepName = step.querySelector('.step-name')?.textContent || '';
715
+ const stepDetail = step.querySelector('.step-detail')?.textContent || '';
716
+ const stepStatus = step.querySelector('.step-status')?.textContent || '';
717
+
718
+ if (stepName.toLowerCase().includes(searchTerm)) {
719
+ const key = `step:${stepName}`;
720
+ if (!suggestionsMap.has(key)) {
721
+ suggestionsMap.set(key, {
722
+ text: stepName,
723
+ category: 'Step',
724
+ type: 'step',
725
+ count: 1
726
+ });
727
+ } else {
728
+ suggestionsMap.get(key).count++;
729
+ }
730
+ }
731
+
732
+ if (stepDetail.toLowerCase().includes(searchTerm)) {
733
+ const key = `detail:${stepDetail}`;
734
+ if (!suggestionsMap.has(key)) {
735
+ suggestionsMap.set(key, {
736
+ text: stepDetail,
737
+ category: 'Step Detail',
738
+ type: 'detail',
739
+ count: 1
740
+ });
741
+ } else {
742
+ suggestionsMap.get(key).count++;
743
+ }
744
+ }
745
+
746
+ if (stepStatus.toLowerCase().includes(searchTerm)) {
747
+ const key = `status:${stepStatus}`;
748
+ if (!suggestionsMap.has(key)) {
749
+ suggestionsMap.set(key, {
750
+ text: stepStatus,
751
+ category: 'Status',
752
+ type: 'status',
753
+ count: 1
754
+ });
755
+ } else {
756
+ suggestionsMap.get(key).count++;
757
+ }
758
+ }
759
+ });
760
+
761
+ this.suggestions = Array.from(suggestionsMap.values())
762
+ .sort((a, b) => {
763
+ const aExact = a.text.toLowerCase().startsWith(searchTerm);
764
+ const bExact = b.text.toLowerCase().startsWith(searchTerm);
765
+ if (aExact && !bExact) return -1;
766
+ if (!aExact && bExact) return 1;
767
+ return b.count - a.count;
768
+ })
769
+ .slice(0, 10);
770
+ }
771
+
772
+ showTypeahead() {
773
+ if (this.suggestions.length > 0) {
774
+ this.renderSuggestions();
775
+ this.typeaheadDropdown.style.display = 'block';
776
+ this.isTypeaheadVisible = true;
777
+ this.selectedIndex = -1;
778
+ }
779
+ }
780
+
781
+ hideTypeahead() {
782
+ this.typeaheadDropdown.style.display = 'none';
783
+ this.isTypeaheadVisible = false;
784
+ this.selectedIndex = -1;
785
+ }
786
+
787
+ renderSuggestions() {
788
+ this.typeaheadDropdown.innerHTML = '';
789
+
790
+ this.suggestions.forEach((suggestion, index) => {
791
+ const suggestionEl = document.createElement('div');
792
+ suggestionEl.className = 'typeahead-suggestion';
793
+ suggestionEl.innerHTML = `
794
+ <div class="suggestion-text">${suggestion.text}</div>
795
+ <div class="suggestion-category">${suggestion.category}</div>
796
+ ${suggestion.count > 1 ? `<div class="suggestion-count">${suggestion.count} matches</div>` : ''}
797
+ `;
798
+
799
+ suggestionEl.addEventListener('click', () => {
800
+ this.selectSuggestion(suggestion);
801
+ });
802
+
803
+ this.typeaheadDropdown.appendChild(suggestionEl);
804
+ });
805
+ }
806
+
807
+ updateHighlightedSuggestion() {
808
+ const suggestions = this.typeaheadDropdown.querySelectorAll('.typeahead-suggestion');
809
+ suggestions.forEach((el, index) => {
810
+ el.classList.toggle('highlighted', index === this.selectedIndex);
811
+ });
812
+ }
813
+
814
+ selectSuggestion(suggestion) {
815
+ this.searchInput.value = suggestion.text;
816
+ this.performSearch(suggestion.text);
817
+ this.hideTypeahead();
818
+ this.searchInput.focus();
819
+ }
820
+
821
+ performSearch(query) {
822
+ const searchTerm = query.toLowerCase().trim();
823
+
824
+ if (!searchTerm) {
825
+ this.showAllResults();
826
+ this.updateSearchStats();
827
+ return;
828
+ }
829
+
830
+ let visibleClasses = 0;
831
+ let visibleMethods = 0;
832
+ let visibleSteps = 0;
833
+
834
+ this.allTestClasses.forEach(testClass => {
835
+ const className = testClass.querySelector('h2')?.textContent.toLowerCase() || '';
836
+ const methods = testClass.querySelectorAll('.test-method');
837
+ let classVisible = false;
838
+ let classMethodCount = 0;
839
+ let classStepCount = 0;
840
+
841
+ methods.forEach(method => {
842
+ const methodName = method.querySelector('h3')?.textContent.toLowerCase() || '';
843
+ const steps = method.querySelectorAll('.step');
844
+ let methodVisible = false;
845
+ let methodStepCount = 0;
846
+
847
+ steps.forEach(step => {
848
+ const stepName = step.querySelector('.step-name')?.textContent.toLowerCase() || '';
849
+ const stepStatus = step.querySelector('.step-status')?.textContent.toLowerCase() || '';
850
+ const stepDetail = step.querySelector('.step-detail')?.textContent.toLowerCase() || '';
851
+ const stepError = step.querySelector('.error-log pre')?.textContent.toLowerCase() || '';
852
+
853
+ const matches = stepName.includes(searchTerm) ||
854
+ stepStatus.includes(searchTerm) ||
855
+ stepDetail.includes(searchTerm) ||
856
+ stepError.includes(searchTerm);
857
+
858
+ if (matches) {
859
+ step.classList.remove('hidden');
860
+ methodStepCount++;
861
+ methodVisible = true;
862
+ this.highlightText(step, searchTerm);
863
+ } else {
864
+ step.classList.add('hidden');
865
+ }
866
+ });
867
+
868
+ if (methodVisible || methodName.includes(searchTerm)) {
869
+ method.classList.remove('hidden');
870
+ classMethodCount++;
871
+ classVisible = true;
872
+ classStepCount += methodStepCount;
873
+ this.highlightText(method, searchTerm);
874
+ } else {
875
+ method.classList.add('hidden');
876
+ }
877
+ });
878
+
879
+ if (classVisible || className.includes(searchTerm)) {
880
+ testClass.classList.remove('hidden');
881
+ visibleClasses++;
882
+ visibleMethods += classMethodCount;
883
+ visibleSteps += classStepCount;
884
+ this.highlightText(testClass, searchTerm);
885
+ } else {
886
+ testClass.classList.add('hidden');
887
+ }
888
+ });
889
+
890
+ this.updateSearchStats(visibleClasses, visibleMethods, visibleSteps);
891
+ }
892
+
893
+ showAllResults() {
894
+ this.allTestClasses.forEach(testClass => {
895
+ testClass.classList.remove('hidden');
896
+ const methods = testClass.querySelectorAll('.test-method');
897
+ methods.forEach(method => {
898
+ method.classList.remove('hidden');
899
+ const steps = method.querySelectorAll('.step');
900
+ steps.forEach(step => {
901
+ step.classList.remove('hidden');
902
+ });
903
+ });
904
+ });
905
+ }
906
+
907
+ highlightText(element, searchTerm) {
908
+ const walker = document.createTreeWalker(
909
+ element,
910
+ NodeFilter.SHOW_TEXT,
911
+ null,
912
+ false
913
+ );
914
+
915
+ const textNodes = [];
916
+ let node;
917
+ while (node = walker.nextNode()) {
918
+ textNodes.push(node);
919
+ }
920
+
921
+ textNodes.forEach(textNode => {
922
+ const parent = textNode.parentNode;
923
+ if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') {
924
+ return;
925
+ }
926
+
927
+ const text = textNode.textContent;
928
+ const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
929
+
930
+ if (regex.test(text)) {
931
+ const highlightedText = text.replace(regex, '<span class="highlight">$1</span>');
932
+ const wrapper = document.createElement('span');
933
+ wrapper.innerHTML = highlightedText;
934
+ parent.replaceChild(wrapper, textNode);
935
+ }
936
+ });
937
+ }
938
+
939
+ updateSearchStats(visibleClasses = null, visibleMethods = null, visibleSteps = null) {
940
+ if (this.searchStats && visibleClasses === null) {
941
+ this.searchStats.textContent = '';
942
+ return;
943
+ }
944
+
945
+ if (this.searchStats) {
946
+ const totalClasses = this.allTestClasses.length;
947
+ const totalMethods = this.allTestMethods.length;
948
+ const totalSteps = this.allSteps.length;
949
+
950
+ this.searchStats.innerHTML = `
951
+ Showing ${visibleClasses} of ${totalClasses} test classes
952
+ • ${visibleMethods} of ${totalMethods} tests
953
+ • ${visibleSteps} of ${totalSteps} steps
954
+ `;
955
+ }
956
+ }
957
+ }
958
+
959
+ function toggleScreenshot(screenshotId) {
960
+ const container = document.getElementById(`screenshot-${screenshotId}`);
961
+ const button = document.querySelector(`[onclick*="${screenshotId}"]`);
962
+
963
+ if (container && button) {
964
+ const isHidden = container.style.display === 'none' || container.style.display === '';
965
+
966
+ if (isHidden) {
967
+ container.style.display = 'block';
968
+ button.textContent = '📸 Hide Screenshot';
969
+ button.style.background = '#e74c3c';
970
+ } else {
971
+ container.style.display = 'none';
972
+ button.textContent = '📸 Screenshot';
973
+ button.style.background = '#3498db';
974
+ }
975
+ }
976
+ }
977
+
978
+ function toggleTestMethod(methodName) {
979
+ const stepsContainer = document.getElementById(`steps-${methodName}`);
980
+ const button = document.querySelector(`[onclick*="toggleTestMethod('${methodName}')"]`);
981
+ const icon = button.querySelector('.expand-icon');
982
+
983
+ if (stepsContainer && button && icon) {
984
+ const isCollapsed = stepsContainer.classList.contains('collapsed');
985
+
986
+ if (isCollapsed) {
987
+ // Expand
988
+ stepsContainer.classList.remove('collapsed');
989
+ button.classList.remove('collapsed');
990
+ icon.textContent = '▼';
991
+ } else {
992
+ // Collapse
993
+ stepsContainer.classList.add('collapsed');
994
+ button.classList.add('collapsed');
995
+ icon.textContent = '▶';
996
+ }
997
+ }
998
+ }
999
+
1000
+ document.addEventListener('DOMContentLoaded', () => {
1001
+ new CapyDashDashboard();
1002
+ });
1003
+ JS
1004
+ end
1005
+ end
1006
+ end
1007
+ end