probatio_diabolica 0.3.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 +72 -3
- data/lib/pr_d/code.rb +2 -1
- data/lib/pr_d/formatters/html_formatter.rb +38 -6
- data/lib/pr_d/formatters/pdf_formatter.rb +46 -9
- data/lib/pr_d/helpers/chrome_helper.rb +208 -13
- data/lib/pr_d/version.rb +1 -1
- metadata +1 -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
|
@@ -161,6 +161,42 @@ end
|
|
|
161
161
|
- `expect { |subject| ... }.to matcher`
|
|
162
162
|
- `expect.to matcher` (uses `subject`)
|
|
163
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
|
+
|
|
164
200
|
### Matchers
|
|
165
201
|
|
|
166
202
|
- `eq(expected)` equality with `==`
|
|
@@ -174,13 +210,30 @@ end
|
|
|
174
210
|
|
|
175
211
|
`PrD::Runtime` exposes helpers to test content loaded in Chrome:
|
|
176
212
|
|
|
213
|
+
- `page(at:, warmup_time:)` opens a page and returns a `BrowserSession`
|
|
177
214
|
- `screen(at:, width:, height:, warmup_time:)` captures a PNG and returns a `File`
|
|
178
|
-
- `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`)
|
|
179
216
|
- `network(at:, warmup_time:)` returns Ferrum network traffic
|
|
180
217
|
- `network_urls(at:, warmup_time:)` returns traffic URLs
|
|
181
218
|
- `pdf(at:, warmup_time:)` generates a PDF and returns a `PDF::Reader`
|
|
182
219
|
- `html(at:, warmup_time:)` returns HTML (`browser.body`)
|
|
183
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
|
+
|
|
184
237
|
Prerequisites:
|
|
185
238
|
|
|
186
239
|
- Chrome/Chromium must be installed.
|
|
@@ -197,6 +250,22 @@ it 'checks dynamic content loaded in browser' do
|
|
|
197
250
|
end
|
|
198
251
|
```
|
|
199
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
|
+
|
|
200
269
|
### Source code helper (Prism)
|
|
201
270
|
|
|
202
271
|
`source_code(...)` uses the `prism` gem to parse Ruby source and extract class/method code.
|
|
@@ -256,12 +325,12 @@ When you define a `subject`, each formatter tries to render it in the most usefu
|
|
|
256
325
|
- for files, prints a textual representation (for example path, file preview for `.txt`)
|
|
257
326
|
- `HtmlFormatter`:
|
|
258
327
|
- renders text values directly
|
|
259
|
-
- for `PrD::Code`, renders syntax-highlighted code blocks (Rouge)
|
|
328
|
+
- for `PrD::Code`, renders syntax-highlighted code blocks (Rouge) inside collapsible sections
|
|
260
329
|
- for image files (`.png`, `.jpg`, `.jpeg`), embeds the image in the report
|
|
261
330
|
- for PDF subjects (`File` `.pdf` or `PDF::Reader`), embeds the PDF with a `data:application/pdf;base64,...` URI
|
|
262
331
|
- `PdfFormatter`:
|
|
263
332
|
- renders text values as report lines
|
|
264
|
-
- for `PrD::Code`, renders language + code block (
|
|
333
|
+
- for `PrD::Code`, renders language + syntax-highlighted code block (Rouge -> Prawn colors)
|
|
265
334
|
- for image files (`.png`, `.jpg`, `.jpeg`), inserts the image directly in the PDF report
|
|
266
335
|
- `JsonFormatter`:
|
|
267
336
|
- keeps a structured representation for machine processing
|
data/lib/pr_d/code.rb
CHANGED
|
@@ -357,19 +357,49 @@ module PrD
|
|
|
357
357
|
.code-language {
|
|
358
358
|
color: var(--muted);
|
|
359
359
|
font-size: 0.85rem;
|
|
360
|
-
margin-bottom: 0.35rem;
|
|
361
360
|
text-transform: uppercase;
|
|
362
361
|
letter-spacing: 0.05em;
|
|
363
362
|
}
|
|
364
363
|
|
|
365
364
|
.code-block {
|
|
366
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';
|
|
367
397
|
}
|
|
368
398
|
|
|
369
399
|
.highlight {
|
|
370
400
|
margin: 0;
|
|
371
|
-
border: 1px solid var(--line);
|
|
372
|
-
border-radius: 10px;
|
|
401
|
+
border-top: 1px solid var(--line);
|
|
402
|
+
border-radius: 0 0 10px 10px;
|
|
373
403
|
overflow-x: auto;
|
|
374
404
|
}
|
|
375
405
|
|
|
@@ -515,10 +545,12 @@ module PrD
|
|
|
515
545
|
language = normalize_text(code.language)
|
|
516
546
|
highlighted = highlight_code(source, language)
|
|
517
547
|
<<~HTML
|
|
518
|
-
<
|
|
519
|
-
<
|
|
548
|
+
<details class="code-block">
|
|
549
|
+
<summary class="code-toggle">
|
|
550
|
+
<span class="code-language">#{escape(language)}</span>
|
|
551
|
+
</summary>
|
|
520
552
|
#{highlighted}
|
|
521
|
-
</
|
|
553
|
+
</details>
|
|
522
554
|
HTML
|
|
523
555
|
end
|
|
524
556
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require 'prawn'
|
|
2
|
+
require 'rouge'
|
|
2
3
|
|
|
3
4
|
module PrD
|
|
4
5
|
module Formatters
|
|
@@ -76,7 +77,7 @@ module PrD
|
|
|
76
77
|
add_event(:subject, message: 'Subject', level: @level)
|
|
77
78
|
if code_object?(subject)
|
|
78
79
|
add_event(:code_header, message: "Language: #{subject.language}", level: @level + 1)
|
|
79
|
-
add_event(:code_block, message: subject.source, level: @level + 1)
|
|
80
|
+
add_event(:code_block, message: subject.source, level: @level + 1, language: subject.language)
|
|
80
81
|
elsif image_file?(subject)
|
|
81
82
|
add_event(:detail, message: serialize(subject).to_s, level: @level + 1)
|
|
82
83
|
add_event(:subject_image, message: subject.path, level: @level + 1)
|
|
@@ -98,7 +99,7 @@ module PrD
|
|
|
98
99
|
return if synthetic?
|
|
99
100
|
if code_object?(expectation)
|
|
100
101
|
add_event(:code_header, message: "Expect (#{expectation.language})", level: @level + 1)
|
|
101
|
-
add_event(:code_block, message: expectation.source, level: @level + 1)
|
|
102
|
+
add_event(:code_block, message: expectation.source, level: @level + 1, language: expectation.language)
|
|
102
103
|
else
|
|
103
104
|
add_event(:detail, message: "Expect: #{serialize(expectation)}", level: @level + 1)
|
|
104
105
|
end
|
|
@@ -153,13 +154,13 @@ module PrD
|
|
|
153
154
|
|
|
154
155
|
private
|
|
155
156
|
|
|
156
|
-
def add_event(type, message:, level:, anchor_id: nil)
|
|
157
|
+
def add_event(type, message:, level:, anchor_id: nil, **extra)
|
|
157
158
|
@events << {
|
|
158
159
|
type:,
|
|
159
160
|
message: safe_pdf_text(message.to_s),
|
|
160
161
|
level: [level, 0].max,
|
|
161
162
|
anchor_id:
|
|
162
|
-
}
|
|
163
|
+
}.merge(extra)
|
|
163
164
|
end
|
|
164
165
|
|
|
165
166
|
def add_index_entry(type:, label:, level:, anchor_id:)
|
|
@@ -177,6 +178,43 @@ module PrD
|
|
|
177
178
|
.encode('UTF-8')
|
|
178
179
|
end
|
|
179
180
|
|
|
181
|
+
def rouge_lexer_for(source, language)
|
|
182
|
+
Rouge::Lexer.find_fancy(language.to_s, source.to_s) || Rouge::Lexers::PlainText
|
|
183
|
+
rescue StandardError
|
|
184
|
+
Rouge::Lexers::PlainText
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def rouge_token_color(token)
|
|
188
|
+
qualname = token.qualname.to_s
|
|
189
|
+
|
|
190
|
+
return '6B7280' if qualname.start_with?('Comment')
|
|
191
|
+
return 'C2410C' if qualname.start_with?('Keyword', 'Operator')
|
|
192
|
+
return '1D4ED8' if qualname.start_with?('Name.Function', 'Name.Class', 'Name.Builtin')
|
|
193
|
+
return '047857' if qualname.start_with?('Literal.String')
|
|
194
|
+
return '7C3AED' if qualname.start_with?('Literal.Number')
|
|
195
|
+
|
|
196
|
+
COLORS[:text]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def highlighted_code_fragments(source, language)
|
|
200
|
+
lexer = rouge_lexer_for(source, language)
|
|
201
|
+
fragments = lexer.lex(source.to_s).filter_map do |token, value|
|
|
202
|
+
next if value.nil? || value.empty?
|
|
203
|
+
|
|
204
|
+
{
|
|
205
|
+
text: safe_pdf_text(value),
|
|
206
|
+
color: rouge_token_color(token),
|
|
207
|
+
font: 'Courier'
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
return fragments unless fragments.empty?
|
|
212
|
+
|
|
213
|
+
[{ text: safe_pdf_text(source.to_s), color: COLORS[:text], font: 'Courier' }]
|
|
214
|
+
rescue StandardError
|
|
215
|
+
[{ text: safe_pdf_text(source.to_s), color: COLORS[:text], font: 'Courier' }]
|
|
216
|
+
end
|
|
217
|
+
|
|
180
218
|
def render_header(document)
|
|
181
219
|
document.fill_color COLORS[:title]
|
|
182
220
|
document.text 'Probatio Diabolica', size: 20, style: :bold
|
|
@@ -238,7 +276,7 @@ module PrD
|
|
|
238
276
|
when :code_header
|
|
239
277
|
styled_line(document, event[:message], level: event[:level], size: 10, style: :bold, color: COLORS[:muted])
|
|
240
278
|
when :code_block
|
|
241
|
-
render_code_block(document, event[:message], level: event[:level])
|
|
279
|
+
render_code_block(document, event[:message], level: event[:level], language: event[:language])
|
|
242
280
|
when :detail, :subject, :justification
|
|
243
281
|
styled_line(document, event[:message], level: event[:level], size: 10, color: COLORS[:text])
|
|
244
282
|
when :subject_image
|
|
@@ -296,12 +334,11 @@ module PrD
|
|
|
296
334
|
document.move_down 2
|
|
297
335
|
end
|
|
298
336
|
|
|
299
|
-
def render_code_block(document, text, level:)
|
|
337
|
+
def render_code_block(document, text, level:, language: nil)
|
|
300
338
|
document.indent(level * 14) do
|
|
301
339
|
document.fill_color COLORS[:muted]
|
|
302
340
|
document.text '--- Code Block ---', size: 9, style: :italic
|
|
303
|
-
document.
|
|
304
|
-
document.font('Courier') { document.text text, size: 9 }
|
|
341
|
+
document.formatted_text(highlighted_code_fragments(text, language), size: 9)
|
|
305
342
|
document.fill_color COLORS[:muted]
|
|
306
343
|
document.text '--- End Block ---', size: 9, style: :italic
|
|
307
344
|
document.fill_color COLORS[:text]
|
|
@@ -312,7 +349,7 @@ module PrD
|
|
|
312
349
|
def add_matcher_value_event(label, value)
|
|
313
350
|
if code_object?(value)
|
|
314
351
|
add_event(:matcher, message: "#{label} (#{value.language})", level: @level + 2)
|
|
315
|
-
add_event(:code_block, message: value.source, level: @level + 2)
|
|
352
|
+
add_event(:code_block, message: value.source, level: @level + 2, language: value.language)
|
|
316
353
|
else
|
|
317
354
|
add_event(:matcher, message: "#{label}: #{serialize(value)}", level: @level + 2)
|
|
318
355
|
end
|
|
@@ -5,10 +5,199 @@ require 'pdf-reader'
|
|
|
5
5
|
module PrD
|
|
6
6
|
module Helpers
|
|
7
7
|
module ChromeHelper
|
|
8
|
+
class BrowserSession
|
|
9
|
+
SHADOW_QUERY_FUNCTION = <<~JS.freeze
|
|
10
|
+
function(shadowSelectors, targetSelector, within) {
|
|
11
|
+
let scope = within || document;
|
|
12
|
+
|
|
13
|
+
for (const scopeSelector of shadowSelectors) {
|
|
14
|
+
if (!scope || typeof scope.querySelector !== "function") return null;
|
|
15
|
+
const node = scope.querySelector(scopeSelector);
|
|
16
|
+
if (!node) return null;
|
|
17
|
+
scope = node.shadowRoot || node;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!scope || typeof scope.querySelector !== "function") return null;
|
|
21
|
+
return scope.querySelector(targetSelector);
|
|
22
|
+
}
|
|
23
|
+
JS
|
|
24
|
+
|
|
25
|
+
def initialize(browser, poll_interval: 0.05)
|
|
26
|
+
@browser = browser
|
|
27
|
+
@poll_interval = poll_interval
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :browser
|
|
31
|
+
|
|
32
|
+
def navigate(to:, warmup_time: 0)
|
|
33
|
+
@browser.go_to(to)
|
|
34
|
+
sleep(warmup_time.to_f) if warmup_time.to_f.positive?
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def wait(seconds)
|
|
39
|
+
sleep(seconds.to_f) if seconds.to_f.positive?
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def wait_for(css: nil, xpath: nil, within: nil, shadow: nil, timeout: 2)
|
|
44
|
+
find(css:, xpath:, within:, shadow:, wait: timeout)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def exists?(css: nil, xpath: nil, within: nil, shadow: nil, wait: 0)
|
|
48
|
+
!find(css:, xpath:, within:, shadow:, wait:, raise_on_missing: false).nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def find(css: nil, xpath: nil, within: nil, shadow: nil, wait: 2, raise_on_missing: true)
|
|
52
|
+
selector = normalize_selector(css:, xpath:)
|
|
53
|
+
node = find_with_wait(selector:, within:, shadow:, wait:)
|
|
54
|
+
return node if node
|
|
55
|
+
return nil unless raise_on_missing
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "Selector not found: #{selector[:label]}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def click(css: nil, xpath: nil, within: nil, shadow: nil, wait: 2, mode: :left, keys: [], offset: {}, delay: 0)
|
|
61
|
+
node = find(css:, xpath:, within:, shadow:, wait:)
|
|
62
|
+
node.scroll_into_view
|
|
63
|
+
node.click(mode:, keys:, offset:, delay:)
|
|
64
|
+
node
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fill(css: nil, xpath: nil, within: nil, shadow: nil, with:, wait: 2, clear: true, blur: false, dispatch_events: true)
|
|
68
|
+
node = find(css:, xpath:, within:, shadow:, wait:)
|
|
69
|
+
node.focus
|
|
70
|
+
node.evaluate("this.value = ''") if clear
|
|
71
|
+
node.type(with.to_s)
|
|
72
|
+
dispatch_input_events(node) if dispatch_events
|
|
73
|
+
node.blur if blur
|
|
74
|
+
node
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def select_option(css:, within: nil, shadow: nil, value: nil, values: nil, by: :value, wait: 2)
|
|
78
|
+
node = find(css:, within:, shadow:, wait:)
|
|
79
|
+
option_values = Array(values || value).flatten.compact
|
|
80
|
+
raise ArgumentError, 'select_option requires `value` or `values`.' if option_values.empty?
|
|
81
|
+
|
|
82
|
+
node.select(*option_values, by:)
|
|
83
|
+
node
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def set_files(css:, within: nil, shadow: nil, path: nil, paths: nil, wait: 2, dispatch_events: true)
|
|
87
|
+
node = find(css:, within:, shadow:, wait:)
|
|
88
|
+
file_paths = normalize_file_paths(path:, paths:)
|
|
89
|
+
node.select_file(file_paths)
|
|
90
|
+
dispatch_input_events(node) if dispatch_events
|
|
91
|
+
node
|
|
92
|
+
end
|
|
93
|
+
alias upload_files set_files
|
|
94
|
+
|
|
95
|
+
def method_missing(method_name, *args, **kwargs, &block)
|
|
96
|
+
return @browser.public_send(method_name, *args, **kwargs, &block) if @browser.respond_to?(method_name)
|
|
97
|
+
|
|
98
|
+
super
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
102
|
+
@browser.respond_to?(method_name, include_private) || super
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def normalize_selector(css:, xpath:)
|
|
108
|
+
css = normalize_optional_selector(css)
|
|
109
|
+
xpath = normalize_optional_selector(xpath)
|
|
110
|
+
|
|
111
|
+
if css.nil? && xpath.nil?
|
|
112
|
+
raise ArgumentError, 'Provide a selector with `css:` or `xpath:`.'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if css && xpath
|
|
116
|
+
raise ArgumentError, 'Use either `css:` or `xpath:`, not both.'
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if xpath && !xpath.nil?
|
|
120
|
+
{ type: :xpath, value: xpath, label: "xpath=#{xpath}" }
|
|
121
|
+
else
|
|
122
|
+
{ type: :css, value: css, label: "css=#{css}" }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def normalize_optional_selector(value)
|
|
127
|
+
return nil if value.nil?
|
|
128
|
+
|
|
129
|
+
selector = value.to_s.strip
|
|
130
|
+
return nil if selector.empty?
|
|
131
|
+
|
|
132
|
+
selector
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def find_with_wait(selector:, within:, shadow:, wait:)
|
|
136
|
+
timeout = wait.to_f
|
|
137
|
+
timeout = 0 if timeout.negative?
|
|
138
|
+
deadline = monotonic_now + timeout
|
|
139
|
+
|
|
140
|
+
loop do
|
|
141
|
+
node = resolve_node(selector:, within:, shadow:)
|
|
142
|
+
return node if node
|
|
143
|
+
|
|
144
|
+
break if monotonic_now >= deadline
|
|
145
|
+
|
|
146
|
+
sleep(@poll_interval)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def resolve_node(selector:, within:, shadow:)
|
|
153
|
+
case selector[:type]
|
|
154
|
+
when :xpath
|
|
155
|
+
raise ArgumentError, '`shadow:` can only be used with `css:` selectors.' if shadow && !Array(shadow).empty?
|
|
156
|
+
|
|
157
|
+
within ? within.at_xpath(selector[:value]) : @browser.at_xpath(selector[:value])
|
|
158
|
+
when :css
|
|
159
|
+
if shadow && !Array(shadow).empty?
|
|
160
|
+
@browser.evaluate_func(SHADOW_QUERY_FUNCTION, Array(shadow), selector[:value], within)
|
|
161
|
+
elsif within
|
|
162
|
+
within.at_css(selector[:value])
|
|
163
|
+
else
|
|
164
|
+
@browser.at_css(selector[:value])
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def dispatch_input_events(node)
|
|
170
|
+
node.evaluate("this.dispatchEvent(new Event('input', { bubbles: true }))")
|
|
171
|
+
node.evaluate("this.dispatchEvent(new Event('change', { bubbles: true }))")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def normalize_file_paths(path:, paths:)
|
|
175
|
+
raw_paths = Array(paths || path).flatten.compact.map { |value| value.to_s.strip }.reject(&:empty?)
|
|
176
|
+
raise ArgumentError, 'set_files requires `path` or `paths`.' if raw_paths.empty?
|
|
177
|
+
|
|
178
|
+
expanded_paths = raw_paths.map { |file_path| File.expand_path(file_path, Dir.pwd) }
|
|
179
|
+
missing = expanded_paths.reject { |file_path| File.exist?(file_path) }
|
|
180
|
+
raise ArgumentError, "File not found: #{missing.join(', ')}" unless missing.empty?
|
|
181
|
+
|
|
182
|
+
expanded_paths
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def monotonic_now
|
|
186
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def page(at:, warmup_time: 2)
|
|
191
|
+
session = prepare_browser_session(at:, warmup_time:)
|
|
192
|
+
yield session if block_given?
|
|
193
|
+
session
|
|
194
|
+
end
|
|
195
|
+
|
|
8
196
|
def screen(at:, width: 1280, height: 800, warmup_time: 2)
|
|
9
|
-
|
|
197
|
+
session = prepare_browser_session(at:, warmup_time:)
|
|
198
|
+
browser = session.browser
|
|
10
199
|
browser.set_viewport(width:, height:)
|
|
11
|
-
yield
|
|
200
|
+
yield session if block_given?
|
|
12
201
|
|
|
13
202
|
screenshot_id = Digest::SHA256.hexdigest("#{at}-#{width}-#{height}-#{warmup_time}")
|
|
14
203
|
file_name = File.join(chrome_annex_dir, "screenshot-#{screenshot_id}.png")
|
|
@@ -17,19 +206,19 @@ module PrD
|
|
|
17
206
|
end
|
|
18
207
|
|
|
19
208
|
def text(at:, css: 'body', warmup_time: 2)
|
|
20
|
-
|
|
21
|
-
yield
|
|
209
|
+
session = prepare_browser_session(at:, warmup_time:)
|
|
210
|
+
yield session if block_given?
|
|
22
211
|
|
|
23
|
-
text_node =
|
|
212
|
+
text_node = session.find(css:, wait: 0, raise_on_missing: false)
|
|
24
213
|
raise ArgumentError, "CSS selector not found: #{css}" unless text_node
|
|
25
214
|
|
|
26
215
|
PrD::Code.new(source: text_node.text, language: 'text')
|
|
27
216
|
end
|
|
28
217
|
|
|
29
218
|
def network(at:, warmup_time: 2)
|
|
30
|
-
|
|
31
|
-
yield
|
|
32
|
-
browser.network.traffic
|
|
219
|
+
session = prepare_browser_session(at:, warmup_time:)
|
|
220
|
+
yield session if block_given?
|
|
221
|
+
session.browser.network.traffic
|
|
33
222
|
end
|
|
34
223
|
|
|
35
224
|
def network_urls(at:, warmup_time: 2, &block)
|
|
@@ -37,8 +226,9 @@ module PrD
|
|
|
37
226
|
end
|
|
38
227
|
|
|
39
228
|
def pdf(at:, warmup_time: 2)
|
|
40
|
-
|
|
41
|
-
|
|
229
|
+
session = prepare_browser_session(at:, warmup_time:)
|
|
230
|
+
browser = session.browser
|
|
231
|
+
yield session if block_given?
|
|
42
232
|
|
|
43
233
|
pdf_id = Digest::SHA256.hexdigest(at)
|
|
44
234
|
file_name = File.join(chrome_annex_dir, "pdf-#{pdf_id}.pdf")
|
|
@@ -47,10 +237,10 @@ module PrD
|
|
|
47
237
|
end
|
|
48
238
|
|
|
49
239
|
def html(at:, warmup_time: 2)
|
|
50
|
-
|
|
51
|
-
yield
|
|
240
|
+
session = prepare_browser_session(at:, warmup_time:)
|
|
241
|
+
yield session if block_given?
|
|
52
242
|
|
|
53
|
-
PrD::Code.new(source: browser.body, language: 'html')
|
|
243
|
+
PrD::Code.new(source: session.browser.body, language: 'html')
|
|
54
244
|
end
|
|
55
245
|
|
|
56
246
|
def close_chrome_browser
|
|
@@ -68,6 +258,11 @@ module PrD
|
|
|
68
258
|
@browser ||= Ferrum::Browser.new
|
|
69
259
|
end
|
|
70
260
|
|
|
261
|
+
def prepare_browser_session(at:, warmup_time:)
|
|
262
|
+
browser = prepare_browser(at:, warmup_time:)
|
|
263
|
+
BrowserSession.new(browser)
|
|
264
|
+
end
|
|
265
|
+
|
|
71
266
|
def prepare_browser(at:, warmup_time:)
|
|
72
267
|
browser = chrome_browser
|
|
73
268
|
browser.go_to(at)
|
data/lib/pr_d/version.rb
CHANGED