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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +3 -0
  3. data/CHANGELOG.md +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +258 -0
  6. data/Rakefile +12 -0
  7. data/assets/logo-header.svg +28 -0
  8. data/lib/generators/regresso/install_generator.rb +29 -0
  9. data/lib/generators/regresso/templates/regresso.rb +18 -0
  10. data/lib/regresso/adapters/.gitkeep +0 -0
  11. data/lib/regresso/adapters/base.rb +30 -0
  12. data/lib/regresso/adapters/csv.rb +43 -0
  13. data/lib/regresso/adapters/database.rb +53 -0
  14. data/lib/regresso/adapters/database_snapshot.rb +49 -0
  15. data/lib/regresso/adapters/graphql.rb +75 -0
  16. data/lib/regresso/adapters/graphql_batch.rb +42 -0
  17. data/lib/regresso/adapters/http.rb +70 -0
  18. data/lib/regresso/adapters/json_file.rb +30 -0
  19. data/lib/regresso/adapters/proc.rb +30 -0
  20. data/lib/regresso/ci/github_annotation_formatter.rb +46 -0
  21. data/lib/regresso/ci/github_pr_commenter.rb +55 -0
  22. data/lib/regresso/ci/junit_xml_formatter.rb +42 -0
  23. data/lib/regresso/ci/reporter.rb +49 -0
  24. data/lib/regresso/ci.rb +6 -0
  25. data/lib/regresso/comparator.rb +45 -0
  26. data/lib/regresso/configuration.rb +92 -0
  27. data/lib/regresso/differ.rb +129 -0
  28. data/lib/regresso/difference.rb +45 -0
  29. data/lib/regresso/history/entry.rb +70 -0
  30. data/lib/regresso/history/file_backend.rb +51 -0
  31. data/lib/regresso/history/statistics.rb +65 -0
  32. data/lib/regresso/history/store.rb +122 -0
  33. data/lib/regresso/history/trend_reporter.rb +68 -0
  34. data/lib/regresso/history.rb +7 -0
  35. data/lib/regresso/json_path.rb +55 -0
  36. data/lib/regresso/minitest.rb +107 -0
  37. data/lib/regresso/notifiers/base.rb +33 -0
  38. data/lib/regresso/notifiers/microsoft_teams.rb +60 -0
  39. data/lib/regresso/notifiers/slack.rb +72 -0
  40. data/lib/regresso/notifiers.rb +5 -0
  41. data/lib/regresso/parallel/comparison_result.rb +51 -0
  42. data/lib/regresso/parallel/parallel_result.rb +68 -0
  43. data/lib/regresso/parallel/result_aggregator.rb +25 -0
  44. data/lib/regresso/parallel/runner.rb +71 -0
  45. data/lib/regresso/parallel.rb +6 -0
  46. data/lib/regresso/reporter.rb +68 -0
  47. data/lib/regresso/result.rb +70 -0
  48. data/lib/regresso/rspec/.gitkeep +0 -0
  49. data/lib/regresso/rspec/shared_examples.rb +42 -0
  50. data/lib/regresso/rspec.rb +142 -0
  51. data/lib/regresso/snapshot_manager.rb +57 -0
  52. data/lib/regresso/tasks/ci.rake +51 -0
  53. data/lib/regresso/tasks/history.rake +36 -0
  54. data/lib/regresso/templates/.gitkeep +0 -0
  55. data/lib/regresso/templates/report.html.erb +44 -0
  56. data/lib/regresso/version.rb +6 -0
  57. data/lib/regresso/web_ui/diff_formatter.rb +46 -0
  58. data/lib/regresso/web_ui/public/css/app.css +239 -0
  59. data/lib/regresso/web_ui/public/js/app.js +117 -0
  60. data/lib/regresso/web_ui/result_store.rb +60 -0
  61. data/lib/regresso/web_ui/server.rb +70 -0
  62. data/lib/regresso/web_ui/views/index.erb +58 -0
  63. data/lib/regresso/web_ui/views/layout.erb +13 -0
  64. data/lib/regresso/web_ui.rb +5 -0
  65. data/lib/regresso.rb +46 -0
  66. data/sig/regresso.rbs +4 -0
  67. data/site/Gemfile +7 -0
  68. data/site/assets/logo-header.png +0 -0
  69. data/site/assets/site.css +458 -0
  70. data/site/content/index.md +6 -0
  71. data/site/craze.yml +24 -0
  72. data/site/dist/assets/logo-header.svg +28 -0
  73. data/site/dist/assets/site.css +458 -0
  74. data/site/dist/index.html +232 -0
  75. data/site/dist/search.json +9 -0
  76. data/site/dist/sitemap.xml +7 -0
  77. data/site/templates/index.erb +232 -0
  78. data/site/templates/layout.erb +17 -0
  79. 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "history/entry"
4
+ require_relative "history/file_backend"
5
+ require_relative "history/statistics"
6
+ require_relative "history/store"
7
+ require_relative "history/trend_reporter"