rspec-ai-formatter 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/LICENSE +21 -0
- data/README.md +302 -0
- data/bin/rspec-ai +100 -0
- data/bin/rspec-ai-merge +214 -0
- data/lib/rspec/ai_formatter/error_formatter.rb +103 -0
- data/lib/rspec/ai_formatter/formatter.rb +212 -0
- data/lib/rspec/ai_formatter/location_helper.rb +72 -0
- data/lib/rspec/ai_formatter/log_writer.rb +87 -0
- data/lib/rspec/ai_formatter/output_helper.rb +40 -0
- data/lib/rspec/ai_formatter.rb +11 -0
- data/lib/rspec_ai_formatter.rb +3 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 78649016754440db1ab8030edcf686c32a35871bb8d967d3b2423e6677c6d68c
|
|
4
|
+
data.tar.gz: 3d82c9e6e41523f4e914d6a66815755cbe7e59d5313feadc938fcbe6ec785904
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1b0d8fc0ef7a083fa584db00e2f11ad5dd0bb2a5456a7addca0ab10aac6ef6bd514ca39b24f208b82d5dcfda027ab20b2b9617a8a94fbe57acd1bcc33f76874c
|
|
7
|
+
data.tar.gz: e03b3ac07503f063d3b3c9aa8c9ed3591dfb1fc85471aaa75b9d5c89c0c482762122d6187efe893c2e8ca5964ba90f202697d26ee032e22e5332363026f62763
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# RSpec AI Formatter
|
|
2
|
+
|
|
3
|
+
AI-friendly RSpec formatter optimized for minimal token usage in LLM contexts.
|
|
4
|
+
|
|
5
|
+
## Why?
|
|
6
|
+
|
|
7
|
+
Standard RSpec formatters waste tokens on visual noise:
|
|
8
|
+
- **Progress formatter**: Dots and asterisks (`.F*`) with no file context
|
|
9
|
+
- **Documentation formatter**: Hierarchical descriptions with indentation whitespace
|
|
10
|
+
- **Both**: Failures dumped at end with redundant stack traces
|
|
11
|
+
|
|
12
|
+
**AI Formatter**: One line per test, structured NDJSON, references to detailed logs.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add to your Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
group :test do
|
|
20
|
+
gem 'rspec-ai-formatter', require: false
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Basic
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Via CLI helper
|
|
30
|
+
rspec-ai
|
|
31
|
+
|
|
32
|
+
# Via RSpec directly
|
|
33
|
+
rspec --format RSpec::AiFormatter::Formatter
|
|
34
|
+
|
|
35
|
+
# With custom log directory
|
|
36
|
+
rspec-ai --ai-logs-dir logs/test -- spec/
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Environment Variables
|
|
40
|
+
|
|
41
|
+
| Variable | Description | Default |
|
|
42
|
+
|----------|-------------|---------|
|
|
43
|
+
| `RSPEC_AI_LOGS` | Directory for failure logs | `tmp/rspec_logs` |
|
|
44
|
+
| `RSPEC_AI_CLEAN` | Clean logs before run | `0` |
|
|
45
|
+
| `RSPEC_AI_FULL` | Enable full output (default is minimal) | `0` |
|
|
46
|
+
| `RSPEC_AI_SIGNATURES` | Enable error signature hashing | `0` |
|
|
47
|
+
| `RSPEC_AI_DEDUP` | Deduplicate identical errors | `0` |
|
|
48
|
+
|
|
49
|
+
### Output Modes
|
|
50
|
+
|
|
51
|
+
**Minimal mode (default)** — optimized for AI and CI:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bundle exec rspec --format RSpec::AiFormatter::Formatter
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Output: `start`, failures, skips, `done`. Passing tests omitted.
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{"t":"start","total":100}
|
|
61
|
+
{"t":"test","id":"spec/api_spec.rb:10","s":"fail","e":{"type":"Error","msg":"timeout","loc":"spec/api_spec.rb:12"}}
|
|
62
|
+
{"t":"test","id":"spec/user_spec.rb:5","s":"skip","skip":"pending"}
|
|
63
|
+
{"t":"done","passed":98,"failed":1,"skipped":1,"total":100,"time":1234}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Full mode** — with all details:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
RSPEC_AI_FULL=1 bundle exec rspec --format RSpec::AiFormatter::Formatter
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Includes: test names, timestamps, timing for every test.
|
|
73
|
+
|
|
74
|
+
| Mode | Passing tests | Failures | Skips | Events for 1000 tests |
|
|
75
|
+
|------|---------------|----------|-------|----------------------|
|
|
76
|
+
| Minimal (default) | Omitted | Full | Minimal | ~50 |
|
|
77
|
+
| Full | Full event | Full | Full | ~1002 |
|
|
78
|
+
|
|
79
|
+
**Use full mode when:**
|
|
80
|
+
- Debugging individual test performance
|
|
81
|
+
- You need to see all test names
|
|
82
|
+
- Parsing requires timing data per test
|
|
83
|
+
- Human-readable output is priority
|
|
84
|
+
|
|
85
|
+
**Minimal mode removes:**
|
|
86
|
+
- Passing tests (100% savings)
|
|
87
|
+
- Test names (`n`) for skips
|
|
88
|
+
- Timestamps (`ts`) on individual tests
|
|
89
|
+
- Timing (`time`) for skips
|
|
90
|
+
- Timestamp from `done` event
|
|
91
|
+
- Line end ranges in `id`
|
|
92
|
+
- `./` prefix in file paths
|
|
93
|
+
|
|
94
|
+
### Combined with Other Formatters
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# AI format to file, progress to console
|
|
98
|
+
rspec --format progress --format RSpec::AiFormatter::Formatter --out rspec.jsonl
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### GitHub Actions
|
|
102
|
+
|
|
103
|
+
The formatter auto-detects `GITHUB_ACTIONS=true` and emits workflow commands for inline PR annotations:
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
- name: Run tests
|
|
107
|
+
env:
|
|
108
|
+
GITHUB_ACTIONS: true # Usually already set by GitHub
|
|
109
|
+
run: bundle exec rspec --format RSpec::AiFormatter::Formatter
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Annotations appear on:**
|
|
113
|
+
- Failed tests → `::error` with file/line
|
|
114
|
+
- Skipped/pending tests → `::warning`
|
|
115
|
+
|
|
116
|
+
**Example output:**
|
|
117
|
+
```
|
|
118
|
+
::error file=spec/models/user_spec.rb,line=42::expected false, got true
|
|
119
|
+
::warning file=spec/api/client_spec.rb,line=15::Skipped: pending implementation
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
To disable annotations but keep NDJSON:
|
|
123
|
+
```bash
|
|
124
|
+
GITHUB_ACTIONS= bundle exec rspec --format RSpec::AiFormatter::Formatter
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Parallel Tests
|
|
128
|
+
|
|
129
|
+
When using `parallel_tests`, each process writes its own output file. Merge them with `rspec-ai-merge`:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Run tests in parallel, each with unique output file
|
|
133
|
+
parallel_rspec --format RSpec::AiFormatter::Formatter --out tmp/parallel_{%}.jsonl -- spec/
|
|
134
|
+
|
|
135
|
+
# Merge outputs
|
|
136
|
+
rspec-ai-merge tmp/parallel_*.jsonl > combined.jsonl
|
|
137
|
+
|
|
138
|
+
# With deduplication across all parallel runs
|
|
139
|
+
rspec-ai-merge --dedup tmp/parallel_*.jsonl > combined.jsonl
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Merge options:**
|
|
143
|
+
```bash
|
|
144
|
+
rspec-ai-merge --help
|
|
145
|
+
|
|
146
|
+
# Sort by timestamp (default)
|
|
147
|
+
rspec-ai-merge --output combined.jsonl file1.jsonl file2.jsonl
|
|
148
|
+
|
|
149
|
+
# Skip sorting, keep file order
|
|
150
|
+
rspec-ai-merge --no-sort file1.jsonl file2.jsonl
|
|
151
|
+
|
|
152
|
+
# Deduplicate errors across files
|
|
153
|
+
rspec-ai-merge --dedup file1.jsonl file2.jsonl
|
|
154
|
+
|
|
155
|
+
# Skip final summary
|
|
156
|
+
rspec-ai-merge --no-summary file1.jsonl file2.jsonl
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Merged output includes:**
|
|
160
|
+
- All test events sorted by timestamp
|
|
161
|
+
- Combined `done` event with totals from all files
|
|
162
|
+
- Deduplication summaries (if enabled)
|
|
163
|
+
|
|
164
|
+
## Output Format
|
|
165
|
+
|
|
166
|
+
### NDJSON Stream
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{"t":"start","suite":["spec/models/user_spec.rb"],"total":3,"ts":"2025-06-25T14:30:22.123Z"}
|
|
170
|
+
{"t":"test","id":"spec/models/user_spec.rb:15-25","n":"User>valid?>requires email","s":"pass","time":12,"ts":"2025-06-25T14:30:22.135Z"}
|
|
171
|
+
{"t":"test","id":"spec/models/user_spec.rb:27-40","n":"User>valid?>rejects invalid format","s":"fail","time":45,"e":{"type":"ExpectationNotMet","msg":"expected false, got true","loc":"spec/models/user_spec.rb:42","log":"tmp/rspec_logs/user_spec_27.log"},"ts":"2025-06-25T14:30:22.180Z"}
|
|
172
|
+
{"t":"test","id":"spec/models/user_spec.rb:42-55","n":"User>valid?>accepts valid format","s":"skip","time":0,"skip":"pending implementation","ts":"2025-06-25T14:30:22.181Z"}
|
|
173
|
+
{"t":"done","passed":1,"failed":1,"skipped":1,"total":3,"time":1247,"ts":"2025-06-25T14:30:23.370Z"}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Log Files
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
tmp/rspec_logs/
|
|
180
|
+
├── user_spec_27.log # Per-failure details
|
|
181
|
+
├── api_client_15.log
|
|
182
|
+
└── index.json # Machine-readable index
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Example failure log:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
================================================================================
|
|
189
|
+
TEST: User > valid? > rejects invalid format
|
|
190
|
+
LOCATION: spec/models/user_spec.rb:27
|
|
191
|
+
STATUS: FAIL
|
|
192
|
+
TIME: 45ms
|
|
193
|
+
================================================================================
|
|
194
|
+
|
|
195
|
+
ERROR: ExpectationNotMet
|
|
196
|
+
MESSAGE:
|
|
197
|
+
expected false, got true
|
|
198
|
+
|
|
199
|
+
EXPECTED:
|
|
200
|
+
false
|
|
201
|
+
|
|
202
|
+
ACTUAL:
|
|
203
|
+
true
|
|
204
|
+
|
|
205
|
+
BACKTRACE:
|
|
206
|
+
spec/models/user_spec.rb:42:in `block (3 levels) in <top (required)>'
|
|
207
|
+
...
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Schema
|
|
211
|
+
|
|
212
|
+
### Event Types
|
|
213
|
+
|
|
214
|
+
| `t` | Description | Additional Fields |
|
|
215
|
+
|-----|-------------|-------------------|
|
|
216
|
+
| `start` | Suite started | `suite`, `total` |
|
|
217
|
+
| `test` | Test completed | `id`, `n`, `s`, `time`, `e`/`skip` |
|
|
218
|
+
| `done` | Suite finished | `passed`, `failed`, `skipped`, `total`, `time` |
|
|
219
|
+
|
|
220
|
+
### Status Values
|
|
221
|
+
|
|
222
|
+
| `s` | Meaning |
|
|
223
|
+
|-----|---------|
|
|
224
|
+
| `pass` | Test passed |
|
|
225
|
+
| `fail` | Test failed (see `e` for details) |
|
|
226
|
+
| `skip` | Test skipped/pending (see `skip` for reason) |
|
|
227
|
+
|
|
228
|
+
### Error Object (`e`)
|
|
229
|
+
|
|
230
|
+
| Field | Description |
|
|
231
|
+
|-------|-------------|
|
|
232
|
+
| `type` | Error class (shortened) |
|
|
233
|
+
| `msg` | Truncated message (200 chars) |
|
|
234
|
+
| `loc` | File:line of failure |
|
|
235
|
+
| `log` | Path to full log file |
|
|
236
|
+
| `sig` | MD5 signature of error (if dedup/signatures enabled) |
|
|
237
|
+
| `diff` | Expected/actual diff (for expectations) |
|
|
238
|
+
|
|
239
|
+
### Deduplication
|
|
240
|
+
|
|
241
|
+
When `RSPEC_AI_DEDUP=1`, identical errors are collapsed:
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
// First occurrence - full details
|
|
245
|
+
{"t":"test","id":"spec/api_spec.rb:20","n":"GET /users returns 200","s":"fail","e":{"type":"TimeoutError","msg":"execution expired","loc":"spec/api_spec.rb:25","sig":"a3f9e2d1"}}
|
|
246
|
+
|
|
247
|
+
// Duplicates - lightweight reference
|
|
248
|
+
{"t":"dedup","sig":"a3f9e2d1","id":"spec/api_spec.rb:40","n":"GET /users with params","first":"spec/api_spec.rb:20"}
|
|
249
|
+
{"t":"dedup","sig":"a3f9e2d1","id":"spec/api_spec.rb:60","n":"POST /users creates user","first":"spec/api_spec.rb:20"}
|
|
250
|
+
|
|
251
|
+
// Summary at end
|
|
252
|
+
{"t":"dedup_summary","sig":"a3f9e2d1","type":"TimeoutError","msg":"execution expired","count":3,"first":"spec/api_spec.rb:20","examples":["spec/api_spec.rb:20","spec/api_spec.rb:40","spec/api_spec.rb:60"]}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Useful when a shared setup/teardown failure cascades through many tests.
|
|
256
|
+
|
|
257
|
+
## Token Efficiency
|
|
258
|
+
|
|
259
|
+
| Scenario | Progress | Documentation | AI Formatter |
|
|
260
|
+
|----------|----------|---------------|--------------|
|
|
261
|
+
| 100 tests, all pass | ~200 | ~800 | ~150 |
|
|
262
|
+
| 10 failures with traces | ~2000 | ~2500 | ~300 + files |
|
|
263
|
+
| Parsing complexity | Hard | Hard | Trivial |
|
|
264
|
+
|
|
265
|
+
## Features
|
|
266
|
+
|
|
267
|
+
- ✅ **Streaming NDJSON**: Real-time output, parse-as-you-go
|
|
268
|
+
- ✅ **Minimal mode**: Ultra-compact output for massive test suites (80% smaller)
|
|
269
|
+
- ✅ **Location tracking**: File + line start-end for each test
|
|
270
|
+
- ✅ **Failure isolation**: Full output to separate log files
|
|
271
|
+
- ✅ **Error signatures**: Group similar failures by hash
|
|
272
|
+
- ✅ **Error deduplication**: Collapse identical errors, show "and 4 more like this"
|
|
273
|
+
- ✅ **Parallel test merging**: Merge outputs from parallel_tests runs
|
|
274
|
+
- ✅ **Diff generation**: Smart expected/actual comparison
|
|
275
|
+
- ✅ **Truncation**: Prevent token explosion on large outputs
|
|
276
|
+
- ✅ **ANSI-free**: Clean text, no escape codes
|
|
277
|
+
- ✅ **GitHub Actions**: Auto-detects and emits `::error`/`::warning` annotations
|
|
278
|
+
|
|
279
|
+
## TODO
|
|
280
|
+
|
|
281
|
+
### CI/CD Integration
|
|
282
|
+
- [x] GitHub Actions workflow command integration — Auto-detect `GITHUB_ACTIONS` env and emit `::error` / `::warning` commands for inline PR annotations
|
|
283
|
+
|
|
284
|
+
### Error Intelligence
|
|
285
|
+
- [x] Error pattern deduplication across test runs — Group failures by signature hash, show "and 4 more like this"
|
|
286
|
+
- [ ] Quick-fix hints integration with `did_you_mean` — Suggest typo corrections, similar method names
|
|
287
|
+
- [ ] Error context snippets — Include 3-5 lines of source code around failure in log files
|
|
288
|
+
|
|
289
|
+
### Performance & Insights
|
|
290
|
+
- [ ] Performance baseline tracking — Compare to previous run, flag regressions >50%
|
|
291
|
+
- [ ] Test selection metadata — Add tags, flakiness score, last failure date to each test event
|
|
292
|
+
- [ ] Slowest tests report — Top N slowest tests in summary
|
|
293
|
+
|
|
294
|
+
### Compatibility & Tooling
|
|
295
|
+
- [ ] JUnit XML compatibility mode — Dual output for systems requiring XML
|
|
296
|
+
- [x] Parallel test log merging — Clean merge of outputs from `parallel_tests` runs
|
|
297
|
+
- [ ] Screenshot capture for system tests — Auto-capture Capybara screenshot path on failure
|
|
298
|
+
- [ ] Custom output templates — User-defined NDJSON schema / field selection
|
|
299
|
+
|
|
300
|
+
## License
|
|
301
|
+
|
|
302
|
+
MIT
|
data/bin/rspec-ai
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# CLI helper for rspec-ai-formatter
|
|
5
|
+
# Usage: rspec-ai [rspec-options] [--ai-logs-dir DIR]
|
|
6
|
+
|
|
7
|
+
require 'optparse'
|
|
8
|
+
|
|
9
|
+
# CLI wrapper for running RSpec with AI formatter
|
|
10
|
+
# Handles option parsing and environment setup
|
|
11
|
+
class RspecAiCli
|
|
12
|
+
def initialize(args)
|
|
13
|
+
@args = args
|
|
14
|
+
@log_dir = ENV['RSPEC_AI_LOGS'] || 'tmp/rspec_logs'
|
|
15
|
+
@rspec_args = []
|
|
16
|
+
@clean = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
parse_options!
|
|
21
|
+
setup_environment
|
|
22
|
+
exec_rspec
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def parse_options!
|
|
28
|
+
parser = OptionParser.new do |opts|
|
|
29
|
+
opts.banner = 'Usage: rspec-ai [options] [rspec options]'
|
|
30
|
+
opts.separator ''
|
|
31
|
+
opts.separator 'AI Formatter options:'
|
|
32
|
+
|
|
33
|
+
opts.on('--ai-logs-dir DIR', 'Directory for failure logs') do |dir|
|
|
34
|
+
@log_dir = dir
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
opts.on('--ai-clean', 'Clean log directory before run') do
|
|
38
|
+
@clean = true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
opts.on('--ai-signatures', 'Enable error signature hashing') do
|
|
42
|
+
ENV['RSPEC_AI_SIGNATURES'] = '1'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
46
|
+
puts opts
|
|
47
|
+
puts
|
|
48
|
+
puts 'Environment variables:'
|
|
49
|
+
puts ' RSPEC_AI_LOGS - Default log directory'
|
|
50
|
+
puts ' RSPEC_AI_CLEAN - Clean logs before run (1/0)'
|
|
51
|
+
puts ' RSPEC_AI_SIGNATURES - Enable error signatures (1/0)'
|
|
52
|
+
exit
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
opts.separator ''
|
|
56
|
+
opts.separator 'All other options are passed to RSpec.'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Split AI-specific options from RSpec options
|
|
60
|
+
ai_args = []
|
|
61
|
+
rspec_args = []
|
|
62
|
+
|
|
63
|
+
i = 0
|
|
64
|
+
while i < @args.length
|
|
65
|
+
arg = @args[i]
|
|
66
|
+
|
|
67
|
+
if arg.start_with?('--ai-')
|
|
68
|
+
ai_args << arg
|
|
69
|
+
if ['--ai-logs-dir'].include?(arg) && i + 1 < @args.length
|
|
70
|
+
ai_args << @args[i + 1]
|
|
71
|
+
i += 1
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
rspec_args << arg
|
|
75
|
+
end
|
|
76
|
+
i += 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
parser.parse!(ai_args)
|
|
80
|
+
@rspec_args = rspec_args
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def setup_environment
|
|
84
|
+
ENV['RSPEC_AI_LOGS'] = @log_dir
|
|
85
|
+
ENV['RSPEC_AI_CLEAN'] = '1' if @clean
|
|
86
|
+
|
|
87
|
+
# Ensure the formatter is loaded
|
|
88
|
+
formatter_path = File.expand_path('../lib', __dir__)
|
|
89
|
+
ENV['RUBYOPT'] = "#{ENV.fetch('RUBYOPT', '')} -I#{formatter_path}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def exec_rspec
|
|
93
|
+
cmd = ['rspec', '--format', 'RSpec::AiFormatter::Formatter']
|
|
94
|
+
cmd += @rspec_args
|
|
95
|
+
|
|
96
|
+
exec(*cmd)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
RspecAiCli.new(ARGV).run
|
data/bin/rspec-ai-merge
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Merge multiple RSpec AI formatter outputs from parallel test runs
|
|
5
|
+
# Usage: rspec-ai-merge [options] file1.jsonl file2.jsonl ... > combined.jsonl
|
|
6
|
+
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'optparse'
|
|
9
|
+
|
|
10
|
+
# Merges multiple RSpec AI formatter JSONL outputs
|
|
11
|
+
# Supports deduplication, sorting, and summary generation
|
|
12
|
+
class RspecAiMerger
|
|
13
|
+
def initialize(args)
|
|
14
|
+
@files = []
|
|
15
|
+
@output = $stdout
|
|
16
|
+
@dedup = false
|
|
17
|
+
@sort = true
|
|
18
|
+
@summary = true
|
|
19
|
+
parse_options!(args)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run
|
|
23
|
+
if @files.empty?
|
|
24
|
+
puts 'No input files specified. Use --help for usage.'
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
missing = @files.reject { |f| File.exist?(f) }
|
|
29
|
+
unless missing.empty?
|
|
30
|
+
warn "Missing files: #{missing.join(', ')}"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
events = load_events
|
|
35
|
+
events = sort_events(events) if @sort
|
|
36
|
+
events = deduplicate(events) if @dedup
|
|
37
|
+
|
|
38
|
+
write_output(events)
|
|
39
|
+
write_summary(events) if @summary
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parse_options!(args)
|
|
45
|
+
parser = OptionParser.new do |opts|
|
|
46
|
+
opts.banner = 'Usage: rspec-ai-merge [options] file1.jsonl file2.jsonl ...'
|
|
47
|
+
opts.separator ''
|
|
48
|
+
opts.separator 'Merge multiple RSpec AI formatter outputs from parallel test runs'
|
|
49
|
+
opts.separator ''
|
|
50
|
+
opts.separator 'Options:'
|
|
51
|
+
|
|
52
|
+
opts.on('-o', '--output FILE', 'Output file (default: stdout)') do |f|
|
|
53
|
+
@output = File.open(f, 'w')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
opts.on('-d', '--dedup', 'Deduplicate identical errors across files') do
|
|
57
|
+
@dedup = true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
opts.on('--no-sort', 'Do not sort by timestamp') do
|
|
61
|
+
@sort = false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
opts.on('--no-summary', 'Skip summary event') do
|
|
65
|
+
@summary = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
69
|
+
puts opts
|
|
70
|
+
exit
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
parser.parse!(args)
|
|
75
|
+
@files = args
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def load_events
|
|
79
|
+
events = []
|
|
80
|
+
|
|
81
|
+
@files.each do |file|
|
|
82
|
+
File.readlines(file).each do |line|
|
|
83
|
+
line = line.strip
|
|
84
|
+
next if line.empty?
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
event = JSON.parse(line)
|
|
88
|
+
events << event
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
# Skip malformed lines
|
|
91
|
+
warn "Skipping malformed line in #{file}: #{line[0..50]}..."
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
events
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sort_events(events)
|
|
100
|
+
# Sort by timestamp if available, otherwise keep original order
|
|
101
|
+
events.sort_by do |e|
|
|
102
|
+
ts = e['ts']
|
|
103
|
+
if ts
|
|
104
|
+
# Parse ISO8601 timestamp
|
|
105
|
+
Time.parse(ts).to_f
|
|
106
|
+
else
|
|
107
|
+
0
|
|
108
|
+
end
|
|
109
|
+
rescue StandardError
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def deduplicate(events)
|
|
115
|
+
signatures = {}
|
|
116
|
+
result = []
|
|
117
|
+
|
|
118
|
+
events.each do |event|
|
|
119
|
+
case event['t']
|
|
120
|
+
when 'test'
|
|
121
|
+
if event['s'] == 'fail' && event.dig('e', 'sig')
|
|
122
|
+
sig = event['e']['sig']
|
|
123
|
+
if signatures.key?(sig)
|
|
124
|
+
# Convert to dedup event
|
|
125
|
+
signatures[sig][:count] += 1
|
|
126
|
+
signatures[sig][:examples] << event['id']
|
|
127
|
+
result << {
|
|
128
|
+
't' => 'dedup',
|
|
129
|
+
'sig' => sig,
|
|
130
|
+
'id' => event['id'],
|
|
131
|
+
'n' => event['n'],
|
|
132
|
+
'first' => signatures[sig][:first],
|
|
133
|
+
'ts' => event['ts']
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
signatures[sig] = {
|
|
137
|
+
count: 1,
|
|
138
|
+
first: event['id'],
|
|
139
|
+
examples: [event['id']],
|
|
140
|
+
type: event.dig('e', 'type'),
|
|
141
|
+
msg: event.dig('e', 'msg')
|
|
142
|
+
}
|
|
143
|
+
result << event
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
result << event
|
|
147
|
+
end
|
|
148
|
+
when 'dedup'
|
|
149
|
+
# Already a dedup event, track it
|
|
150
|
+
sig = event['sig']
|
|
151
|
+
if signatures.key?(sig)
|
|
152
|
+
signatures[sig][:count] += 1
|
|
153
|
+
signatures[sig][:examples] << event['id']
|
|
154
|
+
end
|
|
155
|
+
result << event
|
|
156
|
+
when 'dedup_summary'
|
|
157
|
+
# Skip original dedup_summary, we'll generate a new one
|
|
158
|
+
next
|
|
159
|
+
else
|
|
160
|
+
result << event
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Add merged dedup summaries
|
|
165
|
+
signatures.each do |sig, data|
|
|
166
|
+
next if data[:count] <= 1
|
|
167
|
+
|
|
168
|
+
result << {
|
|
169
|
+
't' => 'dedup_summary',
|
|
170
|
+
'sig' => sig,
|
|
171
|
+
'type' => data[:type],
|
|
172
|
+
'msg' => data[:msg],
|
|
173
|
+
'count' => data[:count],
|
|
174
|
+
'first' => data[:first],
|
|
175
|
+
'examples' => data[:examples]
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
result
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def write_output(events)
|
|
183
|
+
events.each do |event|
|
|
184
|
+
@output.puts(event.to_json)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def write_summary(events)
|
|
189
|
+
# Calculate totals from all 'done' events
|
|
190
|
+
dones = events.select { |e| e['t'] == 'done' }
|
|
191
|
+
|
|
192
|
+
return unless dones.any?
|
|
193
|
+
|
|
194
|
+
total_passed = dones.sum { |d| d['passed'] || 0 }
|
|
195
|
+
total_failed = dones.sum { |d| d['failed'] || 0 }
|
|
196
|
+
total_skipped = dones.sum { |d| d['skipped'] || 0 }
|
|
197
|
+
total_tests = dones.sum { |d| d['total'] || 0 }
|
|
198
|
+
total_time = dones.sum { |d| d['time'] || 0 }
|
|
199
|
+
|
|
200
|
+
# Remove original done events and add merged one
|
|
201
|
+
@output.puts({
|
|
202
|
+
't' => 'done',
|
|
203
|
+
'passed' => total_passed,
|
|
204
|
+
'failed' => total_failed,
|
|
205
|
+
'skipped' => total_skipped,
|
|
206
|
+
'total' => total_tests,
|
|
207
|
+
'time' => total_time,
|
|
208
|
+
'merged_from' => @files.length,
|
|
209
|
+
'ts' => Time.now.utc.iso8601(3)
|
|
210
|
+
}.to_json)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
RspecAiMerger.new(ARGV).run
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module AiFormatter
|
|
5
|
+
# Formats error details for output
|
|
6
|
+
module ErrorFormatter
|
|
7
|
+
def error_details(example, notification, log_path, sig = nil)
|
|
8
|
+
exception = notification.exception
|
|
9
|
+
return {} unless exception
|
|
10
|
+
|
|
11
|
+
details = {
|
|
12
|
+
type: error_class(exception),
|
|
13
|
+
msg: truncate(error_message(exception), 200),
|
|
14
|
+
loc: failure_location(example, exception)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
details[:log] = log_path if log_path && @log_dir != File::NULL
|
|
18
|
+
details[:sig] = sig if sig
|
|
19
|
+
|
|
20
|
+
# Add diff for expectation failures
|
|
21
|
+
if exception.respond_to?(:expected) && exception.respond_to?(:actual)
|
|
22
|
+
details[:diff] = generate_diff(exception.expected, exception.actual)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
details
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def error_class(exception)
|
|
29
|
+
exception.class.name.split('::').last
|
|
30
|
+
rescue StandardError
|
|
31
|
+
'Error'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def error_message(exception)
|
|
35
|
+
msg = exception.message.to_s
|
|
36
|
+
# Clean up RSpec expectation messages
|
|
37
|
+
msg.gsub(/\e\[\d+m/, '') # Strip ANSI codes
|
|
38
|
+
rescue StandardError
|
|
39
|
+
'Unknown error'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def error_signature(exception)
|
|
43
|
+
# Create a hash of the error type + normalized message
|
|
44
|
+
# Useful for grouping similar failures
|
|
45
|
+
sig = "#{error_class(exception)}:#{error_message(exception)[0..50]}"
|
|
46
|
+
require 'digest'
|
|
47
|
+
Digest::MD5.hexdigest(sig)[0..7]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def failure_location(example, exception)
|
|
51
|
+
# Find the most relevant location in the test file
|
|
52
|
+
example_file = example.metadata[:file_path]
|
|
53
|
+
|
|
54
|
+
if exception.backtrace
|
|
55
|
+
# Find first line in the test file
|
|
56
|
+
test_line = exception.backtrace.find { |l| l.include?(example_file) }
|
|
57
|
+
return test_line.split(':')[0..1].join(':') if test_line
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Fallback to example location
|
|
61
|
+
"#{example_file}:#{example.metadata[:line_number]}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def skip_reason(example)
|
|
65
|
+
result = example.execution_result
|
|
66
|
+
return nil unless result.respond_to?(:pending_message)
|
|
67
|
+
|
|
68
|
+
result.pending_message
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def generate_diff(expected, actual)
|
|
74
|
+
# Simple diff generation
|
|
75
|
+
return nil if expected.nil? || actual.nil?
|
|
76
|
+
|
|
77
|
+
exp_str = inspect_value(expected)
|
|
78
|
+
act_str = inspect_value(actual)
|
|
79
|
+
|
|
80
|
+
return nil if exp_str == act_str
|
|
81
|
+
|
|
82
|
+
# Return truncated diff
|
|
83
|
+
{
|
|
84
|
+
expected: truncate(exp_str, 500),
|
|
85
|
+
actual: truncate(act_str, 500)
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def inspect_value(value)
|
|
90
|
+
case value
|
|
91
|
+
when String
|
|
92
|
+
value
|
|
93
|
+
when Hash, Array
|
|
94
|
+
JSON.generate(value)
|
|
95
|
+
else
|
|
96
|
+
value.inspect
|
|
97
|
+
end
|
|
98
|
+
rescue StandardError
|
|
99
|
+
value.to_s
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'time'
|
|
6
|
+
require_relative 'log_writer'
|
|
7
|
+
require_relative 'error_formatter'
|
|
8
|
+
require_relative 'location_helper'
|
|
9
|
+
require_relative 'output_helper'
|
|
10
|
+
|
|
11
|
+
module RSpec
|
|
12
|
+
module AiFormatter
|
|
13
|
+
# AI-friendly formatter for RSpec
|
|
14
|
+
# Outputs compact NDJSON with references to detailed logs
|
|
15
|
+
class Formatter
|
|
16
|
+
include LogWriter
|
|
17
|
+
include ErrorFormatter
|
|
18
|
+
include LocationHelper
|
|
19
|
+
include OutputHelper
|
|
20
|
+
|
|
21
|
+
RSpec::Core::Formatters.register self,
|
|
22
|
+
:start,
|
|
23
|
+
:example_started,
|
|
24
|
+
:example_passed,
|
|
25
|
+
:example_failed,
|
|
26
|
+
:example_pending,
|
|
27
|
+
:dump_summary
|
|
28
|
+
|
|
29
|
+
def initialize(output, log_dir: nil)
|
|
30
|
+
@output = output
|
|
31
|
+
@log_dir = log_dir || ENV.fetch('RSPEC_AI_LOGS', 'tmp/rspec_logs')
|
|
32
|
+
@start_time = nil
|
|
33
|
+
@suite_start = nil
|
|
34
|
+
@failure_count = 0
|
|
35
|
+
@pending_count = 0
|
|
36
|
+
@passed_count = 0
|
|
37
|
+
@test_index = []
|
|
38
|
+
@deduplicate = ENV['RSPEC_AI_DEDUP'] == '1'
|
|
39
|
+
@minimal = ENV['RSPEC_AI_FULL'] != '1'
|
|
40
|
+
@error_signatures = {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def start(notification)
|
|
44
|
+
@suite_start = Time.now
|
|
45
|
+
|
|
46
|
+
setup_log_directory
|
|
47
|
+
|
|
48
|
+
emit(
|
|
49
|
+
t: 'start',
|
|
50
|
+
total: notification.count,
|
|
51
|
+
ts: timestamp
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def example_started(notification); end
|
|
56
|
+
|
|
57
|
+
def example_passed(notification)
|
|
58
|
+
@passed_count += 1
|
|
59
|
+
return if @minimal
|
|
60
|
+
|
|
61
|
+
ex = notification.example
|
|
62
|
+
emit(
|
|
63
|
+
t: 'test',
|
|
64
|
+
id: test_location(ex),
|
|
65
|
+
n: short_name(ex),
|
|
66
|
+
s: 'pass',
|
|
67
|
+
time: execution_time_ms(ex),
|
|
68
|
+
ts: timestamp
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def example_failed(notification)
|
|
73
|
+
@failure_count += 1
|
|
74
|
+
ex = notification.example
|
|
75
|
+
exception = notification.exception
|
|
76
|
+
log_path = write_failure_log(ex, notification)
|
|
77
|
+
|
|
78
|
+
# Calculate signature for deduplication
|
|
79
|
+
sig = error_signature(exception) if @deduplicate || ENV['RSPEC_AI_SIGNATURES'] == '1'
|
|
80
|
+
|
|
81
|
+
if @deduplicate && sig && @error_signatures.key?(sig)
|
|
82
|
+
handle_duplicate_error(ex, sig)
|
|
83
|
+
else
|
|
84
|
+
handle_first_error(ex, notification, log_path, sig)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def example_pending(notification)
|
|
89
|
+
@pending_count += 1
|
|
90
|
+
ex = notification.example
|
|
91
|
+
reason = skip_reason(ex)
|
|
92
|
+
|
|
93
|
+
if @minimal
|
|
94
|
+
emit_minimal_test(ex, 'skip', skip: reason)
|
|
95
|
+
else
|
|
96
|
+
emit(
|
|
97
|
+
t: 'test',
|
|
98
|
+
id: test_location(ex),
|
|
99
|
+
n: short_name(ex),
|
|
100
|
+
s: 'skip',
|
|
101
|
+
time: execution_time_ms(ex),
|
|
102
|
+
skip: reason,
|
|
103
|
+
ts: timestamp
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def dump_summary(notification)
|
|
109
|
+
total_time = ((Time.now - @suite_start) * 1000).round
|
|
110
|
+
|
|
111
|
+
emit_dedup_summaries if @deduplicate && duplicates_exist?
|
|
112
|
+
|
|
113
|
+
emit_summary(total_time, notification)
|
|
114
|
+
write_index_file
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def handle_duplicate_error(example, sig)
|
|
120
|
+
@error_signatures[sig][:count] += 1
|
|
121
|
+
@error_signatures[sig][:examples] << test_location(example)
|
|
122
|
+
|
|
123
|
+
emit(
|
|
124
|
+
t: 'dedup',
|
|
125
|
+
sig: sig,
|
|
126
|
+
id: test_location(example),
|
|
127
|
+
n: short_name(example),
|
|
128
|
+
first: @error_signatures[sig][:first],
|
|
129
|
+
ts: timestamp
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_first_error(example, notification, log_path, sig)
|
|
134
|
+
# First occurrence of this error
|
|
135
|
+
if @deduplicate && sig
|
|
136
|
+
@error_signatures[sig] = {
|
|
137
|
+
count: 1,
|
|
138
|
+
first: test_location(example),
|
|
139
|
+
examples: [test_location(example)],
|
|
140
|
+
type: error_class(notification.exception),
|
|
141
|
+
msg: truncate(error_message(notification.exception), 100)
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
emit(
|
|
146
|
+
t: 'test',
|
|
147
|
+
id: test_location(example),
|
|
148
|
+
n: short_name(example),
|
|
149
|
+
s: 'fail',
|
|
150
|
+
time: execution_time_ms(example),
|
|
151
|
+
e: error_details(example, notification, log_path, sig),
|
|
152
|
+
ts: timestamp
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def duplicates_exist?
|
|
157
|
+
@error_signatures.any? { |_, v| v[:count] > 1 }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def emit_dedup_summaries
|
|
161
|
+
@error_signatures.each do |sig, data|
|
|
162
|
+
next if data[:count] <= 1
|
|
163
|
+
|
|
164
|
+
emit(build_dedup_summary(sig, data))
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_dedup_summary(sig, data)
|
|
169
|
+
if @minimal
|
|
170
|
+
{ t: 'dedup_summary', sig: sig, count: data[:count], first: data[:first] }
|
|
171
|
+
else
|
|
172
|
+
{
|
|
173
|
+
t: 'dedup_summary',
|
|
174
|
+
sig: sig,
|
|
175
|
+
type: data[:type],
|
|
176
|
+
msg: data[:msg],
|
|
177
|
+
count: data[:count],
|
|
178
|
+
first: data[:first],
|
|
179
|
+
examples: data[:examples],
|
|
180
|
+
ts: timestamp
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def emit_summary(total_time, notification)
|
|
186
|
+
summary = {
|
|
187
|
+
t: 'done',
|
|
188
|
+
passed: @passed_count,
|
|
189
|
+
failed: @failure_count,
|
|
190
|
+
skipped: @pending_count,
|
|
191
|
+
total: notification.example_count,
|
|
192
|
+
time: total_time
|
|
193
|
+
}
|
|
194
|
+
summary[:ts] = timestamp unless @minimal
|
|
195
|
+
emit(summary)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Register with RSpec
|
|
202
|
+
if defined?(RSpec::Core::Formatters)
|
|
203
|
+
RSpec::Core::Formatters.register(
|
|
204
|
+
RSpec::AiFormatter::Formatter,
|
|
205
|
+
:start,
|
|
206
|
+
:example_started,
|
|
207
|
+
:example_passed,
|
|
208
|
+
:example_failed,
|
|
209
|
+
:example_pending,
|
|
210
|
+
:dump_summary
|
|
211
|
+
)
|
|
212
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module AiFormatter
|
|
5
|
+
# Handles location and naming for test examples
|
|
6
|
+
module LocationHelper
|
|
7
|
+
def test_location(example)
|
|
8
|
+
meta = example.metadata
|
|
9
|
+
file = meta[:file_path]
|
|
10
|
+
line = meta[:line_number]
|
|
11
|
+
# Try to get end line from source location
|
|
12
|
+
end_line = detect_end_line(example)
|
|
13
|
+
|
|
14
|
+
if end_line && end_line > line
|
|
15
|
+
"#{file}:#{line}-#{end_line}"
|
|
16
|
+
else
|
|
17
|
+
"#{file}:#{line}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def minimal_location(example)
|
|
22
|
+
# Minimal format: no ./ prefix, no end line, just file:start_line
|
|
23
|
+
meta = example.metadata
|
|
24
|
+
file = meta[:file_path].to_s.sub(%r{^\./}, '')
|
|
25
|
+
line = meta[:line_number]
|
|
26
|
+
"#{file}:#{line}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def short_name(example)
|
|
30
|
+
# Build hierarchical name from example groups
|
|
31
|
+
parts = example_groups(example).map(&:description)
|
|
32
|
+
parts << example.description
|
|
33
|
+
|
|
34
|
+
# Compact: skip empty descriptions, limit length
|
|
35
|
+
parts.reject(&:empty?).join(' > ').slice(0, 200)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def detect_end_line(example)
|
|
41
|
+
# Get block source location end if available
|
|
42
|
+
block = example.metadata[:block]
|
|
43
|
+
return nil unless block.respond_to?(:source_location)
|
|
44
|
+
|
|
45
|
+
loc = block.source_location
|
|
46
|
+
return nil unless loc.is_a?(Array) && loc.length >= 2
|
|
47
|
+
|
|
48
|
+
# Try to get end location from proc/method
|
|
49
|
+
if block.respond_to?(:source_location_end)
|
|
50
|
+
block.source_location_end&.last
|
|
51
|
+
else
|
|
52
|
+
# Fallback: estimate based on next example or simple heuristic
|
|
53
|
+
line = loc.last
|
|
54
|
+
# Rough estimate: typical test is 5-15 lines
|
|
55
|
+
line + 10
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def example_groups(example, groups = [])
|
|
62
|
+
group = example.example_group
|
|
63
|
+
while group && group != RSpec::Core::ExampleGroup
|
|
64
|
+
groups.unshift(group) unless group.description.empty?
|
|
65
|
+
group = group.superclass
|
|
66
|
+
break if group == RSpec::Core::ExampleGroup
|
|
67
|
+
end
|
|
68
|
+
groups
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module AiFormatter
|
|
5
|
+
# Handles writing failure logs to disk
|
|
6
|
+
module LogWriter
|
|
7
|
+
def write_failure_log(example, notification)
|
|
8
|
+
return nil if @log_dir == File::NULL
|
|
9
|
+
|
|
10
|
+
file = example.metadata[:file_path]
|
|
11
|
+
line = example.metadata[:line_number]
|
|
12
|
+
filename = "#{File.basename(file, '.rb')}_#{line}.log"
|
|
13
|
+
log_path = File.join(@log_dir, filename)
|
|
14
|
+
|
|
15
|
+
File.open(log_path, 'w') { |f| write_log_content(f, example, notification) }
|
|
16
|
+
|
|
17
|
+
@test_index << {
|
|
18
|
+
test: short_name(example),
|
|
19
|
+
location: "#{file}:#{line}",
|
|
20
|
+
log: log_path,
|
|
21
|
+
status: 'fail'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
log_path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write_index_file
|
|
28
|
+
return if @log_dir == File::NULL || @test_index.empty?
|
|
29
|
+
|
|
30
|
+
index_path = File.join(@log_dir, 'index.json')
|
|
31
|
+
File.write(index_path, JSON.pretty_generate(@test_index))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def setup_log_directory
|
|
35
|
+
return if @log_dir == File::NULL || @log_dir.nil?
|
|
36
|
+
|
|
37
|
+
FileUtils.mkdir_p(@log_dir)
|
|
38
|
+
return unless ENV['RSPEC_AI_CLEAN'] == '1'
|
|
39
|
+
|
|
40
|
+
FileUtils.rm_rf(Dir.glob(File.join(@log_dir, '*.log')))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def write_log_content(file, example, notification)
|
|
46
|
+
file.puts('=' * 80)
|
|
47
|
+
file.puts("TEST: #{short_name(example)}")
|
|
48
|
+
file.puts("LOCATION: #{example.metadata[:file_path]}:#{example.metadata[:line_number]}")
|
|
49
|
+
file.puts('STATUS: FAIL')
|
|
50
|
+
file.puts("TIME: #{execution_time_ms(example)}ms")
|
|
51
|
+
file.puts('=' * 80)
|
|
52
|
+
file.puts
|
|
53
|
+
|
|
54
|
+
write_exception_details(file, notification.exception)
|
|
55
|
+
file.puts
|
|
56
|
+
file.puts('=' * 80)
|
|
57
|
+
file.puts('FULL OUTPUT:')
|
|
58
|
+
file.puts('=' * 80)
|
|
59
|
+
|
|
60
|
+
return unless notification.respond_to?(:formatted_backtrace)
|
|
61
|
+
|
|
62
|
+
file.puts(notification.formatted_backtrace.join("\n"))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write_exception_details(file, exception)
|
|
66
|
+
return unless exception
|
|
67
|
+
|
|
68
|
+
file.puts("ERROR: #{error_class(exception)}")
|
|
69
|
+
file.puts('MESSAGE:')
|
|
70
|
+
file.puts(error_message(exception))
|
|
71
|
+
file.puts
|
|
72
|
+
|
|
73
|
+
return unless exception.respond_to?(:expected) && exception.respond_to?(:actual)
|
|
74
|
+
|
|
75
|
+
file.puts('EXPECTED:')
|
|
76
|
+
file.puts(inspect_value(exception.expected))
|
|
77
|
+
file.puts
|
|
78
|
+
file.puts('ACTUAL:')
|
|
79
|
+
file.puts(inspect_value(exception.actual))
|
|
80
|
+
file.puts
|
|
81
|
+
|
|
82
|
+
file.puts('BACKTRACE:')
|
|
83
|
+
exception.backtrace&.first(20)&.each { |l| file.puts(l) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module AiFormatter
|
|
5
|
+
# Handles output formatting and utility methods
|
|
6
|
+
module OutputHelper
|
|
7
|
+
def emit(hash)
|
|
8
|
+
@output.puts(hash.to_json)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def emit_minimal_test(example, status, skip: nil)
|
|
12
|
+
hash = {
|
|
13
|
+
t: 'test',
|
|
14
|
+
id: minimal_location(example),
|
|
15
|
+
s: status
|
|
16
|
+
}
|
|
17
|
+
hash[:skip] = skip if skip
|
|
18
|
+
emit(hash)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def execution_time_ms(example)
|
|
22
|
+
result = example.execution_result
|
|
23
|
+
return nil unless result.respond_to?(:run_time)
|
|
24
|
+
|
|
25
|
+
(result.run_time * 1000).round
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def timestamp
|
|
29
|
+
Time.now.utc.iso8601(3)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def truncate(str, max)
|
|
33
|
+
str = str.to_s
|
|
34
|
+
return str if str.length <= max
|
|
35
|
+
|
|
36
|
+
"#{str[0...max]}...[truncated #{str.length - max} chars]"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rspec-ai-formatter
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- SK
|
|
8
|
+
autorequire: rspec_ai_formatter
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-02-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rspec-core
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.10'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.10'
|
|
27
|
+
description: |
|
|
28
|
+
RSpec formatter optimized for AI agents and CI systems.
|
|
29
|
+
Outputs compact NDJSON with file references instead of verbose text.
|
|
30
|
+
Supports log splitting, error deduplication, and context-efficient reporting.
|
|
31
|
+
email:
|
|
32
|
+
- konstantin.suhov@gmail.com
|
|
33
|
+
executables:
|
|
34
|
+
- rspec-ai
|
|
35
|
+
- rspec-ai-merge
|
|
36
|
+
extensions: []
|
|
37
|
+
extra_rdoc_files: []
|
|
38
|
+
files:
|
|
39
|
+
- LICENSE
|
|
40
|
+
- README.md
|
|
41
|
+
- bin/rspec-ai
|
|
42
|
+
- bin/rspec-ai-merge
|
|
43
|
+
- lib/rspec/ai_formatter.rb
|
|
44
|
+
- lib/rspec/ai_formatter/error_formatter.rb
|
|
45
|
+
- lib/rspec/ai_formatter/formatter.rb
|
|
46
|
+
- lib/rspec/ai_formatter/location_helper.rb
|
|
47
|
+
- lib/rspec/ai_formatter/log_writer.rb
|
|
48
|
+
- lib/rspec/ai_formatter/output_helper.rb
|
|
49
|
+
- lib/rspec_ai_formatter.rb
|
|
50
|
+
homepage: https://github.com/sciencejet/rspec-ai-formatter
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
allowed_push_host: https://rubygems.org
|
|
55
|
+
rubygems_mfa_required: 'true'
|
|
56
|
+
post_install_message:
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: 3.0.0
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 3.5.22
|
|
72
|
+
signing_key:
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: AI-friendly RSpec formatter with minimal token usage
|
|
75
|
+
test_files: []
|