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,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
# Warrants -- automatic flaky detection.
|
|
5
|
+
#
|
|
6
|
+
# Jail asks "does this block the build?". A warrant asks the question underneath it:
|
|
7
|
+
# *is this failure even real?* The two are deliberately separate mechanisms, and a test
|
|
8
|
+
# can be subject to both.
|
|
9
|
+
#
|
|
10
|
+
# Opt in with `warrants: true` in config, or `constable test --warrants` for one run.
|
|
11
|
+
# Once a warrant exists it stands on its own: a warranted test gets the full retry
|
|
12
|
+
# treatment on **every** future run whether or not the flag is passed, because the
|
|
13
|
+
# blotter entry carries the rule, not the command line.
|
|
14
|
+
#
|
|
15
|
+
# Issuing one:
|
|
16
|
+
#
|
|
17
|
+
# A test fails its normal attempt while warrants are active. Constable reruns that one
|
|
18
|
+
# test, in isolation, `warrant_retries` more times (default 5).
|
|
19
|
+
# * fails every retry -> a genuine failure. No warrant. Handled like any other
|
|
20
|
+
# failure, jail included if `--jail` is also on.
|
|
21
|
+
# * passes at least once -> flaky, not broken. A warrant goes on the blotter, the
|
|
22
|
+
# result stops blocking the build, and it gets its own section in the summary.
|
|
23
|
+
#
|
|
24
|
+
# Living under one:
|
|
25
|
+
#
|
|
26
|
+
# * all retries pass -> warrant cleared, entry removed, reported as cleared.
|
|
27
|
+
# * any retry fails -> warrant stands, "still under warrant", still non-blocking.
|
|
28
|
+
#
|
|
29
|
+
# The blotter is the only record. Nothing here ever writes to a source file, and there
|
|
30
|
+
# is deliberately no companion command that annotates specs -- same reasoning as jail:
|
|
31
|
+
# one source of truth, nothing to drift out of sync. `constable warrants release
|
|
32
|
+
# PATH:LINE` is the manual clear.
|
|
33
|
+
class Warrants
|
|
34
|
+
# What a run's adjudication concluded about one test.
|
|
35
|
+
#
|
|
36
|
+
# :issued a fresh warrant -- failed, then passed under retry
|
|
37
|
+
# :genuine no warrant -- failed every retry, this is a real failure
|
|
38
|
+
# :upheld standing warrant, still flaky
|
|
39
|
+
# :cleared standing warrant, clean sweep -- lifted
|
|
40
|
+
# :none the machinery did not apply
|
|
41
|
+
VERDICTS = %i[none issued genuine upheld cleared].freeze
|
|
42
|
+
|
|
43
|
+
# One row of the warrants table, normalized. Tolerant of string keys for the same
|
|
44
|
+
# reason Jail::Entry is: the storage layer is pluggable.
|
|
45
|
+
class Entry
|
|
46
|
+
attr_reader :identity, :label, :file, :line, :reason, :issued_at, :last_seen_at,
|
|
47
|
+
:times_seen, :clean_runs, :failed_runs, :row
|
|
48
|
+
|
|
49
|
+
def self.wrap(row)
|
|
50
|
+
return nil if row.nil?
|
|
51
|
+
return row if row.is_a?(Entry)
|
|
52
|
+
return nil unless row.respond_to?(:to_h)
|
|
53
|
+
|
|
54
|
+
hash = row.to_h
|
|
55
|
+
hash.empty? ? nil : new(hash)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(row)
|
|
59
|
+
@row = row.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
|
|
60
|
+
@identity = @row[:identity].to_s
|
|
61
|
+
@label = @row[:label]
|
|
62
|
+
@file = @row[:file].to_s
|
|
63
|
+
@line = @row[:line]&.to_i
|
|
64
|
+
@reason = @row[:reason]
|
|
65
|
+
@issued_at = @row[:issued_at]
|
|
66
|
+
@last_seen_at = @row[:last_seen_at]
|
|
67
|
+
@times_seen = (@row[:times_seen] || 0).to_i
|
|
68
|
+
@clean_runs = (@row[:clean_runs] || 0).to_i
|
|
69
|
+
@failed_runs = (@row[:failed_runs] || 0).to_i
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A cleared entry is handed back by storage as it was at the moment the row went
|
|
73
|
+
# away -- the only chance the reporter gets to name its final counters.
|
|
74
|
+
def cleared? = @row[:cleared] == true || @row[:state].to_s == "cleared"
|
|
75
|
+
|
|
76
|
+
def location = "#{@file}:#{@line}"
|
|
77
|
+
def to_h = @row.dup
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
attr_reader :config, :storage, :issued, :cleared, :upheld, :genuine, :verdicts
|
|
81
|
+
|
|
82
|
+
def initialize(config: Constable.config, storage: Constable.storage)
|
|
83
|
+
@config = config
|
|
84
|
+
@storage = storage
|
|
85
|
+
@issued = []
|
|
86
|
+
@cleared = []
|
|
87
|
+
@upheld = []
|
|
88
|
+
@genuine = []
|
|
89
|
+
@verdicts = {}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# How many isolated reruns one adjudication costs. Zero disables the mechanism
|
|
93
|
+
# outright -- there is nothing to learn from retrying a test no times.
|
|
94
|
+
def warrant_retries
|
|
95
|
+
retries = @config.respond_to?(:warrant_retries) ? @config.warrant_retries.to_i : 0
|
|
96
|
+
retries.negative? ? 0 : retries
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The flag, not the blotter.
|
|
100
|
+
def enabled? = @config.respond_to?(:warrants?) ? @config.warrants? : false
|
|
101
|
+
|
|
102
|
+
def active?(requested: false) = requested || enabled?
|
|
103
|
+
|
|
104
|
+
# The blotter, not the flag. A standing warrant outranks the flag in both directions:
|
|
105
|
+
# it applies without `--warrants`, and no flag is needed to clear it.
|
|
106
|
+
def standing?(identity) = !entry(identity).nil?
|
|
107
|
+
alias warranted? standing?
|
|
108
|
+
|
|
109
|
+
# "Is this identity subject to the warrant machinery this run?" -- either because
|
|
110
|
+
# somebody asked for warrants, or because this test already has one.
|
|
111
|
+
def applies_to?(identity, requested: false)
|
|
112
|
+
return false if warrant_retries.zero?
|
|
113
|
+
|
|
114
|
+
standing?(identity) || active?(requested: requested)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# "Should I actually spend retries on this result?" A standing warrant is retried
|
|
118
|
+
# however the normal attempt went; an ordinary test only earns retries by failing.
|
|
119
|
+
def retry?(result, requested: false)
|
|
120
|
+
return false if warrant_retries.zero?
|
|
121
|
+
return false if result.nil? || result.jailed? || result.skipped?
|
|
122
|
+
return true if standing?(result.identity)
|
|
123
|
+
|
|
124
|
+
active?(requested: requested) && result.failed?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# --- queries ---------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def entries = Array(@storage.warrants).filter_map { |row| Entry.wrap(row) }
|
|
130
|
+
def entry(identity) = Entry.wrap(@storage.warrant_entry(identity.to_s))
|
|
131
|
+
def any? = !entries.empty?
|
|
132
|
+
|
|
133
|
+
# A warranted result never fails the build. That is the whole point of establishing
|
|
134
|
+
# that the failure was not real.
|
|
135
|
+
def blocks_build?(result) = result.failed?
|
|
136
|
+
|
|
137
|
+
# --- adjudication ----------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
# The Runner's single entry point. Hands back the same Result, decided.
|
|
140
|
+
#
|
|
141
|
+
# warrants.adjudicate(result, requested: cli_flag, subject: investigation) do |subject, attempt|
|
|
142
|
+
# runner.run_one_in_isolation(subject) # => a Constable::Result
|
|
143
|
+
# end
|
|
144
|
+
#
|
|
145
|
+
# The block reruns exactly one test in isolation and returns its outcome -- a Result,
|
|
146
|
+
# a status Symbol, or a boolean. It is called `warrant_retries` times, always the full
|
|
147
|
+
# count: a partial sample is a worse answer than a slower one, and the retry statuses
|
|
148
|
+
# end up on `Result#retries` for the reporter.
|
|
149
|
+
#
|
|
150
|
+
# Order matters at the call site: adjudicate first, then hand the result to Jail. A
|
|
151
|
+
# warranted result is not a failure, so it must never reach the docket; a genuine one
|
|
152
|
+
# should, jail mode included.
|
|
153
|
+
def adjudicate(result, requested: false, subject: nil, &rerun)
|
|
154
|
+
decide(result, requested: requested, subject: subject, &rerun)
|
|
155
|
+
persist(result)
|
|
156
|
+
result
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The half that runs tests and decides, with no writes -- safe inside a fork worker,
|
|
160
|
+
# where the architecture's "only the parent writes" rule applies. The parent calls
|
|
161
|
+
# #persist on the Result that comes back over the pipe.
|
|
162
|
+
def decide(result, requested: false, subject: nil, &rerun)
|
|
163
|
+
return result unless retry?(result, requested: requested)
|
|
164
|
+
raise ArgumentError, "Warrants#decide needs a block that reruns one test in isolation" unless rerun
|
|
165
|
+
|
|
166
|
+
statuses = run_retries(result, subject, rerun)
|
|
167
|
+
result.retries = statuses
|
|
168
|
+
result.status = decided_status(result, statuses)
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The half that writes. Derives the verdict from the blotter plus the retry statuses
|
|
173
|
+
# already on the Result, so it works equally on a locally decided result and on one
|
|
174
|
+
# that arrived from a worker as a hash.
|
|
175
|
+
def persist(result, verdict = verdict_for(result))
|
|
176
|
+
@verdicts[result.identity] = verdict unless verdict == :none
|
|
177
|
+
|
|
178
|
+
case verdict
|
|
179
|
+
when :issued
|
|
180
|
+
@storage.issue_warrant(result.identity, label: result.display_label, file: result.file,
|
|
181
|
+
line: result.line, reason: issue_reason(result))
|
|
182
|
+
@issued << result
|
|
183
|
+
when :upheld
|
|
184
|
+
@storage.touch_warrant(result.identity, cleared: false)
|
|
185
|
+
@upheld << result
|
|
186
|
+
when :cleared
|
|
187
|
+
# touch_warrant(cleared: true) lifts the warrant and hands back the final row.
|
|
188
|
+
# clear_warrant stays the manual path; calling both would just be noise.
|
|
189
|
+
@storage.touch_warrant(result.identity, cleared: true)
|
|
190
|
+
@cleared << result
|
|
191
|
+
when :genuine
|
|
192
|
+
@genuine << result
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
verdict
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Fully derivable from (does a warrant stand?, how did the retries go?), which is why
|
|
199
|
+
# a worker never has to ship a verdict back alongside the result.
|
|
200
|
+
def verdict_for(result)
|
|
201
|
+
statuses = Array(result&.retries).map { |status| normalize_status(status) }
|
|
202
|
+
return :none if statuses.empty?
|
|
203
|
+
|
|
204
|
+
if standing?(result.identity)
|
|
205
|
+
statuses.all?(:passed) ? :cleared : :upheld
|
|
206
|
+
else
|
|
207
|
+
statuses.any?(:passed) ? :issued : :genuine
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# --- human operations ------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
# `constable warrants release PATH:LINE`.
|
|
214
|
+
def release(identity) = @storage.clear_warrant(identity.to_s) ? true : false
|
|
215
|
+
|
|
216
|
+
# Counts for the summary line and its own section.
|
|
217
|
+
def summary_counts
|
|
218
|
+
{ issued: @issued.size, cleared: @cleared.size, upheld: @upheld.size, standing: entries.size }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# --- PATH:LINE resolution ---------------------------------------------------
|
|
222
|
+
|
|
223
|
+
# The CLI speaks in file:line, the blotter is keyed by content hash. Warrant rows
|
|
224
|
+
# carry both; loaded investigations are the fallback.
|
|
225
|
+
def resolve(target)
|
|
226
|
+
text = target.to_s.strip
|
|
227
|
+
return nil if text.empty?
|
|
228
|
+
return text if text.match?(/\A[0-9a-f]{8,64}\z/) && entry(text)
|
|
229
|
+
|
|
230
|
+
file, line = Jail.split_target(text)
|
|
231
|
+
return nil if file.empty?
|
|
232
|
+
|
|
233
|
+
matches = entries.select { |e| Jail.same_path?(e.file, file) }
|
|
234
|
+
matches = matches.select { |e| e.line == line } if line
|
|
235
|
+
return matches.first.identity if matches.any?
|
|
236
|
+
|
|
237
|
+
Jail.registry_identity(file, line)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def resolve!(target)
|
|
241
|
+
resolve(target) || raise(Constable::Error, "no test found for #{target.inspect} " \
|
|
242
|
+
"(expected PATH:LINE, e.g. test/cases/users_case.rb:12)")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
def run_retries(result, subject, rerun)
|
|
248
|
+
Array.new(warrant_retries) do |index|
|
|
249
|
+
normalize_status(call_rerun(rerun, subject || result, index + 1))
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Blocks are lenient about extra arguments; lambdas are not, and somebody will pass one.
|
|
254
|
+
def call_rerun(rerun, subject, attempt)
|
|
255
|
+
return rerun.call(subject, attempt) unless rerun.lambda?
|
|
256
|
+
|
|
257
|
+
case rerun.arity
|
|
258
|
+
when 0 then rerun.call
|
|
259
|
+
when 1 then rerun.call(subject)
|
|
260
|
+
else rerun.call(subject, attempt)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def decided_status(result, statuses)
|
|
265
|
+
if standing?(result.identity)
|
|
266
|
+
# A standing warrant is judged on the retries alone -- a clean sweep lifts it,
|
|
267
|
+
# anything else keeps it standing and non-blocking.
|
|
268
|
+
statuses.all?(:passed) ? :passed : :warranted
|
|
269
|
+
elsif statuses.any?(:passed)
|
|
270
|
+
:warranted
|
|
271
|
+
else
|
|
272
|
+
result.status # a genuine failure keeps whatever kind of failure it was
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def normalize_status(value)
|
|
277
|
+
return value.passed? ? :passed : :failed if value.respond_to?(:passed?)
|
|
278
|
+
return :passed if value == true
|
|
279
|
+
return :failed if value == false || value.nil?
|
|
280
|
+
|
|
281
|
+
value.to_s == "passed" ? :passed : :failed
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def issue_reason(result)
|
|
285
|
+
statuses = Array(result.retries).map { |status| normalize_status(status) }
|
|
286
|
+
passes = statuses.count(:passed)
|
|
287
|
+
"failed its normal attempt, then passed #{passes} of #{statuses.size} isolated retries"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The gem is published as "constable-rails" because the name "constable" was claimed on
|
|
4
|
+
# RubyGems in 2011 by an unrelated, long-abandoned gem.
|
|
5
|
+
#
|
|
6
|
+
# Bundler.require requires each gem by its *gem* name, so an app writing the ordinary
|
|
7
|
+
#
|
|
8
|
+
# gem "constable-rails"
|
|
9
|
+
#
|
|
10
|
+
# gets `require "constable-rails"` and nothing else. Without this file that raises
|
|
11
|
+
# LoadError, and Bundler's only fallback is "constable/rails", which does not exist
|
|
12
|
+
# either -- so the gem would never be loaded into the app at all. Everything would still
|
|
13
|
+
# appear to work, because the `constable` executable requires "constable" itself, right
|
|
14
|
+
# up until the moment something needed the Railtie: `rails generate scaffold` would
|
|
15
|
+
# quietly keep emitting Minitest files, for the framework the app had just replaced.
|
|
16
|
+
require "constable"
|
data/lib/constable.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "constable/version"
|
|
4
|
+
|
|
5
|
+
# Constable -- an opinionated, strict Rails testing framework.
|
|
6
|
+
#
|
|
7
|
+
# Published on RubyGems as "constable-rails"; everything inside is simply Constable.
|
|
8
|
+
#
|
|
9
|
+
# Five ideas hold the whole thing up:
|
|
10
|
+
#
|
|
11
|
+
# 1. Isolation is non-negotiable in native code. No class-level shared state, no
|
|
12
|
+
# before(:all) equivalent. Every native test gets a clean transaction and a clean
|
|
13
|
+
# object graph.
|
|
14
|
+
# 2. Nondeterminism is caught by the linter, not discovered in CI.
|
|
15
|
+
# 3. Adoption never requires a rewrite. A whole existing RSpec/Minitest file runs
|
|
16
|
+
# untouched from day one.
|
|
17
|
+
# 4. Every escape hatch is visible. Nothing that bends the rules is ever silent.
|
|
18
|
+
# 5. Fast is the default, not an opt-in.
|
|
19
|
+
module Constable
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
class ConfigurationError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised by assertion primitives. Distinct from Error so a genuine bug in a test never
|
|
24
|
+
# gets mistaken for an assertion failure.
|
|
25
|
+
class AssertionFailed < Error
|
|
26
|
+
attr_reader :context
|
|
27
|
+
|
|
28
|
+
def initialize(message, context: nil)
|
|
29
|
+
@context = context
|
|
30
|
+
super(message)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
autoload :Backtrace, "constable/result"
|
|
35
|
+
autoload :CLI, "constable/cli"
|
|
36
|
+
autoload :Case, "constable/case"
|
|
37
|
+
autoload :ColdCase, "constable/cold_case"
|
|
38
|
+
autoload :Config, "constable/config"
|
|
39
|
+
autoload :Coverage, "constable/coverage"
|
|
40
|
+
autoload :DSL, "constable/dsl"
|
|
41
|
+
autoload :Diff, "constable/diff"
|
|
42
|
+
autoload :Failure, "constable/result"
|
|
43
|
+
autoload :Identity, "constable/identity"
|
|
44
|
+
autoload :Importer, "constable/importer"
|
|
45
|
+
autoload :Investigation, "constable/investigation"
|
|
46
|
+
autoload :Isolation, "constable/isolation"
|
|
47
|
+
autoload :Jail, "constable/jail"
|
|
48
|
+
autoload :LogRouter, "constable/log_router"
|
|
49
|
+
autoload :Matchers, "constable/matchers"
|
|
50
|
+
autoload :OrderAudit, "constable/order_audit"
|
|
51
|
+
autoload :RailsSupport, "constable/rails_support"
|
|
52
|
+
autoload :Registry, "constable/registry"
|
|
53
|
+
autoload :Reporter, "constable/reporter"
|
|
54
|
+
autoload :Result, "constable/result"
|
|
55
|
+
autoload :Runner, "constable/runner"
|
|
56
|
+
autoload :Selection, "constable/selection"
|
|
57
|
+
autoload :Storage, "constable/storage"
|
|
58
|
+
autoload :Warrants, "constable/warrants"
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
attr_writer :root, :config, :storage
|
|
62
|
+
|
|
63
|
+
# The application root. Rails.root when Rails is booted, otherwise the nearest
|
|
64
|
+
# directory that looks like a project (has .constable/, Gemfile, or .git).
|
|
65
|
+
def root
|
|
66
|
+
@root ||= detect_root
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def config
|
|
70
|
+
@config ||= Config.load(root: root)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Code-level configuration -- matchers, tier base classes, one-time global setup.
|
|
74
|
+
# Settings that are merely settings belong in .constable/config.yml instead.
|
|
75
|
+
#
|
|
76
|
+
# Constable.configure do |c|
|
|
77
|
+
# c.parallel_workers = 4
|
|
78
|
+
# c.before_suite { Capybara.default_driver = :rack_test }
|
|
79
|
+
# end
|
|
80
|
+
def configure
|
|
81
|
+
yield configuration if block_given?
|
|
82
|
+
configuration
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def configuration
|
|
86
|
+
@configuration ||= Configuration.new
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def storage
|
|
90
|
+
@storage ||= Storage::Adapter.build(config).tap(&:setup!)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def registry
|
|
94
|
+
@registry ||= Registry.new
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Warnings are never silent and never fatal by default. They accumulate through a run
|
|
98
|
+
# and always get their own section in the summary.
|
|
99
|
+
def warn!(message, location: nil, kind: :unsafe)
|
|
100
|
+
warnings << { message: message, location: location, kind: kind }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def warnings
|
|
104
|
+
@warnings ||= []
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def reset!
|
|
108
|
+
@config = nil
|
|
109
|
+
@storage = nil
|
|
110
|
+
@registry = nil
|
|
111
|
+
@warnings = []
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def detect_root
|
|
117
|
+
return Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
118
|
+
|
|
119
|
+
dir = Dir.pwd
|
|
120
|
+
until dir == "/"
|
|
121
|
+
return dir if File.exist?(File.join(dir, ".constable")) ||
|
|
122
|
+
File.exist?(File.join(dir, "Gemfile")) ||
|
|
123
|
+
File.directory?(File.join(dir, ".git"))
|
|
124
|
+
|
|
125
|
+
dir = File.dirname(dir)
|
|
126
|
+
end
|
|
127
|
+
Dir.pwd
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Code-level configuration set from test/case_helper.rb.
|
|
132
|
+
class Configuration
|
|
133
|
+
attr_accessor :parallel_workers, :seed, :coverage, :warrants
|
|
134
|
+
|
|
135
|
+
def initialize
|
|
136
|
+
@before_suite_hooks = []
|
|
137
|
+
@after_suite_hooks = []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def before_suite(&block) = @before_suite_hooks << block
|
|
141
|
+
def after_suite(&block) = @after_suite_hooks << block
|
|
142
|
+
|
|
143
|
+
def run_before_suite! = @before_suite_hooks.each(&:call)
|
|
144
|
+
def run_after_suite! = @after_suite_hooks.each(&:call)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Registers Constable as the app's generator test framework, so `rails generate model`
|
|
149
|
+
# writes a case instead of a Minitest file. Conditional on purpose: everything above this
|
|
150
|
+
# line must load in a process with no Rails at all, which is what the :unit tier is for.
|
|
151
|
+
require "constable/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rails generator machinery is required *here*, not from lib/constable.rb.
|
|
4
|
+
# `require "constable"` has to work in a process with no Rails app at all --
|
|
5
|
+
# that is the entire point of the :unit tier -- so railties is only ever pulled
|
|
6
|
+
# in by the files that genuinely cannot exist without it.
|
|
7
|
+
require "rails/generators/named_base"
|
|
8
|
+
require "constable"
|
|
9
|
+
|
|
10
|
+
# Rails' own generators get these for free because a booted app has loaded all of
|
|
11
|
+
# ActiveSupport by the time anything generates. Ours can be loaded by a bare
|
|
12
|
+
# `rails generate` in a half-booted process, and GeneratedAttribute#parse reaches for
|
|
13
|
+
# String#remove, so name what we actually depend on rather than hoping.
|
|
14
|
+
require "active_support/core_ext/string/filters"
|
|
15
|
+
require "active_support/core_ext/string/inflections"
|
|
16
|
+
|
|
17
|
+
module Constable
|
|
18
|
+
module Generators
|
|
19
|
+
# Shared ground for the generators Rails invokes on Constable's behalf.
|
|
20
|
+
#
|
|
21
|
+
# Once Constable::Railtie has registered the app's test framework, every
|
|
22
|
+
# `rails generate` command that hooks :test_framework goes looking for a generator
|
|
23
|
+
# called constable:<something>. Rails resolves that by namespace, the namespace comes
|
|
24
|
+
# from the class name, and the file is found by converting the namespace back into a
|
|
25
|
+
# path -- so the layout is not a matter of taste. Each generator lives at
|
|
26
|
+
# generators/constable/<name>/<name>_generator.rb and is called <Name>Generator.
|
|
27
|
+
# rspec-rails is arranged the same way, for the same reason.
|
|
28
|
+
#
|
|
29
|
+
# Named Base deliberately: Rails::Generators::Base.inherited skips registering any
|
|
30
|
+
# class whose name ends in "Base", so this never turns up in `rails generate` output
|
|
31
|
+
# as a generator of its own.
|
|
32
|
+
class Base < ::Rails::Generators::NamedBase
|
|
33
|
+
# Each generator keeps its templates beside itself. Rails' default looks for them
|
|
34
|
+
# under railties' own directory, which is no use to a gem that isn't railties.
|
|
35
|
+
def self.source_root(path = nil)
|
|
36
|
+
return @source_root = path if path
|
|
37
|
+
|
|
38
|
+
@source_root ||= File.expand_path(File.join(__dir__, generator_name, "templates"))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `rails generate job CleanUpJob` and `rails generate job CleanUp` have to land in
|
|
42
|
+
# the same file, so the generators Rails hooks strip their own suffix off the name
|
|
43
|
+
# first. Declared here once rather than written out six times, and with one thing
|
|
44
|
+
# worth knowing: the memo has to be @_file_name and not @file_name, because
|
|
45
|
+
# NamedBase's own reader is backed by @file_name -- memoizing into it would make
|
|
46
|
+
# `super` hand back the already-stripped value and the suffix would never come off.
|
|
47
|
+
# The no_commands wrapper is not decoration: Thor turns every public instance method
|
|
48
|
+
# on a generator into a runnable step, and a generator whose first step is
|
|
49
|
+
# "file_name" writes no files at all.
|
|
50
|
+
def self.strips_suffix(pattern)
|
|
51
|
+
no_commands do
|
|
52
|
+
# rubocop:disable-next Naming/MemoizedInstanceVariableName -- @file_name is taken, see above
|
|
53
|
+
define_method(:file_name) { @_file_name ||= super().sub(pattern, "") }
|
|
54
|
+
end
|
|
55
|
+
private :file_name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Where a generated case file goes. `test/cases/<area>/...` is the shape the `tiers:`
|
|
61
|
+
# globs in Constable::Config::DEFAULTS already use, so the path-based tier fallback
|
|
62
|
+
# and the tier base class the file inherits from agree with each other instead of
|
|
63
|
+
# quietly disagreeing. The base class still wins; agreeing just means nobody has to
|
|
64
|
+
# work out which one applied.
|
|
65
|
+
def case_path(area, *parts)
|
|
66
|
+
File.join("test/cases", area, *parts)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The attributes worth putting in a witness. References and virtual attributes
|
|
70
|
+
# (rich text, attachments) are dropped: their value is another record or an
|
|
71
|
+
# uploaded file, and inventing one would generate a test that fails for a reason
|
|
72
|
+
# nobody wrote.
|
|
73
|
+
def case_attributes
|
|
74
|
+
@case_attributes ||= attributes.reject { |attribute| attribute.reference? || attribute.virtual? }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `title: "MyString", published: false` -- the body of the witness a case starts from.
|
|
78
|
+
def attributes_arguments
|
|
79
|
+
case_attributes.map { |attribute| "#{attribute.column_name}: #{attribute_value(attribute)}" }.join(", ")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def attributes_literal
|
|
83
|
+
case_attributes.empty? ? "{}" : "{ #{attributes_arguments} }"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# `Post.new(title: "MyString")`, or a bare `Post.new` for a model generated with
|
|
87
|
+
# nothing to fill in.
|
|
88
|
+
def new_record_expression(klass = class_name)
|
|
89
|
+
case_attributes.empty? ? "#{klass}.new" : "#{klass}.new(#{attributes_arguments})"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def attribute_value(attribute)
|
|
93
|
+
return '"secret"' if %w[password password_confirmation].include?(attribute.name)
|
|
94
|
+
|
|
95
|
+
attribute.default.inspect
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "generators/constable/base"
|
|
4
|
+
|
|
5
|
+
module Constable
|
|
6
|
+
module Generators
|
|
7
|
+
# Invoked by `rails generate channel Room`.
|
|
8
|
+
#
|
|
9
|
+
# test/cases/channels/room_channel_case.rb class RoomChannelCase < IntegrationCase
|
|
10
|
+
class ChannelGenerator < Base
|
|
11
|
+
check_class_collision suffix: "ChannelCase"
|
|
12
|
+
|
|
13
|
+
strips_suffix(/_channel\z/i)
|
|
14
|
+
|
|
15
|
+
def create_case_file
|
|
16
|
+
template "channel_case.rb.tt", case_path("channels", class_path, "#{file_name}_channel_case.rb")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "case_helper"
|
|
4
|
+
|
|
5
|
+
<% module_namespacing do -%>
|
|
6
|
+
class <%= class_name %>ChannelCase < IntegrationCase
|
|
7
|
+
# ActionCable ships its own harness -- `subscribe`, `subscription`, `perform` -- as a
|
|
8
|
+
# module, so it composes into a case the same way any shared behavior does. One line,
|
|
9
|
+
# here or in the IntegrationCase base class in test/case_helper.rb:
|
|
10
|
+
#
|
|
11
|
+
# include ActionCable::Channel::TestCase::Behavior
|
|
12
|
+
#
|
|
13
|
+
# self.channel_class = <%= class_name %>Channel
|
|
14
|
+
#
|
|
15
|
+
# With that in place, the first two investigations write themselves:
|
|
16
|
+
#
|
|
17
|
+
# investigate "subscribes" do
|
|
18
|
+
# subscribe
|
|
19
|
+
#
|
|
20
|
+
# attest(subscription).to be_confirmed
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# investigate "streams from its own room" do
|
|
24
|
+
# subscribe(room: "1")
|
|
25
|
+
#
|
|
26
|
+
# attest(streams).to include("<%= file_name %>_1")
|
|
27
|
+
# end
|
|
28
|
+
end
|
|
29
|
+
<% end -%>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "generators/constable/base"
|
|
4
|
+
|
|
5
|
+
module Constable
|
|
6
|
+
module Generators
|
|
7
|
+
# Invoked by `rails generate controller Posts index show`.
|
|
8
|
+
#
|
|
9
|
+
# test/cases/controllers/posts_controller_case.rb class PostsControllerCase < IntegrationCase
|
|
10
|
+
class ControllerGenerator < Base
|
|
11
|
+
argument :actions, type: :array, default: [], banner: "action action"
|
|
12
|
+
|
|
13
|
+
# Rails passes this through when it skipped writing routes. Without a route there is
|
|
14
|
+
# nothing to drive, so the template says so instead of generating a request that
|
|
15
|
+
# cannot resolve.
|
|
16
|
+
class_option :skip_routes, type: :boolean
|
|
17
|
+
|
|
18
|
+
check_class_collision suffix: "ControllerCase"
|
|
19
|
+
|
|
20
|
+
def create_case_file
|
|
21
|
+
template "controller_case.rb.tt", case_path("controllers", class_path, "#{file_name}_controller_case.rb")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "case_helper"
|
|
4
|
+
|
|
5
|
+
<% module_namespacing do -%>
|
|
6
|
+
class <%= class_name %>ControllerCase < IntegrationCase
|
|
7
|
+
<% if mountable_engine? -%>
|
|
8
|
+
include Engine.routes.url_helpers
|
|
9
|
+
|
|
10
|
+
<% end -%>
|
|
11
|
+
<% if actions.empty? || options[:skip_routes] -%>
|
|
12
|
+
# `rails generate controller <%= name %>` ran <%= actions.empty? ? "without any actions" : "with --skip-routes" %>, so there is no route to
|
|
13
|
+
# drive yet. When there is, an investigation reads like this -- one request, one
|
|
14
|
+
# assertion about the response, nothing shared with the next one:
|
|
15
|
+
#
|
|
16
|
+
# investigate "index responds successfully" do
|
|
17
|
+
# get <%= url_helper_prefix %>_index_url
|
|
18
|
+
#
|
|
19
|
+
# attest(response).to have_http_status(:ok)
|
|
20
|
+
# end
|
|
21
|
+
<% else -%>
|
|
22
|
+
<% actions.each do |action| -%>
|
|
23
|
+
investigate "<%= action %> responds successfully" do
|
|
24
|
+
get <%= url_helper_prefix %>_<%= action %>_url
|
|
25
|
+
|
|
26
|
+
attest(response).to have_http_status(:ok)
|
|
27
|
+
end
|
|
28
|
+
<%= "\n" unless action == actions.last -%>
|
|
29
|
+
<% end -%>
|
|
30
|
+
<% end -%>
|
|
31
|
+
end
|
|
32
|
+
<% end -%>
|