covet 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +12 -0
- data/README.md +82 -0
- data/Rakefile +37 -0
- data/bin/covet +3 -0
- data/ext/covet_coverage/covet_coverage.c +153 -0
- data/ext/covet_coverage/extconf.rb +7 -0
- data/lib/covet.rb +206 -0
- data/lib/covet/cli.rb +254 -0
- data/lib/covet/collection_compressor.rb +20 -0
- data/lib/covet/collection_filter.rb +113 -0
- data/lib/covet/line_changes_vcs.rb +13 -0
- data/lib/covet/log_collection.rb +51 -0
- data/lib/covet/log_file.rb +164 -0
- data/lib/covet/test_runners/minitest.rb +159 -0
- data/lib/covet/test_runners/rspec.rb +92 -0
- data/lib/covet/vcs/git.rb +62 -0
- data/lib/covet/version.rb +3 -0
- data/lib/covet_collect.rb +24 -0
- metadata +92 -0
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
|