constable-rails 1.0.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05e7e65ec7f1f096713bc5cb7b891ce2297275876c01cf9151130dc13608befe
4
- data.tar.gz: db0b094a1e70a0052f02bb343cd0d41c53b224e56df089638ad6f5be60025e36
3
+ metadata.gz: 65a31ecd5db11b141e18762227af70518749d77042d45fad1626cc1713e06289
4
+ data.tar.gz: 6fe6e92175d4d306e47626a4d66e6e1ed029817c80af644f6c70f88f4c15c9df
5
5
  SHA512:
6
- metadata.gz: 19e12d780b73ec73830eab6290b27d61f1fb8c9cd40894ff0c2832120883f06265e58b405ec107c1dab002d6bf484fb166a99cad273507d1b05e0da1234ae0df
7
- data.tar.gz: 789ba56df5a2720e25ddbadec56d9916776f809c95b812748468addbdf05276292e76ee37686a40d8489c5678121d44d35e41a0b2b09ab6e72af82b016dc682a
6
+ metadata.gz: 717a3d9604ef554b4216a0fc497626b7bbff207767904c7c73d66805d70b7e3e60c932d030c728123e414823a346040817ddeb715fe84260ff46c8038bb02181
7
+ data.tar.gz: 4f22e2278306e93af27767a71ae63931ea4b1752dc403ef5b33d604cb6ca8b4c136267f3c3d95f78e74bfdaa581eab2d8f343a65bc49bd8e1180a434bbf1ebeb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,173 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.2.0]
9
+
10
+ Two bugs found installing 1.1.0 into a large real Postgres app (~3,000-line schema,
11
+ custom types, a factory directory). Both are the same shape as the ones 1.0.0 fixed:
12
+ Constable was confidently wrong and said nothing useful about it.
13
+
14
+ ### A factory named `*_case.rb` was loaded as a test
15
+
16
+ `spec/**/*_case.rb` is a generous net, and a real app has things in it that merely share
17
+ the suffix. Caseflow has a FactoryBot factory at `spec/factories/distributed_case.rb`.
18
+ Constable loaded it as a case file, FactoryBot raised `DuplicateDefinitionError` because
19
+ the factory was already registered, and the run reported a failing test in a file that
20
+ contains no tests:
21
+
22
+ ```
23
+ ✗ spec/factories/distributed_case.rb
24
+ "could not be loaded"
25
+ FactoryBot::DuplicateDefinitionError: Factory already registered: distributed_case
26
+ ```
27
+
28
+ A filename is not evidence. A file outside the conventional `test/cases/` and
29
+ `spec/cases/` directories now has to look like a case before it is loaded — a class
30
+ declaration, or the DSL. Files under those directories are still taken at their word,
31
+ since that is what they are for and an empty one there is a case somebody is part-way
32
+ through writing.
33
+
34
+ ### An app that cannot be sharded now runs anyway
35
+
36
+ Per-worker databases (1.0.0) are built by loading `schema.rb` into `<database>_<index>`.
37
+ Not every app can do that: one with Postgres custom types, functions or triggers cannot
38
+ rebuild itself from `schema.rb` at all, which is exactly why such apps keep a
39
+ `structure.sql`. Rails' own `parallelize` fails the same way.
40
+
41
+ Constable handled it about as badly as possible. Each worker raised, printing a full
42
+ stack trace — four workers, four traces, several hundred lines — and the parent then
43
+ reported a run that had never happened:
44
+
45
+ ```
46
+ CONSTABLE 1 test · 1 case · 10.8s
47
+ ✓ 0 passed ✗ 1 failed
48
+ ```
49
+
50
+ A worker that cannot build its database now reports that home rather than raising. If no
51
+ worker got started, nothing has run yet, so the parent simply runs the suite serially and
52
+ says why in one sentence — including the real error and how to skip the attempt
53
+ (`parallel_workers: 1`). The rule from 1.0.0 is unchanged: a worker never falls back to
54
+ sharing the parent's database, because that is the corruption this whole mechanism
55
+ exists to prevent.
56
+
57
+
58
+ ## [1.1.0]
59
+
60
+ Three things that were documented and did not work, plus the command for a docket
61
+ that has gone stale.
62
+
63
+ ### `Constable.configure` actually configures things now
64
+
65
+ The generated `test/case_helper.rb` told you to write `c.parallel_workers = 4`, explained
66
+ when you would want to, and then **nothing in the codebase ever read it**. Four accessors,
67
+ all inert.
68
+
69
+ They work now, and every setting `.constable/config.yml` understands is settable in Ruby
70
+ alongside them — `cold_cases`, `storage`, `warrants`, `warrant_retries`, `auto_relink`,
71
+ `parole_period`, `coverage`, `coverage_threshold`, `coverage_html`, `fail_on_warnings`,
72
+ `parallel_workers`, `output`, `tiers`. A test asserts the two halves stay in step, so a
73
+ setting cannot be added to one and forgotten in the other.
74
+
75
+ Precedence, and the reasoning:
76
+
77
+ ```
78
+ a CLI flag --workers 4, --expanded one run, most specific
79
+ Constable.configure test/case_helper.rb code you deliberately ran
80
+ .constable/config.yml the project's declared default
81
+ Constable's defaults
82
+ ```
83
+
84
+ Which to use? A setting that differs per machine or per branch belongs in the YAML, where
85
+ it is obvious and greppable. A setting that has to be *computed* belongs in Ruby, because
86
+ YAML cannot do this:
87
+
88
+ ```ruby
89
+ Constable.configure do |c|
90
+ c.parallel_workers = ENV.fetch("CI_WORKERS", 4).to_i
91
+ c.coverage = ENV["CI"] == "true"
92
+ end
93
+ ```
94
+
95
+ Values set in Ruby go through the same clamping as values set in the file, so a typo is no
96
+ more dangerous in one than the other.
97
+
98
+ **`storage` is the one exception, and it now says so.** The blotter is opened before
99
+ `case_helper.rb` loads, so that `constable jail`, `warrants`, `watchlist` and `status` can
100
+ read the docket without booting the app — a broken app should not stop you reading the
101
+ docket. Setting it in Ruby would have been silently ignored, which is the exact failure
102
+ this release exists to stop, so it raises and explains why.
103
+
104
+ The docs now lead with Ruby. The generated `case_helper.rb` lists **every** setting at its
105
+ default in one compact block — a test asserts the list stays complete, and that it never
106
+ offers `storage`. The long-form explanation of each stays in `config.yml`, so the two files
107
+ are a reference and a place to write code rather than two competing references.
108
+
109
+ Neither is `config/initializers/`, and the reason is concrete: initializers run on every
110
+ boot including production, where a test-only gem is not in the bundle, so an initializer
111
+ calling `Constable.configure` takes the app down with a NoMethodError. Same reason RSpec,
112
+ SimpleCov, WebMock and Capybara all configure from the test helper.
113
+
114
+ ### `.constable/config.yml` is now genuinely optional
115
+
116
+ You can delete it. `rails generate constable:install --skip-config` never writes it.
117
+
118
+ The one thing keeping it mandatory was `storage`, which cannot be set in Ruby for the
119
+ ordering reason above. It now reads from the environment as well, which is the right shape
120
+ for CI anyway, where the value is a secret and differs per machine:
121
+
122
+ ```
123
+ CONSTABLE_STORAGE_URL=postgres://user:pass@host/constable_metadata
124
+ CONSTABLE_STORAGE_PATH=/var/lib/constable/blotter.sqlite3
125
+ CONSTABLE_STORAGE_ADAPTER=postgres
126
+ ```
127
+
128
+ An adapter is inferred from the URL scheme when it is not given, and an empty variable
129
+ means unset rather than "connect to the empty string". The `.constable/` **directory**
130
+ still exists to hold the blotter — deliberately not `tmp/`, which `rails tmp:clear` and
131
+ most deploys would wipe, taking weeks of flake history with it.
132
+
133
+ One bug found while checking this end to end, in the override layer added above: settings
134
+ were applied *after* the selection had already been asked for its targets, so
135
+ `c.cold_cases` in Ruby was silently ignored and a suite of 192 ran 35. Overrides are now
136
+ applied between requiring the helper and asking the selection anything.
137
+
138
+ ### Settings added after you install are no longer invisible
139
+
140
+ `.constable/config.yml` doubles as the reference — every key at its default, with the
141
+ reasoning above it — which only works if it stays current. A gem upgrade cannot rewrite it
142
+ without clobbering your settings, and Thor's only other answer is to skip the file, so
143
+ **`output` shipped in 1.0.0 and never appeared in any existing config.** The first anyone
144
+ knew was running `bundle update` and finding the setting missing.
145
+
146
+ `rails generate constable:install` now appends only the settings your file does not
147
+ mention, each with its explanation, under a header saying where they came from. Your
148
+ values and your own comments are never touched. Re-run it after any upgrade.
149
+
150
+ ### `constable prune`
151
+
152
+ A test's key is a content hash of its body, so editing a jailed test gives it a new
153
+ identity and leaves the old row behind — pointing at a `file:line` that may now hold
154
+ something else. That is identity working as designed, and it was the one known limitation
155
+ left open in 1.0.0. This is the broom:
156
+
157
+ ```console
158
+ $ constable prune --dry-run # list what would go
159
+ $ constable prune # forget it
160
+ ```
161
+
162
+ It loads the whole suite first, because which tests still exist is only knowable once
163
+ every case file has been read, and it is deliberately conservative in two directions:
164
+
165
+ - **A known identity is never pruned**, even when the file recorded beside it is gone. The
166
+ path is a display label; the identity is the truth. A test that moved file has an
167
+ out-of-date label, not a missing test.
168
+ - **A cold case is never pruned while its file exists.** Cold-case tests cannot be
169
+ enumerated without running their own engine, so their absence says nothing.
170
+
171
+ Flake history is left alone either way — only the docket and outstanding warrants are
172
+ touched.
173
+
174
+
8
175
  ## [1.0.0]
9
176
 
10
177
  The first release anybody should install.
@@ -241,6 +408,8 @@ Initial release.
241
408
  - Diff-based coverage gate — only lines changed in the current diff are held to the
242
409
  threshold. `constable beat` for the full picture, `--html` for a browsable report.
243
410
 
244
- [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.0.0...HEAD
411
+ [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.2.0...HEAD
412
+ [1.2.0]: https://github.com/Ray-Hughes/constable/compare/v1.1.0...v1.2.0
413
+ [1.1.0]: https://github.com/Ray-Hughes/constable/compare/v1.0.0...v1.1.0
245
414
  [1.0.0]: https://github.com/Ray-Hughes/constable/compare/v0.1.0...v1.0.0
246
415
  [0.1.0]: https://github.com/Ray-Hughes/constable/releases/tag/v0.1.0
data/README.md CHANGED
@@ -399,6 +399,7 @@ worse than one that resets.
399
399
  | `constable status` | How the suite is doing over time |
400
400
  | `constable beat [--html]` | Coverage: overall %, per-file, the unpatrolled list |
401
401
  | `constable history relink OLD NEW` | Carry history across a real body change |
402
+ | `constable prune [--dry-run]` | Forget docket rows and warrants for tests that no longer exist |
402
403
  | `constable import --from=rspec` | Adopt an existing suite as cold cases |
403
404
  | `constable modernize PATH` | Opt-in AST rewrite into the native DSL |
404
405
 
@@ -557,6 +558,50 @@ storage:
557
558
 
558
559
  ### Configuration
559
560
 
561
+ Settings can be written in Ruby, in `test/case_helper.rb` — the same place RSpec puts
562
+ `RSpec.configure` — or in `.constable/config.yml`, or on the command line. The most
563
+ specific wins:
564
+
565
+ ```
566
+ a CLI flag --workers 4, --expanded one run
567
+ Constable.configure test/case_helper.rb code you deliberately ran
568
+ .constable/config.yml the project's declared default
569
+ Constable's defaults
570
+ ```
571
+
572
+ Every key below can be set in either place. Put settings that differ per machine or per
573
+ branch in the YAML, where they are obvious and greppable; put settings that have to be
574
+ *computed* in Ruby, because YAML cannot:
575
+
576
+ ```ruby
577
+ # test/case_helper.rb
578
+ Constable.configure do |c|
579
+ c.parallel_workers = ENV.fetch("CI_WORKERS", 4).to_i
580
+ c.coverage = ENV["CI"] == "true"
581
+ c.output = :expanded
582
+ end
583
+ ```
584
+
585
+ Not `config/initializers/`, which is where a runtime gem like Devise goes. Initializers
586
+ run on **every** boot including production, where a test-only gem is not in the bundle, so
587
+ an initializer calling `Constable.configure` takes the app down. It is the same reason
588
+ RSpec, SimpleCov, WebMock and Capybara all configure from the test helper.
589
+
590
+ `config.yml` is optional, and one setting is the reason it exists: **`storage` can only be
591
+ set there.** The blotter is opened before `case_helper.rb` loads, so that `constable jail`,
592
+ `warrants`, `watchlist` and `status` can read the docket without booting the app — a
593
+ broken app should not stop you reading the docket. Setting it in Ruby raises rather than
594
+ being quietly ignored.
595
+
596
+ Beyond that it is a preference. A settings file is greppable and diffable without running
597
+ anything, which suits values that differ per project or per branch; Ruby suits anything
598
+ computed.
599
+
600
+ `config.yml` doubles as the reference, so re-run
601
+ `rails generate constable:install --skip` after an upgrade: it appends any settings your
602
+ file does not mention and leaves your own values and comments alone. (`--skip` so the
603
+ other generated files, which you have probably edited, are left as they are.)
604
+
560
605
  ```yaml
561
606
  # .constable/config.yml
562
607
  cold_cases:
data/lib/constable/cli.rb CHANGED
@@ -399,6 +399,48 @@ module Constable
399
399
  end
400
400
  end
401
401
 
402
+ desc "prune", "Forget docket rows and warrants for tests that no longer exist"
403
+ long_desc <<~DESC
404
+ A test's key is a content hash of its body, so editing a jailed test gives it a new
405
+ identity and leaves the old row behind -- pointing at a file:line that may now hold
406
+ something else. That is identity working as designed; this is the broom.
407
+
408
+ Loads the whole suite first, because which tests still exist is only knowable once
409
+ every case file has been read. Cold cases are never pruned while their file exists:
410
+ their tests cannot be enumerated without running their own engine, so silence about
411
+ them means nothing.
412
+ DESC
413
+ option :dry_run, type: :boolean, default: false, desc: "List what would go, change nothing"
414
+ def prune
415
+ config = load_config
416
+ known = Runner.identities(config: config)
417
+ cold = ->(path) { config.cold_case?(path) }
418
+
419
+ jail = Jail.new(config: config, storage: Constable.storage)
420
+ warrants = Warrants.new(config: config, storage: Constable.storage)
421
+
422
+ stale_rows = jail.stale_entries(known, cold_case: cold)
423
+ stale_warrants = warrants.stale_entries(known, cold_case: cold)
424
+
425
+ if stale_rows.empty? && stale_warrants.empty?
426
+ say "Nothing to prune -- every row on the docket still names a test that exists."
427
+ return 0
428
+ end
429
+
430
+ report_prunable("docket", stale_rows)
431
+ report_prunable("warrants", stale_warrants)
432
+
433
+ if options[:dry_run]
434
+ say "Dry run: nothing was changed."
435
+ return 0
436
+ end
437
+
438
+ stale_rows.each { |entry| jail.forget(entry.identity) }
439
+ stale_warrants.each { |entry| warrants.release(entry.identity) }
440
+ say "Pruned #{stale_rows.size + stale_warrants.size} row(s). Flake history is left alone."
441
+ 0
442
+ end
443
+
402
444
  desc "jail SUBCOMMAND", "The jail docket"
403
445
  subcommand "jail", JailCommand
404
446
 
@@ -409,6 +451,16 @@ module Constable
409
451
  subcommand "history", HistoryCommand
410
452
 
411
453
  no_commands do
454
+ def report_prunable(heading, entries)
455
+ return if entries.empty?
456
+
457
+ say "#{heading} (#{entries.size}):"
458
+ entries.sort_by { |entry| [entry.file.to_s, entry.line.to_i] }.each do |entry|
459
+ say " #{entry.location} #{entry.label}"
460
+ end
461
+ say ""
462
+ end
463
+
412
464
  def load_config
413
465
  Constable.config
414
466
  end
@@ -63,6 +63,16 @@ module Constable
63
63
  @raw = deep_merge(@raw, stringify(overrides || {}))
64
64
  end
65
65
 
66
+ # Merges values set in Ruby (Constable.configure) over the ones read from the file.
67
+ # Called once, after case_helper.rb has been loaded -- which is the first moment those
68
+ # values exist.
69
+ def apply_overrides!(overrides)
70
+ return self if overrides.nil? || overrides.empty?
71
+
72
+ @raw = deep_merge(@raw, stringify(overrides))
73
+ self
74
+ end
75
+
66
76
  def cold_cases = Array(@raw["cold_cases"])
67
77
  def warrants? = truthy(@raw["warrants"])
68
78
  # Negative retries are a typo for "off", not an instruction to count backwards.
@@ -103,11 +113,33 @@ module Constable
103
113
  def expanded_output? = output_mode == :expanded
104
114
  def storage = @raw["storage"] || {}
105
115
 
106
- def storage_adapter = (storage["adapter"] || "sqlite").to_s
107
- def storage_url = storage["url"]
116
+ # Storage is the one setting that cannot be written in Ruby -- the blotter is opened
117
+ # before test/case_helper.rb loads, so `constable jail` and `constable status` can read
118
+ # the docket without booting the app. That would leave .constable/config.yml mandatory
119
+ # for anyone not on the default SQLite, so the environment can say it instead:
120
+ #
121
+ # CONSTABLE_STORAGE_URL=postgres://user:pass@host/constable_metadata
122
+ # CONSTABLE_STORAGE_PATH=/var/lib/constable/blotter.sqlite3
123
+ #
124
+ # Which is also the right shape for CI, where the value is a secret and differs per
125
+ # machine. The environment wins over the file, as an environment usually should.
126
+ def storage_url = env_or("CONSTABLE_STORAGE_URL", storage["url"])
127
+
128
+ def storage_adapter
129
+ explicit = env_or("CONSTABLE_STORAGE_ADAPTER", storage["adapter"])
130
+ return explicit.to_s if explicit
131
+
132
+ # A URL with no adapter names its own: postgres://... can only mean postgres.
133
+ scheme = storage_url.to_s[%r{\A([a-z][a-z0-9+.-]*)://}, 1]
134
+ return "postgres" if %w[postgres postgresql].include?(scheme)
135
+ return "mysql" if %w[mysql mysql2].include?(scheme)
136
+
137
+ "sqlite"
138
+ end
108
139
 
109
140
  def storage_path
110
- path = storage["path"] || DEFAULTS["storage"]["path"]
141
+ path = env_or("CONSTABLE_STORAGE_PATH", storage["path"]) ||
142
+ DEFAULTS["storage"]["path"]
111
143
  File.absolute_path?(path) ? path : File.join(@root, path)
112
144
  end
113
145
 
@@ -145,6 +177,13 @@ module Constable
145
177
 
146
178
  private
147
179
 
180
+ # An empty environment variable is not a value. `CONSTABLE_STORAGE_URL=` in a CI
181
+ # config means "unset", not "connect to the empty string".
182
+ def env_or(name, fallback)
183
+ value = ENV.fetch(name, nil)
184
+ value.nil? || value.strip.empty? ? fallback : value.strip
185
+ end
186
+
148
187
  def truthy(value)
149
188
  return false if value.nil? || value == false
150
189
  return false if value.to_s.strip.downcase == "false"
@@ -241,6 +241,11 @@ module Constable
241
241
  # `constable jail release PATH:LINE`. Off the docket entirely, no supervision.
242
242
  def release(identity) = @storage.release(identity.to_s) ? true : false
243
243
 
244
+ # Pruning is not release. Release says "this test is trusted again"; prune says "this
245
+ # row is about a test that no longer exists". The distinction matters because release
246
+ # is a judgement someone made and prune is bookkeeping.
247
+ def forget(identity) = @storage.release(identity.to_s) ? true : false
248
+
244
249
  # `jail run` never auto-releases and never auto-paroles. One green run proves nothing;
245
250
  # it only earns a mention. A human reads this list and decides.
246
251
  #
@@ -360,6 +365,39 @@ module Constable
360
365
  matches
361
366
  end
362
367
 
368
+ # Rows whose test no longer exists.
369
+ #
370
+ # A test's key is a content hash of its body, so editing a jailed test gives it a new
371
+ # identity and leaves the old row behind -- pointing at a file:line that may now hold
372
+ # something else entirely. That is content-hash identity working as designed, but over
373
+ # a few months the docket fills with tests nobody can find.
374
+ #
375
+ # `known` is every identity the loaded suite registered, so this is only meaningful
376
+ # after a full load. Two things are deliberately conservative about it:
377
+ #
378
+ # * A known identity is never stale, even when the file recorded beside it is gone.
379
+ # The path is a display label; the identity is the truth. A test that moved file
380
+ # has an out-of-date label, not a missing test.
381
+ # * A cold case is never pruned while its file exists. Cold-case tests cannot be
382
+ # enumerated without running their own engine, so absence from `known` says
383
+ # nothing about them.
384
+ def stale_entries(known, cold_case: nil)
385
+ known = Array(known)
386
+ entries.reject { |entry| known.include?(entry.identity) }
387
+ .select { |entry| gone?(entry, cold_case) }
388
+ end
389
+
390
+ def gone?(entry, cold_case)
391
+ relative = entry.file.to_s
392
+ return true if relative.empty?
393
+
394
+ path = File.absolute_path?(relative) ? relative : File.join(Constable.root, relative)
395
+ return true unless File.exist?(path)
396
+ return false if cold_case&.call(relative)
397
+
398
+ true
399
+ end
400
+
363
401
  # An identity String, or nil when nothing matches -- or when more than one does.
364
402
  # Ambiguity is the caller's to report, with the candidates in hand.
365
403
  def resolve(target)
@@ -100,14 +100,16 @@ module Constable
100
100
  0x1F900..0x1F9FF, 0x20000..0x3FFFD
101
101
  ].freeze
102
102
 
103
- attr_reader :io, :config, :seed, :total, :mode
103
+ attr_reader :io, :config, :seed, :total
104
104
 
105
105
  def initialize(io: $stdout, config: nil, color: nil, slowest: DEFAULT_SLOWEST, mode: nil)
106
106
  @io = io
107
107
  @config = config || Constable.config
108
108
  @color = resolve_color(color)
109
109
  @slowest = slowest.to_i
110
- @mode = resolve_mode(mode)
110
+ # Resolved lazily: the reporter is built before case_helper.rb has run, so reading
111
+ # the config now would miss anything Constable.configure sets.
112
+ @requested_mode = mode
111
113
  @io.set_encoding(Encoding::UTF_8) if @io.respond_to?(:set_encoding)
112
114
 
113
115
  reset_stream!
@@ -210,7 +212,8 @@ module Constable
210
212
  def failed? = !success?
211
213
  def color? = @color
212
214
  def finished? = @finished
213
- def expanded? = @mode == :expanded
215
+ def mode = @mode ||= resolve_mode(@requested_mode)
216
+ def expanded? = mode == :expanded
214
217
 
215
218
  private
216
219
 
@@ -64,6 +64,19 @@ module Constable
64
64
  def jail_run? = @jail_run
65
65
  def coverage? = @coverage_requested
66
66
 
67
+ # Loads every case file and hands back the identities the suite actually defines,
68
+ # without running anything. `constable prune` needs this: which tests still exist is
69
+ # only knowable once the whole suite has been loaded.
70
+ def self.identities(config: Constable.config)
71
+ selection = Selection.new([], config: config, root: Constable.root, full: true)
72
+ runner = new(selection: selection, config: config,
73
+ reporter: Reporter.new(io: StringIO.new, config: config, color: false),
74
+ storage: Constable.storage, workers: 1)
75
+ runner.send(:load_suite!)
76
+ Constable.registry.disambiguate_identities!
77
+ Constable.registry.investigations.map(&:identity)
78
+ end
79
+
67
80
  # => Integer exit status (0 clean, 1 failures)
68
81
  def call
69
82
  # Before anything is loaded: Coverage only counts files required after it starts, so
@@ -143,6 +156,15 @@ module Constable
143
156
  .find { |p| File.exist?(p) }
144
157
  require helper if helper
145
158
 
159
+ # Between requiring the helper and asking the selection anything.
160
+ #
161
+ # Ordering is the whole point. case_helper.rb is where Constable.configure runs, so
162
+ # its settings do not exist until the line above. But Selection memoizes its targets
163
+ # the first time it is asked for them, and `cold_cases` is one of the settings people
164
+ # will most want to set in Ruby -- ask first and the override arrives too late to
165
+ # matter, silently. Requiring the helper needs no selection, so this fits between.
166
+ @config.apply_overrides!(Constable.configuration.overrides)
167
+
146
168
  @selection.native_targets.each { |target| load_case_file(target.path) }
147
169
  helper
148
170
  end
@@ -315,9 +337,17 @@ module Constable
315
337
  reader.close
316
338
 
317
339
  # Before a single test runs: build this worker's own database and point the
318
- # process at it. Raises rather than falling back to the shared one, because a
319
- # silent fallback is the bug we are here to prevent.
320
- WorkerDatabases.after_fork!(worker_index)
340
+ # process at it. Never falls back to the shared one -- that is the bug this
341
+ # exists to prevent -- but the failure is reported home rather than raised.
342
+ # A raise here dumps a full stack trace per worker and leaves the parent
343
+ # reporting a run that never happened.
344
+ begin
345
+ WorkerDatabases.after_fork!(worker_index)
346
+ rescue Constable::Error => e
347
+ write_message(writer, :worker_error, e.message)
348
+ writer.close
349
+ exit!(0)
350
+ end
321
351
 
322
352
  bucket.each do |item|
323
353
  run_item(item).each { |result| write_message(writer, :result, result.to_h) }
@@ -343,6 +373,17 @@ module Constable
343
373
  collected = drain(readers)
344
374
  pids.each { |pid| Process.waitpid(pid) rescue nil } # rubocop:disable Style/RescueModifier
345
375
 
376
+ # No worker could build itself a database, so no test ran. Not every app can be
377
+ # sharded: an app whose schema.rb cannot rebuild the database on its own -- Postgres
378
+ # custom types, functions and triggers are the usual reason, and are exactly why
379
+ # such apps use structure.sql -- will fail here every time. Rails' own `parallelize`
380
+ # fails the same way; the difference is that this is not the user's fault and they
381
+ # should not have to read four stack traces to find that out.
382
+ #
383
+ # Nothing has run yet, so falling back to a serial run costs a restart, not
384
+ # correctness.
385
+ return run_serially_after_worker_failure(items) if collected.empty? && worker_errors.any?
386
+
346
387
  # A warning raised inside a worker only ever reached that worker's memory, so the
347
388
  # results carry them home. Nothing that bends the rules is allowed to go missing
348
389
  # just because it happened in a subprocess.
@@ -350,6 +391,25 @@ module Constable
350
391
  collected
351
392
  end
352
393
 
394
+ def worker_errors = (@worker_errors ||= [])
395
+
396
+ def run_serially_after_worker_failure(items)
397
+ reason = worker_errors.first.to_s
398
+
399
+ Constable.warn!(
400
+ "no parallel worker could build its own test database, so the suite ran serially " \
401
+ "instead. This usually means the app's schema cannot rebuild the database by " \
402
+ "itself -- Postgres custom types, functions and triggers are the common reason, " \
403
+ "and `rails test` parallelization fails the same way. Set `parallel_workers: 1` " \
404
+ "to skip the attempt. The first worker said: #{reason}",
405
+ kind: :parallel
406
+ )
407
+
408
+ # The blotter handle was closed before forking, and the pool was cleared. Both come
409
+ # back on their next use, so there is nothing to reopen by hand.
410
+ run_serial(items)
411
+ end
412
+
353
413
  # Every message on the pipe is tagged, because results are not the only thing a worker
354
414
  # has to send home.
355
415
  def write_message(writer, kind, body)
@@ -394,6 +454,10 @@ module Constable
394
454
  @reporter.record(result)
395
455
  when :coverage
396
456
  @worker_coverage = Constable::Coverage.merge_raw(@worker_coverage, body)
457
+ when :worker_error
458
+ # A worker that could not start. Collected rather than raised, so the parent
459
+ # decides what to do once it knows whether any worker got going at all.
460
+ worker_errors << body
397
461
  end
398
462
  end
399
463
  end
@@ -17,6 +17,22 @@ module Constable
17
17
  def to_s = line ? "#{path}:#{line}" : path.to_s
18
18
  end
19
19
 
20
+ # A filename is not evidence. `spec/**/*_case.rb` is a generous net, and in a real app
21
+ # it catches things that merely share the suffix -- a FactoryBot factory named
22
+ # spec/factories/distributed_case.rb, say. Loading one of those runs somebody's code
23
+ # twice and reports the resulting explosion as a failing test in a file that contains
24
+ # no tests.
25
+ #
26
+ # So the file has to look like a case before we load it: a class declaration, or the
27
+ # DSL. Deliberately a cheap read rather than a parse -- this runs over every candidate
28
+ # on every run, and anything a parse would catch that this misses would also have to
29
+ # be a file that defines a case without mentioning one.
30
+ NATIVE_MARKERS = /
31
+ <\s*(?:Constable::Case|\w*Case)\b # class FooCase < UnitCase
32
+ | ^\s*investigate\s*[("] # or the DSL, for a reopened class
33
+ | ^\s*tier\s+:
34
+ /x
35
+
20
36
  NATIVE_GLOBS = [
21
37
  "test/cases/**/*.rb",
22
38
  "spec/cases/**/*.rb",
@@ -183,6 +199,17 @@ module Constable
183
199
 
184
200
  def native_files
185
201
  @native_files ||= glob(NATIVE_GLOBS).reject { |f| @config.cold_case?(f) }
202
+ .select { |f| native_by_content?(f) }
203
+ end
204
+
205
+ def native_by_content?(path)
206
+ # Files under the conventional case directories are taken at their word: that is
207
+ # what the directory is for, and an empty one there is a case file being written.
208
+ return true if path.match?(%r{/(?:test|spec)/cases/})
209
+
210
+ File.read(path).match?(NATIVE_MARKERS)
211
+ rescue StandardError
212
+ false
186
213
  end
187
214
 
188
215
  def cold_files
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Constable
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
@@ -244,6 +244,39 @@ module Constable
244
244
  matches
245
245
  end
246
246
 
247
+ # Rows whose test no longer exists.
248
+ #
249
+ # A test's key is a content hash of its body, so editing a jailed test gives it a new
250
+ # identity and leaves the old row behind -- pointing at a file:line that may now hold
251
+ # something else entirely. That is content-hash identity working as designed, but over
252
+ # a few months the docket fills with tests nobody can find.
253
+ #
254
+ # `known` is every identity the loaded suite registered, so this is only meaningful
255
+ # after a full load. Two things are deliberately conservative about it:
256
+ #
257
+ # * A known identity is never stale, even when the file recorded beside it is gone.
258
+ # The path is a display label; the identity is the truth. A test that moved file
259
+ # has an out-of-date label, not a missing test.
260
+ # * A cold case is never pruned while its file exists. Cold-case tests cannot be
261
+ # enumerated without running their own engine, so absence from `known` says
262
+ # nothing about them.
263
+ def stale_entries(known, cold_case: nil)
264
+ known = Array(known)
265
+ entries.reject { |entry| known.include?(entry.identity) }
266
+ .select { |entry| gone?(entry, cold_case) }
267
+ end
268
+
269
+ def gone?(entry, cold_case)
270
+ relative = entry.file.to_s
271
+ return true if relative.empty?
272
+
273
+ path = File.absolute_path?(relative) ? relative : File.join(Constable.root, relative)
274
+ return true unless File.exist?(path)
275
+ return false if cold_case&.call(relative)
276
+
277
+ true
278
+ end
279
+
247
280
  def resolve(target)
248
281
  matches = candidates(target)
249
282
  return matches.first.identity if matches.size == 1
data/lib/constable.rb CHANGED
@@ -130,14 +130,60 @@ module Constable
130
130
  end
131
131
 
132
132
  # Code-level configuration set from test/case_helper.rb.
133
+ # The Ruby half of configuration, set from test/case_helper.rb.
134
+ #
135
+ # Anything that is *code* -- custom matchers, tier base classes, one-time global setup --
136
+ # can only live here. Everything that is merely a *setting* can live in either place, and
137
+ # this is the one that wins:
138
+ #
139
+ # a CLI flag for one run
140
+ # Constable.configure in case_helper.rb, because it is code and ran deliberately
141
+ # .constable/config.yml the declared default for the project
142
+ # Constable's defaults
143
+ #
144
+ # Until 1.1.0 the four accessors here were read by nothing at all: `c.parallel_workers = 4`
145
+ # was in the generated case_helper.rb, documented, and silently ignored.
133
146
  class Configuration
134
- attr_accessor :parallel_workers, :seed, :coverage, :warrants
147
+ # Every setting .constable/config.yml understands, settable in Ruby as well. A nil is
148
+ # "not set here" rather than "set to nothing", so leaving one alone defers to the file.
149
+ SETTINGS = %i[
150
+ cold_cases warrants warrant_retries auto_relink parole_period
151
+ coverage coverage_threshold coverage_html fail_on_warnings parallel_workers
152
+ output tiers
153
+ ].freeze
154
+
155
+ # `storage` is the one setting that cannot live here, and the reason is ordering, not
156
+ # preference: the blotter handle is opened before test/case_helper.rb is loaded,
157
+ # because `constable jail`, `warrants`, `watchlist` and `status` all read the docket
158
+ # without booting the app at all. By the time this block runs, it is already open.
159
+ #
160
+ # Raising beats accepting the value and quietly using the old path -- silently
161
+ # ignoring a setting somebody wrote is the failure mode this whole class was fixed for.
162
+ SETTINGS_ONLY_IN_YAML = %i[storage].freeze
163
+
164
+ attr_accessor(*SETTINGS, :seed)
165
+
166
+ def storage=(_value)
167
+ raise ConfigurationError,
168
+ "storage must be set in .constable/config.yml, not Constable.configure. The " \
169
+ "blotter is opened before case_helper.rb loads, so that `constable jail` and " \
170
+ "`constable status` can read the docket without booting the app -- by the " \
171
+ "time this block runs the connection is already open."
172
+ end
135
173
 
136
174
  def initialize
137
175
  @before_suite_hooks = []
138
176
  @after_suite_hooks = []
139
177
  end
140
178
 
179
+ # Only the settings actually assigned, ready to merge over the file's values.
180
+ def overrides
181
+ SETTINGS.each_with_object({}) do |name, out|
182
+ value = public_send(name)
183
+ out[name.to_s] = value unless value.nil?
184
+ end
185
+ end
186
+
141
187
  def before_suite(&block) = @before_suite_hooks << block
142
188
  def after_suite(&block) = @after_suite_hooks << block
143
189
 
@@ -45,6 +45,8 @@ module Constable
45
45
  desc: "Don't write the example case under test/cases/"
46
46
  class_option :skip_support, type: :boolean, default: false,
47
47
  desc: "Don't write the example files under test/support/"
48
+ class_option :skip_config, type: :boolean, default: false,
49
+ desc: "Don't write .constable/config.yml -- configure in Ruby instead"
48
50
 
49
51
  COLD_CASE_HEADER = <<~RUBY
50
52
  # Cold cases: your existing RSpec/Minitest files, run verbatim through their own
@@ -72,6 +74,15 @@ module Constable
72
74
  /.constable/*.sqlite3-*
73
75
  TEXT
74
76
 
77
+ NEW_SETTINGS_HEADER = <<~TEXT
78
+ # ---------------------------------------------------------------------------
79
+ # Added by `rails generate constable:install` on a later upgrade. These settings
80
+ # did not exist when this file was written; each is shown at its default, so
81
+ # deleting any of them changes nothing.
82
+ # ---------------------------------------------------------------------------
83
+
84
+ TEXT
85
+
75
86
  RUBOCOP_EXTENSION = "rubocop-constable"
76
87
 
77
88
  def create_case_helper
@@ -85,8 +96,32 @@ module Constable
85
96
  template "authenticatable.rb.tt", "test/support/authenticatable.rb"
86
97
  end
87
98
 
99
+ # The config file doubles as the reference -- every key at its default, with the
100
+ # reasoning above it -- which only works if it stays current. A gem upgrade cannot
101
+ # rewrite it (that would clobber your settings) and Thor's only other answer is to
102
+ # skip the file entirely, so before 1.1.0 a setting added after you installed was
103
+ # invisible: `output` shipped in 1.0.0 and never appeared in an existing config.
104
+ #
105
+ # So: create it if it is missing, and otherwise append only the settings it does not
106
+ # already mention. Your edits and comments are never touched.
88
107
  def create_config
89
- template "config.yml.tt", ".constable/config.yml"
108
+ if options[:skip_config]
109
+ say_status :skip, ".constable/config.yml (configure in test/case_helper.rb instead)", :blue
110
+ return
111
+ end
112
+
113
+ path = File.join(destination_root, ".constable/config.yml")
114
+ return template("config.yml.tt", ".constable/config.yml") unless File.exist?(path)
115
+
116
+ missing = missing_config_blocks(File.read(path))
117
+ if missing.empty?
118
+ say_status :identical, ".constable/config.yml (every setting is documented)", :blue
119
+ return
120
+ end
121
+
122
+ names = missing.flat_map { |block| config_keys_in(block) }
123
+ say_status :append, ".constable/config.yml (#{names.join(", ")})", :green
124
+ append_to_file ".constable/config.yml", "\n#{NEW_SETTINGS_HEADER}#{missing.join("\n\n")}\n"
90
125
  end
91
126
 
92
127
  def create_example_case
@@ -204,6 +239,31 @@ module Constable
204
239
  end
205
240
  end
206
241
 
242
+ # Blocks in the shipped reference whose settings the existing file never mentions.
243
+ # A "block" is a run of lines between blank ones: the comment and the setting it
244
+ # explains travel together, because a bare key with no reasoning is not a reference.
245
+ def missing_config_blocks(existing)
246
+ present = config_keys_in(existing)
247
+
248
+ reference_blocks.select do |block|
249
+ keys = config_keys_in(block)
250
+ keys.any? && (keys - present) == keys
251
+ end
252
+ end
253
+
254
+ def reference_blocks
255
+ File.read(find_in_source_paths("config.yml.tt")).split(/\n{2,}/).map(&:rstrip).reject(&:empty?)
256
+ end
257
+
258
+ # Top-level YAML keys only, ignoring comments and nested ones -- a commented-out
259
+ # example is a suggestion, not a declaration.
260
+ def config_keys_in(text)
261
+ text.lines.filter_map do |line|
262
+ match = line.match(/\A([a-z][a-z0-9_]*):/)
263
+ match && match[1]
264
+ end.uniq
265
+ end
266
+
207
267
  def gemfile_contents
208
268
  File.read(File.join(destination_root, "Gemfile"))
209
269
  end
@@ -139,16 +139,49 @@ end
139
139
  # Code-level configuration.
140
140
  # -----------------------------------------------------------------------------
141
141
 
142
+ # Code only, by default: matchers and suite hooks can be written nowhere else.
143
+ #
144
+ # Settings can be written here too, and this file wins over
145
+ # .constable/config.yml -- a CLI flag wins over both, for one run. Use the file
146
+ # for values that differ per project or per branch, where being greppable
147
+ # without running anything is worth something. Use Ruby when the value has to be
148
+ # computed, which YAML cannot do:
149
+ #
150
+ # c.parallel_workers = ENV.fetch("CI_WORKERS", 4).to_i
151
+ # c.coverage = ENV["CI"] == "true"
152
+ #
153
+ # Every setting, at its default -- uncomment what you want and delete the rest.
154
+ # `.constable/config.yml` explains each one at length and is optional; between
155
+ # the two, this file can be the only place you configure Constable.
156
+ #
157
+ # c.cold_cases = [] # RSpec/Minitest globs to run as cold cases
158
+ # c.parallel_workers = "auto" # or an integer. Each worker gets its own database
159
+ # c.output = :concise # or :expanded -- a line per test, with timings
160
+ # c.fail_on_warnings = false # CI: fail when the warning count is not trending down
161
+ # c.warrants = false # rerun a failure in isolation before believing it
162
+ # c.warrant_retries = 5
163
+ # c.parole_period = 10 # clean runs before a paroled test releases itself
164
+ # c.auto_relink = false # auto-confirm rename detection
165
+ # c.coverage = false
166
+ # c.coverage_threshold = 90 # diff-based: only lines changed in this diff
167
+ # c.coverage_html = false
168
+ # c.tiers = { "unit" => "test/cases/models/**/*" }
169
+ #
170
+ # `storage` is the one setting that cannot go here: the blotter is opened before
171
+ # this file loads, so `constable jail` and `constable status` can read the docket
172
+ # without booting the app. Setting it here raises rather than being ignored. Use
173
+ # .constable/config.yml, or CONSTABLE_STORAGE_URL / _PATH / _ADAPTER.
174
+ #
175
+ # Not config/initializers/: initializers run on every boot including production,
176
+ # where a test-only gem is not in the bundle. Same reason RSpec, SimpleCov and
177
+ # WebMock all configure from the test helper rather than an initializer.
178
+ # -----------------------------------------------------------------------------
179
+
142
180
  Constable.configure do |c|
143
- # Workers default to `auto` -- processor count minus a little headroom, so the
144
- # machine stays usable while the suite runs. Pin it only when a CI container
145
- # lies about its core count.
146
- # c.parallel_workers = 4
147
-
148
- # One-time global setup, run once per process before the whole suite. This is
149
- # for configuring the world -- drivers, adapters, formats -- and deliberately
150
- # not for creating records that tests then share. See the note at the bottom
151
- # of this file about why that distinction is the whole ballgame.
181
+ # One-time global setup, run once per process before the whole suite. For
182
+ # configuring the world -- drivers, adapters, formats -- and deliberately not
183
+ # for creating records that tests then share. See the note at the bottom of
184
+ # this file about why that distinction is the whole ballgame.
152
185
  #
153
186
  # c.before_suite do
154
187
  # Capybara.default_driver = :rack_test
@@ -1,13 +1,29 @@
1
1
  # .constable/config.yml
2
2
  #
3
- # Settings, as opposed to code. Everything here is a number, a flag or a glob;
4
- # anything that is Ruby -- custom matchers, tier base classes, one-time global
5
- # setup -- lives in test/case_helper.rb instead.
3
+ # Optional. Everything here can also be written in Ruby, in test/case_helper.rb:
6
4
  #
7
- # Every key Constable understands is present below at its default value, so this
8
- # file doubles as the complete reference. Delete anything you haven't changed;
9
- # the behavior is identical either way. CLI flags win over this file for the
10
- # duration of a single run.
5
+ # Constable.configure do |c|
6
+ # c.parallel_workers = ENV.fetch("CI_WORKERS", 4).to_i
7
+ # c.output = :expanded
8
+ # end
9
+ #
10
+ # ...which is the better home for anything computed, and the more familiar one if you
11
+ # come from RSpec's spec_helper.rb. Ruby wins over this file; a CLI flag wins over both.
12
+ #
13
+ # Two reasons this file exists at all:
14
+ #
15
+ # 1. `storage` cannot go anywhere else. The blotter is opened before case_helper.rb
16
+ # loads, so that `constable jail`, `warrants`, `watchlist` and `status` can read the
17
+ # docket without booting the app -- a broken app should not stop you reading the
18
+ # docket. Setting it in Ruby raises rather than being quietly ignored.
19
+ #
20
+ # 2. A settings file is greppable and diffable without executing anything, which is
21
+ # what you want for the values that differ per project or per branch.
22
+ #
23
+ # Every key Constable understands is below at its default, so this doubles as the
24
+ # reference. Delete anything you have not changed; the behavior is identical either way.
25
+ # Re-run `rails generate constable:install --skip` after upgrading and any settings added
26
+ # since will be appended here, leaving your own values and comments alone.
11
27
 
12
28
  # Glob paths to run as cold cases: original RSpec/Minitest files, driven verbatim
13
29
  # through their own real engine, with pass/fail/timing fed into Constable's
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: constable-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes