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,1792 @@
1
+ # Lookbook Visual Tester Production Hardening Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Harden `lookbook_visual_tester` into a reliable, CI/agent-friendly visual regression tool by removing ImageMagick/Cuprite runtime dependencies, fixing thread-safety and auth-mocking issues, rewiring the legacy auto-watch flow onto the Ferrum `Runner`, and adding a server wrapper so agents can run it unattended.
6
+
7
+ **Architecture:** Keep the existing Ferrum-based `Runner` as the single visual regression path. Extract a pure-Ruby `ImageTrimmer` so screenshots can be cropped without ImageMagick. Replace hard-coded `User`/`Devise`/`Pundit` mocks in `PreviewChecker` with a host-provided `preview_checker_setup` block. Rewire the Railtie's `Lookbook.after_change` auto-run hook and `UpdatePreviews` to call `Runner` instead of the deleted Capybara/Cuprite `ScreenshotTaker`; delete the now-dead `BaselineManager`. Remove legacy Capybara/Cuprite code from the runtime gem. Add a hardened `ServerTestRunner` that starts Rails, waits for Lookbook, runs tests, and ensures cleanup.
8
+
9
+ **Tech Stack:** Ruby 3+, Rails 7/8, ViewComponent 4.x, Lookbook 2.x, Ferrum, ChunkyPNG, RSpec.
10
+
11
+ ## Global Constraints
12
+
13
+ - No runtime dependency on ImageMagick or `xclip` for the visual test path.
14
+ - No global `$stdout` mutation; output must be thread-safe and injectable.
15
+ - No hard-coded `User`/`Devise`/`Pundit` mocks inside the gem. Auth setup must be host-provided via `config.preview_checker_setup`.
16
+ - Lookbook 2.x (`scenarios`) is the only supported target. Lookbook 1.x (`examples`) compatibility code is removed everywhere it appears.
17
+ - Every task ends with a green RSpec run for new/changed specs. The single Ferrum/Chrome integration spec (`spec/integration/full_flow_spec.rb`) is allowed to fail in headless environments without Chrome; it must not be made to pass by weakening assertions.
18
+ - Backwards-compatible config paths are preserved unless explicitly renamed and deprecated.
19
+ - `rubocop` must not be loaded in the test environment (it currently segfaults via `racc` on Ruby 3.4 when the dummy app `Bundler.require`s it).
20
+
21
+ ## File Map
22
+
23
+ | File | Responsibility | Task |
24
+ |---|---|---|
25
+ | `Gemfile` | Move `rubocop` to `:development` so test env doesn't load it. | 2 |
26
+ | `lookbook_visual_tester.gemspec` | Drop `cuprite` runtime dep; keep `ferrum`, `chunky_png`, `concurrent-ruby`, `lookbook`, `rails`, `rainbow`, `benchmark`. | 2 |
27
+ | `lib/lookbook_visual_tester/configuration.rb` | Gem configuration: defaults, paths, hooks. Fix duplicate assignment + `automatic_run` parsing. | 1 |
28
+ | `lib/lookbook_visual_tester.rb` | Top-level loader. Drop legacy requires; add `image_trimmer` and `server_test_runner`. | 3 |
29
+ | `lib/lookbook_visual_tester/session_manager.rb` | Legacy Capybara/Cuprite session. **Delete**. | 3 |
30
+ | `lib/lookbook_visual_tester/capybara_setup.rb` | Legacy Capybara driver setup. **Delete**. | 3 |
31
+ | `lib/lookbook_visual_tester/screenshot_taker.rb` | Legacy Capybara screenshot path. **Delete**. | 3 |
32
+ | `lib/lookbook_visual_tester/baseline_manager.rb` | Dead code only used by deleted `ScreenshotTaker`. **Delete**. | 3 |
33
+ | `lib/lookbook_visual_tester/update_previews.rb` | Auto-watch change handler. Rewire to call `Runner` instead of `ScreenshotTaker`. | 3 |
34
+ | `lib/lookbook_visual_tester/railtie.rb` | Loads rake tasks; keeps auto-run hook (off by default), now backed by `Runner`. | 3 |
35
+ | `lib/lookbook_visual_tester/services/image_trimmer.rb` | Pure-Ruby image trimming with ChunkyPNG. | 4 |
36
+ | `lib/lookbook_visual_tester/services/image_comparator.rb` | ChunkyPNG diff (existing, no change). | — |
37
+ | `lib/lookbook_visual_tester/runner.rb` | Main visual test runner. Remove ImageMagick shell call, inject output, drop 1.x `examples` fallback, refactor `run_scenario`. | 5 |
38
+ | `lib/lookbook_visual_tester/preview_checker.rb` | Preview health checks. Replace hard-coded auth with setup hook; keep `preview_example` path; drop 1.x fallback. | 6 |
39
+ | `lib/tasks/lookbook_visual_tester.rake` | Rake tasks. Stop mutating `$stdout`; drop 1.x fallback; add `server_and_test`; fix `approve`. | 7, 8, 9 |
40
+ | `lib/lookbook_visual_tester/server_test_runner.rb` | Starts Rails, runs tests, cleans up. | 8 |
41
+ | `README.md` | Update installation, config reference, tasks. | 10 |
42
+ | `CHANGELOG.md` | Document breaking + behavior changes. | 10 |
43
+
44
+ ---
45
+
46
+ ## Task 1: Fix Configuration Defaults and the Host Setup Hook
47
+
48
+ **Files:**
49
+ - Modify: `lib/lookbook_visual_tester/configuration.rb`
50
+ - Test: `spec/lib/lookbook_visual_tester/configuration_spec.rb` (create)
51
+
52
+ **Interfaces:**
53
+ - Produces: `LookbookVisualTester.config.preview_checker_setup` (callable or `nil`), `LookbookVisualTester.config.copy_to_clipboard` defaults to `false`, `LookbookVisualTester.config.driver_adapter` defaults to `:ferrum`, `LookbookVisualTester.config.automatic_run` is a boolean parsed from the `LOOKBOOK_AUTOMATIC_RUN` env var, `config.mask_selectors = []` stays.
54
+
55
+ - [ ] **Step 1: Write the failing spec for the corrected defaults**
56
+
57
+ ```ruby
58
+ require 'spec_helper'
59
+ require 'lookbook_visual_tester/configuration'
60
+
61
+ RSpec.describe LookbookVisualTester::Configuration do
62
+ subject(:config) { described_class.new }
63
+
64
+ it 'defaults copy_to_clipboard to false' do
65
+ expect(config.copy_to_clipboard).to be(false)
66
+ end
67
+
68
+ it 'defaults driver_adapter to ferrum' do
69
+ expect(config.driver_adapter).to eq(:ferrum)
70
+ end
71
+
72
+ it 'defaults automatic_run to false when env is unset' do
73
+ allow(ENV).to receive(:fetch).with('LOOKBOOK_AUTOMATIC_RUN', 'false').and_return('false')
74
+ expect(config.automatic_run).to be(false)
75
+ end
76
+
77
+ it 'parses automatic_run as true when LOOKBOOK_AUTOMATIC_RUN=true' do
78
+ allow(ENV).to receive(:fetch).with('LOOKBOOK_AUTOMATIC_RUN', 'false').and_return('true')
79
+ expect(config.automatic_run).to be(true)
80
+ end
81
+
82
+ it 'accepts a preview_checker_setup callable' do
83
+ setup = -> { :ok }
84
+ config.preview_checker_setup = setup
85
+ expect(config.preview_checker_setup).to eq(setup)
86
+ end
87
+
88
+ it 'defaults preview_checker_setup to nil' do
89
+ expect(config.preview_checker_setup).to be_nil
90
+ end
91
+
92
+ it 'keeps legacy wait_time default' do
93
+ expect(config.wait_time).to eq(0.5)
94
+ end
95
+ end
96
+ ```
97
+
98
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/configuration_spec.rb`
99
+ Expected: FAIL — `copy_to_clipboard` is `true`, `automatic_run` is the string `"false"`.
100
+
101
+ - [ ] **Step 2: Fix the configuration**
102
+
103
+ ```ruby
104
+ # lib/lookbook_visual_tester/configuration.rb
105
+ module LookbookVisualTester
106
+ class Configuration
107
+ attr_reader :base_path
108
+ attr_accessor :lookbook_host, :ui_comparison, :diff_dir, :baseline_dir, :current_dir,
109
+ :history_dir, :history_keep_last_n, :threads, :copy_to_clipboard,
110
+ :components_folder, :automatic_run, :mask_selectors, :driver_adapter,
111
+ :preview_checker_setup, :logger, :wait_time, :tolerance
112
+
113
+ DEFAULT_THREADS = 4
114
+
115
+ def initialize
116
+ root_path = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
117
+ Rails.root
118
+ else
119
+ Pathname.new(Dir.pwd)
120
+ end
121
+
122
+ @base_path = root_path.join('coverage/screenshots')
123
+ @baseline_dir = @base_path.join('baseline')
124
+ @current_dir = @base_path.join('current_run')
125
+ @diff_dir = @base_path.join('diff')
126
+ @history_dir = @base_path.join('history')
127
+ @threads = ENV.fetch('LOOKBOOK_THREADS', DEFAULT_THREADS).to_i
128
+ @history_keep_last_n = 5
129
+ @copy_to_clipboard = false
130
+ @components_folder = 'app/components'
131
+ @automatic_run = ENV.fetch('LOOKBOOK_AUTOMATIC_RUN', 'false') == 'true'
132
+ @mask_selectors = []
133
+ @driver_adapter = :ferrum
134
+ @preview_checker_setup = nil
135
+ @wait_time = 0.5
136
+ @tolerance = 0.0
137
+ @logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
138
+ Rails.logger
139
+ else
140
+ require 'logger'
141
+ Logger.new($stdout).tap { |l| l.level = Logger::INFO }
142
+ end
143
+
144
+ @lookbook_host = ENV.fetch('LOOKBOOK_HOST', 'http://localhost:5000')
145
+ end
146
+
147
+ def base_path=(value)
148
+ @base_path = Pathname.new(value)
149
+ @baseline_dir = @base_path.join('baseline')
150
+ @current_dir = @base_path.join('current_run')
151
+ @diff_dir = @base_path.join('diff')
152
+ @history_dir = @base_path.join('history')
153
+ end
154
+
155
+ class << self
156
+ def config
157
+ @config ||= new
158
+ end
159
+
160
+ def configure
161
+ yield(config)
162
+ end
163
+ end
164
+ end
165
+
166
+ def self.config
167
+ @config ||= Configuration.new
168
+ end
169
+ end
170
+ ```
171
+
172
+ Notes:
173
+ - Removed the duplicate `@preview_checker_setup = nil`.
174
+ - `automatic_run` is now a boolean, not the raw env string.
175
+ - `copy_to_clipboard` defaults to `false`. Hosts opt in explicitly.
176
+
177
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/configuration_spec.rb`
178
+ Expected: PASS.
179
+
180
+ - [ ] **Step 3: Commit**
181
+
182
+ ```bash
183
+ git add lib/lookbook_visual_tester/configuration.rb spec/lib/lookbook_visual_tester/configuration_spec.rb
184
+ git commit -m "fix(config): boolean automatic_run, default copy_to_clipboard false, dedupe setup hook"
185
+ ```
186
+
187
+ ---
188
+
189
+ ## Task 2: Gemfile and Gemspec Hygiene
190
+
191
+ **Files:**
192
+ - Modify: `Gemfile`
193
+ - Modify: `lookbook_visual_tester.gemspec`
194
+
195
+ **Interfaces:**
196
+ - Produces: `rubocop` only in `group :development` (not loaded by `Bundler.require` in test env). `cuprite` removed from runtime dependencies. `capybara`/`cuprite` remain in the `:development, :test` group only for the legacy specs until Task 3 deletes them.
197
+
198
+ - [ ] **Step 1: Move `rubocop` to the development group in the Gemfile**
199
+
200
+ ```ruby
201
+ # frozen_string_literal: true
202
+
203
+ source 'https://rubygems.org'
204
+
205
+ # Specify your gem's dependencies in lookbook_visual_tester.gemspec
206
+ gemspec
207
+
208
+ gem 'rake', '~> 13.0'
209
+ gem 'minitest', '~> 5.16'
210
+
211
+ gem 'async'
212
+ gem 'async-http'
213
+ gem 'concurrent-ruby'
214
+ gem 'lookbook'
215
+ gem 'rails', '~> 8.0'
216
+
217
+ group :development do
218
+ gem 'rubocop', '~> 1.66'
219
+ end
220
+
221
+ group :development, :test do
222
+ gem 'capybara', '~> 3.35'
223
+ gem 'cuprite', '~> 0.14'
224
+ gem 'puma'
225
+ gem 'rspec', '~> 3.10'
226
+ gem 'view_component'
227
+ end
228
+ ```
229
+
230
+ - [ ] **Step 2: Drop `cuprite` from the gemspec runtime dependencies**
231
+
232
+ In `lookbook_visual_tester.gemspec`, remove the `spec.add_dependency 'cuprite'` line. The runtime block becomes:
233
+
234
+ ```ruby
235
+ spec.add_dependency 'benchmark'
236
+ spec.add_dependency 'chunky_png'
237
+ spec.add_dependency 'concurrent-ruby'
238
+ spec.add_dependency 'ferrum'
239
+ spec.add_dependency 'lookbook'
240
+ spec.add_dependency 'rails'
241
+ spec.add_dependency 'rainbow'
242
+ ```
243
+
244
+ - [ ] **Step 3: Re-bundle and run the suite**
245
+
246
+ ```bash
247
+ bundle install
248
+ bundle exec rspec
249
+ ```
250
+
251
+ Expected: The suite loads without the `rubocop`/`racc` segfault. Existing specs pass except the Ferrum/Chrome integration spec (allowed failure).
252
+
253
+ - [ ] **Step 4: Commit**
254
+
255
+ ```bash
256
+ git add Gemfile Gemfile.lock lookbook_visual_tester.gemspec
257
+ git commit -m "chore: move rubocop to development, drop cuprite runtime dependency"
258
+ ```
259
+
260
+ ---
261
+
262
+ ## Task 3: Remove Legacy Capybara/Cuprite Code and Rewire the Auto-Run Hook
263
+
264
+ **Files:**
265
+ - Modify: `lib/lookbook_visual_tester.rb`
266
+ - Modify: `lib/lookbook_visual_tester/railtie.rb`
267
+ - Modify: `lib/lookbook_visual_tester/update_previews.rb`
268
+ - Delete: `lib/lookbook_visual_tester/session_manager.rb`
269
+ - Delete: `lib/lookbook_visual_tester/capybara_setup.rb`
270
+ - Delete: `lib/lookbook_visual_tester/screenshot_taker.rb`
271
+ - Delete: `lib/lookbook_visual_tester/baseline_manager.rb`
272
+ - Delete: `spec/lib/lookbook_visual_tester/screenshot_taker_spec.rb`
273
+ - Modify: `spec/lib/lookbook_visual_tester/update_previews_spec.rb`
274
+
275
+ **Interfaces:**
276
+ - Produces: Top-level gem no longer requires `session_manager`, `capybara_setup`, `screenshot_taker`, or `baseline_manager`. `UpdatePreviews#process_changes` calls `LookbookVisualTester::Runner.new(pattern: <resolved pattern>).run` for each changed preview instead of `ScreenshotTaker`. The Railtie auto-run hook is unchanged in shape but is now backed by `Runner` and stays off unless `config.automatic_run` is true.
277
+
278
+ - [ ] **Step 1: Audit references before deleting**
279
+
280
+ Run:
281
+
282
+ ```bash
283
+ grep -R "SessionManager\|CapybaraSetup\|ScreenshotTaker\|BaselineManager" lib/ spec/ README.md
284
+ ```
285
+
286
+ Expected: references only in `update_previews.rb`, `railtie.rb` (none for BaselineManager outside its own file), and the legacy specs. `BaselineManager` has no callers — safe to delete.
287
+
288
+ - [ ] **Step 2: Rewire `UpdatePreviews` to use `Runner`**
289
+
290
+ ```ruby
291
+ # lib/lookbook_visual_tester/update_previews.rb
292
+ require_relative 'service'
293
+
294
+ module LookbookVisualTester
295
+ class UpdatePreviews < Service
296
+ attr_reader :changes
297
+
298
+ def initialize(changes)
299
+ @changes = changes[:modified]
300
+ @changes_hash = changes
301
+ end
302
+
303
+ def update_app_data
304
+ LookbookVisualTester.data[:last_changed_files] = changes.presence || []
305
+ LookbookVisualTester.data[:last_changed_previews] = selected_previews
306
+ end
307
+
308
+ def call
309
+ Rails.logger.info "LookbookVisualTester: Processing changes for #{should_process?} #{selected_changes.inspect}, #{changes.inspect}"
310
+ return unless should_process?
311
+
312
+ process_changes
313
+ rescue StandardError => e
314
+ Rails.logger.error "LookbookVisualTester: Error processing changes: #{e.message}"
315
+ Rails.logger.error e.backtrace.join("\n")
316
+ end
317
+
318
+ private
319
+
320
+ def selected_changes
321
+ @selected_changes ||= changes.select { |change| process_change?(change) }
322
+ end
323
+
324
+ def process_change?(change)
325
+ change.to_s.downcase.include?('preview.rb') || change.to_s.downcase.match?(/component\.(html|haml|rb|erb)/)
326
+ end
327
+
328
+ def should_process?
329
+ return false if changes.nil? || changes.empty?
330
+
331
+ selected_changes.any?
332
+ end
333
+
334
+ def components_folder
335
+ LookbookVisualTester.config.components_folder
336
+ end
337
+
338
+ def clean_file_name(file)
339
+ file = file.split(components_folder)[-1]
340
+ file.split('.')[0].gsub('_preview', '')
341
+ end
342
+
343
+ def selected_previews
344
+ @selected_previews ||= Lookbook.previews.select do |preview|
345
+ selected_changes.any? { |file| preview.file_path.to_s.include?(clean_file_name(file)) }
346
+ end
347
+ end
348
+
349
+ def process_changes
350
+ Rails.logger.info "LookbookVisualTester: previews #{selected_previews.count}"
351
+ selected_previews.each do |preview|
352
+ Rails.logger.info "LookbookVisualTester: running Runner for #{preview.inspect}"
353
+ LookbookVisualTester::Runner.new(pattern: preview.name).run
354
+ end
355
+ end
356
+ end
357
+ end
358
+ ```
359
+
360
+ Notes:
361
+ - The stray `puts ">>>> #{file}"` debug line is removed.
362
+ - Each changed preview is re-run through the Ferrum `Runner` (the single visual regression path), filtered by `preview.name`.
363
+
364
+ - [ ] **Step 3: Update the Railtie (drop the legacy require, keep the hook)**
365
+
366
+ ```ruby
367
+ # lib/lookbook_visual_tester/railtie.rb
368
+ module LookbookVisualTester
369
+ class Railtie < ::Rails::Railtie
370
+ rake_tasks do
371
+ path = File.expand_path('../tasks/lookbook_visual_tester.rake', __dir__)
372
+ load path
373
+ end
374
+
375
+ initializer 'LookbookVisualTester.lookbook_after_change' do |_app|
376
+ Rails.logger.info "LookbookVisualTester initialized with host: #{LookbookVisualTester.config.lookbook_host}"
377
+ Lookbook.after_change do |_app, changes|
378
+ next unless LookbookVisualTester.config.automatic_run
379
+
380
+ modified = changes[:modified]
381
+ my_hash = modified.sort.map { |f| File.read(f) }.hash
382
+
383
+ lock_file = Rails.root.join('tmp', 'lookbook_visual_tester.lock')
384
+ Rails.logger.info ">>> LookbookVisualTester: No changes detected in #{LookbookVisualTester.data}"
385
+
386
+ File.open(lock_file, 'w') do |file|
387
+ if file.flock(File::LOCK_EX | File::LOCK_NB)
388
+ if LookbookVisualTester.data[:last_hash] == my_hash
389
+ Rails.logger.info 'LookbookVisualTester: No changes detected in Lookbook'
390
+ else
391
+ LookbookVisualTester.data[:last_hash] = my_hash
392
+ Rails.logger.info "LookbookVisualTester: Running UpdatePreviews, updating to #{LookbookVisualTester.data.inspect}"
393
+ LookbookVisualTester::UpdatePreviews.call(changes)
394
+ end
395
+ file.flock(File::LOCK_UN)
396
+ Rails.logger.info 'LookbookVisualTester: UpdatePreviews File unlocked.'
397
+ else
398
+ Rails.logger.info 'LookbookVisualTester: UpdatePreviews already running, skipping this call.'
399
+ end
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end
405
+ ```
406
+
407
+ - [ ] **Step 4: Update the top-level loader (drop legacy requires, add `image_trimmer` and `server_test_runner`)**
408
+
409
+ ```ruby
410
+ # lib/lookbook_visual_tester.rb
411
+ require_relative 'lookbook_visual_tester/version'
412
+ require_relative 'lookbook_visual_tester/configuration'
413
+ require_relative 'lookbook_visual_tester/railtie' if defined?(Rails)
414
+ require_relative 'lookbook_visual_tester/scenario_finder'
415
+ require_relative 'lookbook_visual_tester/store'
416
+ require_relative 'lookbook_visual_tester/runner'
417
+ require_relative 'lookbook_visual_tester/driver'
418
+ require_relative 'lookbook_visual_tester/drivers/ferrum_driver'
419
+ require_relative 'lookbook_visual_tester/services/image_comparator'
420
+ require_relative 'lookbook_visual_tester/services/image_trimmer'
421
+ require_relative 'lookbook_visual_tester/server_test_runner'
422
+
423
+ module LookbookVisualTester
424
+ class Error < StandardError; end
425
+
426
+ def self.configure
427
+ yield(config)
428
+ end
429
+ end
430
+ ```
431
+
432
+ - [ ] **Step 5: Delete the legacy files and their specs**
433
+
434
+ ```bash
435
+ rm lib/lookbook_visual_tester/session_manager.rb
436
+ rm lib/lookbook_visual_tester/capybara_setup.rb
437
+ rm lib/lookbook_visual_tester/screenshot_taker.rb
438
+ rm lib/lookbook_visual_tester/baseline_manager.rb
439
+ rm spec/lib/lookbook_visual_tester/screenshot_taker_spec.rb
440
+ ```
441
+
442
+ - [ ] **Step 6: Update `update_previews_spec.rb` to expect `Runner` instead of `ScreenshotTaker`**
443
+
444
+ Replace the `describe '#process_changes'` block with:
445
+
446
+ ```ruby
447
+ describe '#process_changes' do
448
+ let(:modified_files) { ['app/components/button_preview.rb'] }
449
+ let(:scenario) { double('scenario') }
450
+ let(:preview) { double('preview', scenarios: [scenario], file_path: 'app/components/button_preview.rb', name: 'Button') }
451
+ let(:runner) { double('Runner') }
452
+
453
+ before do
454
+ allow(preview).to receive(:respond_to?).with(:scenarios).and_return(true)
455
+ allow(Lookbook).to receive(:previews).and_return([preview])
456
+ allow(LookbookVisualTester::Runner).to receive(:new).with(pattern: 'Button').and_return(runner)
457
+ allow(runner).to receive(:run)
458
+ allow(Rails.logger).to receive(:info)
459
+ end
460
+
461
+ it 'runs the Ferrum Runner for each changed preview' do
462
+ expect(LookbookVisualTester::Runner).to receive(:new).with(pattern: 'Button')
463
+ expect(runner).to receive(:run)
464
+
465
+ service.send(:process_changes)
466
+ end
467
+ end
468
+ ```
469
+
470
+ Also remove the `require 'lookbook_visual_tester/screenshot_taker'` line at the top of the spec.
471
+
472
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/update_previews_spec.rb`
473
+ Expected: PASS.
474
+
475
+ - [ ] **Step 7: Commit**
476
+
477
+ ```bash
478
+ git add -A
479
+ git commit -m "refactor: remove legacy cuprite/capybara code, rewire auto-run to Ferrum Runner"
480
+ ```
481
+
482
+ ---
483
+
484
+ ## Task 4: Implement Pure-Ruby Image Trimming
485
+
486
+ **Files:**
487
+ - Create: `lib/lookbook_visual_tester/services/image_trimmer.rb`
488
+ - Test: `spec/lib/lookbook_visual_tester/services/image_trimmer_spec.rb` (create)
489
+
490
+ **Interfaces:**
491
+ - `LookbookVisualTester::ImageTrimmer.call(path, padding: 10)` — reads PNG at `path`, trims fully-transparent or fully-white margins, adds `padding` pixels of transparent border, overwrites `path` with the result. Returns `path`. Idempotent, pure-Ruby, no shell calls.
492
+
493
+ - [ ] **Step 1: Write the failing spec**
494
+
495
+ ```ruby
496
+ require 'spec_helper'
497
+ require 'lookbook_visual_tester/services/image_trimmer'
498
+ require 'chunky_png'
499
+ require 'fileutils'
500
+
501
+ RSpec.describe LookbookVisualTester::ImageTrimmer do
502
+ let(:tmp_dir) { 'spec/tmp/trimmer' }
503
+ let(:path) { "#{tmp_dir}/input.png" }
504
+
505
+ before { FileUtils.mkdir_p(tmp_dir) }
506
+ after { FileUtils.rm_rf(tmp_dir) }
507
+
508
+ def build_image(width:, height:, fill: ChunkyPNG::Color::WHITE, content_color: ChunkyPNG::Color::BLACK)
509
+ image = ChunkyPNG::Image.new(width, height, fill)
510
+ image[5, 5] = content_color
511
+ image[6, 5] = content_color
512
+ image[5, 6] = content_color
513
+ image[6, 6] = content_color
514
+ image
515
+ end
516
+
517
+ it 'trims white margins and adds padding' do
518
+ build_image(width: 20, height: 20).save(path)
519
+
520
+ described_class.call(path, padding: 4)
521
+
522
+ trimmed = ChunkyPNG::Image.from_file(path)
523
+ expect(trimmed.width).to eq(12) # 2px blob + 4px padding each side
524
+ expect(trimmed.height).to eq(12)
525
+ end
526
+
527
+ it 'preserves images that have no uniform border' do
528
+ image = ChunkyPNG::Image.new(10, 10, ChunkyPNG::Color.from_hex('#EFEFEF'))
529
+ image.save(path)
530
+
531
+ described_class.call(path, padding: 0)
532
+
533
+ trimmed = ChunkyPNG::Image.from_file(path)
534
+ expect(trimmed.width).to eq(10)
535
+ expect(trimmed.height).to eq(10)
536
+ end
537
+
538
+ it 'raises when the file does not exist' do
539
+ expect { described_class.call('spec/tmp/missing.png') }.to raise_error(Errno::ENOENT)
540
+ end
541
+
542
+ it 'returns the path' do
543
+ build_image(width: 10, height: 10).save(path)
544
+ expect(described_class.call(path)).to eq(path)
545
+ end
546
+
547
+ it 'does not shell out to ImageMagick' do
548
+ build_image(width: 10, height: 10).save(path)
549
+ expect(Kernel).not_to receive(:system).with(/convert/)
550
+ expect(Kernel).not_to receive(:`).with(/convert/)
551
+ described_class.call(path)
552
+ end
553
+ end
554
+ ```
555
+
556
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/services/image_trimmer_spec.rb`
557
+ Expected: FAIL — `ImageTrimmer` not defined.
558
+
559
+ - [ ] **Step 2: Implement `ImageTrimmer`**
560
+
561
+ ```ruby
562
+ # lib/lookbook_visual_tester/services/image_trimmer.rb
563
+ require 'chunky_png'
564
+
565
+ module LookbookVisualTester
566
+ module ImageTrimmer
567
+ DEFAULT_PADDING = 10
568
+
569
+ # Pixels matching any of these colors are considered "empty" border and trimmed.
570
+ BORDER_COLORS = [
571
+ ChunkyPNG::Color::WHITE,
572
+ ChunkyPNG::Color::TRANSPARENT
573
+ ].freeze
574
+
575
+ def self.call(path, padding: DEFAULT_PADDING)
576
+ image = ChunkyPNG::Image.from_file(path)
577
+
578
+ min_x = image.width
579
+ max_x = -1
580
+ min_y = image.height
581
+ max_y = -1
582
+
583
+ image.height.times do |y|
584
+ image.width.times do |x|
585
+ next if border_pixel?(image[x, y])
586
+
587
+ min_x = x if x < min_x
588
+ max_x = x if x > max_x
589
+ min_y = y if y < min_y
590
+ max_y = y if y > max_y
591
+ end
592
+ end
593
+
594
+ # No content found: keep the original image.
595
+ return path if max_x < min_x
596
+
597
+ content_width = max_x - min_x + 1
598
+ content_height = max_y - min_y + 1
599
+ new_width = content_width + (padding * 2)
600
+ new_height = content_height + (padding * 2)
601
+
602
+ trimmed = ChunkyPNG::Image.new(new_width, new_height, ChunkyPNG::Color::TRANSPARENT)
603
+
604
+ image.height.times do |y|
605
+ image.width.times do |x|
606
+ next if x < min_x || x > max_x || y < min_y || y > max_y
607
+
608
+ trimmed[x - min_x + padding, y - min_y + padding] = image[x, y]
609
+ end
610
+ end
611
+
612
+ trimmed.save(path)
613
+ path
614
+ end
615
+
616
+ def self.border_pixel?(color)
617
+ BORDER_COLORS.include?(color)
618
+ end
619
+ end
620
+ end
621
+ ```
622
+
623
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/services/image_trimmer_spec.rb`
624
+ Expected: PASS.
625
+
626
+ - [ ] **Step 3: Commit**
627
+
628
+ ```bash
629
+ git add lib/lookbook_visual_tester/services/image_trimmer.rb spec/lib/lookbook_visual_tester/services/image_trimmer_spec.rb
630
+ git commit -m "feat: pure-ruby image trimmer using chunky_png"
631
+ ```
632
+
633
+ ---
634
+
635
+ ## Task 5: Harden the Runner (ImageMagick removal, output injection, refactor, drop 1.x fallback)
636
+
637
+ **Files:**
638
+ - Modify: `lib/lookbook_visual_tester/runner.rb`
639
+ - Test: `spec/lib/lookbook_visual_tester/runner_spec.rb`
640
+
641
+ **Interfaces:**
642
+ - `Runner.new(config = LookbookVisualTester.config, pattern: nil, force_update: false, output: $stdout)` accepts an IO-like output stream.
643
+ - `Runner#run_scenario` calls `LookbookVisualTester::ImageTrimmer.call(current_path.to_s)` instead of `system("convert ...")`.
644
+ - All progress output inside `Runner` goes through `@output.puts` / `@output.print` (no bare `puts`/`print`).
645
+ - `run_sequentially` and `run_concurrently` iterate `preview.scenarios` only (Lookbook 2.x); the `preview.examples` fallback is removed.
646
+ - `run_scenario` is decomposed into focused private methods: `prepare_paths`, `capture`, `compare_against_baseline`, `record_failure`, `copy_to_clipboard_if_enabled`.
647
+
648
+ - [ ] **Step 1: Add failing tests for output injection and no ImageMagick**
649
+
650
+ Append to `spec/lib/lookbook_visual_tester/runner_spec.rb`:
651
+
652
+ ```ruby
653
+ context 'with a custom output stream' do
654
+ let(:output) { StringIO.new }
655
+
656
+ before do
657
+ allow(LookbookVisualTester::ImageComparator).to receive(:new).and_return(
658
+ double(call: { mismatch: 0.0 })
659
+ )
660
+ allow(FileUtils).to receive(:cp)
661
+ allow(LookbookVisualTester::ImageTrimmer).to receive(:call).and_return('path')
662
+ end
663
+
664
+ it 'writes progress to the provided stream' do
665
+ runner = described_class.new(output: output)
666
+ runner.run
667
+
668
+ expect(output.string).to include('Found 1 previews')
669
+ end
670
+ end
671
+
672
+ context 'image trimming' do
673
+ before do
674
+ allow(LookbookVisualTester::ImageComparator).to receive(:new).and_return(
675
+ double(call: { mismatch: 0.0 })
676
+ )
677
+ allow(FileUtils).to receive(:cp)
678
+ end
679
+
680
+ it 'trims the screenshot via ImageTrimmer without calling ImageMagick' do
681
+ expect(Kernel).not_to receive(:system).with(/convert/)
682
+ allow(LookbookVisualTester::ImageTrimmer).to receive(:call).and_call_original
683
+
684
+ runner = described_class.new
685
+ runner.run
686
+
687
+ expect(LookbookVisualTester::ImageTrimmer).to have_received(:call).with(String).at_least(:once)
688
+ end
689
+ end
690
+ ```
691
+
692
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/runner_spec.rb`
693
+ Expected: FAIL — `Runner` has no `output:` keyword; `Kernel.system(/convert/)` is still called.
694
+
695
+ - [ ] **Step 2: Refactor `Runner`**
696
+
697
+ ```ruby
698
+ # lib/lookbook_visual_tester/runner.rb
699
+ require 'lookbook'
700
+ require 'json'
701
+ require_relative 'configuration'
702
+ require_relative 'scenario_run'
703
+ require_relative 'services/image_comparator'
704
+ require_relative 'services/image_trimmer'
705
+ require_relative 'drivers/ferrum_driver'
706
+ require_relative 'variant_resolver'
707
+
708
+ module LookbookVisualTester
709
+ class Runner
710
+ Result = Struct.new(:scenario_name, :status, :mismatch, :diff_path, :error, :baseline_path,
711
+ :current_path, keyword_init: true)
712
+
713
+ DEFAULT_DRIVER_WIDTH = 1280
714
+ DEFAULT_DRIVER_HEIGHT = 800
715
+
716
+ def initialize(config = LookbookVisualTester.config, pattern: nil, force_update: false, output: $stdout)
717
+ @config = config
718
+ @pattern = pattern
719
+ @force_update = force_update
720
+ @output = output
721
+ @driver_pool = Queue.new
722
+ init_driver_pool
723
+ @results = []
724
+ @variants = load_variants
725
+ end
726
+
727
+ def run
728
+ previews = Lookbook.previews
729
+
730
+ if @pattern.present?
731
+ previews = previews.select do |preview|
732
+ preview.label.downcase.include?(@pattern.downcase) ||
733
+ preview.name.downcase.include?(@pattern.downcase)
734
+ end
735
+ end
736
+
737
+ @output.puts "Found #{previews.count} previews matching '#{@pattern}'."
738
+ @output.puts "Running against #{@variants.size} variant(s)."
739
+
740
+ @variants.each do |variant_input|
741
+ resolver = VariantResolver.new(variant_input)
742
+ variant_options = resolver.resolve
743
+ variant_slug = resolver.slug
744
+ width = resolver.width_in_pixels
745
+
746
+ @output.puts " Variant: #{variant_slug.presence || 'Default'}"
747
+
748
+ if @config.threads > 1
749
+ run_concurrently(previews, variant_slug, variant_options, width)
750
+ else
751
+ run_sequentially(previews, variant_slug, variant_options, width)
752
+ end
753
+ end
754
+
755
+ @results
756
+ ensure
757
+ cleanup_drivers
758
+ end
759
+
760
+ private
761
+
762
+ def load_variants
763
+ variants_json = ENV['VARIANTS'] || ENV.fetch('LOOKBOOK_VARIANTS', nil)
764
+ return [{}] if variants_json.blank?
765
+
766
+ begin
767
+ JSON.parse(variants_json)
768
+ rescue JSON::ParserError
769
+ @output.puts 'Invalid JSON in VARIANTS env var. Defaulting to standard run.'
770
+ [{}]
771
+ end
772
+ end
773
+
774
+ def scenarios_for(preview)
775
+ preview.scenarios
776
+ end
777
+
778
+ def run_sequentially(previews, variant_slug, variant_options, width)
779
+ previews.each do |preview|
780
+ scenarios_for(preview).each do |scenario|
781
+ driver = checkout_driver
782
+ begin
783
+ @results << run_scenario(scenario, driver, variant_slug, variant_options, width)
784
+ ensure
785
+ return_driver(driver)
786
+ end
787
+ end
788
+ end
789
+ end
790
+
791
+ def run_concurrently(previews, variant_slug, variant_options, width)
792
+ require 'concurrent-ruby'
793
+ pool = Concurrent::FixedThreadPool.new(@config.threads)
794
+ promises = []
795
+
796
+ previews.each do |preview|
797
+ scenarios_for(preview).each do |scenario|
798
+ promises << Concurrent::Promises.future_on(pool) do
799
+ driver = checkout_driver
800
+ begin
801
+ run_scenario(scenario, driver, variant_slug, variant_options, width)
802
+ ensure
803
+ return_driver(driver)
804
+ end
805
+ end
806
+ end
807
+ end
808
+
809
+ @results.concat(Concurrent::Promises.zip(*promises).value)
810
+ pool.shutdown
811
+ pool.wait_for_termination
812
+ end
813
+
814
+ def init_driver_pool
815
+ count = @config.threads > 1 ? @config.threads : 1
816
+ count.times { @driver_pool << Drivers::FerrumDriver.new(@config) }
817
+ end
818
+
819
+ def checkout_driver
820
+ @driver_pool.pop
821
+ end
822
+
823
+ def return_driver(driver)
824
+ @driver_pool << driver
825
+ end
826
+
827
+ def cleanup_drivers
828
+ until @driver_pool.empty?
829
+ driver = @driver_pool.pop
830
+ driver.cleanup
831
+ end
832
+ end
833
+
834
+ def run_scenario(scenario, driver, variant_slug, variant_options, width)
835
+ run_data = ScenarioRun.new(scenario, variant_slug: variant_slug,
836
+ display_params: variant_options)
837
+ @output.puts "Running visual test for: #{run_data.name} #{variant_slug.present? ? "[#{variant_slug}]" : ''}"
838
+
839
+ paths = prepare_paths(run_data, variant_slug)
840
+ begin
841
+ capture(driver, run_data, paths, width)
842
+ compare_against_baseline(run_data, paths)
843
+ rescue StandardError => e
844
+ record_failure(run_data, paths, e)
845
+ end
846
+ end
847
+
848
+ def prepare_paths(run_data, variant_slug)
849
+ folder_name = variant_slug.presence || 'default'
850
+ {
851
+ current: run_data.current_path,
852
+ baseline: run_data.baseline_path,
853
+ diff: @config.diff_dir.join(folder_name, run_data.diff_filename)
854
+ }
855
+ end
856
+
857
+ def capture(driver, run_data, paths, width)
858
+ driver.resize_window(width || DEFAULT_DRIVER_WIDTH, DEFAULT_DRIVER_HEIGHT)
859
+ driver.visit(run_data.preview_url)
860
+
861
+ FileUtils.mkdir_p(File.dirname(paths[:current]))
862
+ FileUtils.mkdir_p(File.dirname(paths[:diff]))
863
+
864
+ driver.save_screenshot(paths[:current].to_s)
865
+ ImageTrimmer.call(paths[:current].to_s) if File.exist?(paths[:current].to_s)
866
+ end
867
+
868
+ def compare_against_baseline(run_data, paths)
869
+ comparator = ImageComparator.new(paths[:baseline].to_s, paths[:current].to_s, paths[:diff].to_s)
870
+ result = comparator.call
871
+
872
+ @results << build_result(run_data, paths, result)
873
+ end
874
+
875
+ def build_result(run_data, paths, result)
876
+ if result[:error]
877
+ if result[:error] == 'Baseline not found' || @force_update
878
+ handle_missing_baseline(run_data, paths, result)
879
+ else
880
+ @output.puts " [ERROR] #{result[:error]}"
881
+ Result.new(scenario_name: run_data.name, status: :error, error: result[:error],
882
+ baseline_path: paths[:baseline].to_s, current_path: paths[:current].to_s)
883
+ end
884
+ elsif result[:mismatch] > 0
885
+ record_mismatch(run_data, paths, result)
886
+ else
887
+ @output.puts ' [PASS] Identical.'
888
+ Result.new(scenario_name: run_data.name, status: :passed, mismatch: 0.0,
889
+ diff_path: paths[:diff].to_s, baseline_path: paths[:baseline].to_s,
890
+ current_path: paths[:current].to_s)
891
+ end
892
+ end
893
+
894
+ def handle_missing_baseline(run_data, paths, result)
895
+ if @force_update
896
+ @output.puts ' [UPDATE] Baseline forced update.'
897
+ status = :passed
898
+ else
899
+ @output.puts ' [NEW] Baseline not found. Saved current as potential baseline.'
900
+ status = :new
901
+ end
902
+ FileUtils.mkdir_p(File.dirname(paths[:baseline]))
903
+ FileUtils.cp(paths[:current], paths[:baseline])
904
+ Result.new(scenario_name: run_data.name, status: status, mismatch: 0.0,
905
+ diff_path: nil, baseline_path: paths[:baseline].to_s,
906
+ current_path: paths[:current].to_s)
907
+ end
908
+
909
+ def record_mismatch(run_data, paths, result)
910
+ mismatch = result[:mismatch]
911
+ @output.puts " [FAIL] Mismatch: #{mismatch.round(2)}%. Diff saved to #{paths[:diff]}"
912
+
913
+ dom_path = paths[:diff].sub('.png', '.html')
914
+ File.write(dom_path, driver.page_source)
915
+ @output.puts " DOM Snapshot saved to #{dom_path}"
916
+
917
+ copy_to_clipboard_if_enabled(paths[:current])
918
+
919
+ Result.new(scenario_name: run_data.name, status: :failed, mismatch: mismatch,
920
+ diff_path: paths[:diff].to_s, baseline_path: paths[:baseline].to_s,
921
+ current_path: paths[:current].to_s)
922
+ end
923
+
924
+ def copy_to_clipboard_if_enabled(current_path)
925
+ return unless @config.copy_to_clipboard
926
+
927
+ system("xclip -selection clipboard -t image/png -i #{current_path}")
928
+ end
929
+
930
+ def record_failure(run_data, paths, error)
931
+ @output.puts " [ERROR] Exception: #{error.message}"
932
+ @output.puts error.backtrace.take(5)
933
+ Result.new(scenario_name: run_data.name, status: :error, error: error.message,
934
+ baseline_path: paths[:baseline].to_s, current_path: paths[:current].to_s)
935
+ end
936
+
937
+ # Used by record_mismatch; resolves the driver for the current scenario thread.
938
+ def driver
939
+ Thread.current[:lookbook_visual_tester_driver]
940
+ end
941
+ end
942
+ end
943
+ ```
944
+
945
+ **Important fix-up note for the implementer:** `record_mismatch` references `driver.page_source`, but `driver` is local to `capture`. Pass the driver through to `run_scenario` and store it on the thread (or pass it as an argument to `record_mismatch`). The concrete pattern:
946
+
947
+ ```ruby
948
+ def run_scenario(scenario, driver, variant_slug, variant_options, width)
949
+ run_data = ScenarioRun.new(...)
950
+ paths = prepare_paths(run_data, variant_slug)
951
+ Thread.current[:lookbook_visual_tester_driver] = driver
952
+ begin
953
+ capture(driver, run_data, paths, width)
954
+ compare_against_baseline(run_data, paths)
955
+ rescue StandardError => e
956
+ record_failure(run_data, paths, e)
957
+ ensure
958
+ Thread.current[:lookbook_visual_tester_driver] = nil
959
+ end
960
+ end
961
+ ```
962
+
963
+ `record_mismatch` reads `driver.page_source` via the `driver` helper above. Add this wiring so the DOM snapshot still works. Add a spec that asserts `driver.page_source` is written to the `.html` path on mismatch (stub the driver double to return `"<html>"` for `page_source`).
964
+
965
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/runner_spec.rb`
966
+ Expected: PASS (existing specs may need `output:` plumbing if they assert on stdout; update them to pass a `StringIO`).
967
+
968
+ - [ ] **Step 3: Commit**
969
+
970
+ ```bash
971
+ git add lib/lookbook_visual_tester/runner.rb spec/lib/lookbook_visual_tester/runner_spec.rb
972
+ git commit -m "refactor(runner): pure-ruby trim, inject output, drop 1.x fallback, decompose run_scenario"
973
+ ```
974
+
975
+ ---
976
+
977
+ ## Task 6: Refactor PreviewChecker to Use the Host Setup Hook
978
+
979
+ **Files:**
980
+ - Modify: `lib/lookbook_visual_tester/preview_checker.rb`
981
+ - Test: `spec/lib/lookbook_visual_tester/preview_checker_spec.rb`
982
+
983
+ **Interfaces:**
984
+ - `PreviewChecker#run_setup` calls `config.preview_checker_setup` if present, else does nothing.
985
+ - The hard-coded `build_mock_user`, `@mocks`, `define_singleton_method`, `signed_in?`/`policy` helpers, and `default_setup` are removed.
986
+ - `deep_render_check` keeps the existing `preview_class.respond_to?(:preview_example)` path (so implicit-template previews still work) and uses a minimal view context with no auth mocking.
987
+ - `check_preview_controller_config` is removed (it introspected Lookbook internals and was version-fragile).
988
+
989
+ - [ ] **Step 1: Add failing tests for the setup hook**
990
+
991
+ Append to `spec/lib/lookbook_visual_tester/preview_checker_spec.rb`:
992
+
993
+ ```ruby
994
+ describe '#run_setup' do
995
+ it 'calls the configured preview_checker_setup block' do
996
+ called = false
997
+ local_config = LookbookVisualTester::Configuration.new
998
+ local_config.preview_checker_setup = -> { called = true }
999
+
1000
+ checker = described_class.new(local_config)
1001
+ checker.send(:run_setup)
1002
+
1003
+ expect(called).to be(true)
1004
+ end
1005
+
1006
+ it 'does nothing when no setup block is configured' do
1007
+ local_config = LookbookVisualTester::Configuration.new
1008
+ local_config.preview_checker_setup = nil
1009
+
1010
+ checker = described_class.new(local_config)
1011
+ expect { checker.send(:run_setup) }.not_to raise_error
1012
+ end
1013
+ end
1014
+ ```
1015
+
1016
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/preview_checker_spec.rb`
1017
+ Expected: FAIL — current `run_setup` calls `default_setup` (which builds mocks) when the block is absent.
1018
+
1019
+ - [ ] **Step 2: Replace `PreviewChecker` body**
1020
+
1021
+ ```ruby
1022
+ # lib/lookbook_visual_tester/preview_checker.rb
1023
+ require 'concurrent'
1024
+ require 'benchmark'
1025
+
1026
+ module LookbookVisualTester
1027
+ class PreviewChecker
1028
+ CheckResult = Struct.new(:preview_name, :example_name, :status, :error, :backtrace, :duration,
1029
+ keyword_init: true)
1030
+ MissingResult = Struct.new(:component_path, keyword_init: true)
1031
+
1032
+ def initialize(config = LookbookVisualTester.config)
1033
+ @config = config
1034
+ end
1035
+
1036
+ def check
1037
+ run_checks(:basic_check)
1038
+ end
1039
+
1040
+ def deep_check
1041
+ run_setup
1042
+ run_checks(:deep_render_check)
1043
+ end
1044
+
1045
+ def missing
1046
+ components_dir = Rails.root.join(@config.components_folder)
1047
+ previews_dir = preview_paths.first
1048
+
1049
+ components = Dir.glob(File.join(components_dir, '**', '*_component.rb'))
1050
+
1051
+ missing = []
1052
+ components.each do |component_path|
1053
+ next if component_path.end_with?('application_component.rb')
1054
+ next if component_path.include?('/concerns/')
1055
+
1056
+ relative_path = Pathname.new(component_path).relative_path_from(components_dir).to_s
1057
+ preview_relative_path = relative_path.sub('_component.rb', '_component_preview.rb')
1058
+ preview_path = File.join(previews_dir, preview_relative_path)
1059
+
1060
+ missing << MissingResult.new(component_path: relative_path) unless File.exist?(preview_path)
1061
+ end
1062
+ missing
1063
+ end
1064
+
1065
+ private
1066
+
1067
+ def preview_paths
1068
+ if defined?(Rails) && Rails.application.config.view_component.preview_paths.any?
1069
+ Rails.application.config.view_component.preview_paths.map { |p| Pathname.new(p) }
1070
+ else
1071
+ [Rails.root.join('test/components/previews')]
1072
+ end
1073
+ end
1074
+
1075
+ def run_setup
1076
+ @config.preview_checker_setup&.call
1077
+ end
1078
+
1079
+ def run_checks(check_method)
1080
+ previews = Lookbook.previews
1081
+ work_items = previews.flat_map do |preview|
1082
+ preview.scenarios.map { |example| { preview: preview, example: example } }
1083
+ end
1084
+
1085
+ if @config.threads > 1
1086
+ pool = Concurrent::FixedThreadPool.new(@config.threads)
1087
+ promises = work_items.map do |item|
1088
+ Concurrent::Promises.future_on(pool) do
1089
+ measure_and_send(item[:preview], item[:example], check_method)
1090
+ end
1091
+ end
1092
+ results = Concurrent::Promises.zip(*promises).value
1093
+ pool.shutdown
1094
+ pool.wait_for_termination
1095
+ results
1096
+ else
1097
+ work_items.map { |item| measure_and_send(item[:preview], item[:example], check_method) }
1098
+ end
1099
+ end
1100
+
1101
+ def measure_and_send(preview, example, method_name)
1102
+ result = nil
1103
+ time = Benchmark.realtime { result = send(method_name, preview, example) }
1104
+ result.duration = time
1105
+ result
1106
+ end
1107
+
1108
+ def basic_check(preview, example)
1109
+ preview_class = preview.preview_class
1110
+ example_name = example.name
1111
+
1112
+ begin
1113
+ preview_instance = preview_class.new
1114
+ return CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed) unless preview_instance.respond_to?(example_name)
1115
+
1116
+ preview_instance.public_send(example_name)
1117
+ CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed)
1118
+ rescue StandardError => e
1119
+ CheckResult.new(preview_name: preview.name, example_name: example_name, status: :failed,
1120
+ error: e.message, backtrace: e.backtrace)
1121
+ end
1122
+ end
1123
+
1124
+ def deep_render_check(preview, example)
1125
+ preview_class = preview.preview_class
1126
+ example_name = example.name
1127
+
1128
+ begin
1129
+ if preview_class.respond_to?(:preview_example)
1130
+ result = preview_class.preview_example(example_name)
1131
+ else
1132
+ preview_instance = preview_class.new
1133
+ return CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed) unless preview_instance.respond_to?(example_name)
1134
+ result = preview_instance.public_send(example_name)
1135
+ end
1136
+
1137
+ result = result[:component] if result.is_a?(Hash) && result.key?(:component)
1138
+
1139
+ if result.respond_to?(:render_in)
1140
+ output = result.render_in(build_view_context)
1141
+ if output.is_a?(String) && output.include?('ActionView::Template::Error')
1142
+ return CheckResult.new(preview_name: preview.name, example_name: example_name,
1143
+ status: :failed, error: 'ActionView::Template::Error found in rendered output',
1144
+ backtrace: [])
1145
+ end
1146
+ elsif result.is_a?(String)
1147
+ if result.include?('ActionView::Template::Error')
1148
+ return CheckResult.new(preview_name: preview.name, example_name: example_name,
1149
+ status: :failed, error: 'ActionView::Template::Error found in rendered output',
1150
+ backtrace: [])
1151
+ end
1152
+ elsif result.nil?
1153
+ verify_implicit_template!(preview_class, example_name)
1154
+ end
1155
+
1156
+ CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed)
1157
+ rescue StandardError => e
1158
+ CheckResult.new(preview_name: preview.name, example_name: example_name, status: :failed,
1159
+ error: e.message, backtrace: e.backtrace)
1160
+ end
1161
+ end
1162
+
1163
+ def build_view_context
1164
+ controller = if defined?(ApplicationController)
1165
+ ApplicationController.new
1166
+ else
1167
+ ActionController::Base.new
1168
+ end
1169
+ controller.request = ActionDispatch::TestRequest.create
1170
+ controller.view_context
1171
+ end
1172
+
1173
+ def verify_implicit_template!(preview_class, example_name)
1174
+ method = preview_class.instance_method(example_name)
1175
+ source_file = method&.source_location&.first
1176
+ return if source_file.nil?
1177
+
1178
+ dir = File.dirname(source_file)
1179
+ filename = File.basename(source_file, '.rb')
1180
+ template_dir = File.join(dir, filename)
1181
+
1182
+ extensions = ['.html.erb', '.html.haml', '.html.slim']
1183
+ path = File.join(template_dir, example_name)
1184
+
1185
+ return if extensions.any? { |ext| File.exist?("#{path}#{ext}") }
1186
+
1187
+ raise ViewComponent::MissingPreviewTemplateError.new(
1188
+ "Preview #{example_name} returned nil and no template found at #{path}.* (checked erb, haml, slim)"
1189
+ ) if defined?(ViewComponent::MissingPreviewTemplateError)
1190
+
1191
+ raise "Preview returned nil and no template found at #{path}.*"
1192
+ end
1193
+ end
1194
+ end
1195
+ ```
1196
+
1197
+ Notes:
1198
+ - `run_setup` no longer falls back to `default_setup`; hosts that need `Current.user`/helpers set `config.preview_checker_setup`.
1199
+ - `build_view_context` provides a minimal context with no Devise/Warden/Pundit mocking.
1200
+ - `preview_example` path is preserved so implicit-template previews keep working.
1201
+ - `check_preview_controller_config` is removed.
1202
+
1203
+ Update the existing `describe '#deep_check'` specs: remove the `allow(checker).to receive(:setup_view_context)` stub (the method no longer exists) and instead stub `build_view_context` if needed.
1204
+
1205
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/preview_checker_spec.rb spec/lib/lookbook_visual_tester/check_action_view_error_spec.rb spec/lib/lookbook_visual_tester/deep_check_error_handling_spec.rb`
1206
+ Expected: PASS (adjust any specs that relied on deleted internals).
1207
+
1208
+ - [ ] **Step 3: Commit**
1209
+
1210
+ ```bash
1211
+ git add lib/lookbook_visual_tester/preview_checker.rb spec/lib/lookbook_visual_tester/preview_checker_spec.rb
1212
+ git commit -m "refactor(preview_checker): host setup hook, remove auth mocks, keep preview_example path"
1213
+ ```
1214
+
1215
+ ---
1216
+
1217
+ ## Task 7: Rake Tasks — Stop Mutating `$stdout`, Drop 1.x Fallback
1218
+
1219
+ **Files:**
1220
+ - Modify: `lib/tasks/lookbook_visual_tester.rake`
1221
+ - Test: `spec/integration/tasks_spec.rb`
1222
+
1223
+ **Interfaces:**
1224
+ - `lookbook:screenshot`, `lookbook:test`, `lookbook:retry`, `lookbook_visual_tester:images`, and `lookbook_visual_tester:profile` never reassign `$stdout`. When quiet/JSON mode is requested they pass `output: File.open(File::NULL, 'w')` to `Runner`.
1225
+ - `lookbook:list` uses `preview.scenarios` only.
1226
+
1227
+ - [ ] **Step 1: Add a failing test that `$stdout` is never reassigned**
1228
+
1229
+ Append to `spec/integration/tasks_spec.rb`:
1230
+
1231
+ ```ruby
1232
+ it 'does not reassign $stdout during lookbook:test in json mode' do
1233
+ original = $stdout
1234
+ allow(LookbookVisualTester::Runner).to receive(:new).and_return(
1235
+ double(run: [], 'class' => LookbookVisualTester::Runner)
1236
+ )
1237
+ Rake::Task['lookbook:test'].reenable
1238
+ Rake::Task['lookbook:test'].invoke('json')
1239
+
1240
+ expect($stdout).to equal(original)
1241
+ end
1242
+ ```
1243
+
1244
+ Run: `bundle exec rspec spec/integration/tasks_spec.rb`
1245
+ Expected: FAIL — current tasks reassign `$stdout`.
1246
+
1247
+ - [ ] **Step 2: Refactor the rake tasks**
1248
+
1249
+ For `lookbook:screenshot`:
1250
+
1251
+ ```ruby
1252
+ desc 'Generate screenshots for a specific preview (and run comparison)'
1253
+ task :screenshot, %i[preview_name format] => :environment do |_, args|
1254
+ preview_name = args[:preview_name]
1255
+ format = args[:format]
1256
+ json_mode = format == 'json'
1257
+
1258
+ unless preview_name
1259
+ if json_mode
1260
+ LookbookVisualTester::JsonOutputHandler.print({ error: 'Please provide a preview name' })
1261
+ else
1262
+ puts 'Please provide a preview name: rake lookbook:screenshot[Button]'
1263
+ end
1264
+ exit 1
1265
+ end
1266
+
1267
+ output = json_mode ? File.open(File::NULL, 'w') : $stdout
1268
+ runner = LookbookVisualTester::Runner.new(pattern: preview_name, output: output)
1269
+ results = runner.run
1270
+ output.close if json_mode
1271
+
1272
+ if json_mode
1273
+ json_results = results.map do |r|
1274
+ { scenario_name: r.scenario_name, status: r.status, mismatch: r.mismatch,
1275
+ diff_path: r.diff_path, baseline_path: r.baseline_path,
1276
+ current_path: r.current_path, error: r.error }
1277
+ end
1278
+ output_payload = json_results.size == 1 ? json_results.first : json_results
1279
+ LookbookVisualTester::JsonOutputHandler.print(output_payload)
1280
+ else
1281
+ print_cli_summary(results)
1282
+ end
1283
+ end
1284
+ ```
1285
+
1286
+ For `lookbook:test`:
1287
+
1288
+ ```ruby
1289
+ desc 'Run visual regression tests for all previews'
1290
+ task :test, [:format] => :environment do |_, args|
1291
+ json_mode = args[:format] == 'json' || ENV['JSON_OUTPUT'] == 'true'
1292
+ output = json_mode ? File.open(File::NULL, 'w') : $stdout
1293
+
1294
+ runner = LookbookVisualTester::Runner.new(
1295
+ force_update: args[:format] == 'force' || ENV['UPDATE'] == 'true',
1296
+ output: output
1297
+ )
1298
+ results = runner.run
1299
+ output.close if json_mode
1300
+
1301
+ if defined?(LookbookVisualTester.config.diff_dir)
1302
+ result_file = LookbookVisualTester.config.diff_dir.join('last_run.json')
1303
+ FileUtils.mkdir_p(File.dirname(result_file))
1304
+ simplified = results.map do |r|
1305
+ { scenario_name: r.scenario_name, status: r.status, mismatch: r.mismatch,
1306
+ diff_path: r.diff_path.to_s, current_path: r.current_path.to_s,
1307
+ baseline_path: r.baseline_path.to_s }
1308
+ end
1309
+ File.write(result_file, JSON.dump(simplified))
1310
+ end
1311
+
1312
+ if json_mode
1313
+ summary = {
1314
+ total: results.size,
1315
+ passed: results.count { |r| r.status == :passed },
1316
+ failed: results.count { |r| r.status == :failed },
1317
+ new: results.count { |r| r.status == :new },
1318
+ errors: results.count { |r| r.status == :error },
1319
+ results: results.map do |r|
1320
+ { name: r.scenario_name, status: r.status, mismatch: r.mismatch, diff_path: r.diff_path }
1321
+ end
1322
+ }
1323
+ LookbookVisualTester::JsonOutputHandler.print(summary)
1324
+ else
1325
+ print_cli_summary(results)
1326
+ LookbookVisualTester::ReportGenerator.new(results).call
1327
+ exit 1 if results.any? { |r| r.status == :failed }
1328
+ end
1329
+ end
1330
+ ```
1331
+
1332
+ For `lookbook:retry`:
1333
+
1334
+ ```ruby
1335
+ desc 'Re-run only failing tests from the last run'
1336
+ task :retry, [:format] => :environment do |_, args|
1337
+ result_file = LookbookVisualTester.config.diff_dir.join('last_run.json')
1338
+ unless File.exist?(result_file)
1339
+ puts "No previous run data found at #{result_file}. Run 'rake lookbook:test' first."
1340
+ exit 1
1341
+ end
1342
+
1343
+ last_run = JSON.parse(File.read(result_file), symbolize_names: true)
1344
+ failures = last_run.select { |r| r[:status] == 'failed' || r[:status] == 'error' }
1345
+
1346
+ if failures.empty?
1347
+ puts 'No failures found in last run.'
1348
+ exit 0
1349
+ end
1350
+
1351
+ puts "Retrying #{failures.size} failure(s)..."
1352
+
1353
+ json_mode = args[:format] == 'json' || ENV['JSON_OUTPUT'] == 'true'
1354
+ output = json_mode ? File.open(File::NULL, 'w') : $stdout
1355
+ begin
1356
+ failures.each do |failure|
1357
+ puts "Retrying #{failure[:scenario_name]}..."
1358
+ LookbookVisualTester::Runner.new(pattern: failure[:scenario_name], output: output).run
1359
+ end
1360
+ ensure
1361
+ output.close if json_mode
1362
+ end
1363
+ end
1364
+ ```
1365
+
1366
+ For `lookbook:list`, replace:
1367
+
1368
+ ```ruby
1369
+ group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
1370
+ ```
1371
+
1372
+ with:
1373
+
1374
+ ```ruby
1375
+ group = preview.scenarios
1376
+ ```
1377
+
1378
+ For `lookbook_visual_tester:images`, replace the `$stdout` mutation:
1379
+
1380
+ ```ruby
1381
+ if args[:skip_capture].to_s == 'true'
1382
+ # Just print existing
1383
+ else
1384
+ runner = LookbookVisualTester::Runner.new(pattern: args[:name], output: File.open(File::NULL, 'w'))
1385
+ runner.run
1386
+ end
1387
+ ```
1388
+
1389
+ For `lookbook_visual_tester:profile`, replace `$stdout = File.new('/dev/null', 'w')` patterns with passing `output:` to `Runner` (or keep `RubyProf` printing to `$stdout` since that is the profile output, not Runner noise — only silence the Runner). Concretely, do not mutate `$stdout` at all; if Runner noise is unwanted, pass `output: File.open(File::NULL, 'w')` to the invoked test task by refactoring `lookbook:test` to accept an `output` env override. Keep this task minimal: leave `$stdout` alone.
1390
+
1391
+ Run: `bundle exec rspec spec/integration/tasks_spec.rb`
1392
+ Expected: PASS.
1393
+
1394
+ - [ ] **Step 3: Commit**
1395
+
1396
+ ```bash
1397
+ git add lib/tasks/lookbook_visual_tester.rake spec/integration/tasks_spec.rb
1398
+ git commit -m "refactor(rake): inject null output instead of mutating $stdout, drop 1.x fallback"
1399
+ ```
1400
+
1401
+ ---
1402
+
1403
+ ## Task 8: Server Wrapper for Agent Automation
1404
+
1405
+ **Files:**
1406
+ - Create: `lib/lookbook_visual_tester/server_test_runner.rb`
1407
+ - Create: `spec/lib/lookbook_visual_tester/server_test_runner_spec.rb`
1408
+ - Modify: `lib/tasks/lookbook_visual_tester.rake`
1409
+ - Modify: `spec/integration/tasks_spec.rb`
1410
+
1411
+ **Interfaces:**
1412
+ - `LookbookVisualTester::ServerTestRunner.call(config: LookbookVisualTester.config, timeout: 60, test_task: 'lookbook:test', log_path: nil)` starts Rails in a child process (new process group), polls the Lookbook endpoint, invokes the test task, and ensures the whole process group is terminated.
1413
+ - `rake lookbook:server_and_test` delegates to it.
1414
+
1415
+ - [ ] **Step 1: Write the spec**
1416
+
1417
+ ```ruby
1418
+ require 'spec_helper'
1419
+ require 'lookbook_visual_tester/server_test_runner'
1420
+
1421
+ RSpec.describe LookbookVisualTester::ServerTestRunner do
1422
+ let(:config) { LookbookVisualTester::Configuration.new }
1423
+
1424
+ before do
1425
+ allow(LookbookVisualTester).to receive(:config).and_return(config)
1426
+ config.lookbook_host = 'http://localhost:5000'
1427
+ end
1428
+
1429
+ it 'requires a lookbook_host' do
1430
+ config.lookbook_host = nil
1431
+ expect { described_class.call }.to raise_error(LookbookVisualTester::Error, /lookbook_host/)
1432
+ end
1433
+
1434
+ it 'raises on an invalid lookbook_host URL' do
1435
+ config.lookbook_host = 'not-a-url'
1436
+ expect { described_class.call }.to raise_error(LookbookVisualTester::Error, /lookbook_host/)
1437
+ end
1438
+
1439
+ describe '.wait_for_server' do
1440
+ it 'returns true when the server responds < 500' do
1441
+ allow(Net::HTTP).to receive(:get_response).and_return(double(code: '200'))
1442
+ expect(described_class.wait_for_server(config.lookbook_host, timeout: 1)).to be(true)
1443
+ end
1444
+
1445
+ it 'raises when the server never responds' do
1446
+ allow(Net::HTTP).to receive(:get_response).and_raise(Errno::ECONNREFUSED)
1447
+ expect { described_class.wait_for_server(config.lookbook_host, timeout: 0.1) }.to raise_error(LookbookVisualTester::Error, /did not start/)
1448
+ end
1449
+ end
1450
+
1451
+ describe 'rake task registration' do
1452
+ it 'registers lookbook:server_and_test' do
1453
+ expect(Rake::Task.task_defined?('lookbook:server_and_test')).to be(true)
1454
+ end
1455
+ end
1456
+ end
1457
+ ```
1458
+
1459
+ Add to `spec/integration/tasks_spec.rb`:
1460
+
1461
+ ```ruby
1462
+ it 'registers lookbook:server_and_test' do
1463
+ expect(Rake::Task.task_defined?('lookbook:server_and_test')).to be true
1464
+ end
1465
+ ```
1466
+
1467
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/server_test_runner_spec.rb`
1468
+ Expected: FAIL — file/class does not exist; task not registered.
1469
+
1470
+ - [ ] **Step 2: Implement `ServerTestRunner`**
1471
+
1472
+ ```ruby
1473
+ # lib/lookbook_visual_tester/server_test_runner.rb
1474
+ require 'net/http'
1475
+ require 'uri'
1476
+ require 'timeout'
1477
+ require 'tempfile'
1478
+
1479
+ module LookbookVisualTester
1480
+ class ServerTestRunner
1481
+ DEFAULT_TEST_TASK = 'lookbook:test'
1482
+ DEFAULT_TIMEOUT = 60
1483
+ POLL_INTERVAL = 0.25
1484
+
1485
+ def self.call(config: LookbookVisualTester.config, timeout: DEFAULT_TIMEOUT, test_task: DEFAULT_TEST_TASK, log_path: nil)
1486
+ new(config: config, timeout: timeout, test_task: test_task, log_path: log_path).call
1487
+ end
1488
+
1489
+ def initialize(config: LookbookVisualTester.config, timeout: DEFAULT_TIMEOUT, test_task: DEFAULT_TEST_TASK, log_path: nil)
1490
+ @config = config
1491
+ @timeout = timeout
1492
+ @test_task = test_task
1493
+ @log_path = log_path
1494
+ @pid = nil
1495
+ @log_file = nil
1496
+ end
1497
+
1498
+ def call
1499
+ validate_host!
1500
+ @pid = spawn_server
1501
+
1502
+ begin
1503
+ self.class.wait_for_server(@config.lookbook_host, timeout: @timeout)
1504
+ puts "[LookbookVisualTester] Server ready at #{@config.lookbook_host}. Running #{@test_task}..."
1505
+
1506
+ task = Rake::Task[@test_task]
1507
+ task.reenable
1508
+ task.invoke
1509
+ ensure
1510
+ stop_server
1511
+ end
1512
+ end
1513
+
1514
+ def self.wait_for_server(host, timeout: DEFAULT_TIMEOUT)
1515
+ uri = URI.parse(host)
1516
+ raise Error, "Invalid lookbook_host: #{host}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
1517
+
1518
+ Timeout.timeout(timeout) do
1519
+ loop do
1520
+ begin
1521
+ response = Net::HTTP.get_response(uri)
1522
+ return true if response.code.to_i < 500
1523
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Net::OpenTimeout, Net::ReadTimeout
1524
+ # server not ready yet
1525
+ end
1526
+ sleep POLL_INTERVAL
1527
+ end
1528
+ end
1529
+ rescue Timeout::Error
1530
+ raise Error, "Lookbook server at #{host} did not start within #{timeout}s"
1531
+ end
1532
+
1533
+ private
1534
+
1535
+ def validate_host!
1536
+ host = @config.lookbook_host
1537
+ raise Error, 'lookbook_host must be configured' if host.nil? || host.empty?
1538
+
1539
+ uri = URI.parse(host)
1540
+ raise Error, "Invalid lookbook_host: #{host}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
1541
+ raise Error, "lookbook_host must include a port: #{host}" unless uri.port
1542
+ end
1543
+
1544
+ def spawn_server
1545
+ port = URI.parse(@config.lookbook_host).port
1546
+ env = {
1547
+ 'PORT' => port.to_s,
1548
+ 'RAILS_ENV' => ENV.fetch('RAILS_ENV', 'development')
1549
+ }
1550
+ command = ['bundle', 'exec', 'rails', 'server', '-p', port.to_s]
1551
+
1552
+ @log_file = @log_path ? File.open(@log_path, 'w') : Tempfile.create('lookbook_server')
1553
+ Process.spawn(env, *command, out: @log_file, err: @log_file, pgroup: true)
1554
+ end
1555
+
1556
+ def stop_server
1557
+ return unless @pid
1558
+
1559
+ begin
1560
+ Process.kill('-TERM', @pid) # negative pid signals the whole process group
1561
+ rescue Errno::ESRCH
1562
+ # already gone
1563
+ end
1564
+
1565
+ begin
1566
+ Process.waitpid(@pid)
1567
+ rescue Errno::ECHILD
1568
+ # already reaped
1569
+ end
1570
+ ensure
1571
+ @log_file&.close
1572
+ end
1573
+ end
1574
+ end
1575
+ ```
1576
+
1577
+ Add to `lib/lookbook_visual_tester.rb` (already added in Task 3):
1578
+
1579
+ ```ruby
1580
+ require_relative 'lookbook_visual_tester/server_test_runner'
1581
+ ```
1582
+
1583
+ - [ ] **Step 3: Add the rake task**
1584
+
1585
+ In `lib/tasks/lookbook_visual_tester.rake`, inside the `namespace :lookbook` block:
1586
+
1587
+ ```ruby
1588
+ desc 'Start Rails server, run visual tests, then stop the server'
1589
+ task server_and_test: :environment do
1590
+ LookbookVisualTester::ServerTestRunner.call(
1591
+ timeout: ENV.fetch('LOOKBOOK_SERVER_TIMEOUT', '60').to_i,
1592
+ test_task: 'lookbook:test',
1593
+ log_path: ENV['LOOKBOOK_SERVER_LOG']
1594
+ )
1595
+ end
1596
+ ```
1597
+
1598
+ Run: `bundle exec rspec spec/lib/lookbook_visual_tester/server_test_runner_spec.rb spec/integration/tasks_spec.rb`
1599
+ Expected: PASS.
1600
+
1601
+ - [ ] **Step 4: Commit**
1602
+
1603
+ ```bash
1604
+ git add lib/lookbook_visual_tester/server_test_runner.rb spec/lib/lookbook_visual_tester/server_test_runner_spec.rb lib/tasks/lookbook_visual_tester.rake spec/integration/tasks_spec.rb
1605
+ git commit -m "feat: hardened server wrapper for unattended agent runs"
1606
+ ```
1607
+
1608
+ ---
1609
+
1610
+ ## Task 9: Fix `lookbook:approve` for Namespaced Previews
1611
+
1612
+ **Files:**
1613
+ - Modify: `lib/tasks/lookbook_visual_tester.rake`
1614
+ - Modify: `spec/integration/tasks_spec.rb`
1615
+
1616
+ **Interfaces:**
1617
+ - `rake "lookbook:approve[ui/button/default]"` locates files under `current_run/**/<normalized>.png` (excluding `_diff.png`) where the base name equals `ui_button_default` exactly, or ends with `_ui_button_default` to support variant subfolders. Each is copied to the matching `baseline/` path (preserving subfolders).
1618
+
1619
+ - [ ] **Step 1: Inspect current file naming**
1620
+
1621
+ `ScenarioRun#filename` = `"#{preview_name}_#{scenario_name}.png"` with `preview_name`/`scenario_name` underscored. For a namespaced preview `Ui::ButtonComponentPreview` with scenario `default`, `preview.name` is `Ui::Button` (Lookbook strips `ComponentPreview`), so `preview_name` becomes `ui_button` and the file is `ui_button_default.png`. Approval by `ui/button/default` must match `ui_button_default.png` exactly — not as a prefix, otherwise `ui_button_defaultmobile` would also match.
1622
+
1623
+ - [ ] **Step 2: Rewrite the approve task**
1624
+
1625
+ ```ruby
1626
+ desc 'Approve a specific preview change (update baseline)'
1627
+ task :approve, [:preview_name] => :environment do |_, args|
1628
+ preview_name = args[:preview_name]
1629
+ unless preview_name
1630
+ puts 'Please provide a preview name: rake "lookbook:approve[ui/button/default]"'
1631
+ exit 1
1632
+ end
1633
+
1634
+ normalized = preview_name.tr('/', '_').tr(' ', '_')
1635
+ baseline_dir = LookbookVisualTester.config.baseline_dir
1636
+ current_dir = LookbookVisualTester.config.current_dir
1637
+
1638
+ candidates = Dir.glob(current_dir.join('**', '*.png')).reject do |f|
1639
+ File.basename(f).end_with?('_diff.png')
1640
+ end.select do |f|
1641
+ base = File.basename(f, '.png')
1642
+ base == normalized || base.end_with?("_#{normalized}")
1643
+ end
1644
+
1645
+ if candidates.empty?
1646
+ puts "No current runs found matching '#{preview_name}' in #{current_dir}."
1647
+ exit 1
1648
+ end
1649
+
1650
+ candidates.each do |current_file|
1651
+ relative = Pathname.new(current_file).relative_path_from(current_dir)
1652
+ baseline_file = baseline_dir.join(relative)
1653
+ FileUtils.mkdir_p(File.dirname(baseline_file))
1654
+ FileUtils.cp(current_file, baseline_file)
1655
+ puts "Approved: #{relative}"
1656
+ end
1657
+ end
1658
+ ```
1659
+
1660
+ - [ ] **Step 3: Add a spec**
1661
+
1662
+ Append to `spec/integration/tasks_spec.rb`:
1663
+
1664
+ ```ruby
1665
+ describe 'lookbook:approve' do
1666
+ it 'approves the correct baseline file for a namespaced preview' do
1667
+ current_dir = LookbookVisualTester.config.current_dir
1668
+ baseline_dir = LookbookVisualTester.config.baseline_dir
1669
+ FileUtils.mkdir_p(current_dir.join('theme-dark'))
1670
+
1671
+ file = current_dir.join('theme-dark/ui_button_default.png')
1672
+ File.write(file, 'fake-png-data')
1673
+
1674
+ # A differently-named preview that shares the prefix must NOT be approved.
1675
+ decoy = current_dir.join('theme-dark/ui_button_defaultmobile.png')
1676
+ File.write(decoy, 'should-not-be-approved')
1677
+
1678
+ Rake::Task['lookbook:approve'].reenable
1679
+ Rake::Task['lookbook:approve'].invoke('ui/button/default')
1680
+
1681
+ expect(File.exist?(baseline_dir.join('theme-dark/ui_button_default.png'))).to be(true)
1682
+ expect(File.exist?(baseline_dir.join('theme-dark/ui_button_defaultmobile.png'))).to be(false)
1683
+ end
1684
+ end
1685
+ ```
1686
+
1687
+ Run: `bundle exec rspec spec/integration/tasks_spec.rb`
1688
+ Expected: PASS.
1689
+
1690
+ - [ ] **Step 4: Commit**
1691
+
1692
+ ```bash
1693
+ git add lib/tasks/lookbook_visual_tester.rake spec/integration/tasks_spec.rb
1694
+ git commit -m "fix(approve): exact + suffix match for namespaced previews"
1695
+ ```
1696
+
1697
+ ---
1698
+
1699
+ ## Task 10: Update README and CHANGELOG
1700
+
1701
+ **Files:**
1702
+ - Modify: `README.md`
1703
+ - Modify: `CHANGELOG.md`
1704
+
1705
+ - [ ] **Step 1: Update README system dependencies**
1706
+
1707
+ Remove the `imagemagick`/`xclip` system dependency block. Replace with:
1708
+
1709
+ > This gem uses Ferrum (Chrome) and ChunkyPNG. You need a Chrome-compatible browser installed locally. No ImageMagick or xclip required. Clipboard support via `xclip` is optional and opt-in (`config.copy_to_clipboard = true`).
1710
+
1711
+ - [ ] **Step 2: Update the README configuration example**
1712
+
1713
+ ```ruby
1714
+ LookbookVisualTester.configure do |config|
1715
+ config.lookbook_host = "http://localhost:5000"
1716
+ config.base_path = "coverage/screenshots"
1717
+ config.threads = 4
1718
+ config.wait_time = 0.5
1719
+ config.tolerance = 0.0
1720
+ config.copy_to_clipboard = false # default; opt in to xclip
1721
+ config.preview_checker_setup = -> {
1722
+ # Provide auth/helpers for deep checks, e.g.:
1723
+ # Current.user = LookbookFixtures.demo_user
1724
+ }
1725
+ end
1726
+ ```
1727
+
1728
+ - [ ] **Step 3: Document the new task**
1729
+
1730
+ ```bash
1731
+ bundle exec rake lookbook:server_and_test
1732
+ ```
1733
+
1734
+ > Starts Rails, waits for Lookbook to be reachable, runs `lookbook:test`, and stops the server. Set `LOOKBOOK_SERVER_TIMEOUT` (seconds) and optional `LOOKBOOK_SERVER_LOG` (path to capture server output) as needed.
1735
+
1736
+ Also remove the "Compatible with both Lookbook 1.x (`examples`) and 2.x (`scenarios`)" line from the features list — only Lookbook 2.x is now supported.
1737
+
1738
+ - [ ] **Step 4: Update CHANGELOG**
1739
+
1740
+ Add an entry documenting:
1741
+ - **Removed:** `cuprite` runtime dependency; legacy `SessionManager`/`CapybaraSetup`/`ScreenshotTaker`/`BaselineManager`; ImageMagick `convert` and `compare` shell calls; hard-coded Devise/Pundit auth mocking; Lookbook 1.x `examples` fallback; `$stdout` mutation in rake tasks; `check_preview_controller_config` introspection.
1742
+ - **Added:** pure-Ruby `ImageTrimmer` (ChunkyPNG); `config.preview_checker_setup` host hook; `ServerTestRunner` and `rake lookbook:server_and_test`; `Runner#output` injection.
1743
+ - **Changed:** `copy_to_clipboard` defaults to `false`; `automatic_run` parsed as boolean; auto-run hook now uses the Ferrum `Runner`; `lookbook:approve` matches namespaced previews by exact base name or `_suffix`.
1744
+
1745
+ - [ ] **Step 5: Run the full suite**
1746
+
1747
+ ```bash
1748
+ bundle exec rspec
1749
+ ```
1750
+
1751
+ Expected: All unit specs pass; the Ferrum/Chrome integration spec may fail in headless environments without Chrome (allowed).
1752
+
1753
+ - [ ] **Step 6: Commit**
1754
+
1755
+ ```bash
1756
+ git add README.md CHANGELOG.md
1757
+ git commit -m "docs: update README and CHANGELOG for hardening changes"
1758
+ ```
1759
+
1760
+ ---
1761
+
1762
+ ## Self-Review Checklist
1763
+
1764
+ - [ ] **Spec coverage:** Every hardening issue is mapped:
1765
+ - ImageMagick removal → Tasks 4 & 5.
1766
+ - `cuprite`/legacy code removal + auto-run rewiring → Task 3.
1767
+ - Auth mocking → Task 6.
1768
+ - `$stdout` thread-safety → Tasks 5 & 7.
1769
+ - Running server requirement → Task 8.
1770
+ - Namespaced approval → Task 9.
1771
+ - `rubocop` test-env crash → Task 2.
1772
+ - Lookbook 1.x fallback removal → Tasks 5, 6, 7.
1773
+ - Dead `BaselineManager` → Task 3.
1774
+ - [ ] **Placeholder scan:** No `TBD`, `TODO`, or vague requirements. Every step includes concrete file paths or code.
1775
+ - [ ] **Type consistency:** `preview_checker_setup` is consistently a callable/`nil`. `Runner` output is consistently an IO-like object. Approval uses `Pathname` relative paths consistently. `ServerTestRunner` uses the same `config.lookbook_host` string everywhere.
1776
+ - [ ] **Deletion safety:** Legacy files are only deleted after their references are removed from `lib/lookbook_visual_tester.rb` and `UpdatePreviews`/`Railtie` are rewired (Task 3 ordering).
1777
+ - [ ] **Test cycle:** Every task ends with a `bundle exec rspec` run for new/changed specs. The Ferrum/Chrome integration spec is an explicitly-allowed environmental failure.
1778
+ - [ ] **Driver thread-safety:** `record_mismatch` reads the per-thread driver via `Thread.current[:lookbook_visual_tester_driver]`, set in `run_scenario`, so concurrent runs do not share a driver reference.
1779
+
1780
+ ---
1781
+
1782
+ ## Execution Handoff
1783
+
1784
+ **Plan saved to:** `docs/superpowers/plans/2026-08-09-lookbook-visual-tester-hardening.md`
1785
+
1786
+ Suggested execution order:
1787
+
1788
+ 1. Run the full test suite as a baseline: `bundle exec rspec`. (Note: before Task 2 this may segfault on Ruby 3.4 due to `rubocop`/`racc`; that is expected and fixed by Task 2.)
1789
+ 2. Work through Tasks 1 → 10 in order. Each task is independently reviewable and ends with a green test run (except the allowed Ferrum/Chrome integration spec).
1790
+ 3. After Task 10, do a final `bundle exec rspec` and confirm the CHANGELOG is accurate.
1791
+
1792
+ Once the gem is hardened, the CaptainTandem integration (initializer, `config/lookbook.rb`, `preview_checker_setup` providing `Current.user`, wrapper task, baseline git workflow) can be written so agents can run it end-to-end.