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.
- checksums.yaml +4 -4
- data/.env.example +13 -0
- data/CHANGELOG.md +31 -0
- data/README.md +18 -9
- data/Rakefile +53 -0
- data/docs/superpowers/plans/2026-08-09-lookbook-visual-tester-hardening.md +1792 -0
- data/lib/lookbook_visual_tester/browser_discovery.rb +31 -0
- data/lib/lookbook_visual_tester/configuration.rb +11 -4
- data/lib/lookbook_visual_tester/drivers/ferrum_driver.rb +8 -4
- data/lib/lookbook_visual_tester/preview_checker.rb +43 -213
- data/lib/lookbook_visual_tester/railtie.rb +2 -8
- data/lib/lookbook_visual_tester/runner.rb +108 -92
- data/lib/lookbook_visual_tester/server_test_runner.rb +102 -0
- data/lib/lookbook_visual_tester/services/image_trimmer.rb +59 -0
- data/lib/lookbook_visual_tester/update_previews.rb +3 -12
- data/lib/lookbook_visual_tester/version.rb +1 -1
- data/lib/lookbook_visual_tester.rb +4 -0
- data/lib/tasks/lookbook_visual_tester.rake +50 -51
- metadata +38 -51
- data/lib/lookbook_visual_tester/baseline_manager.rb +0 -34
- data/lib/lookbook_visual_tester/capybara_setup.rb +0 -29
- data/lib/lookbook_visual_tester/screenshot_taker.rb +0 -118
- data/lib/lookbook_visual_tester/session_manager.rb +0 -17
|
@@ -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.
|