regresso 0.1.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 +7 -0
- data/.yardopts +3 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +258 -0
- data/Rakefile +12 -0
- data/assets/logo-header.svg +28 -0
- data/lib/generators/regresso/install_generator.rb +29 -0
- data/lib/generators/regresso/templates/regresso.rb +18 -0
- data/lib/regresso/adapters/.gitkeep +0 -0
- data/lib/regresso/adapters/base.rb +30 -0
- data/lib/regresso/adapters/csv.rb +43 -0
- data/lib/regresso/adapters/database.rb +53 -0
- data/lib/regresso/adapters/database_snapshot.rb +49 -0
- data/lib/regresso/adapters/graphql.rb +75 -0
- data/lib/regresso/adapters/graphql_batch.rb +42 -0
- data/lib/regresso/adapters/http.rb +70 -0
- data/lib/regresso/adapters/json_file.rb +30 -0
- data/lib/regresso/adapters/proc.rb +30 -0
- data/lib/regresso/ci/github_annotation_formatter.rb +46 -0
- data/lib/regresso/ci/github_pr_commenter.rb +55 -0
- data/lib/regresso/ci/junit_xml_formatter.rb +42 -0
- data/lib/regresso/ci/reporter.rb +49 -0
- data/lib/regresso/ci.rb +6 -0
- data/lib/regresso/comparator.rb +45 -0
- data/lib/regresso/configuration.rb +92 -0
- data/lib/regresso/differ.rb +129 -0
- data/lib/regresso/difference.rb +45 -0
- data/lib/regresso/history/entry.rb +70 -0
- data/lib/regresso/history/file_backend.rb +51 -0
- data/lib/regresso/history/statistics.rb +65 -0
- data/lib/regresso/history/store.rb +122 -0
- data/lib/regresso/history/trend_reporter.rb +68 -0
- data/lib/regresso/history.rb +7 -0
- data/lib/regresso/json_path.rb +55 -0
- data/lib/regresso/minitest.rb +107 -0
- data/lib/regresso/notifiers/base.rb +33 -0
- data/lib/regresso/notifiers/microsoft_teams.rb +60 -0
- data/lib/regresso/notifiers/slack.rb +72 -0
- data/lib/regresso/notifiers.rb +5 -0
- data/lib/regresso/parallel/comparison_result.rb +51 -0
- data/lib/regresso/parallel/parallel_result.rb +68 -0
- data/lib/regresso/parallel/result_aggregator.rb +25 -0
- data/lib/regresso/parallel/runner.rb +71 -0
- data/lib/regresso/parallel.rb +6 -0
- data/lib/regresso/reporter.rb +68 -0
- data/lib/regresso/result.rb +70 -0
- data/lib/regresso/rspec/.gitkeep +0 -0
- data/lib/regresso/rspec/shared_examples.rb +42 -0
- data/lib/regresso/rspec.rb +142 -0
- data/lib/regresso/snapshot_manager.rb +57 -0
- data/lib/regresso/tasks/ci.rake +51 -0
- data/lib/regresso/tasks/history.rake +36 -0
- data/lib/regresso/templates/.gitkeep +0 -0
- data/lib/regresso/templates/report.html.erb +44 -0
- data/lib/regresso/version.rb +6 -0
- data/lib/regresso/web_ui/diff_formatter.rb +46 -0
- data/lib/regresso/web_ui/public/css/app.css +239 -0
- data/lib/regresso/web_ui/public/js/app.js +117 -0
- data/lib/regresso/web_ui/result_store.rb +60 -0
- data/lib/regresso/web_ui/server.rb +70 -0
- data/lib/regresso/web_ui/views/index.erb +58 -0
- data/lib/regresso/web_ui/views/layout.erb +13 -0
- data/lib/regresso/web_ui.rb +5 -0
- data/lib/regresso.rb +46 -0
- data/sig/regresso.rbs +4 -0
- data/site/Gemfile +7 -0
- data/site/assets/logo-header.png +0 -0
- data/site/assets/site.css +458 -0
- data/site/content/index.md +6 -0
- data/site/craze.yml +24 -0
- data/site/dist/assets/logo-header.svg +28 -0
- data/site/dist/assets/site.css +458 -0
- data/site/dist/index.html +232 -0
- data/site/dist/search.json +9 -0
- data/site/dist/sitemap.xml +7 -0
- data/site/templates/index.erb +232 -0
- data/site/templates/layout.erb +17 -0
- metadata +190 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# Immutable JSON-path-like identifier used in diffs.
|
|
5
|
+
class JsonPath
|
|
6
|
+
# @return [Array<String,Integer>]
|
|
7
|
+
attr_reader :segments
|
|
8
|
+
|
|
9
|
+
# Returns the root path.
|
|
10
|
+
#
|
|
11
|
+
# @return [Regresso::JsonPath]
|
|
12
|
+
def self.root
|
|
13
|
+
new([])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param segments [Array<String,Integer>]
|
|
17
|
+
def initialize(segments)
|
|
18
|
+
@segments = segments.dup.freeze
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns a new path with the segment appended.
|
|
23
|
+
#
|
|
24
|
+
# @param segment [String,Integer]
|
|
25
|
+
# @return [Regresso::JsonPath]
|
|
26
|
+
def /(segment)
|
|
27
|
+
self.class.new(segments + [segment])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the string representation of the path.
|
|
31
|
+
#
|
|
32
|
+
# @return [String]
|
|
33
|
+
def to_s
|
|
34
|
+
return "$" if segments.empty?
|
|
35
|
+
|
|
36
|
+
segments.reduce("$") do |path, segment|
|
|
37
|
+
if segment.is_a?(Integer)
|
|
38
|
+
"#{path}[#{segment}]"
|
|
39
|
+
else
|
|
40
|
+
"#{path}.#{segment}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Compares paths by string representation.
|
|
46
|
+
#
|
|
47
|
+
# @param other [Object]
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def ==(other)
|
|
50
|
+
return false unless other.respond_to?(:to_s)
|
|
51
|
+
|
|
52
|
+
to_s == other.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "regresso"
|
|
4
|
+
require "regresso/snapshot_manager"
|
|
5
|
+
|
|
6
|
+
# Minitest helpers for Regresso comparisons.
|
|
7
|
+
# @!parse
|
|
8
|
+
# # Minitest namespace from the minitest gem.
|
|
9
|
+
# module ::Minitest
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# # Base test class from minitest.
|
|
13
|
+
# class ::Minitest::Test
|
|
14
|
+
# end
|
|
15
|
+
|
|
16
|
+
module Regresso
|
|
17
|
+
# Minitest integration helpers.
|
|
18
|
+
module Minitest
|
|
19
|
+
# Assertion helpers for regression testing.
|
|
20
|
+
module Assertions
|
|
21
|
+
# Asserts that two sources have no regression.
|
|
22
|
+
#
|
|
23
|
+
# @param source_a [Object]
|
|
24
|
+
# @param source_b [Object]
|
|
25
|
+
# @param tolerance [Float]
|
|
26
|
+
# @param ignore [Array<String,Regexp>]
|
|
27
|
+
# @param message [String,nil]
|
|
28
|
+
# @return [void]
|
|
29
|
+
def assert_no_regression(source_a, source_b, tolerance: 0.0, ignore: [], message: nil)
|
|
30
|
+
config = Regresso::Configuration.new
|
|
31
|
+
config.default_tolerance = tolerance
|
|
32
|
+
config.ignore_paths = ignore
|
|
33
|
+
|
|
34
|
+
comparator = Regresso::Comparator.new(
|
|
35
|
+
source_a: normalize_source(source_a),
|
|
36
|
+
source_b: normalize_source(source_b),
|
|
37
|
+
config: config
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
result = comparator.compare
|
|
41
|
+
|
|
42
|
+
msg = message || build_failure_message(result)
|
|
43
|
+
assert result.passed?, msg
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Asserts that an object matches a stored snapshot.
|
|
47
|
+
#
|
|
48
|
+
# @param actual [Object]
|
|
49
|
+
# @param snapshot_name [String]
|
|
50
|
+
# @param tolerance [Float]
|
|
51
|
+
# @param ignore [Array<String,Regexp>]
|
|
52
|
+
# @return [void]
|
|
53
|
+
def assert_matches_snapshot(actual, snapshot_name, tolerance: 0.0, ignore: [])
|
|
54
|
+
manager = Regresso::SnapshotManager.new
|
|
55
|
+
path = manager.path_for(snapshot_name)
|
|
56
|
+
|
|
57
|
+
if ENV["UPDATE_SNAPSHOTS"] || !File.exist?(path)
|
|
58
|
+
manager.save(snapshot_name, actual)
|
|
59
|
+
pass
|
|
60
|
+
else
|
|
61
|
+
expected = manager.load(snapshot_name)
|
|
62
|
+
assert_no_regression(expected, actual, tolerance: tolerance, ignore: ignore)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def normalize_source(source)
|
|
69
|
+
case source
|
|
70
|
+
when Regresso::Adapters::Base
|
|
71
|
+
source
|
|
72
|
+
when Hash
|
|
73
|
+
if source[:url] || source[:base_url]
|
|
74
|
+
base_url = source[:base_url] || source[:url]
|
|
75
|
+
Regresso::Adapters::Http.new(**source.merge(base_url: base_url).reject { |k, _| k == :url })
|
|
76
|
+
elsif source[:path] && source[:path].to_s.end_with?(".csv")
|
|
77
|
+
Regresso::Adapters::Csv.new(**source)
|
|
78
|
+
elsif source[:path]
|
|
79
|
+
Regresso::Adapters::JsonFile.new(**source)
|
|
80
|
+
else
|
|
81
|
+
Regresso::Adapters::Proc.new { source }
|
|
82
|
+
end
|
|
83
|
+
when String
|
|
84
|
+
if source.end_with?(".csv")
|
|
85
|
+
Regresso::Adapters::Csv.new(path: source)
|
|
86
|
+
else
|
|
87
|
+
Regresso::Adapters::JsonFile.new(path: source)
|
|
88
|
+
end
|
|
89
|
+
when ::Proc
|
|
90
|
+
Regresso::Adapters::Proc.new(source)
|
|
91
|
+
else
|
|
92
|
+
Regresso::Adapters::Proc.new { source }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_failure_message(result)
|
|
97
|
+
diffs = result.meaningful_diffs
|
|
98
|
+
"Expected no regression, found #{diffs.size} difference(s):\n" +
|
|
99
|
+
diffs.first(5).map { |diff| " #{diff}" }.join("\n")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if defined?(::Minitest::Test)
|
|
106
|
+
::Minitest::Test.include Regresso::Minitest::Assertions
|
|
107
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# Notification backends for reporting results.
|
|
5
|
+
module Notifiers
|
|
6
|
+
# Base class for notification integrations.
|
|
7
|
+
class Base
|
|
8
|
+
# Sends a notification if the notifier deems it necessary.
|
|
9
|
+
#
|
|
10
|
+
# @param result [Regresso::Parallel::ParallelResult]
|
|
11
|
+
# @return [void]
|
|
12
|
+
def notify(result)
|
|
13
|
+
return unless should_notify?(result)
|
|
14
|
+
|
|
15
|
+
send_notification(build_message(result))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def should_notify?(_result)
|
|
21
|
+
raise NotImplementedError, "Notifiers must implement #should_notify?"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_message(_result)
|
|
25
|
+
raise NotImplementedError, "Notifiers must implement #build_message"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def send_notification(_message)
|
|
29
|
+
raise NotImplementedError, "Notifiers must implement #send_notification"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
module Regresso
|
|
7
|
+
module Notifiers
|
|
8
|
+
# Microsoft Teams webhook notifier.
|
|
9
|
+
class MicrosoftTeams < Base
|
|
10
|
+
# @param webhook_url [String]
|
|
11
|
+
# @param notify_on [Symbol] :always, :failure, or :success
|
|
12
|
+
def initialize(webhook_url:, notify_on: :failure)
|
|
13
|
+
@webhook_url = webhook_url
|
|
14
|
+
@notify_on = notify_on
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def should_notify?(result)
|
|
20
|
+
case @notify_on
|
|
21
|
+
when :always
|
|
22
|
+
true
|
|
23
|
+
when :failure
|
|
24
|
+
!result.success?
|
|
25
|
+
when :success
|
|
26
|
+
result.success?
|
|
27
|
+
else
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_message(result)
|
|
33
|
+
{
|
|
34
|
+
"@type" => "MessageCard",
|
|
35
|
+
"@context" => "http://schema.org/extensions",
|
|
36
|
+
"summary" => "Regresso Report",
|
|
37
|
+
"themeColor" => result.success? ? "2ECC71" : "E74C3C",
|
|
38
|
+
"title" => "Regresso Report",
|
|
39
|
+
"sections" => [
|
|
40
|
+
{
|
|
41
|
+
"facts" => [
|
|
42
|
+
{ "name" => "Total", "value" => result.total.to_s },
|
|
43
|
+
{ "name" => "Passed", "value" => result.passed.to_s },
|
|
44
|
+
{ "name" => "Failed", "value" => result.failed.to_s },
|
|
45
|
+
{ "name" => "Errors", "value" => result.errors.to_s }
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def send_notification(message)
|
|
53
|
+
Faraday.post(@webhook_url) do |req|
|
|
54
|
+
req.headers["Content-Type"] = "application/json"
|
|
55
|
+
req.body = JSON.generate(message)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "faraday"
|
|
5
|
+
|
|
6
|
+
module Regresso
|
|
7
|
+
module Notifiers
|
|
8
|
+
# Slack webhook notifier.
|
|
9
|
+
class Slack < Base
|
|
10
|
+
# @param webhook_url [String]
|
|
11
|
+
# @param channel [String,nil]
|
|
12
|
+
# @param notify_on [Symbol] :always, :failure, or :success
|
|
13
|
+
# @param mention [String,nil]
|
|
14
|
+
# @param username [String,nil]
|
|
15
|
+
# @param icon_emoji [String,nil]
|
|
16
|
+
def initialize(webhook_url:, channel: nil, notify_on: :failure, mention: nil, username: nil, icon_emoji: nil)
|
|
17
|
+
@webhook_url = webhook_url
|
|
18
|
+
@channel = channel
|
|
19
|
+
@notify_on = notify_on
|
|
20
|
+
@mention = mention
|
|
21
|
+
@username = username
|
|
22
|
+
@icon_emoji = icon_emoji
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def should_notify?(result)
|
|
28
|
+
case @notify_on
|
|
29
|
+
when :always
|
|
30
|
+
true
|
|
31
|
+
when :failure
|
|
32
|
+
!result.success?
|
|
33
|
+
when :success
|
|
34
|
+
result.success?
|
|
35
|
+
else
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_message(result)
|
|
41
|
+
status_text = result.success? ? "Tests passed" : "Regression detected"
|
|
42
|
+
text = [@mention, status_text].compact.join(" ")
|
|
43
|
+
|
|
44
|
+
message = {
|
|
45
|
+
channel: @channel,
|
|
46
|
+
username: @username,
|
|
47
|
+
icon_emoji: @icon_emoji,
|
|
48
|
+
text: text,
|
|
49
|
+
attachments: [
|
|
50
|
+
{
|
|
51
|
+
color: result.success? ? "good" : "danger",
|
|
52
|
+
fields: [
|
|
53
|
+
{ title: "Total", value: result.total.to_s, short: true },
|
|
54
|
+
{ title: "Failed", value: result.failed.to_s, short: true },
|
|
55
|
+
{ title: "Errors", value: result.errors.to_s, short: true }
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
message.delete_if { |_key, value| value.nil? }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def send_notification(message)
|
|
65
|
+
Faraday.post(@webhook_url) do |req|
|
|
66
|
+
req.headers["Content-Type"] = "application/json"
|
|
67
|
+
req.body = JSON.generate(message)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
module Parallel
|
|
5
|
+
# Result wrapper for a single comparison execution.
|
|
6
|
+
class ComparisonResult
|
|
7
|
+
# @return [String]
|
|
8
|
+
attr_reader :name
|
|
9
|
+
|
|
10
|
+
# @return [Regresso::Result,nil]
|
|
11
|
+
attr_reader :result
|
|
12
|
+
|
|
13
|
+
# @return [StandardError,nil]
|
|
14
|
+
attr_reader :error
|
|
15
|
+
|
|
16
|
+
# @return [Float]
|
|
17
|
+
attr_reader :duration
|
|
18
|
+
|
|
19
|
+
# @param name [String]
|
|
20
|
+
# @param result [Regresso::Result,nil]
|
|
21
|
+
# @param error [StandardError,nil]
|
|
22
|
+
# @param duration [Float]
|
|
23
|
+
def initialize(name:, result: nil, error: nil, duration:)
|
|
24
|
+
@name = name
|
|
25
|
+
@result = result
|
|
26
|
+
@error = error
|
|
27
|
+
@duration = duration
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def passed?
|
|
31
|
+
error.nil? && result&.passed?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def failed?
|
|
35
|
+
error.nil? && result&.failed?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def error?
|
|
39
|
+
!error.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Symbol] :passed, :failed, or :error
|
|
43
|
+
def status
|
|
44
|
+
return :error if error?
|
|
45
|
+
return :passed if passed?
|
|
46
|
+
|
|
47
|
+
:failed
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
module Parallel
|
|
5
|
+
# Summary of multiple comparison results.
|
|
6
|
+
class ParallelResult
|
|
7
|
+
# @return [Integer]
|
|
8
|
+
attr_reader :total
|
|
9
|
+
|
|
10
|
+
# @return [Integer]
|
|
11
|
+
attr_reader :passed
|
|
12
|
+
|
|
13
|
+
# @return [Integer]
|
|
14
|
+
attr_reader :failed
|
|
15
|
+
|
|
16
|
+
# @return [Integer]
|
|
17
|
+
attr_reader :errors
|
|
18
|
+
|
|
19
|
+
# @return [Array<Regresso::Parallel::ComparisonResult>]
|
|
20
|
+
attr_reader :results
|
|
21
|
+
|
|
22
|
+
# @return [Float]
|
|
23
|
+
attr_reader :total_duration
|
|
24
|
+
|
|
25
|
+
# @param total [Integer]
|
|
26
|
+
# @param passed [Integer]
|
|
27
|
+
# @param failed [Integer]
|
|
28
|
+
# @param errors [Integer]
|
|
29
|
+
# @param results [Array<Regresso::Parallel::ComparisonResult>]
|
|
30
|
+
# @param total_duration [Float]
|
|
31
|
+
def initialize(total:, passed:, failed:, errors:, results:, total_duration:)
|
|
32
|
+
@total = total
|
|
33
|
+
@passed = passed
|
|
34
|
+
@failed = failed
|
|
35
|
+
@errors = errors
|
|
36
|
+
@results = results
|
|
37
|
+
@total_duration = total_duration
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def success?
|
|
42
|
+
failed.zero? && errors.zero?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Hash]
|
|
46
|
+
def summary
|
|
47
|
+
{
|
|
48
|
+
total: total,
|
|
49
|
+
passed: passed,
|
|
50
|
+
failed: failed,
|
|
51
|
+
errors: errors,
|
|
52
|
+
success_rate: (passed.to_f / total * 100).round(2),
|
|
53
|
+
total_duration: total_duration.round(2)
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Array<Regresso::Parallel::ComparisonResult>]
|
|
58
|
+
def failed_results
|
|
59
|
+
results.select(&:failed?)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Array<Regresso::Parallel::ComparisonResult>]
|
|
63
|
+
def error_results
|
|
64
|
+
results.select(&:error?)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
module Parallel
|
|
5
|
+
# Aggregates comparison results into a summary.
|
|
6
|
+
class ResultAggregator
|
|
7
|
+
# @param results [Array<Regresso::Parallel::ComparisonResult>]
|
|
8
|
+
def initialize(results)
|
|
9
|
+
@results = results
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Regresso::Parallel::ParallelResult]
|
|
13
|
+
def aggregate
|
|
14
|
+
ParallelResult.new(
|
|
15
|
+
total: @results.size,
|
|
16
|
+
passed: @results.count(&:passed?),
|
|
17
|
+
failed: @results.count(&:failed?),
|
|
18
|
+
errors: @results.count(&:error?),
|
|
19
|
+
results: @results,
|
|
20
|
+
total_duration: @results.sum(&:duration)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# Parallel execution utilities for multiple comparisons.
|
|
5
|
+
module Parallel
|
|
6
|
+
# Executes multiple comparisons concurrently.
|
|
7
|
+
class Runner
|
|
8
|
+
# Default number of worker threads.
|
|
9
|
+
DEFAULT_WORKERS = 4
|
|
10
|
+
|
|
11
|
+
# @param comparisons [Array<Hash>]
|
|
12
|
+
# @param workers [Integer]
|
|
13
|
+
# @param config [Regresso::Configuration]
|
|
14
|
+
def initialize(comparisons:, workers: DEFAULT_WORKERS, config: Configuration.new)
|
|
15
|
+
@comparisons = comparisons
|
|
16
|
+
@workers = workers
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Regresso::Parallel::ParallelResult]
|
|
21
|
+
def run
|
|
22
|
+
results = execute_parallel
|
|
23
|
+
ResultAggregator.new(results).aggregate
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def execute_parallel
|
|
29
|
+
queue = Queue.new
|
|
30
|
+
@comparisons.each { |comparison| queue << comparison }
|
|
31
|
+
|
|
32
|
+
threads = @workers.times.map do
|
|
33
|
+
Thread.new do
|
|
34
|
+
thread_results = []
|
|
35
|
+
loop do
|
|
36
|
+
comparison = queue.pop(true) rescue nil
|
|
37
|
+
break unless comparison
|
|
38
|
+
|
|
39
|
+
thread_results << execute_comparison(comparison)
|
|
40
|
+
end
|
|
41
|
+
thread_results
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
threads.flat_map(&:value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def execute_comparison(comparison)
|
|
49
|
+
start_time = Time.now
|
|
50
|
+
comparator = Comparator.new(
|
|
51
|
+
source_a: comparison[:source_a],
|
|
52
|
+
source_b: comparison[:source_b],
|
|
53
|
+
config: @config
|
|
54
|
+
)
|
|
55
|
+
result = comparator.compare
|
|
56
|
+
|
|
57
|
+
ComparisonResult.new(
|
|
58
|
+
name: comparison[:name],
|
|
59
|
+
result: result,
|
|
60
|
+
duration: Time.now - start_time
|
|
61
|
+
)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
ComparisonResult.new(
|
|
64
|
+
name: comparison[:name],
|
|
65
|
+
error: e,
|
|
66
|
+
duration: Time.now - start_time
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "erb"
|
|
5
|
+
|
|
6
|
+
module Regresso
|
|
7
|
+
# Formats a {Regresso::Result} into text, JSON, or HTML.
|
|
8
|
+
class Reporter
|
|
9
|
+
# @param result [Regresso::Result]
|
|
10
|
+
def initialize(result)
|
|
11
|
+
@result = result
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Generates a report string.
|
|
15
|
+
#
|
|
16
|
+
# @param format [Symbol]
|
|
17
|
+
# @return [String]
|
|
18
|
+
def generate(format)
|
|
19
|
+
case format
|
|
20
|
+
when :text
|
|
21
|
+
to_text
|
|
22
|
+
when :json
|
|
23
|
+
to_json
|
|
24
|
+
when :html
|
|
25
|
+
to_html
|
|
26
|
+
else
|
|
27
|
+
raise ArgumentError, "Unknown format: #{format}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def to_text
|
|
34
|
+
lines = []
|
|
35
|
+
lines << "Regresso Report"
|
|
36
|
+
lines << "Passed: #{@result.passed?}"
|
|
37
|
+
lines << "Total diffs: #{@result.diffs.size}"
|
|
38
|
+
lines << "Meaningful diffs: #{@result.meaningful_diffs.size}"
|
|
39
|
+
lines << ""
|
|
40
|
+
|
|
41
|
+
@result.meaningful_diffs.each do |diff|
|
|
42
|
+
lines << "- #{diff}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
lines.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_json
|
|
49
|
+
{
|
|
50
|
+
summary: @result.summary,
|
|
51
|
+
diffs: @result.diffs.map do |diff|
|
|
52
|
+
{
|
|
53
|
+
path: diff.path.to_s,
|
|
54
|
+
old_value: diff.old_value,
|
|
55
|
+
new_value: diff.new_value,
|
|
56
|
+
type: diff.type
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
}.to_json
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_html
|
|
63
|
+
template_path = File.expand_path("templates/report.html.erb", __dir__)
|
|
64
|
+
template = File.read(template_path)
|
|
65
|
+
ERB.new(template).result(binding)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# Holds the outcome of comparing two sources.
|
|
5
|
+
class Result
|
|
6
|
+
# @return [Object]
|
|
7
|
+
attr_reader :source_a
|
|
8
|
+
|
|
9
|
+
# @return [Object]
|
|
10
|
+
attr_reader :source_b
|
|
11
|
+
|
|
12
|
+
# @return [Array<Regresso::Difference>]
|
|
13
|
+
attr_reader :diffs
|
|
14
|
+
|
|
15
|
+
# @return [Regresso::Configuration]
|
|
16
|
+
attr_reader :config
|
|
17
|
+
|
|
18
|
+
# @param source_a [Object]
|
|
19
|
+
# @param source_b [Object]
|
|
20
|
+
# @param diffs [Array<Regresso::Difference>]
|
|
21
|
+
# @param config [Regresso::Configuration]
|
|
22
|
+
def initialize(source_a:, source_b:, diffs:, config:)
|
|
23
|
+
@source_a = source_a
|
|
24
|
+
@source_b = source_b
|
|
25
|
+
@diffs = diffs
|
|
26
|
+
@config = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def passed?
|
|
31
|
+
meaningful_diffs.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
def failed?
|
|
36
|
+
!passed?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns diffs not ignored by configuration.
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<Regresso::Difference>]
|
|
42
|
+
def meaningful_diffs
|
|
43
|
+
@meaningful_diffs ||= diffs.reject { |diff| config.ignored?(diff.path) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns a summary hash.
|
|
47
|
+
#
|
|
48
|
+
# @return [Hash]
|
|
49
|
+
def summary
|
|
50
|
+
{
|
|
51
|
+
total_diffs: diffs.size,
|
|
52
|
+
meaningful_diffs: meaningful_diffs.size,
|
|
53
|
+
ignored_diffs: diffs.size - meaningful_diffs.size,
|
|
54
|
+
passed: passed?
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Generates a report string for the given format.
|
|
59
|
+
#
|
|
60
|
+
# @param format [Symbol]
|
|
61
|
+
# @return [String]
|
|
62
|
+
def to_report(format: :text)
|
|
63
|
+
unless defined?(Regresso::Reporter)
|
|
64
|
+
raise NotImplementedError, "Reporter is not available"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Reporter.new(self).generate(format)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
File without changes
|