Skip to content

Commit 51dbb6b

Browse files
committed
Update test runner
- Support "skip_tests" and "uitest_auto_screenshots" in launch options. - Improve simulator_test stability.
1 parent 5657307 commit 51dbb6b

File tree

8 files changed

+136
-28
lines changed

8 files changed

+136
-28
lines changed

xctestrunner/shared/ios_constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ def enum(**enums):
3030
SUPPORTED_SIM_OSS = [OS.IOS]
3131

3232
TEST_STARTED_SIGNAL = 'Test Suite'
33+
XCTRUNNER_STARTED_SIGNAL = 'Running tests...'
34+
35+
CORESIMULATOR_INTERRUPTED_ERROR = 'CoreSimulatorService connection interrupted'
3336

3437
LAUNCH_OPTIONS_JSON_HELP = (
3538
"""The path of json file, which contains options of launching test.
@@ -55,6 +58,14 @@ def enum(**enums):
5558
The specific test classes or test methods to run. Each item should be
5659
string and its format is Test-Class-Name[/Test-Method-Name]. It is supported
5760
in Xcode 8+.
61+
skip_tests: array
62+
The specific test classes or test methods to skip. Each item should be
63+
string and its format is Test-Class-Name[/Test-Method-Name]. Logic test
64+
does not support that.
65+
uitest_auto_screenshots: bool
66+
Whether captures screenshots automatically in ui test. If yes, will save the
67+
screenshots when the test failed. By default, it is false. Prior Xcode 9,
68+
this option does not work and the auto screenshot is enable by default.
5869
""")
5970

6071
SIGNING_OPTIONS_JSON_HELP = (

xctestrunner/simulator_control/simulator_util.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,7 @@ def Shutdown(self):
120120
'Can not shut down the simulator in state CREATING.')
121121
logging.info('Shutting down simulator %s.', self.simulator_id)
122122
try:
123-
subprocess.check_output(
124-
['xcrun', 'simctl', 'shutdown', self.simulator_id],
125-
stderr=subprocess.STDOUT)
123+
_RunSimctlCommand(['xcrun', 'simctl', 'shutdown', self.simulator_id])
126124
except subprocess.CalledProcessError as e:
127125
if 'Unable to shutdown device in current state: Shutdown' in e.output:
128126
logging.info('Simulator %s has already shut down.', self.simulator_id)
@@ -147,7 +145,7 @@ def Delete(self):
147145
'Can only delete the simulator with state SHUTDOWN. The current '
148146
'state of simulator %s is %s.' % (self._simulator_id, sim_state))
149147
try:
150-
subprocess.check_call(['xcrun', 'simctl', 'delete', self.simulator_id])
148+
_RunSimctlCommand(['xcrun', 'simctl', 'delete', self.simulator_id])
151149
except subprocess.CalledProcessError as e:
152150
raise ios_errors.SimError(
153151
'Failed to delete simulator %s: %s' % (self.simulator_id, e.output))
@@ -185,9 +183,9 @@ def GetAppDocumentsPath(self, app_bundle_id):
185183
"""Gets the path of the app's Documents directory."""
186184
if xcode_info_util.GetXcodeVersionNumber() >= 830:
187185
try:
188-
app_data_container = subprocess.check_output(
186+
app_data_container = _RunSimctlCommand(
189187
['xcrun', 'simctl', 'get_app_container', self._simulator_id,
190-
app_bundle_id, 'data']).strip()
188+
app_bundle_id, 'data'])
191189
return os.path.join(app_data_container, 'Documents')
192190
except subprocess.CalledProcessError as e:
193191
raise ios_errors.SimError(
@@ -313,8 +311,8 @@ def CreateNewSimulator(device_type=None, os_version=None, name=None):
313311
name, os_type, os_version, device_type)
314312
for i in range(0, _SIM_OPERATION_MAX_ATTEMPTS):
315313
try:
316-
new_simulator_id = subprocess.check_output(
317-
['xcrun', 'simctl', 'create', name, device_type, runtime_id]).strip()
314+
new_simulator_id = _RunSimctlCommand(
315+
['xcrun', 'simctl', 'create', name, device_type, runtime_id])
318316
except subprocess.CalledProcessError as e:
319317
raise ios_errors.SimError(
320318
'Failed to create simulator: %s' % e.output)
@@ -371,8 +369,7 @@ def GetSupportedSimDeviceTypes(os_type=None):
371369
#
372370
# See more examples in testdata/simctl_list_devicetypes.json
373371
sim_types_infos_json = ast.literal_eval(
374-
subprocess.check_output(
375-
('xcrun', 'simctl', 'list', 'devicetypes', '-j')))
372+
_RunSimctlCommand(('xcrun', 'simctl', 'list', 'devicetypes', '-j')))
376373
sim_types = []
377374
for sim_types_info in sim_types_infos_json['devicetypes']:
378375
sim_type = sim_types_info['name']
@@ -438,8 +435,7 @@ def GetSupportedSimOsVersions(os_type=ios_constants.OS.IOS):
438435
#
439436
# See more examples in testdata/simctl_list_runtimes.json
440437
sim_runtime_infos_json = ast.literal_eval(
441-
subprocess.check_output(
442-
('xcrun', 'simctl', 'list', 'runtimes', '-j')))
438+
_RunSimctlCommand(('xcrun', 'simctl', 'list', 'runtimes', '-j')))
443439
sim_versions = []
444440
for sim_runtime_info in sim_runtime_infos_json['runtimes']:
445441
# Normally, the json does not contain unavailable runtimes. To be safe,
@@ -610,3 +606,14 @@ def IsXctestFailedToLaunchOnSim(sim_sys_log):
610606
"""
611607
pattern = re.compile(_PATTERN_XCTEST_PROCESS_CRASH_ON_SIM)
612608
return pattern.search(sim_sys_log) is not None
609+
610+
611+
def _RunSimctlCommand(command):
612+
"""Runs simctl command."""
613+
for i in range(2):
614+
try:
615+
return subprocess.check_output(command, stderr=subprocess.STDOUT).strip()
616+
except subprocess.CalledProcessError as e:
617+
if i == 0 and ios_constants.CORESIMULATOR_INTERRUPTED_ERROR in e.output:
618+
continue
619+
raise e

xctestrunner/test_runner/TestProject/TestProject.xcodeproj/xcshareddata/xcschemes/TestProjectXcuitest.xcscheme

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
2828
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
2929
shouldUseLaunchSchemeArgsEnv = "YES"
30+
systemAttachmentLifetime = "keepNever"
3031
disableMainThreadChecker = "YES">
3132
<Testables>
3233
<TestableReference

xctestrunner/test_runner/dummy_project.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,12 @@ def __init__(self, app_under_test_dir, test_bundle_dir,
5858
5959
Args:
6060
app_under_test_dir: string, path of the app to be tested in
61-
dummy project.
61+
dummy project.
6262
test_bundle_dir: string, path of the test bundle.
6363
sdk: string, SDKRoot of the dummy project. See supported SDKs in
64-
module shared.ios_constants.
64+
module shared.ios_constants.
6565
test_type: string, test type of the test bundle. See supported test types
66-
in module shared.ios_constants.
66+
in module shared.ios_constants.
6767
work_dir: string, work directory which contains run files.
6868
"""
6969
self._app_under_test_dir = app_under_test_dir
@@ -411,7 +411,7 @@ def SetTestBundleProvisioningProfile(self, test_bundle_provisioning_profile):
411411
412412
Args:
413413
test_bundle_provisioning_profile: string, name/path of the provisioning
414-
profile of test bundle.
414+
profile of test bundle.
415415
"""
416416
if not test_bundle_provisioning_profile:
417417
return
@@ -488,6 +488,28 @@ def SetArgs(self, args):
488488
arg_element.set('isEnabled', 'YES')
489489
scheme_tree.write(scheme_path)
490490

491+
def SetSkipTests(self, skip_tests):
492+
"""Sets the skip tests in the dummy project's scheme.
493+
494+
Args:
495+
skip_tests: a list of string. The format of each item is
496+
Test-Class-Name[/Test-Method-Name].
497+
"""
498+
if not skip_tests:
499+
return
500+
self.GenerateDummyProject()
501+
scheme_path = self.test_scheme_path
502+
scheme_tree = ET.parse(scheme_path)
503+
test_action_element = scheme_tree.getroot().find('TestAction')
504+
testable_reference_element = test_action_element.find(
505+
'Testables').find('TestableReference')
506+
skip_tests_element = ET.SubElement(
507+
testable_reference_element, 'SkippedTests')
508+
for skip_test in skip_tests:
509+
skip_test_element = ET.SubElement(skip_tests_element, 'Test')
510+
skip_test_element.set('Identifier', skip_test)
511+
scheme_tree.write(scheme_path)
512+
491513

492514
def _GetTestProject(work_dir):
493515
"""Gets the TestProject path."""

xctestrunner/test_runner/test_summaries_util.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ def GetTestSummariesPaths(derived_data_dir):
2828
return glob.glob('%s/Logs/Test/*_TestSummaries.plist' % derived_data_dir)
2929

3030

31-
def ParseTestSummaries(test_summaries_path, attachments_dir_path):
31+
def ParseTestSummaries(
32+
test_summaries_path, attachments_dir_path,
33+
delete_uitest_auto_screenshots=True):
3234
"""Parse the TestSummaries.plist and structure the attachments' files.
3335
3436
Only the screenshots file from failure test methods and .crash files will be
@@ -37,13 +39,17 @@ def ParseTestSummaries(test_summaries_path, attachments_dir_path):
3739
Args:
3840
test_summaries_path: string, the path of TestSummaries.plist file.
3941
attachments_dir_path: string, the path of Attachments directory.
42+
delete_uitest_auto_screenshots: bool, whether deletes the auto screenshots.
4043
"""
4144
test_summaries_plist = plist_util.Plist(test_summaries_path)
4245
tests_obj = test_summaries_plist.GetPlistField('TestableSummaries:0:Tests:0')
4346
# Store the required screenshots and crash files under temp directory first.
4447
# Then use the temp directory to replace the original Attachments directory.
48+
# If delete_uitest_auto_screenshots is true, only move crash files to
49+
# temp directory and the left screenshots will be deleted.
4550
temp_dir = tempfile.mkdtemp(dir=os.path.dirname(attachments_dir_path))
46-
_ParseTestObject(tests_obj, attachments_dir_path, temp_dir)
51+
if not delete_uitest_auto_screenshots:
52+
_ParseTestObject(tests_obj, attachments_dir_path, temp_dir)
4753
for crash_file in glob.glob('%s/*.crash' % attachments_dir_path):
4854
shutil.move(crash_file, temp_dir)
4955
shutil.rmtree(attachments_dir_path)

xctestrunner/test_runner/xcodebuild_test_executor.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import io
1818
import logging
1919
import os
20+
import random
2021
import re
2122
import shutil
2223
import subprocess
@@ -32,7 +33,7 @@
3233

3334

3435
_XCODEBUILD_TEST_STARTUP_TIMEOUT_SEC = 150
35-
_SIM_TEST_MAX_ATTEMPTS = 2
36+
_SIM_TEST_MAX_ATTEMPTS = 3
3637
_TAIL_SIM_LOG_LINE = 200
3738
_BACKGROUND_TEST_RUNNER_ERROR = 'Failed to background test runner'
3839
_PROCESS_EXISTED_OR_CRASHED_ERROR = ('The process did launch, but has since '
@@ -41,6 +42,7 @@
4142
'(SBMainWorkspace) for reason')
4243
_APP_UNKNOWN_TO_FRONTEND_PATTERN = re.compile(
4344
'Application ".*" is unknown to FrontBoard.')
45+
_INIT_SIM_SERVICE_ERROR = 'Failed to initiate service connection to simulator'
4446

4547

4648
class CheckXcodebuildStuckThread(threading.Thread):
@@ -142,9 +144,15 @@ def Execute(self, return_output=True):
142144
output = io.BytesIO()
143145
for stdout_line in iter(process.stdout.readline, ''):
144146
if not test_started:
147+
# Terminates the CheckXcodebuildStuckThread when test has started
148+
# or XCTRunner.app has started.
149+
# But XCTRunner.app start does not mean test start.
145150
if ios_constants.TEST_STARTED_SIGNAL in stdout_line:
146151
test_started = True
147152
check_xcodebuild_stuck.Terminate()
153+
if (self._test_type == ios_constants.TestType.XCUITEST and
154+
ios_constants.XCTRUNNER_STARTED_SIGNAL in stdout_line):
155+
check_xcodebuild_stuck.Terminate()
148156
else:
149157
if self._succeeded_signal and self._succeeded_signal in stdout_line:
150158
test_succeeded = True
@@ -180,7 +188,7 @@ def Execute(self, return_output=True):
180188

181189
# The following error can be fixed by relaunching the test again.
182190
try:
183-
if sim_log_path:
191+
if sim_log_path and os.path.exists(sim_log_path):
184192
tail_sim_log = _ReadFileTailInShell(
185193
sim_log_path, _TAIL_SIM_LOG_LINE)
186194
if (self._test_type == ios_constants.TestType.LOGIC_TEST and
@@ -190,6 +198,12 @@ def Execute(self, return_output=True):
190198
raise ios_errors.SimError('')
191199
if _PROCESS_EXISTED_OR_CRASHED_ERROR in output_str:
192200
raise ios_errors.SimError('')
201+
if ios_constants.CORESIMULATOR_INTERRUPTED_ERROR in output_str:
202+
# Sleep random[0,2] seconds to avoid race condition. It is known
203+
# issue that CoreSimulatorService connection will interrupte if
204+
# two simulators booting at the same time.
205+
time.sleep(random.uniform(0, 2))
206+
raise ios_errors.SimError('')
193207
except ios_errors.SimError:
194208
if i < max_attempts - 1:
195209
logging.warning(
@@ -228,6 +242,8 @@ def _NeedRecreateSim(self, output_str):
228242
return True
229243
if _REQUEST_DENIED_ERROR in output_str:
230244
return True
245+
if _INIT_SIM_SERVICE_ERROR in output_str:
246+
return True
231247
return False
232248

233249

xctestrunner/test_runner/xctest_session.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from xctestrunner.shared import xcode_info_util
2727
from xctestrunner.test_runner import dummy_project
2828
from xctestrunner.test_runner import logic_test_util
29+
from xctestrunner.test_runner import runner_exit_codes
2930
from xctestrunner.test_runner import test_summaries_util
3031
from xctestrunner.test_runner import xctestrun
3132

@@ -62,6 +63,8 @@ def __init__(self, sdk, work_dir=None, output_dir=None):
6263
self._logic_test_env_vars = None
6364
self._logic_test_args = None
6465
self._logic_tests_to_run = None
66+
# The following fields are only for XCUITest.
67+
self._disable_uitest_auto_screenshots = True
6568

6669
def __enter__(self):
6770
return self
@@ -70,6 +73,8 @@ def __exit__(self, unused_type, unused_value, unused_traceback):
7073
"""Deletes the temp directories."""
7174
self.Close()
7275

76+
# TODO(albertdai): Support bundle id as the value of app_under_test and
77+
# test_bundle.
7378
def Prepare(self, app_under_test=None, test_bundle=None,
7479
xctestrun_file_path=None, test_type=None, signing_options=None):
7580
"""Prepares the test session.
@@ -177,13 +182,24 @@ def SetLaunchOptions(self, launch_options):
177182
self._xctestrun_obj.SetTestEnvVars(launch_options.get('env_vars'))
178183
self._xctestrun_obj.SetTestArgs(launch_options.get('args'))
179184
self._xctestrun_obj.SetTestsToRun(launch_options.get('tests_to_run'))
185+
self._xctestrun_obj.SetSkipTests(launch_options.get('skip_tests'))
180186
self._xctestrun_obj.SetAppUnderTestEnvVars(
181187
launch_options.get('app_under_test_env_vars'))
182188
self._xctestrun_obj.SetAppUnderTestArgs(
183189
launch_options.get('app_under_test_args'))
190+
191+
if launch_options.get('uitest_auto_screenshots'):
192+
self._disable_uitest_auto_screenshots = False
193+
# By default, this SystemAttachmentLifetime field is in the generated
194+
# xctestrun.plist.
195+
try:
196+
self._xctestrun_obj.DeleteXctestrunField('SystemAttachmentLifetime')
197+
except ios_errors.PlistError:
198+
pass
184199
elif self._dummy_project_obj:
185200
self._dummy_project_obj.SetEnvVars(launch_options.get('env_vars'))
186201
self._dummy_project_obj.SetArgs(launch_options.get('args'))
202+
self._dummy_project_obj.SetSkipTests(launch_options.get('skip_tests'))
187203
elif self._logic_test_bundle:
188204
self._logic_test_env_vars = launch_options.get('env_vars')
189205
self._logic_test_args = launch_options.get('args')
@@ -214,11 +230,12 @@ def RunTest(self, device_id):
214230
try:
215231
test_summaries_util.ParseTestSummaries(
216232
test_summaries_path,
217-
os.path.join(self._output_dir, 'Logs/Test/Attachments'))
233+
os.path.join(self._output_dir, 'Logs/Test/Attachments'),
234+
True if self._disable_uitest_auto_screenshots else
235+
exit_code == runner_exit_codes.EXITCODE.SUCCEEDED)
218236
except ios_errors.PlistError as e:
219-
logging.warning(
220-
'Failed to parse test summaries %s: %s',
221-
test_summaries_path, e.message)
237+
logging.warning('Failed to parse test summaries %s: %s',
238+
test_summaries_path, e.message)
222239
return exit_code
223240
elif self._dummy_project_obj:
224241
return self._dummy_project_obj.RunXcTest(

xctestrunner/test_runner/xctestrun.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,17 @@ def SetTestsToRun(self, tests_to_run):
128128
return
129129
self.SetXctestrunField('OnlyTestIdentifiers', tests_to_run)
130130

131+
def SetSkipTests(self, skip_tests):
132+
"""Sets the specific test methods/test classes to skip in xctestrun file.
133+
134+
Args:
135+
skip_tests: a list of string. The format of each item is
136+
Test-Class-Name[/Test-Method-Name]
137+
"""
138+
if not skip_tests:
139+
return
140+
self.SetXctestrunField('SkipTestIdentifiers', skip_tests)
141+
131142
def Run(self, device_id, sdk, derived_data_dir):
132143
"""Runs the test with generated xctestrun file in the specific device.
133144
@@ -413,8 +424,17 @@ def _GenerateXctestrunFileForXcuitest(self):
413424
if os.path.exists(xctrunner_plugins_dir):
414425
shutil.rmtree(xctrunner_plugins_dir)
415426
os.mkdir(xctrunner_plugins_dir)
416-
self._test_bundle_dir = _MoveAndReplaceFile(
417-
self._test_bundle_dir, xctrunner_plugins_dir)
427+
# The test bundle should not exist under the new generated XCTRunner.app.
428+
if os.path.islink(self._test_bundle_dir):
429+
# The test bundle under PlugIns can not be symlink since it will cause
430+
# app installation error.
431+
new_test_bundle_path = os.path.join(
432+
xctrunner_plugins_dir, os.path.basename(self._test_bundle_dir))
433+
shutil.copytree(self._test_bundle_dir, new_test_bundle_path)
434+
self._test_bundle_dir = new_test_bundle_path
435+
else:
436+
self._test_bundle_dir = _MoveAndReplaceFile(
437+
self._test_bundle_dir, xctrunner_plugins_dir)
418438

419439
generated_xctestrun_file_paths = glob.glob('%s/*.xctestrun' %
420440
derived_data_build_products_dir)
@@ -460,8 +480,16 @@ def _GenerateXctestrunFileForXctest(self):
460480
self._app_under_test_dir, 'PlugIns')
461481
if not os.path.exists(app_under_test_plugins_dir):
462482
os.mkdir(app_under_test_plugins_dir)
463-
self._test_bundle_dir = _MoveAndReplaceFile(
464-
self._test_bundle_dir, app_under_test_plugins_dir)
483+
new_test_bundle_path = os.path.join(
484+
app_under_test_plugins_dir, os.path.basename(self._test_bundle_dir))
485+
# The test bundle under PlugIns can not be symlink since it will cause
486+
# app installation error.
487+
if os.path.islink(self._test_bundle_dir):
488+
shutil.copytree(self._test_bundle_dir, new_test_bundle_path)
489+
self._test_bundle_dir = new_test_bundle_path
490+
elif new_test_bundle_path != self._test_bundle_dir:
491+
self._test_bundle_dir = _MoveAndReplaceFile(
492+
self._test_bundle_dir, app_under_test_plugins_dir)
465493

466494
# The xctestrun file are under the build products directory of dummy
467495
# project's derived data dir.

0 commit comments

Comments
 (0)