capybara-lightpanda 0.12.0 → 0.14.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.
@@ -79,7 +79,15 @@ module Capybara
79
79
  # Ferrum/Cuprite spelling: `browser.cookies["session_id"]`.
80
80
  alias [] get
81
81
 
82
- def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists
82
+ # Returns whether the browser accepted the cookie (ferrum parity).
83
+ # Lightpanda answers `{success: false}` and drops the cookie for an
84
+ # invalid combination — notably `same_site: "None"` without `secure`
85
+ # since upstream #3529 (build 9877), Chrome's rule — so callers that
86
+ # need it to stick should check the result. A malformed field (a `;` or
87
+ # control character in the name or value, surrounding whitespace) raises
88
+ # BrowserError instead: since upstream #3791 (build 10212) the browser
89
+ # answers `-32602 Sanitizing cookie failed`, as Chrome does.
90
+ def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists, Naming/PredicateMethod
83
91
  same_site: nil, expires: nil)
84
92
  params = {
85
93
  name: name,
@@ -96,7 +104,8 @@ module Capybara
96
104
  params[:sameSite] = same_site if %w[Strict Lax None].include?(same_site)
97
105
  params[:expires] = expires.to_i if expires
98
106
 
99
- browser.command("Network.setCookie", **params)
107
+ result = browser.command("Network.setCookie", **params)
108
+ !(result.is_a?(Hash) && result["success"] == false)
100
109
  end
101
110
 
102
111
  def remove(name:, domain: nil, path: "/")
@@ -119,10 +128,15 @@ module Capybara
119
128
  # Load cookies from a YAML file produced by `store` and re-set them.
120
129
  # CDP requires either domain or url for each cookie; entries from `store`
121
130
  # already include domain, so they round-trip cleanly. Returns true on
122
- # success (intentionally not a predicate — mirrors ferrum's API).
131
+ # success (intentionally not a predicate — mirrors ferrum's API). A
132
+ # cookie the browser refuses — `{success: false}`, or a malformed field
133
+ # it rejects outright — is reported on stderr rather than raised, so one
134
+ # bad entry doesn't abort restoring the rest. Builds before 10212 stored
135
+ # malformed cookies, so files saved from them can hold such entries.
123
136
  def load(path = "cookies.yml") # rubocop:disable Naming/PredicateMethod
124
137
  cookies = YAML.load_file(path)
125
- cookies.each { |c| restore_cookie(c) }
138
+ rejected = cookies.reject { |c| restore_cookie(c) }.map { |c| c.transform_keys(&:to_s)["name"] }
139
+ warn "Capybara::Lightpanda: the browser refused cookies from #{path}: #{rejected.join(', ')}" if rejected.any?
126
140
  true
127
141
  end
128
142
 
@@ -140,10 +154,36 @@ module Capybara
140
154
  http_only: attrs["httpOnly"] || false,
141
155
  }
142
156
  params[:domain] = attrs["domain"] if attrs["domain"]
143
- params[:same_site] = attrs["sameSite"] if attrs["sameSite"]
157
+ same_site = restored_same_site(attrs)
158
+ params[:same_site] = same_site if same_site
144
159
  exp = attrs["expires"]
145
160
  params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
161
+ set_or_refuse(**params)
162
+ end
163
+
164
+ def set_or_refuse(**params)
146
165
  set(**params)
166
+ rescue BrowserError => e
167
+ raise unless e.code == INVALID_PARAMS
168
+
169
+ false
170
+ end
171
+
172
+ # Chrome's (and, since upstream #3791, Lightpanda's) answer to a cookie
173
+ # whose fields fail sanitizing.
174
+ INVALID_PARAMS = -32_602
175
+ private_constant :INVALID_PARAMS
176
+
177
+ # Lightpanda below build 9877 reported every cookie without a SameSite
178
+ # attribute as "None", so a file stored then is full of insecure
179
+ # SameSite=None cookies — which the browser now rejects (Chrome's rule,
180
+ # upstream #3529). Leaving SameSite unset restores what the cookie
181
+ # actually was: unspecified, i.e. Lax-by-default.
182
+ def restored_same_site(attrs)
183
+ same_site = attrs["sameSite"]
184
+ return nil if same_site == "None" && !attrs["secure"]
185
+
186
+ same_site
147
187
  end
148
188
  end
149
189
  end
@@ -69,7 +69,7 @@ module Capybara
69
69
 
70
70
  def active_element
71
71
  oid = browser.active_element
72
- oid && Node.new(self, oid)
72
+ oid && build_node(oid)
73
73
  end
74
74
 
75
75
  # Capybara's Session#send_keys routes to Driver#send_keys; Cuprite's pattern
@@ -80,25 +80,28 @@ module Capybara
80
80
 
81
81
  def find_xpath(selector)
82
82
  object_ids = browser.find("xpath", selector)
83
- object_ids.map { |oid| Node.new(self, oid) }
83
+ object_ids.map { |oid| build_node(oid) }
84
84
  end
85
85
 
86
86
  def find_css(selector)
87
87
  object_ids = browser.find("css", selector)
88
- object_ids.map { |oid| Node.new(self, oid) }
88
+ object_ids.map { |oid| build_node(oid) }
89
89
  end
90
90
 
91
+ # Inside `within_frame` these run in the frame's document, as in Chrome.
91
92
  def evaluate_script(script, *args)
92
- unwrap_script_result(browser.evaluate(script.strip, *native_args(args)))
93
+ unwrap_script_result(browser.evaluate(script.strip, *native_args(args),
94
+ frame: browser.frame_stack.last))
93
95
  end
94
96
 
95
97
  def execute_script(script, *args)
96
- browser.execute(script.strip, *native_args(args))
98
+ browser.execute(script.strip, *native_args(args), frame: browser.frame_stack.last)
97
99
  nil
98
100
  end
99
101
 
100
102
  def evaluate_async_script(script, *args)
101
- unwrap_script_result(browser.evaluate_async(script.strip, *native_args(args)))
103
+ unwrap_script_result(browser.evaluate_async(script.strip, *native_args(args),
104
+ frame: browser.frame_stack.last))
102
105
  end
103
106
 
104
107
  # -- Network Inspection --
@@ -369,6 +372,12 @@ module Capybara
369
372
 
370
373
  private
371
374
 
375
+ # A node found while a frame is switched to lives in that frame's
376
+ # 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?)
379
+ end
380
+
372
381
  # Every window method takes a handle because Capybara's Window objects
373
382
  # carry one; with a single window the only valid value is the current
374
383
  # target id. Reject anything else rather than silently operating on the
@@ -414,7 +423,7 @@ module Capybara
414
423
  when Array then value.map { |v| unwrap_script_result(v) }
415
424
  when Hash
416
425
  if value.size == 1 && value.key?(Browser::NODE_MARKER)
417
- Node.new(self, value[Browser::NODE_MARKER])
426
+ build_node(value[Browser::NODE_MARKER])
418
427
  else
419
428
  value.transform_values { |v| unwrap_script_result(v) }
420
429
  end
@@ -12,5 +12,6 @@ window._lightpanda = {
12
12
  isObscured: isObscured,
13
13
  isDisabled: isDisabled,
14
14
  isContentEditable: isContentEditable,
15
+ pointerFocusTarget: pointerFocusTarget,
15
16
  visibleText: visibleText
16
17
  };
@@ -17,7 +17,7 @@
17
17
  // separately because checkVisibility's defaults (per spec) ignore it.
18
18
  //
19
19
  // External `<link rel=stylesheet>` ARE fetched and applied — the gem
20
- // always passes --enable-external-stylesheets (Lightpanda PR #2487,
20
+ // always passes `--load-resources stylesheet` (Lightpanda PR #2487,
21
21
  // guaranteed by the build floor) — so stylesheet-driven `display:none`
22
22
  // participates in this check. The remaining gap is viewport emulation:
23
23
  // `@media` rules and `matchMedia()` evaluate against the hardcoded
@@ -68,7 +68,9 @@ function isDisabled(el) {
68
68
  if ((el.tagName || '').toUpperCase() === 'OPTION') {
69
69
  var p = el.parentElement;
70
70
  while (p) {
71
- if (p.disabled) return true;
71
+ // `=== true`: a <form>'s `disabled` is named field access, so
72
+ // `<input name="disabled">` makes `form.disabled` that input.
73
+ if (p.disabled === true) return true;
72
74
  p = p.parentElement;
73
75
  }
74
76
  }
@@ -92,6 +94,43 @@ function isContentEditable(el) {
92
94
  return false;
93
95
  }
94
96
 
97
+ // The element a real pointer press on `el` would focus: the nearest focusable
98
+ // inclusive ancestor, or null when the press lands on nothing focusable (Chrome
99
+ // then moves focus to the body). Lightpanda's handleClick stopped focusing
100
+ // controls in upstream #3702 (build 9968), and an untrusted `mousedown` — the
101
+ // only kind CLICK_JS can dispatch — never runs the focus default, so the
102
+ // driver has to apply it itself or `click` then `page.send_keys` types nowhere.
103
+ function pointerFocusTarget(el) {
104
+ function isEditingHost(n) {
105
+ if (!n.hasAttribute || !n.hasAttribute('contenteditable')) return false;
106
+ return (n.getAttribute('contenteditable') || '').toLowerCase() !== 'false';
107
+ }
108
+ function isFocusable(n) {
109
+ if (isDisabled(n)) return false;
110
+ switch ((n.tagName || '').toUpperCase()) {
111
+ case 'INPUT': return (n.getAttribute('type') || '').toLowerCase() !== 'hidden';
112
+ case 'SELECT': case 'TEXTAREA': case 'BUTTON': case 'IFRAME': case 'SUMMARY': return true;
113
+ case 'A': case 'AREA': return n.hasAttribute('href');
114
+ }
115
+ return n.hasAttribute('tabindex');
116
+ }
117
+
118
+ for (var n = el; n && n.nodeType === 1; n = n.parentElement) {
119
+ // A press on a <label> lands on its control (the label's activation
120
+ // behavior); the label itself is never focusable.
121
+ if ((n.tagName || '').toUpperCase() === 'LABEL' && n.control) {
122
+ return isFocusable(n.control) ? n.control : null;
123
+ }
124
+ if (isEditingHost(n)) {
125
+ // Focus goes to the outermost element of a contiguous editable region.
126
+ while (n.parentElement && isEditingHost(n.parentElement)) n = n.parentElement;
127
+ return n;
128
+ }
129
+ if (isFocusable(n)) return n;
130
+ }
131
+ return null;
132
+ }
133
+
95
134
  // Capybara's "visible text" = WebDriver getText semantics, which native
96
135
  // innerText alone doesn't provide at two edges: a non-rendered element must
97
136
  // yield "" (innerText returns textContent per the HTML getter's step 1), and a
@@ -171,19 +171,37 @@ module Capybara
171
171
  release_modifiers(active_mods)
172
172
  end
173
173
 
174
+ # A chord with Ctrl or Meta is a command, not text: Chrome inserts
175
+ # nothing for Ctrl+A, it selects. Since upstream #3542 (build 9580)
176
+ # Lightpanda inserts whatever the keyDown's `text` carries, so sending
177
+ # `text` with Ctrl held typed a stray "a" into the field. The keyDown
178
+ # still names the key, so shortcut handlers (`e.ctrlKey && e.key ===
179
+ # "a"`) fire exactly as before.
180
+ COMMAND_MODIFIERS = %i[ctrl control meta command].freeze
181
+ private_constant :COMMAND_MODIFIERS
182
+
174
183
  def dispatch_modified_char(char, modifier_value, modifiers)
175
- text = modifiers.include?(:shift) ? self.class.shifted(char) : char
176
- send_key_event("keyDown", { key: text, text: text, unmodifiedText: char }, modifiers: modifier_value)
177
- send_key_event("keyUp", { key: text }, modifiers: modifier_value)
184
+ key = modifiers.include?(:shift) ? self.class.shifted(char) : char
185
+ definition = { key: key, unmodifiedText: char }
186
+ definition[:text] = key unless modifiers.intersect?(COMMAND_MODIFIERS)
187
+ send_key_event("keyDown", definition, modifiers: modifier_value)
188
+ send_key_event("keyUp", { key: key }, modifiers: modifier_value)
178
189
  end
179
190
 
180
191
  def key_definition(key)
181
192
  KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
182
193
  end
183
194
 
195
+ # Always `keyDown`, never `rawKeyDown`. Chrome's convention (Ferrum,
196
+ # Puppeteer) is rawKeyDown for keys that carry no `text`, but Lightpanda's
197
+ # Input.dispatchKeyEvent drops rawKeyDown on the floor as a
198
+ # Chrome-internal type (input.zig: `if (params.type == .rawKeyDown)
199
+ # return;`). Under that convention every text-less key — Backspace,
200
+ # Delete, Tab, Escape, the arrows — silently never reached the page,
201
+ # while Enter and Space (which have `text`) did. Lightpanda synthesizes
202
+ # its own keypress from a keyDown, so there is nothing to double-fire.
184
203
  def raw_dispatch(definition, modifiers: 0)
185
- type = definition[:text] ? "keyDown" : "rawKeyDown"
186
- send_key_event(type, definition, modifiers: modifiers)
204
+ send_key_event("keyDown", definition, modifiers: modifiers)
187
205
  send_key_event("keyUp", definition, modifiers: modifiers)
188
206
  end
189
207
 
@@ -20,7 +20,7 @@ module Capybara
20
20
  @traffic = []
21
21
  @traffic_mutex = Mutex.new
22
22
  @enabled = false
23
- @request_handler = @response_handler = nil
23
+ @request_handler = @response_handler = @failure_handler = nil
24
24
  @last_navigation_response = nil
25
25
  @document_request_id = nil
26
26
  end
@@ -111,10 +111,10 @@ module Capybara
111
111
  browser.page_command("Network.setExtraHTTPHeaders", headers: {})
112
112
  end
113
113
 
114
- # Count of in-flight requests (those with no response yet recorded).
114
+ # Count of in-flight requests (no response and no failure recorded yet).
115
115
  # Cheap predicate-friendly accessor (ferrum parity).
116
116
  def pending_connections
117
- @traffic_mutex.synchronize { @traffic.count { |t| t[:response].nil? } }
117
+ @traffic_mutex.synchronize { @traffic.count { |t| open?(t) } }
118
118
  end
119
119
 
120
120
  # True when no more than `connections` requests are in-flight.
@@ -158,8 +158,10 @@ module Capybara
158
158
  def subscribe
159
159
  @request_handler = build_request_handler
160
160
  @response_handler = build_response_handler
161
+ @failure_handler = build_failure_handler
161
162
  browser.on("Network.requestWillBeSent", &@request_handler)
162
163
  browser.on("Network.responseReceived", &@response_handler)
164
+ browser.on("Network.loadingFailed", &@failure_handler)
163
165
  end
164
166
 
165
167
  # Redirects follow Chrome's shape (Lightpanda since #3175, build ≥8602):
@@ -184,6 +186,7 @@ module Capybara
184
186
  method: params.dig("request", "method"),
185
187
  timestamp: params["timestamp"],
186
188
  response: nil,
189
+ error: nil,
187
190
  }
188
191
  @traffic_mutex.synchronize do
189
192
  if (redirect = params["redirectResponse"]) && (previous = last_open_entry(params["requestId"]))
@@ -214,9 +217,31 @@ module Capybara
214
217
  end
215
218
  end
216
219
 
220
+ # A request that never gets a response — refused connection, CORS
221
+ # block, abort — ends with Network.loadingFailed instead. Without this
222
+ # handler its entry stayed open forever, so pending_connections never
223
+ # reached zero and every wait_for_idle burned its full timeout. Routine
224
+ # since Lightpanda enforces CORS by default (upstream #3654, build 9883:
225
+ # a blocked fetch reports errorText "CorsBlocked"). Ferrum closes its
226
+ # exchange on the same event.
227
+ def build_failure_handler
228
+ lambda do |params|
229
+ @traffic_mutex.synchronize do
230
+ request = last_open_entry(params["requestId"])
231
+ next unless request
232
+
233
+ request[:error] = params["errorText"].to_s
234
+ end
235
+ end
236
+ end
237
+
217
238
  # Caller holds @traffic_mutex.
218
239
  def last_open_entry(request_id)
219
- @traffic.reverse_each.find { |t| t[:request_id] == request_id && t[:response].nil? }
240
+ @traffic.reverse_each.find { |t| t[:request_id] == request_id && open?(t) }
241
+ end
242
+
243
+ def open?(entry)
244
+ entry[:response].nil? && entry[:error].nil?
220
245
  end
221
246
 
222
247
  def response_summary(response)
@@ -231,8 +256,10 @@ module Capybara
231
256
  def unsubscribe
232
257
  browser.off("Network.requestWillBeSent", @request_handler) if @request_handler
233
258
  browser.off("Network.responseReceived", @response_handler) if @response_handler
259
+ browser.off("Network.loadingFailed", @failure_handler) if @failure_handler
234
260
  @request_handler = nil
235
261
  @response_handler = nil
262
+ @failure_handler = nil
236
263
  end
237
264
  end
238
265
  end
@@ -8,9 +8,13 @@ module Capybara
8
8
 
9
9
  attr_reader :remote_object_id
10
10
 
11
- def initialize(driver, remote_object_id)
12
- super
11
+ # `frame_scoped`: the node lives in an iframe's document — it was found
12
+ # while a frame was switched to, or from another frame-scoped node. See
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)
13
16
  @remote_object_id = remote_object_id
17
+ @frame_scoped = frame_scoped
14
18
  end
15
19
 
16
20
  # Capybara::Driver::Node#native returns the constructor's second argument,
@@ -265,14 +269,11 @@ module Capybara
265
269
  # absence then showed up as `current_url` being read against the
266
270
  # outgoing document (test/features/keyboard_activation_test.rb).
267
271
  #
268
- # Caveat, shared with #click: `wait_for_idle` only sniffs for ~50 ms
269
- # (Browser::SNIFF_WINDOW) and watches Runtime.executionContextsCleared,
270
- # which Lightpanda emits when the *response* lands, not when the request
271
- # starts. A navigation whose server round-trip exceeds that window is
272
- # therefore still not awaited here — use Capybara's waiting matchers
273
- # (`have_current_path`) for those, as you would after a click.
272
+ # `wait_for_idle` sniffs for ~50 ms (Browser::SNIFF_WINDOW) for the
273
+ # main frame to *start* navigating, then waits for the new document —
274
+ # so a slow server round-trip is awaited, as after a click.
274
275
  def send_keys(*)
275
- call("function() { this.focus() }")
276
+ call(SEND_KEYS_FOCUS_JS)
276
277
  driver.browser.keyboard.type(*)
277
278
  driver.browser.wait_for_idle
278
279
  end
@@ -320,17 +321,17 @@ module Capybara
320
321
  # returned as Lightpanda::Node wrappers. Mirrors Cuprite's `Node#parents`.
321
322
  def parents
322
323
  oids = driver.browser.parents_of(@remote_object_id)
323
- oids.map { |oid| self.class.new(driver, oid) }
324
+ oids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
324
325
  end
325
326
 
326
327
  def find_xpath(selector)
327
328
  object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
328
- object_ids.map { |oid| self.class.new(driver, oid) }
329
+ object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
329
330
  end
330
331
 
331
332
  def find_css(selector)
332
333
  object_ids = driver.browser.find_within(@remote_object_id, "css", selector)
333
- object_ids.map { |oid| self.class.new(driver, oid) }
334
+ object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
334
335
  end
335
336
 
336
337
  # Equality compares the underlying DOM node via backendNodeId, the only
@@ -515,7 +516,9 @@ module Capybara
515
516
  # instead of returning stale data from V8's still-live reference. After
516
517
  # a DOM mutation like `replaceWith`, the cached objectId still resolves
517
518
  # to the detached node, so reads succeed quietly and Capybara's
518
- # automatic_reload never re-runs the original query.
519
+ # automatic_reload never re-runs the original query. A frame-scoped node
520
+ # whose document has lost its window is obsolete too (see
521
+ # DISCARDED_DOCUMENT_CHECK).
519
522
  def call(function_declaration, *args, return_by_value: true)
520
523
  guarded = wrap_with_attached_guard(function_declaration)
521
524
  driver.browser.with_default_context_wait do
@@ -531,12 +534,21 @@ module Capybara
531
534
  end
532
535
 
533
536
  OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
534
- private_constant :OBSOLETE_NODE_MARKER
537
+
538
+ # When an iframe navigates, Chrome destroys the old document's context,
539
+ # so calls on its nodes fail and Capybara re-finds them. Lightpanda keeps
540
+ # the context alive: the node stays `isConnected` and answers from the
541
+ # discarded document, whose `defaultView` is null. Only for frame-scoped
542
+ # nodes: a script-built document (DOMParser, createHTMLDocument) has no
543
+ # window either, and its nodes stay usable. `|| this` covers a Document
544
+ # node, whose ownerDocument is null.
545
+ DISCARDED_DOCUMENT_CHECK = " || (this.ownerDocument || this).defaultView === null"
546
+ private_constant :OBSOLETE_NODE_MARKER, :DISCARDED_DOCUMENT_CHECK
535
547
 
536
548
  def wrap_with_attached_guard(function_declaration)
537
549
  <<~JS
538
550
  function() {
539
- if (!this.isConnected) throw new Error(#{OBSOLETE_NODE_MARKER.inspect});
551
+ if (!this.isConnected#{DISCARDED_DOCUMENT_CHECK if @frame_scoped}) throw new Error(#{OBSOLETE_NODE_MARKER.inspect});
540
552
  return (#{function_declaration}).apply(this, arguments);
541
553
  }
542
554
  JS
@@ -609,28 +621,56 @@ module Capybara
609
621
  // Cancelling mousedown suppresses focus/text-selection in a real
610
622
  // browser but never the click, so the click below is dispatched
611
623
  // unconditionally.
612
- hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
624
+ var pressed = hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
625
+ // mousedown's default action moves focus. Ours is untrusted, so the
626
+ // browser won't do it (and since upstream #3702 its click handling
627
+ // doesn't either): focus what the press landed on, or blur the
628
+ // current field when it landed on nothing focusable — Chrome's
629
+ // behavior, and what `click` then `page.send_keys` relies on.
630
+ if (pressed && window._lightpanda) {
631
+ var doc = hit.ownerDocument;
632
+ var focusTarget = _lightpanda.pointerFocusTarget(hit);
633
+ if (focusTarget) {
634
+ if (doc.activeElement !== focusTarget) focusTarget.focus();
635
+ } else if (doc.activeElement && doc.activeElement !== doc.body && doc.activeElement.blur) {
636
+ doc.activeElement.blur();
637
+ }
638
+ }
613
639
  hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
614
640
  var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
615
641
  var notCancelled = hit.dispatchEvent(clickEvt);
616
642
  if (!notCancelled || clickEvt.defaultPrevented) return;
617
643
  var tag = hit.tagName;
644
+ // A `javascript:` link never navigates: the click's own activation
645
+ // behavior has already run its code. Assigning it to location.href
646
+ // makes Lightpanda request `http://javascript:…`, and since upstream
647
+ // #3843 (build 10261) that failed navigation leaves the page with no
648
+ // execution context — `href="javascript:void(0)"` toggles (select2,
649
+ // Bootstrap) killed the page (lightpanda-io/browser#3898).
650
+ if (tag === 'A' && /^\\s*javascript:/i.test(hit.getAttribute('href') || '')) return;
618
651
  if (tag === 'A' && hit.href && hit.target !== '_blank') {
652
+ // Navigate the link's own window, never the bare `window`:
653
+ // Lightpanda runs callFunctionOn in the top document's context
654
+ // even for a node inside an iframe, so `window` would be the top
655
+ // window and a link clicked in a frame replaced the whole page.
656
+ var own = hit.ownerDocument.defaultView || window;
657
+ var target = (hit.target || '').toLowerCase();
658
+ var win = (target === '_top' && own.top) || (target === '_parent' && own.parent) || own;
619
659
  // Same-document fragment-only navigation: just update hash (or do
620
660
  // nothing if identical). Mirrors Chrome — assigning location.href
621
661
  // to a same-document URL on Lightpanda triggers a real navigation
622
662
  // tick that cancels pending setTimeout callbacks and clears form
623
663
  // values, which breaks any test driving DOM updates from a click
624
664
  // handler on `<a href="#...">`.
625
- var dest = new URL(hit.href, document.baseURI);
626
- var here = new URL(window.location.href);
665
+ var dest = new URL(hit.href, hit.ownerDocument.baseURI);
666
+ var here = new URL(win.location.href);
627
667
  if (dest.origin === here.origin && dest.pathname === here.pathname &&
628
668
  dest.search === here.search) {
629
669
  if (dest.hash !== here.hash) {
630
- window.location.hash = dest.hash;
670
+ win.location.hash = dest.hash;
631
671
  }
632
672
  } else {
633
- window.location.href = hit.href;
673
+ win.location.href = hit.href;
634
674
  }
635
675
  }
636
676
  }
@@ -918,8 +958,46 @@ module Capybara
918
958
  }
919
959
  JS
920
960
 
961
+ # Focuses the element keys are about to be typed into, WebDriver's
962
+ # "Element Send Keys" way (what chromedriver's focus script does): an
963
+ # element that did NOT already have focus gets its caret after its
964
+ # content. Since upstream #3424 (build 9234) `Input.insertText` inserts
965
+ # at the caret, and a value seeded by the `value=` attribute leaves the
966
+ # caret at 0 — without this, `send_keys("x")` on a pre-filled field
967
+ # typed "xfoo" where Chrome types "foox". An element that already has
968
+ # focus keeps its caret, so `page.send_keys` mid-edit continues in place.
969
+ # A non-focusable target (focus() is a no-op on those since upstream
970
+ # #3592, build 9646) must not leave the previous field focused, or its
971
+ # keys land there: blur it so they go to the body, as in Chrome.
972
+ SEND_KEYS_FOCUS_JS = <<~JS
973
+ function() {
974
+ var doc = this.ownerDocument;
975
+ if (doc.activeElement === this) return;
976
+ this.focus();
977
+ if (doc.activeElement !== this) {
978
+ if (doc.activeElement && doc.activeElement !== doc.body && doc.activeElement.blur) doc.activeElement.blur();
979
+ return;
980
+ }
981
+ if (typeof this.setSelectionRange === 'function') {
982
+ // Throws on types without a selection (email, number, …) — those
983
+ // have no caret to place.
984
+ try {
985
+ var end = String(this.value || '').length;
986
+ this.setSelectionRange(end, end);
987
+ } catch (e) {}
988
+ }
989
+ }
990
+ JS
991
+
992
+ # A disabled option (or one in a disabled <optgroup>/<select>) is not
993
+ # selectable by a user, and Capybara's shared spec expects selecting one
994
+ # to be a no-op (rack_test: `return if disabled?`). Both IDL routes —
995
+ # `sel.value =` and `option.selected = true` — select it per spec;
996
+ # Lightpanda's `value` getter used to skip disabled options and hide that,
997
+ # until upstream #3502 (build 9424).
921
998
  SELECT_OPTION_JS = <<~JS
922
999
  function() {
1000
+ if (_lightpanda.isDisabled(this)) return;
923
1001
  var sel = this.parentElement;
924
1002
  while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
925
1003
  if (!sel) {
@@ -929,14 +1007,10 @@ module Capybara
929
1007
  // that path itself; just no-op here.
930
1008
  return;
931
1009
  }
932
- if (sel.multiple) {
933
- this.selected = true;
934
- } else {
935
- // Lightpanda doesn't auto-deselect siblings when we set
936
- // `option.selected`, so mirror what a real browser does and
937
- // route the change through the parent's `value`.
938
- sel.value = this.value;
939
- }
1010
+ // Selects exactly this option — not the first one sharing its
1011
+ // value — and, in a single <select>, deselects the rest (upstream
1012
+ // #3375, guaranteed by the floor).
1013
+ this.selected = true;
940
1014
  sel.dispatchEvent(new Event('input', {bubbles: true}));
941
1015
  sel.dispatchEvent(new Event('change', {bubbles: true}));
942
1016
  }
@@ -47,13 +47,12 @@ module Capybara
47
47
  # a runaway confirm cancels the action and the spec can still pass. false
48
48
  # (default, Cuprite parity) warns on stderr; true raises
49
49
  # UnhandledModalError from the action that opened it.
50
- # load_images: passes `--load-resources image` so `<img>` elements are
51
- # fetched over the network (upstream #3230, build >= 8834). Off by
52
- # default, matching Lightpanda's default of skipping image fetches
53
- # entirely. When on, image requests ride the Network domain like any
54
- # other — they show in `network.traffic` and count toward
55
- # `wait_for_network_idle`. On builds < 8834 the flag is a fatal
56
- # UnknownOption at boot, so only enable against binaries that have it.
50
+ # load_images: adds `image` to the `--load-resources` value so `<img>`
51
+ # elements are fetched over the network (upstream #3230, build >= 8834,
52
+ # in the floor). Off by default, matching Lightpanda's default of
53
+ # skipping image fetches entirely. When on, image requests ride the
54
+ # Network domain like any other — they show in `network.traffic` and
55
+ # count toward `wait_for_network_idle`.
57
56
  attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
58
57
  :window_size, :browser_path, :headless, :logger, :save_path,
59
58
  :raise_on_unhandled_modal, :load_images