< Prev^ UpNext >

The Application Class

Overview

We had to create the skeleton of an application object, RubyExplorer, in order to have something to attach the CLI to. Now it's time to put some flesh on that skeleton's bones.

So, what does the application object have to do? It needs to:

In addition, when we start the target Rails app, we need to respect the app's (possible) use of rvm, and its (likely) use of bundler.

Inserting the Probe

Another bit of terminology again: In hardware development, one uses a probe from a test instrument (e.g., an voltmeter) to connect to the circuit under development and look at what's going on. By analogy, I'm going to refer to the code inserted into the target Rails app as the probe.

We can insert our probe into the target application in the same way that our spikes did, using Ruby's -r option. Respecting bundler should happen automatically, as long as we don't do anything to mess that up. Respecting rvm is slightly trickier.

Rvm and similar packages work their magic by hooks in bash that are installed when the user logs in. We can fake a login shell by using bash's -l option, but that leaves us with a quandary: We want to load up the application using ruby -r, but at the same time we want to use bash -l to make sure we let rvm insert itself. Fortunately, bash also provides an option that gets us out of the dillema:-c.

When run with -c, bash doesn't read commands from the user, as it normally would. Instead, it expects a single command to be provided as a command line argument. It starts up, runs the specified command, and then exits. Putting all of this together (and ignoring, for the moment, the issue of what directory our probe code is in versus what directory the target app is in; we'll deal with that in a bit) we can accomplish what we want if we start up the app like:

bash -l -c 'ruby -r probe.rb bin/rails'

So let's write our first test for RubyApplication:

require './src/ruby_explorer'

describe "RubyExplorer#run" do
  let(:app) { RubyExplorer.new }

  before do
    allow(Process).to receive(:spawn)
    app.run
  end

  it "spawns the probe inserter" do
    expect(Process).to have_received(:spawn).with("/bin/bash",
                                                  "-l",
                                                  "-c",
                                                  "ruby -r probe bin/rails")

  end
end

And the code to make it pass is:

class RubyExplorer
  attr_accessor :target_directory

  def run
    Process.spawn("/bin/bash", "-l", "-c", "ruby -r probe bin/rails")
  end
end

Keeping Track of Where We Are

As I mentioned, we've entirely neglected the issue of directories for ruby-explorer and the target app here. In addition, although we've got a unit test in place to ensure that we're doing what we want to, but we don't have any assurance that it all works. To get that confidence, we have to actually run the application.

To run the application, we need a Rails app to be our target. Eventually, we want to run this on a "legacy" app - one that's already been written to do something. After all, that's ultimately the point. For now, though, all we want is to ensure that we start the target app, so we can just create a new rails app and use that for our target app.

jmax@deepthought $ rvm use 2.5.5@bare-rails --create
ruby-2.5.5 - #gemset created /home/jmax/.rvm/gems/ruby-2.5.5@bare-rails
ruby-2.5.5 - #generating bare-rails wrappers.........
Using /home/jmax/.rvm/gems/ruby-2.5.5 with gemset bare-rails
jmax@deepthought $ rails new bare-rails
      create
      create  README.md
      create  Rakefile
      create  .ruby-version
   :
   :
jmax@deepthought $ cd bare-rails/
jmax@deepthought $ echo >.ruby-version ruby-2.5.5
jmax@deepthought $ echo >.gemset-version bare-rails
jmax@deepthought $ bundle install
Fetching gem metadata from https://rubygems.org/............
Fetching rake 13.0.1
Installing rake 13.0.1
   :
   :
jmax@deepthought $ bin/rails server
=> Booting Puma
=> Rails 6.0.2.2 application starting in development
=> Run `rails server --help` for more startup options
Puma starting in single mode...
* Version 4.3.3 (ruby 2.5.5-p157), codename: Mysterious Traveller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://127.0.0.1:3000
* Listening on tcp://[::1]:3000
Use Ctrl-C to stop
Gracefully stopping, waiting for requests to finish
=== puma shutdown: 2020-04-17 18:54:36 -0400 ===
- Goodbye!
Exiting
jmax@deepthought $

So now we have a target app. When we try running with ruby-explorer, it fails, of course.

jmax@deepthought $ bin/ruby-explorer ../bare-rails
bash: bin/ruby-explorer: Permission denied
jmax@deepthought $

Wups! Our CLI doesn't have execute permission. This is simple enough to fix; we flip the execution permission bit on the CLI and try again.

jmax@deepthought $ chmod 744 bin/ruby-explorer
jmax@deepthought $ bin/ruby-explorer ../bare-rails
jmax@deepthought $ Traceback (most recent call last):
ruby: No such file or directory -- bin/rails (LoadError)

jmax@deepthought $

And it fails in a much more reasonable fashion. We're looking for bin/rails under the root directory of ruby-explorer, and of course it isn't there; it's in the target app's directory.

To fix this, we have to change to the target app's directory. We already know what that directory is; our CLI stashed it in RubyExplorer#target_directory. Time for another test:

require './src/ruby_explorer'

describe "RubyExplorer#run" do
  let(:app) { RubyExplorer.new }
  let(:target_directory) {"target_directory"}

  before do
    allow(Dir).to receive(:chdir)
    allow(Process).to receive(:spawn)

    app.target_directory= target_directory
    app.run
  end

  it "spawns the target app" do
    expect(Dir).to have_received(:chdir).with(target_directory).ordered
    expect(Process).to have_received(:spawn).with("/bin/bash",
                                                  "-l",
                                                  "-c",
                                                  "ruby -r probe bin/rails").ordered
  end
end

And the code to make it pass is:

class RubyExplorer
  attr_accessor :target_directory

  def run
    Dir.chdir(target_directory)
    Process.spawn("/bin/bash", "-l", "-c", "ruby -r probe bin/rails")
  end
end

Running it, we fail again.

jmax@deepthought $ bin/ruby-explorer ../bare-rails
jmax@deepthought $ Traceback (most recent call last):
    1: from /home/jmax/.rvm/rubies/ruby-2.5.5/lib/ruby/site_ruby/2.5.0/rubygems/core_ext/kernel_require.rb:54:in `require'
/home/jmax/.rvm/rubies/ruby-2.5.5/lib/ruby/site_ruby/2.5.0/rubygems/core_ext/kernel_require.rb:54:in `require': cannot load such file -- probe (LoadError)

jmax@deepthought $

This time, we failed because Ruby can't find our probe. We've got several problems here. First, we haven't even created a probe. Second, when we do, it'll be back in ruby-explorer's directory. Third, back when we set up the test for ruby-explorer's call to spawn, we forgot to include the .rb extension on the probe.

Fixing these problems in the test, we get:

require './src/ruby_explorer'

describe "RubyExplorer#run" do
  let(:app) { RubyExplorer.new }
  let(:target_directory) {"target_directory"}
  let(:original_directory) {"original-directory"}

  before do
    allow(Dir).to receive(:chdir)
    allow(Process).to receive(:spawn)
    allow(Dir).to receive(:getwd).and_return(original_directory)

    app.target_directory= target_directory
    app.run
  end

  it "spawns the target app" do
    expect(Dir).to have_received(:chdir).with(target_directory).ordered
    expect(Process).to have_received(:spawn).with("/bin/bash",
                                                  "-l",
                                                  "-c",
                                                  "ruby -r #{original_directory}/src/probe.rb bin/rails").ordered
  end
end

And then we change the code so the test passes.

class RubyExplorer
  attr_accessor :target_directory

  def run
    original_directory= Dir.getwd
    Dir.chdir(target_directory)
    Process.spawn("/bin/bash", "-l", "-c", "ruby -r #{original_directory}/src/probe.rb bin/rails")
  end
end

Let's add a dummy Probe class, so we have something to load:

class Probe
end

And we're ready to try again:

jmax@deepthought $ bin/ruby-explorer ../bare-rails
jmax@deepthought $
The most common rails commands are:
 generate     Generate new code (short-cut alias: "g")
 console      Start the Rails console (short-cut alias: "c")
 server       Start the Rails server (short-cut alias: "s")
 test         Run tests except system tests (short-cut alias: "t")
 test:system  Run system tests
 dbconsole    Start a console for the database specified in config/database.yml
              (short-cut alias: "db")

 new          Create a new Rails application. "rails new my_app" creates a
              new application called MyApp in "./my_app"


All commands can be run with -h (or --help) for more information.
In addition to those commands, there are:

  about
  action_mailbox:ingress:exim
  action_mailbox:ingress:postfix
   :
   :
jmax@deepthought $

So close. The only reason we failed this time is that we didn't tell rails to start a server; in fact we didn't tell it to do anything. Fixing the test and code again:

require './src/ruby_explorer'

describe "RubyExplorer#run" do
  let(:app) { RubyExplorer.new }
  let(:target_directory) {"target_directory"}
  let(:original_directory) {"original-directory"}

  before do
    allow(Dir).to receive(:chdir)
    allow(Process).to receive(:spawn)
    allow(Dir).to receive(:getwd).and_return(original_directory)

    app.target_directory= target_directory
    app.run
  end

  it "spawns the target app" do
    expect(Dir).to have_received(:chdir).with(target_directory).ordered
    expect(Process).to have_received(:spawn).with("/bin/bash",
                                                  "-l",
                                                  "-c",
                                                  "ruby -r #{original_directory}/src/probe.rb bin/rails server").ordered
  end
end
class RubyExplorer
  attr_accessor :target_directory

  def run
    original_directory= Dir.getwd
    Dir.chdir(target_directory)
    Process.spawn("/bin/bash", "-l", "-c", "ruby -r #{original_directory}/src/probe.rb bin/rails server")
  end
end

Crossing our fingers, we try running it again:

jmax@deepthought $ bin/ruby-explorer ../bare-rails
jmax@deepthought $ => Booting Puma
=> Rails 6.0.2.2 application starting in development
=> Run `rails server --help` for more startup options
Puma starting in single mode...
* Version 4.3.3 (ruby 2.5.5-p157), codename: Mysterious Traveller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://127.0.0.1:3000
* Listening on tcp://[::1]:3000
Use Ctrl-C to stop

Success!

Feedback and Cleanup

After dancing in joy for a bit, we realize that we still have an empty probe. We really aren't doing anything more than running Rails in an unusual fashion. In fact, we don't even have any feedback to tell us our probe is getting loaded. We'll tackle putting some real guts into the probe shortly, but first lets just print something from inside the probe.

class Probe
end

puts "Hello from the probe!"

Running it again, we see our message:

jmax@deepthought $ bin/ruby-explorer ../bare-rails
jmax@deepthought $ Hello from the probe!
=> Booting Puma
=> Rails 6.0.2.2 application starting in development
=> Run `rails server --help` for more startup options
Puma starting in single mode...
* Version 4.3.3 (ruby 2.5.5-p157), codename: Mysterious Traveller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://127.0.0.1:3000
* Listening on tcp://[::1]:3000
Use Ctrl-C to stop

jmax@deepthought $

Before we start work on the probe in the next section, let's clean up the tests a bit. We should always keep our code, which includes the code for tests, as clean as possible. There's actually not much to criticize in the RubyExplorer class, but we've got a little bit of a smell in our tests for it:

require './src/ruby_explorer'

describe "RubyExplorer#run" do
  let(:app) { RubyExplorer.new }
  let(:target_directory) {"target_directory"}
  let(:original_directory) {"original-directory"}

  before do
    allow(Dir).to receive(:chdir)
    allow(Process).to receive(:spawn)
    allow(Dir).to receive(:getwd).and_return(original_directory)

    app.target_directory= target_directory
    app.run
  end

  it "spawns the target app" do
    expect(Dir).to have_received(:chdir).with(target_directory).ordered
    expect(Process).to have_received(:spawn).with("/bin/bash",
                                                  "-l",
                                                  "-c",
                                                  "ruby -r #{original_directory}/src/probe.rb bin/rails server").ordered
  end
end

We're actually testing three different things at once.

The first two are checked by having .with clauses on the expect statements, which check the arguments passed. The third thing is checked by the .ordered clauses, which check that the calls happen in the same order as the expect statements.

It's also a little annoying that we have two separate expectations in our test; ideally, each test should check for one and only one thing, so that if it fails, we can easily determine what's wrong.

At this point, things are not awful, but it's best to clean them up now, before they get any worse. So, splitting our existing test into three tests, we get:

require './src/ruby_explorer'

describe "RubyExplorer#run" do
  let(:app) { RubyExplorer.new }
  let(:target_directory) {"target_directory"}
  let(:original_directory) {"original-directory"}

  before do
    allow(Dir).to receive(:chdir)
    allow(Process).to receive(:spawn)
    allow(Dir).to receive(:getwd).and_return(original_directory)

    app.target_directory= target_directory
    app.run
  end

  it "changes to the the target directory" do
    expect(Dir).to have_received(:chdir).with(target_directory)
  end

  it "spawns the target app" do
    expected_ruby_command=
      "ruby -r #{original_directory}/src/probe.rb bin/rails server"

    expect(Process).to have_received(:spawn).
                         with("/bin/bash",
                              "-l",
                              "-c",
                              expected_ruby_command)
  end

  it "changes directories before trying to spawn" do
    expect(Dir).to have_received(:chdir).ordered
    expect(Process).to have_received(:spawn).ordered
  end
end

Keeping Better Track of Where We Are

Looking at that, we've still got one more problem with our app class. We're assuming that the current directory is the root of the ruby-explorer project, which won't generally be true. So lets' modify the tests to remove that assumption. This is actually slightly tricky.

At first glance, we might try picking the directory for the ruby-explorer binary off of $0. This won't work, though, because at the time when we need the directory for probe.rb, we won't be in our original location; we will have changed directories to the target project.

Or, rather, it won't always work. It will work if the original command to run ruby-explorer used an absolute path, for example: /home/jmax/ruby-explorer/bin/ruby-explorer. This gives our way to resolve the problem: before we change to the target project's directory, we need to get the directory part of $0 and convert it to an absolute path. So the test ends up like:

require './src/ruby_explorer'

describe "RubyExplorer#run" do
  let(:app) { RubyExplorer.new }

  let(:target_directory) {"../target-directory"}
  let(:bin_directory) {"../ruby-explorer/bin"}
  let(:cli_binary_path) {"#{bin_directory}/ruby-explorer"}
  let(:absolute_bin_directory) {"/home/user/ruby-explorer/bin"}
  let(:absolute_src_directory) {"/home/user/ruby-explorer/src"}

  before do
    allow(Dir).to receive(:chdir)
    allow(Process).to receive(:spawn)
    allow(File).to receive(:expand_path).and_return(absolute_src_directory)

    $0= cli_binary_path
    app.target_directory= target_directory
    app.run
  end

  it "finds the absolute path of the src directory" do
    expect(File).to have_received(:expand_path).with("#{bin_directory}/../src")
  end

  it "changes to the the target directory" do
    expect(Dir).to have_received(:chdir).with(target_directory)
  end

  it "spawns the target app" do
    expected_ruby_command=
      "ruby -r #{absolute_src_directory}/probe.rb bin/rails server"

    expect(Process).to have_received(:spawn).
                         with("/bin/bash",
                              "-l",
                              "-c",
                              expected_ruby_command)
  end

  it "picks off the src directory, changes directories, and spawns, in that order" do
    expect(File).to have_received(:expand_path).ordered
    expect(Dir).to have_received(:chdir).ordered
    expect(Process).to have_received(:spawn).ordered
  end
end

And the code changes to:

require 'pathname'

class RubyExplorer
  attr_accessor :target_directory

  def run
    bin_directory= Pathname.new($0).dirname
    src_directory= File.expand_path("#{bin_directory}/../src")
    Dir.chdir(target_directory)
    Process.spawn("/bin/bash", "-l", "-c", "ruby -r #{src_directory}/probe.rb bin/rails server")
  end
end

Trying all this out, we cd up a level and run ruby-explorer:

jmax@deepthought $ cd ..
jmax@deepthought $ ruby-explorer/bin/ruby-explorer bare-rails
jmax@deepthought $ Hello from the probe!
=> Booting Puma
=> Rails 6.0.2.2 application starting in development
=> Run `rails server --help` for more startup options
Puma starting in single mode...
* Version 4.3.3 (ruby 2.5.5-p157), codename: Mysterious Traveller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://127.0.0.1:3000
* Listening on tcp://[::1]:3000
Use Ctrl-C to stop

On to the probe!

< Prev^ UpNext >