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(/, "<").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