constable-rails 0.1.0 → 1.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.
@@ -19,6 +19,7 @@ module Constable
19
19
  # test/cases/example_case.rb a worked case so `constable test` does something
20
20
  # .constable/config.yml every setting, at its default, as a reference
21
21
  # .rubocop.yml the linter, merged into yours if you have one
22
+ # .gitignore two lines, so the blotter stays local
22
23
  #
23
24
  # Plus the optional :cold_case Gemfile group, but only when there is actually
24
25
  # an RSpec or Minitest suite in the repo to import. Installing the gem into a
@@ -44,18 +45,44 @@ module Constable
44
45
  desc: "Don't write the example case under test/cases/"
45
46
  class_option :skip_support, type: :boolean, default: false,
46
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"
47
50
 
48
- COLD_CASE_GROUP = <<~RUBY
51
+ COLD_CASE_HEADER = <<~RUBY
49
52
  # Cold cases: your existing RSpec/Minitest files, run verbatim through their own
50
53
  # real engine, with results merged into Constable's reporting and CI gate. These
51
- # two gems are needed only for as long as cold cases exist -- delete the group
52
- # once the suite is fully modernized and both dependencies drop out with it.
53
- group :cold_case do
54
- gem "rspec-rails"
55
- gem "minitest"
56
- end
54
+ # gems are needed only for as long as cold cases exist -- delete the group once
55
+ # the suite is fully modernized and the dependencies drop out with it.
57
56
  RUBY
58
57
 
58
+ # Only the engines this repo actually has files for, and only ones the Gemfile does
59
+ # not already declare. An app adopting Constable *from RSpec* -- which is most of
60
+ # them -- already has rspec-rails, and declaring it twice is not a style question:
61
+ # Bundler refuses to parse the file at all, so the install leaves the app unbootable.
62
+ COLD_CASE_GEMS = { rspec: "rspec-rails", minitest: "minitest" }.freeze
63
+
64
+ # Per engine, so a repo with only RSpec files does not get minitest added to its
65
+ # Gemfile for a migration it is never going to do.
66
+ LEGACY_GLOBS = { rspec: "spec/**/*_spec.rb", minitest: "test/**/*_test.rb" }.freeze
67
+
68
+ # The blotter is machine state: this laptop's flake history and jail docket. Sharing
69
+ # it through git would hand CI somebody else's docket and conflict on every run.
70
+ GITIGNORE_ENTRY = <<~TEXT
71
+ # Constable's blotter -- flake history, the jail docket, warrants. Local state:
72
+ # each machine keeps its own, and CI starts clean.
73
+ /.constable/*.sqlite3
74
+ /.constable/*.sqlite3-*
75
+ TEXT
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
+
59
86
  RUBOCOP_EXTENSION = "rubocop-constable"
60
87
 
61
88
  def create_case_helper
@@ -69,8 +96,32 @@ module Constable
69
96
  template "authenticatable.rb.tt", "test/support/authenticatable.rb"
70
97
  end
71
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.
72
107
  def create_config
73
- 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"
74
125
  end
75
126
 
76
127
  def create_example_case
@@ -101,7 +152,31 @@ module Constable
101
152
  return
102
153
  end
103
154
 
104
- append_to_file "Gemfile", "\n#{COLD_CASE_GROUP}"
155
+ gems = cold_case_gems_to_add
156
+ if gems.empty?
157
+ say_status :skip, "Gemfile (every cold-case engine is already declared)", :blue
158
+ return
159
+ end
160
+
161
+ append_to_file "Gemfile", "\n#{cold_case_group(gems)}"
162
+ end
163
+
164
+ # The blotter must not be committed. Appended rather than templated, because an app
165
+ # always has a .gitignore already and ours is two lines of it.
166
+ def ignore_the_blotter
167
+ path = File.join(destination_root, ".gitignore")
168
+
169
+ unless File.exist?(path)
170
+ create_file ".gitignore", GITIGNORE_ENTRY
171
+ return
172
+ end
173
+
174
+ if File.read(path).include?("/.constable/*.sqlite3")
175
+ say_status :identical, ".gitignore (blotter already ignored)", :blue
176
+ return
177
+ end
178
+
179
+ append_to_file ".gitignore", "\n#{GITIGNORE_ENTRY}"
105
180
  end
106
181
 
107
182
  # An app that already lints has opinions in .rubocop.yml worth more than
@@ -137,6 +212,58 @@ module Constable
137
212
 
138
213
  private
139
214
 
215
+ # An engine earns a line only if this repo has files for it and the Gemfile does not
216
+ # already declare it.
217
+ def cold_case_gems_to_add
218
+ COLD_CASE_GEMS.filter_map do |engine, gem_name|
219
+ next unless legacy_files?(engine)
220
+ next if gem_declared?(gem_name)
221
+
222
+ gem_name
223
+ end
224
+ end
225
+
226
+ def cold_case_group(gems)
227
+ lines = gems.map { |gem_name| %( gem "#{gem_name}") }
228
+ "#{COLD_CASE_HEADER}group :cold_case do\n#{lines.join("\n")}\nend\n"
229
+ end
230
+
231
+ # Matches `gem "rspec-rails"` and `gem 'rspec-rails', "~> 8.0"` alike, and ignores a
232
+ # commented-out line, which is a suggestion rather than a declaration.
233
+ def gem_declared?(gem_name)
234
+ gemfile_contents.each_line.any? do |line|
235
+ stripped = line.strip
236
+ next false if stripped.start_with?("#")
237
+
238
+ stripped.match?(/\Agem\s+["']#{Regexp.escape(gem_name)}["']/)
239
+ end
240
+ end
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
+
140
267
  def gemfile_contents
141
268
  File.read(File.join(destination_root, "Gemfile"))
142
269
  end
@@ -148,6 +275,10 @@ module Constable
148
275
  Dir.glob(File.join(destination_root, "{spec,test}/**/*_{spec,test}.rb")).any?
149
276
  end
150
277
 
278
+ def legacy_files?(engine)
279
+ Dir.glob(File.join(destination_root, LEGACY_GLOBS.fetch(engine))).any?
280
+ end
281
+
151
282
  # A textual merge, not a YAML round trip: parsing and re-emitting someone's
152
283
  # .rubocop.yml would silently eat every comment in it, and comments in a
153
284
  # lint config are usually the reason a rule is there at all.
@@ -67,9 +67,37 @@ end
67
67
  # reimplementation of them would be a slightly wrong one.
68
68
  # -----------------------------------------------------------------------------
69
69
 
70
+ # -----------------------------------------------------------------------------
71
+ # Support files -- shared modules and custom matchers.
72
+ #
73
+ # Same role RSpec's spec/support/**/*.rb plays. Shared behavior *across* files
74
+ # is a plain module you `include`; there is deliberately no shared-examples DSL
75
+ # here, because Ruby's own composition tools already do that job with fewer
76
+ # rules to learn and more flexibility once the reuse stops being simple.
77
+ #
78
+ # Sorted on purpose: Dir[] returns filesystem order, which differs between your
79
+ # laptop and CI. A suite that loads its own support files in an unpredictable
80
+ # order has already lost the argument about determinism.
81
+ #
82
+ # Loaded BEFORE the tier classes below, so `include Authenticatable` on a tier
83
+ # is a thing you can actually write. Loading them afterwards would make the
84
+ # advice in test/support/authenticatable.rb raise NameError.
85
+ # -----------------------------------------------------------------------------
86
+
87
+ Dir[Rails.root.join("test/support/**/*.rb")].sort.each { |f| require f }
88
+
89
+ # Factories, when the app has them. `create(:user)` is what a converted spec will be
90
+ # full of, and a NoMethodError on the first native run is a bad way to learn that the
91
+ # include was missing. Guarded, so a suite with no factory gem is unaffected.
92
+ module CaseFactories
93
+ include FactoryBot::Syntax::Methods if defined?(FactoryBot::Syntax::Methods)
94
+ end
95
+
70
96
  # No database, no request stack, no browser -- just Ruby. The fastest thing
71
97
  # Constable can run, and where most of a suite should live.
72
98
  class UnitCase < Constable::Case
99
+ include CaseFactories
100
+
73
101
  tier :unit
74
102
  end
75
103
 
@@ -78,6 +106,12 @@ end
78
106
  # investigation runs inside a transaction that is rolled back afterwards.
79
107
  class IntegrationCase < Constable::Case
80
108
  include Constable::RailsSupport::Integration
109
+ include CaseFactories
110
+
111
+ # Every integration case gets the app's sign-in helpers. Uncomment once
112
+ # test/support/authenticatable.rb says something true about your app:
113
+ #
114
+ # include Authenticatable
81
115
 
82
116
  tier :integration
83
117
  end
@@ -87,6 +121,8 @@ end
87
121
  # and Rails' own `driven_by`. There is no `response` here -- a system case looks
88
122
  # at the rendered page, which is the whole reason to pay for a browser.
89
123
  class SystemCase < Constable::Case
124
+ include CaseFactories
125
+
90
126
  # Capybara is not a Constable dependency. System cases are opt-in, and a suite
91
127
  # with no browser tests shouldn't be made to install a browser driver to boot.
92
128
  include Constable::RailsSupport::System if defined?(Capybara)
@@ -100,34 +136,52 @@ class SystemCase < Constable::Case
100
136
  end
101
137
 
102
138
  # -----------------------------------------------------------------------------
103
- # Support files -- shared modules and custom matchers.
104
- #
105
- # Same role RSpec's spec/support/**/*.rb plays. Shared behavior *across* files
106
- # is a plain module you `include`; there is deliberately no shared-examples DSL
107
- # here, because Ruby's own composition tools already do that job with fewer
108
- # rules to learn and more flexibility once the reuse stops being simple.
109
- #
110
- # Sorted on purpose: Dir[] returns filesystem order, which differs between your
111
- # laptop and CI. A suite that loads its own support files in an unpredictable
112
- # order has already lost the argument about determinism.
139
+ # Code-level configuration.
113
140
  # -----------------------------------------------------------------------------
114
141
 
115
- Dir[Rails.root.join("test/support/**/*.rb")].sort.each { |f| require f }
116
-
117
- # -----------------------------------------------------------------------------
118
- # Code-level configuration.
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.
119
178
  # -----------------------------------------------------------------------------
120
179
 
121
180
  Constable.configure do |c|
122
- # Workers default to `auto` -- processor count minus a little headroom, so the
123
- # machine stays usable while the suite runs. Pin it only when a CI container
124
- # lies about its core count.
125
- # c.parallel_workers = 4
126
-
127
- # One-time global setup, run once per process before the whole suite. This is
128
- # for configuring the world -- drivers, adapters, formats -- and deliberately
129
- # not for creating records that tests then share. See the note at the bottom
130
- # 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.
131
185
  #
132
186
  # c.before_suite do
133
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
@@ -57,6 +73,15 @@ fail_on_warnings: false # CI: fail the build when the warning count doesn't tre
57
73
 
58
74
  parallel_workers: auto # or an explicit integer
59
75
 
76
+ # How much the live stream says while the suite is running. The summary is identical
77
+ # either way -- this only changes what you watch on the way there.
78
+ #
79
+ # concise one glyph per test, grouped into a run per case. A thousand tests stay
80
+ # inside one screen, and a wall of green is the point.
81
+ # expanded a line per test: glyph, name, duration. Slower to read in bulk, but you
82
+ # can see which test is hanging while it hangs, rather than after.
83
+ output: concise # concise | expanded
84
+
60
85
  # Fallback path-based tier inference, used only when a case doesn't inherit from
61
86
  # a tiered base class. The base classes in test/case_helper.rb are the primary
62
87
  # mechanism and always win -- this is here so an un-migrated file still lands in
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: constable-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-07 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -96,7 +96,7 @@ description: |
96
96
  The gem is published as "constable-rails"; everything inside it -- the module, the
97
97
  CLI, the config directory -- is simply "constable".
98
98
  email:
99
- - r.hughes2136@gmail.com
99
+ - raymond.hughes@live.com
100
100
  executables:
101
101
  - constable
102
102
  extensions: []
@@ -141,6 +141,7 @@ files:
141
141
  - lib/constable/storage/sqlite_adapter.rb
142
142
  - lib/constable/version.rb
143
143
  - lib/constable/warrants.rb
144
+ - lib/constable/worker_databases.rb
144
145
  - lib/generators/constable/base.rb
145
146
  - lib/generators/constable/channel/channel_generator.rb
146
147
  - lib/generators/constable/channel/templates/channel_case.rb.tt