diff --git a/bin/cmdtest.rb b/bin/cmdtest.rb new file mode 100755 index 0000000..05d964a --- /dev/null +++ b/bin/cmdtest.rb @@ -0,0 +1,330 @@ +#!/usr/bin/ruby +#---------------------------------------------------------------------- +# cmdtest.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +# This is the main program of "cmdtest". +# It reads a number of "CMDTEST_*.rb" files and executes the testcases +# found in the files. The result can be reported in different ways. +# Most of the testing logic is found in the library files "cmdtest/*.rb". + +top_dir = File.dirname(File.dirname(__FILE__)) +lib_dir = File.join(File.expand_path(top_dir), "lib") +$:.unshift(lib_dir) if File.directory?(File.join(lib_dir, "cmdtest")) + +require "cmdtest/baselogger" +require "cmdtest/consolelogger" +require "cmdtest/junitlogger" +require "cmdtest/testcase" +require "cmdtest/workdir" +require "cmdtest/util" +require "set" +require "stringio" +require "fileutils" +require "find" +require "rbconfig" + +module Cmdtest + + #---------------------------------------------------------------------- + + class TestMethod + + def initialize(test_method, test_class, runner) + @test_method, @test_class, @runner = test_method, test_class, runner + end + + def run + @runner.notify("testmethod", @test_method) do + obj = @test_class.testcase_class.new(self, @runner) + obj._work_dir.chdir do + obj.setup + begin + obj.send(@test_method) + @runner.assert_success + rescue Cmdtest::AssertFailed => e + @runner.assert_failure(e.message) + rescue => e + io = StringIO.new + io.puts "CAUGHT EXCEPTION:" + io.puts " " + e.message + " (#{e.class})" + io.puts "BACKTRACE:" + io.puts e.backtrace.map {|line| " " + line } + @runner.assert_error(io.string) + end + obj.teardown + end + end + end + + end + + #---------------------------------------------------------------------- + + class TestClass + + attr_reader :testcase_class + + def initialize(testcase_class, file, runner) + @testcase_class, @file, @runner = testcase_class, file, runner + end + + def run + @runner.notify("testclass", @testcase_class) do + get_test_methods.each do |method| + TestMethod.new(method, self, @runner).run + end + end + end + + def get_test_methods + @testcase_class.public_instance_methods(false).sort.select do |method| + in_list = @runner.opts.tests.empty? || @runner.opts.tests.include?(method) + method =~ /^test_/ && in_list + end + end + + end + + #---------------------------------------------------------------------- + + class TestFile + + def initialize(file) + @file = file + end + + def run(runner) + @runner = runner + @runner.notify("testfile", @file) do + testcase_classes = Cmdtest::Testcase.new_subclasses do + Kernel.load(@file, true) + end + for testcase_class in testcase_classes + test_class = TestClass.new(testcase_class, self, @runner) + if ! test_class.get_test_methods.empty? + test_class.run + end + end + end + end + + end + + #---------------------------------------------------------------------- + + class Runner + + attr_reader :opts + + def initialize(project_dir, opts) + @project_dir = project_dir + @listeners = [] + @opts = opts + end + + def add_listener(listener) + @listeners << listener + end + + def notify_once(method, *args) + for listener in @listeners + listener.send(method, *args) + end + end + + def notify(method, *args) + if block_given? + notify_once(method + "_begin", *args) + yield + notify_once(method + "_end", *args) + else + notify_once(method, *args) + end + end + + def run + ENV["PATH"] = Dir.pwd + Config::CONFIG["PATH_SEPARATOR"] + ENV["PATH"] + @n_assert_failures = 0 + @n_assert_errors = 0 + @n_assert_successes = 0 + notify("testsuite") do + for test_file in @project_dir.test_files + test_file.run(self) + end + end + end + + def assert_success + @n_assert_successes += 1 + end + + def assert_failure(str) + @n_assert_failures += 1 + notify("assert_failure", str) + end + + def assert_error(str) + @n_assert_errors += 1 + notify("assert_error", str) + end + end + + #---------------------------------------------------------------------- + + class ProjectDir + + def initialize(argv) + @argv = argv + end + + def test_files + if ! @argv.empty? + files = _expand_files_or_dirs(@argv) + if files.empty? + puts "ERROR: no files given" + exit 1 + end + return files + end + + try = Dir.glob("t/CMDTEST_*.rb") + return _test_files(try) if ! try.empty? + + try = Dir.glob("CMDTEST_*.rb") + return _test_files(try) if ! try.empty? + + puts "ERROR: no CMDTEST_*.rb files found" + exit 1 + end + + private + + def _test_files(files) + files.map {|file| TestFile.new(file) } + end + + def _expand_files_or_dirs(argv) + files = [] + for arg in @argv + if File.file?(arg) + if File.basename(arg) =~ /^.*\.rb$/ + files << TestFile.new(arg) + else + puts "ERROR: illegal file: #{arg}" + exit(1) + end + elsif File.directory?(arg) + Dir.foreach(arg) do |entry| + path = File.join(arg,entry) + next unless File.file?(path) + next unless entry =~ /^CMDTEST_.*\.rb$/ + files << TestFile.new(path) + end + else + puts "ERROR: unknown file: #{arg}" + end + end + return files + end + + end + + #---------------------------------------------------------------------- + + class Main + + attr_reader :tests, :quiet, :verbose, :fast, :ruby_s + + def initialize + @tests = [] + @quiet = false + @verbose = false + @fast = false + @xml = nil + @ruby_s = false + + _update_cmdtest_level + end + + def run + while ! ARGV.empty? && ARGV[0] =~ /^-/ + opt = ARGV.shift + case opt + when /^--test=(.*)/ + @tests << $1 + when /^--quiet$/ + @quiet = true + when /^--verbose$/ + @verbose = true + when /^--fast$/ + @fast = true + when /^--xml=(.+)$/ + @xml = $1 + when /^--ruby_s$/ + @ruby_s = true + when /^--help$/, /^-h$/ + puts + _show_options + puts + exit 0 + else + puts "ERROR: unknown option: #{opt}" + puts + _show_options + puts + exit 1 + end + end + + Util.opts = self + @project_dir = ProjectDir.new(ARGV) + @runner = Runner.new(@project_dir, self) + logger = ConsoleLogger.new(self) + @runner.add_listener(logger) + if @xml + @runner.add_listener(JunitLogger.new(self, @xml)) + end + + @runner.run + end + + private + + def _update_cmdtest_level + $cmdtest_level = (ENV["CMDTEST_LEVEL"] || "0").to_i + 1 + ENV["CMDTEST_LEVEL"] = $cmdtest_level.to_s + end + + def _show_options + puts " --help show this help" + puts " --quiet be more quiet" + puts " --verbose be more verbose" + puts " --fast run fast without waiting for unique mtime:s" + puts " --test=NAME only run named test" + end + + end + +end + +#---------------------------------------------------------------------- +Cmdtest::Main.new.run +#---------------------------------------------------------------------- diff --git a/lib/cmdtest/baselogger.rb b/lib/cmdtest/baselogger.rb new file mode 100644 index 0000000..b8c25e6 --- /dev/null +++ b/lib/cmdtest/baselogger.rb @@ -0,0 +1,94 @@ +#---------------------------------------------------------------------- +# baselogger.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +module Cmdtest + class BaseLogger + + @@debug = false + + attr_reader :opts + + attr_reader :n_suites, :n_files, :n_classes + attr_reader :n_methods, :n_commands, :n_failures, :n_errors + + def initialize(opts) + @opts = opts + + @n_suites = 0 + @n_files = 0 + @n_classes = 0 + @n_methods = 0 + @n_commands = 0 + @n_failures = 0 + @n_errors = 0 + end + + def testsuite_begin + p :testsuite_begin if @@debug + @n_suites += 1 + end + + def testsuite_end + p :testsuite_end if @@debug + end + + def testfile_begin(file) + p [:testfile_begin, file] if @@debug + @n_files += 1 + end + + def testfile_end(file) + p :testfile_end if @@debug + end + + def testclass_begin(testcase_class) + p [:testclass_begin, testcase_class] if @@debug + @n_classes += 1 + end + + def testclass_end(testcase_class) + p :testclass_end if @@debug + end + + def testmethod_begin(method) + p [:testmethod_begin, method] if @@debug + @n_methods += 1 + end + + def testmethod_end(method) + p :testmethod_end if @@debug + end + + def cmdline(method, comment) + p :testmethod_end if @@debug + @n_commands += 1 + end + + def assert_failure + @n_failures += 1 + end + + def assert_error + @n_errors += 1 + end + + end +end diff --git a/lib/cmdtest/cmdeffects.rb b/lib/cmdtest/cmdeffects.rb new file mode 100644 index 0000000..32681de --- /dev/null +++ b/lib/cmdtest/cmdeffects.rb @@ -0,0 +1,83 @@ +#---------------------------------------------------------------------- +# cmdeffects.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +require "set" + +module Cmdtest + class CmdEffects + + attr_reader :stdout, :stderr + + def initialize(process_status, stdout, stderr, snapshot_before, snapshot_after) + @process_status = process_status + @stdout = stdout + @stderr = stderr + @before = snapshot_before + @after = snapshot_after + end + + def exit_status + @process_status.exitstatus + end + + def _select_files + files = @before.files.to_set + @after.files.to_set + files.sort.select do |file| + before = @before.fileinfo(file) + after = @after.fileinfo(file) + yield before, after + end + end + + def affected_files + _select_files do |before,after| + ((!! before ^ !! after) || + (before && after && before != after)) + end + end + + def written_files + _select_files do |before,after| + ((! before && after) || + (before && after && before != after)) + end + end + + def created_files + _select_files do |before,after| + (! before && after) + end + end + + def modified_files + _select_files do |before,after| + (before && after && before != after) + end + end + + def removed_files + _select_files do |before,after| + (before && ! after) + end + end + + end +end diff --git a/lib/cmdtest/consolelogger.rb b/lib/cmdtest/consolelogger.rb new file mode 100644 index 0000000..0d354f3 --- /dev/null +++ b/lib/cmdtest/consolelogger.rb @@ -0,0 +1,82 @@ +#---------------------------------------------------------------------- +# consolelogger.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +require "cmdtest/baselogger" + +module Cmdtest + class ConsoleLogger < BaseLogger + + def _banner(ch, str) + puts "### " + ch * 40 + " " + str + end + + def testfile_begin(file) + super + _banner "=", file if ! opts.quiet + end + + def testclass_begin(testcase_class) + super + _banner "-", testcase_class.display_name if ! opts.quiet + end + + def testmethod_begin(method) + super + _banner ".", method.to_s if ! opts.quiet + end + + def cmdline(cmdline_arg, comment) + super + + if opts.verbose + first = comment || "..." + puts "### %s" % [first] + puts "### %s" % [cmdline_arg] + else + first = comment || cmdline_arg + puts "### %s" % [first] + end + end + + def assert_failure(str) + super() + puts str.gsub(/^/, "--- ") + end + + def assert_error(str) + super() + puts str.gsub(/^/, "--- ") + end + + def testsuite_end + super + if ! opts.quiet + puts + puts "%s %d test classes, %d test methods, %d commands, %d errors, %d fatals." % [ + n_failures == 0 && n_errors == 0 ? "###" : "---", + n_classes, n_methods, n_commands, n_failures, n_errors + ] + puts + end + end + + end +end diff --git a/lib/cmdtest/fileinfo.rb b/lib/cmdtest/fileinfo.rb new file mode 100644 index 0000000..ae957c9 --- /dev/null +++ b/lib/cmdtest/fileinfo.rb @@ -0,0 +1,67 @@ +#---------------------------------------------------------------------- +# fileinfo.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +require "digest/md5" + +module Cmdtest + class FileInfo + + attr_reader :stat, :digest + + def initialize(path) + @path = path + @stat = File.lstat(path) + + if @stat.file? + md5 = Digest::MD5.new + File.open(path) {|f| f.binmode; md5.update(f.read) } + @digest = md5.hexdigest + else + @digest = "a-directory" + end + end + + FILE_SUFFIXES = { + "file" => "", + "directory" => "/", + "link" => "@", + } + + def display_path + @path + (FILE_SUFFIXES[@stat.ftype] || "?") + end + + def ==(other) + stat = other.stat + case + when @stat.file? && stat.file? + (@stat.mtime == stat.mtime && + @stat.ino == stat.ino && + @digest == other.digest) + when @stat.directory? && stat.directory? + true + else + false + end + end + + end +end diff --git a/lib/cmdtest/fssnapshot.rb b/lib/cmdtest/fssnapshot.rb new file mode 100644 index 0000000..2e5ea22 --- /dev/null +++ b/lib/cmdtest/fssnapshot.rb @@ -0,0 +1,62 @@ +#---------------------------------------------------------------------- +# fssnapshot.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +require "cmdtest/fileinfo" +require "cmdtest/util" + +require "find" + +module Cmdtest + class FsSnapshot + + def initialize(dir, ignored_files) + @dir = dir + @ignored_files = ignored_files + @fileinfo_by_path = {} + Util.chdir(@dir) do + Find.find(".") do |path| + next if path == "." + path.sub!(/^\.\//, "") + file_info = FileInfo.new(path) + display_path = file_info.display_path + Find.prune if _ignore_file?(display_path) + @fileinfo_by_path[display_path] = file_info + end + end + end + + def _ignore_file?(path) + @ignored_files.any? {|ignored| ignored === path } + end + + def files + @fileinfo_by_path.keys.sort.select do |path| + stat = @fileinfo_by_path[path].stat + stat.file? || stat.directory? + end + end + + def fileinfo(path) + @fileinfo_by_path[path] + end + + end +end diff --git a/lib/cmdtest/junitfile.rb b/lib/cmdtest/junitfile.rb new file mode 100644 index 0000000..4641c0c --- /dev/null +++ b/lib/cmdtest/junitfile.rb @@ -0,0 +1,173 @@ +#---------------------------------------------------------------------- +# junitfile.rb +#---------------------------------------------------------------------- +# Copyright 2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +module Cmdtest + class JunitFile + + #---------- + + class XmlFile + + def initialize(file) + @file = file + @f = File.open(file, "w") + end + + def put(str, args=[]) + @f.puts str % args.map {|arg| String === arg ? _quote(arg) : arg} + end + + def _quote(arg) + arg.gsub(/&/, "&").gsub(//, ">") + end + + def close + @f.close + end + + end + + #---------- + + class Testcase + def write(f) + f.put ' ', [ + @classname, + @name, + ] + end + end + + #---------- + + class OkTestcase < Testcase + + def initialize(classname, name) + @classname = classname + @name = name + @message = @type = @text = nil + end + + end + + #---------- + + class ErrTestcase < Testcase + + def initialize(classname, name, message, type, text) + @classname = classname + @name = name + @message = message + @type = type + @text = text + end + + def write(f) + f.put ' ', [ + @classname, + @name, + ] + f.put ' %s', [ + @message, + @type, + @text, + ] + f.put ' ' + end + end + + #---------- + + class Testsuite + + def initialize(package, name) + @package = package + @name = name + @testcases = [] + end + + def ok_testcase(classname, name) + testcase = OkTestcase.new(classname, name) + @testcases << testcase + testcase + end + + def err_testcase(classname, name, message, type, text) + testcase = ErrTestcase.new(classname, name, message, type, text) + @testcases << testcase + testcase + end + + def write(f) + f.put ' ', [ + 0, + @testcases.grep(ErrTestcase).size, + @name, + @package, + ] + for testcase in @testcases + testcase.write(f) + end + f.put ' ' + end + end + + #---------- + + def initialize(file) + @file = file + @testsuites = [] + end + + def new_testsuite(*args) + testsuite = Testsuite.new(*args) + @testsuites << testsuite + testsuite + end + + def write + @f = XmlFile.new(@file) + @f.put '' + @f.put '' + for testsuite in @testsuites + testsuite.write(@f) + end + @f.put '' + @f.close + end + + end +end + +if $0 == __FILE__ + jf = Cmdtest::JunitFile.new("jh.xml") + ts = jf.new_testsuite("foo") + ts.ok_testcase("jh.Foo", "test_a") + ts.ok_testcase("jh.Foo", "test_b") + + ts.err_testcase("jh.Foo", "test_c", "2 > 1", "assert", "111\n222\n333\n") + + ts = jf.new_testsuite("bar") + ts.ok_testcase("jh.Bar", "test_x") + + jf.write +end + diff --git a/lib/cmdtest/junitlogger.rb b/lib/cmdtest/junitlogger.rb new file mode 100644 index 0000000..cf89c92 --- /dev/null +++ b/lib/cmdtest/junitlogger.rb @@ -0,0 +1,93 @@ +#---------------------------------------------------------------------- +# junitlogger.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +require "cmdtest/baselogger" +require "cmdtest/junitfile" + +module Cmdtest + class JunitLogger < BaseLogger + + def initialize(opts, file) + super(opts) + @file = file + end + + def testsuite_begin + @jf = JunitFile.new(@file) + end + + def testfile_begin(file) + super + end + + def testclass_begin(testcase_class) + super + @testcase_class = testcase_class + @ts = @jf.new_testsuite("CMDTEST", testcase_class.display_name) + end + + def testclass_end(testcase_class) + super + end + + def testmethod_begin(method) + super + @err_assertions = [] + end + + def testmethod_end(method) + super + if @err_assertions.size > 0 + message = @err_assertions[0].split(/\n/)[0] + type = "assert" + text = @err_assertions.join + @ts.err_testcase(_xml_class, method, message, type, text) + else + @ts.ok_testcase(_xml_class, method) + end + end + + def _xml_class + "CMDTEST." + @testcase_class.display_name + end + + def cmdline(cmdline_arg, comment) + super + end + + def assert_failure(str) + super() + @err_assertions << str + end + + def assert_error(str) + super() + @err_assertions << str + end + + def testsuite_end + super + @jf.write + end + + end +end + diff --git a/lib/cmdtest/testcase.rb b/lib/cmdtest/testcase.rb new file mode 100644 index 0000000..e6e3453 --- /dev/null +++ b/lib/cmdtest/testcase.rb @@ -0,0 +1,515 @@ +#---------------------------------------------------------------------- +# testcase.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +require "set" +require "stringio" + +module Cmdtest + + class AssertFailed < RuntimeError ; end + + # Base class for testcases. + # Some attributes and methods are prefixed with an "_" to avoid + # name collisions with user declared variables/methods. + + class Testcase + + @@_loaded_classes = [] + + def self.inherited(klass) + @@_loaded_classes << klass + end + + def self.new_subclasses + @@_loaded_classes = [] + yield + @@_loaded_classes + end + + #------------------------------ + + def self.display_name + to_s.sub(/^.*?::/, "") + end + + #------------------------------ + + def setup + end + + def teardown + end + + #------------------------------ + + attr_reader :_work_dir + + def initialize(test_method, runner) + @_test_method = test_method + @_runner = runner + @_work_dir = Workdir.new(runner) + @_in_cmd = false + @_comment_str = nil + end + + #------------------------------ + # Import file into the "workdir" from the outside world. + # The source is found relative to the current directory when "cmdtest" + # was invoked. The target is created inside the "workdir" relative to + # the current directory at the time of the call. + + def import_file(src, tgt) + src_path = File.expand_path(src, Workdir::ORIG_CWD) + tgt_path = tgt # rely on CWD + FileUtils.mkdir_p(File.dirname(tgt_path)) + FileUtils.cp(src_path, tgt_path) + end + + #------------------------------ + # Create a file inside the "workdir". + # The content can be specified either as an Array of lines or as + # a string with the content of the whole file. + # The filename is evaluated relative to the current directory at the + # time of the call. + + def create_file(filename, lines) + Util.wait_for_new_second + FileUtils.mkdir_p( File.dirname(filename) ) + File.open(filename, "w") do |f| + case lines + when Array + f.puts lines + else + f.write lines + end + end + end + + #------------------------------ + # "touch" a file inside the "workdir". + # The filename is evaluated relative to the current directory at the + # time of the call. + + def touch_file(filename) + Util.wait_for_new_second + FileUtils.touch(filename) + end + + #------------------------------ + # Dont count the specified file when calculating the "side effects" + # of a command. + + def ignore_file(file) + @_work_dir.ignore_file(file) + end + + #------------------------------ + # Dont count the specified file when calculating the "side effects" + # of a command. + + def ignore_files(*files) + for file in files.flatten + @_work_dir.ignore_file(file) + end + end + + #============================== + + # Used in methods invoked from the "cmd" do-block, in methods that + # should be executed *before* the actual command. + def _process_before + yield + end + + # Used in methods invoked from the "cmd" do-block, in methods that + # should be executed *after* the actual command. + def _process_after + _delayed_run_cmd + yield + end + + + def comment(str) + _process_before do + @_comment_str = str + end + end + + #------------------------------ + + def assert(flag, msg=nil) + _process_after do + _assert flag do + msg ? "assertion: #{msg}" : "assertion failed" + end + end + end + + #------------------------------ + + def exit_zero + _process_after do + @_checked_status = true + status = @_effects.exit_status + _assert status == 0 do + "expected zero exit status, got #{status}" + end + end + end + + #------------------------------ + + def exit_nonzero + _process_after do + @_checked_status = true + status = @_effects.exit_status + _assert status != 0 do + "expected nonzero exit status" + end + end + end + + #------------------------------ + + def exit_status(expected_status) + _process_after do + @_checked_status = true + status = @_effects.exit_status + _assert status == expected_status do + "expected #{expected_status} exit status, got #{status}" + end + end + end + + #------------------------------ + #------------------------------ + + def _xxx_files(xxx, files) + actual = @_effects.send(xxx) + expected = files.flatten.sort + #p [:xxx_files, xxx, actual, expected] + _assert0 actual == expected do + _format_output(xxx.to_s.gsub(/_/, " ").gsub(/modified/, "changed"), + actual.inspect + "\n", + expected.inspect + "\n") + end + end + + #------------------------------ + + def created_files(*files) + _process_after do + _xxx_files(:created_files, files) + @_checked_files_set << :created + end + end + + #------------------------------ + + def modified_files(*files) + _process_after do + _xxx_files(:modified_files, files) + @_checked_files_set << :modified + end + end + + alias :changed_files :modified_files + + #------------------------------ + + def removed_files(*files) + _process_after do + _xxx_files(:removed_files, files) + @_checked_files_set << :removed + end + end + + #------------------------------ + + def written_files(*files) + _process_after do + _xxx_files(:written_files, files) + @_checked_files_set << :created << :modified + end + end + + #------------------------------ + + def affected_files(*files) + _process_after do + _xxx_files(:affected_files, files) + @_checked_files_set << :created << :modified << :removed + end + end + + #------------------------------ + + def _read_file(file) + if File.directory?(file) && RUBY_PLATFORM =~ /mswin32/ + :is_directory + else + File.read(file) + end + rescue Errno::ENOENT + :no_such_file + rescue Errno::EISDIR + :is_directory + rescue + :other_error + end + + # Assert file equal to specific value. + def file_equal(file, expected) + _file_equal_aux(true, file, expected) + end + + def file_not_equal(file, expected) + _file_equal_aux(false, file, expected) + end + + def _file_equal_aux(positive, file, expected) + _process_after do + actual = _read_file(file) + case actual + when :no_such_file + _assert false do + "no such file: '#{file}'" + end + when :is_directory + _assert false do + "is a directory: '#{file}'" + end + when :other_error + _assert false do + "error reading file: '#{file}'" + end + else + _xxx_equal("file '#{file}'", positive, actual, expected) + end + end + end + + # Assert stdout equal to specific value. + def stdout_equal(expected) + _stdxxx_equal_aux("stdout", true, expected) + end + + # Assert stdout not equal to specific value. + def stdout_not_equal(expected) + _stdxxx_equal_aux("stdout", false, expected) + end + + # Assert stderr equal to specific value. + def stderr_equal(expected) + _stdxxx_equal_aux("stderr", true, expected) + end + + # Assert stderr not equal to specific value. + def stderr_not_equal(expected) + _stdxxx_equal_aux("stderr", false, expected) + end + + # helper methods + + def _stdxxx_equal_aux(stdxxx, positive, expected) + _process_after do + @_checked[stdxxx] = true + actual = @_effects.send(stdxxx) + _xxx_equal(stdxxx, positive, actual, expected) + end + end + + def _xxx_equal(xxx, positive, actual, expected) + _assert0 _output_match(positive, actual, expected) do + _format_output "wrong #{xxx}", actual, expected + end + end + + def _output_match(positive, actual, expected) + ! positive ^ _output_match_positive(actual, expected) + end + + def _output_match_positive(actual, expected) + case expected + when String + expected == actual + when Regexp + expected =~ actual + when Array + actual_lines = _str_as_lines(actual) + expected_lines = _str_or_arr_as_lines(expected) + if actual_lines.size != expected_lines.size + return false + end + actual_lines.each_index do |i| + return false if ! (expected_lines[i] === actual_lines[i]) + end + return true + else + raise "error" + end + end + + def _str_as_lines(str) + lines = str.split(/\n/, -1) + if lines[-1] == "" + lines.pop + elsif ! lines.empty? + lines[-1] << "<>" + end + return lines + end + + def _str_or_arr_as_lines(arg) + case arg + when Array + arg + when String + _str_as_lines(arg) + else + raise "unknown arg: #{arg.inspect}" + end + end + + def _indented_lines(prefix, output) + case output + when Array + lines = output + when String + lines = output.split(/\n/, -1) + if lines[-1] == "" + lines.pop + elsif ! lines.empty? + lines[-1] << "<>" + end + when Regexp + lines = [output] + else + raise "unexpected arg: #{output}" + end + if lines == [] + lines = ["[[empty]]"] + end + first = true + lines.map do |line| + if first + first = false + prefix + line.to_s + "\n" + else + " " * prefix.size + line.to_s + "\n" + end + end.join("") + end + + def _format_output(error, actual, expected) + res = "" + res << "ERROR: #{error}\n" + res << _indented_lines(" actual: ", actual) + res << _indented_lines(" expect: ", expected) + return res + end + + def _assert0(flag) + if ! flag + msg = yield + @_io.puts msg + @_nerrors += 1 + end + end + + def _assert(flag) + if ! flag + msg = yield + @_io.puts "ERROR: " + msg + @_nerrors += 1 + end + end + + #------------------------------ + + def _update_hardlinks + return if ! @_runner.opts.fast + + @_work_dir.chdir do + FileUtils.mkdir_p("../hardlinks") + Find.find(".") do |path| + st = File.lstat(path) + if st.file? + inode_path = "../hardlinks/%d" % [st.ino] + if ! File.file?(inode_path) + FileUtils.ln(path,inode_path) + end + end + end + end + end + + #------------------------------ + + def _delayed_run_cmd + return if @_cmd_done + @_cmd_done = true + + @_runner.notify("cmdline", @_cmdline, @_comment_str) + @_comment_str = nil + @_effects = @_work_dir.run_cmd(@_cmdline) + + @_checked_status = false + + @_checked = {} + @_checked["stdout"] = false + @_checked["stderr"] = false + + @_checked_files_set = Set.new + + @_nerrors = 0 + @_io = StringIO.new + end + + #------------------------------ + + def cmd(cmdline) + Util.wait_for_new_second + _update_hardlinks + @_cmdline = cmdline + @_cmd_done = false + + yield + _delayed_run_cmd + + exit_zero if ! @_checked_status + stdout_equal "" if ! @_checked["stdout"] + stderr_equal "" if ! @_checked["stderr"] + + created_files [] if ! @_checked_files_set.include?( :created ) + modified_files [] if ! @_checked_files_set.include?( :modified ) + removed_files [] if ! @_checked_files_set.include?( :removed ) + + if @_nerrors > 0 + str = @_io.string + str = str.gsub(/actual: \S+\/tmp-command\.sh/, "actual: COMMAND.sh") + raise AssertFailed, str + end + end + + end + +end diff --git a/lib/cmdtest/util.rb b/lib/cmdtest/util.rb new file mode 100644 index 0000000..c4044c2 --- /dev/null +++ b/lib/cmdtest/util.rb @@ -0,0 +1,67 @@ +#---------------------------------------------------------------------- +# util.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +module Cmdtest + class Util + + TRUST_MTIME = (ENV["TRUST_MTIME"] || "1").to_i != 0 + + @@opts = nil + + def self.opts=(opts) + @@opts = opts + end + + def self._timestamp_file + File.join(Workdir.tmp_cmdtest_dir, "TIMESTAMP") + end + + def self.wait_for_new_second + return if ! TRUST_MTIME || @@opts.fast + loop do + File.open(_timestamp_file, "w") {|f| f.puts Time.now } + break if File.mtime(_timestamp_file) != _newest_file_time + sleep 0.2 + end + end + + def self._newest_file_time + tnew = Time.at(0) + Find.find(Workdir.tmp_work_dir) do |path| + next if ! File.file?(path) + t = File.mtime(path) + tnew = t > tnew ? t : tnew + end + return tnew + end + + def self.chdir(dir) + old_cwd = Dir.pwd + Dir.chdir(dir) + yield + ensure + if Dir.pwd != old_cwd + Dir.chdir(old_cwd) + end + end + + end +end diff --git a/lib/cmdtest/workdir.rb b/lib/cmdtest/workdir.rb new file mode 100644 index 0000000..4fb6f00 --- /dev/null +++ b/lib/cmdtest/workdir.rb @@ -0,0 +1,120 @@ +#---------------------------------------------------------------------- +# workdir.rb +#---------------------------------------------------------------------- +# Copyright 2002-2009 Johan Holmberg. +#---------------------------------------------------------------------- +# This file is part of "cmdtest". +# +# "cmdtest" is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# "cmdtest" is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with "cmdtest". If not, see . +#---------------------------------------------------------------------- + +require "cmdtest/fssnapshot" +require "cmdtest/cmdeffects" + +require "fileutils" + +module Cmdtest + class Workdir + + ORIG_CWD = Dir.pwd + + def self.tmp_cmdtest_dir + File.join(ORIG_CWD, "tmp-cmdtest-%d" % [$cmdtest_level]) + end + + def self.tmp_work_dir + File.join(tmp_cmdtest_dir, "workdir") + end + + def initialize(runner) + @runner = runner + @dir = Workdir.tmp_work_dir + hardlinkdir = File.join(Workdir.tmp_cmdtest_dir, "hardlinks") + FileUtils.rm_rf(@dir) + FileUtils.rm_rf(hardlinkdir) + FileUtils.mkdir_p(@dir) + @ignored_files = [] + end + + #-------------------- + # called by user (indirectly) + + def ignore_file(file) + @ignored_files << file + end + + #-------------------- + + def chdir(&block) + Util.chdir(@dir, &block) + end + + def _take_snapshot + FsSnapshot.new(@dir, @ignored_files) + end + + def _windows + RUBY_PLATFORM =~ /mswin32/ + end + + def _shell + _windows ? "cmd /Q /c" : "sh" + end + + def _tmp_command_sh + File.join(Workdir.tmp_cmdtest_dir, + _windows ? "tmp-command.bat" : "tmp-command.sh") + end + + def _tmp_stdout_log + File.join(Workdir.tmp_cmdtest_dir, "tmp-stdout.log") + end + + def _tmp_stderr_log + File.join(Workdir.tmp_cmdtest_dir, "tmp-stderr.log") + end + + def _ruby_S(cmdline) + if @runner.opts.ruby_s + if cmdline =~ /ruby/ + cmdline + else + cmdline.gsub(/\b(\w+\.rb)\b/, 'ruby -S \1') + end + else + cmdline + end + end + + def run_cmd(cmdline) + File.open(_tmp_command_sh, "w") do |f| + f.puts _ruby_S(cmdline) + end + str = "%s %s > %s 2> %s" % [ + _shell, + _tmp_command_sh, + _tmp_stdout_log, + _tmp_stderr_log, + ] + before = _take_snapshot + ok = system(str) + after = _take_snapshot + CmdEffects.new($?, + File.read(_tmp_stdout_log), + File.read(_tmp_stderr_log), + before, after) + end + + end +end