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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +14 -1
- data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
- data/lib/capybara/lightpanda/binary.rb +72 -54
- data/lib/capybara/lightpanda/browser/console.rb +190 -0
- data/lib/capybara/lightpanda/browser/finder.rb +196 -0
- data/lib/capybara/lightpanda/browser/modals.rb +150 -0
- data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
- data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
- data/lib/capybara/lightpanda/browser.rb +158 -813
- data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
- data/lib/capybara/lightpanda/client.rb +5 -4
- data/lib/capybara/lightpanda/downloads.rb +176 -0
- data/lib/capybara/lightpanda/driver.rb +124 -32
- data/lib/capybara/lightpanda/errors.rb +24 -10
- data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
- data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
- data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
- data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
- data/lib/capybara/lightpanda/keyboard.rb +23 -19
- data/lib/capybara/lightpanda/network.rb +103 -18
- data/lib/capybara/lightpanda/node.rb +200 -95
- data/lib/capybara/lightpanda/options.rb +44 -3
- data/lib/capybara/lightpanda/process.rb +154 -29
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +14 -3
- 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
|
|
101
|
-
#
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
raise
|
|
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
|
data/lib/capybara-lightpanda.rb
CHANGED
|
@@ -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.
|
|
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/
|
|
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.
|
|
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
|
-
})();
|