Make it possible to use an existing simulator instance for one-off testing
[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         if not lines:
404             return
405         device_types_header = next(lines)
406         if device_types_header != '== Device Types ==':
407             raise RuntimeError('Expected == Device Types == header but got: "{}"'.format(device_types_header))
408         self._parse_device_types(lines)
409
410     def _parse_device_types(self, lines):
411         """
412         Parse device types from ``simctl list``.
413         :param lines: A generator for the output lines from ``simctl list``.
414         :type lines: genexpr
415         :return: None
416         """
417         for line in lines:
418             device_type_match = self.device_type_re.match(line)
419             if not device_type_match:
420                 if line != '== Runtimes ==':
421                     raise RuntimeError('Expected == Runtimes == header but got: "{}"'.format(line))
422                 break
423             device_type = DeviceType(name=device_type_match.group('name').rstrip(),
424                                      identifier=device_type_match.group('identifier'))
425             self.device_types.append(device_type)
426
427         self._parse_runtimes(lines)
428
429     def _parse_runtimes(self, lines):
430         """
431         Continue to parse runtimes from ``simctl list``.
432         :param lines: A generator for the output lines from ``simctl list``.
433         :type lines: genexpr
434         :return: None
435         """
436         for line in lines:
437             runtime_match = self.runtime_re.match(line)
438             if not runtime_match:
439                 if line != '== Devices ==':
440                     raise RuntimeError('Expected == Devices == header but got: "{}"'.format(line))
441                 break
442             version = tuple(map(int, runtime_match.group('version').split('.')))
443             runtime = Runtime(version=version,
444                               identifier=runtime_match.group('identifier'),
445                               available=runtime_match.group('availability') is None,
446                               is_internal_runtime=bool(runtime_match.group('internal')))
447             self.runtimes.append(runtime)
448         self._parse_devices(lines)
449
450     def _parse_devices(self, lines):
451         """
452         Continue to parse devices from ``simctl list``.
453         :param lines: A generator for the output lines from ``simctl list``.
454         :type lines: genexpr
455         :return: None
456         """
457         current_runtime = None
458         for line in lines:
459             version_match = self.version_re.match(line)
460             if version_match:
461                 version = tuple(map(int, version_match.group('version').split('.')))
462                 current_runtime = self.runtime(version=version, is_internal_runtime=bool(version_match.group('internal')))
463                 assert current_runtime
464                 continue
465
466             unavailable_version_match = self.unavailable_version_re.match(line)
467             if unavailable_version_match:
468                 current_runtime = None
469                 continue
470
471             device_match = self.devices_re.match(line)
472             if not device_match:
473                 if line != '== Device Pairs ==':
474                     raise RuntimeError('Expected == Device Pairs == header but got: "{}"'.format(line))
475                 break
476             if current_runtime:
477                 device = Device(name=device_match.group('name').rstrip(),
478                                 udid=device_match.group('udid'),
479                                 available=device_match.group('availability') is None,
480                                 runtime=current_runtime)
481                 current_runtime.devices.append(device)
482
483     def device_type(self, name=None, identifier=None):
484         """
485         :param name: The short name of the device type.
486         :type name: str
487         :param identifier: The CoreSimulator identifier of the desired device type.
488         :type identifier: str
489         :return: A device type with the specified name and/or identifier, or None if one doesn't exist as such.
490         :rtype: DeviceType
491         """
492         for device_type in self.device_types:
493             if name and device_type.name != name:
494                 continue
495             if identifier and device_type.identifier != identifier:
496                 continue
497             return device_type
498         return None
499
500     def runtime(self, version=None, identifier=None, is_internal_runtime=None):
501         """
502         :param version: The iOS version of the desired runtime.
503         :type version: tuple
504         :param identifier: The CoreSimulator identifier of the desired runtime.
505         :type identifier: str
506         :return: A runtime with the specified version and/or identifier, or None if one doesn't exist as such.
507         :rtype: Runtime or None
508         """
509         if version is None and identifier is None:
510             raise TypeError('Must supply version and/or identifier.')
511
512         for runtime in self.runtimes:
513             if version and runtime.version != version:
514                 continue
515             if is_internal_runtime and runtime.is_internal_runtime != is_internal_runtime:
516                 continue
517             if identifier and runtime.identifier != identifier:
518                 continue
519             return runtime
520         return None
521
522     def find_device_by_udid(self, udid):
523         """
524         :param udid: The UDID of the device to find.
525         :type udid: str
526         :return: The `Device` with the specified UDID.
527         :rtype: Device
528         """
529         for device in self.devices:
530             if device.udid == udid:
531                 return device
532         return None
533
534     def current_device(self):
535         # FIXME: Find the simulator device that was booted by Simulator.app. For now, pick some booted simulator device, which
536         # may have been booted using the simctl command line tool.
537         for device in self.devices:
538             if device.state == Simulator.DeviceState.BOOTED:
539                 return device
540         return None
541
542     # FIXME: We should find an existing device with respect to its name, device type and runtime.
543     def device(self, name=None, runtime=None, should_ignore_unavailable_devices=False):
544         """
545         :param name: The name of the desired device.
546         :type name: str
547         :param runtime: The runtime of the desired device.
548         :type runtime: Runtime
549         :return: A device with the specified name and/or runtime, or None if one doesn't exist as such
550         :rtype: Device or None
551         """
552         if name is None and runtime is None:
553             raise TypeError('Must supply name and/or runtime.')
554
555         for device in self.devices:
556             if should_ignore_unavailable_devices and not device.available:
557                 continue
558             if name and device.name != name:
559                 continue
560             if runtime and device.runtime != runtime:
561                 continue
562             return device
563         return None
564
565     @property
566     def available_runtimes(self):
567         """
568         :return: An iterator of all available runtimes.
569         :rtype: iter
570         """
571         return itertools.ifilter(lambda runtime: runtime.available, self.runtimes)
572
573     @property
574     def devices(self):
575         """
576         :return: An iterator of all devices from all runtimes.
577         :rtype: iter
578         """
579         return itertools.chain(*[runtime.devices for runtime in self.runtimes])
580
581     @property
582     def latest_available_runtime(self):
583         """
584         :return: Returns a Runtime object with the highest version.
585         :rtype: Runtime or None
586         """
587         if not self.runtimes:
588             return None
589         return sorted(self.available_runtimes, key=lambda runtime: runtime.version, reverse=True)[0]
590
591     def lookup_or_create_device(self, name, device_type, runtime):
592         """
593         Returns an available iOS Simulator device for testing.
594
595         This function will create a new simulator device with the specified name,
596         device type and runtime if one does not already exist.
597
598         :param name: The name of the simulator device to lookup or create.
599         :type name: str
600         :param device_type: The CoreSimulator device type.
601         :type device_type: DeviceType
602         :param runtime: The CoreSimulator runtime.
603         :type runtime: Runtime
604         :return: A dictionary describing the device.
605         :rtype: Device
606         """
607         assert(runtime.available)
608         testing_device = self.device(name=name, runtime=runtime, should_ignore_unavailable_devices=True)
609         if testing_device:
610             _log.debug('lookup_or_create_device %s %s %s found %s', name, device_type, runtime, testing_device.name)
611             return testing_device
612         testing_device = Device.create(name, device_type, runtime)
613         _log.debug('lookup_or_create_device %s %s %s created %s', name, device_type, runtime, testing_device.name)
614         assert(testing_device.available)
615         return testing_device
616
617     def __repr__(self):
618         return '<iOS Simulator: {num_runtimes} runtimes, {num_device_types} device types>'.format(
619             num_runtimes=len(self.runtimes),
620             num_device_types=len(self.device_types))
621
622     def __str__(self):
623         description = ['iOS Simulator:']
624         description += map(str, self.runtimes)
625         description += map(str, self.device_types)
626         description += map(str, self.devices)
627         return '\n'.join(description)