diff --git a/bake/async/webdriver/chrome.rb b/bake/async/webdriver/chrome.rb new file mode 100644 index 0000000..8f936a6 --- /dev/null +++ b/bake/async/webdriver/chrome.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 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 `~/.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`), +# 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" + + 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, + ) + + return installation +end diff --git a/lib/async/webdriver/bridge/chrome.rb b/lib/async/webdriver/bridge/chrome.rb index ffe5449..75ac479 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,51 @@ 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 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_path: Installer.cache_path("chrome"), **options) + require_relative "../installer/chrome" + 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 + + # 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..6fce27a --- /dev/null +++ b/lib/async/webdriver/installer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, 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 + # 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) + 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 +end diff --git a/lib/async/webdriver/installer/chrome.rb b/lib/async/webdriver/installer/chrome.rb new file mode 100644 index 0000000..55612a1 --- /dev/null +++ b/lib/async/webdriver/installer/chrome.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, 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 `~/.cache/async-webdriver.rb/` by default + # (respects `$XDG_CACHE_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 cache directory, following the XDG Base Directory Specification. + + + # 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 cache_path [String] Root of the cache directory. + # @returns [Installation] + 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_path [String] Root of the cache directory. + # @returns [Installation | Nil] + def self.find(version, cache_path: Installer.cache_path("chrome")) + Installation.find(version, Platform.current, cache_path: cache_path) + 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..5aa2cb7 --- /dev/null +++ b/lib/async/webdriver/installer/chrome/installation.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "fileutils" +require "tempfile" +require_relative "platform" +require_relative "releases" + +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 cache_path directory, organised as: + # + # {cache_path}/{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. + # + # 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 cache_path [String] Root of the cache directory. + # @returns [Installation] + def self.install(version, cache_path:) + platform = Platform.current + release = Releases.resolve(version, platform) + + 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, 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, cache_path: cache_path) 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 + + # 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, cache_path: cache_path) + end + + return installation + end + + # 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 [Symbol | String] Channel or exact version string. + # @parameter platform [String] Platform string, e.g. `"mac-arm64"`. + # @parameter cache_path [String] Root of the cache directory. + # @returns [Installation | Nil] + def self.find(version, platform, cache_path:) + if channel = channel_name(version) + find_channel(channel, platform, cache_path: cache_path) + else + find_version(version, platform, cache_path: cache_path) + end + 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.channel_name(version) + Releases::CHANNELS.key(version.to_s.capitalize) && version.to_s.downcase + end + + 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, cache_path: cache_path) + end + + 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)) + + 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, 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 + File.unlink(symlink) + end + + File.symlink(target, symlink) unless File.symlink?(symlink) + end + + 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, cache_path:) + File.join(cache_path, 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..f1aef10 --- /dev/null +++ b/lib/async/webdriver/installer/chrome/platform.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, 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" + "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 + + # 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" + "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 + 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..bbea720 --- /dev/null +++ b/lib/async/webdriver/installer/chrome/releases.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, 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(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 + + 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..94a9c64 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. + - 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 `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`. + ## v0.11.0 - Add `Scope::Window` with `#window_rect`, `#resize_window`, `#set_window_rect`, `#maximize_window`, `#minimize_window`, and `#fullscreen_window`. diff --git a/test/async/webdriver/bridge.rb b/test/async/webdriver/bridge.rb index c482e83..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" @@ -32,9 +32,8 @@ def driver @driver ||= bridge.start end - def after(error = nil) + after do @driver&.close - super end it_behaves_like ABridge diff --git a/test/async/webdriver/installer/chrome/installation.rb b/test/async/webdriver/installer/chrome/installation.rb new file mode 100644 index 0000000..1e80d32 --- /dev/null +++ b/test/async/webdriver/installer/chrome/installation.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, 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(:cache_path) {Dir.mktmpdir("async-webdriver-test-")} + + after do + FileUtils.rm_rf(cache_path) + end + + with ".find" do + it "returns nil when nothing is installed" do + 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, cache_path: cache_path)).to be_nil + end + end + + with ".install" do + it "installs stable and returns an Installation" do + installation = subject.install(:stable, cache_path: cache_path) + + expect(installation).to be_a(subject) + 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 + end + + it "creates a channel symlink" do + 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, 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, 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 + 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..9a21882 --- /dev/null +++ b/test/async/webdriver/installer/chrome/platform.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, 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..e36b1df --- /dev/null +++ b/test/async/webdriver/installer/chrome/releases.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, 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 be =~ /\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 be(:start_with?, "#{major}.") + end + + it "raises for an unknown channel" do + expect{subject.resolve(:nightly, platform)}.to raise_exception(ArgumentError) + end + end +end