snap_diff-capybara 0.0.1 → 2.0.0.beta1

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +190 -0
  3. data/CODE_OF_CONDUCT.md +129 -0
  4. data/LICENSE.txt +21 -0
  5. data/Rakefile +65 -0
  6. data/capybara-screenshot-diff.gemspec +29 -0
  7. data/docs/RELEASE_PREP.md +44 -0
  8. data/docs/UPGRADING.md +702 -0
  9. data/docs/architecture.md +289 -0
  10. data/docs/ci-integration.md +238 -0
  11. data/docs/configuration.md +395 -0
  12. data/docs/docker-testing.md +24 -0
  13. data/docs/drivers.md +102 -0
  14. data/docs/framework-setup.md +87 -0
  15. data/docs/images/snap_diff_annotated.png +0 -0
  16. data/docs/images/snap_diff_web_ui.png +0 -0
  17. data/docs/migration-guide.md +286 -0
  18. data/docs/organization.md +204 -0
  19. data/docs/reporters.md +46 -0
  20. data/docs/thread_safety.md +112 -0
  21. data/gems.rb +39 -0
  22. data/lib/capybara/screenshot/diff/annotation_service.rb +7 -0
  23. data/lib/capybara/screenshot/diff/area_calculator.rb +7 -0
  24. data/lib/capybara/screenshot/diff/browser_helpers.rb +7 -0
  25. data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
  26. data/lib/capybara/screenshot/diff/cucumber.rb +3 -0
  27. data/lib/capybara/screenshot/diff/difference.rb +8 -0
  28. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +9 -0
  29. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +7 -0
  30. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +7 -0
  31. data/lib/capybara/screenshot/diff/drivers.rb +9 -0
  32. data/lib/capybara/screenshot/diff/image_compare.rb +12 -0
  33. data/lib/capybara/screenshot/diff/image_preprocessor.rb +7 -0
  34. data/lib/capybara/screenshot/diff/os.rb +14 -0
  35. data/lib/capybara/screenshot/diff/region.rb +107 -0
  36. data/lib/capybara/screenshot/diff/reporters/default.rb +109 -0
  37. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +7 -0
  38. data/lib/capybara/screenshot/diff/screenshoter.rb +7 -0
  39. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -0
  40. data/lib/capybara/screenshot/diff/utils.rb +7 -0
  41. data/lib/capybara/screenshot/diff/vcs.rb +7 -0
  42. data/lib/capybara/screenshot/diff/version.rb +15 -0
  43. data/lib/capybara/screenshot/diff.rb +3 -0
  44. data/lib/capybara-screenshot-diff.rb +3 -0
  45. data/lib/capybara_screenshot_diff/attempts_reporter.rb +7 -0
  46. data/lib/capybara_screenshot_diff/cucumber.rb +8 -0
  47. data/lib/capybara_screenshot_diff/dsl.rb +12 -0
  48. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +8 -0
  49. data/lib/capybara_screenshot_diff/minitest.rb +14 -0
  50. data/lib/capybara_screenshot_diff/reporters/html.rb +7 -0
  51. data/lib/capybara_screenshot_diff/rspec.rb +8 -0
  52. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +71 -0
  53. data/lib/capybara_screenshot_diff/screenshot_namer.rb +7 -0
  54. data/lib/capybara_screenshot_diff/snap.rb +7 -0
  55. data/lib/capybara_screenshot_diff/snap_manager.rb +7 -0
  56. data/lib/capybara_screenshot_diff/static.rb +9 -0
  57. data/lib/capybara_screenshot_diff.rb +50 -0
  58. data/lib/snap_diff/annotation_service.rb +84 -0
  59. data/lib/snap_diff/area_calculator.rb +56 -0
  60. data/lib/snap_diff/attempts_reporter.rb +51 -0
  61. data/lib/snap_diff/browser_helpers.rb +121 -0
  62. data/lib/snap_diff/capture/viewport.rb +40 -0
  63. data/lib/snap_diff/comparison.rb +238 -0
  64. data/lib/snap_diff/comparison_result.rb +104 -0
  65. data/lib/snap_diff/config.rb +78 -0
  66. data/lib/snap_diff/deprecation.rb +84 -0
  67. data/lib/snap_diff/driver.rb +37 -0
  68. data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
  69. data/lib/snap_diff/drivers/vips_driver.rb +171 -0
  70. data/lib/snap_diff/drivers.rb +14 -0
  71. data/lib/snap_diff/dsl.rb +143 -0
  72. data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
  73. data/lib/snap_diff/image_preprocessor.rb +68 -0
  74. data/lib/snap_diff/integrations/cucumber.rb +22 -0
  75. data/lib/snap_diff/integrations/minitest.rb +70 -0
  76. data/lib/snap_diff/integrations/rspec.rb +68 -0
  77. data/lib/snap_diff/legacy_shims.rb +99 -0
  78. data/lib/snap_diff/os.rb +17 -0
  79. data/lib/snap_diff/reporters/html.rb +143 -0
  80. data/lib/snap_diff/reporters/templates/report.html.erb +463 -0
  81. data/lib/snap_diff/reporting.rb +53 -0
  82. data/lib/snap_diff/screenshot_assertion.rb +143 -0
  83. data/lib/snap_diff/screenshot_matcher.rb +113 -0
  84. data/lib/snap_diff/screenshot_namer.rb +81 -0
  85. data/lib/snap_diff/screenshoter.rb +129 -0
  86. data/lib/snap_diff/snap.rb +66 -0
  87. data/lib/snap_diff/snap_manager.rb +125 -0
  88. data/lib/snap_diff/stable_screenshoter.rb +103 -0
  89. data/lib/snap_diff/static.rb +11 -0
  90. data/lib/snap_diff/utils.rb +38 -0
  91. data/lib/snap_diff/vcs.rb +35 -0
  92. data/lib/snap_diff/version.rb +5 -0
  93. data/lib/snap_diff.rb +65 -0
  94. metadata +160 -10
  95. data/README.md +0 -3
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara_screenshot_diff/snap_manager"
4
+ require_relative "screenshoter"
5
+ require_relative "stable_screenshoter"
6
+ require_relative "browser_helpers"
7
+ require_relative "capture/viewport"
8
+ require_relative "vcs"
9
+ require_relative "area_calculator"
10
+
11
+ module SnapDiff
12
+ class ScreenshotMatcher
13
+ attr_reader :screenshot_full_name, :driver_options, :screenshot_format
14
+
15
+ def initialize(screenshot_full_name, options = {})
16
+ @screenshot_full_name = screenshot_full_name
17
+ @driver_options = Capybara::Screenshot::Diff.default_options.merge(options)
18
+
19
+ @screenshot_format = @driver_options[:screenshot_format]
20
+ @snapshot = SnapDiff::SnapManager.snapshot(screenshot_full_name, @screenshot_format)
21
+ end
22
+
23
+ def build_screenshot_assertion(skip_stack_frames: 0)
24
+ Capture::Viewport.prepare!(Capybara::Screenshot.window_size, anchor: nil)
25
+ prepare_screenshot_options
26
+ check_base_screenshot
27
+
28
+ capture_options, comparison_options = extract_capture_and_comparison_options(driver_options)
29
+
30
+ capture_screenshot(capture_options, comparison_options)
31
+
32
+ # Pre-computation: No need to compare without base screenshot
33
+ # NOTE: Consider to return PreValid Assertion Value Object with hard coded valid result
34
+ unless need_to_compare?
35
+ CapybaraScreenshotDiff.record_new_screenshot(screenshot_full_name)
36
+ return
37
+ end
38
+
39
+ create_screenshot_assertion(skip_stack_frames + 1, comparison_options)
40
+ end
41
+
42
+ # Captures a screenshot without comparing it to a baseline.
43
+ def capture
44
+ Capture::Viewport.prepare!(Capybara::Screenshot.window_size, anchor: nil)
45
+ prepare_screenshot_options
46
+
47
+ capture_options, comparison_options = extract_capture_and_comparison_options(driver_options)
48
+
49
+ @snapshot.manager.create_output_directory_for(@snapshot.path)
50
+ capture_screenshot(capture_options, comparison_options)
51
+ end
52
+
53
+ private
54
+
55
+ def need_to_compare?
56
+ @snapshot.base_path.exist?
57
+ end
58
+
59
+ def prepare_screenshot_options
60
+ area_calculator = AreaCalculator.new(driver_options.delete(:crop), driver_options[:skip_area])
61
+
62
+ driver_options[:crop] = area_calculator.calculate_crop
63
+ driver_options[:skip_area] = area_calculator.calculate_skip_area
64
+ driver_options[:driver] = SnapDiff::Drivers.for(driver_options[:driver])
65
+ end
66
+
67
+ def check_base_screenshot
68
+ @snapshot.checkout_base_screenshot
69
+
70
+ if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist?
71
+ raise CapybaraScreenshotDiff::ExpectationNotMet.new(<<~ERROR.chomp, caller)
72
+ No existing screenshot found for #{@snapshot.base_path}!
73
+ To record baselines: RECORD_SCREENSHOTS=1 bundle exec rake test
74
+ To allow new screenshots: Capybara::Screenshot::Diff.fail_if_new = false
75
+ ERROR
76
+ end
77
+ end
78
+
79
+ def capture_screenshot(capture_options, comparison_options)
80
+ screenshoter = if capture_options[:stability_time_limit]
81
+ StableScreenshoter.new(capture_options, comparison_options)
82
+ else
83
+ Capybara::Screenshot::Diff.screenshoter.new(capture_options, comparison_options)
84
+ end
85
+ screenshoter.take_comparison_screenshot(@snapshot)
86
+ end
87
+
88
+ def create_screenshot_assertion(skip_stack_frames, comparison_options)
89
+ assertion = SnapDiff::ScreenshotAssertion.new(screenshot_full_name)
90
+ assertion.caller = caller(skip_stack_frames + 1)
91
+ assertion.compare = Comparison.new(@snapshot.path, @snapshot.base_path, comparison_options)
92
+ assertion
93
+ end
94
+
95
+ # Pure partition of one options hash into [capture_options,
96
+ # comparison_options]. Unlike the previous delete-based carve, this
97
+ # method itself does not mutate its input hash.
98
+ def extract_capture_and_comparison_options(driver_options = {})
99
+ capture_options = {
100
+ # screenshot options
101
+ capybara_screenshot_options: driver_options[:capybara_screenshot_options],
102
+ crop: driver_options[:crop],
103
+ # delivery options
104
+ screenshot_format: driver_options[:screenshot_format],
105
+ # stability options
106
+ stability_time_limit: driver_options[:stability_time_limit],
107
+ wait: driver_options[:wait]
108
+ }
109
+
110
+ [capture_options, driver_options.except(:crop, :stability_time_limit, :wait)]
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module SnapDiff
7
+ # Handles the naming, path generation, and organization of screenshots.
8
+ # This class encapsulates logic related to screenshot sections, groups,
9
+ # and counters, providing a centralized way to determine screenshot filenames
10
+ # and directories.
11
+ class ScreenshotNamer
12
+ attr_reader :section, :group
13
+
14
+ def initialize(screenshot_area = nil)
15
+ @section = nil
16
+ @group = nil
17
+ @counter = nil
18
+ @screenshot_area = screenshot_area
19
+ end
20
+
21
+ def screenshot_area
22
+ @screenshot_area ||= Capybara::Screenshot.screenshot_area
23
+ end
24
+
25
+ # Sets the current section for screenshots.
26
+ # @param name [String, nil] The name of the section.
27
+ def section=(name)
28
+ @section = name&.to_s
29
+ reset_group_counter
30
+ end
31
+
32
+ # Sets the current group for screenshots and resets the counter.
33
+ # @param name [String, nil] The name of the group.
34
+ def group=(name)
35
+ @group = name&.to_s
36
+ reset_group_counter
37
+ end
38
+
39
+ # Builds the full, unique name for a screenshot, including any counter.
40
+ # @param base_name [String] The base name for the screenshot.
41
+ # @return [String] The full screenshot name.
42
+ def full_name(base_name)
43
+ name = base_name.to_s
44
+
45
+ if @counter
46
+ name = format("%02i_%s", @counter, name)
47
+ @counter += 1
48
+ end
49
+
50
+ File.join(*directory_parts.push(name.to_s))
51
+ end
52
+
53
+ # Builds the full path for a screenshot file, including section and group directories.
54
+ # @param base_name [String] The base name for the screenshot.
55
+ # @return [String] The absolute path for the screenshot file.
56
+ def full_name_with_path(base_name)
57
+ File.join(screenshot_area, full_name(base_name))
58
+ end
59
+
60
+ # Returns the directory parts (section and group) for constructing paths.
61
+ # @return [Array<String>] An array of directory names.
62
+ def directory_parts
63
+ parts = []
64
+ parts << @section unless @section.nil? || @section.empty?
65
+ parts << @group unless @group.nil? || @group.empty?
66
+ parts
67
+ end
68
+
69
+ # Calculates the directory path for the current section and group.
70
+ # @return [String] The full path to the directory.
71
+ def current_group_directory
72
+ File.join(*([screenshot_area] + directory_parts))
73
+ end
74
+
75
+ private
76
+
77
+ def reset_group_counter
78
+ @counter = (@group.nil? || @group.empty?) ? nil : 0
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ require_relative "os"
6
+ require_relative "browser_helpers"
7
+
8
+ module SnapDiff
9
+ class Screenshoter
10
+ attr_reader :capture_options, :driver
11
+
12
+ # @param capture_options [Hash] Options for capturing (window_size, wait, etc.)
13
+ # @param comparison_options [Hash] Options for image comparison (driver, tolerance, etc.)
14
+ def initialize(capture_options, comparison_options = {})
15
+ @capture_options = capture_options
16
+ @driver = SnapDiff::Drivers.for(comparison_options)
17
+ end
18
+
19
+ def crop
20
+ @capture_options[:crop]
21
+ end
22
+
23
+ def wait
24
+ @capture_options[:wait]
25
+ end
26
+
27
+ def capybara_screenshot_options
28
+ @capture_options[:capybara_screenshot_options] || {}
29
+ end
30
+
31
+ # Try to get screenshot from browser.
32
+ # On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts
33
+ # On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug
34
+ def take_comparison_screenshot(snapshot)
35
+ capture_screenshot_at(snapshot)
36
+ snapshot.cleanup_attempts!
37
+ end
38
+
39
+ PNG_EXTENSION = ".png"
40
+
41
+ def take_screenshot(screenshot_path)
42
+ blurred_input = prepare_page_for_screenshot(timeout: wait)
43
+
44
+ # Take browser screenshot and save
45
+ save_and_process_screenshot(screenshot_path)
46
+
47
+ blurred_input&.click
48
+ end
49
+
50
+ def process_screenshot(stored_path, screenshot_path)
51
+ screenshot_image = driver.from_file(stored_path)
52
+
53
+ # TODO(uwe): Remove when chromedriver takes right size screenshots
54
+ # TODO: Adds tests when this case is true
55
+ screenshot_image = resize_if_needed(screenshot_image) if selenium_with_retina_screen?
56
+ # ODOT
57
+
58
+ screenshot_image = driver.crop(crop, screenshot_image) if crop
59
+
60
+ driver.save_image_to(screenshot_image, screenshot_path)
61
+ end
62
+
63
+ def notice_how_to_avoid_this
64
+ unless defined?(@_csd_retina_warned)
65
+ warn "Halving retina screenshot. " \
66
+ 'You should add "force-device-scale-factor=1" to your Chrome chromeOptions args.'
67
+ @_csd_retina_warned = true
68
+ end
69
+ end
70
+
71
+ def prepare_page_for_screenshot(timeout:)
72
+ wait_images_loaded(timeout: timeout) if timeout
73
+
74
+ blurred_input = BrowserHelpers.blur_from_focused_element if Capybara::Screenshot.blur_active_element
75
+
76
+ BrowserHelpers.hide_caret if Capybara::Screenshot.hide_caret
77
+ BrowserHelpers.disable_animations if Capybara::Screenshot.disable_animations
78
+
79
+ blurred_input
80
+ end
81
+
82
+ def wait_images_loaded(timeout:)
83
+ return unless timeout
84
+
85
+ deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
86
+ loop do
87
+ pending_image = BrowserHelpers.pending_image_to_load
88
+ break unless pending_image
89
+
90
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at
91
+ raise CapybaraScreenshotDiff::ExpectationNotMet.new("Images have not been loaded after #{timeout}s: #{pending_image.inspect}", caller)
92
+ end
93
+
94
+ sleep 0.025
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def save_and_process_screenshot(screenshot_path)
101
+ tmpfile = Tempfile.new([screenshot_path.basename.to_s, PNG_EXTENSION])
102
+ BrowserHelpers.session.save_screenshot(tmpfile.path, **capybara_screenshot_options)
103
+ # Load saved screenshot and pre-process it
104
+ process_screenshot(tmpfile.path, screenshot_path)
105
+ ensure
106
+ tmpfile&.close!
107
+ end
108
+
109
+ def capture_screenshot_at(snapshot)
110
+ take_screenshot(snapshot.next_attempt_path!)
111
+
112
+ snapshot.commit_last_attempt
113
+ end
114
+
115
+ def resize_if_needed(saved_image)
116
+ expected_image_width = Capybara::Screenshot.window_size[0]
117
+ return saved_image if driver.width_for(saved_image) < expected_image_width * 2
118
+
119
+ notice_how_to_avoid_this
120
+
121
+ new_height = expected_image_width * driver.height_for(saved_image) / driver.width_for(saved_image)
122
+ driver.resize_image_to(saved_image, expected_image_width, new_height)
123
+ end
124
+
125
+ def selenium_with_retina_screen?
126
+ Os::ON_MAC && BrowserHelpers.selenium? && Capybara::Screenshot.window_size
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ class Snap
5
+ attr_reader :full_name, :format, :path, :base_path, :manager, :attempt_path, :prev_attempt_path, :attempts_count
6
+
7
+ def initialize(full_name, format, manager: SnapManager.instance)
8
+ @full_name = full_name
9
+ @format = format
10
+ @path = manager.abs_path_for(Pathname.new(@full_name).sub_ext(".#{@format}"))
11
+ @base_path = @path.sub_ext(".base.#{@format}")
12
+ @manager = manager
13
+ @attempts_count = 0
14
+ end
15
+
16
+ def delete!
17
+ path.delete if path.exist?
18
+ base_path.delete if base_path.exist?
19
+ cleanup_diff_artifacts!
20
+ cleanup_attempts!
21
+ end
22
+
23
+ def checkout_base_screenshot
24
+ @manager.checkout_file(path, base_path)
25
+ end
26
+
27
+ def path_for(version = :actual)
28
+ case version
29
+ when :base
30
+ base_path
31
+ else
32
+ path
33
+ end
34
+ end
35
+
36
+ def next_attempt_path!
37
+ @prev_attempt_path = @attempt_path
38
+ @attempt_path = path.sub_ext(sprintf(".attempt_%02i.#{format}", @attempts_count))
39
+ ensure
40
+ @attempts_count += 1
41
+ end
42
+
43
+ def commit_last_attempt
44
+ @manager.move(attempt_path, path)
45
+ end
46
+
47
+ def cleanup_attempts!
48
+ @manager.cleanup_attempts!(self)
49
+ @attempts_count = 0
50
+ end
51
+
52
+ def find_attempts_paths
53
+ Dir[@manager.abs_path_for("**/#{full_name}.attempt_[0-9][0-9].#{format}")]
54
+ end
55
+
56
+ private
57
+
58
+ def cleanup_diff_artifacts!
59
+ [
60
+ path.sub_ext(".diff.#{format}"),
61
+ path.sub_ext(".heatmap.diff.#{format}"),
62
+ base_path.sub_ext(".diff.#{format}")
63
+ ].each { |f| f.unlink if f.exist? }
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require_relative "vcs"
6
+ require "active_support/core_ext/module/attribute_accessors"
7
+
8
+ require_relative "snap"
9
+
10
+ module SnapDiff
11
+ class SnapManager
12
+ attr_reader :root
13
+
14
+ def initialize(root)
15
+ @root = Pathname.new(root)
16
+ @snapshots = Set.new
17
+ end
18
+
19
+ def snapshot(screenshot_full_name, screenshot_format = "png")
20
+ Snap.new(screenshot_full_name, screenshot_format, manager: self).tap do |snapshot|
21
+ @snapshots << snapshot
22
+ end
23
+ end
24
+
25
+ def self.snapshot(screenshot_full_name, screenshot_format = "png")
26
+ instance.snapshot(screenshot_full_name, screenshot_format)
27
+ end
28
+
29
+ # Pure path lookup: builds the Snap (path bundle) for a name WITHOUT
30
+ # registering it for cleanup. Use this when you only need to know where
31
+ # a screenshot lives; use #snapshot when taking one that cleanup! owns.
32
+ def path_for(screenshot_full_name, screenshot_format = "png")
33
+ Snap.new(screenshot_full_name, screenshot_format, manager: self)
34
+ end
35
+
36
+ def self.path_for(screenshot_full_name, screenshot_format = "png")
37
+ instance.path_for(screenshot_full_name, screenshot_format)
38
+ end
39
+
40
+ def abs_path_for(relative_path)
41
+ @root / relative_path
42
+ end
43
+
44
+ def checkout_file(path, as_path)
45
+ create_output_directory_for(as_path) unless as_path.exist?
46
+ SnapDiff::Vcs.checkout_vcs(root, path, as_path)
47
+ end
48
+
49
+ def provision_snap_with(snap, path, version: :actual)
50
+ managed_path = snap.path_for(version)
51
+ create_output_directory_for(managed_path) unless managed_path.exist?
52
+ FileUtils.cp(path, managed_path)
53
+ end
54
+
55
+ def create_output_directory_for(path = nil)
56
+ path ? path.dirname.mkpath : root.mkpath
57
+ end
58
+
59
+ # TODO: rename to delete!
60
+ def cleanup!
61
+ snapshots.each do |snapshot|
62
+ cleanup_attempts!(snapshot)
63
+ snapshot.delete!
64
+ end
65
+ # With the manager memoized per thread the set would otherwise grow for
66
+ # the whole run, and a later cleanup! could delete a path an earlier
67
+ # test tracked but the current test re-provisioned.
68
+ snapshots.clear
69
+ end
70
+
71
+ def self.cleanup!
72
+ instance.cleanup!
73
+ end
74
+
75
+ def cleanup_attempts!(snapshot)
76
+ FileUtils.rm_rf snapshot.find_attempts_paths, secure: true
77
+ end
78
+
79
+ def move(new_screenshot_path, screenshot_path)
80
+ FileUtils.mv(new_screenshot_path, screenshot_path, force: true)
81
+ end
82
+
83
+ def screenshots
84
+ root.children.map { |f| f.basename.to_s }
85
+ end
86
+
87
+ attr_reader :snapshots
88
+
89
+ def self.screenshots
90
+ instance.screenshots
91
+ end
92
+
93
+ def self.root
94
+ instance.root
95
+ end
96
+
97
+ # Thread-local memoized manager (D7 fix): snapshots tracked via the
98
+ # class-method path and class-method cleanup! now share one instance, so
99
+ # cleanup! actually deletes what was tracked. Rebuilt when the configured
100
+ # manager class or screenshot root changes (tests re-root per example).
101
+ #
102
+ # Thread.current[] is fiber-local — same construct as
103
+ # CapybaraScreenshotDiff.registry; both split per fiber and must migrate
104
+ # together if async support ever lands.
105
+ #
106
+ # Scope of the fix: default-root unit tests and before_setup re-rooters
107
+ # get working cleanup. System tests do NOT — system_test_case.rb re-roots
108
+ # in `setup do`, and LIFO teardown restores the root BEFORE test_helper's
109
+ # cleanup! runs, so the root-mismatch check below rebuilds an empty
110
+ # manager there. That miss is protective: firing cleanup on the restored
111
+ # instance would delete committed baselines under
112
+ # test/fixtures/app/doc/screenshots/ that VCS rollback just restored.
113
+ # Do not reorder those teardowns without revisiting this.
114
+ def self.instance
115
+ manager_class = Capybara::Screenshot::Diff.manager
116
+ root = Pathname.new(Capybara::Screenshot.screenshot_area_abs)
117
+
118
+ current = Thread.current[:snap_diff_manager]
119
+ unless current&.instance_of?(manager_class) && current.root == root
120
+ current = Thread.current[:snap_diff_manager] = manager_class.new(root)
121
+ end
122
+ current
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ class StableScreenshoter
5
+ STABILITY_OPTIONS = [:stability_time_limit, :wait]
6
+
7
+ attr_reader :stability_time_limit, :wait
8
+
9
+ # Initializes a new instance of StableScreenshoter
10
+ #
11
+ # This method sets up a new screenshoter with specific capture and comparison options. It validates the presence of
12
+ # `:stability_time_limit` and `:wait` in capture options and ensures that `:stability_time_limit` is less than or equal to `:wait`.
13
+ #
14
+ # @param capture_options [Hash] The options for capturing screenshots, must include `:stability_time_limit` and `:wait`.
15
+ # @param comparison_options [Hash] The options for comparing screenshots, defaults to `{}`. Same signature as {Capybara::Screenshot::Screenshoter#initialize}.
16
+ # @raise [ArgumentError] If `:wait` or `:stability_time_limit` are not provided, or if `:stability_time_limit` is greater than `:wait`.
17
+ def initialize(capture_options, comparison_options = {})
18
+ @stability_time_limit, @wait = capture_options.fetch_values(*STABILITY_OPTIONS)
19
+
20
+ raise ArgumentError, "wait should be provided for stable screenshots" unless wait
21
+ raise ArgumentError, "stability_time_limit should be provided for stable screenshots" unless stability_time_limit
22
+ raise ArgumentError, "stability_time_limit (#{stability_time_limit}) should be less or equal than wait (#{wait}) for stable screenshots" unless stability_time_limit <= wait
23
+
24
+ @comparison_options = comparison_options
25
+
26
+ @screenshoter = Capybara::Screenshot::Diff.screenshoter.new(capture_options.except(:stability_time_limit), @comparison_options)
27
+ end
28
+
29
+ # Takes a comparison screenshot ensuring page stability
30
+ #
31
+ # Attempts to take a stable screenshot of the page by comparing several screenshot attempts until the page stops updating
32
+ # or the `:wait` limit is reached. If unable to achieve a stable state within the time limit, it annotates the attempts
33
+ # to aid debugging.
34
+ #
35
+ # @param snapshot Snap The snapshot details to take a stable screenshot of.
36
+ # @return [void]
37
+ # @raise [CapybaraScreenshotDiff::UnstableImage] If a stable screenshot cannot be obtained within the specified `:wait` time.
38
+ def take_comparison_screenshot(snapshot)
39
+ result = take_stable_screenshot(snapshot)
40
+
41
+ # We failed to get stable browser state! Generate difference between attempts to overview moving parts!
42
+ unless result
43
+ # The failed-stability outcome is data (the annotated attempts report); raising is this
44
+ # boundary's decision, not the reporter's.
45
+ # FIXME(uwe): Hand the failure to the verify path so it only reports after the test's own assertions run.
46
+ raise CapybaraScreenshotDiff::UnstableImage.new(stability_failure_report(snapshot), caller)
47
+ end
48
+
49
+ # store success attempt as actual screenshot
50
+ snapshot.commit_last_attempt
51
+
52
+ # cleanup all previous attempts
53
+ snapshot.cleanup_attempts!
54
+ end
55
+
56
+ def take_stable_screenshot(snapshot)
57
+ # We try to compare first attempt with checkout version, in order to not run next screenshots
58
+ deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
59
+
60
+ # Cleanup all previous attempts for sure
61
+ snapshot.cleanup_attempts!
62
+
63
+ loop do
64
+ attempt_next_screenshot(snapshot)
65
+
66
+ return true if attempt_successful?(snapshot)
67
+ return false if timeout?(deadline_at)
68
+
69
+ sleep(stability_time_limit)
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def attempt_successful?(snapshot)
76
+ return false unless snapshot.prev_attempt_path
77
+
78
+ build_last_attempts_comparison_for(snapshot).quick_equal?
79
+ rescue ArgumentError
80
+ false
81
+ end
82
+
83
+ def attempt_next_screenshot(snapshot)
84
+ @screenshoter.take_screenshot(snapshot.next_attempt_path!)
85
+ end
86
+
87
+ def timeout?(deadline_at)
88
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at
89
+ end
90
+
91
+ def build_last_attempts_comparison_for(snapshot)
92
+ Comparison.new(snapshot.attempt_path, snapshot.prev_attempt_path, @comparison_options)
93
+ end
94
+
95
+ # Builds the failed-stability outcome as data: annotates the attempt artifacts on disk
96
+ # and returns the report message. Does not raise.
97
+ # @return [String] the annotated attempts report
98
+ def stability_failure_report(snapshot)
99
+ require_relative "attempts_reporter"
100
+ SnapDiff::AttemptsReporter.new(snapshot, @comparison_options, {wait: wait, stability_time_limit: stability_time_limit}).generate
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/files"
4
+ require_relative "integrations/minitest"
5
+
6
+ module SnapDiff
7
+ def self.serve(directory, root: Dir.pwd)
8
+ Capybara.app = Rack::Files.new(directory)
9
+ Capybara::Screenshot.root = root
10
+ end
11
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ module Utils
5
+ def self.detect_available_drivers
6
+ result = []
7
+ begin
8
+ result << :vips if defined?(Vips) || require("vips")
9
+ rescue LoadError
10
+ # vips not present
11
+ Object.send(:remove_const, :Vips) if defined?(Vips)
12
+ end
13
+ begin
14
+ result << :chunky_png if defined?(ChunkyPNG) || require("chunky_png")
15
+ rescue LoadError
16
+ # chunky_png not present
17
+ Object.send(:remove_const, :ChunkyPNG) if defined?(ChunkyPNG)
18
+ end
19
+ result
20
+ end
21
+
22
+ def self.find_driver_class_for(driver)
23
+ driver = Capybara::Screenshot::Diff::AVAILABLE_DRIVERS.first if driver == :auto
24
+
25
+ Capybara::Screenshot::Diff::LOADED_DRIVERS[driver] ||=
26
+ case driver
27
+ when :chunky_png
28
+ require "snap_diff/drivers/chunky_png_driver"
29
+ SnapDiff::Drivers::ChunkyPNGDriver
30
+ when :vips
31
+ require "snap_diff/drivers/vips_driver"
32
+ SnapDiff::Drivers::VipsDriver
33
+ else
34
+ fail "Wrong adapter #{driver.inspect}. Available adapters: #{Capybara::Screenshot::Diff::AVAILABLE_DRIVERS.inspect}"
35
+ end
36
+ end
37
+ end
38
+ end