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.
- checksums.yaml +4 -4
- data/.rubocop.yml +0 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +39 -3
- data/README.md +144 -32
- data/RELEASING.md +31 -0
- data/Rakefile +16 -5
- data/lib/lookbook_visual_tester/check_reporter.rb +81 -0
- data/lib/lookbook_visual_tester/configuration.rb +27 -4
- data/lib/lookbook_visual_tester/driver.rb +51 -0
- data/lib/lookbook_visual_tester/drivers/ferrum_driver.rb +111 -0
- data/lib/lookbook_visual_tester/json_output_handler.rb +9 -0
- data/lib/lookbook_visual_tester/preview_checker.rb +260 -0
- data/lib/lookbook_visual_tester/railtie.rb +5 -2
- data/lib/lookbook_visual_tester/report_generator.rb +25 -48
- data/lib/lookbook_visual_tester/runner.rb +224 -0
- data/lib/lookbook_visual_tester/scenario_finder.rb +7 -2
- data/lib/lookbook_visual_tester/scenario_run.rb +26 -8
- data/lib/lookbook_visual_tester/services/image_comparator.rb +66 -0
- data/lib/lookbook_visual_tester/templates/preview_check_report.html.tt +63 -0
- data/lib/lookbook_visual_tester/templates/report.html.erb +206 -0
- data/lib/lookbook_visual_tester/update_previews.rb +3 -2
- data/lib/lookbook_visual_tester/variant_resolver.rb +62 -0
- data/lib/lookbook_visual_tester/version.rb +1 -1
- data/lib/lookbook_visual_tester.rb +11 -3
- data/lib/tasks/lookbook_visual_tester.rake +293 -58
- metadata +32 -22
- data/tasks/lookbook_visual_tester.rake +0 -0
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
17
|
+
|
|
18
|
+
def self.configure
|
|
19
|
+
yield(config)
|
|
20
|
+
end
|
|
13
21
|
end
|