Class: Capybara::Lightpanda::Browser
- Inherits:
-
Object
- Object
- Capybara::Lightpanda::Browser
- Extended by:
- Forwardable
- Includes:
- Console, Finder, Modals, Navigation, Runtime, SeleniumCompat
- Defined in:
- lib/capybara/lightpanda/browser.rb,
lib/capybara/lightpanda/browser/finder.rb,
lib/capybara/lightpanda/browser/modals.rb,
lib/capybara/lightpanda/browser/console.rb,
lib/capybara/lightpanda/browser/runtime.rb,
lib/capybara/lightpanda/browser/navigation.rb,
lib/capybara/lightpanda/browser/selenium_compat.rb
Defined Under Namespace
Modules: Console, Finder, Modals, Navigation, Runtime, SeleniumCompat
Constant Summary collapse
- NODE_MARKER =
Sentinel key marking a serialized DOM node in JS-result payloads. Produced by #unwrap_call_result / #serialize_remote_array, consumed by Driver#unwrap_script_result, which wraps the objectId in a Node.
"__lightpanda_node__"
Constants included from SeleniumCompat
SeleniumCompat::CONSOLE_LEVELS
Instance Attribute Summary collapse
-
#browser_context_id ⇒ Object
readonly
Returns the value of attribute browser_context_id.
-
#client ⇒ Object
readonly
Returns the value of attribute client.
-
#frame_stack ⇒ Object
readonly
Returns the value of attribute frame_stack.
-
#options ⇒ Object
readonly
Returns the value of attribute options.
-
#process ⇒ Object
readonly
Returns the value of attribute process.
-
#session_id ⇒ Object
readonly
Returns the value of attribute session_id.
-
#target_id ⇒ Object
readonly
Returns the value of attribute target_id.
Class Method Summary collapse
-
.quit_all ⇒ Object
at_exit handler: close every live browser's CDP WebSocket (via #quit) before its Process finalizer can SIGTERM the binary.
- .track(browser) ⇒ Object
- .untrack(browser) ⇒ Object
Instance Method Summary collapse
-
#active_element ⇒ Object
objectId of document.activeElement, or nil if none/document detached.
-
#alive? ⇒ Boolean
Liveness of the CDP transport.
-
#backend_node_id(remote_object_id) ⇒ Object
Resolve an objectId to its stable per-page backendNodeId.
- #body ⇒ Object (also: #html)
- #clear_frames ⇒ Object
- #command(method, **params) ⇒ Object
-
#configured_download_path ⇒ Object
Download destination: explicit :save_path option wins, else Capybara.save_path (Cuprite parity).
- #cookies ⇒ Object
- #current_url ⇒ Object
- #downloads ⇒ Object
- #frame_title ⇒ Object
-
#frame_url ⇒ Object
Capybara::Driver::Base resolves frame_url/frame_title via the top execution context, which always reports the parent document.
-
#initialize(options = {}) ⇒ Browser
constructor
A new instance of Browser.
- #keyboard ⇒ Object
- #network ⇒ Object
-
#nightly_build ⇒ Object
Set on the nightly/dev channel only; nil for a tagged release, which carries no build counter.
- #page_command(method, **params) ⇒ Object
- #pop_frame ⇒ Object
-
#push_frame(node) ⇒ Object
-- Frame Support --
frame_stack(Array) is the Capybara switch_to_framestack; it drives wherefindresolves selectors. - #quit ⇒ Object
-
#reconnect ⇒ Object
Recover after a WebSocket disconnect or process crash during navigation.
- #release ⇒ Object
-
#reset ⇒ Object
Wipe per-session state — cookies, storage, all targets — and start over with a fresh BrowserContext.
-
#response_headers ⇒ Object
Response headers of the last document navigation, wrapped in a Headers instance so
["Content-Type"]works despite CDP lowercasing keys. - #screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary) ⇒ Object
-
#set_file_input_files(remote_object_id, paths) ⇒ Object
Populate a file from one or more local file paths via DOM.setFileInputFiles (PR #2635, build ≥6625): Lightpanda resolves the objectId, replaces input.files with a real FileList, and fires
input/change. -
#set_viewport(width = nil, height = nil) ⇒ Object
Apply the
window_sizeoption as a JS-visible viewport: it drives window.innerWidth/innerHeight and the viewportmatchMedia/@mediaevaluate against, so responsive branches resolve at the requested size. - #start ⇒ Object
-
#status_code ⇒ Object
HTTP status of the last document navigation; nil before the first navigation completes.
- #title ⇒ Object
-
#version ⇒ Object
Lightpanda binary version (e.g. "lightpanda 0.2.9 nightly.5267") and parsed nightly build number, captured at Process startup.
-
#viewport_size ⇒ Object
The viewport as the page currently sees it.
-
#wait_for_default_context(timeout = 1.0) ⇒ Object
Block up to
timeoutseconds for a default V8 execution context to exist. - #wait_for_idle ⇒ Object
-
#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.
Methods included from SeleniumCompat
#execute_async_script, #execute_cdp, #logs, #switch_to
Methods included from Console
#clear_console_logs, #clear_page_errors, #console_logs, #page_errors
Methods included from Modals
#accept_modal, #check_unhandled_modal!, #dismiss_modal, #find_modal
Methods included from Navigation
#back, #forward, #go_to, #refresh
Methods included from Finder
#find, #find_within, #parents_of
Methods included from Runtime
#call_function_on, #evaluate, #evaluate_async, #evaluate_with_ref, #execute, #release_object
Constructor Details
#initialize(options = {}) ⇒ Browser
Returns a new instance of Browser.
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/capybara/lightpanda/browser.rb', line 94 def initialize( = {}) @options = Options.new() @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 @modal_armed = false @unhandled_modal = nil @console_logs = [] @console_logs_mutex = Mutex.new @page_errors = [] @page_errors_mutex = Mutex.new @frame_stack = [] @turbo_event = Utils::Event.new @turbo_event.set start end |
Instance Attribute Details
#browser_context_id ⇒ Object (readonly)
Returns the value of attribute browser_context_id.
24 25 26 |
# File 'lib/capybara/lightpanda/browser.rb', line 24 def browser_context_id @browser_context_id end |
#client ⇒ Object (readonly)
Returns the value of attribute client.
24 25 26 |
# File 'lib/capybara/lightpanda/browser.rb', line 24 def client @client end |
#frame_stack ⇒ Object (readonly)
Returns the value of attribute frame_stack.
24 25 26 |
# File 'lib/capybara/lightpanda/browser.rb', line 24 def frame_stack @frame_stack end |
#options ⇒ Object (readonly)
Returns the value of attribute options.
24 25 26 |
# File 'lib/capybara/lightpanda/browser.rb', line 24 def @options end |
#process ⇒ Object (readonly)
Returns the value of attribute process.
24 25 26 |
# File 'lib/capybara/lightpanda/browser.rb', line 24 def process @process end |
#session_id ⇒ Object (readonly)
Returns the value of attribute session_id.
24 25 26 |
# File 'lib/capybara/lightpanda/browser.rb', line 24 def session_id @session_id end |
#target_id ⇒ Object (readonly)
Returns the value of attribute target_id.
24 25 26 |
# File 'lib/capybara/lightpanda/browser.rb', line 24 def target_id @target_id end |
Class Method Details
.quit_all ⇒ Object
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.
67 68 69 70 71 72 73 |
# File 'lib/capybara/lightpanda/browser.rb', line 67 def quit_all @live_mutex.synchronize { @live.dup }.each do |browser| browser.quit rescue StandardError nil end end |
.track(browser) ⇒ Object
50 51 52 53 54 55 56 57 58 |
# File 'lib/capybara/lightpanda/browser.rb', line 50 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
60 61 62 |
# File 'lib/capybara/lightpanda/browser.rb', line 60 def untrack(browser) @live_mutex.synchronize { @live.delete(browser) } end |
Instance Method Details
#active_element ⇒ Object
objectId of document.activeElement, or nil if none/document detached.
353 354 355 356 |
# File 'lib/capybara/lightpanda/browser.rb', line 353 def active_element result = evaluate_with_ref("document.activeElement") result&.dig("objectId") end |
#alive? ⇒ Boolean
Liveness of the CDP transport. Driver#browser checks this to decide whether to respawn a dead browser.
246 247 248 249 250 |
# File 'lib/capybara/lightpanda/browser.rb', line 246 def alive? !client.nil? && !client.closed? rescue StandardError false 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.
361 362 363 |
# File 'lib/capybara/lightpanda/browser.rb', line 361 def backend_node_id(remote_object_id) page_command("DOM.describeNode", objectId: remote_object_id).dig("node", "backendNodeId") end |
#body ⇒ Object Also known as: html
328 329 330 331 332 333 334 |
# File 'lib/capybara/lightpanda/browser.rb', line 328 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 |
#clear_frames ⇒ Object
468 469 470 |
# File 'lib/capybara/lightpanda/browser.rb', line 468 def clear_frames @frame_stack.clear end |
#command(method, **params) ⇒ Object
285 286 287 |
# File 'lib/capybara/lightpanda/browser.rb', line 285 def command(method, **params) @client.command(method, params) end |
#configured_download_path ⇒ Object
Download destination: explicit :save_path option wins, else Capybara.save_path (Cuprite parity). nil => downloads stay off.
179 180 181 182 |
# File 'lib/capybara/lightpanda/browser.rb', line 179 def configured_download_path @options.save_path || (defined?(Capybara) && Capybara.respond_to?(:save_path) ? Capybara.save_path : nil) end |
#cookies ⇒ Object
451 452 453 |
# File 'lib/capybara/lightpanda/browser.rb', line 451 def @cookies ||= Cookies.new(self) end |
#current_url ⇒ Object
320 321 322 |
# File 'lib/capybara/lightpanda/browser.rb', line 320 def current_url evaluate("window.location.href") end |
#downloads ⇒ Object
447 448 449 |
# File 'lib/capybara/lightpanda/browser.rb', line 447 def downloads @downloads ||= Downloads.new(self) end |
#frame_title ⇒ Object
483 484 485 486 487 488 |
# File 'lib/capybara/lightpanda/browser.rb', line 483 def frame_title frame = frame_stack.last return title unless frame call_function_on(frame.remote_object_id, FRAME_TITLE_JS) end |
#frame_url ⇒ Object
Capybara::Driver::Base resolves frame_url/frame_title via the top execution context, which always reports the parent document. Resolve them through the iframe element's contentWindow / contentDocument so they reflect the active frame.
476 477 478 479 480 481 |
# File 'lib/capybara/lightpanda/browser.rb', line 476 def frame_url frame = frame_stack.last return current_url unless frame call_function_on(frame.remote_object_id, FRAME_URL_JS) end |
#keyboard ⇒ Object
439 440 441 |
# File 'lib/capybara/lightpanda/browser.rb', line 439 def keyboard @keyboard ||= Keyboard.new(self) end |
#network ⇒ Object
443 444 445 |
# File 'lib/capybara/lightpanda/browser.rb', line 443 def network @network ||= Network.new(self) end |
#nightly_build ⇒ Object
Set on the nightly/dev channel only; nil for a tagged release, which
carries no build counter. release is its mirror image — exactly one of
the two is non-nil once the process has started.
86 87 88 |
# File 'lib/capybara/lightpanda/browser.rb', line 86 def nightly_build @process&.nightly_build end |
#page_command(method, **params) ⇒ Object
289 290 291 |
# File 'lib/capybara/lightpanda/browser.rb', line 289 def page_command(method, **params) @client.command(method, params, session_id: @session_id) end |
#pop_frame ⇒ Object
464 465 466 |
# File 'lib/capybara/lightpanda/browser.rb', line 464 def pop_frame @frame_stack.pop end |
#push_frame(node) ⇒ Object
-- Frame Support --
frame_stack (Arrayswitch_to_frame stack;
it drives where find resolves selectors. Stored as Nodes so
callFunctionOn can scope to the iframe's contentDocument.
460 461 462 |
# File 'lib/capybara/lightpanda/browser.rb', line 460 def push_frame(node) @frame_stack.push(node) end |
#quit ⇒ Object
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 |
# File 'lib/capybara/lightpanda/browser.rb', line 252 def quit self.class.untrack(self) # Flip Network back to disabled so a later #start re-installs its # subscriptions — without this, quit→start reuse of the same # instance leaves @enabled true and create_page's network.enable # no-ops, silently killing status_code/traffic capture. Guarded on # @client: with no client the handlers are already moot and # unsubscribe would have nothing to detach from. Downloads carries the # same subscription contract, so it resets under the same guard. if @client @network&.reset @downloads&.reset end 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 |
#reconnect ⇒ Object
Recover after a WebSocket disconnect or process crash during navigation. Restarts the process if it died, then creates a fresh client and page.
200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/capybara/lightpanda/browser.rb', line 200 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 |
#release ⇒ Object
90 91 92 |
# File 'lib/capybara/lightpanda/browser.rb', line 90 def release @process&.release end |
#reset ⇒ Object
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.
190 191 192 193 194 195 196 |
# File 'lib/capybara/lightpanda/browser.rb', line 190 def reset dispose_browser_context @client.clear_subscriptions clear_session_state create_browser_context create_page end |
#response_headers ⇒ Object
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.
347 348 349 350 |
# File 'lib/capybara/lightpanda/browser.rb', line 347 def response_headers raw = network.&.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
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 |
# File 'lib/capybara/lightpanda/browser.rb', line 376 def screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary) params = { format: format.to_s } params[:quality] = quality if quality && format == :jpeg if full_page metrics = page_command("Page.getLayoutMetrics") content_size = metrics["contentSize"] params[:clip] = { x: 0, y: 0, width: content_size["width"], height: content_size["height"], scale: 1, } end result = page_command("Page.captureScreenshot", **params) data = result["data"] if encoding == :base64 data else decoded = Base64.decode64(data) if path File.binwrite(path, decoded) path else decoded end end end |
#set_file_input_files(remote_object_id, paths) ⇒ Object
Populate a file from one or more local file paths via
DOM.setFileInputFiles (PR #2635, build ≥6625): Lightpanda resolves the
objectId, replaces input.files with a real FileList, and fires
input/change. The submitted form then carries the bytes as
multipart/form-data (PR #2654, build ≥6672) — both halves are needed,
and both are guaranteed by the MINIMUM_NIGHTLY_BUILD floor. Paths are
read off the machine running Lightpanda (local for the spawned process).
372 373 374 |
# File 'lib/capybara/lightpanda/browser.rb', line 372 def set_file_input_files(remote_object_id, paths) page_command("DOM.setFileInputFiles", objectId: remote_object_id, files: paths) end |
#set_viewport(width = nil, height = nil) ⇒ Object
Apply the window_size option as a JS-visible viewport: it drives
window.innerWidth/innerHeight and the viewport matchMedia / @media
evaluate against, so responsive branches resolve at the requested size.
It is NOT layout — Lightpanda has no rendering engine, so nothing
reflows and getBoundingClientRect stays synthetic.
Re-applied on every create_page rather than once at connect. The override lives on the CDP-connection-scoped Browser upstream, so it already survives Driver#reset!'s disposeBrowserContext; re-sending is one idempotent call that also covers the reconnect path, where the connection (and therefore the override) is genuinely new.
window_size is validated in Options#initialize, which runs before the process is spawned — a bad value must not leave an orphaned browser behind, and raising from here would (Browser#initialize starts the process before create_page ever runs).
Public (declared below the private section via public :set_viewport)
because Driver#resize_window_to drives it mid-test: with no arguments
it re-applies the configured size, which is what create_page wants.
SHARP EDGE, verified 2026-07-25 on nightly 8285: this updates
window.innerWidth/innerHeight and what matchMedia reports
immediately, but it does NOT re-resolve @media rules for the document
already loaded. Lightpanda fixes the cascade when a document is parsed
and nothing invalidates it on a metrics change, so a page loaded at
1920 keeps rendering its desktop branch even while matchMedia
("(max-width: 500px)") reports true. Navigating (Driver#visit) parses
under the new metrics and resolves correctly.
No workaround shipped on purpose. DOM/CSSOM mutations look like they force a re-cascade but don't survive scrutiny (the probe that suggested otherwise had been contaminated by the previous iteration's viewport), so anything here would be a guess dressed up as a fix. Driver's window methods document the resize-then-visit shape instead.
539 540 541 542 543 |
# File 'lib/capybara/lightpanda/browser.rb', line 539 def (width = nil, height = nil) width, height = @options.window_size if width.nil? || height.nil? page_command("Emulation.setDeviceMetricsOverride", width: width, height: height) [width, height] end |
#start ⇒ Object
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/capybara/lightpanda/browser.rb', line 119 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_code ⇒ Object
HTTP status of the last document navigation; nil before the first navigation completes. Captured by Network's subscription (installed via network.enable in create_page).
340 341 342 |
# File 'lib/capybara/lightpanda/browser.rb', line 340 def status_code network.&.dig(:status) end |
#title ⇒ Object
324 325 326 |
# File 'lib/capybara/lightpanda/browser.rb', line 324 def title evaluate("document.title") end |
#version ⇒ Object
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.
79 80 81 |
# File 'lib/capybara/lightpanda/browser.rb', line 79 def version @process&.version end |
#viewport_size ⇒ Object
The viewport as the page currently sees it. Read back from JS rather than echoing what we last set, so it stays truthful if a page (or a direct Emulation call through #execute_cdp) moved it behind our back.
548 549 550 |
# File 'lib/capybara/lightpanda/browser.rb', line 548 def evaluate("[window.innerWidth, window.innerHeight]") 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.
303 304 305 |
# File 'lib/capybara/lightpanda/browser.rb', line 303 def wait_for_default_context(timeout = 1.0) @default_context_event.wait(timeout) end |
#wait_for_idle ⇒ Object
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 |
# File 'lib/capybara/lightpanda/browser.rb', line 423 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) check_unhandled_modal! 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.
313 314 315 316 317 318 |
# File 'lib/capybara/lightpanda/browser.rb', line 313 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 |