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_HOME = 3 |
33 KEYCODE_BACK = 4 | 34 KEYCODE_BACK = 4 |
| 35 KEYCODE_APP_SWITCH = 187 |
34 | 36 |
35 # Parse rectangle bounds given as: '[left,top][right,bottom]'. | 37 # Parse rectangle bounds given as: '[left,top][right,bottom]'. |
36 RE_BOUNDS = re.compile( | 38 RE_BOUNDS = re.compile( |
37 r'\[(?P<left>\d+),(?P<top>\d+)\]\[(?P<right>\d+),(?P<bottom>\d+)\]') | 39 r'\[(?P<left>\d+),(?P<top>\d+)\]\[(?P<right>\d+),(?P<bottom>\d+)\]') |
38 | 40 |
39 # TODO: Maybe replace with a true on-device temp file. | 41 # TODO: Maybe replace with a true on-device temp file. |
40 UI_DUMP_TEMP = '/data/local/tmp/tm_ui_dump.xml' | 42 UI_DUMP_TEMP = '/data/local/tmp/tm_ui_dump.xml' |
41 | 43 |
42 | 44 |
43 def RetryOn(exc_type=(), returns_falsy=False, retries=5): | 45 def RetryOn(exc_type=(), returns_falsy=False, retries=5): |
(...skipping 108 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
152 if output.startswith('ERROR:'): | 154 if output.startswith('ERROR:'): |
153 # uiautomator may fail if device is not in idle state, e.g. animations | 155 # uiautomator may fail if device is not in idle state, e.g. animations |
154 # or video playing. Retry if that's the case. | 156 # or video playing. Retry if that's the case. |
155 raise AdbCommandError(output) | 157 raise AdbCommandError(output) |
156 | 158 |
157 with tempfile.NamedTemporaryFile(suffix='.xml') as f: | 159 with tempfile.NamedTemporaryFile(suffix='.xml') as f: |
158 f.close() | 160 f.close() |
159 self.RunCommand('pull', UI_DUMP_TEMP, f.name) | 161 self.RunCommand('pull', UI_DUMP_TEMP, f.name) |
160 return element_tree.parse(f.name) | 162 return element_tree.parse(f.name) |
161 | 163 |
162 @RetryOn(LookupError) | 164 def HasUiElement(self, attr_values): |
163 def FindUiElement(self, attr_values): | 165 """Check whether a UI element is visible on the screen.""" |
164 """Find a UI element on screen capture, retrying if not yet visible.""" | |
165 root = self.GetUiScreenDump() | 166 root = self.GetUiScreenDump() |
166 for node in root.iter(): | 167 for node in root.iter(): |
167 if all(node.get(k) == v for k, v in attr_values): | 168 if all(node.get(k) == v for k, v in attr_values): |
168 return node | 169 return node |
169 raise LookupError('Specified UI element not found') | 170 return None |
| 171 |
| 172 @RetryOn(LookupError) |
| 173 def FindUiElement(self, *args, **kwargs): |
| 174 """Find a UI element on the screen, retrying if not yet visible.""" |
| 175 node = self.HasUiElement(*args, **kwargs) |
| 176 if node is None: |
| 177 raise LookupError('Specified UI element not found') |
| 178 return node |
170 | 179 |
171 def TapUiElement(self, *args, **kwargs): | 180 def TapUiElement(self, *args, **kwargs): |
| 181 """Tap on a UI element found on screen.""" |
172 node = self.FindUiElement(*args, **kwargs) | 182 node = self.FindUiElement(*args, **kwargs) |
173 m = RE_BOUNDS.match(node.get('bounds')) | 183 m = RE_BOUNDS.match(node.get('bounds')) |
174 left, top, right, bottom = (int(v) for v in m.groups()) | 184 left, top, right, bottom = (int(v) for v in m.groups()) |
175 x, y = (left + right) / 2, (top + bottom) / 2 | 185 x, y = (left + right) / 2, (top + bottom) / 2 |
176 self.RunShellCommand('input', 'tap', str(x), str(y)) | 186 self.RunShellCommand('input', 'tap', str(x), str(y)) |
177 | 187 |
178 | 188 |
179 def _UserAction(f): | 189 def _UserAction(f): |
180 """Decorator to add repeat, and action_delay options to user action methods. | 190 """Decorator to add repeat, and action_delay options to user action methods. |
181 | 191 |
182 Note: The values (or their defaults) supplied for these extra options will | 192 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 | 193 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. | 194 them in a catch-all **kwargs, even if just to discard them immediately. |
185 | 195 |
186 This is a workaround for https://github.com/PyCQA/pylint/issues/258 in which | 196 This is a workaround for https://github.com/PyCQA/pylint/issues/258 in which |
187 decorators confuse pylint and trigger spurious 'unexpected-keyword-arg' | 197 decorators confuse pylint and trigger spurious 'unexpected-keyword-arg' |
188 warnings on method calls that use the extra options. | 198 warnings on method calls that use the extra options. |
189 """ | 199 """ |
190 @functools.wraps(f) | 200 @functools.wraps(f) |
191 def Wrapper(self, *args, **kwargs): | 201 def Wrapper(self, *args, **kwargs): |
192 repeat = kwargs.setdefault('repeat', 1) | 202 repeat = kwargs.setdefault('repeat', 1) |
193 action_delay = kwargs.setdefault('action_delay', None) | 203 action_delay = kwargs.setdefault('action_delay', None) |
194 for _ in xrange(repeat): | 204 for _ in xrange(repeat): |
195 f(self, *args, **kwargs) | 205 f(self, *args, **kwargs) |
196 self.Idle(action_delay) | 206 self.Idle(action_delay) |
197 return Wrapper | 207 return Wrapper |
198 | 208 |
199 | 209 |
200 class AndroidActions(object): | 210 class AndroidActions(object): |
| 211 APP_SWITCHER_CLEAR_ALL = [ |
| 212 ('resource-id', 'com.android.systemui:id/button'), |
| 213 ('text', 'CLEAR ALL')] |
| 214 APP_SWITCHER_NO_RECENT = [ |
| 215 ('package', 'com.android.systemui'), |
| 216 ('text', 'No recent items')] |
| 217 |
201 def __init__(self, device, user_action_delay=1): | 218 def __init__(self, device, user_action_delay=1): |
202 self.device = device | 219 self.device = device |
203 self.user_action_delay = user_action_delay | 220 self.user_action_delay = user_action_delay |
204 | 221 |
205 def Idle(self, duration=None): | 222 def Idle(self, duration=None): |
206 if duration is None: | 223 if duration is None: |
207 duration = self.user_action_delay | 224 duration = self.user_action_delay |
208 if duration: | 225 if duration: |
209 time.sleep(duration) | 226 time.sleep(duration) |
210 | 227 |
211 @_UserAction | 228 @_UserAction |
| 229 def GoHome(self, **kwargs): |
| 230 del kwargs |
| 231 self.device.RunShellCommand('input', 'keyevent', str(KEYCODE_HOME)) |
| 232 |
| 233 @_UserAction |
212 def GoBack(self, **kwargs): | 234 def GoBack(self, **kwargs): |
213 del kwargs | 235 del kwargs |
214 self.device.RunShellCommand('input', 'keyevent', str(KEYCODE_BACK)) | 236 self.device.RunShellCommand('input', 'keyevent', str(KEYCODE_BACK)) |
215 | 237 |
216 @_UserAction | 238 @_UserAction |
| 239 def GoAppSwitcher(self, **kwargs): |
| 240 del kwargs |
| 241 self.device.RunShellCommand('input', 'keyevent', str(KEYCODE_APP_SWITCH)) |
| 242 |
| 243 @_UserAction |
217 def StartActivity( | 244 def StartActivity( |
218 self, data_uri, action='android.intent.action.VIEW', **kwargs): | 245 self, data_uri, action='android.intent.action.VIEW', **kwargs): |
219 del kwargs | 246 del kwargs |
220 self.device.RunShellCommand('am', 'start', '-a', action, '-d', data_uri) | 247 self.device.RunShellCommand('am', 'start', '-a', action, '-d', data_uri) |
221 | 248 |
222 @_UserAction | 249 @_UserAction |
223 def TapUiElement(self, attr_values, **kwargs): | 250 def TapUiElement(self, attr_values, **kwargs): |
224 del kwargs | 251 del kwargs |
225 self.device.TapUiElement(attr_values) | 252 self.device.TapUiElement(attr_values) |
226 | 253 |
| 254 def TapHomeScreenShortcut(self, description, **kwargs): |
| 255 self.TapUiElement([ |
| 256 ('package', 'com.android.launcher3'), |
| 257 ('class', 'android.widget.TextView'), |
| 258 ('content-desc', description) |
| 259 ], **kwargs) |
| 260 |
| 261 def TapAppSwitcherTitle(self, text, **kwargs): |
| 262 self.TapUiElement([ |
| 263 ('resource-id', 'com.android.systemui:id/title'), |
| 264 ('text', text) |
| 265 ], **kwargs) |
| 266 |
| 267 def TapAppSwitcherClearAll(self, **kwargs): |
| 268 self.TapUiElement(self.APP_SWITCHER_CLEAR_ALL, **kwargs) |
| 269 |
227 @_UserAction | 270 @_UserAction |
228 def SwipeUp(self, **kwargs): | 271 def SwipeUp(self, **kwargs): |
229 del kwargs | 272 del kwargs |
230 # Hardcoded values for 480x854 screen size; should work reasonably on | 273 # Hardcoded values for 480x854 screen size; should work reasonably on |
231 # other screen sizes. | 274 # other screen sizes. |
232 # Command args: swipe <x1> <y1> <x2> <y2> [duration(ms)] | 275 # Command args: swipe <x1> <y1> <x2> <y2> [duration(ms)] |
233 self.device.RunShellCommand( | 276 self.device.RunShellCommand( |
234 'input', 'swipe', '240', '568', '240', '284', '400') | 277 'input', 'swipe', '240', '568', '240', '284', '400') |
235 | 278 |
| 279 @_UserAction |
| 280 def SwipeDown(self, **kwargs): |
| 281 del kwargs |
| 282 # Hardcoded values for 480x854 screen size; should work reasonably on |
| 283 # other screen sizes. |
| 284 # Command args: swipe <x1> <y1> <x2> <y2> [duration(ms)] |
| 285 self.device.RunShellCommand( |
| 286 'input', 'swipe', '240', '284', '240', '568', '400') |
| 287 |
| 288 def ClearRecentApps(self): |
| 289 self.GoAppSwitcher() |
| 290 if self.device.HasUiElement(self.APP_SWITCHER_NO_RECENT): |
| 291 self.GoHome() |
| 292 else: |
| 293 self.SwipeDown() |
| 294 self.TapAppSwitcherClearAll() |
| 295 |
236 | 296 |
237 class DevToolsWebSocket(object): | 297 class DevToolsWebSocket(object): |
238 def __init__(self, url): | 298 def __init__(self, url): |
239 self._url = url | 299 self._url = url |
240 self._socket = None | 300 self._socket = None |
241 self._cmdid = 0 | 301 self._cmdid = 0 |
242 | 302 |
243 def __enter__(self): | 303 def __enter__(self): |
244 self.Open() | 304 self.Open() |
245 return self | 305 return self |
(...skipping 138 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
384 Ensures that command line flags and port forwarding are ready, the browser | 444 Ensures that command line flags and port forwarding are ready, the browser |
385 is not alive before starting, it has a clear profile to begin with, and is | 445 is not alive before starting, it has a clear profile to begin with, and is |
386 finally closed when done. | 446 finally closed when done. |
387 | 447 |
388 It does not, however, launch the browser itself. This must be done by the | 448 It does not, however, launch the browser itself. This must be done by the |
389 context managed code. | 449 context managed code. |
390 | 450 |
391 To the extent possible, measurements from browsers launched within | 451 To the extent possible, measurements from browsers launched within |
392 different sessions are meant to be independent of each other. | 452 different sessions are meant to be independent of each other. |
393 """ | 453 """ |
394 self.RemoveProfile() | 454 # Removing the profile breaks Chrome Shortcuts on the Home Screen. |
| 455 # TODO: Figure out a way to automatically create the shortcuts before |
| 456 # running the story. |
| 457 # self.RemoveProfile() |
395 with self.CommandLineFlags(flags): | 458 with self.CommandLineFlags(flags): |
396 with self.StartupTracing(trace_config): | 459 with self.StartupTracing(trace_config): |
397 # Ensure browser is closed after setting command line flags and | 460 # Ensure browser is closed after setting command line flags and |
398 # trace config to ensure they are read on startup. | 461 # trace config to ensure they are read on startup. |
399 self.ForceStop() | 462 self.ForceStop() |
400 with self.PortForwarding(): | 463 with self.PortForwarding(): |
401 try: | 464 try: |
402 yield | 465 yield |
403 finally: | 466 finally: |
404 self.ForceStop() | 467 self.ForceStop() |
(...skipping 44 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
449 logging.warning('Disabling %s', self.PACKAGE_NAME) | 512 logging.warning('Disabling %s', self.PACKAGE_NAME) |
450 self.device.RunShellCommand('pm', 'disable', self.PACKAGE_NAME) | 513 self.device.RunShellCommand('pm', 'disable', self.PACKAGE_NAME) |
451 | 514 |
452 | 515 |
453 class UserStory(object): | 516 class UserStory(object): |
454 def __init__(self, browser): | 517 def __init__(self, browser): |
455 self.device = browser.device | 518 self.device = browser.device |
456 self.browser = browser | 519 self.browser = browser |
457 self.actions = AndroidActions(self.device) | 520 self.actions = AndroidActions(self.device) |
458 | 521 |
459 def GetExtraStoryApps(self): | |
460 """Sequence of AndroidApp's, other than the browser, used in the story.""" | |
461 return () | |
462 | |
463 def EnsureExtraStoryAppsClosed(self): | |
464 running_processes = self.device.ProcessStatus() | |
465 for app in self.GetExtraStoryApps(): | |
466 if app.PACKAGE_NAME in running_processes: | |
467 app.ForceStop() | |
468 | |
469 def Run(self, browser_flags, trace_config, trace_file): | 522 def Run(self, browser_flags, trace_config, trace_file): |
470 with self.browser.Session(browser_flags, trace_config): | 523 with self.browser.Session(browser_flags, trace_config): |
471 self.EnsureExtraStoryAppsClosed() | 524 self.RunPrepareSteps() |
472 try: | 525 try: |
473 self.RunStorySteps() | 526 self.RunStorySteps() |
474 self.browser.CollectTrace(trace_file) | 527 self.browser.CollectTrace(trace_file) |
475 except Exception as exc: | 528 except Exception as exc: |
476 # Helps to pin point in the logs the moment where the story failed, | 529 # Helps to pin point in the logs the moment where the story failed, |
477 # before any of the finally blocks get to be executed. | 530 # before any of the finally blocks get to be executed. |
478 logging.error('Aborting story due to %s.', type(exc).__name__) | 531 logging.error('Aborting story due to %s.', type(exc).__name__) |
479 raise | 532 raise |
480 finally: | 533 finally: |
481 self.EnsureExtraStoryAppsClosed() | 534 self.RunCleanupSteps() |
| 535 |
| 536 def RunPrepareSteps(self): |
| 537 """Subclasses may override to perform actions before running the story.""" |
| 538 pass |
482 | 539 |
483 def RunStorySteps(self): | 540 def RunStorySteps(self): |
484 """Subclasses should override this method to implement the story. | 541 """Subclasses should override this method to implement the story. |
485 | 542 |
486 The steps must: | 543 The steps must: |
487 - at some point cause the browser to be launched, and | 544 - at some point cause the browser to be launched, and |
488 - make sure the browser remains alive when done (even if backgrounded). | 545 - make sure the browser remains alive when done (even if backgrounded). |
489 """ | 546 """ |
490 raise NotImplementedError | 547 raise NotImplementedError |
491 | 548 |
| 549 def RunCleanupSteps(self): |
| 550 """Subclasses may override to perform actions after running the story. |
| 551 |
| 552 Note: This will be called even if an exception was raised during the |
| 553 execution of RunStorySteps (but not for errors in RunPrepareSteps). |
| 554 """ |
| 555 pass |
| 556 |
492 | 557 |
493 def ReadProcessMetrics(trace_file): | 558 def ReadProcessMetrics(trace_file): |
494 """Return a list of {"name": process_name, metric: value} dicts.""" | 559 """Return a list of {"name": process_name, metric: value} dicts.""" |
495 with open(trace_file) as f: | 560 with open(trace_file) as f: |
496 trace = json.load(f) | 561 trace = json.load(f) |
497 | 562 |
498 processes = collections.defaultdict(dict) | 563 processes = collections.defaultdict(dict) |
499 for event in trace['traceEvents']: | 564 for event in trace['traceEvents']: |
500 if event['ph'] == 'v': | 565 if event['ph'] == 'v': |
501 # Extract any metrics you may need from the trace. | 566 # Extract any metrics you may need from the trace. |
502 value = event['args']['dumps']['allocators'][ | 567 value = event['args']['dumps']['allocators'][ |
503 'java_heap/allocated_objects']['attrs']['size'] | 568 'java_heap/allocated_objects']['attrs']['size'] |
504 assert value['units'] == 'bytes' | 569 assert value['units'] == 'bytes' |
505 processes[event['pid']]['java_heap'] = int(value['value'], 16) | 570 processes[event['pid']]['java_heap'] = int(value['value'], 16) |
506 elif event['ph'] == 'M' and event['name'] == 'process_name': | 571 elif event['ph'] == 'M' and event['name'] == 'process_name': |
507 processes[event['pid']]['name'] = event['args']['name'] | 572 processes[event['pid']]['name'] = event['args']['name'] |
508 | 573 |
509 return processes.values() | 574 return processes.values() |
OLD | NEW |