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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. metadata +209 -0
@@ -0,0 +1,475 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Constable
6
+ # The command line. Every command answers one question and prints the answer -- stdout is
7
+ # reserved for results, so anything a Rails app would normally shout into the terminal
8
+ # goes to log/test.log instead.
9
+ class CLI < Thor
10
+ EXIT_CLEAN = 0
11
+ EXIT_FAILED = 1
12
+ EXIT_USAGE = 2
13
+
14
+ def self.exit_on_failure? = true
15
+
16
+ # Thor's own exit status handling doesn't distinguish "tests failed" from "command was
17
+ # wrong", and CI needs to. Commands return a status; this turns it into one.
18
+ def self.dispatch!(argv)
19
+ start(argv)
20
+ rescue Thor::Error => e
21
+ warn e.message
22
+ exit(EXIT_USAGE)
23
+ rescue Interrupt
24
+ warn "\ninterrupted"
25
+ exit(EXIT_FAILED)
26
+ end
27
+
28
+ class_option :"no-color", type: :boolean, default: false, desc: "Disable ANSI color"
29
+
30
+ desc "test [PATH[:LINE]]", "Run the suite -- native and cold cases side by side"
31
+ long_desc <<~DESC
32
+ With no arguments, runs only the cases touched by your current git diff. CI should
33
+ always pass --full. PATH runs one file; PATH:LINE runs the single investigation at
34
+ that line.
35
+ DESC
36
+ option :full, type: :boolean, default: false, desc: "Run the whole suite (always use this in CI)"
37
+ option :unsafe, type: :boolean, default: false, desc: "Run cold cases only"
38
+ option :jail, type: :boolean, default: false, desc: "Jail failures instead of failing the build"
39
+ option :warrants, type: :boolean, desc: "Turn the flaky detector on for this run"
40
+ option :coverage, type: :boolean, desc: "Record coverage for this run"
41
+ option :seed, type: :numeric, desc: "Replay a previous run's order"
42
+ option :workers, type: :numeric, desc: "Parallel workers (default: config, or auto)"
43
+ option :verbose, type: :boolean, default: false, desc: "Stream log/test.log to stdout"
44
+ option :tier, type: :string, desc: "Run one tier only: unit, integration or system"
45
+ def test(*paths)
46
+ config = load_config
47
+ LogRouter.route!(verbose: options[:verbose])
48
+
49
+ selection = Selection.new(
50
+ paths,
51
+ config: config,
52
+ root: Constable.root,
53
+ full: options[:full],
54
+ unsafe_only: options[:unsafe],
55
+ tier: options[:tier]
56
+ )
57
+
58
+ runner = Runner.new(
59
+ selection: selection,
60
+ config: config,
61
+ reporter: reporter(config),
62
+ storage: Constable.storage,
63
+ seed: options[:seed],
64
+ jail_mode: options[:jail],
65
+ warrants: options[:warrants],
66
+ coverage: options[:coverage],
67
+ workers: options[:workers],
68
+ verbose: options[:verbose]
69
+ )
70
+
71
+ exit(runner.call)
72
+ end
73
+
74
+ desc "watchlist", "Everything under supervision right now: jailed, paroled and warranted"
75
+ def watchlist
76
+ config = load_config
77
+ storage = Constable.storage
78
+ jail = Jail.new(config: config, storage: storage)
79
+ warrants = Warrants.new(config: config, storage: storage)
80
+
81
+ say_table("JAILED", jail.jailed) do |entry|
82
+ [entry.location, entry.label, entry.reason, jailed_on(entry)]
83
+ end
84
+
85
+ say_table("ON PAROLE", jail.paroled) do |entry|
86
+ ["#{entry.parole_day}/#{config.parole_period} clean"].then do |clean|
87
+ [entry.location, entry.label, clean.first, jailed_on(entry)]
88
+ end
89
+ end
90
+
91
+ say_table("WARRANTS", warrants.entries) do |entry|
92
+ [entry.location, entry.label, "issued #{short_date(entry.issued_at)}"]
93
+ end
94
+
95
+ exit(EXIT_CLEAN)
96
+ end
97
+
98
+ desc "status", "How the suite is doing over time"
99
+ long_desc <<~DESC
100
+ The trend view: how much of the suite is still running as cold cases, how the last
101
+ runs went, and which tests have been the slowest historically. For what is flagged
102
+ right now -- jailed, paroled, warranted -- use `constable watchlist` instead.
103
+ DESC
104
+ def status
105
+ storage = Constable.storage
106
+ runs = storage.runs(limit: 20)
107
+
108
+ if runs.empty?
109
+ say "No runs recorded yet. Run: constable test --full"
110
+ exit(EXIT_CLEAN)
111
+ end
112
+
113
+ print_adoption(storage, runs)
114
+ print_recent_runs(runs)
115
+ print_historical_slowest(storage)
116
+ exit(EXIT_CLEAN)
117
+ end
118
+
119
+ desc "beat", "Coverage: overall %, per-file breakdown and the unpatrolled list"
120
+ option :html, type: :boolean, default: false, desc: "Write a browsable HTML report"
121
+ def beat
122
+ config = load_config
123
+ LogRouter.route!(verbose: false)
124
+
125
+ # The beat is walked, not remembered: a stored snapshot carries percentages but not
126
+ # the per-line detail the breakdown and the HTML report are made of. So this runs
127
+ # the full suite with coverage on and reports on what it finds.
128
+ selection = Selection.new([], config: config, root: Constable.root, full: true)
129
+ runner = Runner.new(
130
+ selection: selection, config: config, reporter: reporter(config),
131
+ storage: Constable.storage, coverage: true
132
+ )
133
+ runner.call
134
+ report = runner.coverage_report
135
+
136
+ unless report
137
+ say "No coverage was recorded. Is there anything to run?"
138
+ exit(EXIT_FAILED)
139
+ end
140
+
141
+ say ""
142
+ say report.beat_report
143
+
144
+ if options[:html] || config.coverage_html?
145
+ path = Constable::Coverage.write_html(report)
146
+ say "\nHTML report: #{path}"
147
+ end
148
+ exit(EXIT_CLEAN)
149
+ end
150
+
151
+ desc "import", "Adopt an existing suite as cold cases -- verbatim, nothing rewritten"
152
+ option :from, type: :string, required: true, enum: %w[rspec minitest], desc: "Source framework"
153
+ option :strategy, type: :string, default: "auto", enum: %w[auto config superclass],
154
+ desc: "auto: widest clean glob, else a superclass swap. " \
155
+ "config: no file changes at all. superclass: one line per file"
156
+ option :"dry-run", type: :boolean, default: false, desc: "Show what would change"
157
+ def import
158
+ result = Importer.run(
159
+ from: options[:from].to_sym,
160
+ config: load_config,
161
+ dry_run: options[:"dry-run"],
162
+ strategy: options[:strategy].to_sym
163
+ )
164
+
165
+ say result.summary
166
+ say "\nNothing was rewritten -- cold cases run through their own engine, unchanged." if result.any_changes?
167
+ exit(EXIT_CLEAN)
168
+ end
169
+
170
+ desc "modernize PATH [PATH...]", "Opt-in AST rewrite into the native DSL"
171
+ long_desc <<~DESC
172
+ Reports by default and writes nothing. --alongside writes a new *_case.rb next to the
173
+ original; --in-place overwrites it. Anything the rewrite cannot decide safely is
174
+ flagged for a human rather than guessed at, and never converted.
175
+ DESC
176
+ option :alongside, type: :boolean, default: false, desc: "Write PATH_case.rb beside the original"
177
+ option :"in-place", type: :boolean, default: false, desc: "Overwrite the file"
178
+ option :"show-source", type: :boolean, default: false, desc: "Print the rewritten source"
179
+ def modernize(*paths)
180
+ if paths.empty?
181
+ warn "modernize needs at least one path"
182
+ exit(EXIT_USAGE)
183
+ end
184
+
185
+ mode = if options[:"in-place"] then :in_place
186
+ elsif options[:alongside] then :alongside
187
+ else :none
188
+ end
189
+
190
+ run = Importer.modernize(paths, config: load_config, write: mode)
191
+
192
+ run.results.each do |result|
193
+ if result.error
194
+ say "#{result.relative_path}: #{result.error}"
195
+ next
196
+ end
197
+
198
+ counts = result.counts
199
+ say "#{result.relative_path} — #{describe_counts(counts)}"
200
+ say(result.source) if options[:"show-source"]
201
+
202
+ result.flags.each { |flag| say " flagged #{flag[:location]} #{flag[:reason]}" }
203
+ end
204
+
205
+ say "\nWrote #{run.written.size} file(s)." if run.written.any?
206
+ say "Report: #{run.report_path}" if run.report_path
207
+ say "\nNothing was written. Re-run with --alongside or --in-place." if mode == :none
208
+ exit(run.ok? ? EXIT_CLEAN : EXIT_FAILED)
209
+ end
210
+
211
+ desc "version", "Print the version"
212
+ def version
213
+ say "constable #{Constable::VERSION} (gem: constable-rails)"
214
+ exit(EXIT_CLEAN)
215
+ end
216
+ map %w[-v --version] => :version
217
+
218
+ # --- subcommands -----------------------------------------------------------
219
+
220
+ # The jail docket. Jailing isn't hiding -- it swaps "blocks the build" for "tracked and
221
+ # skipped", and nothing leaves the docket without someone deciding it should.
222
+ class JailCommand < Thor
223
+ def self.exit_on_failure? = true
224
+
225
+ default_task :list
226
+
227
+ desc "list", "Every jailed test: reason, file:line, date jailed"
228
+ def list
229
+ config = Constable.config
230
+ jail = Jail.new(config: config, storage: Constable.storage)
231
+ entries = jail.entries
232
+
233
+ if entries.empty?
234
+ say "The docket is empty."
235
+ return
236
+ end
237
+
238
+ entries.each do |entry|
239
+ state = entry.paroled? ? "on parole" : "jailed"
240
+ say format("%-9s %s", state, entry.location)
241
+ say " #{entry.label}"
242
+ say " #{entry.reason}"
243
+ say " jailed #{short_date(entry.jailed_at)}#{repeat_note(entry)}"
244
+ say ""
245
+ end
246
+ end
247
+
248
+ # Thor reserves `run` as a method name, so the command is named for the user and the
249
+ # method is named for Ruby.
250
+ map "run" => :rerun
251
+
252
+ desc "run [PATH:LINE]", "Re-run jailed tests -- sequentially, for clean attribution"
253
+ option :full, type: :boolean, default: false,
254
+ desc: "Re-run the whole docket in one parallel batch (faster, coarser)"
255
+ def rerun(path = nil)
256
+ config = Constable.config
257
+ storage = Constable.storage
258
+ jail = Jail.new(config: config, storage: storage)
259
+ targets = path ? [path] : jail.entries.map(&:location)
260
+
261
+ if targets.empty?
262
+ say "The docket is empty."
263
+ return
264
+ end
265
+
266
+ selection = Selection.new(targets, config: config, root: Constable.root, full: true)
267
+ runner = Runner.new(
268
+ selection: selection,
269
+ config: config,
270
+ storage: storage,
271
+ jail_run: true,
272
+ # Sequential by default: one test at a time gives clean attribution for a
273
+ # docket nobody trusts yet. --full trades that for speed.
274
+ workers: options[:full] ? nil : 1
275
+ )
276
+ status = runner.call
277
+
278
+ # A pass here is a candidate, never a release. One green run doesn't prove anything.
279
+ passed = runner.results.select(&:passed?)
280
+ if passed.any?
281
+ say "\nCandidates for parole (a pass here is not a release):"
282
+ passed.each { |r| say " constable jail parole #{r.location} # #{r.display_label}" }
283
+ end
284
+ exit(status)
285
+ end
286
+
287
+ desc "parole PATH:LINE", "Move a jailed test to parole -- runs again, but watched"
288
+ def parole(locator)
289
+ act(locator) { |jail, identity| jail.parole(identity) }
290
+ say "Paroled. It runs normally now; one failure sends it straight back."
291
+ end
292
+
293
+ desc "release PATH:LINE", "Fully release a test, no supervision"
294
+ def release(locator)
295
+ act(locator) { |jail, identity| jail.release(identity) }
296
+ say "Released."
297
+ end
298
+
299
+ no_commands do
300
+ def act(locator)
301
+ jail = Jail.new(config: Constable.config, storage: Constable.storage)
302
+ identity = jail.identity_for(locator)
303
+ unless identity
304
+ warn "Nothing on the docket at #{locator}"
305
+ exit(EXIT_USAGE)
306
+ end
307
+ yield jail, identity
308
+ end
309
+
310
+ def repeat_note(entry)
311
+ count = entry.times_jailed
312
+ count > 1 ? " (this is its #{Reporter.ordinalize(count)} time in jail)" : ""
313
+ end
314
+
315
+ def short_date(value) = value.to_s[0, 10]
316
+ end
317
+ end
318
+
319
+ # Warrants answer a different question from jail: not "does this block the build" but
320
+ # "is this failure even real."
321
+ class WarrantsCommand < Thor
322
+ def self.exit_on_failure? = true
323
+
324
+ default_task :list
325
+
326
+ desc "list", "Every test currently under a warrant"
327
+ def list
328
+ warrants = Warrants.new(config: Constable.config, storage: Constable.storage)
329
+ entries = warrants.entries
330
+
331
+ if entries.empty?
332
+ say "No warrants outstanding."
333
+ return
334
+ end
335
+
336
+ entries.each do |entry|
337
+ say entry.location
338
+ say " #{entry.label}"
339
+ say " issued #{entry.issued_at.to_s[0, 10]}, last seen #{entry.last_seen_at.to_s[0, 10]}"
340
+ say ""
341
+ end
342
+ end
343
+
344
+ desc "release PATH:LINE", "Clear a warrant by hand"
345
+ def release(locator)
346
+ warrants = Warrants.new(config: Constable.config, storage: Constable.storage)
347
+ identity = warrants.identity_for(locator)
348
+ unless identity
349
+ warn "No warrant at #{locator}"
350
+ exit(EXIT_USAGE)
351
+ end
352
+ warrants.clear(identity)
353
+ say "Warrant cleared."
354
+ end
355
+ end
356
+
357
+ # Flake history is keyed by a content hash of the investigate block, so renames carry
358
+ # over on their own. This is for the case where a rename shipped with a real edit.
359
+ class HistoryCommand < Thor
360
+ def self.exit_on_failure? = true
361
+
362
+ default_task :show
363
+
364
+ desc "show", "Recent runs"
365
+ def show
366
+ storage = Constable.storage
367
+ storage.runs(limit: 20).each do |run|
368
+ say format("%s seed %-6s %s %d passed, %d failed",
369
+ run[:started_at], run[:seed], run[:mode], run[:passed].to_i, run[:failed].to_i)
370
+ end
371
+ end
372
+
373
+ desc "relink OLD_HASH NEW_HASH", "Carry a test's history across a real body change"
374
+ def relink(old_hash, new_hash)
375
+ Constable.storage.relink(old_hash, new_hash)
376
+ say "Relinked #{old_hash} -> #{new_hash}. History carried over."
377
+ end
378
+ end
379
+
380
+ desc "jail SUBCOMMAND", "The jail docket"
381
+ subcommand "jail", JailCommand
382
+
383
+ desc "warrants SUBCOMMAND", "Outstanding warrants"
384
+ subcommand "warrants", WarrantsCommand
385
+
386
+ desc "history SUBCOMMAND", "Flake history"
387
+ subcommand "history", HistoryCommand
388
+
389
+ no_commands do
390
+ def load_config
391
+ Constable.config
392
+ end
393
+
394
+ def color?
395
+ return false if options[:"no-color"]
396
+ return false unless $stdout.tty?
397
+ return false unless ENV["NO_COLOR"].to_s.empty?
398
+
399
+ true
400
+ end
401
+
402
+ def reporter(config)
403
+ Reporter.new(io: $stdout, config: config, color: color?)
404
+ end
405
+
406
+ def say_table(heading, entries)
407
+ say heading
408
+ say "─" * heading.length
409
+ if entries.empty?
410
+ say " (none)"
411
+ else
412
+ entries.each { |entry| say " #{yield(entry).compact.join(" ")}" }
413
+ end
414
+ say ""
415
+ end
416
+
417
+ def jailed_on(entry)
418
+ "jailed #{short_date(entry.jailed_at)}"
419
+ end
420
+
421
+ def short_date(value)
422
+ value.to_s[0, 10]
423
+ end
424
+
425
+ # The adoption number: what share of the suite is still opted out of native rules.
426
+ # It only means anything as a direction of travel, so it is shown against the run
427
+ # the blotter remembers furthest back.
428
+ def print_adoption(storage, runs)
429
+ totals = storage.kind_totals(limit: runs.size)
430
+ return if totals.empty?
431
+
432
+ latest = totals.first
433
+ total = latest[:native] + latest[:cold]
434
+ return if total.zero?
435
+
436
+ native_pct = (latest[:native] * 100.0 / total).round
437
+ say_table("ADOPTION", [latest]) do |entry|
438
+ ["#{native_pct}% native", "#{entry[:native]} native", "#{entry[:cold]} cold"]
439
+ end
440
+
441
+ oldest = totals.last
442
+ oldest_total = oldest[:native] + oldest[:cold]
443
+ return if oldest_total.zero? || totals.size < 2
444
+
445
+ was = (oldest[:native] * 100.0 / oldest_total).round
446
+ say " #{was}% native #{totals.size} runs ago -> #{native_pct}% now"
447
+ say ""
448
+ end
449
+
450
+ def print_recent_runs(runs)
451
+ say_table("RECENT RUNS", runs.first(10)) do |run|
452
+ [
453
+ short_date(run[:started_at]),
454
+ format("%-5s", run[:mode]),
455
+ "#{run[:passed].to_i} passed",
456
+ "#{run[:failed].to_i} failed",
457
+ ("#{run[:jailed].to_i} jailed" if run[:jailed].to_i.positive?)
458
+ ]
459
+ end
460
+ end
461
+
462
+ def print_historical_slowest(storage)
463
+ slowest = storage.slowest(limit: 10)
464
+ say_table("SLOWEST, HISTORICALLY", slowest) do |entry|
465
+ [format("%6.2fs", entry[:average].to_f), entry[:label] || entry[:identity]]
466
+ end
467
+ end
468
+
469
+ def describe_counts(counts)
470
+ parts = counts.filter_map { |kind, count| "#{count} #{kind}" if count.to_i.positive? }
471
+ parts.empty? ? "nothing to convert" : parts.join(", ")
472
+ end
473
+ end
474
+ end
475
+ end