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,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# Compares two data sources and produces a {Regresso::Result}.
|
|
5
|
+
class Comparator
|
|
6
|
+
# Creates a comparator for two data sources.
|
|
7
|
+
#
|
|
8
|
+
# @param source_a [#fetch] first data source
|
|
9
|
+
# @param source_b [#fetch] second data source
|
|
10
|
+
# @param config [Regresso::Configuration] comparison config
|
|
11
|
+
def initialize(source_a:, source_b:, config: Configuration.new)
|
|
12
|
+
@source_a = source_a
|
|
13
|
+
@source_b = source_b
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Compares two sources and returns the comparison result.
|
|
18
|
+
#
|
|
19
|
+
# @return [Regresso::Result]
|
|
20
|
+
def compare
|
|
21
|
+
data_a = fetch_data(@source_a)
|
|
22
|
+
data_b = fetch_data(@source_b)
|
|
23
|
+
|
|
24
|
+
differ = Differ.new(@config)
|
|
25
|
+
diffs = differ.diff(data_a, data_b)
|
|
26
|
+
|
|
27
|
+
Result.new(
|
|
28
|
+
source_a: @source_a,
|
|
29
|
+
source_b: @source_b,
|
|
30
|
+
diffs: diffs,
|
|
31
|
+
config: @config
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def fetch_data(source)
|
|
38
|
+
unless source.respond_to?(:fetch)
|
|
39
|
+
raise ArgumentError, "source must respond to #fetch"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
source.fetch
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# Stores comparison settings used by {Regresso::Differ} and {Regresso::Comparator}.
|
|
5
|
+
class Configuration
|
|
6
|
+
# Default numeric tolerance applied when no path-specific override exists.
|
|
7
|
+
#
|
|
8
|
+
# @return [Float]
|
|
9
|
+
attr_accessor :default_tolerance
|
|
10
|
+
|
|
11
|
+
# Paths or patterns to ignore during comparison.
|
|
12
|
+
#
|
|
13
|
+
# @return [Array<String,Regexp>]
|
|
14
|
+
attr_accessor :ignore_paths
|
|
15
|
+
|
|
16
|
+
# Per-path tolerance overrides keyed by JSON path string.
|
|
17
|
+
#
|
|
18
|
+
# @return [Hash{String => Float}]
|
|
19
|
+
attr_accessor :tolerance_overrides
|
|
20
|
+
|
|
21
|
+
# Whether array order must match during comparison.
|
|
22
|
+
#
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
attr_accessor :array_order_sensitive
|
|
25
|
+
|
|
26
|
+
# Whether numeric strings should be coerced when comparing.
|
|
27
|
+
#
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
attr_accessor :type_coercion
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@default_tolerance = 0.0
|
|
33
|
+
@ignore_paths = []
|
|
34
|
+
@tolerance_overrides = {}
|
|
35
|
+
@array_order_sensitive = true
|
|
36
|
+
@type_coercion = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns tolerance for a given path.
|
|
40
|
+
#
|
|
41
|
+
# @param path [Regresso::JsonPath]
|
|
42
|
+
# @return [Float]
|
|
43
|
+
def tolerance_for(path)
|
|
44
|
+
@tolerance_overrides[path.to_s] || @default_tolerance
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Checks if a path should be ignored.
|
|
48
|
+
#
|
|
49
|
+
# @param path [Regresso::JsonPath]
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def ignored?(path)
|
|
52
|
+
@ignore_paths.any? { |pattern| path_matches?(path, pattern) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Merges another configuration into a new configuration.
|
|
56
|
+
#
|
|
57
|
+
# @param other [Regresso::Configuration]
|
|
58
|
+
# @return [Regresso::Configuration]
|
|
59
|
+
def merge(other)
|
|
60
|
+
dup.tap do |config|
|
|
61
|
+
unless other.default_tolerance.nil?
|
|
62
|
+
config.default_tolerance = other.default_tolerance
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
config.ignore_paths += other.ignore_paths if other.ignore_paths
|
|
66
|
+
config.tolerance_overrides.merge!(other.tolerance_overrides) if other.tolerance_overrides
|
|
67
|
+
|
|
68
|
+
unless other.array_order_sensitive.nil?
|
|
69
|
+
config.array_order_sensitive = other.array_order_sensitive
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
unless other.type_coercion.nil?
|
|
73
|
+
config.type_coercion = other.type_coercion
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def path_matches?(path, pattern)
|
|
81
|
+
case pattern
|
|
82
|
+
when Regexp
|
|
83
|
+
path.to_s.match?(pattern)
|
|
84
|
+
when String
|
|
85
|
+
path_string = path.to_s
|
|
86
|
+
path_string == pattern || path_string.end_with?(pattern)
|
|
87
|
+
else
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
# Computes differences between two structured values.
|
|
7
|
+
class Differ
|
|
8
|
+
# @param config [Regresso::Configuration]
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Computes differences between two values.
|
|
14
|
+
#
|
|
15
|
+
# @param a [Object]
|
|
16
|
+
# @param b [Object]
|
|
17
|
+
# @param path [Regresso::JsonPath]
|
|
18
|
+
# @return [Array<Regresso::Difference>]
|
|
19
|
+
def diff(a, b, path = JsonPath.root)
|
|
20
|
+
return [] if should_ignore?(path)
|
|
21
|
+
|
|
22
|
+
if a.is_a?(Hash) && b.is_a?(Hash)
|
|
23
|
+
diff_hashes(a, b, path)
|
|
24
|
+
elsif a.is_a?(Array) && b.is_a?(Array)
|
|
25
|
+
diff_arrays(a, b, path)
|
|
26
|
+
else
|
|
27
|
+
diff_values(a, b, path)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def diff_hashes(a, b, path)
|
|
34
|
+
(a.keys | b.keys).flat_map do |key|
|
|
35
|
+
next_path = path / key
|
|
36
|
+
if a.key?(key) && b.key?(key)
|
|
37
|
+
diff(a[key], b[key], next_path)
|
|
38
|
+
elsif a.key?(key)
|
|
39
|
+
[Difference.new(path: next_path, old_value: a[key], new_value: nil)]
|
|
40
|
+
else
|
|
41
|
+
[Difference.new(path: next_path, old_value: nil, new_value: b[key])]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def diff_arrays(a, b, path)
|
|
47
|
+
if @config.array_order_sensitive
|
|
48
|
+
max = [a.length, b.length].max
|
|
49
|
+
(0...max).flat_map do |index|
|
|
50
|
+
next_path = path / index
|
|
51
|
+
if index < a.length && index < b.length
|
|
52
|
+
diff(a[index], b[index], next_path)
|
|
53
|
+
elsif index < a.length
|
|
54
|
+
[Difference.new(path: next_path, old_value: a[index], new_value: nil)]
|
|
55
|
+
else
|
|
56
|
+
[Difference.new(path: next_path, old_value: nil, new_value: b[index])]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
sorted_a = sort_by_canonical(a)
|
|
61
|
+
sorted_b = sort_by_canonical(b)
|
|
62
|
+
max = [sorted_a.length, sorted_b.length].max
|
|
63
|
+
(0...max).flat_map do |index|
|
|
64
|
+
next_path = path / index
|
|
65
|
+
if index < sorted_a.length && index < sorted_b.length
|
|
66
|
+
diff(sorted_a[index], sorted_b[index], next_path)
|
|
67
|
+
elsif index < sorted_a.length
|
|
68
|
+
[Difference.new(path: next_path, old_value: sorted_a[index], new_value: nil)]
|
|
69
|
+
else
|
|
70
|
+
[Difference.new(path: next_path, old_value: nil, new_value: sorted_b[index])]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def diff_values(a, b, path)
|
|
77
|
+
return [] if values_equal?(a, b, path)
|
|
78
|
+
|
|
79
|
+
[Difference.new(path: path, old_value: a, new_value: b)]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def values_equal?(a, b, path)
|
|
83
|
+
return true if a == b
|
|
84
|
+
|
|
85
|
+
if @config.type_coercion
|
|
86
|
+
coerced_a = numeric_value(a)
|
|
87
|
+
coerced_b = numeric_value(b)
|
|
88
|
+
return numeric_equal?(coerced_a, coerced_b, path) if coerced_a && coerced_b
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if a.is_a?(Numeric) && b.is_a?(Numeric)
|
|
92
|
+
numeric_equal?(a, b, path)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def numeric_equal?(a, b, path)
|
|
97
|
+
tolerance = @config.tolerance_for(path)
|
|
98
|
+
(a.to_f - b.to_f).abs <= tolerance
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def numeric_value(value)
|
|
102
|
+
return value.to_f if value.is_a?(Numeric)
|
|
103
|
+
return value.to_s.to_f if value.is_a?(String) && value.match?(/\A-?\d+(\.\d+)?\z/)
|
|
104
|
+
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def should_ignore?(path)
|
|
109
|
+
@config.ignored?(path)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def sort_by_canonical(array)
|
|
113
|
+
array.sort_by { |value| canonical_value(value) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def canonical_value(value)
|
|
117
|
+
case value
|
|
118
|
+
when Hash
|
|
119
|
+
"{" + value.sort_by { |k, _| k.to_s }
|
|
120
|
+
.map { |k, v| "#{k}:#{canonical_value(v)}" }
|
|
121
|
+
.join(",") + "}"
|
|
122
|
+
when Array
|
|
123
|
+
"[" + value.map { |v| canonical_value(v) }.join(",") + "]"
|
|
124
|
+
else
|
|
125
|
+
value.inspect
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# Represents a single difference between two values.
|
|
5
|
+
class Difference
|
|
6
|
+
# @return [Regresso::JsonPath]
|
|
7
|
+
attr_reader :path
|
|
8
|
+
|
|
9
|
+
# @return [Object]
|
|
10
|
+
attr_reader :old_value
|
|
11
|
+
|
|
12
|
+
# @return [Object]
|
|
13
|
+
attr_reader :new_value
|
|
14
|
+
|
|
15
|
+
# @return [Symbol] one of :added, :removed, :changed
|
|
16
|
+
attr_reader :type
|
|
17
|
+
|
|
18
|
+
# @param path [Regresso::JsonPath]
|
|
19
|
+
# @param old_value [Object]
|
|
20
|
+
# @param new_value [Object]
|
|
21
|
+
def initialize(path:, old_value:, new_value:)
|
|
22
|
+
@path = path
|
|
23
|
+
@old_value = old_value
|
|
24
|
+
@new_value = new_value
|
|
25
|
+
@type = determine_type
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns a human-readable diff description.
|
|
30
|
+
#
|
|
31
|
+
# @return [String]
|
|
32
|
+
def to_s
|
|
33
|
+
"#{path}: #{old_value.inspect} -> #{new_value.inspect}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def determine_type
|
|
39
|
+
return :added if old_value.nil?
|
|
40
|
+
return :removed if new_value.nil?
|
|
41
|
+
|
|
42
|
+
:changed
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# Persistent storage structures for historical comparisons.
|
|
5
|
+
module History
|
|
6
|
+
# A stored snapshot of a single comparison result.
|
|
7
|
+
class Entry
|
|
8
|
+
# @return [String]
|
|
9
|
+
attr_reader :id
|
|
10
|
+
|
|
11
|
+
# @return [Time]
|
|
12
|
+
attr_reader :timestamp
|
|
13
|
+
|
|
14
|
+
# @return [Hash]
|
|
15
|
+
attr_reader :result
|
|
16
|
+
|
|
17
|
+
# @return [Hash]
|
|
18
|
+
attr_reader :metadata
|
|
19
|
+
|
|
20
|
+
# @param id [String]
|
|
21
|
+
# @param timestamp [Time]
|
|
22
|
+
# @param result [Hash]
|
|
23
|
+
# @param metadata [Hash]
|
|
24
|
+
def initialize(id:, timestamp:, result:, metadata: {})
|
|
25
|
+
@id = id
|
|
26
|
+
@timestamp = timestamp
|
|
27
|
+
@result = result
|
|
28
|
+
@metadata = metadata
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def passed?
|
|
33
|
+
value = result[:success]
|
|
34
|
+
value = result["success"] if value.nil?
|
|
35
|
+
!!value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def failed?
|
|
40
|
+
!passed?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
def to_h
|
|
45
|
+
{
|
|
46
|
+
"id" => id,
|
|
47
|
+
"timestamp" => timestamp.iso8601,
|
|
48
|
+
"result" => result,
|
|
49
|
+
"metadata" => metadata
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param hash [Hash]
|
|
54
|
+
# @return [Regresso::History::Entry]
|
|
55
|
+
def self.from_h(hash)
|
|
56
|
+
source = hash.transform_keys(&:to_s)
|
|
57
|
+
result = source["result"] || {}
|
|
58
|
+
result = result.transform_keys(&:to_s) if result.respond_to?(:transform_keys)
|
|
59
|
+
metadata = source["metadata"] || {}
|
|
60
|
+
metadata = metadata.transform_keys(&:to_s) if metadata.respond_to?(:transform_keys)
|
|
61
|
+
new(
|
|
62
|
+
id: source["id"] || hash[:id],
|
|
63
|
+
timestamp: Time.parse(source["timestamp"] || hash[:timestamp].to_s),
|
|
64
|
+
result: result,
|
|
65
|
+
metadata: metadata
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Regresso
|
|
7
|
+
module History
|
|
8
|
+
# File-backed persistence for {Regresso::History::Entry}.
|
|
9
|
+
class FileBackend
|
|
10
|
+
# @param storage_dir [String]
|
|
11
|
+
def initialize(storage_dir: "tmp/regresso_history")
|
|
12
|
+
@storage_dir = storage_dir
|
|
13
|
+
FileUtils.mkdir_p(@storage_dir)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param entry [Regresso::History::Entry]
|
|
17
|
+
# @return [void]
|
|
18
|
+
def save(entry)
|
|
19
|
+
File.write(path_for(entry.id), JSON.generate(entry.to_h))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param id [String]
|
|
23
|
+
# @return [Regresso::History::Entry,nil]
|
|
24
|
+
def find(id)
|
|
25
|
+
path = path_for(id)
|
|
26
|
+
return nil unless File.exist?(path)
|
|
27
|
+
|
|
28
|
+
Entry.from_h(JSON.parse(File.read(path)))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<Regresso::History::Entry>]
|
|
32
|
+
def all
|
|
33
|
+
Dir.glob(File.join(@storage_dir, "*.json")).map do |path|
|
|
34
|
+
Entry.from_h(JSON.parse(File.read(path)))
|
|
35
|
+
end.sort_by(&:timestamp).reverse
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param id [String]
|
|
39
|
+
# @return [void]
|
|
40
|
+
def delete(id)
|
|
41
|
+
FileUtils.rm_f(path_for(id))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def path_for(id)
|
|
47
|
+
File.join(@storage_dir, "#{id}.json")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
module History
|
|
5
|
+
# Computes aggregated statistics from stored entries.
|
|
6
|
+
class Statistics
|
|
7
|
+
# @param entries [Array<Regresso::History::Entry>]
|
|
8
|
+
def initialize(entries)
|
|
9
|
+
@entries = entries
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Hash] summarized statistics
|
|
13
|
+
def calculate
|
|
14
|
+
{
|
|
15
|
+
total_runs: @entries.size,
|
|
16
|
+
success_rate: calculate_success_rate,
|
|
17
|
+
average_duration: calculate_average_duration,
|
|
18
|
+
failure_trend: calculate_failure_trend,
|
|
19
|
+
common_failures: identify_common_failures,
|
|
20
|
+
by_day: aggregate_by_day
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def calculate_success_rate
|
|
27
|
+
return 0.0 if @entries.empty?
|
|
28
|
+
|
|
29
|
+
passed = @entries.count(&:passed?)
|
|
30
|
+
(passed.to_f / @entries.size * 100).round(2)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def calculate_average_duration
|
|
34
|
+
return 0.0 if @entries.empty?
|
|
35
|
+
|
|
36
|
+
total = @entries.sum { |entry| entry.result[:duration].to_f }
|
|
37
|
+
(total / @entries.size).round(2)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def calculate_failure_trend
|
|
41
|
+
@entries.group_by { |entry| entry.timestamp.to_date }
|
|
42
|
+
.transform_values { |list| list.count(&:failed?) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def identify_common_failures
|
|
46
|
+
failures = @entries.select(&:failed?)
|
|
47
|
+
failures.group_by { |entry| entry.metadata["error"] || "unknown" }
|
|
48
|
+
.transform_values(&:size)
|
|
49
|
+
.sort_by { |_key, value| -value }
|
|
50
|
+
.to_h
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def aggregate_by_day
|
|
54
|
+
@entries.group_by { |entry| entry.timestamp.to_date }
|
|
55
|
+
.transform_values do |list|
|
|
56
|
+
{
|
|
57
|
+
total: list.size,
|
|
58
|
+
passed: list.count(&:passed?),
|
|
59
|
+
failed: list.count(&:failed?)
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Regresso
|
|
7
|
+
module History
|
|
8
|
+
# Manages persistence and queries for historical results.
|
|
9
|
+
class Store
|
|
10
|
+
# @param storage_backend [Regresso::History::FileBackend]
|
|
11
|
+
def initialize(storage_backend: FileBackend.new)
|
|
12
|
+
@backend = storage_backend
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param result [Regresso::Parallel::ParallelResult]
|
|
16
|
+
# @param metadata [Hash]
|
|
17
|
+
# @return [Regresso::History::Entry]
|
|
18
|
+
def record(result, metadata = {})
|
|
19
|
+
entry = Entry.new(
|
|
20
|
+
id: SecureRandom.uuid,
|
|
21
|
+
timestamp: Time.now,
|
|
22
|
+
result: serialize_result(result),
|
|
23
|
+
metadata: metadata
|
|
24
|
+
)
|
|
25
|
+
@backend.save(entry)
|
|
26
|
+
entry
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param id [String]
|
|
30
|
+
# @return [Regresso::History::Entry,nil]
|
|
31
|
+
def find(id)
|
|
32
|
+
@backend.find(id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param filters [Hash]
|
|
36
|
+
# @return [Array<Regresso::History::Entry>]
|
|
37
|
+
def list(filters = {})
|
|
38
|
+
entries = @backend.all
|
|
39
|
+
filter_entries(entries, filters)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param start_date [Time,nil]
|
|
43
|
+
# @param end_date [Time,nil]
|
|
44
|
+
# @param status [Symbol,nil]
|
|
45
|
+
# @param limit [Integer,nil]
|
|
46
|
+
# @return [Array<Regresso::History::Entry>]
|
|
47
|
+
def query(start_date: nil, end_date: nil, status: nil, limit: nil)
|
|
48
|
+
entries = @backend.all
|
|
49
|
+
entries = entries.select do |entry|
|
|
50
|
+
within_range?(entry, start_date, end_date) && status_match?(entry, status)
|
|
51
|
+
end
|
|
52
|
+
limit ? entries.first(limit) : entries
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param period [Symbol]
|
|
56
|
+
# @return [Hash]
|
|
57
|
+
def statistics(period: :week)
|
|
58
|
+
entries = list_for_period(period)
|
|
59
|
+
Statistics.new(entries).calculate
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def serialize_result(result)
|
|
65
|
+
{
|
|
66
|
+
total: result.total,
|
|
67
|
+
passed: result.passed,
|
|
68
|
+
failed: result.failed,
|
|
69
|
+
errors: result.errors,
|
|
70
|
+
success: result.success?,
|
|
71
|
+
duration: result.total_duration
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def filter_entries(entries, filters)
|
|
76
|
+
return entries if filters.empty?
|
|
77
|
+
|
|
78
|
+
entries.select do |entry|
|
|
79
|
+
matches = true
|
|
80
|
+
matches &&= status_match?(entry, filters[:status]) if filters[:status]
|
|
81
|
+
matches &&= within_range?(entry, filters[:start_date], filters[:end_date])
|
|
82
|
+
matches
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def status_match?(entry, status)
|
|
87
|
+
return true unless status
|
|
88
|
+
|
|
89
|
+
case status
|
|
90
|
+
when :passed
|
|
91
|
+
entry.passed?
|
|
92
|
+
when :failed
|
|
93
|
+
entry.failed?
|
|
94
|
+
else
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def within_range?(entry, start_date, end_date)
|
|
100
|
+
start_ok = start_date.nil? || entry.timestamp >= start_date
|
|
101
|
+
end_ok = end_date.nil? || entry.timestamp <= end_date
|
|
102
|
+
start_ok && end_ok
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def list_for_period(period)
|
|
106
|
+
now = Time.now
|
|
107
|
+
start_date = case period
|
|
108
|
+
when :day
|
|
109
|
+
now - 60 * 60 * 24
|
|
110
|
+
when :week
|
|
111
|
+
now - 60 * 60 * 24 * 7
|
|
112
|
+
when :month
|
|
113
|
+
now - 60 * 60 * 24 * 30
|
|
114
|
+
else
|
|
115
|
+
now - 60 * 60 * 24 * 7
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
query(start_date: start_date)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
module History
|
|
7
|
+
# Generates an HTML trend report for stored results.
|
|
8
|
+
class TrendReporter
|
|
9
|
+
# @param store [Regresso::History::Store]
|
|
10
|
+
def initialize(store)
|
|
11
|
+
@store = store
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param period [Symbol]
|
|
15
|
+
# @return [String] HTML report
|
|
16
|
+
def generate(period: :week)
|
|
17
|
+
stats = @store.statistics(period: period)
|
|
18
|
+
template = <<~HTML
|
|
19
|
+
<!doctype html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8">
|
|
23
|
+
<title>Regresso Trends</title>
|
|
24
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
25
|
+
<style>
|
|
26
|
+
body { font-family: Arial, sans-serif; padding: 20px; }
|
|
27
|
+
.chart { max-width: 800px; margin: 32px auto; }
|
|
28
|
+
</style>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<h1>Regresso Trends</h1>
|
|
32
|
+
<p>Total runs: <%= stats[:total_runs] %></p>
|
|
33
|
+
<p>Success rate: <%= stats[:success_rate] %>%</p>
|
|
34
|
+
|
|
35
|
+
<div class="chart">
|
|
36
|
+
<canvas id="successChart"></canvas>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<script>
|
|
40
|
+
const data = <%= JSON.generate(stats[:by_day]) %>;
|
|
41
|
+
const labels = Object.keys(data);
|
|
42
|
+
const successRates = labels.map(day => {
|
|
43
|
+
const dayData = data[day];
|
|
44
|
+
return dayData.total === 0 ? 0 : (dayData.passed / dayData.total * 100).toFixed(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
new Chart(document.getElementById('successChart'), {
|
|
48
|
+
type: 'line',
|
|
49
|
+
data: {
|
|
50
|
+
labels: labels,
|
|
51
|
+
datasets: [{
|
|
52
|
+
label: 'Success Rate (%)',
|
|
53
|
+
data: successRates,
|
|
54
|
+
borderColor: '#1d4d9f',
|
|
55
|
+
backgroundColor: 'rgba(29, 77, 159, 0.2)'
|
|
56
|
+
}]
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
</script>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
HTML
|
|
63
|
+
|
|
64
|
+
ERB.new(template).result(binding)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|