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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +166 -0
  4. data/lib/rspec/hermetic/allowlist.rb +75 -0
  5. data/lib/rspec/hermetic/candidate_report.rb +47 -0
  6. data/lib/rspec/hermetic/change.rb +61 -0
  7. data/lib/rspec/hermetic/configuration.rb +93 -0
  8. data/lib/rspec/hermetic/corpus_evaluation.rb +124 -0
  9. data/lib/rspec/hermetic/diff.rb +63 -0
  10. data/lib/rspec/hermetic/evaluation.rb +184 -0
  11. data/lib/rspec/hermetic/evaluation_task.rb +53 -0
  12. data/lib/rspec/hermetic/forensic.rb +22 -0
  13. data/lib/rspec/hermetic/formatter.rb +93 -0
  14. data/lib/rspec/hermetic/minitest.rb +80 -0
  15. data/lib/rspec/hermetic/probe/base.rb +17 -0
  16. data/lib/rspec/hermetic/probe/constants.rb +87 -0
  17. data/lib/rspec/hermetic/probe/env.rb +15 -0
  18. data/lib/rspec/hermetic/probe/filesystem.rb +109 -0
  19. data/lib/rspec/hermetic/probe/globals.rb +31 -0
  20. data/lib/rspec/hermetic/probe/rails.rb +146 -0
  21. data/lib/rspec/hermetic/probe/randomness.rb +78 -0
  22. data/lib/rspec/hermetic/probe/resources.rb +110 -0
  23. data/lib/rspec/hermetic/probe/ruby_runtime.rb +37 -0
  24. data/lib/rspec/hermetic/probe/time.rb +54 -0
  25. data/lib/rspec/hermetic/probe.rb +38 -0
  26. data/lib/rspec/hermetic/resource_tracker.rb +111 -0
  27. data/lib/rspec/hermetic/restorer.rb +330 -0
  28. data/lib/rspec/hermetic/runner.rb +183 -0
  29. data/lib/rspec/hermetic/snapshot.rb +37 -0
  30. data/lib/rspec/hermetic/stable_value.rb +140 -0
  31. data/lib/rspec/hermetic/verdict.rb +39 -0
  32. data/lib/rspec/hermetic/verify_task.rb +65 -0
  33. data/lib/rspec/hermetic/version.rb +11 -0
  34. data/lib/rspec/hermetic.rb +59 -0
  35. data/sig/rspec/hermetic.rbs +112 -0
  36. 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