diff options
author | eregon <eregon@b2dd03c8-39d4-4d8f-98ff-823fe69b080e> | 2017-05-07 12:04:49 +0000 |
---|---|---|
committer | eregon <eregon@b2dd03c8-39d4-4d8f-98ff-823fe69b080e> | 2017-05-07 12:04:49 +0000 |
commit | 95e8c48dd3348503a8c7db5d0498894a1b676395 (patch) | |
tree | 9eef7f720314ebaff56845a74e203770e62284e4 /spec/mspec/lib/mspec/runner | |
parent | ed7d803500de38186c74bce94d233e85ef51e503 (diff) |
Add in-tree mspec and ruby/spec
* For easier modifications of ruby/spec by MRI developers.
* .gitignore: track changes under spec.
* spec/mspec, spec/rubyspec: add in-tree mspec and ruby/spec.
These files can therefore be updated like any other file in MRI.
Instructions are provided in spec/README.
[Feature #13156] [ruby-core:79246]
git-svn-id: svn+ssh://ci.ruby-lang.org/ruby/trunk@58595 b2dd03c8-39d4-4d8f-98ff-823fe69b080e
Diffstat (limited to 'spec/mspec/lib/mspec/runner')
35 files changed, 2471 insertions, 0 deletions
diff --git a/spec/mspec/lib/mspec/runner/actions.rb b/spec/mspec/lib/mspec/runner/actions.rb new file mode 100644 index 0000000000..0a5a05fbd1 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions.rb @@ -0,0 +1,6 @@ +require 'mspec/runner/actions/tally' +require 'mspec/runner/actions/timer' +require 'mspec/runner/actions/filter' +require 'mspec/runner/actions/tag' +require 'mspec/runner/actions/taglist' +require 'mspec/runner/actions/tagpurge' diff --git a/spec/mspec/lib/mspec/runner/actions/filter.rb b/spec/mspec/lib/mspec/runner/actions/filter.rb new file mode 100644 index 0000000000..35899c8dc8 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/filter.rb @@ -0,0 +1,40 @@ +require 'mspec/runner/filters/match' + +# ActionFilter is a base class for actions that are triggered by +# specs that match the filter. The filter may be specified by +# strings that match spec descriptions or by tags for strings +# that match spec descriptions. +# +# Unlike TagFilter and RegexpFilter, ActionFilter instances do +# not affect the specs that are run. The filter is only used to +# trigger the action. + +class ActionFilter + def initialize(tags=nil, descs=nil) + @tags = Array(tags) + descs = Array(descs) + @sfilter = descs.empty? ? nil : MatchFilter.new(nil, *descs) + @tfilter = nil + end + + def ===(string) + @sfilter === string or @tfilter === string + end + + def load + return if @tags.empty? + + desc = MSpec.read_tags(@tags).map { |t| t.description } + return if desc.empty? + + @tfilter = MatchFilter.new(nil, *desc) + end + + def register + MSpec.register :load, self + end + + def unregister + MSpec.unregister :load, self + end +end diff --git a/spec/mspec/lib/mspec/runner/actions/leakchecker.rb b/spec/mspec/lib/mspec/runner/actions/leakchecker.rb new file mode 100644 index 0000000000..e947cda9ff --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/leakchecker.rb @@ -0,0 +1,301 @@ +# Adapted from ruby's test/lib/leakchecker.rb. +# Ruby's 2-clause BSDL follows. + +# Copyright (C) 1993-2013 Yukihiro Matsumoto. All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +class LeakChecker + def initialize + @fd_info = find_fds + @tempfile_info = find_tempfiles + @thread_info = find_threads + @env_info = find_env + @argv_info = find_argv + @encoding_info = find_encodings + end + + def check(test_name) + @no_leaks = true + leaks = [ + check_fd_leak(test_name), + check_tempfile_leak(test_name), + check_thread_leak(test_name), + check_process_leak(test_name), + check_env(test_name), + check_argv(test_name), + check_encodings(test_name) + ] + GC.start if leaks.any? + return leaks.none? + end + + private + def find_fds + fd_dir = "/proc/self/fd" + if File.directory?(fd_dir) + fds = Dir.open(fd_dir) {|d| + a = d.grep(/\A\d+\z/, &:to_i) + if d.respond_to? :fileno + a -= [d.fileno] + end + a + } + fds.sort + else + [] + end + end + + def check_fd_leak(test_name) + leaked = false + live1 = @fd_info + if IO.respond_to?(:console) and (m = IO.method(:console)).arity.nonzero? + m[:close] + end + live2 = find_fds + fd_closed = live1 - live2 + if !fd_closed.empty? + fd_closed.each {|fd| + puts "Closed file descriptor: #{test_name}: #{fd}" + } + end + fd_leaked = live2 - live1 + if !fd_leaked.empty? + leaked = true + h = {} + ObjectSpace.each_object(IO) {|io| + inspect = io.inspect + begin + autoclose = io.autoclose? + fd = io.fileno + rescue IOError # closed IO object + next + end + (h[fd] ||= []) << [io, autoclose, inspect] + } + fd_leaked.each {|fd| + str = '' + if h[fd] + str << ' :' + h[fd].map {|io, autoclose, inspect| + s = ' ' + inspect + s << "(not-autoclose)" if !autoclose + s + }.sort.each {|s| + str << s + } + end + puts "Leaked file descriptor: #{test_name}: #{fd}#{str}" + } + #system("lsof -p #$$") if !fd_leaked.empty? + h.each {|fd, list| + next if list.length <= 1 + if 1 < list.count {|io, autoclose, inspect| autoclose } + str = list.map {|io, autoclose, inspect| " #{inspect}" + (autoclose ? "(autoclose)" : "") }.sort.join + puts "Multiple autoclose IO object for a file descriptor:#{str}" + end + } + end + @fd_info = live2 + return leaked + end + + def extend_tempfile_counter + return if defined? LeakChecker::TempfileCounter + m = Module.new { + @count = 0 + class << self + attr_accessor :count + end + + def new(data) + LeakChecker::TempfileCounter.count += 1 + super(data) + end + } + LeakChecker.const_set(:TempfileCounter, m) + + class << Tempfile::Remover + prepend LeakChecker::TempfileCounter + end + end + + def find_tempfiles(prev_count=-1) + return [prev_count, []] unless defined? Tempfile + extend_tempfile_counter + count = TempfileCounter.count + if prev_count == count + [prev_count, []] + else + tempfiles = ObjectSpace.each_object(Tempfile).find_all {|t| t.path } + [count, tempfiles] + end + end + + def check_tempfile_leak(test_name) + return false unless defined? Tempfile + count1, initial_tempfiles = @tempfile_info + count2, current_tempfiles = find_tempfiles(count1) + leaked = false + tempfiles_leaked = current_tempfiles - initial_tempfiles + if !tempfiles_leaked.empty? + leaked = true + list = tempfiles_leaked.map {|t| t.inspect }.sort + list.each {|str| + puts "Leaked tempfile: #{test_name}: #{str}" + } + tempfiles_leaked.each {|t| t.close! } + end + @tempfile_info = [count2, initial_tempfiles] + return leaked + end + + def find_threads + Thread.list.find_all {|t| + t != Thread.current && t.alive? + } + end + + def check_thread_leak(test_name) + live1 = @thread_info + live2 = find_threads + thread_finished = live1 - live2 + leaked = false + if !thread_finished.empty? + list = thread_finished.map {|t| t.inspect }.sort + list.each {|str| + puts "Finished thread: #{test_name}: #{str}" + } + end + thread_leaked = live2 - live1 + if !thread_leaked.empty? + leaked = true + list = thread_leaked.map {|t| t.inspect }.sort + list.each {|str| + puts "Leaked thread: #{test_name}: #{str}" + } + end + @thread_info = live2 + return leaked + end + + def check_process_leak(test_name) + subprocesses_leaked = Process.waitall + subprocesses_leaked.each { |pid, status| + puts "Leaked subprocess: #{pid}: #{status}" + } + return !subprocesses_leaked.empty? + end + + def find_env + ENV.to_h + end + + def check_env(test_name) + old_env = @env_info + new_env = find_env + return false if old_env == new_env + (old_env.keys | new_env.keys).sort.each {|k| + if old_env.has_key?(k) + if new_env.has_key?(k) + if old_env[k] != new_env[k] + puts "Environment variable changed: #{test_name} : #{k.inspect} changed : #{old_env[k].inspect} -> #{new_env[k].inspect}" + end + else + puts "Environment variable changed: #{test_name} : #{k.inspect} deleted" + end + else + if new_env.has_key?(k) + puts "Environment variable changed: #{test_name} : #{k.inspect} added" + else + flunk "unreachable" + end + end + } + @env_info = new_env + return true + end + + def find_argv + ARGV.map { |e| e.dup } + end + + def check_argv(test_name) + old_argv = @argv_info + new_argv = find_argv + leaked = false + if new_argv != old_argv + puts "ARGV changed: #{test_name} : #{old_argv.inspect} to #{new_argv.inspect}" + @argv_info = new_argv + leaked = true + end + return leaked + end + + def find_encodings + [Encoding.default_internal, Encoding.default_external] + end + + def check_encodings(test_name) + old_internal, old_external = @encoding_info + new_internal, new_external = find_encodings + leaked = false + if new_internal != old_internal + leaked = true + puts "Encoding.default_internal changed: #{test_name} : #{old_internal} to #{new_internal}" + end + if new_external != old_external + leaked = true + puts "Encoding.default_external changed: #{test_name} : #{old_external} to #{new_external}" + end + @encoding_info = [new_internal, new_external] + return leaked + end + + def puts(*args) + if @no_leaks + @no_leaks = false + print "\n" + end + super(*args) + end +end + +class LeakCheckerAction + def register + MSpec.register :start, self + MSpec.register :after, self + end + + def start + @checker = LeakChecker.new + end + + def after(state) + unless @checker.check(state.description) + if state.example + puts state.example.source_location.join(':') + end + end + end +end diff --git a/spec/mspec/lib/mspec/runner/actions/tag.rb b/spec/mspec/lib/mspec/runner/actions/tag.rb new file mode 100644 index 0000000000..760152b2a3 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/tag.rb @@ -0,0 +1,133 @@ +require 'mspec/runner/actions/filter' + +# TagAction - Write tagged spec description string to a +# tag file associated with each spec file. +# +# The action is triggered by specs whose descriptions +# match the filter created with 'tags' and/or 'desc' +# +# The action fires in the :after event, after the spec +# had been run. The action fires if the outcome of +# running the spec matches 'outcome'. +# +# The arguments are: +# +# action: :add, :del +# outcome: :pass, :fail, :all +# tag: the tag to create/delete +# comment: the comment to create +# tags: zero or more tags to get matching +# spec description strings from +# desc: zero or more strings to match the +# spec description strings + +class TagAction < ActionFilter + def initialize(action, outcome, tag, comment, tags=nil, descs=nil) + super tags, descs + @action = action + @outcome = outcome + @tag = tag + @comment = comment + @report = [] + @exception = false + end + + # Returns true if there are no _tag_ or _description_ filters. This + # means that a TagAction matches any example by default. Otherwise, + # returns true if either the _tag_ or the _description_ filter + # matches +string+. + def ===(string) + return true unless @sfilter or @tfilter + @sfilter === string or @tfilter === string + end + + # Callback for the MSpec :before event. Resets the +#exception?+ + # flag to false. + def before(state) + @exception = false + end + + # Callback for the MSpec :exception event. Sets the +#exception?+ + # flag to true. + def exception(exception) + @exception = true + end + + # Callback for the MSpec :after event. Performs the tag action + # depending on the type of action and the outcome of evaluating + # the example. See +TagAction+ for a description of the actions. + def after(state) + if self === state.description and outcome? + tag = SpecTag.new + tag.tag = @tag + tag.comment = @comment + tag.description = state.description + + case @action + when :add + changed = MSpec.write_tag tag + when :del + changed = MSpec.delete_tag tag + end + + @report << state.description if changed + end + end + + # Returns true if the result of evaluating the example matches + # the _outcome_ registered for this tag action. See +TagAction+ + # for a description of the _outcome_ types. + def outcome? + @outcome == :all or + (@outcome == :pass and not exception?) or + (@outcome == :fail and exception?) + end + + # Returns true if an exception was raised while evaluating the + # current example. + def exception? + @exception + end + + def report + @report.join("\n") + "\n" + end + private :report + + # Callback for the MSpec :finish event. Prints the actions + # performed while evaluating the examples. + def finish + case @action + when :add + if @report.empty? + print "\nTagAction: no specs were tagged with '#{@tag}'\n" + else + print "\nTagAction: specs tagged with '#{@tag}':\n\n" + print report + end + when :del + if @report.empty? + print "\nTagAction: no tags '#{@tag}' were deleted\n" + else + print "\nTagAction: tag '#{@tag}' deleted for specs:\n\n" + print report + end + end + end + + def register + super + MSpec.register :before, self + MSpec.register :exception, self + MSpec.register :after, self + MSpec.register :finish, self + end + + def unregister + super + MSpec.unregister :before, self + MSpec.unregister :exception, self + MSpec.unregister :after, self + MSpec.unregister :finish, self + end +end diff --git a/spec/mspec/lib/mspec/runner/actions/taglist.rb b/spec/mspec/lib/mspec/runner/actions/taglist.rb new file mode 100644 index 0000000000..c1aba53794 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/taglist.rb @@ -0,0 +1,56 @@ +require 'mspec/runner/actions/filter' + +# TagListAction - prints out the descriptions for any specs +# tagged with +tags+. If +tags+ is an empty list, prints out +# descriptions for any specs that are tagged. +class TagListAction + def initialize(tags=nil) + @tags = tags.nil? || tags.empty? ? nil : Array(tags) + @filter = nil + end + + # Returns true. This enables us to match any tag when loading + # tags from the file. + def include?(arg) + true + end + + # Returns true if any tagged descriptions matches +string+. + def ===(string) + @filter === string + end + + # Prints a banner about matching tagged specs. + def start + if @tags + print "\nListing specs tagged with #{@tags.map { |t| "'#{t}'" }.join(", ") }\n\n" + else + print "\nListing all tagged specs\n\n" + end + end + + # Creates a MatchFilter for specific tags or for all tags. + def load + @filter = nil + desc = MSpec.read_tags(@tags || self).map { |t| t.description } + @filter = MatchFilter.new(nil, *desc) unless desc.empty? + end + + # Prints the spec description if it matches the filter. + def after(state) + return unless self === state.description + print state.description, "\n" + end + + def register + MSpec.register :start, self + MSpec.register :load, self + MSpec.register :after, self + end + + def unregister + MSpec.unregister :start, self + MSpec.unregister :load, self + MSpec.unregister :after, self + end +end diff --git a/spec/mspec/lib/mspec/runner/actions/tagpurge.rb b/spec/mspec/lib/mspec/runner/actions/tagpurge.rb new file mode 100644 index 0000000000..f4587de6bc --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/tagpurge.rb @@ -0,0 +1,56 @@ +require 'mspec/runner/actions/filter' +require 'mspec/runner/actions/taglist' + +# TagPurgeAction - removes all tags not matching any spec +# descriptions. +class TagPurgeAction < TagListAction + attr_reader :matching + + def initialize + @matching = [] + @filter = nil + @tags = nil + end + + # Prints a banner about purging tags. + def start + print "\nRemoving tags not matching any specs\n\n" + end + + # Creates a MatchFilter for all tags. + def load + @filter = nil + @tags = MSpec.read_tags self + desc = @tags.map { |t| t.description } + @filter = MatchFilter.new(nil, *desc) unless desc.empty? + end + + # Saves any matching tags + def after(state) + @matching << state.description if self === state.description + end + + # Rewrites any matching tags. Prints non-matching tags. + # Deletes the tag file if there were no tags (this cleans + # up empty or malformed tag files). + def unload + if @filter + matched = @tags.select { |t| @matching.any? { |s| s == t.description } } + MSpec.write_tags matched + + (@tags - matched).each { |t| print t.description, "\n" } + else + MSpec.delete_tags + end + end + + def register + super + MSpec.register :unload, self + end + + def unregister + super + MSpec.unregister :unload, self + end +end diff --git a/spec/mspec/lib/mspec/runner/actions/tally.rb b/spec/mspec/lib/mspec/runner/actions/tally.rb new file mode 100644 index 0000000000..33f937293c --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/tally.rb @@ -0,0 +1,133 @@ +class Tally + attr_accessor :files, :examples, :expectations, :failures, :errors, :guards, :tagged + + def initialize + @files = @examples = @expectations = @failures = @errors = @guards = @tagged = 0 + end + + def files!(add=1) + @files += add + end + + def examples!(add=1) + @examples += add + end + + def expectations!(add=1) + @expectations += add + end + + def failures!(add=1) + @failures += add + end + + def errors!(add=1) + @errors += add + end + + def guards!(add=1) + @guards += add + end + + def tagged!(add=1) + @tagged += add + end + + def file + pluralize files, "file" + end + + def example + pluralize examples, "example" + end + + def expectation + pluralize expectations, "expectation" + end + + def failure + pluralize failures, "failure" + end + + def error + pluralize errors, "error" + end + + def guard + pluralize guards, "guard" + end + + def tag + "#{tagged} tagged" + end + + def format + results = [ file, example, expectation, failure, error, tag ] + if [:report, :report_on, :verify].any? { |m| MSpec.mode? m } + results << guard + end + results.join(", ") + end + + alias_method :to_s, :format + + def pluralize(count, singular) + "#{count} #{singular}#{'s' unless count == 1}" + end + private :pluralize +end + +class TallyAction + attr_reader :counter + + def initialize + @counter = Tally.new + end + + def register + MSpec.register :load, self + MSpec.register :exception, self + MSpec.register :example, self + MSpec.register :tagged, self + MSpec.register :expectation, self + end + + def unregister + MSpec.unregister :load, self + MSpec.unregister :exception, self + MSpec.unregister :example, self + MSpec.unregister :tagged, self + MSpec.unregister :expectation, self + end + + def load + @counter.files! + end + + # Callback for the MSpec :expectation event. Increments the + # tally of expectations (e.g. #should, #should_receive, etc.). + def expectation(state) + @counter.expectations! + end + + # Callback for the MSpec :exception event. Increments the + # tally of errors and failures. + def exception(exception) + exception.failure? ? @counter.failures! : @counter.errors! + end + + # Callback for the MSpec :example event. Increments the tally + # of examples. + def example(state, block) + @counter.examples! + end + + def tagged(state) + @counter.examples! + @counter.tagged! + end + + def format + @counter.format + end +end diff --git a/spec/mspec/lib/mspec/runner/actions/timer.rb b/spec/mspec/lib/mspec/runner/actions/timer.rb new file mode 100644 index 0000000000..e7ebfebe0d --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/timer.rb @@ -0,0 +1,22 @@ +class TimerAction + def register + MSpec.register :start, self + MSpec.register :finish, self + end + + def start + @start = Time.now + end + + def finish + @stop = Time.now + end + + def elapsed + @stop - @start + end + + def format + "Finished in %f seconds" % elapsed + end +end diff --git a/spec/mspec/lib/mspec/runner/context.rb b/spec/mspec/lib/mspec/runner/context.rb new file mode 100644 index 0000000000..2b470f226a --- /dev/null +++ b/spec/mspec/lib/mspec/runner/context.rb @@ -0,0 +1,239 @@ +# Holds the state of the +describe+ block that is being +# evaluated. Every example (i.e. +it+ block) is evaluated +# in a context, which may include state set up in <tt>before +# :each</tt> or <tt>before :all</tt> blocks. +# +#-- +# A note on naming: this is named _ContextState_ rather +# than _DescribeState_ because +describe+ is the keyword +# in the DSL for refering to the context in which an example +# is evaluated, just as +it+ refers to the example itself. +#++ +class ContextState + attr_reader :state, :parent, :parents, :children, :examples, :to_s + + def initialize(mod, options=nil) + @to_s = mod.to_s + if options.is_a? Hash + @options = options + else + @to_s += "#{".:#".include?(options[0,1]) ? "" : " "}#{options}" if options + @options = { } + end + @options[:shared] ||= false + + @parsed = false + @before = { :all => [], :each => [] } + @after = { :all => [], :each => [] } + @pre = {} + @post = {} + @examples = [] + @parent = nil + @parents = [self] + @children = [] + + @mock_verify = Proc.new { Mock.verify_count } + @mock_cleanup = Proc.new { Mock.cleanup } + @expectation_missing = Proc.new { raise SpecExpectationNotFoundError } + end + + # Remove caching when a ContextState is dup'd for shared specs. + def initialize_copy(other) + @pre = {} + @post = {} + end + + # Returns true if this is a shared +ContextState+. Essentially, when + # created with: describe "Something", :shared => true { ... } + def shared? + return @options[:shared] + end + + # Set the parent (enclosing) +ContextState+ for this state. Creates + # the +parents+ list. + def parent=(parent) + @description = nil + + if shared? + @parent = nil + else + @parent = parent + parent.child self if parent + + @parents = [self] + state = parent + while state + @parents.unshift state + state = state.parent + end + end + end + + # Add the ContextState instance +child+ to the list of nested + # describe blocks. + def child(child) + @children << child + end + + # Adds a nested ContextState in a shared ContextState to a containing + # ContextState. + # + # Normal adoption is from the parent's perspective. But adopt is a good + # verb and it's reasonable for the child to adopt the parent as well. In + # this case, manipulating state from inside the child avoids needlessly + # exposing the state to manipulate it externally in the dup. (See + # #it_should_behave_like) + def adopt(parent) + self.parent = parent + + @examples = @examples.map do |example| + example = example.dup + example.context = self + example + end + + children = @children + @children = [] + + children.each { |child| child.dup.adopt self } + end + + # Returns a list of all before(+what+) blocks from self and any parents. + def pre(what) + @pre[what] ||= parents.inject([]) { |l, s| l.push(*s.before(what)) } + end + + # Returns a list of all after(+what+) blocks from self and any parents. + # The list is in reverse order. In other words, the blocks defined in + # inner describes are in the list before those defined in outer describes, + # and in a particular describe block those defined later are in the list + # before those defined earlier. + def post(what) + @post[what] ||= parents.inject([]) { |l, s| l.unshift(*s.after(what)) } + end + + # Records before(:each) and before(:all) blocks. + def before(what, &block) + return if MSpec.guarded? + block ? @before[what].push(block) : @before[what] + end + + # Records after(:each) and after(:all) blocks. + def after(what, &block) + return if MSpec.guarded? + block ? @after[what].unshift(block) : @after[what] + end + + # Creates an ExampleState instance for the block and stores it + # in a list of examples to evaluate unless the example is filtered. + def it(desc, &block) + example = ExampleState.new(self, desc, block) + MSpec.actions :add, example + return if MSpec.guarded? + @examples << example + end + + # Evaluates the block and resets the toplevel +ContextState+ to #parent. + def describe(&block) + @parsed = protect @to_s, block, false + MSpec.register_current parent + MSpec.register_shared self if shared? + end + + # Returns a description string generated from self and all parents + def description + @description ||= parents.map { |p| p.to_s }.compact.join(" ") + end + + # Injects the before/after blocks and examples from the shared + # describe block into this +ContextState+ instance. + def it_should_behave_like(desc) + return if MSpec.guarded? + + unless state = MSpec.retrieve_shared(desc) + raise Exception, "Unable to find shared 'describe' for #{desc}" + end + + state.before(:all).each { |b| before :all, &b } + state.before(:each).each { |b| before :each, &b } + state.after(:each).each { |b| after :each, &b } + state.after(:all).each { |b| after :all, &b } + + state.examples.each do |example| + example = example.dup + example.context = self + @examples << example + end + + state.children.each do |child| + child.dup.adopt self + end + end + + # Evaluates each block in +blocks+ using the +MSpec.protect+ method + # so that exceptions are handled and tallied. Returns true and does + # NOT evaluate any blocks if +check+ is true and + # <tt>MSpec.mode?(:pretend)</tt> is true. + def protect(what, blocks, check=true) + return true if check and MSpec.mode? :pretend + Array(blocks).all? { |block| MSpec.protect what, &block } + end + + # Removes filtered examples. Returns true if there are examples + # left to evaluate. + def filter_examples + filtered, @examples = @examples.partition do |ex| + ex.filtered? + end + + filtered.each do |ex| + MSpec.actions :tagged, ex + end + + !@examples.empty? + end + + # Evaluates the examples in a +ContextState+. Invokes the MSpec events + # for :enter, :before, :after, :leave. + def process + MSpec.register_current self + + if @parsed and filter_examples + MSpec.shuffle @examples if MSpec.randomize? + MSpec.actions :enter, description + + if protect "before :all", pre(:all) + @examples.each do |state| + MSpec.repeat do + @state = state + example = state.example + MSpec.actions :before, state + + if protect "before :each", pre(:each) + MSpec.clear_expectations + if example + passed = protect nil, example + MSpec.actions :example, state, example + protect nil, @expectation_missing unless MSpec.expectation? or !passed + end + end + protect "after :each", post(:each) + protect "Mock.verify_count", @mock_verify + + protect "Mock.cleanup", @mock_cleanup + MSpec.actions :after, state + @state = nil + end + end + protect "after :all", post(:all) + else + protect "Mock.cleanup", @mock_cleanup + end + + MSpec.actions :leave + end + + MSpec.register_current nil + children.each { |child| child.process } + end +end diff --git a/spec/mspec/lib/mspec/runner/evaluate.rb b/spec/mspec/lib/mspec/runner/evaluate.rb new file mode 100644 index 0000000000..fded84421f --- /dev/null +++ b/spec/mspec/lib/mspec/runner/evaluate.rb @@ -0,0 +1,54 @@ +class SpecEvaluate + def self.desc=(desc) + @desc = desc + end + + def self.desc + @desc ||= "evaluates " + end + + def initialize(ruby, desc) + @ruby = ruby.rstrip + @desc = desc || self.class.desc + end + + # Formats the Ruby source code for reabable output in the -fs formatter + # option. If the source contains no newline characters, wraps the source in + # single quotes to set if off from the rest of the description string. If + # the source does contain newline characters, sets the indent level to four + # characters. + def format(ruby, newline=true) + if ruby.include?("\n") + lines = ruby.each_line.to_a + if /( *)/ =~ lines.first + if $1.size > 4 + dedent = $1.size - 4 + ruby = lines.map { |l| l[dedent..-1] }.join + else + indent = " " * (4 - $1.size) + ruby = lines.map { |l| "#{indent}#{l}" }.join + end + end + "\n#{ruby}" + else + "'#{ruby.lstrip}'" + end + end + + def define(&block) + ruby = @ruby + desc = @desc + evaluator = self + + specify "#{desc} #{format ruby}" do + evaluator.instance_eval(ruby) + evaluator.instance_eval(&block) + end + end +end + +class Object + def evaluate(str, desc=nil, &block) + SpecEvaluate.new(str, desc).define(&block) + end +end diff --git a/spec/mspec/lib/mspec/runner/example.rb b/spec/mspec/lib/mspec/runner/example.rb new file mode 100644 index 0000000000..19eb29b079 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/example.rb @@ -0,0 +1,34 @@ +require 'mspec/runner/mspec' + +# Holds some of the state of the example (i.e. +it+ block) that is +# being evaluated. See also +ContextState+. +class ExampleState + attr_reader :context, :it, :example + + def initialize(context, it, example=nil) + @context = context + @it = it + @example = example + end + + def context=(context) + @description = nil + @context = context + end + + def describe + @context.description + end + + def description + @description ||= "#{describe} #{@it}" + end + + def filtered? + incl = MSpec.retrieve(:include) || [] + excl = MSpec.retrieve(:exclude) || [] + included = incl.empty? || incl.any? { |f| f === description } + included &&= excl.empty? || !excl.any? { |f| f === description } + !included + end +end diff --git a/spec/mspec/lib/mspec/runner/exception.rb b/spec/mspec/lib/mspec/runner/exception.rb new file mode 100644 index 0000000000..0d9bb43105 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/exception.rb @@ -0,0 +1,43 @@ +# Initialize $MSPEC_DEBUG +$MSPEC_DEBUG ||= false + +class ExceptionState + attr_reader :description, :describe, :it, :exception + + def initialize(state, location, exception) + @exception = exception + + @description = location ? "An exception occurred during: #{location}" : "" + if state + @description += "\n" unless @description.empty? + @description += state.description + @describe = state.describe + @it = state.it + else + @describe = @it = "" + end + end + + def failure? + [SpecExpectationNotMetError, SpecExpectationNotFoundError].any? { |e| @exception.is_a? e } + end + + def message + if @exception.message.empty? + "<No message>" + elsif @exception.class == SpecExpectationNotMetError || + @exception.class == SpecExpectationNotFoundError + @exception.message + else + "#{@exception.class}: #{@exception.message}" + end + end + + def backtrace + @backtrace_filter ||= MSpecScript.config[:backtrace_filter] + + bt = @exception.backtrace || [] + + bt.select { |line| $MSPEC_DEBUG or @backtrace_filter !~ line }.join("\n") + end +end diff --git a/spec/mspec/lib/mspec/runner/filters.rb b/spec/mspec/lib/mspec/runner/filters.rb new file mode 100644 index 0000000000..d0420faca6 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/filters.rb @@ -0,0 +1,4 @@ +require 'mspec/runner/filters/match' +require 'mspec/runner/filters/regexp' +require 'mspec/runner/filters/tag' +require 'mspec/runner/filters/profile' diff --git a/spec/mspec/lib/mspec/runner/filters/match.rb b/spec/mspec/lib/mspec/runner/filters/match.rb new file mode 100644 index 0000000000..539fd02d01 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/filters/match.rb @@ -0,0 +1,18 @@ +class MatchFilter + def initialize(what, *strings) + @what = what + @strings = strings + end + + def ===(string) + @strings.any? { |s| string.include?(s) } + end + + def register + MSpec.register @what, self + end + + def unregister + MSpec.unregister @what, self + end +end diff --git a/spec/mspec/lib/mspec/runner/filters/profile.rb b/spec/mspec/lib/mspec/runner/filters/profile.rb new file mode 100644 index 0000000000..a59722c451 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/filters/profile.rb @@ -0,0 +1,54 @@ +class ProfileFilter + def initialize(what, *files) + @what = what + @methods = load(*files) + @pattern = /([^ .#]+[.#])([^ ]+)/ + end + + def find(name) + return name if File.exist?(File.expand_path(name)) + + ["spec/profiles", "spec", "profiles", "."].each do |dir| + file = File.join dir, name + return file if File.exist? file + end + end + + def parse(file) + pattern = /(\S+):\s*/ + key = "" + file.inject(Hash.new { |h,k| h[k] = [] }) do |hash, line| + line.chomp! + if line[0,2] == "- " + hash[key] << line[2..-1].gsub(/[ '"]/, "") + elsif m = pattern.match(line) + key = m[1] + end + hash + end + end + + def load(*files) + files.inject({}) do |hash, file| + next hash unless name = find(file) + + File.open name, "r" do |f| + hash.merge parse(f) + end + end + end + + def ===(string) + return false unless m = @pattern.match(string) + return false unless l = @methods[m[1]] + l.include? m[2] + end + + def register + MSpec.register @what, self + end + + def unregister + MSpec.unregister @what, self + end +end diff --git a/spec/mspec/lib/mspec/runner/filters/regexp.rb b/spec/mspec/lib/mspec/runner/filters/regexp.rb new file mode 100644 index 0000000000..2bd1448d3f --- /dev/null +++ b/spec/mspec/lib/mspec/runner/filters/regexp.rb @@ -0,0 +1,7 @@ +require 'mspec/runner/filters/match' + +class RegexpFilter < MatchFilter + def to_regexp(*strings) + strings.map { |str| Regexp.new str } + end +end diff --git a/spec/mspec/lib/mspec/runner/filters/tag.rb b/spec/mspec/lib/mspec/runner/filters/tag.rb new file mode 100644 index 0000000000..c641c01606 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/filters/tag.rb @@ -0,0 +1,29 @@ +class TagFilter + def initialize(what, *tags) + @what = what + @tags = tags + end + + def load + @descriptions = MSpec.read_tags(@tags).map { |t| t.description } + MSpec.register @what, self + end + + def unload + MSpec.unregister @what, self + end + + def ===(string) + @descriptions.include?(string) + end + + def register + MSpec.register :load, self + MSpec.register :unload, self + end + + def unregister + MSpec.unregister :load, self + MSpec.unregister :unload, self + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters.rb b/spec/mspec/lib/mspec/runner/formatters.rb new file mode 100644 index 0000000000..d085031a12 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters.rb @@ -0,0 +1,12 @@ +require 'mspec/runner/formatters/describe' +require 'mspec/runner/formatters/dotted' +require 'mspec/runner/formatters/file' +require 'mspec/runner/formatters/specdoc' +require 'mspec/runner/formatters/html' +require 'mspec/runner/formatters/summary' +require 'mspec/runner/formatters/unit' +require 'mspec/runner/formatters/spinner' +require 'mspec/runner/formatters/method' +require 'mspec/runner/formatters/yaml' +require 'mspec/runner/formatters/profile' +require 'mspec/runner/formatters/junit' diff --git a/spec/mspec/lib/mspec/runner/formatters/describe.rb b/spec/mspec/lib/mspec/runner/formatters/describe.rb new file mode 100644 index 0000000000..176bd79279 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/describe.rb @@ -0,0 +1,24 @@ +require 'mspec/runner/formatters/dotted' +require 'mspec/runner/actions/tally' + +class DescribeFormatter < DottedFormatter + # Callback for the MSpec :finish event. Prints a summary of + # the number of errors and failures for each +describe+ block. + def finish + describes = Hash.new { |h,k| h[k] = Tally.new } + + @exceptions.each do |exc| + desc = describes[exc.describe] + exc.failure? ? desc.failures! : desc.errors! + end + + print "\n" + describes.each do |d, t| + text = d.size > 40 ? "#{d[0,37]}..." : d.ljust(40) + print "\n#{text} #{t.failure}, #{t.error}" + end + print "\n" unless describes.empty? + + print "\n#{@timer.format}\n\n#{@tally.format}\n" + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/dotted.rb b/spec/mspec/lib/mspec/runner/formatters/dotted.rb new file mode 100644 index 0000000000..61c8e4c27c --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/dotted.rb @@ -0,0 +1,117 @@ +require 'mspec/expectations/expectations' +require 'mspec/runner/actions/timer' +require 'mspec/runner/actions/tally' +require 'mspec/runner/actions/leakchecker' if ENV['CHECK_LEAKS'] + +class DottedFormatter + attr_reader :exceptions, :timer, :tally + + def initialize(out=nil) + @exception = @failure = false + @exceptions = [] + @count = 0 # For subclasses + if out.nil? + @out = $stdout + else + @out = File.open out, "w" + end + + @current_state = nil + end + + # Creates the +TimerAction+ and +TallyAction+ instances and + # registers them. Registers +self+ for the +:exception+, + # +:before+, +:after+, and +:finish+ actions. + def register + (@timer = TimerAction.new).register + (@tally = TallyAction.new).register + LeakCheckerAction.new.register if ENV['CHECK_LEAKS'] + @counter = @tally.counter + + MSpec.register :exception, self + MSpec.register :before, self + MSpec.register :after, self + MSpec.register :finish, self + MSpec.register :abort, self + end + + def abort + if @current_state + puts "\naborting example: #{@current_state.description}" + end + end + + # Returns true if any exception is raised while running + # an example. This flag is reset before each example + # is evaluated. + def exception? + @exception + end + + # Returns true if all exceptions during the evaluation + # of an example are failures rather than errors. See + # <tt>ExceptionState#failure</tt>. This flag is reset + # before each example is evaluated. + def failure? + @failure + end + + # Callback for the MSpec :before event. Resets the + # +#exception?+ and +#failure+ flags. + def before(state=nil) + @current_state = state + @failure = @exception = false + end + + # Callback for the MSpec :exception event. Stores the + # +ExceptionState+ object to generate the list of backtraces + # after all the specs are run. Also updates the internal + # +#exception?+ and +#failure?+ flags. + def exception(exception) + @count += 1 + @failure = @exception ? @failure && exception.failure? : exception.failure? + @exception = true + @exceptions << exception + end + + # Callback for the MSpec :after event. Prints an indicator + # for the result of evaluating this example as follows: + # . = No failure or error + # F = An SpecExpectationNotMetError was raised + # E = Any exception other than SpecExpectationNotMetError + def after(state = nil) + @current_state = nil + + unless exception? + print "." + else + print failure? ? "F" : "E" + end + end + + # Callback for the MSpec :finish event. Prints a description + # and backtrace for every exception that occurred while + # evaluating the examples. + def finish + print "\n" + count = 0 + @exceptions.each do |exc| + count += 1 + print_exception(exc, count) + end + print "\n#{@timer.format}\n\n#{@tally.format}\n" + end + + def print_exception(exc, count) + outcome = exc.failure? ? "FAILED" : "ERROR" + print "\n#{count})\n#{exc.description} #{outcome}\n" + print exc.message, "\n" + print exc.backtrace, "\n" + end + + # A convenience method to allow printing to different outputs. + def print(*args) + @out.print(*args) + @out.flush + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/file.rb b/spec/mspec/lib/mspec/runner/formatters/file.rb new file mode 100644 index 0000000000..6db72af4ff --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/file.rb @@ -0,0 +1,19 @@ +require 'mspec/runner/formatters/dotted' + +class FileFormatter < DottedFormatter + # Unregisters DottedFormatter#before, #after methods and + # registers #load, #unload, which perform the same duties + # as #before, #after in DottedFormatter. + def register + super + + MSpec.unregister :before, self + MSpec.unregister :after, self + + MSpec.register :load, self + MSpec.register :unload, self + end + + alias_method :load, :before + alias_method :unload, :after +end diff --git a/spec/mspec/lib/mspec/runner/formatters/html.rb b/spec/mspec/lib/mspec/runner/formatters/html.rb new file mode 100644 index 0000000000..060d2732f0 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/html.rb @@ -0,0 +1,81 @@ +require 'mspec/expectations/expectations' +require 'mspec/runner/formatters/dotted' + +class HtmlFormatter < DottedFormatter + def register + super + MSpec.register :start, self + MSpec.register :enter, self + MSpec.register :leave, self + end + + def start + print <<-EOH +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" + "http://www.w3.org/TR/html4/strict.dtd"> +<html> +<head> +<title>Spec Output For #{RUBY_NAME} (#{RUBY_VERSION})</title> +<style type="text/css"> +ul { + list-style: none; +} +.fail { + color: red; +} +.pass { + color: green; +} +#details :target { + background-color: #ffffe0; +} +</style> +</head> +<body> +EOH + end + + def enter(describe) + print "<div><p>#{describe}</p>\n<ul>\n" + end + + def leave + print "</ul>\n</div>\n" + end + + def exception(exception) + super + outcome = exception.failure? ? "FAILED" : "ERROR" + print %[<li class="fail">- #{exception.it} (<a href="#details-#{@count}">] + print %[#{outcome} - #{@count}</a>)</li>\n] + end + + def after(state) + print %[<li class="pass">- #{state.it}</li>\n] unless exception? + end + + def finish + success = @exceptions.empty? + unless success + print "<hr>\n" + print %[<ol id="details">] + count = 0 + @exceptions.each do |exc| + outcome = exc.failure? ? "FAILED" : "ERROR" + print %[\n<li id="details-#{count += 1}"><p>#{escape(exc.description)} #{outcome}</p>\n<p>] + print escape(exc.message) + print "</p>\n<pre>\n" + print escape(exc.backtrace) + print "</pre>\n</li>\n" + end + print "</ol>\n" + end + print %[<p>#{@timer.format}</p>\n] + print %[<p class="#{success ? "pass" : "fail"}">#{@tally.format}</p>\n] + print "</body>\n</html>\n" + end + + def escape(string) + string.gsub("&", " ").gsub("<", "<").gsub(">", ">") + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/junit.rb b/spec/mspec/lib/mspec/runner/formatters/junit.rb new file mode 100644 index 0000000000..647deee7e1 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/junit.rb @@ -0,0 +1,89 @@ +require 'mspec/expectations/expectations' +require 'mspec/utils/ruby_name' +require 'mspec/runner/formatters/yaml' + +class JUnitFormatter < YamlFormatter + def initialize(out=nil) + super + @tests = [] + end + + def after(state = nil) + super + @tests << {:test => state, :exception => false} unless exception? + end + + def exception(exception) + super + @tests << {:test => exception, :exception => true} + end + + def finish + switch + + time = @timer.elapsed + tests = @tally.counter.examples + errors = @tally.counter.errors + failures = @tally.counter.failures + + printf <<-XML + +<?xml version="1.0" encoding="UTF-8" ?> + <testsuites + testCount="#{tests}" + errorCount="#{errors}" + failureCount="#{failures}" + timeCount="#{time}" time="#{time}"> + <testsuite + tests="#{tests}" + errors="#{errors}" + failures="#{failures}" + time="#{time}" + name="Spec Output For #{::RUBY_NAME} (#{::RUBY_VERSION})"> + XML + @tests.each do |h| + description = encode_for_xml h[:test].description + + printf <<-XML, "Spec", description, 0.0 + <testcase classname="%s" name="%s" time="%f"> + XML + if h[:exception] + outcome = h[:test].failure? ? "failure" : "error" + message = encode_for_xml h[:test].message + backtrace = encode_for_xml h[:test].backtrace + print <<-XML + <#{outcome} message="error in #{description}" type="#{outcome}"> + #{message} + #{backtrace} + </#{outcome}> + XML + end + print <<-XML + </testcase> + XML + end + + print <<-XML + </testsuite> + </testsuites> + XML + end + + private + LT = "<" + GT = ">" + QU = """ + AP = "'" + AM = "&" + TARGET_ENCODING = "ISO-8859-1" + + def encode_for_xml(str) + encode_as_latin1(str).gsub("<", LT).gsub(">", GT). + gsub('"', QU).gsub("'", AP).gsub("&", AM). + tr("\x00-\x08", "?") + end + + def encode_as_latin1(str) + str.encode(TARGET_ENCODING, :undef => :replace, :invalid => :replace) + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/method.rb b/spec/mspec/lib/mspec/runner/formatters/method.rb new file mode 100644 index 0000000000..ff115193fd --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/method.rb @@ -0,0 +1,93 @@ +require 'mspec/runner/formatters/dotted' + +class MethodFormatter < DottedFormatter + attr_accessor :methods + + def initialize(out=nil) + super + @methods = Hash.new do |h, k| + hash = {} + hash[:examples] = 0 + hash[:expectations] = 0 + hash[:failures] = 0 + hash[:errors] = 0 + hash[:exceptions] = [] + h[k] = hash + end + end + + # Returns the type of method as a "class", "instance", + # or "unknown". + def method_type(sep) + case sep + when '.', '::' + "class" + when '#' + "instance" + else + "unknown" + end + end + + # Callback for the MSpec :before event. Parses the + # describe string into class and method if possible. + # Resets the tallies so the counts are only for this + # example. + def before(state) + super + + # The pattern for a method name is not correctly + # restrictive but it is simplistic and useful + # for our purpose. + /^([A-Za-z_]+\w*)(\.|#|::)([^ ]+)/ =~ state.describe + @key = $1 && $2 && $3 ? "#{$1}#{$2}#{$3}" : state.describe + + unless methods.key? @key + h = methods[@key] + h[:class] = "#{$1}" + h[:method] = "#{$3}" + h[:type] = method_type $2 + h[:description] = state.description + end + + tally.counter.examples = 0 + tally.counter.expectations = 0 + tally.counter.failures = 0 + tally.counter.errors = 0 + + @exceptions = [] + end + + # Callback for the MSpec :after event. Sets or adds to + # tallies for the example block. + def after(state) + h = methods[@key] + h[:examples] += tally.counter.examples + h[:expectations] += tally.counter.expectations + h[:failures] += tally.counter.failures + h[:errors] += tally.counter.errors + @exceptions.each do |exc| + h[:exceptions] << "#{exc.message}\n#{exc.backtrace}\n" + end + end + + # Callback for the MSpec :finish event. Prints out the + # summary information in YAML format for all the methods. + def finish + print "---\n" + + methods.each do |key, hash| + print key.inspect, ":\n" + print " class: ", hash[:class].inspect, "\n" + print " method: ", hash[:method].inspect, "\n" + print " type: ", hash[:type], "\n" + print " description: ", hash[:description].inspect, "\n" + print " examples: ", hash[:examples], "\n" + print " expectations: ", hash[:expectations], "\n" + print " failures: ", hash[:failures], "\n" + print " errors: ", hash[:errors], "\n" + print " exceptions:\n" + hash[:exceptions].each { |exc| print " - ", exc.inspect, "\n" } + end + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/multi.rb b/spec/mspec/lib/mspec/runner/formatters/multi.rb new file mode 100644 index 0000000000..bcc5411e6f --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/multi.rb @@ -0,0 +1,36 @@ +require 'mspec/runner/formatters/spinner' +require 'yaml' + +class MultiFormatter < SpinnerFormatter + def initialize(out=nil) + super(out) + @counter = @tally = Tally.new + @timer = TimerAction.new + @timer.start + end + + def aggregate_results(files) + @timer.finish + @exceptions = [] + + files.each do |file| + d = File.open(file, "r") { |f| YAML.load f } + File.delete file + + @exceptions += Array(d['exceptions']) + @tally.files! d['files'] + @tally.examples! d['examples'] + @tally.expectations! d['expectations'] + @tally.errors! d['errors'] + @tally.failures! d['failures'] + end + end + + def print_exception(exc, count) + print "\n#{count})\n#{exc}\n" + end + + def finish + super(false) + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/profile.rb b/spec/mspec/lib/mspec/runner/formatters/profile.rb new file mode 100644 index 0000000000..498cd4a3b7 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/profile.rb @@ -0,0 +1,70 @@ +require 'mspec/expectations/expectations' +require 'mspec/runner/formatters/dotted' + +class ProfileFormatter < DottedFormatter + def initialize(out=nil) + super + + @describe_name = nil + @describe_time = nil + @describes = [] + @its = [] + end + + def register + super + MSpec.register :enter, self + end + + # Callback for the MSpec :enter event. Prints the + # +describe+ block string. + def enter(describe) + if @describe_time + @describes << [@describe_name, Time.now.to_f - @describe_time] + end + + @describe_name = describe + @describe_time = Time.now.to_f + end + + # Callback for the MSpec :before event. Prints the + # +it+ block string. + def before(state) + super + + @it_name = state.it + @it_time = Time.now.to_f + end + + # Callback for the MSpec :after event. Prints a + # newline to finish the description string output. + def after(state) + @its << [@describe_name, @it_name, Time.now.to_f - @it_time] + super + end + + def finish + puts "\nProfiling info:" + + desc = @describes.sort { |a,b| b.last <=> a.last } + desc.delete_if { |a| a.last <= 0.001 } + show = desc[0, 100] + + puts "Top #{show.size} describes:" + + show.each do |des, time| + printf "%3.3f - %s\n", time, des + end + + its = @its.sort { |a,b| b.last <=> a.last } + its.delete_if { |a| a.last <= 0.001 } + show = its[0, 100] + + puts "\nTop #{show.size} its:" + show.each do |des, it, time| + printf "%3.3f - %s %s\n", time, des, it + end + + super + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/specdoc.rb b/spec/mspec/lib/mspec/runner/formatters/specdoc.rb new file mode 100644 index 0000000000..29adde3c5c --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/specdoc.rb @@ -0,0 +1,41 @@ +require 'mspec/expectations/expectations' +require 'mspec/runner/formatters/dotted' + +class SpecdocFormatter < DottedFormatter + def register + super + MSpec.register :enter, self + end + + # Callback for the MSpec :enter event. Prints the + # +describe+ block string. + def enter(describe) + print "\n#{describe}\n" + end + + # Callback for the MSpec :before event. Prints the + # +it+ block string. + def before(state) + super + print "- #{state.it}" + end + + # Callback for the MSpec :exception event. Prints + # either 'ERROR - X' or 'FAILED - X' where _X_ is + # the sequential number of the exception raised. If + # there has already been an exception raised while + # evaluating this example, it prints another +it+ + # block description string so that each discription + # string has an associated 'ERROR' or 'FAILED' + def exception(exception) + print "\n- #{exception.it}" if exception? + super + print " (#{exception.failure? ? 'FAILED' : 'ERROR'} - #{@count})" + end + + # Callback for the MSpec :after event. Prints a + # newline to finish the description string output. + def after(state) + print "\n" + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/spinner.rb b/spec/mspec/lib/mspec/runner/formatters/spinner.rb new file mode 100644 index 0000000000..f6f35cc476 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/spinner.rb @@ -0,0 +1,117 @@ +require 'mspec/expectations/expectations' +require 'mspec/runner/formatters/dotted' + +class SpinnerFormatter < DottedFormatter + attr_reader :length + + Spins = %w!| / - \\! + HOUR = 3600 + MIN = 60 + + def initialize(out=nil) + super(nil) + + @which = 0 + @loaded = 0 + self.length = 40 + @percent = 0 + @start = Time.now + + term = ENV['TERM'] + @color = (term != "dumb") + @fail_color = "32" + @error_color = "32" + end + + def register + super + + MSpec.register :start, self + MSpec.register :unload, self + MSpec.unregister :before, self + end + + def length=(length) + @length = length + @ratio = 100.0 / length + @position = length / 2 - 2 + end + + def compute_etr + return @etr = "00:00:00" if @percent == 0 + elapsed = Time.now - @start + remain = (100 * elapsed / @percent) - elapsed + + hour = remain >= HOUR ? (remain / HOUR).to_i : 0 + remain -= hour * HOUR + min = remain >= MIN ? (remain / MIN).to_i : 0 + sec = remain - min * MIN + + @etr = "%02d:%02d:%02d" % [hour, min, sec] + end + + def compute_percentage + @percent = @loaded * 100 / @total + bar = ("=" * (@percent / @ratio)).ljust @length + label = "%d%%" % @percent + bar[@position, label.size] = label + @bar = bar + end + + def compute_progress + compute_percentage + compute_etr + end + + def progress_line + @which = (@which + 1) % Spins.size + data = [Spins[@which], @bar, @etr, @counter.failures, @counter.errors] + if @color + "\r[%s | %s | %s] \e[0;#{@fail_color}m%6dF \e[0;#{@error_color}m%6dE\e[0m " % data + else + "\r[%s | %s | %s] %6dF %6dE " % data + end + end + + def clear_progress_line + print "\r#{' '*progress_line.length}" + end + + # Callback for the MSpec :start event. Stores the total + # number of files that will be processed. + def start + @total = MSpec.retrieve(:files).size + compute_progress + print progress_line + end + + # Callback for the MSpec :unload event. Increments the number + # of files that have been run. + def unload + @loaded += 1 + compute_progress + print progress_line + end + + # Callback for the MSpec :exception event. Changes the color + # used to display the tally of errors and failures + def exception(exception) + super + @fail_color = "31" if exception.failure? + @error_color = "33" unless exception.failure? + + clear_progress_line + print_exception(exception, @count) + end + + # Callback for the MSpec :after event. Updates the spinner. + def after(state) + print progress_line + end + + def finish(printed_exceptions = true) + # We already printed the exceptions + @exceptions = [] if printed_exceptions + super() + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/summary.rb b/spec/mspec/lib/mspec/runner/formatters/summary.rb new file mode 100644 index 0000000000..0c9207194c --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/summary.rb @@ -0,0 +1,11 @@ +require 'mspec/expectations/expectations' +require 'mspec/runner/formatters/dotted' + +class SummaryFormatter < DottedFormatter + # Callback for the MSpec :after event. Overrides the + # callback provided by +DottedFormatter+ and does not + # print any output for each example evaluated. + def after(state) + # do nothing + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/unit.rb b/spec/mspec/lib/mspec/runner/formatters/unit.rb new file mode 100644 index 0000000000..69b68dc0d5 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/unit.rb @@ -0,0 +1,21 @@ +require 'mspec/expectations/expectations' +require 'mspec/runner/formatters/dotted' + +class UnitdiffFormatter < DottedFormatter + def finish + print "\n\n#{@timer.format}\n" + count = 0 + @exceptions.each do |exc| + outcome = exc.failure? ? "FAILED" : "ERROR" + print "\n#{count += 1})\n#{exc.description} #{outcome}\n" + print exc.message, ": \n" + print exc.backtrace, "\n" + end + print "\n#{@tally.format}\n" + end + + def backtrace(exc) + exc.backtrace && exc.backtrace.join("\n") + end + private :backtrace +end diff --git a/spec/mspec/lib/mspec/runner/formatters/yaml.rb b/spec/mspec/lib/mspec/runner/formatters/yaml.rb new file mode 100644 index 0000000000..090a9b1b9d --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/yaml.rb @@ -0,0 +1,42 @@ +require 'mspec/expectations/expectations' +require 'mspec/runner/formatters/dotted' + +class YamlFormatter < DottedFormatter + def initialize(out=nil) + super(nil) + + if out.nil? + @finish = $stdout + else + @finish = File.open out, "w" + end + end + + def switch + @out = @finish + end + + def after(state) + end + + def finish + switch + + print "---\n" + print "exceptions:\n" + @exceptions.each do |exc| + outcome = exc.failure? ? "FAILED" : "ERROR" + str = "#{exc.description} #{outcome}\n" + str << exc.message << "\n" << exc.backtrace + print "- ", str.inspect, "\n" + end + + print "time: ", @timer.elapsed, "\n" + print "files: ", @tally.counter.files, "\n" + print "examples: ", @tally.counter.examples, "\n" + print "expectations: ", @tally.counter.expectations, "\n" + print "failures: ", @tally.counter.failures, "\n" + print "errors: ", @tally.counter.errors, "\n" + print "tagged: ", @tally.counter.tagged, "\n" + end +end diff --git a/spec/mspec/lib/mspec/runner/mspec.rb b/spec/mspec/lib/mspec/runner/mspec.rb new file mode 100644 index 0000000000..0ff0de36ca --- /dev/null +++ b/spec/mspec/lib/mspec/runner/mspec.rb @@ -0,0 +1,391 @@ +require 'mspec/runner/context' +require 'mspec/runner/exception' +require 'mspec/runner/tag' + +module MSpec + + @exit = nil + @abort = nil + @start = nil + @enter = nil + @before = nil + @add = nil + @after = nil + @leave = nil + @finish = nil + @exclude = nil + @include = nil + @leave = nil + @load = nil + @unload = nil + @tagged = nil + @current = nil + @example = nil + @modes = [] + @shared = {} + @guarded = [] + @features = {} + @exception = nil + @randomize = nil + @repeat = nil + @expectation = nil + @expectations = false + + def self.describe(mod, options=nil, &block) + state = ContextState.new mod, options + state.parent = current + + MSpec.register_current state + state.describe(&block) + + state.process unless state.shared? or current + end + + def self.process + STDOUT.puts RUBY_DESCRIPTION + + actions :start + files + actions :finish + end + + def self.each_file(&block) + if ENV["MSPEC_MULTI"] + STDOUT.print "." + STDOUT.flush + while (file = STDIN.gets.chomp) != "QUIT" + yield file + STDOUT.print "." + STDOUT.flush + end + else + return unless files = retrieve(:files) + shuffle files if randomize? + files.each(&block) + end + end + + def self.files + each_file do |file| + setup_env + store :file, file + actions :load + protect("loading #{file}") { Kernel.load file } + actions :unload + end + end + + def self.setup_env + @env = Object.new + @env.extend MSpec + end + + def self.actions(action, *args) + actions = retrieve(action) + actions.each { |obj| obj.send action, *args } if actions + end + + def self.protect(location, &block) + begin + @env.instance_eval(&block) + return true + rescue SystemExit => e + raise e + rescue Exception => exc + register_exit 1 + actions :exception, ExceptionState.new(current && current.state, location, exc) + return false + end + end + + # Guards can be nested, so a stack is necessary to know when we have + # exited the toplevel guard. + def self.guard + @guarded << true + end + + def self.unguard + @guarded.pop + end + + def self.guarded? + !@guarded.empty? + end + + # Sets the toplevel ContextState to +state+. + def self.register_current(state) + store :current, state + end + + # Sets the toplevel ContextState to +nil+. + def self.clear_current + store :current, nil + end + + # Returns the toplevel ContextState. + def self.current + retrieve :current + end + + # Stores the shared ContextState keyed by description. + def self.register_shared(state) + @shared[state.to_s] = state + end + + # Returns the shared ContextState matching description. + def self.retrieve_shared(desc) + @shared[desc.to_s] + end + + # Stores the exit code used by the runner scripts. + def self.register_exit(code) + store :exit, code + end + + # Retrieves the stored exit code. + def self.exit_code + retrieve(:exit).to_i + end + + # Stores the list of files to be evaluated. + def self.register_files(files) + store :files, files + end + + # Stores one or more substitution patterns for transforming + # a spec filename into a tags filename, where each pattern + # has the form: + # + # [Regexp, String] + # + # See also +tags_file+. + def self.register_tags_patterns(patterns) + store :tags_patterns, patterns + end + + # Registers an operating mode. Modes recognized by MSpec: + # + # :pretend - actions execute but specs are not run + # :verify - specs are run despite guards and the result is + # verified to match the expectation of the guard + # :report - specs that are guarded are reported + # :unguarded - all guards are forced off + def self.register_mode(mode) + modes = retrieve :modes + modes << mode unless modes.include? mode + end + + # Clears all registered modes. + def self.clear_modes + store :modes, [] + end + + # Returns +true+ if +mode+ is registered. + def self.mode?(mode) + retrieve(:modes).include? mode + end + + def self.enable_feature(feature) + retrieve(:features)[feature] = true + end + + def self.disable_feature(feature) + retrieve(:features)[feature] = false + end + + def self.feature_enabled?(feature) + retrieve(:features)[feature] || false + end + + def self.retrieve(symbol) + instance_variable_get :"@#{symbol}" + end + + def self.store(symbol, value) + instance_variable_set :"@#{symbol}", value + end + + # This method is used for registering actions that are + # run at particular points in the spec cycle: + # :start before any specs are run + # :load before a spec file is loaded + # :enter before a describe block is run + # :before before a single spec is run + # :add while a describe block is adding examples to run later + # :expectation before a 'should', 'should_receive', etc. + # :example after an example block is run, passed the block + # :exception after an exception is rescued + # :after after a single spec is run + # :leave after a describe block is run + # :unload after a spec file is run + # :finish after all specs are run + # + # Objects registered as actions above should respond to + # a method of the same name. For example, if an object + # is registered as a :start action, it should respond to + # a #start method call. + # + # Additionally, there are two "action" lists for + # filtering specs: + # :include return true if the spec should be run + # :exclude return true if the spec should NOT be run + # + def self.register(symbol, action) + unless value = retrieve(symbol) + value = store symbol, [] + end + value << action unless value.include? action + end + + def self.unregister(symbol, action) + if value = retrieve(symbol) + value.delete action + end + end + + def self.randomize(flag=true) + @randomize = flag + end + + def self.randomize? + @randomize == true + end + + def self.repeat=(times) + @repeat = times + end + + def self.repeat + (@repeat || 1).times do + yield + end + end + + def self.shuffle(ary) + return if ary.empty? + + size = ary.size + size.times do |i| + r = rand(size - i - 1) + ary[i], ary[r] = ary[r], ary[i] + end + end + + # Records that an expectation has been encountered in an example. + def self.expectation + store :expectations, true + end + + # Returns true if an expectation has been encountered + def self.expectation? + retrieve :expectations + end + + # Resets the flag that an expectation has been encountered in an example. + def self.clear_expectations + store :expectations, false + end + + # Transforms a spec filename into a tags filename by applying each + # substitution pattern in :tags_pattern. The default patterns are: + # + # [%r(/spec/), '/spec/tags/'], [/_spec.rb$/, '_tags.txt'] + # + # which will perform the following transformation: + # + # path/to/spec/class/method_spec.rb => path/to/spec/tags/class/method_tags.txt + # + # See also +register_tags_patterns+. + def self.tags_file + patterns = retrieve(:tags_patterns) || + [[%r(spec/), 'spec/tags/'], [/_spec.rb$/, '_tags.txt']] + patterns.inject(retrieve(:file).dup) do |file, pattern| + file.gsub(*pattern) + end + end + + # Returns a list of tags matching any tag string in +keys+ based + # on the return value of <tt>keys.include?("tag_name")</tt> + def self.read_tags(keys) + tags = [] + file = tags_file + if File.exist? file + File.open(file, "r:utf-8") do |f| + f.each_line do |line| + line.chomp! + next if line.empty? + tag = SpecTag.new line + tags << tag if keys.include? tag.tag + end + end + end + tags + end + + def self.make_tag_dir(path) + parent = File.dirname(path) + return if File.exist? parent + begin + Dir.mkdir(parent) + rescue SystemCallError + make_tag_dir(parent) + Dir.mkdir(parent) + end + end + + # Writes each tag in +tags+ to the tag file. Overwrites the + # tag file if it exists. + def self.write_tags(tags) + file = tags_file + make_tag_dir(file) + File.open(file, "w:utf-8") do |f| + tags.each { |t| f.puts t } + end + end + + # Writes +tag+ to the tag file if it does not already exist. + # Returns +true+ if the tag is written, +false+ otherwise. + def self.write_tag(tag) + tags = read_tags([tag.tag]) + tags.each do |t| + if t.tag == tag.tag and t.description == tag.description + return false + end + end + + file = tags_file + make_tag_dir(file) + File.open(file, "a:utf-8") { |f| f.puts tag.to_s } + return true + end + + # Deletes +tag+ from the tag file if it exists. Returns +true+ + # if the tag is deleted, +false+ otherwise. Deletes the tag + # file if it is empty. + def self.delete_tag(tag) + deleted = false + desc = tag.escape(tag.description) + file = tags_file + if File.exist? file + lines = IO.readlines(file) + File.open(file, "w:utf-8") do |f| + lines.each do |line| + line = line.chomp + if line.start_with?(tag.tag) and line.end_with?(desc) + deleted = true + else + f.puts line unless line.empty? + end + end + end + File.delete file unless File.size? file + end + return deleted + end + + # Removes the tag file associated with a spec file. + def self.delete_tags + file = tags_file + File.delete file if File.exist? file + end +end diff --git a/spec/mspec/lib/mspec/runner/object.rb b/spec/mspec/lib/mspec/runner/object.rb new file mode 100644 index 0000000000..018e356149 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/object.rb @@ -0,0 +1,28 @@ +class Object + def before(at=:each, &block) + MSpec.current.before at, &block + end + + def after(at=:each, &block) + MSpec.current.after at, &block + end + + def describe(mod, msg=nil, options=nil, &block) + MSpec.describe mod, msg, &block + end + + def it(msg, &block) + MSpec.current.it msg, &block + end + + def it_should_behave_like(desc) + MSpec.current.it_should_behave_like desc + end + + # For ReadRuby compatiability + def doc(*a) + end + + alias_method :context, :describe + alias_method :specify, :it +end diff --git a/spec/mspec/lib/mspec/runner/shared.rb b/spec/mspec/lib/mspec/runner/shared.rb new file mode 100644 index 0000000000..336e35f6ac --- /dev/null +++ b/spec/mspec/lib/mspec/runner/shared.rb @@ -0,0 +1,12 @@ +require 'mspec/runner/mspec' + +class Object + def it_behaves_like(desc, meth, obj=nil) + send :before, :all do + @method = meth + @object = obj + end + + send :it_should_behave_like, desc.to_s + end +end diff --git a/spec/mspec/lib/mspec/runner/tag.rb b/spec/mspec/lib/mspec/runner/tag.rb new file mode 100644 index 0000000000..e2275ad3a6 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/tag.rb @@ -0,0 +1,38 @@ +class SpecTag + attr_accessor :tag, :comment, :description + + def initialize(string=nil) + parse(string) if string + end + + def parse(string) + m = /^([^()#:]+)(\(([^)]+)?\))?:(.*)$/.match string + @tag, @comment, description = m.values_at(1, 3, 4) if m + @description = unescape description + end + + def unescape(str) + return unless str + if str[0] == ?" and str[-1] == ?" + str[1..-2].gsub('\n', "\n") + else + str + end + end + + def escape(str) + if str.include? "\n" + %["#{str.gsub("\n", '\n')}"] + else + str + end + end + + def to_s + "#{@tag}#{ "(#{@comment})" if @comment }:#{escape @description}" + end + + def ==(o) + @tag == o.tag and @comment == o.comment and @description == o.description + end +end |