From 8245c819bd4d23b436fac85db3bb3196d0b9051d Mon Sep 17 00:00:00 2001
From: Johan Holmberg <holmberg556@gmail.com>
Date: Tue, 24 Mar 2009 08:41:36 +0000
Subject: [PATCH] add the code

---
 bin/cmdtest.rb               | 330 ++++++++++++++++++++++
 lib/cmdtest/baselogger.rb    |  94 +++++++
 lib/cmdtest/cmdeffects.rb    |  83 ++++++
 lib/cmdtest/consolelogger.rb |  82 ++++++
 lib/cmdtest/fileinfo.rb      |  67 +++++
 lib/cmdtest/fssnapshot.rb    |  62 +++++
 lib/cmdtest/junitfile.rb     | 173 ++++++++++++
 lib/cmdtest/junitlogger.rb   |  93 +++++++
 lib/cmdtest/testcase.rb      | 515 +++++++++++++++++++++++++++++++++++
 lib/cmdtest/util.rb          |  67 +++++
 lib/cmdtest/workdir.rb       | 120 ++++++++
 11 files changed, 1686 insertions(+)
 create mode 100755 bin/cmdtest.rb
 create mode 100644 lib/cmdtest/baselogger.rb
 create mode 100644 lib/cmdtest/cmdeffects.rb
 create mode 100644 lib/cmdtest/consolelogger.rb
 create mode 100644 lib/cmdtest/fileinfo.rb
 create mode 100644 lib/cmdtest/fssnapshot.rb
 create mode 100644 lib/cmdtest/junitfile.rb
 create mode 100644 lib/cmdtest/junitlogger.rb
 create mode 100644 lib/cmdtest/testcase.rb
 create mode 100644 lib/cmdtest/util.rb
 create mode 100644 lib/cmdtest/workdir.rb

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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+# 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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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(/&/, "&amp;").gsub(/</, "&lt;").gsub(/>/, "&gt;")
+      end
+
+      def close
+        @f.close
+      end
+
+    end
+
+    #----------
+
+    class Testcase
+      def write(f)
+        f.put '    <testcase classname="%s" name="%s"/>', [
+          @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 '    <testcase classname="%s" name="%s">', [
+          @classname,
+          @name,
+        ]
+        f.put '      <failure message="%s" type="%s">%s</failure>', [
+          @message,
+          @type,
+          @text,
+        ]
+        f.put '    </testcase>'
+      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 '  <testsuite errors="%d" failures="%d" name="%s" package="%s">', [
+          0,
+          @testcases.grep(ErrTestcase).size,
+          @name,
+          @package,
+        ]
+        for testcase in @testcases
+          testcase.write(f)
+        end
+        f.put '  </testsuite>'
+      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 '<?xml version="1.0" encoding="UTF-8" ?>'
+      @f.put '<testsuites>'
+      for testsuite in @testsuites
+        testsuite.write(@f)
+      end
+      @f.put '</testsuites>'
+      @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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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] << "<<missing newline>>"
+      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] << "<<missing newline>>"
+        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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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 <http://www.gnu.org/licenses/>.
+#----------------------------------------------------------------------
+
+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