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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/Rakefile +29 -1
  4. data/capybara-screenshot-diff.gemspec +4 -3
  5. data/docs/RELEASE_PREP.md +58 -0
  6. data/docs/UPGRADING.md +390 -0
  7. data/docs/ci-integration.md +208 -0
  8. data/docs/configuration.md +379 -0
  9. data/docs/docker-testing.md +24 -0
  10. data/docs/drivers.md +102 -0
  11. data/docs/framework-setup.md +87 -0
  12. data/docs/images/snap_diff_web_ui.png +0 -0
  13. data/docs/organization.md +226 -0
  14. data/docs/reporters.md +46 -0
  15. data/docs/thread_safety.md +97 -0
  16. data/gems.rb +2 -1
  17. data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
  18. data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
  19. data/lib/capybara/screenshot/diff/comparison.rb +3 -0
  20. data/lib/capybara/screenshot/diff/difference.rb +40 -3
  21. data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
  22. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
  23. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
  24. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
  25. data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
  26. data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
  27. data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
  28. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +63 -36
  29. data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
  30. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
  31. data/lib/capybara/screenshot/diff/vcs.rb +19 -52
  32. data/lib/capybara/screenshot/diff/version.rb +1 -1
  33. data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
  34. data/lib/capybara_screenshot_diff/cucumber.rb +2 -0
  35. data/lib/capybara_screenshot_diff/dsl.rb +102 -7
  36. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
  37. data/lib/capybara_screenshot_diff/minitest.rb +4 -2
  38. data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
  39. data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
  40. data/lib/capybara_screenshot_diff/rspec.rb +12 -2
  41. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +61 -23
  42. data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
  43. data/lib/capybara_screenshot_diff/snap.rb +14 -3
  44. data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
  45. data/lib/capybara_screenshot_diff/static.rb +11 -0
  46. data/lib/capybara_screenshot_diff.rb +30 -5
  47. metadata +47 -8
  48. 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
- screenshot_format = difference.comparison.options[:screenshot_format] || comparison.new_image_path.extname.slice(1..-1)
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.meta[:diff_mask]
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, DIFF_COLOR, offset: 1).first
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, SKIP_COLOR).first
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.meta[:diff_mask], merged_image, color: DIFF_COLOR)
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 build_screenshot_matches_job
25
- # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
26
- return if BrowserHelpers.window_size_is_wrong?(Screenshot.window_size)
24
+ def build_screenshot_assertion(skip_stack_frames: 0)
25
+ check_window_size!
26
+ prepare_screenshot_options
27
+ check_base_screenshot
27
28
 
28
- # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
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
- # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
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
- # When fail_if_new is true no need to create screenshot if base screenshot is missing
40
- return if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist?
41
-
42
- capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options)
43
-
44
- # Load new screenshot from Browser
45
- take_comparison_screenshot(capture_options, comparison_options, @snapshot)
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
- # Add comparison job in the queue
51
- [screenshot_full_name, ImageCompare.new(@snapshot.path, @snapshot.base_path, comparison_options)]
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
- private
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
- def initialize(capture_options, driver)
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 = 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
- BrowserHelpers.hide_caret
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, "Images have not been loaded after #{timeout}s: #{pending_image.inspect}"
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
- File.unlink(tmpfile) if tmpfile
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
- driver = Diff::Drivers.for(@comparison_options)
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, attempts_reporter.generate
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
- if svn?(root)
11
- restore_svn_revision(screenshot_path, checkout_path)
12
- else
13
- restore_git_revision(screenshot_path, checkout_path, root: root)
14
- end
15
- end
16
-
17
- def self.svn?(root)
18
- (root / ".svn").exist?
19
- end
20
-
21
- SILENCE_ERRORS = Os::ON_WINDOWS ? "2>nul" : "2>/dev/null"
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
- end
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
- true
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
- def self.restore_svn_revision(screenshot_path, checkout_path)
49
- committed_file_name = screenshot_path + "../.svn/text-base/" + "#{screenshot_path.basename}.svn-base"
50
- if committed_file_name.exist?
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
- false
34
+ true
68
35
  end
69
36
  end
70
37
  end
@@ -3,7 +3,7 @@
3
3
  module Capybara
4
4
  module Screenshot
5
5
  module Diff
6
- VERSION = "1.10.3"
6
+ VERSION = "1.12.0"
7
7
  end
8
8
  end
9
9
  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
@@ -8,3 +8,5 @@ Before do
8
8
  Capybara::Screenshot::Diff.delayed = false
9
9
  Capybara::Screenshot::BrowserHelpers.resize_window_if_needed
10
10
  end
11
+
12
+ AfterAll { CapybaraScreenshotDiff.finalize_reporters! }
@@ -1,18 +1,113 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "capybara_screenshot_diff"
4
- require "capybara/screenshot/diff/test_methods"
5
- require_relative "screenshot_assertion"
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
- alias_method :_screenshot, :screenshot
13
- def screenshot(name, **args)
14
- assertion = CapybaraScreenshotDiff::ScreenshotAssertion.new(name, **args) { _screenshot(name, **args) }
15
- CapybaraScreenshotDiff.add_assertion(assertion)
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 + 3, **opts)
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)