shell_test 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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