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 | |
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')
120 files changed, 6898 insertions, 0 deletions
diff --git a/spec/mspec/lib/mspec.rb b/spec/mspec/lib/mspec.rb new file mode 100644 index 0000000000..42d590c99a --- /dev/null +++ b/spec/mspec/lib/mspec.rb @@ -0,0 +1,20 @@ +require 'mspec/matchers' +require 'mspec/expectations' +require 'mspec/mocks' +require 'mspec/runner' +require 'mspec/guards' +require 'mspec/helpers' +require 'mspec/version' + +# If the implementation on which the specs are run cannot +# load pp from the standard library, add a pp.rb file that +# defines the #pretty_inspect method on Object or Kernel. +begin + require 'pp' +rescue LoadError + module Kernel + def pretty_inspect + inspect + end + end +end diff --git a/spec/mspec/lib/mspec/commands/mkspec.rb b/spec/mspec/lib/mspec/commands/mkspec.rb new file mode 100755 index 0000000000..7a943aa1fe --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mkspec.rb @@ -0,0 +1,155 @@ +#!/usr/bin/env ruby + +require 'rbconfig' +require 'mspec/version' +require 'mspec/utils/options' +require 'mspec/utils/name_map' +require 'mspec/helpers/fs' + +class MkSpec + attr_reader :config + + def initialize + @config = { + :constants => [], + :requires => [], + :base => "core", + :version => nil + } + @map = NameMap.new true + end + + def options(argv=ARGV) + options = MSpecOptions.new "mkspec [options]", 32 + + options.on("-c", "--constant", "CONSTANT", + "Class or Module to generate spec stubs for") do |name| + config[:constants] << name + end + options.on("-b", "--base", "DIR", + "Directory to generate specs into") do |directory| + config[:base] = File.expand_path directory + end + options.on("-r", "--require", "LIBRARY", + "A library to require") do |file| + config[:requires] << file + end + options.on("-V", "--version-guard", "VERSION", + "Specify version for ruby_version_is guards") do |version| + config[:version] = version + end + options.version MSpec::VERSION + options.help + + options.doc "\n How might this work in the real world?\n" + options.doc " 1. To create spec stubs for every class or module in Object\n" + options.doc " $ mkspec\n" + options.doc " 2. To create spec stubs for Fixnum\n" + options.doc " $ mkspec -c Fixnum\n" + options.doc " 3. To create spec stubs for Complex in 'superspec/complex'\n" + options.doc " $ mkspec -c Complex -r complex -b superspec" + options.doc "" + + options.parse argv + end + + def create_directory(mod) + subdir = @map.dir_name mod, config[:base] + + if File.exist? subdir + unless File.directory? subdir + puts "#{subdir} already exists and is not a directory." + return nil + end + else + mkdir_p subdir + end + + subdir + end + + def write_requires(dir, file) + prefix = config[:base] + '/' + raise dir unless dir.start_with? prefix + sub = dir[prefix.size..-1] + parents = '../' * (sub.split('/').length + 1) + + File.open(file, 'w') do |f| + f.puts "require File.expand_path('../#{parents}spec_helper', __FILE__)" + config[:requires].each do |lib| + f.puts "require '#{lib}'" + end + end + end + + def write_version(f) + f.puts "" + if version = config[:version] + f.puts "ruby_version_is #{version} do" + yield " " + f.puts "end" + else + yield "" + end + end + + def write_spec(file, meth, exists) + if exists + out = `#{ruby} #{MSPEC_HOME}/bin/mspec-run --dry-run --unguarded -fs -e '#{meth}' #{file}` + return if out.include?(meth) + end + + File.open file, 'a' do |f| + write_version(f) do |indent| + f.puts <<-EOS +#{indent}describe "#{meth}" do +#{indent} it "needs to be reviewed for spec completeness" +#{indent}end +EOS + end + end + + puts file + end + + def create_file(dir, mod, meth, name) + file = File.join dir, @map.file_name(meth, mod) + exists = File.exist? file + + write_requires dir, file unless exists + write_spec file, name, exists + end + + def run + config[:requires].each { |lib| require lib } + constants = config[:constants] + constants = Object.constants if constants.empty? + + @map.map({}, constants).each do |mod, methods| + name = mod.chop + next unless dir = create_directory(name) + + methods.each { |method| create_file dir, name, method, mod + method } + end + end + + ## + # Determine and return the path of the ruby executable. + + def ruby + ruby = File.join(RbConfig::CONFIG['bindir'], + RbConfig::CONFIG['ruby_install_name']) + + ruby.gsub! File::SEPARATOR, File::ALT_SEPARATOR if File::ALT_SEPARATOR + + return ruby + end + + def self.main + ENV['MSPEC_RUNNER'] = '1' + + script = new + script.options + script.run + end +end diff --git a/spec/mspec/lib/mspec/commands/mspec-ci.rb b/spec/mspec/lib/mspec/commands/mspec-ci.rb new file mode 100644 index 0000000000..225d2bb96d --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mspec-ci.rb @@ -0,0 +1,79 @@ +#!/usr/bin/env ruby + +$:.unshift File.expand_path(File.dirname(__FILE__) + '/../lib') + +require 'mspec/version' +require 'mspec/utils/options' +require 'mspec/utils/script' + + +class MSpecCI < MSpecScript + def options(argv=ARGV) + options = MSpecOptions.new "mspec ci [options] (FILE|DIRECTORY|GLOB)+", 30, config + + options.doc " Ask yourself:" + options.doc " 1. How to run the specs?" + options.doc " 2. How to modify the guard behavior?" + options.doc " 2. How to display the output?" + options.doc " 3. What action to perform?" + options.doc " 4. When to perform it?" + + options.doc "\n How to run the specs" + options.chdir + options.prefix + options.configure { |f| load f } + options.name + options.pretend + options.interrupt + + options.doc "\n How to modify the guard behavior" + options.unguarded + options.verify + + options.doc "\n How to display their output" + options.formatters + options.verbose + + options.doc "\n What action to perform" + options.actions + + options.doc "\n When to perform it" + options.action_filters + + options.doc "\n Help!" + options.debug + options.version MSpec::VERSION + options.help + + options.doc "\n Custom options" + custom_options options + + options.doc "\n How might this work in the real world?" + options.doc "\n 1. To simply run the known good specs" + options.doc "\n $ mspec ci" + options.doc "\n 2. To run a subset of the known good specs" + options.doc "\n $ mspec ci path/to/specs" + options.doc "\n 3. To start the debugger before the spec matching 'this crashes'" + options.doc "\n $ mspec ci --spec-debug -S 'this crashes'" + options.doc "" + + patterns = options.parse argv + patterns = config[:ci_files] if patterns.empty? + @files = files patterns + end + + def run + MSpec.register_tags_patterns config[:tags_patterns] + MSpec.register_files @files + + tags = ["fails", "critical", "unstable", "incomplete", "unsupported"] + tags += Array(config[:ci_xtags]) + + require 'mspec/runner/filters/tag' + filter = TagFilter.new(:exclude, *tags) + filter.register + + MSpec.process + exit MSpec.exit_code + end +end diff --git a/spec/mspec/lib/mspec/commands/mspec-run.rb b/spec/mspec/lib/mspec/commands/mspec-run.rb new file mode 100644 index 0000000000..45b26e88ad --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mspec-run.rb @@ -0,0 +1,87 @@ +#!/usr/bin/env ruby + +$:.unshift File.expand_path(File.dirname(__FILE__) + '/../lib') + +require 'mspec/version' +require 'mspec/utils/options' +require 'mspec/utils/script' + + +class MSpecRun < MSpecScript + def initialize + super + + config[:files] = [] + end + + def options(argv=ARGV) + options = MSpecOptions.new "mspec run [options] (FILE|DIRECTORY|GLOB)+", 30, config + + options.doc " Ask yourself:" + options.doc " 1. What specs to run?" + options.doc " 2. How to modify the execution?" + options.doc " 3. How to modify the guard behavior?" + options.doc " 4. How to display the output?" + options.doc " 5. What action to perform?" + options.doc " 6. When to perform it?" + + options.doc "\n What specs to run" + options.filters + + options.doc "\n How to modify the execution" + options.chdir + options.prefix + options.configure { |f| load f } + options.name + options.randomize + options.repeat + options.pretend + options.interrupt + + options.doc "\n How to modify the guard behavior" + options.unguarded + options.verify + + options.doc "\n How to display their output" + options.formatters + options.verbose + + options.doc "\n What action to perform" + options.actions + + options.doc "\n When to perform it" + options.action_filters + + options.doc "\n Help!" + options.debug + options.version MSpec::VERSION + options.help + + options.doc "\n Custom options" + custom_options options + + options.doc "\n How might this work in the real world?" + options.doc "\n 1. To simply run some specs" + options.doc "\n $ mspec path/to/the/specs" + options.doc " mspec path/to/the_file_spec.rb" + options.doc "\n 2. To run specs tagged with 'fails'" + options.doc "\n $ mspec -g fails path/to/the_file_spec.rb" + options.doc "\n 3. To start the debugger before the spec matching 'this crashes'" + options.doc "\n $ mspec --spec-debug -S 'this crashes' path/to/the_file_spec.rb" + options.doc "\n 4. To run some specs matching 'this crashes'" + options.doc "\n $ mspec -e 'this crashes' path/to/the_file_spec.rb" + + options.doc "" + + patterns = options.parse argv + @files = files_from_patterns(patterns) + end + + def run + MSpec.register_tags_patterns config[:tags_patterns] + MSpec.register_files @files + + MSpec.process + exit MSpec.exit_code + end +end diff --git a/spec/mspec/lib/mspec/commands/mspec-tag.rb b/spec/mspec/lib/mspec/commands/mspec-tag.rb new file mode 100644 index 0000000000..7582015916 --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mspec-tag.rb @@ -0,0 +1,133 @@ +#!/usr/bin/env ruby + +require 'mspec/version' +require 'mspec/utils/options' +require 'mspec/utils/script' + + +class MSpecTag < MSpecScript + def initialize + super + + config[:tagger] = :add + config[:tag] = 'fails:' + config[:outcome] = :fail + config[:ltags] = [] + end + + def options(argv=ARGV) + options = MSpecOptions.new "mspec tag [options] (FILE|DIRECTORY|GLOB)+", 30, config + + options.doc " Ask yourself:" + options.doc " 1. What specs to run?" + options.doc " 2. How to modify the execution?" + options.doc " 3. How to display the output?" + options.doc " 4. What tag action to perform?" + options.doc " 5. When to perform it?" + + options.doc "\n What specs to run" + options.filters + + options.doc "\n How to modify the execution" + options.configure { |f| load f } + options.name + options.pretend + options.unguarded + options.interrupt + + options.doc "\n How to display their output" + options.formatters + options.verbose + + options.doc "\n What action to perform and when to perform it" + options.on("-N", "--add", "TAG", + "Add TAG with format 'tag' or 'tag(comment)' (see -Q, -F, -L)") do |o| + config[:tagger] = :add + config[:tag] = "#{o}:" + end + options.on("-R", "--del", "TAG", + "Delete TAG (see -Q, -F, -L)") do |o| + config[:tagger] = :del + config[:tag] = "#{o}:" + config[:outcome] = :pass + end + options.on("-Q", "--pass", "Apply action to specs that pass (default for --del)") do + config[:outcome] = :pass + end + options.on("-F", "--fail", "Apply action to specs that fail (default for --add)") do + config[:outcome] = :fail + end + options.on("-L", "--all", "Apply action to all specs") do + config[:outcome] = :all + end + options.on("--list", "TAG", "Display descriptions of any specs tagged with TAG") do |t| + config[:tagger] = :list + config[:ltags] << t + end + options.on("--list-all", "Display descriptions of any tagged specs") do + config[:tagger] = :list_all + end + options.on("--purge", "Remove all tags not matching any specs") do + config[:tagger] = :purge + end + + options.doc "\n Help!" + options.debug + options.version MSpec::VERSION + options.help + + options.doc "\n Custom options" + custom_options options + + options.doc "\n How might this work in the real world?" + options.doc "\n 1. To add the 'fails' tag to failing specs" + options.doc "\n $ mspec tag path/to/the_file_spec.rb" + options.doc "\n 2. To remove the 'fails' tag from passing specs" + options.doc "\n $ mspec tag --del fails path/to/the_file_spec.rb" + options.doc "\n 3. To display the descriptions for all specs tagged with 'fails'" + options.doc "\n $ mspec tag --list fails path/to/the/specs" + options.doc "" + + patterns = options.parse argv + if patterns.empty? + puts options + puts "No files specified." + exit 1 + end + @files = files patterns + end + + def register + require 'mspec/runner/actions' + + case config[:tagger] + when :add, :del + tag = SpecTag.new config[:tag] + tagger = TagAction.new(config[:tagger], config[:outcome], tag.tag, tag.comment, + config[:atags], config[:astrings]) + when :list, :list_all + tagger = TagListAction.new config[:tagger] == :list_all ? nil : config[:ltags] + MSpec.register_mode :pretend + config[:formatter] = false + when :purge + tagger = TagPurgeAction.new + MSpec.register_mode :pretend + MSpec.register_mode :unguarded + config[:formatter] = false + else + raise ArgumentError, "No recognized action given" + end + tagger.register + + super + end + + def run + MSpec.register_tags_patterns config[:tags_patterns] + MSpec.register_files @files + + MSpec.process + exit MSpec.exit_code + end +end + diff --git a/spec/mspec/lib/mspec/commands/mspec.rb b/spec/mspec/lib/mspec/commands/mspec.rb new file mode 100755 index 0000000000..6f1ae8cb6e --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mspec.rb @@ -0,0 +1,163 @@ +#!/usr/bin/env ruby + +require 'mspec/version' +require 'mspec/utils/options' +require 'mspec/utils/script' +require 'mspec/helpers/tmp' +require 'mspec/runner/actions/filter' +require 'mspec/runner/actions/timer' + + +class MSpecMain < MSpecScript + def initialize + super + + config[:loadpath] = [] + config[:requires] = [] + config[:target] = ENV['RUBY'] || 'ruby' + config[:flags] = [] + config[:command] = nil + config[:options] = [] + config[:launch] = [] + end + + def options(argv=ARGV) + config[:command] = argv.shift if ["ci", "run", "tag"].include?(argv[0]) + + options = MSpecOptions.new "mspec [COMMAND] [options] (FILE|DIRECTORY|GLOB)+", 30, config + + options.doc " The mspec command sets up and invokes the sub-commands" + options.doc " (see below) to enable, for instance, running the specs" + options.doc " with different implementations like ruby, jruby, rbx, etc.\n" + + options.configure do |f| + load f + config[:options] << '-B' << f + end + + options.targets + + options.on("--warnings", "Don't supress warnings") do + config[:flags] << '-w' + ENV['OUTPUT_WARNINGS'] = '1' + end + + options.on("-j", "--multi", "Run multiple (possibly parallel) subprocesses") do + config[:multi] = true + config[:options] << "-fy" + end + + options.version MSpec::VERSION do + if config[:command] + config[:options] << "-v" + else + puts "#{File.basename $0} #{MSpec::VERSION}" + exit + end + end + + options.help do + if config[:command] + config[:options] << "-h" + else + puts options + exit 1 + end + end + + options.doc "\n Custom options" + custom_options options + + # The rest of the help output + options.doc "\n where COMMAND is one of:\n" + options.doc " run - Run the specified specs (default)" + options.doc " ci - Run the known good specs" + options.doc " tag - Add or remove tags\n" + options.doc " mspec COMMAND -h for more options\n" + options.doc " example: $ mspec run -h\n" + + options.on_extra { |o| config[:options] << o } + options.parse(argv) + + if config[:multi] + options = MSpecOptions.new "mspec", 30, config + options.all + patterns = options.parse(config[:options]) + @files = files_from_patterns(patterns) + end + end + + def register; end + + def multi_exec(argv) + MSpec.register_files @files + + require 'mspec/runner/formatters/multi' + formatter = MultiFormatter.new + + output_files = [] + processes = [cores, @files.size].min + children = processes.times.map { |i| + name = tmp "mspec-multi-#{i}" + output_files << name + + env = { + "SPEC_TEMP_DIR" => "rubyspec_temp_#{i}", + "MSPEC_MULTI" => i.to_s + } + command = argv + ["-o", name] + $stderr.puts "$ #{command.join(' ')}" if $MSPEC_DEBUG + IO.popen([env, *command], "rb+") + } + + puts children.map { |child| child.gets }.uniq + formatter.start + + until @files.empty? + IO.select(children)[0].each { |io| + reply = io.read(1) + case reply + when '.' + formatter.unload + when nil + raise "Worker died!" + else + while chunk = (io.read_nonblock(4096) rescue nil) + reply += chunk + end + raise reply + end + io.puts @files.shift unless @files.empty? + } + end + + ok = true + children.each { |child| + child.puts "QUIT" + Process.wait(child.pid) + ok &&= $?.success? + } + + formatter.aggregate_results(output_files) + formatter.finish + ok + end + + def run + argv = config[:target].split(/\s+/) + + argv.concat config[:launch] + argv.concat config[:flags] + argv.concat config[:loadpath] + argv.concat config[:requires] + argv << "#{MSPEC_HOME}/bin/mspec-#{ config[:command] || "run" }" + argv.concat config[:options] + + if config[:multi] + exit multi_exec(argv) + else + $stderr.puts "$ #{argv.join(' ')}" + exec(*argv) + end + end +end diff --git a/spec/mspec/lib/mspec/expectations.rb b/spec/mspec/lib/mspec/expectations.rb new file mode 100644 index 0000000000..d07f959b27 --- /dev/null +++ b/spec/mspec/lib/mspec/expectations.rb @@ -0,0 +1,2 @@ +require 'mspec/expectations/expectations' +require 'mspec/expectations/should' diff --git a/spec/mspec/lib/mspec/expectations/expectations.rb b/spec/mspec/lib/mspec/expectations/expectations.rb new file mode 100644 index 0000000000..cfdc2b63a3 --- /dev/null +++ b/spec/mspec/lib/mspec/expectations/expectations.rb @@ -0,0 +1,21 @@ +class SpecExpectationNotMetError < StandardError +end + +class SpecExpectationNotFoundError < StandardError + def message + "No behavior expectation was found in the example" + end +end + +class SpecExpectation + def self.fail_with(expected, actual) + expected_to_s = expected.to_s + actual_to_s = actual.to_s + if expected_to_s.size + actual_to_s.size > 80 + message = "#{expected_to_s.chomp}\n#{actual_to_s}" + else + message = "#{expected_to_s} #{actual_to_s}" + end + Kernel.raise SpecExpectationNotMetError, message + end +end diff --git a/spec/mspec/lib/mspec/expectations/should.rb b/spec/mspec/lib/mspec/expectations/should.rb new file mode 100644 index 0000000000..f6d83053f5 --- /dev/null +++ b/spec/mspec/lib/mspec/expectations/should.rb @@ -0,0 +1,29 @@ +class Object + NO_MATCHER_GIVEN = Object.new + + def should(matcher = NO_MATCHER_GIVEN) + MSpec.expectation + MSpec.actions :expectation, MSpec.current.state + unless matcher.equal? NO_MATCHER_GIVEN + unless matcher.matches? self + expected, actual = matcher.failure_message + SpecExpectation.fail_with(expected, actual) + end + else + SpecPositiveOperatorMatcher.new(self) + end + end + + def should_not(matcher = NO_MATCHER_GIVEN) + MSpec.expectation + MSpec.actions :expectation, MSpec.current.state + unless matcher.equal? NO_MATCHER_GIVEN + if matcher.matches? self + expected, actual = matcher.negative_failure_message + SpecExpectation.fail_with(expected, actual) + end + else + SpecNegativeOperatorMatcher.new(self) + end + end +end diff --git a/spec/mspec/lib/mspec/guards.rb b/spec/mspec/lib/mspec/guards.rb new file mode 100644 index 0000000000..0d7d300c64 --- /dev/null +++ b/spec/mspec/lib/mspec/guards.rb @@ -0,0 +1,12 @@ +require 'mspec/utils/ruby_name' +require 'mspec/guards/block_device' +require 'mspec/guards/bug' +require 'mspec/guards/conflict' +require 'mspec/guards/endian' +require 'mspec/guards/feature' +require 'mspec/guards/guard' +require 'mspec/guards/platform' +require 'mspec/guards/quarantine' +require 'mspec/guards/support' +require 'mspec/guards/superuser' +require 'mspec/guards/version' diff --git a/spec/mspec/lib/mspec/guards/block_device.rb b/spec/mspec/lib/mspec/guards/block_device.rb new file mode 100644 index 0000000000..327f6e564e --- /dev/null +++ b/spec/mspec/lib/mspec/guards/block_device.rb @@ -0,0 +1,18 @@ +require 'mspec/guards/guard' + +class BlockDeviceGuard < SpecGuard + def match? + platform_is_not :freebsd, :windows, :opal do + block = `find /dev /devices -type b 2> /dev/null` + return !(block.nil? || block.empty?) + end + + false + end +end + +class Object + def with_block_device(&block) + BlockDeviceGuard.new.run_if(:with_block_device, &block) + end +end diff --git a/spec/mspec/lib/mspec/guards/bug.rb b/spec/mspec/lib/mspec/guards/bug.rb new file mode 100644 index 0000000000..31de6e080d --- /dev/null +++ b/spec/mspec/lib/mspec/guards/bug.rb @@ -0,0 +1,30 @@ +require 'mspec/guards/version' + +class BugGuard < VersionGuard + def initialize(bug, version) + @bug = bug + if String === version + MSpec.deprecate "ruby_bug with a single version", 'an exclusive range ("2.1"..."2.3")' + @version = SpecVersion.new version, true + else + super(version) + end + @parameters = [@bug, @version] + end + + def match? + return false if MSpec.mode? :no_ruby_bug + return false unless PlatformGuard.standard? + if Range === @version + super + else + FULL_RUBY_VERSION <= @version + end + end +end + +class Object + def ruby_bug(bug, version, &block) + BugGuard.new(bug, version).run_unless(:ruby_bug, &block) + end +end diff --git a/spec/mspec/lib/mspec/guards/conflict.rb b/spec/mspec/lib/mspec/guards/conflict.rb new file mode 100644 index 0000000000..c1d33e3512 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/conflict.rb @@ -0,0 +1,19 @@ +require 'mspec/guards/guard' + +class ConflictsGuard < SpecGuard + def match? + # Always convert constants to symbols regardless of version. + constants = Object.constants.map { |x| x.to_sym } + @parameters.any? { |mod| constants.include? mod } + end +end + +class Object + # In some cases, libraries will modify another Ruby method's + # behavior. The specs for the method's behavior will then fail + # if that library is loaded. This guard will not run if any of + # the specified constants exist in Object.constants. + def conflicts_with(*modules, &block) + ConflictsGuard.new(*modules).run_unless(:conflicts_with, &block) + end +end diff --git a/spec/mspec/lib/mspec/guards/endian.rb b/spec/mspec/lib/mspec/guards/endian.rb new file mode 100644 index 0000000000..6bb01263c7 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/endian.rb @@ -0,0 +1,27 @@ +require 'mspec/guards/guard' + +# Despite that these are inverses, the two classes are +# used to simplify MSpec guard reporting modes + +class EndianGuard < SpecGuard + def pattern + @pattern ||= [1].pack('L') + end + private :pattern +end + +class BigEndianGuard < EndianGuard + def match? + pattern[-1] == ?\001 + end +end + +class Object + def big_endian(&block) + BigEndianGuard.new.run_if(:big_endian, &block) + end + + def little_endian(&block) + BigEndianGuard.new.run_unless(:little_endian, &block) + end +end diff --git a/spec/mspec/lib/mspec/guards/feature.rb b/spec/mspec/lib/mspec/guards/feature.rb new file mode 100644 index 0000000000..346212bda0 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/feature.rb @@ -0,0 +1,43 @@ +require 'mspec/guards/guard' + +class FeatureGuard < SpecGuard + def self.enabled?(*features) + new(*features).match? + end + + def match? + @parameters.all? { |f| MSpec.feature_enabled? f } + end +end + +class Object + # Provides better documentation in the specs by + # naming sets of features that work together as + # a whole. Examples include :encoding, :fiber, + # :continuation, :fork. + # + # Usage example: + # + # with_feature :encoding do + # # specs for a method that provides aspects + # # of the encoding feature + # end + # + # Multiple features must all be enabled for the + # guard to run: + # + # with_feature :one, :two do + # # these specs will run if features :one AND + # # :two are enabled. + # end + # + # The implementation must explicitly enable a feature + # by adding code like the following to the .mspec + # configuration file: + # + # MSpec.enable_feature :encoding + # + def with_feature(*features, &block) + FeatureGuard.new(*features).run_if(:with_feature, &block) + end +end diff --git a/spec/mspec/lib/mspec/guards/guard.rb b/spec/mspec/lib/mspec/guards/guard.rb new file mode 100644 index 0000000000..c95d8f7923 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/guard.rb @@ -0,0 +1,118 @@ +require 'mspec/runner/mspec' +require 'mspec/runner/actions/tally' +require 'mspec/utils/ruby_name' + +class SpecGuard + def self.report + @report ||= Hash.new { |h,k| h[k] = [] } + end + + def self.clear + @report = nil + end + + def self.finish + report.keys.sort.each do |key| + desc = report[key] + size = desc.size + spec = size == 1 ? "spec" : "specs" + print "\n\n#{size} #{spec} omitted by guard: #{key}:\n" + desc.each { |description| print "\n", description; } + end + + print "\n\n" + end + + def self.guards + @guards ||= [] + end + + def self.clear_guards + @guards = [] + end + + # Returns a partial Ruby version string based on +which+. + # For example, if RUBY_VERSION = 8.2.3: + # + # :major => "8" + # :minor => "8.2" + # :tiny => "8.2.3" + # :teeny => "8.2.3" + # :full => "8.2.3" + def self.ruby_version(which = :minor) + case which + when :major + n = 1 + when :minor + n = 2 + when :tiny, :teeny, :full + n = 3 + end + + RUBY_VERSION.split('.')[0,n].join('.') + end + + attr_accessor :name + + def initialize(*args) + @parameters = args + end + + def yield?(invert = false) + return true if MSpec.mode? :unguarded + + allow = match? ^ invert + + if !allow and reporting? + MSpec.guard + MSpec.register :finish, SpecGuard + MSpec.register :add, self + return true + elsif MSpec.mode? :verify + return true + end + + allow + end + + def run_if(name, &block) + @name = name + yield if yield?(false) + ensure + unregister + end + + def run_unless(name, &block) + @name = name + yield if yield?(true) + ensure + unregister + end + + def reporting? + MSpec.mode?(:report) or + (MSpec.mode?(:report_on) and SpecGuard.guards.include?(name)) + end + + def report_key + "#{name} #{@parameters.join(", ")}" + end + + def record(description) + SpecGuard.report[report_key] << description + end + + def add(example) + record example.description + MSpec.retrieve(:formatter).tally.counter.guards! + end + + def unregister + MSpec.unguard + MSpec.unregister :add, self + end + + def match? + raise "must be implemented by the subclass" + end +end diff --git a/spec/mspec/lib/mspec/guards/platform.rb b/spec/mspec/lib/mspec/guards/platform.rb new file mode 100644 index 0000000000..875aef6c9c --- /dev/null +++ b/spec/mspec/lib/mspec/guards/platform.rb @@ -0,0 +1,78 @@ +require 'mspec/guards/guard' + +class PlatformGuard < SpecGuard + def self.implementation?(*args) + args.any? do |name| + case name + when :rubinius + RUBY_NAME.start_with?('rbx') + when :ruby, :jruby, :truffleruby, :ironruby, :macruby, :maglev, :topaz, :opal + RUBY_NAME.start_with?(name.to_s) + else + raise "unknown implementation #{name}" + end + end + end + + def self.standard? + implementation? :ruby + end + + HOST_OS = begin + require 'rbconfig' + RbConfig::CONFIG['host_os'] || RUBY_PLATFORM + rescue LoadError + RUBY_PLATFORM + end.downcase + + def self.os?(*oses) + oses.any? do |os| + raise ":java is not a valid OS" if os == :java + if os == :windows + HOST_OS =~ /(mswin|mingw)/ + else + HOST_OS.include?(os.to_s) + end + end + end + + def self.windows? + os?(:windows) + end + + def self.wordsize?(size) + size == 8 * 1.size + end + + def initialize(*args) + if args.last.is_a?(Hash) + @options, @platforms = args.last, args[0..-2] + else + @options, @platforms = {}, args + end + @parameters = args + end + + def match? + match = @platforms.empty? ? true : PlatformGuard.os?(*@platforms) + @options.each do |key, value| + case key + when :os + match &&= PlatformGuard.os?(*value) + when :wordsize + match &&= PlatformGuard.wordsize? value + end + end + match + end +end + +class Object + def platform_is(*args, &block) + PlatformGuard.new(*args).run_if(:platform_is, &block) + end + + def platform_is_not(*args, &block) + PlatformGuard.new(*args).run_unless(:platform_is_not, &block) + end +end diff --git a/spec/mspec/lib/mspec/guards/quarantine.rb b/spec/mspec/lib/mspec/guards/quarantine.rb new file mode 100644 index 0000000000..4724613a0f --- /dev/null +++ b/spec/mspec/lib/mspec/guards/quarantine.rb @@ -0,0 +1,13 @@ +require 'mspec/guards/guard' + +class QuarantineGuard < SpecGuard + def match? + true + end +end + +class Object + def quarantine!(&block) + QuarantineGuard.new.run_unless(:quarantine!, &block) + end +end diff --git a/spec/mspec/lib/mspec/guards/superuser.rb b/spec/mspec/lib/mspec/guards/superuser.rb new file mode 100644 index 0000000000..6e447198a7 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/superuser.rb @@ -0,0 +1,17 @@ +require 'mspec/guards/guard' + +class SuperUserGuard < SpecGuard + def match? + Process.euid == 0 + end +end + +class Object + def as_superuser(&block) + SuperUserGuard.new.run_if(:as_superuser, &block) + end + + def as_user(&block) + SuperUserGuard.new.run_unless(:as_user, &block) + end +end diff --git a/spec/mspec/lib/mspec/guards/support.rb b/spec/mspec/lib/mspec/guards/support.rb new file mode 100644 index 0000000000..f1760ece2e --- /dev/null +++ b/spec/mspec/lib/mspec/guards/support.rb @@ -0,0 +1,16 @@ +require 'mspec/guards/platform' + +class SupportedGuard < SpecGuard + def match? + if @parameters.include? :ruby + raise Exception, "improper use of not_supported_on guard" + end + !PlatformGuard.standard? and PlatformGuard.implementation?(*@parameters) + end +end + +class Object + def not_supported_on(*args, &block) + SupportedGuard.new(*args).run_unless(:not_supported_on, &block) + end +end diff --git a/spec/mspec/lib/mspec/guards/version.rb b/spec/mspec/lib/mspec/guards/version.rb new file mode 100644 index 0000000000..110853e082 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/version.rb @@ -0,0 +1,39 @@ +require 'mspec/utils/deprecate' +require 'mspec/utils/version' +require 'mspec/guards/guard' + +class VersionGuard < SpecGuard + FULL_RUBY_VERSION = SpecVersion.new SpecGuard.ruby_version(:full) + + def initialize(version) + case version + when String + @version = SpecVersion.new version + when Range + MSpec.deprecate "an empty version range end", 'a specific version' if version.end.empty? + a = SpecVersion.new version.begin + b = SpecVersion.new version.end + unless version.exclude_end? + MSpec.deprecate "ruby_version_is with an inclusive range", 'an exclusive range ("2.1"..."2.3")' + end + @version = version.exclude_end? ? a...b : a..b + else + raise "version must be a String or Range but was a #{version.class}" + end + @parameters = [version] + end + + def match? + if Range === @version + @version.include? FULL_RUBY_VERSION + else + FULL_RUBY_VERSION >= @version + end + end +end + +class Object + def ruby_version_is(*args, &block) + VersionGuard.new(*args).run_if(:ruby_version_is, &block) + end +end diff --git a/spec/mspec/lib/mspec/helpers.rb b/spec/mspec/lib/mspec/helpers.rb new file mode 100644 index 0000000000..f2d1c9fb21 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers.rb @@ -0,0 +1,12 @@ +require 'mspec/helpers/argf' +require 'mspec/helpers/argv' +require 'mspec/helpers/datetime' +require 'mspec/helpers/fixture' +require 'mspec/helpers/flunk' +require 'mspec/helpers/fs' +require 'mspec/helpers/io' +require 'mspec/helpers/mock_to_path' +require 'mspec/helpers/numeric' +require 'mspec/helpers/ruby_exe' +require 'mspec/helpers/scratch' +require 'mspec/helpers/tmp' diff --git a/spec/mspec/lib/mspec/helpers/argf.rb b/spec/mspec/lib/mspec/helpers/argf.rb new file mode 100644 index 0000000000..1ba48b9378 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/argf.rb @@ -0,0 +1,37 @@ +class Object + # Convenience helper for specs using ARGF. + # Set @argf to an instance of ARGF.class with the given +argv+. + # That instance must be used instead of ARGF as ARGF is global + # and it is not always possible to reset its state correctly. + # + # The helper yields to the block and then close + # the files open by the instance. Example: + # + # describe "That" do + # it "does something" do + # argf ['a', 'b'] do + # # do something + # end + # end + # end + def argf(argv) + if argv.empty? or argv.length > 2 + raise "Only 1 or 2 filenames are allowed for the argf helper so files can be properly closed: #{argv.inspect}" + end + @argf ||= nil + raise "Cannot nest calls to the argf helper" if @argf + + @argf = ARGF.class.new(*argv) + @__mspec_saved_argf_file__ = @argf.file + begin + yield + ensure + file1 = @__mspec_saved_argf_file__ + file2 = @argf.file # Either the first file or the second + file1.close if !file1.closed? and file1 != STDIN + file2.close if !file2.closed? and file2 != STDIN + @argf = nil + @__mspec_saved_argf_file__ = nil + end + end +end diff --git a/spec/mspec/lib/mspec/helpers/argv.rb b/spec/mspec/lib/mspec/helpers/argv.rb new file mode 100644 index 0000000000..c8cbbf2ac3 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/argv.rb @@ -0,0 +1,46 @@ +class Object + # Convenience helper for altering ARGV. Saves the + # value of ARGV and sets it to +args+. If a block + # is given, yields to the block and then restores + # the value of ARGV. The previously saved value of + # ARGV can be restored by passing +:restore+. The + # former is useful in a single spec. The latter is + # useful in before/after actions. For example: + # + # describe "This" do + # before do + # argv ['a', 'b'] + # end + # + # after do + # argv :restore + # end + # + # it "does something" do + # # do something + # end + # end + # + # describe "That" do + # it "does something" do + # argv ['a', 'b'] do + # # do something + # end + # end + # end + def argv(args) + if args == :restore + ARGV.replace(@__mspec_saved_argv__ || []) + else + @__mspec_saved_argv__ = ARGV.dup + ARGV.replace args + if block_given? + begin + yield + ensure + argv :restore + end + end + end + end +end diff --git a/spec/mspec/lib/mspec/helpers/datetime.rb b/spec/mspec/lib/mspec/helpers/datetime.rb new file mode 100644 index 0000000000..4cb57bdaa1 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/datetime.rb @@ -0,0 +1,51 @@ +class Object + # The new_datetime helper makes writing DateTime specs more simple by + # providing default constructor values and accepting a Hash of only the + # constructor values needed for the particular spec. For example: + # + # new_datetime :hour => 1, :minute => 20 + # + # Possible keys are: + # :year, :month, :day, :hour, :minute, :second, :offset and :sg. + + def new_datetime(opts={}) + require 'date' + + value = { + :year => -4712, + :month => 1, + :day => 1, + :hour => 0, + :minute => 0, + :second => 0, + :offset => 0, + :sg => Date::ITALY + }.merge opts + + DateTime.new value[:year], value[:month], value[:day], value[:hour], + value[:minute], value[:second], value[:offset], value[:sg] + end + + def with_timezone(name, offset = nil, daylight_saving_zone = "") + zone = name.dup + + if offset + # TZ convention is backwards + offset = -offset + + zone += offset.to_s + zone += ":00:00" + end + zone += daylight_saving_zone + + old = ENV["TZ"] + ENV["TZ"] = zone + + begin + yield + ensure + ENV["TZ"] = old + end + end + +end diff --git a/spec/mspec/lib/mspec/helpers/fixture.rb b/spec/mspec/lib/mspec/helpers/fixture.rb new file mode 100644 index 0000000000..718c1b7a94 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/fixture.rb @@ -0,0 +1,26 @@ +class Object + # Returns the name of a fixture file by adjoining the directory + # of the +file+ argument with "fixtures" and the contents of the + # +args+ array. For example, + # + # +file+ == "some/example_spec.rb" + # + # and + # + # +args+ == ["subdir", "file.txt"] + # + # then the result is the expanded path of + # + # "some/fixtures/subdir/file.txt". + def fixture(file, *args) + path = File.dirname(file) + path = path[0..-7] if path[-7..-1] == "/shared" + fixtures = path[-9..-1] == "/fixtures" ? "" : "fixtures" + if File.respond_to?(:realpath) + path = File.realpath(path) + else + path = File.expand_path(path) + end + File.join(path, fixtures, args) + end +end diff --git a/spec/mspec/lib/mspec/helpers/flunk.rb b/spec/mspec/lib/mspec/helpers/flunk.rb new file mode 100644 index 0000000000..35bd939b85 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/flunk.rb @@ -0,0 +1,5 @@ +class Object + def flunk(msg="This example is a failure") + SpecExpectation.fail_with "Failed:", msg + end +end diff --git a/spec/mspec/lib/mspec/helpers/fs.rb b/spec/mspec/lib/mspec/helpers/fs.rb new file mode 100644 index 0000000000..ee33f5fec0 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/fs.rb @@ -0,0 +1,62 @@ +class Object + # Copies a file + def cp(source, dest) + File.open(dest, "w") do |d| + File.open(source, "r") do |s| + while data = s.read(1024) + d.write data + end + end + end + end + + # Creates each directory in path that does not exist. + def mkdir_p(path) + parts = File.expand_path(path).split %r[/|\\] + name = parts.shift + parts.each do |part| + name = File.join name, part + + if File.file? name + raise ArgumentError, "path component of #{path} is a file" + end + + Dir.mkdir name unless File.directory? name + end + end + + # Recursively removes all files and directories in +path+ + # if +path+ is a directory. Removes the file if +path+ is + # a file. + def rm_r(*paths) + paths.each do |path| + path = File.expand_path path + + prefix = SPEC_TEMP_DIR + unless path[0, prefix.size] == prefix + raise ArgumentError, "#{path} is not prefixed by #{prefix}" + end + + # File.symlink? needs to be checked first as + # File.exist? returns false for dangling symlinks + if File.symlink? path + File.unlink path + elsif File.directory? path + Dir.entries(path).each { |x| rm_r "#{path}/#{x}" unless x =~ /^\.\.?$/ } + Dir.rmdir path + elsif File.exist? path + File.delete path + end + end + end + + # Creates a file +name+. Creates the directory for +name+ + # if it does not exist. + def touch(name, mode="w") + mkdir_p File.dirname(name) + + File.open(name, mode) do |f| + yield f if block_given? + end + end +end diff --git a/spec/mspec/lib/mspec/helpers/io.rb b/spec/mspec/lib/mspec/helpers/io.rb new file mode 100644 index 0000000000..83d14441a7 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/io.rb @@ -0,0 +1,113 @@ +require 'mspec/guards/feature' + +class IOStub + def initialize + @buffer = [] + @output = '' + end + + def write(*str) + self << str.join + end + + def << str + @buffer << str + self + end + + def print(*str) + write(str.join + $\.to_s) + end + + def method_missing(name, *args, &block) + to_s.send(name, *args, &block) + end + + def == other + to_s == other + end + + def =~ other + to_s =~ other + end + + def puts(*str) + if str.empty? + write "\n" + else + write(str.collect { |s| s.to_s.chomp }.concat([nil]).join("\n")) + end + end + + def printf(format, *args) + self << sprintf(format, *args) + end + + def flush + @output += @buffer.join('') + @buffer.clear + self + end + + def to_s + flush + @output + end + + alias_method :to_str, :to_s + + def inspect + to_s.inspect + end +end + +class Object + # Creates a "bare" file descriptor (i.e. one that is not associated + # with any Ruby object). The file descriptor can safely be passed + # to IO.new without creating a Ruby object alias to the fd. + def new_fd(name, mode="w:utf-8") + mode = options_or_mode(mode) + + if mode.kind_of? Hash + if mode.key? :mode + mode = mode[:mode] + else + raise ArgumentError, "new_fd options Hash must include :mode" + end + end + + IO.sysopen name, fmode(mode) + end + + # Creates an IO instance for a temporary file name. The file + # must be deleted. + def new_io(name, mode="w:utf-8") + IO.new new_fd(name, options_or_mode(mode)), options_or_mode(mode) + end + + # This helper simplifies passing file access modes regardless of + # whether the :encoding feature is enabled. Only the access specifier + # itself will be returned if :encoding is not enabled. Otherwise, + # the full mode string will be returned (i.e. the helper is a no-op). + def fmode(mode) + if FeatureGuard.enabled? :encoding + mode + else + mode.split(':').first + end + end + + # This helper simplifies passing file access modes or options regardless of + # whether the :encoding feature is enabled. Only the access specifier itself + # will be returned if :encoding is not enabled. Otherwise, the full mode + # string or option will be returned (i.e. the helper is a no-op). + def options_or_mode(oom) + return fmode(oom) if oom.kind_of? String + + if FeatureGuard.enabled? :encoding + oom + else + fmode(oom[:mode] || "r:utf-8") + end + end +end diff --git a/spec/mspec/lib/mspec/helpers/mock_to_path.rb b/spec/mspec/lib/mspec/helpers/mock_to_path.rb new file mode 100644 index 0000000000..683bb1d9d6 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/mock_to_path.rb @@ -0,0 +1,8 @@ +class Object + def mock_to_path(path) + # Cannot use our Object#mock here since it conflicts with RSpec + obj = MockObject.new('path') + obj.should_receive(:to_path).and_return(path) + obj + end +end diff --git a/spec/mspec/lib/mspec/helpers/numeric.rb b/spec/mspec/lib/mspec/helpers/numeric.rb new file mode 100644 index 0000000000..ff30cf2b83 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/numeric.rb @@ -0,0 +1,72 @@ +require 'mspec/guards/platform' + +class Object + def nan_value + 0/0.0 + end + + def infinity_value + 1/0.0 + end + + def bignum_value(plus=0) + 0x8000_0000_0000_0000 + plus + end + + # This is a bit hairy, but we need to be able to write specs that cover the + # boundary between Fixnum and Bignum for operations like Fixnum#<<. Since + # this boundary is implementation-dependent, we use these helpers to write + # specs based on the relationship between values rather than specific + # values. + if PlatformGuard.standard? or PlatformGuard.implementation? :topaz + if PlatformGuard.wordsize? 32 + def fixnum_max + (2**30) - 1 + end + + def fixnum_min + -(2**30) + end + elsif PlatformGuard.wordsize? 64 + def fixnum_max + (2**62) - 1 + end + + def fixnum_min + -(2**62) + end + end + elsif PlatformGuard.implementation? :opal + def fixnum_max + Integer::MAX + end + + def fixnum_min + Integer::MIN + end + elsif PlatformGuard.implementation? :rubinius + def fixnum_max + Fixnum::MAX + end + + def fixnum_min + Fixnum::MIN + end + elsif PlatformGuard.implementation?(:jruby) || PlatformGuard.implementation?(:truffleruby) + def fixnum_max + 9223372036854775807 + end + + def fixnum_min + -9223372036854775808 + end + else + def fixnum_max + raise "unknown implementation for fixnum_max() helper" + end + + def fixnum_min + raise "unknown implementation for fixnum_min() helper" + end + end +end diff --git a/spec/mspec/lib/mspec/helpers/ruby_exe.rb b/spec/mspec/lib/mspec/helpers/ruby_exe.rb new file mode 100644 index 0000000000..a025be6c81 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/ruby_exe.rb @@ -0,0 +1,178 @@ +require 'mspec/utils/ruby_name' +require 'mspec/guards/platform' +require 'mspec/helpers/tmp' + +# The ruby_exe helper provides a wrapper for invoking the +# same Ruby interpreter with the same falgs as the one running +# the specs and getting the output from running the code. +# If +code+ is a file that exists, it will be run. +# Otherwise, +code+ should be Ruby code that will be run with +# the -e command line option. For example: +# +# ruby_exe('path/to/some/file.rb') +# +# will be executed as +# +# `#{RUBY_EXE} 'path/to/some/file.rb'` +# +# while +# +# ruby_exe('puts "hello, world."') +# +# will be executed as +# +# `#{RUBY_EXE} -e 'puts "hello, world."'` +# +# The ruby_exe helper also accepts an options hash with three +# keys: :options, :args and :env. For example: +# +# ruby_exe('file.rb', :options => "-w", +# :args => "> file.txt", +# :env => { :FOO => "bar" }) +# +# will be executed as +# +# `#{RUBY_EXE} -w #{'file.rb'} > file.txt` +# +# with access to ENV["FOO"] with value "bar". +# +# If +nil+ is passed for the first argument, the command line +# will be built only from the options hash. +# +# The RUBY_EXE constant is setup by mspec automatically +# and is used by ruby_exe and ruby_cmd. The mspec runner script +# will set ENV['RUBY_EXE'] to the name of the executable used +# to invoke the mspec-run script. The value of RUBY_EXE will be +# constructed as follows: +# +# 1. the value of ENV['RUBY_EXE'] +# 2. an explicit value based on RUBY_NAME +# 3. cwd/(RUBY_NAME + $(EXEEXT) || $(exeext) || '') +# 4. $(bindir)/$(RUBY_INSTALL_NAME) +# +# The value will only be used if the file exists and is executable. +# The flags will then be appended to the resulting value. +# +# These 4 ways correspond to the following scenarios: +# +# 1. Using the MSpec runner scripts, the name of the +# executable is explicitly passed by ENV['RUBY_EXE'] +# so there is no ambiguity. +# +# Otherwise, if using RSpec (or something else) +# +# 2. Running the specs while developing an alternative +# Ruby implementation. This explicitly names the +# executable in the development directory based on +# the value of RUBY_NAME, which is probably initialized +# from the value of RUBY_ENGINE. +# 3. Running the specs within the source directory for +# some implementation. (E.g. a local build directory.) +# 4. Running the specs against some installed Ruby +# implementation. +# +# Additionally, the flags passed to mspec +# (with -T on the command line or in the config with set :flags) +# will be appended to RUBY_EXE so that the interpreter +# is always called with those flags. + +class Object + def ruby_exe_options(option) + case option + when :env + ENV['RUBY_EXE'] + when :engine + case RUBY_NAME + when 'rbx' + "bin/rbx" + when 'jruby' + "bin/jruby" + when 'maglev' + "maglev-ruby" + when 'topaz' + "topaz" + when 'ironruby' + "ir" + end + when :name + require 'rbconfig' + bin = RUBY_NAME + (RbConfig::CONFIG['EXEEXT'] || RbConfig::CONFIG['exeext'] || '') + File.join(".", bin) + when :install_name + require 'rbconfig' + bin = RbConfig::CONFIG["RUBY_INSTALL_NAME"] || RbConfig::CONFIG["ruby_install_name"] + bin << (RbConfig::CONFIG['EXEEXT'] || RbConfig::CONFIG['exeext'] || '') + File.join(RbConfig::CONFIG['bindir'], bin) + end + end + + def resolve_ruby_exe + [:env, :engine, :name, :install_name].each do |option| + next unless exe = ruby_exe_options(option) + + if File.file?(exe) and File.executable?(exe) + exe = File.expand_path(exe) + exe = exe.tr('/', '\\') if PlatformGuard.windows? + flags = ENV['RUBY_FLAGS'] + if flags and !flags.empty? + return exe + ' ' + flags + else + return exe + end + end + end + raise Exception, "Unable to find a suitable ruby executable." + end + + def ruby_exe(code, opts = {}) + if opts[:dir] + raise "ruby_exe(..., dir: dir) is no longer supported, use Dir.chdir" + end + + env = opts[:env] || {} + saved_env = {} + env.each do |key, value| + key = key.to_s + saved_env[key] = ENV[key] if ENV.key? key + ENV[key] = value + end + + escape = opts.delete(:escape) + if code and !File.exist?(code) and escape != false + tmpfile = tmp("rubyexe.rb") + File.open(tmpfile, "w") { |f| f.write(code) } + code = tmpfile + end + + begin + platform_is_not :opal do + `#{ruby_cmd(code, opts)}` + end + ensure + saved_env.each { |key, value| ENV[key] = value } + env.keys.each do |key| + key = key.to_s + ENV.delete key unless saved_env.key? key + end + File.delete tmpfile if tmpfile + end + end + + def ruby_cmd(code, opts = {}) + body = code + + if opts[:escape] + raise "escape: true is no longer supported in ruby_cmd, use ruby_exe or a fixture" + end + + if code and !File.exist?(code) + body = "-e #{code.inspect}" + end + + [RUBY_EXE, opts[:options], body, opts[:args]].compact.join(' ') + end + + unless Object.const_defined?(:RUBY_EXE) and RUBY_EXE + RUBY_EXE = resolve_ruby_exe + end +end diff --git a/spec/mspec/lib/mspec/helpers/scratch.rb b/spec/mspec/lib/mspec/helpers/scratch.rb new file mode 100644 index 0000000000..a6b0c02748 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/scratch.rb @@ -0,0 +1,17 @@ +module ScratchPad + def self.clear + @record = nil + end + + def self.record(arg) + @record = arg + end + + def self.<<(arg) + @record << arg + end + + def self.recorded + @record + end +end diff --git a/spec/mspec/lib/mspec/helpers/tmp.rb b/spec/mspec/lib/mspec/helpers/tmp.rb new file mode 100644 index 0000000000..742eb57fdc --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/tmp.rb @@ -0,0 +1,45 @@ +# Creates a temporary directory in the current working directory +# for temporary files created while running the specs. All specs +# should clean up any temporary files created so that the temp +# directory is empty when the process exits. + +SPEC_TEMP_DIR = File.expand_path(ENV["SPEC_TEMP_DIR"] || "rubyspec_temp") + +SPEC_TEMP_UNIQUIFIER = "0" + +SPEC_TEMP_DIR_PID = Process.pid + +at_exit do + begin + if SPEC_TEMP_DIR_PID == Process.pid + Dir.delete SPEC_TEMP_DIR if File.directory? SPEC_TEMP_DIR + end + rescue SystemCallError + STDERR.puts <<-EOM + +----------------------------------------------------- +The rubyspec temp directory is not empty. Ensure that +all specs are cleaning up temporary files: + #{SPEC_TEMP_DIR} +----------------------------------------------------- + + EOM + rescue Object => e + STDERR.puts "failed to remove spec temp directory" + STDERR.puts e.message + end +end + +class Object + def tmp(name, uniquify=true) + Dir.mkdir SPEC_TEMP_DIR unless Dir.exist? SPEC_TEMP_DIR + + if uniquify and !name.empty? + slash = name.rindex "/" + index = slash ? slash + 1 : 0 + name.insert index, "#{SPEC_TEMP_UNIQUIFIER.succ!}-" + end + + File.join SPEC_TEMP_DIR, name + end +end diff --git a/spec/mspec/lib/mspec/matchers.rb b/spec/mspec/lib/mspec/matchers.rb new file mode 100644 index 0000000000..8eab73198a --- /dev/null +++ b/spec/mspec/lib/mspec/matchers.rb @@ -0,0 +1,35 @@ +require 'mspec/matchers/base' +require 'mspec/matchers/be_an_instance_of' +require 'mspec/matchers/be_ancestor_of' +require 'mspec/matchers/be_close' +require 'mspec/matchers/be_computed_by' +require 'mspec/matchers/be_empty' +require 'mspec/matchers/be_false' +require 'mspec/matchers/be_kind_of' +require 'mspec/matchers/be_nan' +require 'mspec/matchers/be_nil' +require 'mspec/matchers/be_true' +require 'mspec/matchers/be_true_or_false' +require 'mspec/matchers/complain' +require 'mspec/matchers/eql' +require 'mspec/matchers/equal' +require 'mspec/matchers/equal_element' +require 'mspec/matchers/have_constant' +require 'mspec/matchers/have_class_variable' +require 'mspec/matchers/have_instance_method' +require 'mspec/matchers/have_instance_variable' +require 'mspec/matchers/have_method' +require 'mspec/matchers/have_private_instance_method' +require 'mspec/matchers/have_private_method' +require 'mspec/matchers/have_protected_instance_method' +require 'mspec/matchers/have_public_instance_method' +require 'mspec/matchers/have_singleton_method' +require 'mspec/matchers/include' +require 'mspec/matchers/infinity' +require 'mspec/matchers/match_yaml' +require 'mspec/matchers/raise_error' +require 'mspec/matchers/output' +require 'mspec/matchers/output_to_fd' +require 'mspec/matchers/respond_to' +require 'mspec/matchers/signed_zero' +require 'mspec/matchers/block_caller' diff --git a/spec/mspec/lib/mspec/matchers/base.rb b/spec/mspec/lib/mspec/matchers/base.rb new file mode 100644 index 0000000000..30fb1f93dc --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/base.rb @@ -0,0 +1,95 @@ +class SpecPositiveOperatorMatcher + def initialize(actual) + @actual = actual + end + + def ==(expected) + unless @actual == expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "to equal #{expected.pretty_inspect}") + end + end + + def <(expected) + unless @actual < expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "to be less than #{expected.pretty_inspect}") + end + end + + def <=(expected) + unless @actual <= expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "to be less than or equal to #{expected.pretty_inspect}") + end + end + + def >(expected) + unless @actual > expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "to be greater than #{expected.pretty_inspect}") + end + end + + def >=(expected) + unless @actual >= expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "to be greater than or equal to #{expected.pretty_inspect}") + end + end + + def =~(expected) + unless @actual =~ expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "to match #{expected.pretty_inspect}") + end + end +end + +class SpecNegativeOperatorMatcher + def initialize(actual) + @actual = actual + end + + def ==(expected) + if @actual == expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "not to equal #{expected.pretty_inspect}") + end + end + + def <(expected) + if @actual < expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "not to be less than #{expected.pretty_inspect}") + end + end + + def <=(expected) + if @actual <= expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "not to be less than or equal to #{expected.pretty_inspect}") + end + end + + def >(expected) + if @actual > expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "not to be greater than #{expected.pretty_inspect}") + end + end + + def >=(expected) + if @actual >= expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "not to be greater than or equal to #{expected.pretty_inspect}") + end + end + + def =~(expected) + if @actual =~ expected + SpecExpectation.fail_with("Expected #{@actual.pretty_inspect}", + "not to match #{expected.pretty_inspect}") + end + end +end diff --git a/spec/mspec/lib/mspec/matchers/be_an_instance_of.rb b/spec/mspec/lib/mspec/matchers/be_an_instance_of.rb new file mode 100644 index 0000000000..6e31afcddd --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_an_instance_of.rb @@ -0,0 +1,26 @@ +class BeAnInstanceOfMatcher + def initialize(expected) + @expected = expected + end + + def matches?(actual) + @actual = actual + @actual.instance_of?(@expected) + end + + def failure_message + ["Expected #{@actual.inspect} (#{@actual.class})", + "to be an instance of #{@expected}"] + end + + def negative_failure_message + ["Expected #{@actual.inspect} (#{@actual.class})", + "not to be an instance of #{@expected}"] + end +end + +class Object + def be_an_instance_of(expected) + BeAnInstanceOfMatcher.new(expected) + end +end diff --git a/spec/mspec/lib/mspec/matchers/be_ancestor_of.rb b/spec/mspec/lib/mspec/matchers/be_ancestor_of.rb new file mode 100644 index 0000000000..792c64089a --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_ancestor_of.rb @@ -0,0 +1,24 @@ +class BeAncestorOfMatcher + def initialize(expected) + @expected = expected + end + + def matches?(actual) + @actual = actual + @expected.ancestors.include? @actual + end + + def failure_message + ["Expected #{@actual}", "to be an ancestor of #{@expected}"] + end + + def negative_failure_message + ["Expected #{@actual}", "not to be an ancestor of #{@expected}"] + end +end + +class Object + def be_ancestor_of(expected) + BeAncestorOfMatcher.new(expected) + end +end diff --git a/spec/mspec/lib/mspec/matchers/be_close.rb b/spec/mspec/lib/mspec/matchers/be_close.rb new file mode 100644 index 0000000000..5d79654099 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_close.rb @@ -0,0 +1,27 @@ +TOLERANCE = 0.00003 unless Object.const_defined?(:TOLERANCE) + +class BeCloseMatcher + def initialize(expected, tolerance) + @expected = expected + @tolerance = tolerance + end + + def matches?(actual) + @actual = actual + (@actual - @expected).abs < @tolerance + end + + def failure_message + ["Expected #{@expected}", "to be within +/- #{@tolerance} of #{@actual}"] + end + + def negative_failure_message + ["Expected #{@expected}", "not to be within +/- #{@tolerance} of #{@actual}"] + end +end + +class Object + def be_close(expected, tolerance) + BeCloseMatcher.new(expected, tolerance) + end +end diff --git a/spec/mspec/lib/mspec/matchers/be_computed_by.rb b/spec/mspec/lib/mspec/matchers/be_computed_by.rb new file mode 100644 index 0000000000..c927eb7697 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_computed_by.rb @@ -0,0 +1,37 @@ +class BeComputedByMatcher + def initialize(sym, *args) + @method = sym + @args = args + end + + def matches?(array) + array.each do |line| + @receiver = line.shift + @value = line.pop + @arguments = line + @arguments += @args + @actual = @receiver.send(@method, *@arguments) + return false unless @actual == @value + end + + return true + end + + def method_call + method_call = "#{@receiver.inspect}.#{@method}" + unless @arguments.empty? + method_call = "#{method_call} from #{@arguments.map { |x| x.inspect }.join(", ")}" + end + method_call + end + + def failure_message + ["Expected #{@value.inspect}", "to be computed by #{method_call} (computed #{@actual.inspect} instead)"] + end +end + +class Object + def be_computed_by(sym, *args) + BeComputedByMatcher.new(sym, *args) + end +end diff --git a/spec/mspec/lib/mspec/matchers/be_empty.rb b/spec/mspec/lib/mspec/matchers/be_empty.rb new file mode 100644 index 0000000000..8a401b63fd --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_empty.rb @@ -0,0 +1,20 @@ +class BeEmptyMatcher + def matches?(actual) + @actual = actual + @actual.empty? + end + + def failure_message + ["Expected #{@actual.inspect}", "to be empty"] + end + + def negative_failure_message + ["Expected #{@actual.inspect}", "not to be empty"] + end +end + +class Object + def be_empty + BeEmptyMatcher.new + end +end diff --git a/spec/mspec/lib/mspec/matchers/be_false.rb b/spec/mspec/lib/mspec/matchers/be_false.rb new file mode 100644 index 0000000000..0a6e8cfd63 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_false.rb @@ -0,0 +1,20 @@ +class BeFalseMatcher + def matches?(actual) + @actual = actual + @actual == false + end + + def failure_message + ["Expected #{@actual.inspect}", "to be false"] + end + + def negative_failure_message + ["Expected #{@actual.inspect}", "not to be false"] + end +end + +class Object + def be_false + BeFalseMatcher.new + end +end
\ No newline at end of file diff --git a/spec/mspec/lib/mspec/matchers/be_kind_of.rb b/spec/mspec/lib/mspec/matchers/be_kind_of.rb new file mode 100644 index 0000000000..a734f6159c --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_kind_of.rb @@ -0,0 +1,24 @@ +class BeKindOfMatcher + def initialize(expected) + @expected = expected + end + + def matches?(actual) + @actual = actual + @actual.is_a?(@expected) + end + + def failure_message + ["Expected #{@actual.inspect} (#{@actual.class})", "to be kind of #{@expected}"] + end + + def negative_failure_message + ["Expected #{@actual.inspect} (#{@actual.class})", "not to be kind of #{@expected}"] + end +end + +class Object + def be_kind_of(expected) + BeKindOfMatcher.new(expected) + end +end diff --git a/spec/mspec/lib/mspec/matchers/be_nan.rb b/spec/mspec/lib/mspec/matchers/be_nan.rb new file mode 100644 index 0000000000..aa19391211 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_nan.rb @@ -0,0 +1,20 @@ +class BeNaNMatcher + def matches?(actual) + @actual = actual + @actual.kind_of?(Float) && @actual.nan? + end + + def failure_message + ["Expected #{@actual}", "to be NaN"] + end + + def negative_failure_message + ["Expected #{@actual}", "not to be NaN"] + end +end + +class Object + def be_nan + BeNaNMatcher.new + end +end diff --git a/spec/mspec/lib/mspec/matchers/be_nil.rb b/spec/mspec/lib/mspec/matchers/be_nil.rb new file mode 100644 index 0000000000..ecea6feffa --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_nil.rb @@ -0,0 +1,20 @@ +class BeNilMatcher + def matches?(actual) + @actual = actual + @actual.nil? + end + + def failure_message + ["Expected #{@actual.inspect}", "to be nil"] + end + + def negative_failure_message + ["Expected #{@actual.inspect}", "not to be nil"] + end +end + +class Object + def be_nil + BeNilMatcher.new + end +end
\ No newline at end of file diff --git a/spec/mspec/lib/mspec/matchers/be_true.rb b/spec/mspec/lib/mspec/matchers/be_true.rb new file mode 100644 index 0000000000..de8e237d35 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_true.rb @@ -0,0 +1,20 @@ +class BeTrueMatcher + def matches?(actual) + @actual = actual + @actual == true + end + + def failure_message + ["Expected #{@actual.inspect}", "to be true"] + end + + def negative_failure_message + ["Expected #{@actual.inspect}", "not to be true"] + end +end + +class Object + def be_true + BeTrueMatcher.new + end +end
\ No newline at end of file diff --git a/spec/mspec/lib/mspec/matchers/be_true_or_false.rb b/spec/mspec/lib/mspec/matchers/be_true_or_false.rb new file mode 100644 index 0000000000..b2262779ed --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_true_or_false.rb @@ -0,0 +1,20 @@ +class BeTrueOrFalseMatcher + def matches?(actual) + @actual = actual + @actual == true || @actual == false + end + + def failure_message + ["Expected #{@actual.inspect}", "to be true or false"] + end + + def negative_failure_message + ["Expected #{@actual.inspect}", "not to be true or false"] + end +end + +class Object + def be_true_or_false + BeTrueOrFalseMatcher.new + end +end diff --git a/spec/mspec/lib/mspec/matchers/block_caller.rb b/spec/mspec/lib/mspec/matchers/block_caller.rb new file mode 100644 index 0000000000..5451950712 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/block_caller.rb @@ -0,0 +1,35 @@ +class BlockingMatcher + def matches?(block) + started = false + blocking = true + + thread = Thread.new do + started = true + block.call + + blocking = false + end + + while !started and status = thread.status and status != "sleep" + Thread.pass + end + thread.kill + thread.join + + blocking + end + + def failure_message + ['Expected the given Proc', 'to block the caller'] + end + + def negative_failure_message + ['Expected the given Proc', 'to not block the caller'] + end +end + +class Object + def block_caller(timeout = 0.1) + BlockingMatcher.new + end +end diff --git a/spec/mspec/lib/mspec/matchers/complain.rb b/spec/mspec/lib/mspec/matchers/complain.rb new file mode 100644 index 0000000000..1313215156 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/complain.rb @@ -0,0 +1,56 @@ +require 'mspec/helpers/io' + +class ComplainMatcher + def initialize(complaint) + @complaint = complaint + end + + def matches?(proc) + @saved_err = $stderr + @stderr = $stderr = IOStub.new + @verbose = $VERBOSE + $VERBOSE = false + + proc.call + + unless @complaint.nil? + case @complaint + when Regexp + return false unless $stderr =~ @complaint + else + return false unless $stderr == @complaint + end + end + + return $stderr.empty? ? false : true + ensure + $VERBOSE = @verbose + $stderr = @saved_err + end + + def failure_message + if @complaint.nil? + ["Expected a warning", "but received none"] + elsif @complaint.kind_of? Regexp + ["Expected warning to match: #{@complaint.inspect}", "but got: #{@stderr.chomp.inspect}"] + else + ["Expected warning: #{@complaint.inspect}", "but got: #{@stderr.chomp.inspect}"] + end + end + + def negative_failure_message + if @complaint.nil? + ["Unexpected warning: ", @stderr.chomp.inspect] + elsif @complaint.kind_of? Regexp + ["Expected warning not to match: #{@complaint.inspect}", "but got: #{@stderr.chomp.inspect}"] + else + ["Expected warning: #{@complaint.inspect}", "but got: #{@stderr.chomp.inspect}"] + end + end +end + +class Object + def complain(complaint=nil) + ComplainMatcher.new(complaint) + end +end diff --git a/spec/mspec/lib/mspec/matchers/eql.rb b/spec/mspec/lib/mspec/matchers/eql.rb new file mode 100644 index 0000000000..82117d862c --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/eql.rb @@ -0,0 +1,26 @@ +class EqlMatcher + def initialize(expected) + @expected = expected + end + + def matches?(actual) + @actual = actual + @actual.eql?(@expected) + end + + def failure_message + ["Expected #{@actual.pretty_inspect}", + "to have same value and type as #{@expected.pretty_inspect}"] + end + + def negative_failure_message + ["Expected #{@actual.pretty_inspect}", + "not to have same value or type as #{@expected.pretty_inspect}"] + end +end + +class Object + def eql(expected) + EqlMatcher.new(expected) + end +end diff --git a/spec/mspec/lib/mspec/matchers/equal.rb b/spec/mspec/lib/mspec/matchers/equal.rb new file mode 100644 index 0000000000..ee6431fd4f --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/equal.rb @@ -0,0 +1,26 @@ +class EqualMatcher + def initialize(expected) + @expected = expected + end + + def matches?(actual) + @actual = actual + @actual.equal?(@expected) + end + + def failure_message + ["Expected #{@actual.pretty_inspect}", + "to be identical to #{@expected.pretty_inspect}"] + end + + def negative_failure_message + ["Expected #{@actual.pretty_inspect}", + "not to be identical to #{@expected.pretty_inspect}"] + end +end + +class Object + def equal(expected) + EqualMatcher.new(expected) + end +end diff --git a/spec/mspec/lib/mspec/matchers/equal_element.rb b/spec/mspec/lib/mspec/matchers/equal_element.rb new file mode 100644 index 0000000000..8d032fd088 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/equal_element.rb @@ -0,0 +1,78 @@ +class EqualElementMatcher + def initialize(element, attributes = nil, content = nil, options = {}) + @element = element + @attributes = attributes + @content = content + @options = options + end + + def matches?(actual) + @actual = actual + + matched = true + + if @options[:not_closed] + matched &&= actual =~ /^#{Regexp.quote("<" + @element)}.*#{Regexp.quote(">" + (@content || ''))}$/ + else + matched &&= actual =~ /^#{Regexp.quote("<" + @element)}/ + matched &&= actual =~ /#{Regexp.quote("</" + @element + ">")}$/ + matched &&= actual =~ /#{Regexp.quote(">" + @content + "</")}/ if @content + end + + if @attributes + if @attributes.empty? + matched &&= actual.scan(/\w+\=\"(.*)\"/).size == 0 + else + @attributes.each do |key, value| + if value == true + matched &&= (actual.scan(/#{Regexp.quote(key)}(\s|>)/).size == 1) + else + matched &&= (actual.scan(%Q{ #{key}="#{value}"}).size == 1) + end + end + end + end + + !!matched + end + + def failure_message + ["Expected #{@actual.pretty_inspect}", + "to be a '#{@element}' element with #{attributes_for_failure_message} and #{content_for_failure_message}"] + end + + def negative_failure_message + ["Expected #{@actual.pretty_inspect}", + "not to be a '#{@element}' element with #{attributes_for_failure_message} and #{content_for_failure_message}"] + end + + def attributes_for_failure_message + if @attributes + if @attributes.empty? + "no attributes" + else + @attributes.inject([]) { |memo, n| memo << %Q{#{n[0]}="#{n[1]}"} }.join(" ") + end + else + "any attributes" + end + end + + def content_for_failure_message + if @content + if @content.empty? + "no content" + else + "#{@content.inspect} as content" + end + else + "any content" + end + end +end + +class Object + def equal_element(*args) + EqualElementMatcher.new(*args) + end +end
\ No newline at end of file diff --git a/spec/mspec/lib/mspec/matchers/have_class_variable.rb b/spec/mspec/lib/mspec/matchers/have_class_variable.rb new file mode 100644 index 0000000000..45cd0b5ae1 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_class_variable.rb @@ -0,0 +1,12 @@ +require 'mspec/matchers/variable' + +class HaveClassVariableMatcher < VariableMatcher + self.variables_method = :class_variables + self.description = 'class variable' +end + +class Object + def have_class_variable(variable) + HaveClassVariableMatcher.new(variable) + end +end
\ No newline at end of file diff --git a/spec/mspec/lib/mspec/matchers/have_constant.rb b/spec/mspec/lib/mspec/matchers/have_constant.rb new file mode 100644 index 0000000000..df95219e53 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_constant.rb @@ -0,0 +1,12 @@ +require 'mspec/matchers/variable' + +class HaveConstantMatcher < VariableMatcher + self.variables_method = :constants + self.description = 'constant' +end + +class Object + def have_constant(variable) + HaveConstantMatcher.new(variable) + end +end diff --git a/spec/mspec/lib/mspec/matchers/have_instance_method.rb b/spec/mspec/lib/mspec/matchers/have_instance_method.rb new file mode 100644 index 0000000000..00dcbd39eb --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_instance_method.rb @@ -0,0 +1,24 @@ +require 'mspec/matchers/method' + +class HaveInstanceMethodMatcher < MethodMatcher + def matches?(mod) + @mod = mod + mod.instance_methods(@include_super).include? @method + end + + def failure_message + ["Expected #{@mod} to have instance method '#{@method.to_s}'", + "but it does not"] + end + + def negative_failure_message + ["Expected #{@mod} NOT to have instance method '#{@method.to_s}'", + "but it does"] + end +end + +class Object + def have_instance_method(method, include_super=true) + HaveInstanceMethodMatcher.new method, include_super + end +end diff --git a/spec/mspec/lib/mspec/matchers/have_instance_variable.rb b/spec/mspec/lib/mspec/matchers/have_instance_variable.rb new file mode 100644 index 0000000000..e83eb9408c --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_instance_variable.rb @@ -0,0 +1,12 @@ +require 'mspec/matchers/variable' + +class HaveInstanceVariableMatcher < VariableMatcher + self.variables_method = :instance_variables + self.description = 'instance variable' +end + +class Object + def have_instance_variable(variable) + HaveInstanceVariableMatcher.new(variable) + end +end
\ No newline at end of file diff --git a/spec/mspec/lib/mspec/matchers/have_method.rb b/spec/mspec/lib/mspec/matchers/have_method.rb new file mode 100644 index 0000000000..2fc3e66f69 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_method.rb @@ -0,0 +1,24 @@ +require 'mspec/matchers/method' + +class HaveMethodMatcher < MethodMatcher + def matches?(mod) + @mod = mod + @mod.methods(@include_super).include? @method + end + + def failure_message + ["Expected #{@mod} to have method '#{@method.to_s}'", + "but it does not"] + end + + def negative_failure_message + ["Expected #{@mod} NOT to have method '#{@method.to_s}'", + "but it does"] + end +end + +class Object + def have_method(method, include_super=true) + HaveMethodMatcher.new method, include_super + end +end diff --git a/spec/mspec/lib/mspec/matchers/have_private_instance_method.rb b/spec/mspec/lib/mspec/matchers/have_private_instance_method.rb new file mode 100644 index 0000000000..87d9767a69 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_private_instance_method.rb @@ -0,0 +1,24 @@ +require 'mspec/matchers/method' + +class HavePrivateInstanceMethodMatcher < MethodMatcher + def matches?(mod) + @mod = mod + mod.private_instance_methods(@include_super).include? @method + end + + def failure_message + ["Expected #{@mod} to have private instance method '#{@method.to_s}'", + "but it does not"] + end + + def negative_failure_message + ["Expected #{@mod} NOT to have private instance method '#{@method.to_s}'", + "but it does"] + end +end + +class Object + def have_private_instance_method(method, include_super=true) + HavePrivateInstanceMethodMatcher.new method, include_super + end +end diff --git a/spec/mspec/lib/mspec/matchers/have_private_method.rb b/spec/mspec/lib/mspec/matchers/have_private_method.rb new file mode 100644 index 0000000000..d99d4ccb7f --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_private_method.rb @@ -0,0 +1,24 @@ +require 'mspec/matchers/method' + +class HavePrivateMethodMatcher < MethodMatcher + def matches?(mod) + @mod = mod + mod.private_methods(@include_super).include? @method + end + + def failure_message + ["Expected #{@mod} to have private method '#{@method.to_s}'", + "but it does not"] + end + + def negative_failure_message + ["Expected #{@mod} NOT to have private method '#{@method.to_s}'", + "but it does"] + end +end + +class Object + def have_private_method(method, include_super=true) + HavePrivateMethodMatcher.new method, include_super + end +end diff --git a/spec/mspec/lib/mspec/matchers/have_protected_instance_method.rb b/spec/mspec/lib/mspec/matchers/have_protected_instance_method.rb new file mode 100644 index 0000000000..92f38e9acb --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_protected_instance_method.rb @@ -0,0 +1,24 @@ +require 'mspec/matchers/method' + +class HaveProtectedInstanceMethodMatcher < MethodMatcher + def matches?(mod) + @mod = mod + mod.protected_instance_methods(@include_super).include? @method + end + + def failure_message + ["Expected #{@mod} to have protected instance method '#{@method.to_s}'", + "but it does not"] + end + + def negative_failure_message + ["Expected #{@mod} NOT to have protected instance method '#{@method.to_s}'", + "but it does"] + end +end + +class Object + def have_protected_instance_method(method, include_super=true) + HaveProtectedInstanceMethodMatcher.new method, include_super + end +end diff --git a/spec/mspec/lib/mspec/matchers/have_public_instance_method.rb b/spec/mspec/lib/mspec/matchers/have_public_instance_method.rb new file mode 100644 index 0000000000..035547d28f --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_public_instance_method.rb @@ -0,0 +1,24 @@ +require 'mspec/matchers/method' + +class HavePublicInstanceMethodMatcher < MethodMatcher + def matches?(mod) + @mod = mod + mod.public_instance_methods(@include_super).include? @method + end + + def failure_message + ["Expected #{@mod} to have public instance method '#{@method.to_s}'", + "but it does not"] + end + + def negative_failure_message + ["Expected #{@mod} NOT to have public instance method '#{@method.to_s}'", + "but it does"] + end +end + +class Object + def have_public_instance_method(method, include_super=true) + HavePublicInstanceMethodMatcher.new method, include_super + end +end diff --git a/spec/mspec/lib/mspec/matchers/have_singleton_method.rb b/spec/mspec/lib/mspec/matchers/have_singleton_method.rb new file mode 100644 index 0000000000..5f3acb84e2 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/have_singleton_method.rb @@ -0,0 +1,24 @@ +require 'mspec/matchers/method' + +class HaveSingletonMethodMatcher < MethodMatcher + def matches?(obj) + @obj = obj + obj.singleton_methods(@include_super).include? @method + end + + def failure_message + ["Expected #{@obj} to have singleton method '#{@method.to_s}'", + "but it does not"] + end + + def negative_failure_message + ["Expected #{@obj} NOT to have singleton method '#{@method.to_s}'", + "but it does"] + end +end + +class Object + def have_singleton_method(method, include_super=true) + HaveSingletonMethodMatcher.new method, include_super + end +end diff --git a/spec/mspec/lib/mspec/matchers/include.rb b/spec/mspec/lib/mspec/matchers/include.rb new file mode 100644 index 0000000000..b4e54158d1 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/include.rb @@ -0,0 +1,32 @@ +class IncludeMatcher + def initialize(*expected) + @expected = expected + end + + def matches?(actual) + @actual = actual + @expected.each do |e| + @element = e + unless @actual.include?(e) + return false + end + end + return true + end + + def failure_message + ["Expected #{@actual.inspect}", "to include #{@element.inspect}"] + end + + def negative_failure_message + ["Expected #{@actual.inspect}", "not to include #{@element.inspect}"] + end +end + +# Cannot override #include at the toplevel in MRI +module MSpec + def include(*expected) + IncludeMatcher.new(*expected) + end + module_function :include +end diff --git a/spec/mspec/lib/mspec/matchers/infinity.rb b/spec/mspec/lib/mspec/matchers/infinity.rb new file mode 100644 index 0000000000..0949fd47eb --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/infinity.rb @@ -0,0 +1,28 @@ +class InfinityMatcher + def initialize(expected_sign) + @expected_sign = expected_sign + end + + def matches?(actual) + @actual = actual + @actual.kind_of?(Float) && @actual.infinite? == @expected_sign + end + + def failure_message + ["Expected #{@actual}", "to be #{"-" if @expected_sign == -1}Infinity"] + end + + def negative_failure_message + ["Expected #{@actual}", "not to be #{"-" if @expected_sign == -1}Infinity"] + end +end + +class Object + def be_positive_infinity + InfinityMatcher.new(1) + end + + def be_negative_infinity + InfinityMatcher.new(-1) + end +end diff --git a/spec/mspec/lib/mspec/matchers/match_yaml.rb b/spec/mspec/lib/mspec/matchers/match_yaml.rb new file mode 100644 index 0000000000..542dece2b4 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/match_yaml.rb @@ -0,0 +1,47 @@ +class MatchYAMLMatcher + + def initialize(expected) + if valid_yaml?(expected) + @expected = expected + else + @expected = expected.to_yaml + end + end + + def matches?(actual) + @actual = actual + clean_yaml(@actual) == clean_yaml(@expected) + end + + def failure_message + ["Expected #{@actual.inspect}", " to match #{@expected.inspect}"] + end + + def negative_failure_message + ["Expected #{@actual.inspect}", " to match #{@expected.inspect}"] + end + + protected + + def clean_yaml(yaml) + yaml.gsub(/([^-]|^---)\s+\n/, "\\1\n").sub(/\n\.\.\.\n$/, "\n") + end + + def valid_yaml?(obj) + require 'yaml' + begin + YAML.load(obj) + rescue + false + else + true + end + end +end + +class Object + def match_yaml(expected) + MatchYAMLMatcher.new(expected) + end +end + diff --git a/spec/mspec/lib/mspec/matchers/method.rb b/spec/mspec/lib/mspec/matchers/method.rb new file mode 100644 index 0000000000..e8cdfa62ff --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/method.rb @@ -0,0 +1,10 @@ +class MethodMatcher + def initialize(method, include_super=true) + @include_super = include_super + @method = method.to_sym + end + + def matches?(mod) + raise Exception, "define #matches? in the subclass" + end +end diff --git a/spec/mspec/lib/mspec/matchers/output.rb b/spec/mspec/lib/mspec/matchers/output.rb new file mode 100644 index 0000000000..551e7506cf --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/output.rb @@ -0,0 +1,67 @@ +require 'mspec/helpers/io' + +class OutputMatcher + def initialize(stdout, stderr) + @out = stdout + @err = stderr + end + + def matches?(proc) + @saved_out = $stdout + @saved_err = $stderr + @stdout = $stdout = IOStub.new + @stderr = $stderr = IOStub.new + + proc.call + + unless @out.nil? + case @out + when Regexp + return false unless $stdout =~ @out + else + return false unless $stdout == @out + end + end + + unless @err.nil? + case @err + when Regexp + return false unless $stderr =~ @err + else + return false unless $stderr == @err + end + end + + return true + ensure + $stdout = @saved_out + $stderr = @saved_err + end + + def failure_message + expected_out = "\n" + actual_out = "\n" + unless @out.nil? + expected_out += " $stdout: #{@out.inspect}\n" + actual_out += " $stdout: #{@stdout.inspect}\n" + end + unless @err.nil? + expected_out += " $stderr: #{@err.inspect}\n" + actual_out += " $stderr: #{@stderr.inspect}\n" + end + ["Expected:#{expected_out}", " got:#{actual_out}"] + end + + def negative_failure_message + out = "" + out += " $stdout: #{@stdout.chomp.dump}\n" unless @out.nil? + out += " $stderr: #{@stderr.chomp.dump}\n" unless @err.nil? + ["Expected output not to be:\n", out] + end +end + +class Object + def output(stdout=nil, stderr=nil) + OutputMatcher.new(stdout, stderr) + end +end diff --git a/spec/mspec/lib/mspec/matchers/output_to_fd.rb b/spec/mspec/lib/mspec/matchers/output_to_fd.rb new file mode 100644 index 0000000000..5daaf5545c --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/output_to_fd.rb @@ -0,0 +1,71 @@ +require 'mspec/helpers/tmp' + +# Lower-level output speccing mechanism for a single +# output stream. Unlike OutputMatcher which provides +# methods to capture the output, we actually replace +# the FD itself so that there is no reliance on a +# certain method being used. +class OutputToFDMatcher + def initialize(expected, to) + @to, @expected = to, expected + + case @to + when STDOUT + @to_name = "STDOUT" + when STDERR + @to_name = "STDERR" + when IO + @to_name = @to.object_id.to_s + else + raise ArgumentError, "#{@to.inspect} is not a supported output target" + end + end + + def with_tmp + path = tmp("mspec_output_to_#{$$}_#{Time.now.to_i}") + File.open(path, 'w+') { |io| + yield(io) + } + ensure + File.delete path if path + end + + def matches?(block) + old_to = @to.dup + with_tmp do |out| + # Replacing with a file handle so that Readline etc. work + @to.reopen out + begin + block.call + ensure + @to.reopen old_to + old_to.close + end + + out.rewind + @actual = out.read + + case @expected + when Regexp + !(@actual =~ @expected).nil? + else + @actual == @expected + end + end + end + + def failure_message() + ["Expected (#{@to_name}): #{@expected.inspect}\n", + "#{'but got'.rjust(@to_name.length + 10)}: #{@actual.inspect}\nBacktrace"] + end + + def negative_failure_message() + ["Expected output (#{@to_name}) to NOT be:\n", @actual.inspect] + end +end + +class Object + def output_to_fd(what, where = STDOUT) + OutputToFDMatcher.new what, where + end +end diff --git a/spec/mspec/lib/mspec/matchers/raise_error.rb b/spec/mspec/lib/mspec/matchers/raise_error.rb new file mode 100644 index 0000000000..a5d6e01ec9 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/raise_error.rb @@ -0,0 +1,79 @@ +require 'mspec/utils/deprecate' + +class RaiseErrorMatcher + def initialize(exception, message, &block) + @exception = exception + @message = message + @block = block + end + + def matches?(proc) + @result = proc.call + return false + rescue Exception => @actual + if matching_exception?(@actual) + return true + else + raise @actual + end + end + + def matching_exception?(exc) + return false unless @exception === exc + if @message then + case @message + when String + return false if @message != exc.message + when Regexp + return false if @message !~ exc.message + end + end + + # The block has its own expectations and will throw an exception if it fails + @block[exc] if @block + + return true + end + + def exception_class_and_message(exception_class, message) + if message + "#{exception_class} (#{message})" + else + "#{exception_class}" + end + end + + def format_expected_exception + exception_class_and_message(@exception, @message) + end + + def format_exception(exception) + exception_class_and_message(exception.class, exception.message) + end + + def failure_message + message = ["Expected #{format_expected_exception}"] + + if @actual then + message << "but got #{format_exception(@actual)}" + else + message << "but no exception was raised (#{@result.pretty_inspect.chomp} was returned)" + end + + message + end + + def negative_failure_message + message = ["Expected to not get #{format_expected_exception}", ""] + unless @actual.class == @exception + message[1] = "but got #{format_exception(@actual)}" + end + message + end +end + +class Object + def raise_error(exception=Exception, message=nil, &block) + RaiseErrorMatcher.new(exception, message, &block) + end +end diff --git a/spec/mspec/lib/mspec/matchers/respond_to.rb b/spec/mspec/lib/mspec/matchers/respond_to.rb new file mode 100644 index 0000000000..2aa3ab14d1 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/respond_to.rb @@ -0,0 +1,24 @@ +class RespondToMatcher + def initialize(expected) + @expected = expected + end + + def matches?(actual) + @actual = actual + @actual.respond_to?(@expected) + end + + def failure_message + ["Expected #{@actual.inspect} (#{@actual.class})", "to respond to #{@expected}"] + end + + def negative_failure_message + ["Expected #{@actual.inspect} (#{@actual.class})", "not to respond to #{@expected}"] + end +end + +class Object + def respond_to(expected) + RespondToMatcher.new(expected) + end +end diff --git a/spec/mspec/lib/mspec/matchers/signed_zero.rb b/spec/mspec/lib/mspec/matchers/signed_zero.rb new file mode 100644 index 0000000000..3fd1472fc8 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/signed_zero.rb @@ -0,0 +1,28 @@ +class SignedZeroMatcher + def initialize(expected_sign) + @expected_sign = expected_sign + end + + def matches?(actual) + @actual = actual + (1.0/actual).infinite? == @expected_sign + end + + def failure_message + ["Expected #{@actual}", "to be #{"-" if @expected_sign == -1}0.0"] + end + + def negative_failure_message + ["Expected #{@actual}", "not to be #{"-" if @expected_sign == -1}0.0"] + end +end + +class Object + def be_positive_zero + SignedZeroMatcher.new(1) + end + + def be_negative_zero + SignedZeroMatcher.new(-1) + end +end diff --git a/spec/mspec/lib/mspec/matchers/variable.rb b/spec/mspec/lib/mspec/matchers/variable.rb new file mode 100644 index 0000000000..4d801ea337 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/variable.rb @@ -0,0 +1,24 @@ +class VariableMatcher + class << self + attr_accessor :variables_method, :description + end + + def initialize(variable) + @variable = variable.to_sym + end + + def matches?(object) + @object = object + @object.send(self.class.variables_method).include? @variable + end + + def failure_message + ["Expected #{@object} to have #{self.class.description} '#{@variable}'", + "but it does not"] + end + + def negative_failure_message + ["Expected #{@object} NOT to have #{self.class.description} '#{@variable}'", + "but it does"] + end +end diff --git a/spec/mspec/lib/mspec/mocks.rb b/spec/mspec/lib/mspec/mocks.rb new file mode 100644 index 0000000000..6a029c7b53 --- /dev/null +++ b/spec/mspec/lib/mspec/mocks.rb @@ -0,0 +1,3 @@ +require 'mspec/mocks/mock' +require 'mspec/mocks/proxy' +require 'mspec/mocks/object' diff --git a/spec/mspec/lib/mspec/mocks/mock.rb b/spec/mspec/lib/mspec/mocks/mock.rb new file mode 100644 index 0000000000..1557f2008e --- /dev/null +++ b/spec/mspec/lib/mspec/mocks/mock.rb @@ -0,0 +1,197 @@ +require 'mspec/expectations/expectations' + +class Object + alias_method :__mspec_object_id__, :object_id +end + +module Mock + def self.reset + @mocks = @stubs = @objects = nil + end + + def self.objects + @objects ||= {} + end + + def self.mocks + @mocks ||= Hash.new { |h,k| h[k] = [] } + end + + def self.stubs + @stubs ||= Hash.new { |h,k| h[k] = [] } + end + + def self.replaced_name(obj, sym) + :"__mspec_#{obj.__mspec_object_id__}_#{sym}__" + end + + def self.replaced_key(obj, sym) + [replaced_name(obj, sym), sym] + end + + def self.has_key?(keys, sym) + !!keys.find { |k| k.first == sym } + end + + def self.replaced?(sym) + has_key?(mocks.keys, sym) or has_key?(stubs.keys, sym) + end + + def self.clear_replaced(key) + mocks.delete key + stubs.delete key + end + + def self.mock_respond_to?(obj, sym, include_private = false) + name = replaced_name(obj, :respond_to?) + if replaced? name + obj.__send__ name, sym, include_private + else + obj.respond_to? sym, include_private + end + end + + def self.install_method(obj, sym, type=nil) + meta = obj.singleton_class + + key = replaced_key obj, sym + sym = sym.to_sym + + if (sym == :respond_to? or mock_respond_to?(obj, sym, true)) and !replaced?(key.first) + meta.__send__ :alias_method, key.first, sym + end + + meta.class_eval { + define_method(sym) do |*args, &block| + Mock.verify_call self, sym, *args, &block + end + } + + proxy = MockProxy.new type + + if proxy.mock? + MSpec.expectation + MSpec.actions :expectation, MSpec.current.state + end + + if proxy.stub? + stubs[key].unshift proxy + else + mocks[key] << proxy + end + objects[key] = obj + + proxy + end + + def self.name_or_inspect(obj) + obj.instance_variable_get(:@name) || obj.inspect + end + + def self.verify_count + mocks.each do |key, proxies| + obj = objects[key] + proxies.each do |proxy| + qualifier, count = proxy.count + pass = case qualifier + when :at_least + proxy.calls >= count + when :at_most + proxy.calls <= count + when :exactly + proxy.calls == count + when :any_number_of_times + true + else + false + end + unless pass + SpecExpectation.fail_with( + "Mock '#{name_or_inspect obj}' expected to receive '#{key.last}' " + \ + "#{qualifier.to_s.sub('_', ' ')} #{count} times", + "but received it #{proxy.calls} times") + end + end + end + end + + def self.verify_call(obj, sym, *args, &block) + compare = *args + compare = compare.first if compare.length <= 1 + + key = replaced_key obj, sym + [mocks, stubs].each do |proxies| + proxies[key].each do |proxy| + pass = case proxy.arguments + when :any_args + true + when :no_args + compare.nil? + else + proxy.arguments == compare + end + + if proxy.yielding? + if block + proxy.yielding.each do |args_to_yield| + if block.arity == -1 || block.arity == args_to_yield.size + block.call(*args_to_yield) + else + SpecExpectation.fail_with( + "Mock '#{name_or_inspect obj}' asked to yield " + \ + "|#{proxy.yielding.join(', ')}| on #{sym}\n", + "but a block with arity #{block.arity} was passed") + end + end + else + SpecExpectation.fail_with( + "Mock '#{name_or_inspect obj}' asked to yield " + \ + "|[#{proxy.yielding.join('], [')}]| on #{sym}\n", + "but no block was passed") + end + end + + if pass + proxy.called + + if proxy.raising? + raise proxy.raising + else + return proxy.returning + end + end + end + end + + if sym.to_sym == :respond_to? + mock_respond_to? obj, compare + else + SpecExpectation.fail_with("Mock '#{name_or_inspect obj}': method #{sym}\n", + "called with unexpected arguments (#{Array(compare).join(' ')})") + end + end + + def self.cleanup + objects.each do |key, obj| + if obj.kind_of? MockIntObject + clear_replaced key + next + end + + replaced = key.first + sym = key.last + meta = obj.singleton_class + + if mock_respond_to? obj, replaced, true + meta.__send__ :alias_method, sym, replaced + meta.__send__ :remove_method, replaced + else + meta.__send__ :remove_method, sym + end + + clear_replaced key + end + ensure + reset + end +end diff --git a/spec/mspec/lib/mspec/mocks/object.rb b/spec/mspec/lib/mspec/mocks/object.rb new file mode 100644 index 0000000000..f4652a4671 --- /dev/null +++ b/spec/mspec/lib/mspec/mocks/object.rb @@ -0,0 +1,28 @@ +require 'mspec/mocks/proxy' + +class Object + def stub!(sym) + Mock.install_method self, sym, :stub + end + + def should_receive(sym) + Mock.install_method self, sym + end + + def should_not_receive(sym) + proxy = Mock.install_method self, sym + proxy.exactly(0).times + end + + def mock(name, options={}) + MockObject.new name, options + end + + def mock_int(val) + MockIntObject.new(val) + end + + def mock_numeric(name, options={}) + NumericMockObject.new name, options + end +end diff --git a/spec/mspec/lib/mspec/mocks/proxy.rb b/spec/mspec/lib/mspec/mocks/proxy.rb new file mode 100644 index 0000000000..f5acc89d62 --- /dev/null +++ b/spec/mspec/lib/mspec/mocks/proxy.rb @@ -0,0 +1,186 @@ +class MockObject + def initialize(name, options={}) + @name = name + @null = options[:null_object] + end + + def method_missing(sym, *args, &block) + @null ? self : super + end + private :method_missing +end + +class NumericMockObject < Numeric + def initialize(name, options={}) + @name = name + @null = options[:null_object] + end + + def method_missing(sym, *args, &block) + @null ? self : super + end + + def singleton_method_added(val) + end +end + +class MockIntObject + def initialize(val) + @value = val + @calls = 0 + + key = [self, :to_int] + + Mock.objects[key] = self + Mock.mocks[key] << self + end + + attr_reader :calls + + def to_int + @calls += 1 + @value.to_int + end + + def count + [:at_least, 1] + end +end + +class MockProxy + attr_reader :raising, :yielding + + def initialize(type=nil) + @multiple_returns = nil + @returning = nil + @raising = nil + @yielding = [] + @arguments = :any_args + @type = type || :mock + end + + def mock? + @type == :mock + end + + def stub? + @type == :stub + end + + def count + @count ||= mock? ? [:exactly, 1] : [:any_number_of_times, 0] + end + + def arguments + @arguments + end + + def returning + if @multiple_returns + if @returning.size == 1 + @multiple_returns = false + return @returning = @returning.shift + end + return @returning.shift + end + @returning + end + + def times + self + end + + def calls + @calls ||= 0 + end + + def called + @calls = calls + 1 + end + + def exactly(n) + @count = [:exactly, n_times(n)] + self + end + + def at_least(n) + @count = [:at_least, n_times(n)] + self + end + + def at_most(n) + @count = [:at_most, n_times(n)] + self + end + + def once + exactly 1 + end + + def twice + exactly 2 + end + + def any_number_of_times + @count = [:any_number_of_times, 0] + self + end + + def with(*args) + raise ArgumentError, "you must specify the expected arguments" if args.empty? + if args.length == 1 + @arguments = args.first + else + @arguments = args + end + self + end + + def and_return(*args) + case args.size + when 0 + @returning = nil + when 1 + @returning = args[0] + else + @multiple_returns = true + @returning = args + count[1] = args.size if count[1] < args.size + end + self + end + + def and_raise(exception) + if exception.kind_of? String + @raising = RuntimeError.new exception + else + @raising = exception + end + end + + def raising? + @raising != nil + end + + def and_yield(*args) + @yielding << args + self + end + + def yielding? + end + + private + + def n_times(n) + case n + when :once + 1 + when :twice + 2 + else + Integer n + end + end +end diff --git a/spec/mspec/lib/mspec/runner.rb b/spec/mspec/lib/mspec/runner.rb new file mode 100644 index 0000000000..df57b9f69b --- /dev/null +++ b/spec/mspec/lib/mspec/runner.rb @@ -0,0 +1,12 @@ +require 'mspec/mocks' +require 'mspec/runner/mspec' +require 'mspec/runner/context' +require 'mspec/runner/evaluate' +require 'mspec/runner/example' +require 'mspec/runner/exception' +require 'mspec/runner/object' +require 'mspec/runner/formatters' +require 'mspec/runner/actions' +require 'mspec/runner/filters' +require 'mspec/runner/shared' +require 'mspec/runner/tag' 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 + + 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? + 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 diff --git a/spec/mspec/lib/mspec/utils/deprecate.rb b/spec/mspec/lib/mspec/utils/deprecate.rb new file mode 100644 index 0000000000..1db843b329 --- /dev/null +++ b/spec/mspec/lib/mspec/utils/deprecate.rb @@ -0,0 +1,6 @@ +module MSpec + def self.deprecate(what, replacement) + user_caller = caller.find { |line| !line.include?('lib/mspec') } + $stderr.puts "\n#{what} is deprecated, use #{replacement} instead.\nfrom #{user_caller}" + end +end diff --git a/spec/mspec/lib/mspec/utils/name_map.rb b/spec/mspec/lib/mspec/utils/name_map.rb new file mode 100644 index 0000000000..c1de081af0 --- /dev/null +++ b/spec/mspec/lib/mspec/utils/name_map.rb @@ -0,0 +1,128 @@ +class NameMap + MAP = { + '`' => 'backtick', + '+' => 'plus', + '-' => 'minus', + '+@' => 'uplus', + '-@' => 'uminus', + '*' => 'multiply', + '/' => 'divide', + '%' => 'modulo', + '<<' => {'Bignum' => 'left_shift', + 'Fixnum' => 'left_shift', + 'IO' => 'output', + :default => 'append' }, + '>>' => 'right_shift', + '<' => 'lt', + '<=' => 'lte', + '>' => 'gt', + '>=' => 'gte', + '=' => 'assignment', + '==' => 'equal_value', + '===' => 'case_compare', + '<=>' => 'comparison', + '[]' => 'element_reference', + '[]=' => 'element_set', + '**' => 'exponent', + '!' => 'not', + '~' => {'Bignum' => 'complement', + 'Fixnum' => 'complement', + 'Regexp' => 'match', + 'String' => 'match' }, + '!=' => 'not_equal', + '!~' => 'not_match', + '=~' => 'match', + '&' => {'Bignum' => 'bit_and', + 'Fixnum' => 'bit_and', + 'Array' => 'intersection', + 'TrueClass' => 'and', + 'FalseClass' => 'and', + 'NilClass' => 'and', + 'Set' => 'intersection' }, + '|' => {'Bignum' => 'bit_or', + 'Fixnum' => 'bit_or', + 'Array' => 'union', + 'TrueClass' => 'or', + 'FalseClass' => 'or', + 'NilClass' => 'or', + 'Set' => 'union' }, + '^' => {'Bignum' => 'bit_xor', + 'Fixnum' => 'bit_xor', + 'TrueClass' => 'xor', + 'FalseClass' => 'xor', + 'NilClass' => 'xor', + 'Set' => 'exclusion'}, + } + + EXCLUDED = %w[ + MSpecScript + MkSpec + MSpecOption + MSpecOptions + NameMap + SpecVersion + ] + + def initialize(filter=false) + @seen = {} + @filter = filter + end + + def exception?(name) + return false unless c = class_or_module(name) + c == Errno or c.ancestors.include? Exception + end + + def class_or_module(c) + const = Object.const_get(c, false) + filtered = @filter && EXCLUDED.include?(const.name) + return const if Module === const and !filtered + rescue NameError + end + + def namespace(mod, const) + return const.to_s if mod.nil? or %w[Object Class Module].include? mod + "#{mod}::#{const}" + end + + def map(hash, constants, mod=nil) + @seen = {} unless mod + + constants.each do |const| + name = namespace mod, const + m = class_or_module name + next unless m and !@seen[m] + @seen[m] = true + + ms = m.methods(false).map { |x| x.to_s } + hash["#{name}."] = ms.sort unless ms.empty? + + ms = m.public_instance_methods(false) + + m.protected_instance_methods(false) + ms.map! { |x| x.to_s } + hash["#{name}#"] = ms.sort unless ms.empty? + + map hash, m.constants(false), name + end + + hash + end + + def dir_name(c, base) + return File.join(base, 'exception') if exception? c + + c.split('::').inject(base) do |dir, name| + name.gsub!(/Class/, '') unless name == 'Class' + File.join dir, name.downcase + end + end + + def file_name(m, c) + if MAP.key?(m) + name = MAP[m].is_a?(Hash) ? MAP[m][c.split('::').last] || MAP[m][:default] : MAP[m] + else + name = m.gsub(/[?!=]/, '') + end + "#{name}_spec.rb" + end +end diff --git a/spec/mspec/lib/mspec/utils/options.rb b/spec/mspec/lib/mspec/utils/options.rb new file mode 100644 index 0000000000..122ef6e135 --- /dev/null +++ b/spec/mspec/lib/mspec/utils/options.rb @@ -0,0 +1,489 @@ +require 'mspec/version' + +MSPEC_HOME = File.expand_path('../../../..', __FILE__) + +class MSpecOption + attr_reader :short, :long, :arg, :description, :block + + def initialize(short, long, arg, description, block) + @short = short + @long = long + @arg = arg + @description = description + @block = block + end + + def arg? + @arg != nil + end + + def match?(opt) + opt == @short or opt == @long + end +end + +# MSpecOptions provides a parser for command line options. It also +# provides a composable set of options from which the runner scripts +# can select for their particular functionality. +class MSpecOptions + # Raised if incorrect or incomplete formats are passed to #on. + class OptionError < Exception; end + + # Raised if an unrecognized option is encountered. + class ParseError < Exception; end + + attr_accessor :config, :banner, :width, :options + + def initialize(banner="", width=30, config=nil) + @banner = banner + @config = config + @width = width + @options = [] + @doc = [] + @extra = [] + @on_extra = lambda { |x| + raise ParseError, "Unrecognized option: #{x}" if x[0] == ?- + @extra << x + } + + yield self if block_given? + end + + # Registers an option. Acceptable formats for arguments are: + # + # on "-a", "description" + # on "-a", "--abdc", "description" + # on "-a", "ARG", "description" + # on "--abdc", "ARG", "description" + # on "-a", "--abdc", "ARG", "description" + # + # If an block is passed, it will be invoked when the option is + # matched. Not passing a block is permitted, but nonsensical. + def on(*args, &block) + raise OptionError, "option and description are required" if args.size < 2 + + description = args.pop + short, long, argument = nil + args.each do |arg| + if arg[0] == ?- + if arg[1] == ?- + long = arg + else + short = arg + end + else + argument = arg + end + end + + add short, long, argument, description, block + end + + # Adds documentation text for an option and adds an +MSpecOption+ + # instance to the list of registered options. + def add(short, long, arg, description, block) + s = short ? short.dup : " " + s += (short ? ", " : " ") if long + doc " #{s}#{long} #{arg}".ljust(@width-1) + " #{description}" + @options << MSpecOption.new(short, long, arg, description, block) + end + + # Searches all registered options to find a match for +opt+. Returns + # +nil+ if no registered options match. + def match?(opt) + @options.find { |o| o.match? opt } + end + + # Processes an option. Calles the #on_extra block (or default) for + # unrecognized options. For registered options, possibly fetches an + # argument and invokes the option's block if it is not nil. + def process(argv, entry, opt, arg) + unless option = match?(opt) + @on_extra[entry] + else + if option.arg? + arg = argv.shift if arg.nil? + raise ParseError, "No argument provided for #{opt}" unless arg + option.block[arg] if option.block + else + option.block[] if option.block + end + end + option + end + + # Splits a string at +n+ characters into the +opt+ and the +rest+. + # The +arg+ is set to +nil+ if +rest+ is an empty string. + def split(str, n) + opt = str[0, n] + rest = str[n, str.size] + arg = rest == "" ? nil : rest + return opt, arg, rest + end + + # Parses an array of command line entries, calling blocks for + # registered options. + def parse(argv=ARGV) + argv = Array(argv).dup + + while entry = argv.shift + # collect everything that is not an option + if entry[0] != ?- or entry.size < 2 + @on_extra[entry] + next + end + + # this is a long option + if entry[1] == ?- + opt, arg = entry.split "=" + process argv, entry, opt, arg + next + end + + # disambiguate short option group from short option with argument + opt, arg, rest = split entry, 2 + + # process first option + option = process argv, entry, opt, arg + next unless option and !option.arg? + + # process the rest of the options + while rest.size > 0 + opt, arg, rest = split rest, 1 + opt = "-" + opt + option = process argv, opt, opt, arg + break if !option or option.arg? + end + end + + @extra + rescue ParseError => e + puts self + puts e + exit 1 + end + + # Adds a string of documentation text inline in the text generated + # from the options. See #on and #add. + def doc(str) + @doc << str + end + + # Convenience method for providing -v, --version options. + def version(version, &block) + show = block || lambda { puts "#{File.basename $0} #{version}"; exit } + on "-v", "--version", "Show version", &show + end + + # Convenience method for providing -h, --help options. + def help(&block) + help = block || lambda { puts self; exit 1 } + on "-h", "--help", "Show this message", &help + end + + # Stores a block that will be called with unrecognized options + def on_extra(&block) + @on_extra = block + end + + # Returns a string representation of the options and doc strings. + def to_s + @banner + "\n\n" + @doc.join("\n") + "\n" + end + + # The methods below provide groups of options that + # are composed by the particular runners to provide + # their functionality + + def configure(&block) + on("-B", "--config", "FILE", + "Load FILE containing configuration options", &block) + end + + def name + on("-n", "--name", "RUBY_NAME", + "Set the value of RUBY_NAME (used to determine the implementation)") do |n| + Object.const_set :RUBY_NAME, n + end + end + + def targets + on("-t", "--target", "TARGET", + "Implementation to run the specs, where TARGET is:") do |t| + case t + when 'r', 'ruby' + config[:target] = 'ruby' + when 'x', 'rubinius' + config[:target] = './bin/rbx' + when 'X', 'rbx' + config[:target] = 'rbx' + when 'j', 'jruby' + config[:target] = 'jruby' + when 'i','ironruby' + config[:target] = 'ir' + when 'm','maglev' + config[:target] = 'maglev-ruby' + when 't','topaz' + config[:target] = 'topaz' + when 'o','opal' + mspec_lib = File.expand_path('../../../', __FILE__) + config[:target] = "./bin/opal -syaml -sfileutils -rnodejs -rnodejs/require -rnodejs/yaml -rprocess -Derror -I#{mspec_lib} -I./lib/ -I. " + else + config[:target] = t + end + end + + doc "" + doc " r or ruby invokes ruby in PATH" + doc " x or rubinius invokes ./bin/rbx" + doc " X or rbx invokes rbx in PATH" + doc " j or jruby invokes jruby in PATH" + doc " i or ironruby invokes ir in PATH" + doc " m or maglev invokes maglev-ruby in PATH" + doc " t or topaz invokes topaz in PATH" + doc " o or opal invokes ./bin/opal with options" + doc " full path to EXE invokes EXE directly\n" + + on("-T", "--target-opt", "OPT", + "Pass OPT as a flag to the target implementation") do |t| + config[:flags] << t + end + on("-I", "--include", "DIR", + "Pass DIR through as the -I option to the target") do |d| + config[:loadpath] << "-I#{d}" + end + on("-r", "--require", "LIBRARY", + "Pass LIBRARY through as the -r option to the target") do |f| + config[:requires] << "-r#{f}" + end + end + + def formatters + on("-f", "--format", "FORMAT", + "Formatter for reporting, where FORMAT is one of:") do |o| + require 'mspec/runner/formatters' + case o + when 's', 'spec', 'specdoc' + config[:formatter] = SpecdocFormatter + when 'h', 'html' + config[:formatter] = HtmlFormatter + when 'd', 'dot', 'dotted' + config[:formatter] = DottedFormatter + when 'b', 'describe' + config[:formatter] = DescribeFormatter + when 'f', 'file' + config[:formatter] = FileFormatter + when 'u', 'unit', 'unitdiff' + config[:formatter] = UnitdiffFormatter + when 'm', 'summary' + config[:formatter] = SummaryFormatter + when 'a', '*', 'spin' + config[:formatter] = SpinnerFormatter + when 't', 'method' + config[:formatter] = MethodFormatter + when 'y', 'yaml' + config[:formatter] = YamlFormatter + when 'p', 'profile' + config[:formatter] = ProfileFormatter + when 'j', 'junit' + config[:formatter] = JUnitFormatter + else + abort "Unknown format: #{o}\n#{@parser}" unless File.exist?(o) + require File.expand_path(o) + if Object.const_defined?(:CUSTOM_MSPEC_FORMATTER) + config[:formatter] = CUSTOM_MSPEC_FORMATTER + else + abort "You must define CUSTOM_MSPEC_FORMATTER in your custom formatter file" + end + end + end + + doc "" + doc " s, spec, specdoc SpecdocFormatter" + doc " h, html, HtmlFormatter" + doc " d, dot, dotted DottedFormatter" + doc " f, file FileFormatter" + doc " u, unit, unitdiff UnitdiffFormatter" + doc " m, summary SummaryFormatter" + doc " a, *, spin SpinnerFormatter" + doc " t, method MethodFormatter" + doc " y, yaml YamlFormatter" + doc " p, profile ProfileFormatter" + doc " j, junit JUnitFormatter\n" + + on("-o", "--output", "FILE", + "Write formatter output to FILE") do |f| + config[:output] = f + end + end + + def filters + on("-e", "--example", "STR", + "Run examples with descriptions matching STR") do |o| + config[:includes] << o + end + on("-E", "--exclude", "STR", + "Exclude examples with descriptions matching STR") do |o| + config[:excludes] << o + end + on("-p", "--pattern", "PATTERN", + "Run examples with descriptions matching PATTERN") do |o| + config[:patterns] << Regexp.new(o) + end + on("-P", "--excl-pattern", "PATTERN", + "Exclude examples with descriptions matching PATTERN") do |o| + config[:xpatterns] << Regexp.new(o) + end + on("-g", "--tag", "TAG", + "Run examples with descriptions matching ones tagged with TAG") do |o| + config[:tags] << o + end + on("-G", "--excl-tag", "TAG", + "Exclude examples with descriptions matching ones tagged with TAG") do |o| + config[:xtags] << o + end + on("-w", "--profile", "FILE", + "Run examples for methods listed in the profile FILE") do |f| + config[:profiles] << f + end + on("-W", "--excl-profile", "FILE", + "Exclude examples for methods listed in the profile FILE") do |f| + config[:xprofiles] << f + end + end + + def chdir + on("-C", "--chdir", "DIR", + "Change the working directory to DIR before running specs") do |d| + Dir.chdir d + end + end + + def prefix + on("--prefix", "STR", "Prepend STR when resolving spec file names") do |p| + config[:prefix] = p + end + end + + def pretend + on("-Z", "--dry-run", + "Invoke formatters and other actions, but don't execute the specs") do + MSpec.register_mode :pretend + end + end + + def unguarded + on("--unguarded", "Turn off all guards") do + MSpec.register_mode :unguarded + end + on("--no-ruby_bug", "Turn off the ruby_bug guard") do + MSpec.register_mode :no_ruby_bug + end + end + + def randomize + on("-H", "--random", + "Randomize the list of spec files") do + MSpec.randomize + end + end + + def repeat + on("-R", "--repeat", "NUMBER", + "Repeatedly run an example NUMBER times") do |o| + MSpec.repeat = o.to_i + end + end + + def verbose + on("-V", "--verbose", "Output the name of each file processed") do + obj = Object.new + def obj.start + @width = MSpec.retrieve(:files).inject(0) { |max, f| f.size > max ? f.size : max } + end + def obj.load + file = MSpec.retrieve :file + print "\n#{file.ljust(@width)}" + end + MSpec.register :start, obj + MSpec.register :load, obj + end + + on("-m", "--marker", "MARKER", + "Output MARKER for each file processed") do |o| + obj = Object.new + obj.instance_variable_set :@marker, o + def obj.load + print @marker + end + MSpec.register :load, obj + end + end + + def interrupt + on("--int-spec", "Control-C interupts the current spec only") do + config[:abort] = false + end + end + + def verify + on("--report-on", "GUARD", "Report specs guarded by GUARD") do |g| + MSpec.register_mode :report_on + SpecGuard.guards << g.to_sym + end + on("-O", "--report", "Report guarded specs") do + MSpec.register_mode :report + end + on("-Y", "--verify", + "Verify that guarded specs pass and fail as expected") do + MSpec.register_mode :verify + end + end + + def action_filters + on("-K", "--action-tag", "TAG", + "Spec descriptions marked with TAG will trigger the specified action") do |o| + config[:atags] << o + end + on("-S", "--action-string", "STR", + "Spec descriptions matching STR will trigger the specified action") do |o| + config[:astrings] << o + end + end + + def actions + on("--spec-debug", + "Invoke the debugger when a spec description matches (see -K, -S)") do + config[:debugger] = true + end + end + + def debug + on("-d", "--debug", + "Set MSpec debugging flag for more verbose output") do + $MSPEC_DEBUG = true + end + end + + def all + # Generated with: + # puts File.read(__FILE__).scan(/def (\w+).*\n\s*on\(/) + configure {} + name + targets + formatters + filters + chdir + prefix + pretend + unguarded + randomize + repeat + verbose + interrupt + verify + action_filters + actions + debug + end +end diff --git a/spec/mspec/lib/mspec/utils/ruby_name.rb b/spec/mspec/lib/mspec/utils/ruby_name.rb new file mode 100644 index 0000000000..e381e387f6 --- /dev/null +++ b/spec/mspec/lib/mspec/utils/ruby_name.rb @@ -0,0 +1,8 @@ +unless Object.const_defined?(:RUBY_NAME) and RUBY_NAME + if Object.const_defined?(:RUBY_ENGINE) and RUBY_ENGINE + RUBY_NAME = RUBY_ENGINE + else + require 'rbconfig' + RUBY_NAME = RbConfig::CONFIG["RUBY_INSTALL_NAME"] || RbConfig::CONFIG["ruby_install_name"] + end +end diff --git a/spec/mspec/lib/mspec/utils/script.rb b/spec/mspec/lib/mspec/utils/script.rb new file mode 100644 index 0000000000..28be854a85 --- /dev/null +++ b/spec/mspec/lib/mspec/utils/script.rb @@ -0,0 +1,267 @@ +require 'mspec/guards/guard' +require 'mspec/utils/warnings' + +# MSpecScript provides a skeleton for all the MSpec runner scripts. + +class MSpecScript + # Returns the config object. Maintained at the class + # level to easily enable simple config files. See the + # class method +set+. + def self.config + @config ||= { + :path => ['.', 'spec'], + :config_ext => '.mspec' + } + end + + # Associates +value+ with +key+ in the config object. Enables + # simple config files of the form: + # + # class MSpecScript + # set :target, "ruby" + # set :files, ["one_spec.rb", "two_spec.rb"] + # end + def self.set(key, value) + config[key] = value + end + + # Gets the value of +key+ from the config object. Simplifies + # getting values in a config file: + # + # class MSpecScript + # set :a, 1 + # set :b, 2 + # set :c, get(:a) + get(:b) + # end + def self.get(key) + config[key] + end + + def initialize + config[:formatter] = nil + config[:includes] = [] + config[:excludes] = [] + config[:patterns] = [] + config[:xpatterns] = [] + config[:tags] = [] + config[:xtags] = [] + config[:profiles] = [] + config[:xprofiles] = [] + config[:atags] = [] + config[:astrings] = [] + config[:ltags] = [] + config[:abort] = true + @loaded = [] + end + + # Returns the config object maintained by the instance's class. + # See the class methods +set+ and +config+. + def config + MSpecScript.config + end + + # Returns +true+ if the file was located in +config[:path]+, + # possibly appending +config[:config_ext]. Returns +false+ + # otherwise. + def try_load(target) + names = [target] + unless target[-6..-1] == config[:config_ext] + names << target + config[:config_ext] + end + + names.each do |name| + config[:path].each do |dir| + file = File.expand_path name, dir + if @loaded.include?(file) + return true + elsif File.exist? file + value = Kernel.load(file) + @loaded << file + return value + end + end + end + + false + end + + def load(target) + try_load(target) or abort "Could not load config file #{target}" + end + + # Attempts to load a default config file. First tries to load + # 'default.mspec'. If that fails, attempts to load a config + # file name constructed from the value of RUBY_ENGINE and the + # first two numbers in RUBY_VERSION. For example, on MRI 1.8.6, + # the file name would be 'ruby.1.8.mspec'. + def load_default + try_load 'default.mspec' + + if Object.const_defined?(:RUBY_ENGINE) + engine = RUBY_ENGINE + else + engine = 'ruby' + end + try_load "#{engine}.#{SpecGuard.ruby_version}.mspec" + try_load "#{engine}.mspec" + end + + # Callback for enabling custom options. This version is a no-op. + # Provide an implementation specific version in a config file. + # Called by #options after the MSpec-provided options are added. + def custom_options(options) + options.doc " No custom options registered" + end + + # Registers all filters and actions. + def register + require 'mspec/runner/formatters/dotted' + require 'mspec/runner/formatters/spinner' + require 'mspec/runner/formatters/file' + require 'mspec/runner/filters' + + if config[:formatter].nil? + config[:formatter] = STDOUT.tty? ? SpinnerFormatter : @files.size < 50 ? DottedFormatter : FileFormatter + end + + if config[:formatter] + formatter = config[:formatter].new(config[:output]) + formatter.register + MSpec.store :formatter, formatter + end + + MatchFilter.new(:include, *config[:includes]).register unless config[:includes].empty? + MatchFilter.new(:exclude, *config[:excludes]).register unless config[:excludes].empty? + RegexpFilter.new(:include, *config[:patterns]).register unless config[:patterns].empty? + RegexpFilter.new(:exclude, *config[:xpatterns]).register unless config[:xpatterns].empty? + TagFilter.new(:include, *config[:tags]).register unless config[:tags].empty? + TagFilter.new(:exclude, *config[:xtags]).register unless config[:xtags].empty? + ProfileFilter.new(:include, *config[:profiles]).register unless config[:profiles].empty? + ProfileFilter.new(:exclude, *config[:xprofiles]).register unless config[:xprofiles].empty? + + DebugAction.new(config[:atags], config[:astrings]).register if config[:debugger] + + custom_register + end + + # Callback for enabling custom actions, etc. This version is a + # no-op. Provide an implementation specific version in a config + # file. Called by #register. + def custom_register + end + + # Sets up signal handlers. Only a handler for SIGINT is + # registered currently. + def signals + if config[:abort] + Signal.trap "INT" do + MSpec.actions :abort + puts "\nProcess aborted!" + exit! 1 + end + end + end + + # Attempts to resolve +partial+ as a file or directory name in the + # following order: + # + # 1. +partial+ + # 2. +partial+ + "_spec.rb" + # 3. <tt>File.join(config[:prefix], partial)</tt> + # 4. <tt>File.join(config[:prefix], partial + "_spec.rb")</tt> + # + # If it is a file name, returns the name as an entry in an array. + # If it is a directory, returns all *_spec.rb files in the + # directory and subdirectories. + # + # If unable to resolve +partial+, +Kernel.abort+ is called. + def entries(partial) + file = partial + "_spec.rb" + patterns = [partial, file] + if config[:prefix] + patterns << File.join(config[:prefix], partial) + patterns << File.join(config[:prefix], file) + end + + patterns.each do |pattern| + expanded = File.expand_path(pattern) + if File.file?(expanded) + return [expanded] + elsif File.directory?(expanded) + return Dir["#{expanded}/**/*_spec.rb"].sort + end + end + + abort "Could not find spec file #{partial}" + end + + # Resolves each entry in +list+ to a set of files. + # + # If the entry has a leading '^' character, the list of files + # is subtracted from the list of files accumulated to that point. + # + # If the entry has a leading ':' character, the corresponding + # key is looked up in the config object and the entries in the + # value retrieved are processed through #entries. + def files(list) + list.inject([]) do |files, item| + case item[0] + when ?^ + files -= entries(item[1..-1]) + when ?: + key = item[1..-1].to_sym + files += files(Array(config[key])) + else + files += entries(item) + end + files + end + end + + def files_from_patterns(patterns) + unless $0.end_with?("_spec.rb") + if patterns.empty? + patterns = config[:files] + end + if patterns.empty? and File.directory? "./spec" + patterns = ["spec/"] + end + if patterns.empty? + puts "No files specified." + exit 1 + end + end + files patterns + end + + def cores + require 'etc' + Etc.nprocessors + end + + def setup_env + ENV['MSPEC_RUNNER'] = '1' + + unless ENV['RUBY_EXE'] + ENV['RUBY_EXE'] = config[:target] if config[:target] + end + + unless ENV['RUBY_FLAGS'] + ENV['RUBY_FLAGS'] = config[:flags].join(" ") if config[:flags] + end + end + + # Instantiates an instance and calls the series of methods to + # invoke the script. + def self.main + script = new + script.load_default + script.try_load '~/.mspecrc' + script.options + script.signals + script.register + script.setup_env + require 'mspec' + script.run + end +end diff --git a/spec/mspec/lib/mspec/utils/version.rb b/spec/mspec/lib/mspec/utils/version.rb new file mode 100644 index 0000000000..787a76b053 --- /dev/null +++ b/spec/mspec/lib/mspec/utils/version.rb @@ -0,0 +1,52 @@ +class SpecVersion + # If beginning implementations have a problem with this include, we can + # manually implement the relational operators that are needed. + include Comparable + + # SpecVersion handles comparison correctly for the context by filling in + # missing version parts according to the value of +ceil+. If +ceil+ is + # +false+, 0 digits fill in missing version parts. If +ceil+ is +true+, 9 + # digits fill in missing parts. (See e.g. VersionGuard and BugGuard.) + def initialize(version, ceil = false) + @version = version + @ceil = ceil + @integer = nil + end + + def to_s + @version + end + + def to_str + to_s + end + + # Converts a string representation of a version major.minor.tiny + # to an integer representation so that comparisons can be made. For example, + # "2.2.10" < "2.2.2" would be false if compared as strings. + def to_i + unless @integer + major, minor, tiny = @version.split "." + if @ceil + tiny = 99 unless tiny + end + parts = [major, minor, tiny].map { |x| x.to_i } + @integer = ("1%02d%02d%02d" % parts).to_i + end + @integer + end + + def to_int + to_i + end + + def <=>(other) + if other.respond_to? :to_int + other = Integer other + else + other = SpecVersion.new(String(other)).to_i + end + + self.to_i <=> other + end +end diff --git a/spec/mspec/lib/mspec/utils/warnings.rb b/spec/mspec/lib/mspec/utils/warnings.rb new file mode 100644 index 0000000000..74c7f88a52 --- /dev/null +++ b/spec/mspec/lib/mspec/utils/warnings.rb @@ -0,0 +1,32 @@ +require 'mspec/guards/version' + +if RUBY_ENGINE == "ruby" and RUBY_VERSION >= "2.4.0" + ruby_version_is "2.4"..."2.5" do + # Kernel#warn does not delegate to Warning.warn in 2.4 + module Kernel + def warn(*messages) + return if $VERBOSE == nil or messages.empty? + msg = messages.join("\n") + msg += "\n" unless msg.end_with?("\n") + Warning.warn(msg) + end + private :warn + end + end + + def Warning.warn(message) + case message + when /constant ::(Fixnum|Bignum) is deprecated/ + when /\/(argf|io|stringio)\/.+(ARGF|IO)#(lines|chars|bytes|codepoints) is deprecated/ + when /Thread\.exclusive is deprecated.+\n.+thread\/exclusive_spec\.rb/ + when /hash\/shared\/index\.rb:\d+: warning: Hash#index is deprecated; use Hash#key/ + when /env\/shared\/key\.rb:\d+: warning: ENV\.index is deprecated; use ENV\.key/ + when /exponent(_spec)?\.rb:\d+: warning: in a\*\*b, b may be too big/ + when /enumerator\/(new|initialize_spec)\.rb:\d+: warning: Enumerator\.new without a block is deprecated/ + else + $stderr.write message + end + end +else + $VERBOSE = nil unless ENV['OUTPUT_WARNINGS'] +end diff --git a/spec/mspec/lib/mspec/version.rb b/spec/mspec/lib/mspec/version.rb new file mode 100644 index 0000000000..9126f5366e --- /dev/null +++ b/spec/mspec/lib/mspec/version.rb @@ -0,0 +1,5 @@ +require 'mspec/utils/version' + +module MSpec + VERSION = SpecVersion.new "1.8.0" +end |