capybara-lightpanda 0.9.0 → 0.11.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 +51 -0
- data/README.md +14 -1
- data/lib/capybara/lightpanda/auto_scripts.rb +3 -1
- data/lib/capybara/lightpanda/binary.rb +70 -8
- data/lib/capybara/lightpanda/browser/console.rb +89 -7
- data/lib/capybara/lightpanda/browser/modals.rb +54 -3
- data/lib/capybara/lightpanda/browser/navigation.rb +1 -0
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
- data/lib/capybara/lightpanda/browser.rb +104 -4
- data/lib/capybara/lightpanda/downloads.rb +176 -0
- data/lib/capybara/lightpanda/driver.rb +109 -2
- data/lib/capybara/lightpanda/errors.rb +5 -0
- data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +19 -55
- data/lib/capybara/lightpanda/network.rb +36 -10
- data/lib/capybara/lightpanda/node.rb +357 -65
- data/lib/capybara/lightpanda/options.rb +53 -7
- data/lib/capybara/lightpanda/process.rb +173 -11
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b898fd26a9e25f95263b605f022264cd2a132bbca82081bd931fe542c88673e
|
|
4
|
+
data.tar.gz: 4776e1326f56cd6f123538b130dfb7de1edb1a745a45574e31520a1a5227ce94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 025d4fd23a6f0b4379c6e34d878610b657a06dcf33ab5490b606e5e42ed2e452475860283df232f8c397bbf1b48144f0d89be33bf7e5a70fe430a14e26bfb1a7
|
|
7
|
+
data.tar.gz: 16193011f9ea880c74300a45491ab5dbe36abe55e0a080fe1f65edf36d9722daf6e9842de361f498068952970f3f2d975a64d8b92eb8ecaf2600c01b342caf34
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.11.0] - 2026-08-25
|
|
4
|
+
|
|
5
|
+
> **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8796 (2026-08-25 or newer). **No tagged Lightpanda release is new enough yet**: 0.3.7 is below the floor and is now refused, so version pinning is unavailable until Lightpanda ships its next release — CI runs on the rolling nightly in the meantime.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Drag and drop between elements.** `element.drag_to(target)` performs an HTML5 drag — `dragstart` through `drop` with a shared `DataTransfer`, so dropzone pages and `setData`/`getData` handlers behave as in Chrome, and drag events carry whole-number coordinates like Chrome's. Libraries that only respond to real mouse movement (SortableJS's fallback mode) remain out of reach — there is still no layout engine to drive a pointer through.
|
|
10
|
+
- **`load_images` driver option.** `load_images: true` makes the browser fetch `<img>` sources over the network, so image requests appear in `network.traffic` and are counted by `wait_for_network_idle` — useful for suites that assert on image traffic. Off by default (images are skipped entirely, as before). Needs a nightly from 2026-08-26 onward.
|
|
11
|
+
- **Screenshots show the page.** `save_screenshot` — and the automatic failure screenshots capybara-screenshot takes — now produce a real rendering of the page's content instead of a fixed placeholder image. It is a text-layout rendering, useful for seeing what the DOM held at failure time; pixel-accurate visual regression still needs a full browser.
|
|
12
|
+
|
|
13
|
+
### Removed
|
|
14
|
+
|
|
15
|
+
- The internal workaround for reading an element's `draggable` state is gone now that the browser answers natively — no change required on your end.
|
|
16
|
+
|
|
17
|
+
## [0.10.0] - 2026-08-19
|
|
18
|
+
|
|
19
|
+
> **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8448 **or a tagged release ≥ 0.3.7**. The driver refuses to start below either floor and tells you which version it found. If you pin the browser (see below), pin `0.3.7` or newer.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Tagged Lightpanda releases are accepted, and can be pinned.** Set `Capybara::Lightpanda::Binary.required_version = "0.3.7"` and the driver downloads that exact release, so CI runs against the same browser every time instead of whatever the rolling nightly is that morning. Pinned binaries live in their own path (`~/.cache/lightpanda/lightpanda-0.3.7`), so several can coexist and a stale nightly can no longer shadow the pin. The docs have a CI recipe, including the pre-provisioning step suites that stub HTTP (VCR/WebMock) need.
|
|
24
|
+
- **File downloads.** When your app sends a file with `Content-Disposition: attachment` (`send_file`, `send_data`), the browser saves it to disk. `page.driver.downloads` lists the completed files, `page.driver.wait_for_download` blocks until in-flight ones finish. Turned on automatically when a destination exists: the new `:save_path` driver option, else `Capybara.save_path`. Responses that rely on MIME type alone (a bare `text/csv` with no `Content-Disposition`) are rendered as a page, not downloaded.
|
|
25
|
+
- **Uncaught JavaScript errors are captured.** `page.driver.browser.page_errors` returns every uncaught exception and unhandled promise rejection from page JS (message, file, line, column, stack). Previously a handler dying on a `TypeError` left `console_logs` empty and the failure surfaced far away as `ElementNotFound`. Kept separate from `console_logs`, which still holds only explicit `console.*` calls.
|
|
26
|
+
- **`raise_on_unhandled_modal` driver option.** A `confirm`/`prompt`/`alert` that opens outside an `accept_*`/`dismiss_*` block used to be silently cancelled by the browser, so a runaway confirm cancelled the action and the spec still passed. The driver now warns on stderr naming the dialog text; with `raise_on_unhandled_modal: true` it raises `Capybara::Lightpanda::UnhandledModalError` from the click or `visit` that opened it. Wrapped dialogs are unaffected.
|
|
27
|
+
- **Window API.** `page.current_window.resize_to(w, h)`, `.size`, `.maximize`, `current_window_handle`/`window_handles`, `switch_to_window` — all single-window (Lightpanda has one page per connection; `open_new_window` says so). One sharp edge: resizing changes `window.innerWidth` and `matchMedia` immediately but does not re-evaluate `@media` for the page already loaded, so **resize, then `visit`** for responsive specs.
|
|
28
|
+
- **The `:window_size` driver option now does something.** It sets the viewport that `window.innerWidth`/`innerHeight`, `matchMedia` and `@media` rules see, so `@media`-gated markup resolves at the size you ask for. Nothing reflows (there is no layout engine), so an element off-viewport is not reported as obscured.
|
|
29
|
+
- **Selenium-named helpers shared across Rails suites work.** `page.driver.browser.logs.get(:browser)` (the "no JS errors" helper), `browser.execute_async_script` (axe-core), `element.native.send_keys(...)`, and `browser.execute_cdp` for raw protocol calls. `browser.switch_to.alert` raises with a message pointing at `accept_confirm`/`accept_alert` — dialogs here are answered before they open, so there is nothing to switch to afterwards.
|
|
30
|
+
- `Node#path` returns an XPath that re-finds the element (Capybara's contract), and `"(: Shadow DOM element - no XPath :)"` for an element inside a shadow root, as Selenium does. `Node#exists?` answers whether a node is still attached without raising.
|
|
31
|
+
- **Intel Macs and arm64 Linux** (Graviton-class CI runners) are supported; Lightpanda publishes binaries for both, the gem just refused to look them up.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **Minimum Lightpanda version raised** (nightly 8448 / release 0.3.7). What that buys your suite: elements hidden by Tailwind v4's layered CSS are correctly invisible; `<form method="dialog">` closes its dialog (the Turbo-confirm pattern in Spree 5's admin) instead of navigating away; `select` reaches options inside `<optgroup>`; pages scheduling more than 512 timers no longer stall; dynamically inserted scripts load and fire `load`/`error` correctly; setting a `<select>` value can no longer crash the browser mid-spec; and non-ASCII download filenames come through intact.
|
|
36
|
+
- **`Element#drop` hands files to the browser** instead of streaming their bytes through the driver, so there is no longer a size ceiling on dropped files and `file.type` is what the browser sniffs. Same call, same behavior for the page. Capybara's own `Element#drop` shared specs now run against the driver.
|
|
37
|
+
- **Visible text is what the browser renders.** `text` uses the browser's own `innerText`, so block breaks, hidden descendants and whitespace match what a real browser reports, instead of the driver's approximation.
|
|
38
|
+
- **Clicking a wrapper element reaches the widget inside it.** A click on a plain container descends to the first interactive child a pointer would have hit, so select2 dropdowns (and similar widgets that bind on an inner node) open when a helper clicks the outer container.
|
|
39
|
+
- **`hover` fires `mouseenter` as well as `mouseover`**, so Stimulus `mouseenter->` actions, Floating UI and tippy menus open. CSS `:hover` rules still do not apply (no pointer state).
|
|
40
|
+
- Default `window_size` is `[1920, 1080]`, Lightpanda's own default, rather than `[1024, 768]`; suites that want the old size pass it explicitly. An invalid `window_size` raises `ArgumentError` at driver construction.
|
|
41
|
+
- Large `execute_script` payloads (axe-core-sized bundles and bigger) no longer drop the connection: the CDP message cap is 100 MiB.
|
|
42
|
+
- More of Capybara's shared specs run and pass, and stay as regression cover: shadow DOM (`#shadow_root` finding/clicking/text), `node #style`/`#matches_style?`, `send_keys` key events, and `fetch`/XHR `FormData` submissions.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **A redirect no longer stalls `wait_for_network_idle` for the rest of the session.** After any `redirect_to` (post-create, post-login), `page.driver.network.pending_connections` stayed at 1 and every `wait_for_network_idle` burned its full timeout. Redirect chains are tracked correctly now, `network.traffic` shows the 302 and the final 200 on the right URLs, and `status_code` still reports the final page. Regular clicks and visits were never affected.
|
|
47
|
+
- **A pinned browser version was silently ignored** whenever a binary was already cached, so CI kept running the nightly the pin was meant to replace. Pins are now self-verifying (see Added).
|
|
48
|
+
- **An interrupted browser download no longer corrupts the cached binary.** The refresh wrote over the file in place, so a dropped connection left a zero-byte "executable" that the fallback path then handed back as usable. Downloads go to a temp file and are renamed into place atomically.
|
|
49
|
+
|
|
50
|
+
### Internal
|
|
51
|
+
|
|
52
|
+
- Docs site resynced with what the gem does; a local `script/real-app` harness boots the real-apps CI targets and captures `console_logs`/`network.traffic` per failing example.
|
|
53
|
+
|
|
3
54
|
## [0.9.0] - 2026-06-18
|
|
4
55
|
|
|
5
56
|
### Fixed
|
data/README.md
CHANGED
|
@@ -20,6 +20,16 @@ Self-contained — built-in CDP client, no external browser-client gem required.
|
|
|
20
20
|
|
|
21
21
|
</div>
|
|
22
22
|
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
| | |
|
|
26
|
+
|---|---|
|
|
27
|
+
| **Ruby** | ≥ 3.3 — CI covers 3.3 and 4.0 |
|
|
28
|
+
| **Capybara** | ≥ 3.0, < 5 |
|
|
29
|
+
| **Platforms** | Linux `x86_64` · Linux `aarch64` · macOS Apple Silicon · macOS Intel · Windows through WSL2 (no native Windows build upstream) |
|
|
30
|
+
|
|
31
|
+
An unsupported host raises `UnsupportedPlatformError` at boot, naming what it detected — it never fails halfway through a suite.
|
|
32
|
+
|
|
23
33
|
## Install
|
|
24
34
|
|
|
25
35
|
Add this to your `Gemfile` and run `bundle install`:
|
|
@@ -44,7 +54,10 @@ driven_by :lightpanda
|
|
|
44
54
|
> The Lightpanda binary is auto-downloaded on first use — no separate install step needed.
|
|
45
55
|
|
|
46
56
|
> [!IMPORTANT]
|
|
47
|
-
> Lightpanda is a headless agentic browser, not a layout engine. External `<link rel="stylesheet">` **are** fetched and applied (the gem enables this by default),
|
|
57
|
+
> Lightpanda is a headless agentic browser, not a layout engine. External `<link rel="stylesheet">` **are** fetched and applied (the gem enables this by default), and `@media` / `window.matchMedia()` evaluate against the `window_size` you configure — so a mobile-only CTA gated by `@media (max-width: …)` resolves at the width you ask for. What's missing is *layout*: nothing reflows, `getBoundingClientRect` stays synthetic, and there is no real scroll. Specs that assert on pixel geometry, scrolling, or screenshots — plus the two that catch people out, a second browser tab and a menu revealed purely by CSS `:hover` — should stay on Cuprite (or whichever full-browser driver you were already using). The [per-spec dual-driver setup](https://navidemad.github.io/capybara-lightpanda/docs/#dual-per-spec) routes that minority to Cuprite and the structural majority to Lightpanda for speed.
|
|
58
|
+
|
|
59
|
+
> [!TIP]
|
|
60
|
+
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "0.3.8"`. (0.3.8 is the first release that will clear the gem's current floor, nightly build 8796 — until upstream tags it, the driver needs the rolling `nightly` tag.) Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
|
|
48
61
|
|
|
49
62
|
## Credits
|
|
50
63
|
|
|
@@ -17,7 +17,9 @@ module Capybara
|
|
|
17
17
|
|
|
18
18
|
# Order matters: declarations (turbo, predicates) before the wiring
|
|
19
19
|
# (attach) that reads their names. banner is a leading comment block.
|
|
20
|
-
|
|
20
|
+
# turbo.js and errors.js register their listeners at top level and expose
|
|
21
|
+
# nothing through attach.js, so they only need to run inside the IIFE.
|
|
22
|
+
PARTS = %w[banner.js turbo.js errors.js predicates.js attach.js].freeze
|
|
21
23
|
|
|
22
24
|
# The guard short-circuits a repeat run before turbo.js can register its
|
|
23
25
|
# listeners a second time (double-registration would double-count
|
|
@@ -11,8 +11,19 @@ module Capybara
|
|
|
11
11
|
class Binary
|
|
12
12
|
GITHUB_RELEASE_URL = "https://github.com/lightpanda-io/browser/releases/download"
|
|
13
13
|
|
|
14
|
+
# Upstream publishes all four arch/OS combinations on every channel —
|
|
15
|
+
# verified 2026-07-26 against the `nightly` tag and release 0.3.6 (the
|
|
16
|
+
# MINIMUM_RELEASE floor, so any acceptable pin carries them too). Intel
|
|
17
|
+
# macOS and arm64 Linux were absent here and raised
|
|
18
|
+
# UnsupportedPlatformError on machines upstream ships a binary for: Intel
|
|
19
|
+
# MacBooks, and Graviton / arm64 CI runners. normalize_arch folds arm64 ->
|
|
20
|
+
# aarch64, so the arm64 rows are unreachable defensive duplicates kept for
|
|
21
|
+
# symmetry with the pre-existing arm64-darwin one.
|
|
14
22
|
PLATFORMS = {
|
|
15
23
|
%w[x86_64 linux] => "lightpanda-x86_64-linux",
|
|
24
|
+
%w[aarch64 linux] => "lightpanda-aarch64-linux",
|
|
25
|
+
%w[arm64 linux] => "lightpanda-aarch64-linux",
|
|
26
|
+
%w[x86_64 darwin] => "lightpanda-x86_64-macos",
|
|
16
27
|
%w[aarch64 darwin] => "lightpanda-aarch64-macos",
|
|
17
28
|
%w[arm64 darwin] => "lightpanda-aarch64-macos",
|
|
18
29
|
}.freeze
|
|
@@ -143,17 +154,45 @@ module Capybara
|
|
|
143
154
|
nil
|
|
144
155
|
end
|
|
145
156
|
|
|
157
|
+
# Downloads into a sibling temp file and renames it into place only
|
|
158
|
+
# once the transfer finished.
|
|
159
|
+
#
|
|
160
|
+
# Writing straight to `destination` corrupted a working binary on any
|
|
161
|
+
# interrupted transfer: File.open(_, "wb") truncates the existing file
|
|
162
|
+
# the moment the request starts, and truncation KEEPS the mode bits —
|
|
163
|
+
# so a dropped connection left a partial file that still answered
|
|
164
|
+
# File.executable? => true. #update's "fall back to the cached binary"
|
|
165
|
+
# rescue then handed that corpse back as if it were the usable stale
|
|
166
|
+
# binary, warning as though nothing was wrong. That is the common path,
|
|
167
|
+
# not an edge case: past cache_time, #update calls #download precisely
|
|
168
|
+
# when a good binary is already sitting at `destination`.
|
|
169
|
+
#
|
|
170
|
+
# rename(2) within one directory is atomic, so a concurrent reader sees
|
|
171
|
+
# either the old binary or the new one, never a half-written one. The
|
|
172
|
+
# temp file is a sibling (not Dir.tmpdir) so the rename never crosses a
|
|
173
|
+
# filesystem, and chmod happens before it so the binary is never
|
|
174
|
+
# visible non-executable. Two racing downloads get distinct temp names
|
|
175
|
+
# and both rename a complete file — last writer wins, both are valid.
|
|
146
176
|
def download
|
|
147
177
|
binary_name = platform_binary
|
|
148
178
|
tag = required_version || "nightly"
|
|
149
|
-
url = "#{
|
|
179
|
+
url = "#{release_url}/#{tag}/#{binary_name}"
|
|
150
180
|
destination = install_path
|
|
151
181
|
|
|
152
182
|
log("Downloading #{binary_name} (#{tag}) → #{destination}")
|
|
153
183
|
FileUtils.mkdir_p(File.dirname(destination))
|
|
154
184
|
|
|
155
|
-
|
|
156
|
-
|
|
185
|
+
# ::Process, not Process — Capybara::Lightpanda::Process would win.
|
|
186
|
+
temp = "#{destination}.download-#{::Process.pid}"
|
|
187
|
+
begin
|
|
188
|
+
download_file(url, temp)
|
|
189
|
+
FileUtils.chmod(0o755, temp)
|
|
190
|
+
File.rename(temp, destination)
|
|
191
|
+
ensure
|
|
192
|
+
# No-op on success (rename consumed it). ensure, not rescue, so an
|
|
193
|
+
# Interrupt mid-download cleans up too; rm_f ignores a missing file.
|
|
194
|
+
FileUtils.rm_f(temp)
|
|
195
|
+
end
|
|
157
196
|
|
|
158
197
|
destination
|
|
159
198
|
end
|
|
@@ -174,12 +213,20 @@ module Capybara
|
|
|
174
213
|
# any environment. The `remove` step is required because `update`
|
|
175
214
|
# honors `cache_time` and would otherwise no-op on a
|
|
176
215
|
# too-old-but-not-yet-expired file.
|
|
216
|
+
# - Path equals our cache AND a pin is set → the version is a deliberate
|
|
217
|
+
# choice, so tell the user to raise the pin. The re-provision one-liner
|
|
218
|
+
# would be a dead end here: it re-downloads the same pinned release and
|
|
219
|
+
# lands on the identical "too old" error.
|
|
177
220
|
# - Anything else (user-managed install at a custom path) → keep
|
|
178
221
|
# the curl-overwrite suggestion, since we don't know how the file
|
|
179
222
|
# got there.
|
|
180
223
|
def update_hint(binary_path)
|
|
181
224
|
if brew_managed?(binary_path)
|
|
182
225
|
"brew update && brew upgrade lightpanda"
|
|
226
|
+
elsif binary_path == install_path && required_version
|
|
227
|
+
"Capybara::Lightpanda::Binary.required_version is pinned to " \
|
|
228
|
+
"#{required_version} — raise the pin to a newer release, " \
|
|
229
|
+
"or unset it to track the rolling nightly."
|
|
183
230
|
elsif binary_path == install_path
|
|
184
231
|
PROVISION_HINT
|
|
185
232
|
else
|
|
@@ -203,16 +250,31 @@ module Capybara
|
|
|
203
250
|
|
|
204
251
|
# Path the gem writes the downloaded binary to. Honors a
|
|
205
252
|
# user-configured install_dir; otherwise falls back to default_binary_path.
|
|
253
|
+
#
|
|
254
|
+
# A pin gets its own filename (`lightpanda-0.3.5`) rather than sharing
|
|
255
|
+
# the rolling-nightly one. `update` only checks that a file EXISTS at
|
|
256
|
+
# this path, not which version it holds, so on a shared path a nightly
|
|
257
|
+
# left over from an earlier run would be accepted as "the pin" — setting
|
|
258
|
+
# required_version on any machine with a warm cache (every CI runner
|
|
259
|
+
# restoring a cache, every existing dev checkout) would silently keep
|
|
260
|
+
# running the nightly it was meant to replace. Version-scoping makes the
|
|
261
|
+
# pin self-verifying and lets several pins coexist in one cache dir.
|
|
206
262
|
def install_path
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
end
|
|
263
|
+
dir = @install_dir || File.dirname(default_binary_path)
|
|
264
|
+
basename = required_version ? "lightpanda-#{required_version}" : "lightpanda"
|
|
265
|
+
|
|
266
|
+
File.join(dir, basename)
|
|
212
267
|
end
|
|
213
268
|
|
|
214
269
|
private
|
|
215
270
|
|
|
271
|
+
# Release host, as its own method so tests can point #download at a
|
|
272
|
+
# local socket and exercise the real streaming writer (which is where
|
|
273
|
+
# the truncation bug lived) instead of stubbing it out.
|
|
274
|
+
def release_url
|
|
275
|
+
GITHUB_RELEASE_URL
|
|
276
|
+
end
|
|
277
|
+
|
|
216
278
|
# Detects a Homebrew-managed binary by checking whether `path` is a
|
|
217
279
|
# symlink that resolves into a `/Cellar/` directory — the convention
|
|
218
280
|
# both `/opt/homebrew` (Apple Silicon) and `/usr/local` (Intel /
|
|
@@ -23,6 +23,34 @@ module Capybara
|
|
|
23
23
|
@console_logs_mutex.synchronize { @console_logs.clear }
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Uncaught page exceptions and unhandled promise rejections since the
|
|
27
|
+
# last `reset`: `{kind:, message:, url:, line:, column:, stack:,
|
|
28
|
+
# timestamp:}`, where `kind` is "error" or "unhandledrejection".
|
|
29
|
+
#
|
|
30
|
+
# Deliberately NOT folded into #console_logs. Chrome reports an
|
|
31
|
+
# uncaught exception through Runtime.exceptionThrown rather than
|
|
32
|
+
# consoleAPICalled, and Playwright/Puppeteer expose it as `pageerror`
|
|
33
|
+
# separately from `console` — so a suite that greps console_logs for
|
|
34
|
+
# errors on those stacks doesn't see exceptions there either. Merging
|
|
35
|
+
# them would also start failing every suite that already asserts
|
|
36
|
+
# console_logs holds no errors.
|
|
37
|
+
#
|
|
38
|
+
# Lightpanda emits no Runtime.exceptionThrown at all, so the source is
|
|
39
|
+
# a passive listener pair in javascripts/errors.js reporting over the
|
|
40
|
+
# console.debug sentinel channel. It sees what reaches `window`:
|
|
41
|
+
# exceptions a framework catches itself (Stimulus's handleError, any
|
|
42
|
+
# try/catch) never arrive, and a cross-origin script collapses to
|
|
43
|
+
# "Script error." with no detail. Partial by construction — but the
|
|
44
|
+
# alternative is the zero visibility that made the solidus taxon-tree
|
|
45
|
+
# failure take a hand-injected listener to explain.
|
|
46
|
+
def page_errors
|
|
47
|
+
@page_errors_mutex.synchronize { @page_errors.dup }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear_page_errors
|
|
51
|
+
@page_errors_mutex.synchronize { @page_errors.clear }
|
|
52
|
+
end
|
|
53
|
+
|
|
26
54
|
private
|
|
27
55
|
|
|
28
56
|
def subscribe_to_console_logs
|
|
@@ -32,7 +60,7 @@ module Capybara
|
|
|
32
60
|
on("Runtime.consoleAPICalled") do |params|
|
|
33
61
|
params["args"]&.each do |r|
|
|
34
62
|
value = r["value"]
|
|
35
|
-
next if
|
|
63
|
+
next if driver_sentinel?(value)
|
|
36
64
|
|
|
37
65
|
logger.puts(value)
|
|
38
66
|
end
|
|
@@ -42,6 +70,9 @@ module Capybara
|
|
|
42
70
|
TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
|
|
43
71
|
private_constant :TURBO_SENTINEL_PREFIX
|
|
44
72
|
|
|
73
|
+
PAGE_ERROR_SENTINEL_PREFIX = "__lightpanda_page_error_"
|
|
74
|
+
private_constant :PAGE_ERROR_SENTINEL_PREFIX
|
|
75
|
+
|
|
45
76
|
# The Turbo activity tracker signals busy/idle via console.debug
|
|
46
77
|
# sentinels (see subscribe_to_turbo_signals); every consoleAPICalled
|
|
47
78
|
# consumer must filter them out of user-facing output.
|
|
@@ -49,14 +80,28 @@ module Capybara
|
|
|
49
80
|
value.is_a?(String) && value.start_with?(TURBO_SENTINEL_PREFIX)
|
|
50
81
|
end
|
|
51
82
|
|
|
83
|
+
# javascripts/errors.js reports uncaught page errors over the same
|
|
84
|
+
# console.debug channel, prefix + JSON payload.
|
|
85
|
+
def page_error_sentinel?(value)
|
|
86
|
+
value.is_a?(String) && value.start_with?(PAGE_ERROR_SENTINEL_PREFIX)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Anything the injected bundle emits for the driver's own benefit. Every
|
|
90
|
+
# consoleAPICalled consumer filters on this, not on one prefix — missing
|
|
91
|
+
# a sentinel here leaks driver plumbing into user-facing output.
|
|
92
|
+
def driver_sentinel?(value)
|
|
93
|
+
turbo_sentinel?(value) || page_error_sentinel?(value)
|
|
94
|
+
end
|
|
95
|
+
|
|
52
96
|
# Oldest entries are dropped past this cap so a chatty page can't grow
|
|
53
97
|
# the buffer unbounded across a long session.
|
|
54
98
|
CONSOLE_LOGS_LIMIT = 1_000
|
|
55
99
|
|
|
56
100
|
# Ring-buffer every console.* call for `Browser#console_logs`. Separate
|
|
57
101
|
# from subscribe_to_console_logs (which streams to an optional IO logger)
|
|
58
|
-
# so capture works without any logger configured.
|
|
59
|
-
#
|
|
102
|
+
# so capture works without any logger configured. Driver sentinels are
|
|
103
|
+
# plumbing, not page output: the Turbo ones are dropped, and the
|
|
104
|
+
# page-error ones are rerouted to @page_errors instead.
|
|
60
105
|
def subscribe_to_console_capture
|
|
61
106
|
on("Runtime.consoleAPICalled") do |params|
|
|
62
107
|
args = params["args"]
|
|
@@ -65,16 +110,53 @@ module Capybara
|
|
|
65
110
|
first = args.first&.dig("value")
|
|
66
111
|
next if turbo_sentinel?(first)
|
|
67
112
|
|
|
113
|
+
if page_error_sentinel?(first)
|
|
114
|
+
record_page_error(first, params["timestamp"])
|
|
115
|
+
next
|
|
116
|
+
end
|
|
117
|
+
|
|
68
118
|
entry = {
|
|
69
119
|
type: params["type"],
|
|
70
120
|
text: args.map { |a| a.fetch("value") { a["description"] }.to_s }.join(" "),
|
|
71
121
|
timestamp: params["timestamp"],
|
|
72
122
|
args: args,
|
|
73
123
|
}
|
|
74
|
-
@console_logs_mutex
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
124
|
+
push_capped(@console_logs, @console_logs_mutex, entry, CONSOLE_LOGS_LIMIT)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Same cap and reasoning as CONSOLE_LOGS_LIMIT: a page erroring inside a
|
|
129
|
+
# loop (a rejected fetch on an interval) must not grow this unbounded.
|
|
130
|
+
PAGE_ERRORS_LIMIT = 1_000
|
|
131
|
+
|
|
132
|
+
# Parse one `__lightpanda_page_error_<json>` sentinel into @page_errors.
|
|
133
|
+
# A payload that won't parse is still worth surfacing — dropping it would
|
|
134
|
+
# turn "the page threw" into silence, which is the whole failure mode
|
|
135
|
+
# this exists to end — so it lands with the raw text as the message.
|
|
136
|
+
def record_page_error(sentinel, timestamp)
|
|
137
|
+
json = sentinel.delete_prefix(PAGE_ERROR_SENTINEL_PREFIX)
|
|
138
|
+
fields = begin
|
|
139
|
+
JSON.parse(json)
|
|
140
|
+
rescue JSON::ParserError
|
|
141
|
+
{ "kind" => "error", "message" => json }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
entry = {
|
|
145
|
+
kind: fields["kind"] || "error",
|
|
146
|
+
message: fields["message"].to_s,
|
|
147
|
+
url: fields["url"].to_s,
|
|
148
|
+
line: fields["line"],
|
|
149
|
+
column: fields["column"],
|
|
150
|
+
stack: fields["stack"],
|
|
151
|
+
timestamp: timestamp,
|
|
152
|
+
}
|
|
153
|
+
push_capped(@page_errors, @page_errors_mutex, entry, PAGE_ERRORS_LIMIT)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def push_capped(buffer, mutex, entry, limit)
|
|
157
|
+
mutex.synchronize do
|
|
158
|
+
buffer << entry
|
|
159
|
+
buffer.shift(buffer.size - limit) if buffer.size > limit
|
|
78
160
|
end
|
|
79
161
|
end
|
|
80
162
|
|
|
@@ -24,7 +24,19 @@ module Capybara
|
|
|
24
24
|
|
|
25
25
|
on("Page.javascriptDialogOpening") do |params|
|
|
26
26
|
entry = { type: params["type"], message: params["message"] }
|
|
27
|
-
@modal_messages_mutex.synchronize
|
|
27
|
+
@modal_messages_mutex.synchronize do
|
|
28
|
+
@modal_messages << entry
|
|
29
|
+
# The pre-arm slot is single-shot on both sides: Lightpanda
|
|
30
|
+
# consumes its stashed response on this dialog, so we consume
|
|
31
|
+
# ours. No pre-arm in flight means Lightpanda just applied its
|
|
32
|
+
# silent default — remember it for the main thread; raising here
|
|
33
|
+
# would only die inside the subscriber.
|
|
34
|
+
if @modal_armed
|
|
35
|
+
@modal_armed = false
|
|
36
|
+
else
|
|
37
|
+
@unhandled_modal ||= entry
|
|
38
|
+
end
|
|
39
|
+
end
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
@modal_handler_installed = true
|
|
@@ -34,12 +46,31 @@ module Capybara
|
|
|
34
46
|
prepare_modals
|
|
35
47
|
params = { accept: true }
|
|
36
48
|
params[:promptText] = text if text
|
|
37
|
-
page_command("LP.handleJavaScriptDialog", **params)
|
|
49
|
+
arm_modal { page_command("LP.handleJavaScriptDialog", **params) }
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
def dismiss_modal(_type)
|
|
41
53
|
prepare_modals
|
|
42
|
-
page_command("LP.handleJavaScriptDialog", accept: false)
|
|
54
|
+
arm_modal { page_command("LP.handleJavaScriptDialog", accept: false) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Surface a dialog that opened with no pre-arm. Called from the main
|
|
58
|
+
# thread after every action that can open one (Browser#wait_for_idle,
|
|
59
|
+
# #go_to). Warns by default; raises when the driver was built with
|
|
60
|
+
# `raise_on_unhandled_modal: true` (Cuprite's option name). Either way
|
|
61
|
+
# the dialog is already gone — Lightpanda's default resolved it.
|
|
62
|
+
def check_unhandled_modal!
|
|
63
|
+
entry = @modal_messages_mutex.synchronize do
|
|
64
|
+
e = @unhandled_modal
|
|
65
|
+
@unhandled_modal = nil
|
|
66
|
+
e
|
|
67
|
+
end
|
|
68
|
+
return unless entry
|
|
69
|
+
|
|
70
|
+
message = unhandled_modal_message(entry)
|
|
71
|
+
raise UnhandledModalError, message if @options.raise_on_unhandled_modal
|
|
72
|
+
|
|
73
|
+
warn "[capybara-lightpanda] #{message}"
|
|
43
74
|
end
|
|
44
75
|
|
|
45
76
|
# `type` is accepted for the error message only: like Selenium (where
|
|
@@ -66,6 +97,26 @@ module Capybara
|
|
|
66
97
|
|
|
67
98
|
private
|
|
68
99
|
|
|
100
|
+
def arm_modal
|
|
101
|
+
@modal_messages_mutex.synchronize { @modal_armed = true }
|
|
102
|
+
yield
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
LIGHTPANDA_DIALOG_DEFAULTS = {
|
|
106
|
+
"confirm" => "cancelled it",
|
|
107
|
+
"prompt" => "answered null",
|
|
108
|
+
"beforeunload" => "cancelled it",
|
|
109
|
+
}.freeze
|
|
110
|
+
private_constant :LIGHTPANDA_DIALOG_DEFAULTS
|
|
111
|
+
|
|
112
|
+
def unhandled_modal_message(entry)
|
|
113
|
+
with_text = entry[:message] ? " with text `#{entry[:message]}`" : ""
|
|
114
|
+
default = LIGHTPANDA_DIALOG_DEFAULTS.fetch(entry[:type].to_s, "dismissed it")
|
|
115
|
+
"A #{entry[:type]} dialog#{with_text} opened, but the action was not wrapped in " \
|
|
116
|
+
"accept_alert / accept_confirm / dismiss_confirm / accept_prompt / dismiss_prompt " \
|
|
117
|
+
"— Lightpanda #{default} by default"
|
|
118
|
+
end
|
|
119
|
+
|
|
69
120
|
# Pop the first queued dialog whose message matches the requested
|
|
70
121
|
# pattern (any dialog when `regexp` is nil). Returns the entry or nil.
|
|
71
122
|
# Serialized with the message-thread writer.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Browser
|
|
6
|
+
# Selenium-shaped entry points onto capability the gem already has under
|
|
7
|
+
# its own names.
|
|
8
|
+
#
|
|
9
|
+
# Rails suites reach through `page.driver.browser.<x>` in *shared* helpers
|
|
10
|
+
# — `expect_no_js_errors` (the widely-copied "catch JavaScript errors in
|
|
11
|
+
# your system tests" helper) calls `browser.logs.get(:browser)`, and the
|
|
12
|
+
# axe-core accessibility matchers call `browser.execute_async_script`.
|
|
13
|
+
# Both are driver-agnostic in intent but Selenium-named in fact, so on
|
|
14
|
+
# this driver they raised NoMethodError — which takes out every example in
|
|
15
|
+
# the file before a single real assertion runs. Two aliases buy back whole
|
|
16
|
+
# spec files (decidim's `account_spec.rb` + the shared "accessible page"
|
|
17
|
+
# examples, real-apps run 30116365373).
|
|
18
|
+
#
|
|
19
|
+
# Scope is deliberately narrow: alias what maps cleanly onto an existing
|
|
20
|
+
# API, and raise something *actionable* where the underlying model
|
|
21
|
+
# genuinely differs (see #switch_to). We do not grow a Selenium
|
|
22
|
+
# emulation layer.
|
|
23
|
+
module SeleniumCompat
|
|
24
|
+
# Selenium's `Logs#get(:browser)` shape over Browser#console_logs.
|
|
25
|
+
# `level` is the Selenium severity string ("SEVERE"/"WARNING"/…), not
|
|
26
|
+
# the CDP console type, because that is what callers compare against.
|
|
27
|
+
LogEntry = Struct.new(:level, :message, :timestamp) do
|
|
28
|
+
def to_s
|
|
29
|
+
"#{timestamp} #{level} #{message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# CDP `Runtime.consoleAPICalled` type -> Selenium log level. Anything
|
|
34
|
+
# unlisted (log, info, dir, table, …) is INFO, matching Chrome. "warn"
|
|
35
|
+
# is mapped alongside "warning" because Lightpanda emitted the former
|
|
36
|
+
# before upstream #2731.
|
|
37
|
+
CONSOLE_LEVELS = {
|
|
38
|
+
"error" => "SEVERE",
|
|
39
|
+
"assert" => "SEVERE",
|
|
40
|
+
"warning" => "WARNING",
|
|
41
|
+
"warn" => "WARNING",
|
|
42
|
+
"debug" => "DEBUG",
|
|
43
|
+
"trace" => "DEBUG",
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Selenium's `driver.logs` accessor. Only the `:browser` type has a
|
|
47
|
+
# meaning here; other Selenium log types (`:driver`, `:client`,
|
|
48
|
+
# `:server`) have no analogue and read as empty rather than raising, so
|
|
49
|
+
# a helper that probes several types still works.
|
|
50
|
+
class Logs
|
|
51
|
+
def initialize(browser)
|
|
52
|
+
@browser = browser
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get(type = :browser)
|
|
56
|
+
return [] unless type.to_sym == :browser
|
|
57
|
+
|
|
58
|
+
@browser.console_logs.map do |entry|
|
|
59
|
+
LogEntry.new(
|
|
60
|
+
CONSOLE_LEVELS.fetch(entry[:type], "INFO"),
|
|
61
|
+
entry[:text],
|
|
62
|
+
entry[:timestamp]
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def available_types
|
|
68
|
+
[:browser]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# NOTE: unlike Chrome's `getLog`, this does NOT drain the buffer —
|
|
73
|
+
# repeated calls re-report the same entries. Draining would let an
|
|
74
|
+
# earlier reader silently swallow a JS error from a later assertion,
|
|
75
|
+
# and the buffer is already scoped to the session (cleared by
|
|
76
|
+
# Driver#reset!). Use #clear_console_logs for an explicit reset.
|
|
77
|
+
#
|
|
78
|
+
# Not memoized on purpose: Logs holds nothing but a back-reference, and
|
|
79
|
+
# an ivar here would have to be declared in Browser#initialize per the
|
|
80
|
+
# "all ivars initialized in the constructor" rule for these modules.
|
|
81
|
+
def logs
|
|
82
|
+
Logs.new(self)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Selenium's `execute_async_script`: the script receives a completion
|
|
86
|
+
# callback as its last argument. Same contract as
|
|
87
|
+
# Driver#evaluate_async_script, minus the DOM-node unwrapping — callers
|
|
88
|
+
# on this path (axe-core et al.) hand back plain JSON.
|
|
89
|
+
def execute_async_script(script, *)
|
|
90
|
+
evaluate_async(script.to_s.strip, *)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Selenium's raw-CDP escape hatch, as used by
|
|
94
|
+
# `page.driver.browser.execute_cdp("Network.setBlockedURLs", urls: [...])`.
|
|
95
|
+
# Scoped to the page session, matching Selenium — that covers the
|
|
96
|
+
# Page / Runtime / DOM / Network / Emulation domains a suite would
|
|
97
|
+
# reach for. Browser-scoped commands (Target.*, Browser.*) still go
|
|
98
|
+
# through Browser#command.
|
|
99
|
+
#
|
|
100
|
+
# Deliberately unvalidated: the point is to reach CDP surface the gem
|
|
101
|
+
# has not wrapped yet, so an unknown method should fail with
|
|
102
|
+
# Lightpanda's own error rather than one of ours.
|
|
103
|
+
def execute_cdp(method, **params)
|
|
104
|
+
page_command(method.to_s, **params)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Selenium's post-hoc `switch_to.alert` cannot be honored: Lightpanda
|
|
108
|
+
# requires the accept/dismiss response to be armed BEFORE the action
|
|
109
|
+
# that opens the dialog (LP.handleJavaScriptDialog, upstream #2261 —
|
|
110
|
+
# Page.handleJavaScriptDialog deliberately errors), so by the time a
|
|
111
|
+
# caller could ask for the alert there is nothing left to answer. Fake
|
|
112
|
+
# it and the dialog silently keeps whatever default it already took.
|
|
113
|
+
# Raise with the migration instead of NoMethodError.
|
|
114
|
+
def switch_to
|
|
115
|
+
raise ::Capybara::NotSupportedByDriverError,
|
|
116
|
+
"Lightpanda arms dialog responses before the triggering action, so Selenium's " \
|
|
117
|
+
"post-hoc switch_to.alert has nothing to act on. Wrap the action instead: " \
|
|
118
|
+
"accept_alert/accept_confirm/accept_prompt (or Driver#accept_modal/#dismiss_modal), " \
|
|
119
|
+
"which pre-arm the response and still expose the dialog text."
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|