snap_diff-capybara 2.0.0.beta2 → 2.0.0.beta4

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +471 -0
  3. data/README.md +330 -0
  4. data/docs/UPGRADING.md +232 -34
  5. data/docs/architecture.md +9 -7
  6. data/docs/ci-integration.md +42 -11
  7. data/docs/configuration.md +176 -10
  8. data/docs/drivers.md +48 -5
  9. data/docs/framework-setup.md +10 -3
  10. data/docs/migration-guide.md +24 -13
  11. data/docs/reporters.md +70 -1
  12. data/docs/snapdiff.md +41 -8
  13. data/docs/thread_safety.md +77 -86
  14. data/lib/capybara/screenshot/diff/annotation_service.rb +1 -3
  15. data/lib/capybara/screenshot/diff/area_calculator.rb +1 -3
  16. data/lib/capybara/screenshot/diff/browser_helpers.rb +1 -3
  17. data/lib/capybara/screenshot/diff/config_legacy.rb +15 -60
  18. data/lib/capybara/screenshot/diff/cucumber.rb +5 -1
  19. data/lib/capybara/screenshot/diff/difference.rb +1 -4
  20. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +3 -3
  21. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +2 -3
  22. data/lib/capybara/screenshot/diff/drivers.rb +4 -5
  23. data/lib/capybara/screenshot/diff/image_compare.rb +12 -11
  24. data/lib/capybara/screenshot/diff/image_preprocessor.rb +1 -3
  25. data/lib/capybara/screenshot/diff/os.rb +4 -11
  26. data/lib/capybara/screenshot/diff/region.rb +2 -2
  27. data/lib/capybara/screenshot/diff/reporters/default.rb +4 -3
  28. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +1 -3
  29. data/lib/capybara/screenshot/diff/screenshoter.rb +1 -3
  30. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +1 -3
  31. data/lib/capybara/screenshot/diff/utils.rb +1 -3
  32. data/lib/capybara/screenshot/diff/vcs.rb +1 -3
  33. data/lib/capybara/screenshot/diff/version.rb +8 -13
  34. data/lib/capybara-screenshot-diff.rb +10 -1
  35. data/lib/capybara_screenshot_diff/attempts_reporter.rb +1 -3
  36. data/lib/capybara_screenshot_diff/dsl.rb +5 -0
  37. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +1 -4
  38. data/lib/capybara_screenshot_diff/reporters/html.rb +1 -3
  39. data/lib/capybara_screenshot_diff/screenshot_namer.rb +1 -3
  40. data/lib/capybara_screenshot_diff/snap.rb +1 -3
  41. data/lib/capybara_screenshot_diff/snap_manager.rb +1 -3
  42. data/lib/capybara_screenshot_diff/static.rb +4 -0
  43. data/lib/capybara_screenshot_diff.rb +17 -21
  44. data/lib/snap_diff/browser_helpers.rb +26 -5
  45. data/lib/snap_diff/capture/viewport.rb +2 -5
  46. data/lib/snap_diff/comparison.rb +54 -3
  47. data/lib/snap_diff/comparison_result.rb +3 -1
  48. data/lib/snap_diff/config.rb +189 -93
  49. data/lib/snap_diff/deprecation.rb +89 -28
  50. data/lib/snap_diff/driver.rb +18 -0
  51. data/lib/snap_diff/drivers/vips_driver.rb +12 -6
  52. data/lib/snap_diff/drivers.rb +94 -12
  53. data/lib/snap_diff/dsl.rb +51 -45
  54. data/lib/snap_diff/errors.rb +7 -1
  55. data/lib/snap_diff/integrations/cucumber.rb +1 -1
  56. data/lib/snap_diff/integrations/minitest.rb +45 -9
  57. data/lib/snap_diff/integrations/rspec.rb +11 -0
  58. data/lib/snap_diff/legacy_shims.rb +283 -28
  59. data/lib/snap_diff/removal.rb +159 -0
  60. data/lib/snap_diff/reporters/default.rb +86 -23
  61. data/lib/snap_diff/reporters/html.rb +29 -13
  62. data/lib/snap_diff/reporting.rb +330 -4
  63. data/lib/snap_diff/screenshot_assertion.rb +28 -23
  64. data/lib/snap_diff/screenshot_matcher.rb +121 -14
  65. data/lib/snap_diff/screenshot_namer.rb +1 -19
  66. data/lib/snap_diff/screenshoter.rb +5 -7
  67. data/lib/snap_diff/snap.rb +6 -1
  68. data/lib/snap_diff/snap_manager.rb +2 -3
  69. data/lib/snap_diff/stable_screenshoter.rb +2 -2
  70. data/lib/snap_diff/static.rb +1 -1
  71. data/lib/snap_diff/utils.rb +35 -17
  72. data/lib/snap_diff/vcs.rb +40 -7
  73. data/lib/snap_diff/version.rb +1 -1
  74. data/lib/snap_diff-capybara.rb +38 -0
  75. data/lib/snap_diff.rb +40 -39
  76. metadata +13 -10
  77. data/CODE_OF_CONDUCT.md +0 -129
  78. data/Rakefile +0 -65
  79. data/capybara-screenshot-diff.gemspec +0 -29
  80. data/docs/RELEASE_PREP.md +0 -44
  81. data/docs/docker-testing.md +0 -24
  82. data/gems.rb +0 -39
@@ -1,27 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "capybara_screenshot_diff/snap_manager"
3
+ require "snap_diff/removal"
4
+ require "snap_diff/snap_manager"
4
5
  require_relative "screenshoter"
5
6
  require_relative "stable_screenshoter"
6
7
  require_relative "browser_helpers"
7
8
  require_relative "capture/viewport"
8
9
  require_relative "vcs"
9
10
  require_relative "area_calculator"
11
+ require_relative "reporting"
10
12
 
11
13
  module SnapDiff
12
14
  class ScreenshotMatcher
13
- attr_reader :screenshot_full_name, :driver_options, :screenshot_format
15
+ attr_reader :screenshot_full_name, :driver_options, :screenshot_format, :record_mode
14
16
 
15
17
  def initialize(screenshot_full_name, options = {})
16
18
  @screenshot_full_name = screenshot_full_name
17
- @driver_options = Capybara::Screenshot::Diff.default_options.merge(options)
19
+ # BEFORE the merge below -- see SnapDiff.compare. Afterwards `:driver`
20
+ # is present for every caller and proves nothing.
21
+ Removal.warn_once(:driver_setting, Removal::DRIVER_REMOVED) if options.key?(:driver)
22
+ @driver_options = SnapDiff.config.default_options.merge(options)
23
+
24
+ # `record:` is a workflow mode, not a capture or comparison option, so
25
+ # it is carved out here rather than added to Comparison::KNOWN_OPTIONS
26
+ # -- a key that hash accepts and nothing downstream reads is the
27
+ # silent no-op ADR-010 forbids. Deleted before anything else touches
28
+ # the hash, so no later split has to know about it.
29
+ @record_mode = resolve_record_mode(@driver_options.delete(:record))
18
30
 
19
31
  @screenshot_format = @driver_options[:screenshot_format]
20
32
  @snapshot = SnapDiff::SnapManager.snapshot(screenshot_full_name, @screenshot_format)
21
33
  end
22
34
 
23
35
  def build_screenshot_assertion(skip_stack_frames: 0)
24
- Capture::Viewport.prepare!(Capybara::Screenshot.window_size)
36
+ # Here rather than in #initialize, so it covers exactly the path where
37
+ # `:all` means anything. #capture never compares against a baseline, so
38
+ # the mode has nothing to say about it and refusing there would be a
39
+ # failure invented out of a setting that changes nothing.
40
+ refuse_bulk_record_under_ci! if record_mode == :all
41
+
42
+ Capture::Viewport.prepare!(SnapDiff.config.window_size)
25
43
  prepare_screenshot_options
26
44
  check_base_screenshot
27
45
 
@@ -29,10 +47,14 @@ module SnapDiff
29
47
 
30
48
  capture_screenshot(capture_options, comparison_options)
31
49
 
50
+ # AFTER the capture, so the path the message tells the user to
51
+ # `git add` is one that exists by the time they read it (#260).
52
+ fail_if_new_screenshot
53
+
32
54
  # Pre-computation: No need to compare without base screenshot
33
55
  # NOTE: Consider to return PreValid Assertion Value Object with hard coded valid result
34
56
  unless need_to_compare?
35
- SnapDiff.session.record_new_screenshot(screenshot_full_name)
57
+ record_uncompared_screenshot
36
58
  return
37
59
  end
38
60
 
@@ -41,7 +63,7 @@ module SnapDiff
41
63
 
42
64
  # Captures a screenshot without comparing it to a baseline.
43
65
  def capture
44
- Capture::Viewport.prepare!(Capybara::Screenshot.window_size)
66
+ Capture::Viewport.prepare!(SnapDiff.config.window_size)
45
67
  prepare_screenshot_options
46
68
 
47
69
  capture_options, comparison_options = extract_capture_and_comparison_options(driver_options)
@@ -52,10 +74,53 @@ module SnapDiff
52
74
 
53
75
  private
54
76
 
77
+ # The per-screenshot option outranks the configured mode, which in turn
78
+ # outranks `fail_if_new` (SnapDiff::Config#record). One resolution, so
79
+ # both routes to a mode agree about everything downstream.
80
+ def resolve_record_mode(per_screenshot)
81
+ SnapDiff::Config.validate_record_mode!(per_screenshot) || SnapDiff.config.record
82
+ end
83
+
84
+ # `:all` accepts every rendering as correct -- that is the feature. On
85
+ # CI there is nobody to review the result and the re-recorded files go
86
+ # away with the box, so a mode left in a committed config file buys a
87
+ # build that compares nothing and passes, forever. That is precisely how
88
+ # Percy goes green on a job that lost its token; refuse it rather than
89
+ # shipping our own version.
90
+ #
91
+ # A CI job that must record NEW baselines does not need `:all` at all:
92
+ # `record: :once` records them and still compares everything that has a
93
+ # baseline (docs/ci-integration.md).
94
+ def refuse_bulk_record_under_ci!
95
+ return if ENV["CI"].to_s.empty?
96
+
97
+ raise SnapDiff::ExpectationNotMet.new(<<~ERROR.chomp, caller)
98
+ `record: :all` re-records every baseline WITHOUT comparing, so it refuses to run under CI (ENV["CI"] is set).
99
+ Nothing would be verified and the recorded screenshots would be discarded with the runner.
100
+ Re-record locally, review the result, and commit the screenshots.
101
+ To let a CI job record screenshots that have no baseline yet: SnapDiff.config.record = :once
102
+ ERROR
103
+ end
104
+
105
+ # No `record_mode != :all` clause here on purpose: mutation testing
106
+ # showed one guards nothing. #check_base_screenshot already leaves `:all`
107
+ # with no base file at all, so this reads false for it either way, and a
108
+ # second condition that cannot fire is a second thing to keep true.
55
109
  def need_to_compare?
56
110
  @snapshot.base_path.exist?
57
111
  end
58
112
 
113
+ # `:all` re-records, so nothing was compared and nothing is "new" in the
114
+ # missing-baseline sense. Reported through its own channel, which counts
115
+ # only the screenshots that really went down this path.
116
+ def record_uncompared_screenshot
117
+ if record_mode == :all
118
+ SnapDiff::Reporting.record_rerecorded_baseline(screenshot_full_name)
119
+ else
120
+ SnapDiff.session.record_new_screenshot(screenshot_full_name)
121
+ end
122
+ end
123
+
59
124
  def prepare_screenshot_options
60
125
  area_calculator = AreaCalculator.new(driver_options.delete(:crop), driver_options[:skip_area])
61
126
 
@@ -64,23 +129,65 @@ module SnapDiff
64
129
  driver_options[:driver] = SnapDiff::Drivers.for(driver_options[:driver])
65
130
  end
66
131
 
132
+ # The git checkout that drives #need_to_compare?, plus the half of the
133
+ # no-baseline reporting that MUST run before the capture: it is the only
134
+ # moment at which `@snapshot.path` still tells us whether the user had a
135
+ # PNG sitting there already -- the case that confuses people most.
67
136
  def check_base_screenshot
137
+ # `:all` does not ask git for a baseline -- there is nothing to
138
+ # compare against. A `.base.<fmt>` left by an earlier failing run
139
+ # would otherwise sit beside the re-recorded screenshot and land in
140
+ # the user's `git add`.
141
+ return discard_base_screenshot if record_mode == :all
142
+
68
143
  @snapshot.checkout_base_screenshot
144
+ return if @snapshot.base_path.exist?
145
+ # fail_if_new_screenshot raises after the capture and says the same
146
+ # thing with the fix attached; two messages for one missing baseline
147
+ # is one too many.
148
+ return if record_mode == :none
69
149
 
70
- if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist?
71
- raise SnapDiff::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
150
+ warn_no_committed_baseline
151
+ end
152
+
153
+ def discard_base_screenshot
154
+ @snapshot.base_path.delete if @snapshot.base_path.exist?
155
+ end
156
+
157
+ # Runs AFTER the capture, so `@snapshot.path` names a file that is
158
+ # really there and `git add` on it is a command the user can run on this
159
+ # very test run (#260). Before, the raise came first and the screenshot
160
+ # was never written -- the instruction was unfollowable in CI, the one
161
+ # place fail_if_new is on by default.
162
+ def fail_if_new_screenshot
163
+ return if @snapshot.base_path.exist?
164
+ return unless record_mode == :none
165
+
166
+ raise SnapDiff::ExpectationNotMet.new(<<~ERROR.chomp, caller)
167
+ No existing screenshot found for #{@snapshot.path}!
168
+ To record it: `git add #{@snapshot.path}` and commit -- baselines are read from git.
169
+ To allow new screenshots: SnapDiff.config.record = :once (was: SnapDiff.config.fail_if_new = false)
170
+ ERROR
171
+ end
172
+
173
+ # `fail_if_new` defaults to false off CI, deliberately: a new screenshot
174
+ # must not break a local run. The cost is that nothing is compared and
175
+ # the test passes whatever the page looks like, while the capture
176
+ # overwrites the file on disk -- a green run that proves nothing. Say so
177
+ # once per screenshot, and name what to do about it.
178
+ def warn_no_committed_baseline
179
+ return unless SnapDiff::Reporting.record_missing_baseline(screenshot_full_name)
180
+
181
+ already_there = @snapshot.path.exist? ? " (the file already there is not a baseline until it is committed)" : ""
182
+ warn "[snap_diff] No committed baseline for #{@snapshot.path}#{already_there} -- nothing was compared. " \
183
+ "Commit it to enable comparison."
77
184
  end
78
185
 
79
186
  def capture_screenshot(capture_options, comparison_options)
80
187
  screenshoter = if capture_options[:stability_time_limit]
81
188
  StableScreenshoter.new(capture_options, comparison_options)
82
189
  else
83
- Capybara::Screenshot::Diff.screenshoter.new(capture_options, comparison_options)
190
+ SnapDiff.config.screenshoter.new(capture_options, comparison_options)
84
191
  end
85
192
  screenshoter.take_comparison_screenshot(@snapshot)
86
193
  end
@@ -11,15 +11,10 @@ module SnapDiff
11
11
  class ScreenshotNamer
12
12
  attr_reader :section, :group
13
13
 
14
- def initialize(screenshot_area = nil)
14
+ def initialize
15
15
  @section = nil
16
16
  @group = nil
17
17
  @counter = nil
18
- @screenshot_area = screenshot_area
19
- end
20
-
21
- def screenshot_area
22
- @screenshot_area ||= Capybara::Screenshot.screenshot_area
23
18
  end
24
19
 
25
20
  # Sets the current section for screenshots.
@@ -50,13 +45,6 @@ module SnapDiff
50
45
  File.join(*directory_parts.push(name.to_s))
51
46
  end
52
47
 
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
48
  # Returns the directory parts (section and group) for constructing paths.
61
49
  # @return [Array<String>] An array of directory names.
62
50
  def directory_parts
@@ -66,12 +54,6 @@ module SnapDiff
66
54
  parts
67
55
  end
68
56
 
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
57
  private
76
58
 
77
59
  def reset_group_counter
@@ -51,9 +51,7 @@ module SnapDiff
51
51
  screenshot_image = driver.from_file(stored_path)
52
52
 
53
53
  # TODO(uwe): Remove when chromedriver takes right size screenshots
54
- # TODO: Adds tests when this case is true
55
54
  screenshot_image = resize_if_needed(screenshot_image) if selenium_with_retina_screen?
56
- # ODOT
57
55
 
58
56
  screenshot_image = driver.crop(crop, screenshot_image) if crop
59
57
 
@@ -71,10 +69,10 @@ module SnapDiff
71
69
  def prepare_page_for_screenshot(timeout:)
72
70
  wait_images_loaded(timeout: timeout) if timeout
73
71
 
74
- blurred_input = BrowserHelpers.blur_from_focused_element if Capybara::Screenshot.blur_active_element
72
+ blurred_input = BrowserHelpers.blur_from_focused_element if SnapDiff.config.blur_active_element
75
73
 
76
- BrowserHelpers.hide_caret if Capybara::Screenshot.hide_caret
77
- BrowserHelpers.disable_animations if Capybara::Screenshot.disable_animations
74
+ BrowserHelpers.hide_caret if SnapDiff.config.hide_caret
75
+ BrowserHelpers.disable_animations if SnapDiff.config.disable_animations
78
76
 
79
77
  blurred_input
80
78
  end
@@ -113,7 +111,7 @@ module SnapDiff
113
111
  end
114
112
 
115
113
  def resize_if_needed(saved_image)
116
- expected_image_width = Capybara::Screenshot.window_size[0]
114
+ expected_image_width = SnapDiff.config.window_size[0]
117
115
  return saved_image if driver.width_for(saved_image) < expected_image_width * 2
118
116
 
119
117
  notice_how_to_avoid_this
@@ -123,7 +121,7 @@ module SnapDiff
123
121
  end
124
122
 
125
123
  def selenium_with_retina_screen?
126
- Os::ON_MAC && BrowserHelpers.selenium? && Capybara::Screenshot.window_size
124
+ Os::ON_MAC && BrowserHelpers.selenium? && SnapDiff.config.window_size
127
125
  end
128
126
  end
129
127
  end
@@ -20,8 +20,13 @@ module SnapDiff
20
20
  cleanup_attempts!
21
21
  end
22
22
 
23
+ # Records the successful checkout on the session, so that a LATER
24
+ # "there is no baseline" reading can be told apart from "there never
25
+ # was one". Only the second is a legitimate state (#217).
23
26
  def checkout_base_screenshot
24
- @manager.checkout_file(path, base_path)
27
+ @manager.checkout_file(path, base_path).tap do |checked_out|
28
+ SnapDiff.session.record_baseline_checkout(full_name) if checked_out
29
+ end
25
30
  end
26
31
 
27
32
  def path_for(version = :actual)
@@ -55,7 +55,6 @@ module SnapDiff
55
55
  path ? path.dirname.mkpath : root.mkpath
56
56
  end
57
57
 
58
- # TODO: rename to delete!
59
58
  def cleanup!
60
59
  snapshots.each do |snapshot|
61
60
  cleanup_attempts!(snapshot)
@@ -111,8 +110,8 @@ module SnapDiff
111
110
  # test/fixtures/app/doc/screenshots/ that VCS rollback just restored.
112
111
  # Do not reorder those teardowns without revisiting this.
113
112
  def self.instance
114
- manager_class = Capybara::Screenshot::Diff.manager
115
- root = Pathname.new(Capybara::Screenshot.screenshot_area_abs)
113
+ manager_class = SnapDiff.config.manager
114
+ root = Pathname.new(SnapDiff.config.screenshot_area_abs)
116
115
 
117
116
  current = Thread.current[:snap_diff_manager]
118
117
  unless current&.instance_of?(manager_class) && current.root == root
@@ -12,7 +12,7 @@ module SnapDiff
12
12
  # `:stability_time_limit` and `:wait` in capture options and ensures that `:stability_time_limit` is less than or equal to `:wait`.
13
13
  #
14
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}.
15
+ # @param comparison_options [Hash] The options for comparing screenshots, defaults to `{}`. Same signature as {SnapDiff::Screenshoter#initialize}.
16
16
  # @raise [ArgumentError] If `:wait` or `:stability_time_limit` are not provided, or if `:stability_time_limit` is greater than `:wait`.
17
17
  def initialize(capture_options, comparison_options = {})
18
18
  @stability_time_limit, @wait = capture_options.fetch_values(*STABILITY_OPTIONS)
@@ -23,7 +23,7 @@ module SnapDiff
23
23
 
24
24
  @comparison_options = comparison_options
25
25
 
26
- @screenshoter = Capybara::Screenshot::Diff.screenshoter.new(capture_options.except(:stability_time_limit), @comparison_options)
26
+ @screenshoter = SnapDiff.config.screenshoter.new(capture_options.except(:stability_time_limit), @comparison_options)
27
27
  end
28
28
 
29
29
  # Takes a comparison screenshot ensuring page stability
@@ -6,6 +6,6 @@ require_relative "integrations/minitest"
6
6
  module SnapDiff
7
7
  def self.serve(directory, root: Dir.pwd)
8
8
  Capybara.app = Rack::Files.new(directory)
9
- Capybara::Screenshot.root = root
9
+ SnapDiff.config.root = root
10
10
  end
11
11
  end
@@ -1,30 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "snap_diff/drivers"
4
+ require "snap_diff/removal"
4
5
 
5
6
  module SnapDiff
6
7
  module Utils
8
+ # THE selection funnel. Every surface that picks a driver ends up here --
9
+ # `driver: :chunky_png` per comparison, `SnapDiff.config.driver =`, the
10
+ # legacy `Diff.driver =` (a delegator onto the same storage), and `:auto`
11
+ # -- so this is the one place the chunky_png removal has to be announced
12
+ # from. Warned before the registry lookup, not inside it: the cache is a
13
+ # `||=`, so a hook there would fire only for the first comparison of a
14
+ # process that happened to miss.
15
+ CHUNKY_PNG_REMOVED =
16
+ "The chunky_png driver is REMOVED in 2.1, when libvips (the `ruby-vips` gem) becomes " \
17
+ "required. Install ruby-vips and drop `driver: :chunky_png`. See docs/drivers.md."
18
+
19
+ # The case that matters most: nobody asked for this driver, so the
20
+ # warning has to say why they are on it.
21
+ CHUNKY_PNG_AUTO_REMOVED =
22
+ "`driver: :auto` selected chunky_png because libvips is not available in this process. " \
23
+ "The chunky_png driver is REMOVED in 2.1, when libvips (the `ruby-vips` gem) becomes " \
24
+ "required -- install it now, or this setup stops comparing on 2.1. See docs/drivers.md."
25
+
26
+ # Detection itself lives on Drivers now (its canonical home -- so that
27
+ # `require "snap_diff/drivers"` standalone can answer .available); this
28
+ # keeps the documented Utils name working. One-way: Drivers never calls
29
+ # back here at load time, so requiring either file first is safe.
7
30
  def self.detect_available_drivers
8
- result = []
9
- begin
10
- result << :vips if defined?(Vips) || require("vips")
11
- rescue LoadError
12
- # vips not present
13
- Object.send(:remove_const, :Vips) if defined?(Vips)
14
- end
15
- begin
16
- result << :chunky_png if defined?(ChunkyPNG) || require("chunky_png")
17
- rescue LoadError
18
- # chunky_png not present
19
- Object.send(:remove_const, :ChunkyPNG) if defined?(ChunkyPNG)
20
- end
21
- result
31
+ Drivers.detect_available
22
32
  end
23
33
 
24
34
  def self.find_driver_class_for(driver)
25
- driver = Drivers.available.first if driver == :auto
35
+ if driver == :auto
36
+ # Drivers::AVAILABLE_DRIVERS, not Drivers.available: same value and
37
+ # the same stubbing point, without the gem tripping .available's own
38
+ # removal warning on every comparison.
39
+ driver = Drivers::AVAILABLE_DRIVERS.first
40
+ Removal.warn_once(:chunky_png_auto, CHUNKY_PNG_AUTO_REMOVED) if driver == :chunky_png
41
+ elsif driver == :chunky_png
42
+ Removal.warn_once(:chunky_png, CHUNKY_PNG_REMOVED)
43
+ end
26
44
 
27
- Drivers.loaded[driver] ||=
45
+ Drivers.registry[driver] ||=
28
46
  case driver
29
47
  when :chunky_png
30
48
  require "snap_diff/drivers/chunky_png_driver"
@@ -33,7 +51,7 @@ module SnapDiff
33
51
  require "snap_diff/drivers/vips_driver"
34
52
  SnapDiff::Drivers::VipsDriver
35
53
  else
36
- fail "Wrong adapter #{driver.inspect}. Available adapters: #{Drivers.available.inspect}"
54
+ fail "Wrong adapter #{driver.inspect}. Available adapters: #{Drivers::AVAILABLE_DRIVERS.inspect}"
37
55
  end
38
56
  end
39
57
  end
data/lib/snap_diff/vcs.rb CHANGED
@@ -5,23 +5,32 @@ require_relative "os"
5
5
 
6
6
  module SnapDiff
7
7
  module Vcs
8
+ # `-C <dir>` sets the working directory, but GIT_DIR/GIT_WORK_TREE OVERRIDE
9
+ # it -- so a suite launched from a git hook (which exports both) reads the
10
+ # WRONG repository. Every baseline lookup then fails, and because
11
+ # `fail_if_new` is false locally, screenshots are recorded as new and the
12
+ # tests PASS. Scrubbing them makes `-C` mean what this code already assumes.
13
+ GIT_ENV = {"GIT_DIR" => nil, "GIT_WORK_TREE" => nil, "GIT_INDEX_FILE" => nil}.freeze
14
+
15
+ @git_roots = {}
16
+ @git_roots_lock = Mutex.new
17
+
8
18
  def self.checkout_vcs(root, screenshot_path, checkout_path)
9
19
  root_path = root.to_s
10
- git_root, _, status = Open3.capture3("git", "-C", root_path, "rev-parse", "--show-toplevel")
11
- return false unless status.success?
20
+ git_root = git_root_for(root_path)
21
+ return false unless git_root
12
22
 
13
- git_root = git_root.chomp
14
23
  vcs_file_path = Pathname.new(screenshot_path).expand_path.relative_path_from(Pathname.new(git_root)).to_s
15
24
 
16
- if Capybara::Screenshot.use_lfs
25
+ if SnapDiff.config.use_lfs
17
26
  tmp_path = "#{checkout_path}.tmp"
18
- success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: tmp_path, err: File::NULL)
27
+ success = system(GIT_ENV, "git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: tmp_path, err: File::NULL)
19
28
  if success
20
- system("git", "-C", root_path, "lfs", "smudge", in: tmp_path, out: checkout_path.to_s, err: File::NULL)
29
+ system(GIT_ENV, "git", "-C", root_path, "lfs", "smudge", in: tmp_path, out: checkout_path.to_s, err: File::NULL)
21
30
  end
22
31
  File.delete(tmp_path) if File.exist?(tmp_path)
23
32
  else
24
- success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: checkout_path.to_s, err: File::NULL)
33
+ success = system(GIT_ENV, "git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: checkout_path.to_s, err: File::NULL)
25
34
  end
26
35
 
27
36
  unless success
@@ -31,5 +40,29 @@ module SnapDiff
31
40
 
32
41
  true
33
42
  end
43
+
44
+ # `git rev-parse --show-toplevel` costs a process spawn (~6ms) and used to
45
+ # run once per screenshot -- 200 screenshots, 200 spawns, all answering the
46
+ # same question. The repository a directory belongs to does not change
47
+ # while the suite runs, so remember it per directory. `false` (not a repo)
48
+ # is remembered too: that is the every-assertion answer for anyone whose
49
+ # screenshots live outside a git checkout.
50
+ #
51
+ # Synchronized because the lookup itself is what must not be duplicated:
52
+ # MRI releases the GVL for the whole of `Open3.capture3`, so eight threads
53
+ # asking about one root all miss `key?` before any of them writes -- eight
54
+ # spawns, the exact cost this cache exists to remove. Threads are the
55
+ # default parallel mode on JRuby, which has no GVL to make the Hash write
56
+ # safe either. Holding the lock across the spawn is deliberate: callers
57
+ # almost always share one root, so the other threads wait once and then
58
+ # read the cache, which is the outcome we want.
59
+ def self.git_root_for(root_path)
60
+ @git_roots_lock.synchronize do
61
+ next @git_roots[root_path] if @git_roots.key?(root_path)
62
+
63
+ git_root, _, status = Open3.capture3(GIT_ENV, "git", "-C", root_path, "rev-parse", "--show-toplevel")
64
+ @git_roots[root_path] = status.success? && git_root.chomp
65
+ end
66
+ end
34
67
  end
35
68
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SnapDiff
4
- VERSION = "2.0.0.beta2"
4
+ VERSION = "2.0.0.beta4"
5
5
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler.require entry point for `gem "snap_diff-capybara"`: Bundler
4
+ # requires the gem's own name, and its dash->slash fallback ("snap_diff/
5
+ # capybara") misses too, so without this file a Rails user gets a silent
6
+ # no-op and a confusing NameError later. The sibling
7
+ # capybara-screenshot-diff.rb is the same door under the v1 gem name and
8
+ # forwards here.
9
+ #
10
+ # Everything below therefore loads for EVERY consumer, RSpec and Cucumber
11
+ # users included, so nothing outside the gem's declared runtime dependencies
12
+ # may be hard-required. minitest is not one of them -- the gemspec declares
13
+ # capybara only -- and requiring it here killed an RSpec-only bundle at boot
14
+ # with `cannot load such file -- minitest`, from a gem that ships a
15
+ # first-class RSpec integration.
16
+ #
17
+ # So: load the gem, then feature-detect minitest. Present is the documented
18
+ # zero-require Rails path and still activates the assertions. Absent gets a
19
+ # line saying so -- a gem that loads and then does nothing, silently, is its
20
+ # own bug report.
21
+ # This IS the canonical door, and it loads the v1 umbrella below -- so claim
22
+ # the process before that require, or every canonical user is told to
23
+ # migrate off an API they never touched.
24
+ require "snap_diff/deprecation"
25
+ SnapDiff::Deprecation.canonical_entry_point!
26
+
27
+ require "capybara_screenshot_diff"
28
+
29
+ begin
30
+ require "minitest"
31
+ rescue LoadError
32
+ warn "[snap_diff] minitest is not in this bundle, so `Bundler.require` activated no test-framework " \
33
+ "integration. Require the one you use -- `require \"snap_diff/integrations/rspec\"` or " \
34
+ "`require \"snap_diff/integrations/cucumber\"` -- and set `require: false` on the gem in your " \
35
+ "Gemfile to silence this. See docs/framework-setup.md."
36
+ end
37
+
38
+ require "capybara_screenshot_diff/minitest" if defined?(::Minitest)
data/lib/snap_diff.rb CHANGED
@@ -6,8 +6,14 @@
6
6
  # undetectable. Refuse that setup at the entry point. Local dev from
7
7
  # source loads neither spec, so the guard fires only when both are
8
8
  # genuinely installed as gems.
9
+ #
10
+ # snap_diff/errors is pulled in first (two dependency-free files) so
11
+ # DualInstallError can sit under SnapDiff::Error like every other error the
12
+ # gem raises -- docs/snapdiff.md promises Error is the catch-all.
13
+ require "snap_diff/errors"
14
+
9
15
  module SnapDiff
10
- DualInstallError = Class.new(StandardError)
16
+ DualInstallError = Class.new(Error)
11
17
 
12
18
  # @api private
13
19
  def self.assert_single_gem!(loaded_specs = Gem.loaded_specs)
@@ -21,59 +27,54 @@ module SnapDiff
21
27
  end
22
28
  SnapDiff.assert_single_gem!
23
29
 
24
- # None of these requires ever leads back to this file: config_legacy sits
25
- # just above the snap_diff/config leaf (see both headers -- since ADR-008
26
- # step 1 the storage leaf is snap_diff/config, which config_legacy
27
- # requires), the image_compare forwarder pulls in
28
- # snap_diff/comparison plus the legacy const_missing shims, and
29
- # "capybara/dsl" is the base gem. That keeps this file's require graph
30
- # acyclic -- it never requires "capybara_screenshot_diff" back, unlike the
31
- # old autoload-based wiring this replaced. The forwarder path (not
32
- # "snap_diff/comparison" directly) is deliberate: it installs
33
- # snap_diff/legacy_shims, so the old Capybara::Screenshot::Diff constants
34
- # stay resolvable (now with deprecation warnings) even in processes that
35
- # only ever require "snap_diff".
30
+ # This lean entry must never load the umbrella "capybara_screenshot_diff"
31
+ # -- snap_diff_test.rb's "bare require never loads the umbrella" guard
32
+ # enforces it -- so nothing required below may reach back here. None of
33
+ # these requires reaches into lib/capybara* at all, so the canonical entry
34
+ # point is exactly what 3.0 keeps.
35
+ #
36
36
  # "capybara/dsl" is needed directly (not just transitively) so
37
37
  # `Capybara.default_max_wait_time` in Config#default_options resolves even
38
38
  # when "snap_diff" is required standalone (SnapDiffTest's
39
39
  # "standalone-loadable in a fresh process" regression test).
40
+ #
41
+ # snap_diff/legacy_shims is deliberate and is the ONE line here that 3.0
42
+ # drops: it carries the whole v1 surface (const_missing forwarders, the old
43
+ # mattr_accessors, SnapDiff.start), so a process that only ever requires
44
+ # "snap_diff" still resolves the old Capybara::Screenshot::Diff names --
45
+ # with deprecation warnings -- exactly as it did when this file reached
46
+ # through the capybara/screenshot/diff/* forwarders to get them.
40
47
  require "capybara/dsl"
41
- require "capybara/screenshot/diff/config_legacy"
42
- require "capybara/screenshot/diff/image_compare"
43
- require "snap_diff/errors"
44
48
  require "snap_diff/config"
49
+ require "snap_diff/comparison"
50
+ require "snap_diff/legacy_shims"
51
+ require "snap_diff/version"
52
+ # SnapDiff.session/.reset/.pending_screenshots_message are part of the
53
+ # documented core surface (docs/snapdiff.md object map lists them with no
54
+ # extra require), so the entry point owns them rather than leaving them to
55
+ # whichever integration happens to be loaded.
56
+ require "snap_diff/screenshot_assertion"
45
57
 
46
- # Forward-looking namespace for the gem, per ADR-004.
47
- #
48
- # Since v2 step 5 the comparison implementation lives here
49
- # ({SnapDiff::Comparison}, {SnapDiff::ComparisonResult}); since step 6 the
50
- # old +Capybara::Screenshot::Diff+ constants are same-object const_missing
51
- # shims (snap_diff/legacy_shims) that emit a deprecation warning once per
52
- # constant per process. See ADR-004 for the full migration plan.
58
+ # The canonical namespace for the gem. The old
59
+ # +Capybara::Screenshot::Diff+ constants are same-object const_missing shims
60
+ # (snap_diff/legacy_shims) that warn once per constant per process.
53
61
  module SnapDiff
54
- # Compare two images on disk with the configured defaults. Canonical home
55
- # since ADR-008 step 7b; +Capybara::Screenshot::Diff.compare+ now forwards
56
- # here rather than the other way round.
62
+ # Compare two images on disk with the configured defaults. Canonical home;
63
+ # +Capybara::Screenshot::Diff.compare+ forwards here.
57
64
  #
58
65
  # Note the argument order swap: callers pass baseline first (reading
59
66
  # "compare baseline against current"), Comparison takes current first.
60
67
  def self.compare(baseline_path, current_path, **options)
68
+ # BEFORE the merge, which is the last moment `driver:` still means "the
69
+ # caller asked for a backend" rather than "config.default_options
70
+ # carries the key for everyone".
71
+ Removal.warn_once(:driver_setting, Removal::DRIVER_REMOVED) if options.key?(:driver)
61
72
  Comparison.new(current_path, baseline_path, config.default_options.merge(options))
62
73
  end
63
74
 
64
- # v1-style configuration: yields the two legacy accessor holders
65
- # (+Capybara::Screenshot+, +Capybara::Screenshot::Diff+) exactly as
66
- # +Capybara::Screenshot::Diff.configure+ always has -- and, since ADR-008
67
- # step 7b, this is where that yield actually happens; Diff.configure
68
- # forwards here. Both names stay identical in call shape.
69
- #
70
- # SnapDiff.start do |screenshot, diff|
71
- # screenshot.window_size = [1280, 1024]
72
- # diff.tolerance = 0.0005
73
- # end
74
- def self.start
75
- yield Capybara::Screenshot, Capybara::Screenshot::Diff
76
- end
75
+ # SnapDiff.start -- the v1-shaped two-holder config block -- is defined in
76
+ # snap_diff/legacy_shims (required above), because the holders it yields
77
+ # are the v1 surface and it cannot outlive them.
77
78
 
78
79
  # Forward-looking configuration: yields the single consolidated
79
80
  # {SnapDiff::Config} object instead of the two old holders. Same