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.



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

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_handler_installed = false
  @frame_stack = []
  @frames = Concurrent::Hash.new
  @turbo_event = Utils::Event.new
  @turbo_event.set

  start
end

Instance Attribute Details

#browser_context_idObject (readonly)

Returns the value of attribute browser_context_id.



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

def browser_context_id
  @browser_context_id
end

#clientObject (readonly)

Returns the value of attribute client.



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

def client
  @client
end

#frame_stackObject (readonly)

Returns the value of attribute frame_stack.



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

def frame_stack
  @frame_stack
end

#optionsObject (readonly)

Returns the value of attribute options.



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

def options
  @options
end

#processObject (readonly)

Returns the value of attribute process.



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

def process
  @process
end

#session_idObject (readonly)

Returns the value of attribute session_id.



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

def session_id
  @session_id
end

#target_idObject (readonly)

Returns the value of attribute target_id.



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

def target_id
  @target_id
end

Instance Method Details

#accept_modal(_type, text: nil) ⇒ Object



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

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.



407
408
409
410
# File 'lib/capybara/lightpanda/browser.rb', line 407

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

#at_css(selector) ⇒ Object



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

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

  result["nodeId"]
end

#backObject



219
220
221
# File 'lib/capybara/lightpanda/browser.rb', line 219

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.



415
416
417
# File 'lib/capybara/lightpanda/browser.rb', line 415

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

#bodyObject Also known as: html



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

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.



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

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



537
538
539
# File 'lib/capybara/lightpanda/browser.rb', line 537

def clear_frames
  @frame_stack.clear
end

#command(method, **params) ⇒ Object



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

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

#cookiesObject



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

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.



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

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

#create_pageObject



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

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

  @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



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

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

#current_urlObject



232
233
234
# File 'lib/capybara/lightpanda/browser.rb', line 232

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.



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

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



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

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

#enable_page_eventsObject



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

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.



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/capybara/lightpanda/browser.rb', line 269

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.



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/capybara/lightpanda/browser.rb', line 320

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



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

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



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/capybara/lightpanda/browser.rb', line 292

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.



389
390
391
392
393
394
395
# File 'lib/capybara/lightpanda/browser.rb', line 389

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



594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# File 'lib/capybara/lightpanda/browser.rb', line 594

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.



399
400
401
402
403
404
# File 'lib/capybara/lightpanda/browser.rb', line 399

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)
rescue JavaScriptError => e
  raise_invalid_selector(e, method, selector)
end

#forwardObject



223
224
225
# File 'lib/capybara/lightpanda/browser.rb', line 223

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

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



552
553
554
555
556
557
558
# File 'lib/capybara/lightpanda/browser.rb', line 552

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



542
543
544
# File 'lib/capybara/lightpanda/browser.rb', line 542

def frames
  @frames.values
end

#get_object_properties(remote_object_id) ⇒ Object

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



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

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



181
182
183
184
185
186
187
188
189
# File 'lib/capybara/lightpanda/browser.rb', line 181

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



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

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



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

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

#networkObject



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

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

#nightly_buildObject



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

def nightly_build
  @process&.nightly_build
end

#page_command(method, **params) ⇒ Object



163
164
165
# File 'lib/capybara/lightpanda/browser.rb', line 163

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

#pop_frameObject



533
534
535
# File 'lib/capybara/lightpanda/browser.rb', line 533

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.



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

def prepare_modals
  return if @modal_handler_installed

  enable_page_events

  on("Page.javascriptDialogOpening") do |params|
    @modal_messages << { type: params["type"], message: params["message"] }
  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.


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

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

#quitObject



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/capybara/lightpanda/browser.rb', line 138

def quit
  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
  @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:



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

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

#refreshObject Also known as: reload



227
228
229
# File 'lib/capybara/lightpanda/browser.rb', line 227

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.



381
382
383
384
385
# File 'lib/capybara/lightpanda/browser.rb', line 381

def release_object(remote_object_id)
  page_command("Runtime.releaseObject", objectId: remote_object_id)
rescue BrowserError
  # Object may already be released or context destroyed
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.



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

def reset
  dispose_browser_context
  @client.clear_subscriptions
  @page_events_enabled = false
  @modal_handler_installed = false
  @modal_messages.clear
  @frame_stack.clear
  # 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
  create_browser_context
  create_page
end

#reset_modalsObject



614
615
616
# File 'lib/capybara/lightpanda/browser.rb', line 614

def reset_modals
  @modal_messages.clear
end

#restartObject



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

def restart
  quit
  start
end

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



430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/capybara/lightpanda/browser.rb', line 430

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



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/capybara/lightpanda/browser.rb', line 45

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
end

#titleObject



236
237
238
# File 'lib/capybara/lightpanda/browser.rb', line 236

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.



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

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.



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

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

#wait_for_idleObject



489
490
491
492
493
494
495
496
497
498
499
500
501
502
# File 'lib/capybara/lightpanda/browser.rb', line 489

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.



472
473
474
# File 'lib/capybara/lightpanda/browser.rb', line 472

def wait_for_turbo
  @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.



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

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