lookbook_visual_tester 0.1.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +0 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +39 -3
- data/README.md +144 -32
- data/RELEASING.md +31 -0
- data/Rakefile +16 -5
- data/lib/lookbook_visual_tester/check_reporter.rb +81 -0
- data/lib/lookbook_visual_tester/configuration.rb +27 -4
- data/lib/lookbook_visual_tester/driver.rb +51 -0
- data/lib/lookbook_visual_tester/drivers/ferrum_driver.rb +111 -0
- data/lib/lookbook_visual_tester/json_output_handler.rb +9 -0
- data/lib/lookbook_visual_tester/preview_checker.rb +260 -0
- data/lib/lookbook_visual_tester/railtie.rb +5 -2
- data/lib/lookbook_visual_tester/report_generator.rb +25 -48
- data/lib/lookbook_visual_tester/runner.rb +224 -0
- data/lib/lookbook_visual_tester/scenario_finder.rb +7 -2
- data/lib/lookbook_visual_tester/scenario_run.rb +26 -8
- data/lib/lookbook_visual_tester/services/image_comparator.rb +66 -0
- data/lib/lookbook_visual_tester/templates/preview_check_report.html.tt +63 -0
- data/lib/lookbook_visual_tester/templates/report.html.erb +206 -0
- data/lib/lookbook_visual_tester/update_previews.rb +3 -2
- data/lib/lookbook_visual_tester/variant_resolver.rb +62 -0
- data/lib/lookbook_visual_tester/version.rb +1 -1
- data/lib/lookbook_visual_tester.rb +11 -3
- data/lib/tasks/lookbook_visual_tester.rake +293 -58
- metadata +32 -22
- data/tasks/lookbook_visual_tester.rake +0 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
require 'concurrent'
|
|
2
|
+
require 'benchmark'
|
|
3
|
+
|
|
4
|
+
module LookbookVisualTester
|
|
5
|
+
class PreviewChecker
|
|
6
|
+
CheckResult = Struct.new(:preview_name, :example_name, :status, :error, :backtrace, :duration,
|
|
7
|
+
keyword_init: true)
|
|
8
|
+
MissingResult = Struct.new(:component_path, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(config = LookbookVisualTester.config)
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def check
|
|
15
|
+
run_checks(:basic_check)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def deep_check
|
|
19
|
+
# Ensure custom setup is run before deep checks
|
|
20
|
+
run_setup
|
|
21
|
+
run_checks(:deep_render_check)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def missing
|
|
25
|
+
components_dir = Rails.root.join(@config.components_folder)
|
|
26
|
+
# Assuming standard structure: test/components/previews for previews
|
|
27
|
+
# But Lookbook can be configured differently. We should use Lookbook's config if possible to know where previews are.
|
|
28
|
+
# For now, let's stick to the user's script logic which assumes standard paths or iterate through loaded components.
|
|
29
|
+
|
|
30
|
+
# Better approach: Iterate through all known components and check if they have a preview.
|
|
31
|
+
# However, "all known components" might be hard to get if they aren't loaded.
|
|
32
|
+
# Let's use the file system approach as in the user script.
|
|
33
|
+
|
|
34
|
+
components = Dir.glob(File.join(components_dir, '**', '*_component.rb'))
|
|
35
|
+
previews_dir = Rails.root.join('test/components/previews') # Default, maybe make configurable?
|
|
36
|
+
|
|
37
|
+
# Trying to find where previews are located from Rails config if possible
|
|
38
|
+
if defined?(Rails) && Rails.application.config.view_component.preview_paths.any?
|
|
39
|
+
# Use simple heuristic: first path
|
|
40
|
+
previews_dir = Pathname.new(Rails.application.config.view_component.preview_paths.first)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
missing = []
|
|
44
|
+
components.each do |component_path|
|
|
45
|
+
next if component_path.end_with?('application_component.rb')
|
|
46
|
+
next if component_path.include?('/concerns/')
|
|
47
|
+
|
|
48
|
+
relative_path = Pathname.new(component_path).relative_path_from(components_dir).to_s
|
|
49
|
+
preview_relative_path = relative_path.sub('_component.rb', '_component_preview.rb')
|
|
50
|
+
preview_path = File.join(previews_dir, preview_relative_path)
|
|
51
|
+
|
|
52
|
+
missing << MissingResult.new(component_path: relative_path) unless File.exist?(preview_path)
|
|
53
|
+
end
|
|
54
|
+
missing
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def run_checks(check_method)
|
|
60
|
+
previews = Lookbook.previews
|
|
61
|
+
results = []
|
|
62
|
+
|
|
63
|
+
# We want to flatten the work items: (PreviewClass, example_name)
|
|
64
|
+
work_items = []
|
|
65
|
+
previews.each do |preview|
|
|
66
|
+
# preview is a Lookbook::Preview object which wraps the class
|
|
67
|
+
# But for checking we might want the class directly or iterate examples
|
|
68
|
+
examples = preview.examples
|
|
69
|
+
examples.each do |example|
|
|
70
|
+
work_items << { preview: preview, example: example }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if @config.threads > 1
|
|
75
|
+
pool = Concurrent::FixedThreadPool.new(@config.threads)
|
|
76
|
+
promises = work_items.map do |item|
|
|
77
|
+
Concurrent::Promises.future_on(pool) do
|
|
78
|
+
measure_and_send(item[:preview], item[:example], check_method)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
results = Concurrent::Promises.zip(*promises).value
|
|
82
|
+
pool.shutdown
|
|
83
|
+
pool.wait_for_termination
|
|
84
|
+
else
|
|
85
|
+
results = work_items.map do |item|
|
|
86
|
+
measure_and_send(item[:preview], item[:example], check_method)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
results
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def measure_and_send(preview, example, method_name)
|
|
94
|
+
result = nil
|
|
95
|
+
time = Benchmark.realtime do
|
|
96
|
+
result = send(method_name, preview, example)
|
|
97
|
+
end
|
|
98
|
+
result.duration = time
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def basic_check(preview, example)
|
|
103
|
+
# user script logic:
|
|
104
|
+
# preview_instance = preview_class.new
|
|
105
|
+
# component = preview_instance.public_send(preview_example)
|
|
106
|
+
# if component.respond_to?(:render_in) ...
|
|
107
|
+
|
|
108
|
+
# Lookbook::Preview wrapper might help, but let's go to the class
|
|
109
|
+
preview_class = preview.preview_class
|
|
110
|
+
example_name = example.name
|
|
111
|
+
|
|
112
|
+
begin
|
|
113
|
+
preview_instance = preview_class.new
|
|
114
|
+
preview_instance.public_send(example_name)
|
|
115
|
+
|
|
116
|
+
# We don't render, just verify we can call it.
|
|
117
|
+
CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed)
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
CheckResult.new(preview_name: preview.name, example_name: example_name, status: :failed,
|
|
120
|
+
error: e.message, backtrace: e.backtrace)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def deep_render_check(preview, example)
|
|
125
|
+
preview_class = preview.preview_class
|
|
126
|
+
example_name = example.name
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
preview_instance = preview_class.new
|
|
130
|
+
result = preview_instance.public_send(example_name)
|
|
131
|
+
|
|
132
|
+
if result.respond_to?(:render_in)
|
|
133
|
+
# Mock current_user/pundit if needed on the component itself if possible
|
|
134
|
+
# But mainly we render it with a view context
|
|
135
|
+
view_context = setup_view_context
|
|
136
|
+
|
|
137
|
+
# Inject mocks into result if it supports it or reliance on global view_context
|
|
138
|
+
# The user script defines singleton methods on the result.
|
|
139
|
+
if @mocks
|
|
140
|
+
@mocks.each do |key, value|
|
|
141
|
+
if result.respond_to?(key) || !result.respond_to?(key) # Force define
|
|
142
|
+
result.define_singleton_method(key) { value }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
result.render_in(view_context)
|
|
148
|
+
elsif result.is_a?(String)
|
|
149
|
+
# Rendered string, good.
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
CheckResult.new(preview_name: preview.name, example_name: example_name, status: :passed)
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
CheckResult.new(preview_name: preview.name, example_name: example_name, status: :failed,
|
|
155
|
+
error: e.message, backtrace: e.backtrace)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run_setup
|
|
160
|
+
if @config.preview_checker_setup
|
|
161
|
+
@config.preview_checker_setup.call
|
|
162
|
+
else
|
|
163
|
+
default_setup
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def default_setup
|
|
168
|
+
# Logic from the user's script
|
|
169
|
+
# We need to set up a controller and view context globally or for use in checks
|
|
170
|
+
|
|
171
|
+
# This part is tricky because we need to make these available to the `deep_render_check` method.
|
|
172
|
+
# We can store the view_context in an instance variable or re-create it.
|
|
173
|
+
|
|
174
|
+
# Let's perform the class-level mocks here (User, etc.)
|
|
175
|
+
|
|
176
|
+
# Mock User
|
|
177
|
+
unless defined?(User)
|
|
178
|
+
# Defining a dummy user if not exists is risky if the app doesn't have User.
|
|
179
|
+
# But the script assumes User exists or creates a mock.
|
|
180
|
+
# Let's create a OpenStruct-like mock for typical Devise/Pundit usage.
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The script logic:
|
|
184
|
+
# controller = ApplicationController.new ...
|
|
185
|
+
# We will do this in `setup_view_context` called per check or once?
|
|
186
|
+
# `ApplicationController` might not be thread safe if we modify it?
|
|
187
|
+
# Actually we create a new controller instance.
|
|
188
|
+
|
|
189
|
+
# Define @mocks to be injected
|
|
190
|
+
@mocks = {}
|
|
191
|
+
@mocks[:current_user] = build_mock_user
|
|
192
|
+
@mocks[:pundit_user] = @mocks[:current_user]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def build_mock_user
|
|
196
|
+
# Try to load a real user or build a struct
|
|
197
|
+
if defined?(User) && User.respond_to?(:first) && User.first
|
|
198
|
+
User.first
|
|
199
|
+
else
|
|
200
|
+
# Fallback mock
|
|
201
|
+
u = Object.new
|
|
202
|
+
u.define_singleton_method(:email) { 'test@example.com' }
|
|
203
|
+
u.define_singleton_method(:id) { 1 }
|
|
204
|
+
# Add other common methods as needed or let them fail/mock dynamic
|
|
205
|
+
u
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def setup_view_context
|
|
210
|
+
# We need a controller to get a view context
|
|
211
|
+
controller = if defined?(ApplicationController)
|
|
212
|
+
ApplicationController.new
|
|
213
|
+
else
|
|
214
|
+
ActionController::Base.new
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
controller.request = ActionDispatch::TestRequest.create
|
|
218
|
+
if controller.request.env
|
|
219
|
+
controller.request.env['rack.session'] = {}
|
|
220
|
+
controller.request.env['rack.session.options'] = { id: SecureRandom.uuid }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Devise mapping
|
|
224
|
+
if defined?(Devise)
|
|
225
|
+
controller.request.env['devise.mapping'] = Devise.mappings[:user] if Devise.mappings[:user]
|
|
226
|
+
|
|
227
|
+
# Mock warden
|
|
228
|
+
warden = Object.new
|
|
229
|
+
warden.define_singleton_method(:authenticate!) { |*| true }
|
|
230
|
+
warden.define_singleton_method(:authenticate) { |*| true }
|
|
231
|
+
warden.define_singleton_method(:user) { |*| @mocks[:current_user] }
|
|
232
|
+
controller.request.env['warden'] = warden
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
view_context = controller.view_context
|
|
236
|
+
|
|
237
|
+
# Add helper methods to view_context
|
|
238
|
+
# We can use `class_eval` on the singleton class of the view context
|
|
239
|
+
vc_singleton = view_context.singleton_class
|
|
240
|
+
|
|
241
|
+
if @mocks
|
|
242
|
+
@mocks.each do |key, value|
|
|
243
|
+
vc_singleton.send(:define_method, key) { value }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Common auth helpers
|
|
248
|
+
vc_singleton.send(:define_method, :signed_in?) { true }
|
|
249
|
+
|
|
250
|
+
# Pundit policy mock
|
|
251
|
+
vc_singleton.send(:define_method, :policy) do |_record|
|
|
252
|
+
Struct.new(:show?, :index?, :create?, :update?, :destroy?, :edit?, :new?, :manage?).new(
|
|
253
|
+
true, true, true, true, true, true, true, true
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
view_context
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
# lib/lookbook_visual_tester/railtie.rb
|
|
2
2
|
|
|
3
|
-
require 'lookbook_visual_tester/capybara_setup'
|
|
3
|
+
# require 'lookbook_visual_tester/capybara_setup'
|
|
4
4
|
require 'lookbook_visual_tester/update_previews'
|
|
5
5
|
|
|
6
6
|
module LookbookVisualTester
|
|
7
7
|
class Railtie < ::Rails::Railtie
|
|
8
8
|
rake_tasks do
|
|
9
|
-
|
|
9
|
+
path = File.expand_path('../tasks/lookbook_visual_tester.rake', __dir__)
|
|
10
|
+
load path
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
initializer 'LookbookVisualTester.lookbook_after_change' do |_app|
|
|
13
14
|
Rails.logger.info "LookbookVisualTester initialized with host: #{LookbookVisualTester.config.lookbook_host}"
|
|
14
15
|
Lookbook.after_change do |app, changes|
|
|
16
|
+
next unless LookbookVisualTester.config.automatic_run
|
|
17
|
+
|
|
15
18
|
# get hash of content of modified files to see if has changed
|
|
16
19
|
modified = changes[:modified]
|
|
17
20
|
my_hash = modified.sort.map { |f| File.read(f) }.hash
|
|
@@ -1,57 +1,34 @@
|
|
|
1
|
+
require 'erb'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
1
4
|
module LookbookVisualTester
|
|
2
5
|
class ReportGenerator
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
TEMPLATE_PATH = File.expand_path("../templates/report.html.erb", __FILE__)
|
|
7
|
+
OUTPUT_PATH = "coverage/visual_report.html"
|
|
8
|
+
|
|
9
|
+
def initialize(results)
|
|
10
|
+
@results = results
|
|
11
|
+
@stats = {
|
|
12
|
+
total: results.size,
|
|
13
|
+
failed: results.count { |r| r.status == :failed },
|
|
14
|
+
new: results.count { |r| r.status == :new },
|
|
15
|
+
passed: results.count { |r| r.status == :passed }
|
|
16
|
+
}
|
|
11
17
|
end
|
|
12
18
|
|
|
13
|
-
def
|
|
14
|
-
File.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
file.puts "<head><meta charset='UTF-8'><title>Visual Regression Report</title></head>"
|
|
18
|
-
file.puts '<body>'
|
|
19
|
-
file.puts '<h1>Visual Regression Report</h1>'
|
|
20
|
-
file.puts '<ul>'
|
|
21
|
-
|
|
22
|
-
diff_files = Dir.glob(diff_dir.join('*_diff.png'))
|
|
23
|
-
diff_files.each do |diff_file|
|
|
24
|
-
filename = File.basename(diff_file)
|
|
25
|
-
# Extract preview and scenario names
|
|
26
|
-
preview_scenario = filename.sub('_diff.png', '')
|
|
27
|
-
preview, scenario = preview_scenario.split('_', 2)
|
|
28
|
-
|
|
29
|
-
baseline_image = baseline_dir.join("#{preview}_#{scenario}.png")
|
|
30
|
-
current_image = current_dir.join("#{preview}_#{scenario}.png")
|
|
19
|
+
def call
|
|
20
|
+
template = File.read(TEMPLATE_PATH)
|
|
21
|
+
renderer = ERB.new(template)
|
|
22
|
+
html = renderer.result(binding)
|
|
31
23
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
baseline_image_path = Pathname.new(baseline_image)
|
|
37
|
-
current_image_path = Pathname.new(current_image)
|
|
38
|
-
diff_file_path = Pathname.new(diff_file)
|
|
39
|
-
|
|
40
|
-
file.puts "<div><h3>Baseline</h3><img src='#{baseline_image_path.relative_path_from(base_path)}' alt='Baseline'></div>"
|
|
41
|
-
file.puts "<div><h3>Current</h3><img src='#{current_image_path.relative_path_from(base_path)}' alt='Current'></div>"
|
|
42
|
-
file.puts "<div><h3>Diff</h3><img src='#{diff_file_path.relative_path_from(base_path)}' alt='Diff'></div>"
|
|
43
|
-
file.puts '</div>'
|
|
44
|
-
file.puts '</li>'
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
file.puts '<li>No differences found!</li>' if diff_files.empty?
|
|
48
|
-
|
|
49
|
-
file.puts '</ul>'
|
|
50
|
-
file.puts '</body>'
|
|
51
|
-
file.puts '</html>'
|
|
52
|
-
end
|
|
24
|
+
FileUtils.mkdir_p("coverage")
|
|
25
|
+
File.write(OUTPUT_PATH, html)
|
|
26
|
+
puts "📊 Report generated at: #{OUTPUT_PATH}"
|
|
27
|
+
end
|
|
53
28
|
|
|
54
|
-
|
|
29
|
+
# Helper to generate the terminal command for the user
|
|
30
|
+
def approve_command(result)
|
|
31
|
+
"cp \"#{result.current_path}\" \"#{result.baseline_path}\""
|
|
55
32
|
end
|
|
56
33
|
end
|
|
57
34
|
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
require 'lookbook'
|
|
2
|
+
require 'json'
|
|
3
|
+
require_relative 'configuration'
|
|
4
|
+
require_relative 'scenario_run'
|
|
5
|
+
require_relative 'services/image_comparator'
|
|
6
|
+
require_relative 'drivers/ferrum_driver'
|
|
7
|
+
require_relative 'variant_resolver'
|
|
8
|
+
|
|
9
|
+
module LookbookVisualTester
|
|
10
|
+
class Runner
|
|
11
|
+
Result = Struct.new(:scenario_name, :status, :mismatch, :diff_path, :error, :baseline_path,
|
|
12
|
+
:current_path, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def initialize(config = LookbookVisualTester.config, pattern: nil)
|
|
15
|
+
@config = config
|
|
16
|
+
@pattern = pattern
|
|
17
|
+
@driver_pool = Queue.new
|
|
18
|
+
init_driver_pool
|
|
19
|
+
@results = []
|
|
20
|
+
@variants = load_variants
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
previews = Lookbook.previews
|
|
25
|
+
|
|
26
|
+
if @pattern.present?
|
|
27
|
+
previews = previews.select do |preview|
|
|
28
|
+
preview.label.downcase.include?(@pattern.downcase) ||
|
|
29
|
+
preview.name.downcase.include?(@pattern.downcase)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
puts "Found #{previews.count} previews matching '#{@pattern}'."
|
|
34
|
+
puts "Running against #{@variants.size} variant(s)."
|
|
35
|
+
|
|
36
|
+
@variants.each do |variant_input|
|
|
37
|
+
resolver = VariantResolver.new(variant_input)
|
|
38
|
+
variant_options = resolver.resolve
|
|
39
|
+
variant_slug = resolver.slug
|
|
40
|
+
width = resolver.width_in_pixels
|
|
41
|
+
|
|
42
|
+
puts " Variant: #{variant_slug.presence || 'Default'}"
|
|
43
|
+
|
|
44
|
+
if @config.threads > 1
|
|
45
|
+
run_concurrently(previews, variant_slug, variant_options, width)
|
|
46
|
+
else
|
|
47
|
+
run_sequentially(previews, variant_slug, variant_options, width)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@results
|
|
52
|
+
ensure
|
|
53
|
+
cleanup_drivers
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def load_variants
|
|
59
|
+
variants_json = ENV['VARIANTS'] || ENV.fetch('LOOKBOOK_VARIANTS', nil)
|
|
60
|
+
return [{}] if variants_json.blank?
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
JSON.parse(variants_json)
|
|
64
|
+
rescue JSON::ParserError
|
|
65
|
+
puts 'Invalid JSON in VARIANTS env var. Defaulting to standard run.'
|
|
66
|
+
[{}]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def run_sequentially(previews, variant_slug, variant_options, width)
|
|
71
|
+
previews.each do |preview|
|
|
72
|
+
group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
|
|
73
|
+
group.each do |scenario|
|
|
74
|
+
driver = checkout_driver
|
|
75
|
+
begin
|
|
76
|
+
@results << run_scenario(scenario, driver, variant_slug, variant_options, width)
|
|
77
|
+
ensure
|
|
78
|
+
return_driver(driver)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run_concurrently(previews, variant_slug, variant_options, width)
|
|
85
|
+
require 'concurrent-ruby'
|
|
86
|
+
pool = Concurrent::FixedThreadPool.new(@config.threads)
|
|
87
|
+
promises = []
|
|
88
|
+
|
|
89
|
+
previews.each do |preview|
|
|
90
|
+
group = preview.respond_to?(:scenarios) ? preview.scenarios : preview.examples
|
|
91
|
+
group.each do |scenario|
|
|
92
|
+
promises << Concurrent::Promises.future_on(pool) do
|
|
93
|
+
driver = checkout_driver
|
|
94
|
+
begin
|
|
95
|
+
run_scenario(scenario, driver, variant_slug, variant_options, width)
|
|
96
|
+
ensure
|
|
97
|
+
return_driver(driver)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Zip results from this concurrent batch into results
|
|
104
|
+
# Note: This aggregates results per variant loop.
|
|
105
|
+
@results.concat(Concurrent::Promises.zip(*promises).value)
|
|
106
|
+
pool.shutdown
|
|
107
|
+
pool.wait_for_termination
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def init_driver_pool
|
|
111
|
+
count = @config.threads > 1 ? @config.threads : 1
|
|
112
|
+
count.times do
|
|
113
|
+
@driver_pool << LookbookVisualTester::Drivers::FerrumDriver.new(@config)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def checkout_driver
|
|
118
|
+
@driver_pool.pop
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def return_driver(driver)
|
|
122
|
+
@driver_pool << driver
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def cleanup_drivers
|
|
126
|
+
until @driver_pool.empty?
|
|
127
|
+
driver = @driver_pool.pop
|
|
128
|
+
driver.cleanup
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def run_scenario(scenario, driver, variant_slug, variant_options, width)
|
|
133
|
+
run_data = ScenarioRun.new(scenario, variant_slug: variant_slug,
|
|
134
|
+
display_params: variant_options)
|
|
135
|
+
puts "Running visual test for: #{run_data.name} #{variant_slug.present? ? "[#{variant_slug}]" : ''}"
|
|
136
|
+
|
|
137
|
+
begin
|
|
138
|
+
driver_width = width || 1280
|
|
139
|
+
driver.resize_window(driver_width, 800)
|
|
140
|
+
driver.visit(run_data.preview_url)
|
|
141
|
+
|
|
142
|
+
# Determine paths
|
|
143
|
+
current_path = run_data.current_path
|
|
144
|
+
baseline_path = run_data.baseline_path
|
|
145
|
+
diff_path = @config.diff_dir.join(run_data.diff_filename)
|
|
146
|
+
# Update diff path to respect variant structure if needed?
|
|
147
|
+
# Actually ScenarioRun#diff_filename is just flat for now but let's fix that?
|
|
148
|
+
# ScenarioRun doesn't expose diff_path with slug. Let's fix that manually here if needed or update ScenarioRun.
|
|
149
|
+
# Wait, ScenarioRun stores baseline/current in folders but diff_filename is just name.
|
|
150
|
+
# We should probably put diffs in folders too.
|
|
151
|
+
# Let's adjust diff_path here:
|
|
152
|
+
if variant_slug.present?
|
|
153
|
+
diff_path = @config.diff_dir.join(variant_slug, run_data.diff_filename)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
FileUtils.mkdir_p(File.dirname(current_path))
|
|
157
|
+
FileUtils.mkdir_p(File.dirname(diff_path))
|
|
158
|
+
|
|
159
|
+
driver.save_screenshot(current_path.to_s)
|
|
160
|
+
|
|
161
|
+
# Trimming (Feature parity with legacy ScreenshotTaker)
|
|
162
|
+
if File.exist?(current_path)
|
|
163
|
+
system("convert #{current_path} -trim -bordercolor white -border 10x10 #{current_path}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
comparator = LookbookVisualTester::ImageComparator.new(
|
|
167
|
+
baseline_path.to_s,
|
|
168
|
+
current_path.to_s,
|
|
169
|
+
diff_path.to_s
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
result = comparator.call
|
|
173
|
+
|
|
174
|
+
status = :passed
|
|
175
|
+
mismatch = 0.0
|
|
176
|
+
error = nil
|
|
177
|
+
|
|
178
|
+
if result[:error]
|
|
179
|
+
if result[:error] == 'Baseline not found'
|
|
180
|
+
# First run, maybe auto-approve or just report
|
|
181
|
+
puts ' [NEW] Baseline not found. Saved current as potential baseline.'
|
|
182
|
+
status = :new
|
|
183
|
+
else
|
|
184
|
+
puts " [ERROR] #{result[:error]}"
|
|
185
|
+
status = :error
|
|
186
|
+
error = result[:error]
|
|
187
|
+
end
|
|
188
|
+
elsif result[:mismatch] > 0
|
|
189
|
+
mismatch = result[:mismatch]
|
|
190
|
+
puts " [FAIL] Mismatch: #{mismatch.round(2)}%. Diff saved to #{diff_path}"
|
|
191
|
+
status = :failed
|
|
192
|
+
|
|
193
|
+
# Clipboard (Feature parity with legacy ScreenshotTaker)
|
|
194
|
+
if @config.copy_to_clipboard
|
|
195
|
+
system("xclip -selection clipboard -t image/png -i #{current_path}")
|
|
196
|
+
end
|
|
197
|
+
else
|
|
198
|
+
puts ' [PASS] Identical.'
|
|
199
|
+
status = :passed
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
Result.new(
|
|
203
|
+
scenario_name: run_data.name,
|
|
204
|
+
status: status,
|
|
205
|
+
mismatch: mismatch,
|
|
206
|
+
diff_path: diff_path.to_s,
|
|
207
|
+
error: error,
|
|
208
|
+
baseline_path: baseline_path.to_s,
|
|
209
|
+
current_path: current_path.to_s
|
|
210
|
+
)
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
puts " [ERROR] Exception: #{e.message}"
|
|
213
|
+
puts e.backtrace.take(5)
|
|
214
|
+
Result.new(
|
|
215
|
+
scenario_name: run_data.name,
|
|
216
|
+
status: :error,
|
|
217
|
+
error: e.message,
|
|
218
|
+
baseline_path: run_data.baseline_path.to_s,
|
|
219
|
+
current_path: run_data.current_path.to_s
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -12,19 +12,24 @@ module LookbookVisualTester
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def regex
|
|
15
|
-
@regex = Regexp.new(
|
|
15
|
+
@regex = Regexp.new(clean_search.chars.join('.*'), Regexp::IGNORECASE)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def matched_previews
|
|
19
19
|
@matched_previews ||= previews.select { |preview| regex.match?(preview.name.downcase) }
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def clean_search
|
|
23
|
+
@clean_search ||= search.downcase.gsub(/[^a-z0-9\s]/, '').strip
|
|
24
|
+
end
|
|
25
|
+
|
|
22
26
|
def call
|
|
23
27
|
return nil if search.nil? || search == '' || previews.empty?
|
|
24
28
|
|
|
25
29
|
previews.each do |preview|
|
|
26
30
|
preview.scenarios.each do |scenario|
|
|
27
|
-
|
|
31
|
+
name = "#{preview.name} #{scenario.name}".downcase
|
|
32
|
+
return ScenarioRun.new(scenario) if regex.match?(name.downcase) #name.downcase.include?(clean_search)
|
|
28
33
|
end
|
|
29
34
|
end
|
|
30
35
|
|
|
@@ -2,17 +2,19 @@ require 'lookbook_visual_tester/configuration'
|
|
|
2
2
|
|
|
3
3
|
module LookbookVisualTester
|
|
4
4
|
class ScenarioRun
|
|
5
|
-
attr_reader :scenario, :preview
|
|
5
|
+
attr_reader :scenario, :preview, :variant_slug, :display_params
|
|
6
6
|
|
|
7
|
-
def initialize(scenario)
|
|
7
|
+
def initialize(scenario, variant_slug: nil, display_params: {})
|
|
8
8
|
@scenario = scenario
|
|
9
9
|
@preview = scenario.preview
|
|
10
|
+
@variant_slug = variant_slug
|
|
11
|
+
@display_params = display_params
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
LookbookVisualTester.config.logger.info " Scenario: #{scenario_name} #{variant_suffix}"
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def preview_name
|
|
15
|
-
preview.name.underscore
|
|
17
|
+
preview.name.underscore.gsub('/', '_')
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def scenario_name
|
|
@@ -39,18 +41,34 @@ module LookbookVisualTester
|
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def current_path
|
|
42
|
-
LookbookVisualTester.config.current_dir
|
|
44
|
+
base = LookbookVisualTester.config.current_dir
|
|
45
|
+
base = base.join(variant_slug) if variant_slug.present?
|
|
46
|
+
base.join(filename)
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
def baseline_path
|
|
46
|
-
LookbookVisualTester.config.baseline_dir
|
|
50
|
+
base = LookbookVisualTester.config.baseline_dir
|
|
51
|
+
base = base.join(variant_slug) if variant_slug.present?
|
|
52
|
+
base.join(filename)
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
def preview_url
|
|
56
|
+
params = { path: preview.lookup_path + '/' + scenario.name }
|
|
57
|
+
|
|
58
|
+
if display_params.any?
|
|
59
|
+
# Transform display_params { theme: 'dark' } -> { _display: { theme: 'dark' } }
|
|
60
|
+
params[:_display] = display_params
|
|
61
|
+
end
|
|
62
|
+
|
|
50
63
|
Lookbook::Engine.routes.url_helpers.lookbook_preview_url(
|
|
51
|
-
|
|
52
|
-
host: LookbookVisualTester.config.lookbook_host
|
|
64
|
+
params.merge(host: LookbookVisualTester.config.lookbook_host)
|
|
53
65
|
)
|
|
54
66
|
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def variant_suffix
|
|
71
|
+
variant_slug.present? ? "[#{variant_slug}]" : ''
|
|
72
|
+
end
|
|
55
73
|
end
|
|
56
74
|
end
|