diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 25910416..b77088fb 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -194,3 +194,122 @@ jobs: with: name: appium-android-${{matrix.test_targets.name}}.log path: appium.log + + flutter_e2e_test: + # These flutter integration driver tests are maintained by: MummanaSubramanya + strategy: + fail-fast: false + matrix: + include: + - platform: macos-14 + e2e-tests: flutter-ios + - platform: ubuntu-latest + e2e-tests: flutter-android + + runs-on: ${{ matrix.platform }} + + env: + API_LEVEL: 28 + ARCH: x86 + CI: true + XCODE_VERSION: 15.4 + IOS_VERSION: 17.5 + IPHONE_MODEL: iPhone 15 + FLUTTER_ANDROID_APP: "https://github.com/AppiumTestDistribution/appium-flutter-server/releases/latest/download/app-debug.apk" + FLUTTER_IOS_APP: "https://github.com/AppiumTestDistribution/appium-flutter-server/releases/latest/download/ios.zip" + + steps: + + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + if: matrix.e2e-tests == 'flutter-android' + with: + distribution: 'zulu' + java-version: '17' + + - name: Enable KVM group perms + if: matrix.e2e-tests == 'flutter-android' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Set up Python 3.12 + uses: actions/setup-python@v3 + with: + python-version: 3.12 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + + - name: Install Appium + run: npm install --location=global appium + + - name: Install Android drivers and Run Appium + if: matrix.e2e-tests == 'flutter-android' + run: | + appium driver install uiautomator2 + appium driver install appium-flutter-integration-driver --source npm + nohup appium --allow-insecure=adb_shell --relaxed-security --log-timestamp --log-no-colors 2>&1 > appium_flutter_android.log & + + - name: Run Android tests + if: matrix.e2e-tests == 'flutter-android' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ env.API_LEVEL }} + script: | + pip install --upgrade pip + pip install --upgrade pipenv + pipenv lock --clear + pipenv install -d --system + export PLATFORM=android + pytest test/functional/flutter_integration/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html + target: default + disable-spellchecker: true + disable-animations: true + + - name: Save server output + if: always() && matrix.e2e-tests == 'flutter-android' + uses: actions/upload-artifact@master + with: + name: appium-flutter-android.log + path: appium_flutter_android.log + + - name: Select Xcode + if: matrix.e2e-tests == 'flutter-ios' + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - uses: futureware-tech/simulator-action@v3 + if: matrix.e2e-tests == 'flutter-ios' + with: + # https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md + model: ${{ env.IPHONE_MODEL }} + os_version: ${{ env.IOS_VERSION }} + + - name: install dependencies + if: matrix.e2e-tests == 'flutter-ios' + run: brew install ffmpeg + + - name: Install IOS drivers and Run Appium + if: matrix.e2e-tests == 'flutter-ios' + run: | + appium driver install xcuitest + appium driver install appium-flutter-integration-driver --source npm + appium driver run xcuitest build-wda + nohup appium --allow-insecure=adb_shell --relaxed-security --log-timestamp --log-no-colors 2>&1 > appium_ios.log & + + - name: Run IOS tests + if: matrix.e2e-tests == 'flutter-ios' + run: | + # Separate 'run' creates differnet pipenv env. Does them in one run for now. + pip install --upgrade pip + pip install --upgrade pipenv + pipenv lock --clear + pipenv install -d --system + export PLATFORM=ios + pytest test/functional/flutter_integration/*_test.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html diff --git a/appium/common/helper.py b/appium/common/helper.py index 874c453f..1565b96e 100644 --- a/appium/common/helper.py +++ b/appium/common/helper.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 from typing import Any, Dict from appium import version as appium_version @@ -33,3 +34,9 @@ def library_version() -> str: """Return a version of this python library""" return appium_version.version + + +def encode_file_to_base64(file_path: str) -> str: + """Return base64 encoded string for given file""" + with open(file_path, 'rb') as file: + return base64.b64encode(file.read()).decode('utf-8') diff --git a/appium/options/flutter_integration/__init__.py b/appium/options/flutter_integration/__init__.py new file mode 100644 index 00000000..865d653e --- /dev/null +++ b/appium/options/flutter_integration/__init__.py @@ -0,0 +1 @@ +from .base import FlutterOptions diff --git a/appium/options/flutter_integration/base.py b/appium/options/flutter_integration/base.py new file mode 100644 index 00000000..65d1c19a --- /dev/null +++ b/appium/options/flutter_integration/base.py @@ -0,0 +1,40 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Dict + +from appium.options.common.automation_name_option import AUTOMATION_NAME +from appium.options.common.base import AppiumOptions +from appium.options.flutter_integration.flutter_element_wait_timeout_option import FlutterElementWaitTimeOutOption +from appium.options.flutter_integration.flutter_enable_mock_camera_option import FlutterEnableMockCameraOption +from appium.options.flutter_integration.flutter_server_launch_timeout_option import FlutterServerLaunchTimeOutOption +from appium.options.flutter_integration.flutter_system_port_option import FlutterSystemPortOption + + +class FlutterOptions( + AppiumOptions, + FlutterElementWaitTimeOutOption, + FlutterEnableMockCameraOption, + FlutterServerLaunchTimeOutOption, + FlutterSystemPortOption, +): + + @property + def default_capabilities(self) -> Dict: + return { + AUTOMATION_NAME: 'FlutterIntegration', + } diff --git a/appium/options/flutter_integration/flutter_element_wait_timeout_option.py b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py new file mode 100644 index 00000000..6f6b2ae9 --- /dev/null +++ b/appium/options/flutter_integration/flutter_element_wait_timeout_option.py @@ -0,0 +1,51 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from datetime import timedelta +from typing import Optional, Union + +from appium.options.common.supports_capabilities import SupportsCapabilities + +FLUTTER_ELEMENT_WAIT_TIMEOUT = 'flutterElementWaitTimeout' + + +class FlutterElementWaitTimeOutOption(SupportsCapabilities): + + @property + def flutter_element_wait_timeout(self) -> Optional[timedelta]: + """ + Maximum timeout to wait for element for Flutter integration test + + Returns: + Optional[timedelta]: The timeout value as a `timedelta` object if set, or `None` if the timeout is not defined. + """ + return self.get_capability(FLUTTER_ELEMENT_WAIT_TIMEOUT) + + @flutter_element_wait_timeout.setter + def flutter_element_wait_timeout(self, value: Union[timedelta, int]) -> None: + """ + Sets the maximum timeout to wait for a Flutter element in an integration test. + Default timeout is 5000ms + + Args: + value (Union[timedelta, int]): The timeout value, either as a `timedelta` object or an integer in milliseconds. + If provided as a `timedelta`, it will be converted to milliseconds. + """ + self.set_capability( + FLUTTER_ELEMENT_WAIT_TIMEOUT, + (int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value), + ) diff --git a/appium/options/flutter_integration/flutter_enable_mock_camera_option.py b/appium/options/flutter_integration/flutter_enable_mock_camera_option.py new file mode 100644 index 00000000..7b335b25 --- /dev/null +++ b/appium/options/flutter_integration/flutter_enable_mock_camera_option.py @@ -0,0 +1,46 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Optional + +from appium.options.common.supports_capabilities import SupportsCapabilities + +FLUTTER_ENABLE_MOCK_CAMERA = 'flutterEnableMockCamera' + + +class FlutterEnableMockCameraOption(SupportsCapabilities): + + @property + def flutter_enable_mock_camera(self) -> bool: + """ + Get state of the mock camera for Flutter integration test + + Returns: + bool: A boolean indicating whether the mock camera is enabled (True) or disabled (False). + """ + return self.get_capability(FLUTTER_ENABLE_MOCK_CAMERA) + + @flutter_enable_mock_camera.setter + def flutter_enable_mock_camera(self, value: bool) -> None: + """ + Setter method enable or disable the mock camera for Flutter integration test + Default state is `False` + + Args: + value (bool): A boolean value indicating whether to enable (True) or disable (False) the mock camera. + """ + self.set_capability(FLUTTER_ENABLE_MOCK_CAMERA, value) diff --git a/appium/options/flutter_integration/flutter_server_launch_timeout_option.py b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py new file mode 100644 index 00000000..8f8bea4e --- /dev/null +++ b/appium/options/flutter_integration/flutter_server_launch_timeout_option.py @@ -0,0 +1,52 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from datetime import timedelta +from typing import Optional, Union + +from appium.options.common.supports_capabilities import SupportsCapabilities + +FLUTTER_SERVER_LAUNCH_TIMEOUT = 'flutterServerLaunchTimeout' + + +class FlutterServerLaunchTimeOutOption(SupportsCapabilities): + + @property + def flutter_server_launch_timeout(self) -> Optional[timedelta]: + """ + Gets the current timeout for launching the Flutter server in a Flutter application. + + Returns: + Optional[timedelta]: The timeout value as a `timedelta` object if set, or `None` if the timeout is not defined. + + """ + return self.get_capability(FLUTTER_SERVER_LAUNCH_TIMEOUT) + + @flutter_server_launch_timeout.setter + def flutter_server_launch_timeout(self, value: Union[timedelta, int]) -> None: + """ + Sets the timeout for launching the Flutter server in Flutter application. + Default timeout is 5000ms + + Args: + value (Union[timedelta, int]): The timeout value, either as a `timedelta` object or an integer in milliseconds. + If provided as a `timedelta`, it will be converted to milliseconds. + """ + self.set_capability( + FLUTTER_SERVER_LAUNCH_TIMEOUT, + (int(value.total_seconds() * 1000) if isinstance(value, timedelta) else value), + ) diff --git a/appium/options/flutter_integration/flutter_system_port_option.py b/appium/options/flutter_integration/flutter_system_port_option.py new file mode 100644 index 00000000..2e049dd7 --- /dev/null +++ b/appium/options/flutter_integration/flutter_system_port_option.py @@ -0,0 +1,46 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Optional + +from appium.options.common.supports_capabilities import SupportsCapabilities + +FLUTTER_SYSTEM_PORT = 'flutterSystemPort' + + +class FlutterSystemPortOption(SupportsCapabilities): + + @property + def flutter_system_port(self) -> Optional[int]: + """ + Get flutter system port for Flutter integration tests. + + Returns: + int: returns the port number + """ + return self.get_capability(FLUTTER_SYSTEM_PORT) + + @flutter_system_port.setter + def flutter_system_port(self, value: int) -> None: + """ + Sets the system port for Flutter integration tests. + By default the first free port from 10000..11000 range is selected + + Args: + value (int): The port number to be used for the Flutter server. + """ + self.set_capability(FLUTTER_SYSTEM_PORT, value) diff --git a/appium/webdriver/common/appiumby.py b/appium/webdriver/common/appiumby.py index 7632ce35..b269bb0f 100644 --- a/appium/webdriver/common/appiumby.py +++ b/appium/webdriver/common/appiumby.py @@ -25,3 +25,10 @@ class AppiumBy(By): ACCESSIBILITY_ID = 'accessibility id' IMAGE = '-image' CUSTOM = '-custom' + + # For Flutter integration usage https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/tree/main + FLUTTER_INTEGRATION_SEMANTICS_LABEL = '-flutter semantics label' + FLUTTER_INTEGRATION_TYPE = '-flutter type' + FLUTTER_INTEGRATION_KEY = '-flutter key' + FLUTTER_INTEGRATION_TEXT = '-flutter text' + FLUTTER_INTEGRATION_TEXT_CONTAINING = '-flutter text containing' diff --git a/appium/webdriver/extensions/flutter_integration/flutter_commands.py b/appium/webdriver/extensions/flutter_integration/flutter_commands.py new file mode 100644 index 00000000..fa9dcaee --- /dev/null +++ b/appium/webdriver/extensions/flutter_integration/flutter_commands.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from typing import Any, Dict, Optional, Tuple, Union + +from appium.common.helper import encode_file_to_base64 +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection +from appium.webdriver.webdriver import WebDriver +from appium.webdriver.webelement import WebElement + + +class FlutterCommand: + + def __init__(self, driver: WebDriver) -> None: + self.driver = driver + + # wait commands + + def wait_for_visible( + self, + locator: Union[WebElement, FlutterFinder], + timeout: Optional[float] = None, + ) -> None: + """ + Waits for a element to become visible. + + Args: + locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. + timeout (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. + + Returns: + None: + """ + opts: Dict[str, Any] = self.__get_locator_options(locator) + if timeout is not None: + opts['timeout'] = timeout + + self.execute_flutter_command('waitForVisible', opts) + + def wait_for_invisible( + self, + locator: Union[WebElement, FlutterFinder], + timeout: Optional[float] = None, + ) -> None: + """ + Waits for a element to become invisible. + + Args: + locator (Union[WebElement, FlutterFinder]): The element to wait for; can be a WebElement or a FlutterFinder. + timeout (Optional[float]): Maximum wait time in seconds. Defaults to a predefined timeout if not specified. + + Returns: + None: + """ + opts: Dict[str, Any] = self.__get_locator_options(locator) + if timeout is not None: + opts['timeout'] = timeout + + self.execute_flutter_command('waitForAbsent', opts) + + # flutter action commands + + def perform_double_click(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: + """ + Performs a double-click on the given element, with an optional offset. + + Args: + element (WebElement): The element to double-click on. This parameter is required. + offset (Optional[Tuple[int, int]]): The x and y offsets from the element to click at. If not specified, the click is performed at the element's center. + + Returns: + None: + """ + opts: Dict[str, Union[WebElement, Dict[str, int]]] = {"origin": element} + if offset is not None: + opts['offset'] = {'x': offset[0], 'y': offset[1]} + self.execute_flutter_command('doubleClick', opts) + + def perform_long_press(self, element: WebElement, offset: Optional[Tuple[int, int]] = None) -> None: + """ + Performs a long press on the given element, with an optional offset. + + Args: + element (WebElement): The element to perform the long press on. This parameter is required. + offset (Optional[Tuple[int, int]]): The x and y offsets from the element to perform the long press at. If not specified, the long press is performed at the element's center. + + Returns: + None: + """ + opts: Dict[str, Union[WebElement, Dict[str, int]]] = {'origin': element} + if offset is not None: + opts['offset'] = {'x': offset[0], 'y': offset[1]} + self.execute_flutter_command('longPress', opts) + + def perform_drag_and_drop(self, source: WebElement, target: WebElement) -> None: + """ + Performs a drag-and-drop operation from a source element to a target element. + + Args: + source (WebElement): The element to drag from. + target (WebElement): The element to drop onto. + + Returns: + None: + """ + self.execute_flutter_command('dragAndDrop', {'source': source, 'target': target}) + + def scroll_till_visible( + self, + scroll_to: FlutterFinder, + scroll_direction: ScrollDirection = ScrollDirection.DOWN, + **opts: Any, + ) -> WebElement: + """ + Scrolls until the specified element becomes visible. + + Args: + scroll_to (FlutterFinder): The Flutter element to scroll to. + scroll_direction (ScrollDirection): The direction to scroll up or down. Defaults to `ScrollDirection.DOWN`. + + KeywordArgs: + scrollView (str): The view of the scroll. Default value is 'Scrollable' + delta (int): delta for the scroll. Default value is 64 + maxScrolls (int): Max times to scroll. Default value is 15 + settleBetweenScrollsTimeout (float): settle timeout in milliseconds. Default value is 5000 + dragDuration (float): time gap between each scroll in milliseconds. Default value is 100 + + Returns: + Webelement: scrolled element + """ + opts['finder'] = scroll_to.to_dict() + opts['scrollDirection'] = scroll_direction.value + return self.execute_flutter_command('scrollTillVisible', opts) + + def inject_mock_image(self, value: str) -> str: + """ + Injects a mock image to the device. The input can be a file path or a base64-encoded string. + + Args: + value (str): The file path of the image or a base64-encoded string. + + Returns: + str: Image ID of the injected image. + """ + if os.path.isfile(value): + base64_encoded_image = encode_file_to_base64(value) + else: + base64_encoded_image = value + return self.execute_flutter_command('injectImage', {'base64Image': base64_encoded_image}) + + def activate_injected_image(self, image_id: str) -> None: + """ + Activates an injected image with image ID. + + Args: + image_id (str): The ID of the injected image to activate. + + Returns: + None: + """ + self.execute_flutter_command('activateInjectedImage', {'imageId': image_id}) + + def execute_flutter_command(self, scriptName: str, params: dict) -> Any: + """ + Executes a Flutter command by sending a script and parameters to the flutter integration driver. + + Args: + scriptName (str): The name of the Flutter command to execute. + This will be prefixed with 'flutter:' when passed to the driver. + params (dict): A dictionary of parameters to be passed along with the Flutter command. + + Returns: + Any: The result of the command execution. The return value depends on the + specific Flutter command being executed. + """ + return self.driver.execute_script(f'flutter: {scriptName}', params) + + def __get_locator_options(self, locator: Union[WebElement, 'FlutterFinder']) -> Dict[str, dict]: + if isinstance(locator, WebElement): + return {'element': locator} + return {'locator': locator.to_dict()} diff --git a/appium/webdriver/extensions/flutter_integration/flutter_finder.py b/appium/webdriver/extensions/flutter_integration/flutter_finder.py new file mode 100644 index 00000000..5243ee94 --- /dev/null +++ b/appium/webdriver/extensions/flutter_integration/flutter_finder.py @@ -0,0 +1,53 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from typing import Tuple + +from appium.webdriver.common.appiumby import AppiumBy + + +class FlutterFinder: + + def __init__(self, using: str, value: str) -> None: + self.using = using + self.value = value + + @staticmethod + def by_key(value: str) -> 'FlutterFinder': + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_KEY, value) + + @staticmethod + def by_text(value: str) -> 'FlutterFinder': + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TEXT, value) + + @staticmethod + def by_semantics_label(value: str) -> 'FlutterFinder': + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, value) + + @staticmethod + def by_type(value: str) -> 'FlutterFinder': + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TYPE, value) + + @staticmethod + def by_text_containing(value: str) -> 'FlutterFinder': + return FlutterFinder(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, value) + + def to_dict(self) -> dict: + return {'using': self.using, 'value': self.value} + + def as_args(self) -> Tuple[str, str]: + return self.using, self.value diff --git a/appium/webdriver/extensions/flutter_integration/scroll_directions.py b/appium/webdriver/extensions/flutter_integration/scroll_directions.py new file mode 100644 index 00000000..7624b5b2 --- /dev/null +++ b/appium/webdriver/extensions/flutter_integration/scroll_directions.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class ScrollDirection(Enum): + UP = 'up' + DOWN = 'down' diff --git a/test/functional/flutter_integration/__init__.py b/test/functional/flutter_integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/functional/flutter_integration/commands_test.py b/test/functional/flutter_integration/commands_test.py new file mode 100644 index 00000000..010c0f8c --- /dev/null +++ b/test/functional/flutter_integration/commands_test.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from appium.webdriver.common.appiumby import AppiumBy +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection +from test.functional.flutter_integration.helper.test_helper import BaseTestCase + + +class TestFlutterCommands(BaseTestCase): + + def test_wait_command(self) -> None: + self.__open_screen('Lazy Loading') + + message_field_finder = FlutterFinder.by_key('message_field') + toggle_button_finder = FlutterFinder.by_key('toggle_button') + + message_field = self.driver.find_element(*message_field_finder.as_args()) + toggle_button = self.driver.find_element(*toggle_button_finder.as_args()) + assert message_field.is_displayed() == True + assert message_field.text == 'Hello world' + + toggle_button.click() + self.flutter_command.wait_for_invisible(message_field_finder) + assert len(self.driver.find_elements(*message_field_finder.as_args())) == 0 + + toggle_button.click() + self.flutter_command.wait_for_visible(message_field) + assert len(self.driver.find_elements(*message_field_finder.as_args())) == 1 + + def test_scroll_till_visible_command(self) -> None: + self.__open_screen('Vertical Swiping') + + java_text_finder = FlutterFinder.by_text('Java') + protractor_text_finder = FlutterFinder.by_text('Protractor') + + first_element = self.flutter_command.scroll_till_visible(java_text_finder) + assert first_element.get_attribute('displayed') == 'true' + + second_element = self.flutter_command.scroll_till_visible(protractor_text_finder) + assert second_element.get_attribute('displayed') == 'true' + assert first_element.get_attribute('displayed') == 'false' + + first_element = self.flutter_command.scroll_till_visible(java_text_finder, ScrollDirection.UP) + assert second_element.get_attribute('displayed') == 'false' + assert first_element.get_attribute('displayed') == 'true' + + def test_scroll_till_visible_with_scroll_params_command(self) -> None: + self.__open_screen('Vertical Swiping') + + scroll_params = { + 'scrollView': FlutterFinder.by_type('Scrollable').to_dict(), + 'delta': 30, + 'maxScrolls': 30, + 'settleBetweenScrollsTimeout': 5000, + 'dragDuration': 35, + } + first_element = self.flutter_command.scroll_till_visible( + FlutterFinder.by_text('Playwright'), scroll_direction=ScrollDirection.DOWN, **scroll_params + ) + assert first_element.get_attribute('displayed') == 'true' + + def test_double_click_command(self) -> None: + self.__open_screen('Double Tap') + + double_tap_button = self.driver.find_element( + AppiumBy.FLUTTER_INTEGRATION_KEY, 'double_tap_button' + ).find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Double Tap') + assert double_tap_button.text == 'Double Tap' + + self.flutter_command.perform_double_click(double_tap_button) + assert ( + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful').text + == 'Double Tap Successful' + ) + + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Ok').click() + self.flutter_command.perform_double_click(double_tap_button, (10, 2)) + assert ( + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Successful').text + == 'Double Tap Successful' + ) + + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Ok').click() + + def test_long_press_command(self) -> None: + self.__open_screen('Long Press') + + long_press_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'long_press_button') + self.flutter_command.perform_long_press(long_press_button) + + success_pop_up = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'It was a long press') + assert success_pop_up.text == 'It was a long press' + assert success_pop_up.is_displayed() == True + + def test_drag_and_drop_command(self) -> None: + self.__open_screen('Drag & Drop') + + drag_element = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'drag_me') + drop_element = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'drop_zone') + self.flutter_command.perform_drag_and_drop(drag_element, drop_element) + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'The box is dropped').is_displayed() == True + + def test_camera_mocking(self) -> None: + self.__open_screen('Image Picker') + + success_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png') + second_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'second_qr.png') + + image_id = self.flutter_command.inject_mock_image(success_qr_file_path) + self.flutter_command.inject_mock_image(second_qr_file_path) + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image').click() + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'PICK').click() + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'SecondInjectedImage').is_displayed() == True + + self.flutter_command.activate_injected_image(image_id) + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'capture_image').click() + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'PICK').click() + assert self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Success!').is_displayed() == True + + def __open_screen(self, screen_name: str) -> None: + self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Login').click() + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text(screen_name)) + element.click() diff --git a/test/functional/flutter_integration/file/second_qr.png b/test/functional/flutter_integration/file/second_qr.png new file mode 100644 index 00000000..355548c3 Binary files /dev/null and b/test/functional/flutter_integration/file/second_qr.png differ diff --git a/test/functional/flutter_integration/file/success_qr.png b/test/functional/flutter_integration/file/success_qr.png new file mode 100644 index 00000000..8896d86f Binary files /dev/null and b/test/functional/flutter_integration/file/success_qr.png differ diff --git a/test/functional/flutter_integration/finder_test.py b/test/functional/flutter_integration/finder_test.py new file mode 100644 index 00000000..c5faf262 --- /dev/null +++ b/test/functional/flutter_integration/finder_test.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from appium.webdriver.common.appiumby import AppiumBy +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder +from test.functional.flutter_integration.helper.test_helper import BaseTestCase + +LOGIN_BUTTON_FINDER = FlutterFinder.by_text("Login") + + +class TestFlutterFinders(BaseTestCase): + + def test_by_flutter_key(self) -> None: + user_name_field_finder = FlutterFinder.by_key('username_text_field') + user_name_field = self.driver.find_element(*user_name_field_finder.as_args()) + assert user_name_field.text == 'admin' + + user_name_field.clear() + user_name_field = self.driver.find_element(*user_name_field_finder.as_args()).send_keys('admin123') + assert user_name_field.text == 'admin123' + + def test_by_flutter_type(self) -> None: + login_button = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'ElevatedButton') + assert login_button.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Text').text == 'Login' + + def test_by_flutter_text(self) -> None: + login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) + assert login_button.text == 'Login' + + login_button.click() + slider = self.driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Slider') + assert len(slider) == 1 + + def test_by_flutter_text_containing(self) -> None: + login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) + login_button.click() + vertical_swipe_label = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Vertical') + assert vertical_swipe_label.text == 'Vertical Swiping' + + def test_by_flutter_semantics_label(self) -> None: + login_button = self.driver.find_element(*LOGIN_BUTTON_FINDER.as_args()) + login_button.click() + element = self.flutter_command.scroll_till_visible(FlutterFinder.by_text('Lazy Loading')) + element.click() + message_field = self.driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'message_field') + assert message_field.text == 'Hello world' diff --git a/test/functional/flutter_integration/helper/__init__.py b/test/functional/flutter_integration/helper/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/functional/flutter_integration/helper/desired_capabilities.py b/test/functional/flutter_integration/helper/desired_capabilities.py new file mode 100644 index 00000000..b0305341 --- /dev/null +++ b/test/functional/flutter_integration/helper/desired_capabilities.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from typing import Any, Dict + + +def get_desired_capabilities(platform_name: str) -> Dict[str, Any]: + desired_caps: Dict[str, Any] = {} + if platform_name == 'android': + desired_caps.update( + { + 'platformName': 'Android', + 'deviceName': 'Android Emulator', + 'newCommandTimeout': 120, + 'uiautomator2ServerInstallTimeout': 120000, + 'adbExecTimeout': 120000, + 'app': os.getenv('FLUTTER_ANDROID_APP'), + 'autoGrantPermissions': True, + } + ) + else: + desired_caps.update( + { + 'deviceName': os.getenv('IPHONE_MODEL'), + 'platformName': 'iOS', + 'platformVersion': os.getenv('IOS_VERSION'), + 'allowTouchIdEnroll': True, + 'wdaLaunchTimeout': 240000, + 'wdaLocalPort': 8100, + 'eventTimings': True, + 'app': os.getenv('FLUTTER_IOS_APP'), + } + ) + + return desired_caps diff --git a/test/functional/flutter_integration/helper/test_helper.py b/test/functional/flutter_integration/helper/test_helper.py new file mode 100644 index 00000000..1cc8e364 --- /dev/null +++ b/test/functional/flutter_integration/helper/test_helper.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import os + +from appium import webdriver +from appium.options.flutter_integration.base import FlutterOptions +from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand +from test.functional.test_helper import is_ci +from test.helpers.constants import SERVER_URL_BASE + +from . import desired_capabilities + + +class BaseTestCase(object): + + def setup_method(self) -> None: + platform_name = os.getenv('PLATFORM', 'android').lower() + + # set flutter options + flutterOptions = FlutterOptions() + flutterOptions.flutter_system_port = 9999 + flutterOptions.flutter_enable_mock_camera = True + flutterOptions.flutter_element_wait_timeout = 10000 + flutterOptions.flutter_server_launch_timeout = 120000 + + desired_caps = desired_capabilities.get_desired_capabilities(platform_name) + self.driver = webdriver.Remote(SERVER_URL_BASE, options=flutterOptions.load_capabilities(desired_caps)) + self.flutter_command = FlutterCommand(self.driver) + + def teardown_method(self) -> None: # type: ignore + if not hasattr(self, 'driver'): + return + self.driver.quit() diff --git a/test/unit/helper/test_helper.py b/test/unit/helper/test_helper.py index 7037757e..3061ef1f 100644 --- a/test/unit/helper/test_helper.py +++ b/test/unit/helper/test_helper.py @@ -152,6 +152,55 @@ def ios_w3c_driver_with_extensions(extensions) -> 'WebDriver': return driver +def flutter_w3c_driver() -> 'WebDriver': + """Return a W3C driver which is generated by a mock response for Flutter + + Returns: + `webdriver.webdriver.WebDriver`: An instance of WebDriver + """ + + response_body_json = json.dumps( + { + 'sessionId': '1234567890', + 'capabilities': { + 'platform': 'LINUX', + 'desired': { + 'platformName': 'Android', + 'autoGrantPermissions': True, + 'flutterSystemPort': 9999, + 'flutterElementWaitTimeout': 10000, + 'flutterEnableMockCamera': True, + 'flutterServerLaunchTimeout': 120000, + 'automationName': 'FlutterIntegration', + 'platformVersion': '7.1.1', + 'deviceName': 'Android Emulator', + 'app': '/test/apps/ApiDemos-debug.apk', + }, + 'platformName': 'Android', + 'automationName': 'FlutterIntegration', + 'platformVersion': '7.1.1', + 'deviceName': 'emulator-5554', + 'app': '/test/apps/ApiDemos-debug.apk', + 'deviceUDID': 'emulator-5554', + 'appPackage': 'io.appium.android.apis', + 'appWaitPackage': 'io.appium.android.apis', + }, + } + ) + + httpretty.register_uri(httpretty.POST, appium_command('/session'), body=response_body_json) + + desired_caps = { + 'platformName': 'Android', + 'deviceName': 'Android Emulator', + 'app': 'path/to/app', + 'automationName': 'FlutterIntegration', + } + + driver = webdriver.Remote(SERVER_URL_BASE, options=UiAutomator2Options().load_capabilities(desired_caps)) + return driver + + def get_httpretty_request_body(request: 'HTTPrettyRequestEmpty') -> Dict[str, Any]: """Returns utf-8 decoded request body""" return json.loads(request.body.decode('utf-8')) diff --git a/test/unit/webdriver/flutter_integration/file/success_qr.png b/test/unit/webdriver/flutter_integration/file/success_qr.png new file mode 100644 index 00000000..8896d86f Binary files /dev/null and b/test/unit/webdriver/flutter_integration/file/success_qr.png differ diff --git a/test/unit/webdriver/flutter_integration/flutter_actions_test.py b/test/unit/webdriver/flutter_integration/flutter_actions_test.py new file mode 100644 index 00000000..2a21b60c --- /dev/null +++ b/test/unit/webdriver/flutter_integration/flutter_actions_test.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +import httpretty + +from appium.common.helper import encode_file_to_base64 +from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder +from appium.webdriver.extensions.flutter_integration.scroll_directions import ScrollDirection +from appium.webdriver.webelement import WebElement as MobileWebElement +from test.unit.helper.test_helper import appium_command, flutter_w3c_driver, get_httpretty_request_body + + +class TestFlutterActions(object): + + @httpretty.activate + def test_double_click(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + element = MobileWebElement(driver, 'element_id') + flutter.perform_double_click(element, (10, 20)) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: doubleClick' + assert list(arguments['origin'].values())[0] == 'element_id' + assert arguments['offset'] == {'x': 10, 'y': 20} + + @httpretty.activate + def test_drag_and_drop(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + drag_element = MobileWebElement(driver, 'element_id1') + drop_element = MobileWebElement(driver, 'element_id2') + flutter.perform_drag_and_drop(drag_element, drop_element) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: dragAndDrop' + assert list(arguments['source'].values())[0] == 'element_id1' + assert list(arguments['target'].values())[0] == 'element_id2' + + @httpretty.activate + def test_scroll_till_visible(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + finder = FlutterFinder.by_key('message_field') + flutter.scroll_till_visible(finder) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + expected_arguments = { + 'finder': {'using': '-flutter key', 'value': 'message_field'}, + 'scrollDirection': 'down', + } + assert request_body['script'] == 'flutter: scrollTillVisible' + assert arguments == expected_arguments + + @httpretty.activate + def test_scroll_till_visible_with_kwargs(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + finder = FlutterFinder.by_key('message_field') + scroll_params = { + 'scrollView': FlutterFinder.by_type('Scrollable').to_dict(), + 'delta': 30, + 'maxScrolls': 30, + 'settleBetweenScrollsTimeout': 5000, + 'dragDuration': 35, + } + flutter.scroll_till_visible(finder, ScrollDirection.UP, **scroll_params) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: scrollTillVisible' + expected_arguments = { + 'finder': {'using': '-flutter key', 'value': 'message_field'}, + 'scrollView': {'using': '-flutter type', 'value': 'Scrollable'}, + 'scrollDirection': 'up', + 'dragDuration': 35, + 'settleBetweenScrollsTimeout': 5000, + 'maxScrolls': 30, + 'delta': 30, + } + assert arguments == expected_arguments + + @httpretty.activate + def test_inject_mock_image_with_file(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + success_qr_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'file', 'success_qr.png') + base64_encoded_image = encode_file_to_base64(success_qr_file_path) + flutter.inject_mock_image(success_qr_file_path) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: injectImage' + assert arguments == {'base64Image': base64_encoded_image} + + @httpretty.activate + def test_activate_injected_image(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + flutter.activate_injected_image('213476478') + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: activateInjectedImage' + assert arguments == {'imageId': '213476478'} diff --git a/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py b/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py new file mode 100644 index 00000000..2e3aa481 --- /dev/null +++ b/test/unit/webdriver/flutter_integration/flutter_integration_driver_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +import httpretty + +from appium import webdriver +from appium.options.flutter_integration.base import FlutterOptions +from test.helpers.constants import SERVER_URL_BASE + + +class TestFlutterIntegrationDriver: + + @httpretty.activate + def test_create_session(self): + + # Set flutter options + flutterOptions = FlutterOptions() + flutterOptions.flutter_system_port = 9999 + flutterOptions.flutter_enable_mock_camera = True + flutterOptions.flutter_element_wait_timeout = 10000 + flutterOptions.flutter_server_launch_timeout = 120000 + + httpretty.register_uri( + httpretty.POST, + f'{SERVER_URL_BASE}/session', + body='{ "value": {"sessionId": "session-id", "capabilities": {"deviceName": "Android Emulator"}} }', + ) + + desired_caps = { + 'platformName': 'Android', + 'deviceName': 'Android Emulator', + 'app': 'path/to/app', + } + driver = webdriver.Remote(SERVER_URL_BASE, options=flutterOptions.load_capabilities(desired_caps)) + + request_json = json.loads(httpretty.HTTPretty.latest_requests[0].body.decode('utf-8')) + assert request_json.get('capabilities') is not None + assert request_json['capabilities']['alwaysMatch'] == { + 'platformName': 'Android', + 'appium:deviceName': 'Android Emulator', + 'appium:app': 'path/to/app', + 'appium:automationName': 'FlutterIntegration', + 'appium:flutterSystemPort': 9999, + 'appium:flutterEnableMockCamera': True, + 'appium:flutterElementWaitTimeout': 10000, + 'appium:flutterServerLaunchTimeout': 120000, + } + assert request_json.get('desiredCapabilities') is None + assert driver.session_id == 'session-id' diff --git a/test/unit/webdriver/flutter_integration/flutter_search_context_test.py b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py new file mode 100644 index 00000000..e0d20bd0 --- /dev/null +++ b/test/unit/webdriver/flutter_integration/flutter_search_context_test.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import httpretty + +from appium.webdriver.common.appiumby import AppiumBy +from test.unit.helper.test_helper import appium_command, flutter_w3c_driver, get_httpretty_request_body + + +class TestFlutterSearchContext(object): + + @httpretty.activate + def test_find_element_by_flutter_key(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_KEY, 'Flutter UI Key') + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter key' + assert d['value'] == 'Flutter UI Key' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_key(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_KEY, 'Flutter UI Key') + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter key' + assert d['value'] == 'Flutter UI Key' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' + + @httpretty.activate + def test_find_element_by_flutter_text(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Flutter UI Text') + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter text' + assert d['value'] == 'Flutter UI Text' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_text(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TEXT, 'Flutter UI Text') + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter text' + assert d['value'] == 'Flutter UI Text' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' + + @httpretty.activate + def test_find_element_by_flutter_semantics_label(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label') + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter semantics label' + assert d['value'] == 'Flutter UI Semantics Label' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_semantics_label(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_SEMANTICS_LABEL, 'Flutter UI Semantics Label') + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter semantics label' + assert d['value'] == 'Flutter UI Semantics Label' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' + + @httpretty.activate + def test_find_element_by_flutter_type(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Flutter UI Type') + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter type' + assert d['value'] == 'Flutter UI Type' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_type(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements(AppiumBy.FLUTTER_INTEGRATION_TYPE, 'Flutter UI Type') + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter type' + assert d['value'] == 'Flutter UI Type' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' + + @httpretty.activate + def test_find_element_by_flutter_text_containing(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/element'), + body='{"value": {"element-6066-11e4-a52e-4f735466cecf": "element-id"}}', + ) + el = driver.find_element(AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, 'Flutter UI Partial Text') + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter text containing' + assert d['value'] == 'Flutter UI Partial Text' + assert el.id == 'element-id' + + @httpretty.activate + def test_find_elements_by_flutter_text_containing(self): + driver = flutter_w3c_driver() + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/elements'), + body='{"value": [{"element-6066-11e4-a52e-4f735466cecf": "child-element-id1"}, ' + '{"element-6066-11e4-a52e-4f735466cecf": "child-element-id2"}]}', + ) + els = driver.find_elements( + AppiumBy.FLUTTER_INTEGRATION_TEXT_CONTAINING, + 'Flutter UI Partial Text', + ) + + d = get_httpretty_request_body(httpretty.last_request()) + assert d['using'] == '-flutter text containing' + assert d['value'] == 'Flutter UI Partial Text' + assert els[0].id == 'child-element-id1' + assert els[1].id == 'child-element-id2' diff --git a/test/unit/webdriver/flutter_integration/flutter_waits_test.py b/test/unit/webdriver/flutter_integration/flutter_waits_test.py new file mode 100644 index 00000000..338952e2 --- /dev/null +++ b/test/unit/webdriver/flutter_integration/flutter_waits_test.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import httpretty + +from appium.webdriver.extensions.flutter_integration.flutter_commands import FlutterCommand +from appium.webdriver.extensions.flutter_integration.flutter_finder import FlutterFinder +from appium.webdriver.webelement import WebElement as MobileWebElement +from test.unit.helper.test_helper import appium_command, flutter_w3c_driver, get_httpretty_request_body + + +class TestFlutterWaits(object): + + @httpretty.activate + def test_wait_for_visible_with_finder(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + finder = FlutterFinder.by_key('message_field') + flutter.wait_for_visible(finder, 5) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: waitForVisible' + expected_arguments = { + 'locator': {'using': '-flutter key', 'value': 'message_field'}, + 'timeout': 5, + } + assert arguments == expected_arguments + + @httpretty.activate + def test_wait_for_visible_with_webelement(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + element = MobileWebElement(driver, 'element_id') + flutter.wait_for_visible(element, 5) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: waitForVisible' + assert list(arguments['element'].values())[0] == 'element_id' + assert arguments['timeout'] == 5 + + @httpretty.activate + def test_wait_for_invisible_with_finder(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + message_field_finder = FlutterFinder.by_key('message_field') + flutter.wait_for_invisible(message_field_finder, 5) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: waitForAbsent' + expected_arguments = { + 'locator': {'using': '-flutter key', 'value': 'message_field'}, + 'timeout': 5, + } + assert arguments == expected_arguments + + @httpretty.activate + def test_wait_for_invisible_with_webelement(self): + driver = flutter_w3c_driver() + flutter = FlutterCommand(driver) + + httpretty.register_uri( + httpretty.POST, + appium_command('/session/1234567890/execute/sync'), + ) + + element = MobileWebElement(driver, 'element_id') + flutter.wait_for_invisible(element, 5) + + request_body = get_httpretty_request_body(httpretty.last_request()) + arguments = request_body['args'][0] + assert request_body['script'] == 'flutter: waitForAbsent' + assert list(arguments['element'].values())[0] == 'element_id' + assert arguments['timeout'] == 5