b6aa3dab9e4bd95e78bafb9e54d2f7687a252721
[WebKit-https.git] / Tools / Scripts / webkitpy / xcode / simulator.py
1 # Copyright (C) 2014, 2015 Apple Inc. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or without
4 # modification, are permitted provided that the following conditions
5 # are met:
6 # 1.  Redistributions of source code must retain the above copyright
7 #     notice, this list of conditions and the following disclaimer.
8 # 2.  Redistributions in binary form must reproduce the above copyright
9 #     notice, this list of conditions and the following disclaimer in the
10 #     documentation and/or other materials provided with the distribution.
11 #
12 # THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND ANY
13 # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
14 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
15 # DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR ANY
16 # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
17 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
18 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
19 # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
20 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
21 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22
23 import itertools
24 import logging
25 import os
26 import plistlib
27 import re
28 import subprocess
29 import time
30
31 from webkitpy.benchmark_runner.utils import timeout
32 from webkitpy.common.host import Host
33
34 _log = logging.getLogger(__name__)
35
36 """
37 Minimally wraps CoreSimulator functionality through simctl.
38
39 If possible, use real CoreSimulator.framework functionality by linking to the framework itself.
40 Do not use PyObjC to dlopen the framework.
41 """
42
43
44 class DeviceType(object):
45     """
46     Represents a CoreSimulator device type.
47     """
48     def __init__(self, name, identifier):
49         """
50         :param name: The device type's human-readable name
51         :type name: str
52         :param identifier: The CoreSimulator identifier.
53         :type identifier: str
54         """
55         self.name = name
56         self.identifier = identifier
57
58     @classmethod
59     def from_name(cls, name):
60         """
61         :param name: The name for the desired device type.
62         :type name: str
63         :returns: A `DeviceType` object with the specified identifier or throws a TypeError if it doesn't exist.
64         :rtype: DeviceType
65         """
66         identifier = None
67         for device_type in Simulator().device_types:
68             if device_type.name == name:
69                 identifier = device_type.identifier
70                 break
71
72         if identifier is None:
73             raise TypeError('A device type with name "{name}" does not exist.'.format(name=name))
74
75         return DeviceType(name, identifier)
76
77     @classmethod
78     def from_identifier(cls, identifier):
79         """
80         :param identifier: The CoreSimulator identifier for the desired runtime.
81         :type identifier: str
82         :returns: A `Runtime` object witht the specified identifier or throws a TypeError if it doesn't exist.
83         :rtype: DeviceType
84         """
85         name = None
86         for device_type in Simulator().device_types:
87             if device_type.identifier == identifier:
88                 name = device_type.name
89                 break
90
91         if name is None:
92             raise TypeError('A device type with identifier "{identifier}" does not exist.'.format(
93                 identifier=identifier))
94
95         return DeviceType(name, identifier)
96
97     def __eq__(self, other):
98         return (self.name == other.name) and (self.identifier == other.identifier)
99
100     def __ne__(self, other):
101         return not self.__eq__(other)
102
103     def __repr__(self):
104         return '<DeviceType "{name}": {identifier}>'.format(name=self.name, identifier=self.identifier)
105
106
107 class Runtime(object):
108     """
109     Represents a CoreSimulator runtime associated with an iOS SDK.
110     """
111
112     def __init__(self, version, identifier, available, devices=None, is_internal_runtime=False):
113         """
114         :param version: The iOS SDK version
115         :type version: tuple
116         :param identifier: The CoreSimulator runtime identifier
117         :type identifier: str
118         :param availability: Whether the runtime is available for use.
119         :type availability: bool
120         :param devices: A list of devices under this runtime
121         :type devices: list or None
122         :param is_internal_runtime: Whether the runtime is an Apple internal runtime
123         :type is_internal_runtime: bool
124         """
125         self.version = version
126         self.identifier = identifier
127         self.available = available
128         self.devices = devices or []
129         self.is_internal_runtime = is_internal_runtime
130
131     @classmethod
132     def from_version_string(cls, version):
133         return cls.from_identifier('com.apple.CoreSimulator.SimRuntime.iOS-' + version.replace('.', '-'))
134
135     @classmethod
136     def from_identifier(cls, identifier):
137         """
138         :param identifier: The identifier for the desired CoreSimulator runtime.
139         :type identifier: str
140         :returns: A `Runtime` object with the specified identifier or throws a TypeError if it doesn't exist.
141         :rtype: Runtime
142         """
143         for runtime in Simulator().runtimes:
144             if runtime.identifier == identifier:
145                 return runtime
146         raise TypeError('A runtime with identifier "{identifier}" does not exist.'.format(identifier=identifier))
147
148     def __eq__(self, other):
149         return (self.version == other.version) and (self.identifier == other.identifier) and (self.is_internal_runtime == other.is_internal_runtime)
150
151     def __ne__(self, other):
152         return not self.__eq__(other)
153
154     def __repr__(self):
155         version_suffix = ""
156         if self.is_internal_runtime:
157             version_suffix = " Internal"
158         return '<Runtime {version}: {identifier}. Available: {available}, {num_devices} devices>'.format(
159             version='.'.join(map(str, self.version)) + version_suffix,
160             identifier=self.identifier,
161             available=self.available,
162             num_devices=len(self.devices))
163
164
165 class Device(object):
166     """
167     Represents a CoreSimulator device underneath a runtime
168     """
169
170     def __init__(self, name, udid, available, runtime):
171         """
172         :param name: The device name
173         :type name: str
174         :param udid: The device UDID (a UUID string)
175         :type udid: str
176         :param available: Whether the device is available for use.
177         :type available: bool
178         :param runtime: The iOS Simulator runtime that hosts this device
179         :type runtime: Runtime
180         """
181         self.name = name
182         self.udid = udid
183         self.available = available
184         self.runtime = runtime
185
186     @property
187     def state(self):
188         """
189         :returns: The current state of the device.
190         :rtype: Simulator.DeviceState
191         """
192         return Simulator.device_state(self.udid)
193
194     @property
195     def path(self):
196         """
197         :returns: The filesystem path that contains the simulator device's data.
198         :rtype: str
199         """
200         return Simulator.device_directory(self.udid)
201
202     @classmethod
203     def create(cls, name, device_type, runtime):
204         """
205         Create a new CoreSimulator device.
206         :param name: The name of the device.
207         :type name: str
208         :param device_type: The CoreSimulatort device type.
209         :type device_type: DeviceType
210         :param runtime:  The CoreSimualtor runtime.
211         :type runtime: Runtime
212         :return: The new device or raises a CalledProcessError if ``simctl create`` failed.
213         :rtype: Device
214         """
215         device_udid = subprocess.check_output(['xcrun', 'simctl', 'create', name, device_type.identifier, runtime.identifier]).rstrip()
216         _log.debug('"xcrun simctl create %s %s %s" returned %s', name, device_type.identifier, runtime.identifier, device_udid)
217         Simulator.wait_until_device_is_in_state(device_udid, Simulator.DeviceState.SHUTDOWN)
218         return Simulator().find_device_by_udid(device_udid)
219
220     @classmethod
221     def shutdown(cls, udid):
222         """
223         Shut down the given CoreSimulator device.
224         :param udid: The udid of the device.
225         :type udid: str
226         """
227         device_state = Simulator.device_state(udid)
228         if device_state == Simulator.DeviceState.BOOTING or device_state == Simulator.DeviceState.BOOTED:
229             _log.debug('xcrun simctl shutdown %s', udid)
230             # Don't throw on error. Device shutdown seems to be racy with Simulator app killing.
231             subprocess.call(['xcrun', 'simctl', 'shutdown', udid])
232
233         Simulator.wait_until_device_is_in_state(udid, Simulator.DeviceState.SHUTDOWN)
234
235     @classmethod
236     def delete(cls, udid):
237         """
238         Delete the given CoreSimulator device.
239         :param udid: The udid of the device.
240         :type udid: str
241         """
242         Device.shutdown(udid)
243         try:
244             _log.debug('xcrun simctl delete %s', udid)
245             subprocess.check_call(['xcrun', 'simctl', 'delete', udid])
246         except subprocess.CalledProcessError:
247             raise RuntimeError('"xcrun simctl delete" failed: device state is {}'.format(Simulator.device_state(udid)))
248
249     @classmethod
250     def reset(cls, udid):
251         """
252         Reset the given CoreSimulator device.
253         :param udid: The udid of the device.
254         :type udid: str
255         """
256         Device.shutdown(udid)
257         try:
258             _log.debug('xcrun simctl erase %s', udid)
259             subprocess.check_call(['xcrun', 'simctl', 'erase', udid])
260         except subprocess.CalledProcessError:
261             raise RuntimeError('"xcrun simctl erase" failed: device state is {}'.format(Simulator.device_state(udid)))
262
263     def __eq__(self, other):
264         return self.udid == other.udid
265
266     def __ne__(self, other):
267         return not self.__eq__(other)
268
269     def __repr__(self):
270         return '<Device "{name}": {udid}. State: {state}. Runtime: {runtime}, Available: {available}>'.format(
271             name=self.name,
272             udid=self.udid,
273             state=self.state,
274             available=self.available,
275             runtime=self.runtime.identifier)
276
277
278 # FIXME: This class is fragile because it parses the output of the simctl command line utility, which may change.
279 #        We should find a better way to query for simulator device state and capabilities. Maybe take a similiar
280 #        approach as in webkitdirs.pm and utilize the parsed output from the device.plist files in the sub-
281 #        directories of ~/Library/Developer/CoreSimulator/Devices?
282 #        Also, simctl has the option to output in JSON format (xcrun simctl list --json).
283 class Simulator(object):
284     """
285     Represents the iOS Simulator infrastructure under the currently select Xcode.app bundle.
286     """
287     device_type_re = re.compile('(?P<name>[^(]+)\((?P<identifier>[^)]+)\)')
288     # FIXME: runtime_re parses the version from the runtime name, but that does not contain the full version number
289     # (it can omit the revision). We should instead parse the version from the number contained in parentheses.
290     runtime_re = re.compile(
291         '(i|watch|tv)OS (?P<version>\d+\.\d)(?P<internal> Internal)? \(\d+\.\d+(\.\d+)? - (?P<build_version>[^)]+)\) \((?P<identifier>[^)]+)\)( \((?P<availability>[^)]+)\))?')
292     unavailable_version_re = re.compile('-- Unavailable: (?P<identifier>[^ ]+) --')
293     version_re = re.compile('-- (i|watch|tv)OS (?P<version>\d+\.\d+)(?P<internal> Internal)? --')
294     devices_re = re.compile(
295         '\s*(?P<name>[^(]+ )\((?P<udid>[^)]+)\) \((?P<state>[^)]+)\)( \((?P<availability>[^)]+)\))?')
296
297     _managed_devices = {}
298
299     def __init__(self, host=None):
300         self._host = host or Host()
301         self.runtimes = []
302         self.device_types = []
303         self.refresh()
304
305     # Keep these constants synchronized with the SimDeviceState constants in CoreSimulator/SimDevice.h.
306     class DeviceState:
307         DOES_NOT_EXIST = -1
308         CREATING = 0
309         SHUTDOWN = 1
310         BOOTING = 2
311         BOOTED = 3
312         SHUTTING_DOWN = 4
313
314     NAME_FOR_STATE = [
315         'CREATING',
316         'SHUTDOWN',
317         'BOOTING',
318         'BOOTED',
319         'SHUTTING_DOWN'
320     ]
321
322     @staticmethod
323     def create_device(number, device_type, runtime):
324         device = Simulator().lookup_or_create_device(device_type.name + ' WebKit Tester' + str(number), device_type, runtime)
325         _log.debug('created device {} {}'.format(number, device))
326         assert(len(Simulator._managed_devices) == number)
327         Simulator._managed_devices[number] = device
328
329     @staticmethod
330     def remove_device(number):
331         if not Simulator._managed_devices[number]:
332             return
333         device_udid = Simulator._managed_devices[number].udid
334         _log.debug('removing device {} {}'.format(number, device_udid))
335         del Simulator._managed_devices[number]
336         Simulator.delete_device(device_udid)
337
338     @staticmethod
339     def device_number(number):
340         return Simulator._managed_devices[number]
341
342     @staticmethod
343     def device_state_description(state):
344         if (state == Simulator.DeviceState.DOES_NOT_EXIST):
345             return 'DOES_NOT_EXIST'
346         return Simulator.NAME_FOR_STATE[state]
347
348     @staticmethod
349     def wait_until_device_is_booted(udid, timeout_seconds=60 * 5):
350         Simulator.wait_until_device_is_in_state(udid, Simulator.DeviceState.BOOTED, timeout_seconds)
351         with timeout(seconds=timeout_seconds):
352             while True:
353                 try:
354                     state = subprocess.check_output(['xcrun', 'simctl', 'spawn', udid, 'launchctl', 'print', 'system']).strip()
355                     _log.debug('xcrun simctl spawn %s', udid)
356
357                     if re.search("A[\s]+com.apple.springboard.services", state):
358                         return
359                 except subprocess.CalledProcessError:
360                     if Simulator.device_state(udid) != Simulator.DeviceState.BOOTED:
361                         raise RuntimeError('Simuator device quit unexpectedly.')
362                     _log.warn("Error in checking Simulator boot status. Will retry in 1 second.")
363                 time.sleep(1)
364
365     @staticmethod
366     def wait_until_device_is_in_state(udid, wait_until_state, timeout_seconds=60 * 5):
367         _log.debug('waiting for device %s to enter state %s with timeout %s', udid, Simulator.device_state_description(wait_until_state), timeout_seconds)
368         with timeout(seconds=timeout_seconds):
369             device_state = Simulator.device_state(udid)
370             while (device_state != wait_until_state):
371                 device_state = Simulator.device_state(udid)
372                 _log.debug(' device state %s', Simulator.device_state_description(device_state))
373                 time.sleep(0.5)
374
375         end_state = Simulator.device_state(udid)
376         if (end_state != wait_until_state):
377             raise RuntimeError('Timed out waiting for simulator device to enter state {0}; current state is {1}'.format(Simulator.device_state_description(wait_until_state), Simulator.device_state_description(end_state)))
378
379     @staticmethod
380     def device_state(udid):
381         device_plist = os.path.join(Simulator.device_directory(udid), 'device.plist')
382         if not os.path.isfile(device_plist):
383             return Simulator.DeviceState.DOES_NOT_EXIST
384         return plistlib.readPlist(device_plist)['state']
385
386     @staticmethod
387     def device_directory(udid):
388         return os.path.realpath(os.path.expanduser(os.path.join('~/Library/Developer/CoreSimulator/Devices', udid)))
389
390     @staticmethod
391     def delete_device(udid):
392         Device.delete(udid)
393
394     @staticmethod
395     def reset_device(udid):
396         Device.reset(udid)
397
398     def refresh(self):
399         """
400         Refresh runtime and device type information from ``simctl list``.
401         """
402         lines = self._host.platform.xcode_simctl_list()
403         device_types_header = next(lines)
404         if device_types_header != '== Device Types ==':
405             raise RuntimeError('Expected == Device Types == header but got: "{}"'.format(device_types_header))
406         self._parse_device_types(lines)
407
408     def _parse_device_types(self, lines):
409         """
410         Parse device types from ``simctl list``.
411         :param lines: A generator for the output lines from ``simctl list``.
412         :type lines: genexpr
413         :return: None
414         """
415         for line in lines:
416             device_type_match = self.device_type_re.match(line)
417             if not device_type_match:
418                 if line != '== Runtimes ==':
419                     raise RuntimeError('Expected == Runtimes == header but got: "{}"'.format(line))
420                 break
421             device_type = DeviceType(name=device_type_match.group('name').rstrip(),
422                                      identifier=device_type_match.group('identifier'))
423             self.device_types.append(device_type)
424
425         self._parse_runtimes(lines)
426
427     def _parse_runtimes(self, lines):
428         """
429         Continue to parse runtimes from ``simctl list``.
430         :param lines: A generator for the output lines from ``simctl list``.
431         :type lines: genexpr
432         :return: None
433         """
434         for line in lines:
435             runtime_match = self.runtime_re.match(line)
436             if not runtime_match:
437                 if line != '== Devices ==':
438                     raise RuntimeError('Expected == Devices == header but got: "{}"'.format(line))
439                 break
440             version = tuple(map(int, runtime_match.group('version').split('.')))
441             runtime = Runtime(version=version,
442                               identifier=runtime_match.group('identifier'),
443                               available=runtime_match.group('availability') is None,
444                               is_internal_runtime=bool(runtime_match.group('internal')))
445             self.runtimes.append(runtime)
446         self._parse_devices(lines)
447
448     def _parse_devices(self, lines):
449         """
450         Continue to parse devices from ``simctl list``.
451         :param lines: A generator for the output lines from ``simctl list``.
452         :type lines: genexpr
453         :return: None
454         """
455         current_runtime = None
456         for line in lines:
457             version_match = self.version_re.match(line)
458             if version_match:
459                 version = tuple(map(int, version_match.group('version').split('.')))
460                 current_runtime = self.runtime(version=version, is_internal_runtime=bool(version_match.group('internal')))
461                 assert current_runtime
462                 continue
463
464             unavailable_version_match = self.unavailable_version_re.match(line)
465             if unavailable_version_match:
466                 current_runtime = None
467                 continue
468
469             device_match = self.devices_re.match(line)
470             if not device_match:
471                 if line != '== Device Pairs ==':
472                     raise RuntimeError('Expected == Device Pairs == header but got: "{}"'.format(line))
473                 break
474             if current_runtime:
475                 device = Device(name=device_match.group('name').rstrip(),
476                                 udid=device_match.group('udid'),
477                                 available=device_match.group('availability') is None,
478                                 runtime=current_runtime)
479                 current_runtime.devices.append(device)
480
481     def device_type(self, name=None, identifier=None):
482         """
483         :param name: The short name of the device type.
484         :type name: str
485         :param identifier: The CoreSimulator identifier of the desired device type.
486         :type identifier: str
487         :return: A device type with the specified name and/or identifier, or None if one doesn't exist as such.
488         :rtype: DeviceType
489         """
490         for device_type in self.device_types:
491             if name and device_type.name != name:
492                 continue
493             if identifier and device_type.identifier != identifier:
494                 continue
495             return device_type
496         return None
497
498     def runtime(self, version=None, identifier=None, is_internal_runtime=None):
499         """
500         :param version: The iOS version of the desired runtime.
501         :type version: tuple
502         :param identifier: The CoreSimulator identifier of the desired runtime.
503         :type identifier: str
504         :return: A runtime with the specified version and/or identifier, or None if one doesn't exist as such.
505         :rtype: Runtime or None
506         """
507         if version is None and identifier is None:
508             raise TypeError('Must supply version and/or identifier.')
509
510         for runtime in self.runtimes:
511             if version and runtime.version != version:
512                 continue
513             if is_internal_runtime and runtime.is_internal_runtime != is_internal_runtime:
514                 continue
515             if identifier and runtime.identifier != identifier:
516                 continue
517             return runtime
518         return None
519
520     def find_device_by_udid(self, udid):
521         """
522         :param udid: The UDID of the device to find.
523         :type udid: str
524         :return: The `Device` with the specified UDID.
525         :rtype: Device
526         """
527         for device in self.devices:
528             if device.udid == udid:
529                 return device
530         return None
531
532     # FIXME: We should find an existing device with respect to its name, device type and runtime.
533     def device(self, name=None, runtime=None, should_ignore_unavailable_devices=False):
534         """
535         :param name: The name of the desired device.
536         :type name: str
537         :param runtime: The runtime of the desired device.
538         :type runtime: Runtime
539         :return: A device with the specified name and/or runtime, or None if one doesn't exist as such
540         :rtype: Device or None
541         """
542         if name is None and runtime is None:
543             raise TypeError('Must supply name and/or runtime.')
544
545         for device in self.devices:
546             if should_ignore_unavailable_devices and not device.available:
547                 continue
548             if name and device.name != name:
549                 continue
550             if runtime and device.runtime != runtime:
551                 continue
552             return device
553         return None
554
555     @property
556     def available_runtimes(self):
557         """
558         :return: An iterator of all available runtimes.
559         :rtype: iter
560         """
561         return itertools.ifilter(lambda runtime: runtime.available, self.runtimes)
562
563     @property
564     def devices(self):
565         """
566         :return: An iterator of all devices from all runtimes.
567         :rtype: iter
568         """
569         return itertools.chain(*[runtime.devices for runtime in self.runtimes])
570
571     @property
572     def latest_available_runtime(self):
573         """
574         :return: Returns a Runtime object with the highest version.
575         :rtype: Runtime or None
576         """
577         if not self.runtimes:
578             return None
579         return sorted(self.available_runtimes, key=lambda runtime: runtime.version, reverse=True)[0]
580
581     def lookup_or_create_device(self, name, device_type, runtime):
582         """
583         Returns an available iOS Simulator device for testing.
584
585         This function will create a new simulator device with the specified name,
586         device type and runtime if one does not already exist.
587
588         :param name: The name of the simulator device to lookup or create.
589         :type name: str
590         :param device_type: The CoreSimulator device type.
591         :type device_type: DeviceType
592         :param runtime: The CoreSimulator runtime.
593         :type runtime: Runtime
594         :return: A dictionary describing the device.
595         :rtype: Device
596         """
597         assert(runtime.available)
598         testing_device = self.device(name=name, runtime=runtime, should_ignore_unavailable_devices=True)
599         if testing_device:
600             _log.debug('lookup_or_create_device %s %s %s found %s', name, device_type, runtime, testing_device.name)
601             return testing_device
602         testing_device = Device.create(name, device_type, runtime)
603         _log.debug('lookup_or_create_device %s %s %s created %s', name, device_type, runtime, testing_device.name)
604         assert(testing_device.available)
605         return testing_device
606
607     def __repr__(self):
608         return '<iOS Simulator: {num_runtimes} runtimes, {num_device_types} device types>'.format(
609             num_runtimes=len(self.runtimes),
610             num_device_types=len(self.device_types))
611
612     def __str__(self):
613         description = ['iOS Simulator:']
614         description += map(str, self.runtimes)
615         description += map(str, self.device_types)
616         description += map(str, self.devices)
617         return '\n'.join(description)