├── .gitignore ├── History.txt ├── MIT-LICENSE.txt ├── README.rdoc ├── Rakefile ├── TODO.txt ├── bin └── testjour ├── cucumber.yml ├── features ├── cli.feature ├── distributed_run.feature ├── mysql.feature ├── run.feature ├── step_definitions │ └── testjour_steps.rb └── support │ └── env.rb ├── lib ├── testjour.rb └── testjour │ ├── cli.rb │ ├── colorer.rb │ ├── commands.rb │ ├── commands │ ├── command.rb │ ├── help.rb │ ├── run.rb │ ├── run_remote.rb │ ├── run_slave.rb │ └── version.rb │ ├── configuration.rb │ ├── core_extensions │ ├── retryable.rb │ └── wait_for_service.rb │ ├── cucumber_extensions │ ├── feature_file_finder.rb │ ├── http_formatter.rb │ └── step_counter.rb │ ├── mysql.rb │ ├── progressbar.rb │ ├── redis_queue.rb │ ├── result.rb │ ├── result_set.rb │ ├── results_formatter.rb │ └── rsync.rb ├── spec └── fixtures │ ├── config │ └── cucumber.yml │ ├── failing.feature │ ├── inline_table.feature │ ├── mysql_db.feature │ ├── passing.feature │ ├── sleep1.feature │ ├── sleep2.feature │ ├── step_definitions │ └── sample_steps.rb │ ├── support │ └── env.rb │ ├── table.feature │ └── undefined.feature └── vendor └── authprogs /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | pkg 3 | *.log 4 | tmp -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | === 1.0.0 / 2008-09-23 2 | 3 | * 1 major enhancement 4 | 5 | * Birthday! 6 | 7 | -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 Bryan Helmkamp 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | === Testjour 2 | 3 | * http://github.com/brynary/testjour 4 | 5 | === Description 6 | 7 | Distributed test running with autodiscovery via Bonjour (for Cucumber first) 8 | 9 | === Synopsis 10 | 11 | On machines to be used as Testjour slaves: 12 | 13 | $ mkdir testjour-working-dir 14 | $ testjour slave:start 15 | 16 | 17 | On your development machine, verify it can see the testjour slave: 18 | 19 | $ testjour list 20 | 21 | Testjour servers: 22 | 23 | bhelmkamp available bryans-computer.local.:62434 24 | 25 | Now run your tests: 26 | 27 | $ testjour run features 28 | 29 | Note: This only really makes sense if you use more than one slave. Otherwise 30 | it's slower than just running them locally. 31 | 32 | === Install 33 | 34 | To install the latest release (once there is a release): 35 | 36 | $ sudo gem install testjour 37 | 38 | For now, just pull down the code from the GitHub repo: 39 | 40 | $ git clone git://github.com/brynary/testjour.git 41 | $ cd testjour 42 | $ rake gem 43 | $ rake install_gem 44 | 45 | === Authors 46 | 47 | - Maintained by Bryan Helmkamp (http://brynary.com/) 48 | - Thanks to Weplay (http://weplay.com) for sponsoring development and supporting open sourcing it from the start -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require "rake/gempackagetask" 3 | require "rake/clean" 4 | require "spec/rake/spectask" 5 | require "cucumber/rake/task" 6 | require './lib/testjour.rb' 7 | 8 | Spec::Rake::SpecTask.new do |t| 9 | t.spec_opts == ["--color"] 10 | end 11 | 12 | Cucumber::Rake::Task.new do |t| 13 | end 14 | 15 | desc "Run the specs and the features" 16 | task :default => ["spec", "features"] 17 | 18 | spec = Gem::Specification.new do |s| 19 | s.name = "testjour" 20 | s.version = Testjour::VERSION 21 | s.author = "Bryan Helmkamp" 22 | s.email = "bryan" + "@" + "brynary.com" 23 | s.homepage = "http://github.com/brynary/testjour" 24 | s.summary = "Distributed test running with autodiscovery via Bonjour (for Cucumber first)" 25 | s.description = s.summary 26 | s.executables = "testjour" 27 | s.files = %w[History.txt MIT-LICENSE.txt README.rdoc Rakefile] + Dir["bin/*"] + Dir["lib/**/*"] + Dir["vendor/**/*"] 28 | end 29 | 30 | Rake::GemPackageTask.new(spec) do |package| 31 | package.gem_spec = spec 32 | end 33 | 34 | CLEAN.include ["pkg", "*.gem", "doc", "ri", "coverage"] 35 | 36 | desc 'Install the package as a gem.' 37 | task :install => [:clean, :package] do 38 | gem = Dir['pkg/*.gem'].first 39 | sh "sudo gem install --no-rdoc --no-ri --local #{gem}" 40 | end 41 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | Rewrite: 2 | 3 | Ensure we're counting table cells properly 4 | Ensure we're counting empty scenarios properly 5 | 6 | Check the testjour version of the slaves matches the runner 7 | Detect runner crashes and send the feature file to another runner 8 | Detect and handle RSync failure 9 | Add testjour authorize and deauthorize for managing SSH config -------------------------------------------------------------------------------- /bin/testjour: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "rubygems" 4 | 5 | require File.expand_path(File.dirname(__FILE__) + "/../lib/testjour") 6 | require "testjour/cli" 7 | 8 | result = Testjour::CLI.execute(ARGV) 9 | Kernel.exit(result) 10 | -------------------------------------------------------------------------------- /cucumber.yml: -------------------------------------------------------------------------------- 1 | default: --format pretty features 2 | -------------------------------------------------------------------------------- /features/cli.feature: -------------------------------------------------------------------------------- 1 | Feature: testjour CLI 2 | 3 | In order to learn how to use Testjour 4 | As a software engineer 5 | I want to see Testjour help and version information 6 | 7 | Scenario: Print version information 8 | 9 | When I run `testjour --version` 10 | Then it should not print to stderr 11 | And it should pass with "testjour 0.3.2" 12 | 13 | Scenario: Print help information 14 | When I run `testjour --help` 15 | Then it should not print to stderr 16 | And it should pass with 17 | """ 18 | testjour help: 19 | """ 20 | -------------------------------------------------------------------------------- /features/distributed_run.feature: -------------------------------------------------------------------------------- 1 | Feature: Distributed runs 2 | 3 | In order to write software quicker 4 | As a software engineer 5 | I want to run my Cucumber features distributed across hardware 6 | 7 | Scenario: Distribute runs (localhost) 8 | Given Testjour is configured to run on localhost in a temp1 directory 9 | And Testjour is configured to run on localhost in a temp2 directory 10 | When I run `testjour sleep1.feature sleep2.feature` 11 | Then it should fail with "2 steps passed" 12 | And the output should contain "FAIL" 13 | And the output should contain "1 steps failed" 14 | And it should run on 2 remote slaves 15 | 16 | Scenario: Distribute runs (using named host) 17 | Given Testjour is configured to run on this machine in a temp1 directory 18 | And Testjour is configured to run on this machine in a temp2 directory 19 | When I run `testjour sleep1.feature sleep2.feature` 20 | Then it should fail with "2 steps passed" 21 | And the output should contain "FAIL" 22 | And the output should contain "1 steps failed" 23 | And it should run on 2 remote slaves 24 | 25 | Scenario: Distribute runs (using > 1 remote slave, queue host and rsync uri) 26 | Given Testjour is configured to run on this machine in a temp1 directory with 2 slaves 27 | And Testjour is configured to use this machine as the queue host 28 | And Testjour is configured to use this machine as the rsync host 29 | When I run `testjour sleep1.feature sleep2.feature` 30 | Then it should fail with "2 steps passed" 31 | And the output should contain "FAIL" 32 | And the output should contain "1 steps failed" 33 | And it should run on 2 remote slaves 34 | -------------------------------------------------------------------------------- /features/mysql.feature: -------------------------------------------------------------------------------- 1 | Feature: MySQL databases 2 | 3 | In order to avoid MySQL deadlocks 4 | As a software engineer 5 | I want to run my Testjour slaves with separate DBs 6 | 7 | Scenario: Create MySQL databases 8 | When I run `testjour --create-mysql-db mysql_db.feature` 9 | Then it should pass with "1 steps passed" 10 | And testjour.log should include "mysqladmin -u root create testjour_runner_" 11 | 12 | Scenario: Don't create MySQL databases by default 13 | When I run `testjour mysql_db.feature` 14 | Then it should fail with "1 steps failed" 15 | -------------------------------------------------------------------------------- /features/run.feature: -------------------------------------------------------------------------------- 1 | Feature: Run Features 2 | 3 | In order to write software quicker 4 | As a software engineer 5 | I want to run my Cucumber features in parallel 6 | 7 | Scenario: Run passing steps 8 | When I run `testjour passing.feature` 9 | Then it should pass with "1 steps passed" 10 | 11 | Scenario: Run scenario outline tables 12 | When I run `testjour table.feature` 13 | Then it should pass with "9 steps passed" 14 | 15 | Scenario: Only run scenarios matching tags 16 | When I run `testjour --tags @foo passing.feature` 17 | Then it should pass with no output 18 | 19 | Scenario: Run inline tables 20 | When I run `testjour inline_table.feature` 21 | Then it should pass with "2 steps passed" 22 | 23 | Scenario: Run files from a profile 24 | When I run `testjour -p failing` 25 | Then it should fail with "1 steps failed" 26 | 27 | Scenario: Run failing steps 28 | When I run `testjour failing.feature` 29 | Then it should fail with "1 steps failed" 30 | And the output should contain "F1) FAIL" 31 | 32 | Scenario: Run undefined steps 33 | When I run `testjour -r support/env undefined.feature` 34 | Then it should pass with "1 steps undefined" 35 | And the output should contain "U1) undefined.feature:4:in `Given undefined'" 36 | 37 | Scenario: Strict mode 38 | When I run `testjour --strict -r support/env undefined.feature` 39 | Then it should fail with "1 steps undefined" 40 | And the output should contain "U1) undefined.feature:4:in `Given undefined'" 41 | 42 | Scenario: Run pending steps 43 | When I run `testjour -r support/env -r step_definitions undefined.feature` 44 | Then it should pass with "1 steps pending" 45 | 46 | Scenario: Parallel runs 47 | When I run `testjour failing.feature passing.feature` 48 | Then it should fail with "1 steps passed" 49 | And the output should contain "FAIL" 50 | And the output should contain "1 steps failed" 51 | And it should run on 2 slaves 52 | 53 | Scenario: Preload application 54 | Given a file testjour_preload.rb at the root of the project that logs "Hello, world" 55 | When I run `testjour passing.feature` 56 | And testjour.log should include "Hello, world" 57 | -------------------------------------------------------------------------------- /features/step_definitions/testjour_steps.rb: -------------------------------------------------------------------------------- 1 | require "systemu" 2 | require "fileutils" 3 | 4 | Given /^Testjour is configured to run on localhost in a (\w+) directory$/ do |dir_name| 5 | @args ||= [] 6 | full_path = File.expand_path("./tmp/#{dir_name}") 7 | @args << "--on=testjour://localhost#{full_path}" 8 | 9 | FileUtils.rm_rf full_path 10 | FileUtils.mkdir_p full_path 11 | end 12 | 13 | Given /^Testjour is configured to run on this machine in a (\w+) directory$/ do |dir_name| 14 | @args ||= [] 15 | full_path = File.expand_path("./tmp/#{dir_name}") 16 | @args << "--on=testjour://#{Socket.gethostname}#{full_path}" 17 | 18 | FileUtils.rm_rf full_path 19 | FileUtils.mkdir_p full_path 20 | end 21 | 22 | Given /^Testjour is configured to run on this machine in a (\w+) directory with (\d+) slaves$/ do |dir_name, slave_count| 23 | @args ||= [] 24 | full_path = File.expand_path("./tmp/#{dir_name}") 25 | @args << "--on=testjour://#{Socket.gethostname}#{full_path}?workers=#{slave_count}" 26 | 27 | FileUtils.rm_rf full_path 28 | FileUtils.mkdir_p full_path 29 | end 30 | 31 | Given /^Testjour is configured to use this machine as the queue host$/ do 32 | @args ||= [] 33 | @args << "--queue-host=#{Socket.gethostname}" 34 | end 35 | 36 | Given /^Testjour is configured to use this machine as the rsync host$/ do 37 | @args ||= [] 38 | @args << "--rsync-uri=#{Socket.gethostname}:/tmp/testjour_feature_run" 39 | end 40 | 41 | Given /^a file testjour_preload.rb at the root of the project that logs "Hello, world"$/ do 42 | File.open(File.join(@full_dir, 'testjour_preload.rb'), 'w') do |file| 43 | file.puts "Testjour.logger.info 'Hello, world'" 44 | end 45 | end 46 | 47 | When /^I run `testjour (.+)`$/ do |args| 48 | @args ||= [] 49 | @args += args.split 50 | 51 | Dir.chdir(@full_dir) do 52 | testjour_path = File.expand_path(File.dirname(__FILE__) + "/../../../../bin/testjour") 53 | cmd = "#{testjour_path} #{@args.join(" ")}" 54 | # puts cmd 55 | status, @stdout, @stderr = systemu(cmd) 56 | @exit_code = status.exitstatus 57 | # puts @stderr.to_s 58 | end 59 | end 60 | 61 | Then "it should not print to stderr" do 62 | @stderr.should == "" 63 | end 64 | 65 | Then /^it should (pass|fail) with "(.+)"$/ do |pass_or_fail, text| 66 | if pass_or_fail == "pass" 67 | @exit_code.should == 0 68 | else 69 | @exit_code.should_not == 0 70 | end 71 | 72 | @stdout.should include(text) 73 | end 74 | 75 | Then /^it should pass with no output$/ do 76 | @exit_code.should == 0 77 | @stdout.should == "" 78 | end 79 | 80 | Then /^it should (pass|fail) with$/ do |pass_or_fail, text| 81 | if pass_or_fail == "pass" 82 | @exit_code.should == 0 83 | else 84 | @exit_code.should_not == 0 85 | end 86 | 87 | @stdout.should include(text) 88 | end 89 | 90 | Then /^the output should contain "(.+)"$/ do |text| 91 | @stdout.should include(text) 92 | end 93 | 94 | Then /^([a-z\.]+) should include "(.+)"$/ do |filename, text| 95 | Dir.chdir(@full_dir) do 96 | IO.read(filename).should include(text) 97 | end 98 | end 99 | 100 | Then /^it should run on (\d+) slaves?$/ do |count| 101 | Dir.chdir(@full_dir) do 102 | log = IO.read("testjour.log") 103 | pids = log.scan(/\[\d+\]/).uniq 104 | 105 | # One master process and the slaves 106 | if pids.size != count.to_i + 1 107 | raise("Expected #{count} slave PIDs, got #{pids.size - 1}:\nLog is:\n#{log}") 108 | end 109 | end 110 | end 111 | 112 | Then /^it should run on 2 remote slaves$/ do 113 | pids = @stdout.scan(/\[\d+\] ran \d+ steps/).uniq 114 | pids.size.should == 2 115 | end 116 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + "/../../lib/testjour") 2 | 3 | require 'spec/expectations' 4 | 5 | def be_like(expected) 6 | simple_matcher "should be like #{expected.inspect}" do |actual| 7 | actual.strip == expected.strip 8 | end 9 | end 10 | 11 | def testjour_cleanup 12 | @full_dir = File.expand_path(File.dirname(__FILE__) + "/../../spec/fixtures") 13 | 14 | Dir.chdir(@full_dir) do 15 | File.unlink("testjour.log") if File.exists?("testjour.log") 16 | File.unlink("testjour_preload.rb") if File.exists?("testjour_preload.rb") 17 | end 18 | end 19 | 20 | Before do 21 | testjour_cleanup 22 | end 23 | 24 | After do 25 | testjour_cleanup 26 | end -------------------------------------------------------------------------------- /lib/testjour.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__))) unless $LOAD_PATH.include?(File.expand_path(File.dirname(__FILE__))) 2 | 3 | require "testjour/cli" 4 | require "logger" 5 | require "English" 6 | 7 | def silence_stream(stream) 8 | old_stream = stream.dup 9 | stream.reopen(RUBY_PLATFORM =~ /mswin/ ? 'NUL:' : '/dev/null') 10 | stream.sync = true 11 | yield 12 | ensure 13 | stream.reopen(old_stream) 14 | end 15 | 16 | def detached_exec(command) 17 | pid = fork do 18 | silence_stream(STDOUT) do 19 | silence_stream(STDERR) do 20 | exec(command) 21 | end 22 | end 23 | end 24 | 25 | Process.detach(pid) 26 | return pid 27 | end 28 | 29 | module Testjour 30 | VERSION = "0.3.2" 31 | 32 | def self.socket_hostname 33 | @socket_hostname ||= Socket.gethostname 34 | end 35 | 36 | def self.logger 37 | return @logger if @logger 38 | setup_logger 39 | @logger 40 | end 41 | 42 | def self.override_logger_pid(pid) 43 | @overridden_logger_pid = pid 44 | end 45 | 46 | def self.effective_pid 47 | @overridden_logger_pid || $PID 48 | end 49 | 50 | def self.setup_logger(dir = "./") 51 | @logger = Logger.new(File.expand_path(File.join(dir, "testjour.log"))) 52 | 53 | @logger.formatter = proc do |severity, time, progname, msg| 54 | "#{time.strftime("%b %d %H:%M:%S")} [#{Testjour.effective_pid}]: #{msg}\n" 55 | end 56 | 57 | @logger.level = Logger::DEBUG 58 | end 59 | end -------------------------------------------------------------------------------- /lib/testjour/cli.rb: -------------------------------------------------------------------------------- 1 | require "testjour/commands" 2 | 3 | module Testjour 4 | class CLI 5 | 6 | def self.execute(*args) 7 | new(*args).execute 8 | end 9 | 10 | def initialize(args, out_stream = STDOUT, err_stream = STDERR) 11 | @args = args 12 | @out_stream = out_stream 13 | @err_stream = err_stream 14 | end 15 | 16 | def command_class 17 | case @args.first 18 | when "--help" 19 | @args.shift 20 | Commands::Help 21 | when "--version" 22 | @args.shift 23 | Commands::Version 24 | when "run:slave" 25 | @args.shift 26 | Commands::RunSlave 27 | when "run:remote" 28 | @args.shift 29 | Commands::RunRemote 30 | else 31 | Commands::Run 32 | end 33 | end 34 | 35 | def execute 36 | command_class.new(@args, @out_stream, @err_stream).execute || 0 37 | end 38 | 39 | end 40 | end -------------------------------------------------------------------------------- /lib/testjour/colorer.rb: -------------------------------------------------------------------------------- 1 | require "cucumber" 2 | require "cucumber/formatter/ansicolor" 3 | 4 | module Testjour 5 | class Colorer 6 | extend ::Cucumber::Formatter::ANSIColor 7 | end 8 | end -------------------------------------------------------------------------------- /lib/testjour/commands.rb: -------------------------------------------------------------------------------- 1 | require "testjour/commands/help" 2 | require "testjour/commands/version" 3 | require "testjour/commands/run" 4 | require "testjour/commands/run_slave" 5 | require "testjour/commands/run_remote" -------------------------------------------------------------------------------- /lib/testjour/commands/command.rb: -------------------------------------------------------------------------------- 1 | module Testjour 2 | module Commands 3 | 4 | class Command 5 | 6 | def initialize(args = [], out_stream = STDOUT, err_stream = STDERR) 7 | @options = {} 8 | @args = args 9 | @out_stream = out_stream 10 | @err_stream = err_stream 11 | end 12 | 13 | protected 14 | 15 | def configuration 16 | return @configuration if @configuration 17 | @configuration = Configuration.new(@args) 18 | @configuration 19 | end 20 | 21 | def load_plain_text_features(files) 22 | features = Cucumber::Ast::Features.new 23 | 24 | Array(files).each do |f| 25 | feature_file = Cucumber::FeatureFile.new(f) 26 | feature = feature_file.parse(step_mother, configuration.cucumber_configuration.options) 27 | if feature 28 | features.add_feature(feature) 29 | end 30 | end 31 | 32 | return features 33 | end 34 | 35 | def parser 36 | @parser ||= Cucumber::Parser::FeatureParser.new 37 | end 38 | 39 | def step_mother 40 | Cucumber::Cli::Main.step_mother 41 | end 42 | 43 | def testjour_path 44 | File.expand_path(File.dirname(__FILE__) + "/../../../bin/testjour") 45 | end 46 | 47 | end 48 | 49 | end 50 | end -------------------------------------------------------------------------------- /lib/testjour/commands/help.rb: -------------------------------------------------------------------------------- 1 | require "testjour/commands/command" 2 | 3 | module Testjour 4 | module Commands 5 | 6 | class Help < Command 7 | 8 | def execute 9 | @out_stream.puts "testjour help:" 10 | end 11 | 12 | end 13 | 14 | end 15 | end -------------------------------------------------------------------------------- /lib/testjour/commands/run.rb: -------------------------------------------------------------------------------- 1 | require "optparse" 2 | require "socket" 3 | require "etc" 4 | 5 | require "testjour/commands/command" 6 | require "testjour/redis_queue" 7 | require "testjour/configuration" 8 | require "testjour/cucumber_extensions/step_counter" 9 | require "testjour/cucumber_extensions/feature_file_finder" 10 | require "testjour/results_formatter" 11 | require "testjour/result" 12 | 13 | module Testjour 14 | module Commands 15 | 16 | class Run < Command 17 | 18 | def execute 19 | configuration.load_additional_args_from_external_file 20 | configuration.parse! 21 | configuration.setup 22 | 23 | if configuration.feature_files.any? 24 | redis_queue = RedisQueue.new(configuration.queue_host, 25 | configuration.queue_prefix, 26 | configuration.queue_timeout) 27 | redis_queue.reset_all 28 | queue_features 29 | 30 | at_exit do 31 | Testjour.logger.info caller.join("\n") 32 | redis_queue.reset_all 33 | end 34 | 35 | 36 | @started_slaves = 0 37 | start_slaves 38 | 39 | puts "Requested build from #{@started_slaves} slaves... (Waiting for #{step_counter.count} results)" 40 | puts 41 | 42 | print_results 43 | else 44 | Testjour.logger.info("No feature files. Quitting.") 45 | end 46 | end 47 | 48 | def queue_features 49 | Testjour.logger.info("Queuing features...") 50 | queue = RedisQueue.new(configuration.queue_host, 51 | configuration.queue_prefix, 52 | configuration.queue_timeout) 53 | 54 | configuration.feature_files.each do |feature_file| 55 | queue.push(:feature_files, feature_file) 56 | Testjour.logger.info "Queued: #{feature_file}" 57 | end 58 | end 59 | 60 | def start_slaves 61 | start_local_slaves 62 | start_remote_slaves 63 | end 64 | 65 | def start_local_slaves 66 | configuration.local_slave_count.times do 67 | @started_slaves += 1 68 | start_slave 69 | end 70 | end 71 | 72 | def start_remote_slaves 73 | if configuration.remote_slaves.any? 74 | if configuration.external_rsync_uri 75 | Rsync.copy_from_current_directory_to(configuration.external_rsync_uri) 76 | end 77 | configuration.remote_slaves.each do |remote_slave| 78 | @started_slaves += start_remote_slave(remote_slave) 79 | end 80 | end 81 | end 82 | 83 | def start_remote_slave(remote_slave) 84 | num_workers = 1 85 | if remote_slave.match(/\?workers=(\d+)/) 86 | num_workers = $1.to_i 87 | remote_slave.gsub(/\?workers=(\d+)/, '') 88 | end 89 | uri = URI.parse(remote_slave) 90 | cmd = remote_slave_run_command(uri.user, uri.host, uri.path, num_workers) 91 | Testjour.logger.info "Starting remote slave: #{cmd}" 92 | detached_exec(cmd) 93 | num_workers 94 | end 95 | 96 | def remote_slave_run_command(user, host, path, max_remote_slaves) 97 | "ssh -o StrictHostKeyChecking=no #{user}#{'@' if user}#{host} testjour run:remote --in=#{path} --max-remote-slaves=#{max_remote_slaves} #{configuration.run_slave_args.join(' ')} #{testjour_uri}".squeeze(" ") 98 | end 99 | 100 | def start_slave 101 | Testjour.logger.info "Starting slave: #{local_run_command}" 102 | detached_exec(local_run_command) 103 | end 104 | 105 | def print_results 106 | results_formatter = ResultsFormatter.new(step_counter, configuration.options) 107 | queue = RedisQueue.new(configuration.queue_host, 108 | configuration.queue_prefix, 109 | configuration.queue_timeout) 110 | 111 | step_counter.count.times do 112 | results_formatter.result(queue.blocking_pop(:results)) 113 | end 114 | 115 | results_formatter.finish 116 | return results_formatter.failed? ? 1 : 0 117 | end 118 | 119 | def step_counter 120 | return @step_counter if @step_counter 121 | 122 | features = load_plain_text_features(configuration.feature_files) 123 | @step_counter = Testjour::StepCounter.new 124 | tree_walker = Cucumber::Ast::TreeWalker.new(step_mother, [@step_counter]) 125 | tree_walker.options = configuration.cucumber_configuration.options 126 | tree_walker.visit_features(features) 127 | return @step_counter 128 | end 129 | 130 | def local_run_command 131 | "testjour run:slave #{configuration.run_slave_args.join(' ')} #{testjour_uri}".squeeze(" ") 132 | end 133 | 134 | def testjour_uri 135 | if configuration.external_rsync_uri 136 | "rsync://#{configuration.external_rsync_uri}" 137 | else 138 | user = Etc.getpwuid.name 139 | host = Testjour.socket_hostname 140 | "rsync://#{user}@#{host}" + File.expand_path(".") 141 | end 142 | end 143 | 144 | def testjour_path 145 | File.expand_path(File.dirname(__FILE__) + "/../../../bin/testjour") 146 | end 147 | 148 | end 149 | 150 | end 151 | end -------------------------------------------------------------------------------- /lib/testjour/commands/run_remote.rb: -------------------------------------------------------------------------------- 1 | require "testjour/commands/command" 2 | require "cucumber" 3 | require "uri" 4 | require "daemons/daemonize" 5 | require "testjour/cucumber_extensions/http_formatter" 6 | require "testjour/mysql" 7 | require "testjour/rsync" 8 | require "stringio" 9 | 10 | module Testjour 11 | module Commands 12 | 13 | class RunRemote < Command 14 | 15 | def dir 16 | configuration.in 17 | end 18 | 19 | def execute 20 | configuration.parse! 21 | configuration.parse_uri! 22 | 23 | Dir.chdir(dir) do 24 | Testjour.setup_logger(dir) 25 | Testjour.logger.info "Starting #{self.class.name}" 26 | rsync 27 | start_additional_slaves 28 | end 29 | end 30 | 31 | def start_additional_slaves 32 | 1.upto(configuration.max_remote_slaves) do |i| 33 | start_slave 34 | end 35 | end 36 | 37 | def start_slave 38 | Testjour.logger.info "Starting slave: #{local_run_command}" 39 | detached_exec(local_run_command) 40 | end 41 | 42 | def local_run_command 43 | "testjour run:slave #{configuration.run_slave_args.join(' ')} #{testjour_uri}".squeeze(" ") 44 | end 45 | 46 | def testjour_uri 47 | user = Etc.getpwuid.name 48 | host = Testjour.socket_hostname 49 | "rsync://#{user}@#{host}" + File.expand_path(".") 50 | end 51 | 52 | 53 | def rsync 54 | Rsync.copy_to_current_directory_from(configuration.rsync_uri) 55 | end 56 | end 57 | 58 | end 59 | end -------------------------------------------------------------------------------- /lib/testjour/commands/run_slave.rb: -------------------------------------------------------------------------------- 1 | require "testjour/commands/command" 2 | require "cucumber" 3 | require "uri" 4 | require "daemons/daemonize" 5 | require "testjour/cucumber_extensions/http_formatter" 6 | require "testjour/mysql" 7 | require "stringio" 8 | 9 | module Testjour 10 | module Commands 11 | 12 | class RunSlave < Command 13 | 14 | # Boolean indicating whether this worker can or can not fork. 15 | # Automatically set if a fork(2) fails. 16 | attr_accessor :cant_fork 17 | 18 | def execute 19 | configuration.parse! 20 | configuration.parse_uri! 21 | 22 | Dir.chdir(dir) do 23 | Testjour.setup_logger(dir) 24 | Testjour.logger.info "Starting #{self.class.name}" 25 | 26 | before_require 27 | 28 | begin 29 | configuration.setup 30 | configuration.setup_mysql 31 | 32 | require_cucumber_files 33 | preload_app 34 | 35 | work 36 | rescue Object => ex 37 | Testjour.logger.error "#{self.class.name} error: #{ex.message}" 38 | Testjour.logger.error ex.backtrace.join("\n") 39 | end 40 | end 41 | end 42 | 43 | def dir 44 | configuration.path 45 | end 46 | 47 | def before_require 48 | enable_gc_optimizations 49 | end 50 | 51 | def work 52 | queue = RedisQueue.new(configuration.queue_host, 53 | configuration.queue_prefix, 54 | configuration.queue_timeout) 55 | feature_file = true 56 | 57 | while feature_file 58 | if (feature_file = queue.pop(:feature_files)) 59 | Testjour.logger.info "Loading: #{feature_file}" 60 | features = load_plain_text_features(feature_file) 61 | parent_pid = $PID 62 | if @child = fork 63 | Testjour.logger.info "Forked #{@child} to run #{feature_file}" 64 | Process.wait 65 | Testjour.logger.info "Finished running: #{feature_file}" 66 | else 67 | Testjour.override_logger_pid(parent_pid) 68 | Testjour.logger.info "Executing: #{feature_file}" 69 | execute_features(features) 70 | exit! unless @cant_fork 71 | end 72 | else 73 | Testjour.logger.info "No feature file found. Finished" 74 | end 75 | end 76 | end 77 | 78 | def execute_features(features) 79 | http_formatter = Testjour::HttpFormatter.new(configuration) 80 | tree_walker = Cucumber::Ast::TreeWalker.new(step_mother, [http_formatter]) 81 | tree_walker.options = configuration.cucumber_configuration.options 82 | Testjour.logger.info "Visiting..." 83 | tree_walker.visit_features(features) 84 | end 85 | 86 | def require_cucumber_files 87 | step_mother.load_code_files(configuration.cucumber_configuration.support_to_load) 88 | step_mother.after_configuration(configuration.cucumber_configuration) 89 | step_mother.load_code_files(configuration.cucumber_configuration.step_defs_to_load) 90 | end 91 | 92 | def preload_app 93 | if File.exist?('./testjour_preload.rb') 94 | Testjour.logger.info 'Requiring ./testjour_preload.rb' 95 | require './testjour_preload.rb' 96 | end 97 | end 98 | 99 | # Not every platform supports fork. Here we do our magic to 100 | # determine if yours does. 101 | def fork 102 | return if @cant_fork 103 | 104 | begin 105 | Kernel.fork 106 | rescue NotImplementedError 107 | @cant_fork = true 108 | nil 109 | end 110 | end 111 | 112 | # Enables GC Optimizations if you're running REE. 113 | # http://www.rubyenterpriseedition.com/faq.html#adapt_apps_for_cow 114 | def enable_gc_optimizations 115 | if GC.respond_to?(:copy_on_write_friendly=) 116 | GC.copy_on_write_friendly = true 117 | end 118 | end 119 | 120 | end 121 | 122 | end 123 | end -------------------------------------------------------------------------------- /lib/testjour/commands/version.rb: -------------------------------------------------------------------------------- 1 | require "testjour/commands/command" 2 | 3 | module Testjour 4 | module Commands 5 | 6 | class Version < Command 7 | 8 | def execute 9 | @out_stream.puts "testjour #{VERSION}" 10 | end 11 | 12 | end 13 | 14 | end 15 | end -------------------------------------------------------------------------------- /lib/testjour/configuration.rb: -------------------------------------------------------------------------------- 1 | module Testjour 2 | 3 | class Configuration 4 | attr_reader :unknown_args, :options, :path, :full_uri 5 | 6 | def initialize(args) 7 | @options = {} 8 | @args = args 9 | @unknown_args = [] 10 | end 11 | 12 | def setup 13 | require 'cucumber/cli/main' 14 | Cucumber.class_eval do 15 | def language_incomplete? 16 | false 17 | end 18 | end 19 | # Cucumber.load_language("en") 20 | step_mother.options = cucumber_configuration.options 21 | end 22 | 23 | def max_local_slaves 24 | @options[:max_local_slaves] || 2 25 | end 26 | 27 | def max_remote_slaves 28 | @options[:max_remote_slaves] || 1 29 | end 30 | 31 | def in 32 | @options[:in] 33 | end 34 | 35 | def rsync_uri 36 | external_rsync_uri || "#{full_uri.user}#{'@' if full_uri.user}#{full_uri.host}:#{full_uri.path}" 37 | end 38 | 39 | def external_rsync_uri 40 | @options[:rsync_uri] 41 | end 42 | 43 | def queue_host 44 | @queue_host || @options[:queue_host] || Testjour.socket_hostname 45 | end 46 | 47 | def external_queue_host? 48 | queue_host != Testjour.socket_hostname 49 | end 50 | 51 | def queue_prefix 52 | @options[:queue_prefix] || 'default' 53 | end 54 | 55 | def queue_timeout 56 | @options[:queue_timeout].to_i || 270 57 | end 58 | 59 | def remote_slaves 60 | @options[:slaves] || [] 61 | end 62 | 63 | def setup_mysql 64 | return unless mysql_mode? 65 | 66 | mysql = MysqlDatabaseSetup.new 67 | 68 | mysql.create_database 69 | at_exit do 70 | Testjour.logger.info caller.join("\n") 71 | mysql.drop_database 72 | end 73 | 74 | ENV["TESTJOUR_DB"] = mysql.runner_database_name 75 | mysql.load_schema 76 | end 77 | 78 | def step_mother 79 | Cucumber::Cli::Main.step_mother 80 | end 81 | 82 | def mysql_mode? 83 | @options[:create_mysql_db] 84 | end 85 | 86 | def local_slave_count 87 | [feature_files.size, max_local_slaves].min 88 | end 89 | 90 | def parser 91 | @parser ||= Cucumber::Parser::FeatureParser.new 92 | end 93 | 94 | def load_plain_text_features(files) 95 | features = Cucumber::Ast::Features.new 96 | 97 | Array(files).each do |f| 98 | feature_file = Cucumber::FeatureFile.new(f) 99 | feature = feature_file.parse(step_mother, cucumber_configuration.options) 100 | if feature 101 | features.add_feature(feature) 102 | end 103 | end 104 | 105 | return features 106 | end 107 | 108 | def feature_files 109 | return @feature_files if @feature_files 110 | 111 | features = load_plain_text_features(cucumber_configuration.feature_files) 112 | finder = Testjour::FeatureFileFinder.new 113 | walker = Cucumber::Ast::TreeWalker.new(step_mother, [finder]) 114 | walker.options = cucumber_configuration.options 115 | walker.visit_features(features) 116 | @feature_files = finder.feature_files 117 | 118 | return @feature_files 119 | end 120 | 121 | def cucumber_configuration 122 | return @cucumber_configuration if @cucumber_configuration 123 | @cucumber_configuration = Cucumber::Cli::Configuration.new(StringIO.new, StringIO.new) 124 | Testjour.logger.info "Arguments for Cucumber: #{args_for_cucumber.inspect}" 125 | @cucumber_configuration.parse!(args_for_cucumber) 126 | @cucumber_configuration 127 | end 128 | 129 | def unshift_args(pushed_args) 130 | pushed_args.each do |pushed_arg| 131 | @args.unshift(pushed_arg) 132 | end 133 | end 134 | 135 | def load_additional_args_from_external_file 136 | args_from_file = begin 137 | if File.exist?(args_file) 138 | File.read(args_file).strip.split 139 | else 140 | [] 141 | end 142 | end 143 | unshift_args(args_from_file) 144 | end 145 | 146 | def args_file 147 | # We need to know about this CLI option prior to OptParse's parse 148 | args_file_option = @args.detect{|arg| arg =~ /^--testjour-config=/} 149 | if args_file_option 150 | args_file_option =~ /^--testjour-config=(.*)/ 151 | $1 152 | else 153 | 'testjour.yml' 154 | end 155 | end 156 | 157 | def parse! 158 | begin 159 | option_parser.parse!(@args) 160 | rescue OptionParser::InvalidOption => e 161 | e.recover @args 162 | saved_arg = @args.shift 163 | @unknown_args << saved_arg 164 | 165 | if @args.any? && !saved_arg.include?("=") && @args.first[0..0] != "-" 166 | @unknown_args << @args.shift 167 | end 168 | 169 | retry 170 | end 171 | end 172 | 173 | def parse_uri! 174 | full_uri = URI.parse(@args.shift) 175 | @path = full_uri.path 176 | @full_uri = full_uri.dup 177 | @queue_host = full_uri.host unless options[:queue_host] 178 | end 179 | 180 | def run_slave_args 181 | [testjour_args + @unknown_args] 182 | end 183 | 184 | def testjour_args 185 | args_from_options = [] 186 | if @options[:create_mysql_db] 187 | args_from_options << "--create-mysql-db" 188 | end 189 | if @options[:queue_host] || external_queue_host? 190 | args_from_options << "--queue-host=#{queue_host}" 191 | end 192 | if @options[:queue_prefix] 193 | args_from_options << "--queue-prefix=#{@options[:queue_prefix]}" 194 | end 195 | return args_from_options 196 | end 197 | 198 | def args_for_cucumber 199 | @unknown_args + @args 200 | end 201 | 202 | protected 203 | 204 | def option_parser 205 | OptionParser.new do |opts| 206 | opts.on("--testjour-config=ARGS_FILE", "Load additional testjour args from the specified file (defaults to testjour.yml)") do |args_file| 207 | @options[:args_file] = args_file 208 | end 209 | 210 | opts.on("--on=SLAVE", "Specify a slave URI (testjour://user@host:/path/to/working/dir?workers=3)") do |slave| 211 | @options[:slaves] ||= [] 212 | @options[:slaves] << slave 213 | end 214 | 215 | opts.on("--in=DIR", "Working directory to use (for run:remote only)") do |directory| 216 | @options[:in] = directory 217 | end 218 | 219 | opts.on("--max-remote-slaves=MAX", "Number of workers to run (for run:remote only)") do |max| 220 | @options[:max_remote_slaves] = max.to_i 221 | end 222 | 223 | opts.on("--strict", "Fail if there are any undefined steps") do 224 | @options[:strict] = true 225 | end 226 | 227 | opts.on("--create-mysql-db", "Create MySQL for each slave") do 228 | @options[:create_mysql_db] = true 229 | end 230 | 231 | opts.on("--simple-progress", "Use a simpler progress bar that may display better in logs") do 232 | @options[:simple_progress] = true 233 | end 234 | 235 | opts.on("--queue-host=QUEUE_HOST", "Use another server to host the main redis queue") do |queue_host| 236 | @options[:queue_host] = queue_host 237 | end 238 | 239 | opts.on("--queue-prefix=QUEUE_PREFIX", "Provide a prefix to uniquely identify this testjour run (Default is 'default')") do |queue_prefix| 240 | @options[:queue_prefix] = queue_prefix 241 | end 242 | 243 | opts.on("--queue-timeout=QUEUE_TIMEOUT", "How long to wait for results to appear in the queue before giving up") do |queue_timeout| 244 | @options[:queue_timeout] = queue_timeout 245 | end 246 | 247 | opts.on("--rsync-uri=RSYNC_URI", "Use another location to host the codebase for slave rsync (master will rsync to this URI first)") do |rsync_uri| 248 | @options[:rsync_uri] = rsync_uri 249 | end 250 | 251 | opts.on("--max-local-slaves=MAX", "Maximum number of local slaves") do |max| 252 | @options[:max_local_slaves] = max.to_i 253 | end 254 | end 255 | end 256 | end 257 | 258 | end -------------------------------------------------------------------------------- /lib/testjour/core_extensions/retryable.rb: -------------------------------------------------------------------------------- 1 | class Object 2 | def retryable(options = {}, &block) 3 | opts = { :tries => 1, :on => Exception }.merge(options) 4 | 5 | retry_exception, retries = opts[:on], opts[:tries] 6 | 7 | begin 8 | return yield 9 | rescue retry_exception 10 | retry if (retries -= 1) > 0 11 | end 12 | 13 | yield 14 | end 15 | end -------------------------------------------------------------------------------- /lib/testjour/core_extensions/wait_for_service.rb: -------------------------------------------------------------------------------- 1 | require 'timeout' 2 | require 'socket' 3 | 4 | TCPSocket.class_eval do 5 | 6 | def self.wait_for_no_service(options) 7 | socket = nil 8 | Timeout::timeout(options[:timeout] || 20) do 9 | loop do 10 | begin 11 | socket = TCPSocket.new(options[:host], options[:port]) 12 | socket.close unless socket.nil? 13 | rescue Errno::ECONNREFUSED 14 | return 15 | end 16 | end 17 | end 18 | end 19 | 20 | def self.wait_for_service(options) 21 | socket = nil 22 | Timeout::timeout(options[:timeout] || 20) do 23 | loop do 24 | begin 25 | socket = TCPSocket.new(options[:host], options[:port]) 26 | return 27 | rescue Errno::ECONNREFUSED 28 | sleep 1.5 29 | end 30 | end 31 | end 32 | ensure 33 | socket.close unless socket.nil? 34 | end 35 | 36 | end -------------------------------------------------------------------------------- /lib/testjour/cucumber_extensions/feature_file_finder.rb: -------------------------------------------------------------------------------- 1 | module Testjour 2 | 3 | class FeatureFileFinder 4 | attr_reader :feature_files 5 | 6 | def initialize 7 | @feature_files = [] 8 | end 9 | 10 | def before_feature(feature) 11 | @current_feature = feature 12 | end 13 | 14 | def before_step(step) 15 | @feature_files << @current_feature.file 16 | @feature_files.uniq! 17 | end 18 | end 19 | 20 | end -------------------------------------------------------------------------------- /lib/testjour/cucumber_extensions/http_formatter.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'English' 3 | require 'cucumber/formatter/console' 4 | require 'testjour/result' 5 | 6 | module Testjour 7 | 8 | class HttpFormatter 9 | 10 | def initialize(configuration) 11 | @configuration = configuration 12 | end 13 | 14 | def before_multiline_arg(multiline_arg) 15 | @multiline_arg = true 16 | end 17 | 18 | def after_multiline_arg(multiline_arg) 19 | @multiline_arg = false 20 | end 21 | 22 | def before_step(step) 23 | @step_start = Time.now 24 | end 25 | 26 | def after_step_result(keyword, step_match, multiline_arg, status, exception, source_indent, background) 27 | progress(Time.now - @step_start, status, step_match, exception) 28 | end 29 | 30 | def before_outline_table(outline_table) 31 | @outline_table = outline_table 32 | end 33 | 34 | def after_outline_table(outline_table) 35 | @outline_table = nil 36 | end 37 | 38 | def table_cell_value(value, status) 39 | return unless @outline_table 40 | progress(0.0, status) unless table_header_cell?(status) 41 | end 42 | 43 | private 44 | 45 | def progress(time, status, step_match = nil, exception = nil) 46 | queue = RedisQueue.new(@configuration.queue_host, 47 | @configuration.queue_prefix, 48 | @configuration.queue_timeout) 49 | queue.push(:results, Result.new(time, status, step_match, exception)) 50 | end 51 | 52 | def table_header_cell?(status) 53 | status == :skipped_param 54 | end 55 | 56 | end 57 | 58 | end -------------------------------------------------------------------------------- /lib/testjour/cucumber_extensions/step_counter.rb: -------------------------------------------------------------------------------- 1 | module Testjour 2 | 3 | class StepCounter 4 | attr_reader :backtrace_lines 5 | 6 | def initialize 7 | @backtrace_lines = [] 8 | end 9 | 10 | def after_step_result(keyword, step_match, multiline_arg, status, exception, source_indent, background) 11 | @backtrace_lines << step_match.backtrace_line 12 | end 13 | 14 | def before_outline_table(outline_table) 15 | @outline_table = outline_table 16 | end 17 | 18 | def after_outline_table(outline_table) 19 | @outline_table = nil 20 | end 21 | 22 | def table_cell_value(value, status) 23 | return unless @outline_table 24 | @backtrace_lines << "Table cell value: #{value}" unless table_header_cell?(status) 25 | end 26 | 27 | def count 28 | @backtrace_lines.size 29 | end 30 | 31 | private 32 | 33 | def table_header_cell?(status) 34 | status == :skipped_param 35 | end 36 | 37 | end 38 | 39 | end -------------------------------------------------------------------------------- /lib/testjour/mysql.rb: -------------------------------------------------------------------------------- 1 | module Testjour 2 | 3 | # Stolen from deep-test 4 | 5 | class MysqlDatabaseSetup 6 | 7 | def initialize(runner_database_name = nil) 8 | @runner_database_name = runner_database_name 9 | end 10 | 11 | def create_database 12 | run "/usr/local/mysql/bin/mysqladmin -u root create #{runner_database_name}" 13 | end 14 | 15 | def drop_database 16 | run "/usr/local/mysql/bin/mysqladmin -u root -f drop #{runner_database_name}" 17 | end 18 | 19 | def load_schema 20 | schema_file = File.expand_path("./db/development_structure.sql") 21 | run "/usr/local/mysql/bin/mysql -u root #{runner_database_name} < #{schema_file}" 22 | end 23 | 24 | def runner_database_name 25 | @runner_database_name ||= "testjour_runner_#{rand(1_000)}_#{Testjour.effective_pid}" 26 | end 27 | 28 | protected 29 | 30 | def run(cmd) 31 | Testjour.logger.info "Executing: #{cmd}" 32 | status, stdout, stderr = systemu(cmd) 33 | exit_code = status.exitstatus 34 | 35 | unless exit_code.zero? 36 | Testjour.logger.info "Failed: #{exit_code}" 37 | Testjour.logger.info stderr 38 | Testjour.logger.info stdout 39 | end 40 | end 41 | 42 | end 43 | 44 | end 45 | 46 | -------------------------------------------------------------------------------- /lib/testjour/progressbar.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Ruby/ProgressBar - a text progress bar library 3 | # 4 | # Copyright (C) 2001 Satoru Takabayashi 5 | # All rights reserved. 6 | # This is free software with ABSOLUTELY NO WARRANTY. 7 | # 8 | # You can redistribute it and/or modify it under the terms 9 | # of Ruby's licence. 10 | # 11 | 12 | class ProgressBar 13 | VERSION = "0.3" 14 | 15 | attr_accessor :colorer 16 | attr_writer :title 17 | 18 | def initialize (title, total, simple, out = STDERR) 19 | @title = title 20 | @total = total 21 | @simple = simple 22 | @out = out 23 | @current = 0 24 | @previous = 0 25 | @is_finished = false 26 | @start_time = Time.now 27 | show_progress 28 | end 29 | 30 | def inspect 31 | "(ProgressBar: #{@current}/#{@total})" 32 | end 33 | 34 | def format_time (t) 35 | t = t.to_i 36 | sec = t % 60 37 | min = (t / 60) % 60 38 | hour = t / 3600 39 | sprintf("%02d:%02d:%02d", hour, min, sec); 40 | end 41 | 42 | # ETA stands for Estimated Time of Arrival. 43 | def eta 44 | if @current == 0 45 | "ETA: --:--:--" 46 | else 47 | elapsed = Time.now - @start_time 48 | eta = elapsed * @total / @current - elapsed; 49 | sprintf("ETA: %s", format_time(eta)) 50 | end 51 | end 52 | 53 | def elapsed 54 | elapsed = Time.now - @start_time 55 | sprintf("Time: %s", format_time(elapsed)) 56 | end 57 | 58 | def time 59 | if @is_finished then elapsed else eta end 60 | end 61 | 62 | def eol 63 | if (@simple || @is_finished) then "\n" else "\r" end 64 | end 65 | 66 | def bar(percentage) 67 | @bar = "=" * 41 68 | len = percentage * (@bar.length + 1) / 100 69 | sprintf("[%.*s%s%*s]", len, @bar, ">", [@bar.size - len, 0].max, "") 70 | end 71 | 72 | def show (percentage) 73 | output = sprintf("%-25s %3d%% %s %s%s", 74 | @title[0,25], 75 | percentage, 76 | bar(percentage), 77 | time, 78 | eol 79 | ) 80 | 81 | unless @colorer.nil? 82 | output = colorer.call(output) 83 | end 84 | 85 | @out.print(output) 86 | end 87 | 88 | def show_progress 89 | if @total.zero? 90 | cur_percentage = 100 91 | prev_percentage = 0 92 | else 93 | cur_percentage = (@current * 100 / @total).to_i 94 | prev_percentage = (@previous * 100 / @total).to_i 95 | end 96 | 97 | if cur_percentage > prev_percentage || @is_finished 98 | show(cur_percentage) 99 | end 100 | end 101 | 102 | public 103 | def finish 104 | @current = @total 105 | @is_finished = true 106 | show_progress 107 | end 108 | 109 | def set (count) 110 | if count < 0 || count > @total 111 | raise "invalid count: #{count} (total: #{total})" 112 | end 113 | @current = count 114 | show_progress 115 | @previous = @current 116 | end 117 | 118 | def inc (step = 1) 119 | @current += step 120 | @current = @total if @current > @total 121 | show_progress 122 | @previous = @current 123 | end 124 | end 125 | 126 | -------------------------------------------------------------------------------- /lib/testjour/redis_queue.rb: -------------------------------------------------------------------------------- 1 | require "testjour/core_extensions/wait_for_service" 2 | require "redis" 3 | 4 | module Testjour 5 | 6 | class RedisQueue 7 | 8 | def initialize(redis_host, queue_namespace, queue_timeout) 9 | @redis = Redis.new(:db => 11, :host => redis_host) 10 | @queue_namespace = queue_namespace 11 | @queue_timeout = queue_timeout 12 | end 13 | 14 | attr_reader :redis 15 | 16 | def push(queue_name, data) 17 | redis.lpush("testjour:#{queue_namespace}:#{queue_name}", Marshal.dump(data)) 18 | end 19 | 20 | def pop(queue_name) 21 | result = redis.rpop("testjour:#{queue_namespace}:#{queue_name}") 22 | result ? Marshal.load(result) : nil 23 | end 24 | 25 | def blocking_pop(queue_name) 26 | Timeout.timeout(@queue_timeout) do 27 | result = nil 28 | 29 | while result.nil? 30 | result = pop(queue_name) 31 | sleep 0.1 unless result 32 | end 33 | 34 | result 35 | end 36 | end 37 | 38 | def reset_all 39 | redis.del "testjour:#{queue_namespace}:feature_files" 40 | redis.del "testjour:#{queue_namespace}:results" 41 | end 42 | 43 | protected 44 | 45 | def queue_namespace 46 | @queue_namespace 47 | end 48 | 49 | end 50 | 51 | end -------------------------------------------------------------------------------- /lib/testjour/result.rb: -------------------------------------------------------------------------------- 1 | require "English" 2 | require "socket" 3 | 4 | module Testjour 5 | 6 | class Result 7 | attr_reader :time 8 | attr_reader :status 9 | attr_reader :message 10 | attr_reader :backtrace 11 | attr_reader :backtrace_line 12 | 13 | CHARS = { 14 | :undefined => 'U', 15 | :passed => '.', 16 | :failed => 'F', 17 | :pending => 'P', 18 | :skipped => 'S' 19 | } 20 | 21 | def initialize(time, status, step_match = nil, exception = nil) 22 | @time = time 23 | @status = status 24 | 25 | if step_match 26 | @backtrace_line = step_match.backtrace_line 27 | end 28 | 29 | if exception 30 | @message = exception.message.to_s 31 | @backtrace = exception.backtrace.join("\n") 32 | end 33 | 34 | @pid = Testjour.effective_pid 35 | @hostname = Testjour.socket_hostname 36 | end 37 | 38 | def server_id 39 | "#{@hostname} [#{@pid}]" 40 | end 41 | 42 | def char 43 | CHARS[@status] 44 | end 45 | 46 | def failed? 47 | status == :failed 48 | end 49 | 50 | def undefined? 51 | status == :undefined 52 | end 53 | 54 | end 55 | 56 | end -------------------------------------------------------------------------------- /lib/testjour/result_set.rb: -------------------------------------------------------------------------------- 1 | module Testjour 2 | class ResultSet 3 | 4 | def initialize(step_counter) 5 | @step_counter = step_counter 6 | 7 | @counts = Hash.new { |h, result| h[result] = 0 } 8 | @results = Hash.new { |h, server_id| h[server_id] = [] } 9 | 10 | @backtrace_lines_seen = [] 11 | end 12 | 13 | def record(result) 14 | @results[result.server_id] << result 15 | @counts[result.status] += 1 16 | @backtrace_lines_seen << result.backtrace_line 17 | end 18 | 19 | def count(result) 20 | @counts[result] 21 | end 22 | 23 | def each_server_stat(&block) 24 | @results.sort_by { |server_id, times| server_id }.each do |server_id, results| 25 | total_time = total_time(results) 26 | steps_per_second = results.size.to_f / total_time.to_f 27 | 28 | block.call(server_id, results.size, total_time, steps_per_second) 29 | end 30 | end 31 | 32 | def errors 33 | @results.values.flatten.select { |r| r.failed? } 34 | end 35 | 36 | def undefineds 37 | @results.values.flatten.select { |r| r.undefined? } 38 | end 39 | 40 | def slaves 41 | @results.keys.size 42 | end 43 | 44 | def missing_backtrace_lines 45 | @step_counter.backtrace_lines - @backtrace_lines_seen 46 | end 47 | 48 | protected 49 | 50 | def total_time(results) 51 | results.inject(0) { |memo, r| r.time + memo } 52 | end 53 | 54 | end 55 | end -------------------------------------------------------------------------------- /lib/testjour/results_formatter.rb: -------------------------------------------------------------------------------- 1 | require "testjour/progressbar" 2 | require "testjour/colorer" 3 | require "testjour/result_set" 4 | 5 | module Testjour 6 | class ResultsFormatter 7 | 8 | def initialize(step_counter, options = {}) 9 | @options = options 10 | @progress_bar = ProgressBar.new("0 failures", step_counter.count, options[:simple_progress]) 11 | @result_set = ResultSet.new(step_counter) 12 | end 13 | 14 | def missing_backtrace_lines 15 | @result_set.missing_backtrace_lines 16 | end 17 | 18 | def result(result) 19 | @result_set.record(result) 20 | log_result(result) 21 | update_progress_bar 22 | end 23 | 24 | def update_progress_bar 25 | @progress_bar.colorer = colorer 26 | @progress_bar.title = title 27 | @progress_bar.inc 28 | end 29 | 30 | def log_result(result) 31 | case result.char 32 | when "F" 33 | erase_current_line 34 | print Testjour::Colorer.failed("F#{@result_set.errors.size}) ") 35 | puts Testjour::Colorer.failed(result.message) 36 | puts result.backtrace 37 | puts 38 | when "U" 39 | erase_current_line 40 | print Testjour::Colorer.undefined("U#{@result_set.undefineds.size}) ") 41 | puts Testjour::Colorer.undefined(result.backtrace_line) 42 | puts 43 | end 44 | end 45 | 46 | def colorer 47 | if failed? 48 | Testjour::Colorer.method(:failed).to_proc 49 | else 50 | Testjour::Colorer.method(:passed).to_proc 51 | end 52 | end 53 | 54 | def title 55 | "#{@result_set.slaves} slaves, #{@result_set.errors.size} failures" 56 | end 57 | 58 | def erase_current_line 59 | print "\e[K" 60 | end 61 | 62 | def print_summary 63 | print_summary_line(:passed) 64 | puts Colorer.failed("#{@result_set.errors.size} steps failed") unless @result_set.errors.empty? 65 | print_summary_line(:skipped) 66 | print_summary_line(:pending) 67 | print_summary_line(:undefined) 68 | end 69 | 70 | def print_stats 71 | @result_set.each_server_stat do |server_id, steps, total_time, steps_per_second| 72 | puts "#{server_id} ran #{steps} steps in %.2fs (%.2f steps/s)" % [total_time, steps_per_second] 73 | end 74 | end 75 | 76 | def print_summary_line(step_type) 77 | count = @result_set.count(step_type) 78 | return if count.zero? 79 | puts Colorer.send(step_type, "#{count} steps #{step_type}") 80 | end 81 | 82 | def finish 83 | @progress_bar.finish 84 | puts 85 | puts 86 | print_summary 87 | puts 88 | print_stats 89 | puts 90 | end 91 | 92 | def failed? 93 | if @options[:strict] 94 | @result_set.errors.any? || @result_set.undefineds.any? 95 | else 96 | @result_set.errors.any? 97 | end 98 | end 99 | 100 | end 101 | end -------------------------------------------------------------------------------- /lib/testjour/rsync.rb: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require "systemu" 3 | require "testjour/core_extensions/retryable" 4 | 5 | module Testjour 6 | 7 | class RsyncFailed < StandardError 8 | end 9 | 10 | class Rsync 11 | 12 | def self.copy_to_current_directory_from(source_uri) 13 | new(source_uri, File.expand_path(".")).copy_with_retry 14 | end 15 | 16 | def self.copy_from_current_directory_to(destination_uri) 17 | new(File.expand_path("."), destination_uri).copy_with_retry 18 | end 19 | 20 | def initialize(source_uri, destination_uri) 21 | @source_uri = source_uri 22 | @destination_uri = destination_uri 23 | end 24 | 25 | def copy_with_retry 26 | retryable :tries => 2, :on => RsyncFailed do 27 | Testjour.logger.info "Rsyncing: #{command}" 28 | copy 29 | 30 | if successful? 31 | Testjour.logger.debug("Rsync finished in %.2fs" % elapsed_time) 32 | else 33 | Testjour.logger.debug("Rsync failed in %.2fs" % elapsed_time) 34 | Testjour.logger.debug("Rsync stdout: #{@stdout}") 35 | Testjour.logger.debug("Rsync stderr: #{@stderr}") 36 | raise RsyncFailed.new 37 | end 38 | end 39 | end 40 | 41 | def copy 42 | @start_time = Time.now 43 | 44 | status, @stdout, @stderr = systemu(command) 45 | @exit_code = status.exitstatus 46 | end 47 | 48 | def elapsed_time 49 | Time.now - @start_time 50 | end 51 | 52 | def successful? 53 | @exit_code.zero? 54 | end 55 | 56 | def command 57 | "rsync -az -e \"ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no\" --delete --exclude=.git --exclude=*.log --exclude=*.pid #{@source_uri}/ #{@destination_uri}" 58 | end 59 | end 60 | end -------------------------------------------------------------------------------- /spec/fixtures/config/cucumber.yml: -------------------------------------------------------------------------------- 1 | default: . 2 | failing: failing.feature -------------------------------------------------------------------------------- /spec/fixtures/failing.feature: -------------------------------------------------------------------------------- 1 | Feature: Failing 2 | 3 | Scenario: Failing 4 | Given failing -------------------------------------------------------------------------------- /spec/fixtures/inline_table.feature: -------------------------------------------------------------------------------- 1 | Feature: Inline table 2 | 3 | Scenario: Inline table 4 | 5 | Given the values: 6 | | value | 7 | | foo | 8 | | bar | 9 | | baz | 10 | Then passing -------------------------------------------------------------------------------- /spec/fixtures/mysql_db.feature: -------------------------------------------------------------------------------- 1 | Feature: MySQL DB creation 2 | 3 | Scenario: Environment variable should be set 4 | Then ENV['TESTJOUR_DB'] should be set -------------------------------------------------------------------------------- /spec/fixtures/passing.feature: -------------------------------------------------------------------------------- 1 | Feature: Passing 2 | 3 | Scenario: Passing 4 | Given passing -------------------------------------------------------------------------------- /spec/fixtures/sleep1.feature: -------------------------------------------------------------------------------- 1 | Feature: Sleep1 2 | 3 | Scenario: sleeping 4 | Given sleep -------------------------------------------------------------------------------- /spec/fixtures/sleep2.feature: -------------------------------------------------------------------------------- 1 | Feature: Sleep2 2 | 3 | Scenario: sleeping 4 | Given sleep 5 | And failing -------------------------------------------------------------------------------- /spec/fixtures/step_definitions/sample_steps.rb: -------------------------------------------------------------------------------- 1 | Given /^passing$/ do 2 | end 3 | 4 | Given /^failing$/ do 5 | raise "FAIL" 6 | end 7 | 8 | Given /^undefined$/ do 9 | pending 10 | end 11 | 12 | Given /^sleep$/ do 13 | sleep 1 14 | end 15 | 16 | Given /^table value "([^\"]*)"$/ do |value| 17 | end 18 | 19 | Then /^the result "([^\"]*)"$/ do |result| 20 | end 21 | 22 | Given /^the values:$/ do |table| 23 | # table is a Cucumber::Ast::Table 24 | end 25 | 26 | Then /^ENV\['TESTJOUR_DB'\] should be set$/ do 27 | ENV["TESTJOUR_DB"].should_not be_nil 28 | end 29 | -------------------------------------------------------------------------------- /spec/fixtures/support/env.rb: -------------------------------------------------------------------------------- 1 | require 'spec/expectations' -------------------------------------------------------------------------------- /spec/fixtures/table.feature: -------------------------------------------------------------------------------- 1 | Feature: Table 2 | 3 | Scenario Outline: Table scenarios 4 | 5 | Given table value "" 6 | 7 | Examples: 8 | | value | 9 | | foo | 10 | | bar | 11 | | baz | 12 | 13 | Scenario Outline: Table scenarios (multiple columns) 14 | 15 | Given table value "" 16 | Then the result "" 17 | 18 | Examples: 19 | | value | result | 20 | | foo | foo | 21 | | bar | bar | 22 | | baz | baz | -------------------------------------------------------------------------------- /spec/fixtures/undefined.feature: -------------------------------------------------------------------------------- 1 | Feature: Undefined 2 | 3 | Scenario: Undefined 4 | Given undefined -------------------------------------------------------------------------------- /vendor/authprogs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | # 3 | # authprogs, Copyright 2003, Brian Hatch. 4 | # 5 | # Released under the GPL. See the file 6 | # COPYING for more information. 7 | # 8 | # This program is intended to be called from an authorized_keys 9 | # file, i.e. triggered by use of specific SSH identities. 10 | # 11 | # It will check the original command (saved in $SSH_ORIGINAL_COMMAND 12 | # environment variable by sshd) and see if it is on the 'approved' 13 | # list. 14 | # 15 | # Allowed commands are stored in ~/.ssh/authprogs.conf 16 | # The format of this file is as follows: 17 | # 18 | # [ ALL ] 19 | # command0 arg arg arg 20 | # 21 | # [ ip.ad.dr.01 ip.ad.dr.02 ] 22 | # command1 arg arg arg 23 | # 24 | # [ ip.ad.dr.03 ] 25 | # command2 arg arg arg 26 | # command3 arg arg 27 | # 28 | # There is no regexp or shell metacharacter support. If 29 | # you want to allow 'ls /dir1' and 'ls /dir2' you need to 30 | # explicitly create those two rules. Putting "ls /dir[12]" 31 | # in the authprogs.conf file will *not* work. 32 | # 33 | # NOTE: Some versions of Bash do not export the (already exported) 34 | # SSH_CLIENT environment variable. You can get around this by adding 35 | # export SSH_CLIENT=${SSH_CLIENT} 36 | # or something similar in your ~/.bashrc, /etc/profile, etc. 37 | # http://mail.gnu.org/archive/html/bug-bash/2002-01/msg00096.html 38 | # 39 | # Changes: 40 | # 2003-10-27: fixed exit status, noted by Brad Fritz. 41 | # 2003-10-27: added blank SSH_ORIGINAL_COMMAND debug log message 42 | 43 | 44 | use strict; 45 | use subs qw(bail log); 46 | use POSIX qw(strftime); 47 | use File::Basename; 48 | use FileHandle; 49 | 50 | # DEBUGLEVEL values: 51 | # 0 - log nothing 52 | # 1 - log errors 53 | # 2 - log failed commands 54 | # 3 - log successful commands 55 | # 4 - log debugging info 56 | my $DEBUGLEVEL = 4; 57 | 58 | # Salt to taste. /dev/null might be a likely 59 | # place if you don't want any logging. 60 | my $LOGFILE = "$ENV{HOME}/.ssh/authprogs.log"; 61 | 62 | # Configfile - location of the host/commands allowed. 63 | my $CONFIGFILE = "$ENV{HOME}/.ssh/authprogs.conf"; 64 | 65 | # $CLIENT_COMMAND is the string the client sends us. 66 | # 67 | # Unfortunately, the actual spacing is lost. IE 68 | # ("some string" and "some" "string" are not differentiable.) 69 | my ($CLIENT_COMMAND) = $ENV{SSH_ORIGINAL_COMMAND}; 70 | 71 | # strip quotes - we'll explain later on. 72 | $CLIENT_COMMAND =~ s/['"]//g; 73 | 74 | # Set CLIENT_IP to just the ip addr, sans port numbers. 75 | my ($CLIENT_IP) = $ENV{SSH_CLIENT} =~ /^(\S+)/; 76 | 77 | 78 | # Open log in append mode. Note that the use of '>>' 79 | # means you better be doing it somewhere that is only 80 | # writeable by you, lest you have a symlink/etc attack. 81 | # Since we default to ~/.ssh, this should not be a problem. 82 | 83 | if ( $DEBUGLEVEL ) { 84 | open LOG, ">>$LOGFILE" or bail "Can't open $LOGFILE\n"; 85 | LOG->autoflush(1); 86 | } 87 | 88 | if ( ! $ENV{SSH_ORIGINAL_COMMAND} ) { 89 | log(4, "SSH_ORIGINAL_COMMAND not set - either the client ". 90 | "didn't send one, or your shell is removing it from ". 91 | "the environment."); 92 | } 93 | 94 | 95 | # Ok, let's scan the authprogs.conf file 96 | open CONFIGFILE, $CONFIGFILE or bail "Config '$CONFIGFILE' not readable!"; 97 | 98 | # Note: we do not verify that the configuration file is owned by 99 | # this user. Some might argue that we should. (A quick stat 100 | # compared to $< would do the trick.) However some installations 101 | # relax the requirement that the .ssh dir is owned by the user 102 | # s.t. it can be owned by root and only modifyable in that way to 103 | # keep even the user from making changes. We should trust the 104 | # administrator's SSH setup (StrictModes) and not bother checking 105 | # the ownership/perms of configfile. 106 | 107 | my $VALID_COMMAND=0; # flag: is this command appopriate for this host? 108 | 109 | READ_CONF: while () { 110 | chomp; 111 | 112 | # Skip blanks and comments. 113 | if ( /^\s*#/ ) { next } 114 | if ( /^\s*$/ ) { next } 115 | 116 | # Are we the beginning of a new set of 117 | # clients? 118 | if ( /^\[/ ) { 119 | 120 | # Snag the IP address(es) in question. 121 | 122 | /^ \[ ( [^\]]+ ) \] /x; 123 | $_ = $1; 124 | 125 | if ( /^\s*ALL\s*$/ ) { # If wildcard selected 126 | $_ = $CLIENT_IP; 127 | } 128 | 129 | my @clients = split; 130 | 131 | log 4, "Found new clients line for @clients\n"; 132 | 133 | # This would be a great place to add 134 | # ip <=> name mapping so we can have it work 135 | # on hostnames rather than just IP addresses. 136 | # If so, better make sure that forward and 137 | # reverse lookups match -- an attacker in 138 | # control of his network can easily set a PTR 139 | # record, so don't rely on it alone. 140 | 141 | unless ( grep /^$CLIENT_IP$/, @clients ) { 142 | 143 | log 4, "Client IP does not match this list.\n"; 144 | 145 | $VALID_COMMAND=0; 146 | 147 | # Nope, not relevant - go to next 148 | # host definition list. 149 | while () { 150 | last if /^\[/; 151 | } 152 | 153 | # Never found another host definition. Bail. 154 | redo READ_CONF; 155 | } 156 | $VALID_COMMAND=1; 157 | log 4, "Client matches this list.\n"; 158 | 159 | next; 160 | } 161 | 162 | # We must be a potential command 163 | if ( ! $VALID_COMMAND ) { 164 | bail "Parsing error at line $. of $CONFIGFILE\n"; 165 | } 166 | 167 | 168 | my $allowed_command = $_; 169 | $allowed_command =~ s/\s+$//; # strip trailing slashes 170 | $allowed_command =~ s/^\s+//; # strip leading slashes 171 | 172 | # We've now got the command as we'd run it through 'system'. 173 | # 174 | # Problem: SSH sticks the command in $SSH_ORIGINAL_COMMAND 175 | # but doesn't retain the argument breaks. 176 | # 177 | # Solution: Let's guess by stripping double and single quotes 178 | # from both the client and the config file. If those 179 | # versions match, we'll assume the client was right. 180 | 181 | my $allowed_command_sans_quotes = $allowed_command; 182 | $allowed_command_sans_quotes =~ s/["']//g; 183 | 184 | log 4, "Comparing allowed command and client's command:\n"; 185 | log 4, " Allowed: $allowed_command_sans_quotes\n"; 186 | log 4, " Client: $CLIENT_COMMAND\n"; 187 | 188 | if ( $allowed_command_sans_quotes eq $CLIENT_COMMAND ) { 189 | log 3, "Running [$allowed_command] from $ENV{SSH_CLIENT}\n"; 190 | 191 | # System is a bad thing to use on untrusted input. 192 | # But $allowed_command comes from the user on the SSH 193 | # server, from his authprogs.conf file. So we can trust 194 | # it as much as we trust him, since it's running as that 195 | # user. 196 | 197 | system $allowed_command; 198 | exit $? >> 8; 199 | } 200 | 201 | } 202 | 203 | # The remote end wants to run something they're not allowed to run. 204 | # Log it, and chastize them. 205 | 206 | log 2, "Denying request '$ENV{SSH_ORIGINAL_COMMAND}' from $ENV{SSH_CLIENT}\n"; 207 | print STDERR "You're not allowed to run '$ENV{SSH_ORIGINAL_COMMAND}'\n"; 208 | exit 1; 209 | 210 | 211 | sub bail { 212 | # print log message (w/ guarenteed newline) 213 | if (@_) { 214 | $_ = join '', @_; 215 | chomp $_; 216 | log 1, "$_\n"; 217 | } 218 | 219 | close LOG if $DEBUGLEVEL; 220 | exit 1 221 | } 222 | 223 | sub log { 224 | my ($level,@list) = @_; 225 | return if $DEBUGLEVEL < $level; 226 | 227 | my $timestamp = strftime "%Y/%m/%d %H:%M:%S", localtime; 228 | my $progname = basename $0; 229 | grep { s/^/$timestamp $progname\[$$\]: / } @list; 230 | print LOG @list; 231 | } 232 | --------------------------------------------------------------------------------