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

#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



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

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.



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

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

#at_css(selector) ⇒ Object



327
328
329
330
331
# File 'lib/capybara/lightpanda/browser.rb', line 327

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

  result["nodeId"]
end

#backObject



173
174
175
# File 'lib/capybara/lightpanda/browser.rb', line 173

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.



318
319
320
# File 'lib/capybara/lightpanda/browser.rb', line 318

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

#bodyObject Also known as: html



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

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.

Raises:



262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/capybara/lightpanda/browser.rb', line 262

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)
  raise JavaScriptError, response if response["exceptionDetails"]

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

  return_by_value ? result["value"] : result
end

#clear_framesObject



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

def clear_frames
  @frame_stack.clear
end

#command(method, **params) ⇒ Object



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

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

#cookiesObject



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

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

#create_pageObject



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/capybara/lightpanda/browser.rb', line 60

def create_page
  result = @client.command("Target.createTarget", { url: "about:blank" })
  @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



322
323
324
325
# File 'lib/capybara/lightpanda/browser.rb', line 322

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

#current_urlObject



186
187
188
# File 'lib/capybara/lightpanda/browser.rb', line 186

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

#dismiss_modal(_type) ⇒ Object



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

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

#enable_page_eventsObject



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

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.



204
205
206
207
208
209
210
211
212
213
214
# File 'lib/capybara/lightpanda/browser.rb', line 204

def evaluate(expression, *args)
  if args.empty?
    response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
    raise JavaScriptError, response if response["exceptionDetails"]

    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.



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/capybara/lightpanda/browser.rb', line 231

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

Raises:



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

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

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

  result
end

#execute(expression, *args) ⇒ Object

Execute JS without returning a value.



217
218
219
220
221
222
223
224
225
226
# File 'lib/capybara/lightpanda/browser.rb', line 217

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.



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

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



497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# File 'lib/capybara/lightpanda/browser.rb', line 497

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.



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

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

#forwardObject



177
178
179
# File 'lib/capybara/lightpanda/browser.rb', line 177

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

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



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

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



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

def frames
  @frames.values
end

#get_object_properties(remote_object_id) ⇒ Object

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



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

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



135
136
137
138
139
140
141
142
143
# File 'lib/capybara/lightpanda/browser.rb', line 135

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



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

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



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

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

#networkObject



411
412
413
# File 'lib/capybara/lightpanda/browser.rb', line 411

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



117
118
119
# File 'lib/capybara/lightpanda/browser.rb', line 117

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

#pop_frameObject



436
437
438
# File 'lib/capybara/lightpanda/browser.rb', line 436

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.



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

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.


432
433
434
# File 'lib/capybara/lightpanda/browser.rb', line 432

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

#quitObject



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/capybara/lightpanda/browser.rb', line 95

def quit
  begin
    @client&.close
  rescue StandardError
    nil
  end
  begin
    @process&.stop
  rescue StandardError
    nil
  end
  @client = nil
  @process = nil
  @started = false
  @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:



83
84
85
86
87
88
89
90
91
92
93
# File 'lib/capybara/lightpanda/browser.rb', line 83

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

#refreshObject Also known as: reload



181
182
183
# File 'lib/capybara/lightpanda/browser.rb', line 181

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.



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

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

#reset_modalsObject



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

def reset_modals
  @modal_messages.clear
end

#restartObject



76
77
78
79
# File 'lib/capybara/lightpanda/browser.rb', line 76

def restart
  quit
  start
end

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



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/capybara/lightpanda/browser.rb', line 333

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



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

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_page

  @started = true
end

#titleObject



190
191
192
# File 'lib/capybara/lightpanda/browser.rb', line 190

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.



156
157
158
# File 'lib/capybara/lightpanda/browser.rb', line 156

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

#wait_for_idleObject



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

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.



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

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.



166
167
168
169
170
171
# File 'lib/capybara/lightpanda/browser.rb', line 166

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