lookbook_visual_tester 0.5.10 → 0.7.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.
@@ -3,6 +3,7 @@ require 'json'
3
3
  require_relative 'configuration'
4
4
  require_relative 'scenario_run'
5
5
  require_relative 'services/image_comparator'
6
+ require_relative 'services/image_trimmer'
6
7
  require_relative 'drivers/ferrum_driver'
7
8
  require_relative 'variant_resolver'
8
9
 
@@ -11,10 +12,14 @@ module LookbookVisualTester
11
12
  Result = Struct.new(:scenario_name, :status, :mismatch, :diff_path, :error, :baseline_path,
12
13
  :current_path, keyword_init: true)
13
14
 
14
- def initialize(config = LookbookVisualTester.config, pattern: nil, force_update: false)
15
+ DEFAULT_DRIVER_WIDTH = 1280
16
+ DEFAULT_DRIVER_HEIGHT = 800
17
+
18
+ def initialize(config = LookbookVisualTester.config, pattern: nil, force_update: false, output: $stdout)
15
19
  @config = config
16
20
  @pattern = pattern
17
21
  @force_update = force_update
22
+ @output = output
18
23
  @driver_pool = Queue.new
19
24
  init_driver_pool
20
25
  @results = []
@@ -31,8 +36,8 @@ module LookbookVisualTester
31
36
  end
32
37
  end
33
38
 
34
- puts "Found #{previews.count} previews matching '#{@pattern}'."
35
- puts "Running against #{@variants.size} variant(s)."
39
+ @output.puts "Found #{previews.count} previews matching '#{@pattern}'."
40
+ @output.puts "Running against #{@variants.size} variant(s)."
36
41
 
37
42
  @variants.each do |variant_input|
38
43
  resolver = VariantResolver.new(variant_input)
@@ -40,7 +45,7 @@ module LookbookVisualTester
40
45
  variant_slug = resolver.slug
41
46
  width = resolver.width_in_pixels
42
47
 
43
- puts " Variant: #{variant_slug.presence || 'Default'}"
48
+ @output.puts " Variant: #{variant_slug.presence || 'Default'}"
44
49
 
45
50
  if @config.threads > 1
46
51
  run_concurrently(previews, variant_slug, variant_options, width)
@@ -63,15 +68,18 @@ module LookbookVisualTester
63
68
  begin
64
69
  JSON.parse(variants_json)
65
70
  rescue JSON::ParserError
66
- puts 'Invalid JSON in VARIANTS env var. Defaulting to standard run.'
71
+ @output.puts 'Invalid JSON in VARIANTS env var. Defaulting to standard run.'
67
72
  [{}]
68
73
  end
69
74
  end
70
75
 
76
+ def scenarios_for(preview)
77
+ preview.scenarios
78
+ end
79
+
71
80
  def run_sequentially(previews, variant_slug, variant_options, width)
72
81
  previews.each do |preview|
73
- group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
74
- group.each do |scenario|
82
+ scenarios_for(preview).each do |scenario|
75
83
  driver = checkout_driver
76
84
  begin
77
85
  @results << run_scenario(scenario, driver, variant_slug, variant_options, width)
@@ -88,8 +96,7 @@ module LookbookVisualTester
88
96
  promises = []
89
97
 
90
98
  previews.each do |preview|
91
- group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
92
- group.each do |scenario|
99
+ scenarios_for(preview).each do |scenario|
93
100
  promises << Concurrent::Promises.future_on(pool) do
94
101
  driver = checkout_driver
95
102
  begin
@@ -101,8 +108,6 @@ module LookbookVisualTester
101
108
  end
102
109
  end
103
110
 
104
- # Zip results from this concurrent batch into results
105
- # Note: This aggregates results per variant loop.
106
111
  @results.concat(Concurrent::Promises.zip(*promises).value)
107
112
  pool.shutdown
108
113
  pool.wait_for_termination
@@ -110,9 +115,7 @@ module LookbookVisualTester
110
115
 
111
116
  def init_driver_pool
112
117
  count = @config.threads > 1 ? @config.threads : 1
113
- count.times do
114
- @driver_pool << LookbookVisualTester::Drivers::FerrumDriver.new(@config)
115
- end
118
+ count.times { @driver_pool << Drivers::FerrumDriver.new(@config) }
116
119
  end
117
120
 
118
121
  def checkout_driver
@@ -133,98 +136,111 @@ module LookbookVisualTester
133
136
  def run_scenario(scenario, driver, variant_slug, variant_options, width)
134
137
  run_data = ScenarioRun.new(scenario, variant_slug: variant_slug,
135
138
  display_params: variant_options)
136
- puts "Running visual test for: #{run_data.name} #{variant_slug.present? ? "[#{variant_slug}]" : ''}"
139
+ @output.puts "Running visual test for: #{run_data.name} #{variant_slug.present? ? "[#{variant_slug}]" : ''}"
137
140
 
141
+ paths = prepare_paths(run_data, variant_slug)
142
+ Thread.current[:lookbook_visual_tester_driver] = driver
138
143
  begin
139
- driver_width = width || 1280
140
- driver.resize_window(driver_width, 800)
141
- driver.visit(run_data.preview_url)
144
+ capture(driver, run_data, paths, width)
145
+ compare_against_baseline(run_data, paths)
146
+ rescue StandardError => e
147
+ record_failure(run_data, paths, e)
148
+ ensure
149
+ Thread.current[:lookbook_visual_tester_driver] = nil
150
+ end
151
+ end
142
152
 
143
- # Determine paths
144
- current_path = run_data.current_path
145
- baseline_path = run_data.baseline_path
146
- folder_name = variant_slug.presence || 'default'
147
- diff_path = @config.diff_dir.join(folder_name, run_data.diff_filename)
153
+ def prepare_paths(run_data, variant_slug)
154
+ folder_name = variant_slug.presence || 'default'
155
+ {
156
+ current: run_data.current_path,
157
+ baseline: run_data.baseline_path,
158
+ diff: @config.diff_dir.join(folder_name, run_data.diff_filename)
159
+ }
160
+ end
148
161
 
149
- FileUtils.mkdir_p(File.dirname(current_path))
150
- FileUtils.mkdir_p(File.dirname(diff_path))
162
+ def capture(driver, run_data, paths, width)
163
+ driver.resize_window(width || DEFAULT_DRIVER_WIDTH, DEFAULT_DRIVER_HEIGHT)
164
+ driver.visit(run_data.preview_url)
151
165
 
152
- driver.save_screenshot(current_path.to_s)
166
+ FileUtils.mkdir_p(File.dirname(paths[:current]))
167
+ FileUtils.mkdir_p(File.dirname(paths[:diff]))
153
168
 
154
- # Trimming (Feature parity with legacy ScreenshotTaker)
155
- if File.exist?(current_path)
156
- system("convert #{current_path} -trim -bordercolor white -border 10x10 #{current_path}")
157
- end
169
+ driver.save_screenshot(paths[:current].to_s)
170
+ ImageTrimmer.call(paths[:current].to_s) if File.exist?(paths[:current].to_s)
171
+ end
158
172
 
159
- comparator = LookbookVisualTester::ImageComparator.new(
160
- baseline_path.to_s,
161
- current_path.to_s,
162
- diff_path.to_s
163
- )
173
+ def compare_against_baseline(run_data, paths)
174
+ comparator = ImageComparator.new(paths[:baseline].to_s, paths[:current].to_s, paths[:diff].to_s)
175
+ result = comparator.call
176
+ @results << build_result(run_data, paths, result)
177
+ end
164
178
 
165
- result = comparator.call
179
+ def build_result(run_data, paths, result)
180
+ if result[:error]
181
+ if result[:error] == 'Baseline not found' || @force_update
182
+ handle_missing_baseline(run_data, paths)
183
+ else
184
+ @output.puts " [ERROR] #{result[:error]}"
185
+ Result.new(scenario_name: run_data.name, status: :error, error: result[:error],
186
+ baseline_path: paths[:baseline].to_s, current_path: paths[:current].to_s)
187
+ end
188
+ elsif result[:mismatch] > 0
189
+ record_mismatch(run_data, paths, result)
190
+ else
191
+ @output.puts ' [PASS] Identical.'
192
+ Result.new(scenario_name: run_data.name, status: :passed, mismatch: 0.0,
193
+ diff_path: paths[:diff].to_s, baseline_path: paths[:baseline].to_s,
194
+ current_path: paths[:current].to_s)
195
+ end
196
+ end
166
197
 
198
+ def handle_missing_baseline(run_data, paths)
199
+ if @force_update
200
+ @output.puts ' [UPDATE] Baseline forced update.'
167
201
  status = :passed
168
- mismatch = 0.0
169
- error = nil
170
-
171
- if result[:error]
172
- if result[:error] == 'Baseline not found' || @force_update
173
- # First run, maybe auto-approve or just report
174
- if @force_update
175
- puts ' [UPDATE] Baseline forced update.'
176
- status = :passed # Or :updated? Let's use passed for now so it doesn't fail the build
177
- else
178
- puts ' [NEW] Baseline not found. Saved current as potential baseline.'
179
- status = :new
180
- end
181
- FileUtils.mkdir_p(File.dirname(baseline_path))
182
- FileUtils.cp(current_path, baseline_path)
183
- else
184
- puts " [ERROR] #{result[:error]}"
185
- status = :error
186
- error = result[:error]
187
- end
188
- elsif result[:mismatch] > 0
189
- mismatch = result[:mismatch]
190
- puts " [FAIL] Mismatch: #{mismatch.round(2)}%. Diff saved to #{diff_path}"
202
+ else
203
+ @output.puts ' [NEW] Baseline not found. Saved current as potential baseline.'
204
+ status = :new
205
+ end
206
+ FileUtils.mkdir_p(File.dirname(paths[:baseline]))
207
+ FileUtils.cp(paths[:current], paths[:baseline])
208
+ Result.new(scenario_name: run_data.name, status: status, mismatch: 0.0,
209
+ diff_path: nil, baseline_path: paths[:baseline].to_s,
210
+ current_path: paths[:current].to_s)
211
+ end
191
212
 
192
- # Save DOM snapshot for debugging
193
- dom_path = diff_path.sub('.png', '.html')
194
- File.write(dom_path, driver.page_source)
195
- puts " DOM Snapshot saved to #{dom_path}"
213
+ def record_mismatch(run_data, paths, result)
214
+ mismatch = result[:mismatch]
215
+ @output.puts " [FAIL] Mismatch: #{mismatch.round(2)}%. Diff saved to #{paths[:diff]}"
196
216
 
197
- status = :failed
217
+ dom_path = paths[:diff].sub('.png', '.html')
218
+ File.write(dom_path, driver.page_source)
219
+ @output.puts " DOM Snapshot saved to #{dom_path}"
198
220
 
199
- # Clipboard (Feature parity with legacy ScreenshotTaker)
200
- if @config.copy_to_clipboard
201
- system("xclip -selection clipboard -t image/png -i #{current_path}")
202
- end
203
- else
204
- puts ' [PASS] Identical.'
205
- status = :passed
206
- end
221
+ copy_to_clipboard_if_enabled(paths[:current])
207
222
 
208
- Result.new(
209
- scenario_name: run_data.name,
210
- status: status,
211
- mismatch: mismatch,
212
- diff_path: diff_path.to_s,
213
- error: error,
214
- baseline_path: baseline_path.to_s,
215
- current_path: current_path.to_s
216
- )
217
- rescue StandardError => e
218
- puts " [ERROR] Exception: #{e.message}"
219
- puts e.backtrace.take(5)
220
- Result.new(
221
- scenario_name: run_data.name,
222
- status: :error,
223
- error: e.message,
224
- baseline_path: run_data.baseline_path.to_s,
225
- current_path: run_data.current_path.to_s
226
- )
227
- end
223
+ Result.new(scenario_name: run_data.name, status: :failed, mismatch: mismatch,
224
+ diff_path: paths[:diff].to_s, baseline_path: paths[:baseline].to_s,
225
+ current_path: paths[:current].to_s)
226
+ end
227
+
228
+ def copy_to_clipboard_if_enabled(current_path)
229
+ return unless @config.copy_to_clipboard
230
+
231
+ system("xclip -selection clipboard -t image/png -i #{current_path}")
232
+ end
233
+
234
+ def record_failure(run_data, paths, error)
235
+ @output.puts " [ERROR] Exception: #{error.message}"
236
+ @output.puts error.backtrace.take(5)
237
+ Result.new(scenario_name: run_data.name, status: :error, error: error.message,
238
+ baseline_path: paths[:baseline].to_s, current_path: paths[:current].to_s)
239
+ end
240
+
241
+ # Resolves the driver for the current scenario thread, set in run_scenario.
242
+ def driver
243
+ Thread.current[:lookbook_visual_tester_driver]
228
244
  end
229
245
  end
230
246
  end
@@ -0,0 +1,102 @@
1
+ # lib/lookbook_visual_tester/server_test_runner.rb
2
+ require 'net/http'
3
+ require 'uri'
4
+ require 'timeout'
5
+ require 'tempfile'
6
+
7
+ module LookbookVisualTester
8
+ class ServerTestRunner
9
+ DEFAULT_TEST_TASK = 'lookbook:test'
10
+ DEFAULT_TIMEOUT = 60
11
+ POLL_INTERVAL = 0.25
12
+
13
+ def self.call(config: LookbookVisualTester.config, timeout: DEFAULT_TIMEOUT, test_task: DEFAULT_TEST_TASK, log_path: nil)
14
+ new(config: config, timeout: timeout, test_task: test_task, log_path: log_path).call
15
+ end
16
+
17
+ def initialize(config: LookbookVisualTester.config, timeout: DEFAULT_TIMEOUT, test_task: DEFAULT_TEST_TASK, log_path: nil)
18
+ @config = config
19
+ @timeout = timeout
20
+ @test_task = test_task
21
+ @log_path = log_path
22
+ @pid = nil
23
+ @log_file = nil
24
+ end
25
+
26
+ def call
27
+ validate_host!
28
+ @pid = spawn_server
29
+
30
+ begin
31
+ self.class.wait_for_server(@config.lookbook_host, timeout: @timeout)
32
+ puts "[LookbookVisualTester] Server ready at #{@config.lookbook_host}. Running #{@test_task}..."
33
+
34
+ task = Rake::Task[@test_task]
35
+ task.reenable
36
+ task.invoke
37
+ ensure
38
+ stop_server
39
+ end
40
+ end
41
+
42
+ def self.wait_for_server(host, timeout: DEFAULT_TIMEOUT)
43
+ uri = URI.parse(host)
44
+ raise Error, "Invalid lookbook_host: #{host}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
45
+
46
+ Timeout.timeout(timeout) do
47
+ loop do
48
+ begin
49
+ response = Net::HTTP.get_response(uri)
50
+ return true if response.code.to_i < 500
51
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Net::OpenTimeout, Net::ReadTimeout
52
+ # server not ready yet
53
+ end
54
+ sleep POLL_INTERVAL
55
+ end
56
+ end
57
+ rescue Timeout::Error
58
+ raise Error, "Lookbook server at #{host} did not start within #{timeout}s"
59
+ end
60
+
61
+ private
62
+
63
+ def validate_host!
64
+ host = @config.lookbook_host
65
+ raise Error, 'lookbook_host must be configured' if host.nil? || host.empty?
66
+
67
+ uri = URI.parse(host)
68
+ raise Error, "Invalid lookbook_host: #{host}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
69
+ raise Error, "lookbook_host must include a port: #{host}" unless uri.port
70
+ end
71
+
72
+ def spawn_server
73
+ port = URI.parse(@config.lookbook_host).port
74
+ env = {
75
+ 'PORT' => port.to_s,
76
+ 'RAILS_ENV' => ENV.fetch('RAILS_ENV', 'development')
77
+ }
78
+ command = ['bundle', 'exec', 'rails', 'server', '-p', port.to_s]
79
+
80
+ @log_file = @log_path ? File.open(@log_path, 'w') : Tempfile.create('lookbook_server')
81
+ Process.spawn(env, *command, out: @log_file, err: @log_file, pgroup: true)
82
+ end
83
+
84
+ def stop_server
85
+ return unless @pid
86
+
87
+ begin
88
+ Process.kill('-TERM', @pid) # negative pid signals the whole process group
89
+ rescue Errno::ESRCH
90
+ # already gone
91
+ end
92
+
93
+ begin
94
+ Process.waitpid(@pid)
95
+ rescue Errno::ECHILD
96
+ # already reaped
97
+ end
98
+ ensure
99
+ @log_file&.close
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,59 @@
1
+ # lib/lookbook_visual_tester/services/image_trimmer.rb
2
+ require 'chunky_png'
3
+
4
+ module LookbookVisualTester
5
+ module ImageTrimmer
6
+ DEFAULT_PADDING = 10
7
+
8
+ # Pixels matching any of these colors are considered "empty" border and trimmed.
9
+ BORDER_COLORS = [
10
+ ChunkyPNG::Color::WHITE,
11
+ ChunkyPNG::Color::TRANSPARENT
12
+ ].freeze
13
+
14
+ def self.call(path, padding: DEFAULT_PADDING)
15
+ image = ChunkyPNG::Image.from_file(path)
16
+
17
+ min_x = image.width
18
+ max_x = -1
19
+ min_y = image.height
20
+ max_y = -1
21
+
22
+ image.height.times do |y|
23
+ image.width.times do |x|
24
+ next if border_pixel?(image[x, y])
25
+
26
+ min_x = x if x < min_x
27
+ max_x = x if x > max_x
28
+ min_y = y if y < min_y
29
+ max_y = y if y > max_y
30
+ end
31
+ end
32
+
33
+ # No content found: keep the original image.
34
+ return path if max_x < min_x
35
+
36
+ content_width = max_x - min_x + 1
37
+ content_height = max_y - min_y + 1
38
+ new_width = content_width + (padding * 2)
39
+ new_height = content_height + (padding * 2)
40
+
41
+ trimmed = ChunkyPNG::Image.new(new_width, new_height, ChunkyPNG::Color::TRANSPARENT)
42
+
43
+ image.height.times do |y|
44
+ image.width.times do |x|
45
+ next if x < min_x || x > max_x || y < min_y || y > max_y
46
+
47
+ trimmed[x - min_x + padding, y - min_y + padding] = image[x, y]
48
+ end
49
+ end
50
+
51
+ trimmed.save(path)
52
+ path
53
+ end
54
+
55
+ def self.border_pixel?(color)
56
+ BORDER_COLORS.include?(color)
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,4 @@
1
1
  require_relative 'service'
2
- require_relative 'screenshot_taker'
3
2
 
4
3
  module LookbookVisualTester
5
4
  class UpdatePreviews < Service
@@ -12,7 +11,7 @@ module LookbookVisualTester
12
11
 
13
12
  def update_app_data
14
13
  LookbookVisualTester.data[:last_changed_files] = changes.presence || []
15
- LookbookVisualTester.data[:last_changed_previews] = previews
14
+ LookbookVisualTester.data[:last_changed_previews] = selected_previews
16
15
  end
17
16
 
18
17
  def call
@@ -47,8 +46,6 @@ module LookbookVisualTester
47
46
 
48
47
  def clean_file_name(file)
49
48
  file = file.split(components_folder)[-1]
50
- # '/' + file.split('/')[-1].split('.')[0]
51
- puts ">>>> #{file}"
52
49
  file.split('.')[0].gsub('_preview', '')
53
50
  end
54
51
 
@@ -61,14 +58,8 @@ module LookbookVisualTester
61
58
  def process_changes
62
59
  Rails.logger.info "LookbookVisualTester: previews #{selected_previews.count}"
63
60
  selected_previews.each do |preview|
64
- Rails.logger.info "LookbookVisualTester: entering #{preview.inspect}"
65
-
66
- group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
67
- group.each do |scenario|
68
- scenario_run = LookbookVisualTester::ScenarioRun.new(scenario)
69
- Rails.logger.info "LookbookVisualTester: Processing scenario #{scenario_run.inspect}"
70
- LookbookVisualTester::ScreenshotTaker.call(scenario_run: scenario_run)
71
- end
61
+ Rails.logger.info "LookbookVisualTester: running Runner for #{preview.inspect}"
62
+ LookbookVisualTester::Runner.new(pattern: preview.name).run
72
63
  end
73
64
  end
74
65
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LookbookVisualTester
4
- VERSION = '0.5.10'
4
+ VERSION = '0.7.0'
5
5
  end
@@ -3,6 +3,7 @@
3
3
  # lib/lookbook_visual_tester.rb
4
4
 
5
5
  require_relative 'lookbook_visual_tester/version'
6
+ require_relative 'lookbook_visual_tester/browser_discovery'
6
7
  require_relative 'lookbook_visual_tester/configuration'
7
8
  require_relative 'lookbook_visual_tester/railtie' if defined?(Rails)
8
9
  require_relative 'lookbook_visual_tester/scenario_finder'
@@ -11,6 +12,9 @@ require_relative 'lookbook_visual_tester/runner'
11
12
  require_relative 'lookbook_visual_tester/driver'
12
13
  require_relative 'lookbook_visual_tester/drivers/ferrum_driver'
13
14
  require_relative 'lookbook_visual_tester/services/image_comparator'
15
+ require_relative 'lookbook_visual_tester/services/image_trimmer'
16
+ require_relative 'lookbook_visual_tester/server_test_runner'
17
+
14
18
 
15
19
  module LookbookVisualTester
16
20
  class Error < StandardError; end