parallel_cucumber 0.2.0.pre.36 → 0.2.3

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.
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