covet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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