Skip to content

Commit

Permalink
document Net::SSH::Test::Channel. Refactor Test::Extensions to use mo…
Browse files Browse the repository at this point in the history
…dules rather than direct monkeypatching, and add documentation.
  • Loading branch information
jamis committed Mar 22, 2008
1 parent 1be2180 commit c3e39e6
Show file tree
Hide file tree
Showing 2 changed files with 197 additions and 66 deletions.
70 changes: 69 additions & 1 deletion lib/net/ssh/test/channel.rb
@@ -1,58 +1,126 @@
module Net; module SSH; module Test

# A mock channel, used for scripting actions in tests. It wraps a
# Net::SSH::Test::Script instance, and delegates to it for the most part.
# This class has little real functionality on its own, but rather acts as
# a convenience for scripting channel-related activity for later comparison
# in a unit test.
#
# story do |session|
# channel = session.opens_channel
# channel.sends_exec "ls"
# channel.gets_data "result of ls"
# channel.gets_close
# channel.sends_close
# end
class Channel
# The Net::SSH::Test::Script instance employed by this mock channel.
attr_reader :script
attr_writer :local_id, :remote_id

# Sets the local-id of this channel object (the id assigned by the client).
attr_writer :local_id

# Sets the remote-id of this channel object (the id assigned by the mock-server).
attr_writer :remote_id

# Creates a new Test::Channel instance on top of the given +script+ (which
# must be a Net::SSH::Test::Script instance).
def initialize(script)
@script = script
@local_id = @remote_id = nil
end

# Returns the local (client-assigned) id for this channel, or a Proc object
# that will return the local-id later if the local id has not yet been set.
# (See Net::SSH::Test::Packet#instantiate!.)
def local_id
@local_id || Proc.new { @local_id or raise "local-id has not been set yet!" }
end

# Returns the remote (server-assigned) id for this channel, or a Proc object
# that will return the remote-id later if the remote id has not yet been set.
# (See Net::SSH::Test::Packet#instantiate!.)
def remote_id
@remote_id || Proc.new { @remote_id or raise "remote-id has not been set yet!" }
end

# Because adjacent calls to #gets_data will sometimes cause the data packets
# to be concatenated (causing expectations in tests to fail), you may
# need to separate those calls with calls to #inject_remote_delay! (which
# essentially just mimics receiving an empty data packet):
#
# channel.gets_data "abcdefg"
# channel.inject_remote_delay!
# channel.gets_data "hijklmn"
def inject_remote_delay!
gets_data("")
end

# Scripts the sending of an "exec" channel request packet to the mock
# server. If +reply+ is true, then the server is expected to reply to the
# request, otherwise no response to this request will be sent. If +success+
# is +true+, then the request will be successful, otherwise a failure will
# be scripted.
#
# channel.sends_exec "ls -l"
def sends_exec(command, reply=true, success=true)
script.sends_channel_request(self, "exec", reply, command, success)
end

# Scripts the sending of a "subsystem" channel request packet to the mock
# server. See #sends_exec for a discussion of the meaning of the +reply+
# and +success+ arguments.
#
# channel.sends_subsystem "sftp"
def sends_subsystem(subsystem, reply=true, success=true)
script.sends_channel_request(self, "subsystem", reply, subsystem, success)
end

# Scripts the sending of a data packet across the channel.
#
# channel.sends_data "foo"
def sends_data(data)
script.sends_channel_data(self, data)
end

# Scripts the sending of an EOF packet across the channel.
#
# channel.sends_eof
def sends_eof
script.sends_channel_eof(self)
end

# Scripts the sending of a "channel close" packet across the channel.
#
# channel.sends_close
def sends_close
script.sends_channel_close(self)
end

# Scripts the reception of a channel data packet from the remote end.
#
# channel.gets_data "bar"
def gets_data(data)
script.gets_channel_data(self, data)
end

# Scripts the reception of an "exit-status" channel request packet.
#
# channel.gets_exit_status(127)
def gets_exit_status(status=0)
script.gets_channel_request(self, "exit-status", false, status)
end

# Scripts the reception of an EOF packet from the remote end.
#
# channel.gets_eof
def gets_eof
script.gets_channel_eof(self)
end

# Scripts the reception of a "channel close" packet from the remote end.
#
# channel.gets_close
def gets_close
script.gets_channel_close(self)
end
Expand Down
193 changes: 128 additions & 65 deletions lib/net/ssh/test/extensions.rb
Expand Up @@ -6,84 +6,147 @@
require 'net/ssh/transport/constants'
require 'net/ssh/transport/packet_stream'

module Net::SSH::BufferedIo
def select_for_read?
pos < size
end
module Net; module SSH; module Test

# A collection of modules used to extend/override the default behavior of
# Net::SSH internals for ease of testing. As a consumer of Net::SSH, you'll
# never need to use this directly--they're all used under the covers by
# the Net::SSH::Test system.
module Extensions

# An extension to Net::SSH::BufferedIo (assumes that the underlying IO
# is actually a StringIO). Facilitates unit testing.
module BufferedIo
# Returns +true+ if the position in the stream is less than the total
# length of the stream.
def select_for_read?
pos < size
end

attr_accessor :select_for_write, :select_for_error
alias select_for_write? select_for_write
alias select_for_error? select_for_error
end
# Set this to +true+ if you want the IO to pretend to be available for writing
attr_accessor :select_for_write

# Set this to +true+ if you want the IO to pretend to be in an error state
attr_accessor :select_for_error

module Net::SSH::Transport::PacketStream
include Net::SSH::Connection::Constants
include Net::SSH::Transport::Constants
alias select_for_write? select_for_write
alias select_for_error? select_for_error
end

MAP = Hash.new { |h,k| const_get(k.to_s.upcase) }
# An extension to Net::SSH::Transport::PacketStream (assumes that the
# underlying IO is actually a StringIO). Facilitates unit testing.
module PacketStream
include BufferedIo # make sure we get the extensions here, too

def idle!
return false unless script.next(:first)
def self.included(base) #:nodoc:
base.send :alias_method, :real_available_for_read?, :available_for_read?
base.send :alias_method, :available_for_read?, :test_available_for_read?

if script.next(:first).remote?
self.string << script.next.to_s
self.pos = pos
end
base.send :alias_method, :real_enqueue_packet, :enqueue_packet
base.send :alias_method, :enqueue_packet, :test_enqueue_packet

return true
end
base.send :alias_method, :real_poll_next_packet, :poll_next_packet
base.send :alias_method, :poll_next_packet, :test_poll_next_packet
end

alias real_available_for_read? available_for_read?
def available_for_read?
return true if select_for_read?
idle!
false
end
# Called when another packet should be inspected from the current
# script. If the next packet is a remote packet, it pops it off the
# script and shoves it onto this IO object, making it available to
# be read.
def idle!
return false unless script.next(:first)

alias real_enqueue_packet enqueue_packet
def enqueue_packet(payload)
packet = Net::SSH::Buffer.new(payload.to_s)
script.process(packet)
end
if script.next(:first).remote?
self.string << script.next.to_s
self.pos = pos
end

alias real_poll_next_packet poll_next_packet
def poll_next_packet
return nil if available <= 0
packet = Net::SSH::Buffer.new(read_available(4))
length = packet.read_long
Net::SSH::Packet.new(read_available(length))
end
end

class Net::SSH::Connection::Channel
alias original_send_data send_data
def send_data(data)
original_send_data(data)
# force each packet of sent data to be enqueued separately, so that
# scripted sends are properly interpreted.
enqueue_pending_output
end
end

class IO
class <<self
alias real_select select
def select(readers=nil, writers=nil, errors=nil, wait=nil)
ready_readers = Array(readers).select { |r| r.select_for_read? }
ready_writers = Array(writers).select { |r| r.select_for_write? }
ready_errors = Array(errors).select { |r| r.select_for_error? }

if ready_readers.any? || ready_writers.any? || ready_errors.any?
return [ready_readers, ready_writers, ready_errors]
return true
end

processed = 0
Array(readers).each do |reader|
processed += 1 if reader.idle!
# The testing version of Net::SSH::Transport::PacketStream#available_for_read?.
# Returns true if there is data pending to be read. Otherwise calls #idle!.
def test_available_for_read?
return true if select_for_read?
idle!
false
end

raise "no readers were ready for reading, and none had any incoming packets" if processed == 0
# The testing version of Net::SSH::Transport::PacketStream#enqueued_packet.
# Simply calls Net::SSH::Test::Script#process on the packet.
def test_enqueue_packet(payload)
packet = Net::SSH::Buffer.new(payload.to_s)
script.process(packet)
end

# The testing version of Net::SSH::Transport::PacketStream#poll_next_packet.
# Reads the next available packet from the IO object and returns it.
def test_poll_next_packet
return nil if available <= 0
packet = Net::SSH::Buffer.new(read_available(4))
length = packet.read_long
Net::SSH::Packet.new(read_available(length))
end
end

# An extension to Net::SSH::Connection::Channel. Facilitates unit testing.
module Channel
def self.included(base) #:nodoc:
base.send :alias_method, :send_data_for_real, :send_data
base.send :alias_method, :send_data, :send_data_for_test
end

# The testing version of Net::SSH::Connection::Channel#send_data. Calls
# the original implementation, and then immediately enqueues the data for
# output so that scripted sends are properly interpreted as discrete
# (rather than concatenated) data packets.
def send_data_for_test(data)
send_data_for_real(data)
enqueue_pending_output
end
end

# An extension to the built-in ::IO class. Simply redefines IO.select
# so that it can be scripted in Net::SSH unit tests.
module IO
def self.included(base) #:nodoc:
base.extend(ClassMethods)
end

module ClassMethods
def self.extended(obj) #:nodoc:
class <<obj
alias_method :select_for_real, :select
alias_method :select, :select_for_test
end
end

# The testing version of ::IO.select. Assumes that all readers,
# writers, and errors arrays are either nil, or contain only objects
# that mix in Net::SSH::Test::Extensions::BufferedIo.
def select_for_test(readers=nil, writers=nil, errors=nil, wait=nil)
ready_readers = Array(readers).select { |r| r.select_for_read? }
ready_writers = Array(writers).select { |r| r.select_for_write? }
ready_errors = Array(errors).select { |r| r.select_for_error? }

if ready_readers.any? || ready_writers.any? || ready_errors.any?
return [ready_readers, ready_writers, ready_errors]
end

processed = 0
Array(readers).each do |reader|
processed += 1 if reader.idle!
end

raise "no readers were ready for reading, and none had any incoming packets" if processed == 0
end
end
end
end
end

end; end; end

Net::SSH::BufferedIo.send(:include, Net::SSH::Test::Extensions::BufferedIo)
Net::SSH::Transport::PacketStream.send(:include, Net::SSH::Test::Extensions::PacketStream)
Net::SSH::Connection::Channel.send(:include, Net::SSH::Test::Extensions::Channel)
IO.send(:include, Net::SSH::Test::Extensions::IO)

0 comments on commit c3e39e6

Please sign in to comment.