capybara-lightpanda 0.2.2 → 0.4.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.
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
- require "concurrent-ruby"
5
4
 
6
5
  module Capybara
7
6
  module Lightpanda
@@ -33,11 +32,13 @@ module Capybara
33
32
  @started = false
34
33
  @page_events_enabled = false
35
34
  @modal_messages = []
35
+ @modal_messages_mutex = Mutex.new
36
36
  @modal_handler_installed = false
37
37
  @frame_stack = []
38
- @frames = Concurrent::Hash.new
39
38
  @turbo_event = Utils::Event.new
40
39
  @turbo_event.set
40
+ @last_navigation_response = nil
41
+ @document_request_id = nil
41
42
 
42
43
  start
43
44
  end
@@ -77,20 +78,14 @@ module Capybara
77
78
  attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
78
79
  @session_id = attach_result["sessionId"]
79
80
 
80
- @frames.clear
81
81
  @turbo_event.set
82
82
  subscribe_to_console_logs
83
83
  subscribe_to_execution_context
84
- subscribe_to_frame_events
85
84
  subscribe_to_turbo_signals
85
+ subscribe_to_navigation_response
86
86
  register_auto_scripts
87
87
  end
88
88
 
89
- def restart
90
- quit
91
- start
92
- end
93
-
94
89
  # Wipe per-session state — cookies, storage, all targets — and start
95
90
  # over with a fresh BrowserContext. Mirrors ferrum's Browser#reset:
96
91
  # one CDP call (`Target.disposeBrowserContext`) does the work that
@@ -105,15 +100,7 @@ module Capybara
105
100
  def reset
106
101
  dispose_browser_context
107
102
  @client.clear_subscriptions
108
- @page_events_enabled = false
109
- @modal_handler_installed = false
110
- @modal_messages.clear
111
- @frame_stack.clear
112
- # Network#reset, not #clear: disposing the BrowserContext also
113
- # destroyed the Network domain and its subscriptions, so we must
114
- # flip @enabled back to false — otherwise the next #enable
115
- # short-circuits and traffic tracking is silently dead.
116
- @network&.reset
103
+ clear_session_state
117
104
  create_browser_context
118
105
  create_page
119
106
  end
@@ -130,9 +117,30 @@ module Capybara
130
117
  @client = Client.new(ws_url, @options)
131
118
  # Process may have died; the old browserContextId is gone with it.
132
119
  @browser_context_id = nil
120
+ clear_session_state
133
121
  create_browser_context
134
122
  create_page
123
+ end
124
+
125
+ # Per-session in-memory state that must be wiped whenever the underlying
126
+ # CDP connection is replaced (#reset disposes the BrowserContext, #reconnect
127
+ # builds a fresh Client). Without this, a mid-test process crash leaves
128
+ # stale frame_stack Nodes (whose objectIds belong to the dead V8 context)
129
+ # and a `@modal_handler_installed = true` flag that makes prepare_modals
130
+ # short-circuit on the new client, so find_modal silently sees no
131
+ # javascriptDialogOpening events.
132
+ def clear_session_state
135
133
  @page_events_enabled = false
134
+ @modal_handler_installed = false
135
+ @modal_messages_mutex.synchronize { @modal_messages.clear }
136
+ @last_navigation_response = nil
137
+ @document_request_id = nil
138
+ clear_frames
139
+ # Network#reset, not #clear: disposing the BrowserContext also
140
+ # destroyed the Network domain and its subscriptions, so we must
141
+ # flip @enabled back to false — otherwise the next #enable
142
+ # short-circuits and traffic tracking is silently dead.
143
+ @network&.reset
136
144
  end
137
145
 
138
146
  def quit
@@ -153,7 +161,7 @@ module Capybara
153
161
  @target_id = nil
154
162
  @session_id = nil
155
163
  @modal_handler_installed = false
156
- @frame_stack.clear
164
+ clear_frames
157
165
  end
158
166
 
159
167
  def command(method, **params)
@@ -217,11 +225,11 @@ module Capybara
217
225
  end
218
226
 
219
227
  def back
220
- wait_for_navigation { execute("history.back()") }
228
+ wait_for_navigation { navigate_history(-1) }
221
229
  end
222
230
 
223
231
  def forward
224
- wait_for_navigation { execute("history.forward()") }
232
+ wait_for_navigation { navigate_history(+1) }
225
233
  end
226
234
 
227
235
  def refresh
@@ -246,6 +254,21 @@ module Capybara
246
254
  end
247
255
  alias html body
248
256
 
257
+ # HTTP status of the last document navigation; nil before the first
258
+ # navigation completes. Driven by the Network.responseReceived
259
+ # subscription installed in create_page.
260
+ def status_code
261
+ @last_navigation_response&.dig(:status)
262
+ end
263
+
264
+ # Response headers of the last document navigation, wrapped in a Headers
265
+ # instance so `["Content-Type"]` works despite CDP lowercasing keys.
266
+ # Returns an empty Headers (not nil) so callers can chain `[]` safely.
267
+ def response_headers
268
+ raw = @last_navigation_response&.dig(:headers) || {}
269
+ Headers.new.tap { |h| raw.each { |k, v| h[k.to_s.downcase] = v } }
270
+ end
271
+
249
272
  # Evaluate JS and return a serialized value.
250
273
  # No-args fast path uses Runtime.evaluate; with args we wrap as a function
251
274
  # and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
@@ -377,11 +400,15 @@ module Capybara
377
400
  page_command("Runtime.getProperties", objectId: remote_object_id, ownProperties: true)
378
401
  end
379
402
 
380
- # Release a remote object reference to free V8 memory.
403
+ # Release a remote object reference to free V8 memory. Cleanup is
404
+ # best-effort: callers wrap their work in `ensure release_object(...)`,
405
+ # so a TimeoutError or transport hiccup here must not propagate out of
406
+ # the ensure block and bury the original failure.
381
407
  def release_object(remote_object_id)
382
408
  page_command("Runtime.releaseObject", objectId: remote_object_id)
383
- rescue BrowserError
384
- # Object may already be released or context destroyed
409
+ rescue Error
410
+ # Object may already be released, context destroyed, or the CDP call
411
+ # itself timed out / failed in transport.
385
412
  end
386
413
 
387
414
  # Find elements in the current context (top frame or active frame).
@@ -396,13 +423,33 @@ module Capybara
396
423
 
397
424
  # Find child elements within a specific node.
398
425
  # Returns an array of remote object ID strings.
426
+ #
427
+ # Wrapped in `with_default_context_wait` so a click that triggered a
428
+ # navigation immediately before the find (e.g. a fill_in following a
429
+ # link that mutated the DOM) doesn't race against
430
+ # `Runtime.executionContextCreated` and surface as
431
+ # `NoExecutionContextError`. `find_in_document` and `find_in_frame`
432
+ # already use the same wrapper; `find_within` was the odd one out.
399
433
  def find_within(remote_object_id, method, selector)
400
- result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
401
- extract_node_object_ids(result)
434
+ with_default_context_wait do
435
+ result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
436
+ extract_node_object_ids(result)
437
+ end
402
438
  rescue JavaScriptError => e
403
439
  raise_invalid_selector(e, method, selector)
404
440
  end
405
441
 
442
+ # Ancestor chain of `remote_object_id` from parentNode up to (but
443
+ # excluding) `document`, returned as an array of remote object IDs.
444
+ # Mirrors Cuprite's JS `parents` helper. Same `with_default_context_wait`
445
+ # wrapping as `find_within` — same race window applies.
446
+ def parents_of(remote_object_id)
447
+ with_default_context_wait do
448
+ result = call_function_on(remote_object_id, PARENTS_JS, return_by_value: false)
449
+ extract_node_object_ids(result)
450
+ end
451
+ end
452
+
406
453
  # objectId of document.activeElement, or nil if none/document detached.
407
454
  def active_element
408
455
  result = evaluate_with_ref("document.activeElement")
@@ -416,17 +463,6 @@ module Capybara
416
463
  page_command("DOM.describeNode", objectId: remote_object_id).dig("node", "backendNodeId")
417
464
  end
418
465
 
419
- def css(selector)
420
- node_ids = page_command("DOM.querySelectorAll", nodeId: document_node_id, selector: selector)
421
- node_ids["nodeIds"] || []
422
- end
423
-
424
- def at_css(selector)
425
- result = page_command("DOM.querySelector", nodeId: document_node_id, selector: selector)
426
-
427
- result["nodeId"]
428
- end
429
-
430
466
  def screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary)
431
467
  params = { format: format.to_s }
432
468
  params[:quality] = quality if quality && format == :jpeg
@@ -461,18 +497,6 @@ module Capybara
461
497
  end
462
498
  end
463
499
 
464
- # Wait for any pending Turbo operations to complete. Event-driven: the
465
- # injected JS in index.js calls `console.debug('__lightpanda_turbo_busy')`
466
- # when the pending-ops counter rises above 0 and `_idle` when it returns
467
- # to 0. We toggle @turbo_event accordingly (see subscribe_to_turbo_signals).
468
- #
469
- # Pages without Turbo never trigger _turboStart, so no sentinels fire and
470
- # @turbo_event stays set (initial state) — wait returns immediately. Same
471
- # for Turbo-loaded pages that have no pending work.
472
- def wait_for_turbo
473
- @turbo_event.wait(@options.timeout)
474
- end
475
-
476
500
  # Wait for the page to settle after an action that may have kicked off
477
501
  # a Turbo fetch OR a full-page navigation. Used by Node#click and
478
502
  # Node#implicit_submit so callers can immediately read updated state
@@ -514,17 +538,9 @@ module Capybara
514
538
  end
515
539
 
516
540
  # -- Frame Support --
517
- # Two parallel views of frames:
518
- #
519
- # * `frame_stack` (Array<Node>) the Capybara `switch_to_frame` stack;
520
- # drives where `find` resolves selectors. Stored as Nodes so
521
- # callFunctionOn can scope to the iframe's contentDocument.
522
- #
523
- # * `@frames` (Concurrent::Hash<String, Frame>) — metadata view
524
- # populated from Page.frame{Attached,Navigated,Detached,...} events.
525
- # Used for diagnostics / introspection (frames, main_frame, frame_by).
526
- # Lightpanda's frame events are not reliable enough to drive
527
- # navigation waits, so this is read-only metadata.
541
+ # `frame_stack` (Array<Node>) is the Capybara `switch_to_frame` stack;
542
+ # it drives where `find` resolves selectors. Stored as Nodes so
543
+ # callFunctionOn can scope to the iframe's contentDocument.
528
544
 
529
545
  def push_frame(node)
530
546
  @frame_stack.push(node)
@@ -538,25 +554,6 @@ module Capybara
538
554
  @frame_stack.clear
539
555
  end
540
556
 
541
- # All frames currently attached to the page (main frame + iframes).
542
- def frames
543
- @frames.values
544
- end
545
-
546
- # The top-level frame, or nil if it hasn't been registered yet (events
547
- # arrive asynchronously after Page.enable).
548
- def main_frame
549
- @frames.each_value.find(&:main?)
550
- end
551
-
552
- def frame_by(id: nil, name: nil)
553
- if id
554
- @frames[id]
555
- elsif name
556
- @frames.each_value.find { |f| f.name == name }
557
- end
558
- end
559
-
560
557
  # -- Modal/Dialog Support --
561
558
  # Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
562
559
  # `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
@@ -573,7 +570,8 @@ module Capybara
573
570
  enable_page_events
574
571
 
575
572
  on("Page.javascriptDialogOpening") do |params|
576
- @modal_messages << { type: params["type"], message: params["message"] }
573
+ entry = { type: params["type"], message: params["message"] }
574
+ @modal_messages_mutex.synchronize { @modal_messages << entry }
577
575
  end
578
576
 
579
577
  @modal_handler_installed = true
@@ -593,34 +591,59 @@ module Capybara
593
591
 
594
592
  def find_modal(type, text: nil, wait: options.timeout)
595
593
  regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
596
- deadline = monotonic_time + wait
597
- last_message = nil
598
- loop do
599
- msg = @modal_messages.find { |m| m[:type] == type.to_s }
600
- if msg
601
- last_message = msg[:message]
602
- if regexp.nil? || last_message.match?(regexp)
603
- @modal_messages.delete(msg)
604
- return last_message
605
- end
606
- end
607
- break if monotonic_time > deadline
608
-
609
- sleep 0.05
594
+ last_matching_type_message = nil
595
+ last_seen_message = nil
596
+ claimed = nil
597
+ Utils::Wait.until(timeout: wait, interval: 0.05) do
598
+ claimed = pop_modal_message(type.to_s, regexp)
599
+ next true if claimed
600
+
601
+ last = peek_last_modal_message(type.to_s)
602
+ last_matching_type_message = last[:matching_type] || last_matching_type_message
603
+ last_seen_message = last[:any] || last_seen_message
604
+ false
610
605
  end
611
- raise_modal_not_found(text, last_message)
606
+ claimed[:message]
607
+ rescue TimeoutError
608
+ raise_modal_not_found(type, text, last_matching_type_message, last_seen_message)
612
609
  end
613
610
 
614
- def reset_modals
615
- @modal_messages.clear
611
+ private
612
+
613
+ # Pop the first queued dialog whose type matches and (when `regexp` is
614
+ # non-nil) whose message matches the requested pattern. Returns the
615
+ # entry or nil. Serialized with the message-thread writer.
616
+ def pop_modal_message(type, regexp)
617
+ @modal_messages_mutex.synchronize do
618
+ match = @modal_messages.find do |m|
619
+ m[:type] == type && (regexp.nil? || m[:message].to_s.match?(regexp))
620
+ end
621
+ @modal_messages.delete(match) if match
622
+ match
623
+ end
616
624
  end
617
625
 
618
- private
626
+ # Inspect the queue for diagnostics. Returns the most recent message
627
+ # of the requested type (if any) AND the most recent message of any
628
+ # type so the failure message can hint at a type mismatch.
629
+ def peek_last_modal_message(type)
630
+ @modal_messages_mutex.synchronize do
631
+ {
632
+ matching_type: @modal_messages.reverse.find { |m| m[:type] == type }&.dig(:message),
633
+ any: @modal_messages.last&.dig(:message),
634
+ }
635
+ end
636
+ end
619
637
 
620
- def raise_modal_not_found(text, last_message)
621
- if last_message
638
+ def raise_modal_not_found(type, text, matching_type_message, any_message)
639
+ if matching_type_message
622
640
  raise Capybara::ModalNotFound,
623
- "Unable to find modal dialog with #{text} - found '#{last_message}' instead."
641
+ "Unable to find modal dialog with #{text} - found '#{matching_type_message}' instead."
642
+ end
643
+ if any_message
644
+ raise Capybara::ModalNotFound,
645
+ "Unable to find #{type} modal#{" with #{text}" if text} - " \
646
+ "a different dialog fired with message '#{any_message}'."
624
647
  end
625
648
  raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
626
649
  end
@@ -632,16 +655,26 @@ module Capybara
632
655
  INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
633
656
 
634
657
  # JS function for finding elements within a node.
635
- # Works in any execution context (top frame or iframe). Any throw from
636
- # querySelectorAll means the selector is malformed (the spec only allows
637
- # SYNTAX_ERR DOMException; Lightpanda's V8 currently throws a generic
638
- # Error with messages like "InvalidClassSelector"). Re-throw with the
639
- # marker prefix so Ruby converts to InvalidSelector regardless.
658
+ # Works in any execution context (top frame or iframe). For CSS, any
659
+ # throw from querySelectorAll means the selector is malformed
660
+ # (re-throw with the marker prefix so Ruby converts to InvalidSelector).
661
+ # XPath routes through native `Document.evaluate` + `XPathResult`
662
+ # (Lightpanda PR #2305, in nightly >=6109); on parse error we return
663
+ # [] silently to match Capybara's internal XPath generator, which
664
+ # sometimes produces selectors with empty trailing predicates like
665
+ # `(...)[]` that native rejects but `has_element?` expects to behave
666
+ # as "not found" rather than raise InvalidSelector.
667
+ # `XPathResult.ORDERED_NODE_SNAPSHOT_TYPE` is `7` in the spec — inlined
668
+ # so the JS doesn't depend on the enum being defined as a constant.
640
669
  FIND_WITHIN_JS = <<~JS.freeze
641
670
  function(method, selector) {
642
671
  if (method === 'xpath') {
643
- if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, this);
644
- return [];
672
+ try {
673
+ var r = this.ownerDocument.evaluate(selector, this, null, 7, null);
674
+ var nodes = [];
675
+ for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
676
+ return nodes;
677
+ } catch(e) { return []; }
645
678
  }
646
679
  try { return Array.from(this.querySelectorAll(selector)); }
647
680
  catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
@@ -656,8 +689,12 @@ module Capybara
656
689
  try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
657
690
  if (!doc) return [];
658
691
  if (method === 'xpath') {
659
- if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, doc);
660
- return [];
692
+ try {
693
+ var r = doc.evaluate(selector, doc, null, 7, null);
694
+ var nodes = [];
695
+ for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
696
+ return nodes;
697
+ } catch(e) { return []; }
661
698
  }
662
699
  try { return Array.from(doc.querySelectorAll(selector)); }
663
700
  catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
@@ -665,14 +702,41 @@ module Capybara
665
702
  JS
666
703
  private_constant :FIND_IN_FRAME_JS
667
704
 
705
+ # Walks `parentNode` from `this` up to (but excluding) `document`,
706
+ # returning the chain as a JS array. Each entry is an element node so
707
+ # `extract_node_object_ids` can wrap them as Lightpanda::Nodes.
708
+ PARENTS_JS = <<~JS
709
+ function() {
710
+ var nodes = [];
711
+ var p = this.parentNode;
712
+ while (p && p !== this.ownerDocument) {
713
+ nodes.push(p);
714
+ p = p.parentNode;
715
+ }
716
+ return nodes;
717
+ }
718
+ JS
719
+ private_constant :PARENTS_JS
720
+
668
721
  def find_in_document(method, selector)
669
722
  with_default_context_wait do
670
723
  # Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
671
724
  # through) to a string before quoting. Symbol#inspect returns `:p`,
672
725
  # which would inject a bare token into the JS source.
673
726
  selector_literal = selector.to_s.inspect
727
+ # XPath parse errors return [] silently to match Capybara's expected
728
+ # "not found" behavior (see FIND_WITHIN_JS comment above for why).
674
729
  js = if method == "xpath"
675
- "(typeof _lightpanda !== 'undefined') ? _lightpanda.xpathFind(#{selector_literal}, document) : []"
730
+ <<~XPATH_FIND
731
+ (function() {
732
+ try {
733
+ var r = document.evaluate(#{selector_literal}, document, null, 7, null);
734
+ var nodes = [];
735
+ for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
736
+ return nodes;
737
+ } catch(e) { return []; }
738
+ })()
739
+ XPATH_FIND
676
740
  else
677
741
  <<~CSS_FIND
678
742
  (function() {
@@ -689,10 +753,12 @@ module Capybara
689
753
  end
690
754
 
691
755
  def find_in_frame(method, selector)
692
- frame_node = @frame_stack.last
693
- result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
694
- return_by_value: false)
695
- extract_node_object_ids(result)
756
+ with_default_context_wait do
757
+ frame_node = @frame_stack.last
758
+ result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
759
+ return_by_value: false)
760
+ extract_node_object_ids(result)
761
+ end
696
762
  rescue JavaScriptError => e
697
763
  raise_invalid_selector(e, method, selector)
698
764
  end
@@ -706,26 +772,29 @@ module Capybara
706
772
  end
707
773
 
708
774
  # Extract individual node objectIds from a remote array reference.
775
+ # `ensure release_object` so the outer array handle is freed even when
776
+ # property walking raises — without this, a transient CDP error during
777
+ # property enumeration leaks one V8 handle per failed find call.
709
778
  def extract_node_object_ids(result)
710
779
  return [] unless result && result["objectId"]
711
780
 
712
- props = get_object_properties(result["objectId"])
713
- properties = props["result"] || []
714
-
715
- ids = properties
716
- .select { |p| p["name"] =~ /\A\d+\z/ }
717
- .sort_by { |p| p["name"].to_i }
718
- .filter_map { |p| p.dig("value", "objectId") }
719
-
720
- release_object(result["objectId"])
721
- ids
722
- rescue Error
723
- []
781
+ outer_id = result["objectId"]
782
+ begin
783
+ props = get_object_properties(outer_id)
784
+ properties = props["result"] || []
785
+ properties
786
+ .select { |p| p["name"] =~ /\A\d+\z/ }
787
+ .sort_by { |p| p["name"].to_i }
788
+ .filter_map { |p| p.dig("value", "objectId") }
789
+ rescue Error
790
+ []
791
+ ensure
792
+ release_object(outer_id)
793
+ end
724
794
  end
725
795
 
726
796
  def register_auto_scripts
727
- page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::JS)
728
- page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::POLYFILLS_JS)
797
+ page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
729
798
  end
730
799
 
731
800
  def subscribe_to_console_logs
@@ -770,43 +839,41 @@ module Capybara
770
839
  on("Runtime.executionContextsCleared") { @turbo_event.set }
771
840
  end
772
841
 
773
- # Maintain @frames from Page.frame* events. Subscribed once per page
774
- # (create_page resets @frames and re-subscribes on a fresh client, so
775
- # handlers don't accumulate across reconnects). Loading-state events
776
- # are best-effort: Lightpanda's Page.frameStoppedLoading is unreliable
777
- # on complex pages (#1801), so we track state for diagnostics only.
778
- def subscribe_to_frame_events
779
- on("Page.frameAttached") { |params| handle_frame_attached(params) }
780
- on("Page.frameNavigated") { |params| handle_frame_navigated(params) }
781
- on("Page.frameStartedLoading") { |params| set_frame_state(params["frameId"], :started_loading) }
782
- on("Page.frameStoppedLoading") { |params| set_frame_state(params["frameId"], :stopped_loading) }
783
- on("Page.frameDetached") { |params| handle_frame_detached(params) }
784
- end
842
+ # Remember the latest top-level navigation response so
843
+ # `Driver#status_code` / `#response_headers` can answer it. Mirrors the
844
+ # capybara-playwright-driver page hook that captures
845
+ # `request.navigation_request?` (lib/capybara/playwright/page.rb#L33-L37);
846
+ # CDP normally signals "this is the main-document response" via
847
+ # `Network.responseReceived.type`, but Lightpanda omits that field on
848
+ # responses (only emits `type` on `Network.requestWillBeSent`). So we
849
+ # do the matching the long way: capture the document requestId from
850
+ # `requestWillBeSent {type: "Document"}`, then store the response whose
851
+ # `requestId` equals it. Re-installed per `create_page` so the new
852
+ # BrowserContext after `Driver#reset!` starts with a fresh slot.
853
+ #
854
+ # Caveat: sending `Network.disable` (e.g. through `driver.network.disable`)
855
+ # also silences this handler — they share the same CDP toggle.
856
+ def subscribe_to_navigation_response
857
+ @last_navigation_response = nil
858
+ @document_request_id = nil
785
859
 
786
- def handle_frame_attached(params)
787
- parent_id, frame_id = params.values_at("parentFrameId", "frameId")
788
- @frames[frame_id] ||= Frame.new(frame_id, parent_id)
789
- end
860
+ on("Network.requestWillBeSent") do |params|
861
+ next unless params["type"] == "Document"
790
862
 
791
- def handle_frame_navigated(params)
792
- frame_data = params["frame"] || {}
793
- frame_id = frame_data["id"]
794
- return unless frame_id
863
+ @document_request_id = params["requestId"]
864
+ @last_navigation_response = nil
865
+ end
795
866
 
796
- frame = @frames[frame_id] ||= Frame.new(frame_id, frame_data["parentId"])
797
- frame.name = frame_data["name"]
798
- frame.url = frame_data["url"]
799
- frame.state = :navigated
800
- end
867
+ on("Network.responseReceived") do |params|
868
+ next unless params["requestId"] == @document_request_id
801
869
 
802
- def handle_frame_detached(params)
803
- frame = @frames.delete(params["frameId"])
804
- frame&.state = :detached
805
- end
870
+ @last_navigation_response = {
871
+ status: params.dig("response", "status"),
872
+ headers: params.dig("response", "headers") || {},
873
+ }
874
+ end
806
875
 
807
- def set_frame_state(frame_id, state)
808
- frame = @frames[frame_id]
809
- frame.state = state if frame
876
+ command("Network.enable")
810
877
  end
811
878
 
812
879
  # Track default-execution-context availability via Runtime events.
@@ -861,8 +928,12 @@ module Capybara
861
928
  # `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
862
929
  # hashes the Driver can wrap as Capybara nodes.
863
930
  def call_with_args(function_declaration, args, return_by_value: false)
931
+ # document_object_id returns a fresh RemoteObject handle every call.
932
+ # Release it on the way out so long-running shared-spec sessions don't
933
+ # accumulate orphaned V8 handles between resets.
934
+ doc_oid = document_object_id
864
935
  params = {
865
- objectId: document_object_id,
936
+ objectId: doc_oid,
866
937
  functionDeclaration: function_declaration,
867
938
  returnByValue: return_by_value,
868
939
  awaitPromise: true,
@@ -875,12 +946,18 @@ module Capybara
875
946
  end
876
947
 
877
948
  return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
949
+ ensure
950
+ release_object(doc_oid) if doc_oid
878
951
  end
879
952
 
880
953
  # Translate a non-by-value Runtime result into a plain Ruby value, surfacing
881
954
  # DOM nodes as `{ "__lightpanda_node__" => "..." }` so the Driver can wrap
882
955
  # them. The sentinel key (rather than a plain "objectId") prevents
883
956
  # misclassifying user JS that legitimately returns `{ objectId: "x" }`.
957
+ #
958
+ # When the result carries an objectId we can't unwrap (function, regexp,
959
+ # date, …), release the handle before falling back to `result["value"]`
960
+ # so V8 doesn't accumulate orphaned references across long sessions.
884
961
  def unwrap_call_result(result)
885
962
  return nil if result["type"] == "undefined"
886
963
  return nil if result["subtype"] == "null"
@@ -890,6 +967,8 @@ module Capybara
890
967
  return { "__lightpanda_node__" => object_id } if result["subtype"] == "node"
891
968
  return serialize_remote_array(object_id) if result["subtype"] == "array"
892
969
  return serialize_remote_object(object_id) if result["type"] == "object"
970
+
971
+ release_object(object_id)
893
972
  end
894
973
 
895
974
  result["value"]
@@ -953,17 +1032,14 @@ module Capybara
953
1032
  rescue DeadBrowserError
954
1033
  raise
955
1034
  rescue StandardError
956
- # reconnect itself failed (process won't restart, port stuck, etc.)
1035
+ # reconnect itself failed (process won't restart, port stuck, etc.).
1036
+ # Fall through to the raise below — a second immediate reconnect
1037
+ # attempt would just duplicate the failure we already swallowed.
957
1038
  end
958
1039
  end
959
1040
 
960
1041
  return unless @client.closed?
961
1042
 
962
- begin
963
- reconnect
964
- rescue StandardError
965
- nil
966
- end
967
1043
  raise DeadBrowserError, "Lightpanda crashed navigating to #{url}"
968
1044
  end
969
1045
 
@@ -1012,6 +1088,21 @@ module Capybara
1012
1088
  await_navigation(&)
1013
1089
  end
1014
1090
 
1091
+ # Step the session history by `offset` (-1 = back, +1 = forward) using
1092
+ # native CDP. `Page.getNavigationHistory` returns the entry list and
1093
+ # `currentIndex`; `Page.navigateToHistoryEntry` jumps to the chosen
1094
+ # entry's `id`. No-op when the offset would step past either end so
1095
+ # the behavior matches `history.back()` / `history.forward()` on a
1096
+ # bounded session history.
1097
+ def navigate_history(offset)
1098
+ history = page_command("Page.getNavigationHistory")
1099
+ target_index = history["currentIndex"] + offset
1100
+ entries = history["entries"]
1101
+ return if target_index.negative? || target_index >= entries.length
1102
+
1103
+ page_command("Page.navigateToHistoryEntry", entryId: entries[target_index]["id"])
1104
+ end
1105
+
1015
1106
  # Common navigation lifecycle shared by `wait_for_page_load` (fresh
1016
1107
  # `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
1017
1108
  # Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
@@ -1041,28 +1132,28 @@ module Capybara
1041
1132
  end
1042
1133
 
1043
1134
  # Poll document.readyState as a fallback when Page.loadEventFired
1044
- # doesn't fire. When starting_url is provided, the poll ignores
1135
+ # doesn't fire (CLAUDE.md rules call this out as load-bearing — do
1136
+ # not remove). When starting_url is provided, the poll ignores
1045
1137
  # readyState values from the old page (e.g. about:blank reports
1046
1138
  # "complete" while the new page is still loading in the background).
1047
1139
  def poll_ready_state(timeout, loaded_event: nil, starting_url: nil)
1048
- deadline = monotonic_time + timeout
1049
1140
  # Use a short per-evaluation timeout because Lightpanda may block
1050
1141
  # all commands while navigating. Without this, a single evaluate()
1051
1142
  # call would consume the entire @options.timeout, making the poll
1052
1143
  # loop effectively a single attempt.
1053
1144
  poll_cmd_timeout = [timeout / 5.0, 2].max
1054
1145
 
1055
- loop do
1056
- break if loaded_event&.set?
1057
- break if @client.closed?
1058
- break if page_ready?(poll_cmd_timeout, starting_url)
1059
- break if monotonic_time > deadline
1060
-
1061
- sleep 0.1
1146
+ Utils::Wait.until(timeout: timeout, interval: 0.1) do
1147
+ loaded_event&.set? || @client.closed? || page_ready?(poll_cmd_timeout, starting_url)
1062
1148
  end
1149
+ rescue TimeoutError
1150
+ # Expected — readyState fallback exhausted its budget. The caller
1151
+ # (await_navigation) keeps going and lets handle_navigation_crash
1152
+ # decide whether the session is recoverable.
1063
1153
  end
1064
1154
 
1065
1155
  POLL_STATE_JS = "(function(){return{r:document.readyState,u:location.href}})()"
1156
+ private_constant :POLL_STATE_JS
1066
1157
 
1067
1158
  def page_ready?(cmd_timeout, starting_url)
1068
1159
  response = @client.command(