punchblock 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.travis.yml +3 -3
- data/CHANGELOG.md +23 -0
- data/lib/punchblock.rb +24 -0
- data/lib/punchblock/command/reject.rb +10 -2
- data/lib/punchblock/component/record.rb +16 -0
- data/lib/punchblock/core_ext/blather/stanza.rb +3 -1
- data/lib/punchblock/dead_actor_safety.rb +9 -0
- data/lib/punchblock/event/complete.rb +9 -11
- data/lib/punchblock/rayo_node.rb +4 -0
- data/lib/punchblock/translator/asterisk.rb +65 -22
- data/lib/punchblock/translator/asterisk/call.rb +49 -30
- data/lib/punchblock/translator/asterisk/component.rb +6 -8
- data/lib/punchblock/translator/asterisk/component/asterisk/agi_command.rb +13 -20
- data/lib/punchblock/translator/asterisk/component/asterisk/ami_action.rb +1 -1
- data/lib/punchblock/translator/asterisk/component/input.rb +3 -6
- data/lib/punchblock/translator/asterisk/component/output.rb +40 -45
- data/lib/punchblock/translator/asterisk/component/record.rb +1 -1
- data/lib/punchblock/translator/asterisk/component/stop_by_redirect.rb +5 -2
- data/lib/punchblock/version.rb +1 -1
- data/punchblock.gemspec +5 -5
- data/spec/punchblock/command/reject_spec.rb +7 -1
- data/spec/punchblock/command_node_spec.rb +5 -2
- data/spec/punchblock/component/component_node_spec.rb +4 -0
- data/spec/punchblock/component/output_spec.rb +1 -1
- data/spec/punchblock/component/record_spec.rb +30 -0
- data/spec/punchblock/event/complete_spec.rb +10 -0
- data/spec/punchblock/translator/asterisk/call_spec.rb +191 -48
- data/spec/punchblock/translator/asterisk/component/asterisk/agi_command_spec.rb +6 -39
- data/spec/punchblock/translator/asterisk/component/asterisk/ami_action_spec.rb +3 -3
- data/spec/punchblock/translator/asterisk/component/input_spec.rb +8 -3
- data/spec/punchblock/translator/asterisk/component/output_spec.rb +153 -46
- data/spec/punchblock/translator/asterisk/component/record_spec.rb +6 -5
- data/spec/punchblock/translator/asterisk/component/stop_by_redirect_spec.rb +1 -2
- data/spec/punchblock/translator/asterisk/component_spec.rb +1 -0
- data/spec/punchblock/translator/asterisk_spec.rb +147 -12
- data/spec/punchblock_spec.rb +34 -0
- data/spec/spec_helper.rb +5 -1
- metadata +30 -20
|
@@ -16,13 +16,15 @@ module Punchblock
|
|
|
16
16
|
OptionError = Class.new Punchblock::Error
|
|
17
17
|
|
|
18
18
|
include Celluloid
|
|
19
|
+
include DeadActorSafety
|
|
19
20
|
|
|
20
|
-
attr_reader :id, :call
|
|
21
|
+
attr_reader :id, :call, :call_id
|
|
21
22
|
attr_accessor :internal
|
|
22
23
|
|
|
23
24
|
def initialize(component_node, call = nil)
|
|
24
25
|
@component_node, @call = component_node, call
|
|
25
|
-
@
|
|
26
|
+
@call_id = safe_from_dead_actors { call.id } if call
|
|
27
|
+
@id = Punchblock.new_uuid
|
|
26
28
|
@complete = false
|
|
27
29
|
setup
|
|
28
30
|
pb_logger.debug "Starting up..."
|
|
@@ -53,16 +55,12 @@ module Punchblock
|
|
|
53
55
|
if internal
|
|
54
56
|
@component_node.add_event event
|
|
55
57
|
else
|
|
56
|
-
translator.handle_pb_event
|
|
58
|
+
safe_from_dead_actors { translator.handle_pb_event event }
|
|
57
59
|
end
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
def logger_id
|
|
61
|
-
"#{self.class}: #{
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def call_id
|
|
65
|
-
call.id if call
|
|
63
|
+
"#{self.class}: #{call_id ? "Call ID: #{call_id}, Component ID: #{id}" : id}"
|
|
66
64
|
end
|
|
67
65
|
|
|
68
66
|
def call_ended
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# encoding: utf-8
|
|
2
2
|
|
|
3
|
-
require 'uri'
|
|
4
3
|
require 'active_support/core_ext/string/filters'
|
|
5
4
|
|
|
6
5
|
module Punchblock
|
|
@@ -16,7 +15,8 @@ module Punchblock
|
|
|
16
15
|
end
|
|
17
16
|
|
|
18
17
|
def execute
|
|
19
|
-
|
|
18
|
+
send_ref
|
|
19
|
+
@call.send_ami_action @action
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def handle_ami_event(event)
|
|
@@ -29,19 +29,22 @@ module Punchblock
|
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
def handle_response(response)
|
|
33
|
+
pb_logger.debug "Handling response: #{response.inspect}"
|
|
34
|
+
case response
|
|
35
|
+
when RubyAMI::Error
|
|
36
|
+
set_node_response false
|
|
37
|
+
when RubyAMI::Response
|
|
38
|
+
send_ref
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
private
|
|
41
43
|
|
|
42
44
|
def create_action
|
|
45
|
+
command = current_actor
|
|
43
46
|
RubyAMI::Action.new 'AGI', 'Channel' => @call.channel, 'Command' => agi_command, 'CommandID' => id do |response|
|
|
44
|
-
handle_response response
|
|
47
|
+
command.handle_response response
|
|
45
48
|
end
|
|
46
49
|
end
|
|
47
50
|
|
|
@@ -55,19 +58,9 @@ module Punchblock
|
|
|
55
58
|
'"' + arg.to_s.gsub(/["\\]/) { |m| "\\#{m}" } + '"'
|
|
56
59
|
end
|
|
57
60
|
|
|
58
|
-
def handle_response(response)
|
|
59
|
-
pb_logger.debug "Handling response: #{response.inspect}"
|
|
60
|
-
case response
|
|
61
|
-
when RubyAMI::Error
|
|
62
|
-
set_node_response false
|
|
63
|
-
when RubyAMI::Response
|
|
64
|
-
send_ref
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
61
|
def success_reason(event)
|
|
69
|
-
|
|
70
|
-
Punchblock::Component::Asterisk::AGI::Command::Complete::Success.new :code => code, :result => result, :data => data
|
|
62
|
+
parser = RubyAMI::AGIResultParser.new event['Result']
|
|
63
|
+
Punchblock::Component::Asterisk::AGI::Command::Complete::Success.new :code => parser.code, :result => parser.result, :data => parser.data
|
|
71
64
|
end
|
|
72
65
|
end
|
|
73
66
|
end
|
|
@@ -13,7 +13,7 @@ module Punchblock
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def execute
|
|
16
|
-
@call.
|
|
16
|
+
@call.send_progress
|
|
17
17
|
initial_timeout = @component_node.initial_timeout || -1
|
|
18
18
|
@inter_digit_timeout = @component_node.inter_digit_timeout || -1
|
|
19
19
|
|
|
@@ -33,9 +33,7 @@ module Punchblock
|
|
|
33
33
|
|
|
34
34
|
component = current_actor
|
|
35
35
|
|
|
36
|
-
@
|
|
37
|
-
|
|
38
|
-
call.register_handler :ami, :name => 'DTMF' do |event|
|
|
36
|
+
@dtmf_handler_id = call.register_handler :ami, :name => 'DTMF' do |event|
|
|
39
37
|
component.process_dtmf! event['Digit'] if event['End'] == 'Yes'
|
|
40
38
|
end
|
|
41
39
|
rescue OptionError => e
|
|
@@ -43,7 +41,6 @@ module Punchblock
|
|
|
43
41
|
end
|
|
44
42
|
|
|
45
43
|
def process_dtmf(digit)
|
|
46
|
-
return unless @active
|
|
47
44
|
pb_logger.trace "Processing incoming DTMF digit #{digit}"
|
|
48
45
|
buffer << digit
|
|
49
46
|
cancel_initial_timer
|
|
@@ -113,7 +110,7 @@ module Punchblock
|
|
|
113
110
|
end
|
|
114
111
|
|
|
115
112
|
def complete(reason)
|
|
116
|
-
@
|
|
113
|
+
call.unregister_handler :ami, @dtmf_handler_id if instance_variable_defined?(:@dtmf_handler_id)
|
|
117
114
|
cancel_initial_timer
|
|
118
115
|
cancel_inter_digit_timer
|
|
119
116
|
send_complete_event reason
|
|
@@ -16,8 +16,6 @@ module Punchblock
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def execute
|
|
19
|
-
@call.answer_if_not_answered
|
|
20
|
-
|
|
21
19
|
raise OptionError, 'An SSML document is required.' unless @component_node.ssml
|
|
22
20
|
raise OptionError, 'An interrupt-on value of speech is unsupported.' if @component_node.interrupt_on == :speech
|
|
23
21
|
|
|
@@ -25,40 +23,43 @@ module Punchblock
|
|
|
25
23
|
raise OptionError, "A #{opt} value is unsupported on Asterisk." if @component_node.send opt
|
|
26
24
|
end
|
|
27
25
|
|
|
26
|
+
early = !@call.answered?
|
|
27
|
+
|
|
28
|
+
output_component = current_actor
|
|
29
|
+
|
|
28
30
|
case @media_engine
|
|
29
31
|
when :asterisk, nil
|
|
30
32
|
raise OptionError, "A voice value is unsupported on Asterisk." if @component_node.voice
|
|
33
|
+
raise OptionError, 'Interrupt digits are not allowed with early media.' if early && @component_node.interrupt_on
|
|
34
|
+
|
|
35
|
+
case @component_node.interrupt_on
|
|
36
|
+
when :any, :dtmf
|
|
37
|
+
interrupt = true
|
|
38
|
+
end
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
@pending_actions = @execution_elements.count
|
|
40
|
+
path = filenames.join '&'
|
|
34
41
|
|
|
35
42
|
send_ref
|
|
36
43
|
|
|
37
|
-
@
|
|
38
|
-
'0123456789*#'
|
|
39
|
-
else
|
|
40
|
-
nil
|
|
41
|
-
end
|
|
44
|
+
@call.send_progress if early
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
if interrupt
|
|
47
|
+
call.register_handler :ami, :name => 'DTMF' do |event|
|
|
48
|
+
output_component.stop_by_redirect Punchblock::Component::Output::Complete::Success.new if event['End'] == 'Yes'
|
|
49
|
+
end
|
|
47
50
|
end
|
|
51
|
+
|
|
52
|
+
opts = early ? "#{path},noanswer" : path
|
|
53
|
+
playback opts
|
|
48
54
|
when :unimrcp
|
|
49
55
|
send_ref
|
|
50
|
-
output_component = current_actor
|
|
51
56
|
@call.send_agi_action! 'EXEC MRCPSynth', escaped_doc, mrcpsynth_options do |complete_event|
|
|
52
57
|
pb_logger.debug "MRCPSynth completed with #{complete_event}."
|
|
53
58
|
output_component.send_complete_event! success_reason
|
|
54
59
|
end
|
|
55
60
|
when :swift
|
|
56
|
-
doc = escaped_doc
|
|
57
|
-
doc << "|1|1" if [:any, :dtmf].include? @component_node.interrupt_on
|
|
58
|
-
doc.insert 0, "#{@component_node.voice}^" if @component_node.voice
|
|
59
61
|
send_ref
|
|
60
|
-
|
|
61
|
-
@call.send_agi_action! 'EXEC Swift', doc do |complete_event|
|
|
62
|
+
@call.send_agi_action! 'EXEC Swift', swift_doc do |complete_event|
|
|
62
63
|
pb_logger.debug "Swift completed with #{complete_event}."
|
|
63
64
|
output_component.send_complete_event! success_reason
|
|
64
65
|
end
|
|
@@ -69,46 +70,33 @@ module Punchblock
|
|
|
69
70
|
with_error 'option error', e.message
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def filenames
|
|
76
|
+
@filenames ||= @component_node.ssml.children.map do |node|
|
|
74
77
|
case node
|
|
75
78
|
when RubySpeech::SSML::Audio
|
|
76
|
-
|
|
79
|
+
node.src
|
|
77
80
|
when String
|
|
78
|
-
raise
|
|
79
|
-
|
|
81
|
+
raise if node.include?(' ')
|
|
82
|
+
node
|
|
80
83
|
else
|
|
81
|
-
raise
|
|
84
|
+
raise
|
|
82
85
|
end
|
|
83
86
|
end.compact
|
|
84
87
|
rescue
|
|
85
88
|
raise UnrenderableDocError, 'The provided document could not be rendered.'
|
|
86
89
|
end
|
|
87
90
|
|
|
88
|
-
def
|
|
89
|
-
|
|
90
|
-
pb_logger.debug "Received action completion. Now waiting on #{@pending_actions} actions."
|
|
91
|
-
if @pending_actions < 1
|
|
92
|
-
pb_logger.debug "Sending complete event"
|
|
93
|
-
send_complete_event success_reason
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def continue(event = nil)
|
|
98
|
-
signal :continue, event
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def play_audio(path)
|
|
102
|
-
pb_logger.debug "Playing an audio file (#{path}) via STREAM FILE"
|
|
91
|
+
def playback(path)
|
|
92
|
+
pb_logger.debug "Playing an audio file (#{path}) via Playback"
|
|
103
93
|
op = current_actor
|
|
104
|
-
@call.send_agi_action! '
|
|
105
|
-
pb_logger.debug "
|
|
106
|
-
op.
|
|
94
|
+
@call.send_agi_action! 'EXEC Playback', path do |complete_event|
|
|
95
|
+
pb_logger.debug "File playback completed with #{complete_event}. Sending complete event"
|
|
96
|
+
op.send_complete_event! success_reason
|
|
107
97
|
end
|
|
108
98
|
end
|
|
109
99
|
|
|
110
|
-
private
|
|
111
|
-
|
|
112
100
|
def escaped_doc
|
|
113
101
|
@component_node.ssml.to_s.squish.gsub(/["\\]/) { |m| "\\#{m}" }
|
|
114
102
|
end
|
|
@@ -120,6 +108,13 @@ module Punchblock
|
|
|
120
108
|
end.join '&'
|
|
121
109
|
end
|
|
122
110
|
|
|
111
|
+
def swift_doc
|
|
112
|
+
doc = escaped_doc
|
|
113
|
+
doc << "|1|1" if [:any, :dtmf].include? @component_node.interrupt_on
|
|
114
|
+
doc.insert 0, "#{@component_node.voice}^" if @component_node.voice
|
|
115
|
+
doc
|
|
116
|
+
end
|
|
117
|
+
|
|
123
118
|
def success_reason
|
|
124
119
|
Punchblock::Component::Output::Complete::Success.new
|
|
125
120
|
end
|
|
@@ -14,6 +14,7 @@ module Punchblock
|
|
|
14
14
|
def execute
|
|
15
15
|
max_duration = @component_node.max_duration || -1
|
|
16
16
|
|
|
17
|
+
raise OptionError, 'Record cannot be used on a call that is not answered.' unless @call.answered?
|
|
17
18
|
raise OptionError, 'A start-paused value of true is unsupported.' if @component_node.start_paused
|
|
18
19
|
raise OptionError, 'An initial-timeout value is unsupported.' if @component_node.initial_timeout && @component_node.initial_timeout != -1
|
|
19
20
|
raise OptionError, 'A final-timeout value is unsupported.' if @component_node.final_timeout && @component_node.final_timeout != -1
|
|
@@ -21,7 +22,6 @@ module Punchblock
|
|
|
21
22
|
|
|
22
23
|
@format = @component_node.format || 'wav'
|
|
23
24
|
|
|
24
|
-
@call.answer_if_not_answered
|
|
25
25
|
|
|
26
26
|
component = current_actor
|
|
27
27
|
call.register_handler :ami, :name => 'MonitorStop' do |event|
|
|
@@ -9,12 +9,15 @@ module Punchblock
|
|
|
9
9
|
module StopByRedirect
|
|
10
10
|
def execute_command(command)
|
|
11
11
|
return super unless command.is_a?(Punchblock::Component::Stop)
|
|
12
|
+
stop_by_redirect Punchblock::Event::Complete::Stop.new
|
|
13
|
+
command.response = true
|
|
14
|
+
end
|
|
12
15
|
|
|
16
|
+
def stop_by_redirect(complete_reason)
|
|
13
17
|
component_actor = current_actor
|
|
14
18
|
call.register_handler :ami, lambda { |e| e['SubEvent'] == 'Start' }, :name => 'AsyncAGI' do |event|
|
|
15
|
-
component_actor.send_complete_event!
|
|
19
|
+
component_actor.send_complete_event! complete_reason
|
|
16
20
|
end
|
|
17
|
-
command.response = true
|
|
18
21
|
call.redirect_back!
|
|
19
22
|
end
|
|
20
23
|
end
|
data/lib/punchblock/version.rb
CHANGED
data/punchblock.gemspec
CHANGED
|
@@ -29,13 +29,13 @@ Gem::Specification.new do |s|
|
|
|
29
29
|
s.add_runtime_dependency %q<future-resource>, ["~> 1.0"]
|
|
30
30
|
s.add_runtime_dependency %q<has-guarded-handlers>, ["~> 1.0"]
|
|
31
31
|
s.add_runtime_dependency %q<celluloid>, [">= 0.10.0"]
|
|
32
|
-
s.add_runtime_dependency %q<ruby_ami>, ["~> 1.
|
|
32
|
+
s.add_runtime_dependency %q<ruby_ami>, ["~> 1.2", ">= 1.2.1"]
|
|
33
33
|
s.add_runtime_dependency %q<ruby_speech>, ["~> 1.0"]
|
|
34
34
|
|
|
35
|
-
s.add_development_dependency %q<bundler>, ["
|
|
36
|
-
s.add_development_dependency %q<rspec>, ["~> 2.7
|
|
37
|
-
s.add_development_dependency %q<ci_reporter>, ["
|
|
38
|
-
s.add_development_dependency %q<yard>, ["
|
|
35
|
+
s.add_development_dependency %q<bundler>, ["~> 1.0"]
|
|
36
|
+
s.add_development_dependency %q<rspec>, ["~> 2.7"]
|
|
37
|
+
s.add_development_dependency %q<ci_reporter>, ["~> 1.6"]
|
|
38
|
+
s.add_development_dependency %q<yard>, ["~> 0.6"]
|
|
39
39
|
s.add_development_dependency %q<rake>, [">= 0"]
|
|
40
40
|
s.add_development_dependency %q<mocha>, [">= 0"]
|
|
41
41
|
s.add_development_dependency %q<i18n>, [">= 0"]
|
|
@@ -37,7 +37,7 @@ module Punchblock
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
describe "with the reason" do
|
|
40
|
-
[:decline, :busy, :error].each do |reason|
|
|
40
|
+
[nil, :decline, :busy, :error].each do |reason|
|
|
41
41
|
describe reason do
|
|
42
42
|
subject { Reject.new :reason => reason }
|
|
43
43
|
|
|
@@ -45,6 +45,12 @@ module Punchblock
|
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
describe "no reason" do
|
|
49
|
+
subject { Reject.new }
|
|
50
|
+
|
|
51
|
+
its(:reason) { should be_nil }
|
|
52
|
+
end
|
|
53
|
+
|
|
48
54
|
describe "blahblahblah" do
|
|
49
55
|
it "should raise an error" do
|
|
50
56
|
expect { Reject.new(:reason => :blahblahblah) }.to raise_error ArgumentError
|
|
@@ -5,6 +5,11 @@ require 'spec_helper'
|
|
|
5
5
|
module Punchblock
|
|
6
6
|
module Command
|
|
7
7
|
describe CommandNode do
|
|
8
|
+
let(:args) { [] }
|
|
9
|
+
subject do
|
|
10
|
+
Class.new(described_class) { register 'foo'}.new(*args)
|
|
11
|
+
end
|
|
12
|
+
|
|
8
13
|
its(:state_name) { should be == :new }
|
|
9
14
|
|
|
10
15
|
describe "#new" do
|
|
@@ -13,8 +18,6 @@ module Punchblock
|
|
|
13
18
|
let(:component_id) { 'abc123' }
|
|
14
19
|
let(:args) { [{:target_call_id => call_id, :component_id => component_id}] }
|
|
15
20
|
|
|
16
|
-
subject { CommandNode.new(*args) }
|
|
17
|
-
|
|
18
21
|
its(:target_call_id) { should be == call_id }
|
|
19
22
|
its(:component_id) { should be == component_id }
|
|
20
23
|
end
|
|
@@ -230,7 +230,7 @@ module Punchblock
|
|
|
230
230
|
describe '#seek_action' do
|
|
231
231
|
subject { command.seek_action seek_options }
|
|
232
232
|
|
|
233
|
-
its(:to_xml) { should be == '<seek xmlns="urn:xmpp:rayo:output:1" direction="forward" amount="1500"/>' }
|
|
233
|
+
its(:to_xml) { should be == Nokogiri::XML('<seek xmlns="urn:xmpp:rayo:output:1" direction="forward" amount="1500"/>').root.to_xml }
|
|
234
234
|
its(:component_id) { should be == 'abc123' }
|
|
235
235
|
its(:target_call_id) { should be == '123abc' }
|
|
236
236
|
end
|
|
@@ -154,6 +154,36 @@ module Punchblock
|
|
|
154
154
|
end
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
+
context "direct recording accessors" do
|
|
158
|
+
let :stanza do
|
|
159
|
+
<<-MESSAGE
|
|
160
|
+
<complete xmlns='urn:xmpp:rayo:ext:1'>
|
|
161
|
+
<success xmlns='urn:xmpp:rayo:record:complete:1'/>
|
|
162
|
+
<recording xmlns='urn:xmpp:rayo:record:complete:1' uri="file:/tmp/rayo7451601434771683422.mp3" duration="34000" size="23450"/>
|
|
163
|
+
</complete>
|
|
164
|
+
MESSAGE
|
|
165
|
+
end
|
|
166
|
+
let(:event) { RayoNode.import(parse_stanza(stanza).root) }
|
|
167
|
+
|
|
168
|
+
before do
|
|
169
|
+
subject.request!
|
|
170
|
+
subject.execute!
|
|
171
|
+
subject.add_event event
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
describe "#recording" do
|
|
175
|
+
it "should be a Punchblock::Component::Record::Recording" do
|
|
176
|
+
subject.recording.should be_a Punchblock::Component::Record::Recording
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
describe "#recording_uri" do
|
|
181
|
+
it "should be the recording URI set earlier" do
|
|
182
|
+
subject.recording_uri.should be == "file:/tmp/rayo7451601434771683422.mp3"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
157
187
|
describe '#stop_action' do
|
|
158
188
|
subject { command.stop_action }
|
|
159
189
|
|