4e1a9100c0893ec2c9c8f662a155a6816206fef2
[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 class DeviceType(object):
44     """
45     Represents a CoreSimulator device type.
46     """
47     def __init__(self, name, identifier):
48         """
49         :param name: The device type's human-readable name
50         :type name: str
51         :param identifier: The CoreSimulator identifier.
52         :type identifier: str
53         """
54         self.name = name
55         self.identifier = identifier
56
57     @classmethod
58     def from_name(cls, name):
59         """
60         :param name: The name for the desired device type.
61         :type name: str
62         :returns: A `DeviceType` object with the specified identifier or throws a TypeError if it doesn't exist.
63         :rtype: DeviceType
64         """
65         identifier = None
66         for device_type in Simulator().device_types:
67             if device_type.name == name:
68                 identifier = device_type.identifier
69                 break
70
71         if identifier is None:
72             raise TypeError('A device type with name "{name}" does not exist.'.format(name=name))
73
74         return DeviceType(name, identifier)
75
76     @classmethod
77     def from_identifier(cls, identifier):
78         """
79         :param identifier: The CoreSimulator identifier for the desired runtime.
80         :type identifier: str
81         :returns: A `Runtime` object witht the specified identifier or throws a TypeError if it doesn't exist.
82         :rtype: DeviceType
83         """
84         name = None
85         for device_type in Simulator().device_types:
86             if device_type.identifier == identifier:
87                 name = device_type.name
88                 break
89
90         if name is None:
91             raise TypeError('A device type with identifier "{identifier}" does not exist.'.format(
92                 identifier=identifier))
93
94         return DeviceType(name, identifier)
95
96     def __eq__(self, other):
97         return (self.name == other.name) and (self.identifier == other.identifier)
98
99     def __ne__(self, other):
100         return not self.__eq__(other)
101
102     def __repr__(self):
103         return '<DeviceType "{name}": {identifier}>'.format(name=self.name, identifier=self.identifier)
104
105
106 class Runtime(object):
107     """
108     Represents a CoreSimulator runtime associated with an iOS SDK.
109     """
110
111     def __init__(self, version, identifier, available, devices=None, is_internal_runtime=False):
112         """
113         :param version: The iOS SDK version
114         :type version: tuple
115         :param identifier: The CoreSimulator runtime identifier
116         :type identifier: str
117         :param availability: Whether the runtime is available for use.
118         :type availability: bool
119         :param devices: A list of devices under this runtime
120         :type devices: list or None
121         :param is_internal_runtime: Whether the runtime is an Apple internal runtime
122         :type is_internal_runtime: bool
123         """
124         self.version = version
125         self.identifier = identifier
126         self.available = available
127         self.devices = devices or []
128         self.is_internal_runtime = is_internal_runtime
129
130     @classmethod
131     def from_version_string(cls, version):
132         return cls.from_identifier('com.apple.CoreSimulator.SimRuntime.iOS-' + version.replace('.', '-'))
133
134     @classmethod
135     def from_identifier(cls, identifier):
136         """
137         :param identifier: The identifier for the desired CoreSimulator runtime.
138         :type identifier: str
139         :returns: A `Runtime` object with the specified identifier or throws a TypeError if it doesn't exist.
140         :rtype: Runtime
141         """
142         for runtime in Simulator().runtimes:
143             if runtime.identifier == identifier:
144                 return runtime
145         raise TypeError('A runtime with identifier "{identifier}" does not exist.'.format(identifier=identifier))
146
147     def __eq__(self, other):
148         return (self.version == other.version) and (self.identifier == other.identifier) and (self.is_internal_runtime == other.is_internal_runtime)
149
150     def __ne__(self, other):
151         return not self.__eq__(other)
152
153     def __repr__(self):
154         version_suffix = ""
155         if self.is_internal_runtime:
156             version_suffix = " Internal"
157         return '<Runtime {version}: {identifier}. Available: {available}, {num_devices} devices>'.format(
158             version='.'.join(map(str, self.version)) + version_suffix,
159             identifier=self.identifier,
160             available=self.available,
161             num_devices=len(self.devices))
162
163
164 class Device(object):
165     """
166     Represents a CoreSimulator device underneath a runtime
167     """
168
169     def __init__(self, name, udid, available, runtime):
170         """
171         :param name: The device name
172         :type name: str
173         :param udid: The device UDID (a UUID string)
174         :type udid: str
175         :param available: Whether the device is available for use.
176         :type available: bool
177         :param runtime: The iOS Simulator runtime that hosts this device
178         :type runtime: Runtime
179         """
180         self.name = name
181         self.udid = udid
182         self.available = available
183         self.runtime = runtime
184
185     @property
186     def state(self):
187         """
188         :returns: The current state of the device.
189         :rtype: Simulator.DeviceState
190         """
191         return Simulator.device_state(self.udid)
192
193     @property
194     def path(self):
195         """
196         :returns: The filesystem path that contains the simulator device's data.
197         :rtype: str
198         """
199         return Simulator.device_directory(self.udid)
200
201     @classmethod
202     def create(cls, name, device_type, runtime):
203         """
204         Create a new CoreSimulator device.
205         :param name: The name of the device.
206         :type name: str
207         :param device_type: The CoreSimulatort device type.
208         :type device_type: DeviceType
209         :param runtime:  The CoreSimualtor runtime.
210         :type runtime: Runtime
211         :return: The new device or raises a CalledProcessError if ``simctl create`` failed.
212         :rtype: Device
213         """
214         device_udid = subprocess.check_output(['xcrun', 'simctl', 'create', name, device_type.identifier, runtime.identifier]).rstrip()
215         Simulator.wait_until_device_is_in_state(device_udid, Simulator.DeviceState.SHUTDOWN)
216         return Simulator().find_device_by_udid(device_udid)
217
218     @classmethod
219     def delete(cls, udid):
220         """
221         Delete the given CoreSimulator device.
222         :param udid: The udid of the device.
223         :type udid: str
224         """
225         subprocess.call(['xcrun', 'simctl', 'delete', udid])
226
227     def __eq__(self, other):
228         return self.udid == other.udid
229
230     def __ne__(self, other):
231         return not self.__eq__(other)
232
233     def __repr__(self):
234         return '<Device "{name}": {udid}. State: {state}. Runtime: {runtime}, Available: {available}>'.format(
235             name=self.name,
236             udid=self.udid,
237             state=self.state,
238             available=self.available,
239             runtime=self.runtime.identifier)
240
241
242 # FIXME: This class is fragile because it parses the output of the simctl command line utility, which may change.
243 #        We should find a better way to query for simulator device state and capabilities. Maybe take a similiar
244 #        approach as in webkitdirs.pm and utilize the parsed output from the device.plist files in the sub-
245 #        directories of ~/Library/Developer/CoreSimulator/Devices?
246 #        Also, simctl has the option to output in JSON format (xcrun simctl list --json).
247 class Simulator(object):
248     """
249     Represents the iOS Simulator infrastructure under the currently select Xcode.app bundle.
250     """
251     device_type_re = re.compile('(?P<name>[^(]+)\((?P<identifier>[^)]+)\)')
252     # FIXME: runtime_re parses the version from the runtime name, but that does not contain the full version number
253     # (it can omit the revision). We should instead parse the version from the number contained in parentheses.
254     runtime_re = re.compile(
255         '(i|watch|tv)OS (?P<version>\d+\.\d)(?P<internal> Internal)? \(\d+\.\d+(\.\d+)? - (?P<build_version>[^)]+)\) \((?P<identifier>[^)]+)\)( \((?P<availability>[^)]+)\))?')
256     unavailable_version_re = re.compile('-- Unavailable: (?P<identifier>[^ ]+) --')
257     version_re = re.compile('-- (i|watch|tv)OS (?P<version>\d+\.\d+)(?P<internal> Internal)? --')
258     devices_re = re.compile(
259         '\s*(?P<name>[^(]+ )\((?P<udid>[^)]+)\) \((?P<state>[^)]+)\)( \((?P<availability>[^)]+)\))?')
260
261     def __init__(self, host=None):
262         self._host = host or Host()
263         self.runtimes = []
264         self.device_types = []
265         self.refresh()
266
267     # Keep these constants synchronized with the SimDeviceState constants in CoreSimulator/SimDevice.h.
268     class DeviceState:
269         DOES_NOT_EXIST = -1
270         CREATING = 0
271         SHUTDOWN = 1
272         BOOTING = 2
273         BOOTED = 3
274         SHUTTING_DOWN = 4
275
276     @staticmethod
277     def wait_until_device_is_booted(udid, timeout_seconds=60 * 5):
278         with timeout(seconds=timeout_seconds):
279             while True:
280                 state = subprocess.check_output(['xcrun', 'simctl', 'spawn', udid, 'launchctl', 'print', 'system']).strip()
281                 if re.search("A[\s]+com.apple.springboard.services", state):
282                     return
283                 time.sleep(1)
284
285     @staticmethod
286     def wait_until_device_is_in_state(udid, wait_until_state, timeout_seconds=60 * 5):
287         with timeout(seconds=timeout_seconds):
288             while (Simulator.device_state(udid) != wait_until_state):
289                 time.sleep(0.5)
290
291     @staticmethod
292     def device_state(udid):
293         device_plist = os.path.join(Simulator.device_directory(udid), 'device.plist')
294         if not os.path.isfile(device_plist):
295             return Simulator.DeviceState.DOES_NOT_EXIST
296         return plistlib.readPlist(device_plist)['state']
297
298     @staticmethod
299     def device_directory(udid):
300         return os.path.realpath(os.path.expanduser(os.path.join('~/Library/Developer/CoreSimulator/Devices', udid)))
301
302     def delete_device(self, udid):
303         Simulator.wait_until_device_is_in_state(udid, Simulator.DeviceState.SHUTDOWN)
304         Device.delete(udid)
305
306     def refresh(self):
307         """
308         Refresh runtime and device type information from ``simctl list``.
309         """
310         lines = self._host.platform.xcode_simctl_list()
311         device_types_header = next(lines)
312         if device_types_header != '== Device Types ==':
313             raise RuntimeError('Expected == Device Types == header but got: "{}"'.format(device_types_header))
314         self._parse_device_types(lines)
315
316     def _parse_device_types(self, lines):
317         """
318         Parse device types from ``simctl list``.
319         :param lines: A generator for the output lines from ``simctl list``.
320         :type lines: genexpr
321         :return: None
322         """
323         for line in lines:
324             device_type_match = self.device_type_re.match(line)
325             if not device_type_match:
326                 if line != '== Runtimes ==':
327                     raise RuntimeError('Expected == Runtimes == header but got: "{}"'.format(line))
328                 break
329             device_type = DeviceType(name=device_type_match.group('name').rstrip(),
330                                      identifier=device_type_match.group('identifier'))
331             self.device_types.append(device_type)
332
333         self._parse_runtimes(lines)
334
335     def _parse_runtimes(self, lines):
336         """
337         Continue to parse runtimes from ``simctl list``.
338         :param lines: A generator for the output lines from ``simctl list``.
339         :type lines: genexpr
340         :return: None
341         """
342         for line in lines:
343             runtime_match = self.runtime_re.match(line)
344             if not runtime_match:
345                 if line != '== Devices ==':
346                     raise RuntimeError('Expected == Devices == header but got: "{}"'.format(line))
347                 break
348             version = tuple(map(int, runtime_match.group('version').split('.')))
349             runtime = Runtime(version=version,
350                               identifier=runtime_match.group('identifier'),
351                               available=runtime_match.group('availability') is None,
352                               is_internal_runtime=bool(runtime_match.group('internal')))
353             self.runtimes.append(runtime)
354         self._parse_devices(lines)
355
356     def _parse_devices(self, lines):
357         """
358         Continue to parse devices from ``simctl list``.
359         :param lines: A generator for the output lines from ``simctl list``.
360         :type lines: genexpr
361         :return: None
362         """
363         current_runtime = None
364         for line in lines:
365             version_match = self.version_re.match(line)
366             if version_match:
367                 version = tuple(map(int, version_match.group('version').split('.')))
368                 current_runtime = self.runtime(version=version, is_internal_runtime=bool(version_match.group('internal')))
369                 assert current_runtime
370                 continue
371
372             unavailable_version_match = self.unavailable_version_re.match(line)
373             if unavailable_version_match:
374                 current_runtime = None
375                 continue
376
377             device_match = self.devices_re.match(line)
378             if not device_match:
379                 if line != '== Device Pairs ==':
380                     raise RuntimeError('Expected == Device Pairs == header but got: "{}"'.format(line))
381                 break
382             if current_runtime:
383                 device = Device(name=device_match.group('name').rstrip(),
384                                 udid=device_match.group('udid'),
385                                 available=device_match.group('availability') is None,
386                                 runtime=current_runtime)
387                 current_runtime.devices.append(device)
388
389     def device_type(self, name=None, identifier=None):
390         """
391         :param name: The short name of the device type.
392         :type name: str
393         :param identifier: The CoreSimulator identifier of the desired device type.
394         :type identifier: str
395         :return: A device type with the specified name and/or identifier, or None if one doesn't exist as such.
396         :rtype: DeviceType
397         """
398         for device_type in self.device_types:
399             if name and device_type.name != name:
400                 continue
401             if identifier and device_type.identifier != identifier:
402                 continue
403             return device_type
404         return None
405
406     def runtime(self, version=None, identifier=None, is_internal_runtime=None):
407         """
408         :param version: The iOS version of the desired runtime.
409         :type version: tuple
410         :param identifier: The CoreSimulator identifier of the desired runtime.
411         :type identifier: str
412         :return: A runtime with the specified version and/or identifier, or None if one doesn't exist as such.
413         :rtype: Runtime or None
414         """
415         if version is None and identifier is None:
416             raise TypeError('Must supply version and/or identifier.')
417
418         for runtime in self.runtimes:
419             if version and runtime.version != version:
420                 continue
421             if is_internal_runtime and runtime.is_internal_runtime != is_internal_runtime:
422                 continue
423             if identifier and runtime.identifier != identifier:
424                 continue
425             return runtime
426         return None
427
428     def find_device_by_udid(self, udid):
429         """
430         :param udid: The UDID of the device to find.
431         :type udid: str
432         :return: The `Device` with the specified UDID.
433         :rtype: Device
434         """
435         for device in self.devices:
436             if device.udid == udid:
437                 return device
438         return None
439
440     # FIXME: We should find an existing device with respect to its name, device type and runtime.
441     def device(self, name=None, runtime=None, should_ignore_unavailable_devices=False):
442         """
443         :param name: The name of the desired device.
444         :type name: str
445         :param runtime: The runtime of the desired device.
446         :type runtime: Runtime
447         :return: A device with the specified name and/or runtime, or None if one doesn't exist as such
448         :rtype: Device or None
449         """
450         if name is None and runtime is None:
451             raise TypeError('Must supply name and/or runtime.')
452
453         for device in self.devices:
454             if should_ignore_unavailable_devices and not device.available:
455                 continue
456             if name and device.name != name:
457                 continue
458             if runtime and device.runtime != runtime:
459                 continue
460             return device
461         return None
462
463     @property
464     def available_runtimes(self):
465         """
466         :return: An iterator of all available runtimes.
467         :rtype: iter
468         """
469         return itertools.ifilter(lambda runtime: runtime.available, self.runtimes)
470
471     @property
472     def devices(self):
473         """
474         :return: An iterator of all devices from all runtimes.
475         :rtype: iter
476         """
477         return itertools.chain(*[runtime.devices for runtime in self.runtimes])
478
479     @property
480     def latest_available_runtime(self):
481         """
482         :return: Returns a Runtime object with the highest version.
483         :rtype: Runtime or None
484         """
485         if not self.runtimes:
486             return None
487         return sorted(self.available_runtimes, key=lambda runtime: runtime.version, reverse=True)[0]
488
489     def lookup_or_create_device(self, name, device_type, runtime):
490         """
491         Returns an available iOS Simulator device for testing.
492
493         This function will create a new simulator device with the specified name,
494         device type and runtime if one does not already exist.
495
496         :param name: The name of the simulator device to lookup or create.
497         :type name: str
498         :param device_type: The CoreSimulator device type.
499         :type device_type: DeviceType
500         :param runtime: The CoreSimulator runtime.
501         :type runtime: Runtime
502         :return: A dictionary describing the device.
503         :rtype: Device
504         """
505         assert(runtime.available)
506         testing_device = self.device(name=name, runtime=runtime, should_ignore_unavailable_devices=True)
507         if testing_device:
508             return testing_device
509         testing_device = Device.create(name, device_type, runtime)
510         assert(testing_device.available)
511         return testing_device
512
513     def __repr__(self):
514         return '<iOS Simulator: {num_runtimes} runtimes, {num_device_types} device types>'.format(
515             num_runtimes=len(self.runtimes),
516             num_device_types=len(self.device_types))
517
518     def __str__(self):
519         description = ['iOS Simulator:']
520         description += map(str, self.runtimes)
521         description += map(str, self.device_types)
522         description += map(str, self.devices)
523         return '\n'.join(description)