shell_test 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,3 +1,10 @@
1
+ == 0.3.0 2011/10/23
2
+
3
+ Rewrite and expansion of library to use PTY shell sessions rather than
4
+ individual system commands to test scripts. This marks a significant break in
5
+ functionality. Per-command exit status tests are no longer possible, but
6
+ testing scripts with state is now possible.
7
+
1
8
  == 0.2.0 2011/08/03
2
9
 
3
10
  * Split out a StringMethods module [issue #7]
@@ -28,43 +28,33 @@ may be used independently, but by including ShellTest you get them all:
28
28
  assert_script %{
29
29
  $ sh '#{script}' moon
30
30
  goodnight moon
31
- }
31
+ }, :exitstatus => 0
32
32
  end
33
33
  end
34
34
 
35
35
  ==== {ShellMethods}[link:classes/ShellTest/ShellMethods.html]
36
36
 
37
37
  Provides the shell testing methods. These methods are designed to input a
38
- string that looks like terminal input/output. Commands are parsed out of the
39
- string, run, and then anything printed to stdout is compared to the expected
40
- output. In addition the exit status is checked for success (0).
41
-
42
- Special comments following the first line of a command can turn off
43
- output/status checking, or specify a different exit status to expect.
38
+ string that looks like terminal input/output. Indeed with PS1 and PS2 set to
39
+ their POSIX defaults, assert_script tests can be created by directly copying
40
+ from the terminal.
44
41
 
45
42
  require 'shell_test/unit'
46
-
47
43
  class ShellMethodsExample < Test::Unit::TestCase
48
44
  include ShellTest::ShellMethods
49
-
50
- def test_a_script_using_variables
51
- with_env("THING" => "moon") do
45
+
46
+ def test_a_script_with_env_variables
47
+ with_env("THIS" => "moon") do
52
48
  assert_script %{
53
- $ echo "goodnight $THING"
49
+ $ THAT="boat"
50
+ $ echo "goodnight $THIS"
54
51
  goodnight moon
52
+ $ echo "goodnight $THAT"
53
+ goodnight boat
55
54
  }
56
55
  end
57
56
  end
58
-
59
- def test_multiple_commands
60
- assert_script %{
61
- $ echo one
62
- one
63
- $ echo two
64
- two
65
- }
66
- end
67
-
57
+
68
58
  def test_multiline_commands
69
59
  assert_script %{
70
60
  $ for n in one two; do
@@ -74,28 +64,35 @@ output/status checking, or specify a different exit status to expect.
74
64
  two
75
65
  }
76
66
  end
77
-
78
- def test_exit_statuses
79
- assert_script %{
80
- $ true # [0]
81
- $ false # [1]
82
- }
83
- end
84
-
85
- def test_exit_status_and_not_ouptut
67
+
68
+ def test_script_with_overall_and_per_command_timeouts
86
69
  assert_script %{
87
- $ date # [0] ...
88
- }
70
+ $ sleep 0.1 # [0.5]
71
+ $ sleep 0.1 # [0.5]
72
+ }, :max_run_time => 1
89
73
  end
90
-
91
- def test_output_with_inline_regexps
74
+
75
+ def test_scripts_where_the_output_is_variable
92
76
  assert_script_match %{
93
77
  $ cal
94
78
  :...:
95
- Su Mo Tu We Th Fr Sa
96
- :...:
79
+ Su Mo Tu We Th Fr Sa:. *.:
80
+ :....:
97
81
  }
98
82
  end
83
+
84
+ def test_scripts_that_take_input
85
+ assert_script %{
86
+ $ sudo echo 'sorry i cant do that dave'
87
+ Password:{{notIt}}
88
+ Sorry, try again.
89
+ Password:{{mayBeThis}}
90
+ Sorry, try again.
91
+ Password:{{cr@pWhatIsIt}}
92
+ Sorry, try again.
93
+ sudo: 3 incorrect password attempts
94
+ }, :max_run_time => 10
95
+ end
99
96
  end
100
97
 
101
98
  ==== {FileMethods}[link:classes/ShellTest/FileMethods.html]
@@ -130,17 +127,11 @@ ShellTest is available as a gem[http://rubygems.org/gems/shell_test].
130
127
 
131
128
  To get started, checkout the code from GitHub[http://github.com/thinkerbot/shell_test] and run:
132
129
 
133
- git clone git://github.com/thinkerbot/shell_test.git
134
- cd shell_test
135
130
  rake test
136
131
 
137
- To test against multiple platforms I suggest using rvm. In that case:
138
-
139
- rvm rake test
140
-
141
132
  Please report any issues {here}[http://github.com/thinkerbot/shell_test/issues].
142
133
 
143
134
  == Info
144
135
 
145
- Developer:: {Simon Chiang}[http://thinkerbot.posterous.com]
136
+ Developer:: {Simon Chiang}[http://github.com/thinkerbot]
146
137
  License:: {MIT-Style}[link:files/MIT-LICENSE.html]
@@ -0,0 +1,44 @@
1
+ module ShellTest
2
+ module EnvMethods
3
+ module_function
4
+
5
+ # Sets the specified ENV variables and returns the *current* env.
6
+ # If replace is true, current ENV variables are replaced; otherwise
7
+ # the new env variables are simply added to the existing set.
8
+ def set_env(env={}, replace=false)
9
+ current_env = {}
10
+ ENV.each_pair do |key, value|
11
+ current_env[key] = value
12
+ end
13
+
14
+ ENV.clear if replace
15
+
16
+ env.each_pair do |key, value|
17
+ if value.nil?
18
+ ENV.delete(key)
19
+ else
20
+ ENV[key] = value
21
+ end
22
+ end if env
23
+
24
+ current_env
25
+ end
26
+
27
+ # Sets the specified ENV variables for the duration of the block.
28
+ # If replace is true, current ENV variables are replaced; otherwise
29
+ # the new env variables are simply added to the existing set.
30
+ #
31
+ # Returns the block return.
32
+ def with_env(env={}, replace=false)
33
+ current_env = nil
34
+ begin
35
+ current_env = set_env(env, replace)
36
+ yield
37
+ ensure
38
+ if current_env
39
+ set_env(current_env, true)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -128,10 +128,14 @@ module ShellTest
128
128
  include StringMethods
129
129
  extend ModuleMethods
130
130
 
131
+ # Returns the absolute path to the current working directory.
132
+ attr_reader :user_dir
133
+
131
134
  # Calls cleanup to remove any files left over from previous test runs (for
132
135
  # instance by running with a flag to keep outputs).
133
136
  def setup
134
137
  super
138
+ @user_dir = Dir.pwd
135
139
  cleanup
136
140
  end
137
141
 
@@ -161,11 +165,6 @@ module ShellTest
161
165
  ENV["KEEP_OUTPUTS"] == "true"
162
166
  end
163
167
 
164
- # Returns the absolute path to the current working directory.
165
- def user_dir
166
- @user_dir ||= File.expand_path('.')
167
- end
168
-
169
168
  # Returns the absolute path to a directory specific to the current test
170
169
  # class, specifically the class.class_dir expanded relative to the
171
170
  # user_dir.
@@ -211,7 +210,9 @@ module ShellTest
211
210
  # Creates a directory under method_dir.
212
211
  def prepare_dir(relative_path)
213
212
  target_dir = path(relative_path)
214
- FileUtils.mkdir_p(target_dir)
213
+ unless File.directory?(target_dir)
214
+ FileUtils.mkdir_p(target_dir)
215
+ end
215
216
  target_dir
216
217
  end
217
218
 
@@ -223,7 +224,7 @@ module ShellTest
223
224
  FileUtils.rm(target)
224
225
  else
225
226
  target_dir = File.dirname(target)
226
- FileUtils.mkdir_p(target_dir)
227
+ FileUtils.mkdir_p(target_dir) unless File.exists?(target_dir)
227
228
  end
228
229
 
229
230
  FileUtils.touch(target)
@@ -1,23 +1,25 @@
1
1
  module ShellTest
2
2
  # RegexpEscape is a subclass of regexp that escapes all but the text in a
3
- # special escape sequence. This allows the creation of complex regexps
4
- # to match, for instance, console output.
3
+ # special escape sequence. This allows the creation of complex regexps to
4
+ # match, for instance, console output.
5
5
  #
6
- # The RegexpEscape.escape (or equivalently the quote) method does the
7
- # work; all regexp-active characters are escaped except for characters
8
- # enclosed by ':.' and '.:' delimiters.
6
+ # The RegexpEscape.escape (or equivalently the quote) method does the work;
7
+ # all regexp-active characters are escaped except for characters enclosed by
8
+ # ':.' and '.:' delimiters.
9
9
  #
10
10
  # RegexpEscape.escape('reg[exp]+ chars. are(quoted)') # => 'reg\[exp\]\+\ chars\.\ are\(quoted\)'
11
11
  # RegexpEscape.escape('these are not: :.a(b*)c.:') # => 'these\ are\ not:\ a(b*)c'
12
12
  #
13
- # In addition, all-period regexps are automatically upgraded to '.*?';
14
- # use the '.{n}' notation to specify n arbitrary characters.
13
+ # All-period regexps are treated specially. A single period is translated
14
+ # to '.*?' to lazily match anything on a single line. Multiple periods are
15
+ # translated to '(?:(?m).*?)' to lazily match anything actoss multiple
16
+ # lines. Use the '.{n}' notation to specify n arbitrary characters.
15
17
  #
16
- # RegexpEscape.escape('_:..:_:...:_:....:') # => '_.*?_.*?_.*?'
17
- # RegexpEscape.escape(':..{1}.:') # => '.{1}'
18
+ # RegexpEscape.escape('a:...:b:....:c') # => 'a.*?b(?:(?m).*?)c'
19
+ # RegexpEscape.escape('a:..{1}.:b') # => 'a.{1}b'
18
20
  #
19
- # RegexpEscape instances are initialized using the escaped input string
20
- # and return the original string upon to_s.
21
+ # RegexpEscape instances are initialized using the escaped input string and
22
+ # return the original string upon to_s.
21
23
  #
22
24
  # str = %q{
23
25
  # a multiline
@@ -50,8 +52,14 @@ module ShellTest
50
52
  substituents = []
51
53
  str.scan(ESCAPE_SEQUENCE) do
52
54
  regexp_str = $&[2...-2]
53
- regexp_str = ".*?" if regexp_str =~ /^\.*$/
54
- substituents << regexp_str
55
+ substituents << case regexp_str
56
+ when '.'
57
+ ".*?"
58
+ when /\A\.+\z/
59
+ "(?:(?m).*?)"
60
+ else
61
+ regexp_str
62
+ end
55
63
  end
56
64
  substituents << ""
57
65
 
@@ -1,84 +1,20 @@
1
1
  require 'shell_test/regexp_escape'
2
- require 'shell_test/command_parser'
3
2
  require 'shell_test/string_methods'
3
+ require 'shell_test/shell_methods/session'
4
4
 
5
5
  module ShellTest
6
6
  module ShellMethods
7
7
  include StringMethods
8
+ include EnvMethods
8
9
 
9
- def setup
10
- super
11
- @notify_method_name = true
10
+ def pty(script, options={}, &block)
11
+ _pty outdent(script), options, &block
12
12
  end
13
13
 
14
- # Parse a script into an array of [cmd, output, status] triplets.
15
- def parse_script(script, options={})
16
- CommandParser.new(options).parse(script)
17
- end
18
-
19
- # Returns true if the ENV variable 'VERBOSE' is true. When verbose,
20
- # ShellTest prints the expanded commands of sh_test to $stdout.
21
- def verbose?
22
- verbose = ENV['VERBOSE']
23
- verbose && verbose =~ /^true$/i ? true : false
24
- end
25
-
26
- # Sets the specified ENV variables and returns the *current* env.
27
- # If replace is true, current ENV variables are replaced; otherwise
28
- # the new env variables are simply added to the existing set.
29
- def set_env(env={}, replace=false)
30
- current_env = {}
31
- ENV.each_pair do |key, value|
32
- current_env[key] = value
33
- end
34
-
35
- ENV.clear if replace
36
-
37
- env.each_pair do |key, value|
38
- if value.nil?
39
- ENV.delete(key)
40
- else
41
- ENV[key] = value
42
- end
43
- end if env
44
-
45
- current_env
46
- end
47
-
48
- # Sets the specified ENV variables for the duration of the block.
49
- # If replace is true, current ENV variables are replaced; otherwise
50
- # the new env variables are simply added to the existing set.
51
- #
52
- # Returns the block return.
53
- def with_env(env={}, replace=false)
54
- current_env = nil
55
- begin
56
- current_env = set_env(env, replace)
57
- yield
58
- ensure
59
- if current_env
60
- set_env(current_env, true)
61
- end
62
- end
63
- end
64
-
65
- def sh(cmd)
66
- if @notify_method_name && verbose?
67
- @notify_method_name = false
68
- puts
69
- puts method_name
70
- end
71
-
72
- start = Time.now
73
- result = `#{cmd}`
74
- finish = Time.now
75
-
76
- if verbose?
77
- elapsed = "%.3f" % [finish-start]
78
- puts " (#{elapsed}s) #{cmd}"
79
- end
80
-
81
- result
14
+ def _pty(script, options={}, &block)
15
+ session = Session.new(options)
16
+ session.parse(script, options, &block)
17
+ session.run
82
18
  end
83
19
 
84
20
  def assert_script(script, options={})
@@ -86,11 +22,26 @@ module ShellTest
86
22
  end
87
23
 
88
24
  def _assert_script(script, options={})
89
- parse_script(script, options).each do |cmd, output, status|
90
- result = sh(cmd)
25
+ pty = _pty(script, options) do |session, expected, actual|
26
+ expected = expand_ctrl_chars(expected)
27
+ actual = expand_ctrl_chars(actual)
28
+
29
+ _assert_str_equal expected, actual do
30
+ session.summary %Q{
31
+ %s (elapsed: %.2fs max: %.2fs)
32
+ =========================================================
33
+ %s
34
+ -------------------- expected output --------------------
35
+ #{whitespace_escape(expected)}
36
+ ------------------------ but was ------------------------
37
+ #{whitespace_escape(actual)}
38
+ =========================================================
39
+ }
40
+ end
41
+ end
91
42
 
92
- _assert_str_equal(output, result, cmd) if output
93
- assert_equal(status, $?.exitstatus, cmd) if status
43
+ if status = options[:exitstatus]
44
+ assert_equal(status, pty.status.exitstatus)
94
45
  end
95
46
  end
96
47
 
@@ -99,11 +50,26 @@ module ShellTest
99
50
  end
100
51
 
101
52
  def _assert_script_match(script, options={})
102
- parse_script(script, options).each do |cmd, output, status|
103
- result = sh(cmd)
53
+ pty = _pty(script, options) do |session, expected, actual|
54
+ expected = expand_ctrl_chars(expected)
55
+ actual = expand_ctrl_chars(actual)
56
+
57
+ _assert_str_match expected, actual do
58
+ session.summary %Q{
59
+ %s (%.2f:%.2fs)
60
+ =========================================================
61
+ %s
62
+ ----------------- expected output like ------------------
63
+ #{whitespace_escape(expected)}
64
+ ------------------------ but was ------------------------
65
+ #{whitespace_escape(actual)}
66
+ =========================================================
67
+ }
68
+ end
69
+ end
104
70
 
105
- _assert_str_match(output, result, cmd) if output
106
- assert_equal(status, $?.exitstatus, cmd) if status
71
+ if status = options[:exitstatus]
72
+ assert_equal(status, pty.status.exitstatus)
107
73
  end
108
74
  end
109
75
  end
@@ -0,0 +1,135 @@
1
+ require 'shell_test/shell_methods/timer'
2
+
3
+ module ShellTest
4
+ module ShellMethods
5
+ # Agent wraps a PTY master-slave pair and provides methods for doing reads
6
+ # and writes. The expect method is inspired by the IO.expect method in the
7
+ # stdlib, but tailored to allow for better timeout control.
8
+ class Agent
9
+
10
+ # The pty master
11
+ attr_reader :master
12
+
13
+ # The pty slave
14
+ attr_reader :slave
15
+
16
+ # The timer managing read timeouts. The timer ensures that the timeouts
17
+ # used by expect are never negative, or nil to indicate no timeout.
18
+ #
19
+ # If implementing a custom timer, note that timeouts are set on the
20
+ # timer using `timer.timeout=` and retrieved via `timer.timeout`.
21
+ attr_reader :timer
22
+
23
+ # A hash of [name, regexp] pairs mapping logical names to prompts.
24
+ attr_reader :prompts
25
+
26
+ def initialize(master, slave, timer, prompts={})
27
+ @master = master
28
+ @slave = slave
29
+ @timer = timer
30
+ @prompts = prompts
31
+ end
32
+
33
+ def on(prompt, input, timeout=nil)
34
+ output = expect(prompt, timeout)
35
+ write(input)
36
+ output
37
+ end
38
+
39
+ # Reads from the slave until the prompt (a regexp) is matched and
40
+ # returns the resulting string. If a nil prompt is given then expect
41
+ # reads until the slave eof.
42
+ #
43
+ # A timeout may be given. If the slave doesn't produce the expected
44
+ # string within the timeout then expect raises a ReadError. A ReadError
45
+ # will be also be raised if the slave eof is reached before the prompt
46
+ # matches.
47
+ def expect(prompt, timeout=nil)
48
+ regexp = prompts[prompt] || prompt
49
+ timer.timeout = timeout
50
+
51
+ buffer = ''
52
+ while true
53
+ timeout = timer.timeout
54
+
55
+ # Use read+select instead of read_nonblock to avoid polling in a
56
+ # tight loop. Don't bother with readpartial and partial lengths. It
57
+ # would be an optimization, especially because the regexp matches
58
+ # each loop, but adds complexity because expect could read past the
59
+ # end of the regexp and it is unlikely to be necessary in test
60
+ # scenarios (ie this is not meant to be a general solution).
61
+ unless IO.select([slave],nil,nil,timeout)
62
+ msg = "timeout waiting for %s after %.2fs" % [regexp ? regexp.inspect : 'EOF', timeout]
63
+ raise ReadError.new(msg, buffer)
64
+ end
65
+
66
+ begin
67
+ c = slave.read(1)
68
+ rescue Errno::EIO
69
+ # On some linux (ex ubuntu) read can return an eof or fail with
70
+ # an EIO error when a terminal disconnect occurs and an EIO
71
+ # condition occurs - the exact behavior is unspecified but the
72
+ # meaning is the same... no more data is available, so break.
73
+ c = nil
74
+ end
75
+
76
+ if c.nil?
77
+ if regexp.nil?
78
+ break
79
+ else
80
+ raise ReadError.new("end of file reached", buffer)
81
+ end
82
+ end
83
+
84
+ buffer << c
85
+
86
+ if regexp && buffer =~ regexp
87
+ break
88
+ end
89
+ end
90
+ buffer
91
+ end
92
+
93
+ # Read to the end of the slave. Raises a ReadError if the slave eof is
94
+ # not reached within the timeout.
95
+ def read(timeout=nil)
96
+ expect nil, timeout
97
+ end
98
+
99
+ # Writes to the master. A timeout may be given. If the master doesn't
100
+ # become available for writing within the timeout then write raises an
101
+ # WriteError.
102
+ def write(input, timeout=nil)
103
+ unless IO.select(nil,[master],nil,timeout)
104
+ raise WriteError.new("timeout waiting for master")
105
+ end
106
+ master.print input
107
+ end
108
+
109
+ # Closes the master and slave.
110
+ def close
111
+ unless master.closed?
112
+ master.close
113
+ end
114
+ unless slave.closed?
115
+ slave.close
116
+ end
117
+ end
118
+
119
+ # Raised when Agent writes timeout.
120
+ class WriteError < RuntimeError
121
+ end
122
+
123
+ # Raised when Agent reads fail or timeout.
124
+ class ReadError < RuntimeError
125
+ # The buffer containing whatever had been read at the time of error.
126
+ attr_reader :buffer
127
+
128
+ def initialize(message, buffer)
129
+ @buffer = buffer
130
+ super message
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,293 @@
1
+ require 'shell_test/env_methods'
2
+ require 'shell_test/shell_methods/agent'
3
+ require 'shell_test/shell_methods/utils'
4
+ require 'strscan'
5
+
6
+ module ShellTest
7
+ module ShellMethods
8
+
9
+ # Session is an engine for running shell sessions.
10
+ class Session
11
+ include EnvMethods
12
+ include Utils
13
+
14
+ DEFAULT_SHELL = '/bin/sh'
15
+ DEFAULT_PS1 = '$ '
16
+ DEFAULT_PS2 = '> '
17
+ DEFAULT_STTY = '-echo -onlcr'
18
+ DEFAULT_MAX_RUN_TIME = 1
19
+
20
+ # The session shell
21
+ attr_reader :shell
22
+
23
+ # The shell PS1
24
+ attr_reader :ps1
25
+
26
+ # The shell PS2
27
+ attr_reader :ps2
28
+
29
+ # Aguments string passed stty on run
30
+ attr_reader :stty
31
+
32
+ # The session timer, used by agents to determine timeouts
33
+ attr_reader :timer
34
+
35
+ # The maximum run time for the session
36
+ attr_reader :max_run_time
37
+
38
+ # An array of entries like [prompt, input, max_run_time, callback] that
39
+ # indicate each step of a session. See the on method for adding steps.
40
+ attr_reader :steps
41
+
42
+ # A log of the output at each step (set during run)
43
+ attr_reader :log
44
+
45
+ # A Process::Status for the session (set by run)
46
+ attr_reader :status
47
+
48
+ def initialize(options={})
49
+ @shell = options[:shell] || DEFAULT_SHELL
50
+ @ps1 = options[:ps1] || DEFAULT_PS1
51
+ @ps2 = options[:ps2] || DEFAULT_PS2
52
+ @stty = options[:stty] || DEFAULT_STTY
53
+ @timer = options[:timer] || Timer.new
54
+ @max_run_time = options[:max_run_time] || DEFAULT_MAX_RUN_TIME
55
+ @steps = [[nil, nil, nil, nil]]
56
+ @log = []
57
+ @status = nil
58
+ @prompts = {
59
+ :ps1 => /#{Regexp.escape(ps1)}/,
60
+ :ps2 => /#{Regexp.escape(ps2)}/
61
+ }
62
+ end
63
+
64
+ # Define a step. At each step:
65
+ #
66
+ # 1. The session waits until the prompt is matched
67
+ # 2. The input is written to the shell (if given)
68
+ # 3. The output passed to the callback (if given)
69
+ #
70
+ # If the next prompt (or an EOF if there is no next prompt) is not
71
+ # reached within max_run_time then a ReadError occurs. Special
72
+ # considerations:
73
+ #
74
+ # * The prompt should be a regular expression, :ps1, or :ps2.
75
+ # * A nil max_run_time indicates no maximum run time - which more
76
+ # accurately means the input can go until the overall max_run_time for
77
+ # the session runs out.
78
+ # * The output passed to the callback will include the string matched by
79
+ # the next prompt, if present.
80
+ #
81
+ # Returns self.
82
+ def on(prompt, input=nil, max_run_time=nil, &callback) # :yields: output
83
+ if prompt.nil?
84
+ raise ArgumentError, "no prompt specified"
85
+ end
86
+
87
+ # Stagger assignment of step arguments so that the callback will be
88
+ # recieve the output of the input. Not only is this more intuitive, it
89
+ # ensures the last step will read to EOF (which expedites waiting on
90
+ # the session to terminate).
91
+ last = steps.last
92
+ last[0] = prompt
93
+ last[1] = input
94
+ last[2] = max_run_time
95
+
96
+ steps << [nil, nil, nil, callback]
97
+ self
98
+ end
99
+
100
+ def split(str)
101
+ scanner = StringScanner.new(str)
102
+ scanner.scan_until(/(#{@prompts[:ps1]})/)
103
+ scanner.pos -= scanner[1].to_s.length
104
+
105
+ args = []
106
+ while output = scanner.scan_until(/(#{@prompts[:ps1]}|#{@prompts[:ps2]}|\{\{(.*?)\}\})/)
107
+ match = scanner[1]
108
+ input = scanner[2] ? "#{scanner[2]}\n" : scanner.scan_until(/\n/)
109
+
110
+ max_run_time = -1
111
+ input.sub!(/\#\s*\[(\d+(?:\.\d+)?)\].*$/) do
112
+ max_run_time = $1.to_f
113
+ nil
114
+ end
115
+
116
+ case match
117
+ when ps1
118
+ prompt = :ps1
119
+ if max_run_time == -1
120
+ max_run_time = nil
121
+ end
122
+ when ps2
123
+ prompt = :ps2
124
+ else
125
+ output = output.chomp(match)
126
+ prompt = /^#{output.split("\n").last}\z/
127
+ end
128
+
129
+ args << output
130
+ args << prompt
131
+ args << input
132
+ args << max_run_time
133
+ end
134
+
135
+ args << scanner.rest
136
+ args
137
+ end
138
+
139
+ # Parses a terminal snippet into steps that a Session can run, and adds
140
+ # those steps to self. The snippet should utilize ps1 and ps2 as set on
141
+ # self. An exit command is added unless the :noexit option is set to
142
+ # true.
143
+ #
144
+ # session = Session.new
145
+ # session.parse %{
146
+ # $ echo abc
147
+ # abc
148
+ # }
149
+ # session.run.result # => "$ echo abc\nabc\n$ exit\nexit\n"
150
+ #
151
+ # Steps are registered with a callback block, if given, to recieve the
152
+ # expected and actual outputs during run. Normally the callback is used
153
+ # to validate that the run is going as planned.
154
+ def parse(script, options={}, &block)
155
+ args = split(script)
156
+ args.shift # ignore script before first prompt
157
+
158
+ if options[:noexit]
159
+ args.pop
160
+ else
161
+ args.last << ps1
162
+ args.concat [:ps1, "exit\n", nil, nil]
163
+ end
164
+
165
+ while !args.empty?
166
+ prompt = args.shift
167
+ input = args.shift
168
+ max_run_time = args.shift
169
+ output = args.shift
170
+ callback = make_callback(output, &block)
171
+
172
+ on(prompt, input, max_run_time, &callback)
173
+ end
174
+
175
+ self
176
+ end
177
+
178
+ # Spawns a PTY shell session and yields an Agent to the block. The
179
+ # session is logged to log and the final exit status set into status
180
+ # (any previous values are overwritten).
181
+ #
182
+ # ==== ENV variables
183
+ #
184
+ # PS1 and PS2 are set into ENV for the duration of the block and so in
185
+ # most cases the shell inherits those values. Keep in mind, however,
186
+ # that the shell config scripts can set these variables and on some
187
+ # distributions (ex SLES 10) the config script do not respect prior
188
+ # values.
189
+ #
190
+ def spawn
191
+ with_env('PS1' => ps1, 'PS2' => ps2) do
192
+ @log = []
193
+ @status = super(shell) do |master, slave|
194
+ agent = Agent.new(master, slave, timer, @prompts)
195
+ timer.start(max_run_time)
196
+
197
+ if stty
198
+ # It would be lovely to work this into steps somehow, or to set
199
+ # the stty externally like:
200
+ #
201
+ # system("stty #{stty} < '#{master.path}'")
202
+ #
203
+ # Unfortunately the former complicates result and the latter
204
+ # doesn't work. In tests the stty settings DO get set but they
205
+ # don't refresh in the pty.
206
+ log << agent.on(:ps1, "stty #{stty}\n")
207
+ log << agent.on(:ps1, "echo $?\n")
208
+ log << agent.on(:ps1, "\n")
209
+
210
+ unless log.last == "0\n#{ps1}"
211
+ raise "stty failure\n#{summary}"
212
+ end
213
+
214
+ log.clear
215
+ end
216
+
217
+ begin
218
+ yield agent
219
+ rescue Agent::ReadError
220
+ log << $!.buffer
221
+ $!.message << "\n#{summary}"
222
+ raise
223
+ end
224
+
225
+ timer.stop
226
+ agent.close
227
+ end
228
+ end
229
+ self
230
+ end
231
+
232
+ # Runs each of steps within a shell session and collects the
233
+ # inputs/outputs into log. After run the exit status of the session is
234
+ # set into status.
235
+ def run
236
+ spawn do |agent|
237
+ timeout = nil
238
+ steps.each do |prompt, input, max_run_time, callback|
239
+ buffer = agent.expect(prompt, timeout)
240
+ log << buffer
241
+
242
+ if callback
243
+ callback.call buffer
244
+ end
245
+
246
+ if input
247
+ log << input
248
+ agent.write(input)
249
+ end
250
+
251
+ timeout = max_run_time
252
+ end
253
+ end
254
+ end
255
+
256
+ # Returns what would appear to the user at the current point in the
257
+ # session (with granularity of an input/output step).
258
+ #
259
+ # Currently result ONLY works as intended when stty is set to turn off
260
+ # input echo and output carriage returns, either with '-echo -onlcr'
261
+ # (the default) or 'raw'. Otherwise the inputs can appear twice in the
262
+ # result and there will be inconsistent end-of-lines.
263
+ def result
264
+ log.join
265
+ end
266
+
267
+ # Formats the status of self into a string. A format string can be
268
+ # provided - it is evaluated using '%' using arguments: [shell,
269
+ # elapsed_time, result]
270
+ def summary(format=nil)
271
+ (format || %Q{
272
+ %s (elapsed: %.2fs max: %.2fs)
273
+ =========================================================
274
+ %s
275
+ =========================================================
276
+ }) % [shell, timer.elapsed_time, max_run_time, result]
277
+ end
278
+
279
+ private
280
+
281
+ # helper to make a callback for validating output
282
+ def make_callback(output) # :nodoc:
283
+ if output && block_given?
284
+ lambda do |actual|
285
+ yield(self, output, actual)
286
+ end
287
+ else
288
+ nil
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,71 @@
1
+ module ShellTest
2
+ module ShellMethods
3
+ class Timer
4
+ attr_reader :clock
5
+ attr_reader :start_time
6
+ attr_reader :stop_time
7
+ attr_reader :step_time
8
+
9
+ def initialize(clock=Time)
10
+ @clock = clock
11
+ reset
12
+ end
13
+
14
+ def current_time
15
+ clock.now.to_f
16
+ end
17
+
18
+ def reset
19
+ @start_time = nil
20
+ @stop_time = nil
21
+ @step_time = 0
22
+ end
23
+
24
+ def start(max_run_time=60)
25
+ reset
26
+ @start_time = current_time
27
+ @stop_time = start_time + max_run_time
28
+ @step_time = stop_time
29
+ end
30
+
31
+ def running?
32
+ start_time.nil? || stop_time.nil? ? false : true
33
+ end
34
+
35
+ def elapsed_time
36
+ current_time - start_time
37
+ end
38
+
39
+ def stop
40
+ if running?
41
+ elapsed = elapsed_time
42
+ reset
43
+ elapsed
44
+ else
45
+ nil
46
+ end
47
+ end
48
+
49
+ def timeout=(timeout)
50
+ unless running?
51
+ raise "cannot set timeout unless running"
52
+ end
53
+
54
+ case
55
+ when timeout.nil?
56
+ @step_time = stop_time
57
+ when timeout < 0
58
+ step_time
59
+ else
60
+ mtime = current_time + timeout
61
+ @step_time = mtime > stop_time ? stop_time : mtime
62
+ end
63
+ end
64
+
65
+ def timeout
66
+ timeout = step_time - current_time
67
+ timeout < 0 ? 0 : timeout
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,53 @@
1
+ require 'pty'
2
+
3
+ module ShellTest
4
+ module ShellMethods
5
+ module Utils
6
+ module_function
7
+
8
+ # Spawns a PTY session and returns the Process::Status for the session
9
+ # upon completion. The PTY process is killed upon an unhandled error
10
+ # (but the error is re-raised for further handling).
11
+ #
12
+ # Note that $? is set by spawn but is not reliable until 1.9.2 (ish).
13
+ # Prior to that PTY used a cleanup thread that would wait on a spawned
14
+ # process and raise a PTY::ChildExited error in some cases. As a result
15
+ # manual calls to Process.wait (which would set $?) cause a race
16
+ # condition. Rely on the output of spawn instead.
17
+ def spawn(cmd)
18
+ PTY.spawn(cmd) do |slave, master, pid|
19
+ begin
20
+ yield(master, slave)
21
+ Process.wait(pid)
22
+
23
+ rescue PTY::ChildExited
24
+ # handle a ChildExited error on 1.8.6 and 1.8.7 as a 'normal' exit
25
+ # route. 1.9.2 does not exit this way.
26
+ return $!.status
27
+
28
+ rescue Exception
29
+ # cleanup the pty on error
30
+ Process.kill(9, pid)
31
+
32
+ # clearing the slave allows the wait to complete faster
33
+ while IO.select([slave],nil,nil,0.1)
34
+ begin
35
+ break unless slave.read(1)
36
+ rescue Errno::EIO
37
+ break
38
+ end
39
+ end
40
+
41
+ # any wait can cause a ChildExited error so account for that here
42
+ # - the $? is indeterminate in this case prior to 1.9.2
43
+ Process.wait(pid) rescue PTY::ChildExited
44
+
45
+ raise
46
+ end
47
+ end
48
+
49
+ $?
50
+ end
51
+ end
52
+ end
53
+ end
@@ -3,19 +3,21 @@ module ShellTest
3
3
  # Asserts whether or not the a and b strings are equal, with a more
4
4
  # readable output than assert_equal for large strings (especially large
5
5
  # strings with significant whitespace).
6
- def assert_str_equal(a, b, msg=nil)
7
- _assert_str_equal outdent(a), b, msg
6
+ def assert_str_equal(a, b, msg=nil, &block)
7
+ _assert_str_equal outdent(a), b, msg, &block
8
8
  end
9
9
 
10
+ # Same as assert_str_equal but does not outdent.
10
11
  def _assert_str_equal(a, b, msg=nil)
11
12
  if a == b
12
13
  assert true
13
14
  else
14
- flunk %Q{
15
+ flunk block_given? ? yield(a, b) : %Q{
16
+ =========================================================
15
17
  #{msg}
16
- ==================== expected output ====================
18
+ -------------------- expected output --------------------
17
19
  #{whitespace_escape(a)}
18
- ======================== but was ========================
20
+ ------------------------ but was ------------------------
19
21
  #{whitespace_escape(b)}
20
22
  =========================================================
21
23
  }
@@ -26,12 +28,13 @@ module ShellTest
26
28
  # provides a more readable output in the case of a failure as compared
27
29
  # with assert_match.
28
30
  #
29
- # If a is a string it is turned into a RegexpEscape.
30
- def assert_str_match(a, b, msg=nil)
31
+ # If a is a string then it is turned into a RegexpEscape.
32
+ def assert_str_match(a, b, msg=nil, &block)
31
33
  a = outdent(a) if a.kind_of?(String)
32
- _assert_str_match a, b, msg
34
+ _assert_str_match a, b, msg, &block
33
35
  end
34
36
 
37
+ # Same as assert_str_match but does not outdent.
35
38
  def _assert_str_match(a, b, msg=nil)
36
39
  if a.kind_of?(String)
37
40
  a = RegexpEscape.new(a)
@@ -40,25 +43,33 @@ module ShellTest
40
43
  if b =~ a
41
44
  assert true
42
45
  else
43
- flunk %Q{
46
+ flunk block_given? ? yield(a,b) : %Q{
47
+ =========================================================
44
48
  #{msg}
45
- ================= expected output like ==================
49
+ ----------------- expected output like ------------------
46
50
  #{whitespace_escape(a)}
47
- ======================== but was ========================
51
+ ------------------------ but was ------------------------
48
52
  #{whitespace_escape(b)}
49
53
  =========================================================
50
54
  }
51
55
  end
52
56
  end
53
57
 
54
- # helper for stripping indentation off a string
58
+ # Indents each line of str as specified.
59
+ def indent(str, indent=' ')
60
+ str.split("\n").collect do |frag|
61
+ "#{indent}#{frag}"
62
+ end.join("\n")
63
+ end
64
+
65
+ # Strips indentation off of the input string.
55
66
  def outdent(str)
56
67
  str =~ /\A(?:\s*?\n)( *)(.*)\z/m ? $2.gsub!(/^ {0,#{$1.length}}/, '') : str
57
68
  end
58
69
 
59
- # helper for formatting escaping whitespace into readable text
70
+ # Escapes non-printable characters into readable text.
60
71
  def whitespace_escape(str)
61
- str.to_s.gsub(/\s/) do |match|
72
+ str = str.to_s.gsub(/\s/) do |match|
62
73
  case match
63
74
  when "\n" then "\\n\n"
64
75
  when "\t" then "\\t"
@@ -67,6 +78,28 @@ module ShellTest
67
78
  else match
68
79
  end
69
80
  end
81
+ str.gsub!("\b", "\\b")
82
+ str.gsub!("\a", "\\a")
83
+ str.split("\0").join('\\0')
84
+ end
85
+
86
+ # Expands non-printable characters (ie control characters) in str to their
87
+ # common print equivalents. Specifically:
88
+ #
89
+ # ctrl char before after
90
+ # null "ab\0c" "abc"
91
+ # bell "ab\ac" "abc"
92
+ # backspace "ab\bc" "ac"
93
+ # horizonal tab "ab\tc" "ab\tc"
94
+ # line feed "ab\nc" "ab\nc"
95
+ # form feed "ab\fc" "ab\n c"
96
+ # carraige return "ab\rc" "c"
97
+ #
98
+ def expand_ctrl_chars(str)
99
+ str = str.gsub(/^.*?\r/, '')
100
+ str.gsub!(/(\A#{"\b"}|.#{"\b"}|#{"\a"}|#{"\0"})/m, '')
101
+ str.gsub!(/(^.*?)\f/) { "#{$1}\n#{' ' * $1.length}" }
102
+ str
70
103
  end
71
104
  end
72
105
  end
@@ -1,3 +1,3 @@
1
1
  module ShellTest
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shell_test
3
3
  version: !ruby/object:Gem::Version
4
- hash: 23
4
+ hash: 19
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
- - 2
8
+ - 3
9
9
  - 0
10
- version: 0.2.0
10
+ version: 0.3.0
11
11
  platform: ruby
12
12
  authors:
13
13
  - Simon Chiang
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2011-08-03 00:00:00 -06:00
18
+ date: 2011-10-23 00:00:00 -06:00
19
19
  default_executable:
20
20
  dependencies: []
21
21
 
@@ -32,10 +32,14 @@ extra_rdoc_files:
32
32
  - MIT-LICENSE
33
33
  files:
34
34
  - lib/shell_test.rb
35
- - lib/shell_test/command_parser.rb
35
+ - lib/shell_test/env_methods.rb
36
36
  - lib/shell_test/file_methods.rb
37
37
  - lib/shell_test/regexp_escape.rb
38
38
  - lib/shell_test/shell_methods.rb
39
+ - lib/shell_test/shell_methods/agent.rb
40
+ - lib/shell_test/shell_methods/session.rb
41
+ - lib/shell_test/shell_methods/timer.rb
42
+ - lib/shell_test/shell_methods/utils.rb
39
43
  - lib/shell_test/string_methods.rb
40
44
  - lib/shell_test/unit.rb
41
45
  - lib/shell_test/unit/shim.rb
@@ -45,8 +49,8 @@ files:
45
49
  - MIT-LICENSE
46
50
  has_rdoc: true
47
51
  homepage: ""
48
- licenses: []
49
-
52
+ licenses:
53
+ - MIT
50
54
  post_install_message:
51
55
  rdoc_options:
52
56
  - --main
@@ -1,67 +0,0 @@
1
- module ShellTest
2
- class CommandParser
3
- attr_reader :ps1
4
- attr_reader :ps2
5
-
6
- def initialize(options={})
7
- options = {
8
- :ps1 => '$ ',
9
- :ps2 => '> '
10
- }.merge(options)
11
-
12
- @ps1 = options[:ps1]
13
- @ps2 = options[:ps2]
14
- end
15
-
16
- def parse_cmd(cmd)
17
- cmd =~ /.*?#\s*(?:\[(\d+)\])?\s*(\.{3})?/
18
- exit_status = $1 ? $1.to_i : 0
19
- output = $2 ? nil : ""
20
-
21
- [cmd, output, exit_status]
22
- end
23
-
24
- def parse(script)
25
- commands = []
26
-
27
- command, output, exit_status = nil, "", 0
28
- script.each_line do |line|
29
- case
30
- when line.index(ps1) == 0
31
- if command
32
- commands << [command, output, exit_status]
33
- end
34
-
35
- command, output, exit_status = parse_cmd lchomp(ps1, line)
36
-
37
- when command.nil?
38
- unless line.strip.empty?
39
- command, output, exit_status = parse_cmd(line)
40
- end
41
-
42
- when line.index(ps2) == 0
43
- command << lchomp(ps2, line)
44
-
45
- when output.nil?
46
- output = line
47
-
48
- else
49
- output << line
50
- end
51
- end
52
-
53
- if command
54
- commands << [command, output, exit_status]
55
- end
56
-
57
- commands
58
- end
59
-
60
- private
61
-
62
- def lchomp(prefix, line) # :nodoc:
63
- length = prefix.length
64
- line[length, line.length - length]
65
- end
66
- end
67
- end