Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Added more examples; added Cucumber steps.

  • Loading branch information...
commit 23bf9dd461d5d43f18c4e6a5660521169343bd59 1 parent d0eaa97
Avdi Grimm authored July 19, 2010
1  .gitignore
@@ -16,3 +16,4 @@ announcement.txt
16 16
 coverage
17 17
 doc
18 18
 pkg
  19
+/examples/cucumber/greenletters.log
2  History.txt
... ...
@@ -1,4 +1,4 @@
1  
-== 1.0.0 / 2010-07-03
  1
+== 0.0.1 / 2010-07-19
2 2
 
3 3
 * 1 major enhancement
4 4
   * Birthday!
78  README.org
Source Rendered
... ...
@@ -0,0 +1,78 @@
  1
+#+Title:        Greenletters README
  2
+#+AUTHOR:       Avdi Grimm
  3
+#+EMAIL:        avdi@avdi.org
  4
+
  5
+# Configuration:
  6
+#+STARTUP:      odd
  7
+#+STARTUP:      hi
  8
+#+STARTUP:      hidestars
  9
+
  10
+
  11
+* Synopsis
  12
+
  13
+#+begin_src ruby
  14
+  require 'greenletters'
  15
+  adv = Greenletters::Process.new("adventure", :transcript => $stdout)
  16
+  adv.on(:output, /welcome to adventure/i) do |process, match_data|
  17
+    adv << "no\n"
  18
+  end
  19
+
  20
+  puts "Starting adventure..."
  21
+  adv.start!
  22
+  adv.wait_for(:output, /you are standing at the end of a road/i)
  23
+  adv << "east\n"
  24
+  adv.wait_for(:output, /inside a building/i)
  25
+  adv << "quit\n"
  26
+  adv.wait_for(:output, /really want to quit/i)
  27
+  adv << "yes\n"
  28
+  adv.wait_for(:exit)
  29
+  puts "Adventure has exited."
  30
+#+end_src
  31
+
  32
+* What
  33
+
  34
+  Greenletters is a console interaction automation library similar to [[http://directory.fsf.org/project/expect/][GNU
  35
+  Expect]]. You can use it to script interactions with command-line programs.
  36
+
  37
+* Why
  38
+  Because Ruby's built-in expect.rb is pretty underpowered and I wanted to drive
  39
+  command-line applications from Ruby, not TCL.
  40
+
  41
+* Who
  42
+  Greenletters is by [[mailto:avdi@avdi.org][Avdi Grimm]].
  43
+
  44
+* Where
  45
+  http://github.com/avdi/greenletters
  46
+
  47
+* How
  48
+  Greenletters uses the pty.rb library under the covers to create a UNIX
  49
+  pseudoterminal under Ruby's control. Of course, this means that it is
  50
+  *NIX-only; Windows users need not apply.
  51
+
  52
+  The advantage of using a PTY is that *any* output - inclding output written to
  53
+  the console instead of STDOUT/STDERR - will be captured by Greenletters.
  54
+
  55
+* LICENSE
  56
+
  57
+(The MIT License)
  58
+
  59
+Copyright (c) 2010 Avdi Grimm
  60
+
  61
+Permission is hereby granted, free of charge, to any person obtaining
  62
+a copy of this software and associated documentation files (the
  63
+'Software'), to deal in the Software without restriction, including
  64
+without limitation the rights to use, copy, modify, merge, publish,
  65
+distribute, sublicense, and/or sell copies of the Software, and to
  66
+permit persons to whom the Software is furnished to do so, subject to
  67
+the following conditions:
  68
+
  69
+The above copyright notice and this permission notice shall be
  70
+included in all copies or substantial portions of the Software.
  71
+
  72
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
  73
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  74
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
  75
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
  76
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
  77
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
  78
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
48  README.txt
... ...
@@ -1,48 +0,0 @@
1  
-greenletters
2  
-    by FIXME (your name)
3  
-    FIXME (url)
4  
-
5  
-== DESCRIPTION:
6  
-
7  
-FIXME (describe your package)
8  
-
9  
-== FEATURES/PROBLEMS:
10  
-
11  
-* FIXME (list of features or problems)
12  
-
13  
-== SYNOPSIS:
14  
-
15  
-  FIXME (code sample of usage)
16  
-
17  
-== REQUIREMENTS:
18  
-
19  
-* FIXME (list of requirements)
20  
-
21  
-== INSTALL:
22  
-
23  
-* FIXME (sudo gem install, anything else)
24  
-
25  
-== LICENSE:
26  
-
27  
-(The MIT License)
28  
-
29  
-Copyright (c) 2009 FIXME (different license?)
30  
-
31  
-Permission is hereby granted, free of charge, to any person obtaining
32  
-a copy of this software and associated documentation files (the
33  
-'Software'), to deal in the Software without restriction, including
34  
-without limitation the rights to use, copy, modify, merge, publish,
35  
-distribute, sublicense, and/or sell copies of the Software, and to
36  
-permit persons to whom the Software is furnished to do so, subject to
37  
-the following conditions:
38  
-
39  
-The above copyright notice and this permission notice shall be
40  
-included in all copies or substantial portions of the Software.
41  
-
42  
-THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
43  
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
44  
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
45  
-IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
46  
-CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
47  
-TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
48  
-SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
11  Rakefile
@@ -9,10 +9,13 @@ task :default => 'test:run'
9 9
 task 'gem:release' => 'test:run'
10 10
 
11 11
 Bones {
12  
-  name  'greenletters'
13  
-  authors  'Avdi Grimm'
14  
-  email  'avdi@avdi.org'
15  
-  url  'http://github.com/avdi/greenletters'
  12
+  name         'greenletters'
  13
+  authors      'Avdi Grimm'
  14
+  email        'avdi@avdi.org'
  15
+  url          'http://github.com/avdi/greenletters'
16 16
   ignore_file  '.gitignore'
  17
+  readme_file  'README.org'
  18
+
  19
+  summary      'A Ruby command-line automation framework a la Expect'
17 20
 }
18 21
 
30  examples/adventure.rb
... ...
@@ -0,0 +1,30 @@
  1
+# This demo interacts with the classic Collossal Cave Adventure game. To install
  2
+# the game on Debian-based systems (Ubuntu, etc), execute:
  3
+#
  4
+#   sudo apt-get install bsdgames
  5
+#
  6
+$:.unshift(File.expand_path('../lib', File.dirname(__FILE__)))
  7
+require 'greenletters'
  8
+require 'logger'
  9
+
  10
+logger = ::Logger.new($stdout)
  11
+logger.level = ::Logger::INFO
  12
+# logger.level = ::Logger::DEBUG
  13
+adv = Greenletters::Process.new("adventure",
  14
+   :logger     => logger,
  15
+   :transcript => $stdout)
  16
+adv.on(:output, /welcome to adventure/i) do |process, match_data|
  17
+  adv << "no\n"
  18
+end
  19
+
  20
+puts "Starting aadventure..."
  21
+adv.start!
  22
+adv.wait_for(:output, /you are standing at the end of a road/i)
  23
+adv << "east\n"
  24
+adv.wait_for(:output, /inside a building/i)
  25
+adv << "quit\n"
  26
+adv.wait_for(:output, /really want to quit/i)
  27
+adv << "yes\n"
  28
+adv.wait_for(:exit)
  29
+puts "Adventure has exited."
  30
+
67  examples/cucumber/adventure.feature
... ...
@@ -0,0 +1,67 @@
  1
+Feature: play adventure
  2
+  As a nerd
  3
+  I want to play a text adventure game
  4
+  Because I'm old-skool
  5
+
  6
+  Scenario: play first few rooms (named process)
  7
+    Given process activity is logged to "greenletters.log"
  8
+    Given a process "adventure" from command "adventure"
  9
+    Given I reply "no" to output "Would you like instructions?" from process "adventure"
  10
+    Given I reply "yes" to output "Do you really want to quit" from process "adventure"
  11
+    When I execute the process "adventure"
  12
+    Then I should see the following output from process "adventure":
  13
+    """
  14
+    You are standing at the end of a road before a small brick building.
  15
+    Around you is a forest.  A small stream flows out of the building and
  16
+    down a gully.
  17
+    """
  18
+    When I enter "east" into process "adventure"
  19
+    Then I should see the following output from process "adventure":
  20
+    """
  21
+    You are inside a building, a well house for a large spring.
  22
+    """
  23
+    When I enter "west" into process "adventure"
  24
+    Then I should see the following output from process "adventure":
  25
+    """
  26
+    You're at end of road again.
  27
+    """
  28
+    When I enter "south" into process "adventure"
  29
+    Then I should see the following output from process "adventure":
  30
+    """
  31
+    You are in a valley in the forest beside a stream tumbling along a
  32
+    rocky bed.
  33
+    """
  34
+    When I enter "quit" into process "adventure"
  35
+    Then the process "adventure" should exit succesfully
  36
+
  37
+  Scenario: play first few rooms (default process)
  38
+    Given process activity is logged to "greenletters.log"
  39
+    Given a process from command "adventure"
  40
+    Given I reply "no" to output "Would you like instructions?"
  41
+    Given I reply "yes" to output "Do you really want to quit"
  42
+    When I execute the process
  43
+    Then I should see the following output:
  44
+    """
  45
+    You are standing at the end of a road before a small brick building.
  46
+    Around you is a forest.  A small stream flows out of the building and
  47
+    down a gully.
  48
+    """
  49
+    When I enter "east"
  50
+    Then I should see the following output:
  51
+    """
  52
+    You are inside a building, a well house for a large spring.
  53
+    """
  54
+    When I enter "west"
  55
+    Then I should see the following output:
  56
+    """
  57
+    You're at end of road again.
  58
+    """
  59
+    When I enter "south"
  60
+    Then I should see the following output:
  61
+    """
  62
+    You are in a valley in the forest beside a stream tumbling along a
  63
+    rocky bed.
  64
+    """
  65
+    When I enter "quit"
  66
+    Then the process should exit succesfully
  67
+
4  examples/cucumber/support/env.rb
... ...
@@ -0,0 +1,4 @@
  1
+$:.unshift(File.expand_path('../../../lib', File.dirname(__FILE__)))
  2
+require 'greenletters'
  3
+require 'greenletters/cucumber_steps'
  4
+
505  lib/greenletters.rb
... ...
@@ -1,4 +1,29 @@
  1
+require 'logger'
  2
+require 'pty'
  3
+require 'forwardable'
  4
+require 'stringio'
  5
+require 'shellwords'
  6
+require 'rbconfig'
  7
+require 'strscan'
1 8
 
  9
+# A better expect.rb
  10
+#
  11
+# Implementation note: because of the way PTY is implemented in Ruby, it is
  12
+# possible when executing a quick non-interactive command for PTY::ChildExited
  13
+# to be raised before ever getting input/output handles to the child
  14
+# process. Without an output handle, it's not possible to read any output the
  15
+# process produced. This is obviously undesirable, especially since when a
  16
+# command is unexpectedly quick and noninteractive it's usually because there
  17
+# was an error and you really want to be able to see what the problem was.
  18
+#
  19
+# Greenletters' solution to this problem is to wrap every command in a short
  20
+# script. The script executes the passed command and on termination, outputs an
  21
+# easily recognizable marker string. Then it waits for acknowledgment (a
  22
+# newline) before exiting. When Greenletters sees the marker string in the
  23
+# output, it automatically performs the acknowledgement and allows the child
  24
+# process to finish. By forcing the child process to wait for acknowledgement,
  25
+# we guarantee that the child will never exit before we have a chance to look at
  26
+# the output.
2 27
 module Greenletters
3 28
 
4 29
   # :stopdoc:
@@ -56,10 +81,486 @@ def self.require_all_libs_relative_to( fname, dir = nil )
56 81
     search_me = ::File.expand_path(
57 82
         ::File.join(::File.dirname(fname), dir, '**', '*.rb'))
58 83
 
59  
-    Dir.glob(search_me).sort.each {|rb| require rb}
  84
+    Dir.glob(search_me).reject{|fn| fn =~ /cucumber_steps.rb$/}.sort.each {|rb| require rb}
60 85
   end
61 86
 
62  
-end  # module Greenletters
  87
+  LogicError   = Class.new(::Exception)
  88
+  SystemError  = Class.new(RuntimeError)
  89
+  TimeoutError = Class.new(SystemError)
  90
+  ClientError  = Class.new(RuntimeError)
  91
+  StateError   = Class.new(ClientError)
  92
+
  93
+  # This class offers a pass-through << operator and saves the most recent 256
  94
+  # bytes which have passed through.
  95
+  class TranscriptHistoryBuffer
  96
+    attr_reader :buffer
  97
+
  98
+    def initialize(transcript)
  99
+      @buffer     = String.new
  100
+      @transcript = transcript
  101
+    end
  102
+
  103
+    def <<(output)
  104
+      @buffer     << output
  105
+      @transcript << output
  106
+      length = [@buffer.length, 256].min
  107
+      @buffer = @buffer[-length, length]
  108
+      self
  109
+    end
  110
+  end
  111
+
  112
+  def Trigger(event, *args, &block)
  113
+    klass = trigger_class_for_event(event)
  114
+    klass.new(*args, &block)
  115
+  end
  116
+
  117
+  def trigger_class_for_event(event)
  118
+    ::Greenletters.const_get("#{event.to_s.capitalize}Trigger")
  119
+  end
  120
+
  121
+  class Trigger
  122
+    attr_accessor :time_to_live
  123
+    attr_accessor :exclusive
  124
+    attr_accessor :logger
  125
+    attr_accessor :interruption
  126
+
  127
+    alias_method :exclusive?, :exclusive
  128
+
  129
+    def initialize(options={}, &block)
  130
+      @block        = block || lambda{}
  131
+      @exclusive    = options.fetch(:exclusive) { false }
  132
+      @logger       = ::Logger.new($stdout)
  133
+      @interruption = :none
  134
+    end
  135
+
  136
+    def call(process)
  137
+      @block.call(process)
  138
+      true
  139
+    end
  140
+  end
  141
+
  142
+  class OutputTrigger < Trigger
  143
+    def initialize(pattern=//, options={}, &block)
  144
+      super(options, &block)
  145
+      @pattern = pattern
  146
+    end
  147
+
  148
+    def to_s
  149
+      "output matching #{@pattern.inspect}"
  150
+    end
  151
+
  152
+    def call(process)
  153
+      scanner = process.output_buffer
  154
+      @logger.debug "matching #{@pattern.inspect} against #{scanner.rest.inspect}"
  155
+      if scanner.scan_until(@pattern)
  156
+        @logger.debug "matched #{@pattern.inspect}"
  157
+        @block.call(process, scanner)
  158
+        true
  159
+      else
  160
+        false
  161
+      end
  162
+    end
  163
+  end
  164
+
  165
+  class TimeoutTrigger < Trigger
  166
+    def to_s
  167
+      "timeout"
  168
+    end
  169
+
  170
+    def call(process)
  171
+      @block.call(process, process.blocker)
  172
+      true
  173
+    end
  174
+  end
  175
+
  176
+  class ExitTrigger < Trigger
  177
+    attr_reader :pattern
  178
+
  179
+    def initialize(pattern=0, options={}, &block)
  180
+      super(options, &block)
  181
+      @pattern = pattern
  182
+    end
  183
+
  184
+    def call(process)
  185
+      if pattern === process.status.exitstatus
  186
+        @block.call(process, process.status)
  187
+        true
  188
+      else
  189
+        false
  190
+      end
  191
+    end
  192
+
  193
+    def to_s
  194
+      "exit with status #{pattern}"
  195
+    end
  196
+  end
  197
+
  198
+  class UnsatisfiedTrigger < Trigger
  199
+    def to_s
  200
+      "unsatisfied wait"
  201
+    end
  202
+
  203
+    def call(process)
  204
+      @block.call(process, process.interruption, process.blocker)
  205
+      true
  206
+    end
  207
+  end
  208
+
  209
+  class Process
  210
+    END_MARKER = '__GREENLETTERS_PROCESS_ENDED__'
  211
+
  212
+    # Shamelessly stolen from Rake
  213
+    RUBY_EXT =
  214
+      ((Config::CONFIG['ruby_install_name'] =~ /\.(com|cmd|exe|bat|rb|sh)$/) ?
  215
+      "" :
  216
+      Config::CONFIG['EXEEXT'])
  217
+    RUBY       = File.join(
  218
+      Config::CONFIG['bindir'],
  219
+      Config::CONFIG['ruby_install_name'] + RUBY_EXT).
  220
+      sub(/.*\s.*/m, '"\&"')
  221
+
  222
+    extend Forwardable
  223
+    include ::Greenletters
  224
+
  225
+    attr_reader   :command      # Command to run in a subshell
  226
+    attr_accessor :blocker      # The Trigger currently being waited for, if any
  227
+    attr_reader   :input_buffer # Input waiting to be written to process
  228
+    attr_reader   :output_buffer # Output ready to be read from process
  229
+    attr_reader   :status        # :not_started -> :running -> :ended -> :exited
  230
+    attr_reader   :cwd          # Working directory for the command
  231
+
  232
+    def_delegators :input_buffer, :puts, :write, :print, :printf, :<<
  233
+    def_delegators :output_buffer, :read, :readpartial, :read_nonblock, :gets,
  234
+                                   :getline
  235
+    def_delegators  :blocker, :interruption, :interruption=
  236
+
  237
+    def initialize(*args)
  238
+      options         = if args.last.is_a?(Hash) then args.pop else {} end
  239
+      @command        = args
  240
+      @triggers       = []
  241
+      @blocker        = nil
  242
+      @input_buffer   = StringIO.new
  243
+      @output_buffer  = StringScanner.new("")
  244
+      @env            = options.fetch(:env) {{}}
  245
+      @cwd            = options.fetch(:cwd) {Dir.pwd}
  246
+      @logger   = options.fetch(:logger) {
  247
+        l = ::Logger.new($stdout)
  248
+        l.level = ::Logger::WARN
  249
+        l
  250
+      }
  251
+      @state         = :not_started
  252
+      @shell         = options.fetch(:shell) { '/bin/sh' }
  253
+      @transcript    = options.fetch(:transcript) {
  254
+        t = Object.new
  255
+        def t.<<(*)
  256
+          # NOOP
  257
+        end
  258
+        t
  259
+      }
  260
+      @history = TranscriptHistoryBuffer.new(@transcript)
  261
+    end
  262
+
  263
+    def on(event, *args, &block)
  264
+      t = add_trigger(event, *args, &block)
  265
+    end
  266
+
  267
+    def wait_for(event, *args, &block)
  268
+      raise "Already waiting for #{blocker}" if blocker
  269
+      t = add_blocking_trigger(event, *args, &block)
  270
+      process_events
  271
+    rescue
  272
+      unblock!
  273
+      triggers.delete(t)
  274
+      raise
  275
+    end
  276
+
  277
+    def add_trigger(event, *args, &block)
  278
+      t = Trigger(event, *args, &block)
  279
+      t.logger = @logger
  280
+      triggers << t
  281
+      @logger.debug "added trigger on #{t}"
  282
+      t
  283
+    end
  284
+
  285
+    def prepend_trigger(event, *args, &block)
  286
+      t = Trigger(event, *args, &block)
  287
+      t.logger = @logger
  288
+      triggers.unshift(t)
  289
+      @logger.debug "prepended trigger on #{t}"
  290
+      t
  291
+    end
  292
+
  293
+
  294
+    def add_blocking_trigger(event, *args, &block)
  295
+      t = add_trigger(event, *args, &block)
  296
+      t.time_to_live = 1
  297
+      @logger.debug "waiting for #{t}"
  298
+      self.blocker = t
  299
+      t
  300
+    end
  301
+
  302
+    def start!
  303
+      raise StateError, "Already started!" unless not_started?
  304
+      @logger.debug "installing end marker handler for #{END_MARKER}"
  305
+      prepend_trigger(:output, /#{END_MARKER}/, :exclusive => false, :time_to_live => 1) do |process, data|
  306
+        handle_end_marker
  307
+      end
  308
+      handle_child_exit do
  309
+        cmd = wrapped_command
  310
+        @logger.debug "executing #{cmd.join(' ')}"
  311
+        merge_environment(@env) do
  312
+          @logger.debug "command environment:\n#{ENV.inspect}"
  313
+          @output, @input, @pid = PTY.spawn(*cmd)
  314
+        end
  315
+        @state = :running
  316
+        @logger.debug "spawned pid #{@pid}"
  317
+      end
  318
+    end
  319
+
  320
+    def flush_output_buffer!
  321
+      @logger.debug "flushing output buffer"
  322
+      @output_buffer.terminate
  323
+    end
  324
+
  325
+    def alive?
  326
+      ::Process.kill(0, @pid)
  327
+      true
  328
+    rescue Errno::ESRCH, Errno::ENOENT
  329
+      false
  330
+    end
  331
+
  332
+    def blocked?
  333
+      @blocker
  334
+    end
  335
+
  336
+    def running?
  337
+      @state == :running
  338
+    end
  339
+
  340
+    def not_started?
  341
+      @state == :not_started
  342
+    end
  343
+
  344
+    def exited?
  345
+      @state == :exited
  346
+    end
  347
+
  348
+    # Have we seen the end marker yet?
  349
+    def ended?
  350
+      @state == :ended
  351
+    end
  352
+
  353
+    private
  354
+
  355
+    attr_reader :triggers
  356
+
  357
+    def wrapped_command
  358
+      [RUBY,
  359
+        '-C', cwd,
  360
+        '-e', "system(*#{command.inspect})",
  361
+        '-e', "puts(#{END_MARKER.inspect})",
  362
+        '-e', "gets",
  363
+        '-e', "exit $?.exitstatus"
  364
+      ]
  365
+    end
  366
+
  367
+    def process_events
  368
+      raise StateError, "Process not started!" if not_started?
  369
+      handle_child_exit do
  370
+        while blocked?
  371
+          @logger.debug "select()"
  372
+          input_handles  = input_buffer.string.empty? ? [] : [@input]
  373
+          output_handles = [@output]
  374
+          error_handles  = [@input, @output]
  375
+          @logger.debug "select() on #{[output_handles, input_handles, error_handles].inspect}"
  376
+          ready_handles = IO.select(
  377
+            output_handles, input_handles, error_handles, 1.0)
  378
+          if ready_handles.nil?
  379
+            process_timeout
  380
+          else
  381
+            ready_outputs, ready_inputs, ready_errors = *ready_handles
  382
+            ready_errors.each do |handle| process_error(handle) end
  383
+            ready_outputs.each do |handle| process_output(handle) end
  384
+            ready_inputs.each do |handle| process_input(handle) end
  385
+          end
  386
+        end
  387
+      end
  388
+    end
  389
+
  390
+    def process_input(handle)
  391
+      @logger.debug "input ready #{handle.inspect}"
  392
+      handle.write(input_buffer.string)
  393
+      @logger.debug format_output_for_log(input_buffer.string)
  394
+      @logger.debug "wrote #{input_buffer.string.size} bytes"
  395
+      input_buffer.string = ""
  396
+    end
  397
+
  398
+    def process_output(handle)
  399
+      @logger.debug "output ready #{handle.inspect}"
  400
+      data = handle.readpartial(1024)
  401
+      output_buffer << data
  402
+      @history << data
  403
+      @logger.debug format_input_for_log(data)
  404
+      @logger.debug "read #{data.size} bytes"
  405
+      handle_triggers(:output)
  406
+      flush_triggers!(OutputTrigger) if ended?
  407
+      # flush_output_buffer! unless ended?
  408
+    end
  409
+
  410
+    def collect_remaining_output
  411
+      if @output.nil?
  412
+        @logger.debug "unable to collect output for missing output handle"
  413
+        return
  414
+      end
  415
+      @logger.debug "collecting remaining output"
  416
+      while data = @output.read_nonblock(1024)
  417
+        output_buffer << data
  418
+        @logger.debug "read #{data.size} bytes"
  419
+      end
  420
+    rescue EOFError, Errno::EIO => error
  421
+      @logger.debug error.message
  422
+    end
  423
+
  424
+    def wait_for_child_to_die
  425
+      # Soon we should get a PTY::ChildExited
  426
+      while running? || ended?
  427
+        @logger.debug "waiting for child #{@pid} to die"
  428
+        sleep 0.1
  429
+      end
  430
+    end
  431
+
  432
+    def process_error(handle)
  433
+      @logger.debug "error on #{handle.inspect}"
  434
+      raise NotImplementedError, "process_error()"
  435
+    end
  436
+
  437
+    def process_timeout
  438
+      @logger.debug "timeout"
  439
+      handle_triggers(:timeout)
  440
+      process_interruption(:timeout)
  441
+    end
  442
+
  443
+    def handle_exit(status=status_from_waitpid)
  444
+      return false if exited?
  445
+      @logger.debug "handling exit of process #{@pid}"
  446
+      @state  = :exited
  447
+      @status = status
  448
+      handle_triggers(:exit)
  449
+      if status == 0
  450
+        process_interruption(:exit)
  451
+      else
  452
+        process_interruption(:abnormal_exit)
  453
+      end
  454
+    end
  455
+
  456
+    def status_from_waitpid
  457
+      @logger.debug "waiting for exist status of #{@pid}"
  458
+      ::Process.waitpid2(@pid)[1]
  459
+    end
  460
+
  461
+    def handle_triggers(event)
  462
+      klass = trigger_class_for_event(event)
  463
+      matches = 0
  464
+      triggers.grep(klass).each do |t|
  465
+        @logger.debug "checking #{event} against #{t}"
  466
+        if t.call(self)         # match
  467
+          matches += 1
  468
+          @logger.debug "match trigger #{t}"
  469
+          if blocker.equal?(t)
  470
+            unblock!
  471
+          end
  472
+          if t.time_to_live
  473
+            if t.time_to_live > 1
  474
+              t.time_to_live -= 1
  475
+              @logger.debug "trigger ttl reduced to #{t.time_to_live}"
  476
+            else
  477
+              triggers.delete(t)
  478
+              @logger.debug "trigger removed"
  479
+            end
  480
+          end
  481
+          break if t.exclusive?
  482
+        else
  483
+          @logger.debug "no match"
  484
+        end
  485
+      end
  486
+      matches > 0
  487
+    end
  488
+
  489
+    def handle_end_marker
  490
+      return false if ended?
  491
+      @logger.debug "end marker found"
  492
+      output_buffer.string.gsub!(/#{END_MARKER}\s*/, '')
  493
+      output_buffer.unscan
  494
+      @state = :ended
  495
+      @logger.debug "end marker expunged from output buffer"
  496
+      @logger.debug "acknowledging end marker"
  497
+      self.puts
  498
+    end
  499
+
  500
+    def unblock!
  501
+      @logger.debug "unblocked"
  502
+      triggers.delete(@blocker)
  503
+      @blocker = nil
  504
+    end
  505
+
  506
+    def handle_child_exit
  507
+      handle_eio do
  508
+        yield
  509
+      end
  510
+    rescue PTY::ChildExited => error
  511
+      @logger.debug "caught PTY::ChildExited"
  512
+      collect_remaining_output
  513
+      handle_exit(error.status)
  514
+    end
  515
+
  516
+    def handle_eio
  517
+      yield
  518
+    rescue Errno::EIO => error
  519
+      @logger.debug "Errno::EIO caught"
  520
+      wait_for_child_to_die
  521
+    end
  522
+
  523
+    def flush_triggers!(kind)
  524
+      @logger.debug "flushing triggers matching #{kind}"
  525
+      triggers.delete_if{|t| kind === t}
  526
+    end
  527
+
  528
+    def merge_environment(new_env)
  529
+      old_env = new_env.inject({}) do |old, (key, value)|
  530
+        old[key] = ENV[key]
  531
+        ENV[key] = value
  532
+        old
  533
+      end
  534
+      yield
  535
+    ensure
  536
+      old_env.each_pair do |key, value|
  537
+        if value.nil? then ENV.delete(key) else ENV[key] = value end
  538
+      end
  539
+    end
  540
+
  541
+    def process_interruption(reason)
  542
+      if blocked?
  543
+        self.interruption = reason
  544
+        unless handle_triggers(:unsatisfied)
  545
+          raise SystemError,
  546
+                "Interrupted (#{reason}) while waiting for #{blocker}.\n" \
  547
+                "Recent activity:\n" +
  548
+                @history.buffer
  549
+        end
  550
+        unblock!
  551
+      end
  552
+    end
  553
+
  554
+    def format_output_for_log(text)
  555
+      "\n" + text.split("\n").map{|l| ">> #{l}"}.join("\n")
  556
+    end
  557
+
  558
+    def format_input_for_log(text)
  559
+      "\n" + text.split("\n").map{|l| "<< #{l}"}.join("\n")
  560
+    end
  561
+
  562
+  end
  563
+end
63 564
 
64 565
 Greenletters.require_all_libs_relative_to(__FILE__)
65 566
 
68  lib/greenletters/cucumber_steps.rb
... ...
@@ -0,0 +1,68 @@
  1
+require 'cucumber'
  2
+
  3
+module Greenletters
  4
+  module CucumberHelpers
  5
+    def greenletters_prepare_entry(text)
  6
+      text.chomp + "\n"
  7
+    end
  8
+    def greenletters_massage_pattern(text)
  9
+      Regexp.new(Regexp.escape(text.strip.tr_s(" \r\n\t", " ")).gsub('\ ', '\s+'))
  10
+    end
  11
+  end
  12
+end
  13
+
  14
+World(Greenletters::CucumberHelpers)
  15
+
  16
+Before do
  17
+  @greenletters_process_table = Hash.new {|h,k|
  18
+    raise "No such process defined: #{k}"
  19
+  }
  20
+end
  21
+
  22
+Given /^process activity is logged to "([^\"]*)"$/ do |filename|
  23
+  logger = ::Logger.new(open(filename, 'w+'))
  24
+  #logger.level = ::Logger::INFO
  25
+  logger.level = ::Logger::DEBUG
  26
+  @greenletters_process_log = logger
  27
+end
  28
+
  29
+Given /^a process (?:"([^\"]*)" )?from command "([^\"]*)"$/ do |name, command|
  30
+  name ||= "default"
  31
+  options = {
  32
+  }
  33
+  options[:logger] = @greenletters_process_log if @greenletters_process_log
  34
+  @greenletters_process_table[name] = Greenletters::Process.new(command, options)
  35
+end
  36
+
  37
+Given /^I reply "([^\"]*)" to output "([^\"]*)"(?: from process "([^\"]*)")?$/ do
  38
+  |reply, pattern, name|
  39
+  name ||= "default"
  40
+  pattern = greenletters_massage_pattern(pattern)
  41
+  @greenletters_process_table[name].on(:output, pattern) do |process, match_data|
  42
+    process << greenletters_prepare_entry(reply)
  43
+  end
  44
+end
  45
+
  46
+When /^I execute the process(?: "([^\"]*)")?$/ do |name|
  47
+  name ||= "default"
  48
+  @greenletters_process_table[name].start!
  49
+end
  50
+
  51
+Then /^I should see the following output(?: from process "([^\"]*)")?:$/ do
  52
+  |name, pattern|
  53
+  name ||= "default"
  54
+  pattern = greenletters_massage_pattern(pattern)
  55
+  @greenletters_process_table[name].wait_for(:output, pattern)
  56
+end
  57
+
  58
+When /^I enter "([^\"]*)"(?: into process "([^\"]*)")?$/ do
  59
+  |input, name|
  60
+  name ||= "default"
  61
+  @greenletters_process_table[name] << greenletters_prepare_entry(input)
  62
+end
  63
+
  64
+Then /^the process(?: "([^\"]*)")? should exit succesfully$/ do |name|
  65
+  name ||= "default"
  66
+  @greenletters_process_table[name].wait_for(:exit, 0)
  67
+end
  68
+
5  script/console
... ...
@@ -0,0 +1,5 @@
  1
+#!/usr/bin/env ruby
  2
+$:.unshift(File.expand_path("../lib", File.dirname(__FILE__)))
  3
+require 'greenletters'
  4
+require 'irb'
  5
+IRB.start
2  version.txt
... ...
@@ -1 +1 @@
1  
-0.0.0
  1
+0.0.1

0 notes on commit 23bf9dd

Please sign in to comment.
Something went wrong with that request. Please try again.