(My note: before the episode):

mv ~/bin/goon ~/tmp/goon

In Episode 068, we discussed the Port module, which is a thin wrapper around Erlang's port module. Since that Episode, Alexei Sholik has released a library called Porcelain which smooths out quite a few of the Port module's rough edges. Let's have a look at it.

Project

We'll start a new project to play with it:

mix new porcelain_playground
cd porcelain_playground

Alright, so we'll add a dependency for it and start it with our app:

  def application do
    [applications: [:porcelain]]
  end

  def deps do
    [
      {:porcelain, "~> 1.0.0"}
    ]
  end

Now that that's in place, let's have a look at writing some tests to interact with external programs using porcelain. Open up test/porcelain_playground_test.exs and we'll reproduce some of Episode 068's tasks, but with tests this time, and a nicer API.

First, we'll set up some test files to use in our tests:


  @test_files_path "test/files/elixir_port"

  setup do
    File.mkdir_p(@test_files_path)
    :ok
  end

  teardown do
    File.rm_rf(@test_files_path)
    :ok
  end

Now let's write a test that verifies that we can spawn "ls" and read it, and see various options Porcelain gives us for this:

  test "evaluating and getting a response" do
    # Now, to do this we can use Porcelain.shell, but let's inspect
    # the output to see what the response is like
    File.mkdir_p(@test_files_path <> "/1")
    File.touch(@test_files_path <> "/1/first")
    File.touch(@test_files_path <> "/1/second")
    IO.inspect Porcelain.shell("ls #{@test_files_path}/1")
  end

Run the tests. So here we can see that we get back a %Porcelain.Result{} struct which has the attributes [:error, :out, :status]. For now we'll just use dot to access these values out, so let's verify in this test that the value of ls is what we expect:

  test "evaluating and getting a response" do
    File.mkdir_p(@test_files_path <> "/1")
    File.touch(@test_files_path <> "/1/first")
    File.touch(@test_files_path <> "/1/second")
    expected_output = "first\nsecond\n"

    assert expected_output == Porcelain.shell("ls #{@test_files_path}/1").out
  end

So here we're just using this like a rich man's System.cmd, where we get more interesting information back and don't have to receive the response ourselves. shell is one way to do this, and another is exec. Exec won't launch a shell to run the program in, so you'll need to hand your executable a list of arguments as that's something the shell normally does:

  test "evaluating and getting a response" do
    File.mkdir_p(@test_files_path <> "/1")
    File.touch(@test_files_path <> "/1/first")
    File.touch(@test_files_path <> "/1/second")
    expected_output = "first\nsecond\n"

    assert expected_output == Porcelain.shell("ls #{@test_files_path}/1").out
    assert expected_output == Porcelain.exec("ls", ["#{@test_files_path}/1"]).out
  end

For something like ls, there's no need to do anything interesting and async, so we'll leave that one there. You can also manage the pipelines in and out of your Porcelain spawned processes:

  test "managing pipelines" do
    opus = """
    poppycock
    garnish
    rutabaga
    pipsqueak
    """
    sorted_opus = """
    garnish
    pipsqueak
    poppycock
    rutabaga
    """
    File.mkdir_p(@test_files_path <> "/2")
    text_file = @test_files_path <> "/2/text_file"
    output_file = @test_files_path <> "/2/output_file"
    File.write!(text_file, opus)

    Porcelain.exec("sort", [], in: {:path, text_file}, out: {:append, output_file})
    assert sorted_opus == File.read!(output_file)
  end

Go ahead and run it....and it will hang and hang, and tell you that isn't using the goon driver. It will use the goon driver if goon is in your path. Goon is a companion binary for porcelain that provides some features that erlang's port executables layer just doesn't offer, and one of those features causes grief in this example. In the resources section I've linked to goon's project page, which has downloads for various architectures. At any rate, I'll move the driver into my path and it will get picked up now:

mv ~/tmp/goon ~/bin/goon

Alright, now I'll run the test again...and it passes. So you can actually pass in Elixir streams or files as input, which is pretty neat, and you can do the same for output. Let's try that:

    numstream = Stream.cycle([1,2,3]) |> Stream.take(10)
    Porcelain.exec("sort", [], in: numstream, out: {:path, output_file})
    assert <<1,2,3,1,2,3,1,2,3,1>> == File.read!(output_file)

Alright, so if you run this test...it fails. There's an extra <<10>> at the end of the binary. Turns out that's a newline character...I'm not 100% sure why it ended up there, but let's just change our assertion now that we know the behaviour:

    assert <<1,2,3,1,2,3,1,2,3,1>> <> "\n" == File.read!(output_file)

OK, run the tests, and they pass. You can also pass the output into anything that implements Collectable.

Let's move on to running a bash shell in a port and interacting with it:

  test "interacting with a bash shell" do
    alias Porcelain.Process, as: Proc
    # So we'll spawn the bash shell, telling it to receive for its stdin and
    # send us its stdout...we'll also grab a reference to its pid for later
    proc = %Proc{pid: pid} = Porcelain.spawn("bash", ["--noediting", "-i"], in: :receive, out: {:send, self()})
    # Now let's make a file with some known content using normal bash commands
    Proc.send_input(proc, "echo foo > #{@test_files_path}/foo\n")
    # We'll cat the file out
    Proc.send_input(proc, "cat #{@test_files_path}/foo\n")
    # And we'll verify that we received the file's data.  There's an extra
    # newline because cat injected one for the prompt
    assert_receive {pid, :data, "foo\n"}, 1000
  end

OK, run the tests...and they pass.

Alright, so our grand finale in Episode 068 involved spawning vim inside of our interactive bash process and interacting with it via messages. We can reproduce that, but there's a bit of non-determinism we have to deal with via the anti-glory of sleep statements. At any rate, here it is:

  test "dat vim" do
    alias Porcelain.Process, as: Proc
    # Again we spawn bash, same as the last test
    proc = %Proc{pid: pid} = Porcelain.spawn("bash", ["--noediting", "-i"], in: :receive, out: {:send, self()})
    # We'll open a file in vim
    Proc.send_input(proc, "vim #{@test_files_path}/vimfile\n")
    # Here we have to wait for a little bit for vim to get ready for input
    :timer.sleep 1000
    # Enter insert mode, type some text
    Proc.send_input(proc, "izomg hi there")
    # Again, wait for a little bit for vim....
    :timer.sleep 1000
    # We'll leave insert mode and write the file and quit
    esc = <<27>>
    Proc.send_input(proc, esc)
    Proc.send_input(proc, ":wq\n")
    :timer.sleep 1000
    # Now let's cat the file out
    Proc.send_input(proc, "cat #{@test_files_path}/vimfile\n")
    # And if everything worked, this should be what got catted out
    assert_receive {pid, :data, "zomg hi there\n"}, 1000
  end

OK, run the tests...and they SHOULD pass. Who knows, due to the non-determinism :)

Summary

In today's episode we looked at using Porcelain as a nicer interface for interacting with remote OS processes. It's got a much more pleasant interface for sending commands in, it has less arcane syntax for spawning processes with certain interesting options, and it has some features that you just can't get with the Port module, like streaming from an Elixir stream into stdin of a process. It looks great, and I expect I'll use it quite frequently. Hope you enjoyed it. See you soon!

Resources