From ecef914ca6e1e2a388e9ed12821d33dec729956a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:17:52 +0900 Subject: [PATCH 01/25] Add Installer::Chrome and standardise driver_path:/browser_path: across all bridges. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Async::WebDriver::Installer::Chrome for automatic Chrome for Testing installation and management. Installer::Chrome.install(version) resolves the version via the Chrome for Testing JSON API, caches binaries under ~/.local/state/async-webdriver/ (XDG $XDG_STATE_HOME), and returns an Installation with browser_path and driver_path attributes. Version specifiers: :stable, :beta, :dev, :canary (channels), "148" (latest patch for major), or "148.0.7778.56" (exact version). The Installer:: namespace is structured to accommodate Installer::Firefox and other browsers in the future without structural changes. - Add Bridge::Chrome.for(version) as a convenience shorthand that installs if needed and returns a fully configured Chrome bridge. - Add Bridge::Chrome.install(version) for explicit pre-downloading in CI setup steps or bake tasks, before entering the Async reactor. - Rename path: to driver_path: and binary: to browser_path: on Bridge::Chrome, and rename path: to driver_path: on Bridge::Firefox and Bridge::Safari, for clarity and cross-bridge consistency. - Fix Bridge::Chrome#start, Bridge::Firefox#start, and Bridge::Safari#start not forwarding the bridge's own options (including :driver_path) to the Driver process — custom driver paths were silently ignored. --- lib/async/webdriver/bridge/chrome.rb | 63 ++++++-- lib/async/webdriver/bridge/firefox.rb | 12 +- lib/async/webdriver/bridge/safari.rb | 10 +- lib/async/webdriver/installer.rb | 19 +++ lib/async/webdriver/installer/chrome.rb | 68 +++++++++ .../installer/chrome/installation.rb | 141 ++++++++++++++++++ .../webdriver/installer/chrome/platform.rb | 68 +++++++++ .../webdriver/installer/chrome/releases.rb | 112 ++++++++++++++ releases.md | 8 + 9 files changed, 480 insertions(+), 21 deletions(-) create mode 100644 lib/async/webdriver/installer.rb create mode 100644 lib/async/webdriver/installer/chrome.rb create mode 100644 lib/async/webdriver/installer/chrome/installation.rb create mode 100644 lib/async/webdriver/installer/chrome/platform.rb create mode 100644 lib/async/webdriver/installer/chrome/releases.rb diff --git a/lib/async/webdriver/bridge/chrome.rb b/lib/async/webdriver/bridge/chrome.rb index ffe5449..4afb953 100644 --- a/lib/async/webdriver/bridge/chrome.rb +++ b/lib/async/webdriver/bridge/chrome.rb @@ -21,13 +21,13 @@ module Bridge # ``` class Chrome < Generic # @returns [String] The path to the `chromedriver` executable. - def path - @options.fetch(:path, "chromedriver") + def driver_path + @options.fetch(:driver_path, "chromedriver") end # @returns [String] The version of the `chromedriver` executable. def version - ::IO.popen([self.path, "--version"]) do |io| + ::IO.popen([self.driver_path, "--version"]) do |io| return io.read end rescue Errno::ENOENT @@ -46,7 +46,7 @@ def initialize(**options) # @returns [Array(String)] The arguments to pass to the `chromedriver` executable. def arguments(**options) [ - options.fetch(:path, "chromedriver"), + options.fetch(:driver_path, "chromedriver"), "--port=#{self.port}", ].compact end @@ -69,21 +69,64 @@ def close end end - # Start the driver. + # Start the driver, forwarding the bridge's own options to the driver process + # so that a custom `:driver_path` reaches the chromedriver executable. def start(**options) - Driver.new(**options).tap(&:start) + Driver.new(**@options, **options).tap(&:start) + end + + # Ensure the given version of Chrome for Testing is installed and return a + # fully configured {Chrome} bridge pointing at it. + # + # Delegates to {Async::WebDriver::Installer::Chrome.install} for version + # resolution and download, then wraps the result in a configured bridge. + # + # @parameter version [Symbol | String] `:stable`, `:beta`, `:dev`, `:canary`, + # a major version string like `"148"`, or an exact version like `"148.0.7778.56"`. + # @parameter state [String] Root of the state directory. + # Default: `~/.local/state/async-webdriver` (XDG-compliant). + # @parameter options [Hash] Additional options forwarded to {.new} (e.g. `headless: false`). + # @returns [Chrome] A configured bridge. + def self.for(version = :stable, state: Installer::Chrome::DEFAULT_STATE, **options) + require_relative "../installer/chrome" + installation = Installer::Chrome.install(version, state: state) + new(driver_path: installation.driver_path, browser_path: installation.browser_path, **options) + end + + # Download and install a specific version of Chrome for Testing if not already present. + # + # Useful in CI setup steps or bake tasks that want to pre-download before + # entering the Async reactor. + # + # @parameter version [Symbol | String] Version specifier — see {.for}. + # @parameter state [String] Root of the state directory. + # @returns [Installer::Chrome::Installation] The installation details. + def self.install(version = :stable, state: Installer::Chrome::DEFAULT_STATE) + require_relative "../installer/chrome" + Installer::Chrome.install(version, state: state) + end + + # The path to the Chrome browser executable. If `nil`, ChromeDriver uses its own discovery. + # @returns [String | Nil] + def browser_path + @options[:browser_path] end # The default capabilities for the Chrome browser which need to be provided when requesting a new session. # @parameter headless [Boolean] Whether to run the browser in headless mode. + # @parameter browser_path [String | Nil] Path to the Chrome browser executable. Overrides ChromeDriver's default discovery, useful for pointing at a specific Chrome for Testing installation. # @returns [Hash] The default capabilities for the Chrome browser. - def default_capabilities(headless: self.headless?) + def default_capabilities(headless: self.headless?, browser_path: self.browser_path) + chrome_options = { + args: [headless ? "--headless=new" : nil].compact, + } + + chrome_options[:binary] = browser_path if browser_path + { alwaysMatch: { browserName: "chrome", - "goog:chromeOptions": { - args: [headless ? "--headless=new" : nil].compact, - }, + "goog:chromeOptions": chrome_options, webSocketUrl: true, }, } diff --git a/lib/async/webdriver/bridge/firefox.rb b/lib/async/webdriver/bridge/firefox.rb index 1033833..7e220f0 100644 --- a/lib/async/webdriver/bridge/firefox.rb +++ b/lib/async/webdriver/bridge/firefox.rb @@ -20,13 +20,13 @@ module Bridge # end class Firefox < Generic # @returns [String] The path to the `geckodriver` executable. - def path - @options.fetch(:path, "geckodriver") + def driver_path + @options.fetch(:driver_path, "geckodriver") end # @returns [String] The version of the `geckodriver` executable. def version - ::IO.popen([self.path, "--version"]) do |io| + ::IO.popen([self.driver_path, "--version"]) do |io| return io.read end rescue Errno::ENOENT @@ -47,10 +47,10 @@ def concurrency 1 end - # @returns [Array(String)] The arguments to pass to the `chromedriver` executable. + # @returns [Array(String)] The arguments to pass to the `geckodriver` executable. def arguments(**options) [ - options.fetch(:path, "geckodriver"), + options.fetch(:driver_path, "geckodriver"), "--port", self.port.to_s, ].compact end @@ -75,7 +75,7 @@ def close # Start the driver. def start(**options) - Driver.new(**options).tap(&:start) + Driver.new(**@options, **options).tap(&:start) end # The default capabilities for the Firefox browser which need to be provided when requesting a new session. diff --git a/lib/async/webdriver/bridge/safari.rb b/lib/async/webdriver/bridge/safari.rb index 1da9a57..6589480 100644 --- a/lib/async/webdriver/bridge/safari.rb +++ b/lib/async/webdriver/bridge/safari.rb @@ -21,13 +21,13 @@ module Bridge # ``` class Safari < Generic # @returns [String] The path to the `safaridriver` executable. - def path - @options.fetch(:path, "safaridriver") + def driver_path + @options.fetch(:driver_path, "safaridriver") end # @returns [String] The version of the `safaridriver` executable. def version - ::IO.popen([self.path, "--version"]) do |io| + ::IO.popen([self.driver_path, "--version"]) do |io| return io.read end rescue Errno::ENOENT @@ -46,7 +46,7 @@ def initialize(**options) # @returns [Array(String)] The arguments to pass to the `safaridriver` executable. def arguments(**options) [ - options.fetch(:path, "safaridriver"), + options.fetch(:driver_path, "safaridriver"), "--port=#{self.port}", ].compact end @@ -71,7 +71,7 @@ def close # Start the driver. def start(**options) - Driver.new(**options).tap(&:start) + Driver.new(**@options, **options).tap(&:start) end # The default capabilities for the Safari browser which need to be provided when requesting a new session. diff --git a/lib/async/webdriver/installer.rb b/lib/async/webdriver/installer.rb new file mode 100644 index 0000000..54e66c0 --- /dev/null +++ b/lib/async/webdriver/installer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2025, by Samuel Williams. + +require_relative "installer/chrome" + +module Async + module WebDriver + # Browser installation and management for automated testing. + # + # Each browser has its own sub-module with browser-specific platform detection, + # version resolution, and download logic: + # + # - {Installer::Chrome} — Chrome for Testing, via the Chrome for Testing JSON API. + module Installer + end + end +end diff --git a/lib/async/webdriver/installer/chrome.rb b/lib/async/webdriver/installer/chrome.rb new file mode 100644 index 0000000..18c5c27 --- /dev/null +++ b/lib/async/webdriver/installer/chrome.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2025, by Samuel Williams. + +require_relative "chrome/platform" +require_relative "chrome/releases" +require_relative "chrome/installation" + +module Async + module WebDriver + module Installer + # Installer for Chrome for Testing, the purpose-built Chrome variant + # designed for automated testing. + # + # Versions can be specified as: + # - A channel symbol: `:stable`, `:beta`, `:dev`, `:canary` + # - A major version string: `"148"` (resolves to the latest patch) + # - An exact version string: `"148.0.7778.56"` + # + # Installations are cached in `~/.local/state/async-webdriver/` by default + # (respects `$XDG_STATE_HOME`). + # + # ## Example + # + # ``` ruby + # installation = Async::WebDriver::Installer::Chrome.install(:stable) + # bridge = Async::WebDriver::Bridge::Chrome.new( + # driver_path: installation.driver_path, + # browser_path: installation.browser_path, + # ) + # ``` + # + # Or via the convenience shorthand on the bridge: + # + # ``` ruby + # bridge = Async::WebDriver::Bridge::Chrome.for(:stable) + # ``` + module Chrome + # Default state directory, following the XDG Base Directory Specification. + DEFAULT_STATE = File.expand_path( + File.join(ENV.fetch("XDG_STATE_HOME", "~/.local/state"), "async-webdriver") + ).freeze + + # Ensure the given version is installed and return an {Installation}. + # + # Checks the local cache first; downloads from the Chrome for Testing + # infrastructure only when the version is not already present. + # + # @parameter version [Symbol | String] Version specifier. + # @parameter state [String] Root of the state directory. + # @returns [Installation] + def self.install(version = :stable, state: DEFAULT_STATE) + Installation.install(version, state: state) + end + + # Find an already-installed version without hitting the network. + # + # @parameter version [String] Exact version string, e.g. `"148.0.7778.56"`. + # @parameter state [String] Root of the state directory. + # @returns [Installation | Nil] + def self.find(version, state: DEFAULT_STATE) + Installation.find(version, Platform.current, state: state) + end + end + end + end +end diff --git a/lib/async/webdriver/installer/chrome/installation.rb b/lib/async/webdriver/installer/chrome/installation.rb new file mode 100644 index 0000000..87cc284 --- /dev/null +++ b/lib/async/webdriver/installer/chrome/installation.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2025, by Samuel Williams. + +require "fileutils" +require "tempfile" + +module Async + module WebDriver + module Installer + module Chrome + # Represents a Chrome for Testing installation on disk, and provides class-level + # methods for resolving, locating, and downloading installations. + # + # Installations are stored under the state directory, organised as: + # + # {state}/{platform}/{version}/ + # chrome/ ← extracted chrome zip contents + # chromedriver/ ← extracted chromedriver zip contents + class Installation + # Look up an existing installation, or download and install a fresh one. + # + # @parameter version [Symbol | String] Channel or version specifier — see {Async::WebDriver::Bridge::Chrome.for}. + # @parameter state [String] Root of the state directory. + # @returns [Installation] + def self.install(version, state:) + require_relative "platform" + require_relative "releases" + + platform = Platform.current + info = Releases.resolve(version, platform) + + existing = find(info[:version], platform, state: state) + return existing if existing + + Console.info(self, "Installing Chrome for Testing #{info[:version]}...", platform: platform) + + dir = installation_dir(info[:version], platform, state: state) + FileUtils.mkdir_p(dir) + + begin + download_and_extract(info[:chrome_url], File.join(dir, "chrome")) + download_and_extract(info[:chromedriver_url], File.join(dir, "chromedriver")) + + installation = find(info[:version], platform, state: state) or + raise "Installation failed: binaries not found after extraction" + + Console.info(self, "Installed Chrome for Testing #{info[:version]}.", platform: platform) + + installation + rescue + FileUtils.rm_rf(dir) + raise + end + end + + # Find an already-installed version, without hitting the network. + # + # @parameter version [String] Exact version, e.g. `"148.0.7778.56"`. + # @parameter platform [String] Platform string, e.g. `"mac-arm64"`. + # @parameter state [String] Root of the state directory. + # @returns [Installation | Nil] + def self.find(version, platform, state:) + require_relative "platform" + + dir = installation_dir(version, platform, state: state) + + browser_path = File.join(dir, "chrome", Platform.chrome_binary(platform)) + driver_path = File.join(dir, "chromedriver", Platform.chromedriver_binary(platform)) + + return nil unless File.exist?(browser_path) && File.exist?(driver_path) + + new( + browser_path: browser_path, + driver_path: driver_path, + version: version, + platform: platform, + ) + end + + # @parameter browser_path [String] Absolute path to the Chrome browser executable. + # @parameter driver_path [String] Absolute path to the chromedriver executable. + # @parameter version [String] Exact version string. + # @parameter platform [String] Platform string. + def initialize(browser_path:, driver_path:, version:, platform:) + @browser_path = browser_path + @driver_path = driver_path + @version = version + @platform = platform + end + + # @attribute [String] Absolute path to the Chrome browser executable. + attr :browser_path + + # @attribute [String] Absolute path to the chromedriver executable. + attr :driver_path + + # @attribute [String] Exact installed version, e.g. `"148.0.7778.56"`. + attr :version + + # @attribute [String] Platform, e.g. `"mac-arm64"`. + attr :platform + + private_class_method def self.installation_dir(version, platform, state:) + File.join(state, platform, version) + end + + private_class_method def self.download_and_extract(url, dest) + require "async/http/internet" + + Tempfile.create(["async-webdriver-", ".zip"]) do |tmp| + tmp.binmode + + Sync do + internet = Async::HTTP::Internet.new + begin + Console.debug(self, "Downloading...", url: url) + response = internet.get(url) + tmp.write(response.read) + tmp.flush + ensure + internet.close + end + end + + FileUtils.mkdir_p(dest) + system("unzip", "-q", "-o", tmp.path, "-d", dest) or + raise "Failed to extract #{url}" + + # Remove macOS quarantine attributes added to files downloaded via code. + if RUBY_PLATFORM.include?("darwin") + system("xattr", "-r", "-d", "com.apple.quarantine", dest) + end + end + end + end + end + end + end +end diff --git a/lib/async/webdriver/installer/chrome/platform.rb b/lib/async/webdriver/installer/chrome/platform.rb new file mode 100644 index 0000000..9aa1b74 --- /dev/null +++ b/lib/async/webdriver/installer/chrome/platform.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2025, by Samuel Williams. + +module Async + module WebDriver + module Installer + module Chrome + # Platform detection for Chrome for Testing downloads. + # + # Maps Ruby's `RUBY_PLATFORM` to the platform strings used by the + # Chrome for Testing JSON API and zip file naming conventions. + module Platform + # Ordered list of (pattern, platform) pairs. First match wins. + PLATFORM_MAP = [ + [/arm.*darwin|darwin.*arm|aarch64.*darwin|darwin.*aarch64/, "mac-arm64"], + [/darwin/, "mac-x64"], + [/aarch64.*linux|linux.*aarch64/, "linux-arm64"], + [/linux/, "linux64"], + [/x64.*mingw|mingw.*x64/, "win64"], + [/mingw/, "win32"], + ].freeze + + # Detect the current platform. + # @returns [String] e.g. `"mac-arm64"`, `"linux64"`. + # @raises [RuntimeError] If the platform is not recognised. + def self.current + PLATFORM_MAP.each do |pattern, platform| + return platform if RUBY_PLATFORM.match?(pattern) + end + raise "Unsupported platform: #{RUBY_PLATFORM}" + end + + # Relative path to the Chrome binary inside the extracted chrome zip. + # @parameter platform [String] + # @returns [String] + def self.chrome_binary(platform) + case platform + when "mac-arm64" then "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" + when "mac-x64" then "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" + when "linux64" then "chrome-linux64/chrome" + when "linux-arm64" then "chrome-linux-arm64/chrome" + when "win64" then "chrome-win64/chrome.exe" + when "win32" then "chrome-win32/chrome.exe" + else raise "Unknown platform: #{platform}" + end + end + + # Relative path to the chromedriver binary inside the extracted chromedriver zip. + # @parameter platform [String] + # @returns [String] + def self.chromedriver_binary(platform) + case platform + when "mac-arm64" then "chromedriver-mac-arm64/chromedriver" + when "mac-x64" then "chromedriver-mac-x64/chromedriver" + when "linux64" then "chromedriver-linux64/chromedriver" + when "linux-arm64" then "chromedriver-linux-arm64/chromedriver" + when "win64" then "chromedriver-win64/chromedriver.exe" + when "win32" then "chromedriver-win32/chromedriver.exe" + else raise "Unknown platform: #{platform}" + end + end + end + end + end + end +end diff --git a/lib/async/webdriver/installer/chrome/releases.rb b/lib/async/webdriver/installer/chrome/releases.rb new file mode 100644 index 0000000..a1dea85 --- /dev/null +++ b/lib/async/webdriver/installer/chrome/releases.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2025, by Samuel Williams. + +require "json" + +module Async + module WebDriver + module Installer + module Chrome + # Resolves Chrome for Testing version specifiers and download URLs using the + # public Chrome for Testing JSON API. + module Releases + # Returns the latest known-good version for each release channel. + CHANNELS_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json" + + # Returns every known-good version with its download URLs. + VERSIONS_URL = "https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json" + + # Maps symbolic channel names to the API's title-case keys. + CHANNELS = { + stable: "Stable", + beta: "Beta", + dev: "Dev", + canary: "Canary", + }.freeze + + # Resolve a version specifier and platform to a version string and download URLs. + # + # @parameter version [Symbol | String] `:stable`, `:beta`, `:dev`, `:canary`, + # a major version string like `"148"`, or an exact version like `"148.0.7778.56"`. + # @parameter platform [String] A Chrome for Testing platform string, e.g. `"mac-arm64"`. + # @returns [Hash] `{ version:, chrome_url:, chromedriver_url: }` + def self.resolve(version, platform) + case version + when Symbol then resolve_channel(version, platform) + when /\A\d+\z/ then resolve_major(version, platform) + else resolve_exact(version, platform) + end + end + + private + + def self.fetch_json(url) + require "async/http/internet" + + Sync do + internet = Async::HTTP::Internet.new + begin + response = internet.get(url) + JSON.parse(response.read) + ensure + internet.close + end + end + end + + def self.resolve_channel(channel, platform) + key = CHANNELS.fetch(channel) do + raise ArgumentError, "Unknown channel #{channel.inspect}. Expected one of: #{CHANNELS.keys.inspect}" + end + + data = fetch_json(CHANNELS_URL) + entry = data.dig("channels", key) or raise "Channel #{key} not found in API response" + + extract(entry, platform) + end + + def self.resolve_major(major, platform) + data = fetch_json(VERSIONS_URL) + + entry = data["versions"] + .select { |v| v["version"].start_with?("#{major}.") } + .max_by { |v| Gem::Version.new(v["version"]) } + + raise "No version found for major version #{major}" unless entry + + extract(entry, platform) + end + + def self.resolve_exact(version, platform) + data = fetch_json(VERSIONS_URL) + + entry = data["versions"].find { |v| v["version"] == version } + raise "Version #{version} not found" unless entry + + extract(entry, platform) + end + + def self.extract(entry, platform) + version = entry["version"] + downloads = entry["downloads"] + + chrome_url = downloads["chrome"] + &.find { |d| d["platform"] == platform } + &.dig("url") + + chromedriver_url = downloads["chromedriver"] + &.find { |d| d["platform"] == platform } + &.dig("url") + + raise "No Chrome download for platform #{platform} in version #{version}" unless chrome_url + raise "No ChromeDriver download for platform #{platform} in version #{version}" unless chromedriver_url + + {version: version, chrome_url: chrome_url, chromedriver_url: chromedriver_url} + end + end + end + end + end +end diff --git a/releases.md b/releases.md index 3b89f9e..30777de 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,13 @@ # Releases +## Unreleased + + - Add `Async::WebDriver::Installer::Chrome` for automatic Chrome for Testing installation and management. `Installer::Chrome.install(version)` resolves the version via the Chrome for Testing JSON API, caches binaries in `~/.local/state/async-webdriver/` (XDG `$XDG_STATE_HOME`), and returns an `Installation` with paths to both the Chrome and ChromeDriver binaries. The namespace is designed to accommodate additional browsers (e.g. `Installer::Firefox`) in the future. + - Add `Bridge::Chrome.for(version)` as a convenience shorthand: installs the requested version if needed, then returns a fully configured `Chrome` bridge. Versions can be a channel symbol (`:stable`, `:beta`, `:dev`, `:canary`), a major version string (`"148"`), or an exact version string (`"148.0.7778.56"`). + - Add `Bridge::Chrome.install(version)` for pre-downloading in CI setup steps or bake tasks, before entering the Async reactor. + - Fix `Bridge::Chrome#start`, `Bridge::Firefox#start`, and `Bridge::Safari#start` not forwarding the bridge's own options (including `:driver_path`) to the driver process. + - Rename `path:` to `driver_path:` on `Bridge::Chrome`, `Bridge::Firefox`, and `Bridge::Safari` for consistency. Add `browser_path:` to `Bridge::Chrome` (mapped to `goog:chromeOptions.binary`) in place of the former `binary:` option, consistent with `Installer::Chrome::Installation#browser_path` and `#driver_path`. + ## v0.11.0 - Add `Scope::Window` with `#window_rect`, `#resize_window`, `#set_window_rect`, `#maximize_window`, `#minimize_window`, and `#fullscreen_window`. From 2a5171d410c38b565c9d255b21a76beab8fb6c42 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:25:51 +0900 Subject: [PATCH 02/25] Add bake async:webdriver:chrome:install task. --- bake/async/webdriver/chrome.rb | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 bake/async/webdriver/chrome.rb diff --git a/bake/async/webdriver/chrome.rb b/bake/async/webdriver/chrome.rb new file mode 100644 index 0000000..4863786 --- /dev/null +++ b/bake/async/webdriver/chrome.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025-2026, by Samuel Williams. + +# Install Chrome for Testing and its matching ChromeDriver. +# +# Downloads the requested version from the Chrome for Testing infrastructure +# and caches it in `~/.local/state/async-webdriver/` (XDG `$XDG_STATE_HOME`). +# Subsequent calls with the same version are a no-op. +# +# @parameter version [String] The version to install: a channel (`stable`, `beta`, `dev`, `canary`), +# a major version (e.g. `148`), or an exact version (e.g. `148.0.7778.56`). Default: `stable`. +def install(version: "stable") + require "async/webdriver/installer/chrome" + + version = version.to_sym if %w[stable beta dev canary].include?(version) + + installation = Async::WebDriver::Installer::Chrome.install(version) + + Console.info(self, "Chrome for Testing is ready.", + version: installation.version, + platform: installation.platform, + browser_path: installation.browser_path, + driver_path: installation.driver_path, + ) +end From fa4b3074d317ddfba09e8beb9146162bd41ceb71 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:26:02 +0900 Subject: [PATCH 03/25] Update release notes with bake task. --- releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/releases.md b/releases.md index 30777de..c2bb56c 100644 --- a/releases.md +++ b/releases.md @@ -5,6 +5,7 @@ - Add `Async::WebDriver::Installer::Chrome` for automatic Chrome for Testing installation and management. `Installer::Chrome.install(version)` resolves the version via the Chrome for Testing JSON API, caches binaries in `~/.local/state/async-webdriver/` (XDG `$XDG_STATE_HOME`), and returns an `Installation` with paths to both the Chrome and ChromeDriver binaries. The namespace is designed to accommodate additional browsers (e.g. `Installer::Firefox`) in the future. - Add `Bridge::Chrome.for(version)` as a convenience shorthand: installs the requested version if needed, then returns a fully configured `Chrome` bridge. Versions can be a channel symbol (`:stable`, `:beta`, `:dev`, `:canary`), a major version string (`"148"`), or an exact version string (`"148.0.7778.56"`). - Add `Bridge::Chrome.install(version)` for pre-downloading in CI setup steps or bake tasks, before entering the Async reactor. + - Add `bake async:webdriver:chrome:install` task for installing Chrome for Testing from the command line, e.g. in CI setup steps. - Fix `Bridge::Chrome#start`, `Bridge::Firefox#start`, and `Bridge::Safari#start` not forwarding the bridge's own options (including `:driver_path`) to the driver process. - Rename `path:` to `driver_path:` on `Bridge::Chrome`, `Bridge::Firefox`, and `Bridge::Safari` for consistency. Add `browser_path:` to `Bridge::Chrome` (mapped to `goog:chromeOptions.binary`) in place of the former `binary:` option, consistent with `Installer::Chrome::Installation#browser_path` and `#driver_path`. From a3cda94c842afb229be38a79fa9e116e7015212d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:29:05 +0900 Subject: [PATCH 04/25] Handle channel strings in Releases.resolve; remove conversion from bake task. --- bake/async/webdriver/chrome.rb | 2 -- lib/async/webdriver/installer/chrome/releases.rb | 7 ++++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/bake/async/webdriver/chrome.rb b/bake/async/webdriver/chrome.rb index 4863786..e4f5175 100644 --- a/bake/async/webdriver/chrome.rb +++ b/bake/async/webdriver/chrome.rb @@ -14,8 +14,6 @@ def install(version: "stable") require "async/webdriver/installer/chrome" - version = version.to_sym if %w[stable beta dev canary].include?(version) - installation = Async::WebDriver::Installer::Chrome.install(version) Console.info(self, "Chrome for Testing is ready.", diff --git a/lib/async/webdriver/installer/chrome/releases.rb b/lib/async/webdriver/installer/chrome/releases.rb index a1dea85..4a61b0e 100644 --- a/lib/async/webdriver/installer/chrome/releases.rb +++ b/lib/async/webdriver/installer/chrome/releases.rb @@ -34,9 +34,10 @@ module Releases # @returns [Hash] `{ version:, chrome_url:, chromedriver_url: }` def self.resolve(version, platform) case version - when Symbol then resolve_channel(version, platform) - when /\A\d+\z/ then resolve_major(version, platform) - else resolve_exact(version, platform) + when Symbol then resolve_channel(version, platform) + when /\A(stable|beta|dev|canary)\z/ then resolve_channel(version.to_sym, platform) + when /\A\d+\z/ then resolve_major(version, platform) + else resolve_exact(version, platform) end end From 48855dd571c2c7b0733b7620a92c33069990d219 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:30:00 +0900 Subject: [PATCH 05/25] Return installation from bake task. --- bake/async/webdriver/chrome.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bake/async/webdriver/chrome.rb b/bake/async/webdriver/chrome.rb index e4f5175..05afc94 100644 --- a/bake/async/webdriver/chrome.rb +++ b/bake/async/webdriver/chrome.rb @@ -22,4 +22,6 @@ def install(version: "stable") browser_path: installation.browser_path, driver_path: installation.driver_path, ) + + return installation end From d3fb33cb64daaa7b91297e5af29c3009c77b2206 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:32:15 +0900 Subject: [PATCH 06/25] Move requires to top of installation.rb. --- lib/async/webdriver/installer/chrome/installation.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/async/webdriver/installer/chrome/installation.rb b/lib/async/webdriver/installer/chrome/installation.rb index 87cc284..f9a9fe7 100644 --- a/lib/async/webdriver/installer/chrome/installation.rb +++ b/lib/async/webdriver/installer/chrome/installation.rb @@ -5,6 +5,8 @@ require "fileutils" require "tempfile" +require_relative "platform" +require_relative "releases" module Async module WebDriver @@ -25,8 +27,6 @@ class Installation # @parameter state [String] Root of the state directory. # @returns [Installation] def self.install(version, state:) - require_relative "platform" - require_relative "releases" platform = Platform.current info = Releases.resolve(version, platform) @@ -62,7 +62,6 @@ def self.install(version, state:) # @parameter state [String] Root of the state directory. # @returns [Installation | Nil] def self.find(version, platform, state:) - require_relative "platform" dir = installation_dir(version, platform, state: state) From 6ba174b25765179ff45357ae3f12b012ca6a46ca Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:34:28 +0900 Subject: [PATCH 07/25] Rename info to release; remove aligned = throughout installation.rb. --- .../installer/chrome/installation.rb | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/lib/async/webdriver/installer/chrome/installation.rb b/lib/async/webdriver/installer/chrome/installation.rb index f9a9fe7..075e434 100644 --- a/lib/async/webdriver/installer/chrome/installation.rb +++ b/lib/async/webdriver/installer/chrome/installation.rb @@ -27,26 +27,25 @@ class Installation # @parameter state [String] Root of the state directory. # @returns [Installation] def self.install(version, state:) - platform = Platform.current - info = Releases.resolve(version, platform) + release = Releases.resolve(version, platform) - existing = find(info[:version], platform, state: state) + existing = find(release[:version], platform, state: state) return existing if existing - Console.info(self, "Installing Chrome for Testing #{info[:version]}...", platform: platform) + Console.info(self, "Installing Chrome for Testing #{release[:version]}...", platform: platform) - dir = installation_dir(info[:version], platform, state: state) + dir = installation_dir(release[:version], platform, state: state) FileUtils.mkdir_p(dir) begin - download_and_extract(info[:chrome_url], File.join(dir, "chrome")) - download_and_extract(info[:chromedriver_url], File.join(dir, "chromedriver")) + download_and_extract(release[:chrome_url], File.join(dir, "chrome")) + download_and_extract(release[:chromedriver_url], File.join(dir, "chromedriver")) - installation = find(info[:version], platform, state: state) or + installation = find(release[:version], platform, state: state) or raise "Installation failed: binaries not found after extraction" - Console.info(self, "Installed Chrome for Testing #{info[:version]}.", platform: platform) + Console.info(self, "Installed Chrome for Testing #{release[:version]}.", platform: platform) installation rescue @@ -62,19 +61,18 @@ def self.install(version, state:) # @parameter state [String] Root of the state directory. # @returns [Installation | Nil] def self.find(version, platform, state:) - dir = installation_dir(version, platform, state: state) - browser_path = File.join(dir, "chrome", Platform.chrome_binary(platform)) - driver_path = File.join(dir, "chromedriver", Platform.chromedriver_binary(platform)) + browser_path = File.join(dir, "chrome", Platform.chrome_binary(platform)) + driver_path = File.join(dir, "chromedriver", Platform.chromedriver_binary(platform)) return nil unless File.exist?(browser_path) && File.exist?(driver_path) new( browser_path: browser_path, - driver_path: driver_path, - version: version, - platform: platform, + driver_path: driver_path, + version: version, + platform: platform, ) end @@ -84,9 +82,9 @@ def self.find(version, platform, state:) # @parameter platform [String] Platform string. def initialize(browser_path:, driver_path:, version:, platform:) @browser_path = browser_path - @driver_path = driver_path - @version = version - @platform = platform + @driver_path = driver_path + @version = version + @platform = platform end # @attribute [String] Absolute path to the Chrome browser executable. From dfe8e0fe46b8f73ba6553bfea2ee49accbd2ba96 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:36:18 +0900 Subject: [PATCH 08/25] Restructure install to use unless/assign pattern with single return. --- .../installer/chrome/installation.rb | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/lib/async/webdriver/installer/chrome/installation.rb b/lib/async/webdriver/installer/chrome/installation.rb index 075e434..87138ef 100644 --- a/lib/async/webdriver/installer/chrome/installation.rb +++ b/lib/async/webdriver/installer/chrome/installation.rb @@ -30,28 +30,27 @@ def self.install(version, state:) platform = Platform.current release = Releases.resolve(version, platform) - existing = find(release[:version], platform, state: state) - return existing if existing - - Console.info(self, "Installing Chrome for Testing #{release[:version]}...", platform: platform) - - dir = installation_dir(release[:version], platform, state: state) - FileUtils.mkdir_p(dir) - - begin - download_and_extract(release[:chrome_url], File.join(dir, "chrome")) - download_and_extract(release[:chromedriver_url], File.join(dir, "chromedriver")) + unless installation = find(release[:version], platform, state: state) + Console.info(self, "Installing Chrome for Testing #{release[:version]}...", platform: platform) - installation = find(release[:version], platform, state: state) or - raise "Installation failed: binaries not found after extraction" + dir = installation_dir(release[:version], platform, state: state) + FileUtils.mkdir_p(dir) - Console.info(self, "Installed Chrome for Testing #{release[:version]}.", platform: platform) - - installation - rescue - FileUtils.rm_rf(dir) - raise + begin + download_and_extract(release[:chrome_url], File.join(dir, "chrome")) + download_and_extract(release[:chromedriver_url], File.join(dir, "chromedriver")) + + installation = find(release[:version], platform, state: state) or + raise "Installation failed: binaries not found after extraction" + + Console.info(self, "Installed Chrome for Testing #{release[:version]}.", platform: platform) + rescue + FileUtils.rm_rf(dir) + raise + end end + + return installation end # Find an already-installed version, without hitting the network. From bd79e4081f5ef2ada59fc51eba48682c19487965 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:37:55 +0900 Subject: [PATCH 09/25] Remove aligned keyword arguments in bake task. --- bake/async/webdriver/chrome.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bake/async/webdriver/chrome.rb b/bake/async/webdriver/chrome.rb index 05afc94..b4bff2e 100644 --- a/bake/async/webdriver/chrome.rb +++ b/bake/async/webdriver/chrome.rb @@ -17,10 +17,10 @@ def install(version: "stable") installation = Async::WebDriver::Installer::Chrome.install(version) Console.info(self, "Chrome for Testing is ready.", - version: installation.version, - platform: installation.platform, + version: installation.version, + platform: installation.platform, browser_path: installation.browser_path, - driver_path: installation.driver_path, + driver_path: installation.driver_path, ) return installation From 28f308242c4274cd488e9b2c1689479f6d8f17a8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:39:33 +0900 Subject: [PATCH 10/25] Remove aligned columns in PLATFORM_MAP. --- lib/async/webdriver/installer/chrome/platform.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/async/webdriver/installer/chrome/platform.rb b/lib/async/webdriver/installer/chrome/platform.rb index 9aa1b74..d4042da 100644 --- a/lib/async/webdriver/installer/chrome/platform.rb +++ b/lib/async/webdriver/installer/chrome/platform.rb @@ -15,11 +15,11 @@ module Platform # Ordered list of (pattern, platform) pairs. First match wins. PLATFORM_MAP = [ [/arm.*darwin|darwin.*arm|aarch64.*darwin|darwin.*aarch64/, "mac-arm64"], - [/darwin/, "mac-x64"], - [/aarch64.*linux|linux.*aarch64/, "linux-arm64"], - [/linux/, "linux64"], - [/x64.*mingw|mingw.*x64/, "win64"], - [/mingw/, "win32"], + [/darwin/, "mac-x64"], + [/aarch64.*linux|linux.*aarch64/, "linux-arm64"], + [/linux/, "linux64"], + [/x64.*mingw|mingw.*x64/, "win64"], + [/mingw/, "win32"], ].freeze # Detect the current platform. From 3483db857eb5073ca52306609cea5a60acc8d8be Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:40:31 +0900 Subject: [PATCH 11/25] Expand case/when branches to multi-line in platform.rb. --- .../webdriver/installer/chrome/platform.rb | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/lib/async/webdriver/installer/chrome/platform.rb b/lib/async/webdriver/installer/chrome/platform.rb index d4042da..89b634b 100644 --- a/lib/async/webdriver/installer/chrome/platform.rb +++ b/lib/async/webdriver/installer/chrome/platform.rb @@ -37,13 +37,20 @@ def self.current # @returns [String] def self.chrome_binary(platform) case platform - when "mac-arm64" then "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" - when "mac-x64" then "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" - when "linux64" then "chrome-linux64/chrome" - when "linux-arm64" then "chrome-linux-arm64/chrome" - when "win64" then "chrome-win64/chrome.exe" - when "win32" then "chrome-win32/chrome.exe" - else raise "Unknown platform: #{platform}" + when "mac-arm64" + "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" + when "mac-x64" + "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" + when "linux64" + "chrome-linux64/chrome" + when "linux-arm64" + "chrome-linux-arm64/chrome" + when "win64" + "chrome-win64/chrome.exe" + when "win32" + "chrome-win32/chrome.exe" + else + raise "Unknown platform: #{platform}" end end @@ -52,13 +59,20 @@ def self.chrome_binary(platform) # @returns [String] def self.chromedriver_binary(platform) case platform - when "mac-arm64" then "chromedriver-mac-arm64/chromedriver" - when "mac-x64" then "chromedriver-mac-x64/chromedriver" - when "linux64" then "chromedriver-linux64/chromedriver" - when "linux-arm64" then "chromedriver-linux-arm64/chromedriver" - when "win64" then "chromedriver-win64/chromedriver.exe" - when "win32" then "chromedriver-win32/chromedriver.exe" - else raise "Unknown platform: #{platform}" + when "mac-arm64" + "chromedriver-mac-arm64/chromedriver" + when "mac-x64" + "chromedriver-mac-x64/chromedriver" + when "linux64" + "chromedriver-linux64/chromedriver" + when "linux-arm64" + "chromedriver-linux-arm64/chromedriver" + when "win64" + "chromedriver-win64/chromedriver.exe" + when "win32" + "chromedriver-win32/chromedriver.exe" + else + raise "Unknown platform: #{platform}" end end end From bd881eb1930c04cf8cd2b87fff1af45ebb1f2ad3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:41:33 +0900 Subject: [PATCH 12/25] RuboCop. --- lib/async/webdriver/installer/chrome/releases.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/async/webdriver/installer/chrome/releases.rb b/lib/async/webdriver/installer/chrome/releases.rb index 4a61b0e..481b4b6 100644 --- a/lib/async/webdriver/installer/chrome/releases.rb +++ b/lib/async/webdriver/installer/chrome/releases.rb @@ -72,8 +72,8 @@ def self.resolve_major(major, platform) data = fetch_json(VERSIONS_URL) entry = data["versions"] - .select { |v| v["version"].start_with?("#{major}.") } - .max_by { |v| Gem::Version.new(v["version"]) } + .select{|v| v["version"].start_with?("#{major}.")} + .max_by{|v| Gem::Version.new(v["version"])} raise "No version found for major version #{major}" unless entry @@ -83,7 +83,7 @@ def self.resolve_major(major, platform) def self.resolve_exact(version, platform) data = fetch_json(VERSIONS_URL) - entry = data["versions"].find { |v| v["version"] == version } + entry = data["versions"].find{|v| v["version"] == version} raise "Version #{version} not found" unless entry extract(entry, platform) @@ -94,11 +94,11 @@ def self.extract(entry, platform) downloads = entry["downloads"] chrome_url = downloads["chrome"] - &.find { |d| d["platform"] == platform } + &.find{|d| d["platform"] == platform} &.dig("url") chromedriver_url = downloads["chromedriver"] - &.find { |d| d["platform"] == platform } + &.find{|d| d["platform"] == platform} &.dig("url") raise "No Chrome download for platform #{platform} in version #{version}" unless chrome_url From cd6dc81c92b6715a1cef2a806f1635432834a6ca Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 15:54:32 +0900 Subject: [PATCH 13/25] Use channel symlinks to avoid network requests on repeated Chrome.for calls. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install(:stable) hits the API, downloads if needed, then creates/updates a symlink at {state}/{platform}/stable -> {version}/. - find(:stable) resolves the symlink locally — no network request. - Chrome.for(:stable) calls find first; only falls through to install if the symlink doesn't exist yet. --- lib/async/webdriver/bridge/chrome.rb | 2 +- lib/async/webdriver/installer/chrome.rb | 4 +- .../installer/chrome/installation.rb | 88 +++++++++++++++---- 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/lib/async/webdriver/bridge/chrome.rb b/lib/async/webdriver/bridge/chrome.rb index 4afb953..2281f1d 100644 --- a/lib/async/webdriver/bridge/chrome.rb +++ b/lib/async/webdriver/bridge/chrome.rb @@ -89,7 +89,7 @@ def start(**options) # @returns [Chrome] A configured bridge. def self.for(version = :stable, state: Installer::Chrome::DEFAULT_STATE, **options) require_relative "../installer/chrome" - installation = Installer::Chrome.install(version, state: state) + installation = Installer::Chrome.find(version, state: state) || Installer::Chrome.install(version, state: state) new(driver_path: installation.driver_path, browser_path: installation.browser_path, **options) end diff --git a/lib/async/webdriver/installer/chrome.rb b/lib/async/webdriver/installer/chrome.rb index 18c5c27..a247f68 100644 --- a/lib/async/webdriver/installer/chrome.rb +++ b/lib/async/webdriver/installer/chrome.rb @@ -54,9 +54,9 @@ def self.install(version = :stable, state: DEFAULT_STATE) Installation.install(version, state: state) end - # Find an already-installed version without hitting the network. + # Find an already-installed version or channel without hitting the network. # - # @parameter version [String] Exact version string, e.g. `"148.0.7778.56"`. + # @parameter version [Symbol | String] Channel or exact version string. # @parameter state [String] Root of the state directory. # @returns [Installation | Nil] def self.find(version, state: DEFAULT_STATE) diff --git a/lib/async/webdriver/installer/chrome/installation.rb b/lib/async/webdriver/installer/chrome/installation.rb index 87138ef..9f6f3d0 100644 --- a/lib/async/webdriver/installer/chrome/installation.rb +++ b/lib/async/webdriver/installer/chrome/installation.rb @@ -20,10 +20,20 @@ module Chrome # {state}/{platform}/{version}/ # chrome/ ← extracted chrome zip contents # chromedriver/ ← extracted chromedriver zip contents + # + # Channel names (e.g. `stable`) are stored as symlinks pointing at the + # specific version directory, so that {find} can resolve them without + # hitting the network. {install} always re-checks the API and updates + # the symlink if the channel has moved on to a newer version. class Installation # Look up an existing installation, or download and install a fresh one. # - # @parameter version [Symbol | String] Channel or version specifier — see {Async::WebDriver::Bridge::Chrome.for}. + # For channel specifiers (`:stable`, `:beta`, etc.), always hits the + # Chrome for Testing API to resolve the current version, downloads if + # needed, and updates the channel symlink. For exact versions, checks + # the local cache only. + # + # @parameter version [Symbol | String] Channel or version specifier. # @parameter state [String] Root of the state directory. # @returns [Installation] def self.install(version, state:) @@ -50,29 +60,30 @@ def self.install(version, state:) end end + # Update the channel symlink so subsequent find(:stable) calls + # resolve locally without a network request. + if channel = channel_name(version) + update_channel_symlink(channel, release[:version], platform, state: state) + end + return installation end - # Find an already-installed version, without hitting the network. + # Find an already-installed version or channel, without hitting the network. + # + # For channel names (`:stable`, `"stable"`, etc.), resolves the local + # symlink. For exact versions, checks the installation directory directly. # - # @parameter version [String] Exact version, e.g. `"148.0.7778.56"`. + # @parameter version [Symbol | String] Channel or exact version string. # @parameter platform [String] Platform string, e.g. `"mac-arm64"`. # @parameter state [String] Root of the state directory. # @returns [Installation | Nil] def self.find(version, platform, state:) - dir = installation_dir(version, platform, state: state) - - browser_path = File.join(dir, "chrome", Platform.chrome_binary(platform)) - driver_path = File.join(dir, "chromedriver", Platform.chromedriver_binary(platform)) - - return nil unless File.exist?(browser_path) && File.exist?(driver_path) - - new( - browser_path: browser_path, - driver_path: driver_path, - version: version, - platform: platform, - ) + if channel = channel_name(version) + find_channel(channel, platform, state: state) + else + find_version(version, platform, state: state) + end end # @parameter browser_path [String] Absolute path to the Chrome browser executable. @@ -98,6 +109,51 @@ def initialize(browser_path:, driver_path:, version:, platform:) # @attribute [String] Platform, e.g. `"mac-arm64"`. attr :platform + private_class_method def self.channel_name(version) + Releases::CHANNELS.key(version.to_s.capitalize) && version.to_s.downcase + end + + private_class_method def self.find_channel(channel, platform, state:) + symlink = channel_symlink(channel, platform, state: state) + return nil unless File.symlink?(symlink) + + # Derive the version from the symlink target name. + version = File.basename(File.readlink(symlink)) + find_version(version, platform, state: state) + end + + private_class_method def self.find_version(version, platform, state:) + dir = installation_dir(version, platform, state: state) + + browser_path = File.join(dir, "chrome", Platform.chrome_binary(platform)) + driver_path = File.join(dir, "chromedriver", Platform.chromedriver_binary(platform)) + + return nil unless File.exist?(browser_path) && File.exist?(driver_path) + + new( + browser_path: browser_path, + driver_path: driver_path, + version: version, + platform: platform, + ) + end + + private_class_method def self.update_channel_symlink(channel, version, platform, state:) + symlink = channel_symlink(channel, platform, state: state) + target = installation_dir(version, platform, state: state) + + # Remove stale symlink if it points elsewhere. + if File.symlink?(symlink) && File.readlink(symlink) != target + File.unlink(symlink) + end + + File.symlink(target, symlink) unless File.symlink?(symlink) + end + + private_class_method def self.channel_symlink(channel, platform, state:) + File.join(state, platform, channel.to_s) + end + private_class_method def self.installation_dir(version, platform, state:) File.join(state, platform, version) end From 0fa1e3c7673d7f9f6d36d2f549431da901fbca68 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:00:02 +0900 Subject: [PATCH 14/25] Add tests for Installer::Chrome::{Platform, Releases, Installation}. --- .../installer/chrome/installation.rb | 60 +++++++++++++++++++ .../webdriver/installer/chrome/platform.rb | 37 ++++++++++++ .../webdriver/installer/chrome/releases.rb | 33 ++++++++++ 3 files changed, 130 insertions(+) create mode 100644 test/async/webdriver/installer/chrome/installation.rb create mode 100644 test/async/webdriver/installer/chrome/platform.rb create mode 100644 test/async/webdriver/installer/chrome/releases.rb diff --git a/test/async/webdriver/installer/chrome/installation.rb b/test/async/webdriver/installer/chrome/installation.rb new file mode 100644 index 0000000..32359c5 --- /dev/null +++ b/test/async/webdriver/installer/chrome/installation.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2025, by Samuel Williams. + +require "async/webdriver/installer/chrome/installation" +require "tmpdir" + +describe Async::WebDriver::Installer::Chrome::Installation do + let(:platform) { Async::WebDriver::Installer::Chrome::Platform.current } + let(:state) { Dir.mktmpdir("async-webdriver-test-") } + + def after(error = nil) + FileUtils.rm_rf(state) + super + end + + with ".find" do + it "returns nil when nothing is installed" do + expect(subject.find(:stable, platform, state: state)).to be_nil + end + + it "returns nil for an exact version that is not installed" do + expect(subject.find("999.0.0.0", platform, state: state)).to be_nil + end + end + + with ".install" do + it "installs stable and returns an Installation" do + installation = subject.install(:stable, state: state) + + expect(installation).to be_a(subject) + expect(installation.version).to match(/\A\d+\.\d+\.\d+\.\d+\z/) + expect(installation.platform).to be == platform + expect(File.exist?(installation.browser_path)).to be == true + expect(File.exist?(installation.driver_path)).to be == true + end + + it "creates a channel symlink" do + subject.install(:stable, state: state) + expect(File.symlink?(File.join(state, platform, "stable"))).to be == true + end + + it "is idempotent — second call returns without re-downloading" do + first = subject.install(:stable, state: state) + second = subject.install(:stable, state: state) + expect(second.version).to be == first.version + end + end + + with ".find after .install" do + it "resolves the channel symlink without a network request" do + subject.install(:stable, state: state) + installation = subject.find(:stable, platform, state: state) + + expect(installation).to be_a(subject) + expect(File.exist?(installation.browser_path)).to be == true + end + end +end diff --git a/test/async/webdriver/installer/chrome/platform.rb b/test/async/webdriver/installer/chrome/platform.rb new file mode 100644 index 0000000..da8bcd2 --- /dev/null +++ b/test/async/webdriver/installer/chrome/platform.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2025, by Samuel Williams. + +require "async/webdriver/installer/chrome/platform" + +describe Async::WebDriver::Installer::Chrome::Platform do + with ".current" do + it "detects the current platform" do + platform = subject.current + expect(platform).to be_a(String) + known_platforms = ["mac-arm64", "mac-x64", "linux64", "linux-arm64", "win64", "win32"] + expect(known_platforms).to be(:include?, platform) + end + end + + with ".chrome_binary" do + it "returns a path for the current platform" do + expect(subject.chrome_binary(subject.current)).to be_a(String) + end + + it "raises for an unknown platform" do + expect { subject.chrome_binary("bogus") }.to raise_exception(RuntimeError) + end + end + + with ".chromedriver_binary" do + it "returns a path for the current platform" do + expect(subject.chromedriver_binary(subject.current)).to be_a(String) + end + + it "raises for an unknown platform" do + expect { subject.chromedriver_binary("bogus") }.to raise_exception(RuntimeError) + end + end +end diff --git a/test/async/webdriver/installer/chrome/releases.rb b/test/async/webdriver/installer/chrome/releases.rb new file mode 100644 index 0000000..ca20047 --- /dev/null +++ b/test/async/webdriver/installer/chrome/releases.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2023-2025, by Samuel Williams. + +require "async/webdriver/installer/chrome/releases" +require "async/webdriver/installer/chrome/platform" + +describe Async::WebDriver::Installer::Chrome::Releases do + let(:platform) { Async::WebDriver::Installer::Chrome::Platform.current } + + with ".resolve" do + it "resolves :stable to a version hash" do + result = subject.resolve(:stable, platform) + expect(result).to have_keys(:version, :chrome_url, :chromedriver_url) + expect(result[:version]).to match(/\A\d+\.\d+\.\d+\.\d+\z/) + end + + it "resolves 'stable' string the same as :stable" do + expect(subject.resolve("stable", platform)).to be == subject.resolve(:stable, platform) + end + + it "resolves a major version string" do + major = subject.resolve(:stable, platform)[:version].split(".").first + result = subject.resolve(major, platform) + expect(result[:version]).to start_with("#{major}.") + end + + it "raises for an unknown channel" do + expect { subject.resolve(:nightly, platform) }.to raise_exception(ArgumentError) + end + end +end From 1748477350891f9a6ba8036084650f82a8dc1b5e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:00:51 +0900 Subject: [PATCH 15/25] Simplify DEFAULT_STATE using File.expand_path dir argument. --- lib/async/webdriver/installer/chrome.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/async/webdriver/installer/chrome.rb b/lib/async/webdriver/installer/chrome.rb index a247f68..65eddce 100644 --- a/lib/async/webdriver/installer/chrome.rb +++ b/lib/async/webdriver/installer/chrome.rb @@ -38,9 +38,7 @@ module Installer # ``` module Chrome # Default state directory, following the XDG Base Directory Specification. - DEFAULT_STATE = File.expand_path( - File.join(ENV.fetch("XDG_STATE_HOME", "~/.local/state"), "async-webdriver") - ).freeze + DEFAULT_STATE = File.expand_path("async-webdriver", ENV.fetch("XDG_STATE_HOME", "~/.local/state")).freeze # Ensure the given version is installed and return an {Installation}. # From 1b4bd34c1961a9b193c5b960219914fb1a57a104 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:02:20 +0900 Subject: [PATCH 16/25] Use XDG_CACHE_HOME and async-webdriver.rb directory name. --- lib/async/webdriver/bridge/chrome.rb | 4 ++-- lib/async/webdriver/installer/chrome.rb | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/async/webdriver/bridge/chrome.rb b/lib/async/webdriver/bridge/chrome.rb index 2281f1d..a264b32 100644 --- a/lib/async/webdriver/bridge/chrome.rb +++ b/lib/async/webdriver/bridge/chrome.rb @@ -87,7 +87,7 @@ def start(**options) # Default: `~/.local/state/async-webdriver` (XDG-compliant). # @parameter options [Hash] Additional options forwarded to {.new} (e.g. `headless: false`). # @returns [Chrome] A configured bridge. - def self.for(version = :stable, state: Installer::Chrome::DEFAULT_STATE, **options) + def self.for(version = :stable, state: Installer::Chrome::DEFAULT_CACHE, **options) require_relative "../installer/chrome" installation = Installer::Chrome.find(version, state: state) || Installer::Chrome.install(version, state: state) new(driver_path: installation.driver_path, browser_path: installation.browser_path, **options) @@ -101,7 +101,7 @@ def self.for(version = :stable, state: Installer::Chrome::DEFAULT_STATE, **optio # @parameter version [Symbol | String] Version specifier — see {.for}. # @parameter state [String] Root of the state directory. # @returns [Installer::Chrome::Installation] The installation details. - def self.install(version = :stable, state: Installer::Chrome::DEFAULT_STATE) + def self.install(version = :stable, state: Installer::Chrome::DEFAULT_CACHE) require_relative "../installer/chrome" Installer::Chrome.install(version, state: state) end diff --git a/lib/async/webdriver/installer/chrome.rb b/lib/async/webdriver/installer/chrome.rb index 65eddce..45fb933 100644 --- a/lib/async/webdriver/installer/chrome.rb +++ b/lib/async/webdriver/installer/chrome.rb @@ -18,8 +18,8 @@ module Installer # - A major version string: `"148"` (resolves to the latest patch) # - An exact version string: `"148.0.7778.56"` # - # Installations are cached in `~/.local/state/async-webdriver/` by default - # (respects `$XDG_STATE_HOME`). + # Installations are cached in `~/.cache/async-webdriver.rb/` by default + # (respects `$XDG_CACHE_HOME`). # # ## Example # @@ -37,8 +37,8 @@ module Installer # bridge = Async::WebDriver::Bridge::Chrome.for(:stable) # ``` module Chrome - # Default state directory, following the XDG Base Directory Specification. - DEFAULT_STATE = File.expand_path("async-webdriver", ENV.fetch("XDG_STATE_HOME", "~/.local/state")).freeze + # Default cache directory, following the XDG Base Directory Specification. + DEFAULT_CACHE = File.expand_path("async-webdriver.rb", ENV.fetch("XDG_CACHE_HOME", "~/.cache")).freeze # Ensure the given version is installed and return an {Installation}. # @@ -46,19 +46,19 @@ module Chrome # infrastructure only when the version is not already present. # # @parameter version [Symbol | String] Version specifier. - # @parameter state [String] Root of the state directory. + # @parameter cache [String] Root of the cache directory. # @returns [Installation] - def self.install(version = :stable, state: DEFAULT_STATE) - Installation.install(version, state: state) + def self.install(version = :stable, cache: DEFAULT_CACHE) + Installation.install(version, cache: cache) end # Find an already-installed version or channel without hitting the network. # # @parameter version [Symbol | String] Channel or exact version string. - # @parameter state [String] Root of the state directory. + # @parameter cache [String] Root of the cache directory. # @returns [Installation | Nil] - def self.find(version, state: DEFAULT_STATE) - Installation.find(version, Platform.current, state: state) + def self.find(version, cache: DEFAULT_CACHE) + Installation.find(version, Platform.current, cache: cache) end end end From 0719936f0044db332c71b0ec65de12e7126dc9ba Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:07:45 +0900 Subject: [PATCH 17/25] Replace DEFAULT_CACHE with Installer.cache_path(subdirectory, env = ENV). --- lib/async/webdriver/bridge/chrome.rb | 14 +++++++------- lib/async/webdriver/installer.rb | 13 +++++++++++++ lib/async/webdriver/installer/chrome.rb | 6 +++--- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/lib/async/webdriver/bridge/chrome.rb b/lib/async/webdriver/bridge/chrome.rb index a264b32..c21a861 100644 --- a/lib/async/webdriver/bridge/chrome.rb +++ b/lib/async/webdriver/bridge/chrome.rb @@ -83,13 +83,13 @@ def start(**options) # # @parameter version [Symbol | String] `:stable`, `:beta`, `:dev`, `:canary`, # a major version string like `"148"`, or an exact version like `"148.0.7778.56"`. - # @parameter state [String] Root of the state directory. - # Default: `~/.local/state/async-webdriver` (XDG-compliant). + # @parameter cache [String] Root of the cache directory. + # Default: `~/.cache/async-webdriver.rb` (XDG-compliant). # @parameter options [Hash] Additional options forwarded to {.new} (e.g. `headless: false`). # @returns [Chrome] A configured bridge. - def self.for(version = :stable, state: Installer::Chrome::DEFAULT_CACHE, **options) + def self.for(version = :stable, cache: Installer.cache_path("chrome"), **options) require_relative "../installer/chrome" - installation = Installer::Chrome.find(version, state: state) || Installer::Chrome.install(version, state: state) + installation = Installer::Chrome.find(version, cache: cache) || Installer::Chrome.install(version, cache: cache) new(driver_path: installation.driver_path, browser_path: installation.browser_path, **options) end @@ -99,11 +99,11 @@ def self.for(version = :stable, state: Installer::Chrome::DEFAULT_CACHE, **optio # entering the Async reactor. # # @parameter version [Symbol | String] Version specifier — see {.for}. - # @parameter state [String] Root of the state directory. + # @parameter cache [String] Root of the cache directory. # @returns [Installer::Chrome::Installation] The installation details. - def self.install(version = :stable, state: Installer::Chrome::DEFAULT_CACHE) + def self.install(version = :stable, cache: Installer.cache_path("chrome")) require_relative "../installer/chrome" - Installer::Chrome.install(version, state: state) + Installer::Chrome.install(version, cache: cache) end # The path to the Chrome browser executable. If `nil`, ChromeDriver uses its own discovery. diff --git a/lib/async/webdriver/installer.rb b/lib/async/webdriver/installer.rb index 54e66c0..6ba72e5 100644 --- a/lib/async/webdriver/installer.rb +++ b/lib/async/webdriver/installer.rb @@ -14,6 +14,19 @@ module WebDriver # # - {Installer::Chrome} — Chrome for Testing, via the Chrome for Testing JSON API. module Installer + # Resolve the cache path for the given sub-directory. + # + # Follows the XDG Base Directory Specification, using `$XDG_CACHE_HOME` + # (default: `~/.cache`) as the root, with `async-webdriver.rb` as the + # application directory. + # + # @parameter subdirectory [String | Nil] Optional sub-directory, e.g. `"chrome"`. + # @parameter env [Hash] Environment to read `XDG_CACHE_HOME` from. Default: `ENV`. + # @returns [String] Absolute path. + def self.cache_path(subdirectory = nil, env = ENV) + base = File.expand_path("async-webdriver.rb", env.fetch("XDG_CACHE_HOME", "~/.cache")) + subdirectory ? File.join(base, subdirectory) : base + end end end end diff --git a/lib/async/webdriver/installer/chrome.rb b/lib/async/webdriver/installer/chrome.rb index 45fb933..be5e4ad 100644 --- a/lib/async/webdriver/installer/chrome.rb +++ b/lib/async/webdriver/installer/chrome.rb @@ -38,7 +38,7 @@ module Installer # ``` module Chrome # Default cache directory, following the XDG Base Directory Specification. - DEFAULT_CACHE = File.expand_path("async-webdriver.rb", ENV.fetch("XDG_CACHE_HOME", "~/.cache")).freeze + # Ensure the given version is installed and return an {Installation}. # @@ -48,7 +48,7 @@ module Chrome # @parameter version [Symbol | String] Version specifier. # @parameter cache [String] Root of the cache directory. # @returns [Installation] - def self.install(version = :stable, cache: DEFAULT_CACHE) + def self.install(version = :stable, cache: Installer.cache_path("chrome")) Installation.install(version, cache: cache) end @@ -57,7 +57,7 @@ def self.install(version = :stable, cache: DEFAULT_CACHE) # @parameter version [Symbol | String] Channel or exact version string. # @parameter cache [String] Root of the cache directory. # @returns [Installation | Nil] - def self.find(version, cache: DEFAULT_CACHE) + def self.find(version, cache: Installer.cache_path("chrome")) Installation.find(version, Platform.current, cache: cache) end end From a8a876d5638c1f20f2bccc89f896f032bba84890 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:11:59 +0900 Subject: [PATCH 18/25] Update release notes. --- releases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/releases.md b/releases.md index c2bb56c..892b99e 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Add `Async::WebDriver::Installer::Chrome` for automatic Chrome for Testing installation and management. `Installer::Chrome.install(version)` resolves the version via the Chrome for Testing JSON API, caches binaries in `~/.local/state/async-webdriver/` (XDG `$XDG_STATE_HOME`), and returns an `Installation` with paths to both the Chrome and ChromeDriver binaries. The namespace is designed to accommodate additional browsers (e.g. `Installer::Firefox`) in the future. + - Add `Async::WebDriver::Installer::Chrome` for automatic Chrome for Testing installation and management. `Installer::Chrome.install(version)` resolves the version via the Chrome for Testing JSON API, caches binaries in `~/.local/state/async-webdriver/` (XDG `$XDG_STATE_HOME`), and returns an `Installation` with paths to both the Chrome and ChromeDriver binaries. - Add `Bridge::Chrome.for(version)` as a convenience shorthand: installs the requested version if needed, then returns a fully configured `Chrome` bridge. Versions can be a channel symbol (`:stable`, `:beta`, `:dev`, `:canary`), a major version string (`"148"`), or an exact version string (`"148.0.7778.56"`). - Add `Bridge::Chrome.install(version)` for pre-downloading in CI setup steps or bake tasks, before entering the Async reactor. - Add `bake async:webdriver:chrome:install` task for installing Chrome for Testing from the command line, e.g. in CI setup steps. From 807b03cd3a1c025d52cccc2846a3390086dc540c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:14:50 +0900 Subject: [PATCH 19/25] RuboCop. --- .../installer/chrome/installation.rb | 48 +++++++++---------- .../installer/chrome/installation.rb | 4 +- .../webdriver/installer/chrome/platform.rb | 4 +- .../webdriver/installer/chrome/releases.rb | 4 +- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/lib/async/webdriver/installer/chrome/installation.rb b/lib/async/webdriver/installer/chrome/installation.rb index 9f6f3d0..c0a338b 100644 --- a/lib/async/webdriver/installer/chrome/installation.rb +++ b/lib/async/webdriver/installer/chrome/installation.rb @@ -15,9 +15,9 @@ module Chrome # Represents a Chrome for Testing installation on disk, and provides class-level # methods for resolving, locating, and downloading installations. # - # Installations are stored under the state directory, organised as: + # Installations are stored under the cache_path directory, organised as: # - # {state}/{platform}/{version}/ + # {cache_path}/{platform}/{version}/ # chrome/ ← extracted chrome zip contents # chromedriver/ ← extracted chromedriver zip contents # @@ -34,23 +34,23 @@ class Installation # the local cache only. # # @parameter version [Symbol | String] Channel or version specifier. - # @parameter state [String] Root of the state directory. + # @parameter cache_path [String] Root of the cache_path directory. # @returns [Installation] - def self.install(version, state:) + def self.install(version, cache_path:) platform = Platform.current release = Releases.resolve(version, platform) - unless installation = find(release[:version], platform, state: state) + unless installation = find(release[:version], platform, cache_path: cache_path) Console.info(self, "Installing Chrome for Testing #{release[:version]}...", platform: platform) - dir = installation_dir(release[:version], platform, state: state) + dir = installation_dir(release[:version], platform, cache_path: cache_path) FileUtils.mkdir_p(dir) begin download_and_extract(release[:chrome_url], File.join(dir, "chrome")) download_and_extract(release[:chromedriver_url], File.join(dir, "chromedriver")) - installation = find(release[:version], platform, state: state) or + installation = find(release[:version], platform, cache_path: cache_path) or raise "Installation failed: binaries not found after extraction" Console.info(self, "Installed Chrome for Testing #{release[:version]}.", platform: platform) @@ -63,7 +63,7 @@ def self.install(version, state:) # Update the channel symlink so subsequent find(:stable) calls # resolve locally without a network request. if channel = channel_name(version) - update_channel_symlink(channel, release[:version], platform, state: state) + update_channel_symlink(channel, release[:version], platform, cache_path: cache_path) end return installation @@ -76,13 +76,13 @@ def self.install(version, state:) # # @parameter version [Symbol | String] Channel or exact version string. # @parameter platform [String] Platform string, e.g. `"mac-arm64"`. - # @parameter state [String] Root of the state directory. + # @parameter cache_path [String] Root of the cache_path directory. # @returns [Installation | Nil] - def self.find(version, platform, state:) + def self.find(version, platform, cache_path:) if channel = channel_name(version) - find_channel(channel, platform, state: state) + find_channel(channel, platform, cache_path: cache_path) else - find_version(version, platform, state: state) + find_version(version, platform, cache_path: cache_path) end end @@ -113,17 +113,17 @@ def initialize(browser_path:, driver_path:, version:, platform:) Releases::CHANNELS.key(version.to_s.capitalize) && version.to_s.downcase end - private_class_method def self.find_channel(channel, platform, state:) - symlink = channel_symlink(channel, platform, state: state) + private_class_method def self.find_channel(channel, platform, cache_path:) + symlink = channel_symlink(channel, platform, cache_path: cache_path) return nil unless File.symlink?(symlink) # Derive the version from the symlink target name. version = File.basename(File.readlink(symlink)) - find_version(version, platform, state: state) + find_version(version, platform, cache_path: cache_path) end - private_class_method def self.find_version(version, platform, state:) - dir = installation_dir(version, platform, state: state) + private_class_method def self.find_version(version, platform, cache_path:) + dir = installation_dir(version, platform, cache_path: cache_path) browser_path = File.join(dir, "chrome", Platform.chrome_binary(platform)) driver_path = File.join(dir, "chromedriver", Platform.chromedriver_binary(platform)) @@ -138,9 +138,9 @@ def initialize(browser_path:, driver_path:, version:, platform:) ) end - private_class_method def self.update_channel_symlink(channel, version, platform, state:) - symlink = channel_symlink(channel, platform, state: state) - target = installation_dir(version, platform, state: state) + private_class_method def self.update_channel_symlink(channel, version, platform, cache_path:) + symlink = channel_symlink(channel, platform, cache_path: cache_path) + target = installation_dir(version, platform, cache_path: cache_path) # Remove stale symlink if it points elsewhere. if File.symlink?(symlink) && File.readlink(symlink) != target @@ -150,12 +150,12 @@ def initialize(browser_path:, driver_path:, version:, platform:) File.symlink(target, symlink) unless File.symlink?(symlink) end - private_class_method def self.channel_symlink(channel, platform, state:) - File.join(state, platform, channel.to_s) + private_class_method def self.channel_symlink(channel, platform, cache_path:) + File.join(cache_path, platform, channel.to_s) end - private_class_method def self.installation_dir(version, platform, state:) - File.join(state, platform, version) + private_class_method def self.installation_dir(version, platform, cache_path:) + File.join(cache_path, platform, version) end private_class_method def self.download_and_extract(url, dest) diff --git a/test/async/webdriver/installer/chrome/installation.rb b/test/async/webdriver/installer/chrome/installation.rb index 32359c5..8c8dc73 100644 --- a/test/async/webdriver/installer/chrome/installation.rb +++ b/test/async/webdriver/installer/chrome/installation.rb @@ -7,8 +7,8 @@ require "tmpdir" describe Async::WebDriver::Installer::Chrome::Installation do - let(:platform) { Async::WebDriver::Installer::Chrome::Platform.current } - let(:state) { Dir.mktmpdir("async-webdriver-test-") } + let(:platform) {Async::WebDriver::Installer::Chrome::Platform.current} + let(:state) {Dir.mktmpdir("async-webdriver-test-")} def after(error = nil) FileUtils.rm_rf(state) diff --git a/test/async/webdriver/installer/chrome/platform.rb b/test/async/webdriver/installer/chrome/platform.rb index da8bcd2..89110d7 100644 --- a/test/async/webdriver/installer/chrome/platform.rb +++ b/test/async/webdriver/installer/chrome/platform.rb @@ -21,7 +21,7 @@ end it "raises for an unknown platform" do - expect { subject.chrome_binary("bogus") }.to raise_exception(RuntimeError) + expect{subject.chrome_binary("bogus")}.to raise_exception(RuntimeError) end end @@ -31,7 +31,7 @@ end it "raises for an unknown platform" do - expect { subject.chromedriver_binary("bogus") }.to raise_exception(RuntimeError) + expect{subject.chromedriver_binary("bogus")}.to raise_exception(RuntimeError) end end end diff --git a/test/async/webdriver/installer/chrome/releases.rb b/test/async/webdriver/installer/chrome/releases.rb index ca20047..d2eb42a 100644 --- a/test/async/webdriver/installer/chrome/releases.rb +++ b/test/async/webdriver/installer/chrome/releases.rb @@ -7,7 +7,7 @@ require "async/webdriver/installer/chrome/platform" describe Async::WebDriver::Installer::Chrome::Releases do - let(:platform) { Async::WebDriver::Installer::Chrome::Platform.current } + let(:platform) {Async::WebDriver::Installer::Chrome::Platform.current} with ".resolve" do it "resolves :stable to a version hash" do @@ -27,7 +27,7 @@ end it "raises for an unknown channel" do - expect { subject.resolve(:nightly, platform) }.to raise_exception(ArgumentError) + expect{subject.resolve(:nightly, platform)}.to raise_exception(ArgumentError) end end end From 0432582262ead56854449c3b0e3cb2f6a85fc110 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:16:12 +0900 Subject: [PATCH 20/25] Rename state:/cache: to cache_path: throughout; use after block in tests. --- bake/async/webdriver/chrome.rb | 2 +- lib/async/webdriver/bridge/chrome.rb | 12 ++++----- lib/async/webdriver/installer/chrome.rb | 12 ++++----- .../installer/chrome/installation.rb | 4 +-- .../installer/chrome/installation.rb | 25 +++++++++---------- 5 files changed, 27 insertions(+), 28 deletions(-) diff --git a/bake/async/webdriver/chrome.rb b/bake/async/webdriver/chrome.rb index b4bff2e..0d0b513 100644 --- a/bake/async/webdriver/chrome.rb +++ b/bake/async/webdriver/chrome.rb @@ -6,7 +6,7 @@ # Install Chrome for Testing and its matching ChromeDriver. # # Downloads the requested version from the Chrome for Testing infrastructure -# and caches it in `~/.local/state/async-webdriver/` (XDG `$XDG_STATE_HOME`). +# and caches it in `~/.cache/async-webdriver.rb/` (XDG `$XDG_CACHE_HOME`). # Subsequent calls with the same version are a no-op. # # @parameter version [String] The version to install: a channel (`stable`, `beta`, `dev`, `canary`), diff --git a/lib/async/webdriver/bridge/chrome.rb b/lib/async/webdriver/bridge/chrome.rb index c21a861..4fe1063 100644 --- a/lib/async/webdriver/bridge/chrome.rb +++ b/lib/async/webdriver/bridge/chrome.rb @@ -83,13 +83,13 @@ def start(**options) # # @parameter version [Symbol | String] `:stable`, `:beta`, `:dev`, `:canary`, # a major version string like `"148"`, or an exact version like `"148.0.7778.56"`. - # @parameter cache [String] Root of the cache directory. + # @parameter cache_path [String] Root of the cache directory. # Default: `~/.cache/async-webdriver.rb` (XDG-compliant). # @parameter options [Hash] Additional options forwarded to {.new} (e.g. `headless: false`). # @returns [Chrome] A configured bridge. - def self.for(version = :stable, cache: Installer.cache_path("chrome"), **options) + def self.for(version = :stable, cache_path: Installer.cache_path("chrome"), **options) require_relative "../installer/chrome" - installation = Installer::Chrome.find(version, cache: cache) || Installer::Chrome.install(version, cache: cache) + installation = Installer::Chrome.find(version, cache_path: cache_path) || Installer::Chrome.install(version, cache_path: cache_path) new(driver_path: installation.driver_path, browser_path: installation.browser_path, **options) end @@ -99,11 +99,11 @@ def self.for(version = :stable, cache: Installer.cache_path("chrome"), **options # entering the Async reactor. # # @parameter version [Symbol | String] Version specifier — see {.for}. - # @parameter cache [String] Root of the cache directory. + # @parameter cache_path [String] Root of the cache directory. # @returns [Installer::Chrome::Installation] The installation details. - def self.install(version = :stable, cache: Installer.cache_path("chrome")) + def self.install(version = :stable, cache_path: Installer.cache_path("chrome")) require_relative "../installer/chrome" - Installer::Chrome.install(version, cache: cache) + Installer::Chrome.install(version, cache_path: cache_path) end # The path to the Chrome browser executable. If `nil`, ChromeDriver uses its own discovery. diff --git a/lib/async/webdriver/installer/chrome.rb b/lib/async/webdriver/installer/chrome.rb index be5e4ad..a2d2591 100644 --- a/lib/async/webdriver/installer/chrome.rb +++ b/lib/async/webdriver/installer/chrome.rb @@ -46,19 +46,19 @@ module Chrome # infrastructure only when the version is not already present. # # @parameter version [Symbol | String] Version specifier. - # @parameter cache [String] Root of the cache directory. + # @parameter cache_path [String] Root of the cache directory. # @returns [Installation] - def self.install(version = :stable, cache: Installer.cache_path("chrome")) - Installation.install(version, cache: cache) + def self.install(version = :stable, cache_path: Installer.cache_path("chrome")) + Installation.install(version, cache_path: cache_path) end # Find an already-installed version or channel without hitting the network. # # @parameter version [Symbol | String] Channel or exact version string. - # @parameter cache [String] Root of the cache directory. + # @parameter cache_path [String] Root of the cache directory. # @returns [Installation | Nil] - def self.find(version, cache: Installer.cache_path("chrome")) - Installation.find(version, Platform.current, cache: cache) + def self.find(version, cache_path: Installer.cache_path("chrome")) + Installation.find(version, Platform.current, cache_path: cache_path) end end end diff --git a/lib/async/webdriver/installer/chrome/installation.rb b/lib/async/webdriver/installer/chrome/installation.rb index c0a338b..2bf7c36 100644 --- a/lib/async/webdriver/installer/chrome/installation.rb +++ b/lib/async/webdriver/installer/chrome/installation.rb @@ -34,7 +34,7 @@ class Installation # the local cache only. # # @parameter version [Symbol | String] Channel or version specifier. - # @parameter cache_path [String] Root of the cache_path directory. + # @parameter cache_path [String] Root of the cache directory. # @returns [Installation] def self.install(version, cache_path:) platform = Platform.current @@ -76,7 +76,7 @@ def self.install(version, cache_path:) # # @parameter version [Symbol | String] Channel or exact version string. # @parameter platform [String] Platform string, e.g. `"mac-arm64"`. - # @parameter cache_path [String] Root of the cache_path directory. + # @parameter cache_path [String] Root of the cache directory. # @returns [Installation | Nil] def self.find(version, platform, cache_path:) if channel = channel_name(version) diff --git a/test/async/webdriver/installer/chrome/installation.rb b/test/async/webdriver/installer/chrome/installation.rb index 8c8dc73..2e58c62 100644 --- a/test/async/webdriver/installer/chrome/installation.rb +++ b/test/async/webdriver/installer/chrome/installation.rb @@ -8,26 +8,25 @@ describe Async::WebDriver::Installer::Chrome::Installation do let(:platform) {Async::WebDriver::Installer::Chrome::Platform.current} - let(:state) {Dir.mktmpdir("async-webdriver-test-")} + let(:cache_path) {Dir.mktmpdir("async-webdriver-test-")} - def after(error = nil) - FileUtils.rm_rf(state) - super + after do + FileUtils.rm_rf(cache_path) end with ".find" do it "returns nil when nothing is installed" do - expect(subject.find(:stable, platform, state: state)).to be_nil + expect(subject.find(:stable, platform, cache_path: cache_path)).to be_nil end it "returns nil for an exact version that is not installed" do - expect(subject.find("999.0.0.0", platform, state: state)).to be_nil + expect(subject.find("999.0.0.0", platform, cache_path: cache_path)).to be_nil end end with ".install" do it "installs stable and returns an Installation" do - installation = subject.install(:stable, state: state) + installation = subject.install(:stable, cache_path: cache_path) expect(installation).to be_a(subject) expect(installation.version).to match(/\A\d+\.\d+\.\d+\.\d+\z/) @@ -37,21 +36,21 @@ def after(error = nil) end it "creates a channel symlink" do - subject.install(:stable, state: state) - expect(File.symlink?(File.join(state, platform, "stable"))).to be == true + subject.install(:stable, cache_path: cache_path) + expect(File.symlink?(File.join(cache_path, platform, "stable"))).to be == true end it "is idempotent — second call returns without re-downloading" do - first = subject.install(:stable, state: state) - second = subject.install(:stable, state: state) + first = subject.install(:stable, cache_path: cache_path) + second = subject.install(:stable, cache_path: cache_path) expect(second.version).to be == first.version end end with ".find after .install" do it "resolves the channel symlink without a network request" do - subject.install(:stable, state: state) - installation = subject.find(:stable, platform, state: state) + subject.install(:stable, cache_path: cache_path) + installation = subject.find(:stable, platform, cache_path: cache_path) expect(installation).to be_a(subject) expect(File.exist?(installation.browser_path)).to be == true From 31d1ea4c230b7d9700f13b514f65f6ab7e28ca1b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:17:29 +0900 Subject: [PATCH 21/25] Minor test tweaks. --- lib/async/webdriver/installer.rb | 9 +++++++-- test/async/webdriver/bridge.rb | 3 +-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/async/webdriver/installer.rb b/lib/async/webdriver/installer.rb index 6ba72e5..784f79d 100644 --- a/lib/async/webdriver/installer.rb +++ b/lib/async/webdriver/installer.rb @@ -24,8 +24,13 @@ module Installer # @parameter env [Hash] Environment to read `XDG_CACHE_HOME` from. Default: `ENV`. # @returns [String] Absolute path. def self.cache_path(subdirectory = nil, env = ENV) - base = File.expand_path("async-webdriver.rb", env.fetch("XDG_CACHE_HOME", "~/.cache")) - subdirectory ? File.join(base, subdirectory) : base + path = File.expand_path("async-webdriver.rb", env.fetch("XDG_CACHE_HOME", "~/.cache")) + + if subdirectory + path = File.join(path, subdirectory) + end + + return path end end end diff --git a/test/async/webdriver/bridge.rb b/test/async/webdriver/bridge.rb index c482e83..5e28341 100644 --- a/test/async/webdriver/bridge.rb +++ b/test/async/webdriver/bridge.rb @@ -32,9 +32,8 @@ def driver @driver ||= bridge.start end - def after(error = nil) + after do @driver&.close - super end it_behaves_like ABridge From 2452bc37346e62cce33a40a000f77f34176946df Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:21:21 +0900 Subject: [PATCH 22/25] Copyrights. --- bake/async/webdriver/chrome.rb | 2 +- lib/async/webdriver/installer.rb | 2 +- lib/async/webdriver/installer/chrome.rb | 2 +- lib/async/webdriver/installer/chrome/installation.rb | 2 +- lib/async/webdriver/installer/chrome/platform.rb | 2 +- lib/async/webdriver/installer/chrome/releases.rb | 2 +- test/async/webdriver/bridge.rb | 2 +- test/async/webdriver/installer/chrome/installation.rb | 2 +- test/async/webdriver/installer/chrome/platform.rb | 2 +- test/async/webdriver/installer/chrome/releases.rb | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bake/async/webdriver/chrome.rb b/bake/async/webdriver/chrome.rb index 0d0b513..8f936a6 100644 --- a/bake/async/webdriver/chrome.rb +++ b/bake/async/webdriver/chrome.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2025-2026, by Samuel Williams. +# Copyright, 2026, by Samuel Williams. # Install Chrome for Testing and its matching ChromeDriver. # diff --git a/lib/async/webdriver/installer.rb b/lib/async/webdriver/installer.rb index 784f79d..f032e67 100644 --- a/lib/async/webdriver/installer.rb +++ b/lib/async/webdriver/installer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2025, by Samuel Williams. +# Copyright, 2026, by Samuel Williams. require_relative "installer/chrome" diff --git a/lib/async/webdriver/installer/chrome.rb b/lib/async/webdriver/installer/chrome.rb index a2d2591..55612a1 100644 --- a/lib/async/webdriver/installer/chrome.rb +++ b/lib/async/webdriver/installer/chrome.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2025, by Samuel Williams. +# Copyright, 2026, by Samuel Williams. require_relative "chrome/platform" require_relative "chrome/releases" diff --git a/lib/async/webdriver/installer/chrome/installation.rb b/lib/async/webdriver/installer/chrome/installation.rb index 2bf7c36..5aa2cb7 100644 --- a/lib/async/webdriver/installer/chrome/installation.rb +++ b/lib/async/webdriver/installer/chrome/installation.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2025, by Samuel Williams. +# Copyright, 2026, by Samuel Williams. require "fileutils" require "tempfile" diff --git a/lib/async/webdriver/installer/chrome/platform.rb b/lib/async/webdriver/installer/chrome/platform.rb index 89b634b..f1aef10 100644 --- a/lib/async/webdriver/installer/chrome/platform.rb +++ b/lib/async/webdriver/installer/chrome/platform.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2025, by Samuel Williams. +# Copyright, 2026, by Samuel Williams. module Async module WebDriver diff --git a/lib/async/webdriver/installer/chrome/releases.rb b/lib/async/webdriver/installer/chrome/releases.rb index 481b4b6..bbea720 100644 --- a/lib/async/webdriver/installer/chrome/releases.rb +++ b/lib/async/webdriver/installer/chrome/releases.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2025, by Samuel Williams. +# Copyright, 2026, by Samuel Williams. require "json" diff --git a/test/async/webdriver/bridge.rb b/test/async/webdriver/bridge.rb index 5e28341..fb1e346 100644 --- a/test/async/webdriver/bridge.rb +++ b/test/async/webdriver/bridge.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2025, by Samuel Williams. +# Copyright, 2023-2026, by Samuel Williams. require "sus/fixtures/async/reactor_context" diff --git a/test/async/webdriver/installer/chrome/installation.rb b/test/async/webdriver/installer/chrome/installation.rb index 2e58c62..1278f32 100644 --- a/test/async/webdriver/installer/chrome/installation.rb +++ b/test/async/webdriver/installer/chrome/installation.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2025, by Samuel Williams. +# Copyright, 2026, by Samuel Williams. require "async/webdriver/installer/chrome/installation" require "tmpdir" diff --git a/test/async/webdriver/installer/chrome/platform.rb b/test/async/webdriver/installer/chrome/platform.rb index 89110d7..9a21882 100644 --- a/test/async/webdriver/installer/chrome/platform.rb +++ b/test/async/webdriver/installer/chrome/platform.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2025, by Samuel Williams. +# Copyright, 2026, by Samuel Williams. require "async/webdriver/installer/chrome/platform" diff --git a/test/async/webdriver/installer/chrome/releases.rb b/test/async/webdriver/installer/chrome/releases.rb index d2eb42a..a2d37d4 100644 --- a/test/async/webdriver/installer/chrome/releases.rb +++ b/test/async/webdriver/installer/chrome/releases.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2025, by Samuel Williams. +# Copyright, 2026, by Samuel Williams. require "async/webdriver/installer/chrome/releases" require "async/webdriver/installer/chrome/platform" From 9552b4454e1d11ec7e8b4f0f6ae803d8c3a8cf14 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:22:51 +0900 Subject: [PATCH 23/25] Remove Bridge::Chrome.install; use Installer::Chrome.install directly. --- lib/async/webdriver/bridge/chrome.rb | 13 ------------- releases.md | 1 - 2 files changed, 14 deletions(-) diff --git a/lib/async/webdriver/bridge/chrome.rb b/lib/async/webdriver/bridge/chrome.rb index 4fe1063..75ac479 100644 --- a/lib/async/webdriver/bridge/chrome.rb +++ b/lib/async/webdriver/bridge/chrome.rb @@ -93,19 +93,6 @@ def self.for(version = :stable, cache_path: Installer.cache_path("chrome"), **op new(driver_path: installation.driver_path, browser_path: installation.browser_path, **options) end - # Download and install a specific version of Chrome for Testing if not already present. - # - # Useful in CI setup steps or bake tasks that want to pre-download before - # entering the Async reactor. - # - # @parameter version [Symbol | String] Version specifier — see {.for}. - # @parameter cache_path [String] Root of the cache directory. - # @returns [Installer::Chrome::Installation] The installation details. - def self.install(version = :stable, cache_path: Installer.cache_path("chrome")) - require_relative "../installer/chrome" - Installer::Chrome.install(version, cache_path: cache_path) - end - # The path to the Chrome browser executable. If `nil`, ChromeDriver uses its own discovery. # @returns [String | Nil] def browser_path diff --git a/releases.md b/releases.md index 892b99e..94a9c64 100644 --- a/releases.md +++ b/releases.md @@ -4,7 +4,6 @@ - Add `Async::WebDriver::Installer::Chrome` for automatic Chrome for Testing installation and management. `Installer::Chrome.install(version)` resolves the version via the Chrome for Testing JSON API, caches binaries in `~/.local/state/async-webdriver/` (XDG `$XDG_STATE_HOME`), and returns an `Installation` with paths to both the Chrome and ChromeDriver binaries. - Add `Bridge::Chrome.for(version)` as a convenience shorthand: installs the requested version if needed, then returns a fully configured `Chrome` bridge. Versions can be a channel symbol (`:stable`, `:beta`, `:dev`, `:canary`), a major version string (`"148"`), or an exact version string (`"148.0.7778.56"`). - - Add `Bridge::Chrome.install(version)` for pre-downloading in CI setup steps or bake tasks, before entering the Async reactor. - Add `bake async:webdriver:chrome:install` task for installing Chrome for Testing from the command line, e.g. in CI setup steps. - Fix `Bridge::Chrome#start`, `Bridge::Firefox#start`, and `Bridge::Safari#start` not forwarding the bridge's own options (including `:driver_path`) to the driver process. - Rename `path:` to `driver_path:` on `Bridge::Chrome`, `Bridge::Firefox`, and `Bridge::Safari` for consistency. Add `browser_path:` to `Bridge::Chrome` (mapped to `goog:chromeOptions.binary`) in place of the former `binary:` option, consistent with `Installer::Chrome::Installation#browser_path` and `#driver_path`. From e765758ded06b76f20778259f460b63335b24d0c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:25:54 +0900 Subject: [PATCH 24/25] RuboCop. --- lib/async/webdriver/installer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/async/webdriver/installer.rb b/lib/async/webdriver/installer.rb index f032e67..6fce27a 100644 --- a/lib/async/webdriver/installer.rb +++ b/lib/async/webdriver/installer.rb @@ -29,7 +29,7 @@ def self.cache_path(subdirectory = nil, env = ENV) if subdirectory path = File.join(path, subdirectory) end - + return path end end From 942460e276026d9633705e5d7e68544f4bebd3cc Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Apr 2026 16:26:58 +0900 Subject: [PATCH 25/25] Fix test assertions: use be =~ and be(:start_with?) for Sus compatibility. --- test/async/webdriver/installer/chrome/installation.rb | 2 +- test/async/webdriver/installer/chrome/releases.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/async/webdriver/installer/chrome/installation.rb b/test/async/webdriver/installer/chrome/installation.rb index 1278f32..1e80d32 100644 --- a/test/async/webdriver/installer/chrome/installation.rb +++ b/test/async/webdriver/installer/chrome/installation.rb @@ -29,7 +29,7 @@ installation = subject.install(:stable, cache_path: cache_path) expect(installation).to be_a(subject) - expect(installation.version).to match(/\A\d+\.\d+\.\d+\.\d+\z/) + expect(installation.version).to be =~ /\A\d+\.\d+\.\d+\.\d+\z/ expect(installation.platform).to be == platform expect(File.exist?(installation.browser_path)).to be == true expect(File.exist?(installation.driver_path)).to be == true diff --git a/test/async/webdriver/installer/chrome/releases.rb b/test/async/webdriver/installer/chrome/releases.rb index a2d37d4..e36b1df 100644 --- a/test/async/webdriver/installer/chrome/releases.rb +++ b/test/async/webdriver/installer/chrome/releases.rb @@ -13,7 +13,7 @@ it "resolves :stable to a version hash" do result = subject.resolve(:stable, platform) expect(result).to have_keys(:version, :chrome_url, :chromedriver_url) - expect(result[:version]).to match(/\A\d+\.\d+\.\d+\.\d+\z/) + expect(result[:version]).to be =~ /\A\d+\.\d+\.\d+\.\d+\z/ end it "resolves 'stable' string the same as :stable" do @@ -23,7 +23,7 @@ it "resolves a major version string" do major = subject.resolve(:stable, platform)[:version].split(".").first result = subject.resolve(major, platform) - expect(result[:version]).to start_with("#{major}.") + expect(result[:version]).to be(:start_with?, "#{major}.") end it "raises for an unknown channel" do