lookbook_visual_tester 0.6.0 → 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.
@@ -0,0 +1,31 @@
1
+ # lib/lookbook_visual_tester/browser_discovery.rb
2
+ require 'rbconfig'
3
+
4
+ module LookbookVisualTester
5
+ # Finds a usable Chrome/Chromium binary, preferring standard absolute paths
6
+ # over PATH-resolved lookups. PATH lookup can resolve to wrapper scripts
7
+ # (e.g. a shell wrapper that injects GPU/Wayland flags) that break headless
8
+ # Chrome with errors like "Multiple targets are not supported in headless mode".
9
+ module BrowserDiscovery
10
+ LINUX_CANDIDATES = %w[
11
+ /usr/bin/google-chrome-stable
12
+ /usr/bin/google-chrome
13
+ /usr/bin/chromium
14
+ /usr/bin/chromium-browser
15
+ /opt/google/chrome/google-chrome
16
+ ].freeze
17
+
18
+ MAC_CANDIDATES = %w[
19
+ /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
20
+ /Applications/Chromium.app/Contents/MacOS/Chromium
21
+ ].freeze
22
+
23
+ def self.find_binary
24
+ candidates = case RbConfig::CONFIG['host_os']
25
+ when /darwin/ then MAC_CANDIDATES
26
+ else LINUX_CANDIDATES
27
+ end
28
+ candidates.find { |path| File.executable?(path) }
29
+ end
30
+ end
31
+ end
@@ -4,7 +4,8 @@ module LookbookVisualTester
4
4
  attr_accessor :lookbook_host, :ui_comparison, :diff_dir, :baseline_dir, :current_dir,
5
5
  :history_dir, :history_keep_last_n, :threads, :copy_to_clipboard,
6
6
  :components_folder, :automatic_run, :mask_selectors, :driver_adapter,
7
- :preview_checker_setup, :logger, :wait_time, :tolerance
7
+ :preview_checker_setup, :logger, :wait_time, :tolerance,
8
+ :browser_path, :browser_options, :browser_timeout, :process_timeout
8
9
 
9
10
  DEFAULT_THREADS = 4
10
11
 
@@ -21,13 +22,12 @@ module LookbookVisualTester
21
22
  @history_dir = @base_path.join('history')
22
23
  @threads = ENV.fetch('LOOKBOOK_THREADS', DEFAULT_THREADS).to_i
23
24
  @history_keep_last_n = 5
24
- @copy_to_clipboard = true
25
+ @copy_to_clipboard = false
25
26
  @components_folder = 'app/components'
26
- @automatic_run = ENV.fetch('LOOKBOOK_AUTOMATIC_RUN', false)
27
+ @automatic_run = ENV.fetch('LOOKBOOK_AUTOMATIC_RUN', 'false') == 'true'
27
28
  @mask_selectors = []
28
29
  @driver_adapter = :ferrum
29
30
  @preview_checker_setup = nil
30
- @preview_checker_setup = nil
31
31
  @wait_time = 0.5
32
32
  @tolerance = 0.0
33
33
  @logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
@@ -38,6 +38,13 @@ module LookbookVisualTester
38
38
  end
39
39
 
40
40
  @lookbook_host = ENV.fetch('LOOKBOOK_HOST', 'http://localhost:5000')
41
+ # Browser (Ferrum/Chrome) configuration. Defaults target modern Chrome's
42
+ # `--headless=new` mode; the old `--headless` flag is rejected by current
43
+ # Chrome builds ("Multiple targets are not supported in headless mode").
44
+ @browser_path = ENV['LOOKBOOK_BROWSER_PATH'] || LookbookVisualTester::BrowserDiscovery.find_binary
45
+ @browser_options = { 'headless' => 'new' }
46
+ @browser_timeout = ENV.fetch('LOOKBOOK_BROWSER_TIMEOUT', '10').to_i
47
+ @process_timeout = ENV.fetch('LOOKBOOK_PROCESS_TIMEOUT', '10').to_i
41
48
  end
42
49
 
43
50
  def base_path=(value)
@@ -6,11 +6,15 @@ module LookbookVisualTester
6
6
  class FerrumDriver < Driver
7
7
  def initialize(config)
8
8
  super
9
- @browser = Ferrum::Browser.new(
9
+ browser_opts = {
10
10
  headless: true,
11
- window_size: [1280, 800], # Default, can be resized
12
- timeout: 10 # Configurable?
13
- )
11
+ window_size: [1280, 800],
12
+ timeout: config.browser_timeout,
13
+ process_timeout: config.process_timeout,
14
+ browser_options: config.browser_options
15
+ }
16
+ browser_opts[:browser_path] = config.browser_path if config.browser_path
17
+ @browser = Ferrum::Browser.new(**browser_opts)
14
18
  end
15
19
 
16
20
  def visit(url)
@@ -16,30 +16,15 @@ module LookbookVisualTester
16
16
  end
17
17
 
18
18
  def deep_check
19
- # Ensure custom setup is run before deep checks
20
19
  run_setup
21
- check_preview_controller_config
22
20
  run_checks(:deep_render_check)
23
21
  end
24
22
 
25
23
  def missing
26
24
  components_dir = Rails.root.join(@config.components_folder)
27
- # Assuming standard structure: test/components/previews for previews
28
- # But Lookbook can be configured differently. We should use Lookbook's config if possible to know where previews are.
29
- # For now, let's stick to the user's script logic which assumes standard paths or iterate through loaded components.
30
-
31
- # Better approach: Iterate through all known components and check if they have a preview.
32
- # However, "all known components" might be hard to get if they aren't loaded.
33
- # Let's use the file system approach as in the user script.
25
+ previews_dir = preview_paths.first
34
26
 
35
27
  components = Dir.glob(File.join(components_dir, '**', '*_component.rb'))
36
- previews_dir = Rails.root.join('test/components/previews') # Default, maybe make configurable?
37
-
38
- # Trying to find where previews are located from Rails config if possible
39
- if defined?(Rails) && Rails.application.config.view_component.preview_paths.any?
40
- # Use simple heuristic: first path
41
- previews_dir = Pathname.new(Rails.application.config.view_component.preview_paths.first)
42
- end
43
28
 
44
29
  missing = []
45
30
  components.each do |component_path|
@@ -57,19 +42,22 @@ module LookbookVisualTester
57
42
 
58
43
  private
59
44
 
45
+ def preview_paths
46
+ if defined?(Rails) && Rails.application.config.view_component.preview_paths.any?
47
+ Rails.application.config.view_component.preview_paths.map { |p| Pathname.new(p) }
48
+ else
49
+ [Rails.root.join('test/components/previews')]
50
+ end
51
+ end
52
+
53
+ def run_setup
54
+ @config.preview_checker_setup&.call
55
+ end
56
+
60
57
  def run_checks(check_method)
61
58
  previews = Lookbook.previews
62
- results = []
63
-
64
- # We want to flatten the work items: (PreviewClass, example_name)
65
- work_items = []
66
- previews.each do |preview|
67
- # preview is a Lookbook::Preview object which wraps the class
68
- # But for checking we might want the class directly or iterate scenarios
69
- examples = preview.scenarios
70
- examples.each do |example|
71
- work_items << { preview: preview, example: example }
72
- end
59
+ work_items = previews.flat_map do |preview|
60
+ preview.scenarios.map { |example| { preview: preview, example: example } }
73
61
  end
74
62
 
75
63
  if @config.threads > 1
@@ -82,44 +70,28 @@ module LookbookVisualTester
82
70
  results = Concurrent::Promises.zip(*promises).value
83
71
  pool.shutdown
84
72
  pool.wait_for_termination
73
+ results
85
74
  else
86
- results = work_items.map do |item|
87
- measure_and_send(item[:preview], item[:example], check_method)
88
- end
75
+ work_items.map { |item| measure_and_send(item[:preview], item[:example], check_method) }
89
76
  end
90
-
91
- results
92
77
  end
93
78
 
94
79
  def measure_and_send(preview, example, method_name)
95
80
  result = nil
96
- time = Benchmark.realtime do
97
- result = send(method_name, preview, example)
98
- end
81
+ time = Benchmark.realtime { result = send(method_name, preview, example) }
99
82
  result.duration = time
100
83
  result
101
84
  end
102
85
 
103
86
  def basic_check(preview, example)
104
- # user script logic:
105
- # preview_instance = preview_class.new
106
- # component = preview_instance.public_send(preview_example)
107
- # if component.respond_to?(:render_in) ...
108
-
109
- # Lookbook::Preview wrapper might help, but let's go to the class
110
87
  preview_class = preview.preview_class
111
88
  example_name = example.name
112
89
 
113
90
  begin
114
91
  preview_instance = preview_class.new
115
- unless preview_instance.respond_to?(example_name)
116
- return CheckResult.new(preview_name: preview.name, example_name: example_name,
117
- status: :passed)
118
- end
92
+ return CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed) unless preview_instance.respond_to?(example_name)
119
93
 
120
94
  preview_instance.public_send(example_name)
121
-
122
- # We don't render, just verify we can call it.
123
95
  CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed)
124
96
  rescue StandardError => e
125
97
  CheckResult.new(preview_name: preview.name, example_name: example_name, status: :failed,
@@ -132,85 +104,31 @@ module LookbookVisualTester
132
104
  example_name = example.name
133
105
 
134
106
  begin
135
- # Use ViewComponent::Preview logic to get the component to render
136
- # This handles the case where the method returns nil (implicit template)
137
- # and returns a component that renders the template.
138
107
  if preview_class.respond_to?(:preview_example)
139
108
  result = preview_class.preview_example(example_name)
140
109
  else
141
- # Fallback for older VC or non-standard setups
142
110
  preview_instance = preview_class.new
143
- unless preview_instance.respond_to?(example_name)
144
- return CheckResult.new(preview_name: preview.name, example_name: example_name,
145
- status: :passed)
146
- end
111
+ return CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed) unless preview_instance.respond_to?(example_name)
147
112
  result = preview_instance.public_send(example_name)
148
113
  end
149
114
 
150
- # Handle Hash return (ViewComponent 3.x+ behavior?)
151
115
  result = result[:component] if result.is_a?(Hash) && result.key?(:component)
152
116
 
153
117
  if result.respond_to?(:render_in)
154
- # Mock current_user/pundit if needed on the component itself if possible
155
- # But mainly we render it with a view context
156
- view_context = setup_view_context
157
-
158
- # Inject mocks into result if it supports it or reliance on global view_context
159
- # The user script defines singleton methods on the result.
160
- if @mocks
161
- @mocks.each do |key, value|
162
- if result.respond_to?(key) || !result.respond_to?(key) # Force define
163
- result.define_singleton_method(key) { value }
164
- end
165
- end
166
- end
167
-
168
- output = result.render_in(view_context)
169
-
118
+ output = result.render_in(build_view_context)
170
119
  if output.is_a?(String) && output.include?('ActionView::Template::Error')
171
- return CheckResult.new(preview_name: preview.name,
172
- example_name: example_name,
120
+ return CheckResult.new(preview_name: preview.name, example_name: example_name,
173
121
  status: :failed, error: 'ActionView::Template::Error found in rendered output',
174
122
  backtrace: [])
175
123
  end
176
124
  elsif result.is_a?(String)
177
125
  if result.include?('ActionView::Template::Error')
178
- return CheckResult.new(preview_name: preview.name,
179
- example_name: example_name,
126
+ return CheckResult.new(preview_name: preview.name, example_name: example_name,
180
127
  status: :failed, error: 'ActionView::Template::Error found in rendered output',
181
128
  backtrace: [])
182
129
  end
183
- # Rendered string, good.
184
130
  elsif result.nil?
185
- # If result is nil, it implies an implicit template rendering.
186
- # We need to verify that the template exists.
187
-
188
- # Try to find the template file based on conventions
189
- # Convention: preview_file_dir/preview_file_name/example_name.html.erb
190
-
191
- # We need the preview file path. Lookbook::Preview might not expose it easily
192
- # but the class might have source_location.
193
-
194
- method = preview_class.instance_method(example_name)
195
- source_file = method.source_location&.first
196
-
197
- found_template = false
198
- if source_file
199
- dir = File.dirname(source_file)
200
- filename = File.basename(source_file, '.rb')
201
- template_dir = File.join(dir, filename)
202
-
203
- extensions = ['.html.erb', '.html.haml', '.html.slim']
204
- path = File.join(template_dir, "#{example_name}")
205
-
206
- found_template = extensions.any? { |ext| File.exist?("#{path}#{ext}") }
207
- end
208
-
209
- unless found_template
210
- raise ViewComponent::MissingPreviewTemplateError.new("Preview #{example_name} returned nil and no template found at #{path}.* (checked erb, haml, slim)") if defined?(ViewComponent::MissingPreviewTemplateError)
211
-
212
- raise "Preview returned nil and no template found at #{path}.*"
213
- end
131
+ verify_implicit_template!(preview_class, example_name)
214
132
  end
215
133
 
216
134
  CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed)
@@ -220,126 +138,35 @@ module LookbookVisualTester
220
138
  end
221
139
  end
222
140
 
223
- def check_preview_controller_config
224
- return unless defined?(Rails)
225
-
226
- controller_name = Rails.application.config.view_component.preview_controller
227
- return unless controller_name
228
-
229
- begin
230
- controller_class = controller_name.constantize
231
- unless controller_class.action_methods.include?('render_scenario_to_string')
232
- puts "WARNING: Configured preview controller '#{controller_name}' does not have 'render_scenario_to_string' action."
233
- puts ' This is required for Lookbook to render previews correctly.'
234
- puts " Please ensure your preview controller inherits from 'Lookbook::PreviewController'."
235
- # We could raise an error here to fail deep_check
236
- raise "Preview Controller '#{controller_name}' missing required action 'render_scenario_to_string'"
237
- end
238
- rescue NameError
239
- puts "WARNING: Configured preview controller '#{controller_name}' could not be loaded."
240
- raise "Preview Controller '#{controller_name}' could not be loaded"
241
- end
242
- end
243
-
244
- def run_setup
245
- if @config.preview_checker_setup
246
- @config.preview_checker_setup.call
247
- else
248
- default_setup
249
- end
250
- end
251
-
252
- def default_setup
253
- # Logic from the user's script
254
- # We need to set up a controller and view context globally or for use in checks
255
-
256
- # This part is tricky because we need to make these available to the `deep_render_check` method.
257
- # We can store the view_context in an instance variable or re-create it.
258
-
259
- # Let's perform the class-level mocks here (User, etc.)
260
-
261
- # Mock User
262
- unless defined?(User)
263
- # Defining a dummy user if not exists is risky if the app doesn't have User.
264
- # But the script assumes User exists or creates a mock.
265
- # Let's create a OpenStruct-like mock for typical Devise/Pundit usage.
266
- end
267
-
268
- # The script logic:
269
- # controller = ApplicationController.new ...
270
- # We will do this in `setup_view_context` called per check or once?
271
- # `ApplicationController` might not be thread safe if we modify it?
272
- # Actually we create a new controller instance.
273
-
274
- # Define @mocks to be injected
275
- @mocks = {}
276
- @mocks[:current_user] = build_mock_user
277
- @mocks[:pundit_user] = @mocks[:current_user]
278
- end
279
-
280
- def build_mock_user
281
- # Try to load a real user or build a struct
282
- if defined?(User) && User.respond_to?(:first) && User.first
283
- User.first
284
- else
285
- # Fallback mock
286
- u = Object.new
287
- u.define_singleton_method(:email) { 'test@example.com' }
288
- u.define_singleton_method(:id) { 1 }
289
- # Add other common methods as needed or let them fail/mock dynamic
290
- u
291
- end
292
- end
293
-
294
- def setup_view_context
295
- # We need a controller to get a view context
141
+ def build_view_context
296
142
  controller = if defined?(ApplicationController)
297
143
  ApplicationController.new
298
144
  else
299
145
  ActionController::Base.new
300
146
  end
301
-
302
147
  controller.request = ActionDispatch::TestRequest.create
303
- if controller.request.env
304
- controller.request.env['rack.session'] = {}
305
- controller.request.env['rack.session.options'] = { id: SecureRandom.uuid }
306
- end
307
-
308
- # Devise mapping
309
- if defined?(Devise)
310
- controller.request.env['devise.mapping'] = Devise.mappings[:user] if Devise.mappings[:user]
311
-
312
- # Mock warden
313
- warden = Object.new
314
- warden.define_singleton_method(:authenticate!) { |*| true }
315
- warden.define_singleton_method(:authenticate) { |*| true }
316
- warden.define_singleton_method(:user) { |*| @mocks[:current_user] }
317
- controller.request.env['warden'] = warden
318
- end
148
+ controller.view_context
149
+ end
319
150
 
320
- view_context = controller.view_context
151
+ def verify_implicit_template!(preview_class, example_name)
152
+ method = preview_class.instance_method(example_name)
153
+ source_file = method&.source_location&.first
154
+ return if source_file.nil?
321
155
 
322
- # Add helper methods to view_context
323
- # We can use `class_eval` on the singleton class of the view context
324
- vc_singleton = view_context.singleton_class
156
+ dir = File.dirname(source_file)
157
+ filename = File.basename(source_file, '.rb')
158
+ template_dir = File.join(dir, filename)
325
159
 
326
- if @mocks
327
- @mocks.each do |key, value|
328
- vc_singleton.send(:define_method, key) { value }
329
- end
330
- end
160
+ extensions = ['.html.erb', '.html.haml', '.html.slim']
161
+ path = File.join(template_dir, example_name)
331
162
 
332
- # Common auth helpers
333
- vc_singleton.send(:define_method, :signed_in?) { true }
163
+ return if extensions.any? { |ext| File.exist?("#{path}#{ext}") }
334
164
 
335
- # Pundit policy mock
336
- vc_singleton.send(:define_method, :policy) do |_record|
337
- Struct.new(:show?, :index?, :create?, :update?, :destroy?, :edit?, :new?, :manage?).new(
338
- true, true, true, true, true, true, true, true
339
- )
340
- end
165
+ raise ViewComponent::MissingPreviewTemplateError.new(
166
+ "Preview #{example_name} returned nil and no template found at #{path}.* (checked erb, haml, slim)"
167
+ ) if defined?(ViewComponent::MissingPreviewTemplateError)
341
168
 
342
- view_context
169
+ raise "Preview returned nil and no template found at #{path}.*"
343
170
  end
344
171
  end
345
172
  end
@@ -1,8 +1,5 @@
1
1
  # lib/lookbook_visual_tester/railtie.rb
2
2
 
3
- # require 'lookbook_visual_tester/capybara_setup'
4
- require 'lookbook_visual_tester/update_previews'
5
-
6
3
  module LookbookVisualTester
7
4
  class Railtie < ::Rails::Railtie
8
5
  rake_tasks do
@@ -12,25 +9,22 @@ module LookbookVisualTester
12
9
 
13
10
  initializer 'LookbookVisualTester.lookbook_after_change' do |_app|
14
11
  Rails.logger.info "LookbookVisualTester initialized with host: #{LookbookVisualTester.config.lookbook_host}"
15
- Lookbook.after_change do |app, changes|
12
+ Lookbook.after_change do |_app, changes|
16
13
  next unless LookbookVisualTester.config.automatic_run
17
14
 
18
- # get hash of content of modified files to see if has changed
19
15
  modified = changes[:modified]
20
16
  my_hash = modified.sort.map { |f| File.read(f) }.hash
21
17
 
22
18
  lock_file = Rails.root.join('tmp', 'lookbook_visual_tester.lock')
23
19
  Rails.logger.info ">>> LookbookVisualTester: No changes detected in #{LookbookVisualTester.data}"
24
20
 
25
- # Rails.logger.info ">>> LookbookVisualTester: Stack trace: #{caller.join("\n")}"
26
- Rails.logger.info ">>> LookbookVisualTester: Changes in #{Rails.cache.instance_variable_get(:@data).keys.inspect}"
27
21
  File.open(lock_file, 'w') do |file|
28
22
  if file.flock(File::LOCK_EX | File::LOCK_NB)
29
23
  if LookbookVisualTester.data[:last_hash] == my_hash
30
24
  Rails.logger.info 'LookbookVisualTester: No changes detected in Lookbook'
31
25
  else
32
26
  LookbookVisualTester.data[:last_hash] = my_hash
33
- Rails.logger.info "LookbookVisualTester: Running UpdatePreviews, updattin to #{LookbookVisualTester.data.inspect}"
27
+ Rails.logger.info "LookbookVisualTester: Running UpdatePreviews, updating to #{LookbookVisualTester.data.inspect}"
34
28
  LookbookVisualTester::UpdatePreviews.call(changes)
35
29
  end
36
30
  file.flock(File::LOCK_UN)