diff --git a/lib/undercover.rb b/lib/undercover.rb index 1324781..4d82840 100644 --- a/lib/undercover.rb +++ b/lib/undercover.rb @@ -21,11 +21,12 @@ module Undercover class Report extend Forwardable + def_delegators :changeset, :validate attr_reader :changeset, :lcov, - :simplecov_resultset, + :coverage_adapter, :results, :code_dir, :filter_set, @@ -35,15 +36,16 @@ class Report # # @param changeset [Undercover::Changeset] # @param opts [Undercover::Options] - def initialize(changeset, opts) - if opts.simplecov_resultset - @simplecov_resultset = SimplecovResultAdapter.parse(File.open(opts.simplecov_resultset), opts) - end - @lcov = LcovParser.parse(File.open(opts.lcov), opts) if opts.lcov + # @param coverage_adapter [Undercover::SimplecovResultAdapter|Undercover::LcovParser] pre-parsed coverage adapter + def initialize(changeset, opts, coverage_adapter) + @coverage_adapter = coverage_adapter @code_dir = opts.path @changeset = changeset - @filter_set = FilterSet.new(opts.glob_allow_filters, opts.glob_reject_filters) + + ignored_files = coverage_adapter.ignored_files || [] + @filter_set = FilterSet.new(opts.glob_allow_filters, opts.glob_reject_filters, ignored_files) + changeset.filter_with(filter_set) @max_warnings_limit = opts.max_warnings_limit @loaded_files = {} @results = {} @@ -106,29 +108,21 @@ def inspect attr_reader :loaded_files - # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + # rubocop:disable Metrics/AbcSize def load_and_parse_file(filepath) key = filepath.gsub(/^\.\//, '') return if loaded_files[key] - return unless include_file?(filepath) root_ast = Imagen::Node::Root.new.build_from_file( File.join(code_dir, filepath) ) return if root_ast.children.empty? - # lcov will be deprecated at some point and we'll be able to refactor harder - coverage = simplecov_resultset || lcov - loaded_files[key] = [] root_ast.find_all(->(node) { !node.is_a?(Imagen::Node::Root) }).each do |imagen_node| - loaded_files[key] << Result.new(imagen_node, coverage, filepath) + loaded_files[key] << Result.new(imagen_node, coverage_adapter, filepath) end end - # rubocop:enable Metrics/MethodLength, Metrics/AbcSize - - def include_file?(filepath) - filter_set.include?(filepath) - end + # rubocop:enable Metrics/AbcSize end end diff --git a/lib/undercover/changeset.rb b/lib/undercover/changeset.rb index 87cf78e..9159da7 100644 --- a/lib/undercover/changeset.rb +++ b/lib/undercover/changeset.rb @@ -49,6 +49,10 @@ def validate(lcov_report_path) :stale_coverage if last_modified > File.mtime(lcov_report_path) end + def filter_with(filter_set) + @filter_set = filter_set + end + private # Diffs `head` or `head` + `compare_base` (if exists), diff --git a/lib/undercover/cli.rb b/lib/undercover/cli.rb index f58d3d8..f847d21 100644 --- a/lib/undercover/cli.rb +++ b/lib/undercover/cli.rb @@ -5,9 +5,6 @@ module Undercover module CLI - # TODO: Report calls >parser< for each file instead of - # traversing the whole project at first! - WARNINGS_TO_S = { stale_coverage: Rainbow('🚨 WARNING: Coverage data is older than your ' \ 'latest changes and results might be incomplete. ' \ @@ -21,12 +18,20 @@ def self.run(args) run_report(opts) end - def self.run_report(opts) + def self.run_report(opts) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength coverage_path = opts.simplecov_resultset || opts.lcov return handle_missing_coverage_path(opts) if coverage_path.nil? return handle_missing_file(coverage_path) unless File.exist?(coverage_path) - report = Undercover::Report.new(changeset(opts), opts).build + simplecov_adapter = if opts.simplecov_resultset + SimplecovResultAdapter.parse(File.open(opts.simplecov_resultset), opts) + else + # TODO: lcov will be deprecated end of 2025 and we'll be able to refactor harder + LcovParser.parse(File.open(opts.lcov), opts) + end + + changeset_obj = changeset(opts) + report = Undercover::Report.new(changeset_obj, opts, simplecov_adapter).build handle_report_validation(report, coverage_path) end @@ -63,8 +68,7 @@ def self.syntax_version(version) def self.changeset(opts) git_dir = File.join(opts.path, opts.git_dir) - filter_set = Undercover::FilterSet.new(opts.glob_allow_filters, opts.glob_reject_filters) - Undercover::Changeset.new(git_dir, opts.compare, filter_set) + Undercover::Changeset.new(git_dir, opts.compare) end end end diff --git a/lib/undercover/filter_set.rb b/lib/undercover/filter_set.rb index 676e906..35cdfa0 100644 --- a/lib/undercover/filter_set.rb +++ b/lib/undercover/filter_set.rb @@ -2,16 +2,36 @@ module Undercover class FilterSet - attr_reader :allow_filters, :reject_filters + attr_reader :allow_filters, :reject_filters, :simplecov_filters - def initialize(allow_filters, reject_filters) + def initialize(allow_filters, reject_filters, simplecov_filters) @allow_filters = allow_filters || [] @reject_filters = reject_filters || [] + @simplecov_filters = simplecov_filters || [] end def include?(filepath) fnmatch = proc { |glob| File.fnmatch(glob, filepath, File::FNM_EXTGLOB) } + + # Check if file was ignored by SimpleCov filters + return false if ignored_by_simplecov?(filepath) + + # Apply Undercover's own filters allow_filters.any?(fnmatch) && reject_filters.none?(fnmatch) end + + private + + def ignored_by_simplecov?(filepath) + simplecov_filters.any? do |filter| + if filter[:string] + filepath.include?(filter[:string]) + elsif filter[:regex] + filepath.match?(Regexp.new(filter[:regex])) + elsif filter[:file] + filepath == filter[:file] + end + end + end end end diff --git a/lib/undercover/lcov_parser.rb b/lib/undercover/lcov_parser.rb index 96a054b..151ddf9 100644 --- a/lib/undercover/lcov_parser.rb +++ b/lib/undercover/lcov_parser.rb @@ -57,6 +57,11 @@ def skipped?(_filepath, _line_no) false end + def ignored_files + # supported by SimplecovResultAdapter only + [] + end + private # rubocop:disable Metrics/MethodLength, Style/SpecialGlobalVars, Metrics/AbcSize diff --git a/lib/undercover/simplecov_formatter.rb b/lib/undercover/simplecov_formatter.rb index b9d7cf1..65cacb3 100644 --- a/lib/undercover/simplecov_formatter.rb +++ b/lib/undercover/simplecov_formatter.rb @@ -13,12 +13,60 @@ def export_path end end +module SimpleCov + class << self + attr_accessor :filter_definitions + + alias filtered_uncached filtered + + def filtered(files) + @filter_definitions ||= extract_filter_definitions + original_files = files.dup + filtered_uncached(files).tap do |filtered_files| + filtered_file_paths = (original_files.map(&:filename) - filtered_files.map(&:filename)) + filtered_file_paths.each do |file| + relative_path = file.delete_prefix("#{SimpleCov.root}/") + @filter_definitions << {file: relative_path} unless covered_by_serializable_filters?(relative_path) + end + end + end + + private + + def extract_filter_definitions + filter_array = [] + + filters.each do |filter| + case filter + when SimpleCov::StringFilter + filter_array << {string: filter.filter_argument} + when SimpleCov::RegexFilter + filter_array << {regex: filter.filter_argument.source} + end + end + + filter_array + end + + def covered_by_serializable_filters?(relative_path) + @filter_definitions.any? do |filter_def| + if filter_def[:string] + relative_path.include?(filter_def[:string]) + elsif filter_def[:regex] + relative_path.match?(Regexp.new(filter_def[:regex])) + end + end + end + end +end + module Undercover class ResultHashFormatterWithRoot < SimpleCovJSONFormatter::ResultHashFormatter def format formatted_result[:meta] = {timestamp: @result.created_at.to_i} format_files add_undercover_meta_fields + add_ignored_files formatted_result end @@ -30,6 +78,10 @@ def add_undercover_meta_fields end end + def add_ignored_files + formatted_result[:meta][:ignored_files] = SimpleCov.filter_definitions || [] + end + # format_files uses relative path as keys, as opposed to the superclass method def format_files formatted_result[:coverage] ||= {} diff --git a/lib/undercover/simplecov_result_adapter.rb b/lib/undercover/simplecov_result_adapter.rb index a6ba794..22f50c7 100644 --- a/lib/undercover/simplecov_result_adapter.rb +++ b/lib/undercover/simplecov_result_adapter.rb @@ -56,6 +56,10 @@ def skipped?(filepath, line_no) def total_coverage; end def total_branch_coverage; end + def ignored_files + @ignored_files ||= simplecov_result.dig('meta', 'ignored_files') || [] + end + private def find_file(filepath) diff --git a/spec/changeset_spec.rb b/spec/changeset_spec.rb index 70c454d..01adf92 100644 --- a/spec/changeset_spec.rb +++ b/spec/changeset_spec.rb @@ -62,7 +62,7 @@ describe 'filtering' do it 'filters files using FilterSet in each_changed_line' do - filter_set = Undercover::FilterSet.new(['*.rb'], ['*_spec.rb']) + filter_set = Undercover::FilterSet.new(['*.rb'], ['*_spec.rb'], []) changeset = Undercover::Changeset.new('spec/fixtures/test.git', 'master', filter_set) yielded_files = [] @@ -75,7 +75,7 @@ end it 'filters files using FilterSet with brace expansion' do - filter_set = Undercover::FilterSet.new(['*.{rb,js}'], ['*_spec.rb']) + filter_set = Undercover::FilterSet.new(['*.{rb,js}'], ['*_spec.rb'], []) changeset = Undercover::Changeset.new('spec/fixtures/test.git', 'master', filter_set) yielded_files = [] diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index d8f0c7b..f1c49de 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -16,7 +16,8 @@ path: '.', git_dir: '.git', compare: nil - ) + ), + mock_simplecov_result_adapter ) .and_call_original subject.run([]) @@ -63,6 +64,7 @@ it 'creates an Undercover::Report with options' do stub_stdout stub_build + expect(Undercover::Report) .to receive(:new) .with( @@ -72,7 +74,8 @@ path: 'spec/fixtures', git_dir: 'test.git', compare: nil - ) + ), + mock_simplecov_result_adapter ) .and_call_original subject.run(%w[-lspec/fixtures/sample.lcov -pspec/fixtures -gtest.git]) @@ -81,6 +84,7 @@ it 'accepts --compare' do stub_stdout stub_build + expect(Undercover::Report) .to receive(:new) .with( @@ -90,7 +94,8 @@ path: '.', git_dir: '.git', compare: 'HEAD~1' - ) + ), + mock_simplecov_result_adapter ) .and_call_original subject.run(%w[-cHEAD~1]) @@ -99,6 +104,7 @@ it 'sets file globs from options' do stub_stdout stub_build + expect(Undercover::Report) .to receive(:new) .with( @@ -110,7 +116,8 @@ compare: nil, glob_allow_filters: ['*.rb', '*.rake'], glob_reject_filters: ['Rakefile'] - ) + ), + mock_simplecov_result_adapter ) .and_call_original subject.run(%w[-f *.rb,*.rake -x Rakefile]) @@ -181,6 +188,7 @@ it 'sets max_warnings_limit from options' do stub_stdout stub_build + expect(Undercover::Report) .to receive(:new) .with( @@ -191,7 +199,8 @@ git_dir: '.git', compare: nil, max_warnings_limit: 5 - ) + ), + mock_simplecov_result_adapter ) .and_call_original subject.run(%w[-w 5]) @@ -200,6 +209,7 @@ it 'sets max_warnings_limit from long option' do stub_stdout stub_build + expect(Undercover::Report) .to receive(:new) .with( @@ -210,7 +220,8 @@ git_dir: '.git', compare: nil, max_warnings_limit: 10 - ) + ), + mock_simplecov_result_adapter ) .and_call_original subject.run(%w[--max-warnings 10]) @@ -250,11 +261,75 @@ allow_any_instance_of(Undercover::Report).to receive(:build) { |rep| rep } allow_any_instance_of(Undercover::Report).to receive(:flagged_results) { [] } - expect(Undercover::SimplecovResultAdapter).to receive(:parse).with(json_file, instance_of(Undercover::Options)) + expect(Undercover::SimplecovResultAdapter) + .to receive(:parse).with(json_file, instance_of(Undercover::Options)) + .and_return(double(coverage: [], ignored_files: [])) subject.run(['-l', 'test.lcov', '-s', 'test.json']) end + it 'creates changeset with SimpleCov ignored files' do + stub_stdout + allow_any_instance_of(Undercover::Options).to receive(:guess_resultset_path) + + json_content = { + 'meta' => { + 'ignored_files' => [{'string' => 'app/lib/temp/'}, {'file' => 'db/migrate/migration.rb'}] + }, + 'coverage' => { + 'app/models/user.rb' => {'lines' => [1, 0]} + } + }.to_json + json_file = StringIO.new(json_content) + + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with('test.json').and_return(true) + allow(File).to receive(:exist?).with('./.undercover').and_return(false) + allow(File).to receive(:open).with('test.json') { json_file } + + simplecov_adapter = double('SimpleCov adapter', + coverage: [], + ignored_files: [{'string' => 'app/lib/temp/'}, + {'file' => 'db/migrate/migration.rb'}]) + allow(Undercover::SimplecovResultAdapter).to receive(:parse).and_return(simplecov_adapter) + + allow_any_instance_of(Undercover::Report).to receive(:validate) { nil } + allow_any_instance_of(Undercover::Report).to receive(:build) { |rep| rep } + allow_any_instance_of(Undercover::Report).to receive(:flagged_results) { [] } + + expect(Undercover::FilterSet).to receive(:new).with( + ['*.rb', '*.rake', '*.ru', 'Rakefile'], + ['test/*', 'spec/*', 'db/*', 'config/*', '*_test.rb', '*_spec.rb'], + [{'string' => 'app/lib/temp/'}, {'file' => 'db/migrate/migration.rb'}] + ).once.and_call_original + + subject.run(['-s', 'test.json']) + end + + it 'parses lcov report and passes it to the report builder' do + stub_stdout + + allow_any_instance_of(Undercover::Report).to receive(:validate) { nil } + allow_any_instance_of(Undercover::Report).to receive(:build) { |rep| rep } + allow_any_instance_of(Undercover::Report).to receive(:flagged_results) { [] } + expect(Undercover::LcovParser).to receive(:parse) { mock_lcov_parser } + expect(Undercover::Report) + .to receive(:new) + .with( + instance_of(Undercover::Changeset), + undercover_options( + lcov: 'spec/fixtures/sample.lcov', + path: '.', + git_dir: '.git', + compare: nil + ), + mock_lcov_parser + ) + .and_call_original + + subject.run(['-l', 'spec/fixtures/sample.lcov']) + end + it 'returns 1 exit code when no coverage report found' do allow_any_instance_of(Undercover::Options).to receive(:guess_resultset_path) allow_any_instance_of(Undercover::Options).to receive(:guess_lcov_path) @@ -292,6 +367,11 @@ end.to output(expected_output).to_stdout end + let(:mock_simplecov_result_adapter) do + instance_double(Undercover::SimplecovResultAdapter, coverage: [], ignored_files: []) + end + let(:mock_lcov_parser) { instance_double(Undercover::LcovParser, coverage: [], ignored_files: []) } + def stub_build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength file_stub = double('file', read: '{"coverage": {}}', each: nil) allow_any_instance_of(Undercover::Options).to receive(:guess_resultset_path) do |opts| @@ -302,10 +382,10 @@ def stub_build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength allow(File).to receive(:exist?).with('./.undercover').and_return(false) allow(File).to receive(:open) { file_stub } allow(Undercover::SimplecovResultAdapter).to receive(:parse).with(file_stub, instance_of(Undercover::Options)) do - double(coverage: []) + mock_simplecov_result_adapter end allow(Undercover::LcovParser).to receive(:parse).with(file_stub, instance_of(Undercover::Options)) do - double(coverage: []) + mock_lcov_parser end allow_any_instance_of(Undercover::Report).to receive(:validate) { nil } allow_any_instance_of(Undercover::Report).to receive(:build) { |rep| rep } diff --git a/spec/filter_set_spec.rb b/spec/filter_set_spec.rb new file mode 100644 index 0000000..a9fb4c4 --- /dev/null +++ b/spec/filter_set_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'undercover/filter_set' + +describe Undercover::FilterSet do + let(:allow_filters) { ['*.rb'] } + let(:reject_filters) { ['*_spec.rb'] } + let(:simplecov_filters) { [{file: 'app/lib/filtered_file.rb'}] } + + subject(:filter_set) { described_class.new(allow_filters, reject_filters, simplecov_filters) } + + describe '#include?' do + context 'when file is in SimpleCov ignored files' do + it 'returns false regardless of other filters' do + expect(filter_set.include?('app/lib/filtered_file.rb')).to be false + end + end + + context 'when file is not in SimpleCov ignored files' do + it 'returns true for files matching allow filters and not matching reject filters' do + expect(filter_set.include?('app/models/user.rb')).to be true + end + + it 'returns false for files matching reject filters' do + expect(filter_set.include?('app/models/user_spec.rb')).to be false + end + + it 'returns false for files not matching allow filters' do + expect(filter_set.include?('app/assets/style.css')).to be false + end + end + + context 'with empty SimpleCov ignored files' do + let(:simplecov_filters) { [] } + + it 'behaves like the original FilterSet' do + expect(filter_set.include?('app/models/user.rb')).to be true + expect(filter_set.include?('app/models/user_spec.rb')).to be false + expect(filter_set.include?('app/assets/style.css')).to be false + end + end + + context 'with complex glob patterns' do + let(:allow_filters) { ['*.rb', '*.rake', 'Rakefile'] } + let(:reject_filters) { ['test/*', 'spec/*'] } + let(:simplecov_filters) { [{file: 'lib/migrations/20230101_create_users.rb'}] } + + it 'correctly applies all filters' do + expect(filter_set.include?('app/models/user.rb')).to be true + expect(filter_set.include?('Rakefile')).to be true + expect(filter_set.include?('test/user_test.rb')).to be false + expect(filter_set.include?('spec/user_spec.rb')).to be false + expect(filter_set.include?('lib/migrations/20230101_create_users.rb')).to be false + end + end + + context 'with string and regex filters' do + let(:simplecov_filters) do + [ + {string: 'spec/'}, + {regex: '\/test\/'}, + {file: 'custom_ignored.rb'}, + ] + end + + it 'correctly evaluates string filters' do + expect(filter_set.include?('spec/user_spec.rb')).to be false + expect(filter_set.include?('app/spec/helper.rb')).to be false + end + + it 'correctly evaluates regex filters' do + expect(filter_set.include?('app/test/unit_test.rb')).to be false + expect(filter_set.include?('lib/test/integration_test.rb')).to be false + end + + it 'correctly evaluates file filters' do + expect(filter_set.include?('custom_ignored.rb')).to be false + end + + it 'allows files not matching any filter' do + expect(filter_set.include?('app/models/user.rb')).to be true + end + + it 'handles file filter that does not match' do + expect(filter_set.include?('different_file.rb')).to be true + end + + it 'handles file filter that returns false when filepath does not match exactly' do + file_filter_set = described_class.new(['*.rb'], [], [{file: 'exact_match.rb'}]) + expect(file_filter_set.include?('different_file.rb')).to be true + expect(file_filter_set.include?('exact_match.rb')).to be false + end + + it 'explicitly tests file filter branch where comparison returns false' do + test_filter_set = described_class.new(['*.rb'], [], [{file: 'specific_file.rb'}]) + expect(test_filter_set.include?('other_file.rb')).to be true + end + + it 'tests the false branch of file filter comparison within any loop' do + multi_filter_set = described_class.new(['*.rb'], [], [ + {file: 'will_not_match.rb'}, + {string: 'also_will_not_match'}, + ]) + expect(multi_filter_set.include?('some_other_file.rb')).to be true + end + + it 'specifically tests file filter false return in isolation' do + isolated_filter_set = described_class.new(['*.rb'], [], [{file: 'exact_name.rb'}]) + expect(isolated_filter_set.include?('totally_different.rb')).to be true + expect(isolated_filter_set.include?('exact_name.rb')).to be false + end + + it 'forces file filter false evaluation by using non-matching filename' do + force_false_set = described_class.new(['*.rb'], [], [{file: 'specific_file.rb'}]) + expect(force_false_set.include?('different_file.rb')).to be true + end + + it 'tests the elsif branch condition itself with falsy file value' do + falsy_filter_set = described_class.new(['*.rb'], [], [ + {file: nil}, + {file: ''}, + {string: 'will_not_match'}, + ]) + expect(falsy_filter_set.include?('any_file.rb')).to be true + end + end + end +end diff --git a/spec/fixtures/simplecov_with_ignored_files.json b/spec/fixtures/simplecov_with_ignored_files.json new file mode 100644 index 0000000..57c86f1 --- /dev/null +++ b/spec/fixtures/simplecov_with_ignored_files.json @@ -0,0 +1,36 @@ +{ + "meta": { + "timestamp": 1579085826, + "simplecov_root": "/Users/mrgrodo/dev/undercover", + "ignored_files": [ + { "string": "app/lib/temp/" }, + { "regex": "\/migrate\/" }, + { "file": "test/factories/user_factory.rb" } + ] + }, + "coverage": { + "class.rb": { + "lines": [ + null, + 1, + 1, + 1, + null, + 1, + 0, + null + ], + "branches": [] + }, + "module.rb": { + "lines": [ + null, + 1, + 1, + 0, + null + ], + "branches": [] + } + } +} \ No newline at end of file diff --git a/spec/report_spec.rb b/spec/report_spec.rb index 26bdaae..788c759 100644 --- a/spec/report_spec.rb +++ b/spec/report_spec.rb @@ -16,7 +16,7 @@ git_dir = File.join(options.path, options.git_dir) Undercover::Changeset.new(git_dir, options.compare) end - subject(:report) { described_class.new(changeset, options) } + subject(:report) { described_class.new(changeset, options, lcov_from_options(options)) } context 'with SimpleCov resultset' do let(:options_with_simplecov) do @@ -28,14 +28,11 @@ end it 'initializes with SimpleCov resultset adapter' do - json_file = StringIO.new('{"coverage": {}}') - lcov_file = double - expect(File).to receive(:open).with('spec/fixtures/nocov.json').and_return(json_file) + simplecov_adapter = double('SimpleCov adapter', coverage: [], ignored_files: []) - expect(Undercover::SimplecovResultAdapter).to receive(:parse).with(json_file, options_with_simplecov) - expect(Undercover::LcovParser).not_to receive(:parse).with(lcov_file, options_with_simplecov) + expect(Undercover::LcovParser).not_to receive(:parse) - described_class.new(changeset, options_with_simplecov) + described_class.new(changeset, options_with_simplecov, simplecov_adapter) end end @@ -119,27 +116,16 @@ .and_yield('test_two_patches.rb', 21) .and_yield('Rakefile', 1) .and_yield('.undercover_config', 1) # unparsable, won't appear in the report + allow(mock_changeset).to receive(:filter_with) mock_changeset end - it 'flags 2 two results when Rakefile is ignored' do - options.glob_reject_filters = ['Rakefile'] - options.lcov = 'spec/fixtures/test_two_patches.lcov' - report.build - flagged = report.flagged_results - expect(flagged.size).to eq(2) - expect(flagged[0].file_path).to eq('test_two_patches.rb') - expect(flagged[0].first_line).to eq(3) - expect(flagged[1].file_path).to eq('test_two_patches.rb') - expect(flagged[1].first_line).to eq(15) - end - it 'deprecated build_warnings still works' do options.glob_allow_filters = ['*.rb'] options.lcov = 'spec/fixtures/test_two_patches.lcov' report.build warnings = report.build_warnings.to_a - expect(warnings.size).to eq(2) + expect(warnings.size).to eq(3) expect(warnings[0].file_path).to eq('test_two_patches.rb') expect(warnings[0].first_line).to eq(3) expect(warnings[1].file_path).to eq('test_two_patches.rb') @@ -207,6 +193,7 @@ opt.lcov = 'spec/fixtures/monorepo/app/coverage/app.lcov' opt.path = 'spec/fixtures/monorepo' opt.git_dir = 'monorepo.git' + opt.glob_allow_filters = ['*.rb'] end end @@ -214,7 +201,7 @@ git_dir = File.join(options.path, options.git_dir) Undercover::Changeset.new(git_dir, options.compare) end - subject(:report) { described_class.new(changeset, options) } + subject(:report) { described_class.new(changeset, options, lcov_from_options(options)) } it 'matches the paths relative to where undercover runs' do # simulate running undercover in the subdirectory @@ -239,4 +226,30 @@ skip end end + + context 'without SimpleCov resultset' do + let(:options_without_simplecov) do + Undercover::Options.new.tap do |opt| + opt.lcov = 'spec/fixtures/fixtures.lcov' + opt.path = 'spec/fixtures' + opt.git_dir = 'test.git' + end + end + subject(:report) do + described_class.new(changeset, options_without_simplecov, lcov_from_options(options_without_simplecov)) + end + + it 'creates FilterSet with empty ignored files' do + expect(report.filter_set.simplecov_filters).to eq([]) + end + + it 'behaves like the original implementation' do + expect(report.filter_set.include?('class.rb')).to be true + expect(report.filter_set.include?('app/lib/temp/temp_file.rb')).to be true + end + end + + def lcov_from_options(opts) + Undercover::LcovParser.parse(File.open(opts.lcov), opts) + end end diff --git a/spec/simplecov_formatter_spec.rb b/spec/simplecov_formatter_spec.rb index 8f840a3..88d8eb4 100644 --- a/spec/simplecov_formatter_spec.rb +++ b/spec/simplecov_formatter_spec.rb @@ -60,7 +60,9 @@ RSpec.describe 'Undercover::ResultHashFormatterWithRoot' do let(:lines) { [1, 0, nil, 1] } - let(:source_file) { double('source_file', project_filename: '/absolute/path/file.rb') } + let(:source_file) do + double('source_file', project_filename: '/absolute/path/file.rb', filename: '/absolute/path/file.rb') + end let(:result) do result = SimpleCov::Result.from_hash( 'rspec' => { @@ -96,6 +98,136 @@ expect(formatted[:coverage]).to have_key('absolute/path/file.rb') expect(formatted[:coverage]).not_to have_key('/absolute/path/file.rb') end + + it 'includes ignored files in meta' do + # Mock the filter definitions to return a mix of filter types + allow(SimpleCov).to receive(:filter_definitions).and_return([ + {string: 'spec/'}, + {regex: '\/test\/'}, + {file: 'path/custom_ignored.rb'}, + ]) + + formatted = formatter.format + + expect(formatted[:meta][:ignored_files]).to eq([ + {string: 'spec/'}, + {regex: '\/test\/'}, + {file: 'path/custom_ignored.rb'}, + ]) + end + + it 'handles case with no ignored files' do + # Mock empty filter definitions + allow(SimpleCov).to receive(:filter_definitions).and_return([]) + + formatted = formatter.format + + expect(formatted[:meta][:ignored_files]).to eq([]) + end + + it 'handles mixed filter types' do + # Mock filter definitions with various types + allow(SimpleCov).to receive(:filter_definitions).and_return([ + {string: 'app/models/'}, + {regex: '\/fixtures\/'}, + {file: 'specific_ignored.rb'}, + {string: 'vendor/'}, + ]) + + formatted = formatter.format + + expect(formatted[:meta][:ignored_files]).to eq([ + {string: 'app/models/'}, + {regex: '\/fixtures\/'}, + {file: 'specific_ignored.rb'}, + {string: 'vendor/'}, + ]) + end + + it 'does not add file entries for paths covered by serializable filters' do + SimpleCov.filter_definitions = nil + string_filter = SimpleCov::StringFilter.new('spec/') + allow(SimpleCov).to receive(:filters).and_return([string_filter]) + + filtered_file = double('filtered_file', filename: '/absolute/spec/example_spec.rb') + kept_file = double('kept_file', filename: '/absolute/app/model.rb') + + allow(SimpleCov).to receive(:filtered_uncached).and_return([kept_file]) + allow(SimpleCov).to receive(:root).and_return('/absolute') + + SimpleCov.filtered([kept_file, filtered_file]) + formatted = formatter.format + + expect(formatted[:meta][:ignored_files]).to eq([{string: 'spec/'}]) + end + + it 'adds file entries for paths not covered by serializable filters' do + SimpleCov.filter_definitions = nil + string_filter = SimpleCov::StringFilter.new('vendor/') + allow(SimpleCov).to receive(:filters).and_return([string_filter]) + + filtered_file = double('filtered_file', filename: '/absolute/custom/special.rb') + kept_file = double('kept_file', filename: '/absolute/app/model.rb') + + allow(SimpleCov).to receive(:filtered_uncached).and_return([kept_file]) + allow(SimpleCov).to receive(:root).and_return('/absolute') + + SimpleCov.filtered([kept_file, filtered_file]) + formatted = formatter.format + + expect(formatted[:meta][:ignored_files]).to eq([ + {string: 'vendor/'}, + {file: 'custom/special.rb'}, + ]) + end + + it 'handles regex filters that do not match in covered_by_serializable_filters' do + SimpleCov.filter_definitions = nil + regex_filter = SimpleCov::RegexFilter.new(/\/nonexistent\//) + allow(SimpleCov).to receive(:filters).and_return([regex_filter]) + + filtered_file = double('filtered_file', filename: '/absolute/app/model.rb') + kept_file = double('kept_file', filename: '/absolute/app/other.rb') + + allow(SimpleCov).to receive(:filtered_uncached).and_return([kept_file]) + allow(SimpleCov).to receive(:root).and_return('/absolute') + + SimpleCov.filtered([kept_file, filtered_file]) + formatted = formatter.format + + expect(formatted[:meta][:ignored_files]).to eq([ + {regex: '/nonexistent/'}, + {file: 'app/model.rb'}, + ]) + end + + it 'handles unknown filter types in extract_filter_definitions' do + SimpleCov.filter_definitions = nil + + custom_filter_class = Class.new(SimpleCov::Filter) do + def matches?(_source_file) + true + end + end + + string_filter = SimpleCov::StringFilter.new('spec/') + custom_filter = custom_filter_class.new('custom_argument') + allow(SimpleCov).to receive(:filters).and_return([string_filter, custom_filter]) + + filtered_file = double('filtered_file', filename: '/absolute/custom/file.rb') + kept_file = double('kept_file', filename: '/absolute/app/model.rb') + + allow(SimpleCov).to receive(:filtered_uncached).and_return([kept_file]) + allow(SimpleCov).to receive(:root).and_return('/absolute') + + SimpleCov.filtered([kept_file, filtered_file]) + formatted = formatter.format + + expect(formatted[:meta][:ignored_files]).to eq([ + {string: 'spec/'}, + {file: 'custom/file.rb'}, + ]) + end end describe '#add_undercover_meta_fields' do diff --git a/spec/simplecov_result_adapter_spec.rb b/spec/simplecov_result_adapter_spec.rb index 43a0e05..26531cc 100644 --- a/spec/simplecov_result_adapter_spec.rb +++ b/spec/simplecov_result_adapter_spec.rb @@ -200,4 +200,32 @@ expect(adapter.skipped?('test.rb', 3)).to be false end end + + describe '#ignored_files' do + it 'returns ignored files from meta section' do + adapter = simplecov_coverage_fixture('spec/fixtures/simplecov_with_ignored_files.json') + expected_ignored = [ + {'string' => 'app/lib/temp/'}, + {'regex' => '/migrate/'}, + {'file' => 'test/factories/user_factory.rb'}, + ] + + expect(adapter.ignored_files).to eq(expected_ignored) + end + + it 'returns empty array when no ignored files in meta' do + adapter = simplecov_coverage_fixture('spec/fixtures/nocov.json') + expect(adapter.ignored_files).to eq([]) + end + + it 'returns empty array when meta section is missing' do + result = { + 'coverage' => { + 'test.rb' => {'lines' => [1, 0]} + } + } + adapter = described_class.new(result, nil) + expect(adapter.ignored_files).to eq([]) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 98a1d83..a8f0bdc 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -33,7 +33,12 @@ RSpec::Matchers.define :undercover_options do |opts_hash| match do |actual| opts_hash.all? do |opt_key, opt_value| - expect(actual.send(opt_key)).to match(opt_value) + actual_value = actual.send(opt_key) + if opt_value.is_a?(RSpec::Matchers::BuiltIn::BaseMatcher) + opt_value.matches?(actual_value) + else + actual_value == opt_value + end end end end