lookbook_visual_tester 0.3.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d40b1ba348d95a09c7ea9997d965e00026e9873cf5a8e2faebeed31d6898b6e9
4
- data.tar.gz: 725d2b67ae419319e79ac56c52cb74f1d3f5354099fa30e670cf292c2e25b07d
3
+ metadata.gz: 60819c43209d30cab93b22e923f369c0fdb85ae2f0cbc5780a9670ca26571677
4
+ data.tar.gz: 231ec3a33592037451d4f8a9354c702971314405b4ab13a6332c911baa915681
5
5
  SHA512:
6
- metadata.gz: 1c8fac94fb8e61906506b8dd48f87d9b6bb9cb01ec238a2b805a4f98ceadde02cf8555b45c80f1daef4b1710392b0fcd05a36f7420fe279440cd2491d3f63ad2
7
- data.tar.gz: 3025ca8c334a4185dcbcdc8d2c03288fb46bb07c582d8a2c363cc69929a6be2044246c1d7bc313f06787970e49541872bee57e18cf6b800f8a1afb41b5375aec
6
+ metadata.gz: 782a83e7388499ededdc31453a708969a839b060e09b24de481f55c48f193397d30c873a8137bbec40e4da0f30688cb4485f66d3e93b07ecb19f9d7dc0517bb4
7
+ data.tar.gz: 699056dca68df2a0961547c639da77b62bda599dd5a44013e4b7717d6dd8afeb02195b34c447bb66a72fb8ef8a0b35168cce7bc12cffaa4570a4b9629b8dbc21
data/CHANGELOG.md CHANGED
@@ -1,4 +1,25 @@
1
1
  # Changelog
2
+ ## [0.5.0] - 2026-01-03
3
+
4
+ ### ✨ New Features & Improvements
5
+ - **Preview Health Checks**: Added new Rake tasks to verify preview integrity:
6
+ - `lookbook:check`: Rapidly checks if previews load and instantiate without errors.
7
+ - `lookbook:deep_check`: Verified previews by effectively rendering them to catch runtime and template errors.
8
+ - `lookbook:missing`: Identifies ViewComponents that lack a corresponding preview.
9
+ - **Parallel Preview Checks**: Health checks run in parallel using `concurrent-ruby`.
10
+ - **Comprehensive Reporting**: Checks generate colored terminal output and a detailed HTML report (`coverage/preview_check_report.html`) including timing stats and slowest previews.
11
+ - **Custom Deep Check Setup**: Added `config.preview_checker_setup` to allow defining mocks (e.g., User, Warden) required for deep checking.
12
+
13
+ ## [0.4.0] - 2026-01-03
14
+
15
+ ### ✨ New Features & Improvements
16
+ - **Multiple Screenshot Variants**: Support for defining screenshot variants (e.g., specific viewports, themes) using Lookbook's `preview_display_options`.
17
+ - Configurable via `VARIANTS` environment variable (JSON array).
18
+ - Screenshots are saved in subdirectories corresponding to the variant options.
19
+ - Automatic browser resizing based on `width` options.
20
+
21
+ ### 🧹 Housekeeping
22
+ - **Removed Minitest**: Switched completely to RSpec for internal testing. Deleted unused Minitest files and configuration.
2
23
 
3
24
  ## [0.3.0] - 2026-01-03
4
25
 
data/README.md CHANGED
@@ -75,6 +75,35 @@ You can override the host or other settings inline:
75
75
  LOOKBOOK_HOST=http://localhost:5000 LOOKBOOK_THREADS=8 bundle exec rake lookbook:test
76
76
  ```
77
77
 
78
+ #### Screenshot Variants
79
+
80
+ You can run your visual tests against multiple configurations (variants), such as different themes or viewports, by leveraging Lookbook's `preview_display_options`.
81
+
82
+ 1. **Define Options in Lookbook**:
83
+ Ensure your Rails app has display options configured:
84
+ ```ruby
85
+ # config/lookbook.rb
86
+ Lookbook.config.preview_display_options = {
87
+ theme: ["light", "dark"],
88
+ width: [["Mobile", "375px"], ["Desktop", "1280px"]]
89
+ }
90
+ ```
91
+
92
+ 2. **Run with Variants**:
93
+ Use the `VARIANTS` environment variable to define a JSON array of option sets to test.
94
+
95
+ *Example: Run standard tests + Dark Mode + Mobile View*
96
+ ```bash
97
+ VARIANTS='[{}, {"theme":"dark"}, {"width":"Mobile"}]' bundle exec rake lookbook:test
98
+ ```
99
+
100
+ * **`{}`**: Runs the default/standard preview.
101
+ * **`{"theme":"dark"}`**: Runs with `_display[theme]=dark`.
102
+ * **`{"width":"Mobile"}`**: Runs with `_display[width]=375px` AND automatically resizes the browser window to 375px width.
103
+
104
+ Screenshots for variants are saved in dedicated subfolders (e.g., `spec/visual_regression/baseline/theme-dark/`).
105
+
106
+
78
107
  ### Baseline Management
79
108
 
80
109
  1. **First Run**: When you run the tests for the first time, all screenshots are saved as **Baselines**.
@@ -113,12 +142,31 @@ bundle exec rspec
113
142
  bundle exec rspec spec/integration/full_flow_spec.rb
114
143
  ```
115
144
 
145
+ ### Preview Health Checks
146
+
147
+ The gem provides tasks to ensure your previews are healthy and up-to-date.
148
+
149
+ #### Check Load/Syntax
150
+ Checks if all previews can be loaded and instantiated without errors.
151
+ ```bash
152
+ bundle exec rake lookbook:check
153
+ ```
154
+
155
+ #### Deep Check (Render)
156
+ effectively renders all previews to catch runtime and template errors.
157
+ ```bash
158
+ bundle exec rake lookbook:deep_check
159
+ ```
160
+
161
+ #### Find Missing Previews
162
+ Identifies components that don't have a corresponding preview file.
163
+ ```bash
164
+ bundle exec rake lookbook:missing
165
+ ```
166
+
116
167
  ## Next Steps
117
168
 
118
- - **Multi-Viewport Support**: Add ability to capture screenshots at different screen widths (Mobile, Tablet, Desktop).
119
169
  - **CI/CD Integration**: Provide recipes for GitHub Actions to run visual regression on PRs.
120
- - **Reporting Dashboard**: Generate a static HTML report to easily browse all diffs in a single view.
121
- - [x] **Concurrent Captures**: Optimize execution speed by parallelizing screenshot taking across multiple browser instances.
122
170
 
123
171
  ## Contributing
124
172
 
data/Rakefile CHANGED
@@ -1,15 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/gem_tasks'
4
- require 'minitest/test_task'
5
-
6
- Minitest::TestTask.create
7
-
4
+ require 'rspec/core/rake_task'
8
5
  require 'rubocop/rake_task'
9
6
 
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
10
9
  RuboCop::RakeTask.new
11
10
 
12
- task default: %i[test rubocop]
11
+ task default: %i[spec rubocop]
13
12
 
14
13
  namespace :release do
15
14
  desc 'Release with OTP (MFA) support'
@@ -0,0 +1,81 @@
1
+ require 'rainbow'
2
+ require 'thor/group'
3
+
4
+ module LookbookVisualTester
5
+ class CheckReporter < Thor::Group
6
+ include Thor::Actions
7
+
8
+ argument :results
9
+
10
+ def self.source_root
11
+ File.dirname(__FILE__)
12
+ end
13
+
14
+ def report
15
+ @errors = results.select { |r| r.status == :failed }
16
+ @success_count = results.count { |r| r.status == :passed }
17
+ @total_duration = results.sum { |r| r.duration.to_f }
18
+
19
+ report_terminal
20
+ report_html
21
+ end
22
+
23
+ def self.report_missing(missing)
24
+ if missing.any?
25
+ puts Rainbow("\nFound #{missing.size} components missing previews:").yellow
26
+ missing.sort_by { |m| m.component_path }.each do |m|
27
+ puts " - #{m.component_path}"
28
+ end
29
+ puts "\nTotal: #{missing.size} missing previews."
30
+ else
31
+ puts Rainbow('All components have previews!').green
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :errors, :success_count, :total_duration
38
+
39
+ def report_terminal
40
+ puts "\n--- Check Results ---"
41
+
42
+ results.each do |result|
43
+ if result.status == :passed
44
+ print Rainbow('.').green
45
+ else
46
+ print Rainbow('F').red
47
+ end
48
+ end
49
+ puts "\n"
50
+
51
+ if errors.any?
52
+ puts Rainbow("\n#{errors.size} errors found:").red
53
+ errors.each do |err|
54
+ puts "\n--------------------------------------------------"
55
+ puts "Preview: #{err.preview_name}##{err.example_name}"
56
+ puts "Error: #{err.error}"
57
+ puts "Time: #{err.duration.to_f.round(4)}s"
58
+ puts "Backtrace: #{err.backtrace&.first(3)&.join("\n ")}"
59
+ end
60
+ else
61
+ puts Rainbow("\nAll #{results.size} previews passed!").green
62
+ end
63
+
64
+ puts "\nTotal time: #{total_duration.round(2)}s"
65
+
66
+ report_slowest_previews
67
+ end
68
+
69
+ def report_slowest_previews
70
+ puts "\n--- Top 5 Slowest Previews ---"
71
+ slowest = results.sort_by { |r| -r.duration.to_f }.first(5)
72
+ slowest.each do |res|
73
+ puts "#{Rainbow("#{res.duration.to_f.round(4)}s").yellow} - #{res.preview_name}##{res.example_name}"
74
+ end
75
+ end
76
+
77
+ def report_html
78
+ template 'templates/preview_check_report.html.tt', 'coverage/preview_check_report.html'
79
+ end
80
+ end
81
+ end
@@ -7,6 +7,7 @@ module LookbookVisualTester
7
7
  :components_folder,
8
8
  :automatic_run,
9
9
  :mask_selectors, :driver_adapter,
10
+ :preview_checker_setup,
10
11
  :logger
11
12
 
12
13
  DEFAULT_THREADS = 4
@@ -31,6 +32,7 @@ module LookbookVisualTester
31
32
  @automatic_run = ENV.fetch('LOOKBOOK_AUTOMATIC_RUN', false)
32
33
  @mask_selectors = []
33
34
  @driver_adapter = :ferrum
35
+ @preview_checker_setup = nil
34
36
  @logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
35
37
  Rails.logger
36
38
  else
@@ -0,0 +1,260 @@
1
+ require 'concurrent'
2
+ require 'benchmark'
3
+
4
+ module LookbookVisualTester
5
+ class PreviewChecker
6
+ CheckResult = Struct.new(:preview_name, :example_name, :status, :error, :backtrace, :duration,
7
+ keyword_init: true)
8
+ MissingResult = Struct.new(:component_path, keyword_init: true)
9
+
10
+ def initialize(config = LookbookVisualTester.config)
11
+ @config = config
12
+ end
13
+
14
+ def check
15
+ run_checks(:basic_check)
16
+ end
17
+
18
+ def deep_check
19
+ # Ensure custom setup is run before deep checks
20
+ run_setup
21
+ run_checks(:deep_render_check)
22
+ end
23
+
24
+ def missing
25
+ components_dir = Rails.root.join(@config.components_folder)
26
+ # Assuming standard structure: test/components/previews for previews
27
+ # But Lookbook can be configured differently. We should use Lookbook's config if possible to know where previews are.
28
+ # For now, let's stick to the user's script logic which assumes standard paths or iterate through loaded components.
29
+
30
+ # Better approach: Iterate through all known components and check if they have a preview.
31
+ # However, "all known components" might be hard to get if they aren't loaded.
32
+ # Let's use the file system approach as in the user script.
33
+
34
+ components = Dir.glob(File.join(components_dir, '**', '*_component.rb'))
35
+ previews_dir = Rails.root.join('test/components/previews') # Default, maybe make configurable?
36
+
37
+ # Trying to find where previews are located from Rails config if possible
38
+ if defined?(Rails) && Rails.application.config.view_component.preview_paths.any?
39
+ # Use simple heuristic: first path
40
+ previews_dir = Pathname.new(Rails.application.config.view_component.preview_paths.first)
41
+ end
42
+
43
+ missing = []
44
+ components.each do |component_path|
45
+ next if component_path.end_with?('application_component.rb')
46
+ next if component_path.include?('/concerns/')
47
+
48
+ relative_path = Pathname.new(component_path).relative_path_from(components_dir).to_s
49
+ preview_relative_path = relative_path.sub('_component.rb', '_component_preview.rb')
50
+ preview_path = File.join(previews_dir, preview_relative_path)
51
+
52
+ missing << MissingResult.new(component_path: relative_path) unless File.exist?(preview_path)
53
+ end
54
+ missing
55
+ end
56
+
57
+ private
58
+
59
+ def run_checks(check_method)
60
+ previews = Lookbook.previews
61
+ results = []
62
+
63
+ # We want to flatten the work items: (PreviewClass, example_name)
64
+ work_items = []
65
+ previews.each do |preview|
66
+ # preview is a Lookbook::Preview object which wraps the class
67
+ # But for checking we might want the class directly or iterate examples
68
+ examples = preview.examples
69
+ examples.each do |example|
70
+ work_items << { preview: preview, example: example }
71
+ end
72
+ end
73
+
74
+ if @config.threads > 1
75
+ pool = Concurrent::FixedThreadPool.new(@config.threads)
76
+ promises = work_items.map do |item|
77
+ Concurrent::Promises.future_on(pool) do
78
+ measure_and_send(item[:preview], item[:example], check_method)
79
+ end
80
+ end
81
+ results = Concurrent::Promises.zip(*promises).value
82
+ pool.shutdown
83
+ pool.wait_for_termination
84
+ else
85
+ results = work_items.map do |item|
86
+ measure_and_send(item[:preview], item[:example], check_method)
87
+ end
88
+ end
89
+
90
+ results
91
+ end
92
+
93
+ def measure_and_send(preview, example, method_name)
94
+ result = nil
95
+ time = Benchmark.realtime do
96
+ result = send(method_name, preview, example)
97
+ end
98
+ result.duration = time
99
+ result
100
+ end
101
+
102
+ def basic_check(preview, example)
103
+ # user script logic:
104
+ # preview_instance = preview_class.new
105
+ # component = preview_instance.public_send(preview_example)
106
+ # if component.respond_to?(:render_in) ...
107
+
108
+ # Lookbook::Preview wrapper might help, but let's go to the class
109
+ preview_class = preview.preview_class
110
+ example_name = example.name
111
+
112
+ begin
113
+ preview_instance = preview_class.new
114
+ preview_instance.public_send(example_name)
115
+
116
+ # We don't render, just verify we can call it.
117
+ CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed)
118
+ rescue StandardError => e
119
+ CheckResult.new(preview_name: preview.name, example_name: example_name, status: :failed,
120
+ error: e.message, backtrace: e.backtrace)
121
+ end
122
+ end
123
+
124
+ def deep_render_check(preview, example)
125
+ preview_class = preview.preview_class
126
+ example_name = example.name
127
+
128
+ begin
129
+ preview_instance = preview_class.new
130
+ result = preview_instance.public_send(example_name)
131
+
132
+ if result.respond_to?(:render_in)
133
+ # Mock current_user/pundit if needed on the component itself if possible
134
+ # But mainly we render it with a view context
135
+ view_context = setup_view_context
136
+
137
+ # Inject mocks into result if it supports it or reliance on global view_context
138
+ # The user script defines singleton methods on the result.
139
+ if @mocks
140
+ @mocks.each do |key, value|
141
+ if result.respond_to?(key) || !result.respond_to?(key) # Force define
142
+ result.define_singleton_method(key) { value }
143
+ end
144
+ end
145
+ end
146
+
147
+ result.render_in(view_context)
148
+ elsif result.is_a?(String)
149
+ # Rendered string, good.
150
+ end
151
+
152
+ CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed)
153
+ rescue StandardError => e
154
+ CheckResult.new(preview_name: preview.name, example_name: example_name, status: :failed,
155
+ error: e.message, backtrace: e.backtrace)
156
+ end
157
+ end
158
+
159
+ def run_setup
160
+ if @config.preview_checker_setup
161
+ @config.preview_checker_setup.call
162
+ else
163
+ default_setup
164
+ end
165
+ end
166
+
167
+ def default_setup
168
+ # Logic from the user's script
169
+ # We need to set up a controller and view context globally or for use in checks
170
+
171
+ # This part is tricky because we need to make these available to the `deep_render_check` method.
172
+ # We can store the view_context in an instance variable or re-create it.
173
+
174
+ # Let's perform the class-level mocks here (User, etc.)
175
+
176
+ # Mock User
177
+ unless defined?(User)
178
+ # Defining a dummy user if not exists is risky if the app doesn't have User.
179
+ # But the script assumes User exists or creates a mock.
180
+ # Let's create a OpenStruct-like mock for typical Devise/Pundit usage.
181
+ end
182
+
183
+ # The script logic:
184
+ # controller = ApplicationController.new ...
185
+ # We will do this in `setup_view_context` called per check or once?
186
+ # `ApplicationController` might not be thread safe if we modify it?
187
+ # Actually we create a new controller instance.
188
+
189
+ # Define @mocks to be injected
190
+ @mocks = {}
191
+ @mocks[:current_user] = build_mock_user
192
+ @mocks[:pundit_user] = @mocks[:current_user]
193
+ end
194
+
195
+ def build_mock_user
196
+ # Try to load a real user or build a struct
197
+ if defined?(User) && User.respond_to?(:first) && User.first
198
+ User.first
199
+ else
200
+ # Fallback mock
201
+ u = Object.new
202
+ u.define_singleton_method(:email) { 'test@example.com' }
203
+ u.define_singleton_method(:id) { 1 }
204
+ # Add other common methods as needed or let them fail/mock dynamic
205
+ u
206
+ end
207
+ end
208
+
209
+ def setup_view_context
210
+ # We need a controller to get a view context
211
+ controller = if defined?(ApplicationController)
212
+ ApplicationController.new
213
+ else
214
+ ActionController::Base.new
215
+ end
216
+
217
+ controller.request = ActionDispatch::TestRequest.create
218
+ if controller.request.env
219
+ controller.request.env['rack.session'] = {}
220
+ controller.request.env['rack.session.options'] = { id: SecureRandom.uuid }
221
+ end
222
+
223
+ # Devise mapping
224
+ if defined?(Devise)
225
+ controller.request.env['devise.mapping'] = Devise.mappings[:user] if Devise.mappings[:user]
226
+
227
+ # Mock warden
228
+ warden = Object.new
229
+ warden.define_singleton_method(:authenticate!) { |*| true }
230
+ warden.define_singleton_method(:authenticate) { |*| true }
231
+ warden.define_singleton_method(:user) { |*| @mocks[:current_user] }
232
+ controller.request.env['warden'] = warden
233
+ end
234
+
235
+ view_context = controller.view_context
236
+
237
+ # Add helper methods to view_context
238
+ # We can use `class_eval` on the singleton class of the view context
239
+ vc_singleton = view_context.singleton_class
240
+
241
+ if @mocks
242
+ @mocks.each do |key, value|
243
+ vc_singleton.send(:define_method, key) { value }
244
+ end
245
+ end
246
+
247
+ # Common auth helpers
248
+ vc_singleton.send(:define_method, :signed_in?) { true }
249
+
250
+ # Pundit policy mock
251
+ vc_singleton.send(:define_method, :policy) do |_record|
252
+ Struct.new(:show?, :index?, :create?, :update?, :destroy?, :edit?, :new?, :manage?).new(
253
+ true, true, true, true, true, true, true, true
254
+ )
255
+ end
256
+
257
+ view_context
258
+ end
259
+ end
260
+ end
@@ -6,7 +6,8 @@ require 'lookbook_visual_tester/update_previews'
6
6
  module LookbookVisualTester
7
7
  class Railtie < ::Rails::Railtie
8
8
  rake_tasks do
9
- load 'tasks/lookbook_visual_tester.rake'
9
+ path = File.expand_path('../tasks/lookbook_visual_tester.rake', __dir__)
10
+ load path
10
11
  end
11
12
 
12
13
  initializer 'LookbookVisualTester.lookbook_after_change' do |_app|
@@ -1,8 +1,10 @@
1
1
  require 'lookbook'
2
+ require 'json'
2
3
  require_relative 'configuration'
3
4
  require_relative 'scenario_run'
4
5
  require_relative 'services/image_comparator'
5
6
  require_relative 'drivers/ferrum_driver'
7
+ require_relative 'variant_resolver'
6
8
 
7
9
  module LookbookVisualTester
8
10
  class Runner
@@ -15,6 +17,7 @@ module LookbookVisualTester
15
17
  @driver_pool = Queue.new
16
18
  init_driver_pool
17
19
  @results = []
20
+ @variants = load_variants
18
21
  end
19
22
 
20
23
  def run
@@ -28,11 +31,21 @@ module LookbookVisualTester
28
31
  end
29
32
 
30
33
  puts "Found #{previews.count} previews matching '#{@pattern}'."
34
+ puts "Running against #{@variants.size} variant(s)."
31
35
 
32
- if @config.threads > 1
33
- run_concurrently(previews)
34
- else
35
- run_sequentially(previews)
36
+ @variants.each do |variant_input|
37
+ resolver = VariantResolver.new(variant_input)
38
+ variant_options = resolver.resolve
39
+ variant_slug = resolver.slug
40
+ width = resolver.width_in_pixels
41
+
42
+ puts " Variant: #{variant_slug.presence || 'Default'}"
43
+
44
+ if @config.threads > 1
45
+ run_concurrently(previews, variant_slug, variant_options, width)
46
+ else
47
+ run_sequentially(previews, variant_slug, variant_options, width)
48
+ end
36
49
  end
37
50
 
38
51
  @results
@@ -42,13 +55,25 @@ module LookbookVisualTester
42
55
 
43
56
  private
44
57
 
45
- def run_sequentially(previews)
58
+ def load_variants
59
+ variants_json = ENV['VARIANTS'] || ENV.fetch('LOOKBOOK_VARIANTS', nil)
60
+ return [{}] if variants_json.blank?
61
+
62
+ begin
63
+ JSON.parse(variants_json)
64
+ rescue JSON::ParserError
65
+ puts 'Invalid JSON in VARIANTS env var. Defaulting to standard run.'
66
+ [{}]
67
+ end
68
+ end
69
+
70
+ def run_sequentially(previews, variant_slug, variant_options, width)
46
71
  previews.each do |preview|
47
72
  group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
48
73
  group.each do |scenario|
49
74
  driver = checkout_driver
50
75
  begin
51
- @results << run_scenario(scenario, driver)
76
+ @results << run_scenario(scenario, driver, variant_slug, variant_options, width)
52
77
  ensure
53
78
  return_driver(driver)
54
79
  end
@@ -56,7 +81,7 @@ module LookbookVisualTester
56
81
  end
57
82
  end
58
83
 
59
- def run_concurrently(previews)
84
+ def run_concurrently(previews, variant_slug, variant_options, width)
60
85
  require 'concurrent-ruby'
61
86
  pool = Concurrent::FixedThreadPool.new(@config.threads)
62
87
  promises = []
@@ -67,7 +92,7 @@ module LookbookVisualTester
67
92
  promises << Concurrent::Promises.future_on(pool) do
68
93
  driver = checkout_driver
69
94
  begin
70
- run_scenario(scenario, driver)
95
+ run_scenario(scenario, driver, variant_slug, variant_options, width)
71
96
  ensure
72
97
  return_driver(driver)
73
98
  end
@@ -75,19 +100,14 @@ module LookbookVisualTester
75
100
  end
76
101
  end
77
102
 
78
- @results = Concurrent::Promises.zip(*promises).value
103
+ # Zip results from this concurrent batch into results
104
+ # Note: This aggregates results per variant loop.
105
+ @results.concat(Concurrent::Promises.zip(*promises).value)
79
106
  pool.shutdown
80
107
  pool.wait_for_termination
81
108
  end
82
109
 
83
110
  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
111
  count = @config.threads > 1 ? @config.threads : 1
92
112
  count.times do
93
113
  @driver_pool << LookbookVisualTester::Drivers::FerrumDriver.new(@config)
@@ -109,20 +129,32 @@ module LookbookVisualTester
109
129
  end
110
130
  end
111
131
 
112
- def run_scenario(scenario, driver)
113
- run_data = ScenarioRun.new(scenario)
114
- puts "Running visual test for: #{run_data.name}"
132
+ def run_scenario(scenario, driver, variant_slug, variant_options, width)
133
+ run_data = ScenarioRun.new(scenario, variant_slug: variant_slug,
134
+ display_params: variant_options)
135
+ puts "Running visual test for: #{run_data.name} #{variant_slug.present? ? "[#{variant_slug}]" : ''}"
115
136
 
116
137
  begin
117
- driver.resize_window(1280, 800) # Default or config
138
+ driver_width = width || 1280
139
+ driver.resize_window(driver_width, 800)
118
140
  driver.visit(run_data.preview_url)
119
141
 
120
142
  # Determine paths
121
143
  current_path = run_data.current_path
122
144
  baseline_path = run_data.baseline_path
123
145
  diff_path = @config.diff_dir.join(run_data.diff_filename)
146
+ # Update diff path to respect variant structure if needed?
147
+ # Actually ScenarioRun#diff_filename is just flat for now but let's fix that?
148
+ # ScenarioRun doesn't expose diff_path with slug. Let's fix that manually here if needed or update ScenarioRun.
149
+ # Wait, ScenarioRun stores baseline/current in folders but diff_filename is just name.
150
+ # We should probably put diffs in folders too.
151
+ # Let's adjust diff_path here:
152
+ if variant_slug.present?
153
+ diff_path = @config.diff_dir.join(variant_slug, run_data.diff_filename)
154
+ end
124
155
 
125
156
  FileUtils.mkdir_p(File.dirname(current_path))
157
+ FileUtils.mkdir_p(File.dirname(diff_path))
126
158
 
127
159
  driver.save_screenshot(current_path.to_s)
128
160
 
@@ -2,13 +2,15 @@ require 'lookbook_visual_tester/configuration'
2
2
 
3
3
  module LookbookVisualTester
4
4
  class ScenarioRun
5
- attr_reader :scenario, :preview
5
+ attr_reader :scenario, :preview, :variant_slug, :display_params
6
6
 
7
- def initialize(scenario)
7
+ def initialize(scenario, variant_slug: nil, display_params: {})
8
8
  @scenario = scenario
9
9
  @preview = scenario.preview
10
+ @variant_slug = variant_slug
11
+ @display_params = display_params
10
12
 
11
- LookbookVisualTester.config.logger.info " Scenario: #{scenario_name}"
13
+ LookbookVisualTester.config.logger.info " Scenario: #{scenario_name} #{variant_suffix}"
12
14
  end
13
15
 
14
16
  def preview_name
@@ -39,18 +41,34 @@ module LookbookVisualTester
39
41
  end
40
42
 
41
43
  def current_path
42
- LookbookVisualTester.config.current_dir.join(filename)
44
+ base = LookbookVisualTester.config.current_dir
45
+ base = base.join(variant_slug) if variant_slug.present?
46
+ base.join(filename)
43
47
  end
44
48
 
45
49
  def baseline_path
46
- LookbookVisualTester.config.baseline_dir.join(filename)
50
+ base = LookbookVisualTester.config.baseline_dir
51
+ base = base.join(variant_slug) if variant_slug.present?
52
+ base.join(filename)
47
53
  end
48
54
 
49
55
  def preview_url
56
+ params = { path: preview.lookup_path + '/' + scenario.name }
57
+
58
+ if display_params.any?
59
+ # Transform display_params { theme: 'dark' } -> { _display: { theme: 'dark' } }
60
+ params[:_display] = display_params
61
+ end
62
+
50
63
  Lookbook::Engine.routes.url_helpers.lookbook_preview_url(
51
- path: preview.lookup_path + '/' + scenario.name,
52
- host: LookbookVisualTester.config.lookbook_host
64
+ params.merge(host: LookbookVisualTester.config.lookbook_host)
53
65
  )
54
66
  end
67
+
68
+ private
69
+
70
+ def variant_suffix
71
+ variant_slug.present? ? "[#{variant_slug}]" : ''
72
+ end
55
73
  end
56
74
  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,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.3.0'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -3,6 +3,35 @@ require 'lookbook_visual_tester/runner'
3
3
  require 'lookbook_visual_tester/report_generator'
4
4
  require 'lookbook_visual_tester/json_output_handler'
5
5
 
6
+ require 'lookbook_visual_tester/preview_checker'
7
+ require 'lookbook_visual_tester/check_reporter'
8
+
9
+ namespace :lookbook do
10
+ desc 'Check previews for load/syntax errors'
11
+ task check: :environment do
12
+ puts 'Checking previews...'
13
+ checker = LookbookVisualTester::PreviewChecker.new
14
+ results = checker.check
15
+ LookbookVisualTester::CheckReporter.start([results])
16
+ end
17
+
18
+ desc 'Deep check previews by rendering them'
19
+ task deep_check: :environment do
20
+ puts 'Deep checking previews (render)...'
21
+ checker = LookbookVisualTester::PreviewChecker.new
22
+ results = checker.deep_check
23
+ LookbookVisualTester::CheckReporter.start([results])
24
+ end
25
+
26
+ desc 'Find components missing previews'
27
+ task missing: :environment do
28
+ puts 'Finding missing previews...'
29
+ checker = LookbookVisualTester::PreviewChecker.new
30
+ missing = checker.missing
31
+ LookbookVisualTester::CheckReporter.report_missing(missing)
32
+ end
33
+ end
34
+
6
35
  namespace :lookbook do
7
36
  desc 'List all available previews'
8
37
  task :list, [:format] => :environment do |_, args|
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lookbook_visual_tester
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Murilo Vasconcelos
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rainbow
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: bundler
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -155,11 +169,13 @@ files:
155
169
  - lib/lookbook_visual_tester.rb
156
170
  - lib/lookbook_visual_tester/baseline_manager.rb
157
171
  - lib/lookbook_visual_tester/capybara_setup.rb
172
+ - lib/lookbook_visual_tester/check_reporter.rb
158
173
  - lib/lookbook_visual_tester/configuration.rb
159
174
  - lib/lookbook_visual_tester/driver.rb
160
175
  - lib/lookbook_visual_tester/drivers/ferrum_driver.rb
161
176
  - lib/lookbook_visual_tester/image_comparator.rb
162
177
  - lib/lookbook_visual_tester/json_output_handler.rb
178
+ - lib/lookbook_visual_tester/preview_checker.rb
163
179
  - lib/lookbook_visual_tester/railtie.rb
164
180
  - lib/lookbook_visual_tester/report_generator.rb
165
181
  - lib/lookbook_visual_tester/runner.rb
@@ -170,8 +186,10 @@ files:
170
186
  - lib/lookbook_visual_tester/services/image_comparator.rb
171
187
  - lib/lookbook_visual_tester/session_manager.rb
172
188
  - lib/lookbook_visual_tester/store.rb
189
+ - lib/lookbook_visual_tester/templates/preview_check_report.html.tt
173
190
  - lib/lookbook_visual_tester/templates/report.html.erb
174
191
  - lib/lookbook_visual_tester/update_previews.rb
192
+ - lib/lookbook_visual_tester/variant_resolver.rb
175
193
  - lib/lookbook_visual_tester/version.rb
176
194
  - lib/tasks/lookbook_visual_tester.rake
177
195
  - sig/lookbook_visual_tester.rbs