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
@@ -0,0 +1,164 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module Covet
|
4
|
+
# Represents log file of JSON coverage information for each test method.
|
5
|
+
# For each write of a memory buffer to disk, a separate index file keeps track
|
6
|
+
# of the file offset and bytes written for the buffer. This is so that when
|
7
|
+
# there lots of tests in a test suite, we don't have to keep all coverage
|
8
|
+
# information in memory. Instead, we flush the information and write it to
|
9
|
+
# disk at certain intervals. This way, we can also load the information in
|
10
|
+
# chunks as well, using the same index file.
|
11
|
+
class LogFile
|
12
|
+
require 'tempfile'
|
13
|
+
require 'fileutils'
|
14
|
+
|
15
|
+
LoadError = Class.new(StandardError)
|
16
|
+
|
17
|
+
attr_reader :name, :writes
|
18
|
+
|
19
|
+
def initialize(options = {})
|
20
|
+
@mode = options[:mode] || 'w'
|
21
|
+
@name = options[:filename] || File.join(Dir.pwd, 'run_log.json')
|
22
|
+
if @mode != 'r'
|
23
|
+
# We only want to create the real file during the `write_end` method, so write to
|
24
|
+
# a tempfile until then. This is in case the user stops their test suite with an
|
25
|
+
# interrupt.
|
26
|
+
@tmpfile = Tempfile.new(File.basename(@name))
|
27
|
+
@tmpname = @tmpfile.path
|
28
|
+
else
|
29
|
+
@tmpfile = nil
|
30
|
+
@tmpname = nil
|
31
|
+
end
|
32
|
+
@index_file = LogFileIndex.new(:filename => options[:index_filename])
|
33
|
+
@writes = 0
|
34
|
+
end
|
35
|
+
|
36
|
+
def write_start
|
37
|
+
check_can_write!
|
38
|
+
@tmpfile.write('[')
|
39
|
+
@writes += 1
|
40
|
+
end
|
41
|
+
|
42
|
+
def write_buf(buf)
|
43
|
+
check_can_write!
|
44
|
+
pos_start = @tmpfile.pos
|
45
|
+
@tmpfile.write(JSON.dump(buf) + ',')
|
46
|
+
@writes += 1
|
47
|
+
pos_after = @tmpfile.pos
|
48
|
+
@index_file.add_index(pos_start, pos_after - pos_start)
|
49
|
+
end
|
50
|
+
|
51
|
+
def write_end
|
52
|
+
check_can_write!
|
53
|
+
@tmpfile.pos -= 1 # remove final comma at end of array
|
54
|
+
@tmpfile.write(']')
|
55
|
+
@writes += 1
|
56
|
+
@tmpfile.close
|
57
|
+
FileUtils.cp(@tmpfile, @name)
|
58
|
+
@index_file.finish!
|
59
|
+
end
|
60
|
+
|
61
|
+
def load!
|
62
|
+
JSON.load(File.read(@name))
|
63
|
+
end
|
64
|
+
|
65
|
+
# Yields each coverage buffer (Array) one a time from the run log.
|
66
|
+
# @raises LogFile::LoadError
|
67
|
+
def load_each_buf! # yields
|
68
|
+
@index_file.reload!('r')
|
69
|
+
reload!('r')
|
70
|
+
index = JSON.load(File.read(@index_file.name))
|
71
|
+
index.each do |(pos, bytes_to_read)|
|
72
|
+
res = load_buf_from_file!(pos, bytes_to_read)
|
73
|
+
yield res # @var Array
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# @raises LogFile::LoadError
|
78
|
+
# @return Array
|
79
|
+
def load_buf!(buf_idx)
|
80
|
+
@index_file.reload!('r')
|
81
|
+
reload!('r')
|
82
|
+
index = JSON.load(File.read(@index_file.name))
|
83
|
+
pos, bytes_to_read = index[buf_idx]
|
84
|
+
load_buf_from_file!(pos, bytes_to_read)
|
85
|
+
end
|
86
|
+
|
87
|
+
# @raises LogFile::LoadError
|
88
|
+
# @return Hash
|
89
|
+
def load_run_stats!
|
90
|
+
load_buf!(-1).last[-1]
|
91
|
+
end
|
92
|
+
|
93
|
+
def file_exists?
|
94
|
+
File.exist?(@name)
|
95
|
+
end
|
96
|
+
|
97
|
+
# re-opens file, can raise Errno::ENOENT
|
98
|
+
def reload!(mode)
|
99
|
+
if @file && !@file.closed?
|
100
|
+
@file.close
|
101
|
+
end
|
102
|
+
@file = File.open(@name, mode)
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
def file
|
108
|
+
@file ||= File.open(@name, @mode)
|
109
|
+
end
|
110
|
+
|
111
|
+
def check_can_write!
|
112
|
+
if @mode == 'r'
|
113
|
+
raise "For writing to the log file, you must construct it with a different :mode"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# @raises LogFile::LoadError
|
118
|
+
# @return Array
|
119
|
+
def load_buf_from_file!(pos, bytes_to_read)
|
120
|
+
file.pos = pos
|
121
|
+
buf = file.read(bytes_to_read)
|
122
|
+
if buf.end_with?(',', ']]')
|
123
|
+
buf = buf[0..-2]
|
124
|
+
end
|
125
|
+
JSON.load(buf)
|
126
|
+
rescue JSON::ParserError => e
|
127
|
+
raise LogFile::LoadError, e.message
|
128
|
+
end
|
129
|
+
|
130
|
+
end
|
131
|
+
|
132
|
+
class LogFileIndex
|
133
|
+
attr_reader :name
|
134
|
+
|
135
|
+
def initialize(options = {})
|
136
|
+
@name = options[:filename] || File.join(Dir.pwd, 'run_log_index.json')
|
137
|
+
@index = []
|
138
|
+
end
|
139
|
+
|
140
|
+
def add_index(offset, bytes_written)
|
141
|
+
@index << [offset, bytes_written]
|
142
|
+
end
|
143
|
+
|
144
|
+
def finish!
|
145
|
+
if @index.size > 0
|
146
|
+
file.write(JSON.dump(@index))
|
147
|
+
file.close
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def reload!(mode)
|
152
|
+
if @file && !@file.closed?
|
153
|
+
@file.close
|
154
|
+
end
|
155
|
+
@file = File.open(@name, mode)
|
156
|
+
end
|
157
|
+
|
158
|
+
private
|
159
|
+
|
160
|
+
def file
|
161
|
+
@file ||= File.open(@name, 'w')
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
require 'rake/testtask'
|
2
|
+
|
3
|
+
module Covet
|
4
|
+
module TestRunners
|
5
|
+
module Minitest
|
6
|
+
@covet_hooked = false
|
7
|
+
@create_collection_file_on_exit = true
|
8
|
+
class << self
|
9
|
+
attr_accessor :create_collection_file_on_exit
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.hook_into_test_methods!
|
13
|
+
if @covet_hooked
|
14
|
+
warn "Warning - Covet.register_coverage_collection! called multiple times"
|
15
|
+
return
|
16
|
+
end
|
17
|
+
gem 'minitest'
|
18
|
+
require 'minitest'
|
19
|
+
|
20
|
+
::Minitest.after_run do
|
21
|
+
after_t = Time.now
|
22
|
+
diff_t = after_t - ::Minitest::Runnable.covet_start_time
|
23
|
+
time_taken = sprintf("%.2f", diff_t)
|
24
|
+
Covet.log_collection << ['stats', {
|
25
|
+
:time_taken => time_taken,
|
26
|
+
:files_filtered => CollectionFilter.files_filtered,
|
27
|
+
}]
|
28
|
+
if Covet::TestRunners::Minitest.create_collection_file_on_exit
|
29
|
+
Covet.log_collection.finish!
|
30
|
+
else
|
31
|
+
$stderr.puts "Covet: skipped writing to collection file"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
::Minitest::Runnable.class_eval do
|
36
|
+
@@covet_run_num = 0
|
37
|
+
@@covet_skips = 0
|
38
|
+
@@covet_failures = 0
|
39
|
+
@@covet_start_time = nil
|
40
|
+
|
41
|
+
class << self
|
42
|
+
def covet_start_time
|
43
|
+
@@covet_start_time
|
44
|
+
end
|
45
|
+
|
46
|
+
alias :covet_old_run_one_method :run_one_method
|
47
|
+
|
48
|
+
def run_one_method(klass, method_name, reporter)
|
49
|
+
# first run, collect coverage 'base' coverage information
|
50
|
+
# (coverage information for before any tests get run).
|
51
|
+
if @@covet_run_num == 0
|
52
|
+
@@covet_start_time = Time.now
|
53
|
+
base_coverage = CovetCoverage.peek_result
|
54
|
+
base_coverage = Covet.normalize_coverage_info(base_coverage)
|
55
|
+
if base_coverage.empty?
|
56
|
+
warn "Warning - covet is not properly set up, as it must be required " \
|
57
|
+
"before other libraries to work correctly.\nTry adding\n require 'covet'\n" \
|
58
|
+
"to the top of your test helper file."
|
59
|
+
end
|
60
|
+
Covet::BASE_COVERAGE.update base_coverage
|
61
|
+
# TODO: save Random::DEFAULT.seed in run log file if Covet.options[:test_order] == :random_seeded,
|
62
|
+
# then we can run the methods in the same order as before.
|
63
|
+
Covet.log_collection << ['base', base_coverage, {
|
64
|
+
:version => Covet::VERSION,
|
65
|
+
:options => Covet.options,
|
66
|
+
:seed => Random::DEFAULT.seed,
|
67
|
+
}]
|
68
|
+
end
|
69
|
+
|
70
|
+
@@covet_run_num += 1
|
71
|
+
file = nil
|
72
|
+
begin
|
73
|
+
file = klass.instance_method(method_name).source_location[0]
|
74
|
+
rescue
|
75
|
+
warn "\nWarning - Skipping collecting test coverage for method #{klass}##{method_name}\n"
|
76
|
+
return
|
77
|
+
end
|
78
|
+
|
79
|
+
before = CovetCoverage.peek_result
|
80
|
+
|
81
|
+
# Run test method
|
82
|
+
before_t = Time.now
|
83
|
+
result = covet_old_run_one_method(klass, method_name, reporter)
|
84
|
+
after_t = Time.now
|
85
|
+
|
86
|
+
summary_reporter = result.first
|
87
|
+
skips = summary_reporter.results.select(&:skipped?).size
|
88
|
+
|
89
|
+
# test was skipped, don't record coverage info
|
90
|
+
if @@covet_skips != skips
|
91
|
+
@@covet_skips = skips
|
92
|
+
@@covet_failures += 1
|
93
|
+
return result
|
94
|
+
end
|
95
|
+
|
96
|
+
# test failed, don't record coverage info
|
97
|
+
failures = summary_reporter.results.select(&:failures).size
|
98
|
+
if @@covet_failures != failures
|
99
|
+
@@covet_failures = failures
|
100
|
+
return result
|
101
|
+
end
|
102
|
+
|
103
|
+
after = CovetCoverage.peek_result
|
104
|
+
|
105
|
+
before_orig = Covet.normalize_coverage_info(before)
|
106
|
+
if Covet::BASE_COVERAGE.any?
|
107
|
+
before = Covet.diff_coverages(Covet::BASE_COVERAGE, before_orig)
|
108
|
+
end
|
109
|
+
|
110
|
+
after_orig = Covet.normalize_coverage_info(after)
|
111
|
+
after = Covet.diff_coverages(before_orig, after_orig)
|
112
|
+
if @@covet_run_num > 1
|
113
|
+
if [:random_seeded, :ordered].include?(Covet.options[:test_order])
|
114
|
+
Covet::BASE_COVERAGE.update(after_orig)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
if after == before
|
119
|
+
after = nil
|
120
|
+
end
|
121
|
+
Covet.log_collection << ["#{file}##{method_name}", after, {
|
122
|
+
:time => sprintf("%.2f", after_t - before_t),
|
123
|
+
}]
|
124
|
+
result
|
125
|
+
# NOTE: if the interrupt is fired outside of `Minitest.run_one_method`, then the
|
126
|
+
# collection file gets logged even on interrupt :(
|
127
|
+
rescue Interrupt
|
128
|
+
Covet::TestRunners::Minitest.create_collection_file_on_exit = false
|
129
|
+
raise
|
130
|
+
end
|
131
|
+
|
132
|
+
end
|
133
|
+
end
|
134
|
+
@covet_hooked = true
|
135
|
+
end
|
136
|
+
|
137
|
+
def self.cmdline_for_run_list(run_list)
|
138
|
+
files = run_list.map { |double| double[1].split('#').first }
|
139
|
+
files.uniq!
|
140
|
+
|
141
|
+
files_str = files.map { |fname| %Q("#{fname}") }.join(' ')
|
142
|
+
rake_testtask = Rake::TestTask.new
|
143
|
+
rake_loader_str = rake_testtask.rake_loader
|
144
|
+
rake_include_arg = %Q(-I"#{rake_testtask.rake_lib_dir}")
|
145
|
+
|
146
|
+
cmd = %Q(ruby -I"test" -I"lib" #{rake_include_arg} "#{rake_loader_str}" ) +
|
147
|
+
files_str
|
148
|
+
|
149
|
+
unless Covet.options[:disable_test_method_filter]
|
150
|
+
test_methods = run_list.map { |double| double[1].split('#').last }
|
151
|
+
test_methods.uniq!
|
152
|
+
test_methods_regex = Regexp.union(test_methods)
|
153
|
+
cmd << %Q( "-n #{test_methods_regex.inspect}")
|
154
|
+
end
|
155
|
+
cmd
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module Covet
|
2
|
+
module TestRunners
|
3
|
+
module Rspec
|
4
|
+
@hooked = false
|
5
|
+
@run_num = 0
|
6
|
+
@skips = 0
|
7
|
+
@failures = 0
|
8
|
+
|
9
|
+
def self.hook_into_test_methods!
|
10
|
+
if @hooked
|
11
|
+
warn "Warning - Covet.register_coverage_collection! called multiple times"
|
12
|
+
return
|
13
|
+
end
|
14
|
+
gem 'rspec'
|
15
|
+
require 'rspec'
|
16
|
+
|
17
|
+
::RSpec.configuration.after(:suite) do
|
18
|
+
Covet.log_collection.finish!
|
19
|
+
end
|
20
|
+
|
21
|
+
run_num = @run_num
|
22
|
+
skips = @skips
|
23
|
+
failures = @failures
|
24
|
+
|
25
|
+
::RSpec.configuration.around(:each) do |test|
|
26
|
+
if run_num == 0
|
27
|
+
base_coverage = CovetCoverage.peek_result
|
28
|
+
base_coverage = Covet.normalize_coverage_info(base_coverage)
|
29
|
+
if base_coverage.empty?
|
30
|
+
warn "Warning - covet is not properly set up, as it must be required " \
|
31
|
+
"before other libraries to properly work.\nIf it isn't already, try " \
|
32
|
+
"adding\n require 'covet'\nas the first thing in your test helper file."
|
33
|
+
end
|
34
|
+
Covet::BASE_COVERAGE.update base_coverage
|
35
|
+
# TODO: save Random::DEFAULT.seed in run log file if Covet.options[:test_order] == :random_seeded,
|
36
|
+
# then we can run the methods in the same order as before.
|
37
|
+
Covet.log_collection << ['base', base_coverage]
|
38
|
+
end
|
39
|
+
|
40
|
+
run_num += 1
|
41
|
+
before = CovetCoverage.peek_result
|
42
|
+
test.call
|
43
|
+
rspec_metadata = test.metadata
|
44
|
+
|
45
|
+
# TODO: figure out if failed or skipped, and break out of block if it did
|
46
|
+
#
|
47
|
+
# XXX: is this right for all recent versions of rake?
|
48
|
+
file = rspec_metadata[:file_path].sub(%r{\A\./}, '') # remove leading './'
|
49
|
+
line = rspec_metadata[:line_number]
|
50
|
+
|
51
|
+
after = CovetCoverage.peek_result
|
52
|
+
|
53
|
+
before_orig = Covet.normalize_coverage_info(before)
|
54
|
+
if Covet::BASE_COVERAGE.any?
|
55
|
+
before = Covet.diff_coverages(Covet::BASE_COVERAGE, before_orig)
|
56
|
+
end
|
57
|
+
after_orig = Covet.normalize_coverage_info(after)
|
58
|
+
after = Covet.diff_coverages(before, after_orig)
|
59
|
+
if run_num > 1
|
60
|
+
if [:random_seeded, :ordered].include?(Covet.options[:test_order])
|
61
|
+
Covet::BASE_COVERAGE.update(after_orig)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
if after == before
|
66
|
+
after = nil
|
67
|
+
end
|
68
|
+
Covet.log_collection << ["#{file}:#{line}", after]
|
69
|
+
end
|
70
|
+
@hooked = true
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.cmdline_for_run_list(run_list)
|
74
|
+
require 'rspec/core/rake_task'
|
75
|
+
files = run_list.map { |double| double[1] }
|
76
|
+
files.uniq!
|
77
|
+
file_list = files.join(' ')
|
78
|
+
old_spec_env = ENV['SPEC']
|
79
|
+
begin
|
80
|
+
ENV['SPEC'] = file_list
|
81
|
+
rspec_rake_task = RSpec::Core::RakeTask.new(:spec) do |t|
|
82
|
+
end
|
83
|
+
ensure
|
84
|
+
ENV['SPEC'] = old_spec_env
|
85
|
+
end
|
86
|
+
# XXX: is this right for all recent versions of rake?
|
87
|
+
# TODO: try to figure out how to get test method names
|
88
|
+
rspec_rake_task.send(:spec_command)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Covet
|
2
|
+
module VCS
|
3
|
+
module Git
|
4
|
+
require 'set'
|
5
|
+
require 'rugged'
|
6
|
+
|
7
|
+
# Find lines in git-indexed files that were changed (added/deleted/modified)
|
8
|
+
# in the codebase since `revision`.
|
9
|
+
# @raise Rugged::Error, Rugged::InvalidError, TypeError
|
10
|
+
# @return Set<Array>
|
11
|
+
def self.changes_since(revision = :last_commit)
|
12
|
+
repo = Rugged::Repository.new(find_git_repo_path!) # raises if can't find git repo
|
13
|
+
lines_to_run = Set.new
|
14
|
+
diff = case revision.to_s
|
15
|
+
when 'last_commit', 'HEAD'
|
16
|
+
repo.index.diff
|
17
|
+
else
|
18
|
+
# raises Rugged::Error or TypeError if `revision` is invalid Git object id
|
19
|
+
# (tag name or sha1, etc.)
|
20
|
+
commit = Rugged::Commit.new(repo, revision)
|
21
|
+
repo.index.diff(commit, {}) # NOTE: for some reason, this call doesn't work properly if the second parameter isn't given. Bug in rugged?
|
22
|
+
end
|
23
|
+
diff.each_patch { |patch|
|
24
|
+
file = patch.delta.old_file[:path] # NOTE: old file is the index's version
|
25
|
+
|
26
|
+
patch.each_hunk { |hunk|
|
27
|
+
hunk.each_line { |line|
|
28
|
+
case line.line_origin
|
29
|
+
when :addition
|
30
|
+
lines_to_run << [file, line.new_lineno]
|
31
|
+
when :deletion
|
32
|
+
lines_to_run << [file, line.old_lineno]
|
33
|
+
when :context
|
34
|
+
lines_to_run << [file, line.new_lineno]
|
35
|
+
end
|
36
|
+
}
|
37
|
+
}
|
38
|
+
}
|
39
|
+
lines_to_run
|
40
|
+
end
|
41
|
+
|
42
|
+
# find git repository path at or below `Dir.pwd`
|
43
|
+
def self.find_git_repo_path!
|
44
|
+
dir = orig_dir = Dir.pwd
|
45
|
+
found = Dir.exist?('.git') && dir
|
46
|
+
while !found && dir && Dir.exist?(dir)
|
47
|
+
old_dir = Dir.pwd
|
48
|
+
Dir.chdir('..')
|
49
|
+
dir = Dir.pwd
|
50
|
+
return if old_dir == dir # at root directory
|
51
|
+
if dir && Dir.exist?('.git')
|
52
|
+
found = dir
|
53
|
+
end
|
54
|
+
end
|
55
|
+
found
|
56
|
+
ensure
|
57
|
+
Dir.chdir(orig_dir) if Dir.pwd != orig_dir
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|