lookbook_visual_tester 0.1.6 → 0.5.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.
@@ -0,0 +1,66 @@
1
+ require 'chunky_png'
2
+ require 'fileutils'
3
+
4
+ module LookbookVisualTester
5
+ class ImageComparator
6
+ attr_reader :baseline_path, :current_path, :diff_path
7
+
8
+ # Neon Red for differences
9
+ DIFF_COLOR = ChunkyPNG::Color.from_hex('#FF073A')
10
+
11
+ def initialize(baseline_path, current_path, diff_path)
12
+ @baseline_path = baseline_path
13
+ @current_path = current_path
14
+ @diff_path = diff_path
15
+ end
16
+
17
+ def call
18
+ unless File.exist?(baseline_path)
19
+ return { diff_path: nil, mismatch: 0.0, error: "Baseline not found" }
20
+ end
21
+
22
+ baseline = ChunkyPNG::Image.from_file(baseline_path)
23
+ current = ChunkyPNG::Image.from_file(current_path)
24
+
25
+ if baseline.dimension != current.dimension
26
+ return {
27
+ diff_path: nil,
28
+ mismatch: 100.0,
29
+ error: "Dimensions mismatch: #{baseline.width}x#{baseline.height} vs #{current.width}x#{current.height}"
30
+ }
31
+ end
32
+
33
+ diff_pixels_count = 0
34
+ diff_image = ChunkyPNG::Image.new(baseline.width, baseline.height, ChunkyPNG::Color::WHITE)
35
+
36
+ baseline.height.times do |y|
37
+ baseline.width.times do |x|
38
+ pixel1 = baseline[x, y]
39
+ pixel2 = current[x, y]
40
+
41
+ if pixel1 != pixel2
42
+ diff_image[x, y] = DIFF_COLOR
43
+ diff_pixels_count += 1
44
+ else
45
+ # Blue context for unchanged pixels to make it easier for humans
46
+ gray_val = ChunkyPNG::Color.r(ChunkyPNG::Color.grayscale_teint(pixel1))
47
+ # Keep intensity in R/G but push blue to make it the dominant tint
48
+ diff_image[x, y] = ChunkyPNG::Color.rgba(gray_val, gray_val, 255, 50)
49
+ end
50
+ end
51
+ end
52
+
53
+ mismatch_percentage = (diff_pixels_count.to_f / baseline.pixels.size) * 100.0
54
+
55
+ if diff_pixels_count > 0
56
+ FileUtils.mkdir_p(File.dirname(diff_path))
57
+ diff_image.save(diff_path)
58
+ { diff_path: diff_path, mismatch: mismatch_percentage, error: nil }
59
+ else
60
+ { diff_path: nil, mismatch: 0.0, error: nil }
61
+ end
62
+ rescue StandardError => e
63
+ { diff_path: nil, mismatch: 0.0, error: e.message }
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,63 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Preview Check Report</title>
5
+ <style>
6
+ body { font-family: system-ui, -apple-system, sans-serif; padding: 20px; color: #333; }
7
+ .summary { margin-bottom: 20px; padding: 15px; background: #f5f5f5; border-radius: 8px; }
8
+ .passed { color: green; }
9
+ .failed { color: red; }
10
+ table { width: 100%; border-collapse: collapse; margin-top: 20px; }
11
+ th, td { text-align: left; padding: 10px; border-bottom: 1px solid #ddd; }
12
+ th { background: #f0f0f0; }
13
+ tr.error { background: #fff0f0; }
14
+ .backtrace { font-family: monospace; font-size: 0.9em; white-space: pre-wrap; color: #666; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <h1>Preview Check Report</h1>
19
+
20
+ <div class="summary">
21
+ <p><strong>Total Checks:</strong> <%= results.size %></p>
22
+ <p><strong>Passed:</strong> <span class="passed"><%= success_count %></span></p>
23
+ <p><strong>Failed:</strong> <span class="failed"><%= errors.size %></span></p>
24
+ <p><strong>Total Duration:</strong> <%= total_duration.round(2) %>s</p>
25
+ </div>
26
+
27
+ <h2>Top 5 Slowest Previews</h2>
28
+ <ul>
29
+ <% results.sort_by { |r| -r.duration.to_f }.first(5).each do |res| %>
30
+ <li><strong><%= res.duration.to_f.round(4) %>s</strong> - <%= res.preview_name %>#<%= res.example_name %></li>
31
+ <% end %>
32
+ </ul>
33
+
34
+ <h2>Detailed Results</h2>
35
+ <table>
36
+ <thead>
37
+ <tr>
38
+ <th>Status</th>
39
+ <th>Preview</th>
40
+ <th>Example</th>
41
+ <th>Time (s)</th>
42
+ <th>Error</th>
43
+ </tr>
44
+ </thead>
45
+ <tbody>
46
+ <% results.sort_by { |r| r.status == :failed ? 0 : 1 }.each do |result| %>
47
+ <tr class="<%= 'error' if result.status == :failed %>">
48
+ <td class="<%= result.status %>"><%= result.status.to_s.upcase %></td>
49
+ <td><%= result.preview_name %></td>
50
+ <td><%= result.example_name %></td>
51
+ <td><%= result.duration.to_f.round(4) %></td>
52
+ <td>
53
+ <% if result.error %>
54
+ <div><strong><%= result.error %></strong></div>
55
+ <div class="backtrace"><%= result.backtrace&.first(5)&.join("\n") %></div>
56
+ <% end %>
57
+ </td>
58
+ </tr>
59
+ <% end %>
60
+ </tbody>
61
+ </table>
62
+ </body>
63
+ </html>
@@ -0,0 +1,206 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Visual Regression Report</title>
7
+ <style>
8
+ :root {
9
+ --bg-dark: #0f172a;
10
+ --bg-card: #1e293b;
11
+ --text-main: #f8fafc;
12
+ --text-muted: #94a3b8;
13
+ --danger: #ef4444;
14
+ --success: #22c55e;
15
+ --new: #3b82f6;
16
+ --border: #334155;
17
+ }
18
+
19
+ body {
20
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
21
+ background-color: var(--bg-dark);
22
+ color: var(--text-main);
23
+ margin: 0;
24
+ padding: 20px;
25
+ }
26
+
27
+ /* Header & Stats */
28
+ .header {
29
+ display: flex;
30
+ justify-content: space-between;
31
+ align-items: center;
32
+ margin-bottom: 30px;
33
+ border-bottom: 1px solid var(--border);
34
+ padding-bottom: 20px;
35
+ }
36
+
37
+ .stats {
38
+ display: flex;
39
+ gap: 15px;
40
+ }
41
+
42
+ .badge {
43
+ padding: 5px 12px;
44
+ border-radius: 9999px;
45
+ font-weight: bold;
46
+ font-size: 0.9rem;
47
+ }
48
+ .badge-failed { background: rgba(239, 68, 68, 0.2); color: var(--danger); }
49
+ .badge-passed { background: rgba(34, 197, 94, 0.2); color: var(--success); }
50
+ .badge-new { background: rgba(59, 130, 246, 0.2); color: var(--new); }
51
+
52
+ /* Test Case Card */
53
+ .test-case {
54
+ background: var(--bg-card);
55
+ border: 1px solid var(--border);
56
+ border-radius: 8px;
57
+ margin-bottom: 20px;
58
+ overflow: hidden;
59
+ }
60
+
61
+ .test-header {
62
+ padding: 15px;
63
+ display: flex;
64
+ justify-content: space-between;
65
+ align-items: center;
66
+ background: rgba(0,0,0,0.2);
67
+ }
68
+
69
+ .test-title { font-size: 1.1rem; font-weight: 600; }
70
+
71
+ /* Comparison View */
72
+ .comparison-grid {
73
+ display: grid;
74
+ grid-template-columns: 1fr 1fr 1fr;
75
+ gap: 2px;
76
+ background: var(--border);
77
+ }
78
+
79
+ .image-col {
80
+ background: var(--bg-card);
81
+ padding: 10px;
82
+ text-align: center;
83
+ }
84
+
85
+ .image-col h4 {
86
+ color: var(--text-muted);
87
+ margin: 0 0 10px 0;
88
+ font-size: 0.8rem;
89
+ text-transform: uppercase;
90
+ letter-spacing: 1px;
91
+ }
92
+
93
+ .image-col img {
94
+ max-width: 100%;
95
+ height: auto;
96
+ border: 1px solid var(--border);
97
+ display: block; /* Removes bottom spacing */
98
+ }
99
+
100
+ /* Actions */
101
+ .actions {
102
+ padding: 15px;
103
+ text-align: right;
104
+ border-top: 1px solid var(--border);
105
+ }
106
+
107
+ .btn {
108
+ background: var(--bg-dark);
109
+ border: 1px solid var(--border);
110
+ color: var(--text-main);
111
+ padding: 8px 16px;
112
+ border-radius: 4px;
113
+ cursor: pointer;
114
+ font-size: 0.9rem;
115
+ transition: all 0.2s;
116
+ }
117
+
118
+ .btn:hover { background: #334155; }
119
+
120
+ .btn-copy {
121
+ color: var(--new);
122
+ border-color: var(--new);
123
+ }
124
+
125
+ /* Helper helper */
126
+ .hidden { display: none; }
127
+ </style>
128
+ </head>
129
+ <body>
130
+
131
+ <div class="header">
132
+ <h1>Visual Test Report</h1>
133
+ <div class="stats">
134
+ <span class="badge badge-failed"><%= @stats[:failed] %> Failed</span>
135
+ <span class="badge badge-new"><%= @stats[:new] %> New</span>
136
+ <span class="badge badge-passed"><%= @stats[:passed] %> Passed</span>
137
+ </div>
138
+ </div>
139
+
140
+ <% @results.each do |result| %>
141
+ <% next if result.status == :passed # Skip passed tests to reduce noise %>
142
+
143
+ <div class="test-case">
144
+ <div class="test-header">
145
+ <span class="test-title">
146
+ <%= result.scenario_name %>
147
+ <span class="badge badge-<%= result.status %>"><%= result.status.upcase %></span>
148
+ </span>
149
+ </div>
150
+
151
+ <div class="comparison-grid">
152
+ <div class="image-col">
153
+ <h4>Baseline</h4>
154
+ <% if result.baseline_path && File.exist?(result.baseline_path) %>
155
+ <img src="<%= File.expand_path(result.baseline_path, Dir.pwd) %>" alt="Baseline">
156
+ <% else %>
157
+ <div style="padding: 40px; color: var(--text-muted);">No Baseline</div>
158
+ <% end %>
159
+ </div>
160
+
161
+ <div class="image-col">
162
+ <h4>Diff</h4>
163
+ <% if result.diff_path && File.exist?(result.diff_path) %>
164
+ <img src="<%= File.expand_path(result.diff_path, Dir.pwd) %>" alt="Diff">
165
+ <% else %>
166
+ <div style="padding: 40px; color: var(--text-muted);">No Diff Available</div>
167
+ <% end %>
168
+ </div>
169
+
170
+ <div class="image-col">
171
+ <h4>New / Actual</h4>
172
+ <% if result.current_path && File.exist?(result.current_path) %>
173
+ <img src="<%= File.expand_path(result.current_path, Dir.pwd) %>" alt="Actual">
174
+ <% else %>
175
+ <div style="padding: 40px; color: var(--text-muted);">No Capture</div>
176
+ <% end %>
177
+ </div>
178
+ </div>
179
+
180
+ <div class="actions">
181
+ <input type="hidden" value='<%= approve_command(result) %>' id="cmd-<%= result.object_id %>">
182
+
183
+ <button class="btn btn-copy" onclick="copyCommand('<%= result.object_id %>')">
184
+ 📋 Copy Approval Command
185
+ </button>
186
+ </div>
187
+ </div>
188
+ <% end %>
189
+
190
+ <% if @stats[:failed] == 0 && @stats[:new] == 0 %>
191
+ <div style="text-align: center; padding: 50px; color: var(--success);">
192
+ <h2>All clear! ✨</h2>
193
+ <p>No visual regressions detected.</p>
194
+ </div>
195
+ <% end %>
196
+
197
+ <script>
198
+ function copyCommand(id) {
199
+ const cmd = document.getElementById('cmd-' + id).value;
200
+ navigator.clipboard.writeText(cmd).then(() => {
201
+ alert('Command copied! Paste it in your terminal to approve this change.');
202
+ });
203
+ }
204
+ </script>
205
+ </body>
206
+ </html>
@@ -63,10 +63,11 @@ module LookbookVisualTester
63
63
  selected_previews.each do |preview|
64
64
  Rails.logger.info "LookbookVisualTester: entering #{preview.inspect}"
65
65
 
66
- preview.scenarios.each do |scenario|
66
+ group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
67
+ group.each do |scenario|
67
68
  scenario_run = LookbookVisualTester::ScenarioRun.new(scenario)
68
69
  Rails.logger.info "LookbookVisualTester: Processing scenario #{scenario_run.inspect}"
69
- LookbookVisualTester::ScreenshotTaker.call(scenario_run:)
70
+ LookbookVisualTester::ScreenshotTaker.call(scenario_run: scenario_run)
70
71
  end
71
72
  end
72
73
  end
@@ -0,0 +1,62 @@
1
+ module LookbookVisualTester
2
+ class VariantResolver
3
+ attr_reader :input_variant
4
+
5
+ def initialize(input_variant)
6
+ @input_variant = input_variant || {}
7
+ end
8
+
9
+ def resolve
10
+ resolved = {}
11
+ @input_variant.each do |key, label|
12
+ resolved[key.to_sym] = resolve_value(key, label)
13
+ end
14
+ resolved
15
+ end
16
+
17
+ def slug
18
+ return '' if @input_variant.empty?
19
+
20
+ @input_variant.sort_by { |k, _v| k.to_s }.map do |key, label|
21
+ "#{key}-#{sanitize(label)}"
22
+ end.join('_')
23
+ end
24
+
25
+ def width_in_pixels
26
+ resolved_width = resolve[:width]
27
+ return nil unless resolved_width
28
+
29
+ if resolved_width.to_s.end_with?('px')
30
+ resolved_width.to_i
31
+ else
32
+ nil # Ignore percentages or other units for resizing
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def resolve_value(key, label)
39
+ options = Lookbook.config.preview_display_options[key.to_sym]
40
+ return label unless options
41
+
42
+ # Options can be an array of strings or array of [label, value] arrays
43
+ found = options.find do |option|
44
+ if option.is_a?(Array)
45
+ option[0] == label
46
+ else
47
+ option == label
48
+ end
49
+ end
50
+
51
+ if found
52
+ found.is_a?(Array) ? found[1] : found
53
+ else
54
+ label
55
+ end
56
+ end
57
+
58
+ def sanitize(value)
59
+ value.to_s.gsub(/[^a-zA-Z0-9]/, '_').squeeze('_').gsub(/^_|_$/, '')
60
+ end
61
+ end
62
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LookbookVisualTester
4
- VERSION = '0.1.6'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -3,11 +3,19 @@
3
3
  # lib/lookbook_visual_tester.rb
4
4
 
5
5
  require_relative 'lookbook_visual_tester/version'
6
+ require_relative 'lookbook_visual_tester/configuration'
6
7
  require_relative 'lookbook_visual_tester/railtie' if defined?(Rails)
7
- require 'lookbook_visual_tester/scenario_finder'
8
- require 'lookbook_visual_tester/store'
8
+ require_relative 'lookbook_visual_tester/scenario_finder'
9
+ require_relative 'lookbook_visual_tester/store'
10
+ require_relative 'lookbook_visual_tester/runner'
11
+ require_relative 'lookbook_visual_tester/driver'
12
+ require_relative 'lookbook_visual_tester/drivers/ferrum_driver'
13
+ require_relative 'lookbook_visual_tester/services/image_comparator'
9
14
 
10
15
  module LookbookVisualTester
11
16
  class Error < StandardError; end
12
- # Your code goes here...
17
+
18
+ def self.configure
19
+ yield(config)
20
+ end
13
21
  end