test_budget 0.0.0 → 0.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.
- checksums.yaml +4 -4
- data/.test_budget.yml +4 -0
- data/README.md +245 -17
- data/Rakefile +1 -0
- data/exe/test_budget +6 -0
- data/lib/test_budget/allowlist.rb +68 -0
- data/lib/test_budget/audit.rb +23 -0
- data/lib/test_budget/auditor.rb +33 -0
- data/lib/test_budget/budget/estimate.rb +83 -0
- data/lib/test_budget/budget.rb +107 -0
- data/lib/test_budget/cli.rb +133 -0
- data/lib/test_budget/parser/rspec.rb +53 -0
- data/lib/test_budget/reporter.rb +33 -0
- data/lib/test_budget/statistics.rb +28 -0
- data/lib/test_budget/test_case.rb +65 -0
- data/lib/test_budget/test_run.rb +13 -0
- data/lib/test_budget/version.rb +1 -1
- data/lib/test_budget/violation.rb +19 -0
- data/lib/test_budget/warning.rb +10 -0
- data/lib/test_budget.rb +15 -1
- metadata +40 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e19638c3b0f42996339e6a676005e07193ee34017b67c636f241ec04c91b59d
|
|
4
|
+
data.tar.gz: 68fff54ed7f1f4e888e18bb15db65888de38b5c76e6d5ff8fc3188cafdeb83d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab0d1bef70892fa3ae89a504e380498307e97646b2f0a98dbd8ec20bd5cb858d1e99b5725fd043d7cfecf822cadf5a6e9c0a1e8760fe89791057b09916f3d0d6
|
|
7
|
+
data.tar.gz: 5ef882443f34709c32785eb39ca669ea50a46fafee70e173ceaae534cb2da30845b53ba893d79554670dac5a30b63e9f1ac55263fc2e43029ed7082e049abac1
|
data/.test_budget.yml
ADDED
data/README.md
CHANGED
|
@@ -1,39 +1,267 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Test Budget
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Prevent slow tests from creeping into your suite.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Test suites get slow one test at a time. By the time you notice, your CI takes
|
|
6
|
+
40 minutes and nobody wants to touch it.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Test Budget is **a linter for test performance**. It reads your test results
|
|
9
|
+
after the run, checks durations against configured budgets, and fails if
|
|
10
|
+
anything goes over.
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
It doesn't change how your tests run. It just tells you when they're too slow —
|
|
13
|
+
before it gets worse.
|
|
10
14
|
|
|
11
|
-
Install
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "test_budget", github: "thoughtbot/test_budget"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
> [!NOTE]
|
|
24
|
+
> Test Budget currently supports **RSpec only**. Minitest support is not yet available.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
Generate a starter config from an existing RSpec JSON results file:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle exec test_budget init tmp/test_timings.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When given a results file, `init` derives budgets from your actual data (99th
|
|
35
|
+
percentile + 10% tolerance, rounded to the nearest 0.5s). If you don't have a
|
|
36
|
+
results file yet, run without arguments to generate a config with Rails
|
|
37
|
+
defaults:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bundle exec test_budget init
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use `--force` to overwrite an existing `.test_budget.yml`.
|
|
44
|
+
|
|
45
|
+
`estimate` is an alias for `init`. Use whichever name feels right:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bundle exec test_budget estimate tmp/test_timings.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then run the audit after your tests:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bundle exec test_budget audit
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Example output:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Test budget: 1 violation(s) found
|
|
61
|
+
|
|
62
|
+
1) spec/system/signup_spec.rb -- creates account (11.20s) exceeds system limit (6.00s)
|
|
63
|
+
To allowlist, run:
|
|
64
|
+
bundle exec test_budget allowlist spec/system/signup_spec.rb:15 --reason "<reason>"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## RSpec JSON results file
|
|
68
|
+
|
|
69
|
+
> [!TIP]
|
|
70
|
+
> You can skip this step and run `test_budget init` without a results
|
|
71
|
+
> file to get started with Rails defaults right away.
|
|
72
|
+
|
|
73
|
+
Add to your RSpec configuration or CI command:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bundle exec rspec --format json --out tmp/test_timings.json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or combine with your usual formatter:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
bundle exec rspec --format progress --format json --out tmp/test_timings.json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Parallel test runners
|
|
86
|
+
|
|
87
|
+
If you use [`parallel_tests`][parallel_tests],
|
|
88
|
+
`$TEST_ENV_NUMBER` in command arguments is replaced per worker (empty string for
|
|
89
|
+
worker 1, `2` for worker 2, etc.). Use it to write a separate output file per
|
|
90
|
+
worker:
|
|
12
91
|
|
|
13
92
|
```bash
|
|
14
|
-
bundle
|
|
93
|
+
bundle exec parallel_rspec -- --format json --out 'tmp/test_timings$TEST_ENV_NUMBER.json'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This produces `test_timings.json`, `test_timings2.json`, `test_timings3.json`,
|
|
97
|
+
etc. Then set your `timings_path` to a glob pattern:
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
timings_path: "tmp/test_timings*.json"
|
|
15
101
|
```
|
|
16
102
|
|
|
17
|
-
If
|
|
103
|
+
If you use [`flatware`][flatware], each worker appends its results to the same
|
|
104
|
+
output file. Test Budget handles this automatically:
|
|
18
105
|
|
|
19
106
|
```bash
|
|
20
|
-
|
|
107
|
+
flatware rspec --format json --out tmp/test_timings.json
|
|
21
108
|
```
|
|
22
109
|
|
|
23
|
-
##
|
|
110
|
+
## Configuration
|
|
24
111
|
|
|
25
|
-
|
|
112
|
+
The `init` command creates a `.test_budget.yml` in your project root. You can
|
|
113
|
+
also create one manually:
|
|
26
114
|
|
|
27
|
-
|
|
115
|
+
```yaml
|
|
116
|
+
timings_path: tmp/test_timings.json
|
|
28
117
|
|
|
29
|
-
|
|
118
|
+
suite:
|
|
119
|
+
max_duration: 300 # seconds
|
|
30
120
|
|
|
31
|
-
|
|
121
|
+
per_test_case:
|
|
122
|
+
default: 2
|
|
123
|
+
system: 6
|
|
124
|
+
request: 3
|
|
125
|
+
model: 1.5
|
|
32
126
|
|
|
33
|
-
|
|
127
|
+
allowlist:
|
|
128
|
+
- test_case: "spec/services/invoice_pdf_spec.rb -- generates PDF with line items"
|
|
129
|
+
reason: "PDF generation is inherently slow, tracking in JIRA-1234"
|
|
130
|
+
expires_on: "2025-06-01"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- **`timings_path`** (required) — path (or glob pattern) to the RSpec JSON output file(s).
|
|
134
|
+
- **`suite.max_duration`** — total duration budget for the entire suite.
|
|
135
|
+
- **`per_test_case.default`** — default per-test limit. Applies to any type without a specific limit.
|
|
136
|
+
- **`per_test_case.<type>`** — per-test limit for a specific type. Types are inferred from file paths by singularizing the directory name (`spec/models/` -> `model`, `spec/features/` -> `feature`, `spec/system/` -> `system`, etc).
|
|
137
|
+
- **`allowlist`** — known slow tests to skip. Each entry requires an `expires_on` date (YYYY-MM-DD). Expired entries stop exempting their tests. Use this as a temporary escape hatch, not a permanent solution.
|
|
138
|
+
|
|
139
|
+
> [!IMPORTANT]
|
|
140
|
+
> At least one limit (`suite.max_duration`, `per_test_case.default`, or a type-specific limit) must be configured.
|
|
34
141
|
|
|
35
|
-
|
|
142
|
+
## Audit
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
bundle exec test_budget audit
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Use `--budget` to point to a different config file:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
bundle exec test_budget audit --budget config/test_budget.yml
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Use `--tolerant` to apply a 10% tolerance to all limits. This is useful on
|
|
155
|
+
shared CI infrastructure where CPU contention causes small fluctuations in test
|
|
156
|
+
durations:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
bundle exec test_budget audit --tolerant
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
With `--tolerant`, a test only fails if it exceeds the limit by more than 10%
|
|
163
|
+
(e.g., a 5s limit becomes an effective 5.5s limit).
|
|
164
|
+
|
|
165
|
+
Exit code is `0` when all tests are within budget, `1` when there are violations.
|
|
166
|
+
|
|
167
|
+
## Allowlist
|
|
168
|
+
|
|
169
|
+
You can allowlist individual tests via the CLI:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
bundle exec test_budget allowlist spec/models/user_spec.rb:10 --reason "Tracking in JIRA-1234"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Entries are created with a 60-day expiration by default. Edit the `expires_on`
|
|
176
|
+
date in the YAML file if you need a different window.
|
|
177
|
+
|
|
178
|
+
### Pruning obsolete entries
|
|
179
|
+
|
|
180
|
+
Over time, allowlisted tests may be fixed or removed. Use `prune` to clean up
|
|
181
|
+
entries that are no longer needed:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
bundle exec test_budget prune
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
This removes **stale** entries (test no longer exists) and **unnecessary** entries
|
|
188
|
+
(test is now within budget). The `audit` command also warns about these entries
|
|
189
|
+
so you know when it's time to prune.
|
|
190
|
+
|
|
191
|
+
### Example output
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
Test budget: 2 violation(s) found
|
|
195
|
+
|
|
196
|
+
1) spec/models/user_spec.rb -- User#full_name (2.50s) exceeds model limit (1.00s)
|
|
197
|
+
To allowlist, run:
|
|
198
|
+
bundle exec test_budget allowlist spec/models/user_spec.rb:10 --reason "<reason>"
|
|
199
|
+
|
|
200
|
+
2) Suite total (650.00s) exceeds limit (600.00s)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## CI integration
|
|
204
|
+
|
|
205
|
+
Run the audit after your test suite:
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
# .github/workflows/ci.yml
|
|
209
|
+
- run: bundle exec rspec --format progress --format json --out tmp/test_timings.json
|
|
210
|
+
- run: bundle exec test_budget audit
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The second step fails the build if any test exceeds its budget.
|
|
214
|
+
|
|
215
|
+
## I have violations. Now what?
|
|
216
|
+
|
|
217
|
+
Violations mean tests are slower than you decided they should be. You have
|
|
218
|
+
options:
|
|
219
|
+
|
|
220
|
+
- **Make the tests faster.** This is the best option. Look for unnecessary
|
|
221
|
+
setup, N+1 queries, slow external calls that could be stubbed. Can the same
|
|
222
|
+
behavior be exercised with a faster test type? (e.g. system -> request,
|
|
223
|
+
request -> model)
|
|
224
|
+
- **Split the work.** A test doing too much can often be broken into
|
|
225
|
+
focused scenarios.
|
|
226
|
+
- **Parallelize.** Tools like [`parallel_tests`][parallel_tests] and
|
|
227
|
+
[`flatware`][flatware] reduce wall time without changing individual test
|
|
228
|
+
durations, but consider also setting per-test budgets to keep individual tests
|
|
229
|
+
honest.
|
|
230
|
+
- **Upgrade infrastructure.** Faster CI (or developer) machines buy time.
|
|
231
|
+
- **Allowlist temporarily.** If a fix isn't immediate, add the test to the
|
|
232
|
+
allowlist and create a ticket. This keeps the budget enforced for everything
|
|
233
|
+
else.
|
|
234
|
+
|
|
235
|
+
The goal isn't zero violations on day one. It's to stop the bleeding and make
|
|
236
|
+
test performance visible.
|
|
36
237
|
|
|
37
238
|
## License
|
|
38
239
|
|
|
39
|
-
The gem is available as open source under the terms of the [MIT
|
|
240
|
+
The gem is available as open source under the terms of the [MIT
|
|
241
|
+
License](https://opensource.org/licenses/MIT).
|
|
242
|
+
|
|
243
|
+
## Code of Conduct
|
|
244
|
+
|
|
245
|
+
Everyone interacting in the Test Budget project's codebases, issue trackers,
|
|
246
|
+
chat rooms and mailing lists is expected to follow the [code of
|
|
247
|
+
conduct](https://github.com/thoughtbot/test_budget/blob/main/CODE_OF_CONDUCT.md).
|
|
248
|
+
|
|
249
|
+
<!-- START /templates/footer.md -->
|
|
250
|
+
|
|
251
|
+
## About thoughtbot
|
|
252
|
+
|
|
253
|
+

|
|
254
|
+
|
|
255
|
+
This repo is maintained and funded by thoughtbot, inc. The names and logos for
|
|
256
|
+
thoughtbot are trademarks of thoughtbot, inc.
|
|
257
|
+
|
|
258
|
+
We love open source software! See [our other projects][community]. We are
|
|
259
|
+
[available for hire][hire].
|
|
260
|
+
|
|
261
|
+
[community]: https://thoughtbot.com/community?utm_source=github&utm_medium=readme&utm_campaign=test_budget
|
|
262
|
+
[hire]: https://thoughtbot.com/hire-us?utm_source=github&utm_medium=readme&utm_campaign=test_budget
|
|
263
|
+
|
|
264
|
+
<!-- END /templates/footer.md -->
|
|
265
|
+
|
|
266
|
+
[parallel_tests]: https://github.com/grosser/parallel_tests
|
|
267
|
+
[flatware]: https://github.com/briandunn/flatware
|
data/Rakefile
CHANGED
data/exe/test_budget
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module TestBudget
|
|
6
|
+
class Allowlist
|
|
7
|
+
def initialize(raw_entries = [])
|
|
8
|
+
@entries = raw_entries.map do |entry|
|
|
9
|
+
Entry.new(
|
|
10
|
+
test_case_key: entry["test_case"],
|
|
11
|
+
reason: entry["reason"],
|
|
12
|
+
expires_on: parse_date(entry["expires_on"])
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def allowed?(key)
|
|
18
|
+
@entries.find { |e| e.matches?(key) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add(key, reason:, expires_on:)
|
|
22
|
+
raise Error, "#{key} is already allowlisted" if allowed?(key)
|
|
23
|
+
|
|
24
|
+
entry = Entry.new(test_case_key: key, reason: reason, expires_on: expires_on)
|
|
25
|
+
@entries << entry
|
|
26
|
+
entry
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def prune(test_run, budget)
|
|
30
|
+
removed, @entries = @entries.partition { |entry| entry.obsolete?(test_run, budget) }
|
|
31
|
+
removed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_a
|
|
35
|
+
@entries.dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def parse_date(raw)
|
|
41
|
+
Date.parse(raw.to_s)
|
|
42
|
+
rescue Date::Error, TypeError
|
|
43
|
+
raise Error, "expires_on is required and must be a valid date (YYYY-MM-DD)"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Entry = Data.define(:test_case_key, :reason, :expires_on) do
|
|
47
|
+
def expired? = Date.today > expires_on
|
|
48
|
+
|
|
49
|
+
def matches?(key)
|
|
50
|
+
test_case_key == key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def obsolete?(test_run, budget)
|
|
54
|
+
test_case = test_run.test_cases.find { |tc| tc.key == test_case_key }
|
|
55
|
+
|
|
56
|
+
if test_case.nil?
|
|
57
|
+
:stale
|
|
58
|
+
elsif test_case.within?(budget)
|
|
59
|
+
:unnecessary
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_h
|
|
64
|
+
{"test_case" => test_case_key, "reason" => reason, "expires_on" => expires_on.to_s}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestBudget
|
|
4
|
+
class Audit
|
|
5
|
+
Result = Data.define(:violations, :warnings) do
|
|
6
|
+
def passed? = violations.empty?
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(budget_path:, tolerant: false)
|
|
10
|
+
@budget_path = budget_path
|
|
11
|
+
@tolerant = tolerant
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def perform
|
|
15
|
+
budget = Budget.load(@budget_path)
|
|
16
|
+
test_run = Parser::Rspec.parse(budget.timings_path)
|
|
17
|
+
result = Auditor.new(budget, tolerant: @tolerant).audit(test_run)
|
|
18
|
+
Reporter.new(budget_path: @budget_path).report(result)
|
|
19
|
+
|
|
20
|
+
result
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestBudget
|
|
4
|
+
class Auditor
|
|
5
|
+
TOLERANCE = 0.1
|
|
6
|
+
|
|
7
|
+
def initialize(budget, tolerant: false)
|
|
8
|
+
@budget = tolerant ? budget.inflate_by(TOLERANCE) : budget
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def audit(test_run)
|
|
12
|
+
Audit::Result.new(
|
|
13
|
+
violations: violations_for(test_run),
|
|
14
|
+
warnings: warnings_for(test_run)
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def violations_for(test_run)
|
|
21
|
+
violations = test_run.test_cases.filter_map { |tc| tc.violation_for(@budget) }
|
|
22
|
+
violations << test_run.over?(@budget)
|
|
23
|
+
violations.compact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def warnings_for(test_run)
|
|
27
|
+
@budget.allowlist.to_a.filter_map do |entry|
|
|
28
|
+
kind = entry.obsolete?(test_run, @budget)
|
|
29
|
+
Warning.new(entry: entry, kind: kind) if kind
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module TestBudget
|
|
6
|
+
class Budget
|
|
7
|
+
class Estimate
|
|
8
|
+
DEFAULT_TIMINGS_PATH = "tmp/test_timings.json"
|
|
9
|
+
TOLERANCE = 0.10
|
|
10
|
+
PER_TEST_CASE_DEFAULTS = {"default" => 3, "system" => 6, "request" => 3, "model" => 1.5}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(timings_path: nil, force: false)
|
|
13
|
+
@timings_path = timings_path
|
|
14
|
+
@force = force
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate
|
|
18
|
+
guard_existing_config!
|
|
19
|
+
|
|
20
|
+
if @timings_path
|
|
21
|
+
generate_from_results
|
|
22
|
+
else
|
|
23
|
+
generate_defaults
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
puts "Created #{DEFAULT_BUDGET_PATH}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def guard_existing_config!
|
|
32
|
+
return if @force || !File.exist?(DEFAULT_BUDGET_PATH)
|
|
33
|
+
|
|
34
|
+
raise TestBudget::Error, "#{DEFAULT_BUDGET_PATH} already exists. Use --force to overwrite."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def generate_from_results
|
|
38
|
+
test_run = Parser::Rspec.parse(@timings_path)
|
|
39
|
+
suite_budget = (test_run.suite_duration * (1 + TOLERANCE)).ceil
|
|
40
|
+
per_test_case_limits = derive_per_test_case(test_run.test_cases)
|
|
41
|
+
|
|
42
|
+
budget = build_budget(
|
|
43
|
+
timings_path: @timings_path,
|
|
44
|
+
suite_max_duration: suite_budget,
|
|
45
|
+
per_test_case_limits: per_test_case_limits
|
|
46
|
+
)
|
|
47
|
+
budget.save
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def generate_defaults
|
|
51
|
+
build_budget(
|
|
52
|
+
timings_path: DEFAULT_TIMINGS_PATH,
|
|
53
|
+
per_test_case_limits: PER_TEST_CASE_DEFAULTS
|
|
54
|
+
).save
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_budget(timings_path:, per_test_case_limits:, suite_max_duration: nil)
|
|
58
|
+
types = per_test_case_limits.except("default").transform_keys(&:to_sym)
|
|
59
|
+
|
|
60
|
+
Budget.new(
|
|
61
|
+
path: DEFAULT_BUDGET_PATH,
|
|
62
|
+
timings_path: timings_path,
|
|
63
|
+
suite: Budget::Suite.new(max_duration: suite_max_duration),
|
|
64
|
+
per_test_case: Budget::PerTestCase.new(
|
|
65
|
+
default: per_test_case_limits["default"],
|
|
66
|
+
types: types
|
|
67
|
+
),
|
|
68
|
+
allowlist: Allowlist.new
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def derive_per_test_case(test_cases)
|
|
73
|
+
grouped = test_cases.group_by { |tc| tc.type.to_s }
|
|
74
|
+
|
|
75
|
+
PER_TEST_CASE_DEFAULTS.merge(
|
|
76
|
+
grouped.transform_values { |cases|
|
|
77
|
+
Statistics.p99(cases.map(&:duration), tolerance: TOLERANCE)
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module TestBudget
|
|
7
|
+
class Budget < Data.define(:path, :timings_path, :suite, :per_test_case, :allowlist)
|
|
8
|
+
DEFAULT_EXPIRATION_DAYS = 60
|
|
9
|
+
|
|
10
|
+
Suite = Data.define(:max_duration)
|
|
11
|
+
PerTestCase = Data.define(:default, :types)
|
|
12
|
+
|
|
13
|
+
def self.estimate(...) = Estimate.new(...).generate
|
|
14
|
+
|
|
15
|
+
def self.load(path)
|
|
16
|
+
config = YAML.safe_load_file(path) || {}
|
|
17
|
+
suite_config = config["suite"] || {}
|
|
18
|
+
per_test_case_config = config["per_test_case"] || {}
|
|
19
|
+
types = per_test_case_config.except("default").transform_keys(&:to_sym)
|
|
20
|
+
|
|
21
|
+
budget = new(
|
|
22
|
+
path: path,
|
|
23
|
+
timings_path: config["timings_path"],
|
|
24
|
+
suite: Suite.new(max_duration: suite_config["max_duration"]),
|
|
25
|
+
per_test_case: PerTestCase.new(
|
|
26
|
+
default: per_test_case_config["default"],
|
|
27
|
+
types: types
|
|
28
|
+
),
|
|
29
|
+
allowlist: Allowlist.new(config["allowlist"] || [])
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
raise TestBudget::Error, "timings_path is required in budget file" unless budget.timings_path
|
|
33
|
+
raise TestBudget::Error, "No limits configured. Set suite.max_duration or per_test_case limits" unless budget.limits_set?
|
|
34
|
+
|
|
35
|
+
budget
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
raise TestBudget::Error, "Budget file not found: #{path}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def exempt?(test_case)
|
|
41
|
+
entry = allowlist.allowed?(test_case.key)
|
|
42
|
+
|
|
43
|
+
entry && !entry.expired?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def limit_for(test_case)
|
|
47
|
+
per_test_case.types[test_case.type] || per_test_case.default
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_to_allowlist(locator, reason:)
|
|
51
|
+
test_run = Parser::Rspec.parse(timings_path)
|
|
52
|
+
test_case = TestCase.find_by_location!(test_run.test_cases, locator)
|
|
53
|
+
|
|
54
|
+
allowlist.add(test_case.key, reason: reason, expires_on: Date.today + DEFAULT_EXPIRATION_DAYS).tap { save }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def prune_allowlist
|
|
58
|
+
test_run = Parser::Rspec.parse(timings_path)
|
|
59
|
+
removed = allowlist.prune(test_run, self)
|
|
60
|
+
save if removed.any?
|
|
61
|
+
removed
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inflate_by(percent)
|
|
65
|
+
factor = 1 + percent
|
|
66
|
+
|
|
67
|
+
with(
|
|
68
|
+
suite: Suite.new(max_duration: suite.max_duration&.*(factor)),
|
|
69
|
+
per_test_case: PerTestCase.new(
|
|
70
|
+
default: per_test_case.default&.*(factor),
|
|
71
|
+
types: per_test_case.types.transform_values { |v| v * factor }
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def limits_set?
|
|
77
|
+
suite.max_duration || per_test_case.default || per_test_case.types.any?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def save
|
|
81
|
+
File.write(path, YAML.dump(to_h))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def to_h
|
|
87
|
+
deep_compact_blank(
|
|
88
|
+
"timings_path" => timings_path,
|
|
89
|
+
"suite" => {"max_duration" => suite.max_duration},
|
|
90
|
+
"per_test_case" => {
|
|
91
|
+
"default" => per_test_case.default,
|
|
92
|
+
**per_test_case.types.transform_keys(&:to_s)
|
|
93
|
+
},
|
|
94
|
+
"allowlist" => allowlist.to_a.map(&:to_h)
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def deep_compact_blank(hash)
|
|
99
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
100
|
+
value = deep_compact_blank(value) if value.is_a?(Hash)
|
|
101
|
+
next if value.nil?
|
|
102
|
+
next if value.respond_to?(:empty?) && value.empty?
|
|
103
|
+
result[key] = value
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "argument_parser"
|
|
5
|
+
|
|
6
|
+
module TestBudget
|
|
7
|
+
class CLI
|
|
8
|
+
def call(argv)
|
|
9
|
+
args = argv.dup
|
|
10
|
+
|
|
11
|
+
return print_help if args.empty? || (args & %w[--help -h]).any?
|
|
12
|
+
return print_version if (args & %w[--version -v]).any?
|
|
13
|
+
|
|
14
|
+
parsed = command_parser.parse!(args)
|
|
15
|
+
|
|
16
|
+
case parsed[:command]
|
|
17
|
+
in "audit" then run_audit(args)
|
|
18
|
+
in "allowlist" then run_allowlist(args)
|
|
19
|
+
in "prune" then run_prune(args)
|
|
20
|
+
in "init" then run_init(args)
|
|
21
|
+
in "help" then print_help
|
|
22
|
+
end
|
|
23
|
+
rescue ArgumentParser::ParseError, TestBudget::Error, OptionParser::MissingArgument => e
|
|
24
|
+
warn e.message
|
|
25
|
+
1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def command_parser
|
|
31
|
+
ArgumentParser.build do
|
|
32
|
+
required :command, pattern: {"audit" => "audit", "allowlist" => "allowlist", "prune" => "prune",
|
|
33
|
+
"init" => "init", "estimate" => "init", "help" => "help"}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def print_help
|
|
38
|
+
puts help_text
|
|
39
|
+
0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def print_version
|
|
43
|
+
puts "test_budget #{TestBudget::VERSION}"
|
|
44
|
+
0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def help_text
|
|
48
|
+
<<~HELP
|
|
49
|
+
Usage: test_budget <command> [options]
|
|
50
|
+
|
|
51
|
+
Commands:
|
|
52
|
+
audit Check test results against budget
|
|
53
|
+
allowlist Exclude a test from budget checks
|
|
54
|
+
prune Remove obsolete allowlist entries
|
|
55
|
+
init Generate starter .test_budget.yml config
|
|
56
|
+
estimate Alias for init
|
|
57
|
+
help Show this help message
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
-h, --help Show this help message
|
|
61
|
+
-v, --version Show version
|
|
62
|
+
HELP
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run_audit(args)
|
|
66
|
+
budget_path = DEFAULT_BUDGET_PATH
|
|
67
|
+
tolerant = false
|
|
68
|
+
|
|
69
|
+
OptionParser.new do |opts|
|
|
70
|
+
opts.banner = "Usage: test_budget audit [options]"
|
|
71
|
+
opts.on("--budget PATH", "Path to budget file") { |path| budget_path = path }
|
|
72
|
+
opts.on("--tolerant", "Apply 10% tolerance to limits") { tolerant = true }
|
|
73
|
+
end.parse!(args)
|
|
74
|
+
|
|
75
|
+
result = Audit.new(budget_path: budget_path, tolerant: tolerant).perform
|
|
76
|
+
|
|
77
|
+
result.passed? ? 0 : 1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run_allowlist(args)
|
|
81
|
+
budget_path = DEFAULT_BUDGET_PATH
|
|
82
|
+
reason = nil
|
|
83
|
+
|
|
84
|
+
OptionParser.new do |opts|
|
|
85
|
+
opts.banner = "Usage: test_budget allowlist FILE:LINE --reason REASON [options]"
|
|
86
|
+
opts.on("--budget PATH", "Path to budget file") { |path| budget_path = path }
|
|
87
|
+
opts.on("--reason REASON", "Reason for allowlisting") { |r| reason = r }
|
|
88
|
+
end.parse!(args)
|
|
89
|
+
|
|
90
|
+
locator = args.shift
|
|
91
|
+
raise Error, "--reason is required for allowlist" unless reason
|
|
92
|
+
raise Error, "locator (e.g., spec/file_spec.rb:10) is required" unless locator
|
|
93
|
+
|
|
94
|
+
entry = Budget.load(budget_path).add_to_allowlist(locator, reason: reason)
|
|
95
|
+
puts "Allowlisted: #{entry.test_case_key}"
|
|
96
|
+
|
|
97
|
+
0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def run_prune(args)
|
|
101
|
+
budget_path = DEFAULT_BUDGET_PATH
|
|
102
|
+
|
|
103
|
+
OptionParser.new do |opts|
|
|
104
|
+
opts.banner = "Usage: test_budget prune [options]"
|
|
105
|
+
opts.on("--budget PATH", "Path to budget file") { |path| budget_path = path }
|
|
106
|
+
end.parse!(args)
|
|
107
|
+
|
|
108
|
+
removed = Budget.load(budget_path).prune_allowlist
|
|
109
|
+
|
|
110
|
+
if removed.any?
|
|
111
|
+
puts "#{removed.size} obsolete allowlist #{(removed.size == 1) ? "entry" : "entries"} removed"
|
|
112
|
+
else
|
|
113
|
+
puts "No obsolete allowlist entries found"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def run_init(args)
|
|
120
|
+
force = false
|
|
121
|
+
|
|
122
|
+
OptionParser.new do |opts|
|
|
123
|
+
opts.banner = "Usage: test_budget init [timings_file] [options]"
|
|
124
|
+
opts.on("--force", "Overwrite existing config") { force = true }
|
|
125
|
+
end.parse!(args)
|
|
126
|
+
timings_path = args.shift
|
|
127
|
+
|
|
128
|
+
Budget.estimate(timings_path: timings_path, force: force)
|
|
129
|
+
|
|
130
|
+
0
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module TestBudget
|
|
6
|
+
module Parser
|
|
7
|
+
module Rspec
|
|
8
|
+
extend self
|
|
9
|
+
|
|
10
|
+
def parse(pattern)
|
|
11
|
+
paths = Dir.glob(pattern)
|
|
12
|
+
raise TestBudget::Error, "No timing files found matching: #{pattern}" if paths.empty?
|
|
13
|
+
|
|
14
|
+
groups = paths.flat_map { |path| parse_file(path) }
|
|
15
|
+
TestRun.new(
|
|
16
|
+
test_cases: groups.flatten,
|
|
17
|
+
suite_duration: groups.map { |g| g.sum(&:duration) }.max || 0
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def parse_file(path)
|
|
24
|
+
read_json_objects(path).map do |data|
|
|
25
|
+
data["examples"].map do |example|
|
|
26
|
+
TestCase.new(
|
|
27
|
+
file: example["file_path"],
|
|
28
|
+
name: example["full_description"],
|
|
29
|
+
duration: example["run_time"],
|
|
30
|
+
status: example["status"],
|
|
31
|
+
line_number: example["line_number"]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read_json_objects(path)
|
|
38
|
+
content = File.read(path)
|
|
39
|
+
[JSON.parse(content)]
|
|
40
|
+
rescue JSON::ParserError
|
|
41
|
+
parse_concatenated_json(content, path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
CONCATENATED_JSON_BOUNDARY = /(?<=\})(?=\{)/
|
|
45
|
+
|
|
46
|
+
def parse_concatenated_json(content, path)
|
|
47
|
+
content.split(CONCATENATED_JSON_BOUNDARY).map { |chunk| JSON.parse(chunk) }
|
|
48
|
+
rescue JSON::ParserError => e
|
|
49
|
+
raise TestBudget::Error, "Invalid JSON in #{path}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestBudget
|
|
4
|
+
class Reporter
|
|
5
|
+
def initialize(budget_path:)
|
|
6
|
+
@budget_path = budget_path
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def report(result)
|
|
10
|
+
violations = result.violations
|
|
11
|
+
|
|
12
|
+
if violations.empty?
|
|
13
|
+
puts "Test budget: all clear"
|
|
14
|
+
else
|
|
15
|
+
puts "Test budget: #{violations.size} violation(s) found\n\n"
|
|
16
|
+
|
|
17
|
+
violations.each_with_index do |violation, index|
|
|
18
|
+
puts " #{index + 1}) #{violation.message}"
|
|
19
|
+
|
|
20
|
+
if (locator = violation.locator)
|
|
21
|
+
budget_flag = (@budget_path == ".test_budget.yml") ? "" : " --budget #{@budget_path}"
|
|
22
|
+
puts " To allowlist, run:"
|
|
23
|
+
puts " bundle exec test_budget allowlist #{locator}#{budget_flag} --reason \"<reason>\""
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
result.warnings.each do |warning|
|
|
29
|
+
warn "Warning: #{warning.message}\n\n"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestBudget
|
|
4
|
+
module Statistics
|
|
5
|
+
def self.p99(values, tolerance: 0) = percentile(0.99, values, tolerance: tolerance)
|
|
6
|
+
|
|
7
|
+
private_class_method def self.percentile(rank, values, tolerance: 0)
|
|
8
|
+
sorted = values.sort
|
|
9
|
+
n = sorted.size
|
|
10
|
+
return apply_tolerance(sorted[0], tolerance) if n == 1
|
|
11
|
+
|
|
12
|
+
index = rank * (n - 1)
|
|
13
|
+
lower = sorted[index.floor]
|
|
14
|
+
upper = sorted[index.ceil]
|
|
15
|
+
result = lower + (upper - lower) * (index - index.floor)
|
|
16
|
+
|
|
17
|
+
apply_tolerance(result, tolerance)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private_class_method def self.apply_tolerance(value, tolerance)
|
|
21
|
+
ceil_to_half(value * (1 + tolerance))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private_class_method def self.ceil_to_half(value)
|
|
25
|
+
(value * 2).ceil / 2.0
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestBudget
|
|
4
|
+
class TestCase < Data.define(:file, :name, :duration, :status, :line_number)
|
|
5
|
+
def initialize(file:, name:, duration:, status:, line_number:)
|
|
6
|
+
super(file: file.delete_prefix("./"), name: name, duration: duration, status: status, line_number: line_number)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
IRREGULAR_TYPES = {
|
|
10
|
+
"policies" => :policy,
|
|
11
|
+
"factories" => :factory,
|
|
12
|
+
"queries" => :query
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def type
|
|
16
|
+
dir = file[%r{^spec/([^/]+)/}, 1]
|
|
17
|
+
return :default unless dir
|
|
18
|
+
|
|
19
|
+
IRREGULAR_TYPES[dir] || dir.chomp("s").to_sym
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def key
|
|
23
|
+
"#{file} -- #{name}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def violation_for(budget)
|
|
27
|
+
return unless violates?(budget)
|
|
28
|
+
|
|
29
|
+
Violation.new(test_case: self, duration: duration, limit: budget.limit_for(self), kind: :per_test_case)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def violates?(budget) = over?(budget) && !exempted?(budget)
|
|
33
|
+
|
|
34
|
+
def over?(budget) = !within?(budget)
|
|
35
|
+
|
|
36
|
+
def within?(budget)
|
|
37
|
+
limit = budget.limit_for(self)
|
|
38
|
+
return true if limit.nil?
|
|
39
|
+
|
|
40
|
+
duration <= limit
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def exempted?(budget)
|
|
44
|
+
budget.exempt?(self)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.find_by_location!(test_cases, locator)
|
|
48
|
+
file, line = parse_locator(locator)
|
|
49
|
+
match = test_cases.find { |tc| tc.file == file && tc.line_number == line }
|
|
50
|
+
|
|
51
|
+
raise Error, "No test case found at #{locator}" unless match
|
|
52
|
+
|
|
53
|
+
match
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.parse_locator(locator)
|
|
57
|
+
m = locator.match(/\A(.+):(\d+)\z/)
|
|
58
|
+
raise Error, "Line number required in locator (e.g., spec/file_spec.rb:10)" unless m
|
|
59
|
+
|
|
60
|
+
[m[1], m[2].to_i]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private_class_method :parse_locator
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestBudget
|
|
4
|
+
class TestRun < Data.define(:test_cases, :suite_duration)
|
|
5
|
+
def over?(budget)
|
|
6
|
+
max = budget.suite.max_duration
|
|
7
|
+
return if max.nil?
|
|
8
|
+
return if suite_duration <= max
|
|
9
|
+
|
|
10
|
+
Violation.new(test_case: nil, duration: suite_duration, limit: max, kind: :suite)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/test_budget/version.rb
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestBudget
|
|
4
|
+
Violation = Data.define(:test_case, :duration, :limit, :kind) do
|
|
5
|
+
def message
|
|
6
|
+
if kind == :suite
|
|
7
|
+
"Suite total time (%.2fs) exceeds limit (%.2fs)" % [duration, limit]
|
|
8
|
+
else
|
|
9
|
+
format("%s (%.2fs) exceeds %s limit (%.2fs)", test_case.key, duration, test_case.type, limit)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def locator
|
|
14
|
+
return nil if kind == :suite
|
|
15
|
+
|
|
16
|
+
"#{test_case.file}:#{test_case.line_number}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestBudget
|
|
4
|
+
Warning = Data.define(:entry, :kind) do
|
|
5
|
+
def message
|
|
6
|
+
reason_line = entry.reason ? "\nreason: #{entry.reason}" : ""
|
|
7
|
+
"obsolete allowlist entry (#{kind})\n#{entry.test_case_key}#{reason_line}"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
data/lib/test_budget.rb
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "test_budget/version"
|
|
4
|
+
require_relative "test_budget/allowlist"
|
|
5
|
+
require_relative "test_budget/test_case"
|
|
6
|
+
require_relative "test_budget/violation"
|
|
7
|
+
require_relative "test_budget/warning"
|
|
8
|
+
require_relative "test_budget/budget"
|
|
9
|
+
require_relative "test_budget/test_run"
|
|
10
|
+
require_relative "test_budget/auditor"
|
|
11
|
+
require_relative "test_budget/reporter"
|
|
12
|
+
require_relative "test_budget/parser/rspec"
|
|
13
|
+
require_relative "test_budget/audit"
|
|
14
|
+
require_relative "test_budget/statistics"
|
|
15
|
+
require_relative "test_budget/budget/estimate"
|
|
16
|
+
require_relative "test_budget/cli"
|
|
4
17
|
|
|
5
18
|
module TestBudget
|
|
6
19
|
class Error < StandardError; end
|
|
7
|
-
|
|
20
|
+
|
|
21
|
+
DEFAULT_BUDGET_PATH = ".test_budget.yml"
|
|
8
22
|
end
|
metadata
CHANGED
|
@@ -1,30 +1,65 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: test_budget
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matheus Richard
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: argument_parser
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
description: Set a time budget for your tests (and your whole suite) and have it automatically
|
|
27
|
+
enforced in CI. No more slow tests sneaking into your suite!
|
|
13
28
|
email:
|
|
14
29
|
- matheusrichardt@gmail.com
|
|
15
|
-
executables:
|
|
30
|
+
executables:
|
|
31
|
+
- test_budget
|
|
16
32
|
extensions: []
|
|
17
33
|
extra_rdoc_files: []
|
|
18
34
|
files:
|
|
35
|
+
- ".test_budget.yml"
|
|
19
36
|
- CHANGELOG.md
|
|
20
37
|
- LICENSE.txt
|
|
21
38
|
- README.md
|
|
22
39
|
- Rakefile
|
|
40
|
+
- exe/test_budget
|
|
23
41
|
- lib/test_budget.rb
|
|
42
|
+
- lib/test_budget/allowlist.rb
|
|
43
|
+
- lib/test_budget/audit.rb
|
|
44
|
+
- lib/test_budget/auditor.rb
|
|
45
|
+
- lib/test_budget/budget.rb
|
|
46
|
+
- lib/test_budget/budget/estimate.rb
|
|
47
|
+
- lib/test_budget/cli.rb
|
|
48
|
+
- lib/test_budget/parser/rspec.rb
|
|
49
|
+
- lib/test_budget/reporter.rb
|
|
50
|
+
- lib/test_budget/statistics.rb
|
|
51
|
+
- lib/test_budget/test_case.rb
|
|
52
|
+
- lib/test_budget/test_run.rb
|
|
24
53
|
- lib/test_budget/version.rb
|
|
54
|
+
- lib/test_budget/violation.rb
|
|
55
|
+
- lib/test_budget/warning.rb
|
|
56
|
+
homepage: https://github.com/thoughtbot/test_budget
|
|
25
57
|
licenses:
|
|
26
58
|
- MIT
|
|
27
|
-
metadata:
|
|
59
|
+
metadata:
|
|
60
|
+
homepage_uri: https://github.com/thoughtbot/test_budget
|
|
61
|
+
source_code_uri: https://github.com/thoughtbot/test_budget
|
|
62
|
+
changelog_uri: https://github.com/thoughtbot/test_budget/blob/main/CHANGELOG.md
|
|
28
63
|
rdoc_options: []
|
|
29
64
|
require_paths:
|
|
30
65
|
- lib
|