Video transcript & code
A few episodes back we came up with a minimal template for writing an interactive console program in Ruby. Interactive programs differ from command-line programs in that instead of performing some action and then exiting, they prompt the user for new commands until they are dismissed.
For instance, here's a simple interactive program called
shout. It just echoes back whatever we tell it at the prompt, but capitalized and with a bang on the end. It keeps this up until we tell it to quit.
while (print "> "; input = gets) do input.chomp! case input when "quit" then break else puts input.upcase + "!" end end
Let's say we'd like to do some internal redesign on this program. We always like to have a safety net when we refactor, in the form of some sanity tests that show we haven't changed the program's behavior. But how can we put a program like this under test? If we look at the source code, we can see that it has a basic command loop, and it's using global
Kernel methods to talk directly to the standard in and standard out streams.
We might think about opening a pipe to the program, as we saw in episode #267. But this doesn't work as well as we might hope. It turns out that while opening pipes to another process works great if that process is non-interactive, there are some serious problems with talking to interactive processes. Even if we open the pipe in read/write mode with the nonblocking flag, any reading blocks forever. This code here will never return.
pipe = open("|ruby shout.rb", File::RDWR|File::NONBLOCK) pipe.read # =>
Only after we close the "write" end of the pipe can we read anything out of it.
pipe = open("|ruby shout.rb", File::RDWR|File::NONBLOCK) pipe.close_write pipe.read # => "> "
I believe this behavior is related to pipe buffering, which can be difficult to control.
There's another, better way to run an interactive child process, however. On UNIX-like operating systems, it's possible to create something called a pseudo-terminal, or PTY. A PTY is just like a real terminal from the point of view of a process running inside it. It just isn't connected to any terminal window.
Ruby gives us access to PTYs in the form of the
PTY library. We tell it to spawn a new PTY with a particular command running inside it, and it returns three values: read pipe, a write pipe, and a process ID.
(By the way, I'm afraid that what I'm showing today will probably not work on Windows systems. Sorry about that!)
We still can't do unlimited reads from this PTY. That will result in more endless waiting. But we can grab individual characters of output.
require "pty" r, w, pid = PTY.spawn("ruby shout.rb") r.getc # => ">" r.getc # => " "
We can also write command input to the input IO object, and then read a line back using a nonblocking read. It may seem strange that we get back the exact same text that we wrote, but remember that terminals normally echo back what you type as you type it, and the same is true of PTYs.
Unfortunately when we try to read the program output, we run into an exception indicating that it's not ready. What's going on here? Well, here' we're running into the fact that this code is talking to a separate process, and that process takes time to generate output. It may not seem like noticeable time to us, but it's long enough that our second read is executed before any output is ready. And so it fails.
require "pty" r, w, pid = PTY.spawn("ruby shout.rb") w.write "hello\n" r.read_nonblock(1024) # => "hello\r\n" r.read_nonblock(1024) # => IO::EAGAINWaitReadable: Resource tempora... # ~> IO::EAGAINWaitReadable # ~> Resource temporarily unavailable - read would block # ~> # ~> xmptmp-in6109_Yd.rb:6:in `read_nonblock' # ~> xmptmp-in6109_Yd.rb:6:in `<main>'
We could insert a
sleep in order to wait for the output to be available…
require "pty" r, w, pid = PTY.spawn("ruby shout.rb") w.write "hello\n" r.read_nonblock(1024) # => "hello\r\n" sleep 0.1 r.read_nonblock(1024) # => "> HELLO!\r\n> "
…but then how do we know what's the shortest sleep which will still reliably give the other process enough time?
It seems that reading output from PTYs is only slightly less frustrating than reading from pipes. Thankfully, Ruby has a special tool to help us automate interactions with interactive console programs.
If we require the
expect library, IO objects gain a brand-new method. Instead of doing raw reads, we can tell them that we expect certain output. Let's go ahead and expect an initial prompt. The
#expect method returns an array containing the matched output when it sees it.
require "pty" require "expect" r, w, pid = PTY.spawn("ruby shout.rb") r.expect("> ") # => ["> "]
Let's write a word to our
shout process, being careful to include the newline at the end, and then expect the shouted version. This time, we'll pass a regular expression instead of a string.
require "pty" require "expect" r, w, pid = PTY.spawn("ruby shout.rb") r.expect(/\>/) # => [">"] w.write("hello\n") r.expect(/HELLO!/) # => [" hello\r\nHELLO!"]
We can see that the output includes both the command echo and the program output.
So far we've been expecting things that are, in fact, received. But if we were to expect something which never appears, the method would hang indefinitely. Fortunately, we can pass a second argument indicating how many seconds to wait for the expected output before giving up and returning
require "pty" require "expect" r, w, pid = PTY.spawn("ruby shout.rb") r.expect("heffalump", 1) # => nil
Let's go ahead and add 1-second timeouts to our existing expectations.
Let's then add an expectation of one more prompt, and then tell the program to quit.
Of course, if we're going to be incorporating this into a test script, we'd like to verify that the program did, in fact, quit, and that it did so without any error. That's where the process ID we captured earlier comes in handy. We can use the
Process.waitpid2 method to tell Ruby to wait until the given process has ended. It returns the PID again, along with the process status for the ended subprocess.
We can see that the exit status is zero, which means no error. We can confirm this explicitly using the
require "pty" require "expect" r, w, pid = PTY.spawn("ruby shout.rb") r.expect("> ") # => ["> "] w.write("hello\n") r.expect(/HELLO!/) # => ["hello\r\nHELLO!"] r.expect("> ") # => ["\r\n> "] w.write("quit\n") pid, status = Process.waitpid2(pid) status # => #<Process::Status: pid 23466 exit 0> status.success? # => true
At this point, we have successfully automated the program we wanted to test. We can start it up, issue commands to it, and shut it down, all under program control.
Now it's simply a matter of incorporating this script into whatever our test framework of choice is, and adding some assertions of the expectation returns at various points along the way. I'll leave that as an exercise for you, if you feel like giving it a try. Happy hacking!