| 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 |