unmagic-browser 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 +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +21 -0
- data/README.md +372 -0
- data/lib/unmagic/browser/configuration.rb +55 -0
- data/lib/unmagic/browser/console/banner.rb +82 -0
- data/lib/unmagic/browser/console/graphics.rb +145 -0
- data/lib/unmagic/browser/console/helpers.rb +325 -0
- data/lib/unmagic/browser/console/prompt.rb +152 -0
- data/lib/unmagic/browser/console/renderer.rb +205 -0
- data/lib/unmagic/browser/console/terminal.rb +108 -0
- data/lib/unmagic/browser/console/values.rb +78 -0
- data/lib/unmagic/browser/console.rb +161 -0
- data/lib/unmagic/browser/driver/base.rb +146 -0
- data/lib/unmagic/browser/driver/cloudflare/transport.rb +67 -0
- data/lib/unmagic/browser/driver/cloudflare.rb +214 -0
- data/lib/unmagic/browser/driver/connection.rb +75 -0
- data/lib/unmagic/browser/driver/local.rb +87 -0
- data/lib/unmagic/browser/driver/remote.rb +45 -0
- data/lib/unmagic/browser/driver.rb +75 -0
- data/lib/unmagic/browser/element.rb +257 -0
- data/lib/unmagic/browser/emulation.rb +248 -0
- data/lib/unmagic/browser/error.rb +44 -0
- data/lib/unmagic/browser/failures.rb +76 -0
- data/lib/unmagic/browser/locator.rb +256 -0
- data/lib/unmagic/browser/markdown.rb +54 -0
- data/lib/unmagic/browser/session.rb +588 -0
- data/lib/unmagic/browser/version.rb +8 -0
- data/lib/unmagic/browser.rb +214 -0
- metadata +105 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Browser
|
|
7
|
+
# A live browser page you drive step by step.
|
|
8
|
+
#
|
|
9
|
+
# The verbs read like Capybara's, and they auto-wait: `click_button "Sign
|
|
10
|
+
# in"` keeps looking until a button by that name is on the page and
|
|
11
|
+
# actionable, so an explicit {#wait_for} is rare. Questions — `has_text?`
|
|
12
|
+
# and friends — answer immediately instead, because "no" is an answer a
|
|
13
|
+
# caller usually wants now rather than in ten seconds.
|
|
14
|
+
#
|
|
15
|
+
# A session is a plain object. Hold it and it stays live between calls,
|
|
16
|
+
# which is what lets an agent open a page in one turn and act on it in the
|
|
17
|
+
# next — and what makes {#close} your job.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# browser.open("https://store.example.com") do |session|
|
|
21
|
+
# session.fill_in "Search", with: "espresso"
|
|
22
|
+
# session.click_button "Go"
|
|
23
|
+
# session.all(".product").map { |element| element.text }
|
|
24
|
+
# end
|
|
25
|
+
class Session
|
|
26
|
+
# Symbols that read better than Chrome's own key names.
|
|
27
|
+
KEYS = {
|
|
28
|
+
enter: "Enter",
|
|
29
|
+
return: "Enter",
|
|
30
|
+
tab: "Tab",
|
|
31
|
+
escape: "Escape",
|
|
32
|
+
esc: "Escape",
|
|
33
|
+
space: "Space",
|
|
34
|
+
backspace: "Backspace",
|
|
35
|
+
delete: "Delete",
|
|
36
|
+
up: "ArrowUp",
|
|
37
|
+
down: "ArrowDown",
|
|
38
|
+
left: "ArrowLeft",
|
|
39
|
+
right: "ArrowRight",
|
|
40
|
+
home: "Home",
|
|
41
|
+
end: "End",
|
|
42
|
+
page_up: "PageUp",
|
|
43
|
+
page_down: "PageDown",
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# How often a waiting locator re-checks the page, in milliseconds. Slower
|
|
47
|
+
# than Chrome's animation frame on purpose: the matcher walks the DOM, and
|
|
48
|
+
# running that sixty times a second buys nothing a caller would notice.
|
|
49
|
+
POLL_INTERVAL_MS = 100
|
|
50
|
+
|
|
51
|
+
# `innerText` is what a person would read — it respects `display: none`
|
|
52
|
+
# and collapses the way the page renders — where `textContent` would hand
|
|
53
|
+
# back hidden markup as if it were on screen.
|
|
54
|
+
PAGE_TEXT_SCRIPT = <<~JAVASCRIPT
|
|
55
|
+
() => {
|
|
56
|
+
const body = document.body;
|
|
57
|
+
if (!body) return "";
|
|
58
|
+
return body.innerText === undefined ? body.textContent : body.innerText;
|
|
59
|
+
}
|
|
60
|
+
JAVASCRIPT
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# Translate a key symbol into the name Chrome knows.
|
|
64
|
+
#
|
|
65
|
+
# @param key [Symbol, String] e.g. `:enter` or `"ArrowDown"`
|
|
66
|
+
# @return [String]
|
|
67
|
+
def key_name(key)
|
|
68
|
+
KEYS[key.to_s.downcase.to_sym] || key.to_s
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Unmagic::Browser] the browser this session belongs to
|
|
73
|
+
attr_reader :browser
|
|
74
|
+
|
|
75
|
+
# @return [Emulation] what's currently being emulated
|
|
76
|
+
attr_reader :emulation
|
|
77
|
+
|
|
78
|
+
# @param browser [Unmagic::Browser] the browser that opened this session
|
|
79
|
+
# @param connection [Driver::Connection] the browser this session owns and will close
|
|
80
|
+
# @param page [Puppeteer::Page] the live page
|
|
81
|
+
# @param emulation [Emulation] what to emulate from the start
|
|
82
|
+
# @param timeout [Numeric] seconds a locator waits before giving up
|
|
83
|
+
# @param navigation_timeout [Numeric] seconds a navigation is given
|
|
84
|
+
def initialize(browser:, connection:, page:, emulation: nil, timeout: nil, navigation_timeout: nil)
|
|
85
|
+
@browser = browser
|
|
86
|
+
@connection = connection
|
|
87
|
+
@page = page
|
|
88
|
+
@emulation = Emulation.build(emulation)
|
|
89
|
+
@timeout = timeout || Browser.configuration.timeout
|
|
90
|
+
@navigation_timeout = navigation_timeout || Browser.configuration.navigation_timeout
|
|
91
|
+
@monitor = Monitor.new
|
|
92
|
+
@scopes = []
|
|
93
|
+
@closed = false
|
|
94
|
+
|
|
95
|
+
guarded("setting up emulation") { @emulation.apply(@page) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The live Puppeteer page, for everything this API doesn't wrap — network
|
|
99
|
+
# interception, tracing, the rest of the surface. Wrap multi-step native
|
|
100
|
+
# work in {#lock} when it has to be atomic against other callers.
|
|
101
|
+
#
|
|
102
|
+
# @return [Puppeteer::Page]
|
|
103
|
+
def driver
|
|
104
|
+
@page
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------- navigate
|
|
108
|
+
|
|
109
|
+
# Go to a URL and wait for it to settle.
|
|
110
|
+
#
|
|
111
|
+
# @param url [String]
|
|
112
|
+
# @return [self]
|
|
113
|
+
def visit(url)
|
|
114
|
+
navigating("visiting #{url}") { @page.goto(url, wait_until: Driver::Base::WAIT_UNTIL, timeout: navigation_ms) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [self]
|
|
118
|
+
def reload
|
|
119
|
+
navigating("reloading") { @page.reload(wait_until: Driver::Base::WAIT_UNTIL, timeout: navigation_ms) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [self]
|
|
123
|
+
def go_back
|
|
124
|
+
navigating("going back") { @page.go_back(wait_until: Driver::Base::WAIT_UNTIL, timeout: navigation_ms) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [self]
|
|
128
|
+
def go_forward
|
|
129
|
+
navigating("going forward") { @page.go_forward(wait_until: Driver::Base::WAIT_UNTIL, timeout: navigation_ms) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [String] the URL currently shown
|
|
133
|
+
def current_url
|
|
134
|
+
guarded("reading the URL") { @page.url }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @return [String] the page's title
|
|
138
|
+
def title
|
|
139
|
+
guarded("reading the title") { @page.title }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------- act
|
|
143
|
+
|
|
144
|
+
# Type into a field, replacing whatever's in it.
|
|
145
|
+
#
|
|
146
|
+
# @param query [String, nil] the field's label, placeholder, name or id
|
|
147
|
+
# @param with [String] the text to type
|
|
148
|
+
# @param options [Hash] see {Locator.build}
|
|
149
|
+
# @return [self]
|
|
150
|
+
# @raise [Error::NotFound] when no such field turns up in time
|
|
151
|
+
def fill_in(query = nil, with:, **options)
|
|
152
|
+
find(query, purpose: :field, **options).fill_in(with)
|
|
153
|
+
self
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @param query [String, nil] the button's text, value or name
|
|
157
|
+
# @param options [Hash] see {Locator.build}
|
|
158
|
+
# @return [self]
|
|
159
|
+
# @raise [Error::NotFound] when no such button turns up in time
|
|
160
|
+
def click_button(query = nil, **options)
|
|
161
|
+
find(query, purpose: :button, **options).click
|
|
162
|
+
self
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @param query [String, nil] the link's text
|
|
166
|
+
# @param options [Hash] see {Locator.build}
|
|
167
|
+
# @return [self]
|
|
168
|
+
# @raise [Error::NotFound] when no such link turns up in time
|
|
169
|
+
def click_link(query = nil, **options)
|
|
170
|
+
find(query, purpose: :link, **options).click
|
|
171
|
+
self
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Click whatever matches — a button, a link, a div, anything.
|
|
175
|
+
#
|
|
176
|
+
# @param query [String, nil] see {Locator.build}
|
|
177
|
+
# @param options [Hash] see {Locator.build}
|
|
178
|
+
# @return [self]
|
|
179
|
+
# @raise [Error::NotFound] when nothing matches in time
|
|
180
|
+
def click(query = nil, **options)
|
|
181
|
+
find(query, **options).click
|
|
182
|
+
self
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Pick an option in a select, by the label a reader sees.
|
|
186
|
+
#
|
|
187
|
+
# @param option [String] the option's visible label, or its value
|
|
188
|
+
# @param from [String, nil] the select's label or name
|
|
189
|
+
# @param options [Hash] see {Locator.build}
|
|
190
|
+
# @return [self]
|
|
191
|
+
# @raise [Error::NotFound] when the select or the option isn't there
|
|
192
|
+
def select(option, from: nil, **options)
|
|
193
|
+
# With nothing naming the select, there's only one sensible reading:
|
|
194
|
+
# the page has one, and this is it.
|
|
195
|
+
options = { css: "select" } if from.nil? && options.empty?
|
|
196
|
+
find(from, purpose: :select, **options).select_option(option)
|
|
197
|
+
self
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @param query [String, nil] the checkbox's label or name
|
|
201
|
+
# @param options [Hash] see {Locator.build}
|
|
202
|
+
# @return [self]
|
|
203
|
+
def check(query = nil, **options)
|
|
204
|
+
find(query, purpose: :checkbox, **options).check
|
|
205
|
+
self
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# @param query [String, nil] the checkbox's label or name
|
|
209
|
+
# @param options [Hash] see {Locator.build}
|
|
210
|
+
# @return [self]
|
|
211
|
+
def uncheck(query = nil, **options)
|
|
212
|
+
find(query, purpose: :checkbox, **options).uncheck
|
|
213
|
+
self
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @param query [String, nil] the radio button's label or name
|
|
217
|
+
# @param options [Hash] see {Locator.build}
|
|
218
|
+
# @return [self]
|
|
219
|
+
def choose(query = nil, **options)
|
|
220
|
+
find(query, purpose: :radio, **options).click
|
|
221
|
+
self
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @param query [String, nil] the file field's label or name
|
|
225
|
+
# @param paths [Array<String>] the files to attach
|
|
226
|
+
# @param options [Hash] see {Locator.build}
|
|
227
|
+
# @return [self]
|
|
228
|
+
def attach_file(query = nil, *paths, **options)
|
|
229
|
+
find(query, purpose: :file_field, **options).attach(*paths)
|
|
230
|
+
self
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Press a key. With no locator it goes to whatever has focus.
|
|
234
|
+
#
|
|
235
|
+
# @param key [Symbol, String] e.g. `:enter`, `"ArrowDown"`
|
|
236
|
+
# @param on [String, Element, nil] press it with this element focused instead
|
|
237
|
+
# @param options [Hash] see {Locator.build}
|
|
238
|
+
# @return [self]
|
|
239
|
+
def press(key, on: nil, **options)
|
|
240
|
+
if on
|
|
241
|
+
element(on, **options).press(key)
|
|
242
|
+
else
|
|
243
|
+
guarded("pressing #{key}") { @page.keyboard.press(self.class.key_name(key)) }
|
|
244
|
+
end
|
|
245
|
+
self
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @param query [String, nil] see {Locator.build}
|
|
249
|
+
# @param options [Hash] see {Locator.build}
|
|
250
|
+
# @return [self]
|
|
251
|
+
def hover(query = nil, **options)
|
|
252
|
+
find(query, **options).hover
|
|
253
|
+
self
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------ read
|
|
257
|
+
|
|
258
|
+
# @return [String] the visible text of the page, or of the current scope
|
|
259
|
+
def text
|
|
260
|
+
raw = if scope
|
|
261
|
+
scope.evaluate("(root) => (root.innerText === undefined ? root.textContent : root.innerText)")
|
|
262
|
+
else
|
|
263
|
+
guarded("reading the page") { @page.evaluate(PAGE_TEXT_SCRIPT) }
|
|
264
|
+
end
|
|
265
|
+
raw.to_s.gsub(/[ \t]+/, " ").strip
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @return [String] the page's HTML, as it stands after JavaScript has run
|
|
269
|
+
def html
|
|
270
|
+
return scope.html if scope
|
|
271
|
+
|
|
272
|
+
guarded("reading the page") { @page.content }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @return [String] the page as Markdown
|
|
276
|
+
def markdown
|
|
277
|
+
Markdown.from_html(html)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @param query [String, nil] the field's label, placeholder, name or id
|
|
281
|
+
# @param options [Hash] see {Locator.build}
|
|
282
|
+
# @return [String, nil] what's currently in the field
|
|
283
|
+
def value(query = nil, **options)
|
|
284
|
+
find(query, purpose: :field, **options).value
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# @param full_page [Boolean] capture the whole scrollable page
|
|
288
|
+
# @param selector [String, Element, nil] capture just this element
|
|
289
|
+
# @return [String] PNG bytes
|
|
290
|
+
def screenshot(full_page: false, selector: nil)
|
|
291
|
+
target = selector || scope
|
|
292
|
+
return element(target).screenshot if target
|
|
293
|
+
|
|
294
|
+
guarded("taking a screenshot") { @page.screenshot(type: "png", full_page: full_page) }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# @return [String] PDF bytes
|
|
298
|
+
def pdf
|
|
299
|
+
guarded("printing to PDF") { @page.pdf }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Run JavaScript in the page.
|
|
303
|
+
#
|
|
304
|
+
# @param script [String] an expression, or a function like `"() => document.title"`
|
|
305
|
+
# @param args [Array] arguments passed to the function form
|
|
306
|
+
# @return [Object] whatever it returns, as a Ruby value
|
|
307
|
+
def evaluate(script, *args)
|
|
308
|
+
guarded("evaluating JavaScript") { @page.evaluate(script, *args) }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# -------------------------------------------------------------- wait & ask
|
|
312
|
+
|
|
313
|
+
# Block until something shows up.
|
|
314
|
+
#
|
|
315
|
+
# @param text [String, nil] wait for this text to appear
|
|
316
|
+
# @param selector [String, nil] wait for an element matching this locator
|
|
317
|
+
# @param timeout [Numeric, nil] seconds to wait, defaulting to the session's
|
|
318
|
+
# @param options [Hash] see {Locator.build}
|
|
319
|
+
# @return [self]
|
|
320
|
+
# @raise [Error::TimedOut] when it never shows up
|
|
321
|
+
def wait_for(text: nil, selector: nil, timeout: nil, **options)
|
|
322
|
+
raise ArgumentError, "wait_for needs text: or selector:" if text.nil? && selector.nil?
|
|
323
|
+
|
|
324
|
+
wait_for_text(text, timeout) if text
|
|
325
|
+
find(selector, wait: timeout || @timeout, **options) if selector
|
|
326
|
+
self
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# @param string [String] the text to look for
|
|
330
|
+
# @return [Boolean] whether it's on the page right now
|
|
331
|
+
def has_text?(string)
|
|
332
|
+
text.downcase.include?(string.to_s.downcase.gsub(/\s+/, " ").strip)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# @param query [String, nil] see {Locator.build}
|
|
336
|
+
# @param options [Hash] see {Locator.build}
|
|
337
|
+
# @return [Boolean] whether such a button is on the page right now
|
|
338
|
+
def has_button?(query = nil, **options)
|
|
339
|
+
exists?(query, purpose: :button, **options)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# @param query [String, nil] see {Locator.build}
|
|
343
|
+
# @param options [Hash] see {Locator.build}
|
|
344
|
+
# @return [Boolean] whether such a link is on the page right now
|
|
345
|
+
def has_link?(query = nil, **options)
|
|
346
|
+
exists?(query, purpose: :link, **options)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# @param query [String, nil] see {Locator.build}
|
|
350
|
+
# @param options [Hash] see {Locator.build}
|
|
351
|
+
# @return [Boolean] whether such a field is on the page right now
|
|
352
|
+
def has_field?(query = nil, **options)
|
|
353
|
+
exists?(query, purpose: :field, **options)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @param css [String] a CSS selector
|
|
357
|
+
# @return [Boolean] whether anything matches it right now
|
|
358
|
+
def has_selector?(css)
|
|
359
|
+
exists?(css: css)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# @param query [String, nil] see {Locator.build}
|
|
363
|
+
# @param options [Hash] see {Locator.build}
|
|
364
|
+
# @return [Boolean] whether anything matches right now
|
|
365
|
+
def has_element?(query = nil, **options)
|
|
366
|
+
exists?(query, **options)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# ----------------------------------------------------------------- scope
|
|
370
|
+
|
|
371
|
+
# Find one element, waiting for it to turn up.
|
|
372
|
+
#
|
|
373
|
+
# @param query [String, nil] see {Locator.build}
|
|
374
|
+
# @param scope [Element, nil] search inside this element instead of the page
|
|
375
|
+
# @param wait [Numeric, nil] seconds to keep looking, defaulting to the session's
|
|
376
|
+
# @param purpose [Symbol] see {Locator::PURPOSES}
|
|
377
|
+
# @param options [Hash] see {Locator.build}
|
|
378
|
+
# @return [Element]
|
|
379
|
+
# @raise [Error::NotFound] when nothing matches in time
|
|
380
|
+
def find(query = nil, scope: nil, wait: nil, purpose: :any, **options)
|
|
381
|
+
locator = Locator.build(query, purpose: purpose, **options)
|
|
382
|
+
found = resolve(locator, scope: scope || self.scope, wait: wait || @timeout)
|
|
383
|
+
raise Error::NotFound, "Couldn't find a #{locator} on #{current_url}." unless found
|
|
384
|
+
|
|
385
|
+
found
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Find every matching element, right now — no waiting, because "none" is a
|
|
389
|
+
# legitimate answer.
|
|
390
|
+
#
|
|
391
|
+
# @param query [String, nil] see {Locator.build}
|
|
392
|
+
# @param scope [Element, nil] search inside this element instead of the page
|
|
393
|
+
# @param purpose [Symbol] see {Locator::PURPOSES}
|
|
394
|
+
# @param options [Hash] see {Locator.build}
|
|
395
|
+
# @return [Array<Element>]
|
|
396
|
+
def all(query = nil, scope: nil, purpose: :any, **options)
|
|
397
|
+
locator = Locator.build(query, purpose: purpose, **options)
|
|
398
|
+
resolve_all(locator, scope: scope || self.scope)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Narrow every locator inside the block to one part of the page.
|
|
402
|
+
#
|
|
403
|
+
# @param query [String, Element, nil] see {Locator.build}
|
|
404
|
+
# @param options [Hash] see {Locator.build}
|
|
405
|
+
# @yield with the scope in force; the session is passed for convenience
|
|
406
|
+
# @yieldparam session [Session]
|
|
407
|
+
# @return [Object] whatever the block returns
|
|
408
|
+
def within(query = nil, **options)
|
|
409
|
+
inner = element(query, **options)
|
|
410
|
+
@scopes.push(inner)
|
|
411
|
+
begin
|
|
412
|
+
yield(self)
|
|
413
|
+
ensure
|
|
414
|
+
@scopes.pop
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# @return [Element, nil] the element locators are currently scoped to
|
|
419
|
+
def scope
|
|
420
|
+
@scopes.last
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# ------------------------------------------------------------- lifecycle
|
|
424
|
+
|
|
425
|
+
# Change what's emulated from here on, or — with a block — just for the
|
|
426
|
+
# block, reverting afterwards.
|
|
427
|
+
#
|
|
428
|
+
# @param config [Emulation, Hash, nil] a whole configuration
|
|
429
|
+
# @param knobs [Hash] individual settings, layered over what's already emulated
|
|
430
|
+
# @yield with the change in force
|
|
431
|
+
# @return [Emulation, Object] the emulation now in force, or the block's value
|
|
432
|
+
def emulate(config = nil, **knobs)
|
|
433
|
+
previous = @emulation
|
|
434
|
+
apply_emulation(previous.merge(config || knobs))
|
|
435
|
+
return @emulation unless block_given?
|
|
436
|
+
|
|
437
|
+
begin
|
|
438
|
+
yield
|
|
439
|
+
ensure
|
|
440
|
+
apply_emulation(previous)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Hold the session for a whole sequence, so another caller can't slip a
|
|
445
|
+
# keystroke between two of your steps. Individual operations are already
|
|
446
|
+
# serialised; this is for when a *sequence* has to be atomic.
|
|
447
|
+
#
|
|
448
|
+
# @yield the sequence to run uninterrupted
|
|
449
|
+
# @return [Object] whatever the block returns
|
|
450
|
+
def lock(&block)
|
|
451
|
+
@monitor.synchronize(&block)
|
|
452
|
+
end
|
|
453
|
+
alias_method :synchronize, :lock
|
|
454
|
+
|
|
455
|
+
# Close the page and give the browser back. On Cloudflare that releases
|
|
456
|
+
# one of your concurrent slots, so a kept session that's finished with
|
|
457
|
+
# should always be closed.
|
|
458
|
+
#
|
|
459
|
+
# @return [void]
|
|
460
|
+
def close
|
|
461
|
+
@monitor.synchronize do
|
|
462
|
+
return if @closed
|
|
463
|
+
|
|
464
|
+
@closed = true
|
|
465
|
+
begin
|
|
466
|
+
@page.close
|
|
467
|
+
rescue StandardError
|
|
468
|
+
nil # The page may have gone with its browser; the connection close is what matters.
|
|
469
|
+
end
|
|
470
|
+
@connection.close
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# @return [Boolean] whether this session has been closed
|
|
475
|
+
def closed?
|
|
476
|
+
@closed
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# @return [String] the session and where it is, or that it's closed
|
|
480
|
+
def inspect
|
|
481
|
+
state = @closed ? "closed" : current_url
|
|
482
|
+
"#<#{self.class.name} #{state}>"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
private
|
|
486
|
+
|
|
487
|
+
def navigation_ms
|
|
488
|
+
(@navigation_timeout * 1000).to_i
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Coerce whatever names an element — an Element, a locator string, keyword
|
|
492
|
+
# options — into an Element.
|
|
493
|
+
def element(query, **options)
|
|
494
|
+
query.is_a?(Element) ? query : find(query, **options)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def resolve(locator, scope:, wait:)
|
|
498
|
+
handle = guarded("looking for a #{locator}") do
|
|
499
|
+
if wait.to_f.positive?
|
|
500
|
+
wait_for_handle(locator, scope, wait)
|
|
501
|
+
else
|
|
502
|
+
@page.evaluate_handle(Locator::SCRIPT, locator.to_spec, scope&.handle)
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
found = handle&.as_element
|
|
506
|
+
handle.dispose if handle && found.nil?
|
|
507
|
+
found && Element.new(self, found)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def wait_for_handle(locator, scope, wait)
|
|
511
|
+
@page.wait_for_function(
|
|
512
|
+
Locator::SCRIPT,
|
|
513
|
+
args: [locator.to_spec, scope&.handle],
|
|
514
|
+
polling: POLL_INTERVAL_MS,
|
|
515
|
+
timeout: (wait * 1000).to_i,
|
|
516
|
+
)
|
|
517
|
+
rescue Puppeteer::TimeoutError
|
|
518
|
+
nil # Nothing matched in time. The caller decides whether that's an error.
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def resolve_all(locator, scope:)
|
|
522
|
+
guarded("looking for every #{locator}") do
|
|
523
|
+
handle = @page.evaluate_handle(Locator::SCRIPT, locator.to_spec(all: true), scope&.handle)
|
|
524
|
+
begin
|
|
525
|
+
handle.properties
|
|
526
|
+
.sort_by { |index, _| index.to_i }
|
|
527
|
+
.filter_map { |_, item| item.as_element }
|
|
528
|
+
.map { |found| Element.new(self, found) }
|
|
529
|
+
ensure
|
|
530
|
+
handle.dispose
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def exists?(query = nil, purpose: :any, **options)
|
|
536
|
+
find(query, purpose: purpose, wait: 0, **options)
|
|
537
|
+
true
|
|
538
|
+
rescue Error::NotFound
|
|
539
|
+
false
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
WAIT_FOR_TEXT_SCRIPT = <<~JAVASCRIPT
|
|
543
|
+
(needle, root) => {
|
|
544
|
+
const node = root || document.body;
|
|
545
|
+
if (!node) return false;
|
|
546
|
+
const text = (node.innerText === undefined ? node.textContent : node.innerText) || "";
|
|
547
|
+
return text.replace(/\\s+/g, " ").toLowerCase().indexOf(needle) !== -1;
|
|
548
|
+
}
|
|
549
|
+
JAVASCRIPT
|
|
550
|
+
private_constant :WAIT_FOR_TEXT_SCRIPT
|
|
551
|
+
|
|
552
|
+
def wait_for_text(string, timeout)
|
|
553
|
+
seconds = timeout || @timeout
|
|
554
|
+
guarded("waiting for #{string.inspect}") do
|
|
555
|
+
@page.wait_for_function(
|
|
556
|
+
WAIT_FOR_TEXT_SCRIPT,
|
|
557
|
+
args: [string.to_s.gsub(/\s+/, " ").strip.downcase, scope&.handle],
|
|
558
|
+
polling: POLL_INTERVAL_MS,
|
|
559
|
+
timeout: (seconds * 1000).to_i,
|
|
560
|
+
)
|
|
561
|
+
rescue Puppeteer::TimeoutError
|
|
562
|
+
raise Error::TimedOut, "Waited #{seconds}s for #{string.inspect} to appear on #{@page.url}."
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def apply_emulation(wanted)
|
|
567
|
+
guarded("emulating #{wanted.to_h.compact.keys.join(", ")}") do
|
|
568
|
+
wanted.apply(@page)
|
|
569
|
+
@emulation = wanted
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def navigating(doing)
|
|
574
|
+
guarded(doing) { yield }
|
|
575
|
+
self
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Everything that touches the page goes through here: it holds the session
|
|
579
|
+
# lock so two callers can't interleave, refuses to drive a closed session,
|
|
580
|
+
# and turns whatever puppeteer-ruby throws into one of our typed errors.
|
|
581
|
+
def guarded(doing)
|
|
582
|
+
raise Error::SessionClosed, "This session is closed." if @closed
|
|
583
|
+
|
|
584
|
+
@monitor.synchronize { Failures.wrap(doing) { yield } }
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
end
|