mutation_tester 1.2.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +83 -0
  5. data/LICENSE.txt +19 -0
  6. data/Rakefile +57 -0
  7. data/docs/ci.md +158 -0
  8. data/docs/execution-runners.md +163 -0
  9. data/docs/json-schema.md +171 -0
  10. data/docs/mutation-types.md +207 -0
  11. data/examples/calculator.rb +35 -0
  12. data/examples/calculator_100_perc_spec.rb +121 -0
  13. data/examples/calculator_minitest.rb +53 -0
  14. data/examples/calculator_spec.rb +105 -0
  15. data/examples/github_actions/ai_mutation_gate.yml +75 -0
  16. data/examples/github_actions/mutation_test.yml +87 -0
  17. data/examples/hooks/pre-push +41 -0
  18. data/examples/run_example.rb +31 -0
  19. data/examples/run_example_minitest.rb +38 -0
  20. data/examples/run_example_parallel_minitest.rb +48 -0
  21. data/examples/run_example_parallel_rspec.rb +48 -0
  22. data/examples/run_example_rspec.rb +38 -0
  23. data/examples/spec_helper.rb +21 -0
  24. data/exe/mutation_test +345 -0
  25. data/lib/mutation_tester/batch_runner.rb +286 -0
  26. data/lib/mutation_tester/configuration.rb +105 -0
  27. data/lib/mutation_tester/core.rb +193 -0
  28. data/lib/mutation_tester/fork_runner/worker.rb +202 -0
  29. data/lib/mutation_tester/fork_runner.rb +382 -0
  30. data/lib/mutation_tester/framework_detector.rb +25 -0
  31. data/lib/mutation_tester/in_memory_loader.rb +25 -0
  32. data/lib/mutation_tester/mutation_runner.rb +644 -0
  33. data/lib/mutation_tester/mutator.rb +874 -0
  34. data/lib/mutation_tester/progress_display.rb +130 -0
  35. data/lib/mutation_tester/railtie.rb +7 -0
  36. data/lib/mutation_tester/rake_task.rb +18 -0
  37. data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
  38. data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
  39. data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
  40. data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
  41. data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
  42. data/lib/mutation_tester/test_command.rb +165 -0
  43. data/lib/mutation_tester/version.rb +3 -0
  44. data/lib/mutation_tester.rb +52 -0
  45. data/lib/tasks/mutation_tester.rake +80 -0
  46. data/readme.md +925 -0
  47. metadata +213 -0
@@ -0,0 +1,693 @@
1
+ module MutationTester
2
+ module Reporters
3
+ class HtmlReporter < BaseReporter
4
+ include ERB::Util
5
+
6
+ STATUS_SORT_ORDER = %i[survived killed timeout stillborn error].freeze
7
+
8
+ def generate
9
+ output_file = File.join(@config.output_dir, 'mutation_report.html')
10
+ html_content = ERB.new(template).result(binding)
11
+ File.write(output_file, html_content)
12
+ puts Rainbow("✓ HTML report saved to: #{output_file}").green
13
+ end
14
+
15
+ private
16
+
17
+ def sorted_results
18
+ @results.sort_by do |result|
19
+ [STATUS_SORT_ORDER.index(status_of(result)) || STATUS_SORT_ORDER.size, result[:line]]
20
+ end
21
+ end
22
+
23
+ def ungrouped_results
24
+ sorted_results.reject { |result| status_of(result) == :survived }
25
+ end
26
+
27
+ def group_location_label(group)
28
+ if @config.show_file_path
29
+ "#{h(group[:file_path])}:#{group[:line]}"
30
+ else
31
+ "Line #{group[:line]}"
32
+ end
33
+ end
34
+
35
+ def group_types_label(group)
36
+ group[:mutations].map { |m| m[:type].to_s.capitalize }.uniq.join(', ')
37
+ end
38
+
39
+ def diff_css_class(kind)
40
+ { hunk: 'hunk', context: 'context', removed: 'original', added: 'mutated' }.fetch(kind)
41
+ end
42
+
43
+ def diff_marker_char(kind)
44
+ { removed: '-', added: '+' }.fetch(kind, ' ')
45
+ end
46
+
47
+ def template
48
+ <<~'HTML'
49
+ <!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="UTF-8">
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
54
+ <title>Mutation Testing Report</title>
55
+ <link rel="preconnect" href="https://fonts.googleapis.com">
56
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
57
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
58
+ <style>
59
+ :root {
60
+ --bg-main: #0f172a;
61
+ --bg-card: #1e293b;
62
+ --bg-card-hover: #334155;
63
+ --text-primary: #f8fafc;
64
+ --text-secondary: #94a3b8;
65
+ --text-muted: #64748b;
66
+ --border-color: #334155;
67
+ --accent-primary: #3b82f6;
68
+ --accent-success: #10b981;
69
+ --accent-danger: #ef4444;
70
+ --accent-warning: #f59e0b;
71
+ --gradient-bg: radial-gradient(circle at top right, #1e293b 0%, #0f172a 100%);
72
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
73
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
74
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
75
+ --shadow-glow: 0 0 20px rgba(59, 130, 246, 0.15);
76
+ }
77
+
78
+ * { margin: 0; padding: 0; box-sizing: border-box; }
79
+
80
+ body {
81
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
82
+ background: var(--bg-main);
83
+ color: var(--text-primary);
84
+ line-height: 1.6;
85
+ min-height: 100vh;
86
+ }
87
+
88
+ .layout {
89
+ max-width: 1400px;
90
+ margin: 0 auto;
91
+ padding: 40px 20px;
92
+ }
93
+
94
+ /* Header */
95
+ .header {
96
+ display: flex;
97
+ justify-content: space-between;
98
+ align-items: flex-start;
99
+ margin-bottom: 60px;
100
+ padding-bottom: 20px;
101
+ border-bottom: 1px solid var(--border-color);
102
+ }
103
+
104
+ .brand h1 {
105
+ font-size: 2.5rem;
106
+ font-weight: 700;
107
+ background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
108
+ -webkit-background-clip: text;
109
+ -webkit-text-fill-color: transparent;
110
+ margin-bottom: 8px;
111
+ letter-spacing: -0.02em;
112
+ }
113
+
114
+ .brand-meta {
115
+ color: var(--text-secondary);
116
+ font-size: 0.95rem;
117
+ display: flex;
118
+ gap: 15px;
119
+ align-items: center;
120
+ }
121
+
122
+ .version-badge {
123
+ background: rgba(59, 130, 246, 0.1);
124
+ color: #60a5fa;
125
+ padding: 4px 12px;
126
+ border-radius: 20px;
127
+ font-size: 0.8rem;
128
+ font-weight: 600;
129
+ }
130
+
131
+ .report-meta {
132
+ text-align: right;
133
+ color: var(--text-secondary);
134
+ font-size: 0.9rem;
135
+ }
136
+
137
+ .interrupted-banner {
138
+ display: flex;
139
+ align-items: center;
140
+ gap: 12px;
141
+ background: rgba(245, 158, 11, 0.12);
142
+ border: 1px solid var(--accent-warning);
143
+ border-radius: 12px;
144
+ color: var(--accent-warning);
145
+ font-weight: 600;
146
+ padding: 16px 20px;
147
+ margin-bottom: 40px;
148
+ }
149
+
150
+ .interrupted-banner small {
151
+ color: var(--text-secondary);
152
+ font-weight: 400;
153
+ }
154
+
155
+ /* Summary Stats */
156
+ .summary-grid {
157
+ display: grid;
158
+ grid-template-columns: repeat(4, 1fr);
159
+ gap: 24px;
160
+ margin-bottom: 60px;
161
+ }
162
+
163
+ .stat-card {
164
+ background: var(--bg-card);
165
+ border: 1px solid var(--border-color);
166
+ border-radius: 16px;
167
+ padding: 24px;
168
+ transition: all 0.3s ease;
169
+ position: relative;
170
+ overflow: hidden;
171
+ }
172
+
173
+ .stat-card:hover {
174
+ transform: translateY(-4px);
175
+ border-color: var(--text-secondary);
176
+ box-shadow: var(--shadow-glow);
177
+ }
178
+
179
+ .stat-card::before {
180
+ content: '';
181
+ position: absolute;
182
+ top: 0;
183
+ left: 0;
184
+ width: 100%;
185
+ height: 4px;
186
+ background: var(--card-color, var(--text-muted));
187
+ }
188
+
189
+ .stat-label {
190
+ color: var(--text-secondary);
191
+ font-size: 0.85rem;
192
+ text-transform: uppercase;
193
+ letter-spacing: 0.05em;
194
+ font-weight: 600;
195
+ margin-bottom: 12px;
196
+ }
197
+
198
+ .stat-value {
199
+ font-size: 2.5rem;
200
+ font-weight: 700;
201
+ color: var(--text-primary);
202
+ line-height: 1;
203
+ }
204
+
205
+ .stat-sub {
206
+ margin-top: 8px;
207
+ font-size: 0.9rem;
208
+ color: var(--text-muted);
209
+ }
210
+
211
+ /* Score Card Special Styling */
212
+ .stat-card.score {
213
+ grid-column: span 1;
214
+ background: linear-gradient(145deg, var(--bg-card) 0%, rgba(30, 41, 59, 0.8) 100%);
215
+ }
216
+
217
+ .score-ring {
218
+ position: relative;
219
+ display: inline-flex;
220
+ align-items: center;
221
+ justify-content: center;
222
+ }
223
+
224
+ /* Filters */
225
+ .controls {
226
+ display: flex;
227
+ justify-content: space-between;
228
+ align-items: center;
229
+ margin-bottom: 30px;
230
+ background: var(--bg-card);
231
+ padding: 8px;
232
+ border-radius: 12px;
233
+ border: 1px solid var(--border-color);
234
+ width: fit-content;
235
+ }
236
+
237
+ .filter-btn {
238
+ background: transparent;
239
+ border: none;
240
+ color: var(--text-secondary);
241
+ padding: 10px 24px;
242
+ border-radius: 8px;
243
+ cursor: pointer;
244
+ font-weight: 600;
245
+ font-size: 0.95rem;
246
+ transition: all 0.2s ease;
247
+ font-family: inherit;
248
+ }
249
+
250
+ .filter-btn:hover {
251
+ color: var(--text-primary);
252
+ }
253
+
254
+ .filter-btn.active {
255
+ background: var(--bg-main);
256
+ color: var(--text-primary);
257
+ box-shadow: var(--shadow-sm);
258
+ }
259
+
260
+ .filter-count {
261
+ background: rgba(255,255,255,0.1);
262
+ padding: 2px 8px;
263
+ border-radius: 12px;
264
+ font-size: 0.8em;
265
+ margin-left: 8px;
266
+ }
267
+
268
+ /* Mutation List */
269
+ .mutation-list {
270
+ display: flex;
271
+ flex-direction: column;
272
+ gap: 20px;
273
+ }
274
+
275
+ .mutation-item {
276
+ background: var(--bg-card);
277
+ border: 1px solid var(--border-color);
278
+ border-radius: 12px;
279
+ overflow: hidden;
280
+ transition: all 0.3s ease;
281
+ }
282
+
283
+ .mutation-item:hover {
284
+ border-color: var(--text-secondary);
285
+ }
286
+
287
+ .mutation-header {
288
+ padding: 20px 24px;
289
+ display: flex;
290
+ justify-content: space-between;
291
+ align-items: center;
292
+ background: rgba(255,255,255,0.02);
293
+ border-bottom: 1px solid var(--border-color);
294
+ cursor: pointer;
295
+ }
296
+
297
+ .mutation-id {
298
+ font-family: 'JetBrains Mono', monospace;
299
+ color: var(--text-secondary);
300
+ font-size: 0.9rem;
301
+ }
302
+
303
+ .status-badge {
304
+ padding: 6px 16px;
305
+ border-radius: 20px;
306
+ font-size: 0.85rem;
307
+ font-weight: 700;
308
+ text-transform: uppercase;
309
+ letter-spacing: 0.05em;
310
+ }
311
+
312
+ .status-killed {
313
+ background: rgba(16, 185, 129, 0.15);
314
+ color: #34d399;
315
+ border: 1px solid rgba(16, 185, 129, 0.2);
316
+ }
317
+
318
+ .status-survived {
319
+ background: rgba(239, 68, 68, 0.15);
320
+ color: #f87171;
321
+ border: 1px solid rgba(239, 68, 68, 0.2);
322
+ }
323
+
324
+ .status-timeout {
325
+ background: rgba(245, 158, 11, 0.15);
326
+ color: #fbbf24;
327
+ border: 1px solid rgba(245, 158, 11, 0.2);
328
+ }
329
+
330
+ .status-stillborn {
331
+ background: rgba(148, 163, 184, 0.15);
332
+ color: #cbd5e1;
333
+ border: 1px solid rgba(148, 163, 184, 0.2);
334
+ }
335
+
336
+ .status-error {
337
+ background: rgba(168, 85, 247, 0.15);
338
+ color: #c084fc;
339
+ border: 1px solid rgba(168, 85, 247, 0.2);
340
+ }
341
+
342
+ /* Category breakdown */
343
+ .category-bar {
344
+ display: flex;
345
+ flex-wrap: wrap;
346
+ gap: 12px;
347
+ margin-bottom: 40px;
348
+ }
349
+
350
+ .category-chip {
351
+ background: var(--bg-card);
352
+ border: 1px solid var(--border-color);
353
+ border-radius: 20px;
354
+ padding: 8px 18px;
355
+ font-size: 0.9rem;
356
+ color: var(--text-secondary);
357
+ display: flex;
358
+ gap: 8px;
359
+ align-items: center;
360
+ }
361
+
362
+ .category-chip b {
363
+ color: var(--text-primary);
364
+ font-weight: 700;
365
+ }
366
+
367
+ .mutation-content {
368
+ padding: 24px;
369
+ }
370
+
371
+ .info-grid {
372
+ display: grid;
373
+ grid-template-columns: auto 1fr;
374
+ gap: 16px 32px;
375
+ margin-bottom: 24px;
376
+ }
377
+
378
+ .info-label {
379
+ color: var(--text-muted);
380
+ font-weight: 500;
381
+ font-size: 0.9rem;
382
+ }
383
+
384
+ .info-value {
385
+ color: var(--text-primary);
386
+ font-family: 'JetBrains Mono', monospace;
387
+ font-size: 0.9rem;
388
+ }
389
+
390
+ .code-diff {
391
+ background: #000;
392
+ border-radius: 8px;
393
+ padding: 20px;
394
+ font-family: 'JetBrains Mono', monospace;
395
+ font-size: 0.9rem;
396
+ border: 1px solid var(--border-color);
397
+ position: relative;
398
+ }
399
+
400
+ .diff-line {
401
+ display: flex;
402
+ padding: 2px 0;
403
+ }
404
+
405
+ .diff-line.original {
406
+ color: #f87171;
407
+ background: rgba(239, 68, 68, 0.1);
408
+ }
409
+
410
+ .diff-line.mutated {
411
+ color: #34d399;
412
+ background: rgba(16, 185, 129, 0.1);
413
+ }
414
+
415
+ .diff-line.context {
416
+ color: var(--text-secondary);
417
+ }
418
+
419
+ .diff-line.hunk {
420
+ color: var(--text-muted);
421
+ }
422
+
423
+ .diff-variant-ref {
424
+ color: var(--text-muted);
425
+ margin-left: 12px;
426
+ font-size: 0.8em;
427
+ }
428
+
429
+ .variant-badge {
430
+ color: var(--text-secondary);
431
+ font-family: 'JetBrains Mono', monospace;
432
+ font-size: 0.85rem;
433
+ }
434
+
435
+ .diff-marker {
436
+ width: 24px;
437
+ display: inline-block;
438
+ text-align: center;
439
+ opacity: 0.5;
440
+ user-select: none;
441
+ }
442
+
443
+ .suggestion-box {
444
+ margin-top: 20px;
445
+ background: rgba(245, 158, 11, 0.1);
446
+ border: 1px solid rgba(245, 158, 11, 0.2);
447
+ padding: 16px;
448
+ border-radius: 8px;
449
+ display: flex;
450
+ gap: 12px;
451
+ align-items: flex-start;
452
+ }
453
+
454
+ .suggestion-icon {
455
+ font-size: 1.2rem;
456
+ }
457
+
458
+ .suggestion-text {
459
+ color: #fbbf24;
460
+ font-size: 0.95rem;
461
+ }
462
+
463
+ /* Responsive */
464
+ @media (max-width: 1024px) {
465
+ .summary-grid {
466
+ grid-template-columns: repeat(2, 1fr);
467
+ }
468
+ }
469
+
470
+ @media (max-width: 640px) {
471
+ .summary-grid {
472
+ grid-template-columns: 1fr;
473
+ }
474
+ .header {
475
+ flex-direction: column;
476
+ gap: 20px;
477
+ }
478
+ .report-meta {
479
+ text-align: left;
480
+ }
481
+ .controls {
482
+ width: 100%;
483
+ overflow-x: auto;
484
+ }
485
+ }
486
+ </style>
487
+ </head>
488
+ <body>
489
+ <div class="layout">
490
+ <header class="header">
491
+ <div class="brand">
492
+ <h1>Mutation Report</h1>
493
+ <div class="brand-meta">
494
+ <span class="version-badge">v<%= MutationTester::VERSION %></span>
495
+ <span><%= h(@source_file) %></span>
496
+ </div>
497
+ </div>
498
+ <div class="report-meta">
499
+ <p>Generated <%= Time.now.strftime('%B %d, %Y') %></p>
500
+ <p style="color: var(--text-muted); font-size: 0.85rem; margin-top: 4px">
501
+ <%= Time.now.strftime('%H:%M:%S') %>
502
+ </p>
503
+ </div>
504
+ </header>
505
+
506
+ <% if interrupted? %>
507
+ <div class="interrupted-banner">
508
+ <span>🛑 Interrupted run</span>
509
+ <small>The run was stopped early by --fail-fast after the first surviving mutant; this report covers only the mutations processed before the interruption.</small>
510
+ </div>
511
+ <% end %>
512
+
513
+ <div class="summary-grid">
514
+ <div class="stat-card" style="--card-color: var(--accent-primary)">
515
+ <div class="stat-label">Total Mutations</div>
516
+ <div class="stat-value"><%= total_count %></div>
517
+ <div class="stat-sub">Analyzed points</div>
518
+ </div>
519
+
520
+ <div class="stat-card" style="--card-color: var(--accent-success)">
521
+ <div class="stat-label">Killed</div>
522
+ <div class="stat-value" style="color: var(--accent-success)"><%= killed_count %></div>
523
+ <div class="stat-sub"><%= total_count > 0 ? (killed_count.to_f / total_count * 100).round(1) : 0.0 %>% coverage</div>
524
+ </div>
525
+
526
+ <div class="stat-card" style="--card-color: var(--accent-danger)">
527
+ <div class="stat-label">Survived</div>
528
+ <div class="stat-value" style="color: var(--accent-danger)"><%= survived_count %></div>
529
+ <div class="stat-sub">Potential gaps</div>
530
+ </div>
531
+
532
+ <div class="stat-card score" style="--card-color: <%= mutation_score >= 80 ? 'var(--accent-success)' : 'var(--accent-warning)' %>">
533
+ <div class="stat-label">Mutation Score</div>
534
+ <div class="stat-value"><%= mutation_score %>%</div>
535
+ <div class="stat-sub">Quality: <%= quality_rating %></div>
536
+ </div>
537
+ </div>
538
+
539
+ <div class="category-bar">
540
+ <span class="category-chip status-killed">Killed <b><%= killed_count %></b></span>
541
+ <span class="category-chip status-survived">Survived <b><%= survived_count %></b></span>
542
+ <span class="category-chip status-timeout">Timeout <b><%= timeout_count %></b></span>
543
+ <span class="category-chip status-stillborn">Stillborn <b><%= stillborn_count %></b></span>
544
+ <span class="category-chip status-error">Error <b><%= error_count %></b></span>
545
+ </div>
546
+
547
+ <div class="controls">
548
+ <button class="filter-btn active" onclick="filterMutations('all', this)">
549
+ All <span class="filter-count"><%= total_count %></span>
550
+ </button>
551
+ <button class="filter-btn" onclick="filterMutations('killed', this)">
552
+ Killed <span class="filter-count"><%= killed_count %></span>
553
+ </button>
554
+ <button class="filter-btn" onclick="filterMutations('survived', this)">
555
+ Survived <span class="filter-count"><%= survived_count %></span>
556
+ </button>
557
+ <button class="filter-btn" onclick="filterMutations('timeout', this)">
558
+ Timeout <span class="filter-count"><%= timeout_count %></span>
559
+ </button>
560
+ <button class="filter-btn" onclick="filterMutations('stillborn', this)">
561
+ Stillborn <span class="filter-count"><%= stillborn_count %></span>
562
+ </button>
563
+ <button class="filter-btn" onclick="filterMutations('error', this)">
564
+ Error <span class="filter-count"><%= error_count %></span>
565
+ </button>
566
+ </div>
567
+
568
+ <div class="mutation-list">
569
+ <% survivor_groups.each do |group| %>
570
+ <% variants = group[:mutations] %>
571
+ <div class="mutation-item" data-status="survived">
572
+ <div class="mutation-header" onclick="toggleDetails(this)">
573
+ <div style="display: flex; align-items: center; gap: 16px;">
574
+ <span class="status-badge status-survived">Survived</span>
575
+ <span class="mutation-id">
576
+ <%= group_location_label(group) %><%= " (#{variants.size} variants)" if variants.size > 1 %>
577
+ </span>
578
+ </div>
579
+ <span style="color: var(--text-muted); font-size: 0.9rem;">
580
+ <%= h(group_types_label(group)) %>
581
+ </span>
582
+ </div>
583
+
584
+ <div class="mutation-content">
585
+ <div class="info-grid">
586
+ <% variants.each do |mutation| %>
587
+ <div class="info-label"><span class="variant-badge">#<%= mutation[:id] %> <%= h(mutation[:type].to_s) %></span></div>
588
+ <div class="info-value"><%= h(mutation[:description]) %></div>
589
+ <% end %>
590
+ </div>
591
+
592
+ <div class="code-diff">
593
+ <% diff_lines(variants).each do |kind, text, variant| %>
594
+ <div class="diff-line <%= diff_css_class(kind) %>"><span class="diff-marker"><%= diff_marker_char(kind) %></span><%= h(text) %><% if variant && variants.size > 1 %><span class="diff-variant-ref">#<%= variant[:id] %></span><% end %></div>
595
+ <% end %>
596
+ </div>
597
+
598
+ <div class="suggestion-box">
599
+ <span class="suggestion-icon">💡</span>
600
+ <div class="suggestion-text">
601
+ <strong>Suggestion:</strong>
602
+ <% variants.each do |mutation| %>
603
+ Add a test case to verify the behavior when <%= h(mutation[:description].downcase) %>.
604
+ <% end %>
605
+ The code was mutated but your tests still passed.
606
+ </div>
607
+ </div>
608
+ </div>
609
+ </div>
610
+ <% end %>
611
+ <% ungrouped_results.each do |mutation| %>
612
+ <% status = status_of(mutation) %>
613
+ <div class="mutation-item" data-status="<%= status %>">
614
+ <div class="mutation-header" onclick="toggleDetails(this)">
615
+ <div style="display: flex; align-items: center; gap: 16px;">
616
+ <span class="status-badge status-<%= status %>">
617
+ <%= status.to_s.capitalize %>
618
+ </span>
619
+ <span class="mutation-id">
620
+ <%= @config.show_file_path ? "#{h(mutation[:file_path])}:#{mutation[:line]}" : "Line #{mutation[:line]}" %>
621
+ </span>
622
+ </div>
623
+ <span style="color: var(--text-muted); font-size: 0.9rem;">
624
+ <%= h(mutation[:type].to_s.capitalize) %>
625
+ </span>
626
+ </div>
627
+
628
+ <div class="mutation-content">
629
+ <div class="info-grid">
630
+ <div class="info-label">Description</div>
631
+ <div class="info-value"><%= h(mutation[:description]) %></div>
632
+ </div>
633
+
634
+ <div class="code-diff">
635
+ <% if status == :timeout %>
636
+ <% diff_lines([mutation]).each do |kind, text, _variant| %>
637
+ <div class="diff-line <%= diff_css_class(kind) %>"><span class="diff-marker"><%= diff_marker_char(kind) %></span><%= h(text) %></div>
638
+ <% end %>
639
+ <% elsif mutation[:source_line] && mutation[:mutated_line] %>
640
+ <div class="diff-line original">
641
+ <span class="diff-marker">-</span>
642
+ <%= h(mutation[:source_line]) %>
643
+ </div>
644
+ <div class="diff-line mutated">
645
+ <span class="diff-marker">+</span>
646
+ <%= h(mutation[:mutated_line]) %>
647
+ </div>
648
+ <% else %>
649
+ <div class="diff-line original">
650
+ <span class="diff-marker">-</span>
651
+ <%= h(mutation[:original]) %>
652
+ </div>
653
+ <div class="diff-line mutated">
654
+ <span class="diff-marker">+</span>
655
+ <%= h(mutation[:mutated]) %>
656
+ </div>
657
+ <% end %>
658
+ </div>
659
+ </div>
660
+ </div>
661
+ <% end %>
662
+ </div>
663
+ </div>
664
+
665
+ <script>
666
+ function filterMutations(filter, button) {
667
+ const items = document.querySelectorAll('.mutation-item');
668
+ const buttons = document.querySelectorAll('.filter-btn');
669
+
670
+ buttons.forEach(btn => btn.classList.remove('active'));
671
+ button.classList.add('active');
672
+
673
+ items.forEach(item => {
674
+ if (filter === 'all') {
675
+ item.style.display = 'block';
676
+ } else {
677
+ item.style.display = item.dataset.status === filter ? 'block' : 'none';
678
+ }
679
+ });
680
+ }
681
+
682
+ function toggleDetails(header) {
683
+ // Optional: Add collapse/expand functionality if list is too long
684
+ // Currently always expanded as per design preference for visibility
685
+ }
686
+ </script>
687
+ </body>
688
+ </html>
689
+ HTML
690
+ end
691
+ end
692
+ end
693
+ end