capybara-lightpanda 0.2.1 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42246ea44b80c592e6779cf2aa95890c7635fb057b0061064b63bf15004dcde6
4
- data.tar.gz: 63131038538438b32d39d8e46a36005df0306e8daadaa38ff88540874c6c46aa
3
+ metadata.gz: 9bb84252bec4e22492ad1e2165a63842c3fad60ab9d80ee0df60db55c2425c9a
4
+ data.tar.gz: '090fbdca94bf7e482dda301a2aad4b51065211972bff4a06a9aa3e848a2a9ef4'
5
5
  SHA512:
6
- metadata.gz: 1728491bdd3d3dac24559cc663ee250af16f90d7944d2c27be0b879ddbfc23a2a00181f39970d7622fd1b640449d97c7d373c4f6a414aae9073dc0f3cf92d1f8
7
- data.tar.gz: 47e0408c55b5150347656d8136b6dc993b39d38a051a39efec480e2d913cf799f45c18242fd031bbb6962f79b7a39d63cc2eec8d2c14e2f42f34ed3b029af867
6
+ metadata.gz: 3bf39418c02c38ead4fc8ead897064730711d97bcd2e787d049ae1860e89bcf07e155f4ebb3743263e4c531250935d73e02ae768196aa5d27e205f8c3f903d1e
7
+ data.tar.gz: 145951b47b0ef2ce55e4c02932c006efdaa48fd6ac556daf5a18fef5949d63ebf861eabf262a93bb1a2a51dcc46b89d9d510fcbcc5c38fc7153dc1a0cd24402b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-05-12
4
+
5
+ > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6109 (published 2026-05-12). The driver refuses to start against older binaries.
6
+
7
+ ### Fixed
8
+
9
+ - Iframe tests no longer crash with `NoExecutionContextError` when the page inside the iframe navigates. `switch_to_frame` and `within_frame` now re-resolve the iframe element on a stale-handle error and retry once, so multi-step flows inside an `<iframe>` (logins, embedded checkouts, OAuth dialogs) stay stable across child-frame navigations.
10
+ - Clicking a submit button no longer fires `submit` twice. On Turbo Drive pages this previously produced duplicate `turbo:submit-start` events and could abort the real fetch mid-flight.
11
+
12
+ ### Removed
13
+
14
+ - The gem no longer ships its own XPath engine — Lightpanda evaluates XPath natively now, including the full XPath 1.0 selector surface. `find(:xpath, …)`, Capybara's automatic XPath fallback, and any custom XPath finders all keep working unchanged; the same behavior, with ~750 fewer lines injected per test process.
15
+ - The gem's JavaScript shim for `back` / `forward` is gone. Navigation history now routes through Lightpanda directly, which is more reliable across navigation crashes.
16
+
17
+ ## [0.2.2] - 2026-05-06
18
+
19
+ > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6065 (published 2026-05-06). The driver refuses to start against older binaries.
20
+
21
+ ### Fixed
22
+
23
+ - Turbo Drive `<form>` submissions now intercept correctly. Forms inside a
24
+ Hotwire / Turbo Drive page no longer crash on submit, and Turbo's `submit`
25
+ interceptors fire as they should — `click_button`, `find('form').submit`,
26
+ and Enter-key implicit submission all complete end-to-end.
27
+ - `evaluate_script` and `execute_script` calls with top-level `const` / `let`
28
+ no longer collide across calls. Consecutive scripts that each declare the
29
+ same identifier used to fail with `Identifier 'foo' has already been
30
+ declared`; they're now isolated. `execute_script` also now raises on
31
+ JavaScript errors instead of silently swallowing them.
32
+ - Same-document fragment-only `<a href="#…">` clicks update the URL hash
33
+ instead of triggering a real navigation. Tests that drive DOM updates from
34
+ an anchor click no longer lose pending `setTimeout` callbacks or have form
35
+ values cleared from under them.
36
+ - `body` returns an empty string rather than crashing during the brief window
37
+ after `reset_session!` when the new session has a target but no document yet.
38
+ - Stale element references during cross-document navigation now resolve to
39
+ `nil` internally instead of bubbling a browser error up to your test,
40
+ letting Capybara's automatic-reload pick a fresh element.
41
+
42
+ ### Internal
43
+
44
+ - One internal polyfill removed: Lightpanda now matches the spec when a DOM
45
+ event listener throws (a throwing listener no longer halts the rest of the
46
+ bubble walk), so the gem doesn't need to compensate. No code change required
47
+ on your end.
48
+
3
49
  ## [0.2.1] - 2026-05-05
4
50
 
5
51
  ### Fixed
@@ -35,6 +35,7 @@ module Capybara
35
35
  @modal_messages = []
36
36
  @modal_handler_installed = false
37
37
  @frame_stack = []
38
+ @frame_locators = []
38
39
  @frames = Concurrent::Hash.new
39
40
  @turbo_event = Utils::Event.new
40
41
  @turbo_event.set
@@ -108,7 +109,7 @@ module Capybara
108
109
  @page_events_enabled = false
109
110
  @modal_handler_installed = false
110
111
  @modal_messages.clear
111
- @frame_stack.clear
112
+ clear_frames
112
113
  # Network#reset, not #clear: disposing the BrowserContext also
113
114
  # destroyed the Network domain and its subscriptions, so we must
114
115
  # flip @enabled back to false — otherwise the next #enable
@@ -153,7 +154,7 @@ module Capybara
153
154
  @target_id = nil
154
155
  @session_id = nil
155
156
  @modal_handler_installed = false
156
- @frame_stack.clear
157
+ clear_frames
157
158
  end
158
159
 
159
160
  def command(method, **params)
@@ -217,11 +218,11 @@ module Capybara
217
218
  end
218
219
 
219
220
  def back
220
- wait_for_navigation { execute("history.back()") }
221
+ wait_for_navigation { navigate_history(-1) }
221
222
  end
222
223
 
223
224
  def forward
224
- wait_for_navigation { execute("history.forward()") }
225
+ wait_for_navigation { navigate_history(+1) }
225
226
  end
226
227
 
227
228
  def refresh
@@ -238,7 +239,11 @@ module Capybara
238
239
  end
239
240
 
240
241
  def body
241
- evaluate("document.documentElement.outerHTML")
242
+ # Guard against the brief window after a fresh BrowserContext / target
243
+ # is created where the V8 context exists but `document.documentElement`
244
+ # is still null. Hit by Capybara's `#reset_session! resets page body`
245
+ # spec since the 0.2.0 Ferrum-style reset rewrite.
246
+ evaluate("(document.documentElement && document.documentElement.outerHTML) || ''")
242
247
  end
243
248
  alias html body
244
249
 
@@ -247,9 +252,25 @@ module Capybara
247
252
  # and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
248
253
  # Both paths use `returnByValue: false` and unwrap so DOM-node returns
249
254
  # come back as `{ "__lightpanda_node__" => ... }` for the Driver to wrap.
255
+ #
256
+ # Even the no-args path wraps the expression in an IIFE to isolate
257
+ # top-level `const`/`let` declarations. Upstream Lightpanda retains
258
+ # those bindings across `Runtime.evaluate` calls (V8 starts each call
259
+ # with fresh lexical scope per spec), so a second `const sel = ...`
260
+ # raises `SyntaxError: Identifier 'sel' has already been declared`.
261
+ # Wrapping pushes the declarations into a function scope that gets
262
+ # discarded when the IIFE returns.
263
+ #
264
+ # Use direct `eval` inside the IIFE so the user's text can be a bare
265
+ # expression (`'foo'`), a `throw` statement, OR a multi-statement
266
+ # script with `const`/`let`. `eval`'s completion-value semantics
267
+ # return the last expression's value in all cases. A naive
268
+ # `return EXPR;` wrap would syntax-error on `throw …` and on
269
+ # multi-statement scripts.
250
270
  def evaluate(expression, *args)
251
271
  if args.empty?
252
- response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
272
+ wrapped = "(function(){return eval(#{expression.to_json})})()"
273
+ response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: true)
253
274
  if response["exceptionDetails"]
254
275
  debug_js_failure("evaluate", expression, response)
255
276
  raise JavaScriptError, response
@@ -263,9 +284,20 @@ module Capybara
263
284
  end
264
285
 
265
286
  # Execute JS without returning a value.
287
+ #
288
+ # Like `evaluate`, the no-args path wraps in an IIFE — same upstream
289
+ # `const`/`let` leak. Also raises on JS exceptions so silent
290
+ # failures don't mask test bugs (the previous fast path swallowed them
291
+ # because `awaitPromise: false` was checked but `exceptionDetails` was
292
+ # not).
266
293
  def execute(expression, *args)
267
294
  if args.empty?
268
- page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: false)
295
+ wrapped = "(function(){#{expression}})()"
296
+ response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: false)
297
+ if response["exceptionDetails"]
298
+ debug_js_failure("execute", expression, response)
299
+ raise JavaScriptError, response
300
+ end
269
301
  return nil
270
302
  end
271
303
 
@@ -497,13 +529,16 @@ module Capybara
497
529
 
498
530
  def push_frame(node)
499
531
  @frame_stack.push(node)
532
+ @frame_locators.push(capture_frame_locator(node))
500
533
  end
501
534
 
502
535
  def pop_frame
536
+ @frame_locators.pop
503
537
  @frame_stack.pop
504
538
  end
505
539
 
506
540
  def clear_frames
541
+ @frame_locators.clear
507
542
  @frame_stack.clear
508
543
  end
509
544
 
@@ -601,16 +636,26 @@ module Capybara
601
636
  INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
602
637
 
603
638
  # JS function for finding elements within a node.
604
- # Works in any execution context (top frame or iframe). Any throw from
605
- # querySelectorAll means the selector is malformed (the spec only allows
606
- # SYNTAX_ERR DOMException; Lightpanda's V8 currently throws a generic
607
- # Error with messages like "InvalidClassSelector"). Re-throw with the
608
- # marker prefix so Ruby converts to InvalidSelector regardless.
639
+ # Works in any execution context (top frame or iframe). For CSS, any
640
+ # throw from querySelectorAll means the selector is malformed
641
+ # (re-throw with the marker prefix so Ruby converts to InvalidSelector).
642
+ # XPath routes through native `Document.evaluate` + `XPathResult`
643
+ # (Lightpanda PR #2305, in nightly >=6109); on parse error we return
644
+ # [] silently to match Capybara's internal XPath generator, which
645
+ # sometimes produces selectors with empty trailing predicates like
646
+ # `(...)[]` that native rejects but `has_element?` expects to behave
647
+ # as "not found" rather than raise InvalidSelector.
648
+ # `XPathResult.ORDERED_NODE_SNAPSHOT_TYPE` is `7` in the spec — inlined
649
+ # so the JS doesn't depend on the enum being defined as a constant.
609
650
  FIND_WITHIN_JS = <<~JS.freeze
610
651
  function(method, selector) {
611
652
  if (method === 'xpath') {
612
- if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, this);
613
- return [];
653
+ try {
654
+ var r = this.ownerDocument.evaluate(selector, this, null, 7, null);
655
+ var nodes = [];
656
+ for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
657
+ return nodes;
658
+ } catch(e) { return []; }
614
659
  }
615
660
  try { return Array.from(this.querySelectorAll(selector)); }
616
661
  catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
@@ -625,8 +670,12 @@ module Capybara
625
670
  try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
626
671
  if (!doc) return [];
627
672
  if (method === 'xpath') {
628
- if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, doc);
629
- return [];
673
+ try {
674
+ var r = doc.evaluate(selector, doc, null, 7, null);
675
+ var nodes = [];
676
+ for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
677
+ return nodes;
678
+ } catch(e) { return []; }
630
679
  }
631
680
  try { return Array.from(doc.querySelectorAll(selector)); }
632
681
  catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
@@ -634,14 +683,97 @@ module Capybara
634
683
  JS
635
684
  private_constant :FIND_IN_FRAME_JS
636
685
 
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
692
+ 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; }
697
+ }
698
+ return {
699
+ id: this.getAttribute('id'),
700
+ name: this.getAttribute('name'),
701
+ src: this.getAttribute('src'),
702
+ index: index,
703
+ };
704
+ }
705
+ 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
757
+
637
758
  def find_in_document(method, selector)
638
759
  with_default_context_wait do
639
760
  # Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
640
761
  # through) to a string before quoting. Symbol#inspect returns `:p`,
641
762
  # which would inject a bare token into the JS source.
642
763
  selector_literal = selector.to_s.inspect
764
+ # XPath parse errors return [] silently to match Capybara's expected
765
+ # "not found" behavior (see FIND_WITHIN_JS comment above for why).
643
766
  js = if method == "xpath"
644
- "(typeof _lightpanda !== 'undefined') ? _lightpanda.xpathFind(#{selector_literal}, document) : []"
767
+ <<~XPATH_FIND
768
+ (function() {
769
+ try {
770
+ var r = document.evaluate(#{selector_literal}, document, null, 7, null);
771
+ var nodes = [];
772
+ for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
773
+ return nodes;
774
+ } catch(e) { return []; }
775
+ })()
776
+ XPATH_FIND
645
777
  else
646
778
  <<~CSS_FIND
647
779
  (function() {
@@ -658,10 +790,27 @@ module Capybara
658
790
  end
659
791
 
660
792
  def find_in_frame(method, selector)
661
- frame_node = @frame_stack.last
662
- result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
663
- return_by_value: false)
664
- extract_node_object_ids(result)
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
813
+ end
665
814
  rescue JavaScriptError => e
666
815
  raise_invalid_selector(e, method, selector)
667
816
  end
@@ -674,6 +823,63 @@ module Capybara
674
823
  raise js_error
675
824
  end
676
825
 
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
+
677
883
  # Extract individual node objectIds from a remote array reference.
678
884
  def extract_node_object_ids(result)
679
885
  return [] unless result && result["objectId"]
@@ -981,6 +1187,21 @@ module Capybara
981
1187
  await_navigation(&)
982
1188
  end
983
1189
 
1190
+ # Step the session history by `offset` (-1 = back, +1 = forward) using
1191
+ # native CDP. `Page.getNavigationHistory` returns the entry list and
1192
+ # `currentIndex`; `Page.navigateToHistoryEntry` jumps to the chosen
1193
+ # entry's `id`. No-op when the offset would step past either end so
1194
+ # the behavior matches `history.back()` / `history.forward()` on a
1195
+ # bounded session history.
1196
+ def navigate_history(offset)
1197
+ history = page_command("Page.getNavigationHistory")
1198
+ target_index = history["currentIndex"] + offset
1199
+ entries = history["entries"]
1200
+ return if target_index.negative? || target_index >= entries.length
1201
+
1202
+ page_command("Page.navigateToHistoryEntry", entryId: entries[target_index]["id"])
1203
+ end
1204
+
984
1205
  # Common navigation lifecycle shared by `wait_for_page_load` (fresh
985
1206
  # `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
986
1207
  # Subscribes to Page.loadEventFired, runs the trigger, waits briefly for