1# Copyright 2024 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import datetime as dt 8import logging 9import re 10from typing import List, Optional 11 12from crossbench.action_runner.action import all as i_action 13from crossbench.action_runner.base import InputSourceNotImplementedError 14from crossbench.action_runner.basic_action_runner import BasicActionRunner 15from crossbench.action_runner.display_rectangle import DisplayRectangle 16from crossbench.action_runner.element_not_found_error import \ 17 ElementNotFoundError 18from crossbench.benchmarks.loading.point import Point 19from crossbench.browsers.attributes import BrowserAttributes 20from crossbench.runner.actions import Actions 21from crossbench.runner.run import Run 22 23 24class ViewportInfo: 25 26 def __init__(self, 27 raw_chrome_window_bounds: DisplayRectangle, 28 window_inner_height: int, 29 window_inner_width: int, 30 element_rect: Optional[DisplayRectangle] = None) -> None: 31 self._element_rect: Optional[DisplayRectangle] = None 32 33 # On android, clank does not report the correct window.devicePixelRatio 34 # when a page is zoomed. 35 # Zoom can happen automatically on load with pages that force a certain 36 # viewport width (such as speedometer), so calculate the ratio manually. 37 # Note: this calculation assumes there are no system borders on the side of 38 # the chrome window. 39 self._actual_pixel_ratio: float = float(raw_chrome_window_bounds.width / 40 window_inner_width) 41 42 window_inner_height = int( 43 round(self.actual_pixel_ratio * window_inner_height)) 44 window_inner_width = int( 45 round(self.actual_pixel_ratio * window_inner_width)) 46 47 # On Android there may be a system added border from the top of the app view 48 # that is included in the mAppBounds rectangle dimensions. Calculate the 49 # height of this border using the difference between the height reported by 50 # chrome and the height reported by android. 51 top_border_height = raw_chrome_window_bounds.height - window_inner_height 52 53 self._chrome_window: DisplayRectangle = DisplayRectangle( 54 Point(raw_chrome_window_bounds.origin.x, 55 raw_chrome_window_bounds.origin.y + top_border_height), 56 raw_chrome_window_bounds.width, 57 raw_chrome_window_bounds.height - top_border_height) 58 59 if element_rect: 60 self._element_rect = (element_rect * self.actual_pixel_ratio).shift_by( 61 self._chrome_window) 62 63 @property 64 def chrome_window(self) -> DisplayRectangle: 65 return self._chrome_window 66 67 @property 68 def actual_pixel_ratio(self) -> float: 69 return self._actual_pixel_ratio 70 71 def element_rect(self) -> Optional[DisplayRectangle]: 72 return self._element_rect 73 74 def element_center(self) -> Optional[Point]: 75 if not self._element_rect: 76 return None 77 return self._element_rect.middle 78 79 def css_to_native_distance(self, distance: float) -> float: 80 return distance * self.actual_pixel_ratio 81 82 83class AndroidInputActionRunner(BasicActionRunner): 84 85 # Represents the position of the chrome main window relative to the entire 86 # screen as reported by Android window manager. 87 _raw_chrome_window_bounds: Optional[DisplayRectangle] = None 88 89 @property 90 def raw_chrome_window_bounds(self) -> DisplayRectangle: 91 assert self._raw_chrome_window_bounds, "Uninitialized chrome window bounds" 92 return self._raw_chrome_window_bounds 93 94 _BOUNDS_RE = re.compile( 95 r"mAppBounds=Rect\((?P<left>\d+), (?P<top>\d+) - (?P<right>\d+)," 96 r" (?P<bottom>\d+)\)") 97 98 _GET_JS_VALUES = """ 99const found_element = arguments[0] && element; 100if(found_element && arguments[1]) element.scrollIntoView(); 101rect = found_element ? element.getBoundingClientRect() : new DOMRect(); 102return [ 103 found_element, 104 window.innerHeight, 105 window.innerWidth, 106 rect.left, 107 rect.top, 108 rect.width, 109 rect.height 110];""" 111 112 def scroll_touch(self, run: Run, action: i_action.ScrollAction) -> None: 113 with run.actions("ScrollAction", measure=False) as actions: 114 115 viewport_info = self._get_viewport_info(run, actions, action.selector) 116 117 # The scroll distance is specified in terms of css pixels so adjust to the 118 # native pixel density. 119 total_scroll_distance = ( 120 viewport_info.css_to_native_distance(action.distance)) 121 122 # Default to scrolling within the entire chrome window. 123 scroll_area: DisplayRectangle = viewport_info.chrome_window 124 125 if action.selector: 126 if element_rect := viewport_info.element_rect(): 127 scroll_area = element_rect 128 else: 129 if action.required: 130 raise ElementNotFoundError(action.selector) 131 return 132 133 scrollable_top = scroll_area.top 134 scrollable_bottom = scroll_area.bottom 135 136 max_swipe_distance = scrollable_bottom - scrollable_top 137 138 remaining_distance = abs(total_scroll_distance) 139 140 while remaining_distance > 0: 141 142 current_distance = min(max_swipe_distance, remaining_distance) 143 144 # The duration for this swipe should be only a fraction of the total 145 # duration since the entire distance may not be covered in one swipe. 146 current_duration = (current_distance / 147 abs(total_scroll_distance)) * action.duration 148 149 # If scrolling down, the swipe should start at the bottom and end above. 150 y_start = scrollable_bottom 151 y_end = scrollable_bottom - current_distance 152 153 # If scrolling up, the swipe should start at the top and end below. 154 if total_scroll_distance < 0: 155 y_start = scrollable_top 156 y_end = scrollable_top + current_distance 157 158 self._swipe_impl(run, round(scroll_area.mid_x), round(y_start), 159 round(scroll_area.mid_x), round(y_end), 160 current_duration) 161 162 remaining_distance -= current_distance 163 164 def click_touch(self, run: Run, action: i_action.ClickAction) -> None: 165 self._click_impl(run, action, False) 166 167 def click_mouse(self, run: Run, action: i_action.ClickAction) -> None: 168 self._click_impl(run, action, True) 169 170 def swipe(self, run: Run, action: i_action.SwipeAction) -> None: 171 with run.actions("SwipeAction", measure=False): 172 self._swipe_impl(run, action.start_x, action.start_y, action.end_x, 173 action.end_y, action.duration) 174 175 def text_input_keyboard(self, run: Run, 176 action: i_action.TextInputAction) -> None: 177 self._rate_limit_keystrokes(run, action, self._type_characters) 178 179 def _click_impl(self, run: Run, action: i_action.ClickAction, 180 use_mouse: bool) -> None: 181 if action.duration > dt.timedelta(): 182 raise InputSourceNotImplementedError(self, action, action.input_source, 183 "Non-zero duration not implemented") 184 185 with run.actions("ClickAction", measure=False) as actions: 186 187 coordinates = action.coordinates 188 189 if action.selector: 190 viewport_info = self._get_viewport_info(run, actions, action.selector, 191 action.scroll_into_view) 192 193 rect = viewport_info.element_rect() 194 if not rect: 195 logging.warning("No clickable element_rect found for %s", 196 action.selector) 197 if action.required: 198 raise ElementNotFoundError(action.selector) 199 return 200 201 coordinates = Point(rect.mid_x, rect.mid_y) 202 203 cmd: List[str] = ["input"] 204 205 if use_mouse: 206 cmd.append("mouse") 207 208 cmd.extend(["tap", str(coordinates.x), str(coordinates.y)]) 209 210 run.browser_platform.sh(*cmd) 211 212 def _swipe_impl(self, run: Run, start_x: int, start_y: int, end_x: int, 213 end_y: int, duration: dt.timedelta) -> None: 214 215 duration_millis = round(duration // dt.timedelta(milliseconds=1)) 216 217 run.browser_platform.sh("input", "swipe", str(start_x), str(start_y), 218 str(end_x), str(end_y), str(duration_millis)) 219 220 def _get_viewport_info(self, 221 run: Run, 222 actions: Actions, 223 selector: Optional[str] = None, 224 scroll_into_view: bool = False) -> ViewportInfo: 225 226 script = "" 227 228 if selector: 229 selector, script = self.get_selector_script(selector) 230 231 script += self._GET_JS_VALUES 232 233 (found_element, inner_height, inner_width, left, top, width, 234 height) = actions.js( 235 script, arguments=[selector, scroll_into_view]) 236 237 # If the chrome window position has not yet been found, 238 # initialize it now. 239 # Note: this assumes the chrome app will not be moved or resized during 240 # the test. 241 if not self._raw_chrome_window_bounds: 242 self._raw_chrome_window_bounds = self._find_chrome_window_size(run) 243 244 element_rect: Optional[DisplayRectangle] = None 245 if found_element: 246 element_rect = DisplayRectangle(Point(left, top), width, height) 247 248 return ViewportInfo(self.raw_chrome_window_bounds, inner_height, 249 inner_width, element_rect) 250 251 252 # Returns the name of the browser's main window as reported by android's 253 # window manager. 254 def _get_browser_window_name(self, 255 browser_attributes: BrowserAttributes) -> str: 256 if browser_attributes.is_chrome: 257 return "chrome.Main" 258 259 raise RuntimeError("Unsupported browser for android action runner.") 260 261 def _find_chrome_window_size(self, run: Run) -> DisplayRectangle: 262 # Find the chrome app window position by dumping the android app window 263 # list. 264 # 265 # Chrome's main view is always called 'chrome.Main' and is followed by the 266 # configuration for that window. 267 # 268 # The mAppBounds config of the chrome.Main window contains the dimensions 269 # for the visible part of the current chrome window formatted like this for 270 # a 800 height by 480 width window: 271 # 272 # mAppBounds=Rect(0, 0 - 480, 800) 273 browser_main_window_name = self._get_browser_window_name( 274 run.browser.attributes) 275 276 raw_window_config = run.browser_platform.sh_stdout( 277 "dumpsys", 278 "window", 279 "windows", 280 "|", 281 "grep", 282 "-E", 283 "-A100", 284 browser_main_window_name, 285 ) 286 match = self._BOUNDS_RE.search(raw_window_config) 287 if not match: 288 raise RuntimeError("Could not find chrome window bounds") 289 290 width = int(match["right"]) - int(match["left"]) 291 height = int(match["bottom"]) - int(match["top"]) 292 293 return DisplayRectangle( 294 Point(int(match["left"]), int(match["top"])), width, height) 295 296 def _type_characters(self, run: Run, _: Actions, characters: str) -> None: 297 # TODO(kalutes) handle special characters and other whitespaces like '\t' 298 299 # The 'input text' command cannot handle spaces directly. Replace space 300 # characters with the encoding '%s'. 301 characters = characters.replace(" ", "%s") 302 run.browser_platform.sh("input", "keyboard", "text", characters) 303