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