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.
@@ -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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Browser
5
+ # The gem's version.
6
+ VERSION = "0.1.0"
7
+ end
8
+ end