capybara-lightpanda 0.7.0 → 0.9.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.
@@ -3,25 +3,54 @@
3
3
  module Capybara
4
4
  module Lightpanda
5
5
  class Network
6
+ # Tracking is always on (create_page enables it), so the buffer needs
7
+ # the same unbounded-growth cap as Browser's console ring buffer —
8
+ # one very long session would otherwise grow @traffic indefinitely.
9
+ TRAFFIC_LIMIT = 1_000
10
+
6
11
  attr_reader :browser
7
12
 
13
+ # Status/headers of the last top-level document navigation; nil before
14
+ # the first navigation completes. Backs Browser#status_code /
15
+ # #response_headers. Captured by #subscribe's handlers.
16
+ attr_reader :last_navigation_response
17
+
8
18
  def initialize(browser)
9
19
  @browser = browser
10
20
  @traffic = []
11
21
  @traffic_mutex = Mutex.new
12
22
  @enabled = false
13
- @request_handler = nil
14
- @response_handler = nil
23
+ @request_handler = @response_handler = nil
24
+ @last_navigation_response = nil
25
+ @document_request_id = nil
15
26
  end
16
27
 
28
+ # The domain toggle is connection-scoped (browser.command), while
29
+ # setExtraHTTPHeaders is session-scoped (browser.page_command) — see
30
+ # #headers=. Browser#create_page calls this, so tracking (traffic AND
31
+ # the navigation-response capture) is on for every page.
17
32
  def enable
18
33
  return if @enabled
19
34
 
20
- browser.command("Network.enable")
35
+ # Subscribe BEFORE flipping the wire toggle (mirror image of
36
+ # #disable's ordering): events can't be emitted while the domain is
37
+ # off, so this order can never miss one. If the command fails
38
+ # (Lightpanda can block commands mid-navigation), roll the handlers
39
+ # back — orphaned duplicates would double-count every request and
40
+ # wedge pending_connections above zero for the session.
21
41
  subscribe
42
+ begin
43
+ browser.command("Network.enable")
44
+ rescue StandardError
45
+ unsubscribe
46
+ raise
47
+ end
22
48
  @enabled = true
23
49
  end
24
50
 
51
+ # Caveat: disabling the domain also stops the navigation-response
52
+ # capture, so Browser#status_code / #response_headers freeze at their
53
+ # last values until the next #enable.
25
54
  def disable
26
55
  return unless @enabled
27
56
 
@@ -51,10 +80,16 @@ module Capybara
51
80
  # cleared the Subscriber first.
52
81
  def reset
53
82
  unsubscribe
54
- @traffic_mutex.synchronize { @traffic.clear }
83
+ clear
55
84
  @enabled = false
85
+ @extra_headers = nil # the fresh context never received setExtraHTTPHeaders
86
+ @last_navigation_response = nil
87
+ @document_request_id = nil
56
88
  end
57
89
 
90
+ # Headers applied via headers= / add_headers. Backs Driver#headers.
91
+ def extra_headers = @extra_headers || {}
92
+
58
93
  # Setting extra headers also lazily enables the Network domain. Without
59
94
  # this, headers were silently ignored until the caller separately ran
60
95
  # `network.enable` (or `wait_for_network_idle`). Cuprite/Ferrum parity.
@@ -108,11 +143,30 @@ module Capybara
108
143
 
109
144
  private
110
145
 
146
+ # CDP events arrive on the message thread while wait_for_idle /
147
+ # pending_connections read from the main thread; serialize all
148
+ # @traffic mutations and reads through @traffic_mutex.
149
+ #
150
+ # The handlers also capture the last top-level navigation response
151
+ # (mirrors capybara-playwright-driver's navigation_request? hook). CDP
152
+ # normally marks the main-document response via
153
+ # `Network.responseReceived.type`, but Lightpanda omits that field on
154
+ # responses (only emits `type` on requestWillBeSent) — so match the
155
+ # long way: remember the document requestId, store the response whose
156
+ # requestId equals it.
111
157
  def subscribe
112
- # CDP events arrive on the message thread while wait_for_idle /
113
- # pending_connections read from the main thread; serialize all
114
- # @traffic mutations and reads through @traffic_mutex.
115
- @request_handler = lambda do |params|
158
+ @request_handler = build_request_handler
159
+ @response_handler = build_response_handler
160
+ browser.on("Network.requestWillBeSent", &@request_handler)
161
+ browser.on("Network.responseReceived", &@response_handler)
162
+ end
163
+
164
+ def build_request_handler
165
+ lambda do |params|
166
+ if params["type"] == "Document"
167
+ @document_request_id = params["requestId"]
168
+ @last_navigation_response = nil
169
+ end
116
170
  entry = {
117
171
  request_id: params["requestId"],
118
172
  url: params.dig("request", "url"),
@@ -120,10 +174,21 @@ module Capybara
120
174
  timestamp: params["timestamp"],
121
175
  response: nil,
122
176
  }
123
- @traffic_mutex.synchronize { @traffic << entry }
177
+ @traffic_mutex.synchronize do
178
+ @traffic << entry
179
+ @traffic.shift(@traffic.size - TRAFFIC_LIMIT) if @traffic.size > TRAFFIC_LIMIT
180
+ end
124
181
  end
182
+ end
125
183
 
126
- @response_handler = lambda do |params|
184
+ def build_response_handler
185
+ lambda do |params|
186
+ if params["requestId"] == @document_request_id
187
+ @last_navigation_response = {
188
+ status: params.dig("response", "status"),
189
+ headers: params.dig("response", "headers") || {},
190
+ }
191
+ end
127
192
  @traffic_mutex.synchronize do
128
193
  request = @traffic.find { |t| t[:request_id] == params["requestId"] }
129
194
  next unless request
@@ -135,9 +200,6 @@ module Capybara
135
200
  }
136
201
  end
137
202
  end
138
-
139
- browser.on("Network.requestWillBeSent", &@request_handler)
140
- browser.on("Network.responseReceived", &@response_handler)
141
203
  end
142
204
 
143
205
  def unsubscribe
@@ -69,14 +69,12 @@ module Capybara
69
69
  previous
70
70
  end
71
71
 
72
+ # Routed through #call (not a bare call_function_on) so a detached
73
+ # host raises ObsoleteNode like every other node operation — Capybara's
74
+ # automatic_reload then re-finds the host instead of silently reading
75
+ # a stale shadowRoot.
72
76
  def shadow_root
73
- result = driver.browser.with_default_context_wait do
74
- driver.browser.call_function_on(
75
- @remote_object_id,
76
- "function() { return this.shadowRoot }",
77
- return_by_value: false
78
- )
79
- end
77
+ result = call(SHADOW_ROOT_JS, return_by_value: false)
80
78
  return nil unless result.is_a?(Hash) && result["objectId"]
81
79
 
82
80
  self.class.new(driver, result["objectId"])
@@ -171,15 +169,9 @@ module Capybara
171
169
  end
172
170
 
173
171
  def unselect_option
174
- unless call("function() {
175
- var s = this.parentElement;
176
- while (s && (s.tagName || '').toUpperCase() !== 'SELECT') s = s.parentElement;
177
- return !!(s && s.multiple);
178
- }")
179
- raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
180
- end
172
+ return unless call(UNSELECT_OPTION_JS) == "not_multiple"
181
173
 
182
- call(UNSELECT_OPTION_JS)
174
+ raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
183
175
  end
184
176
 
185
177
  def send_keys(*)
@@ -437,22 +429,18 @@ module Capybara
437
429
  # a DOM mutation like `replaceWith`, the cached objectId still resolves
438
430
  # to the detached node, so reads succeed quietly and Capybara's
439
431
  # automatic_reload never re-runs the original query.
440
- def call(function_declaration, *args)
432
+ def call(function_declaration, *args, return_by_value: true)
441
433
  guarded = wrap_with_attached_guard(function_declaration)
442
434
  driver.browser.with_default_context_wait do
443
- driver.browser.call_function_on(@remote_object_id, guarded, *args)
435
+ driver.browser.call_function_on(@remote_object_id, guarded, *args,
436
+ return_by_value: return_by_value)
444
437
  end
445
438
  rescue JavaScriptError => e
446
439
  if e.message.include?(OBSOLETE_NODE_MARKER)
447
440
  raise ObsoleteNode.new(self, "Node is no longer attached to the document")
448
441
  end
449
442
 
450
- case e.class_name
451
- when "InvalidSelector"
452
- raise InvalidSelector.new(e.message, nil, args.first)
453
- else
454
- raise
455
- end
443
+ raise
456
444
  end
457
445
 
458
446
  OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
@@ -485,6 +473,13 @@ module Capybara
485
473
  CLICK_JS = <<~JS
486
474
  function() {
487
475
  var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
476
+ // Real pointer clicks are a mousedown -> mouseup -> click sequence,
477
+ // and widgets like select2 open on `mousedown`, not `click`.
478
+ // Cancelling mousedown suppresses focus/text-selection in a real
479
+ // browser but never the click, so the click below is dispatched
480
+ // unconditionally.
481
+ this.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
482
+ this.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
488
483
  var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
489
484
  var notCancelled = this.dispatchEvent(clickEvt);
490
485
  if (!notCancelled || clickEvt.defaultPrevented) return;
@@ -658,11 +653,14 @@ module Capybara
658
653
  }
659
654
  JS
660
655
 
656
+ # Returns 'not_multiple' (without mutating) when the owning <select>
657
+ # isn't multiple — Ruby raises Capybara::UnselectNotAllowed. One CDP
658
+ # round-trip instead of a separate ancestor-walk precheck.
661
659
  UNSELECT_OPTION_JS = <<~JS
662
660
  function() {
663
661
  var sel = this.parentElement;
664
662
  while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
665
- if (!sel || !sel.multiple) return;
663
+ if (!sel || !sel.multiple) return 'not_multiple';
666
664
  this.selected = false;
667
665
  sel.dispatchEvent(new Event('input', {bubbles: true}));
668
666
  sel.dispatchEvent(new Event('change', {bubbles: true}));
@@ -675,13 +673,7 @@ module Capybara
675
673
  }
676
674
  JS
677
675
 
678
- APPEND_KEYS_JS = <<~JS
679
- function(key) {
680
- this.focus();
681
- this.value += key;
682
- this.dispatchEvent(new Event('input', {bubbles: true}));
683
- }
684
- JS
676
+ SHADOW_ROOT_JS = "function() { return this.shadowRoot }"
685
677
 
686
678
  EDITABLE_HOST_JS = "function() { return _lightpanda.isContentEditable(this); }"
687
679
 
@@ -731,6 +723,14 @@ module Capybara
731
723
  return path.join(' > ');
732
724
  }
733
725
  JS
726
+
727
+ # Internal wire format, not API — aligned with browser.rb's convention
728
+ # of private_constant for its JS snippets.
729
+ private_constant :CLICK_JS, :TRIGGER_JS, :DROP_JS, :SHADOW_ROOT_JS, :VISIBLE_JS, :VISIBLE_TEXT_JS,
730
+ :PROPERTY_OR_ATTRIBUTE_JS, :GET_VALUE_JS, :SET_VALUE_JS,
731
+ :IMPLICIT_SUBMIT_JS, :SELECT_OPTION_JS, :UNSELECT_OPTION_JS,
732
+ :SET_CHECKBOX_JS, :EDITABLE_HOST_JS, :DISABLED_JS, :GET_STYLE_JS,
733
+ :GET_RECT_JS, :OBSCURED_JS, :GET_PATH_JS
734
734
  end
735
735
  end
736
736
  end
@@ -22,6 +22,10 @@ module Capybara
22
22
  DEFAULT_PORT = 0
23
23
  DEFAULT_WINDOW_SIZE = [1024, 768].freeze
24
24
 
25
+ # window_size and headless are accepted for Cuprite drop-in
26
+ # compatibility (standard options at driver registration) but are
27
+ # inert: Lightpanda has no rendering engine, so there is nothing to
28
+ # resize, and headless is the only mode it runs in.
25
29
  attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
26
30
  :window_size, :browser_path, :headless, :logger
27
31
  attr_writer :ws_url
@@ -90,10 +90,16 @@ module Capybara
90
90
  # DragEvent, merged 2026-06-10) provides the APIs Node#drop's DROP_JS
91
91
  # assembles its payload from; on builds without it the drop JS raises
92
92
  # "DataTransfer is not defined".
93
- # Build 6699 = the #2671 merge (d1f4c409, 2026-06-10) — now the binding
94
- # floor. (The prior 6672 file-upload floor — and 6353 before it — are
95
- # subsumed.)
96
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6699")
93
+ # Build 6699 = the #2671 merge (d1f4c409, 2026-06-10) — the Node#drop
94
+ # DataTransfer floor. (The prior 6672 file-upload floor — and 6353
95
+ # before it — are subsumed.)
96
+ # PR #2708 (document fires readystatechange on readiness changes,
97
+ # merged 2026-06-12) made the index.js readystatechange re-dispatch
98
+ # shim redundant, so it was removed — on builds without #2708 Turbo's
99
+ # PageObserver never reaches pageLoaded() and turbo:load never fires.
100
+ # Build 6736 = first published nightly carrying the #2708 merge — now
101
+ # the binding floor.
102
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6736")
97
103
 
98
104
  attr_reader :pid, :ws_url, :version, :nightly_build
99
105
 
@@ -117,9 +123,7 @@ module Capybara
117
123
 
118
124
  check_minimum_version(binary_path)
119
125
  attempt_start(binary_path)
120
- rescue ProcessTimeoutError => e
121
- raise unless e.message.include?("already in use")
122
-
126
+ rescue PortInUseError
123
127
  kill_process_on_port(@options.port)
124
128
  attempt_start(binary_path)
125
129
  end
@@ -251,8 +255,8 @@ module Capybara
251
255
  end
252
256
 
253
257
  if output.match?(ADDRESS_IN_USE_PATTERN)
254
- cleanup_failed_process
255
- raise ProcessTimeoutError,
258
+ stop
259
+ raise PortInUseError,
256
260
  "Lightpanda failed to start: port #{@options.port} is already in use"
257
261
  end
258
262
  rescue IO::WaitReadable
@@ -269,19 +273,6 @@ module Capybara
269
273
  end
270
274
  end
271
275
 
272
- def cleanup_failed_process
273
- return unless @pid
274
-
275
- begin
276
- ::Process.wait(@pid, ::Process::WNOHANG)
277
- rescue Errno::ECHILD
278
- nil
279
- end
280
-
281
- cleanup_pipes
282
- @pid = nil
283
- end
284
-
285
276
  # Auto-recover when a previous Lightpanda is still bound to our port.
286
277
  # Best-effort: relies on `lsof` to map port → pid (macOS / most Linux
287
278
  # distros). Where `lsof` isn't on PATH, we surface a clear error rather
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ # Exposes the gem's rake tasks (lightpanda:binary:*) inside Rails apps.
6
+ # Without this, the BinaryError remediation hint ("bundle exec rake
7
+ # lightpanda:binary:remove lightpanda:binary:update") pointed at tasks
8
+ # that didn't exist in the app's rake namespace.
9
+ class Railtie < Rails::Railtie
10
+ rake_tasks do
11
+ load File.expand_path("tasks/binary.rake", __dir__)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.7.0"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
@@ -21,6 +21,7 @@ require_relative "capybara/lightpanda/auto_scripts"
21
21
  require_relative "capybara/lightpanda/node"
22
22
  require_relative "capybara/lightpanda/element_extension"
23
23
  require_relative "capybara/lightpanda/driver"
24
+ require_relative "capybara/lightpanda/railtie" if defined?(Rails::Railtie)
24
25
 
25
26
  module Capybara
26
27
  module Lightpanda
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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad
@@ -74,6 +74,11 @@ files:
74
74
  - lib/capybara/lightpanda/auto_scripts.rb
75
75
  - lib/capybara/lightpanda/binary.rb
76
76
  - lib/capybara/lightpanda/browser.rb
77
+ - lib/capybara/lightpanda/browser/console.rb
78
+ - lib/capybara/lightpanda/browser/finder.rb
79
+ - lib/capybara/lightpanda/browser/modals.rb
80
+ - lib/capybara/lightpanda/browser/navigation.rb
81
+ - lib/capybara/lightpanda/browser/runtime.rb
77
82
  - lib/capybara/lightpanda/client.rb
78
83
  - lib/capybara/lightpanda/client/subscriber.rb
79
84
  - lib/capybara/lightpanda/client/web_socket.rb
@@ -82,13 +87,17 @@ files:
82
87
  - lib/capybara/lightpanda/element_extension.rb
83
88
  - lib/capybara/lightpanda/errors.rb
84
89
  - lib/capybara/lightpanda/headers.rb
85
- - lib/capybara/lightpanda/javascripts/index.js
90
+ - lib/capybara/lightpanda/javascripts/attach.js
91
+ - lib/capybara/lightpanda/javascripts/banner.js
92
+ - lib/capybara/lightpanda/javascripts/predicates.js
93
+ - lib/capybara/lightpanda/javascripts/turbo.js
86
94
  - lib/capybara/lightpanda/keyboard.rb
87
95
  - lib/capybara/lightpanda/logger.rb
88
96
  - lib/capybara/lightpanda/network.rb
89
97
  - lib/capybara/lightpanda/node.rb
90
98
  - lib/capybara/lightpanda/options.rb
91
99
  - lib/capybara/lightpanda/process.rb
100
+ - lib/capybara/lightpanda/railtie.rb
92
101
  - lib/capybara/lightpanda/tasks/binary.rake
93
102
  - lib/capybara/lightpanda/utils/attempt.rb
94
103
  - lib/capybara/lightpanda/utils/event.rb
@@ -1,239 +0,0 @@
1
- (function() {
2
- if (window._lightpanda) return;
3
-
4
- // --- readystatechange re-dispatch (upstream gap, wishlist A36) ---
5
- // Lightpanda transitions document.readyState correctly but never fires the
6
- // readystatechange event. Turbo's PageObserver listens ONLY for that event
7
- // to reach pageLoaded() — without it, turbo:load never fires. Re-dispatch
8
- // from the two lifecycle events Lightpanda does fire: DOMContentLoaded
9
- // (readyState=interactive) and window load (readyState=complete).
10
- // Drop this block once the upstream fix is covered by MINIMUM_NIGHTLY_BUILD.
11
- function _fireReadyStateChange() {
12
- document.dispatchEvent(new Event('readystatechange'));
13
- }
14
- document.addEventListener('DOMContentLoaded', _fireReadyStateChange);
15
- window.addEventListener('load', _fireReadyStateChange);
16
-
17
- // --- Turbo activity tracking ---
18
- // Tracks pending Turbo operations so the driver can wait for Turbo to settle.
19
- // Inspired by the CapybaraLockstep approach for stabilizing Turbo integration tests.
20
- // Events are not perfectly symmetrical in Turbo, so we track multiple pairs
21
- // and use a counter to handle overlapping operations.
22
- //
23
- // Transitions across 0 emit `__lightpanda_turbo_busy` / `__lightpanda_turbo_idle`
24
- // sentinels via console.debug. Browser#wait_for_turbo subscribes to those
25
- // sentinels (Runtime.consoleAPICalled) and toggles a Concurrent::Event so the
26
- // Ruby side can wait event-driven instead of polling.
27
- //
28
- // Pages without Turbo never trigger _turboStart, so no sentinels fire and the
29
- // Ruby Event stays set (idle by default) — wait_for_turbo returns immediately.
30
- var _pendingTurboOps = 0;
31
- function _signalTurbo(state) {
32
- try { console.debug('__lightpanda_turbo_' + state); } catch (e) {}
33
- }
34
- function _turboStart() {
35
- _pendingTurboOps++;
36
- if (_pendingTurboOps === 1) _signalTurbo('busy');
37
- }
38
- function _turboEnd() {
39
- if (_pendingTurboOps > 0) {
40
- _pendingTurboOps--;
41
- if (_pendingTurboOps === 0) _signalTurbo('idle');
42
- }
43
- }
44
-
45
- // Fetch requests (covers Drive, Frames, and Form submission fetches)
46
- document.addEventListener('turbo:before-fetch-request', _turboStart);
47
- document.addEventListener('turbo:before-fetch-response', _turboEnd);
48
- document.addEventListener('turbo:fetch-request-error', _turboEnd);
49
-
50
- // Form submissions (can outlast their underlying fetch)
51
- document.addEventListener('turbo:submit-start', _turboStart);
52
- document.addEventListener('turbo:submit-end', _turboEnd);
53
-
54
- // Frame rendering (can outlast the fetch that triggered it)
55
- document.addEventListener('turbo:before-frame-render', _turboStart);
56
- document.addEventListener('turbo:frame-render', _turboEnd);
57
-
58
- // Stream rendering (no symmetric end event — wrap the render function)
59
- document.addEventListener('turbo:before-stream-render', function(event) {
60
- _turboStart();
61
- if (event.detail && event.detail.render) {
62
- var originalRender = event.detail.render;
63
- event.detail.render = function(streamElement) {
64
- var result = originalRender(streamElement);
65
- if (result && typeof result.then === 'function') {
66
- return result.finally(_turboEnd);
67
- }
68
- _turboEnd();
69
- return result;
70
- };
71
- } else {
72
- _turboEnd();
73
- }
74
- });
75
-
76
- // Drive page visits: turbo:load fires after the page is fully rendered.
77
- // Also serves as a safety reset — clears any counter leaks from aborted fetches.
78
- // Always re-signal idle so the Ruby Event re-arms even if some `_turboEnd`
79
- // call dropped on the floor mid-navigation.
80
- document.addEventListener('turbo:load', function() {
81
- _pendingTurboOps = 0;
82
- _signalTurbo('idle');
83
- });
84
-
85
- // --- Main API ---
86
-
87
- window._lightpanda = {
88
- turbo: {
89
- pending: function() { return _pendingTurboOps; },
90
- idle: function() { return _pendingTurboOps <= 0; }
91
- },
92
-
93
- // --- DOM visibility / state predicates ---
94
- // Centralized so node.rb's predicate methods (visible?, obscured?, disabled?)
95
- // and the visible_text walker share one implementation of the ancestor
96
- // cascade. Available in iframes too because index.js is registered via
97
- // Page.addScriptToEvaluateOnNewDocument.
98
-
99
- // Returns true if `el` is visible per Capybara's semantics. Lightpanda's
100
- // `checkVisibility()` walks ancestors and rejects `display:none` from
101
- // inline styles, stylesheets, and the UA sheet (PR #2294 — covers
102
- // HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
103
- // closed-<details> children). `visibility:hidden|collapse` is handled
104
- // separately because checkVisibility's defaults (per spec) ignore it.
105
- //
106
- // Intentional upstream out-of-scope (upstream-wishlist.md C10):
107
- // Lightpanda is a headless agentic browser and deliberately doesn't
108
- // load external `<link rel=stylesheet>` or apply `@media` rules to
109
- // the cascade, and `matchMedia()` returns false for every query.
110
- // Responsive patterns that hide one of two mobile/desktop CTA
111
- // duplicates via `@media (min-width: …) { display: none }` leak
112
- // both variants past this check — Capybara then raises
113
- // `Ambiguous: found 2 elements`. There is no in-gem fix: rebuilding
114
- // the CSS cascade in JS would need a CSS parser, sync access to
115
- // remote stylesheets, and a real media-query evaluator. Run
116
- // cuprite for responsive-UI assertions.
117
- isVisible: function(el) {
118
- if (!el || el.nodeType !== 1) return false;
119
- var win = el.ownerDocument.defaultView || window;
120
- var style = win.getComputedStyle(el);
121
- if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
122
- return el.checkVisibility();
123
- },
124
-
125
- // Returns true if the element is obscured at its center point — i.e.
126
- // hit-testing elementFromPoint at the center returns something that is
127
- // not the element or its descendant. `visibility:hidden|collapse` is
128
- // short-circuited explicitly because those elements still produce a
129
- // layout box (so the rect-zero check below can't catch them); every
130
- // other "not rendered" case (display:none, [hidden], descendants of
131
- // either) falls out naturally because getBoundingClientRect returns
132
- // DOMRect{0,0,0,0} for elements with no layout box.
133
- isObscured: function(el) {
134
- var doc = el.ownerDocument;
135
- var win = doc.defaultView || window;
136
- var style = win.getComputedStyle(el);
137
- if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
138
- var r = el.getBoundingClientRect();
139
- if (r.width === 0 || r.height === 0) return true;
140
- var cx = r.left + (r.width / 2);
141
- var cy = r.top + (r.height / 2);
142
- var w = win.innerWidth || doc.documentElement.clientWidth;
143
- var h = win.innerHeight || doc.documentElement.clientHeight;
144
- if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
145
- var hit = doc.elementFromPoint(cx, cy);
146
- if (!hit) return true;
147
- if (hit === el) return false;
148
- return !el.contains(hit);
149
- },
150
-
151
- // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
152
- // an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
153
- // is reported as disabled, even though those don't match CSS `:disabled`
154
- // per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
155
- isDisabled: function(el) {
156
- if (el.matches && el.matches(':disabled')) return true;
157
- if ((el.tagName || '').toUpperCase() === 'OPTION') {
158
- var p = el.parentElement;
159
- while (p) {
160
- if (p.disabled) return true;
161
- p = p.parentElement;
162
- }
163
- }
164
- return false;
165
- },
166
-
167
- // True if the element is the host of a contenteditable region: it (or any
168
- // ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
169
- // native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
170
- // `false` for every element — it has no caret/keyboard editing pipeline —
171
- // so the IDL property is useless here and we walk ancestors ourselves.
172
- isContentEditable: function(el) {
173
- var n = el;
174
- while (n && n.nodeType === 1) {
175
- if (n.hasAttribute && n.hasAttribute('contenteditable')) {
176
- var v = (n.getAttribute('contenteditable') || '').toLowerCase();
177
- return v !== 'false';
178
- }
179
- n = n.parentElement;
180
- }
181
- return false;
182
- },
183
-
184
- // Walk descendants and accumulate text from visible nodes only. Inserts
185
- // newlines around block-display containers so paragraphs/lists render with
186
- // natural breaks (Capybara expects this from Chrome's innerText).
187
- // Lightpanda's innerText returns textContent verbatim (no rendering, so no
188
- // hidden-descendant filtering), so we DIY the visibility filtering here.
189
- visibleText: function(el) {
190
- var BLOCK_DISP = { BLOCK:1, FLEX:1, GRID:1, 'LIST-ITEM':1, TABLE:1, 'TABLE-ROW':1,
191
- 'TABLE-CAPTION':1, 'TABLE-CELL':1 };
192
- var BLOCK_TAG = { ADDRESS:1, ARTICLE:1, ASIDE:1, BLOCKQUOTE:1, DETAILS:1, DIALOG:1,
193
- DIV:1, DL:1, DT:1, DD:1, FIELDSET:1, FIGCAPTION:1, FIGURE:1,
194
- FOOTER:1, FORM:1, H1:1, H2:1, H3:1, H4:1, H5:1, H6:1, HEADER:1,
195
- HGROUP:1, HR:1, LI:1, MAIN:1, NAV:1, OL:1, P:1, PRE:1, SECTION:1,
196
- TABLE:1, TR:1, UL:1 };
197
- var self = this;
198
-
199
- // Collapse runs of ASCII whitespace (preserving NBSP) to a single space —
200
- // matches Chrome's innerText whitespace handling for text nodes.
201
- function normText(s) {
202
- return s.replace(/[\t\n\r\f\v ]+/g, ' ');
203
- }
204
-
205
- function walk(node) {
206
- if (node.nodeType === 3) return normText(node.nodeValue);
207
- // DocumentFragment / ShadowRoot — no element of its own to test
208
- // for visibility, just walk children.
209
- if (node.nodeType === 11) {
210
- var fout = '';
211
- for (var k = 0; k < node.childNodes.length; k++) fout += walk(node.childNodes[k]);
212
- return fout;
213
- }
214
- if (node.nodeType !== 1) return '';
215
- if (!self.isVisible(node)) return '';
216
- var tag = (node.tagName || '').toUpperCase();
217
- if (tag === 'TEXTAREA') return node.value || '';
218
- if (tag === 'BR') return '\n';
219
- var win = node.ownerDocument.defaultView || window;
220
- var style = win.getComputedStyle(node);
221
- var disp = (style.display || '').toUpperCase();
222
- var isBlock = BLOCK_DISP[disp] || BLOCK_TAG[tag];
223
- var out = '';
224
- for (var i = 0; i < node.childNodes.length; i++) {
225
- out += walk(node.childNodes[i]);
226
- }
227
- // Block-level elements get wrapped in \n…\n only when they actually
228
- // contribute visible text. An empty <div> between two inline siblings
229
- // would otherwise introduce a phantom line break that Chrome's
230
- // innerText algorithm collapses out (required line breaks around
231
- // empty blocks coalesce in the line-collapse pass).
232
- if (isBlock && /\S/.test(out)) out = '\n' + out + '\n';
233
- return out;
234
- }
235
-
236
- return walk(el);
237
- }
238
- };
239
- })();