pinspec 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 +133 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/exe/pinspec +8 -0
- data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
- data/lib/pinspec/analyzer/factory_registry.rb +288 -0
- data/lib/pinspec/analyzer/inflector.rb +85 -0
- data/lib/pinspec/analyzer/schema_reader.rb +444 -0
- data/lib/pinspec/analyzer/source.rb +56 -0
- data/lib/pinspec/analyzer/target_parser.rb +710 -0
- data/lib/pinspec/cli.rb +585 -0
- data/lib/pinspec/emit/namer.rb +103 -0
- data/lib/pinspec/emit/spec_writer.rb +504 -0
- data/lib/pinspec/emit/stability_filter.rb +183 -0
- data/lib/pinspec/errors.rb +85 -0
- data/lib/pinspec/inputs/boundary.rb +112 -0
- data/lib/pinspec/inputs/corpus.rb +148 -0
- data/lib/pinspec/inputs/hydrator.rb +197 -0
- data/lib/pinspec/inputs/redactor.rb +138 -0
- data/lib/pinspec/inputs/sample_runner.rb +98 -0
- data/lib/pinspec/inputs/sampler.rb +187 -0
- data/lib/pinspec/report/summary.rb +348 -0
- data/lib/pinspec/runner/capture.rb +127 -0
- data/lib/pinspec/runner/probe_generator.rb +662 -0
- data/lib/pinspec/runner/sandbox.rb +121 -0
- data/lib/pinspec/setup/context_builder.rb +471 -0
- data/lib/pinspec/setup/dependency_resolver.rb +236 -0
- data/lib/pinspec/tags.rb +103 -0
- data/lib/pinspec/types.rb +497 -0
- data/lib/pinspec/validate/mutation_adapter.rb +108 -0
- data/lib/pinspec/validate/pin_scorer.rb +164 -0
- data/lib/pinspec/verify/verifier.rb +149 -0
- data/lib/pinspec/version.rb +8 -0
- data/lib/pinspec.rb +17 -0
- data/templates/factory_build.rb +54 -0
- data/templates/serializer.rb +243 -0
- data/templates/spec_support.rb +83 -0
- metadata +134 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Runner
|
|
7
|
+
class ProbeGenerator
|
|
8
|
+
SERIALIZER_TEMPLATE = File.expand_path("../../../templates/serializer.rb", __dir__)
|
|
9
|
+
FACTORY_TEMPLATE = File.expand_path("../../../templates/factory_build.rb", __dir__)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def generate(plan:, corpus:, fk_map:, target:, max_collection: 50)
|
|
13
|
+
new(plan: plan, corpus: corpus, fk_map: fk_map, target: target,
|
|
14
|
+
max_collection: max_collection).generate
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def serializer_source
|
|
18
|
+
File.read(SERIALIZER_TEMPLATE)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def factory_source
|
|
22
|
+
File.read(FACTORY_TEMPLATE)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(plan:, corpus:, fk_map:, target:, max_collection: 50)
|
|
27
|
+
@plan = plan
|
|
28
|
+
@corpus = corpus
|
|
29
|
+
@fk_map = fk_map
|
|
30
|
+
@target = target
|
|
31
|
+
@max_collection = max_collection
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def payload
|
|
35
|
+
{
|
|
36
|
+
"pinspec_probe_version" => PROBE_VERSION,
|
|
37
|
+
"serializer" => SERIALIZER_VERSION,
|
|
38
|
+
"plan_id" => @plan.plan_id,
|
|
39
|
+
"isolation" => @plan.isolation.to_s,
|
|
40
|
+
"env_fingerprint" => stringify(@plan.env_fingerprint),
|
|
41
|
+
"fk_map" => @fk_map,
|
|
42
|
+
"max_collection" => @max_collection,
|
|
43
|
+
"target" => {
|
|
44
|
+
"class" => @target.class_name,
|
|
45
|
+
"method" => @target.method_name.to_s,
|
|
46
|
+
"construction" => @target.construction_kind.to_s,
|
|
47
|
+
"visibility" => @target.visibility.to_s
|
|
48
|
+
},
|
|
49
|
+
"setup_plan" => @plan.steps.map { |step| { "kind" => step.kind.to_s, "payload" => stringify(step.payload) } },
|
|
50
|
+
"cases" => @corpus.cases.map { |input_case| case_payload(input_case) }
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def generate
|
|
55
|
+
<<~RUBY
|
|
56
|
+
# frozen_string_literal: true
|
|
57
|
+
#
|
|
58
|
+
# Generated by pinspec #{VERSION}. Do not edit: regenerate it.
|
|
59
|
+
#
|
|
60
|
+
# Runs inside the target application via `rails runner`. Requires only the
|
|
61
|
+
# standard library beyond what the app already loads, and is held to a Ruby
|
|
62
|
+
# 2.6 syntax floor because it runs in the app's Ruby, not pinspec's.
|
|
63
|
+
#
|
|
64
|
+
# Every case is wrapped in the plan's isolation regime and reversed
|
|
65
|
+
# afterwards. Nothing here writes outside a transaction unless the plan says
|
|
66
|
+
# the suite is untransacted, in which case the tables it touched are
|
|
67
|
+
# truncated instead.
|
|
68
|
+
|
|
69
|
+
require "json"
|
|
70
|
+
|
|
71
|
+
#{indent(self.class.serializer_source, 0)}
|
|
72
|
+
|
|
73
|
+
#{indent(self.class.factory_source, 0)}
|
|
74
|
+
|
|
75
|
+
PINSPEC = JSON.parse(<<'PINSPEC_PAYLOAD_JSON')
|
|
76
|
+
#{JSON.pretty_generate(payload)}
|
|
77
|
+
PINSPEC_PAYLOAD_JSON
|
|
78
|
+
|
|
79
|
+
#{indent(runtime, 0)}
|
|
80
|
+
RUBY
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def case_payload(input_case)
|
|
86
|
+
{
|
|
87
|
+
"id" => input_case.id,
|
|
88
|
+
"origin" => input_case.origin.to_s,
|
|
89
|
+
"ctor_args" => input_case.ctor_args,
|
|
90
|
+
"ctor_kwargs" => input_case.ctor_kwargs,
|
|
91
|
+
"args" => input_case.args,
|
|
92
|
+
"kwargs" => input_case.kwargs
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stringify(value)
|
|
97
|
+
case value
|
|
98
|
+
when Hash then value.each_with_object({}) { |(key, inner), out| out[key.to_s] = stringify(inner) }
|
|
99
|
+
when Array then value.map { |inner| stringify(inner) }
|
|
100
|
+
when Symbol then value.to_s
|
|
101
|
+
else value
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def indent(text, _spaces)
|
|
106
|
+
text.to_s
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def runtime
|
|
110
|
+
[
|
|
111
|
+
runtime_environment,
|
|
112
|
+
runtime_isolation,
|
|
113
|
+
runtime_sinks,
|
|
114
|
+
runtime_sql,
|
|
115
|
+
runtime_steps,
|
|
116
|
+
runtime_invoke,
|
|
117
|
+
runtime_main
|
|
118
|
+
].join("\n")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def runtime_environment
|
|
122
|
+
<<~'RUBY'
|
|
123
|
+
# --------------------------------------------------------------- guards --
|
|
124
|
+
|
|
125
|
+
unless defined?(ActiveRecord::Base)
|
|
126
|
+
abort("pinspec probe: ActiveRecord is not loaded; is this a Rails application?")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
unless (defined?(Rails) && Rails.env.test?) || ENV["PINSPEC_UNSAFE"] == "1"
|
|
130
|
+
abort("pinspec probe: refusing to run outside RAILS_ENV=test (got " +
|
|
131
|
+
(defined?(Rails) ? Rails.env.to_s : "no Rails") + ")")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
require "active_support/testing/time_helpers"
|
|
135
|
+
|
|
136
|
+
module PinspecClock
|
|
137
|
+
extend ActiveSupport::Testing::TimeHelpers
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Forced once, for the whole process: a factory callback that enqueues during
|
|
141
|
+
# setup must land in a test sink rather than reaching a real queue.
|
|
142
|
+
ActiveJob::Base.queue_adapter = :test if defined?(ActiveJob::Base)
|
|
143
|
+
if defined?(ActionMailer::Base)
|
|
144
|
+
ActionMailer::Base.delivery_method = :test
|
|
145
|
+
ActionMailer::Base.perform_deliveries = true
|
|
146
|
+
end
|
|
147
|
+
RUBY
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def runtime_isolation
|
|
151
|
+
<<~'RUBY'
|
|
152
|
+
# ------------------------------------------------------------ isolation --
|
|
153
|
+
|
|
154
|
+
# Raised to end a case early without it looking like a failure.
|
|
155
|
+
class PinspecCaseDone < StandardError; end
|
|
156
|
+
# The regime the PLAN chose, not the one this file would prefer. It has to
|
|
157
|
+
# be the plan's, because the emitted spec will run under the same one:
|
|
158
|
+
#
|
|
159
|
+
# :transaction wrap and roll back. after_commit callbacks never fire,
|
|
160
|
+
# in either host, and the divergence from production is
|
|
161
|
+
# documented rather than faked.
|
|
162
|
+
# :truncation do not wrap. after_commit callbacks DO fire - which is
|
|
163
|
+
# the whole reason this branch exists, because a suite that
|
|
164
|
+
# truncates would fire them in the emitted spec too, and a
|
|
165
|
+
# capture taken inside a transaction would have missed
|
|
166
|
+
# them. Reversed by truncating afterwards instead.
|
|
167
|
+
def pinspec_with_isolation
|
|
168
|
+
if PINSPEC["isolation"] == "truncation"
|
|
169
|
+
begin
|
|
170
|
+
yield
|
|
171
|
+
rescue PinspecCaseDone
|
|
172
|
+
nil
|
|
173
|
+
ensure
|
|
174
|
+
pinspec_truncate!
|
|
175
|
+
end
|
|
176
|
+
else
|
|
177
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
178
|
+
begin
|
|
179
|
+
yield
|
|
180
|
+
rescue PinspecCaseDone
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
raise ActiveRecord::Rollback
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Everything the app owns, minus Rails' own bookkeeping. The safety claim
|
|
190
|
+
# is that a capture is reversible; under truncation that means leaving no
|
|
191
|
+
# rows behind rather than leaving no transaction behind.
|
|
192
|
+
def pinspec_truncate!
|
|
193
|
+
connection = ActiveRecord::Base.connection
|
|
194
|
+
tables = connection.tables - %w[schema_migrations ar_internal_metadata]
|
|
195
|
+
return if tables.empty?
|
|
196
|
+
|
|
197
|
+
if connection.respond_to?(:truncate_tables)
|
|
198
|
+
connection.truncate_tables(*tables)
|
|
199
|
+
else
|
|
200
|
+
tables.each { |table| connection.execute("DELETE FROM " + connection.quote_table_name(table)) }
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
warn("pinspec probe: could not truncate after a case: " + e.class.to_s + ": " + e.message.to_s)
|
|
204
|
+
end
|
|
205
|
+
RUBY
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def runtime_sinks
|
|
209
|
+
<<~'RUBY'
|
|
210
|
+
# ---------------------------------------------------------------- sinks --
|
|
211
|
+
|
|
212
|
+
# Cleared after setup and before the target runs, so setup noise is never
|
|
213
|
+
# attributed to the target.
|
|
214
|
+
def pinspec_clear_sinks
|
|
215
|
+
if defined?(ActiveJob::Base) && ActiveJob::Base.queue_adapter.respond_to?(:enqueued_jobs)
|
|
216
|
+
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
|
|
217
|
+
ActiveJob::Base.queue_adapter.performed_jobs.clear
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
ActionMailer::Base.deliveries.clear if defined?(ActionMailer::Base)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def pinspec_enqueued_jobs
|
|
224
|
+
return [] unless defined?(ActiveJob::Base)
|
|
225
|
+
adapter = ActiveJob::Base.queue_adapter
|
|
226
|
+
return [] unless adapter.respond_to?(:enqueued_jobs)
|
|
227
|
+
|
|
228
|
+
adapter.enqueued_jobs.map do |job|
|
|
229
|
+
{
|
|
230
|
+
"job" => job[:job].to_s,
|
|
231
|
+
"queue" => job[:queue].to_s,
|
|
232
|
+
"args" => PinspecSerializer.encode(job[:args], refs: $pinspec_refs,
|
|
233
|
+
fk_map: PINSPEC["fk_map"],
|
|
234
|
+
max_collection: PINSPEC["max_collection"])
|
|
235
|
+
}
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def pinspec_mail_deliveries
|
|
240
|
+
return [] unless defined?(ActionMailer::Base)
|
|
241
|
+
|
|
242
|
+
ActionMailer::Base.deliveries.map do |mail|
|
|
243
|
+
{ "to" => Array(mail.to).join(","), "subject" => mail.subject.to_s }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
RUBY
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def runtime_sql
|
|
250
|
+
<<~'RUBY'
|
|
251
|
+
# ------------------------------------------------------------------ sql --
|
|
252
|
+
|
|
253
|
+
PINSPEC_IGNORED_TABLES = %w[
|
|
254
|
+
versions ar_internal_metadata schema_migrations
|
|
255
|
+
].freeze
|
|
256
|
+
|
|
257
|
+
# A breach is a statement that escapes the reversal pinspec relies on, and
|
|
258
|
+
# what counts depends on the regime. Under :transaction a COMMIT means the
|
|
259
|
+
# target broke out of the envelope. Under :truncation there IS no envelope
|
|
260
|
+
# - every write commits, and pinspec's own TRUNCATE follows the case - so
|
|
261
|
+
# only DDL and advisory locks are breaches there.
|
|
262
|
+
PINSPEC_BREACH =
|
|
263
|
+
if PINSPEC["isolation"] == "truncation"
|
|
264
|
+
/\A\s*(CREATE|ALTER|DROP)\b|pg_advisory_lock/i
|
|
265
|
+
else
|
|
266
|
+
/\A\s*(COMMIT|CREATE|ALTER|DROP|TRUNCATE)\b|pg_advisory_lock/i
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Rails logs its own column introspection as name "SCHEMA", once per model
|
|
270
|
+
# class per process - so the first case to touch each model would otherwise
|
|
271
|
+
# diverge from every later one and from the second boot. TRANSACTION covers
|
|
272
|
+
# BEGIN/COMMIT/SAVEPOINT, and payload[:cached] covers query-cache hits.
|
|
273
|
+
def pinspec_ignore_sql?(payload)
|
|
274
|
+
return true if %w[SCHEMA TRANSACTION].include?(payload[:name].to_s)
|
|
275
|
+
return true if payload[:cached]
|
|
276
|
+
|
|
277
|
+
sql = payload[:sql].to_s
|
|
278
|
+
return true if sql =~ /\A\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE SAVEPOINT)/i
|
|
279
|
+
return true if PINSPEC_IGNORED_TABLES.any? { |table| sql.include?(table) }
|
|
280
|
+
|
|
281
|
+
false
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def pinspec_fingerprint(sql)
|
|
285
|
+
text = sql.to_s.dup
|
|
286
|
+
text.gsub!(/'[^']*'/, "?")
|
|
287
|
+
text.gsub!(/\b\d+\b/, "?")
|
|
288
|
+
text.gsub!(/\$\d+/, "?")
|
|
289
|
+
text.gsub!(/\s+/, " ")
|
|
290
|
+
text.gsub!(/IN \((?:\?,\s*)*\?\)/i, "IN (?+)")
|
|
291
|
+
text.strip
|
|
292
|
+
end
|
|
293
|
+
RUBY
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def runtime_steps
|
|
297
|
+
<<~'RUBY'
|
|
298
|
+
# ---------------------------------------------------------------- steps --
|
|
299
|
+
|
|
300
|
+
# Serializer-v3 tagged values arrive from cases.json; refs are resolved
|
|
301
|
+
# against the records this plan just built.
|
|
302
|
+
def pinspec_decode(tagged, records)
|
|
303
|
+
return tagged unless tagged.is_a?(Hash) && tagged.key?("t")
|
|
304
|
+
|
|
305
|
+
case tagged["t"]
|
|
306
|
+
when "nil" then nil
|
|
307
|
+
when "bool", "int", "float", "str" then tagged["v"]
|
|
308
|
+
when "sym" then tagged["v"].to_sym
|
|
309
|
+
when "decimal" then BigDecimal(tagged["v"])
|
|
310
|
+
when "date" then Date.parse(tagged["v"])
|
|
311
|
+
when "time" then Time.parse(tagged["v"])
|
|
312
|
+
when "nan" then Float::NAN
|
|
313
|
+
when "inf" then tagged["sign"].to_i < 0 ? -Float::INFINITY : Float::INFINITY
|
|
314
|
+
when "array" then tagged["v"].map { |element| pinspec_decode(element, records) }
|
|
315
|
+
when "hash"
|
|
316
|
+
out = {}
|
|
317
|
+
tagged["v"].each { |pair| out[pinspec_decode(pair[0], records)] = pinspec_decode(pair[1], records) }
|
|
318
|
+
out
|
|
319
|
+
when "ref"
|
|
320
|
+
records.fetch(tagged["v"]) do
|
|
321
|
+
raise "pinspec probe: case references unknown record " + tagged["v"].to_s
|
|
322
|
+
end
|
|
323
|
+
when "bin" then tagged["v"].unpack("m0").first
|
|
324
|
+
else tagged["v"]
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def pinspec_run_steps(records, refs)
|
|
329
|
+
PINSPEC["setup_plan"].each do |step|
|
|
330
|
+
payload = step["payload"]
|
|
331
|
+
|
|
332
|
+
case step["kind"]
|
|
333
|
+
when "freeze_time"
|
|
334
|
+
PinspecClock.travel_to(Time.parse(payload["at"]))
|
|
335
|
+
when "seed_random"
|
|
336
|
+
srand(payload["seed"].to_i)
|
|
337
|
+
when "set_locale"
|
|
338
|
+
I18n.locale = payload["locale"] if defined?(I18n)
|
|
339
|
+
when "set_zone"
|
|
340
|
+
Time.zone = payload["zone"] if Time.respond_to?(:zone=)
|
|
341
|
+
when "set_flag"
|
|
342
|
+
if defined?(Flipper)
|
|
343
|
+
payload["enabled"] ? Flipper.enable(payload["flag"].to_sym) : Flipper.disable(payload["flag"].to_sym)
|
|
344
|
+
end
|
|
345
|
+
when "create_record"
|
|
346
|
+
record = pinspec_create_record(payload, records)
|
|
347
|
+
pinspec_register(record, records, refs, payload["name"])
|
|
348
|
+
when "import_record"
|
|
349
|
+
record = pinspec_import_record(payload, records)
|
|
350
|
+
pinspec_register(record, records, refs, payload["name"]) if record
|
|
351
|
+
when "set_tenant"
|
|
352
|
+
if defined?(ActsAsTenant)
|
|
353
|
+
ActsAsTenant.current_tenant = records[payload["record_ref"]]
|
|
354
|
+
end
|
|
355
|
+
when "stub_current"
|
|
356
|
+
pinspec_stub_current(payload, records)
|
|
357
|
+
when "set_whodunnit"
|
|
358
|
+
if defined?(PaperTrail)
|
|
359
|
+
PaperTrail.request.whodunnit = records[payload["record_ref"]].try(:id).to_s
|
|
360
|
+
end
|
|
361
|
+
when "construct_subject"
|
|
362
|
+
nil # built per case, since its arguments are per case
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# A real application bundles factory_bot with `require: false` and requires it
|
|
368
|
+
# from `rails_helper` - OFN does exactly this - and `rails runner` never
|
|
369
|
+
# reads rails_helper. So the probe loads it, rather than reporting that an
|
|
370
|
+
# app which plainly has 113 factories has none.
|
|
371
|
+
#
|
|
372
|
+
# `factory_bot_rails` is not enough on its own here: its railtie calls
|
|
373
|
+
# find_definitions during initialize!, which has already happened by the
|
|
374
|
+
# time this runs. So the definitions are found explicitly.
|
|
375
|
+
def pinspec_factory_module
|
|
376
|
+
return FactoryBot if defined?(FactoryBot)
|
|
377
|
+
return FactoryGirl if defined?(FactoryGirl)
|
|
378
|
+
|
|
379
|
+
begin
|
|
380
|
+
require "factory_bot"
|
|
381
|
+
rescue LoadError
|
|
382
|
+
begin
|
|
383
|
+
require "factory_girl"
|
|
384
|
+
rescue LoadError
|
|
385
|
+
return nil
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
mod = defined?(FactoryBot) ? FactoryBot : (defined?(FactoryGirl) ? FactoryGirl : nil)
|
|
390
|
+
return nil if mod.nil?
|
|
391
|
+
|
|
392
|
+
# Definitions are loaded once per process. A second call would raise
|
|
393
|
+
# DuplicateDefinitionError and take the whole probe down with it.
|
|
394
|
+
unless $pinspec_factories_found
|
|
395
|
+
$pinspec_factories_found = true
|
|
396
|
+
mod.find_definitions
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
mod
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def pinspec_register(record, records, refs, name)
|
|
403
|
+
records[name] = record
|
|
404
|
+
refs[record.class.table_name.to_s + ":" + record.id.to_s] = name
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def pinspec_create_record(payload, records)
|
|
408
|
+
attrs = {}
|
|
409
|
+
(payload["attrs"] || {}).each { |column, value| attrs[column] = value }
|
|
410
|
+
(payload["assoc_refs"] || {}).each do |column, ref|
|
|
411
|
+
attrs[column] = pinspec_decode(ref, records).id
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
if payload["factory"]
|
|
415
|
+
factory_module = pinspec_factory_module
|
|
416
|
+
raise "pinspec probe: plan wants factory :" + payload["factory"].to_s + " but factory_bot could not be loaded. " \
|
|
417
|
+
"It is bundled with require: false, and this probe runs through rails runner, which does not read rails_helper" if factory_module.nil?
|
|
418
|
+
|
|
419
|
+
return PinspecFactory.create(payload["factory"], attrs.empty? ? {} : symbolize(attrs), factory_module)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
payload["model"].constantize.create!(attrs)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# insert_all skips validations AND callbacks, which is the only honest way
|
|
426
|
+
# to recreate a legacy row: those rows routinely fail today's validations,
|
|
427
|
+
# and a before_save would rewrite the very attributes being imported.
|
|
428
|
+
def pinspec_import_record(payload, records)
|
|
429
|
+
model = payload["model"].constantize
|
|
430
|
+
attrs = {}
|
|
431
|
+
|
|
432
|
+
(payload["attrs"] || {}).each do |column, tagged|
|
|
433
|
+
value = pinspec_decode(tagged, records)
|
|
434
|
+
value = value.id if value.is_a?(ActiveRecord::Base)
|
|
435
|
+
attrs[column] = value
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
result = model.insert_all([attrs], returning: [model.primary_key])
|
|
439
|
+
key = result.rows.first && result.rows.first.first
|
|
440
|
+
return nil if key.nil?
|
|
441
|
+
|
|
442
|
+
found = model.find_by(model.primary_key => key)
|
|
443
|
+
|
|
444
|
+
# Refetch and diff: a fallback path that fires callbacks would silently
|
|
445
|
+
# rewrite what was imported, and that has to be visible.
|
|
446
|
+
(payload["attrs"] || {}).each do |column, tagged|
|
|
447
|
+
wanted = pinspec_decode(tagged, records)
|
|
448
|
+
next if wanted.is_a?(ActiveRecord::Base)
|
|
449
|
+
next if found[column].to_s == wanted.to_s
|
|
450
|
+
|
|
451
|
+
$pinspec_flags << "import_mutated"
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
found
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def pinspec_stub_current(payload, records)
|
|
458
|
+
record = records[payload["record_ref"]]
|
|
459
|
+
return if record.nil?
|
|
460
|
+
|
|
461
|
+
if payload["kind"] == "current_attributes" && defined?(Current)
|
|
462
|
+
Current.user = record if Current.respond_to?(:user=)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def symbolize(hash)
|
|
467
|
+
out = {}
|
|
468
|
+
hash.each { |key, value| out[key.to_sym] = value }
|
|
469
|
+
out
|
|
470
|
+
end
|
|
471
|
+
RUBY
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def runtime_invoke
|
|
475
|
+
<<~'RUBY'
|
|
476
|
+
# --------------------------------------------------------------- invoke --
|
|
477
|
+
|
|
478
|
+
def pinspec_build_subject(input_case, records)
|
|
479
|
+
construction = PINSPEC["target"]["construction"]
|
|
480
|
+
klass = PINSPEC["target"]["class"].constantize
|
|
481
|
+
|
|
482
|
+
return klass if construction == "class_method"
|
|
483
|
+
|
|
484
|
+
args = input_case["ctor_args"].map { |value| pinspec_decode(value, records) }
|
|
485
|
+
kwargs = {}
|
|
486
|
+
input_case["ctor_kwargs"].each { |name, value| kwargs[name.to_sym] = pinspec_decode(value, records) }
|
|
487
|
+
|
|
488
|
+
return records.values.last if construction == "model_instance"
|
|
489
|
+
|
|
490
|
+
pinspec_construct(klass, construction, args, kwargs)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def pinspec_construct(klass, construction, args, kwargs)
|
|
494
|
+
case construction
|
|
495
|
+
when "interactor" then klass
|
|
496
|
+
when "struct", "dry_initializer", "new"
|
|
497
|
+
if kwargs.empty?
|
|
498
|
+
args.empty? ? klass.new : klass.new(*args)
|
|
499
|
+
else
|
|
500
|
+
klass.new(*args, **kwargs)
|
|
501
|
+
end
|
|
502
|
+
else
|
|
503
|
+
klass.new
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# One shim, because keyword forwarding of an EMPTY hash differs before Ruby
|
|
508
|
+
# 2.7 and open-coding it at each call site is how that bites.
|
|
509
|
+
def pinspec_invoke(subject, method_name, args, kwargs)
|
|
510
|
+
if kwargs.empty?
|
|
511
|
+
subject.send(method_name, *args)
|
|
512
|
+
else
|
|
513
|
+
subject.send(method_name, *args, **kwargs)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
RUBY
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def runtime_main
|
|
520
|
+
<<~'RUBY'
|
|
521
|
+
# ----------------------------------------------------------------- main --
|
|
522
|
+
|
|
523
|
+
$pinspec_refs = {}
|
|
524
|
+
$pinspec_flags = []
|
|
525
|
+
|
|
526
|
+
# Under truncation the plan's values are deterministic but the database is
|
|
527
|
+
# not: whatever ran before - the app's own suite, a previous pinspec run,
|
|
528
|
+
# seed data - is still there, and a leftover row collides with the plan on
|
|
529
|
+
# any unique index. Starting clean is not an extra liberty either: a suite
|
|
530
|
+
# in this regime has already declared its test database disposable.
|
|
531
|
+
pinspec_truncate! if PINSPEC["isolation"] == "truncation"
|
|
532
|
+
|
|
533
|
+
observations = []
|
|
534
|
+
order = (0...PINSPEC["cases"].length).to_a
|
|
535
|
+
order = order.shuffle(random: Random.new(ENV.fetch("PINSPEC_SHUFFLE_SEED", "42").to_i))
|
|
536
|
+
|
|
537
|
+
order.each do |index|
|
|
538
|
+
input_case = PINSPEC["cases"][index]
|
|
539
|
+
|
|
540
|
+
records = {}
|
|
541
|
+
refs = {}
|
|
542
|
+
$pinspec_refs = refs
|
|
543
|
+
$pinspec_flags = []
|
|
544
|
+
fingerprints = []
|
|
545
|
+
deltas = { "inserts" => 0, "updates" => 0, "deletes" => 0 }
|
|
546
|
+
started = Time.now.to_f
|
|
547
|
+
|
|
548
|
+
observation = {
|
|
549
|
+
"case_id" => input_case["id"],
|
|
550
|
+
"status" => "returned",
|
|
551
|
+
"return_value" => nil,
|
|
552
|
+
"error" => nil,
|
|
553
|
+
"setup_error" => nil,
|
|
554
|
+
"enqueued_jobs" => [],
|
|
555
|
+
"mail_deliveries" => [],
|
|
556
|
+
"sql_fingerprints" => [],
|
|
557
|
+
"db_delta" => deltas,
|
|
558
|
+
"flags" => [],
|
|
559
|
+
"duration_ms" => 0
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
563
|
+
payload = args.last
|
|
564
|
+
unless pinspec_ignore_sql?(payload)
|
|
565
|
+
sql = payload[:sql].to_s
|
|
566
|
+
$pinspec_flags << "escaped_transaction" if sql =~ PINSPEC_BREACH
|
|
567
|
+
fingerprints << pinspec_fingerprint(sql)
|
|
568
|
+
deltas["inserts"] += 1 if sql =~ /\A\s*INSERT/i
|
|
569
|
+
deltas["updates"] += 1 if sql =~ /\A\s*UPDATE/i
|
|
570
|
+
deltas["deletes"] += 1 if sql =~ /\A\s*DELETE/i
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
begin
|
|
575
|
+
pinspec_with_isolation do
|
|
576
|
+
begin
|
|
577
|
+
pinspec_run_steps(records, refs)
|
|
578
|
+
rescue StandardError => setup_error
|
|
579
|
+
observation["status"] = "setup_error"
|
|
580
|
+
observation["setup_error"] = {
|
|
581
|
+
"error" => { "class" => setup_error.class.name.to_s, "message" => setup_error.message.to_s }
|
|
582
|
+
}
|
|
583
|
+
raise PinspecCaseDone
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# After setup, before the target: setup noise is not the target's.
|
|
587
|
+
pinspec_clear_sinks
|
|
588
|
+
fingerprints.clear
|
|
589
|
+
deltas["inserts"] = 0
|
|
590
|
+
deltas["updates"] = 0
|
|
591
|
+
deltas["deletes"] = 0
|
|
592
|
+
|
|
593
|
+
subject = pinspec_build_subject(input_case, records)
|
|
594
|
+
args = input_case["args"].map { |value| pinspec_decode(value, records) }
|
|
595
|
+
kwargs = {}
|
|
596
|
+
input_case["kwargs"].each { |name, value| kwargs[name.to_sym] = pinspec_decode(value, records) }
|
|
597
|
+
|
|
598
|
+
begin
|
|
599
|
+
value = pinspec_invoke(subject, PINSPEC["target"]["method"], args, kwargs)
|
|
600
|
+
observation["status"] = "returned"
|
|
601
|
+
observation["return_value"] = PinspecSerializer.encode(
|
|
602
|
+
value, refs: refs, fk_map: PINSPEC["fk_map"],
|
|
603
|
+
max_collection: PINSPEC["max_collection"]
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# Registered BEFORE the sinks are read: the commonest side effect
|
|
607
|
+
# is perform_later(the_record_the_target_just_created.id), and
|
|
608
|
+
# that id churns between runs unless it can name something.
|
|
609
|
+
if value.is_a?(ActiveRecord::Base) && !value.id.nil?
|
|
610
|
+
refs[value.class.table_name.to_s + ":" + value.id.to_s] = "__returned__"
|
|
611
|
+
end
|
|
612
|
+
rescue StandardError, ScriptError => target_error
|
|
613
|
+
observation["status"] = "raised"
|
|
614
|
+
observation["error"] = {
|
|
615
|
+
"class" => target_error.class.name.to_s,
|
|
616
|
+
"message" => target_error.message.to_s
|
|
617
|
+
}
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
observation["enqueued_jobs"] = pinspec_enqueued_jobs
|
|
621
|
+
observation["mail_deliveries"] = pinspec_mail_deliveries
|
|
622
|
+
end
|
|
623
|
+
rescue StandardError => outer
|
|
624
|
+
observation["status"] = "setup_error"
|
|
625
|
+
observation["setup_error"] = {
|
|
626
|
+
"error" => { "class" => outer.class.name.to_s, "message" => outer.message.to_s }
|
|
627
|
+
}
|
|
628
|
+
ensure
|
|
629
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
observation["sql_fingerprints"] = fingerprints
|
|
633
|
+
observation["flags"] = $pinspec_flags.uniq
|
|
634
|
+
observation["duration_ms"] = ((Time.now.to_f - started) * 1000).round
|
|
635
|
+
|
|
636
|
+
observations << observation
|
|
637
|
+
|
|
638
|
+
# Between cases: anything memoized in a cache or a CurrentAttributes slot
|
|
639
|
+
# would otherwise leak into the next case.
|
|
640
|
+
Rails.cache.clear if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
|
|
641
|
+
ActiveSupport::CurrentAttributes.reset_all if defined?(ActiveSupport::CurrentAttributes)
|
|
642
|
+
pinspec_clear_sinks
|
|
643
|
+
PinspecClock.travel_back
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
puts JSON.generate(
|
|
647
|
+
"pinspec_probe_version" => PINSPEC["pinspec_probe_version"],
|
|
648
|
+
"serializer" => PINSPEC["serializer"],
|
|
649
|
+
"plan_id" => PINSPEC["plan_id"],
|
|
650
|
+
"env" => {
|
|
651
|
+
"ruby" => RUBY_VERSION,
|
|
652
|
+
"rails" => (defined?(Rails) ? Rails.version : nil),
|
|
653
|
+
"tz" => ENV["TZ"].to_s,
|
|
654
|
+
"shuffle_seed" => ENV.fetch("PINSPEC_SHUFFLE_SEED", "42")
|
|
655
|
+
},
|
|
656
|
+
"observations" => observations.sort_by { |observation| observation["case_id"] }
|
|
657
|
+
)
|
|
658
|
+
RUBY
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|