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 +4 -4
- data/CHANGELOG.md +46 -0
- data/lib/capybara/lightpanda/browser.rb +242 -21
- data/lib/capybara/lightpanda/javascripts/index.js +0 -759
- data/lib/capybara/lightpanda/javascripts/polyfills.js +165 -34
- data/lib/capybara/lightpanda/node.rb +28 -28
- data/lib/capybara/lightpanda/process.rb +13 -6
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bb84252bec4e22492ad1e2165a63842c3fad60ab9d80ee0df60db55c2425c9a
|
|
4
|
+
data.tar.gz: '090fbdca94bf7e482dda301a2aad4b51065211972bff4a06a9aa3e848a2a9ef4'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
221
|
+
wait_for_navigation { navigate_history(-1) }
|
|
221
222
|
end
|
|
222
223
|
|
|
223
224
|
def forward
|
|
224
|
-
wait_for_navigation {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
605
|
-
# querySelectorAll means the selector is malformed
|
|
606
|
-
#
|
|
607
|
-
#
|
|
608
|
-
#
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|