test/test_cli.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require_relative "helper"
require_relative "helpers/ssl" if ::Puma::HAS_SSL
require_relative "helpers/tmp_path"
require_relative "helpers/test_puma/puma_socket"

require "puma/cli"
require "json"
require "psych"

class TestCLI < Minitest::Test
  include SSLHelper if ::Puma::HAS_SSL
  include TmpPath
  include TestPuma::PumaSocket

  def setup
    @environment = 'production'

    @tmp_path = tmp_path('puma-test')
    @tmp_path2 = "#{@tmp_path}2"

    File.unlink @tmp_path  if File.exist? @tmp_path
    File.unlink @tmp_path2 if File.exist? @tmp_path2

    @wait, @ready = IO.pipe

    @log_writer = Puma::LogWriter.strings

    @events = Puma::Events.new
    @events.on_booted { @ready << "!" }

    @puma_version_pattern = "\\d+.\\d+.\\d+(\\.[a-z\\d]+)?"
  end

  def wait_booted
    @wait.sysread 1
  rescue Errno::EAGAIN
    sleep 0.001
    retry
  end

  def teardown
    File.unlink @tmp_path if File.exist? @tmp_path
    File.unlink @tmp_path2 if File.exist? @tmp_path2

    @wait.close
    @ready.close
  end

  def test_control_for_tcp
    control_port = UniquePort.call
    url = "tcp://127.0.0.1:#{control_port}/"

    cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:0",
                         "--control-url", url,
                         "--control-token", "",
                         "test/rackup/hello.ru"], @log_writer, @events

    t = Thread.new { cli.run }

    wait_booted

    body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", port: control_port

    assert_equal Puma.stats_hash, JSON.parse(Puma.stats, symbolize_names: true)

    dmt = Puma::Configuration::DEFAULTS[:max_threads]
    expected_stats = /\{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","backlog":0,"running":0,"pool_capacity":#{dmt},"max_threads":#{dmt},"requests_count":0,"versions":\{"puma":"#{@puma_version_pattern}","ruby":\{"engine":"\w+","version":"\d+.\d+.\d+","patchlevel":-?\d+\}\}\}/
    assert_match(expected_stats, body)
  ensure
    cli.launcher.stop
    t.join
  end

  def test_control_for_ssl
    skip_unless :ssl

    require "net/http"
    control_port = UniquePort.call
    control_host = "127.0.0.1"
    control_url = "ssl://#{control_host}:#{control_port}?#{ssl_query}"
    token = "token"

    cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:0",
                         "--control-url", control_url,
                         "--control-token", token,
                         "test/rackup/hello.ru"], @log_writer, @events

    t = Thread.new { cli.run }

    wait_booted

    body = send_http_read_resp_body "GET /stats?token=#{token} HTTP/1.0\r\n\r\n",
      port: control_port, ctx: new_ctx

    dmt = Puma::Configuration::DEFAULTS[:max_threads]
    expected_stats = /{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","backlog":0,"running":0,"pool_capacity":#{dmt},"max_threads":#{dmt}/
    assert_match(expected_stats, body)
  ensure
    # always called, even if skipped
    cli&.launcher&.stop
    t&.join
  end

  def test_control_clustered
    skip_unless :fork
    skip_unless :unix
    url = "unix://#{@tmp_path}"

    cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}",
                         "-t", "2:2",
                         "-w", "2",
                         "--control-url", url,
                         "--control-token", "",
                         "test/rackup/hello.ru"], @log_writer, @events

    # without this, Minitest.after_run will trigger on this test ?
    $debugging_hold = true

    t = Thread.new { cli.run }

    wait_booted

    body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", path: @tmp_path

    status = JSON.parse(body)

    assert_equal 2, status["workers"]

    body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", path: @tmp_path

    expected_stats = /\{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","workers":2,"phase":0,"booted_workers":2,"old_workers":0,"worker_status":\[\{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","pid":\d+,"index":0,"phase":0,"booted":true,"last_checkin":"[^"]+","last_status":\{"backlog":0,"running":2,"pool_capacity":2,"max_threads":2,"requests_count":0\}\},\{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","pid":\d+,"index":1,"phase":0,"booted":true,"last_checkin":"[^"]+","last_status":\{"backlog":0,"running":2,"pool_capacity":2,"max_threads":2,"requests_count":0\}\}\],"versions":\{"puma":"#{@puma_version_pattern}","ruby":\{"engine":"\w+","version":"\d+.\d+.\d+","patchlevel":-?\d+\}\}\}/
    assert_match(expected_stats, body)
  ensure
    if UNIX_SKT_EXIST && HAS_FORK
      cli.launcher.stop
      t.join

      done = nil
      until done
        @log_writer.stdout.rewind
        log = @log_writer.stdout.readlines.join ''
        done = log[/ - Goodbye!/]
      end

      $debugging_hold = false
    end
  end

  def test_control
    skip_unless :unix
    url = "unix://#{@tmp_path}"

    cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}",
                         "--control-url", url,
                         "--control-token", "",
                         "test/rackup/hello.ru"], @log_writer, @events

    t = Thread.new { cli.run }

    wait_booted

    body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", path: @tmp_path

    dmt = Puma::Configuration::DEFAULTS[:max_threads]
    expected_stats = /{"started_at":"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z","backlog":0,"running":0,"pool_capacity":#{dmt},"max_threads":#{dmt},"requests_count":0,"versions":\{"puma":"#{@puma_version_pattern}","ruby":\{"engine":"\w+","version":"\d+.\d+.\d+","patchlevel":-?\d+\}\}\}/
    assert_match(expected_stats, body)
  ensure
    if UNIX_SKT_EXIST
      cli.launcher.stop
      t.join
    end
  end

  def test_control_stop
    skip_unless :unix
    url = "unix://#{@tmp_path}"

    cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}",
                         "--control-url", url,
                         "--control-token", "",
                         "test/rackup/hello.ru"], @log_writer, @events

    t = Thread.new { cli.run }

    wait_booted

    body = send_http_read_resp_body "GET /stop HTTP/1.0\r\n\r\n", path: @tmp_path

    assert_equal '{ "status": "ok" }', body
  ensure
    t.join if UNIX_SKT_EXIST
  end

  def test_control_requests_count
    @bind_port = UniquePort.call
    control_port = UniquePort.call
    url = "tcp://127.0.0.1:#{control_port}/"

    cli = Puma::CLI.new ["-b", "tcp://127.0.0.1:#{@bind_port}",
                         "--control-url", url,
                         "--control-token", "",
                         "test/rackup/hello.ru"], @log_writer, @events

    t = Thread.new { cli.run }

    wait_booted

    body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", port: control_port

    assert_equal 0, JSON.parse(body)['requests_count']

    # send real requests to server
    3.times { send_http_read_resp_body GET_10 }

    body = send_http_read_resp_body "GET /stats HTTP/1.0\r\n\r\n", port: control_port

    assert_equal 3, JSON.parse(body)['requests_count']
  ensure
    cli.launcher.stop
    t.join
  end

  def test_control_thread_backtraces
    skip_unless :unix
    url = "unix://#{@tmp_path}"

    cli = Puma::CLI.new ["-b", "unix://#{@tmp_path2}",
                         "--control-url", url,
                         "--control-token", "",
                         "test/rackup/hello.ru"], @log_writer, @events

    t = Thread.new { cli.run }

    wait_booted

    if TRUFFLE
      Thread.pass
      sleep 0.2
    end

    # All thread backtraces may be very large, just get a chunk
    socket = send_http "GET /thread-backtraces HTTP/1.0\r\n\r\n", path: @tmp_path
    socket.wait_readable 3
    body = socket.sysread 32_768
    assert_match %r{Thread: TID-}, body
  ensure
    cli.launcher.stop if cli
    t.join if UNIX_SKT_EXIST
  end


  def test_tmp_control
    skip_if :jruby, suffix: " - Unknown issue"

    cli = Puma::CLI.new ["--state", @tmp_path, "--control-url", "auto"]
    cli.launcher.write_state

    opts = cli.launcher.instance_variable_get(:@options)

    data = Psych.load_file @tmp_path

    Puma::StateFile::ALLOWED_FIELDS.each do |key|
      val =
        case key
        when 'pid'          then Process.pid
        when 'running_from' then File.expand_path('.') # same as Launcher
        else                     opts[key.to_sym]
        end
      assert_equal val, data[key]
    end

    assert_equal (Puma::StateFile::ALLOWED_FIELDS & data.keys).sort, data.keys.sort

    url = data["control_url"]

    assert_operator url, :start_with?, "unix://", "'#{url}' is not a URL"
  end

  def test_state_file_callback_filtering
    skip_unless :fork
    cli = Puma::CLI.new [ "--config", "test/config/state_file_testing_config.rb",
                          "--state", @tmp_path ]
    cli.launcher.write_state

    data = Psych.load_file @tmp_path

    assert_equal (Puma::StateFile::ALLOWED_FIELDS & data.keys).sort, data.keys.sort
  end

  def test_log_formatter_default_single
    cli = Puma::CLI.new [ ]
    assert_instance_of Puma::LogWriter::DefaultFormatter, cli.launcher.log_writer.formatter
  end

  def test_log_formatter_default_clustered
    skip_unless :fork

    cli = Puma::CLI.new [ "-w 2" ]
    assert_instance_of Puma::LogWriter::PidFormatter, cli.launcher.log_writer.formatter
  end

  def test_log_formatter_custom_single
    cli = Puma::CLI.new [ "--config", "test/config/custom_log_formatter.rb" ]
    assert_instance_of Proc, cli.launcher.log_writer.formatter
    assert_match(/^\[.*\] \[.*\] .*: test$/, cli.launcher.log_writer.format('test'))
  end

  def test_log_formatter_custom_clustered
    skip_unless :fork

    cli = Puma::CLI.new [ "--config", "test/config/custom_log_formatter.rb", "-w 2" ]
    assert_instance_of Proc, cli.launcher.log_writer.formatter
    assert_match(/^\[.*\] \[.*\] .*: test$/, cli.launcher.log_writer.format('test'))
  end

  def test_state
    url = "tcp://127.0.0.1:#{UniquePort.call}"
    cli = Puma::CLI.new ["--state", @tmp_path, "--control-url", url]
    cli.launcher.write_state

    data = Psych.load_file @tmp_path

    assert_equal Process.pid, data["pid"]
    assert_equal url, data["control_url"]
  end

  def test_load_path
    Puma::CLI.new ["--include", 'foo/bar']

    assert_equal 'foo/bar', $LOAD_PATH[0]
    $LOAD_PATH.shift

    Puma::CLI.new ["--include", 'foo/bar:baz/qux']

    assert_equal 'foo/bar', $LOAD_PATH[0]
    $LOAD_PATH.shift
    assert_equal 'baz/qux', $LOAD_PATH[0]
    $LOAD_PATH.shift
  end

  def test_extra_runtime_dependencies
    cli = Puma::CLI.new ['--extra-runtime-dependencies', 'a,b']
    extra_dependencies = cli.instance_variable_get(:@conf)
                            .instance_variable_get(:@options)[:extra_runtime_dependencies]

    assert_equal %w[a b], extra_dependencies
  end

  def test_environment_app_env
    ENV['RACK_ENV'] = @environment
    ENV['RAILS_ENV'] = @environment
    ENV['APP_ENV'] = 'test'

    cli = Puma::CLI.new []
    cli.send(:setup_options)

    assert_equal 'test', cli.instance_variable_get(:@conf).environment
  ensure
    ENV.delete 'APP_ENV'
    ENV.delete 'RAILS_ENV'
  end

  def test_environment_rack_env
    ENV['RACK_ENV'] = @environment

    cli = Puma::CLI.new []
    cli.send(:setup_options)

    assert_equal @environment, cli.instance_variable_get(:@conf).environment
  end

  def test_environment_rails_env
    ENV.delete 'RACK_ENV'
    ENV['RAILS_ENV'] = @environment

    cli = Puma::CLI.new []
    cli.send(:setup_options)

    assert_equal @environment, cli.instance_variable_get(:@conf).environment
  ensure
    ENV.delete 'RAILS_ENV'
  end

  def test_silent
    cli = Puma::CLI.new ['--silent']
    cli.send(:setup_options)

    log_writer = cli.instance_variable_get(:@log_writer)

    assert_equal log_writer.class, Puma::LogWriter.null.class
    assert_equal log_writer.stdout.class, Puma::NullIO
    assert_equal log_writer.stderr, $stderr
  end

  def test_plugins
    assert_empty Puma::Plugins.instance_variable_get(:@plugins)

    cli = Puma::CLI.new ['--plugin', 'tmp_restart', '--plugin', 'systemd']
    cli.send(:setup_options)

    assert Puma::Plugins.find("tmp_restart")
    assert Puma::Plugins.find("systemd")
  end
end