capybara-lightpanda 0.14.2 → 0.15.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.
@@ -76,7 +76,7 @@ module Capybara
76
76
  def get(name)
77
77
  find { |cookie| cookie.name == name }
78
78
  end
79
- # Ferrum/Cuprite spelling: `browser.cookies["session_id"]`.
79
+ # Ferrum spelling: `browser.cookies["session_id"]`.
80
80
  alias [] get
81
81
 
82
82
  # Returns whether the browser accepted the cookie (ferrum parity).
@@ -72,20 +72,20 @@ module Capybara
72
72
  oid && build_node(oid)
73
73
  end
74
74
 
75
- # Capybara's Session#send_keys routes to Driver#send_keys; Cuprite's pattern
76
- # is to fan that out to whatever element currently has focus.
75
+ # Capybara's Session#send_keys routes to Driver#send_keys; fan that out
76
+ # to whatever element currently has focus.
77
77
  def send_keys(*keys)
78
78
  active_element&.send_keys(*keys)
79
79
  end
80
80
 
81
- def find_xpath(selector)
82
- object_ids = browser.find("xpath", selector)
83
- object_ids.map { |oid| build_node(oid) }
81
+ # Accepting `**hints` is what makes Capybara send them (it checks the
82
+ # arity); Node.hint_spec turns them into the answers the find returns.
83
+ def find_xpath(selector, **hints)
84
+ browser.find_hinted("xpath", selector, Node.hint_spec(**hints)).map { |oid, cache| build_node(oid, cache) }
84
85
  end
85
86
 
86
- def find_css(selector)
87
- object_ids = browser.find("css", selector)
88
- object_ids.map { |oid| build_node(oid) }
87
+ def find_css(selector, **hints)
88
+ browser.find_hinted("css", selector, Node.hint_spec(**hints)).map { |oid, cache| build_node(oid, cache) }
89
89
  end
90
90
 
91
91
  # Inside `within_frame` these run in the frame's document, as in Chrome.
@@ -107,7 +107,7 @@ module Capybara
107
107
  # -- Network Inspection --
108
108
 
109
109
  # Network tracker (lazily auto-enabled). Exposes `traffic`, `clear`,
110
- # `wait_for_idle`, header overrides, etc. Cuprite parity.
110
+ # `wait_for_idle`, header overrides, etc.
111
111
  def network
112
112
  browser.network
113
113
  end
@@ -124,7 +124,7 @@ module Capybara
124
124
  # Files downloaded since the session started (absolute paths). Capture is
125
125
  # on whenever a destination exists (the :save_path driver option, else
126
126
  # Capybara.save_path) and the server sends `Content-Disposition:
127
- # attachment`. Ferrum/Cuprite expose downloads on the driver too.
127
+ # attachment`. Ferrum exposes downloads on its browser too.
128
128
 
129
129
  def downloads
130
130
  browser.downloads.files
@@ -286,18 +286,18 @@ module Capybara
286
286
  end
287
287
 
288
288
  # capybara-screenshot's fallback for unregistered drivers calls
289
- # `driver.render(path)` (the Cuprite/Ferrum spelling). Without the alias
290
- # every failed test in a capybara-screenshot suite logged
291
- # "Screenshot could not be saved: undefined method 'render'".
289
+ # `driver.render(path)`. Without the alias every failed test in a
290
+ # capybara-screenshot suite logged "Screenshot could not be saved:
291
+ # undefined method 'render'".
292
292
  alias render save_screenshot
293
293
 
294
- # -- Headers (Cuprite-compatible driver surface) --
294
+ # -- Headers (driver surface) --
295
295
  # Delegates to Network, which lazily enables the Network domain. The
296
296
  # overrides do NOT survive reset!: disposing the BrowserContext takes
297
297
  # setExtraHTTPHeaders with it, so Network#reset drops its cached copy
298
298
  # rather than report headers the browser stopped sending (pinned by
299
- # network_test.rb "clears extra_headers"). Cuprite exposes these on the
300
- # driver, and real suites call them there (page.driver.headers = ...).
299
+ # network_test.rb "clears extra_headers"). Real suites call these on the
300
+ # driver (page.driver.headers = ...).
301
301
 
302
302
  def headers
303
303
  browser.network.extra_headers
@@ -313,9 +313,9 @@ module Capybara
313
313
 
314
314
  # -- Lifecycle --
315
315
 
316
- # Thin Cuprite-style wrapper. The interesting work — disposing the
317
- # BrowserContext (cookies, storage, all targets) and starting a fresh
318
- # one — happens in Browser#reset.
316
+ # Thin wrapper. The interesting work — disposing the BrowserContext
317
+ # (cookies, storage, all targets) and starting a fresh one — happens in
318
+ # Browser#reset.
319
319
  #
320
320
  # Rescue is the gem hierarchy plus raw IO escapees only — NOT
321
321
  # StandardError: reset! runs between every test, so a blanket rescue
@@ -345,11 +345,10 @@ module Capybara
345
345
  true
346
346
  end
347
347
 
348
- # Expanded error list for Capybara retry logic (Cuprite pattern).
349
- # MouseEventFailed is in Cuprite's list, but Lightpanda has no
350
- # rendering engine and the gem dispatches clicks through JS — the
351
- # underlying CDP Input.dispatchMouseEvent path doesn't run, so
352
- # MouseEventFailed is never raised.
348
+ # Expanded error list for Capybara retry logic. MouseEventFailed is left
349
+ # out: Lightpanda has no rendering engine and the gem dispatches clicks
350
+ # through JS — the underlying CDP Input.dispatchMouseEvent path doesn't
351
+ # run, so MouseEventFailed is never raised.
353
352
  def invalid_element_errors
354
353
  [
355
354
  NodeNotFoundError,
@@ -374,8 +373,8 @@ module Capybara
374
373
 
375
374
  # A node found while a frame is switched to lives in that frame's
376
375
  # document, which Node#call checks for a frame navigation.
377
- def build_node(oid)
378
- Node.new(self, oid, frame_scoped: browser.frame_stack.any?)
376
+ def build_node(oid, initial_cache = {})
377
+ Node.new(self, oid, initial_cache, frame_scoped: browser.frame_stack.any?)
379
378
  end
380
379
 
381
380
  # Every window method takes a handle because Capybara's Window objects
@@ -391,7 +390,7 @@ module Capybara
391
390
 
392
391
  # Unwrap arguments before sending to the browser. Capybara::Node::Element wraps
393
392
  # our Lightpanda::Node — pull `.base` out so `serialize_argument` can build
394
- # `{objectId: …}` for the CDP payload. Cuprite's `native_args` pattern.
393
+ # `{objectId: …}` for the CDP payload.
395
394
  def native_args(args)
396
395
  args.map { |a| a.is_a?(Capybara::Node::Element) ? a.base : a }
397
396
  end
@@ -399,7 +398,7 @@ module Capybara
399
398
  # Lightpanda's `Network.setCookie` requires either `domain` or `url`
400
399
  # (storage.zig → Cookie.parseDomain). When the caller doesn't supply one,
401
400
  # use the host of the current page if any, else `Capybara.app_host`,
402
- # else loopback. Cuprite parity — lets pre-visit cookie setup just work.
401
+ # else loopback. Lets pre-visit cookie setup just work.
403
402
  def default_domain
404
403
  candidate = (@started && safe_uri_host(browser.current_url)) ||
405
404
  safe_uri_host(Capybara.app_host)
@@ -106,11 +106,11 @@ module Capybara
106
106
  end
107
107
  end
108
108
 
109
- # --- Cuprite/Ferrum drop-in compatibility surface ---
109
+ # --- Drop-in compatibility surface ---
110
110
  # This gem never raises the three classes below (no coordinate-based
111
111
  # mouse path, no multi-page API, no page-status tracking). They mirror
112
- # the peer taxonomy (Cuprite's MouseEventFailed, Ferrum's
113
- # NoSuchPageError/StatusError) so suites migrating from those drivers
112
+ # the error taxonomy of other Capybara CDP drivers (MouseEventFailed, plus
113
+ # Ferrum's NoSuchPageError/StatusError) so suites migrating from them
114
114
  # don't NameError on rescue lists that reference them.
115
115
  class MouseEventFailed < BrowserError
116
116
  attr_reader :node, :selector, :position
@@ -13,5 +13,6 @@ window._lightpanda = {
13
13
  isDisabled: isDisabled,
14
14
  isContentEditable: isContentEditable,
15
15
  pointerFocusTarget: pointerFocusTarget,
16
- visibleText: visibleText
16
+ visibleText: visibleText,
17
+ findHints: findHints
17
18
  };
@@ -19,12 +19,11 @@
19
19
  // External `<link rel=stylesheet>` ARE fetched and applied — the gem
20
20
  // always passes `--load-resources stylesheet` (Lightpanda PR #2487,
21
21
  // guaranteed by the build floor) — so stylesheet-driven `display:none`
22
- // participates in this check. The remaining gap is viewport emulation:
23
- // `@media` rules and `matchMedia()` evaluate against the hardcoded
24
- // 1920×1080 viewport (no resize), so visibility gated on a non-desktop
25
- // viewport — e.g. a mobile-only CTA under `@media (max-width: …)` —
26
- // resolves like desktop Chrome at 1920×1080. Keep those specs on a
27
- // full-layout driver (README's dual-driver setup).
22
+ // participates in this check. `@media` rules evaluate against the
23
+ // emulated viewport (`window_size`, 1920×1080 by default), so a
24
+ // mobile-only CTA under `@media (max-width: …)` needs a narrow
25
+ // `window_size` — or `resize_to` then `visit`, since a loaded document's
26
+ // cascade doesn't re-resolve on resize.
28
27
  function isVisible(el) {
29
28
  if (!el || el.nodeType !== 1) return false;
30
29
  var win = el.ownerDocument.defaultView || window;
@@ -56,13 +55,22 @@ function isObscured(el) {
56
55
  var hit = doc.elementFromPoint(cx, cy);
57
56
  if (!hit) return true;
58
57
  if (hit === el) return false;
59
- return !el.contains(hit);
58
+ if (el.contains(hit)) return false;
59
+ // A hit inside a shadow tree is retargeted to its host, and there is no
60
+ // ShadowRoot.elementFromPoint to look further, so landing on one of el's own
61
+ // hosts is the best "not obscured" answer available.
62
+ var root = el.getRootNode ? el.getRootNode() : doc;
63
+ while (root && root !== doc && root.host) {
64
+ if (hit === root.host) return false;
65
+ root = root.host.getRootNode();
66
+ }
67
+ return true;
60
68
  }
61
69
 
62
70
  // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
63
71
  // an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
64
72
  // is reported as disabled, even though those don't match CSS `:disabled`
65
- // per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
73
+ // per the HTML spec — the Capybara shared specs expect them disabled.
66
74
  function isDisabled(el) {
67
75
  if (el.matches && el.matches(':disabled')) return true;
68
76
  if ((el.tagName || '').toUpperCase() === 'OPTION') {
@@ -153,3 +161,60 @@ function visibleText(el) {
153
161
  }
154
162
  return out;
155
163
  }
164
+
165
+ // Capture intrinsics before page scripts can replace them. Array operations use
166
+ // indexed loops: an overridden map must never detach cache rows from node IDs.
167
+ var hintStringify = JSON.stringify;
168
+ var hintLowerCase = Function.prototype.call.bind(String.prototype.toLowerCase);
169
+
170
+ // Capybara keeps Result candidates for lazy enumeration, possibly after the DOM
171
+ // changes. Only immutable tag names belong in initial_cache; mutable visibility,
172
+ // geometry and styles must be read live. Text hints are a conservative prefilter.
173
+ function findHints(nodes, spec) {
174
+ // Bound eager subtree text walks: nested candidates otherwise make first()
175
+ // quadratic even though Capybara would inspect only the first candidate.
176
+ if (nodes.length > 512) return nodes;
177
+ try {
178
+ var texts = spec.texts || [];
179
+ var needles = [];
180
+ for (var i = 0; i < texts.length; i++) {
181
+ // Ruby splits ASCII whitespace, not every Unicode separator. Unicode
182
+ // casing can also depend on context (Greek final sigma). Fail open.
183
+ if (/[^\x00-\x7F]/.test(texts[i])) return nodes;
184
+ needles[i] = hintLowerCase(texts[i]);
185
+ }
186
+ var kept = [];
187
+ var hints = [];
188
+ for (var j = 0; j < nodes.length; j++) {
189
+ var el = nodes[j];
190
+ var matches = true;
191
+ if (needles.length) {
192
+ var all = el.textContent || '';
193
+ var visible = visibleText(el) || '';
194
+ // The driver's all_text and visible_text normalize Unicode differently.
195
+ // Retain such candidates for Capybara's authoritative text comparison.
196
+ if (!/[^\x00-\x7F]/.test(all + visible)) {
197
+ all = hintLowerCase(all);
198
+ visible = hintLowerCase(visible);
199
+ for (var k = 0; k < needles.length; k++) {
200
+ if (all.indexOf(needles[k]) === -1 && visible.indexOf(needles[k]) === -1) {
201
+ matches = false;
202
+ break;
203
+ }
204
+ }
205
+ }
206
+ }
207
+ if (matches) {
208
+ hints[kept.length] = { tag_name: el.nodeType === 11 ? 'ShadowRoot' :
209
+ (el.tagName ? hintLowerCase(el.tagName) : '') };
210
+ kept[kept.length] = el;
211
+ }
212
+ }
213
+ // Publish only after every read succeeds. Any exception returns the exact
214
+ // original array, without partial filtering or partially constructed hints.
215
+ kept.__lightpandaHints = hintStringify(hints);
216
+ return kept;
217
+ } catch (e) {
218
+ return nodes;
219
+ }
220
+ }
@@ -97,6 +97,11 @@ module Capybara
97
97
  # so JS keydown handlers see the modifier event; CDP's `rawKeyDown` is
98
98
  # documented as "no JS keyDown event is generated" and would hide the
99
99
  # modifier from listeners that count keydown events.
100
+ #
101
+ # Anything that is not a Symbol or an Array is typed as its `to_s`, as
102
+ # Selenium's `Keys.encode` does: `send_keys [1, :tab]` types "1", and
103
+ # `nil` types nothing. Without it an Integer was dropped silently and
104
+ # only the Tab went out.
100
105
  def type(*keys)
101
106
  active_mods = []
102
107
  keys.each do |key|
@@ -107,10 +112,10 @@ module Capybara
107
112
  else
108
113
  dispatch_key_with_mods(key, active_mods)
109
114
  end
110
- when String
111
- key.each_char { |char| dispatch_char_with_mods(char, active_mods) }
112
115
  when Array
113
116
  type_with_modifiers(key)
117
+ else
118
+ type_text(key, active_mods)
114
119
  end
115
120
  end
116
121
  release_modifiers(active_mods)
@@ -163,14 +168,19 @@ module Capybara
163
168
  active_mods = []
164
169
  modifiers.each { |m| press_modifier(m, active_mods) }
165
170
  rest.each do |key|
166
- case key
167
- when Symbol then dispatch_key_with_mods(key, active_mods)
168
- when String then key.each_char { |char| dispatch_char_with_mods(char, active_mods) }
171
+ if key.is_a?(Symbol)
172
+ dispatch_key_with_mods(key, active_mods)
173
+ else
174
+ type_text(key, active_mods)
169
175
  end
170
176
  end
171
177
  release_modifiers(active_mods)
172
178
  end
173
179
 
180
+ def type_text(key, active_mods)
181
+ key.to_s.each_char { |char| dispatch_char_with_mods(char, active_mods) }
182
+ end
183
+
174
184
  # A chord with Ctrl or Meta is a command, not text: Chrome inserts
175
185
  # nothing for Ctrl+A, it selects. Since upstream #3542 (build 9580)
176
186
  # Lightpanda inserts whatever the keyDown's `text` carries, so sending
@@ -92,7 +92,7 @@ module Capybara
92
92
 
93
93
  # Setting extra headers also lazily enables the Network domain. Without
94
94
  # this, headers were silently ignored until the caller separately ran
95
- # `network.enable` (or `wait_for_network_idle`). Cuprite/Ferrum parity.
95
+ # `network.enable` (or `wait_for_network_idle`).
96
96
  def headers=(headers)
97
97
  enable
98
98
  @extra_headers = headers
@@ -11,14 +11,22 @@ module Capybara
11
11
  # `frame_scoped`: the node lives in an iframe's document — it was found
12
12
  # while a frame was switched to, or from another frame-scoped node. See
13
13
  # #wrap_with_attached_guard for what that changes.
14
- def initialize(driver, remote_object_id, frame_scoped: false)
15
- super(driver, remote_object_id)
14
+ def initialize(driver, remote_object_id, initial_cache = {}, frame_scoped: false)
15
+ super(driver, remote_object_id, initial_cache)
16
16
  @remote_object_id = remote_object_id
17
17
  @frame_scoped = frame_scoped
18
+ @tag_name = initial_cache[:tag_name]
19
+ end
20
+
21
+ # Capybara's finder hints → the spec `_lightpanda.findHints` answers.
22
+ # Only tag_name is cached (when the bounded helper runs); Capybara reads
23
+ # initial_cache[:tag_name] before asking the node.
24
+ def self.hint_spec(texts: nil, **)
25
+ texts&.any? ? { texts: texts.map(&:to_s) } : {}
18
26
  end
19
27
 
20
28
  # Capybara::Driver::Node#native returns the constructor's second argument,
21
- # which here is the raw CDP objectId — a String. So the Selenium/Cuprite
29
+ # which here is the raw CDP objectId — a String. So the Selenium
22
30
  # idiom `element.native.send_keys(...)` (solidus's
23
31
  # return_authorizations_spec.rb does exactly that) died with
24
32
  # "undefined method 'send_keys' for an instance of String". This driver
@@ -112,7 +120,7 @@ module Capybara
112
120
  self.class.new(driver, result["objectId"])
113
121
  end
114
122
 
115
- # Smart property/attribute getter (Cuprite pattern).
123
+ # Smart property/attribute getter.
116
124
  # Returns resolved URLs for src/href, raw attributes otherwise.
117
125
  def [](name)
118
126
  call(PROPERTY_OR_ATTRIBUTE_JS, name.to_s)
@@ -165,10 +173,10 @@ module Capybara
165
173
  def scroll_to(*); end
166
174
  def scroll_by(*); end
167
175
 
168
- # Dispatch an arbitrary DOM event by name. Mirrors Cuprite's Node#trigger
169
- # — picks the right Event constructor for known mouse/focus/form names
170
- # and falls back to a generic Event for everything else (so callers can
171
- # fire custom events like `node.trigger('lp:custom')`).
176
+ # Dispatch an arbitrary DOM event by name. Picks the right Event
177
+ # constructor for known mouse/focus/form names and falls back to a
178
+ # generic Event for everything else (so callers can fire custom events
179
+ # like `node.trigger('lp:custom')`).
172
180
  def trigger(event)
173
181
  call(TRIGGER_JS, event.to_s)
174
182
  end
@@ -194,10 +202,10 @@ module Capybara
194
202
  # on this element, so HTML5 dropzones see the payload via
195
203
  # `event.dataTransfer`.
196
204
  #
197
- # Files reach the page the way Cuprite's #316 does it: a hidden
198
- # `<input type=file>` is attached to this element's document,
199
- # `DOM.setFileInputFiles` points it at the paths (the browser reads the
200
- # bytes off disk itself), and the drop JS moves `input.files` into the
205
+ # Files reach the page through a hidden `<input type=file>`: it is
206
+ # attached to this element's document, `DOM.setFileInputFiles` points it
207
+ # at the paths (the browser reads the bytes off disk itself), and the
208
+ # drop JS moves `input.files` into the
201
209
  # DataTransfer and removes the input. Previously the bytes were base64'd
202
210
  # into the `Runtime.callFunctionOn` message, which capped a drop at
203
211
  # ~70 MB under `--cdp-max-message-size` and pinned every byte in Ruby;
@@ -216,15 +224,15 @@ module Capybara
216
224
  end
217
225
 
218
226
  # Maps Capybara's documented drop_modifiers aliases onto the DragEvent
219
- # init keys (`ctrlKey`, `metaKey`, ...). Same table as Cuprite's #315.
227
+ # init keys (`ctrlKey`, `metaKey`, ...).
220
228
  DRAG_MODIFIER_ALIASES = { control: :ctrl, command: :meta, cmd: :meta }.freeze
221
229
 
222
230
  # Capybara's `Element#drag_to` — HTML5 half only. HTML5_DRAG_JS replays
223
- # Capybara's own Selenium HTML5_DRAG_DROP_SCRIPT (the same source
224
- # Cuprite's drag.js ports): dragstart on the draggable ancestor, then
225
- # dragenter -> 2x dragover -> dragleave/drop -> dragend, setTimeout-paced,
226
- # sharing one DataTransfer so `setData` in the page's dragstart handler is
227
- # readable at drop. Runs through `evaluate_async` (the script signals
231
+ # Capybara's own Selenium HTML5_DRAG_DROP_SCRIPT: dragstart on the
232
+ # draggable ancestor, then dragenter -> 2x dragover -> drop (accepted)
233
+ # or dragleave (refused) -> dragend, setTimeout-paced, sharing one
234
+ # DataTransfer so `setData` in the page's dragstart handler is readable
235
+ # at drop. Runs through `evaluate_async` (the script signals
228
236
  # completion via the appended callback), so the drag has fully played out
229
237
  # before this method returns.
230
238
  #
@@ -235,8 +243,8 @@ module Capybara
235
243
  # Selenium presses a real button, then apply the same
236
244
  # prevented-or-no-draggable-ancestor test.
237
245
  #
238
- # `steps:`/`scroll:` (Cuprite's legacy-path knobs) are accepted and
239
- # ignored so suites migrating from cuprite don't ArgumentError.
246
+ # `steps:`/`scroll:` (legacy mouse-path knobs of other CDP drivers) are
247
+ # accepted and ignored so suites migrating from them don't ArgumentError.
240
248
  def drag_to(other, html5: nil, delay: 0.05, drop_modifiers: [], **)
241
249
  keys = Array(drop_modifiers).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym).to_s }
242
250
  html5 = !call(LEGACY_DRAG_CHECK_JS) if html5.nil?
@@ -318,20 +326,18 @@ module Capybara
318
326
  end
319
327
 
320
328
  # Ancestor chain from `parentNode` up to (but not including) `document`,
321
- # returned as Lightpanda::Node wrappers. Mirrors Cuprite's `Node#parents`.
329
+ # returned as Lightpanda::Node wrappers.
322
330
  def parents
323
331
  oids = driver.browser.parents_of(@remote_object_id)
324
332
  oids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
325
333
  end
326
334
 
327
- def find_xpath(selector)
328
- object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
329
- object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
335
+ def find_xpath(selector, **hints)
336
+ find_hinted("xpath", selector, hints)
330
337
  end
331
338
 
332
- def find_css(selector)
333
- object_ids = driver.browser.find_within(@remote_object_id, "css", selector)
334
- object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
339
+ def find_css(selector, **hints)
340
+ find_hinted("css", selector, hints)
335
341
  end
336
342
 
337
343
  # Equality compares the underlying DOM node via backendNodeId, the only
@@ -368,6 +374,11 @@ module Capybara
368
374
 
369
375
  private
370
376
 
377
+ def find_hinted(method, selector, hints)
378
+ driver.browser.find_within_hinted(@remote_object_id, method, selector, self.class.hint_spec(**hints))
379
+ .map { |oid, cache| self.class.new(driver, oid, cache, frame_scoped: @frame_scoped) }
380
+ end
381
+
371
382
  def implicit_submit
372
383
  call(IMPLICIT_SUBMIT_JS)
373
384
  driver.browser.wait_for_idle
@@ -446,8 +457,8 @@ module Capybara
446
457
 
447
458
  # ISO 8601 week-of-year, "%G" giving the ISO week-numbering year so that
448
459
  # the last days of December that belong to week 1 of the next year are
449
- # rendered with the correct year. Matches Cuprite's `Node#set` for week
450
- # inputs and what the user would type into a `<input type=week>` field.
460
+ # rendered with the correct year. Matches what the user would type into
461
+ # a `<input type=week>` field.
451
462
  def format_week_value(value)
452
463
  return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
453
464
 
@@ -492,7 +503,7 @@ module Capybara
492
503
  self.class.new(driver, oid)
493
504
  end
494
505
 
495
- # Whitespace-normalized text (Cuprite pattern). Capybara's text matchers compare
506
+ # Whitespace-normalized text. Capybara's text matchers compare
496
507
  # against this, and Lightpanda's textContent preserves source-template whitespace
497
508
  # differently than Chrome — without normalization, multi-line fixtures fail
498
509
  # `text: "Line\nLine"` matchers.
@@ -639,25 +650,13 @@ module Capybara
639
650
  hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
640
651
  var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
641
652
  // A link to a scheme the browser can't fetch (mailto:, tel:, sms:…)
642
- // goes to an external app in Chrome and the page stays. Below
643
- // upstream #3940 (build 10536) the link's own activation instead
644
- // turns `mailto:a@b.com` into `http://mailto:a@b.com/` and loads
645
- // b.com (1.0.0), or loses the execution context on a failed `tel:`
646
- // navigation (10261–10535). Cancel that default from a window
647
- // listener added for this one dispatch: it runs after the page's
648
- // own listeners, which still see an uncancelled click.
653
+ // goes to an external app in Chrome and the page stays. The link's
654
+ // own activation is a no-op for it since upstream #3940 (10536, in
655
+ // the floor); don't navigate to it ourselves either.
649
656
  var link = hit.closest ? hit.closest('a[href]') : null;
650
657
  var scheme = link && /^\\s*([a-z][a-z0-9+.-]*):/i.exec(link.getAttribute('href'));
651
658
  var external = !!scheme && !/^(https?|about|blob|data|file|javascript)$/i.test(scheme[1]);
652
- var view = hit.ownerDocument.defaultView;
653
- var keepPage = function (e) { if (e === clickEvt) e.preventDefault(); };
654
- if (external && view) view.addEventListener('click', keepPage);
655
- var notCancelled;
656
- try {
657
- notCancelled = hit.dispatchEvent(clickEvt);
658
- } finally {
659
- if (external && view) view.removeEventListener('click', keepPage);
660
- }
659
+ var notCancelled = hit.dispatchEvent(clickEvt);
661
660
  if (external || !notCancelled || clickEvt.defaultPrevented) return;
662
661
  var tag = hit.tagName;
663
662
  // A `javascript:` link never navigates: the click's own activation
@@ -695,9 +694,9 @@ module Capybara
695
694
  }
696
695
  JS
697
696
 
698
- # Mirrors Cuprite's trigger map. Picks the right Event constructor based
699
- # on the event name so listeners that key on `event instanceof MouseEvent`
700
- # / `instanceof SubmitEvent` see what they expect; everything else goes
697
+ # Picks the right Event constructor based on the event name so listeners
698
+ # that key on `event instanceof MouseEvent` / `instanceof SubmitEvent`
699
+ # see what they expect; everything else goes
701
700
  # through a generic Event so custom names ("turbo:load", "lp:custom")
702
701
  # still dispatch. Each constructor is feature-detected (`typeof X !==
703
702
  # 'undefined'`) before use so a missing IDL on Lightpanda falls back
@@ -715,6 +714,9 @@ module Capybara
715
714
  event = new FocusEvent(name, init);
716
715
  } else if (name === 'submit' && typeof SubmitEvent !== 'undefined') {
717
716
  event = new SubmitEvent(name, init);
717
+ } else if (name === 'input' && typeof InputEvent !== 'undefined') {
718
+ // inputType is what typing sends; a plain Event leaves it undefined.
719
+ event = new InputEvent(name, { bubbles: true, cancelable: false, inputType: 'insertText' });
718
720
  } else {
719
721
  event = new Event(name, init);
720
722
  }
@@ -776,19 +778,45 @@ module Capybara
776
778
  }
777
779
  JS
778
780
 
779
- # Ported near-verbatim from Capybara's Selenium driver
781
+ # Ported from Capybara's Selenium driver
780
782
  # (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) —
781
- # the same source Cuprite's #315 drag.js ports — kept close to ease
782
- # future syncs. Upstream quirks preserved deliberately: `rectPt.top` in
783
- # pointOnRect (DOMPoint has no .top, that branch just falls through), the
784
- # undeclared `key` loop variable, and `callback.call(true)`. The
783
+ # kept close to ease future syncs. Unlike the original, a drag ends the
784
+ # way it does in a browser: `drop` if the target accepted it (cancelled
785
+ # the last dragover), otherwise `dragleave`, never both. Selenium's
786
+ # script fired `dragleave` before `drop`, and Pragmatic drag and drop
787
+ # reads a relatedTarget-less `dragleave` as the drag leaving the window,
788
+ # so the drop landed on nothing. Two port bugs are fixed too:
789
+ # pointOnRect read `rectPt.top` (a DOMPoint has none, so a target
790
+ # entered from above or below got the source's center as its entry
791
+ # point), and `key` leaked as a global. Cuprite made the same changes
792
+ # (rubycdp/cuprite#343). The
785
793
  # `source.draggable` reads are native — the HTMLElement.draggable IDL
786
794
  # shipped upstream in #3257 (build 8793, guaranteed by the floor; the
787
795
  # `_lightpanda.isDraggable` polyfill was retired with that bump).
788
796
  # Coordinates come from getBoundingClientRect, which Lightpanda
789
797
  # synthesizes without layout — dropzones reading clientX/Y get
790
798
  # plausible-but-synthetic (integer since #3259) numbers.
799
+ #
800
+ # One deliberate deviation: the target is centered by
801
+ # `scrollCenterIntoView`, not `scrollIntoView({block: 'center'})`.
802
+ # Lightpanda's scrollIntoView ignores its options and aligns the
803
+ # element's top with the viewport's, and since upstream #3658 (nightly
804
+ # 10661) rects follow the scroll, so a source just above the target
805
+ # left the viewport and the first dragover carried a negative clientY.
806
+ # The helper does what Chrome does (element center at viewport center,
807
+ # clamped to the scrollable range), measuring against the root's rect
808
+ # so it holds whether or not the build's rects follow the scroll.
809
+ # Pinned by `drag_test.rb`.
791
810
  HTML5_DRAG_JS = <<~JS
811
+ function scrollCenterIntoView(el) {
812
+ var doc = el.ownerDocument, win = doc.defaultView;
813
+ var rect = el.getBoundingClientRect();
814
+ var root = doc.documentElement.getBoundingClientRect();
815
+ var x = rect.left - root.left + rect.width / 2 - win.innerWidth / 2;
816
+ var y = rect.top - root.top + rect.height / 2 - win.innerHeight / 2;
817
+ win.scrollTo(Math.max(0, x), Math.max(0, y));
818
+ }
819
+
792
820
  function rectCenter(rect){
793
821
  return new DOMPoint(
794
822
  (rect.left + rect.right)/2,
@@ -813,7 +841,7 @@ module Capybara
813
841
  }
814
842
 
815
843
  if (pt.y <= rectPt.y) { // top side
816
- var minYx = (rectPt.top - pt.y) / slope + pt.x;
844
+ var minYx = (rect.top - pt.y) / slope + pt.x;
817
845
  if (rect.left <= minYx && minYx <= rect.right)
818
846
  return new DOMPoint(minYx, rect.top);
819
847
  }
@@ -828,12 +856,12 @@ module Capybara
828
856
  }
829
857
 
830
858
  function dragEnterTarget() {
831
- target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
859
+ scrollCenterIntoView(target);
832
860
  var targetRect = target.getBoundingClientRect();
833
861
  var sourceCenter = rectCenter(source.getBoundingClientRect());
834
862
 
835
863
  for (var i = 0; i < drop_modifier_keys.length; i++) {
836
- key = drop_modifier_keys[i];
864
+ var key = drop_modifier_keys[i];
837
865
  if (key == "control"){
838
866
  key = "ctrl"
839
867
  }
@@ -856,18 +884,14 @@ module Capybara
856
884
  var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
857
885
  var dragOverEvent = new DragEvent('dragover', dragOverOpts);
858
886
  target.dispatchEvent(dragOverEvent);
859
- window.setTimeout(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
887
+ window.setTimeout(dragEnd, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
860
888
  }
861
889
 
862
- function dragLeave(drop, dragOverOpts) {
863
- var dragLeaveOptions = Object.assign({}, opts, dragOverOpts);
864
- var dragLeaveEvent = new DragEvent('dragleave', dragLeaveOptions);
865
- target.dispatchEvent(dragLeaveEvent);
866
- if (drop) {
867
- var dropEvent = new DragEvent('drop', dragLeaveOptions);
868
- target.dispatchEvent(dropEvent);
869
- }
870
- var dragEndEvent = new DragEvent('dragend', dragLeaveOptions);
890
+ function dragEnd(drop, dragOverOpts) {
891
+ var dragEndOptions = Object.assign({}, opts, dragOverOpts);
892
+ var dropOrLeaveEvent = new DragEvent(drop ? 'drop' : 'dragleave', dragEndOptions);
893
+ target.dispatchEvent(dropOrLeaveEvent);
894
+ var dragEndEvent = new DragEvent('dragend', dragEndOptions);
871
895
  source.dispatchEvent(dragEndEvent);
872
896
  callback.call(true);
873
897
  }
@@ -1085,7 +1109,7 @@ module Capybara
1085
1109
  # same node (`node #path returns xpath which points to itself`), so this
1086
1110
  # emits `/HTML/BODY/DIV[2]/P[1]` rather than the CSS-ish
1087
1111
  # `html > body > div:nth-of-type(2) > p` it used to. Chrome exposes no
1088
- # native equivalent either; Cuprite hand-rolls the same walk.
1112
+ # native equivalent either.
1089
1113
  #
1090
1114
  # Every step is indexed, including single children: `P[1]` and `P` select
1091
1115
  # the same node, but the explicit index keeps the output stable if a