lib/selenium/server.rb



# frozen_string_literal: true

# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

require 'selenium/webdriver/common/child_process'
require 'selenium/webdriver/common/port_prober'
require 'selenium/webdriver/common/socket_poller'
require 'net/http'

module Selenium
  #
  # Wraps the remote server jar
  #
  # Usage:
  #
  #   server = Selenium::Server.new('/path/to/selenium-server-standalone.jar')
  #   server.start
  #
  # Automatically download the given version:
  #
  #   server = Selenium::Server.get '2.6.0'
  #   server.start
  #
  # or the latest version:
  #
  #   server = Selenium::Server.get :latest
  #   server.start
  #
  # Run the server in the background:
  #
  #   server = Selenium::Server.new(jar, :background => true)
  #   server.start
  #
  # Add additional arguments:
  #
  #   server = Selenium::Server.new(jar)
  #   server << ["--additional", "args"]
  #   server.start
  #

  class Server
    class Error < StandardError; end

    CL_RESET = WebDriver::Platform.windows? ? '' : "\r\e[0K"

    class << self
      #
      # Download the given version of the selenium-server jar and return instance
      #
      # @param [String, Symbol] required_version X.Y.Z defaults to ':latest'
      # @param [Hash] opts
      # @return [Selenium::Server]
      #

      def get(required_version = :latest, opts = {})
        new(download(required_version), opts)
      end

      #
      # Download the given version of the selenium-server jar and return location
      #
      # @param [String, Symbol] required_version X.Y.Z defaults to ':latest'
      # @return [String] location of downloaded file
      #

      def download(required_version = :latest)
        required_version = latest if required_version == :latest
        download_file_name = "selenium-server-#{required_version}.jar"

        return download_file_name if File.exist? download_file_name

        begin
          download_location = available_assets[download_file_name]['browser_download_url']
          released = Net::HTTP.get_response(URI.parse(download_location))
          redirected = URI.parse released.header['location']

          File.open(download_file_name, 'wb') do |destination|
            download_server(redirected, destination)
          end
        rescue StandardError
          FileUtils.rm_rf download_file_name
          raise
        end

        download_file_name
      end

      #
      # Ask GitHub what the latest selenium-server version is.
      #

      def latest
        @latest ||= begin
          available = available_assets.keys.map { |key| key[/selenium-server-(\d+\.\d+\.\d+)\.jar/, 1] }
          available.map { |asset| Gem::Version.new(asset) }.max.to_s
        end
      end

      # @api private

      def available_assets
        @available_assets ||= net_http_start('api.github.com') do |http|
          json = http.get('/repos/seleniumhq/selenium/releases').body
          all_assets = JSON.parse(json).map { |release| release['assets'] }.flatten
          server_assets = all_assets.select { |asset| asset['name'].match(/selenium-server-(\d+\.\d+\.\d+)\.jar/) }
          server_assets.each_with_object({}) { |asset, hash| hash[asset.delete('name')] = asset }
        end
      end

      def net_http_start(address, &)
        http_proxy = ENV.fetch('http_proxy', nil) || ENV.fetch('HTTP_PROXY', nil)
        if http_proxy
          http_proxy = "http://#{http_proxy}" unless http_proxy.start_with?('http://')
          uri = URI.parse(http_proxy)

          Net::HTTP.start(address, nil, uri.host, uri.port, &)
        else
          Net::HTTP.start(address, use_ssl: true, &)
        end
      end

      def download_server(uri, destination)
        net_http_start('github-releases.githubusercontent.com') do |http|
          request = Net::HTTP::Get.new uri
          resp = http.request(request) do |response|
            total = response.content_length
            progress = 0
            segment_count = 0

            response.read_body do |segment|
              progress += segment.length
              segment_count += 1

              if (segment_count % 15).zero?
                percent = progress.fdiv(total) * 100
                print "#{CL_RESET}Downloading #{destination.path}: #{percent.to_i}% (#{progress} / #{total})"
                segment_count = 0
              end

              destination.write(segment)
            end
          end

          raise Error, "#{resp.code} for #{destination.path}" unless resp.is_a? Net::HTTPSuccess
        end
      end
    end

    #
    # The Mode of the Server
    # :standalone, #hub, #node
    #

    attr_accessor :role, :host, :port, :timeout, :background, :log

    #
    # @param [String] jar Path to the server jar.
    # @param [Hash] opts the options to create the server process with
    #
    # @option opts [Integer] :port Port the server should listen on (default: 4444).
    # @option opts [Integer] :timeout Seconds to wait for server launch/shutdown (default: 30)
    # @option opts [true,false] :background Run the server in the background (default: false)
    # @option opts [true,false,String] :log Either a path to a log file,
    #                                      or true to pass server log to stdout.
    # @raise [Errno::ENOENT] if the jar file does not exist
    #

    def initialize(jar, opts = {})
      raise Errno::ENOENT, jar unless File.exist?(jar)

      @java = opts.fetch(:java, 'java')
      @jar = jar
      @host = '127.0.0.1'
      @role = opts.fetch(:role, 'standalone')
      @port = opts.fetch(:port, WebDriver::PortProber.above(4444))
      @timeout = opts.fetch(:timeout, 30)
      @background = opts.fetch(:background, false)
      @additional_args = opts.fetch(:args, [])
      @log = opts[:log]
      if opts[:log_level]
        @log ||= true
        @additional_args << '--log-level'
        @additional_args << opts[:log_level].to_s
      end

      @log_file = nil
    end

    def start
      process.start
      poll_for_service

      process.wait unless @background
    end

    def stop
      stop_process if @process
      poll_for_shutdown

      @log_file&.close
    end

    def webdriver_url
      "http://#{@host}:#{@port}/wd/hub"
    end

    def <<(arg)
      if arg.is_a?(Array)
        @additional_args += arg
      else
        @additional_args << arg.to_s
      end
    end

    private

    def stop_process
      @process.stop
    rescue Errno::ECHILD
      # already dead
    ensure
      @process = nil
    end

    def process
      @process ||= begin
        # extract any additional_args that start with -D as options
        properties = @additional_args.dup - @additional_args.delete_if { |arg| arg[/^-D/] }
        args = ['-jar', @jar, @role, '--port', @port.to_s]
        server_command = [@java] + properties + args + @additional_args
        cp = WebDriver::ChildProcess.build(*server_command)

        if @log.is_a?(String)
          cp.io = @log
        elsif @log
          cp.io = :out
        end

        cp.detach = @background

        cp
      end
    end

    def poll_for_service
      return if socket.connected?

      raise Error, "remote server not launched in #{@timeout} seconds"
    end

    def poll_for_shutdown
      return if socket.closed?

      raise Error, "remote server not stopped in #{@timeout} seconds"
    end

    def socket
      @socket ||= WebDriver::SocketPoller.new(@host, @port, @timeout)
    end
  end # Server
end # Selenium