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.
- 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
|