Class: RspecParallel

Inherits:
Object
  • Object
show all
Defined in:
lib/rspec_parallel.rb

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ RspecParallel

Returns a new instance of RspecParallel.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/rspec_parallel.rb', line 26

def initialize(options = {})
  @options = {:thread_number => 4, :case_folder => "./spec/", :report_folder => "./reports/",
              :filter => {}, :env_list => [], :show_pending => false, :rerun => false,
              :single_report => false, :random_order => false, :random_seed => nil,
              :max_thread_number => 16, :longevity => 0}.merge(options)
  @thread_number = @options[:thread_number]
  @max_thread_number = @options[:max_thread_number]

  @case_number = 0
  @failure_number = 0
  @pending_number = 0
  @interrupted = false
  @target = @options[:target]
end

Instance Attribute Details

#case_info_listObject (readonly)

Returns the value of attribute case_info_list.



20
21
22
# File 'lib/rspec_parallel.rb', line 20

def case_info_list
  @case_info_list
end

#case_numberObject (readonly)

Returns the value of attribute case_number.



17
18
19
# File 'lib/rspec_parallel.rb', line 17

def case_number
  @case_number
end

#failure_numberObject (readonly)

Returns the value of attribute failure_number.



18
19
20
# File 'lib/rspec_parallel.rb', line 18

def failure_number
  @failure_number
end

#interruptedObject (readonly)

Returns the value of attribute interrupted.



21
22
23
# File 'lib/rspec_parallel.rb', line 21

def interrupted
  @interrupted
end

#max_thread_numberObject

Returns the value of attribute max_thread_number.



24
25
26
# File 'lib/rspec_parallel.rb', line 24

def max_thread_number
  @max_thread_number
end

#pending_numberObject (readonly)

Returns the value of attribute pending_number.



19
20
21
# File 'lib/rspec_parallel.rb', line 19

def pending_number
  @pending_number
end

#thread_numberObject

Returns the value of attribute thread_number.



23
24
25
# File 'lib/rspec_parallel.rb', line 23

def thread_number
  @thread_number
end

Instance Method Details

#format_time(t) ⇒ Object



351
352
353
354
355
356
357
# File 'lib/rspec_parallel.rb', line 351

def format_time(t)
  time_str = ''
  time_str += (t / 3600).to_i.to_s + " hours " if t > 3600
  time_str += (t % 3600 / 60).to_i.to_s + " minutes " if t > 60
  time_str += (t % 60).to_f.round(2).to_s + " seconds"
  time_str
end

#get_case_listObject



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/rspec_parallel.rb', line 186

def get_case_list
  case_folder = @options[:case_folder]
  case_list = []
  Find.find(case_folder) { |filename|
    unless filename.include? "_spec.rb"
      next
    end
    f = File.read(filename.strip).force_encoding("ISO-8859-1").encode("utf-8", replace: nil)

    # try to get tags of describe level
    describe_text = f.scan(/describe [\s\S]*? do/)[0]
    describe_tags = []
    temp = describe_text.scan(/[,\s]:(\w+)/)
    unless temp == nil
      temp.each do |t|
        describe_tags << t[0]
      end
    end

    # get cases of normal format: "it ... do"
    cases = f.scan(/(it (["'])([\s\S]*?)\2[\s\S]*? do)/)
    line_number = 0
    if cases
      cases.each { |c1|
        c = c1[0]
        tags = []
        draft_tags = c.scan(/[,\s]:(\w+)/)
        draft_tags.each { |tag|
          tags << tag[0]
        }
        tags += describe_tags
        tags.uniq

        i = 0
        cross_line = false
        f.each_line { |line|
          i += 1
          if i <= line_number && line_number > 0
            next
          end
          if line.include? c1[2]
            if line.strip.end_with? " do"
              case_hash = {"line" => "#{filename.strip}:#{i}", "tags" => tags}
              case_list << case_hash
              line_number = i
              cross_line = false
              break
            else
              cross_line = true
            end
          end
          if cross_line && (line.strip.end_with? " do")
            case_hash = {"line" => "#{filename.strip}:#{i}", "tags" => tags}
            case_list << case_hash
            line_number = i
            cross_line = false
            break
          end
        }
      }
    end

    # get cases of another format: "it {...}"
    cases = f.scan(/it \{[\s\S]*?\}/)
    line_number = 0
    if cases
      cases.each { |c|
        i = 0
        f.each_line { |line|
          i += 1
          if i <= line_number && line_number > 0
            next
          end
          if line.include? c
            case_hash = {"line" => "#{filename.strip}:#{i}", "tags" => describe_tags}
            case_list << case_hash
            line_number = i
            break
          end
        }
      }
    end
  }
  case_list
end

#parse_case_list(filter) ⇒ Object



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/rspec_parallel.rb', line 272

def parse_case_list(filter)
  all_case_list = get_case_list
  pattern_filter_list = []
  tags_filter_list = []

  if filter["pattern"]
    all_case_list.each { |c|
      if c["line"].match(filter["pattern"])
        pattern_filter_list << c
      end
    }
  else
    pattern_filter_list = all_case_list
  end

  if filter["tags"]
    include_tags = []
    exclude_tags = []
    all_tags = filter["tags"].split(",")
    all_tags.each { |tag|
      if tag.start_with? "~"
        exclude_tags << tag.gsub("~", "")
      else
        include_tags << tag
      end
    }
    pattern_filter_list.each { |c|
      if (include_tags.length == 0 || (c["tags"] - include_tags).length < c["tags"].length) &&
          ((c["tags"] - exclude_tags).length == c["tags"].length)
        tags_filter_list << c
      end
    }
  else
    tags_filter_list = pattern_filter_list
  end

  tags_filter_list = random_tests(tags_filter_list) if @options[:random_order]

  tags_filter_list = reorder_tests(tags_filter_list)

  tags_filter_list.each { |t|
    @queue << t["line"]
  }
end

#parse_case_log(str) ⇒ Object



359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/rspec_parallel.rb', line 359

def parse_case_log(str)
  return nil unless str =~ /1 example/
  result = {}
  logs = []
  str.each_line {|l| logs << l}
  return nil if logs == []

  stderr = ''
  unless logs[0].start_with? 'Run options:'
    clear_logs = []
    logs_start = false
    for i in 0..logs.length-1
      if logs[i].strip.start_with? 'Run options:'
        logs_start = true
      end
      if logs_start
        clear_logs << logs[i]
      else
        stderr += logs[i]
      end
    end
    logs = clear_logs
  end
  result['stderr'] = stderr

  stdout = ''
  if logs[4].strip != ''
    clear_logs = []
    stdout_start = true
    for i in 0..logs.length-1
      if i < 3
        clear_logs << logs[i]
      elsif stdout_start && logs[i+1].strip == ''
        clear_logs << logs[i]
        stdout_start = false
      elsif !stdout_start
        clear_logs << logs[i]
      else
        stdout += logs[i]
      end
    end
    logs = clear_logs
  end
  result['stdout'] = stdout

  result['class_name'] = logs[2].strip
  result['test_desc'] = logs[3].gsub(/\((FAILED|PENDING).+\)/, '').strip
  result['test_name'] = result['class_name'] + ' ' + result['test_desc']

  if logs[-1].include? '1 pending'
    result['status'] = 'pending'
    pending_info = ''
    for i in 7..logs.length-4
      next if logs[i].strip == ''
      pending_info += logs[i]
    end
    result['pending_info'] = pending_info
  elsif logs[-1].include? '0 failures'
    result['status'] = 'pass'
  elsif logs[-1].start_with? 'rspec '
    result['status'] = 'fail'
    result['rerun_cmd'] = logs[-1]
    error_message = logs[8]
    error_stack_trace = ''
    for i in 9..logs.length-8
      next if logs[i].strip == ''
      if logs[i].strip.start_with? '# '
        error_stack_trace += logs[i]
      else
        error_message += logs[i]
      end
    end
    error_message.each_line do |l|
      next if l.include? 'Error:'
      result['error_details'] = l.strip
      break
    end
    if error_message.index(result['error_details']) < error_message.length - result['error_details'].length - 10
      result['error_details'] += "..."
    end
    result['error_message'] = error_message
    result['error_stack_trace'] = error_stack_trace
  else
    result['status'] = 'unknown'
  end

  result
end

#random_tests(case_list) ⇒ Object



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# File 'lib/rspec_parallel.rb', line 331

def random_tests(case_list)
  if @options[:random_seed]
    seed = @options[:random_seed].to_i
  else
    seed = Time.now.to_i
  end
  puts yellow("running tests randomly with the seed: #{seed}")
  rand_num = Random.new(seed)

  random_case_list = []
  case_list.sort_by { rand_num.rand }.each do |c|
    random_case_list << c
  end
  random_case_list
end

#reorder_tests(case_list) ⇒ Object



347
348
349
# File 'lib/rspec_parallel.rb', line 347

def reorder_tests(case_list)
  return case_list
end

#run_task(task, env_extras) ⇒ Object



317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/rspec_parallel.rb', line 317

def run_task(task, env_extras)
  cmd = [] # Preparing command for popen
  cmd << ENV.to_hash.merge(env_extras)
  cmd += ["bundle", "exec", "rspec", "-f", "d", "--color", task]
  cmd

  output = ""
  IO.popen(cmd, :err => [:child, :out]) do |io|
    output << io.read
  end

  output
end

#run_testsObject



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/rspec_parallel.rb', line 41

def run_tests()
  start_time = Time.now # timer of rspec task
  @queue = Queue.new # store all tests to run
  @case_info_list = [] # store results of all tests
  @lock = Mutex.new # use lock to avoid output mess up
  @return_message = "ok"

  if @thread_number < 1
    @return_message = "thread_number can't be less than 1"
    puts red(@return_message)
    return @return_message
  elsif @thread_number > @max_thread_number
    @return_message = "thread_number can't be greater than #{@max_thread_number}"
    puts red(@return_message)
    return @return_message
  end
  puts yellow("threads number: #{@thread_number}\n")

  rerun = @options[:rerun]
  single_report = @options[:single_report]

  if !single_report && rerun
    @report_folder = get_report_folder(@options[:report_folder], true)
  end

  @report_folder = @options[:report_folder] if @report_folder.nil?

  filter = @options[:filter]
  if rerun
    @queue = get_failed_cases(@options[:report_folder], single_report)
  else
    parse_case_list(filter)
  end

  if @queue.empty?
    @return_message = "no cases to run, exit."
    puts yellow(@return_message)
    return @return_message
  end

  pbar = ProgressBar.new("0/#{@queue.size}", @queue.size, $stdout)
  pbar.format_arguments = [:title, :percentage, :bar, :stat]
  failure_list = []
  pending_list = []

  Thread.abort_on_exception = false
  threads = []

  @thread_number.times do |i|
    threads << Thread.new do
      until @queue.empty?
        task = @queue.pop
        env_extras = {}
        env_list = @options[:env_list]
        if env_list && env_list.size > 0
          env_extras = env_list[i % env_list.size]
        end
        t1 = Time.now
        task_output = run_task(task, env_extras)
        t2 = Time.now
        case_info = parse_case_log(task_output)
        unless case_info
          puts task_output
          next
        end
        case_info['duration'] = t2 - t1
        @case_info_list << case_info

        if case_info['status'] == 'fail'
          @lock.synchronize do
            @failure_number += 1
            failure_list << case_info

            # print failure immediately during the execution
            $stdout.print "\e[K"
            if @failure_number == 1
              $stdout.print "Failures:\n\n"
            end
            puts "  #{@failure_number}) #{case_info['test_name']}"
            $stdout.print "#{red(case_info['error_message'])}"
            $stdout.print "#{cyan(case_info['error_stack_trace'])}"
            $stdout.print red("     (Failure time: #{Time.now})\n\n")
          end
        elsif case_info['status'] == 'pending'
          @lock.synchronize do
            @pending_number += 1
            pending_list << case_info
          end
        end
        @case_number += 1
        pbar.inc
        pbar.instance_variable_set("@title", "#{pbar.current}/#{pbar.total}")
      end
    end
    # ramp up user threads one by one
    sleep 0.1
  end

  begin
    threads.each { |t| t.join }
  rescue Interrupt
    puts yellow("catch Ctrl+C, will exit gracefully")
    @interrupted = true
  end
  pbar.finish

  # print pending cases if configured
  show_pending = @options[:show_pending]
  if show_pending && @pending_number > 0
    $stdout.print "\n"
    puts "Pending:"
    pending_list.each {|case_info|
      puts "  #{yellow(case_info['test_name'])}\n"
      $stdout.print cyan("#{case_info['pending_info']}")
    }
  end

  # print total time and summary result
  end_time = Time.now
  puts "\nFinished in #{format_time(end_time-start_time)}\n"
  if @failure_number > 0
    $stdout.print red("#{@case_number} examples, #{@failure_number} failures")
    $stdout.print red(", #{@pending_number} pending") if @pending_number > 0
  elsif @pending_number > 0
    $stdout.print yellow("#{@case_number} examples, #{@failure_number} failures, #{@pending_number} pending")
  else
    $stdout.print green("#{@case_number} examples, 0 failures")
  end
  $stdout.print "\n"

  # print rerun command of failed examples
  unless failure_list.empty?
    $stdout.print "\nFailed examples:\n\n"
    failure_list.each do |case_info|
      $stdout.print red(case_info['rerun_cmd'].split(' # ')[0])
      $stdout.print cyan(" # #{case_info['test_name']}\n")
    end
  end

  # CI: true - update ; default: false - new file
  generate_reports(@report_folder, end_time - start_time, @case_info_list, @options)

  @return_message
end