capybara-screenshot-diff 1.10.3.1 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/CODE_OF_CONDUCT.md +129 -0
- data/Rakefile +29 -1
- data/capybara-screenshot-diff.gemspec +4 -3
- data/docs/RELEASE_PREP.md +42 -0
- data/docs/UPGRADING.md +390 -0
- data/docs/architecture.md +270 -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 +97 -0
- data/gems.rb +2 -1
- data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
- data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
- data/lib/capybara/screenshot/diff/comparison.rb +3 -0
- data/lib/capybara/screenshot/diff/difference.rb +40 -3
- data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
- data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
- data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +77 -36
- data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
- data/lib/capybara/screenshot/diff/vcs.rb +19 -52
- data/lib/capybara/screenshot/diff/version.rb +1 -1
- data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
- data/lib/capybara_screenshot_diff/cucumber.rb +11 -0
- data/lib/capybara_screenshot_diff/dsl.rb +123 -7
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
- data/lib/capybara_screenshot_diff/minitest.rb +10 -5
- data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
- data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
- data/lib/capybara_screenshot_diff/rspec.rb +20 -4
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +73 -22
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
- data/lib/capybara_screenshot_diff/snap.rb +14 -3
- data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
- data/lib/capybara_screenshot_diff/static.rb +11 -0
- data/lib/capybara_screenshot_diff.rb +31 -5
- metadata +51 -8
- data/lib/capybara/screenshot/diff/test_methods.rb +0 -157
data/docs/reporters.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Reporters
|
|
2
|
+
|
|
3
|
+
## Web UI for Reviewing Screenshot Changes
|
|
4
|
+
|
|
5
|
+
Generate an interactive Web UI report of screenshot differences:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Add to test_helper.rb — one line, that's it
|
|
9
|
+
require 'capybara_screenshot_diff/reporters/html'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
After running tests, open the report (generated only when there are failures):
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
open doc/screenshots/snap_diff_report.html
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The report includes a sidebar with thumbnails, side-by-side comparison with diff toggle, search, and summary stats. No configuration needed — just require it.
|
|
19
|
+
|
|
20
|
+
**Note:** The report is not generated when all screenshots match. In parallel test environments, each worker writes to the same file — the last worker's results will be in the report.
|
|
21
|
+
|
|
22
|
+
## Custom Reporters
|
|
23
|
+
|
|
24
|
+
Build your own reporter by implementing `record` and `finalize`:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class MyReporter
|
|
28
|
+
def record(assertions)
|
|
29
|
+
assertions.each do |assertion|
|
|
30
|
+
next unless assertion.compare&.difference&.different?
|
|
31
|
+
# process the failure — send to Slack, write JSON, etc.
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def finalize
|
|
36
|
+
# called once at process exit — write summary, upload report, etc.
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Register in test_helper.rb
|
|
41
|
+
CapybaraScreenshotDiff.reporters << MyReporter.new
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Reporters are notified before assertions are cleared on each test teardown. `finalize` is called via `at_exit`.
|
|
45
|
+
|
|
46
|
+
[← Back to README](../README.md)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Thread Safety Guide for Parallel Testing
|
|
2
|
+
|
|
3
|
+
This document explains how `snap_diff` behaves under Rails parallel tests with the `:thread` strategy.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`snap_diff` is thread safe for parallel test execution as long as global configuration is set before tests run. Per-thread state is isolated, and shared state is protected where it matters.
|
|
8
|
+
|
|
9
|
+
## Architecture Summary
|
|
10
|
+
|
|
11
|
+
### Per-thread Assertion Registry
|
|
12
|
+
|
|
13
|
+
Each thread gets its own `AssertionRegistry` stored in thread-local storage:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
def registry
|
|
17
|
+
Thread.current[:capybara_screenshot_diff_registry] ||= AssertionRegistry.new
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This prevents cross-thread leakage for assertions and screenshot naming.
|
|
22
|
+
|
|
23
|
+
### Reporters Snapshot on Notify
|
|
24
|
+
|
|
25
|
+
Reporters are notified using a snapshot protected by an eagerly initialized mutex:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
@reporters_mutex = Mutex.new
|
|
29
|
+
|
|
30
|
+
def notify_reporters(assertions)
|
|
31
|
+
reporters_snapshot = reporters_mutex.synchronize { reporters.dup }
|
|
32
|
+
reporters_snapshot.each { |reporter| reporter.record(assertions) }
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This ensures a stable list while notifying without forcing a global lock around reporter work.
|
|
37
|
+
|
|
38
|
+
### HTML Reporter Internal Lock
|
|
39
|
+
|
|
40
|
+
The HTML reporter protects `@failures`, `@total`, and `@finalized` with a mutex so `record` and `finalize` can run safely:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
return if @finalized
|
|
45
|
+
@total += total
|
|
46
|
+
@failures.concat(failures)
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`@finalized` is set only after `write_report` succeeds, so a failed write can be retried.
|
|
51
|
+
|
|
52
|
+
### Screenshot Naming Isolation
|
|
53
|
+
|
|
54
|
+
Each thread gets its own `ScreenshotNamer` via the per-thread registry, so counters, sections, and groups do not collide.
|
|
55
|
+
|
|
56
|
+
### SnapManager Per Call
|
|
57
|
+
|
|
58
|
+
`SnapManager` returns a new instance for each call, avoiding shared mutable state.
|
|
59
|
+
|
|
60
|
+
## Global Configuration
|
|
61
|
+
|
|
62
|
+
Configuration uses `mattr_accessor` and should be set once before tests run. Do not mutate config during parallel execution.
|
|
63
|
+
|
|
64
|
+
## Parallel Test Lifecycle
|
|
65
|
+
|
|
66
|
+
- Setup: per-thread registry is created, config is read
|
|
67
|
+
- Execution: assertions are added to the thread-local registry
|
|
68
|
+
- Teardown: `verify` and `reset` operate on the thread-local registry, reporters are notified
|
|
69
|
+
- Exit: reporters finalize once per process (using mutex-protected snapshot)
|
|
70
|
+
|
|
71
|
+
## Usage Examples
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
parallelize(workers: :number_of_processors, with: :threads)
|
|
75
|
+
|
|
76
|
+
Capybara::Screenshot::Diff.configure do |screenshot, diff|
|
|
77
|
+
screenshot.window_size = [1280, 1024]
|
|
78
|
+
screenshot.save_path = "doc/screenshots"
|
|
79
|
+
diff.tolerance = 0.001
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Do and Do Not
|
|
84
|
+
|
|
85
|
+
Do:
|
|
86
|
+
- Set config once in test helper
|
|
87
|
+
- Pass per-screenshot options in the call
|
|
88
|
+
|
|
89
|
+
Do not:
|
|
90
|
+
- Change global config inside tests
|
|
91
|
+
- Manually mutate registry internals
|
|
92
|
+
|
|
93
|
+
## File System Notes
|
|
94
|
+
|
|
95
|
+
- Paths are unique per screenshot name and counter
|
|
96
|
+
- `FileUtils.mv` is atomic on most file systems
|
|
97
|
+
- Directory creation uses `mkpath`
|
data/gems.rb
CHANGED
|
@@ -15,7 +15,8 @@ gem "ruby-vips", require: false
|
|
|
15
15
|
group :test do
|
|
16
16
|
gem "capybara", ">= 3.26"
|
|
17
17
|
gem "mutex_m" # Needed for RubyMine debugging. Try removing it.
|
|
18
|
-
gem "minitest", require: false
|
|
18
|
+
gem "minitest", "< 6", require: false
|
|
19
|
+
gem "minitest-mock", require: false
|
|
19
20
|
gem "minitest-stub-const", require: false
|
|
20
21
|
gem "simplecov", require: false
|
|
21
22
|
gem "rspec", require: false
|
|
@@ -60,6 +60,19 @@ module Capybara
|
|
|
60
60
|
session.execute_script(HIDE_CARET_SCRIPT)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
DISABLE_ANIMATIONS_SCRIPT = <<~JS
|
|
64
|
+
if (!document.getElementById('csdDisableAnimationsStyle')) {
|
|
65
|
+
let style = document.createElement('style');
|
|
66
|
+
style.setAttribute('id', 'csdDisableAnimationsStyle');
|
|
67
|
+
style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
|
|
68
|
+
document.head.appendChild(style);
|
|
69
|
+
}
|
|
70
|
+
JS
|
|
71
|
+
|
|
72
|
+
def self.disable_animations
|
|
73
|
+
session.execute_script(DISABLE_ANIMATIONS_SCRIPT)
|
|
74
|
+
end
|
|
75
|
+
|
|
63
76
|
FIND_ACTIVE_ELEMENT_SCRIPT = <<~JS
|
|
64
77
|
function activeElement(){
|
|
65
78
|
const ae = document.activeElement;
|
|
@@ -85,7 +98,7 @@ module Capybara
|
|
|
85
98
|
JS
|
|
86
99
|
|
|
87
100
|
def self.all_visible_regions_for(selector)
|
|
88
|
-
BrowserHelpers.session.all(selector, visible: true).map(
|
|
101
|
+
BrowserHelpers.session.all(selector, visible: true).map { |el| region_for(el) }
|
|
89
102
|
end
|
|
90
103
|
|
|
91
104
|
def self.region_for(element)
|
|
@@ -5,7 +5,31 @@ require "json"
|
|
|
5
5
|
module Capybara
|
|
6
6
|
module Screenshot
|
|
7
7
|
module Diff
|
|
8
|
-
|
|
8
|
+
# Represents a difference between two images
|
|
9
|
+
#
|
|
10
|
+
# This value object encapsulates the result of an image comparison operation.
|
|
11
|
+
# It follows the Single Responsibility Principle by focusing solely on representing
|
|
12
|
+
# the difference state, including:
|
|
13
|
+
# - Whether images are different or equal
|
|
14
|
+
# - Why they differ (dimensions, pixels, etc.)
|
|
15
|
+
# - The specific region of difference
|
|
16
|
+
# - Whether differences are tolerable based on configured thresholds
|
|
17
|
+
#
|
|
18
|
+
# As part of the layered comparison architecture, this class represents the final
|
|
19
|
+
# output of the comparison process, containing all data needed for reporting.
|
|
20
|
+
# Represents a difference between two images
|
|
21
|
+
class Difference < Struct.new(:region, :meta, :comparison, :failed_by, :base_image_path, :image_path, keyword_init: nil)
|
|
22
|
+
def self.build_null(comparison, base_image_path, new_image_path, failed_by = nil)
|
|
23
|
+
Difference.new(
|
|
24
|
+
nil,
|
|
25
|
+
{difference_level: nil, max_color_distance: 0},
|
|
26
|
+
comparison,
|
|
27
|
+
failed_by,
|
|
28
|
+
base_image_path,
|
|
29
|
+
new_image_path
|
|
30
|
+
).freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
9
33
|
def different?
|
|
10
34
|
failed? || !(blank? || tolerable?)
|
|
11
35
|
end
|
|
@@ -27,7 +51,7 @@ module Capybara
|
|
|
27
51
|
end
|
|
28
52
|
|
|
29
53
|
def skip_area
|
|
30
|
-
|
|
54
|
+
comparison.skip_area
|
|
31
55
|
end
|
|
32
56
|
|
|
33
57
|
def area_size_limit
|
|
@@ -39,7 +63,7 @@ module Capybara
|
|
|
39
63
|
end
|
|
40
64
|
|
|
41
65
|
def region_area_size
|
|
42
|
-
region&.size || 0
|
|
66
|
+
@region_area_size ||= region&.size || 0
|
|
43
67
|
end
|
|
44
68
|
|
|
45
69
|
def ratio
|
|
@@ -61,6 +85,19 @@ module Capybara
|
|
|
61
85
|
def tolerable?
|
|
62
86
|
!!((area_size_limit && area_size_limit >= region_area_size) || (tolerance && tolerance >= ratio))
|
|
63
87
|
end
|
|
88
|
+
|
|
89
|
+
# Path accessors for backward compatibility
|
|
90
|
+
def new_image_path
|
|
91
|
+
image_path || comparison&.new_image_path
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def original_image_path
|
|
95
|
+
base_image_path || comparison&.base_image_path
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def diff_mask
|
|
99
|
+
meta[:diff_mask]
|
|
100
|
+
end
|
|
64
101
|
end
|
|
65
102
|
end
|
|
66
103
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "capybara/screenshot/diff/comparison"
|
|
4
|
+
require "capybara/screenshot/diff/difference"
|
|
5
|
+
|
|
6
|
+
module Capybara
|
|
7
|
+
module Screenshot
|
|
8
|
+
module Diff
|
|
9
|
+
# Analyzes image differences with configurable tolerance levels.
|
|
10
|
+
#
|
|
11
|
+
# This class implements the core comparison logic for detecting visual differences
|
|
12
|
+
# between images while accounting for various tolerances and optimizations.
|
|
13
|
+
#
|
|
14
|
+
# The comparison process follows these steps:
|
|
15
|
+
# 1. Dimension Check (Fastest)
|
|
16
|
+
# - Compares image dimensions first for quick rejection
|
|
17
|
+
# - Different dimensions always indicate a difference
|
|
18
|
+
#
|
|
19
|
+
# 2. Pixel Equality Check (Fast)
|
|
20
|
+
# - Performs bitwise comparison if dimensions match
|
|
21
|
+
# - Returns immediately if images are exactly identical
|
|
22
|
+
#
|
|
23
|
+
# 3. Tolerant Comparison (Slower)
|
|
24
|
+
# - Only runs if quick checks don't determine equality
|
|
25
|
+
# - Respects configured tolerances for color and shift differences
|
|
26
|
+
# - Can ignore specific regions (skip_area)
|
|
27
|
+
# - Considers anti-aliasing and sub-pixel rendering differences
|
|
28
|
+
#
|
|
29
|
+
# The class is designed to be stateless and thread-safe, with all configuration
|
|
30
|
+
# passed in through the constructor.
|
|
31
|
+
class DifferenceFinder
|
|
32
|
+
TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :driver, :options
|
|
35
|
+
|
|
36
|
+
# Creates a new DifferenceFinder instance.
|
|
37
|
+
#
|
|
38
|
+
# @param driver [Drivers::Base] The image processing driver to use.
|
|
39
|
+
# Must implement the driver interface expected by DifferenceFinder.
|
|
40
|
+
# @param options [Hash] Configuration options for the comparison:
|
|
41
|
+
# @option options [Numeric] :tolerance (0.001) Color tolerance threshold (0.0-1.0).
|
|
42
|
+
# @option options [Numeric] :color_distance_limit Maximum allowed color distance.
|
|
43
|
+
# @option options [Numeric] :shift_distance_limit Maximum allowed shift distance.
|
|
44
|
+
# @option options [Numeric] :area_size_limit Maximum allowed difference area size.
|
|
45
|
+
# @option options [Array<Array>] :skip_area Regions to exclude from comparison.
|
|
46
|
+
def initialize(driver, options)
|
|
47
|
+
@driver = driver
|
|
48
|
+
@options = options
|
|
49
|
+
@without_tolerable_options = (options.keys & TOLERABLE_OPTIONS).empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Analyzes the comparison and determines if images are different.
|
|
53
|
+
#
|
|
54
|
+
# @param comparison [Comparison] The comparison object containing images to analyze.
|
|
55
|
+
# @param quick_mode [Boolean] When true, performs minimal checks and returns early.
|
|
56
|
+
# In quick mode, returns [is_equal, difference] where:
|
|
57
|
+
# - is_equal is true if images are considered equal
|
|
58
|
+
# - difference is a Difference object or nil
|
|
59
|
+
# When false, returns a Difference object directly.
|
|
60
|
+
# @return [Array, Difference] Result format depends on quick_mode parameter.
|
|
61
|
+
# @raise [ArgumentError] If the comparison object is invalid.
|
|
62
|
+
def call(comparison, quick_mode: true)
|
|
63
|
+
# Process the comparison and return result
|
|
64
|
+
|
|
65
|
+
# Handle dimension differences
|
|
66
|
+
unless driver.same_dimension?(comparison)
|
|
67
|
+
result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
|
|
68
|
+
return quick_mode ? [false, result] : result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Handle identical pixels
|
|
72
|
+
if driver.same_pixels?(comparison)
|
|
73
|
+
result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
|
|
74
|
+
return quick_mode ? [true, result] : result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Handle early return for non-tolerable options
|
|
78
|
+
if quick_mode && without_tolerable_options?
|
|
79
|
+
return [false, nil]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Process difference region
|
|
83
|
+
region = driver.find_difference_region(comparison)
|
|
84
|
+
|
|
85
|
+
# Only create a proper difference object if we've completed the comparison
|
|
86
|
+
quick_mode ? [!region.different?, region] : region
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def without_tolerable_options?
|
|
92
|
+
@without_tolerable_options
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -24,10 +24,6 @@ module Capybara
|
|
|
24
24
|
_load_images(old_bytes, new_bytes)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def filter_image_with_median(_image)
|
|
28
|
-
raise NotImplementedError
|
|
29
|
-
end
|
|
30
|
-
|
|
31
27
|
def add_black_box(image, _region)
|
|
32
28
|
image
|
|
33
29
|
end
|
|
@@ -217,22 +213,24 @@ module Capybara
|
|
|
217
213
|
|
|
218
214
|
def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
|
|
219
215
|
org_color = old_img[x, y]
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
216
|
+
unless shift_distance_limit
|
|
217
|
+
return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
start_x = [0, x - shift_distance_limit].max
|
|
221
|
+
end_x = [x + shift_distance_limit, new_img.width - 1].min
|
|
222
|
+
start_y = [0, y - shift_distance_limit].max
|
|
223
|
+
end_y = [y + shift_distance_limit, new_img.height - 1].min
|
|
224
|
+
|
|
225
|
+
min_distance = Float::INFINITY
|
|
226
|
+
(start_y..end_y).each do |dy|
|
|
227
|
+
(start_x..end_x).each do |dx|
|
|
228
|
+
distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
|
|
229
|
+
return 0 if distance == 0
|
|
230
|
+
min_distance = distance if distance < min_distance
|
|
231
231
|
end
|
|
232
|
-
distances.min
|
|
233
|
-
else
|
|
234
|
-
ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
|
|
235
232
|
end
|
|
233
|
+
min_distance
|
|
236
234
|
end
|
|
237
235
|
|
|
238
236
|
def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
|
|
@@ -254,10 +252,10 @@ module Capybara
|
|
|
254
252
|
if (x - shift_distance) >= 0 # left
|
|
255
253
|
([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
|
|
256
254
|
.each do |dy|
|
|
257
|
-
|
|
258
|
-
|
|
255
|
+
if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
|
|
256
|
+
return shift_distance
|
|
257
|
+
end
|
|
259
258
|
end
|
|
260
|
-
end
|
|
261
259
|
else
|
|
262
260
|
bounds_breached += 1
|
|
263
261
|
end
|
|
@@ -273,10 +271,10 @@ module Capybara
|
|
|
273
271
|
if (x + shift_distance) < new_img.width # right
|
|
274
272
|
([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
|
|
275
273
|
.each do |dy|
|
|
276
|
-
|
|
277
|
-
|
|
274
|
+
if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
|
|
275
|
+
return shift_distance
|
|
276
|
+
end
|
|
278
277
|
end
|
|
279
|
-
end
|
|
280
278
|
else
|
|
281
279
|
bounds_breached += 1
|
|
282
280
|
end
|
|
@@ -15,15 +15,18 @@ module Capybara
|
|
|
15
15
|
# Compare two images and determine if they are equal, different, or within some comparison
|
|
16
16
|
# range considering color values and difference area size.
|
|
17
17
|
module Drivers
|
|
18
|
-
DEFAULT_HIGHLIGHT_COLOR = [255, 0, 0, 255].freeze
|
|
19
|
-
|
|
20
18
|
class VipsDriver < BaseDriver
|
|
21
19
|
def find_difference_region(comparison)
|
|
22
20
|
new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options
|
|
23
21
|
|
|
24
|
-
diff_mask =
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
diff_mask = if options[:perceptual_threshold]
|
|
23
|
+
self.class.perceptual_difference_mask(base_image, new_image, options[:perceptual_threshold])
|
|
24
|
+
else
|
|
25
|
+
self.class.difference_mask(base_image, new_image, options[:color_distance_limit])
|
|
26
|
+
end
|
|
27
|
+
region = self.class.difference_region_by(diff_mask)
|
|
28
|
+
# TODO: schedule research when we got this case for VIPs
|
|
29
|
+
# region = nil if region && region_covers_entire_image?(region, base_image)
|
|
27
30
|
|
|
28
31
|
result = Difference.new(region, {}, comparison)
|
|
29
32
|
|
|
@@ -40,7 +43,7 @@ module Capybara
|
|
|
40
43
|
rescue Vips::Error => e
|
|
41
44
|
warn(
|
|
42
45
|
"[capybara-screenshot-diff] Crop has been failed for " \
|
|
43
|
-
|
|
46
|
+
"{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }"
|
|
44
47
|
)
|
|
45
48
|
raise e
|
|
46
49
|
end
|
|
@@ -56,7 +59,7 @@ module Capybara
|
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
def difference_level(diff_mask, old_img, _region = nil)
|
|
59
|
-
|
|
62
|
+
self.class.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
|
|
60
63
|
end
|
|
61
64
|
|
|
62
65
|
MAX_FILENAME_LENGTH = 200
|
|
@@ -89,10 +92,6 @@ module Capybara
|
|
|
89
92
|
result
|
|
90
93
|
end
|
|
91
94
|
|
|
92
|
-
def dimension(image)
|
|
93
|
-
[width_for(image), height_for(image)]
|
|
94
|
-
end
|
|
95
|
-
|
|
96
95
|
def draw_rectangles(images, region, rgba, offset: 0)
|
|
97
96
|
images.map do |image|
|
|
98
97
|
image.draw_rect(rgba, region.left - offset, region.top - offset, region.width + (offset * 2), region.height + (offset * 2))
|
|
@@ -107,37 +106,51 @@ module Capybara
|
|
|
107
106
|
base_image.composite2(new_image, :over)
|
|
108
107
|
end
|
|
109
108
|
|
|
110
|
-
def highlight_mask(diff_mask, merged_image, color:
|
|
109
|
+
def highlight_mask(diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
|
|
111
110
|
diff_mask.ifthenelse(color, merged_image * 0.75)
|
|
112
111
|
end
|
|
113
112
|
|
|
114
113
|
private
|
|
115
114
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
region.width == width_for(base_image)
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
class VipsUtil
|
|
124
|
-
def self.difference_area(old_image, new_image, color_distance: 0)
|
|
125
|
-
difference_mask = difference_mask(new_image, old_image, color_distance)
|
|
126
|
-
difference_area_size_by(difference_mask)
|
|
115
|
+
class << self
|
|
116
|
+
def difference_area(old_image, new_image, color_distance: 0)
|
|
117
|
+
mask = difference_mask(new_image, old_image, color_distance)
|
|
118
|
+
difference_area_size_by(mask)
|
|
127
119
|
end
|
|
128
120
|
|
|
129
|
-
def
|
|
121
|
+
def difference_area_size_by(difference_mask)
|
|
130
122
|
diff_mask = difference_mask == 0
|
|
131
123
|
diff_mask.hist_find.to_a[0][0].max
|
|
132
124
|
end
|
|
133
125
|
|
|
134
|
-
def
|
|
126
|
+
def difference_mask(base_image, new_image, color_distance = nil)
|
|
135
127
|
result = (new_image - base_image).abs
|
|
136
|
-
|
|
137
128
|
color_distance ? result > color_distance : result
|
|
138
129
|
end
|
|
139
130
|
|
|
140
|
-
def
|
|
131
|
+
def perceptual_difference_mask(base_image, new_image, threshold = 2.0)
|
|
132
|
+
color_diff = perceptual_color_diff(base_image, new_image) > threshold
|
|
133
|
+
alpha_diff = alpha_channel_diff(base_image, new_image)
|
|
134
|
+
alpha_diff ? (color_diff | alpha_diff) : color_diff
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def perceptual_color_diff(base_image, new_image)
|
|
138
|
+
base_rgb = (base_image.bands > 3) ? base_image.extract_band(0, n: 3) : base_image
|
|
139
|
+
new_rgb = (new_image.bands > 3) ? new_image.extract_band(0, n: 3) : new_image
|
|
140
|
+
base_lab = base_rgb.colourspace(:lab)
|
|
141
|
+
new_lab = new_rgb.colourspace(:lab)
|
|
142
|
+
base_lab.dE00(new_lab)
|
|
143
|
+
rescue Vips::Error
|
|
144
|
+
base_lab.dE76(new_lab)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def alpha_channel_diff(base_image, new_image)
|
|
148
|
+
return unless base_image.bands > 3 && new_image.bands > 3
|
|
149
|
+
|
|
150
|
+
(base_image.extract_band(3) - new_image.extract_band(3)).abs > 0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def difference_region_by(diff_mask)
|
|
141
154
|
columns, rows = diff_mask.bandor.project
|
|
142
155
|
|
|
143
156
|
left = columns.profile[1].min
|