OLD | NEW |
1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
2 # Copyright 2017 The Chromium Authors. All rights reserved. | 2 # Copyright 2017 The Chromium Authors. All rights reserved. |
3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
5 | 5 |
6 # This is intended to be a very trimmed down, single-file, hackable, and easy | 6 # This is intended to be a very trimmed down, single-file, hackable, and easy |
7 # to understand version of Telemetry. It's able to run simple user stories on | 7 # to understand version of Telemetry. It's able to run simple user stories on |
8 # Android, grab traces, and extract metrics from them. May be useful to | 8 # Android, grab traces, and extract metrics from them. May be useful to |
9 # diagnose issues with Chrome, reproduce regressions or prototype new user | 9 # diagnose issues with Chrome, reproduce regressions or prototype new user |
10 # stories. | 10 # stories. |
(...skipping 12 matching lines...) Expand all Loading... |
23 import posixpath | 23 import posixpath |
24 import re | 24 import re |
25 import socket | 25 import socket |
26 import subprocess | 26 import subprocess |
27 import tempfile | 27 import tempfile |
28 import time | 28 import time |
29 import websocket # pylint: disable=import-error | 29 import websocket # pylint: disable=import-error |
30 from xml.etree import ElementTree as element_tree | 30 from xml.etree import ElementTree as element_tree |
31 | 31 |
32 | 32 |
| 33 KEYCODE_BACK = 4 |
| 34 |
33 # Parse rectangle bounds given as: '[left,top][right,bottom]'. | 35 # Parse rectangle bounds given as: '[left,top][right,bottom]'. |
34 RE_BOUNDS = re.compile( | 36 RE_BOUNDS = re.compile( |
35 r'\[(?P<left>\d+),(?P<top>\d+)\]\[(?P<right>\d+),(?P<bottom>\d+)\]') | 37 r'\[(?P<left>\d+),(?P<top>\d+)\]\[(?P<right>\d+),(?P<bottom>\d+)\]') |
36 | 38 |
37 # TODO: Maybe replace with a true on-device temp file. | 39 # TODO: Maybe replace with a true on-device temp file. |
38 UI_DUMP_TEMP = '/data/local/tmp/tm_ui_dump.xml' | 40 UI_DUMP_TEMP = '/data/local/tmp/tm_ui_dump.xml' |
39 | 41 |
40 | 42 |
41 def RetryOn(exc_type=(), returns_falsy=False, retries=5): | 43 def RetryOn(exc_type=(), returns_falsy=False, retries=5): |
42 """Decorator to retry a function in case of errors or falsy values. | 44 """Decorator to retry a function in case of errors or falsy values. |
(...skipping 92 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
135 row = line.split(None, 8) | 137 row = line.split(None, 8) |
136 try: | 138 try: |
137 pid = int(row[1]) | 139 pid = int(row[1]) |
138 process_name = row[-1] | 140 process_name = row[-1] |
139 except StandardError: | 141 except StandardError: |
140 continue | 142 continue |
141 result[process_name].append(pid) | 143 result[process_name].append(pid) |
142 return result | 144 return result |
143 | 145 |
144 @RetryOn(AdbCommandError) | 146 @RetryOn(AdbCommandError) |
145 def GetUiDump(self): | 147 def GetUiScreenDump(self): |
146 """Return the root XML node with screen captured from the device.""" | 148 """Return the root XML node with screen captured from the device.""" |
147 self.RunShellCommand('rm', '-f', UI_DUMP_TEMP) | 149 self.RunShellCommand('rm', '-f', UI_DUMP_TEMP) |
148 output = self.RunShellCommand('uiautomator', 'dump', UI_DUMP_TEMP).strip() | 150 output = self.RunShellCommand('uiautomator', 'dump', UI_DUMP_TEMP).strip() |
149 | 151 |
150 if output.startswith('ERROR:'): | 152 if output.startswith('ERROR:'): |
151 # uiautomator may fail if device is not in idle state, e.g. animations | 153 # uiautomator may fail if device is not in idle state, e.g. animations |
152 # or video playing. Retry if that's the case. | 154 # or video playing. Retry if that's the case. |
153 raise AdbCommandError(output) | 155 raise AdbCommandError(output) |
154 | 156 |
155 with tempfile.NamedTemporaryFile(suffix='.xml') as f: | 157 with tempfile.NamedTemporaryFile(suffix='.xml') as f: |
156 f.close() | 158 f.close() |
157 self.RunCommand('pull', UI_DUMP_TEMP, f.name) | 159 self.RunCommand('pull', UI_DUMP_TEMP, f.name) |
158 return element_tree.parse(f.name) | 160 return element_tree.parse(f.name) |
159 | 161 |
160 @RetryOn(LookupError) | 162 @RetryOn(LookupError) |
161 def FindUiNode(self, attr_values): | 163 def FindUiElement(self, attr_values): |
162 """Find a UI node on screen capture, retrying if not yet visible.""" | 164 """Find a UI element on screen capture, retrying if not yet visible.""" |
163 root = self.GetUiDump() | 165 root = self.GetUiScreenDump() |
164 for node in root.iter(): | 166 for node in root.iter(): |
165 if all(node.get(k) == v for k, v in attr_values): | 167 if all(node.get(k) == v for k, v in attr_values): |
166 return node | 168 return node |
167 raise LookupError('Specified UI node not found') | 169 raise LookupError('Specified UI element not found') |
168 | 170 |
169 def TapUiNode(self, *args, **kwargs): | 171 def TapUiElement(self, *args, **kwargs): |
170 node = self.FindUiNode(*args, **kwargs) | 172 node = self.FindUiElement(*args, **kwargs) |
171 m = RE_BOUNDS.match(node.get('bounds')) | 173 m = RE_BOUNDS.match(node.get('bounds')) |
172 left, top, right, bottom = (int(v) for v in m.groups()) | 174 left, top, right, bottom = (int(v) for v in m.groups()) |
173 x, y = (left + right) / 2, (top + bottom) / 2 | 175 x, y = (left + right) / 2, (top + bottom) / 2 |
174 self.RunShellCommand('input', 'tap', str(x), str(y)) | 176 self.RunShellCommand('input', 'tap', str(x), str(y)) |
175 | 177 |
176 | 178 |
| 179 def _UserAction(f): |
| 180 """Decorator to add repeat, and action_delay options to user action methods. |
| 181 |
| 182 Note: The values (or their defaults) supplied for these extra options will |
| 183 also be passed down to the decorated method. It's thus advisable to collect |
| 184 them in a catch-all **kwargs, even if just to discard them immediately. |
| 185 |
| 186 This is a workaround for https://github.com/PyCQA/pylint/issues/258 in which |
| 187 decorators confuse pylint and trigger spurious 'unexpected-keyword-arg' |
| 188 warnings on method calls that use the extra options. |
| 189 """ |
| 190 @functools.wraps(f) |
| 191 def Wrapper(self, *args, **kwargs): |
| 192 repeat = kwargs.setdefault('repeat', 1) |
| 193 action_delay = kwargs.setdefault('action_delay', None) |
| 194 for _ in xrange(repeat): |
| 195 f(self, *args, **kwargs) |
| 196 self.Idle(action_delay) |
| 197 return Wrapper |
| 198 |
| 199 |
| 200 class AndroidActions(object): |
| 201 def __init__(self, device, user_action_delay=1): |
| 202 self.device = device |
| 203 self.user_action_delay = user_action_delay |
| 204 |
| 205 def Idle(self, duration=None): |
| 206 if duration is None: |
| 207 duration = self.user_action_delay |
| 208 if duration: |
| 209 time.sleep(duration) |
| 210 |
| 211 @_UserAction |
| 212 def GoBack(self, **kwargs): |
| 213 del kwargs |
| 214 self.device.RunShellCommand('input', 'keyevent', str(KEYCODE_BACK)) |
| 215 |
| 216 @_UserAction |
| 217 def StartActivity( |
| 218 self, data_uri, action='android.intent.action.VIEW', **kwargs): |
| 219 del kwargs |
| 220 self.device.RunShellCommand('am', 'start', '-a', action, '-d', data_uri) |
| 221 |
| 222 @_UserAction |
| 223 def TapUiElement(self, attr_values, **kwargs): |
| 224 del kwargs |
| 225 self.device.TapUiElement(attr_values) |
| 226 |
| 227 @_UserAction |
| 228 def SwipeUp(self, **kwargs): |
| 229 del kwargs |
| 230 # Hardcoded values for 480x854 screen size; should work reasonably on |
| 231 # other screen sizes. |
| 232 # Command args: swipe <x1> <y1> <x2> <y2> [duration(ms)] |
| 233 self.device.RunShellCommand( |
| 234 'input', 'swipe', '240', '568', '240', '284', '400') |
| 235 |
| 236 |
177 class DevToolsWebSocket(object): | 237 class DevToolsWebSocket(object): |
178 def __init__(self, url): | 238 def __init__(self, url): |
179 self._url = url | 239 self._url = url |
180 self._socket = None | 240 self._socket = None |
181 self._cmdid = 0 | 241 self._cmdid = 0 |
182 | 242 |
183 def __enter__(self): | 243 def __enter__(self): |
184 self.Open() | 244 self.Open() |
185 return self | 245 return self |
186 | 246 |
(...skipping 200 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
387 def Uninstall(self): | 447 def Uninstall(self): |
388 # System Chrome app cannot be (un)installed, so we enable/disable instead. | 448 # System Chrome app cannot be (un)installed, so we enable/disable instead. |
389 logging.warning('Disabling %s', self.PACKAGE_NAME) | 449 logging.warning('Disabling %s', self.PACKAGE_NAME) |
390 self.device.RunShellCommand('pm', 'disable', self.PACKAGE_NAME) | 450 self.device.RunShellCommand('pm', 'disable', self.PACKAGE_NAME) |
391 | 451 |
392 | 452 |
393 class UserStory(object): | 453 class UserStory(object): |
394 def __init__(self, browser): | 454 def __init__(self, browser): |
395 self.device = browser.device | 455 self.device = browser.device |
396 self.browser = browser | 456 self.browser = browser |
| 457 self.actions = AndroidActions(self.device) |
397 | 458 |
398 def GetExtraStoryApps(self): | 459 def GetExtraStoryApps(self): |
399 """Sequence of AndroidApp's, other than the browser, used in the story.""" | 460 """Sequence of AndroidApp's, other than the browser, used in the story.""" |
400 return () | 461 return () |
401 | 462 |
402 def EnsureExtraStoryAppsClosed(self): | 463 def EnsureExtraStoryAppsClosed(self): |
403 running_processes = self.device.ProcessStatus() | 464 running_processes = self.device.ProcessStatus() |
404 for app in self.GetExtraStoryApps(): | 465 for app in self.GetExtraStoryApps(): |
405 if app.PACKAGE_NAME in running_processes: | 466 if app.PACKAGE_NAME in running_processes: |
406 app.ForceStop() | 467 app.ForceStop() |
(...skipping 32 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
439 if event['ph'] == 'v': | 500 if event['ph'] == 'v': |
440 # Extract any metrics you may need from the trace. | 501 # Extract any metrics you may need from the trace. |
441 value = event['args']['dumps']['allocators'][ | 502 value = event['args']['dumps']['allocators'][ |
442 'java_heap/allocated_objects']['attrs']['size'] | 503 'java_heap/allocated_objects']['attrs']['size'] |
443 assert value['units'] == 'bytes' | 504 assert value['units'] == 'bytes' |
444 processes[event['pid']]['java_heap'] = int(value['value'], 16) | 505 processes[event['pid']]['java_heap'] = int(value['value'], 16) |
445 elif event['ph'] == 'M' and event['name'] == 'process_name': | 506 elif event['ph'] == 'M' and event['name'] == 'process_name': |
446 processes[event['pid']]['name'] = event['args']['name'] | 507 processes[event['pid']]['name'] = event['args']['name'] |
447 | 508 |
448 return processes.values() | 509 return processes.values() |
OLD | NEW |