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



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

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.



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

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

#at_css(selector) ⇒ Object



393
394
395
396
397
# File 'lib/capybara/lightpanda/browser.rb', line 393

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.



384
385
386
# File 'lib/capybara/lightpanda/browser.rb', line 384

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
# File 'lib/capybara/lightpanda/browser.rb', line 240

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

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

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



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/capybara/lightpanda/browser.rb', line 323

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



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

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



481
482
483
# File 'lib/capybara/lightpanda/browser.rb', line 481

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



388
389
390
391
# File 'lib/capybara/lightpanda/browser.rb', line 388

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.



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

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



558
559
560
561
# File 'lib/capybara/lightpanda/browser.rb', line 558

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.



250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/capybara/lightpanda/browser.rb', line 250

def evaluate(expression, *args)
  if args.empty?
    response = page_command("Runtime.evaluate", expression: expression, 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.



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

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



308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/capybara/lightpanda/browser.rb', line 308

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.



266
267
268
269
270
271
272
273
274
275
# File 'lib/capybara/lightpanda/browser.rb', line 266

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

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

#find(method, selector) ⇒ Object

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



358
359
360
361
362
363
364
# File 'lib/capybara/lightpanda/browser.rb', line 358

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



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

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.



368
369
370
371
372
373
# File 'lib/capybara/lightpanda/browser.rb', line 368

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



521
522
523
524
525
526
527
# File 'lib/capybara/lightpanda/browser.rb', line 521

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



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

def frames
  @frames.values
end

#get_object_properties(remote_object_id) ⇒ Object

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



345
346
347
# File 'lib/capybara/lightpanda/browser.rb', line 345

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



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

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



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

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

#networkObject



477
478
479
# File 'lib/capybara/lightpanda/browser.rb', line 477

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



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

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.



539
540
541
542
543
544
545
546
547
548
549
# File 'lib/capybara/lightpanda/browser.rb', line 539

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.


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

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.



350
351
352
353
354
# File 'lib/capybara/lightpanda/browser.rb', line 350

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



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

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



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# File 'lib/capybara/lightpanda/browser.rb', line 399

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



458
459
460
461
462
463
464
465
466
467
468
469
470
471
# File 'lib/capybara/lightpanda/browser.rb', line 458

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.



441
442
443
# File 'lib/capybara/lightpanda/browser.rb', line 441

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