capybara-lightpanda 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,15 +2,32 @@
2
2
 
3
3
  require "forwardable"
4
4
 
5
+ require_relative "browser/runtime"
6
+ require_relative "browser/finder"
7
+ require_relative "browser/navigation"
8
+ require_relative "browser/modals"
9
+ require_relative "browser/console"
10
+
5
11
  module Capybara
6
12
  module Lightpanda
7
13
  class Browser
8
14
  extend Forwardable
9
15
 
16
+ include Runtime
17
+ include Finder
18
+ include Navigation
19
+ include Modals
20
+ include Console
21
+
10
22
  attr_reader :options, :process, :client, :target_id, :session_id, :browser_context_id, :frame_stack
11
23
 
12
24
  delegate %i[on off] => :client
13
25
 
26
+ # Sentinel key marking a serialized DOM node in JS-result payloads.
27
+ # Produced by #unwrap_call_result / #serialize_remote_array, consumed by
28
+ # Driver#unwrap_script_result, which wraps the objectId in a Node.
29
+ NODE_MARKER = "__lightpanda_node__"
30
+
14
31
  # --- Live-browser registry: clean teardown at process exit --------------
15
32
  # Capybara's per-test reset (Driver#reset!) disposes only the
16
33
  # BrowserContext and keeps the process + CDP connection alive, so a
@@ -77,11 +94,11 @@ module Capybara
77
94
  @modal_messages = []
78
95
  @modal_messages_mutex = Mutex.new
79
96
  @modal_handler_installed = false
97
+ @console_logs = []
98
+ @console_logs_mutex = Mutex.new
80
99
  @frame_stack = []
81
100
  @turbo_event = Utils::Event.new
82
101
  @turbo_event.set
83
- @last_navigation_response = nil
84
- @document_request_id = nil
85
102
 
86
103
  start
87
104
  end
@@ -124,9 +141,14 @@ module Capybara
124
141
 
125
142
  @turbo_event.set
126
143
  subscribe_to_console_logs
144
+ subscribe_to_console_capture
127
145
  subscribe_to_execution_context
128
146
  subscribe_to_turbo_signals
129
- subscribe_to_navigation_response
147
+ # Network owns the Network.* domain: enabling installs traffic
148
+ # tracking AND the navigation-response capture behind status_code.
149
+ # clear_session_state's network.reset flipped @enabled back, so this
150
+ # re-subscribes on the fresh context.
151
+ network.enable
130
152
  register_auto_scripts
131
153
  end
132
154
 
@@ -172,8 +194,7 @@ module Capybara
172
194
  @page_events_enabled = false
173
195
  @modal_handler_installed = false
174
196
  @modal_messages_mutex.synchronize { @modal_messages.clear }
175
- @last_navigation_response = nil
176
- @document_request_id = nil
197
+ @console_logs_mutex.synchronize { @console_logs.clear }
177
198
  clear_frames
178
199
  # Network#reset, not #clear: disposing the BrowserContext also
179
200
  # destroyed the Network domain and its subscriptions, so we must
@@ -182,8 +203,23 @@ module Capybara
182
203
  @network&.reset
183
204
  end
184
205
 
206
+ # Liveness of the CDP transport. Driver#browser checks this to decide
207
+ # whether to respawn a dead browser.
208
+ def alive?
209
+ !client.nil? && !client.closed?
210
+ rescue StandardError
211
+ false
212
+ end
213
+
185
214
  def quit
186
215
  self.class.untrack(self)
216
+ # Flip Network back to disabled so a later #start re-installs its
217
+ # subscriptions — without this, quit→start reuse of the same
218
+ # instance leaves @enabled true and create_page's network.enable
219
+ # no-ops, silently killing status_code/traffic capture. Guarded on
220
+ # @client: with no client the handlers are already moot and
221
+ # unsubscribe would have nothing to detach from.
222
+ @network&.reset if @client
187
223
  begin
188
224
  @client&.close
189
225
  rescue StandardError
@@ -212,31 +248,6 @@ module Capybara
212
248
  @client.command(method, params, session_id: @session_id)
213
249
  end
214
250
 
215
- # Navigation with readyState fallback.
216
- #
217
- # Lightpanda may never fire Page.loadEventFired on complex JS pages
218
- # (lightpanda-io/browser#1801, #1832). When the event times out,
219
- # we poll document.readyState as a fallback.
220
- #
221
- # Page.navigate is sent asynchronously because Lightpanda may not
222
- # return the command result until the page is fully loaded (unlike
223
- # Chrome which returns immediately with frameId/loaderId). If we
224
- # waited synchronously, the readyState fallback would never be
225
- # reached on pages that fail to fully load.
226
- #
227
- # Uses a single shared deadline so the worst-case wait is 1x timeout,
228
- # not 2x (lightpanda-io/browser#1849).
229
- def go_to(url, wait: true, retried: false)
230
- enable_page_events
231
-
232
- if wait
233
- wait_for_page_load(url, retried: retried)
234
- else
235
- page_command("Page.navigate", url: url)
236
- end
237
- end
238
- alias goto go_to
239
-
240
251
  def enable_page_events
241
252
  return if @page_events_enabled
242
253
 
@@ -264,19 +275,6 @@ module Capybara
264
275
  end
265
276
  end
266
277
 
267
- def back
268
- wait_for_navigation { navigate_history(-1) }
269
- end
270
-
271
- def forward
272
- wait_for_navigation { navigate_history(+1) }
273
- end
274
-
275
- def refresh
276
- wait_for_navigation { page_command("Page.reload") }
277
- end
278
- alias reload refresh
279
-
280
278
  def current_url
281
279
  evaluate("window.location.href")
282
280
  end
@@ -295,201 +293,20 @@ module Capybara
295
293
  alias html body
296
294
 
297
295
  # HTTP status of the last document navigation; nil before the first
298
- # navigation completes. Driven by the Network.responseReceived
299
- # subscription installed in create_page.
296
+ # navigation completes. Captured by Network's subscription (installed
297
+ # via network.enable in create_page).
300
298
  def status_code
301
- @last_navigation_response&.dig(:status)
299
+ network.last_navigation_response&.dig(:status)
302
300
  end
303
301
 
304
302
  # Response headers of the last document navigation, wrapped in a Headers
305
303
  # instance so `["Content-Type"]` works despite CDP lowercasing keys.
306
304
  # Returns an empty Headers (not nil) so callers can chain `[]` safely.
307
305
  def response_headers
308
- raw = @last_navigation_response&.dig(:headers) || {}
306
+ raw = network.last_navigation_response&.dig(:headers) || {}
309
307
  Headers.new.tap { |h| raw.each { |k, v| h[k.to_s.downcase] = v } }
310
308
  end
311
309
 
312
- # Evaluate JS and return a serialized value.
313
- # No-args fast path uses Runtime.evaluate; with args we wrap as a function
314
- # and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
315
- # Both paths use `returnByValue: false` and unwrap so DOM-node returns
316
- # come back as `{ "__lightpanda_node__" => ... }` for the Driver to wrap.
317
- #
318
- # Even the no-args path wraps the expression in an IIFE to isolate
319
- # top-level `const`/`let` declarations. Upstream Lightpanda retains
320
- # those bindings across `Runtime.evaluate` calls (V8 starts each call
321
- # with fresh lexical scope per spec), so a second `const sel = ...`
322
- # raises `SyntaxError: Identifier 'sel' has already been declared`.
323
- # Wrapping pushes the declarations into a function scope that gets
324
- # discarded when the IIFE returns.
325
- #
326
- # Use direct `eval` inside the IIFE so the user's text can be a bare
327
- # expression (`'foo'`), a `throw` statement, OR a multi-statement
328
- # script with `const`/`let`. `eval`'s completion-value semantics
329
- # return the last expression's value in all cases. A naive
330
- # `return EXPR;` wrap would syntax-error on `throw …` and on
331
- # multi-statement scripts.
332
- def evaluate(expression, *args)
333
- if args.empty?
334
- wrapped = "(function(){return eval(#{expression.to_json})})()"
335
- response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: true)
336
- if response["exceptionDetails"]
337
- debug_js_failure("evaluate", expression, response)
338
- raise JavaScriptError, response
339
- end
340
-
341
- return unwrap_call_result(response["result"])
342
- end
343
-
344
- wrapped = "function() { return #{expression} }"
345
- call_with_args(wrapped, args)
346
- end
347
-
348
- # Execute JS without returning a value.
349
- #
350
- # Like `evaluate`, the no-args path wraps in an IIFE — same upstream
351
- # `const`/`let` leak. Also raises on JS exceptions so silent
352
- # failures don't mask test bugs (the previous fast path swallowed them
353
- # because `awaitPromise: false` was checked but `exceptionDetails` was
354
- # not).
355
- def execute(expression, *args)
356
- if args.empty?
357
- wrapped = "(function(){#{expression}})()"
358
- response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: false)
359
- if response["exceptionDetails"]
360
- debug_js_failure("execute", expression, response)
361
- raise JavaScriptError, response
362
- end
363
- return nil
364
- end
365
-
366
- wrapped = "function() { #{expression} }"
367
- call_with_args(wrapped, args, return_by_value: false)
368
- nil
369
- end
370
-
371
- # When LIGHTPANDA_DEBUG=1 is set, log the JS expression and full CDP
372
- # response for every JsException to STDERR. Invaluable for isolating
373
- # which exact JS triggers an upstream Lightpanda bug.
374
- def debug_js_failure(site, expression, response)
375
- return unless ENV["LIGHTPANDA_DEBUG"]
376
-
377
- warn "[lightpanda:#{site}] expression:\n#{expression}\n[lightpanda:#{site}] response:\n#{response.inspect}\n"
378
- end
379
-
380
- # Evaluate async JS with a callback. The user's script receives
381
- # the callback as its last argument (`arguments[arguments.length - 1]`),
382
- # matching Capybara's evaluate_async_script contract.
383
- def evaluate_async(expression, *args, wait: @options.timeout)
384
- timeout_ms = (wait * 1000).to_i
385
- wrapped = <<~JS
386
- function() {
387
- var __args = Array.prototype.slice.call(arguments);
388
- return new Promise(function(__resolve, __reject) {
389
- var __timer = setTimeout(function() {
390
- __reject(new Error('Async script timeout after #{timeout_ms}ms'));
391
- }, #{timeout_ms});
392
- var __done = function(val) { clearTimeout(__timer); __resolve(val); };
393
- __args.push(__done);
394
- (function() { #{expression} }).apply(null, __args);
395
- });
396
- }
397
- JS
398
- call_with_args(wrapped, args)
399
- end
400
-
401
- # Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
402
- def evaluate_with_ref(expression)
403
- response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
404
- if response["exceptionDetails"]
405
- debug_js_failure("evaluate_with_ref", expression, response)
406
- raise JavaScriptError, response
407
- end
408
-
409
- result = response["result"]
410
- return nil if result["type"] == "undefined"
411
-
412
- result
413
- end
414
-
415
- # Call a function on a remote object via Runtime.callFunctionOn.
416
- # Binds `this` to the DOM element referenced by remote_object_id.
417
- def call_function_on(remote_object_id, function_declaration, *args, return_by_value: true)
418
- params = {
419
- objectId: remote_object_id,
420
- functionDeclaration: function_declaration,
421
- returnByValue: return_by_value,
422
- awaitPromise: true,
423
- }
424
- params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?
425
-
426
- response = page_command("Runtime.callFunctionOn", **params)
427
- if response["exceptionDetails"]
428
- debug_js_failure("call_function_on", function_declaration, response)
429
- raise JavaScriptError, response
430
- end
431
-
432
- result = response["result"]
433
- return nil if result["type"] == "undefined"
434
-
435
- return_by_value ? result["value"] : result
436
- end
437
-
438
- # Get properties of a remote object (used to extract array elements).
439
- def get_object_properties(remote_object_id)
440
- page_command("Runtime.getProperties", objectId: remote_object_id, ownProperties: true)
441
- end
442
-
443
- # Release a remote object reference to free V8 memory. Cleanup is
444
- # best-effort: callers wrap their work in `ensure release_object(...)`,
445
- # so a TimeoutError or transport hiccup here must not propagate out of
446
- # the ensure block and bury the original failure.
447
- def release_object(remote_object_id)
448
- page_command("Runtime.releaseObject", objectId: remote_object_id)
449
- rescue Error
450
- # Object may already be released, context destroyed, or the CDP call
451
- # itself timed out / failed in transport.
452
- end
453
-
454
- # Find elements in the current context (top frame or active frame).
455
- # Returns an array of remote object ID strings.
456
- def find(method, selector)
457
- if @frame_stack.empty?
458
- find_in_document(method, selector)
459
- else
460
- find_in_frame(method, selector)
461
- end
462
- end
463
-
464
- # Find child elements within a specific node.
465
- # Returns an array of remote object ID strings.
466
- #
467
- # Wrapped in `with_default_context_wait` so a click that triggered a
468
- # navigation immediately before the find (e.g. a fill_in following a
469
- # link that mutated the DOM) doesn't race against
470
- # `Runtime.executionContextCreated` and surface as
471
- # `NoExecutionContextError`. `find_in_document` and `find_in_frame`
472
- # already use the same wrapper; `find_within` was the odd one out.
473
- def find_within(remote_object_id, method, selector)
474
- with_default_context_wait do
475
- result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
476
- extract_node_object_ids(result)
477
- end
478
- rescue JavaScriptError => e
479
- raise_invalid_selector(e, method, selector)
480
- end
481
-
482
- # Ancestor chain of `remote_object_id` from parentNode up to (but
483
- # excluding) `document`, returned as an array of remote object IDs.
484
- # Mirrors Cuprite's JS `parents` helper. Same `with_default_context_wait`
485
- # wrapping as `find_within` — same race window applies.
486
- def parents_of(remote_object_id)
487
- with_default_context_wait do
488
- result = call_function_on(remote_object_id, PARENTS_JS, return_by_value: false)
489
- extract_node_object_ids(result)
490
- end
491
- end
492
-
493
310
  # objectId of document.activeElement, or nil if none/document detached.
494
311
  def active_element
495
312
  result = evaluate_with_ref("document.activeElement")
@@ -605,328 +422,39 @@ module Capybara
605
422
  @frame_stack.clear
606
423
  end
607
424
 
608
- # -- Modal/Dialog Support --
609
- # Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
610
- # `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
611
- # the client sends `LP.handleJavaScriptDialog {accept, promptText}`
612
- # BEFORE the action that triggers the dialog, and the response is
613
- # consumed when the dialog opens. `Page.javascriptDialogOpening` still
614
- # fires, so we capture the message text for `find_modal`. Single-shot:
615
- # `pending_dialog_response` is one slot, so a second pre-arm before
616
- # the first dialog opens overwrites the first.
617
-
618
- def prepare_modals
619
- return if @modal_handler_installed
620
-
621
- enable_page_events
622
-
623
- on("Page.javascriptDialogOpening") do |params|
624
- entry = { type: params["type"], message: params["message"] }
625
- @modal_messages_mutex.synchronize { @modal_messages << entry }
626
- end
627
-
628
- @modal_handler_installed = true
629
- end
630
-
631
- def accept_modal(_type, text: nil)
632
- prepare_modals
633
- params = { accept: true }
634
- params[:promptText] = text if text
635
- page_command("LP.handleJavaScriptDialog", **params)
636
- end
637
-
638
- def dismiss_modal(_type)
639
- prepare_modals
640
- page_command("LP.handleJavaScriptDialog", accept: false)
641
- end
642
-
643
- def find_modal(type, text: nil, wait: options.timeout)
644
- regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
645
- last_matching_type_message = nil
646
- last_seen_message = nil
647
- claimed = nil
648
- Utils::Wait.until(timeout: wait, interval: 0.05) do
649
- claimed = pop_modal_message(type.to_s, regexp)
650
- next true if claimed
651
-
652
- last = peek_last_modal_message(type.to_s)
653
- last_matching_type_message = last[:matching_type] || last_matching_type_message
654
- last_seen_message = last[:any] || last_seen_message
655
- false
656
- end
657
- claimed[:message]
658
- rescue TimeoutError
659
- raise_modal_not_found(type, text, last_matching_type_message, last_seen_message)
660
- end
661
-
662
- private
425
+ # Capybara::Driver::Base resolves frame_url/frame_title via the top
426
+ # execution context, which always reports the parent document. Resolve
427
+ # them through the iframe element's contentWindow / contentDocument so
428
+ # they reflect the active frame.
429
+ def frame_url
430
+ frame = frame_stack.last
431
+ return current_url unless frame
663
432
 
664
- # Pop the first queued dialog whose type matches and (when `regexp` is
665
- # non-nil) whose message matches the requested pattern. Returns the
666
- # entry or nil. Serialized with the message-thread writer.
667
- def pop_modal_message(type, regexp)
668
- @modal_messages_mutex.synchronize do
669
- match = @modal_messages.find do |m|
670
- m[:type] == type && (regexp.nil? || m[:message].to_s.match?(regexp))
671
- end
672
- @modal_messages.delete(match) if match
673
- match
674
- end
433
+ call_function_on(frame.remote_object_id, FRAME_URL_JS)
675
434
  end
676
435
 
677
- # Inspect the queue for diagnostics. Returns the most recent message
678
- # of the requested type (if any) AND the most recent message of any
679
- # type so the failure message can hint at a type mismatch.
680
- def peek_last_modal_message(type)
681
- @modal_messages_mutex.synchronize do
682
- {
683
- matching_type: @modal_messages.reverse.find { |m| m[:type] == type }&.dig(:message),
684
- any: @modal_messages.last&.dig(:message),
685
- }
686
- end
687
- end
436
+ def frame_title
437
+ frame = frame_stack.last
438
+ return title unless frame
688
439
 
689
- def raise_modal_not_found(type, text, matching_type_message, any_message)
690
- if matching_type_message
691
- raise Capybara::ModalNotFound,
692
- "Unable to find modal dialog with #{text} - found '#{matching_type_message}' instead."
693
- end
694
- if any_message
695
- raise Capybara::ModalNotFound,
696
- "Unable to find #{type} modal#{" with #{text}" if text} - " \
697
- "a different dialog fired with message '#{any_message}'."
698
- end
699
- raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
440
+ call_function_on(frame.remote_object_id, FRAME_TITLE_JS)
700
441
  end
701
442
 
702
- # Sentinel string thrown from FIND_*_JS when querySelectorAll rejects a
703
- # malformed selector, so the Ruby side can convert JavaScriptError into
704
- # Capybara::Lightpanda::InvalidSelector. Cuprite uses a JS subclass for
705
- # the same purpose; a plain prefixed string keeps our inline JS simple.
706
- INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
707
-
708
- # JS function for finding elements within a node.
709
- # Works in any execution context (top frame or iframe). For CSS, any
710
- # throw from querySelectorAll means the selector is malformed
711
- # (re-throw with the marker prefix so Ruby converts to InvalidSelector).
712
- # XPath routes through native `Document.evaluate` + `XPathResult`
713
- # (Lightpanda PR #2305, in nightly >=6109); on parse error we return
714
- # [] silently to match Capybara's internal XPath generator, which
715
- # sometimes produces selectors with empty trailing predicates like
716
- # `(...)[]` that native rejects but `has_element?` expects to behave
717
- # as "not found" rather than raise InvalidSelector.
718
- # `XPathResult.ORDERED_NODE_SNAPSHOT_TYPE` is `7` in the spec — inlined
719
- # so the JS doesn't depend on the enum being defined as a constant.
720
- FIND_WITHIN_JS = <<~JS.freeze
721
- function(method, selector) {
722
- if (method === 'xpath') {
723
- try {
724
- var r = this.ownerDocument.evaluate(selector, this, null, 7, null);
725
- var nodes = [];
726
- for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
727
- return nodes;
728
- } catch(e) { return []; }
729
- }
730
- try { return Array.from(this.querySelectorAll(selector)); }
731
- catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
732
- }
733
- JS
734
- private_constant :FIND_WITHIN_JS
735
-
736
- # JS function for finding elements in an iframe's contentDocument.
737
- FIND_IN_FRAME_JS = <<~JS.freeze
738
- function(method, selector) {
739
- var doc;
740
- try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
741
- if (!doc) return [];
742
- if (method === 'xpath') {
743
- try {
744
- var r = doc.evaluate(selector, doc, null, 7, null);
745
- var nodes = [];
746
- for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
747
- return nodes;
748
- } catch(e) { return []; }
749
- }
750
- try { return Array.from(doc.querySelectorAll(selector)); }
751
- catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
752
- }
753
- JS
754
- private_constant :FIND_IN_FRAME_JS
755
-
756
- # Walks `parentNode` from `this` up to (but excluding) `document`,
757
- # returning the chain as a JS array. Each entry is an element node so
758
- # `extract_node_object_ids` can wrap them as Lightpanda::Nodes.
759
- PARENTS_JS = <<~JS
760
- function() {
761
- var nodes = [];
762
- var p = this.parentNode;
763
- while (p && p !== this.ownerDocument) {
764
- nodes.push(p);
765
- p = p.parentNode;
766
- }
767
- return nodes;
768
- }
769
- JS
770
- private_constant :PARENTS_JS
771
-
772
- def find_in_document(method, selector)
773
- with_default_context_wait do
774
- # Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
775
- # through) to a string before quoting. Symbol#inspect returns `:p`,
776
- # which would inject a bare token into the JS source.
777
- selector_literal = selector.to_s.inspect
778
- # XPath parse errors return [] silently to match Capybara's expected
779
- # "not found" behavior (see FIND_WITHIN_JS comment above for why).
780
- js = if method == "xpath"
781
- <<~XPATH_FIND
782
- (function() {
783
- try {
784
- var r = document.evaluate(#{selector_literal}, document, null, 7, null);
785
- var nodes = [];
786
- for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
787
- return nodes;
788
- } catch(e) { return []; }
789
- })()
790
- XPATH_FIND
791
- else
792
- <<~CSS_FIND
793
- (function() {
794
- try { return Array.from(document.querySelectorAll(#{selector_literal})); }
795
- catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + #{selector_literal}); }
796
- })()
797
- CSS_FIND
798
- end
799
- result = evaluate_with_ref(js)
800
- extract_node_object_ids(result)
801
- end
802
- rescue JavaScriptError => e
803
- raise_invalid_selector(e, method, selector)
804
- end
443
+ FRAME_URL_JS = "function() { return this.contentWindow.location.href }"
444
+ FRAME_TITLE_JS = "function() { return this.contentDocument.title }"
445
+ private_constant :FRAME_URL_JS, :FRAME_TITLE_JS
805
446
 
806
- def find_in_frame(method, selector)
807
- with_default_context_wait do
808
- frame_node = @frame_stack.last
809
- result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
810
- return_by_value: false)
811
- extract_node_object_ids(result)
812
- end
813
- rescue JavaScriptError => e
814
- raise_invalid_selector(e, method, selector)
815
- end
447
+ # Internal lifecycle steps defined above near their topical groups —
448
+ # calling them out of order corrupts session state, so they are not API.
449
+ private :create_browser_context, :create_page, :clear_session_state,
450
+ :enable_page_events
816
451
 
817
- def raise_invalid_selector(js_error, method, selector)
818
- if js_error.message.include?(INVALID_SELECTOR_MARKER)
819
- raise InvalidSelector.new("Invalid #{method} selector: #{selector.inspect}", method, selector)
820
- end
821
-
822
- raise js_error
823
- end
824
-
825
- # Extract individual node objectIds from a remote array reference.
826
- # `ensure release_object` so the outer array handle is freed even when
827
- # property walking raises — without this, a transient CDP error during
828
- # property enumeration leaks one V8 handle per failed find call.
829
- def extract_node_object_ids(result)
830
- return [] unless result && result["objectId"]
831
-
832
- outer_id = result["objectId"]
833
- begin
834
- props = get_object_properties(outer_id)
835
- properties = props["result"] || []
836
- properties
837
- .select { |p| p["name"] =~ /\A\d+\z/ }
838
- .sort_by { |p| p["name"].to_i }
839
- .filter_map { |p| p.dig("value", "objectId") }
840
- rescue Error
841
- []
842
- ensure
843
- release_object(outer_id)
844
- end
845
- end
452
+ private
846
453
 
847
454
  def register_auto_scripts
848
455
  page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
849
456
  end
850
457
 
851
- def subscribe_to_console_logs
852
- logger = @options.logger
853
- return unless logger
854
-
855
- on("Runtime.consoleAPICalled") do |params|
856
- params["args"]&.each do |r|
857
- value = r["value"]
858
- next if value.is_a?(String) && value.start_with?(TURBO_SENTINEL_PREFIX)
859
-
860
- logger.puts(value)
861
- end
862
- end
863
- end
864
-
865
- TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
866
- private_constant :TURBO_SENTINEL_PREFIX
867
-
868
- # Wire @turbo_event to the JS-side _signalTurbo emissions. The JS calls
869
- # console.debug('__lightpanda_turbo_busy') / '_idle' on transitions across
870
- # zero pending ops; Lightpanda forwards those to Runtime.consoleAPICalled.
871
- # Idle → set the event (wakes any waiter); busy → reset.
872
- #
873
- # On Runtime.executionContextsCleared (navigation), unconditionally set
874
- # the event: if we navigated away mid-busy state, no further idle signal
875
- # would ever come from the old context, and we'd block for the full
876
- # timeout. The new context will signal busy again if Turbo is active.
877
- def subscribe_to_turbo_signals
878
- on("Runtime.consoleAPICalled") do |params|
879
- next unless params["args"].is_a?(Array)
880
-
881
- marker = params["args"].first&.dig("value")
882
- next unless marker.is_a?(String) && marker.start_with?(TURBO_SENTINEL_PREFIX)
883
-
884
- case marker
885
- when "#{TURBO_SENTINEL_PREFIX}busy" then @turbo_event.reset
886
- when "#{TURBO_SENTINEL_PREFIX}idle" then @turbo_event.set
887
- end
888
- end
889
-
890
- on("Runtime.executionContextsCleared") { @turbo_event.set }
891
- end
892
-
893
- # Remember the latest top-level navigation response so
894
- # `Driver#status_code` / `#response_headers` can answer it. Mirrors the
895
- # capybara-playwright-driver page hook that captures
896
- # `request.navigation_request?` (lib/capybara/playwright/page.rb#L33-L37);
897
- # CDP normally signals "this is the main-document response" via
898
- # `Network.responseReceived.type`, but Lightpanda omits that field on
899
- # responses (only emits `type` on `Network.requestWillBeSent`). So we
900
- # do the matching the long way: capture the document requestId from
901
- # `requestWillBeSent {type: "Document"}`, then store the response whose
902
- # `requestId` equals it. Re-installed per `create_page` so the new
903
- # BrowserContext after `Driver#reset!` starts with a fresh slot.
904
- #
905
- # Caveat: sending `Network.disable` (e.g. through `driver.network.disable`)
906
- # also silences this handler — they share the same CDP toggle.
907
- def subscribe_to_navigation_response
908
- @last_navigation_response = nil
909
- @document_request_id = nil
910
-
911
- on("Network.requestWillBeSent") do |params|
912
- next unless params["type"] == "Document"
913
-
914
- @document_request_id = params["requestId"]
915
- @last_navigation_response = nil
916
- end
917
-
918
- on("Network.responseReceived") do |params|
919
- next unless params["requestId"] == @document_request_id
920
-
921
- @last_navigation_response = {
922
- status: params.dig("response", "status"),
923
- headers: params.dig("response", "headers") || {},
924
- }
925
- end
926
-
927
- command("Network.enable")
928
- end
929
-
930
458
  # Track default-execution-context availability via Runtime events.
931
459
  # Lightpanda destroys the V8 default context at navigation start (long
932
460
  # before frameNavigated fires), then re-creates it once the new page
@@ -947,153 +475,6 @@ module Capybara
947
475
  page_command("Runtime.enable")
948
476
  end
949
477
 
950
- def serialize_argument(arg)
951
- if arg.respond_to?(:remote_object_id)
952
- { objectId: arg.remote_object_id }
953
- else
954
- { value: arg }
955
- end
956
- end
957
-
958
- def document_node_id
959
- result = page_command("DOM.getDocument")
960
-
961
- result.dig("root", "nodeId")
962
- end
963
-
964
- def handle_evaluate_response(response)
965
- if response["exceptionDetails"]
966
- debug_js_failure("handle_evaluate_response", "(unknown — already-issued call)", response)
967
- raise JavaScriptError, response
968
- end
969
-
970
- result = response["result"]
971
- return nil if result["type"] == "undefined"
972
-
973
- result["value"]
974
- end
975
-
976
- # Run a wrapped function via Runtime.callFunctionOn with `arguments` bound.
977
- # `args` is converted via `serialize_argument` (Nodes → objectId, scalars → value).
978
- # When `return_by_value: false` (the default) the return value is unwrapped via
979
- # `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
980
- # hashes the Driver can wrap as Capybara nodes.
981
- def call_with_args(function_declaration, args, return_by_value: false)
982
- # document_object_id returns a fresh RemoteObject handle every call.
983
- # Release it on the way out so long-running shared-spec sessions don't
984
- # accumulate orphaned V8 handles between resets.
985
- doc_oid = document_object_id
986
- params = {
987
- objectId: doc_oid,
988
- functionDeclaration: function_declaration,
989
- returnByValue: return_by_value,
990
- awaitPromise: true,
991
- arguments: args.map { |a| serialize_argument(a) },
992
- }
993
- response = page_command("Runtime.callFunctionOn", **params)
994
- if response["exceptionDetails"]
995
- debug_js_failure("call_with_args", function_declaration, response)
996
- raise JavaScriptError, response
997
- end
998
-
999
- return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
1000
- ensure
1001
- release_object(doc_oid) if doc_oid
1002
- end
1003
-
1004
- # Translate a non-by-value Runtime result into a plain Ruby value, surfacing
1005
- # DOM nodes as `{ "__lightpanda_node__" => "..." }` so the Driver can wrap
1006
- # them. The sentinel key (rather than a plain "objectId") prevents
1007
- # misclassifying user JS that legitimately returns `{ objectId: "x" }`.
1008
- #
1009
- # When the result carries an objectId we can't unwrap (function, regexp,
1010
- # date, …), release the handle before falling back to `result["value"]`
1011
- # so V8 doesn't accumulate orphaned references across long sessions.
1012
- def unwrap_call_result(result)
1013
- return nil if result["type"] == "undefined"
1014
- return nil if result["subtype"] == "null"
1015
-
1016
- object_id = result["objectId"]
1017
- if object_id
1018
- return { "__lightpanda_node__" => object_id } if result["subtype"] == "node"
1019
- return serialize_remote_array(object_id) if result["subtype"] == "array"
1020
- return serialize_remote_object(object_id) if result["type"] == "object"
1021
-
1022
- release_object(object_id)
1023
- end
1024
-
1025
- result["value"]
1026
- end
1027
-
1028
- # Re-fetch a remote object as JSON-serializable value for plain objects/arrays.
1029
- # Cheaper than walking properties and good enough for shared specs. Releases
1030
- # the original handle so long-lived sessions don't accumulate leaked objectIds.
1031
- def serialize_remote_object(object_id)
1032
- json = page_command(
1033
- "Runtime.callFunctionOn",
1034
- objectId: object_id,
1035
- functionDeclaration: "function() { return this }",
1036
- returnByValue: true
1037
- )
1038
- handle_evaluate_response(json)
1039
- ensure
1040
- release_object(object_id)
1041
- end
1042
-
1043
- # Walk an array's own indexed properties via `Runtime.getProperties`,
1044
- # unwrapping each element through the regular result pipeline so that
1045
- # DOM-node entries surface as `{ "__lightpanda_node__" => ... }` instead
1046
- # of being flattened to `{}` by `returnByValue: true`. Releases the
1047
- # outer array's objectId once we've harvested its elements.
1048
- def serialize_remote_array(object_id)
1049
- properties = get_object_properties(object_id).fetch("result", [])
1050
- properties
1051
- .select { |p| p["enumerable"] && p["name"] =~ /\A\d+\z/ }
1052
- .sort_by { |p| p["name"].to_i }
1053
- .map { |p| unwrap_call_result(p["value"] || {}) }
1054
- ensure
1055
- release_object(object_id)
1056
- end
1057
-
1058
- # objectId of `document`, used as the `this` context for callFunctionOn when
1059
- # we need `arguments` binding but don't care about `this`. Re-resolved per
1060
- # call because the document objectId is invalidated by navigation.
1061
- def document_object_id
1062
- result = page_command("Runtime.evaluate", expression: "document", returnByValue: false)
1063
- result.dig("result", "objectId")
1064
- end
1065
-
1066
- def wait_for_page_load(url, retried:)
1067
- deadline = await_navigation do
1068
- @client.command("Page.navigate", { url: url }, async: true, session_id: @session_id)
1069
- end
1070
- handle_navigation_crash(url, deadline, retried: retried)
1071
- end
1072
-
1073
- # Lightpanda may kill the WebSocket or crash during complex page
1074
- # navigation (lightpanda-io/browser#1849, #1854). Reconnect and
1075
- # retry once. If the retry also crashes, raise a clear error
1076
- # instead of leaving the client in a dead state.
1077
- def handle_navigation_crash(url, deadline, retried:)
1078
- if @client.closed? && !retried
1079
- begin
1080
- reconnect
1081
- remaining = deadline - monotonic_time
1082
- go_to(url, wait: remaining.positive?, retried: true) if remaining.positive?
1083
- rescue DeadBrowserError
1084
- raise
1085
- rescue StandardError
1086
- # reconnect itself failed (process won't restart, port stuck, etc.).
1087
- # Fall through to the raise below — a second immediate reconnect
1088
- # attempt would just duplicate the failure we already swallowed.
1089
- end
1090
- end
1091
-
1092
- return unless @client.closed?
1093
-
1094
- raise DeadBrowserError, "Lightpanda crashed navigating to #{url}"
1095
- end
1096
-
1097
478
  def close_client_silently
1098
479
  @client&.close
1099
480
  rescue StandardError
@@ -1126,102 +507,6 @@ module Capybara
1126
507
  @process.start
1127
508
  end
1128
509
 
1129
- def safe_current_url
1130
- current_url
1131
- rescue StandardError
1132
- nil
1133
- end
1134
-
1135
- # Wait for a navigation triggered by the given block.
1136
- # Uses the same loadEventFired + readyState fallback as go_to.
1137
- def wait_for_navigation(&)
1138
- enable_page_events
1139
- await_navigation(&)
1140
- end
1141
-
1142
- # Step the session history by `offset` (-1 = back, +1 = forward) using
1143
- # native CDP. `Page.getNavigationHistory` returns the entry list and
1144
- # `currentIndex`; `Page.navigateToHistoryEntry` jumps to the chosen
1145
- # entry's `id`. No-op when the offset would step past either end so
1146
- # the behavior matches `history.back()` / `history.forward()` on a
1147
- # bounded session history.
1148
- def navigate_history(offset)
1149
- history = page_command("Page.getNavigationHistory")
1150
- target_index = history["currentIndex"] + offset
1151
- entries = history["entries"]
1152
- return if target_index.negative? || target_index >= entries.length
1153
-
1154
- page_command("Page.navigateToHistoryEntry", entryId: entries[target_index]["id"])
1155
- end
1156
-
1157
- # Common navigation lifecycle shared by `wait_for_page_load` (fresh
1158
- # `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
1159
- # Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
1160
- # the event, falls back to readyState polling for the remaining budget.
1161
- # The handler is unsubscribed via `ensure` so a raising trigger doesn't
1162
- # leak a subscription onto the next navigation. Returns the deadline so
1163
- # the caller can decide whether to attempt crash recovery.
1164
- def await_navigation
1165
- starting_url = safe_current_url
1166
- deadline = monotonic_time + @options.timeout
1167
- loaded = Utils::Event.new
1168
- handler = proc { loaded.set }
1169
- @client.on("Page.loadEventFired", &handler)
1170
-
1171
- begin
1172
- yield
1173
-
1174
- unless loaded.wait([2, @options.timeout].min)
1175
- remaining = deadline - monotonic_time
1176
- poll_ready_state(remaining, loaded_event: loaded, starting_url: starting_url) if remaining.positive?
1177
- end
1178
- ensure
1179
- @client.off("Page.loadEventFired", handler)
1180
- end
1181
-
1182
- deadline
1183
- end
1184
-
1185
- # Poll document.readyState as a fallback when Page.loadEventFired
1186
- # doesn't fire (CLAUDE.md rules call this out as load-bearing — do
1187
- # not remove). When starting_url is provided, the poll ignores
1188
- # readyState values from the old page (e.g. about:blank reports
1189
- # "complete" while the new page is still loading in the background).
1190
- def poll_ready_state(timeout, loaded_event: nil, starting_url: nil)
1191
- # Use a short per-evaluation timeout because Lightpanda may block
1192
- # all commands while navigating. Without this, a single evaluate()
1193
- # call would consume the entire @options.timeout, making the poll
1194
- # loop effectively a single attempt.
1195
- poll_cmd_timeout = [timeout / 5.0, 2].max
1196
-
1197
- Utils::Wait.until(timeout: timeout, interval: 0.1) do
1198
- loaded_event&.set? || @client.closed? || page_ready?(poll_cmd_timeout, starting_url)
1199
- end
1200
- rescue TimeoutError
1201
- # Expected — readyState fallback exhausted its budget. The caller
1202
- # (await_navigation) keeps going and lets handle_navigation_crash
1203
- # decide whether the session is recoverable.
1204
- end
1205
-
1206
- POLL_STATE_JS = "(function(){return{r:document.readyState,u:location.href}})()"
1207
- private_constant :POLL_STATE_JS
1208
-
1209
- def page_ready?(cmd_timeout, starting_url)
1210
- response = @client.command(
1211
- "Runtime.evaluate",
1212
- { expression: POLL_STATE_JS, returnByValue: true, awaitPromise: true },
1213
- session_id: @session_id,
1214
- timeout: cmd_timeout
1215
- )
1216
- state = response.dig("result", "value")
1217
- return false unless state
1218
-
1219
- url_changed = starting_url.nil? || state["u"] != starting_url
1220
- url_changed && %w[complete interactive].include?(state["r"])
1221
- rescue Error
1222
- false
1223
- end
1224
-
1225
510
  def monotonic_time
1226
511
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
1227
512
  end