rubotium 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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +24 -0
  6. data/Rakefile +1 -0
  7. data/bin/rubotium +26 -0
  8. data/lib/rubotium/adb/command.rb +21 -0
  9. data/lib/rubotium/adb/devices.rb +25 -0
  10. data/lib/rubotium/adb/install_command.rb +17 -0
  11. data/lib/rubotium/adb/instrumentation.rb +36 -0
  12. data/lib/rubotium/adb/shell.rb +30 -0
  13. data/lib/rubotium/adb/test_result_parser.rb +87 -0
  14. data/lib/rubotium/adb/uninstall_command.rb +17 -0
  15. data/lib/rubotium/adb.rb +13 -0
  16. data/lib/rubotium/apk/converter.rb +22 -0
  17. data/lib/rubotium/apk.rb +7 -0
  18. data/lib/rubotium/cmd.rb +17 -0
  19. data/lib/rubotium/device.rb +49 -0
  20. data/lib/rubotium/devices.rb +25 -0
  21. data/lib/rubotium/formatters/junit_formatter.rb +82 -0
  22. data/lib/rubotium/grouper.rb +40 -0
  23. data/lib/rubotium/jar_reader.rb +70 -0
  24. data/lib/rubotium/package.rb +36 -0
  25. data/lib/rubotium/runable_test.rb +11 -0
  26. data/lib/rubotium/test_case.rb +6 -0
  27. data/lib/rubotium/test_suite.rb +12 -0
  28. data/lib/rubotium/version.rb +3 -0
  29. data/lib/rubotium.rb +85 -0
  30. data/rubotium.gemspec +33 -0
  31. data/spec/fixtures/adb_devices_results.rb +23 -0
  32. data/spec/fixtures/adb_results.rb +60 -0
  33. data/spec/fixtures/jar_contents.rb +28 -0
  34. data/spec/fixtures/javap_classes.rb +52 -0
  35. data/spec/lib/rubotium/adb/adb_devices_spec.rb +25 -0
  36. data/spec/lib/rubotium/adb/adb_instrumentation_spec.rb +32 -0
  37. data/spec/lib/rubotium/adb/adb_result_parser_spec.rb +132 -0
  38. data/spec/lib/rubotium/adb/adb_shell_spec.rb +23 -0
  39. data/spec/lib/rubotium/devices_spec.rb +45 -0
  40. data/spec/lib/rubotium/formatters/junit_formatter_spec.rb +7 -0
  41. data/spec/lib/rubotium/grouper_spec.rb +56 -0
  42. data/spec/lib/rubotium/jar_reader_spec.rb +58 -0
  43. data/spec/lib/rubotium_spec.rb +13 -0
  44. data/spec/spec_helper.rb +20 -0
  45. metadata +256 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a668e6a758f73295484b33a8bb12d7787ace3669
4
+ data.tar.gz: 32bb1aedd3650eadb296d6cbe3c8030d85cbe013
5
+ SHA512:
6
+ metadata.gz: e803e2b146f3654ef5b790f6e4c989b66afaf5aefd172c37c5c03b68ba43014d56ce1b7bd68af8123d84431d5fb953c2b895b4bd1c2f4f4a4c6294a99dd5987f
7
+ data.tar.gz: 999a97ea8be422f305648034fc05eab2dac5a12210bac25f1f7e0bb67c887d812773df18b75b75d00a890625cbf068a1847a54633877076aa9185fea4c268ae9
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rubotium.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Slawomir Smiechura
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Rubotium
2
+
3
+ This is an Android's Instrumentation test runner. It's in quite early phase but already solves couple of issues with instrumentation tests:
4
+
5
+ * runs tests in parallel
6
+ * retries failed tests
7
+ * does execute all the package tests even if the app dies during the execution
8
+
9
+ ## Installation
10
+
11
+ gem install rubotium
12
+
13
+ ## Usage
14
+
15
+ $ rubotium -t <path_to_tests.apk> -a <path_to_application.apk> -r <instrumentation_test_runner>
16
+ $ rubotium -h for help
17
+
18
+ ## Contributing
19
+
20
+ 1. Fork it ( http://github.com/<my-github-username>/rubotium/fork )
21
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
22
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
23
+ 4. Push to the branch (`git push origin my-new-feature`)
24
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/bin/rubotium ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubotium'
4
+
5
+ require 'trollop'
6
+ opts = Trollop::options do
7
+ opt :test_jar_path, 'Path to the tests .jar file', :type => :string, :short => '-j'
8
+ opt :test_apk_path, 'Path to the tests .apk file', :type => :string, :short => '-t'
9
+ opt :app_apk_path, 'Path to the app .apk file', :type => :string, :short => '-a'
10
+ opt :rerun, 'Retries count', :default => 0, :short => '-R'
11
+ opt :out, 'Report file', :default => 'report.xml', :short => '-o'
12
+ opt :device, 'Match devices', :type => :string, :short => '-d'
13
+ opt :runner, 'Test runner', :type => :string, :short => 'r'
14
+ end
15
+
16
+ params = {
17
+ :tests_jar_path => opts[:test_jar_path],
18
+ :tests_apk_path => opts[:test_apk_path],
19
+ :app_apk_path => opts[:app_apk_path],
20
+ :rerun_count => opts[:rerun],
21
+ :report => opts[:out],
22
+ :device_matcher => opts[:device],
23
+ :runner => opts[:runner]
24
+ }
25
+
26
+ Rubotium.new(params)
@@ -0,0 +1,21 @@
1
+ module Rubotium
2
+ module Adb
3
+ class Command
4
+ def initialize(device_serial)
5
+ @device_serial = device_serial
6
+ end
7
+
8
+ def execute(command_to_run)
9
+ puts "EXECUTING_COMMAND: #{adb_command} #{command_to_run.executable_command}"
10
+ CMD.run_command(adb_command + " " + command_to_run.executable_command)
11
+ end
12
+
13
+ private
14
+ attr_reader :device_serial
15
+
16
+ def adb_command
17
+ "adb -s #{device_serial} "
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ module Rubotium
2
+ module Adb
3
+ class Devices
4
+ attr_reader :list
5
+
6
+ def initialize
7
+ @list = parse(CMD.run_command('adb devices',{ :timeout => 5 } ))
8
+ end
9
+
10
+ private
11
+ def parse result
12
+ list = result.split("\n")
13
+ list.shift
14
+ attached_devices list
15
+ end
16
+
17
+ def attached_devices(list)
18
+ list.collect { |device|
19
+ parts = device.split("\t")
20
+ parts.last == 'device' ? parts.first : nil
21
+ }.compact
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ module Rubotium
2
+ module Adb
3
+ class InstallCommand
4
+ COMMAND = 'install'
5
+ def initialize(apk_path)
6
+ @apk_path = apk_path
7
+ end
8
+
9
+ def executable_command
10
+ "#{COMMAND} #{apk_path}"
11
+ end
12
+
13
+ private
14
+ attr_reader :apk_path
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ module Rubotium
2
+ module Adb
3
+ class NoTestRunnerError < StandardError; end
4
+ class NoTestPackageError < StandardError; end
5
+
6
+ class Instrumentation
7
+ attr_accessor :test_package_name, :test_runner
8
+ attr_reader :adb_shell
9
+
10
+ def initialize(device)
11
+ @adb_shell = Rubotium::Adb::Shell.new(device)
12
+ end
13
+
14
+ def run_test(runable_test)
15
+ check_packages
16
+ result = adb_shell.run_command(instrument_command(runable_test.package_name, runable_test.test_name))
17
+ TestResultParser.new(result, runable_test.package_name, runable_test.test_name)
18
+ end
19
+
20
+ private
21
+ def instrument_command package_name, test_name
22
+ "am instrument -w -e class #{class_test(package_name, test_name)} #{test_package_name}/#{test_runner}"
23
+ end
24
+
25
+ def class_test package_name, test_name
26
+ "#{package_name}##{test_name}"
27
+ end
28
+
29
+ def check_packages
30
+ raise NoTestRunnerError if !test_runner
31
+ raise NoTestPackageError if !test_package_name
32
+ end
33
+
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,30 @@
1
+ module Rubotium
2
+ module Adb
3
+ class Shell
4
+ ADB = 'adb'
5
+ attr_reader :device_serial
6
+
7
+ def initialize(device_serial)
8
+ @device_serial = device_serial
9
+ end
10
+
11
+ def run_command command_to_run
12
+ CMD.run_command(command + " " + command_to_run)
13
+ end
14
+
15
+ private
16
+ def command
17
+ [ADB, device, 'shell'].compact.join(" ")
18
+ end
19
+
20
+ def device
21
+ if device_serial && !device_serial.empty?
22
+ ['-s', device_serial]
23
+ else
24
+ nil
25
+ end
26
+ end
27
+
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,87 @@
1
+ module Rubotium
2
+ module Adb
3
+ class TestResultParser
4
+ ERROR_STATUS = 'ERROR'
5
+ FAIL_STATUS = 'FAIL'
6
+ OK_STATUS = 'OK'
7
+
8
+ attr_reader :result, :stack_trace, :time, :error_message, :status, :package_name, :test_name
9
+
10
+ def initialize(result, package_name, test_name)
11
+ @result = result
12
+ @package_name = package_name
13
+ @test_name = test_name
14
+ parse
15
+ end
16
+
17
+ def failed?
18
+ @status == FAIL_STATUS
19
+ end
20
+
21
+ def passed?
22
+ @status == OK_STATUS
23
+ end
24
+
25
+ def errored?
26
+ @status == ERROR_STATUS
27
+ end
28
+
29
+ private
30
+ def parse
31
+ if ENV['DEBUG']
32
+ p result
33
+ end
34
+ @stack_trace = has_failed? ? get_stack_trace : ""
35
+ @status = get_status
36
+ @time = has_errored? ? 0 : get_time
37
+ @error_message = has_errored? ? get_error : ""
38
+ end
39
+
40
+ def get_status
41
+ if has_errored?
42
+ ERROR_STATUS
43
+ elsif has_failed?
44
+ FAIL_STATUS
45
+ else
46
+ OK_STATUS
47
+ end
48
+ end
49
+
50
+ def has_errored?
51
+ ['INSTRUMENTATION_ABORTED', 'INSTRUMENTATION_RESULT','INSTRUMENTATION_STATUS'].any? {|error_message|
52
+ result.include? error_message
53
+ }
54
+ end
55
+
56
+ def has_failed?
57
+ result.include? 'FAILURES'
58
+ end
59
+
60
+ def get_time
61
+ result.match(/Time: (-?\d+.\d+)/)[1].to_f.abs
62
+ end
63
+
64
+ def get_stack_trace
65
+ result.split("\r\n\r\n")[0].split(/^/)[3..-1].join
66
+ end
67
+
68
+ def get_error
69
+ if instrumentation_error
70
+ result.match(/INSTRUMENTATION_RESULT: longMsg=(.*)\r/)[1]
71
+ elsif application_error
72
+ result.match(/INSTRUMENTATION_ABORTED: (.*)/)[1]
73
+ else
74
+ result.match(/INSTRUMENTATION_STATUS: Error=(.*)\r/)[1]
75
+ end
76
+ end
77
+
78
+ def application_error
79
+ result.match(/INSTRUMENTATION_ABORTED/) != nil
80
+ end
81
+
82
+ def instrumentation_error
83
+ result.match(/INSTRUMENTATION_RESULT: longMsg/) != nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,17 @@
1
+ module Rubotium
2
+ module Adb
3
+ class UninstallCommand
4
+ COMMAND = 'uninstall'
5
+ def initialize(package_name)
6
+ @package_name = package_name
7
+ end
8
+
9
+ def executable_command
10
+ "#{COMMAND} #{package_name}"
11
+ end
12
+
13
+ private
14
+ attr_reader :package_name
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ require_relative 'adb/command'
2
+ require_relative 'adb/install_command'
3
+ require_relative 'adb/uninstall_command'
4
+ require_relative 'adb/devices'
5
+ require_relative 'adb/test_result_parser'
6
+ require_relative 'adb/shell'
7
+ require_relative 'adb/instrumentation'
8
+
9
+ module Rubotium
10
+ module Adb
11
+
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ require 'dex2jar'
2
+ module Rubotium
3
+ module Apk
4
+ class Converter
5
+ def initialize(apk_path, output_path)
6
+ @apk_path = apk_path
7
+ @output_path = output_path
8
+ end
9
+
10
+ def convert_to_jar
11
+ Dex2jar.execute("-f -o #{output_path}", [apk_path])
12
+ end
13
+
14
+ private
15
+ attr_reader :apk_path, :output_path
16
+
17
+ def file_exists?
18
+ File.exist? apk_path
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,7 @@
1
+ require_relative 'apk/converter'
2
+
3
+ module Rubotium
4
+ module Apk
5
+
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ require 'timeout'
2
+ module Rubotium
3
+ class CMD
4
+ class << self
5
+ def run_command(command_to_run, opts = {})
6
+ begin
7
+ Timeout::timeout(opts[:timeout] || 10 * 60) {
8
+ puts "[EXECUTING]: #{command_to_run}" if ENV['DEBUG']
9
+ `#{command_to_run}`
10
+ }
11
+ rescue Timeout::Error
12
+ ""
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ module Rubotium
2
+ class Device
3
+ attr_accessor :testsuite
4
+ attr_reader :serial, :results
5
+ def initialize(serial, test_runner)
6
+ @runner = test_runner
7
+ @retry = 1
8
+ @serial = serial
9
+ @results = {}
10
+ @command = Rubotium::Adb::Command.new(serial)
11
+ end
12
+
13
+ def test_runner_name= name
14
+ runner.test_runner = name
15
+ end
16
+
17
+ def test_package_name= name
18
+ runner.test_package_name = name
19
+ end
20
+
21
+ def install(apk_path)
22
+ command.execute(Rubotium::Adb::InstallCommand.new(apk_path))
23
+ end
24
+
25
+ def uninstall(package_name)
26
+ command.execute(Rubotium::Adb::UninstallCommand.new(package_name))
27
+ end
28
+
29
+ def run_tests
30
+ raise(NoTestSuiteError, "Please setup test suite before running tests") if testsuite.nil?
31
+ puts "Running tests"
32
+ testsuite.each{|runable_test|
33
+ @results[runable_test.package_name] = []
34
+ puts runable_test.name
35
+ run_count = 0
36
+ puts "TEST: #{runable_test.name}"
37
+ while ((result = runner.run_test(runable_test)) && (result.failed? || result.errored?) && run_count < @retry ) do
38
+ puts "RERUNNING TEST: #{runable_test.name}, STATUS: #{result.status}"
39
+ run_count += 1
40
+ end
41
+ puts "FINISHED with status: #{result.status}"
42
+ @results[runable_test.package_name].push(result)
43
+ }
44
+ end
45
+
46
+ private
47
+ attr_reader :runner, :command
48
+ end
49
+ end
@@ -0,0 +1,25 @@
1
+ module Rubotium
2
+ class Devices
3
+ def initialize(device_matcher = '')
4
+ @attached_devices = Adb::Devices.new.list
5
+ @matched = matched_devices device_matcher
6
+ end
7
+
8
+ def all
9
+ raise NoDevicesError if attached_devices.empty?
10
+ raise NoMatchedDevicesError if matched.empty?
11
+ matched.map{|device|
12
+ Device.new(device, Adb::Instrumentation.new(device))
13
+ }
14
+ end
15
+
16
+ private
17
+ attr_reader :matched, :attached_devices
18
+
19
+ def matched_devices(device_matcher)
20
+ device_matcher ? attached_devices.select{|device|
21
+ device.include? device_matcher
22
+ } : attached_devices
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,82 @@
1
+ require 'builder'
2
+
3
+ module Rubotium
4
+ module Formatters
5
+ class JunitFormatter
6
+ attr_reader :xml
7
+ def initialize(results, path_to_file)
8
+ @xml = Builder::XmlMarkup.new :target => ensure_io(path_to_file), :indent => 2
9
+
10
+ xml.testsuites do
11
+ results.each{|package_name, tests|
12
+ start_test_suite(package_name, tests)
13
+ }
14
+ end
15
+ end
16
+ private
17
+ def start_test_suite(package_name, tests)
18
+ failures = get_failures(tests)
19
+ errors = get_errors(tests)
20
+ tests_time = get_tests_time(tests)
21
+ tests_count = tests.count
22
+ params = {
23
+ :errors => errors,
24
+ :failures => failures,
25
+ :name => package_name,
26
+ :tests => tests_count,
27
+ :time => tests_time,
28
+ :timestamp => Time.now
29
+ }
30
+
31
+ xml.testsuite(params) do
32
+ tests.each { |test|
33
+ print_testcase(test)
34
+ }
35
+ end
36
+ end
37
+
38
+ def print_testcase(test)
39
+ xml.testcase :classname=>test.package_name, :name=>test.test_name, :time=>test.time do
40
+ has_failures(test)
41
+ has_errors(test)
42
+ end
43
+ end
44
+
45
+ def has_failures(test)
46
+ if test.failed?
47
+ xml.failure :message=>"", :type=>"" do
48
+ xml.cdata! test.stack_trace
49
+ end
50
+ end
51
+ end
52
+
53
+ def has_errors(test)
54
+ if test.errored?
55
+ xml.error :message=>"", :type=>"" do
56
+ xml.cdata! test.error_message
57
+ end
58
+ end
59
+ end
60
+
61
+ def get_tests_time(tests)
62
+ tests.inject(0){|time_sum, test| time_sum += test.time.to_f }
63
+ end
64
+
65
+ def get_errors(tests)
66
+ tests.select{|test|
67
+ test.errored?
68
+ }.count
69
+ end
70
+
71
+ def get_failures(tests)
72
+ tests.select{|test|
73
+ test.failed?
74
+ }.count
75
+ end
76
+
77
+ def ensure_io(path_to_file)
78
+ File.open(path_to_file, 'w')
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,40 @@
1
+ module Rubotium
2
+ class Grouper
3
+ def initialize(test_suites, num_of_groups)
4
+ @test_suites = test_suites
5
+ @num_of_groups = num_of_groups
6
+ end
7
+
8
+ def create_groups
9
+ runnable_tests.each{|runnable|
10
+ next_bucket.push(runnable)
11
+ }
12
+ buckets
13
+ end
14
+
15
+ def runnable_tests
16
+ test_suites.map {|test_suite|
17
+ test_suite.test_cases.map{|test|
18
+ RunableTest.new(test_suite.name, test.name)
19
+ }
20
+ }.flatten
21
+ end
22
+
23
+ def next_bucket
24
+ buckets_enum.next
25
+ end
26
+
27
+ def buckets_enum
28
+ @buckets_enum ||= buckets.cycle
29
+ end
30
+
31
+
32
+ def buckets
33
+ @buckets ||= Array.new(num_of_groups) { [] }
34
+ end
35
+
36
+ private
37
+ attr_reader :test_suites, :num_of_groups
38
+
39
+ end
40
+ end
@@ -0,0 +1,70 @@
1
+ require 'set'
2
+ module Rubotium
3
+ class JarReader
4
+ TEST_PATTERN = Regexp.new('public void (test.*)\(')
5
+ attr_reader :path_to_jar
6
+
7
+ def initialize(jar_path)
8
+ @path_to_jar = jar_path
9
+ end
10
+
11
+ def test_suites
12
+ Converter.new(classes_in_jar).convert
13
+ end
14
+
15
+ def get_tests
16
+ parse
17
+ end
18
+
19
+ private
20
+ def classes_in_jar
21
+ CMD.run_command("jar -tf #{path_to_jar} | grep '.class'")
22
+ end
23
+
24
+ def tests_in_class(suite_name)
25
+ CMD.run_command "javap -classpath #{path_to_jar} #{suite_name}"
26
+ end
27
+
28
+ def parse
29
+ test_suites.map{|test_suite|
30
+ tests_in_class(test_suite.name).scan(TEST_PATTERN).flatten.each{|test|
31
+ test_suite.add_test_case(TestCase.new(test))
32
+ }
33
+ test_suite
34
+ }.delete_if{|test_suite| test_suite.test_cases.empty?}
35
+ end
36
+
37
+
38
+ class Converter
39
+ attr_reader :list
40
+ def initialize(list_of_classes)
41
+ @list = list_of_classes.split
42
+ end
43
+
44
+ def convert
45
+ deduplicated_test_suites
46
+ end
47
+
48
+ private
49
+ def test_suites
50
+ converted_class_names.map{|class_name|
51
+ TestSuite.new(class_name)
52
+ }
53
+ end
54
+
55
+ def converted_class_names
56
+ list.map{|class_name|
57
+ class_name.gsub("/", ".")
58
+ .gsub(".class", "")
59
+ .gsub(/\$.*/, "")
60
+ }
61
+ end
62
+
63
+ def deduplicated_test_suites
64
+ test_suites.uniq{|test_suite|
65
+ test_suite.name
66
+ }
67
+ end
68
+ end
69
+ end
70
+ end