Class: Capybara::Lightpanda::Binary

Inherits:
Object
  • Object
show all
Defined in:
lib/capybara/lightpanda/binary.rb

Constant Summary collapse

GITHUB_RELEASE_URL =
"https://github.com/lightpanda-io/browser/releases/download"
PLATFORMS =

Upstream publishes all four arch/OS combinations on every channel — verified 2026-07-26 against the nightly tag and release 0.3.6 (the MINIMUM_RELEASE floor, so any acceptable pin carries them too). Intel macOS and arm64 Linux were absent here and raised UnsupportedPlatformError on machines upstream ships a binary for: Intel MacBooks, and Graviton / arm64 CI runners. normalize_arch folds arm64 -> aarch64, so the arm64 rows are unreachable defensive duplicates kept for symmetry with the pre-existing arm64-darwin one.

{
  %w[x86_64 linux] => "lightpanda-x86_64-linux",
  %w[aarch64 linux] => "lightpanda-aarch64-linux",
  %w[arm64 linux] => "lightpanda-aarch64-linux",
  %w[x86_64 darwin] => "lightpanda-x86_64-macos",
  %w[aarch64 darwin] => "lightpanda-aarch64-macos",
  %w[arm64 darwin] => "lightpanda-aarch64-macos",
}.freeze
DEFAULT_CACHE_TIME =
86_400
PROVISION_HINT =

One-liner that re-provisions the binary from a process with no HTTP-stubbing loaded (VCR/WebMock guard the test process itself). Referenced from the stale-fallback warning and BETA_TESTING.md.

"bundle exec ruby -r capybara-lightpanda " \
"-e 'Capybara::Lightpanda::Binary.remove; puts Capybara::Lightpanda::Binary.update'"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.cache_timeObject



52
53
54
# File 'lib/capybara/lightpanda/binary.rb', line 52

def cache_time
  @cache_time ||= Integer(ENV.fetch("LIGHTPANDA_CACHE_TIME", DEFAULT_CACHE_TIME))
end

.install_dirObject



56
57
58
# File 'lib/capybara/lightpanda/binary.rb', line 56

def install_dir
  @install_dir ||= File.dirname(default_binary_path)
end

.loggerObject



60
61
62
63
64
65
# File 'lib/capybara/lightpanda/binary.rb', line 60

def logger
  return @logger if defined?(@logger) && @logger
  return nil unless ENV["LIGHTPANDA_DEBUG"]

  @logger = Capybara::Lightpanda::Logger.new($stderr.tap { |s| s.sync = true })
end

.proxy_addrObject

Returns the value of attribute proxy_addr.



50
51
52
# File 'lib/capybara/lightpanda/binary.rb', line 50

def proxy_addr
  @proxy_addr
end

.proxy_passObject

Returns the value of attribute proxy_pass.



50
51
52
# File 'lib/capybara/lightpanda/binary.rb', line 50

def proxy_pass
  @proxy_pass
end

.proxy_portObject

Returns the value of attribute proxy_port.



50
51
52
# File 'lib/capybara/lightpanda/binary.rb', line 50

def proxy_port
  @proxy_port
end

.proxy_userObject

Returns the value of attribute proxy_user.



50
51
52
# File 'lib/capybara/lightpanda/binary.rb', line 50

def proxy_user
  @proxy_user
end

.required_versionObject

Set a specific release tag (e.g. "0.3.0") to pin downloads to that release. When nil, the rolling "nightly" tag is used. The pin only affects download URL construction — the gem's MINIMUM_NIGHTLY_BUILD floor is still enforced at process start.



45
46
47
# File 'lib/capybara/lightpanda/binary.rb', line 45

def required_version
  @required_version
end

Class Method Details

.configure {|_self| ... } ⇒ Object

Yields:

  • (_self)

Yield Parameters:



67
68
69
# File 'lib/capybara/lightpanda/binary.rb', line 67

def configure
  yield self
end

.current_versionObject

Returns the lightpanda version output of the cached binary, or nil if the binary isn't present / not runnable.



147
148
149
150
151
152
153
154
155
# File 'lib/capybara/lightpanda/binary.rb', line 147

def current_version
  path = install_path
  return nil unless File.executable?(path)

  stdout, _, status = Open3.capture3(path, "version")
  status.success? ? stdout.strip : nil
rescue Errno::ENOENT
  nil
end

.default_binary_pathObject



245
246
247
248
249
# File 'lib/capybara/lightpanda/binary.rb', line 245

def default_binary_path
  cache_dir = ENV.fetch("XDG_CACHE_HOME") { File.expand_path("~/.cache") }

  File.join(cache_dir, "lightpanda", "lightpanda")
end

.downloadObject

Downloads into a sibling temp file and renames it into place only once the transfer finished.

Writing straight to destination corrupted a working binary on any interrupted transfer: File.open(_, "wb") truncates the existing file the moment the request starts, and truncation KEEPS the mode bits — so a dropped connection left a partial file that still answered File.executable? => true. #update's "fall back to the cached binary" rescue then handed that corpse back as if it were the usable stale binary, warning as though nothing was wrong. That is the common path, not an edge case: past cache_time, #update calls #download precisely when a good binary is already sitting at destination.

rename(2) within one directory is atomic, so a concurrent reader sees either the old binary or the new one, never a half-written one. The temp file is a sibling (not Dir.tmpdir) so the rename never crosses a filesystem, and chmod happens before it so the binary is never visible non-executable. Two racing downloads get distinct temp names and both rename a complete file — last writer wins, both are valid.



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/capybara/lightpanda/binary.rb', line 176

def download
  binary_name = platform_binary
  tag = required_version || "nightly"
  url = "#{release_url}/#{tag}/#{binary_name}"
  destination = install_path

  log("Downloading #{binary_name} (#{tag}) → #{destination}")
  FileUtils.mkdir_p(File.dirname(destination))

  # ::Process, not Process — Capybara::Lightpanda::Process would win.
  temp = "#{destination}.download-#{::Process.pid}"
  begin
    download_file(url, temp)
    FileUtils.chmod(0o755, temp)
    File.rename(temp, destination)
  ensure
    # No-op on success (rename consumed it). ensure, not rescue, so an
    # Interrupt mid-download cleans up too; rm_f ignores a missing file.
    FileUtils.rm_f(temp)
  end

  destination
end

.install_pathObject

Path the gem writes the downloaded binary to. Honors a user-configured install_dir; otherwise falls back to default_binary_path.

A pin gets its own filename (lightpanda-0.3.5) rather than sharing the rolling-nightly one. update only checks that a file EXISTS at this path, not which version it holds, so on a shared path a nightly left over from an earlier run would be accepted as "the pin" — setting required_version on any machine with a warm cache (every CI runner restoring a cache, every existing dev checkout) would silently keep running the nightly it was meant to replace. Version-scoping makes the pin self-verifying and lets several pins coexist in one cache dir.



262
263
264
265
266
267
# File 'lib/capybara/lightpanda/binary.rb', line 262

def install_path
  dir = @install_dir || File.dirname(default_binary_path)
  basename = required_version ? "lightpanda-#{required_version}" : "lightpanda"

  File.join(dir, basename)
end

.platform_binaryObject



238
239
240
241
242
243
# File 'lib/capybara/lightpanda/binary.rb', line 238

def platform_binary
  arch = normalize_arch(RbConfig::CONFIG["host_cpu"])
  os = normalize_os(RbConfig::CONFIG["host_os"])

  PLATFORMS[[arch, os]] || raise(UnsupportedPlatformError, "Unsupported platform: #{arch}-#{os}")
end

.removeObject

Delete the cached binary. Returns the path that was deleted, or nil if nothing was there.



133
134
135
136
137
138
139
140
141
142
143
# File 'lib/capybara/lightpanda/binary.rb', line 133

def remove
  path = install_path
  unless File.exist?(path)
    log("Nothing to remove at #{path}")
    return nil
  end

  File.delete(path)
  log("Removed #{path}")
  path
end

.updateObject

Canonical entrypoint: ensure the binary at install_path is current, download if needed, return its path. Pinned (required_version set) never re-downloads when present. Unpinned re-downloads when older than cache_time. When unpinned and the gem cache is empty/stale, an already-installed lightpanda on PATH (e.g. via Homebrew) wins over re-downloading — keeps test suites running under VCR/WebMock from triggering surprise HTTP to github.com.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/capybara/lightpanda/binary.rb', line 78

def update
  destination = install_path

  if required_version
    if File.executable?(destination)
      log("Pinned #{required_version} present at #{destination}")
      return destination
    end
    return download
  end

  if cached_fresh?(destination)
    log("Cached binary at #{destination} is fresh (< #{cache_time}s)")
    return destination
  end

  if (system_path = system_binary_path)
    log("Using lightpanda from PATH at #{system_path}")
    return system_path
  end

  # Stale-or-absent cache, nothing on PATH: refresh from the network.
  # If that fails (GitHub 5xx, DNS/connect timeouts, SocketError) but a
  # usable — if stale — binary is already cached, keep using it rather
  # than hard-failing. A cold cache (nothing on disk) still surfaces the
  # error. The MINIMUM_NIGHTLY_BUILD floor is enforced downstream in
  # Process#start, so a sub-floor binary can't slip in.
  #
  # Deliberately StandardError, not Exception: WebMock's
  # NetConnectNotAllowedError descends from Exception so it propagates
  # through app rescue blocks by design — a test suite that blocks net
  # connections SHOULD fail loudly here, not silently fall back. CI
  # pre-provisions the binary outside that guard instead (real-apps.yml).
  begin
    download
  rescue StandardError => e
    raise unless File.executable?(destination)

    # Kernel.warn, not log: log() is silent unless LIGHTPANDA_DEBUG or
    # an explicit logger is set, and this fallback is exactly the
    # moment the user needs to hear about — a VCR-guarded suite (whose
    # UnhandledHTTPRequestError is a StandardError, unlike raw
    # WebMock's Exception) lands here silently, keeps a stale binary,
    # and later hits a confusing MINIMUM_NIGHTLY_BUILD floor error
    # with no trace of the blocked download.
    warn("[capybara-lightpanda] Binary download failed (#{e.class}: #{e.message}); " \
         "falling back to the cached binary at #{destination}. " \
         "If your suite stubs HTTP (VCR/WebMock), pre-provision from an " \
         "unstubbed process: #{PROVISION_HINT}")
    destination
  end
end

.update_hint(binary_path) ⇒ Object

Build a path-appropriate "how to update" command for Process's too-old-binary error. Three branches:

  • Symlink into a /Cellar/ directory → installed via Homebrew; suggest brew update && brew upgrade lightpanda (brew pins each user's binary at install time and doesn't refresh on its own when the tap publishes a newer nightly).
  • Path equals our own cache → suggest the require-the-gem one-liner. NOT the lightpanda:binary:* rake tasks: in a Rails app the gem usually sits in the :test Gemfile group, so the tasks only exist under RAILS_ENV=test (the Railtie can't help a plain bundle exec rake in development), and outside Rails they're never loaded at all. The one-liner requires the gem explicitly, so it works from any environment. The remove step is required because update honors cache_time and would otherwise no-op on a too-old-but-not-yet-expired file.
  • Path equals our cache AND a pin is set → the version is a deliberate choice, so tell the user to raise the pin. The re-provision one-liner would be a dead end here: it re-downloads the same pinned release and lands on the identical "too old" error.
  • Anything else (user-managed install at a custom path) → keep the curl-overwrite suggestion, since we don't know how the file got there.


223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/capybara/lightpanda/binary.rb', line 223

def update_hint(binary_path)
  if brew_managed?(binary_path)
    "brew update && brew upgrade lightpanda"
  elsif binary_path == install_path && required_version
    "Capybara::Lightpanda::Binary.required_version is pinned to " \
      "#{required_version} — raise the pin to a newer release, " \
      "or unset it to track the rolling nightly."
  elsif binary_path == install_path
    PROVISION_HINT
  else
    "curl -sL #{GITHUB_RELEASE_URL}/nightly/#{platform_binary} " \
      "-o #{binary_path} && chmod +x #{binary_path}"
  end
end