covet 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/covet/cli.rb ADDED
@@ -0,0 +1,254 @@
1
+ require 'optparse'
2
+ require_relative 'log_file'
3
+ require_relative 'collection_filter'
4
+ require_relative 'version'
5
+
6
+ module Covet
7
+ class CLI
8
+ class << self
9
+ attr_accessor :options
10
+ end
11
+
12
+ def initialize(argv)
13
+ @argv = argv
14
+ end
15
+
16
+ # TODO: process cmdline options for
17
+ # - specify VCS [ ]
18
+ # - specify test seed (ordering)
19
+ # - stats (show filtered files, files to run vs files to ignore, etc.)
20
+ def run
21
+ options = nil
22
+ begin
23
+ options = Options.parse!(@argv)
24
+ self.class.options = options
25
+ rescue OptionParser::InvalidArgument, OptionParser::InvalidOption => e
26
+ Kernel.abort "Error: #{e.message}"
27
+ end
28
+
29
+ if options[:collect_cmdline] && !options[:collect_cmdline].empty?
30
+ cmd = options[:collect_cmdline]
31
+ env_options = { 'COVET_COLLECT' => '1' }
32
+ if options[:collect_gem_whitelist].any?
33
+ env_options['COVET_GEM_WHITELIST'] = %Q("#{options[:collect_gem_whitelist].join(',')}")
34
+ end
35
+ if options[:test_runner] != Options::DEFAULTS[:test_runner]
36
+ env_options['COVET_TEST_RUNNER'] = %Q("#{options[:test_runner]}")
37
+ end
38
+ env_list_str = env_options.to_a.map { |ary| ary[0] + '=' + ary[1] }.join(' ')
39
+ cmd = %Q(#{env_list_str} #{cmd})
40
+ puts cmd
41
+ puts "Collecting coverage information for each test method..."
42
+ Kernel.exec cmd
43
+ end
44
+
45
+ revision = options[:revision]
46
+ line_changes = nil # establish scope
47
+ begin
48
+ line_changes = LineChangesVCS.changes_since(revision)
49
+ rescue Rugged::RepositoryError
50
+ Kernel.abort "Error: #{Dir.pwd} is not a git repository. " \
51
+ "Make sure you're in the project root."
52
+ rescue Rugged::Error, Rugged::InvalidError, TypeError
53
+ Kernel.abort "Error: #{options[:revision]} is not a valid revision reference in #{options[:VCS]}"
54
+ end
55
+ if line_changes.empty?
56
+ if revision.to_s == 'last_commit'
57
+ revision = "last commit" # prettier output below
58
+ end
59
+ puts "# No changes since #{revision}. You can specify the #{options[:VCS]} revision using the --revision option."
60
+ Kernel.exit
61
+ end
62
+
63
+ cov_map = Hash.new { |h, file| h[file] = Hash.new { |i, line| i[line] = [] } }
64
+ logfile = LogFile.new(:mode => 'r')
65
+
66
+ if logfile.file_exists?
67
+
68
+ run_stats = {}
69
+ # Read in the coverage info
70
+ logfile.load_each_buf! do |buf|
71
+ buf.each do |args|
72
+ if args[0] == 'base' # first value logged
73
+ run_options = args.last
74
+ if run_options['version'] != Covet::VERSION
75
+ warn "Warning - the run log was created with another version of covet " \
76
+ "(#{run_options['version']}), which is not guaranteed to be compatible " \
77
+ "with this version of covet (#{Covet::VERSION}). Please run 'covet -c' again."
78
+ end
79
+ next
80
+ end
81
+
82
+ if args[0] == 'stats' # last value logged
83
+ run_stats.update(args.last)
84
+ next
85
+ end
86
+
87
+ desc = args[0]
88
+ delta = args[1]
89
+ next if delta.nil? # no coverage difference
90
+ #stats = args[2]
91
+
92
+ delta.each_pair do |fname, lines_hash|
93
+ file_map = cov_map[fname]
94
+ lines_hash.each do |line, _executions|
95
+ # add the test name to the map. Multiple tests can execute the same
96
+ # line, so we need to use an array.
97
+ file_map[line.to_i] << desc
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ git_repo = VCS::Git.find_git_repo_path!
104
+
105
+ to_run = []
106
+ line_changes.each do |(file, line)|
107
+ full_path = File.join(git_repo, file)
108
+ relative_to_pwd = file
109
+ if git_repo != Dir.pwd
110
+ relative_to_pwd = full_path.sub(Dir.pwd, '').sub(File::SEPARATOR, '')
111
+ end
112
+ # NOTE: here, `file` is a filename starting from the GIT path (not necessarily `Dir.pwd`)
113
+ # if the actual test files changed, then we need to run the whole file again.
114
+ if relative_to_pwd.start_with?(*Covet.test_directories)
115
+ if relative_to_pwd.start_with?("test#{File::SEPARATOR}") && relative_to_pwd.end_with?('_test.rb', '_spec.rb')
116
+ to_run << [file, full_path] unless to_run.include?([file, full_path])
117
+ # We have to disable the method filter in this case because we
118
+ # don't know the method names of all these methods in this file.
119
+ # TODO: save this information in the coverage log file and use it here.
120
+ options[:disable_test_method_filter] = true
121
+ elsif relative_to_pwd.start_with?("spec#{File::SEPARATOR}") && relative_to_pwd.end_with?('_test.rb', '_spec.rb')
122
+ to_run << [file, full_path] unless to_run.include?([file, full_path])
123
+ # We have to disable the method filter in this case because we
124
+ # don't know the method names of all these methods in this file.
125
+ # TODO: save this information in the coverage log file and use it here.
126
+ options[:disable_test_method_filter] = true
127
+ end
128
+ next
129
+ end
130
+ # library code changes
131
+ cov_map[full_path][line].each do |desc|
132
+ to_run << [file, desc] unless to_run.include?([file, desc])
133
+ end
134
+ end
135
+ if ENV['COVET_INVERT_RUN_LIST'] == '1' # NOTE: for debugging covet only
136
+ to_run_fnames = to_run.map { |(_file, desc)| desc.split('#').first }.flatten.uniq
137
+ all_fnames = Dir.glob("{#{Covet.test_directories.join(',')}}/**/*_{test,spec}.rb").to_a.map { |fname| File.expand_path(fname, Dir.pwd) }
138
+ to_run = (all_fnames - to_run_fnames).map { |fname| [fname, "#{fname}##{fname}"] }.sort_by do |ary|
139
+ ary[1].split('#').first
140
+ end
141
+ end
142
+ if options[:exec_run_list]
143
+ if to_run.empty?
144
+ puts "# No test cases to run"
145
+ else
146
+ cmdline = Covet.cmdline_for_run_list(to_run)
147
+ puts cmdline
148
+ Kernel.exec cmdline
149
+ end
150
+ elsif options[:print_run_list]
151
+ if to_run.empty?
152
+ puts "# No test cases to run"
153
+ else
154
+ if options[:print_run_list_format] == :"test-runner"
155
+ puts Covet.cmdline_for_run_list(to_run)
156
+ else
157
+ # TODO: show not just the files but also the methods in each file
158
+ puts "You need to run:"
159
+ to_run.uniq! { |(_file, desc)| desc.split('#').first }
160
+ to_run.each do |(_file, desc)|
161
+ puts " - #{desc.split('#').first}"
162
+ end
163
+ end
164
+ end
165
+ end
166
+ else
167
+ Kernel.abort "Error: The coverage log file doesn't exist.\n" \
168
+ "You need to collect info first with 'covet -c $TEST_CMD'\n" \
169
+ "Ex: covet -c \"rake test\""
170
+ end
171
+ end
172
+ end
173
+
174
+ module Options
175
+ DEFAULTS = {
176
+ :collect_cmdline => nil,
177
+ :VCS => :git,
178
+ :revision => :last_commit,
179
+ :test_order => :random_seeded, # one of [:random_seeded, :random, or :ordered]
180
+ :test_runner => :minitest, # ENV['COVET_TEST_RUNNER']
181
+ :exec_run_list => false,
182
+ :disable_test_method_filter => false,
183
+ :print_run_list => true,
184
+ :print_run_list_format => :simple,
185
+ :collect_gem_whitelist => [], # ENV['COVET_GEM_WHITELIST']
186
+ :debug => false, # TODO: use
187
+ :verbose => 0, # TODO: levels 0, 1, 2, maybe?
188
+ }.freeze
189
+
190
+ # @return Hash
191
+ def self.parse!(argv)
192
+ options = DEFAULTS.dup
193
+
194
+ OptionParser.new do |opts|
195
+ opts.banner = "Usage: covet [options]"
196
+ opts.separator ""
197
+ opts.separator "Specific options:"
198
+
199
+ opts.on('-c', '--collect CMDLINE', 'collect coverage information for test run of given cmdline') do |cmdline|
200
+ options[:collect_cmdline] = cmdline
201
+ end
202
+ opts.on('--whitelist-gems GEMS', Array, 'whitelist given gems for collection phase') do |gems|
203
+ options[:collect_gem_whitelist] = gems
204
+ gems.each { |gem| CollectionFilter.whitelist_gem(gem) }
205
+ end
206
+ opts.on('-f', '--print-fmt FMT', "Format run list - 'simple' (default) or 'test-runner'") do |fmt|
207
+ case fmt
208
+ # TODO: add 'json' format to dump run list in JSON
209
+ when 'simple', 'test-runner'
210
+ options[:print_run_list_format] = fmt.intern
211
+ else
212
+ raise OptionParser::InvalidArgument,
213
+ "Valid values: 'simple', 'test-runner'"
214
+ end
215
+ end
216
+ opts.on('-e', '--exec', 'Execute run list') do
217
+ options[:print_run_list] = false
218
+ options[:exec_run_list] = true
219
+ end
220
+ opts.on('--disable-method-filter', 'When executing run list, run all test methods for each file') do
221
+ options[:disable_test_method_filter] = true
222
+ end
223
+ opts.on('-r', '--revision REVISION', 'VCS Revision (defaults to last commit)') do |rev|
224
+ options[:revision] = rev
225
+ end
226
+ opts.on('-t', '--test-runner RUNNER') do |runner|
227
+ begin
228
+ Covet.test_runner = runner
229
+ rescue ArgumentError => e
230
+ Kernel.abort "Error: #{e.message}"
231
+ end
232
+ options[:test_runner] = runner.intern
233
+ end
234
+ #opts.on('-o', '--test-order ORDER', 'Specify test order for collection phase.') do |order|
235
+ #begin
236
+ #Covet.test_order = order.to_s
237
+ #rescue ArgumentError => e
238
+ #Kernel.abort "Error: #{e.message}"
239
+ #end
240
+ #end
241
+ opts.on_tail('-v', '--version', 'Show covet version') do
242
+ puts VERSION
243
+ Kernel.exit
244
+ end
245
+ opts.on('-h', '--help', 'Show this message') do
246
+ puts opts
247
+ Kernel.exit
248
+ end
249
+ end.parse!(argv)
250
+
251
+ options
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,20 @@
1
+ module Covet
2
+ module CollectionCompressor
3
+
4
+ # Turn sparse Array returned from `Coverage.peek_result` into
5
+ # more compact representation - a Hash of only the lines that
6
+ # were executed at least once.
7
+ # @param [Hash] coverage_info
8
+ def self.compress(coverage_info)
9
+ ret = {}
10
+ coverage_info.each do |fname, cov_ary|
11
+ ret[fname] ||= {}
12
+ cov_ary.each_with_index do |times_run, idx|
13
+ next if times_run.to_i == 0
14
+ ret[fname][idx+1] = times_run # lineno = idx + 1
15
+ end
16
+ end
17
+ ret
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,113 @@
1
+ require 'rbconfig'
2
+ require 'set'
3
+
4
+ module Covet
5
+ # Responsible for filtering out files that shouldn't be logged in the
6
+ # `run_log` during the coverage collection phase. For instance, if using
7
+ # the `minitest` test runner, we shouldn't log coverage information
8
+ # for internal `minitest` methods (unless we're using covet ON `minitest` itself),
9
+ # The same goes for `rake`, etc. This minimizes the amount of JSON we have to save
10
+ # in the `run_log`, and also the amount of processing we have to do on the `run_log`
11
+ # structure when generating the `run_list`.
12
+ module CollectionFilter
13
+ if (gem_whitelist = ENV['COVET_GEM_WHITELIST'])
14
+ @@gem_whitelist = gem_whitelist.split(',').reject(&:empty?)
15
+ else
16
+ @@gem_whitelist = []
17
+ end
18
+ @@custom_filters = [] # @var Array<Proc>
19
+ @@file_whitelist = [] # @var Array<String>, full file path whitelist
20
+ @@regexp_whitelist = [] # @var Array<Regexp>
21
+ @@filter_stats = Hash.new { |h,k| h[k] = Set.new } # @var Hash<String => Set>, holds info on which files were blacklisted or whitelisted from `run_log`
22
+
23
+ # @param String|Symbol gem_name
24
+ def self.whitelist_gem(gem_name)
25
+ @@gem_whitelist << gem_name.to_s
26
+ end
27
+
28
+ # FIXME: should take filename AND method name
29
+ # @param Proc filter, arity = 1, takes filename
30
+ def self.add_custom_filter(&filter)
31
+ @@custom_filters << filter
32
+ end
33
+ def self.add_to_file_whitelist(fname)
34
+ unless fname.start_with?(File::SEPARATOR)
35
+ raise ArgumentError, "expected #{fname} to be an absolute path"
36
+ end
37
+ @@file_whitelist << fname
38
+ end
39
+ def self.add_to_regexp_whitelist(regexp)
40
+ @@regexp_whitelist << regexp
41
+ end
42
+
43
+ def self.files_filtered
44
+ Hash[@@filter_stats.map { |k, v| [k, v.to_a] }]
45
+ end
46
+
47
+ # @return Hash
48
+ def self.filter(raw_coverage_info)
49
+ raw_coverage_info = raw_coverage_info.dup
50
+ # NOTE: The list of activated gems isn't cached, because it could be
51
+ # that a test method activates a gem or calls code that activates a
52
+ # gem. In that case, we want to omit the newly activated gem from the
53
+ # run log as well (unless it's whitelisted).
54
+ gem_base_dirs_to_omit = Gem.loaded_specs.values.reject do |spec|
55
+ @@gem_whitelist.include?(spec.name)
56
+ end.map do |spec|
57
+ spec.full_gem_path
58
+ end
59
+
60
+ files_to_omit = []
61
+
62
+ # find file names to omit from the run log
63
+ raw_coverage_info.each do |filename, _|
64
+ if whitelisted_filename?(filename)
65
+ @@filter_stats['whitelisted'] << filename
66
+ next # don't omit
67
+ end
68
+
69
+ if filename.start_with?(ruby_stdlib_dir)
70
+ @@filter_stats['stdlib_files'] << filename
71
+ files_to_omit << filename
72
+ next
73
+ end
74
+
75
+ omitted = gem_base_dirs_to_omit.find do |gem_base_dir|
76
+ if filename.start_with?(gem_base_dir)
77
+ @@filter_stats['gem_files'] << filename
78
+ files_to_omit << filename
79
+ end
80
+ end
81
+ next if omitted
82
+
83
+ # custom filters
84
+ @@custom_filters.find do |filter|
85
+ if filter.call(filename)
86
+ @@filter_stats['custom_filters'] << filename
87
+ files_to_omit << filename
88
+ end
89
+ end
90
+ end
91
+
92
+ files_to_omit.each do |fname|
93
+ raw_coverage_info.delete(fname)
94
+ end
95
+
96
+ raw_coverage_info
97
+ end
98
+
99
+ private
100
+
101
+ def self.whitelisted_filename?(filename)
102
+ if @@file_whitelist.include?(filename)
103
+ return true
104
+ end
105
+ @@regexp_whitelist.find { |re| re === filename }
106
+ end
107
+
108
+ def self.ruby_stdlib_dir
109
+ RbConfig::CONFIG['libdir']
110
+ end
111
+
112
+ end
113
+ end
@@ -0,0 +1,13 @@
1
+ module Covet
2
+ # Gets file and line numbers that have changed since an arbitrary point in the VCS's
3
+ # (Version Control System's) history.
4
+ module LineChangesVCS
5
+ # @return Set<Array>
6
+ def self.changes_since(since = :last_commit)
7
+ require_relative "vcs/#{Covet.vcs}"
8
+ Covet::VCS.const_get(Covet.vcs.capitalize).changes_since(since)
9
+ rescue LoadError
10
+ raise NotImplementedError, "#{self.class} can't get line changes using VCS: #{Covet.vcs} (not implemented)"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,51 @@
1
+ require_relative 'log_file'
2
+
3
+ module Covet
4
+ class LogCollection
5
+ attr_reader :flushes, :size
6
+
7
+ def initialize(options = {})
8
+ @bufsize = options[:bufsize] || 100 # max log buffer size to keep in memory
9
+ @log_file = LogFile.new(:filename => options[:filename], :mode => 'w')
10
+ @buf = []
11
+ @flushes = 0
12
+ @size = 0
13
+ end
14
+
15
+ # @param [Array] logs
16
+ def <<(logs)
17
+ unless Array === logs
18
+ raise TypeError, "expecting Array, got #{logs.class}"
19
+ end
20
+ @buf << logs
21
+ if @buf.size == @bufsize
22
+ flush!
23
+ end
24
+ @size += 1
25
+ true
26
+ end
27
+ alias :append :<<
28
+
29
+ def finish!
30
+ if @flushes == 0 && @buf.size == 0
31
+ return # avoid writing to file if no collections
32
+ end
33
+ flush! if @buf.any?
34
+ @log_file.write_end
35
+ true
36
+ end
37
+
38
+ private
39
+
40
+ # Flushes buffer to file
41
+ def flush!
42
+ if @flushes == 0
43
+ @log_file.write_start
44
+ end
45
+ @log_file.write_buf(@buf)
46
+ @buf.clear
47
+ @flushes += 1
48
+ end
49
+
50
+ end
51
+ end