capydash 0.2.4 → 0.3.0

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.
@@ -1,94 +1,10 @@
1
- require 'time'
2
- require 'fileutils'
3
- require 'erb'
1
+ require 'capydash/reporter'
4
2
 
5
3
  module CapyDash
6
4
  module RSpec
7
- class << self
8
- # Public method: Called from RSpec before(:suite) hook
9
- def start_run
10
- @results = []
11
- @started_at = Time.now
12
- end
13
-
14
- # Public method: Called from RSpec after(:each) hook
15
- def record_example(example)
16
- return unless @started_at
17
-
18
- execution_result = example.execution_result
19
- status = normalize_status(execution_result.status)
20
-
21
- error_message = nil
22
- if execution_result.status == :failed && execution_result.exception
23
- error_message = format_exception(execution_result.exception)
24
- end
25
-
26
- file_path = example.metadata[:file_path] || ''
27
- class_name = extract_class_name(file_path)
28
-
29
- @results << {
30
- class_name: class_name,
31
- method_name: example.full_description,
32
- status: status,
33
- error: error_message,
34
- location: example.metadata[:location]
35
- }
36
- end
37
-
38
- # Public method: Called from RSpec after(:suite) hook
39
- def generate_report
40
- return unless @started_at
41
- return if @results.empty?
42
-
43
- report_dir = File.join(Dir.pwd, "capydash_report")
44
- FileUtils.mkdir_p(report_dir)
45
-
46
- assets_dir = File.join(report_dir, "assets")
47
- FileUtils.mkdir_p(assets_dir)
48
-
49
- # Group results by class
50
- tests_by_class = @results.group_by { |r| r[:class_name] }
51
-
52
- # Calculate statistics
53
- total_tests = @results.length
54
- passed_tests = @results.count { |r| r[:status] == 'passed' }
55
- failed_tests = @results.count { |r| r[:status] == 'failed' }
56
-
57
- # Process for template
58
- processed_tests = tests_by_class.map do |class_name, examples|
59
- {
60
- class_name: class_name,
61
- methods: examples.map do |ex|
62
- {
63
- name: ex[:method_name],
64
- status: ex[:status],
65
- steps: [{
66
- name: 'test_execution',
67
- detail: ex[:method_name],
68
- status: ex[:status],
69
- error: ex[:error]
70
- }]
71
- }
72
- end
73
- }
74
- end
75
-
76
- # Generate HTML
77
- html_content = generate_html(processed_tests, @started_at, total_tests, passed_tests, failed_tests)
78
- File.write(File.join(report_dir, "index.html"), html_content)
79
-
80
- # Generate CSS
81
- css_content = generate_css
82
- File.write(File.join(assets_dir, "dashboard.css"), css_content)
83
-
84
- # Generate JS
85
- js_content = generate_javascript
86
- File.write(File.join(assets_dir, "dashboard.js"), js_content)
87
-
88
- report_dir
89
- end
5
+ extend CapyDash::Reporter
90
6
 
91
- # Public method: Sets up RSpec hooks
7
+ class << self
92
8
  def setup!
93
9
  return unless rspec_available?
94
10
  return if @configured
@@ -112,444 +28,71 @@ module CapyDash
112
28
  end
113
29
  end
114
30
  rescue => e
115
- # If RSpec isn't ready, silently fail - it will be set up later
116
31
  @configured = false
117
32
  end
118
33
  end
119
34
 
120
- private
121
-
122
- def rspec_available?
123
- return false unless defined?(::RSpec)
124
- return false unless ::RSpec.respond_to?(:configure)
125
- true
126
- rescue
127
- false
128
- end
129
-
130
- def normalize_status(status)
131
- # Normalize RSpec status symbols to strings
132
- case status
133
- when :passed, 'passed'
134
- 'passed'
135
- when :failed, 'failed'
136
- 'failed'
137
- when :pending, 'pending'
138
- 'pending'
139
- else
140
- status.to_s
141
- end
142
- end
143
-
144
- def extract_class_name(file_path)
145
- return 'UnknownSpec' if file_path.nil? || file_path.empty?
146
-
147
- filename = File.basename(file_path, '.rb')
148
- filename.split('_').map(&:capitalize).join('')
149
- end
35
+ def record_example(example)
36
+ return unless @started_at
150
37
 
151
- def format_exception(exception)
152
- return nil unless exception
38
+ execution_result = example.execution_result
153
39
 
154
- message = exception.message || 'Unknown error'
155
- backtrace = exception.backtrace || []
40
+ # Derive status from exception since execution_result.status
41
+ # is not yet set during after(:each) hooks
42
+ status = if example.pending? || example.skipped?
43
+ 'pending'
44
+ elsif execution_result.exception
45
+ 'failed'
46
+ else
47
+ 'passed'
48
+ end
156
49
 
157
- formatted = "#{exception.class}: #{message}"
158
- if backtrace.any?
159
- formatted += "\n" + backtrace.first(5).map { |line| " #{line}" }.join("\n")
50
+ error_message = nil
51
+ if execution_result.exception
52
+ error_message = format_exception(execution_result.exception)
160
53
  end
161
54
 
162
- formatted
163
- end
164
-
165
- def generate_html(processed_tests, created_at, total_tests, passed_tests, failed_tests)
166
- # Create safe IDs for method names (escape special chars for HTML/JS)
167
- processed_tests.each do |test_class|
168
- test_class[:methods].each do |method|
169
- method[:safe_id] = method[:name].gsub(/['"]/, '').gsub(/[^a-zA-Z0-9]/, '_')
170
- end
55
+ screenshot_path = nil
56
+ if status == 'failed'
57
+ screenshot_path = capture_screenshot
171
58
  end
172
59
 
173
- template_path = File.join(__dir__, 'templates', 'report.html.erb')
174
- template = File.read(template_path)
175
- erb = ERB.new(template)
60
+ class_name = extract_class_name(example)
176
61
 
177
- erb.result(binding)
62
+ record_result({
63
+ class_name: class_name,
64
+ method_name: example.full_description,
65
+ status: status,
66
+ error: error_message,
67
+ location: example.metadata[:location],
68
+ screenshot_path: screenshot_path
69
+ })
178
70
  end
179
71
 
180
- def generate_css
181
- <<~CSS
182
- * {
183
- margin: 0;
184
- padding: 0;
185
- box-sizing: border-box;
186
- }
187
-
188
- body {
189
- font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
190
- line-height: 1.6;
191
- color: #333;
192
- background-color: #f8f9fa;
193
- }
194
-
195
- .container {
196
- max-width: 1400px;
197
- margin: 0 auto;
198
- padding: 1rem;
199
- }
200
-
201
- .header {
202
- background: white;
203
- padding: 1.5rem;
204
- border-radius: 8px;
205
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
206
- margin-bottom: 1.5rem;
207
- }
208
-
209
- .header h1 {
210
- font-size: 2rem;
211
- margin-bottom: 0.5rem;
212
- color: #2c3e50;
213
- }
214
-
215
- .header .subtitle {
216
- color: #666;
217
- font-size: 0.9rem;
218
- margin-bottom: 1rem;
219
- }
220
-
221
- .search-container {
222
- margin-top: 1rem;
223
- }
224
-
225
- .search-input {
226
- width: 100%;
227
- padding: 0.75rem 1rem;
228
- border: 2px solid #ddd;
229
- border-radius: 6px;
230
- font-size: 1rem;
231
- transition: border-color 0.2s;
232
- }
233
-
234
- .search-input:focus {
235
- outline: none;
236
- border-color: #3498db;
237
- box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
238
- }
239
-
240
- .summary {
241
- display: grid;
242
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
243
- gap: 1rem;
244
- margin-bottom: 1.5rem;
245
- }
246
-
247
- .summary-card {
248
- background: white;
249
- padding: 1.5rem;
250
- border-radius: 8px;
251
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
252
- text-align: center;
253
- }
254
-
255
- .summary-card .number {
256
- font-size: 2.5rem;
257
- font-weight: bold;
258
- margin-bottom: 0.5rem;
259
- }
260
-
261
- .summary-card.total .number {
262
- color: #3498db;
263
- }
264
-
265
- .summary-card.passed .number {
266
- color: #27ae60;
267
- }
268
-
269
- .summary-card.failed .number {
270
- color: #e74c3c;
271
- }
272
-
273
- .summary-card .label {
274
- color: #666;
275
- font-size: 0.9rem;
276
- text-transform: uppercase;
277
- letter-spacing: 0.5px;
278
- }
279
-
280
- .test-results {
281
- background: white;
282
- border-radius: 8px;
283
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
284
- overflow: hidden;
285
- }
286
-
287
- .test-class {
288
- border-bottom: 1px solid #eee;
289
- }
290
-
291
- .test-class:last-child {
292
- border-bottom: none;
293
- }
294
-
295
- .test-class.hidden {
296
- display: none;
297
- }
298
-
299
- .test-class h2 {
300
- background: #f8f9fa;
301
- padding: 1rem 1.5rem;
302
- margin: 0;
303
- font-size: 1.25rem;
304
- color: #2c3e50;
305
- border-bottom: 1px solid #eee;
306
- }
307
-
308
- .test-method {
309
- padding: 1.5rem;
310
- border-bottom: 1px solid #f0f0f0;
311
- }
312
-
313
- .test-method:last-child {
314
- border-bottom: none;
315
- }
316
-
317
- .test-method.hidden {
318
- display: none;
319
- }
320
-
321
- .test-method-header {
322
- display: flex;
323
- align-items: center;
324
- margin-bottom: 1rem;
325
- cursor: pointer;
326
- gap: 0.75rem;
327
- }
328
-
329
- .test-method h3 {
330
- margin: 0;
331
- font-size: 1.1rem;
332
- color: #34495e;
333
- flex: 1;
334
- }
335
-
336
- .method-status {
337
- display: inline-flex;
338
- align-items: center;
339
- padding: 0.35rem 0.85rem;
340
- border-radius: 4px;
341
- font-size: 0.75rem;
342
- font-weight: 600;
343
- text-transform: uppercase;
344
- letter-spacing: 0.5px;
345
- white-space: nowrap;
346
- min-width: 60px;
347
- justify-content: center;
348
- }
349
-
350
- .method-status-passed {
351
- background-color: #27ae60;
352
- color: white;
353
- }
354
-
355
- .method-status-failed {
356
- background-color: #e74c3c;
357
- color: white;
358
- }
359
-
360
- .method-status-pending {
361
- background-color: #f39c12;
362
- color: white;
363
- }
364
-
365
- .expand-toggle {
366
- background: none;
367
- border: none;
368
- cursor: pointer;
369
- padding: 0.5rem;
370
- border-radius: 4px;
371
- transition: background-color 0.2s;
372
- }
373
-
374
- .expand-toggle:hover {
375
- background-color: #f0f0f0;
376
- }
377
-
378
- .expand-icon {
379
- font-size: 0.8rem;
380
- color: #666;
381
- }
382
-
383
- .steps {
384
- display: flex;
385
- flex-direction: column;
386
- gap: 1rem;
387
- transition: max-height 0.3s ease-out;
388
- overflow: hidden;
389
- }
390
-
391
- .steps.collapsed {
392
- max-height: 0;
393
- margin: 0;
394
- }
395
-
396
- .step {
397
- border: 1px solid #ddd;
398
- border-radius: 6px;
399
- padding: 1rem;
400
- background: #fafafa;
401
- }
402
-
403
- .step.passed {
404
- border-color: #27ae60;
405
- background: #f8fff8;
406
- }
407
-
408
- .step.failed {
409
- border-color: #e74c3c;
410
- background: #fff8f8;
411
- }
412
-
413
- .step-header {
414
- display: flex;
415
- justify-content: space-between;
416
- align-items: center;
417
- margin-bottom: 0.5rem;
418
- }
419
-
420
- .step-name {
421
- font-weight: 600;
422
- color: #2c3e50;
423
- }
424
-
425
- .step-status {
426
- padding: 0.25rem 0.5rem;
427
- border-radius: 4px;
428
- font-size: 0.8rem;
429
- font-weight: 600;
430
- text-transform: uppercase;
431
- }
432
-
433
- .step.passed .step-status {
434
- background: #27ae60;
435
- color: white;
436
- }
437
-
438
- .step.failed .step-status {
439
- background: #e74c3c;
440
- color: white;
441
- }
442
-
443
- .step-detail {
444
- color: #666;
445
- font-size: 0.9rem;
446
- margin-bottom: 0.5rem;
447
- }
448
-
449
- .error-log {
450
- margin-top: 1rem;
451
- padding: 1rem;
452
- background: #fff5f5;
453
- border: 1px solid #fed7d7;
454
- border-radius: 6px;
455
- }
456
-
457
- .error-log h4 {
458
- color: #e53e3e;
459
- margin: 0 0 0.5rem 0;
460
- font-size: 0.9rem;
461
- }
72
+ private
462
73
 
463
- .error-log pre {
464
- background: #2d3748;
465
- color: #e2e8f0;
466
- padding: 1rem;
467
- border-radius: 4px;
468
- overflow-x: auto;
469
- font-size: 0.8rem;
470
- line-height: 1.4;
471
- margin: 0;
472
- white-space: pre-wrap;
473
- word-wrap: break-word;
474
- }
475
- CSS
74
+ def rspec_available?
75
+ return false unless defined?(::RSpec)
76
+ return false unless ::RSpec.respond_to?(:configure)
77
+ true
78
+ rescue
79
+ false
476
80
  end
477
81
 
478
- def generate_javascript
479
- <<~JS
480
- function toggleTestMethod(safeId) {
481
- const stepsContainer = document.getElementById('steps-' + safeId);
482
- const button = document.querySelector('[onclick*="' + safeId + '"]');
483
-
484
- if (stepsContainer && button) {
485
- const icon = button.querySelector('.expand-icon');
486
- if (icon) {
487
- const isCollapsed = stepsContainer.classList.contains('collapsed');
488
-
489
- if (isCollapsed) {
490
- stepsContainer.classList.remove('collapsed');
491
- icon.textContent = '▼';
492
- } else {
493
- stepsContainer.classList.add('collapsed');
494
- icon.textContent = '▶';
495
- }
496
- }
497
- }
498
- }
499
-
500
- // Search functionality
501
- document.addEventListener('DOMContentLoaded', function() {
502
- const searchInput = document.getElementById('searchInput');
503
- if (!searchInput) return;
504
-
505
- searchInput.addEventListener('input', function(e) {
506
- const query = e.target.value.toLowerCase().trim();
507
- const testMethods = document.querySelectorAll('.test-method');
508
- const testClasses = document.querySelectorAll('.test-class');
509
-
510
- // Determine if query is a status filter
511
- const isStatusFilter = query === 'pass' || query === 'fail' ||
512
- query === 'passed' || query === 'failed' ||
513
- query === 'pending';
514
-
515
- testMethods.forEach(function(method) {
516
- const name = method.getAttribute('data-name') || '';
517
- const status = method.getAttribute('data-status') || '';
518
-
519
- let shouldShow = false;
520
-
521
- if (!query) {
522
- // No query - show all
523
- shouldShow = true;
524
- } else if (isStatusFilter) {
525
- // Status filter - check status
526
- shouldShow = (query === 'pass' && status === 'passed') ||
527
- (query === 'fail' && status === 'failed') ||
528
- query === status;
529
- } else {
530
- // Name filter - check if name contains query
531
- shouldShow = name.includes(query);
532
- }
533
-
534
- if (shouldShow) {
535
- method.classList.remove('hidden');
536
- } else {
537
- method.classList.add('hidden');
538
- }
539
- });
82
+ def extract_class_name(example)
83
+ group = example.metadata[:example_group]
84
+ while group && group[:parent_example_group]
85
+ group = group[:parent_example_group]
86
+ end
540
87
 
541
- // Hide test classes if all methods are hidden
542
- testClasses.forEach(function(testClass) {
543
- const visibleMethods = testClass.querySelectorAll('.test-method:not(.hidden)');
544
- if (visibleMethods.length === 0) {
545
- testClass.classList.add('hidden');
546
- } else {
547
- testClass.classList.remove('hidden');
548
- }
549
- });
550
- });
551
- });
552
- JS
88
+ if group && group[:description] && !group[:description].to_s.empty?
89
+ group[:description].to_s
90
+ else
91
+ file_path = example.metadata[:file_path] || ''
92
+ return 'UnknownSpec' if file_path.empty?
93
+ filename = File.basename(file_path, '.rb')
94
+ filename.split('_').map(&:capitalize).join('')
95
+ end
553
96
  end
554
97
  end
555
98
  end
@@ -35,32 +35,39 @@
35
35
  <div class="test-results">
36
36
  <% processed_tests.each do |test_class| %>
37
37
  <div class="test-class">
38
- <h2><%= test_class[:class_name] %></h2>
38
+ <h2><%= h(test_class[:class_name]) %></h2>
39
39
 
40
40
  <% test_class[:methods].each do |method| %>
41
- <div class="test-method" data-status="<%= method[:status] %>" data-name="<%= method[:name].downcase %>">
41
+ <div class="test-method" data-status="<%= h(method[:status]) %>" data-name="<%= h(method[:name].downcase) %>">
42
42
  <div class="test-method-header">
43
- <button class="expand-toggle" onclick="toggleTestMethod('<%= method[:safe_id] %>')">
44
- <span class="expand-icon">▶</span>
43
+ <button class="expand-toggle" onclick="toggleTestMethod('<%= h(method[:safe_id]) %>')">
44
+ <span class="expand-icon">&#9654;</span>
45
45
  </button>
46
- <h3><%= method[:name] %></h3>
46
+ <h3><%= h(method[:name]) %></h3>
47
47
  <% if method[:status] %>
48
- <span class="method-status method-status-<%= method[:status] %>"><%= method[:status] %></span>
48
+ <span class="method-status method-status-<%= h(method[:status]) %>"><%= h(method[:status]) %></span>
49
49
  <% end %>
50
50
  </div>
51
- <div class="steps collapsed" id="steps-<%= method[:safe_id] %>">
51
+ <div class="steps collapsed" id="steps-<%= h(method[:safe_id]) %>">
52
52
  <% method[:steps].each do |step| %>
53
- <div class="step <%= step[:status] %>">
53
+ <div class="step <%= h(step[:status]) %>">
54
54
  <div class="step-header">
55
- <span class="step-name"><%= step[:name] %></span>
56
- <span class="step-status"><%= step[:status] %></span>
55
+ <span class="step-name"><%= h(step[:name]) %></span>
56
+ <span class="step-status"><%= h(step[:status]) %></span>
57
57
  </div>
58
- <div class="step-detail"><%= step[:detail] %></div>
58
+ <div class="step-detail"><%= h(step[:detail]) %></div>
59
59
 
60
60
  <% if step[:error] %>
61
61
  <div class="error-log">
62
62
  <h4>Error Details</h4>
63
- <pre><%= step[:error] %></pre>
63
+ <pre><%= h(step[:error]) %></pre>
64
+ </div>
65
+ <% end %>
66
+
67
+ <% if step[:screenshot] %>
68
+ <div class="screenshot-container" onclick="openLightbox('<%= h(step[:screenshot]) %>')">
69
+ <h4>Failure Screenshot</h4>
70
+ <img src="<%= h(step[:screenshot]) %>" alt="Failure screenshot">
64
71
  </div>
65
72
  <% end %>
66
73
  </div>
@@ -72,6 +79,9 @@
72
79
  <% end %>
73
80
  </div>
74
81
  </div>
82
+ <div class="lightbox" id="screenshotLightbox" onclick="closeLightbox()">
83
+ <img id="lightboxImage" src="" alt="Screenshot full view">
84
+ </div>
75
85
  <script src="assets/dashboard.js"></script>
76
86
  </body>
77
87
  </html>
@@ -1,3 +1,3 @@
1
1
  module CapyDash
2
- VERSION = "0.2.4"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/capydash.rb CHANGED
@@ -4,12 +4,18 @@ require "capydash/version"
4
4
  if defined?(RSpec) && RSpec.respond_to?(:configure)
5
5
  require "capydash/rspec"
6
6
  CapyDash::RSpec.setup!
7
- elsif defined?(Rails)
8
- # In Rails, RSpec might load after the gem, so set up a hook
9
- Rails.application.config.after_initialize do
10
- if defined?(RSpec) && RSpec.respond_to?(:configure)
11
- require "capydash/rspec" unless defined?(CapyDash::RSpec)
12
- CapyDash::RSpec.setup!
7
+ elsif defined?(Rails) && defined?(Rails::Railtie)
8
+ # In Rails, RSpec might load after the gem, so defer setup
9
+ class CapyDash::Railtie < Rails::Railtie
10
+ config.after_initialize do
11
+ if defined?(RSpec) && RSpec.respond_to?(:configure)
12
+ require "capydash/rspec" unless defined?(CapyDash::RSpec)
13
+ CapyDash::RSpec.setup!
14
+ end
13
15
  end
14
16
  end
15
17
  end
18
+
19
+ # Minitest integration is handled automatically via the minitest plugin system.
20
+ # Minitest discovers lib/minitest/capydash_plugin.rb and calls
21
+ # plugin_capydash_init during Minitest.run, which adds our reporter.
@@ -0,0 +1,6 @@
1
+ module Minitest
2
+ def self.plugin_capydash_init(options)
3
+ require 'capydash/minitest'
4
+ self.reporter << CapyDash::Minitest::Reporter.new
5
+ end
6
+ end