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