diff --git a/CHANGELOG b/CHANGELOG index 2b41370b..565098de 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,11 @@ +*SVN* + +* Added -e switch to explain specific task. Added -X to extend -x. Made -h much briefer. Added -T to list known tasks. [Jamis Buck] + +* Added namespaces for tasks [Jamis Buck] + +* Merged the Configuration and Actor classes, performed various other massive refactorings of the code [Jamis Buck] + *1.4.1* (February 24, 2007) * Use the no-auth-cache option with subversion so that username/password tokens do not get cached by capistrano usage [jonathan] diff --git a/bin/cap b/bin/cap index 4288dade..72e64b47 100755 --- a/bin/cap +++ b/bin/cap @@ -1,11 +1,4 @@ #!/usr/bin/env ruby -begin - require 'rubygems' -rescue LoadError - # no rubygems to load, so we fail silently -end - require 'capistrano/cli' - -Capistrano::CLI.execute! +Capistrano::CLI.execute diff --git a/capistrano.gemspec b/capistrano.gemspec index 66dfc2a0..b643ed81 100644 --- a/capistrano.gemspec +++ b/capistrano.gemspec @@ -7,8 +7,7 @@ Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.summary = <<-DESC.strip.gsub(/\n\s+/, " ") Capistrano is a framework and utility for executing commands in parallel - on multiple remote machines, via SSH. The primary goal is to simplify and - automate the deployment of web applications. + on multiple remote machines, via SSH. DESC s.files = Dir.glob("{bin,lib,examples,test}/**/*") + %w(README MIT-LICENSE CHANGELOG THANKS) @@ -18,10 +17,9 @@ Gem::Specification.new do |s| s.bindir = "bin" s.executables << "cap" - s.add_dependency 'rake', ">= 0.7.0" - s.add_dependency 'net-ssh', ">= #{Capistrano::Version::SSH_REQUIRED.join(".")}" s.add_dependency 'net-sftp', ">= #{Capistrano::Version::SFTP_REQUIRED.join(".")}" + s.add_dependency 'highline' s.author = "Jamis Buck" s.email = "jamis@37signals.com" diff --git a/lib/capistrano/cli.rb b/lib/capistrano/cli.rb index 1e84b254..d1fc64ab 100644 --- a/lib/capistrano/cli.rb +++ b/lib/capistrano/cli.rb @@ -1,83 +1,24 @@ -require 'optparse' -require 'capistrano' +require 'capistrano/cli/execute' +require 'capistrano/cli/help' +require 'capistrano/cli/options' +require 'capistrano/cli/ui' module Capistrano # The CLI class encapsulates the behavior of capistrano when it is invoked - # as a command-line utility. This allows other programs to embed ST and - # preserve it's command-line semantics. + # as a command-line utility. This allows other programs to embed Capistrano + # and preserve it's command-line semantics. class CLI - # Invoke capistrano using the ARGV array as the option parameters. This - # is what the command-line capistrano utility does. - def self.execute! - new.execute! - end - - # The following determines whether or not echo-suppression is available. - # This requires the termios library to be installed (which, unfortunately, - # is not available for Windows). - begin - require 'termios' - - # Enable or disable stdin echoing to the terminal. - def self.echo(enable) - term = Termios::getattr(STDIN) - - if enable - term.c_lflag |= (Termios::ECHO | Termios::ICANON) - else - term.c_lflag &= ~Termios::ECHO - end - - Termios::setattr(STDIN, Termios::TCSANOW, term) - end - rescue LoadError - def self.echo(enable) - end - end - - # execute the associated block with echo-suppression enabled. Note that - # if termios is not available, echo suppression will not be available - # either. - def self.with_echo - unless @warned_about_echo - puts "WARNING: Password will echo -- install the 'termios' gem to hide your password." if !defined?(Termios) && RUBY_PLATFORM !~ /mswin/ - @warned_about_echo = true - end - echo(false) - yield - ensure - echo(true) - end - - # Prompt for a password using echo suppression. - def self.password_prompt(prompt="Password: ") - sync = STDOUT.sync - begin - with_echo do - STDOUT.sync = true - print(prompt) - STDIN.gets.chomp - end - ensure - STDOUT.sync = sync - puts - end - end - # The array of (unparsed) command-line options attr_reader :args - # The hash of (parsed) command-line options - attr_reader :options - # Create a new CLI instance using the given array of command-line parameters # to initialize it. By default, +ARGV+ is used, but you can specify a - # different set of parameters (such as when embedded ST in a program): + # different set of parameters (such as when embedded cap in a program): # # require 'capistrano/cli' - # Capistrano::CLI.new(%w(-vvvv -r config/deploy -a update_code)).execute! + # Capistrano::CLI.parse(%w(-vvvv -r config/deploy update_code)).execute! # - # Note that you can also embed ST directly by creating a new Configuration + # Note that you can also embed cao directly by creating a new Configuration # instance and setting it up, but you'll often wind up duplicating logic # defined in the CLI class. The above snippet, redone using the Configuration # class directly, would look like: @@ -87,261 +28,18 @@ module Capistrano # config = Capistrano::Configuration.new # config.logger_level = Capistrano::Logger::TRACE # config.set(:password) { Capistrano::CLI.password_prompt } - # config.load "standard", "config/deploy" - # config.actor.update_code + # config.load "config/deploy" + # config.update_code # # There may be times that you want/need the additional control offered by # manipulating the Configuration directly, but generally interfacing with # the CLI class is recommended. - def initialize(args = ARGV) - @args = args - @options = { :recipes => [], :actions => [], :vars => {}, - :pre_vars => {}, :sysconf => default_sysconf, :dotfile => default_dotfile } - - OptionParser.new do |opts| - opts.banner = "Usage: #{$0} [options] [args]" - - opts.separator "" - opts.separator "Recipe Options -----------------------" - opts.separator "" - - opts.on("-a", "--action ACTION", - "An action to execute. Multiple actions may", - "be specified, and are loaded in the given order." - ) { |value| @options[:actions] << value } - - opts.on("-f", "--file FILE", - "A recipe file to load. Multiple recipes may", - "be specified, and are loaded in the given order." - ) { |value| @options[:recipes] << value } - - opts.on("-p", "--password [PASSWORD]", - "The password to use when connecting. If the switch", - "is given without a password, the password will be", - "prompted for immediately. (Default: prompt for password", - "the first time it is needed.)" - ) { |value| @options[:password] = value } - - opts.on("-r", "--recipe RECIPE", - "A recipe file to load. Multiple recipes may", - "be specified, and are loaded in the given order.", - "(This option is deprecated--please use -f instead)" - ) do |value| - warn "Deprecated -r/--recipe flag used. Please use -f instead" - @options[:recipes] << value - end - - opts.on("-s", "--set NAME=VALUE", - "Specify a variable and it's value to set. This", - "will be set after loading all recipe files." - ) do |pair| - name, value = pair.split(/=/, 2) - @options[:vars][name.to_sym] = value - end - - opts.on("-S", "--set-before NAME=VALUE", - "Specify a variable and it's value to set. This", - "will be set BEFORE loading all recipe files." - ) do |pair| - name, value = pair.split(/=/, 2) - @options[:pre_vars][name.to_sym] = value - end - - opts.on("-x", "--skip-config", - "Disables the loading of the default personal config", - "file. Specifying -C after this option will reenable", - "it. (Default: config file is loaded)" - ) { @options[:dotfile] = nil } - - opts.separator "" - opts.separator "Framework Integration Options --------" - opts.separator "" - - opts.on("-A", "--apply-to DIRECTORY", - "Create a minimal set of scripts and recipes to use", - "capistrano with the application at the given", - "directory. (Currently only works with Rails apps.)" - ) { |value| @options[:apply_to] = value } - - opts.separator "" - opts.separator "Miscellaneous Options ----------------" - opts.separator "" - - opts.on("-h", "--help", "Display this help message") do - puts opts - exit - end - - opts.on("-P", "--[no-]pretend", - "Run the task(s), but don't actually connect to or", - "execute anything on the servers. (For various reasons", - "this will not necessarily be an accurate depiction", - "of the work that will actually be performed.", - "Default: don't pretend.)" - ) { |value| @options[:pretend] = value } - - opts.on("-q", "--quiet", - "Make the output as quiet as possible (the default)" - ) { @options[:verbose] = 0 } - - opts.on("-v", "--verbose", - "Specify the verbosity of the output.", - "May be given multiple times. (Default: silent)" - ) { @options[:verbose] ||= 0; @options[:verbose] += 1 } - - opts.on("-V", "--version", - "Display the version info for this utility" - ) do - require 'capistrano/version' - puts "Capistrano v#{Capistrano::Version::STRING}" - exit - end - - opts.separator "" - opts.separator <<-DETAIL.split(/\n/) -You can use the --apply-to switch to generate a minimal set of capistrano -scripts and recipes for an application. Just specify the path to the application -as the argument to --apply-to, like this: - - cap --apply-to ~/projects/myapp - -You'll wind up with a sample deployment recipe in config/deploy.rb and some new -rake tasks in lib/tasks. - -(Currently, --apply-to only works with Rails applications.) -DETAIL -#' # vim syntax highlighting fix - - if args.empty? - puts opts - exit - else - opts.parse!(args) - end - end - - check_options! - - password_proc = Proc.new { self.class.password_prompt } - - if !@options.has_key?(:password) - @options[:password] = password_proc - elsif !@options[:password] - @options[:password] = password_proc.call - end + def initialize(args) + @args = args.dup end - # Beginning running Capistrano based on the configured options. - def execute! - if @options[:apply_to] - execute_apply_to! - else - execute_recipes! - end - end - - private - - # Load the recipes specified by the options, and execute the actions - # specified. - def execute_recipes! - config = Capistrano::Configuration.new - config.logger.level = options[:verbose] - config.set :password, options[:password] - config.set :pretend, options[:pretend] - - options[:pre_vars].each { |name, value| config.set(name, value) } - - # load the standard recipe definition - config.load "standard" - - # load systemwide config/recipe definition - config.load(@options[:sysconf]) if @options[:sysconf] && File.exist?(@options[:sysconf]) - - # load user config/recipe definition - config.load(@options[:dotfile]) if @options[:dotfile] && File.exist?(@options[:dotfile]) - - options[:recipes].each { |recipe| config.load(recipe) } - options[:vars].each { |name, value| config.set(name, value) } - - actor = config.actor - options[:actions].each { |action| actor.send action } - rescue Exception => error - handle_error(error) - end - - # Load the Rails generator and apply it to the specified directory. - def execute_apply_to! - require 'capistrano/generators/rails/loader' - Generators::RailsLoader.load! @options - end - - APPLY_TO_OPTIONS = [:apply_to] - RECIPE_OPTIONS = [:password] - DEFAULT_RECIPES = %w(Capfile capfile config/deploy.rb) - - # A sanity check to ensure that a valid operation is specified. - def check_options! - # if no verbosity has been specified, be verbose - @options[:verbose] = 3 if !@options.has_key?(:verbose) - - apply_to_given = !(@options.keys & APPLY_TO_OPTIONS).empty? - recipe_given = !(@options.keys & RECIPE_OPTIONS).empty? || - !@options[:recipes].empty? || - !@options[:actions].empty? - - if apply_to_given && recipe_given - abort "You cannot specify both recipe options and framework integration options." - elsif !apply_to_given - look_for_default_recipe_file! if @options[:recipes].empty? - look_for_raw_actions! - abort "You must specify at least one action" if @options[:actions].empty? - else - @options[:application] = args.shift - @options[:recipe_file] = args.shift - end - end - - def default_sysconf - File.join(sysconf_directory, "capistrano.conf") - end - - def default_dotfile - File.join(home_directory, ".caprc") - end - - def sysconf_directory - # I'm guessing at where Windows users would keep their conf file. - ENV["SystemRoot"] || '/etc' - end - - def home_directory - ENV["HOME"] || - (ENV["HOMEPATH"] && "#{ENV["HOMEDRIVE"]}#{ENV["HOMEPATH"]}") || - "/" - end - - def look_for_default_recipe_file! - DEFAULT_RECIPES.each do |file| - if File.exist?(file) - @options[:recipes] << file - break - end - end - end - - def look_for_raw_actions! - @options[:actions].concat(@args) - end - - def handle_error(error) - case error - when Net::SSH::AuthenticationFailed - abort "authentication failed for `#{error.message}'" - when Capistrano::Command::Error - abort(error.message) - else raise error - end - end + # Mix-in the actual behavior + include Execute, Options, UI + include Help # needs to be included last, because it overrides some methods end end diff --git a/lib/capistrano/cli/execute.rb b/lib/capistrano/cli/execute.rb new file mode 100644 index 00000000..46ad084c --- /dev/null +++ b/lib/capistrano/cli/execute.rb @@ -0,0 +1,73 @@ +require 'capistrano/configuration' + +module Capistrano + class CLI + module Execute + def self.included(base) #:nodoc: + base.extend(ClassMethods) + end + + module ClassMethods + # Invoke capistrano using the ARGV array as the option parameters. This + # is what the command-line capistrano utility does. + def execute + parse(ARGV).execute! + end + end + + # Using the options build when the command-line was parsed, instantiate + # a new Capistrano configuration, initialize it, and execute the + # requested actions. + def execute! + config = instantiate_configuration + config.logger.level = options[:verbose] + + set_pre_vars(config) + load_recipes(config) + + execute_requested_actions(config) + rescue Exception => error + handle_error(error) + end + + def execute_requested_actions(config) + Array(options[:vars]).each { |name, value| config.set(name, value) } + Array(options[:actions]).each { |action| config.find_and_execute_task(action) } + end + + def set_pre_vars(config) #:nodoc: + config.set :password, options[:password] + Array(options[:pre_vars]).each { |name, value| config.set(name, value) } + end + + def load_recipes(config) #:nodoc: + # load the standard recipe definition + config.load "standard" + + # load systemwide config/recipe definition + config.load(options[:sysconf]) if options[:sysconf] && File.file?(options[:sysconf]) + + # load user config/recipe definition + config.load(options[:dotfile]) if options[:dotfile] && File.file?(options[:dotfile]) + + Array(options[:recipes]).each { |recipe| config.load(recipe) } + end + + # Primarily useful for testing, but subclasses of CLI could conceivably + # override this method to return a Configuration subclass or replacement. + def instantiate_configuration #:nodoc: + Capistrano::Configuration.new + end + + def handle_error(error) #:nodoc: + case error + when Net::SSH::AuthenticationFailed + abort "authentication failed for `#{error.message}'" + when Capistrano::Error + abort(error.message) + else raise error + end + end + end + end +end \ No newline at end of file diff --git a/lib/capistrano/cli/help.rb b/lib/capistrano/cli/help.rb new file mode 100644 index 00000000..0dca3726 --- /dev/null +++ b/lib/capistrano/cli/help.rb @@ -0,0 +1,76 @@ +module Capistrano + class CLI + module Help + LINE_PADDING = 7 + MIN_MAX_LEN = 30 + HEADER_LEN = 60 + + def self.included(base) #:nodoc: + base.send :alias_method, :execute_requested_actions_without_help, :execute_requested_actions + base.send :alias_method, :execute_requested_actions, :execute_requested_actions_with_help + end + + def execute_requested_actions_with_help(config) + if options[:tasks] + task_list(config) + elsif options[:explain] + explain_task(config, options[:explain]) + else + execute_requested_actions_without_help(config) + end + end + + def task_list(config) #:nodoc: + tasks = config.task_list(:all) + + if tasks.empty? + warn "There are no tasks available. Please specify a recipe file to load." + else + tasks = tasks.sort_by { |task| task.fully_qualified_name } + + longest = tasks.map { |task| task.fully_qualified_name.length }.max + max_length = output_columns - longest - LINE_PADDING + max_length = MIN_MAX_LEN if max_length < MIN_MAX_LEN + + tasks.each do |task| + puts "cap %-#{longest}s # %s" % [task.fully_qualified_name, task.brief_description(max_length)] + end + + puts + puts "Extended help may be available for any of these tasks." + puts "Type `#{$0} -e taskname' to view it." + end + end + + def explain_task(config, name) #:nodoc: + task = config.find_task(name) + if task.nil? + warn "The task `#{name}' does not exist." + else + puts "-" * HEADER_LEN + puts "cap #{name}" + puts "-" * HEADER_LEN + + if task.description.empty? + puts "There is no description for this task." + else + task.description.each_line do |line| + lines = line.gsub(/(.{1,#{output_columns}})(?:\s+|\Z)/, "\\1\n").split(/\n/) + if lines.empty? + puts + else + puts lines + end + end + end + + puts + end + end + + def output_columns #:nodoc: + @output_columns ||= self.class.ui.output_cols > 80 ? 80 : self.class.ui.output_cols + end + end + end +end \ No newline at end of file diff --git a/lib/capistrano/cli/options.rb b/lib/capistrano/cli/options.rb new file mode 100644 index 00000000..ef226d88 --- /dev/null +++ b/lib/capistrano/cli/options.rb @@ -0,0 +1,161 @@ +require 'optparse' + +module Capistrano + class CLI + module Options + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + # Return a new CLI instance with the given arguments pre-parsed and + # ready for execution. + def parse(args) + cli = new(args) + cli.parse_options! + cli + end + end + + # The hash of (parsed) command-line options + attr_reader :options + + # Return an OptionParser instance that defines the acceptable command + # line switches for Capistrano, and what their corresponding behaviors + # are. + def option_parser #:nodoc: + @option_parser ||= OptionParser.new do |opts| + opts.banner = "Usage: #{$0} [options] action ..." + + opts.on("-e", "--explain TASK", + "Displays help (if available) for the task." + ) { |value| options[:explain] = value } + + opts.on("-f", "--file FILE", + "A recipe file to load. May be given more than once." + ) { |value| options[:recipes] << value } + + opts.on("-h", "--help", "Display this help message.") do + puts opts + exit + end + + opts.on("-p", "--password", + "Immediately prompt for the password." + ) { options[:password] = nil } + + opts.on("-q", "--quiet", + "Make the output as quiet as possible (default)" + ) { options[:verbose] = 0 } + + opts.on("-S", "--set-before NAME=VALUE", + "Set a variable before the recipes are loaded." + ) do |pair| + name, value = pair.split(/=/, 2) + options[:pre_vars][name.to_sym] = value + end + + opts.on("-s", "--set NAME=VALUE", + "Set a variable after the recipes are loaded." + ) do |pair| + name, value = pair.split(/=/, 2) + options[:vars][name.to_sym] = value + end + + opts.on("-T", "--tasks", + "List all tasks in the loaded recipe files." + ) { |value| options[:tasks] = true } + + opts.on("-V", "--version", + "Display the Capistrano version, and exit." + ) do + require 'capistrano/version' + puts "Capistrano v#{Capistrano::Version::STRING}" + exit + end + + opts.on("-v", "--verbose", + "Be more verbose. May be given more than once." + ) { options[:verbose] ||= 0; options[:verbose] += 1 } + + opts.on("-X", "--skip-system-config", + "Don't load the system config file (capistrano.conf)" + ) { options.delete(:sysconf) } + + opts.on("-x", "--skip-user-config", + "Don't load the user config file (.caprc)" + ) { options.delete(:dotfile) } + end + end + + # If the arguments to the command are empty, this will print the + # allowed options and exit. Otherwise, it will parse the command + # line and set up any default options. + def parse_options! #:nodoc: + @options = { :recipes => [], :actions => [], + :vars => {}, :pre_vars => {}, + :sysconf => default_sysconf, :dotfile => default_dotfile } + + if args.empty? + warn "Please specify at least one action to execute." + warn option_parser + exit + end + + option_parser.parse!(args) + + # if no verbosity has been specified, be verbose + options[:verbose] = 3 if !options.has_key?(:verbose) + + look_for_default_recipe_file! if options[:recipes].empty? + extract_environment_variables! + + options[:actions].concat(args) + + password = options.has_key?(:password) + options[:password] = Proc.new { self.class.password_prompt } + options[:password] = options[:password].call if password + end + + # Extracts name=value pairs from the remaining command-line arguments + # and assigns them as environment variables. + def extract_environment_variables! #:nodoc: + args.delete_if do |arg| + next unless arg.match(/^(\w+)=(.*)$/) + ENV[$1] = $2 + end + end + + # Looks for a default recipe file in the current directory. + def look_for_default_recipe_file! #:nodoc: + %w(Capfile capfile).each do |file| + if File.file?(file) + options[:recipes] << file + break + end + end + end + + def default_sysconf #:nodoc: + File.join(sysconf_directory, "capistrano.conf") + end + + def default_dotfile #:nodoc: + File.join(home_directory, ".caprc") + end + + def sysconf_directory #:nodoc: + # TODO if anyone cares, feel free to submit a patch that uses a more + # appropriate location for this file in Windows. + ENV["SystemRoot"] || '/etc' + end + + def home_directory #:nodoc: + ENV["HOME"] || + (ENV["HOMEPATH"] && "#{ENV["HOMEDRIVE"]}#{ENV["HOMEPATH"]}") || + "/" + end + + end + end +end \ No newline at end of file diff --git a/lib/capistrano/cli/ui.rb b/lib/capistrano/cli/ui.rb new file mode 100644 index 00000000..bcceb676 --- /dev/null +++ b/lib/capistrano/cli/ui.rb @@ -0,0 +1,24 @@ +require 'highline' + +module Capistrano + class CLI + module UI + def self.included(base) #:nodoc: + base.extend(ClassMethods) + end + + module ClassMethods + # Return the object that provides UI-specific methods, such as prompts + # and more. + def ui + @ui ||= HighLine.new + end + + # Prompt for a password using echo suppression. + def password_prompt(prompt="Password: ") + ui.ask(prompt) { |q| q.echo = false } + end + end + end + end +end \ No newline at end of file diff --git a/lib/capistrano/configuration.rb b/lib/capistrano/configuration.rb index ea487f4f..4b2f0cf6 100644 --- a/lib/capistrano/configuration.rb +++ b/lib/capistrano/configuration.rb @@ -1,5 +1,6 @@ +#require 'capistrano/extensions' require 'capistrano/logger' -require 'capistrano/extensions' +require 'capistrano/utils' require 'capistrano/configuration/connections' require 'capistrano/configuration/execution' diff --git a/lib/capistrano/configuration/actions/inspect.rb b/lib/capistrano/configuration/actions/inspect.rb index 12cf702a..40105d49 100644 --- a/lib/capistrano/configuration/actions/inspect.rb +++ b/lib/capistrano/configuration/actions/inspect.rb @@ -11,7 +11,7 @@ module Capistrano # one. Do note that this is quite expensive from a bandwidth # perspective, so use it with care. # - # The command is invoked via #invoke. + # The command is invoked via #invoke_command. # # Usage: # @@ -20,7 +20,7 @@ module Capistrano # stream "tail -f #{shared_path}/log/fastcgi.crash.log" # end def stream(command, options={}) - invoke(command, options) do |ch, stream, out| + invoke_command(command, options) do |ch, stream, out| puts out if stream == :out warn "[err :: #{ch[:host]}] #{out}" if stream == :err end @@ -28,10 +28,10 @@ module Capistrano # Executes the given command on the first server targetted by the # current task, collects it's stdout into a string, and returns the - # string. The command is invoked via #invoke. + # string. The command is invoked via #invoke_command. def capture(command, options={}) output = "" - invoke(command, options.merge(:once => true)) do |ch, stream, data| + invoke_command(command, options.merge(:once => true)) do |ch, stream, data| case stream when :out then output << data when :err then raise CaptureError, "error processing #{command.inspect}: #{data.inspect}" diff --git a/lib/capistrano/configuration/actions/invocation.rb b/lib/capistrano/configuration/actions/invocation.rb index 9c01f028..b62f3eba 100644 --- a/lib/capistrano/configuration/actions/invocation.rb +++ b/lib/capistrano/configuration/actions/invocation.rb @@ -21,7 +21,7 @@ module Capistrano # to determine what method to use to invoke the command. It defaults # to :run, but may be :sudo, or any other method that conforms to the # same interface as run and sudo. - def invoke(cmd, options={}, &block) + def invoke_command(cmd, options={}, &block) options = options.dup via = options.delete(:via) || :run send(via, cmd, options, &block) diff --git a/lib/capistrano/configuration/connections.rb b/lib/capistrano/configuration/connections.rb index 462a9ed0..34b31de1 100644 --- a/lib/capistrano/configuration/connections.rb +++ b/lib/capistrano/configuration/connections.rb @@ -45,16 +45,11 @@ module Capistrano # establish connections to servers defined via ServerDefinition objects. def connection_factory @connection_factory ||= begin - options = { :user => fetch(:user, nil), - :password => fetch(:password, nil), - :port => fetch(:port, nil), - :ssh_options => fetch(:ssh_options, nil) } - if exists?(:gateway) logger.debug "establishing connection to gateway `#{fetch(:gateway)}'" - Gateway.new(ServerDefinition.new(fetch(:gateway)), options.merge(:logger => logger)) + Gateway.new(ServerDefinition.new(fetch(:gateway)), self) else - DefaultConnectionFactory.new(options) + DefaultConnectionFactory.new(self) end end end diff --git a/lib/capistrano/configuration/execution.rb b/lib/capistrano/configuration/execution.rb index fdcb9794..e3cc1af6 100644 --- a/lib/capistrano/configuration/execution.rb +++ b/lib/capistrano/configuration/execution.rb @@ -1,3 +1,5 @@ +require 'capistrano/errors' + module Capistrano class Configuration module Execution @@ -70,15 +72,9 @@ module Capistrano # Executes the task with the given name, including the before and after # hooks. - def execute_task(name, namespace, fail_silently=false) - name = name.to_sym - unless task = namespace.tasks[name] - return if fail_silently - fqn = " in `#{namespace.fully_qualified_name}'" if namespace.parent - raise NoMethodError, "no such task `#{name}'#{fqn}" - end - - execute_task("before_#{name}", namespace, true) + def execute_task(task) + before = task.namespace.tasks[:"before_#{task.name}"] + execute_task(before) if before logger.debug "executing `#{task.fully_qualified_name}'" begin @@ -88,10 +84,19 @@ module Capistrano pop_task_call_frame end - execute_task("after_#{name}", namespace, true) + after = task.namespace.tasks[:"after_#{task.name}"] + execute_task(after) if after result end + # Attempts to locate the task at the given fully-qualified path, and + # execute it. If no such task exists, a Capistrano::NoSuchTaskError will + # be raised. + def find_and_execute_task(path) + task = find_task(path) or raise NoSuchTaskError, "the task `#{path}' does not exist" + execute_task(task) + end + protected def rollback! diff --git a/lib/capistrano/configuration/namespaces.rb b/lib/capistrano/configuration/namespaces.rb index 3f5225c1..46cf4edd 100644 --- a/lib/capistrano/configuration/namespaces.rb +++ b/lib/capistrano/configuration/namespaces.rb @@ -3,6 +3,8 @@ require 'capistrano/task_definition' module Capistrano class Configuration module Namespaces + DEFAULT_TASK = :default + def self.included(base) #:nodoc: base.send :alias_method, :initialize_without_namespaces, :initialize base.send :alias_method, :initialize, :initialize_with_namespaces @@ -91,36 +93,76 @@ module Capistrano end end + # Find the task with the given name, where name is the fully-qualified + # name of the task. This will search into the namespaces and return + # the referenced task, or nil if no such task can be found. If the name + # refers to a namespace, the task in that namespace named "default" + # will be returned instead, if one exists. + def find_task(name) + parts = name.to_s.split(/:/) + tail = parts.pop.to_sym + + ns = self + until parts.empty? + ns = ns.namespaces[parts.shift.to_sym] + return nil if ns.nil? + end + + if ns.namespaces.key?(tail) + ns = ns.namespaces[tail] + tail = DEFAULT_TASK + end + + ns.tasks[tail] + end + + # Returns the default task for this namespace. This will be +nil+ if + # the namespace is at the top-level, and will otherwise return the + # task named "default". If no such task exists, +nil+ will be returned. + def default_task + return nil if parent.nil? + return tasks[DEFAULT_TASK] + end + + # Returns the tasks in this namespace as an array of TaskDefinition + # objects. If a non-false parameter is given, all tasks in all + # namespaces under this namespace will be returned as well. + def task_list(all=false) + list = tasks.values + namespaces.each { |name,space| list.concat(space.task_list(:all)) } if all + list + end + private def all_methods public_methods.concat(protected_methods).concat(private_methods) end - class Namespace - def initialize(name, parent) - @parent = parent - @name = name - end - - def role(*args) - raise NotImplementedError, "roles cannot be defined in a namespace" - end - - def respond_to?(sym) - super || parent.respond_to?(sym) - end - - def method_missing(sym, *args, &block) - if parent.respond_to?(sym) - parent.send(sym, *args, &block) - else - super + class Namespace + def initialize(name, parent) + @parent = parent + @name = name end - end - include Capistrano::Configuration::Namespaces - end + def role(*args) + raise NotImplementedError, "roles cannot be defined in a namespace" + end + + def respond_to?(sym) + super || parent.respond_to?(sym) + end + + def method_missing(sym, *args, &block) + if parent.respond_to?(sym) + parent.send(sym, *args, &block) + else + super + end + end + + include Capistrano::Configuration::Namespaces + end end end end \ No newline at end of file diff --git a/lib/capistrano/configuration/variables.rb b/lib/capistrano/configuration/variables.rb index f7ce03d6..79aad638 100644 --- a/lib/capistrano/configuration/variables.rb +++ b/lib/capistrano/configuration/variables.rb @@ -91,6 +91,7 @@ module Capistrano @original_procs = {} set :ssh_options, {} + set :logger, logger end private :initialize_with_variables diff --git a/lib/capistrano/errors.rb b/lib/capistrano/errors.rb index c50cd1b1..15d85a21 100644 --- a/lib/capistrano/errors.rb +++ b/lib/capistrano/errors.rb @@ -5,4 +5,5 @@ module Capistrano class CommandError < Error; end class ConnectionError < Error; end class UploadError < Error; end + class NoSuchTaskError < Error; end end \ No newline at end of file diff --git a/lib/capistrano/extensions.rb b/lib/capistrano/extensions.rb index d3c9a2ac..5d0dbeaf 100644 --- a/lib/capistrano/extensions.rb +++ b/lib/capistrano/extensions.rb @@ -1,38 +1,36 @@ -require 'capistrano/actor' - -module Capistrano - class ExtensionProxy - def initialize(actor, mod) - @actor = actor - extend(mod) - end - - def method_missing(sym, *args, &block) - @actor.send(sym, *args, &block) - end - end - - EXTENSIONS = {} - - def self.plugin(name, mod) - return false if EXTENSIONS.has_key?(name) - - Capistrano::Actor.class_eval <<-STR, __FILE__, __LINE__+1 - def #{name} - @__#{name}_proxy ||= Capistrano::ExtensionProxy.new(self, Capistrano::EXTENSIONS[#{name.inspect}]) - end - STR - - EXTENSIONS[name] = mod - return true - end - - def self.remove_plugin(name) - if EXTENSIONS.delete(name) - Capistrano::Actor.send(:remove_method, name) - return true - end - - return false - end -end +# module Capistrano +# class ExtensionProxy +# def initialize(actor, mod) +# @actor = actor +# extend(mod) +# end +# +# def method_missing(sym, *args, &block) +# @actor.send(sym, *args, &block) +# end +# end +# +# EXTENSIONS = {} +# +# def self.plugin(name, mod) +# return false if EXTENSIONS.has_key?(name) +# +# Capistrano::Actor.class_eval <<-STR, __FILE__, __LINE__+1 +# def #{name} +# @__#{name}_proxy ||= Capistrano::ExtensionProxy.new(self, Capistrano::EXTENSIONS[#{name.inspect}]) +# end +# STR +# +# EXTENSIONS[name] = mod +# return true +# end +# +# def self.remove_plugin(name) +# if EXTENSIONS.delete(name) +# Capistrano::Actor.send(:remove_method, name) +# return true +# end +# +# return false +# end +# end diff --git a/lib/capistrano/gateway.rb b/lib/capistrano/gateway.rb index 749f9b80..9825d823 100644 --- a/lib/capistrano/gateway.rb +++ b/lib/capistrano/gateway.rb @@ -73,7 +73,7 @@ module Capistrano # Net::SSH connection via that port. def connect_to(server) connection = nil - logger.trace "establishing connection to `#{server.host}' via gateway" if logger + logger.debug "establishing connection to `#{server.host}' via gateway" if logger local_port = next_port thread = Thread.new do diff --git a/lib/capistrano/recipes/standard.rb b/lib/capistrano/recipes/standard.rb index f25fb7a5..8d581e9c 100644 --- a/lib/capistrano/recipes/standard.rb +++ b/lib/capistrano/recipes/standard.rb @@ -1,287 +1,35 @@ -# Standard tasks that are useful for most recipes. It makes a few assumptions: -# -# * The :app role has been defined as the set of machines consisting of the -# application servers. -# * The :web role has been defined as the set of machines consisting of the -# web servers. -# * The :db role has been defined as the set of machines consisting of the -# databases, with exactly one set up as the :primary DB server. -# * The Rails spawner and reaper scripts are being used to manage the FCGI -# processes. - -set :rake, "rake" - -set :rails_env, :production - -set :migrate_target, :current -set :migrate_env, "" - -set :use_sudo, true -set(:run_method) { use_sudo ? :sudo : :run } - -set :spinner_user, :app - -desc "Enumerate and describe every available task." -task :show_tasks do - puts "Available tasks" - puts "---------------" - each_task do |info| - wrap_length = 80 - info[:longest] - 1 - lines = info[:desc].gsub(/(.{1,#{wrap_length}})(?:\s|\Z)+/, "\\1\n").split(/\n/) - puts "%-#{info[:longest]}s %s" % [info[:task], lines.shift] - puts "%#{info[:longest]}s %s" % ["", lines.shift] until lines.empty? - puts - end -end - -desc "Set up the expected application directory structure on all boxes" -task :setup, :except => { :no_release => true } do - run <<-CMD - umask 02 && - mkdir -p #{deploy_to} #{releases_path} #{shared_path} #{shared_path}/system && - mkdir -p #{shared_path}/log && - mkdir -p #{shared_path}/pids - CMD -end - desc <<-DESC -Disable the web server by writing a "maintenance.html" file to the web -servers. The servers must be configured to detect the presence of this file, -and if it is present, always display it instead of performing the request. +Invoke a single command on the remote servers. This is useful for performing \ +one-off commands that may not require a full task to be written for them. \ +Simply specify the command to execute via the COMMAND environment variable. \ +To execute the command only on certain roles, specify the ROLES environment \ +variable as a comma-delimited list of role names. Alternatively, you can \ +specify the HOSTS environment variable as a comma-delimited list of hostnames \ +to execute the task on those hosts, explicitly. Lastly, if you want to \ +execute the command via sudo, specify a non-empty value for the SUDO \ +environment variable. + +Sample usage: + + $ cap COMMAND=uptime HOSTS=foo.capistano.test invoke + $ cap ROLES=app,web SUDO=1 COMMAND="tail -f /var/log/messages" invoke DESC -task :disable_web, :roles => :web do - on_rollback { delete "#{shared_path}/system/maintenance.html" } - - maintenance = render("maintenance", :deadline => ENV['UNTIL'], - :reason => ENV['REASON']) - put maintenance, "#{shared_path}/system/maintenance.html", :mode => 0644 -end - -desc %(Re-enable the web server by deleting any "maintenance.html" file.) -task :enable_web, :roles => :web do - delete "#{shared_path}/system/maintenance.html" -end - -desc <<-DESC -Sets group permissions on checkout. Useful for team environments, bad on -shared hosts. Override this task if you're on a shared host. -DESC -task :set_permissions, :except => { :no_release => true } do - run "chmod -R g+w #{release_path}" -end - -desc <<-DESC -Update all servers with the latest release of the source code. All this does -is do a checkout (as defined by the selected scm module). -DESC -task :update_code, :except => { :no_release => true } do - on_rollback { delete release_path, :recursive => true } - - source.checkout(self) - - set_permissions - - run <<-CMD - rm -rf #{release_path}/log #{release_path}/public/system && - ln -nfs #{shared_path}/log #{release_path}/log && - ln -nfs #{shared_path}/system #{release_path}/public/system - CMD - - run <<-CMD - test -d #{shared_path}/pids && - rm -rf #{release_path}/tmp/pids && - ln -nfs #{shared_path}/pids #{release_path}/tmp/pids; true - CMD - - # update the asset timestamps so they are in sync across all servers. This - # lets the asset timestamping feature of rails work correctly - stamp = Time.now.utc.strftime("%Y%m%d%H%M.%S") - asset_paths = %w(images stylesheets javascripts).map { |p| "#{release_path}/public/#{p}" } - run "TZ=UTC find #{asset_paths.join(" ")} -exec touch -t #{stamp} {} \\;; true" - - # uncache the list of releases, so that the next time it is called it will - # include the newly released path. - @releases = nil -end - -desc <<-DESC -Rollback the latest checked-out version to the previous one by fixing the -symlinks and deleting the current release from all servers. -DESC -task :rollback_code, :except => { :no_release => true } do - if releases.length < 2 - raise "could not rollback the code because there is no prior release" - else - run <<-CMD - ln -nfs #{previous_release} #{current_path} && - rm -rf #{current_release} - CMD - end -end - -desc <<-DESC -Update the 'current' symlink to point to the latest version of -the application's code. -DESC -task :symlink, :except => { :no_release => true } do - on_rollback { run "ln -nfs #{previous_release} #{current_path}" } - run "ln -nfs #{current_release} #{current_path}" -end - -desc <<-DESC -Restart the FCGI processes on the app server. This uses the :use_sudo -variable to determine whether to use sudo or not. By default, :use_sudo is -set to true, but you can set it to false if you are in a shared environment. -DESC -task :restart, :roles => :app do - send(run_method, "#{current_path}/script/process/reaper") -end - -desc <<-DESC -Updates the code and fixes the symlink under a transaction -DESC -task :update do - transaction do - update_code - symlink - end -end - -desc <<-DESC -Run the migrate rake task. By default, it runs this in the version of the app -indicated by the 'current' symlink. (This means you should not invoke this task -until the symlink has been updated to the most recent version.) However, you -can specify a different release via the migrate_target variable, which must be -one of "current" (for the default behavior), or "latest" (for the latest release -to be deployed with the update_code task). You can also specify additional -environment variables to pass to rake via the migrate_env variable. Finally, you -can specify the full path to the rake executable by setting the rake variable. -DESC -task :migrate, :roles => :db, :only => { :primary => true } do - directory = case migrate_target.to_sym - when :current then current_path - when :latest then current_release - else - raise ArgumentError, - "you must specify one of current or latest for migrate_target" - end - - run "cd #{directory} && " + - "#{rake} RAILS_ENV=#{rails_env} #{migrate_env} db:migrate" -end - -desc <<-DESC -A macro-task that updates the code, fixes the symlink, and restarts the -application servers. -DESC -task :deploy do - update - restart -end - -desc <<-DESC -Similar to deploy, but it runs the migrate task on the new release before -updating the symlink. (Note that the update in this case it is not atomic, -and transactions are not used, because migrations are not guaranteed to be -reversible.) -DESC -task :deploy_with_migrations do - update_code - - begin - old_migrate_target = migrate_target - set :migrate_target, :latest - migrate - ensure - set :migrate_target, old_migrate_target - end - - symlink - - restart -end - -desc "A macro-task that rolls back the code and restarts the application servers." -task :rollback do - rollback_code - restart -end - -desc <<-DESC -Displays the diff between HEAD and what was last deployed. (Not available -with all SCM's.) -DESC -task :diff_from_last_deploy do - diff = source.diff(self) - puts - puts diff - puts -end - -desc "Update the currently released version of the software directly via an SCM update operation" -task :update_current do - source.update(self) -end - -desc <<-DESC -Removes unused releases from the releases directory. By default, the last 5 -releases are retained, but this can be configured with the 'keep_releases' -variable. This will use sudo to do the delete by default, but you can specify -that run should be used by setting the :use_sudo variable to false. -DESC -task :cleanup, :except => { :no_release => true } do - count = (self[:keep_releases] || 5).to_i - if count >= releases.length - logger.important "no old releases to clean up" - else - logger.info "keeping #{count} of #{releases.length} deployed releases" - directories = (releases - releases.last(count)).map { |release| - File.join(releases_path, release) }.join(" ") - - send(run_method, "rm -rf #{directories}") - end -end - -desc <<-DESC -Start the spinner daemon for the application (requires script/spin). This will -use sudo to start the spinner by default, unless :use_sudo is false. If using -sudo, you can specify the user that the spinner ought to run as by setting the -:spinner_user variable (defaults to :app). -DESC -task :spinner, :roles => :app do - user = (use_sudo && spinner_user) ? "-u #{spinner_user} " : "" - send(run_method, "#{user}#{current_path}/script/spin") -end - -desc <<-DESC -Used only for deploying when the spinner isn't running. It invokes 'update', -and when it finishes it then invokes the spinner task (to start the spinner). -DESC -task :cold_deploy do - update - spinner -end - -desc <<-DESC -A simple task for performing one-off commands that may not require a full task -to be written for them. Simply specify the command to execute via the COMMAND -environment variable. To execute the command only on certain roles, specify -the ROLES environment variable as a comma-delimited list of role names. Lastly, -if you want to execute the command via sudo, specify a non-empty value for the -SUDO environment variable. -DESC -task :invoke, :roles => Capistrano.str2roles(ENV["ROLES"] || "") do +task :invoke do method = ENV["SUDO"] ? :sudo : :run - send(method, ENV["COMMAND"]) + invoke_command(ENV["COMMAND"], :via => method) end desc <<-DESC -Begin an interactive Capistrano session. This gives you an interactive -terminal from which to execute tasks and commands on all of your servers. -(This is still an experimental feature, and is subject to change without +Begin an interactive Capistrano session. This gives you an interactive \ +terminal from which to execute tasks and commands on all of your servers. \ +(This is still an experimental feature, and is subject to change without \ notice!) + +Sample usage: + + $ cap shell DESC -task(:shell) do +task :shell do require 'capistrano/shell' - Capistrano::Shell.run!(self) -end + Capistrano::Shell.run(self) +end \ No newline at end of file diff --git a/lib/capistrano/shell.rb b/lib/capistrano/shell.rb index 793dcdb8..5bb43683 100644 --- a/lib/capistrano/shell.rb +++ b/lib/capistrano/shell.rb @@ -11,7 +11,7 @@ module Capistrano attr_reader :actor # Instantiate a new shell and begin executing it immediately. - def self.run!(actor) + def self.run(actor) new(actor).run! end diff --git a/lib/capistrano/task_definition.rb b/lib/capistrano/task_definition.rb index 8eae3e9c..19cb7b7a 100644 --- a/lib/capistrano/task_definition.rb +++ b/lib/capistrano/task_definition.rb @@ -13,7 +13,13 @@ module Capistrano # Returns the task's fully-qualified name, including the namespace def fully_qualified_name - @fully_qualified_name ||= [namespace.fully_qualified_name, name].compact.join(":") + @fully_qualified_name ||= begin + if namespace.default_task == self + namespace.fully_qualified_name + else + [namespace.fully_qualified_name, name].compact.join(":") + end + end end # Returns the list of server definitions (_not_ connections to servers) @@ -27,7 +33,33 @@ module Capistrano apply_except(apply_only(find_servers_by_role)).uniq end end - + + # Returns the description for this task, with newlines collapsed and + # whitespace stripped. Returns the empty string if there is no + # description for this task. + def description(rebuild=false) + @description = nil if rebuild + @description ||= begin + description = options[:desc] || "" + description.strip. + gsub(/\r\n/, "\n"). + gsub(/\\\n/, " ") + end + end + + # Returns the first sentence of the full description. If +max_length+ is + # given, the result will be truncated if it is longer than +max_length+, + # and an ellipsis appended. + def brief_description(max_length=nil) + brief = description[/^.*?\./] || description + + if max_length && brief.length > max_length + brief = brief[0,max_length-3] + "..." + end + + brief + end + private def find_servers_by_role roles = namespace.roles diff --git a/test/cli/execute_test.rb b/test/cli/execute_test.rb new file mode 100644 index 00000000..0a148f25 --- /dev/null +++ b/test/cli/execute_test.rb @@ -0,0 +1,116 @@ +require "#{File.dirname(__FILE__)}/../utils" +require 'capistrano/cli/execute' + +class CLIExecuteTest < Test::Unit::TestCase + class MockCLI + attr_reader :options + + def initialize + @options = {} + end + + include Capistrano::CLI::Execute + end + + def setup + @cli = MockCLI.new + @logger = stub_everything + @config = stub(:logger => @logger) + @config.stubs(:set) + @config.stubs(:load) + @cli.stubs(:instantiate_configuration).returns(@config) + end + + def test_execute_should_set_logger_verbosity + @cli.options[:verbose] = 7 + @logger.expects(:level=).with(7) + @cli.execute! + end + + def test_execute_should_set_password + @cli.options[:password] = "nosoup4u" + @config.expects(:set).with(:password, "nosoup4u") + @cli.execute! + end + + def test_execute_should_set_prevars_before_loading + @config.expects(:load).never + @config.expects(:set).with(:stage, "foobar").returns(Proc.new { @config.expects(:load).with("standard") }) + @cli.options[:pre_vars] = { :stage => "foobar" } + @cli.execute! + end + + def test_execute_should_load_sysconf_if_sysconf_set_and_exists + @cli.options[:sysconf] = "/etc/capistrano.conf" + @config.expects(:load).with("/etc/capistrano.conf") + File.expects(:file?).with("/etc/capistrano.conf").returns(true) + @cli.execute! + end + + def test_execute_should_not_load_sysconf_when_sysconf_set_and_not_exists + @cli.options[:sysconf] = "/etc/capistrano.conf" + File.expects(:file?).with("/etc/capistrano.conf").returns(false) + @cli.execute! + end + + def test_execute_should_load_dotfile_if_dotfile_set_and_exists + @cli.options[:dotfile] = "/home/jamis/.caprc" + @config.expects(:load).with("/home/jamis/.caprc") + File.expects(:file?).with("/home/jamis/.caprc").returns(true) + @cli.execute! + end + + def test_execute_should_not_load_dotfile_when_dotfile_set_and_not_exists + @cli.options[:dotfile] = "/home/jamis/.caprc" + File.expects(:file?).with("/home/jamis/.caprc").returns(false) + @cli.execute! + end + + def test_execute_should_load_recipes_when_recipes_are_given + @cli.options[:recipes] = %w(config/deploy path/to/extra) + @config.expects(:load).with("config/deploy") + @config.expects(:load).with("path/to/extra") + @cli.execute! + end + + def test_execute_should_set_vars_and_execute_tasks + @cli.options[:vars] = { :foo => "bar", :baz => "bang" } + @cli.options[:actions] = %w(first second) + @config.expects(:set).with(:foo, "bar") + @config.expects(:set).with(:baz, "bang") + @config.expects(:find_and_execute_task).with("first") + @config.expects(:find_and_execute_task).with("second") + @cli.execute! + end + + def test_execute_should_call_handle_error_when_exceptions_occur + @config.expects(:load).raises(Exception, "boom") + @cli.expects(:handle_error).with { |e,| Exception === e } + @cli.execute! + end + + def test_instantiate_configuration_should_return_new_configuration_instance + assert_instance_of Capistrano::Configuration, MockCLI.new.instantiate_configuration + end + + def test_handle_error_with_auth_error_should_abort_with_message_including_user_name + @cli.expects(:abort).with { |s| s.include?("jamis") } + @cli.handle_error(Net::SSH::AuthenticationFailed.new("jamis")) + end + + def test_handle_error_with_cap_error_should_abort_with_message + @cli.expects(:abort).with("Wish you were here") + @cli.handle_error(Capistrano::Error.new("Wish you were here")) + end + + def test_handle_error_with_other_errors_should_reraise_error + other_error = Class.new(RuntimeError) + assert_raises(other_error) { @cli.handle_error(other_error.new("boom")) } + end + + def test_class_execute_method_should_call_parse_and_execute_with_ARGV + cli = mock(:execute! => nil) + MockCLI.expects(:parse).with(ARGV).returns(cli) + MockCLI.execute + end +end \ No newline at end of file diff --git a/test/cli/help_test.rb b/test/cli/help_test.rb new file mode 100644 index 00000000..a16f2a49 --- /dev/null +++ b/test/cli/help_test.rb @@ -0,0 +1,102 @@ +require "#{File.dirname(__FILE__)}/../utils" +require 'capistrano/cli/help' + +class CLIHelpTest < Test::Unit::TestCase + class MockCLI + attr_reader :options, :called_original + + def initialize + @options = {} + @called_original = false + end + + def execute_requested_actions(config) + @called_original = config + end + + include Capistrano::CLI::Help + end + + def setup + @cli = MockCLI.new + @ui = stub("ui", :output_cols => 80) + MockCLI.stubs(:ui).returns(@ui) + end + + def test_execute_requested_actions_without_tasks_or_explain_should_call_original + @cli.execute_requested_actions(:config) + @cli.expects(:task_list).never + @cli.expects(:explain_task).never + assert_equal :config, @cli.called_original + end + + def test_execute_requested_actions_with_tasks_should_call_task_list + @cli.options[:tasks] = true + @cli.expects(:task_list).with(:config) + @cli.expects(:explain_task).never + @cli.execute_requested_actions(:config) + assert !@cli.called_original + end + + def test_execute_requested_actions_with_explain_should_call_explain_task + @cli.options[:explain] = "deploy_with_niftiness" + @cli.expects(:task_list).never + @cli.expects(:explain_task).with(:config, "deploy_with_niftiness") + @cli.execute_requested_actions(:config) + assert !@cli.called_original + end + + def test_task_list_with_no_tasks_should_emit_warning + config = mock("config", :task_list => []) + @cli.expects(:warn) + @cli.task_list(config) + end + + def test_task_list_should_query_all_tasks_in_all_namespaces + expected_max_len = 80 - 3 - MockCLI::LINE_PADDING + task_list = [task("c"), task("g", "c:g"), task("b", "c:b"), task("a")] + task_list.each { |t| t.expects(:brief_description).with(expected_max_len).returns(t.fully_qualified_name) } + + config = mock("config") + config.expects(:task_list).with(:all).returns(task_list) + @cli.stubs(:puts) + @cli.task_list(config) + end + + def test_task_list_should_never_use_less_than_MIN_MAX_LEN_chars_for_descriptions + @ui.stubs(:output_cols).returns(20) + t = task("c") + t.expects(:brief_description).with(30).returns("hello") + config = mock("config", :task_list => [t]) + @cli.stubs(:puts) + @cli.task_list(config) + end + + def test_explain_task_should_warn_if_task_does_not_exist + config = mock("config", :find_task => nil) + @cli.expects(:warn).with { |s,| s =~ /`deploy_with_niftiness'/ } + @cli.explain_task(config, "deploy_with_niftiness") + end + + def test_explain_task_with_task_that_has_no_description_should_emit_stub + t = mock("task", :description => "") + config = mock("config") + config.expects(:find_task).with("deploy_with_niftiness").returns(t) + @cli.stubs(:puts) + @cli.expects(:puts).with("There is no description for this task.") + @cli.explain_task(config, "deploy_with_niftiness") + end + + def test_explain_task_with_task_should_format_description + t = stub("task", :description => "line1\nline2\n\nline3") + config = mock("config", :find_task => t) + @cli.stubs(:puts) + @cli.explain_task(config, "deploy_with_niftiness") + end + + private + + def task(name, fqn=name) + stub("task", :name => name, :fully_qualified_name => fqn) + end +end \ No newline at end of file diff --git a/test/cli/options_test.rb b/test/cli/options_test.rb new file mode 100644 index 00000000..b0da2cc5 --- /dev/null +++ b/test/cli/options_test.rb @@ -0,0 +1,186 @@ +require "#{File.dirname(__FILE__)}/../utils" +require 'capistrano/cli/options' + +class CLIOptionsTest < Test::Unit::TestCase + class ExitException < Exception; end + + class MockCLI + def initialize + @args = [] + end + + attr_reader :args + + include Capistrano::CLI::Options + end + + def setup + @cli = MockCLI.new + end + + def test_parse_options_should_require_non_empty_args_list + @cli.stubs(:warn) + @cli.expects(:exit).raises(ExitException) + assert_raises(ExitException) { @cli.parse_options! } + end + + def test_parse_options_with_e_should_set_explain_option + @cli.args << "-e" << "sample" + @cli.parse_options! + assert_equal "sample", @cli.options[:explain] + end + + def test_parse_options_with_f_should_add_recipe_file + @cli.args << "-f" << "deploy" + @cli.parse_options! + assert_equal %w(deploy), @cli.options[:recipes] + end + + def test_parse_options_with_multiple_f_should_add_each_as_recipe_file + @cli.args << "-f" << "deploy" << "-f" << "monitor" + @cli.parse_options! + assert_equal %w(deploy monitor), @cli.options[:recipes] + end + + def test_parse_options_with_h_should_show_options_and_exit + @cli.expects(:puts).with(@cli.option_parser) + @cli.expects(:exit).raises(ExitException) + @cli.args << "-h" + assert_raises(ExitException) { @cli.parse_options! } + end + + def test_parse_options_with_p_should_prompt_for_password + MockCLI.expects(:password_prompt).returns(:the_password) + @cli.args << "-p" + @cli.parse_options! + assert_equal :the_password, @cli.options[:password] + end + + def test_parse_options_without_p_should_set_proc_for_password + @cli.args << "-e" << "sample" + @cli.parse_options! + assert_instance_of Proc, @cli.options[:password] + end + + def test_parse_options_with_q_should_set_verbose_to_0 + @cli.args << "-q" + @cli.parse_options! + assert_equal 0, @cli.options[:verbose] + end + + def test_parse_options_with_S_should_set_pre_vars + @cli.args << "-S" << "foo=bar" + @cli.parse_options! + assert_equal "bar", @cli.options[:pre_vars][:foo] + end + + def test_parse_options_with_s_should_set_vars + @cli.args << "-s" << "foo=bar" + @cli.parse_options! + assert_equal "bar", @cli.options[:vars][:foo] + end + + def test_parse_options_with_T_should_set_tasks_option + @cli.args << "-T" + @cli.parse_options! + assert @cli.options[:tasks] + end + + def test_parse_options_with_V_should_show_version_and_exit + @cli.args << "-V" + @cli.expects(:puts).with { |s| s.include?(Capistrano::Version::STRING) } + @cli.expects(:exit).raises(ExitException) + assert_raises(ExitException) { @cli.parse_options! } + end + + def test_parse_options_with_v_should_set_verbose_to_1 + @cli.args << "-v" + @cli.parse_options! + assert_equal 1, @cli.options[:verbose] + end + + def test_parse_options_with_multiple_v_should_set_verbose_accordingly + @cli.args << "-vvvvvvv" + @cli.parse_options! + assert_equal 7, @cli.options[:verbose] + end + + def test_parse_options_without_X_should_set_sysconf + @cli.args << "-v" + @cli.parse_options! + assert @cli.options.key?(:sysconf) + end + + def test_parse_options_with_X_should_unset_sysconf + @cli.args << "-X" + @cli.parse_options! + assert !@cli.options.key?(:sysconf) + end + + def test_parse_options_without_x_should_set_dotfile + @cli.args << "-v" + @cli.parse_options! + assert @cli.options.key?(:dotfile) + end + + def test_parse_options_with_x_should_unset_dotfile + @cli.args << "-x" + @cli.parse_options! + assert !@cli.options.key?(:dotfile) + end + + def test_parse_options_without_q_or_v_should_set_verbose_to_3 + @cli.args << "-T" + @cli.parse_options! + assert_equal 3, @cli.options[:verbose] + end + + def test_should_search_for_default_recipes_if_f_not_given + @cli.expects(:look_for_default_recipe_file!) + @cli.args << "-v" + @cli.parse_options! + end + + def test_should_not_search_for_default_recipes_if_f_given + @cli.expects(:look_for_default_recipe_file!).never + @cli.args << "-f" << "hello" + @cli.parse_options! + end + + def test_should_extract_env_vars_from_command_line + assert_nil ENV["HELLO"] + assert_nil ENV["ANOTHER"] + + @cli.args << "HELLO=world" << "hello" << "ANOTHER=value" + @cli.parse_options! + + assert_equal "world", ENV["HELLO"] + assert_equal "value", ENV["ANOTHER"] + ensure + ENV["HELLO"] = ENV["ANOTHER"] = nil + end + + def test_remaining_args_should_be_added_to_actions_list + @cli.args << "-v" << "HELLO=world" << "-f" << "foo" << "something" << "else" + @cli.parse_options! + assert_equal %w(something else), @cli.args + ensure + ENV["HELLO"] = nil + end + + def test_search_for_default_recipe_file_should_look_for_Capfile + File.stubs(:file?).returns(false) + File.expects(:file?).with("Capfile").returns(true) + @cli.args << "-v" + @cli.parse_options! + assert_equal %w(Capfile), @cli.options[:recipes] + end + + def test_search_for_default_recipe_file_should_look_for_capfile + File.stubs(:file?).returns(false) + File.expects(:file?).with("capfile").returns(true) + @cli.args << "-v" + @cli.parse_options! + assert_equal %w(capfile), @cli.options[:recipes] + end +end \ No newline at end of file diff --git a/test/cli/ui_test.rb b/test/cli/ui_test.rb new file mode 100644 index 00000000..9bd343ac --- /dev/null +++ b/test/cli/ui_test.rb @@ -0,0 +1,28 @@ +require "#{File.dirname(__FILE__)}/../utils" +require 'capistrano/cli/ui' + +class CLIUITest < Test::Unit::TestCase + class MockCLI + include Capistrano::CLI::UI + end + + def test_ui_should_return_highline_instance + assert_instance_of HighLine, MockCLI.ui + end + + def test_password_prompt_should_have_default_prompt_and_set_echo_false + q = mock("question") + q.expects(:echo=).with(false) + ui = mock("ui") + ui.expects(:ask).with("Password: ").yields(q).returns("sayuncle") + MockCLI.expects(:ui).returns(ui) + assert_equal "sayuncle", MockCLI.password_prompt + end + + def test_password_prompt_with_custom_prompt_should_use_custom_prompt + ui = mock("ui") + ui.expects(:ask).with("Give the passphrase: ").returns("sayuncle") + MockCLI.expects(:ui).returns(ui) + assert_equal "sayuncle", MockCLI.password_prompt("Give the passphrase: ") + end +end \ No newline at end of file diff --git a/test/configuration/actions/inspect_test.rb b/test/configuration/actions/inspect_test.rb index 0cf6cde5..5c287cad 100644 --- a/test/configuration/actions/inspect_test.rb +++ b/test/configuration/actions/inspect_test.rb @@ -12,12 +12,12 @@ class ConfigurationActionsRunTest < Test::Unit::TestCase end def test_stream_should_pass_options_through_to_run - @config.expects(:invoke).with("tail -f foo.log", :once => true) + @config.expects(:invoke_command).with("tail -f foo.log", :once => true) @config.stream("tail -f foo.log", :once => true) end def test_stream_should_emit_stdout_via_puts - @config.expects(:invoke).yields(mock("channel"), :out, "something streamed") + @config.expects(:invoke_command).yields(mock("channel"), :out, "something streamed") @config.expects(:puts).with("something streamed") @config.expects(:warn).never @config.stream("tail -f foo.log") @@ -26,33 +26,33 @@ class ConfigurationActionsRunTest < Test::Unit::TestCase def test_stream_should_emit_stderr_via_warn ch = mock("channel") ch.expects(:[]).with(:host).returns("capistrano") - @config.expects(:invoke).yields(ch, :err, "something streamed") + @config.expects(:invoke_command).yields(ch, :err, "something streamed") @config.expects(:puts).never @config.expects(:warn).with("[err :: capistrano] something streamed") @config.stream("tail -f foo.log") end def test_capture_should_pass_options_merged_with_once_to_run - @config.expects(:invoke).with("hostname", :foo => "bar", :once => true) + @config.expects(:invoke_command).with("hostname", :foo => "bar", :once => true) @config.capture("hostname", :foo => "bar") end def test_capture_with_stderr_result_should_raise_capture_error - @config.expects(:invoke).yields(mock("channel"), :err, "boom") + @config.expects(:invoke_command).yields(mock("channel"), :err, "boom") assert_raises(Capistrano::CaptureError) { @config.capture("hostname") } end def test_capture_with_stdout_should_aggregate_and_return_stdout - config_expects_invoke_to_loop_with(mock("channel"), "foo", "bar", "baz") + config_expects_invoke_command_to_loop_with(mock("channel"), "foo", "bar", "baz") assert_equal "foobarbaz", @config.capture("hostname") end private - def config_expects_invoke_to_loop_with(channel, *output) + def config_expects_invoke_command_to_loop_with(channel, *output) class <<@config attr_accessor :script, :channel - def invoke(*args) + def invoke_command(*args) script.each { |item| yield channel, :out, item } end end diff --git a/test/configuration/actions/invocation_test.rb b/test/configuration/actions/invocation_test.rb index 0729a1d7..662e03da 100644 --- a/test/configuration/actions/invocation_test.rb +++ b/test/configuration/actions/invocation_test.rb @@ -1,7 +1,7 @@ require "#{File.dirname(__FILE__)}/../../utils" require 'capistrano/configuration/actions/invocation' -class ConfigurationActionsRunTest < Test::Unit::TestCase +class ConfigurationActionsInvocationTest < Test::Unit::TestCase class MockConfig attr_reader :options @@ -125,14 +125,14 @@ class ConfigurationActionsRunTest < Test::Unit::TestCase callback[a, b, c] end - def test_invoke_should_default_to_run + def test_invoke_command_should_default_to_run @config.expects(:run).with("ls", :once => true) - @config.invoke("ls", :once => true) + @config.invoke_command("ls", :once => true) end - def test_invoke_should_delegate_to_method_identified_by_via + def test_invoke_command_should_delegate_to_method_identified_by_via @config.expects(:foobar).with("ls", :once => true) - @config.invoke("ls", :once => true, :via => :foobar) + @config.invoke_command("ls", :once => true, :via => :foobar) end private diff --git a/test/configuration/connections_test.rb b/test/configuration/connections_test.rb index 0e3030ad..d569f5d2 100644 --- a/test/configuration/connections_test.rb +++ b/test/configuration/connections_test.rb @@ -16,6 +16,10 @@ class ConfigurationConnectionsTest < Test::Unit::TestCase @values.fetch(*args) end + def [](key) + @values[key] + end + def exists?(key) @values.key?(key) end @@ -49,9 +53,8 @@ class ConfigurationConnectionsTest < Test::Unit::TestCase end def test_default_connection_factory_honors_config_options - @config.values.update(@ssh_options) server = server("capistrano") - Capistrano::SSH.expects(:connect).with(server, @ssh_options).returns(:session) + Capistrano::SSH.expects(:connect).with(server, @config).returns(:session) assert_equal :session, @config.connection_factory.connect_to(server) end @@ -65,7 +68,7 @@ class ConfigurationConnectionsTest < Test::Unit::TestCase def test_connection_factory_as_gateway_should_honor_config_options @config.values[:gateway] = "capistrano" @config.values.update(@ssh_options) - Capistrano::SSH.expects(:connect).with { |s,opts| s.host == "capistrano" && opts == @ssh_options.merge(:logger => @config.logger) }.yields(stub_everything) + Capistrano::SSH.expects(:connect).with { |s,opts| s.host == "capistrano" && opts == @config }.yields(stub_everything) assert_instance_of Capistrano::Gateway, @config.connection_factory end diff --git a/test/configuration/execution_test.rb b/test/configuration/execution_test.rb index 6cacbb9a..3cb60d17 100644 --- a/test/configuration/execution_test.rb +++ b/test/configuration/execution_test.rb @@ -6,7 +6,7 @@ class ConfigurationExecutionTest < Test::Unit::TestCase class MockConfig attr_reader :tasks, :namespaces, :fully_qualified_name, :parent attr_reader :state, :original_initialize_called - attr_accessor :logger + attr_accessor :logger, :default_task def initialize(options={}) @original_initialize_called = true @@ -31,47 +31,35 @@ class ConfigurationExecutionTest < Test::Unit::TestCase assert @config.task_call_frames.empty? end - def test_execute_task_with_unknown_task_should_raise_error - assert_raises(NoMethodError) do - @config.execute_task(:bogus, @config) - end - end - - def test_execute_task_with_unknown_task_and_fail_silently_should_fail_silently - assert_nothing_raised do - @config.execute_task(:bogus, @config, true) - end - end - def test_execute_task_should_populate_call_stack - new_task @config, :testing - assert_nothing_raised { @config.execute_task :testing, @config } + task = new_task @config, :testing + assert_nothing_raised { @config.execute_task(task) } assert_equal %w(testing), @config.state[:testing][:stack] assert_nil @config.state[:testing][:history] assert @config.task_call_frames.empty? end def test_nested_execute_task_should_add_to_call_stack - new_task @config, :testing - new_task(@config, :outer) { execute_task :testing, self } + testing = new_task @config, :testing + outer = new_task(@config, :outer) { execute_task(testing) } - assert_nothing_raised { @config.execute_task :outer, @config } + assert_nothing_raised { @config.execute_task(outer) } assert_equal %w(outer testing), @config.state[:testing][:stack] assert_nil @config.state[:testing][:history] assert @config.task_call_frames.empty? end def test_execute_task_should_execute_before_hook_if_defined - new_task @config, :testing + testing = new_task @config, :testing new_task @config, :before_testing - @config.execute_task :testing, @config + @config.execute_task(testing) assert_equal %w(before_testing testing), @config.state[:trail] end def test_execute_task_should_execute_after_hook_if_defined - new_task @config, :testing + testing = new_task @config, :testing new_task @config, :after_testing - @config.execute_task :testing, @config + @config.execute_task(testing) assert_equal %w(testing after_testing), @config.state[:trail] end @@ -80,38 +68,38 @@ class ConfigurationExecutionTest < Test::Unit::TestCase end def test_transaction_without_block_should_raise_argument_error - new_task(@config, :testing) { transaction } - assert_raises(ArgumentError) { @config.execute_task :testing, @config } + testing = new_task(@config, :testing) { transaction } + assert_raises(ArgumentError) { @config.execute_task(testing) } end def test_transaction_should_initialize_transaction_history @config.state[:inspector] = stack_inspector - new_task(@config, :testing) { transaction { instance_eval(&state[:inspector]) } } - @config.execute_task :testing, @config + testing = new_task(@config, :testing) { transaction { instance_eval(&state[:inspector]) } } + @config.execute_task(testing) assert_equal [], @config.state[:testing][:history] end def test_transaction_from_within_transaction_should_not_start_new_transaction - new_task(@config, :third, &stack_inspector) - new_task(@config, :second) { transaction { execute_task(:third, self) } } - new_task(@config, :first) { transaction { execute_task(:second, self) } } + third = new_task(@config, :third, &stack_inspector) + second = new_task(@config, :second) { transaction { execute_task(third) } } + first = new_task(@config, :first) { transaction { execute_task(second) } } # kind of fragile...not sure how else to check that transaction was only # really run twice...but if the transaction was REALLY run, logger.info # will be called once when it starts, and once when it finishes. @config.logger = mock() @config.logger.stubs(:debug) @config.logger.expects(:info).times(2) - @config.execute_task :first, @config + @config.execute_task(first) end def test_exception_raised_in_transaction_should_call_all_registered_rollback_handlers_in_reverse_order - new_task(@config, :aaa) { on_rollback { (state[:rollback] ||= []) << :aaa } } - new_task(@config, :bbb) { on_rollback { (state[:rollback] ||= []) << :bbb } } - new_task(@config, :ccc) {} - new_task(@config, :ddd) { on_rollback { (state[:rollback] ||= []) << :ddd }; execute_task(:bbb, self); execute_task(:ccc, self) } - new_task(@config, :eee) { transaction { execute_task(:ddd, self); execute_task(:aaa, self); raise "boom" } } + aaa = new_task(@config, :aaa) { on_rollback { (state[:rollback] ||= []) << :aaa } } + bbb = new_task(@config, :bbb) { on_rollback { (state[:rollback] ||= []) << :bbb } } + ccc = new_task(@config, :ccc) {} + ddd = new_task(@config, :ddd) { on_rollback { (state[:rollback] ||= []) << :ddd }; execute_task(bbb); execute_task(ccc) } + eee = new_task(@config, :eee) { transaction { execute_task(ddd); execute_task(aaa); raise "boom" } } assert_raises(RuntimeError) do - @config.execute_task :eee, @config + @config.execute_task(eee) end assert_equal [:aaa, :bbb, :ddd], @config.state[:rollback] assert_nil @config.rollback_requests @@ -119,15 +107,25 @@ class ConfigurationExecutionTest < Test::Unit::TestCase end def test_exception_during_rollback_should_simply_be_logged_and_ignored - new_task(@config, :aaa) { on_rollback { state[:aaa] = true; raise LoadError, "ouch" }; execute_task(:bbb, self) } - new_task(@config, :bbb) { raise MadError, "boom" } - new_task(@config, :ccc) { transaction { execute_task(:aaa, self) } } + aaa = new_task(@config, :aaa) { on_rollback { state[:aaa] = true; raise LoadError, "ouch" }; execute_task(bbb) } + bbb = new_task(@config, :bbb) { raise MadError, "boom" } + ccc = new_task(@config, :ccc) { transaction { execute_task(aaa) } } assert_raises(NameError) do - @config.execute_task :ccc, @config + @config.execute_task(ccc) end assert @config.state[:aaa] end + def test_find_and_execute_task_should_raise_error_when_task_cannot_be_found + @config.expects(:find_task).with("path:to:task").returns(nil) + assert_raises(Capistrano::NoSuchTaskError) { @config.find_and_execute_task("path:to:task") } + end + + def test_find_and_execute_task_should_execute_task_when_task_is_found + @config.expects(:find_task).with("path:to:task").returns(:found) + @config.expects(:execute_task).with(:found) + assert_nothing_raised { @config.find_and_execute_task("path:to:task") } + end private def stack_inspector diff --git a/test/configuration/namespace_dsl_test.rb b/test/configuration/namespace_dsl_test.rb index 472021e6..6a878b9b 100644 --- a/test/configuration/namespace_dsl_test.rb +++ b/test/configuration/namespace_dsl_test.rb @@ -159,4 +159,78 @@ class ConfigurationNamespacesDSLTest < Test::Unit::TestCase @config.namespace(:outer) { namespace(:inner) {} } assert_equal @config.namespaces[:outer], @config.namespaces[:outer].namespaces[:inner].parent end + + def test_find_task_should_dereference_nested_tasks + @config.namespace(:outer) do + namespace(:inner) { task(:nested) { puts "nested" } } + end + + task = @config.find_task("outer:inner:nested") + assert_not_nil task + assert_equal "outer:inner:nested", task.fully_qualified_name + end + + def test_find_task_should_return_nil_if_no_task_matches + assert_nil @config.find_task("outer:inner:nested") + end + + def test_find_task_should_return_default_if_deferences_to_namespace_and_namespace_has_default + @config.namespace(:outer) do + namespace(:inner) { task(:default) { puts "nested" } } + end + + task = @config.find_task("outer:inner") + assert_not_nil task + assert_equal :default, task.name + assert_equal "outer:inner", task.namespace.fully_qualified_name + end + + def test_find_task_should_return_nil_if_deferences_to_namespace_and_namespace_has_no_default + @config.namespace(:outer) do + namespace(:inner) { task(:nested) { puts "nested" } } + end + + assert_nil @config.find_task("outer:inner") + end + + def test_default_task_should_return_nil_for_top_level + @config.task(:default) {} + assert_nil @config.default_task + end + + def test_default_task_should_return_nil_for_namespace_without_default + @config.namespace(:outer) { task(:nested) { puts "nested" } } + assert_nil @config.namespaces[:outer].default_task + end + + def test_default_task_should_return_task_for_namespace_with_default + @config.namespace(:outer) { task(:default) { puts "nested" } } + task = @config.namespaces[:outer].default_task + assert_not_nil task + assert_equal :default, task.name + end + + def test_task_list_should_return_only_tasks_immediately_within_namespace + @config.task(:first) { puts "here" } + @config.namespace(:outer) do + task(:second) { puts "here" } + namespace(:inner) do + task(:third) { puts "here" } + end + end + + assert_equal %w(first), @config.task_list.map { |t| t.fully_qualified_name } + end + + def test_task_list_with_all_should_return_all_tasks_under_this_namespace_recursively + @config.task(:first) { puts "here" } + @config.namespace(:outer) do + task(:second) { puts "here" } + namespace(:inner) do + task(:third) { puts "here" } + end + end + + assert_equal %w(first outer:inner:third outer:second), @config.task_list(:all).map { |t| t.fully_qualified_name }.sort + end end \ No newline at end of file diff --git a/test/configuration/variables_test.rb b/test/configuration/variables_test.rb index 0bccbd3c..d9664b3c 100644 --- a/test/configuration/variables_test.rb +++ b/test/configuration/variables_test.rb @@ -13,12 +13,13 @@ class ConfigurationVariablesTest < Test::Unit::TestCase end def setup + MockConfig.any_instance.stubs(:logger).returns(stub_everything) @config = MockConfig.new end def test_initialize_should_initialize_variables_hash assert @config.original_initialize_called - assert_equal({:ssh_options => {}}, @config.variables) + assert_equal({:ssh_options => {}, :logger => @config.logger}, @config.variables) end def test_set_should_add_variable_to_hash diff --git a/test/task_definition_test.rb b/test/task_definition_test.rb index 62d87207..616517c6 100644 --- a/test/task_definition_test.rb +++ b/test/task_definition_test.rb @@ -13,7 +13,7 @@ class TaskDefinitionTest < Test::Unit::TestCase end def test_task_without_roles_should_apply_to_all_defined_hosts - task = new_task(:testing, @namespace) + task = new_task(:testing) assert_equal %w(app1 app2 app3 web1 web2 file).sort, task.servers.map { |s| s.host }.sort end @@ -39,7 +39,7 @@ class TaskDefinitionTest < Test::Unit::TestCase def test_task_with_roles_as_environment_variable_should_apply_only_to_that_role ENV['ROLES'] = "app,file" - task = new_task(:testing, @namespace) + task = new_task(:testing) assert_equal %w(app1 app2 app3 file).sort, task.servers.map { |s| s.host }.sort ensure ENV['ROLES'] = nil @@ -47,7 +47,7 @@ class TaskDefinitionTest < Test::Unit::TestCase def test_task_with_hosts_as_environment_variable_should_apply_only_to_those_hosts ENV['HOSTS'] = "foo,bar" - task = new_task(:testing, @namespace) + task = new_task(:testing) assert_equal %w(foo bar).sort, task.servers.map { |s| s.host }.sort ensure ENV['HOSTS'] = nil @@ -64,7 +64,7 @@ class TaskDefinitionTest < Test::Unit::TestCase end def test_fqn_at_top_level_should_be_task_name - task = new_task(:testing, @namespace) + task = new_task(:testing) assert_equal "testing", task.fully_qualified_name end @@ -74,16 +74,56 @@ class TaskDefinitionTest < Test::Unit::TestCase assert_equal "outer:inner:testing", task.fully_qualified_name end + def test_fqn_at_top_level_when_default_should_be_default + task = new_task(:default) + assert_equal "default", task.fully_qualified_name + end + + def test_fqn_in_namespace_when_default_should_be_namespace_fqn + ns = namespace("outer:inner") + task = new_task(:default, ns) + ns.stubs(:default_task => task) + assert_equal "outer:inner", task.fully_qualified_name + end + def test_task_should_require_block assert_raises(ArgumentError) do Capistrano::TaskDefinition.new(:testing, @namespace) end end + def test_description_should_return_empty_string_if_not_given + assert_equal "", new_task(:testing).description + end + + def test_description_should_return_desc_attribute + assert_equal "something", new_task(:testing, @namespace, :desc => "something").description + end + + def test_description_should_strip_leading_and_trailing_whitespace + assert_equal "something", new_task(:testing, @namespace, :desc => " something ").description + end + + def test_description_should_normalize_newlines + assert_equal "a\nb\nc", new_task(:testing, @namespace, :desc => "a\nb\r\nc").description + end + + def test_task_brief_description_should_return_first_sentence_in_description + desc = "This is the task. It does all kinds of things." + task = new_task(:testing, @namespace, :desc => desc) + assert_equal "This is the task.", task.brief_description + end + + def test_task_brief_description_should_truncate_if_length_given + desc = "This is the task that does all kinds of things. And then some." + task = new_task(:testing, @namespace, :desc => desc) + assert_equal "This is the task ...", task.brief_description(20) + end + private def namespace(fqn=nil) - space = stub(:roles => {}, :fully_qualified_name => fqn) + space = stub(:roles => {}, :fully_qualified_name => fqn, :default_task => nil) yield(space) if block_given? space end @@ -94,7 +134,7 @@ class TaskDefinitionTest < Test::Unit::TestCase space.roles[name].concat(args.map { |h| Capistrano::ServerDefinition.new(h, opts) }) end - def new_task(name, namespace, options={}, &block) + def new_task(name, namespace=@namespace, options={}, &block) block ||= Proc.new {} task = Capistrano::TaskDefinition.new(name, namespace, options, &block) assert_equal block, task.body