Class: Capybara::Lightpanda::Browser

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Includes:
Console, Finder, Modals, Navigation, Runtime
Defined in:
lib/capybara/lightpanda/browser.rb,
lib/capybara/lightpanda/browser/finder.rb,
lib/capybara/lightpanda/browser/modals.rb,
lib/capybara/lightpanda/browser/console.rb,
lib/capybara/lightpanda/browser/runtime.rb,
lib/capybara/lightpanda/browser/navigation.rb

Defined Under Namespace

Modules: Console, Finder, Modals, Navigation, Runtime

Constant Summary collapse

NODE_MARKER =

Sentinel key marking a serialized DOM node in JS-result payloads. Produced by #unwrap_call_result / #serialize_remote_array, consumed by Driver#unwrap_script_result, which wraps the objectId in a Node.

"__lightpanda_node__"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Console

#clear_console_logs, #console_logs

Methods included from Modals

#accept_modal, #dismiss_modal, #find_modal

Methods included from Navigation

#back, #forward, #go_to, #refresh

Methods included from Finder

#find, #find_within, #parents_of

Methods included from Runtime

#call_function_on, #evaluate, #evaluate_async, #evaluate_with_ref, #execute, #release_object

Constructor Details

#initialize(options = {}) ⇒ Browser

Returns a new instance of Browser.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/capybara/lightpanda/browser.rb', line 85

def initialize(options = {})
  @options = Options.new(options)
  @process = nil
  @client = nil
  @target_id = nil
  @session_id = nil
  @browser_context_id = nil
  @started = false
  @page_events_enabled = false
  @modal_messages = []
  @modal_messages_mutex = Mutex.new
  @modal_handler_installed = false
  @console_logs = []
  @console_logs_mutex = Mutex.new
  @frame_stack = []
  @turbo_event = Utils::Event.new
  @turbo_event.set

  start
end

Instance Attribute Details

#browser_context_idObject (readonly)

Returns the value of attribute browser_context_id.



22
23
24
# File 'lib/capybara/lightpanda/browser.rb', line 22

def browser_context_id
  @browser_context_id
end

#clientObject (readonly)

Returns the value of attribute client.



22
23
24
# File 'lib/capybara/lightpanda/browser.rb', line 22

def client
  @client
end

#frame_stackObject (readonly)

Returns the value of attribute frame_stack.



22
23
24
# File 'lib/capybara/lightpanda/browser.rb', line 22

def frame_stack
  @frame_stack
end

#optionsObject (readonly)

Returns the value of attribute options.



22
23
24
# File 'lib/capybara/lightpanda/browser.rb', line 22

def options
  @options
end

#processObject (readonly)

Returns the value of attribute process.



22
23
24
# File 'lib/capybara/lightpanda/browser.rb', line 22

def process
  @process
end

#session_idObject (readonly)

Returns the value of attribute session_id.



22
23
24
# File 'lib/capybara/lightpanda/browser.rb', line 22

def session_id
  @session_id
end

#target_idObject (readonly)

Returns the value of attribute target_id.



22
23
24
# File 'lib/capybara/lightpanda/browser.rb', line 22

def target_id
  @target_id
end

Class Method Details

.quit_allObject

at_exit handler: close every live browser’s CDP WebSocket (via #quit) before its Process finalizer can SIGTERM the binary. Per-browser rescue so one wedged browser can’t strand the rest.



65
66
67
68
69
70
71
# File 'lib/capybara/lightpanda/browser.rb', line 65

def quit_all
  @live_mutex.synchronize { @live.dup }.each do |browser|
    browser.quit
  rescue StandardError
    nil
  end
end

.track(browser) ⇒ Object



48
49
50
51
52
53
54
55
56
# File 'lib/capybara/lightpanda/browser.rb', line 48

def track(browser)
  @live_mutex.synchronize do
    @live << browser unless @live.include?(browser)
    next if @at_exit_installed

    @at_exit_installed = true
    at_exit { quit_all }
  end
end

.untrack(browser) ⇒ Object



58
59
60
# File 'lib/capybara/lightpanda/browser.rb', line 58

def untrack(browser)
  @live_mutex.synchronize { @live.delete(browser) }
end

Instance Method Details

#active_elementObject

objectId of document.activeElement, or nil if none/document detached.



311
312
313
314
# File 'lib/capybara/lightpanda/browser.rb', line 311

def active_element
  result = evaluate_with_ref("document.activeElement")
  result&.dig("objectId")
end

#alive?Boolean

Liveness of the CDP transport. Driver#browser checks this to decide whether to respawn a dead browser.

Returns:

  • (Boolean)


208
209
210
211
212
# File 'lib/capybara/lightpanda/browser.rb', line 208

def alive?
  !client.nil? && !client.closed?
rescue StandardError
  false
end

#backend_node_id(remote_object_id) ⇒ Object

Resolve an objectId to its stable per-page backendNodeId. objectIds are transient (re-issued per Runtime call) but backendNodeId is stable, so this is what we compare for cross-query node equality.



319
320
321
# File 'lib/capybara/lightpanda/browser.rb', line 319

def backend_node_id(remote_object_id)
  page_command("DOM.describeNode", objectId: remote_object_id).dig("node", "backendNodeId")
end

#bodyObject Also known as: html



286
287
288
289
290
291
292
# File 'lib/capybara/lightpanda/browser.rb', line 286

def body
  # Guard against the brief window after a fresh BrowserContext / target
  # is created where the V8 context exists but `document.documentElement`
  # is still null. Hit by Capybara's `#reset_session! resets page body`
  # spec since the 0.2.0 Ferrum-style reset rewrite.
  evaluate("(document.documentElement && document.documentElement.outerHTML) || ''")
end

#clear_framesObject



421
422
423
# File 'lib/capybara/lightpanda/browser.rb', line 421

def clear_frames
  @frame_stack.clear
end

#command(method, **params) ⇒ Object



243
244
245
# File 'lib/capybara/lightpanda/browser.rb', line 243

def command(method, **params)
  @client.command(method, params)
end

#cookiesObject



404
405
406
# File 'lib/capybara/lightpanda/browser.rb', line 404

def cookies
  @cookies ||= Cookies.new(self)
end

#current_urlObject



278
279
280
# File 'lib/capybara/lightpanda/browser.rb', line 278

def current_url
  evaluate("window.location.href")
end

#frame_titleObject



436
437
438
439
440
441
# File 'lib/capybara/lightpanda/browser.rb', line 436

def frame_title
  frame = frame_stack.last
  return title unless frame

  call_function_on(frame.remote_object_id, FRAME_TITLE_JS)
end

#frame_urlObject

Capybara::Driver::Base resolves frame_url/frame_title via the top execution context, which always reports the parent document. Resolve them through the iframe element’s contentWindow / contentDocument so they reflect the active frame.



429
430
431
432
433
434
# File 'lib/capybara/lightpanda/browser.rb', line 429

def frame_url
  frame = frame_stack.last
  return current_url unless frame

  call_function_on(frame.remote_object_id, FRAME_URL_JS)
end

#keyboardObject



396
397
398
# File 'lib/capybara/lightpanda/browser.rb', line 396

def keyboard
  @keyboard ||= Keyboard.new(self)
end

#networkObject



400
401
402
# File 'lib/capybara/lightpanda/browser.rb', line 400

def network
  @network ||= Network.new(self)
end

#nightly_buildObject



81
82
83
# File 'lib/capybara/lightpanda/browser.rb', line 81

def nightly_build
  @process&.nightly_build
end

#page_command(method, **params) ⇒ Object



247
248
249
# File 'lib/capybara/lightpanda/browser.rb', line 247

def page_command(method, **params)
  @client.command(method, params, session_id: @session_id)
end

#pop_frameObject



417
418
419
# File 'lib/capybara/lightpanda/browser.rb', line 417

def pop_frame
  @frame_stack.pop
end

#push_frame(node) ⇒ Object

– Frame Support – ‘frame_stack` (Array<Node>) is the Capybara `switch_to_frame` stack; it drives where `find` resolves selectors. Stored as Nodes so callFunctionOn can scope to the iframe’s contentDocument.



413
414
415
# File 'lib/capybara/lightpanda/browser.rb', line 413

def push_frame(node)
  @frame_stack.push(node)
end

#quitObject



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/capybara/lightpanda/browser.rb', line 214

def quit
  self.class.untrack(self)
  # Flip Network back to disabled so a later #start re-installs its
  # subscriptions — without this, quit→start reuse of the same
  # instance leaves @enabled true and create_page's network.enable
  # no-ops, silently killing status_code/traffic capture. Guarded on
  # @client: with no client the handlers are already moot and
  # unsubscribe would have nothing to detach from.
  @network&.reset if @client
  begin
    @client&.close
  rescue StandardError
    nil
  end
  begin
    @process&.stop
  rescue StandardError
    nil
  end
  @client = nil
  @process = nil
  @started = false
  @browser_context_id = nil
  @target_id = nil
  @session_id = nil
  @modal_handler_installed = false
  clear_frames
end

#reconnectObject

Recover after a WebSocket disconnect or process crash during navigation. Restarts the process if it died, then creates a fresh client and page.

Raises:



171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/capybara/lightpanda/browser.rb', line 171

def reconnect
  close_client_silently
  restart_process_if_dead

  ws_url = @options.ws_url? ? @options.ws_url : @process&.ws_url
  raise DeadBrowserError, "Cannot reconnect: no WebSocket URL" unless ws_url

  @client = Client.new(ws_url, @options)
  # Process may have died; the old browserContextId is gone with it.
  @browser_context_id = nil
  clear_session_state
  create_browser_context
  create_page
end

#resetObject

Wipe per-session state — cookies, storage, all targets — and start over with a fresh BrowserContext. Mirrors ferrum’s Browser#reset: one CDP call (‘Target.disposeBrowserContext`) does the work that would otherwise require explicit cookies.clear / storage.clear / close-target dance, and the browser auto-isolates state for the new context. Driver#reset! delegates here.



161
162
163
164
165
166
167
# File 'lib/capybara/lightpanda/browser.rb', line 161

def reset
  dispose_browser_context
  @client.clear_subscriptions
  clear_session_state
  create_browser_context
  create_page
end

#response_headersObject

Response headers of the last document navigation, wrapped in a Headers instance so ‘[“Content-Type”]` works despite CDP lowercasing keys. Returns an empty Headers (not nil) so callers can chain `[]` safely.



305
306
307
308
# File 'lib/capybara/lightpanda/browser.rb', line 305

def response_headers
  raw = network.last_navigation_response&.dig(:headers) || {}
  Headers.new.tap { |h| raw.each { |k, v| h[k.to_s.downcase] = v } }
end

#screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary) ⇒ Object



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/capybara/lightpanda/browser.rb', line 334

def screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary)
  params = { format: format.to_s }
  params[:quality] = quality if quality && format == :jpeg

  if full_page
    metrics = page_command("Page.getLayoutMetrics")
    content_size = metrics["contentSize"]

    params[:clip] = {
      x: 0,
      y: 0,
      width: content_size["width"],
      height: content_size["height"],
      scale: 1,
    }
  end

  result = page_command("Page.captureScreenshot", **params)
  data = result["data"]

  if encoding == :base64
    data
  else
    decoded = Base64.decode64(data)

    if path
      File.binwrite(path, decoded)
      path
    else
      decoded
    end
  end
end

#set_file_input_files(remote_object_id, paths) ⇒ Object

Populate a file <input> from one or more local file paths via DOM.setFileInputFiles (PR #2635, build ≥6625): Lightpanda resolves the objectId, replaces input.files with a real FileList, and fires ‘input`/`change`. The submitted form then carries the bytes as multipart/form-data (PR #2654, build ≥6672) — both halves are needed, which is why MINIMUM_NIGHTLY_BUILD sits at the 6672 floor. Paths are read off the machine running Lightpanda (local for the spawned process).



330
331
332
# File 'lib/capybara/lightpanda/browser.rb', line 330

def set_file_input_files(remote_object_id, paths)
  page_command("DOM.setFileInputFiles", objectId: remote_object_id, files: paths)
end

#startObject



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/capybara/lightpanda/browser.rb', line 106

def start
  return if @started

  if @options.ws_url?
    @client = Client.new(@options.ws_url, @options)
  else
    @process = Process.new(@options)
    @process.start
    @client = Client.new(@process.ws_url, @options)
  end

  create_browser_context
  create_page

  @started = true
  self.class.track(self)
end

#status_codeObject

HTTP status of the last document navigation; nil before the first navigation completes. Captured by Network’s subscription (installed via network.enable in create_page).



298
299
300
# File 'lib/capybara/lightpanda/browser.rb', line 298

def status_code
  network.last_navigation_response&.dig(:status)
end

#titleObject



282
283
284
# File 'lib/capybara/lightpanda/browser.rb', line 282

def title
  evaluate("document.title")
end

#versionObject

Lightpanda binary version (e.g. “lightpanda 0.2.9 nightly.5267”) and parsed nightly build number, captured at Process startup. nil when the gem is connecting to an externally-managed Lightpanda via ws_url.



77
78
79
# File 'lib/capybara/lightpanda/browser.rb', line 77

def version
  @process&.version
end

#wait_for_default_context(timeout = 1.0) ⇒ Object

Block up to ‘timeout` seconds for a default V8 execution context to exist. Returns true if available (immediately or after waiting), false if the timeout elapses with no executionContextCreated event.



261
262
263
# File 'lib/capybara/lightpanda/browser.rb', line 261

def wait_for_default_context(timeout = 1.0)
  @default_context_event.wait(timeout)
end

#wait_for_idleObject



381
382
383
384
385
386
387
388
389
390
391
392
393
394
# File 'lib/capybara/lightpanda/browser.rb', line 381

def wait_for_idle
  prior_context_iteration = @default_context_event.iteration
  sniff_deadline = monotonic_time + SNIFF_WINDOW
  loop do
    break if @default_context_event.iteration > prior_context_iteration
    break unless @turbo_event.set?
    break if monotonic_time > sniff_deadline

    sleep 0.001
  end

  @default_context_event.wait(@options.timeout)
  @turbo_event.wait(@options.timeout)
end

#with_default_context_wait(timeout: 1.0, attempts: 3) ⇒ Object

Run the block; if it raises NoExecutionContextError (the navigation race window — lightpanda-io/browser#2187), wait for the next default context to be signaled by Runtime.executionContextCreated, then retry. Up to ‘attempts` total tries; defaults to 3, can be bumped for stubborn flakes. Each retry blocks up to `timeout` seconds for the executionContextCreated signal — no blind sleeps.



271
272
273
274
275
276
# File 'lib/capybara/lightpanda/browser.rb', line 271

def with_default_context_wait(timeout: 1.0, attempts: 3)
  Utils::Attempt.with_retry(errors: NoExecutionContextError, max: attempts, wait: 0) do
    wait_for_default_context(timeout)
    yield
  end
end