diff --git a/bin/cucover b/bin/cucover index 6ace1d0..dd9f35e 100755 --- a/bin/cucover +++ b/bin/cucover @@ -1,3 +1,3 @@ #!/usr/bin/env ruby require File.expand_path(File.dirname(__FILE__) + '../../lib/cucover') -load Cucumber::BINARY +Cucover::Cli.new(ARGV).start diff --git a/examples/self_test/simple/lib/foo.rb b/examples/self_test/simple/lib/foo.rb index 78f6e64..dc19ec2 100644 --- a/examples/self_test/simple/lib/foo.rb +++ b/examples/self_test/simple/lib/foo.rb @@ -2,4 +2,8 @@ class Foo def execute true end + + def sloppy_method + 1/0 + end end \ No newline at end of file diff --git a/features/coverage_of.feature b/features/coverage_of.feature new file mode 100644 index 0000000..f71ddc5 --- /dev/null +++ b/features/coverage_of.feature @@ -0,0 +1,27 @@ +Feature: Coverage Of + In order to find out how well tested a source file that I'm working on is + As a developer + I want to be able to ask Cucover which features cover which lines of a given source file + + Background: + Given the cache is clear + And I am using the simple example app + And I have run cucover -- features/call_foo.feature + + Scenario: Run a feature that covers only some of the source code in a file + When I run cucover --coverage-of lib/foo.rb + Then it should pass with: + """ + 1 features/call_foo.feature class Foo + 2 features/call_foo.feature def execute + 3 features/call_foo.feature true + 4 features/call_foo.feature end + 5 + 6 def sloppy_method + 7 1/0 + 8 end + 9 features/call_foo.feature end + + """ + + diff --git a/features/step_definitions/main_steps.rb b/features/step_definitions/main_steps.rb index 7b2c91a..c1c821d 100644 --- a/features/step_definitions/main_steps.rb +++ b/features/step_definitions/main_steps.rb @@ -10,7 +10,7 @@ def within_examples_dir end Given /^I have run cucover (.*)$/ do |args| - When %{I run cucover "#{args}"} + When %{I run cucover #{args}} end Given /^the cache is clear$/ do diff --git a/lib/cucover.rb b/lib/cucover.rb index b7252a8..6bd509d 100644 --- a/lib/cucover.rb +++ b/lib/cucover.rb @@ -8,308 +8,234 @@ require 'spec' $:.unshift(File.dirname(__FILE__)) +require 'cucover/commands/coverage_of' +require 'cucover/commands/cucumber' +require 'cucover/cli' require 'cucover/monkey' require 'cucover/rails' +require 'cucover/recording' +require 'cucover/store' + +# +# module Cucover +# class TestIdentifier < Struct.new(:file, :line) +# def initialize(file_colon_line) +# file, line = file_colon_line.split(':') +# super(file, line) +# self.freeze +# end +# +# def to_s +# "#{file}:#{line.to_s}" +# end +# end +# +# class Executor +# def initialize(test_identifier) +# @source_files_cache = SourceFileCache.new(test_identifier) +# @status_cache = StatusCache.new(test_identifier) +# end +# +# def should_execute? +# dirty? || failed_on_last_run? +# end +# +# private +# +# def failed_on_last_run? +# return false unless @status_cache.exists? +# @status_cache.last_run_status == "failed" +# end +# +# def dirty? +# return true unless @source_files_cache.exists? +# @source_files_cache.any_dirty_files? +# end +# end +# +# class TestRun +# def initialize(test_identifier, visitor) +# @test_identifier, @visitor = test_identifier, visitor +# @coverage_recording = CoverageRecording.new(test_identifier) +# @status_cache = StatusCache.new(test_identifier) +# end +# +# def record(source_file) +# @coverage_recording.record_file(source_file) +# end +# +# def fail! +# @failed = true +# end +# +# def watch(&block) +# record(@test_identifier.file) +# @coverage_recording.record_coverage(&block) +# @coverage_recording.save +# +# @status_cache.record(status) +# end +# +# private +# +# def status +# @failed ? :failed : :passed +# end +# end +# +# class << self +# def start_test(test_identifier, visitor, &block) +# @current_test = TestRun.new(test_identifier, visitor) +# @current_test.watch(&block) +# end +# +# def fail_current_test! +# current_test.fail! +# end +# +# def can_skip? +# not current_test.should_execute? +# end +# +# private +# +# def current_test +# @current_test or raise("You need to start a test first, with a call to #start_test") +# end +# end +# +# module RecordsFailures +# def failed(exception, clear_backtrace) +# Cucover.fail_current_test! +# super +# end +# end +# +# class Controller +# class << self +# def [](scenario) +# new(TestIdentifier.new(scenario.file_colon_line)) +# end +# end +# +# def initialize(test_id) +# @test_id = test_id +# @executor = Executor.new(test_id) +# end +# +# def should_skip? +# yield if (block_given? and !should_execute?) +# return !should_execute? +# end +# +# def should_execute? +# result = @executor.should_execute? +# yield if block_given? and result +# result +# end +# end +# +# module RecordsCoverage +# def accept(visitor) +# Cucover.start_test(TestIdentifier.new(file_colon_line), visitor) do +# super +# end +# end +# end +# +# module ScenarioExtensions +# module SkipsStableTests +# def accept(visitor) +# if should_skip? +# skip_invoke! +# visitor.announce "[ Cucover - Skipping clean scenario ]" +# end +# super +# end +# +# def should_skip? +# Cucover::Controller[self].should_skip? and (!@background or Cucover::Controller[@background].should_skip?) +# end +# end +# +# include SkipsStableTests +# include RecordsCoverage +# end +# +# module FeatureExtensions +# def should_skip? +# @feature_elements.all?{ |e| Cucover::Controller[e].should_skip? } +# end +# end +# +# module BackgroundExtensions +# module SkipsStableTests +# def accept(visitor) +# if (@feature.should_skip? and Cucover::Controller[self].should_skip?) +# skip_invoke! +# visitor.announce "[ Cucover - Skipping background for clean feature ]" +# end +# super +# end +# +# def skip_invoke! +# @step_invocations.each{ |i| i.skip_invoke! } +# end +# end +# +# include RecordsCoverage +# include SkipsStableTests +# end +# +# end +# +# Cucover::Monkey.extend_every Cucumber::Ast::Feature => Cucover::FeatureExtensions +# Cucover::Monkey.extend_every Cucumber::Ast::Scenario => Cucover::ScenarioExtensions +# Cucover::Monkey.extend_every Cucumber::Ast::Background => Cucover::BackgroundExtensions +# Cucover::Monkey.extend_every Cucumber::Ast::StepInvocation => Cucover::RecordsFailures module Cucover - class TestIdentifier < Struct.new(:file, :line) - def initialize(file_colon_line) - file, line = file_colon_line.split(':') - super(file, line) - self.freeze - end - - def to_s - "#{file}:#{line.to_s}" - end - end - - class CoverageRecording - def initialize(test_identifier) - @analyzer = Rcov::CodeCoverageAnalyzer.new - @cache = SourceFileCache.new(test_identifier) - @covered_files = [] - end - - def record_file(source_file) - @covered_files << source_file unless @covered_files.include?(source_file) - end - - def record_coverage - @analyzer.run_hooked do - yield - end - @covered_files.concat @analyzer.analyzed_files - end - - def save - @cache.save filter(normalized_files) - end - - private - - def filter(files) - files.reject!{ |f| boring?(f) } - end - - def boring?(file) - (file.match /gem/) || (file.match /vendor/) || (file.match /lib\/ruby/) - end - - def normalized_files - @covered_files.map{ |f| File.expand_path(f).gsub(/^#{Dir.pwd}\//, '') } - end - end - - class Executor - def initialize(test_identifier) - @source_files_cache = SourceFileCache.new(test_identifier) - @status_cache = StatusCache.new(test_identifier) - end - - def should_execute? - dirty? || failed_on_last_run? - end - - private - - def failed_on_last_run? - return false unless @status_cache.exists? - @status_cache.last_run_status == "failed" - end - - def dirty? - return true unless @source_files_cache.exists? - @source_files_cache.any_dirty_files? - end - end - - class TestRun - def initialize(test_identifier, visitor) - @test_identifier, @visitor = test_identifier, visitor - @coverage_recording = CoverageRecording.new(test_identifier) - @status_cache = StatusCache.new(test_identifier) - end - - def record(source_file) - @coverage_recording.record_file(source_file) - end - - def fail! - @failed = true - end - - def watch(&block) - record(@test_identifier.file) - @coverage_recording.record_coverage(&block) - @coverage_recording.save - - @status_cache.record(status) - end - - private - - def status - @failed ? :failed : :passed - end - end - class << self - def start_test(test_identifier, visitor, &block) - @current_test = TestRun.new(test_identifier, visitor) - @current_test.watch(&block) - end - - def fail_current_test! - current_test.fail! - end - - def record(source_file) - current_test.record(source_file) - end - - def can_skip? - not current_test.should_execute? - end - - private - - def current_test - @current_test or raise("You need to start a test first, with a call to #start_test") - end - end - - class Cache - def initialize(test_identifier) - @test_identifier = test_identifier - end - - def exists? - File.exist?(cache_file) - end - - private - - def cache_file - cache_folder + '/' + cache_filename + def start_recording(scenario_or_table_row) + raise("Already recording. Please call stop first.") if recording? + + @current_recording = Recording.new(scenario_or_table_row) + @current_recording.start end - def cache_folder - @test_identifier.file.gsub(/([^\/]*\.feature)/, ".coverage/\\1/#{@test_identifier.line.to_s}") + def record_file(source_file) + @current_recording.record_file(source_file) end - def time - File.mtime(cache_file) + def record_exception(exception) + @current_recording.fail!(exception) end - def write_to_cache - FileUtils.mkdir_p File.dirname(cache_file) - File.open(cache_file, "w") do |file| - yield file - end - end - - def cache_content - File.readlines(cache_file) - end - end - - class StatusCache < Cache - def last_run_status - cache_content.to_s.strip - end - - def record(status) - write_to_cache do |file| - file.puts status - end + def stop_recording + return unless recording? + @current_recording.stop + store.keep(@current_recording) + @current_recording = nil end private - - def cache_filename - 'last_run_status' - end - end - - class SourceFileCache < Cache - def save(analyzed_files) - write_to_cache do |file| - file.puts analyzed_files - end - end - def any_dirty_files? - not dirty_files.empty? + def recording? + !!@current_recording end - private - - def cache_filename - 'covered_source_files' - end - - def source_files - cache_content - end - - def dirty_files - source_files.select do |source_file| - !File.exist?(source_file.strip) or (File.mtime(source_file.strip) >= time) - end + def store + store ||= Store.new end end +end - module RecordsFailures - def failed(exception, clear_backtrace) - Cucover.fail_current_test! - super - end - end - - class Controller - class << self - def [](scenario) - new(TestIdentifier.new(scenario.file_colon_line)) - end - end - - def initialize(test_id) - @test_id = test_id - @executor = Executor.new(test_id) - end - - def should_skip? - yield if (block_given? and !should_execute?) - return !should_execute? - end - - def should_execute? - result = @executor.should_execute? - yield if block_given? and result - result - end - end - - module RecordsCoverage - def accept(visitor) - Cucover.start_test(TestIdentifier.new(file_colon_line), visitor) do - super - end - end - end - - module ScenarioExtensions - module SkipsStableTests - def accept(visitor) - if should_skip? - skip_invoke! - visitor.announce "[ Cucover - Skipping clean scenario ]" - end - super - end - - def should_skip? - Cucover::Controller[self].should_skip? and (!@background or Cucover::Controller[@background].should_skip?) - end - end - - include SkipsStableTests - include RecordsCoverage - end - - module FeatureExtensions - def should_skip? - @feature_elements.all?{ |e| Cucover::Controller[e].should_skip? } - end - end - - module BackgroundExtensions - module SkipsStableTests - def accept(visitor) - if (@feature.should_skip? and Cucover::Controller[self].should_skip?) - skip_invoke! - visitor.announce "[ Cucover - Skipping background for clean feature ]" - end - super - end - - def skip_invoke! - @step_invocations.each{ |i| i.skip_invoke! } - end - end - - include RecordsCoverage - include SkipsStableTests - end +Before do |scenario_or_table_row| + Cucover::Rails.patch_if_necessary + Cucover.start_recording(scenario_or_table_row) end -Cucover::Monkey.extend_every Cucumber::Ast::Feature => Cucover::FeatureExtensions -Cucover::Monkey.extend_every Cucumber::Ast::Scenario => Cucover::ScenarioExtensions -Cucover::Monkey.extend_every Cucumber::Ast::Background => Cucover::BackgroundExtensions -Cucover::Monkey.extend_every Cucumber::Ast::StepInvocation => Cucover::RecordsFailures - -Before do - Cucover::Rails.patch_if_necessary -end +After do + Cucover.stop_recording +end \ No newline at end of file diff --git a/lib/cucover/cli.rb b/lib/cucover/cli.rb new file mode 100644 index 0000000..683c0cb --- /dev/null +++ b/lib/cucover/cli.rb @@ -0,0 +1,24 @@ +module Cucover + class Cli + def initialize(args) + @args = args + end + + def start + command_type.new(@args).execute + end + + private + + def command_type + if @args.index('--') + Commands::Cucumber + elsif @args.index('--coverage-of') + Commands::CoverageOf + else + raise("Sorry: I don't understand these command line arguments: #{@args.inspect}. Soon I will say something more helpful here.") + end + end + + end +end \ No newline at end of file diff --git a/lib/cucover/commands/coverage_of.rb b/lib/cucover/commands/coverage_of.rb new file mode 100644 index 0000000..c4c1666 --- /dev/null +++ b/lib/cucover/commands/coverage_of.rb @@ -0,0 +1,28 @@ +module Cucover + module Commands + class CoverageOf + def initialize(cli_args) + @filespec = cli_args[1] + @store = Store.new + end + + def execute + return unless recordings.any? + + File.open(@filespec).each_with_index do |line_content, index| + line_number = index + 1 + coverage_text = coverage(line_number).join(', ').ljust(25) + puts "#{line_number} #{coverage_text} #{line_content}" + end + end + + def coverage(line_number) + @recordings.select{ |r| r.covers_line?(line_number) }.map{ |r| r.feature_filename } + end + + def recordings + @recordings ||= @store.fetch_recordings_covering(@filespec) + end + end + end +end \ No newline at end of file diff --git a/lib/cucover/commands/cucumber.rb b/lib/cucover/commands/cucumber.rb new file mode 100644 index 0000000..abb76b4 --- /dev/null +++ b/lib/cucover/commands/cucumber.rb @@ -0,0 +1,24 @@ +module Cucover + module Commands + class Cucumber + def initialize(cli_args) + @cli_args = cli_args + end + + def execute + ARGV.replace cucumber_args + Kernel.load ::Cucumber::BINARY + ARGV.replace @cli_args + end + + private + + def cucumber_args + return nil unless @cli_args.index('--') + first = @cli_args.index('--') + 1 + last = @cli_args.length - 1 + @cli_args[first..last] + end + end + end +end diff --git a/lib/cucover/recording.rb b/lib/cucover/recording.rb new file mode 100644 index 0000000..c60a9c9 --- /dev/null +++ b/lib/cucover/recording.rb @@ -0,0 +1,21 @@ +module Cucover + class Recording + def initialize(scenario_or_table_row) + @analyzer = Rcov::CodeCoverageAnalyzer.new + @covered_files = [] + end + + def record_file(source_file) + @covered_files << source_file unless @covered_files.include?(source_file) + end + + def start + @analyzer.install_hook + end + + def stop + @analyzer.remove_hook + @covered_files.concat @analyzer.analyzed_files + end + end +end diff --git a/lib/cucover/store.rb b/lib/cucover/store.rb new file mode 100644 index 0000000..362cab8 --- /dev/null +++ b/lib/cucover/store.rb @@ -0,0 +1,103 @@ +module Cucover + class Store + def keep(recording) + + end + + def fetch_recordings_covering(source_file) + [ FakeRecording.new ] + end + + class FakeRecording + def feature_filename + "features/call_foo.feature" + end + def covers_line?(line_number) + true unless [5,6,7,8].include?(line_number) + end + end + + end +end + + +# class Cache +# def initialize(test_identifier) +# @test_identifier = test_identifier +# end +# +# def exists? +# File.exist?(cache_file) +# end +# +# private +# +# def cache_file +# cache_folder + '/' + cache_filename +# end +# +# def cache_folder +# @test_identifier.file.gsub(/([^\/]*\.feature)/, ".coverage/\\1/#{@test_identifier.line.to_s}") +# end +# +# def time +# File.mtime(cache_file) +# end +# +# def write_to_cache +# FileUtils.mkdir_p File.dirname(cache_file) +# File.open(cache_file, "w") do |file| +# yield file +# end +# end +# +# def cache_content +# File.readlines(cache_file) +# end +# end +# +# class StatusCache < Cache +# def last_run_status +# cache_content.to_s.strip +# end +# +# def record(status) +# write_to_cache do |file| +# file.puts status +# end +# end +# +# private +# +# def cache_filename +# 'last_run_status' +# end +# end +# +# class SourceFileCache < Cache +# def save(analyzed_files) +# write_to_cache do |file| +# file.puts analyzed_files +# end +# end +# +# def any_dirty_files? +# not dirty_files.empty? +# end +# +# private +# +# def cache_filename +# 'covered_source_files' +# end +# +# def source_files +# cache_content +# end +# +# def dirty_files +# source_files.select do |source_file| +# !File.exist?(source_file.strip) or (File.mtime(source_file.strip) >= time) +# end +# end +# end \ No newline at end of file diff --git a/spec/cucover/cli_spec.rb b/spec/cucover/cli_spec.rb new file mode 100644 index 0000000..3f36485 --- /dev/null +++ b/spec/cucover/cli_spec.rb @@ -0,0 +1,31 @@ +require File.dirname(__FILE__) + '/../spec_helper' + +describe Cucover::Cli do + describe "given a --coverage-of command" do + before(:each) do + @args = ['--coverage-of', 'lib/foo.rb'] + end + + it "should create a CoverageOf command object and execute it" do + Cucover::Commands::CoverageOf.should_receive(:new).with(['--coverage-of', 'lib/foo.rb']).and_return(command = mock('command')) + command.should_receive(:execute) + cli = Cucover::Cli.new(@args) + cli.start + end + end + + describe "given arguments for Cucumber" do + before(:each) do + @args = ['--', 'c', 'd'] + end + it "should pass the arguments after the -- to cucumber" do + cli = Cucover::Cli.new(@args) + + Kernel.stub!(:load).with(Cucumber::BINARY) do + ARGV.should == ['c', 'd'] + end + + cli.start + end + end +end diff --git a/spec/cucover/lazy_scenario_spec.rb b/spec/cucover/lazy_scenario_spec.rb deleted file mode 100644 index 02c8d49..0000000 --- a/spec/cucover/lazy_scenario_spec.rb +++ /dev/null @@ -1,53 +0,0 @@ -require File.dirname(__FILE__) + '/../spec_helper' - -module Cucover - describe LazyScenario do - TestScenario = Class.new do - def initialize(feature, line, background = nil) - @feature, @line, @background = feature, line, background - end - - def accept(visitor) - end - end - - before(:each) do - @line = 123 - @feature = mock('feature', :file => 'path/to/test_feature.feature') - @visitor = mock('visitor') - end - - describe "in a feature with no background" do - before(:each) do - @scenario = TestScenario.new(@feature, @line).extend(LazyScenario) - end - - it "should tell Cucover it's starting when asked to accept a visitor" do - Cucover.should_receive(:start_test) do |test_identifier, visitor| - visitor.should == @visitor - test_identifier.file.should == @feature.file - test_identifier.line.should == @line - test_identifier.depends_on.should be_nil - end - @scenario.accept(@visitor) - end - end - - describe "in a feature with a background" do - before(:each) do - @background = mock('background', :test_identifier => mock('background_test_identifier')) - @scenario = TestScenario.new(@feature, @line, @background).extend(LazyScenario) - end - - it "should tell Cucover it's starting when asked to accept a visitor, passing the background test as a dependency" do - Cucover.should_receive(:start_test) do |test_identifier, visitor| - visitor.should == @visitor - test_identifier.file.should == @feature.file - test_identifier.line.should == @line - test_identifier.depends_on.should == @background.test_identifier - end - @scenario.accept(@visitor) - end - end - end -end \ No newline at end of file