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



625
626
627
628
629
630
# File 'lib/capybara/lightpanda/browser.rb', line 625

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.



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

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

#backObject



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

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.



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

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

#bodyObject Also known as: html



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

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.



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

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



598
599
600
# File 'lib/capybara/lightpanda/browser.rb', line 598

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.



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

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



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

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

#cookiesObject



581
582
583
# File 'lib/capybara/lightpanda/browser.rb', line 581

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



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

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.



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

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



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

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

#enable_page_eventsObject



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

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.



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

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.



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

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



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

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



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

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.



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

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



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

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.



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

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



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

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



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

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



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

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



573
574
575
# File 'lib/capybara/lightpanda/browser.rb', line 573

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

#networkObject



577
578
579
# File 'lib/capybara/lightpanda/browser.rb', line 577

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



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

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.



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

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



594
595
596
# File 'lib/capybara/lightpanda/browser.rb', line 594

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.



612
613
614
615
616
617
618
619
620
621
622
623
# File 'lib/capybara/lightpanda/browser.rb', line 612

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.



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

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

#quitObject



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

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:



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

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



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

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.



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

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.

Side benefit: avoids ‘Page.navigate(“about:blank”)` against a non-blank tab, which doesn’t actually replace the document on current Lightpanda nightly (lightpanda-io/browser#2363). The context-disposal path sidesteps that bug entirely.



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

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.



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

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



511
512
513
514
515
516
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
# File 'lib/capybara/lightpanda/browser.rb', line 511

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



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.



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

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

#titleObject



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

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.



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

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

#wait_for_idleObject



558
559
560
561
562
563
564
565
566
567
568
569
570
571
# File 'lib/capybara/lightpanda/browser.rb', line 558

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.



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

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