capybara-lightpanda 0.2.2 → 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 +14 -0
- data/lib/capybara/lightpanda/browser.rb +208 -18
- data/lib/capybara/lightpanda/javascripts/index.js +0 -759
- data/lib/capybara/lightpanda/node.rb +9 -27
- data/lib/capybara/lightpanda/process.rb +11 -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,19 @@
|
|
|
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
|
+
|
|
3
17
|
## [0.2.2] - 2026-05-06
|
|
4
18
|
|
|
5
19
|
> **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6065 (published 2026-05-06). The driver refuses to start against older binaries.
|
|
@@ -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
|
|
@@ -528,13 +529,16 @@ module Capybara
|
|
|
528
529
|
|
|
529
530
|
def push_frame(node)
|
|
530
531
|
@frame_stack.push(node)
|
|
532
|
+
@frame_locators.push(capture_frame_locator(node))
|
|
531
533
|
end
|
|
532
534
|
|
|
533
535
|
def pop_frame
|
|
536
|
+
@frame_locators.pop
|
|
534
537
|
@frame_stack.pop
|
|
535
538
|
end
|
|
536
539
|
|
|
537
540
|
def clear_frames
|
|
541
|
+
@frame_locators.clear
|
|
538
542
|
@frame_stack.clear
|
|
539
543
|
end
|
|
540
544
|
|
|
@@ -632,16 +636,26 @@ module Capybara
|
|
|
632
636
|
INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
|
|
633
637
|
|
|
634
638
|
# JS function for finding elements within a node.
|
|
635
|
-
# Works in any execution context (top frame or iframe).
|
|
636
|
-
# querySelectorAll means the selector is malformed
|
|
637
|
-
#
|
|
638
|
-
#
|
|
639
|
-
#
|
|
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.
|
|
640
650
|
FIND_WITHIN_JS = <<~JS.freeze
|
|
641
651
|
function(method, selector) {
|
|
642
652
|
if (method === 'xpath') {
|
|
643
|
-
|
|
644
|
-
|
|
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 []; }
|
|
645
659
|
}
|
|
646
660
|
try { return Array.from(this.querySelectorAll(selector)); }
|
|
647
661
|
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
@@ -656,8 +670,12 @@ module Capybara
|
|
|
656
670
|
try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
|
|
657
671
|
if (!doc) return [];
|
|
658
672
|
if (method === 'xpath') {
|
|
659
|
-
|
|
660
|
-
|
|
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 []; }
|
|
661
679
|
}
|
|
662
680
|
try { return Array.from(doc.querySelectorAll(selector)); }
|
|
663
681
|
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
@@ -665,14 +683,97 @@ module Capybara
|
|
|
665
683
|
JS
|
|
666
684
|
private_constant :FIND_IN_FRAME_JS
|
|
667
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
|
+
|
|
668
758
|
def find_in_document(method, selector)
|
|
669
759
|
with_default_context_wait do
|
|
670
760
|
# Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
|
|
671
761
|
# through) to a string before quoting. Symbol#inspect returns `:p`,
|
|
672
762
|
# which would inject a bare token into the JS source.
|
|
673
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).
|
|
674
766
|
js = if method == "xpath"
|
|
675
|
-
|
|
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
|
|
676
777
|
else
|
|
677
778
|
<<~CSS_FIND
|
|
678
779
|
(function() {
|
|
@@ -689,10 +790,27 @@ module Capybara
|
|
|
689
790
|
end
|
|
690
791
|
|
|
691
792
|
def find_in_frame(method, selector)
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
696
814
|
rescue JavaScriptError => e
|
|
697
815
|
raise_invalid_selector(e, method, selector)
|
|
698
816
|
end
|
|
@@ -705,6 +823,63 @@ module Capybara
|
|
|
705
823
|
raise js_error
|
|
706
824
|
end
|
|
707
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
|
+
|
|
708
883
|
# Extract individual node objectIds from a remote array reference.
|
|
709
884
|
def extract_node_object_ids(result)
|
|
710
885
|
return [] unless result && result["objectId"]
|
|
@@ -1012,6 +1187,21 @@ module Capybara
|
|
|
1012
1187
|
await_navigation(&)
|
|
1013
1188
|
end
|
|
1014
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
|
+
|
|
1015
1205
|
# Common navigation lifecycle shared by `wait_for_page_load` (fresh
|
|
1016
1206
|
# `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
|
|
1017
1207
|
# Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
|
|
@@ -69,748 +69,9 @@
|
|
|
69
69
|
_signalTurbo('idle');
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
// ====== XPath 1.0 Evaluator ======
|
|
73
|
-
|
|
74
|
-
var XPathEval = (function() {
|
|
75
|
-
|
|
76
|
-
// --- Tokenizer ---
|
|
77
|
-
|
|
78
|
-
var NODE_TYPES = {text:1, node:1, comment:1, 'processing-instruction':1};
|
|
79
|
-
|
|
80
|
-
function tokenize(expr) {
|
|
81
|
-
var toks = [], i = 0, len = expr.length;
|
|
82
|
-
while (i < len) {
|
|
83
|
-
// Skip whitespace
|
|
84
|
-
while (i < len && ' \t\n\r'.indexOf(expr[i]) >= 0) i++;
|
|
85
|
-
if (i >= len) break;
|
|
86
|
-
var c = expr[i];
|
|
87
|
-
|
|
88
|
-
// String literals
|
|
89
|
-
if (c === '"' || c === "'") {
|
|
90
|
-
var q = c, s = ++i;
|
|
91
|
-
while (i < len && expr[i] !== q) i++;
|
|
92
|
-
toks.push({t: 'S', v: expr.substring(s, i)});
|
|
93
|
-
i++;
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Numbers: digits or . followed by digit
|
|
98
|
-
if (c >= '0' && c <= '9' || (c === '.' && i + 1 < len && expr[i + 1] >= '0' && expr[i + 1] <= '9')) {
|
|
99
|
-
var s = i;
|
|
100
|
-
while (i < len && expr[i] >= '0' && expr[i] <= '9') i++;
|
|
101
|
-
if (i < len && expr[i] === '.') { i++; while (i < len && expr[i] >= '0' && expr[i] <= '9') i++; }
|
|
102
|
-
toks.push({t: 'D', v: parseFloat(expr.substring(s, i))});
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Double-char operators
|
|
107
|
-
if (i + 1 < len) {
|
|
108
|
-
var c2 = expr[i + 1];
|
|
109
|
-
if (c === '/' && c2 === '/') { toks.push({t: '//'}); i += 2; continue; }
|
|
110
|
-
if (c === ':' && c2 === ':') { toks.push({t: '::'}); i += 2; continue; }
|
|
111
|
-
if (c === '!' && c2 === '=') { toks.push({t: '!='}); i += 2; continue; }
|
|
112
|
-
if (c === '<' && c2 === '=') { toks.push({t: '<='}); i += 2; continue; }
|
|
113
|
-
if (c === '>' && c2 === '=') { toks.push({t: '>='}); i += 2; continue; }
|
|
114
|
-
if (c === '.' && c2 === '.') { toks.push({t: '..'}); i += 2; continue; }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Single-char operators
|
|
118
|
-
if ('()[],|=<>+-*$/@.'.indexOf(c) >= 0) { toks.push({t: c}); i++; continue; }
|
|
119
|
-
|
|
120
|
-
// Names (NCName, possibly with namespace prefix)
|
|
121
|
-
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_') {
|
|
122
|
-
var s = i;
|
|
123
|
-
while (i < len && /[a-zA-Z0-9_\-.]/.test(expr[i])) i++;
|
|
124
|
-
var name = expr.substring(s, i);
|
|
125
|
-
// Check for namespace prefix (name:localname but not name::)
|
|
126
|
-
if (i < len && expr[i] === ':' && (i + 1 >= len || expr[i + 1] !== ':')) {
|
|
127
|
-
i++; // skip :
|
|
128
|
-
if (i < len && expr[i] === '*') { name += ':*'; i++; }
|
|
129
|
-
else { var ls = i; while (i < len && /[a-zA-Z0-9_\-.]/.test(expr[i])) i++; name += ':' + expr.substring(ls, i); }
|
|
130
|
-
}
|
|
131
|
-
toks.push({t: 'N', v: name});
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
i++; // skip unknown characters
|
|
136
|
-
}
|
|
137
|
-
toks.push({t: 'E'}); // EOF
|
|
138
|
-
return toks;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// --- Parser ---
|
|
142
|
-
// Recursive descent parser producing an AST from XPath 1.0 tokens.
|
|
143
|
-
|
|
144
|
-
function Parser(tokens) { this.tk = tokens; this.p = 0; }
|
|
145
|
-
|
|
146
|
-
Parser.prototype.peek = function() { return this.tk[this.p]; };
|
|
147
|
-
Parser.prototype.next = function() { return this.tk[this.p++]; };
|
|
148
|
-
Parser.prototype.expect = function(t) {
|
|
149
|
-
var tok = this.next();
|
|
150
|
-
if (tok.t !== t) throw new Error('XPath parse error: expected ' + t + ', got ' + tok.t);
|
|
151
|
-
return tok;
|
|
152
|
-
};
|
|
153
|
-
Parser.prototype.at = function(t) { return this.peek().t === t; };
|
|
154
|
-
Parser.prototype.match = function(t) { if (this.at(t)) { this.p++; return true; } return false; };
|
|
155
|
-
Parser.prototype.lookahead = function(offset) { return this.tk[this.p + offset] || {t: 'E'}; };
|
|
156
|
-
|
|
157
|
-
Parser.prototype.parseExpr = function() { return this.parseOrExpr(); };
|
|
158
|
-
|
|
159
|
-
Parser.prototype.parseOrExpr = function() {
|
|
160
|
-
var left = this.parseAndExpr();
|
|
161
|
-
while (this.peek().t === 'N' && this.peek().v === 'or') { this.next(); left = {op: 'or', l: left, r: this.parseAndExpr()}; }
|
|
162
|
-
return left;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
Parser.prototype.parseAndExpr = function() {
|
|
166
|
-
var left = this.parseEqualityExpr();
|
|
167
|
-
while (this.peek().t === 'N' && this.peek().v === 'and') { this.next(); left = {op: 'and', l: left, r: this.parseEqualityExpr()}; }
|
|
168
|
-
return left;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
Parser.prototype.parseEqualityExpr = function() {
|
|
172
|
-
var left = this.parseRelationalExpr();
|
|
173
|
-
while (this.at('=') || this.at('!=')) {
|
|
174
|
-
var op = this.next().t === '=' ? 'eq' : 'neq';
|
|
175
|
-
left = {op: op, l: left, r: this.parseRelationalExpr()};
|
|
176
|
-
}
|
|
177
|
-
return left;
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
Parser.prototype.parseRelationalExpr = function() {
|
|
181
|
-
var left = this.parseAdditiveExpr();
|
|
182
|
-
while (true) {
|
|
183
|
-
var t = this.peek().t, op;
|
|
184
|
-
if (t === '<') op = 'lt'; else if (t === '>') op = 'gt'; else if (t === '<=') op = 'lte'; else if (t === '>=') op = 'gte'; else break;
|
|
185
|
-
this.next();
|
|
186
|
-
left = {op: op, l: left, r: this.parseAdditiveExpr()};
|
|
187
|
-
}
|
|
188
|
-
return left;
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
Parser.prototype.parseAdditiveExpr = function() {
|
|
192
|
-
var left = this.parseMultExpr();
|
|
193
|
-
while (this.at('+') || this.at('-')) {
|
|
194
|
-
var op = this.next().t === '+' ? 'add' : 'sub';
|
|
195
|
-
left = {op: op, l: left, r: this.parseMultExpr()};
|
|
196
|
-
}
|
|
197
|
-
return left;
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
// After a complete unary expression, * is multiply; div/mod are operators
|
|
201
|
-
Parser.prototype.parseMultExpr = function() {
|
|
202
|
-
var left = this.parseUnaryExpr();
|
|
203
|
-
while (true) {
|
|
204
|
-
var t = this.peek(), op;
|
|
205
|
-
if (t.t === '*') op = 'mul';
|
|
206
|
-
else if (t.t === 'N' && t.v === 'div') op = 'div';
|
|
207
|
-
else if (t.t === 'N' && t.v === 'mod') op = 'mod';
|
|
208
|
-
else break;
|
|
209
|
-
this.next();
|
|
210
|
-
left = {op: op, l: left, r: this.parseUnaryExpr()};
|
|
211
|
-
}
|
|
212
|
-
return left;
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
Parser.prototype.parseUnaryExpr = function() {
|
|
216
|
-
if (this.at('-')) { this.next(); return {op: 'neg', a: this.parseUnaryExpr()}; }
|
|
217
|
-
return this.parseUnionExpr();
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
Parser.prototype.parseUnionExpr = function() {
|
|
221
|
-
var left = this.parsePathExpr();
|
|
222
|
-
while (this.match('|')) { left = {op: 'union', l: left, r: this.parsePathExpr()}; }
|
|
223
|
-
return left;
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
// Distinguishes filter expressions (starting with primary) from location paths
|
|
227
|
-
Parser.prototype.parsePathExpr = function() {
|
|
228
|
-
var t = this.peek();
|
|
229
|
-
|
|
230
|
-
// Absolute path: / or //
|
|
231
|
-
if (t.t === '/' || t.t === '//') return this.parseAbsPath();
|
|
232
|
-
|
|
233
|
-
// Check if this starts a filter expression (primary expr)
|
|
234
|
-
var isFilter = false;
|
|
235
|
-
if (t.t === '(' || t.t === 'S' || t.t === 'D' || t.t === '$') isFilter = true;
|
|
236
|
-
else if (t.t === 'N' && this.lookahead(1).t === '(' && !NODE_TYPES[t.v]) isFilter = true;
|
|
237
|
-
|
|
238
|
-
if (isFilter) {
|
|
239
|
-
var primary = this.parsePrimaryExpr();
|
|
240
|
-
// Parse predicates on the filter expression
|
|
241
|
-
while (this.at('[')) { this.next(); var pred = this.parseExpr(); this.expect(']'); primary = {op: 'filt', e: primary, pred: pred}; }
|
|
242
|
-
// Optional / or // after filter
|
|
243
|
-
if (this.at('/') || this.at('//')) {
|
|
244
|
-
var dsl = this.next().t === '//';
|
|
245
|
-
var steps = this.parseRelSteps();
|
|
246
|
-
if (dsl) steps.unshift({ax: 'descendant-or-self', test: {ty: 'type', nt: 'node'}, preds: []});
|
|
247
|
-
return {op: 'fpath', f: primary, steps: steps};
|
|
248
|
-
}
|
|
249
|
-
return primary;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Relative location path
|
|
253
|
-
return this.parseRelPath();
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
Parser.prototype.parseAbsPath = function() {
|
|
257
|
-
var steps = [];
|
|
258
|
-
if (this.match('//')) {
|
|
259
|
-
steps.push({ax: 'descendant-or-self', test: {ty: 'type', nt: 'node'}, preds: []});
|
|
260
|
-
steps = steps.concat(this.parseRelSteps());
|
|
261
|
-
} else {
|
|
262
|
-
this.expect('/');
|
|
263
|
-
if (this.canStartStep()) steps = this.parseRelSteps();
|
|
264
|
-
}
|
|
265
|
-
return {op: 'path', abs: true, steps: steps};
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
Parser.prototype.parseRelPath = function() {
|
|
269
|
-
return {op: 'path', abs: false, steps: this.parseRelSteps()};
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
Parser.prototype.parseRelSteps = function() {
|
|
273
|
-
var steps = [this.parseStep()];
|
|
274
|
-
while (this.at('/') || this.at('//')) {
|
|
275
|
-
if (this.next().t === '//') steps.push({ax: 'descendant-or-self', test: {ty: 'type', nt: 'node'}, preds: []});
|
|
276
|
-
steps.push(this.parseStep());
|
|
277
|
-
}
|
|
278
|
-
return steps;
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
Parser.prototype.canStartStep = function() {
|
|
282
|
-
var t = this.peek().t;
|
|
283
|
-
return t === 'N' || t === '*' || t === '.' || t === '..' || t === '@';
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
Parser.prototype.parseStep = function() {
|
|
287
|
-
// Abbreviated steps
|
|
288
|
-
if (this.match('.')) return {ax: 'self', test: {ty: 'type', nt: 'node'}, preds: []};
|
|
289
|
-
if (this.match('..')) return {ax: 'parent', test: {ty: 'type', nt: 'node'}, preds: []};
|
|
290
|
-
|
|
291
|
-
// Determine axis
|
|
292
|
-
var axis = 'child';
|
|
293
|
-
if (this.match('@')) {
|
|
294
|
-
axis = 'attribute';
|
|
295
|
-
} else if (this.peek().t === 'N' && this.lookahead(1).t === '::') {
|
|
296
|
-
axis = this.next().v; this.next(); // consume name and ::
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Node test
|
|
300
|
-
var test;
|
|
301
|
-
if (this.match('*')) {
|
|
302
|
-
test = {ty: 'name', n: '*'};
|
|
303
|
-
} else if (this.peek().t === 'N') {
|
|
304
|
-
var name = this.peek().v;
|
|
305
|
-
if (NODE_TYPES[name] && this.lookahead(1).t === '(') {
|
|
306
|
-
this.next(); this.next(); // consume name and (
|
|
307
|
-
if (name === 'processing-instruction' && this.at('S')) this.next(); // optional literal
|
|
308
|
-
this.expect(')');
|
|
309
|
-
test = {ty: 'type', nt: name};
|
|
310
|
-
} else {
|
|
311
|
-
this.next();
|
|
312
|
-
test = {ty: 'name', n: name};
|
|
313
|
-
}
|
|
314
|
-
} else {
|
|
315
|
-
throw new Error('XPath parse error: expected node test, got ' + this.peek().t);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Predicates
|
|
319
|
-
var preds = [];
|
|
320
|
-
while (this.at('[')) { this.next(); preds.push(this.parseExpr()); this.expect(']'); }
|
|
321
|
-
|
|
322
|
-
return {ax: axis, test: test, preds: preds};
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
Parser.prototype.parsePrimaryExpr = function() {
|
|
326
|
-
if (this.at('S')) { var v = this.next().v; return {op: 'lit', v: v}; }
|
|
327
|
-
if (this.at('D')) { var v = this.next().v; return {op: 'num', v: v}; }
|
|
328
|
-
if (this.match('$')) { return {op: 'var', v: this.expect('N').v}; }
|
|
329
|
-
if (this.match('(')) { var e = this.parseExpr(); this.expect(')'); return e; }
|
|
330
|
-
if (this.at('N')) {
|
|
331
|
-
var name = this.next().v;
|
|
332
|
-
this.expect('(');
|
|
333
|
-
var args = [];
|
|
334
|
-
if (!this.at(')')) {
|
|
335
|
-
args.push(this.parseExpr());
|
|
336
|
-
while (this.match(',')) args.push(this.parseExpr());
|
|
337
|
-
}
|
|
338
|
-
this.expect(')');
|
|
339
|
-
return {op: 'fn', name: name, args: args};
|
|
340
|
-
}
|
|
341
|
-
throw new Error('XPath parse error: expected primary expression, got ' + this.peek().t);
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
// --- Evaluator Utilities ---
|
|
345
|
-
|
|
346
|
-
function stringVal(node) {
|
|
347
|
-
if (!node) return '';
|
|
348
|
-
if (node.nodeType === 1 || node.nodeType === 9) return node.textContent || '';
|
|
349
|
-
if (node.nodeType === 2) return node.value || '';
|
|
350
|
-
return node.nodeValue || node.textContent || '';
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function toStr(val) {
|
|
354
|
-
if (Array.isArray(val)) return val.length > 0 ? stringVal(val[0]) : '';
|
|
355
|
-
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
|
356
|
-
if (typeof val === 'number') return isNaN(val) ? 'NaN' : String(val);
|
|
357
|
-
return String(val);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function toNum(val) {
|
|
361
|
-
if (typeof val === 'number') return val;
|
|
362
|
-
if (typeof val === 'boolean') return val ? 1 : 0;
|
|
363
|
-
if (Array.isArray(val)) val = toStr(val);
|
|
364
|
-
var s = String(val).trim();
|
|
365
|
-
return s === '' ? NaN : Number(s);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function toBool(val) {
|
|
369
|
-
if (Array.isArray(val)) return val.length > 0;
|
|
370
|
-
if (typeof val === 'string') return val.length > 0;
|
|
371
|
-
if (typeof val === 'number') return val !== 0 && !isNaN(val);
|
|
372
|
-
return Boolean(val);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// --- Comparison ---
|
|
376
|
-
|
|
377
|
-
function cmpOp(a, b, op) {
|
|
378
|
-
switch (op) {
|
|
379
|
-
case 'eq': return a === b;
|
|
380
|
-
case 'neq': return a !== b;
|
|
381
|
-
case 'lt': return a < b;
|
|
382
|
-
case 'gt': return a > b;
|
|
383
|
-
case 'lte': return a <= b;
|
|
384
|
-
case 'gte': return a >= b;
|
|
385
|
-
}
|
|
386
|
-
return false;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// XPath comparison with type coercion rules per spec section 3.4
|
|
390
|
-
function xCmp(left, right, op) {
|
|
391
|
-
var isEq = (op === 'eq' || op === 'neq');
|
|
392
|
-
var lArr = Array.isArray(left), rArr = Array.isArray(right);
|
|
393
|
-
|
|
394
|
-
// Both node-sets
|
|
395
|
-
if (lArr && rArr) {
|
|
396
|
-
for (var i = 0; i < left.length; i++) {
|
|
397
|
-
var lv = stringVal(left[i]);
|
|
398
|
-
for (var j = 0; j < right.length; j++) {
|
|
399
|
-
if (isEq ? cmpOp(lv, stringVal(right[j]), op) : cmpOp(toNum(lv), toNum(stringVal(right[j])), op)) return true;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
return false;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// One node-set, one scalar
|
|
406
|
-
if (lArr || rArr) {
|
|
407
|
-
var ns = lArr ? left : right, other = lArr ? right : left, nsLeft = lArr;
|
|
408
|
-
|
|
409
|
-
// Boolean comparison: convert node-set to boolean
|
|
410
|
-
if (typeof other === 'boolean') {
|
|
411
|
-
var b = ns.length > 0;
|
|
412
|
-
return cmpOp(nsLeft ? b : other, nsLeft ? other : b, op);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
for (var i = 0; i < ns.length; i++) {
|
|
416
|
-
var sv = stringVal(ns[i]);
|
|
417
|
-
var a, b;
|
|
418
|
-
if (typeof other === 'number') {
|
|
419
|
-
a = nsLeft ? toNum(sv) : other;
|
|
420
|
-
b = nsLeft ? other : toNum(sv);
|
|
421
|
-
} else if (isEq) {
|
|
422
|
-
a = nsLeft ? sv : String(other);
|
|
423
|
-
b = nsLeft ? String(other) : sv;
|
|
424
|
-
} else {
|
|
425
|
-
a = nsLeft ? toNum(sv) : toNum(String(other));
|
|
426
|
-
b = nsLeft ? toNum(String(other)) : toNum(sv);
|
|
427
|
-
}
|
|
428
|
-
if (cmpOp(a, b, op)) return true;
|
|
429
|
-
}
|
|
430
|
-
return false;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Neither is a node-set
|
|
434
|
-
if (isEq) {
|
|
435
|
-
if (typeof left === 'boolean' || typeof right === 'boolean') return cmpOp(toBool(left), toBool(right), op);
|
|
436
|
-
if (typeof left === 'number' || typeof right === 'number') return cmpOp(toNum(left), toNum(right), op);
|
|
437
|
-
return cmpOp(toStr(left), toStr(right), op);
|
|
438
|
-
}
|
|
439
|
-
return cmpOp(toNum(left), toNum(right), op);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// --- Axis Traversal ---
|
|
443
|
-
|
|
444
|
-
function addDesc(node, out) {
|
|
445
|
-
var c = node.firstChild;
|
|
446
|
-
while (c) { out.push(c); addDesc(c, out); c = c.nextSibling; }
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function addFollowing(node, out) {
|
|
450
|
-
var n = node;
|
|
451
|
-
while (n) {
|
|
452
|
-
var s = n.nextSibling;
|
|
453
|
-
while (s) { out.push(s); addDesc(s, out); s = s.nextSibling; }
|
|
454
|
-
n = n.parentNode;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function addPrecedingSubtree(node, out) {
|
|
459
|
-
var c = node.lastChild;
|
|
460
|
-
while (c) { addPrecedingSubtree(c, out); c = c.previousSibling; }
|
|
461
|
-
out.push(node);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function addPreceding(node, out) {
|
|
465
|
-
var n = node;
|
|
466
|
-
while (n.parentNode) {
|
|
467
|
-
var s = n.previousSibling;
|
|
468
|
-
while (s) { addPrecedingSubtree(s, out); s = s.previousSibling; }
|
|
469
|
-
n = n.parentNode;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function getAxisNodes(node, axis) {
|
|
474
|
-
var out = [], c, p;
|
|
475
|
-
switch (axis) {
|
|
476
|
-
case 'child':
|
|
477
|
-
c = node.firstChild; while (c) { out.push(c); c = c.nextSibling; } break;
|
|
478
|
-
case 'descendant':
|
|
479
|
-
addDesc(node, out); break;
|
|
480
|
-
case 'descendant-or-self':
|
|
481
|
-
out.push(node); addDesc(node, out); break;
|
|
482
|
-
case 'self':
|
|
483
|
-
out.push(node); break;
|
|
484
|
-
case 'parent':
|
|
485
|
-
if (node.parentNode) out.push(node.parentNode); break;
|
|
486
|
-
case 'ancestor':
|
|
487
|
-
// Reverse axis — emit in PROXIMITY order (nearest first) so positional
|
|
488
|
-
// predicates evaluate correctly: ancestor::*[1] picks the parent, not
|
|
489
|
-
// the root. The final node-set is sorted into document order at the
|
|
490
|
-
// XPathEval.find entry point per the XPath spec.
|
|
491
|
-
p = node.parentNode; while (p) { out.push(p); p = p.parentNode; } break;
|
|
492
|
-
case 'ancestor-or-self':
|
|
493
|
-
out.push(node); p = node.parentNode; while (p) { out.push(p); p = p.parentNode; } break;
|
|
494
|
-
case 'following-sibling':
|
|
495
|
-
c = node.nextSibling; while (c) { out.push(c); c = c.nextSibling; } break;
|
|
496
|
-
case 'preceding-sibling':
|
|
497
|
-
// Reverse axis — emit in proximity order (closest first).
|
|
498
|
-
c = node.previousSibling; while (c) { out.push(c); c = c.previousSibling; } break;
|
|
499
|
-
case 'following':
|
|
500
|
-
addFollowing(node, out); break;
|
|
501
|
-
case 'preceding':
|
|
502
|
-
addPreceding(node, out); break;
|
|
503
|
-
case 'attribute':
|
|
504
|
-
if (node.attributes) { for (var i = 0; i < node.attributes.length; i++) out.push(node.attributes[i]); } break;
|
|
505
|
-
case 'namespace':
|
|
506
|
-
break; // stub
|
|
507
|
-
}
|
|
508
|
-
return out;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// --- Node Test Matching ---
|
|
512
|
-
|
|
513
|
-
function matchTest(node, test, axis) {
|
|
514
|
-
if (test.ty === 'type') {
|
|
515
|
-
switch (test.nt) {
|
|
516
|
-
case 'node': return true;
|
|
517
|
-
case 'text': return node.nodeType === 3;
|
|
518
|
-
case 'comment': return node.nodeType === 8;
|
|
519
|
-
case 'processing-instruction': return node.nodeType === 7;
|
|
520
|
-
}
|
|
521
|
-
return false;
|
|
522
|
-
}
|
|
523
|
-
// Name test
|
|
524
|
-
if (axis === 'attribute') {
|
|
525
|
-
return test.n === '*' || (node.name || node.nodeName || '').toLowerCase() === test.n.toLowerCase();
|
|
526
|
-
}
|
|
527
|
-
if (node.nodeType !== 1) return false;
|
|
528
|
-
if (test.n === '*') return true;
|
|
529
|
-
return node.nodeName.toLowerCase() === test.n.toLowerCase();
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// --- Step Evaluation ---
|
|
533
|
-
|
|
534
|
-
function evalStep(ctxNodes, step) {
|
|
535
|
-
var result = [];
|
|
536
|
-
for (var i = 0; i < ctxNodes.length; i++) {
|
|
537
|
-
var axNodes = getAxisNodes(ctxNodes[i], step.ax);
|
|
538
|
-
// Filter by node test
|
|
539
|
-
var filtered = [];
|
|
540
|
-
for (var j = 0; j < axNodes.length; j++) {
|
|
541
|
-
if (matchTest(axNodes[j], step.test, step.ax)) filtered.push(axNodes[j]);
|
|
542
|
-
}
|
|
543
|
-
// Apply predicates
|
|
544
|
-
var cur = filtered;
|
|
545
|
-
for (var p = 0; p < step.preds.length; p++) {
|
|
546
|
-
var newCur = [], sz = cur.length;
|
|
547
|
-
for (var k = 0; k < cur.length; k++) {
|
|
548
|
-
var val = evaluate(step.preds[p], cur[k], k + 1, sz);
|
|
549
|
-
if (typeof val === 'number') { if (val === k + 1) newCur.push(cur[k]); }
|
|
550
|
-
else { if (toBool(val)) newCur.push(cur[k]); }
|
|
551
|
-
}
|
|
552
|
-
cur = newCur;
|
|
553
|
-
}
|
|
554
|
-
// Add to result, dedup
|
|
555
|
-
for (var k = 0; k < cur.length; k++) {
|
|
556
|
-
if (result.indexOf(cur[k]) < 0) result.push(cur[k]);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
return result;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// --- Document Order Sort ---
|
|
563
|
-
|
|
564
|
-
function sortDocOrder(nodes) {
|
|
565
|
-
if (nodes.length <= 1) return nodes;
|
|
566
|
-
if (nodes[0] && typeof nodes[0].compareDocumentPosition === 'function') {
|
|
567
|
-
return nodes.sort(function(a, b) {
|
|
568
|
-
if (a === b) return 0;
|
|
569
|
-
var pos = a.compareDocumentPosition(b);
|
|
570
|
-
return (pos & 4) ? -1 : (pos & 2) ? 1 : 0;
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
return nodes;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// --- AST Evaluation ---
|
|
577
|
-
|
|
578
|
-
function evaluate(ast, ctx, pos, size) {
|
|
579
|
-
if (!ast || !ast.op) {
|
|
580
|
-
// Step node (from path parsing)
|
|
581
|
-
if (ast && ast.ax) return evalStep([ctx], ast);
|
|
582
|
-
throw new Error('XPath eval error: invalid AST node');
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
switch (ast.op) {
|
|
586
|
-
case 'path': {
|
|
587
|
-
var nodes;
|
|
588
|
-
if (ast.abs) {
|
|
589
|
-
nodes = [ctx.nodeType === 9 ? ctx : (ctx.ownerDocument || ctx)];
|
|
590
|
-
} else {
|
|
591
|
-
nodes = [ctx];
|
|
592
|
-
}
|
|
593
|
-
for (var i = 0; i < ast.steps.length; i++) nodes = evalStep(nodes, ast.steps[i]);
|
|
594
|
-
return nodes;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
case 'fpath': {
|
|
598
|
-
var base = evaluate(ast.f, ctx, pos, size);
|
|
599
|
-
if (!Array.isArray(base)) return base;
|
|
600
|
-
for (var i = 0; i < ast.steps.length; i++) base = evalStep(base, ast.steps[i]);
|
|
601
|
-
return base;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
case 'filt': {
|
|
605
|
-
var base = evaluate(ast.e, ctx, pos, size);
|
|
606
|
-
if (!Array.isArray(base)) return base;
|
|
607
|
-
var out = [], sz = base.length;
|
|
608
|
-
for (var i = 0; i < base.length; i++) {
|
|
609
|
-
var val = evaluate(ast.pred, base[i], i + 1, sz);
|
|
610
|
-
if (typeof val === 'number') { if (val === i + 1) out.push(base[i]); }
|
|
611
|
-
else { if (toBool(val)) out.push(base[i]); }
|
|
612
|
-
}
|
|
613
|
-
return out;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
case 'or': return toBool(evaluate(ast.l, ctx, pos, size)) || toBool(evaluate(ast.r, ctx, pos, size));
|
|
617
|
-
case 'and': return toBool(evaluate(ast.l, ctx, pos, size)) && toBool(evaluate(ast.r, ctx, pos, size));
|
|
618
|
-
|
|
619
|
-
case 'eq': case 'neq': case 'lt': case 'gt': case 'lte': case 'gte':
|
|
620
|
-
return xCmp(evaluate(ast.l, ctx, pos, size), evaluate(ast.r, ctx, pos, size), ast.op);
|
|
621
|
-
|
|
622
|
-
case 'add': return toNum(evaluate(ast.l, ctx, pos, size)) + toNum(evaluate(ast.r, ctx, pos, size));
|
|
623
|
-
case 'sub': return toNum(evaluate(ast.l, ctx, pos, size)) - toNum(evaluate(ast.r, ctx, pos, size));
|
|
624
|
-
case 'mul': return toNum(evaluate(ast.l, ctx, pos, size)) * toNum(evaluate(ast.r, ctx, pos, size));
|
|
625
|
-
case 'div': return toNum(evaluate(ast.l, ctx, pos, size)) / toNum(evaluate(ast.r, ctx, pos, size));
|
|
626
|
-
case 'mod': return toNum(evaluate(ast.l, ctx, pos, size)) % toNum(evaluate(ast.r, ctx, pos, size));
|
|
627
|
-
case 'neg': return -toNum(evaluate(ast.a, ctx, pos, size));
|
|
628
|
-
|
|
629
|
-
case 'union': {
|
|
630
|
-
var l = evaluate(ast.l, ctx, pos, size), r = evaluate(ast.r, ctx, pos, size);
|
|
631
|
-
if (!Array.isArray(l) || !Array.isArray(r)) throw new Error('Union requires node-sets');
|
|
632
|
-
var merged = l.slice();
|
|
633
|
-
for (var i = 0; i < r.length; i++) { if (merged.indexOf(r[i]) < 0) merged.push(r[i]); }
|
|
634
|
-
return sortDocOrder(merged);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
case 'lit': return ast.v;
|
|
638
|
-
case 'num': return ast.v;
|
|
639
|
-
case 'var': return '';
|
|
640
|
-
case 'fn': return evalFunc(ast.name, ast.args, ctx, pos, size);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
throw new Error('XPath eval error: unknown op ' + ast.op);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// --- XPath Functions ---
|
|
647
|
-
|
|
648
|
-
function evalFunc(name, args, ctx, pos, size) {
|
|
649
|
-
switch (name) {
|
|
650
|
-
// -- Node-set functions --
|
|
651
|
-
case 'position': return pos;
|
|
652
|
-
case 'last': return size;
|
|
653
|
-
case 'count': {
|
|
654
|
-
var ns = evaluate(args[0], ctx, pos, size);
|
|
655
|
-
return Array.isArray(ns) ? ns.length : 0;
|
|
656
|
-
}
|
|
657
|
-
case 'id': {
|
|
658
|
-
var val = evaluate(args[0], ctx, pos, size);
|
|
659
|
-
var idStr;
|
|
660
|
-
if (Array.isArray(val)) { idStr = []; for (var i = 0; i < val.length; i++) idStr.push(stringVal(val[i])); idStr = idStr.join(' '); }
|
|
661
|
-
else idStr = toStr(val);
|
|
662
|
-
var ids = idStr.split(/\s+/), doc = ctx.ownerDocument || ctx, nodes = [];
|
|
663
|
-
for (var i = 0; i < ids.length; i++) {
|
|
664
|
-
if (ids[i]) { var el = doc.getElementById(ids[i]); if (el && nodes.indexOf(el) < 0) nodes.push(el); }
|
|
665
|
-
}
|
|
666
|
-
return nodes;
|
|
667
|
-
}
|
|
668
|
-
case 'local-name': {
|
|
669
|
-
var ns = args.length === 0 ? [ctx] : evaluate(args[0], ctx, pos, size);
|
|
670
|
-
if (!Array.isArray(ns) || ns.length === 0) return '';
|
|
671
|
-
return (ns[0].localName || ns[0].nodeName || '').toLowerCase();
|
|
672
|
-
}
|
|
673
|
-
case 'name': case 'namespace-uri': {
|
|
674
|
-
if (name === 'namespace-uri') return '';
|
|
675
|
-
var ns = args.length === 0 ? [ctx] : evaluate(args[0], ctx, pos, size);
|
|
676
|
-
if (!Array.isArray(ns) || ns.length === 0) return '';
|
|
677
|
-
return (ns[0].nodeName || '').toLowerCase();
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// -- String functions --
|
|
681
|
-
case 'string':
|
|
682
|
-
return args.length === 0 ? stringVal(ctx) : toStr(evaluate(args[0], ctx, pos, size));
|
|
683
|
-
case 'concat': {
|
|
684
|
-
var r = '';
|
|
685
|
-
for (var i = 0; i < args.length; i++) r += toStr(evaluate(args[i], ctx, pos, size));
|
|
686
|
-
return r;
|
|
687
|
-
}
|
|
688
|
-
case 'contains': {
|
|
689
|
-
var s1 = toStr(evaluate(args[0], ctx, pos, size));
|
|
690
|
-
var s2 = toStr(evaluate(args[1], ctx, pos, size));
|
|
691
|
-
return s1.indexOf(s2) >= 0;
|
|
692
|
-
}
|
|
693
|
-
case 'starts-with': {
|
|
694
|
-
var s1 = toStr(evaluate(args[0], ctx, pos, size));
|
|
695
|
-
var s2 = toStr(evaluate(args[1], ctx, pos, size));
|
|
696
|
-
return s1.indexOf(s2) === 0;
|
|
697
|
-
}
|
|
698
|
-
case 'substring': {
|
|
699
|
-
var s = toStr(evaluate(args[0], ctx, pos, size));
|
|
700
|
-
var start = Math.round(toNum(evaluate(args[1], ctx, pos, size)));
|
|
701
|
-
if (isNaN(start)) return '';
|
|
702
|
-
if (args.length >= 3) {
|
|
703
|
-
var len = Math.round(toNum(evaluate(args[2], ctx, pos, size)));
|
|
704
|
-
if (isNaN(len)) return '';
|
|
705
|
-
var si = Math.max(start - 1, 0), ei = Math.min(start - 1 + len, s.length);
|
|
706
|
-
return si >= ei ? '' : s.substring(si, ei);
|
|
707
|
-
}
|
|
708
|
-
return s.substring(Math.max(start - 1, 0));
|
|
709
|
-
}
|
|
710
|
-
case 'substring-before': {
|
|
711
|
-
var s1 = toStr(evaluate(args[0], ctx, pos, size));
|
|
712
|
-
var s2 = toStr(evaluate(args[1], ctx, pos, size));
|
|
713
|
-
var idx = s1.indexOf(s2);
|
|
714
|
-
return idx >= 0 ? s1.substring(0, idx) : '';
|
|
715
|
-
}
|
|
716
|
-
case 'substring-after': {
|
|
717
|
-
var s1 = toStr(evaluate(args[0], ctx, pos, size));
|
|
718
|
-
var s2 = toStr(evaluate(args[1], ctx, pos, size));
|
|
719
|
-
var idx = s1.indexOf(s2);
|
|
720
|
-
return idx >= 0 ? s1.substring(idx + s2.length) : '';
|
|
721
|
-
}
|
|
722
|
-
case 'string-length': {
|
|
723
|
-
var s = args.length === 0 ? stringVal(ctx) : toStr(evaluate(args[0], ctx, pos, size));
|
|
724
|
-
return s.length;
|
|
725
|
-
}
|
|
726
|
-
case 'normalize-space': {
|
|
727
|
-
var s = args.length === 0 ? stringVal(ctx) : toStr(evaluate(args[0], ctx, pos, size));
|
|
728
|
-
return s.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
|
|
729
|
-
}
|
|
730
|
-
case 'translate': {
|
|
731
|
-
var s = toStr(evaluate(args[0], ctx, pos, size));
|
|
732
|
-
var from = toStr(evaluate(args[1], ctx, pos, size));
|
|
733
|
-
var to = toStr(evaluate(args[2], ctx, pos, size));
|
|
734
|
-
var r = '';
|
|
735
|
-
for (var i = 0; i < s.length; i++) {
|
|
736
|
-
var idx = from.indexOf(s[i]);
|
|
737
|
-
if (idx < 0) r += s[i];
|
|
738
|
-
else if (idx < to.length) r += to[idx];
|
|
739
|
-
// else: character removed
|
|
740
|
-
}
|
|
741
|
-
return r;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// -- Boolean functions --
|
|
745
|
-
case 'boolean': return toBool(evaluate(args[0], ctx, pos, size));
|
|
746
|
-
case 'not': return !toBool(evaluate(args[0], ctx, pos, size));
|
|
747
|
-
case 'true': return true;
|
|
748
|
-
case 'false': return false;
|
|
749
|
-
case 'lang': return false; // stub
|
|
750
|
-
|
|
751
|
-
// -- Number functions --
|
|
752
|
-
case 'number':
|
|
753
|
-
return args.length === 0 ? toNum(stringVal(ctx)) : toNum(evaluate(args[0], ctx, pos, size));
|
|
754
|
-
case 'sum': {
|
|
755
|
-
var ns = evaluate(args[0], ctx, pos, size);
|
|
756
|
-
if (!Array.isArray(ns)) return NaN;
|
|
757
|
-
var total = 0;
|
|
758
|
-
for (var i = 0; i < ns.length; i++) total += toNum(stringVal(ns[i]));
|
|
759
|
-
return total;
|
|
760
|
-
}
|
|
761
|
-
case 'floor': return Math.floor(toNum(evaluate(args[0], ctx, pos, size)));
|
|
762
|
-
case 'ceiling': return Math.ceil(toNum(evaluate(args[0], ctx, pos, size)));
|
|
763
|
-
case 'round': {
|
|
764
|
-
var n = toNum(evaluate(args[0], ctx, pos, size));
|
|
765
|
-
if (isNaN(n) || !isFinite(n)) return n;
|
|
766
|
-
return Math.round(n);
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
throw new Error('XPath error: unknown function ' + name + '()');
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// --- Public API ---
|
|
773
|
-
return {
|
|
774
|
-
find: function(expression, contextNode) {
|
|
775
|
-
var tokens = tokenize(expression);
|
|
776
|
-
var parser = new Parser(tokens);
|
|
777
|
-
var ast = parser.parseExpr();
|
|
778
|
-
var result = evaluate(ast, contextNode, 1, 1);
|
|
779
|
-
// XPath spec: deliver the final node-set in document order. Internal
|
|
780
|
-
// step results are kept in axis order for predicate semantics; we sort
|
|
781
|
-
// here, at the public entry, so consumers see ordered results.
|
|
782
|
-
return Array.isArray(result) ? sortDocOrder(result) : [];
|
|
783
|
-
}
|
|
784
|
-
};
|
|
785
|
-
})();
|
|
786
|
-
|
|
787
72
|
// --- Main API ---
|
|
788
73
|
|
|
789
74
|
window._lightpanda = {
|
|
790
|
-
xpathFind: function(expression, contextNode) {
|
|
791
|
-
// Use native XPath if available (non-polyfilled)
|
|
792
|
-
if (typeof contextNode.evaluate === 'function' && typeof XPathResult !== 'undefined' &&
|
|
793
|
-
!XPathResult._polyfilled) {
|
|
794
|
-
try {
|
|
795
|
-
var result = contextNode.evaluate(expression, contextNode, null,
|
|
796
|
-
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
797
|
-
var nodes = [];
|
|
798
|
-
for (var i = 0; i < result.snapshotLength; i++) {
|
|
799
|
-
nodes.push(result.snapshotItem(i));
|
|
800
|
-
}
|
|
801
|
-
return nodes;
|
|
802
|
-
} catch(e) {
|
|
803
|
-
// Fall through to polyfill
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
try {
|
|
808
|
-
return XPathEval.find(expression, contextNode);
|
|
809
|
-
} catch(e) {
|
|
810
|
-
return [];
|
|
811
|
-
}
|
|
812
|
-
},
|
|
813
|
-
|
|
814
75
|
turbo: {
|
|
815
76
|
pending: function() { return _pendingTurboOps; },
|
|
816
77
|
idle: function() { return _pendingTurboOps <= 0; }
|
|
@@ -975,24 +236,4 @@
|
|
|
975
236
|
return walk(el);
|
|
976
237
|
}
|
|
977
238
|
};
|
|
978
|
-
|
|
979
|
-
// --- XPathResult polyfill ---
|
|
980
|
-
|
|
981
|
-
if (typeof XPathResult === 'undefined') {
|
|
982
|
-
window.XPathResult = {
|
|
983
|
-
ORDERED_NODE_SNAPSHOT_TYPE: 7,
|
|
984
|
-
FIRST_ORDERED_NODE_TYPE: 9,
|
|
985
|
-
_polyfilled: true
|
|
986
|
-
};
|
|
987
|
-
}
|
|
988
|
-
if (!document.evaluate) {
|
|
989
|
-
document.evaluate = function(expression, contextNode) {
|
|
990
|
-
var nodes = window._lightpanda.xpathFind(expression, contextNode);
|
|
991
|
-
return {
|
|
992
|
-
snapshotLength: nodes.length,
|
|
993
|
-
snapshotItem: function(i) { return nodes[i] || null; },
|
|
994
|
-
singleNodeValue: nodes[0] || null
|
|
995
|
-
};
|
|
996
|
-
};
|
|
997
|
-
}
|
|
998
239
|
})();
|
|
@@ -372,15 +372,14 @@ module Capybara
|
|
|
372
372
|
# the manual default action below, which does a full-page navigation
|
|
373
373
|
# instead of a frame swap.
|
|
374
374
|
#
|
|
375
|
-
#
|
|
376
|
-
# `<
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
382
|
-
# the
|
|
383
|
-
# trigger the implicit form-submission default action per DOM spec.
|
|
375
|
+
# Submit buttons (`<input type=submit>`, `<input type=image>`,
|
|
376
|
+
# `<button type=submit>`): native click on the dispatched MouseEvent
|
|
377
|
+
# already runs the form-submission default action via Frame.submitForm
|
|
378
|
+
# in Lightpanda (extension of PR #2312 for image to all submit
|
|
379
|
+
# variants). A manual `form.requestSubmit(this)` here would fire a
|
|
380
|
+
# second SubmitEvent and double-submit the form — observed on nightly
|
|
381
|
+
# 6167 as duplicate `turbo:submit-start` events; the first request
|
|
382
|
+
# gets aborted by the second and Turbo never renders the response.
|
|
384
383
|
CLICK_JS = <<~JS
|
|
385
384
|
function() {
|
|
386
385
|
var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
|
|
@@ -388,24 +387,7 @@ module Capybara
|
|
|
388
387
|
var notCancelled = this.dispatchEvent(clickEvt);
|
|
389
388
|
if (!notCancelled || clickEvt.defaultPrevented) return;
|
|
390
389
|
var tag = this.tagName;
|
|
391
|
-
|
|
392
|
-
var isSubmitButton =
|
|
393
|
-
(tag === 'BUTTON' && (type === 'submit' || type === '')) ||
|
|
394
|
-
(tag === 'INPUT' && (type === 'submit' || type === 'image'));
|
|
395
|
-
if (isSubmitButton && this.form) {
|
|
396
|
-
// Lightpanda raises a JsException from requestSubmit when a
|
|
397
|
-
// bubble-phase listener (e.g. Turbo's submitBubbled) calls
|
|
398
|
-
// preventDefault + stopImmediatePropagation on the SubmitEvent.
|
|
399
|
-
// Per HTML spec a cancelled submission should be a silent no-op.
|
|
400
|
-
// Log unexpected errors via console.warn so they remain
|
|
401
|
-
// diagnosable (LIGHTPANDA_DEBUG surfaces console output) instead
|
|
402
|
-
// of silently swallowing future regressions.
|
|
403
|
-
try {
|
|
404
|
-
this.form.requestSubmit(this);
|
|
405
|
-
} catch (e) {
|
|
406
|
-
try { console.warn('[capybara-lightpanda] requestSubmit threw:', e && e.message ? e.message : e); } catch (_) {}
|
|
407
|
-
}
|
|
408
|
-
} else if (tag === 'A' && this.href && this.target !== '_blank') {
|
|
390
|
+
if (tag === 'A' && this.href && this.target !== '_blank') {
|
|
409
391
|
// Same-document fragment-only navigation: just update hash (or do
|
|
410
392
|
// nothing if identical). Mirrors Chrome — assigning location.href
|
|
411
393
|
// to a same-document URL on Lightpanda triggers a real navigation
|
|
@@ -8,8 +8,8 @@ module Capybara
|
|
|
8
8
|
READY_PATTERN = /server running.*address\s*=\s*(\d+\.\d+\.\d+\.\d+:\d+)/m
|
|
9
9
|
ADDRESS_IN_USE_PATTERN = /err=AddressInUse/
|
|
10
10
|
|
|
11
|
-
# Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch
|
|
12
|
-
# fixes the gem now relies on: PR #2255 (Network.clearBrowserCookies
|
|
11
|
+
# Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch/
|
|
12
|
+
# xpath/history fixes the gem now relies on: PR #2255 (Network.clearBrowserCookies
|
|
13
13
|
# empty params + Network.getAllCookies), PR #2257
|
|
14
14
|
# (window.location.pathname/.search assignment triggers navigation),
|
|
15
15
|
# PR #2265 (URL fragment inherited across fragment-less redirect),
|
|
@@ -25,10 +25,15 @@ module Capybara
|
|
|
25
25
|
# PR #2342 (<summary> click toggles parent <details>.open),
|
|
26
26
|
# PR #2352 (HTMLInputElement.pattern + patternMismatch via V8 RegExp),
|
|
27
27
|
# PR #2368 (events: report listener exceptions instead of halting
|
|
28
|
-
# dispatch — lets us drop the polyfills.js patchDispatch IIFE)
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
28
|
+
# dispatch — lets us drop the polyfills.js patchDispatch IIFE),
|
|
29
|
+
# PR #2289 (Page.getNavigationHistory + Page.navigateToHistoryEntry —
|
|
30
|
+
# lets us drop the history.back()/history.forward() JS workaround in
|
|
31
|
+
# Browser#back / #forward), PR #2305 (XPath 1.0: Document.evaluate,
|
|
32
|
+
# XPathResult, XPathEvaluator, XPathExpression — lets us drop the
|
|
33
|
+
# ~700 LOC XPath polyfill in javascripts/index.js).
|
|
34
|
+
# Build 6109 = main HEAD cfcfe4ee (2026-05-11, after PR #2289 and
|
|
35
|
+
# PR #2305 merges); will ship in nightly published 2026-05-12 ~03:30 UTC.
|
|
36
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6109")
|
|
32
37
|
|
|
33
38
|
attr_reader :pid, :ws_url, :version, :nightly_build
|
|
34
39
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: capybara-lightpanda
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Navid Emad
|
|
@@ -115,7 +115,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
115
115
|
- !ruby/object:Gem::Version
|
|
116
116
|
version: '0'
|
|
117
117
|
requirements: []
|
|
118
|
-
rubygems_version: 4.0.
|
|
118
|
+
rubygems_version: 4.0.10
|
|
119
119
|
specification_version: 4
|
|
120
120
|
summary: Capybara driver for the Lightpanda headless browser
|
|
121
121
|
test_files: []
|