Class: Unmagic::Browser::Session

Inherits:
Object
  • Object
show all
Defined in:
lib/unmagic/browser/session.rb

Overview

A live browser page you drive step by step.

The verbs read like Capybara's, and they auto-wait: click_button "Sign in" keeps looking until a button by that name is on the page and actionable, so an explicit #wait_for is rare. Questions — has_text? and friends — answer immediately instead, because "no" is an answer a caller usually wants now rather than in ten seconds.

A session is a plain object. Hold it and it stays live between calls, which is what lets an agent open a page in one turn and act on it in the next — and what makes #close your job.

Examples:

browser.open("https://store.example.com") do |session|
  session.fill_in "Search", with: "espresso"
  session.click_button "Go"
  session.all(".product").map { |element| element.text }
end

Constant Summary collapse

KEYS =

Symbols that read better than Chrome's own key names.

{
  enter: "Enter",
  return: "Enter",
  tab: "Tab",
  escape: "Escape",
  esc: "Escape",
  space: "Space",
  backspace: "Backspace",
  delete: "Delete",
  up: "ArrowUp",
  down: "ArrowDown",
  left: "ArrowLeft",
  right: "ArrowRight",
  home: "Home",
  end: "End",
  page_up: "PageUp",
  page_down: "PageDown",
}.freeze
POLL_INTERVAL_MS =

How often a waiting locator re-checks the page, in milliseconds. Slower than Chrome's animation frame on purpose: the matcher walks the DOM, and running that sixty times a second buys nothing a caller would notice.

100
PAGE_TEXT_SCRIPT =

innerText is what a person would read — it respects display: none and collapses the way the page renders — where textContent would hand back hidden markup as if it were on screen.

<<~JAVASCRIPT
  () => {
    const body = document.body;
    if (!body) return "";
    return body.innerText === undefined ? body.textContent : body.innerText;
  }
JAVASCRIPT

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(browser:, connection:, page:, emulation: nil, timeout: nil, navigation_timeout: nil) ⇒ Session

Returns a new instance of Session.

Parameters:

  • browser (Unmagic::Browser)

    the browser that opened this session

  • connection (Driver::Connection)

    the browser this session owns and will close

  • page (Puppeteer::Page)

    the live page

  • emulation (Emulation) (defaults to: nil)

    what to emulate from the start

  • timeout (Numeric) (defaults to: nil)

    seconds a locator waits before giving up

  • navigation_timeout (Numeric) (defaults to: nil)

    seconds a navigation is given



84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/unmagic/browser/session.rb', line 84

def initialize(browser:, connection:, page:, emulation: nil, timeout: nil, navigation_timeout: nil)
  @browser = browser
  @connection = connection
  @page = page
  @emulation = Emulation.build(emulation)
  @timeout = timeout || Browser.configuration.timeout
  @navigation_timeout = navigation_timeout || Browser.configuration.navigation_timeout
  @monitor = Monitor.new
  @scopes = []
  @closed = false

  guarded("setting up emulation") { @emulation.apply(@page) }
end

Instance Attribute Details

#browserUnmagic::Browser (readonly)

Returns the browser this session belongs to.

Returns:



73
74
75
# File 'lib/unmagic/browser/session.rb', line 73

def browser
  @browser
end

#emulationEmulation (readonly)

Returns what's currently being emulated.

Returns:

  • (Emulation)

    what's currently being emulated



76
77
78
# File 'lib/unmagic/browser/session.rb', line 76

def emulation
  @emulation
end

Class Method Details

.key_name(key) ⇒ String

Translate a key symbol into the name Chrome knows.

Parameters:

  • key (Symbol, String)

    e.g. :enter or "ArrowDown"

Returns:

  • (String)


67
68
69
# File 'lib/unmagic/browser/session.rb', line 67

def key_name(key)
  KEYS[key.to_s.downcase.to_sym] || key.to_s
end

Instance Method Details

#all(query = nil, scope: nil, purpose: :any, **options) ⇒ Array<Element>

Find every matching element, right now — no waiting, because "none" is a legitimate answer.

Parameters:

  • query (String, nil) (defaults to: nil)
  • scope (Element, nil) (defaults to: nil)

    search inside this element instead of the page

  • purpose (Symbol) (defaults to: :any)
  • options (Hash)

Returns:



396
397
398
399
# File 'lib/unmagic/browser/session.rb', line 396

def all(query = nil, scope: nil, purpose: :any, **options)
  locator = Locator.build(query, purpose: purpose, **options)
  resolve_all(locator, scope: scope || self.scope)
end

#attach_file(query = nil, *paths, **options) ⇒ self

Parameters:

  • query (String, nil) (defaults to: nil)

    the file field's label or name

  • paths (Array<String>)

    the files to attach

  • options (Hash)

Returns:

  • (self)


228
229
230
231
# File 'lib/unmagic/browser/session.rb', line 228

def attach_file(query = nil, *paths, **options)
  find(query, purpose: :file_field, **options).attach(*paths)
  self
end

#check(query = nil, **options) ⇒ self

Parameters:

  • query (String, nil) (defaults to: nil)

    the checkbox's label or name

  • options (Hash)

Returns:

  • (self)


203
204
205
206
# File 'lib/unmagic/browser/session.rb', line 203

def check(query = nil, **options)
  find(query, purpose: :checkbox, **options).check
  self
end

#choose(query = nil, **options) ⇒ self

Parameters:

  • query (String, nil) (defaults to: nil)

    the radio button's label or name

  • options (Hash)

Returns:

  • (self)


219
220
221
222
# File 'lib/unmagic/browser/session.rb', line 219

def choose(query = nil, **options)
  find(query, purpose: :radio, **options).click
  self
end

#click(query = nil, **options) ⇒ self

Click whatever matches — a button, a link, a div, anything.

Parameters:

Returns:

  • (self)

Raises:



180
181
182
183
# File 'lib/unmagic/browser/session.rb', line 180

def click(query = nil, **options)
  find(query, **options).click
  self
end

#click_button(query = nil, **options) ⇒ self

Parameters:

  • query (String, nil) (defaults to: nil)

    the button's text, value or name

  • options (Hash)

Returns:

  • (self)

Raises:



160
161
162
163
# File 'lib/unmagic/browser/session.rb', line 160

def click_button(query = nil, **options)
  find(query, purpose: :button, **options).click
  self
end

Parameters:

  • query (String, nil) (defaults to: nil)

    the link's text

  • options (Hash)

Returns:

  • (self)

Raises:



169
170
171
172
# File 'lib/unmagic/browser/session.rb', line 169

def click_link(query = nil, **options)
  find(query, purpose: :link, **options).click
  self
end

#closevoid

This method returns an undefined value.

Close the page and give the browser back. On Cloudflare that releases one of your concurrent slots, so a kept session that's finished with should always be closed.



460
461
462
463
464
465
466
467
468
469
470
471
472
# File 'lib/unmagic/browser/session.rb', line 460

def close
  @monitor.synchronize do
    return if @closed

    @closed = true
    begin
      @page.close
    rescue StandardError
      nil # The page may have gone with its browser; the connection close is what matters.
    end
    @connection.close
  end
end

#closed?Boolean

Returns whether this session has been closed.

Returns:

  • (Boolean)

    whether this session has been closed



475
476
477
# File 'lib/unmagic/browser/session.rb', line 475

def closed?
  @closed
end

#current_urlString

Returns the URL currently shown.

Returns:

  • (String)

    the URL currently shown



133
134
135
# File 'lib/unmagic/browser/session.rb', line 133

def current_url
  guarded("reading the URL") { @page.url }
end

#driverPuppeteer::Page

The live Puppeteer page, for everything this API doesn't wrap — network interception, tracing, the rest of the surface. Wrap multi-step native work in #lock when it has to be atomic against other callers.

Returns:

  • (Puppeteer::Page)


103
104
105
# File 'lib/unmagic/browser/session.rb', line 103

def driver
  @page
end

#emulate(config = nil, **knobs) { ... } ⇒ Emulation, Object

Change what's emulated from here on, or — with a block — just for the block, reverting afterwards.

Parameters:

  • config (Emulation, Hash, nil) (defaults to: nil)

    a whole configuration

  • knobs (Hash)

    individual settings, layered over what's already emulated

Yields:

  • with the change in force

Returns:

  • (Emulation, Object)

    the emulation now in force, or the block's value



432
433
434
435
436
437
438
439
440
441
442
# File 'lib/unmagic/browser/session.rb', line 432

def emulate(config = nil, **knobs)
  previous = @emulation
  apply_emulation(previous.merge(config || knobs))
  return @emulation unless block_given?

  begin
    yield
  ensure
    apply_emulation(previous)
  end
end

#evaluate(script, *args) ⇒ Object

Run JavaScript in the page.

Parameters:

  • script (String)

    an expression, or a function like "() => document.title"

  • args (Array)

    arguments passed to the function form

Returns:

  • (Object)

    whatever it returns, as a Ruby value



307
308
309
# File 'lib/unmagic/browser/session.rb', line 307

def evaluate(script, *args)
  guarded("evaluating JavaScript") { @page.evaluate(script, *args) }
end

#fill_in(query = nil, with:, **options) ⇒ self

Type into a field, replacing whatever's in it.

Parameters:

  • query (String, nil) (defaults to: nil)

    the field's label, placeholder, name or id

  • with (String)

    the text to type

  • options (Hash)

Returns:

  • (self)

Raises:



151
152
153
154
# File 'lib/unmagic/browser/session.rb', line 151

def fill_in(query = nil, with:, **options)
  find(query, purpose: :field, **options).fill_in(with)
  self
end

#find(query = nil, scope: nil, wait: nil, purpose: :any, **options) ⇒ Element

Find one element, waiting for it to turn up.

Parameters:

  • query (String, nil) (defaults to: nil)
  • scope (Element, nil) (defaults to: nil)

    search inside this element instead of the page

  • wait (Numeric, nil) (defaults to: nil)

    seconds to keep looking, defaulting to the session's

  • purpose (Symbol) (defaults to: :any)
  • options (Hash)

Returns:

Raises:



380
381
382
383
384
385
386
# File 'lib/unmagic/browser/session.rb', line 380

def find(query = nil, scope: nil, wait: nil, purpose: :any, **options)
  locator = Locator.build(query, purpose: purpose, **options)
  found = resolve(locator, scope: scope || self.scope, wait: wait || @timeout)
  raise Error::NotFound, "Couldn't find a #{locator} on #{current_url}." unless found

  found
end

#go_backself

Returns:

  • (self)


123
124
125
# File 'lib/unmagic/browser/session.rb', line 123

def go_back
  navigating("going back") { @page.go_back(wait_until: Driver::Base::WAIT_UNTIL, timeout: navigation_ms) }
end

#go_forwardself

Returns:

  • (self)


128
129
130
# File 'lib/unmagic/browser/session.rb', line 128

def go_forward
  navigating("going forward") { @page.go_forward(wait_until: Driver::Base::WAIT_UNTIL, timeout: navigation_ms) }
end

#has_button?(query = nil, **options) ⇒ Boolean

Returns whether such a button is on the page right now.

Parameters:

Returns:

  • (Boolean)

    whether such a button is on the page right now



338
339
340
# File 'lib/unmagic/browser/session.rb', line 338

def has_button?(query = nil, **options)
  exists?(query, purpose: :button, **options)
end

#has_element?(query = nil, **options) ⇒ Boolean

Returns whether anything matches right now.

Parameters:

Returns:

  • (Boolean)

    whether anything matches right now



365
366
367
# File 'lib/unmagic/browser/session.rb', line 365

def has_element?(query = nil, **options)
  exists?(query, **options)
end

#has_field?(query = nil, **options) ⇒ Boolean

Returns whether such a field is on the page right now.

Parameters:

Returns:

  • (Boolean)

    whether such a field is on the page right now



352
353
354
# File 'lib/unmagic/browser/session.rb', line 352

def has_field?(query = nil, **options)
  exists?(query, purpose: :field, **options)
end

#has_link?(query = nil, **options) ⇒ Boolean

Returns whether such a link is on the page right now.

Parameters:

Returns:

  • (Boolean)

    whether such a link is on the page right now



345
346
347
# File 'lib/unmagic/browser/session.rb', line 345

def has_link?(query = nil, **options)
  exists?(query, purpose: :link, **options)
end

#has_selector?(css) ⇒ Boolean

Returns whether anything matches it right now.

Parameters:

  • css (String)

    a CSS selector

Returns:

  • (Boolean)

    whether anything matches it right now



358
359
360
# File 'lib/unmagic/browser/session.rb', line 358

def has_selector?(css)
  exists?(css: css)
end

#has_text?(string) ⇒ Boolean

Returns whether it's on the page right now.

Parameters:

  • string (String)

    the text to look for

Returns:

  • (Boolean)

    whether it's on the page right now



331
332
333
# File 'lib/unmagic/browser/session.rb', line 331

def has_text?(string)
  text.downcase.include?(string.to_s.downcase.gsub(/\s+/, " ").strip)
end

#hover(query = nil, **options) ⇒ self

Parameters:

Returns:

  • (self)


251
252
253
254
# File 'lib/unmagic/browser/session.rb', line 251

def hover(query = nil, **options)
  find(query, **options).hover
  self
end

#htmlString

Returns the page's HTML, as it stands after JavaScript has run.

Returns:

  • (String)

    the page's HTML, as it stands after JavaScript has run



269
270
271
272
273
# File 'lib/unmagic/browser/session.rb', line 269

def html
  return scope.html if scope

  guarded("reading the page") { @page.content }
end

#inspectString

Returns the session and where it is, or that it's closed.

Returns:

  • (String)

    the session and where it is, or that it's closed



480
481
482
483
# File 'lib/unmagic/browser/session.rb', line 480

def inspect
  state = @closed ? "closed" : current_url
  "#<#{self.class.name} #{state}>"
end

#lock { ... } ⇒ Object Also known as: synchronize

Hold the session for a whole sequence, so another caller can't slip a keystroke between two of your steps. Individual operations are already serialised; this is for when a sequence has to be atomic.

Yields:

  • the sequence to run uninterrupted

Returns:

  • (Object)

    whatever the block returns



450
451
452
# File 'lib/unmagic/browser/session.rb', line 450

def lock(&block)
  @monitor.synchronize(&block)
end

#markdownString

Returns the page as Markdown.

Returns:

  • (String)

    the page as Markdown



276
277
278
# File 'lib/unmagic/browser/session.rb', line 276

def markdown
  Markdown.from_html(html)
end

#pdfString

Returns PDF bytes.

Returns:

  • (String)

    PDF bytes



298
299
300
# File 'lib/unmagic/browser/session.rb', line 298

def pdf
  guarded("printing to PDF") { @page.pdf }
end

#press(key, on: nil, **options) ⇒ self

Press a key. With no locator it goes to whatever has focus.

Parameters:

  • key (Symbol, String)

    e.g. :enter, "ArrowDown"

  • on (String, Element, nil) (defaults to: nil)

    press it with this element focused instead

  • options (Hash)

Returns:

  • (self)


239
240
241
242
243
244
245
246
# File 'lib/unmagic/browser/session.rb', line 239

def press(key, on: nil, **options)
  if on
    element(on, **options).press(key)
  else
    guarded("pressing #{key}") { @page.keyboard.press(self.class.key_name(key)) }
  end
  self
end

#reloadself

Returns:

  • (self)


118
119
120
# File 'lib/unmagic/browser/session.rb', line 118

def reload
  navigating("reloading") { @page.reload(wait_until: Driver::Base::WAIT_UNTIL, timeout: navigation_ms) }
end

#scopeElement?

Returns the element locators are currently scoped to.

Returns:

  • (Element, nil)

    the element locators are currently scoped to



419
420
421
# File 'lib/unmagic/browser/session.rb', line 419

def scope
  @scopes.last
end

#screenshot(full_page: false, selector: nil) ⇒ String

Returns PNG bytes.

Parameters:

  • full_page (Boolean) (defaults to: false)

    capture the whole scrollable page

  • selector (String, Element, nil) (defaults to: nil)

    capture just this element

Returns:

  • (String)

    PNG bytes



290
291
292
293
294
295
# File 'lib/unmagic/browser/session.rb', line 290

def screenshot(full_page: false, selector: nil)
  target = selector || scope
  return element(target).screenshot if target

  guarded("taking a screenshot") { @page.screenshot(type: "png", full_page: full_page) }
end

#select(option, from: nil, **options) ⇒ self

Pick an option in a select, by the label a reader sees.

Parameters:

  • option (String)

    the option's visible label, or its value

  • from (String, nil) (defaults to: nil)

    the select's label or name

  • options (Hash)

Returns:

  • (self)

Raises:



192
193
194
195
196
197
198
# File 'lib/unmagic/browser/session.rb', line 192

def select(option, from: nil, **options)
  # With nothing naming the select, there's only one sensible reading:
  # the page has one, and this is it.
  options = { css: "select" } if from.nil? && options.empty?
  find(from, purpose: :select, **options).select_option(option)
  self
end

#textString

Returns the visible text of the page, or of the current scope.

Returns:

  • (String)

    the visible text of the page, or of the current scope



259
260
261
262
263
264
265
266
# File 'lib/unmagic/browser/session.rb', line 259

def text
  raw = if scope
    scope.evaluate("(root) => (root.innerText === undefined ? root.textContent : root.innerText)")
  else
    guarded("reading the page") { @page.evaluate(PAGE_TEXT_SCRIPT) }
  end
  raw.to_s.gsub(/[ \t]+/, " ").strip
end

#titleString

Returns the page's title.

Returns:

  • (String)

    the page's title



138
139
140
# File 'lib/unmagic/browser/session.rb', line 138

def title
  guarded("reading the title") { @page.title }
end

#uncheck(query = nil, **options) ⇒ self

Parameters:

  • query (String, nil) (defaults to: nil)

    the checkbox's label or name

  • options (Hash)

Returns:

  • (self)


211
212
213
214
# File 'lib/unmagic/browser/session.rb', line 211

def uncheck(query = nil, **options)
  find(query, purpose: :checkbox, **options).uncheck
  self
end

#value(query = nil, **options) ⇒ String?

Returns what's currently in the field.

Parameters:

  • query (String, nil) (defaults to: nil)

    the field's label, placeholder, name or id

  • options (Hash)

Returns:

  • (String, nil)

    what's currently in the field



283
284
285
# File 'lib/unmagic/browser/session.rb', line 283

def value(query = nil, **options)
  find(query, purpose: :field, **options).value
end

#visit(url) ⇒ self

Go to a URL and wait for it to settle.

Parameters:

  • url (String)

Returns:

  • (self)


113
114
115
# File 'lib/unmagic/browser/session.rb', line 113

def visit(url)
  navigating("visiting #{url}") { @page.goto(url, wait_until: Driver::Base::WAIT_UNTIL, timeout: navigation_ms) }
end

#wait_for(text: nil, selector: nil, timeout: nil, **options) ⇒ self

Block until something shows up.

Parameters:

  • text (String, nil) (defaults to: nil)

    wait for this text to appear

  • selector (String, nil) (defaults to: nil)

    wait for an element matching this locator

  • timeout (Numeric, nil) (defaults to: nil)

    seconds to wait, defaulting to the session's

  • options (Hash)

Returns:

  • (self)

Raises:



321
322
323
324
325
326
327
# File 'lib/unmagic/browser/session.rb', line 321

def wait_for(text: nil, selector: nil, timeout: nil, **options)
  raise ArgumentError, "wait_for needs text: or selector:" if text.nil? && selector.nil?

  wait_for_text(text, timeout) if text
  find(selector, wait: timeout || @timeout, **options) if selector
  self
end

#within(query = nil, **options) {|session| ... } ⇒ Object

Narrow every locator inside the block to one part of the page.

Parameters:

Yields:

  • with the scope in force; the session is passed for convenience

Yield Parameters:

Returns:

  • (Object)

    whatever the block returns



408
409
410
411
412
413
414
415
416
# File 'lib/unmagic/browser/session.rb', line 408

def within(query = nil, **options)
  inner = element(query, **options)
  @scopes.push(inner)
  begin
    yield(self)
  ensure
    @scopes.pop
  end
end