lookbook_visual_tester 0.1.6 → 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.
@@ -0,0 +1,192 @@
1
+ require 'lookbook'
2
+ require_relative 'configuration'
3
+ require_relative 'scenario_run'
4
+ require_relative 'services/image_comparator'
5
+ require_relative 'drivers/ferrum_driver'
6
+
7
+ module LookbookVisualTester
8
+ class Runner
9
+ Result = Struct.new(:scenario_name, :status, :mismatch, :diff_path, :error, :baseline_path,
10
+ :current_path, keyword_init: true)
11
+
12
+ def initialize(config = LookbookVisualTester.config, pattern: nil)
13
+ @config = config
14
+ @pattern = pattern
15
+ @driver_pool = Queue.new
16
+ init_driver_pool
17
+ @results = []
18
+ end
19
+
20
+ def run
21
+ previews = Lookbook.previews
22
+
23
+ if @pattern.present?
24
+ previews = previews.select do |preview|
25
+ preview.label.downcase.include?(@pattern.downcase) ||
26
+ preview.name.downcase.include?(@pattern.downcase)
27
+ end
28
+ end
29
+
30
+ puts "Found #{previews.count} previews matching '#{@pattern}'."
31
+
32
+ if @config.threads > 1
33
+ run_concurrently(previews)
34
+ else
35
+ run_sequentially(previews)
36
+ end
37
+
38
+ @results
39
+ ensure
40
+ cleanup_drivers
41
+ end
42
+
43
+ private
44
+
45
+ def run_sequentially(previews)
46
+ previews.each do |preview|
47
+ group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
48
+ group.each do |scenario|
49
+ driver = checkout_driver
50
+ begin
51
+ @results << run_scenario(scenario, driver)
52
+ ensure
53
+ return_driver(driver)
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def run_concurrently(previews)
60
+ require 'concurrent-ruby'
61
+ pool = Concurrent::FixedThreadPool.new(@config.threads)
62
+ promises = []
63
+
64
+ previews.each do |preview|
65
+ group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
66
+ group.each do |scenario|
67
+ promises << Concurrent::Promises.future_on(pool) do
68
+ driver = checkout_driver
69
+ begin
70
+ run_scenario(scenario, driver)
71
+ ensure
72
+ return_driver(driver)
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ @results = Concurrent::Promises.zip(*promises).value
79
+ pool.shutdown
80
+ pool.wait_for_termination
81
+ end
82
+
83
+ def init_driver_pool
84
+ # Create N drivers where N = threads
85
+ # If sequential, we only need 1, but we can just simplify and create as many as updated threads config says
86
+ # Or just 1 if not concurrent?
87
+ # Actually, let's just stick to @config.threads.
88
+ # Even for sequential run, if threads was configured to 4, we might create 4 but only use 1.
89
+ # Optimization: if sequential, only create 1.
90
+
91
+ count = @config.threads > 1 ? @config.threads : 1
92
+ count.times do
93
+ @driver_pool << LookbookVisualTester::Drivers::FerrumDriver.new(@config)
94
+ end
95
+ end
96
+
97
+ def checkout_driver
98
+ @driver_pool.pop
99
+ end
100
+
101
+ def return_driver(driver)
102
+ @driver_pool << driver
103
+ end
104
+
105
+ def cleanup_drivers
106
+ until @driver_pool.empty?
107
+ driver = @driver_pool.pop
108
+ driver.cleanup
109
+ end
110
+ end
111
+
112
+ def run_scenario(scenario, driver)
113
+ run_data = ScenarioRun.new(scenario)
114
+ puts "Running visual test for: #{run_data.name}"
115
+
116
+ begin
117
+ driver.resize_window(1280, 800) # Default or config
118
+ driver.visit(run_data.preview_url)
119
+
120
+ # Determine paths
121
+ current_path = run_data.current_path
122
+ baseline_path = run_data.baseline_path
123
+ diff_path = @config.diff_dir.join(run_data.diff_filename)
124
+
125
+ FileUtils.mkdir_p(File.dirname(current_path))
126
+
127
+ driver.save_screenshot(current_path.to_s)
128
+
129
+ # Trimming (Feature parity with legacy ScreenshotTaker)
130
+ if File.exist?(current_path)
131
+ system("convert #{current_path} -trim -bordercolor white -border 10x10 #{current_path}")
132
+ end
133
+
134
+ comparator = LookbookVisualTester::ImageComparator.new(
135
+ baseline_path.to_s,
136
+ current_path.to_s,
137
+ diff_path.to_s
138
+ )
139
+
140
+ result = comparator.call
141
+
142
+ status = :passed
143
+ mismatch = 0.0
144
+ error = nil
145
+
146
+ if result[:error]
147
+ if result[:error] == 'Baseline not found'
148
+ # First run, maybe auto-approve or just report
149
+ puts ' [NEW] Baseline not found. Saved current as potential baseline.'
150
+ status = :new
151
+ else
152
+ puts " [ERROR] #{result[:error]}"
153
+ status = :error
154
+ error = result[:error]
155
+ end
156
+ elsif result[:mismatch] > 0
157
+ mismatch = result[:mismatch]
158
+ puts " [FAIL] Mismatch: #{mismatch.round(2)}%. Diff saved to #{diff_path}"
159
+ status = :failed
160
+
161
+ # Clipboard (Feature parity with legacy ScreenshotTaker)
162
+ if @config.copy_to_clipboard
163
+ system("xclip -selection clipboard -t image/png -i #{current_path}")
164
+ end
165
+ else
166
+ puts ' [PASS] Identical.'
167
+ status = :passed
168
+ end
169
+
170
+ Result.new(
171
+ scenario_name: run_data.name,
172
+ status: status,
173
+ mismatch: mismatch,
174
+ diff_path: diff_path.to_s,
175
+ error: error,
176
+ baseline_path: baseline_path.to_s,
177
+ current_path: current_path.to_s
178
+ )
179
+ rescue StandardError => e
180
+ puts " [ERROR] Exception: #{e.message}"
181
+ puts e.backtrace.take(5)
182
+ Result.new(
183
+ scenario_name: run_data.name,
184
+ status: :error,
185
+ error: e.message,
186
+ baseline_path: run_data.baseline_path.to_s,
187
+ current_path: run_data.current_path.to_s
188
+ )
189
+ end
190
+ end
191
+ end
192
+ end
@@ -12,19 +12,24 @@ module LookbookVisualTester
12
12
  end
13
13
 
14
14
  def regex
15
- @regex = Regexp.new(search.chars.join('.*'), Regexp::IGNORECASE)
15
+ @regex = Regexp.new(clean_search.chars.join('.*'), Regexp::IGNORECASE)
16
16
  end
17
17
 
18
18
  def matched_previews
19
19
  @matched_previews ||= previews.select { |preview| regex.match?(preview.name.downcase) }
20
20
  end
21
21
 
22
+ def clean_search
23
+ @clean_search ||= search.downcase.gsub(/[^a-z0-9\s]/, '').strip
24
+ end
25
+
22
26
  def call
23
27
  return nil if search.nil? || search == '' || previews.empty?
24
28
 
25
29
  previews.each do |preview|
26
30
  preview.scenarios.each do |scenario|
27
- return ScenarioRun.new(scenario) if scenario.name.downcase.include?(search.downcase)
31
+ name = "#{preview.name} #{scenario.name}".downcase
32
+ return ScenarioRun.new(scenario) if regex.match?(name.downcase) #name.downcase.include?(clean_search)
28
33
  end
29
34
  end
30
35
 
@@ -8,11 +8,11 @@ module LookbookVisualTester
8
8
  @scenario = scenario
9
9
  @preview = scenario.preview
10
10
 
11
- puts " Scenario: #{scenario_name}"
11
+ LookbookVisualTester.config.logger.info " Scenario: #{scenario_name}"
12
12
  end
13
13
 
14
14
  def preview_name
15
- preview.name.underscore
15
+ preview.name.underscore.gsub('/', '_')
16
16
  end
17
17
 
18
18
  def scenario_name
@@ -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,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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LookbookVisualTester
4
- VERSION = '0.1.6'
4
+ VERSION = '0.3.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