capybara-lightpanda 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7ec772a2b4254837394ec6705d94c8759e750f0f2ba7ce12ac4d02bfdd30bf5
4
- data.tar.gz: a65953b578a45f0f652f0f3dadc2334bfa6db4ae6c6f4d931da4d9730f3656a1
3
+ metadata.gz: 11c82a5cc9cde03ae1a231efb72764a35c77beda531118960fb499c209348a3e
4
+ data.tar.gz: 66f4b3190f2dcc158925fd26e7b7b0590a8460d9789737d8bb08c3d5f9deed6b
5
5
  SHA512:
6
- metadata.gz: 95af3574b27e16d24845f3bcdacc78779c8a036a81d7ab68870de8fd8bc957b8557a2beb9812f85030e7e1e74d994abf2f0d43e116ebb3c87069c52006012429
7
- data.tar.gz: 20bf49585073a9d06e76da5460aeb143d653de2dab9a7d1a11d55b16603e9d956737147d712403416f3a8818967193e3150ebc7ae04dbf248ab52b3c62a40495
6
+ metadata.gz: 1dc37a8e9df8fb0533718e9aa17f35cb7dc162251a0f5f46e036b054afcbeec2da928277ac3af60ac592493d80357913f1f8d2384d671baa2e802905b6357c09
7
+ data.tar.gz: 95c360b046406da232e6104fe9cb10aa158fedc2ff4e3cdeaa7c12042bc3bd725d9825bd8e2c730365e46c0f9f884c02c4a523af1ce9e4d582fc5aa760093e99
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0] - 2026-08-19
4
+
5
+ > **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.
6
+
7
+ ### Added
8
+
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **`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.
13
+ - **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.
14
+ - **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.
15
+ - **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.
16
+ - `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.
17
+ - **Intel Macs and arm64 Linux** (Graviton-class CI runners) are supported; Lightpanda publishes binaries for both, the gem just refused to look them up.
18
+
19
+ ### Changed
20
+
21
+ - **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.
22
+ - **`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.
23
+ - **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.
24
+ - **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.
25
+ - **`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).
26
+ - 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.
27
+ - Large `execute_script` payloads (axe-core-sized bundles and bigger) no longer drop the connection: the CDP message cap is 100 MiB.
28
+ - 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.
29
+
30
+ ### Fixed
31
+
32
+ - **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.
33
+ - **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).
34
+ - **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.
35
+
36
+ ### Internal
37
+
38
+ - 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.
39
+
3
40
  ## [0.9.0] - 2026-06-18
4
41
 
5
42
  ### 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), but `@media` rules and `window.matchMedia()` evaluate against a fixed 1920×1080 viewport — there is no resize emulation. Any spec whose visibility depends on a non-desktop viewportfor example a mobile-only CTA shown via `@media (max-width: …)` — should stay on Cuprite (or whichever full-browser driver you were already using). The [dual-driver setup](https://navidemad.github.io/capybara-lightpanda/#docs) routes the layout-sensitive minority to Cuprite and the structural majority to Lightpanda for speed.
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 configureso 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 screenshotsplus 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.7"`. 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
- PARTS = %w[banner.js turbo.js predicates.js attach.js].freeze
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 = "#{GITHUB_RELEASE_URL}/#{tag}/#{binary_name}"
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
- download_file(url, destination)
156
- FileUtils.chmod(0o755, destination)
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
- if @install_dir
208
- File.join(@install_dir, "lightpanda")
209
- else
210
- default_binary_path
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 turbo_sentinel?(value)
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. Skips the Turbo
59
- # activity-tracker sentinels they're driver plumbing, not page output.
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.synchronize do
75
- @console_logs << entry
76
- @console_logs.shift(@console_logs.size - CONSOLE_LOGS_LIMIT) if @console_logs.size > CONSOLE_LOGS_LIMIT
77
- end
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 { @modal_messages << entry }
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.
@@ -29,6 +29,7 @@ module Capybara
29
29
  else
30
30
  page_command("Page.navigate", url: url)
31
31
  end
32
+ check_unhandled_modal!
32
33
  end
33
34
  alias goto go_to
34
35
 
@@ -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