rspec-background-process 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ lib/**/*.rb
2
+ bin/*
3
+ -
4
+ features/**/*.feature
5
+ LICENSE.txt
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ source 'http://rubygems.org'
2
+
3
+ group :development do
4
+ gem 'faraday', '>= 0.8'
5
+ gem 'rspec', '~> 3.1'
6
+ gem 'jeweler', '~> 1.8.4'
7
+ gem 'bundler', '~> 1.0'
8
+ gem 'daemon', '~> 1.2'
9
+ gem 'micromachine', '~> 1.1'
10
+ gem 'rufus-lru', '~> 1.0'
11
+ gem 'file-tail', '~> 1.0'
12
+ gem 'retries', '~> 0.0.5'
13
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,76 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ addressable (2.3.6)
5
+ builder (3.2.2)
6
+ daemon (1.2.0)
7
+ diff-lcs (1.2.5)
8
+ faraday (0.8.9)
9
+ multipart-post (~> 1.2.0)
10
+ file-tail (1.0.12)
11
+ tins (~> 0.5)
12
+ git (1.2.8)
13
+ github_api (0.10.1)
14
+ addressable
15
+ faraday (~> 0.8.1)
16
+ hashie (>= 1.2)
17
+ multi_json (~> 1.4)
18
+ nokogiri (~> 1.5.2)
19
+ oauth2
20
+ hashie (3.3.1)
21
+ highline (1.6.21)
22
+ jeweler (1.8.8)
23
+ builder
24
+ bundler (~> 1.0)
25
+ git (>= 1.2.5)
26
+ github_api (= 0.10.1)
27
+ highline (>= 1.6.15)
28
+ nokogiri (= 1.5.10)
29
+ rake
30
+ rdoc
31
+ json (1.8.1)
32
+ jwt (1.0.0)
33
+ micromachine (1.1.0)
34
+ multi_json (1.10.1)
35
+ multi_xml (0.5.5)
36
+ multipart-post (1.2.0)
37
+ nokogiri (1.5.10)
38
+ oauth2 (1.0.0)
39
+ faraday (>= 0.8, < 0.10)
40
+ jwt (~> 1.0)
41
+ multi_json (~> 1.3)
42
+ multi_xml (~> 0.5)
43
+ rack (~> 1.2)
44
+ rack (1.5.2)
45
+ rake (10.3.2)
46
+ rdoc (4.1.1)
47
+ json (~> 1.4)
48
+ retries (0.0.5)
49
+ rspec (3.1.0)
50
+ rspec-core (~> 3.1.0)
51
+ rspec-expectations (~> 3.1.0)
52
+ rspec-mocks (~> 3.1.0)
53
+ rspec-core (3.1.2)
54
+ rspec-support (~> 3.1.0)
55
+ rspec-expectations (3.1.0)
56
+ diff-lcs (>= 1.2.0, < 2.0)
57
+ rspec-support (~> 3.1.0)
58
+ rspec-mocks (3.1.0)
59
+ rspec-support (~> 3.1.0)
60
+ rspec-support (3.1.0)
61
+ rufus-lru (1.0.5)
62
+ tins (0.13.2)
63
+
64
+ PLATFORMS
65
+ ruby
66
+
67
+ DEPENDENCIES
68
+ bundler (~> 1.0)
69
+ daemon (~> 1.2)
70
+ faraday (>= 0.8)
71
+ file-tail (~> 1.0)
72
+ jeweler (~> 1.8.4)
73
+ micromachine (~> 1.1)
74
+ retries (~> 0.0.5)
75
+ rspec (~> 3.1)
76
+ rufus-lru (~> 1.0)
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2014 Global Medical Treatment Ltd trading as WhatClinic.com
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,19 @@
1
+ = rspec-background-process
2
+
3
+ See features.
4
+
5
+ == Contributing to rspec-background-process
6
+
7
+ * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
8
+ * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
9
+ * Fork the project.
10
+ * Start a feature/bugfix branch.
11
+ * Commit and push until you are happy with your contribution.
12
+ * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
13
+ * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
14
+
15
+ == Copyright
16
+
17
+ Copyright (c) 2014 Jakub Pastuszek. See LICENSE.txt for
18
+ further details.
19
+
data/Rakefile ADDED
@@ -0,0 +1,41 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ $stderr.puts e.message
9
+ $stderr.puts "Run `bundle install` to install missing gems"
10
+ exit e.status_code
11
+ end
12
+ require 'rake'
13
+
14
+ require 'jeweler'
15
+ Jeweler::Tasks.new do |gem|
16
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
17
+ gem.name = "rspec-background-process"
18
+ gem.homepage = "http://github.com/jpastuszek/rspec-background-process"
19
+ gem.license = "MIT"
20
+ gem.summary = "RSpec and Cucumber DSL library that helps managing background processes during test runs"
21
+ gem.description = "RSpec and Cucumber DSL that allows definition of processes with their arguments, working directory, time outs, port numbers etc. and start/stop them during test runs. Processes with same definitions can pooled and reused between example runs to save time on startup/shutdown. Pooling supports process number limiting with LRU to limit memory used."
22
+ gem.email = "jpastuszek@gmail.com"
23
+ gem.authors = ["Jakub Pastuszek"]
24
+ # dependencies defined in Gemfile
25
+ end
26
+ Jeweler::RubygemsDotOrgTasks.new
27
+
28
+ require 'rspec/core/rake_task'
29
+ RSpec::Core::RakeTask.new(:spec)
30
+
31
+ task :default => :spec
32
+
33
+ require 'rdoc/task'
34
+ Rake::RDocTask.new do |rdoc|
35
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
36
+
37
+ rdoc.rdoc_dir = 'rdoc'
38
+ rdoc.title = "rspec-background-process #{version}"
39
+ rdoc.rdoc_files.include('README*')
40
+ rdoc.rdoc_files.include('lib/**/*.rb')
41
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,4 @@
1
+ require_relative 'rspec-background-process/background_process_helpers'
2
+ require_relative 'rspec-background-process/readiness_checks'
3
+ require_relative 'rspec-background-process/refresh_actions'
4
+ require_relative 'rspec-background-process/server'
@@ -0,0 +1,416 @@
1
+ require 'timeout'
2
+ require 'pathname'
3
+ require 'tmpdir'
4
+ require 'daemon'
5
+ require 'shellwords'
6
+ require 'thwait'
7
+ require 'micromachine'
8
+
9
+ module RSpecBackgroundProcess
10
+ class BackgroundProcess
11
+ class ProcessExitedError < RuntimeError
12
+ def initialize(process, exit_code)
13
+ super "process #{process} exited with exit code: #{exit_code}"
14
+ end
15
+ end
16
+
17
+ class ProcessReadyFailedError < RuntimeError
18
+ def initialize(process)
19
+ super "process #{process} readiness check failed"
20
+ end
21
+ end
22
+
23
+ class ProcessReadyTimeOutError < Timeout::Error
24
+ def initialize(process)
25
+ super "process #{process} failed to start in time"
26
+ end
27
+ end
28
+
29
+ class ProcessRunAwayError < RuntimeError
30
+ def initialize(process, pid)
31
+ super "process #{process} could not be stopped; pid: #{pid}"
32
+ end
33
+ end
34
+
35
+ class StateError < RuntimeError
36
+ def initialize(process, action, state)
37
+ super "process #{process} can't #{action} when in state: #{state}"
38
+ end
39
+ end
40
+
41
+ def initialize(name, cmd, args = [], working_directory = nil, options = {})
42
+ @name = name
43
+
44
+ @exec = (Pathname.new(Dir.pwd) + cmd).cleanpath.to_s
45
+ @args = args.map(&:to_s)
46
+
47
+ @pid = nil
48
+ @process = nil
49
+
50
+ @state_log = []
51
+
52
+ case working_directory
53
+ when Array
54
+ working_directory = Dir.mktmpdir(working_directory)
55
+ when nil
56
+ working_directory = Dir.mktmpdir(name.to_s)
57
+ end
58
+
59
+ @working_directory = Pathname.new(working_directory.to_s)
60
+ @working_directory.directory? or @working_directory.mkdir
61
+
62
+ @pid_file = @working_directory + "#{@name}.pid"
63
+ @log_file = @working_directory + "out.log"
64
+
65
+ @options = options
66
+ reset_options(options)
67
+
68
+ @fsm_lock = Mutex.new
69
+
70
+ @_fsm = MicroMachine.new(:not_running)
71
+
72
+ @state_change_time = Time.now.to_f
73
+ @after_state_change = []
74
+
75
+ @_fsm.on(:any) do
76
+ @state_change_time = Time.now.to_f
77
+ puts "process is now #{@_fsm.state}"
78
+ @after_state_change.each{|callback| callback.call(@_fsm.state)}
79
+ end
80
+
81
+ @_fsm.when(:starting,
82
+ not_running: :starting
83
+ )
84
+
85
+ @_fsm.on(:starting) do
86
+ puts "starting: `#{command}`"
87
+ puts "working directory: #{@working_directory}"
88
+ puts "log file: #{@log_file}"
89
+ end
90
+
91
+ @_fsm.when(:started,
92
+ starting: :running
93
+ )
94
+ @_fsm.on(:running) do
95
+ puts "running with pid: #{@pid}"
96
+ end
97
+
98
+ @_fsm.when(:stopped,
99
+ running: :not_running,
100
+ ready: :not_running
101
+ )
102
+
103
+ @_fsm.when(:died,
104
+ starting: :dead,
105
+ running: :dead,
106
+ ready: :dead
107
+ )
108
+
109
+ # it is topped before marked failed
110
+ @_fsm.when(:failed,
111
+ not_running: :failed
112
+ )
113
+
114
+ @_fsm.when(:verified,
115
+ running: :ready,
116
+ ready: :ready,
117
+ )
118
+ @_fsm.when(:run_away,
119
+ running: :jammed,
120
+ ready: :jammed
121
+ )
122
+
123
+ @template_renderer = options[:template_renderer]
124
+
125
+ # make sure we stop on exit
126
+ my_pid = Process.pid
127
+ at_exit do
128
+ stop if Process.pid == my_pid and running? #only run in master process
129
+ end
130
+ end
131
+
132
+ def render(str)
133
+ if @template_renderer
134
+ @template_renderer.call(template_variables, str)
135
+ else
136
+ str
137
+ end
138
+ end
139
+
140
+ def template_variables
141
+ {
142
+ /working directory/ => -> { working_directory },
143
+ /pid file/ => -> { pid_file },
144
+ /log file/ => -> { log_file },
145
+ /name/ => -> { name },
146
+ }
147
+ end
148
+
149
+ def command
150
+ # update arguments with actual port numbers, working directories etc. (see template variables)
151
+ Shellwords.join([@exec, *@args.map{|arg| render(arg)}])
152
+ end
153
+
154
+ attr_reader :name
155
+ attr_reader :working_directory
156
+ attr_reader :pid_file
157
+ attr_reader :log_file
158
+ attr_reader :ready_timeout
159
+ attr_reader :term_timeout
160
+ attr_reader :kill_timeout
161
+ attr_reader :state_change_time
162
+ attr_reader :state_log
163
+
164
+ def reset_options(opts)
165
+ @logging = opts[:logging]
166
+
167
+ @ready_timeout = opts[:ready_timeout] || 10
168
+ @term_timeout = opts[:term_timeout] || 10
169
+ @kill_timeout = opts[:kill_timeout] || 10
170
+
171
+ @ready_test = opts[:ready_test] || ->(_){true}
172
+ @refresh_action = opts[:refresh_action] || ->(_){restart}
173
+ end
174
+
175
+ def pid
176
+ @pid if starting? or running?
177
+ end
178
+
179
+ def exit_code
180
+ @process.value.exitstatus if not running? and @process
181
+ end
182
+
183
+ def running?
184
+ trigger? :stopped # if it can be stopped it must be running :D
185
+ end
186
+
187
+ def starting?
188
+ state == :starting
189
+ end
190
+
191
+ def ready?
192
+ state == :ready
193
+ end
194
+
195
+ def dead?
196
+ state == :dead
197
+ end
198
+
199
+ def failed?
200
+ state == :failed
201
+ end
202
+
203
+ def jammed?
204
+ state == :jammed
205
+ end
206
+
207
+ def state
208
+ lock_fsm{|fsm| fsm.state }
209
+ end
210
+
211
+ def refresh
212
+ puts 'refreshing'
213
+ cwd = Dir.pwd
214
+ begin
215
+ Dir.chdir(@working_directory.to_s)
216
+ @refresh_action.call(self)
217
+ ensure
218
+ Dir.chdir(cwd)
219
+ end
220
+ self
221
+ end
222
+
223
+ def restart
224
+ puts 'restarting'
225
+ stop
226
+ start
227
+ end
228
+
229
+ def start
230
+ return self if trigger? :stopped
231
+ trigger? :starting or raise StateError.new(self, 'start', state)
232
+
233
+ trigger :starting
234
+ @pid, @process = spawn
235
+
236
+ fail "expected 2 values from #spawn, got: #{@pid}, #{@process}" unless @pid and @process
237
+
238
+ @process_watcher = Thread.new do
239
+ @process.join
240
+ trigger :died
241
+ end
242
+
243
+ trigger :started
244
+ self
245
+ end
246
+
247
+ def stop
248
+ return if trigger? :started
249
+ trigger? :stopped or raise StateError.new(self, 'stop', state)
250
+
251
+ # get rid of the watcher thread
252
+ @process_watcher and @process_watcher.kill and @process_watcher.join
253
+
254
+ catch :done do
255
+ begin
256
+ if @term_timeout > 0
257
+ puts "terminating process: #{@pid}"
258
+ Process.kill("TERM", @pid)
259
+ @process.join(@term_timeout) and throw :done
260
+ puts "process #{@pid} did not terminate in time"
261
+ end
262
+
263
+ if @kill_timeout > 0
264
+ puts "killing process: #{@pid}"
265
+ Process.kill("KILL", @pid)
266
+ @process.join(@kill_timeout) and throw :done
267
+ puts "process #{@pid} could not be killed!!!"
268
+ end
269
+ rescue Errno::ESRCH
270
+ throw :done
271
+ end
272
+
273
+ trigger :run_away
274
+ raise ProcessRunAwayError.new(self, @pid)
275
+ end
276
+
277
+ trigger :stopped
278
+ self
279
+ end
280
+
281
+ def wait_ready
282
+ trigger? :verified or raise StateError.new(self, 'wait ready', state)
283
+
284
+ puts 'waiting ready'
285
+
286
+ status = while_running do
287
+ begin
288
+ Timeout.timeout(@ready_timeout) do
289
+ @ready_test.call(self) ? :ready : :failed
290
+ end
291
+ rescue Timeout::Error
292
+ :ready_timeout
293
+ end
294
+ end
295
+
296
+ case status
297
+ when :failed
298
+ puts "process failed to pass it's readiness test"
299
+ stop
300
+ trigger :failed
301
+ raise ProcessReadyFailedError.new(self)
302
+ when :ready_timeout
303
+ puts "process not ready in time; see #{log_file} for detail"
304
+ stop
305
+ trigger :failed
306
+ raise ProcessReadyTimeOutError.new(self)
307
+ when Exception
308
+ puts "process readiness check raised error: #{status}; see #{log_file} for detail"
309
+ stop
310
+ trigger :failed
311
+ raise status
312
+ else
313
+ trigger :verified
314
+ self
315
+ end
316
+ end
317
+
318
+ def after_state_change(&callback)
319
+ @after_state_change << callback
320
+ end
321
+
322
+ def puts(message)
323
+ message = "#{name}: #{message}"
324
+ @state_log << message
325
+ super message if @logging
326
+ end
327
+
328
+ def to_s
329
+ "#{name}[#{@exec}](#{state})"
330
+ end
331
+
332
+ private
333
+
334
+ def lock_fsm
335
+ @fsm_lock.synchronize{yield @_fsm}
336
+ end
337
+
338
+ def trigger(change)
339
+ lock_fsm{|fsm| fsm.trigger(change)}
340
+ end
341
+
342
+ def trigger?(change)
343
+ lock_fsm{|fsm| fsm.trigger?(change)}
344
+ end
345
+
346
+ def spawn
347
+ daemonize('exec') do |command|
348
+ # TODO: looks like exec is eating pending TERM (or other) signal and .start.stop may time out on TERM if signal was delivered before exec?
349
+ Kernel.exec(command)
350
+ end
351
+ end
352
+
353
+ def while_running
354
+ action = Thread.new do
355
+ begin
356
+ yield
357
+ rescue => error
358
+ error
359
+ end
360
+ end
361
+
362
+ value = ThreadsWait.new.join(action, @process).value
363
+ case value
364
+ when Process::Status
365
+ puts "process exited; see #{log_file} for detail"
366
+ trigger :died
367
+ raise ProcessExitedError.new(self, exit_code)
368
+ end
369
+
370
+ value
371
+ end
372
+
373
+ def daemonize(type = 'exec')
374
+ Daemon.daemonize(@pid_file, @log_file) do |log|
375
+ _command = command # render command
376
+
377
+ log.truncate(0)
378
+ Dir.chdir(@working_directory.to_s)
379
+
380
+ # useful for testing
381
+ ENV['PROCESS_SPAWN_TYPE'] = type
382
+
383
+ yield _command
384
+ end
385
+ end
386
+ end
387
+
388
+ class LoadedBackgroundProcess < BackgroundProcess
389
+ private
390
+
391
+ # cmd will be loaded in forked ruby interpreter and arguments passed via ENV['ARGS']
392
+ # This way starting new process will be much faster since ruby VM is already loaded
393
+ def spawn
394
+ puts "loading ruby script: #{@exec}"
395
+ daemonize('load') do |command|
396
+ cmd = Shellwords.split(command)
397
+ file = cmd.shift
398
+
399
+ # reset ARGV
400
+ Object.instance_eval{ remove_const(:ARGV) }
401
+ Object.const_set(:ARGV, cmd)
402
+
403
+ # reset $0
404
+ $0 = file
405
+
406
+ # reset $*
407
+ $*.replace(cmd)
408
+
409
+ load file
410
+
411
+ # make sure we exit if loaded file won't
412
+ exit 0
413
+ end
414
+ end
415
+ end
416
+ end