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.
@@ -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,82 +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
- puts "DEBUG: preview_example('#{example_name}') returned: #{result.inspect} (class: #{result.class})"
141
109
  else
142
- # Fallback for older VC or non-standard setups
143
110
  preview_instance = preview_class.new
144
- unless preview_instance.respond_to?(example_name)
145
- return CheckResult.new(preview_name: preview.name, example_name: example_name,
146
- status: :passed)
147
- end
111
+ return CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed) unless preview_instance.respond_to?(example_name)
148
112
  result = preview_instance.public_send(example_name)
149
113
  end
150
114
 
151
- if result.respond_to?(:render_in)
152
- # Mock current_user/pundit if needed on the component itself if possible
153
- # But mainly we render it with a view context
154
- view_context = setup_view_context
115
+ result = result[:component] if result.is_a?(Hash) && result.key?(:component)
155
116
 
156
- # Inject mocks into result if it supports it or reliance on global view_context
157
- # The user script defines singleton methods on the result.
158
- if @mocks
159
- @mocks.each do |key, value|
160
- if result.respond_to?(key) || !result.respond_to?(key) # Force define
161
- result.define_singleton_method(key) { value }
162
- end
163
- end
164
- end
165
-
166
- output = result.render_in(view_context)
117
+ if result.respond_to?(:render_in)
118
+ output = result.render_in(build_view_context)
167
119
  if output.is_a?(String) && output.include?('ActionView::Template::Error')
168
- return CheckResult.new(preview_name: preview.name,
169
- example_name:,
120
+ return CheckResult.new(preview_name: preview.name, example_name: example_name,
170
121
  status: :failed, error: 'ActionView::Template::Error found in rendered output',
171
122
  backtrace: [])
172
123
  end
173
124
  elsif result.is_a?(String)
174
125
  if result.include?('ActionView::Template::Error')
175
- return CheckResult.new(preview_name: preview.name,
176
- example_name:,
126
+ return CheckResult.new(preview_name: preview.name, example_name: example_name,
177
127
  status: :failed, error: 'ActionView::Template::Error found in rendered output',
178
128
  backtrace: [])
179
129
  end
180
- # Rendered string, good.
181
130
  elsif result.nil?
182
- # If result is nil, it implies an implicit template rendering.
183
- # We need to verify that the template exists.
184
-
185
- # Try to find the template file based on conventions
186
- # Convention: preview_file_dir/preview_file_name/example_name.html.erb
187
-
188
- # We need the preview file path. Lookbook::Preview might not expose it easily
189
- # but the class might have source_location.
190
-
191
- method = preview_class.instance_method(example_name)
192
- source_file = method.source_location&.first
193
-
194
- found_template = false
195
- if source_file
196
- dir = File.dirname(source_file)
197
- filename = File.basename(source_file, '.rb')
198
- template_dir = File.join(dir, filename)
199
-
200
- extensions = ['.html.erb', '.html.haml', '.html.slim']
201
- path = File.join(template_dir, "#{example_name}")
202
-
203
- found_template = extensions.any? { |ext| File.exist?("#{path}#{ext}") }
204
- end
205
-
206
- unless found_template
207
- raise ViewComponent::MissingPreviewTemplateError.new("Preview #{example_name} returned nil and no template found at #{path}.* (checked erb, haml, slim)") if defined?(ViewComponent::MissingPreviewTemplateError)
208
-
209
- raise "Preview returned nil and no template found at #{path}.*"
210
- end
131
+ verify_implicit_template!(preview_class, example_name)
211
132
  end
212
133
 
213
134
  CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed)
@@ -217,126 +138,35 @@ module LookbookVisualTester
217
138
  end
218
139
  end
219
140
 
220
- def check_preview_controller_config
221
- return unless defined?(Rails)
222
-
223
- controller_name = Rails.application.config.view_component.preview_controller
224
- return unless controller_name
225
-
226
- begin
227
- controller_class = controller_name.constantize
228
- unless controller_class.action_methods.include?('render_scenario_to_string')
229
- puts "WARNING: Configured preview controller '#{controller_name}' does not have 'render_scenario_to_string' action."
230
- puts ' This is required for Lookbook to render previews correctly.'
231
- puts " Please ensure your preview controller inherits from 'Lookbook::PreviewController'."
232
- # We could raise an error here to fail deep_check
233
- raise "Preview Controller '#{controller_name}' missing required action 'render_scenario_to_string'"
234
- end
235
- rescue NameError
236
- puts "WARNING: Configured preview controller '#{controller_name}' could not be loaded."
237
- raise "Preview Controller '#{controller_name}' could not be loaded"
238
- end
239
- end
240
-
241
- def run_setup
242
- if @config.preview_checker_setup
243
- @config.preview_checker_setup.call
244
- else
245
- default_setup
246
- end
247
- end
248
-
249
- def default_setup
250
- # Logic from the user's script
251
- # We need to set up a controller and view context globally or for use in checks
252
-
253
- # This part is tricky because we need to make these available to the `deep_render_check` method.
254
- # We can store the view_context in an instance variable or re-create it.
255
-
256
- # Let's perform the class-level mocks here (User, etc.)
257
-
258
- # Mock User
259
- unless defined?(User)
260
- # Defining a dummy user if not exists is risky if the app doesn't have User.
261
- # But the script assumes User exists or creates a mock.
262
- # Let's create a OpenStruct-like mock for typical Devise/Pundit usage.
263
- end
264
-
265
- # The script logic:
266
- # controller = ApplicationController.new ...
267
- # We will do this in `setup_view_context` called per check or once?
268
- # `ApplicationController` might not be thread safe if we modify it?
269
- # Actually we create a new controller instance.
270
-
271
- # Define @mocks to be injected
272
- @mocks = {}
273
- @mocks[:current_user] = build_mock_user
274
- @mocks[:pundit_user] = @mocks[:current_user]
275
- end
276
-
277
- def build_mock_user
278
- # Try to load a real user or build a struct
279
- if defined?(User) && User.respond_to?(:first) && User.first
280
- User.first
281
- else
282
- # Fallback mock
283
- u = Object.new
284
- u.define_singleton_method(:email) { 'test@example.com' }
285
- u.define_singleton_method(:id) { 1 }
286
- # Add other common methods as needed or let them fail/mock dynamic
287
- u
288
- end
289
- end
290
-
291
- def setup_view_context
292
- # We need a controller to get a view context
141
+ def build_view_context
293
142
  controller = if defined?(ApplicationController)
294
143
  ApplicationController.new
295
144
  else
296
145
  ActionController::Base.new
297
146
  end
298
-
299
147
  controller.request = ActionDispatch::TestRequest.create
300
- if controller.request.env
301
- controller.request.env['rack.session'] = {}
302
- controller.request.env['rack.session.options'] = { id: SecureRandom.uuid }
303
- end
304
-
305
- # Devise mapping
306
- if defined?(Devise)
307
- controller.request.env['devise.mapping'] = Devise.mappings[:user] if Devise.mappings[:user]
308
-
309
- # Mock warden
310
- warden = Object.new
311
- warden.define_singleton_method(:authenticate!) { |*| true }
312
- warden.define_singleton_method(:authenticate) { |*| true }
313
- warden.define_singleton_method(:user) { |*| @mocks[:current_user] }
314
- controller.request.env['warden'] = warden
315
- end
148
+ controller.view_context
149
+ end
316
150
 
317
- 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?
318
155
 
319
- # Add helper methods to view_context
320
- # We can use `class_eval` on the singleton class of the view context
321
- 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)
322
159
 
323
- if @mocks
324
- @mocks.each do |key, value|
325
- vc_singleton.send(:define_method, key) { value }
326
- end
327
- end
160
+ extensions = ['.html.erb', '.html.haml', '.html.slim']
161
+ path = File.join(template_dir, example_name)
328
162
 
329
- # Common auth helpers
330
- vc_singleton.send(:define_method, :signed_in?) { true }
163
+ return if extensions.any? { |ext| File.exist?("#{path}#{ext}") }
331
164
 
332
- # Pundit policy mock
333
- vc_singleton.send(:define_method, :policy) do |_record|
334
- Struct.new(:show?, :index?, :create?, :update?, :destroy?, :edit?, :new?, :manage?).new(
335
- true, true, true, true, true, true, true, true
336
- )
337
- 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)
338
168
 
339
- view_context
169
+ raise "Preview returned nil and no template found at #{path}.*"
340
170
  end
341
171
  end
342
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)