capybara-lightpanda 0.3.0 → 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,12 +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
- @frame_locators = []
39
- @frames = Concurrent::Hash.new
40
38
  @turbo_event = Utils::Event.new
41
39
  @turbo_event.set
40
+ @last_navigation_response = nil
41
+ @document_request_id = nil
42
42
 
43
43
  start
44
44
  end
@@ -78,20 +78,14 @@ module Capybara
78
78
  attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
79
79
  @session_id = attach_result["sessionId"]
80
80
 
81
- @frames.clear
82
81
  @turbo_event.set
83
82
  subscribe_to_console_logs
84
83
  subscribe_to_execution_context
85
- subscribe_to_frame_events
86
84
  subscribe_to_turbo_signals
85
+ subscribe_to_navigation_response
87
86
  register_auto_scripts
88
87
  end
89
88
 
90
- def restart
91
- quit
92
- start
93
- end
94
-
95
89
  # Wipe per-session state — cookies, storage, all targets — and start
96
90
  # over with a fresh BrowserContext. Mirrors ferrum's Browser#reset:
97
91
  # one CDP call (`Target.disposeBrowserContext`) does the work that
@@ -106,15 +100,7 @@ module Capybara
106
100
  def reset
107
101
  dispose_browser_context
108
102
  @client.clear_subscriptions
109
- @page_events_enabled = false
110
- @modal_handler_installed = false
111
- @modal_messages.clear
112
- clear_frames
113
- # Network#reset, not #clear: disposing the BrowserContext also
114
- # destroyed the Network domain and its subscriptions, so we must
115
- # flip @enabled back to false — otherwise the next #enable
116
- # short-circuits and traffic tracking is silently dead.
117
- @network&.reset
103
+ clear_session_state
118
104
  create_browser_context
119
105
  create_page
120
106
  end
@@ -131,9 +117,30 @@ module Capybara
131
117
  @client = Client.new(ws_url, @options)
132
118
  # Process may have died; the old browserContextId is gone with it.
133
119
  @browser_context_id = nil
120
+ clear_session_state
134
121
  create_browser_context
135
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
136
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
137
144
  end
138
145
 
139
146
  def quit
@@ -247,6 +254,21 @@ module Capybara
247
254
  end
248
255
  alias html body
249
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
+
250
272
  # Evaluate JS and return a serialized value.
251
273
  # No-args fast path uses Runtime.evaluate; with args we wrap as a function
252
274
  # and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
@@ -378,11 +400,15 @@ module Capybara
378
400
  page_command("Runtime.getProperties", objectId: remote_object_id, ownProperties: true)
379
401
  end
380
402
 
381
- # 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.
382
407
  def release_object(remote_object_id)
383
408
  page_command("Runtime.releaseObject", objectId: remote_object_id)
384
- rescue BrowserError
385
- # 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.
386
412
  end
387
413
 
388
414
  # Find elements in the current context (top frame or active frame).
@@ -397,13 +423,33 @@ module Capybara
397
423
 
398
424
  # Find child elements within a specific node.
399
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.
400
433
  def find_within(remote_object_id, method, selector)
401
- result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
402
- 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
403
438
  rescue JavaScriptError => e
404
439
  raise_invalid_selector(e, method, selector)
405
440
  end
406
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
+
407
453
  # objectId of document.activeElement, or nil if none/document detached.
408
454
  def active_element
409
455
  result = evaluate_with_ref("document.activeElement")
@@ -417,17 +463,6 @@ module Capybara
417
463
  page_command("DOM.describeNode", objectId: remote_object_id).dig("node", "backendNodeId")
418
464
  end
419
465
 
420
- def css(selector)
421
- node_ids = page_command("DOM.querySelectorAll", nodeId: document_node_id, selector: selector)
422
- node_ids["nodeIds"] || []
423
- end
424
-
425
- def at_css(selector)
426
- result = page_command("DOM.querySelector", nodeId: document_node_id, selector: selector)
427
-
428
- result["nodeId"]
429
- end
430
-
431
466
  def screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary)
432
467
  params = { format: format.to_s }
433
468
  params[:quality] = quality if quality && format == :jpeg
@@ -462,18 +497,6 @@ module Capybara
462
497
  end
463
498
  end
464
499
 
465
- # Wait for any pending Turbo operations to complete. Event-driven: the
466
- # injected JS in index.js calls `console.debug('__lightpanda_turbo_busy')`
467
- # when the pending-ops counter rises above 0 and `_idle` when it returns
468
- # to 0. We toggle @turbo_event accordingly (see subscribe_to_turbo_signals).
469
- #
470
- # Pages without Turbo never trigger _turboStart, so no sentinels fire and
471
- # @turbo_event stays set (initial state) — wait returns immediately. Same
472
- # for Turbo-loaded pages that have no pending work.
473
- def wait_for_turbo
474
- @turbo_event.wait(@options.timeout)
475
- end
476
-
477
500
  # Wait for the page to settle after an action that may have kicked off
478
501
  # a Turbo fetch OR a full-page navigation. Used by Node#click and
479
502
  # Node#implicit_submit so callers can immediately read updated state
@@ -515,52 +538,22 @@ module Capybara
515
538
  end
516
539
 
517
540
  # -- Frame Support --
518
- # Two parallel views of frames:
519
- #
520
- # * `frame_stack` (Array<Node>) the Capybara `switch_to_frame` stack;
521
- # drives where `find` resolves selectors. Stored as Nodes so
522
- # callFunctionOn can scope to the iframe's contentDocument.
523
- #
524
- # * `@frames` (Concurrent::Hash<String, Frame>) — metadata view
525
- # populated from Page.frame{Attached,Navigated,Detached,...} events.
526
- # Used for diagnostics / introspection (frames, main_frame, frame_by).
527
- # Lightpanda's frame events are not reliable enough to drive
528
- # 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.
529
544
 
530
545
  def push_frame(node)
531
546
  @frame_stack.push(node)
532
- @frame_locators.push(capture_frame_locator(node))
533
547
  end
534
548
 
535
549
  def pop_frame
536
- @frame_locators.pop
537
550
  @frame_stack.pop
538
551
  end
539
552
 
540
553
  def clear_frames
541
- @frame_locators.clear
542
554
  @frame_stack.clear
543
555
  end
544
556
 
545
- # All frames currently attached to the page (main frame + iframes).
546
- def frames
547
- @frames.values
548
- end
549
-
550
- # The top-level frame, or nil if it hasn't been registered yet (events
551
- # arrive asynchronously after Page.enable).
552
- def main_frame
553
- @frames.each_value.find(&:main?)
554
- end
555
-
556
- def frame_by(id: nil, name: nil)
557
- if id
558
- @frames[id]
559
- elsif name
560
- @frames.each_value.find { |f| f.name == name }
561
- end
562
- end
563
-
564
557
  # -- Modal/Dialog Support --
565
558
  # Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
566
559
  # `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
@@ -577,7 +570,8 @@ module Capybara
577
570
  enable_page_events
578
571
 
579
572
  on("Page.javascriptDialogOpening") do |params|
580
- @modal_messages << { type: params["type"], message: params["message"] }
573
+ entry = { type: params["type"], message: params["message"] }
574
+ @modal_messages_mutex.synchronize { @modal_messages << entry }
581
575
  end
582
576
 
583
577
  @modal_handler_installed = true
@@ -597,34 +591,59 @@ module Capybara
597
591
 
598
592
  def find_modal(type, text: nil, wait: options.timeout)
599
593
  regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
600
- deadline = monotonic_time + wait
601
- last_message = nil
602
- loop do
603
- msg = @modal_messages.find { |m| m[:type] == type.to_s }
604
- if msg
605
- last_message = msg[:message]
606
- if regexp.nil? || last_message.match?(regexp)
607
- @modal_messages.delete(msg)
608
- return last_message
609
- end
610
- end
611
- break if monotonic_time > deadline
612
-
613
- 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
614
605
  end
615
- 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)
616
609
  end
617
610
 
618
- def reset_modals
619
- @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
620
624
  end
621
625
 
622
- 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
623
637
 
624
- def raise_modal_not_found(text, last_message)
625
- if last_message
638
+ def raise_modal_not_found(type, text, matching_type_message, any_message)
639
+ if matching_type_message
640
+ raise Capybara::ModalNotFound,
641
+ "Unable to find modal dialog with #{text} - found '#{matching_type_message}' instead."
642
+ end
643
+ if any_message
626
644
  raise Capybara::ModalNotFound,
627
- "Unable to find modal dialog with #{text} - found '#{last_message}' instead."
645
+ "Unable to find #{type} modal#{" with #{text}" if text} - " \
646
+ "a different dialog fired with message '#{any_message}'."
628
647
  end
629
648
  raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
630
649
  end
@@ -683,77 +702,21 @@ module Capybara
683
702
  JS
684
703
  private_constant :FIND_IN_FRAME_JS
685
704
 
686
- # Captures identifying attributes of an iframe element at push_frame
687
- # time so we can re-resolve it later if its contextId is churned by
688
- # lightpanda-io/browser#2400. id/name/src are tried in order; `index`
689
- # (position among iframes in the owning document) is the last-resort
690
- # fallback when none of the attributes is present.
691
- CAPTURE_FRAME_LOCATOR_JS = <<~JS
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
692
709
  function() {
693
- var siblings = this.ownerDocument.querySelectorAll('iframe');
694
- var index = -1;
695
- for (var i = 0; i < siblings.length; i++) {
696
- if (siblings[i] === this) { index = i; break; }
710
+ var nodes = [];
711
+ var p = this.parentNode;
712
+ while (p && p !== this.ownerDocument) {
713
+ nodes.push(p);
714
+ p = p.parentNode;
697
715
  }
698
- return {
699
- id: this.getAttribute('id'),
700
- name: this.getAttribute('name'),
701
- src: this.getAttribute('src'),
702
- index: index,
703
- };
716
+ return nodes;
704
717
  }
705
718
  JS
706
- private_constant :CAPTURE_FRAME_LOCATOR_JS
707
-
708
- # Picks the iframe inside `this.contentDocument` matching a locator
709
- # produced by CAPTURE_FRAME_LOCATOR_JS. Used to re-resolve a nested
710
- # iframe element when its parent iframe has just been refreshed.
711
- RESOLVE_NESTED_IFRAME_JS = <<~JS
712
- function(locator) {
713
- var doc;
714
- try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
715
- if (!doc) return null;
716
- var iframes = doc.querySelectorAll('iframe');
717
- for (var i = 0; i < iframes.length; i++) {
718
- var f = iframes[i];
719
- if (locator.id && f.getAttribute('id') === locator.id) return f;
720
- if (locator.name && f.getAttribute('name') === locator.name) return f;
721
- if (locator.src && f.getAttribute('src') === locator.src) return f;
722
- }
723
- if (typeof locator.index === 'number' && locator.index >= 0 && locator.index < iframes.length) {
724
- return iframes[locator.index];
725
- }
726
- return null;
727
- }
728
- JS
729
- private_constant :RESOLVE_NESTED_IFRAME_JS
730
-
731
- # Same shape as RESOLVE_NESTED_IFRAME_JS but reads `document` directly
732
- # for the top-level case where `this` is unbound (Runtime.evaluate).
733
- RESOLVE_ROOT_IFRAME_JS = <<~JS
734
- (function(locator) {
735
- var iframes = document.querySelectorAll('iframe');
736
- for (var i = 0; i < iframes.length; i++) {
737
- var f = iframes[i];
738
- if (locator.id && f.getAttribute('id') === locator.id) return f;
739
- if (locator.name && f.getAttribute('name') === locator.name) return f;
740
- if (locator.src && f.getAttribute('src') === locator.src) return f;
741
- }
742
- if (typeof locator.index === 'number' && locator.index >= 0 && locator.index < iframes.length) {
743
- return iframes[locator.index];
744
- }
745
- return null;
746
- })
747
- JS
748
- private_constant :RESOLVE_ROOT_IFRAME_JS
749
-
750
- # Lightweight stand-in for a Node when refresh_frame_stack! re-resolves
751
- # an iframe. The only field anyone reads off frame_stack entries is
752
- # remote_object_id (browser.rb find_in_frame, driver.rb frame_url /
753
- # frame_title), so a Struct with that one field is sufficient and
754
- # avoids constructing real Node instances (which need a Driver ref).
755
- FrameRef = Struct.new(:remote_object_id)
756
- private_constant :FrameRef
719
+ private_constant :PARENTS_JS
757
720
 
758
721
  def find_in_document(method, selector)
759
722
  with_default_context_wait do
@@ -790,26 +753,11 @@ module Capybara
790
753
  end
791
754
 
792
755
  def find_in_frame(method, selector)
793
- refreshed = false
794
- begin
795
- with_default_context_wait do
796
- frame_node = @frame_stack.last
797
- result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
798
- return_by_value: false)
799
- extract_node_object_ids(result)
800
- end
801
- rescue NoExecutionContextError
802
- # Issue #2400: a child iframe navigation re-emits executionContextCreated
803
- # for the main-frame V8 context under the child's frameId, churning the
804
- # iframe's executionContextId. Our stored remote_object_id is bound to
805
- # the old contextId, so callFunctionOn raises "Cannot find context with
806
- # specified id". with_default_context_wait can't help — the default
807
- # context is fine; only the iframe handle is stale. Re-resolve the
808
- # stack from locators captured at push_frame time and retry once.
809
- raise if refreshed || !refresh_frame_stack!
810
-
811
- refreshed = true
812
- retry
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)
813
761
  end
814
762
  rescue JavaScriptError => e
815
763
  raise_invalid_selector(e, method, selector)
@@ -823,84 +771,30 @@ module Capybara
823
771
  raise js_error
824
772
  end
825
773
 
826
- # Snapshot iframe identifying attributes at push_frame time so we can
827
- # re-find the element after lightpanda-io/browser#2400 churns its
828
- # contextId. Returns a Hash on success or nil if the call fails — the
829
- # latter blocks refresh attempts for this stack entry but leaves the
830
- # original behaviour intact (caller falls through to the upstream
831
- # error). Best-effort: never raises.
832
- def capture_frame_locator(node)
833
- call_function_on(node.remote_object_id, CAPTURE_FRAME_LOCATOR_JS)
834
- rescue BrowserError
835
- nil
836
- end
837
-
838
- # Re-resolve every level of @frame_stack from locators captured at
839
- # push_frame time. Replaces stack entries in-place with FrameRef
840
- # holders whose objectIds are bound to the current contextId.
841
- # Returns true on success, false if any level can't be resolved
842
- # (missing locator, iframe removed from DOM, fresh CDP error).
843
- # Called from find_in_frame on NoExecutionContextError; failure is
844
- # not fatal — the caller re-raises the original error.
845
- def refresh_frame_stack!
846
- return false if @frame_stack.empty?
847
- return false if @frame_locators.any?(&:nil?)
848
-
849
- fresh = []
850
- @frame_locators.each_with_index do |locator, level|
851
- node = level.zero? ? resolve_root_iframe(locator) : resolve_nested_iframe(fresh.last, locator)
852
- return false unless node
853
-
854
- fresh << node
855
- end
856
-
857
- @frame_stack.replace(fresh)
858
- true
859
- rescue BrowserError
860
- false
861
- end
862
-
863
- def resolve_root_iframe(locator)
864
- # Runtime.evaluate doesn't accept call arguments, so inline the
865
- # locator as a JSON literal into the invocation expression. The
866
- # locator was captured from the browser's own getAttribute reads
867
- # at push_frame time, so JSON.generate is safe.
868
- expression = "(#{RESOLVE_ROOT_IFRAME_JS})(#{locator.to_json})"
869
- result = evaluate_with_ref(expression)
870
- return nil unless result && result["objectId"]
871
-
872
- FrameRef.new(result["objectId"])
873
- end
874
-
875
- def resolve_nested_iframe(parent, locator)
876
- result = call_function_on(parent.remote_object_id, RESOLVE_NESTED_IFRAME_JS, locator,
877
- return_by_value: false)
878
- return nil unless result && result["objectId"]
879
-
880
- FrameRef.new(result["objectId"])
881
- end
882
-
883
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.
884
778
  def extract_node_object_ids(result)
885
779
  return [] unless result && result["objectId"]
886
780
 
887
- props = get_object_properties(result["objectId"])
888
- properties = props["result"] || []
889
-
890
- ids = properties
891
- .select { |p| p["name"] =~ /\A\d+\z/ }
892
- .sort_by { |p| p["name"].to_i }
893
- .filter_map { |p| p.dig("value", "objectId") }
894
-
895
- release_object(result["objectId"])
896
- ids
897
- rescue Error
898
- []
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
899
794
  end
900
795
 
901
796
  def register_auto_scripts
902
- page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::JS)
903
- page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::POLYFILLS_JS)
797
+ page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
904
798
  end
905
799
 
906
800
  def subscribe_to_console_logs
@@ -945,43 +839,41 @@ module Capybara
945
839
  on("Runtime.executionContextsCleared") { @turbo_event.set }
946
840
  end
947
841
 
948
- # Maintain @frames from Page.frame* events. Subscribed once per page
949
- # (create_page resets @frames and re-subscribes on a fresh client, so
950
- # handlers don't accumulate across reconnects). Loading-state events
951
- # are best-effort: Lightpanda's Page.frameStoppedLoading is unreliable
952
- # on complex pages (#1801), so we track state for diagnostics only.
953
- def subscribe_to_frame_events
954
- on("Page.frameAttached") { |params| handle_frame_attached(params) }
955
- on("Page.frameNavigated") { |params| handle_frame_navigated(params) }
956
- on("Page.frameStartedLoading") { |params| set_frame_state(params["frameId"], :started_loading) }
957
- on("Page.frameStoppedLoading") { |params| set_frame_state(params["frameId"], :stopped_loading) }
958
- on("Page.frameDetached") { |params| handle_frame_detached(params) }
959
- 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
960
859
 
961
- def handle_frame_attached(params)
962
- parent_id, frame_id = params.values_at("parentFrameId", "frameId")
963
- @frames[frame_id] ||= Frame.new(frame_id, parent_id)
964
- end
860
+ on("Network.requestWillBeSent") do |params|
861
+ next unless params["type"] == "Document"
965
862
 
966
- def handle_frame_navigated(params)
967
- frame_data = params["frame"] || {}
968
- frame_id = frame_data["id"]
969
- return unless frame_id
863
+ @document_request_id = params["requestId"]
864
+ @last_navigation_response = nil
865
+ end
970
866
 
971
- frame = @frames[frame_id] ||= Frame.new(frame_id, frame_data["parentId"])
972
- frame.name = frame_data["name"]
973
- frame.url = frame_data["url"]
974
- frame.state = :navigated
975
- end
867
+ on("Network.responseReceived") do |params|
868
+ next unless params["requestId"] == @document_request_id
976
869
 
977
- def handle_frame_detached(params)
978
- frame = @frames.delete(params["frameId"])
979
- frame&.state = :detached
980
- end
870
+ @last_navigation_response = {
871
+ status: params.dig("response", "status"),
872
+ headers: params.dig("response", "headers") || {},
873
+ }
874
+ end
981
875
 
982
- def set_frame_state(frame_id, state)
983
- frame = @frames[frame_id]
984
- frame.state = state if frame
876
+ command("Network.enable")
985
877
  end
986
878
 
987
879
  # Track default-execution-context availability via Runtime events.
@@ -1036,8 +928,12 @@ module Capybara
1036
928
  # `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
1037
929
  # hashes the Driver can wrap as Capybara nodes.
1038
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
1039
935
  params = {
1040
- objectId: document_object_id,
936
+ objectId: doc_oid,
1041
937
  functionDeclaration: function_declaration,
1042
938
  returnByValue: return_by_value,
1043
939
  awaitPromise: true,
@@ -1050,12 +946,18 @@ module Capybara
1050
946
  end
1051
947
 
1052
948
  return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
949
+ ensure
950
+ release_object(doc_oid) if doc_oid
1053
951
  end
1054
952
 
1055
953
  # Translate a non-by-value Runtime result into a plain Ruby value, surfacing
1056
954
  # DOM nodes as `{ "__lightpanda_node__" => "..." }` so the Driver can wrap
1057
955
  # them. The sentinel key (rather than a plain "objectId") prevents
1058
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.
1059
961
  def unwrap_call_result(result)
1060
962
  return nil if result["type"] == "undefined"
1061
963
  return nil if result["subtype"] == "null"
@@ -1065,6 +967,8 @@ module Capybara
1065
967
  return { "__lightpanda_node__" => object_id } if result["subtype"] == "node"
1066
968
  return serialize_remote_array(object_id) if result["subtype"] == "array"
1067
969
  return serialize_remote_object(object_id) if result["type"] == "object"
970
+
971
+ release_object(object_id)
1068
972
  end
1069
973
 
1070
974
  result["value"]
@@ -1128,17 +1032,14 @@ module Capybara
1128
1032
  rescue DeadBrowserError
1129
1033
  raise
1130
1034
  rescue StandardError
1131
- # 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.
1132
1038
  end
1133
1039
  end
1134
1040
 
1135
1041
  return unless @client.closed?
1136
1042
 
1137
- begin
1138
- reconnect
1139
- rescue StandardError
1140
- nil
1141
- end
1142
1043
  raise DeadBrowserError, "Lightpanda crashed navigating to #{url}"
1143
1044
  end
1144
1045
 
@@ -1231,28 +1132,28 @@ module Capybara
1231
1132
  end
1232
1133
 
1233
1134
  # Poll document.readyState as a fallback when Page.loadEventFired
1234
- # 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
1235
1137
  # readyState values from the old page (e.g. about:blank reports
1236
1138
  # "complete" while the new page is still loading in the background).
1237
1139
  def poll_ready_state(timeout, loaded_event: nil, starting_url: nil)
1238
- deadline = monotonic_time + timeout
1239
1140
  # Use a short per-evaluation timeout because Lightpanda may block
1240
1141
  # all commands while navigating. Without this, a single evaluate()
1241
1142
  # call would consume the entire @options.timeout, making the poll
1242
1143
  # loop effectively a single attempt.
1243
1144
  poll_cmd_timeout = [timeout / 5.0, 2].max
1244
1145
 
1245
- loop do
1246
- break if loaded_event&.set?
1247
- break if @client.closed?
1248
- break if page_ready?(poll_cmd_timeout, starting_url)
1249
- break if monotonic_time > deadline
1250
-
1251
- 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)
1252
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.
1253
1153
  end
1254
1154
 
1255
1155
  POLL_STATE_JS = "(function(){return{r:document.readyState,u:location.href}})()"
1156
+ private_constant :POLL_STATE_JS
1256
1157
 
1257
1158
  def page_ready?(cmd_timeout, starting_url)
1258
1159
  response = @client.command(