parallel_cucumber 0.2.0.pre.36 → 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 9a409cddb7ad8882664b62866dd1d9ffb39caa9d
4
- data.tar.gz: 0224ea5dd38f661176160e0534f1b5c673f9d147
3
+ metadata.gz: c446c47f0dd453f9b1a8f66e11d374fbf6e16b2e
4
+ data.tar.gz: 7a15cd91d8a712344a63d843899278d52a8d86b1
5
5
  SHA512:
6
- metadata.gz: 155b9bd2377687099c769731f2cf576d4e8984883745adadc08a48ac6403ea0f6e779714c0ade86b2dc816f469733ce78cb22c7826b195c6aed74df7fb3cca33
7
- data.tar.gz: bd51ffc599151b873599bff76353b3c565f878d18262b6d1c981b364eb708ac3fa6e3e89e611987124a56d110337873d0a2b498d07a57a59944033a3bcf9c8e5
6
+ metadata.gz: 9c24151c5b51f480adcfc7641b9e5f784ac397c875c7568d60a36451af2ac7569d67f95ddc68ce31fd0f5331323d8bf6aa5fc58b70bcb86e28b0071cc4ecaaa9
7
+ data.tar.gz: 4f2a5adf38c27af996b465fb60da139aee157c3becc975332e1e95a3a0fec857fb5ff027ee1145bd5ee453abcf634d1335184e3b160a19660332c9e00e0cb4e0
data/README.md CHANGED
@@ -7,17 +7,31 @@ Usage: parallel_cucumber [options] [ [FILE|DIR|URL][:LINE[:LINE]*] ]
7
7
  Example: parallel_cucumber -n 4 -o "-f pretty -f html -o report.html" examples/i18n/en/features
8
8
  -n WORKERS How many workers to use. Default is 1 or longest list in -e
9
9
  -o, --cucumber-options "OPTIONS" Run cucumber with these options
10
+ -r, --require "file_path" Load files for parallel_cucumber
11
+ --directed-tests JSON Direct tests to specific workers, e.g. {"0": "-t @head"}
10
12
  --test-command COMMAND Command to run for test phase, default cucumber
11
13
  --pre-batch-check COMMAND Command causing worker to quit on exit failure
14
+ --on-batch-error COMMAND Command to call on any error happened while running batch of tests.
15
+ It will receive as argument json file with list of tests and error happened.
16
+ --no-pretty Now a no-op for compatibility there is no default pretty
17
+ --log-dir DIR Directory for worker logfiles
18
+ --log-decoration JSON Block quoting for logs, e.g. {start: "#start %s", end: "#end %s"}
19
+ --summary JSON Summary files, e.g. {failed: "./failed.txt", unknown: "./unknown.txt"}
12
20
  -e, --env-variables JSON Set additional environment variables to processes
13
21
  --batch-size SIZE How many tests each worker takes from queue at once. Default is 1
22
+ --group-by ENV_VAR Key for cumulative report
14
23
  -q ARRAY, `url,name` Url for TCP connection: `redis://[password]@[hostname]:[port]/[db]` (password, port and database are optional), for unix socket connection: `unix://[path to Redis socket]`. Default is redis://127.0.0.1:6379 and name is `queue`
15
24
  --queue-connection-params
16
25
  --setup-worker SCRIPT Execute SCRIPT before each worker
17
26
  --teardown-worker SCRIPT Execute SCRIPT after each worker
18
27
  --worker-delay SECONDS Delay before next worker starting. Could be used for avoiding 'spikes' in CPU and RAM usage Default is 0
19
28
  --batch-timeout SECONDS Timeout for each batch of tests. Default is 600
29
+ --precheck-timeout SECONDS Timeout for each test precheck. Default is 600
30
+ --batch-error-timeout SECONDS
31
+ Timeout for each batch_error script. Default is 30
32
+ --setup-timeout SECONDS Timeout for each worker's set-up phase. Default is 30
20
33
  --debug Print more debug information
34
+ --long-running-tests STRING Cucumber arguments for long-running-tests
21
35
  -v, --version Show version
22
36
  -h, --help Show this
23
37
  ```
@@ -10,3 +10,4 @@ require 'parallel_cucumber/main'
10
10
  require 'parallel_cucumber/status'
11
11
  require 'parallel_cucumber/version'
12
12
  require 'parallel_cucumber/worker'
13
+ require 'parallel_cucumber/hooks'
@@ -8,8 +8,11 @@ module ParallelCucumber
8
8
  batch_size: 1,
9
9
  batch_timeout: 600,
10
10
  setup_timeout: 30,
11
+ precheck_timeout: 30,
12
+ batch_error_timeout: 30,
11
13
  cucumber_options: '',
12
14
  debug: false,
15
+ directed_tests: {},
13
16
  log_dir: '.',
14
17
  log_decoration: {},
15
18
  env_variables: {},
@@ -62,6 +65,21 @@ module ParallelCucumber
62
65
  options[:cucumber_options] = cucumber_options
63
66
  end
64
67
 
68
+ opts.on('-r', '--require "file_path"', 'Load files for parallel_cucumber') do |load_file|
69
+ raise(ArgumentError, "No such file to load: #{load_file}") unless File.exist?(load_file)
70
+ options[:load_files] ||= []
71
+ options[:load_files] << load_file
72
+ end
73
+
74
+ opts.on('--directed-tests JSON', 'Direct tests to specific workers, e.g. {"0": "-t @head"}') do |json|
75
+ options[:directed_tests] = begin
76
+ JSON.parse(json)
77
+ rescue JSON::ParserError
78
+ puts 'Log block quoting not in JSON format. Did you forget to escape the quotes?'
79
+ raise
80
+ end
81
+ end
82
+
65
83
  opts.on('--test-command COMMAND',
66
84
  "Command to run for test phase, default #{DEFAULTS[:test_command]}") do |test_command|
67
85
  options[:test_command] = test_command
@@ -71,11 +89,14 @@ module ParallelCucumber
71
89
  options[:pre_check] = pre_check
72
90
  end
73
91
 
74
- options[:pretty] = '--format pretty'
75
- opts.on('--no-pretty', "Suppress the default 'pretty' formatter directed at stdout") do
76
- options[:pretty] = ''
92
+ opts.on('--on-batch-error COMMAND',
93
+ 'Command to call on any error happened while running batch of tests.
94
+ It will receive as argument json file with list of tests and error happened.') do |on_batch_error|
95
+ options[:on_batch_error] = on_batch_error
77
96
  end
78
97
 
98
+ opts.on('--no-pretty', 'Now a no-op for compatibility there is no default pretty') {}
99
+
79
100
  opts.on('--log-dir DIR', 'Directory for worker logfiles') do |log_dir|
80
101
  options[:log_dir] = log_dir
81
102
  end
@@ -108,7 +129,7 @@ module ParallelCucumber
108
129
  end
109
130
 
110
131
  help_message = "How many tests each worker takes from queue at once. Default is #{DEFAULTS[:batch_size]}"
111
- opts.on('--batch-size SIZE', Integer, help_message.gsub(/\s+/, ' ').strip) do |batch_size|
132
+ opts.on('--batch-size SIZE', Integer, help_message) do |batch_size|
112
133
  if batch_size < 1
113
134
  puts "The minimum batch size is 1 but given: '#{batch_size}'"
114
135
  exit 1
@@ -116,14 +137,18 @@ module ParallelCucumber
116
137
  options[:batch_size] = batch_size
117
138
  end
118
139
 
119
- help_message = <<-TEXT
140
+ opts.on('--group-by ENV_VAR', 'Key for cumulative report') do |group_by|
141
+ options[:group_by] = group_by
142
+ end
143
+
144
+ help_message = <<-TEXT.gsub(/\s+/, ' ').strip
120
145
  `url,name`
121
146
  Url for TCP connection:
122
147
  `redis://[password]@[hostname]:[port]/[db]` (password, port and database are optional),
123
148
  for unix socket connection: `unix://[path to Redis socket]`.
124
149
  Default is redis://127.0.0.1:6379 and name is `queue`
125
150
  TEXT
126
- opts.on('-q', '--queue-connection-params ARRAY', Array, help_message.gsub(/\s+/, ' ').strip) do |params|
151
+ opts.on('-q', '--queue-connection-params ARRAY', Array, help_message) do |params|
127
152
  options[:queue_connection_params] = params
128
153
  end
129
154
 
@@ -135,26 +160,40 @@ module ParallelCucumber
135
160
  options[:teardown_worker] = script
136
161
  end
137
162
 
138
- help_message = <<-TEXT
163
+ help_message = <<-TEXT.gsub(/\s+/, ' ').strip
139
164
  Delay before next worker starting.
140
165
  Could be used for avoiding 'spikes' in CPU and RAM usage
141
166
  Default is #{DEFAULTS[:worker_delay]}
142
167
  TEXT
143
- opts.on('--worker-delay SECONDS', Float, help_message.gsub(/\s+/, ' ').strip) do |worker_delay|
168
+ opts.on('--worker-delay SECONDS', Float, help_message) do |worker_delay|
144
169
  options[:worker_delay] = worker_delay
145
170
  end
146
171
 
147
- help_message = <<-TEXT
172
+ help_message = <<-TEXT.gsub(/\s+/, ' ').strip
148
173
  Timeout for each batch of tests. Default is #{DEFAULTS[:batch_timeout]}
149
174
  TEXT
150
- opts.on('--batch-timeout SECONDS', Float, help_message.gsub(/\s+/, ' ').strip) do |batch_timeout|
175
+ opts.on('--batch-timeout SECONDS', Float, help_message) do |batch_timeout|
151
176
  options[:batch_timeout] = batch_timeout
152
177
  end
153
178
 
154
- help_message = <<-TEXT
179
+ help_message = <<-TEXT.gsub(/\s+/, ' ').strip
180
+ Timeout for each test precheck. Default is #{DEFAULTS[:batch_timeout]}
181
+ TEXT
182
+ opts.on('--precheck-timeout SECONDS', Float, help_message) do |timeout|
183
+ options[:precheck_timeout] = timeout
184
+ end
185
+
186
+ help_message = <<-TEXT.gsub(/\s+/, ' ').strip
187
+ Timeout for each batch_error script. Default is #{DEFAULTS[:batch_error_timeout]}
188
+ TEXT
189
+ opts.on('--batch-error-timeout SECONDS', Float, help_message) do |timeout|
190
+ options[:batch_error_timeout] = timeout
191
+ end
192
+
193
+ help_message = <<-TEXT.gsub(/\s+/, ' ').strip
155
194
  Timeout for each worker's set-up phase. Default is #{DEFAULTS[:setup_timeout]}
156
195
  TEXT
157
- opts.on('--setup-timeout SECONDS', Float, help_message.gsub(/\s+/, ' ').strip) do |setup_timeout|
196
+ opts.on('--setup-timeout SECONDS', Float, help_message) do |setup_timeout|
158
197
  options[:setup_timeout] = setup_timeout
159
198
  end
160
199
 
@@ -162,6 +201,11 @@ module ParallelCucumber
162
201
  options[:debug] = debug
163
202
  end
164
203
 
204
+ help_message = 'Cucumber arguments for long-running-tests'
205
+ opts.on('--long-running-tests STRING', String, help_message) do |cucumber_long_run_args|
206
+ options[:long_running_tests] = cucumber_long_run_args
207
+ end
208
+
165
209
  opts.on('-v', '--version', 'Show version') do
166
210
  puts ParallelCucumber::VERSION
167
211
  exit 0
@@ -174,7 +218,7 @@ module ParallelCucumber
174
218
  end
175
219
 
176
220
  option_parser.parse!(argv)
177
- options[:cucumber_args] = argv
221
+ options[:cucumber_args] = argv.join(' ')
178
222
 
179
223
  options
180
224
  rescue OptionParser::InvalidOption => e
@@ -0,0 +1,18 @@
1
+ require_relative 'hooks'
2
+
3
+ module ParallelCucumber
4
+ module DSL
5
+ class << self
6
+
7
+ # Registers a callback hook which will be called at the end of every batch
8
+ # There can be more than one after_batch, they will be invoked sequentially
9
+ # If one hook fails, rest all will be skipped
10
+ # @yieldparam [optional, Hash] batch_results results of all tests in a batch
11
+ # @yieldparam [optional, String] batch_id batch id
12
+ # @yieldparam [optional, Hash] batch_env env of batch
13
+ def after_batch(&proc)
14
+ Hooks.register_after_batch(proc)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -1,85 +1,176 @@
1
1
  module ParallelCucumber
2
2
  module Helper
3
3
  module Command
4
+ class TimedOutError < RuntimeError; end
4
5
  class << self
5
6
  def wrap_block(log_decoration, block_name, logger)
7
+ [$stdout, $stderr].each(&:flush)
6
8
  logger << format(log_decoration['start'] + "\n", block_name) if log_decoration['start']
9
+ [$stdout, $stderr].each(&:flush)
7
10
  yield
8
11
  ensure
12
+ [$stdout, $stderr].each(&:flush)
9
13
  logger << format(log_decoration['end'] + "\n", block_name) if log_decoration['end']
14
+ [$stdout, $stderr].each(&:flush)
10
15
  end
11
16
 
12
- def exec_command(env, desc, script, log_file, logger, log_decoration = {}, timeout = 30) # rubocop:disable Metrics/ParameterLists, Metrics/LineLength
13
- file = File.open(log_file)
14
- file.seek(0, File::SEEK_END)
15
- full_script = "#{script} >>#{log_file} 2>&1"
17
+ ONE_SECOND = 1
18
+
19
+ def exec_command(env, desc, script, logger, log_decoration = {}, timeout: 30, capture: false, return_script_error: false) # rubocop:disable Metrics/ParameterLists, Metrics/LineLength
20
+ block_name = ''
21
+ if log_decoration['worker_block']
22
+ if log_decoration['start'] || log_decoration['end']
23
+ block_name = "#{"#{env['TEST_USER']}-w#{env['WORKER_INDEX']}>"} #{desc}"
24
+ end
25
+ end
26
+
27
+ logger << format(log_decoration['start'] + "\n", block_name) if log_decoration['start']
28
+ full_script = "#{script} 2>&1"
16
29
  env_string = env.map { |k, v| "#{k}=#{v}" }.sort.join(' ')
17
- message = <<-LOG
18
- Running command `#{full_script}` with environment variables: #{env_string}
19
- LOG
20
- logger.debug(message)
30
+ logger << "== Running command `#{full_script}` at #{Time.now}\n== with environment variables: #{env_string}\n"
21
31
  pstat = nil
22
32
  pout = nil
33
+ capture &&= [''] # Pass by reference
34
+ exception = nil
35
+
23
36
  begin
24
- completed = Timeout.timeout(timeout) do
37
+ completed = begin
25
38
  pin, pout, pstat = Open3.popen2e(env, full_script)
26
- logger.debug("Command has pid #{pstat[:pid]}")
39
+ logger << "Command has pid #{pstat[:pid]}\n"
27
40
  pin.close
28
- out = pout.readlines.join("\n") # Not expecting anything in 'out' due to redirection, but...
29
- pout.close
30
- pstat.value # N.B. Await process termination
31
- "Command completed #{pstat.value} and expecting '#{out}' to be empty due to redirects"
32
- end
33
- logger.debug(completed)
34
- # system("lsof #{log_file} >> #{log_file} 2>&1")
35
- system("ps -axf | grep '#{pstat[:pid]}\\s' >> #{log_file}")
36
- return pstat.value.success?
37
- rescue Timeout::Error
38
- pout.close
39
- logger.debug("Timeout, so trying SIGINT #{pstat[:pid]}")
40
- wait_sigint = 15
41
- logger.error("Timeout #{timeout}s was reached. Sending SIGINT(2), then waiting up to #{wait_sigint}s")
42
- tree = Helper::Processes.ps_tree
43
- begin
44
- Helper::Processes.kill_tree('SIGINT', pstat[:pid].to_s, tree)
45
- timed_out = wait_sigint.times do |t|
46
- break if Helper::Processes.all_pids_dead?(pstat[:pid].to_s, nil, tree)
47
- logger.info("Wait dead #{t}")
48
- sleep 1
41
+ out_reader = Thread.new do
42
+ output_reader(pout, pstat, logger, capture)
49
43
  end
50
- if timed_out
51
- logger.error("Process #{pstat[:pid]} lasted #{wait_sigint}s after SIGINT(2), so SIGKILL(9)! Fatality!")
52
- Helper::Processes.kill_tree('SIGKILL', pstat[:pid].to_s, nil, tree)
44
+
45
+ unless out_reader.join(timeout)
46
+ raise TimedOutError
53
47
  end
54
- logger.debug("About to reap root #{pstat[:pid]}")
55
- pstat.value # reap root - everything else should be reaped by init.
56
- logger.debug("Reaped root #{pstat[:pid]}")
57
- # system("lsof #{log_file} >> #{log_file}")
58
- logger.debug("Tried SIGKILL #{pstat[:pid]} - hopefully no processes still have #{log_file}!")
48
+
49
+ graceful_process_shutdown(out_reader, pstat, pout, logger)
50
+
51
+ pstat.value # reap already-terminated child.
52
+ "Command completed #{pstat.value} at #{Time.now}"
59
53
  end
54
+
55
+ logger << "#{completed}\n"
56
+
57
+ raise "Script returned #{pstat.value.exitstatus}" unless pstat.value.success? || return_script_error
58
+
59
+ capture_or_empty = capture ? capture.first : '' # Even '' is truthy
60
+ return pstat.value.success? ? capture_or_empty : nil
61
+ rescue TimedOutError => e
62
+ force_kill_process_with_tree(out_reader, pstat, pout, full_script, logger, timeout)
63
+
64
+ exception = e
60
65
  rescue => e
61
66
  logger.debug("Exception #{pstat ? pstat[:pid] : "pstat=#{pstat}=nil"}")
62
67
  trace = e.backtrace.join("\n\t").sub("\n\t", ": #{$ERROR_INFO}#{e.class ? " (#{e.class})" : ''}\n\t")
63
- logger.error("Threw: for #{full_script}, caused #{trace}")
68
+ logger.error("Threw for #{full_script}, caused #{trace}")
69
+
70
+ exception = e
64
71
  ensure
65
- # It's only logging - don't really care if we lose some, though it would be nice if we didn't.
66
- if log_decoration['worker_block']
67
- prefix = "#{env['TEST_USER']}-w#{env['WORKER_INDEX']}>"
68
- block_name = ''
69
- if log_decoration['start'] || log_decoration['end']
70
- block_name = "#{prefix} #{desc}"
71
- prefix = ''
72
- end
73
- message = ''
74
- message << format(log_decoration['start'] + "\n", block_name) if log_decoration['start']
75
- message << "#{prefix}#{file.readline}" until file.eof
76
- message << format(log_decoration['end'] + "\n", block_name) if log_decoration['end']
77
- logger << message
78
- file.close
72
+ logger << format(log_decoration['end'] + "\n", block_name) if log_decoration['end']
73
+ end
74
+ logger.error("*** UNUSUAL TERMINATION FOR: #{script}")
75
+
76
+ raise exception
77
+ end
78
+
79
+ def log_until_incomplete_line(logger, out_string)
80
+ loop do
81
+ line, out_string = out_string.split(/\n/, 2)
82
+ return line || '' unless out_string
83
+ logger << line
84
+ logger << "\n"
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def output_reader(pout, pstat, logger, capture)
91
+ out_string = ''
92
+
93
+ loop do
94
+ io_select = IO.select([pout], [], [], ONE_SECOND)
95
+ unless io_select || pstat.alive?
96
+ logger << "\n== Terminating because io_select=#{io_select} when pstat.alive?=#{pstat.alive?}\n"
97
+ break
79
98
  end
99
+ next unless io_select
100
+ # Windows doesn't support read_nonblock!
101
+ partial = pout.readpartial(8192)
102
+ capture[0] += partial if capture
103
+ out_string = log_until_incomplete_line(logger, out_string + partial)
104
+ end
105
+ rescue EOFError
106
+ logger << "\n== EOF is normal exit, #{pstat.inspect}\n"
107
+ rescue => e
108
+ logger << "\n== Exception in out_reader due to #{e.inspect} #{e.backtrace}\n"
109
+ ensure
110
+ logger << out_string
111
+ logger << ["\n== Left out_reader at #{Time.now}; ",
112
+ "pipe=#{pstat.status}+#{pstat.status ? '≤no value≥' : pstat.value}\n"].join
113
+ end
114
+
115
+ def graceful_process_shutdown(out_reader, pstat, pout, logger)
116
+ out_reader.value # Should terminate with pstat
117
+ pout.close
118
+ if pstat.status
119
+ logger << "== Thread #{pstat.inspect} is not dead"
120
+
121
+ if pstat.join(3)
122
+ logger << "== Thread #{pstat.inspect} joined late"
123
+ else
124
+ pstat.terminate # Just in case
125
+ logger << "== Thread #{pstat.inspect} terminated"
126
+ end # Make an effort to reap
127
+ end
128
+
129
+ pstat.value # reap already-terminated child.
130
+ "Command completed #{pstat.value} at #{Time.now}"
131
+ end
132
+
133
+ def force_kill_process_with_tree(out_reader, pstat, pout, full_script, logger, timeout) # rubocop:disable Metrics/ParameterLists, Metrics/LineLength
134
+ out_reader.exit
135
+ tree = Helper::Processes.ps_tree
136
+ pid = pstat[:pid].to_s
137
+ unless Helper::Processes.ms_windows?
138
+ logger << "Timeout, so trying SIGUSR1 to trigger watchdog stacktrace #{pstat[:pid]}=#{full_script}"
139
+ Helper::Processes.kill_tree('SIGUSR1', pid, logger, tree)
140
+ logger << %x(ps -ax)
141
+ sleep 2
142
+ end
143
+
144
+ logger << "Timeout, so trying SIGINT at #{pstat[:pid]}=#{full_script}"
145
+
146
+ log_copy = Thread.new do
147
+ pout.each_line { |l| logger << l }
148
+ end
149
+ log_copy.exit unless log_copy.join(2)
150
+
151
+ pout.close
152
+
153
+ wait_sigint = 15
154
+ logger << "Timeout #{timeout}s was reached. Sending SIGINT(2), SIGKILL after #{wait_sigint}s."
155
+ begin
156
+ Helper::Processes.kill_tree('SIGINT', pid, logger, tree)
157
+
158
+ timed_out = wait_sigint.times do |t|
159
+ break if Helper::Processes.all_pids_dead?(pid, logger, nil, tree)
160
+ logger << "Wait dead #{t} pid #{pid}"
161
+ sleep 1
162
+ end
163
+
164
+ if timed_out
165
+ logger << "Process #{pid} lasted #{wait_sigint}s after SIGINT(2), so SIGKILL(9)! Fatality!"
166
+ Helper::Processes.kill_tree('SIGKILL', pid, logger, nil, tree)
167
+ logger << "Tried SIGKILL #{pid}!"
168
+ end
169
+
170
+ logger << "About to reap root #{pid}"
171
+ pstat.value # reap root - everything else should be reaped by init.
172
+ logger << "Reaped root #{pid}"
80
173
  end
81
- logger.debug("Unusual termination for command: #{script}")
82
- nil
83
174
  end
84
175
  end
85
176
  end