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