rspec-flake-classifier 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/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/Rakefile +8 -0
- data/exe/rspec-flake +6 -0
- data/lib/rspec/flake/classifier/classify/classifier.rb +228 -0
- data/lib/rspec/flake/classifier/classify/context.rb +41 -0
- data/lib/rspec/flake/classifier/classify/result.rb +44 -0
- data/lib/rspec/flake/classifier/cli.rb +298 -0
- data/lib/rspec/flake/classifier/configuration.rb +40 -0
- data/lib/rspec/flake/classifier/coverage_snapshot.rb +89 -0
- data/lib/rspec/flake/classifier/deflaker.rb +102 -0
- data/lib/rspec/flake/classifier/evaluation.rb +127 -0
- data/lib/rspec/flake/classifier/example_history.rb +24 -0
- data/lib/rspec/flake/classifier/features.rb +42 -0
- data/lib/rspec/flake/classifier/formatter.rb +194 -0
- data/lib/rspec/flake/classifier/integrations.rb +247 -0
- data/lib/rspec/flake/classifier/predictor.rb +144 -0
- data/lib/rspec/flake/classifier/probe_evidence.rb +77 -0
- data/lib/rspec/flake/classifier/rerun/bisect_dependency_search.rb +81 -0
- data/lib/rspec/flake/classifier/rerun/isolated_runner.rb +69 -0
- data/lib/rspec/flake/classifier/rerun/protocol.rb +83 -0
- data/lib/rspec/flake/classifier/rerun/result.rb +82 -0
- data/lib/rspec/flake/classifier/runtime_controls.rb +63 -0
- data/lib/rspec/flake/classifier/sensitivity.rb +82 -0
- data/lib/rspec/flake/classifier/signature.rb +59 -0
- data/lib/rspec/flake/classifier/store/jsonl_store.rb +131 -0
- data/lib/rspec/flake/classifier/version.rb +13 -0
- data/lib/rspec/flake/classifier.rb +285 -0
- data/sig/rspec/flake/classifier.rbs +176 -0
- metadata +135 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
class Features
|
|
6
|
+
RESOURCE_PATTERNS = {
|
|
7
|
+
"network" => /Net::HTTP|HTTP\.|Faraday|Socket|TCPSocket|UDPSocket|WebMock|VCR/,
|
|
8
|
+
"filesystem" => /\bFile\b|\bDir\b|Tempfile|Pathname|tmpdir|fixture/,
|
|
9
|
+
"time" => /\bTime\b|\bDate\b|Timecop|travel_to|freeze_time|timezone|TZ/,
|
|
10
|
+
"randomness" => /Random|SecureRandom|srand|shuffle|sample|Faker/,
|
|
11
|
+
"concurrency" => /Thread|Fiber|Mutex|Queue|Concurrent::|fork/,
|
|
12
|
+
"async_wait" => /sleep|wait|eventually|Capybara|Selenium/
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def extract(file: nil, source: nil, duration: nil, metadata: {})
|
|
16
|
+
source ||= file && File.file?(file) ? File.read(file) : ""
|
|
17
|
+
features = {
|
|
18
|
+
"file" => file,
|
|
19
|
+
"duration" => duration&.to_f,
|
|
20
|
+
"line_count" => source.lines.count,
|
|
21
|
+
"expectation_count" => source.scan(/\b(expect|should|is_expected)\b/).length,
|
|
22
|
+
"example_count" => source.scan(/\b(it|specify|example)\s+["'{(]/).length,
|
|
23
|
+
"metadata" => stringify_keys(metadata)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
RESOURCE_PATTERNS.each do |name, pattern|
|
|
27
|
+
features["uses_#{name}"] = source.match?(pattern)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
features
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def stringify_keys(hash)
|
|
36
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
37
|
+
result[key.to_s] = value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "cgi"
|
|
5
|
+
require_relative "../classifier"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module FlakeClassifier
|
|
9
|
+
class Formatter
|
|
10
|
+
::RSpec::Core::Formatters.register self, :example_failed, :dump_summary if defined?(::RSpec::Core)
|
|
11
|
+
|
|
12
|
+
def initialize(output)
|
|
13
|
+
@output = output
|
|
14
|
+
@failures = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def example_failed(notification)
|
|
18
|
+
example = notification.example
|
|
19
|
+
exception = notification_exception(notification, example)
|
|
20
|
+
signature = Signature.from_exception(exception)
|
|
21
|
+
classification = classify(example, exception)
|
|
22
|
+
@failures << failure_hash(example, exception, signature, classification)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dump_summary(_notification)
|
|
26
|
+
output.puts(JSON.pretty_generate("failures" => failures))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :output, :failures
|
|
32
|
+
|
|
33
|
+
def classify(example, exception)
|
|
34
|
+
stored = example.metadata[:flake_classifier]&.fetch(:classification, nil)
|
|
35
|
+
return stored if stored
|
|
36
|
+
|
|
37
|
+
Classify::Classifier.new.classify(message: exception.message, backtrace: exception.backtrace).to_h
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failure_hash(example, exception, signature, classification)
|
|
41
|
+
labels = Array(classification["labels"]).map { |label| label_category(label) }
|
|
42
|
+
{
|
|
43
|
+
"id" => example.id,
|
|
44
|
+
"description" => example.full_description,
|
|
45
|
+
"file_path" => example.metadata[:file_path],
|
|
46
|
+
"line_number" => example.metadata[:line_number],
|
|
47
|
+
"message" => exception.message,
|
|
48
|
+
"signature" => signature.digest,
|
|
49
|
+
"flaky" => flaky?(classification),
|
|
50
|
+
"labels" => labels,
|
|
51
|
+
"ci" => ci_metadata(example, signature.digest, labels, classification),
|
|
52
|
+
"classification" => classification
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def flaky?(classification)
|
|
57
|
+
classification["status"] == "flaky" || !Array(classification["labels"]).empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def notification_exception(notification, example)
|
|
61
|
+
return notification.exception if notification.respond_to?(:exception)
|
|
62
|
+
|
|
63
|
+
example.exception
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def label_category(label)
|
|
67
|
+
return label.fetch("category", label.fetch(:category, label.to_s)) if label.respond_to?(:fetch)
|
|
68
|
+
|
|
69
|
+
label.to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ci_metadata(example, signature, labels, classification)
|
|
73
|
+
{
|
|
74
|
+
"buildkite" => {
|
|
75
|
+
"flaky" => flaky?(classification),
|
|
76
|
+
"flake_signature" => signature,
|
|
77
|
+
"flaky_labels" => labels
|
|
78
|
+
},
|
|
79
|
+
"circleci" => {
|
|
80
|
+
"result" => "failure",
|
|
81
|
+
"source" => example.metadata[:file_path].to_s,
|
|
82
|
+
"run_time" => run_time(example),
|
|
83
|
+
"flaky" => flaky?(classification),
|
|
84
|
+
"flaky_labels" => labels.join(",")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
rescue StandardError
|
|
88
|
+
{
|
|
89
|
+
"buildkite" => { "flaky" => flaky?(classification), "flake_signature" => signature, "flaky_labels" => labels },
|
|
90
|
+
"circleci" => { "result" => "failure", "source" => example.metadata[:file_path].to_s, "flaky" => flaky?(classification), "flaky_labels" => labels.join(",") }
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_time(example)
|
|
95
|
+
return nil unless example.respond_to?(:execution_result)
|
|
96
|
+
return nil unless example.execution_result.respond_to?(:run_time)
|
|
97
|
+
|
|
98
|
+
example.execution_result.run_time.to_f
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class JUnitFormatter
|
|
103
|
+
::RSpec::Core::Formatters.register self, :example_failed, :example_passed, :dump_summary if defined?(::RSpec::Core)
|
|
104
|
+
|
|
105
|
+
def initialize(output)
|
|
106
|
+
@output = output
|
|
107
|
+
@testcases = []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def example_passed(notification)
|
|
111
|
+
@testcases << testcase(notification.example)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def example_failed(notification)
|
|
115
|
+
example = notification.example
|
|
116
|
+
exception = notification.respond_to?(:exception) ? notification.exception : example.exception
|
|
117
|
+
signature = Signature.from_exception(exception)
|
|
118
|
+
classification = classify(example, exception)
|
|
119
|
+
@testcases << testcase(
|
|
120
|
+
example,
|
|
121
|
+
failure: exception.message,
|
|
122
|
+
signature: signature.digest,
|
|
123
|
+
classification: classification
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def dump_summary(_notification)
|
|
128
|
+
output.puts(%(<?xml version="1.0" encoding="UTF-8"?>))
|
|
129
|
+
output.puts(%(<testsuite tests="#{testcases.length}">))
|
|
130
|
+
testcases.each { |attributes| output.puts(render_case(attributes)) }
|
|
131
|
+
output.puts("</testsuite>")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
attr_reader :output, :testcases
|
|
137
|
+
|
|
138
|
+
def classify(example, exception)
|
|
139
|
+
stored = example.metadata[:flake_classifier]&.fetch(:classification, nil)
|
|
140
|
+
return stored if stored
|
|
141
|
+
|
|
142
|
+
Classify::Classifier.new.classify(message: exception.message, backtrace: exception.backtrace).to_h
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def testcase(example, failure: nil, signature: nil, classification: nil)
|
|
146
|
+
labels = Array(classification&.fetch("labels", nil)).map { |label| label_category(label) }
|
|
147
|
+
{
|
|
148
|
+
"classname" => example.metadata[:file_path].to_s,
|
|
149
|
+
"name" => example.full_description,
|
|
150
|
+
"file" => example.metadata[:file_path].to_s,
|
|
151
|
+
"line" => example.metadata[:line_number].to_s,
|
|
152
|
+
"flaky" => (!labels.empty?).to_s,
|
|
153
|
+
"flaky_labels" => labels.join(","),
|
|
154
|
+
"flake_signature" => signature.to_s,
|
|
155
|
+
"properties" => {
|
|
156
|
+
"buildkite.flaky" => (!labels.empty?).to_s,
|
|
157
|
+
"buildkite.flaky_labels" => labels.join(","),
|
|
158
|
+
"buildkite.flake_signature" => signature.to_s,
|
|
159
|
+
"circleci.flaky" => (!labels.empty?).to_s,
|
|
160
|
+
"circleci.flaky_labels" => labels.join(","),
|
|
161
|
+
"circleci.source" => example.metadata[:file_path].to_s
|
|
162
|
+
},
|
|
163
|
+
"failure" => failure
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def render_case(attributes)
|
|
168
|
+
failure = attributes.delete("failure")
|
|
169
|
+
properties = attributes.delete("properties") || {}
|
|
170
|
+
xml_attributes = attributes.map do |key, value|
|
|
171
|
+
%(#{key}="#{CGI.escapeHTML(value.to_s)}")
|
|
172
|
+
end.join(" ")
|
|
173
|
+
return %( <testcase #{xml_attributes}>#{render_properties(properties)}</testcase>) unless failure
|
|
174
|
+
|
|
175
|
+
%( <testcase #{xml_attributes}>#{render_properties(properties)}<failure>#{CGI.escapeHTML(failure)}</failure></testcase>)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def label_category(label)
|
|
179
|
+
return label.fetch("category", label.fetch(:category, label.to_s)) if label.respond_to?(:fetch)
|
|
180
|
+
|
|
181
|
+
label.to_s
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def render_properties(properties)
|
|
185
|
+
return "" if properties.empty?
|
|
186
|
+
|
|
187
|
+
values = properties.map do |key, value|
|
|
188
|
+
%( <property name="#{CGI.escapeHTML(key.to_s)}" value="#{CGI.escapeHTML(value.to_s)}"/>)
|
|
189
|
+
end.join
|
|
190
|
+
"<properties>#{values}</properties>"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
module Integrations
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def coverage_for(example)
|
|
9
|
+
RSpecCovers.coverage_for(example)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def probe_for(example)
|
|
13
|
+
RSpecHermetic.probe_for(example)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def tag_ci_execution(example:, signature:, classification:)
|
|
17
|
+
BuildkiteCollector.tag_execution(example: example, signature: signature, classification: classification)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def constant(name)
|
|
21
|
+
name.to_s.split("::").reduce(Object) do |namespace, const_name|
|
|
22
|
+
return nil unless namespace.const_defined?(const_name, false)
|
|
23
|
+
|
|
24
|
+
namespace.const_get(const_name, false)
|
|
25
|
+
end
|
|
26
|
+
rescue NameError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def try_require(feature)
|
|
31
|
+
require feature
|
|
32
|
+
true
|
|
33
|
+
rescue LoadError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call_first(receivers, methods, *args)
|
|
38
|
+
Array(receivers).compact.each do |receiver|
|
|
39
|
+
Array(methods).each do |method_name|
|
|
40
|
+
next unless receiver.respond_to?(method_name)
|
|
41
|
+
|
|
42
|
+
value = receiver.public_send(method_name, *args)
|
|
43
|
+
return value if value
|
|
44
|
+
rescue ArgumentError
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module RSpecCovers
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
def coverage_for(example)
|
|
55
|
+
Integrations.try_require("rspec/covers")
|
|
56
|
+
result = reporter_result(example)
|
|
57
|
+
return line_hash(result.executed_locations) if result&.respond_to?(:executed_locations)
|
|
58
|
+
|
|
59
|
+
receivers = [
|
|
60
|
+
Integrations.constant("RSpec::Covers"),
|
|
61
|
+
Integrations.constant("RSpec::Covers::Probe"),
|
|
62
|
+
Integrations.constant("RSpec::Covers::Store"),
|
|
63
|
+
Integrations.constant("RSpec::Covers::Coverage"),
|
|
64
|
+
Integrations.constant("Rspec::Covers")
|
|
65
|
+
]
|
|
66
|
+
Integrations.call_first(
|
|
67
|
+
receivers,
|
|
68
|
+
%i[coverage_for covered_lines_for example_coverage result_for for_example],
|
|
69
|
+
example
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reporter_result(example)
|
|
74
|
+
covers = Integrations.constant("RSpec::Covers")
|
|
75
|
+
return nil unless covers&.respond_to?(:reporter)
|
|
76
|
+
return nil unless covers.reporter.respond_to?(:results)
|
|
77
|
+
|
|
78
|
+
covers.reporter.results.reverse.find do |result|
|
|
79
|
+
result.respond_to?(:id) && result.id == example.id ||
|
|
80
|
+
result.respond_to?(:example) && result.example.equal?(example)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def line_hash(locations)
|
|
85
|
+
Array(locations).each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |location, result|
|
|
86
|
+
file = location.respond_to?(:file) ? location.file : location[:file]
|
|
87
|
+
line = location.respond_to?(:line) ? location.line : location[:line]
|
|
88
|
+
result[relative_path(file)] << line.to_i if file && line
|
|
89
|
+
end.transform_values { |lines| lines.uniq.sort }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def relative_path(file)
|
|
93
|
+
path = file.to_s
|
|
94
|
+
return path unless path.start_with?(Dir.pwd)
|
|
95
|
+
|
|
96
|
+
path.delete_prefix("#{Dir.pwd}/")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
module RSpecHermetic
|
|
101
|
+
module_function
|
|
102
|
+
|
|
103
|
+
def probe_for(example)
|
|
104
|
+
Integrations.try_require("rspec/hermetic")
|
|
105
|
+
record = runner_record(example)
|
|
106
|
+
return normalize_record(record) if record
|
|
107
|
+
|
|
108
|
+
receivers = [
|
|
109
|
+
Integrations.constant("RSpec::Hermetic"),
|
|
110
|
+
Integrations.constant("RSpec::Hermetic::Forensic"),
|
|
111
|
+
Integrations.constant("RSpec::Hermetic::Probe"),
|
|
112
|
+
Integrations.constant("Rspec::Hermetic")
|
|
113
|
+
]
|
|
114
|
+
value = Integrations.call_first(
|
|
115
|
+
receivers,
|
|
116
|
+
%i[evidence_for forensic_for probe_for result_for for_example],
|
|
117
|
+
example
|
|
118
|
+
)
|
|
119
|
+
return normalize_hash(value) if value
|
|
120
|
+
|
|
121
|
+
collect_individual(receivers, example)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def collect_individual(receivers, example)
|
|
125
|
+
{
|
|
126
|
+
resources: Integrations.call_first(receivers, %i[resources_for resource_leaks_for leaks_for], example),
|
|
127
|
+
files: Integrations.call_first(receivers, %i[files_for filesystem_for], example),
|
|
128
|
+
sockets: Integrations.call_first(receivers, %i[sockets_for network_for], example)
|
|
129
|
+
}.compact
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def runner_record(example)
|
|
133
|
+
hermetic = Integrations.constant("RSpec::Hermetic")
|
|
134
|
+
return nil unless hermetic&.respond_to?(:runner)
|
|
135
|
+
return nil unless hermetic.runner&.respond_to?(:records)
|
|
136
|
+
|
|
137
|
+
hermetic.runner.records.reverse.find do |record|
|
|
138
|
+
candidate = record[:example] || record["example"]
|
|
139
|
+
candidate.equal?(example) || candidate&.respond_to?(:id) && candidate.id == example.id
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def normalize_record(record)
|
|
144
|
+
changes = Array((record[:changes] || record["changes"])&.changes)
|
|
145
|
+
evidence = changes.map { |change| change_summary(change) }
|
|
146
|
+
{
|
|
147
|
+
resources: evidence_for(changes, /resource|runtime|global|constant|env|random|time|rails/i),
|
|
148
|
+
files: evidence_for(changes, /file|filesystem|tmp|dir/i),
|
|
149
|
+
sockets: evidence_for(changes, /socket|network|tcp|udp|http/i),
|
|
150
|
+
source: evidence.join("\n")
|
|
151
|
+
}.compact
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def evidence_for(changes, pattern)
|
|
155
|
+
changes.select { |change| change.probe.to_s.match?(pattern) || change.key.to_s.match?(pattern) }
|
|
156
|
+
.map { |change| change_summary(change) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def change_summary(change)
|
|
160
|
+
"#{change.probe}:#{change.key} #{stable_value(change.before)} -> #{stable_value(change.after)}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def stable_value(value)
|
|
164
|
+
return "<missing>" if missing_value?(value)
|
|
165
|
+
|
|
166
|
+
value.inspect
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def missing_value?(value)
|
|
170
|
+
change = Integrations.constant("RSpec::Hermetic::Change")
|
|
171
|
+
change && change.const_defined?(:MISSING) && value.equal?(change.const_get(:MISSING))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def normalize_hash(value)
|
|
175
|
+
return value.to_h if value.respond_to?(:to_h)
|
|
176
|
+
|
|
177
|
+
{
|
|
178
|
+
resources: value.respond_to?(:resources) ? value.resources : nil,
|
|
179
|
+
files: value.respond_to?(:files) ? value.files : nil,
|
|
180
|
+
sockets: value.respond_to?(:sockets) ? value.sockets : nil,
|
|
181
|
+
source: value.respond_to?(:source) ? value.source : nil
|
|
182
|
+
}.compact
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
module BuildkiteCollector
|
|
187
|
+
module_function
|
|
188
|
+
|
|
189
|
+
def tag_execution(example:, signature:, classification:)
|
|
190
|
+
labels = labels_for(classification)
|
|
191
|
+
tags = {
|
|
192
|
+
"rspec_flake_classifier.flaky" => flaky?(classification, labels).to_s,
|
|
193
|
+
"rspec_flake_classifier.labels" => labels.join(","),
|
|
194
|
+
"rspec_flake_classifier.signature" => signature.digest.to_s
|
|
195
|
+
}
|
|
196
|
+
return if tag_current_execution(tags)
|
|
197
|
+
|
|
198
|
+
tag_recorded_trace(example, tags)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def tag_current_execution(tags)
|
|
204
|
+
collector = Integrations.constant("Buildkite::TestCollector")
|
|
205
|
+
return false unless collector&.respond_to?(:tag_execution)
|
|
206
|
+
|
|
207
|
+
tags.each { |key, value| collector.tag_execution(key, value) }
|
|
208
|
+
true
|
|
209
|
+
rescue StandardError
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def tag_recorded_trace(example, tags)
|
|
214
|
+
trace = buildkite_trace(example)
|
|
215
|
+
return unless trace&.respond_to?(:tags)
|
|
216
|
+
return unless trace.tags.respond_to?(:[]=)
|
|
217
|
+
|
|
218
|
+
tags.each { |key, value| trace.tags[key] = value }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def buildkite_trace(example)
|
|
222
|
+
collector = Integrations.constant("Buildkite::TestCollector")
|
|
223
|
+
uploader = collector&.respond_to?(:uploader) ? collector.uploader : nil
|
|
224
|
+
return nil unless uploader&.respond_to?(:traces)
|
|
225
|
+
|
|
226
|
+
uploader.traces[example.id]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def labels_for(classification)
|
|
230
|
+
hash = classification.respond_to?(:to_h) ? classification.to_h : classification
|
|
231
|
+
Array(hash&.fetch("labels", nil)).map do |label|
|
|
232
|
+
if label.respond_to?(:fetch)
|
|
233
|
+
label.fetch("category", label.fetch(:category, label.to_s)).to_s
|
|
234
|
+
else
|
|
235
|
+
label.to_s
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def flaky?(classification, labels)
|
|
241
|
+
hash = classification.respond_to?(:to_h) ? classification.to_h : classification
|
|
242
|
+
hash&.fetch("status", nil) == "flaky" || labels.any?
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module FlakeClassifier
|
|
7
|
+
class Predictor
|
|
8
|
+
WEIGHTS = {
|
|
9
|
+
"duration" => 0.15,
|
|
10
|
+
"uses_network" => 0.2,
|
|
11
|
+
"uses_filesystem" => 0.08,
|
|
12
|
+
"uses_time" => 0.15,
|
|
13
|
+
"uses_randomness" => 0.15,
|
|
14
|
+
"uses_concurrency" => 0.17,
|
|
15
|
+
"uses_async_wait" => 0.2
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :weights
|
|
19
|
+
|
|
20
|
+
def self.train(examples)
|
|
21
|
+
new(weights: Trainer.new(Array(examples)).weights)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.load(path)
|
|
25
|
+
payload = JSON.parse(File.read(path))
|
|
26
|
+
new(weights: payload.fetch("weights", payload))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(weights: WEIGHTS)
|
|
30
|
+
@weights = normalize_weights(weights)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def score(features)
|
|
34
|
+
score = weights.sum do |key, weight|
|
|
35
|
+
feature_score(features, key) * weight
|
|
36
|
+
end
|
|
37
|
+
[[score, 0.0].max, 1.0].min.round(4)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def rank(feature_sets)
|
|
41
|
+
Array(feature_sets).map do |features|
|
|
42
|
+
features.merge("flake_score" => score(features), "priority" => priority(score(features)))
|
|
43
|
+
end.sort_by { |features| -features.fetch("flake_score") }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
{ "weights" => weights }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def feature_score(features, key)
|
|
53
|
+
value = features[key] || features[key.to_sym]
|
|
54
|
+
return duration_score(value) if key == "duration"
|
|
55
|
+
|
|
56
|
+
value ? 1.0 : 0.0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def duration_score(value)
|
|
60
|
+
duration = Float(value || 0.0)
|
|
61
|
+
return 0.0 unless duration.positive?
|
|
62
|
+
|
|
63
|
+
[duration / 10.0, 1.0].min
|
|
64
|
+
rescue ArgumentError, TypeError
|
|
65
|
+
0.0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def priority(score)
|
|
69
|
+
return "high" if score >= 0.45
|
|
70
|
+
return "medium" if score >= 0.2
|
|
71
|
+
|
|
72
|
+
"low"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize_weights(input)
|
|
76
|
+
weights = WEIGHTS.merge(stringify_keys(input)).transform_values { |value| Float(value) }
|
|
77
|
+
total = weights.values.sum
|
|
78
|
+
return WEIGHTS if total <= 0.0
|
|
79
|
+
|
|
80
|
+
weights.transform_values { |value| (value / total).round(6) }
|
|
81
|
+
rescue ArgumentError, TypeError
|
|
82
|
+
WEIGHTS
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stringify_keys(hash)
|
|
86
|
+
hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class Trainer
|
|
90
|
+
def initialize(examples)
|
|
91
|
+
@examples = examples
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def weights
|
|
95
|
+
positives = examples.select { |example| flaky?(example) }
|
|
96
|
+
negatives = examples.reject { |example| flaky?(example) }
|
|
97
|
+
return WEIGHTS if positives.empty? || negatives.empty?
|
|
98
|
+
|
|
99
|
+
raw = WEIGHTS.keys.to_h do |key|
|
|
100
|
+
[key, [average(positives, key) - average(negatives, key), 0.0].max]
|
|
101
|
+
end
|
|
102
|
+
normalize(raw)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
attr_reader :examples
|
|
108
|
+
|
|
109
|
+
def flaky?(example)
|
|
110
|
+
value = example["flaky"] || example[:flaky] || example["label"] || example[:label] ||
|
|
111
|
+
example["status"] || example[:status]
|
|
112
|
+
value == true || value.to_s == "flaky"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def average(records, key)
|
|
116
|
+
records.sum { |record| feature_score(record, key) } / records.length.to_f
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def feature_score(record, key)
|
|
120
|
+
value = record[key] || record[key.to_sym]
|
|
121
|
+
return duration_score(value) if key == "duration"
|
|
122
|
+
|
|
123
|
+
value ? 1.0 : 0.0
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def duration_score(value)
|
|
127
|
+
duration = Float(value || 0.0)
|
|
128
|
+
return 0.0 unless duration.positive?
|
|
129
|
+
|
|
130
|
+
[duration / 10.0, 1.0].min
|
|
131
|
+
rescue ArgumentError, TypeError
|
|
132
|
+
0.0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def normalize(raw)
|
|
136
|
+
total = raw.values.sum
|
|
137
|
+
return WEIGHTS if total <= 0.0
|
|
138
|
+
|
|
139
|
+
raw.transform_values { |value| (value / total).round(6) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "integrations"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module FlakeClassifier
|
|
7
|
+
class ProbeEvidence
|
|
8
|
+
EMPTY = {
|
|
9
|
+
resources: [],
|
|
10
|
+
files: [],
|
|
11
|
+
sockets: [],
|
|
12
|
+
source: nil
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(provider: nil)
|
|
16
|
+
@provider = provider
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def collect(example)
|
|
20
|
+
data = from_integration(example).merge(from_provider(example)) do |_key, integration_value, provider_value|
|
|
21
|
+
merge_value(integration_value, provider_value)
|
|
22
|
+
end.merge(from_metadata(example)) do |_key, provider_value, metadata_value|
|
|
23
|
+
merge_value(provider_value, metadata_value)
|
|
24
|
+
end
|
|
25
|
+
EMPTY.merge(data)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :provider
|
|
31
|
+
|
|
32
|
+
def from_integration(example)
|
|
33
|
+
normalize(Integrations.probe_for(example))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def from_provider(example)
|
|
37
|
+
return {} unless provider
|
|
38
|
+
|
|
39
|
+
normalize(provider.call(example))
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
{ resources: ["probe provider failed: #{e.class}: #{e.message}"] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def from_metadata(example)
|
|
45
|
+
metadata = example.metadata
|
|
46
|
+
normalize(
|
|
47
|
+
resources: metadata[:flake_classifier_resources] || metadata[:hermetic_resources],
|
|
48
|
+
files: metadata[:flake_classifier_files] || metadata[:hermetic_files],
|
|
49
|
+
sockets: metadata[:flake_classifier_sockets] || metadata[:hermetic_sockets],
|
|
50
|
+
source: metadata[:flake_classifier_source]
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def normalize(data)
|
|
55
|
+
hash = data || {}
|
|
56
|
+
{
|
|
57
|
+
resources: array_value(hash, :resources),
|
|
58
|
+
files: array_value(hash, :files),
|
|
59
|
+
sockets: array_value(hash, :sockets),
|
|
60
|
+
source: hash[:source] || hash["source"]
|
|
61
|
+
}.compact
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def array_value(hash, key)
|
|
65
|
+
Array(hash[key] || hash[key.to_s]).compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def merge_value(left, right)
|
|
69
|
+
return right if left.nil?
|
|
70
|
+
return left if right.nil?
|
|
71
|
+
return [*Array(left), *Array(right)].uniq unless left.is_a?(String) || right.is_a?(String)
|
|
72
|
+
|
|
73
|
+
[left, right].compact.join("\n")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|