async-webdriver 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a70af1593c116227ab4863c4e209bb4002faf9bdfe18e7183b0656811b83066
4
- data.tar.gz: 64ca1a5125e4a8289f2d55b6a6fe8b6e8579ae1b0b9ffd139bcc407d22a4702a
3
+ metadata.gz: 243addf7d5e3f7a978552847d2ae011d34399050668c0c2fc7b6573cacc9fa7e
4
+ data.tar.gz: 296591c00edb0071b417a0bf70dc42ef3caed330d4155b15b81da7a2c6ae5282
5
5
  SHA512:
6
- metadata.gz: e9770cacf231cf999256001afb4e53a6c8d2dbbe5ea34f9d67974651ba8c2d04ff7ce0984aa083dfe15895d63ec7c2265de7d6d919e9571a4e7cd11a8f65cc6a
7
- data.tar.gz: f775e069001ebbf3a52a4d909e8734242c039410c218142c44cd1973921af1f6490098debd3be854dbd214e017908e277d4a6a5f6c1585393254cacce3992d97
6
+ metadata.gz: 9337ecc0e8a306a5f2b4ddabaffd2cc76f03ba1cd3132cb76ef22cfca413bf63f5dd318efb1c93b864250c8238b5726551c8e9d3e20f5b78b9655f59de7912f5
7
+ data.tar.gz: f367473a2e748c86b3dbbfcfbe3286c09e0e9c70b8e3a44e0fa504ec9dec48f3f3446b08cdc83386dc0cc898f09a7e67122e941b52e24e5008624331ce5be8c3
checksums.yaml.gz.sig CHANGED
Binary file
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "generic"
7
7
  require_relative "process_group"
8
+ require_relative "../installer"
8
9
 
9
10
  module Async
10
11
  module WebDriver
@@ -21,13 +22,13 @@ module Async
21
22
  # ```
22
23
  class Chrome < Generic
23
24
  # @returns [String] The path to the `chromedriver` executable.
24
- def path
25
- @options.fetch(:path, "chromedriver")
25
+ def driver_path
26
+ @options.fetch(:driver_path, "chromedriver")
26
27
  end
27
28
 
28
29
  # @returns [String] The version of the `chromedriver` executable.
29
30
  def version
30
- ::IO.popen([self.path, "--version"]) do |io|
31
+ ::IO.popen([self.driver_path, "--version"]) do |io|
31
32
  return io.read
32
33
  end
33
34
  rescue Errno::ENOENT
@@ -46,7 +47,7 @@ module Async
46
47
  # @returns [Array(String)] The arguments to pass to the `chromedriver` executable.
47
48
  def arguments(**options)
48
49
  [
49
- options.fetch(:path, "chromedriver"),
50
+ options.fetch(:driver_path, "chromedriver"),
50
51
  "--port=#{self.port}",
51
52
  ].compact
52
53
  end
@@ -69,21 +70,51 @@ module Async
69
70
  end
70
71
  end
71
72
 
72
- # Start the driver.
73
+ # Start the driver, forwarding the bridge's own options to the driver process
74
+ # so that a custom `:driver_path` reaches the chromedriver executable.
73
75
  def start(**options)
74
- Driver.new(**options).tap(&:start)
76
+ Driver.new(**@options, **options).tap(&:start)
77
+ end
78
+
79
+ # Ensure the given version of Chrome for Testing is installed and return a
80
+ # fully configured {Chrome} bridge pointing at it.
81
+ #
82
+ # Delegates to {Async::WebDriver::Installer::Chrome.install} for version
83
+ # resolution and download, then wraps the result in a configured bridge.
84
+ #
85
+ # @parameter version [Symbol | String] `:stable`, `:beta`, `:dev`, `:canary`,
86
+ # a major version string like `"148"`, or an exact version like `"148.0.7778.56"`.
87
+ # @parameter cache_path [String] Root of the cache directory.
88
+ # Default: `~/.cache/async-webdriver.rb` (XDG-compliant).
89
+ # @parameter options [Hash] Additional options forwarded to {.new} (e.g. `headless: false`).
90
+ # @returns [Chrome] A configured bridge.
91
+ def self.for(version = :stable, cache_path: Installer.cache_path("chrome"), **options)
92
+ require_relative "../installer/chrome"
93
+ installation = Installer::Chrome.find(version, cache_path: cache_path) || Installer::Chrome.install(version, cache_path: cache_path)
94
+ new(driver_path: installation.driver_path, browser_path: installation.browser_path, **options)
95
+ end
96
+
97
+ # The path to the Chrome browser executable. If `nil`, ChromeDriver uses its own discovery.
98
+ # @returns [String | Nil]
99
+ def browser_path
100
+ @options[:browser_path]
75
101
  end
76
102
 
77
103
  # The default capabilities for the Chrome browser which need to be provided when requesting a new session.
78
104
  # @parameter headless [Boolean] Whether to run the browser in headless mode.
105
+ # @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.
79
106
  # @returns [Hash] The default capabilities for the Chrome browser.
80
- def default_capabilities(headless: self.headless?)
107
+ def default_capabilities(headless: self.headless?, browser_path: self.browser_path)
108
+ chrome_options = {
109
+ args: [headless ? "--headless=new" : nil].compact,
110
+ }
111
+
112
+ chrome_options[:binary] = browser_path if browser_path
113
+
81
114
  {
82
115
  alwaysMatch: {
83
116
  browserName: "chrome",
84
- "goog:chromeOptions": {
85
- args: [headless ? "--headless=new" : nil].compact,
86
- },
117
+ "goog:chromeOptions": chrome_options,
87
118
  webSocketUrl: true,
88
119
  },
89
120
  }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  module WebDriver
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "generic"
7
7
  require_relative "process_group"
@@ -20,13 +20,13 @@ module Async
20
20
  # end
21
21
  class Firefox < Generic
22
22
  # @returns [String] The path to the `geckodriver` executable.
23
- def path
24
- @options.fetch(:path, "geckodriver")
23
+ def driver_path
24
+ @options.fetch(:driver_path, "geckodriver")
25
25
  end
26
26
 
27
27
  # @returns [String] The version of the `geckodriver` executable.
28
28
  def version
29
- ::IO.popen([self.path, "--version"]) do |io|
29
+ ::IO.popen([self.driver_path, "--version"]) do |io|
30
30
  return io.read
31
31
  end
32
32
  rescue Errno::ENOENT
@@ -47,10 +47,10 @@ module Async
47
47
  1
48
48
  end
49
49
 
50
- # @returns [Array(String)] The arguments to pass to the `chromedriver` executable.
50
+ # @returns [Array(String)] The arguments to pass to the `geckodriver` executable.
51
51
  def arguments(**options)
52
52
  [
53
- options.fetch(:path, "geckodriver"),
53
+ options.fetch(:driver_path, "geckodriver"),
54
54
  "--port", self.port.to_s,
55
55
  ].compact
56
56
  end
@@ -75,7 +75,7 @@ module Async
75
75
 
76
76
  # Start the driver.
77
77
  def start(**options)
78
- Driver.new(**options).tap(&:start)
78
+ Driver.new(**@options, **options).tap(&:start)
79
79
  end
80
80
 
81
81
  # The default capabilities for the Firefox browser which need to be provided when requesting a new session.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require "socket"
7
7
  require "async/http/endpoint"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require "async/actor"
7
7
  require "async/pool"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "driver"
7
7
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024-2025, by Samuel Williams.
4
+ # Copyright, 2024-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "generic"
7
7
  require_relative "process_group"
@@ -21,13 +21,13 @@ module Async
21
21
  # ```
22
22
  class Safari < Generic
23
23
  # @returns [String] The path to the `safaridriver` executable.
24
- def path
25
- @options.fetch(:path, "safaridriver")
24
+ def driver_path
25
+ @options.fetch(:driver_path, "safaridriver")
26
26
  end
27
27
 
28
28
  # @returns [String] The version of the `safaridriver` executable.
29
29
  def version
30
- ::IO.popen([self.path, "--version"]) do |io|
30
+ ::IO.popen([self.driver_path, "--version"]) do |io|
31
31
  return io.read
32
32
  end
33
33
  rescue Errno::ENOENT
@@ -46,7 +46,7 @@ module Async
46
46
  # @returns [Array(String)] The arguments to pass to the `safaridriver` executable.
47
47
  def arguments(**options)
48
48
  [
49
- options.fetch(:path, "safaridriver"),
49
+ options.fetch(:driver_path, "safaridriver"),
50
50
  "--port=#{self.port}",
51
51
  ].compact
52
52
  end
@@ -71,7 +71,7 @@ module Async
71
71
 
72
72
  # Start the driver.
73
73
  def start(**options)
74
- Driver.new(**options).tap(&:start)
74
+ Driver.new(**@options, **options).tap(&:start)
75
75
  end
76
76
 
77
77
  # The default capabilities for the Safari browser which need to be provided when requesting a new session.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "bridge/chrome"
7
7
  require_relative "bridge/firefox"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "request_helper"
7
7
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "version"
7
7
 
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "fileutils"
7
+ require "tempfile"
8
+ require_relative "platform"
9
+ require_relative "releases"
10
+
11
+ module Async
12
+ module WebDriver
13
+ module Installer
14
+ module Chrome
15
+ # Represents a Chrome for Testing installation on disk, and provides class-level
16
+ # methods for resolving, locating, and downloading installations.
17
+ #
18
+ # Installations are stored under the cache_path directory, organised as:
19
+ #
20
+ # {cache_path}/{platform}/{version}/
21
+ # chrome/ ← extracted chrome zip contents
22
+ # chromedriver/ ← extracted chromedriver zip contents
23
+ #
24
+ # Channel names (e.g. `stable`) are stored as symlinks pointing at the
25
+ # specific version directory, so that {find} can resolve them without
26
+ # hitting the network. {install} always re-checks the API and updates
27
+ # the symlink if the channel has moved on to a newer version.
28
+ class Installation
29
+ # Look up an existing installation, or download and install a fresh one.
30
+ #
31
+ # For channel specifiers (`:stable`, `:beta`, etc.), always hits the
32
+ # Chrome for Testing API to resolve the current version, downloads if
33
+ # needed, and updates the channel symlink. For exact versions, checks
34
+ # the local cache only.
35
+ #
36
+ # @parameter version [Symbol | String] Channel or version specifier.
37
+ # @parameter cache_path [String] Root of the cache directory.
38
+ # @returns [Installation]
39
+ def self.install(version, cache_path:)
40
+ platform = Platform.current
41
+ release = Releases.resolve(version, platform)
42
+
43
+ unless installation = find(release[:version], platform, cache_path: cache_path)
44
+ Console.info(self, "Installing Chrome for Testing #{release[:version]}...", platform: platform)
45
+
46
+ dir = installation_dir(release[:version], platform, cache_path: cache_path)
47
+ FileUtils.mkdir_p(dir)
48
+
49
+ begin
50
+ download_and_extract(release[:chrome_url], File.join(dir, "chrome"))
51
+ download_and_extract(release[:chromedriver_url], File.join(dir, "chromedriver"))
52
+
53
+ installation = find(release[:version], platform, cache_path: cache_path) or
54
+ raise "Installation failed: binaries not found after extraction"
55
+
56
+ Console.info(self, "Installed Chrome for Testing #{release[:version]}.", platform: platform)
57
+ rescue
58
+ FileUtils.rm_rf(dir)
59
+ raise
60
+ end
61
+ end
62
+
63
+ # Update the channel symlink so subsequent find(:stable) calls
64
+ # resolve locally without a network request.
65
+ if channel = channel_name(version)
66
+ update_channel_symlink(channel, release[:version], platform, cache_path: cache_path)
67
+ end
68
+
69
+ return installation
70
+ end
71
+
72
+ # Find an already-installed version or channel, without hitting the network.
73
+ #
74
+ # For channel names (`:stable`, `"stable"`, etc.), resolves the local
75
+ # symlink. For exact versions, checks the installation directory directly.
76
+ #
77
+ # @parameter version [Symbol | String] Channel or exact version string.
78
+ # @parameter platform [String] Platform string, e.g. `"mac-arm64"`.
79
+ # @parameter cache_path [String] Root of the cache directory.
80
+ # @returns [Installation | Nil]
81
+ def self.find(version, platform, cache_path:)
82
+ if channel = channel_name(version)
83
+ find_channel(channel, platform, cache_path: cache_path)
84
+ else
85
+ find_version(version, platform, cache_path: cache_path)
86
+ end
87
+ end
88
+
89
+ # @parameter browser_path [String] Absolute path to the Chrome browser executable.
90
+ # @parameter driver_path [String] Absolute path to the chromedriver executable.
91
+ # @parameter version [String] Exact version string.
92
+ # @parameter platform [String] Platform string.
93
+ def initialize(browser_path:, driver_path:, version:, platform:)
94
+ @browser_path = browser_path
95
+ @driver_path = driver_path
96
+ @version = version
97
+ @platform = platform
98
+ end
99
+
100
+ # @attribute [String] Absolute path to the Chrome browser executable.
101
+ attr :browser_path
102
+
103
+ # @attribute [String] Absolute path to the chromedriver executable.
104
+ attr :driver_path
105
+
106
+ # @attribute [String] Exact installed version, e.g. `"148.0.7778.56"`.
107
+ attr :version
108
+
109
+ # @attribute [String] Platform, e.g. `"mac-arm64"`.
110
+ attr :platform
111
+
112
+ private_class_method def self.channel_name(version)
113
+ Releases::CHANNELS.key(version.to_s.capitalize) && version.to_s.downcase
114
+ end
115
+
116
+ private_class_method def self.find_channel(channel, platform, cache_path:)
117
+ symlink = channel_symlink(channel, platform, cache_path: cache_path)
118
+ return nil unless File.symlink?(symlink)
119
+
120
+ # Derive the version from the symlink target name.
121
+ version = File.basename(File.readlink(symlink))
122
+ find_version(version, platform, cache_path: cache_path)
123
+ end
124
+
125
+ private_class_method def self.find_version(version, platform, cache_path:)
126
+ dir = installation_dir(version, platform, cache_path: cache_path)
127
+
128
+ browser_path = File.join(dir, "chrome", Platform.chrome_binary(platform))
129
+ driver_path = File.join(dir, "chromedriver", Platform.chromedriver_binary(platform))
130
+
131
+ return nil unless File.exist?(browser_path) && File.exist?(driver_path)
132
+
133
+ new(
134
+ browser_path: browser_path,
135
+ driver_path: driver_path,
136
+ version: version,
137
+ platform: platform,
138
+ )
139
+ end
140
+
141
+ private_class_method def self.update_channel_symlink(channel, version, platform, cache_path:)
142
+ symlink = channel_symlink(channel, platform, cache_path: cache_path)
143
+ target = installation_dir(version, platform, cache_path: cache_path)
144
+
145
+ # Remove stale symlink if it points elsewhere.
146
+ if File.symlink?(symlink) && File.readlink(symlink) != target
147
+ File.unlink(symlink)
148
+ end
149
+
150
+ File.symlink(target, symlink) unless File.symlink?(symlink)
151
+ end
152
+
153
+ private_class_method def self.channel_symlink(channel, platform, cache_path:)
154
+ File.join(cache_path, platform, channel.to_s)
155
+ end
156
+
157
+ private_class_method def self.installation_dir(version, platform, cache_path:)
158
+ File.join(cache_path, platform, version)
159
+ end
160
+
161
+ private_class_method def self.download_and_extract(url, dest)
162
+ require "async/http/internet"
163
+
164
+ Tempfile.create(["async-webdriver-", ".zip"]) do |tmp|
165
+ tmp.binmode
166
+
167
+ Sync do
168
+ internet = Async::HTTP::Internet.new
169
+ begin
170
+ Console.debug(self, "Downloading...", url: url)
171
+ response = internet.get(url)
172
+ tmp.write(response.read)
173
+ tmp.flush
174
+ ensure
175
+ internet.close
176
+ end
177
+ end
178
+
179
+ FileUtils.mkdir_p(dest)
180
+ system("unzip", "-q", "-o", tmp.path, "-d", dest) or
181
+ raise "Failed to extract #{url}"
182
+
183
+ # Remove macOS quarantine attributes added to files downloaded via code.
184
+ if RUBY_PLATFORM.include?("darwin")
185
+ system("xattr", "-r", "-d", "com.apple.quarantine", dest)
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Async
7
+ module WebDriver
8
+ module Installer
9
+ module Chrome
10
+ # Platform detection for Chrome for Testing downloads.
11
+ #
12
+ # Maps Ruby's `RUBY_PLATFORM` to the platform strings used by the
13
+ # Chrome for Testing JSON API and zip file naming conventions.
14
+ module Platform
15
+ # Ordered list of (pattern, platform) pairs. First match wins.
16
+ PLATFORM_MAP = [
17
+ [/arm.*darwin|darwin.*arm|aarch64.*darwin|darwin.*aarch64/, "mac-arm64"],
18
+ [/darwin/, "mac-x64"],
19
+ [/aarch64.*linux|linux.*aarch64/, "linux-arm64"],
20
+ [/linux/, "linux64"],
21
+ [/x64.*mingw|mingw.*x64/, "win64"],
22
+ [/mingw/, "win32"],
23
+ ].freeze
24
+
25
+ # Detect the current platform.
26
+ # @returns [String] e.g. `"mac-arm64"`, `"linux64"`.
27
+ # @raises [RuntimeError] If the platform is not recognised.
28
+ def self.current
29
+ PLATFORM_MAP.each do |pattern, platform|
30
+ return platform if RUBY_PLATFORM.match?(pattern)
31
+ end
32
+ raise "Unsupported platform: #{RUBY_PLATFORM}"
33
+ end
34
+
35
+ # Relative path to the Chrome binary inside the extracted chrome zip.
36
+ # @parameter platform [String]
37
+ # @returns [String]
38
+ def self.chrome_binary(platform)
39
+ case platform
40
+ when "mac-arm64"
41
+ "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
42
+ when "mac-x64"
43
+ "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
44
+ when "linux64"
45
+ "chrome-linux64/chrome"
46
+ when "linux-arm64"
47
+ "chrome-linux-arm64/chrome"
48
+ when "win64"
49
+ "chrome-win64/chrome.exe"
50
+ when "win32"
51
+ "chrome-win32/chrome.exe"
52
+ else
53
+ raise "Unknown platform: #{platform}"
54
+ end
55
+ end
56
+
57
+ # Relative path to the chromedriver binary inside the extracted chromedriver zip.
58
+ # @parameter platform [String]
59
+ # @returns [String]
60
+ def self.chromedriver_binary(platform)
61
+ case platform
62
+ when "mac-arm64"
63
+ "chromedriver-mac-arm64/chromedriver"
64
+ when "mac-x64"
65
+ "chromedriver-mac-x64/chromedriver"
66
+ when "linux64"
67
+ "chromedriver-linux64/chromedriver"
68
+ when "linux-arm64"
69
+ "chromedriver-linux-arm64/chromedriver"
70
+ when "win64"
71
+ "chromedriver-win64/chromedriver.exe"
72
+ when "win32"
73
+ "chromedriver-win32/chromedriver.exe"
74
+ else
75
+ raise "Unknown platform: #{platform}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "json"
7
+
8
+ module Async
9
+ module WebDriver
10
+ module Installer
11
+ module Chrome
12
+ # Resolves Chrome for Testing version specifiers and download URLs using the
13
+ # public Chrome for Testing JSON API.
14
+ module Releases
15
+ # Returns the latest known-good version for each release channel.
16
+ CHANNELS_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"
17
+
18
+ # Returns every known-good version with its download URLs.
19
+ VERSIONS_URL = "https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json"
20
+
21
+ # Maps symbolic channel names to the API's title-case keys.
22
+ CHANNELS = {
23
+ stable: "Stable",
24
+ beta: "Beta",
25
+ dev: "Dev",
26
+ canary: "Canary",
27
+ }.freeze
28
+
29
+ # Resolve a version specifier and platform to a version string and download URLs.
30
+ #
31
+ # @parameter version [Symbol | String] `:stable`, `:beta`, `:dev`, `:canary`,
32
+ # a major version string like `"148"`, or an exact version like `"148.0.7778.56"`.
33
+ # @parameter platform [String] A Chrome for Testing platform string, e.g. `"mac-arm64"`.
34
+ # @returns [Hash] `{ version:, chrome_url:, chromedriver_url: }`
35
+ def self.resolve(version, platform)
36
+ case version
37
+ when Symbol then resolve_channel(version, platform)
38
+ when /\A(stable|beta|dev|canary)\z/ then resolve_channel(version.to_sym, platform)
39
+ when /\A\d+\z/ then resolve_major(version, platform)
40
+ else resolve_exact(version, platform)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def self.fetch_json(url)
47
+ require "async/http/internet"
48
+
49
+ Sync do
50
+ internet = Async::HTTP::Internet.new
51
+ begin
52
+ response = internet.get(url)
53
+ JSON.parse(response.read)
54
+ ensure
55
+ internet.close
56
+ end
57
+ end
58
+ end
59
+
60
+ def self.resolve_channel(channel, platform)
61
+ key = CHANNELS.fetch(channel) do
62
+ raise ArgumentError, "Unknown channel #{channel.inspect}. Expected one of: #{CHANNELS.keys.inspect}"
63
+ end
64
+
65
+ data = fetch_json(CHANNELS_URL)
66
+ entry = data.dig("channels", key) or raise "Channel #{key} not found in API response"
67
+
68
+ extract(entry, platform)
69
+ end
70
+
71
+ def self.resolve_major(major, platform)
72
+ data = fetch_json(VERSIONS_URL)
73
+
74
+ entry = data["versions"]
75
+ .select{|v| v["version"].start_with?("#{major}.")}
76
+ .max_by{|v| Gem::Version.new(v["version"])}
77
+
78
+ raise "No version found for major version #{major}" unless entry
79
+
80
+ extract(entry, platform)
81
+ end
82
+
83
+ def self.resolve_exact(version, platform)
84
+ data = fetch_json(VERSIONS_URL)
85
+
86
+ entry = data["versions"].find{|v| v["version"] == version}
87
+ raise "Version #{version} not found" unless entry
88
+
89
+ extract(entry, platform)
90
+ end
91
+
92
+ def self.extract(entry, platform)
93
+ version = entry["version"]
94
+ downloads = entry["downloads"]
95
+
96
+ chrome_url = downloads["chrome"]
97
+ &.find{|d| d["platform"] == platform}
98
+ &.dig("url")
99
+
100
+ chromedriver_url = downloads["chromedriver"]
101
+ &.find{|d| d["platform"] == platform}
102
+ &.dig("url")
103
+
104
+ raise "No Chrome download for platform #{platform} in version #{version}" unless chrome_url
105
+ raise "No ChromeDriver download for platform #{platform} in version #{version}" unless chromedriver_url
106
+
107
+ {version: version, chrome_url: chrome_url, chromedriver_url: chromedriver_url}
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "chrome/platform"
7
+ require_relative "chrome/releases"
8
+ require_relative "chrome/installation"
9
+
10
+ module Async
11
+ module WebDriver
12
+ module Installer
13
+ # Installer for Chrome for Testing, the purpose-built Chrome variant
14
+ # designed for automated testing.
15
+ #
16
+ # Versions can be specified as:
17
+ # - A channel symbol: `:stable`, `:beta`, `:dev`, `:canary`
18
+ # - A major version string: `"148"` (resolves to the latest patch)
19
+ # - An exact version string: `"148.0.7778.56"`
20
+ #
21
+ # Installations are cached in `~/.cache/async-webdriver.rb/` by default
22
+ # (respects `$XDG_CACHE_HOME`).
23
+ #
24
+ # ## Example
25
+ #
26
+ # ``` ruby
27
+ # installation = Async::WebDriver::Installer::Chrome.install(:stable)
28
+ # bridge = Async::WebDriver::Bridge::Chrome.new(
29
+ # driver_path: installation.driver_path,
30
+ # browser_path: installation.browser_path,
31
+ # )
32
+ # ```
33
+ #
34
+ # Or via the convenience shorthand on the bridge:
35
+ #
36
+ # ``` ruby
37
+ # bridge = Async::WebDriver::Bridge::Chrome.for(:stable)
38
+ # ```
39
+ module Chrome
40
+ # Default cache directory, following the XDG Base Directory Specification.
41
+
42
+
43
+ # Ensure the given version is installed and return an {Installation}.
44
+ #
45
+ # Checks the local cache first; downloads from the Chrome for Testing
46
+ # infrastructure only when the version is not already present.
47
+ #
48
+ # @parameter version [Symbol | String] Version specifier.
49
+ # @parameter cache_path [String] Root of the cache directory.
50
+ # @returns [Installation]
51
+ def self.install(version = :stable, cache_path: Installer.cache_path("chrome"))
52
+ Installation.install(version, cache_path: cache_path)
53
+ end
54
+
55
+ # Find an already-installed version or channel without hitting the network.
56
+ #
57
+ # @parameter version [Symbol | String] Channel or exact version string.
58
+ # @parameter cache_path [String] Root of the cache directory.
59
+ # @returns [Installation | Nil]
60
+ def self.find(version, cache_path: Installer.cache_path("chrome"))
61
+ Installation.find(version, Platform.current, cache_path: cache_path)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "installer/chrome"
7
+
8
+ module Async
9
+ module WebDriver
10
+ # Browser installation and management for automated testing.
11
+ #
12
+ # Each browser has its own sub-module with browser-specific platform detection,
13
+ # version resolution, and download logic:
14
+ #
15
+ # - {Installer::Chrome} — Chrome for Testing, via the Chrome for Testing JSON API.
16
+ module Installer
17
+ # Resolve the cache path for the given sub-directory.
18
+ #
19
+ # Follows the XDG Base Directory Specification, using `$XDG_CACHE_HOME`
20
+ # (default: `~/.cache`) as the root, with `async-webdriver.rb` as the
21
+ # application directory.
22
+ #
23
+ # @parameter subdirectory [String | Nil] Optional sub-directory, e.g. `"chrome"`.
24
+ # @parameter env [Hash] Environment to read `XDG_CACHE_HOME` from. Default: `ENV`.
25
+ # @returns [String] Absolute path.
26
+ def self.cache_path(subdirectory = nil, env = ENV)
27
+ path = File.expand_path("async-webdriver.rb", env.fetch("XDG_CACHE_HOME", "~/.cache"))
28
+
29
+ if subdirectory
30
+ path = File.join(path, subdirectory)
31
+ end
32
+
33
+ return path
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require "base64"
7
7
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  module WebDriver
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "scope/alerts"
7
7
  require_relative "scope/cookies"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "request_helper"
7
7
  require_relative "element"
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023-2025, by Samuel Williams.
4
+ # Copyright, 2023-2026, by Samuel Williams.
5
5
 
6
6
  # @namespace
7
7
  module Async
8
8
  # @namespace
9
9
  module WebDriver
10
- VERSION = "0.11.0"
10
+ VERSION = "0.12.1"
11
11
  end
12
12
  end
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2023-2025, by Samuel Williams.
3
+ Copyright, 2023-2026, by Samuel Williams.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/readme.md CHANGED
@@ -28,6 +28,14 @@ Please see the [project documentation](https://socketry.github.io/async-webdrive
28
28
 
29
29
  Please see the [project releases](https://socketry.github.io/async-webdriver/releases/index) for all releases.
30
30
 
31
+ ### v0.12.0
32
+
33
+ - 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.
34
+ - 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"`).
35
+ - Add `bake async:webdriver:chrome:install` task for installing Chrome for Testing from the command line, e.g. in CI setup steps.
36
+ - 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.
37
+ - 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`.
38
+
31
39
  ### v0.11.0
32
40
 
33
41
  - Add `Scope::Window` with `#window_rect`, `#resize_window`, `#set_window_rect`, `#maximize_window`, `#minimize_window`, and `#fullscreen_window`.
data/releases.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Releases
2
2
 
3
+ ## v0.12.0
4
+
5
+ - 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.
6
+ - 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"`).
7
+ - Add `bake async:webdriver:chrome:install` task for installing Chrome for Testing from the command line, e.g. in CI setup steps.
8
+ - 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.
9
+ - 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`.
10
+
3
11
  ## v0.11.0
4
12
 
5
13
  - Add `Scope::Window` with `#window_rect`, `#resize_window`, `#set_window_rect`, `#maximize_window`, `#minimize_window`, and `#fullscreen_window`.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-webdriver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -130,6 +130,11 @@ files:
130
130
  - lib/async/webdriver/client.rb
131
131
  - lib/async/webdriver/element.rb
132
132
  - lib/async/webdriver/error.rb
133
+ - lib/async/webdriver/installer.rb
134
+ - lib/async/webdriver/installer/chrome.rb
135
+ - lib/async/webdriver/installer/chrome/installation.rb
136
+ - lib/async/webdriver/installer/chrome/platform.rb
137
+ - lib/async/webdriver/installer/chrome/releases.rb
133
138
  - lib/async/webdriver/locator.rb
134
139
  - lib/async/webdriver/request_helper.rb
135
140
  - lib/async/webdriver/scope.rb
metadata.gz.sig CHANGED
Binary file