probatio_diabolica 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c53898870646f0712544d5bc8998715622cffb4981711737757c26d3f8e41ad7
4
+ data.tar.gz: e3a828ea56faac76e54443c1292235a849bbfd0aca1ff9745c94b3f53190650e
5
+ SHA512:
6
+ metadata.gz: 38aa1da254d62abeed89a013e82a1e87b63970c4faedf0fb858d140e3a83a8c61a2fc16fff6f073e0cafd81260f762a7fa0ab294d7a879f20c27c204d9549d50
7
+ data.tar.gz: 5e108178e7da9d4ec7605959f96c6b782754a068bcec05faedac09bf93c709fc559cf1c4fb6e0f71aaa6110f396ec2a2f549092ab1f17581efd7d8a92a5dd8d9
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # Probatio Diabolica
2
+
3
+ <p align="center">
4
+ <img src="diable.png" alt="Logo Probatio Diabolica" width="260" />
5
+ </p>
6
+
7
+ A Ruby DSL-based testing framework with classic matchers and LLM-powered matchers (text and image).
8
+
9
+ This project is experimental and not production-ready.
10
+
11
+ ## What this project does
12
+
13
+ `probatio_diabolica` runs `*_spec.rb` files through a custom runtime (`PrD::Runtime`) with an RSpec-like syntax:
14
+
15
+ - `describe`, `context`, `it`, `pending`, `let`, `subject`
16
+ - `expect(...).to(...)` and `expect(...).not_to(...)`
17
+ - standard matchers (`eq`, `be`, `includes`, `have`, `all`)
18
+ - LLM matcher `satisfy(...)` to validate natural-language conditions
19
+
20
+ Tests are evaluated with `instance_eval` (not through RSpec).
21
+
22
+ ## Installation
23
+
24
+ ### From the gem
25
+
26
+ ```ruby
27
+ gem 'probatio_diabolica'
28
+ ```
29
+
30
+ Then:
31
+
32
+ ```bash
33
+ bundle install
34
+ ```
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
+ After installation, you can run:
60
+
61
+ ```bash
62
+ prd examples/basics_spec.rb
63
+ ```
64
+
65
+ If `prd` is not found, add your gem bin directory to `PATH`:
66
+
67
+ ```bash
68
+ export PATH="$(ruby -e 'print Gem.user_dir')/bin:$PATH"
69
+ ```
70
+
71
+ ## Configuration LLM
72
+
73
+ The runtime automatically loads `prd_helper.rb` if present (or a file passed with `-c`).
74
+
75
+ Minimal example:
76
+
77
+ ```ruby
78
+ # prd_helper.rb
79
+ RubyLLM.configure do |config|
80
+ config.openrouter_api_key = ENV['OPENROUTER_API_KEY']
81
+ end
82
+ ```
83
+
84
+ Without valid configuration, tests using `satisfy(...)` will fail.
85
+
86
+ ## Running tests
87
+
88
+ Command:
89
+
90
+ ```bash
91
+ prd <file_or_directory> [options]
92
+ ```
93
+
94
+ From source checkout (without gem install), use:
95
+
96
+ ```bash
97
+ bundle exec prd <file_or_directory> [options]
98
+ ```
99
+
100
+ Supported options:
101
+
102
+ - `-o, --out DIR` writes output to `DIR/report.qd` (otherwise stdout)
103
+ - `-c, --config FILE` Ruby config file to require
104
+ - `-t, --type TYPE` formatter type (`simple` by default; supports `simple`, `html`, `json`, `pdf`)
105
+ - `-m, --mode MODE` output verbosity mode (`verbose` by default; supports `verbose`, `synthetic`)
106
+
107
+ Examples:
108
+
109
+ ```bash
110
+ # single file
111
+ bundle exec prd examples/basics_spec.rb
112
+
113
+ # all *_spec.rb files in a directory
114
+ bundle exec prd examples
115
+
116
+ # HTML report
117
+ bundle exec prd examples/image_spec.rb -t html -o ./tmp
118
+
119
+ # compact synthetic output on console
120
+ bundle exec prd examples/basics_spec.rb --mode synthetic
121
+ ```
122
+
123
+ ## Available DSL
124
+
125
+ ### Structure
126
+
127
+ ```ruby
128
+ describe 'My domain' do
129
+ context 'my context' do
130
+ let(:value) { 5 }
131
+ subject { 'hello' }
132
+
133
+ it 'runs an assertion' do
134
+ expect(value).to eq(5)
135
+ end
136
+
137
+ pending 'test to implement later'
138
+ end
139
+ end
140
+ ```
141
+
142
+ ### Assertions
143
+
144
+ - `expect(actual).to matcher`
145
+ - `expect(actual).not_to matcher`
146
+ - `expect { |subject| ... }.to matcher`
147
+ - `expect.to matcher` (uses `subject`)
148
+
149
+ ### Matchers
150
+
151
+ - `eq(expected)` equality with `==`
152
+ - `be(expected)` object identity (`equal?`)
153
+ - `includes(expected)` inclusion for `String`, `Array`, `File`, `PDF::Reader`
154
+ - `have(expected)` alias inclusion via `include?`
155
+ - `all(proc)` checks all elements against a block
156
+ - `satisfy(natural_language_condition)` LLM-based validation
157
+
158
+ ### Browser helpers (Ferrum)
159
+
160
+ `PrD::Runtime` exposes helpers to test content loaded in Chrome:
161
+
162
+ - `screen(at:, width:, height:, warmup_time:)` captures a PNG and returns a `File`
163
+ - `text(at:, css:, warmup_time:)` extracts a CSS node into a `.txt` file and returns a `File`
164
+ - `network(at:, warmup_time:)` returns Ferrum network traffic
165
+ - `network_urls(at:, warmup_time:)` returns traffic URLs
166
+ - `pdf(at:, warmup_time:)` generates a PDF and returns a `PDF::Reader`
167
+ - `html(at:, warmup_time:)` returns HTML (`browser.body`)
168
+
169
+ Prerequisites:
170
+
171
+ - Chrome/Chromium must be installed.
172
+ - The `ferrum` gem is optional and only required for these helpers.
173
+ - Add `gem 'ferrum'` to your Gemfile, or install it with `gem install ferrum`.
174
+ - If it is missing, an explicit `LoadError` is raised on the first browser helper call.
175
+
176
+ Example:
177
+
178
+ ```ruby
179
+ it 'checks dynamic content loaded in browser' do
180
+ page_text = text(at: 'https://example.com', css: 'main')
181
+ expect(page_text).to(includes('Example Domain'))
182
+ end
183
+ ```
184
+
185
+ ### Source code helper (Prism)
186
+
187
+ `source_code(...)` uses the `prism` gem to parse Ruby source and extract class/method code.
188
+
189
+ Prerequisites:
190
+
191
+ - The `prism` gem is optional and only required for `source_code(...)`.
192
+ - Add `gem 'prism'` to your Gemfile, or install it with `gem install prism`.
193
+ - If it is missing, an explicit `LoadError` is raised on the first `source_code(...)` call.
194
+
195
+ ## LLM models
196
+
197
+ You can set a model at `context` or `it` level:
198
+
199
+ ```ruby
200
+ context 'SQL checks', model: 'qwen/qwen-2.5-72b-instruct:free' do
201
+ it 'accepts a valid query' do
202
+ expect('SELECT * FROM users').to satisfy('This statement is valid SQL.')
203
+ end
204
+ end
205
+ ```
206
+
207
+ The runtime keeps a model stack (`it` can temporarily override the parent `context` model).
208
+
209
+ ## Formatters
210
+
211
+ - `PrD::Formatters::SimpleFormatter` (text console output)
212
+ - `PrD::Formatters::HtmlFormatter` (simple HTML output)
213
+ - `PrD::Formatters::JsonFormatter` exists in code but is not currently exposed by `bin/prd`
214
+
215
+ ### Subject rendering policy (best effort)
216
+
217
+ When you define a `subject`, each formatter tries to render it in the most useful way for its medium:
218
+
219
+ - `SimpleFormatter`:
220
+ - renders readable text in terminal
221
+ - for files, prints a textual representation (for example path, file preview for `.txt`)
222
+ - `HtmlFormatter`:
223
+ - renders text values directly
224
+ - for image files (`.png`, `.jpg`, `.jpeg`), embeds the image in the report
225
+ - for PDF subjects (`File` `.pdf` or `PDF::Reader`), embeds the PDF with a `data:application/pdf;base64,...` URI
226
+ - `PdfFormatter`:
227
+ - renders text values as report lines
228
+ - for image files (`.png`, `.jpg`, `.jpeg`), inserts the image directly in the PDF report
229
+ - `JsonFormatter`:
230
+ - keeps a structured representation for machine processing
231
+ - `File` values (images, PDFs, text files, etc.) are embedded as base64 payloads
232
+ - `PDF::Reader` values are also embedded as base64 (`application/pdf`)
233
+
234
+ The goal is to preserve readability and report size while surfacing the richest representation each formatter can reasonably support.
235
+
236
+ ## Useful references in this repository
237
+
238
+ - Basic example: `examples/basics_spec.rb`
239
+ - Code analysis example: `examples/code_example_spec.rb`
240
+ - Image example: `examples/image_spec.rb`
241
+ - Browser example: `examples/browser_spec.rb`
242
+ - CLI entrypoint: `bin/prd`
243
+ - DSL runtime: `lib/pr_d.rb`
244
+
245
+ ## Current limitations
246
+
247
+ - Work in progress, API may change.
248
+ - Strong dependency on an LLM provider for `satisfy`.
249
+ - `-o` output is always written to a file named `report.qd`.
data/bin/prd ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'pr_d'
5
+ rescue LoadError
6
+ require_relative '../lib/pr_d'
7
+ end
8
+ require 'optparse'
9
+
10
+ options = {}
11
+ OptionParser
12
+ .new do |opts|
13
+ opts.banner = 'Usage: prd [options] FILE_OR_DIR'
14
+ opts.on('-o', '--out DIR', 'Specify the output directory') { |dir| options[:out] = dir }
15
+ opts.on('-c', '--config FILE', 'Specify the config file') { |file| options[:config] = file }
16
+ opts.on('-t', '--type TYPE', 'Specify the formatter type (simple, html, json, pdf)') do |type|
17
+ options[:formatter] = type
18
+ end
19
+ opts.on('-m', '--mode MODE', 'Specify output mode (verbose, synthetic)') do |mode|
20
+ options[:mode] = mode
21
+ end
22
+ end
23
+ .parse!
24
+
25
+ input_path = ARGV.shift
26
+ raise 'No tests found. Please specify a test file or directory (e.g. prd examples/basics_spec.rb).' if input_path.nil? || input_path.empty?
27
+ raise "Unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
28
+ raise "Path not found: #{input_path}" unless File.exist?(input_path)
29
+
30
+ tests =
31
+ if File.directory?(input_path)
32
+ files = Dir[File.join(input_path, '**', '*_spec.rb')].sort
33
+ raise "No spec files found in directory: #{input_path}" if files.empty?
34
+ files.map { |file| File.read(file) }
35
+ elsif File.file?(input_path)
36
+ [File.read(input_path)]
37
+ end
38
+
39
+ io =
40
+ if options[:out].nil?
41
+ raise 'PDF formatter requires --out to write a binary report file.' if options[:formatter] == 'pdf'
42
+ STDOUT
43
+ else
44
+ Dir.mkdir(options[:out]) unless Dir.exist?(options[:out])
45
+ report_filename = case options[:formatter]
46
+ when 'html'
47
+ 'report.html'
48
+ when 'pdf'
49
+ 'report.pdf'
50
+ else
51
+ 'report.qd'
52
+ end
53
+ mode = options[:formatter] == 'pdf' ? 'wb' : 'w'
54
+ File.open(File.join(options[:out], report_filename), mode)
55
+ end
56
+
57
+ formatter_class = case options[:formatter]
58
+ when nil, 'simple'
59
+ PrD::Formatters::SimpleFormatter
60
+ when 'html'
61
+ PrD::Formatters::HtmlFormatter
62
+ when 'json'
63
+ PrD::Formatters::JsonFormatter
64
+ when 'pdf'
65
+ PrD::Formatters::PdfFormatter
66
+ else
67
+ raise "Unsupported formatter type: #{options[:formatter]}. Supported: simple, html, json, pdf"
68
+ end
69
+
70
+ mode = options[:mode] || 'verbose'
71
+ supported_modes = %w[verbose synthetic]
72
+ raise "Unsupported mode: #{mode}. Supported: verbose, synthetic" unless supported_modes.include?(mode)
73
+
74
+ serializers = {
75
+ # Ferrum::Network::Exchange => (->(exchange) { exchange.url }),
76
+ # PDF::Reader => (->(pdf) { pdf.pages.map(&:text).join("\n") })
77
+ }
78
+
79
+ formatter = formatter_class.new(io:, serializers:, mode: mode.to_sym)
80
+ runtime = PrD::Runtime.new(formatter:, output_dir: options[:out], config_file: options[:config])
81
+ runtime.run(tests)
82
+ io.close if io != STDOUT
@@ -0,0 +1,105 @@
1
+ module PrD
2
+ module Formatters
3
+ class Formatter
4
+ SUPPORTED_MODES = %i[verbose synthetic].freeze
5
+
6
+ def initialize(io: $stdout, serializers: {}, mode: :verbose)
7
+ @io = io
8
+ @serializers = serializers
9
+ @level = 0
10
+ @mode = normalize_mode(mode)
11
+ @current_test_title = nil
12
+ end
13
+
14
+ def title(message)
15
+ raise NotImplementedError, "#{self.class} must implement #title"
16
+ end
17
+
18
+ def context(message)
19
+ raise NotImplementedError, "#{self.class} must implement #context"
20
+ end
21
+
22
+ def success_result(message)
23
+ raise NotImplementedError, "#{self.class} must implement #success_result"
24
+ end
25
+
26
+ def failure_result(message)
27
+ raise NotImplementedError, "#{self.class} must implement #failure_result"
28
+ end
29
+
30
+ def it(description = nil, &block)
31
+ raise NotImplementedError, "#{self.class} must implement #it"
32
+ end
33
+
34
+ def end_it(description = nil, &block)
35
+ raise NotImplementedError, "#{self.class} must implement #end_it"
36
+ end
37
+
38
+ def justification(justification)
39
+ raise NotImplementedError, "#{self.class} must implement #justification"
40
+ end
41
+
42
+ def subject(subject)
43
+ raise NotImplementedError, "#{self.class} must implement #subject"
44
+ end
45
+
46
+ def pending(description = nil)
47
+ raise NotImplementedError, "#{self.class} must implement #pending"
48
+ end
49
+
50
+ def expect(expectation)
51
+ raise NotImplementedError, "#{self.class} must implement #expect"
52
+ end
53
+
54
+ def to
55
+ raise NotImplementedError, "#{self.class} must implement #to"
56
+ end
57
+
58
+ def not_to
59
+ raise NotImplementedError, "#{self.class} must implement #not_to"
60
+ end
61
+ def matcher(matcher, sources: nil)
62
+ raise NotImplementedError, "#{self.class} must implement #matcher"
63
+ end
64
+
65
+ def result(passed_count, failed_count)
66
+ raise NotImplementedError, "#{self.class} must implement #result"
67
+ end
68
+
69
+ def increment_level
70
+ @level += 1
71
+ end
72
+
73
+ def decrement_level
74
+ @level -= 1
75
+ end
76
+
77
+ def flush
78
+ @io.flush
79
+ end
80
+
81
+ private
82
+
83
+ def synthetic?
84
+ @mode == :synthetic
85
+ end
86
+
87
+ def normalize_mode(mode)
88
+ normalized = mode.to_sym
89
+ return normalized if SUPPORTED_MODES.include?(normalized)
90
+
91
+ raise ArgumentError, "Unsupported formatter mode: #{mode}. Supported: #{SUPPORTED_MODES.join(', ')}"
92
+ end
93
+
94
+ def serialize(value)
95
+ serializer = @serializers[value.class]
96
+ return serializer.call(value) if serializer
97
+ return value.path if value.is_a?(File)
98
+ return value.map { |v| serialize(v) } if value.is_a?(Array)
99
+ return value.transform_values { |v| serialize(v) } if value.is_a?(Hash)
100
+
101
+ value
102
+ end
103
+ end
104
+ end
105
+ end