Class: Capybara::Lightpanda::Browser

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Defined in:
lib/capybara/lightpanda/browser.rb

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ Browser

Returns a new instance of Browser.



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/capybara/lightpanda/browser.rb', line 27

def initialize(options = {})
  @options = Options.new(options)
  @process = nil
  @client = nil
  @target_id = nil
  @session_id = nil
  @started = false
  @page_events_enabled = false
  @modal_responses = []
  @modal_messages = []
  @modal_handler_installed = false
  @frame_stack = []
  @frames = Concurrent::Hash.new
  @turbo_event = Utils::Event.new
  @turbo_event.set
  @visited_origins = Concurrent::Set.new

  start
end

Instance Attribute Details

#clientObject (readonly)

Returns the value of attribute client.



12
13
14
# File 'lib/capybara/lightpanda/browser.rb', line 12

def client
  @client
end

#frame_stackObject (readonly)

Returns the value of attribute frame_stack.



12
13
14
# File 'lib/capybara/lightpanda/browser.rb', line 12

def frame_stack
  @frame_stack
end

#optionsObject (readonly)

Returns the value of attribute options.



12
13
14
# File 'lib/capybara/lightpanda/browser.rb', line 12

def options
  @options
end

#processObject (readonly)

Returns the value of attribute process.



12
13
14
# File 'lib/capybara/lightpanda/browser.rb', line 12

def process
  @process
end

#session_idObject (readonly)

Returns the value of attribute session_id.



12
13
14
# File 'lib/capybara/lightpanda/browser.rb', line 12

def session_id
  @session_id
end

#target_idObject (readonly)

Returns the value of attribute target_id.



12
13
14
# File 'lib/capybara/lightpanda/browser.rb', line 12

def target_id
  @target_id
end

#visited_originsObject (readonly)

Set of ‘scheme://host:port` strings the browser has navigated to during this session. Used by Cookies#clear to enumerate cookies across all domains: Lightpanda’s ‘Network.getCookies` (no urls param) is scoped to the current page’s origin, so without tracked origins we’d miss cookies set on previously-visited domains.



52
53
54
# File 'lib/capybara/lightpanda/browser.rb', line 52

def visited_origins
  @visited_origins
end

Instance Method Details

#accept_modal(type, text: nil) ⇒ Object



497
498
499
500
# File 'lib/capybara/lightpanda/browser.rb', line 497

def accept_modal(type, text: nil)
  prepare_modals
  @modal_responses << { accept: true, text: text, type: type.to_s }
end

#active_elementObject

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



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

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

#at_css(selector) ⇒ Object



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

def at_css(selector)
  result = page_command("DOM.querySelector", nodeId: document_node_id, selector: selector)

  result["nodeId"]
end

#backObject



184
185
186
# File 'lib/capybara/lightpanda/browser.rb', line 184

def back
  wait_for_navigation { execute("history.back()") }
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.



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

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

#bodyObject Also known as: html



205
206
207
# File 'lib/capybara/lightpanda/browser.rb', line 205

def body
  evaluate("document.documentElement.outerHTML")
end

#call_function_on(remote_object_id, function_declaration, *args, return_by_value: true) ⇒ Object

Call a function on a remote object via Runtime.callFunctionOn. Binds ‘this` to the DOM element referenced by remote_object_id.

Raises:



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/capybara/lightpanda/browser.rb', line 273

def call_function_on(remote_object_id, function_declaration, *args, return_by_value: true)
  params = {
    objectId: remote_object_id,
    functionDeclaration: function_declaration,
    returnByValue: return_by_value,
    awaitPromise: true,
  }
  params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?

  response = page_command("Runtime.callFunctionOn", **params)
  raise JavaScriptError, response if response["exceptionDetails"]

  result = response["result"]
  return nil if result["type"] == "undefined"

  return_by_value ? result["value"] : result
end

#clear_framesObject



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

def clear_frames
  @frame_stack.clear
end

#command(method, **params) ⇒ Object



123
124
125
# File 'lib/capybara/lightpanda/browser.rb', line 123

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

#cookiesObject



426
427
428
# File 'lib/capybara/lightpanda/browser.rb', line 426

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

#create_pageObject



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/capybara/lightpanda/browser.rb', line 70

def create_page
  result = @client.command("Target.createTarget", { url: "about:blank" })
  @target_id = result["targetId"]

  attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
  @session_id = attach_result["sessionId"]

  @frames.clear
  @turbo_event.set
  subscribe_to_console_logs
  subscribe_to_execution_context
  subscribe_to_frame_events
  subscribe_to_turbo_signals
  register_auto_scripts
end

#css(selector) ⇒ Object



333
334
335
336
# File 'lib/capybara/lightpanda/browser.rb', line 333

def css(selector)
  node_ids = page_command("DOM.querySelectorAll", nodeId: document_node_id, selector: selector)
  node_ids["nodeIds"] || []
end

#current_urlObject



197
198
199
# File 'lib/capybara/lightpanda/browser.rb', line 197

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

#dismiss_modal(type) ⇒ Object



502
503
504
505
# File 'lib/capybara/lightpanda/browser.rb', line 502

def dismiss_modal(type)
  prepare_modals
  @modal_responses << { accept: false, type: type.to_s }
end

#enable_page_eventsObject



158
159
160
161
162
163
# File 'lib/capybara/lightpanda/browser.rb', line 158

def enable_page_events
  return if @page_events_enabled

  page_command("Page.enable")
  @page_events_enabled = true
end

#evaluate(expression, *args) ⇒ Object

Evaluate JS and return a serialized value. No-args fast path uses Runtime.evaluate; with args we wrap as a function and dispatch via Runtime.callFunctionOn so ‘arguments` is bound. Both paths use `returnByValue: false` and unwrap so DOM-node returns come back as `{ “lightpanda_node” => … }` for the Driver to wrap.



215
216
217
218
219
220
221
222
223
224
225
# File 'lib/capybara/lightpanda/browser.rb', line 215

def evaluate(expression, *args)
  if args.empty?
    response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
    raise JavaScriptError, response if response["exceptionDetails"]

    return unwrap_call_result(response["result"])
  end

  wrapped = "function() { return #{expression} }"
  call_with_args(wrapped, args)
end

#evaluate_async(expression, *args, wait: @options.timeout) ⇒ Object

Evaluate async JS with a callback. The user’s script receives the callback as its last argument (‘arguments[arguments.length - 1]`), matching Capybara’s evaluate_async_script contract.



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/capybara/lightpanda/browser.rb', line 242

def evaluate_async(expression, *args, wait: @options.timeout)
  timeout_ms = (wait * 1000).to_i
  wrapped = <<~JS
    function() {
      var __args = Array.prototype.slice.call(arguments);
      return new Promise(function(__resolve, __reject) {
        var __timer = setTimeout(function() {
          __reject(new Error('Async script timeout after #{timeout_ms}ms'));
        }, #{timeout_ms});
        var __done = function(val) { clearTimeout(__timer); __resolve(val); };
        __args.push(__done);
        (function() { #{expression} }).apply(null, __args);
      });
    }
  JS
  call_with_args(wrapped, args)
end

#evaluate_with_ref(expression) ⇒ Object

Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).

Raises:



261
262
263
264
265
266
267
268
269
# File 'lib/capybara/lightpanda/browser.rb', line 261

def evaluate_with_ref(expression)
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
  raise JavaScriptError, response if response["exceptionDetails"]

  result = response["result"]
  return nil if result["type"] == "undefined"

  result
end

#execute(expression, *args) ⇒ Object

Execute JS without returning a value.



228
229
230
231
232
233
234
235
236
237
# File 'lib/capybara/lightpanda/browser.rb', line 228

def execute(expression, *args)
  if args.empty?
    page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: false)
    return nil
  end

  wrapped = "function() { #{expression} }"
  call_with_args(wrapped, args, return_by_value: false)
  nil
end

#find(method, selector) ⇒ Object

Find elements in the current context (top frame or active frame). Returns an array of remote object ID strings.



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

def find(method, selector)
  if @frame_stack.empty?
    find_in_document(method, selector)
  else
    find_in_frame(method, selector)
  end
end

#find_modal(type, text: nil, wait: options.timeout) ⇒ Object



507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
# File 'lib/capybara/lightpanda/browser.rb', line 507

def find_modal(type, text: nil, wait: options.timeout)
  regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
  deadline = monotonic_time + wait
  last_message = nil
  loop do
    msg = @modal_messages.find { |m| m[:type] == type.to_s }
    if msg
      last_message = msg[:message]
      if regexp.nil? || last_message.match?(regexp)
        @modal_messages.delete(msg)
        return last_message
      end
    end
    break if monotonic_time > deadline

    sleep 0.05
  end
  raise_modal_not_found(text, last_message)
end

#find_within(remote_object_id, method, selector) ⇒ Object

Find child elements within a specific node. Returns an array of remote object ID strings.



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

def find_within(remote_object_id, method, selector)
  result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
  extract_node_object_ids(result)
end

#forwardObject



188
189
190
# File 'lib/capybara/lightpanda/browser.rb', line 188

def forward
  wait_for_navigation { execute("history.forward()") }
end

#frame_by(id: nil, name: nil) ⇒ Object



466
467
468
469
470
471
472
# File 'lib/capybara/lightpanda/browser.rb', line 466

def frame_by(id: nil, name: nil)
  if id
    @frames[id]
  elsif name
    @frames.each_value.find { |f| f.name == name }
  end
end

#framesObject

All frames currently attached to the page (main frame + iframes).



456
457
458
# File 'lib/capybara/lightpanda/browser.rb', line 456

def frames
  @frames.values
end

#get_object_properties(remote_object_id) ⇒ Object

Get properties of a remote object (used to extract array elements).



292
293
294
# File 'lib/capybara/lightpanda/browser.rb', line 292

def get_object_properties(remote_object_id)
  page_command("Runtime.getProperties", objectId: remote_object_id, ownProperties: true)
end

#go_to(url, wait: true, retried: false) ⇒ Object Also known as: goto

Navigation with readyState fallback.

Lightpanda may never fire Page.loadEventFired on complex JS pages (lightpanda-io/browser#1801, #1832). When the event times out, we poll document.readyState as a fallback.

Page.navigate is sent asynchronously because Lightpanda may not return the command result until the page is fully loaded (unlike Chrome which returns immediately with frameId/loaderId). If we waited synchronously, the readyState fallback would never be reached on pages that fail to fully load.

Uses a single shared deadline so the worst-case wait is 1x timeout, not 2x (lightpanda-io/browser#1849).



145
146
147
148
149
150
151
152
153
154
155
# File 'lib/capybara/lightpanda/browser.rb', line 145

def go_to(url, wait: true, retried: false)
  enable_page_events

  if wait
    wait_for_page_load(url, retried: retried)
  else
    page_command("Page.navigate", url: url)
  end

  record_visited_origin(url)
end

#keyboardObject



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

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

#main_frameObject

The top-level frame, or nil if it hasn’t been registered yet (events arrive asynchronously after Page.enable).



462
463
464
# File 'lib/capybara/lightpanda/browser.rb', line 462

def main_frame
  @frames.each_value.find(&:main?)
end

#networkObject



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

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

#nightly_buildObject



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

def nightly_build
  @process&.nightly_build
end

#page_command(method, **params) ⇒ Object



127
128
129
# File 'lib/capybara/lightpanda/browser.rb', line 127

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

#pop_frameObject



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

def pop_frame
  @frame_stack.pop
end

#prepare_modalsObject

– Modal/Dialog Support – Lightpanda auto-dismisses dialogs in headless mode: alert→OK, confirm→false, prompt→null. Page.javascriptDialogOpening fires (since 2026-04-03), so we capture messages for find_modal, but Page.handleJavaScriptDialog always errors with “No dialog is showing” and we never call it (the dispatch thread cannot make synchronous CDP calls without deadlocking). @modal_responses is retained so accept_modal/dismiss_modal preserve their API contract; the accept/dismiss choice is informational only.



484
485
486
487
488
489
490
491
492
493
494
495
# File 'lib/capybara/lightpanda/browser.rb', line 484

def prepare_modals
  return if @modal_handler_installed

  enable_page_events

  on("Page.javascriptDialogOpening") do |params|
    @modal_messages << { type: params["type"], message: params["message"] }
    @modal_responses.shift
  end

  @modal_handler_installed = true
end

#push_frame(node) ⇒ Object

– Frame Support – Two parallel views of frames:

* `frame_stack` (Array<Node>) — the Capybara `switch_to_frame` stack;
  drives where `find` resolves selectors. Stored as Nodes so
  callFunctionOn can scope to the iframe's contentDocument.

* `@frames` (Concurrent::Hash<String, Frame>) — metadata view
  populated from Page.frame{Attached,Navigated,Detached,...} events.
  Used for diagnostics / introspection (frames, main_frame, frame_by).
  Lightpanda's frame events are not reliable enough to drive
  navigation waits, so this is read-only metadata.


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

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

#quitObject



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

def quit
  begin
    @client&.close
  rescue StandardError
    nil
  end
  begin
    @process&.stop
  rescue StandardError
    nil
  end
  @client = nil
  @process = nil
  @started = false
  @modal_handler_installed = false
  @frame_stack.clear
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:



93
94
95
96
97
98
99
100
101
102
103
# File 'lib/capybara/lightpanda/browser.rb', line 93

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)
  create_page
  @page_events_enabled = false
end

#refreshObject Also known as: reload



192
193
194
# File 'lib/capybara/lightpanda/browser.rb', line 192

def refresh
  wait_for_navigation { page_command("Page.reload") }
end

#release_object(remote_object_id) ⇒ Object

Release a remote object reference to free V8 memory.



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

def release_object(remote_object_id)
  page_command("Runtime.releaseObject", objectId: remote_object_id)
rescue BrowserError, NoExecutionContextError
  # Object may already be released or context destroyed
end

#reset_modalsObject



527
528
529
530
# File 'lib/capybara/lightpanda/browser.rb', line 527

def reset_modals
  @modal_responses.clear
  @modal_messages.clear
end

#restartObject



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

def restart
  quit
  start
end

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



344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/capybara/lightpanda/browser.rb', line 344

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

#startObject



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/capybara/lightpanda/browser.rb', line 54

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_page

  @started = true
end

#titleObject



201
202
203
# File 'lib/capybara/lightpanda/browser.rb', line 201

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.



19
20
21
# File 'lib/capybara/lightpanda/browser.rb', line 19

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.



168
169
170
# File 'lib/capybara/lightpanda/browser.rb', line 168

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

#wait_for_idleObject



403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/capybara/lightpanda/browser.rb', line 403

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

#wait_for_turboObject

Wait for any pending Turbo operations to complete. Event-driven: the injected JS in index.js calls ‘console.debug(’__lightpanda_turbo_busy’)‘ when the pending-ops counter rises above 0 and `_idle` when it returns to 0. We toggle @turbo_event accordingly (see subscribe_to_turbo_signals).

Pages without Turbo never trigger _turboStart, so no sentinels fire and for Turbo-loaded pages that have no pending work.



386
387
388
# File 'lib/capybara/lightpanda/browser.rb', line 386

def wait_for_turbo
  @turbo_event.wait(@options.timeout)
end

#with_default_context_wait(timeout: 1.0) ⇒ 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 once. Replaces blind 100 ms sleep retries.



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

def with_default_context_wait(timeout: 1.0)
  yield
rescue NoExecutionContextError
  raise unless wait_for_default_context(timeout)

  yield
end