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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05e7e65ec7f1f096713bc5cb7b891ce2297275876c01cf9151130dc13608befe
|
|
4
|
+
data.tar.gz: db0b094a1e70a0052f02bb343cd0d41c53b224e56df089638ad6f5be60025e36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
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`,
|
|
219
|
-
`be_created`, `redirect_to`, `have_http_status`, `change`,
|
|
220
|
-
`
|
|
221
|
-
`
|
|
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
|
|
453
|
+
CONSTABLE 6 tests · 3 cases · 12.4s
|
|
404
454
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
405
|
-
✓
|
|
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,
|
|
423
|
-
|
|
424
|
-
|
|
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
|
data/lib/constable/case.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|