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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a0630c0ca6e000d33e21515a284616186d1939531ba5f5e9c5c61882d6dc9ec
4
- data.tar.gz: 10fcc4a75763bec06db7049bd3815214e1320ecdb4f3ad2780ef0497b8f8af6b
3
+ metadata.gz: 05e7e65ec7f1f096713bc5cb7b891ce2297275876c01cf9151130dc13608befe
4
+ data.tar.gz: db0b094a1e70a0052f02bb343cd0d41c53b224e56df089638ad6f5be60025e36
5
5
  SHA512:
6
- metadata.gz: 9a823bc22286ffaff8ab4c29f4f357ba40af37e423a9c6d1cab82382edf5d19c0ca0fae9796c0171f256bab6bdef93c9e2f14a65dd9a57e6e49e2cede204e959
7
- data.tar.gz: 0c043327dc97ace117a1dae5919c9b066df22059deafa5a422066e96b3abafc6f67df7e33f911f68a8eb314b3cca6e7bab0150c7d91c3d6b26f92482baee8b0e
6
+ metadata.gz: 19e12d780b73ec73830eab6290b27d61f1fb8c9cd40894ff0c2832120883f06265e58b405ec107c1dab002d6bf484fb166a99cad273507d1b05e0da1234ae0df
7
+ data.tar.gz: 789ba56df5a2720e25ddbadec56d9916776f809c95b812748468addbdf05276292e76ee37686a40d8489c5678121d44d35e41a0b2b09ab6e72af82b016dc682a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,163 @@ All notable changes to this project are documented here. This project adheres to
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.0.0]
9
+
10
+ The first release anybody should install.
11
+
12
+ 0.1.0 shipped the ideas; running it against a real Rails app for the first time found
13
+ that several of them did not survive contact. Four bugs made the documented adoption
14
+ path impossible to follow, three commands raised `NoMethodError` the moment they were
15
+ run, and three separate ways of mistyping a command produced a **green build that ran no
16
+ tests at all**. Those are all fixed, with tests, and the suite has grown from 738 runs to
17
+ 888.
18
+
19
+ The API has not changed. Every 0.1.0 case file, config key and command still works.
20
+
21
+ ### The adoption path now actually works
22
+
23
+ These four were found by installing 0.1.0 into a Rails app with a 188-example RSpec
24
+ suite and following the README from the top.
25
+
26
+ - **`rails generate constable:install` no longer breaks the Gemfile.** It appended a
27
+ `:cold_case` group declaring `rspec-rails` without checking whether the app already
28
+ had it. Any app adopting Constable *from RSpec* — which is the entire target audience
29
+ — was left with a Gemfile Bundler refused to parse: *"You cannot specify the same gem
30
+ twice with different version requirements."* The installer now adds only the engines
31
+ the repo has files for, and only ones the Gemfile does not already declare.
32
+
33
+ - **Cold cases run `before(:suite)` and `after(:suite)` hooks.** `ColdCase::RSpec` drove
34
+ example groups directly and skipped RSpec's `with_suite_hooks`, so the hooks never
35
+ fired. That is where `webmock/rspec` calls `WebMock.enable!`, where VCR and
36
+ DatabaseCleaner install themselves, and where SimpleCov starts. It failed *open*: a
37
+ spec that stubbed HTTP opened a real socket instead of erroring. Each hook now runs
38
+ exactly once per run — after a file has loaded, since a legacy file's own
39
+ `require "rails_helper"` is what registers them.
40
+
41
+ - **Parallel workers get their own database.** Constable forks its own workers and so
42
+ never picked up the per-worker databases Rails builds for `rails test`. Every worker
43
+ opened the same one. On SQLite a suite that passed 188/0 serially collapsed into 130
44
+ `database is locked` failures. On a client/server database it would have been quieter
45
+ and worse. A worker that cannot build its own database now raises rather than falling
46
+ back to the shared one, and an app that cannot shard runs serially with a warning.
47
+
48
+ - **An outage no longer jails healthy tests.** Flake history reads "passed last run,
49
+ failed this run" as evidence about a test, so that one broken parallel run put 29
50
+ healthy tests on the docket marked *"passed, then failed with no code change"*. A run
51
+ where a quarter of the suite fails with the identical error is now recognised as one
52
+ broken run: the failures still stand and the build still goes red, but nothing moves
53
+ through the jail or parole state machine and nothing is written to flake history.
54
+
55
+ - **`constable:install` writes the blotter into `.gitignore`.** It is this machine's
56
+ flake history and docket. Committing it hands CI somebody else's docket and conflicts
57
+ on every run.
58
+
59
+ - **FactoryBot is wired into the tier base classes,** and `test/support/**` loads
60
+ *before* them. `create(:user)` is what a converted spec is full of, and the generated
61
+ `case_helper.rb` both omitted the include and told you to `include Authenticatable` in
62
+ a class defined thirty lines above the file that defines it.
63
+
64
+ ### Commands that had never been run
65
+
66
+ There was no test file for the CLI at all. All three of these are in the published
67
+ command reference and all three raised `NoMethodError` on their first line:
68
+
69
+ - `constable jail parole PATH:LINE`
70
+ - `constable jail release PATH:LINE`
71
+ - `constable warrants release PATH:LINE` — twice over: after fixing the first bug it
72
+ went on to call a second method that does not exist either.
73
+
74
+ Ambiguous targets are also refused rather than guessed at. `constable jail parole
75
+ test/cases/reports_case.rb`, with three tests from that file on the docket, paroled one
76
+ of them — not the first by line, whichever row the database happened to return. It now
77
+ prints the candidates and exits.
78
+
79
+ ### Three ways to get a green build that ran nothing
80
+
81
+ Each of these printed `0 passed, 0 failed` and exited **0**, so a typo in a CI script
82
+ went green having tested nothing. The most expensive kind of bug a test runner can have,
83
+ because it stays invisible for months.
84
+
85
+ - `constable test test/cases/typo_case.rb` — no such file.
86
+ - `constable test users_case.rb:999` — worse than nothing. `PATH:LINE` picks the
87
+ investigation declared nearest above the line so you can point anywhere inside a
88
+ block; unbounded, a line past the end of the file ran the **last** investigation in
89
+ it. Not the test you asked for, not an error, green either way.
90
+ - `constable test --tier nonsense` — and `--tier UNIT`, which matched nothing because
91
+ tiers were case-sensitive.
92
+
93
+ ### Correctness
94
+
95
+ - **Two tests no longer share one identity.** A test's key is a content hash of its
96
+ body, which is what lets history survive a rename — but two tests with byte-identical
97
+ bodies got the same key, in different classes, with different descriptions. The
98
+ blotter treated them as one test: jail either and both went, and their flake histories
99
+ merged. This is routine rather than exotic; the model generator writes an identical
100
+ first investigation into every file it touches.
101
+
102
+ - **A witness can no longer replace the framework.** `witness` defines a real instance
103
+ method, so `witness(:class)` quietly replaced `Object#class` and every later failure
104
+ message reported the wrong thing. `witness(:attest)` was worse: it disabled assertions
105
+ outright, so the tests passed by doing nothing.
106
+
107
+ - **`be(nil)` asserted the opposite of what it said.** `[nil].any?` is false — `Array#any?`
108
+ without a block tests the truthiness of the elements, not presence — so it fell through
109
+ to the truthiness branch.
110
+
111
+ - **`match_array` was aliased to `contain_exactly`,** but RSpec's takes one array where
112
+ `contain_exactly` takes varargs, so `match_array([1, 2])` asserted the collection held
113
+ a single element which was itself `[1, 2]`.
114
+
115
+ ### Matchers
116
+
117
+ `constable modernize` rewrote any matcher name straight into an `attest` call, so a spec
118
+ using one Constable did not implement converted cleanly and then died at runtime.
119
+
120
+ - Added: `contain_exactly`, `match_array`, `be` (identity, truthiness, and the
121
+ `be >= 0` operator form), `be_within(d).of(x)`, `start_with`, `end_with`,
122
+ `be_between`, `satisfy`.
123
+ - `modernize` now **flags any matcher it does not recognize** instead of converting it.
124
+ - `have_http_status` resolves names through `Rack::Utils` and knows
125
+ `:unprocessable_content` — the Rack 3.1 name for 422, and the one Rails 8.1 tells you
126
+ to use while Constable accepted only the deprecated spelling.
127
+ - Helper specs are flagged. They converted reporting *"21 converted, 0 flagged"* and
128
+ then failed every example with `undefined local variable or method 'helper'`.
129
+
130
+ ### Output
131
+
132
+ - **An `expanded` mode.** `output: concise | expanded` in `.constable/config.yml`, or
133
+ `--expanded` / `--concise` for one run. Concise is unchanged and still the default.
134
+ Expanded prints a line per test — glyph, description, duration — so you can see which
135
+ test is hanging while it hangs. Per-test durations are in milliseconds; the run-scale
136
+ format rendered every fast test as `0.0s`.
137
+ - **Sections for the supervision states.** Jailed tests, warrants and tests on parole
138
+ were counted in the headline and then never mentioned again, so "2 jailed" was a
139
+ number with nothing behind it. `WARRANTS`, `JAILED` and `ON PAROLE` now print like
140
+ `FAILURES` does.
141
+ - **Each one ends with what to do next.** "2 jailed" is a fact;
142
+ `constable jail parole PATH:LINE` is an action. Parole shows its progress toward
143
+ release.
144
+ - Parole entries carry their `file:line` — the one thing their own hint asked you to
145
+ pass — and warning text wraps to the 60-column frame instead of spilling out of it.
146
+
147
+ ### Messages instead of stack traces
148
+
149
+ - A YAML typo in `config.yml` raised a raw `Psych::SyntaxError`; a file that parsed but
150
+ was not a mapping raised *"no implicit conversion of Array into Hash"* from inside the
151
+ merge. Both now name the file and the problem.
152
+ - A corrupt blotter raised *"file is not a database: PRAGMA journal_mode = WAL"*. It now
153
+ says what the file holds — flake history, the docket, warrants, never a test — and
154
+ that deleting it is safe.
155
+ - `coverage_threshold` is clamped to a percentage, negative `warrant_retries` means off,
156
+ and `parole_period` clamps in one place. `Jail` already refused a period of zero, but
157
+ the reporter read the raw value and would print *"Day 1 of 0 — 0 clean runs to go"*
158
+ while the docket waited for ten.
159
+
160
+ ### Also
161
+
162
+ - A new logo, with a dark variant, and a `<picture>` element so GitHub picks.
163
+
164
+
8
165
  ## [0.1.0]
9
166
 
10
167
  Initial release.
@@ -84,5 +241,6 @@ Initial release.
84
241
  - Diff-based coverage gate — only lines changed in the current diff are held to the
85
242
  threshold. `constable beat` for the full picture, `--html` for a browsable report.
86
243
 
87
- [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v0.1.0...HEAD
244
+ [Unreleased]: https://github.com/Ray-Hughes/constable/compare/v1.0.0...HEAD
245
+ [1.0.0]: https://github.com/Ray-Hughes/constable/compare/v0.1.0...v1.0.0
88
246
  [0.1.0]: https://github.com/Ray-Hughes/constable/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  <div align="center">
2
2
 
3
- <img src="https://raw.githubusercontent.com/Ray-Hughes/constable/main/docs/assets/logo.png" alt="Constable" width="200">
4
-
5
- # Constable
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Ray-Hughes/constable/main/docs/assets/logo-dark.png">
5
+ <img src="https://raw.githubusercontent.com/Ray-Hughes/constable/main/docs/assets/logo.png" alt="Constable" width="340">
6
+ </picture>
6
7
 
7
8
  **A strict Rails testing framework where fast and non-flaky are structural, not disciplinary.**
8
9
 
@@ -215,10 +216,16 @@ Constable::Matchers.define(:be_created) { |response| response.status == 201 }
215
216
  Constable::Matchers.define(:exist) { |model_class, attrs| model_class.exists?(attrs) }
216
217
  ```
217
218
 
218
- Built in: `eq`, `eql`, `include`, `match`, `raise_error`, `have_attributes`, `exist`,
219
- `be_created`, `redirect_to`, `have_http_status`, `change`, plus `be_a`, `be_nil`, `be_empty`,
220
- `be_truthy`, `be_falsey` and a `be_*` / `have_*` predicate fallback. Plain `assert_*` and
221
- `refute_*` primitives are always available alongside `attest`.
219
+ Built in: `eq`, `eql`, `be`, `include`, `match`, `raise_error`, `have_attributes`,
220
+ `exist`, `be_created`, `redirect_to`, `have_http_status`, `change`, `contain_exactly`,
221
+ `match_array`, `start_with`, `end_with`, `be_between`, `be_within(d).of(x)`, `satisfy`,
222
+ plus `be_a`, `be_nil`, `be_empty`, `be_truthy`, `be_falsey` and a `be_*` / `have_*`
223
+ predicate fallback. `be` also takes the operator form — `attest(count).to be > 0`.
224
+ Plain `assert_*` and `refute_*` primitives are always available alongside `attest`.
225
+
226
+ The set is deliberately smaller than RSpec's, so `constable modernize` **flags any
227
+ matcher it does not recognize** rather than converting it into a case that only fails
228
+ once you run it.
222
229
 
223
230
  ### Shared behavior is just Ruby
224
231
 
@@ -369,6 +376,14 @@ Class name, description and file are stored alongside purely as a display label.
369
376
  new one appears and suggests `constable history relink OLD NEW`. Set `auto_relink: true` to
370
377
  confirm high-confidence matches automatically.
371
378
 
379
+ Two tests with byte-identical bodies would otherwise share a key — and bodies repeat more
380
+ than the phrase "content hash" suggests, since
381
+ `attest(build(:thing, name: nil)).not_to be_valid` is the same handful of tokens in every
382
+ model case. Constable re-keys colliding tests on their class and description once the
383
+ suite is loaded, so no two tests ever share a docket row. Rename-survival is weaker for
384
+ exactly those tests, which is the right trade: a history belonging to two tests at once is
385
+ worse than one that resets.
386
+
372
387
  ### Command reference
373
388
 
374
389
  | Command | Runs |
@@ -387,22 +402,69 @@ Class name, description and file are stored alongside purely as a display label.
387
402
  | `constable import --from=rspec` | Adopt an existing suite as cold cases |
388
403
  | `constable modernize PATH` | Opt-in AST rewrite into the native DSL |
389
404
 
390
- Flags: `--full --unsafe --jail --warrants --coverage --seed N --workers N --verbose --tier T --no-color`.
405
+ Flags: `--full --unsafe --jail --warrants --coverage --seed N --workers N --verbose --tier T\n--expanded --concise --output MODE --no-color`.
391
406
 
392
407
  Order is randomized every run for native cases, with the seed printed and replayable via
393
408
  `--seed`. Cold cases keep their own engine's order. Workers run in parallel by default,
394
409
  load-balanced by a cached per-test duration index.
395
410
 
411
+ Each worker gets **its own database**, built from schema the way `rails test` does it.
412
+ Sharing one would not be a speed/safety trade but a correctness bug: on SQLite the run
413
+ dissolves into `database is locked`, and on a client/server database tests quietly see
414
+ each other's rows. If your app has ActiveRecord but cannot shard, Constable runs serially
415
+ and says why — slow is a trade-off, wrong is not.
416
+
396
417
  ### Output
397
418
 
398
419
  stdout is reserved for results. `Rails.logger`, SQL and request/response logging go to
399
420
  `log/test.log`; `--verbose` streams it back for active debugging.
400
421
 
422
+ **While it runs**, the live stream has two modes. `concise` is the default: one glyph per
423
+ test, grouped into a run per case, so a thousand-test suite stays inside one screen and a
424
+ wall of green is the point.
425
+
426
+ ```
427
+ ProjectCase ✓✓✓✓✓✓✓✓✓✓✓✓
428
+ BillingCase ✓✓⚖⛓✓
429
+ ```
430
+
431
+ `expanded` trades that for a line per test — glyph, name, duration — so you can see which
432
+ test is hanging while it hangs, rather than after.
433
+
434
+ ```
435
+ ProjectCase
436
+ ✓ validations rejects a colour outside the palette 2ms
437
+ ✓ validations rejects a duplicate name for the same owner 12ms
438
+ ✗ #completion_ratio is the fraction of done tasks 8ms
439
+
440
+ BillingCase
441
+ ⚖ charges a card 310ms
442
+ ⛓ refunds a charge — assertion failed on the amount
443
+ ✓ issues a receipt 1.4s
444
+ ```
445
+
446
+ Set it in `.constable/config.yml` (`output: concise` or `expanded`), or per run with
447
+ `--expanded` / `--concise`. A jailed test never ran its body, so it is given no duration
448
+ rather than a dishonest `0ms`. **The summary below is identical in both modes** — the mode
449
+ only changes what you watch on the way there.
450
+
401
451
  ```
402
452
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
403
- CONSTABLE 482 tests · 3 cases · 12.4s
453
+ CONSTABLE 6 tests · 3 cases · 12.4s
404
454
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
405
- ✓ 478 passed ✗ 2 failed ⛓ 2 jailed (1 parole violation) ◑ 1 on parole ⚖ 1 warrant issued ⚠ 3 warnings ◐ 92% covered
455
+ ✓ 2 passed ✗ 1 failed ⛓ 2 jailed (1 parole violation) ◑ 1 on parole ⚖ 1 warrant issued ⚠ 2 warnings ◐ 92% covered (3 files unpatrolled)
456
+
457
+ PAROLE VIOLATED
458
+ ───────────────
459
+ ⛓ UsersController::CreatesUserCase
460
+ "creates a user with valid params"
461
+ spec/cases/users_controller/creates_user_case.rb:8
462
+ Failed on day 3 of a 10-run parole — back to jail. This is its 2nd time in jail.
463
+
464
+ → Somebody trusted this test again and it let them down,
465
+ so it is back on the docket. Fix it before the next
466
+ constable jail parole — a second violation is the signal
467
+ that the test, not the flake, is the problem.
406
468
 
407
469
  FAILURES
408
470
  ────────
@@ -417,11 +479,64 @@ stdout is reserved for results. `Rails.logger`, SQL and request/response logging
417
479
 
418
480
  Rerun just this test:
419
481
  constable test spec/cases/sessions_case.rb:12 --seed 8841
482
+
483
+ WARRANTS
484
+ ────────
485
+ ⚖ BillingCase
486
+ "charges a card"
487
+ spec/cases/sessions_case.rb:12
488
+ Failed, then passed 4 of 5 retries run in isolation.
489
+
490
+ → A warrant is "not reproducible", not "not a problem" —
491
+ it stops blocking the build and stays visible until
492
+ someone deals with it. Fixed the flake? constable
493
+ warrants release PATH:LINE
494
+
495
+ JAILED
496
+ ──────
497
+ ⛓ BillingCase
498
+ "refunds a charge"
499
+ spec/cases/sessions_case.rb:12
500
+ Assertion failed on the amount.
501
+
502
+ → Jailed means skipped and tracked, not passing. Think one
503
+ is fixed? constable jail parole PATH:LINE runs it for
504
+ real again — 10 clean runs and it releases itself.
505
+
506
+ ON PAROLE
507
+ ─────────
508
+ ◑ SessionsCase
509
+ "signs a user in"
510
+ spec/cases/sessions_case.rb:12
511
+ Day 4 of 10 — 6 clean runs to go.
512
+
513
+ → A paroled test runs for real and is watched: one failure
514
+ sends it straight back to jail. constable watchlist
515
+ shows everything under supervision.
516
+
517
+ WARNINGS
518
+ ────────
519
+ ⚠ spec/legacy/old_users_spec.rb
520
+ running as a cold case (Constable::ColdCase::RSpec) — 12
521
+ tests not yet under native rules
522
+
523
+ ⚠ spec/controllers/sessions_case.rb:44
524
+ unsafe { sleep(0.1) } — "testing an actual timeout path,
525
+ not a code smell"
526
+
527
+ SLOWEST
528
+ ────────
529
+ 3.2s UsersController::CreatesUserCase "creates a user with valid params"
530
+ 1.1s SessionsCase "times out after thirty seconds"
531
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
420
532
  ```
421
533
 
422
- Sections print worst-to-least-urgent: parole violations, failures, warnings, slowest.
423
- Failures carry their own context and point at your `investigate` line, not at framework
424
- internals.
534
+ Sections print worst-to-least-urgent: parole violations, failures, warrants, jailed, on
535
+ parole, warnings, slowest. A section only appears when it has something to say.
536
+
537
+ Each supervision section ends with one line saying what to do next, because "2 jailed" is
538
+ a fact and `constable jail parole PATH:LINE` is an action. Failures don't get a hint —
539
+ they already end with the exact command to rerun them.
425
540
 
426
541
  ### The blotter
427
542
 
@@ -462,6 +577,7 @@ coverage_threshold: 90 # diff-based — only lines changed in the curr
462
577
  coverage_html: false
463
578
 
464
579
  fail_on_warnings: false
580
+ output: concise # live stream detail: concise | expanded
465
581
  parallel_workers: auto
466
582
 
467
583
  tiers: # fallback inference; base classes are primary
@@ -60,6 +60,7 @@ module Constable
60
60
  raise ArgumentError, "witness(#{name.inspect}) requires a block" unless block
61
61
 
62
62
  name = name.to_sym
63
+ guard_witness_name!(name)
63
64
  own_witnesses[name] = block
64
65
  define_method(name) do
65
66
  constable_witnesses.fetch(name) { constable_witnesses[name] = instance_exec(&block) }
@@ -161,6 +162,38 @@ module Constable
161
162
  constable_lineage.flat_map(&:own_briefings)
162
163
  end
163
164
 
165
+ # Names a witness may not take.
166
+ #
167
+ # `witness` defines a real instance method, so `witness(:class) { ... }` quietly
168
+ # replaces Object#class on the case and every later `attest` failure reports the
169
+ # wrong thing. Worse, `witness(:attest)` disables assertions outright -- the tests
170
+ # then pass by doing nothing, which is the one failure mode a testing framework
171
+ # must never have.
172
+ #
173
+ # Deliberately a list rather than `method_defined?`: a blanket check would reject
174
+ # ordinary names a tier happens to define (`response` on an integration case), and
175
+ # shadowing those is a legitimate, if unusual, thing to want.
176
+ RESERVED_WITNESS_NAMES = %i[
177
+ class send __send__ __id__ object_id method methods freeze frozen? dup clone
178
+ hash inspect to_s instance_variable_get instance_variable_set instance_variables
179
+ attest unsafe witness briefing investigate docket tier setup teardown
180
+ assert refute flunk skip pass freeze_time travel_to travel_back
181
+ ].freeze
182
+
183
+ def guard_witness_name!(name)
184
+ if RESERVED_WITNESS_NAMES.include?(name)
185
+ raise ArgumentError,
186
+ "witness(:#{name}) would replace Constable::Case##{name}, which the " \
187
+ "framework needs. Pick another name."
188
+ end
189
+
190
+ return unless name.to_s.start_with?("constable_")
191
+
192
+ raise ArgumentError,
193
+ "witness(:#{name}) is in Constable's own namespace. Names beginning " \
194
+ "`constable_` belong to the framework."
195
+ end
196
+
164
197
  def witnesses
165
198
  constable_lineage.each_with_object({}) { |klass, out| out.merge!(klass.own_witnesses) }
166
199
  end
data/lib/constable/cli.rb CHANGED
@@ -17,7 +17,10 @@ module Constable
17
17
  # wrong", and CI needs to. Commands return a status; this turns it into one.
18
18
  def self.dispatch!(argv)
19
19
  start(argv)
20
- rescue Thor::Error => e
20
+ # Thor::Error is a malformed command; Constable::Error is a wrong path, an unknown
21
+ # tier, an unreadable config. Both are the user's mistake rather than a crash, and
22
+ # both deserve one sentence and a usage status instead of a backtrace.
23
+ rescue Thor::Error, Constable::Error => e
21
24
  warn e.message
22
25
  exit(EXIT_USAGE)
23
26
  rescue Interrupt
@@ -42,6 +45,9 @@ module Constable
42
45
  option :workers, type: :numeric, desc: "Parallel workers (default: config, or auto)"
43
46
  option :verbose, type: :boolean, default: false, desc: "Stream log/test.log to stdout"
44
47
  option :tier, type: :string, desc: "Run one tier only: unit, integration or system"
48
+ option :output, type: :string, desc: "Live stream detail: concise (default) or expanded"
49
+ option :expanded, type: :boolean, default: false, desc: "Shorthand for --output=expanded"
50
+ option :concise, type: :boolean, default: false, desc: "Shorthand for --output=concise"
45
51
  def test(*paths)
46
52
  config = load_config
47
53
  LogRouter.route!(verbose: options[:verbose])
@@ -299,7 +305,9 @@ module Constable
299
305
  no_commands do
300
306
  def act(locator)
301
307
  jail = Jail.new(config: Constable.config, storage: Constable.storage)
302
- identity = jail.identity_for(locator)
308
+ refuse_ambiguous(locator, jail.candidates(locator), "on the docket")
309
+
310
+ identity = jail.resolve(locator)
303
311
  unless identity
304
312
  warn "Nothing on the docket at #{locator}"
305
313
  exit(EXIT_USAGE)
@@ -307,6 +315,18 @@ module Constable
307
315
  yield jail, identity
308
316
  end
309
317
 
318
+ # A bare path naming several tests is a question. Answer it with the list rather
319
+ # than acting on whichever row the database happened to return first.
320
+ def refuse_ambiguous(locator, candidates, noun)
321
+ return if candidates.size <= 1
322
+
323
+ warn "#{locator} matches #{candidates.size} tests #{noun}. Name one:"
324
+ candidates.sort_by { |entry| entry.line.to_i }.each do |entry|
325
+ warn " #{entry.location} #{entry.label}"
326
+ end
327
+ exit(EXIT_USAGE)
328
+ end
329
+
310
330
  def repeat_note(entry)
311
331
  count = entry.times_jailed
312
332
  count > 1 ? " (this is its #{Reporter.ordinalize(count)} time in jail)" : ""
@@ -344,12 +364,14 @@ module Constable
344
364
  desc "release PATH:LINE", "Clear a warrant by hand"
345
365
  def release(locator)
346
366
  warrants = Warrants.new(config: Constable.config, storage: Constable.storage)
347
- identity = warrants.identity_for(locator)
367
+ JailCommand.new.send(:refuse_ambiguous, locator, warrants.candidates(locator), "under warrant")
368
+
369
+ identity = warrants.resolve(locator)
348
370
  unless identity
349
371
  warn "No warrant at #{locator}"
350
372
  exit(EXIT_USAGE)
351
373
  end
352
- warrants.clear(identity)
374
+ warrants.release(identity)
353
375
  say "Warrant cleared."
354
376
  end
355
377
  end
@@ -400,7 +422,18 @@ module Constable
400
422
  end
401
423
 
402
424
  def reporter(config)
403
- Reporter.new(io: $stdout, config: config, color: color?)
425
+ Reporter.new(io: $stdout, config: config, color: color?, mode: output_mode)
426
+ end
427
+
428
+ # nil means "the config file decides". The explicit --output wins over the two
429
+ # shorthands, and --concise wins over --expanded if somebody passes both -- the
430
+ # quieter of two contradictory instructions is the safer one to obey.
431
+ def output_mode
432
+ return options[:output] if options[:output]
433
+ return :concise if options[:concise]
434
+ return :expanded if options[:expanded]
435
+
436
+ nil
404
437
  end
405
438
 
406
439
  def say_table(heading, entries)
@@ -120,7 +120,12 @@ module Constable
120
120
  with_engine do
121
121
  collector = Collector.new
122
122
  load_error = capture_load(path)
123
- run_world(collector) unless load_error
123
+ unless load_error
124
+ # After the file has loaded -- that load is what registers them -- and
125
+ # before its examples run. See #run_pending_before_suite_hooks.
126
+ run_pending_before_suite_hooks(::RSpec.configuration)
127
+ run_world(collector)
128
+ end
124
129
 
125
130
  ColdCase.warn_for_file(path, base_class_name, collector.examples.size, config: config)
126
131
  results = build_results(path, collector.examples, config: config, seed: seed,
@@ -134,14 +139,101 @@ module Constable
134
139
  # configuration -- which also means any RSpec.configure hooks a rails_helper
135
140
  # installed are gone, so this is a teardown call, not a between-files call.
136
141
  def reset_engine!
142
+ # Inside the global-state swap, not outside it. Building a SuiteHookContext
143
+ # makes rspec-core lazily construct a world and a configuration, so running
144
+ # these hooks bare would leave both behind in a host process that had none --
145
+ # exactly the leak #with_engine exists to prevent.
146
+ with_engine { run_after_suite_hooks! } if after_suite_hooks_pending?
147
+
137
148
  @session_world = nil
138
149
  @session_configuration = nil
139
150
  @session_prepared = false
151
+ @ran_before_suite_hooks = nil
140
152
  nil
141
153
  end
142
154
 
143
155
  private
144
156
 
157
+ # --- Suite hooks ------------------------------------------------------------
158
+ #
159
+ # RSpec's own Runner wraps its group loop in `configuration.with_suite_hooks`.
160
+ # We drive the groups directly (see #run_world), so without this a cold case
161
+ # never fires `before(:suite)` -- and that is exactly where webmock/rspec calls
162
+ # `WebMock.enable!`, where VCR and DatabaseCleaner install themselves, and where
163
+ # SimpleCov starts. Skipping them fails *open*: a spec that stubs HTTP opens a
164
+ # real socket instead of erroring, which is the worst direction for a testing
165
+ # tool to be wrong in.
166
+ #
167
+ # `with_suite_hooks` itself is the wrong shape here. It is a bracket around one
168
+ # block, but "suite" means the whole run rather than one file: wrapping each file
169
+ # would fire `after(:suite)` after file one and hand file two the wreckage. Nor
170
+ # can the hooks all be run up front, because a legacy file's own
171
+ # `require "rails_helper"` is what registers them -- before the first load there
172
+ # is nothing to run.
173
+ #
174
+ # So each hook runs exactly once, the first time we see it: after a file has
175
+ # loaded, before its examples. `after(:suite)` runs once, from #reset_engine!.
176
+ def run_pending_before_suite_hooks(configuration)
177
+ pending = suite_hooks(configuration, :@before_suite_hooks) - ran_before_suite_hooks
178
+ return if pending.empty?
179
+
180
+ ran_before_suite_hooks.concat(pending)
181
+ invoke_suite_hooks(configuration, "a `before(:suite)` hook", pending,
182
+ scope: :before_suite_hook)
183
+ end
184
+
185
+ # Only worth running if we ever ran the matching before(:suite) half -- otherwise
186
+ # this is a teardown for setup that never happened.
187
+ def run_after_suite_hooks!
188
+ configuration = @session_configuration
189
+ return if configuration.nil? || ran_before_suite_hooks.empty?
190
+
191
+ hooks = suite_hooks(configuration, :@after_suite_hooks)
192
+ return if hooks.empty?
193
+
194
+ invoke_suite_hooks(configuration, "an `after(:suite)` hook", hooks,
195
+ scope: :after_suite_hook)
196
+ end
197
+
198
+ def ran_before_suite_hooks
199
+ @ran_before_suite_hooks ||= []
200
+ end
201
+
202
+ # Nothing was set up, so there is nothing to tear down -- and no reason to build
203
+ # an RSpec world to discover that.
204
+ def after_suite_hooks_pending?
205
+ !@session_configuration.nil? &&
206
+ ran_before_suite_hooks.any? &&
207
+ suite_hooks(@session_configuration, :@after_suite_hooks).any?
208
+ end
209
+
210
+ # RSpec keeps these in plain ivars with no public reader. Read them defensively:
211
+ # a missing ivar means a version that stores them elsewhere, and running no suite
212
+ # hooks is the behavior we already had.
213
+ def suite_hooks(configuration, ivar)
214
+ return [] unless configuration.respond_to?(:instance_variable_defined?)
215
+ return [] unless configuration.instance_variable_defined?(ivar)
216
+
217
+ Array(configuration.instance_variable_get(ivar))
218
+ end
219
+
220
+ # `run_suite_hooks` is private on Configuration, and it is the part that builds a
221
+ # SuiteHookContext and keeps one failing before-hook from running the rest. Use it
222
+ # when it is there, and fall back to driving the hooks ourselves when it is not.
223
+ def invoke_suite_hooks(configuration, description, hooks, scope:)
224
+ previous = ::RSpec.current_scope if ::RSpec.respond_to?(:current_scope)
225
+ ::RSpec.current_scope = scope if ::RSpec.respond_to?(:current_scope=)
226
+
227
+ if configuration.respond_to?(:run_suite_hooks, true)
228
+ configuration.send(:run_suite_hooks, description, hooks)
229
+ else
230
+ context = ::RSpec::Core::SuiteHookContext.new(description, configuration.reporter)
231
+ hooks.each { |hook| hook.run(context) }
232
+ end
233
+ ensure
234
+ ::RSpec.current_scope = previous if previous && ::RSpec.respond_to?(:current_scope=)
235
+ end
236
+
145
237
  # Running RSpec in-process is a global-state problem: RSpec.world holds every
146
238
  # registered example group and RSpec.configuration holds every hook. We swap in a
147
239
  # session world/configuration for the duration of a file and put whatever was