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,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
# Rails generators for Regresso setup.
|
|
7
|
+
module Generators
|
|
8
|
+
# Installs Regresso initializer and snapshot directories.
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
# Creates config/initializers/regresso.rb.
|
|
13
|
+
def create_initializer
|
|
14
|
+
copy_file "regresso.rb", "config/initializers/regresso.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Creates the snapshot directory with a .gitkeep file.
|
|
18
|
+
def create_snapshot_directory
|
|
19
|
+
empty_directory "spec/snapshots/regresso"
|
|
20
|
+
create_file "spec/snapshots/regresso/.gitkeep"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Adds Regresso snapshot temp files to .gitignore.
|
|
24
|
+
def add_to_gitignore
|
|
25
|
+
append_to_file ".gitignore", "\n# Regresso temporary files\nspec/snapshots/regresso/*.tmp\n"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Regresso.configure do |config|
|
|
4
|
+
# Default numeric tolerance for comparisons
|
|
5
|
+
config.default_tolerance = 0.0
|
|
6
|
+
|
|
7
|
+
# Paths to ignore (String or Regexp)
|
|
8
|
+
config.ignore_paths = []
|
|
9
|
+
|
|
10
|
+
# Per-path tolerance overrides
|
|
11
|
+
config.tolerance_overrides = {}
|
|
12
|
+
|
|
13
|
+
# Array order sensitivity
|
|
14
|
+
config.array_order_sensitive = true
|
|
15
|
+
|
|
16
|
+
# Type coercion for numeric comparison
|
|
17
|
+
config.type_coercion = true
|
|
18
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# Data source adapters for comparison inputs.
|
|
5
|
+
module Adapters
|
|
6
|
+
# Base interface for adapters.
|
|
7
|
+
class Base
|
|
8
|
+
attr_reader :options
|
|
9
|
+
|
|
10
|
+
# @param options [Hash]
|
|
11
|
+
def initialize(**options)
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Fetches data from the source.
|
|
16
|
+
#
|
|
17
|
+
# @return [Object]
|
|
18
|
+
def fetch
|
|
19
|
+
raise NotImplementedError, "Adapters must implement #fetch"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns a short description of the source.
|
|
23
|
+
#
|
|
24
|
+
# @return [String]
|
|
25
|
+
def description
|
|
26
|
+
raise NotImplementedError, "Adapters must implement #description"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
module Adapters
|
|
7
|
+
# Adapter that parses CSV input into a comparable structure.
|
|
8
|
+
class Csv < Base
|
|
9
|
+
# @param path [String,nil]
|
|
10
|
+
# @param content [String,nil]
|
|
11
|
+
# @param headers [Boolean]
|
|
12
|
+
# @param col_sep [String]
|
|
13
|
+
def initialize(path: nil, content: nil, headers: true, col_sep: ",")
|
|
14
|
+
super()
|
|
15
|
+
@path = path
|
|
16
|
+
@content = content
|
|
17
|
+
@headers = headers
|
|
18
|
+
@col_sep = col_sep
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Parses CSV into an array of hashes.
|
|
22
|
+
#
|
|
23
|
+
# @return [Array<Hash>]
|
|
24
|
+
def fetch
|
|
25
|
+
csv_content = @content || File.read(@path)
|
|
26
|
+
rows = CSV.parse(csv_content, headers: @headers, col_sep: @col_sep)
|
|
27
|
+
if @headers
|
|
28
|
+
rows.map(&:to_h)
|
|
29
|
+
else
|
|
30
|
+
rows.map do |row|
|
|
31
|
+
values = row.is_a?(CSV::Row) ? row.fields : row
|
|
32
|
+
values.each_with_index.to_h { |value, index| [index.to_s, value] }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String]
|
|
38
|
+
def description
|
|
39
|
+
@path ? "CSV: #{@path}" : "CSV (inline content)"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
module Adapters
|
|
7
|
+
# Adapter that runs a query against a database connection.
|
|
8
|
+
class Database < Base
|
|
9
|
+
# @param connection_config [Hash]
|
|
10
|
+
# @param query [String]
|
|
11
|
+
# @param params [Hash]
|
|
12
|
+
# @param row_identifier [String,Symbol,nil]
|
|
13
|
+
def initialize(connection_config:, query:, params: {}, row_identifier: nil)
|
|
14
|
+
super()
|
|
15
|
+
@connection_config = connection_config
|
|
16
|
+
@query = query
|
|
17
|
+
@params = params
|
|
18
|
+
@row_identifier = row_identifier
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Executes the query and returns rows as hashes.
|
|
22
|
+
#
|
|
23
|
+
# @return [Array<Hash>]
|
|
24
|
+
def fetch
|
|
25
|
+
with_connection do |conn|
|
|
26
|
+
result = conn.exec_query(sanitize_query)
|
|
27
|
+
rows = result.to_a
|
|
28
|
+
@row_identifier ? rows.sort_by { |row| row[@row_identifier.to_s] } : rows
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String]
|
|
33
|
+
def description
|
|
34
|
+
"Database Query: #{@query.to_s.tr("\n", " ")[0, 50]}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def with_connection
|
|
40
|
+
ActiveRecord::Base.establish_connection(@connection_config)
|
|
41
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
42
|
+
yield conn
|
|
43
|
+
end
|
|
44
|
+
ensure
|
|
45
|
+
ActiveRecord::Base.connection_pool.disconnect! if ActiveRecord::Base.connected?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sanitize_query
|
|
49
|
+
ActiveRecord::Base.sanitize_sql_array([@query, @params])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
module Adapters
|
|
7
|
+
# Adapter that collects full table snapshots from a database.
|
|
8
|
+
class DatabaseSnapshot < Base
|
|
9
|
+
# @param connection_config [Hash]
|
|
10
|
+
# @param tables [Array<String,Symbol>]
|
|
11
|
+
# @param where_clause [String,nil]
|
|
12
|
+
def initialize(connection_config:, tables:, where_clause: nil)
|
|
13
|
+
super()
|
|
14
|
+
@connection_config = connection_config
|
|
15
|
+
@tables = tables
|
|
16
|
+
@where_clause = where_clause
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Fetches rows for each table into a hash keyed by table name.
|
|
20
|
+
#
|
|
21
|
+
# @return [Hash{String => Array<Hash>}]
|
|
22
|
+
def fetch
|
|
23
|
+
with_connection do |conn|
|
|
24
|
+
@tables.each_with_object({}) do |table, acc|
|
|
25
|
+
query = "SELECT * FROM #{table}"
|
|
26
|
+
query += " WHERE #{@where_clause}" if @where_clause
|
|
27
|
+
acc[table.to_s] = conn.exec_query(query).to_a
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String]
|
|
33
|
+
def description
|
|
34
|
+
"Database Snapshot: #{@tables.join(", ")}".strip
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def with_connection
|
|
40
|
+
ActiveRecord::Base.establish_connection(@connection_config)
|
|
41
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
42
|
+
yield conn
|
|
43
|
+
end
|
|
44
|
+
ensure
|
|
45
|
+
ActiveRecord::Base.connection_pool.disconnect! if ActiveRecord::Base.connected?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Regresso
|
|
7
|
+
module Adapters
|
|
8
|
+
# Adapter that executes GraphQL queries over HTTP.
|
|
9
|
+
class GraphQL < Base
|
|
10
|
+
# @param endpoint [String]
|
|
11
|
+
# @param query [String]
|
|
12
|
+
# @param variables [Hash]
|
|
13
|
+
# @param headers [Hash]
|
|
14
|
+
# @param operation_name [String,nil]
|
|
15
|
+
def initialize(endpoint:, query:, variables: {}, headers: {}, operation_name: nil)
|
|
16
|
+
super()
|
|
17
|
+
@endpoint = endpoint
|
|
18
|
+
@query = query
|
|
19
|
+
@variables = variables
|
|
20
|
+
@headers = headers
|
|
21
|
+
@operation_name = operation_name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Executes the GraphQL query and returns the data payload.
|
|
25
|
+
#
|
|
26
|
+
# @return [Object]
|
|
27
|
+
def fetch
|
|
28
|
+
response = connection.post do |req|
|
|
29
|
+
req.headers = default_headers.merge(@headers)
|
|
30
|
+
req.body = {
|
|
31
|
+
query: @query,
|
|
32
|
+
variables: @variables,
|
|
33
|
+
operationName: @operation_name
|
|
34
|
+
}.compact.to_json
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
data = response.body
|
|
38
|
+
raise GraphQLError, data["errors"] if data["errors"]&.any?
|
|
39
|
+
|
|
40
|
+
data["data"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [String]
|
|
44
|
+
def description
|
|
45
|
+
"GraphQL: #{@operation_name || "anonymous"}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def connection
|
|
51
|
+
@connection ||= Faraday.new(url: @endpoint) do |faraday|
|
|
52
|
+
faraday.request :json
|
|
53
|
+
faraday.response :json
|
|
54
|
+
faraday.adapter Faraday.default_adapter
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def default_headers
|
|
59
|
+
{ "Content-Type" => "application/json", "Accept" => "application/json" }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Raised when a GraphQL response contains errors.
|
|
64
|
+
class GraphQLError < StandardError
|
|
65
|
+
# @return [Array<Hash>]
|
|
66
|
+
attr_reader :errors
|
|
67
|
+
|
|
68
|
+
# @param errors [Array<Hash>]
|
|
69
|
+
def initialize(errors)
|
|
70
|
+
@errors = errors
|
|
71
|
+
super(errors.map { |error| error["message"] }.join(", "))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "regresso/adapters/graphql"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
module Adapters
|
|
7
|
+
# Adapter that runs multiple GraphQL queries and returns a keyed hash.
|
|
8
|
+
class GraphQLBatch < Base
|
|
9
|
+
# @param endpoint [String]
|
|
10
|
+
# @param queries [Array<Hash>]
|
|
11
|
+
# @param headers [Hash]
|
|
12
|
+
def initialize(endpoint:, queries:, headers: {})
|
|
13
|
+
super()
|
|
14
|
+
@endpoint = endpoint
|
|
15
|
+
@queries = queries
|
|
16
|
+
@headers = headers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Executes all configured queries.
|
|
20
|
+
#
|
|
21
|
+
# @return [Hash{String => Object}]
|
|
22
|
+
def fetch
|
|
23
|
+
@queries.each_with_object({}) do |query, acc|
|
|
24
|
+
adapter = GraphQL.new(
|
|
25
|
+
endpoint: @endpoint,
|
|
26
|
+
query: query.fetch(:query),
|
|
27
|
+
variables: query[:variables] || {},
|
|
28
|
+
headers: @headers,
|
|
29
|
+
operation_name: query[:operation_name]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
acc[query.fetch(:name).to_s] = adapter.fetch
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [String]
|
|
37
|
+
def description
|
|
38
|
+
"GraphQL Batch: #{@queries.size} queries"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/follow_redirects"
|
|
5
|
+
|
|
6
|
+
module Regresso
|
|
7
|
+
module Adapters
|
|
8
|
+
# Adapter that fetches data from an HTTP endpoint.
|
|
9
|
+
class Http < Base
|
|
10
|
+
# @param base_url [String]
|
|
11
|
+
# @param endpoint [String]
|
|
12
|
+
# @param params [Hash]
|
|
13
|
+
# @param headers [Hash]
|
|
14
|
+
# @param method [Symbol]
|
|
15
|
+
def initialize(base_url:, endpoint:, params: {}, headers: {}, method: :get)
|
|
16
|
+
super()
|
|
17
|
+
@base_url = base_url
|
|
18
|
+
@endpoint = endpoint
|
|
19
|
+
@params = params
|
|
20
|
+
@headers = headers
|
|
21
|
+
@method = method
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Executes the HTTP request and returns parsed response.
|
|
25
|
+
#
|
|
26
|
+
# @return [Object]
|
|
27
|
+
def fetch
|
|
28
|
+
response = connection.public_send(@method, @endpoint) do |req|
|
|
29
|
+
req.params = @params
|
|
30
|
+
req.headers = @headers
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if response.status >= 400
|
|
34
|
+
raise Regresso::Error, "HTTP request failed with status #{response.status}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
parse_response(response)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String]
|
|
41
|
+
def description
|
|
42
|
+
"#{@method.to_s.upcase} #{@base_url}#{@endpoint}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def connection
|
|
48
|
+
@connection ||= Faraday.new(url: @base_url) do |faraday|
|
|
49
|
+
faraday.request :json
|
|
50
|
+
faraday.response :json
|
|
51
|
+
faraday.response :follow_redirects
|
|
52
|
+
faraday.adapter Faraday.default_adapter
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_response(response)
|
|
57
|
+
content_type = response.headers["content-type"].to_s
|
|
58
|
+
return response.body if content_type.empty?
|
|
59
|
+
|
|
60
|
+
if content_type.match?(/json/)
|
|
61
|
+
response.body
|
|
62
|
+
elsif content_type.match?(/csv/)
|
|
63
|
+
response.body
|
|
64
|
+
else
|
|
65
|
+
response.body
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
module Adapters
|
|
7
|
+
# Adapter that loads JSON content from a file.
|
|
8
|
+
class JsonFile < Base
|
|
9
|
+
# @param path [String]
|
|
10
|
+
def initialize(path:)
|
|
11
|
+
super()
|
|
12
|
+
@path = path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Loads JSON from the file.
|
|
16
|
+
#
|
|
17
|
+
# @return [Object]
|
|
18
|
+
def fetch
|
|
19
|
+
raise Errno::ENOENT, @path unless File.exist?(@path)
|
|
20
|
+
|
|
21
|
+
JSON.parse(File.read(@path))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String]
|
|
25
|
+
def description
|
|
26
|
+
"JSON File: #{@path}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
module Adapters
|
|
5
|
+
# Adapter that calls a provided Proc or block for data.
|
|
6
|
+
class Proc < Base
|
|
7
|
+
# @param callable [Proc,nil]
|
|
8
|
+
# @param description [String]
|
|
9
|
+
def initialize(callable = nil, description: "Custom source", &block)
|
|
10
|
+
super()
|
|
11
|
+
@callable = callable || block
|
|
12
|
+
@description_text = description
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Executes the callable and returns its value.
|
|
16
|
+
#
|
|
17
|
+
# @return [Object]
|
|
18
|
+
def fetch
|
|
19
|
+
raise ArgumentError, "callable is required" unless @callable
|
|
20
|
+
|
|
21
|
+
@callable.call
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String]
|
|
25
|
+
def description
|
|
26
|
+
@description_text
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
module CI
|
|
5
|
+
# Formats parallel results into GitHub Actions annotations.
|
|
6
|
+
class GitHubAnnotationFormatter
|
|
7
|
+
# @param result [Regresso::Parallel::ParallelResult]
|
|
8
|
+
def initialize(result)
|
|
9
|
+
@result = result
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Array<Hash>] GitHub annotations payload
|
|
13
|
+
def annotations
|
|
14
|
+
@result.results.map do |res|
|
|
15
|
+
next unless res.failed? || res.error?
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
title: "Regresso: #{res.name}",
|
|
19
|
+
file: "regresso",
|
|
20
|
+
line: 1,
|
|
21
|
+
end_line: 1,
|
|
22
|
+
annotation_level: "error",
|
|
23
|
+
message: annotation_message(res)
|
|
24
|
+
}
|
|
25
|
+
end.compact
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String] GitHub Actions workflow annotation output
|
|
29
|
+
def output_for_workflow
|
|
30
|
+
annotations.map do |annotation|
|
|
31
|
+
"::error file=#{annotation[:file]},line=#{annotation[:line]}::#{annotation[:message]}"
|
|
32
|
+
end.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def annotation_message(res)
|
|
38
|
+
if res.failed?
|
|
39
|
+
"Regression detected"
|
|
40
|
+
else
|
|
41
|
+
"Error: #{res.error.class} - #{res.error.message}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "octokit"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
module CI
|
|
7
|
+
# Posts a summary comment to a GitHub pull request.
|
|
8
|
+
class GitHubPRCommenter
|
|
9
|
+
# @param token [String]
|
|
10
|
+
# @param repo [String]
|
|
11
|
+
# @param pr_number [Integer]
|
|
12
|
+
def initialize(token:, repo:, pr_number:)
|
|
13
|
+
@client = Octokit::Client.new(access_token: token)
|
|
14
|
+
@repo = repo
|
|
15
|
+
@pr_number = pr_number
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Creates or updates a PR comment with the report summary.
|
|
19
|
+
#
|
|
20
|
+
# @param result [Regresso::Parallel::ParallelResult]
|
|
21
|
+
# @return [void]
|
|
22
|
+
def post_comment(result)
|
|
23
|
+
body = build_comment_body(result)
|
|
24
|
+
existing = find_existing_comment
|
|
25
|
+
if existing
|
|
26
|
+
@client.update_comment(@repo, existing.id, body)
|
|
27
|
+
else
|
|
28
|
+
@client.add_comment(@repo, @pr_number, body)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def find_existing_comment
|
|
35
|
+
@client.issue_comments(@repo, @pr_number).find do |comment|
|
|
36
|
+
comment.body.include?("<!-- regresso-report -->")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_comment_body(result)
|
|
41
|
+
<<~MARKDOWN
|
|
42
|
+
<!-- regresso-report -->
|
|
43
|
+
## Regresso Report
|
|
44
|
+
|
|
45
|
+
| Status | Count |
|
|
46
|
+
|--------|-------|
|
|
47
|
+
| Passed | #{result.passed} |
|
|
48
|
+
| Failed | #{result.failed} |
|
|
49
|
+
| Errors | #{result.errors} |
|
|
50
|
+
| Total | #{result.total} |
|
|
51
|
+
MARKDOWN
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "builder"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
module CI
|
|
7
|
+
# Formats parallel results into JUnit XML.
|
|
8
|
+
class JUnitXmlFormatter
|
|
9
|
+
# @param result [Regresso::Parallel::ParallelResult]
|
|
10
|
+
def initialize(result)
|
|
11
|
+
@result = result
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [String] JUnit XML payload
|
|
15
|
+
def format
|
|
16
|
+
builder = Builder::XmlMarkup.new(indent: 2)
|
|
17
|
+
builder.instruct!
|
|
18
|
+
|
|
19
|
+
builder.testsuites(
|
|
20
|
+
name: "Regresso Regression Tests",
|
|
21
|
+
tests: @result.total,
|
|
22
|
+
failures: @result.failed,
|
|
23
|
+
errors: @result.errors
|
|
24
|
+
) do |suites|
|
|
25
|
+
suites.testsuite(name: "Regression", tests: @result.total) do |suite|
|
|
26
|
+
@result.results.each do |res|
|
|
27
|
+
suite.testcase(name: res.name, time: res.duration) do |tc|
|
|
28
|
+
if res.failed?
|
|
29
|
+
tc.failure(message: "Regression detected", type: "RegressionFailure")
|
|
30
|
+
elsif res.error?
|
|
31
|
+
tc.error(message: res.error.message, type: res.error.class.name)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
builder.target!
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Regresso
|
|
4
|
+
# CI-related reporting helpers.
|
|
5
|
+
module CI
|
|
6
|
+
# Builds CI-friendly reports from parallel results.
|
|
7
|
+
class Reporter
|
|
8
|
+
# @param result [Regresso::Parallel::ParallelResult]
|
|
9
|
+
def initialize(result)
|
|
10
|
+
@result = result
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [String] JUnit XML output
|
|
14
|
+
def to_junit_xml
|
|
15
|
+
JUnitXmlFormatter.new(@result).format
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Array<Hash>] GitHub annotations payload
|
|
19
|
+
def to_github_annotations
|
|
20
|
+
GitHubAnnotationFormatter.new(@result).annotations
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Hash] GitLab report payload
|
|
24
|
+
def to_gitlab_report
|
|
25
|
+
{
|
|
26
|
+
total: @result.total,
|
|
27
|
+
passed: @result.passed,
|
|
28
|
+
failed: @result.failed,
|
|
29
|
+
errors: @result.errors,
|
|
30
|
+
results: @result.results.map do |res|
|
|
31
|
+
{
|
|
32
|
+
name: res.name,
|
|
33
|
+
status: res.status,
|
|
34
|
+
duration: res.duration
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Writes JUnit XML output to a file.
|
|
41
|
+
#
|
|
42
|
+
# @param path [String]
|
|
43
|
+
# @return [void]
|
|
44
|
+
def write_junit_xml(path)
|
|
45
|
+
File.write(path, to_junit_xml)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|