rspec-hermetic 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 +166 -0
- data/lib/rspec/hermetic/allowlist.rb +75 -0
- data/lib/rspec/hermetic/candidate_report.rb +47 -0
- data/lib/rspec/hermetic/change.rb +61 -0
- data/lib/rspec/hermetic/configuration.rb +93 -0
- data/lib/rspec/hermetic/corpus_evaluation.rb +124 -0
- data/lib/rspec/hermetic/diff.rb +63 -0
- data/lib/rspec/hermetic/evaluation.rb +184 -0
- data/lib/rspec/hermetic/evaluation_task.rb +53 -0
- data/lib/rspec/hermetic/forensic.rb +22 -0
- data/lib/rspec/hermetic/formatter.rb +93 -0
- data/lib/rspec/hermetic/minitest.rb +80 -0
- data/lib/rspec/hermetic/probe/base.rb +17 -0
- data/lib/rspec/hermetic/probe/constants.rb +87 -0
- data/lib/rspec/hermetic/probe/env.rb +15 -0
- data/lib/rspec/hermetic/probe/filesystem.rb +109 -0
- data/lib/rspec/hermetic/probe/globals.rb +31 -0
- data/lib/rspec/hermetic/probe/rails.rb +146 -0
- data/lib/rspec/hermetic/probe/randomness.rb +78 -0
- data/lib/rspec/hermetic/probe/resources.rb +110 -0
- data/lib/rspec/hermetic/probe/ruby_runtime.rb +37 -0
- data/lib/rspec/hermetic/probe/time.rb +54 -0
- data/lib/rspec/hermetic/probe.rb +38 -0
- data/lib/rspec/hermetic/resource_tracker.rb +111 -0
- data/lib/rspec/hermetic/restorer.rb +330 -0
- data/lib/rspec/hermetic/runner.rb +183 -0
- data/lib/rspec/hermetic/snapshot.rb +37 -0
- data/lib/rspec/hermetic/stable_value.rb +140 -0
- data/lib/rspec/hermetic/verdict.rb +39 -0
- data/lib/rspec/hermetic/verify_task.rb +65 -0
- data/lib/rspec/hermetic/version.rb +11 -0
- data/lib/rspec/hermetic.rb +59 -0
- data/sig/rspec/hermetic.rbs +112 -0
- metadata +117 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../stable_value"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Hermetic
|
|
8
|
+
module Probe
|
|
9
|
+
class Rails < Base
|
|
10
|
+
def capture(_context)
|
|
11
|
+
{}.tap do |values|
|
|
12
|
+
capture_i18n(values)
|
|
13
|
+
capture_time_zone(values)
|
|
14
|
+
capture_rails_config(values)
|
|
15
|
+
capture_rails_cache(values)
|
|
16
|
+
capture_action_mailer(values)
|
|
17
|
+
capture_active_job(values)
|
|
18
|
+
capture_action_controller(values)
|
|
19
|
+
capture_current_attributes(values)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def capture_i18n(values)
|
|
26
|
+
return unless Object.const_defined?(:I18n)
|
|
27
|
+
|
|
28
|
+
values["I18n.locale"] = ::I18n.locale
|
|
29
|
+
values["I18n.default_locale"] = ::I18n.default_locale
|
|
30
|
+
rescue StandardError => error
|
|
31
|
+
values["I18n"] = "#{error.class}: #{error.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def capture_time_zone(values)
|
|
35
|
+
return unless Object.const_defined?(:Time) && ::Time.respond_to?(:zone)
|
|
36
|
+
|
|
37
|
+
values["Time.zone"] = StableValue.capture(::Time.zone)
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
values["Time.zone"] = "#{error.class}: #{error.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def capture_rails_cache(values)
|
|
43
|
+
return unless Object.const_defined?(:Rails) && ::Rails.respond_to?(:cache)
|
|
44
|
+
|
|
45
|
+
cache = ::Rails.cache
|
|
46
|
+
values["Rails.cache.class"] = cache.class.name
|
|
47
|
+
values["Rails.cache.size"] = cache.size if cache.respond_to?(:size)
|
|
48
|
+
values["Rails.cache.keys"] = StableValue.capture(cache.keys.sort) if cache.respond_to?(:keys)
|
|
49
|
+
capture_memory_cache_data(values, cache)
|
|
50
|
+
rescue StandardError => error
|
|
51
|
+
values["Rails.cache"] = "#{error.class}: #{error.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def capture_rails_config(values)
|
|
55
|
+
return unless Object.const_defined?(:Rails) &&
|
|
56
|
+
::Rails.respond_to?(:application) &&
|
|
57
|
+
::Rails.application.respond_to?(:config)
|
|
58
|
+
|
|
59
|
+
config = ::Rails.application.config
|
|
60
|
+
@configuration.rails_config_paths.each do |path|
|
|
61
|
+
values["Rails.application.config.#{path}"] = StableValue.capture(read_path(config, path))
|
|
62
|
+
rescue StandardError => error
|
|
63
|
+
values["Rails.application.config.#{path}"] = "#{error.class}: #{error.message}"
|
|
64
|
+
end
|
|
65
|
+
rescue StandardError => error
|
|
66
|
+
values["Rails.application.config"] = "#{error.class}: #{error.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def capture_action_mailer(values)
|
|
70
|
+
return unless defined?(::ActionMailer::Base) && ::ActionMailer::Base.respond_to?(:deliveries)
|
|
71
|
+
|
|
72
|
+
values["ActionMailer::Base.deliveries.size"] = ::ActionMailer::Base.deliveries.size
|
|
73
|
+
rescue StandardError => error
|
|
74
|
+
values["ActionMailer::Base.deliveries"] = "#{error.class}: #{error.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def capture_active_job(values)
|
|
78
|
+
return unless defined?(::ActiveJob::Base) && ::ActiveJob::Base.respond_to?(:queue_adapter)
|
|
79
|
+
|
|
80
|
+
adapter = ::ActiveJob::Base.queue_adapter
|
|
81
|
+
values["ActiveJob::Base.queue_adapter"] = adapter.class.name
|
|
82
|
+
values["ActiveJob::Base.enqueued_jobs.size"] = adapter.enqueued_jobs.size if adapter.respond_to?(:enqueued_jobs)
|
|
83
|
+
rescue StandardError => error
|
|
84
|
+
values["ActiveJob::Base"] = "#{error.class}: #{error.message}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def capture_action_controller(values)
|
|
88
|
+
return unless defined?(::ActionController::Base)
|
|
89
|
+
|
|
90
|
+
base = ::ActionController::Base
|
|
91
|
+
if base.respond_to?(:allow_forgery_protection)
|
|
92
|
+
values["ActionController::Base.allow_forgery_protection"] = base.allow_forgery_protection
|
|
93
|
+
end
|
|
94
|
+
rescue StandardError => error
|
|
95
|
+
values["ActionController::Base"] = "#{error.class}: #{error.message}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def capture_current_attributes(values)
|
|
99
|
+
return unless defined?(::ActiveSupport::CurrentAttributes)
|
|
100
|
+
|
|
101
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
102
|
+
next unless klass < ::ActiveSupport::CurrentAttributes
|
|
103
|
+
|
|
104
|
+
values["#{klass.name}.attributes"] = StableValue.capture(klass.instance.attributes)
|
|
105
|
+
rescue StandardError => error
|
|
106
|
+
values["#{klass.name}.attributes"] = "#{error.class}: #{error.message}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def capture_memory_cache_data(values, cache)
|
|
111
|
+
return unless cache.instance_variable_defined?(:@data)
|
|
112
|
+
|
|
113
|
+
data = cache.instance_variable_get(:@data)
|
|
114
|
+
return unless data.respond_to?(:keys)
|
|
115
|
+
|
|
116
|
+
values["Rails.cache.memory.keys"] = StableValue.capture(data.keys.map(&:to_s).sort)
|
|
117
|
+
values["Rails.cache.memory.values"] = StableValue.capture(
|
|
118
|
+
data.to_h do |key, entry|
|
|
119
|
+
[key.to_s, cache_entry_value(entry)]
|
|
120
|
+
end
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def cache_entry_value(entry)
|
|
125
|
+
return entry.value if entry.respond_to?(:value)
|
|
126
|
+
|
|
127
|
+
entry
|
|
128
|
+
rescue StandardError
|
|
129
|
+
entry.inspect
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def read_path(object, path)
|
|
133
|
+
path.split(".").reduce(object) do |current, segment|
|
|
134
|
+
if current.respond_to?(segment)
|
|
135
|
+
current.public_send(segment)
|
|
136
|
+
elsif current.respond_to?(:[])
|
|
137
|
+
current[segment.to_sym] || current[segment]
|
|
138
|
+
else
|
|
139
|
+
raise NoMethodError, "undefined config path segment #{segment.inspect}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../stable_value"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Hermetic
|
|
8
|
+
module Probe
|
|
9
|
+
class Randomness < Base
|
|
10
|
+
def capture(_context)
|
|
11
|
+
{}.tap do |values|
|
|
12
|
+
capture_default_random(values)
|
|
13
|
+
capture_srand_seed(values) if @configuration.randomness_seed_probe
|
|
14
|
+
capture_factory_bot_sequences(values)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def capture_default_random(values)
|
|
21
|
+
values["Random.seed"] = ::Random.seed if ::Random.respond_to?(:seed)
|
|
22
|
+
return unless ::Random.const_defined?(:DEFAULT)
|
|
23
|
+
|
|
24
|
+
values["Random::DEFAULT"] = StableValue.capture(::Random.const_get(:DEFAULT))
|
|
25
|
+
rescue StandardError => error
|
|
26
|
+
values["Random::DEFAULT"] = "#{error.class}: #{error.message}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def capture_srand_seed(values)
|
|
30
|
+
previous_seed = Kernel.srand
|
|
31
|
+
Kernel.srand(previous_seed)
|
|
32
|
+
values["Kernel.srand.seed"] = previous_seed
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def capture_factory_bot_sequences(values)
|
|
36
|
+
return unless defined?(::FactoryBot)
|
|
37
|
+
|
|
38
|
+
sequences = factory_bot_sequences
|
|
39
|
+
return unless sequences
|
|
40
|
+
|
|
41
|
+
values["FactoryBot.sequences"] = StableValue.capture(sequences)
|
|
42
|
+
values["FactoryBot.sequence_counters"] = factory_bot_sequence_counters(sequences)
|
|
43
|
+
rescue StandardError => error
|
|
44
|
+
values["FactoryBot.sequences"] = "#{error.class}: #{error.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def factory_bot_sequences
|
|
48
|
+
return ::FactoryBot.sequences if ::FactoryBot.respond_to?(:sequences)
|
|
49
|
+
if defined?(::FactoryBot::Internal) && ::FactoryBot::Internal.respond_to?(:sequences)
|
|
50
|
+
return ::FactoryBot::Internal.sequences
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def factory_bot_sequence_counters(sequences)
|
|
57
|
+
Array(sequences).each_with_object({}) do |sequence, counters|
|
|
58
|
+
name = if sequence.respond_to?(:name)
|
|
59
|
+
sequence.name
|
|
60
|
+
else
|
|
61
|
+
sequence.object_id
|
|
62
|
+
end
|
|
63
|
+
counters[name.to_s] = sequence_counter(sequence)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sequence_counter(sequence)
|
|
68
|
+
return sequence.value if sequence.respond_to?(:value)
|
|
69
|
+
|
|
70
|
+
%i[@value @counter @sequence].each do |ivar|
|
|
71
|
+
return StableValue.capture(sequence.instance_variable_get(ivar)) if sequence.instance_variable_defined?(ivar)
|
|
72
|
+
end
|
|
73
|
+
StableValue.capture(sequence)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../stable_value"
|
|
4
|
+
require_relative "../resource_tracker"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module Hermetic
|
|
9
|
+
module Probe
|
|
10
|
+
class Resources < Base
|
|
11
|
+
def capture(_context)
|
|
12
|
+
{}.tap do |values|
|
|
13
|
+
values["threads"] = thread_snapshot
|
|
14
|
+
values["io"] = io_snapshot
|
|
15
|
+
values["child_processes"] = child_process_snapshot if @configuration.resource_process_probe
|
|
16
|
+
capture_active_record(values)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def thread_snapshot
|
|
23
|
+
Thread.list.filter_map do |thread|
|
|
24
|
+
next if thread == Thread.current
|
|
25
|
+
next unless thread.alive?
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
object_id: thread.object_id,
|
|
29
|
+
status: thread.status,
|
|
30
|
+
name: thread.name,
|
|
31
|
+
backtrace: thread.backtrace&.first,
|
|
32
|
+
origin: ResourceTracker.origin_for(thread)&.first
|
|
33
|
+
}
|
|
34
|
+
end.sort_by { |entry| entry[:object_id] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def io_snapshot
|
|
38
|
+
ObjectSpace.each_object(IO).filter_map do |io|
|
|
39
|
+
next if io.closed?
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
fileno: safe_fileno(io),
|
|
43
|
+
class: io.class.name,
|
|
44
|
+
path: safe_path(io),
|
|
45
|
+
origin: ResourceTracker.origin_for(io)&.first
|
|
46
|
+
}
|
|
47
|
+
end.sort_by { |entry| [entry[:fileno].to_i, entry[:path].to_s] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def safe_fileno(io)
|
|
51
|
+
io.fileno
|
|
52
|
+
rescue IOError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def safe_path(io)
|
|
57
|
+
io.path if io.respond_to?(:path)
|
|
58
|
+
rescue IOError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def capture_active_record(values)
|
|
63
|
+
return unless defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.respond_to?(:connection_pool)
|
|
64
|
+
|
|
65
|
+
pool = ::ActiveRecord::Base.connection_pool
|
|
66
|
+
values["ActiveRecord::Base.connection_pool.stat"] = StableValue.capture(pool.stat) if pool.respond_to?(:stat)
|
|
67
|
+
if pool.respond_to?(:connections)
|
|
68
|
+
values["ActiveRecord::Base.connection_pool.connections.size"] = pool.connections.size
|
|
69
|
+
end
|
|
70
|
+
if ::ActiveRecord::Base.respond_to?(:connection) &&
|
|
71
|
+
::ActiveRecord::Base.connection.respond_to?(:open_transactions)
|
|
72
|
+
values["ActiveRecord::Base.connection.open_transactions"] =
|
|
73
|
+
::ActiveRecord::Base.connection.open_transactions
|
|
74
|
+
end
|
|
75
|
+
rescue StandardError => error
|
|
76
|
+
values["ActiveRecord::Base"] = "#{error.class}: #{error.message}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def child_process_snapshot
|
|
80
|
+
tracked = ResourceTracker.tracked_processes.map do |entry|
|
|
81
|
+
{
|
|
82
|
+
pid: entry.fetch(:pid),
|
|
83
|
+
stat: "tracked",
|
|
84
|
+
command: entry.fetch(:command),
|
|
85
|
+
origin: entry.fetch(:origin)&.first
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
(tracked + ps_child_process_snapshot).uniq { |entry| entry[:pid] }.sort_by { |entry| entry[:pid].to_i }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ps_child_process_snapshot
|
|
93
|
+
IO.popen(%w[ps -o pid= -o ppid= -o stat= -o command=], err: File::NULL, &:read).lines.filter_map do |line|
|
|
94
|
+
pid, ppid, stat, command = line.strip.split(/\s+/, 4)
|
|
95
|
+
next unless ppid.to_i == Process.pid
|
|
96
|
+
next if command&.include?("ps -o pid=")
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
pid: pid.to_i,
|
|
100
|
+
stat: stat,
|
|
101
|
+
command: command
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
rescue StandardError => error
|
|
105
|
+
[]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hermetic
|
|
7
|
+
module Probe
|
|
8
|
+
class RubyRuntime < Base
|
|
9
|
+
WARNING_CATEGORIES = %i[deprecated experimental performance].freeze
|
|
10
|
+
|
|
11
|
+
def capture(_context)
|
|
12
|
+
{
|
|
13
|
+
"$VERBOSE" => $VERBOSE,
|
|
14
|
+
"$DEBUG" => $DEBUG,
|
|
15
|
+
"Encoding.default_external" => Encoding.default_external&.name,
|
|
16
|
+
"Encoding.default_internal" => Encoding.default_internal&.name,
|
|
17
|
+
"Thread.abort_on_exception" => Thread.abort_on_exception,
|
|
18
|
+
"Thread.report_on_exception" => Thread.report_on_exception,
|
|
19
|
+
"GC.stress" => GC.stress
|
|
20
|
+
}.merge(warning_settings)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def warning_settings
|
|
26
|
+
return {} unless defined?(Warning)
|
|
27
|
+
|
|
28
|
+
WARNING_CATEGORIES.each_with_object({}) do |category, values|
|
|
29
|
+
values["Warning[:#{category}]"] = Warning[category]
|
|
30
|
+
rescue ArgumentError
|
|
31
|
+
values["Warning[:#{category}]"] = :unsupported
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hermetic
|
|
7
|
+
module Probe
|
|
8
|
+
class Time < Base
|
|
9
|
+
def capture(context)
|
|
10
|
+
{}.tap do |values|
|
|
11
|
+
capture_clock_offset(values)
|
|
12
|
+
capture_active_support_time_helpers(values, context)
|
|
13
|
+
capture_timecop(values)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def capture_clock_offset(values)
|
|
20
|
+
wall_time = ::Time.now.to_f
|
|
21
|
+
monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
values["clock.wall_monotonic_offset_seconds"] = (wall_time - monotonic).round
|
|
23
|
+
values["Time.now.utc_offset"] = ::Time.now.utc_offset
|
|
24
|
+
rescue StandardError => error
|
|
25
|
+
values["clock.wall_monotonic_offset_seconds"] = "#{error.class}: #{error.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def capture_active_support_time_helpers(values, context)
|
|
29
|
+
return unless context
|
|
30
|
+
|
|
31
|
+
simple_stubs = context.send(:simple_stubs) if context.respond_to?(:send)
|
|
32
|
+
return unless simple_stubs&.respond_to?(:stubbed?)
|
|
33
|
+
|
|
34
|
+
values["active_support.time_helpers.stubbed"] = simple_stubs.stubbed?
|
|
35
|
+
values["active_support.time_helpers.stubbed_object_ids"] =
|
|
36
|
+
simple_stubs.instance_variable_get(:@stubs)&.map { |stub| stub.object_id } if simple_stubs.instance_variable_defined?(:@stubs)
|
|
37
|
+
rescue NameError, NoMethodError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def capture_timecop(values)
|
|
42
|
+
return unless Object.const_defined?(:Timecop)
|
|
43
|
+
|
|
44
|
+
timecop = Object.const_get(:Timecop)
|
|
45
|
+
values["Timecop.frozen?"] = timecop.frozen? if timecop.respond_to?(:frozen?)
|
|
46
|
+
values["Timecop.travelled?"] = timecop.travelled? if timecop.respond_to?(:travelled?)
|
|
47
|
+
values["Timecop.stack"] = timecop.top_stack_item.inspect if timecop.respond_to?(:top_stack_item)
|
|
48
|
+
rescue StandardError => error
|
|
49
|
+
values["Timecop"] = "#{error.class}: #{error.message}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "probe/env"
|
|
4
|
+
require_relative "probe/constants"
|
|
5
|
+
require_relative "probe/globals"
|
|
6
|
+
require_relative "probe/ruby_runtime"
|
|
7
|
+
require_relative "probe/rails"
|
|
8
|
+
require_relative "probe/time"
|
|
9
|
+
require_relative "probe/randomness"
|
|
10
|
+
require_relative "probe/resources"
|
|
11
|
+
require_relative "probe/filesystem"
|
|
12
|
+
|
|
13
|
+
module RSpec
|
|
14
|
+
module Hermetic
|
|
15
|
+
module Probe
|
|
16
|
+
REGISTRY = {
|
|
17
|
+
env: Env,
|
|
18
|
+
constants: Constants,
|
|
19
|
+
globals: Globals,
|
|
20
|
+
ruby_runtime: RubyRuntime,
|
|
21
|
+
rails: Rails,
|
|
22
|
+
time: Time,
|
|
23
|
+
randomness: Randomness,
|
|
24
|
+
resources: Resources,
|
|
25
|
+
filesystem: Filesystem
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def build(name, configuration)
|
|
31
|
+
probe_class = REGISTRY.fetch(name.to_sym) do
|
|
32
|
+
raise Error, "unknown hermetic probe: #{name.inspect}"
|
|
33
|
+
end
|
|
34
|
+
probe_class.new(configuration)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hermetic
|
|
5
|
+
module ResourceTracker
|
|
6
|
+
ORIGIN_IVAR = :@rspec_hermetic_origin
|
|
7
|
+
|
|
8
|
+
module ThreadSingletonMethods
|
|
9
|
+
def new(*args, **kwargs, &block)
|
|
10
|
+
super.tap { |thread| RSpec::Hermetic::ResourceTracker.annotate(thread) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def start(*args, **kwargs, &block)
|
|
14
|
+
super.tap { |thread| RSpec::Hermetic::ResourceTracker.annotate(thread) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module IOSingletonMethods
|
|
19
|
+
def open(*args, **kwargs)
|
|
20
|
+
if block_given?
|
|
21
|
+
super(*args, **kwargs) do |io|
|
|
22
|
+
RSpec::Hermetic::ResourceTracker.annotate(io)
|
|
23
|
+
yield io
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
super.tap { |io| RSpec::Hermetic::ResourceTracker.annotate(io) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module ProcessSingletonMethods
|
|
32
|
+
def spawn(*args, **kwargs)
|
|
33
|
+
super.tap do |pid|
|
|
34
|
+
RSpec::Hermetic::ResourceTracker.annotate_process(pid, args)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def install!
|
|
42
|
+
return if @installed
|
|
43
|
+
|
|
44
|
+
Thread.singleton_class.prepend(ThreadSingletonMethods)
|
|
45
|
+
IO.singleton_class.prepend(IOSingletonMethods)
|
|
46
|
+
File.singleton_class.prepend(IOSingletonMethods)
|
|
47
|
+
Process.singleton_class.prepend(ProcessSingletonMethods)
|
|
48
|
+
@installed = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def start!
|
|
52
|
+
install!
|
|
53
|
+
@active = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stop!
|
|
57
|
+
@active = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def annotate(resource)
|
|
61
|
+
return resource unless @active
|
|
62
|
+
return resource if resource.instance_variable_defined?(ORIGIN_IVAR)
|
|
63
|
+
|
|
64
|
+
resource.instance_variable_set(ORIGIN_IVAR, caller_locations(2, 8).map(&:to_s))
|
|
65
|
+
resource
|
|
66
|
+
rescue StandardError
|
|
67
|
+
resource
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def annotate_process(pid, command)
|
|
71
|
+
return pid unless @active
|
|
72
|
+
|
|
73
|
+
process_origins[pid] = {
|
|
74
|
+
pid: pid,
|
|
75
|
+
command: command.flatten.map(&:to_s).join(" "),
|
|
76
|
+
origin: caller_locations(2, 8).map(&:to_s)
|
|
77
|
+
}
|
|
78
|
+
pid
|
|
79
|
+
rescue StandardError
|
|
80
|
+
pid
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def origin_for(resource)
|
|
84
|
+
resource.instance_variable_get(ORIGIN_IVAR) if resource.instance_variable_defined?(ORIGIN_IVAR)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def tracked_processes
|
|
90
|
+
process_origins.values.filter_map do |entry|
|
|
91
|
+
next unless process_alive?(entry.fetch(:pid))
|
|
92
|
+
|
|
93
|
+
entry
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def process_origins
|
|
98
|
+
@process_origins ||= {}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def process_alive?(pid)
|
|
102
|
+
Process.kill(0, pid)
|
|
103
|
+
true
|
|
104
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
105
|
+
false
|
|
106
|
+
rescue Errno::EPERM
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|