Class: Henitai::Integration::Rspec

Inherits:
Base
  • Object
show all
Defined in:
lib/henitai/integration.rb

Overview

RSpec integration adapter.

Direct Known Subclasses

Minitest

Constant Summary collapse

DEFAULT_SUITE_TIMEOUT =
300.0
REQUIRE_DIRECTIVE_PATTERN =
/
  \A\s*
  (require|require_relative)
  \s*
  (?:\(\s*)?
  ["']([^"']+)["']
  \s*\)?
/x

Instance Method Summary collapse

Methods inherited from Base

#cleanup_process_group, #reap_child, #wait_with_timeout

Instance Method Details

#build_result(wait_result, log_paths) ⇒ Object



596
597
598
599
600
601
602
603
604
605
606
607
# File 'lib/henitai/integration.rb', line 596

def build_result(wait_result, log_paths)
  stdout = read_log_file(log_paths[:stdout_path])
  stderr = read_log_file(log_paths[:stderr_path])
  write_combined_log(log_paths[:log_path], stdout, stderr)

  ScenarioExecutionResult.build(
    wait_result:,
    stdout:,
    stderr:,
    log_path: log_paths[:log_path]
  )
end

#combined_log(stdout, stderr) ⇒ Object



751
752
753
754
755
756
# File 'lib/henitai/integration.rb', line 751

def combined_log(stdout, stderr)
  [
    (stdout.empty? ? nil : "stdout:\n#{stdout}"),
    (stderr.empty? ? nil : "stderr:\n#{stderr}")
  ].compact.join("\n")
end

#excluded_spec_filesObject



628
629
630
# File 'lib/henitai/integration.rb', line 628

def excluded_spec_files
  @excluded_spec_files ||= rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
end

#expand_candidates(base_path, required_path) ⇒ Object



703
704
705
706
707
708
# File 'lib/henitai/integration.rb', line 703

def expand_candidates(base_path, required_path)
  [
    File.expand_path(required_path, base_path),
    File.expand_path("#{required_path}.rb", base_path)
  ].uniq
end

#fallback_spec_files(subject) ⇒ Object



616
617
618
619
620
621
622
623
624
625
626
# File 'lib/henitai/integration.rb', line 616

def fallback_spec_files(subject)
  return [] unless subject.source_file

  matches = spec_files.select do |path|
    requires_source_file_transitively?(path, subject.source_file)
  rescue StandardError
    false
  end

  matches.empty? ? spec_files : matches
end

#mutant_log_name(mutant) ⇒ Object



736
737
738
# File 'lib/henitai/integration.rb', line 736

def mutant_log_name(mutant)
  "mutant-#{mutant.id}"
end

#per_test_coverage_supported?Boolean

Returns:

  • (Boolean)


548
549
550
# File 'lib/henitai/integration.rb', line 548

def per_test_coverage_supported?
  true
end

#read_log_file(path) ⇒ Object



740
741
742
743
744
# File 'lib/henitai/integration.rb', line 740

def read_log_file(path)
  return "" unless File.exist?(path)

  File.read(path)
end

#relative_candidates(spec_file, required_path) ⇒ Object



693
694
695
# File 'lib/henitai/integration.rb', line 693

def relative_candidates(spec_file, required_path)
  expand_candidates(File.dirname(spec_file), required_path)
end

#require_candidates(spec_file, required_path) ⇒ Object



697
698
699
700
701
# File 'lib/henitai/integration.rb', line 697

def require_candidates(spec_file, required_path)
  ([File.dirname(spec_file), Dir.pwd] + $LOAD_PATH).flat_map do |base_path|
    expand_candidates(base_path, required_path)
  end
end

#required_files(spec_file) ⇒ Object



673
674
675
676
677
678
679
680
# File 'lib/henitai/integration.rb', line 673

def required_files(spec_file)
  File.read(spec_file).lines.filter_map do |line|
    match = line.match(REQUIRE_DIRECTIVE_PATTERN)
    next unless match

    resolve_required_file(spec_file, match[1].to_s, match[2].to_s)
  end
end

#requires_source_file?(spec_file, source_file) ⇒ Boolean

Returns:

  • (Boolean)


655
656
657
658
659
# File 'lib/henitai/integration.rb', line 655

def requires_source_file?(spec_file, source_file)
  content = File.read(spec_file)
  basename = File.basename(source_file, ".rb")
  content.include?(basename) || content.include?(source_file)
end

#requires_source_file_transitively?(spec_file, source_file, visited = []) ⇒ Boolean

Returns:

  • (Boolean)


661
662
663
664
665
666
667
668
669
670
671
# File 'lib/henitai/integration.rb', line 661

def requires_source_file_transitively?(spec_file, source_file, visited = [])
  normalized_spec_file = File.expand_path(spec_file)
  return false if visited.include?(normalized_spec_file)

  visited << normalized_spec_file
  return true if requires_source_file?(spec_file, source_file)

  required_files(spec_file).any? do |required_file|
    requires_source_file_transitively?(required_file, source_file, visited)
  end
end

#resolve_required_file(spec_file, method_name, required_path) ⇒ Object



682
683
684
685
686
687
688
689
690
691
# File 'lib/henitai/integration.rb', line 682

def resolve_required_file(spec_file, method_name, required_path)
  candidates =
    if method_name == "require_relative"
      relative_candidates(spec_file, required_path)
    else
      require_candidates(spec_file, required_path)
    end

  candidates.find { |candidate| File.file?(candidate) }
end

#rspec_config_linesObject



638
639
640
641
642
# File 'lib/henitai/integration.rb', line 638

def rspec_config_lines
  return [] unless File.exist?(rspec_config_path)

  File.readlines(rspec_config_path, chomp: true).map(&:strip)
end

#rspec_config_pathObject



644
645
646
# File 'lib/henitai/integration.rb', line 644

def rspec_config_path
  ".rspec"
end

#rspec_exclude_patternsObject



632
633
634
635
636
# File 'lib/henitai/integration.rb', line 632

def rspec_exclude_patterns
  rspec_config_lines.filter_map do |line|
    line[/\A--exclude-pattern\s+(.+)\z/, 1]
  end
end

#rspec_suite_runner_scriptObject



565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# File 'lib/henitai/integration.rb', line 565

def rspec_suite_runner_script
  <<~RUBY
    require "rspec/core"

    test_files = ARGV.map { |file| File.expand_path(file) }
    config = RSpec.configuration
    options = RSpec::Core::ConfigurationOptions.new(
      ["--format", "progress", "--format", "Henitai::CoverageFormatter"]
    )
    runner = RSpec::Core::Runner.send(:new, options)

    RSpec::Core::Runner.send(:trap_interrupt)
    runner.send(:configure, $stderr, $stdout)
    config.files_to_run = test_files
    config.load_spec_files

    status = runner.send(:run_specs, RSpec.world.ordered_example_groups)
    exit(status.is_a?(Integer) ? status : (status == true ? 0 : 1))
  RUBY
end

#run_in_child(mutant:, test_files:, log_paths:) ⇒ Object



724
725
726
727
728
729
730
731
732
733
734
# File 'lib/henitai/integration.rb', line 724

def run_in_child(mutant:, test_files:, log_paths:)
  Thread.report_on_exception = false
  with_subprocess_env do
    suppress_simplecov!
    suppress_coverage!
    install_debug_timeout_trap if debug_child?
    with_non_interactive_stdin do
      run_child_activation_and_tests(mutant:, test_files:, log_paths:)
    end
  end
end

#run_mutant(mutant:, test_files:, timeout:) ⇒ Object



544
545
546
# File 'lib/henitai/integration.rb', line 544

def run_mutant(mutant:, test_files:, timeout:)
  RspecProcessRunner.new.run_mutant(self, mutant:, test_files:, timeout:)
end

#run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT) ⇒ Object



552
553
554
# File 'lib/henitai/integration.rb', line 552

def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
  RspecProcessRunner.new.run_suite(self, test_files, timeout:)
end

#scenario_log_paths(name) ⇒ Object



586
587
588
589
590
591
592
593
594
# File 'lib/henitai/integration.rb', line 586

def scenario_log_paths(name)
  reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
  log_dir = File.join(reports_dir, "mutation-logs")
  {
    stdout_path: File.join(log_dir, "#{name}.stdout.log"),
    stderr_path: File.join(log_dir, "#{name}.stderr.log"),
    log_path: File.join(log_dir, "#{name}.log")
  }
end

#select_tests(subject) ⇒ Object



524
525
526
527
528
529
530
531
532
533
534
535
# File 'lib/henitai/integration.rb', line 524

def select_tests(subject)
  matches = spec_files.select do |path|
    content = File.read(path)
    selection_patterns(subject).any? { |pattern| content.include?(pattern) }
  rescue StandardError
    false
  end

  return matches unless matches.empty?

  fallback_spec_files(subject)
end

#selection_patterns(subject) ⇒ Object



648
649
650
651
652
653
# File 'lib/henitai/integration.rb', line 648

def selection_patterns(subject)
  [
    subject.expression,
    subject.namespace
  ].compact.uniq.sort_by(&:length).reverse
end

#spawn_mutant(mutant:, test_files:) ⇒ Object



539
540
541
542
# File 'lib/henitai/integration.rb', line 539

def spawn_mutant(mutant:, test_files:)
  log_paths = scenario_log_paths(mutant_log_name(mutant))
  RspecProcessRunner.new.spawn_mutant(self, mutant:, test_files:, log_paths:)
end

#spawn_suite_process(test_files, log_paths) ⇒ Object



710
711
712
713
714
715
716
717
718
719
720
721
722
# File 'lib/henitai/integration.rb', line 710

def spawn_suite_process(test_files, log_paths)
  File.open(log_paths[:stdout_path], "w") do |stdout_file|
    File.open(log_paths[:stderr_path], "w") do |stderr_file|
      Process.spawn(
        subprocess_env,
        *suite_command(test_files),
        out: stdout_file,
        err: stderr_file,
        pgroup: true
      )
    end
  end
end

#spec_filesObject



609
610
611
612
613
614
# File 'lib/henitai/integration.rb', line 609

def spec_files
  @spec_files ||= begin
    paths = Dir.glob("spec/**/*_spec.rb")
    paths - excluded_spec_files
  end
end

#suite_command(test_files) ⇒ Object



556
557
558
559
560
561
562
563
# File 'lib/henitai/integration.rb', line 556

def suite_command(test_files)
  [
    "bundle", "exec", "ruby",
    "-r", "henitai/rspec_coverage_formatter",
    "-e", rspec_suite_runner_script,
    *test_files
  ]
end

#test_filesObject



537
# File 'lib/henitai/integration.rb', line 537

def test_files = spec_files

#write_combined_log(path, stdout, stderr) ⇒ Object



746
747
748
749
# File 'lib/henitai/integration.rb', line 746

def write_combined_log(path, stdout, stderr)
  FileUtils.mkdir_p(File.dirname(path))
  File.write(path, combined_log(stdout, stderr))
end