buildkite-test_collector 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+
5
+ module Buildkite::TestCollector::MinitestPlugin
6
+ class Trace
7
+ attr_accessor :example, :failure_reason, :failure_expanded
8
+ attr_reader :id, :history
9
+
10
+ RESULT_CODES = {
11
+ '.' => 'passed',
12
+ 'F' => 'failed',
13
+ 'E' => 'failed',
14
+ 'S' => 'pending',
15
+ }
16
+
17
+ FILE_PATH_REGEX = /^(.*?\.(rb|feature))/
18
+
19
+ def initialize(example, history:, failure_reason: nil, failure_expanded: [])
20
+ @id = SecureRandom.uuid
21
+ @example = example
22
+ @history = history
23
+ @failure_reason = failure_reason
24
+ @failure_expanded = failure_expanded
25
+ end
26
+
27
+ def result
28
+ RESULT_CODES[example.result_code]
29
+ end
30
+
31
+ def source_location
32
+ @source_location ||= example.method(example.name).source_location
33
+ end
34
+
35
+ def as_hash
36
+ strip_invalid_utf8_chars(
37
+ id: id,
38
+ scope: example.class.name,
39
+ name: example.name,
40
+ identifier: identifier,
41
+ location: location,
42
+ file_name: file_name,
43
+ result: result,
44
+ failure_reason: failure_reason,
45
+ failure_expanded: failure_expanded,
46
+ history: history,
47
+ ).with_indifferent_access.compact
48
+ end
49
+
50
+ private
51
+
52
+ def location
53
+ if file_name
54
+ "#{file_name}:#{line_number}"
55
+ end
56
+ end
57
+ alias_method :identifier, :location
58
+
59
+ def file_name
60
+ @file_name ||= File.join('./', source_location[0].delete_prefix(project_dir))
61
+ end
62
+
63
+ def line_number
64
+ @line_number ||= source_location[1]
65
+ end
66
+
67
+ def project_dir
68
+ if defined?(Rails) && Rails.respond_to?(:root)
69
+ Rails.root
70
+ else
71
+ Dir.getwd
72
+ end
73
+ end
74
+
75
+ def failure_reason
76
+ @failure_reason ||= example.failure&.message
77
+ end
78
+
79
+ def failure_expanded
80
+ @failure_expanded ||= begin
81
+ example.failures.map do |failure|
82
+ {
83
+ expanded: failure.message,
84
+ backtrace: failure.backtrace,
85
+ }
86
+ end
87
+ end
88
+ end
89
+
90
+ def strip_invalid_utf8_chars(object)
91
+ if object.is_a?(Hash)
92
+ Hash[object.map { |key, value| [key, strip_invalid_utf8_chars(value)] }]
93
+ elsif object.is_a?(Array)
94
+ object.map { |value| strip_invalid_utf8_chars(value) }
95
+ elsif object.is_a?(String)
96
+ object.encode('UTF-8', :invalid => :replace, :undef => :replace)
97
+ else
98
+ object
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "minitest_plugin/reporter"
4
+ require_relative "minitest_plugin/trace"
5
+
6
+ module Buildkite::TestCollector::MinitestPlugin
7
+ def before_setup
8
+ super
9
+ tracer = Buildkite::TestCollector::Tracer.new
10
+ # The _buildkite prefix here is added as a safeguard against name collisions
11
+ # as we are in the main thread
12
+ Thread.current[:_buildkite_tracer] = tracer
13
+ end
14
+
15
+ def before_teardown
16
+ super
17
+
18
+ tracer = Thread.current[:_buildkite_tracer]
19
+ if !tracer.nil?
20
+ Thread.current[:_buildkite_tracer] = nil
21
+ tracer.finalize
22
+
23
+ trace = Buildkite::TestCollector::MinitestPlugin::Trace.new(self, history: tracer.history)
24
+ Buildkite::TestCollector.uploader.traces[trace.source_location] = trace
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Buildkite::TestCollector
4
+ class Network
5
+ module NetHTTPPatch
6
+ def request(request, *args, &block)
7
+ unless uri = request.uri
8
+ protocol = use_ssl? ? "https" : "http"
9
+ uri = URI.join("#{protocol}://#{address}:#{port}", request.path)
10
+ end
11
+
12
+ detail = { method: request.method.upcase, url: uri.to_s, lib: "net-http" }
13
+
14
+ http_tracer = Buildkite::TestCollector::Uploader.tracer
15
+ http_tracer&.enter("http", **detail)
16
+
17
+ super
18
+ ensure
19
+ http_tracer&.leave
20
+ end
21
+ end
22
+
23
+ module VCRPatch
24
+ def handle
25
+ if request_type == :stubbed_by_vcr && tracer = Buildkite::TestCollector::Uploader.tracer
26
+ tracer.current_span.detail.merge!(stubbed: "vcr")
27
+ end
28
+
29
+ super
30
+ end
31
+ end
32
+
33
+ module HTTPPatch
34
+ def perform(request, options)
35
+ detail = { method: request.verb.to_s.upcase, url: request.uri.to_s, lib: "http" }
36
+
37
+ http_tracer = Buildkite::TestCollector::Uploader.tracer
38
+ http_tracer&.enter("http", **detail)
39
+
40
+ super
41
+ ensure
42
+ http_tracer&.leave
43
+ end
44
+ end
45
+
46
+ module WebMockPatch
47
+ def response_for_request(request_signature)
48
+ response_from_webmock = super
49
+
50
+ if response_from_webmock && tracer = Buildkite::TestCollector::Uploader.tracer
51
+ tracer.current_span.detail.merge!(stubbed: "webmock")
52
+ end
53
+
54
+ response_from_webmock
55
+ end
56
+ end
57
+
58
+ def self.configure
59
+ if defined?(VCR)
60
+ require "vcr/request_handler"
61
+ VCR::RequestHandler.prepend(VCRPatch)
62
+ end
63
+
64
+ if defined?(WebMock)
65
+ WebMock::StubRegistry.prepend(WebMockPatch)
66
+ end
67
+
68
+ if defined?(Net) && defined?(Net::HTTP)
69
+ Net::HTTP.prepend(NetHTTPPatch)
70
+ end
71
+
72
+ if defined?(HTTP) && defined?(HTTP::Client)
73
+ HTTP::Client.prepend(HTTPPatch)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Buildkite::TestCollector
4
+ class Object
5
+ module CustomObjectSleep
6
+ def sleep(duration)
7
+ tracer = Buildkite::TestCollector::Uploader.tracer
8
+ tracer&.enter("sleep")
9
+
10
+ super
11
+ ensure
12
+ tracer&.leave
13
+ end
14
+ end
15
+
16
+ def self.configure
17
+ ::Object.prepend(CustomObjectSleep)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Buildkite::TestCollector::RSpecPlugin
6
+ class Reporter
7
+ RSpec::Core::Formatters.register self, :example_passed, :example_failed, :example_pending, :dump_summary
8
+
9
+ attr_reader :output
10
+
11
+ def initialize(output)
12
+ @output = output
13
+ end
14
+
15
+ def handle_example(notification)
16
+ example = notification.example
17
+ trace = Buildkite::TestCollector.uploader.traces[example.id]
18
+
19
+ if trace
20
+ trace.example = example
21
+ if example.execution_result.status == :failed
22
+ trace.failure_reason, trace.failure_expanded = failure_info(notification)
23
+ end
24
+ Buildkite::TestCollector.session&.write_result(trace)
25
+ end
26
+ end
27
+
28
+ def dump_summary(notification)
29
+ if Buildkite::TestCollector.session.present?
30
+ examples_count = {
31
+ examples: notification.examples.count,
32
+ failed: notification.failed_examples.count,
33
+ pending: notification.pending_examples.count,
34
+ errors_outside_examples: notification.errors_outside_of_examples_count
35
+ }
36
+
37
+ Buildkite::TestCollector.session.close(examples_count)
38
+ end
39
+ end
40
+
41
+ alias_method :example_passed, :handle_example
42
+ alias_method :example_failed, :handle_example
43
+ alias_method :example_pending, :handle_example
44
+
45
+ private
46
+
47
+ MULTIPLE_ERRORS = [
48
+ RSpec::Expectations::MultipleExpectationsNotMetError,
49
+ RSpec::Core::MultipleExceptionError
50
+ ]
51
+
52
+ def failure_info(notification)
53
+ failure_expanded = []
54
+
55
+ if Buildkite::TestCollector::RSpecPlugin::Reporter::MULTIPLE_ERRORS.include?(notification.exception.class)
56
+ failure_reason = notification.exception.summary
57
+ notification.exception.all_exceptions.each do |exception|
58
+ # an example with multiple failures doesn't give us a
59
+ # separate message lines and backtrace object to send, so
60
+ # I've reached into RSpec internals and duplicated the
61
+ # construction of these
62
+ message_lines = RSpec::Core::Formatters::ExceptionPresenter.new(exception, notification.example).colorized_message_lines
63
+
64
+ failure_expanded << {
65
+ expanded: format_message_lines(message_lines),
66
+ backtrace: RSpec.configuration.backtrace_formatter.format_backtrace(exception.backtrace)
67
+ }
68
+ end
69
+ else
70
+ message_lines = notification.colorized_message_lines
71
+ failure_reason = strip_diff_colors(message_lines.shift)
72
+
73
+ failure_expanded << {
74
+ expanded: format_message_lines(message_lines),
75
+ backtrace: notification.formatted_backtrace
76
+ }
77
+ end
78
+
79
+ return failure_reason, failure_expanded
80
+ end
81
+
82
+ def format_message_lines(message_lines)
83
+ message_lines.map! { |l| strip_diff_colors(l) }
84
+ # the first line is sometimes blank, depending on the error reported
85
+ message_lines.shift if message_lines.first.blank?
86
+ # the last line is sometimes blank, depending on the error reported
87
+ message_lines.pop if message_lines.last.blank?
88
+ message_lines
89
+ end
90
+
91
+ def strip_diff_colors(string)
92
+ string.gsub(/\e\[([;\d]+)?m/, '')
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+
5
+ module Buildkite::TestCollector::RSpecPlugin
6
+ class Trace
7
+ attr_accessor :example, :failure_reason, :failure_expanded
8
+ attr_reader :id, :history
9
+
10
+ FILE_PATH_REGEX = /^(.*?\.(rb|feature))/
11
+
12
+ def initialize(example, history:, failure_reason: nil, failure_expanded: [])
13
+ @id = SecureRandom.uuid
14
+ @example = example
15
+ @history = history
16
+ @failure_reason = failure_reason
17
+ @failure_expanded = failure_expanded
18
+ end
19
+
20
+ def result
21
+ case example.execution_result.status
22
+ when :passed; "passed"
23
+ when :failed; "failed"
24
+ when :pending; "skipped"
25
+ end
26
+ end
27
+
28
+ def as_hash
29
+ strip_invalid_utf8_chars(
30
+ id: id,
31
+ scope: example.example_group.metadata[:full_description],
32
+ name: example.description,
33
+ identifier: example.id,
34
+ location: example.location,
35
+ file_name: file_name,
36
+ result: result,
37
+ failure_reason: failure_reason,
38
+ failure_expanded: failure_expanded,
39
+ history: history,
40
+ ).with_indifferent_access.compact
41
+ end
42
+
43
+ private
44
+
45
+ def file_name
46
+ @file_name ||= begin
47
+ identifier_file_name = strip_invalid_utf8_chars(example.id)[FILE_PATH_REGEX]
48
+ location_file_name = example.location[FILE_PATH_REGEX]
49
+
50
+ if identifier_file_name != location_file_name
51
+ # If the identifier and location files are not the same, we assume
52
+ # that the test was run as part of a shared example. If this isn't the
53
+ # case, then there's something we haven't accounted for
54
+ if shared_example?
55
+ # Taking the last frame in this backtrace will give us the original
56
+ # entry point for the shared example
57
+ shared_example_call_location[FILE_PATH_REGEX]
58
+ else
59
+ "Unknown"
60
+ end
61
+ else
62
+ identifier_file_name
63
+ end
64
+ end
65
+ end
66
+
67
+ def shared_example?
68
+ example.metadata[:shared_group_inclusion_backtrace].any?
69
+ end
70
+
71
+ def shared_example_call_location
72
+ example.metadata[:shared_group_inclusion_backtrace].last.inclusion_location
73
+ end
74
+
75
+ def strip_invalid_utf8_chars(object)
76
+ if object.is_a?(Hash)
77
+ Hash[object.map { |key, value| [key, strip_invalid_utf8_chars(value)] }]
78
+ elsif object.is_a?(Array)
79
+ object.map { |value| strip_invalid_utf8_chars(value) }
80
+ elsif object.is_a?(String)
81
+ object.encode('UTF-8', :invalid => :replace, :undef => :replace)
82
+ else
83
+ object
84
+ end
85
+ end
86
+ end
87
+ end