aws-swf 0.1.2
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/Gemfile +5 -0
- data/Gemfile.lock +28 -0
- data/LICENSE +21 -0
- data/README.md +254 -0
- data/bin/swf_run +11 -0
- data/lib/aws-swf.rb +5 -0
- data/lib/swf.rb +54 -0
- data/lib/swf/activity_task_handler.rb +51 -0
- data/lib/swf/boot.rb +104 -0
- data/lib/swf/decision_task_handler.rb +105 -0
- data/lib/swf/runner.rb +52 -0
- data/lib/swf/task_handler.rb +56 -0
- data/lib/workflows.rb +27 -0
- data/spec/swf/activity_task_handler_spec.rb +58 -0
- data/spec/swf/boot_spec.rb +107 -0
- data/spec/swf/decision_task_handler_spec.rb +141 -0
- data/spec/swf/runner_spec.rb +47 -0
- data/spec/swf/task_handler_spec.rb +65 -0
- data/spec/swf_spec.rb +81 -0
- metadata +86 -0
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'swf/task_handler'
|
2
|
+
|
3
|
+
module SWF
|
4
|
+
|
5
|
+
class MissingWorkflowStartedEvent < StandardError; end
|
6
|
+
|
7
|
+
# subclass must call .register(name, version), and define #handle(runner, task)
|
8
|
+
class DecisionTaskHandler
|
9
|
+
extend TaskHandler
|
10
|
+
|
11
|
+
@@handler_classes_by_name_version = {}
|
12
|
+
|
13
|
+
# Register statically self (subclass) to handle workflow_type with given name and version
|
14
|
+
def self.register(name, version)
|
15
|
+
@@handler_classes_by_name_version [ [name.to_s, version.to_s] ] = self
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.fail!(task, args={})
|
19
|
+
task.fail_workflow_execution(args)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.find_handler_class(task)
|
23
|
+
type = task.workflow_type
|
24
|
+
@@handler_classes_by_name_version[ [type.name, type.version] ]
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.configuration_help_message
|
28
|
+
"Each decision task handler running on this task list in this domain must know how to handle this workflow_type's name and version.\n" +
|
29
|
+
"I only know: #{@@handler_classes_by_name_version.inspect}"
|
30
|
+
end
|
31
|
+
|
32
|
+
attr_reader :runner, :decision_task
|
33
|
+
|
34
|
+
def initialize(runner, decision_task)
|
35
|
+
@runner = runner
|
36
|
+
@decision_task = decision_task
|
37
|
+
end
|
38
|
+
|
39
|
+
def call_handle
|
40
|
+
handle
|
41
|
+
end
|
42
|
+
|
43
|
+
def events
|
44
|
+
# make events into an array to avoid token timeout issues
|
45
|
+
# see https://forums.aws.amazon.com/thread.jspa?threadID=98925
|
46
|
+
@events ||= decision_task.events.to_a
|
47
|
+
end
|
48
|
+
|
49
|
+
def new_events
|
50
|
+
enum_for(:_new_events)
|
51
|
+
end
|
52
|
+
|
53
|
+
def _new_events(&block)
|
54
|
+
events.each {|e|
|
55
|
+
yield(e) if e.new?
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
# slot time of 51.2 microseconds is way too little
|
60
|
+
# even 1 second still results in collision growing
|
61
|
+
# but it doesn't seem to get to 10 so we'll leave
|
62
|
+
# it at that for now
|
63
|
+
def slot_time
|
64
|
+
#5.12e-5 # you wish
|
65
|
+
1
|
66
|
+
end
|
67
|
+
|
68
|
+
# exponential backoff handles rate limiting exceptions
|
69
|
+
# when querying tags on a workflow execution.
|
70
|
+
def tags
|
71
|
+
runner.tag_lists[decision_task.workflow_execution] ||= begin
|
72
|
+
collision = 0
|
73
|
+
begin
|
74
|
+
decision_task.workflow_execution.tags
|
75
|
+
rescue => e
|
76
|
+
collision += 1 if collision < 10
|
77
|
+
max_slot_delay = 2**collision - 1
|
78
|
+
sleep(slot_time * rand(0 .. max_slot_delay))
|
79
|
+
retry
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
|
85
|
+
def workflow_started_event
|
86
|
+
@workflow_started_event ||= begin
|
87
|
+
events.find {|e| e.event_type == 'WorkflowExecutionStarted' } or raise MissingWorkflowStartedEvent, "Missing WorkflowExecutionStarted event in #{runner}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def workflow_task_list
|
92
|
+
@workflow_task_list ||= workflow_started_event.attributes.task_list
|
93
|
+
end
|
94
|
+
|
95
|
+
def workflow_input
|
96
|
+
@workflow_input ||= JSON.parse(workflow_started_event.attributes.input)
|
97
|
+
end
|
98
|
+
|
99
|
+
def event_input(event)
|
100
|
+
JSON.parse(event.attributes.input)
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
data/lib/swf/runner.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'swf'
|
2
|
+
|
3
|
+
module SWF
|
4
|
+
class Runner
|
5
|
+
|
6
|
+
attr_reader :domain_name, :task_list_name
|
7
|
+
|
8
|
+
def initialize(domain_name, task_list_name)
|
9
|
+
@domain_name = domain_name
|
10
|
+
@task_list_name = task_list_name
|
11
|
+
end
|
12
|
+
|
13
|
+
def be_decider
|
14
|
+
domain.decision_tasks.poll(task_list) {|decision_task|
|
15
|
+
DecisionTaskHandler.handle(self, decision_task)
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
def be_worker
|
20
|
+
domain.activity_tasks.poll(task_list) {|activity_task|
|
21
|
+
ActivityTaskHandler.handle(self, activity_task)
|
22
|
+
}
|
23
|
+
end
|
24
|
+
|
25
|
+
# these are static for workflow executions
|
26
|
+
# so no need to refetch per decision_task
|
27
|
+
def tag_lists
|
28
|
+
@tag_lists ||= {}
|
29
|
+
end
|
30
|
+
|
31
|
+
def domain
|
32
|
+
@domain ||= begin
|
33
|
+
SWF.domain_name = domain_name
|
34
|
+
SWF.domain
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def task_list
|
39
|
+
@task_list ||= begin
|
40
|
+
SWF.task_list = task_list_name
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
def effect_activity_type(name, version, options={})
|
46
|
+
@activity_types ||= {}
|
47
|
+
@activity_types[[name, version]] ||= domain.activity_types.find {|t| [t.name, t.version] == [name, version] }
|
48
|
+
@activity_types[[name, version]] ||= domain.activity_types.create(name, version, options)
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module SWF
|
4
|
+
|
5
|
+
# use extend, not include
|
6
|
+
module TaskHandler
|
7
|
+
|
8
|
+
def handle(runner, task)
|
9
|
+
handler_class = nil
|
10
|
+
handler = nil
|
11
|
+
begin
|
12
|
+
|
13
|
+
handler_class = get_handler_class_or_fail task
|
14
|
+
return unless handler_class
|
15
|
+
handler = handler_class.new(runner, task)
|
16
|
+
handler.call_handle
|
17
|
+
|
18
|
+
rescue => e
|
19
|
+
|
20
|
+
puts "HANDLER #{self} ERROR:"
|
21
|
+
begin
|
22
|
+
details_json = JSON.pretty_unparse({
|
23
|
+
handler_class: handler_class && handler_class.name,
|
24
|
+
handler: handler.to_s,
|
25
|
+
error: e.inspect,
|
26
|
+
backtrace: e.backtrace,
|
27
|
+
})
|
28
|
+
puts details_json
|
29
|
+
fail!(task, reason: "handler raised error", details: details_json[0...32768])
|
30
|
+
rescue
|
31
|
+
msg = "FAIL to handle fail!!"
|
32
|
+
puts msg
|
33
|
+
# failing again will cause #<RuntimeError: already responded>
|
34
|
+
#fail!(task, reason: msg)
|
35
|
+
raise
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def get_handler_class_or_fail(task)
|
44
|
+
find_handler_class(task).tap {|handler_class|
|
45
|
+
unless handler_class
|
46
|
+
details_text = "This is a configuration issue.\n#{configuration_help_message}"
|
47
|
+
puts details_text
|
48
|
+
fail!(task, reason: "unknown type", details: details_text)
|
49
|
+
return
|
50
|
+
end
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
data/lib/workflows.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'swf'
|
2
|
+
|
3
|
+
module SWF
|
4
|
+
module Workflows
|
5
|
+
extend self
|
6
|
+
|
7
|
+
def effect_workflow_type(name, version, options={})
|
8
|
+
@workflow_types ||= {}
|
9
|
+
@workflow_types[[name, version]] ||= SWF.domain.workflow_types.find {|t| [t.name, t.version] == [name, version] }
|
10
|
+
@workflow_types[[name, version]] ||= SWF.domain.workflow_types.create(name, version, options)
|
11
|
+
end
|
12
|
+
|
13
|
+
def start(options, execution_options)
|
14
|
+
execution_options[:task_list] ||= SWF.task_list
|
15
|
+
execution_options.merge!({
|
16
|
+
input: options.to_json
|
17
|
+
})
|
18
|
+
workflow_type.start_execution(execution_options)
|
19
|
+
end
|
20
|
+
|
21
|
+
def wait_for_workflow_execution_complete(workflow_execution)
|
22
|
+
sleep 1 while :open == (status = workflow_execution.status)
|
23
|
+
raise "workflow_execution #{workflow_execution} did not succeed: #{workflow_execution.status}" unless status == :completed
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require './lib/swf/activity_task_handler.rb'
|
2
|
+
|
3
|
+
subject_class = SWF::ActivityTaskHandler
|
4
|
+
describe subject_class do
|
5
|
+
|
6
|
+
describe ' .register, .find_handler_class' do
|
7
|
+
before { mock_subclass }
|
8
|
+
|
9
|
+
let(:mock_subclass) {
|
10
|
+
Class.new(subject_class) {
|
11
|
+
register
|
12
|
+
def handle_fake_activity; end
|
13
|
+
}
|
14
|
+
}
|
15
|
+
|
16
|
+
it "registers and finds a subclass with a handle_* method matching a given activity name" do
|
17
|
+
subject_class.find_handler_class(double(:task, activity_type: double(name: 'fake_activity', version: 'fake_version'))).should == mock_subclass
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
let(:subject){ subject_class.new(:runner_placeholder, task) }
|
22
|
+
let(:input) { {"some" => "data"} }
|
23
|
+
let(:task){ double(:task, activity_type: double(name: 'fake_activity'), input: input.to_json) }
|
24
|
+
describe '#initialize' do
|
25
|
+
it 'sets runner & decision_task' do
|
26
|
+
subject.runner.should == :runner_placeholder
|
27
|
+
subject.activity_task.should == task
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
describe '#call_handle' do
|
32
|
+
it "calls #handle_* method" do
|
33
|
+
subject.should_receive(:handle_fake_activity)
|
34
|
+
|
35
|
+
subject.call_handle
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
describe '#activity_task_input' do
|
40
|
+
it 'JSON parses activity_task.input' do
|
41
|
+
subject.activity_task_input.should == input
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe '.fail!' do
|
46
|
+
it 'fails the activity task' do
|
47
|
+
task = double(:task).tap {|o| o.should_receive(:fail!).with(:args_placeholder) }
|
48
|
+
subject_class.fail! task, :args_placeholder
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe '.configuration_help_message' do
|
53
|
+
it { subject_class.configuration_help_message.should be_is_a String }
|
54
|
+
it { subject_class.configuration_help_message.length.should > 100 }
|
55
|
+
it { subject_class.configuration_help_message.should include 'activity' }
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require './lib/swf/boot'
|
2
|
+
|
3
|
+
describe SWF::Boot do
|
4
|
+
before do
|
5
|
+
$stdout.stub(:write)
|
6
|
+
|
7
|
+
SWF::Boot.stub(:settings) {
|
8
|
+
{ domain_name: 'phony_domain', task_list_name: 'phony_task_list'}
|
9
|
+
}
|
10
|
+
end
|
11
|
+
|
12
|
+
describe '.swf_runner' do
|
13
|
+
it "creates a new Runner object with the proper arguments" do
|
14
|
+
SWF::Runner.should_receive(:new).with('phony_domain', 'phony_task_list')
|
15
|
+
SWF::Boot.swf_runner
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '.startup' do
|
20
|
+
before do
|
21
|
+
SWF::Boot.stub(:swf_runner) { double(:runner, be_worker: 'worker', be_decider: 'decider') }
|
22
|
+
end
|
23
|
+
|
24
|
+
context 'without waiting for children' do
|
25
|
+
it 'forks deciders' do
|
26
|
+
Process.should_receive(:fork).exactly(5).times do |&blk|
|
27
|
+
Process.should_receive(:daemon).with(true)
|
28
|
+
SWF::Boot.should_receive(:swf_runner).once
|
29
|
+
blk.call
|
30
|
+
end
|
31
|
+
Process.should_receive(:detach).with('decider').exactly(5).times
|
32
|
+
SWF::Boot.startup(5,0)
|
33
|
+
end
|
34
|
+
it 'forks workers' do
|
35
|
+
Process.should_receive(:fork).exactly(5).times do |&blk|
|
36
|
+
Process.should_receive(:daemon).with(true)
|
37
|
+
SWF::Boot.should_receive(:swf_runner).once
|
38
|
+
blk.call
|
39
|
+
end
|
40
|
+
Process.should_receive(:detach).with('worker').exactly(5).times
|
41
|
+
SWF::Boot.startup(0,5)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
context 'with waiting for children' do
|
46
|
+
it 'forks deciders' do
|
47
|
+
Process.should_receive(:fork).exactly(5).times do |&blk|
|
48
|
+
Process.should_not_receive(:daemon)
|
49
|
+
SWF::Boot.should_receive(:swf_runner).once
|
50
|
+
blk.call
|
51
|
+
end
|
52
|
+
Process.should_receive(:wait).with('decider').exactly(5).times
|
53
|
+
SWF::Boot.startup(5,0,true)
|
54
|
+
end
|
55
|
+
it 'forks workers' do
|
56
|
+
Process.should_receive(:fork).exactly(5).times do |&blk|
|
57
|
+
Process.should_not_receive(:daemon)
|
58
|
+
SWF::Boot.should_receive(:swf_runner).once
|
59
|
+
blk.call
|
60
|
+
end
|
61
|
+
Process.should_receive(:wait).with('worker').exactly(5).times
|
62
|
+
SWF::Boot.startup(0,5,true)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
context 'error handling' do
|
67
|
+
before do
|
68
|
+
SWF::Boot.stub(:swf_runner) {
|
69
|
+
double(:runner).tap {|runner|
|
70
|
+
runner.stub(:be_worker) { raise StandardError }
|
71
|
+
runner.stub(:be_decider) { raise StandardError }
|
72
|
+
}
|
73
|
+
}
|
74
|
+
end
|
75
|
+
it 'fails on deciders' do
|
76
|
+
Process.should_receive(:fork).exactly(5).times do |&blk|
|
77
|
+
Process.should_receive(:daemon)
|
78
|
+
SWF::Boot.should_receive(:swf_runner).twice
|
79
|
+
->{blk.call}.should raise_exception(SWF::Boot::DeciderStartupFailure)
|
80
|
+
end
|
81
|
+
Process.should_receive(:detach).exactly(5).times
|
82
|
+
SWF::Boot.startup(5,0,false)
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'fails on workers' do
|
86
|
+
Process.should_receive(:fork).exactly(5).times do |&blk|
|
87
|
+
Process.should_receive(:daemon)
|
88
|
+
SWF::Boot.should_receive(:swf_runner).twice
|
89
|
+
->{blk.call}.should raise_exception(SWF::Boot::WorkerStartupFailure)
|
90
|
+
end
|
91
|
+
Process.should_receive(:detach).exactly(5).times
|
92
|
+
SWF::Boot.startup(0,5,false)
|
93
|
+
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
describe '.terminate_children' do
|
99
|
+
it 'calls Process.kill for each pid passed' do
|
100
|
+
[1,2,3].each {|pid|
|
101
|
+
Process.should_receive(:kill).with("TERM", pid).once
|
102
|
+
}
|
103
|
+
SWF::Boot.terminate_children([1,2,3])
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
require './lib/swf/decision_task_handler.rb'
|
2
|
+
|
3
|
+
subject_class = SWF::DecisionTaskHandler
|
4
|
+
describe subject_class do
|
5
|
+
|
6
|
+
describe ' .register, .find_handler_class' do
|
7
|
+
before { mock_subclass }
|
8
|
+
|
9
|
+
let(:mock_subclass) {
|
10
|
+
Class.new(subject_class) {
|
11
|
+
register :fake_name, "fake_version"
|
12
|
+
}
|
13
|
+
}
|
14
|
+
|
15
|
+
it "registers and finds a subclass for a given workflow name/version" do
|
16
|
+
subject_class.find_handler_class(double(:task, workflow_type: double(name: 'fake_name', version: 'fake_version'))).should == mock_subclass
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
let(:decision_task) { double(:decision_task, events: []) }
|
21
|
+
let(:subject){ subject_class.new(:runner_placeholder, decision_task ) }
|
22
|
+
|
23
|
+
describe '#initialize' do
|
24
|
+
it 'sets runner & decision_task' do
|
25
|
+
subject.runner.should == :runner_placeholder
|
26
|
+
subject.decision_task.should == decision_task
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe '#call_handle' do
|
31
|
+
it "calls #handle" do
|
32
|
+
subject.should_receive(:handle)
|
33
|
+
subject.call_handle
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe '#events' do
|
38
|
+
it 'calls decision_task.events if no @events' do
|
39
|
+
subject.instance_variable_set(:@events, nil)
|
40
|
+
subject.decision_task.should_receive(:events)
|
41
|
+
subject.events.should == []
|
42
|
+
end
|
43
|
+
it 'otherwise just returns @events' do
|
44
|
+
subject.instance_variable_set(:@events, :foobar)
|
45
|
+
subject.decision_task.should_not_receive(:events)
|
46
|
+
subject.events.should == :foobar
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
let(:new_events) {[
|
51
|
+
double(:event,
|
52
|
+
event_type: 'new_event',
|
53
|
+
new?: true
|
54
|
+
),
|
55
|
+
double(:event,
|
56
|
+
event_type: 'another_new_event',
|
57
|
+
new?: true
|
58
|
+
)
|
59
|
+
]}
|
60
|
+
|
61
|
+
let(:old_events) {[
|
62
|
+
double(:event,
|
63
|
+
event_type: 'old_event',
|
64
|
+
new?: false
|
65
|
+
),
|
66
|
+
double(:event,
|
67
|
+
event_type: 'another_old_event',
|
68
|
+
new?: false
|
69
|
+
)
|
70
|
+
]}
|
71
|
+
|
72
|
+
describe '#new_events' do
|
73
|
+
before do
|
74
|
+
subject.decision_task.stub(:events) {
|
75
|
+
new_events + old_events
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'enumerates over new events' do
|
80
|
+
subject.send(:new_events).each {|e|
|
81
|
+
new_events.include?(e).should be_true
|
82
|
+
old_events.include?(e).should be_false
|
83
|
+
}
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
|
88
|
+
let(:workflow_started_input){ {"foo" => "bar"} }
|
89
|
+
let(:task_list) { 'foobar' }
|
90
|
+
let(:workflow_started_event) {
|
91
|
+
double(:event,
|
92
|
+
event_type: 'WorkflowExecutionStarted',
|
93
|
+
attributes: double(:attributes,
|
94
|
+
task_list: task_list,
|
95
|
+
input: workflow_started_input.to_json
|
96
|
+
)
|
97
|
+
)
|
98
|
+
}
|
99
|
+
|
100
|
+
describe '#workflow_started_event' do
|
101
|
+
it 'raises FeatureMatrix::Workflows::MissingWorkflowStartedEvent if there is no WorkflowExecutionStartedEvent' do
|
102
|
+
->{ subject.send(:workflow_started_event) }.should raise_exception(SWF::MissingWorkflowStartedEvent)
|
103
|
+
end
|
104
|
+
it 'otherwise returns the workflow started event' do
|
105
|
+
subject.decision_task.stub(:events){ [ workflow_started_event] }
|
106
|
+
subject.send(:workflow_started_event).should == workflow_started_event
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
context 'with a workflow_started_event' do
|
111
|
+
before do
|
112
|
+
subject.stub(:workflow_started_event) { workflow_started_event }
|
113
|
+
end
|
114
|
+
|
115
|
+
describe '#workflow_task_list' do
|
116
|
+
it 'returns the task list' do
|
117
|
+
subject.send(:workflow_task_list).should == workflow_started_event.attributes.task_list
|
118
|
+
end
|
119
|
+
end
|
120
|
+
describe '#workflow_input' do
|
121
|
+
it 'returns the workflow input as a hash' do
|
122
|
+
subject.send(:workflow_input).should == workflow_started_input
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
|
128
|
+
describe '.fail!' do
|
129
|
+
it 'fails the workflow execution' do
|
130
|
+
task = double(:task).tap {|o| o.should_receive(:fail_workflow_execution).with(:args_placeholder) }
|
131
|
+
subject_class.fail! task, :args_placeholder
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
describe '.configuration_help_message' do
|
136
|
+
it { subject_class.configuration_help_message.should be_is_a String }
|
137
|
+
it { subject_class.configuration_help_message.length.should > 100 }
|
138
|
+
it { subject_class.configuration_help_message.should include 'decision' }
|
139
|
+
end
|
140
|
+
|
141
|
+
end
|