require 'helper'
require 'io/wait'
require "timeout"
require "spring/sid"
require "spring/env"
require "pty"

class AppTest < ActiveSupport::TestCase
  DEFAULT_TIMEOUT = ENV['CI'] ? 30 : 10

  def app_root
    Pathname.new("#{TEST_ROOT}/apps/rails-3-2")
  end

  def gem_home
    app_root.join "vendor/gems/#{RUBY_VERSION}"
  end

  def spring
    gem_home.join "bin/spring"
  end

  def spring_env
    @spring_env ||= Spring::Env.new(app_root)
  end

  def stdout
    @stdout ||= IO.pipe
  end

  def stderr
    @stderr ||= IO.pipe
  end

  def env
    @env ||= {"GEM_HOME" => gem_home.to_s, "GEM_PATH" => ""}
  end

  def app_run(command, opts = {})
    start_time = Time.now

    Bundler.with_clean_env do
      Process.spawn(
        env,
        command.to_s,
        out:   stdout.last,
        err:   stderr.last,
        in:    :close,
        chdir: app_root.to_s,
      )
    end

    _, status = Timeout.timeout(opts.fetch(:timeout, DEFAULT_TIMEOUT)) { Process.wait2 }

    stdout, stderr = read_streams
    puts dump_streams(command, stdout, stderr) if ENV["SPRING_DEBUG"]

    @times << (Time.now - start_time) if @times

    {
      status: status,
      stdout: stdout,
      stderr: stderr,
    }
  rescue Timeout::Error => e
    raise e, "Output:\n\n#{dump_streams(command, *read_streams)}"
  end

  def read_streams
    [stdout, stderr].map(&:first).map do |stream|
      output = ""
      output << stream.readpartial(10240) while IO.select([stream], [], [], 0.5)
      output
    end
  end

  def dump_streams(command, stdout, stderr)
    output = "$ #{command}\n"

    unless stdout.chomp.empty?
      output << "--- stdout ---\n"
      output << "#{stdout.chomp}\n"
    end

    unless stderr.chomp.empty?
      output << "--- stderr ---\n"
      output << "#{stderr.chomp}\n"
    end

    output << "\n"
    output
  end

  def await_reload
    sleep 0.4
  end

  def assert_output(artifacts, expected)
    expected.each do |stream, output|
      assert artifacts[stream].include?(output),
             "expected #{stream} to include '#{output}', but it was:\n\n#{artifacts[stream]}"
    end
  end

  def assert_success(command, expected_output = nil)
    artifacts = app_run(command)
    assert artifacts[:status].success?, "expected successful exit status\n\n#{dump_streams(command, *artifacts.values_at(:stdout, :stderr))}"
    assert_output artifacts, expected_output if expected_output
  end

  def assert_failure(command, expected_output = nil)
    artifacts = app_run(command)
    assert !artifacts[:status].success?, "expected unsuccessful exit status\n\n#{dump_streams(command, *artifacts.values_at(:stdout, :stderr))}"
    assert_output artifacts, expected_output if expected_output
  end

  def assert_speedup(ratio = 0.6)
    @times = []
    yield
    assert (@times.last / @times.first) < ratio, "#{@times.last} was not less than #{ratio} of #{@times.first}"
    @times = nil
  end

  def spring_test_command
    "#{spring} testunit #{@test}"
  end

  @@installed = false

  setup do
    @test                = "#{app_root}/test/functional/posts_controller_test.rb"
    @test_contents       = File.read(@test)
    @spec                = "#{app_root}/spec/dummy_spec.rb"
    @spec_contents       = File.read(@spec)
    @controller          = "#{app_root}/app/controllers/posts_controller.rb"
    @controller_contents = File.read(@controller)

    unless @@installed
      FileUtils.mkdir_p(gem_home)
      system "gem build spring.gemspec 2>/dev/null 1>/dev/null"
      app_run "gem install ../../../spring-#{Spring::VERSION}.gem"
      app_run "(gem list bundler | grep bundler) || gem install bundler #{'--pre' if RUBY_VERSION >= "2.0"}", timeout: nil
      app_run "bundle check || bundle update", timeout: nil
      app_run "bundle exec rake db:migrate db:test:clone"
      @@installed = true
    end

    FileUtils.rm_rf "#{app_root}/bin"
  end

  teardown do
    app_run "#{spring} stop"
    File.write(@test,       @test_contents)
    File.write(@spec,       @spec_contents)
    File.write(@controller, @controller_contents)
  end

  test "basic" do
    assert_speedup do
      2.times { app_run spring_test_command }
    end
  end

  test "help message when called without arguments" do
    assert_success spring, stdout: 'Usage: spring COMMAND [ARGS]'
  end

  test "test changes are picked up" do
    assert_speedup do
      assert_success spring_test_command, stdout: "0 failures"

      File.write(@test, @test_contents.sub("get :index", "raise 'omg'"))
      assert_failure spring_test_command, stdout: "RuntimeError: omg"
    end
  end

  test "code changes are picked up" do
    assert_speedup do
      assert_success spring_test_command, stdout: "0 failures"

      File.write(@controller, @controller_contents.sub("@posts = Post.all", "raise 'omg'"))
      assert_failure spring_test_command, stdout: "RuntimeError: omg"
    end
  end

  test "code changes in pre-referenced app files are picked up" do
    begin
      initializer = "#{app_root}/config/initializers/load_posts_controller.rb"
      File.write(initializer, "PostsController\n")

      assert_speedup do
        assert_success spring_test_command, stdout: "0 failures"

        File.write(@controller, @controller_contents.sub("@posts = Post.all", "raise 'omg'"))
        assert_failure spring_test_command, stdout: "RuntimeError: omg"
      end
    ensure
      FileUtils.rm_f(initializer)
    end
  end

  def assert_app_reloaded
    application = "#{app_root}/config/application.rb"
    application_contents = File.read(application)

    assert_success spring_test_command

    File.write(application, application_contents + <<-CODE)
      class Foo
        def self.omg
          raise "omg"
        end
      end
    CODE
    File.write(@test, @test_contents.sub("get :index", "Foo.omg"))

    await_reload

    assert_speedup do
      2.times { assert_failure spring_test_command, stdout: "RuntimeError: omg" }
    end
  ensure
    File.write(application, application_contents)
  end

  test "app gets reloaded when preloaded files change (polling watcher)" do
    assert_success "#{spring} rails runner 'puts Spring.watcher.class'", stdout: "Polling"
    assert_app_reloaded
  end

  test "app gets reloaded when preloaded files change (listen watcher)" do
    begin
      gemfile = app_root.join("Gemfile")
      gemfile_contents = gemfile.read
      File.write(gemfile, gemfile_contents.sub(%{# gem 'listen'}, %{gem 'listen'}))
      app_run "bundle install", timeout: nil

      assert_success "#{spring} rails runner 'puts Spring.watcher.class'", stdout: "Listen"
      assert_app_reloaded
    ensure
      File.write(gemfile, gemfile_contents)
      assert_success "bundle check"
    end
  end

  test "app recovers when a boot-level error is introduced" do
    begin
      application = "#{app_root}/config/application.rb"
      application_contents = File.read(application)

      assert_success spring_test_command

      File.write(application, application_contents + "\nomg")
      await_reload

      assert_failure spring_test_command

      File.write(application, application_contents)
      await_reload

      assert_success spring_test_command
    ensure
      File.write(application, application_contents)
    end
  end

  test "stop command kills server" do
    app_run spring_test_command
    assert spring_env.server_running?, "The server should be running but it isn't"

    assert_success "#{spring} stop"
    assert !spring_env.server_running?, "The server should not be running but it is"
  end

  test "custom commands" do
    assert_success "#{spring} custom", stdout: "omg"
  end

  test "binstubs" do
    app_run "#{spring} binstub rake"
    app_run "#{spring} binstub rails"
    assert_success "bin/spring help"
    assert_success "bin/rake -T", stdout: "rake db:migrate"
    assert_success "bin/rails runner 'puts %(omg)'", stdout: "omg"
  end

  test "after fork callback" do
    begin
      config_path = "#{app_root}/config/spring.rb"
      config_contents = File.read(config_path)

      File.write(config_path, config_contents + "\nSpring.after_fork { puts '!callback!' }")
      assert_success "#{spring} rails runner 'puts 2'", stdout: "!callback!\n2"
    ensure
      File.write(config_path, config_contents)
    end
  end

  test "missing config/application.rb" do
    begin
      FileUtils.mv app_root.join("config/application.rb"), app_root.join("config/application.rb.bak")
      assert_failure "#{spring} rake -T", stderr: "unable to find your config/application.rb"
    ensure
      FileUtils.mv app_root.join("config/application.rb.bak"), app_root.join("config/application.rb")
    end
  end

  test "piping" do
    assert_success "#{spring} rake -T | grep db", stdout: "rake db:migrate"
  end

  test "status" do
    assert_success "#{spring} status", stdout: "Spring is not running"
    app_run "#{spring} rails runner ''"
    assert_success "#{spring} status", stdout: "Spring is running"
  end

  test "runner command sets Rails environment from command-line options" do
    # Not using "test" environment here to avoid false positives on Travis (where "test" is default)
    assert_success "#{spring} rails runner -e staging 'puts Rails.env'", stdout: "staging"
    assert_success "#{spring} rails runner --environment=staging 'puts Rails.env'", stdout: "staging"
  end

  test "exit code for failing specs" do
    assert_success "#{spring} rspec"
    File.write(@spec, @spec_contents.sub("true.should be_true", "false.should be_true"))
    assert_failure "#{spring} rspec"
  end

  test "selecting rails environment for rake" do
    env['RAILS_ENV'] = 'staging'
    assert_success "#{spring} rake -p 'ENV[\"RAILS_ENV\"]'", stdout: "staging"
  end

  test "changing the Gemfile restarts the server" do
    begin
      gemfile = app_root.join("Gemfile")
      gemfile_contents = gemfile.read

      assert_success %(#{spring} rails runner 'require "rspec"')

      File.write(gemfile, gemfile_contents.sub(%{gem 'rspec'}, %{# gem 'rspec'}))
      app_run "bundle check"

      await_reload
      assert_failure %(#{spring} rails runner 'require "rspec"'), stderr: "cannot load such file -- rspec"
    ensure
      File.write(gemfile, gemfile_contents)
      assert_success "bundle check"
    end
  end
end