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,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
require_relative "change"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module Hermetic
|
|
9
|
+
class Restorer
|
|
10
|
+
def initialize(configuration)
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def restore(change_set, before_snapshot, context: nil)
|
|
15
|
+
restore_env(before_snapshot) if reset?(:env)
|
|
16
|
+
restore_globals(before_snapshot) if reset?(:globals)
|
|
17
|
+
restore_ruby_runtime(before_snapshot) if reset?(:ruby_runtime)
|
|
18
|
+
restore_rails(before_snapshot) if reset?(:rails)
|
|
19
|
+
restore_time(context) if reset?(:time)
|
|
20
|
+
restore_randomness(before_snapshot) if reset?(:randomness)
|
|
21
|
+
restore_constants(change_set) if reset?(:constants)
|
|
22
|
+
restore_resources(change_set) if reset?(:resources)
|
|
23
|
+
restore_filesystem(change_set) if reset?(:filesystem)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def reset?(probe)
|
|
29
|
+
@configuration.auto_reset_probe?(probe)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def restore_env(before_snapshot)
|
|
33
|
+
before = before_snapshot.data.fetch(:env, {})
|
|
34
|
+
ENV.replace(before)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def restore_globals(before_snapshot)
|
|
38
|
+
before = before_snapshot.data.fetch(:globals, {})
|
|
39
|
+
$LOAD_PATH.replace(before["$LOAD_PATH"]) if before["$LOAD_PATH"].is_a?(Array)
|
|
40
|
+
$LOADED_FEATURES.replace(before["$LOADED_FEATURES"]) if before["$LOADED_FEATURES"].is_a?(Array)
|
|
41
|
+
$PROGRAM_NAME = before["$PROGRAM_NAME"] if before.key?("$PROGRAM_NAME")
|
|
42
|
+
$VERBOSE = before["$VERBOSE"] if before.key?("$VERBOSE")
|
|
43
|
+
$DEBUG = before["$DEBUG"] if before.key?("$DEBUG")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def restore_ruby_runtime(before_snapshot)
|
|
47
|
+
before = before_snapshot.data.fetch(:ruby_runtime, {})
|
|
48
|
+
$VERBOSE = before["$VERBOSE"] if before.key?("$VERBOSE")
|
|
49
|
+
$DEBUG = before["$DEBUG"] if before.key?("$DEBUG")
|
|
50
|
+
GC.stress = before["GC.stress"] if before.key?("GC.stress")
|
|
51
|
+
Thread.abort_on_exception = before["Thread.abort_on_exception"] if before.key?("Thread.abort_on_exception")
|
|
52
|
+
Thread.report_on_exception = before["Thread.report_on_exception"] if before.key?("Thread.report_on_exception")
|
|
53
|
+
restore_warnings(before)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def restore_warnings(before)
|
|
57
|
+
before.each do |key, value|
|
|
58
|
+
next unless key.start_with?("Warning[:")
|
|
59
|
+
next unless defined?(Warning)
|
|
60
|
+
|
|
61
|
+
category = key[/Warning\[:(.+)\]/, 1]&.to_sym
|
|
62
|
+
Warning[category] = value unless category.nil? || value == :unsupported
|
|
63
|
+
rescue ArgumentError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def restore_rails(before_snapshot)
|
|
69
|
+
before = before_snapshot.data.fetch(:rails, {})
|
|
70
|
+
restore_i18n(before)
|
|
71
|
+
restore_time_zone(before)
|
|
72
|
+
restore_rails_config(before)
|
|
73
|
+
restore_action_mailer(before)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def restore_i18n(before)
|
|
77
|
+
return unless Object.const_defined?(:I18n)
|
|
78
|
+
|
|
79
|
+
::I18n.locale = before["I18n.locale"] if before.key?("I18n.locale") && ::I18n.respond_to?(:locale=)
|
|
80
|
+
if before.key?("I18n.default_locale") && ::I18n.respond_to?(:default_locale=)
|
|
81
|
+
::I18n.default_locale = before["I18n.default_locale"]
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def restore_time_zone(before)
|
|
88
|
+
return unless Object.const_defined?(:Time) && ::Time.respond_to?(:zone=)
|
|
89
|
+
|
|
90
|
+
::Time.zone = before["Time.zone"] if before.key?("Time.zone")
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def restore_rails_config(before)
|
|
96
|
+
return unless Object.const_defined?(:Rails) &&
|
|
97
|
+
::Rails.respond_to?(:application) &&
|
|
98
|
+
::Rails.application.respond_to?(:config)
|
|
99
|
+
|
|
100
|
+
before.each do |key, value|
|
|
101
|
+
next unless key.start_with?("Rails.application.config.")
|
|
102
|
+
|
|
103
|
+
path = key.delete_prefix("Rails.application.config.")
|
|
104
|
+
write_path(::Rails.application.config, path, value)
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def restore_action_mailer(before)
|
|
111
|
+
return unless defined?(::ActionMailer::Base) && ::ActionMailer::Base.respond_to?(:deliveries)
|
|
112
|
+
return unless before.key?("ActionMailer::Base.deliveries.size")
|
|
113
|
+
|
|
114
|
+
::ActionMailer::Base.deliveries.slice!(before["ActionMailer::Base.deliveries.size"]..)
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def restore_time(context)
|
|
120
|
+
context.travel_back if context.respond_to?(:travel_back)
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def restore_randomness(before_snapshot)
|
|
126
|
+
before = before_snapshot.data.fetch(:randomness, {})
|
|
127
|
+
Kernel.srand(before["Kernel.srand.seed"]) if before.key?("Kernel.srand.seed")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def restore_constants(change_set)
|
|
131
|
+
change_set.changes.select { |change| change.probe == :constants }.each do |change|
|
|
132
|
+
if change.addition?
|
|
133
|
+
remove_constant(change.key)
|
|
134
|
+
elsif restorable_value?(change.before)
|
|
135
|
+
set_constant(change.key, deep_dup(change.before))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def restore_resources(change_set)
|
|
141
|
+
change_set.changes.select { |change| change.probe == :resources }.each do |change|
|
|
142
|
+
restore_threads(change) if change.key == "threads"
|
|
143
|
+
restore_io(change) if change.key == "io"
|
|
144
|
+
restore_child_processes(change) if change.key == "child_processes"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def restore_threads(change)
|
|
149
|
+
before_ids = Array(change.before).map { |entry| entry[:object_id] || entry["object_id"] }
|
|
150
|
+
after_ids = Array(change.after).map { |entry| entry[:object_id] || entry["object_id"] }
|
|
151
|
+
leaked_ids = after_ids - before_ids
|
|
152
|
+
Thread.list.each do |thread|
|
|
153
|
+
next unless leaked_ids.include?(thread.object_id)
|
|
154
|
+
next if thread == Thread.current
|
|
155
|
+
|
|
156
|
+
thread.kill
|
|
157
|
+
thread.join(0.1)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def restore_io(change)
|
|
162
|
+
before_fds = Array(change.before).map { |entry| entry[:fileno] || entry["fileno"] }
|
|
163
|
+
after_fds = Array(change.after).map { |entry| entry[:fileno] || entry["fileno"] }
|
|
164
|
+
leaked_fds = after_fds - before_fds
|
|
165
|
+
ObjectSpace.each_object(IO) do |io|
|
|
166
|
+
next if io.closed?
|
|
167
|
+
next unless leaked_fds.include?(io.fileno)
|
|
168
|
+
|
|
169
|
+
io.close
|
|
170
|
+
rescue IOError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def restore_filesystem(change_set)
|
|
176
|
+
changes = change_set.changes.select { |change| change.probe == :filesystem }
|
|
177
|
+
remove_added_paths(changes.select(&:addition?))
|
|
178
|
+
restore_removed_or_modified_paths(changes.reject(&:addition?))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def restore_child_processes(change)
|
|
182
|
+
before_pids = Array(change.before).map { |entry| entry[:pid] || entry["pid"] }
|
|
183
|
+
after_pids = Array(change.after).map { |entry| entry[:pid] || entry["pid"] }
|
|
184
|
+
leaked_pids = after_pids - before_pids
|
|
185
|
+
leaked_pids.each { |pid| terminate_child_process(pid) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def terminate_child_process(pid)
|
|
189
|
+
Process.kill("TERM", pid)
|
|
190
|
+
10.times do
|
|
191
|
+
return if Process.waitpid(pid, Process::WNOHANG)
|
|
192
|
+
|
|
193
|
+
sleep 0.02
|
|
194
|
+
rescue Errno::ECHILD
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
Process.kill("KILL", pid)
|
|
198
|
+
Process.waitpid(pid)
|
|
199
|
+
rescue Errno::ESRCH, Errno::ECHILD, Errno::EPERM
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def remove_added_paths(changes)
|
|
204
|
+
changes.sort_by { |change| -change.key.to_s.count(File::SEPARATOR) }.each do |change|
|
|
205
|
+
path = safe_filesystem_path(change.key)
|
|
206
|
+
FileUtils.rm_rf(path) if path
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def restore_removed_or_modified_paths(changes)
|
|
211
|
+
changes.sort_by { |change| change.key.to_s.count(File::SEPARATOR) }.each do |change|
|
|
212
|
+
path = safe_filesystem_path(change.key)
|
|
213
|
+
next unless path
|
|
214
|
+
|
|
215
|
+
if change.removal? || change.mutation?
|
|
216
|
+
restore_filesystem_entry(path, change.before)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def restore_filesystem_entry(path, entry)
|
|
222
|
+
return if entry.equal?(Change::MISSING)
|
|
223
|
+
|
|
224
|
+
case entry_value(entry, :type)
|
|
225
|
+
when :directory, "directory"
|
|
226
|
+
FileUtils.mkdir_p(path)
|
|
227
|
+
when :file, "file"
|
|
228
|
+
restore_file(path, entry)
|
|
229
|
+
when :symlink, "symlink"
|
|
230
|
+
restore_symlink(path, entry)
|
|
231
|
+
end
|
|
232
|
+
restore_mtime(path, entry)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def restore_file(path, entry)
|
|
236
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
237
|
+
content = entry_value(entry, :content)
|
|
238
|
+
return unless content
|
|
239
|
+
|
|
240
|
+
File.binwrite(path, content)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def restore_symlink(path, entry)
|
|
244
|
+
target = entry_value(entry, :target)
|
|
245
|
+
return unless target
|
|
246
|
+
|
|
247
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
248
|
+
FileUtils.rm_f(path)
|
|
249
|
+
File.symlink(target, path)
|
|
250
|
+
rescue NotImplementedError
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def restore_mtime(path, entry)
|
|
255
|
+
return unless File.exist?(path) || File.symlink?(path)
|
|
256
|
+
|
|
257
|
+
mtime = entry_value(entry, :mtime)
|
|
258
|
+
return unless mtime
|
|
259
|
+
|
|
260
|
+
File.utime(mtime, mtime, path)
|
|
261
|
+
rescue StandardError
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def safe_filesystem_path(key)
|
|
266
|
+
root = File.expand_path(@configuration.root_path)
|
|
267
|
+
path = File.expand_path(key, root)
|
|
268
|
+
return unless path == root || path.start_with?("#{root}#{File::SEPARATOR}")
|
|
269
|
+
|
|
270
|
+
path
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def entry_value(entry, key)
|
|
274
|
+
entry[key] || entry[key.to_s]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def remove_constant(name)
|
|
278
|
+
parent, const_name = constant_parent(name)
|
|
279
|
+
parent.__send__(:remove_const, const_name) if parent.const_defined?(const_name, false)
|
|
280
|
+
rescue StandardError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def set_constant(name, value)
|
|
285
|
+
parent, const_name = constant_parent(name)
|
|
286
|
+
parent.__send__(:remove_const, const_name) if parent.const_defined?(const_name, false)
|
|
287
|
+
parent.const_set(const_name, value)
|
|
288
|
+
rescue StandardError
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def constant_parent(name)
|
|
293
|
+
parts = name.to_s.split("::")
|
|
294
|
+
const_name = parts.pop
|
|
295
|
+
parent = parts.reject(&:empty?).reduce(Object) { |mod, part| mod.const_get(part, false) }
|
|
296
|
+
[parent, const_name.to_sym]
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def restorable_value?(value)
|
|
300
|
+
value.equal?(Change::MISSING) || value.nil? || value == true || value == false ||
|
|
301
|
+
value.is_a?(Symbol) || value.is_a?(Numeric) || value.is_a?(String) ||
|
|
302
|
+
value.is_a?(Array) || value.is_a?(Hash)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def deep_dup(value)
|
|
306
|
+
Marshal.load(Marshal.dump(value))
|
|
307
|
+
rescue StandardError
|
|
308
|
+
value
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def write_path(object, path, value)
|
|
312
|
+
segments = path.split(".")
|
|
313
|
+
target = segments[0...-1].reduce(object) { |current, segment| read_segment(current, segment) }
|
|
314
|
+
last = segments.last
|
|
315
|
+
if target.respond_to?(:"#{last}=")
|
|
316
|
+
target.public_send(:"#{last}=", value)
|
|
317
|
+
elsif target.respond_to?(:[]=)
|
|
318
|
+
target[last.to_sym] = value
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def read_segment(object, segment)
|
|
323
|
+
return object.public_send(segment) if object.respond_to?(segment)
|
|
324
|
+
return object[segment.to_sym] if object.respond_to?(:[])
|
|
325
|
+
|
|
326
|
+
raise NoMethodError, "undefined segment #{segment.inspect}"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "diff"
|
|
4
|
+
require_relative "candidate_report"
|
|
5
|
+
require_relative "formatter"
|
|
6
|
+
require_relative "probe"
|
|
7
|
+
require_relative "resource_tracker"
|
|
8
|
+
require_relative "restorer"
|
|
9
|
+
require_relative "snapshot"
|
|
10
|
+
require_relative "verdict"
|
|
11
|
+
|
|
12
|
+
module RSpec
|
|
13
|
+
module Hermetic
|
|
14
|
+
class Runner
|
|
15
|
+
attr_reader :records
|
|
16
|
+
|
|
17
|
+
def initialize(configuration)
|
|
18
|
+
@configuration = configuration
|
|
19
|
+
@records = []
|
|
20
|
+
@suite_baseline = nil
|
|
21
|
+
@last_writers = {}
|
|
22
|
+
@example_count = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(example, context)
|
|
26
|
+
@example_count += 1
|
|
27
|
+
probes = build_probes
|
|
28
|
+
before = Snapshot.capture(probes, context: context)
|
|
29
|
+
@suite_baseline ||= before
|
|
30
|
+
|
|
31
|
+
ResourceTracker.start! if @configuration.track_resource_origins
|
|
32
|
+
example.run
|
|
33
|
+
ensure
|
|
34
|
+
if before
|
|
35
|
+
after = Snapshot.capture(probes, context: context)
|
|
36
|
+
ResourceTracker.stop! if @configuration.track_resource_origins
|
|
37
|
+
change_set = Diff.between(before, after)
|
|
38
|
+
result = Verdict.new(@configuration).call(
|
|
39
|
+
change_set,
|
|
40
|
+
example_allowances: example_allowances(example)
|
|
41
|
+
)
|
|
42
|
+
record(example, change_set, result)
|
|
43
|
+
report_pollution(example, change_set, result)
|
|
44
|
+
report_probe_errors(example, change_set, result)
|
|
45
|
+
report_forensic_victim(example, before) if @configuration.forensic && failed?(example)
|
|
46
|
+
remember_writers(example, change_set)
|
|
47
|
+
restore_state(change_set, before, context)
|
|
48
|
+
fail_example!(example, result, change_set) if should_fail_for_pollution?(example, result)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build_probes
|
|
55
|
+
Array(@configuration.probes).filter_map do |name|
|
|
56
|
+
next unless sampled?(name)
|
|
57
|
+
|
|
58
|
+
Probe.build(name, @configuration)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def sampled?(name)
|
|
63
|
+
return true if @example_count == 1
|
|
64
|
+
|
|
65
|
+
(@example_count % @configuration.probe_interval(name)).zero?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def example_allowances(example)
|
|
69
|
+
metadata = example.respond_to?(:metadata) ? example.metadata : {}
|
|
70
|
+
hermetic = metadata[:hermetic]
|
|
71
|
+
hermetic.is_a?(Hash) ? hermetic[:allow] : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def record(example, change_set, result)
|
|
75
|
+
return unless result.reportable? || change_set.after_errors.any?
|
|
76
|
+
|
|
77
|
+
@records << {
|
|
78
|
+
example: example,
|
|
79
|
+
changes: change_set,
|
|
80
|
+
verdict: result
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def report_pollution(example, change_set, result)
|
|
85
|
+
return unless result.reportable?
|
|
86
|
+
return if @configuration.on_pollution == :fail
|
|
87
|
+
|
|
88
|
+
message = Formatter.pollution_message(example, result, change_set)
|
|
89
|
+
reporter_message(example, message)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def report_probe_errors(example, change_set, result)
|
|
93
|
+
return unless @configuration.report_probe_errors
|
|
94
|
+
return if result.reportable?
|
|
95
|
+
return unless change_set.errors?
|
|
96
|
+
|
|
97
|
+
reporter_message(example, Formatter.probe_error_message(example, change_set))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def report_forensic_victim(example, before_snapshot)
|
|
101
|
+
dirty = Diff.between(@suite_baseline, before_snapshot).changes
|
|
102
|
+
return if dirty.empty?
|
|
103
|
+
|
|
104
|
+
reporter_message(example, Formatter.forensic_message(example, dirty, @last_writers))
|
|
105
|
+
write_candidates(example, dirty)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def remember_writers(example, change_set)
|
|
109
|
+
change_set.changes.each do |change|
|
|
110
|
+
@last_writers[[change.probe, change.key]] = example_label(example)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fail_example!(example, result, change_set)
|
|
115
|
+
message = Formatter.pollution_message(example, result, change_set)
|
|
116
|
+
raise Error, message
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def should_fail_for_pollution?(example, result)
|
|
120
|
+
result.polluted? && @configuration.on_pollution == :fail && !failed?(example)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def restore_state(change_set, before_snapshot, context)
|
|
124
|
+
return unless @configuration.auto_reset
|
|
125
|
+
|
|
126
|
+
Restorer.new(@configuration).restore(change_set, before_snapshot, context: context)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def write_candidates(example, changes)
|
|
130
|
+
return unless @configuration.candidate_report_path
|
|
131
|
+
|
|
132
|
+
report = CandidateReport.new(File.join(@configuration.root_path, @configuration.candidate_report_path))
|
|
133
|
+
changes.each do |change|
|
|
134
|
+
polluter = @last_writers[[change.probe, change.key]]
|
|
135
|
+
next unless polluter
|
|
136
|
+
|
|
137
|
+
report.append(
|
|
138
|
+
"polluter" => polluter,
|
|
139
|
+
"victim" => example_label(example),
|
|
140
|
+
"probe" => change.probe,
|
|
141
|
+
"key" => change.key,
|
|
142
|
+
"before" => safe_json(change.before),
|
|
143
|
+
"after" => safe_json(change.after)
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
rescue StandardError => error
|
|
147
|
+
reporter_message(example, "HERMETIC CANDIDATE REPORT ERROR #{error.class}: #{error.message}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def reporter_message(example, message)
|
|
151
|
+
reporter = if example.respond_to?(:reporter)
|
|
152
|
+
example.reporter
|
|
153
|
+
elsif defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
|
|
154
|
+
::RSpec.configuration.reporter
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if reporter&.respond_to?(:message)
|
|
158
|
+
reporter.message(message)
|
|
159
|
+
else
|
|
160
|
+
warn(message)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def failed?(example)
|
|
165
|
+
example.respond_to?(:exception) && !example.exception.nil?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def example_label(example)
|
|
169
|
+
return example.location if example.respond_to?(:location) && example.location
|
|
170
|
+
|
|
171
|
+
example.respond_to?(:full_description) ? example.full_description : example.object_id.to_s
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def safe_json(value)
|
|
175
|
+
return nil if value.equal?(Change::MISSING)
|
|
176
|
+
|
|
177
|
+
JSON.parse(JSON.generate(value))
|
|
178
|
+
rescue StandardError
|
|
179
|
+
value.inspect
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hermetic
|
|
5
|
+
class Snapshot
|
|
6
|
+
attr_reader :data, :errors, :timings
|
|
7
|
+
|
|
8
|
+
def initialize(data:, errors: {}, timings: {})
|
|
9
|
+
@data = data
|
|
10
|
+
@errors = errors
|
|
11
|
+
@timings = timings
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.capture(probes, context: nil)
|
|
15
|
+
data = {}
|
|
16
|
+
errors = {}
|
|
17
|
+
timings = {}
|
|
18
|
+
|
|
19
|
+
probes.each do |probe|
|
|
20
|
+
started_at = monotonic_now
|
|
21
|
+
data[probe.name] = probe.capture(context)
|
|
22
|
+
rescue StandardError => error
|
|
23
|
+
errors[probe.name] = "#{error.class}: #{error.message}"
|
|
24
|
+
data[probe.name] = {}
|
|
25
|
+
ensure
|
|
26
|
+
timings[probe.name] = monotonic_now - started_at if started_at
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
new(data: data, errors: errors, timings: timings)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.monotonic_now
|
|
33
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require "objspace"
|
|
7
|
+
rescue LoadError
|
|
8
|
+
nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module RSpec
|
|
12
|
+
module Hermetic
|
|
13
|
+
module StableValue
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
MAX_DEPTH = 3
|
|
17
|
+
|
|
18
|
+
def capture(value, depth: MAX_DEPTH, seen: {})
|
|
19
|
+
case value
|
|
20
|
+
when nil, true, false, Symbol, Numeric
|
|
21
|
+
value
|
|
22
|
+
when String
|
|
23
|
+
value.dup.freeze
|
|
24
|
+
when Array
|
|
25
|
+
return fingerprint(value) if depth <= 0
|
|
26
|
+
|
|
27
|
+
value.map { |item| capture(item, depth: depth - 1, seen: seen) }.freeze
|
|
28
|
+
when Hash
|
|
29
|
+
return fingerprint(value) if depth <= 0
|
|
30
|
+
|
|
31
|
+
value.keys.sort_by(&:to_s).to_h do |key|
|
|
32
|
+
[
|
|
33
|
+
capture(key, depth: depth - 1, seen: seen),
|
|
34
|
+
capture(value[key], depth: depth - 1, seen: seen)
|
|
35
|
+
]
|
|
36
|
+
end.freeze
|
|
37
|
+
else
|
|
38
|
+
object_fingerprint(value, depth, seen)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fingerprint(value)
|
|
43
|
+
dumped = Marshal.dump(value)
|
|
44
|
+
"#{value.class}:marshal:#{Digest::SHA256.hexdigest(dumped)}"
|
|
45
|
+
rescue StandardError
|
|
46
|
+
normalized = value.inspect.gsub(/0x[0-9a-f]+/i, "0x...")
|
|
47
|
+
"#{value.class}:inspect:#{Digest::SHA256.hexdigest(normalized)}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reachable_graph_fingerprint(value, max_depth:, max_nodes:)
|
|
51
|
+
return capture(value) unless ObjectSpace.respond_to?(:reachable_objects_from)
|
|
52
|
+
|
|
53
|
+
seen = {}
|
|
54
|
+
queue = [[value, 0]]
|
|
55
|
+
nodes = []
|
|
56
|
+
|
|
57
|
+
until queue.empty? || nodes.length >= max_nodes
|
|
58
|
+
object, depth = queue.shift
|
|
59
|
+
next if seen[object.object_id]
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
seen[object.object_id] = true
|
|
63
|
+
nodes << graph_node(object, depth)
|
|
64
|
+
next if depth >= max_depth
|
|
65
|
+
|
|
66
|
+
ObjectSpace.reachable_objects_from(object).each do |child|
|
|
67
|
+
next if immediate?(child)
|
|
68
|
+
|
|
69
|
+
queue << [child, depth + 1]
|
|
70
|
+
end
|
|
71
|
+
rescue StandardError
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
graph: Digest::SHA256.hexdigest(Marshal.dump(nodes.sort_by(&:to_s))),
|
|
78
|
+
nodes: nodes.length,
|
|
79
|
+
truncated: !queue.empty?
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def object_fingerprint(value, depth, seen)
|
|
84
|
+
return fingerprint(value) if depth <= 0
|
|
85
|
+
return "#{value.class}:cycle:#{seen[value.object_id]}" if seen.key?(value.object_id)
|
|
86
|
+
|
|
87
|
+
seen[value.object_id] = seen.length
|
|
88
|
+
dumped = Marshal.dump(value)
|
|
89
|
+
"#{value.class}:marshal:#{Digest::SHA256.hexdigest(dumped)}"
|
|
90
|
+
rescue StandardError
|
|
91
|
+
payload = {
|
|
92
|
+
class: value.class.name,
|
|
93
|
+
inspect: value.inspect.gsub(/0x[0-9a-f]+/i, "0x..."),
|
|
94
|
+
ivars: instance_variables_for(value, depth, seen),
|
|
95
|
+
class_vars: class_variables_for(value, depth, seen)
|
|
96
|
+
}
|
|
97
|
+
"#{value.class}:graph:#{Digest::SHA256.hexdigest(Marshal.dump(payload))}"
|
|
98
|
+
ensure
|
|
99
|
+
seen.delete(value.object_id)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def instance_variables_for(value, depth, seen)
|
|
103
|
+
value.instance_variables.sort_by(&:to_s).to_h do |ivar|
|
|
104
|
+
[ivar.to_s, capture(value.instance_variable_get(ivar), depth: depth - 1, seen: seen)]
|
|
105
|
+
rescue StandardError => error
|
|
106
|
+
[ivar.to_s, "#{error.class}: #{error.message}"]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def class_variables_for(value, depth, seen)
|
|
111
|
+
return {} unless value.is_a?(Module)
|
|
112
|
+
|
|
113
|
+
value.class_variables.sort_by(&:to_s).to_h do |ivar|
|
|
114
|
+
[ivar.to_s, capture(value.class_variable_get(ivar), depth: depth - 1, seen: seen)]
|
|
115
|
+
rescue StandardError => error
|
|
116
|
+
[ivar.to_s, "#{error.class}: #{error.message}"]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def graph_node(object, depth)
|
|
121
|
+
{
|
|
122
|
+
class: object.class.name,
|
|
123
|
+
depth: depth,
|
|
124
|
+
value: immediate?(object) ? object : capture(object, depth: 1)
|
|
125
|
+
}
|
|
126
|
+
rescue StandardError => error
|
|
127
|
+
{
|
|
128
|
+
class: object.class.name,
|
|
129
|
+
depth: depth,
|
|
130
|
+
error: "#{error.class}: #{error.message}"
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def immediate?(object)
|
|
135
|
+
object.nil? || object == true || object == false ||
|
|
136
|
+
object.is_a?(Symbol) || object.is_a?(Numeric)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|