specbandit 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 +7 -0
- data/README.md +313 -0
- data/bin/specbandit +7 -0
- data/lib/specbandit/cli.rb +165 -0
- data/lib/specbandit/configuration.rb +38 -0
- data/lib/specbandit/publisher.rb +45 -0
- data/lib/specbandit/redis_queue.rb +54 -0
- data/lib/specbandit/version.rb +5 -0
- data/lib/specbandit/worker.rb +137 -0
- data/lib/specbandit.rb +25 -0
- metadata +116 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bc3e7d15445689d7dfb61865891f47958e87c89cf65052b7351c4ec788bec5d2
|
|
4
|
+
data.tar.gz: 2b54637983a2e0cac8beb4d4521d0f7722819a0ef211b5089dc51915275c26e8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3268930d24a06356a28c8fe472a180d8ae0a84c4cd1ee7fbf33e9ae36f2cfe5b34d2d86746c1a165e7a284e0e2ba731f52036210198d1073da96cf5313a9ec89
|
|
7
|
+
data.tar.gz: 22bd3603d6038e55d8540fc1a155182e71dfe95bdb21328a3c369e677185931a4fe9427b5d12286dcf2113f492323c199c97577fac8691f3e5bcbbc94f8f9ef7
|
data/README.md
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="specbandit.png" alt="specbandit logo" width="200">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# specbandit
|
|
6
|
+
|
|
7
|
+
Distributed RSpec runner using Redis as a work queue. One process pushes spec file paths to a Redis list; multiple CI runners atomically steal batches and execute them in-process via `RSpec::Core::Runner`.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
CI Job 1 (push): RPUSH key f1 f2 f3 ... fN --> [Redis List]
|
|
11
|
+
CI Job 2 (worker): LPOP key 5 <-- [Redis List] --> RSpec
|
|
12
|
+
CI Job 3 (worker): LPOP key 5 <-- [Redis List] --> RSpec
|
|
13
|
+
CI Job N (worker): LPOP key 5 <-- [Redis List] --> RSpec
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`LPOP` with a count argument (Redis 6.2+) is atomic -- multiple workers calling it concurrently will never receive the same file.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Add to your Gemfile:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "specbandit"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or install directly:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
gem install specbandit
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Requirements**: Ruby >= 3.0, Redis >= 6.2
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### 1. Push spec files to Redis
|
|
37
|
+
|
|
38
|
+
A single CI job enqueues all spec file paths before workers start.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Via glob pattern (resolved in Ruby, avoids shell ARG_MAX limits)
|
|
42
|
+
specbandit push --key pr-123-run-456 --pattern 'spec/**/*_spec.rb'
|
|
43
|
+
|
|
44
|
+
# Via stdin pipe (for large file lists or custom filtering)
|
|
45
|
+
find spec -name '*_spec.rb' | specbandit push --key pr-123-run-456
|
|
46
|
+
|
|
47
|
+
# Via direct arguments (for small lists)
|
|
48
|
+
specbandit push --key pr-123-run-456 spec/models/user_spec.rb spec/models/order_spec.rb
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
File input priority: **stdin > --pattern > direct args**.
|
|
52
|
+
|
|
53
|
+
### 2. Steal and run from multiple workers
|
|
54
|
+
|
|
55
|
+
Each CI runner steals batches and runs them. Start as many runners as you want -- they'll divide the work automatically.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
specbandit work --key pr-123-run-456 --batch-size 10
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each worker loops:
|
|
62
|
+
1. `LPOP` N file paths from Redis (atomic)
|
|
63
|
+
2. Run them in-process via `RSpec::Core::Runner`
|
|
64
|
+
3. Repeat until the queue is empty
|
|
65
|
+
4. Exit 0 if all batches passed, 1 if any failed
|
|
66
|
+
|
|
67
|
+
A failing batch does **not** stop the worker. It continues stealing remaining work so other runners aren't blocked waiting on files that will never be consumed.
|
|
68
|
+
|
|
69
|
+
### CLI reference
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
specbandit push [options] [files...]
|
|
73
|
+
--key KEY Redis queue key (required)
|
|
74
|
+
--pattern PATTERN Glob pattern for file discovery
|
|
75
|
+
--redis-url URL Redis URL (default: redis://localhost:6379)
|
|
76
|
+
--key-ttl SECONDS TTL for the Redis key (default: 21600 / 6 hours)
|
|
77
|
+
|
|
78
|
+
specbandit work [options]
|
|
79
|
+
--key KEY Redis queue key (required)
|
|
80
|
+
--batch-size N Files per batch (default: 5)
|
|
81
|
+
--redis-url URL Redis URL (default: redis://localhost:6379)
|
|
82
|
+
--rspec-opts OPTS Extra options forwarded to RSpec
|
|
83
|
+
--key-rerun KEY Per-runner rerun key for re-run support (see below)
|
|
84
|
+
--key-rerun-ttl SECS TTL for rerun key (default: 604800 / 1 week)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Environment variables
|
|
88
|
+
|
|
89
|
+
All CLI options can be set via environment variables:
|
|
90
|
+
|
|
91
|
+
| Variable | Description | Default |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `SPECBANDIT_KEY` | Redis queue key | _(required)_ |
|
|
94
|
+
| `SPECBANDIT_REDIS_URL` | Redis connection URL | `redis://localhost:6379` |
|
|
95
|
+
| `SPECBANDIT_BATCH_SIZE` | Files per steal | `5` |
|
|
96
|
+
| `SPECBANDIT_KEY_TTL` | Key expiry in seconds | `21600` (6 hours) |
|
|
97
|
+
| `SPECBANDIT_RSPEC_OPTS` | Space-separated RSpec options | _(none)_ |
|
|
98
|
+
| `SPECBANDIT_KEY_RERUN` | Per-runner rerun key | _(none)_ |
|
|
99
|
+
| `SPECBANDIT_KEY_RERUN_TTL` | Rerun key expiry in seconds | `604800` (1 week) |
|
|
100
|
+
|
|
101
|
+
CLI flags take precedence over environment variables.
|
|
102
|
+
|
|
103
|
+
### Ruby API
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
require "specbandit"
|
|
107
|
+
|
|
108
|
+
Specbandit.configure do |c|
|
|
109
|
+
c.redis_url = "redis://my-redis:6379"
|
|
110
|
+
c.key = "pr-123-run-456"
|
|
111
|
+
c.batch_size = 10
|
|
112
|
+
c.key_ttl = 7200 # 2 hours (default: 21600 / 6 hours)
|
|
113
|
+
c.key_rerun = "pr-123-run-456-runner-3"
|
|
114
|
+
c.key_rerun_ttl = 604_800 # 1 week (default)
|
|
115
|
+
c.rspec_opts = ["--format", "documentation"]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Push
|
|
119
|
+
publisher = Specbandit::Publisher.new
|
|
120
|
+
publisher.publish(pattern: "spec/**/*_spec.rb")
|
|
121
|
+
|
|
122
|
+
# Work (will auto-detect steal/record/replay mode based on key_rerun state)
|
|
123
|
+
worker = Specbandit::Worker.new
|
|
124
|
+
exit_code = worker.run
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Example: GitHub Actions (basic)
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
jobs:
|
|
131
|
+
push-specs:
|
|
132
|
+
runs-on: ubuntu-latest
|
|
133
|
+
steps:
|
|
134
|
+
- uses: actions/checkout@v4
|
|
135
|
+
- run: bundle install
|
|
136
|
+
- run: |
|
|
137
|
+
specbandit push \
|
|
138
|
+
--key "pr-${{ github.event.number }}-${{ github.run_id }}" \
|
|
139
|
+
--redis-url "${{ secrets.REDIS_URL }}" \
|
|
140
|
+
--pattern 'spec/**/*_spec.rb'
|
|
141
|
+
|
|
142
|
+
run-specs:
|
|
143
|
+
runs-on: ubuntu-latest
|
|
144
|
+
needs: push-specs
|
|
145
|
+
strategy:
|
|
146
|
+
matrix:
|
|
147
|
+
runner: [1, 2, 3, 4]
|
|
148
|
+
steps:
|
|
149
|
+
- uses: actions/checkout@v4
|
|
150
|
+
- run: bundle install
|
|
151
|
+
- run: |
|
|
152
|
+
specbandit work \
|
|
153
|
+
--key "pr-${{ github.event.number }}-${{ github.run_id }}" \
|
|
154
|
+
--redis-url "${{ secrets.REDIS_URL }}" \
|
|
155
|
+
--batch-size 10
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Re-running failed CI jobs
|
|
159
|
+
|
|
160
|
+
### The problem
|
|
161
|
+
|
|
162
|
+
When you use specbandit to distribute tests across multiple CI runners (e.g. a GitHub Actions matrix with 4 runners), each runner **steals** a random subset of spec files from the shared Redis queue. The distribution is non-deterministic -- which runner gets which files depends on timing.
|
|
163
|
+
|
|
164
|
+
This creates a subtle but serious problem with CI re-runs:
|
|
165
|
+
|
|
166
|
+
1. **First run**: Runner #3 steals and executes files X, Y, Z. File Y fails. The shared queue is now empty (all files were consumed across all runners).
|
|
167
|
+
2. **Re-run of runner #3**: GitHub Actions re-runs only the failed runner. It starts `specbandit work` again with the same `--key`, but the shared queue is already empty. Runner #3 sees nothing to do and **exits 0 -- the failing test silently passes**.
|
|
168
|
+
|
|
169
|
+
This happens because GitHub Actions re-runs **reuse the same `run_id`**, so the key resolves to the same (now empty) Redis list.
|
|
170
|
+
|
|
171
|
+
### The solution: `--key-rerun`
|
|
172
|
+
|
|
173
|
+
The `--key-rerun` flag gives each matrix runner its own "memory" in Redis. It enables specbandit to **record** which files each runner executed, and **replay** exactly those files on a re-run.
|
|
174
|
+
|
|
175
|
+
Each runner gets a unique rerun key (typically including the matrix index):
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
specbandit work \
|
|
179
|
+
--key "pr-42-run-100" \
|
|
180
|
+
--key-rerun "pr-42-run-100-runner-3" \
|
|
181
|
+
--batch-size 10
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### How it works: three operating modes
|
|
185
|
+
|
|
186
|
+
Specbandit detects the mode automatically based on the state of `--key-rerun`:
|
|
187
|
+
|
|
188
|
+
| `--key-rerun` provided? | Rerun key in Redis | Mode | Behavior |
|
|
189
|
+
|---|---|---|---|
|
|
190
|
+
| No | -- | **Steal** | Original behavior. Steal from shared queue, run, done. |
|
|
191
|
+
| Yes | Empty | **Record** | Steal from shared queue + record each batch to the rerun key. |
|
|
192
|
+
| Yes | Has data | **Replay** | Ignore shared queue entirely. Re-run exactly the recorded files. |
|
|
193
|
+
|
|
194
|
+
**On first run**, the rerun key doesn't exist yet (empty), so specbandit enters **record mode**:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
┌──────────────────┐ LPOP N ┌──────────────────┐ RPUSH ┌──────────────────────────────┐
|
|
198
|
+
│ Redis │ ─────────> │ Runner #3 │ ────────> │ Redis │
|
|
199
|
+
│ --key │ │ │ │ --key-rerun │
|
|
200
|
+
│ (shared queue) │ │ steal + record │ │ (per-runner memory) │
|
|
201
|
+
│ │ │ + run specs │ │ │
|
|
202
|
+
│ [f1,f2,...,fN] │ │ │ │ [f5,f6,f7] ← what #3 stole │
|
|
203
|
+
└──────────────────┘ └──────────────────┘ └──────────────────────────────┘
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**On re-run**, the rerun key already contains the files from the first run, so specbandit enters **replay mode**:
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
┌──────────────────┐ LRANGE ┌──────────────────────────────┐
|
|
210
|
+
--key NOT touched │ Runner #3 │ <──────── │ Redis │
|
|
211
|
+
│ │ │ --key-rerun │
|
|
212
|
+
│ replay specs │ │ (per-runner memory) │
|
|
213
|
+
│ f5, f6, f7 │ │ │
|
|
214
|
+
└──────────────────┘ │ [f5,f6,f7] ← still there │
|
|
215
|
+
└──────────────────────────────┘
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Key details:
|
|
219
|
+
|
|
220
|
+
- **Replay reads non-destructively** (`LRANGE`, not `LPOP`). The rerun key is never consumed. If you re-run the same runner multiple times, it replays the same files every time.
|
|
221
|
+
- **The shared queue is never touched in replay mode**. Other runners are unaffected.
|
|
222
|
+
- **Each runner has its own rerun key**. Only the re-run runner enters replay mode; runners that aren't re-run don't start at all.
|
|
223
|
+
|
|
224
|
+
### Complete GitHub Actions example with re-run support
|
|
225
|
+
|
|
226
|
+
```yaml
|
|
227
|
+
jobs:
|
|
228
|
+
push-specs:
|
|
229
|
+
runs-on: ubuntu-latest
|
|
230
|
+
steps:
|
|
231
|
+
- uses: actions/checkout@v4
|
|
232
|
+
- run: bundle install
|
|
233
|
+
- run: |
|
|
234
|
+
specbandit push \
|
|
235
|
+
--key "pr-${{ github.event.number }}-${{ github.run_id }}" \
|
|
236
|
+
--redis-url "${{ secrets.REDIS_URL }}" \
|
|
237
|
+
--pattern 'spec/**/*_spec.rb'
|
|
238
|
+
|
|
239
|
+
run-specs:
|
|
240
|
+
runs-on: ubuntu-latest
|
|
241
|
+
needs: push-specs
|
|
242
|
+
strategy:
|
|
243
|
+
matrix:
|
|
244
|
+
runner: [1, 2, 3, 4]
|
|
245
|
+
steps:
|
|
246
|
+
- uses: actions/checkout@v4
|
|
247
|
+
- run: bundle install
|
|
248
|
+
- run: |
|
|
249
|
+
specbandit work \
|
|
250
|
+
--key "pr-${{ github.event.number }}-${{ github.run_id }}" \
|
|
251
|
+
--key-rerun "pr-${{ github.event.number }}-${{ github.run_id }}-runner-${{ matrix.runner }}" \
|
|
252
|
+
--redis-url "${{ secrets.REDIS_URL }}" \
|
|
253
|
+
--batch-size 10
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The only difference from the basic example is the addition of `--key-rerun`. The key structure:
|
|
257
|
+
|
|
258
|
+
- `--key` = `pr-42-run-100` -- **shared** across all 4 runners, same on re-run (because `run_id` is reused)
|
|
259
|
+
- `--key-rerun` = `pr-42-run-100-runner-3` -- **unique per runner**, same on re-run
|
|
260
|
+
|
|
261
|
+
### Walk-through: what happens step by step
|
|
262
|
+
|
|
263
|
+
**First run (all 4 runners start fresh):**
|
|
264
|
+
|
|
265
|
+
| Runner | Mode | What happens |
|
|
266
|
+
|---|---|---|
|
|
267
|
+
| Runner 1 | Record | Steals files A, B, C from shared queue. Records them to `...-runner-1`. |
|
|
268
|
+
| Runner 2 | Record | Steals files D, E from shared queue. Records them to `...-runner-2`. |
|
|
269
|
+
| Runner 3 | Record | Steals files F, G, H from shared queue. File G fails. Records them to `...-runner-3`. |
|
|
270
|
+
| Runner 4 | Record | Steals files I, J from shared queue. Records them to `...-runner-4`. |
|
|
271
|
+
|
|
272
|
+
**Re-run of runner 3 only:**
|
|
273
|
+
|
|
274
|
+
| Runner | Mode | What happens |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| Runner 3 | Replay | Reads F, G, H from `...-runner-3`. Runs exactly those files. G still fails = correctly reported. |
|
|
277
|
+
|
|
278
|
+
Runners 1, 2, 4 are not started at all.
|
|
279
|
+
|
|
280
|
+
### Rerun key TTL
|
|
281
|
+
|
|
282
|
+
The rerun key defaults to a **1 week TTL** (`604800` seconds). This is intentionally longer than the shared queue TTL (6 hours) because re-runs can happen hours or even days after the original CI run.
|
|
283
|
+
|
|
284
|
+
Override via `--key-rerun-ttl` or `SPECBANDIT_KEY_RERUN_TTL`:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
# Set rerun key to expire after 3 days
|
|
288
|
+
specbandit work \
|
|
289
|
+
--key "pr-42-run-100" \
|
|
290
|
+
--key-rerun "pr-42-run-100-runner-3" \
|
|
291
|
+
--key-rerun-ttl 259200
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## How it works
|
|
295
|
+
|
|
296
|
+
- **Push** uses `RPUSH` to append all file paths to a Redis list in a single command, then sets `EXPIRE` on the key (default: 6 hours) to ensure stale queues are automatically cleaned up.
|
|
297
|
+
- **Steal** uses `LPOP key count` (Redis 6.2+), which atomically pops up to N elements. No Lua scripts, no locks, no race conditions.
|
|
298
|
+
- **Record** (when `--key-rerun` is set): after each steal, the batch is also `RPUSH`ed to the per-runner rerun key with its own TTL (default: 1 week).
|
|
299
|
+
- **Replay** (when `--key-rerun` has data): reads all files from the rerun key via `LRANGE` (non-destructive), splits into batches, and runs them locally. The shared queue is never touched.
|
|
300
|
+
- **Run** uses `RSpec::Core::Runner.run` in-process with `RSpec.clear_examples` between batches to reset example state while preserving configuration. No subprocess forking overhead.
|
|
301
|
+
- **Exit code** is 0 if every batch passed (or the queue was already empty), 1 if any batch had failures.
|
|
302
|
+
|
|
303
|
+
## Development
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
bundle install
|
|
307
|
+
bundle exec rspec # unit tests (no Redis needed)
|
|
308
|
+
bundle exec rspec spec/integration/ # integration tests (requires Redis on localhost:6379)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## License
|
|
312
|
+
|
|
313
|
+
MIT
|
data/bin/specbandit
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Specbandit
|
|
6
|
+
class CLI
|
|
7
|
+
COMMANDS = %w[push work].freeze
|
|
8
|
+
|
|
9
|
+
def self.run(argv = ARGV)
|
|
10
|
+
new(argv).execute
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :argv
|
|
14
|
+
|
|
15
|
+
def initialize(argv)
|
|
16
|
+
@argv = argv.dup
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def execute
|
|
20
|
+
command = argv.shift
|
|
21
|
+
|
|
22
|
+
case command
|
|
23
|
+
when 'push'
|
|
24
|
+
run_push
|
|
25
|
+
when 'work'
|
|
26
|
+
run_work
|
|
27
|
+
when nil, '-h', '--help'
|
|
28
|
+
print_usage
|
|
29
|
+
0
|
|
30
|
+
when '-v', '--version'
|
|
31
|
+
puts "specbandit #{VERSION}"
|
|
32
|
+
0
|
|
33
|
+
else
|
|
34
|
+
warn "Unknown command: #{command}"
|
|
35
|
+
print_usage
|
|
36
|
+
1
|
|
37
|
+
end
|
|
38
|
+
rescue Specbandit::Error => e
|
|
39
|
+
warn "[specbandit] Error: #{e.message}"
|
|
40
|
+
1
|
|
41
|
+
rescue Redis::BaseError => e
|
|
42
|
+
warn "[specbandit] Redis error: #{e.message}"
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def run_push
|
|
49
|
+
options = { pattern: nil }
|
|
50
|
+
|
|
51
|
+
parser = OptionParser.new do |opts|
|
|
52
|
+
opts.banner = 'Usage: specbandit push [options] [files...]'
|
|
53
|
+
|
|
54
|
+
opts.on('--key KEY', 'Redis queue key (required, or set SPECBANDIT_KEY)') do |v|
|
|
55
|
+
Specbandit.configuration.key = v
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
opts.on('--pattern PATTERN', "Glob pattern to resolve files (e.g. 'spec/**/*_spec.rb')") do |v|
|
|
59
|
+
options[:pattern] = v
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
opts.on('--redis-url URL', 'Redis URL (default: redis://localhost:6379)') do |v|
|
|
63
|
+
Specbandit.configuration.redis_url = v
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opts.on('--key-ttl SECONDS', Integer, 'TTL for the Redis key in seconds (default: 21600 / 6 hours)') do |v|
|
|
67
|
+
Specbandit.configuration.key_ttl = v
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
71
|
+
puts opts
|
|
72
|
+
return 0
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
parser.parse!(argv)
|
|
77
|
+
Specbandit.configuration.validate!
|
|
78
|
+
|
|
79
|
+
publisher = Publisher.new
|
|
80
|
+
files_arg = argv.empty? ? [] : argv
|
|
81
|
+
count = publisher.publish(files: files_arg, pattern: options[:pattern])
|
|
82
|
+
|
|
83
|
+
count.positive? ? 0 : 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def run_work
|
|
87
|
+
parser = OptionParser.new do |opts|
|
|
88
|
+
opts.banner = 'Usage: specbandit work [options]'
|
|
89
|
+
|
|
90
|
+
opts.on('--key KEY', 'Redis queue key (required, or set SPECBANDIT_KEY)') do |v|
|
|
91
|
+
Specbandit.configuration.key = v
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
opts.on('--batch-size N', Integer, 'Number of files to steal per batch (default: 5)') do |v|
|
|
95
|
+
Specbandit.configuration.batch_size = v
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
opts.on('--redis-url URL', 'Redis URL (default: redis://localhost:6379)') do |v|
|
|
99
|
+
Specbandit.configuration.redis_url = v
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
opts.on('--rspec-opts OPTS', 'Extra options to pass to RSpec (space-separated)') do |v|
|
|
103
|
+
Specbandit.configuration.rspec_opts = v.split
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
opts.on('--key-rerun KEY', 'Per-runner rerun key for re-run support') do |v|
|
|
107
|
+
Specbandit.configuration.key_rerun = v
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
opts.on('--key-rerun-ttl SECONDS', Integer, 'TTL for rerun key in seconds (default: 604800 / 1 week)') do |v|
|
|
111
|
+
Specbandit.configuration.key_rerun_ttl = v
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
115
|
+
puts opts
|
|
116
|
+
return 0
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
parser.parse!(argv)
|
|
121
|
+
Specbandit.configuration.validate!
|
|
122
|
+
|
|
123
|
+
worker = Worker.new
|
|
124
|
+
worker.run
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def print_usage
|
|
128
|
+
puts <<~USAGE
|
|
129
|
+
specbandit v#{VERSION} - Distributed RSpec runner using Redis
|
|
130
|
+
|
|
131
|
+
Usage:
|
|
132
|
+
specbandit push [options] [files...] Enqueue spec files into Redis
|
|
133
|
+
specbandit work [options] Steal and run spec file batches
|
|
134
|
+
|
|
135
|
+
Push options:
|
|
136
|
+
--key KEY Redis queue key (required, or set SPECBANDIT_KEY)
|
|
137
|
+
--pattern PATTERN Glob pattern for file discovery (e.g. 'spec/**/*_spec.rb')
|
|
138
|
+
--redis-url URL Redis URL (default: redis://localhost:6379)
|
|
139
|
+
--key-ttl SECONDS TTL for the Redis key (default: 21600 / 6 hours)
|
|
140
|
+
|
|
141
|
+
Work options:
|
|
142
|
+
--key KEY Redis queue key (required, or set SPECBANDIT_KEY)
|
|
143
|
+
--batch-size N Files per batch (default: 5, or set SPECBANDIT_BATCH_SIZE)
|
|
144
|
+
--redis-url URL Redis URL (default: redis://localhost:6379)
|
|
145
|
+
--rspec-opts OPTS Extra options forwarded to RSpec
|
|
146
|
+
--key-rerun KEY Per-runner rerun key for re-run support
|
|
147
|
+
--key-rerun-ttl N TTL for rerun key (default: 604800 / 1 week)
|
|
148
|
+
|
|
149
|
+
Environment variables:
|
|
150
|
+
SPECBANDIT_KEY Queue key
|
|
151
|
+
SPECBANDIT_REDIS_URL Redis URL
|
|
152
|
+
SPECBANDIT_BATCH_SIZE Batch size
|
|
153
|
+
SPECBANDIT_KEY_TTL Key TTL in seconds (default: 21600)
|
|
154
|
+
SPECBANDIT_RSPEC_OPTS RSpec options
|
|
155
|
+
SPECBANDIT_KEY_RERUN Per-runner rerun key
|
|
156
|
+
SPECBANDIT_KEY_RERUN_TTL Rerun key TTL in seconds (default: 604800)
|
|
157
|
+
|
|
158
|
+
File input priority for push:
|
|
159
|
+
1. stdin (piped) echo "spec/a_spec.rb" | specbandit push --key KEY
|
|
160
|
+
2. --pattern specbandit push --key KEY --pattern 'spec/**/*_spec.rb'
|
|
161
|
+
3. direct args specbandit push --key KEY spec/a_spec.rb spec/b_spec.rb
|
|
162
|
+
USAGE
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Specbandit
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :redis_url, :batch_size, :key, :rspec_opts, :key_ttl,
|
|
6
|
+
:key_rerun, :key_rerun_ttl
|
|
7
|
+
|
|
8
|
+
DEFAULT_REDIS_URL = 'redis://localhost:6379'
|
|
9
|
+
DEFAULT_BATCH_SIZE = 5
|
|
10
|
+
DEFAULT_KEY_TTL = 21_600 # 6 hours in seconds
|
|
11
|
+
DEFAULT_KEY_RERUN_TTL = 604_800 # 1 week in seconds
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@redis_url = ENV.fetch('SPECBANDIT_REDIS_URL', DEFAULT_REDIS_URL)
|
|
15
|
+
@batch_size = Integer(ENV.fetch('SPECBANDIT_BATCH_SIZE', DEFAULT_BATCH_SIZE))
|
|
16
|
+
@key = ENV.fetch('SPECBANDIT_KEY', nil)
|
|
17
|
+
@rspec_opts = parse_rspec_opts(ENV.fetch('SPECBANDIT_RSPEC_OPTS', nil))
|
|
18
|
+
@key_ttl = Integer(ENV.fetch('SPECBANDIT_KEY_TTL', DEFAULT_KEY_TTL))
|
|
19
|
+
@key_rerun = ENV.fetch('SPECBANDIT_KEY_RERUN', nil)
|
|
20
|
+
@key_rerun_ttl = Integer(ENV.fetch('SPECBANDIT_KEY_RERUN_TTL', DEFAULT_KEY_RERUN_TTL))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate!
|
|
24
|
+
raise Error, 'key is required (set via --key or SPECBANDIT_KEY)' if key.nil? || key.empty?
|
|
25
|
+
raise Error, 'batch_size must be a positive integer' unless batch_size.positive?
|
|
26
|
+
raise Error, 'key_ttl must be a positive integer' unless key_ttl.positive?
|
|
27
|
+
raise Error, 'key_rerun_ttl must be a positive integer' unless key_rerun_ttl.positive?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_rspec_opts(opts)
|
|
33
|
+
return [] if opts.nil? || opts.empty?
|
|
34
|
+
|
|
35
|
+
opts.split
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Specbandit
|
|
4
|
+
class Publisher
|
|
5
|
+
attr_reader :queue, :key, :key_ttl, :output
|
|
6
|
+
|
|
7
|
+
def initialize(key: Specbandit.configuration.key, key_ttl: Specbandit.configuration.key_ttl, queue: nil,
|
|
8
|
+
output: $stdout)
|
|
9
|
+
@key = key
|
|
10
|
+
@key_ttl = key_ttl
|
|
11
|
+
@queue = queue || RedisQueue.new
|
|
12
|
+
@output = output
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Resolve files from the three input sources (priority: stdin > pattern > args)
|
|
16
|
+
# and push them onto the Redis queue.
|
|
17
|
+
#
|
|
18
|
+
# Returns the number of files enqueued.
|
|
19
|
+
def publish(files: [], pattern: nil)
|
|
20
|
+
resolved = resolve_files(files: files, pattern: pattern)
|
|
21
|
+
|
|
22
|
+
if resolved.empty?
|
|
23
|
+
output.puts '[specbandit] No files to enqueue.'
|
|
24
|
+
return 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
queue.push(key, resolved, ttl: key_ttl)
|
|
28
|
+
output.puts "[specbandit] Enqueued #{resolved.size} files onto key '#{key}' (TTL: #{key_ttl}s)."
|
|
29
|
+
resolved.size
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def resolve_files(files:, pattern:)
|
|
35
|
+
# Priority 1: stdin (if not a TTY)
|
|
36
|
+
return $stdin.each_line.map(&:strip).reject(&:empty?) unless $stdin.tty?
|
|
37
|
+
|
|
38
|
+
# Priority 2: --pattern flag (Dir.glob in Ruby, no shell expansion)
|
|
39
|
+
return Dir.glob(pattern).sort if pattern && !pattern.empty?
|
|
40
|
+
|
|
41
|
+
# Priority 3: direct file arguments
|
|
42
|
+
Array(files)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'redis'
|
|
4
|
+
|
|
5
|
+
module Specbandit
|
|
6
|
+
class RedisQueue
|
|
7
|
+
attr_reader :redis
|
|
8
|
+
|
|
9
|
+
def initialize(redis_url: Specbandit.configuration.redis_url)
|
|
10
|
+
@redis = Redis.new(url: redis_url)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Push file paths onto the queue and set an expiry on the key.
|
|
14
|
+
# Returns the new length of the list.
|
|
15
|
+
def push(key, files, ttl: nil)
|
|
16
|
+
return 0 if files.empty?
|
|
17
|
+
|
|
18
|
+
count = redis.rpush(key, files)
|
|
19
|
+
redis.expire(key, ttl) if ttl
|
|
20
|
+
count
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Atomically steal up to `count` file paths from the queue.
|
|
24
|
+
# Returns an array of file paths (empty array when exhausted).
|
|
25
|
+
#
|
|
26
|
+
# Uses LPOP with count argument (Redis 6.2+).
|
|
27
|
+
def steal(key, count)
|
|
28
|
+
result = redis.lpop(key, count)
|
|
29
|
+
|
|
30
|
+
# LPOP returns nil when the key doesn't exist or list is empty,
|
|
31
|
+
# and returns a single string (not array) when count is 1 on some versions.
|
|
32
|
+
case result
|
|
33
|
+
when nil then []
|
|
34
|
+
when String then [result]
|
|
35
|
+
else Array(result)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns the current length of the queue.
|
|
40
|
+
def length(key)
|
|
41
|
+
redis.llen(key)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Read all file paths from the list non-destructively.
|
|
45
|
+
# Returns an array of file paths (empty array when key doesn't exist).
|
|
46
|
+
def read_all(key)
|
|
47
|
+
redis.lrange(key, 0, -1)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def close
|
|
51
|
+
redis.close
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/core'
|
|
4
|
+
|
|
5
|
+
module Specbandit
|
|
6
|
+
class Worker
|
|
7
|
+
attr_reader :queue, :key, :batch_size, :rspec_opts, :key_rerun, :key_rerun_ttl, :output
|
|
8
|
+
|
|
9
|
+
def initialize(
|
|
10
|
+
key: Specbandit.configuration.key,
|
|
11
|
+
batch_size: Specbandit.configuration.batch_size,
|
|
12
|
+
rspec_opts: Specbandit.configuration.rspec_opts,
|
|
13
|
+
key_rerun: Specbandit.configuration.key_rerun,
|
|
14
|
+
key_rerun_ttl: Specbandit.configuration.key_rerun_ttl,
|
|
15
|
+
queue: nil,
|
|
16
|
+
output: $stdout
|
|
17
|
+
)
|
|
18
|
+
@key = key
|
|
19
|
+
@batch_size = batch_size
|
|
20
|
+
@rspec_opts = Array(rspec_opts)
|
|
21
|
+
@key_rerun = key_rerun
|
|
22
|
+
@key_rerun_ttl = key_rerun_ttl
|
|
23
|
+
@queue = queue || RedisQueue.new
|
|
24
|
+
@output = output
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Main entry point. Detects the operating mode and dispatches accordingly.
|
|
28
|
+
#
|
|
29
|
+
# Returns 0 if all batches passed (or nothing to do), 1 if any batch failed.
|
|
30
|
+
def run
|
|
31
|
+
if key_rerun
|
|
32
|
+
rerun_files = queue.read_all(key_rerun)
|
|
33
|
+
if rerun_files.any?
|
|
34
|
+
run_replay(rerun_files)
|
|
35
|
+
else
|
|
36
|
+
run_steal(record: true)
|
|
37
|
+
end
|
|
38
|
+
else
|
|
39
|
+
run_steal(record: false)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Replay mode: run a known list of files in local batches.
|
|
46
|
+
# Used when re-running a failed CI job -- the rerun key already
|
|
47
|
+
# contains the exact files this runner executed previously.
|
|
48
|
+
def run_replay(files)
|
|
49
|
+
output.puts "[specbandit] Replay mode: found #{files.size} files in rerun key '#{key_rerun}'."
|
|
50
|
+
output.puts '[specbandit] Running previously recorded files (not touching shared queue).'
|
|
51
|
+
|
|
52
|
+
failed = false
|
|
53
|
+
batch_num = 0
|
|
54
|
+
|
|
55
|
+
files.each_slice(batch_size) do |batch|
|
|
56
|
+
batch_num += 1
|
|
57
|
+
output.puts "[specbandit] Batch ##{batch_num}: running #{batch.size} files"
|
|
58
|
+
batch.each { |f| output.puts " #{f}" }
|
|
59
|
+
|
|
60
|
+
exit_code = run_rspec_batch(batch)
|
|
61
|
+
if exit_code != 0
|
|
62
|
+
output.puts "[specbandit] Batch ##{batch_num} FAILED (exit code: #{exit_code})"
|
|
63
|
+
failed = true
|
|
64
|
+
else
|
|
65
|
+
output.puts "[specbandit] Batch ##{batch_num} passed."
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
output.puts "[specbandit] Replay finished: #{batch_num} batches. #{failed ? 'SOME FAILED' : 'All passed.'}"
|
|
70
|
+
failed ? 1 : 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Steal mode: atomically pop batches from the shared queue.
|
|
74
|
+
# When record is true, each stolen batch is also pushed to the
|
|
75
|
+
# rerun key so this runner can replay them on a re-run.
|
|
76
|
+
def run_steal(record:)
|
|
77
|
+
mode_label = record ? 'Record' : 'Steal'
|
|
78
|
+
output.puts "[specbandit] #{mode_label} mode: stealing batches from '#{key}'."
|
|
79
|
+
output.puts "[specbandit] Recording stolen files to rerun key '#{key_rerun}'." if record
|
|
80
|
+
|
|
81
|
+
failed = false
|
|
82
|
+
batch_num = 0
|
|
83
|
+
|
|
84
|
+
loop do
|
|
85
|
+
files = queue.steal(key, batch_size)
|
|
86
|
+
|
|
87
|
+
if files.empty?
|
|
88
|
+
output.puts '[specbandit] Queue exhausted. No more files to run.'
|
|
89
|
+
break
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Record the stolen batch so this runner can replay on re-run
|
|
93
|
+
queue.push(key_rerun, files, ttl: key_rerun_ttl) if record
|
|
94
|
+
|
|
95
|
+
batch_num += 1
|
|
96
|
+
output.puts "[specbandit] Batch ##{batch_num}: running #{files.size} files"
|
|
97
|
+
files.each { |f| output.puts " #{f}" }
|
|
98
|
+
|
|
99
|
+
exit_code = run_rspec_batch(files)
|
|
100
|
+
if exit_code != 0
|
|
101
|
+
output.puts "[specbandit] Batch ##{batch_num} FAILED (exit code: #{exit_code})"
|
|
102
|
+
failed = true
|
|
103
|
+
else
|
|
104
|
+
output.puts "[specbandit] Batch ##{batch_num} passed."
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if batch_num.zero?
|
|
109
|
+
output.puts '[specbandit] Nothing to do (queue was empty).'
|
|
110
|
+
else
|
|
111
|
+
output.puts "[specbandit] Finished #{batch_num} batches. #{failed ? 'SOME FAILED' : 'All passed.'}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
failed ? 1 : 0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def run_rspec_batch(files)
|
|
118
|
+
# Clear example state from previous batches so RSpec can run cleanly
|
|
119
|
+
# in the same process. This preserves configuration but resets
|
|
120
|
+
# the world (example groups, examples, shared groups, etc.).
|
|
121
|
+
RSpec.clear_examples
|
|
122
|
+
|
|
123
|
+
args = files + rspec_opts
|
|
124
|
+
err = StringIO.new
|
|
125
|
+
out = StringIO.new
|
|
126
|
+
|
|
127
|
+
RSpec::Core::Runner.run(args, err, out)
|
|
128
|
+
ensure
|
|
129
|
+
# Print RSpec output through our output stream
|
|
130
|
+
rspec_output = out&.string
|
|
131
|
+
output.print(rspec_output) unless rspec_output.nil? || rspec_output.empty?
|
|
132
|
+
|
|
133
|
+
rspec_err = err&.string
|
|
134
|
+
output.print(rspec_err) unless rspec_err.nil? || rspec_err.empty?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/specbandit.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'specbandit/version'
|
|
4
|
+
require 'specbandit/configuration'
|
|
5
|
+
require 'specbandit/redis_queue'
|
|
6
|
+
require 'specbandit/publisher'
|
|
7
|
+
require 'specbandit/worker'
|
|
8
|
+
|
|
9
|
+
module Specbandit
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def configuration
|
|
14
|
+
@configuration ||= Configuration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield(configuration)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reset_configuration!
|
|
22
|
+
@configuration = Configuration.new
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: specbandit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ferran Basora
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-08 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: redis
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '4.6'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '6'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '4.6'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '6'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: rake
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
type: :development
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: rspec
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.0'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.0'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: rspec-core
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.0'
|
|
68
|
+
type: :development
|
|
69
|
+
prerelease: false
|
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.0'
|
|
75
|
+
description: A work-stealing distributed RSpec runner. Push spec file paths to a Redis
|
|
76
|
+
list, then multiple CI runners atomically steal batches and execute them in-process
|
|
77
|
+
via RSpec::Core::Runner.
|
|
78
|
+
email:
|
|
79
|
+
executables:
|
|
80
|
+
- specbandit
|
|
81
|
+
extensions: []
|
|
82
|
+
extra_rdoc_files: []
|
|
83
|
+
files:
|
|
84
|
+
- README.md
|
|
85
|
+
- bin/specbandit
|
|
86
|
+
- lib/specbandit.rb
|
|
87
|
+
- lib/specbandit/cli.rb
|
|
88
|
+
- lib/specbandit/configuration.rb
|
|
89
|
+
- lib/specbandit/publisher.rb
|
|
90
|
+
- lib/specbandit/redis_queue.rb
|
|
91
|
+
- lib/specbandit/version.rb
|
|
92
|
+
- lib/specbandit/worker.rb
|
|
93
|
+
homepage: https://github.com/factorialco/specbandit
|
|
94
|
+
licenses:
|
|
95
|
+
- MIT
|
|
96
|
+
metadata: {}
|
|
97
|
+
post_install_message:
|
|
98
|
+
rdoc_options: []
|
|
99
|
+
require_paths:
|
|
100
|
+
- lib
|
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '3.0'
|
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0'
|
|
111
|
+
requirements: []
|
|
112
|
+
rubygems_version: 3.5.16
|
|
113
|
+
signing_key:
|
|
114
|
+
specification_version: 4
|
|
115
|
+
summary: Distributed RSpec runner using Redis as a work queue
|
|
116
|
+
test_files: []
|