probatio_diabolica 0.2.0 → 0.3.2
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 +122 -67
- data/bin/prd_mcp +9 -0
- data/lib/pr_d/code.rb +19 -0
- data/lib/pr_d/formatters/formatter.rb +4 -0
- data/lib/pr_d/formatters/html_formatter.rb +232 -26
- data/lib/pr_d/formatters/json_formatter.rb +12 -0
- data/lib/pr_d/formatters/pdf_formatter.rb +80 -9
- data/lib/pr_d/formatters/simple_formatter.rb +29 -7
- data/lib/pr_d/helpers/chrome_helper.rb +210 -19
- 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: 39336802d7f3c344f21acbf0567ea85a73dfbc68c2d339e7ab5e1e1866fc609e
|
|
4
|
+
data.tar.gz: c4f0fa5209ac0a7437edf9e6cf117b95fe859217b49e172557f05c44dc664307
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f78a5ff31c1b5cbee275d596ef74529b0fad41426c4b7ee5303d98d4224cbcf20718485403ec048fc4f26159775c0ae8e33c07eeeb95e6944c2a21cf3feeec26
|
|
7
|
+
data.tar.gz: ffa412e8bc62906e1c9be463a9d48dc9a30bbf4e4fcf458abc1e203026bde0a7e5f60ae0dce03c33cd6375c5402060b80ae1b1cf0b610328b1808a5eb436b8cf
|
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'
|
|
@@ -192,6 +161,42 @@ end
|
|
|
192
161
|
- `expect { |subject| ... }.to matcher`
|
|
193
162
|
- `expect.to matcher` (uses `subject`)
|
|
194
163
|
|
|
164
|
+
### Spec best practices for `subject` (PRD reports)
|
|
165
|
+
|
|
166
|
+
When a test defines a `subject`, PRD can surface it more clearly in generated reports.
|
|
167
|
+
For CLI and integration specs, prefer:
|
|
168
|
+
|
|
169
|
+
- grouping with explicit `context`
|
|
170
|
+
- one `subject` per context for the main action
|
|
171
|
+
- assertions written with `expect.to(...)` when the assertion targets `subject`
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
context 'when CLI receives an unknown formatter type' do
|
|
177
|
+
subject { Open3.capture3('bundle exec ruby bin/prd spec/self_hosted_spec.rb -t unknown') }
|
|
178
|
+
|
|
179
|
+
it 'fails fast on unknown formatter type in CLI' do
|
|
180
|
+
_stdout, stderr, status = subject
|
|
181
|
+
|
|
182
|
+
expect(status.success?).to(be(false))
|
|
183
|
+
expect(stderr).to(includes('Unsupported formatter type: unknown. Supported: simple, html, json, pdf'))
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
For simple value checks, this pattern keeps specs concise:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
context 'with strings' do
|
|
192
|
+
subject { 'probatio diabolica' }
|
|
193
|
+
|
|
194
|
+
it 'matches expected content' do
|
|
195
|
+
expect.to(includes('diabolica'))
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
195
200
|
### Matchers
|
|
196
201
|
|
|
197
202
|
- `eq(expected)` equality with `==`
|
|
@@ -205,13 +210,30 @@ end
|
|
|
205
210
|
|
|
206
211
|
`PrD::Runtime` exposes helpers to test content loaded in Chrome:
|
|
207
212
|
|
|
213
|
+
- `page(at:, warmup_time:)` opens a page and returns a `BrowserSession`
|
|
208
214
|
- `screen(at:, width:, height:, warmup_time:)` captures a PNG and returns a `File`
|
|
209
|
-
- `text(at:, css:, warmup_time:)` extracts a CSS node
|
|
215
|
+
- `text(at:, css:, warmup_time:)` extracts a CSS node and returns `PrD::Code` (language: `text`)
|
|
210
216
|
- `network(at:, warmup_time:)` returns Ferrum network traffic
|
|
211
217
|
- `network_urls(at:, warmup_time:)` returns traffic URLs
|
|
212
218
|
- `pdf(at:, warmup_time:)` generates a PDF and returns a `PDF::Reader`
|
|
213
219
|
- `html(at:, warmup_time:)` returns HTML (`browser.body`)
|
|
214
220
|
|
|
221
|
+
`BrowserSession` adds high-level page interactions:
|
|
222
|
+
|
|
223
|
+
- `find(css:/xpath:, wait:, shadow:)`
|
|
224
|
+
- `exists?(css:/xpath:, wait:, shadow:)`
|
|
225
|
+
- `click(css:/xpath:, wait:, shadow:)`
|
|
226
|
+
- `fill(css:/xpath:, with:, clear:, blur:, wait:, shadow:)`
|
|
227
|
+
- `select_option(css:, value:/values:, by:, wait:, shadow:)`
|
|
228
|
+
- `set_files(css:, path:/paths:, wait:, shadow:)` (alias `upload_files`)
|
|
229
|
+
- `navigate(to:, warmup_time:)`
|
|
230
|
+
|
|
231
|
+
About `shadow:`:
|
|
232
|
+
|
|
233
|
+
- `shadow:` is an ordered CSS path used to narrow the scope before the target selector.
|
|
234
|
+
- Each step can be a shadow host or a regular container.
|
|
235
|
+
- If a step has `shadowRoot`, search continues inside it; otherwise search continues inside the matched node.
|
|
236
|
+
|
|
215
237
|
Prerequisites:
|
|
216
238
|
|
|
217
239
|
- Chrome/Chromium must be installed.
|
|
@@ -228,9 +250,39 @@ it 'checks dynamic content loaded in browser' do
|
|
|
228
250
|
end
|
|
229
251
|
```
|
|
230
252
|
|
|
253
|
+
Form interaction and file upload example:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
it 'uploads a file in a shadow-dom form' do
|
|
257
|
+
html(at: 'https://example.com/upload', warmup_time: 2) do |page|
|
|
258
|
+
page.click(css: 'button[data-open-upload]')
|
|
259
|
+
page.fill(css: 'input[name="title"]', with: 'Invoice')
|
|
260
|
+
page.set_files(
|
|
261
|
+
css: 'input[type="file"]',
|
|
262
|
+
shadow: ['vax-scanner', '[data-view="upload"]'],
|
|
263
|
+
path: 'examples/random_photo.png'
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
231
269
|
### Source code helper (Prism)
|
|
232
270
|
|
|
233
271
|
`source_code(...)` uses the `prism` gem to parse Ruby source and extract class/method code.
|
|
272
|
+
It returns a `PrD::Code` object:
|
|
273
|
+
|
|
274
|
+
- `source` (`String`)
|
|
275
|
+
- `language` (`String`, default: `ruby`)
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
let(:code) { source_code(PrD::Matchers::AllMatcher) }
|
|
281
|
+
|
|
282
|
+
it 'uses raw source text' do
|
|
283
|
+
expect(code.source).to(includes('class AllMatcher'))
|
|
284
|
+
end
|
|
285
|
+
```
|
|
234
286
|
|
|
235
287
|
Prerequisites:
|
|
236
288
|
|
|
@@ -273,13 +325,16 @@ When you define a `subject`, each formatter tries to render it in the most usefu
|
|
|
273
325
|
- for files, prints a textual representation (for example path, file preview for `.txt`)
|
|
274
326
|
- `HtmlFormatter`:
|
|
275
327
|
- renders text values directly
|
|
328
|
+
- for `PrD::Code`, renders syntax-highlighted code blocks (Rouge) inside collapsible sections
|
|
276
329
|
- for image files (`.png`, `.jpg`, `.jpeg`), embeds the image in the report
|
|
277
330
|
- for PDF subjects (`File` `.pdf` or `PDF::Reader`), embeds the PDF with a `data:application/pdf;base64,...` URI
|
|
278
331
|
- `PdfFormatter`:
|
|
279
332
|
- renders text values as report lines
|
|
333
|
+
- for `PrD::Code`, renders language + syntax-highlighted code block (Rouge -> Prawn colors)
|
|
280
334
|
- for image files (`.png`, `.jpg`, `.jpeg`), inserts the image directly in the PDF report
|
|
281
335
|
- `JsonFormatter`:
|
|
282
336
|
- keeps a structured representation for machine processing
|
|
337
|
+
- for `PrD::Code`, emits a structured payload (`type: "code"`, `language`, `source`)
|
|
283
338
|
- `File` values (images, PDFs, text files, etc.) are embedded as base64 payloads
|
|
284
339
|
- `PDF::Reader` values are also embedded as base64 (`application/pdf`)
|
|
285
340
|
|
data/bin/prd_mcp
ADDED
data/lib/pr_d/code.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module PrD
|
|
2
|
+
class Code < SimpleDelegator
|
|
3
|
+
attr_reader :source, :language
|
|
4
|
+
|
|
5
|
+
def initialize(source:, language: 'ruby')
|
|
6
|
+
super(source.to_s)
|
|
7
|
+
@source = source.to_s
|
|
8
|
+
@language = language.to_s
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_s
|
|
12
|
+
@source
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def include?(value)
|
|
16
|
+
@source.include?(value)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
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,83 @@ 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
|
+
text-transform: uppercase;
|
|
361
|
+
letter-spacing: 0.05em;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.code-block {
|
|
365
|
+
margin: 0.45rem 0 0.7rem;
|
|
366
|
+
border: 1px solid var(--line);
|
|
367
|
+
border-radius: 10px;
|
|
368
|
+
background: #fff;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.code-toggle {
|
|
372
|
+
list-style: none;
|
|
373
|
+
cursor: pointer;
|
|
374
|
+
padding: 0.6rem 0.8rem;
|
|
375
|
+
display: flex;
|
|
376
|
+
align-items: center;
|
|
377
|
+
justify-content: space-between;
|
|
378
|
+
gap: 0.8rem;
|
|
379
|
+
font-size: 0.88rem;
|
|
380
|
+
color: var(--text);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.code-toggle::-webkit-details-marker {
|
|
384
|
+
display: none;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.code-toggle::after {
|
|
388
|
+
content: 'Open';
|
|
389
|
+
color: var(--muted);
|
|
390
|
+
font-size: 0.78rem;
|
|
391
|
+
letter-spacing: 0.03em;
|
|
392
|
+
text-transform: uppercase;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.code-block[open] .code-toggle::after {
|
|
396
|
+
content: 'Close';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.highlight {
|
|
400
|
+
margin: 0;
|
|
401
|
+
border-top: 1px solid var(--line);
|
|
402
|
+
border-radius: 0 0 10px 10px;
|
|
403
|
+
overflow-x: auto;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.highlight pre {
|
|
407
|
+
margin: 0;
|
|
408
|
+
padding: 0.8rem;
|
|
409
|
+
line-height: 1.35;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
@media (max-width: 960px) {
|
|
413
|
+
:root { --sidebar-width: min(82vw, 320px); }
|
|
414
|
+
|
|
415
|
+
body.has-index main.container,
|
|
416
|
+
body.has-index.index-collapsed main.container {
|
|
417
|
+
width: calc(100% - 1rem);
|
|
418
|
+
margin: 4rem 0.5rem 1rem 0.5rem;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.report-index {
|
|
422
|
+
top: 3.5rem;
|
|
423
|
+
left: 0.5rem;
|
|
424
|
+
bottom: 0.5rem;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
body.has-index.index-collapsed .report-index {
|
|
428
|
+
transform: translateX(calc(-1 * (var(--sidebar-width) + 0.6rem)));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#{rouge_theme_css}
|
|
317
433
|
</style>
|
|
318
434
|
</head>
|
|
319
435
|
<body>
|
|
@@ -325,28 +441,52 @@ module PrD
|
|
|
325
441
|
return '' if @index_entries.empty?
|
|
326
442
|
|
|
327
443
|
index_items = @index_entries.map do |entry|
|
|
328
|
-
|
|
444
|
+
label = escape(index_label(entry))
|
|
445
|
+
"<li class=\"index-item\" style=\"--index-level: #{entry[:level]};\"><a class=\"index-link\" href=\"##{entry[:anchor_id]}\" title=\"#{label}\">#{label}</a></li>"
|
|
329
446
|
end.join
|
|
330
447
|
|
|
331
448
|
<<~HTML
|
|
332
|
-
<
|
|
449
|
+
<button type="button" class="index-toggle" aria-expanded="false" aria-controls="report-index">Show index</button>
|
|
450
|
+
<nav id="report-index" class="report-index" aria-label="Report index">
|
|
333
451
|
<h2 class="index-title">Index</h2>
|
|
334
452
|
<ul class="index-list">
|
|
335
453
|
#{index_items}
|
|
336
454
|
</ul>
|
|
337
455
|
</nav>
|
|
456
|
+
<script>
|
|
457
|
+
(function() {
|
|
458
|
+
var body = document.body;
|
|
459
|
+
var nav = document.getElementById('report-index');
|
|
460
|
+
var toggle = document.querySelector('.index-toggle');
|
|
461
|
+
if (!body || !nav || !toggle) return;
|
|
462
|
+
|
|
463
|
+
body.classList.add('has-index');
|
|
464
|
+
|
|
465
|
+
var syncToggleLabel = function() {
|
|
466
|
+
var isCollapsed = body.classList.contains('index-collapsed');
|
|
467
|
+
toggle.setAttribute('aria-expanded', String(!isCollapsed));
|
|
468
|
+
toggle.textContent = isCollapsed ? 'Show index' : 'Hide index';
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
toggle.addEventListener('click', function() {
|
|
472
|
+
body.classList.toggle('index-collapsed');
|
|
473
|
+
syncToggleLabel();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
syncToggleLabel();
|
|
477
|
+
})();
|
|
478
|
+
</script>
|
|
338
479
|
HTML
|
|
339
480
|
end
|
|
340
481
|
|
|
341
482
|
def index_label(entry)
|
|
342
|
-
|
|
483
|
+
marker =
|
|
343
484
|
case entry[:type]
|
|
344
|
-
when :context then '
|
|
345
|
-
|
|
346
|
-
else 'Test'
|
|
485
|
+
when :context then '+'
|
|
486
|
+
else '-'
|
|
347
487
|
end
|
|
348
488
|
|
|
349
|
-
"#{
|
|
489
|
+
"#{marker} #{entry[:label]}"
|
|
350
490
|
end
|
|
351
491
|
|
|
352
492
|
def add_index_entry(type:, label:, level:, anchor_id:)
|
|
@@ -363,6 +503,72 @@ module PrD
|
|
|
363
503
|
"#{prefix}-#{@anchor_counters[prefix]}"
|
|
364
504
|
end
|
|
365
505
|
|
|
506
|
+
def render_labeled_value(label, value)
|
|
507
|
+
if code_object?(value)
|
|
508
|
+
@content << "<p class=\"line\"><strong>#{escape(label)}:</strong></p>"
|
|
509
|
+
@content << render_code_block(value)
|
|
510
|
+
else
|
|
511
|
+
@content << "<p class=\"line\"><strong>#{escape(label)}:</strong> #{escape(serialize(value).to_s)}</p>"
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def render_matcher_value(label, value)
|
|
516
|
+
if code_object?(value)
|
|
517
|
+
@content << "<p class=\"line\"><strong>Matcher:</strong> #{escape(label)} (#{escape(value.language)})</p>"
|
|
518
|
+
@content << render_code_block(value)
|
|
519
|
+
else
|
|
520
|
+
@content << "<p class=\"line\"><strong>Matcher:</strong> #{escape(label)} #{escape(serialize(value).to_s)}</p>"
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def render_value_block(label, value)
|
|
525
|
+
@content << '<div class="subject-block">'
|
|
526
|
+
if code_object?(value)
|
|
527
|
+
@content << "<p class=\"line\"><strong>#{escape(label)}:</strong></p>"
|
|
528
|
+
@content << render_code_block(value)
|
|
529
|
+
else
|
|
530
|
+
@content << "<p class=\"line\"><strong>#{escape(label)}:</strong> #{escape(serialize(value).to_s)}</p>"
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
if image_file?(value)
|
|
534
|
+
@content << "<img src=\"#{image_data_uri(value.path)}\" alt=\"#{escape(label)} image\" class=\"subject-image\" />"
|
|
535
|
+
elsif pdf_file?(value)
|
|
536
|
+
@content << "<embed src=\"#{pdf_data_uri(value.path)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
|
|
537
|
+
elsif pdf_reader?(value)
|
|
538
|
+
@content << "<embed src=\"#{pdf_reader_data_uri(value)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
|
|
539
|
+
end
|
|
540
|
+
@content << '</div>'
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def render_code_block(code)
|
|
544
|
+
source = normalize_text(code.source)
|
|
545
|
+
language = normalize_text(code.language)
|
|
546
|
+
highlighted = highlight_code(source, language)
|
|
547
|
+
<<~HTML
|
|
548
|
+
<details class="code-block">
|
|
549
|
+
<summary class="code-toggle">
|
|
550
|
+
<span class="code-language">#{escape(language)}</span>
|
|
551
|
+
</summary>
|
|
552
|
+
#{highlighted}
|
|
553
|
+
</details>
|
|
554
|
+
HTML
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def highlight_code(source, language)
|
|
558
|
+
lexer = rouge_lexer_for(source, language)
|
|
559
|
+
@rouge_formatter.format(lexer.lex(source))
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def rouge_lexer_for(source, language)
|
|
563
|
+
Rouge::Lexer.find_fancy(language, source) || Rouge::Lexers::PlainText
|
|
564
|
+
rescue StandardError
|
|
565
|
+
Rouge::Lexers::PlainText
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def rouge_theme_css
|
|
569
|
+
Rouge::Themes::Github.render(scope: '.highlight')
|
|
570
|
+
end
|
|
571
|
+
|
|
366
572
|
def escape(message)
|
|
367
573
|
CGI.escape_html(normalize_text(message))
|
|
368
574
|
end
|