capybara-screenshot-diff 1.10.3 → 1.12.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/CHANGELOG.md +64 -0
- data/Rakefile +29 -1
- data/capybara-screenshot-diff.gemspec +4 -3
- data/docs/RELEASE_PREP.md +58 -0
- data/docs/UPGRADING.md +390 -0
- data/docs/ci-integration.md +208 -0
- data/docs/configuration.md +379 -0
- data/docs/docker-testing.md +24 -0
- data/docs/drivers.md +102 -0
- data/docs/framework-setup.md +87 -0
- data/docs/images/snap_diff_web_ui.png +0 -0
- data/docs/organization.md +226 -0
- data/docs/reporters.md +46 -0
- data/docs/thread_safety.md +97 -0
- data/gems.rb +2 -1
- data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
- data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
- data/lib/capybara/screenshot/diff/comparison.rb +3 -0
- data/lib/capybara/screenshot/diff/difference.rb +40 -3
- data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
- data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
- data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +63 -36
- data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
- data/lib/capybara/screenshot/diff/vcs.rb +19 -52
- data/lib/capybara/screenshot/diff/version.rb +1 -1
- data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
- data/lib/capybara_screenshot_diff/cucumber.rb +2 -0
- data/lib/capybara_screenshot_diff/dsl.rb +102 -7
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
- data/lib/capybara_screenshot_diff/minitest.rb +4 -2
- data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
- data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
- data/lib/capybara_screenshot_diff/rspec.rb +12 -2
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +61 -23
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
- data/lib/capybara_screenshot_diff/snap.rb +14 -3
- data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
- data/lib/capybara_screenshot_diff/static.rb +11 -0
- data/lib/capybara_screenshot_diff.rb +30 -5
- metadata +47 -8
- data/lib/capybara/screenshot/diff/test_methods.rb +0 -157
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Screenshot
|
|
5
|
+
module Diff
|
|
6
|
+
# Handles image preprocessing operations (skip_area and median filtering)
|
|
7
|
+
#
|
|
8
|
+
# This class applies preprocessing filters to images before comparison,
|
|
9
|
+
# such as masking specific regions (skip_area) or applying noise reduction.
|
|
10
|
+
# It's designed to work with either direct image objects or with options.
|
|
11
|
+
class ImagePreprocessor
|
|
12
|
+
attr_reader :driver, :options
|
|
13
|
+
|
|
14
|
+
def initialize(driver, options = {})
|
|
15
|
+
@driver = driver
|
|
16
|
+
@options = options
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Process a comparison object directly
|
|
20
|
+
# This allows reusing the comparison's existing options
|
|
21
|
+
# @param [Comparison] comparison the comparison object
|
|
22
|
+
# @return [Comparison] the comparison object
|
|
23
|
+
def process_comparison(comparison)
|
|
24
|
+
# Process both images
|
|
25
|
+
comparison.base_image = process_image(comparison.base_image, comparison.base_image_path)
|
|
26
|
+
comparison.new_image = process_image(comparison.new_image, comparison.new_image_path)
|
|
27
|
+
|
|
28
|
+
comparison
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def process_image(image, path)
|
|
34
|
+
result = image
|
|
35
|
+
result = apply_skip_area(result) if skip_area
|
|
36
|
+
result = apply_median_filter(result, path) if median_filter_window_size
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def apply_skip_area(image)
|
|
41
|
+
skip_area.reduce(image) do |result, region|
|
|
42
|
+
driver.add_black_box(result, region)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def apply_median_filter(image, path)
|
|
47
|
+
if driver.supports?(:filter_image_with_median)
|
|
48
|
+
driver.filter_image_with_median(image, median_filter_window_size)
|
|
49
|
+
else
|
|
50
|
+
warn_about_skipped_median_filter(path)
|
|
51
|
+
image
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def warn_about_skipped_median_filter(path)
|
|
56
|
+
warn(
|
|
57
|
+
"[capybara-screenshot-diff] Median filter has been skipped for #{path} " \
|
|
58
|
+
"because it is not supported by #{driver.class}"
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def skip_area
|
|
63
|
+
options[:skip_area]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def median_filter_window_size
|
|
67
|
+
options[:median_filter_window_size]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -8,7 +8,8 @@ module Capybara::Screenshot::Diff
|
|
|
8
8
|
def initialize(difference)
|
|
9
9
|
@difference = difference
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
ext = comparison.new_image_path.extname.delete_prefix(".")
|
|
12
|
+
screenshot_format = difference.comparison.options[:screenshot_format] || (ext unless ext.empty?) || "png"
|
|
12
13
|
@annotated_image_path = comparison.new_image_path.sub_ext(".diff.#{screenshot_format}")
|
|
13
14
|
@annotated_base_image_path = comparison.base_image_path.sub_ext(".diff.#{screenshot_format}")
|
|
14
15
|
@heatmap_diff_path = comparison.new_image_path.sub_ext(".heatmap.diff.#{screenshot_format}")
|
|
@@ -32,6 +33,7 @@ module Capybara::Screenshot::Diff
|
|
|
32
33
|
def clean_tmp_files
|
|
33
34
|
annotated_base_image_path.unlink if annotated_base_image_path.exist?
|
|
34
35
|
annotated_image_path.unlink if annotated_image_path.exist?
|
|
36
|
+
heatmap_diff_path.unlink if heatmap_diff_path.exist?
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def build_error_for_different_dimensions
|
|
@@ -45,27 +47,23 @@ module Capybara::Screenshot::Diff
|
|
|
45
47
|
def annotate_and_save_images
|
|
46
48
|
save_annotation_for(new_image, annotated_image_path)
|
|
47
49
|
save_annotation_for(base_image, annotated_base_image_path)
|
|
48
|
-
save_heatmap_diff if difference.
|
|
50
|
+
save_heatmap_diff if difference.diff_mask
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def save_annotation_for(image, image_path)
|
|
52
54
|
image = annotate_difference(image, difference.region)
|
|
53
|
-
image = annotate_skip_areas(image, difference.skip_area) if difference.skip_area
|
|
55
|
+
image = annotate_skip_areas(image, difference.comparison.skip_area) if difference.comparison.skip_area
|
|
54
56
|
|
|
55
57
|
save(image, image_path.to_path)
|
|
56
58
|
end
|
|
57
59
|
|
|
58
|
-
DIFF_COLOR = [255, 0, 0, 255].freeze
|
|
59
|
-
|
|
60
60
|
def annotate_difference(image, region)
|
|
61
|
-
driver.draw_rectangles([image], region,
|
|
61
|
+
driver.draw_rectangles([image], region, CapybaraScreenshotDiff::RED_RGBA, offset: 1).first
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
SKIP_COLOR = [255, 192, 0, 255].freeze
|
|
65
|
-
|
|
66
64
|
def annotate_skip_areas(image, skip_areas)
|
|
67
65
|
skip_areas.reduce(image) do |memo, region|
|
|
68
|
-
driver.draw_rectangles([memo], region,
|
|
66
|
+
driver.draw_rectangles([memo], region, CapybaraScreenshotDiff::ORANGE_RGBA).first
|
|
69
67
|
end
|
|
70
68
|
end
|
|
71
69
|
|
|
@@ -80,7 +78,8 @@ module Capybara::Screenshot::Diff
|
|
|
80
78
|
"(#{difference.inspect})",
|
|
81
79
|
image_path.to_path,
|
|
82
80
|
annotated_base_image_path.to_path,
|
|
83
|
-
annotated_image_path.to_path
|
|
81
|
+
annotated_image_path.to_path,
|
|
82
|
+
heatmap_diff_path.to_path
|
|
84
83
|
].join(NEW_LINE)
|
|
85
84
|
end
|
|
86
85
|
|
|
@@ -88,7 +87,7 @@ module Capybara::Screenshot::Diff
|
|
|
88
87
|
|
|
89
88
|
def save_heatmap_diff
|
|
90
89
|
merged_image = driver.merge(new_image, base_image)
|
|
91
|
-
highlighted_mask = driver.highlight_mask(difference.
|
|
90
|
+
highlighted_mask = driver.highlight_mask(difference.diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
|
|
92
91
|
|
|
93
92
|
save(highlighted_mask, heatmap_diff_path.to_path)
|
|
94
93
|
end
|
|
@@ -21,37 +21,80 @@ module Capybara
|
|
|
21
21
|
@snapshot = CapybaraScreenshotDiff::SnapManager.snapshot(screenshot_full_name, @screenshot_format)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
def build_screenshot_assertion(skip_stack_frames: 0)
|
|
25
|
+
check_window_size!
|
|
26
|
+
prepare_screenshot_options
|
|
27
|
+
check_base_screenshot
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options)
|
|
30
|
+
|
|
31
|
+
capture_screenshot(capture_options, comparison_options)
|
|
32
|
+
|
|
33
|
+
# Pre-computation: No need to compare without base screenshot
|
|
34
|
+
# NOTE: Consider to return PreValid Assertion Value Object with hard coded valid result
|
|
35
|
+
return unless need_to_compare?
|
|
36
|
+
|
|
37
|
+
create_screenshot_assertion(skip_stack_frames + 1, comparison_options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def need_to_compare?
|
|
43
|
+
@snapshot.base_path.exist?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_window_size!
|
|
47
|
+
if BrowserHelpers.window_size_is_wrong?(Screenshot.window_size)
|
|
48
|
+
current_size = BrowserHelpers.selenium? ?
|
|
49
|
+
BrowserHelpers.session.driver.browser.manage.window.size.to_s :
|
|
50
|
+
"unknown"
|
|
51
|
+
|
|
52
|
+
raise CapybaraScreenshotDiff::WindowSizeMismatchError.new(<<~ERROR.chomp, caller)
|
|
53
|
+
Window size mismatch detected!
|
|
54
|
+
Expected: #{Screenshot.window_size.inspect}
|
|
55
|
+
Actual: #{current_size}
|
|
56
|
+
|
|
57
|
+
Screenshots cannot be compared when window sizes don't match.
|
|
58
|
+
Please ensure the browser window is properly sized before taking screenshots.
|
|
59
|
+
ERROR
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def prepare_screenshot_options
|
|
29
64
|
area_calculator = AreaCalculator.new(driver_options.delete(:crop), driver_options[:skip_area])
|
|
30
|
-
driver_options[:crop] = area_calculator.calculate_crop
|
|
31
65
|
|
|
32
|
-
|
|
33
|
-
# Allow nil or single or multiple areas
|
|
66
|
+
driver_options[:crop] = area_calculator.calculate_crop
|
|
34
67
|
driver_options[:skip_area] = area_calculator.calculate_skip_area
|
|
35
68
|
driver_options[:driver] = Drivers.for(driver_options[:driver])
|
|
69
|
+
end
|
|
36
70
|
|
|
71
|
+
def check_base_screenshot
|
|
37
72
|
@snapshot.checkout_base_screenshot
|
|
38
73
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# Pre-computation: No need to compare without base screenshot
|
|
48
|
-
return unless @snapshot.base_path.exist?
|
|
74
|
+
if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist?
|
|
75
|
+
raise CapybaraScreenshotDiff::ExpectationNotMet.new(<<~ERROR.chomp, caller)
|
|
76
|
+
No existing screenshot found for #{@snapshot.base_path}!
|
|
77
|
+
To record baselines: RECORD_SCREENSHOTS=1 bundle exec rake test
|
|
78
|
+
To allow new screenshots: Capybara::Screenshot::Diff.fail_if_new = false
|
|
79
|
+
ERROR
|
|
80
|
+
end
|
|
81
|
+
end
|
|
49
82
|
|
|
50
|
-
|
|
51
|
-
|
|
83
|
+
def capture_screenshot(capture_options, comparison_options)
|
|
84
|
+
screenshoter = if capture_options[:stability_time_limit]
|
|
85
|
+
StableScreenshoter.new(capture_options, comparison_options)
|
|
86
|
+
else
|
|
87
|
+
Diff.screenshoter.new(capture_options, comparison_options)
|
|
88
|
+
end
|
|
89
|
+
screenshoter.take_comparison_screenshot(@snapshot)
|
|
52
90
|
end
|
|
53
91
|
|
|
54
|
-
|
|
92
|
+
def create_screenshot_assertion(skip_stack_frames, comparison_options)
|
|
93
|
+
assertion = CapybaraScreenshotDiff::ScreenshotAssertion.new(screenshot_full_name)
|
|
94
|
+
assertion.caller = caller(skip_stack_frames + 1)
|
|
95
|
+
assertion.compare = ImageCompare.new(@snapshot.path, @snapshot.base_path, comparison_options)
|
|
96
|
+
assertion
|
|
97
|
+
end
|
|
55
98
|
|
|
56
99
|
def extract_capture_and_comparison_options!(driver_options = {})
|
|
57
100
|
[
|
|
@@ -68,22 +111,6 @@ module Capybara
|
|
|
68
111
|
driver_options
|
|
69
112
|
]
|
|
70
113
|
end
|
|
71
|
-
|
|
72
|
-
# Try to get screenshot from browser.
|
|
73
|
-
# On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts
|
|
74
|
-
# On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug
|
|
75
|
-
def take_comparison_screenshot(capture_options, comparison_options, snapshot = nil)
|
|
76
|
-
screenshoter = build_screenshoter_for(capture_options, comparison_options)
|
|
77
|
-
screenshoter.take_comparison_screenshot(snapshot)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def build_screenshoter_for(capture_options, comparison_options = {})
|
|
81
|
-
if capture_options[:stability_time_limit]
|
|
82
|
-
StableScreenshoter.new(capture_options, comparison_options)
|
|
83
|
-
else
|
|
84
|
-
Diff.screenshoter.new(capture_options, comparison_options[:driver])
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
114
|
end
|
|
88
115
|
end
|
|
89
116
|
end
|
|
@@ -8,9 +8,11 @@ module Capybara
|
|
|
8
8
|
class Screenshoter
|
|
9
9
|
attr_reader :capture_options, :driver
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# @param capture_options [Hash] Options for capturing (window_size, wait, etc.)
|
|
12
|
+
# @param comparison_options [Hash] Options for image comparison (driver, tolerance, etc.)
|
|
13
|
+
def initialize(capture_options, comparison_options = {})
|
|
12
14
|
@capture_options = capture_options
|
|
13
|
-
@driver =
|
|
15
|
+
@driver = Diff::Drivers.for(comparison_options)
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def crop
|
|
@@ -30,7 +32,7 @@ module Capybara
|
|
|
30
32
|
# On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug
|
|
31
33
|
def take_comparison_screenshot(snapshot)
|
|
32
34
|
capture_screenshot_at(snapshot)
|
|
33
|
-
snapshot.cleanup_attempts
|
|
35
|
+
snapshot.cleanup_attempts!
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
PNG_EXTENSION = ".png"
|
|
@@ -70,9 +72,8 @@ module Capybara
|
|
|
70
72
|
|
|
71
73
|
blurred_input = BrowserHelpers.blur_from_focused_element if Screenshot.blur_active_element
|
|
72
74
|
|
|
73
|
-
if Screenshot.hide_caret
|
|
74
|
-
|
|
75
|
-
end
|
|
75
|
+
BrowserHelpers.hide_caret if Screenshot.hide_caret
|
|
76
|
+
BrowserHelpers.disable_animations if Screenshot.disable_animations
|
|
76
77
|
|
|
77
78
|
blurred_input
|
|
78
79
|
end
|
|
@@ -86,7 +87,7 @@ module Capybara
|
|
|
86
87
|
break unless pending_image
|
|
87
88
|
|
|
88
89
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at
|
|
89
|
-
raise CapybaraScreenshotDiff::ExpectationNotMet
|
|
90
|
+
raise CapybaraScreenshotDiff::ExpectationNotMet.new("Images have not been loaded after #{timeout}s: #{pending_image.inspect}", caller)
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
sleep 0.025
|
|
@@ -101,7 +102,7 @@ module Capybara
|
|
|
101
102
|
# Load saved screenshot and pre-process it
|
|
102
103
|
process_screenshot(tmpfile.path, screenshot_path)
|
|
103
104
|
ensure
|
|
104
|
-
|
|
105
|
+
tmpfile&.close!
|
|
105
106
|
end
|
|
106
107
|
|
|
107
108
|
def capture_screenshot_at(snapshot)
|
|
@@ -25,8 +25,7 @@ module Capybara
|
|
|
25
25
|
|
|
26
26
|
@comparison_options = comparison_options
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
@screenshoter = Diff.screenshoter.new(capture_options.except(:stability_time_limit), driver)
|
|
28
|
+
@screenshoter = Diff.screenshoter.new(capture_options.except(:stability_time_limit), @comparison_options)
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
# Takes a comparison screenshot ensuring page stability
|
|
@@ -51,7 +50,7 @@ module Capybara
|
|
|
51
50
|
snapshot.commit_last_attempt
|
|
52
51
|
|
|
53
52
|
# cleanup all previous attempts
|
|
54
|
-
snapshot.cleanup_attempts
|
|
53
|
+
snapshot.cleanup_attempts!
|
|
55
54
|
end
|
|
56
55
|
|
|
57
56
|
def take_stable_screenshot(snapshot)
|
|
@@ -59,16 +58,15 @@ module Capybara
|
|
|
59
58
|
deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
|
|
60
59
|
|
|
61
60
|
# Cleanup all previous attempts for sure
|
|
62
|
-
snapshot.cleanup_attempts
|
|
63
|
-
|
|
64
|
-
0.step do |i|
|
|
65
|
-
# FIXME: it should be wait, and wait should be replaced with stability_time_limit
|
|
66
|
-
sleep(stability_time_limit) unless i == 0 # test prev_attempt_path is nil
|
|
61
|
+
snapshot.cleanup_attempts!
|
|
67
62
|
|
|
63
|
+
loop do
|
|
68
64
|
attempt_next_screenshot(snapshot)
|
|
69
65
|
|
|
70
66
|
return true if attempt_successful?(snapshot)
|
|
71
67
|
return false if timeout?(deadline_at)
|
|
68
|
+
|
|
69
|
+
sleep(stability_time_limit)
|
|
72
70
|
end
|
|
73
71
|
end
|
|
74
72
|
|
|
@@ -100,7 +98,7 @@ module Capybara
|
|
|
100
98
|
attempts_reporter = CapybaraScreenshotDiff::AttemptsReporter.new(snapshot, @comparison_options, {wait: wait, stability_time_limit: stability_time_limit})
|
|
101
99
|
|
|
102
100
|
# TODO: Move fail to the queue after tests passed
|
|
103
|
-
raise CapybaraScreenshotDiff::UnstableImage
|
|
101
|
+
raise CapybaraScreenshotDiff::UnstableImage.new(attempts_reporter.generate, caller)
|
|
104
102
|
end
|
|
105
103
|
end
|
|
106
104
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
3
4
|
require_relative "os"
|
|
4
5
|
|
|
5
6
|
module Capybara
|
|
@@ -7,64 +8,30 @@ module Capybara
|
|
|
7
8
|
module Diff
|
|
8
9
|
module Vcs
|
|
9
10
|
def self.checkout_vcs(root, screenshot_path, checkout_path)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def self.restore_git_revision(screenshot_path, checkout_path = screenshot_path, root:)
|
|
24
|
-
vcs_file_path = screenshot_path.relative_path_from(root)
|
|
25
|
-
redirect_target = "#{checkout_path} #{SILENCE_ERRORS}"
|
|
26
|
-
show_command = "git show HEAD~0:./#{vcs_file_path}"
|
|
27
|
-
|
|
28
|
-
Dir.chdir(root) do
|
|
29
|
-
if Screenshot.use_lfs
|
|
30
|
-
system("#{show_command} > #{checkout_path}.tmp #{SILENCE_ERRORS}", exception: !!ENV["DEBUG"])
|
|
31
|
-
|
|
32
|
-
`git lfs smudge < #{checkout_path}.tmp > #{redirect_target}` if $CHILD_STATUS == 0
|
|
33
|
-
|
|
34
|
-
File.delete "#{checkout_path}.tmp"
|
|
35
|
-
else
|
|
36
|
-
system("#{show_command} > #{redirect_target}", exception: !!ENV["DEBUG"])
|
|
11
|
+
root_path = root.to_s
|
|
12
|
+
git_root, _, status = Open3.capture3("git", "-C", root_path, "rev-parse", "--show-toplevel")
|
|
13
|
+
return false unless status.success?
|
|
14
|
+
|
|
15
|
+
git_root = git_root.chomp
|
|
16
|
+
vcs_file_path = Pathname.new(screenshot_path).expand_path.relative_path_from(Pathname.new(git_root)).to_s
|
|
17
|
+
|
|
18
|
+
if Screenshot.use_lfs
|
|
19
|
+
tmp_path = "#{checkout_path}.tmp"
|
|
20
|
+
success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: tmp_path, err: File::NULL)
|
|
21
|
+
if success
|
|
22
|
+
system("git", "-C", root_path, "lfs", "smudge", in: tmp_path, out: checkout_path.to_s, err: File::NULL)
|
|
37
23
|
end
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if $CHILD_STATUS != 0
|
|
41
|
-
checkout_path.delete if checkout_path.exist?
|
|
42
|
-
false
|
|
24
|
+
File.delete(tmp_path) if File.exist?(tmp_path)
|
|
43
25
|
else
|
|
44
|
-
|
|
26
|
+
success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: checkout_path.to_s, err: File::NULL)
|
|
45
27
|
end
|
|
46
|
-
end
|
|
47
28
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
FileUtils.cp(committed_file_name, checkout_path)
|
|
52
|
-
return true
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
svn_info = `svn info #{screenshot_path} #{SILENCE_ERRORS}`
|
|
56
|
-
unless svn_info.empty?
|
|
57
|
-
wc_root = svn_info.slice(/(?<=Working Copy Root Path: ).*$/)
|
|
58
|
-
checksum = svn_info.slice(/(?<=Checksum: ).*$/)
|
|
59
|
-
|
|
60
|
-
if checksum
|
|
61
|
-
committed_file_name = "#{wc_root}/.svn/pristine/#{checksum[0..1]}/#{checksum}.svn-base"
|
|
62
|
-
FileUtils.cp(committed_file_name, checkout_path)
|
|
63
|
-
return true
|
|
64
|
-
end
|
|
29
|
+
unless success
|
|
30
|
+
checkout_path.delete if checkout_path.exist?
|
|
31
|
+
return false
|
|
65
32
|
end
|
|
66
33
|
|
|
67
|
-
|
|
34
|
+
true
|
|
68
35
|
end
|
|
69
36
|
end
|
|
70
37
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CapybaraScreenshotDiff
|
|
4
|
+
class BacktraceFilter
|
|
5
|
+
LIB_DIRECTORY = File.expand_path(File.join(File.dirname(__FILE__), "..")) + File::SEPARATOR
|
|
6
|
+
|
|
7
|
+
def initialize(lib_directory = LIB_DIRECTORY)
|
|
8
|
+
@lib_directory = lib_directory
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Filters out any backtrace lines originating from the library directory or from gems such as ActiveSupport, Minitest, and Railties
|
|
12
|
+
# @param backtrace [Array<String>]
|
|
13
|
+
# @return [Array<String>]
|
|
14
|
+
def filtered(backtrace)
|
|
15
|
+
backtrace
|
|
16
|
+
.reject { |location| File.expand_path(location).start_with?(@lib_directory) }
|
|
17
|
+
.reject { |l| l =~ /gems\/(activesupport|minitest|railties)/ }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -1,18 +1,113 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "capybara_screenshot_diff"
|
|
4
|
-
require "capybara/screenshot/diff/
|
|
5
|
-
|
|
4
|
+
require "capybara/screenshot/diff/drivers"
|
|
5
|
+
require "capybara/screenshot/diff/image_compare"
|
|
6
|
+
require "capybara/screenshot/diff/screenshot_matcher"
|
|
7
|
+
require "capybara_screenshot_diff/screenshot_namer"
|
|
8
|
+
require "capybara_screenshot_diff/screenshot_assertion"
|
|
6
9
|
|
|
7
10
|
module CapybaraScreenshotDiff
|
|
11
|
+
# DSL for taking screenshots and making assertions in Capybara tests.
|
|
12
|
+
# This module provides methods for taking screenshots, comparing them against baselines,
|
|
13
|
+
# and managing the comparison process with various configuration options.
|
|
14
|
+
#
|
|
15
|
+
# The DSL is designed to be included in your test context (e.g., RSpec, Minitest)
|
|
16
|
+
# to provide screenshot comparison capabilities.
|
|
8
17
|
module DSL
|
|
9
18
|
include Capybara::DSL
|
|
10
|
-
include Capybara::Screenshot::Diff::TestMethods
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
def screenshot_section(name)
|
|
21
|
+
screenshot_namer.section = name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def screenshot_group(name)
|
|
25
|
+
screenshot_namer.group = name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Takes a screenshot and optionally compares it against a baseline image.
|
|
29
|
+
#
|
|
30
|
+
# The method follows a layered optimization strategy for comparison:
|
|
31
|
+
# 1. First checks if screenshot functionality is active
|
|
32
|
+
# 2. Builds a full screenshot name using the current context
|
|
33
|
+
# 3. Creates a screenshot assertion object
|
|
34
|
+
# 4. Either validates immediately or defers validation based on options
|
|
35
|
+
#
|
|
36
|
+
# @param name [String] The base name of the screenshot, used to generate the filename.
|
|
37
|
+
# @param skip_stack_frames [Integer] The number of stack frames to skip when reporting errors.
|
|
38
|
+
# @param options [Hash] Additional options for taking the screenshot and comparison.
|
|
39
|
+
# @option options [Boolean] :delayed (Capybara::Screenshot::Diff.delayed)
|
|
40
|
+
# Whether to validate the screenshot immediately or delay validation.
|
|
41
|
+
# @option options [Array<Integer>] :crop [left, top, right, bottom] Edge coordinates to crop the screenshot to.
|
|
42
|
+
# @option options [Array<Array<Integer>>] :skip_area Array of [left, top, right, bottom] edge coordinates to ignore.
|
|
43
|
+
# @option options [Numeric] :tolerance (0.001 for :vips driver) Color tolerance for comparison.
|
|
44
|
+
# Represents the maximum allowed ratio of different pixels (0.0-1.0 scale).
|
|
45
|
+
# @option options [Numeric] :color_distance_limit Maximum allowed color distance between pixels.
|
|
46
|
+
# Uses Euclidean RGBA distance (0-510 scale). Mutually exclusive with :perceptual_threshold.
|
|
47
|
+
# @option options [Numeric] :perceptual_threshold Maximum perceptual color difference (CIE dE00).
|
|
48
|
+
# Uses human perception-based scale (0-100+). VIPS only. Takes priority over :color_distance_limit if both set.
|
|
49
|
+
# @option options [Numeric] :shift_distance_limit Maximum allowed shift distance for pixels.
|
|
50
|
+
# @option options [Numeric] :area_size_limit Maximum allowed difference area size in pixels.
|
|
51
|
+
# @option options [Symbol] :driver (:auto) The image processing driver to use (:auto, :chunky_png, :vips).
|
|
52
|
+
# @return [Boolean] True if the screenshot was successfully captured and processed.
|
|
53
|
+
# @raise [CapybaraScreenshotDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled.
|
|
54
|
+
# @raise [CapybaraScreenshotDiff::UnstableImage] If the image comparison is unstable.
|
|
55
|
+
# @raise [CapybaraScreenshotDiff::WindowSizeMismatchError] If the window size doesn't match expectations.
|
|
56
|
+
def screenshot(name, skip_stack_frames: 0, **options)
|
|
57
|
+
return false unless Capybara::Screenshot.active?
|
|
58
|
+
|
|
59
|
+
# Get the full name with section and group information
|
|
60
|
+
full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
|
|
61
|
+
|
|
62
|
+
# Build the screenshot assertion
|
|
63
|
+
assertion = build_screenshot_assertion(full_name, options, skip_stack_frames: skip_stack_frames + 1)
|
|
64
|
+
|
|
65
|
+
return false unless assertion
|
|
66
|
+
|
|
67
|
+
# Determine if validation should be delayed or immediate
|
|
68
|
+
delayed = options.fetch(:delayed, Capybara::Screenshot::Diff.delayed)
|
|
69
|
+
|
|
70
|
+
if delayed
|
|
71
|
+
CapybaraScreenshotDiff.add_assertion(assertion)
|
|
72
|
+
else
|
|
73
|
+
assertion.validate!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Alias for backward compatibility with older test suites.
|
|
80
|
+
# @see #screenshot
|
|
81
|
+
alias_method :assert_matches_screenshot, :screenshot
|
|
82
|
+
|
|
83
|
+
# Asserts the current page has no visual changes from the baseline.
|
|
84
|
+
# Override in your base test class to add project-specific behavior
|
|
85
|
+
# (e.g., waiting for Turbo, default skip areas).
|
|
86
|
+
def assert_no_screenshot_changes(name, skip_stack_frames: 0, **opts)
|
|
87
|
+
screenshot(name, skip_stack_frames: skip_stack_frames + 1, **opts)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Builds a screenshot assertion object that can be validated immediately or later.
|
|
93
|
+
#
|
|
94
|
+
# This method constructs a screenshot assertion that encapsulates the comparison logic.
|
|
95
|
+
# The actual comparison is deferred until {ScreenshotAssertion#validate!} is called.
|
|
96
|
+
#
|
|
97
|
+
# @param name [String] The full name of the screenshot, including any section/group context.
|
|
98
|
+
# @param options [Hash] Options for screenshot taking and comparison.
|
|
99
|
+
# See {#screenshot} for available options.
|
|
100
|
+
# @param skip_stack_frames [Integer] Number of stack frames to skip for error reporting.
|
|
101
|
+
# @return [ScreenshotAssertion, nil] The assertion object or nil if no assertion is needed.
|
|
102
|
+
# @see ScreenshotAssertion
|
|
103
|
+
def build_screenshot_assertion(name, options, skip_stack_frames: 0)
|
|
104
|
+
Capybara::Screenshot::Diff::ScreenshotMatcher
|
|
105
|
+
.new(name, options)
|
|
106
|
+
.build_screenshot_assertion(skip_stack_frames: skip_stack_frames + 1)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def screenshot_namer
|
|
110
|
+
CapybaraScreenshotDiff.screenshot_namer
|
|
16
111
|
end
|
|
17
112
|
end
|
|
18
113
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "capybara_screenshot_diff/backtrace_filter"
|
|
4
|
+
|
|
5
|
+
module CapybaraScreenshotDiff
|
|
6
|
+
# @private
|
|
7
|
+
class ErrorWithFilteredBacktrace < StandardError
|
|
8
|
+
# @private
|
|
9
|
+
def initialize(message = nil, backtrace = [])
|
|
10
|
+
super(message)
|
|
11
|
+
filter = BacktraceFilter.new
|
|
12
|
+
set_backtrace(filter.filtered(backtrace))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -22,7 +22,7 @@ module CapybaraScreenshotDiff
|
|
|
22
22
|
def screenshot(*args, skip_stack_frames: 0, **opts)
|
|
23
23
|
self.assertions += 1
|
|
24
24
|
|
|
25
|
-
super(*args, skip_stack_frames: skip_stack_frames +
|
|
25
|
+
super(*args, skip_stack_frames: skip_stack_frames + 1, **opts)
|
|
26
26
|
rescue ::CapybaraScreenshotDiff::ExpectationNotMet => e
|
|
27
27
|
raise ::Minitest::Assertion, e.message
|
|
28
28
|
end
|
|
@@ -39,7 +39,7 @@ module CapybaraScreenshotDiff
|
|
|
39
39
|
CapybaraScreenshotDiff.verify
|
|
40
40
|
rescue CapybaraScreenshotDiff::ExpectationNotMet => e
|
|
41
41
|
assertion = ::Minitest::Assertion.new(e)
|
|
42
|
-
assertion.set_backtrace
|
|
42
|
+
assertion.set_backtrace(e.backtrace)
|
|
43
43
|
failures << assertion
|
|
44
44
|
ensure
|
|
45
45
|
CapybaraScreenshotDiff.reset
|
|
@@ -47,3 +47,5 @@ module CapybaraScreenshotDiff
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
::Minitest.after_run { CapybaraScreenshotDiff.finalize_reporters! } if ::Minitest.respond_to?(:after_run)
|