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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cb36e68fb7bcc35abcbae5a3417091eb54444f7b57e161cb44cb6436d243684
4
- data.tar.gz: 9eff2c5cd278d16ed8ad55d106dca14dab3597a87c3315c46371b98672a35163
3
+ metadata.gz: 9bb84252bec4e22492ad1e2165a63842c3fad60ab9d80ee0df60db55c2425c9a
4
+ data.tar.gz: '090fbdca94bf7e482dda301a2aad4b51065211972bff4a06a9aa3e848a2a9ef4'
5
5
  SHA512:
6
- metadata.gz: 156126b7f57785869aca23b79fe02bc3fe7370cf5b2826999715d5e69794a6b62f8c69bb64601e5e67f935d946e2348274c8ec76d79b674930b239d6b2383677
7
- data.tar.gz: c73dab9284d0d8e0091794e2f88674951bafc64af2fa2e87200926a60d7d86767f31cd5114d5348af51013fe3b4846f1066d0bdca886a5a1051432673b76ef52
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
- @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
@@ -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). Any throw from
636
- # querySelectorAll means the selector is malformed (the spec only allows
637
- # SYNTAX_ERR DOMException; Lightpanda's V8 currently throws a generic
638
- # Error with messages like "InvalidClassSelector"). Re-throw with the
639
- # marker prefix so Ruby converts to InvalidSelector regardless.
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
- if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, this);
644
- 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 []; }
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
- if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, doc);
660
- 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 []; }
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
- "(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
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
- frame_node = @frame_stack.last
693
- result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
694
- return_by_value: false)
695
- extract_node_object_ids(result)
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
- # For submit buttons (`<button type=submit>`, `<input type=submit>`,
376
- # `<input type=image>`): route through `form.requestSubmit(this)` so the
377
- # browser dispatches a real `SubmitEvent` with submitter set, honors the
378
- # submitter's `formaction` / `formmethod` / `formenctype`, and includes
379
- # the submitter's name/value in the form data. A manual
380
- # `dispatchEvent(new Event('submit'))` + `form.submit()` would lose all of
381
- # that and break Turbo Drive / Hotwire form handling. We can't rely on
382
- # the synthetic click's default action because synthetic events don't
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
- var type = (this.type || '').toLowerCase();
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
- # Build 6065 = main HEAD 61364437 (2026-05-06, PR #2368 merge);
30
- # ships in nightly published 2026-05-06 ~03:30 UTC for all four platforms.
31
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6065")
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
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.2.2
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.6
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: []