Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DanielVartanov/89e08ca436eb73cbaeaae1912f3133cd to your computer and use it in GitHub Desktop.
Save DanielVartanov/89e08ca436eb73cbaeaae1912f3133cd to your computer and use it in GitHub Desktop.
require 'tty/prompt'
require 'tmpdir'
require 'active_support/core_ext/object/blank'
describe 'chmod-alike app' do
let(:app) do
TTY::Testing.app_wrapper do |stdin, stdout|
prompt = TTY::Prompt.new(input: stdin, output: stdout)
filepath = prompt.ask('Type file name:')
unless filepath.blank?
if File.file?(filepath)
if File.stat(filepath).writable?
if File.stat(filepath).executable?
if prompt.yes?('Given file is executable. Remove executable flag?')
FileUtils.chmod 'u-x', filepath
end
else
if prompt.yes?('Given file is not executable. Set executable flag?')
FileUtils.chmod 'u+x', filepath
end
end
else
prompt.say 'Given file is not writable you, cannot proceed'
end
else
prompt.say 'Given file is a directory, cannot proceed'
end
else
prompt.say 'No file name given'
end
end
end
before { app.run }
let(:filepath) do
Dir::Tmpname.create('tty-testing-') do |path|
path
end
end
after do
FileUtils.remove_entry(filepath) if File.exist?(filepath)
end
before { app.run }
it 'asks a file name' do
expect(app.output).to end_with("Type file name: ")
end
context 'when file name is given' do
before do
app.pause_until_further_notice
app.stdin.puts filepath # Important! App won't continue to execute after this line because `app.pause_until_further_notice` was called before
end
context "when the given file is a directory" do
before { Dir.mkdir(filepath) } # While on pause we can make some preparations for the clause
before { app.resume_execution } # Now we allow the app to execute and process the input given above
it do
expect(app.output).to end_with("Given file is a directory, cannot proceed\n")
end
end
context "when the given file is a regular file" do
before { FileUtils.touch(filepath) }
context "when the given file is not writable by the user" do
before { FileUtils.chmod 'u-w', filepath }
before { app.resume_execution }
it do
expect(app.output).to end_with("Given file is not writable you, cannot proceed\n")
end
end
context "when the given file is writable by the user" do
before { FileUtils.chmod 'u+w', filepath }
context "when the given file is executable" do
before { FileUtils.chmod 'u+x', filepath }
before { app.resume_execution }
it 'asks whether to remove executable flag' do
expect(app.output).to end_with("Given file is executable. Remove executable flag? (Y/n) ")
end
context "when the user chooses to remove executable flag" do
before { app.stdin.puts 'y' } # Execution was not paused by now, so the execution continues after a line has been passed to the app input
it 'removes executable flag' do
expect(File.stat(filepath)).not_to be_executable
end
it 'exits' do
expect(app).to be_exited
end
end
context "when the user chooses not to remove executable flag" do
before { app.stdin.puts 'n' }
it 'does not remove executable flag' do
expect(File.stat(filepath)).to be_executable
end
it 'exits' do
expect(app).to be_exited
end
end
end
context "when the given file is not executable" do
before { FileUtils.chmod 'u-x', filepath }
before { app.resume_execution }
it 'asks whether to remove executable flag' do
expect(app.output).to end_with("Given file is not executable. Set executable flag? (Y/n) ")
end
context "when the user chooses to add executable flag" do
before { app.stdin.puts 'y' }
it 'adds executable flag' do
expect(File.stat(filepath)).to be_executable
end
it 'exits' do
expect(app).to be_exited
end
end
context "when the user chooses not to add executable flag" do
before { app.stdin.puts 'n' }
it 'does not add executable flag' do
expect(File.stat(filepath)).not_to be_executable
end
it 'exits' do
expect(app).to be_exited
end
end
end
end
end
end
context 'when file name is not given' do
before { app.stdin.puts }
it do
expect(app.output).to end_with("No file name given\n")
end
end
end

TTY::Testing

In the vast majority of cases explicit app.resume_execution are needless noise since putting a line to app.stdin automatically means a request to advance the program.

Quick example:

expect(app.stdout.string).to end_with("Name?")
app.stdin.puts "John"

expect(app.stdout.string).to end_with("Age?")
app.stdin.puts "22"

expect(app.stdout.string).to end_with("Location?")
app.stdin.puts "Earth"

(a full example is in a separate file below)

But sometimes, just sometimes, explicit pauses and resumes are nevertheless necessary. A quick example impersonating $ echo "Please write a poem" && cat:

expect(app.stdout.string).to end_with("Please write a poem")

app.pause_until_further_notice

app.stdin.puts "Roses are red"
app.stdin.puts "Violets are blue"
app.stdin.puts "I have some poem"
app.stdin.puts "Written for you"
app.stdin.print ?\C-d

app.resume_execution

(a full example is in another separate file below)

require 'stringio'
module TTY
module Testing
def self.app_wrapper(&app_block)
AppWrapper.new(&app_block)
end
class AppWrapper
def initialize(&app_block)
@exited = false
@stdin, @stdout, @fiber = build_stdio_and_fiber(app_block)
end
def pause_until_further_notice
@paused = true
end
def resume_execution
@paused = false
@fiber.resume
end
alias run resume_execution
def paused?
@paused
end
def exited?
@exited
end
def stdin
@stdin
end
def stdout
@stdout
end
def output
stdout.string
end
protected
def build_stdio_and_fiber(app_block)
stdin_reader, stdin_writer = IO.pipe # TODO: Substitute with something which does not allocate a file descriptor or touches OS.
stdout = StringIO.new
fiber = Fiber.new do
app_block.call(stdin_reader, stdout)
@exited = true
end
paused_proc = self.method(:paused?).to_proc
stdin_writer.singleton_class.define_method(:puts) do |*args|
super(*args)
fiber.resume unless paused_proc.call
end
stdin_reader.singleton_class.define_method(:wait_readable) do |*args|
if ready?
super(*args)
else
Fiber.yield
wait_readable(*args)
end
end
stdin_reader.singleton_class.define_method(:getc) do
if ready?
super()
else
Fiber.yield
getc
end
end
[stdin_writer, stdout, fiber]
end
end
end
end
require 'tty/prompt'
describe 'Pastel usage generator app' do
let(:app) do
TTY::Testing.app_wrapper do |stdin, stdout|
prompt = TTY::Prompt.new(input: stdin, output: stdout)
text_colour = prompt.select("What should be the colour of your text?", ['red', 'blue'])
background_colour = prompt.select("What should be the background colour?", ['white', 'black'])
prompt.say "Your Pastel code is: `pastel.#{text_colour}.on_#{background_colour}`"
end
end
before { app.run }
it 'asks the text colour' do
expect(app.output).to end_with(
"What should be the colour of your text? (Use ↑/↓ arrow keys, press Enter to select)
‣ red
blue")
end
context 'when red is chosen' do
before { app.stdin.puts } # press enter
it 'asks the background colour' do
expect(app.output).to end_with(
"What should be the background colour? (Use ↑/↓ arrow keys, press Enter to select)
‣ white
black")
end
context 'when white is chosen' do
before { app.stdin.puts }
specify do
expect(app.output).to end_with("Your Pastel code is: `pastel.red.on_white`\n")
end
end
context 'when black is chosen' do
before { app.stdin.puts "\e[B" } # press arrow down and enter
specify do
expect(app.output).to end_with("Your Pastel code is: `pastel.red.on_black`\n")
end
end
end
context 'when blue is chosen' do
before { app.stdin.puts "\e[B" }
it 'asks the background colour' do
expect(app.output).to end_with(
"What should be the background colour? (Use ↑/↓ arrow keys, press Enter to select)
‣ white
black")
end
context 'when white is chosen' do
before { app.stdin.puts }
specify do
expect(app.output).to end_with("Your Pastel code is: `pastel.blue.on_white`\n")
end
end
context 'when black is chosen' do
before { app.stdin.puts "\e[B" } # press arrow down and enter
specify do
expect(app.output).to end_with("Your Pastel code is: `pastel.blue.on_black`\n")
end
end
end
end
@DannyBen
Copy link

Hi Daniel,

Some thoughts on what I am hoping to have in TTY testing helper. I am not sure if this conflicts with your plan, or compliments it, but I thought I will express it in writing here, for reference.


Given CLI that uses TTY that asks these questions:

  • Choose your warrior: (menu)
  • Choose your weapon: (menu)
  • Are you sure: (y/n)

and at the end prints #{warrior} wielding #{weapon}

In my view, the most intuitive and testing-framework-agnostic way to test it would be to:

  1. Define an STDIN buffer with my answers
  2. Define the expected output (including how TTY::Prompt prints the question and answer)

So, translating it to minitest pseudo-code:

include TTY::TestHelpers

def test_that_it_works
  # prepare input for the entire sequence
  # notice that I am answering with the actual string, and not the keyboard
  # movements (down down enter) - the helper should know how to choose 
  # this answer, and we are not testing TTY::Prompt keyboard input - thats an
  # outside dependency
  TTY.input_buffer = ["Luke", "Lightsaber", "Y"]

  # prepare the expected output in whatever way is acceptable by your testing
  # framework
  expected_output = [
    "Choose your warrior: Luke",
    "Choose your weapon: Lightsaber",
    "Are you sure? Y",
    "Luke wielding Lightsaber"
  ].join "\n"

  # execute your program however you need and assert its output normally
  assert_output expected_output do
    subject.run
  end
end

and, in rspec:

describe MyCLI do
  include TTY::TestHelpers   # or move to spec_helper

  let(:expected) {
    [
      "Choose your warrior: Luke",
      "Choose your weapon: Lightsaber",
      "Are you sure? Y",
      "Luke wielding Lightsaber"
    ].join "\n"
  }

  it "works" do
    TTY.input_buffer = ["Luke", "Lightsaber", "Y"]
    expect { subject.run }.to output(expected).to_stdout
  end
end

@DanielVartanov
Copy link
Author

DanielVartanov commented Jun 22, 2020

Hey @DannyBen,

What you have described makes total sense, moreover this is exactly how Piotr's is tesing tty-prompt itself, it does not even require any additional framework, just look at tests folder in tty-prompt.

What we are trying to solve by the framework is the case when you don't want to pre-feed all input in advance, like this:

it "asks for warrior" do
   expect(app.output).to eq "Choose your warrior:"
end

context "when I choose Luke" do
  before { app.input.puts "Luke" }

  it "asks for the lightsaber colour" do
    expect(app.output).to eq "Choose your lightsaber colour"
  end

   context "when I choose red" do
      before { app.input.puts "red" }
      # ...
    end
   
    context "when I choose blue" do
       before { app.input.puts "blue" }
       # ...
    end
end

context "when I choose Rambo" do
  before { app.input.puts "Rambo" }

  it "asks for the machinegun caliber" do
    expect(app.output).to eq "Choose your machinegun caliber"
  end

  # ...
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment