diff options
Diffstat (limited to 'spec/mspec/lib')
130 files changed, 7870 insertions, 0 deletions
diff --git a/spec/mspec/lib/mspec.rb b/spec/mspec/lib/mspec.rb new file mode 100644 index 0000000000..d24abd96f1 --- /dev/null +++ b/spec/mspec/lib/mspec.rb @@ -0,0 +1,8 @@ +require 'mspec/utils/format' +require 'mspec/matchers' +require 'mspec/expectations' +require 'mspec/mocks' +require 'mspec/runner' +require 'mspec/guards' +require 'mspec/helpers' +require 'mspec/version' diff --git a/spec/mspec/lib/mspec/commands/mkspec.rb b/spec/mspec/lib/mspec/commands/mkspec.rb new file mode 100644 index 0000000000..f75e683b19 --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mkspec.rb @@ -0,0 +1,143 @@ +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_relative '#{parents}spec_helper'" + 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 + command = "#{RbConfig.ruby} #{MSPEC_HOME}/bin/mspec-run --dry-run --unguarded -fs -e '#{meth}' #{file}" + puts "$ #{command}" if $DEBUG + out = `#{command}` + 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 + + 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..8951572f69 --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mspec-ci.rb @@ -0,0 +1,76 @@ +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.repeat + options.pretend + options.interrupt + options.timeout + + 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..0fb338fa23 --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mspec-run.rb @@ -0,0 +1,87 @@ +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.env + options.randomize + options.repeat + options.pretend + options.interrupt + options.timeout + + 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 Launchable" + options.launchable + + 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..9ce9f048c6 --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mspec-tag.rb @@ -0,0 +1,132 @@ +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.pretend + options.unguarded + options.interrupt + options.timeout + + 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 + config[:xtags] = [] + 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 100644 index 0000000000..a9d94ca354 --- /dev/null +++ b/spec/mspec/lib/mspec/commands/mspec.rb @@ -0,0 +1,113 @@ +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 = options + + 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("-j", "--multi", "Run multiple (possibly parallel) subprocesses") do + config[:multi] = true + 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) + require 'mspec/runner/formatters/multi' + formatter = config_formatter.extend(MultiFormatter) + + require 'mspec/runner/parallel' + processes = cores(@files.size) + ParallelRunner.new(@files, processes, formatter, argv).run + 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 + log = config[:options].include?('--error-output') ? $stdout : $stderr + log.puts "$ #{argv.join(' ')}" + log.flush + exec(*argv, close_others: false) + 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..09852ab557 --- /dev/null +++ b/spec/mspec/lib/mspec/expectations/expectations.rb @@ -0,0 +1,39 @@ +class SpecExpectationNotMetError < StandardError +end + +class SpecExpectationNotFoundError < StandardError + def message + "No behavior expectation was found in the example" + end +end + +class SkippedSpecError < StandardError +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}\n#{actual_to_s}" + else + message = "#{expected_to_s} #{actual_to_s}" + end + raise SpecExpectationNotMetError, message + end + + def self.fail_predicate(receiver, predicate, args, block, result, expectation) + receiver_to_s = MSpec.format(receiver) + before_method = predicate.to_s =~ /^[a-z]/ ? "." : " " + predicate_to_s = "#{before_method}#{predicate}" + predicate_to_s += " " unless args.empty? + args_to_s = args.map { |arg| MSpec.format(arg) }.join(', ') + args_to_s += " { ... }" if block + result_to_s = MSpec.format(result) + raise SpecExpectationNotMetError, "Expected #{receiver_to_s}#{predicate_to_s}#{args_to_s}\n#{expectation} but was #{result_to_s}" + end + + def self.fail_single_arg_predicate(receiver, predicate, arg, result, expectation) + fail_predicate(receiver, predicate, [arg], nil, result, expectation) + 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..c1790e0ac8 --- /dev/null +++ b/spec/mspec/lib/mspec/expectations/should.rb @@ -0,0 +1,41 @@ +class Object + NO_MATCHER_GIVEN = Object.new + + def should(matcher = NO_MATCHER_GIVEN, &block) + MSpec.expectation + state = MSpec.current.state + raise "should outside example" unless state + MSpec.actions :expectation, state + + if NO_MATCHER_GIVEN.equal?(matcher) + SpecPositiveOperatorMatcher.new(self) + else + # The block was given to #should syntactically, but it was intended for a matcher like #raise_error + matcher.block = block if block + + unless matcher.matches? self + expected, actual = matcher.failure_message + SpecExpectation.fail_with(expected, actual) + end + end + end + + def should_not(matcher = NO_MATCHER_GIVEN, &block) + MSpec.expectation + state = MSpec.current.state + raise "should_not outside example" unless state + MSpec.actions :expectation, state + + if NO_MATCHER_GIVEN.equal?(matcher) + SpecNegativeOperatorMatcher.new(self) + else + # The block was given to #should_not syntactically, but it was intended for the matcher + matcher.block = block if block + + if matcher.matches? self + expected, actual = matcher.negative_failure_message + SpecExpectation.fail_with(expected, actual) + end + 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..454ac0c776 --- /dev/null +++ b/spec/mspec/lib/mspec/guards.rb @@ -0,0 +1,11 @@ +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..ae736a2d4e --- /dev/null +++ b/spec/mspec/lib/mspec/guards/block_device.rb @@ -0,0 +1,16 @@ +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 + +def with_block_device(&block) + BlockDeviceGuard.new.run_if(:with_block_device, &block) +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..a6af0ef964 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/bug.rb @@ -0,0 +1,29 @@ +require 'mspec/guards/version' + +class BugGuard < VersionGuard + def initialize(bug, requirement) + @bug = bug + if String === requirement + MSpec.deprecate "ruby_bug with a single version", 'an exclusive range ("2.1"..."2.3")' + super(FULL_RUBY_VERSION, requirement) + @requirement = SpecVersion.new requirement, true + else + super(FULL_RUBY_VERSION, requirement) + end + end + + def match? + return false if MSpec.mode? :no_ruby_bug + return false unless PlatformGuard.standard? + + if Range === @requirement + super + else + FULL_RUBY_VERSION <= @requirement + end + end +end + +def ruby_bug(bug, requirement, &block) + BugGuard.new(bug, requirement).run_unless(:ruby_bug, &block) +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..4930e5734d --- /dev/null +++ b/spec/mspec/lib/mspec/guards/conflict.rb @@ -0,0 +1,23 @@ +require 'mspec/guards/guard' +require 'mspec/utils/deprecate' + +class ConflictsGuard < SpecGuard + def initialize(*args) + MSpec.deprecate 'conflicts_with', 'guard -> { condition } do' + super(*args) + end + + 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 + +# 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 diff --git a/spec/mspec/lib/mspec/guards/endian.rb b/spec/mspec/lib/mspec/guards/endian.rb new file mode 100644 index 0000000000..79335a8933 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/endian.rb @@ -0,0 +1,25 @@ +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 + +def big_endian(&block) + BigEndianGuard.new.run_if(:big_endian, &block) +end + +def little_endian(&block) + BigEndianGuard.new.run_unless(:little_endian, &block) +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..d4c6dd1cde --- /dev/null +++ b/spec/mspec/lib/mspec/guards/feature.rb @@ -0,0 +1,45 @@ +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 + +# 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 + +def without_feature(*features, &block) + FeatureGuard.new(*features).run_unless(:without_feature, &block) +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..3a6372a660 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/guard.rb @@ -0,0 +1,141 @@ +require 'mspec/runner/mspec' +require 'mspec/runner/actions/tally' + +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 + if block + yield if yield?(false) + else + yield?(false) + end + ensure + unregister + end + + def run_unless(name, &block) + @name = name + if block + yield if yield?(true) + else + yield?(true) + end + 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.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 + +# Combined guards + +def guard(condition, &block) + raise "condition must be a Proc" unless condition.is_a?(Proc) + raise LocalJumpError, "no block given" unless block + return yield if MSpec.mode? :unguarded or MSpec.mode? :verify or MSpec.mode? :report + yield if condition.call +end + +def guard_not(condition, &block) + raise "condition must be a Proc" unless condition.is_a?(Proc) + raise LocalJumpError, "no block given" unless block + return yield if MSpec.mode? :unguarded or MSpec.mode? :verify or MSpec.mode? :report + yield unless condition.call +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..fadd8d75ef --- /dev/null +++ b/spec/mspec/lib/mspec/guards/platform.rb @@ -0,0 +1,122 @@ +require 'mspec/guards/guard' + +class PlatformGuard < SpecGuard + def self.implementation?(*args) + args.any? do |name| + case name + when :rubinius + RUBY_ENGINE.start_with?('rbx') + else + RUBY_ENGINE.start_with?(name.to_s) + end + end + end + + def self.standard? + implementation? :ruby + end + + PLATFORM = if RUBY_ENGINE == "jruby" + require 'rbconfig' + "#{RbConfig::CONFIG['host_cpu']}-#{RbConfig::CONFIG['host_os']}" + else + RUBY_PLATFORM + end + + def self.os?(*oses) + oses.any? do |os| + raise ":java is not a valid OS" if os == :java + case os + when :windows + PLATFORM =~ /(mswin|mingw)/ + when :wsl + wsl? + else + PLATFORM.include?(os.to_s) + end + end + end + + def self.windows? + os?(:windows) + end + + def self.wasi? + os?(:wasi) + end + + def self.wsl? + if defined?(@wsl_p) + @wsl_p + else + @wsl_p = `uname -r`.match?(/microsoft/i) + end + end + + # In bits + WORD_SIZE = 1.size * 8 + deprecate_constant :WORD_SIZE + + # In bits + POINTER_SIZE = begin + require 'rbconfig/sizeof' + RbConfig::SIZEOF["void*"] * 8 + rescue LoadError + [0].pack('j').size * 8 + end + + # In bits + C_LONG_SIZE = if defined?(RbConfig::SIZEOF[]) + RbConfig::SIZEOF["long"] * 8 + else + [0].pack('l!').size * 8 + end + + def self.wordsize?(size) + warn "#wordsize? is deprecated, use #c_long_size?" + size == WORD_SIZE + end + + def self.pointer_size?(size) + size == POINTER_SIZE + end + + def self.c_long_size?(size) + size == C_LONG_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 :pointer_size + match &&= PlatformGuard.pointer_size? value + when :wordsize + warn ":wordsize is deprecated, use :c_long_size" + match &&= PlatformGuard.wordsize? value + when :c_long_size + match &&= PlatformGuard::c_long_size? value + end + end + match + end +end + +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 diff --git a/spec/mspec/lib/mspec/guards/quarantine.rb b/spec/mspec/lib/mspec/guards/quarantine.rb new file mode 100644 index 0000000000..ec4d01f9ea --- /dev/null +++ b/spec/mspec/lib/mspec/guards/quarantine.rb @@ -0,0 +1,11 @@ +require 'mspec/guards/guard' + +class QuarantineGuard < SpecGuard + def match? + true + end +end + +def quarantine!(&block) + QuarantineGuard.new.run_unless(:quarantine!, &block) +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..24daf9b26c --- /dev/null +++ b/spec/mspec/lib/mspec/guards/superuser.rb @@ -0,0 +1,25 @@ +require 'mspec/guards/guard' + +class SuperUserGuard < SpecGuard + def match? + Process.euid == 0 + end +end + +class RealSuperUserGuard < SpecGuard + def match? + Process.uid == 0 + end +end + +def as_superuser(&block) + SuperUserGuard.new.run_if(:as_superuser, &block) +end + +def as_real_superuser(&block) + RealSuperUserGuard.new.run_if(:as_real_superuser, &block) +end + +def as_user(&block) + SuperUserGuard.new.run_unless(:as_user, &block) +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..790bea1077 --- /dev/null +++ b/spec/mspec/lib/mspec/guards/support.rb @@ -0,0 +1,14 @@ +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 + +def not_supported_on(*args, &block) + SupportedGuard.new(*args).run_unless(:not_supported_on, &block) +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..f5ea1988ae --- /dev/null +++ b/spec/mspec/lib/mspec/guards/version.rb @@ -0,0 +1,72 @@ +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, requirement) + version = SpecVersion.new(version) unless SpecVersion === version + @version = version + + case requirement + when String + @requirement = SpecVersion.new requirement + when Range + MSpec.deprecate "an empty version range end", 'a specific version' if requirement.end.empty? + a = SpecVersion.new requirement.begin + b = SpecVersion.new requirement.end + unless requirement.exclude_end? + MSpec.deprecate "ruby_version_is with an inclusive range", 'an exclusive range ("2.1"..."2.3")' + end + @requirement = requirement.exclude_end? ? a...b : a..b + else + raise "version must be a String or Range but was a #{requirement.class}" + end + super(@version, @requirement) + end + + def match? + if Range === @requirement + @requirement.include? @version + else + @version >= @requirement + end + end + + @kernel_version = nil + def self.kernel_version + if @kernel_version + @kernel_version + else + if v = RUBY_PLATFORM[/darwin(\d+)/, 1] # build time version + uname = v + else + begin + require 'etc' + etc = true + rescue LoadError + etc = false + end + if etc and Etc.respond_to?(:uname) + uname = Etc.uname.fetch(:release) + else + uname = `uname -r`.chomp + end + end + @kernel_version = uname + end + end +end + +def version_is(base_version, requirement, &block) + VersionGuard.new(base_version, requirement).run_if(:version_is, &block) +end + +def ruby_version_is(requirement, &block) + VersionGuard.new(VersionGuard::FULL_RUBY_VERSION, requirement).run_if(:ruby_version_is, &block) +end + +def kernel_version_is(requirement, &block) + VersionGuard.new(VersionGuard.kernel_version, requirement).run_if(:kernel_version_is, &block) +end diff --git a/spec/mspec/lib/mspec/helpers.rb b/spec/mspec/lib/mspec/helpers.rb new file mode 100644 index 0000000000..90f9fd3fd4 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers.rb @@ -0,0 +1,13 @@ +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' +require 'mspec/helpers/warning' diff --git a/spec/mspec/lib/mspec/helpers/argf.rb b/spec/mspec/lib/mspec/helpers/argf.rb new file mode 100644 index 0000000000..4d3e0f46b3 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/argf.rb @@ -0,0 +1,35 @@ +# 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 diff --git a/spec/mspec/lib/mspec/helpers/argv.rb b/spec/mspec/lib/mspec/helpers/argv.rb new file mode 100644 index 0000000000..9dac384dbd --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/argv.rb @@ -0,0 +1,44 @@ +# 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 diff --git a/spec/mspec/lib/mspec/helpers/datetime.rb b/spec/mspec/lib/mspec/helpers/datetime.rb new file mode 100644 index 0000000000..84ac86b686 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/datetime.rb @@ -0,0 +1,48 @@ +# 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 = "") + skip "WASI doesn't have TZ concept" if PlatformGuard.wasi? + 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 diff --git a/spec/mspec/lib/mspec/helpers/fixture.rb b/spec/mspec/lib/mspec/helpers/fixture.rb new file mode 100644 index 0000000000..f3bbe423bd --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/fixture.rb @@ -0,0 +1,24 @@ +# 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 diff --git a/spec/mspec/lib/mspec/helpers/flunk.rb b/spec/mspec/lib/mspec/helpers/flunk.rb new file mode 100644 index 0000000000..84fb3ab39c --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/flunk.rb @@ -0,0 +1,3 @@ +def flunk(msg = "This example is a failure") + SpecExpectation.fail_with "Failed:", msg +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..67453eb302 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/fs.rb @@ -0,0 +1,64 @@ +# Copies a file +def cp(source, dest) + IO.copy_stream source, dest +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 + + unless File.directory? name + begin + Dir.mkdir name + rescue Errno::EEXIST => e + if File.directory? name + # OK, another process/thread created the same directory + else + raise e + end + end + end + 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 diff --git a/spec/mspec/lib/mspec/helpers/io.rb b/spec/mspec/lib/mspec/helpers/io.rb new file mode 100644 index 0000000000..2ad14f47a1 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/io.rb @@ -0,0 +1,87 @@ +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 + +# 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") + 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, mode +end + +# Creates an IO instance for a temporary file name. The file +# must be deleted. +def new_io(name, mode = "w:utf-8") + if Hash === mode # Avoid kwargs warnings on Ruby 2.7+ + File.new(name, **mode) + else + File.new(name, mode) + 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..2780afc54a --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/mock_to_path.rb @@ -0,0 +1,6 @@ +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 diff --git a/spec/mspec/lib/mspec/helpers/numeric.rb b/spec/mspec/lib/mspec/helpers/numeric.rb new file mode 100644 index 0000000000..0b47855cd2 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/numeric.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true +require 'mspec/guards/platform' + +def nan_value + 0/0.0 +end + +def infinity_value + 1/0.0 +end + +def bignum_value(plus = 0) + # Must be >= fixnum_max + 2, so -bignum_value is < fixnum_min + # A fixed value has the advantage to be the same numeric value for all Rubies and is much easier to spec + (2**64) + plus +end + +def max_long + 2**(PlatformGuard::C_LONG_SIZE - 1) - 1 +end + +def min_long + -(2**(PlatformGuard::C_LONG_SIZE - 1)) +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 + limits_available = begin + require 'rbconfig/sizeof' + defined?(RbConfig::LIMITS.[]) && ['FIXNUM_MAX', 'FIXNUM_MIN'].all? do |key| + Integer === RbConfig::LIMITS[key] + end + rescue LoadError + false + end + + if limits_available + def fixnum_max + RbConfig::LIMITS['FIXNUM_MAX'] + end + + def fixnum_min + RbConfig::LIMITS['FIXNUM_MIN'] + end + elsif PlatformGuard.c_long_size? 32 + def fixnum_max + (2**30) - 1 + end + + def fixnum_min + -(2**30) + end + elsif PlatformGuard.c_long_size? 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 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..2e499d6f9a --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/ruby_exe.rb @@ -0,0 +1,205 @@ +require 'mspec/guards/platform' +require 'mspec/helpers/tmp' + +# The ruby_exe helper provides a wrapper for invoking the +# same Ruby interpreter with the same flags 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+ will be written to a temporary file and be run. +# For example: +# +# ruby_exe('path/to/some/file.rb') +# +# will be executed as +# +# `#{RUBY_EXE} 'path/to/some/file.rb'` +# +# The ruby_exe helper also accepts an options hash with four +# keys: :options, :args :env and :exception. +# +# For example: +# +# ruby_exe('file.rb', :options => "-w", +# :args => "arg1 arg2", +# :env => { :FOO => "bar" }) +# +# will be executed as +# +# `#{RUBY_EXE} -w file.rb arg1 arg2` +# +# with access to ENV["FOO"] with value "bar". +# +# When `exception: false` and Ruby command fails then exception will not be +# raised. +# +# If +nil+ is passed for the first argument, the command line +# will be built only from the options hash. +# +# If no arguments are passed to ruby_exe, it returns an Array +# containing the interpreter executable and the flags: +# +# spawn(*ruby_exe, "-e", "puts :hello") +# +# This avoids spawning an extra shell, and ensure the pid returned by spawn +# corresponds to the ruby process and not the shell. +# +# 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 will only be used if the file exists and is executable. +# The flags will then be appended to the resulting value, such that +# the RUBY_EXE constant contains both the executable and the flags. +# +# 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. +# +# Failure of a Ruby command leads to raising exception by default. + +def ruby_exe_options(option) + case option + when :env + ENV['RUBY_EXE'] + when :engine + case RUBY_ENGINE + 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_ENGINE + (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'] || '') + 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 + +unless Object.const_defined?(:RUBY_EXE) and RUBY_EXE + RUBY_EXE = resolve_ruby_exe +end + +def ruby_exe(code = :not_given, opts = {}) + skip "WASI doesn't provide subprocess" if PlatformGuard.wasi? + + if opts[:dir] + raise "ruby_exe(..., dir: dir) is no longer supported, use Dir.chdir" + end + + if code == :not_given + return RUBY_EXE.split(' ') + 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 + + expected_status = opts.fetch(:exit_status, 0) + + begin + command = ruby_cmd(code, opts) + + # Try to avoid the extra shell for 2>&1 + # This is notably useful for TimeoutAction which can then signal the ruby subprocess and not the shell + popen_options = [] + if command.end_with?(' 2>&1') + command = command[0...-5] + popen_options = [{ err: [:child, :out] }] + end + + output = IO.popen(command, *popen_options) do |io| + pid = io.pid + MSpec.subprocesses << pid + begin + io.read + ensure + MSpec.subprocesses.delete(pid) + end + end + + status = Process.last_status + + exit_status = if status.exited? + status.exitstatus + elsif status.signaled? + signame = Signal.signame status.termsig + raise "No signal name?" unless signame + :"SIG#{signame}" + else + raise SpecExpectationNotMetError, "#{exit_status.inspect} is neither exited? nor signaled?" + end + if exit_status != expected_status + formatted_output = output.lines.map { |line| " #{line}" }.join + raise SpecExpectationNotMetError, + "Expected exit status is #{expected_status.inspect} but actual is #{exit_status.inspect} for command ruby_exe(#{command.inspect})\nOutput:\n#{formatted_output}" + end + + output + 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 + + command = [RUBY_EXE, opts[:options], body, opts[:args]].compact.join(' ') + STDERR.puts "\nruby_cmd: #{command}" if ENV["DEBUG_MSPEC_RUBY_CMD"] == "true" + command +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..0da3315cd8 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/scratch.rb @@ -0,0 +1,21 @@ +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 + + def self.inspect + "<ScratchPad @record=#{@record.inspect}>" + 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..e903dd9f50 --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/tmp.rb @@ -0,0 +1,62 @@ +# 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_PID = Process.pid + +if spec_temp_dir = ENV["SPEC_TEMP_DIR"] + spec_temp_dir = File.realdirpath(spec_temp_dir) +else + spec_temp_dir = "#{File.realpath(Dir.pwd)}/rubyspec_temp/#{SPEC_TEMP_DIR_PID}" +end +SPEC_TEMP_DIR = spec_temp_dir + +SPEC_TEMP_UNIQUIFIER = +"0" + +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 + +def tmp(name, uniquify = true) + if Dir.exist? SPEC_TEMP_DIR + stat = File.stat(SPEC_TEMP_DIR) + if stat.world_writable? and !stat.sticky? + raise ArgumentError, "SPEC_TEMP_DIR (#{SPEC_TEMP_DIR}) is world writable but not sticky" + end + else + platform_is_not :windows do + umask = File.umask + if (umask & 0002) == 0 # o+w + raise ArgumentError, "File.umask #=> #{umask.to_s(8)} (world-writable)" + end + end + mkdir_p SPEC_TEMP_DIR + end + + if uniquify and !name.empty? + slash = name.rindex "/" + index = slash ? slash + 1 : 0 + name = +name + name.insert index, "#{SPEC_TEMP_UNIQUIFIER.succ!}-" + end + + File.join SPEC_TEMP_DIR, name +end diff --git a/spec/mspec/lib/mspec/helpers/warning.rb b/spec/mspec/lib/mspec/helpers/warning.rb new file mode 100644 index 0000000000..e3d72b78bd --- /dev/null +++ b/spec/mspec/lib/mspec/helpers/warning.rb @@ -0,0 +1,21 @@ +require 'mspec/guards/version' + +# You might be looking for #silence_warnings, use #suppress_warning instead. +# MSpec calls it #suppress_warning for consistency with EnvUtil.suppress_warning in CRuby test/. +def suppress_warning + verbose = $VERBOSE + $VERBOSE = nil + yield +ensure + $VERBOSE = verbose +end + +if ruby_version_is("2.7") + def suppress_keyword_warning(&block) + suppress_warning(&block) + end +else + def suppress_keyword_warning + yield + end +end diff --git a/spec/mspec/lib/mspec/matchers.rb b/spec/mspec/lib/mspec/matchers.rb new file mode 100644 index 0000000000..356e4a9f32 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers.rb @@ -0,0 +1,37 @@ +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/include_any_of' +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' +require 'mspec/matchers/skip' diff --git a/spec/mspec/lib/mspec/matchers/base.rb b/spec/mspec/lib/mspec/matchers/base.rb new file mode 100644 index 0000000000..3534520d88 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/base.rb @@ -0,0 +1,95 @@ +module MSpecMatchers +end + +class MSpecEnv + include MSpecMatchers +end + +# Expectations are sometimes used in a module body +class Module + include MSpecMatchers +end + +class SpecPositiveOperatorMatcher < BasicObject + def initialize(actual) + @actual = actual + end + + def ==(expected) + result = @actual == expected + unless result + ::SpecExpectation.fail_single_arg_predicate(@actual, :==, expected, result, "to be truthy") + end + end + + def !=(expected) + result = @actual != expected + unless result + ::SpecExpectation.fail_single_arg_predicate(@actual, :!=, expected, result, "to be truthy") + end + end + + def equal?(expected) + result = @actual.equal?(expected) + unless result + ::SpecExpectation.fail_single_arg_predicate(@actual, :equal?, expected, result, "to be truthy") + end + end + + def raise(exception = ::Exception, message = nil, options = nil, &block) + matcher = ::RaiseErrorMatcher.new(exception, message, options, &block) + unless matcher.matches? @actual + expected, actual = matcher.failure_message + ::SpecExpectation.fail_with(expected, actual) + end + end + + def method_missing(name, *args, &block) + result = @actual.__send__(name, *args, &block) + unless result + ::SpecExpectation.fail_predicate(@actual, name, args, block, result, "to be truthy") + end + end +end + +class SpecNegativeOperatorMatcher < BasicObject + def initialize(actual) + @actual = actual + end + + def ==(expected) + result = @actual == expected + if result + ::SpecExpectation.fail_single_arg_predicate(@actual, :==, expected, result, "to be falsy") + end + end + + def !=(expected) + result = @actual != expected + if result + ::SpecExpectation.fail_single_arg_predicate(@actual, :!=, expected, result, "to be falsy") + end + end + + def equal?(expected) + result = @actual.equal?(expected) + if result + ::SpecExpectation.fail_single_arg_predicate(@actual, :equal?, expected, result, "to be falsy") + end + end + + def raise(exception = ::Exception, message = nil, options = nil, &block) + matcher = ::RaiseErrorMatcher.new(exception, message, options, &block) + if matcher.matches? @actual + expected, actual = matcher.negative_failure_message + ::SpecExpectation.fail_with(expected, actual) + end + end + + def method_missing(name, *args, &block) + result = @actual.__send__(name, *args, &block) + if result + ::SpecExpectation.fail_predicate(@actual, name, args, block, result, "to be falsy") + 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..fdf3736ac2 --- /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 + +module MSpecMatchers + private 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..05f72099e4 --- /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 + +module MSpecMatchers + private 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..d6a6626f31 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/be_close.rb @@ -0,0 +1,29 @@ +TOLERANCE = 0.00003 unless Object.const_defined?(:TOLERANCE) +# To account for GC, context switches, other processes, load, etc. +TIME_TOLERANCE = 20.0 unless Object.const_defined?(:TIME_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 #{@actual}", "to be within #{@expected} +/- #{@tolerance}"] + end + + def negative_failure_message + ["Expected #{@actual}", "not to be within #{@expected} +/- #{@tolerance}"] + end +end + +module MSpecMatchers + private 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..2e31bc93af --- /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 + +module MSpecMatchers + private 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..5abd5c9485 --- /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 + +module MSpecMatchers + private 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..9e9a2608e1 --- /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 + +module MSpecMatchers + private def be_false + BeFalseMatcher.new + end +end 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..a69906f210 --- /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 + +module MSpecMatchers + private 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..b279d8f1cf --- /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 + +module MSpecMatchers + private 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..049b1e3a53 --- /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 + +module MSpecMatchers + private def be_nil + BeNilMatcher.new + end +end 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..52f5013752 --- /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 + +module MSpecMatchers + private def be_true + BeTrueMatcher.new + end +end 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..4294b08d1b --- /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 + +module MSpecMatchers + private 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..30fab4fc68 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/block_caller.rb @@ -0,0 +1,37 @@ +class BlockingMatcher + def matches?(block) + t = Thread.new do + block.call + end + + loop do + case t.status + when "sleep" # blocked + t.kill + t.join + return true + when false # terminated normally, so never blocked + t.join + return false + when nil # terminated exceptionally + t.value + else + Thread.pass + end + end + 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 + +module MSpecMatchers + private def block_caller + 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..19310c0bbb --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/complain.rb @@ -0,0 +1,69 @@ +require 'mspec/helpers/io' + +class ComplainMatcher + def initialize(complaint = nil, options = nil) + # the proper solution is to use double splat operator e.g. + # def initialize(complaint = nil, **options) + # but we are trying to minimize language features required to run MSpec + if complaint.is_a?(Hash) + @complaint = nil + @options = complaint + else + @complaint = complaint + @options = options || {} + end + end + + def matches?(proc) + @saved_err = $stderr + @verbose = $VERBOSE + err = IOStub.new + + $stderr = err + $VERBOSE = @options.key?(:verbose) ? @options[:verbose] : false + begin + proc.call + ensure + $VERBOSE = @verbose + $stderr = @saved_err + end + + @warning = err.to_s + unless @complaint.nil? + case @complaint + when Regexp + return false unless @warning =~ @complaint + else + return false unless @warning == @complaint + end + end + + return @warning.empty? ? false : true + 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: #{@warning.chomp.inspect}"] + else + ["Expected warning: #{@complaint.inspect}", "but got: #{@warning.chomp.inspect}"] + end + end + + def negative_failure_message + if @complaint.nil? + ["Unexpected warning: ", @warning.chomp.inspect] + elsif @complaint.kind_of? Regexp + ["Expected warning not to match: #{@complaint.inspect}", "but got: #{@warning.chomp.inspect}"] + else + ["Expected warning: #{@complaint.inspect}", "but got: #{@warning.chomp.inspect}"] + end + end +end + +module MSpecMatchers + private def complain(complaint = nil, options = nil) + ComplainMatcher.new(complaint, options) + 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..bcab88ebee --- /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 #{MSpec.format(@actual)}", + "to have same value and type as #{MSpec.format(@expected)}"] + end + + def negative_failure_message + ["Expected #{MSpec.format(@actual)}", + "not to have same value or type as #{MSpec.format(@expected)}"] + end +end + +module MSpecMatchers + private 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..5ba4856d82 --- /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 #{MSpec.format(@actual)}", + "to be identical to #{MSpec.format(@expected)}"] + end + + def negative_failure_message + ["Expected #{MSpec.format(@actual)}", + "not to be identical to #{MSpec.format(@expected)}"] + end +end + +module MSpecMatchers + private 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..8da2567fcf --- /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 #{MSpec.format(@actual)}", + "to be a '#{@element}' element with #{attributes_for_failure_message} and #{content_for_failure_message}"] + end + + def negative_failure_message + ["Expected #{MSpec.format(@actual)}", + "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 + +module MSpecMatchers + private def equal_element(*args) + EqualElementMatcher.new(*args) + end +end 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..dd43ced621 --- /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 + +module MSpecMatchers + private def have_class_variable(variable) + HaveClassVariableMatcher.new(variable) + end +end 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..6ec7c75b85 --- /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 + +module MSpecMatchers + private 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..9a5a31aa0f --- /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 + +module MSpecMatchers + private 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..de51b3209d --- /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 + +module MSpecMatchers + private def have_instance_variable(variable) + HaveInstanceVariableMatcher.new(variable) + end +end 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..e962e69e0a --- /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 + +module MSpecMatchers + private 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..d32db76c6a --- /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 + +module MSpecMatchers + private 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..c74165cfc7 --- /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 + +module MSpecMatchers + private 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..1deb2f995d --- /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 + +module MSpecMatchers + private 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..0e620532c0 --- /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 + +module MSpecMatchers + private 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..b60dd2536b --- /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 + +module MSpecMatchers + private 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..3f07f35548 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/include.rb @@ -0,0 +1,31 @@ +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 #{MSpec.format(@actual)}", "to include #{MSpec.format(@element)}"] + end + + def negative_failure_message + ["Expected #{MSpec.format(@actual)}", "not to include #{MSpec.format(@element)}"] + end +end + +# Cannot override #include at the toplevel in MRI +module MSpecMatchers + private def include(*expected) + IncludeMatcher.new(*expected) + end +end diff --git a/spec/mspec/lib/mspec/matchers/include_any_of.rb b/spec/mspec/lib/mspec/matchers/include_any_of.rb new file mode 100644 index 0000000000..ce097ccf0f --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/include_any_of.rb @@ -0,0 +1,29 @@ +class IncludeAnyOfMatcher + def initialize(*expected) + @expected = expected + end + + def matches?(actual) + @actual = actual + @expected.each do |e| + if @actual.include?(e) + return true + end + end + return false + end + + def failure_message + ["Expected #{@actual.inspect}", "to include any of #{@expected.inspect}"] + end + + def negative_failure_message + ["Expected #{@actual.inspect}", "not to include any of #{@expected.inspect}"] + end +end + +module MSpecMatchers + private def include_any_of(*expected) + IncludeAnyOfMatcher.new(*expected) + end +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..8bfa6dbd10 --- /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 + +module MSpecMatchers + private def be_positive_infinity + InfinityMatcher.new(1) + end + + private 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..30561627c3 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/match_yaml.rb @@ -0,0 +1,50 @@ +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 + if YAML.respond_to?(:unsafe_load) + YAML.unsafe_load(obj) + else + YAML.load(obj) + end + rescue + false + else + true + end + end +end + +module MSpecMatchers + private 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..2b54419faa --- /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..5bb5d55027 --- /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: #{MSpec.format(@out)}\n" + actual_out += " $stdout: #{MSpec.format(@stdout.to_s)}\n" + end + unless @err.nil? + expected_out += " $stderr: #{MSpec.format(@err)}\n" + actual_out += " $stderr: #{MSpec.format(@stderr.to_s)}\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 + +module MSpecMatchers + private 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..f4d7b4ea1f --- /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 + +module MSpecMatchers + private 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..8cba842ce3 --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/raise_error.rb @@ -0,0 +1,132 @@ +class RaiseErrorMatcher + FAILURE_MESSAGE_FOR_EXCEPTION = {}.compare_by_identity + UNDEF_CAUSE = Object.new + + attr_writer :block + + def initialize(exception, message = nil, options = nil, &block) + if message.is_a? Hash + @message = nil + options = message + else + @message = message + end + @cause = options ? options.fetch(:cause, UNDEF_CAUSE) : UNDEF_CAUSE + @exception = exception + @block = block + @actual = nil + end + + # This #matches? method is unusual because it doesn't always return a boolean but instead + # re-raises the original exception if proc.call raises an exception and #matching_exception? is false. + # The reasoning is the original exception class matters and we don't want to change it by raising another exception, + # so instead we attach the #failure_message and extract it in ExceptionState#message. + def matches?(proc) + @result = proc.call + return false + rescue Object => actual + @actual = actual + + if matching_exception?(actual) + # The block has its own expectations and will throw an exception if it fails + @block[actual] if @block + return true + else + FAILURE_MESSAGE_FOR_EXCEPTION[actual] = failure_message + raise actual + end + end + + def matching_class?(exc) + @exception === exc + end + + def matching_message?(exc) + case @message + when String + @message == exc.message + when Regexp + @message =~ exc.message + else + true + end + end + + def matching_cause?(exc) + case @cause + when UNDEF_CAUSE + true + else + @cause == exc.cause + end + end + + def matching_exception?(exc) + matching_class?(exc) and matching_message?(exc) and matching_cause?(exc) + end + + def exception_class_and_message_and_cause(exception_class, message, cause) + string = "#{exception_class}" + prefixed = false + prefix = -> { prefixed ? ", " : prefixed = "(" } + + if message != nil + string << "#{prefix.()}#{message.inspect}" + end + + if cause != UNDEF_CAUSE + string << "#{prefix.()}cause: #{cause.inspect}" + end + + string << ")" if prefixed + + string + end + + def format_expected_exception + exception_class_and_message_and_cause(@exception, @message, @cause) + end + + def format_exception(exception) + exception_class_and_message_and_cause(exception.class, + @message == nil ? nil : exception.message, + @cause == UNDEF_CAUSE ? UNDEF_CAUSE : exception.cause) + end + + def failure_message + message = ["Expected #{format_expected_exception}"] + + if @actual + message << "but got: #{format_exception(@actual)}" + else + message << "but no exception was raised (#{MSpec.format(@result)} 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 + +module MSpecMatchers + private def raise_error(exception = Exception, message = nil, options = nil, &block) + RaiseErrorMatcher.new(exception, message, options, &block) + end + + # CRuby < 4.1 has inconsistent coercion errors: + # https://bugs.ruby-lang.org/issues/21864 + # This matcher ignores the message on CRuby < 4.1 + # and checks the message for all other cases, including other Rubies + private def raise_consistent_error(exception = Exception, message = nil, options = nil, &block) + if RUBY_ENGINE == "ruby" and ruby_version_is ""..."4.1" + message = nil + end + RaiseErrorMatcher.new(exception, message, options, &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..6b35ae2d3c --- /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 + +module MSpecMatchers + private 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..2ff90f4994 --- /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 + +module MSpecMatchers + private def be_positive_zero + SignedZeroMatcher.new(1) + end + + private def be_negative_zero + SignedZeroMatcher.new(-1) + end +end diff --git a/spec/mspec/lib/mspec/matchers/skip.rb b/spec/mspec/lib/mspec/matchers/skip.rb new file mode 100644 index 0000000000..7c175d358d --- /dev/null +++ b/spec/mspec/lib/mspec/matchers/skip.rb @@ -0,0 +1,5 @@ +module MSpecMatchers + private def skip(reason = 'no reason') + raise SkippedSpecError, reason + 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..c61ba35ea7 --- /dev/null +++ b/spec/mspec/lib/mspec/mocks/mock.rb @@ -0,0 +1,209 @@ +require 'mspec/expectations/expectations' +require 'mspec/helpers/warning' + +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(key) + :"__mspec_#{key.last}__" + end + + def self.replaced_key(obj, sym) + [obj.__id__, sym] + end + + def self.replaced?(key) + mocks.include?(key) or stubs.include?(key) + end + + def self.clear_replaced(key) + mocks.delete key + stubs.delete key + end + + def self.mock_respond_to?(obj, sym, include_private = false) + key = replaced_key(obj, :respond_to?) + if replaced? key + name = replaced_name(key) + 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 type == :stub and mocks.key?(key) + # Defining a stub and there is already a mock, ignore the stub + return + end + + if (sym == :respond_to? or mock_respond_to?(obj, sym, true)) and !replaced?(key) + meta.__send__ :alias_method, replaced_name(key), sym + end + + suppress_warning { + 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.mock? and stubs.key?(key) + # Defining a mock and there is already a stub, remove the stub + stubs.delete key + 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.inspect_args(args) + "(#{Array(args).map(&:inspect).join(', ')})" + 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}#{inspect_args proxy.arguments} " + \ + "#{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.fetch(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, *args + else + SpecExpectation.fail_with("Mock '#{name_or_inspect obj}': method #{sym}\n", + "called with unexpected arguments #{inspect_args args}") + end + end + + def self.cleanup + objects.each do |key, obj| + if obj.kind_of? MockIntObject + clear_replaced key + next + end + + replaced = replaced_name(key) + sym = key.last + meta = obj.singleton_class + + if mock_respond_to? obj, replaced, true + suppress_warning do + meta.__send__ :alias_method, sym, replaced + end + 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..fcaa1caef0 --- /dev/null +++ b/spec/mspec/lib/mspec/mocks/object.rb @@ -0,0 +1,28 @@ +require 'mspec/mocks/proxy' + +class Object + 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 stub!(sym) + Mock.install_method self, sym, :stub + end +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 diff --git a/spec/mspec/lib/mspec/mocks/proxy.rb b/spec/mspec/lib/mspec/mocks/proxy.rb new file mode 100644 index 0000000000..8473132b0b --- /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? + !@yielding.empty? + 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/constants_leak_checker.rb b/spec/mspec/lib/mspec/runner/actions/constants_leak_checker.rb new file mode 100644 index 0000000000..abfb6dd0ee --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/constants_leak_checker.rb @@ -0,0 +1,84 @@ +class ConstantsLockFile + LOCK_FILE_NAME = '.mspec.constants' + + def self.lock_file + @prefix ||= File.expand_path(MSpecScript.get(:prefix) || '.') + "#{@prefix}/#{LOCK_FILE_NAME}" + end + + def self.load + if File.exist?(lock_file) + File.readlines(lock_file).map(&:chomp) + else + [] + end + end + + def self.dump(ary) + contents = ary.map(&:to_s).uniq.sort.join("\n") + "\n" + File.write(lock_file, contents) + end +end + +class ConstantLeakError < StandardError +end + +class ConstantsLeakCheckerAction + def initialize(save) + @save = save + @check = !save + @constants_locked = ConstantsLockFile.load + @exclude_patterns = MSpecScript.get(:toplevel_constants_excludes) || [] + end + + def register + MSpec.register :start, self + MSpec.register :before, self + MSpec.register :after, self + MSpec.register :finish, self + end + + def start + @constants_start = constants_now + end + + def before(state) + @constants_before = constants_now + end + + def after(state) + constants = remove_excludes(constants_now - @constants_before - @constants_locked) + + if @check && !constants.empty? + MSpec.protect 'Constants leak check' do + raise ConstantLeakError, "Top level constants leaked: #{constants.join(', ')}" + end + end + end + + def finish + constants = remove_excludes(constants_now - @constants_start - @constants_locked) + + if @save + ConstantsLockFile.dump(@constants_locked + constants) + end + + if @check && !constants.empty? + MSpec.protect 'Global constants leak check' do + raise ConstantLeakError, "Top level constants leaked in the whole test suite: #{constants.join(', ')}" + end + end + end + + private + + def constants_now + Object.constants.map(&:to_s) + end + + def remove_excludes(constants) + constants.reject { |name| + @exclude_patterns.any? { |pattern| pattern === name } + } + end +end 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..b0ad7080da --- /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..0a8c9c3252 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/leakchecker.rb @@ -0,0 +1,377 @@ +# 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 LeakError < StandardError +end + +class LeakChecker + attr_reader :leaks + + def initialize + @fd_info = find_fds + @tempfile_info = find_tempfiles + @thread_info = find_threads + @env_info = find_env + @argv_info = find_argv + @globals_info = find_globals + @encoding_info = find_encodings + end + + def check(state) + @state = state + @leaks = [] + check_fd_leak + check_tempfile_leak + check_thread_leak + check_process_leak + check_env + check_argv + check_globals + check_encodings + check_tracepoints + GC.start unless @leaks.empty? + @leaks.empty? + 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 + 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| + leak "Closed file descriptor: #{fd}" + } + end + fd_leaked = live2 - live1 + if !fd_leaked.empty? + 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 + leak "Leaked file descriptor: #{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 + leak "Multiple autoclose IO object for a file descriptor:#{str}" + end + } + end + @fd_info = live2 + end + + def extend_tempfile_counter + return if defined? LeakChecker::TempfileCounter + m = Module.new { + @count = 0 + class << self + attr_accessor :count + end + + def new(...) + LeakChecker::TempfileCounter.count += 1 + super + end + } + LeakChecker.const_set(:TempfileCounter, m) + + class << Tempfile + 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 + return false unless defined? Tempfile + count1, initial_tempfiles = @tempfile_info + count2, current_tempfiles = find_tempfiles(count1) + tempfiles_leaked = current_tempfiles - initial_tempfiles + if !tempfiles_leaked.empty? + list = tempfiles_leaked.map {|t| t.inspect }.sort + list.each {|str| + leak "Leaked tempfile: #{str}" + } + tempfiles_leaked.each {|t| t.close! } + end + @tempfile_info = [count2, initial_tempfiles] + end + + def find_threads + Thread.list.find_all {|t| + t != Thread.current && t.alive? && + !(t.thread_variable?(:"\0__detached_thread__") && t.thread_variable_get(:"\0__detached_thread__")) + } + end + + def check_thread_leak + live1 = @thread_info + live2 = find_threads + thread_finished = live1 - live2 + if !thread_finished.empty? + list = thread_finished.map {|t| t.inspect }.sort + list.each {|str| + leak "Finished thread: #{str}" + } + end + thread_leaked = live2 - live1 + if !thread_leaked.empty? + list = thread_leaked.map {|t| t.inspect }.sort + list.each {|str| + leak "Leaked thread: #{str}" + } + end + @thread_info = live2 + end + + def check_process_leak + subprocesses_leaked = Process.waitall + subprocesses_leaked.each { |pid, status| + leak "Leaked subprocess: #{pid}: #{status}" + } + end + + def find_env + ENV.to_h + end + + def check_env + old_env = @env_info + new_env = find_env + return 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] + leak "Environment variable changed : #{k.inspect} changed : #{old_env[k].inspect} -> #{new_env[k].inspect}" + end + else + leak "Environment variable changed: #{k.inspect} deleted" + end + else + if new_env.has_key?(k) + leak "Environment variable changed: #{k.inspect} added" + else + flunk "unreachable" + end + end + } + @env_info = new_env + end + + def find_argv + ARGV.map { |e| e.dup } + end + + def check_argv + old_argv = @argv_info + new_argv = find_argv + if new_argv != old_argv + leak "ARGV changed: #{old_argv.inspect} to #{new_argv.inspect}" + @argv_info = new_argv + end + end + + def find_globals + { verbose: $VERBOSE, debug: $DEBUG } + end + + def check_globals + old_globals = @globals_info + new_globals = find_globals + if new_globals != old_globals + leak "Globals changed: #{old_globals.inspect} to #{new_globals.inspect}" + @globals_info = new_globals + end + end + + def find_encodings + [Encoding.default_internal, Encoding.default_external] + end + + def check_encodings + old_internal, old_external = @encoding_info + new_internal, new_external = find_encodings + if new_internal != old_internal + leak "Encoding.default_internal changed: #{old_internal.inspect} to #{new_internal.inspect}" + end + if new_external != old_external + leak "Encoding.default_external changed: #{old_external.inspect} to #{new_external.inspect}" + end + @encoding_info = [new_internal, new_external] + end + + def check_tracepoints + ObjectSpace.each_object(TracePoint) do |tp| + if tp.enabled? + leak "TracePoint is still enabled: #{tp.inspect}" + end + end + end + + def leak(message) + if @leaks.empty? + $stderr.puts "\n" + $stderr.puts @state.description + end + @leaks << message + $stderr.puts message + end +end + +class LeakCheckerAction + def register + MSpec.register :start, self + MSpec.register :after, self + end + + def start + disable_nss_modules + @checker = LeakChecker.new + end + + def after(state) + unless @checker.check(state) + leak_messages = @checker.leaks + location = state.description + if state.example + location = "#{location}\n#{state.example.source_location.join(':')}" + end + MSpec.protect(location) do + raise LeakError, leak_messages.join("\n") + end + end + end + + private + + # This function is intended to disable all NSS modules when ruby is compiled + # against glibc. NSS modules allow the system administrator to load custom + # shared objects into all processes using glibc, and use them to customise + # the behaviour of username, groupname, hostname, etc lookups. This is + # normally configured in the file /etc/nsswitch.conf. + # These modules often do things like open cache files or connect to system + # daemons like sssd or dbus, which of course means they have open file + # descriptors of their own. This can cause the leak-checking functionality + # in this file to report that such descriptors have been leaked, and fail + # the test suite. + # This function uses glibc's __nss_configure_lookup function to override any + # configuration in /etc/nsswitch.conf, and just use the built in files/dns + # name lookup functionality (which is of course perfectly sufficient for + # running ruby/spec). + def disable_nss_modules + begin + require 'fiddle' + rescue LoadError + # Make sure it's possible to run the test suite on a ruby implementation + # which does not (yet?) have Fiddle. + return + end + + begin + libc = Fiddle.dlopen(nil) + # Older versions of fiddle don't have Fiddle::Type (and instead rely on Fiddle::TYPE_) + # Even older versions of fiddle don't have CONST_STRING, + string_type = defined?(Fiddle::TYPE_CONST_STRING) ? Fiddle::TYPE_CONST_STRING : Fiddle::TYPE_VOIDP + nss_configure_lookup = Fiddle::Function.new( + libc['__nss_configure_lookup'], + [string_type, string_type], + Fiddle::TYPE_INT + ) + rescue Fiddle::DLError + # We're not running with glibc - no need to do this. + return + end + + nss_configure_lookup.call 'passwd', 'files' + nss_configure_lookup.call 'shadow', 'files' + nss_configure_lookup.call 'group', 'files' + nss_configure_lookup.call 'hosts', 'files dns' + nss_configure_lookup.call 'services', 'files' + nss_configure_lookup.call 'netgroup', 'files' + nss_configure_lookup.call 'automount', 'files' + nss_configure_lookup.call 'aliases', 'files' + nss_configure_lookup.call 'ethers', 'files' + nss_configure_lookup.call 'gshadow', 'files' + nss_configure_lookup.call 'initgroups', 'files' + nss_configure_lookup.call 'networks', 'files dns' + nss_configure_lookup.call 'protocols', 'files' + nss_configure_lookup.call 'publickey', 'files' + nss_configure_lookup.call 'rpc', 'files' + end +end diff --git a/spec/mspec/lib/mspec/runner/actions/profile.rb b/spec/mspec/lib/mspec/runner/actions/profile.rb new file mode 100644 index 0000000000..c743d6e3e8 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/profile.rb @@ -0,0 +1,60 @@ +class ProfileAction + def initialize + @describe_name = nil + @describe_time = nil + @describes = [] + @its = [] + end + + def register + MSpec.register :enter, self + MSpec.register :before,self + MSpec.register :after, self + MSpec.register :finish,self + end + + def enter(describe) + if @describe_time + @describes << [@describe_name, now - @describe_time] + end + + @describe_name = describe + @describe_time = now + end + + def before(state) + @it_name = state.it + @it_time = now + end + + def after(state = nil) + @its << [@describe_name, @it_name, now - @it_time] + 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 + end + + def now + Time.now.to_f + 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..d40d562451 --- /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..3097e655d5 --- /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..d6ada53bab --- /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/timeout.rb b/spec/mspec/lib/mspec/runner/actions/timeout.rb new file mode 100644 index 0000000000..1200926872 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/actions/timeout.rb @@ -0,0 +1,145 @@ +class TimeoutAction + def initialize(timeout) + @timeout = timeout + @queue = Queue.new + @started = now + @fail = false + @error_message = "took longer than the configured timeout of #{@timeout}s" + end + + def register + MSpec.register :start, self + MSpec.register :before, self + MSpec.register :after, self + MSpec.register :finish, self + end + + private def now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + private def fetch_item + @queue.pop(true) + rescue ThreadError + nil + end + + def start + @thread = Thread.new do + loop do + if action = fetch_item + action.call + else + wakeup_at = @started + @timeout + left = wakeup_at - now + sleep left if left > 0 + Thread.pass # Let the main thread run + + if @queue.empty? + elapsed = now - @started + if elapsed > @timeout + if @current_state + STDERR.puts "\nExample #{@error_message}:" + STDERR.puts "#{@current_state.description}" + else + STDERR.puts "\nSome code outside an example #{@error_message}" + end + STDERR.flush + + show_backtraces + if MSpec.subprocesses.empty? + exit! 2 + else + # Do not exit but signal the subprocess so we can get their output + MSpec.subprocesses.each do |pid| + kill_wait_one_second :SIGTERM, pid + hard_kill :SIGKILL, pid + end + @fail = true + @current_state = nil + break # stop this thread, will fail in #after + end + end + end + end + end + end + end + + def before(state = nil) + time = now + @queue << -> do + @current_state = state + @started = time + end + end + + def after(state = nil) + @queue << -> do + @current_state = nil + end + + if @fail + STDERR.puts "\n\nThe last example #{@error_message}. See above for the subprocess stacktrace." + exit! 2 + end + end + + def finish + @thread.kill + @thread.join + end + + private def hard_kill(signal, pid) + begin + Process.kill signal, pid + rescue Errno::ESRCH + # Process already terminated + end + end + + private def kill_wait_one_second(signal, pid) + begin + Process.kill signal, pid + sleep 1 + rescue Errno::ESRCH + # Process already terminated + end + end + + private def show_backtraces + java_stacktraces = -> pid { + if RUBY_ENGINE == 'truffleruby' || RUBY_ENGINE == 'jruby' + STDERR.puts 'Java stacktraces:' + kill_wait_one_second :SIGQUIT, pid + end + } + + if MSpec.subprocesses.empty? + java_stacktraces.call Process.pid + + STDERR.puts "\nRuby backtraces:" + if defined?(Truffle::Debug.show_backtraces) + Truffle::Debug.show_backtraces + else + Thread.list.each do |thread| + unless thread == Thread.current + STDERR.puts thread.inspect, thread.backtrace, '' + end + end + end + else + MSpec.subprocesses.each do |pid| + STDERR.puts "\nFor subprocess #{pid}" + java_stacktraces.call pid + + if RUBY_ENGINE == 'truffleruby' + STDERR.puts "\nRuby backtraces:" + kill_wait_one_second :SIGALRM, pid + else + STDERR.puts "Don't know how to print backtraces of a subprocess on #{RUBY_ENGINE}" + end + end + end + 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..bcd83b2465 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/context.rb @@ -0,0 +1,237 @@ +# 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 referring 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 + + MOCK_VERIFY = -> { Mock.verify_count } + MOCK_CLEANUP = -> { Mock.cleanup } + EXPECTATION_MISSING = -> { raise SpecExpectationNotFoundError } + + def initialize(description, options = nil) + raise "#describe options should be a Hash or nil" unless Hash === options or options.nil? + @to_s = description.to_s + @shared = options && options[:shared] + + @parsed = false + @before = { :all => [], :each => [] } + @after = { :all => [], :each => [] } + @pre = {} + @post = {} + @examples = [] + @state = nil + @parent = nil + @parents = [self] + @children = [] + 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? + @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) + raise "nested #it" if @state + example = ExampleState.new(self, desc, block) + MSpec.actions :add, example + return if MSpec.guarded? + @examples << example + end + + # Evaluates the block and resets the toplevel +ContextState+ to #parent. + def describe(&block) + @parsed = protect @to_s, block, false + MSpec.register_current parent + MSpec.register_shared self if shared? + end + + # Returns a description string generated from self and all parents + def description + @description ||= parents.map { |p| p.to_s }.compact.join(" ") + end + + # Injects the before/after blocks and examples from the shared + # describe block into this +ContextState+ instance. + def it_should_behave_like(desc) + return if MSpec.guarded? + + unless state = MSpec.retrieve_shared(desc) + raise Exception, "Unable to find shared 'describe' for #{desc}" + end + + state.before(:all).each { |b| before :all, &b } + state.before(:each).each { |b| before :each, &b } + state.after(:each).each { |b| after :each, &b } + state.after(:all).each { |b| after :all, &b } + + state.examples.each do |example| + example = example.dup + example.context = self + @examples << example + end + + state.children.each do |child| + child.dup.adopt self + end + end + + # Evaluates each block in +blocks+ using the +MSpec.protect+ method + # so that exceptions are handled and tallied. Returns true and does + # NOT evaluate any blocks if +check+ is true and + # <tt>MSpec.mode?(:pretend)</tt> is true. + def protect(what, blocks, check = true) + return true if check and MSpec.mode? :pretend + Array(blocks).all? { |block| MSpec.protect what, &block } + end + + # Removes filtered examples. Returns true if there are examples + # left to evaluate. + def filter_examples + filtered, @examples = @examples.partition do |ex| + ex.filtered? + end + + filtered.each do |ex| + MSpec.actions :tagged, ex + end + + !@examples.empty? + end + + # Evaluates the examples in a +ContextState+. Invokes the MSpec events + # for :enter, :before, :after, :leave. + def process + MSpec.register_current self + + if @parsed and filter_examples + MSpec.shuffle @examples if MSpec.randomize? + MSpec.actions :enter, description + + if protect "before :all", pre(:all) + @examples.each do |state| + MSpec.repeat do + @state = state + example = state.example + MSpec.actions :before, state + + if protect "before :each", pre(:each) + MSpec.clear_expectations + if example + passed = protect nil, example + passed = protect nil, -> { MSpec.actions :passed, state, example } if passed + MSpec.actions :example, state, example + protect nil, EXPECTATION_MISSING if !MSpec.expectation? and 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..396a84c118 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/evaluate.rb @@ -0,0 +1,54 @@ +class SpecEvaluate + include MSpecMatchers + + 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 + +def evaluate(str, desc = nil, &block) + SpecEvaluate.new(str, desc).define(&block) +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..0d9f0d618c --- /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.include + excl = MSpec.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..23375733e6 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/exception.rb @@ -0,0 +1,54 @@ +# Initialize $MSPEC_DEBUG +$MSPEC_DEBUG ||= false + +class ExceptionState + attr_reader :description, :describe, :it, :exception + + def initialize(state, location, exception) + @exception = exception + @failure = exception.class == SpecExpectationNotMetError || exception.class == SpecExpectationNotFoundError + + @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? + @failure + end + + def message + message = @exception.message + message = "<No message>" if message.empty? + + if @failure + message + elsif raise_error_message = RaiseErrorMatcher::FAILURE_MESSAGE_FOR_EXCEPTION[@exception] + raise_error_message.join("\n") + else + "#{@exception.class}: #{message}" + end + end + + def backtrace + @backtrace_filter ||= MSpecScript.config[:backtrace_filter] || %r{(?:/bin/mspec|/lib/mspec/)} + + bt = @exception.backtrace || [] + unless $MSPEC_DEBUG + # Exclude <internal: entries inside MSpec code, so only after the first ignored entry + first_excluded_line = bt.index { |line| @backtrace_filter =~ line } + if first_excluded_line + bt = bt[0...first_excluded_line] + bt[first_excluded_line..-1].reject { |line| + @backtrace_filter =~ line || /^<internal:/ =~ line + } + end + end + bt.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..097ec6a755 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/filters/regexp.rb @@ -0,0 +1,23 @@ +class RegexpFilter + def initialize(what, *regexps) + @what = what + @regexps = to_regexp(*regexps) + end + + def ===(string) + @regexps.any? { |regexp| regexp === string } + end + + def register + MSpec.register @what, self + end + + def unregister + MSpec.unregister @what, self + end + + def to_regexp(*regexps) + regexps.map { |str| Regexp.new str } + end + private :to_regexp +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..66f515ddff --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters.rb @@ -0,0 +1,13 @@ +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/stats' +require 'mspec/runner/formatters/yaml' +require 'mspec/runner/formatters/profile' +require 'mspec/runner/formatters/junit' diff --git a/spec/mspec/lib/mspec/runner/formatters/base.rb b/spec/mspec/lib/mspec/runner/formatters/base.rb new file mode 100644 index 0000000000..882e15c8c2 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/base.rb @@ -0,0 +1,150 @@ +require 'mspec/expectations/expectations' +require 'mspec/runner/actions/timer' +require 'mspec/runner/actions/tally' +require 'mspec/utils/options' + +if ENV['CHECK_LEAKS'] + require 'mspec/runner/actions/leakchecker' +end + +if ENV['CHECK_LEAKS'] || ENV['CHECK_CONSTANT_LEAKS'] + require 'mspec/runner/actions/constants_leak_checker' +end + +class BaseFormatter + attr_reader :exceptions, :timer, :tally + + def initialize(out = nil) + @current_state = nil + @exception = false + @failure = false + @exceptions = [] + + @count = 0 # For subclasses + + if out + @out = File.open out, "w" + else + @out = $stdout + end + + err = MSpecOptions.latest && MSpecOptions.latest.config[:error_output] + if err + @err = (err == 'stderr') ? $stderr : File.open(err, "w") + else + @err = @out + end + end + + # Creates the +TimerAction+ and +TallyAction+ instances and registers them. + def register + (@timer = TimerAction.new).register + (@tally = TallyAction.new).register + @counter = @tally.counter + + if ENV['CHECK_LEAKS'] + LeakCheckerAction.new.register + end + + if (ENV['CHECK_LEAKS'] || ENV['CHECK_CONSTANT_LEAKS']) && ENV['CHECK_CONSTANT_LEAKS'] != 'false' + save = ENV['CHECK_LEAKS'] == 'save' || ENV['CHECK_CONSTANT_LEAKS'] == 'save' + ConstantsLeakCheckerAction.new(save).register + end + + MSpec.register :abort, self + MSpec.register :before, self + MSpec.register :after, self + MSpec.register :exception, self + MSpec.register :finish, 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. + def after(state = nil) + @current_state = nil + end + + # Callback for the MSpec :start event. Calls :after event. + # Defined here, in the base class, and used by MultiFormatter. + def start + after + end + + # Callback for the MSpec :unload event. Calls :after event. + # Defined here, in the base class, and used by MultiFormatter. + def unload + after + 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" + + if MSpecOptions.latest && MSpecOptions.latest.config[:print_skips] + print "\nSkips:\n" unless MSpec.skips.empty? + MSpec.skips.each do |skip, block| + print "#{skip.message} in #{(block.source_location || ['?']).join(':')}\n" + end + end + + 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" + @err.print "\n#{count})\n#{exc.description} #{outcome}\n" + @err.print exc.message, "\n" + @err.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/describe.rb b/spec/mspec/lib/mspec/runner/formatters/describe.rb new file mode 100644 index 0000000000..fc4122d13b --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/describe.rb @@ -0,0 +1,23 @@ +require 'mspec/runner/formatters/dotted' + +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..672cdf81dc --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/dotted.rb @@ -0,0 +1,23 @@ +require 'mspec/runner/formatters/base' + +class DottedFormatter < BaseFormatter + def register + super + MSpec.register :after, self + 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) + super(state) + + if exception? + print failure? ? "F" : "E" + else + print "." + end + 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..65cfb1f75b --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/file.rb @@ -0,0 +1,24 @@ +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 + + def load(state = nil) + before(state) + end + + def unload(state = nil) + after(state) + end +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..e37e89a088 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/html.rb @@ -0,0 +1,81 @@ +require 'mspec/runner/formatters/base' + +class HtmlFormatter < BaseFormatter + 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_ENGINE} (#{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(exception) + 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 = nil) + super(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..6351ccbce9 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/junit.rb @@ -0,0 +1,87 @@ +require 'mspec/runner/formatters/yaml' + +class JUnitFormatter < YamlFormatter + def initialize(out = nil) + super(out) + @tests = [] + end + + def after(state = nil) + super(state) + @tests << {:test => state, :exception => false} unless exception? + end + + def exception(exception) + super(exception) + @tests << {:test => exception, :exception => true} + end + + def finish + switch + + time = @timer.elapsed + tests = @tally.counter.examples + errors = @tally.counter.errors + failures = @tally.counter.failures + + print <<-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_ENGINE} (#{::RUBY_VERSION})"> + XML + @tests.each do |h| + description = encode_for_xml h[:test].description + + print <<-XML + <testcase classname="Spec" name="#{description}" time="0.0"> + 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/launchable.rb b/spec/mspec/lib/mspec/runner/formatters/launchable.rb new file mode 100644 index 0000000000..f738781c71 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/launchable.rb @@ -0,0 +1,88 @@ +module LaunchableFormatter + def self.extend_object(obj) + super + obj.init + end + + def self.setDir(dir) + @@path = File.join(dir, "#{rand.to_s}.json") + self + end + + def init + @timer = nil + @tests = [] + end + + def before(state = nil) + super + @timer = TimerAction.new + @timer.start + end + + def after(state = nil) + super + @timer.finish + file = MSpec.file + return if file.nil? || state&.example.nil? || exception? + + @tests << {:test => state, :file => file, :exception => false, duration: @timer.elapsed} + end + + def exception(exception) + super + @timer.finish + file = MSpec.file + return if file.nil? + + @tests << {:test => exception, :file => file, :exception => true, duration: @timer.elapsed} + end + + def finish + super + + require_relative '../../../../../../tool/lib/launchable' + + @writer = writer = Launchable::JsonStreamWriter.new(@@path) + @writer.write_array('testCases') + at_exit { + @writer.close + } + + repo_path = File.expand_path("#{__dir__}/../../../../../../") + + @tests.each do |t| + testcase = t[:test].description + relative_path = t[:file].delete_prefix("#{repo_path}/") + # The test path is a URL-encoded representation. + # https://github.com/launchableinc/cli/blob/v1.81.0/launchable/testpath.py#L18 + test_path = {file: relative_path, testcase: testcase}.map{|key, val| + "#{encode_test_path_component(key)}=#{encode_test_path_component(val)}" + }.join('#') + + status = 'TEST_PASSED' + if t[:exception] + message = t[:test].message + backtrace = t[:test].backtrace + e = "#{message}\n#{backtrace}" + status = 'TEST_FAILED' + end + + @writer.write_object( + { + testPath: test_path, + status: status, + duration: t[:duration], + createdAt: Time.now.to_s, + stderr: e, + stdout: nil + } + ) + end + end + + private + def encode_test_path_component component + component.to_s.gsub('%', '%25').gsub('=', '%3D').gsub('#', '%23').gsub('&', '%26').tr("\x00-\x08", "") + 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..925858c845 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/method.rb @@ -0,0 +1,95 @@ +require 'mspec/runner/formatters/base' + +class MethodFormatter < BaseFormatter + attr_accessor :methods + + def initialize(out = nil) + super(out) + @methods = Hash.new do |h, k| + h[k] = { + examples: 0, + expectations: 0, + failures: 0, + errors: 0, + exceptions: [] + } + 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(state) + + # 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 = nil) + super(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..fa1da3766b --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/multi.rb @@ -0,0 +1,47 @@ +module MultiFormatter + def self.extend_object(obj) + super + obj.multi_initialize + end + + def multi_initialize + @tally = TallyAction.new + @counter = @tally.counter + @timer = TimerAction.new + @timer.start + end + + def register + super + + MSpec.register :start, self + MSpec.register :unload, self + MSpec.unregister :before, self + end + + def aggregate_results(files) + require 'yaml' + + @timer.finish + @exceptions = [] + + files.each do |file| + contents = File.read(file) + d = YAML.load(contents) + File.delete file + + if d # The file might be empty if the child process died + @exceptions += Array(d['exceptions']) + @counter.files! d['files'] + @counter.examples! d['examples'] + @counter.expectations! d['expectations'] + @counter.errors! d['errors'] + @counter.failures! d['failures'] + end + end + end + + def print_exception(exc, count) + @err.print "\n#{count})\n#{exc}\n" + 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..38ef5b12ed --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/profile.rb @@ -0,0 +1,18 @@ +require 'mspec/runner/formatters/dotted' +require 'mspec/runner/actions/profile' + +class ProfileFormatter < DottedFormatter + def initialize(out = nil) + super(out) + + @describe_name = nil + @describe_time = nil + @describes = [] + @its = [] + end + + def register + (@profile = ProfileAction.new).register + 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..d3a5c3d729 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/specdoc.rb @@ -0,0 +1,41 @@ +require 'mspec/runner/formatters/base' + +class SpecdocFormatter < BaseFormatter + 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(state) + 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 description + # string has an associated 'ERROR' or 'FAILED' + def exception(exception) + print "\n- #{exception.it}" if exception? + super(exception) + 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 = nil) + super(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..817d8c02be --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/spinner.rb @@ -0,0 +1,111 @@ +require 'mspec/runner/formatters/base' + +class SpinnerFormatter < BaseFormatter + 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 + 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.files_array.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) + exceptions.clear + end + + # Callback for the MSpec :after event. Updates the spinner. + def after(state = nil) + super(state) + print progress_line + end +end diff --git a/spec/mspec/lib/mspec/runner/formatters/stats.rb b/spec/mspec/lib/mspec/runner/formatters/stats.rb new file mode 100644 index 0000000000..8cff96d145 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/stats.rb @@ -0,0 +1,57 @@ +require 'mspec/runner/formatters/base' + +class StatsPerFileFormatter < BaseFormatter + def initialize(out = nil) + super(out) + @data = {} + @root = File.expand_path(MSpecScript.get(:prefix) || '.') + end + + def register + super + MSpec.register :load, self + MSpec.register :unload, self + end + + # Resets the tallies so the counts are only for this file. + def load + tally.counter.examples = 0 + tally.counter.errors = 0 + tally.counter.failures = 0 + tally.counter.tagged = 0 + end + + def unload + file = format_file MSpec.file + + raise if @data.key?(file) + @data[file] = { + examples: tally.counter.examples, + errors: tally.counter.errors, + failures: tally.counter.failures, + tagged: tally.counter.tagged, + } + end + + def finish + width = @data.keys.max_by(&:size).size + f = "%3d" + @data.each_pair do |file, data| + total = data[:examples] + passing = total - data[:errors] - data[:failures] - data[:tagged] + puts "#{file.ljust(width)} #{f % passing}/#{f % total}" + end + + require 'yaml' + yaml = YAML.dump(@data) + File.write "results-#{RUBY_ENGINE}-#{RUBY_ENGINE_VERSION}.yml", yaml + end + + private def format_file(file) + if file.start_with?(@root) + file[@root.size+1..-1] + else + raise file + end + 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..41819d2158 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/summary.rb @@ -0,0 +1,4 @@ +require 'mspec/runner/formatters/base' + +class SummaryFormatter < BaseFormatter +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..d03ae79e9f --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/unit.rb @@ -0,0 +1,20 @@ +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..6c05cc902f --- /dev/null +++ b/spec/mspec/lib/mspec/runner/formatters/yaml.rb @@ -0,0 +1,38 @@ +require 'mspec/runner/formatters/base' + +class YamlFormatter < BaseFormatter + 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 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..0e016c67a7 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/mspec.rb @@ -0,0 +1,424 @@ +require 'mspec/runner/context' +require 'mspec/runner/exception' +require 'mspec/runner/tag' + +module MSpec +end + +class MSpecEnv + include MSpec +end + +module MSpec + @exit = nil + @abort = nil + @start = nil + @enter = nil + @before = nil + @add = nil + @after = nil + @leave = nil + @finish = nil + @exclude = [] + @include = [] + @leave = nil + @load = nil + @unload = nil + @tagged = nil + @current = nil + @passed = nil + @example = nil + @modes = [] + @shared = {} + @guarded = [] + @features = {} + @exception = nil + @randomize = false + @repeat = 1 + @expectation = nil + @expectations = false + @skips = [] + @subprocesses = [] + + class << self + attr_reader :file, :include, :exclude, :skips, :subprocesses + attr_writer :repeat, :randomize + attr_accessor :formatter + end + + def self.describe(description, options = nil, &block) + state = ContextState.new description, 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 + STDOUT.flush + + actions :start + files + actions :finish + end + + def self.files_array + @files + end + + def self.each_file(&block) + if ENV["MSPEC_MULTI"] + while file = STDIN.gets + file = file.chomp + return if file == "QUIT" + yield file + begin + STDOUT.print "." + STDOUT.flush + rescue Errno::EPIPE + # The parent died + exit 1 + end + end + # The parent closed the connection without QUIT + abort "the parent did not send QUIT" + else + return unless files = @files + shuffle files if randomize? + files.each(&block) + end + end + + def self.files + each_file do |file| + setup_env + @file = file + actions :load + protect("loading #{file}") { Kernel.load file } + actions :unload + raise "#{file} was executed but did not reset the current example: #{@current}" if @current + end + end + + def self.setup_env + @env = MSpecEnv.new + 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_exec(&block) + return true + rescue SystemExit => e + raise e + rescue SkippedSpecError => e + @skips << [e, block] + return false + rescue Object => exc + register_exit 1 + actions :exception, ExceptionState.new(current && current.state, location, exc) + return false + end + end + + # Guards can be nested, so a stack is necessary to know when we have + # exited the toplevel guard. + def self.guard + @guarded << true + end + + def self.unguard + @guarded.pop + end + + def self.guarded? + !@guarded.empty? + end + + # Sets the toplevel ContextState to +state+. + def self.register_current(state) + @current = state + end + + # Sets the toplevel ContextState to +nil+. + def self.clear_current + @current = nil + end + + # Returns the toplevel ContextState. + def self.current + @current + end + + # Stores the shared ContextState keyed by description. + def self.register_shared(state) + name = state.to_s + raise "duplicated shared #describe: #{name}" if @shared.key?(name) + @shared[name] = 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) + @exit = code + end + + # Retrieves the stored exit code. + def self.exit_code + @exit.to_i + end + + # Stores the list of files to be evaluated. + def self.register_files(files) + @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) + @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 = @modes + modes << mode unless modes.include? mode + end + + # Clears all registered modes. + def self.clear_modes + @modes = [] + end + + # Returns +true+ if +mode+ is registered. + def self.mode?(mode) + @modes.include? mode + end + + def self.enable_feature(feature) + @features[feature] = true + end + + def self.disable_feature(feature) + @features[feature] = false + end + + def self.feature_enabled?(feature) + @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. + # :passed after an example block is run and passes, passed the block, run before :example action + # :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? + @randomize + end + + def self.repeat + if @repeat == 1 + yield + else + @repeat.times do + yield + end + 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 + @expectations = true + end + + # Returns true if an expectation has been encountered + def self.expectation? + @expectations + end + + # Resets the flag that an expectation has been encountered in an example. + def self.clear_expectations + @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 = @tags_patterns || + [[%r(spec/), 'spec/tags/'], [/_spec.rb$/, '_tags.txt']] + patterns.inject(@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) + return delete_tags if tags.empty? + 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 = File.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 + + # Initialize @env + setup_env +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..58d98cc4df --- /dev/null +++ b/spec/mspec/lib/mspec/runner/object.rb @@ -0,0 +1,26 @@ +class Object + private def before(at = :each, &block) + MSpec.current.before at, &block + end + + private def after(at = :each, &block) + MSpec.current.after at, &block + end + + private def describe(description, options = nil, &block) + MSpec.describe description, options, &block + end + + private def it(desc, &block) + MSpec.current.it desc, &block + end + + private def it_should_behave_like(desc) + MSpec.current.it_should_behave_like desc + end + + alias_method :context, :describe + private :context + alias_method :specify, :it + private :specify +end diff --git a/spec/mspec/lib/mspec/runner/parallel.rb b/spec/mspec/lib/mspec/runner/parallel.rb new file mode 100644 index 0000000000..6a9ecd155d --- /dev/null +++ b/spec/mspec/lib/mspec/runner/parallel.rb @@ -0,0 +1,98 @@ +class ParallelRunner + def initialize(files, processes, formatter, argv) + @files = files + @processes = processes + @formatter = formatter + @argv = argv + @last_files = {} + @output_files = [] + @success = true + end + + def launch_children + @children = @processes.times.map { |i| + name = tmp "mspec-multi-#{i}" + @output_files << name + + env = { + "SPEC_TEMP_DIR" => "#{SPEC_TEMP_DIR}_#{i}", + "MSPEC_MULTI" => i.to_s + } + command = @argv + ["-fy", "-o", name] + $stderr.puts "$ #{command.join(' ')}" if $MSPEC_DEBUG + IO.popen([env, *command, close_others: false], "rb+") + } + end + + def handle(child, message) + case message + when '.' + @formatter.unload + send_new_file_or_quit(child) + else + if message == nil + msg = "A child mspec-run process died unexpectedly" + else + msg = "A child mspec-run process printed unexpected output on STDOUT" + while chunk = (child.read_nonblock(4096) rescue nil) + message += chunk + end + message.chomp!('.') + msg += ": #{message.inspect}" + end + + if last_file = @last_files[child] + msg += " while running #{last_file}" + end + + @success = false + quit(child) + abort "\n#{msg}" + end + end + + def quit(child) + begin + child.puts "QUIT" + rescue Errno::EPIPE + # The child process already died + end + _pid, status = Process.wait2(child.pid) + @success &&= status.success? + child.close + @children.delete(child) + end + + def send_new_file_or_quit(child) + if @files.empty? + quit(child) + else + file = @files.shift + @last_files[child] = file + child.puts file + end + end + + def run + MSpec.register_files @files + launch_children + + puts @children.map { |child| child.gets }.uniq + @formatter.start + begin + @children.each { |child| send_new_file_or_quit(child) } + + until @children.empty? + IO.select(@children)[0].each { |child| + handle(child, child.read(1)) + } + end + ensure + @children.dup.each { |child| quit(child) } + @formatter.aggregate_results(@output_files) + @formatter.finish + end + + @success + end +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..283711c1d7 --- /dev/null +++ b/spec/mspec/lib/mspec/runner/shared.rb @@ -0,0 +1,14 @@ +require 'mspec/runner/mspec' + +def it_behaves_like(desc, meth, obj = nil) + before :all do + @method = meth + @object = obj + end + after :all do + @method = nil + @object = nil + end + + it_should_behave_like desc.to_s +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..820df9159e --- /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/format.rb b/spec/mspec/lib/mspec/utils/format.rb new file mode 100644 index 0000000000..425dd4d11c --- /dev/null +++ b/spec/mspec/lib/mspec/utils/format.rb @@ -0,0 +1,24 @@ +# 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 + +module MSpec + def self.format(obj) + if String === obj and obj.include?("\n") + "\n#{obj.inspect.gsub('\n', "\n")}" + else + obj.pretty_inspect.chomp + end + rescue => e + "#<#{obj.class}>(#pretty_inspect raised #{e.inspect})" + 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..9b04112e2e --- /dev/null +++ b/spec/mspec/lib/mspec/utils/name_map.rb @@ -0,0 +1,133 @@ +class NameMap + MAP = { + '`' => 'backtick', + '+' => 'plus', + '-' => 'minus', + '+@' => 'uplus', + '-@' => 'uminus', + '*' => 'multiply', + '/' => 'divide', + '%' => 'modulo', + '<<' => {'Integer' => 'left_shift', + 'IO' => 'output', + :default => 'append' }, + '>>' => 'right_shift', + '<' => 'lt', + '<=' => 'lte', + '>' => 'gt', + '>=' => 'gte', + '=' => 'assignment', + '==' => 'equal_value', + '===' => 'case_compare', + '<=>' => 'comparison', + '[]' => 'element_reference', + '[]=' => 'element_set', + '**' => 'exponent', + '!' => 'not', + '~' => {'Integer' => 'complement', + :default => 'match' }, + '!=' => 'not_equal', + '!~' => 'not_match', + '=~' => 'match', + '&' => {'Integer' => 'bit_and', + 'Array' => 'intersection', + 'Set' => 'intersection', + :default => 'and' }, + '|' => {'Integer' => 'bit_or', + 'Array' => 'union', + 'Set' => 'union', + :default => 'or' }, + '^' => {'Integer' => 'bit_xor', + 'Set' => 'exclusion', + :default => 'xor' }, + } + + EXCLUDED = %w[ + MSpecScript + MkSpec + MSpecOption + MSpecOptions + NameMap + SpecVersion + ] + + ALWAYS_PRIVATE = %w[ + initialize initialize_copy initialize_clone initialize_dup respond_to_missing? + ].map(&:to_sym) + + 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) + begin + const = Object.const_get(c, false) + rescue NameError, RuntimeError + # Either the constant doesn't exist or it is + # explicitly raising an error, like `SortedSet`. + return nil + end + return nil unless Module === const + + filtered = @filter && EXCLUDED.include?(const.name) + return const unless filtered + 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) + + (m.private_instance_methods(false) & ALWAYS_PRIVATE) + 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) + mapping = MAP[m] + if mapping.is_a?(Hash) + name = mapping[c.split('::').last] || mapping.fetch(:default) + else + name = mapping + end + 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..adeafa1f81 --- /dev/null +++ b/spec/mspec/lib/mspec/utils/options.rb @@ -0,0 +1,521 @@ +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 + + class << self + attr_accessor :latest + 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 + } + + MSpecOptions.latest = self + 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. Calls 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 env + on("--env", "KEY=VALUE", "Set environment variable") do |env| + key, value = env.split('=', 2) + ENV[key] = value + 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 'e', 'stats' + config[:formatter] = StatsPerFileFormatter + when 'y', 'yaml' + config[:formatter] = YamlFormatter + when 'p', 'profile' + config[:formatter] = ProfileFormatter + when 'j', 'junit' + config[:formatter] = JUnitFormatter + else + abort "Unknown format: #{o}" 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 " e, stats StatsPerFileFormatter" + 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 + + on("--error-output", "FILE", + "Write error output of failing specs to FILE, or $stderr if value is 'stderr'.") do |f| + config[:error_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 = true + end + end + + def repeat + on("-R", "--repeat", "NUMBER", + "Repeatedly run an example NUMBER times") do |o| + MSpec.repeat = Integer(o) + end + end + + def verbose + on("-V", "--verbose", "Output the name of each file processed") do + obj = Object.new + def obj.start + @width = MSpec.files_array.inject(0) { |max, f| f.size > max ? f.size : max } + end + def obj.load + file = MSpec.file + STDERR.print "\n#{file.ljust(@width)}\n" + 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 + STDERR.print @marker + end + MSpec.register :load, obj + end + + on("--print-skips", "Print skips") do + config[:print_skips] = true + end + end + + def interrupt + on("--int-spec", "Control-C interrupts the current spec only") do + config[:abort] = false + end + end + + def timeout + on("--timeout", "TIMEOUT", "Abort if a spec takes longer than TIMEOUT seconds") do |timeout| + require 'mspec/runner/actions/timeout' + timeout = Float(timeout) + TimeoutAction.new(timeout).register + 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", + "Disable MSpec backtrace filtering") do + $MSPEC_DEBUG = true + end + end + + def launchable + on("--launchable-test-reports", "DIR", + "DIR The directory for reporting test results in Launchable JSON format") do |o| + require 'mspec/runner/formatters/launchable' + config[:launchable] = LaunchableFormatter.setDir(o) + end + end + + def all + configure {} + env + targets + formatters + filters + chdir + prefix + pretend + unguarded + randomize + repeat + verbose + interrupt + timeout + verify + action_filters + actions + debug + launchable + 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..15fd23fabf --- /dev/null +++ b/spec/mspec/lib/mspec/utils/script.rb @@ -0,0 +1,305 @@ +require 'mspec/guards/guard' +require 'mspec/guards/version' +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 + + class << self + attr_accessor :child_process + end + + # True if the current process is the one going to run the specs with `MSpec.process`. + # False for e.g. `mspec` which exec's to `mspec-run`. + # This is useful in .mspec config files. + def self.child_process? + MSpecScript.child_process + end + + def initialize + check_version! + + 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 formatter = config_formatter + formatter.register + MSpec.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 + + # Makes a formatter specified by :formatter option. + def config_formatter + if config[:formatter].nil? + config[:formatter] = STDOUT.tty? ? SpinnerFormatter : @files.size < 50 ? DottedFormatter : FileFormatter + end + + if config[:formatter] + config[:formatter] = config[:formatter].new(config[:output]) + end + + if config[:launchable] + config[:formatter].extend config[:launchable] + end + + config[:formatter] + 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| + begin + expanded = File.realpath(pattern) + rescue Errno::ENOENT, Errno::ENOTDIR + next + end + if File.file?(expanded) && expanded.end_with?('.rb') + return [expanded] + elsif File.directory?(expanded) + specs = Dir["#{expanded}/**/*_spec.rb"].sort + return specs unless specs.empty? + end + end + + abort "Could not find spec file #{partial}" + end + + # Resolves each entry in +patterns+ to a set of files. + # + # If the pattern 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(patterns) + list = [] + patterns.each do |pattern| + case pattern[0] + when ?^ + list -= entries(pattern[1..-1]) + when ?: + key = pattern[1..-1].to_sym + value = config[key] + abort "Key #{pattern} not found in mspec config." unless value + list += files(Array(value)) + else + list += entries(pattern) + end + end + list + 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 + end + list = files(patterns) + abort "No files specified." if list.empty? + list + end + + def cores(max) + require 'etc' + [Etc.nprocessors, max].min + 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(child_process = true) + MSpecScript.child_process = child_process + + script = new + script.load_default + script.options + script.signals + script.register + script.setup_env + require 'mspec' + script.run + end + + private def check_version! + ruby_version_is ""..."2.6" do + warn "MSpec is supported for Ruby 2.6 and above only" + end + 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..9c1c58b8df --- /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.to_int) + 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..23efc696a5 --- /dev/null +++ b/spec/mspec/lib/mspec/utils/warnings.rb @@ -0,0 +1,10 @@ +require 'mspec/guards/version' + +# Always enable deprecation warnings when running MSpec, as ruby/spec tests for them, +# and like in most test frameworks, deprecation warnings should be enabled by default, +# so that deprecations are noticed before the breaking change. +# Disable experimental warnings, we want to test new experimental features in ruby/spec. +if Object.const_defined?(:Warning) and Warning.respond_to?(:[]=) + Warning[:deprecated] = true + Warning[:experimental] = false +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 |
