capybara-lightpanda 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +14 -1
- data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
- data/lib/capybara/lightpanda/binary.rb +72 -54
- data/lib/capybara/lightpanda/browser/console.rb +190 -0
- data/lib/capybara/lightpanda/browser/finder.rb +196 -0
- data/lib/capybara/lightpanda/browser/modals.rb +150 -0
- data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
- data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
- data/lib/capybara/lightpanda/browser.rb +158 -813
- data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
- data/lib/capybara/lightpanda/client.rb +5 -4
- data/lib/capybara/lightpanda/downloads.rb +176 -0
- data/lib/capybara/lightpanda/driver.rb +124 -32
- data/lib/capybara/lightpanda/errors.rb +24 -10
- data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
- data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
- data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
- data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
- data/lib/capybara/lightpanda/keyboard.rb +23 -19
- data/lib/capybara/lightpanda/network.rb +103 -18
- data/lib/capybara/lightpanda/node.rb +200 -95
- data/lib/capybara/lightpanda/options.rb +44 -3
- data/lib/capybara/lightpanda/process.rb +154 -29
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +14 -3
- data/lib/capybara/lightpanda/javascripts/index.js +0 -226
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11c82a5cc9cde03ae1a231efb72764a35c77beda531118960fb499c209348a3e
|
|
4
|
+
data.tar.gz: 66f4b3190f2dcc158925fd26e7b7b0590a8460d9789737d8bb08c3d5f9deed6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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),
|
|
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.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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 = "#{
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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
|
|
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
|