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 8from typing import TYPE_CHECKING, Optional, Tuple, Type 9 10from crossbench.action_runner.action.action import ACTION_TIMEOUT, ActionT 11from crossbench.action_runner.action.action_type import ActionType 12from crossbench.action_runner.action.base_input_source import InputSourceAction 13from crossbench.benchmarks.loading.input_source import InputSource 14from crossbench.benchmarks.loading.point import Point 15from crossbench.parse import DurationParser, NumberParser, ObjectParser 16 17if TYPE_CHECKING: 18 from crossbench.action_runner.base import ActionRunner 19 from crossbench.config import ConfigParser 20 from crossbench.runner.run import Run 21 from crossbench.types import JsonDict 22 23 24class ClickAction(InputSourceAction): 25 TYPE: ActionType = ActionType.CLICK 26 27 @classmethod 28 def config_parser(cls: Type[ActionT]) -> ConfigParser[ActionT]: 29 parser = super().config_parser() 30 parser.add_argument("selector", type=ObjectParser.non_empty_str) 31 parser.add_argument("required", type=ObjectParser.bool, default=False) 32 parser.add_argument( 33 "scroll_into_view", type=ObjectParser.bool, default=False) 34 parser.add_argument("x", type=NumberParser.positive_zero_int) 35 parser.add_argument("y", type=NumberParser.positive_zero_int) 36 parser.add_argument( 37 "duration", 38 type=DurationParser.positive_or_zero_duration, 39 default=dt.timedelta()) 40 return parser 41 42 def __init__(self, 43 source: InputSource, 44 duration: dt.timedelta = dt.timedelta(), 45 selector: Optional[str] = None, 46 required: bool = False, 47 scroll_into_view: bool = False, 48 x: Optional[int] = None, 49 y: Optional[int] = None, 50 timeout: dt.timedelta = ACTION_TIMEOUT, 51 index: int = 0): 52 # TODO: convert to custom selector object. 53 self._selector = selector 54 self._required: bool = required 55 self._scroll_into_view: bool = scroll_into_view 56 self._coordinates: Optional[Point] = None 57 if x is not None and y is not None: 58 self._coordinates = Point(x, y) 59 super().__init__(source, duration, timeout, index) 60 61 @property 62 def selector(self) -> Optional[str]: 63 return self._selector 64 65 @property 66 def required(self) -> bool: 67 return self._required 68 69 @property 70 def scroll_into_view(self) -> bool: 71 return self._scroll_into_view 72 73 @property 74 def coordinates(self) -> Optional[Point]: 75 return self._coordinates 76 77 def run_with(self, run: Run, action_runner: ActionRunner) -> None: 78 action_runner.click(run, self) 79 80 def validate(self) -> None: 81 super().validate() 82 83 if self._selector and self._coordinates: 84 raise ValueError("Only one is allowed: either selector or coordinates") 85 86 if not self._selector and not self._coordinates: 87 raise ValueError("Either selector or coordinates are required") 88 89 if self._input_source is InputSource.JS and self._coordinates: 90 raise ValueError("X,Y Coordinates cannot be used with JS click source.") 91 92 if self._required and self._coordinates: 93 raise ValueError("'required' is not compatible with coordinates") 94 95 if self._scroll_into_view and self._coordinates: 96 raise ValueError("'scroll_into_view' is not compatible with coordinates") 97 98 def validate_duration(self) -> None: 99 # A click action is allowed to have a zero duration. 100 return 101 102 def supported_input_sources(self) -> Tuple[InputSource, ...]: 103 return (InputSource.JS, InputSource.TOUCH, InputSource.MOUSE) 104 105 def to_json(self) -> JsonDict: 106 details = super().to_json() 107 108 if self._selector: 109 details["selector"] = self._selector 110 details["required"] = self._required 111 details["scroll_into_view"] = self._scroll_into_view 112 else: 113 assert self._coordinates 114 details["x"] = self._coordinates.x 115 details["y"] = self._coordinates.y 116 return details 117