Class: Capybara::Lightpanda::Browser

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

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ Browser

Returns a new instance of Browser.



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

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
  @frame_stack = []
  @turbo_event = Utils::Event.new
  @turbo_event.set
  @last_navigation_response = nil
  @document_request_id = nil

  start
end

Instance Attribute Details

#browser_context_idObject (readonly)

Returns the value of attribute browser_context_id.



10
11
12
# File 'lib/capybara/lightpanda/browser.rb', line 10

def browser_context_id
  @browser_context_id
end

#clientObject (readonly)

Returns the value of attribute client.



10
11
12
# File 'lib/capybara/lightpanda/browser.rb', line 10

def client
  @client
end

#frame_stackObject (readonly)

Returns the value of attribute frame_stack.



10
11
12
# File 'lib/capybara/lightpanda/browser.rb', line 10

def frame_stack
  @frame_stack
end

#optionsObject (readonly)

Returns the value of attribute options.



10
11
12
# File 'lib/capybara/lightpanda/browser.rb', line 10

def options
  @options
end

#processObject (readonly)

Returns the value of attribute process.



10
11
12
# File 'lib/capybara/lightpanda/browser.rb', line 10

def process
  @process
end

#session_idObject (readonly)

Returns the value of attribute session_id.



10
11
12
# File 'lib/capybara/lightpanda/browser.rb', line 10

def session_id
  @session_id
end

#target_idObject (readonly)

Returns the value of attribute target_id.



10
11
12
# File 'lib/capybara/lightpanda/browser.rb', line 10

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.



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

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

.track(browser) ⇒ Object



31
32
33
34
35
36
37
38
39
# File 'lib/capybara/lightpanda/browser.rb', line 31

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



41
42
43
# File 'lib/capybara/lightpanda/browser.rb', line 41

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

Instance Method Details

#accept_modal(_type, text: nil) ⇒ Object



631
632
633
634
635
636
# File 'lib/capybara/lightpanda/browser.rb', line 631

def accept_modal(_type, text: nil)
  prepare_modals
  params = { accept: true }
  params[:promptText] = text if text
  page_command("LP.handleJavaScriptDialog", **params)
end

#active_elementObject

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



494
495
496
497
# File 'lib/capybara/lightpanda/browser.rb', line 494

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

#backObject



267
268
269
# File 'lib/capybara/lightpanda/browser.rb', line 267

def back
  wait_for_navigation { navigate_history(-1) }
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.



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

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

#bodyObject Also known as: html



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

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

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



417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/capybara/lightpanda/browser.rb', line 417

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)
  if response["exceptionDetails"]
    debug_js_failure("call_function_on", function_declaration, response)
    raise JavaScriptError, response
  end

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

  return_by_value ? result["value"] : result
end

#clear_framesObject



604
605
606
# File 'lib/capybara/lightpanda/browser.rb', line 604

def clear_frames
  @frame_stack.clear
end

#clear_session_stateObject

Per-session in-memory state that must be wiped whenever the underlying CDP connection is replaced (#reset disposes the BrowserContext, #reconnect builds a fresh Client). Without this, a mid-test process crash leaves stale frame_stack Nodes (whose objectIds belong to the dead V8 context) and a ‘@modal_handler_installed = true` flag that makes prepare_modals short-circuit on the new client, so find_modal silently sees no javascriptDialogOpening events.



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

def clear_session_state
  @page_events_enabled = false
  @modal_handler_installed = false
  @modal_messages_mutex.synchronize { @modal_messages.clear }
  @last_navigation_response = nil
  @document_request_id = nil
  clear_frames
  # Network#reset, not #clear: disposing the BrowserContext also
  # destroyed the Network domain and its subscriptions, so we must
  # flip @enabled back to false — otherwise the next #enable
  # short-circuits and traffic tracking is silently dead.
  @network&.reset
end

#command(method, **params) ⇒ Object



207
208
209
# File 'lib/capybara/lightpanda/browser.rb', line 207

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

#cookiesObject



587
588
589
# File 'lib/capybara/lightpanda/browser.rb', line 587

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

#create_browser_contextObject

Per-session BrowserContext (Chrome’s incognito-profile primitive). Cookies, storage, and targets created within the context are wiped when it’s disposed — so ‘reset` is one CDP call instead of an explicit cookies.clear / storage.clear / close-target dance. Mirrors ferrum’s Contexts model.



112
113
114
115
# File 'lib/capybara/lightpanda/browser.rb', line 112

def create_browser_context
  result = @client.command("Target.createBrowserContext")
  @browser_context_id = result["browserContextId"]
end

#create_pageObject



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

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

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

  @turbo_event.set
  subscribe_to_console_logs
  subscribe_to_execution_context
  subscribe_to_turbo_signals
  subscribe_to_navigation_response
  register_auto_scripts
end

#current_urlObject



280
281
282
# File 'lib/capybara/lightpanda/browser.rb', line 280

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

#debug_js_failure(site, expression, response) ⇒ Object

When LIGHTPANDA_DEBUG=1 is set, log the JS expression and full CDP response for every JsException to STDERR. Invaluable for isolating which exact JS triggers an upstream Lightpanda bug.



374
375
376
377
378
# File 'lib/capybara/lightpanda/browser.rb', line 374

def debug_js_failure(site, expression, response)
  return unless ENV["LIGHTPANDA_DEBUG"]

  warn "[lightpanda:#{site}] expression:\n#{expression}\n[lightpanda:#{site}] response:\n#{response.inspect}\n"
end

#dismiss_modal(_type) ⇒ Object



638
639
640
641
# File 'lib/capybara/lightpanda/browser.rb', line 638

def dismiss_modal(_type)
  prepare_modals
  page_command("LP.handleJavaScriptDialog", accept: false)
end

#enable_page_eventsObject



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

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.

Even the no-args path wraps the expression in an IIFE to isolate top-level ‘const`/`let` declarations. Upstream Lightpanda retains those bindings across `Runtime.evaluate` calls (V8 starts each call with fresh lexical scope per spec), so a second `const sel = …` raises `SyntaxError: Identifier ’sel’ has already been declared`. Wrapping pushes the declarations into a function scope that gets discarded when the IIFE returns.

Use direct ‘eval` inside the IIFE so the user’s text can be a bare expression (‘’foo’‘), a `throw` statement, OR a multi-statement script with `const`/`let`. `eval`’s completion-value semantics return the last expression’s value in all cases. A naive ‘return EXPR;` wrap would syntax-error on `throw …` and on multi-statement scripts.



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/capybara/lightpanda/browser.rb', line 332

def evaluate(expression, *args)
  if args.empty?
    wrapped = "(function(){return eval(#{expression.to_json})})()"
    response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: true)
    if response["exceptionDetails"]
      debug_js_failure("evaluate", expression, response)
      raise JavaScriptError, response
    end

    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.



383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/capybara/lightpanda/browser.rb', line 383

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



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

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

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

  result
end

#execute(expression, *args) ⇒ Object

Execute JS without returning a value.

Like ‘evaluate`, the no-args path wraps in an IIFE — same upstream `const`/`let` leak. Also raises on JS exceptions so silent failures don’t mask test bugs (the previous fast path swallowed them because ‘awaitPromise: false` was checked but `exceptionDetails` was not).



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/capybara/lightpanda/browser.rb', line 355

def execute(expression, *args)
  if args.empty?
    wrapped = "(function(){#{expression}})()"
    response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: false)
    if response["exceptionDetails"]
      debug_js_failure("execute", expression, response)
      raise JavaScriptError, response
    end
    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.



456
457
458
459
460
461
462
# File 'lib/capybara/lightpanda/browser.rb', line 456

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



643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
# File 'lib/capybara/lightpanda/browser.rb', line 643

def find_modal(type, text: nil, wait: options.timeout)
  regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
  last_matching_type_message = nil
  last_seen_message = nil
  claimed = nil
  Utils::Wait.until(timeout: wait, interval: 0.05) do
    claimed = pop_modal_message(type.to_s, regexp)
    next true if claimed

    last = peek_last_modal_message(type.to_s)
    last_matching_type_message = last[:matching_type] || last_matching_type_message
    last_seen_message = last[:any] || last_seen_message
    false
  end
  claimed[:message]
rescue TimeoutError
  raise_modal_not_found(type, text, last_matching_type_message, last_seen_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.

Wrapped in ‘with_default_context_wait` so a click that triggered a navigation immediately before the find (e.g. a fill_in following a link that mutated the DOM) doesn’t race against ‘Runtime.executionContextCreated` and surface as `NoExecutionContextError`. `find_in_document` and `find_in_frame` already use the same wrapper; `find_within` was the odd one out.



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

def find_within(remote_object_id, method, selector)
  with_default_context_wait do
    result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
    extract_node_object_ids(result)
  end
rescue JavaScriptError => e
  raise_invalid_selector(e, method, selector)
end

#forwardObject



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

def forward
  wait_for_navigation { navigate_history(+1) }
end

#get_object_properties(remote_object_id) ⇒ Object

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



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

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



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

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
end

#keyboardObject



579
580
581
# File 'lib/capybara/lightpanda/browser.rb', line 579

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

#networkObject



583
584
585
# File 'lib/capybara/lightpanda/browser.rb', line 583

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

#nightly_buildObject



64
65
66
# File 'lib/capybara/lightpanda/browser.rb', line 64

def nightly_build
  @process&.nightly_build
end

#page_command(method, **params) ⇒ Object



211
212
213
# File 'lib/capybara/lightpanda/browser.rb', line 211

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

#parents_of(remote_object_id) ⇒ Object

Ancestor chain of ‘remote_object_id` from parentNode up to (but excluding) `document`, returned as an array of remote object IDs. Mirrors Cuprite’s JS ‘parents` helper. Same `with_default_context_wait` wrapping as `find_within` — same race window applies.



486
487
488
489
490
491
# File 'lib/capybara/lightpanda/browser.rb', line 486

def parents_of(remote_object_id)
  with_default_context_wait do
    result = call_function_on(remote_object_id, PARENTS_JS, return_by_value: false)
    extract_node_object_ids(result)
  end
end

#pop_frameObject



600
601
602
# File 'lib/capybara/lightpanda/browser.rb', line 600

def pop_frame
  @frame_stack.pop
end

#prepare_modalsObject

– Modal/Dialog Support – Lightpanda’s JS dialogs (alert/confirm/prompt) are driven via the ‘LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900): the client sends `LP.handleJavaScriptDialog promptText` BEFORE the action that triggers the dialog, and the response is consumed when the dialog opens. `Page.javascriptDialogOpening` still fires, so we capture the message text for `find_modal`. Single-shot: `pending_dialog_response` is one slot, so a second pre-arm before the first dialog opens overwrites the first.



618
619
620
621
622
623
624
625
626
627
628
629
# File 'lib/capybara/lightpanda/browser.rb', line 618

def prepare_modals
  return if @modal_handler_installed

  enable_page_events

  on("Page.javascriptDialogOpening") do |params|
    entry = { type: params["type"], message: params["message"] }
    @modal_messages_mutex.synchronize { @modal_messages << entry }
  end

  @modal_handler_installed = true
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.



596
597
598
# File 'lib/capybara/lightpanda/browser.rb', line 596

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

#quitObject



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/capybara/lightpanda/browser.rb', line 185

def quit
  self.class.untrack(self)
  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:



149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/capybara/lightpanda/browser.rb', line 149

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

#refreshObject Also known as: reload



275
276
277
# File 'lib/capybara/lightpanda/browser.rb', line 275

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. Cleanup is best-effort: callers wrap their work in ‘ensure release_object(…)`, so a TimeoutError or transport hiccup here must not propagate out of the ensure block and bury the original failure.



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

def release_object(remote_object_id)
  page_command("Runtime.releaseObject", objectId: remote_object_id)
rescue Error
  # Object may already be released, context destroyed, or the CDP call
  # itself timed out / failed in transport.
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.



139
140
141
142
143
144
145
# File 'lib/capybara/lightpanda/browser.rb', line 139

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.



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

def response_headers
  raw = @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



517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
# File 'lib/capybara/lightpanda/browser.rb', line 517

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



513
514
515
# File 'lib/capybara/lightpanda/browser.rb', line 513

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

#startObject



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

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. Driven by the Network.responseReceived subscription installed in create_page.



300
301
302
# File 'lib/capybara/lightpanda/browser.rb', line 300

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

#titleObject



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

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.



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

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.



250
251
252
# File 'lib/capybara/lightpanda/browser.rb', line 250

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

#wait_for_idleObject



564
565
566
567
568
569
570
571
572
573
574
575
576
577
# File 'lib/capybara/lightpanda/browser.rb', line 564

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.



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

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