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
88
89
# 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
  @console_logs = []
  @console_logs_mutex = Mutex.new
  @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



651
652
653
654
655
656
# File 'lib/capybara/lightpanda/browser.rb', line 651

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.



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

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

#backObject



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

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.



506
507
508
# File 'lib/capybara/lightpanda/browser.rb', line 506

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

#bodyObject Also known as: html



292
293
294
295
296
297
298
# File 'lib/capybara/lightpanda/browser.rb', line 292

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.



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

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_console_logsObject



607
608
609
# File 'lib/capybara/lightpanda/browser.rb', line 607

def clear_console_logs
  @console_logs_mutex.synchronize { @console_logs.clear }
end

#clear_framesObject



624
625
626
# File 'lib/capybara/lightpanda/browser.rb', line 624

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.



174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/capybara/lightpanda/browser.rb', line 174

def clear_session_state
  @page_events_enabled = false
  @modal_handler_installed = false
  @modal_messages_mutex.synchronize { @modal_messages.clear }
  @console_logs_mutex.synchronize { @console_logs.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



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

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

#console_logsObject

Console messages captured from ‘Runtime.consoleAPICalled` since the last `reset` (Turbo-tracker sentinels excluded). Loose hashes, like Network#traffic: `text:, timestamp:, args:` where `type` is the console method name (“log”, “error”, “warning”, …), `text` joins the arguments’ primitive values/descriptions, and ‘args` keeps the raw CDP RemoteObjects. Lets suites assert on JS console errors (`browser.console_logs.select { |m| m == “error” }`) the way peer drivers do via custom Ferrum loggers.



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

def console_logs
  @console_logs_mutex.synchronize { @console_logs.dup }
end

#cookiesObject



591
592
593
# File 'lib/capybara/lightpanda/browser.rb', line 591

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.



114
115
116
117
# File 'lib/capybara/lightpanda/browser.rb', line 114

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

#create_pageObject



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

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_console_capture
  subscribe_to_execution_context
  subscribe_to_turbo_signals
  subscribe_to_navigation_response
  register_auto_scripts
end

#current_urlObject



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

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.



378
379
380
381
382
# File 'lib/capybara/lightpanda/browser.rb', line 378

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



658
659
660
661
# File 'lib/capybara/lightpanda/browser.rb', line 658

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

#enable_page_eventsObject



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

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.



336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/capybara/lightpanda/browser.rb', line 336

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.



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/capybara/lightpanda/browser.rb', line 387

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



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

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



359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/capybara/lightpanda/browser.rb', line 359

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.



460
461
462
463
464
465
466
# File 'lib/capybara/lightpanda/browser.rb', line 460

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

‘type` is accepted for the error message only: like Selenium (where alert/confirm are indistinguishable) and Cuprite (whose dialog handler accepts whatever fires), we deliberately do NOT reject a dialog whose reported type differs from the one Capybara asked for. Real suites wrap `data-confirm` deletes in `accept_alert` (e.g. solidus admin) and expect it to work; only the message text is matched.



669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
# File 'lib/capybara/lightpanda/browser.rb', line 669

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

    last_seen_message = peek_last_modal_message || last_seen_message
    false
  end
  claimed[:message]
rescue TimeoutError
  raise_modal_not_found(type, text, 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.



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

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



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

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



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

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



233
234
235
236
237
238
239
240
241
# File 'lib/capybara/lightpanda/browser.rb', line 233

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



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

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

#networkObject



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

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



215
216
217
# File 'lib/capybara/lightpanda/browser.rb', line 215

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.



490
491
492
493
494
495
# File 'lib/capybara/lightpanda/browser.rb', line 490

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



620
621
622
# File 'lib/capybara/lightpanda/browser.rb', line 620

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.



638
639
640
641
642
643
644
645
646
647
648
649
# File 'lib/capybara/lightpanda/browser.rb', line 638

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.



616
617
618
# File 'lib/capybara/lightpanda/browser.rb', line 616

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

#quitObject



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/capybara/lightpanda/browser.rb', line 189

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:



152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/capybara/lightpanda/browser.rb', line 152

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



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

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.



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

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.



142
143
144
145
146
147
148
# File 'lib/capybara/lightpanda/browser.rb', line 142

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.



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

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



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
550
551
552
553
# File 'lib/capybara/lightpanda/browser.rb', line 521

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



517
518
519
# File 'lib/capybara/lightpanda/browser.rb', line 517

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

#startObject



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

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.



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

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

#titleObject



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

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.



254
255
256
# File 'lib/capybara/lightpanda/browser.rb', line 254

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

#wait_for_idleObject



568
569
570
571
572
573
574
575
576
577
578
579
580
581
# File 'lib/capybara/lightpanda/browser.rb', line 568

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.



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

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