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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +190 -0
- data/CODE_OF_CONDUCT.md +129 -0
- data/LICENSE.txt +21 -0
- data/Rakefile +65 -0
- data/capybara-screenshot-diff.gemspec +29 -0
- data/docs/RELEASE_PREP.md +44 -0
- data/docs/UPGRADING.md +702 -0
- data/docs/architecture.md +289 -0
- data/docs/ci-integration.md +238 -0
- data/docs/configuration.md +395 -0
- data/docs/docker-testing.md +24 -0
- data/docs/drivers.md +102 -0
- data/docs/framework-setup.md +87 -0
- data/docs/images/snap_diff_annotated.png +0 -0
- data/docs/images/snap_diff_web_ui.png +0 -0
- data/docs/migration-guide.md +286 -0
- data/docs/organization.md +204 -0
- data/docs/reporters.md +46 -0
- data/docs/thread_safety.md +112 -0
- data/gems.rb +39 -0
- data/lib/capybara/screenshot/diff/annotation_service.rb +7 -0
- data/lib/capybara/screenshot/diff/area_calculator.rb +7 -0
- data/lib/capybara/screenshot/diff/browser_helpers.rb +7 -0
- data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
- data/lib/capybara/screenshot/diff/cucumber.rb +3 -0
- data/lib/capybara/screenshot/diff/difference.rb +8 -0
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +9 -0
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +7 -0
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +7 -0
- data/lib/capybara/screenshot/diff/drivers.rb +9 -0
- data/lib/capybara/screenshot/diff/image_compare.rb +12 -0
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +7 -0
- data/lib/capybara/screenshot/diff/os.rb +14 -0
- data/lib/capybara/screenshot/diff/region.rb +107 -0
- data/lib/capybara/screenshot/diff/reporters/default.rb +109 -0
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +7 -0
- data/lib/capybara/screenshot/diff/screenshoter.rb +7 -0
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -0
- data/lib/capybara/screenshot/diff/utils.rb +7 -0
- data/lib/capybara/screenshot/diff/vcs.rb +7 -0
- data/lib/capybara/screenshot/diff/version.rb +15 -0
- data/lib/capybara/screenshot/diff.rb +3 -0
- data/lib/capybara-screenshot-diff.rb +3 -0
- data/lib/capybara_screenshot_diff/attempts_reporter.rb +7 -0
- data/lib/capybara_screenshot_diff/cucumber.rb +8 -0
- data/lib/capybara_screenshot_diff/dsl.rb +12 -0
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +8 -0
- data/lib/capybara_screenshot_diff/minitest.rb +14 -0
- data/lib/capybara_screenshot_diff/reporters/html.rb +7 -0
- data/lib/capybara_screenshot_diff/rspec.rb +8 -0
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +71 -0
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +7 -0
- data/lib/capybara_screenshot_diff/snap.rb +7 -0
- data/lib/capybara_screenshot_diff/snap_manager.rb +7 -0
- data/lib/capybara_screenshot_diff/static.rb +9 -0
- data/lib/capybara_screenshot_diff.rb +50 -0
- data/lib/snap_diff/annotation_service.rb +84 -0
- data/lib/snap_diff/area_calculator.rb +56 -0
- data/lib/snap_diff/attempts_reporter.rb +51 -0
- data/lib/snap_diff/browser_helpers.rb +121 -0
- data/lib/snap_diff/capture/viewport.rb +40 -0
- data/lib/snap_diff/comparison.rb +238 -0
- data/lib/snap_diff/comparison_result.rb +104 -0
- data/lib/snap_diff/config.rb +78 -0
- data/lib/snap_diff/deprecation.rb +84 -0
- data/lib/snap_diff/driver.rb +37 -0
- data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
- data/lib/snap_diff/drivers/vips_driver.rb +171 -0
- data/lib/snap_diff/drivers.rb +14 -0
- data/lib/snap_diff/dsl.rb +143 -0
- data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
- data/lib/snap_diff/image_preprocessor.rb +68 -0
- data/lib/snap_diff/integrations/cucumber.rb +22 -0
- data/lib/snap_diff/integrations/minitest.rb +70 -0
- data/lib/snap_diff/integrations/rspec.rb +68 -0
- data/lib/snap_diff/legacy_shims.rb +99 -0
- data/lib/snap_diff/os.rb +17 -0
- data/lib/snap_diff/reporters/html.rb +143 -0
- data/lib/snap_diff/reporters/templates/report.html.erb +463 -0
- data/lib/snap_diff/reporting.rb +53 -0
- data/lib/snap_diff/screenshot_assertion.rb +143 -0
- data/lib/snap_diff/screenshot_matcher.rb +113 -0
- data/lib/snap_diff/screenshot_namer.rb +81 -0
- data/lib/snap_diff/screenshoter.rb +129 -0
- data/lib/snap_diff/snap.rb +66 -0
- data/lib/snap_diff/snap_manager.rb +125 -0
- data/lib/snap_diff/stable_screenshoter.rb +103 -0
- data/lib/snap_diff/static.rb +11 -0
- data/lib/snap_diff/utils.rb +38 -0
- data/lib/snap_diff/vcs.rb +35 -0
- data/lib/snap_diff/version.rb +5 -0
- data/lib/snap_diff.rb +65 -0
- metadata +160 -10
- 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,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
|