capybara-lightpanda 0.1.0 → 0.2.1

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.
@@ -5,6 +5,8 @@ require "yaml"
5
5
  module Capybara
6
6
  module Lightpanda
7
7
  class Cookies
8
+ include Enumerable
9
+
8
10
  # Typed wrapper around a CDP cookie hash so callers don't have to remember
9
11
  # the camelCase keys (`httpOnly`, `sameSite`, …) the CDP returns. Mirrors
10
12
  # ferrum's Cookies::Cookie. `attributes` exposes the raw hash for callers
@@ -58,12 +60,21 @@ module Capybara
58
60
  end
59
61
 
60
62
  def all
61
- result = browser.command("Network.getCookies")
63
+ result = browser.command("Network.getAllCookies")
62
64
  (result["cookies"] || []).map { |c| Cookie.new(c) }
63
65
  end
64
66
 
67
+ # Yields each Cookie. Powers `Enumerable` (so callers can do
68
+ # `cookies.find { … }`, `cookies.select { … }`, `cookies.to_a`, …
69
+ # without going through `all` first).
70
+ def each(&block)
71
+ return enum_for(:each) unless block
72
+
73
+ all.each(&block)
74
+ end
75
+
65
76
  def get(name)
66
- all.find { |cookie| cookie.name == name }
77
+ find { |cookie| cookie.name == name }
67
78
  end
68
79
 
69
80
  def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, expires: nil)
@@ -88,29 +99,8 @@ module Capybara
88
99
  browser.command("Network.deleteCookies", **params)
89
100
  end
90
101
 
91
- # Lightpanda gotchas observed on current nightly:
92
- # * `Network.clearBrowserCookies` raises `InvalidParams` (so it does NOT
93
- # clear anything despite the upstream PR #1821 / >= v0.2.6 note).
94
- # * `Network.getCookies` (no `urls` param) is scoped to the CURRENT
95
- # page's origin — cookies set on previously-visited domains are
96
- # invisible from a different page.
97
- # * `Network.getCookies` on `about:blank` raises `InvalidDomain`.
98
- #
99
- # To honor Capybara's `reset_session! removes ALL cookies` contract
100
- # across multiple test domains (e.g. `localhost` AND `127.0.0.1`), we
101
- # iterate every origin Browser has navigated to and per-origin call
102
- # `Network.getCookies(urls: [origin])` then `Network.deleteCookies(url:)`.
103
- # The bulk-clear call is still attempted first as a fast path / future-
104
- # proofing for when upstream fixes it.
105
102
  def clear
106
- begin
107
- browser.command("Network.clearBrowserCookies")
108
- rescue BrowserError, TimeoutError, StandardError
109
- # InvalidParams on current nightly; pre-v0.2.6 used to crash the
110
- # WebSocket. Either way, fall through to per-origin sweep.
111
- end
112
-
113
- sweep_visited_origins
103
+ browser.command("Network.clearBrowserCookies")
114
104
  end
115
105
 
116
106
  # Persist all current cookies to a YAML file (ferrum parity).
@@ -131,35 +121,6 @@ module Capybara
131
121
 
132
122
  private
133
123
 
134
- def sweep_visited_origins
135
- origins = browser.visited_origins.to_a
136
- return if origins.empty?
137
-
138
- result = browser.command("Network.getCookies", urls: origins)
139
- cookies = result["cookies"] || []
140
- cookies.each do |cookie|
141
- # CDP needs either domain or url; build a url from the cookie's
142
- # own domain+path so we don't mismatch (e.g. cookie domain `.x.test`
143
- # against an origin we tracked as `https://x.test:443`).
144
- url = cookie_url(cookie)
145
- params = { name: cookie["name"] }
146
- params[:url] = url if url
147
- params[:domain] = cookie["domain"] unless url
148
- browser.command("Network.deleteCookies", **params)
149
- end
150
- rescue StandardError
151
- # Connection lost or origin no longer valid; nothing more to do.
152
- end
153
-
154
- def cookie_url(cookie)
155
- domain = cookie["domain"].to_s.sub(/\A\./, "")
156
- return nil if domain.empty?
157
-
158
- scheme = cookie["secure"] ? "https" : "http"
159
- path = cookie["path"] || "/"
160
- "#{scheme}://#{domain}#{path}"
161
- end
162
-
163
124
  # set() takes keyword args, but YAML round-trips give us a hash with the
164
125
  # raw CDP keys (camelCase). Normalize and forward.
165
126
  def restore_cookie(hash)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
+ require "uri"
4
5
 
5
6
  module Capybara
6
7
  module Lightpanda
@@ -16,6 +17,7 @@ module Capybara
16
17
  @app = app
17
18
  @options = options
18
19
  @browser = nil
20
+ @started = false
19
21
  end
20
22
 
21
23
  def browser
@@ -30,6 +32,7 @@ module Capybara
30
32
  end
31
33
 
32
34
  def visit(url)
35
+ @started = true
33
36
  browser.go_to(url)
34
37
  end
35
38
 
@@ -84,11 +87,26 @@ module Capybara
84
87
  unwrap_script_result(browser.evaluate_async(script.strip, *native_args(args)))
85
88
  end
86
89
 
90
+ # -- Network Inspection --
91
+
92
+ # Network tracker (lazily auto-enabled). Exposes `traffic`, `clear`,
93
+ # `wait_for_idle`, header overrides, etc. Cuprite parity.
94
+ def network
95
+ browser.network
96
+ end
97
+
98
+ # Block until in-flight HTTP traffic settles. Auto-enables the tracker
99
+ # on first call so callers don't have to remember to flip it on.
100
+ # Returns true on success, false on timeout.
101
+ def wait_for_network_idle(timeout: 5, connections: 0)
102
+ network.enable
103
+ network.wait_for_idle(timeout: timeout, connections: connections)
104
+ end
105
+
87
106
  # -- Cookie Management --
88
107
 
89
108
  def set_cookie(name, value, **options)
90
- cookie_options = {}
91
- cookie_options[:domain] = options[:domain] if options[:domain]
109
+ cookie_options = { domain: options[:domain] || default_domain }
92
110
  cookie_options[:path] = options[:path] if options[:path]
93
111
  cookie_options[:secure] = options[:secure] if options.key?(:secure)
94
112
  if options.key?(:httpOnly) || options.key?(:http_only)
@@ -178,11 +196,12 @@ module Capybara
178
196
 
179
197
  # -- Lifecycle --
180
198
 
199
+ # Thin Cuprite-style wrapper. The interesting work — disposing the
200
+ # BrowserContext (cookies, storage, all targets) and starting a fresh
201
+ # one — happens in Browser#reset.
181
202
  def reset!
182
- browser.clear_frames
183
- browser.reset_modals
184
- browser.cookies.clear
185
- browser.go_to("about:blank")
203
+ browser.reset
204
+ @started = false
186
205
  rescue StandardError
187
206
  @browser&.quit
188
207
  @browser = nil
@@ -232,6 +251,24 @@ module Capybara
232
251
  args.map { |a| a.is_a?(Capybara::Node::Element) ? a.base : a }
233
252
  end
234
253
 
254
+ # Lightpanda's `Network.setCookie` requires either `domain` or `url`
255
+ # (storage.zig → Cookie.parseDomain). When the caller doesn't supply one,
256
+ # use the host of the current page if any, else `Capybara.app_host`,
257
+ # else loopback. Cuprite parity — lets pre-visit cookie setup just work.
258
+ def default_domain
259
+ candidate = (@started && safe_uri_host(browser.current_url)) ||
260
+ safe_uri_host(Capybara.app_host)
261
+ candidate || "127.0.0.1"
262
+ end
263
+
264
+ def safe_uri_host(url)
265
+ return nil if url.nil? || url.empty? || url == "about:blank"
266
+
267
+ URI(url).host
268
+ rescue URI::InvalidURIError
269
+ nil
270
+ end
271
+
235
272
  # Walk through evaluate-script results turning DOM-node markers (the
236
273
  # `{ "__lightpanda_node__" => "..." }` hashes produced by `Browser#unwrap_call_result`)
237
274
  # into Lightpanda::Node instances so Capybara can wrap them as elements.
@@ -9,34 +9,77 @@ module Capybara
9
9
  class BinaryError < Error; end
10
10
  class UnsupportedPlatformError < Error; end
11
11
 
12
- class DeadBrowserError < Error; end
13
12
  class TimeoutError < Error; end
14
13
 
14
+ # Base class for any error originating from a CDP response or live browser
15
+ # state. Lets callers `rescue BrowserError` to catch the whole CDP family
16
+ # in one go (mirrors ferrum's hierarchy). Accepts either a CDP error hash
17
+ # (`{"message" => ..., "code" => ...}`) or a plain string for callsites
18
+ # that raise with a literal message.
15
19
  class BrowserError < Error
16
20
  attr_reader :response
17
21
 
18
- def initialize(response)
19
- @response = response
20
- super(response["message"])
22
+ def initialize(response_or_message = nil)
23
+ if response_or_message.is_a?(Hash)
24
+ @response = response_or_message
25
+ super(response_or_message["message"])
26
+ else
27
+ @response = nil
28
+ super
29
+ end
30
+ end
31
+
32
+ def code
33
+ @response&.dig("code")
34
+ end
35
+
36
+ def data
37
+ @response&.dig("data")
21
38
  end
22
39
  end
23
40
 
24
- class JavaScriptError < Error
25
- attr_reader :class_name, :message
41
+ class DeadBrowserError < BrowserError; end
42
+ class NodeNotFoundError < BrowserError; end
43
+ class NoExecutionContextError < BrowserError; end
44
+
45
+ class JavaScriptError < BrowserError
46
+ attr_reader :class_name, :stack_trace
26
47
 
27
48
  def initialize(response)
28
- @class_name = response.dig("exceptionDetails", "exception", "className")
29
- @message = response.dig("exceptionDetails", "exception",
30
- "description") || response.dig("exceptionDetails", "text")
49
+ details = response["exceptionDetails"] || {}
50
+ exception = details["exception"] || {}
51
+ @class_name = exception["className"]
52
+ @stack_trace = details["stackTrace"]
53
+ super(build_message(details, exception))
54
+ end
31
55
 
32
- super(@message)
56
+ private
57
+
58
+ def build_message(details, exception)
59
+ base = exception["description"] || details["text"] || "JsException"
60
+ parts = [base]
61
+ parts << "(#{@class_name})" if @class_name && !base.include?(@class_name)
62
+ if (val = exception["value"])
63
+ parts << "value=#{val.inspect}"
64
+ end
65
+ if @stack_trace && (frames = @stack_trace["callFrames"])
66
+ parts << format_stack(frames)
67
+ end
68
+ parts.join(" | ")
33
69
  end
34
- end
35
70
 
36
- class NodeNotFoundError < Error; end
37
- class NoExecutionContextError < Error; end
71
+ def format_stack(frames)
72
+ formatted = frames.first(5).map { |f| format_frame(f) }
73
+ "stack:\n #{formatted.join("\n ")}"
74
+ end
75
+
76
+ def format_frame(frame)
77
+ name = frame["functionName"].to_s.empty? ? "<anon>" : frame["functionName"]
78
+ "#{name} @ #{frame['url']}:#{frame['lineNumber']}:#{frame['columnNumber']}"
79
+ end
80
+ end
38
81
 
39
- class ObsoleteNode < Error
82
+ class ObsoleteNode < BrowserError
40
83
  attr_reader :node
41
84
 
42
85
  def initialize(node, message = nil)
@@ -45,7 +88,7 @@ module Capybara
45
88
  end
46
89
  end
47
90
 
48
- class MouseEventFailed < Error
91
+ class MouseEventFailed < BrowserError
49
92
  attr_reader :node, :selector, :position
50
93
 
51
94
  PATTERN = /at position \((\d+),\s*(\d+)\).*selector:\s*(.+)/i
@@ -822,43 +822,36 @@
822
822
  // cascade. Available in iframes too because index.js is registered via
823
823
  // Page.addScriptToEvaluateOnNewDocument.
824
824
 
825
- // Returns true if `el` is visible per Capybara's semantics: not in an
826
- // unrendered tag (HEAD/SCRIPT/TEMPLATE/etc), no `hidden` attribute on
827
- // self/ancestor, no closed-<details> ancestor, not visibility:hidden, and
828
- // (when checkVisibility() is unavailable) not display:none.
825
+ // Returns true if `el` is visible per Capybara's semantics. Lightpanda's
826
+ // UA stylesheet (PR #2294, nightly ≥5918) puts `display:none` on
827
+ // HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE and `[type=hidden]`, and
828
+ // `[hidden]` / closed-<details> children also resolve to `display:none`
829
+ // through the cascade — so a single display:none walk catches the
830
+ // unrendered-element cases without an explicit tag list. `visibility`
831
+ // and offsetParent stay explicit because they aren't covered by display.
832
+ // `checkVisibility()` does the parent walk for us when available.
829
833
  //
830
834
  // `opts.checkOffsetParent` (default true): when true, also rejects
831
- // elements with offsetParent === null as a fallback for ancestor display:none.
832
- // The visible_text walker passes false because it already short-circuits
833
- // when descending into hidden subtrees.
835
+ // elements with `offsetParent === null` as a fallback for ancestor
836
+ // `display:none`. The visible_text walker passes false because it
837
+ // already short-circuits when descending into hidden subtrees.
834
838
  isVisible: function(el, opts) {
835
839
  if (!el || el.nodeType !== 1) return false;
836
840
  var checkOffsetParent = !opts || opts.checkOffsetParent !== false;
837
841
  var TAG = (el.tagName || '').toUpperCase();
838
- if (TAG === 'HEAD' || TAG === 'TEMPLATE' || TAG === 'NOSCRIPT' ||
839
- TAG === 'SCRIPT' || TAG === 'STYLE' || TAG === 'TITLE') return false;
840
- if (TAG === 'INPUT' && (el.type || '').toLowerCase() === 'hidden') return false;
841
-
842
- var node = el;
843
- while (node && node.nodeType === 1) {
844
- if (node.hasAttribute && node.hasAttribute('hidden')) return false;
845
- var parent = node.parentNode;
846
- if (parent && parent.nodeType === 1) {
847
- var ptag = (parent.tagName || '').toUpperCase();
848
- if (ptag === 'HEAD' || ptag === 'TEMPLATE' || ptag === 'NOSCRIPT') return false;
849
- if (ptag === 'DETAILS' && !parent.open) {
850
- var ntag = (node.tagName || '').toUpperCase();
851
- if (ntag !== 'SUMMARY') return false;
852
- }
853
- }
854
- node = parent;
855
- }
856
842
 
857
843
  var win = el.ownerDocument.defaultView || window;
858
844
  var style = win.getComputedStyle(el);
859
845
  if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
860
- if (typeof el.checkVisibility === 'function') return el.checkVisibility();
861
- if (style.display === 'none') return false;
846
+ if (typeof el.checkVisibility === 'function') {
847
+ if (!el.checkVisibility()) return false;
848
+ } else {
849
+ var node = el;
850
+ while (node && node.nodeType === 1) {
851
+ if (win.getComputedStyle(node).display === 'none') return false;
852
+ node = node.parentElement;
853
+ }
854
+ }
862
855
  if (checkOffsetParent && el.offsetParent === null && style.position !== 'fixed' &&
863
856
  TAG !== 'BODY' && TAG !== 'HTML') return false;
864
857
  return true;
@@ -893,36 +886,18 @@
893
886
  return !el.contains(hit);
894
887
  },
895
888
 
896
- // HTML defines a disabled form control as one whose own `disabled`
897
- // attribute is set OR whose ancestor select/optgroup/fieldset is disabled
898
- // (with a fieldset-disabled exception for descendants of its first legend).
899
- // `el.disabled` only reflects the element's own attribute, so we walk
900
- // up the tree to honor the inherited cases.
889
+ // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
890
+ // an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
891
+ // is reported as disabled, even though those don't match CSS `:disabled`
892
+ // per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
901
893
  isDisabled: function(el) {
902
- if (el.disabled) return true;
903
- var tag = (el.tagName || '').toUpperCase();
904
- if (tag === 'OPTION') {
894
+ if (el.matches && el.matches(':disabled')) return true;
895
+ if ((el.tagName || '').toUpperCase() === 'OPTION') {
905
896
  var p = el.parentElement;
906
- while (p && (p.tagName || '').toUpperCase() === 'OPTGROUP') {
897
+ while (p) {
907
898
  if (p.disabled) return true;
908
899
  p = p.parentElement;
909
900
  }
910
- if (p && (p.tagName || '').toUpperCase() === 'SELECT' && p.disabled) return true;
911
- }
912
- var FORM = { INPUT:1, BUTTON:1, SELECT:1, TEXTAREA:1, OPTION:1 };
913
- if (FORM[tag]) {
914
- var node = el.parentElement;
915
- while (node) {
916
- if ((node.tagName || '').toUpperCase() === 'FIELDSET' && node.disabled) {
917
- var firstLegend = null;
918
- for (var c = node.firstElementChild; c; c = c.nextElementSibling) {
919
- if ((c.tagName || '').toUpperCase() === 'LEGEND') { firstLegend = c; break; }
920
- }
921
- if (firstLegend && firstLegend.contains(el)) return false;
922
- return true;
923
- }
924
- node = node.parentElement;
925
- }
926
901
  }
927
902
  return false;
928
903
  },
@@ -988,7 +963,12 @@
988
963
  for (var i = 0; i < node.childNodes.length; i++) {
989
964
  out += walk(node.childNodes[i]);
990
965
  }
991
- if (isBlock) out = '\n' + out + '\n';
966
+ // Block-level elements get wrapped in \n…\n only when they actually
967
+ // contribute visible text. An empty <div> between two inline siblings
968
+ // would otherwise introduce a phantom line break that Chrome's
969
+ // innerText algorithm collapses out (required line breaks around
970
+ // empty blocks coalesce in the line-collapse pass).
971
+ if (isBlock && /\S/.test(out)) out = '\n' + out + '\n';
992
972
  return out;
993
973
  }
994
974
 
@@ -1015,94 +995,4 @@
1015
995
  };
1016
996
  };
1017
997
  }
1018
-
1019
- // --- ID-shorthand rewriter for querySelector/querySelectorAll ---
1020
- // Lightpanda has a CSS-engine bug where `#id` selectors fail after the body is
1021
- // modified via innerHTML and then replaced (e.g. Turbo Drive's snapshot+swap).
1022
- // `[id="..."]` always works, so rewrite `#foo` -> `[id="foo"]` outside of
1023
- // brackets and quoted strings before delegating to the native engine.
1024
-
1025
- (function() {
1026
- if (typeof Document === 'undefined' || typeof Element === 'undefined') return;
1027
-
1028
- function rewriteIdShorthand(selector) {
1029
- if (typeof selector !== 'string' || selector.indexOf('#') < 0) return selector;
1030
- var out = '', i = 0, n = selector.length, depth = 0, quote = null;
1031
- while (i < n) {
1032
- var c = selector.charAt(i);
1033
- if (quote) {
1034
- out += c;
1035
- if (c === '\\' && i + 1 < n) { out += selector.charAt(i + 1); i += 2; continue; }
1036
- if (c === quote) quote = null;
1037
- i++; continue;
1038
- }
1039
- if (c === '"' || c === "'") { quote = c; out += c; i++; continue; }
1040
- if (c === '[') { depth++; out += c; i++; continue; }
1041
- if (c === ']') { if (depth > 0) depth--; out += c; i++; continue; }
1042
- if (c === '\\' && i + 1 < n) { out += c + selector.charAt(i + 1); i += 2; continue; }
1043
- if (c === '#' && depth === 0) {
1044
- var j = i + 1, start = j;
1045
- while (j < n) {
1046
- var cc = selector.charCodeAt(j);
1047
- if (cc === 92 && j + 1 < n) { j += 2; continue; }
1048
- // CSS identifier chars: A-Z a-z 0-9 _ - or non-ASCII (>= 128)
1049
- if ((cc >= 48 && cc <= 57) || (cc >= 65 && cc <= 90) ||
1050
- (cc >= 97 && cc <= 122) || cc === 45 || cc === 95 || cc >= 128) { j++; continue; }
1051
- break;
1052
- }
1053
- if (j > start) {
1054
- var id = selector.substring(start, j);
1055
- out += '[id="' + id.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]';
1056
- i = j; continue;
1057
- }
1058
- }
1059
- out += c; i++;
1060
- }
1061
- return out;
1062
- }
1063
-
1064
- function patch(proto) {
1065
- if (!proto) return;
1066
- var origQS = proto.querySelector;
1067
- var origQSA = proto.querySelectorAll;
1068
- if (typeof origQS === 'function') {
1069
- proto.querySelector = function(s) { return origQS.call(this, rewriteIdShorthand(s)); };
1070
- }
1071
- if (typeof origQSA === 'function') {
1072
- proto.querySelectorAll = function(s) { return origQSA.call(this, rewriteIdShorthand(s)); };
1073
- }
1074
- }
1075
-
1076
- patch(Document.prototype);
1077
- patch(Element.prototype);
1078
- if (typeof DocumentFragment !== 'undefined') patch(DocumentFragment.prototype);
1079
- })();
1080
-
1081
- // --- requestSubmit polyfill ---
1082
- // Required for Turbo form interception. Turbo listens for the `submit` event,
1083
- // but form.submit() doesn't fire it. requestSubmit() does.
1084
-
1085
- if (typeof HTMLFormElement !== 'undefined' && !HTMLFormElement.prototype.requestSubmit) {
1086
- HTMLFormElement.prototype.requestSubmit = function(submitter) {
1087
- if (submitter) {
1088
- var validTypes = {submit: 1, image: 1};
1089
- if (!validTypes[(submitter.type || '').toLowerCase()]) {
1090
- throw new TypeError('The specified element is not a submit button.');
1091
- }
1092
- if (submitter.form !== this) {
1093
- throw new DOMException('The specified element is not owned by this form element.', 'NotFoundError');
1094
- }
1095
- }
1096
- var event;
1097
- if (typeof SubmitEvent === 'function') {
1098
- event = new SubmitEvent('submit', {bubbles: true, cancelable: true, submitter: submitter || null});
1099
- } else {
1100
- event = new Event('submit', {bubbles: true, cancelable: true});
1101
- event.submitter = submitter || null;
1102
- }
1103
- if (this.dispatchEvent(event)) {
1104
- this.submit();
1105
- }
1106
- };
1107
- }
1108
998
  })();
@@ -0,0 +1,81 @@
1
+ // Polyfills compensant des limitations du binaire Lightpanda.
2
+ // Chaque section est gardée par un test de feature : dès qu'upstream implémente
3
+ // l'API native, le polyfill devient un no-op et peut être retiré.
4
+ // Voir UPSTREAM_BUGS.md à la racine du gem pour les repros et liens d'issues.
5
+ (function () {
6
+ "use strict";
7
+
8
+ // ── Bug #4 — HTMLDialogElement.{showModal, show, close} non implémentés ──
9
+ // https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
10
+ if (typeof HTMLDialogElement !== "undefined") {
11
+ var dproto = HTMLDialogElement.prototype;
12
+ if (typeof dproto.showModal !== "function") {
13
+ dproto.showModal = function () {
14
+ if (this.hasAttribute("open")) {
15
+ throw new (window.DOMException || Error)(
16
+ "The element already has an 'open' attribute, and therefore cannot be opened modally.",
17
+ "InvalidStateError"
18
+ );
19
+ }
20
+ this.setAttribute("open", "");
21
+ };
22
+ }
23
+ if (typeof dproto.show !== "function") {
24
+ dproto.show = function () {
25
+ if (!this.hasAttribute("open")) this.setAttribute("open", "");
26
+ };
27
+ }
28
+ if (typeof dproto.close !== "function") {
29
+ dproto.close = function (returnValue) {
30
+ if (!this.hasAttribute("open")) return;
31
+ this.removeAttribute("open");
32
+ if (returnValue !== undefined) this.returnValue = String(returnValue);
33
+ this.dispatchEvent(new Event("close"));
34
+ };
35
+ }
36
+ }
37
+
38
+ // ── Bug #3 (narrower than originally diagnosed; verified 2026-05-04 against build 6005) ──
39
+ // Native dispatch DOES propagate to ancestors with event.target preserved — but if
40
+ // ANY listener throws, Lightpanda halts the whole dispatch path (incl. the bubble
41
+ // phase) instead of reporting the exception and continuing per DOM §2.9 step 4.
42
+ // Stimulus / Turbo Drive listeners that throw silently swallow document-level
43
+ // delegation. Workaround: catch the propagated JsException and re-walk parents
44
+ // manually, spoofing event.target via Object.defineProperty for delegated handlers.
45
+ (function patchDispatch() {
46
+ if (!window.EventTarget || !EventTarget.prototype.dispatchEvent) return;
47
+ var orig = EventTarget.prototype.dispatchEvent;
48
+
49
+ EventTarget.prototype.dispatchEvent = function (event) {
50
+ try {
51
+ return orig.call(this, event);
52
+ } catch (err) {
53
+ if (!event || !event.bubbles || !this.parentNode) throw err;
54
+
55
+ var originalTarget = this;
56
+ try {
57
+ Object.defineProperty(event, "target", {
58
+ value: originalTarget,
59
+ configurable: true,
60
+ });
61
+ } catch (_) { /* target not redefinable — continue anyway */ }
62
+
63
+ var node = this.parentNode;
64
+ while (node) {
65
+ try {
66
+ Object.defineProperty(event, "currentTarget", {
67
+ value: node,
68
+ configurable: true,
69
+ });
70
+ } catch (_) {}
71
+ try {
72
+ orig.call(node, event);
73
+ } catch (_) { /* ignore intermediate crashes; keep propagating */ }
74
+ if (event.cancelBubble) break;
75
+ node = node.parentNode || node.host || null;
76
+ }
77
+ return !event.defaultPrevented;
78
+ }
79
+ };
80
+ })();
81
+ })();
@@ -73,18 +73,59 @@ module Capybara
73
73
  @browser = browser
74
74
  end
75
75
 
76
+ # A top-level modifier symbol (`:shift`, `:ctrl`, `:alt`, `:meta`) is
77
+ # held for the remainder of the call — `send_keys('ocean', :shift, 'side')`
78
+ # types `oceanSIDE`. Modifier presses fire `keyDown` (not `rawKeyDown`)
79
+ # so JS keydown handlers see the modifier event; CDP's `rawKeyDown` is
80
+ # documented as "no JS keyDown event is generated" and would hide the
81
+ # modifier from listeners that count keydown events.
76
82
  def type(*keys)
83
+ active_mods = []
77
84
  keys.each do |key|
78
85
  case key
79
- when Symbol then dispatch_key(key)
80
- when String then key.each_char { |char| dispatch_char(char) }
81
- when Array then type_with_modifiers(key)
86
+ when Symbol
87
+ if MODIFIERS.key?(key)
88
+ press_modifier(key, active_mods)
89
+ else
90
+ dispatch_key_with_mods(key, active_mods)
91
+ end
92
+ when String
93
+ key.each_char { |char| dispatch_char_with_mods(char, active_mods) }
94
+ when Array
95
+ type_with_modifiers(key)
82
96
  end
83
97
  end
98
+ release_modifiers(active_mods)
84
99
  end
85
100
 
86
101
  private
87
102
 
103
+ def press_modifier(mod, active_mods)
104
+ return if active_mods.include?(mod)
105
+
106
+ send_key_event("keyDown", KEYS[mod])
107
+ active_mods << mod
108
+ end
109
+
110
+ def release_modifiers(active_mods)
111
+ active_mods.reverse_each { |m| send_key_event("keyUp", KEYS[m]) }
112
+ active_mods.clear
113
+ end
114
+
115
+ def dispatch_key_with_mods(key, active_mods)
116
+ return dispatch_key(key) if active_mods.empty?
117
+
118
+ modifier_value = active_mods.sum { |m| MODIFIERS[m] }
119
+ dispatch_modified(key, modifier_value, active_mods)
120
+ end
121
+
122
+ def dispatch_char_with_mods(char, active_mods)
123
+ return dispatch_char(char) if active_mods.empty?
124
+
125
+ modifier_value = active_mods.sum { |m| MODIFIERS[m] }
126
+ dispatch_modified_char(char, modifier_value, active_mods)
127
+ end
128
+
88
129
  def dispatch_key(key)
89
130
  definition = KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
90
131
  raw_dispatch(definition)
@@ -98,7 +139,7 @@ module Capybara
98
139
  modifiers, chars = keys.partition { |k| k.is_a?(Symbol) && MODIFIERS.key?(k) }
99
140
  modifier_value = modifiers.sum { |m| MODIFIERS[m] }
100
141
 
101
- modifiers.each { |m| send_key_event("rawKeyDown", KEYS[m]) }
142
+ modifiers.each { |m| send_key_event("keyDown", KEYS[m]) }
102
143
  chars.each { |key| dispatch_modified(key, modifier_value, modifiers) }
103
144
  modifiers.reverse_each { |m| send_key_event("keyUp", KEYS[m]) }
104
145
  end