constable-rails 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/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Constable
|
|
6
|
+
# Executes a selection and turns it into results.
|
|
7
|
+
#
|
|
8
|
+
# Ordering is random every run, because a suite that only passes in one order is a suite
|
|
9
|
+
# that will fail the first time anything is added to it. The seed is always printed and
|
|
10
|
+
# always replayable. Cold cases are exempt -- they keep whatever order their own engine
|
|
11
|
+
# chose, since reordering someone's untouched legacy file is exactly the kind of surprise
|
|
12
|
+
# the cold-case story exists to avoid.
|
|
13
|
+
class Runner
|
|
14
|
+
# One unit of work. Native items are a single investigation; cold items are a whole
|
|
15
|
+
# file, because their engine owns the granularity inside it.
|
|
16
|
+
class Item
|
|
17
|
+
attr_reader :investigation, :path, :kind
|
|
18
|
+
|
|
19
|
+
def initialize(investigation: nil, path: nil, kind: :native)
|
|
20
|
+
@investigation = investigation
|
|
21
|
+
@path = path
|
|
22
|
+
@kind = kind
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def native? = @kind == :native
|
|
26
|
+
def cold? = @kind == :cold
|
|
27
|
+
def identity = native? ? @investigation.identity : Identity.for_cold_case(@path, "file")
|
|
28
|
+
def label = native? ? @investigation.display_label : @path.to_s
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :config, :selection, :reporter, :storage, :seed, :results, :coverage_report
|
|
32
|
+
|
|
33
|
+
def initialize(selection:, config: Constable.config, reporter: nil, storage: nil,
|
|
34
|
+
seed: nil, jail_mode: false, jail_run: false, warrants: nil, coverage: nil,
|
|
35
|
+
workers: nil, verbose: false, io: $stdout)
|
|
36
|
+
@selection = selection
|
|
37
|
+
@config = config
|
|
38
|
+
@storage = storage || Constable.storage
|
|
39
|
+
@seed = (seed || ENV["CONSTABLE_SEED"] || SecureRandom.random_number(10_000)).to_i
|
|
40
|
+
@jail_mode = jail_mode
|
|
41
|
+
@jail_run = jail_run
|
|
42
|
+
@warrants_requested = warrants.nil? ? config.warrants? : warrants
|
|
43
|
+
@coverage_requested = coverage.nil? ? config.coverage? : coverage
|
|
44
|
+
@workers = workers
|
|
45
|
+
@verbose = verbose
|
|
46
|
+
@io = io
|
|
47
|
+
@reporter = reporter || Reporter.new(io: io, config: config)
|
|
48
|
+
@results = []
|
|
49
|
+
@worker_coverage = {}
|
|
50
|
+
@warnings_before = Constable.warnings.size
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def jail_mode? = @jail_mode
|
|
54
|
+
|
|
55
|
+
# `constable jail run` exists precisely to run the bodies the docket normally skips.
|
|
56
|
+
def jail_run? = @jail_run
|
|
57
|
+
def coverage? = @coverage_requested
|
|
58
|
+
|
|
59
|
+
# => Integer exit status (0 clean, 1 failures)
|
|
60
|
+
def call
|
|
61
|
+
# Before anything is loaded: Coverage only counts files required after it starts, so
|
|
62
|
+
# starting it any later measures an empty application and reports a confident 100%.
|
|
63
|
+
# force:, because #coverage? has already folded --coverage together with the config
|
|
64
|
+
# setting, and asking the config again would throw the flag away.
|
|
65
|
+
Constable::Coverage.start!(config: @config, force: true) if coverage?
|
|
66
|
+
|
|
67
|
+
load_suite!
|
|
68
|
+
items = build_items
|
|
69
|
+
ordered = order(items)
|
|
70
|
+
|
|
71
|
+
run_id = @storage.start_run(seed: @seed, mode: mode_label, full: @selection.full?)
|
|
72
|
+
Constable.configuration.run_before_suite!
|
|
73
|
+
|
|
74
|
+
started = monotonic
|
|
75
|
+
# A cold-case file is one work item but an unknown number of tests until its own
|
|
76
|
+
# engine has run it, so claim a total only when every item is a native investigation.
|
|
77
|
+
announced = ordered.all?(&:native?) ? ordered.size : nil
|
|
78
|
+
@reporter.start(total: announced, seed: @seed)
|
|
79
|
+
|
|
80
|
+
# The "alone" half of the order audit has to happen before the suite has touched
|
|
81
|
+
# anything, so it runs here rather than alongside the results it will be compared to.
|
|
82
|
+
order_audit.record_isolated!(ordered.select(&:native?).map(&:investigation))
|
|
83
|
+
|
|
84
|
+
# Warm everything that reads the blotter while we are still single-process.
|
|
85
|
+
docket_snapshot
|
|
86
|
+
duration_index
|
|
87
|
+
known_before
|
|
88
|
+
|
|
89
|
+
raw = order_audit.audit(execute(ordered)) + load_error_results
|
|
90
|
+
|
|
91
|
+
@results = adjudicate(raw)
|
|
92
|
+
duration = monotonic - started
|
|
93
|
+
|
|
94
|
+
Constable.configuration.run_after_suite!
|
|
95
|
+
@coverage_report = build_coverage_report if coverage?
|
|
96
|
+
|
|
97
|
+
persist(run_id, @results, @coverage_report)
|
|
98
|
+
suggestions = rename_suggestions(@results)
|
|
99
|
+
|
|
100
|
+
@reporter.finish(
|
|
101
|
+
results: @results,
|
|
102
|
+
duration: duration,
|
|
103
|
+
seed: @seed,
|
|
104
|
+
coverage: @coverage_report,
|
|
105
|
+
suggestions: suggestions
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
exit_status(@results, @coverage_report)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def mode_label
|
|
114
|
+
return "jail_run" if jail_run?
|
|
115
|
+
return "jail" if jail_mode?
|
|
116
|
+
return "unsafe" if @selection.unsafe_only?
|
|
117
|
+
|
|
118
|
+
@selection.full? ? "full" : "diff"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# test/case_helper.rb is the app's own entry point -- it boots Rails, defines the tier
|
|
122
|
+
# base classes and loads support files. Everything else depends on it having run.
|
|
123
|
+
def load_suite!
|
|
124
|
+
add_suite_dirs_to_load_path!
|
|
125
|
+
|
|
126
|
+
helper = %w[test/case_helper.rb spec/case_helper.rb].map { |p| File.join(Constable.root, p) }
|
|
127
|
+
.find { |p| File.exist?(p) }
|
|
128
|
+
require helper if helper
|
|
129
|
+
|
|
130
|
+
@selection.native_targets.each { |target| load_case_file(target.path) }
|
|
131
|
+
helper
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Case files open with `require "case_helper"`, the way an RSpec file opens with
|
|
135
|
+
# `require "rails_helper"`. That only resolves if the suite directory is on the load
|
|
136
|
+
# path, and nothing else puts it there.
|
|
137
|
+
def add_suite_dirs_to_load_path!
|
|
138
|
+
%w[test spec].each do |dir|
|
|
139
|
+
path = File.join(Constable.root, dir)
|
|
140
|
+
$LOAD_PATH.unshift(path) if File.directory?(path) && !$LOAD_PATH.include?(path)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def load_case_file(path)
|
|
145
|
+
require path
|
|
146
|
+
rescue StandardError, ScriptError => e
|
|
147
|
+
load_errors << [path, e]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def load_errors
|
|
151
|
+
@load_errors ||= []
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A case file that will not load is a failure, not a silence. Reporting it as a result
|
|
155
|
+
# puts it in the FAILURES section with its own error, instead of letting a whole file
|
|
156
|
+
# of tests quietly vanish from the run.
|
|
157
|
+
def load_error_results
|
|
158
|
+
load_errors.map do |path, error|
|
|
159
|
+
relative = path.to_s.delete_prefix("#{Constable.root}/")
|
|
160
|
+
result = Result.new(
|
|
161
|
+
identity: Identity.for_source("load-error:#{relative}"),
|
|
162
|
+
case_name: relative,
|
|
163
|
+
description: "could not be loaded",
|
|
164
|
+
file: relative,
|
|
165
|
+
line: 1,
|
|
166
|
+
kind: :native,
|
|
167
|
+
status: :errored,
|
|
168
|
+
failure: Failure.from_exception(error, context: "This file never ran. Nothing in it was tested.")
|
|
169
|
+
)
|
|
170
|
+
result.seed = @seed
|
|
171
|
+
result
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_items
|
|
176
|
+
native = native_items
|
|
177
|
+
cold = @selection.cold_targets_selected.map { |t| Item.new(path: t.path, kind: :cold) }
|
|
178
|
+
native + cold
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def native_items
|
|
182
|
+
@selection.native_targets.flat_map do |target|
|
|
183
|
+
investigations = Constable.registry.investigations_in(target.path)
|
|
184
|
+
investigations = narrow_to_line(investigations, target.line) if target.line
|
|
185
|
+
investigations.map { |inv| Item.new(investigation: inv) }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# PATH:LINE means "the investigation at that line" -- but developers point at any line
|
|
190
|
+
# inside the block, so pick the investigation whose declaration is nearest above it.
|
|
191
|
+
def narrow_to_line(investigations, line)
|
|
192
|
+
exact = investigations.select { |inv| inv.line == line }
|
|
193
|
+
return exact if exact.any?
|
|
194
|
+
|
|
195
|
+
nearest = investigations.select { |inv| inv.line <= line }.max_by(&:line)
|
|
196
|
+
nearest ? [nearest] : []
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def order(items)
|
|
200
|
+
native, cold = items.partition(&:native?)
|
|
201
|
+
[*native.shuffle(random: Random.new(@seed)), *cold]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def execute(items)
|
|
205
|
+
return [] if items.empty?
|
|
206
|
+
|
|
207
|
+
count = worker_count(items)
|
|
208
|
+
if count > 1 && forkable?
|
|
209
|
+
run_parallel(items, count)
|
|
210
|
+
else
|
|
211
|
+
run_serial(items)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def worker_count(items)
|
|
216
|
+
requested = @workers || @config.parallel_workers
|
|
217
|
+
requested.to_i.clamp(1, items.size)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def forkable?
|
|
221
|
+
Process.respond_to?(:fork) && !@verbose
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def run_serial(items)
|
|
225
|
+
items.flat_map do |item|
|
|
226
|
+
run_item(item).each { |result| @reporter.record(result) }
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Workers never write to the blotter -- they ship results back over a pipe and the
|
|
231
|
+
# parent is the sole writer. That keeps every storage adapter free of cross-process
|
|
232
|
+
# write contention without any locking of its own.
|
|
233
|
+
def run_parallel(items, count)
|
|
234
|
+
buckets = balance(items, count)
|
|
235
|
+
readers = []
|
|
236
|
+
pids = []
|
|
237
|
+
|
|
238
|
+
# Everything that reads the blotter has already been warmed, so the handle can go.
|
|
239
|
+
# A child inheriting a writable SQLite connection is a corruption risk, and the
|
|
240
|
+
# driver rightly complains about it.
|
|
241
|
+
@storage.close
|
|
242
|
+
|
|
243
|
+
buckets.each do |bucket|
|
|
244
|
+
reader, writer = IO.pipe
|
|
245
|
+
# Marshal payloads are binary. Left in text mode, the first byte that isn't valid
|
|
246
|
+
# UTF-8 takes the worker down with an encoding error.
|
|
247
|
+
reader.binmode
|
|
248
|
+
writer.binmode
|
|
249
|
+
pid = fork do
|
|
250
|
+
reader.close
|
|
251
|
+
bucket.each do |item|
|
|
252
|
+
run_item(item).each { |result| write_message(writer, :result, result.to_h) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Ruby's Coverage counts lines in the process that executed them, so a worker's
|
|
256
|
+
# hits would die with it. They ride home on the same pipe as the results.
|
|
257
|
+
write_message(writer, :coverage, Constable::Coverage.peek_raw) if coverage?
|
|
258
|
+
|
|
259
|
+
writer.close
|
|
260
|
+
exit!(0)
|
|
261
|
+
end
|
|
262
|
+
writer.close
|
|
263
|
+
readers << reader
|
|
264
|
+
pids << pid
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
collected = drain(readers)
|
|
268
|
+
pids.each { |pid| Process.waitpid(pid) rescue nil } # rubocop:disable Style/RescueModifier
|
|
269
|
+
|
|
270
|
+
# A warning raised inside a worker only ever reached that worker's memory, so the
|
|
271
|
+
# results carry them home. Nothing that bends the rules is allowed to go missing
|
|
272
|
+
# just because it happened in a subprocess.
|
|
273
|
+
collected.each { |result| Constable.warnings.concat(Array(result.warnings)) }
|
|
274
|
+
collected
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Every message on the pipe is tagged, because results are not the only thing a worker
|
|
278
|
+
# has to send home.
|
|
279
|
+
def write_message(writer, kind, body)
|
|
280
|
+
payload = Marshal.dump([kind, body])
|
|
281
|
+
writer.write([payload.bytesize].pack("N"))
|
|
282
|
+
writer.write(payload)
|
|
283
|
+
writer.flush
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Reads from every worker as results arrive, so the glyph stream stays live rather than
|
|
287
|
+
# arriving in one lump when the slowest worker finishes.
|
|
288
|
+
def drain(readers)
|
|
289
|
+
collected = []
|
|
290
|
+
buffers = Hash.new { |h, k| h[k] = String.new(encoding: Encoding::BINARY) }
|
|
291
|
+
open_readers = readers.dup
|
|
292
|
+
|
|
293
|
+
until open_readers.empty?
|
|
294
|
+
ready, = IO.select(open_readers, nil, nil, 1)
|
|
295
|
+
next unless ready
|
|
296
|
+
|
|
297
|
+
ready.each do |reader|
|
|
298
|
+
chunk = begin
|
|
299
|
+
reader.read_nonblock(65_536)
|
|
300
|
+
rescue IOError # EOFError is one of these -- the worker finished and closed its end.
|
|
301
|
+
nil
|
|
302
|
+
rescue IO::WaitReadable
|
|
303
|
+
next
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
if chunk.nil?
|
|
307
|
+
open_readers.delete(reader)
|
|
308
|
+
reader.close unless reader.closed?
|
|
309
|
+
next
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
buffers[reader] << chunk
|
|
313
|
+
extract(buffers[reader]).each do |kind, body|
|
|
314
|
+
case kind
|
|
315
|
+
when :result
|
|
316
|
+
result = Result.from_h(body)
|
|
317
|
+
collected << result
|
|
318
|
+
@reporter.record(result)
|
|
319
|
+
when :coverage
|
|
320
|
+
@worker_coverage = Constable::Coverage.merge_raw(@worker_coverage, body)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
collected
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def extract(buffer)
|
|
330
|
+
out = []
|
|
331
|
+
loop do
|
|
332
|
+
break if buffer.bytesize < 4
|
|
333
|
+
|
|
334
|
+
size = buffer.byteslice(0, 4).unpack1("N")
|
|
335
|
+
break if buffer.bytesize < 4 + size
|
|
336
|
+
|
|
337
|
+
payload = buffer.byteslice(4, size)
|
|
338
|
+
rest = buffer.byteslice(4 + size, buffer.bytesize - 4 - size)
|
|
339
|
+
buffer.replace(rest || String.new(encoding: Encoding::BINARY))
|
|
340
|
+
out << Marshal.load(payload) # rubocop:disable Security/MarshalLoad -- our own pipe
|
|
341
|
+
end
|
|
342
|
+
out
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Longest-processing-time-first: the slowest tests are handed out before the quick ones,
|
|
346
|
+
# so no worker is left holding a three-second test after everyone else has finished.
|
|
347
|
+
def balance(items, count)
|
|
348
|
+
index = duration_index
|
|
349
|
+
buckets = Array.new(count) { [] }
|
|
350
|
+
loads = Array.new(count, 0.0)
|
|
351
|
+
|
|
352
|
+
items.sort_by { |item| -index.fetch(item.identity, 0.0) }.each do |item|
|
|
353
|
+
slot = loads.index(loads.min)
|
|
354
|
+
buckets[slot] << item
|
|
355
|
+
loads[slot] += index.fetch(item.identity, 0.05)
|
|
356
|
+
end
|
|
357
|
+
buckets.reject(&:empty?)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def duration_index
|
|
361
|
+
@duration_index ||= begin
|
|
362
|
+
@storage.duration_index
|
|
363
|
+
rescue StandardError
|
|
364
|
+
{}
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# --- running one item ------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
def run_item(item)
|
|
371
|
+
item.cold? ? run_cold(item) : [run_native(item)]
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def run_cold(item)
|
|
375
|
+
warnings_before = Constable.warnings.size
|
|
376
|
+
# The seed goes in, not just onto the results: Minitest randomizes its own method
|
|
377
|
+
# order, so passing it is what makes `constable test PATH --seed N` actually replay.
|
|
378
|
+
results = ColdCase.run_file(item.path, config: @config, seed: @seed)
|
|
379
|
+
raised = Constable.warnings[warnings_before..] || []
|
|
380
|
+
|
|
381
|
+
results.each { |r| r.seed = @seed }
|
|
382
|
+
|
|
383
|
+
# A cold case warns once per file, not once per test, so the warning rides home on
|
|
384
|
+
# the first result rather than being repeated on all of them.
|
|
385
|
+
results.first&.warnings&.concat(raised)
|
|
386
|
+
results
|
|
387
|
+
rescue StandardError => e
|
|
388
|
+
[Result.new(
|
|
389
|
+
identity: Identity.for_cold_case(item.path, "load"),
|
|
390
|
+
case_name: File.basename(item.path),
|
|
391
|
+
description: "failed to load",
|
|
392
|
+
file: item.path.to_s.delete_prefix("#{Constable.root}/"),
|
|
393
|
+
line: 0,
|
|
394
|
+
kind: :cold,
|
|
395
|
+
status: :errored,
|
|
396
|
+
failure: Failure.from_exception(e)
|
|
397
|
+
).tap { |r| r.seed = @seed }]
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def run_native(item)
|
|
401
|
+
investigation = item.investigation
|
|
402
|
+
jail_entry = docket_snapshot[investigation.identity]
|
|
403
|
+
|
|
404
|
+
return run_jailed_setup(investigation, jail_entry) if jail_entry&.jailed? && !jail_run?
|
|
405
|
+
|
|
406
|
+
result = execute_investigation(investigation)
|
|
407
|
+
result.seed = @seed
|
|
408
|
+
result
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# A jailed test still runs its briefing and witnesses -- only the investigate body is
|
|
412
|
+
# skipped -- so setup rot surfaces on the next ordinary run rather than lying in wait
|
|
413
|
+
# until someone gets around to `constable jail run`.
|
|
414
|
+
def run_jailed_setup(investigation, entry)
|
|
415
|
+
started = monotonic
|
|
416
|
+
failure = nil
|
|
417
|
+
|
|
418
|
+
instance = Case.constable_instance_for(investigation)
|
|
419
|
+
begin
|
|
420
|
+
# Setup without a body, but with its teardown -- skipping the body is the point of
|
|
421
|
+
# the jail; skipping the cleanup would just leave the request session, the browser
|
|
422
|
+
# or whatever else a teardown releases open for whatever runs next.
|
|
423
|
+
Isolation.with_rollback(investigation.tier) do
|
|
424
|
+
raised = nil
|
|
425
|
+
begin
|
|
426
|
+
instance.run_setup(investigation)
|
|
427
|
+
rescue StandardError => e
|
|
428
|
+
raised = e
|
|
429
|
+
end
|
|
430
|
+
# On its own line, not behind an ||=: teardown has to run whether or not setup
|
|
431
|
+
# got that far, and the first failure is still the one worth reporting.
|
|
432
|
+
teardown_failure = instance.run_teardown
|
|
433
|
+
raised ||= teardown_failure
|
|
434
|
+
raise raised if raised
|
|
435
|
+
end
|
|
436
|
+
rescue StandardError => e
|
|
437
|
+
failure = Failure.from_exception(e, context: "setup for a jailed test still runs, and it failed")
|
|
438
|
+
ensure
|
|
439
|
+
teardown(instance)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
result = Result.from_investigation(
|
|
443
|
+
investigation,
|
|
444
|
+
status: :jailed,
|
|
445
|
+
duration: monotonic - started,
|
|
446
|
+
failure: failure
|
|
447
|
+
)
|
|
448
|
+
result.jail_reason = entry.reason
|
|
449
|
+
result.times_jailed = entry.times_jailed
|
|
450
|
+
result.seed = @seed
|
|
451
|
+
result
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def execute_investigation(investigation)
|
|
455
|
+
started = monotonic
|
|
456
|
+
before = leak_check? ? Isolation.snapshot : nil
|
|
457
|
+
warnings_before = Constable.warnings.size
|
|
458
|
+
failure = nil
|
|
459
|
+
status = :passed
|
|
460
|
+
|
|
461
|
+
instance = Case.constable_instance_for(investigation)
|
|
462
|
+
|
|
463
|
+
begin
|
|
464
|
+
# run_investigation is the whole lifecycle: before_setup, briefings, body,
|
|
465
|
+
# teardowns, after_teardown. The teardown half runs inside the rollback whether or
|
|
466
|
+
# not the body raised, and a teardown that raises never masks the body's failure.
|
|
467
|
+
Isolation.with_rollback(investigation.tier) { instance.run_investigation(investigation) }
|
|
468
|
+
rescue AssertionFailed => e
|
|
469
|
+
status = :failed
|
|
470
|
+
failure = Failure.from_exception(e, context: e.context)
|
|
471
|
+
rescue StandardError => e
|
|
472
|
+
status = :errored
|
|
473
|
+
failure = Failure.from_exception(e)
|
|
474
|
+
ensure
|
|
475
|
+
# Time stays frozen and the network stays blocked only for the life of one
|
|
476
|
+
# investigation. Whatever the outcome, the next one starts from clean ground.
|
|
477
|
+
teardown(instance)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
duration = monotonic - started
|
|
481
|
+
raised = Constable.warnings[warnings_before..] || []
|
|
482
|
+
|
|
483
|
+
if before
|
|
484
|
+
leaks = Isolation.diff(before, Isolation.snapshot)
|
|
485
|
+
if leaks.any?
|
|
486
|
+
Constable.warn!(
|
|
487
|
+
"state leaked out of this investigation: #{leaks.join("; ")}",
|
|
488
|
+
location: investigation.location,
|
|
489
|
+
kind: :leak
|
|
490
|
+
)
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
Result.from_investigation(
|
|
495
|
+
investigation,
|
|
496
|
+
status: status,
|
|
497
|
+
duration: duration,
|
|
498
|
+
failure: failure,
|
|
499
|
+
warnings: Constable.warnings[warnings_before..] || raised
|
|
500
|
+
)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# The leak check walks every user class's class variables, which is worth it per test
|
|
504
|
+
# but not worth it per test in a hot parallel loop on a huge suite.
|
|
505
|
+
def leak_check?
|
|
506
|
+
return @leak_check if defined?(@leak_check)
|
|
507
|
+
|
|
508
|
+
@leak_check = ENV["CONSTABLE_LEAK_CHECK"] != "0"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# --- adjudication ----------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
# Turns raw pass/fail into the verdict the build acts on: warrants decide whether a
|
|
514
|
+
# failure is even real, then jail decides whether it blocks.
|
|
515
|
+
def adjudicate(raw)
|
|
516
|
+
raw.map do |result|
|
|
517
|
+
decided = warrants.adjudicate(
|
|
518
|
+
result,
|
|
519
|
+
requested: @warrants_requested,
|
|
520
|
+
subject: investigation_for(result.identity)
|
|
521
|
+
) { |subject, _attempt| rerun_in_isolation(subject) }
|
|
522
|
+
|
|
523
|
+
jail_run? ? decided : jail.adjudicate(decided, jail_mode: jail_mode?)
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def investigation_for(identity)
|
|
528
|
+
@investigation_index ||= Constable.registry.investigations.to_h { |inv| [inv.identity, inv] }
|
|
529
|
+
@investigation_index[identity]
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# One test, run again from scratch, so a warrant's retries measure the test rather than
|
|
533
|
+
# whatever the rest of the suite left lying around.
|
|
534
|
+
def rerun_in_isolation(subject)
|
|
535
|
+
investigation = subject.is_a?(Investigation) ? subject : investigation_for(subject.to_s)
|
|
536
|
+
return nil unless investigation
|
|
537
|
+
|
|
538
|
+
execute_investigation(investigation)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def jail
|
|
542
|
+
@jail ||= Jail.new(config: @config, storage: @storage)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# The docket, read once in the parent and inherited by every worker through fork.
|
|
546
|
+
# A worker that queried it directly would be reaching into a database handle it does
|
|
547
|
+
# not own -- SQLite is explicit that a connection must not cross a fork -- and the
|
|
548
|
+
# answer cannot change mid-run anyway.
|
|
549
|
+
def docket_snapshot
|
|
550
|
+
@docket_snapshot ||= jail.entries.to_h { |entry| [entry.identity, entry] }
|
|
551
|
+
rescue StandardError
|
|
552
|
+
{}
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def warrants
|
|
556
|
+
@warrants ||= Warrants.new(config: @config, storage: @storage)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def order_audit
|
|
560
|
+
@order_audit ||= OrderAudit.new(config: @config, storage: @storage)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# --- persistence and reporting --------------------------------------------
|
|
564
|
+
|
|
565
|
+
def persist(run_id, results, coverage_report)
|
|
566
|
+
results.each do |result|
|
|
567
|
+
@storage.record_result(run_id, result)
|
|
568
|
+
@storage.record_duration(result.identity, result.duration)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
Constable::Coverage.record!(coverage_report, run_id, storage: @storage) if coverage_report
|
|
572
|
+
|
|
573
|
+
@storage.finish_run(run_id, totals: totals(results))
|
|
574
|
+
rescue StandardError => e
|
|
575
|
+
Constable.warn!("could not write to the blotter: #{e.message}", kind: :storage)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def totals(results)
|
|
579
|
+
{
|
|
580
|
+
total: results.size,
|
|
581
|
+
passed: results.count(&:passed?),
|
|
582
|
+
failed: results.count(&:failed?),
|
|
583
|
+
jailed: results.count(&:jailed?),
|
|
584
|
+
warranted: results.count(&:warranted?)
|
|
585
|
+
}
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# The blotter's identities as they stood before this run wrote anything. Taken up
|
|
589
|
+
# front, because rename detection compares "what we used to know" against "what we
|
|
590
|
+
# just saw", and persisting first would make every test look familiar.
|
|
591
|
+
def known_before
|
|
592
|
+
@known_before ||= Array(@storage.known_identities).map(&:to_s)
|
|
593
|
+
rescue StandardError
|
|
594
|
+
[]
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# A test whose body changed gets a new identity, so an old one vanishing the same run
|
|
598
|
+
# a similar new one appears is usually a rename plus a tweak, not two separate edits.
|
|
599
|
+
# Confirming the relink also carries any jail or warrant entry across, which is what
|
|
600
|
+
# stops a fixed-but-still-jailed test from haunting the docket forever.
|
|
601
|
+
def rename_suggestions(results)
|
|
602
|
+
return [] unless @selection.full?
|
|
603
|
+
|
|
604
|
+
seen = results.map(&:identity)
|
|
605
|
+
vanished = known_before - seen
|
|
606
|
+
fresh = results.reject { |r| known_before.include?(r.identity) }
|
|
607
|
+
return [] if vanished.empty? || fresh.empty?
|
|
608
|
+
|
|
609
|
+
labels = vanished.to_h { |identity| [identity, label_for(identity)] }
|
|
610
|
+
|
|
611
|
+
fresh.filter_map do |result|
|
|
612
|
+
match = vanished.max_by { |old| similarity(labels[old], result.description) }
|
|
613
|
+
next if match.nil?
|
|
614
|
+
|
|
615
|
+
score = similarity(labels[match], result.description)
|
|
616
|
+
next if score < 0.5
|
|
617
|
+
|
|
618
|
+
suggestion = {
|
|
619
|
+
relinked: false, score: score,
|
|
620
|
+
old_identity: match, new_identity: result.identity,
|
|
621
|
+
old_label: labels[match], new_label: result.display_label
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if @config.auto_relink? && score >= 0.85
|
|
625
|
+
@storage.relink(match, result.identity)
|
|
626
|
+
suggestion[:relinked] = true
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
suggestion
|
|
630
|
+
end
|
|
631
|
+
rescue StandardError
|
|
632
|
+
[]
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# The blotter keeps a display label alongside each identity purely so a vanished test
|
|
636
|
+
# can still be named in a suggestion.
|
|
637
|
+
def label_for(identity)
|
|
638
|
+
row = @storage.history_for(identity, limit: 1).first
|
|
639
|
+
return identity unless row
|
|
640
|
+
|
|
641
|
+
[row[:case_name], row[:description]].compact.join(" ").strip
|
|
642
|
+
rescue StandardError
|
|
643
|
+
identity
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# Cheap token overlap -- enough to spot "creates a user" vs "creates a user with valid
|
|
647
|
+
# params" without pulling in a Levenshtein dependency for a hint that a human confirms.
|
|
648
|
+
def similarity(left, right)
|
|
649
|
+
a = left.to_s.downcase.scan(/\w+/)
|
|
650
|
+
b = right.to_s.downcase.scan(/\w+/)
|
|
651
|
+
return 0.0 if a.empty? || b.empty?
|
|
652
|
+
|
|
653
|
+
(a & b).size.to_f / [a.size, b.size].max
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def exit_status(results, coverage_report)
|
|
657
|
+
return 1 if results.any?(&:failed?)
|
|
658
|
+
return 1 if @config.fail_on_warnings? && new_warnings.any?
|
|
659
|
+
return 1 if coverage_report && !coverage_report.meets_threshold?(@config)
|
|
660
|
+
|
|
661
|
+
0
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def new_warnings
|
|
665
|
+
Constable.warnings[@warnings_before..] || []
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Combines what this process saw with everything the workers sent back. Cold cases
|
|
669
|
+
# count here too -- Coverage works at the process level, so it never knew which engine
|
|
670
|
+
# ran the code.
|
|
671
|
+
def build_coverage_report
|
|
672
|
+
merged = Constable::Coverage.merge_raw(Constable::Coverage.peek_raw, @worker_coverage)
|
|
673
|
+
report = Constable::Coverage.build_report(
|
|
674
|
+
merged,
|
|
675
|
+
config: @config,
|
|
676
|
+
root: Constable.root,
|
|
677
|
+
# Cold cases contribute their numbers but are never held to the diff gate, so a
|
|
678
|
+
# run carrying nothing else must not be gated at all.
|
|
679
|
+
gate: !@selection.unsafe_only?
|
|
680
|
+
)
|
|
681
|
+
Constable::Coverage.abort!
|
|
682
|
+
report
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# The last thing to happen to an instance, after its own teardowns and after the
|
|
686
|
+
# transaction is gone: the runtime DSL's global state -- a frozen clock, a barred
|
|
687
|
+
# network, a half-open unsafe block -- belongs to the process, not to the test, so it
|
|
688
|
+
# is released once everything that might still depend on it has finished.
|
|
689
|
+
def teardown(instance)
|
|
690
|
+
instance._constable_dsl_teardown if instance.respond_to?(:_constable_dsl_teardown)
|
|
691
|
+
rescue StandardError => e
|
|
692
|
+
Constable.warn!("teardown after an investigation raised: #{e.message}", kind: :teardown)
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
696
|
+
end
|
|
697
|
+
end
|