Class: Capybara::Lightpanda::Browser

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Includes:
Console, Finder, Modals, Navigation, Runtime, SeleniumCompat
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,
lib/capybara/lightpanda/browser/selenium_compat.rb

Defined Under Namespace

Modules: Console, Finder, Modals, Navigation, Runtime, SeleniumCompat

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__"

Constants included from SeleniumCompat

SeleniumCompat::CONSOLE_LEVELS

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from SeleniumCompat

#execute_async_script, #execute_cdp, #logs, #switch_to

Methods included from Console

#clear_console_logs, #clear_page_errors, #console_logs, #page_errors

Methods included from Modals

#accept_modal, #check_unhandled_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.



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/capybara/lightpanda/browser.rb', line 94

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
  @modal_armed = false
  @unhandled_modal = nil
  @console_logs = []
  @console_logs_mutex = Mutex.new
  @page_errors = []
  @page_errors_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.



24
25
26
# File 'lib/capybara/lightpanda/browser.rb', line 24

def browser_context_id
  @browser_context_id
end

#clientObject (readonly)

Returns the value of attribute client.



24
25
26
# File 'lib/capybara/lightpanda/browser.rb', line 24

def client
  @client
end

#frame_stackObject (readonly)

Returns the value of attribute frame_stack.



24
25
26
# File 'lib/capybara/lightpanda/browser.rb', line 24

def frame_stack
  @frame_stack
end

#optionsObject (readonly)

Returns the value of attribute options.



24
25
26
# File 'lib/capybara/lightpanda/browser.rb', line 24

def options
  @options
end

#processObject (readonly)

Returns the value of attribute process.



24
25
26
# File 'lib/capybara/lightpanda/browser.rb', line 24

def process
  @process
end

#session_idObject (readonly)

Returns the value of attribute session_id.



24
25
26
# File 'lib/capybara/lightpanda/browser.rb', line 24

def session_id
  @session_id
end

#target_idObject (readonly)

Returns the value of attribute target_id.



24
25
26
# File 'lib/capybara/lightpanda/browser.rb', line 24

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.



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

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

.track(browser) ⇒ Object



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

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



60
61
62
# File 'lib/capybara/lightpanda/browser.rb', line 60

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.



353
354
355
356
# File 'lib/capybara/lightpanda/browser.rb', line 353

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)


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

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.



361
362
363
# File 'lib/capybara/lightpanda/browser.rb', line 361

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

#bodyObject Also known as: html



328
329
330
331
332
333
334
# File 'lib/capybara/lightpanda/browser.rb', line 328

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



468
469
470
# File 'lib/capybara/lightpanda/browser.rb', line 468

def clear_frames
  @frame_stack.clear
end

#command(method, **params) ⇒ Object



285
286
287
# File 'lib/capybara/lightpanda/browser.rb', line 285

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

#configured_download_pathObject

Download destination: explicit :save_path option wins, else Capybara.save_path (Cuprite parity). nil => downloads stay off.



179
180
181
182
# File 'lib/capybara/lightpanda/browser.rb', line 179

def configured_download_path
  @options.save_path ||
    (defined?(Capybara) && Capybara.respond_to?(:save_path) ? Capybara.save_path : nil)
end

#cookiesObject



451
452
453
# File 'lib/capybara/lightpanda/browser.rb', line 451

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

#current_urlObject



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

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

#downloadsObject



447
448
449
# File 'lib/capybara/lightpanda/browser.rb', line 447

def downloads
  @downloads ||= Downloads.new(self)
end

#frame_titleObject



483
484
485
486
487
488
# File 'lib/capybara/lightpanda/browser.rb', line 483

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.



476
477
478
479
480
481
# File 'lib/capybara/lightpanda/browser.rb', line 476

def frame_url
  frame = frame_stack.last
  return current_url unless frame

  call_function_on(frame.remote_object_id, FRAME_URL_JS)
end

#keyboardObject



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

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

#networkObject



443
444
445
# File 'lib/capybara/lightpanda/browser.rb', line 443

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

#nightly_buildObject

Set on the nightly/dev channel only; nil for a tagged release, which carries no build counter. release is its mirror image — exactly one of the two is non-nil once the process has started.



86
87
88
# File 'lib/capybara/lightpanda/browser.rb', line 86

def nightly_build
  @process&.nightly_build
end

#page_command(method, **params) ⇒ Object



289
290
291
# File 'lib/capybara/lightpanda/browser.rb', line 289

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

#pop_frameObject



464
465
466
# File 'lib/capybara/lightpanda/browser.rb', line 464

def pop_frame
  @frame_stack.pop
end

#push_frame(node) ⇒ Object

-- Frame Support -- frame_stack (Array) 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.



460
461
462
# File 'lib/capybara/lightpanda/browser.rb', line 460

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

#quitObject



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/capybara/lightpanda/browser.rb', line 252

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. Downloads carries the
  # same subscription contract, so it resets under the same guard.
  if @client
    @network&.reset
    @downloads&.reset
  end
  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:



200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/capybara/lightpanda/browser.rb', line 200

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

#releaseObject



90
91
92
# File 'lib/capybara/lightpanda/browser.rb', line 90

def release
  @process&.release
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.



190
191
192
193
194
195
196
# File 'lib/capybara/lightpanda/browser.rb', line 190

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.



347
348
349
350
# File 'lib/capybara/lightpanda/browser.rb', line 347

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



376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# File 'lib/capybara/lightpanda/browser.rb', line 376

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 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, and both are guaranteed by the MINIMUM_NIGHTLY_BUILD floor. Paths are read off the machine running Lightpanda (local for the spawned process).



372
373
374
# File 'lib/capybara/lightpanda/browser.rb', line 372

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

#set_viewport(width = nil, height = nil) ⇒ Object

Apply the window_size option as a JS-visible viewport: it drives window.innerWidth/innerHeight and the viewport matchMedia / @media evaluate against, so responsive branches resolve at the requested size. It is NOT layout — Lightpanda has no rendering engine, so nothing reflows and getBoundingClientRect stays synthetic.

Re-applied on every create_page rather than once at connect. The override lives on the CDP-connection-scoped Browser upstream, so it already survives Driver#reset!'s disposeBrowserContext; re-sending is one idempotent call that also covers the reconnect path, where the connection (and therefore the override) is genuinely new.

window_size is validated in Options#initialize, which runs before the process is spawned — a bad value must not leave an orphaned browser behind, and raising from here would (Browser#initialize starts the process before create_page ever runs).

Public (declared below the private section via public :set_viewport) because Driver#resize_window_to drives it mid-test: with no arguments it re-applies the configured size, which is what create_page wants. SHARP EDGE, verified 2026-07-25 on nightly 8285: this updates window.innerWidth/innerHeight and what matchMedia reports immediately, but it does NOT re-resolve @media rules for the document already loaded. Lightpanda fixes the cascade when a document is parsed and nothing invalidates it on a metrics change, so a page loaded at 1920 keeps rendering its desktop branch even while matchMedia ("(max-width: 500px)") reports true. Navigating (Driver#visit) parses under the new metrics and resolves correctly.

No workaround shipped on purpose. DOM/CSSOM mutations look like they force a re-cascade but don't survive scrutiny (the probe that suggested otherwise had been contaminated by the previous iteration's viewport), so anything here would be a guess dressed up as a fix. Driver's window methods document the resize-then-visit shape instead.



539
540
541
542
543
# File 'lib/capybara/lightpanda/browser.rb', line 539

def set_viewport(width = nil, height = nil)
  width, height = @options.window_size if width.nil? || height.nil?
  page_command("Emulation.setDeviceMetricsOverride", width: width, height: height)
  [width, height]
end

#startObject



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/capybara/lightpanda/browser.rb', line 119

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).



340
341
342
# File 'lib/capybara/lightpanda/browser.rb', line 340

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

#titleObject



324
325
326
# File 'lib/capybara/lightpanda/browser.rb', line 324

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.



79
80
81
# File 'lib/capybara/lightpanda/browser.rb', line 79

def version
  @process&.version
end

#viewport_sizeObject

The viewport as the page currently sees it. Read back from JS rather than echoing what we last set, so it stays truthful if a page (or a direct Emulation call through #execute_cdp) moved it behind our back.



548
549
550
# File 'lib/capybara/lightpanda/browser.rb', line 548

def viewport_size
  evaluate("[window.innerWidth, window.innerHeight]")
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.



303
304
305
# File 'lib/capybara/lightpanda/browser.rb', line 303

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

#wait_for_idleObject



423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# File 'lib/capybara/lightpanda/browser.rb', line 423

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)
  check_unhandled_modal!
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.



313
314
315
316
317
318
# File 'lib/capybara/lightpanda/browser.rb', line 313

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