constable-rails 0.1.0 → 1.0.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 +4 -4
- data/CHANGELOG.md +159 -1
- data/README.md +129 -13
- data/lib/constable/case.rb +33 -0
- data/lib/constable/cli.rb +38 -5
- data/lib/constable/cold_case/rspec.rb +93 -1
- data/lib/constable/config.rb +53 -5
- data/lib/constable/identity.rb +19 -0
- data/lib/constable/importer/modernizer.rb +40 -0
- data/lib/constable/investigation.rb +10 -0
- data/lib/constable/jail.rb +34 -7
- data/lib/constable/matchers.rb +206 -2
- data/lib/constable/registry.rb +24 -0
- data/lib/constable/reporter.rb +230 -4
- data/lib/constable/runner.rb +131 -3
- data/lib/constable/selection.rb +23 -1
- data/lib/constable/storage/sqlite_adapter.rb +21 -1
- data/lib/constable/version.rb +1 -1
- data/lib/constable/warrants.rb +23 -5
- data/lib/constable/worker_databases.rb +83 -0
- data/lib/constable.rb +1 -0
- data/lib/generators/constable/install_generator.rb +79 -8
- data/lib/generators/constable/templates/case_helper.rb.tt +36 -15
- data/lib/generators/constable/templates/config.yml.tt +9 -0
- metadata +4 -3
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
# Per-worker databases for parallel runs.
|
|
5
|
+
#
|
|
6
|
+
# Forking N workers that all talk to one database is not a speed/safety trade, it is a
|
|
7
|
+
# correctness bug. On SQLite it shows up immediately and honestly -- every worker
|
|
8
|
+
# contends for the same file and the run dissolves into
|
|
9
|
+
# `SQLite3::BusyException: database is locked` -- and on a client/server database it
|
|
10
|
+
# shows up later and far worse, as tests seeing each other's rows.
|
|
11
|
+
#
|
|
12
|
+
# Rails already solved this for `rails test`: each worker gets its own database, named
|
|
13
|
+
# by appending the worker index, rebuilt from schema. This is the same thing, driven by
|
|
14
|
+
# Constable's runner rather than by ActiveSupport::Testing::Parallelization, because
|
|
15
|
+
# Constable does its own forking.
|
|
16
|
+
#
|
|
17
|
+
# Everything here is defensive. Constable runs in apps with no ActiveRecord at all --
|
|
18
|
+
# that is the whole point of the :unit tier -- so every entry point answers "no" rather
|
|
19
|
+
# than raising when the pieces are missing, and the runner falls back to a serial run.
|
|
20
|
+
module WorkerDatabases
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Is there an ActiveRecord in this process whose databases would be shared by forks?
|
|
24
|
+
def active_record?
|
|
25
|
+
defined?(::ActiveRecord::Base) ? true : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Can we actually give each worker its own database? Rails ships the machinery in
|
|
29
|
+
# active_record/test_databases, which is only loaded when someone asks for parallel
|
|
30
|
+
# tests -- so ask for it here rather than assuming.
|
|
31
|
+
def shardable?
|
|
32
|
+
return false unless active_record?
|
|
33
|
+
|
|
34
|
+
load_test_databases!
|
|
35
|
+
defined?(::ActiveRecord::TestDatabases) ? true : false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Parent side, before the fork. A child inheriting a live connection is a corruption
|
|
39
|
+
# risk in exactly the way an inherited SQLite handle is.
|
|
40
|
+
def before_fork!
|
|
41
|
+
return false unless active_record?
|
|
42
|
+
|
|
43
|
+
::ActiveRecord::Base.connection_handler.clear_all_connections!
|
|
44
|
+
true
|
|
45
|
+
rescue StandardError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Child side, immediately after the fork and before any test runs. Builds
|
|
50
|
+
# `<database>_<index>` from schema and points this process at it.
|
|
51
|
+
#
|
|
52
|
+
# ENV["VERBOSE"] is silenced the way Rails silences it: schema loading is chatty, and
|
|
53
|
+
# stdout belongs to the reporter.
|
|
54
|
+
def after_fork!(index)
|
|
55
|
+
return false unless shardable?
|
|
56
|
+
|
|
57
|
+
::ActiveRecord::TestDatabases.create_and_load_schema(index, env_name: env_name)
|
|
58
|
+
true
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
# A worker that cannot build its own database would otherwise silently fall back to
|
|
61
|
+
# sharing the parent's, which is the bug this module exists to prevent. Say so, and
|
|
62
|
+
# let the failure be a real one.
|
|
63
|
+
raise Constable::Error, "worker #{index} could not create its own test database: " \
|
|
64
|
+
"#{e.class}: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def env_name
|
|
68
|
+
if defined?(::ActiveRecord::ConnectionHandling::DEFAULT_ENV)
|
|
69
|
+
::ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
|
|
70
|
+
else
|
|
71
|
+
ENV["RAILS_ENV"] || "test"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def load_test_databases!
|
|
76
|
+
return if defined?(::ActiveRecord::TestDatabases)
|
|
77
|
+
|
|
78
|
+
require "active_record/test_databases"
|
|
79
|
+
rescue LoadError, StandardError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/constable.rb
CHANGED
|
@@ -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
|
|
@@ -45,17 +46,32 @@ module Constable
|
|
|
45
46
|
class_option :skip_support, type: :boolean, default: false,
|
|
46
47
|
desc: "Don't write the example files under test/support/"
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
COLD_CASE_HEADER = <<~RUBY
|
|
49
50
|
# Cold cases: your existing RSpec/Minitest files, run verbatim through their own
|
|
50
51
|
# real engine, with results merged into Constable's reporting and CI gate. These
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
group :cold_case do
|
|
54
|
-
gem "rspec-rails"
|
|
55
|
-
gem "minitest"
|
|
56
|
-
end
|
|
52
|
+
# gems are needed only for as long as cold cases exist -- delete the group once
|
|
53
|
+
# the suite is fully modernized and the dependencies drop out with it.
|
|
57
54
|
RUBY
|
|
58
55
|
|
|
56
|
+
# Only the engines this repo actually has files for, and only ones the Gemfile does
|
|
57
|
+
# not already declare. An app adopting Constable *from RSpec* -- which is most of
|
|
58
|
+
# them -- already has rspec-rails, and declaring it twice is not a style question:
|
|
59
|
+
# Bundler refuses to parse the file at all, so the install leaves the app unbootable.
|
|
60
|
+
COLD_CASE_GEMS = { rspec: "rspec-rails", minitest: "minitest" }.freeze
|
|
61
|
+
|
|
62
|
+
# Per engine, so a repo with only RSpec files does not get minitest added to its
|
|
63
|
+
# Gemfile for a migration it is never going to do.
|
|
64
|
+
LEGACY_GLOBS = { rspec: "spec/**/*_spec.rb", minitest: "test/**/*_test.rb" }.freeze
|
|
65
|
+
|
|
66
|
+
# The blotter is machine state: this laptop's flake history and jail docket. Sharing
|
|
67
|
+
# it through git would hand CI somebody else's docket and conflict on every run.
|
|
68
|
+
GITIGNORE_ENTRY = <<~TEXT
|
|
69
|
+
# Constable's blotter -- flake history, the jail docket, warrants. Local state:
|
|
70
|
+
# each machine keeps its own, and CI starts clean.
|
|
71
|
+
/.constable/*.sqlite3
|
|
72
|
+
/.constable/*.sqlite3-*
|
|
73
|
+
TEXT
|
|
74
|
+
|
|
59
75
|
RUBOCOP_EXTENSION = "rubocop-constable"
|
|
60
76
|
|
|
61
77
|
def create_case_helper
|
|
@@ -101,7 +117,31 @@ module Constable
|
|
|
101
117
|
return
|
|
102
118
|
end
|
|
103
119
|
|
|
104
|
-
|
|
120
|
+
gems = cold_case_gems_to_add
|
|
121
|
+
if gems.empty?
|
|
122
|
+
say_status :skip, "Gemfile (every cold-case engine is already declared)", :blue
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
append_to_file "Gemfile", "\n#{cold_case_group(gems)}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The blotter must not be committed. Appended rather than templated, because an app
|
|
130
|
+
# always has a .gitignore already and ours is two lines of it.
|
|
131
|
+
def ignore_the_blotter
|
|
132
|
+
path = File.join(destination_root, ".gitignore")
|
|
133
|
+
|
|
134
|
+
unless File.exist?(path)
|
|
135
|
+
create_file ".gitignore", GITIGNORE_ENTRY
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if File.read(path).include?("/.constable/*.sqlite3")
|
|
140
|
+
say_status :identical, ".gitignore (blotter already ignored)", :blue
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
append_to_file ".gitignore", "\n#{GITIGNORE_ENTRY}"
|
|
105
145
|
end
|
|
106
146
|
|
|
107
147
|
# An app that already lints has opinions in .rubocop.yml worth more than
|
|
@@ -137,6 +177,33 @@ module Constable
|
|
|
137
177
|
|
|
138
178
|
private
|
|
139
179
|
|
|
180
|
+
# An engine earns a line only if this repo has files for it and the Gemfile does not
|
|
181
|
+
# already declare it.
|
|
182
|
+
def cold_case_gems_to_add
|
|
183
|
+
COLD_CASE_GEMS.filter_map do |engine, gem_name|
|
|
184
|
+
next unless legacy_files?(engine)
|
|
185
|
+
next if gem_declared?(gem_name)
|
|
186
|
+
|
|
187
|
+
gem_name
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def cold_case_group(gems)
|
|
192
|
+
lines = gems.map { |gem_name| %( gem "#{gem_name}") }
|
|
193
|
+
"#{COLD_CASE_HEADER}group :cold_case do\n#{lines.join("\n")}\nend\n"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Matches `gem "rspec-rails"` and `gem 'rspec-rails', "~> 8.0"` alike, and ignores a
|
|
197
|
+
# commented-out line, which is a suggestion rather than a declaration.
|
|
198
|
+
def gem_declared?(gem_name)
|
|
199
|
+
gemfile_contents.each_line.any? do |line|
|
|
200
|
+
stripped = line.strip
|
|
201
|
+
next false if stripped.start_with?("#")
|
|
202
|
+
|
|
203
|
+
stripped.match?(/\Agem\s+["']#{Regexp.escape(gem_name)}["']/)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
140
207
|
def gemfile_contents
|
|
141
208
|
File.read(File.join(destination_root, "Gemfile"))
|
|
142
209
|
end
|
|
@@ -148,6 +215,10 @@ module Constable
|
|
|
148
215
|
Dir.glob(File.join(destination_root, "{spec,test}/**/*_{spec,test}.rb")).any?
|
|
149
216
|
end
|
|
150
217
|
|
|
218
|
+
def legacy_files?(engine)
|
|
219
|
+
Dir.glob(File.join(destination_root, LEGACY_GLOBS.fetch(engine))).any?
|
|
220
|
+
end
|
|
221
|
+
|
|
151
222
|
# A textual merge, not a YAML round trip: parsing and re-emitting someone's
|
|
152
223
|
# .rubocop.yml would silently eat every comment in it, and comments in a
|
|
153
224
|
# 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)
|
|
@@ -99,21 +135,6 @@ class SystemCase < Constable::Case
|
|
|
99
135
|
# driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
|
|
100
136
|
end
|
|
101
137
|
|
|
102
|
-
# -----------------------------------------------------------------------------
|
|
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.
|
|
113
|
-
# -----------------------------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
Dir[Rails.root.join("test/support/**/*.rb")].sort.each { |f| require f }
|
|
116
|
-
|
|
117
138
|
# -----------------------------------------------------------------------------
|
|
118
139
|
# Code-level configuration.
|
|
119
140
|
# -----------------------------------------------------------------------------
|
|
@@ -57,6 +57,15 @@ fail_on_warnings: false # CI: fail the build when the warning count doesn't tre
|
|
|
57
57
|
|
|
58
58
|
parallel_workers: auto # or an explicit integer
|
|
59
59
|
|
|
60
|
+
# How much the live stream says while the suite is running. The summary is identical
|
|
61
|
+
# either way -- this only changes what you watch on the way there.
|
|
62
|
+
#
|
|
63
|
+
# concise one glyph per test, grouped into a run per case. A thousand tests stay
|
|
64
|
+
# inside one screen, and a wall of green is the point.
|
|
65
|
+
# expanded a line per test: glyph, name, duration. Slower to read in bulk, but you
|
|
66
|
+
# can see which test is hanging while it hangs, rather than after.
|
|
67
|
+
output: concise # concise | expanded
|
|
68
|
+
|
|
60
69
|
# Fallback path-based tier inference, used only when a case doesn't inherit from
|
|
61
70
|
# a tiered base class. The base classes in test/case_helper.rb are the primary
|
|
62
71
|
# 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:
|
|
4
|
+
version: 1.0.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-
|
|
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
|
-
-
|
|
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
|