Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(78)

Side by Side Diff: ios/build/bots/scripts/test_runner.py

Issue 2595173003: Add copy of src/ios/build/bots/scripts to unbreak iOS Simulator bots. (Closed)
Patch Set: Created 3 years, 12 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « ios/build/bots/scripts/run.py ('k') | ios/build/bots/scripts/test_runner_test.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 # Copyright 2016 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
4
5 """Test runners for iOS."""
6
7 import argparse
8 import collections
9 import errno
10 import os
11 import shutil
12 import subprocess
13 import sys
14 import tempfile
15 import time
16
17 import find_xcode
18 import gtest_utils
19 import xctest_utils
20
21
22 XCTEST_PROJECT = os.path.abspath(os.path.join(
23 os.path.dirname(__file__),
24 'TestProject',
25 'TestProject.xcodeproj',
26 ))
27
28 XCTEST_SCHEME = 'TestProject'
29
30
31 class Error(Exception):
32 """Base class for errors."""
33 pass
34
35
36 class TestRunnerError(Error):
37 """Base class for TestRunner-related errors."""
38 pass
39
40
41 class AppLaunchError(TestRunnerError):
42 """The app failed to launch."""
43 pass
44
45
46 class AppNotFoundError(TestRunnerError):
47 """The requested app was not found."""
48 def __init__(self, app_path):
49 super(AppNotFoundError, self).__init__(
50 'App does not exist: %s' % app_path)
51
52
53 class DeviceDetectionError(TestRunnerError):
54 """Unexpected number of devices detected."""
55 def __init__(self, udids):
56 super(DeviceDetectionError, self).__init__(
57 'Expected one device, found %s:\n%s' % (len(udids), '\n'.join(udids)))
58
59
60 class PlugInsNotFoundError(TestRunnerError):
61 """The PlugIns directory was not found."""
62 def __init__(self, plugins_dir):
63 super(PlugInsNotFoundError, self).__init__(
64 'PlugIns directory does not exist: %s' % plugins_dir)
65
66
67 class SimulatorNotFoundError(TestRunnerError):
68 """The given simulator binary was not found."""
69 def __init__(self, iossim_path):
70 super(SimulatorNotFoundError, self).__init__(
71 'Simulator does not exist: %s' % iossim_path)
72
73
74 class XcodeVersionNotFoundError(TestRunnerError):
75 """The requested version of Xcode was not found."""
76 def __init__(self, xcode_version):
77 super(XcodeVersionNotFoundError, self).__init__(
78 'Xcode version not found: %s', xcode_version)
79
80
81 class XCTestPlugInNotFoundError(TestRunnerError):
82 """The .xctest PlugIn was not found."""
83 def __init__(self, xctest_path):
84 super(XCTestPlugInNotFoundError, self).__init__(
85 'XCTest not found: %s', xctest_path)
86
87
88 def get_kif_test_filter(tests, invert=False):
89 """Returns the KIF test filter to filter the given test cases.
90
91 Args:
92 tests: List of test cases to filter.
93 invert: Whether to invert the filter or not. Inverted, the filter will match
94 everything except the given test cases.
95
96 Returns:
97 A string which can be supplied to GKIF_SCENARIO_FILTER.
98 """
99 # A pipe-separated list of test cases with the "KIF." prefix omitted.
100 # e.g. NAME:a|b|c matches KIF.a, KIF.b, KIF.c.
101 # e.g. -NAME:a|b|c matches everything except KIF.a, KIF.b, KIF.c.
102 test_filter = '|'.join(test.split('KIF.', 1)[-1] for test in tests)
103 if invert:
104 return '-NAME:%s' % test_filter
105 return 'NAME:%s' % test_filter
106
107
108 def get_gtest_filter(tests, invert=False):
109 """Returns the GTest filter to filter the given test cases.
110
111 Args:
112 tests: List of test cases to filter.
113 invert: Whether to invert the filter or not. Inverted, the filter will match
114 everything except the given test cases.
115
116 Returns:
117 A string which can be supplied to --gtest_filter.
118 """
119 # A colon-separated list of tests cases.
120 # e.g. a:b:c matches a, b, c.
121 # e.g. -a:b:c matches everything except a, b, c.
122 test_filter = ':'.join(test for test in tests)
123 if invert:
124 return '-%s' % test_filter
125 return test_filter
126
127
128 class TestRunner(object):
129 """Base class containing common functionality."""
130
131 def __init__(
132 self,
133 app_path,
134 xcode_version,
135 out_dir,
136 env_vars=None,
137 test_args=None,
138 xctest=False,
139 ):
140 """Initializes a new instance of this class.
141
142 Args:
143 app_path: Path to the compiled .app to run.
144 xcode_version: Version of Xcode to use when running the test.
145 out_dir: Directory to emit test data into.
146 env_vars: List of environment variables to pass to the test itself.
147 test_args: List of strings to pass as arguments to the test when
148 launching.
149 xctest: Whether or not this is an XCTest.
150
151 Raises:
152 AppNotFoundError: If the given app does not exist.
153 PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests.
154 XcodeVersionNotFoundError: If the given Xcode version does not exist.
155 XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist.
156 """
157 app_path = os.path.abspath(app_path)
158 if not os.path.exists(app_path):
159 raise AppNotFoundError(app_path)
160
161 if not find_xcode.find_xcode(xcode_version)['found']:
162 raise XcodeVersionNotFoundError(xcode_version)
163
164 if not os.path.exists(out_dir):
165 os.makedirs(out_dir)
166
167 self.app_name = os.path.splitext(os.path.split(app_path)[-1])[0]
168 self.app_path = app_path
169 self.cfbundleid = subprocess.check_output([
170 '/usr/libexec/PlistBuddy',
171 '-c', 'Print:CFBundleIdentifier',
172 os.path.join(app_path, 'Info.plist'),
173 ]).rstrip()
174 self.env_vars = env_vars or []
175 self.logs = collections.OrderedDict()
176 self.out_dir = out_dir
177 self.test_args = test_args or []
178 self.xcode_version = xcode_version
179 self.xctest_path = ''
180
181 if xctest:
182 plugins_dir = os.path.join(self.app_path, 'PlugIns')
183 if not os.path.exists(plugins_dir):
184 raise PlugInsNotFoundError(plugins_dir)
185 for plugin in os.listdir(plugins_dir):
186 if plugin.endswith('.xctest'):
187 self.xctest_path = os.path.join(plugins_dir, plugin)
188 if not os.path.exists(self.xctest_path):
189 raise XCTestPlugInNotFoundError(self.xctest_path)
190
191 def get_launch_command(self, test_filter=None, invert=False):
192 """Returns the command that can be used to launch the test app.
193
194 Args:
195 test_filter: List of test cases to filter.
196 invert: Whether to invert the filter or not. Inverted, the filter will
197 match everything except the given test cases.
198
199 Returns:
200 A list of strings forming the command to launch the test.
201 """
202 raise NotImplementedError
203
204 def get_launch_env(self):
205 """Returns a dict of environment variables to use to launch the test app.
206
207 Returns:
208 A dict of environment variables.
209 """
210 return os.environ.copy()
211
212 def set_up(self):
213 """Performs setup actions which must occur prior to every test launch."""
214 raise NotImplementedError
215
216 def tear_down(self):
217 """Performs cleanup actions which must occur after every test launch."""
218 raise NotImplementedError
219
220 def screenshot_desktop(self):
221 """Saves a screenshot of the desktop in the output directory."""
222 subprocess.check_call([
223 'screencapture',
224 os.path.join(self.out_dir, 'desktop_%s.png' % time.time()),
225 ])
226
227 def _run(self, cmd):
228 """Runs the specified command, parsing GTest output.
229
230 Args:
231 cmd: List of strings forming the command to run.
232
233 Returns:
234 GTestResult instance.
235 """
236 print ' '.join(cmd)
237 print
238
239 result = gtest_utils.GTestResult(cmd)
240 if self.xctest_path:
241 parser = xctest_utils.XCTestLogParser()
242 else:
243 parser = gtest_utils.GTestLogParser()
244
245 proc = subprocess.Popen(
246 cmd,
247 env=self.get_launch_env(),
248 stdout=subprocess.PIPE,
249 stderr=subprocess.STDOUT,
250 )
251
252 while True:
253 line = proc.stdout.readline()
254 if not line:
255 break
256 line = line.rstrip()
257 parser.ProcessLine(line)
258 print line
259 sys.stdout.flush()
260
261 proc.wait()
262 sys.stdout.flush()
263
264 for test in parser.FailedTests(include_flaky=True):
265 # Test cases are named as <test group>.<test case>. If the test case
266 # is prefixed with "FLAKY_", it should be reported as flaked not failed.
267 if '.' in test and test.split('.', 1)[1].startswith('FLAKY_'):
268 result.flaked_tests[test] = parser.FailureDescription(test)
269 else:
270 result.failed_tests[test] = parser.FailureDescription(test)
271
272 result.passed_tests.extend(parser.PassedTests(include_flaky=True))
273
274 print '%s returned %s' % (cmd[0], proc.returncode)
275 print
276
277 # iossim can return 5 if it exits noncleanly even if all tests passed.
278 # Therefore we cannot rely on process exit code to determine success.
279 result.finalize(proc.returncode, parser.CompletedWithoutFailure())
280 return result
281
282 def launch(self):
283 """Launches the test app."""
284 self.set_up()
285 cmd = self.get_launch_command()
286 try:
287 result = self._run(cmd)
288 if result.crashed and not result.crashed_test:
289 # If the app crashed but not during any particular test case, assume
290 # it crashed on startup. Try one more time.
291 print 'Crashed on startup, retrying...'
292 print
293 result = self._run(cmd)
294
295 if result.crashed and not result.crashed_test:
296 raise AppLaunchError
297
298 passed = result.passed_tests
299 failed = result.failed_tests
300 flaked = result.flaked_tests
301
302 try:
303 # XCTests cannot currently be resumed at the next test case.
304 while not self.xctest_path and result.crashed and result.crashed_test:
305 # If the app crashes during a specific test case, then resume at the
306 # next test case. This is achieved by filtering out every test case
307 # which has already run.
308 print 'Crashed during %s, resuming...' % result.crashed_test
309 print
310 result = self._run(self.get_launch_command(
311 test_filter=passed + failed.keys() + flaked.keys(), invert=True,
312 ))
313 passed.extend(result.passed_tests)
314 failed.update(result.failed_tests)
315 flaked.update(result.flaked_tests)
316 except OSError as e:
317 if e.errno == errno.E2BIG:
318 print 'Too many test cases to resume.'
319 print
320 else:
321 raise
322
323 self.logs['passed tests'] = passed
324 for test, log_lines in failed.iteritems():
325 self.logs[test] = log_lines
326 for test, log_lines in flaked.iteritems():
327 self.logs[test] = log_lines
328
329 return not failed
330 finally:
331 self.tear_down()
332
333
334 class SimulatorTestRunner(TestRunner):
335 """Class for running tests on iossim."""
336
337 def __init__(
338 self,
339 app_path,
340 iossim_path,
341 platform,
342 version,
343 xcode_version,
344 out_dir,
345 env_vars=None,
346 test_args=None,
347 xctest=False,
348 ):
349 """Initializes a new instance of this class.
350
351 Args:
352 app_path: Path to the compiled .app or .ipa to run.
353 iossim_path: Path to the compiled iossim binary to use.
354 platform: Name of the platform to simulate. Supported values can be found
355 by running "iossim -l". e.g. "iPhone 5s", "iPad Retina".
356 version: Version of iOS the platform should be running. Supported values
357 can be found by running "iossim -l". e.g. "9.3", "8.2", "7.1".
358 xcode_version: Version of Xcode to use when running the test.
359 out_dir: Directory to emit test data into.
360 env_vars: List of environment variables to pass to the test itself.
361 test_args: List of strings to pass as arguments to the test when
362 launching.
363 xctest: Whether or not this is an XCTest.
364
365 Raises:
366 AppNotFoundError: If the given app does not exist.
367 PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests.
368 XcodeVersionNotFoundError: If the given Xcode version does not exist.
369 XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist.
370 """
371 super(SimulatorTestRunner, self).__init__(
372 app_path,
373 xcode_version,
374 out_dir,
375 env_vars=env_vars,
376 test_args=test_args,
377 xctest=xctest,
378 )
379
380 iossim_path = os.path.abspath(iossim_path)
381 if not os.path.exists(iossim_path):
382 raise SimulatorNotFoundError(iossim_path)
383
384 self.homedir = ''
385 self.iossim_path = iossim_path
386 self.platform = platform
387 self.start_time = None
388 self.version = version
389
390 @staticmethod
391 def kill_simulators():
392 """Kills all running simulators."""
393 try:
394 subprocess.check_call([
395 'pkill',
396 '-9',
397 '-x',
398 # The simulator's name varies by Xcode version.
399 'iPhone Simulator', # Xcode 5
400 'iOS Simulator', # Xcode 6
401 'Simulator', # Xcode 7+
402 'simctl', # https://crbug.com/637429
403 ])
404 # If a signal was sent, wait for the simulators to actually be killed.
405 time.sleep(5)
406 except subprocess.CalledProcessError as e:
407 if e.returncode != 1:
408 # Ignore a 1 exit code (which means there were no simulators to kill).
409 raise
410
411 def wipe_simulator(self):
412 """Wipes the simulator."""
413 subprocess.check_call([
414 self.iossim_path,
415 '-d', self.platform,
416 '-s', self.version,
417 '-w',
418 ])
419
420 def get_home_directory(self):
421 """Returns the simulator's home directory."""
422 return subprocess.check_output([
423 self.iossim_path,
424 '-d', self.platform,
425 '-p',
426 '-s', self.version,
427 ]).rstrip()
428
429 def set_up(self):
430 """Performs setup actions which must occur prior to every test launch."""
431 self.kill_simulators()
432 self.wipe_simulator()
433 self.homedir = self.get_home_directory()
434 # Crash reports have a timestamp in their file name, formatted as
435 # YYYY-MM-DD-HHMMSS. Save the current time in the same format so
436 # we can compare and fetch crash reports from this run later on.
437 self.start_time = time.strftime('%Y-%m-%d-%H%M%S', time.localtime())
438
439 def extract_test_data(self):
440 """Extracts data emitted by the test."""
441 # Find the Documents directory of the test app. The app directory names
442 # don't correspond with any known information, so we have to examine them
443 # all until we find one with a matching CFBundleIdentifier.
444 apps_dir = os.path.join(
445 self.homedir, 'Containers', 'Data', 'Application')
446 if os.path.exists(apps_dir):
447 for appid_dir in os.listdir(apps_dir):
448 docs_dir = os.path.join(apps_dir, appid_dir, 'Documents')
449 metadata_plist = os.path.join(
450 apps_dir,
451 appid_dir,
452 '.com.apple.mobile_container_manager.metadata.plist',
453 )
454 if os.path.exists(docs_dir) and os.path.exists(metadata_plist):
455 cfbundleid = subprocess.check_output([
456 '/usr/libexec/PlistBuddy',
457 '-c', 'Print:MCMMetadataIdentifier',
458 metadata_plist,
459 ]).rstrip()
460 if cfbundleid == self.cfbundleid:
461 shutil.copytree(docs_dir, os.path.join(self.out_dir, 'Documents'))
462 return
463
464 def retrieve_crash_reports(self):
465 """Retrieves crash reports produced by the test."""
466 # A crash report's naming scheme is [app]_[timestamp]_[hostname].crash.
467 # e.g. net_unittests_2014-05-13-15-0900_vm1-a1.crash.
468 crash_reports_dir = os.path.expanduser(os.path.join(
469 '~', 'Library', 'Logs', 'DiagnosticReports'))
470
471 if not os.path.exists(crash_reports_dir):
472 return
473
474 for crash_report in os.listdir(crash_reports_dir):
475 report_name, ext = os.path.splitext(crash_report)
476 if report_name.startswith(self.app_name) and ext == '.crash':
477 report_time = report_name[len(self.app_name) + 1:].split('_')[0]
478
479 # The timestamp format in a crash report is big-endian and therefore
480 # a staight string comparison works.
481 if report_time > self.start_time:
482 with open(os.path.join(crash_reports_dir, crash_report)) as f:
483 self.logs['crash report (%s)' % report_time] = (
484 f.read().splitlines())
485
486 def tear_down(self):
487 """Performs cleanup actions which must occur after every test launch."""
488 self.extract_test_data()
489 self.retrieve_crash_reports()
490 self.screenshot_desktop()
491 self.kill_simulators()
492 self.wipe_simulator()
493 if os.path.exists(self.homedir):
494 shutil.rmtree(self.homedir, ignore_errors=True)
495 self.homedir = ''
496
497 def get_launch_command(self, test_filter=None, invert=False):
498 """Returns the command that can be used to launch the test app.
499
500 Args:
501 test_filter: List of test cases to filter.
502 invert: Whether to invert the filter or not. Inverted, the filter will
503 match everything except the given test cases.
504
505 Returns:
506 A list of strings forming the command to launch the test.
507 """
508 cmd = [
509 self.iossim_path,
510 '-d', self.platform,
511 '-s', self.version,
512 ]
513
514 if test_filter:
515 kif_filter = get_kif_test_filter(test_filter, invert=invert)
516 gtest_filter = get_gtest_filter(test_filter, invert=invert)
517 cmd.extend(['-e', 'GKIF_SCENARIO_FILTER=%s' % kif_filter])
518 cmd.extend(['-c', '--gtest_filter=%s' % gtest_filter])
519
520 for env_var in self.env_vars:
521 cmd.extend(['-e', env_var])
522
523 for test_arg in self.test_args:
524 cmd.extend(['-c', test_arg])
525
526 cmd.append(self.app_path)
527 if self.xctest_path:
528 cmd.append(self.xctest_path)
529 return cmd
530
531 def get_launch_env(self):
532 """Returns a dict of environment variables to use to launch the test app.
533
534 Returns:
535 A dict of environment variables.
536 """
537 env = super(SimulatorTestRunner, self).get_launch_env()
538 if self.xctest_path:
539 env['NSUnbufferedIO'] = 'YES'
540 return env
541
542
543 class DeviceTestRunner(TestRunner):
544 """Class for running tests on devices."""
545
546 def __init__(
547 self,
548 app_path,
549 xcode_version,
550 out_dir,
551 env_vars=None,
552 test_args=None,
553 xctest=False,
554 ):
555 """Initializes a new instance of this class.
556
557 Args:
558 app_path: Path to the compiled .app to run.
559 xcode_version: Version of Xcode to use when running the test.
560 out_dir: Directory to emit test data into.
561 env_vars: List of environment variables to pass to the test itself.
562 test_args: List of strings to pass as arguments to the test when
563 launching.
564 xctest: Whether or not this is an XCTest.
565
566 Raises:
567 AppNotFoundError: If the given app does not exist.
568 PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests.
569 XcodeVersionNotFoundError: If the given Xcode version does not exist.
570 XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist.
571 """
572 super(DeviceTestRunner, self).__init__(
573 app_path,
574 xcode_version,
575 out_dir,
576 env_vars=env_vars,
577 test_args=test_args,
578 xctest=xctest,
579 )
580
581 self.udid = subprocess.check_output(['idevice_id', '--list']).rstrip()
582 if len(self.udid.splitlines()) != 1:
583 raise DeviceDetectionError(self.udid)
584
585 def uninstall_apps(self):
586 """Uninstalls all apps found on the device."""
587 for app in subprocess.check_output(
588 ['idevicefs', '--udid', self.udid, 'ls', '@']).splitlines():
589 subprocess.check_call(
590 ['ideviceinstaller', '--udid', self.udid, '--uninstall', app])
591
592 def install_app(self):
593 """Installs the app."""
594 subprocess.check_call(
595 ['ideviceinstaller', '--udid', self.udid, '--install', self.app_path])
596
597 def set_up(self):
598 """Performs setup actions which must occur prior to every test launch."""
599 self.uninstall_apps()
600 self.install_app()
601
602 def extract_test_data(self):
603 """Extracts data emitted by the test."""
604 subprocess.check_call([
605 'idevicefs',
606 '--udid', self.udid,
607 'pull',
608 '@%s/Documents' % self.cfbundleid,
609 os.path.join(self.out_dir, 'Documents'),
610 ])
611
612 def retrieve_crash_reports(self):
613 """Retrieves crash reports produced by the test."""
614 logs_dir = os.path.join(self.out_dir, 'Logs')
615 os.mkdir(logs_dir)
616 subprocess.check_call([
617 'idevicecrashreport',
618 '--extract',
619 '--udid', self.udid,
620 logs_dir,
621 ])
622
623 def tear_down(self):
624 """Performs cleanup actions which must occur after every test launch."""
625 self.extract_test_data()
626 self.retrieve_crash_reports()
627 self.screenshot_desktop()
628 self.uninstall_apps()
629
630 def get_launch_command(self, test_filter=None, invert=False):
631 """Returns the command that can be used to launch the test app.
632
633 Args:
634 test_filter: List of test cases to filter.
635 invert: Whether to invert the filter or not. Inverted, the filter will
636 match everything except the given test cases.
637
638 Returns:
639 A list of strings forming the command to launch the test.
640 """
641 if self.xctest_path:
642 return [
643 'xcodebuild',
644 'test-without-building',
645 'BUILT_PRODUCTS_DIR=%s' % os.path.dirname(self.app_path),
646 '-destination', 'id=%s' % self.udid,
647 '-project', XCTEST_PROJECT,
648 '-scheme', XCTEST_SCHEME,
649 ]
650
651 cmd = [
652 'idevice-app-runner',
653 '--udid', self.udid,
654 '--start', self.cfbundleid,
655 ]
656 args = []
657
658 if test_filter:
659 kif_filter = get_kif_test_filter(test_filter, invert=invert)
660 gtest_filter = get_gtest_filter(test_filter, invert=invert)
661 cmd.extend(['-D', 'GKIF_SCENARIO_FILTER=%s' % kif_filter])
662 args.append('--gtest-filter=%s' % gtest_filter)
663
664 for env_var in self.env_vars:
665 cmd.extend(['-D', env_var])
666
667 if args or self.test_args:
668 cmd.append('--args')
669 cmd.extend(self.test_args)
670 cmd.extend(args)
671
672 return cmd
673
674 def get_launch_env(self):
675 """Returns a dict of environment variables to use to launch the test app.
676
677 Returns:
678 A dict of environment variables.
679 """
680 env = super(DeviceTestRunner, self).get_launch_env()
681 if self.xctest_path:
682 env['NSUnbufferedIO'] = 'YES'
683 # e.g. ios_web_shell_egtests
684 env['APP_TARGET_NAME'] = os.path.splitext(
685 os.path.basename(self.app_path))[0]
686 # e.g. ios_web_shell_egtests_module
687 env['TEST_TARGET_NAME'] = env['APP_TARGET_NAME'] + '_module'
688 return env
OLDNEW
« no previous file with comments | « ios/build/bots/scripts/run.py ('k') | ios/build/bots/scripts/test_runner_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698