probatio_diabolica 0.2.0 → 0.3.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/README.md +52 -66
- data/bin/prd_mcp +9 -0
- data/lib/pr_d/code.rb +18 -0
- data/lib/pr_d/formatters/formatter.rb +4 -0
- data/lib/pr_d/formatters/html_formatter.rb +200 -26
- data/lib/pr_d/formatters/json_formatter.rb +12 -0
- data/lib/pr_d/formatters/pdf_formatter.rb +41 -7
- data/lib/pr_d/formatters/simple_formatter.rb +29 -7
- data/lib/pr_d/helpers/chrome_helper.rb +3 -7
- data/lib/pr_d/helpers/source_code_helper.rb +8 -2
- data/lib/pr_d/matchers/includes_matcher.rb +2 -0
- data/lib/pr_d/matchers/llm_matcher.rb +2 -0
- data/lib/pr_d/mcp/run_specs_tool.rb +248 -0
- data/lib/pr_d/mcp/server.rb +178 -0
- data/lib/pr_d/version.rb +1 -1
- data/probatio_diabolica.gemspec +3 -0
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 891d8275e2ccd96dea0d74c456a55693ab667fd556fedf9b5d1fe9470b0949cb
|
|
4
|
+
data.tar.gz: d53faf24d75c1f112e5cb064048372d7ac7c5391f50ed3e546a5f7990cb7d02f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: faafc8c84e60b7058d57ceb6d457502822724aea22325e3e0ccd4efb3e3765903f21f8525f0e4c9937bea8e69901d39e543796a776b5a1305922492955ce373e
|
|
7
|
+
data.tar.gz: 4d9f2a94aee2e50112544aa6c0be783ee79f95ee0b4dd7ef95731590b45f335a4e2e61044a99bfa4d2b6125767c9f9b6f91ba2b7e8b70e275ab792b62b138be3
|
data/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Tests are evaluated with `instance_eval` (not through RSpec).
|
|
|
21
21
|
|
|
22
22
|
## Installation
|
|
23
23
|
|
|
24
|
-
###
|
|
24
|
+
### In the Gemfile
|
|
25
25
|
|
|
26
26
|
```ruby
|
|
27
27
|
gem 'probatio_diabolica'
|
|
@@ -33,63 +33,6 @@ Then:
|
|
|
33
33
|
bundle install
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
In Ruby code, you can load it with:
|
|
37
|
-
|
|
38
|
-
```ruby
|
|
39
|
-
require "probatio_diabolica"
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### From this repository (local development)
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
bundle install
|
|
46
|
-
bundle exec prd examples/basics_spec.rb
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
### Build and install as a gem
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
# build the package
|
|
53
|
-
gem build probatio_diabolica.gemspec
|
|
54
|
-
|
|
55
|
-
# install locally from the built gem
|
|
56
|
-
gem install ./probatio_diabolica-*.gem
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
## Release workflow
|
|
60
|
-
|
|
61
|
-
Use the release helper to bump version, refresh `Gemfile.lock`, create commit/tag, and push:
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
# explicit version
|
|
65
|
-
bin/release --version 0.2.0
|
|
66
|
-
|
|
67
|
-
# or env-style
|
|
68
|
-
VERSION=0.2.0 bin/release
|
|
69
|
-
|
|
70
|
-
# semantic bump from current version
|
|
71
|
-
bin/release --bump patch
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Useful options:
|
|
75
|
-
|
|
76
|
-
- `--dry-run` preview all actions without modifying files/git
|
|
77
|
-
- `--no-push` create commit/tag locally only
|
|
78
|
-
- `--skip-tests` skip `bundle exec ruby bin/prd spec --mode synthetic`
|
|
79
|
-
- `--allow-dirty` bypass clean-working-tree guard
|
|
80
|
-
|
|
81
|
-
After installation, you can run:
|
|
82
|
-
|
|
83
|
-
```bash
|
|
84
|
-
prd examples/basics_spec.rb
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
If `prd` is not found, add your gem bin directory to `PATH`:
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
export PATH="$(ruby -e 'print Gem.user_dir')/bin:$PATH"
|
|
91
|
-
```
|
|
92
|
-
|
|
93
36
|
## Configuration LLM
|
|
94
37
|
|
|
95
38
|
The runtime automatically loads `prd_helper.rb` if present (or a file passed with `-c`).
|
|
@@ -119,6 +62,29 @@ From source checkout (without gem install), this is always valid:
|
|
|
119
62
|
bundle exec ruby bin/prd <file_or_directory> [options]
|
|
120
63
|
```
|
|
121
64
|
|
|
65
|
+
## MCP server (`run_specs`)
|
|
66
|
+
|
|
67
|
+
A minimal MCP server is available through:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
bundle exec ruby bin/prd_mcp
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
It exposes one tool: `run_specs`.
|
|
74
|
+
|
|
75
|
+
Input:
|
|
76
|
+
- `path` (required): file or directory containing specs
|
|
77
|
+
- `config` (optional): same as `-c`
|
|
78
|
+
- `out` (optional): same as `-o`
|
|
79
|
+
- `formatters` (optional): array of `simple|html|json|pdf` (default: `["simple"]`)
|
|
80
|
+
- `mode` (optional): `verbose|synthetic` (default: `synthetic`)
|
|
81
|
+
|
|
82
|
+
Output (`structuredContent`):
|
|
83
|
+
- `ok`, `exit_code`
|
|
84
|
+
- `summary` (`passed`, `failed`, `pending`)
|
|
85
|
+
- `artifacts` (`base_out`, `reports`, `annex_dir`)
|
|
86
|
+
- `logs` (`stdout`, `stderr`)
|
|
87
|
+
|
|
122
88
|
Options:
|
|
123
89
|
|
|
124
90
|
- `-c, --config FILE` Ruby config file to require
|
|
@@ -151,33 +117,36 @@ Examples:
|
|
|
151
117
|
|
|
152
118
|
```bash
|
|
153
119
|
# single file
|
|
154
|
-
|
|
120
|
+
prd examples/basics_spec.rb
|
|
155
121
|
|
|
156
122
|
# all *_spec.rb files in a directory
|
|
157
|
-
|
|
123
|
+
prd examples
|
|
158
124
|
|
|
159
125
|
# HTML report in an existing directory (creates ./tmp/report.html)
|
|
160
|
-
|
|
126
|
+
prd examples/image_spec.rb -t html -o ./tmp/
|
|
161
127
|
|
|
162
128
|
# multiple reports from one run with shared base name
|
|
163
|
-
|
|
129
|
+
prd examples/basics_spec.rb -t html,json,pdf -o ./tmp/my_report
|
|
164
130
|
|
|
165
131
|
# compact synthetic output on console
|
|
166
|
-
|
|
132
|
+
prd examples/basics_spec.rb --mode synthetic
|
|
167
133
|
```
|
|
168
134
|
|
|
169
135
|
## Available DSL
|
|
170
136
|
|
|
137
|
+
It is inspired by RSpec but with a custom runtime and additional features.
|
|
138
|
+
|
|
171
139
|
### Structure
|
|
172
140
|
|
|
173
141
|
```ruby
|
|
174
142
|
describe 'My domain' do
|
|
175
143
|
context 'my context' do
|
|
176
|
-
let(:
|
|
177
|
-
|
|
144
|
+
let(:two) { 2 }
|
|
145
|
+
let(:three) { 3 }
|
|
146
|
+
subject { two + three }
|
|
178
147
|
|
|
179
148
|
it 'runs an assertion' do
|
|
180
|
-
expect
|
|
149
|
+
expect.to eq(5)
|
|
181
150
|
end
|
|
182
151
|
|
|
183
152
|
pending 'test to implement later'
|
|
@@ -231,6 +200,20 @@ end
|
|
|
231
200
|
### Source code helper (Prism)
|
|
232
201
|
|
|
233
202
|
`source_code(...)` uses the `prism` gem to parse Ruby source and extract class/method code.
|
|
203
|
+
It returns a `PrD::Code` object:
|
|
204
|
+
|
|
205
|
+
- `source` (`String`)
|
|
206
|
+
- `language` (`String`, default: `ruby`)
|
|
207
|
+
|
|
208
|
+
Example:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
let(:code) { source_code(PrD::Matchers::AllMatcher) }
|
|
212
|
+
|
|
213
|
+
it 'uses raw source text' do
|
|
214
|
+
expect(code.source).to(includes('class AllMatcher'))
|
|
215
|
+
end
|
|
216
|
+
```
|
|
234
217
|
|
|
235
218
|
Prerequisites:
|
|
236
219
|
|
|
@@ -273,13 +256,16 @@ When you define a `subject`, each formatter tries to render it in the most usefu
|
|
|
273
256
|
- for files, prints a textual representation (for example path, file preview for `.txt`)
|
|
274
257
|
- `HtmlFormatter`:
|
|
275
258
|
- renders text values directly
|
|
259
|
+
- for `PrD::Code`, renders syntax-highlighted code blocks (Rouge)
|
|
276
260
|
- for image files (`.png`, `.jpg`, `.jpeg`), embeds the image in the report
|
|
277
261
|
- for PDF subjects (`File` `.pdf` or `PDF::Reader`), embeds the PDF with a `data:application/pdf;base64,...` URI
|
|
278
262
|
- `PdfFormatter`:
|
|
279
263
|
- renders text values as report lines
|
|
264
|
+
- for `PrD::Code`, renders language + code block (plain, without syntax colors)
|
|
280
265
|
- for image files (`.png`, `.jpg`, `.jpeg`), inserts the image directly in the PDF report
|
|
281
266
|
- `JsonFormatter`:
|
|
282
267
|
- keeps a structured representation for machine processing
|
|
268
|
+
- for `PrD::Code`, emits a structured payload (`type: "code"`, `language`, `source`)
|
|
283
269
|
- `File` values (images, PDFs, text files, etc.) are embedded as base64 payloads
|
|
284
270
|
- `PDF::Reader` values are also embedded as base64 (`application/pdf`)
|
|
285
271
|
|
data/bin/prd_mcp
ADDED
data/lib/pr_d/code.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module PrD
|
|
2
|
+
class Code
|
|
3
|
+
attr_reader :source, :language
|
|
4
|
+
|
|
5
|
+
def initialize(source:, language: 'ruby')
|
|
6
|
+
@source = source.to_s
|
|
7
|
+
@language = language.to_s
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_s
|
|
11
|
+
@source
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def include?(value)
|
|
15
|
+
@source.include?(value)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'cgi'
|
|
2
2
|
require 'base64'
|
|
3
|
+
require 'rouge'
|
|
3
4
|
|
|
4
5
|
module PrD
|
|
5
6
|
module Formatters
|
|
@@ -9,6 +10,7 @@ module PrD
|
|
|
9
10
|
@content = +''
|
|
10
11
|
@index_entries = []
|
|
11
12
|
@anchor_counters = Hash.new(0)
|
|
13
|
+
@rouge_formatter = Rouge::Formatters::HTMLLegacy.new(css_class: 'highlight')
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def context(message)
|
|
@@ -51,20 +53,13 @@ module PrD
|
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
def let(value)
|
|
56
|
+
return if synthetic?
|
|
57
|
+
render_value_block('Let', value)
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def subject(subject)
|
|
57
61
|
return if synthetic?
|
|
58
|
-
|
|
59
|
-
@content << "<p class=\"line\"><strong>Subject:</strong> #{escape(serialize(subject).to_s)}</p>"
|
|
60
|
-
if image_file?(subject)
|
|
61
|
-
@content << "<img src=\"#{image_data_uri(subject.path)}\" alt=\"Subject image\" class=\"subject-image\" />"
|
|
62
|
-
elsif pdf_file?(subject)
|
|
63
|
-
@content << "<embed src=\"#{pdf_data_uri(subject.path)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
|
|
64
|
-
elsif pdf_reader?(subject)
|
|
65
|
-
@content << "<embed src=\"#{pdf_reader_data_uri(subject)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
|
|
66
|
-
end
|
|
67
|
-
@content << '</div>'
|
|
62
|
+
render_value_block('Subject', subject)
|
|
68
63
|
end
|
|
69
64
|
|
|
70
65
|
def pending(description = nil)
|
|
@@ -85,7 +80,7 @@ module PrD
|
|
|
85
80
|
|
|
86
81
|
def expect(expectation)
|
|
87
82
|
return if synthetic?
|
|
88
|
-
|
|
83
|
+
render_labeled_value('Expect', expectation)
|
|
89
84
|
end
|
|
90
85
|
|
|
91
86
|
def to
|
|
@@ -102,15 +97,15 @@ module PrD
|
|
|
102
97
|
return if synthetic?
|
|
103
98
|
case matcher
|
|
104
99
|
when Matchers::EqMatcher
|
|
105
|
-
|
|
100
|
+
render_matcher_value('Be equal to', matcher.expected)
|
|
106
101
|
when Matchers::BeMatcher
|
|
107
|
-
|
|
102
|
+
render_matcher_value('Be the same object as', matcher.expected)
|
|
108
103
|
when Matchers::IncludesMatcher
|
|
109
|
-
|
|
104
|
+
render_matcher_value('Include', matcher.expected)
|
|
110
105
|
when Matchers::HaveMatcher
|
|
111
|
-
|
|
106
|
+
render_matcher_value('Have', matcher.expected)
|
|
112
107
|
when Matchers::LlmMatcher
|
|
113
|
-
|
|
108
|
+
render_matcher_value('Satisfy condition', matcher.expected)
|
|
114
109
|
when Matchers::AllMatcher
|
|
115
110
|
if sources
|
|
116
111
|
code_line = matcher.expected.source_location.last.to_i
|
|
@@ -156,6 +151,7 @@ module PrD
|
|
|
156
151
|
--muted: #6b7280;
|
|
157
152
|
--line: #e5e7eb;
|
|
158
153
|
--accent: #0f766e;
|
|
154
|
+
--sidebar-width: 320px;
|
|
159
155
|
--pass-bg: #ecfdf5;
|
|
160
156
|
--pass-fg: #166534;
|
|
161
157
|
--fail-bg: #fef2f2;
|
|
@@ -182,12 +178,51 @@ module PrD
|
|
|
182
178
|
border-radius: 18px;
|
|
183
179
|
}
|
|
184
180
|
|
|
181
|
+
body.has-index main.container {
|
|
182
|
+
width: min(960px, calc(100% - var(--sidebar-width) - 4rem));
|
|
183
|
+
margin: 1rem 1rem 2rem calc(var(--sidebar-width) + 2rem);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
body.has-index.index-collapsed main.container {
|
|
187
|
+
width: min(960px, calc(100% - 2rem));
|
|
188
|
+
margin: 1rem auto 2rem;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.index-toggle {
|
|
192
|
+
position: fixed;
|
|
193
|
+
top: 0.9rem;
|
|
194
|
+
left: 0.9rem;
|
|
195
|
+
z-index: 1100;
|
|
196
|
+
border: 1px solid #d1d5db;
|
|
197
|
+
border-radius: 999px;
|
|
198
|
+
background: var(--paper);
|
|
199
|
+
color: #0f172a;
|
|
200
|
+
padding: 0.45rem 0.8rem;
|
|
201
|
+
font-size: 0.9rem;
|
|
202
|
+
font-weight: 600;
|
|
203
|
+
cursor: pointer;
|
|
204
|
+
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
|
|
205
|
+
}
|
|
206
|
+
|
|
185
207
|
.report-index {
|
|
208
|
+
position: fixed;
|
|
209
|
+
top: 3.2rem;
|
|
210
|
+
left: 1rem;
|
|
211
|
+
bottom: 1rem;
|
|
212
|
+
z-index: 1000;
|
|
213
|
+
width: var(--sidebar-width);
|
|
186
214
|
background: var(--paper);
|
|
187
215
|
border: 1px solid var(--line);
|
|
188
216
|
border-radius: 14px;
|
|
189
217
|
padding: 0.9rem 1rem;
|
|
190
|
-
|
|
218
|
+
overflow-y: auto;
|
|
219
|
+
transition: transform 0.18s ease, opacity 0.18s ease;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
body.has-index.index-collapsed .report-index {
|
|
223
|
+
transform: translateX(calc(-1 * (var(--sidebar-width) + 1rem)));
|
|
224
|
+
opacity: 0;
|
|
225
|
+
pointer-events: none;
|
|
191
226
|
}
|
|
192
227
|
|
|
193
228
|
.index-title {
|
|
@@ -207,12 +242,16 @@ module PrD
|
|
|
207
242
|
padding-left: calc(var(--index-level, 0) * 1rem);
|
|
208
243
|
}
|
|
209
244
|
|
|
210
|
-
.index-
|
|
245
|
+
.index-link {
|
|
246
|
+
display: block;
|
|
247
|
+
white-space: nowrap;
|
|
248
|
+
overflow: hidden;
|
|
249
|
+
text-overflow: ellipsis;
|
|
211
250
|
color: var(--accent);
|
|
212
251
|
text-decoration: none;
|
|
213
252
|
}
|
|
214
253
|
|
|
215
|
-
.index-
|
|
254
|
+
.index-link:hover {
|
|
216
255
|
text-decoration: underline;
|
|
217
256
|
}
|
|
218
257
|
|
|
@@ -314,6 +353,53 @@ module PrD
|
|
|
314
353
|
.result.success { color: var(--pass-fg); background: var(--pass-bg); }
|
|
315
354
|
.result.failure { color: var(--fail-fg); background: var(--fail-bg); }
|
|
316
355
|
.muted { color: var(--muted); }
|
|
356
|
+
|
|
357
|
+
.code-language {
|
|
358
|
+
color: var(--muted);
|
|
359
|
+
font-size: 0.85rem;
|
|
360
|
+
margin-bottom: 0.35rem;
|
|
361
|
+
text-transform: uppercase;
|
|
362
|
+
letter-spacing: 0.05em;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.code-block {
|
|
366
|
+
margin: 0.45rem 0 0.7rem;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.highlight {
|
|
370
|
+
margin: 0;
|
|
371
|
+
border: 1px solid var(--line);
|
|
372
|
+
border-radius: 10px;
|
|
373
|
+
overflow-x: auto;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.highlight pre {
|
|
377
|
+
margin: 0;
|
|
378
|
+
padding: 0.8rem;
|
|
379
|
+
line-height: 1.35;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
@media (max-width: 960px) {
|
|
383
|
+
:root { --sidebar-width: min(82vw, 320px); }
|
|
384
|
+
|
|
385
|
+
body.has-index main.container,
|
|
386
|
+
body.has-index.index-collapsed main.container {
|
|
387
|
+
width: calc(100% - 1rem);
|
|
388
|
+
margin: 4rem 0.5rem 1rem 0.5rem;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.report-index {
|
|
392
|
+
top: 3.5rem;
|
|
393
|
+
left: 0.5rem;
|
|
394
|
+
bottom: 0.5rem;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
body.has-index.index-collapsed .report-index {
|
|
398
|
+
transform: translateX(calc(-1 * (var(--sidebar-width) + 0.6rem)));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#{rouge_theme_css}
|
|
317
403
|
</style>
|
|
318
404
|
</head>
|
|
319
405
|
<body>
|
|
@@ -325,28 +411,52 @@ module PrD
|
|
|
325
411
|
return '' if @index_entries.empty?
|
|
326
412
|
|
|
327
413
|
index_items = @index_entries.map do |entry|
|
|
328
|
-
|
|
414
|
+
label = escape(index_label(entry))
|
|
415
|
+
"<li class=\"index-item\" style=\"--index-level: #{entry[:level]};\"><a class=\"index-link\" href=\"##{entry[:anchor_id]}\" title=\"#{label}\">#{label}</a></li>"
|
|
329
416
|
end.join
|
|
330
417
|
|
|
331
418
|
<<~HTML
|
|
332
|
-
<
|
|
419
|
+
<button type="button" class="index-toggle" aria-expanded="false" aria-controls="report-index">Show index</button>
|
|
420
|
+
<nav id="report-index" class="report-index" aria-label="Report index">
|
|
333
421
|
<h2 class="index-title">Index</h2>
|
|
334
422
|
<ul class="index-list">
|
|
335
423
|
#{index_items}
|
|
336
424
|
</ul>
|
|
337
425
|
</nav>
|
|
426
|
+
<script>
|
|
427
|
+
(function() {
|
|
428
|
+
var body = document.body;
|
|
429
|
+
var nav = document.getElementById('report-index');
|
|
430
|
+
var toggle = document.querySelector('.index-toggle');
|
|
431
|
+
if (!body || !nav || !toggle) return;
|
|
432
|
+
|
|
433
|
+
body.classList.add('has-index');
|
|
434
|
+
|
|
435
|
+
var syncToggleLabel = function() {
|
|
436
|
+
var isCollapsed = body.classList.contains('index-collapsed');
|
|
437
|
+
toggle.setAttribute('aria-expanded', String(!isCollapsed));
|
|
438
|
+
toggle.textContent = isCollapsed ? 'Show index' : 'Hide index';
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
toggle.addEventListener('click', function() {
|
|
442
|
+
body.classList.toggle('index-collapsed');
|
|
443
|
+
syncToggleLabel();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
syncToggleLabel();
|
|
447
|
+
})();
|
|
448
|
+
</script>
|
|
338
449
|
HTML
|
|
339
450
|
end
|
|
340
451
|
|
|
341
452
|
def index_label(entry)
|
|
342
|
-
|
|
453
|
+
marker =
|
|
343
454
|
case entry[:type]
|
|
344
|
-
when :context then '
|
|
345
|
-
|
|
346
|
-
else 'Test'
|
|
455
|
+
when :context then '+'
|
|
456
|
+
else '-'
|
|
347
457
|
end
|
|
348
458
|
|
|
349
|
-
"#{
|
|
459
|
+
"#{marker} #{entry[:label]}"
|
|
350
460
|
end
|
|
351
461
|
|
|
352
462
|
def add_index_entry(type:, label:, level:, anchor_id:)
|
|
@@ -363,6 +473,70 @@ module PrD
|
|
|
363
473
|
"#{prefix}-#{@anchor_counters[prefix]}"
|
|
364
474
|
end
|
|
365
475
|
|
|
476
|
+
def render_labeled_value(label, value)
|
|
477
|
+
if code_object?(value)
|
|
478
|
+
@content << "<p class=\"line\"><strong>#{escape(label)}:</strong></p>"
|
|
479
|
+
@content << render_code_block(value)
|
|
480
|
+
else
|
|
481
|
+
@content << "<p class=\"line\"><strong>#{escape(label)}:</strong> #{escape(serialize(value).to_s)}</p>"
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def render_matcher_value(label, value)
|
|
486
|
+
if code_object?(value)
|
|
487
|
+
@content << "<p class=\"line\"><strong>Matcher:</strong> #{escape(label)} (#{escape(value.language)})</p>"
|
|
488
|
+
@content << render_code_block(value)
|
|
489
|
+
else
|
|
490
|
+
@content << "<p class=\"line\"><strong>Matcher:</strong> #{escape(label)} #{escape(serialize(value).to_s)}</p>"
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def render_value_block(label, value)
|
|
495
|
+
@content << '<div class="subject-block">'
|
|
496
|
+
if code_object?(value)
|
|
497
|
+
@content << "<p class=\"line\"><strong>#{escape(label)}:</strong></p>"
|
|
498
|
+
@content << render_code_block(value)
|
|
499
|
+
else
|
|
500
|
+
@content << "<p class=\"line\"><strong>#{escape(label)}:</strong> #{escape(serialize(value).to_s)}</p>"
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
if image_file?(value)
|
|
504
|
+
@content << "<img src=\"#{image_data_uri(value.path)}\" alt=\"#{escape(label)} image\" class=\"subject-image\" />"
|
|
505
|
+
elsif pdf_file?(value)
|
|
506
|
+
@content << "<embed src=\"#{pdf_data_uri(value.path)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
|
|
507
|
+
elsif pdf_reader?(value)
|
|
508
|
+
@content << "<embed src=\"#{pdf_reader_data_uri(value)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
|
|
509
|
+
end
|
|
510
|
+
@content << '</div>'
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def render_code_block(code)
|
|
514
|
+
source = normalize_text(code.source)
|
|
515
|
+
language = normalize_text(code.language)
|
|
516
|
+
highlighted = highlight_code(source, language)
|
|
517
|
+
<<~HTML
|
|
518
|
+
<div class="code-block">
|
|
519
|
+
<div class="code-language">#{escape(language)}</div>
|
|
520
|
+
#{highlighted}
|
|
521
|
+
</div>
|
|
522
|
+
HTML
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def highlight_code(source, language)
|
|
526
|
+
lexer = rouge_lexer_for(source, language)
|
|
527
|
+
@rouge_formatter.format(lexer.lex(source))
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def rouge_lexer_for(source, language)
|
|
531
|
+
Rouge::Lexer.find_fancy(language, source) || Rouge::Lexers::PlainText
|
|
532
|
+
rescue StandardError
|
|
533
|
+
Rouge::Lexers::PlainText
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def rouge_theme_css
|
|
537
|
+
Rouge::Themes::Github.render(scope: '.highlight')
|
|
538
|
+
end
|
|
539
|
+
|
|
366
540
|
def escape(message)
|
|
367
541
|
CGI.escape_html(normalize_text(message))
|
|
368
542
|
end
|
|
@@ -117,6 +117,10 @@ module PrD
|
|
|
117
117
|
serializer = @serializers[value.class]
|
|
118
118
|
return serializer.call(value) if serializer
|
|
119
119
|
|
|
120
|
+
if code_object?(value)
|
|
121
|
+
return serialize_code(value)
|
|
122
|
+
end
|
|
123
|
+
|
|
120
124
|
if value.is_a?(File)
|
|
121
125
|
return serialize_file(value)
|
|
122
126
|
end
|
|
@@ -136,6 +140,14 @@ module PrD
|
|
|
136
140
|
value
|
|
137
141
|
end
|
|
138
142
|
|
|
143
|
+
def serialize_code(code)
|
|
144
|
+
{
|
|
145
|
+
type: 'code',
|
|
146
|
+
language: code.language,
|
|
147
|
+
source: code.source
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
139
151
|
def serialize_file(file)
|
|
140
152
|
file.rewind if file.respond_to?(:rewind)
|
|
141
153
|
content = file.read
|
|
@@ -74,7 +74,10 @@ module PrD
|
|
|
74
74
|
def subject(subject)
|
|
75
75
|
return if synthetic?
|
|
76
76
|
add_event(:subject, message: 'Subject', level: @level)
|
|
77
|
-
if
|
|
77
|
+
if code_object?(subject)
|
|
78
|
+
add_event(:code_header, message: "Language: #{subject.language}", level: @level + 1)
|
|
79
|
+
add_event(:code_block, message: subject.source, level: @level + 1)
|
|
80
|
+
elsif image_file?(subject)
|
|
78
81
|
add_event(:detail, message: serialize(subject).to_s, level: @level + 1)
|
|
79
82
|
add_event(:subject_image, message: subject.path, level: @level + 1)
|
|
80
83
|
else
|
|
@@ -93,7 +96,12 @@ module PrD
|
|
|
93
96
|
|
|
94
97
|
def expect(expectation)
|
|
95
98
|
return if synthetic?
|
|
96
|
-
|
|
99
|
+
if code_object?(expectation)
|
|
100
|
+
add_event(:code_header, message: "Expect (#{expectation.language})", level: @level + 1)
|
|
101
|
+
add_event(:code_block, message: expectation.source, level: @level + 1)
|
|
102
|
+
else
|
|
103
|
+
add_event(:detail, message: "Expect: #{serialize(expectation)}", level: @level + 1)
|
|
104
|
+
end
|
|
97
105
|
end
|
|
98
106
|
|
|
99
107
|
def to
|
|
@@ -110,15 +118,15 @@ module PrD
|
|
|
110
118
|
return if synthetic?
|
|
111
119
|
case matcher
|
|
112
120
|
when Matchers::EqMatcher
|
|
113
|
-
|
|
121
|
+
add_matcher_value_event('Be equal to', matcher.expected)
|
|
114
122
|
when Matchers::BeMatcher
|
|
115
|
-
|
|
123
|
+
add_matcher_value_event('Be the same object as', matcher.expected)
|
|
116
124
|
when Matchers::IncludesMatcher
|
|
117
|
-
|
|
125
|
+
add_matcher_value_event('Include', matcher.expected)
|
|
118
126
|
when Matchers::HaveMatcher
|
|
119
|
-
|
|
127
|
+
add_matcher_value_event('Have', matcher.expected)
|
|
120
128
|
when Matchers::LlmMatcher
|
|
121
|
-
|
|
129
|
+
add_matcher_value_event('Satisfy condition', matcher.expected)
|
|
122
130
|
when Matchers::AllMatcher
|
|
123
131
|
add_event(:matcher, message: 'all match the given condition', level: @level + 2)
|
|
124
132
|
else
|
|
@@ -227,6 +235,10 @@ module PrD
|
|
|
227
235
|
status_line(document, 'PENDING', event[:message], event[:level], COLORS[:pending])
|
|
228
236
|
when :matcher
|
|
229
237
|
styled_line(document, event[:message], level: event[:level], size: 10, color: COLORS[:muted])
|
|
238
|
+
when :code_header
|
|
239
|
+
styled_line(document, event[:message], level: event[:level], size: 10, style: :bold, color: COLORS[:muted])
|
|
240
|
+
when :code_block
|
|
241
|
+
render_code_block(document, event[:message], level: event[:level])
|
|
230
242
|
when :detail, :subject, :justification
|
|
231
243
|
styled_line(document, event[:message], level: event[:level], size: 10, color: COLORS[:text])
|
|
232
244
|
when :subject_image
|
|
@@ -284,6 +296,28 @@ module PrD
|
|
|
284
296
|
document.move_down 2
|
|
285
297
|
end
|
|
286
298
|
|
|
299
|
+
def render_code_block(document, text, level:)
|
|
300
|
+
document.indent(level * 14) do
|
|
301
|
+
document.fill_color COLORS[:muted]
|
|
302
|
+
document.text '--- Code Block ---', size: 9, style: :italic
|
|
303
|
+
document.fill_color COLORS[:text]
|
|
304
|
+
document.font('Courier') { document.text text, size: 9 }
|
|
305
|
+
document.fill_color COLORS[:muted]
|
|
306
|
+
document.text '--- End Block ---', size: 9, style: :italic
|
|
307
|
+
document.fill_color COLORS[:text]
|
|
308
|
+
end
|
|
309
|
+
document.move_down 3
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def add_matcher_value_event(label, value)
|
|
313
|
+
if code_object?(value)
|
|
314
|
+
add_event(:matcher, message: "#{label} (#{value.language})", level: @level + 2)
|
|
315
|
+
add_event(:code_block, message: value.source, level: @level + 2)
|
|
316
|
+
else
|
|
317
|
+
add_event(:matcher, message: "#{label}: #{serialize(value)}", level: @level + 2)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
287
321
|
def index_label(entry)
|
|
288
322
|
prefix =
|
|
289
323
|
case entry[:type]
|
|
@@ -47,7 +47,12 @@ module PrD
|
|
|
47
47
|
def subject(subject)
|
|
48
48
|
return if synthetic?
|
|
49
49
|
title('Subject')
|
|
50
|
-
|
|
50
|
+
if code_object?(subject)
|
|
51
|
+
output("Code (#{subject.language}):", :white, indent: 1)
|
|
52
|
+
output(subject.source, :white, indent: 2)
|
|
53
|
+
else
|
|
54
|
+
output(subject, :white, indent: 1)
|
|
55
|
+
end
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
def pending(description = nil)
|
|
@@ -61,7 +66,12 @@ module PrD
|
|
|
61
66
|
|
|
62
67
|
def expect(expectation)
|
|
63
68
|
return if synthetic?
|
|
64
|
-
|
|
69
|
+
if code_object?(expectation)
|
|
70
|
+
output("Expect (#{expectation.language}):", :white, indent: 1)
|
|
71
|
+
output(expectation.source, :white, indent: 2)
|
|
72
|
+
else
|
|
73
|
+
output("Expect: #{expectation}", :white, indent: 1)
|
|
74
|
+
end
|
|
65
75
|
end
|
|
66
76
|
|
|
67
77
|
def to
|
|
@@ -81,15 +91,15 @@ module PrD
|
|
|
81
91
|
return if synthetic?
|
|
82
92
|
case matcher
|
|
83
93
|
when Matchers::EqMatcher
|
|
84
|
-
|
|
94
|
+
output_matcher_value('Be equal to', matcher.expected)
|
|
85
95
|
when Matchers::BeMatcher
|
|
86
|
-
|
|
96
|
+
output_matcher_value('Be the same object as', matcher.expected)
|
|
87
97
|
when Matchers::IncludesMatcher
|
|
88
|
-
|
|
98
|
+
output_matcher_value('Include', matcher.expected)
|
|
89
99
|
when Matchers::HaveMatcher
|
|
90
|
-
|
|
100
|
+
output_matcher_value('Have', matcher.expected)
|
|
91
101
|
when Matchers::LlmMatcher
|
|
92
|
-
|
|
102
|
+
output_matcher_value('Satisfy condition', matcher.expected)
|
|
93
103
|
when Matchers::AllMatcher
|
|
94
104
|
if sources
|
|
95
105
|
code_line = matcher.expected.source_location.last.to_i
|
|
@@ -117,6 +127,9 @@ module PrD
|
|
|
117
127
|
@io.puts "#{INDENT * indent}#{message}"
|
|
118
128
|
when Array
|
|
119
129
|
message.each { |line| output(line, color, figure: figure, indent: indent) }
|
|
130
|
+
when PrD::Code
|
|
131
|
+
output("Code (#{message.language}):", color, indent: indent)
|
|
132
|
+
output(message.source, color, indent: indent + 1)
|
|
120
133
|
when String
|
|
121
134
|
if message.include?("\n")
|
|
122
135
|
@io.puts "#{COLOR_MAPPING[color]}#{INDENT * indent}--- Code Block ---#{COLOR_MAPPING[:default]}"
|
|
@@ -152,6 +165,15 @@ module PrD
|
|
|
152
165
|
output(message, :yellow)
|
|
153
166
|
end
|
|
154
167
|
|
|
168
|
+
def output_matcher_value(label, value)
|
|
169
|
+
if code_object?(value)
|
|
170
|
+
output("#{label} (#{value.language}):", :white, indent: 2)
|
|
171
|
+
output(value.source, :white, indent: 3)
|
|
172
|
+
else
|
|
173
|
+
output("#{label}: #{value}", :white, indent: 2)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
155
177
|
def indented_message(message, indent_incr: 0)
|
|
156
178
|
"#{INDENT * (@level + indent_incr)}#{message}"
|
|
157
179
|
end
|
|
@@ -23,12 +23,7 @@ module PrD
|
|
|
23
23
|
text_node = browser.at_css(css)
|
|
24
24
|
raise ArgumentError, "CSS selector not found: #{css}" unless text_node
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
file_name = File.join(chrome_annex_dir, "text-#{text_id}.txt")
|
|
28
|
-
File.open(file_name, 'w') do |file|
|
|
29
|
-
file.write(text_node.text.scan(/.{1,100}/m).join("\n"))
|
|
30
|
-
end
|
|
31
|
-
File.open(file_name, 'rb')
|
|
26
|
+
PrD::Code.new(source: text_node.text, language: 'text')
|
|
32
27
|
end
|
|
33
28
|
|
|
34
29
|
def network(at:, warmup_time: 2)
|
|
@@ -54,7 +49,8 @@ module PrD
|
|
|
54
49
|
def html(at:, warmup_time: 2)
|
|
55
50
|
browser = prepare_browser(at:, warmup_time:)
|
|
56
51
|
yield browser if block_given?
|
|
57
|
-
|
|
52
|
+
|
|
53
|
+
PrD::Code.new(source: browser.body, language: 'html')
|
|
58
54
|
end
|
|
59
55
|
|
|
60
56
|
def close_chrome_browser
|
|
@@ -10,14 +10,14 @@ module PrD
|
|
|
10
10
|
|
|
11
11
|
code = File.read(file)
|
|
12
12
|
tree = Prism.parse(code)
|
|
13
|
-
extract_class_from_node(tree.value, class_or_method.to_s, code)
|
|
13
|
+
extract_code_object(extract_class_from_node(tree.value, class_or_method.to_s, code))
|
|
14
14
|
else
|
|
15
15
|
file, line = class_or_method.source_location
|
|
16
16
|
return nil unless file && line
|
|
17
17
|
|
|
18
18
|
code = File.read(file)
|
|
19
19
|
tree = Prism.parse(code)
|
|
20
|
-
extract_method_from_node(tree.value, class_or_method.name, code)
|
|
20
|
+
extract_code_object(extract_method_from_node(tree.value, class_or_method.name, code))
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -61,6 +61,12 @@ module PrD
|
|
|
61
61
|
rescue LoadError => e
|
|
62
62
|
raise LoadError, "Source code helpers require the 'prism' gem. Install it with `gem install prism` or add `gem 'prism'` to your Gemfile. (#{e.message})"
|
|
63
63
|
end
|
|
64
|
+
|
|
65
|
+
def extract_code_object(source)
|
|
66
|
+
return nil if source.nil?
|
|
67
|
+
|
|
68
|
+
PrD::Code.new(source:, language: 'ruby')
|
|
69
|
+
end
|
|
64
70
|
end
|
|
65
71
|
end
|
|
66
72
|
end
|
|
@@ -5,6 +5,8 @@ module PrD
|
|
|
5
5
|
def matches?(actual)
|
|
6
6
|
if actual.is_a?(String) || actual.is_a?(Array)
|
|
7
7
|
PrD::Runtime::TestResult.new(comment: nil, pass: actual.include?(@expected))
|
|
8
|
+
elsif defined?(PrD::Code) && actual.is_a?(PrD::Code)
|
|
9
|
+
PrD::Runtime::TestResult.new(comment: nil, pass: actual.source.include?(@expected))
|
|
8
10
|
elsif actual.is_a?(File)
|
|
9
11
|
content = actual.read
|
|
10
12
|
actual.rewind
|
|
@@ -24,6 +24,8 @@ module PrD
|
|
|
24
24
|
def matches?(actual)
|
|
25
25
|
if actual.is_a?(String)
|
|
26
26
|
return build_runtime_result(text(@expected, actual))
|
|
27
|
+
elsif defined?(PrD::Code) && actual.is_a?(PrD::Code)
|
|
28
|
+
return build_runtime_result(text(@expected, actual.source))
|
|
27
29
|
elsif actual.is_a?(File)
|
|
28
30
|
if actual.path.end_with?('.png', '.jpg', '.jpeg')
|
|
29
31
|
return build_runtime_result(image(@expected, actual))
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'open3'
|
|
3
|
+
|
|
4
|
+
module PrD
|
|
5
|
+
module Mcp
|
|
6
|
+
class RunSpecsTool
|
|
7
|
+
SUPPORTED_FORMATTERS = %w[simple html json pdf].freeze
|
|
8
|
+
SUPPORTED_MODES = %w[verbose synthetic].freeze
|
|
9
|
+
FORMATTER_EXTENSIONS = {
|
|
10
|
+
'simple' => '.txt',
|
|
11
|
+
'html' => '.html',
|
|
12
|
+
'json' => '.json',
|
|
13
|
+
'pdf' => '.pdf'
|
|
14
|
+
}.freeze
|
|
15
|
+
DEFAULT_REPORT_BASENAME = 'report'.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(command_runner: Open3, pwd: Dir.pwd)
|
|
18
|
+
@command_runner = command_runner
|
|
19
|
+
@pwd = pwd
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(arguments)
|
|
23
|
+
args = normalize_and_validate_arguments(arguments)
|
|
24
|
+
command = build_command(args)
|
|
25
|
+
stdout, stderr, status = @command_runner.capture3(*command, chdir: @pwd)
|
|
26
|
+
|
|
27
|
+
base_out = args[:out] ? output_base_path(args[:out]) : nil
|
|
28
|
+
parsed_json = parse_json_summary(args:, stdout:, base_out:)
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
ok: true,
|
|
32
|
+
exit_code: status.exitstatus,
|
|
33
|
+
summary: build_summary(args:, stdout:, parsed_json:),
|
|
34
|
+
artifacts: build_artifacts(args:, base_out:),
|
|
35
|
+
logs: {
|
|
36
|
+
stdout: stdout,
|
|
37
|
+
stderr: stderr
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
raise e if e.is_a?(ArgumentError)
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
ok: false,
|
|
45
|
+
exit_code: nil,
|
|
46
|
+
summary: { passed: nil, failed: nil, pending: nil },
|
|
47
|
+
artifacts: { base_out: nil, reports: [], annex_dir: nil },
|
|
48
|
+
logs: { stdout: '', stderr: "#{e.class}: #{e.message}" }
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def normalize_and_validate_arguments(raw_args)
|
|
55
|
+
raise ArgumentError, 'run_specs requires an arguments object.' unless raw_args.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
path = raw_args['path'] || raw_args[:path]
|
|
58
|
+
raise ArgumentError, 'run_specs requires `path` (string).' unless path.is_a?(String) && !path.strip.empty?
|
|
59
|
+
|
|
60
|
+
absolute_path = File.expand_path(path, @pwd)
|
|
61
|
+
raise ArgumentError, "Path not found: #{path}" unless File.exist?(absolute_path)
|
|
62
|
+
|
|
63
|
+
formatters = normalize_formatters(raw_args['formatters'] || raw_args[:formatters])
|
|
64
|
+
mode = normalize_mode(raw_args['mode'] || raw_args[:mode])
|
|
65
|
+
out = normalize_optional_string(raw_args['out'] || raw_args[:out])
|
|
66
|
+
config = normalize_optional_string(raw_args['config'] || raw_args[:config])
|
|
67
|
+
|
|
68
|
+
if (formatters.length > 1 || formatters.include?('pdf')) && out.nil?
|
|
69
|
+
raise ArgumentError, 'Using multiple formatters or pdf requires `out`.'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
{
|
|
73
|
+
path: absolute_path,
|
|
74
|
+
formatters: formatters,
|
|
75
|
+
mode: mode,
|
|
76
|
+
out: out,
|
|
77
|
+
config: config
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_optional_string(value)
|
|
82
|
+
return nil if value.nil?
|
|
83
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
84
|
+
|
|
85
|
+
raise ArgumentError, 'Optional arguments (`config`, `out`) must be non-empty strings when provided.'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalize_formatters(value)
|
|
89
|
+
return ['simple'] if value.nil?
|
|
90
|
+
|
|
91
|
+
unless value.is_a?(Array) && !value.empty?
|
|
92
|
+
raise ArgumentError, '`formatters` must be a non-empty array when provided.'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
formatters = value.map do |formatter|
|
|
96
|
+
formatter_string = formatter.to_s.strip
|
|
97
|
+
raise ArgumentError, '`formatters` cannot contain empty values.' if formatter_string.empty?
|
|
98
|
+
|
|
99
|
+
formatter_string
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
unknown_formatter = formatters.find { |formatter| !SUPPORTED_FORMATTERS.include?(formatter) }
|
|
103
|
+
raise ArgumentError, "Unsupported formatter: #{unknown_formatter}" if unknown_formatter
|
|
104
|
+
|
|
105
|
+
formatters.uniq
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_mode(value)
|
|
109
|
+
return 'synthetic' if value.nil?
|
|
110
|
+
|
|
111
|
+
mode = value.to_s
|
|
112
|
+
raise ArgumentError, "Unsupported mode: #{mode}" unless SUPPORTED_MODES.include?(mode)
|
|
113
|
+
|
|
114
|
+
mode
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_command(args)
|
|
118
|
+
bin_path = File.expand_path('../../../bin/prd', __dir__)
|
|
119
|
+
command = ['bundle', 'exec', 'ruby', bin_path, args[:path]]
|
|
120
|
+
command << '--mode' << args[:mode]
|
|
121
|
+
|
|
122
|
+
args[:formatters].each do |formatter|
|
|
123
|
+
command << '-t' << formatter
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if args[:config]
|
|
127
|
+
command << '-c' << File.expand_path(args[:config], @pwd)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if args[:out]
|
|
131
|
+
command << '-o' << File.expand_path(args[:out], @pwd)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
command
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_summary(args:, stdout:, parsed_json:)
|
|
138
|
+
parsed = parsed_json || parse_simple_summary(stdout)
|
|
139
|
+
return { passed: nil, failed: nil, pending: nil } unless parsed
|
|
140
|
+
|
|
141
|
+
pending_count = parsed[:pending]
|
|
142
|
+
if pending_count.nil? && args[:mode] == 'synthetic' && args[:formatters].include?('simple')
|
|
143
|
+
pending_count = pending_count_from_simple_output(stdout)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
passed: parsed[:passed],
|
|
148
|
+
failed: parsed[:failed],
|
|
149
|
+
pending: pending_count
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def parse_json_summary(args:, stdout:, base_out:)
|
|
154
|
+
return nil unless args[:formatters].include?('json')
|
|
155
|
+
|
|
156
|
+
json_payload = nil
|
|
157
|
+
if base_out
|
|
158
|
+
json_path = "#{base_out}#{FORMATTER_EXTENSIONS['json']}"
|
|
159
|
+
json_payload = File.read(json_path) if File.exist?(json_path)
|
|
160
|
+
elsif args[:formatters] == ['json']
|
|
161
|
+
json_payload = stdout
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
return nil unless json_payload
|
|
165
|
+
|
|
166
|
+
parsed = JSON.parse(json_payload)
|
|
167
|
+
summary = parsed['summary'] || {}
|
|
168
|
+
|
|
169
|
+
pending = nil
|
|
170
|
+
events = parsed['events']
|
|
171
|
+
if events.is_a?(Array)
|
|
172
|
+
pending = events.count do |event|
|
|
173
|
+
(event['type'] == 'test_result' && event['status'] == 'PENDING') || event['type'] == 'pending'
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
{
|
|
178
|
+
passed: summary['passed'],
|
|
179
|
+
failed: summary['failed'],
|
|
180
|
+
pending: pending
|
|
181
|
+
}
|
|
182
|
+
rescue JSON::ParserError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def parse_simple_summary(stdout)
|
|
187
|
+
plain_output = strip_ansi(stdout)
|
|
188
|
+
matches = plain_output.scan(/(\d+)\s+passed,\s+(\d+)\s+failed/)
|
|
189
|
+
return nil if matches.empty?
|
|
190
|
+
match = matches.last
|
|
191
|
+
|
|
192
|
+
{
|
|
193
|
+
passed: match[0].to_i,
|
|
194
|
+
failed: match[1].to_i,
|
|
195
|
+
pending: nil
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def pending_count_from_simple_output(stdout)
|
|
200
|
+
strip_ansi(stdout).scan(/^PENDING:\s+/).count
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def strip_ansi(text)
|
|
204
|
+
text.gsub(/\e\[[0-9;]*m/, '')
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def build_artifacts(args:, base_out:)
|
|
208
|
+
reports = []
|
|
209
|
+
if base_out
|
|
210
|
+
args[:formatters].each do |formatter|
|
|
211
|
+
report_path = "#{base_out}#{FORMATTER_EXTENSIONS.fetch(formatter)}"
|
|
212
|
+
reports << {
|
|
213
|
+
type: formatter,
|
|
214
|
+
path: report_path,
|
|
215
|
+
exists: File.exist?(report_path)
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
annex_dir = if base_out
|
|
221
|
+
candidate = File.join(File.dirname(base_out), 'annex')
|
|
222
|
+
candidate if Dir.exist?(candidate)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
{
|
|
226
|
+
base_out: base_out,
|
|
227
|
+
reports: reports,
|
|
228
|
+
annex_dir: annex_dir
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def output_base_path(out_path)
|
|
233
|
+
resolved = File.expand_path(out_path, @pwd)
|
|
234
|
+
if directory_like_path?(resolved)
|
|
235
|
+
File.join(resolved, DEFAULT_REPORT_BASENAME)
|
|
236
|
+
else
|
|
237
|
+
ext = File.extname(resolved).downcase
|
|
238
|
+
known_extensions = FORMATTER_EXTENSIONS.values
|
|
239
|
+
known_extensions.include?(ext) ? resolved[...-ext.length] : resolved
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def directory_like_path?(path)
|
|
244
|
+
path.end_with?(File::SEPARATOR) || Dir.exist?(path)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module PrD
|
|
4
|
+
module Mcp
|
|
5
|
+
class Server
|
|
6
|
+
JSONRPC_VERSION = '2.0'.freeze
|
|
7
|
+
MCP_PROTOCOL_VERSION = '2024-11-05'.freeze
|
|
8
|
+
RUN_SPECS_TOOL_NAME = 'run_specs'.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(input: $stdin, output: $stdout, run_specs_tool: RunSpecsTool.new)
|
|
11
|
+
@input = input
|
|
12
|
+
@output = output
|
|
13
|
+
@run_specs_tool = run_specs_tool
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
while (message = read_message)
|
|
18
|
+
response = process_message(message)
|
|
19
|
+
write_message(response) if response
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def process_message(message)
|
|
24
|
+
id = message['id']
|
|
25
|
+
method = message['method']
|
|
26
|
+
params = message['params'] || {}
|
|
27
|
+
|
|
28
|
+
case method
|
|
29
|
+
when 'initialize'
|
|
30
|
+
success_response(id, initialize_result)
|
|
31
|
+
when 'notifications/initialized'
|
|
32
|
+
nil
|
|
33
|
+
when 'tools/list'
|
|
34
|
+
success_response(id, tools_list_result)
|
|
35
|
+
when 'tools/call'
|
|
36
|
+
success_response(id, handle_tool_call(params))
|
|
37
|
+
else
|
|
38
|
+
return nil unless id
|
|
39
|
+
|
|
40
|
+
error_response(id, -32601, "Method not found: #{method}")
|
|
41
|
+
end
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
return nil unless id
|
|
44
|
+
|
|
45
|
+
error_response(id, -32603, "Internal error: #{e.message}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def initialize_result
|
|
51
|
+
{
|
|
52
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
53
|
+
capabilities: {
|
|
54
|
+
tools: {}
|
|
55
|
+
},
|
|
56
|
+
serverInfo: {
|
|
57
|
+
name: 'probatio-diabolica-mcp',
|
|
58
|
+
version: PrD::VERSION
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tools_list_result
|
|
64
|
+
{
|
|
65
|
+
tools: [run_specs_definition]
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_specs_definition
|
|
70
|
+
{
|
|
71
|
+
name: RUN_SPECS_TOOL_NAME,
|
|
72
|
+
description: 'Run probatio_diabolica specs from a file or directory path.',
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
path: { type: 'string', description: 'File or directory path containing spec(s).' },
|
|
77
|
+
config: { type: 'string', description: 'Optional config file, equivalent to `-c`.' },
|
|
78
|
+
out: { type: 'string', description: 'Optional output base path, equivalent to `-o`.' },
|
|
79
|
+
formatters: {
|
|
80
|
+
type: 'array',
|
|
81
|
+
description: 'Optional formatter list.',
|
|
82
|
+
items: {
|
|
83
|
+
type: 'string',
|
|
84
|
+
enum: RunSpecsTool::SUPPORTED_FORMATTERS
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
mode: {
|
|
88
|
+
type: 'string',
|
|
89
|
+
enum: RunSpecsTool::SUPPORTED_MODES,
|
|
90
|
+
description: 'Optional output mode.'
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
required: ['path']
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def handle_tool_call(params)
|
|
99
|
+
tool_name = params['name']
|
|
100
|
+
arguments = params['arguments'] || {}
|
|
101
|
+
|
|
102
|
+
return tool_error("Unknown tool: #{tool_name}") unless tool_name == RUN_SPECS_TOOL_NAME
|
|
103
|
+
|
|
104
|
+
result = @run_specs_tool.call(arguments)
|
|
105
|
+
|
|
106
|
+
unless result[:ok]
|
|
107
|
+
return tool_error(result.dig(:logs, :stderr) || 'run_specs failed unexpectedly.')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
tool_success(result)
|
|
111
|
+
rescue ArgumentError => e
|
|
112
|
+
tool_error(e.message)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def tool_success(payload)
|
|
116
|
+
{
|
|
117
|
+
content: [{ type: 'text', text: JSON.pretty_generate(payload) }],
|
|
118
|
+
structuredContent: payload
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def tool_error(message)
|
|
123
|
+
{
|
|
124
|
+
content: [{ type: 'text', text: message }],
|
|
125
|
+
isError: true
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def success_response(id, result)
|
|
130
|
+
{
|
|
131
|
+
jsonrpc: JSONRPC_VERSION,
|
|
132
|
+
id: id,
|
|
133
|
+
result: result
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def error_response(id, code, message)
|
|
138
|
+
{
|
|
139
|
+
jsonrpc: JSONRPC_VERSION,
|
|
140
|
+
id: id,
|
|
141
|
+
error: {
|
|
142
|
+
code: code,
|
|
143
|
+
message: message
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def read_message
|
|
149
|
+
headers = {}
|
|
150
|
+
|
|
151
|
+
while (line = @input.gets)
|
|
152
|
+
line = line.strip
|
|
153
|
+
break if line.empty?
|
|
154
|
+
|
|
155
|
+
key, value = line.split(':', 2)
|
|
156
|
+
next unless key && value
|
|
157
|
+
|
|
158
|
+
headers[key.downcase] = value.strip
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
return nil if headers.empty?
|
|
162
|
+
|
|
163
|
+
content_length = Integer(headers.fetch('content-length'))
|
|
164
|
+
raw = @input.read(content_length)
|
|
165
|
+
JSON.parse(raw)
|
|
166
|
+
rescue EOFError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def write_message(message)
|
|
171
|
+
payload = JSON.dump(message)
|
|
172
|
+
@output.write("Content-Length: #{payload.bytesize}\r\n\r\n")
|
|
173
|
+
@output.write(payload)
|
|
174
|
+
@output.flush
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/pr_d/version.rb
CHANGED
data/probatio_diabolica.gemspec
CHANGED
|
@@ -22,17 +22,20 @@ Gem::Specification.new do |spec|
|
|
|
22
22
|
spec.files = Dir[
|
|
23
23
|
"lib/**/*.rb",
|
|
24
24
|
"bin/prd",
|
|
25
|
+
"bin/prd_mcp",
|
|
25
26
|
"README.md",
|
|
26
27
|
"Gemfile",
|
|
27
28
|
"probatio_diabolica.gemspec"
|
|
28
29
|
]
|
|
29
30
|
spec.bindir = "bin"
|
|
30
31
|
spec.executables << "prd"
|
|
32
|
+
spec.executables << "prd_mcp"
|
|
31
33
|
spec.require_paths = ["lib"]
|
|
32
34
|
|
|
33
35
|
spec.add_dependency "ruby_llm"
|
|
34
36
|
spec.add_dependency 'ruby_llm-schema'
|
|
35
37
|
spec.add_dependency 'pdf-reader'
|
|
36
38
|
spec.add_dependency 'prawn'
|
|
39
|
+
spec.add_dependency 'rouge'
|
|
37
40
|
spec.add_dependency 'zeitwerk'
|
|
38
41
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: probatio_diabolica
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Laporte Mathieu
|
|
@@ -65,6 +65,20 @@ dependencies:
|
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rouge
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
68
82
|
- !ruby/object:Gem::Dependency
|
|
69
83
|
name: zeitwerk
|
|
70
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -85,13 +99,16 @@ email:
|
|
|
85
99
|
- mathieu.laporte+prd@gmail.com
|
|
86
100
|
executables:
|
|
87
101
|
- prd
|
|
102
|
+
- prd_mcp
|
|
88
103
|
extensions: []
|
|
89
104
|
extra_rdoc_files: []
|
|
90
105
|
files:
|
|
91
106
|
- Gemfile
|
|
92
107
|
- README.md
|
|
93
108
|
- bin/prd
|
|
109
|
+
- bin/prd_mcp
|
|
94
110
|
- lib/pr_d.rb
|
|
111
|
+
- lib/pr_d/code.rb
|
|
95
112
|
- lib/pr_d/formatters.rb
|
|
96
113
|
- lib/pr_d/formatters/formatter.rb
|
|
97
114
|
- lib/pr_d/formatters/html_formatter.rb
|
|
@@ -108,6 +125,8 @@ files:
|
|
|
108
125
|
- lib/pr_d/matchers/includes_matcher.rb
|
|
109
126
|
- lib/pr_d/matchers/llm_matcher.rb
|
|
110
127
|
- lib/pr_d/matchers/matcher.rb
|
|
128
|
+
- lib/pr_d/mcp/run_specs_tool.rb
|
|
129
|
+
- lib/pr_d/mcp/server.rb
|
|
111
130
|
- lib/pr_d/version.rb
|
|
112
131
|
- lib/probatio_diabolica.rb
|
|
113
132
|
- probatio_diabolica.gemspec
|