• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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