capybara-lightpanda 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +14 -1
  4. data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
  5. data/lib/capybara/lightpanda/binary.rb +72 -54
  6. data/lib/capybara/lightpanda/browser/console.rb +190 -0
  7. data/lib/capybara/lightpanda/browser/finder.rb +196 -0
  8. data/lib/capybara/lightpanda/browser/modals.rb +150 -0
  9. data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
  10. data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
  11. data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
  12. data/lib/capybara/lightpanda/browser.rb +158 -813
  13. data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
  14. data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
  15. data/lib/capybara/lightpanda/client.rb +5 -4
  16. data/lib/capybara/lightpanda/downloads.rb +176 -0
  17. data/lib/capybara/lightpanda/driver.rb +124 -32
  18. data/lib/capybara/lightpanda/errors.rb +24 -10
  19. data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
  20. data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
  21. data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
  22. data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
  23. data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
  24. data/lib/capybara/lightpanda/keyboard.rb +23 -19
  25. data/lib/capybara/lightpanda/network.rb +103 -18
  26. data/lib/capybara/lightpanda/node.rb +200 -95
  27. data/lib/capybara/lightpanda/options.rb +44 -3
  28. data/lib/capybara/lightpanda/process.rb +154 -29
  29. data/lib/capybara/lightpanda/version.rb +1 -1
  30. data/lib/capybara-lightpanda.rb +1 -0
  31. metadata +14 -3
  32. data/lib/capybara/lightpanda/javascripts/index.js +0 -226
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f039526ec34b172b3824416cd6b7679720cabcb75455a1c3c46bbf0cc11b5d7b
4
- data.tar.gz: '087e752b5baa7d09d43541e90654732e042f28535d53248fab9ab2769f656c01'
3
+ metadata.gz: 11c82a5cc9cde03ae1a231efb72764a35c77beda531118960fb499c209348a3e
4
+ data.tar.gz: 66f4b3190f2dcc158925fd26e7b7b0590a8460d9789737d8bb08c3d5f9deed6b
5
5
  SHA512:
6
- metadata.gz: b2430399381dbcb55f54eafbbb9fb81cc3c4ac5956e4bbc16d5dfe70fb1f347f7f8dfdf60044134c87ae739200911d22e986ce9b4d0a37a2f21ebf74887e50f1
7
- data.tar.gz: 500584b0da1e6bd82737e213fdd13e10395334e0c971923e2f7fdcb78758251f36f50361272b66c1838a781bf134b724c7b1cc77761dfb28a9b6cfbd8de65d31
6
+ metadata.gz: 1dc37a8e9df8fb0533718e9aa17f35cb7dc162251a0f5f46e036b054afcbeec2da928277ac3af60ac592493d80357913f1f8d2384d671baa2e802905b6357c09
7
+ data.tar.gz: 95c360b046406da232e6104fe9cb10aa158fedc2ff4e3cdeaa7c12042bc3bd725d9825bd8e2c730365e46c0f9f884c02c4a523af1ce9e4d582fc5aa760093e99
data/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
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
+
40
+ ## [0.9.0] - 2026-06-18
41
+
42
+ ### Fixed
43
+
44
+ - `send_keys(:ctrl, …)` (held or `[:ctrl, "a"]` array form) crashed with `NoMethodError` — `MODIFIERS` advertised `:ctrl` but `KEYS` lacked the entry. Both forms now dispatch Control correctly, and an unknown key symbol raises `ArgumentError` naming the key everywhere.
45
+ - `Driver#headers` no longer reports phantom values after `reset!` — the cached `extra_headers` are cleared with the disposed BrowserContext.
46
+ - A connection reset (RST) during the WebSocket handshake now raises `DeadBrowserError` like the FIN path instead of leaking a raw `Errno::ECONNRESET`.
47
+ - A duplicate CDP response frame for an already-answered command id no longer kills the process (`IVar#try_set` on the message thread).
48
+
49
+ ### Changed
50
+
51
+ - `Browser` is now composed of include-modules (`Browser::Runtime` / `Finder` / `Navigation` / `Modals` / `Console`) — pure code motion, public API unchanged (verified method-for-method by reflection).
52
+ - The Network CDP domain has a single owner: `Network` captures the navigation response behind `Browser#status_code` / `#response_headers`, and traffic tracking is always on (cleared per `reset`, ferrum parity). `driver.network.disable` now visibly owns the caveat that it freezes status tracking.
53
+ - Port-in-use recovery dispatches on a typed `PortInUseError` (subclass of `ProcessTimeoutError`, so existing rescues keep working) instead of matching the error message; HTTP download failures raise `BinaryError` instead of `BinaryNotFoundError`.
54
+ - `Driver#reset!` rescues the gem's error hierarchy (plus `SystemCallError`/`IOError`) instead of `StandardError`, and warns on respawn — programmer errors no longer degrade into a silent browser restart per test.
55
+ - `Node#shadow_root` routes through the guarded `#call` path: reading the shadow root of a detached host now raises `ObsoleteNode` (handled by Capybara's `automatic_reload`) instead of silently returning stale content.
56
+ - Arrays without modifier symbols passed to `send_keys` now type via `insertText`, consistent with plain strings (previously synthesized per-char keyDown/keyUp).
57
+
58
+ - No-args `evaluate_script` / `execute_script` send the expression with `replMode: true` (DevTools-console REPL semantics) instead of wrapping it in an IIFE. Top-level `const`/`let` can now be redeclared across calls *and* state persists between calls, matching what users see in the Chrome console. JS exceptions still raise `JavaScriptError`.
59
+
60
+ ### Removed
61
+
62
+ - Dead internal API swept (pre-1.0 cleanup; none had a caller or documented use): `Binary.run` / `.exec` / `.fetch` / `.version` / `.path` and the `Binary::Result` struct; `Browser#document_node_id`; `Client#ws_url` / `#options` readers; `WebSocket#open?`. Cuprite/Ferrum drop-in surface is deliberately KEPT and documented: `Options#window_size` / `#headless` (accepted, inert) and the never-raised `MouseEventFailed` / `NoSuchPageError` / `StatusError` (peer-taxonomy mirrors so migrated rescue lists keep loading).
63
+ - UPSTREAM_BUGS.md Bug #9 (`requestSubmit()` threw when a listener canceled the SubmitEvent) retired: fixed upstream, verified on the nightly this gem already requires. Contract tests pin both retired bugs in `test/features/upstream_bugs_test.rb`.
64
+
3
65
  ## [0.8.0] - 2026-06-12
4
66
 
5
67
  > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6736 (published 2026-06-12). The driver refuses to start against older binaries.
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
 
@@ -2,9 +2,37 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
+ # Assembles the `_lightpanda` bundle injected once per session via
6
+ # Page.addScriptToEvaluateOnNewDocument (Browser#create_page).
7
+ #
8
+ # The bundle is split across plain-declaration source files in
9
+ # javascripts/ — none of which contain an IIFE or module syntax — so each
10
+ # file is readable in isolation and parses identically as a classic
11
+ # browser script and as a `new Function(...)` body (how the Bun harness in
12
+ # test/js/ loads predicates.js without a build step). This module is the
13
+ # "linker": it concatenates the parts in order and wraps them in the IIFE
14
+ # plus the idempotency guard.
5
15
  module AutoScripts
6
- JS_PATH = File.expand_path("javascripts/index.js", __dir__).freeze
7
- JS = File.read(JS_PATH).freeze
16
+ JS_DIR = File.expand_path("javascripts", __dir__).freeze
17
+
18
+ # Order matters: declarations (turbo, predicates) before the wiring
19
+ # (attach) that reads their names. banner is a leading comment block.
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
23
+
24
+ # The guard short-circuits a repeat run before turbo.js can register its
25
+ # listeners a second time (double-registration would double-count
26
+ # _pendingTurboOps and desync the busy/idle sentinels console.rb reads).
27
+ # window._lightpanda is only set by attach.js (last), so the guard
28
+ # reflects "a previous full run completed".
29
+ JS = begin
30
+ body = PARTS.map { |name| File.read(File.join(JS_DIR, name)) }.join("\n")
31
+ "(function() {\n" \
32
+ "if (window._lightpanda) return;\n" \
33
+ "#{body}\n" \
34
+ "})();\n"
35
+ end.freeze
8
36
  end
9
37
  end
10
38
  end
@@ -9,24 +9,21 @@ require "uri"
9
9
  module Capybara
10
10
  module Lightpanda
11
11
  class Binary
12
- Result = Struct.new(:stdout, :stderr, :status) do
13
- def success?
14
- status.success?
15
- end
16
-
17
- def exit_code
18
- status.exitstatus
19
- end
20
-
21
- def output
22
- stdout.empty? ? stderr : stdout
23
- end
24
- end
25
-
26
12
  GITHUB_RELEASE_URL = "https://github.com/lightpanda-io/browser/releases/download"
27
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.
28
22
  PLATFORMS = {
29
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",
30
27
  %w[aarch64 darwin] => "lightpanda-aarch64-macos",
31
28
  %w[arm64 darwin] => "lightpanda-aarch64-macos",
32
29
  }.freeze
@@ -71,10 +68,6 @@ module Capybara
71
68
  yield self
72
69
  end
73
70
 
74
- def path
75
- @path ||= update
76
- end
77
-
78
71
  # Canonical entrypoint: ensure the binary at install_path is current,
79
72
  # download if needed, return its path. Pinned (required_version set)
80
73
  # never re-downloads when present. Unpinned re-downloads when older
@@ -145,7 +138,6 @@ module Capybara
145
138
  end
146
139
 
147
140
  File.delete(path)
148
- @path = nil
149
141
  log("Removed #{path}")
150
142
  path
151
143
  end
@@ -162,42 +154,45 @@ module Capybara
162
154
  nil
163
155
  end
164
156
 
165
- def run(*)
166
- stdout, stderr, status = Open3.capture3(path, *)
167
-
168
- Result.new(stdout: stdout, stderr: stderr, status: status)
169
- rescue Errno::ENOENT
170
- raise BinaryNotFoundError, "Lightpanda binary not found"
171
- end
172
-
173
- def exec(*)
174
- Kernel.exec(path, *)
175
- end
176
-
177
- def fetch(url)
178
- result = run("fetch", "--dump", url)
179
- raise BinaryError, result.stderr unless result.success?
180
-
181
- result.stdout
182
- end
183
-
184
- def version
185
- result = run("version")
186
- result.output.strip
187
- end
188
-
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.
189
176
  def download
190
177
  binary_name = platform_binary
191
178
  tag = required_version || "nightly"
192
- url = "#{GITHUB_RELEASE_URL}/#{tag}/#{binary_name}"
179
+ url = "#{release_url}/#{tag}/#{binary_name}"
193
180
  destination = install_path
194
181
 
195
182
  log("Downloading #{binary_name} (#{tag}) → #{destination}")
196
183
  FileUtils.mkdir_p(File.dirname(destination))
197
184
 
198
- download_file(url, destination)
199
- FileUtils.chmod(0o755, destination)
200
- @path = 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
201
196
 
202
197
  destination
203
198
  end
@@ -218,12 +213,20 @@ module Capybara
218
213
  # any environment. The `remove` step is required because `update`
219
214
  # honors `cache_time` and would otherwise no-op on a
220
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.
221
220
  # - Anything else (user-managed install at a custom path) → keep
222
221
  # the curl-overwrite suggestion, since we don't know how the file
223
222
  # got there.
224
223
  def update_hint(binary_path)
225
224
  if brew_managed?(binary_path)
226
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."
227
230
  elsif binary_path == install_path
228
231
  PROVISION_HINT
229
232
  else
@@ -247,16 +250,31 @@ module Capybara
247
250
 
248
251
  # Path the gem writes the downloaded binary to. Honors a
249
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.
250
262
  def install_path
251
- if @install_dir
252
- File.join(@install_dir, "lightpanda")
253
- else
254
- default_binary_path
255
- end
263
+ dir = @install_dir || File.dirname(default_binary_path)
264
+ basename = required_version ? "lightpanda-#{required_version}" : "lightpanda"
265
+
266
+ File.join(dir, basename)
256
267
  end
257
268
 
258
269
  private
259
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
+
260
278
  # Detects a Homebrew-managed binary by checking whether `path` is a
261
279
  # symlink that resolves into a `/Cellar/` directory — the convention
262
280
  # both `/opt/homebrew` (Apple Silicon) and `/usr/local` (Intel /
@@ -314,7 +332,7 @@ module Capybara
314
332
  end
315
333
 
316
334
  def follow_redirects(uri, destination, limit = 10)
317
- raise BinaryNotFoundError, "Too many redirects" if limit.zero?
335
+ raise BinaryError, "Too many redirects" if limit.zero?
318
336
 
319
337
  http_start(uri) do |http|
320
338
  request = Net::HTTP::Get.new(uri)
@@ -329,7 +347,7 @@ module Capybara
329
347
  log("Redirected → #{response['location']}")
330
348
  follow_redirects(URI.parse(response["location"]), destination, limit - 1)
331
349
  else
332
- raise BinaryNotFoundError, "Failed to download binary: #{response.code} #{response.message}"
350
+ raise BinaryError, "Failed to download binary: #{response.code} #{response.message}"
333
351
  end
334
352
  end
335
353
  end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ class Browser
6
+ # Runtime.consoleAPICalled consumers: the user-facing console_logs
7
+ # ring buffer, the optional IO-logger stream, and the Turbo
8
+ # busy/idle sentinel tracking behind Browser#wait_for_idle.
9
+ module Console
10
+ # Console messages captured from `Runtime.consoleAPICalled` since the
11
+ # last `reset` (Turbo-tracker sentinels excluded). Loose hashes, like
12
+ # Network#traffic: `{type:, text:, timestamp:, args:}` where `type` is
13
+ # the console method name ("log", "error", "warning", ...), `text` joins
14
+ # the arguments' primitive values/descriptions, and `args` keeps the raw
15
+ # CDP RemoteObjects. Lets suites assert on JS console errors
16
+ # (`browser.console_logs.select { |m| m[:type] == "error" }`) the way
17
+ # peer drivers do via custom Ferrum loggers.
18
+ def console_logs
19
+ @console_logs_mutex.synchronize { @console_logs.dup }
20
+ end
21
+
22
+ def clear_console_logs
23
+ @console_logs_mutex.synchronize { @console_logs.clear }
24
+ end
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
+
54
+ private
55
+
56
+ def subscribe_to_console_logs
57
+ logger = @options.logger
58
+ return unless logger
59
+
60
+ on("Runtime.consoleAPICalled") do |params|
61
+ params["args"]&.each do |r|
62
+ value = r["value"]
63
+ next if driver_sentinel?(value)
64
+
65
+ logger.puts(value)
66
+ end
67
+ end
68
+ end
69
+
70
+ TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
71
+ private_constant :TURBO_SENTINEL_PREFIX
72
+
73
+ PAGE_ERROR_SENTINEL_PREFIX = "__lightpanda_page_error_"
74
+ private_constant :PAGE_ERROR_SENTINEL_PREFIX
75
+
76
+ # The Turbo activity tracker signals busy/idle via console.debug
77
+ # sentinels (see subscribe_to_turbo_signals); every consoleAPICalled
78
+ # consumer must filter them out of user-facing output.
79
+ def turbo_sentinel?(value)
80
+ value.is_a?(String) && value.start_with?(TURBO_SENTINEL_PREFIX)
81
+ end
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
+
96
+ # Oldest entries are dropped past this cap so a chatty page can't grow
97
+ # the buffer unbounded across a long session.
98
+ CONSOLE_LOGS_LIMIT = 1_000
99
+
100
+ # Ring-buffer every console.* call for `Browser#console_logs`. Separate
101
+ # from subscribe_to_console_logs (which streams to an optional IO logger)
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.
105
+ def subscribe_to_console_capture
106
+ on("Runtime.consoleAPICalled") do |params|
107
+ args = params["args"]
108
+ next unless args.is_a?(Array)
109
+
110
+ first = args.first&.dig("value")
111
+ next if turbo_sentinel?(first)
112
+
113
+ if page_error_sentinel?(first)
114
+ record_page_error(first, params["timestamp"])
115
+ next
116
+ end
117
+
118
+ entry = {
119
+ type: params["type"],
120
+ text: args.map { |a| a.fetch("value") { a["description"] }.to_s }.join(" "),
121
+ timestamp: params["timestamp"],
122
+ args: args,
123
+ }
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
160
+ end
161
+ end
162
+
163
+ # Wire @turbo_event to the JS-side _signalTurbo emissions. The JS calls
164
+ # console.debug('__lightpanda_turbo_busy') / '_idle' on transitions across
165
+ # zero pending ops; Lightpanda forwards those to Runtime.consoleAPICalled.
166
+ # Idle → set the event (wakes any waiter); busy → reset.
167
+ #
168
+ # On Runtime.executionContextsCleared (navigation), unconditionally set
169
+ # the event: if we navigated away mid-busy state, no further idle signal
170
+ # would ever come from the old context, and we'd block for the full
171
+ # timeout. The new context will signal busy again if Turbo is active.
172
+ def subscribe_to_turbo_signals
173
+ on("Runtime.consoleAPICalled") do |params|
174
+ next unless params["args"].is_a?(Array)
175
+
176
+ marker = params["args"].first&.dig("value")
177
+ next unless turbo_sentinel?(marker)
178
+
179
+ case marker
180
+ when "#{TURBO_SENTINEL_PREFIX}busy" then @turbo_event.reset
181
+ when "#{TURBO_SENTINEL_PREFIX}idle" then @turbo_event.set
182
+ end
183
+ end
184
+
185
+ on("Runtime.executionContextsCleared") { @turbo_event.set }
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end