gorgon 0.0.1
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/.gitignore +8 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +52 -0
- data/README.md +53 -0
- data/Rakefile +1 -0
- data/bin/gorgon +41 -0
- data/gorgon.gemspec +33 -0
- data/lib/gorgon.rb +6 -0
- data/lib/gorgon/amqp_service.rb +39 -0
- data/lib/gorgon/callback_handler.rb +21 -0
- data/lib/gorgon/configuration.rb +9 -0
- data/lib/gorgon/failures_printer.rb +37 -0
- data/lib/gorgon/g_logger.rb +22 -0
- data/lib/gorgon/host_state.rb +31 -0
- data/lib/gorgon/job.rb +26 -0
- data/lib/gorgon/job_definition.rb +24 -0
- data/lib/gorgon/job_state.rb +119 -0
- data/lib/gorgon/listener.rb +147 -0
- data/lib/gorgon/originator.rb +120 -0
- data/lib/gorgon/originator_logger.rb +36 -0
- data/lib/gorgon/originator_protocol.rb +65 -0
- data/lib/gorgon/pipe_manager.rb +55 -0
- data/lib/gorgon/progress_bar_view.rb +121 -0
- data/lib/gorgon/source_tree_syncer.rb +37 -0
- data/lib/gorgon/testunit_runner.rb +50 -0
- data/lib/gorgon/version.rb +3 -0
- data/lib/gorgon/worker.rb +103 -0
- data/lib/gorgon/worker_manager.rb +148 -0
- data/lib/gorgon/worker_watcher.rb +22 -0
- data/spec/callback_handler_spec.rb +77 -0
- data/spec/failures_printer_spec.rb +66 -0
- data/spec/host_state_spec.rb +65 -0
- data/spec/job_definition_spec.rb +20 -0
- data/spec/job_state_spec.rb +231 -0
- data/spec/listener_spec.rb +194 -0
- data/spec/originator_logger_spec.rb +40 -0
- data/spec/originator_protocol_spec.rb +134 -0
- data/spec/originator_spec.rb +134 -0
- data/spec/progress_bar_view_spec.rb +98 -0
- data/spec/source_tree_syncer_spec.rb +65 -0
- data/spec/worker_manager_spec.rb +23 -0
- data/spec/worker_spec.rb +114 -0
- metadata +270 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
gorgon (0.0.1)
|
5
|
+
amqp (~> 0.9.7)
|
6
|
+
awesome_print
|
7
|
+
bunny (~> 0.8.0)
|
8
|
+
colorize (~> 0.5.8)
|
9
|
+
open4 (~> 1.3.0)
|
10
|
+
ruby-progressbar (~> 1.0.1)
|
11
|
+
test-unit
|
12
|
+
uuidtools (~> 2.1.3)
|
13
|
+
yajl-ruby (~> 1.1.0)
|
14
|
+
|
15
|
+
GEM
|
16
|
+
remote: http://rubygems.org/
|
17
|
+
specs:
|
18
|
+
amq-client (0.9.4)
|
19
|
+
amq-protocol (>= 0.9.4)
|
20
|
+
eventmachine
|
21
|
+
amq-protocol (0.9.4)
|
22
|
+
amqp (0.9.7)
|
23
|
+
amq-client (~> 0.9.4)
|
24
|
+
amq-protocol (>= 0.9.4)
|
25
|
+
eventmachine
|
26
|
+
awesome_print (1.0.2)
|
27
|
+
bunny (0.8.0)
|
28
|
+
colorize (0.5.8)
|
29
|
+
diff-lcs (1.1.3)
|
30
|
+
eventmachine (1.0.0)
|
31
|
+
open4 (1.3.0)
|
32
|
+
rake (0.9.2.2)
|
33
|
+
rspec (2.8.0)
|
34
|
+
rspec-core (~> 2.8.0)
|
35
|
+
rspec-expectations (~> 2.8.0)
|
36
|
+
rspec-mocks (~> 2.8.0)
|
37
|
+
rspec-core (2.8.0)
|
38
|
+
rspec-expectations (2.8.0)
|
39
|
+
diff-lcs (~> 1.1.2)
|
40
|
+
rspec-mocks (2.8.0)
|
41
|
+
ruby-progressbar (1.0.1)
|
42
|
+
test-unit (2.5.2)
|
43
|
+
uuidtools (2.1.3)
|
44
|
+
yajl-ruby (1.1.0)
|
45
|
+
|
46
|
+
PLATFORMS
|
47
|
+
ruby
|
48
|
+
|
49
|
+
DEPENDENCIES
|
50
|
+
gorgon!
|
51
|
+
rake
|
52
|
+
rspec
|
data/README.md
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
Gorgon
|
2
|
+
=====================
|
3
|
+
|
4
|
+
About
|
5
|
+
---------------------
|
6
|
+
|
7
|
+
Gorgon provides a method for distributing the workload of running a ruby test suites. It relies on amqp for message passing, and rsync for the synchronization of source code.
|
8
|
+
|
9
|
+
Usage
|
10
|
+
---------------------
|
11
|
+
|
12
|
+
To queue the current test suite, run `bundle exec gorgon start`, or `bundle exec gorgon`. _gorgon_ will read the application configuration out of _gorgon.json_, connect to the AMQP server, and publish the job.
|
13
|
+
|
14
|
+
In order for the job to run, _gorgon job listeners_ must be started that can process the job. To start a gorgon listener, run `bundle exec gorgon listen`. This command will read the listener configuration out of _gorgon\_listener.json_, then start the listener process in the background.
|
15
|
+
|
16
|
+
Configuration
|
17
|
+
---------------------
|
18
|
+
|
19
|
+
### gorgon.json
|
20
|
+
This file contains project-specific settings for gorgon, such as:
|
21
|
+
|
22
|
+
* A glob for generating the list of test files
|
23
|
+
* The connection information for AMQP
|
24
|
+
* Information about how clients can rsync the working directory
|
25
|
+
* Files containing Ruby code to be used as callbacks
|
26
|
+
|
27
|
+
### gorgon_listener.json
|
28
|
+
This file contains the listener-specific settings, such as:
|
29
|
+
|
30
|
+
* How many worker slots are provided by this listener
|
31
|
+
* The connection information for AMQP
|
32
|
+
* The file used for logs
|
33
|
+
|
34
|
+
Architecture
|
35
|
+
---------------------
|
36
|
+
|
37
|
+
By running `bundle exec gorgon start`, the originating computer will publish a *job definition* to the AMQP server. This object contains all of the information required to run the tests:
|
38
|
+
|
39
|
+
* The rsync information with which to fetch the source tree
|
40
|
+
* The name of a AMQP queue that contains the list of files that require testing
|
41
|
+
* The name of a AMQP exchange to send replies to
|
42
|
+
* Application-specific setup/teardown, either per-job or per-worker [scheduled for post-alpha]
|
43
|
+
|
44
|
+
The job listener subscribes to the job publish event, and maintains its own queue of jobs. When a job has available *worker slots*, it will prepare the workspace:
|
45
|
+
|
46
|
+
* Create a unique temporary workspace directory for the job
|
47
|
+
* Rsync the source tree to the temporary workspace
|
48
|
+
* Run per-job application-specific setup [scheduled for post-alpha]
|
49
|
+
* Invoke *n* workers, where *n* is the number of available *worker slots*.
|
50
|
+
|
51
|
+
To invoke a job worker, the listener passes the name of the *file queue*, *reply queue*, and *listener queue* to the worker initialization. After all workers have been started, the listener will block until an event appears on the *listener queue*.
|
52
|
+
|
53
|
+
The worker process will run any application-specific startup, start a test environment, and load a stub test file that dynamically pulls files out of the *file queue*. It runs the test, posts the results to the *reply queue*, and repeats until the *file queue* is empty. When the *file queue* becomes empty, the worker runs application-specific teardown, then reports its completion to the *listener queue*, and shuts down.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/bin/gorgon
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require 'gorgon/originator'
|
3
|
+
require 'gorgon/listener'
|
4
|
+
require 'gorgon/worker_manager'
|
5
|
+
|
6
|
+
def start
|
7
|
+
o = Originator.new
|
8
|
+
o.originate
|
9
|
+
end
|
10
|
+
|
11
|
+
def listen
|
12
|
+
l = Listener.new
|
13
|
+
l.listen
|
14
|
+
end
|
15
|
+
|
16
|
+
def manage_workers
|
17
|
+
config_path = ENV["GORGON_CONFIG_PATH"]
|
18
|
+
|
19
|
+
manager = WorkerManager.build config_path
|
20
|
+
manager.manage
|
21
|
+
|
22
|
+
# For some reason I have to 'exit' here, otherwise WorkerManager process crashes
|
23
|
+
exit
|
24
|
+
end
|
25
|
+
|
26
|
+
def usage
|
27
|
+
#print instructions on how to use gorgon
|
28
|
+
end
|
29
|
+
|
30
|
+
case ARGV[0]
|
31
|
+
when nil
|
32
|
+
start
|
33
|
+
when "start"
|
34
|
+
start
|
35
|
+
when "listen"
|
36
|
+
listen
|
37
|
+
when "manage_workers"
|
38
|
+
manage_workers
|
39
|
+
else
|
40
|
+
usage
|
41
|
+
end
|
data/gorgon.gemspec
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "gorgon/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "gorgon"
|
7
|
+
s.version = Gorgon::VERSION
|
8
|
+
s.authors = ["Justin Fitzsimmons", "Sean Kirby", "Victor Savkin", "Clemens Park", "Arturo Pie"]
|
9
|
+
s.email = ["justin@fitzsimmons.ca"]
|
10
|
+
s.homepage = ""
|
11
|
+
s.summary = %q{Distributed testing for ruby with centralized management}
|
12
|
+
s.description = %q{Gorgon provides a method for distributing the workload of running a ruby test suites. It relies on amqp for message passing, and rsync for the synchronization of source code.}
|
13
|
+
|
14
|
+
s.rubyforge_project = "gorgon"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
s.add_development_dependency "rspec"
|
22
|
+
s.add_development_dependency "rake"
|
23
|
+
|
24
|
+
s.add_runtime_dependency "amqp", '~>0.9.7'
|
25
|
+
s.add_runtime_dependency "awesome_print"
|
26
|
+
s.add_runtime_dependency "open4", '~>1.3.0'
|
27
|
+
s.add_runtime_dependency "yajl-ruby", '~>1.1.0'
|
28
|
+
s.add_runtime_dependency "uuidtools", '~>2.1.3'
|
29
|
+
s.add_runtime_dependency "test-unit"
|
30
|
+
s.add_runtime_dependency "bunny", '~>0.8.0'
|
31
|
+
s.add_runtime_dependency "ruby-progressbar", '~>1.0.1'
|
32
|
+
s.add_runtime_dependency "colorize", '~>0.5.8'
|
33
|
+
end
|
data/lib/gorgon.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'bunny'
|
2
|
+
require 'yajl'
|
3
|
+
|
4
|
+
class AmqpQueueDecorator
|
5
|
+
def initialize queue
|
6
|
+
@queue = queue
|
7
|
+
end
|
8
|
+
|
9
|
+
def pop
|
10
|
+
m = @queue.pop
|
11
|
+
p = m[:payload]
|
12
|
+
p == :queue_empty ? nil : p
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class AmqpExchangeDecorator
|
17
|
+
def initialize exchange
|
18
|
+
@exchange = exchange
|
19
|
+
end
|
20
|
+
|
21
|
+
def publish msg
|
22
|
+
serialized_msg = Yajl::Encoder.encode(msg)
|
23
|
+
@exchange.publish serialized_msg
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class AmqpService
|
28
|
+
def initialize connection_config
|
29
|
+
@connection_config = connection_config.merge(:spec => "09")
|
30
|
+
end
|
31
|
+
|
32
|
+
def start_worker file_queue_name, reply_exchange_name
|
33
|
+
Bunny.run @connection_config do |b|
|
34
|
+
queue = b.queue file_queue_name
|
35
|
+
exchange = b.exchange reply_exchange_name
|
36
|
+
yield AmqpQueueDecorator.new(queue), AmqpExchangeDecorator.new(exchange)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class CallbackHandler
|
2
|
+
def initialize(config)
|
3
|
+
@config = config || {}
|
4
|
+
end
|
5
|
+
|
6
|
+
def before_start
|
7
|
+
load(@config[:before_start]) if @config[:before_start]
|
8
|
+
end
|
9
|
+
|
10
|
+
def after_complete
|
11
|
+
load(@config[:after_complete]) if @config[:after_complete]
|
12
|
+
end
|
13
|
+
|
14
|
+
def before_creating_workers
|
15
|
+
load(@config[:before_creating_workers]) if @config[:before_creating_workers]
|
16
|
+
end
|
17
|
+
|
18
|
+
def after_sync
|
19
|
+
load(@config[:after_sync]) if @config[:after_sync]
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'yajl'
|
2
|
+
|
3
|
+
class FailuresPrinter
|
4
|
+
OUTPUT_FILE = "/tmp/gorgon-failed-files.json"
|
5
|
+
|
6
|
+
def initialize job_state
|
7
|
+
@job_state = job_state
|
8
|
+
@job_state.add_observer(self)
|
9
|
+
end
|
10
|
+
|
11
|
+
def update payload
|
12
|
+
return unless @job_state.is_job_complete? || @job_state.is_job_cancelled?
|
13
|
+
|
14
|
+
File.open(OUTPUT_FILE, 'w+') do |fd|
|
15
|
+
fd.write(Yajl::Encoder.encode(failed_files + unfinished_files))
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def failed_files
|
22
|
+
failed_files = []
|
23
|
+
@job_state.each_failed_test do |test|
|
24
|
+
failed_files << "#{test[:filename]}"
|
25
|
+
end
|
26
|
+
failed_files
|
27
|
+
end
|
28
|
+
|
29
|
+
def unfinished_files
|
30
|
+
unfinished_files = []
|
31
|
+
@job_state.each_running_file do |hostname, filename|
|
32
|
+
unfinished_files << "#{filename}"
|
33
|
+
end
|
34
|
+
unfinished_files
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require "logger"
|
2
|
+
|
3
|
+
module GLogger
|
4
|
+
def initialize_logger log_file
|
5
|
+
return unless log_file
|
6
|
+
@logger =
|
7
|
+
if log_file == "-"
|
8
|
+
Logger.new($stdout)
|
9
|
+
else
|
10
|
+
Logger.new(log_file)
|
11
|
+
end
|
12
|
+
@logger.datetime_format = "%Y-%m-%d %H:%M:%S "
|
13
|
+
end
|
14
|
+
|
15
|
+
def log text
|
16
|
+
@logger.info(text) if @logger
|
17
|
+
end
|
18
|
+
|
19
|
+
def log_error text
|
20
|
+
@logger.error(text) if @logger
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
class HostState
|
2
|
+
def initialize
|
3
|
+
@running_workers = {}
|
4
|
+
end
|
5
|
+
|
6
|
+
def file_started worker_id, filename
|
7
|
+
if @running_workers.has_key? worker_id
|
8
|
+
puts "WARNING: worker #{worker_id} started running a new file, but a 'finish' message has not been received for file #{@running_workers[:filename]}"
|
9
|
+
end
|
10
|
+
|
11
|
+
@running_workers[worker_id] = filename
|
12
|
+
end
|
13
|
+
|
14
|
+
def file_finished worker_id, filename
|
15
|
+
if !@running_workers.has_key? worker_id || @running_workers[:worker_id] != filename
|
16
|
+
puts "WARNING: worker #{worker_id} finished running a file, but a 'start' message for that file was not received. File: #{filename}"
|
17
|
+
end
|
18
|
+
|
19
|
+
@running_workers.delete(worker_id)
|
20
|
+
end
|
21
|
+
|
22
|
+
def total_running_workers
|
23
|
+
@running_workers.size
|
24
|
+
end
|
25
|
+
|
26
|
+
def each_running_file
|
27
|
+
@running_workers.each_value do |filename|
|
28
|
+
yield filename
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/gorgon/job.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
class Job
|
2
|
+
def initialize(listener, job_definition)
|
3
|
+
@workers = []
|
4
|
+
@definition = job_definition
|
5
|
+
end
|
6
|
+
|
7
|
+
|
8
|
+
|
9
|
+
def add_worker
|
10
|
+
|
11
|
+
end
|
12
|
+
|
13
|
+
def on_worker_complete
|
14
|
+
@available_worker_slots += 1
|
15
|
+
on_current_job_complete if current_job_complete?
|
16
|
+
end
|
17
|
+
|
18
|
+
def setup_child_process
|
19
|
+
worker = ChildProcess.build("gorgon", "work", @worker_communication.name, @config_filename)
|
20
|
+
|
21
|
+
worker_output = Tempfile.new("gorgon-worker")
|
22
|
+
worker.io.stdout = worker_output
|
23
|
+
worker.io.stderr = worker_output
|
24
|
+
worker
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'yajl'
|
2
|
+
|
3
|
+
class JobDefinition
|
4
|
+
attr_accessor :file_queue_name, :reply_exchange_name, :source_tree_path, :sync_exclude, :callbacks
|
5
|
+
|
6
|
+
def initialize(opts={})
|
7
|
+
@file_queue_name = opts[:file_queue_name]
|
8
|
+
@reply_exchange_name = opts[:reply_exchange_name]
|
9
|
+
@source_tree_path = opts[:source_tree_path]
|
10
|
+
@callbacks = opts[:callbacks]
|
11
|
+
@sync_exclude = opts[:sync_exclude]
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_json
|
15
|
+
Yajl::Encoder.encode(to_hash)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
#This can probably be done with introspection somehow, but this is way easier despite being very verbose
|
21
|
+
def to_hash
|
22
|
+
{:file_queue_name => @file_queue_name, :reply_exchange_name => @reply_exchange_name, :source_tree_path => @source_tree_path, :sync_exclude => @sync_exclude, :callbacks => @callbacks}
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
require 'gorgon/host_state'
|
2
|
+
|
3
|
+
require 'observer'
|
4
|
+
|
5
|
+
class JobState
|
6
|
+
include Observable
|
7
|
+
|
8
|
+
attr_reader :total_files, :remaining_files_count, :state
|
9
|
+
|
10
|
+
def initialize total_files
|
11
|
+
@total_files = total_files
|
12
|
+
@remaining_files_count = total_files
|
13
|
+
@failed_tests = []
|
14
|
+
@hosts = {}
|
15
|
+
|
16
|
+
if @remaining_files_count > 0
|
17
|
+
@state = :starting
|
18
|
+
else
|
19
|
+
@state = :complete
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def failed_files_count
|
24
|
+
@failed_tests.count
|
25
|
+
end
|
26
|
+
|
27
|
+
def finished_files_count
|
28
|
+
total_files - remaining_files_count
|
29
|
+
end
|
30
|
+
|
31
|
+
def file_started payload
|
32
|
+
raise_if_completed_or_cancelled
|
33
|
+
|
34
|
+
if @state == :starting
|
35
|
+
@state = :running
|
36
|
+
end
|
37
|
+
|
38
|
+
file_started_update_host_state payload
|
39
|
+
|
40
|
+
changed
|
41
|
+
notify_observers payload
|
42
|
+
end
|
43
|
+
|
44
|
+
def file_finished payload
|
45
|
+
raise_if_completed_or_cancelled
|
46
|
+
|
47
|
+
@remaining_files_count -= 1
|
48
|
+
@state = :complete if @remaining_files_count == 0
|
49
|
+
|
50
|
+
handle_failed_test payload if failed_test?(payload)
|
51
|
+
|
52
|
+
@hosts[payload[:hostname]].file_finished payload[:worker_id], payload[:filename]
|
53
|
+
|
54
|
+
changed
|
55
|
+
notify_observers payload
|
56
|
+
end
|
57
|
+
|
58
|
+
def cancel
|
59
|
+
@remaining_files_count = 0
|
60
|
+
@state = :cancelled
|
61
|
+
changed
|
62
|
+
notify_observers({})
|
63
|
+
end
|
64
|
+
|
65
|
+
def each_failed_test
|
66
|
+
@failed_tests.each do |test|
|
67
|
+
yield test
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def each_running_file
|
72
|
+
@hosts.each do |hostname, host|
|
73
|
+
host.each_running_file do |filename|
|
74
|
+
yield hostname, filename
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def total_running_hosts
|
80
|
+
@hosts.size
|
81
|
+
end
|
82
|
+
|
83
|
+
def total_running_workers
|
84
|
+
result = 0
|
85
|
+
@hosts.each do |hostname, host|
|
86
|
+
result += host.total_running_workers
|
87
|
+
end
|
88
|
+
result
|
89
|
+
end
|
90
|
+
|
91
|
+
def is_job_complete?
|
92
|
+
@state == :complete
|
93
|
+
end
|
94
|
+
|
95
|
+
def is_job_cancelled?
|
96
|
+
@state == :cancelled
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def file_started_update_host_state payload
|
102
|
+
hostname = payload[:hostname]
|
103
|
+
@hosts[hostname] = HostState.new if @hosts[hostname].nil?
|
104
|
+
@hosts[hostname].file_started payload[:worker_id], payload[:filename]
|
105
|
+
end
|
106
|
+
|
107
|
+
def handle_failed_test payload
|
108
|
+
@failed_tests << payload
|
109
|
+
end
|
110
|
+
|
111
|
+
def raise_if_completed_or_cancelled
|
112
|
+
raise "JobState#file_finished called when job was already complete" if is_job_complete?
|
113
|
+
raise "JobState#file_finished called after job was cancelled" if is_job_cancelled?
|
114
|
+
end
|
115
|
+
|
116
|
+
def failed_test? payload
|
117
|
+
payload[:type] == "fail"
|
118
|
+
end
|
119
|
+
end
|