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,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "result"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module FlakeClassifier
|
|
7
|
+
module Rerun
|
|
8
|
+
class BisectDependencySearch
|
|
9
|
+
BisectRunResult = Struct.new(:all_example_ids, :failed_example_ids, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize(runner:)
|
|
12
|
+
@runner = runner
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def find(example_id, prior_examples, seed: nil)
|
|
16
|
+
return [] if prior_examples.empty?
|
|
17
|
+
|
|
18
|
+
require_rspec_bisect!
|
|
19
|
+
result = minimizer(example_id, prior_examples, seed).find_minimal_repro
|
|
20
|
+
Array(result) - [example_id]
|
|
21
|
+
rescue LoadError, NameError, StandardError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :runner
|
|
28
|
+
|
|
29
|
+
def require_rspec_bisect!
|
|
30
|
+
require "rspec/core/bisect/example_minimizer"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def minimizer(example_id, prior_examples, seed)
|
|
34
|
+
RSpec::Core::Bisect::ExampleMinimizer.new(
|
|
35
|
+
ShellCommand.new,
|
|
36
|
+
RunnerAdapter.new(runner, example_id, prior_examples, seed),
|
|
37
|
+
NullNotifier.new
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class ShellCommand
|
|
42
|
+
def original_cli_args
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def repro_command_from(locations)
|
|
47
|
+
locations.join(" ")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class RunnerAdapter
|
|
52
|
+
def initialize(runner, example_id, prior_examples, seed)
|
|
53
|
+
@runner = runner
|
|
54
|
+
@example_id = example_id
|
|
55
|
+
@prior_examples = prior_examples
|
|
56
|
+
@seed = seed
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def original_results
|
|
60
|
+
BisectRunResult.new(
|
|
61
|
+
all_example_ids: @prior_examples + [@example_id],
|
|
62
|
+
failed_example_ids: [@example_id]
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run(example_ids)
|
|
67
|
+
result = @runner.run(example_ids, seed: @seed)
|
|
68
|
+
BisectRunResult.new(
|
|
69
|
+
all_example_ids: Array(example_ids),
|
|
70
|
+
failed_example_ids: result.failed? ? [@example_id] : []
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class NullNotifier
|
|
76
|
+
def publish(*); end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require_relative "result"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module FlakeClassifier
|
|
9
|
+
module Rerun
|
|
10
|
+
class IsolatedRunner
|
|
11
|
+
DISABLE_ENV = "RSPEC_FLAKE_CLASSIFIER_CHILD"
|
|
12
|
+
|
|
13
|
+
attr_reader :command
|
|
14
|
+
|
|
15
|
+
def initialize(command: nil)
|
|
16
|
+
@command = Array(command || default_command)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run(example_ids, seed: nil, env: {}, extra_args: [])
|
|
20
|
+
ids = Array(example_ids)
|
|
21
|
+
args = command + ids
|
|
22
|
+
args += ["--seed", seed.to_s] if seed
|
|
23
|
+
args += Array(extra_args)
|
|
24
|
+
child_env = { DISABLE_ENV => "1" }.merge(stringify_env(env))
|
|
25
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
|
+
stdout, stderr, process_status = Open3.capture3(child_env, *args)
|
|
27
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
28
|
+
|
|
29
|
+
Result.new(
|
|
30
|
+
example_ids: ids,
|
|
31
|
+
seed: seed,
|
|
32
|
+
status: process_status.success? ? "passed" : "failed",
|
|
33
|
+
exit_status: process_status.exitstatus,
|
|
34
|
+
stdout: stdout,
|
|
35
|
+
stderr: stderr,
|
|
36
|
+
duration: duration,
|
|
37
|
+
command: args
|
|
38
|
+
)
|
|
39
|
+
rescue SystemCallError => e
|
|
40
|
+
Result.new(
|
|
41
|
+
example_ids: ids,
|
|
42
|
+
seed: seed,
|
|
43
|
+
status: "failed",
|
|
44
|
+
exit_status: nil,
|
|
45
|
+
stdout: "",
|
|
46
|
+
stderr: e.message,
|
|
47
|
+
duration: 0.0,
|
|
48
|
+
command: args
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def default_command
|
|
55
|
+
return ENV.fetch("RSPEC_FLAKE_RSPEC_COMMAND").split(" ") if ENV["RSPEC_FLAKE_RSPEC_COMMAND"]
|
|
56
|
+
return ["bundle", "exec", "rspec"] if File.file?("Gemfile")
|
|
57
|
+
|
|
58
|
+
[RbConfig.ruby, "-S", "rspec"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stringify_env(env)
|
|
62
|
+
env.each_with_object({}) do |(key, value), result|
|
|
63
|
+
result[key.to_s] = value.to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "isolated_runner"
|
|
4
|
+
require_relative "bisect_dependency_search"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module FlakeClassifier
|
|
8
|
+
module Rerun
|
|
9
|
+
class Protocol
|
|
10
|
+
attr_reader :runner, :same_order_runs
|
|
11
|
+
|
|
12
|
+
def initialize(runner: IsolatedRunner.new, same_order_runs: 3)
|
|
13
|
+
@runner = runner
|
|
14
|
+
@same_order_runs = same_order_runs
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def investigate(example_id, seed: nil, prior_examples: [])
|
|
18
|
+
sequence = Array(prior_examples) + [example_id]
|
|
19
|
+
runs = run_same_order(sequence, seed)
|
|
20
|
+
return true_failure(example_id, runs) if runs.all?(&:failed?)
|
|
21
|
+
|
|
22
|
+
single = runner.run(example_id, seed: seed, extra_args: ["--order", "defined"])
|
|
23
|
+
runs << single
|
|
24
|
+
|
|
25
|
+
if !Array(prior_examples).empty? && runs.any?(&:failed?) && single.passed?
|
|
26
|
+
dependencies = find_dependency(example_id, prior_examples, seed)
|
|
27
|
+
return flaky(example_id, "od", runs, dependencies, "victim")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
flaky(example_id, "nod", runs)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def run_same_order(example_ids, seed)
|
|
36
|
+
Array.new(same_order_runs) { runner.run(example_ids, seed: seed) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def true_failure(example_id, runs)
|
|
40
|
+
Investigation.new(example_id: example_id, status: "true_failure", order_type: nil, runs: runs)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def flaky(example_id, order_type, runs, dependency_examples = [], dependency_role = nil)
|
|
44
|
+
Investigation.new(
|
|
45
|
+
example_id: example_id,
|
|
46
|
+
status: "flaky",
|
|
47
|
+
order_type: order_type,
|
|
48
|
+
runs: runs,
|
|
49
|
+
dependency_examples: dependency_examples,
|
|
50
|
+
dependency_role: dependency_role
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def find_dependency(example_id, prior_examples, seed)
|
|
55
|
+
candidates = Array(prior_examples)
|
|
56
|
+
return [] if candidates.empty?
|
|
57
|
+
|
|
58
|
+
bisect_result = BisectDependencySearch.new(runner: runner).find(example_id, candidates, seed: seed)
|
|
59
|
+
return bisect_result if bisect_result
|
|
60
|
+
|
|
61
|
+
found = []
|
|
62
|
+
search = candidates
|
|
63
|
+
|
|
64
|
+
until search.empty?
|
|
65
|
+
half_size = [(search.length / 2.0).ceil, 1].max
|
|
66
|
+
left = search.first(half_size)
|
|
67
|
+
right = search.drop(half_size)
|
|
68
|
+
run = runner.run(left + [example_id], seed: seed)
|
|
69
|
+
|
|
70
|
+
if run.failed?
|
|
71
|
+
found = left
|
|
72
|
+
search = left.length == search.length ? [] : left
|
|
73
|
+
else
|
|
74
|
+
search = right
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
found
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
module Rerun
|
|
6
|
+
Result = Struct.new(
|
|
7
|
+
:example_ids,
|
|
8
|
+
:seed,
|
|
9
|
+
:status,
|
|
10
|
+
:exit_status,
|
|
11
|
+
:stdout,
|
|
12
|
+
:stderr,
|
|
13
|
+
:duration,
|
|
14
|
+
:command,
|
|
15
|
+
keyword_init: true
|
|
16
|
+
) do
|
|
17
|
+
def passed?
|
|
18
|
+
status.to_s == "passed"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def failed?
|
|
22
|
+
status.to_s == "failed"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
"example_ids" => Array(example_ids),
|
|
28
|
+
"seed" => seed,
|
|
29
|
+
"status" => status,
|
|
30
|
+
"exit_status" => exit_status,
|
|
31
|
+
"duration" => duration,
|
|
32
|
+
"command" => Array(command)
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class Investigation
|
|
38
|
+
attr_reader :example_id, :status, :order_type, :runs, :dependency_examples,
|
|
39
|
+
:dependency_role
|
|
40
|
+
|
|
41
|
+
def initialize(example_id:, status:, order_type:, runs:, dependency_examples: [], dependency_role: nil)
|
|
42
|
+
@example_id = example_id
|
|
43
|
+
@status = status.to_s
|
|
44
|
+
@order_type = order_type&.to_s
|
|
45
|
+
@runs = runs
|
|
46
|
+
@dependency_examples = dependency_examples
|
|
47
|
+
@dependency_role = dependency_role&.to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def flaky?
|
|
51
|
+
status == "flaky"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def true_failure?
|
|
55
|
+
status == "true_failure"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def labels
|
|
59
|
+
labels = []
|
|
60
|
+
labels << "flaky" if flaky?
|
|
61
|
+
labels << "order-dependent" if order_type == "od"
|
|
62
|
+
labels << "non-order-dependent" if order_type == "nod"
|
|
63
|
+
labels << dependency_role if dependency_role
|
|
64
|
+
labels
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_h
|
|
68
|
+
{
|
|
69
|
+
"example_id" => example_id,
|
|
70
|
+
"status" => status,
|
|
71
|
+
"order_type" => order_type,
|
|
72
|
+
"dependency_role" => dependency_role,
|
|
73
|
+
"labels" => labels,
|
|
74
|
+
"dependency_examples" => dependency_examples,
|
|
75
|
+
"polluter_candidates" => dependency_role == "victim" ? dependency_examples : [],
|
|
76
|
+
"runs" => runs.map(&:to_h)
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
module RuntimeControls
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def activate!
|
|
9
|
+
freeze_time if ENV["RSPEC_FLAKE_TIME_NOW"]
|
|
10
|
+
block_network if ENV["RSPEC_FLAKE_BLOCK_NETWORK"] == "1"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def freeze_time
|
|
14
|
+
timestamp = Integer(ENV.fetch("RSPEC_FLAKE_TIME_NOW"))
|
|
15
|
+
return if Time.singleton_class.method_defined?(:flake_classifier_original_now)
|
|
16
|
+
|
|
17
|
+
Time.singleton_class.class_eval do
|
|
18
|
+
alias_method :flake_classifier_original_now, :now
|
|
19
|
+
|
|
20
|
+
define_method(:now) do
|
|
21
|
+
at(timestamp).getlocal
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
rescue ArgumentError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def block_network
|
|
29
|
+
block_webmock
|
|
30
|
+
require "socket"
|
|
31
|
+
patch_tcp_socket
|
|
32
|
+
rescue LoadError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def block_webmock
|
|
37
|
+
require "webmock"
|
|
38
|
+
return unless defined?(::WebMock) && ::WebMock.respond_to?(:disable_net_connect!)
|
|
39
|
+
|
|
40
|
+
::WebMock.disable_net_connect!
|
|
41
|
+
rescue LoadError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def patch_tcp_socket
|
|
46
|
+
return if TCPSocket.singleton_class.method_defined?(:flake_classifier_original_open)
|
|
47
|
+
|
|
48
|
+
TCPSocket.singleton_class.class_eval do
|
|
49
|
+
alias_method :flake_classifier_original_open, :open
|
|
50
|
+
alias_method :flake_classifier_original_new, :new
|
|
51
|
+
|
|
52
|
+
define_method(:open) do |*|
|
|
53
|
+
raise IOError, "network disabled by rspec-flake-classifier"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
define_method(:new) do |*|
|
|
57
|
+
raise IOError, "network disabled by rspec-flake-classifier"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module FlakeClassifier
|
|
5
|
+
class Sensitivity
|
|
6
|
+
Result = Struct.new(:example_id, :factors, :runs, keyword_init: true) do
|
|
7
|
+
def to_h
|
|
8
|
+
{
|
|
9
|
+
"example_id" => example_id,
|
|
10
|
+
"factors" => factors,
|
|
11
|
+
"runs" => runs.map(&:to_h)
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(runner:)
|
|
17
|
+
@runner = runner
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def analyze(example_id, factors: %i[time randomness network], seed: nil)
|
|
21
|
+
runs = []
|
|
22
|
+
factor_results = {}
|
|
23
|
+
|
|
24
|
+
Array(factors).each do |factor|
|
|
25
|
+
case factor.to_sym
|
|
26
|
+
when :time
|
|
27
|
+
factor_results["time"] = run_time_variants(example_id, seed, runs)
|
|
28
|
+
when :randomness
|
|
29
|
+
factor_results["randomness"] = run_randomness_variants(example_id, seed, runs)
|
|
30
|
+
when :network
|
|
31
|
+
factor_results["network"] = run_network_variant(example_id, seed, runs)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Result.new(example_id: example_id, factors: factor_results, runs: runs)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :runner
|
|
41
|
+
|
|
42
|
+
def run_time_variants(example_id, seed, runs)
|
|
43
|
+
variants = [
|
|
44
|
+
["UTC", "1704067200"],
|
|
45
|
+
["Pacific/Kiritimati", "1704067200"],
|
|
46
|
+
["America/Los_Angeles", "1704153600"]
|
|
47
|
+
]
|
|
48
|
+
statuses = variants.map do |tz, timestamp|
|
|
49
|
+
run = runner.run(example_id, seed: seed, env: { "TZ" => tz, "RSPEC_FLAKE_TIME_NOW" => timestamp })
|
|
50
|
+
runs << run
|
|
51
|
+
["#{tz}@#{timestamp}", run.status]
|
|
52
|
+
end
|
|
53
|
+
factor_result(statuses)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run_randomness_variants(example_id, seed, runs)
|
|
57
|
+
seeds = [seed || 12_345, 54_321].uniq
|
|
58
|
+
statuses = seeds.map do |variant_seed|
|
|
59
|
+
run = runner.run(example_id, seed: variant_seed)
|
|
60
|
+
runs << run
|
|
61
|
+
[variant_seed.to_s, run.status]
|
|
62
|
+
end
|
|
63
|
+
factor_result(statuses)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run_network_variant(example_id, seed, runs)
|
|
67
|
+
allowed = runner.run(example_id, seed: seed)
|
|
68
|
+
blocked = runner.run(example_id, seed: seed, env: { "RSPEC_FLAKE_BLOCK_NETWORK" => "1" })
|
|
69
|
+
runs.concat([allowed, blocked])
|
|
70
|
+
factor_result([["allowed", allowed.status], ["blocked", blocked.status]])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def factor_result(statuses)
|
|
74
|
+
unique_statuses = statuses.map(&:last).uniq
|
|
75
|
+
{
|
|
76
|
+
"sensitive" => unique_statuses.length > 1,
|
|
77
|
+
"observations" => statuses.to_h
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module FlakeClassifier
|
|
7
|
+
class Signature
|
|
8
|
+
PLACEHOLDERS = {
|
|
9
|
+
object_address: "0xADDR",
|
|
10
|
+
tmp_path: "<TMP>",
|
|
11
|
+
line: ":LINE",
|
|
12
|
+
seed: "SEED",
|
|
13
|
+
timestamp: "<TIME>",
|
|
14
|
+
cwd: "<PWD>"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :message, :backtrace, :normalized, :digest
|
|
18
|
+
|
|
19
|
+
def self.from_exception(exception)
|
|
20
|
+
new(
|
|
21
|
+
message: exception.message,
|
|
22
|
+
backtrace: Array(exception.backtrace)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(message:, backtrace: [])
|
|
27
|
+
@message = message.to_s
|
|
28
|
+
@backtrace = Array(backtrace).map(&:to_s)
|
|
29
|
+
@normalized = normalize([@message, *@backtrace].join("\n"))
|
|
30
|
+
@digest = Digest::SHA256.hexdigest(@normalized)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
"digest" => digest,
|
|
36
|
+
"message" => message,
|
|
37
|
+
"normalized" => normalized,
|
|
38
|
+
"backtrace" => backtrace
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def normalize(text)
|
|
45
|
+
normalized = text.dup
|
|
46
|
+
normalized = normalized.gsub(Dir.pwd, PLACEHOLDERS.fetch(:cwd)) if Dir.pwd && !Dir.pwd.empty?
|
|
47
|
+
normalized = normalized.gsub(%r{#<([^>]+):0x[0-9a-f]+>}i, '#<\1:0xADDR>')
|
|
48
|
+
normalized = normalized.gsub(/0x[0-9a-f]+/i, PLACEHOLDERS.fetch(:object_address))
|
|
49
|
+
normalized = normalized.gsub(%r{(?:/private)?/var/folders/[^\s:]+}, PLACEHOLDERS.fetch(:tmp_path))
|
|
50
|
+
normalized = normalized.gsub(%r{(?:/private)?/tmp/[^\s:]+}, PLACEHOLDERS.fetch(:tmp_path))
|
|
51
|
+
normalized = normalized.gsub(%r{:[0-9]+(?::in `[^`]+')?}, PLACEHOLDERS.fetch(:line))
|
|
52
|
+
normalized = normalized.gsub(/\bseed(?:ed)?(?:\s+with)?\s+[0-9]+\b/i, "seed #{PLACEHOLDERS.fetch(:seed)}")
|
|
53
|
+
normalized = normalized.gsub(/\b[0-9]{4}-[0-9]{2}-[0-9]{2}[T\s][0-9:.+-Z]+\b/, PLACEHOLDERS.fetch(:timestamp))
|
|
54
|
+
normalized = normalized.gsub(/\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b/, PLACEHOLDERS.fetch(:timestamp))
|
|
55
|
+
normalized.gsub(/\b[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?\b/, PLACEHOLDERS.fetch(:timestamp))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module FlakeClassifier
|
|
9
|
+
module Store
|
|
10
|
+
class JSONLStore
|
|
11
|
+
FILENAME = "failures.jsonl"
|
|
12
|
+
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
def initialize(path)
|
|
16
|
+
@path = path.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record(signature, classification: nil, metadata: {})
|
|
20
|
+
current = find(signature.digest)
|
|
21
|
+
now = Time.now.utc.iso8601
|
|
22
|
+
entry = build_entry(signature, current, classification, metadata, now)
|
|
23
|
+
|
|
24
|
+
FileUtils.mkdir_p(path)
|
|
25
|
+
File.open(file_path, "a") { |file| file.puts(JSON.generate(entry)) }
|
|
26
|
+
entry
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update_classification(digest, classification:, metadata: {})
|
|
30
|
+
current = find(digest)
|
|
31
|
+
return nil unless current
|
|
32
|
+
|
|
33
|
+
classification_hash = classification_to_hash(classification)
|
|
34
|
+
labels = labels_from(classification_hash)
|
|
35
|
+
labels = Array(current.fetch("labels", [])) if labels.empty?
|
|
36
|
+
entry = current.merge(
|
|
37
|
+
"classification" => classification_hash,
|
|
38
|
+
"labels" => labels,
|
|
39
|
+
"metadata" => current.fetch("metadata", {}).merge(stringify_keys(metadata))
|
|
40
|
+
)
|
|
41
|
+
FileUtils.mkdir_p(path)
|
|
42
|
+
File.open(file_path, "a") { |file| file.puts(JSON.generate(entry)) }
|
|
43
|
+
entry
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def find(digest)
|
|
47
|
+
all[digest]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def known_flake?(digest)
|
|
51
|
+
entry = find(digest)
|
|
52
|
+
return false unless entry
|
|
53
|
+
|
|
54
|
+
labels = Array(entry["labels"]).map(&:to_s)
|
|
55
|
+
entry.dig("classification", "status") == "flaky" || labels.any? { |label| label.to_s.include?("flaky") }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def all
|
|
59
|
+
return {} unless File.file?(file_path)
|
|
60
|
+
|
|
61
|
+
File.foreach(file_path).each_with_object({}) do |line, entries|
|
|
62
|
+
next if line.strip.empty?
|
|
63
|
+
|
|
64
|
+
entry = JSON.parse(line)
|
|
65
|
+
entries[entry.fetch("digest")] = entry
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def entries
|
|
72
|
+
all.values.sort_by { |entry| entry.fetch("last_seen", "") }.reverse
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def file_path
|
|
78
|
+
File.join(path, FILENAME)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_entry(signature, current, classification, metadata, now)
|
|
82
|
+
classification_hash = classification_to_hash(classification)
|
|
83
|
+
labels = labels_from(classification_hash)
|
|
84
|
+
labels = Array(current&.fetch("labels", [])) if labels.empty?
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
"digest" => signature.digest,
|
|
88
|
+
"normalized" => signature.normalized,
|
|
89
|
+
"first_seen" => current&.fetch("first_seen", nil) || now,
|
|
90
|
+
"last_seen" => now,
|
|
91
|
+
"occurrences" => current&.fetch("occurrences", 0).to_i + 1,
|
|
92
|
+
"classification" => classification_hash,
|
|
93
|
+
"labels" => labels,
|
|
94
|
+
"example_ids" => merge_values(current, "example_ids", metadata[:example_id]),
|
|
95
|
+
"commits" => merge_values(current, "commits", metadata[:commit]),
|
|
96
|
+
"metadata" => stringify_keys(metadata)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def classification_to_hash(classification)
|
|
101
|
+
return nil unless classification
|
|
102
|
+
return classification.to_h if classification.respond_to?(:to_h)
|
|
103
|
+
|
|
104
|
+
classification
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def labels_from(classification_hash)
|
|
108
|
+
Array(classification_hash&.fetch("labels", nil)).map do |label|
|
|
109
|
+
if label.respond_to?(:fetch)
|
|
110
|
+
label.fetch("category", label.fetch(:category, label.to_s))
|
|
111
|
+
else
|
|
112
|
+
label.to_s
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def merge_values(current, key, value)
|
|
118
|
+
values = Array(current&.fetch(key, []))
|
|
119
|
+
values << value if value
|
|
120
|
+
values.map(&:to_s).uniq
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def stringify_keys(hash)
|
|
124
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
125
|
+
result[key.to_s] = value
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|