From 471541ec9133efa83061081e74aefffe33f33204 Mon Sep 17 00:00:00 2001 From: Albert Salim <asalim@gitlab.com> Date: Wed, 28 Oct 2020 14:10:05 +0000 Subject: [PATCH] Add Crystalball into rspec - Configure rspec with Crystalball using custom CoverageLinesStrategy for compatibility with SimpleCov on Ruby >= 2.5 - Running rspec with `CRYSTALBALL=true` generates crystalball data in `crystalball/*.yml` containing files covered by each rspec example --- .gitignore | 1 + .gitlab-ci.yml | 2 + .gitlab/ci/rails.gitlab-ci.yml | 2 + .gitlab/ci/test-metadata.gitlab-ci.yml | 2 + Gemfile | 1 + Gemfile.lock | 3 + scripts/generate-test-mapping | 19 +++ scripts/pack-test-mapping | 19 +++ scripts/rspec_helpers.sh | 37 ++++++ scripts/unpack-test-mapping | 17 +++ spec/crystalball_env.rb | 25 ++++ spec/spec_helper.rb | 3 + .../coverage_lines_execution_detector_spec.rb | 42 +++++++ .../coverage_lines_strategy_spec.rb | 16 +++ .../lib/tooling/test_map_generator_spec.rb | 109 ++++++++++++++++++ .../lib/tooling/test_map_packer_spec.rb | 77 +++++++++++++ .../coverage_lines_execution_detector.rb | 44 +++++++ .../crystalball/coverage_lines_strategy.rb | 23 ++++ tooling/lib/tooling/test_map_generator.rb | 36 ++++++ tooling/lib/tooling/test_map_packer.rb | 58 ++++++++++ 20 files changed, 536 insertions(+) create mode 100755 scripts/generate-test-mapping create mode 100755 scripts/pack-test-mapping create mode 100755 scripts/unpack-test-mapping create mode 100644 spec/crystalball_env.rb create mode 100644 spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb create mode 100644 spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb create mode 100644 spec/tooling/lib/tooling/test_map_generator_spec.rb create mode 100644 spec/tooling/lib/tooling/test_map_packer_spec.rb create mode 100644 tooling/lib/tooling/crystalball/coverage_lines_execution_detector.rb create mode 100644 tooling/lib/tooling/crystalball/coverage_lines_strategy.rb create mode 100644 tooling/lib/tooling/test_map_generator.rb create mode 100644 tooling/lib/tooling/test_map_packer.rb diff --git a/.gitignore b/.gitignore index 25c42cdb56d71..8a47cc8d20ba6 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ eslint-report.html /.gitlab_pages_secret /.gitlab_kas_secret /webpack-report/ +/crystalball/ /knapsack/ /rspec_flaky/ /locale/**/LC_MESSAGES diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8a111b1f69593..e14cb18f574c0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -59,6 +59,8 @@ variables: GET_SOURCES_ATTEMPTS: "3" KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/report-master.json FLAKY_RSPEC_SUITE_REPORT_PATH: rspec_flaky/report-suite.json + RSPEC_TESTS_MAPPING_PATH: crystalball/mapping.json + RSPEC_PACKED_TESTS_MAPPING_PATH: crystalball/packed-mapping.json BUILD_ASSETS_IMAGE: "false" ES_JAVA_OPTS: "-Xms256m -Xmx256m" ELASTIC_URL: "http://elastic:changeme@elasticsearch:9200" diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index f44ab9deb0862..5a8f2651b6fea 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -20,6 +20,7 @@ variables: RUBY_GC_MALLOC_LIMIT: 67108864 RUBY_GC_MALLOC_LIMIT_MAX: 134217728 + CRYSTALBALL: "true" needs: ["setup-test-env", "retrieve-tests-metadata", "compile-test-assets"] script: - *base-script @@ -29,6 +30,7 @@ when: always paths: - coverage/ + - crystalball/ - knapsack/ - rspec_flaky/ - rspec_profiling/ diff --git a/.gitlab/ci/test-metadata.gitlab-ci.yml b/.gitlab/ci/test-metadata.gitlab-ci.yml index 08c793120ab1b..ba5b3f986897f 100644 --- a/.gitlab/ci/test-metadata.gitlab-ci.yml +++ b/.gitlab/ci/test-metadata.gitlab-ci.yml @@ -9,6 +9,7 @@ - knapsack/ - rspec_flaky/ - rspec_profiling/ + - crystalball/ retrieve-tests-metadata: extends: @@ -41,3 +42,4 @@ update-tests-metadata: - run_timed_command "retry gem install bundler:1.17.3 fog-aws mime-types activesupport rspec_profiling postgres-copy --no-document" - source ./scripts/rspec_helpers.sh - update_tests_metadata + - update_tests_mapping diff --git a/Gemfile b/Gemfile index d3671cac4f518..f845e9ccd77ff 100644 --- a/Gemfile +++ b/Gemfile @@ -386,6 +386,7 @@ group :development, :test do gem 'benchmark-ips', '~> 2.3.0', require: false gem 'knapsack', '~> 1.17' + gem 'crystalball', '~> 0.7.0', require: false gem 'simple_po_parser', '~> 1.1.2', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7b7223af8b6e0..401286d900dfd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -199,6 +199,8 @@ GEM safe_yaml (~> 1.0.0) crass (1.0.6) creole (0.5.0) + crystalball (0.7.0) + git css_parser (1.7.0) addressable daemons (1.2.6) @@ -1291,6 +1293,7 @@ DEPENDENCIES connection_pool (~> 2.0) countries (~> 3.0) creole (~> 0.5.0) + crystalball (~> 0.7.0) danger (~> 8.0.6) database_cleaner (~> 1.7.0) deckar01-task_list (= 2.3.1) diff --git a/scripts/generate-test-mapping b/scripts/generate-test-mapping new file mode 100755 index 0000000000000..eabe6a5b513f1 --- /dev/null +++ b/scripts/generate-test-mapping @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby + +require 'json' +require_relative '../tooling/lib/tooling/test_map_generator' + +test_mapping_json = ARGV.shift +crystalball_yamls = ARGV + +unless test_mapping_json && !crystalball_yamls.empty? + puts "usage: #{__FILE__} <test_mapping_json> [crystalball_yamls...]" + exit 1 +end + +map_generator = Tooling::TestMapGenerator.new +map_generator.parse(crystalball_yamls) +mapping = map_generator.mapping + +File.write(test_mapping_json, JSON.pretty_generate(mapping)) +puts "Saved #{test_mapping_json}." diff --git a/scripts/pack-test-mapping b/scripts/pack-test-mapping new file mode 100755 index 0000000000000..58ace3eca6797 --- /dev/null +++ b/scripts/pack-test-mapping @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby + +require 'json' +require_relative '../tooling/lib/tooling/test_map_packer' + +unpacked_json_mapping, packed_json_mapping = ARGV.shift(2) +unless packed_json_mapping && unpacked_json_mapping + puts "usage: #{__FILE__} <unpacked_json_mapping> <packed_json_mapping>" + exit 1 +end + +puts "Compressing #{unpacked_json_mapping}" + +mapping = JSON.parse(File.read(unpacked_json_mapping)) +packed_mapping = Tooling::TestMapPacker.new.pack(mapping) + +puts "Writing packed #{packed_json_mapping}" +File.write(packed_json_mapping, JSON.generate(packed_mapping)) +puts "Saved #{packed_json_mapping}." diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh index 3812a8b8ef7af..9fe7d089d93c8 100644 --- a/scripts/rspec_helpers.sh +++ b/scripts/rspec_helpers.sh @@ -48,6 +48,43 @@ function update_tests_metadata() { fi } +function retrieve_tests_mapping() { + mkdir -p crystalball/ + + if [[ ! -f "${RSPEC_PACKED_TESTS_MAPPING_PATH}" ]]; then + (wget -O "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" "http://${TESTS_METADATA_S3_BUCKET}.s3.amazonaws.com/${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" && gzip -d "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") || echo "{}" > "${RSPEC_PACKED_TESTS_MAPPING_PATH}" + fi + + scripts/unpack-test-mapping "${RSPEC_PACKED_TESTS_MAPPING_PATH}" "${RSPEC_TESTS_MAPPING_PATH}" +} + +function update_tests_mapping() { + if ! crystalball_rspec_data_exists; then + echo "No crystalball rspec data found." + return 0 + fi + + scripts/generate-test-mapping "${RSPEC_TESTS_MAPPING_PATH}" crystalball/rspec*.yml + + scripts/pack-test-mapping "${RSPEC_TESTS_MAPPING_PATH}" "${RSPEC_PACKED_TESTS_MAPPING_PATH}" + + gzip "${RSPEC_PACKED_TESTS_MAPPING_PATH}" + + if [[ -n "${TESTS_METADATA_S3_BUCKET}" ]]; then + if [[ "$CI_PIPELINE_SOURCE" == "schedule" ]]; then + scripts/sync-reports put "${TESTS_METADATA_S3_BUCKET}" "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" + else + echo "Not uploading report to S3 as the pipeline is not a scheduled one." + fi + fi + + rm -f crystalball/rspec*.yml +} + +function crystalball_rspec_data_exists() { + compgen -G "crystalball/rspec*.yml" > /dev/null; +} + function rspec_simple_job() { local rspec_opts="${1}" diff --git a/scripts/unpack-test-mapping b/scripts/unpack-test-mapping new file mode 100755 index 0000000000000..c0f706c3f9f0c --- /dev/null +++ b/scripts/unpack-test-mapping @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby + +require 'json' +require_relative '../tooling/lib/tooling/test_map_packer' + +packed_json_mapping, unpacked_json_mapping = ARGV.shift(2) +unless packed_json_mapping && unpacked_json_mapping + puts "usage: #{__FILE__} <packed_json_mapping> <unpacked_json_mapping>" + exit 1 +end + +packed_mapping = JSON.parse(File.read(packed_json_mapping)) +mapping = Tooling::TestMapPacker.new.unpack(packed_mapping) + +puts "Writing unpacked #{unpacked_json_mapping}" +File.write(unpacked_json_mapping, JSON.generate(mapping)) +puts "Saved #{unpacked_json_mapping}." diff --git a/spec/crystalball_env.rb b/spec/crystalball_env.rb new file mode 100644 index 0000000000000..56498f07f85cd --- /dev/null +++ b/spec/crystalball_env.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module CrystalballEnv + EXCLUDED_PREFIXES = %w[vendor/ruby].freeze + + extend self + + def start! + return unless ENV['CRYSTALBALL'] && ENV['CI_PIPELINE_SOURCE'] == 'schedule' + + require 'crystalball' + require_relative '../tooling/lib/tooling/crystalball/coverage_lines_execution_detector' + require_relative '../tooling/lib/tooling/crystalball/coverage_lines_strategy' + + map_storage_path_base = ENV['CI_JOB_NAME'] || 'crystalball_data' + map_storage_path = "crystalball/#{map_storage_path_base.gsub(%r{[/ ]}, '_')}.yml" + + execution_detector = Tooling::Crystalball::CoverageLinesExecutionDetector.new(exclude_prefixes: EXCLUDED_PREFIXES) + + Crystalball::MapGenerator.start! do |config| + config.map_storage_path = map_storage_path + config.register Tooling::Crystalball::CoverageLinesStrategy.new(execution_detector) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 11a45e005b83b..98ce765100b3f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,9 @@ require './spec/simplecov_env' SimpleCovEnv.start! +require './spec/crystalball_env' +CrystalballEnv.start! + ENV["RAILS_ENV"] = 'test' ENV["IN_MEMORY_APPLICATION_SETTINGS"] = 'true' ENV["RSPEC_ALLOW_INVALID_URLS"] = 'true' diff --git a/spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb b/spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb new file mode 100644 index 0000000000000..6b7373cb3c72c --- /dev/null +++ b/spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative '../../../../../tooling/lib/tooling/crystalball/coverage_lines_execution_detector' + +RSpec.describe Tooling::Crystalball::CoverageLinesExecutionDetector do + subject(:detector) { described_class.new(root, exclude_prefixes: %w[vendor/ruby]) } + + let(:root) { '/tmp' } + let(:before_map) { { path => { lines: [0, 2, nil] } } } + let(:after_map) { { path => { lines: [0, 3, nil] } } } + let(:path) { '/tmp/file.rb' } + + describe '#detect' do + subject { detector.detect(before_map, after_map) } + + it { is_expected.to eq(%w[file.rb]) } + + context 'with no changes' do + let(:after_map) { { path => { lines: [0, 2, nil] } } } + + it { is_expected.to eq([]) } + end + + context 'with previously uncovered file' do + let(:before_map) { {} } + + it { is_expected.to eq(%w[file.rb]) } + end + + context 'with path outside of root' do + let(:path) { '/abc/file.rb' } + + it { is_expected.to eq([]) } + end + + context 'with path in excluded prefix' do + let(:path) { '/tmp/vendor/ruby/dependency.rb' } + + it { is_expected.to eq([]) } + end + end +end diff --git a/spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb b/spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb new file mode 100644 index 0000000000000..fd8fc4114a1ed --- /dev/null +++ b/spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative '../../../../../tooling/lib/tooling/crystalball/coverage_lines_strategy' + +RSpec.describe Tooling::Crystalball::CoverageLinesStrategy do + subject { described_class.new(execution_detector) } + + let(:execution_detector) { instance_double('Tooling::Crystalball::CoverageLinesExecutionDetector') } + + describe '#after_register' do + it 'starts coverage' do + expect(Coverage).to receive(:start).with(lines: true) + subject.after_register + end + end +end diff --git a/spec/tooling/lib/tooling/test_map_generator_spec.rb b/spec/tooling/lib/tooling/test_map_generator_spec.rb new file mode 100644 index 0000000000000..7f3b28071622c --- /dev/null +++ b/spec/tooling/lib/tooling/test_map_generator_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative '../../../../tooling/lib/tooling/test_map_generator' + +RSpec.describe Tooling::TestMapGenerator do + subject { described_class.new } + + describe '#parse' do + let(:yaml1) do + <<~YAML + --- + :type: Crystalball::ExecutionMap + :commit: a7d57d333042f3b0334b2f8a282354eef7365976 + :timestamp: 1602668405 + :version: + --- + "./spec/factories_spec.rb[1]": + - lib/gitlab/current_settings.rb + - lib/feature.rb + - lib/gitlab/marginalia.rb + YAML + end + + let(:yaml2) do + <<~YAML + --- + :type: Crystalball::ExecutionMap + :commit: 74056e8d9cf3773f43faa1cf5416f8779c8284c8 + :timestamp: 1602671965 + :version: + --- + "./spec/models/project_spec.rb[1]": + - lib/gitlab/current_settings.rb + - lib/feature.rb + - lib/gitlab/marginalia.rb + YAML + end + + let(:pathname) { instance_double(Pathname) } + + before do + allow(File).to receive(:read).with('yaml1.yml').and_return(yaml1) + allow(File).to receive(:read).with('yaml2.yml').and_return(yaml2) + end + + context 'with single yaml' do + let(:expected_mapping) do + { + 'lib/gitlab/current_settings.rb' => [ + './spec/factories_spec.rb' + ], + 'lib/feature.rb' => [ + './spec/factories_spec.rb' + ], + 'lib/gitlab/marginalia.rb' => [ + './spec/factories_spec.rb' + ] + } + end + + it 'parses crystalball data into test mapping' do + subject.parse('yaml1.yml') + + expect(subject.mapping.keys).to match_array(expected_mapping.keys) + end + + it 'stores test files without example uid' do + subject.parse('yaml1.yml') + + expected_mapping.each do |file, tests| + expect(subject.mapping[file]).to match_array(tests) + end + end + end + + context 'with multiple yamls' do + let(:expected_mapping) do + { + 'lib/gitlab/current_settings.rb' => [ + './spec/factories_spec.rb', + './spec/models/project_spec.rb' + ], + 'lib/feature.rb' => [ + './spec/factories_spec.rb', + './spec/models/project_spec.rb' + ], + 'lib/gitlab/marginalia.rb' => [ + './spec/factories_spec.rb', + './spec/models/project_spec.rb' + ] + } + end + + it 'parses crystalball data into test mapping' do + subject.parse(%w[yaml1.yml yaml2.yml]) + + expect(subject.mapping.keys).to match_array(expected_mapping.keys) + end + + it 'stores test files without example uid' do + subject.parse(%w[yaml1.yml yaml2.yml]) + + expected_mapping.each do |file, tests| + expect(subject.mapping[file]).to match_array(tests) + end + end + end + end +end diff --git a/spec/tooling/lib/tooling/test_map_packer_spec.rb b/spec/tooling/lib/tooling/test_map_packer_spec.rb new file mode 100644 index 0000000000000..233134d252474 --- /dev/null +++ b/spec/tooling/lib/tooling/test_map_packer_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative '../../../../tooling/lib/tooling/test_map_packer' + +RSpec.describe Tooling::TestMapPacker do + subject { described_class.new } + + let(:map) do + { + 'file1.rb' => [ + './a/b/c/test_1.rb', + './a/b/test_2.rb', + './a/b/test_3.rb', + './a/test_4.rb', + './test_5.rb' + ], + 'file2.rb' => [ + './a/b/c/test_1.rb', + './a/test_4.rb', + './test_5.rb' + ] + } + end + + let(:compact_map) do + { + 'file1.rb' => { + '.' => { + 'a' => { + 'b' => { + 'c' => { + 'test_1.rb' => 1 + }, + 'test_2.rb' => 1, + 'test_3.rb' => 1 + }, + 'test_4.rb' => 1 + }, + 'test_5.rb' => 1 + } + }, + 'file2.rb' => { + '.' => { + 'a' => { + 'b' => { + 'c' => { + 'test_1.rb' => 1 + } + }, + 'test_4.rb' => 1 + }, + 'test_5.rb' => 1 + } + } + } + end + + describe '#pack' do + it 'compacts list of test files into a prefix tree' do + expect(subject.pack(map)).to eq(compact_map) + end + + it 'does nothing to empty hash' do + expect(subject.pack({})).to eq({}) + end + end + + describe '#unpack' do + it 'unpack prefix tree into list of test files' do + expect(subject.unpack(compact_map)).to eq(map) + end + + it 'does nothing to empty hash' do + expect(subject.unpack({})).to eq({}) + end + end +end diff --git a/tooling/lib/tooling/crystalball/coverage_lines_execution_detector.rb b/tooling/lib/tooling/crystalball/coverage_lines_execution_detector.rb new file mode 100644 index 0000000000000..47ddf568fe46f --- /dev/null +++ b/tooling/lib/tooling/crystalball/coverage_lines_execution_detector.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'crystalball/map_generator/helpers/path_filter' + +module Tooling + module Crystalball + # Class for detecting code execution path based on coverage information diff + class CoverageLinesExecutionDetector + include ::Crystalball::MapGenerator::Helpers::PathFilter + + attr_reader :exclude_prefixes + + def initialize(*args, exclude_prefixes: []) + super(*args) + @exclude_prefixes = exclude_prefixes + end + + # Detects files affected during example execution based on line coverage. + # Transforms absolute paths to relative. + # Exclude paths outside of repository and in excluded prefixes + # + # @param[Hash] hash of files affected before example execution + # @param[Hash] hash of files affected after example execution + # @return [Array<String>] + def detect(before, after) + file_names = after.keys + covered_files = file_names.reject { |file_name| same_coverage?(before, after, file_name) } + filter(covered_files) + end + + private + + def same_coverage?(before, after, file_name) + before[file_name] && before[file_name][:lines] == after[file_name][:lines] + end + + def filter(paths) + super.reject do |file_name| + exclude_prefixes.any? { |prefix| file_name.start_with?(prefix) } + end + end + end + end +end diff --git a/tooling/lib/tooling/crystalball/coverage_lines_strategy.rb b/tooling/lib/tooling/crystalball/coverage_lines_strategy.rb new file mode 100644 index 0000000000000..ebcaab0b8d894 --- /dev/null +++ b/tooling/lib/tooling/crystalball/coverage_lines_strategy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'coverage' +require 'crystalball/map_generator/coverage_strategy' +require_relative './coverage_lines_execution_detector' + +module Tooling + module Crystalball + # Crystalball map generator strategy based on Crystalball::MapGenerator::CoverageStrategy, + # modified to use Coverage.start(lines: true) + # This maintains compatibility with SimpleCov on Ruby >= 2.5 with start arguments + # and SimpleCov.start uses Coverage.start(lines: true) by default + class CoverageLinesStrategy < ::Crystalball::MapGenerator::CoverageStrategy + def initialize(execution_detector = CoverageLinesExecutionDetector) + super(execution_detector) + end + + def after_register + Coverage.start(lines: true) + end + end + end +end diff --git a/tooling/lib/tooling/test_map_generator.rb b/tooling/lib/tooling/test_map_generator.rb new file mode 100644 index 0000000000000..bd0415f6e6717 --- /dev/null +++ b/tooling/lib/tooling/test_map_generator.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'set' +require 'yaml' + +module Tooling + class TestMapGenerator + def initialize + @mapping = Hash.new { |h, k| h[k] = Set.new } + end + + def parse(yaml_files) + Array(yaml_files).each do |yaml_file| + data = File.read(yaml_file) + _metadata, example_groups = data.split("---\n").reject(&:empty?).map { |yml| YAML.safe_load(yml, [Symbol]) } + + example_groups.each do |example_id, files| + files.each do |file| + spec_file = strip_example_uid(example_id) + @mapping[file] << spec_file + end + end + end + end + + def mapping + @mapping.transform_values { |set| set.to_a } + end + + private + + def strip_example_uid(example_id) + example_id.gsub(/\[.+\]/, '') + end + end +end diff --git a/tooling/lib/tooling/test_map_packer.rb b/tooling/lib/tooling/test_map_packer.rb new file mode 100644 index 0000000000000..520d69610eb60 --- /dev/null +++ b/tooling/lib/tooling/test_map_packer.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Tooling + class TestMapPacker + SEPARATOR = '/'.freeze + MARKER = 1 + + def pack(map) + map.transform_values(&method(:create_tree_from_tests)) + end + + def unpack(compact_map) + compact_map.transform_values(&method(:retrieve_tests_from_tree)) + end + + private + + def create_tree_from_tests(tests) + tests.inject({}) do |tree, test| + segments = test.split(SEPARATOR) + branch = create_branch_from_segments(segments) + deep_merge(tree, branch) + end + end + + def create_branch_from_segments(segments) + segments.reverse.inject(MARKER) { |node, parent| { parent => node } } + end + + def deep_merge(hash, other) + hash.merge(other) do |_, this_val, other_val| + if this_val.is_a?(Hash) && other_val.is_a?(Hash) + deep_merge(this_val, other_val) + else + other_val + end + end + end + + def retrieve_tests_from_tree(tree) + traverse(tree).inject([]) do |tests, test| + tests << test + end + end + + def traverse(tree, segments = [], &block) + return to_enum(__method__, tree, segments) unless block_given? + + if tree == MARKER + return yield segments.join(SEPARATOR) + end + + tree.each do |key, value| + traverse(value, segments + [key], &block) + end + end + end +end -- GitLab