aws-swf 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|