From 7a01445788ce13df81d1802197470c96b6fd8915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= <41592183+Snooz82@users.noreply.github.com> Date: Sun, 1 Nov 2020 19:42:45 +0100 Subject: [PATCH] Relative Mouse Move, defaultBrowserType, TypedDict Converter (#469) * created TypedDictConverter * fixed #470 defaultBrowserType * fixed #467 relative mouse movement --- .github/workflows/python-package.yml | 3 +- Browser/browser.py | 2 +- Browser/keywords/getters.py | 2 +- Browser/keywords/interaction.py | 50 +++++++--- Browser/keywords/playwright_state.py | 65 +++++++------ Browser/utils/__init__.py | 4 + Browser/utils/data_types.py | 97 +++++++++++++++++-- .../device_descriptors.robot | 27 ++++-- .../02_Content_Keywords/virtual_mouse.robot | 16 +++ node/playwright-wrapper/playwright-state.ts | 6 +- 10 files changed, 200 insertions(+), 72 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 04f32fda2..4b6adfe31 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -33,9 +33,10 @@ jobs: uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - - name: Install ffmpeg on unix-like + - name: Install ffmpeg and libgles on unix-like run: | sudo apt-get install ffmpeg + sudo apt-get install libgles2 if: matrix.os != 'windows-latest' - name: Install python dependencies run: | diff --git a/Browser/browser.py b/Browser/browser.py index 6e5833a28..879772233 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -739,7 +739,7 @@ def keyword_error(self): def _failure_screenshot_path(self): test_name = BuiltIn().get_variable_value("${TEST NAME}") return os.path.join( - BuiltIn().get_variable_value("${OUTPUTDIR}"), + self.outputdir, test_name.replace(" ", "_") + "_FAILURE_SCREENSHOT_{index}", ).replace("\\", "\\\\") diff --git a/Browser/keywords/getters.py b/Browser/keywords/getters.py index b303a849b..adde072b7 100644 --- a/Browser/keywords/getters.py +++ b/Browser/keywords/getters.py @@ -603,7 +603,7 @@ def get_boundingbox( with self.playwright.grpc_channel() as stub: response = stub.GetBoundingBox(Request.ElementSelector(selector=selector)) parsed = json.loads(response.json) - logger.debug(parsed) + logger.debug(f"BoundingBox: {parsed}") if key == BoundingBoxFields.ALL: return int_dict_verify_assertion( parsed, assertion_operator, assertion_expected, "BoundingBox is" diff --git a/Browser/keywords/interaction.py b/Browser/keywords/interaction.py index db276ee84..92572235f 100644 --- a/Browser/keywords/interaction.py +++ b/Browser/keywords/interaction.py @@ -274,25 +274,14 @@ def hover( ``selector`` Selector element to click. See the `Finding elements` section for details about the selectors. - ``button`` The button that shall be used for clicking. - - ``clickCount`` How many time shall be clicked. - - ``delay`` Time to wait between mouse-down and mouse-up and next click. - - *Caution: be aware that if the delay leads to a total time that exceeds the timeout, the keyword fails* - - ``position_x`` & ``position_y`` A point to click relative to the - top-left corner of element bounding-box. Only positive values within the bounding-box are allowed. - If not specified, clicks to some visible point of the element. - - *Caution: even with 0, 0 might click a few pixels off from the corner of the bounding-box. Click uses detection to find the first clickable point.* + ``position_x`` & ``position_y`` A point to hover relative to the top-left corner of element bounding box. + If not specified, hovers over some visible point of the element. + Only positive values within the bounding-box are allowed. ``force`` Set to True to skip Playwright's [https://github.com/microsoft/playwright/blob/master/docs/actionability.md | Actionability checks]. - ``*modifiers`` - Modifier keys to press. Ensures that only these modifiers are pressed - during the click, and then restores current modifiers back. + ``*modifiers`` Modifier keys to press. Ensures that only these modifiers are + pressed during the hover, and then restores current modifiers back. If not specified, currently pressed modifiers are used. """ with self.playwright.grpc_channel() as stub: @@ -665,6 +654,35 @@ def _center_of_boundingbox(boundingbox: BoundingBox) -> Coordinates: center["y"] = boundingbox["y"] + (boundingbox["height"] / 2) return center + @keyword(tags=["Setter", "PageContent"]) + def mouse_move_relative_to( + self, selector: str, x: float = 0.0, y: float = 0.0, steps: int = 1 + ): + """Moves the mouse cursor relative to the selected element. + + ``x`` ``y`` are relative coordinates to the center of the elements bounding box. + + ``steps`` Number of intermediate steps for the mouse event. + This is sometime needed for websites to recognize the movement. + """ + with self.playwright.grpc_channel() as stub: + bbox = self.library.get_boundingbox(selector) + center = self._center_of_boundingbox(bbox) + body: MouseOptionsDict = { + "x": center["x"] + x, + "y": center["y"] + y, + "options": {"steps": steps}, + } + logger.info( + f"Moving mouse relative to element center by x: {x}, y: {y} coordinates." + ) + logger.debug(f"Element Center is: {center}") + logger.debug( + f"Mouse Position is: {{'x': {center['x'] + x}, 'y': {center['y'] + y}}}" + ) + response = stub.MouseMove(Request().Json(body=json.dumps(body))) + logger.debug(response.log) + @keyword(tags=["Setter", "PageContent"]) def mouse_move(self, x: float, y: float, steps: int = 1): """Instead of selectors command mouse with coordinates. diff --git a/Browser/keywords/playwright_state.py b/Browser/keywords/playwright_state.py index 6866f5f85..5989385dc 100755 --- a/Browser/keywords/playwright_state.py +++ b/Browser/keywords/playwright_state.py @@ -24,9 +24,13 @@ from ..generated.playwright_pb2 import Request from ..utils import ( ColorScheme, + GeoLocation, + HttpCredentials, + Proxy, SelectionType, SupportedBrowsers, ViewportDimensions, + convert_typed_dict, find_by_id, locals_to_params, logger, @@ -232,7 +236,7 @@ def new_browser( executablePath: Optional[str] = None, args: Optional[List[str]] = None, ignoreDefaultArgs: Optional[List[str]] = None, - proxy: Optional[Dict] = None, + proxy: Optional[Proxy] = None, downloadsPath: Optional[str] = None, handleSIGINT: bool = True, handleSIGTERM: bool = True, @@ -292,6 +296,7 @@ def new_browser( Useful so that you can see what is going on. Defaults to no delay. """ params = locals_to_params(locals()) + params = convert_typed_dict(Proxy, params, "proxy") if timeout: params["timeout"] = self.convert_timeout(timeout) params["slowMo"] = self.convert_timeout(slowMo) @@ -319,17 +324,18 @@ def new_context( hasTouch: bool = False, javaScriptEnabled: bool = True, timezoneId: Optional[str] = None, - geolocation: Optional[Dict] = None, + geolocation: Optional[GeoLocation] = None, locale: Optional[str] = None, permissions: Optional[List[str]] = None, extraHTTPHeaders: Optional[Dict[str, str]] = None, offline: bool = False, - httpCredentials: Optional[Dict] = None, + httpCredentials: Optional[HttpCredentials] = None, colorScheme: Optional[ColorScheme] = None, - hideRfBrowser: bool = False, - defaultBrowserType: Optional[str] = None, + proxy: Optional[Proxy] = None, videosPath: Optional[str] = None, - videoSize: Optional[Dict[str, int]] = None, + videoSize: Optional[ViewportDimensions] = None, + defaultBrowserType: Optional[SupportedBrowsers] = None, + hideRfBrowser: bool = False, ) -> str: """Create a new BrowserContext with specified options. See `Browser, Context and Page` for more information about BrowserContext. @@ -337,7 +343,7 @@ def new_context( Returns a stable identifier for the created context that can be used in `Switch Context`. - ``acceptDownloads`` Whether to automatically downloadall the attachments. + ``acceptDownloads`` Whether to automatically downloads all the attachments. Defaults to False where all the downloads are canceled. ``ignoreHTTPSErrors`` Whether to ignore HTTPS errors during navigation. @@ -367,7 +373,7 @@ def new_context( See [https://source.chromium.org/chromium/chromium/src/+/master:third_party/icu/source/data/misc/metaZones.txt | ICU’s metaZones.txt] for a list of supported timezone IDs. - ``geolocation`` Sets the geolocation. No location is set be default. + ``geolocation`` Sets the geolocation. No location is set by default. - ``latitude`` Latitude between -90 and 90. - ``longitude`` Longitude between -180 and 180. - ``accuracy`` Optional Non-negative accuracy value. Defaults to 0. @@ -396,15 +402,30 @@ def new_context( See [https://github.com/microsoft/playwright/blob/master/docs/api.md#pageemulatemediaoptions|emulateMedia(options)] for more details. Defaults to ``light``. - ``videosPath`` Enables video recording for all pages to videosPath + ``proxy`` Network proxy settings to use with this context. + Note that browser needs to be launched with the global proxy for this option to work. + If all contexts override the proxy, global proxy will be never used and can be any string + + ``videosPath`` Enables video recording for all pages to videosPath folder. If not specified, videos are not recorded. - ``videoSize`` Specifies dimensions of the automatically recorded + + ``videoSize`` Specifies dimensions of the automatically recorded video. Can only be used if videosPath is set. If not specified the size will be equal to viewport. If viewport is not configured explicitly the video size defaults to 1280x720. Actual picture of the page will be scaled down if necessary to fit specified size. - Example {"width": 1280, "height": 720} + ``defaultBrowserType`` If no browser is open and `New Context` opens a new browser + with defaults, it now uses this setting. + Very useful together with `Get Device` keyword: + + Example: + | Test an iPhone + | ${device}= `Get Device` iPhone X + | `New Context` &{device} # unpacking here with & + | `New Page` http://example.com + A BrowserContext is the Playwright object that controls a single browser profile. Within a context caches and cookies are shared. See [https://github.com/microsoft/playwright/blob/master/docs/api.md#browsernewcontextoptions|Playwright browser.newContext] @@ -413,21 +434,12 @@ def new_context( If there's no open Browser this keyword will open one. Does not create pages. """ params = locals_to_params(locals()) - if "geolocation" in params: - location = params["geolocation"] - latitude = location["latitude"] - location["latitude"] = float(latitude) - longitude = location["longitude"] - location["longitude"] = float(longitude) - accuracy = location.get("accuracy") - if accuracy: - location["accuracy"] = float(accuracy) + params = convert_typed_dict(GeoLocation, params, "geolocation") + params = convert_typed_dict(ViewportDimensions, params, "viewport") + params = convert_typed_dict(Proxy, params, "proxy") + params = convert_typed_dict(ViewportDimensions, params, "videoSize") if not videosPath: params.pop("videoSize", None) - if "videoSize" in params: - params = self._size_to_number(params, "videoSize") - if "viewport" in params: - params = self._size_to_number(params, "viewport") options = json.dumps(params, default=str) logger.info(options) with self.playwright.grpc_channel() as stub: @@ -449,13 +461,6 @@ def _get_video_size(self, params: dict) -> dict: return params["viewport"] return {"width": 1280, "height": 720} - def _size_to_number(self, params: dict, argument: str) -> dict: - width = int(params[argument]["width"]) - height = int(params[argument]["height"]) - params[argument]["width"] = width - params[argument]["height"] = height - return params - @keyword(tags=["Setter", "BrowserControl"]) def new_page(self, url: Optional[str] = None) -> str: """Open a new Page. A Page is the Playwright equivalent to a tab. diff --git a/Browser/utils/__init__.py b/Browser/utils/__init__.py index 083d01588..52a63627b 100644 --- a/Browser/utils/__init__.py +++ b/Browser/utils/__init__.py @@ -20,11 +20,15 @@ CookieSameSite, CookieType, ElementState, + GeoLocation, + HttpCredentials, + Proxy, RequestMethod, SelectAttribute, SelectionType, SupportedBrowsers, ViewportDimensions, + convert_typed_dict, ) from .js_utilities import ( exec_scroll_function, diff --git a/Browser/utils/data_types.py b/Browser/utils/data_types.py index b1e606c46..8a2a7a527 100644 --- a/Browser/utils/data_types.py +++ b/Browser/utils/data_types.py @@ -13,22 +13,99 @@ # limitations under the License. from enum import Enum, auto +from typing import Dict from typing_extensions import TypedDict -BoundingBox = TypedDict( - "BoundingBox", - {"x": float, "y": float, "width": float, "height": float}, - total=False, -) -Coordinates = TypedDict("Coordinates", {"x": float, "y": float}, total=False) +def convert_typed_dict(data_type, params: Dict, key: str) -> Dict: + if key not in params: + return params + dictionary = {k.lower(): v for k, v in params[key].items()} + struct = data_type.__annotations__ + typed_dict = data_type() + for req_key in data_type.__required_keys__: + if req_key.lower() not in dictionary: + raise RuntimeError( + f"`{dictionary}` cannot be converted to {data_type.__name__}." + f"\nThe required key '{req_key}' in not set in given value." + f"\nExpected types: {data_type.__annotations__}" + ) + typed_dict[req_key] = struct[req_key](dictionary[req_key.lower()]) + for opt_key in data_type.__optional_keys__: + if opt_key.lower() not in dictionary: + continue + typed_dict[opt_key] = struct[opt_key](dictionary[opt_key.lower()]) + params[key] = typed_dict + return params -MouseOptionsDict = TypedDict( - "MouseOptionsDict", {"x": float, "y": float, "options": dict}, total=False -) -ViewportDimensions = TypedDict("ViewportDimensions", {"width": int, "height": int}) +class BoundingBox(TypedDict, total=False): + x: float + y: float + width: float + height: float + + +class Coordinates(TypedDict, total=False): + x: float + y: float + + +class MouseOptionsDict(TypedDict, total=False): + x: float + y: float + options: dict + + +class ViewportDimensions(TypedDict): + width: int + height: int + + +class HttpCredentials(TypedDict): + username: str + password: str + + +class _GeoCoordinated(TypedDict): + longitude: float + latitude: float + + +class GeoLocation(_GeoCoordinated, total=False): + """Defines the geolocation. + + - ``latitude`` Latitude between -90 and 90. + - ``longitude`` Longitude between -180 and 180. + - ``accuracy`` *Optional* Non-negative accuracy value. Defaults to 0. + Example usage: ``{'latitude': 59.95, 'longitude': 30.31667}``""" + + accuracy: float + + +class _Server(TypedDict): + server: str + + +class Proxy(_Server, total=False): + """Network proxy settings. + + ``server`` Proxy to be used for all requests. HTTP and SOCKS proxies are supported, + for example http://myproxy.com:3128 or socks5://myproxy.com:3128. + Short form myproxy.com:3128 is considered an HTTP proxy. + + ``bypass`` *Optional* coma-separated domains to bypass proxy, + for example ".com, chromium.org, .domain.com". + + ``username`` *Optional* username to use if HTTP proxy requires authentication. + + ``password`` *Optional* password to use if HTTP proxy requires authentication. + """ + + bypass: str + Username: str + password: str class SelectionType(Enum): diff --git a/atest/test/01_Browser_Management/device_descriptors.robot b/atest/test/01_Browser_Management/device_descriptors.robot index 0efca2cef..7ab1ac8d2 100644 --- a/atest/test/01_Browser_Management/device_descriptors.robot +++ b/atest/test/01_Browser_Management/device_descriptors.robot @@ -1,19 +1,20 @@ *** Settings *** Library Browser Resource imports.resource -Suite Setup Open Browser To Login Page -Suite Teardown Close Browser *** Variables *** ${device_json}= -... json.loads("""{ -... "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", -... "viewport": { "width": 375, "height": 812 }, +... json.loads('''{ +... "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36", +... "viewport": { +... "width": 360, +... "height": 640 +... }, ... "deviceScaleFactor": 3, ... "isMobile": true, ... "hasTouch": true, -... "defaultBrowserType": "webkit" -... }""") +... "defaultBrowserType": "chromium" +... }''') *** Test Cases *** Get Devices @@ -22,14 +23,20 @@ Get Devices Get Device ${should_be}= Evaluate ${device_json} - ${device}= Get Device iPhone X + ${device}= Get Device Galaxy S5 Should Be Equal ${device} ${should_be} Get Invalid Device Errors Run Keyword And Expect Error No device named NonExistentDeviceName Get Device NonExistentDeviceName Descriptor Properly sets context settings - ${device}= Get Device iPhone X + ${device}= Get Device Galaxy S5 New Context &{device} New Page - Get Viewport Size ALL == { "width": 375, "height": 812 } + Get Viewport Size ALL == { "width": 360 , "height": 640 } + Verify Browser Type chromium + +*** Keywords *** +Verify Browser Type + [Arguments] ${expectedType} + Get Browser Catalog validate value[0]['type'] == $expectedType diff --git a/atest/test/02_Content_Keywords/virtual_mouse.robot b/atest/test/02_Content_Keywords/virtual_mouse.robot index 5fb73ede0..b4e0599de 100644 --- a/atest/test/02_Content_Keywords/virtual_mouse.robot +++ b/atest/test/02_Content_Keywords/virtual_mouse.robot @@ -59,6 +59,12 @@ Hover and Drop to Hover Get Text \#dragX == 20 Get Text \#dragY == 30 +Drag and Drop with Move Relative + Relative DnD 32 64 32 64 + Relative DnD 0 -64 32 0 + Relative DnD -20 0 12 0 + Relative DnD -22 -20 -10 -20 + Click Count ${x}= Get Boundingbox \#clickWithOptions x ${y}= Get Boundingbox \#clickWithOptions y @@ -80,3 +86,13 @@ Left Right and Middle Click Get Text \#mouse_button == middle Mouse Button click ${x} ${y} button=left Get Text \#mouse_button == left + +*** Keywords *** +Relative DnD + [Arguments] ${x} ${y} ${txt_x} ${txt_y} + Hover id=draggable + Mouse Button down + Mouse Move Relative To id=draggable ${x} ${y} steps=2 + Mouse Button up + Get Text \#dragX == ${txt_x} + Get Text \#dragY == ${txt_y} diff --git a/node/playwright-wrapper/playwright-state.ts b/node/playwright-wrapper/playwright-state.ts index 25a374031..c34a8c65b 100644 --- a/node/playwright-wrapper/playwright-state.ts +++ b/node/playwright-wrapper/playwright-state.ts @@ -106,10 +106,10 @@ export class PlaywrightState { return this.getActiveBrowser(); }; - public async getOrCreateActiveBrowser(): Promise { + public async getOrCreateActiveBrowser(browserType?: string): Promise { const currentBrowser = this.activeBrowser; if (currentBrowser === undefined) { - const [newBrowser, name] = await _newBrowser(); + const [newBrowser, name] = await _newBrowser(browserType); const newState = new BrowserState(name, newBrowser); this.browserStack.push(newState); return newState; @@ -350,8 +350,8 @@ export async function newPage(request: Request.Url, openBrowsers: PlaywrightStat export async function newContext(request: Request.Context, openBrowsers: PlaywrightState): Promise { const hideRfBrowser = request.getHiderfbrowser(); - const browserState = await openBrowsers.getOrCreateActiveBrowser(); const options = JSON.parse(request.getRawoptions()); + const browserState = await openBrowsers.getOrCreateActiveBrowser(options.defaultBrowserType); const defaultTimeout = request.getDefaulttimeout(); const context = await _newBrowserContext(browserState.browser, defaultTimeout, options, hideRfBrowser); browserState.pushContext(context);