[iOS test runners]Modularizing and updating test_runners.

Changes in this cl:
- added test_apps(Gtest and EGtests) to reduce code duplication (
 filling xctest_run files for EG1 and EG2 tests, filtering tests,
 creating commands to run tests).
-Removes shard_xctest method and other EG sharding code from test_runner
because shards only use in xcodebuild_runner.
- updated *runner.py to support test_apps
- replaced `iossim` with `xcodebuild` command.

In following cls:
1. iossim parameter will be removed in all test-runners
   and unittests and then
2. ios/api.py remove iossim.

Bug: 1011498
Change-Id: I9966ba1152eabeee56457fa8db3b6e9f585df3c9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1961166
Commit-Queue: Maksym Onufriienko <[email protected]>
Reviewed-by: Rohit Rao <[email protected]>
Cr-Commit-Position: refs/heads/master@{#742746}
diff --git a/ios/build/bots/scripts/iossim_util_test.py b/ios/build/bots/scripts/iossim_util_test.py
index c235faf9..5b9253d 100644
--- a/ios/build/bots/scripts/iossim_util_test.py
+++ b/ios/build/bots/scripts/iossim_util_test.py
@@ -8,82 +8,12 @@
 import test_runner
 import test_runner_test
 
-SIMULATORS_LIST = {
-    'devices': {
-        'com.apple.CoreSimulator.SimRuntime.iOS-11-4': [{
-            'isAvailable': True,
-            'name': 'iPhone 5s',
-            'state': 'Shutdown',
-            'udid': 'E4E66320-177A-450A-9BA1-488D85B7278E'
-        }],
-        'com.apple.CoreSimulator.SimRuntime.iOS-13-2': [
-            {
-                'isAvailable': True,
-                'name': 'iPhone X',
-                'state': 'Shutdown',
-                'udid': 'E4E66321-177A-450A-9BA1-488D85B7278E'
-            },
-            {
-                'isAvailable': True,
-                'name': 'iPhone 11',
-                'state': 'Shutdown',
-                'udid': 'A4E66321-177A-450A-9BA1-488D85B7278E'
-            }
-        ]
-    },
-    'devicetypes': [
-        {
-            'name': 'iPhone 5s',
-            'bundlePath': '/path/iPhone 4s/Content',
-            'identifier': 'com.apple.CoreSimulator.SimDeviceType.iPhone-5s'
-        },
-        {
-            'name': 'iPhone X',
-            'bundlePath': '/path/iPhone X/Content',
-            'identifier': 'com.apple.CoreSimulator.SimDeviceType.iPhone-X'
-        },
-        {
-            'name': 'iPhone 11',
-            'bundlePath': '/path/iPhone 11/Content',
-            'identifier': 'com.apple.CoreSimulator.SimDeviceType.iPhone-11'
-        },
-    ],
-    'pairs': [],
-    'runtimes': [
-        {
-            "buildversion": "15F79",
-            "bundlePath": "/path/Runtimes/iOS 11.4.simruntime",
-            "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-11-4",
-            "isAvailable": True,
-            "name": "iOS 11.4",
-            "version": "11.4"
-        },
-        {
-            "buildversion": "17A844",
-            "bundlePath": "/path/Runtimes/iOS 13.1.simruntime",
-            "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-13-1",
-            "isAvailable": True,
-            "name": "iOS 13.1",
-            "version": "13.1"
-        },
-        {
-            "buildversion": "17B102",
-            "bundlePath": "/path/Runtimes/iOS.simruntime",
-            "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-13-2",
-            "isAvailable": True,
-            "name": "iOS 13.2",
-            "version": "13.2.2"
-        },
-    ]
-}
-
 
 class GetiOSSimUtil(test_runner_test.TestCase):
   """Tests for iossim_util.py."""
 
   def setUp(self):
     super(GetiOSSimUtil, self).setUp()
-    self.mock(iossim_util, 'get_simulator_list', lambda: SIMULATORS_LIST)
 
   def test_get_simulator_runtime_by_version(self):
     """Ensures correctness of filter."""
diff --git a/ios/build/bots/scripts/test_apps.py b/ios/build/bots/scripts/test_apps.py
new file mode 100644
index 0000000..0d03030
--- /dev/null
+++ b/ios/build/bots/scripts/test_apps.py
@@ -0,0 +1,309 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Test apps for running tests using xcodebuild."""
+
+import os
+import plistlib
+import time
+
+import test_runner
+
+
+#TODO(crbug.com/1046911): Remove usage of KIF filters.
+def get_kif_test_filter(tests, invert=False):
+  """Returns the KIF test filter to filter the given test cases.
+
+  Args:
+    tests: List of test cases to filter.
+    invert: Whether to invert the filter or not. Inverted, the filter will match
+      everything except the given test cases.
+
+  Returns:
+    A string which can be supplied to GKIF_SCENARIO_FILTER.
+  """
+  # A pipe-separated list of test cases with the "KIF." prefix omitted.
+  # e.g. NAME:a|b|c matches KIF.a, KIF.b, KIF.c.
+  # e.g. -NAME:a|b|c matches everything except KIF.a, KIF.b, KIF.c.
+  test_filter = '|'.join(test.split('KIF.', 1)[-1] for test in tests)
+  if invert:
+    return '-NAME:%s' % test_filter
+  return 'NAME:%s' % test_filter
+
+
+def get_gtest_filter(tests, invert=False):
+  """Returns the GTest filter to filter the given test cases.
+
+  Args:
+    tests: List of test cases to filter.
+    invert: Whether to invert the filter or not. Inverted, the filter will match
+      everything except the given test cases.
+
+  Returns:
+    A string which can be supplied to --gtest_filter.
+  """
+  # A colon-separated list of tests cases.
+  # e.g. a:b:c matches a, b, c.
+  # e.g. -a:b:c matches everything except a, b, c.
+  test_filter = ':'.join(test for test in tests)
+  if invert:
+    return '-%s' % test_filter
+  return test_filter
+
+
+class GTestsApp(object):
+  """Gtests app to run.
+
+  Stores data about egtests:
+    test_app: full path to an app.
+  """
+
+  def __init__(self,
+               test_app,
+               included_tests=None,
+               excluded_tests=None,
+               test_args=None,
+               env_vars=None,
+               host_app_path=None):
+    """Initialize Egtests.
+
+    Args:
+      test_app: (str) full path to egtests app.
+      included_tests: (list) Specific tests to run
+         E.g.
+          [ 'TestCaseClass1/testMethod1', 'TestCaseClass2/testMethod2']
+      excluded_tests: (list) Specific tests not to run
+         E.g.
+          [ 'TestCaseClass1', 'TestCaseClass2/testMethod2']
+      test_args: List of strings to pass as arguments to the test when
+        launching.
+      env_vars: List of environment variables to pass to the test itself.
+
+    Raises:
+      AppNotFoundError: If the given app does not exist
+    """
+    if not os.path.exists(test_app):
+      raise test_runner.AppNotFoundError(test_app)
+    self.test_app_path = test_app
+    self.project_path = os.path.dirname(self.test_app_path)
+    self.test_args = test_args or []
+    self.env_vars = {}
+    for env_var in env_vars or []:
+      env_var = env_var.split('=', 1)
+      self.env_vars[env_var[0]] = None if len(env_var) == 1 else env_var[1]
+    self.included_tests = included_tests or []
+    self.excluded_tests = excluded_tests or []
+    self.module_name = os.path.splitext(os.path.basename(test_app))[0]
+    self.host_app_path = host_app_path
+
+  def fill_xctest_run(self, out_dir):
+    """Fills xctestrun file by egtests.
+
+    Args:
+      out_dir: (str) A path where xctestrun will store.
+
+    Returns:
+      A path to xctestrun file.
+    """
+    folder = os.path.abspath(os.path.join(out_dir, os.pardir))
+    if not os.path.exists(folder):
+      os.makedirs(folder)
+    xctestrun = os.path.join(folder, 'run_%d.xctestrun' % int(time.time()))
+    if not os.path.exists(xctestrun):
+      with open(xctestrun, 'w'):
+        pass
+    # Creates a dict with data about egtests to run - fill all required fields:
+    # egtests_module, egtest_app_path, egtests_xctest_path and
+    # filtered tests if filter is specified.
+    # Write data in temp xctest run file.
+    plistlib.writePlist(self.fill_xctestrun_node(), xctestrun)
+    return xctestrun
+
+  def fill_xctestrun_node(self):
+    """Fills only required nodes for egtests in xctestrun file.
+
+    Returns:
+      A node with filled required fields about egtests.
+    """
+    module = self.module_name + '_module'
+
+    # If --run-with-custom-webkit is passed as a test arg, set up
+    # DYLD_FRAMEWORK_PATH to load the custom webkit modules.
+    dyld_framework_path = self.project_path + ':'
+    if '--run-with-custom-webkit' in self.test_args:
+      if self.host_app_path:
+        webkit_path = os.path.join(self.host_app_path, 'WebKitFrameworks')
+      else:
+        webkit_path = os.path.join(self.test_app_path, 'WebKitFrameworks')
+      dyld_framework_path = dyld_framework_path + webkit_path + ':'
+
+    module_data = {
+        'TestBundlePath': self.test_app_path,
+        'TestHostPath': self.test_app_path,
+        'TestingEnvironmentVariables': {
+            'DYLD_LIBRARY_PATH':
+                '%s:__PLATFORMS__/iPhoneSimulator.platform/Developer/Library' %
+                self.project_path,
+            'DYLD_FRAMEWORK_PATH':
+                '%s:__PLATFORMS__/iPhoneSimulator.platform/'
+                'Developer/Library/Frameworks' % dyld_framework_path,
+        }
+    }
+
+    xctestrun_data = {module: module_data}
+    kif_filter = []
+    gtest_filter = []
+
+    if self.included_tests:
+      kif_filter = get_kif_test_filter(self.included_tests, invert=False)
+      gtest_filter = get_gtest_filter(self.included_tests, invert=False)
+    elif self.excluded_tests:
+      kif_filter = get_kif_test_filter(self.excluded_tests, invert=True)
+      gtest_filter = get_gtest_filter(self.excluded_tests, invert=True)
+
+    if kif_filter:
+      self.env_vars['GKIF_SCENARIO_FILTER'] = gtest_filter
+    if gtest_filter:
+      # Removed previous gtest-filter if exists.
+      self.test_args = [el for el in self.test_args
+                        if not el.startswith('--gtest_filter=')]
+      self.test_args.append('--gtest_filter=%s' % gtest_filter)
+
+    if self.env_vars:
+      xctestrun_data[module].update({'EnvironmentVariables': self.env_vars})
+    if self.test_args:
+      xctestrun_data[module].update({'CommandLineArguments': self.test_args})
+
+    if self.excluded_tests:
+      xctestrun_data[module].update({
+          'SkipTestIdentifiers': self.excluded_tests
+      })
+    if self.included_tests:
+      xctestrun_data[module].update({
+          'OnlyTestIdentifiers': self.included_tests
+      })
+    return xctestrun_data
+
+  def command(self, out_dir, destination, shards):
+    """Returns the command that launches tests using xcodebuild.
+
+    Format of command:
+    xcodebuild test-without-building -xctestrun file.xctestrun \
+      -parallel-testing-enabled YES -parallel-testing-worker-count %d% \
+      [-destination "destination"]  -resultBundlePath %output_path%
+
+    Args:
+      out_dir: (str) An output directory.
+      destination: (str) A destination of running simulator.
+      shards: (int) A number of shards.
+
+    Returns:
+      A list of strings forming the command to launch the test.
+    """
+    cmd = [
+        'xcodebuild', 'test-without-building',
+        '-xctestrun', self.fill_xctest_run(out_dir),
+        '-destination', destination,
+        '-resultBundlePath', out_dir
+    ]
+    if shards > 1:
+      cmd += ['-parallel-testing-enabled', 'YES',
+              '-parallel-testing-worker-count', str(shards)]
+    return cmd
+
+
+class EgtestsApp(GTestsApp):
+  """Egtests to run.
+
+  Stores data about egtests:
+    egtests_app: full path to egtests app.
+    project_path: root project folder.
+    module_name: egtests module name.
+    included_tests: List of tests to run.
+    excluded_tests: List of tests not to run.
+  """
+
+  def __init__(self,
+               egtests_app,
+               included_tests=None,
+               excluded_tests=None,
+               test_args=None,
+               env_vars=None,
+               host_app_path=None):
+    """Initialize Egtests.
+
+    Args:
+      egtests_app: (str) full path to egtests app.
+      included_tests: (list) Specific tests to run
+         E.g.
+          [ 'TestCaseClass1/testMethod1', 'TestCaseClass2/testMethod2']
+      excluded_tests: (list) Specific tests not to run
+         E.g.
+          [ 'TestCaseClass1', 'TestCaseClass2/testMethod2']
+      test_args: List of strings to pass as arguments to the test when
+        launching.
+      env_vars: List of environment variables to pass to the test itself.
+      host_app_path: (str) full path to host app.
+
+    Raises:
+      AppNotFoundError: If the given app does not exist
+    """
+    super(EgtestsApp, self).__init__(egtests_app, included_tests,
+                                     excluded_tests, test_args, env_vars,
+                                     host_app_path)
+
+  def _xctest_path(self):
+    """Gets xctest-file from egtests/PlugIns folder.
+
+    Returns:
+      A path for xctest in the format of /PlugIns/file.xctest
+
+    Raises:
+      PlugInsNotFoundError: If no PlugIns folder found in egtests.app.
+      XCTestPlugInNotFoundError: If no xctest-file found in PlugIns.
+    """
+    plugins_dir = os.path.join(self.test_app_path, 'PlugIns')
+    if not os.path.exists(plugins_dir):
+      raise test_runner.PlugInsNotFoundError(plugins_dir)
+    plugin_xctest = None
+    if os.path.exists(plugins_dir):
+      for plugin in os.listdir(plugins_dir):
+        if plugin.endswith('.xctest'):
+          plugin_xctest = os.path.join(plugins_dir, plugin)
+    if not plugin_xctest:
+      raise test_runner.XCTestPlugInNotFoundError(plugin_xctest)
+    return plugin_xctest.replace(self.test_app_path, '')
+
+  def fill_xctestrun_node(self):
+    """Fills only required nodes for egtests in xctestrun file.
+
+    Returns:
+      A node with filled required fields about egtests.
+    """
+    xctestrun_data = super(EgtestsApp, self).fill_xctestrun_node()
+    module_data = xctestrun_data[self.module_name + '_module']
+
+    module_data['TestingEnvironmentVariables']['DYLD_INSERT_LIBRARIES'] = (
+        '__PLATFORMS__/iPhoneSimulator.platform/Developer/'
+        'usr/lib/libXCTestBundleInject.dylib')
+    module_data['TestBundlePath'] = '__TESTHOST__/%s' % self._xctest_path()
+    module_data['TestingEnvironmentVariables'][
+        'XCInjectBundleInto'] = '__TESTHOST__/%s' % self.module_name
+
+    if self.host_app_path:
+      # Module data specific to EG2 tests
+      module_data['IsUITestBundle'] = True
+      module_data['IsXCTRunnerHostedTestBundle'] = True
+      module_data['UITargetAppPath'] = '%s' % self.host_app_path
+      # Special handling for Xcode10.2
+      dependent_products = [
+          module_data['UITargetAppPath'],
+          module_data['TestBundlePath'],
+          module_data['TestHostPath']
+      ]
+      module_data['DependentProductPaths'] = dependent_products
+    # Module data specific to EG1 tests
+    else:
+      module_data['IsAppHostedTestBundle'] = True
+
+    return xctestrun_data
diff --git a/ios/build/bots/scripts/test_apps_test.py b/ios/build/bots/scripts/test_apps_test.py
new file mode 100644
index 0000000..bd1acfa6
--- /dev/null
+++ b/ios/build/bots/scripts/test_apps_test.py
@@ -0,0 +1,56 @@
+# Copyright 2020 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Unittests for test_apps.py."""
+
+import test_apps
+import test_runner_test
+
+
+class GetKIFTestFilterTest(test_runner_test.TestCase):
+  """Tests for test_runner.get_kif_test_filter."""
+
+  def test_correct(self):
+    """Ensures correctness of filter."""
+    tests = [
+      'KIF.test1',
+      'KIF.test2',
+    ]
+    expected = 'NAME:test1|test2'
+
+    self.assertEqual(expected, test_apps.get_kif_test_filter(tests))
+
+  def test_correct_inverted(self):
+    """Ensures correctness of inverted filter."""
+    tests = [
+      'KIF.test1',
+      'KIF.test2',
+    ]
+    expected = '-NAME:test1|test2'
+
+    self.assertEqual(expected,
+                    test_apps.get_kif_test_filter(tests, invert=True))
+
+
+class GetGTestFilterTest(test_runner_test.TestCase):
+  """Tests for test_runner.get_gtest_filter."""
+
+  def test_correct(self):
+    """Ensures correctness of filter."""
+    tests = [
+      'test.1',
+      'test.2',
+    ]
+    expected = 'test.1:test.2'
+
+    self.assertEqual(test_apps.get_gtest_filter(tests), expected)
+
+  def test_correct_inverted(self):
+    """Ensures correctness of inverted filter."""
+    tests = [
+      'test.1',
+      'test.2',
+    ]
+    expected = '-test.1:test.2'
+
+    self.assertEqual(test_apps.get_gtest_filter(tests, invert=True), expected)
diff --git a/ios/build/bots/scripts/test_runner.py b/ios/build/bots/scripts/test_runner.py
index 1eb1d21..cdb53b0 100644
--- a/ios/build/bots/scripts/test_runner.py
+++ b/ios/build/bots/scripts/test_runner.py
@@ -10,21 +10,18 @@
 
 import collections
 import distutils.version
-import json
 import logging
-from multiprocessing import pool
 import os
-import plistlib
 import psutil
 import re
 import shutil
 import subprocess
-import tempfile
 import threading
 import time
 
 import gtest_utils
 import iossim_util
+import test_apps
 import xctest_utils
 
 LOGGER = logging.getLogger(__name__)
@@ -244,7 +241,7 @@
       child process that may block its parent and for such cases
       proc_name refers to the name of child process.
       If proc_name is not specified, process name will be used to kill process.
-    Parser: A parser.
+    parser: A parser.
     timeout: A timeout(in seconds) to subprocess.stdout.readline method.
   """
   out = []
@@ -275,46 +272,6 @@
   return out
 
 
-def get_kif_test_filter(tests, invert=False):
-  """Returns the KIF test filter to filter the given test cases.
-
-  Args:
-    tests: List of test cases to filter.
-    invert: Whether to invert the filter or not. Inverted, the filter will match
-      everything except the given test cases.
-
-  Returns:
-    A string which can be supplied to GKIF_SCENARIO_FILTER.
-  """
-  # A pipe-separated list of test cases with the "KIF." prefix omitted.
-  # e.g. NAME:a|b|c matches KIF.a, KIF.b, KIF.c.
-  # e.g. -NAME:a|b|c matches everything except KIF.a, KIF.b, KIF.c.
-  test_filter = '|'.join(test.split('KIF.', 1)[-1] for test in tests)
-  if invert:
-    return '-NAME:%s' % test_filter
-  return 'NAME:%s' % test_filter
-
-
-def get_gtest_filter(tests, invert=False):
-  """Returns the GTest filter to filter the given test cases.
-
-  Args:
-    tests: List of test cases to filter.
-    invert: Whether to invert the filter or not. Inverted, the filter will match
-      everything except the given test cases.
-
-  Returns:
-    A string which can be supplied to --gtest_filter.
-  """
-  # A colon-separated list of tests cases.
-  # e.g. a:b:c matches a, b, c.
-  # e.g. -a:b:c matches everything except a, b, c.
-  test_filter = ':'.join(test for test in tests)
-  if invert:
-    return '-%s' % test_filter
-  return test_filter
-
-
 def xcode_select(xcode_app_path):
   """Switch the default Xcode system-wide to `xcode_app_path`.
 
@@ -425,41 +382,6 @@
   return test_pattern.findall(subprocess.check_output(cmd))
 
 
-def shard_xctest(object_path, shards, test_cases=None):
-  """Gets EarlGrey test methods inside a test target and splits them into shards
-
-  Args:
-    object_path: Path of the test target bundle.
-    shards: Number of shards to split tests.
-    test_cases: Passed in test cases to run.
-
-  Returns:
-    A list of test shards.
-  """
-  test_names = get_test_names(object_path)
-  # If test_cases are passed in, only shard the intersection of them and the
-  # listed tests.  Format of passed-in test_cases can be either 'testSuite' or
-  # 'testSuite/testMethod'.  The listed tests are tuples of ('testSuite',
-  # 'testMethod').  The intersection includes both test suites and test methods.
-  tests_set = set()
-  if test_cases:
-    for test in test_names:
-      test_method = '%s/%s' % (test[0], test[1])
-      if test[0] in test_cases or test_method in test_cases:
-        tests_set.add(test_method)
-  else:
-    for test in test_names:
-      # 'ChromeTestCase' is the parent class of all EarlGrey test classes. It
-      # has no real tests.
-      if 'ChromeTestCase' != test[0]:
-        tests_set.add('%s/%s' % (test[0], test[1]))
-
-  tests = sorted(tests_set)
-  shard_len = len(tests)/shards  + (len(tests) % shards > 0)
-  test_shards=[tests[i:i + shard_len] for i in range(0, len(tests), shard_len)]
-  return test_shards
-
-
 class TestRunner(object):
   """Base class containing common functionality."""
 
@@ -551,13 +473,14 @@
       if not os.path.exists(self.xctest_path):
         raise XCTestPlugInNotFoundError(self.xctest_path)
 
-  def get_launch_command(self, test_filter=None, invert=False):
+  def get_launch_command(self, test_app, out_dir, destination, shards=1):
     """Returns the command that can be used to launch the test app.
 
     Args:
-      test_filter: List of test cases to filter.
-      invert: Whether to invert the filter or not. Inverted, the filter will
-        match everything except the given test cases.
+      test_app: An app that stores data about test required to run.
+      out_dir: (str) A path for results.
+      destination: (str) A destination of device/simulator.
+      shards: (int) How many shards the tests should be divided into.
 
     Returns:
       A list of strings forming the command to launch the test.
@@ -625,15 +548,14 @@
       shutil.rmtree(DERIVED_DATA)
       os.mkdir(DERIVED_DATA)
 
-  def run_tests(self, test_shard=None):
+  def run_tests(self, cmd=None):
     """Runs passed-in tests.
 
     Args:
-      test_shard: Test cases to be included in the run.
+      cmd: Command to run tests.
 
     Return:
       out: (list) List of strings of subprocess's output.
-      udid: (string) Name of the simulator device in the run.
       returncode: (int) Return code of subprocess.
     """
     raise NotImplementedError
@@ -679,39 +601,19 @@
     else:
       parser = gtest_utils.GTestLogParser()
 
-    if shards > 1:
-      test_shards = shard_xctest(
-        os.path.join(self.app_path, self.app_name),
-        shards,
-        self.test_cases
-      )
-
-      thread_pool = pool.ThreadPool(processes=shards)
-      for out, name, ret in thread_pool.imap_unordered(
-        self.run_tests, test_shards):
-        LOGGER.info('Simulator %s', name)
-        for line in out:
-          LOGGER.info(line)
-          parser.ProcessLine(line)
-        returncode = ret if ret else 0
-      thread_pool.close()
-      thread_pool.join()
-    else:
-      # TODO(crbug.com/812705): Implement test sharding for unit tests.
-      # TODO(crbug.com/812712): Use thread pool for DeviceTestRunner as well.
-      proc = self.start_proc(cmd)
-      old_handler = self.set_sigterm_handler(
-          lambda _signum, _frame: self.handle_sigterm(proc))
-      print_process_output(proc, 'xcodebuild', parser)
-
-      LOGGER.info('Waiting for test process to terminate.')
-      proc.wait()
-      LOGGER.info('Test process terminated.')
-      self.set_sigterm_handler(old_handler)
-      sys.stdout.flush()
-      LOGGER.debug('Stdout flushed after test process.')
-
-      returncode = proc.returncode
+    # TODO(crbug.com/812705): Implement test sharding for unit tests.
+    # TODO(crbug.com/812712): Use thread pool for DeviceTestRunner as well.
+    proc = self.start_proc(cmd)
+    old_handler = self.set_sigterm_handler(
+        lambda _signum, _frame: self.handle_sigterm(proc))
+    print_process_output(proc, 'xcodebuild', parser)
+    LOGGER.info('Waiting for test process to terminate.')
+    proc.wait()
+    LOGGER.info('Test process terminated.')
+    self.set_sigterm_handler(old_handler)
+    sys.stdout.flush()
+    LOGGER.debug('Stdout flushed after test process.')
+    returncode = proc.returncode
 
     if self.xctest and parser.SystemAlertPresent():
       raise SystemAlertPresentError()
@@ -729,7 +631,7 @@
 
     LOGGER.info('%s returned %s\n', cmd[0], returncode)
 
-    # iossim can return 5 if it exits noncleanly even if all tests passed.
+    # xcodebuild can return 5 if it exits noncleanly even if all tests passed.
     # Therefore we cannot rely on process exit code to determine success.
     result.finalize(returncode, parser.CompletedWithoutFailure())
     return result
@@ -737,7 +639,21 @@
   def launch(self):
     """Launches the test app."""
     self.set_up()
-    cmd = self.get_launch_command()
+    destination = 'id=%s' % self.udid
+    if self.xctest:
+      test_app = test_apps.EgtestsApp(
+          self.app_path,
+          included_tests=self.test_cases,
+          env_vars=self.env_vars,
+          test_args=self.test_args)
+    else:
+      test_app = test_apps.GTestsApp(
+          self.app_path,
+          included_tests=self.test_cases,
+          env_vars=self.env_vars,
+          test_args=self.test_args)
+    out_dir = os.path.join(self.out_dir, 'TestResults')
+    cmd = self.get_launch_command(test_app, out_dir, destination, self.shards)
     try:
       result = self._run(cmd=cmd, shards=self.shards or 1)
       if result.crashed and not result.crashed_test:
@@ -745,6 +661,9 @@
         # it crashed on startup. Try one more time.
         self.shutdown_and_restart()
         LOGGER.warning('Crashed on startup, retrying...\n')
+        out_dir = os.path.join(self.out_dir, 'retry_after_crash_on_startup')
+        cmd = self.get_launch_command(test_app, out_dir, destination,
+                                      self.shards)
         result = self._run(cmd)
 
       if result.crashed and not result.crashed_test:
@@ -755,16 +674,19 @@
       flaked = result.flaked_tests
 
       try:
-        # XCTests cannot currently be resumed at the next test case.
-        while not self.xctest and result.crashed and result.crashed_test:
+        while result.crashed and result.crashed_test:
           # If the app crashes during a specific test case, then resume at the
           # next test case. This is achieved by filtering out every test case
           # which has already run.
           LOGGER.warning('Crashed during %s, resuming...\n',
                          result.crashed_test)
-          result = self._run(self.get_launch_command(
-              test_filter=passed + failed.keys() + flaked.keys(), invert=True,
-          ))
+          test_app.excluded_tests = passed + failed.keys() + flaked.keys()
+          retry_out_dir = os.path.join(
+              self.out_dir, 'retry_after_crash_%d' % int(time.time()))
+          result = self._run(
+              self.get_launch_command(
+                  test_app, os.path.join(retry_out_dir, str(int(time.time()))),
+                  destination))
           passed.extend(result.passed_tests)
           failed.update(result.failed_tests)
           flaked.update(result.flaked_tests)
@@ -776,14 +698,17 @@
 
       # Retry failed test cases.
       retry_results = {}
+      test_app.excluded_tests = []
       if self.retries and failed:
         LOGGER.warning('%s tests failed and will be retried.\n', len(failed))
         for i in xrange(self.retries):
           for test in failed.keys():
             LOGGER.info('Retry #%s for %s.\n', i + 1, test)
-            retry_result = self._run(self.get_launch_command(
-                test_filter=[test]
-            ))
+            test_app.included_tests = [test]
+            retry_out_dir = os.path.join(self.out_dir, test + '_failed',
+                                         'retry_%d' % i)
+            retry_result = self._run(
+                self.get_launch_command(test_app, retry_out_dir, destination))
             # If the test passed on retry, consider it flake instead of failure.
             if test in retry_result.passed_tests:
               flaked[test] = failed.pop(test)
@@ -982,7 +907,7 @@
         report_time = report_name[len(self.app_name) + 1:].split('_')[0]
 
         # The timestamp format in a crash report is big-endian and therefore
-        # a staight string comparison works.
+        # a straight string comparison works.
         if report_time > self.start_time:
           with open(os.path.join(crash_reports_dir, crash_report)) as f:
             self.logs['crash report (%s)' % report_time] = (
@@ -1007,32 +932,21 @@
       self.homedir = ''
     LOGGER.debug('End of tear_down.')
 
-  def run_tests(self, test_shard=None):
+  def run_tests(self, cmd):
     """Runs passed-in tests. Builds a command and create a simulator to
       run tests.
     Args:
-      test_shard: Test cases to be included in the run.
+      cmd: A running command.
 
     Return:
       out: (list) List of strings of subprocess's output.
-      udid: (string) Name of the simulator device in the run.
       returncode: (int) Return code of subprocess.
     """
-    cmd = self.sharding_cmd[:]
-    cmd.extend(['-u', self.udid])
-    if test_shard:
-      for test in test_shard:
-        cmd.extend(['-t', test])
-
-    cmd.append(self.app_path)
-    if self.xctest_path:
-      cmd.append(self.xctest_path)
-
     proc = self.start_proc(cmd)
     out = print_process_output(proc, 'xcodebuild',
                                xctest_utils.XCTestLogParser())
     self.deleteSimulator(self.udid)
-    return (out, self.udid, proc.returncode)
+    return (out, proc.returncode)
 
   def getSimulator(self):
     """Gets a simulator or creates a new one by device types and runtimes.
@@ -1048,59 +962,19 @@
     if udid:
       iossim_util.delete_simulator_by_udid(udid)
 
-  def get_launch_command(self, test_filter=None, invert=False, test_shard=None):
+  def get_launch_command(self, test_app, out_dir, destination, shards=1):
     """Returns the command that can be used to launch the test app.
 
     Args:
-      test_filter: List of test cases to filter.
-      invert: Whether to invert the filter or not. Inverted, the filter will
-        match everything except the given test cases.
-      test_shard: How many shards the tests should be divided into.
+      test_app: An app that stores data about test required to run.
+      out_dir: (str) A path for results.
+      destination: (str) A destination of device/simulator.
+      shards: (int) How many shards the tests should be divided into.
 
     Returns:
       A list of strings forming the command to launch the test.
     """
-    cmd = [
-        self.iossim_path,
-        '-d', self.platform,
-        '-s', self.version,
-    ]
-
-    for env_var in self.env_vars:
-      cmd.extend(['-e', env_var])
-
-    for test_arg in self.test_args:
-      cmd.extend(['-c', test_arg])
-
-      # If --run-with-custom-webkit is passed as a test arg, set
-      # DYLD_FRAMEWORK_PATH to point to the embedded custom webkit frameworks.
-      if test_arg == '--run-with-custom-webkit':
-        cmd.extend(['-e', 'DYLD_FRAMEWORK_PATH=' +
-                    os.path.join(self.app_path, 'WebKitFrameworks')])
-
-    if self.xctest_path:
-      self.sharding_cmd = cmd[:]
-      if test_filter:
-        # iossim doesn't support inverted filters for XCTests.
-        if not invert:
-          for test in test_filter:
-            cmd.extend(['-t', test])
-      elif test_shard:
-        for test in test_shard:
-          cmd.extend(['-t', test])
-      elif not invert:
-        for test_case in self.test_cases:
-          cmd.extend(['-t', test_case])
-    elif test_filter:
-      kif_filter = get_kif_test_filter(test_filter, invert=invert)
-      gtest_filter = get_gtest_filter(test_filter, invert=invert)
-      cmd.extend(['-e', 'GKIF_SCENARIO_FILTER=%s' % kif_filter])
-      cmd.extend(['-c', '--gtest_filter=%s' % gtest_filter])
-
-    cmd.append(self.app_path)
-    if self.xctest_path:
-      cmd.append(self.xctest_path)
-    return cmd
+    return test_app.command(out_dir, destination, shards)
 
   def get_launch_env(self):
     """Returns a dict of environment variables to use to launch the test app.
@@ -1179,27 +1053,6 @@
     if xctest or is_iOS13:
       if is_iOS13:
         self.xctest_path = get_xctest_from_app(self.app_path)
-      self.xctestrun_file = tempfile.mkstemp()[1]
-      self.xctestrun_data = {
-          'TestTargetName': {
-              'IsAppHostedTestBundle': True,
-              'TestBundlePath': '%s' % self.xctest_path,
-              'TestHostPath': '%s' % self.app_path,
-              'TestingEnvironmentVariables': {
-                  'DYLD_INSERT_LIBRARIES':
-                      '__PLATFORMS__/iPhoneOS.platform/Developer/usr/lib/'
-                      'libXCTestBundleInject.dylib',
-                  'DYLD_LIBRARY_PATH':
-                      '__PLATFORMS__/iPhoneOS.platform/Developer/Library',
-                  'DYLD_FRAMEWORK_PATH':
-                      '__PLATFORMS__/iPhoneOS.platform/Developer/'
-                      'Library/Frameworks',
-                  'XCInjectBundleInto':
-                      '__TESTHOST__/%s' % self.app_name
-              }
-          }
-      }
-
     self.restart = restart
 
   def uninstall_apps(self):
@@ -1280,71 +1133,40 @@
     self.retrieve_crash_reports()
     self.uninstall_apps()
 
-  def set_xctest_filters(self, test_filter=None, invert=False):
-    """Sets the tests be included in the test run."""
-    if self.test_cases:
-      filter = self.test_cases
-      if test_filter:
-        # If inverted, the filter should match tests in test_cases except the
-        # ones in test_filter. Otherwise, the filter should be tests both in
-        # test_cases and test_filter. test_filter is used for test retries, it
-        # should be a subset of test_cases. If the intersection of test_cases
-        # and test_filter fails, use test_filter.
-        filter = (sorted(set(filter) - set(test_filter)) if invert
-                  else sorted(set(filter) & set(test_filter)) or test_filter)
-      self.xctestrun_data['TestTargetName'].update(
-        {'OnlyTestIdentifiers': filter})
-    elif test_filter:
-      if invert:
-        self.xctestrun_data['TestTargetName'].update(
-          {'SkipTestIdentifiers': test_filter})
-      else:
-        self.xctestrun_data['TestTargetName'].update(
-          {'OnlyTestIdentifiers': test_filter})
-
   def get_command_line_args_xctest_unittests(self, filtered_tests):
     command_line_args = ['--enable-run-ios-unittests-with-xctest']
     if filtered_tests:
       command_line_args.append('--gtest_filter=%s' % filtered_tests)
     return command_line_args
 
-  def get_launch_command(self, test_filter=None, invert=False):
+  def get_launch_command(self, test_app, out_dir, destination, shards=1):
     """Returns the command that can be used to launch the test app.
 
     Args:
-      test_filter: List of test cases to filter.
-      invert: Whether to invert the filter or not. Inverted, the filter will
-        match everything except the given test cases.
+      test_app: An app that stores data about test required to run.
+      out_dir: (str) A path for results.
+      destination: (str) A destination of device/simulator.
+      shards: (int) How many shards the tests should be divided into.
 
     Returns:
       A list of strings forming the command to launch the test.
     """
     if self.xctest_path:
-      if self.env_vars:
-        self.xctestrun_data['TestTargetName'].update(
-          {'EnvironmentVariables': self.env_vars})
+      command_line_args = test_app.test_args
 
-      command_line_args = self.test_args
-
-      if self.xctest:
-        self.set_xctest_filters(test_filter, invert)
-      else:
+      if not self.xctest:
         filtered_tests = []
-        if test_filter:
-          filtered_tests = get_gtest_filter(test_filter, invert=invert)
+        if test_app.included_tests:
+          filtered_tests = test_apps.get_gtest_filter(
+              test_app.included_tests, invert=False)
+        elif test_app.excluded_tests:
+          filtered_tests = test_apps.get_gtest_filter(
+            test_app.excluded_tests, invert=True)
         command_line_args.append(
             self.get_command_line_args_xctest_unittests(filtered_tests))
-
       if command_line_args:
-        self.xctestrun_data['TestTargetName'].update(
-            {'CommandLineArguments': command_line_args})
-      plistlib.writePlist(self.xctestrun_data, self.xctestrun_file)
-      return [
-        'xcodebuild',
-        'test-without-building',
-        '-xctestrun', self.xctestrun_file,
-        '-destination', 'id=%s' % self.udid,
-      ]
+        test_app.test_args = command_line_args
+      return test_app.command(out_dir, destination, shards)
 
     cmd = [
       'idevice-app-runner',
@@ -1352,11 +1174,23 @@
       '--start', self.cfbundleid,
     ]
     args = []
+    gtest_filter = []
+    kif_filter = []
 
-    if test_filter:
-      kif_filter = get_kif_test_filter(test_filter, invert=invert)
-      gtest_filter = get_gtest_filter(test_filter, invert=invert)
+    if test_app.included_tests:
+      kif_filter = test_apps.get_kif_test_filter(test_app.included_tests,
+                                                 invert=False)
+      gtest_filter = test_apps.get_gtest_filter(test_app.included_tests,
+                                                invert=False)
+    elif test_app.excluded_tests:
+      kif_filter = test_apps.get_kif_test_filter(test_app.excluded_tests,
+                                                 invert=True)
+      gtest_filter = test_apps.get_gtest_filter(test_app.excluded_tests,
+                                                invert=True)
+
+    if kif_filter:
       cmd.extend(['-D', 'GKIF_SCENARIO_FILTER=%s' % kif_filter])
+    if gtest_filter:
       args.append('--gtest_filter=%s' % gtest_filter)
 
     for env_var in self.env_vars:
diff --git a/ios/build/bots/scripts/test_runner_test.py b/ios/build/bots/scripts/test_runner_test.py
index 12662219..fef4902d 100755
--- a/ios/build/bots/scripts/test_runner_test.py
+++ b/ios/build/bots/scripts/test_runner_test.py
@@ -6,16 +6,84 @@
 """Unittests for test_runner.py."""
 
 import collections
-import glob
 import logging
 import mock
 import os
-import subprocess
 import tempfile
 import unittest
 
+import iossim_util
 import test_runner
 
+SIMULATORS_LIST = {
+    'devices': {
+        'com.apple.CoreSimulator.SimRuntime.iOS-11-4': [{
+            'isAvailable': True,
+            'name': 'iPhone 5s',
+            'state': 'Shutdown',
+            'udid': 'E4E66320-177A-450A-9BA1-488D85B7278E'
+        }],
+        'com.apple.CoreSimulator.SimRuntime.iOS-13-2': [
+            {
+                'isAvailable': True,
+                'name': 'iPhone X',
+                'state': 'Shutdown',
+                'udid': 'E4E66321-177A-450A-9BA1-488D85B7278E'
+            },
+            {
+                'isAvailable': True,
+                'name': 'iPhone 11',
+                'state': 'Shutdown',
+                'udid': 'A4E66321-177A-450A-9BA1-488D85B7278E'
+            }
+        ]
+    },
+    'devicetypes': [
+        {
+            'name': 'iPhone 5s',
+            'bundlePath': '/path/iPhone 4s/Content',
+            'identifier': 'com.apple.CoreSimulator.SimDeviceType.iPhone-5s'
+        },
+        {
+            'name': 'iPhone X',
+            'bundlePath': '/path/iPhone X/Content',
+            'identifier': 'com.apple.CoreSimulator.SimDeviceType.iPhone-X'
+        },
+        {
+            'name': 'iPhone 11',
+            'bundlePath': '/path/iPhone 11/Content',
+            'identifier': 'com.apple.CoreSimulator.SimDeviceType.iPhone-11'
+        },
+    ],
+    'pairs': [],
+    'runtimes': [
+        {
+            "buildversion": "15F79",
+            "bundlePath": "/path/Runtimes/iOS 11.4.simruntime",
+            "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-11-4",
+            "isAvailable": True,
+            "name": "iOS 11.4",
+            "version": "11.4"
+        },
+        {
+            "buildversion": "17A844",
+            "bundlePath": "/path/Runtimes/iOS 13.1.simruntime",
+            "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-13-1",
+            "isAvailable": True,
+            "name": "iOS 13.1",
+            "version": "13.1"
+        },
+        {
+            "buildversion": "17B102",
+            "bundlePath": "/path/Runtimes/iOS.simruntime",
+            "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-13-2",
+            "isAvailable": True,
+            "name": "iOS 13.2",
+            "version": "13.2.2"
+        },
+    ]
+}
+
 
 class TestCase(unittest.TestCase):
   """Test case which supports installing mocks. Uninstalls on tear down."""
@@ -49,56 +117,6 @@
         setattr(obj, member, original_value)
 
 
-class GetKIFTestFilterTest(TestCase):
-  """Tests for test_runner.get_kif_test_filter."""
-
-  def test_correct(self):
-    """Ensures correctness of filter."""
-    tests = [
-      'KIF.test1',
-      'KIF.test2',
-    ]
-    expected = 'NAME:test1|test2'
-
-    self.assertEqual(test_runner.get_kif_test_filter(tests), expected)
-
-  def test_correct_inverted(self):
-    """Ensures correctness of inverted filter."""
-    tests = [
-      'KIF.test1',
-      'KIF.test2',
-    ]
-    expected = '-NAME:test1|test2'
-
-    self.assertEqual(
-        test_runner.get_kif_test_filter(tests, invert=True), expected)
-
-
-class GetGTestFilterTest(TestCase):
-  """Tests for test_runner.get_gtest_filter."""
-
-  def test_correct(self):
-    """Ensures correctness of filter."""
-    tests = [
-      'test.1',
-      'test.2',
-    ]
-    expected = 'test.1:test.2'
-
-    self.assertEqual(test_runner.get_gtest_filter(tests), expected)
-
-  def test_correct_inverted(self):
-    """Ensures correctness of inverted filter."""
-    tests = [
-      'test.1',
-      'test.2',
-    ]
-    expected = '-test.1:test.2'
-
-    self.assertEqual(
-        test_runner.get_gtest_filter(tests, invert=True), expected)
-
-
 class InstallXcodeTest(TestCase):
   """Tests install_xcode."""
 
@@ -119,6 +137,7 @@
 
   def setUp(self):
     super(SimulatorTestRunnerTest, self).setUp()
+    self.mock(iossim_util, 'get_simulator_list', lambda: SIMULATORS_LIST)
 
     def install_xcode(build, mac_toolchain_cmd, xcode_app_path):
       return True
@@ -158,8 +177,8 @@
       test_runner.SimulatorTestRunner(
         'fake-app',
         'fake-iossim',
-        'platform',
-        'os',
+        'iPhone X',
+        '11.4',
         'xcode-version',
         'xcode-build',
         'out-dir',
@@ -170,8 +189,8 @@
     tr = test_runner.SimulatorTestRunner(
         'fake-app',
         'fake-iossim',
-        'platform',
-        'os',
+        'iPhone X',
+        '11.4',
         'xcode-version',
         'xcode-build',
         'out-dir',
@@ -199,8 +218,8 @@
     tr = test_runner.SimulatorTestRunner(
         'fake-app',
         'fake-iossim',
-        'platform',
-        'os',
+        'iPhone X',
+        '11.4',
         'xcode-version',
         'xcode-build',
         'out-dir',
@@ -211,8 +230,6 @@
 
   def test_run(self):
     """Ensures the _run method is correct with test sharding."""
-    def shard_xctest(object_path, shards, test_cases=None):
-      return [['a/1', 'b/2'], ['c/3', 'd/4'], ['e/5']]
 
     def run_tests(self, test_shard=None):
       out = []
@@ -227,14 +244,13 @@
     tr = test_runner.SimulatorTestRunner(
       'fake-app',
       'fake-iossim',
-      'platform',
-      'os',
+      'iPhone X',
+      '11.4',
       'xcode-version',
       'xcode-build',
       'out-dir',
       xctest=True,
     )
-    self.mock(test_runner, 'shard_xctest', shard_xctest)
     self.mock(test_runner.SimulatorTestRunner, 'run_tests', run_tests)
 
     tr.xctest_path = 'fake.xctest'
@@ -253,8 +269,8 @@
       tr = test_runner.SimulatorTestRunner(
         'fake-app',
         'fake-iossim',
-        'platform',
-        'os',
+        'iPhone X',
+        '11.4',
         'xcode-version',
         'xcode-build',
         'out-dir',
@@ -270,8 +286,8 @@
     tr = test_runner.SimulatorTestRunner(
       'fake-app',
       'fake-iossim',
-      'platform',
-      'os',
+      'iPhone X',
+      '11.4',
       'xcode-version',
       'xcode-build',
       'out-dir',
@@ -279,12 +295,12 @@
     tr.xctest_path = 'fake.xctest'
     # Cases test_filter is not empty, with empty/non-empty self.test_cases.
     tr.test_cases = []
-    cmd = tr.get_launch_command(['a'], invert=False)
+    cmd = tr.get_launch_command(['a'])
     self.assertIn('-t', cmd)
     self.assertIn('a', cmd)
 
     tr.test_cases = ['a', 'b']
-    cmd = tr.get_launch_command(['a'], invert=False)
+    cmd = tr.get_launch_command(['a'])
     self.assertIn('-t', cmd)
     self.assertIn('a', cmd)
     self.assertNotIn('b', cmd)
@@ -344,8 +360,8 @@
     tr = test_runner.SimulatorTestRunner(
         'fake-app',
         'fake-iossim',
-        'platform',
-        'os',
+        'iPhone X',
+        '11.4',
         'xcode-version',
         'xcode-build',
         'out-dir',
@@ -379,70 +395,6 @@
     )
     self.tr.xctestrun_data = {'TestTargetName':{}}
 
-  def test_with_test_filter_without_test_cases(self):
-    """Ensures tests in the run with test_filter and no test_cases."""
-    self.tr.set_xctest_filters(['a', 'b'], invert=False)
-    self.assertEqual(
-        self.tr.xctestrun_data['TestTargetName']['OnlyTestIdentifiers'],
-        ['a', 'b']
-    )
-
-  def test_invert_with_test_filter_without_test_cases(self):
-    """Ensures tests in the run invert with test_filter and no test_cases."""
-    self.tr.set_xctest_filters(['a', 'b'], invert=True)
-    self.assertEqual(
-        self.tr.xctestrun_data['TestTargetName']['SkipTestIdentifiers'],
-        ['a', 'b']
-    )
-
-  def test_with_test_filter_with_test_cases(self):
-    """Ensures tests in the run with test_filter and test_cases."""
-    self.tr.test_cases = ['a', 'b', 'c', 'd']
-    self.tr.set_xctest_filters(['a', 'b', 'irrelevant test'], invert=False)
-    self.assertEqual(
-        self.tr.xctestrun_data['TestTargetName']['OnlyTestIdentifiers'],
-        ['a', 'b']
-    )
-
-  def test_invert_with_test_filter_with_test_cases(self):
-    """Ensures tests in the run invert with test_filter and test_cases."""
-    self.tr.test_cases = ['a', 'b', 'c', 'd']
-    self.tr.set_xctest_filters(['a', 'b', 'irrelevant test'], invert=True)
-    self.assertEqual(
-        self.tr.xctestrun_data['TestTargetName']['OnlyTestIdentifiers'],
-        ['c', 'd']
-    )
-
-  def test_without_test_filter_without_test_cases(self):
-    """Ensures tests in the run with no test_filter and no test_cases."""
-    self.tr.set_xctest_filters(test_filter=None, invert=False)
-    self.assertIsNone(
-        self.tr.xctestrun_data['TestTargetName'].get('OnlyTestIdentifiers'))
-
-  def test_invert_without_test_filter_without_test_cases(self):
-    """Ensures tests in the run invert with no test_filter and no test_cases."""
-    self.tr.set_xctest_filters(test_filter=None, invert=True)
-    self.assertIsNone(
-        self.tr.xctestrun_data['TestTargetName'].get('OnlyTestIdentifiers'))
-
-  def test_without_test_filter_with_test_cases(self):
-    """Ensures tests in the run with no test_filter but test_cases."""
-    self.tr.test_cases = ['a', 'b', 'c', 'd']
-    self.tr.set_xctest_filters(test_filter=None, invert=False)
-    self.assertEqual(
-        self.tr.xctestrun_data['TestTargetName']['OnlyTestIdentifiers'],
-        ['a', 'b', 'c', 'd']
-    )
-
-  def test_invert_without_test_filter_with_test_cases(self):
-    """Ensures tests in the run invert with no test_filter but test_cases."""
-    self.tr.test_cases = ['a', 'b', 'c', 'd']
-    self.tr.set_xctest_filters(test_filter=None, invert=True)
-    self.assertEqual(
-        self.tr.xctestrun_data['TestTargetName']['OnlyTestIdentifiers'],
-        ['a', 'b', 'c', 'd']
-    )
-
   @mock.patch('subprocess.check_output', autospec=True)
   def test_get_test_names(self, mock_subprocess):
     otool_output = (
diff --git a/ios/build/bots/scripts/wpr_runner.py b/ios/build/bots/scripts/wpr_runner.py
index 8b14e76e..a529822 100644
--- a/ios/build/bots/scripts/wpr_runner.py
+++ b/ios/build/bots/scripts/wpr_runner.py
@@ -12,8 +12,8 @@
 import sys
 
 import gtest_utils
+import test_apps
 import test_runner
-import xcodebuild_runner
 import xctest_utils
 
 LOGGER = logging.getLogger(__name__)
@@ -158,7 +158,7 @@
         '--enable-features=AutofillShowTypePredictions',
         '-autofillautomation=%s' % recipe_path,
     ]
-    wpr_egtests_app = xcodebuild_runner.EgtestsApp(
+    wpr_egtests_app = test_apps.EgtestsApp(
         self.app_path,
         included_tests=["AutofillAutomationTestCase"],
         env_vars=self.env_vars,
@@ -174,11 +174,7 @@
         self.version, self.platform, test_name,
         self.test_attempt_count[test_name])
     out_dir = os.path.join(self.out_dir, destination_folder)
-
-    launch_command = xcodebuild_runner.LaunchCommand(
-        wpr_egtests_app, destination, self.shards, self.retries)
-    return launch_command.command(wpr_egtests_app, out_dir, destination,
-                                  self.shards)
+    return wpr_egtests_app.command(out_dir, destination, self.shards)
 
   def get_launch_env(self):
     """Returns a dict of environment variables to use to launch the test app.
@@ -381,30 +377,37 @@
 
     self.deleteSimulator(udid)
 
-    # iossim can return 5 if it exits noncleanly even if all tests passed.
+    # xcodebuild can return 5 if it exits noncleanly even if all tests passed.
     # Therefore we cannot rely on process exit code to determine success.
 
     # NOTE: total_returncode is 0 OR the last non-zero return code from a test.
     result.finalize(total_returncode, completed_without_failure)
     return result
 
-  def get_launch_command(self, test_filter=[], invert=False):
+  def get_launch_command(self, test_app=None, out_dir=None,
+                         destination=None, shards=1):
     """Returns a config dict for the test, instead of the real launch command.
     Normally this is passed into _run as the command it should use, but since
     the WPR runner builds its own cmd, we use this to configure the function.
 
     Args:
-      test_filter: List of test cases to filter.
-      invert: Whether to invert the filter or not. Inverted, the filter will
-      match everything except the given test cases.
+      test_app: A test app needed to run.
+      out_dir: (str) A path for results.
+      destination: (str) A destination of device/simulator.
+      shards: (int) How many shards the tests should be divided into.
 
     Returns:
       A dict forming the configuration for the test.
     """
 
     test_config = {}
-    test_config['invert'] = invert
-    test_config['test_filter'] = test_filter
+    if test_app:
+      if test_app.included_tests:
+        test_config['invert'] = False
+        test_config['test_filter'] = test_app.included_tests
+      elif test_app.excluded_tests:
+        test_config['invert'] = True
+        test_config['test_filter'] = test_app.excluded_tests
     return test_config
 
   def proxy_start(self):
diff --git a/ios/build/bots/scripts/wpr_runner_test.py b/ios/build/bots/scripts/wpr_runner_test.py
index e410d60..2284b1a 100644
--- a/ios/build/bots/scripts/wpr_runner_test.py
+++ b/ios/build/bots/scripts/wpr_runner_test.py
@@ -9,6 +9,7 @@
 import subprocess
 import unittest
 
+import iossim_util
 import test_runner
 import test_runner_test
 import wpr_runner
@@ -38,6 +39,8 @@
               lambda a, b: True)
     self.mock(wpr_runner.WprProxySimulatorTestRunner,
               'copy_trusted_certificate', lambda a: True)
+    self.mock(iossim_util, 'get_simulator',
+              lambda a, b: 'E4E66320-177A-450A-9BA1-488D85B7278E')
 
   def test_app_not_found(self):
     """Ensures AppNotFoundError is raised."""
@@ -178,7 +181,7 @@
     self.mock(subprocess, 'Popen', popen)
 
     tr.xctest_path = 'fake.xctest'
-    cmd = tr.get_launch_command(test_filter=test_filter, invert=invert)
+    cmd = {'invert': invert, 'test_filter': test_filter}
     return tr._run(cmd=cmd, shards=1)
 
   def test_run_no_filter(self):
diff --git a/ios/build/bots/scripts/xcodebuild_runner.py b/ios/build/bots/scripts/xcodebuild_runner.py
index f1df448..991a9fa2e 100644
--- a/ios/build/bots/scripts/xcodebuild_runner.py
+++ b/ios/build/bots/scripts/xcodebuild_runner.py
@@ -7,13 +7,13 @@
 import collections
 import distutils.version
 import logging
-import multiprocessing
+from multiprocessing import pool
 import os
-import plistlib
 import subprocess
 import time
 
 import iossim_util
+import test_apps
 import test_runner
 import xcode_log_parser
 
@@ -102,135 +102,6 @@
     LOGGER.info('Error while killing a process: %s' % ex)
 
 
-class EgtestsApp(object):
-  """Egtests to run.
-
-  Stores data about egtests:
-    egtests_app: full path to egtests app.
-    project_path: root project folder.
-    module_name: egtests module name.
-    included_tests: List of tests to run.
-    excluded_tests: List of tests not to run.
-  """
-
-  def __init__(self, egtests_app, included_tests=None, excluded_tests=None,
-               test_args=None, env_vars=None, host_app_path=None):
-    """Initialize Egtests.
-
-    Args:
-      egtests_app: (str) full path to egtests app.
-      included_tests: (list) Specific tests to run
-         E.g.
-          [ 'TestCaseClass1/testMethod1', 'TestCaseClass2/testMethod2']
-      excluded_tests: (list) Specific tests not to run
-         E.g.
-          [ 'TestCaseClass1', 'TestCaseClass2/testMethod2']
-      test_args: List of strings to pass as arguments to the test when
-        launching.
-      env_vars: List of environment variables to pass to the test itself.
-      host_app_path: (str) full path to host app.
-
-    Raises:
-      AppNotFoundError: If the given app does not exist
-    """
-    if not os.path.exists(egtests_app):
-      raise test_runner.AppNotFoundError(egtests_app)
-    self.egtests_path = egtests_app
-    self.project_path = os.path.dirname(self.egtests_path)
-    self.module_name = os.path.splitext(os.path.basename(egtests_app))[0]
-    self.included_tests = included_tests or []
-    self.excluded_tests = excluded_tests or []
-    self.test_args = test_args
-    self.env_vars = env_vars
-    self.host_app_path = host_app_path
-
-  def _xctest_path(self):
-    """Gets xctest-file from egtests/PlugIns folder.
-
-    Returns:
-      A path for xctest in the format of /PlugIns/file.xctest
-
-    Raises:
-      PlugInsNotFoundError: If no PlugIns folder found in egtests.app.
-      XCTestPlugInNotFoundError: If no xctest-file found in PlugIns.
-    """
-    plugins_dir = os.path.join(self.egtests_path, 'PlugIns')
-    if not os.path.exists(plugins_dir):
-      raise test_runner.PlugInsNotFoundError(plugins_dir)
-    plugin_xctest = None
-    if os.path.exists(plugins_dir):
-      for plugin in os.listdir(plugins_dir):
-        if plugin.endswith('.xctest'):
-          plugin_xctest = os.path.join(plugins_dir, plugin)
-    if not plugin_xctest:
-      raise test_runner.XCTestPlugInNotFoundError(plugin_xctest)
-    return plugin_xctest.replace(self.egtests_path, '')
-
-  def xctestrun_node(self):
-    """Fills only required nodes for egtests in xctestrun file.
-
-    Returns:
-      A node with filled required fields about egtests.
-    """
-    module = self.module_name + '_module'
-
-    # If --run-with-custom-webkit is passed as a test arg, set up
-    # DYLD_FRAMEWORK_PATH to load the custom webkit modules.
-    dyld_framework_path = self.project_path + ':'
-    if '--run-with-custom-webkit' in self.test_args:
-      if self.host_app_path:
-        webkit_path = os.path.join(self.host_app_path, 'WebKitFrameworks')
-      else:
-        webkit_path = os.path.join(self.egtests_path, 'WebKitFrameworks')
-      dyld_framework_path = dyld_framework_path + webkit_path + ':'
-
-    module_data = {
-        'TestBundlePath': '__TESTHOST__%s' % self._xctest_path(),
-        'TestHostPath': '%s' % self.egtests_path,
-        'TestingEnvironmentVariables': {
-            'DYLD_INSERT_LIBRARIES': (
-                '__PLATFORMS__/iPhoneSimulator.platform/Developer/'
-                'usr/lib/libXCTestBundleInject.dylib'),
-            'DYLD_LIBRARY_PATH': self.project_path,
-            'DYLD_FRAMEWORK_PATH': dyld_framework_path,
-            'XCInjectBundleInto': '__TESTHOST__/%s' % self.module_name
-            }
-        }
-    # Add module data specific to EG2 or EG1 tests
-    # EG2 tests
-    if self.host_app_path:
-      module_data['IsUITestBundle'] = True
-      module_data['IsXCTRunnerHostedTestBundle'] = True
-      module_data['UITargetAppPath'] = '%s' % self.host_app_path
-      # Special handling for Xcode10.2
-      dependent_products = [
-          module_data['UITargetAppPath'],
-          module_data['TestBundlePath'],
-          module_data['TestHostPath']
-      ]
-      module_data['DependentProductPaths'] = dependent_products
-    # EG1 tests
-    else:
-      module_data['IsAppHostedTestBundle'] = True
-
-    xctestrun_data = {
-        module: module_data
-    }
-    if self.excluded_tests:
-      xctestrun_data[module].update(
-          {'SkipTestIdentifiers': self.excluded_tests})
-    if self.included_tests:
-      xctestrun_data[module].update(
-          {'OnlyTestIdentifiers': self.included_tests})
-    if self.env_vars:
-      xctestrun_data[module].update(
-          {'EnvironmentVariables': self.env_vars})
-    if self.test_args:
-      xctestrun_data[module].update(
-          {'CommandLineArguments': self.test_args})
-    return xctestrun_data
-
-
 class LaunchCommand(object):
   """Stores xcodebuild test launching command."""
 
@@ -255,7 +126,7 @@
     Raises:
       LaunchCommandCreationError: if one of parameters was not set properly.
     """
-    if not isinstance(egtests_app, EgtestsApp):
+    if not isinstance(egtests_app, test_apps.EgtestsApp):
       raise test_runner.AppNotFoundError(
           'Parameter `egtests_app` is not EgtestsApp: %s' % egtests_app)
     self.egtests_app = egtests_app
@@ -313,12 +184,12 @@
 
   def launch(self):
     """Launches tests using xcodebuild."""
-    cmd_list = []
     self.test_results['attempts'] = []
     cancelled_statuses = {'TESTS_DID_NOT_START', 'BUILD_INTERRUPTED'}
     shards = self.shards
-    running_tests = set(get_all_tests(self.egtests_app.egtests_path,
-                                      self.egtests_app.included_tests))
+    running_tests = set(
+        get_all_tests(self.egtests_app.test_app_path,
+                      self.egtests_app.included_tests))
     # total number of attempts is self.retries+1
     for attempt in range(self.retries + 1):
       # Erase all simulators per each attempt
@@ -330,8 +201,8 @@
         erase_all_simulators()
         erase_all_simulators(XTDEVICE_FOLDER)
       outdir_attempt = os.path.join(self.out_dir, 'attempt_%d' % attempt)
-      cmd_list = self.command(self.egtests_app, outdir_attempt,
-                              'id=%s' % self.udid, shards)
+      cmd_list = self.egtests_app.command(outdir_attempt,
+                                          'id=%s' % self.udid, shards)
       # TODO(crbug.com/914878): add heartbeat logging to xcodebuild_runner.
       LOGGER.info('Start test attempt #%d for command [%s]' % (
           attempt, ' '.join(cmd_list)))
@@ -372,59 +243,6 @@
         'logs': self.logs
     }
 
-  def fill_xctest_run(self, egtests_app):
-    """Fills xctestrun file by egtests.
-
-    Args:
-      egtests_app: (EgtestsApp) An Egetsts_app to run.
-
-    Returns:
-      A path to xctestrun file.
-
-    Raises:
-      AppNotFoundError if egtests is empty.
-    """
-    if not egtests_app:
-      raise test_runner.AppNotFoundError('Egtests is not found!')
-    xctestrun = os.path.join(
-        os.path.abspath(os.path.join(self.out_dir, os.pardir)),
-        'run_%d.xctestrun' % int(time.time()))
-    if not os.path.exists(xctestrun):
-      with open(xctestrun, 'w'):
-        pass
-    # Creates a dict with data about egtests to run - fill all required fields:
-    # egtests_module, egtest_app_path, egtests_xctest_path and
-    # filtered tests if filter is specified.
-    # Write data in temp xctest run file.
-    plistlib.writePlist(egtests_app.xctestrun_node(), xctestrun)
-    return xctestrun
-
-  def command(self, egtests_app, out_dir, destination, shards):
-    """Returns the command that launches tests using xcodebuild.
-
-    Format of command:
-    xcodebuild test-without-building -xctestrun file.xctestrun \
-      -parallel-testing-enabled YES -parallel-testing-worker-count %d% \
-      [-destination "destination"]  -resultBundlePath %output_path%
-
-    Args:
-      egtests_app: (EgtestsApp) An egetsts_app to run.
-      out_dir: (str) An output directory.
-      destination: (str) A destination of running simulator.
-      shards: (int) A number of shards.
-
-    Returns:
-      A list of strings forming the command to launch the test.
-    """
-    cmd = ['xcodebuild', 'test-without-building',
-           '-xctestrun', self.fill_xctest_run(egtests_app),
-           '-destination', destination,
-           '-resultBundlePath', out_dir]
-    if shards > 1:
-      cmd += ['-parallel-testing-enabled', 'YES',
-              '-parallel-testing-worker-count', str(shards)]
-    return cmd
-
 
 class SimulatorParallelTestRunner(test_runner.SimulatorTestRunner):
   """Class for running simulator tests using xCode."""
@@ -531,23 +349,25 @@
     """Launches tests using xcodebuild."""
     launch_commands = []
     for params in self.sharding_data:
+      test_app = test_apps.EgtestsApp(
+          params['app'],
+          included_tests=params['test_cases'],
+          env_vars=self.env_vars,
+          test_args=self.test_args,
+          host_app_path=params['host'])
       launch_commands.append(
           LaunchCommand(
-              EgtestsApp(
-                  params['app'],
-                  included_tests=params['test_cases'],
-                  env_vars=self.env_vars,
-                  test_args=self.test_args,
-                  host_app_path=params['host']),
+              test_app,
               udid=params['udid'],
               shards=params['shards'],
               retries=self.retries,
               out_dir=os.path.join(self.out_dir, params['udid']),
               env=self.get_launch_env()))
 
-    pool = multiprocessing.pool.ThreadPool(len(launch_commands))
+    thread_pool = pool.ThreadPool(len(launch_commands))
     attempts_results = []
-    for result in pool.imap_unordered(LaunchCommand.launch, launch_commands):
+    for result in thread_pool.imap_unordered(LaunchCommand.launch,
+                                             launch_commands):
       attempts_results.append(result['test_results']['attempts'])
 
     # Gets passed tests
diff --git a/ios/build/bots/scripts/xcodebuild_runner_test.py b/ios/build/bots/scripts/xcodebuild_runner_test.py
index f91c5b6..df2780f 100644
--- a/ios/build/bots/scripts/xcodebuild_runner_test.py
+++ b/ios/build/bots/scripts/xcodebuild_runner_test.py
@@ -7,9 +7,11 @@
 import mock
 import os
 
+import iossim_util
 import plistlib
 import shutil
 import tempfile
+import test_apps
 import test_runner
 import test_runner_test
 import xcode_log_parser
@@ -18,7 +20,7 @@
 
 _ROOT_FOLDER_PATH = 'root/folder'
 _XCODE_BUILD_VERSION = '10B61'
-_DESTINATION = 'platform=iOS Simulator,OS=12.0,name=iPhone X'
+_DESTINATION = 'A4E66321-177A-450A-9BA1-488D85B7278E'
 _OUT_DIR = 'out/dir'
 _XTEST_RUN = '/tmp/temp_file.xctestrun'
 _EGTESTS_APP_PATH = '%s/any_egtests.app' % _ROOT_FOLDER_PATH
@@ -36,6 +38,8 @@
     self.mock(xcodebuild_runner, 'get_all_tests',
               lambda _1, _2: ['Class1/passedTest1', 'Class1/passedTest2'])
     self.tmpdir = tempfile.mkdtemp()
+    self.mock(iossim_util, 'get_simulator_list',
+              lambda: test_runner_test.SIMULATORS_LIST)
 
   def tearDown(self):
     shutil.rmtree(self.tmpdir, ignore_errors=True)
@@ -107,92 +111,54 @@
   def testEgtests_not_found_egtests_app(self):
     self.mock(os.path, 'exists', lambda _: False)
     with self.assertRaises(test_runner.AppNotFoundError):
-      xcodebuild_runner.EgtestsApp(_EGTESTS_APP_PATH)
+      test_apps.EgtestsApp(_EGTESTS_APP_PATH)
 
   def testEgtests_not_found_plugins(self):
-    egtests = xcodebuild_runner.EgtestsApp(_EGTESTS_APP_PATH)
+    egtests = test_apps.EgtestsApp(_EGTESTS_APP_PATH)
     self.mock(os.path, 'exists', lambda _: False)
     with self.assertRaises(test_runner.PlugInsNotFoundError):
       egtests._xctest_path()
 
   def testEgtests_found_xctest(self):
     self.assertEqual('/PlugIns/any_egtests.xctest',
-                     xcodebuild_runner.EgtestsApp(
-                         _EGTESTS_APP_PATH)._xctest_path())
+                     test_apps.EgtestsApp(_EGTESTS_APP_PATH)._xctest_path())
 
   @mock.patch('os.listdir', autospec=True)
   def testEgtests_not_found_xctest(self, mock_listdir):
     mock_listdir.return_value = ['random_file']
-    egtest = xcodebuild_runner.EgtestsApp(_EGTESTS_APP_PATH)
+    egtest = test_apps.EgtestsApp(_EGTESTS_APP_PATH)
     with self.assertRaises(test_runner.XCTestPlugInNotFoundError):
       egtest._xctest_path()
 
   def testEgtests_xctestRunNode_without_filter(self):
-    egtest_node = xcodebuild_runner.EgtestsApp(
-        _EGTESTS_APP_PATH).xctestrun_node()['any_egtests_module']
+    egtest_node = test_apps.EgtestsApp(
+        _EGTESTS_APP_PATH).fill_xctestrun_node()['any_egtests_module']
     self.assertNotIn('OnlyTestIdentifiers', egtest_node)
     self.assertNotIn('SkipTestIdentifiers', egtest_node)
 
   def testEgtests_xctestRunNode_with_filter_only_identifiers(self):
     filtered_tests = ['TestCase1/testMethod1', 'TestCase1/testMethod2',
                       'TestCase2/testMethod1', 'TestCase1/testMethod2']
-    egtest_node = xcodebuild_runner.EgtestsApp(
-        _EGTESTS_APP_PATH, included_tests=filtered_tests).xctestrun_node()[
-            'any_egtests_module']
+    egtest_node = test_apps.EgtestsApp(
+        _EGTESTS_APP_PATH,
+        included_tests=filtered_tests).fill_xctestrun_node()['any_egtests_module']
     self.assertEqual(filtered_tests, egtest_node['OnlyTestIdentifiers'])
     self.assertNotIn('SkipTestIdentifiers', egtest_node)
 
   def testEgtests_xctestRunNode_with_filter_skip_identifiers(self):
     skipped_tests = ['TestCase1/testMethod1', 'TestCase1/testMethod2',
                      'TestCase2/testMethod1', 'TestCase1/testMethod2']
-    egtest_node = xcodebuild_runner.EgtestsApp(
-        _EGTESTS_APP_PATH, excluded_tests=skipped_tests
-        ).xctestrun_node()['any_egtests_module']
+    egtest_node = test_apps.EgtestsApp(
+        _EGTESTS_APP_PATH,
+        excluded_tests=skipped_tests).fill_xctestrun_node()['any_egtests_module']
     self.assertEqual(skipped_tests, egtest_node['SkipTestIdentifiers'])
     self.assertNotIn('OnlyTestIdentifiers', egtest_node)
 
-  @mock.patch('xcodebuild_runner.LaunchCommand.fill_xctest_run', autospec=True)
-  def testLaunchCommand_command(self, mock_fill_xctestrun):
-    mock_fill_xctestrun.return_value = _XTEST_RUN
-    mock_egtest = mock.MagicMock(spec=xcodebuild_runner.EgtestsApp)
-    type(mock_egtest).egtests_path = mock.PropertyMock(
-        return_value=_EGTESTS_APP_PATH)
-    cmd = xcodebuild_runner.LaunchCommand(
-        mock_egtest, _DESTINATION, shards=3, retries=1, out_dir=_OUT_DIR)
-    self.assertEqual(['xcodebuild', 'test-without-building',
-                      '-xctestrun', '/tmp/temp_file.xctestrun',
-                      '-destination',
-                      'platform=iOS Simulator,OS=12.0,name=iPhone X',
-                      '-resultBundlePath', 'out/dir',
-                      '-parallel-testing-enabled', 'YES',
-                      '-parallel-testing-worker-count', '3'],
-                     cmd.command(egtests_app=mock_egtest,
-                                 out_dir=_OUT_DIR,
-                                 destination=_DESTINATION,
-                                 shards=3))
-
-  @mock.patch('plistlib.writePlist', autospec=True)
-  @mock.patch('os.path.join', autospec=True)
-  @mock.patch('test_runner.get_current_xcode_info', autospec=True)
-  def testFill_xctest_run(self, xcode_version, mock_path_join, _):
-    mock_path_join.return_value = _XTEST_RUN
-    mock_egtest = mock.MagicMock(spec=xcodebuild_runner.EgtestsApp)
-    xcode_version.return_value = {'version': '10.2.1'}
-    launch_command = xcodebuild_runner.LaunchCommand(
-        mock_egtest, _DESTINATION, shards=1, retries=1, out_dir=_OUT_DIR)
-    self.assertEqual(_XTEST_RUN, launch_command.fill_xctest_run(mock_egtest))
-    self.assertEqual([mock.call.xctestrun_node()], mock_egtest.method_calls)
-
-  def testFill_xctest_run_exception(self):
-    with self.assertRaises(test_runner.AppNotFoundError):
-      xcodebuild_runner.LaunchCommand([], 'destination', shards=1, retries=1,
-                                      out_dir=_OUT_DIR).fill_xctest_run([])
-
   @mock.patch('test_runner.get_current_xcode_info', autospec=True)
   @mock.patch('xcode_log_parser.XcodeLogParser.collect_test_results')
   def testLaunchCommand_restartFailed1stAttempt(self, mock_collect_results,
                                                 xcode_version):
-    egtests = xcodebuild_runner.EgtestsApp(_EGTESTS_APP_PATH)
+    egtests = test_apps.EgtestsApp(_EGTESTS_APP_PATH)
     xcode_version.return_value = {'version': '10.2.1'}
     mock_collect_results.side_effect = [
         {'failed': {'TESTS_DID_NOT_START': ['not started']}, 'passed': []},
@@ -211,7 +177,7 @@
   @mock.patch('xcode_log_parser.XcodeLogParser.collect_test_results')
   def testLaunchCommand_notRestartPassedTest(self, mock_collect_results,
                                              xcode_version):
-    egtests = xcodebuild_runner.EgtestsApp(_EGTESTS_APP_PATH)
+    egtests = test_apps.EgtestsApp(_EGTESTS_APP_PATH)
     xcode_version.return_value = {'version': '10.2.1'}
     mock_collect_results.side_effect = [
         {'failed': {'BUILD_INTERRUPTED': 'BUILD_INTERRUPTED: attempt # 0'},