probatio_diabolica 0.3.0 → 0.4.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 +159 -9
- data/bin/prd +2 -2
- data/lib/pr_d/code.rb +2 -1
- data/lib/pr_d/formatters/formatter.rb +351 -2
- data/lib/pr_d/formatters/html_formatter.rb +313 -111
- data/lib/pr_d/formatters/json_formatter.rb +34 -7
- data/lib/pr_d/formatters/pdf_formatter.rb +172 -51
- data/lib/pr_d/formatters/simple_formatter.rb +118 -49
- data/lib/pr_d/helpers/chrome_helper.rb +208 -13
- data/lib/pr_d/matchers/empty_matcher.rb +15 -0
- data/lib/pr_d/matchers/gt_matcher.rb +11 -0
- data/lib/pr_d/matchers/gte_matcher.rb +11 -0
- data/lib/pr_d/matchers/llm_matcher.rb +44 -18
- data/lib/pr_d/matchers/lt_matcher.rb +11 -0
- data/lib/pr_d/matchers/lte_matcher.rb +11 -0
- data/lib/pr_d/matchers/matcher.rb +2 -0
- data/lib/pr_d/version.rb +1 -1
- data/lib/pr_d.rb +301 -32
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37a15e717233a9bbef0f7fcd533b7984081ea3e9aaee1e4effd1ecd10bd1a7f2
|
|
4
|
+
data.tar.gz: 026bfae9103c3a5f1747b8f0ef14a0ab859a46fda53340d7140645d71197c86e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 96c9fa6473dc6f5b94e9474fb5d4855c38c147c8872075945231c91fdf82cadd1280718da6010a76711efb80168cfbc281c4cfa5c4158cba37e3a9da88fbee6c
|
|
7
|
+
data.tar.gz: dc201873849d9e2e80bdff375c0e185cb1acfe8440b418170327ea8e416c29faaf9316f02f44913818ed6da8205e87aef12a0b106325be95d2e9e56bf582db06
|
data/README.md
CHANGED
|
@@ -12,9 +12,10 @@ This project is experimental and not production-ready.
|
|
|
12
12
|
|
|
13
13
|
`probatio_diabolica` runs `*_spec.rb` files through a custom runtime (`PrD::Runtime`) with an RSpec-like syntax:
|
|
14
14
|
|
|
15
|
-
- `describe`, `context`, `it`, `pending`, `let`, `subject`
|
|
15
|
+
- `describe`, `context`, `it`, `pending`, `let`, `subject`, `subject!`
|
|
16
|
+
- `before`, `after`
|
|
16
17
|
- `expect(...).to(...)` and `expect(...).not_to(...)`
|
|
17
|
-
- standard matchers (`eq`, `be`, `includes`, `have`, `all`)
|
|
18
|
+
- standard matchers (`eq`, `be`, `empty`, `gt`, `gte`, `lt`, `lte`, `includes`, `have`, `all`)
|
|
18
19
|
- LLM matcher `satisfy(...)` to validate natural-language conditions
|
|
19
20
|
|
|
20
21
|
Tests are evaluated with `instance_eval` (not through RSpec).
|
|
@@ -141,14 +142,23 @@ It is inspired by RSpec but with a custom runtime and additional features.
|
|
|
141
142
|
```ruby
|
|
142
143
|
describe 'My domain' do
|
|
143
144
|
context 'my context' do
|
|
145
|
+
before do
|
|
146
|
+
@sum = 0
|
|
147
|
+
end
|
|
148
|
+
|
|
144
149
|
let(:two) { 2 }
|
|
145
150
|
let(:three) { 3 }
|
|
146
151
|
subject { two + three }
|
|
147
152
|
|
|
148
153
|
it 'runs an assertion' do
|
|
154
|
+
@sum += subject
|
|
149
155
|
expect.to eq(5)
|
|
150
156
|
end
|
|
151
157
|
|
|
158
|
+
after do
|
|
159
|
+
expect(@sum).to(eq(5))
|
|
160
|
+
end
|
|
161
|
+
|
|
152
162
|
pending 'test to implement later'
|
|
153
163
|
end
|
|
154
164
|
end
|
|
@@ -159,28 +169,110 @@ end
|
|
|
159
169
|
- `expect(actual).to matcher`
|
|
160
170
|
- `expect(actual).not_to matcher`
|
|
161
171
|
- `expect { |subject| ... }.to matcher`
|
|
162
|
-
- `expect.to matcher` (uses `subject`)
|
|
172
|
+
- `expect.to matcher` (uses `subject` / `subject!`)
|
|
173
|
+
- In verbose formatter output, when `actual` or matcher `expected` come from a `let`, the expectation sentence uses the `let` name.
|
|
174
|
+
- A failed expectation reports a detailed message (with names/values) in `Justification`.
|
|
175
|
+
|
|
176
|
+
### Hooks (`before` / `after`)
|
|
177
|
+
|
|
178
|
+
- `before { ... }` runs before each `it` in the current context and nested contexts
|
|
179
|
+
- `after { ... }` runs after each `it` in the current context and nested contexts
|
|
180
|
+
- nested order:
|
|
181
|
+
- `before`: outer to inner
|
|
182
|
+
- `after`: inner to outer
|
|
183
|
+
|
|
184
|
+
### Subject (`subject` vs `subject!`)
|
|
185
|
+
|
|
186
|
+
- `subject { ... }`: lazy, evaluated on first access in each example, memoized for that example
|
|
187
|
+
- `subject! { ... }`: eager, evaluated before each example body (uses an implicit `before`)
|
|
188
|
+
|
|
189
|
+
### Spec best practices for `subject` (PRD reports)
|
|
190
|
+
|
|
191
|
+
When a test defines a `subject`, PRD can surface it more clearly in generated reports.
|
|
192
|
+
For CLI and integration specs, prefer:
|
|
193
|
+
|
|
194
|
+
- grouping with explicit `context`
|
|
195
|
+
- one `subject` per context for the main action
|
|
196
|
+
- assertions written with `expect.to(...)` when the assertion targets `subject`
|
|
197
|
+
|
|
198
|
+
Example:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
context 'when CLI receives an unknown formatter type' do
|
|
202
|
+
subject { Open3.capture3('bundle exec ruby bin/prd spec/self_hosted_spec.rb -t unknown') }
|
|
203
|
+
|
|
204
|
+
it 'fails fast on unknown formatter type in CLI' do
|
|
205
|
+
_stdout, stderr, status = subject
|
|
206
|
+
|
|
207
|
+
expect(status.success?).to(be(false))
|
|
208
|
+
expect(stderr).to(includes('Unsupported formatter type: unknown. Supported: simple, html, json, pdf'))
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
For simple value checks, this pattern keeps specs concise:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
context 'with strings' do
|
|
217
|
+
subject { 'probatio diabolica' }
|
|
218
|
+
|
|
219
|
+
it 'matches expected content' do
|
|
220
|
+
expect.to(includes('diabolica'))
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|
|
163
224
|
|
|
164
225
|
### Matchers
|
|
165
226
|
|
|
166
227
|
- `eq(expected)` equality with `==`
|
|
167
228
|
- `be(expected)` object identity (`equal?`)
|
|
229
|
+
- `empty` checks `empty?` on the actual value
|
|
230
|
+
- `gt(expected)` strictly greater than (`>`)
|
|
231
|
+
- `gte(expected)` greater than or equal (`>=`)
|
|
232
|
+
- `lt(expected)` strictly less than (`<`)
|
|
233
|
+
- `lte(expected)` less than or equal (`<=`)
|
|
168
234
|
- `includes(expected)` inclusion for `String`, `Array`, `File`, `PDF::Reader`
|
|
169
235
|
- `have(expected)` alias inclusion via `include?`
|
|
170
236
|
- `all(proc)` checks all elements against a block
|
|
171
|
-
- `satisfy(natural_language_condition)` LLM-based validation
|
|
237
|
+
- `satisfy(natural_language_condition)` LLM-based validation (supports text, single image `File`, or array of image files)
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
expect(new_image_size).to be gt(0)
|
|
243
|
+
```
|
|
172
244
|
|
|
173
245
|
### Browser helpers (Ferrum)
|
|
174
246
|
|
|
175
247
|
`PrD::Runtime` exposes helpers to test content loaded in Chrome:
|
|
176
248
|
|
|
249
|
+
- `page(at:, warmup_time:)` opens a page and returns a `BrowserSession`
|
|
177
250
|
- `screen(at:, width:, height:, warmup_time:)` captures a PNG and returns a `File`
|
|
178
|
-
- `text(at:, css:, warmup_time:)` extracts a CSS node
|
|
251
|
+
- `text(at:, css:, warmup_time:)` extracts a CSS node and returns `PrD::Code` (language: `text`)
|
|
179
252
|
- `network(at:, warmup_time:)` returns Ferrum network traffic
|
|
180
253
|
- `network_urls(at:, warmup_time:)` returns traffic URLs
|
|
181
254
|
- `pdf(at:, warmup_time:)` generates a PDF and returns a `PDF::Reader`
|
|
182
255
|
- `html(at:, warmup_time:)` returns HTML (`browser.body`)
|
|
183
256
|
|
|
257
|
+
Detailed dedicated documentation:
|
|
258
|
+
- `docs/chrome_helper.md` (full API contract, inputs/outputs, errors, and LLM-oriented usage patterns)
|
|
259
|
+
|
|
260
|
+
`BrowserSession` adds high-level page interactions:
|
|
261
|
+
|
|
262
|
+
- `find(css:/xpath:, wait:, shadow:)`
|
|
263
|
+
- `exists?(css:/xpath:, wait:, shadow:)`
|
|
264
|
+
- `click(css:/xpath:, wait:, shadow:)`
|
|
265
|
+
- `fill(css:/xpath:, with:, clear:, blur:, wait:, shadow:)`
|
|
266
|
+
- `select_option(css:, value:/values:, by:, wait:, shadow:)`
|
|
267
|
+
- `set_files(css:, path:/paths:, wait:, shadow:)` (alias `upload_files`)
|
|
268
|
+
- `navigate(to:, warmup_time:)`
|
|
269
|
+
|
|
270
|
+
About `shadow:`:
|
|
271
|
+
|
|
272
|
+
- `shadow:` is an ordered CSS path used to narrow the scope before the target selector.
|
|
273
|
+
- Each step can be a shadow host or a regular container.
|
|
274
|
+
- If a step has `shadowRoot`, search continues inside it; otherwise search continues inside the matched node.
|
|
275
|
+
|
|
184
276
|
Prerequisites:
|
|
185
277
|
|
|
186
278
|
- Chrome/Chromium must be installed.
|
|
@@ -197,6 +289,22 @@ it 'checks dynamic content loaded in browser' do
|
|
|
197
289
|
end
|
|
198
290
|
```
|
|
199
291
|
|
|
292
|
+
Form interaction and file upload example:
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
it 'uploads a file in a shadow-dom form' do
|
|
296
|
+
html(at: 'https://example.com/upload', warmup_time: 2) do |page|
|
|
297
|
+
page.click(css: 'button[data-open-upload]')
|
|
298
|
+
page.fill(css: 'input[name="title"]', with: 'Invoice')
|
|
299
|
+
page.set_files(
|
|
300
|
+
css: 'input[type="file"]',
|
|
301
|
+
shadow: ['vax-scanner', '[data-view="upload"]'],
|
|
302
|
+
path: 'examples/random_photo.png'
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
200
308
|
### Source code helper (Prism)
|
|
201
309
|
|
|
202
310
|
`source_code(...)` uses the `prism` gem to parse Ruby source and extract class/method code.
|
|
@@ -246,31 +354,73 @@ In CLI usage:
|
|
|
246
354
|
|
|
247
355
|
- selecting one formatter writes one output stream/file
|
|
248
356
|
- selecting multiple formatters runs tests once and writes one file per formatter
|
|
357
|
+
- expectation rendering is sentence-based across human-readable formatters (for example `Expect 321 to be equal to 321`) to reduce vertical noise
|
|
358
|
+
- HTML/PDF also emphasize keywords and actual/expected values for faster scanning
|
|
249
359
|
|
|
250
|
-
###
|
|
360
|
+
### Let/subject rendering policy (best effort)
|
|
251
361
|
|
|
252
|
-
When you define a `subject`, each formatter tries to render it in the most useful way for its medium:
|
|
362
|
+
When you define a `let` or `subject`, each formatter tries to render it in the most useful way for its medium:
|
|
253
363
|
|
|
254
364
|
- `SimpleFormatter`:
|
|
255
365
|
- renders readable text in terminal
|
|
366
|
+
- prints `Let(:name)` blocks to make fixture values explicit
|
|
367
|
+
- for `Hash`/`Array` subjects, prints one key/index per line with nested indentation
|
|
256
368
|
- for files, prints a textual representation (for example path, file preview for `.txt`)
|
|
369
|
+
- for `Ferrum::Node`, prints a readable summary (`Ferrum::Node <tag#id.class> text="..."`) instead of the Ruby object id
|
|
257
370
|
- `HtmlFormatter`:
|
|
371
|
+
- renders `let` values inside collapsed blocks (click to open)
|
|
258
372
|
- renders text values directly
|
|
259
|
-
- for `
|
|
373
|
+
- for `Hash`/`Array` subjects, renders nested key/value blocks for better readability
|
|
374
|
+
- for `PrD::Code`, renders syntax-highlighted code blocks (Rouge) inside collapsible sections
|
|
260
375
|
- for image files (`.png`, `.jpg`, `.jpeg`), embeds the image in the report
|
|
261
376
|
- for PDF subjects (`File` `.pdf` or `PDF::Reader`), embeds the PDF with a `data:application/pdf;base64,...` URI
|
|
377
|
+
- for `Ferrum::Node`, renders the same readable summary with HTML escaping
|
|
262
378
|
- `PdfFormatter`:
|
|
263
379
|
- renders text values as report lines
|
|
264
|
-
- for `
|
|
380
|
+
- for `Hash`/`Array` subjects, renders nested key/value lines
|
|
381
|
+
- for `PrD::Code`, renders language + syntax-highlighted code block (Rouge -> Prawn colors)
|
|
265
382
|
- for image files (`.png`, `.jpg`, `.jpeg`), inserts the image directly in the PDF report
|
|
383
|
+
- for `Ferrum::Node`, renders the same readable summary in the generated PDF text
|
|
266
384
|
- `JsonFormatter`:
|
|
267
385
|
- keeps a structured representation for machine processing
|
|
386
|
+
- includes `name` in `let` events when available
|
|
387
|
+
- preserves nested `Hash`/`Array` structure in event payloads
|
|
268
388
|
- for `PrD::Code`, emits a structured payload (`type: "code"`, `language`, `source`)
|
|
269
389
|
- `File` values (images, PDFs, text files, etc.) are embedded as base64 payloads
|
|
270
390
|
- `PDF::Reader` values are also embedded as base64 (`application/pdf`)
|
|
391
|
+
- for `Ferrum::Node`, emits a structured payload (`type: "ferrum_node"`, `selector`, `text`, `html_preview`, `summary`)
|
|
271
392
|
|
|
272
393
|
The goal is to preserve readability and report size while surfacing the richest representation each formatter can reasonably support.
|
|
273
394
|
|
|
395
|
+
Detailed dedicated documentation:
|
|
396
|
+
- `docs/let_subject_rendering.md` (rendering pipeline, adapter injection, formatter behavior, compatibility notes)
|
|
397
|
+
|
|
398
|
+
### Inject custom display adapters
|
|
399
|
+
|
|
400
|
+
You can inject custom display logic for domain objects that are not natively handled:
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
formatter = PrD::Formatters::HtmlFormatter.new(
|
|
404
|
+
io: $stdout,
|
|
405
|
+
serializers: {},
|
|
406
|
+
display_adapters: {
|
|
407
|
+
MyDomainObject => lambda do |value|
|
|
408
|
+
{
|
|
409
|
+
title: value.name,
|
|
410
|
+
preview: PrD::Code.new(source: value.to_json, language: 'json')
|
|
411
|
+
}
|
|
412
|
+
end
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Rules:
|
|
418
|
+
|
|
419
|
+
- adapter key (`MyDomainObject` above) can be a class/module, a symbol (duck-typing via `respond_to?`), or a predicate proc
|
|
420
|
+
- adapter value must be callable
|
|
421
|
+
- adapter output can reuse native display types (`String`, `Hash`, `Array`, `PrD::Code`, `File`, `PDF::Reader`, etc.)
|
|
422
|
+
- adapters are applied before default formatter heuristics, so native rendering still applies to the transformed value
|
|
423
|
+
|
|
274
424
|
## Useful references in this repository
|
|
275
425
|
|
|
276
426
|
- Basic example: `examples/basics_spec.rb`
|
data/bin/prd
CHANGED
|
@@ -24,12 +24,12 @@ class CompositeFormatter
|
|
|
24
24
|
@formatters = formatters
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def method_missing(name, *args, &block)
|
|
27
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
28
28
|
handled = false
|
|
29
29
|
@formatters.each do |formatter|
|
|
30
30
|
next unless formatter.respond_to?(name)
|
|
31
31
|
|
|
32
|
-
formatter.public_send(name, *args, &block)
|
|
32
|
+
formatter.public_send(name, *args, **kwargs, &block)
|
|
33
33
|
handled = true
|
|
34
34
|
end
|
|
35
35
|
|
data/lib/pr_d/code.rb
CHANGED
|
@@ -2,13 +2,16 @@ module PrD
|
|
|
2
2
|
module Formatters
|
|
3
3
|
class Formatter
|
|
4
4
|
SUPPORTED_MODES = %i[verbose synthetic].freeze
|
|
5
|
+
MISSING_VALUE = Object.new
|
|
6
|
+
NO_EXPECTED_VALUE = Object.new
|
|
5
7
|
|
|
6
|
-
def initialize(io: $stdout, serializers: {}, mode: :verbose)
|
|
8
|
+
def initialize(io: $stdout, serializers: {}, mode: :verbose, display_adapters: {})
|
|
7
9
|
@io = io
|
|
8
10
|
@serializers = serializers
|
|
9
11
|
@level = 0
|
|
10
12
|
@mode = normalize_mode(mode)
|
|
11
13
|
@current_test_title = nil
|
|
14
|
+
@display_adapters = normalize_display_adapters(display_adapters)
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
def title(message)
|
|
@@ -43,11 +46,27 @@ module PrD
|
|
|
43
46
|
raise NotImplementedError, "#{self.class} must implement #subject"
|
|
44
47
|
end
|
|
45
48
|
|
|
49
|
+
def subject_display_strategy
|
|
50
|
+
:on_evaluation
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def eager_subject_display_strategy
|
|
54
|
+
subject_display_strategy
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def register_display_adapter(matcher, callable = nil, &block)
|
|
58
|
+
adapter = callable || block
|
|
59
|
+
raise ArgumentError, 'Display adapter must be callable.' unless adapter.respond_to?(:call)
|
|
60
|
+
|
|
61
|
+
@display_adapters << [matcher, adapter]
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
46
65
|
def pending(description = nil)
|
|
47
66
|
raise NotImplementedError, "#{self.class} must implement #pending"
|
|
48
67
|
end
|
|
49
68
|
|
|
50
|
-
def expect(expectation)
|
|
69
|
+
def expect(expectation, label: nil)
|
|
51
70
|
raise NotImplementedError, "#{self.class} must implement #expect"
|
|
52
71
|
end
|
|
53
72
|
|
|
@@ -92,8 +111,12 @@ module PrD
|
|
|
92
111
|
end
|
|
93
112
|
|
|
94
113
|
def serialize(value)
|
|
114
|
+
value = apply_display_adapter(value)
|
|
115
|
+
return 'nil' if value.nil?
|
|
116
|
+
|
|
95
117
|
serializer = @serializers[value.class]
|
|
96
118
|
return serializer.call(value) if serializer
|
|
119
|
+
return ferrum_node_summary(value) if ferrum_node?(value)
|
|
97
120
|
return value.path if value.is_a?(File)
|
|
98
121
|
return value.map { |v| serialize(v) } if value.is_a?(Array)
|
|
99
122
|
return value.transform_values { |v| serialize(v) } if value.is_a?(Hash)
|
|
@@ -104,6 +127,332 @@ module PrD
|
|
|
104
127
|
def code_object?(value)
|
|
105
128
|
defined?(PrD::Code) && value.is_a?(PrD::Code)
|
|
106
129
|
end
|
|
130
|
+
|
|
131
|
+
def ferrum_node?(value)
|
|
132
|
+
value.respond_to?(:class) && value.class.respond_to?(:name) && value.class.name == 'Ferrum::Node'
|
|
133
|
+
rescue StandardError
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ferrum_node_payload(node)
|
|
138
|
+
payload = ferrum_node_payload_from_js(node) || {}
|
|
139
|
+
payload[:tag] = payload[:tag].to_s.downcase.strip unless blank_text?(payload[:tag])
|
|
140
|
+
payload[:id] = payload[:id].to_s.strip unless blank_text?(payload[:id])
|
|
141
|
+
payload[:classes] = normalize_classes(payload[:classes])
|
|
142
|
+
payload[:text] = normalize_preview_text(payload[:text], max_length: 160)
|
|
143
|
+
payload[:html] = normalize_preview_text(payload[:html], max_length: 220)
|
|
144
|
+
payload[:description] = normalize_preview_text(payload[:description], max_length: 160)
|
|
145
|
+
payload
|
|
146
|
+
rescue StandardError
|
|
147
|
+
{}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def ferrum_node_summary(node)
|
|
151
|
+
payload = ferrum_node_payload(node)
|
|
152
|
+
selector = ferrum_node_selector(payload)
|
|
153
|
+
summary = +'Ferrum::Node'
|
|
154
|
+
summary << " <#{selector}>" unless selector.nil?
|
|
155
|
+
|
|
156
|
+
if blank_text?(payload[:text]) && !blank_text?(payload[:description])
|
|
157
|
+
summary << " #{payload[:description]}"
|
|
158
|
+
return summary
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
summary << %( text="#{payload[:text]}") unless blank_text?(payload[:text])
|
|
162
|
+
summary
|
|
163
|
+
rescue StandardError
|
|
164
|
+
'Ferrum::Node'
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def ferrum_node_selector(payload)
|
|
168
|
+
return nil unless payload.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
selector = +''
|
|
171
|
+
selector << payload[:tag].to_s unless blank_text?(payload[:tag])
|
|
172
|
+
selector << "##{payload[:id]}" unless blank_text?(payload[:id])
|
|
173
|
+
Array(payload[:classes]).each do |class_name|
|
|
174
|
+
class_name_text = class_name.to_s.strip
|
|
175
|
+
next if class_name_text.empty?
|
|
176
|
+
|
|
177
|
+
selector << ".#{class_name_text}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
return nil if selector.empty?
|
|
181
|
+
|
|
182
|
+
selector
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def ferrum_node_payload_from_js(node)
|
|
186
|
+
return nil unless node.respond_to?(:evaluate)
|
|
187
|
+
|
|
188
|
+
raw_payload = node.evaluate(<<~JS)
|
|
189
|
+
(() => {
|
|
190
|
+
const element = this;
|
|
191
|
+
if (!element) return null;
|
|
192
|
+
|
|
193
|
+
const rawClassName = element.className;
|
|
194
|
+
const className =
|
|
195
|
+
typeof rawClassName === "string" ? rawClassName :
|
|
196
|
+
(rawClassName && typeof rawClassName.baseVal === "string" ? rawClassName.baseVal : "");
|
|
197
|
+
|
|
198
|
+
const classes = className
|
|
199
|
+
.split(/\\s+/)
|
|
200
|
+
.map((token) => token.trim())
|
|
201
|
+
.filter((token) => token.length > 0);
|
|
202
|
+
|
|
203
|
+
const textValue = (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
|
|
204
|
+
const htmlValue = element.outerHTML || "";
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
tag: element.tagName ? element.tagName.toLowerCase() : null,
|
|
208
|
+
id: element.id || null,
|
|
209
|
+
classes,
|
|
210
|
+
text: textValue.length > 160 ? `${textValue.slice(0, 157)}...` : textValue,
|
|
211
|
+
html: htmlValue.length > 220 ? `${htmlValue.slice(0, 217)}...` : htmlValue
|
|
212
|
+
};
|
|
213
|
+
})()
|
|
214
|
+
JS
|
|
215
|
+
return nil unless raw_payload.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
{
|
|
218
|
+
tag: raw_payload['tag'] || raw_payload[:tag],
|
|
219
|
+
id: raw_payload['id'] || raw_payload[:id],
|
|
220
|
+
classes: raw_payload['classes'] || raw_payload[:classes],
|
|
221
|
+
text: raw_payload['text'] || raw_payload[:text],
|
|
222
|
+
html: raw_payload['html'] || raw_payload[:html]
|
|
223
|
+
}
|
|
224
|
+
rescue StandardError
|
|
225
|
+
{
|
|
226
|
+
tag: safe_node_call(node, :tag_name),
|
|
227
|
+
text: safe_node_call(node, :text),
|
|
228
|
+
description: safe_node_call(node, :description)
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def safe_node_call(node, method_name)
|
|
233
|
+
return nil unless node.respond_to?(method_name)
|
|
234
|
+
|
|
235
|
+
node.public_send(method_name)
|
|
236
|
+
rescue StandardError
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def normalize_classes(value)
|
|
241
|
+
Array(value)
|
|
242
|
+
.flat_map { |entry| entry.to_s.split(/\s+/) }
|
|
243
|
+
.map(&:strip)
|
|
244
|
+
.reject(&:empty?)
|
|
245
|
+
.uniq
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def normalize_preview_text(value, max_length:)
|
|
249
|
+
text = value.to_s.gsub(/\s+/, ' ').strip
|
|
250
|
+
return nil if text.empty?
|
|
251
|
+
return text if text.length <= max_length
|
|
252
|
+
|
|
253
|
+
"#{text[0, max_length - 3]}..."
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def blank_text?(value)
|
|
257
|
+
value.nil? || value.to_s.strip.empty?
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def named_value_arguments(name_or_value, value)
|
|
261
|
+
return [nil, name_or_value] if value.equal?(MISSING_VALUE)
|
|
262
|
+
|
|
263
|
+
[name_or_value, value]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def display_node(value, seen: {})
|
|
267
|
+
resolved = apply_display_adapter(value)
|
|
268
|
+
|
|
269
|
+
if code_object?(resolved)
|
|
270
|
+
return {
|
|
271
|
+
type: :code,
|
|
272
|
+
language: resolved.language.to_s,
|
|
273
|
+
source: resolved.source.to_s
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
image_path = display_image_path(resolved)
|
|
278
|
+
return { type: :image, path: image_path } unless image_path.nil?
|
|
279
|
+
|
|
280
|
+
pdf_path = display_pdf_path(resolved)
|
|
281
|
+
return { type: :pdf_file, path: pdf_path } unless pdf_path.nil?
|
|
282
|
+
|
|
283
|
+
if display_pdf_reader?(resolved)
|
|
284
|
+
return { type: :pdf_reader, reader: resolved }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
if resolved.is_a?(Hash)
|
|
288
|
+
return { type: :text, text: '[Circular reference]' } if circular_reference?(resolved, seen)
|
|
289
|
+
|
|
290
|
+
begin
|
|
291
|
+
entries = resolved.map do |key, entry_value|
|
|
292
|
+
{ label: serialize(key).to_s, value: display_node(entry_value, seen: seen) }
|
|
293
|
+
end
|
|
294
|
+
ensure
|
|
295
|
+
seen.delete(resolved.object_id)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
return { type: :map, entries: entries }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
if resolved.is_a?(Array)
|
|
302
|
+
return { type: :text, text: '[Circular reference]' } if circular_reference?(resolved, seen)
|
|
303
|
+
|
|
304
|
+
begin
|
|
305
|
+
items = resolved.map { |entry| display_node(entry, seen: seen) }
|
|
306
|
+
ensure
|
|
307
|
+
seen.delete(resolved.object_id)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
return { type: :list, items: items }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
{ type: :text, text: serialize(resolved).to_s }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def display_file_path(value)
|
|
317
|
+
return value.path if value.is_a?(File)
|
|
318
|
+
return nil unless value.respond_to?(:path)
|
|
319
|
+
|
|
320
|
+
path = value.path
|
|
321
|
+
path.is_a?(String) && !path.empty? ? path : nil
|
|
322
|
+
rescue StandardError
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def display_image_path(value)
|
|
327
|
+
path = display_file_path(value)
|
|
328
|
+
return nil if path.nil?
|
|
329
|
+
return nil unless path.match?(/\.(png|jpe?g)\z/i)
|
|
330
|
+
|
|
331
|
+
path
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def display_pdf_path(value)
|
|
335
|
+
path = display_file_path(value)
|
|
336
|
+
return nil if path.nil?
|
|
337
|
+
return nil unless path.match?(/\.pdf\z/i)
|
|
338
|
+
|
|
339
|
+
path
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def display_pdf_reader?(value)
|
|
343
|
+
defined?(PDF::Reader) && value.is_a?(PDF::Reader)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def apply_display_adapter(value)
|
|
347
|
+
adapter = find_display_adapter(value)
|
|
348
|
+
return value if adapter.nil?
|
|
349
|
+
|
|
350
|
+
if adapter.arity.abs >= 2
|
|
351
|
+
adapter.call(value, self)
|
|
352
|
+
else
|
|
353
|
+
adapter.call(value)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def find_display_adapter(value)
|
|
358
|
+
@display_adapters.each do |matcher, adapter|
|
|
359
|
+
return adapter if display_matcher_matches?(matcher, value)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
nil
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def display_matcher_matches?(matcher, value)
|
|
366
|
+
case matcher
|
|
367
|
+
when Symbol
|
|
368
|
+
value.respond_to?(matcher)
|
|
369
|
+
when Proc
|
|
370
|
+
matcher.call(value)
|
|
371
|
+
else
|
|
372
|
+
matcher === value
|
|
373
|
+
end
|
|
374
|
+
rescue StandardError
|
|
375
|
+
false
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def normalize_display_adapters(display_adapters)
|
|
379
|
+
return [] if display_adapters.nil?
|
|
380
|
+
|
|
381
|
+
entries =
|
|
382
|
+
if display_adapters.is_a?(Hash)
|
|
383
|
+
display_adapters.to_a
|
|
384
|
+
elsif display_adapters.is_a?(Array)
|
|
385
|
+
display_adapters
|
|
386
|
+
else
|
|
387
|
+
raise ArgumentError, '`display_adapters` must be a Hash or an Array of [matcher, adapter].'
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
entries.map do |entry|
|
|
391
|
+
matcher, adapter =
|
|
392
|
+
if entry.is_a?(Array) && entry.length == 2
|
|
393
|
+
entry
|
|
394
|
+
else
|
|
395
|
+
raise ArgumentError, '`display_adapters` entries must be [matcher, adapter].'
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
raise ArgumentError, 'Display adapter matcher is required.' if matcher.nil?
|
|
399
|
+
raise ArgumentError, 'Display adapter must be callable.' unless adapter.respond_to?(:call)
|
|
400
|
+
|
|
401
|
+
[matcher, adapter]
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def circular_reference?(value, seen)
|
|
406
|
+
object_id = value.object_id
|
|
407
|
+
return true if seen.key?(object_id)
|
|
408
|
+
|
|
409
|
+
seen[object_id] = true
|
|
410
|
+
false
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def matcher_sentence_parts(matcher, sources:)
|
|
414
|
+
case matcher
|
|
415
|
+
when Matchers::EqMatcher
|
|
416
|
+
['be equal to', matcher.expected]
|
|
417
|
+
when Matchers::BeMatcher
|
|
418
|
+
['be the same object as', matcher.expected]
|
|
419
|
+
when Matchers::EmptyMatcher
|
|
420
|
+
['be empty', NO_EXPECTED_VALUE]
|
|
421
|
+
when Matchers::GtMatcher
|
|
422
|
+
['be greater than', matcher.expected]
|
|
423
|
+
when Matchers::GteMatcher
|
|
424
|
+
['be greater than or equal to', matcher.expected]
|
|
425
|
+
when Matchers::IncludesMatcher
|
|
426
|
+
['include', matcher.expected]
|
|
427
|
+
when Matchers::HaveMatcher
|
|
428
|
+
['have', matcher.expected]
|
|
429
|
+
when Matchers::LtMatcher
|
|
430
|
+
['be less than', matcher.expected]
|
|
431
|
+
when Matchers::LlmMatcher
|
|
432
|
+
['satisfy condition', matcher.expected]
|
|
433
|
+
when Matchers::LteMatcher
|
|
434
|
+
['be less than or equal to', matcher.expected]
|
|
435
|
+
when Matchers::AllMatcher
|
|
436
|
+
if sources
|
|
437
|
+
code_line = matcher.expected.source_location.last.to_i
|
|
438
|
+
code = sources.lines[code_line - 1]
|
|
439
|
+
expected = code&.strip
|
|
440
|
+
if expected.nil? || expected.empty?
|
|
441
|
+
['all match the given condition', NO_EXPECTED_VALUE]
|
|
442
|
+
else
|
|
443
|
+
['all match condition', expected]
|
|
444
|
+
end
|
|
445
|
+
else
|
|
446
|
+
['all match the given condition', NO_EXPECTED_VALUE]
|
|
447
|
+
end
|
|
448
|
+
else
|
|
449
|
+
['match', matcher.class.to_s]
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def expectation_operator_text(operator)
|
|
454
|
+
operator == :not_to ? 'not to' : 'to'
|
|
455
|
+
end
|
|
107
456
|
end
|
|
108
457
|
end
|
|
109
458
|
end
|