capybara-lightpanda 0.8.0 → 0.10.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +14 -1
  4. data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
  5. data/lib/capybara/lightpanda/binary.rb +72 -54
  6. data/lib/capybara/lightpanda/browser/console.rb +190 -0
  7. data/lib/capybara/lightpanda/browser/finder.rb +196 -0
  8. data/lib/capybara/lightpanda/browser/modals.rb +150 -0
  9. data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
  10. data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
  11. data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
  12. data/lib/capybara/lightpanda/browser.rb +158 -813
  13. data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
  14. data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
  15. data/lib/capybara/lightpanda/client.rb +5 -4
  16. data/lib/capybara/lightpanda/downloads.rb +176 -0
  17. data/lib/capybara/lightpanda/driver.rb +124 -32
  18. data/lib/capybara/lightpanda/errors.rb +24 -10
  19. data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
  20. data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
  21. data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
  22. data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
  23. data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
  24. data/lib/capybara/lightpanda/keyboard.rb +23 -19
  25. data/lib/capybara/lightpanda/network.rb +103 -18
  26. data/lib/capybara/lightpanda/node.rb +200 -95
  27. data/lib/capybara/lightpanda/options.rb +44 -3
  28. data/lib/capybara/lightpanda/process.rb +154 -29
  29. data/lib/capybara/lightpanda/version.rb +1 -1
  30. data/lib/capybara-lightpanda.rb +1 -0
  31. metadata +14 -3
  32. data/lib/capybara/lightpanda/javascripts/index.js +0 -226
@@ -97,11 +97,120 @@ module Capybara
97
97
  # merged 2026-06-12) made the index.js readystatechange re-dispatch
98
98
  # shim redundant, so it was removed — on builds without #2708 Turbo's
99
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")
100
+ # Build 6736 carried the #2708 merge.
101
+ # PR #2722 (Browser.setDownloadBehavior streams Content-Disposition:
102
+ # attachment responses to disk under downloadPath + emits
103
+ # Browser.downloadWillBegin / downloadProgress, merged 2026-06-19) backs
104
+ # the Downloads tracker (downloads.rb) wired in Browser#create_page. On
105
+ # builds < 7545 setDownloadBehavior is a bare success no-op — every
106
+ # download would silently never be written to disk — so the floor MUST
107
+ # include it. Note Lightpanda triggers downloads on Content-Disposition:
108
+ # attachment, NOT by MIME type (a text/csv response without that header
109
+ # is rendered as a normal navigation), which is why Capybara's
110
+ # MIME-triggered :download shared spec stays in capybara_skip.
111
+ # Build 7545 = the #2722 merge (a808386c).
112
+ # PR #2785 (build 7550, improve innerText + outerText) AND PR #2795
113
+ # (build 7571, innerText uses StyleManager visibility) make native
114
+ # Element.innerText implement the HTML rendered-text collection steps —
115
+ # block line breaks + skipping display:none descendants. The
116
+ # _lightpanda.visibleText predicate (predicates.js) now delegates the
117
+ # rendered-text collection to native innerText (keeping only a visibility
118
+ # gate + ShadowRoot fragment walk), so the floor MUST include #2795; on
119
+ # builds < 7571 innerText returns textContent verbatim (no block breaks,
120
+ # leaks display:none text).
121
+ # Build 7571 = the #2795 merge (48ed689c).
122
+ # PR #2719 (css: apply @layer block rules to the cascade, merged
123
+ # 2026-07-17) makes StyleManager rank `@layer` rules correctly. Before it,
124
+ # a rule inside an `@layer` block was dropped from the cascade entirely,
125
+ # so an element hidden by an `@layer` `display: none` — the default shape
126
+ # of Tailwind v4's generated CSS — reported `checkVisibility() === true`.
127
+ # `_lightpanda.isVisible` (predicates.js) terminates in checkVisibility(),
128
+ # so on builds < 8160 Capybara believes such elements are visible and acts
129
+ # on them: clicks land on hidden nodes and `assert_no_selector` passes for
130
+ # visible ones. That is a wrong-answer bug rather than an exception, which
131
+ # is exactly the kind users can't diagnose, so the floor MUST include it.
132
+ # Build 8160 = the #2719 merge (b19b5725).
133
+ # PR #2983 (css: respect @layer priority, merged 2026-07-24) is the second
134
+ # half of that same fix and the reason 8160 is NOT sufficient. #2719 only
135
+ # made rules inside an `@layer` block *participate* in the cascade; it
136
+ # ranked them by specificity + document order alone. #2983 widened
137
+ # VisibilityRule.priority to carry a 12-bit layer rank, so layer order
138
+ # finally beats specificity as the spec requires. On builds 8160–8280 a
139
+ # page whose layers disagree about `display` resolves to the wrong rule —
140
+ # Tailwind v4's `@layer utilities` losing to `@layer base` is the common
141
+ # shape — and `_lightpanda.isVisible` (predicates.js) terminates in
142
+ # checkVisibility(), so Capybara again believes hidden elements are
143
+ # visible. Same undiagnosable wrong-answer class as #2719 itself, so the
144
+ # floor MUST include it.
145
+ # Build 8281 = the #2983 merge (76e2e7e6).
146
+ # PR #3015 (cdp: sanitize non-UTF-8 values, merged 2026-07-24) fixes
147
+ # issue #2992: a legacy-encoded Content-Disposition filename made
148
+ # Browser.downloadWillBegin emit `suggestedFilename` as a JSON array of
149
+ # bytes instead of a string. Downloads#build_will_handler does
150
+ # File.basename(params["suggestedFilename"].to_s), so below this build it
151
+ # records a "[130, 160, ...]" basename and Driver#downloads hands back a
152
+ # path that does not exist.
153
+ # Build 8283 = the #3015 merge (8fbcc7e5).
154
+ # PR #3054 (forms: close the ancestor dialog on method=dialog submission,
155
+ # merged 2026-07-25) makes `<form method="dialog">` close its nearest
156
+ # ancestor <dialog>, set returnValue from the submitter's IDL value, fire
157
+ # close, and perform no navigation. Below it the submission falls through
158
+ # to a GET navigation and the dialog stays open forever — which is the
159
+ # <dialog>+Turbo-confirm idiom Spree 5's admin uses for every destroy
160
+ # confirmation. There is no gem-side workaround to guard this: the only
161
+ # defense is the floor.
162
+ # Build 8311 = the #3054 merge (c917cadc).
163
+ # PR #3058 (forms: include optgroup children in a select's list of
164
+ # options, merged 2026-07-26) makes `<option>`s inside an `<optgroup>`
165
+ # reachable through HTMLSelectElement (options / value / selectedIndex /
166
+ # submission). Below it Capybara's `select` cannot find a grouped option
167
+ # at all and there is no gem-side workaround — a plain ElementNotFound on
168
+ # any grouped <select>. Build 8328 = the #3058 merge (c7182354).
169
+ # PR #3080 (webapi: increase max timer count, merged 2026-07-29) lifts the
170
+ # one-shot setTimeout cap 512 → 2048 (Airbnb-class pages schedule 600+);
171
+ # below it timers past the cap are silently dropped and page JS stalls in
172
+ # ways that read as Capybara timeouts. Build 8412 = the #3080 merge.
173
+ # PR #3081 + #3082/#3085 (script load/error events; dynamic scripts whose
174
+ # `src` is set via setAttribute, merged 2026-07-30) — below them an
175
+ # inline script fired no load event, a throwing script still fired
176
+ # `load`, and a setAttribute('src') script never loaded, which breaks
177
+ # loader idioms (importmap shims, lazy widget bootstraps) that gate the
178
+ # UI Capybara waits for. Builds 8414/8419.
179
+ # PR #3087 (fix: potential UAF when an option's value is programmatically
180
+ # set, merged 2026-07-30) — a use-after-free in exactly the path
181
+ # `Node#select_option`/`Node#set` on a <select> drives, i.e. a browser
182
+ # crash surfacing as DeadBrowserError mid-spec.
183
+ # Build 8448 = the #3087 merge (6d824c88) — now the binding floor.
184
+ # Subsumed by 8448, recorded so they are not re-derived: build 8298 made
185
+ # Network.enable idempotent (the gem never hit it — the Notification is
186
+ # per-BrowserContext and Network#enable's @enabled guard means one enable
187
+ # per context), and build 8305 (#3048) derives scrollWidth/scrollHeight
188
+ # from element content (the gem keeps scroll as a no-op either way).
189
+ # PR #2664 (Emulation.setDeviceMetricsOverride, build 7556) is subsumed:
190
+ # Browser#set_viewport calls it on every create_page to honor the
191
+ # `window_size` option. Note the override only reached
192
+ # Page.getLayoutMetrics later (~build 8300); the gem does not depend on
193
+ # that half, so it is NOT part of the floor.
194
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("8448")
195
+
196
+ # Second, equivalent floor for the *release* channel.
197
+ #
198
+ # Tagged releases are built with `-Dversion=<tag>`, which resolves to a
199
+ # plain semver with no pre-release tag — so `lightpanda version` prints a
200
+ # bare "0.3.6" carrying no commit counter at all, and MINIMUM_NIGHTLY_BUILD
201
+ # has nothing to compare against. Releases are cut from the same trunk, so
202
+ # a release is acceptable exactly when its own commit count clears the
203
+ # nightly floor: 0.3.7 is build 8671, well past the #3087 merge (8448)
204
+ # this floor exists for. (0.3.6 is only 8318 — it predates the optgroup
205
+ # fix and everything after it, which is why this pin had to move.)
206
+ #
207
+ # INVARIANT: every MINIMUM_NIGHTLY_BUILD bump must also move this to the
208
+ # first release containing that build (`git rev-list --count <tag>` in the
209
+ # browser repo tells you). Leaving it behind would let the release channel
210
+ # silently accept a binary the nightly channel rejects.
211
+ MINIMUM_RELEASE = Gem::Version.new("0.3.7")
103
212
 
104
- attr_reader :pid, :ws_url, :version, :nightly_build
213
+ attr_reader :pid, :ws_url, :version, :nightly_build, :release
105
214
 
106
215
  def initialize(options)
107
216
  @options = options
@@ -109,6 +218,7 @@ module Capybara
109
218
  @ws_url = nil
110
219
  @version = nil
111
220
  @nightly_build = nil
221
+ @release = nil
112
222
  @stdout_r = nil
113
223
  @stdout_w = nil
114
224
  @stderr_r = nil
@@ -123,9 +233,7 @@ module Capybara
123
233
 
124
234
  check_minimum_version(binary_path)
125
235
  attempt_start(binary_path)
126
- rescue ProcessTimeoutError => e
127
- raise unless e.message.include?("already in use")
128
-
236
+ rescue PortInUseError
129
237
  kill_process_on_port(@options.port)
130
238
  attempt_start(binary_path)
131
239
  end
@@ -149,20 +257,37 @@ module Capybara
149
257
 
150
258
  private
151
259
 
260
+ # `lightpanda version` prints one of two shapes, and the gem supports both
261
+ # so a suite can either track nightly or pin a reproducible release:
262
+ #
263
+ # "1.0.0-nightly.8285+de85a51d" rolling nightly (also "dev.NNNN" for a
264
+ # locally compiled tree — same
265
+ # `git rev-list --count HEAD` counter,
266
+ # different label). Checked against
267
+ # MINIMUM_NIGHTLY_BUILD.
268
+ # "0.3.5" a tagged release: bare semver, no build
269
+ # metadata. Checked against MINIMUM_RELEASE.
270
+ #
271
+ # The release form is matched anchored — a bare semver and nothing else —
272
+ # so a stray "1.2.3" inside some other string can never be mistaken for a
273
+ # release. Anything that matches neither shape stays a hard failure: a
274
+ # binary we cannot identify is never assumed to be new enough.
152
275
  def check_minimum_version(binary_path)
153
276
  stdout, = Open3.capture3(binary_path, "version")
154
277
  @version = stdout.strip
155
- # Accept either `nightly.NNNN` (publicly distributed builds) or
156
- # `dev.NNNN` (locally compiled trees) — the build number is the same
157
- # `git rev-list --count HEAD` counter, just labelled differently.
158
- build = @version[/(?:nightly|dev)\.(\d+)/, 1]
159
- @nightly_build = Gem::Version.new(build) if build
160
278
 
161
- return if @nightly_build && @nightly_build >= MINIMUM_NIGHTLY_BUILD
279
+ if (build = @version[/(?:nightly|dev)\.(\d+)/, 1])
280
+ @nightly_build = Gem::Version.new(build)
281
+ return if @nightly_build >= MINIMUM_NIGHTLY_BUILD
282
+ elsif (tag = @version[/\A\d+\.\d+\.\d+\z/])
283
+ @release = Gem::Version.new(tag)
284
+ return if @release >= MINIMUM_RELEASE
285
+ end
162
286
 
163
287
  raise BinaryError,
164
288
  "Lightpanda #{@version} is too old. " \
165
- "This gem requires build >= #{MINIMUM_NIGHTLY_BUILD}. " \
289
+ "This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
290
+ "or release >= #{MINIMUM_RELEASE}. " \
166
291
  "Update: #{Binary.update_hint(binary_path)}"
167
292
  rescue Errno::ENOENT
168
293
  # Binary not runnable — let attempt_start handle it
@@ -232,6 +357,19 @@ module Capybara
232
357
  # floor). Always on so linked CSS contributes to checkVisibility /
233
358
  # getComputedStyle; see .claude/rules/lightpanda-io.md limitation #6.
234
359
  "--enable-external-stylesheets",
360
+ # Raise the inbound CDP WebSocket message cap from Lightpanda's 1 MiB
361
+ # default (Config.zig `cdp_max_message_size`) to 100 MiB, matching
362
+ # Chrome's inbound DevTools buffer. An inbound message over the cap is
363
+ # dropped with WS close 1009 and NO JSON-RPC error — the whole CDP
364
+ # connection dies (wishlist A44). The 1 MiB default already covers
365
+ # axe-core (~553 KB), but larger injected bundles (jQuery + plugins,
366
+ # instrumentation) via execute_script exceed it (Node#drop used to as
367
+ # well, before files moved to DOM.setFileInputFiles). The reader
368
+ # buffer grows lazily (16 KB at init),
369
+ # so a high cap costs nothing until a big message actually arrives.
370
+ # Flag added in PR #2760 (build 7441) — below the floor, so guaranteed.
371
+ "--cdp-max-message-size",
372
+ (100 * 1024 * 1024).to_s,
235
373
  ]
236
374
  extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
237
375
  base + extra
@@ -257,8 +395,8 @@ module Capybara
257
395
  end
258
396
 
259
397
  if output.match?(ADDRESS_IN_USE_PATTERN)
260
- cleanup_failed_process
261
- raise ProcessTimeoutError,
398
+ stop
399
+ raise PortInUseError,
262
400
  "Lightpanda failed to start: port #{@options.port} is already in use"
263
401
  end
264
402
  rescue IO::WaitReadable
@@ -275,19 +413,6 @@ module Capybara
275
413
  end
276
414
  end
277
415
 
278
- def cleanup_failed_process
279
- return unless @pid
280
-
281
- begin
282
- ::Process.wait(@pid, ::Process::WNOHANG)
283
- rescue Errno::ECHILD
284
- nil
285
- end
286
-
287
- cleanup_pipes
288
- @pid = nil
289
- end
290
-
291
416
  # Auto-recover when a previous Lightpanda is still bound to our port.
292
417
  # Best-effort: relies on `lsof` to map port → pid (macOS / most Linux
293
418
  # distros). Where `lsof` isn't on PATH, we surface a clear error rather
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
@@ -14,6 +14,7 @@ require_relative "capybara/lightpanda/utils/wait"
14
14
  require_relative "capybara/lightpanda/client"
15
15
  require_relative "capybara/lightpanda/headers"
16
16
  require_relative "capybara/lightpanda/network"
17
+ require_relative "capybara/lightpanda/downloads"
17
18
  require_relative "capybara/lightpanda/cookies"
18
19
  require_relative "capybara/lightpanda/keyboard"
19
20
  require_relative "capybara/lightpanda/browser"
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.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad
@@ -74,15 +74,26 @@ 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
82
+ - lib/capybara/lightpanda/browser/selenium_compat.rb
77
83
  - lib/capybara/lightpanda/client.rb
78
84
  - lib/capybara/lightpanda/client/subscriber.rb
79
85
  - lib/capybara/lightpanda/client/web_socket.rb
80
86
  - lib/capybara/lightpanda/cookies.rb
87
+ - lib/capybara/lightpanda/downloads.rb
81
88
  - lib/capybara/lightpanda/driver.rb
82
89
  - lib/capybara/lightpanda/element_extension.rb
83
90
  - lib/capybara/lightpanda/errors.rb
84
91
  - lib/capybara/lightpanda/headers.rb
85
- - lib/capybara/lightpanda/javascripts/index.js
92
+ - lib/capybara/lightpanda/javascripts/attach.js
93
+ - lib/capybara/lightpanda/javascripts/banner.js
94
+ - lib/capybara/lightpanda/javascripts/errors.js
95
+ - lib/capybara/lightpanda/javascripts/predicates.js
96
+ - lib/capybara/lightpanda/javascripts/turbo.js
86
97
  - lib/capybara/lightpanda/keyboard.rb
87
98
  - lib/capybara/lightpanda/logger.rb
88
99
  - lib/capybara/lightpanda/network.rb
@@ -118,7 +129,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
118
129
  - !ruby/object:Gem::Version
119
130
  version: '0'
120
131
  requirements: []
121
- rubygems_version: 4.0.10
132
+ rubygems_version: 4.0.16
122
133
  specification_version: 4
123
134
  summary: Capybara driver for the Lightpanda headless browser
124
135
  test_files: []
@@ -1,226 +0,0 @@
1
- (function() {
2
- if (window._lightpanda) return;
3
-
4
- // --- Turbo activity tracking ---
5
- // Tracks pending Turbo operations so the driver can wait for Turbo to settle.
6
- // Inspired by the CapybaraLockstep approach for stabilizing Turbo integration tests.
7
- // Events are not perfectly symmetrical in Turbo, so we track multiple pairs
8
- // and use a counter to handle overlapping operations.
9
- //
10
- // Transitions across 0 emit `__lightpanda_turbo_busy` / `__lightpanda_turbo_idle`
11
- // sentinels via console.debug. Browser#wait_for_turbo subscribes to those
12
- // sentinels (Runtime.consoleAPICalled) and toggles a Concurrent::Event so the
13
- // Ruby side can wait event-driven instead of polling.
14
- //
15
- // Pages without Turbo never trigger _turboStart, so no sentinels fire and the
16
- // Ruby Event stays set (idle by default) — wait_for_turbo returns immediately.
17
- var _pendingTurboOps = 0;
18
- function _signalTurbo(state) {
19
- try { console.debug('__lightpanda_turbo_' + state); } catch (e) {}
20
- }
21
- function _turboStart() {
22
- _pendingTurboOps++;
23
- if (_pendingTurboOps === 1) _signalTurbo('busy');
24
- }
25
- function _turboEnd() {
26
- if (_pendingTurboOps > 0) {
27
- _pendingTurboOps--;
28
- if (_pendingTurboOps === 0) _signalTurbo('idle');
29
- }
30
- }
31
-
32
- // Fetch requests (covers Drive, Frames, and Form submission fetches)
33
- document.addEventListener('turbo:before-fetch-request', _turboStart);
34
- document.addEventListener('turbo:before-fetch-response', _turboEnd);
35
- document.addEventListener('turbo:fetch-request-error', _turboEnd);
36
-
37
- // Form submissions (can outlast their underlying fetch)
38
- document.addEventListener('turbo:submit-start', _turboStart);
39
- document.addEventListener('turbo:submit-end', _turboEnd);
40
-
41
- // Frame rendering (can outlast the fetch that triggered it)
42
- document.addEventListener('turbo:before-frame-render', _turboStart);
43
- document.addEventListener('turbo:frame-render', _turboEnd);
44
-
45
- // Stream rendering (no symmetric end event — wrap the render function)
46
- document.addEventListener('turbo:before-stream-render', function(event) {
47
- _turboStart();
48
- if (event.detail && event.detail.render) {
49
- var originalRender = event.detail.render;
50
- event.detail.render = function(streamElement) {
51
- var result = originalRender(streamElement);
52
- if (result && typeof result.then === 'function') {
53
- return result.finally(_turboEnd);
54
- }
55
- _turboEnd();
56
- return result;
57
- };
58
- } else {
59
- _turboEnd();
60
- }
61
- });
62
-
63
- // Drive page visits: turbo:load fires after the page is fully rendered.
64
- // Also serves as a safety reset — clears any counter leaks from aborted fetches.
65
- // Always re-signal idle so the Ruby Event re-arms even if some `_turboEnd`
66
- // call dropped on the floor mid-navigation.
67
- document.addEventListener('turbo:load', function() {
68
- _pendingTurboOps = 0;
69
- _signalTurbo('idle');
70
- });
71
-
72
- // --- Main API ---
73
-
74
- window._lightpanda = {
75
- turbo: {
76
- pending: function() { return _pendingTurboOps; },
77
- idle: function() { return _pendingTurboOps <= 0; }
78
- },
79
-
80
- // --- DOM visibility / state predicates ---
81
- // Centralized so node.rb's predicate methods (visible?, obscured?, disabled?)
82
- // and the visible_text walker share one implementation of the ancestor
83
- // cascade. Available in iframes too because index.js is registered via
84
- // Page.addScriptToEvaluateOnNewDocument.
85
-
86
- // Returns true if `el` is visible per Capybara's semantics. Lightpanda's
87
- // `checkVisibility()` walks ancestors and rejects `display:none` from
88
- // inline styles, stylesheets, and the UA sheet (PR #2294 — covers
89
- // HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
90
- // closed-<details> children). `visibility:hidden|collapse` is handled
91
- // separately because checkVisibility's defaults (per spec) ignore it.
92
- //
93
- // Intentional upstream out-of-scope (upstream-wishlist.md C10):
94
- // Lightpanda is a headless agentic browser and deliberately doesn't
95
- // load external `<link rel=stylesheet>` or apply `@media` rules to
96
- // the cascade, and `matchMedia()` returns false for every query.
97
- // Responsive patterns that hide one of two mobile/desktop CTA
98
- // duplicates via `@media (min-width: …) { display: none }` leak
99
- // both variants past this check — Capybara then raises
100
- // `Ambiguous: found 2 elements`. There is no in-gem fix: rebuilding
101
- // the CSS cascade in JS would need a CSS parser, sync access to
102
- // remote stylesheets, and a real media-query evaluator. Run
103
- // cuprite for responsive-UI assertions.
104
- isVisible: function(el) {
105
- if (!el || el.nodeType !== 1) return false;
106
- var win = el.ownerDocument.defaultView || window;
107
- var style = win.getComputedStyle(el);
108
- if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
109
- return el.checkVisibility();
110
- },
111
-
112
- // Returns true if the element is obscured at its center point — i.e.
113
- // hit-testing elementFromPoint at the center returns something that is
114
- // not the element or its descendant. `visibility:hidden|collapse` is
115
- // short-circuited explicitly because those elements still produce a
116
- // layout box (so the rect-zero check below can't catch them); every
117
- // other "not rendered" case (display:none, [hidden], descendants of
118
- // either) falls out naturally because getBoundingClientRect returns
119
- // DOMRect{0,0,0,0} for elements with no layout box.
120
- isObscured: function(el) {
121
- var doc = el.ownerDocument;
122
- var win = doc.defaultView || window;
123
- var style = win.getComputedStyle(el);
124
- if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
125
- var r = el.getBoundingClientRect();
126
- if (r.width === 0 || r.height === 0) return true;
127
- var cx = r.left + (r.width / 2);
128
- var cy = r.top + (r.height / 2);
129
- var w = win.innerWidth || doc.documentElement.clientWidth;
130
- var h = win.innerHeight || doc.documentElement.clientHeight;
131
- if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
132
- var hit = doc.elementFromPoint(cx, cy);
133
- if (!hit) return true;
134
- if (hit === el) return false;
135
- return !el.contains(hit);
136
- },
137
-
138
- // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
139
- // an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
140
- // is reported as disabled, even though those don't match CSS `:disabled`
141
- // per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
142
- isDisabled: function(el) {
143
- if (el.matches && el.matches(':disabled')) return true;
144
- if ((el.tagName || '').toUpperCase() === 'OPTION') {
145
- var p = el.parentElement;
146
- while (p) {
147
- if (p.disabled) return true;
148
- p = p.parentElement;
149
- }
150
- }
151
- return false;
152
- },
153
-
154
- // True if the element is the host of a contenteditable region: it (or any
155
- // ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
156
- // native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
157
- // `false` for every element — it has no caret/keyboard editing pipeline —
158
- // so the IDL property is useless here and we walk ancestors ourselves.
159
- isContentEditable: function(el) {
160
- var n = el;
161
- while (n && n.nodeType === 1) {
162
- if (n.hasAttribute && n.hasAttribute('contenteditable')) {
163
- var v = (n.getAttribute('contenteditable') || '').toLowerCase();
164
- return v !== 'false';
165
- }
166
- n = n.parentElement;
167
- }
168
- return false;
169
- },
170
-
171
- // Walk descendants and accumulate text from visible nodes only. Inserts
172
- // newlines around block-display containers so paragraphs/lists render with
173
- // natural breaks (Capybara expects this from Chrome's innerText).
174
- // Lightpanda's innerText returns textContent verbatim (no rendering, so no
175
- // hidden-descendant filtering), so we DIY the visibility filtering here.
176
- visibleText: function(el) {
177
- var BLOCK_DISP = { BLOCK:1, FLEX:1, GRID:1, 'LIST-ITEM':1, TABLE:1, 'TABLE-ROW':1,
178
- 'TABLE-CAPTION':1, 'TABLE-CELL':1 };
179
- var BLOCK_TAG = { ADDRESS:1, ARTICLE:1, ASIDE:1, BLOCKQUOTE:1, DETAILS:1, DIALOG:1,
180
- DIV:1, DL:1, DT:1, DD:1, FIELDSET:1, FIGCAPTION:1, FIGURE:1,
181
- FOOTER:1, FORM:1, H1:1, H2:1, H3:1, H4:1, H5:1, H6:1, HEADER:1,
182
- HGROUP:1, HR:1, LI:1, MAIN:1, NAV:1, OL:1, P:1, PRE:1, SECTION:1,
183
- TABLE:1, TR:1, UL:1 };
184
- var self = this;
185
-
186
- // Collapse runs of ASCII whitespace (preserving NBSP) to a single space —
187
- // matches Chrome's innerText whitespace handling for text nodes.
188
- function normText(s) {
189
- return s.replace(/[\t\n\r\f\v ]+/g, ' ');
190
- }
191
-
192
- function walk(node) {
193
- if (node.nodeType === 3) return normText(node.nodeValue);
194
- // DocumentFragment / ShadowRoot — no element of its own to test
195
- // for visibility, just walk children.
196
- if (node.nodeType === 11) {
197
- var fout = '';
198
- for (var k = 0; k < node.childNodes.length; k++) fout += walk(node.childNodes[k]);
199
- return fout;
200
- }
201
- if (node.nodeType !== 1) return '';
202
- if (!self.isVisible(node)) return '';
203
- var tag = (node.tagName || '').toUpperCase();
204
- if (tag === 'TEXTAREA') return node.value || '';
205
- if (tag === 'BR') return '\n';
206
- var win = node.ownerDocument.defaultView || window;
207
- var style = win.getComputedStyle(node);
208
- var disp = (style.display || '').toUpperCase();
209
- var isBlock = BLOCK_DISP[disp] || BLOCK_TAG[tag];
210
- var out = '';
211
- for (var i = 0; i < node.childNodes.length; i++) {
212
- out += walk(node.childNodes[i]);
213
- }
214
- // Block-level elements get wrapped in \n…\n only when they actually
215
- // contribute visible text. An empty <div> between two inline siblings
216
- // would otherwise introduce a phantom line break that Chrome's
217
- // innerText algorithm collapses out (required line breaks around
218
- // empty blocks coalesce in the line-collapse pass).
219
- if (isBlock && /\S/.test(out)) out = '\n' + out + '\n';
220
- return out;
221
- }
222
-
223
- return walk(el);
224
- }
225
- };
226
- })();